Every dictionary entry now has a version: 16 hex digits of the SHA-256 of
what cards take of it (headword, reading, entry), made by the loader and
carried on every hit. A card keeps the version it took (card schema 8).
DictionaryEndpoint.changed returns, of the entries named with the
versions cards keep (1,000 at most a call; the app asks 500 at a time),
those whose version is another now.
The card browser says how many cards are behind their dictionary and takes
the corrections at a tap (decided 2026-10-09: by offer, never by
themselves). Taking one writes the entry as it is now over the revision
in which the card last took it, beside whatever came after, so the
revisions merge per side as two devices' edits do (ADR 0003): a side only
the dictionary changed takes the correction, a side the person changed
too is a conflict settled in the composer, whose words now name that
case. Cards made before versions are never offered one.
The server rereads its dictionary folder hourly now, not daily: the
dictionaries service fills its volume after an instance's first start,
and an hour with nothing changed costs a hash of each file.
Migration 20261009145434785-dictionary-entry-version: adds
dictionary_entry.version, text NOT NULL DEFAULT '' (rows loaded before
have none and are never reported as corrected), and by hand empties
dictionary_source."fileHash", so every source loads once more and its
entries get their versions; the schema stays as definition.sql says. The
old server goes on working until the switch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The study option Write by hand (settings schema 7) has character cards
and words written in a session, on a schedule of their own (review mode
`write`; older apps skip a mode they don't know): what to write on top, a
character's meanings and readings or a word's reading and meanings, and
below it a square to write it in, character by character.
Each stroke is checked as it ends against KanjiVG's: both resampled to 22
points, then their shape once centred, where they lie, how long they are
and which way they go, each against what a loose hand strays by; a stroke
closer to one still to come is out of order. One that counts is drawn as
KanjiVG draws it, and the hand's offset from the middle is forgiven in the
strokes after; any other flashes red, says why and counts as a mistake, as
does a hint. Two misses in a row show the next stroke faintly. Once
written, or given up on (Show it), the rating the mistakes suggest is
outlined: none Good, one or two OK, more Bad.
The square takes every touch on it at once, so writing never scrolls the
card or swipes to rate. Characters without a stroke order (in a word) are
written for the person.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A dictionary source now says what it holds (`kind`: `words`, the default
for files from before, or `characters`), a string so that an app older
than a new kind leaves it out. Search, countLabelled and labelled take the
dictionary by its source and meaning language, as the unique index names
it, rather than by the language: with KANJIDIC2 beside JMdict, a search
for words would otherwise bring kanji, and JLPT N5 would hold both. The
search reads the language for its query variants from the source's row;
a dictionary the instance doesn't hold finds nothing. The rank index goes
by source too.
Migration: drops and recreates dictionary_entry_rank_idx on (source,
glossLanguage, rank), and adds dictionary_source.kind, text NOT NULL
DEFAULT 'words', so a JMdict loaded before is a dictionary of words. Both
tables are reference data, never synced; the old server keeps working
until the switch (its queries by language just lose the index).
In the app, a studyset's dictionary is one of words unless a kind is
asked for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
serverpod_offline_sync v0.0.10 collects outbound changes in one
consistent snapshot, so a sync no longer fails on writes made while it
runs (upstream #84, #163), keeps checkpoints per author, so a device
clock moving backwards no longer stalls sync or resends data, and keeps
unique conflicts and foreign key defaults alike on every device. Its
tables and its messages are unchanged: no migration, and apps on v0.0.8
keep syncing. It needs Serverpod >=4.0.4 <4.1.0 (v4.0.4 is fixes only),
so the root overrides that held the workspace on one Serverpod go: each
package pins v4.0.4 itself. The generated code is identical under the
v4.0.4 CLI, which CI now uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A tag is still its name, but no longer in lowercase: the name keeps the
case it was written in, and names that differ only in case are one tag
(ADR 0004, rule 2, amended). Two such tags from two devices fold into the
one with the smaller id, its case with it. Tags stored before stay
lowercase until renamed.
Words from the dictionary are tagged JLPT-N5 to JLPT-N1 and with their
word class (Noun, Verb, i-Adjective, na-Adjective, ...). Anki tags and
PDF chapter tags arrive as written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The server now holds dictionaries for everyone: `dictionary_entry` and
`dictionary_source`, reference data owned by no one, never synced. It
loads the dictionary files in its `dictionaries` folder at every start,
those whose SHA-256 changed: in the background, each source replaced in
one transaction, so searches meanwhile find the old entries; a file it
can't read is logged and left out. All of JMdict loads in under half a
minute. On an instance the folder is deploy/dictionaries, mounted
read-only (compose.yaml); the server's README says how.
DictionaryEndpoint (signed in): the dictionaries with their credits; a
search (exact key, exact loose key, key starting with the query, loose
key starting with it, each in the source's order; katakana as hiragana,
macrons written out), some 150-450 ms over all of JMdict; and the
entries with a label, counted and a page at a time (all JLPT N5 words).
The curator now keeps spellings that aren't quite right apart, as
`looseKeys` (romaji with long vowels typed once): "sushi" finds 寿司
before 数詞 (すうし).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A PDF now has a position among its studyset's files, as notes and paths
have: a fractional index in a column of its own (so moving it on one
device and renaming it on another don't overwrite each other), last
writer wins. The migration adds it to pdf_source on the server and on
each device with 'a0', so PDFs from before stand in the order they were
added (ties sort by id), and new ones go at the end.
The Files tab lists them in that order by default (Custom order, first
in the Sort menu; newest first was the default before). With two files
or more, Reorder beside the tabs shows the custom order whatever they
were sorted by, a grip in place of each file's options and the Sort menu
put away, until Done.
The next release changes synced tables anyway (card.buriedAt, since
v0.2.2), so older apps have to update for that already.
Also: card.position's doc no longer speaks of moving cards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Finding today's burials in the review log meant reading every review the
cards ever had, once more in the library's progress, the card list and
each round. card.buriedAt holds it instead, read with the cards those
screens read anyway. The sync engine merges a row's fields one by one, so
a burial and a move of the same card on two devices both stand (a sync
test checks it). The review log's bury mode is gone.
The migration adds one nullable column, on the server and on devices:
ALTER TABLE "card" ADD COLUMN "buriedAt" (timestamp without time zone on
the server, INTEGER in SQLite); existing rows read as not buried. Apps
built before it stop syncing until they are updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Published apps can't be made to update, so the app now notices when it
no longer matches the server: the server names the oldest app build it
works with (instance.info's minimumAppBuild), asked before the first
sync of each launch, and the sync engine's refusal of differing synced
tables counts the same. Either way the app says "update the app; if it
is up to date, the server needs an update" in a banner on Home and in
the new Sync card in Settings, and what the person does meanwhile stays
on the device.
The Sync card shows where sync stands since the app started (syncing,
everything synced, sync failing, with the last completion and failure)
and offers Sync now. While the last sync failed, a dot sits on the
Profile tab and the Settings cog. One sync that goes through clears it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The endpoint's comments now name PDFs and the 20 MB rule; the generator
copies them into the client, which the commit before left behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
PDFs live in a studyset's new Files tab: uploaded from Add material
(a file the studyset holds already asks first), each row with its
pages, cards, notes and size, how far it is read and its reading
deadline; renamed, given a deadline (a page or a chapter by a day),
marked as read, started afresh, deleted with a moment to undo.
The reader (/pdfs/:id) opens where reading left off: on wide windows
a rail of tools (search, fit, zoom, fullscreen), a page counter with
the chapter, and a sidebar; on phones a top bar, a page scrubber and
a sheet. The sidebar holds the PDF's flashcards, by page or chapter,
made from selected text or written there, each tied to its page and
tagged with its chapter; and the page's note, an ordinary note tied
to the page. Screenshots of a region become images that know their
page. Cards and screenshots lead back to their page; reading on past
a chapter with cards suggests studying it. Home shows the deadlines
running and to come, and a timeline of them.
Server: pdf_source rows (content in the payload, last writer wins)
and an append-only pdf_reading log, the furthest page derived from
it so devices never conflict. Files up to 50 MB for PDFs (20 MB for
others; a larger file that isn't a PDF is deleted once stored). A
deleted PDF takes its file's asset row along unless another PDF has
the same file. Card payload 4 and note payload 2 keep the page.
The reader draws with pdfrx (PDFium) behind PdfEngine in
Dependencies; widget tests use a fake, and
integration_test/pdf_engine_test.dart checks the real engine on a
device or the desktop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Server: five synced tables, only additions (migration
20261002211346701-learning-paths):
- `learning_path`: its studyset, a fractional position, and its own
content in the payload {title, secondaryTitle, description}, the last
writer winning, as a studyset's;
- `learning_path_step`: its path, a position, and its kind ('info',
'singleChoice', 'multipleChoice', 'dragAndDrop', 'fillInTheBlank'),
fixed once created;
- `learning_path_step_revision`: a step's content as append-only
revisions, as a card's: {title, body, exercise, rightFeedback,
wrongFeedback, sources};
- `learning_path_answer`: an append-only log (right, wrong, continued,
reset), as reviews are, so answers from two devices never conflict;
- `learning_path_tag`: one row per assignment, as `card_tag`.
App, `feature/learning_path`: the five exercises and their JSON, grading
that marks only the learner's own wrong picks, progress worked out from
the log (a step is done once answered right since its last fresh start),
`LearningPathRepository` (steps saved on what they started from, merged
per field) and `PathProgressRepository` (answers, resets with Undo, the
times steps were done). Paths share the person's tags; deleting a path
or a step can be undone for a moment, and a studyset's deletion takes
its paths. Tests: models, codecs, repositories, tags, the device
migration guard, and sync tests against the server (four).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Server: three synced tables, only additions (migration
20261002191122122-notes): `note` (its studyset, a fractional position),
`note_revision` (append-only, as a card's: parents, author, report,
the payload {schemaVersion, title, body}) and `note_tag` (one row per
assignment, never updated, as `card_tag`). Each indexes `spaceId`, and
everything goes with its space, studyset, note or tag. A migration of
synced tables locks app builds from before it out of syncing (ADR 0002,
rule 3): cheap now, before the first release.
App, `feature/note`:
- `NoteContent` is a title and rich text, merged apart (ADR 0003): a
title changed on one device and the body on another both survive, a
body changed on two is a conflict.
- `NoteRepository` creates a note at the end of its studyset, saves an
edit on the revisions it started from (`saveEdit`, as the card
composer now does), says when a note was last edited (its newest
head's UUID v7), counts notes per studyset, and deletes a note's
revisions blanked first (ADR 0002, rule 2).
- Notes share the person's tags: `setTagsOfNote`, and folding tags of
one name or undoing a tag's deletion covers notes too.
- The delayed deletion with its Undo moves to `core/async`
(`DelayedDeletions`): `StudysetDeletions` builds on it and now takes
a studyset's notes too; `NoteDeletions` deletes a single note.
Tests: content, merge, payload, repository, deletions, tags, the device
migration guard, and sync tests against the server (four).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
An anonymous person may keep 100 MB of images and sounds, an identified one
500 MB, and one person more by a file_allowance row the operator adds; all
of them together 15 GB, since every file is in the database and in each dump
the server keeps (decided 2026-10-02). Each comes from the environment
(FILE_LIMIT_*_MB), with these defaults, documented in deploy/.env.example.
The server checks when an upload starts, counting unfinished uploads so they
can't fill the disk unnoticed, and again when it finishes, counting what is
kept, so uploads started side by side can't overshoot; a refusal is a
FileLimitExceededException, and an upload refused as it finishes is deleted.
deletePerson deletes the allowance with the person. Migration: the new
table file_allowance, only added.
The app keeps a refused file waiting on the device and stops that round of
uploads; the Anki import warns when a deck's files may not fit in the room
left (asset.usage()).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The asset endpoint (login required) takes a file into the caller's own space
by its SHA-256 and serves an asset row's file only to its space's owner and
members; anyone else, and a deleted row, gets NotFoundException as for a
missing file. startUpload hands out Serverpod's upload description, or null
when the file is there already (claiming it anew); finishUpload keeps the
upload only if its content has the announced hash. Files are stored in the
private database storage at spaces/<space>/<sha256>, at most 20 MB.
deletePerson deletes the person's files in its transaction, and a daily
future call deletes the files no live asset row refers to, and uploads never
finished, 7 days after they were last claimed (ADR 0004, rule 5).
The tests run on the sync engine's database, as the server does, without
rollback: its writes take transactions of their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Each review records its mode (ADR 0004, rule 3): front to back, back to
front with a schedule of its own, or crammed outside the schedule. A
card's schedule replays only the reviews of one mode. Resetting progress
appends a 'reset' entry per card, no migration (the column is text):
schedules replay what follows it, and the history stays for the streak
and the heatmap. Reviews are inserted in batches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A tag is its name: names are lowercase, a person has one tag per name,
and two of one name (made offline on two devices, or by renaming) fold
into the one with the smallest id, so every device ends up alike. The
colour lives in the payload. Deleting a card deletes its tags too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Decided 2026-10-01: after the starred ones, the library lists the most
recently changed studysets first. That needs a change date, so studyset
gains updatedAt (ADR 0004): when the studyset itself last changed, its
content, place, star or archive date, written by the device making the
change in the same update as the change; the last writer wins. Studysets
from before have none and come last, the newest of them first.
The branch's migration is still unreleased (not merged, not deployed), so it
is recreated rather than followed by a second one:
20261001064926911-tree-tags-assets replaces 20260930213716558 and differs
only by one line on the server, ALTER TABLE "studyset" ADD COLUMN
"updatedAt" timestamp without time zone (nullable, so existing rows keep
null and no data changes), the same column in the table devices rebuild,
and its version. The model's comment on the payload no longer lists schema
1's fields alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
studyset gains archivedAt (archived studysets rest in their own section and are left out
of studying, with everything below them) and starredAt (kept at the top of the
library), both nullable. Columns rather than payload fields, for the same reason as the
parent: starring on one device and renaming on another must not overwrite each other.
The branch's migration wasn't applied anywhere yet (tests build fresh databases, and
the branch isn't merged), so it is recreated with the two columns instead of adding a
second one: 20260930213716558-tree-tags-assets replaces 20260930211154224, and differs
from it only by the two ALTER TABLE lines and its version.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
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>
deletePerson deletes a person with everything they have, in one transaction and in ADR 0001 rule 6's order: their sessions first, since a device with a valid session would re-upload everything; then their memberships and personal space, whose foreign keys cascade into every synced table and the sync engine's bookkeeping; then the person, whose identities cascade. AccountEndpoint.delete deletes the caller and tells their devices that their sessions ended.
pruneAbandonedAnonymousPeople deletes, 500 at a time, anonymous people created and unused for 90 days: their sessions have expired and nobody can ever return to them. Identified people are never pruned. PruningFutureCall runs it daily at 03:17; the server schedules it at start, cancelling the previous entry by its identifier first, since scheduling always adds one.
Tests, refusal first: deleting when not signed in; deleting removes the caller, their sessions, their anonymous identity and the content of their space, and nobody else's; pruning takes the unused and the never-signed-in, and spares the recently used, newcomers and identified people.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
ReviewLog keeps every rating of a card: the card, the rating (FSRS's 1 again to 4 easy), when (UTC) and how long answering took, as decided. It is never changed; each device derives a card's schedule by replaying its reviews, so reviews from different devices never conflict. No device id: the sync engine knows each row's device.
The migration (by CLI) only adds: the table, an index on spaceId for space deletion, one for a card's reviews in time order, and the foreign keys (from the space with ON DELETE CASCADE; from the card with ON DELETE CASCADE, DEFERRABLE INITIALLY DEFERRED).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Both licences (AGPL for the app, SSPL for the server) require offering the source of what an instance runs to the people who use it. instance.info() now carries sourceCodeUrl: the operator's SOURCE_URL, which they must set when running modified code, and otherwise this project's repository. Serverpod validates its configuration files strictly, so the value comes from the environment; deploy/compose.yaml passes it on from deploy/.env, whose example explains it. A test covers the default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Card belongs to a studyset and carries only its position, a fractional index (last writer wins), so cards can be reordered and bulk imports keep their order. Its content lives in CardRevision rows, which are never updated except to blank them (ADR 0001, rule 2): parents lists the revisions a revision replaces (decided: a list, so a revision with two parents resolves a conflict), authorId the writer, reportId the report it answers once reports exist, and the envelope holds the content. Deciding the list of parents and the position now matters: changing a synced table later locks out older app versions.
The migration (created by CLI) only adds: the two tables, an index on spaceId for each (space deletion cascades into them), one for a studyset's cards in order and one for a card's revisions, and the foreign keys, from the space with ON DELETE CASCADE and from the studyset and the card with ON DELETE CASCADE, DEFERRABLE INITIALLY DEFERRED, since synced rows can arrive in any order. The client migration creates the same tables on each device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
As decided, a GitHub sign-in keeps nothing but the numeric GitHub ID, so the login is our own, on Serverpod's GitHub helpers (lib/src/auth/github.dart): Serverpod's stock login also stores the name and email in a profile and downloads the avatar, and its endpoint is never exposed. The server exchanges the authorization code with the client secret, reads the ID, and drops the access token.
GitHubUpgradeEndpoint links the GitHub ID to the signed-in anonymous person, grants identified (to their existing sessions too) and removes the anonymous identity, in one transaction: the person, their spaces and their studysets stay the same. GitHubSignInEndpoint gives a session for a person who has the GitHub account already; it's called without a session, since Serverpod issues no session for another person inside a signed-in call, and it never creates people. InstanceEndpoint.info() tells the app the instance's GitHub client ID. Refusals are GitHubSignInRefusedException with a reason: GitHub sign-in off here, the account taken by another person, another GitHub account linked already, an unknown account.
GitHub sign-in is on only where the passwords hold githubClientId and githubClientSecret (empty counts as missing, as Compose passes unset variables on as empty strings); production takes them from GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in deploy/.env. grantIdentified can now join a caller's transaction. No migration: the GitHub accounts table comes with the auth module.
Tests, refusals first: upgrading when not signed in (checked by switching the guard off: the test fails), both ways without a GitHub app, an account taken by someone else with nothing changed, a second GitHub account, an unknown account with nobody created; then linking keeps the person and reaches their sessions and stores no email, linking twice changes nothing, signing in reaches the right person.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The app and the client package are under AGPL-3.0-or-later, the server under SSPL-1.0, as decided for before the first publish: the server builds on Serverpod's core, which is SSPL, while the app links only BSD-3 packages and the client is generated from our own models. "Or later" keeps the code compatible with future AGPL versions without asking every contributor. Each package carries the official text in its LICENSE (from gnu.org and the SPDX licence data) and a notice in its README; LICENSE.md explains the split and covers the files outside the packages.
CONTRIBUTING.md: contributions come under the licence of the package they change, with no copyright assignment; every commit carries a Signed-off-by certifying the Developer Certificate of Origin, quoted verbatim; tool/check.sh runs before pushes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The first migration since the initial one, created with the CLI (serverpod create-migration --tag studysets), since no MCP bridge was set up yet. On the server it is one transaction that only adds: gen_random_uuid_v7(), the studyset table, the sync engine's tables (spaces, their members and nodes, the CRDT bookkeeping, the log of integrity violations) with their foreign keys, and, from Serverpod v4.0.3, the auth module's profile image foreign key re-created as DEFERRABLE INITIALLY DEFERRED, so that dumps restore. The server tests apply it to their Postgres.
studyset gets an index on spaceId: 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. AGENTS.md makes it the rule for every synced table.
The client migration creates each device's SQLite schema. The generator builds it 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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Moves every Serverpod package, and the four overrides for serverpod_offline_sync, from v4.0.2 to v4.0.3 before the first synced table's migration exists, so that migration is created on the version that stays.
v4.0.3 contains fixes only. Checked before moving: serverpod_database gained only backwards-compatible changes, none of which the sync package calls into. Server-side session keys are hashed as before (the cheaper argon2 applies to JWT refresh tokens, which we don't use), so no session ends. The auth module's profile image relation is now deferred, which fixes restoring a Postgres dump; step 7's migration carries that change.
argon2 now runs in serverpod_argon2 (BSD-3), a new dependency of the server only: a prebuilt, statically linked library that needs no glibc, bundled by dart build cli like the SQLite libraries, so the Docker build is unchanged. It doesn't reach the app.
The code generator still fails on Dart v3.13's primary constructors in endpoint files (tried again), so the server keeps the four syntax lints off. The regenerated test tools gain withServerpod's ephemeralDatabase option.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Adopts serverpod_offline_sync v0.0.8 (ADR 0001): its module (nickname offline_sync) and the experimental database: sync in generator.yaml, its server and client packages, and overrides that keep the four Serverpod core packages on v4.0.2, since the package pins v4.0.0 exactly and the spike showed it works on v4.0.2. The app's new dependencies come along because the workspace shares one lock file.
Studyset is the first synced model. Its space owns it, so it has no owner column, and only the envelope is a column: keyId and nonce stay null (no end-to-end encryption; ADR 0002, rule 6), and payload holds the content as versioned UTF-8 JSON, so a new content field changes the payload's schema version instead of this table, whose every migration locks older app versions out of syncing (rule 3). The audience column of the original design is gone: it served key selection, and the engine's spaces decide who reads a row.
The generated server and client code is included. The migration isn't: it is created together with the move to Serverpod v4.0.3 and reviewed first, like the client's local migration (the client's lib/migrations is still empty).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Serverpod has no row-level security, so every endpoint enforces access itself, by three rules established here once: the person comes from the session, never from the client, and every query filters by them; someone else's row answers exactly like a missing one (NotFoundException, shared in lib/src/common); endpoints for people with an identity declare requiredScopes: {identified} instead of checking it themselves.
DevicesEndpoint is the first such endpoint: it lists your sessions and signs one of them out, for a lost phone. It uses the existing session table, so no migration. Serverpod's own helpers are unguarded (revokeSession deletes by id alone; listSessions without authUserId returns everyone's sessions), so the endpoint puts the owner check into the same query and then notifies the revoked device as revokeSession does. Each query carries its SQL as a comment.
The identified scope lives in lib/src/auth/scopes.dart. grantIdentified gives it to a person and to their existing session rows, because sessions keep the copy of the scopes they were started with; step 9 uses it.
Tests assert the refusals first: anonymous people and callers who aren't signed in are refused; the list shows only your own devices; signing out someone else's device fails exactly like an unknown id and leaves it alone; grantIdentified reaches existing sessions. The shared fixtures live in test/integration/support. AGENTS.md names the endpoint as the reference and warns about the unguarded helpers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
People can sign in without an account: AnonymousIdpEndpoint exposes Serverpod's anonymous identity provider. lib/src/auth/auth_config.dart holds the token managers and identity providers, shared by server.dart and the tests, so the tests check what production runs. At most 30 new anonymous accounts per IP address and hour (Serverpod's default is 100): a school class behind one address can still start together, while one abuser gets at most 720 a day. No migration: the tables for anonymous accounts and rate limiting already exist.
Tests: signing in creates a person; the 31st account from one IP address within an hour is refused. They run without database rollback, since signing in uses concurrent transactions that the rollback can't wrap, and delete what they create instead.
The server switches off the lint rules for Dart v3.13's constructor syntax: Serverpod v4.0.2's code generator can't parse it yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Deviations from a stock `serverpod create`:
- Dropped Flutter web support, for two reasons:
- Appflowy editor had issues in the past, potentially related to the
Flutter-Web IME bridge. Upstream has also open bugs in relation to
that:
- AppFlowy-IO/appflowy-editor#1029 [Bug][Web] Unwanted space on click
and overall space bugs (open)
- AppFlowy-IO/appflowy-editor#782 [Bug] Editor auto-scrolling when
pressing space too quickly if placed inside a scrollable (open)
- Offline-first is a required feature. serverpod_offline_sync's SQLite
support in browsers, maybe via sqlite3.wasm + OPFS, is not
documented.
- SDK constraints raised to ^3.13.0, instead of ^3.12.2, so enjoy the
newest language features.
- Pinned to Flutter 3.47.4 via FVM.
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>