wip(docs): reorder DECISIONS newest-first (task 19, unfinished)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-11-02 04:55:00 +01:00
co-authored by Claude Opus 5.5
parent 0a540208e1
commit b76d071d87
+37 -24
View File
@@ -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 `<dataDir>/backups/<ISO with ':'->'-'>/<username>.zip` per user plus `sttracker.db` (`VACUUM INTO`, SQLite only),
are built in `<stamp>.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`.