Files
STTracker/packages/core/src/history.ts
T
Lucas WintherandClaude Opus 5.5 c7471f7761 feat(core): engagements, lists and copies with history restore
Engagements store partial dates as value + precision and track field_sources;
lists (list/grouping/thread, grouping-only rating/review) keep dense item
order and snapshot items in history; copies store price in cents. All three
restore through restoreVersion.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:35:15 +01:00

137 lines
4.7 KiB
TypeScript

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:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */
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<Database>;
export interface HistoryRecord {
id: string;
entity: string;
entityId: string;
before: Record<string, unknown>;
after: Record<string, unknown>;
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<T>(db: Db, tx: Executor | undefined, fn: (tx: Executor) => Promise<T>): Promise<T> {
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<string, unknown> | null;
after: Record<string, unknown> | null;
actor: Actor;
},
): Promise<string | null> {
const k = "k" in tx ? tx.k : tx;
const before = input.before ?? {};
const after = input.after ?? {};
const b: Record<string, unknown> = {};
const a: Record<string, unknown> = {};
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<HistoryRecord[]> {
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<void> {
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<Record<string, unknown>>(r.before, {}),
after: fromJson<Record<string, unknown>>(r.after, {}),
actor: r.actor,
createdAt: r.created_at,
};
}