Files
STTracker/packages/core/src/log.ts
T
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

169 lines
5.6 KiB
TypeScript

/**
* 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 };
}