diff --git a/docs/TODO.md b/docs/TODO.md index 8d583a3..9eb62bc 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -23,6 +23,7 @@ Small, specific, independently actionable. Remove only when actually done. file, and compare steps and token use. ## Known issues +- [ ] 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. - [ ] `bun run typecheck` fails with TS18003 until source files exist under `apps/`, `packages/` or `plugins/`. diff --git a/packages/core/src/matching.ts b/packages/core/src/matching.ts index 3cf5a52..ac78f7b 100644 --- a/packages/core/src/matching.ts +++ b/packages/core/src/matching.ts @@ -4,6 +4,7 @@ import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { createWorkIn, + finalWorkId, findWorkByExternalIdsIn, normalizePosition, resolveAliasIn, @@ -85,12 +86,13 @@ async function resolve(k: K, userId: string | null, hint: WorkHint, actor: strin .select("id") .where("parent_id", "=", parentId) .where("position", "=", position) - .where("merged_into_id", "is", null) .where("owner_user_id", "is", null) .executeTakeFirst(); if (child) { - await updateWorkFromHintIn(k, child.id, hint, actor); - return { workId: child.id, created: false, draft: false }; + // A merged tombstone still holds its (parent_id, position) slot: follow it to the surviving work. + const workId = await finalWorkId(k, child.id); + await updateWorkFromHintIn(k, workId, hint, actor); + return { workId, created: false, draft: false }; } return { workId: await createWorkIn(k, { hint, parentId }, actor), created: true, draft: false }; } diff --git a/packages/core/test/matching.test.ts b/packages/core/test/matching.test.ts index b47c09e..c80f4bb 100644 --- a/packages/core/test/matching.test.ts +++ b/packages/core/test/matching.test.ts @@ -3,7 +3,7 @@ import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { forEachDialect } from "@sttracker/db/testing"; import { fuzzyScore, matchWork } from "../src/matching.ts"; -import { createWork, getWork } from "../src/works.ts"; +import { adminMergeWorks, createWork, getWork } from "../src/works.ts"; async function addUser(db: Db): Promise { const id = newId(); @@ -145,4 +145,19 @@ forEachDialect("matching", (getDb) => { await db.k.insertInto("work_aliases").values({ id: newId(), user_id: u, from_work_id: shared, to_work_id: priv }).execute(); expect(await matchWork(db, u, { title: "Dune", mediaType: "book", externalIds: { isbn13: "1" } }, "p")).toEqual({ workId: priv, created: false, draft: true }); }); + + test("position lookup follows a merged tombstone instead of colliding with it", async () => { + const db = getDb(); + const u = await addUser(db); + await db.k.updateTable("users").set({ role: "admin" }).where("id", "=", u).execute(); + const e1 = await matchWork(db, u, chain(), "p"); + const h2 = chain(); + h2.title = "Episode 2"; + h2.position = { season: 1, episode: 2 }; + const e2 = await matchWork(db, u, h2, "p"); + await adminMergeWorks(db, u, e1.workId, e2.workId); + const before = (await db.k.selectFrom("works").select("id").execute()).length; + expect(await matchWork(db, u, chain(), "p")).toEqual({ workId: e2.workId, created: false, draft: false }); + expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(before); + }); });