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

+72
View File
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
import { newId } from "@sttracker/db/ids";
import { forEachDialect } from "@sttracker/db/testing";
import { defineCustomField } from "../src/custom-fields.ts";
import { addEngagement, listEngagements } from "../src/engagements.ts";
import { NotFoundError, ValidationError } from "../src/errors.ts";
import {
getEntry,
@@ -179,6 +180,23 @@ forEachDialect("entries", (getDb) => {
expect([...seen].sort()).toEqual([...titles].sort());
});
test("concurrent upserts of a new entry yield one entry with both patches", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db);
const [a, b] = await Promise.all([
upsertEntry(db, userId, workId, { rating: 70 }, "plugin:trakt:c1"),
upsertEntry(db, userId, workId, { tags: ["cosy"] }, "user"),
]);
expect(a.id).toBe(b.id);
const rows = await db.k.selectFrom("entries").selectAll().where("user_id", "=", userId).execute();
expect(rows).toHaveLength(1);
const view = await getEntry(db, userId, a.id);
expect(view.rating).toBe(70);
expect(view.tags).toEqual(["cosy"]);
expect(view.fieldSources).toEqual({ rating: "plugin:trakt:c1", tags: "user" });
});
test("listEntries filters, sorts and paginates", async () => {
const db = getDb();
const userId = await makeUser(db);
@@ -302,4 +320,58 @@ forEachDialect("entries", (getDb) => {
expect(merged.tags.sort()).toEqual(["x", "y"]);
await expect(getEntry(db, userId, src.id)).rejects.toThrow(NotFoundError);
});
test("merge keeps 'user' field_sources on combined fields", async () => {
const db = getDb();
const userId = await makeUser(db);
const a = await makeWork(db, { title: "A" });
const b = await makeWork(db, { title: "B" });
const src = await upsertEntry(db, userId, a, { tags: ["x"], overrides: { year: 1 } }, "plugin:trakt:c1");
await upsertEntry(db, userId, b, { tags: ["y"], status: "completed" }, "user");
await upsertEntry(db, userId, b, { overrides: { title: "B!" } }, "plugin:hardcover:c2");
const merged = await moveEntryToWork(db, userId, src.id, b, "plugin:steam:c3");
expect(merged.tags.sort()).toEqual(["x", "y"]);
expect(merged.overrides).toEqual({ year: 1, title: "B!" });
expect(merged.fieldSources).toEqual({ tags: "user", status: "user", overrides: "plugin:hardcover:c2" });
});
test("merge into a soft-deleted entry carries the moved field_sources as-is", async () => {
const db = getDb();
const userId = await makeUser(db);
const a = await makeWork(db, { title: "A" });
const b = await makeWork(db, { title: "B" });
const src = await upsertEntry(db, userId, a, { rating: 60, review: "mine" }, "user");
const dst = await upsertEntry(db, userId, b, { status: "dropped", tags: ["old"] }, "plugin:trakt:c1");
await softDeleteEntry(db, userId, dst.id, "user");
const merged = await moveEntryToWork(db, userId, src.id, b, "plugin:trakt:c2");
expect(merged.id).toBe(dst.id);
expect(merged.deletedAt).toBeNull();
expect(merged.rating).toBe(60);
expect(merged.status).toBe("none");
expect(merged.tags).toEqual([]);
expect(merged.fieldSources).toEqual({ rating: "user", review: "user" });
});
test("merge concatenates differing text, records the dropped rating and moves engagements with history", async () => {
const db = getDb();
const userId = await makeUser(db);
const a = await makeWork(db, { title: "A" });
const b = await makeWork(db, { title: "B" });
const src = await upsertEntry(db, userId, a, { review: "from A", privateNotes: "same", rating: 40 }, "user");
const dst = await upsertEntry(db, userId, b, { review: "from B", privateNotes: "same", rating: 90 }, "user");
const eg = await addEngagement(db, userId, src.id, { platform: "Kindle" }, "user");
const merged = await moveEntryToWork(db, userId, src.id, b, "user");
expect(merged.review).toBe("from B\n\n---\n\nfrom A");
expect(merged.privateNotes).toBe("same");
expect(merged.rating).toBe(90);
const [h] = await getHistory(db, userId, "entry", dst.id);
expect(h!.before.rating).toBe(40);
expect(h!.after.rating).toBe(90);
expect(h!.before.review).toBe("from B");
expect((await listEngagements(db, userId, dst.id)).map((e) => e.id)).toEqual([eg.id]);
const [egh] = await getHistory(db, userId, "engagement", eg.id);
expect(egh!.before).toEqual({ entryId: src.id });
expect(egh!.after).toEqual({ entryId: dst.id });
});
});