2 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5.5 53d9e7c9c9 feat(server): plugins list says which sources sign in with a device code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:13:24 +01:00
Lucas WintherandClaude Opus 5.5 c2e8c83f86 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 <[email protected]>
2026-10-30 22:11:53 +01:00
21 changed files with 605 additions and 187 deletions
+2
View File
@@ -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,
+15
View File
@@ -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),
}),
];
+2 -2
View File
@@ -38,9 +38,9 @@ export const sourceRoutes = [
route({
method: "GET",
path: "plugins",
summary: "Installed source plugins (manifests).",
summary: "Installed source plugins (manifests, plus deviceAuth: whether sign-in uses a device code).",
auth: "any:read",
handler: async ({ deps }) => deps.registry.list(),
handler: async ({ deps }) => deps.registry.list().map((m) => ({ ...m, deviceAuth: !!deps.registry.get(m.id)?.deviceAuth })),
}),
route({
method: "GET",
+25
View File
@@ -329,6 +329,31 @@ 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("plugins: manifests say whether the source signs in with a device code", async () => {
const ana = await signUp(app, "ana");
const list = (await (await call(app, "GET", "plugins", { cookie: ana })).json()) as { id: string; deviceAuth: boolean; config: unknown[] }[];
expect(list.find((p) => p.id === "echo")).toEqual(expect.objectContaining({ deviceAuth: false, config: expect.any(Array) }));
});
test("JSON bodies need Content-Type application/json (415)", async () => {
const res = await app.fetch(
new Request(`${BASE}/auth/register`, {
+18 -34
View File
@@ -6,21 +6,16 @@ import { orderEngagements, setCustomValue } from "../edits.ts";
import {
actorConnectionId,
describeRating,
draftIncoming,
draftSettled,
draftStartSave,
engagementNouns,
formatDateRange,
formatPartialDate,
formatRating,
friendlyError,
newDraft,
ordinal,
parsePartialDate,
rollupLabel,
statusLabel,
timeAgo,
type Draft,
type PartialDate,
} from "../format.ts";
import { useEntryPatch, useSources } from "../hooks.ts";
@@ -362,7 +357,6 @@ export function SourcesBox({ entry, engagements }: { entry: EntryView; engagemen
export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: string }) {
const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 });
const patch = useEntryPatch(entry.id);
const scale = useRatingScale();
if (!defs.data?.length) return null;
const save = (def: CustomFieldDef, value: unknown) =>
patch.edit((cur) => {
@@ -376,7 +370,7 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin
<CustomField key={d.id} def={d} value={entry.customFields[d.id]} onSave={(v) => save(d, v)} />
))}
</div>
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error, { ratingScale: scale })}</p>}
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
</Box>
);
}
@@ -498,29 +492,23 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string })
// ---------------------------------------------------------------------------------------------
export function ReviewBox({ entry }: { entry: EntryView }) {
const patch = useEntryPatch(entry.id);
const [saved, setSaved] = useState(false);
// Local text plus the server value it last agreed with (see `Draft` in format.ts). A Restore shows
// up here unless the user has typed since; a failed save keeps what they typed.
const [draft, setDraft] = useState<Draft>(() => newDraft(entry.review ?? ""));
// Saves in flight, oldest first. The entry-patch scope runs them serially, so they settle in order.
const saves = useRef<{ was: string; stored: string }[]>([]);
const patch = useEntryPatch(entry.id, {
onSettled: (_p, error) => {
const save = saves.current.shift();
if (!save) return;
setDraft((d) => draftSettled(d, save, error === null));
setSaved(error === null && saves.current.length === 0);
},
});
useEffect(() => setDraft((d) => draftIncoming(d, entry.review ?? "")), [entry.review]);
const [text, setText] = useState(entry.review ?? "");
// The server value this text was last in sync with. When the review changes elsewhere (Restore,
// another tab) and the user has not typed since, show the new value, so a later blur can't undo it.
const synced = useRef(entry.review ?? "");
useEffect(() => {
const incoming = entry.review ?? "";
if (incoming === synced.current) return;
setText((cur) => (cur === synced.current ? incoming : cur));
synced.current = incoming;
}, [entry.review]);
const save = () => {
const next = draft.text.trim() === "" ? null : draft.text;
const stored = next ?? "";
if (stored === draft.synced && stored === (entry.review ?? "")) return;
const s = { was: draft.synced, stored };
saves.current.push(s);
setDraft((d) => draftStartSave(d, s));
patch.mutate({ review: next });
const next = text.trim() === "" ? null : text;
if ((next ?? "") === (entry.review ?? "")) return;
synced.current = next ?? "";
patch.mutate({ review: next }, { onSuccess: () => setSaved(true) });
};
return (
<Box
@@ -533,13 +521,9 @@ export function ReviewBox({ entry }: { entry: EntryView }) {
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
<textarea
id="review-text"
value={draft.text}
value={text}
placeholder="What stayed with you? Markdown works here."
onChange={(e) => {
const text = e.target.value;
setDraft((d) => ({ ...d, text }));
setSaved(false);
}}
onChange={(e) => (setText(e.target.value), setSaved(false))}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save();
+2 -4
View File
@@ -18,9 +18,7 @@ const TIMES = ["", "once", "twice", "three times"];
export function EntryDetail({ id }: { id: string }) {
const entry = useQuery({ queryKey: ["entry", id], queryFn: () => api.entry(id) });
if (entry.isPending) return <div className="detail" aria-busy="true" />;
// A failed background refetch (server briefly down) keeps showing the cached entry, so nothing the
// user is typing gets unmounted; only a first load that fails shows the error screen.
if (!entry.data) {
if (entry.error) {
const gone = entry.error instanceof ApiError && entry.error.status === 404;
return (
<div className="detail-empty" role="alert">
@@ -98,7 +96,7 @@ function Detail({ entry }: { entry: EntryView }) {
onAdd={(t) => patch.edit((cur) => ({ tags: addTag(cur.tags, t) }))}
onRemove={(t) => patch.edit((cur) => ({ tags: removeTag(cur.tags, t) }))}
/>
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error, { ratingScale: scale })}</p>}
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
</div>
</header>
@@ -30,8 +30,6 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
const closeBtn = useRef<HTMLButtonElement>(null);
const history = useQuery({ queryKey: ["history", entry.id], queryFn: () => api.history(entry.id) });
const restore = useMutation({
// Same scope as the entry's PATCHes (useEntryPatch), so a restore never races a pending blur-save.
scope: { id: `entry-patch:${entry.id}` },
mutationFn: api.restore,
onSuccess: () => {
for (const key of [["entry", entry.id], ["history", entry.id], ["engagements", entry.id], ["entries"]]) void qc.invalidateQueries({ queryKey: key });
+2 -56
View File
@@ -231,9 +231,8 @@ const TOO_LARGE = "That file is too large. The limit is 25 MB.";
/**
* An API error in the product's voice: what happened and how to fix it. Validation messages arrive as
* `field: reason` (core) or `field: Zod message`, several joined by "; "; the first one is described.
* Pass the user's rating scale so out-of-range ratings are described in the scale they see.
*/
export function friendlyError(err: unknown, opts: { ratingScale?: RatingScale } = {}): string {
export function friendlyError(err: unknown): string {
if (!err || typeof err !== "object" || !("code" in err) || !("message" in err)) return "Something went wrong. Try again.";
const { status, code, message } = err as { status?: number; code: string; message: string };
if (status === 413) return TOO_LARGE;
@@ -250,11 +249,7 @@ export function friendlyError(err: unknown, opts: { ratingScale?: RatingScale }
if (field === "currency" || reason === "not_cents") return "Price needs a number like 14 or 14.99, and a three-letter currency like GBP.";
if (reason === "not_url") return `${label} needs a full web address, starting with https://.`;
if (reason === "not_date" || reason === "bad_precision") return `${label} needs a date like 2025, 2025-03 or 2025-03-14.`;
if (reason === "out_of_range") {
const scale = opts.ratingScale ?? "5half";
const range = scale === "10" ? "0 and 10" : scale === "100" ? "0 and 100" : "0 and 5 stars";
return label === "Rating" ? `Pick a rating between ${range}.` : `Pick a rating for ${label} between ${range}.`;
}
if (reason === "out_of_range") return `${label} is out of range. Ratings go from 0 to 100.`;
if (reason === "required" || /^Too small: .*>=1 (character|item)/.test(reason) || /received undefined$/.test(reason)) return `${label} can't be empty.`;
if (reason === "too_long" || reason.startsWith("Too big")) return `${label} is too long. Shorten it and try again.`;
if (reason.startsWith("Too small")) return `${label} is too short.`;
@@ -263,52 +258,3 @@ export function friendlyError(err: unknown, opts: { ratingScale?: RatingScale }
if (reason === "duplicate") return `${label} is already in use. Pick another.`;
return `${label} wasn't accepted. Check it and try again.`;
}
// ---------------------------------------------------------------------------------------------
// Review draft sync
// ---------------------------------------------------------------------------------------------
/**
* A text field edited locally and saved on blur. `synced` is the server value the text last agreed
* with; text that differs from it is unsaved and is never replaced. `pending` counts saves in flight
* (one entry's saves run serially, so they settle in order). While any is pending, incoming server
* values are only remembered in `incoming`. Values that are our own saves' echoes (optimistic updates
* show a save's `stored`, rollbacks show its `was`) are listed in `echoes` and ignored; any other
* value (an outside change, a Restore) is applied once the saves settle. So the outcome does not
* depend on the order in which rollbacks, refetches and settle callbacks land.
*/
export interface Draft {
text: string;
synced: string;
pending: number;
incoming: string;
echoes: string[];
}
export function newDraft(value: string): Draft {
return { text: value, synced: value, pending: 0, incoming: value, echoes: [] };
}
/** A server value arrived. Untouched text follows it; edited text is kept. Deferred while saving. */
export function draftIncoming(d: Draft, incoming: string): Draft {
if (d.pending > 0 || incoming === d.synced) return { ...d, incoming };
return { ...d, text: d.text === d.synced ? incoming : d.text, synced: incoming, incoming };
}
/** A save of `stored` started (`was` is the synced value before it): it counts as synced unless it fails. */
export function draftStartSave(d: Draft, save: { was: string; stored: string }): Draft {
return { ...d, synced: save.stored, pending: d.pending + 1, echoes: [...d.echoes, save.was, save.stored] };
}
/**
* The oldest pending save settled. Success: `stored` is on the server. Failure: back to `was`, so the
* local text still counts as unsaved. When the last pending save settles, a remembered incoming value
* that is not one of our own echoes is applied.
*/
export function draftSettled(d: Draft, save: { was: string; stored: string }, ok: boolean): Draft {
const pending = Math.max(0, d.pending - 1);
const next: Draft = { ...d, synced: ok ? save.stored : save.was, pending };
if (pending > 0) return next;
const settled: Draft = { ...next, echoes: [] };
return d.echoes.includes(d.incoming) ? settled : draftIncoming(settled, d.incoming);
}
+3 -10
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { api, type ChangeEvent, type EntryPatch, type EntryView } from "./api.ts";
import { applyEntryPatch } from "./edits.ts";
@@ -37,15 +37,9 @@ export function useDebounced<T>(value: T, ms = 220): T {
* error) and PATCHes for one entry run one at a time (mutation scope), in the order they were made.
* Use `edit(cur => patch)` when the next value depends on the current one (tags, custom fields): it
* builds from the latest cache, so quick successive edits compose.
*
* `onSettled` fires once for EVERY mutation of this hook (it is a hook-level option). Per-call
* `mutate(patch, { onSuccess })` callbacks only fire for the observer's latest call, so never use
* them for bookkeeping.
*/
export function useEntryPatch(id: string, opts: { onSettled?: (patch: EntryPatch, error: Error | null) => void } = {}) {
export function useEntryPatch(id: string) {
const qc = useQueryClient();
const onSettledRef = useRef(opts.onSettled);
onSettledRef.current = opts.onSettled;
const key = ["entry", id];
const mutationKey = ["entry-patch", id];
const m = useMutation({
@@ -66,10 +60,9 @@ export function useEntryPatch(id: string, opts: { onSettled?: (patch: EntryPatch
// Only the last queued edit writes the server's answer, so earlier answers don't flash old values.
if (qc.isMutating({ mutationKey }) <= 1) qc.setQueryData(key, entry);
},
onSettled: (_entry, error, patch) => {
onSettled: () => {
void qc.invalidateQueries({ queryKey: ["entries"] });
void qc.invalidateQueries({ queryKey: ["history", id] });
onSettledRef.current?.(patch, error);
},
});
const edit = (build: (cur: EntryView) => EntryPatch | null) => {
-1
View File
@@ -29,7 +29,6 @@ export function Register() {
setError(null);
try {
await api.register({ username, password, displayName: displayName || undefined, inviteCode: inviteCode || undefined });
void qc.invalidateQueries({ queryKey: ["auth-status"] });
qc.setQueryData(["me"], await api.login(username, password));
navigate("/", { replace: true });
} catch (err) {
+2 -54
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { draftIncoming, draftSettled, draftStartSave, newDraft, type Draft, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
import { formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
describe("formatRating", () => {
test("5 stars with halves", () => {
@@ -72,9 +72,7 @@ describe("friendlyError", () => {
test("maps validation reasons to plain copy", () => {
expect(friendlyError(v("customFields.Website: not_url"))).toBe("Website needs a full web address, starting with https://.");
expect(friendlyError(v("finishedAt: not_date"))).toBe("Finished needs a date like 2025, 2025-03 or 2025-03-14.");
expect(friendlyError(v("rating: out_of_range"))).toBe("Pick a rating between 0 and 5 stars.");
expect(friendlyError(v("rating: out_of_range"), { ratingScale: "10" })).toBe("Pick a rating between 0 and 10.");
expect(friendlyError(v("customFields.Mood: out_of_range"), { ratingScale: "5" })).toBe("Pick a rating for Mood between 0 and 5 stars.");
expect(friendlyError(v("rating: out_of_range"))).toBe("Rating is out of range. Ratings go from 0 to 100.");
expect(friendlyError(v("title: required"))).toBe("Title can't be empty.");
expect(friendlyError(v("review: too_long"))).toBe("Review is too long. Shorten it and try again.");
expect(friendlyError(v("password: Too small: expected string to have >=8 characters"))).toBe("Passwords need at least 8 characters.");
@@ -96,53 +94,3 @@ describe("friendlyError", () => {
test("shortDate", () => {
expect(shortDate("2026-10-12T10:00:00")).toBe("12 Oct");
});
describe("review draft sync", () => {
const draft = (text: string, synced: string): Draft => ({ ...newDraft(synced), text });
const pick = (d: Draft) => [d.text, d.synced, d.pending];
test("a rollback after a failed save keeps the local text", () => {
const save = { was: "First thoughts.", stored: "Second thoughts." };
let d = draftStartSave(draft("Second thoughts.", save.was), save);
d = draftIncoming(d, save.stored); // optimistic update
d = draftIncoming(d, save.was); // rollback
d = draftSettled(d, save, false);
expect(pick(d)).toEqual(["Second thoughts.", "First thoughts.", 0]);
});
// Two overlapping saves: "a" (was "old"), then "b" (was "a"). Incoming values mirror what the cache does.
const A = { was: "old", stored: "a" };
const B = { was: "a", stored: "b" };
const twoSaves = () => draftIncoming(draftIncoming(draftStartSave({ ...draftStartSave(draft("a", "old"), A), text: "b" }, B), "a"), "b");
test("two overlapping saves both settle and the flag clears (success, success)", () => {
let d = twoSaves();
expect(d.pending).toBe(2);
d = draftSettled(draftSettled(d, A, true), B, true);
expect(pick(d)).toEqual(["b", "b", 0]);
expect(d.echoes).toEqual([]);
});
test("two overlapping saves: fail then success ends synced; success then fail keeps text unsaved", () => {
let d = draftIncoming(twoSaves(), "old"); // A's rollback
d = draftSettled(draftSettled(d, A, false), B, true);
expect(pick(d)).toEqual(["b", "b", 0]);
let e = draftIncoming(twoSaves(), "a"); // B's rollback
e = draftSettled(draftSettled(e, A, true), B, false);
expect(pick(e)).toEqual(["b", "a", 0]);
});
test("an incoming value that arrives while saving is applied once saving ends", () => {
const save = { was: "x", stored: "y" };
let d = draftStartSave(draft("y", "x"), save);
d = draftIncoming(d, "Restored.");
expect(d.text).toBe("y");
d = draftSettled(d, save, true);
expect(pick(d)).toEqual(["Restored.", "Restored.", 0]);
});
test("a change from elsewhere replaces untouched text but not edited text", () => {
expect(draftIncoming(draft("Second.", "Second."), "First.").text).toBe("First.");
expect(draftIncoming(draft("Typed.", "Second."), "Other.").text).toBe("Typed.");
});
});
+1 -1
View File
@@ -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/<area>.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/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
+18
View File
@@ -4,6 +4,24 @@ 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.
- **`GET plugins` adds `deviceAuth: boolean`** to each manifest, so the Sources screen knows when to offer the
device-code sign-in (the manifest itself has no such capability).
- **`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).
+8 -3
View File
@@ -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<Copy[]> {
/**
* 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". */
+168
View File
@@ -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<string>`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 };
}
+7 -17
View File
@@ -1,5 +1,4 @@
import type { Db } from "@sttracker/db/db";
import type { Executor } from "./history.ts";
import { fromJson, toJson } from "@sttracker/db/json";
import { nowIso } from "@sttracker/db/ids";
import type { Visibility } from "@sttracker/db/schema";
@@ -17,20 +16,15 @@ export interface Preferences {
export const DEFAULT_PREFERENCES: Preferences = { ratingScale: "5half", defaultVisibility: "private" };
async function readRaw(k: Executor, userId: string, lock = false): Promise<Record<string, unknown>> {
let q = k.selectFrom("users").select("preferences").where("id", "=", userId);
if (lock) q = q.forUpdate();
const u = await q.executeTakeFirst();
async function readRaw(db: Db, userId: string): Promise<Record<string, unknown>> {
const u = await db.k.selectFrom("users").select("preferences").where("id", "=", userId).executeTakeFirst();
if (!u) throw new NotFoundError("user");
return fromJson<Record<string, unknown>>(u.preferences, {});
}
/** The user's preferences; missing or invalid stored values fall back to the defaults. */
export async function getPreferences(db: Db, userId: string): Promise<Preferences> {
return toPreferences(await readRaw(db.k, userId));
}
function toPreferences(raw: Record<string, unknown>): Preferences {
const raw = await readRaw(db, userId);
return {
ratingScale: (RATING_SCALES as readonly unknown[]).includes(raw.ratingScale) ? (raw.ratingScale as RatingScale) : DEFAULT_PREFERENCES.ratingScale,
defaultVisibility: (VISIBILITIES as readonly unknown[]).includes(raw.defaultVisibility)
@@ -43,12 +37,8 @@ function toPreferences(raw: Record<string, unknown>): Preferences {
export async function updatePreferences(db: Db, userId: string, patch: Partial<Preferences>): Promise<Preferences> {
if (patch.ratingScale !== undefined && !RATING_SCALES.includes(patch.ratingScale)) throw new ValidationError("ratingScale", "invalid");
if (patch.defaultVisibility !== undefined && !VISIBILITIES.includes(patch.defaultVisibility)) throw new ValidationError("defaultVisibility", "invalid");
// Read-modify-write in one transaction, with the user row locked on Postgres (SQLite runs one
// connection), so concurrent updates of different keys don't drop each other.
return db.k.transaction().execute(async (tx) => {
const raw = await readRaw(tx, userId, db.dialect === "postgres");
const next = { ...raw, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
await tx.updateTable("users").set({ preferences: toJson(next), updated_at: nowIso() }).where("id", "=", userId).execute();
return toPreferences(next);
});
const raw = await readRaw(db, userId);
const next = { ...raw, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
await db.k.updateTable("users").set({ preferences: toJson(next), updated_at: nowIso() }).where("id", "=", userId).execute();
return getPreferences(db, userId);
}
+60 -3
View File
@@ -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<ChangeItem[]> {
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<string, string>();
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<string, string>();
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;
+50
View File
@@ -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<Map<string, string>> {
const ids = unique(workIds);
const out = new Map<string, string>();
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<Map<string, string>> {
const ids = unique(entryIds);
const out = new Map<string, string>();
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;
}
+20
View File
@@ -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");
});
});
+107
View File
@@ -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();
});
});