24115 Commits

Author SHA1 Message Date
Álvaro Tejero Cantero
fe64834d1d
📚 Call the ids a comparison runs in an id space, not a frame
The branch comments called the set of ids a document's references are
written in (main's file id and media rows, or the branch's) a frame.
Penpot's code uses frame for a board, through `:frame-id`, so a reader
could not tell which sense a comment meant. The comments, the
docstrings of `get-base-data`, `branch-base-data`, and `remap-changes`,
and one test name now say id space. Every board-sense use of frame is
unchanged.

AI-assisted-by: mixed models
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
325f963685
💄 Say that Try interactions plays the published branch
The create pull request note said Try interactions plays the branch
"as it is at this moment", which reads as the live branch. The player
serves the pull request's pinned review snapshot, so an edit made
after the request shows there only once newer changes are published.
The note now says so.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
c7673fee31
📚 Drop the local docket ids from the branch comments
Four comments cited docket entry ids that resolve only on the machine
the set lives on, and the reviewer this branch is written for cannot
follow them. Each citation stood beside the number or the rule it
records, so the sentences stand without it.

AI-assisted-by: mixed models
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
4bb0d79f84
💄 Satisfy the stylesheet linter in the workspace palette
The clearance fallback loses its unit on a zero length, and the comment
about the banner's published room takes the blank line the rule asks for
above it. Both come from the palette lift, and neither changes what the
bar renders.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
8e62644741
🐛 Fix the open defects of the branching tech preview
Merge engine: a same-parent reorder, an addition under a parent main
deleted, and a branch deleting a container main edited each lost work
silently. Each now follows a policy read from
backend/resources/app/branch-merge-policies.edn, re-read when the file
changes, with the defaults: merge the reorder, and raise a conflict on
the deleted parent or container. Added siblings keep the branch's order.
A :mov-page index counts the target's full page list, a cleared page
background reaches main, a per-attribute resolution can remove an
attribute, and a token set reorder survives a rename on the other side.
Conflict payloads name :main and :branch by document in both
directions. Reorder detection costs nothing measurable on a page of
20,201 objects.

Data versions: each op-log row records the data version it was written
at, the branch replays its log in segments and migrates the document
at each boundary, and the comparison base is migrated like main and the
branch, so an upgrade no longer shows as false conflicts.

Writers: a binfile import, an admin repair, file GC and a snapshot
restore refuse a branch file, whose payload nothing reads, and the
branch base pin cannot be renamed into a user snapshot. A branch save
runs the ordinary media fix-up and logs the rewritten references. An
update from main deletes the branch's copy of media main deleted and
squashes no phantom media changes.

Commands and UI: the pull-request commands publish their messages after
the commit, the listing takes one connection per comparison and shows a
warning instead of "in sync" when a comparison fails, materialize takes
:branch-file-id, and a dashboard fetch that never answers ends in an
error state instead of a spinner, with a refetch on reconnect.

backend/dev/branch_merge_cases.clj builds one example file per merge
policy case for demonstrating the alternatives.

Two models wrote this change, claude-opus-5-5 and mimo-v2.6-pro, and
the work switched between them, so the trailer reads mixed models.

AI-assisted-by: mixed models
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
f6dc4bed2d
📚 Replace the branching guide page with a placeholder
The page described a feature that changes daily behind the `:branching`
product flag, and every change cost two descriptions: the page and the
design notes the review reads. The page now names the featureset it
will cover, branches, pull requests, reviews and merges, and promises
its description at the point the feature is proposed for merging into
develop, written against the code that the proposal ships.

The name moves with it, because the page covers version control for
files and not one of its mechanisms.

AI-assisted-by: mimo-v2.6-pro
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
0e51e5b3b8
🐛 Keep the conflict refusal's error page and fix the guide
The backend answers a :conflict refusal (:file-modified, :file-locked)
with HTTP 409 since the previous commit. On the frontend that type had
no ptk/handle-error method, so the version-restore flow would have
fallen to the generic toast instead of the error page the old 500
reached. errors.cljs now maps :conflict to the same exceptional-state
handler.

The technical guide's audit paragraph said rename, archive and delete
report nothing; they report a duration and an outcome. The merge step
list now records the re-validation after the repair and its refusal,
and the update section names the same refusal.

