🐛 Register the 0157 file-branch-change migration

This commit is contained in:
Álvaro Tejero Cantero 2026-08-13 00:43:49 +02:00
parent 2a5a235063
commit e8957a24e4
No known key found for this signature in database
6 changed files with 544 additions and 50 deletions

View File

@ -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.

View File

@ -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]

View File

@ -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;

View File

@ -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

View File

@ -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]}]

View 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)))))))