feat(server): tree, tags, review modes, settings and assets (ADR 0004)
All checks were successful
CI / check (push) Successful in 4m18s
CI / deploy (push) Has been skipped

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:
Tizian 「ティツィアーン」 2026-09-30 23:27:08 +02:00
commit 29a2105a55
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View 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.