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:
co-authored by
Claude Opus 5.5
parent
437ca143e5
commit
50c115b4c7
@@ -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/`.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user