🐛 Fix library updates reappear after file is reloaded (#9563)

* 🐛 Fix library updates reappear after file is reloaded

Summary
Migrate synced_at timestamps to a standalone file_library_sync table to ensure sync state is tracked for both direct and transitive libraries.

Problem
Transitive libraries (libraries imported by other libraries) are not stored as direct rows in file_library_rel. Because the system previously coupled synced_at directly to the file_library_rel schema, transitive libraries lacked a persistent location for their sync timestamps. This caused sync states to be lost or incorrectly reported for nested dependencies.

Changes
Schema Migration: Created file_library_sync and migrated existing synced_at values from file_library_rel.

Decoupling: Removed tight Foreign Key coupling to allow sync rows to exist independently of specific relationship records.

Persistent Writes: Added upsert-file-library-sync! helper. Updated all import, duplication, and RPC write paths (v1/v2/v3 importers, link-file-library) to ensure every write persists a sync row.

Unified Reads: Updated both direct and recursive/transitive library queries to fetch synced_at from the new table.

Testing: Added regression tests to verify that sync rows are correctly created/updated even when a transitive relation is absent in file_library_rel.

Impact
This fix ensures that the system accurately records and retrieves sync states for the entire library dependency tree, resolving the bug where nested libraries appeared out of sync.

*  MR review
This commit is contained in:
Pablo Alba 2026-05-13 11:29:05 +02:00 committed by GitHub
parent 63ff5c87c2
commit fffafdab93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 173 additions and 51 deletions

View File

@ -123,12 +123,13 @@
- Fix plugin parse-point returning plain map instead of Point record (by @FairyPigDev) [Github #9129](https://github.com/penpot/penpot/pull/9129)
- Fix `:heigth` typo in clipboard frame-same-size? (by @iot2edge) [Github #9250](https://github.com/penpot/penpot/pull/9250)
- Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090)
- Fix library updates reappear after being applied and the file is reloaded [Taiga #14040](https://tree.taiga.io/project/penpot/issue/14040)
## 2.15.0 (Unreleased)
### :sparkles: New features & Enhancements
- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174)
- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174)
- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909)
- Improve team name validation [Github #9176](https://github.com/penpot/penpot/pull/9176)
@ -166,7 +167,6 @@
- Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122)
- Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927)
## 2.14.3
### :sparkles: New features & Enhancements
@ -196,7 +196,6 @@
- Fix typo `:podition` in swap-shapes grid cell
- Fix multiple selection on shapes with token applied to stroke color
## 2.14.2
### :sparkles: New features & Enhancements

View File

