halkyon/AGENTS.md

160 lines
44 KiB
Markdown
Raw Normal View History

# 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`.
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 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.
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 (the server's `test/integration/test_tools/` is generated too, so format test files one by one, never the whole `test/` folder); 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.
feat(app): flashcards on append-only revisions A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side. CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews. Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 14:41:44 +02:00
After a migration that adds or changes synced tables, restart `serverpod start` once it has applied it. The sync engine (checked with `serverpod_offline_sync` v0.0.8) reconciles its registry of synced tables when a server first uses the database, and if that changed the registry, every later session of that server rebuilds the projections of every space, one transaction each, until the server stops ([serverpod_offline_sync#150](https://github.com/marcelomendoncasoares/serverpod_offline_sync/issues/150), open). With the many people the tests leave in the development database, syncs then take seconds and the sync tests time out. The restarted server finds the registry as it left it. Deploys aren't affected: their maintenance run applies the migrations and reconciles before it exits (it reads the runtime settings through the engine's wrapper), so the server started after it finds nothing changed. Drop this note once #150 is fixed.
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).
ci: check every push on sahaquiel and deploy a green main Pushes to main were deployed by hand. Now Forgejo Actions runs tool/check.sh on every push, and a green main deploys learn.halkyon.eu by itself. The runner (forgejo-runner v13.1.0) runs on sahaquiel as the unprivileged user `runner`, with jobs directly on the host: containers would take Docker access, which is root on the production server, and Forgejo warns that host jobs have no isolation from the runner's user. So its systemd unit (deploy/runner/forgejo-runner.service) provides it: /home and the Docker socket invisible, Docker's container networks unreachable, no capabilities, two cores and 3 GB at most, lowest priority. It is registered to this repository only, with the UUID and token of Forgejo's "Create new runner" (the old `register` is deprecated); the token reaches it as a systemd credential. The configuration (deploy/runner/config.yml) was dry-run against v13.1.0, the unit rates 1.8 in `systemd-analyze security`. CI runs on pushes only: a pull request from a fork would run its code on the production server. The deploy job calls deploy/redeploy.sh as `deploy`, through a key that authorized_keys pins to that script and to connections from sahaquiel itself. The script deploys the commit CI checked while it is still main's tip, as a fast-forward, only if every new commit is signed by a key in deploy/allowed-signers (as the deployed checkout lists them), after dumping the database (the last ten are kept), and fails unless the server answers afterwards; a failure puts the checkout back. Tested against the local stack: refusals of a malformed argument, a stale commit, a forged unsigned commit and a non-fast-forward, a real deploy with its dump, a repeat, and a failed build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 21:49:04 +02:00
- **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`). CI runs it again, and a green `main` deploys itself (see *CI and deploys*), so an unsigned commit on `main` blocks every deploy after it.
- **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's code generator (checked with v4.0.3) can't parse that syntax in endpoint files yet.
## App architecture
feat(app): the UI foundation: theme, tabs and routes, strings 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>
2026-10-01 00:18:31 +02:00
- **Feature-first.** App: `lib/src/core/` holds infrastructure shared by features, `lib/src/feature/<name>/` everything a user would name, and `lib/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.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/`.
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 10:11:24 +02:00
- **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.
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 10:11:24 +02:00
- **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)`.
feat(app): the UI foundation: theme, tabs and routes, strings 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>
2026-10-01 00:18:31 +02:00
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.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, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
feat: PDF sources: read, take notes and cards from pages, deadlines 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>
2026-10-03 14:40:08 +02:00
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one).
feat(app): flashcards on append-only revisions A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side. CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews. Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 14:41:44 +02:00
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide 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 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`).
feat(app): editor blocks: callout, spoiler, formulas, files, video, study button Blocks beyond the editor's own, each built once for editor and view: - callout: eight kinds in theme colours, kind, title and "folded at first" from its header; written in place, left with Enter on its empty last line; never inside another - spoiler: written in plain sight, hidden behind Reveal read-only - formula on its own line, written on its own screen with a preview and snippet buttons, and formulas within a line (Σ in the format bar); set by flutter_math_fork behind MathView, LaTeX's numbered environments read as their aligned forms - image and sound picked from the device, kept as synced files - YouTube video from its link, with its start and a caption: a card until played, nothing fetched before; played in place where a web view exists (youtube_player_iframe, privacy-enhanced), else opened in the browser, behind YoutubeVideos in Dependencies - study button: a studyset, a tag or all cards, with or without its topics, back first; its StudyFocus rides on the study route and narrows that session only - tables, already in the editor, now insertable everywhere An Insert button opens on the format bar, so blocks can be inserted on phones too; the desktop's / menu lists the same blocks, translated. Card faces now dock the format bar while written, so cards get blocks and formatting on phones as well. The blocks are listed in EditorBlocks: the core's, plus the features' through EditorBlocksScope. Blocks of a type the app doesn't know say they need a newer version and stay as they are, so formatVersion stays 1. Their types and keys are in content_blocks.dart, which RichContent reads: a block's text counts as text, and a video or study button isn't blank. The path import now makes formulas real ones. integration_test/youtube_player_test.dart plays a real video through the app's player on a device, which no widget test can: YouTube refuses embeds that don't say where they are embedded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-03 12:20:41 +02:00
- **Blocks** beyond the editor's own (callout, spoiler, formula, image, sound, YouTube video, study button) keep their type and attributes as named in `core/content/content_blocks.dart`, which `RichContent` reads too (their text counts in `plainText`, and a video or study button isn't blank). An `EditorBlocks` lists how each type is built, which can be inserted (the format bar's Insert sheet and the desktop's `/` menu show the same list) and its typing rules; the core's are `coreBlocks`, and a feature's (the study button, `feature/study/`) reach the editors through `EditorBlocksScope`, which `PersonApp` fills, since the core can't know features. One builder serves editor and view: a block asks `editorState.editable`, and read-only shows what it is for (a spoiler hidden until revealed, a formula set, a video's play). A block taken whole mixes in `AtomicBlock` (selection, the remove button); unknown types show "needs a newer version" and stay as they are. New block types keep `formatVersion` 1: raising it would hide whole studysets and paths in older apps.
- **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.
feat(app): the studyset screen with its topics, the tree's own screen A studyset's screen, as decided with the tree (2026-09-30/10-01): - A breadcrumb from the library down to the studyset; its steps go up (replacing the location, so back leads further up), and steps that don't fit fold into a "…" listing them, nearest first. - From expanded windows on, two columns: the description over the topics, the flashcards beside them, Study and Add material in the app bar. Below that, the topics are in a drawer, opened from a bar at the bottom that also holds Study and Add material and slides away while reading onwards; the back button stays where a drawer would have taken its place. - The topics: "All topics" for the whole studyset, then the studysets inside it in their order, each with the study it invites, what it holds and its options (edit, delete), opening on top of this screen; a switch rearranges them by dragging; "New sub-studyset" creates one inside. - Study covers the studyset with everything inside it ("Learn 12" while something is due). Add material offers flashcards for now. - Options: Edit (the sheet, now with the description, edited in the whole window, and for a studyset inside another the colour of the one it is in, which a new one takes at first), Move to… (the tree and the top level, without the studyset itself and what is inside it), Archive or Unarchive with Undo, Delete with Undo, which closes the screen. - The description cut short when long, the whole a tap away; the licence as the last line, linking to its text where that is known. The list's bloc, scoped to a parent, lists its children, works out the studyset's own progress, creates inside it, reorders and moves. The card list leaves adding and studying to the screen. The inline editing of the title and description, and its bloc, are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-01 12:59:20 +02:00
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows 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 with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. What needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
feat(app): the UI foundation: theme, tabs and routes, strings 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>
2026-10-01 00:18:31 +02:00
- **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.
- **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, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds 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 within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star, which needs the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
feat(app): the UI foundation: theme, tabs and routes, strings 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>
2026-10-01 00:18:31 +02:00
- **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 generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` file.
## 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.
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits 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's `all()` instead. For the same reason a repository never keeps a completed future to chain later work on (a `Future.value()` made in `setUp` runs what waits for it in the real zone): a queue of writes starts empty and empties again when idle (`SerialQueue`, `core/async/`), and a cache keeps values, not futures (`GeoMapRepository`). Assets are read for real, which the fake clock doesn't advance: a widget test showing a map gets the maps read beforehand (`loadedMaps(tester)` into `testDependencies(maps: ...)`).
feat(app): flashcards on append-only revisions A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side. CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews. Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 14:41:44 +02:00
- **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`.
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
feat(app): the studyset screen with its topics, the tree's own screen A studyset's screen, as decided with the tree (2026-09-30/10-01): - A breadcrumb from the library down to the studyset; its steps go up (replacing the location, so back leads further up), and steps that don't fit fold into a "…" listing them, nearest first. - From expanded windows on, two columns: the description over the topics, the flashcards beside them, Study and Add material in the app bar. Below that, the topics are in a drawer, opened from a bar at the bottom that also holds Study and Add material and slides away while reading onwards; the back button stays where a drawer would have taken its place. - The topics: "All topics" for the whole studyset, then the studysets inside it in their order, each with the study it invites, what it holds and its options (edit, delete), opening on top of this screen; a switch rearranges them by dragging; "New sub-studyset" creates one inside. - Study covers the studyset with everything inside it ("Learn 12" while something is due). Add material offers flashcards for now. - Options: Edit (the sheet, now with the description, edited in the whole window, and for a studyset inside another the colour of the one it is in, which a new one takes at first), Move to… (the tree and the top level, without the studyset itself and what is inside it), Archive or Unarchive with Undo, Delete with Undo, which closes the screen. - The description cut short when long, the whole a tap away; the licence as the last line, linking to its text where that is known. The list's bloc, scoped to a parent, lists its children, works out the studyset's own progress, creates inside it, reorders and moves. The card list leaves adding and studying to the screen. The inline editing of the title and description, and its bloc, are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-01 12:59:20 +02:00
- **A menu item acts in the frame after its menu closes** (`MenuAnchor`): pump twice after tapping one before expecting what it does.
- **Server tests of what writes synced rows** run on the sync engine's database, as the server does (the generated test tools build the pod without it): pass the interceptor to `withServerpod`, call `initializeOfflineSync` in `setUp`, and write as a person through `transactionForUser`. Its writes take transactions of their own, so these tests turn rollback off and delete what they made (`test/integration/asset/asset_test.dart`).
- **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/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs. 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 (`_assertCanMergeInboundSpace` in 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 (no `testWidgets`), which would replace real HTTP with a mock.
feat(app): the UI foundation: theme, tabs and routes, strings 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>
2026-10-01 00:18:31 +02:00
- **Widget tests of screens start the whole app**: `pumpApp(tester, testDependencies(...), location: …)` (`test/support/pump_app.dart`) pumps `SessionGate` with the real `PersonApp`, opened at the screen's path, and settles once `PersonScope` has 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`, with `failReads` for the error path), and a repository of the test's own on the same fake gateway writes what the app then reads. `pumpAndSettle` never ends while a spinner shows (`AppLoadingIndicator` animates without end): pump frames instead where one is expected. A widget pumped on its own that reads `AppLocalizations` needs `AppLocalizations.localizationsDelegates` in its `MaterialApp`.
ci: check every push on sahaquiel and deploy a green main Pushes to main were deployed by hand. Now Forgejo Actions runs tool/check.sh on every push, and a green main deploys learn.halkyon.eu by itself. The runner (forgejo-runner v13.1.0) runs on sahaquiel as the unprivileged user `runner`, with jobs directly on the host: containers would take Docker access, which is root on the production server, and Forgejo warns that host jobs have no isolation from the runner's user. So its systemd unit (deploy/runner/forgejo-runner.service) provides it: /home and the Docker socket invisible, Docker's container networks unreachable, no capabilities, two cores and 3 GB at most, lowest priority. It is registered to this repository only, with the UUID and token of Forgejo's "Create new runner" (the old `register` is deprecated); the token reaches it as a systemd credential. The configuration (deploy/runner/config.yml) was dry-run against v13.1.0, the unit rates 1.8 in `systemd-analyze security`. CI runs on pushes only: a pull request from a fork would run its code on the production server. The deploy job calls deploy/redeploy.sh as `deploy`, through a key that authorized_keys pins to that script and to connections from sahaquiel itself. The script deploys the commit CI checked while it is still main's tip, as a fast-forward, only if every new commit is signed by a key in deploy/allowed-signers (as the deployed checkout lists them), after dumping the database (the last ten are kept), and fails unless the server answers afterwards; a failure puts the checkout back. Tested against the local stack: refusals of a malformed argument, a stale commit, a forged unsigned commit and a non-fast-forward, a real deploy with its dump, a repeat, and a failed build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 21:49:04 +02:00
## CI and deploys
- **CI** (`.forgejo/workflows/ci.yaml`) runs `tool/check.sh` on every push, on the Forgejo runner on sahaquiel (`deploy/runner/`). Jobs run directly on the host as the unprivileged user `runner`, 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. `runner` has 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), since `passwords.yaml` is never committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI also runs `serverpod generate` and 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 `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command the runner's key may run as `deploy` (pinned in `authorized_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 in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 3 dumps stay in `~deploy/backups`: the way back from a deploy, not 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>`. Never `docker compose down -v` there.
- **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, and restic stores it in a Hetzner Storage Box (24 hourly, 7 daily, 4 weekly, 3 monthly); the box's own snapshots protect it from a taken-over server. A restore takes the instance back up to an hour, and people who started since lose what their device held: their app no longer finds its session, starts a new anonymous account and deletes the old folder. So a change that makes devices lose their session, or keeps deleted people's data longer, changes what a restore costs; say so in `deploy/backups.md`.
- **Releases** are the project's, not an instance's: the release, the Snap Store listing and the website point to learn.halkyon.app; the builds connect to learn.halkyon.eu, the project's instance, until the app lets people choose one. They come from a developer's laptop, never from CI: `tool/release.sh vX.Y.Z` on `main` as pushed, with the tag signed by a key in `deploy/allowed-signers` and pushed, and the build number raised. It builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; creates the release on code.halkyon.app through Forgejo's API; and uploads the snap to the Store's edge channel. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the script releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere.
ci: check every push on sahaquiel and deploy a green main Pushes to main were deployed by hand. Now Forgejo Actions runs tool/check.sh on every push, and a green main deploys learn.halkyon.eu by itself. The runner (forgejo-runner v13.1.0) runs on sahaquiel as the unprivileged user `runner`, with jobs directly on the host: containers would take Docker access, which is root on the production server, and Forgejo warns that host jobs have no isolation from the runner's user. So its systemd unit (deploy/runner/forgejo-runner.service) provides it: /home and the Docker socket invisible, Docker's container networks unreachable, no capabilities, two cores and 3 GB at most, lowest priority. It is registered to this repository only, with the UUID and token of Forgejo's "Create new runner" (the old `register` is deprecated); the token reaches it as a systemd credential. The configuration (deploy/runner/config.yml) was dry-run against v13.1.0, the unit rates 1.8 in `systemd-analyze security`. CI runs on pushes only: a pull request from a fork would run its code on the production server. The deploy job calls deploy/redeploy.sh as `deploy`, through a key that authorized_keys pins to that script and to connections from sahaquiel itself. The script deploys the commit CI checked while it is still main's tip, as a fast-forward, only if every new commit is signed by a key in deploy/allowed-signers (as the deployed checkout lists them), after dumping the database (the last ten are kept), and fails unless the server answers afterwards; a failure puts the checkout back. Tested against the local stack: refusals of a malformed argument, a stale commit, a forged unsigned commit and a non-fast-forward, a real deploy with its dump, a repeat, and a failed build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 21:49:04 +02:00
## 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.
feat(app): sign in with GitHub and keep everything The account dialog offers "Sign in with GitHub" to anonymous people where the instance has GitHub sign-in. It links the GitHub account to the person, who keeps all their studysets. If the account already belongs to another person (typically their account from another device), the app switches to that person right away when this device holds nothing, and otherwise asks first: switch and leave this device's studysets behind, or stay anonymous. ServerpodAuthGateway uses Serverpod's GitHubSignInService for the browser part (PKCE), with the system browser and a loopback address on Linux and Windows (http://127.0.0.1:43823/github-callback) rather than an embedded webview, and app.halkyon.learn://github-callback elsewhere; Android gets the callback activity. After linking, the device's copy of the session gets the identified scope. A switch signs in through a client without a session, swaps sessions directly (never through "signed out", which would start another anonymous account), then ends the anonymous session left behind. GitHubSignInBloc holds the flow; tests cover every branch (GitHub sign-in off, linked, cancelled on GitHub, switching right away, asking with staying anonymous as the refusal, switching once confirmed, a failure), and the widget tests the dialog. StudysetRepository gains all(), a one-off read: Stream.first on its stream never completes under the widget tests' fake clock, because the broadcast subscription's cancel future belongs to the root zone; AGENTS.md records it along with the GitHub pattern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 13:35:51 +02:00
- **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 hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.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 are `GitHubSignInRefusedException` with 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), choosing another tab, a change of person, or 5 minutes; 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."
- **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.
feat(server): the authorization pattern, with "Your devices" 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>
2026-09-30 09:36:04 +02:00
- **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.
feat(app): flashcards on append-only revisions A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side. CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews. Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 14:41:44 +02:00
- **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 by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. 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. Since blanking can't be undone, an Undo comes before it: `StudysetDeletions`, opened with the person's data, keeps every studyset deletion that can still be undone; lists leave out what it reports as pending, and once the time to undo is over it deletes the whole subtree, what is inside first, each studyset's cards blanked before it. Widgets delete and undo through it directly (`deleteStudysetWithUndo`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. It closes first with the person's data and drops what still waits. Tags differ, being values (a tag is its name, ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. 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, and `parents` lists 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.
feat(app): the studyset screen with its topics, the tree's own screen A studyset's screen, as decided with the tree (2026-09-30/10-01): - A breadcrumb from the library down to the studyset; its steps go up (replacing the location, so back leads further up), and steps that don't fit fold into a "…" listing them, nearest first. - From expanded windows on, two columns: the description over the topics, the flashcards beside them, Study and Add material in the app bar. Below that, the topics are in a drawer, opened from a bar at the bottom that also holds Study and Add material and slides away while reading onwards; the back button stays where a drawer would have taken its place. - The topics: "All topics" for the whole studyset, then the studysets inside it in their order, each with the study it invites, what it holds and its options (edit, delete), opening on top of this screen; a switch rearranges them by dragging; "New sub-studyset" creates one inside. - Study covers the studyset with everything inside it ("Learn 12" while something is due). Add material offers flashcards for now. - Options: Edit (the sheet, now with the description, edited in the whole window, and for a studyset inside another the colour of the one it is in, which a new one takes at first), Move to… (the tree and the top level, without the studyset itself and what is inside it), Archive or Unarchive with Undo, Delete with Undo, which closes the screen. - The description cut short when long, the whole a tap away; the licence as the last line, linking to its text where that is known. The list's bloc, scoped to a parent, lists its children, works out the studyset's own progress, creates inside it, reorders and moves. The card list leaves adding and studying to the screen. The inline editing of the title and description, and its bloc, are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-01 12:59:20 +02:00
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in 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. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, 20 per studyset and local day. The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
feat(app): flashcards on append-only revisions A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side. CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews. Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 14:41:44 +02:00
- **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 `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row 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.
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
feat: PDF sources: read, take notes and cards from pages, deadlines 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>
2026-10-03 14:40:08 +02:00
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3, e.g. `anki`); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`).
feat(server): tree, tags, review modes, settings and assets (ADR 0004) The schema of the features planned for the first release, as one migration: every migration of a synced table locks older app versions out of syncing (ADR 0002, rule 3), which costs nothing before that release. New card types need no migration; they are the next version of the revision payload. - studyset: `parent` (the tree; cycles and orphans show at the top level) and `position` among siblings (fractional index; existing studysets at 'a0'). - tag (its name in the payload) and card_tag, one row per assignment, so concurrent adds and removes merge. - review_log.mode: review, reverse (a schedule of its own) or cram (never moves a schedule); existing reviews are 'review'. - person_settings: study goals now, preferences later; random ids, since an id derived from the person could be claimed by someone else and break the owner's sync. - asset: a file's hash, type, size and name; the file goes into Serverpod's private cloud storage, served through an owner-checked endpoint (to come). Migration 20260930211154224-tree-tags-assets only adds: four tables with their space indexes and foreign keys, three columns with defaults. On devices SQLite rebuilds studyset and review_log to add foreign keys, which is safe only with foreign keys off, as Serverpod's runner does; a new test replays every client migration on a database with a row in each of our synced tables and checks that none is lost (with foreign keys on, it fails as it should). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-09-30 23:27:08 +02:00
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays 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 same `create-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.
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Profile tab and the Settings cog, the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure.
- **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 and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.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().sourceCodeUrl` names it (the operator's `SOURCE_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.delete` deletes 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**: `PruningFutureCall` deletes 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**: `PersonFolders` wipes 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: 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.
feat: PDF sources: read, take notes and cards from pages, deadlines 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>
2026-10-03 14:40:08 +02:00
- **Checks on a device** live in `halkyon_learn_flutter/integration_test/` and run by hand, never in `tool/check.sh` or CI: `flutter test integration_test/<file> -d <device>`. `pdf_engine_test.dart` checks the real PDF engine (on the Linux desktop too), `youtube_player_test.dart` the embedded YouTube player (Android). Never on the emulator from an agent session: it takes the editor down with it.
- **Windows builds need Developer Mode** (pdfrx's build makes symbolic links). PDFium itself comes with the build as a Dart native asset.
- **Linux builds need** `libwebkit2gtk-4.1-dev`, `libsecret-1-dev`, and for sounds (`audioplayers`) `libgstreamer1.0-dev` and `libgstreamer-plugins-base1.0-dev`. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`.