fix(core): follow merged tombstones in the position lookup

A merged child still holds its (parent_id, position) slot under the unique
index, so skipping tombstones made every retry collide. Follow the tombstone
to the surviving work instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 19:32:27 +01:00
co-authored by Claude Opus 5.5
parent 437ca143e5
commit 50c115b4c7
3 changed files with 22 additions and 4 deletions
+1
View File
@@ -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/`.
+5 -3
View File
@@ -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 };
}
+16 -1
View File
@@ -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<string> {
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);
});
});