From b76d071d871f446465669201058fb895151d8d5d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 2 Nov 2026 04:55:00 +0100 Subject: [PATCH] wip(docs): reorder DECISIONS newest-first (task 19, unfinished) Co-Authored-By: Claude Opus 5.5 --- docs/DECISIONS.md | 61 ++++++++++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2fb29c6..c42bb55 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,6 +4,19 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin One entry per decision a future maintainer would otherwise have to reverse-engineer. No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number). +## 2026-10-30: Deployment: one image on `oven/bun:1.3`, source run directly, compose with an optional Postgres profile +**Status:** accepted +**Context:** Task 19 packages the server. `Bun.serve` bundles the `apps/web` HTML import at runtime, so there is no build step to ship. +**Decision:** +- The image is `oven/bun:1.3` (tag, not digest) running `bun apps/server/src/main.ts` from source, as the non-root `bun` user. + It installs production dependencies only and copies `apps/web` too, because the server bundles it at startup. +- `/data` is the volume (`DATA_DIR=/data`): SQLite file, attachments, backups, user plugins. It is created and owned by `bun` in the image. +- `docker-compose.yml` has the `sttracker` service (`./data:/data`, `env_file: .env`) and a `postgres:17-alpine` service under + the `postgres` profile with a named volume. SQLite stays the default. +- CI is `.gitea/workflows/ci.yml` (GitHub-compatible syntax): typecheck, then `bun test` against a Postgres service, plus the `AGENTS.md` 150-line guard. +**Alternatives:** A compiled single binary (`bun build --compile`) was rejected: user plugins load from disk at runtime and the web bundle is built on demand. +**Consequences:** Image start time includes bundling the web app. Agent permission files for the new Docker commands are pending the owner (`docs/TODO.md`). + ## 2026-10-30: HTTP API: auth modes, CSRF header, error mapping, actor **Status:** accepted **Context:** Task 16 (`apps/server`). @@ -159,7 +172,7 @@ are the mechanics a maintainer would otherwise reverse-engineer. Ids are remapped (UUIDs inside history `before`/`after` and custom-field value keys too). Rows are inserted directly, so no new history is written. The import validates first (attachment mime/size/sha256, rating 0-100, status, visibility, 2 GB uncompressed cap) and writes all rows, attachments included, in one transaction; errors are - `invalid_attachment`, `corrupt_bundle`, `bundle_too_large`, `conflict`. Works merged away at export time are exported as the tombstone row without `mergedIntoId`. + `invalid_attachment`, `corrupt_bundle`, `bundle_too_large`, `conflict`. Works merged away at export time are exported as the tombstone row with its `mergedIntoId`. - **Backups** write `/backups/'-'>/.zip` per user plus `sttracker.db` (`VACUUM INTO`, SQLite only), are built in `.tmp` then renamed (modes 0700/0600), and prune to the newest `keep` (default 14). **Consequences:** Any change to these files after release needs `v2` and a migration path for old bundles. @@ -208,6 +221,29 @@ Several behaviours are not obvious from the spec and would otherwise have to be **Consequences:** Undoing a move does not move copies or list items back, and hierarchy deeper than three levels is not rolled up (both in `docs/TODO.md`). +## 2026-10-30: Catalogue matching is race-safe through unique keys plus whole-transaction retry +**Status:** accepted +**Context:** Two syncs matching the same new work at once both miss the lookup and both insert. On +Postgres the loser hit the `external_ids (namespace, value)` unique key and crashed the sync; seasons and +episodes had no key at all and were duplicated. +**Decision:** +- Migration `0002_works_position_unique` adds a unique index on `works (parent_id, position)`. NULLs are + distinct on both dialects, so top-level works and position-less children are unaffected. +- `matchWork` re-runs the whole resolve in a fresh transaction on a unique violation (SQLite + "UNIQUE constraint failed", Postgres SQLSTATE 23505), up to 3 attempts. Postgres aborts the transaction + on the error, so a statement-level retry is not possible. +- Adding missing external IDs to an existing work uses `ON CONFLICT DO NOTHING`. +- An ID match only counts if the work is shared or owned by the matching user, and private works can + never carry external IDs (`createWork` refuses; global merges accept only shared works). +- A global merge repoints children of `from` to `to` unless `to` already has a child at that position. + +**Alternatives:** +- Advisory or table locks: dialect-specific, and SQLite already serialises writes. +- Upserts throughout `createWork`: would hide genuine conflicts and need per-dialect `RETURNING` handling. + +**Consequences:** Parents resolved without IDs or position (title/year reuse) still have no unique key +and can be duplicated under concurrency; tracked in `docs/TODO.md`. + ## 2026-10-30: A single Kysely dialect over Bun.SQL for SQLite and Postgres **Status:** accepted **Context:** Kysely ships no driver for Bun. `Bun.SQL` speaks both `sqlite://` and `postgres://` behind one API. @@ -285,26 +321,3 @@ instead of 26. Where `AGENTS.md` or the spec say "ULID", read "UUIDv7". **Consequences:** Rule changes happen in one file, and the pointer files must stay thin. Plans are working artefacts: kept after merge, they go stale and contradict the code, so the spec is the record. - -## 2026-10-30: Catalogue matching is race-safe through unique keys plus whole-transaction retry -**Status:** accepted -**Context:** Two syncs matching the same new work at once both miss the lookup and both insert. On -Postgres the loser hit the `external_ids (namespace, value)` unique key and crashed the sync; seasons and -episodes had no key at all and were duplicated. -**Decision:** -- Migration `0002_works_position_unique` adds a unique index on `works (parent_id, position)`. NULLs are - distinct on both dialects, so top-level works and position-less children are unaffected. -- `matchWork` re-runs the whole resolve in a fresh transaction on a unique violation (SQLite - "UNIQUE constraint failed", Postgres SQLSTATE 23505), up to 3 attempts. Postgres aborts the transaction - on the error, so a statement-level retry is not possible. -- Adding missing external IDs to an existing work uses `ON CONFLICT DO NOTHING`. -- An ID match only counts if the work is shared or owned by the matching user, and private works can - never carry external IDs (`createWork` refuses; global merges accept only shared works). -- A global merge repoints children of `from` to `to` unless `to` already has a child at that position. - -**Alternatives:** -- Advisory or table locks: dialect-specific, and SQLite already serialises writes. -- Upserts throughout `createWork`: would hide genuine conflicts and need per-dialect `RETURNING` handling. - -**Consequences:** Parents resolved without IDs or position (title/year reuse) still have no unique key -and can be duplicated under concurrency; tracked in `docs/TODO.md`.