docs: ADR 0003, card revisions with a list of parents, merged per side
Records step 10's decisions with their reasons and the alternatives weighed: a list of parents instead of ADR 0001's single parentRevisionId (a revision with two parents resolves a conflict), a three-way merge per side against the newest common ancestor (no silent merge inside a side), and an explicit fractional position for the order. ADR 0001 gets an "Amended by" line, and AGENTS.md points to the new ADR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
66886ef383
commit
ec246e552d
1 changed files with 75 additions and 1 deletions
71
docs/adr/0003-card-revisions.md
Normal file
71
docs/adr/0003-card-revisions.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# ADR 0003: Card revisions have a list of parents and merge per side
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-09-30
|
||||
- **Amends:** [ADR 0001](0001-offline-sync.md), whose rule 2 gave each revision a single
|
||||
`parentRevisionId`.
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0001 keeps card content as append-only revisions, so that edits made on several
|
||||
devices while offline are never lost: "two revisions with the same parent are a visible
|
||||
conflict". Building the cards (step 10) raised three questions the rule left open: how a
|
||||
conflict is resolved in the revision graph, how finely concurrent edits merge, and how
|
||||
cards are ordered. All three had to be answered before the tables existed, because every
|
||||
migration of a synced table locks older app versions out of syncing (ADR 0002, rule 3).
|
||||
|
||||
Merging per character, as a CRDT like Automerge does, was weighed on 2026-09-29. It only
|
||||
pays off for live co-editing. For short content a silent merge is worse than a visible
|
||||
conflict: "big" changed to "large" on one device and to "huge" on another becomes
|
||||
"largehuge", and nothing reports a conflict inside the text.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **A list of parents.** `CardRevision.parents` holds the ids of the revisions it
|
||||
replaces. A card's heads are the revisions no other revision names as a parent. Saving
|
||||
a card appends a revision whose parents are all its current heads, so after a conflict
|
||||
that revision is the resolution.
|
||||
2. **Three-way merge per side.** Several heads merge side by side (front, back) against
|
||||
their newest common ancestor: of the common ancestors, one that no other common
|
||||
ancestor descends from, and the newest of those after criss-cross merges. A side one
|
||||
head changed takes that change, a side changed the same way on several devices is no
|
||||
conflict, and a side changed differently is a conflict that the app shows with every
|
||||
version until the person keeps one. Merging happens when reading; nothing is written
|
||||
until the person saves. The code is
|
||||
`halkyon_learn_flutter/lib/src/feature/card/model/card_merge.dart`.
|
||||
3. **An explicit position.** `Card.position` is a fractional index (rocicorp's
|
||||
algorithm, through `fractional_indexing_dart`, checked against the algorithm's
|
||||
reference test vectors) in a column of its own, where the last writer wins. Two devices
|
||||
appending at once can pick the same key, so ties sort by id.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No edit is lost, and none is merged silently inside a side.
|
||||
- Every device computes the same merge from the same revisions. Two devices resolving the
|
||||
same conflict at once create two heads; if they chose the same content, those merge
|
||||
without a conflict, otherwise the conflict shows again.
|
||||
- Clock skew between devices can make "newest" pick an older common ancestor. Then more
|
||||
sides count as changed, which can add conflicts but never loses an edit.
|
||||
- Moving a card writes only its position; if two devices move the same card, the later
|
||||
move wins.
|
||||
- Revisions accumulate. Compaction (blanking old payloads, the mechanism of ADR 0002,
|
||||
rule 2) comes with shared spaces, where most edits will happen.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **One parent** (`parentRevisionId`, ADR 0001 as written): resolving a conflict would
|
||||
need an extra mechanism, such as marking the other head as superseded.
|
||||
- **Whole-card conflicts:** every concurrent edit would be a conflict, even when the
|
||||
devices changed different sides.
|
||||
- **Merging per block now:** needs stable block ids in the rich content, which
|
||||
`appflowy_editor` doesn't persist, so a new content format, before any long-form content
|
||||
exists. It remains possible later for lessons, as app code over the same tables.
|
||||
- **Merging per character** (a CRDT such as Automerge): see *Context*.
|
||||
- **Creation order instead of a position:** no reordering, and UUID v7 ids aren't
|
||||
guaranteed to keep their order within one millisecond, as in a bulk import.
|
||||
|
||||
## Way back
|
||||
|
||||
The merge is app code: a finer one (per block, for long content) can replace it without
|
||||
touching the tables. Changing `parents` or `position` would be a migration of a synced
|
||||
table.
|
||||
Loading…
Reference in a new issue