AI-assisted-by: mimo-v2.6-pro, claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
ac3abfaf38
💄 Tell reviewers they open the branch itself, not a read-only copy
The create pull request dialog promised reviewers "a read-only sandbox
with the branch state at this moment". A review opens the live branch
file as an ordinary editable workspace, so a reviewer's edit changes
the branch and marks the pull request with "New changes on branch". The
pinned snapshot taken when the request is created backs only Try
interactions and that marker.

The note now says so: reviewers open the branch itself, any edit they
make changes it, and Try interactions plays the state of this moment.
Only en.po carries the key.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
863c7cab1b
🐛 Fix the defects the review of the branch commands found
The source notes on files_branch.clj listed 32 review findings. This
commit fixes the 21 that are defects of the code; each fix carries a
regression test that failed before it and passes after.

Concurrency: merge, update from main, rename, archive and delete now
re-read the branch row under the advisory lock and work off that row,
so a concurrent merge or update can no longer leave a ten-year base
pin behind or release a superseded base twice. Delete and archive take
the branch file lock, create and reopen of a pull request refuse a
branch that a concurrent merge closed, and create checks the branch
quota under the lock. Merge, update and delete publish their msgbus
messages only after the transaction commits.

Merge, update and materialize re-validate after the repair: merge and
update refuse a result that stays invalid, materialize logs it. The
change log row carries the repair changes, and the three paths drop
the cached file summary under :redis-cache.

Contracts: a :conflict refusal answers 409 instead of 500, resolution
values must be :main or :branch, and update from main accepts the
expected-main-revn token the frontend now sends from a loaded diff.

Smaller fixes: create refuses a blank name, writes no file_data row,
and a rename reaches the branch file's own name. Deleting a kept
merged branch keeps its merged status. get-branch-diff reports the
creation time of the current base snapshot. Rename, archive and delete
report their duration and outcome. An image used only by a library
colour is copied into the target file. Dead fallbacks and dead
deleted-at checks are gone.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
ca07aa6e8a
📚 Bring comments and docstrings in line with the op-log engine
Comments written for the duplicate-file prototype still described a
branch as a copy of its file, a merge as clean-only, token diffing as
missing, a review as a read-only snapshot, and file_change rows as
living for the team deletion delay. Each now states what the code at
this revision does: a branch is a file row derived from a pinned base
plus its op log, every change kind except :page-attrs is translated,
merges apply resolutions, a review opens the live editable branch, an
update squashes the log and repositions the base, and file_change rows
expire after one hour. The audited docstring names the props it sets,
the step 6 comment says each revn counter starts at its own side, and
handle-file-merged names both topics it arrives on.

Comment, docstring, and SQL-comment lines only. clj-kondo reports no
errors on the touched Clojure files. Translations are untouched.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
16b7e64fbb
📚 Make the technical guide describe the op-log engine it ships with
The guide still opened by calling a branch an isolated copy of a file,
called the merge base a snapshot frozen at creation and the op log
append-only, and gave the creation transaction a reason for deferring
constraints that does not hold. It now says a branch copies no data,
that an update from main repositions the base and squashes or empties
the log, and that every row referencing the new file is inserted after
it.

The same pass corrects five more statements: base_branch_revn holds the
branch file's own revision; a branch kept after a merge keeps its pin;
op log rows survive a logical deletion and go with the cascade when the
file row is hard-deleted; a resolution may be a per-attribute map; and
the listing now runs through `audited`, while a refusal raised as an
error writes no audit event. The RPC table gains get-branching-limits,
the test lists name all five test namespaces, and the UI and event
lists name the limits, merge, and info dialogs.

AI-assisted-by: claude-opus-5-5
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
5cb615ea88
🐛 Stop an update from main discarding a diverged branch's own work
Data loss, reproduced twice rather than inferred. A branch stores no
data of its own, so its whole work lives in `file_branch_change`, and
`update-branch-from-main` had a fast path that deleted every one of
those rows whenever the update found nothing of main's to apply. The
comment there assumed the branch was in sync with main, which holds only
when the branch has no changes of its own, and `branch-diverged?` was
already computed two lines above and consulted only inside
`reposition-base!`.

