halkyon/AGENTS.md
Tizian 「ティツィアーン」 9387e0a827
feat(app): studysets, offline first and synced on triggers
The first synced feature. Every person gets their own local database (<app support>/people/<authUserId>/sync.sqlite): PersonScope opens it inside SessionGate's per-person subtree through Dependencies.openPerson, together with its sync and the person's repositories (PersonDependencies), and closes it when the person changes. SessionGate then also closes pushed screens, which live above the person's subtree; a screen's route() hands its bloc the repository it needs.

SyncStudysetGateway reads and writes the studyset table through the sync session, names the payload column in every update and asks for a sync after every write. StudysetRepository encodes and decodes the payload (studyset_payload.dart: UTF-8 JSON with a schema version; a newer schema or garbage is skipped with a log), re-reads after its own writes and after merges, and blanks the payload before deleting, since the engine keeps deleted rows until the space is deleted (ADR 0002, rule 2). A failed read reaches the list as an error, and the next change reads again. SyncService syncs on triggers: at start, 2 s after the last local write, on resume and every 5 minutes, one sync at a time, and lets a running sync finish before the database closes.

Screens: the list, now the home screen in place of the editor playground, creates studysets through a dialog; a studyset's screen edits its title and description, saves on request and asks before deleting.

Tests: the payload codec, the repository against a fake gateway (including a failed read and rows from a newer app), the sync triggers under fakeAsync, both blocs' sequences, and the screens, including that cancelling the delete keeps the studyset. The session tests now settle until the person's data is open. AGENTS.md describes the per-person graph, synced data and the envelope as it now is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 20:38:14 +02:00

16 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_migration and apply_migrations for database (after you change data models).
  • create_repair_migration if the database has drifted out of sync with the migrations.
  • tail_server_logs to read logs from the server.
  • tail_flutter_logs to read the raw stdout/stderr of the Flutter app.
  • hot_reload / hot_restart to reload or restart the server and the Flutter app. ALWAYS call hot_restart after doing changes in the Flutter app that may not work with normal hot reload (which is automatically applied).
  • spawn_flutter_app to start a Flutter app declared under serverpod: flutter_apps: in the server pubspec.yaml.
  • get_flutter_app_dtd (Dart tooling daemon) for connecting to the app through the dart MCP.

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.

Only when the server cannot be started at all, fall back to the CLI in the server package:

  • serverpod generate to regenerate the client and the generated server code.
  • serverpod create-migration after changing a model with a table (add --force for destructive changes). It only writes the migration; serverpod start applies 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.

Checklist after doing changes, in this order:

  • dart format (CLI) on the files you changed
  • create_migration and apply_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 serverpod MCP hot_restart if required (hot reload is done automatically). Will also hot restart Flutter app
  • Check serverpod MCP tail_server_logs and tail_flutter_logs for any issues.

If the user asks you to test the app:

  1. Use get_flutter_app_dtd (serverpod MCP) to get the Flutter app's DTD
  2. Pass the DTD to connect_dart_tooling_daemon (dart MCP) to connect to the app
  3. Use flutter_driver (dart MCP) 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, ID app.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.md exists 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.md or an ADR under docs/adr/).
  • Commits are GPG-signed and carry a DCO Signed-off-by trailer (git commit -s; a local prepare-commit-msg hook adds it automatically).
  • Pushes pass tool/check.sh first, through a local pre-push hook (in a fresh clone: ln -s ../../tool/check.sh .git/hooks/pre-push).
  • Lints: LeanCode's strict set (leancode_lint), configured once in the root analysis_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 v4.0.2's code generator can't parse that syntax yet.

App architecture

  • Feature-first. App: lib/src/core/ holds infrastructure shared by features, lib/src/feature/<name>/ everything a user would name. Server: lib/src/<feature>/ holds that feature's .spy.yaml models and endpoints. Code moves into core/ 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 (payload as bytes); model/ holds the decoded content. Mapping between them happens in data/.
  • Import halkyon_learn_client with a prefix (as protocol) or a show list. The generator emits a <Model>Repository for 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 Dependencies object (lib/src/core/dependencies/) is the composition root, built at startup (Dependencies.create()) and exposed through the DependenciesScope InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global client moved 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 KeyedSubtree disposes 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. Dependencies holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is PersonDependencies, opened by PersonScope inside SessionGate's keyed subtree (Dependencies.openPerson) and disposed with it; widgets reach it through PersonScope.of(context).
    • Pushed screens live above that subtree (the Navigator belongs to MaterialApp). A screen's static route(context, …) reads what it needs from PersonScope.of(context) when it is pushed and hands it to the screen's bloc, and SessionGate closes every pushed screen when the person changes.
  • 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 global BlocObserver. 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's missing_equatable_props catches a field left out of props.
  • Rich text is RichContent (lib/src/core/content/): the editor's document JSON plus a formatVersion, pure Dart, so models and the server can use it. lib/src/core/editor/ converts it to and from the editor's Document and holds the one editing widget (RichContentEditor) and the one read-only renderer (RichContentView).
  • Time comes from clock.now() (package:clock), never DateTime.now(), in server and app. Why: scheduling, streaks and expiry are deterministic under test (withClock, fakeAsync), and because Serverpod itself reads clock.now() (e.g. session expiry), withClock controls its behaviour too.
  • UTC for storing and sending, local time for display and day boundaries. Serverpod sends DateTime as UTC and stores it in timestamp without time zone columns. 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 from package:cupertino_ui), never from package: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 through MaterialUiCompatibilityBridge in MaterialApp.builder; remove it once they have moved.

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 test in the server package), including the denial tests.
  • Test dependencies come from a test factory, testDependencies(...) in test/support/test_dependencies.dart, filling in fakes by default (a factory in lib/ couldn't reach the fakes in test/), 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 AppFlowyEditor in a scrollable body with a fixed-height slot (an over-constrained Expanded hangs the test), and pump(const Duration(seconds: 1)) after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
  • Layout-sensitive widget tests set tester.view.physicalSize explicitly, so a responsive branch isn't missed for the wrong reason.
  • Widget tests of the signed-in app pump SessionGate with testDependencies(...) and pumpAndSettle() until PersonScope has opened the person's data; the fake gateways work in memory (FakeStudysetGateway, with failReads for the error path).

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: AuthUser is the person; identity providers (anonymous, GitHub) attach to it. Content references AuthUser, never a provider account.
  • 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 the identified scope, declared via requiredScopes, 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 means NotFoundException (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.revokeSession deletes by id alone, and listSessions without authUserId returns 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.
  • 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 in one place per feature (e.g. studyset_payload.dart). 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.
  • Serverpod's version moves together with serverpod_offline_sync, which pins Serverpod exactly: the root pubspec.yaml overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides, after reading the sync package's changelog.
  • Session logs are not persisted (persistentEnabled: false in every run mode) for privacy. Don't re-enable them.
  • Secrets live in halkyon_learn_server/config/passwords.yaml (git-ignored). Never change serverSideSessionKeyHashPepper: it would sign everyone out, and anonymous accounts can't sign back in.
  • Linux builds need libwebkit2gtk-4.1-dev and libsecret-1-dev. If a Linux build tries to install to /usr/local, the CMake cache is stale: delete build/linux.