# Decisions Append-only. Never edit or delete an entry. Supersede it with a new one that links back. 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: 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. **Decision:** - `packages/db/src/dialect.ts` is one Kysely `Dialect` whose driver wraps `new SQL(url)`, choosing Kysely's SQLite or Postgres adapter, compiler and introspector by URL scheme. - Postgres connections come from `sql.reserve()`. Bun's SQLite adapter does not support `reserve()`, so SQLite uses the single `SQL` instance as its only connection, serialised by a mutex so transactions never interleave. - Transactions are plain `begin`/`commit`/`rollback` on the held connection. **Alternatives:** - Separate dialects over `bun:sqlite` and `Bun.sql`: two drivers to maintain for the same job. - Community Bun dialect packages: an extra dependency, and none covers both databases. **Consequences:** SQLite is effectively single-connection (fine for a self-hosted app). Savepoints and real streaming are not implemented; `streamQuery` returns the whole result as one chunk. ## 2026-10-30: JSON columns are stored as text on both dialects **Status:** accepted **Context:** Spec §11 calls for `jsonb`. SQLite has no JSON type, so `jsonb` on Postgres would need per-dialect read/write code in every service that touches a JSON column. **Decision:** JSON columns are `text` everywhere, written with `toJson()` and read with `fromJson()` from `@sttracker/db/json`. `fromJson` returns a caller-supplied fallback for null or invalid text. **Alternatives:** `jsonb` on Postgres with per-dialect read/write code. **Consequences:** No JSON operators or indexes in SQL; filtering on JSON content happens in app code. Can be superseded later by a migration to `jsonb` if a query needs it. ## 2026-10-30: UUIDv7 instead of ULID for IDs **Status:** accepted **Context:** Spec and `AGENTS.md` say IDs are ULIDs generated in app code. Bun has `Bun.randomUUIDv7()` built in. **Decision:** `newId()` in `@sttracker/db/ids` returns `Bun.randomUUIDv7()`. IDs are stored as `text`. **Alternatives:** ULID, which needs a dependency or hand-written encoder. **Consequences:** IDs are equally time-sortable as strings with no extra dependency. They are 36 characters instead of 26. Where `AGENTS.md` or the spec say "ULID", read "UUIDv7". ## 2026-10-30: Product design decisions recorded in spec 001 **Status:** accepted **Context:** Brainstorming with the owner produced the v1 design. **Decision:** See `docs/specs/001-sttracker-v1-design.md` §13 for the designer's calls. Owner decisions: - Phase 1 is read-only. - Write-back is opt-in per connection. - Conflicts between sources go to the Changes inbox. - Backloggd waits for its official export. - The app UI is Hardcover-style; the public page is scrapbook + river. **Alternatives:** Recorded in the spec. **Consequences:** Phase 1 has no `push` implementations. ## 2026-10-30: Bun workspaces monorepo with Kysely for both databases **Status:** accepted **Context:** The owner requires Bun with SQLite and Postgres options. **Decision:** - Bun workspaces (`apps/*`, `packages/*`, `plugins/*`). - Kysely is the single query layer, with portable migrations. - SQLite runs through `bun:sqlite` and Postgres through `Bun.sql`. **Alternatives:** - Drizzle needs a separate schema per dialect. - Prisma does not fit Bun-native drivers. - Raw SQL would duplicate every query per dialect. **Consequences:** We may need a small custom Kysely dialect for `Bun.sql` or `bun:sqlite`. Every DB test runs on both databases. ## 2026-10-30: Adopt AGENTS.md as single source of truth **Status:** accepted **Context:** Agents had no durable project context and no consistent workflow between sessions. **Decision:** - `AGENTS.md` is canonical; `CLAUDE.md` and `GEMINI.md` are pointers. - Specs are permanent in `docs/specs/`. - Plans live in `docs/plans/` only while work is in progress and are removed before the merge request. - Both locations override the superpowers default paths. **Alternatives:** - Per-tool rule files: they drift and create synchronisation debt. - No rules file: agents re-explore the repo every session, which is measurably slower and costs more tokens. **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`.