mirror of
https://github.com/penpot/penpot.git
synced 2026-05-14 20:43:55 +00:00
🐛 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:
parent
63ff5c87c2
commit
fffafdab93
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user