feat(core): entries, user history and match suggestion accept/reject

Entries carry per-field field_sources, write user_history diffs, resolve
aliased/merged works, and support quick add, per-user moves (with alias and
draft tombstone) and keyset-paginated library listing with child rollups.

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:34:47 +01:00
1 parent 56b86e27da
commit 13b81b1cd5
5 files changed
+1340

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
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 { NotFoundError, ValidationError } from "../src/errors.ts";
import {
getEntry,
listEntries,
moveEntryToWork,
quickAdd,
softDeleteEntry,
upsertEntry,
} from "../src/entries.ts";
import { getHistory } from "../src/history.ts";
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
forEachDialect("entries", (getDb) => {
test("upsertEntry records field_sources and history", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db);
const created = await upsertEntry(db, userId, workId, { status: "planned", tags: ["sci-fi"] }, "user");
expect(created.status).toBe("planned");
expect(created.visibility).toBe("private");
expect(created.fieldSources).toEqual({ status: "user", tags: "user" });
const updated = await upsertEntry(db, userId, workId, { rating: 80, status: "completed" }, "plugin:trakt:c1");
expect(updated.id).toBe(created.id);
expect(updated.rating).toBe(80);
expect(updated.tags).toEqual(["sci-fi"]);
expect(updated.fieldSources).toEqual({ status: "plugin:trakt:c1", tags: "user", rating: "plugin:trakt:c1" });
const history = await getHistory(db, userId, "entry", created.id);
expect(history).toHaveLength(2);
expect(history[0]!.before).toEqual({ status: "planned", rating: null });
expect(history[0]!.after).toEqual({ status: "completed", rating: 80 });
expect(history[0]!.actor).toBe("plugin:trakt:c1");
});
test("upsertEntry works inside a caller's transaction", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db);
await expect(
db.k.transaction().execute(async (tx) => {
await upsertEntry(db, userId, workId, { status: "planned" }, "user", tx);
throw new Error("rollback");
}),
).rejects.toThrow("rollback");
expect((await listEntries(db, userId, {})).items).toHaveLength(0);
const e = await db.k.transaction().execute((tx) => upsertEntry(db, userId, workId, { favorite: true }, "user", tx));
expect(e.favorite).toBe(true);
expect(e.status).toBe("none");
});
test("visibility default comes from user preferences", async () => {
const db = getDb();
const userId = await makeUser(db, "pub", { defaultVisibility: "public" });
const e = await upsertEntry(db, userId, await makeWork(db), {}, "user");
expect(e.visibility).toBe("public");
expect(e.fieldSources).toEqual({});
});
test("rating 101 rejected", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db);
await expect(upsertEntry(db, userId, workId, { rating: 101 }, "user")).rejects.toThrow(ValidationError);
await expect(upsertEntry(db, userId, workId, { rating: 7.5 }, "user")).rejects.toThrow(ValidationError);
await expect(upsertEntry(db, userId, workId, { status: "done" as never }, "user")).rejects.toThrow(
ValidationError,
);
expect((await listEntries(db, userId, {})).items).toHaveLength(0);
});
test("custom field values are validated against the user's entry definitions", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db, { mediaType: "book" });
const def = await defineCustomField(db, userId, { name: "Mood", type: "single_select", options: ["cosy"] });
const gameOnly = await defineCustomField(db, userId, { name: "Hours", type: "number", appliesTo: ["game"] });
const e = await upsertEntry(db, userId, workId, { customFields: { [def.id]: "cosy" } }, "user");
expect(e.customFields).toEqual({ [def.id]: "cosy" });
await expect(upsertEntry(db, userId, workId, { customFields: { [def.id]: "grim" } }, "user")).rejects.toThrow(
ValidationError,
);
await expect(upsertEntry(db, userId, workId, { customFields: { [gameOnly.id]: 3 } }, "user")).rejects.toThrow(
ValidationError,
);
});
test("quickAdd creates draft entry", async () => {
const db = getDb();
const userId = await makeUser(db);
const view = await quickAdd(db, userId, { title: "Zine I found" });
expect(view.draft).toBe(true);
expect(view.work.title).toBe("Zine I found");
expect(view.work.mediaType).toBe("other");
expect(view.status).toBe("none");
const work = await db.k.selectFrom("works").selectAll().where("id", "=", view.workId).executeTakeFirstOrThrow();
expect(work.owner_user_id).toBe(userId);
const shared = await upsertEntry(db, userId, await makeWork(db, { externalIds: { isbn13: "1" } }), {}, "user");
expect((await getEntry(db, userId, shared.id)).draft).toBe(false);
expect((await listEntries(db, userId, { draft: true })).items.map((i) => i.id)).toEqual([view.id]);
await expect(quickAdd(db, userId, { title: " " })).rejects.toThrow(ValidationError);
await expect(quickAdd(db, userId, { title: "x", mediaType: "zine" as never })).rejects.toThrow(ValidationError);
});
test("listEntries rolls up children", async () => {
const db = getDb();
const userId = await makeUser(db);
const show = await makeWork(db, { title: "Severance", mediaType: "show" });
const showEntry = await upsertEntry(db, userId, show, { status: "in_progress" }, "user");
for (const status of ["completed", "completed", "in_progress"] as const) {
const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: show });
await upsertEntry(db, userId, ep, { status }, "user");
}
const lone = await upsertEntry(db, userId, await makeWork(db, { title: "Dune" }), {}, "user");
const top = await listEntries(db, userId, {});
expect(top.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id].sort());
const showView = top.items.find((i) => i.id === showEntry.id)!;
expect(showView.childSummary).toEqual({ total: 3, completed: 2 });
expect(top.items.find((i) => i.id === lone.id)!.childSummary).toBeNull();
const children = await listEntries(db, userId, { parentId: show });
expect(children.items).toHaveLength(3);
expect(children.items.every((i) => i.work.mediaType === "episode")).toBe(true);
});
test("listEntries filters, sorts and paginates", async () => {
const db = getDb();
const userId = await makeUser(db);
const other = await makeUser(db, "bob");
const titles = ["Alpha", "bravo", "Charlie", "delta", "Echo"];
const ids: string[] = [];
for (const [i, title] of titles.entries()) {
const w = await makeWork(db, { title, mediaType: i % 2 ? "game" : "book" });
const e = await upsertEntry(
db,
userId,
w,
{ rating: i === 4 ? null : i * 10, tags: i < 2 ? ["Cosy"] : [], status: i === 0 ? "completed" : "planned" },
"user",
);
ids.push(e.id);
await upsertEntry(db, other, w, { status: "completed" }, "user");
}
// updated sort: newest first, keyset pagination
const p1 = await listEntries(db, userId, { limit: 2 });
expect(p1.items).toHaveLength(2);
expect(p1.nextCursor).not.toBeNull();
const p2 = await listEntries(db, userId, { limit: 2, cursor: p1.nextCursor! });
const p3 = await listEntries(db, userId, { limit: 2, cursor: p2.nextCursor! });
expect(p3.nextCursor).toBeNull();
expect([...p1.items, ...p2.items, ...p3.items].map((i) => i.id)).toEqual([...ids].reverse());
const byTitle = await listEntries(db, userId, { sort: "title", limit: 3 });
const byTitle2 = await listEntries(db, userId, { sort: "title", limit: 3, cursor: byTitle.nextCursor! });
expect([...byTitle.items, ...byTitle2.items].map((i) => i.work.title)).toEqual(titles);
const byRating = await listEntries(db, userId, { sort: "rating", limit: 2 });
const byRating2 = await listEntries(db, userId, { sort: "rating", limit: 10, cursor: byRating.nextCursor! });
expect([...byRating.items, ...byRating2.items].map((i) => i.rating)).toEqual([30, 20, 10, 0, null]);
expect((await listEntries(db, userId, { mediaType: "game" })).items).toHaveLength(2);
expect((await listEntries(db, userId, { status: "completed" })).items.map((i) => i.id)).toEqual([ids[0]!]);
expect((await listEntries(db, userId, { tag: "cosy" })).items).toHaveLength(2);
expect((await listEntries(db, userId, { q: "RAV" })).items.map((i) => i.id)).toEqual([ids[1]!]);
expect((await listEntries(db, userId, { q: "100%" })).items).toHaveLength(0);
await expect(listEntries(db, userId, { cursor: "garbage" })).rejects.toThrow(ValidationError);
await expect(listEntries(db, userId, { sort: "title", cursor: p1.nextCursor! })).rejects.toThrow(
ValidationError,
);
});
test("overrides apply to the work view", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db, { title: "Dune", year: 1965 });
const e = await upsertEntry(db, userId, workId, { overrides: { title: "Dune (my edition)", year: 1990 } }, "user");
const view = await getEntry(db, userId, e.id);
expect(view.work.title).toBe("Dune (my edition)");
expect(view.work.year).toBe(1990);
expect(view.work.coverUrl).toBe("");
await expect(upsertEntry(db, userId, workId, { overrides: { colour: "red" } as never }, "user")).rejects.toThrow(
ValidationError,
);
});
test("other users' entries and private works are not found", async () => {
const db = getDb();
const ada = await makeUser(db);
const bob = await makeUser(db, "bob");
const draft = await quickAdd(db, ada, { title: "Secret" });
await expect(getEntry(db, bob, draft.id)).rejects.toThrow(NotFoundError);
await expect(upsertEntry(db, bob, draft.workId, {}, "user")).rejects.toThrow(NotFoundError);
await expect(softDeleteEntry(db, bob, draft.id, "user")).rejects.toThrow(NotFoundError);
await expect(moveEntryToWork(db, bob, draft.id, await makeWork(db), "user")).rejects.toThrow(NotFoundError);
await expect(getEntry(db, ada, newId())).rejects.toThrow(NotFoundError);
});
test("softDeleteEntry hides the entry and upsert revives it", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db);
const e = await upsertEntry(db, userId, workId, { rating: 40 }, "user");
await softDeleteEntry(db, userId, e.id, "user");
await expect(getEntry(db, userId, e.id)).rejects.toThrow(NotFoundError);
expect((await listEntries(db, userId, {})).items).toHaveLength(0);
const again = await upsertEntry(db, userId, workId, { status: "planned" }, "user");
expect(again.id).toBe(e.id);
expect(again.deletedAt).toBeNull();
expect(again.rating).toBe(40);
});
test("moveEntryToWork leaves alias", async () => {
const db = getDb();
const userId = await makeUser(db);
const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" });
const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "9780441013593" } });
const moved = await moveEntryToWork(db, userId, draft.id, shared, "user");
expect(moved.id).toBe(draft.id);
expect(moved.workId).toBe(shared);
const alias = await db.k
.selectFrom("work_aliases")
.selectAll()
.where("user_id", "=", userId)
.executeTakeFirstOrThrow();
expect(alias.from_work_id).toBe(draft.workId);
expect(alias.to_work_id).toBe(shared);
const old = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow();
expect(old.merged_into_id).toBe(shared);
expect((await getEntry(db, userId, draft.id)).draft).toBe(false);
const [h] = await getHistory(db, userId, "entry", draft.id);
expect(h!.after).toEqual({ workId: shared });
});
test("moveEntryToWork merges into an existing entry on the target", 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: 70, tags: ["x"] }, "user");
const dst = await upsertEntry(db, userId, b, { status: "completed", tags: ["y"] }, "user");
const merged = await moveEntryToWork(db, userId, src.id, b, "user");
expect(merged.id).toBe(dst.id);
expect(merged.status).toBe("completed");
expect(merged.rating).toBe(70);
expect(merged.tags.sort()).toEqual(["x", "y"]);
await expect(getEntry(db, userId, src.id)).rejects.toThrow(NotFoundError);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from "bun:test";
import { forEachDialect } from "@sttracker/db/testing";
import { NotFoundError } from "../src/errors.ts";
import { getEntry, moveEntryToWork, quickAdd, softDeleteEntry, upsertEntry } from "../src/entries.ts";
import { getHistory, recordHistory, restoreVersion } from "../src/history.ts";
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
forEachDialect("history", (getDb) => {
test("recordHistory stores only the changed keys", async () => {
const db = getDb();
const userId = await makeUser(db);
const id = await recordHistory(db.k, {
userId,
entity: "entry",
entityId: "e1",
before: { status: "planned", rating: 50, tags: ["a"] },
after: { status: "completed", rating: 50, tags: ["a"] },
actor: "user",
});
expect(id).not.toBeNull();
const none = await recordHistory(db.k, {
userId,
entity: "entry",
entityId: "e1",
before: { rating: 1 },
after: { rating: 1 },
actor: "user",
});
expect(none).toBeNull();
const rows = await getHistory(db, userId, "entry", "e1");
expect(rows).toHaveLength(1);
expect(rows[0]!.before).toEqual({ status: "planned" });
expect(rows[0]!.after).toEqual({ status: "completed" });
expect(rows[0]!.actor).toBe("user");
});
test("restoreVersion brings back previous review", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db);
const e = await upsertEntry(db, userId, workId, { review: "first take" }, "user");
await upsertEntry(db, userId, workId, { review: "second take", rating: 60 }, "plugin:trakt:c1");
const history = await getHistory(db, userId, "entry", e.id);
expect(history).toHaveLength(2);
const latest = history[0]!; // newest first
expect(latest.before).toEqual({ review: "first take", rating: null });
expect(latest.actor).toBe("plugin:trakt:c1");
await restoreVersion(db, userId, latest.id);
const view = await getEntry(db, userId, e.id);
expect(view.review).toBe("first take");
expect(view.rating).toBeNull();
expect(view.fieldSources.review).toBe("user");
expect(view.fieldSources.rating).toBe("user");
expect(await getHistory(db, userId, "entry", e.id)).toHaveLength(3);
});
test("restoreVersion undoes a soft delete", async () => {
const db = getDb();
const userId = await makeUser(db);
const e = await upsertEntry(db, userId, await makeWork(db), { status: "planned" }, "user");
await softDeleteEntry(db, userId, e.id, "user");
const [del] = await getHistory(db, userId, "entry", e.id);
expect(del!.after.deletedAt).toBeString();
await restoreVersion(db, userId, del!.id);
expect((await getEntry(db, userId, e.id)).status).toBe("planned");
});
test("restoreVersion undoes a move back to the draft work", async () => {
const db = getDb();
const userId = await makeUser(db);
const draft = await quickAdd(db, userId, { title: "Dune??" });
const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
await moveEntryToWork(db, userId, draft.id, shared, "user");
const [move] = await getHistory(db, userId, "entry", draft.id);
await restoreVersion(db, userId, move!.id);
const back = await getEntry(db, userId, draft.id);
expect(back.workId).toBe(draft.workId);
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]]);
});
test("another user's history is invisible", async () => {
const db = getDb();
const ada = await makeUser(db);
const bob = await makeUser(db, "bob");
const e = await upsertEntry(db, ada, await makeWork(db), { review: "x" }, "user");
expect(await getHistory(db, bob, "entry", e.id)).toEqual([]);
const [h] = await getHistory(db, ada, "entry", e.id);
await expect(restoreVersion(db, bob, h!.id)).rejects.toThrow(NotFoundError);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test";
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 { makeUser, makeWork } from "./user-layer-fixtures.ts";
async function suggest(db: Db, userId: string, workId: string, candidateWorkId: string): Promise<string> {
const id = newId();
await db.k
.insertInto("match_suggestions")
.values({ id, user_id: userId, work_id: workId, candidate_work_id: candidateWorkId, score: 0.9, status: "pending", created_at: nowIso() })
.execute();
return id;
}
async function statusOf(db: Db, id: string): Promise<string> {
return (await db.k.selectFrom("match_suggestions").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status;
}
forEachDialect("suggestions", (getDb) => {
test("acceptSuggestion moves the entry and rejects the other pending suggestions", async () => {
const db = getDb();
const userId = await makeUser(db);
const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" });
const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
const other = await makeWork(db, { title: "Dune Messiah" });
const s1 = await suggest(db, userId, draft.workId, good);
const s2 = await suggest(db, userId, draft.workId, other);
await acceptSuggestion(db, userId, s1);
expect(await statusOf(db, s1)).toBe("accepted");
expect(await statusOf(db, s2)).toBe("rejected");
const view = await getEntry(db, userId, draft.id);
expect(view.workId).toBe(good);
expect(view.draft).toBe(false);
});
test("rejectSuggestion; other users get NotFoundError", async () => {
const db = getDb();
const ada = await makeUser(db);
const bob = await makeUser(db, "bob");
const s = await suggest(db, ada, await makeWork(db), await makeWork(db));
await expect(acceptSuggestion(db, bob, s)).rejects.toThrow(NotFoundError);
await expect(rejectSuggestion(db, bob, s)).rejects.toThrow(NotFoundError);
expect(await statusOf(db, s)).toBe("pending");
await rejectSuggestion(db, ada, s);
expect(await statusOf(db, s)).toBe("rejected");
});
});