A branch with one fill edit of `#bb0000` and an untouched main, updated
with no resolutions, returned `:updated` and came back `#000000` with an
empty log. The user asked to catch up with a main that had not moved and
lost their edit. Through the modal the same defect made the resolution
that says "keep mine" the one that discards it.

The fast path is now guarded by `(not branch-diverged?)`, so a diverged
branch falls through to the squash that re-derives its net against the
new base. The predicate is the right one: it compares the branch file's
own revision counter against the revision the base was last positioned
at, and a log row and a revision bump are one event, because
`persist-branch-file!` is the log's only appender. The comment at the
guard states the invariant, which is that the log may be emptied only
when what it encodes already sits in the base.

One correction to how the fast path was described while this was being
chased: `(empty? changes)` does not mean the branch has nothing of its
own. In the update direction the change set is MAIN's contribution, so a
branch that is behind main in content already took the squash path and
still does. The fast path is reached when main has nothing to contribute,
which is what the third new test pins.

The three sibling paths that also empty or replace a log were each
judged and are sound, because each one writes the state elsewhere inside
the same transaction before emptying.

Three regression tests, one per reproduction plus the case the fast path
exists for, asserting through the derived document rather than through
log row counts, because a legitimate squash rewrites the log while
preserving the state.

Verified: the whole backend suite, 923 tests and 5,550 assertions with 0
failures. The two live demo branches were checked read-only and still
hold their one op-log row each, so the hand-test fixture was never
damaged.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
a033a8e1e3
🐛 Compare in main's frame, and stop decoding the merge base twice
Two defects with one cause, which is that a comparison and a write want
different identifier frames and the code used one frame for both.

Bringing main into a branch compared in the branch's frame. It remapped
main and the base into that frame, which puts the branch's inherited
content where it cannot agree with the base: everything the branch
inherited still names main, so every reference the remap rewrote read as
a difference. On a demo branch that was 364 of 368 reported conflicts,
and none of them was an edit anybody made. The comparison now runs in
main's frame, exactly as the compare view and the listing summary
already do, and the changes it produces are translated into the branch's
frame before they are applied and logged. That translation is the new
`branch_merge.cljc::remap-changes`, which is a separate function rather
than a call to `remap-refs` for a reason its docstring gives: `relink-refs`
finds a reference where the attribute and the value travel together, and
a change vector carries the identifier alone. The write map is renamed to
`apply-map`, so no later reader can reach for it as the comparison map.

The compare also decoded its merge base twice. The derive resolves and
decodes the pinned snapshot in order to fold the op log over it, and the
caller then decoded the same bytes again for the comparison's third
version, which is about 406 ms on the design-system file and is paid by
every uncached branch summary in a listing as well. The derive now
offers the pre-fold value it already holds, behind an explicit opt-in, so
an ordinary read gains neither a key nor a cost, and a branch read that
forgets the flag raises rather than comparing against nothing. The
invariant a comment now states is that the comparison's base is the
pre-fold value, which is sound because the fold is pure: `process-changes`
threads a value, every method builds persistently, and `PointerMap`'s
`assoc` and `without` return new objects, so the pre-fold base shares
structure with the folded result and costs no extra memory for the pages
the log did not touch.

The new test asserts both halves, because either alone passes a wrong
implementation. A branch carrying only inherited content, updated from a
main that changed elsewhere, reports no conflicts and leaves an empty op
log. Main and the branch repainting the same shape still reports exactly
one conflict, naming that shape.

Verified: the whole backend suite, 919 tests and 5,525 assertions with 0
failures.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
b9ff74164e
🐛 Give the exit door a button, name what the counters count, and unswap the update resolver
Four defects the first by-hand pass found, and one of them was a lie the
copy told.

The exit door has a button. The limits dialog and three refusal messages
told a user that any branch can become an ordinary file, and nothing
under `frontend/src` called the command that does it. The kebab now
carries the action between Archive and Delete for an open branch, behind
a confirmation whose copy says what is kept and what is lost: the
content stays, and the file stops being a branch, so it can no longer be
compared with main or merged into main.

The counters say what they count. Both spans carry a title naming the
direction and the unit, which is entity-level changes across shapes,
pages, components, colours, typographies, media, and tokens, and stating
that saves are not counted. That reading is what made a jump from 7 to
420 look like a regression when the number was right.

