Files
STTracker/docs/plans/001-phase1-sdd/reports/task-5-report.md
T
Lucas WintherandClaude Opus 5.5 be04fab098 docs: phase 1 handoff, execution ledger and task reports
Captures the owner's requirements and decisions from the planning
conversation, current branch/worktree state, open tasks 17-19, pending
owner decisions and the controller's rulings so a new agent can resume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 04:53:02 +01:00

6.3 KiB
Raw Blame History

Task 5 report

Built packages/core/src/works.ts (createWork, getWork, updateWorkFromHint, findWorkByExternalIds, searchWorks, resolveAlias, adminMergeWorks, Work, plus *In(k) variants usable inside transactions) and matching.ts (fuzzyScore, matchWork). Tests in packages/core/test/{works,matching}.test.ts. acceptSuggestion/rejectSuggestion omitted per controller ruling (Task 6). TDD: first run of the suggestions test failed on both DBs (0 suggestions); fixed; final: sqlite 11 pass, sqlite+postgres 21 pass, typecheck clean. Decisions / deviations:

  • fuzzyScore: a component missing on either side (year, creators) is dropped and weights renormalised; otherwise "The Expanse: Leviathan Wakes" vs "Leviathan Wakes" scores 0.467, not >0.6, and Pokemon=1 with absent year/creators would be 0.7.
  • Candidate LIKE bounding uses OR over up to 5 significant tokens (>=3 chars), not only the first token: first-token-only misses "Expanse" vs "Leviathan Wakes". Still limit 200, scored in TS.
  • Parent without ids/position: reuse shared work with identical media type, lower(title), year, parent; else create shared.
  • Position child lookup: parent_id + normalized position + shared + unmerged.
  • adminMergeWorks does not repoint children, list_items, copies or listens (getWork follows tombstones). Role read from users.role. Logs work_history for both works; nothing in user_history.
  • Work type keeps snake_case column names with creators/metadata/position parsed.
  • createWork writes a work_history "created" row.

Fix round 1

Commit 437ca14 on task/5: fix(core): race-safe matching, visibility-checked ID matches, child-preserving merges.

Changes:

  • 1a: packages/db/src/migrations/0002_works_position_unique.ts (unique index works_parent_id_position_uq on works(parent_id, position), with down), registered in migrations/index.ts. schema.test.ts "migrating down" now uses migrateTo(NO_MIGRATIONS) (one migrateDown only undoes 0002).
  • 1b: matchWork re-runs the whole resolve in a fresh transaction on a unique violation, max 3 attempts. isUniqueViolation(err) (exported from matching.ts) checks code/errno "23505", or a message with "UNIQUE constraint failed" / "duplicate key", walking up to 5 levels of cause.
  • 1c: addMissingExternalIds inserts with onConflict(columns namespace,value).doNothing(). It records an id in history only if numInsertedOrUpdatedRows > 0.
  • 2: adminMergeWorks repoints from's children to to, except a child whose non-null position to already has. Moved child ids are logged in to's work_history (children).
  • 3: findWorkByExternalIds(db, ids, userId = null) / findWorkByExternalIdsIn(k, ids, userId) follow merges and accept a work only if it is shared or owned by userId (null = shared only). An invisible match is skipped and the next namespace is tried. matchWork passes userId; parent resolution passes null. createWork with ownerUserId + external ids throws ValidationError("externalIds", "private works cannot carry external ids").
  • 4: draft reads the owner of the aliased workId. adminMergeWorks throws ValidationError unless both works are shared.
  • Docs: DECISIONS.md entry (race safety, visibility, merge children). TODO.md known issue: a shared parent resolved without IDs or position (title/year reuse) has no unique key, so concurrent matches can still duplicate it.

New tests:

  • schema: works (parent_id, position) uniqueness; NULL parents and NULL positions are not constrained.
  • works: createWork refuses ext ids on private; findWorkByExternalIds visibility (user B gets the shared work, never A's private one, even when A's private work owns one of the hint's IDs; A gets its own); getWork merged_into cycle → null; literal _ in search; adminMergeWorks refuses private; merge show A into B, then matching A's episodes creates no works (s1 moved, s2 left under A because B had s2).
  • matching: concurrent (Promise.all ×3) same new external id → 1 work and 1 external_id; concurrent same new episode chain → 3 works, same id; concurrent new season/episode under an existing show → same id, 3 works; concurrent addition of the same new ext id → no failure, 1 row; draft follows the alias.

RED (before the fix): sqlite 17 pass / 5 fail. Postgres run: 29 pass / 14 fail; the concurrency tests failed with PostgresError: duplicate key value violates unique constraint "external_ids_namespace_value_uq", errno "23505". Before the migration, the schema test failed on sqlite.

GREEN:

  • bun test packages/core packages/db: 30 pass, 0 fail (92 expect calls).
  • TEST_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres bun test packages/core packages/db: 58 pass, 0 fail (175 expect calls).
  • Postgres matching.test.ts repeated 5×: 21 pass, 0 fail each time.
  • bun run typecheck: tsc --noEmit -p tsconfig.json, exit 0, no output.

Note: on SQLite the concurrency tests pass trivially, because the single connection serialises transactions. Only Postgres exercises the retry.

Fix round 2

Commit on task/5: fix(core): follow merged tombstones in the position lookup.

Changes:

  • matching.ts step 2: the position lookup no longer filters merged_into_id is null. A found child is followed through finalWorkId, and that surviving work is the one updated and returned. Before, a merged tombstone still held its (parent_id, position) slot under works_parent_id_position_uq, so createWorkIn collided on every retry.
  • docs/TODO.md known issue: "Admin merge does not merge colliding children recursively; colliding children stay under the tombstoned parent."

Test (matching.test.ts): "position lookup follows a merged tombstone instead of colliding with it". It creates episodes E1 (s1e1) and E2 (s1e2) under one season and admin-merges E1 into E2. Matching an ID-less hint for that season at E1's position then returns { workId: E2, created: false, draft: false } and creates no works.

RED: bun test packages/core/test/matching.test.ts: 11 pass, 1 fail, with SQLiteError: UNIQUE constraint failed: works.parent_id, works.position.

GREEN:

  • bun test packages/core packages/db: 31 pass, 0 fail.
  • TEST_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres bun test packages/core packages/db: 60 pass, 0 fail.
  • bun run typecheck: exit 0.

Note: following the tombstone applies the hint's display fields to the surviving work (E2), the same as an external-ID match through a merge in step 1.