halkyon/docs/adr/0003-card-revisions.md
Tizian 「ティツィアーン」 927017bf15
feat(app): card types, whose fields merge separately
A card has a type: front/back, multiple choice (drawn on a map when it
names one), vocabulary or matching, and the fields of that type. The card
payload's schema 2 holds the type and the fields in use; schema 1 reads
as front/back. Revisions merge per field rather than per side (ADR 0003,
amended): a field one device changed takes the change, a field changed
differently is a conflict, lists included as a whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-01 21:10:02 +02:00

4.5 KiB

ADR 0003: Card revisions have a list of parents and merge per side

  • Status: accepted
  • Date: 2026-09-30
  • Amends: ADR 0001, 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. 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.