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]>
This commit is contained in:
Lucas Winther
2026-10-30 22:11:53 +01:00
co-authored by Claude Opus 5.5
parent 5fa59310f3
commit c2e8c83f86
12 changed files with 561 additions and 7 deletions
+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;