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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5fa59310f3
commit
c2e8c83f86
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user