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