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:
1 parent
512e6e9464
commit
7154ca0a61
6 files changed
+327
-83
No files matched your search
@@ -7,7 +7,7 @@ import {
|
||||
softDeleteEngagement,
|
||||
updateEngagement,
|
||||
} from "../src/engagements.ts";
|
||||
import { upsertEntry } from "../src/entries.ts";
|
||||
import { softDeleteEntry, upsertEntry } from "../src/entries.ts";
|
||||
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||
import { getHistory, restoreVersion } from "../src/history.ts";
|
||||
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||
@@ -114,6 +114,14 @@ forEachDialect("engagements", (getDb) => {
|
||||
expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([early.id, later.id, undated.id]);
|
||||
});
|
||||
|
||||
test("engagements of a soft-deleted entry cannot be changed", async () => {
|
||||
const { db, userId, entry } = await setup();
|
||||
const eg = await addEngagement(db, userId, entry.id, {}, "user");
|
||||
await softDeleteEntry(db, userId, entry.id, "user");
|
||||
await expect(updateEngagement(db, userId, eg.id, { rating: 1 }, "user")).rejects.toThrow(NotFoundError);
|
||||
await expect(softDeleteEngagement(db, userId, eg.id, "user")).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
test("another user's engagements are not found", async () => {
|
||||
const { db, userId, entry } = await setup();
|
||||
const bob = await makeUser(db, "bob");
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { defineCustomField } from "../src/custom-fields.ts";
|
||||
import { NotFoundError } from "../src/errors.ts";
|
||||
import { getEntry, moveEntryToWork, quickAdd, softDeleteEntry, upsertEntry } from "../src/entries.ts";
|
||||
import { getHistory, recordHistory, restoreVersion } from "../src/history.ts";
|
||||
@@ -80,9 +81,25 @@ forEachDialect("history", (getDb) => {
|
||||
expect(back.draft).toBe(true);
|
||||
const work = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow();
|
||||
expect(work.merged_into_id).toBeNull();
|
||||
// the shared work now aliases back to the draft for this user; no alias cycle remains
|
||||
const aliases = await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).execute();
|
||||
expect(aliases.map((a) => [a.from_work_id, a.to_work_id])).toEqual([[shared, draft.workId]]);
|
||||
// undoing removes the forward alias and writes no reverse alias
|
||||
expect(await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).execute()).toEqual([]);
|
||||
// a later sync onto the shared work lands on the shared work, not on the draft
|
||||
const synced = await upsertEntry(db, userId, shared, { rating: 80 }, "plugin:hardcover:c1");
|
||||
expect(synced.workId).toBe(shared);
|
||||
expect(synced.id).not.toBe(draft.id);
|
||||
expect((await getEntry(db, userId, draft.id)).rating).toBeNull();
|
||||
});
|
||||
|
||||
test("restoreVersion does not re-check custom fields against today's definitions", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const def = await defineCustomField(db, userId, { name: "Mood", type: "text" });
|
||||
const e = await upsertEntry(db, userId, await makeWork(db), { customFields: { [def.id]: "cosy" } }, "user");
|
||||
await upsertEntry(db, userId, e.workId, { customFields: {} }, "user");
|
||||
await db.k.deleteFrom("custom_field_definitions").where("id", "=", def.id).execute();
|
||||
const [h] = await getHistory(db, userId, "entry", e.id);
|
||||
await restoreVersion(db, userId, h!.id);
|
||||
expect((await getEntry(db, userId, e.id)).customFields).toEqual({ [def.id]: "cosy" });
|
||||
});
|
||||
|
||||
test("another user's history is invisible", async () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user