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]>
137 lines
4.7 KiB
TypeScript
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,
|
|
};
|
|
}
|