The update resolver showed each side's values under the other side's
heading, and the cause was not a swapped label. The conflict payload is
role-keyed rather than document-keyed: `compute-merge*` runs the update
direction with the branch as `theirs`, and `three-way-entities` writes
`:main` for `theirs`, so an update hands the branch under the key named
`:main`. One binding re-keys each conflict's two sides, and the values
inside `:changed-attrs` with them, in update mode only. The headings,
the chips, and the resolution keywords then all speak documents, the
bulk buttons select the side they name, and the merge mode is unchanged
by construction rather than by inspection. The modal's fallback diff
fetch now also runs in the mode's own direction, so its key space
matches.

The two bars keep off each other. The banner publishes the room it takes
from the bottom edge as one custom property, measured from its live box,
and the palette lifts itself by that value. So the offset follows the
collapsed and the expanded state, and no literal is repeated in two
stylesheets.

Verified in the running frontend rather than by inspection: both
namespaces reload with no warnings, the re-keying is an involution on a
synthetic update conflict with base, id, reason, and label intact, the
real row component renders the new menu for open, archived, and merged
rows, and the confirm's accept path reaches the backend command with the
branch file id.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
81a605f11d
✨ Report what the listing and the branch open cost, and bound the concurrency
Three of the preview's paths reported no duration, and two of them were
the slowest a user meets. A trial can be asked what a merge cost and
could not be asked what the panel cost.

The listing now passes through `audited` with two counters beside the
duration, being how many open branches it compared and how many of those
the summary cache answered, because a listing's cost is entirely
explained by that ratio. The vector result was the one risk and it holds:
a Clojure vector carries metadata, and nothing between the handler and
the audit step rebuilds the value.

A branch open now reports the derive's own time, its op-log depth, and
its batch count, from `branch-file-data` itself. Depth is what predicts
the cost, so the record carries it. An ordinary read never enters that
function, so it gains no line, no prop, and no timer.

A branch save gains the log line the other five print, and its timer
moved rather than gaining a caveat. It now opens before `get-file`, so
the number covers the read and, for a branch, the derive inside it, which
is what compare, merge, update and materialize have always covered. The
six figures are comparable for the first time.

The concurrency limits were decoration. Thirteen ids were declared across
the branch and pull-request commands and `climit.edn` configured none, so
`climit/wrap` logged a warning at every start and ran each handler
unwrapped, leaving the root chain's forty global permits as the only
bound on an operation that holds three realized documents. Every declared
id now has an entry, the two readers that realize whole documents gained
a declaration, and each number is justified in a comment against
`create-file-snapshot` as its anchor and marked provisional until a
peak-heap measurement replaces it.

The test helper drives the private save path directly, so it opens the
timer the RPC method would have opened.

Verified: 73 tests, 694 assertions, 0 failures across the three branch
suites and `rpc-file-test`.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
8a3330f781
🐛 Compare a nil-valued attribute and an absent one as equal
A branch copy re-points its local references through the duplication pipeline
and leaves keys like :fill-color-ref-file and :typography-ref-file present with
nil where the source omitted them. Comparing the two forms as different made an
untouched entity look edited on one side, which becomes a modify-modify
conflict as soon as the other side moves the same shape.

The comparison now drops nil-valued keys deeply, for all three sides, in
three-way-entities and so for every pass that goes through it. The new engine
test failed before the change on both orderings and passes after; the three
branch suites report 30 tests, 245 assertions, 0 failures.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
6259f2b375
🐛 Carry the command's conflicts into the resolution modal
The modal rebuilt its conflict list from a diff fetched separately, which is
computed in another id frame than the one the update command resolves against,
so an update could open a resolver showing 0 of 0 with an Apply that could
never enable. The conflicts a command returns are authoritative, because they
are what its own resolved? matches against: the modal now prefers them, keeps
them in the store, and the bulk actions resolve that same set.

