diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index 941e0d2103..cf1ae7bc84 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -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. diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 69324844c8..4dc4cee0c3 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -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] diff --git a/backend/src/app/migrations/sql/0157-add-file-branch-change-table.sql b/backend/src/app/migrations/sql/0157-add-file-branch-change-table.sql new file mode 100644 index 0000000000..5fecae9319 --- /dev/null +++ b/backend/src/app/migrations/sql/0157-add-file-branch-change-table.sql @@ -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; diff --git a/backend/src/app/rpc/commands/files_branch.clj b/backend/src/app/rpc/commands/files_branch.clj index b8f03bee38..7de3ff2c8a 100644 --- a/backend/src/app/rpc/commands/files_branch.clj +++ b/backend/src/app/rpc/commands/files_branch.clj @@ -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 diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 9d996c6dd4..438e3dc38d 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -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]}] diff --git a/backend/test/backend_tests/rpc_file_branch_oplog_test.clj b/backend/test/backend_tests/rpc_file_branch_oplog_test.clj new file mode 100644 index 0000000000..1182b08f3b --- /dev/null +++ b/backend/test/backend_tests/rpc_file_branch_oplog_test.clj @@ -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)))))))