feat(core,server): log feed, titles on changes, work titles on copies
GET log (engagements and listens, keyset over at/kind/id), listChanges title and intoTitle, listCopies workTitle, all via batched lookups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5fa59310f3
commit
c2e8c83f86
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { createCopy, listCopies, softDeleteCopy, updateCopy } from "../src/copies.ts";
|
||||
import { defineCustomField } from "../src/custom-fields.ts";
|
||||
import { 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";
|
||||
@@ -73,4 +74,23 @@ forEachDialect("copies", (getDb) => {
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
test("listCopies carries the work title, with the user's title override", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const dune = await makeWork(db, { title: "Dune" });
|
||||
const hyperion = await makeWork(db, { title: "Hyperion" });
|
||||
await upsertEntry(db, userId, dune, { overrides: { title: "Dune (1965)" } }, "user");
|
||||
const a = await createCopy(db, userId, { workId: dune });
|
||||
const b = await createCopy(db, userId, { workId: hyperion });
|
||||
const c = await createCopy(db, userId, { titleHint: "HALF-LIFE 2" });
|
||||
const byId = new Map((await listCopies(db, userId)).map((x) => [x.id, x]));
|
||||
expect(byId.get(a.id)!.workTitle).toBe("Dune (1965)");
|
||||
expect(byId.get(b.id)!.workTitle).toBe("Hyperion");
|
||||
expect(byId.get(c.id)!.workTitle).toBeNull();
|
||||
// Another user's override does not leak.
|
||||
const bob = await makeUser(db, "bob");
|
||||
await createCopy(db, bob, { workId: dune });
|
||||
expect((await listCopies(db, bob))[0]!.workTitle).toBe("Dune");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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 { addEngagement, softDeleteEngagement } from "../src/engagements.ts";
|
||||
import { softDeleteEntry, upsertEntry } from "../src/entries.ts";
|
||||
import { ValidationError } from "../src/errors.ts";
|
||||
import { listLog } from "../src/log.ts";
|
||||
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||
|
||||
async function listen(db: Db, userId: string, listenedAt: string, track: string, workId: string | null = null): Promise<string> {
|
||||
const id = newId();
|
||||
await db.k
|
||||
.insertInto("listens")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
listened_at: listenedAt,
|
||||
artist: "Low",
|
||||
track,
|
||||
album: "Hey What",
|
||||
duration_ms: 200_000,
|
||||
mbids: "{}",
|
||||
work_id: workId,
|
||||
source: "listenbrainz",
|
||||
source_key: `${listenedAt}:${track}`,
|
||||
created_at: nowIso(),
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function setup(db: Db) {
|
||||
const userId = await makeUser(db);
|
||||
const dune = await makeWork(db, { title: "Dune" });
|
||||
const entry = await upsertEntry(db, userId, dune, { overrides: { title: "Dune (1965)" } }, "user");
|
||||
const finished = await addEngagement(db, userId, entry.id, { finishedAt: { value: "2026-10-01", precision: "day" } }, "user");
|
||||
const started = await addEngagement(db, userId, entry.id, { startedAt: { value: "2025-03", precision: "month" } }, "user");
|
||||
const undated = await addEngagement(db, userId, entry.id, {}, "user");
|
||||
const gone = await addEngagement(db, userId, entry.id, { finishedAt: { value: "2026-01-01", precision: "day" } }, "user");
|
||||
await softDeleteEngagement(db, userId, gone.id, "user");
|
||||
|
||||
// An engagement on a deleted entry, and another user's data, never show.
|
||||
const game = await makeWork(db, { title: "Outer Wilds", mediaType: "game" });
|
||||
const deletedEntry = await upsertEntry(db, userId, game, {}, "user");
|
||||
await addEngagement(db, userId, deletedEntry.id, { finishedAt: { value: "2026-05-01", precision: "day" } }, "user");
|
||||
await softDeleteEntry(db, userId, deletedEntry.id, "user");
|
||||
const other = await makeUser(db, "bob");
|
||||
const otherEntry = await upsertEntry(db, other, dune, {}, "user");
|
||||
await addEngagement(db, other, otherEntry.id, { finishedAt: { value: "2026-06-01", precision: "day" } }, "user");
|
||||
await listen(db, other, "2026-06-01T10:00:00.000Z", "Not mine");
|
||||
|
||||
// Two listens tie with the finished read on `at`, so pages must break ties by kind, then id.
|
||||
const l1 = await listen(db, userId, "2026-10-01", "Days Like These");
|
||||
const l2 = await listen(db, userId, "2026-10-01", "White Horses");
|
||||
return { userId, entry, finished, started, undated, l1, l2 };
|
||||
}
|
||||
|
||||
forEachDialect("log", (getDb) => {
|
||||
test("newest first: engagements of live entries and listens, with titles", async () => {
|
||||
const db = getDb();
|
||||
const s = await setup(db);
|
||||
const { items, nextCursor } = await listLog(db, s.userId);
|
||||
expect(nextCursor).toBeNull();
|
||||
expect(items.map((i) => i.id)).toEqual([s.undated.id, s.l2, s.l1, s.finished.id, s.started.id]);
|
||||
|
||||
const read = items.find((i) => i.id === s.finished.id)!;
|
||||
expect(read).toMatchObject({
|
||||
kind: "engagement",
|
||||
at: "2026-10-01",
|
||||
entryId: s.entry.id,
|
||||
title: "Dune (1965)",
|
||||
mediaType: "book",
|
||||
finishedAt: { value: "2026-10-01", precision: "day" },
|
||||
startedAt: null,
|
||||
});
|
||||
const heard = items.find((i) => i.id === s.l1)!;
|
||||
expect(heard).toMatchObject({ kind: "listen", at: "2026-10-01", title: "Days Like These", artist: "Low", track: "Days Like These", album: "Hey What", mediaType: "track" });
|
||||
expect(items.find((i) => i.id === s.started.id)!.at).toBe("2025-03");
|
||||
});
|
||||
|
||||
test("keyset pages cross the kind boundary without gaps or repeats", async () => {
|
||||
const db = getDb();
|
||||
const s = await setup(db);
|
||||
const all = (await listLog(db, s.userId)).items.map((i) => i.id);
|
||||
for (const limit of [1, 2, 3]) {
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let guard = 0; guard < 10; guard++) {
|
||||
const page = await listLog(db, s.userId, { limit, cursor });
|
||||
expect(page.items.length).toBeLessThanOrEqual(limit);
|
||||
seen.push(...page.items.map((i) => i.id));
|
||||
if (!page.nextCursor) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
expect(seen).toEqual(all);
|
||||
}
|
||||
});
|
||||
|
||||
test("limit is clamped and a bad cursor is a validation error", async () => {
|
||||
const db = getDb();
|
||||
const s = await setup(db);
|
||||
expect((await listLog(db, s.userId, { limit: 0 })).items).toHaveLength(1);
|
||||
expect((await listLog(db, s.userId, { limit: 1000 })).items).toHaveLength(5);
|
||||
await expect(listLog(db, s.userId, { cursor: "nonsense" })).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { toJson } from "@sttracker/db/json";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { addEngagement } from "../../src/engagements.ts";
|
||||
import { upsertEntry } from "../../src/entries.ts";
|
||||
import { createList } from "../../src/lists.ts";
|
||||
import { listChanges } from "../../src/sync/changes.ts";
|
||||
import { makeConnection, makeUser, makeWork } from "./helpers.ts";
|
||||
|
||||
async function event(
|
||||
db: Db,
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
e: { type: "changed" | "removed" | "merged"; targetEntity: string; targetId: string; payload?: Record<string, unknown> },
|
||||
): Promise<string> {
|
||||
const id = newId();
|
||||
await db.k
|
||||
.insertInto("change_events")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
connection_id: connectionId,
|
||||
run_id: null,
|
||||
record_id: null,
|
||||
type: e.type,
|
||||
target_entity: e.targetEntity,
|
||||
target_id: e.targetId,
|
||||
payload: toJson(e.payload ?? {}),
|
||||
status: "pending",
|
||||
created_at: nowIso(),
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function record(db: Db, connectionId: string, key: string, targetEntity: string, targetId: string): Promise<void> {
|
||||
const now = nowIso();
|
||||
await db.k
|
||||
.insertInto("source_records")
|
||||
.values({
|
||||
id: newId(),
|
||||
connection_id: connectionId,
|
||||
key,
|
||||
kind: targetEntity,
|
||||
current_hash: "h",
|
||||
first_seen: now,
|
||||
last_seen: now,
|
||||
deleted_at: null,
|
||||
merged_into_key: null,
|
||||
target_entity: targetEntity,
|
||||
target_id: targetId,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
forEachDialect("change titles", (getDb) => {
|
||||
test("events carry their target's title, and merged events the merge target's", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const conn = await makeConnection(db, userId, "hardcover");
|
||||
const dune = await upsertEntry(db, userId, await makeWork(db, { title: "Dune" }), { overrides: { title: "Dune (1965)" } }, "user");
|
||||
const piranesi = await upsertEntry(db, userId, await makeWork(db, { title: "Piranesi" }), {}, "user");
|
||||
const deluxe = await upsertEntry(db, userId, await makeWork(db, { title: "Piranesi (Deluxe)" }), {}, "user");
|
||||
const read = await addEngagement(db, userId, piranesi.id, {}, "user");
|
||||
const list = await createList(db, userId, { name: "Want to read" });
|
||||
await record(db, conn, "b:deluxe", "entry", deluxe.id);
|
||||
|
||||
const onEntry = await event(db, userId, conn, { type: "changed", targetEntity: "entry", targetId: dune.id, payload: { field: "rating", from: 80, to: 60 } });
|
||||
const onRead = await event(db, userId, conn, { type: "removed", targetEntity: "engagement", targetId: read.id });
|
||||
const onList = await event(db, userId, conn, { type: "removed", targetEntity: "list", targetId: list.id });
|
||||
const merged = await event(db, userId, conn, { type: "merged", targetEntity: "entry", targetId: piranesi.id, payload: { key: "b:p", intoKey: "b:deluxe" } });
|
||||
const unknownMerge = await event(db, userId, conn, { type: "merged", targetEntity: "entry", targetId: piranesi.id, payload: { key: "b:p", intoKey: "b:nope" } });
|
||||
|
||||
const byId = new Map((await listChanges(db, userId)).items.map((c) => [c.id, c]));
|
||||
expect(byId.get(onEntry)!.title).toBe("Dune (1965)");
|
||||
expect(byId.get(onRead)!.title).toBe("Piranesi");
|
||||
expect(byId.get(onList)!.title).toBe("Want to read");
|
||||
expect(byId.get(merged)!.title).toBe("Piranesi");
|
||||
expect(byId.get(merged)!.intoTitle).toBe("Piranesi (Deluxe)");
|
||||
expect(byId.get(unknownMerge)!.intoTitle).toBeNull();
|
||||
expect(byId.get(onEntry)!.intoTitle).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a missing target gives a null title", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const conn = await makeConnection(db, userId, "hardcover");
|
||||
const id = await event(db, userId, conn, { type: "removed", targetEntity: "entry", targetId: newId() });
|
||||
const [c] = (await listChanges(db, userId)).items;
|
||||
expect(c!.id).toBe(id);
|
||||
expect(c!.title).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user