From 00fc0180b08f1151bd2873f6b3ebc7e33a7f4531 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:00:14 +0100 Subject: [PATCH] docs: record user-layer decisions, core module map and known gaps Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- docs/DECISIONS.md | 42 ++++++++++++++++++++++++++++++++++++++++++ docs/TODO.md | 5 +++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4766a8..16699ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ user layer or files a Changes-inbox event. |---|---|---| | `apps/server` | `Bun.serve`: `/api/v1`, `/ingest/`, job worker, static web | planned | | `apps/web` | React app (HTML imports) | planned | -| `packages/core` | domain services: entries, matching, change engine, history, export | planned | +| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`. Change engine and export planned | exists (partial) | | `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) | | `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned | | `plugins/` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b1787f4..28bdf17 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,6 +4,48 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin 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. diff --git a/docs/TODO.md b/docs/TODO.md index eec9f67..75fa908 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,4 +27,9 @@ Small, specific, independently actionable. Remove only when actually done. - [ ] Admin merge does not merge colliding children recursively; colliding children stay under the tombstoned parent. - [ ] Matching: a shared parent resolved without external IDs or position (title/year reuse in `packages/core/src/matching.ts`) has no unique key, so concurrent matches can create it twice. +- [ ] Library search (`q`) and the title sort use the work's title, not the user's `overrides.title` + (overrides are a JSON text column). The `tag` filter matches the JSON text with LIKE. +- [ ] Library hierarchy rollup and orphan detection look three levels deep; deeper trees are not rolled up. +- [ ] Undoing a per-user move does not move copies or list items back, nor restore aliases that the move + re-pointed. - [ ] `bun run typecheck` fails with TS18003 until source files exist under `apps/`, `packages/` or `plugins/`.