halkyon/docs/adr/0003-card-revisions.md

79 lines
4.6 KiB
Markdown
Raw Normal View History

# 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` (under `apps/learn/`
since 2026-10-19, ADR 0015).
**Amended 2026-10-01, card types:** a card now has a type (front/back, multiple
choice, vocabulary, matching) and the fields of that type, and the merge runs per
field instead of per side: the type itself, the front, the back, the answers, the
reading, the meanings, the matching, the map. A list (the answers, the meanings)
merges as one field: two devices changing it differently are a conflict, as two
versions of one side were. Finer merging, per answer, would need a stable id per
answer; it can come when shared spaces make concurrent edits of one card common.
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.