149 lines
9.6 KiB
Markdown
149 lines
9.6 KiB
Markdown
# 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.
|
|
- **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`.
|