@ -440,11 +440,28 @@
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids (db/create-array conn "uuid" ids)
sql (str "SELECT flr.* FROM file_library_rel AS flr "
" JOIN file AS l ON (flr.library_file_id = l.id) "
" WHERE flr.file_id = ANY(?) AND l.deleted_at IS NULL")]
sql (str "SELECT flr.*,"
" fls.synced_at"
" FROM file_library_rel AS flr"
" JOIN file AS l"
" ON flr.library_file_id = l.id"
" LEFT JOIN file_library_sync AS fls"
" ON fls.file_id = flr.file_id"
" AND fls.library_file_id = flr.library_file_id"
" WHERE flr.file_id = ANY(?)"
" AND l.deleted_at IS NULL;")]
(db/exec! conn [sql ids])))))
(def ^:private sql:upsert-file-library-sync
"INSERT INTO file_library_sync (file_id, library_file_id, synced_at)
VALUES (?::uuid, ?::uuid, ?::timestamptz)
ON CONFLICT (file_id, library_file_id)
DO UPDATE SET synced_at = EXCLUDED.synced_at;")
(defn upsert-file-library-sync!
[conn {:keys [file-id library-file-id synced-at]}]
(db/exec-one! conn [sql:upsert-file-library-sync file-id library-file-id synced-at]))
(def ^:private sql:get-libraries
"WITH RECURSIVE libs AS (
SELECT fl.id
@ -799,32 +816,41 @@
(def ^:private sql:get-file-libraries
"WITH RECURSIVE libs AS (
SELECT fl.*, flr.synced_at
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
WHERE flr.file_id = ?::uuid
UNION
SELECT fl.*, flr.synced_at
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
JOIN libs AS l ON (flr.file_id = l.id)
)
SELECT l.id,
l.features,
l.project_id,
p.team_id,
l.created_at,
l.modified_at,
l.deleted_at,
l.name,
l.revn,
l.vern,
l.synced_at,
l.is_shared,
l.version
FROM libs AS l
INNER JOIN project AS p ON (p.id = l.project_id)
WHERE l.deleted_at IS NULL;")
SELECT fl.*
FROM file AS fl
JOIN file_library_rel AS flr
ON flr.library_file_id = fl.id
WHERE flr.file_id = ?::uuid
UNION
SELECT fl.*
FROM file AS fl
JOIN file_library_rel AS flr
ON flr.library_file_id = fl.id
JOIN libs AS l
ON flr.file_id = l.id
)
SELECT l.id,
l.features,
l.project_id,
p.team_id,
l.created_at,
l.modified_at,
l.deleted_at,
l.name,
l.revn,
l.vern,
l.is_shared,
l.version,
fls.synced_at
FROM libs AS l
JOIN project AS p
ON p.id = l.project_id
LEFT JOIN file_library_sync AS fls
ON fls.file_id = ?::uuid
AND fls.library_file_id = l.id
WHERE l.deleted_at IS NULL;")
(defn get-file-libraries
[conn file-id]
@ -834,7 +860,7 @@
;; completly useless
(map #(assoc % :is-indirect false))
(map decode-row-features))
(db/exec! conn [sql:get-file-libraries file-id])))
(db/exec! conn [sql:get-file-libraries file-id file-id])))
(defn get-resolved-file-libraries
"Get all file libraries including itself. Returns an instance of

View File

@ -573,7 +573,6 @@
;; Insert all file relations
(doseq [{:keys [library-file-id] :as rel} rels]
(let [rel (-> rel
(assoc :synced-at timestamp)
(update :file-id bfc/lookup-index)
(update :library-file-id bfc/lookup-index))]
@ -583,7 +582,12 @@
:file-id (:file-id rel)
:lib-id (:library-file-id rel)
::l/sync? true)
(db/insert! conn :file-library-rel rel))
(let [rel-params (dissoc rel :synced-at)]
(db/insert! conn :file-library-rel rel-params)
(bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params)
:library-file-id (:library-file-id rel-params)
:synced-at (or (:synced-at rel)
timestamp)})))
(l/warn :hint "ignoring file library link"
:file-id (:file-id rel)

View File

@ -314,10 +314,10 @@
(doseq [rel (read-obj cfg :file-rels file-id)]
(let [rel (-> rel
(update :file-id bfc/lookup-index)
(update :library-file-id bfc/lookup-index)
(assoc :synced-at timestamp))]
(update :library-file-id bfc/lookup-index))]
(db/insert! conn :file-library-rel rel
::db/return-keys false)))
::db/return-keys false)
(bfc/upsert-file-library-sync! conn (assoc rel :synced-at timestamp))))
(doseq [media (read-seq cfg :file-media-object file-id)]
(let [media (-> media

View File

@ -824,10 +824,10 @@
:file-id (str file-id)
:lib-id (str libr-id)
::l/sync? true)
(db/insert! conn :file-library-rel
{:synced-at timestamp
:file-id file-id
:library-file-id libr-id})))))
(let [rel-params {:file-id file-id
:library-file-id libr-id}]
(db/insert! conn :file-library-rel rel-params)
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp)))))))
(defn- import-storage-objects
[{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}]

View File

@ -481,7 +481,10 @@
:fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}
{:name "0148-add-variant-name-team-font-variant"
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}])
:fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}
{:name "0149-mod-file-library-rel-synced-at"
:fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@ -0,0 +1,19 @@
CREATE TABLE file_library_sync (
file_id uuid NOT NULL,
library_file_id uuid NOT NULL,
synced_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (file_id, library_file_id)
);
INSERT INTO file_library_sync (file_id, library_file_id, synced_at)
SELECT file_id, library_file_id, synced_at
FROM file_library_rel;
-- DEPRECATED: the `synced_at` column on `file_library_rel` is deprecated
-- and will be removed in a future migration. It's kept temporarily
-- for backward compatibility while data is migrated to `file_library_sync`.
COMMENT ON COLUMN file_library_rel.synced_at IS
'DEPRECATED: will be removed in a future migration; kept temporarily for backward compatibility';

View File

@ -1064,7 +1064,10 @@
(defn link-file-to-library
[conn {:keys [file-id library-id] :as params}]
(db/exec-one! conn [sql:link-file-to-library file-id library-id]))
(db/exec-one! conn [sql:link-file-to-library file-id library-id])
(bfc/upsert-file-library-sync! conn {:file-id file-id
:library-file-id library-id
:synced-at (ct/now)}))
(def ^:private
schema:link-file-to-library
@ -1118,11 +1121,9 @@
(defn update-sync
[conn {:keys [file-id library-id] :as params}]
(db/update! conn :file-library-rel
{:synced-at (ct/now)}
{:file-id file-id
:library-file-id library-id}
{::db/return-keys true}))
(bfc/upsert-file-library-sync! conn {:file-id file-id
:library-file-id library-id
:synced-at (ct/now)}))
(def ^:private schema:update-file-library-sync-status
[:map {:title "update-file-library-sync-status"}

View File

@ -72,10 +72,14 @@
(doseq [params (sequence (comp
(map #(bfc/remap-id % :file-id))
(map #(bfc/remap-id % :library-file-id))
(map #(assoc % :synced-at timestamp))
(map #(assoc % :created-at timestamp)))
flibs)]
(db/insert! conn :file-library-rel params ::db/return-keys false))
(let [rel-params (dissoc params :synced-at)]
(db/insert! conn :file-library-rel rel-params ::db/return-keys false)
(bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params)
:library-file-id (:library-file-id rel-params)
:synced-at (or (:synced-at params)
timestamp)})))
(doseq [params (sequence (comp
(map #(bfc/remap-id % :id))

View File

@ -918,6 +918,72 @@
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))
(t/deftest link-file-to-library-creates-sync-row
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
:profile-id (:id profile)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile)
:profile-id (:id profile)})
data {::th/type :link-file-to-library
::rpc/profile-id (:id profile)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
rel (th/db-get :file-library-rel {:file-id (:id file2)
:library-file-id (:id file1)})
sync (th/db-get :file-library-sync {:file-id (:id file2)
:library-file-id (:id file1)})]
(t/is (nil? (:error out)))
(t/is (some? rel))
(t/is (some? sync))
(t/is (some? (:synced-at sync)))))
(t/deftest update-file-library-sync-status-updates-sync-row
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
:profile-id (:id profile)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile)
:profile-id (:id profile)})
_ (th/link-file-to-library* {:file-id (:id file2)
:library-id (:id file1)})
before (th/db-get :file-library-sync {:file-id (:id file2)
:library-file-id (:id file1)})
_ (th/sleep 10)
data {::th/type :update-file-library-sync-status
::rpc/profile-id (:id profile)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
after (th/db-get :file-library-sync {:file-id (:id file2)
:library-file-id (:id file1)})]
(t/is (nil? (:error out)))
(t/is (some? before))
(t/is (some? after))
(t/is (pos? (compare (:synced-at after) (:synced-at before))))))
(t/deftest update-file-library-sync-status-without-link-creates-sync-row
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
:profile-id (:id profile)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:default-project-id profile)
:profile-id (:id profile)})
data {::th/type :update-file-library-sync-status
::rpc/profile-id (:id profile)
:file-id (:id file2)
:library-id (:id file1)}
out (th/command! data)
sync (th/db-get :file-library-sync {:file-id (:id file2)
:library-file-id (:id file1)})]
(t/is (nil? (:error out)))
(t/is (some? sync))
(t/is (some? (:synced-at sync)))))
(t/deftest deletion
(let [profile1 (th/create-profile* 1)