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>
78 lines
4.5 KiB
Markdown
78 lines
4.5 KiB
Markdown
# 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`.
|
|
**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.
|