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

58 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.