mirror of
https://github.com/penpot/penpot.git
synced 2026-10-30 06:20:32 +00:00
🐛 Register the 0157 file-branch-change migration
This commit is contained in:
parent
2a5a235063
commit
e8957a24e4
@ -11,6 +11,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.files.changes :as cpc]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.files.migrations :as fmg]
|
||||
[app.common.files.validate :as fval]
|
||||
@ -190,6 +191,7 @@
|
||||
f.deleted_at,
|
||||
f.name,
|
||||
f.is_shared,
|
||||
f.is_branch,
|
||||
f.has_media_trimmed,
|
||||
f.revn,
|
||||
f.data AS legacy_data,
|
||||
@ -250,6 +252,92 @@
|
||||
(update-file! cfg file)
|
||||
(fmigr/resolve-applied-migrations cfg file))))))
|
||||
|
||||
;; --- Branch files: derived data --------------------------------------
|
||||
;;
|
||||
;; A branch file stores no data of its own: its `:data` is derived by
|
||||
;; replaying the branch's op log (`file_branch_change`) over the
|
||||
;; merge-base snapshot pinned in `file_branch`. The derive runs inside
|
||||
;; `get-file*`, so every read path (workspace bundle, viewer, diff,
|
||||
;; merge) sees the same state the editor produced.
|
||||
;;
|
||||
;; The snapshot query mirrors `fsnap/get-snapshot` (which cannot be
|
||||
;; reused here: `app.features.file-snapshots` requires THIS namespace).
|
||||
|
||||
(def ^:private sql:get-branch-base-snapshot
|
||||
"SELECT c.id,
|
||||
c.label,
|
||||
c.created_at,
|
||||
c.updated_at AS modified_at,
|
||||
c.deleted_at,
|
||||
c.profile_id,
|
||||
c.created_by,
|
||||
c.locked_by,
|
||||
c.revn,
|
||||
c.features,
|
||||
c.migrations,
|
||||
c.version,
|
||||
c.file_id,
|
||||
c.data AS legacy_data,
|
||||
fd.data AS data,
|
||||
coalesce(fd.backend, 'legacy-db') AS backend,
|
||||
fd.metadata AS metadata
|
||||
FROM file_change AS c
|
||||
LEFT JOIN file_data AS fd ON (fd.file_id = c.file_id
|
||||
AND fd.id = c.id
|
||||
AND fd.type = 'snapshot')
|
||||
WHERE c.label IS NOT NULL
|
||||
AND c.file_id = ?
|
||||
AND c.id = ?
|
||||
AND CASE WHEN c.created_by = 'user'
|
||||
THEN (c.deleted_at IS NULL)
|
||||
WHEN c.created_by = 'system'
|
||||
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
|
||||
END")
|
||||
|
||||
(def ^:private sql:get-branch-changes
|
||||
"SELECT changes
|
||||
FROM file_branch_change
|
||||
WHERE file_id = ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY revn ASC")
|
||||
|
||||
(defn- branch-file-data
|
||||
"Derive the `:data` of a branch file: the merge-base snapshot plus
|
||||
every op appended to the branch's log, replayed in revn order.
|
||||
|
||||
Returns the file with `:data` decoded (plain maps); callers that asked
|
||||
for `:decode? false` get it re-encoded as bytes so the update-file
|
||||
pipeline (which always blob-decodes) is unaffected."
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id] :as file} decode?]
|
||||
(let [branch (db/get* conn :file-branch {:branch-file-id id})
|
||||
_ (when (nil? branch)
|
||||
(ex/raise :type :not-found
|
||||
:code :branch-metadata-missing
|
||||
:hint "branch metadata not found for branch file"
|
||||
:file-id id))
|
||||
base (db/get-with-sql cfg
|
||||
[sql:get-branch-base-snapshot
|
||||
(:source-file-id branch)
|
||||
(:base-snapshot-id branch)
|
||||
(ct/now)]
|
||||
{::db/remove-deleted false})
|
||||
_ (when (nil? base)
|
||||
(ex/raise :type :not-found
|
||||
:code :base-snapshot-missing
|
||||
:hint "the branch merge-base snapshot cannot be resolved"
|
||||
:file-id id))
|
||||
base (-> base
|
||||
(fdata/resolve-file-data cfg)
|
||||
(fdata/decode-file-data cfg))
|
||||
rows (db/exec! conn [sql:get-branch-changes id])
|
||||
data (reduce (fn [data {:keys [changes]}]
|
||||
(cpc/process-changes data (blob/decode changes)))
|
||||
(:data base)
|
||||
rows)
|
||||
data (if decode? data (blob/encode data))]
|
||||
(assoc file :data data)))
|
||||
|
||||
|
||||
(defn- get-file*
|
||||
[{:keys [::db/conn] :as cfg} id
|
||||
{:keys [migrate?
|
||||
@ -309,11 +397,19 @@
|
||||
|
||||
(if file
|
||||
(if load-data?
|
||||
(let [file
|
||||
(->> file
|
||||
(fmigr/resolve-applied-migrations cfg)
|
||||
(fdata/resolve-file-data cfg))
|
||||
(let [branch? (:is-branch file)
|
||||
|
||||
file
|
||||
(if branch?
|
||||
(branch-file-data cfg file decode?)
|
||||
(->> file
|
||||
(fmigr/resolve-applied-migrations cfg)
|
||||
(fdata/resolve-file-data cfg)))
|
||||
|
||||
;; a branch file derives its data from the pinned base
|
||||
;; snapshot and the op log; data migrations apply in
|
||||
;; memory only (read-only) so the derived state stays
|
||||
;; current without persisting a data payload
|
||||
will-migrate?
|
||||
(and migrate? (fmg/need-migration? file))]
|
||||
|
||||
@ -323,7 +419,8 @@
|
||||
(fdata/realize cfg)
|
||||
|
||||
will-migrate?
|
||||
(migrate-file cfg options))
|
||||
(migrate-file cfg (cond-> options
|
||||
branch? (assoc :read-only? true))))
|
||||
|
||||
file))
|
||||
file)
|
||||
@ -799,6 +896,26 @@
|
||||
(fdata/upsert! cfg file-data-params)
|
||||
nil))
|
||||
|
||||
(defn update-file-row!
|
||||
"Update only the `file` row of an existing file, without touching
|
||||
`file_data`. Used for branch file saves: a branch stores no data, so
|
||||
the persist step must skip `encode-file` (its pointer-map transform
|
||||
would write `file_data` rows for the branch) and the data upsert."
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id] :as file} & {:as opts}]
|
||||
(if (::reset-migrations? opts false)
|
||||
(fmigr/reset-migrations! conn file)
|
||||
(fmigr/upsert-migrations! conn file))
|
||||
|
||||
(let [file-params (-> file
|
||||
(d/update-when :features into-array)
|
||||
(file->params)
|
||||
(dissoc :id))]
|
||||
(db/update! conn :file file-params
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
nil))
|
||||
|
||||
|
||||
(defn save-file!
|
||||
"Applies all the final validations and perist the file, binfile
|
||||
specific, should not be used outside of binfile domain.
|
||||
|
||||
@ -523,7 +523,10 @@
|
||||
:fn (mg/resource "app/migrations/sql/0155-add-file-branch-table.sql")}
|
||||
|
||||
{:name "0156-add-file-pull-request-tables"
|
||||
:fn (mg/resource "app/migrations/sql/0156-add-file-pull-request-tables.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0156-add-file-pull-request-tables.sql")}
|
||||
|
||||
{:name "0157-add-file-branch-change-table"
|
||||
:fn (mg/resource "app/migrations/sql/0157-add-file-branch-change-table.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
-- The durable op log of a branch: one row per branch save (the change
|
||||
-- vector the editor produced, blob-encoded) plus the ops a
|
||||
-- update-branch-from-main integration produced. The branch file's
|
||||
-- :data is DERIVED by replaying these rows over the merge-base snapshot
|
||||
-- (`file_branch.base_snapshot_id`), so the branch file itself stores no
|
||||
-- data.
|
||||
--
|
||||
-- `file_change` rows remain the transient source for the lagged-changes
|
||||
-- machinery; this table is what keeps the branch reconstructible after
|
||||
-- those rows are GC'd (team deletion delay). Rows live until the branch
|
||||
-- is deleted or the op log is squashed (update-branch-from-main).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_branch_change (
|
||||
id uuid PRIMARY KEY,
|
||||
|
||||
branch_id uuid NOT NULL REFERENCES file_branch(id) ON DELETE CASCADE DEFERRABLE,
|
||||
file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE,
|
||||
|
||||
-- the BRANCH file's revn the save produced; replay order is by revn
|
||||
revn bigint NOT NULL,
|
||||
|
||||
changes bytea NOT NULL,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
deleted_at timestamptz NULL,
|
||||
|
||||
UNIQUE (file_id, revn)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS file_branch_change__branch_id__idx
|
||||
ON file_branch_change(branch_id) WHERE deleted_at IS NULL;
|
||||
@ -23,6 +23,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@ -35,7 +36,6 @@
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.files-update :as fupd]
|
||||
[app.rpc.commands.management :as mgmt]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.quotes :as quotes]
|
||||
@ -43,6 +43,7 @@
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.services :as sv]
|
||||
[app.worker :as wrk]
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn- check-branching-enabled!
|
||||
@ -219,10 +220,11 @@
|
||||
[:description {:optional true} [:string {:max 4000}]]])
|
||||
|
||||
(sv/defmethod ::create-file-branch
|
||||
"Create a branch from a file. The branch is a full, isolated copy of
|
||||
the source file (created through the same pipeline as duplicate-file,
|
||||
which preserves the internal `:data` ids) plus a system snapshot of
|
||||
main captured as the merge base."
|
||||
"Create a branch from a file. The branch file stores NO data payload:
|
||||
its `:data` is derived on every read by replaying the branch's op log
|
||||
over the merge base — a system snapshot of main taken at creation.
|
||||
The branch file row still exists (permissions, libraries, msgbus
|
||||
topics and media ownership key on it), but nothing is duplicated."
|
||||
{::doc/added "2.16"
|
||||
::webhooks/event? true
|
||||
::sm/params schema:create-file-branch
|
||||
@ -253,18 +255,17 @@
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
|
||||
|
||||
;; Serialize against concurrent update-file on main (same
|
||||
;; advisory lock) so the base snapshot and the duplicated
|
||||
;; copy are taken from the SAME state of the file — a
|
||||
;; concurrent save between them would make the base differ
|
||||
;; from the branch's initial content and manufacture
|
||||
;; phantom diffs later.
|
||||
;; Serialize against concurrent update-file on main
|
||||
;; (same advisory lock) so the base snapshot and the
|
||||
;; branch's initial state (the empty op log over that
|
||||
;; base) are taken from the SAME state of main.
|
||||
(db/xact-lock! conn file-id)
|
||||
|
||||
;; 1. Materialize the merge base: a system snapshot of
|
||||
;; main, kept long-lived so it is not pruned by the
|
||||
;; snapshot GC while the branch is open (it is released
|
||||
;; when the branch is merged/deleted or its base moves).
|
||||
;; snapshot GC while the branch is open (it is
|
||||
;; released when the branch is deleted or its base
|
||||
;; moves).
|
||||
(let [file (bfc/get-file cfg file-id :realize? true)
|
||||
base (fsnap/create! cfg file
|
||||
{:label (str "branch-base/" name)
|
||||
@ -272,39 +273,70 @@
|
||||
:created-by "system"
|
||||
:deleted-at (ct/in-future {:days 3650})})
|
||||
|
||||
;; 2. Duplicate main into a new branch file. The
|
||||
;; duplicate pipeline remaps the file/media/library
|
||||
;; ids but preserves the internal :data ids, which
|
||||
;; is the precondition for an id-based merge.
|
||||
branch-id (uuid/next)
|
||||
branch (binding [bfc/*state* (volatile! {:index {file-id branch-id}})]
|
||||
(mgmt/duplicate-file
|
||||
(assoc cfg ::bfc/timestamp (ct/now))
|
||||
{:profile-id profile-id
|
||||
:file-id file-id
|
||||
:name name
|
||||
:reset-shared-flag true}))
|
||||
ts (ct/now)
|
||||
branch-id (uuid/next)
|
||||
|
||||
meta-id (uuid/next)]
|
||||
;; 2. Create the branch file: an empty file row
|
||||
;; carrying main's project, features and data
|
||||
;; version. No data, no media, no library copy:
|
||||
;; the read path derives the state.
|
||||
branch-file
|
||||
(-> (ctf/make-file {:id branch-id
|
||||
:name name
|
||||
:project-id (:project-id file)}
|
||||
{:create-page false})
|
||||
(assoc :features (set/difference (:features file)
|
||||
#{"fdata/objects-map" "fdata/pointer-map"}))
|
||||
(assoc :version (:version file)))
|
||||
|
||||
;; 3. Mark the new file row as a branch so it is hidden
|
||||
;; from the project/team file listings.
|
||||
meta-id (uuid/next)]
|
||||
|
||||
(bfc/insert-file! cfg branch-file {::db/return-keys false})
|
||||
|
||||
;; 3. Mark the new file row as a branch so it is
|
||||
;; hidden from the project/team file listings.
|
||||
(db/update! conn :file
|
||||
{:is-branch true}
|
||||
{:id (:id branch)}
|
||||
{:id branch-id}
|
||||
{::db/return-keys false})
|
||||
|
||||
;; 4. Persist the branch metadata. `base-revn` tracks
|
||||
;; MAIN's revision counter and `base-branch-revn` the
|
||||
;; BRANCH file's one (they coincide at creation but
|
||||
;; drift apart: they are independent counters).
|
||||
;; 4. Grant the creator ownership (same grant
|
||||
;; duplicate-file used to make).
|
||||
(when (uuid? profile-id)
|
||||
(db/insert! conn :file-profile-rel
|
||||
{:file-id branch-id
|
||||
:profile-id profile-id
|
||||
:is-owner true
|
||||
:is-admin true
|
||||
:can-edit true}
|
||||
{::db/return-keys false}))
|
||||
|
||||
;; 5. The branch resolves libraries exactly like
|
||||
;; main (component instances from shared libraries
|
||||
;; must keep resolving on the derived state).
|
||||
(doseq [rel (bfc/get-files-rels cfg #{file-id})]
|
||||
(let [rel-params (-> rel
|
||||
(assoc :file-id branch-id)
|
||||
(assoc :created-at ts)
|
||||
(dissoc :synced-at))]
|
||||
(db/insert! conn :file-library-rel rel-params ::db/return-keys false)
|
||||
(bfc/upsert-file-library-sync! conn
|
||||
{:file-id branch-id
|
||||
:library-file-id (:library-file-id rel)
|
||||
:synced-at (or (:synced-at rel) ts)})))
|
||||
|
||||
;; 6. Persist the branch metadata. `base-revn`
|
||||
;; tracks MAIN's revision counter and
|
||||
;; `base-branch-revn` the BRANCH file's one (they
|
||||
;; coincide at creation but drift apart: they are
|
||||
;; independent counters).
|
||||
(db/insert! conn :file-branch
|
||||
{:id meta-id
|
||||
:branch-file-id (:id branch)
|
||||
:branch-file-id branch-id
|
||||
:source-file-id file-id
|
||||
:base-snapshot-id (:id base)
|
||||
:base-revn (:revn file)
|
||||
:base-branch-revn (:revn file)
|
||||
:base-branch-revn (:revn branch-file)
|
||||
:created-by profile-id
|
||||
:name name
|
||||
:description description
|
||||
@ -312,7 +344,7 @@
|
||||
{::db/return-keys false})
|
||||
|
||||
{:id meta-id
|
||||
:branch-file-id (:id branch)
|
||||
:branch-file-id branch-id
|
||||
:source-file-id file-id
|
||||
:base-revn (:revn file)
|
||||
:name name
|
||||
@ -584,10 +616,15 @@
|
||||
|
||||
finish-branch!
|
||||
(fn [ts]
|
||||
;; mark merged, release the pinned base snapshot and —
|
||||
;; unless the user chose to keep it — delete the branch
|
||||
;; in this same transaction (no client-driven second
|
||||
;; call, no window where a crash leaves a stale copy)
|
||||
;; mark merged. The pinned base snapshot stays for a
|
||||
;; KEPT branch (its state is derived from that
|
||||
;; snapshot and the op log, so releasing the pin
|
||||
;; would leave it unreadable once the snapshot GC
|
||||
;; runs); delete-branch! releases it on the delete
|
||||
;; path. Unless the user chose to keep it the branch
|
||||
;; and its file are deleted in this same transaction
|
||||
;; (no client-driven second call, no window where a
|
||||
;; crash leaves a stale copy).
|
||||
(db/update! conn :file-branch
|
||||
{:status "merged"
|
||||
:merged-at ts
|
||||
@ -603,8 +640,7 @@
|
||||
{:profile-id profile-id
|
||||
:status "merged"
|
||||
:deleted-at (ct/in-future delay)})
|
||||
(if keep-branch
|
||||
(release-base-snapshot! cfg branch (ct/in-future delay))
|
||||
(when-not keep-branch
|
||||
(delete-branch! cfg branch {:profile-id profile-id
|
||||
:session-id session-id}))))]
|
||||
(cond
|
||||
@ -694,6 +730,38 @@
|
||||
;; branch) | {attr -> side} (per-attr)
|
||||
[:resolutions {:optional true} [:map-of :any [:or :keyword [:map-of :keyword :keyword]]]]])
|
||||
|
||||
(defn- persist-branch-update!
|
||||
"Persist the result of an update-from-main integration on a branch
|
||||
file: update the `file` row and the project modified-at without any
|
||||
data payload, and REPLACE the branch's op log with the squashed
|
||||
branch-only changes. The repositioned merge base already carries
|
||||
everything main contributed, so replaying the squash over it
|
||||
reproduces the updated branch exactly, and the log never accumulates
|
||||
main-side ops (which would otherwise be double-applied on read)."
|
||||
[{:keys [::db/conn] :as cfg} file ts branch-id changes]
|
||||
(let [file (-> file
|
||||
(dissoc ::snapshot)
|
||||
(assoc :modified-at ts)
|
||||
(assoc :has-media-trimmed false))]
|
||||
(db/update! conn :project
|
||||
{:modified-at ts}
|
||||
{:id (:project-id file)}
|
||||
{::db/return-keys false})
|
||||
(db/delete! conn :file-branch-change {:branch-id branch-id})
|
||||
(when (seq changes)
|
||||
(db/insert! conn :file-branch-change
|
||||
{:id (uuid/next)
|
||||
:branch-id branch-id
|
||||
:file-id (:id file)
|
||||
:revn (:revn file)
|
||||
:changes (blob/encode (vec changes))
|
||||
:created-at ts
|
||||
:updated-at ts}
|
||||
{::db/return-keys false}))
|
||||
(bfc/update-file-row! cfg file)
|
||||
nil))
|
||||
|
||||
|
||||
(sv/defmethod ::update-branch-from-main
|
||||
"Bring the changes main received since the merge base into the branch
|
||||
(the reverse direction of a merge). Returns `{:status :conflicts}` when
|
||||
@ -816,6 +884,10 @@
|
||||
|
||||
(empty? changes)
|
||||
(let [ts (ct/now)]
|
||||
;; the branch is in sync with main: the repositioned
|
||||
;; base already represents the branch, so the op log
|
||||
;; is emptied (old ops were built against the old base)
|
||||
(db/delete! conn :file-branch-change {:branch-id branch-id})
|
||||
(reposition-base! ts (:revn branch-file))
|
||||
{:status :updated :revn (:revn branch-file)})
|
||||
|
||||
@ -860,7 +932,23 @@
|
||||
:changes (blob/encode (vec changes))}
|
||||
{::db/return-keys false})
|
||||
|
||||
(fupd/persist-file! (assoc cfg ::fupd/timestamp ts) updated)
|
||||
;; SQUASH: the new base (main's current state)
|
||||
;; already carries everything main contributed,
|
||||
;; so the op log is replaced with the net
|
||||
;; branch-only changes computed against the new
|
||||
;; base. Refuse if that net cannot be translated
|
||||
;; into replayable ops; nothing has been
|
||||
;; persisted yet, so the transaction rollback
|
||||
;; undoes the snapshot and media copies above.
|
||||
(let [{:keys [squashed]
|
||||
unsupported :unsupported}
|
||||
(bm/compute-changes main-data main-data (:data updated) {})]
|
||||
(when (seq unsupported)
|
||||
(ex/raise :type :validation
|
||||
:code :unsupported-update-squash
|
||||
:hint "the update produces branch-only changes that cannot be replayed"
|
||||
:kinds (vec unsupported)))
|
||||
(persist-branch-update! cfg updated ts branch-id squashed))
|
||||
(reposition-base! ts (:revn updated))
|
||||
|
||||
(mbus/pub! msgbus
|
||||
|
||||
@ -51,6 +51,7 @@
|
||||
(declare update-file!)
|
||||
(declare update-file-data!)
|
||||
(declare persist-file!)
|
||||
(declare ^:private persist-branch-file!)
|
||||
(declare get-file)
|
||||
|
||||
;; --- SCHEMA
|
||||
@ -273,7 +274,9 @@
|
||||
:changes (blob/encode changes)}
|
||||
{::db/return-keys false})
|
||||
|
||||
(persist-file! cfg file)
|
||||
(if (:is-branch file)
|
||||
(persist-branch-file! cfg file changes)
|
||||
(persist-file! cfg file))
|
||||
|
||||
(when (contains? cf/flags :redis-cache)
|
||||
(invalidate-caches! cfg file))
|
||||
@ -318,6 +321,50 @@
|
||||
|
||||
(bfc/update-file! cfg file)))
|
||||
|
||||
(defn- persist-branch-file!
|
||||
"Persist a branch file save. A branch stores no data payload: the
|
||||
change vector is appended to the branch's op log (`file_branch_change`)
|
||||
and the `file` row (revn, version, features, modified-at) plus the
|
||||
project modified-at are updated as usual, without any `file_data`
|
||||
write. The transient `file_change` xlog row (inserted by the caller)
|
||||
keeps the lagged-changes machinery working unchanged."
|
||||
[{:keys [::db/conn ::timestamp] :as cfg} file changes]
|
||||
(let [modified-at (or timestamp (ct/now))
|
||||
|
||||
file
|
||||
(-> file
|
||||
(dissoc ::snapshot)
|
||||
(assoc :modified-at modified-at)
|
||||
(assoc :has-media-trimmed false))
|
||||
|
||||
branch
|
||||
(db/get* conn :file-branch {:branch-file-id (:id file)})]
|
||||
|
||||
(when (nil? branch)
|
||||
(ex/raise :type :not-found
|
||||
:code :branch-metadata-missing
|
||||
:hint "branch metadata not found for branch file"
|
||||
:file-id (:id file)))
|
||||
|
||||
(db/update! conn :project
|
||||
{:modified-at modified-at}
|
||||
{:id (:project-id file)}
|
||||
{::db/return-keys false})
|
||||
|
||||
(db/insert! conn :file-branch-change
|
||||
{:id (uuid/next)
|
||||
:branch-id (:id branch)
|
||||
:file-id (:id file)
|
||||
:revn (:revn file)
|
||||
:changes (blob/encode changes)
|
||||
:created-at modified-at
|
||||
:updated-at modified-at}
|
||||
{::db/return-keys false})
|
||||
|
||||
(bfc/update-file-row! cfg file)
|
||||
nil))
|
||||
|
||||
|
||||
(defn- invalidate-caches!
|
||||
[cfg {:keys [id] :as file}]
|
||||
(rds/run! cfg (fn [{:keys [::rds/conn]}]
|
||||
|
||||
207
backend/test/backend_tests/rpc_file_branch_oplog_test.clj
Normal file
207
backend/test/backend_tests/rpc_file_branch_oplog_test.clj
Normal file
@ -0,0 +1,207 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.rpc-file-branch-oplog-test
|
||||
"The op-log storage model: a branch file stores no data payload. Its
|
||||
`:data` is derived on read by replaying `file_branch_change` over the
|
||||
pinned merge-base snapshot, and branch saves append ops instead of
|
||||
persisting file data. These tests assert the storage and derivation
|
||||
invariants on top of the behaviour covered by
|
||||
`backend-tests.rpc-file-branch-test`."
|
||||
(:require
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.util.blob :as blob]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(defn- apply-change* [profile file-id change]
|
||||
(let [f (th/db-get :file {:id file-id})]
|
||||
(th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id file-id
|
||||
:session-id (uuid/random)
|
||||
:revn (:revn f)
|
||||
:vern (:vern f)
|
||||
:features cfeat/supported-features
|
||||
:changes [change]})))
|
||||
|
||||
(defn- create-branch*
|
||||
[profile file-id name]
|
||||
(:result (th/command! {::th/type :create-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id file-id
|
||||
:name name})))
|
||||
|
||||
(defn- oplog-rows [branch-file-id]
|
||||
(th/db-query :file-branch-change {:file-id branch-file-id}))
|
||||
|
||||
(defn- stored-data-rows [file-id]
|
||||
(th/db-query :file-data {:file-id file-id}))
|
||||
|
||||
(t/deftest branch-stores-no-data-and-derives-state
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
create (create-branch* profile (:id file) "oplog-empty")
|
||||
branch-file-id (:branch-file-id create)]
|
||||
|
||||
(t/testing "the branch file stores no real data"
|
||||
(let [rows (stored-data-rows branch-file-id)]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (empty? (-> rows first :data blob/decode :pages)))))
|
||||
|
||||
(t/testing "the op log starts empty"
|
||||
(t/is (empty? (oplog-rows branch-file-id))))
|
||||
|
||||
(t/testing "the derived state equals main"
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [mf (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)})]
|
||||
(t/is (= (-> mf :result :data :pages-index)
|
||||
(-> out :result :data :pages-index)))))))))
|
||||
|
||||
(t/deftest branch-save-appends-ops-and-never-persists-data
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
create (create-branch* profile (:id file) "oplog-save")
|
||||
branch-file-id (:branch-file-id create)
|
||||
color-id (uuid/random)]
|
||||
|
||||
(apply-change* profile branch-file-id
|
||||
{:type :add-color
|
||||
:color {:id color-id :name "C" :color "#112233" :opacity 1}})
|
||||
|
||||
(t/testing "one op log row per save"
|
||||
(let [rows (oplog-rows branch-file-id)]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (= 1 (:revn (first rows))))
|
||||
(t/is (= [{:type :add-color
|
||||
:color {:id color-id :name "C" :color "#112233" :opacity 1}}]
|
||||
(blob/decode (:changes (first rows)))))))
|
||||
|
||||
(t/testing "the stored data payload never grew"
|
||||
(let [rows (stored-data-rows branch-file-id)]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (empty? (-> rows first :data blob/decode :pages)))))
|
||||
|
||||
(t/testing "the derived state carries the branch edit"
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (contains? (-> out :result :data :colors) color-id)))))))
|
||||
|
||||
(t/deftest update-from-main-squashes-the-op-log
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
create (create-branch* profile (:id file) "oplog-update")
|
||||
branch-file-id (:branch-file-id create)
|
||||
branch-id (:id create)
|
||||
main-color (uuid/random)
|
||||
branch-color (uuid/random)]
|
||||
|
||||
;; main gains a color after the fork
|
||||
(apply-change* profile (:id file)
|
||||
{:type :add-color
|
||||
:color {:id main-color :name "Main" :color "#00ff00" :opacity 1}})
|
||||
|
||||
;; the branch gains its own color (so the squash is non-empty)
|
||||
(apply-change* profile branch-file-id
|
||||
{:type :add-color
|
||||
:color {:id branch-color :name "Branch" :color "#ff0000" :opacity 1}})
|
||||
|
||||
(let [out (th/command! {::th/type :update-branch-from-main
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :updated (-> out :result :status))))
|
||||
|
||||
(t/testing "the op log is replaced by the branch-only net"
|
||||
(let [rows (oplog-rows branch-file-id)
|
||||
ops (mapv (comp blob/decode :changes) rows)]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (some #(and (= :add-color (:type %))
|
||||
(= branch-color (get-in % [:color :id])))
|
||||
ops))
|
||||
(t/is (not-any? #(and (= :add-color (:type %))
|
||||
(= main-color (get-in % [:color :id])))
|
||||
ops))))
|
||||
|
||||
(t/testing "the derived state has both colors, main's through the base"
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [colors (-> out :result :data :colors)]
|
||||
(t/is (contains? colors main-color))
|
||||
(t/is (contains? colors branch-color)))))
|
||||
|
||||
(t/testing "the merge base moved to main's revn"
|
||||
(let [[row] (th/db-query :file-branch {:id branch-id})
|
||||
mf (th/db-get :file {:id (:id file)})]
|
||||
(t/is (= (:revn mf) (:base-revn row))))))))
|
||||
|
||||
(t/deftest merge-with-keep-branch-leaves-the-branch-readable
|
||||
(with-redefs [cf/flags (conj cf/flags :branching)]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
proj-id (:default-project-id profile)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id proj-id
|
||||
:is-shared false})
|
||||
create (create-branch* profile (:id file) "oplog-keep")
|
||||
branch-file-id (:branch-file-id create)
|
||||
branch-id (:id create)
|
||||
color-id (uuid/random)]
|
||||
|
||||
(apply-change* profile branch-file-id
|
||||
{:type :add-color
|
||||
:color {:id color-id :name "C" :color "#112233" :opacity 1}})
|
||||
|
||||
(let [out (th/command! {::th/type :merge-file-branch
|
||||
::rpc/profile-id (:id profile)
|
||||
:branch-id branch-id
|
||||
:keep-branch true})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= :merged (-> out :result :status))))
|
||||
|
||||
(t/testing "the kept branch still derives its state"
|
||||
(let [out (th/command! {::th/type :get-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id branch-file-id})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (contains? (-> out :result :data :colors) color-id))))
|
||||
|
||||
(t/testing "the base pin survives while the branch is kept"
|
||||
(let [rows (->> (th/db-query :file-change {:file-id (:id file)})
|
||||
(filter #(some-> (:label %) (str/starts-with? "branch-base/"))))]
|
||||
(t/is (seq rows))
|
||||
(t/is (every? (fn [{:keys [deleted-at]}]
|
||||
(or (nil? deleted-at)
|
||||
(ct/is-after? deleted-at (ct/in-future {:days 400}))))
|
||||
rows)))))))
|
||||
Loading…
x
Reference in New Issue
Block a user