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
3 changed files with 75 additions and 1 deletions
|
|
@ -117,7 +117,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
|
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
|
||||||
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
||||||
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
||||||
- **Content people edit is append-only revisions** (ADR 0001, rule 2; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
||||||
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
||||||
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
||||||
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
|
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
longer holds. The decision and its rules stand. Deleted content is redacted before
|
longer holds. The decision and its rules stand. Deleted content is redacted before
|
||||||
deletion (ADR 0002, rule 2), and how authorship is secured in shared spaces is decided
|
deletion (ADR 0002, rule 2), and how authorship is secured in shared spaces is decided
|
||||||
with shared spaces (ADR 0002, *Consequences*).
|
with shared spaces (ADR 0002, *Consequences*).
|
||||||
|
- **Amended by:** [ADR 0003](0003-card-revisions.md) (2026-09-30): rule 2's revisions have a
|
||||||
|
list of `parents` instead of `parentRevisionId` and merge per side (front, back); cards
|
||||||
|
have a fractional `position`.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|
|
||||||
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