Verified in a browser against preview-demo-loud without applying: 0 of 368
resolved with Apply disabled, then 368 of 368 with Apply enabled after Use
main for all. Main stayed at revn 28453 and the branch row did not move.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
aad3719721
📚 Name the audited set, not every mutation on a branch
The sentence opened with every mutation and then listed three that are the
exception, which a reader has to reconcile. It now opens with the set that
does attach the three keys: the mutations through `audited` and a save routed
to a branch's op log.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
c68833d209
📚 The audit props cover the mutations, not every command
The guide claimed the three keys for every branch operation. The three metadata
mutations and the three reads carry the generic RPC event alone, which records
who called what and no duration, so the section now says which is which.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
f0d6852394
📚 Say which command attaches which audit props
The guide claimed the three audit keys for every branch operation through
`audited`, which was true of creation, compare, merge, update-from-main and
materialise. A save routed to a branch's op log attaches them on the
`update-file` event instead, because that is the props map the ordinary save
already replaces.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
003ff8e771
🐛 Audit a branch save and a materialise like every other command
The preview's observability criterion is stated as every branch operation, and
two of them sat outside it. A save routed to a branch's op log carried only the
ordinary save props, and materialise carried none, so the two operations that
matter most to an enterprise trial were the two with no duration in the record.

Both attach `branch-operation`, `branch-outcome` and `branch-duration-ms` now:
`save-branch`/`saved` on the save path, `materialize`/`materialized` on the exit
door, including its idempotent no-op. The props travel in `::audit/replace-props`
on the save path because that is the key the ordinary save already replaces, so
the ordinary save's own props are untouched.

