The app gets its look and navigation before more features land on them. - Theme (core/theme/): hand-written light and dark Material 3 colour schemes around cobalt and copper; ThemeExtras for the colours Material has no role for (states, the four FSRS ratings); the 20 colours a person can give content. Tests hold text to WCAG AA contrast on each palette colour and on the dark theme's primary colour and surface. - Layout and shared widgets: Material 3's window-size classes (core/layout/); SectionCard, AppLoadingIndicator, confirmDestructiveAction and FoundById (core/widget/). Lucide icons. - Navigation with go_router: four tabs (Home, Library, Explore, Profile), each keeping its own stack of screens, as a navigation rail with the app's icon from 600 dp and as a navigation bar below; choosing the open tab again returns to its first screen. A studyset's screen sits in the library; studying, cards and settings cover the tabs. - Routes carry ids: FoundById finds the item in the repository's live list, so a studyset deleted on another device, or an old link, says it no longer exists. A screen that deletes its own item keeps showing it while it closes, rather than flashing that message. - Every person gets an app of their own (PersonApp, lib/src/app/): a MaterialApp.router with its own GoRouter and PersonScope around the navigator, built by SessionGate inside the person's keyed subtree. Screens read the person's repositories wherever they are built, so the static route() helpers and the listener that closed every pushed screen on a change of person are gone: a change of person now disposes the whole router and starts again at home. - The account dialog becomes the Profile tab, with Settings (sign out, delete the account, About) behind a gear. A waiting GitHub sign-in now keeps waiting while another tab shows; a change of person still cancels it. - The new screens' strings come from Flutter's gen-l10n (English for now). The generated files are committed, left out of analysis and formatting, and written anew by `flutter pub get`, so CI's check of generated code also catches stale ones. Widget tests now start the whole app (test/support/pump_app.dart) at the screen's path and move through it as a person would, by tabs and taps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
31 KiB
Flutter & Serverpod project
This project is a Flutter app (frontend) backed by a Serverpod server (backend). Always build the app's backend with Serverpod.
Build for multiple users, use Serverpod's built-in authentication, which is already set up in lib/server.dart.
The user starts the server and Flutter app with serverpod start. There is no need to check if the server is running: make the changes and call the serverpod MCP tools as needed. If the server is not running, an informative error message will be received from the MCP server. Then STOP and ask the user to start it. NEVER start the server yourself. The Flutter app is started along with it, or can be launched from the MCP tool spawn_flutter_app.
While running, serverpod start watches for file changes to run incremental code generation and hot reload both the server and the Flutter app.
Calling serverpod generate directly is not needed, but might be useful to troubleshoot when an incremental generation fails.
ALWAYS use the MCP server instead of the command line. Use the MCP server to:
create_migrationandapply_migrationsfor database (after you change data models).create_repair_migrationif the database has drifted out of sync with the migrations.tail_server_logsto read logs from the server.tail_flutter_logsto read the raw stdout/stderr of the Flutter app.hot_reload/hot_restartto reload or restart the server and the Flutter app. ALWAYS callhot_restartafter doing changes in the Flutter app that may not work with normal hot reload (which is automatically applied).spawn_flutter_appto start a Flutter app declared underserverpod: flutter_apps:in the serverpubspec.yaml.get_flutter_app_dtd(Dart tooling daemon) for connecting to the app through thedartMCP.
NEVER edit generated code. The server's lib/src/generated/ directory and the whole halkyon_learn_client package are rewritten by the code generator. Change the .spy.yaml models, the endpoints, or lib/server.dart instead.
Migrations are a narrow exception: the migration.sql of a generated migration MAY be edited by hand when the generated SQL would lose data — to add a data transformation, or to reach a destructive change through non-destructive steps. Never touch the other files in the migration directory, and keep the schema the SQL ends up with identical to definition.sql — new databases are created from that file and never run migration.sql.
The serverpod MCP tools come from .mcp.json: serverpod mcp-server, a bridge to the running serverpod start. They load when a session starts; if they are missing, the server wasn't running or the session predates .mcp.json. Like all agent configuration, .mcp.json is git-ignored; in a fresh clone, create it with a server serverpod running serverpod with the arguments --no-analytics mcp-server --server-dir halkyon_learn_server.
Pass --no-analytics to every serverpod call (before the command: serverpod --no-analytics generate). Otherwise the CLI reports the commands run and the Serverpod features the project uses to PostHog; there is no setting or environment variable to switch that off. The VS Code task and .mcp.json pass it already.
Only when the server cannot be started at all, fall back to the CLI in the server package:
serverpod generateto regenerate the client and the generated server code.serverpod create-migrationafter changing a model with atable(add--forcefor destructive changes). It only writes the migration;serverpod startapplies pending migrations when it boots the server.
Tests need no Docker. config/test.yaml sets database.dataPath, so Serverpod starts and manages the test database (an embedded PostgreSQL) itself. Just run dart test in the server package.
Tooling traps: rules of leancode_lint's analyzer plugin are ignored with its prefix (// ignore: leancode_lint/missing_equatable_props); dart fix --apply moves flutter_driver into the app's dependencies (as any) on every run, so restore it as a dev dependency (sdk: flutter) afterwards; never dart format generated code, only the hand-written paths, as tool/check.sh does; and never rebase or check out other commits while serverpod start runs: its watcher regenerates code for each intermediate state, leaves stale generated files behind and hot-reloads them. Stop it first, or work in a separate worktree.
Checklist after doing changes, in this order:
dart format(CLI) on the files you changedcreate_migrationandapply_migrations(MCP - only if necessary)tool/check.sh(CLI): format check,dart analyze --fatal-infos, server and app tests. The pre-push hook runs the same script, so a push only goes through when it passes.- Do
serverpodMCPhot_restartif required (hot reload is done automatically). Will also hot restart Flutter app - Check
serverpodMCPtail_server_logsandtail_flutter_logsfor any issues.
If the user asks you to test the app:
- Use
get_flutter_app_dtd(serverpodMCP) to get the Flutter app's DTD - Pass the DTD to
connect_dart_tooling_daemon(dartMCP) to connect to the app - Use
flutter_driver(dartMCP) to navigate through the app
The app is launched from halkyon_learn_flutter/lib/driver.dart, which starts the Flutter driver extension with text entry emulation turned off so the app stays usable by hand. To let the driver type, set enableTextEntryEmulation: true there and hot_restart the app.
Halkyon Learn
A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS schedule. Content is rich text (appflowy_editor documents) everywhere. Targets: Linux, Windows, macOS, Android, iOS. There is no web build.
- Packages:
halkyon_learn_server(endpoints, models, migrations),halkyon_learn_client(generated, never edited),halkyon_learn_flutter(the app, IDapp.halkyon.learn). - Instances: anyone may run one. The app takes the server URL from
--dart-define=SERVER_URL=…, so never hard-code a host. - Working notes: if
NEXT.mdexists in the repository root, read it first. It holds the current step, the plan and the reasoning behind each decision. It is local and never committed.
How work is done here
- The agent implements; the developer decides. Put every decision to the developer before building (design choices, alternatives, framework defaults kept or changed). The first time a new pattern appears (e.g. the first repository/gateway pair, the first synced model, the first endpoint with its denial tests), walk through it in detail with the developer; afterwards, implement exceedingly similar cases directly and point out only deviations.
- Tests come first and state the specification. Every access rule gets a test that asserts denial, not only permission.
- Explain every migration's SQL.
- Before keeping a framework default, name the alternatives and recommend one, especially where a choice becomes expensive once users or data exist.
- Every decision carries its reason (in
NEXT.mdor an ADR underdocs/adr/). - Commits are GPG-signed and carry a DCO
Signed-off-bytrailer (git commit -s; a localprepare-commit-msghook adds it automatically). - Pushes pass
tool/check.shfirst, through a localpre-pushhook (in a fresh clone:ln -s ../../tool/check.sh .git/hooks/pre-push). CI runs it again, and a greenmaindeploys itself (see CI and deploys), so an unsigned commit onmainblocks every deploy after it. - Lints: LeanCode's strict set (
leancode_lint), configured once in the rootanalysis_options.yaml, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file, so the server and the app include it; the generated client doesn't. Every// ignore:gets a comment saying why (document_ignores). The server switches off the four rules for Dart v3.13's constructor syntax (use_primary_constructors,use_declaring_parameters,empty_container_bodies,unnecessary_type_name_in_constructor): Serverpod's code generator (checked with v4.0.3) can't parse that syntax in endpoint files yet.
App architecture
- Feature-first. App:
lib/src/core/holds infrastructure shared by features,lib/src/feature/<name>/everything a user would name, andlib/src/app/puts the features together (the person's app and its router, the only code that knows them all). Server:lib/src/<feature>/holds that feature's.spy.yamlmodels and endpoints. Code moves intocore/only once a second feature needs it. Why: a feature is added, changed or removed in one place. - Four layers per app feature, dependencies pointing strictly downward:
widget/(UI; reads bloc state, sends events) →bloc/(UI state; orchestrates repositories) →data/(repositories and gateways) →model/(immutable domain types; pure Dart, no Flutter, no I/O). A widget never calls a gateway; a model never imports a bloc. - Generated protocol classes are not domain models. They carry the envelope (
payloadas bytes);model/holds the decoded content. Mapping between them happens indata/. - Import
halkyon_learn_clientwith a prefix (as protocol) or ashowlist. The generator emits a<Model>Repositoryfor every table, and its names clash with the app's own repositories. - Gateway / repository. A gateway is a thin boundary to one backend (e.g. the Serverpod client's endpoints) behind an
abstract interface class. A repository is what features use: it composes gateways and holds the logic. Blocs depend on repositories, never on gateways or the raw client. Why: gateways are the seams tests fake, and adding the offline layer later becomes a new local gateway, not a rewrite of every feature. - Manual dependency injection. One
Dependenciesobject (lib/src/core/dependencies/) is the composition root, built at startup (Dependencies.create()) and exposed through theDependenciesScopeInheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's globalclientmoved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic. - One graph per context. The dependency graph belongs to one instance and one signed-in person; when either changes, a
KeyedSubtreedisposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.- App-wide vs. per person.
Dependenciesholds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) isPersonDependencies, opened byPersonScopeinsideSessionGate's keyed subtree (Dependencies.openPerson) and disposed with it; widgets reach it throughPersonScope.of(context). - Every person has an app of their own (
PersonApp,lib/src/app/), whichSessionGatebuilds inside the keyed subtree: aMaterialApp.routerwith its ownGoRouter, andPersonScopearound the router's navigator (MaterialApp.builder). So every screen and dialog is inside the person's scope and reads their repositories fromPersonScope.of(context)wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in,SessionGateshows a plainMaterialApp: the spinner, or the failure with a way to try again.
- App-wide vs. per person.
- Material's
Cardwidget clashes with the domain'sCard: files that need both importmaterial_uiwithhide Card. - State:
flutter_bloc, uniformly. Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one globalBlocObserver. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point. - Value equality comes from
equatable, mixed in (with Equatable), for models and states alike;leancode_lint'smissing_equatable_propscatches a field left out ofprops. - Rich text is
RichContent(lib/src/core/content/): the editor's document JSON plus aformatVersion, pure Dart, so models and the server can use it.lib/src/core/editor/converts it to and from the editor'sDocumentand holds the one editing widget (RichContentEditor) and the one read-only renderer (RichContentView). - Time comes from
clock.now()(package:clock), neverDateTime.now(), in server and app. Why: scheduling, streaks and expiry are deterministic under test (withClock,fakeAsync), and because Serverpod itself readsclock.now()(e.g. session expiry),withClockcontrols its behaviour too. - UTC for storing and sending, local time for display and day boundaries. Serverpod sends
DateTimeas UTC and stores it intimestamp without time zonecolumns. Compare and compute in UTC; call.toLocal()only to display. "Due today", streaks and daily goals use the learner's local calendar day, computed on the client; the server never needs day boundaries. - Build configuration comes from
--dart-define. The client holds no secrets, ever. - Material comes from
package:material_ui(Cupertino frompackage:cupertino_ui), never frompackage:flutter/material.dart, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations throughMaterialUiCompatibilityBridgeinMaterialApp.builder; remove it once they have moved. - Navigation is
go_router: the routes inlib/src/app/app_router.dart, their paths incore/router/route_paths.dart. Four tabs (Home, Library, Explore, Profile) are the branches of aStatefulShellRoute.indexedStack, each keeping its own stack of screens;AppShellshows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached withcontext.go(a studyset's screen in the library); what needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached withcontext.push. Explore stays empty until something is shared.- Routes carry ids, never objects. A screen finds its item in the repository's live list with
FoundById(core/widget/): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- Routes carry ids, never objects. A screen finds its item in the repository's live list with
- The look is one theme (
core/theme/):appTheme(brightness)with hand-written light and dark colour schemes around the brand's cobalt and copper;Theme.of(context).extras(ThemeExtras) for the colours Material has no role for (states, the four FSRS ratings);PaletteColorfor the 20 colours a person can give content.theme_test.dartholds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (context.windowSize,core/layout/), and running text stays withinreadableContentMaxWidth. Icons are Lucide (lucide_icons_flutter); the Material icons left in older screens go when those are restyled. Shared widgets are incore/widget/(SectionCard,AppLoadingIndicator,confirmDestructiveAction,FoundById). - Text comes from
AppLocalizations(Flutter's gen-l10n,lib/l10n/app_en.arb; English only for now): no string a person reads is written into a widget, except in older screens until they are restyled. The generatedlib/l10n/app_localizations*.dartare committed and never edited, analysed or formatted by hand.flutter pub getwrites them anew, so CI fails when they don't match the.arbfile.
Testing
- By layer: models with plain unit tests; repositories against fake gateways; blocs by asserting the emitted state sequence; widgets pumped in isolation, presentation widgets fed with ready-made data.
- Server endpoints are tested for real against the embedded Postgres (
withServerpod,dart testin the server package), including the denial tests. - Test dependencies come from a test factory,
testDependencies(...)intest/support/test_dependencies.dart, filling in fakes by default (a factory inlib/couldn't reach the fakes intest/), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code. - Shared fakes live in
test/support/; fakes pre-stub their streams so an incidental subscription doesn't fail the test. - Editor in widget tests: host
AppFlowyEditorin a scrollable body with a fixed-height slot (an over-constrainedExpandedhangs the test), andpump(const Duration(seconds: 1))after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests. - No
Stream.firston a repository's stream in code that widget tests run:firstwaits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository'sall()instead. - Text inside the editor's views (
RichContentView,RichContentEditor) is rich text: find it withfindRichText: true. - Layout-sensitive widget tests set
tester.view.physicalSizeexplicitly, so a responsive branch isn't missed for the wrong reason. - Sync tests run against a running server (ADR 0001, rule 9: the client's and the server's sync engines can't share one process):
halkyon_learn_flutter/test/sync/, taggedrequires-server, skipped bytool/check.sh; run them withflutter test -t requires-serverwhileserverpod startruns. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a row that must not exist) is checked with SQL against the development database, through the embedded PostgreSQL's Unix socket where there is one. An honest device only syncs the spaces the server announces, so a forged write never leaves it; the server's own check against modified clients (_assertCanMergeInboundSpacein the sync engine) has no test yet. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (notestWidgets), which would replace real HTTP with a mock. - Widget tests of screens start the whole app:
pumpApp(tester, testDependencies(...), location: …)(test/support/pump_app.dart) pumpsSessionGatewith the realPersonApp, opened at the screen's path, and settles oncePersonScopehas opened the person's data. From there a test moves as a person would:openTab(the tab's label alone may also be a screen's title), taps,pageBack. Data goes into the fake gateways before pumping; they work in memory (FakeStudysetGateway, withfailReadsfor the error path), and a repository of the test's own on the same fake gateway writes what the app then reads.pumpAndSettlenever ends while a spinner shows (AppLoadingIndicatoranimates without end): pump frames instead where one is expected. A widget pumped on its own that readsAppLocalizationsneedsAppLocalizations.localizationsDelegatesin itsMaterialApp.
CI and deploys
- CI (
.forgejo/workflows/ci.yaml) runstool/check.shon every push, on the Forgejo runner on sahaquiel (deploy/runner/). Jobs run directly on the host as the unprivileged userrunner, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no/home, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it.runnerhas user ID 61000 and must never share one with a container's processes (PostgreSQL and the server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (Sandbox) fails if any of this leaks. Flutter is pinned in the workflow (FLUTTER_VERSION): move it together with the local one. The server tests there get throwaway passwords from the environment (SERVERPOD_…, new for every job), sincepasswords.yamlis never committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI also runsserverpod generateand fails if the committed generated code differs from its output. CI runs on pushes only: a pull request from a fork would run its code on the production server. - A green
maindeploys learn.halkyon.eu throughdeploy/redeploy.sh, the only command the runner's key may run asdeploy(pinned inauthorized_keys, from sahaquiel itself only). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key indeploy/allowed-signers(the deployed copy decides), after dumping the database (the last 10 dumps stay in~deploy/backups), applies the migrations in a run of their own before switching (--role=maintenance --apply-migrations: a failing migration stops the deploy while the old server keeps serving, so migrations must keep the old server working until the switch), and fails unless the new server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand:ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>. Neverdocker compose down -vthere.
Rules the code relies on
- Authentication: server-side sessions (
ServerSideSessionsConfigFromPasswords), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too. - Identity:
AuthUseris the person; identity providers (anonymous, GitHub) attach to it. Content referencesAuthUser, never a provider account.- GitHub keeps only the numeric GitHub ID: our own slim login (
lib/src/auth/github.dart) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords holdgithubClientIdandgithubClientSecret(in productionGITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETindeploy/.env); the app asksinstance.info(). - Linking keeps the person (
GitHubUpgradeEndpoint,requireLogin): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. Signing in (GitHubSignInEndpoint) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals areGitHubSignInRefusedExceptionwith a reason. - Switching devices' person (the app's
switchToGitHub): swap the session directly, never through "signed out" (which would start another anonymous account), then end the anonymous session left behind. - On Linux and Windows the browser half is the app's own (
LoopbackWebAuth,feature/auth/data/): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), a change of person, or 5 minutes, also while another tab is open; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- GitHub keeps only the numeric GitHub ID: our own slim login (
- Authorization lives in endpoint code; Serverpod has no row-level security. Load content filtered by the session's
authUserId, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require theidentifiedscope, declared viarequiredScopes, never checked ad hoc.- The reference is
DevicesEndpoint(halkyon_learn_server/lib/src/auth/) with its tests: the owner check sits in the same query (WHERE "id" = $1 AND "authUserId" = $2), zero rows meansNotFoundException(shared,lib/src/common/), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment. - Serverpod's own helpers aren't guarded:
ServerSideSessions.revokeSessiondeletes by id alone, andlistSessionswithoutauthUserIdreturns everyone's sessions. Never pass them anything a client sent. - Granting a scope goes through a function like
grantIdentified, which updates the person and their session rows.
- The reference is
- Content envelope: synced content tables carry
keyIdandnonce(nullable;null= plaintext, the only value, ADR 0002 rule 6) andpayload(ByteData): UTF-8 JSON with aschemaVersion, encoded and decoded by one codec per feature (e.g.studyset_payload.dart) on top ofcore/content/payload.dart, which also holds the blankedredactedPayload. Content fields live insidepayload: 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 theOfflineSyncDatabaseSession, names the columns of every update, and callsSyncService.requestSync()after every write; the repository re-reads after its own writes and when a sync merged changes (remoteChanges).SyncServicesyncs 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, ADR 0003; the reference is
CardRevision): a revision is never updated except to blank it, andparentslists 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. - Reviews are an append-only log (
ReviewLog: card, rating,reviewedAtin UTC,durationMs). A card's schedule is never stored:scheduleOf(feature/study/model/card_schedule.dart) replays its reviews in time order with FSRS-6 (fsrs, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. The study queue (study_queue.dart): due cards earliest first, then new cards in studyset order, 20 per studyset and local day. Deleting a card deletes its reviews. - 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. - Tree, tags, modes, settings and files (ADR 0004): a studyset's
parentand siblingpositionform the tree, and studysets in a cycle or without their parent show at the top level; a card's tags arecard_tagrows, one per assignment, never updated, and a duplicate counts once;review_log.modeisreview,reverse(its own schedule) orcram(never moves a schedule), and a schedule replays the reviews of one mode;person_settingsis meant as one row per person, and extra rows fold into the one with the smallest id; anassetrow stands for a file in private cloud storage, found by its hash. Never derive a row's id from a person or anything else guessable: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync. - Every client migration keeps a device's rows (
test/core/sync/device_migration_test.dartreplays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one. - 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 samecreate-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.
- Content people edit is append-only revisions (ADR 0001 rule 2, ADR 0003; the reference is
- Serverpod's version moves together with
serverpod_offline_sync, which pins Serverpod exactly: the rootpubspec.yamloverrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (SERVERPOD_VERSIONin.forgejo/workflows/ci.yaml), after reading the sync package's changelog. - The source-code link: both licences require offering the source of what an instance runs to the people who use it.
instance.info().sourceCodeUrlnames it (the operator'sSOURCE_URL, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint. - Deleting a person goes through
deletePerson(lib/src/auth/account.dart), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities.AccountEndpoint.deletedeletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - Pruning:
PruningFutureCalldeletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first. - One person's data per device:
PersonFolderswipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close. - Session logs are not persisted (
persistentEnabled: falsein every run mode) for privacy. Don't re-enable them. - Secrets live in
halkyon_learn_server/config/passwords.yaml(git-ignored). Never changeserverSideSessionKeyHashPepper: it would sign everyone out, and anonymous accounts can't sign back in. - Linux builds need
libwebkit2gtk-4.1-devandlibsecret-1-dev. If a Linux build tries to install to/usr/local, the CMake cache is stale: deletebuild/linux.