Files
STTracker/docs/DECISIONS.md
T
Lucas WintherandClaude Opus 5.5 c6865b2522 fix(core): drill-down lists descendants under untracked intermediates
listEntries({ parentId: X }) now applies the top-level rule relative to X: this user's
live entries on descendants of X (up to three levels) with no live-entry work strictly
between them and X, so it agrees with childSummary. Restoring an engagement's re-parenting
onto a soft-deleted entry throws ValidationError("entryId", "target entry is deleted").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:05:12 +01:00

9.8 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: User layer: per-user moves merge entries, keep aliases, and roll the library up by hierarchy

Status: accepted Context: Task 6 built the user layer (packages/core/src/{entries,engagements,history,lists,copies}.ts). Several behaviours are not obvious from the spec and would otherwise have to be reverse-engineered. Decision:

  • Merge on move. moveEntryToWork onto a work where the user already has an entry merges the two:
    • Live target: the target's values win. Empty target fields are filled from the moved entry, carrying the moved entry's field_sources. Tags are unioned and custom fields and overrides merged; their field_sources is "user" if either side's was, otherwise the target's.
    • Differing non-empty review/private_notes are concatenated (target, \n\n---\n\n, moved).
    • A conflicting rating keeps the target's; the moved rating is recorded in the history row's before.
    • Engagements are re-parented, each with an entryId history row (restorable), and the moved entry is soft-deleted.
    • Soft-deleted target: revived with the moved entry's values and field_sources as-is.
  • Alias bookkeeping. A move writes work_aliases (user, old → target), re-points aliases that led to the old work, drops any alias leaving the target (no cycles), tombstones the user's own private draft, and re-points this user's copies (with history) and list items. Writes to an aliased or merged work (upsertEntry, copies, list items) resolve to the target. Undoing a move via restoreVersion deletes the forward alias and writes no reverse alias, so later syncs onto the target land on the target.
  • Library cursor. listEntries cursors are base64 JSON { sort, sortValue, id }. The sort is carried so a cursor cannot be replayed under another sort. For the title sort, sortValue is the SQL lower(w.title) of the last row, never a JS lowercase, so SQL and cursor agree on non-ASCII titles.
  • Custom fields patches replace the whole map (customFields in an entry, engagement or copy patch). This makes restore exact. field_sources tracks the key customFields as a whole. Restore does not re-check values against today's definitions, because it is the user's own past data.
  • List items are hard-deleted. list_items has no deleted_at. The list's user_history snapshot holds the ordered items, so restoreVersion brings removed items back.
  • Hierarchy visibility. childSummary counts the user's live entries up to three levels below the entry's work (child, grandchild, great-grandchild). The default library view (parentId: null) lists entries with no ancestor, up to three levels, on which the user has a live entry. An episode whose show is untracked therefore stays visible, and is rolled up once the show gets an entry. Drilling in (parentId: X) applies the same rule relative to X: descendants up to three levels with no live-entry work strictly between them and X. childSummary still counts every descendant entry.
  • Concurrent creates. upsertEntry inserts with ON CONFLICT (user_id, work_id) DO NOTHING, then patches through the update path, so a concurrent creator never aborts the caller's transaction.

Alternatives:

  • Refusing a move onto an existing entry pushes the merge onto the user.
  • Recursive CTEs for hierarchy are portable but heavier; three levels cover show → season → episode.
  • A JS-lowercased cursor breaks on SQLite, whose lower() is ASCII-only.

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: 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.