halkyon/docs/adr/0003-card-revisions.md
Tizian 「ティツィアーン」 1b62c8098c
docs: one repository for every app and shared package (ADR 0015)
The root AGENTS.md holds what applies to every app and package (how
work is done, Serverpod, checks with Melos and the tools, CI); Learn's
own is apps/learn/AGENTS.md, Todo's a note that it is still the
scaffold. The root README introduces the project, Learn's moves to its
folder; LICENSE.md says which licence covers which directory, Todo's
packages unlicensed until decided; one CONTRIBUTING.md. ADR 0015
records the decision; ADR 0014 and 0003 note what moved.

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

4.6 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 (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.