diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 2077395..0fbc9ee 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -8,6 +8,7 @@ import { engagementRoutes } from "./routes/engagements.ts"; import { entryRoutes } from "./routes/entries.ts"; import { exportRoutes } from "./routes/export.ts"; import { listRoutes } from "./routes/lists.ts"; +import { logRoutes } from "./routes/log.ts"; import { metaRoutes } from "./routes/meta.ts"; import { sourceRoutes } from "./routes/sources.ts"; import { workRoutes } from "./routes/works.ts"; @@ -26,6 +27,7 @@ export function createApp(deps: AppDeps): App { ...engagementRoutes, ...workRoutes, ...listRoutes, + ...logRoutes, ...copyRoutes, ...customFieldRoutes, ...attachmentRoutes, diff --git a/apps/server/src/routes/log.ts b/apps/server/src/routes/log.ts new file mode 100644 index 0000000..874fc10 --- /dev/null +++ b/apps/server/src/routes/log.ts @@ -0,0 +1,15 @@ +import { listLog } from "@sttracker/core/log"; +import { z } from "zod"; +import { route } from "../http.ts"; +import { qInt } from "./schemas.ts"; + +export const logRoutes = [ + route({ + method: "GET", + path: "log", + summary: "Your Log: engagements and listens, newest first (keyset cursor).", + auth: "any:read", + query: z.object({ limit: qInt.optional(), cursor: z.string().max(2000).optional() }), + handler: async ({ deps, user, query }) => listLog(deps.db, user.id, query), + }), +]; diff --git a/apps/server/test/api.test.ts b/apps/server/test/api.test.ts index ac34635..4ab1ccb 100644 --- a/apps/server/test/api.test.ts +++ b/apps/server/test/api.test.ts @@ -329,6 +329,25 @@ forEachDialect("api", (getDb) => { expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "5" }, noCsrf: true })).status).toBe(403); }); + test("log: engagements newest first with keyset paging; copies carry workTitle", async () => { + const ana = await signUp(app, "ana"); + const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string; workId: string }; + for (const value of ["2026-01-02", "2026-03-04"]) { + const r = await call(app, "POST", `entries/${e.id}/engagements`, { cookie: ana, body: { finishedAt: { value, precision: "day" } } }); + expect(r.status).toBe(201); + } + const page1 = (await (await call(app, "GET", "log?limit=1", { cookie: ana })).json()) as { items: { at: string; title: string; kind: string }[]; nextCursor: string }; + expect(page1.items).toEqual([expect.objectContaining({ kind: "engagement", at: "2026-03-04", title: "Dune" })]); + const page2 = (await (await call(app, "GET", `log?limit=1&cursor=${page1.nextCursor}`, { cookie: ana })).json()) as { items: { at: string }[]; nextCursor: string | null }; + expect(page2.items.map((i) => i.at)).toEqual(["2026-01-02"]); + expect(page2.nextCursor).toBeNull(); + expect((await call(app, "GET", "log?cursor=bad", { cookie: ana })).status).toBe(400); + expect((await call(app, "GET", "log")).status).toBe(401); + + await call(app, "POST", "copies", { cookie: ana, body: { workId: e.workId, format: "Paperback" } }); + expect(await (await call(app, "GET", "copies", { cookie: ana })).json()).toEqual([expect.objectContaining({ workTitle: "Dune", format: "Paperback" })]); + }); + test("JSON bodies need Content-Type application/json (415)", async () => { const res = await app.fetch( new Request(`${BASE}/auth/register`, { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67e31ca..6a3730e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ user layer or files a Changes-inbox event. |---|---|---| | `apps/server` | `main.ts` boots (config → `createDb` → `migrateToLatest` → `loadPlugins` → worker + scheduler + daily backup → `Bun.serve`). `app.ts` `createApp(deps)` = `/api/v1` route table + `/ingest/*` mount (404 in Phase 1), testable via `app.fetch`. `http.ts` = `route()`, auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. `routes/.ts` one file per area; `openapi.ts` builds `/api/v1/openapi.json` from the table | exists | | `apps/web` | React 19 app served by the server as an HTML import (`index.html` → `src/main.tsx`, `src/theme.css`). wouter routes in `main.tsx` (`/login`, `/register`, `/`, `/entries/:id`; other sections are placeholders until Task 18); `api.ts` = fetch client (`request`, `ApiError`, cookie auth, CSRF header, mirrored wire types); `format.ts` = pure display helpers and `friendlyError`; `edits.ts` = next-value builders for entry edits; `hooks.ts` = TanStack Query hooks incl. optimistic `useEntryPatch`; `settings.ts` = preferences; `components/` (shell, detail boxes, history drawer, rating control) and `routes/`. Never imports `packages/*` at runtime | exists | -| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `preferences` (per-user rating scale and default visibility), `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) | +| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `preferences` (per-user rating scale and default visibility), `log` (the Log feed: engagements and listens, keyset-paged), `titles` (batched override-aware title lookups for list views), `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) | | `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) | | `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned | | `plugins/` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0d1be58..b004b5b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,6 +4,22 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin One entry per decision a future maintainer would otherwise have to reverse-engineer. No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number). +## 2026-10-30: Log feed, titles on Changes, work titles on copies (Task 18 API additions) +**Status:** accepted +**Context:** Task 18's Log and Changes screens had nothing to read from (controller ruling). +**Decision:** +- **`GET log?cursor=&limit=`** (any:read) → `{ items, nextCursor }` from `packages/core/src/log.ts` `listLog`: the user's + live engagements on live entries plus their listens, newest first. Each item's `at` is a listen's `listened_at`, or an + engagement's finish date, else start date, else `created_at`. Partial dates sort as strings, never through `Date`. + Keyset cursor over (`at`, `kind`, `id`) all descending, base64url JSON; default 50, max 200. Two queries (one per + kind, each `limit + 1` past the cursor) are merged in code, so no dialect-specific UNION is needed. +- **`listChanges` items carry `title`** (an entry's title with the user's override; an engagement resolves to its entry; a + list to its name; null when gone) **and, on merged events, `intoTitle`**: the title of the entry that the same + connection's `intoKey` record maps to, else null. Titles come from a fixed number of batched queries + (`packages/core/src/titles.ts`), not one per event. +- **`listCopies` items carry `workTitle`** (override-aware, null while unmatched) so Owned needs no per-row work lookups. + The export bundle is unchanged. + ## 2026-10-30: Web app (Task 17): preferences, first run, optimistic edits, AA on orange, self-hosted fonts **Status:** accepted **Context:** Task 17 and its fix round 1 (controller rulings). diff --git a/packages/core/src/copies.ts b/packages/core/src/copies.ts index a8231cf..b23f79f 100644 --- a/packages/core/src/copies.ts +++ b/packages/core/src/copies.ts @@ -7,6 +7,7 @@ import { customFieldsFor, validateCustomValues } from "./custom-fields.ts"; import { checkKeys, checkText, fromStoredDate, resolveVisibleWork, toStoredDate } from "./entries.ts"; import { NotFoundError, ValidationError } from "./errors.ts"; import { type Actor, type Executor, inTx, recordHistory } from "./history.ts"; +import { workTitles } from "./titles.ts"; /** An owned copy (spec §6.7). Always private. `workId` is null until matched; then `titleHint` names it. */ export interface CopyPatch { @@ -224,12 +225,16 @@ export async function softDeleteCopy( await inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), {}, actor, { deleted_at: nowIso() })); } -/** The user's live copies, optionally of one work, newest first. */ -export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise { +/** + * The user's live copies, optionally of one work, newest first. `workTitle` is the matched work's title as + * the user sees it (their entry's title override wins); null while unmatched. + */ +export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise<(Copy & { workTitle: string | null })[]> { let q = db.k.selectFrom("copies").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null); if (filter.workId) q = q.where("work_id", "=", filter.workId); const rows = await q.orderBy("created_at", "desc").orderBy("id", "desc").execute(); - return rows.map(toCopy); + const titles = await workTitles(db.k, userId, rows.map((r) => r.work_id)); + return rows.map((r) => ({ ...toCopy(r), workTitle: (r.work_id && titles.get(r.work_id)) || null })); } /** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */ diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts new file mode 100644 index 0000000..f6de637 --- /dev/null +++ b/packages/core/src/log.ts @@ -0,0 +1,168 @@ +/** + * The Log: one chronological feed of a user's engagements (reads, plays, watches) and listens, newest first. + * + * Each item has an `at` sort key: a listen's `listened_at`; an engagement's finish date, else its start + * date, else when it was logged. Partial dates sort as strings ("2025-03" sits after "2025-03-31" and + * before "2025-04"), never through `Date`. Pages use a keyset cursor over (at, kind, id), all descending, + * so equal timestamps across the two kinds page without gaps or repeats. + */ +import type { Db } from "@sttracker/db/db"; +import { fromJson } from "@sttracker/db/json"; +import type { PartialDate } from "@sttracker/plugin-sdk/types"; +import { sql } from "kysely"; +import { fromStoredDate } from "./entries.ts"; +import { ValidationError } from "./errors.ts"; + +export type LogItem = + | { + kind: "engagement"; + id: string; + at: string; + entryId: string; + workId: string; + title: string; + mediaType: string; + coverUrl: string; + startedAt: PartialDate | null; + finishedAt: PartialDate | null; + platform: string | null; + rating: number | null; + } + | { + kind: "listen"; + id: string; + at: string; + workId: string | null; + title: string; + mediaType: "track"; + artist: string; + track: string; + album: string | null; + source: string; + }; + +type Kind = LogItem["kind"]; +interface Cursor { + at: string; + kind: Kind; + id: string; +} + +function encodeCursor(c: Cursor): string { + return Buffer.from(JSON.stringify([c.at, c.kind, c.id])).toString("base64url"); +} + +function decodeCursor(s: string): Cursor { + try { + const [at, kind, id] = JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as unknown[]; + if (typeof at === "string" && (kind === "engagement" || kind === "listen") && typeof id === "string") return { at, kind, id }; + } catch { + // Fall through to the validation error. + } + throw new ValidationError("cursor", "invalid"); +} + +/** (at, kind, id) < cursor, with this query's fixed kind: kinds sort "listen" > "engagement". */ +function after(kind: Kind, c: Cursor): "lt" | "le" | "tie" { + if (kind === c.kind) return "tie"; + return kind < c.kind ? "le" : "lt"; +} + +export async function listLog( + db: Db, + userId: string, + opts: { limit?: number; cursor?: string } = {}, +): Promise<{ items: LogItem[]; nextCursor: string | null }> { + const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); + const cursor = opts.cursor ? decodeCursor(opts.cursor) : null; + + const at = sql`coalesce(nullif(g.finished_at, ''), nullif(g.started_at, ''), g.created_at)`; + let eq = db.k + .selectFrom("engagements as g") + .innerJoin("entries as e", "e.id", "g.entry_id") + .innerJoin("works as w", "w.id", "e.work_id") + .select([ + "g.id", + "g.entry_id", + "g.started_at", + "g.started_precision", + "g.finished_at", + "g.finished_precision", + "g.platform", + "g.rating", + "e.work_id", + "e.overrides", + "e.media_type_override", + "w.media_type", + "w.title", + "w.cover_url", + at.as("at"), + ]) + .where("e.user_id", "=", userId) + .where("e.deleted_at", "is", null) + .where("g.deleted_at", "is", null); + let lq = db.k + .selectFrom("listens") + .select(["id", "listened_at", "artist", "track", "album", "work_id", "source"]) + .where("user_id", "=", userId); + + if (cursor) { + const ge = after("engagement", cursor); + eq = eq.where((x) => + ge === "lt" ? x(at, "<", cursor.at) : ge === "le" ? x(at, "<=", cursor.at) : x.or([x(at, "<", cursor.at), x.and([x(at, "=", cursor.at), x("g.id", "<", cursor.id)])]), + ); + const gl = after("listen", cursor); + lq = lq.where((x) => + gl === "lt" + ? x("listened_at", "<", cursor.at) + : gl === "le" + ? x("listened_at", "<=", cursor.at) + : x.or([x("listened_at", "<", cursor.at), x.and([x("listened_at", "=", cursor.at), x("id", "<", cursor.id)])]), + ); + } + + const [engagements, listens] = await Promise.all([ + eq.orderBy(at, "desc").orderBy("g.id", "desc").limit(limit + 1).execute(), + lq.orderBy("listened_at", "desc").orderBy("id", "desc").limit(limit + 1).execute(), + ]); + + const merged: LogItem[] = [ + ...engagements.map((r): LogItem => { + const o = fromJson<{ title?: unknown; coverUrl?: unknown }>(r.overrides, {}); + return { + kind: "engagement", + id: r.id, + at: r.at, + entryId: r.entry_id, + workId: r.work_id, + title: typeof o.title === "string" && o.title ? o.title : r.title, + mediaType: r.media_type_override ?? r.media_type, + coverUrl: typeof o.coverUrl === "string" ? o.coverUrl : r.cover_url, + startedAt: fromStoredDate(r.started_at, r.started_precision), + finishedAt: fromStoredDate(r.finished_at, r.finished_precision), + platform: r.platform, + rating: r.rating, + }; + }), + ...listens.map( + (r): LogItem => ({ + kind: "listen", + id: r.id, + at: r.listened_at, + workId: r.work_id, + title: r.track, + mediaType: "track", + artist: r.artist, + track: r.track, + album: r.album, + source: r.source, + }), + ), + ]; + // Same order as the SQL: at, then kind, then id, all descending. Plain code-unit comparison on both sides. + const cmp = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0); + merged.sort((a, b) => cmp(a.at, b.at) || cmp(a.kind, b.kind) || cmp(a.id, b.id)); + const items = merged.slice(0, limit); + const last = items[items.length - 1]; + return { items, nextCursor: merged.length > limit && last ? encodeCursor(last) : null }; +} diff --git a/packages/core/src/sync/changes.ts b/packages/core/src/sync/changes.ts index 03ecf6b..2fc04f6 100644 --- a/packages/core/src/sync/changes.ts +++ b/packages/core/src/sync/changes.ts @@ -9,6 +9,7 @@ import { NotFoundError, ValidationError } from "../errors.ts"; import type { Actor, Executor } from "../history.ts"; import { softDeleteList } from "../lists.ts"; import { matchWork } from "../matching.ts"; +import { entryTitles } from "../titles.ts"; import { resolveAliasIn } from "../works.ts"; import { applyPatch, engagementValues, entryValues } from "./apply.ts"; import { decodeVersion } from "./snapshot.ts"; @@ -27,6 +28,14 @@ export interface ChangeEvent { resolvedAt: string | null; } +/** An inbox item: the event plus the titles the Changes screen shows. */ +export interface ChangeItem extends ChangeEvent { + /** The target's title: an entry's (override-aware), an engagement's entry's, a list's name. Null if gone. */ + title: string | null; + /** Merged events only: the title of the entry the merge target record maps to, else null. */ + intoTitle?: string | null; +} + function toChange(r: Database["change_events"]): ChangeEvent { return { id: r.id, @@ -51,15 +60,63 @@ export async function listChanges( db: Db, userId: string, opts: { status?: "pending" | "all"; connectionId?: string; limit?: number; cursor?: string } = {}, -): Promise<{ items: ChangeEvent[]; nextCursor: string | null }> { +): Promise<{ items: ChangeItem[]; nextCursor: string | null }> { const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); let q = db.k.selectFrom("change_events").selectAll().where("user_id", "=", userId); if ((opts.status ?? "pending") === "pending") q = q.where("status", "=", "pending"); if (opts.connectionId) q = q.where("connection_id", "=", opts.connectionId); if (opts.cursor) q = q.where("id", "<", opts.cursor); const rows = await q.orderBy("id", "desc").limit(limit + 1).execute(); - const items = rows.slice(0, limit).map(toChange); - return { items, nextCursor: rows.length > limit ? items[items.length - 1]!.id : null }; + const events = rows.slice(0, limit).map(toChange); + return { items: await withTitles(db.k, userId, events), nextCursor: rows.length > limit ? events[events.length - 1]!.id : null }; +} + +/** Attach titles with a fixed number of batched queries, whatever the page size. */ +async function withTitles(k: Executor, userId: string, events: ChangeEvent[]): Promise { + const ids = (entity: string) => [...new Set(events.filter((e) => e.targetEntity === entity && e.targetId).map((e) => e.targetId!))]; + + // Merged events: the record their intoKey names (same connection) → its target. + const merges = events.filter((e) => e.type === "merged" && e.connectionId && typeof e.payload.intoKey === "string"); + const intoRecords = merges.length + ? await k + .selectFrom("source_records") + .select(["connection_id", "key", "target_entity", "target_id"]) + .where("connection_id", "in", [...new Set(merges.map((e) => e.connectionId!))]) + .where("key", "in", [...new Set(merges.map((e) => String(e.payload.intoKey)))]) + .execute() + : []; + const intoTarget = new Map(intoRecords.map((r) => [`${r.connection_id}\u0000${r.key}`, r])); + + const engagementIds = [...ids("engagement"), ...intoRecords.filter((r) => r.target_entity === "engagement").map((r) => r.target_id!)]; + const engagementEntry = new Map(); + if (engagementIds.length) { + const rows = await k.selectFrom("engagements").select(["id", "entry_id"]).where("id", "in", [...new Set(engagementIds)]).execute(); + for (const r of rows) engagementEntry.set(r.id, r.entry_id); + } + const entryOf = (entity: string | null, id: string | null): string | null => + !id ? null : entity === "entry" ? id : entity === "engagement" ? (engagementEntry.get(id) ?? null) : null; + + const entryIds = [ + ...ids("entry"), + ...engagementEntry.values(), + ...intoRecords.map((r) => entryOf(r.target_entity, r.target_id)), + ]; + const entryTitle = await entryTitles(k, userId, entryIds); + const listIds = ids("list"); + const listName = new Map(); + if (listIds.length) { + const rows = await k.selectFrom("lists").select(["id", "name"]).where("user_id", "=", userId).where("id", "in", listIds).execute(); + for (const r of rows) listName.set(r.id, r.name); + } + + return events.map((e) => { + const title = + e.targetEntity === "list" ? (listName.get(e.targetId ?? "") ?? null) : (entryTitle.get(entryOf(e.targetEntity, e.targetId) ?? "") ?? null); + if (e.type !== "merged") return { ...e, title }; + const into = intoTarget.get(`${e.connectionId}\u0000${String(e.payload.intoKey)}`); + const intoEntry = into ? entryOf(into.target_entity, into.target_id) : null; + return { ...e, title, intoTitle: (intoEntry && entryTitle.get(intoEntry)) || null }; + }); } const STATUS_FOR = { accept: "accepted", ignore: "ignored", keep_mine: "kept_mine" } as const; diff --git a/packages/core/src/titles.ts b/packages/core/src/titles.ts new file mode 100644 index 0000000..41771c8 --- /dev/null +++ b/packages/core/src/titles.ts @@ -0,0 +1,50 @@ +/** + * Batched title lookups for views that list many things at once (Changes, Log, Owned). Titles are what + * the user sees: their entry's `overrides.title` when set, else the work's title. + */ +import { fromJson } from "@sttracker/db/json"; +import type { Executor } from "./history.ts"; + +const unique = (ids: (string | null | undefined)[]) => [...new Set(ids.filter((x): x is string => !!x))]; + +function overrideTitle(overrides: string): string | undefined { + const t = fromJson<{ title?: unknown }>(overrides, {}).title; + return typeof t === "string" && t ? t : undefined; +} + +/** Work id → title as this user sees it (their live entry's title override, else the work's title). */ +export async function workTitles(k: Executor, userId: string, workIds: (string | null | undefined)[]): Promise> { + const ids = unique(workIds); + const out = new Map(); + if (ids.length === 0) return out; + const works = await k.selectFrom("works").select(["id", "title"]).where("id", "in", ids).execute(); + for (const w of works) out.set(w.id, w.title); + const entries = await k + .selectFrom("entries") + .select(["work_id", "overrides"]) + .where("user_id", "=", userId) + .where("work_id", "in", ids) + .where("deleted_at", "is", null) + .execute(); + for (const e of entries) { + const t = overrideTitle(e.overrides); + if (t) out.set(e.work_id, t); + } + return out; +} + +/** Entry id → title (override-aware). Deleted entries keep their title; other users' entries are absent. */ +export async function entryTitles(k: Executor, userId: string, entryIds: (string | null | undefined)[]): Promise> { + const ids = unique(entryIds); + const out = new Map(); + if (ids.length === 0) return out; + const rows = await k + .selectFrom("entries as e") + .innerJoin("works as w", "w.id", "e.work_id") + .select(["e.id", "e.overrides", "w.title"]) + .where("e.user_id", "=", userId) + .where("e.id", "in", ids) + .execute(); + for (const r of rows) out.set(r.id, overrideTitle(r.overrides) ?? r.title); + return out; +} diff --git a/packages/core/test/copies.test.ts b/packages/core/test/copies.test.ts index 8b3c45f..a14886d 100644 --- a/packages/core/test/copies.test.ts +++ b/packages/core/test/copies.test.ts @@ -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"); + }); }); diff --git a/packages/core/test/log.test.ts b/packages/core/test/log.test.ts new file mode 100644 index 0000000..f927922 --- /dev/null +++ b/packages/core/test/log.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/core/test/sync/change-titles.test.ts b/packages/core/test/sync/change-titles.test.ts new file mode 100644 index 0000000..9e71ab1 --- /dev/null +++ b/packages/core/test/sync/change-titles.test.ts @@ -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 }, +): Promise { + 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 { + 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(); + }); +});