The test that asserts the audit props now covers a save and a materialise
alongside creation and merge. The three branch namespaces are 40 tests and 338
assertions, green.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
e9833fdb55
💄 Clip the limits dialog to its panel and scroll the sections 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
d4b0944589
💄 Scroll the limits dialog inside its container, group its digits 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
7df1f9c876
💄 Bound the limits dialog height and group its numbers 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
05d123aa62
🐛 Render the limits list through a component, not a hiccup helper
A helper returning [:li ...] hands React a raw CLJS vector, because rumext
compiles hiccup at macro-expansion time; React then renders the leading
keyword as a child and the whole workspace tree falls into the error
boundary (React #31). Found by opening the dialog in a browser.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
a6e7972297
✨ Publish the preview's limits in the branch panel
Every refusal the branching commands can emit now has a line a user can
read: the three size gates name the limit they hit with the number the
backend sent, the scope refusals (no branch of a branch, a released merge
base, a change kind an update cannot replay, a branch whose state moved)
each get their own message, and a Branching limits dialog reachable from
the panel footer states them before anybody meets one. The three numbers
come from ::get-branching-limits, so the copy keeps no second copy of a
limit it could drift from.
2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
264f1e930c
✨ Publish the size limits so the UI can state them before a refusal 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
fcaf1945fe
📚 Name the three readers that share the summary cache 2026-10-06 10:36:55 +02:00
Álvaro Tejero Cantero
74174deb0d
⚡ Share one branch summary cache across all three readers
The pull-request listing and the file-open info query each recomputed the
same [ahead behind conflicts] the branches listing had already cached, so
a file with ten open pull requests paid the whole cold cost the branch
panel no longer pays. All three now go through cached-diff-counts, keyed
on the same (base-snapshot-id, source-revn, branch-revn) tuple, and none
of them realizes main when every row it needs is cached.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
c78e6f6eea
📚 Describe the size gates and the per-operation audit record 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
5d2dc63cf7
🐛 Audit every outcome of a merge and an update, not only the applied one
A no-op merge, a conflict refusal and an unsupported-kind refusal are
outcomes the first enterprise trial will ask about, and only the applied
path was carrying its duration.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
b09638957d
✨ Refuse instead of timing out, and record what each operation cost
Three gates, each a measured cost divided into a 30 s budget and halved:
50,000 shapes (a merge on 21,169 shapes costs about 6 s), 500 pages (a
whole-file comparison of 48 pages costs 402 ms), and 100,000 op-log
changes (one value-derived squash already emits about 19,000). Each
refusal is a :restriction naming the limit, the actual value and what to
do instead, and none of them truncates anything.

The listing never refuses: it renders the branch panel, and its cost is
bounded by the summary cache instead.

Every branch operation also attaches its duration and outcome to its own
audit event, because duration is the number the first enterprise trial
will be asked about and the generic RPC event does not carry it.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
8b714497b0
📚 Describe bounding the branch side, and where it does not apply 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
6491570a31
♻️ Make both compute-merge arities land on one implementation
A 4-arity that delegates through the var counts as two calls to any test
that redefines it, which made a call-counting assertion measure dispatch
instead of comparisons.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
d7241c6f9a
✨ Prune the branch side of a diff to what its op log touched
The op log IS the delta between a branch and its merge base, so folding
validate/extract-affected-ids over it yields the exact set of pages the
branch can differ in. compute-merge takes :only-pages and bounds every
page pass to it, the presence pass included, which is where a whole-file
comparison spends its time.

The forward pass is bounded; the reverse one reports main's changes and
stays whole-file. The bound is dropped whenever the log holds a change
the reducer cannot scope to a page or a component (a deletion, page
order, a colour, a token), because for those the affected set is
incomplete and a bounded diff would hide work.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
7e623c4526
📚 Describe materialization, the preview's exit door 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
0027f49347
📎 Assert the materialised payload as a main file_data row 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
04c60331b6
📎 Assert materialization against the ordinary storage path 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
bdcb979211
✨ Let any branch become an ordinary file
::materialize-file-branch persists a branch's derived state as the
file's own data payload, drops the op log, releases the pinned base
snapshot, closes any pull request over it and clears the branch marker,
so every read and write path takes the ordinary route from then on.
That is the preview's exit door: the flag can go off and the engine can
be replaced without stranding anybody with an unreadable file.

It keys on the branch file rather than the branch metadata, so calling
it twice is a reported no-op rather than an error.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
b6232edfd5
⚡ Keep the summary cache for a working day, not ten minutes
The fetch the cache exists for is a file open, and expireAfterAccess of
ten minutes threw the entry away between two sessions on the same file:
a listing measured at 5 ms was 26 s again fifteen minutes later. An
entry is a uuid and five numbers, so the window costs nothing.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
48f2f4c55f
🐛 Restore the app.config require the cache test needs 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
cf50457e89
🐛 Restore the uuid require the cache test needs 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
93cec8be7c
📎 Assert a repeat listing performs no comparison work 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
f39905455e
📚 Describe the listing summary cache 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
a844752c47
✨ Cache the branch diff summary by revision tuple
The listing's per-branch [ahead behind conflicts] summary is cached in
memory keyed by (base-snapshot-id, source-revn, branch-revn), which
names exactly the inputs the computation reads. A stale key cannot
exist, so the cache needs no invalidation logic: a repeat listing with
no intervening save performs no comparison work, and a cache miss is
the old behaviour. Failed computations are not stored, so a broken
branch keeps degrading to zeros with a warning.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
dbab1d5620
📚 Reword remap-refs for the derived-data model 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
9b3436e7a2
📚 Describe the op log storage model
The technical guide documented the full-copy branch model this change
replaces. Update the conceptual model, data model, creation steps,
ahead/behind computation, update-from-main (squash), and the limitations
section to describe the derived-state model.
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
b043fcc76a
✨ Store branch state as an op log, not a file copy
Swap the storage and materialization layer of file branching to remove
the per-branch data duplication, keeping the RPC contracts and the
frontend unchanged.

- file_branch_change (0154): one row per branch save, the blob-encoded
  change vector, ordered by revn; branch files store no data payload.
- Read path: app.binfile.common/get-file* derives the branch file data
  by replaying the op log over the pinned merge-base snapshot, carrying
  the base's version and migration set so need-migration? stays quiet.
- Save path: update-file routes branch files to persist-branch-file!,
  which appends the vector to the op log and updates the file row
  without any file_data write.
- Creation: create-file-branch no longer duplicates main's data; it
  inserts an empty branch file row, grants the creator, and copies the
  library rels.
- update-branch-from-main squashes the op log after integrating main's
  changes: the repositioned base already carries main's side, so the
  log is replaced with the net branch-only changes.
- Kept (keep-branch) branches retain the base pin: their state is
  derived, so releasing the pin would leave them unreadable after GC.

Tests: backend-tests.rpc-file-branch-oplog-test asserts the storage and
derivation invariants; the existing branch, file, binfile, snapshot,
thumbnail, and pull request suites pass unchanged against the swapped
engine (84 tests, 1487 assertions, 0 failures).
2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
bc22388776
🐛 Decode snapshot metadata before resolving storage-backed data 2026-10-06 10:36:54 +02:00
Álvaro Tejero Cantero
e8957a24e4
🐛 Register the 0157 file-branch-change migration 2026-10-06 10:36:54 +02:00