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]>
169 lines
5.6 KiB
TypeScript
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 };
|
|
}
|