- dedupe files conflicts for disagreeing fields instead of dropping them - merged-away records never file removals - matched work committed before apply so retries reuse it (no draft leaks) - stale pending events superseded by newer values - multi-source engagement removal only detaches the source - list records applied with history-derived ownership - gone targets resolve as ignored; counts only for committed records Co-Authored-By: Claude Opus 5.5 <[email protected]>
18 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: Sync engine: hash composition, anti-flip-flop, no revive, root ancestor, dedupe
Status: accepted
Context: Task 8 built the change engine (packages/core/src/sync/). Spec §8.3 fixes the outcomes; these
are the mechanics a maintainer would otherwise reverse-engineer.
Decision:
- Hash composition. sha256 of the stable-key-ordered JSON of
{kind, work, entry|engagement|listen|list, deleted, mergedIntoKey}.keyandraware excluded;deletedis always a boolean andmergedIntoKeyalways present (null), so an omitted flag and an explicitfalsehash alike. A version row stores base64(Bun.gzipSync) of{normalized, raw}: the normalised record is what the next change is diffed against (field-level diff = previous version vs. incoming), the raw payload is kept for the record. - Per-field decision. Only fields that differ from the previous version are considered, and of those
only values that differ from the current STTracker value.
field_sources[f]unset or this connection's actor → applied;"user"→pending changed {field, from: current, to: incoming}; another source →pending conflict {field, current, currentSource, incoming, incomingSource}. A new record landing on an entry that already exists goes through the same per-field decision, so a first sync never overwrites what the user or another source wrote. Unchanged values are never sent, so a plugin never takes ownership of a field it did not change. - Anti-flip-flop and superseding. A pending
changed/conflictfor the same (record, field, incoming value) is never filed twice. A different newer value for the same (record, field) resolves the older pending event asignoredwithpayload.supersededBy = <new event id>, so at most one is pending per field. If the source comes round to the value STTracker already holds, its pending events for that field are resolvedignoredwithpayload.reason = "source_agrees".keep_minere-stampsfield_sources[f] = "user", so later values from any source become a user-decidedchanged, never another source-vs-source conflict. - No revive. If the mapped entry/engagement is soft-deleted, the record is still snapshotted but nothing is applied and no event is filed; a new record on a work whose entry the user deleted adds nothing. Only the user revives.
- Root ancestor. Creating an entry on a child work also creates a
status "none"entry (plugin actor, emptyfield_sources) on the root ancestor work, unless the user has one, live or deleted. - Mapped records are never re-matched. Only new records go through
matchWork. Its result is committed at once as the record's row (current_hash "",target_entity "work"), so a failed apply is retried against the same work and an ID-less hint never leaks a second draft. A changed work hint refreshes the mapped work viaupdateWorkFromHint(shared works only).moveEntryToWorkre-pointssource_recordsfrom a merged-away entry to the survivor in the same transaction, so a user's merge never orphans a source. - Engagement dedupe. Among live engagements on the entry not already sourced by this connection:
finish dates match when both are datetimes ≤ 24 h apart as instants, or when both are day precision and
their dates (first 10 chars, computed with
Date.UTC, no timezone shift) are ≤ 1 day apart; or start datetimes are the same instant. A match attaches anengagement_sourcesrow and fills fields that are empty and that no source has set. Every other differing field goes through the per-field decision (so a disagreeing rating is aconflict), except the date that identified the play, whose difference is within the tolerance by definition. - Removal. Absence counts only after a
fullpull whose iteration finished and whose records all applied without error; any record error fails the run and skips removal. A removed record that comes back clearsdeleted_atand retires its still-pendingremovedevent asignored. A merged-away record (merged_into_keyset, or a pendingmergedevent) is expected to vanish: no removal, ever. Acceptingremovedon an engagement other sources still report only deletes this connection'sengagement_sourcesrow; the engagement is soft-deleted when no source remains. Run counts include only records whose transaction committed. - Listens skip snapshots. Listen records are not stored as
source_records(they are high-volume and append-only).listensinsert-or-ignore is the idempotence; they are never removed. Per batch of 500: one lookup of existing keys, each distinct recording resolved once, one multi-row insert. - Lists. A new
listrecord creates a user list (kindlist, descriptionImported from <plugin id>), items matched outside the transaction (an ID-less item first reuses the user's own private work with the same media type and title). Lists have nofield_sources; ownership is derived fromuser_history:name/descriptionbelong to whoever last changed them,itemsto the user once any user edit exists. A user-owned field becomes a pendingchanged(for items, the whole incoming item set); otherwise the fields are applied and items replaced (replaceListItems, one history row). - Merge accept. The target work is the merge target record's mapped entry's work, else
matchWorkon its latest hint. Admin-merge events (toWorkId, no connection) resolve through the sameresolveChange. - Gone targets. Accept/keep_mine on an event whose target is deleted (or merged away with no live
survivor, found through the user's work alias) resolves it
ignoredwithpayload.reason = "target_gone".
Alternatives:
- Diffing against the live entry instead of the previous version would re-file decided conflicts each run.
- Hashing
rawwould turn every cosmetic API change into a new version. - Keeping one run "ok" despite failing records risks marking those records removed.
Consequences: Concurrent runs of one connection are not locked here (Task 9's worker; docs/TODO.md).
2026-10-30: Export format sttracker-export/v1: per-user zip, camelCase JSONL, deleted rows included, no secrets
Status: accepted
Context: Task 15 (packages/core/src/{export,backup}.ts) fixed the on-disk bundle format that backups and restore share.
Decision:
- Files. A zip (
fflate) holdingmanifest.json({ format, exportedAt, user: { username, displayName } }),works.jsonl,entries.jsonl,engagements.jsonl,listens.jsonl,lists.jsonl(items nested),copies.jsonl,custom_fields.jsonl(field definitions),history.jsonl,attachments.jsonl(links nested) andattachments/<sha256>bytes. One JSON object per line,\nseparated. - Keys. camelCase; JSON columns are parsed into real JSON;
favoriteis a boolean.user_idcolumns are omitted (the importing user owns everything).works.jsonladdsprivateandexternalIds. - Deleted rows are included with their
deletedAt, andfieldSources/timestamps are kept, so a restore is lossless. - No secrets. No users row beyond username/display name, no sessions, tokens, connections, credentials or source snapshots.
- Merges and aliases.
works.jsonlrows carrymergedIntoId(the survivor and ancestors are exported too) andwork_aliases.jsonlholds the user's{ fromWorkId, toWorkId }rows. Both are additive to v1. Restore re-creates tombstones (for works it creates) and aliases, so a later write to the old work still resolves to the moved entry. - Export is one snapshot: all reads run in a single transaction (repeatable read on Postgres).
- Restore (
importUserBundle) needs an account with no entries (account_not_empty) andformat === "sttracker-export/v1"(unsupported_format). Works with external IDs are re-matched (else created shared); works without become private. Ids are remapped (UUIDs inside historybefore/afterand 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 areinvalid_attachment,corrupt_bundle,bundle_too_large,conflict. Works merged away at export time are exported as the tombstone row withoutmergedIntoId. - Backups write
<dataDir>/backups/<ISO with ':'->'-'>/<username>.zipper user plussttracker.db(VACUUM INTO, SQLite only), are built in<stamp>.tmpthen renamed (modes 0700/0600), and prune to the newestkeep(default 14). Consequences: Any change to these files after release needsv2and a migration path for old bundles.
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.
moveEntryToWorkonto 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; theirfield_sourcesis"user"if either side's was, otherwise the target's. - Differing non-empty
review/private_notesare 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
entryIdhistory row (restorable), and the moved entry is soft-deleted. - Soft-deleted target: revived with the moved entry's values and
field_sourcesas-is.
- Live target: the target's values win. Empty target fields are filled from the moved entry, carrying
the moved entry's
- 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 viarestoreVersiondeletes the forward alias and writes no reverse alias, so later syncs onto the target land on the target. - Library cursor.
listEntriescursors are base64 JSON{ sort, sortValue, id }. The sort is carried so a cursor cannot be replayed under another sort. For the title sort,sortValueis the SQLlower(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 (
customFieldsin an entry, engagement or copy patch). This makes restore exact.field_sourcestracks the keycustomFieldsas 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_itemshas nodeleted_at. The list'suser_historysnapshot holds the ordered items, sorestoreVersionbrings removed items back. - Hierarchy visibility.
childSummarycounts 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.childSummarystill counts every descendant entry. - Concurrent creates.
upsertEntryinserts withON 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.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.