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>
58 lines
6.3 KiB
Markdown
58 lines
6.3 KiB
Markdown
# 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.
|