import type { Kysely } from "kysely"; import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { fromJson, toJson } from "@sttracker/db/json"; import type { Database } from "@sttracker/db/schema"; import { restoreCopyFromHistory } from "./copies.ts"; import { restoreEngagementFromHistory } from "./engagements.ts"; import { restoreEntryFromHistory } from "./entries.ts"; import { NotFoundError } from "./errors.ts"; import { restoreListFromHistory } from "./lists.ts"; /** Who made a change. Plugin actors are `plugin::`; API tokens are `token:`. */ export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`; /** User-layer entities that write `user_history`. */ export type HistoryEntity = "entry" | "engagement" | "copy" | "list"; /** A Kysely handle: the root instance or a transaction. */ export type Executor = Kysely; export interface HistoryRecord { id: string; entity: string; entityId: string; before: Record; after: Record; actor: string; createdAt: string; } /** * Run `fn` in the caller's transaction when given, otherwise in a new one. * Inside `fn` only the passed handle may be used (SQLite deadlocks on `db.k` mid-transaction). */ export function inTx(db: Db, tx: Executor | undefined, fn: (tx: Executor) => Promise): Promise { return tx ? fn(tx) : db.k.transaction().execute((t) => fn(t)); } /** * Write a `user_history` row holding only the keys whose values differ between `before` and `after` * (compared as JSON). `null` before/after means the entity did not exist / no longer exists. * Returns the history id, or null when nothing changed (no row written). */ export async function recordHistory( tx: Executor | Db, input: { userId: string; entity: HistoryEntity; entityId: string; before: Record | null; after: Record | null; actor: Actor; }, ): Promise { const k = "k" in tx ? tx.k : tx; const before = input.before ?? {}; const after = input.after ?? {}; const b: Record = {}; const a: Record = {}; for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { if (toJson(before[key]) === toJson(after[key])) continue; if (key in before) b[key] = before[key]; if (key in after) a[key] = after[key]; } if (Object.keys(a).length === 0 && Object.keys(b).length === 0) return null; const id = newId(); await k .insertInto("user_history") .values({ id, user_id: input.userId, entity: input.entity, entity_id: input.entityId, before: toJson(b), after: toJson(a), actor: input.actor, created_at: nowIso(), }) .execute(); return id; } /** The user's history for one entity, newest first. Another user's entity yields `[]`. */ export async function getHistory(db: Db, userId: string, entity: string, entityId: string): Promise { const rows = await db.k .selectFrom("user_history") .selectAll() .where("user_id", "=", userId) .where("entity", "=", entity) .where("entity_id", "=", entityId) .orderBy("created_at", "desc") .orderBy("id", "desc") .execute(); return rows.map(toRecord); } /** * Re-apply a history row's `before` values as actor `user` (which writes a new history row and sets * `field_sources` for the restored fields to `"user"`). Throws `NotFoundError` for another user's row. */ export async function restoreVersion(db: Db, userId: string, historyId: string): Promise { const row = await db.k .selectFrom("user_history") .selectAll() .where("id", "=", historyId) .where("user_id", "=", userId) .executeTakeFirst(); if (!row) throw new NotFoundError("history"); const h = toRecord(row); await db.k.transaction().execute(async (tx) => { switch (h.entity as HistoryEntity) { case "entry": return restoreEntryFromHistory(tx, userId, h.entityId, h.before); case "engagement": return restoreEngagementFromHistory(tx, userId, h.entityId, h.before); case "copy": return restoreCopyFromHistory(tx, userId, h.entityId, h.before); case "list": return restoreListFromHistory(tx, userId, h.entityId, h.before); default: throw new NotFoundError("history"); } }); } function toRecord(r: Database["user_history"]): HistoryRecord { return { id: r.id, entity: r.entity, entityId: r.entity_id, before: fromJson>(r.before, {}), after: fromJson>(r.after, {}), actor: r.actor, createdAt: r.created_at, }; }