- migration 0002: unique index on works (parent_id, position) - matchWork retries the whole transaction on a unique violation (3 attempts) - addMissingExternalIds uses ON CONFLICT DO NOTHING - ID matches only return shared works or the user's own; private works cannot carry external ids - adminMergeWorks requires shared works and repoints non-colliding children - draft is computed for the aliased work id Co-Authored-By: Claude Opus 5.5 <[email protected]>
6.2 KiB
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.tsis one KyselyDialectwhose driver wrapsnew 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 supportreserve(), so SQLite uses the singleSQLinstance as its only connection, serialised by a mutex so transactions never interleave. - Transactions are plain
begin/commit/rollbackon the held connection.
Alternatives:
- Separate dialects over
bun:sqliteandBun.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
pushimplementations.
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:sqliteand Postgres throughBun.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.mdis canonical;CLAUDE.mdandGEMINI.mdare 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_uniqueadds a unique index onworks (parent_id, position). NULLs are distinct on both dialects, so top-level works and position-less children are unaffected. matchWorkre-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 (
createWorkrefuses; global merges accept only shared works). - A global merge repoints children of
fromtotounlesstoalready 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-dialectRETURNINGhandling.
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.