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:
co-authored by
Claude Opus 5.5
parent
5fa59310f3
commit
c2e8c83f86
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user