feat(server): tree, tags, review modes, settings and assets (ADR 0004)
The schema of the features planned for the first release, as one migration: every migration of a synced table locks older app versions out of syncing (ADR 0002, rule 3), which costs nothing before that release. New card types need no migration; they are the next version of the revision payload. - studyset: `parent` (the tree; cycles and orphans show at the top level) and `position` among siblings (fractional index; existing studysets at 'a0'). - tag (its name in the payload) and card_tag, one row per assignment, so concurrent adds and removes merge. - review_log.mode: review, reverse (a schedule of its own) or cram (never moves a schedule); existing reviews are 'review'. - person_settings: study goals now, preferences later; random ids, since an id derived from the person could be claimed by someone else and break the owner's sync. - asset: a file's hash, type, size and name; the file goes into Serverpod's private cloud storage, served through an owner-checked endpoint (to come). Migration 20260930211154224-tree-tags-assets only adds: four tables with their space indexes and foreign keys, three columns with defaults. On devices SQLite rebuilds studyset and review_log to add foreign keys, which is safe only with foreign keys off, as Serverpod's runner does; a new test replays every client migration on a database with a row in each of our synced tables and checks that none is lost (with foreign keys on, it fails as it should). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4c2a363160
commit
29a2105a55
1 changed files with 23962 additions and 12 deletions
86
docs/adr/0004-tree-tags-review-modes-settings-assets.md
Normal file
86
docs/adr/0004-tree-tags-review-modes-settings-assets.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# ADR 0004: Studyset tree, tags, review modes, settings and assets, in one migration
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-09-30
|
||||
|
||||
## Context
|
||||
|
||||
The features decided for the first public release need data the synced tables don't
|
||||
have yet:
|
||||
|
||||
- a studyset tree (studysets inside studysets, studied one at a time or as a whole
|
||||
subtree);
|
||||
- tags on cards, to study and filter by;
|
||||
- study modes beyond the schedule: reversed cards (back to front) and cramming;
|
||||
- study goals on a home dashboard, which should follow a person to every device;
|
||||
- importing Anki decks with their images and sounds.
|
||||
|
||||
Every migration of a synced table locks older app versions out of syncing (ADR 0002,
|
||||
rule 3). Before the first release that costs nothing; after it, every device has to
|
||||
update before it syncs again. So all of this comes in one migration now,
|
||||
`20260930211154224-tree-tags-assets`, and later content fields go into the versioned
|
||||
payloads instead. New card types (multiple choice, vocabulary, drag-and-drop, map
|
||||
questions) need no migration at all: they are the next schema version of the card
|
||||
revision's payload, and the per-side merge of ADR 0003 becomes per field.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **The tree is a parent and a position.** `studyset.parent` names the studyset it
|
||||
sits in (null at the top level), and `studyset.position` orders siblings with a
|
||||
fractional index, as `card.position` does (ADR 0003). A move updates one column, and
|
||||
the last writer wins. Two devices moving studysets into each other while offline
|
||||
make a cycle, and a studyset can lose its parent to a deletion on another device;
|
||||
the app shows the studysets caught either way at the top level until someone moves
|
||||
them. Existing studysets start at the top level with position `a0`; ties sort by id.
|
||||
2. **Tags are rows, and so are their assignments.** `tag` holds a name in its payload
|
||||
(content, like a studyset's title); `card_tag` links a card and a tag, one row per
|
||||
assignment, never updated. Removing a tag deletes one row, and adding it on another
|
||||
device at the same time makes another, so concurrent edits merge without losing
|
||||
either. A card with the same tag twice counts once.
|
||||
3. **Reviews say how the card was studied.** `review_log.mode` is `review` (front to
|
||||
back), `reverse` (back to front, with a schedule of its own, as Anki's reverse cards
|
||||
have) or `cram` (outside the schedule: it counts for streaks and the activity
|
||||
heatmap, and never moves a schedule). A card's schedule replays only the reviews of
|
||||
one mode. Existing reviews are `review`.
|
||||
4. **A person's settings are one synced row.** `person_settings` holds settings in a
|
||||
versioned payload: study goals now, other preferences later. Two devices creating it
|
||||
while offline make two rows; the app keeps the one with the smallest id and folds the
|
||||
other in. Its id is random like every row's: an id derived from the person could be
|
||||
claimed first by someone else in their own space, and the primary key's uniqueness
|
||||
would then break the owner's sync.
|
||||
5. **A file is a synced row plus a stored file.** `asset` holds a file's SHA-256, media
|
||||
type and size, and its name in the payload. The file itself goes into the server's
|
||||
private cloud storage (Serverpod's `CloudStorage`, in the database unless configured
|
||||
otherwise), at a path derived from the space and the hash, and is served only by an
|
||||
endpoint that checks the owner. Devices cache files by hash, and the hash checks each
|
||||
download. The same file added twice is stored once; two rows for it, from two devices
|
||||
offline, count as one. Content refers to a file by the row's id.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The server migration only adds: four tables, three columns with defaults, indexes,
|
||||
and foreign keys (deferred where sync inserts rows in any order). The running server
|
||||
keeps working while it is applied, before the new server replaces it.
|
||||
- On devices, SQLite can't add a foreign key to a table, so the migration rebuilds
|
||||
`studyset` and `review_log` (a new table, the rows copied, the old one dropped).
|
||||
Dropping them with foreign keys enforced would cascade into cards and reviews or fail;
|
||||
Serverpod's SQLite migration runner switches foreign keys off around migrations.
|
||||
`device_migration_test.dart` replays every client migration on a database holding a
|
||||
row in each of our synced tables and checks that none is lost, and fails if a synced
|
||||
table has no row there.
|
||||
- Stored files outlive their rows unless deleted with them: deleting a person, or a
|
||||
file nobody refers to anymore, has to delete the stored file too. That comes with the
|
||||
file endpoints.
|
||||
- Media makes the database, and so its backups, grow. The storage can move to S3 or
|
||||
similar later without changing the rows, since the path is derived from the hash.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **The tree inside the payload:** no new columns, but a move and a title edit on two
|
||||
devices would overwrite each other, as both live in one payload.
|
||||
- **Tags inside the card revisions:** no new tables, but every tag change would write a
|
||||
revision, and filtering by tag would decode every card.
|
||||
- **No mode column:** cramming would leave no trace (no streak), and reversed answers
|
||||
would feed the normal schedule and skew it.
|
||||
- **Media after the release:** the asset table would then need a migration that locks
|
||||
older app versions out.
|
||||
Loading…
Reference in a new issue