fix(core): race-safe entry upsert and lossless per-user moves

- upsertEntry inserts with ON CONFLICT DO NOTHING and patches through the update path,
  so a concurrent creator never aborts the caller's transaction on Postgres.
- Undoing a move deletes the forward alias and writes no reverse alias.
- Merging into an existing entry keeps 'user' field_sources on combined fields, carries
  the moved entry's field_sources as-is when reviving a deleted target, concatenates
  differing review/private notes, records a dropped rating in history, and writes history
  for re-parented engagements (restorable).
- Moves re-point this user's copies (with history) and list items to the target work.
- rejectSuggestion requires a pending suggestion; engagements of a deleted entry are not
  editable; restoring an entry does not re-check custom fields against current definitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 19:59:54 +01:00
1 parent 512e6e9464
commit 7154ca0a61
6 files changed
+327 -83

No files matched your search

+21 -1
View File
@@ -3,7 +3,10 @@ import type { Db } from "@sttracker/db/db";
import { newId, nowIso } from "@sttracker/db/ids";
import { forEachDialect } from "@sttracker/db/testing";
import { acceptSuggestion, getEntry, quickAdd, rejectSuggestion } from "../src/entries.ts";
import { NotFoundError } from "../src/errors.ts";
import { createCopy, listCopies } from "../src/copies.ts";
import { NotFoundError, ValidationError } from "../src/errors.ts";
import { getHistory } from "../src/history.ts";
import { addListItem, createList, getList } from "../src/lists.ts";
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
async function suggest(db: Db, userId: string, workId: string, candidateWorkId: string): Promise<string> {
@@ -46,5 +49,22 @@ forEachDialect("suggestions", (getDb) => {
expect(await statusOf(db, s)).toBe("pending");
await rejectSuggestion(db, ada, s);
expect(await statusOf(db, s)).toBe("rejected");
await expect(rejectSuggestion(db, ada, s)).rejects.toThrow(ValidationError);
await expect(acceptSuggestion(db, ada, s)).rejects.toThrow(ValidationError);
});
test("accepting moves this user's copies and list items to the target work", async () => {
const db = getDb();
const userId = await makeUser(db);
const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" });
const target = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
const copy = await createCopy(db, userId, { workId: draft.workId, format: "paperback" });
const list = await createList(db, userId, { name: "Desert" });
await addListItem(db, userId, list.id, { workId: draft.workId });
await acceptSuggestion(db, userId, await suggest(db, userId, draft.workId, target));
expect((await listCopies(db, userId, { workId: target })).map((c) => c.id)).toEqual([copy.id]);
const [ch] = await getHistory(db, userId, "copy", copy.id);
expect(ch!.after).toEqual({ workId: target });
expect((await getList(db, userId, list.id)).items.map((i) => i.work.title)).toEqual(["Dune"]);
});
});