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 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 19:35:15 +01:00
co-authored by Claude Opus 5.5
parent 13b81b1cd5
commit c7471f7761
7 changed files with 1209 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
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 type { PartialDate } from "@sttracker/plugin-sdk/types";
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
import { checkKeys, checkText, fromStoredDate, resolveVisibleWork, toStoredDate } from "./entries.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
/** An owned copy (spec §6.7). Always private. `workId` is null until matched; then `titleHint` names it. */
export interface CopyPatch {
workId: string | null;
titleHint: string | null;
format: string | null;
acquiredFrom: string | null;
acquiredAt: PartialDate | null;
/** Integer minor units (cents). */
priceCents: number | null;
/** ISO 4217 code, e.g. "GBP". */
currency: string | null;
notes: string | null;
customFields: Record<string, unknown>;
sourceRef: string | null;
}
const COPY_KEYS = [
"workId",
"titleHint",
"format",
"acquiredFrom",
"acquiredAt",
"priceCents",
"currency",
"notes",
"customFields",
"sourceRef",
] as const satisfies readonly (keyof CopyPatch)[];
export interface Copy extends CopyPatch {
id: string;
userId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
type Row = Database["copies"];
function toCopy(r: Row): Copy {
return {
id: r.id,
userId: r.user_id,
workId: r.work_id,
titleHint: r.title_hint,
format: r.format,
acquiredFrom: r.acquired_from,
acquiredAt: fromStoredDate(r.acquired_at, r.acquired_precision),
priceCents: r.price_cents,
currency: r.currency,
notes: r.notes,
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
sourceRef: r.source_ref,
createdAt: r.created_at,
updatedAt: r.updated_at,
deletedAt: r.deleted_at,
};
}
function snapshot(c: Copy): Record<string, unknown> {
const s: Record<string, unknown> = { deletedAt: c.deletedAt };
for (const k of COPY_KEYS) s[k] = c[k];
return s;
}
/** Validate a patch into columns. `workId` must be visible to the user; custom fields use copy-level definitions. */
async function toColumns(
k: Executor,
userId: string,
patch: Partial<CopyPatch>,
currentWorkId: string | null,
): Promise<Partial<Row>> {
checkKeys("patch", patch, COPY_KEYS);
const c: Partial<Row> = {};
let workId = currentWorkId;
if ("workId" in patch) {
workId = patch.workId === null ? null : (await resolveVisibleWork(k, userId, String(patch.workId))).id;
c.work_id = workId;
}
if ("titleHint" in patch) c.title_hint = checkText("titleHint", patch.titleHint);
if ("format" in patch) c.format = checkText("format", patch.format);
if ("acquiredFrom" in patch) c.acquired_from = checkText("acquiredFrom", patch.acquiredFrom);
if ("acquiredAt" in patch) {
const d = toStoredDate("acquiredAt", patch.acquiredAt);
c.acquired_at = d.value;
c.acquired_precision = d.precision;
}
if ("priceCents" in patch) {
const p = patch.priceCents;
if (p !== null && (typeof p !== "number" || !Number.isInteger(p) || p < 0)) {
throw new ValidationError("priceCents", "not_cents");
}
c.price_cents = p;
}
if ("currency" in patch) {
const cur = checkText("currency", patch.currency);
if (cur !== null && !/^[A-Z]{3}$/.test(cur)) throw new ValidationError("currency", "invalid");
c.currency = cur;
}
if ("notes" in patch) c.notes = checkText("notes", patch.notes);
if ("sourceRef" in patch) c.source_ref = checkText("sourceRef", patch.sourceRef);
if ("customFields" in patch) {
let mediaType: string | null = null;
if (workId) {
const w = await k.selectFrom("works").select("media_type").where("id", "=", workId).executeTakeFirst();
mediaType = w?.media_type ?? null;
}
const defs = await customFieldsFor(k, userId, { mediaType, level: "copy" });
validateCustomValues(defs, patch.customFields as Record<string, unknown>);
c.custom_fields = toJson(patch.customFields);
}
return c;
}
async function load(k: Executor, userId: string, copyId: string, includeDeleted = false): Promise<Copy> {
let q = k.selectFrom("copies").selectAll().where("id", "=", copyId).where("user_id", "=", userId);
if (!includeDeleted) q = q.where("deleted_at", "is", null);
const r = await q.executeTakeFirst();
if (!r) throw new NotFoundError("copy");
return toCopy(r);
}
async function write(
k: Executor,
current: Copy,
patch: Partial<CopyPatch>,
actor: Actor,
extra: Partial<Pick<Row, "deleted_at">> = {},
): Promise<Copy> {
const cols = await toColumns(k, current.userId, patch, current.workId);
const next = { ...current, ...patchFromCols(current, cols) };
if (next.workId === null && !next.titleHint?.trim()) throw new ValidationError("titleHint", "required");
await k
.updateTable("copies")
.set({ ...cols, ...extra, updated_at: nowIso() })
.where("id", "=", current.id)
.execute();
const after = await load(k, current.userId, current.id, true);
await recordHistory(k, {
userId: current.userId,
entity: "copy",
entityId: current.id,
before: snapshot(current),
after: snapshot(after),
actor,
});
return after;
}
function patchFromCols(current: Copy, cols: Partial<Row>): Pick<Copy, "workId" | "titleHint"> {
return {
workId: "work_id" in cols ? (cols.work_id ?? null) : current.workId,
titleHint: "title_hint" in cols ? (cols.title_hint ?? null) : current.titleHint,
};
}
/** Create an owned copy. Needs a `workId` or a `titleHint`. Writes history. */
export async function createCopy(
db: Db,
userId: string,
input: Partial<CopyPatch>,
actor: Actor = "user",
tx?: Executor,
): Promise<Copy> {
return inTx(db, tx, async (k) => {
const cols = await toColumns(k, userId, input, null);
if (!cols.work_id && !cols.title_hint?.trim()) throw new ValidationError("titleHint", "required");
const now = nowIso();
const row: Row = {
id: newId(),
user_id: userId,
work_id: null,
title_hint: null,
format: null,
acquired_from: null,
acquired_at: null,
acquired_precision: "unknown",
price_cents: null,
currency: null,
notes: null,
custom_fields: toJson({}),
source_ref: null,
...cols,
created_at: now,
updated_at: now,
deleted_at: null,
};
await k.insertInto("copies").values(row).execute();
const copy = toCopy(row);
await recordHistory(k, { userId, entity: "copy", entityId: copy.id, before: null, after: snapshot(copy), actor });
return copy;
});
}
/** Patch a live copy; only the keys present are touched. Writes history. */
export async function updateCopy(
db: Db,
userId: string,
copyId: string,
patch: Partial<CopyPatch>,
actor: Actor = "user",
tx?: Executor,
): Promise<Copy> {
return inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), patch, actor));
}
/** Soft-delete a copy. Writes history. */
export async function softDeleteCopy(
db: Db,
userId: string,
copyId: string,
actor: Actor = "user",
tx?: Executor,
): Promise<void> {
await inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), {}, actor, { deleted_at: nowIso() }));
}
/** The user's live copies, optionally of one work, newest first. */
export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise<Copy[]> {
let q = db.k.selectFrom("copies").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null);
if (filter.workId) q = q.where("work_id", "=", filter.workId);
const rows = await q.orderBy("created_at", "desc").orderBy("id", "desc").execute();
return rows.map(toCopy);
}
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
export async function restoreCopyFromHistory(
k: Executor,
userId: string,
copyId: string,
before: Record<string, unknown>,
): Promise<void> {
const current = await load(k, userId, copyId, true);
const patch: Record<string, unknown> = {};
for (const key of COPY_KEYS) if (key in before) patch[key] = before[key];
const extra = "deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {};
await write(k, current, patch as Partial<CopyPatch>, "user", extra);
}
+263
View File
@@ -0,0 +1,263 @@
import { sql } 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 type { PartialDate } from "@sttracker/plugin-sdk/types";
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
import {
checkKeys,
checkRating,
checkText,
type FieldSources,
fromStoredDate,
requireEntry,
toStoredDate,
} from "./entries.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
/** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */
export interface EngagementPatch {
startedAt: PartialDate | null;
finishedAt: PartialDate | null;
progress: Record<string, number | string>;
platform: string | null;
rating: number | null;
notes: string | null;
customFields: Record<string, unknown>;
}
export const ENGAGEMENT_PATCH_KEYS = [
"startedAt",
"finishedAt",
"progress",
"platform",
"rating",
"notes",
"customFields",
] as const satisfies readonly (keyof EngagementPatch)[];
export interface Engagement extends EngagementPatch {
id: string;
entryId: string;
fieldSources: FieldSources;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
type Row = Database["engagements"];
function toEngagement(r: Row): Engagement {
return {
id: r.id,
entryId: r.entry_id,
startedAt: fromStoredDate(r.started_at, r.started_precision),
finishedAt: fromStoredDate(r.finished_at, r.finished_precision),
progress: fromJson<Record<string, number | string>>(r.progress, {}),
platform: r.platform,
rating: r.rating,
notes: r.notes,
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
fieldSources: fromJson<FieldSources>(r.field_sources, {}),
createdAt: r.created_at,
updatedAt: r.updated_at,
deletedAt: r.deleted_at,
};
}
function snapshot(e: Engagement): Record<string, unknown> {
const s: Record<string, unknown> = { deletedAt: e.deletedAt };
for (const k of ENGAGEMENT_PATCH_KEYS) s[k] = e[k];
return s;
}
/** Validate a patch and turn it into columns. */
async function toColumns(
k: Executor,
userId: string,
mediaType: string,
patch: Partial<EngagementPatch>,
): Promise<Partial<Row>> {
checkKeys("patch", patch, ENGAGEMENT_PATCH_KEYS);
const c: Partial<Row> = {};
if ("startedAt" in patch) {
const d = toStoredDate("startedAt", patch.startedAt);
c.started_at = d.value;
c.started_precision = d.precision;
}
if ("finishedAt" in patch) {
const d = toStoredDate("finishedAt", patch.finishedAt);
c.finished_at = d.value;
c.finished_precision = d.precision;
}
if ("progress" in patch) {
const p = patch.progress;
if (p === null || typeof p !== "object" || Array.isArray(p)) throw new ValidationError("progress", "not_object");
for (const [key, v] of Object.entries(p)) {
if (typeof v !== "string" && (typeof v !== "number" || !Number.isFinite(v))) {
throw new ValidationError(`progress.${key}`, "invalid");
}
}
c.progress = toJson(p);
}
if ("platform" in patch) c.platform = checkText("platform", patch.platform);
if ("rating" in patch) c.rating = checkRating("rating", patch.rating);
if ("notes" in patch) c.notes = checkText("notes", patch.notes);
if ("customFields" in patch) {
const defs = await customFieldsFor(k, userId, { mediaType, level: "engagement" });
validateCustomValues(defs, patch.customFields as Record<string, unknown>);
c.custom_fields = toJson(patch.customFields);
}
return c;
}
/** Load an engagement whose entry belongs to the user. */
async function load(
k: Executor,
userId: string,
engagementId: string,
opts: { includeDeleted?: boolean } = {},
): Promise<{ eg: Engagement; mediaType: string }> {
let q = k
.selectFrom("engagements as g")
.innerJoin("entries as e", "e.id", "g.entry_id")
.innerJoin("works as w", "w.id", "e.work_id")
.selectAll("g")
.select(["e.media_type_override", "w.media_type"])
.where("g.id", "=", engagementId)
.where("e.user_id", "=", userId);
if (!opts.includeDeleted) q = q.where("g.deleted_at", "is", null);
const r = await q.executeTakeFirst();
if (!r) throw new NotFoundError("engagement");
return { eg: toEngagement(r), mediaType: r.media_type_override ?? r.media_type };
}
async function write(
k: Executor,
userId: string,
current: Engagement,
mediaType: string,
patch: Partial<EngagementPatch>,
actor: Actor,
extra: Partial<Pick<Row, "deleted_at">> = {},
): Promise<Engagement> {
const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources = { ...current.fieldSources };
for (const key of Object.keys(patch)) fieldSources[key] = actor;
await k
.updateTable("engagements")
.set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() })
.where("id", "=", current.id)
.execute();
const { eg } = await load(k, userId, current.id, { includeDeleted: true });
await recordHistory(k, {
userId,
entity: "engagement",
entityId: current.id,
before: snapshot(current),
after: snapshot(eg),
actor,
});
return eg;
}
/** Add an engagement to the user's live entry. Each patched key gets `field_sources[key] = actor`. */
export async function addEngagement(
db: Db,
userId: string,
entryId: string,
patch: Partial<EngagementPatch>,
actor: Actor,
tx?: Executor,
): Promise<Engagement> {
return inTx(db, tx, async (k) => {
const { mediaType } = await requireEntry(k, userId, entryId);
const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources: FieldSources = {};
for (const key of Object.keys(patch)) fieldSources[key] = actor;
const now = nowIso();
const row: Row = {
id: newId(),
entry_id: entryId,
started_at: null,
started_precision: "unknown",
finished_at: null,
finished_precision: "unknown",
progress: toJson({}),
platform: null,
rating: null,
notes: null,
custom_fields: toJson({}),
...cols,
field_sources: toJson(fieldSources),
created_at: now,
updated_at: now,
deleted_at: null,
};
await k.insertInto("engagements").values(row).execute();
const eg = toEngagement(row);
await recordHistory(k, { userId, entity: "engagement", entityId: eg.id, before: null, after: snapshot(eg), actor });
return eg;
});
}
/** Patch a live engagement; only the keys present are touched. Writes history. */
export async function updateEngagement(
db: Db,
userId: string,
engagementId: string,
patch: Partial<EngagementPatch>,
actor: Actor,
tx?: Executor,
): Promise<Engagement> {
return inTx(db, tx, async (k) => {
const { eg, mediaType } = await load(k, userId, engagementId);
if (Object.keys(patch).length === 0) return eg;
return write(k, userId, eg, mediaType, patch, actor);
});
}
/** Soft-delete an engagement. Writes history. */
export async function softDeleteEngagement(
db: Db,
userId: string,
engagementId: string,
actor: Actor,
tx?: Executor,
): Promise<void> {
await inTx(db, tx, async (k) => {
const { eg, mediaType } = await load(k, userId, engagementId);
await write(k, userId, eg, mediaType, {}, actor, { deleted_at: nowIso() });
});
}
/** The live engagements of the user's live entry, by start date (undated last), then creation. */
export async function listEngagements(db: Db, userId: string, entryId: string): Promise<Engagement[]> {
await requireEntry(db.k, userId, entryId);
const rows = await db.k
.selectFrom("engagements")
.selectAll()
.where("entry_id", "=", entryId)
.where("deleted_at", "is", null)
.orderBy(sql`case when started_at is null then 1 else 0 end`)
.orderBy("started_at")
.orderBy("created_at")
.orderBy("id")
.execute();
return rows.map(toEngagement);
}
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
export async function restoreEngagementFromHistory(
k: Executor,
userId: string,
engagementId: string,
before: Record<string, unknown>,
): Promise<void> {
const { eg, mediaType } = await load(k, userId, engagementId, { includeDeleted: true });
const patch: Record<string, unknown> = {};
for (const key of ENGAGEMENT_PATCH_KEYS) if (key in before) patch[key] = before[key];
const extra = "deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {};
await write(k, userId, eg, mediaType, patch as Partial<EngagementPatch>, "user", extra);
}
+9
View File
@@ -3,8 +3,11 @@ 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}`;
@@ -108,6 +111,12 @@ export async function restoreVersion(db: Db, userId: string, historyId: string):
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");
}
+384
View File
@@ -0,0 +1,384 @@
import type { Db } from "@sttracker/db/db";
import { newId, nowIso } from "@sttracker/db/ids";
import type { Database, ListKind, Visibility } from "@sttracker/db/schema";
import { checkKeys, checkRating, checkText, defaultVisibility, resolveVisibleWork, VISIBILITIES } from "./entries.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
const KINDS: readonly ListKind[] = ["list", "grouping", "thread"];
/** Spec §6.5. Only a `grouping` may carry `rating`/`review`. */
export interface ListPatch {
name: string;
description: string;
kind: ListKind;
visibility: Visibility;
rating: number | null;
review: string | null;
}
const LIST_KEYS = ["name", "description", "kind", "visibility", "rating", "review"] as const satisfies readonly (keyof ListPatch)[];
export interface List extends ListPatch {
id: string;
userId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface ListItem {
id: string;
listId: string;
workId: string;
/** 0-based, dense. */
position: number;
note: string | null;
}
export interface ListWithItems extends List {
items: (ListItem & { work: { id: string; title: string; mediaType: string; year: number | null; coverUrl: string } })[];
}
type Row = Database["lists"];
type ItemRow = Database["list_items"];
function toList(r: Row): List {
return {
id: r.id,
userId: r.user_id,
name: r.name,
description: r.description,
kind: r.kind,
visibility: r.visibility,
rating: r.rating,
review: r.review,
createdAt: r.created_at,
updatedAt: r.updated_at,
deletedAt: r.deleted_at,
};
}
const toItem = (r: ItemRow): ListItem => ({
id: r.id,
listId: r.list_id,
workId: r.work_id,
position: r.position,
note: r.note,
});
/** The history snapshot: list fields, `deletedAt`, and the ordered items (so removals are restorable). */
async function snapshot(k: Executor, l: List): Promise<Record<string, unknown>> {
const s: Record<string, unknown> = { deletedAt: l.deletedAt };
for (const key of LIST_KEYS) s[key] = l[key];
s.items = (await items(k, l.id)).map((i) => ({ id: i.id, workId: i.workId, note: i.note }));
return s;
}
function normalise(patch: Partial<ListPatch>): Partial<ListPatch> {
checkKeys("patch", patch, LIST_KEYS);
const out: Partial<ListPatch> = {};
if ("name" in patch) {
if (typeof patch.name !== "string" || !patch.name.trim()) throw new ValidationError("name", "required");
out.name = patch.name.trim();
}
if ("description" in patch) out.description = checkText("description", patch.description) ?? "";
if ("kind" in patch) {
if (!KINDS.includes(patch.kind as ListKind)) throw new ValidationError("kind", "invalid");
out.kind = patch.kind;
}
if ("visibility" in patch) {
if (!VISIBILITIES.includes(patch.visibility as Visibility)) throw new ValidationError("visibility", "invalid");
out.visibility = patch.visibility;
}
if ("rating" in patch) out.rating = checkRating("rating", patch.rating);
if ("review" in patch) out.review = checkText("review", patch.review);
return out;
}
function checkGrouping(l: Pick<ListPatch, "kind" | "rating" | "review">): void {
if (l.kind !== "grouping" && (l.rating !== null || l.review !== null)) {
throw new ValidationError(l.rating !== null ? "rating" : "review", "grouping_only");
}
}
function toColumns(p: Partial<ListPatch>): Partial<Row> {
const c: Partial<Row> = {};
if (p.name !== undefined) c.name = p.name;
if (p.description !== undefined) c.description = p.description;
if (p.kind !== undefined) c.kind = p.kind;
if (p.visibility !== undefined) c.visibility = p.visibility;
if (p.rating !== undefined) c.rating = p.rating;
if (p.review !== undefined) c.review = p.review;
return c;
}
async function load(k: Executor, userId: string, listId: string, includeDeleted = false): Promise<List> {
let q = k.selectFrom("lists").selectAll().where("id", "=", listId).where("user_id", "=", userId);
if (!includeDeleted) q = q.where("deleted_at", "is", null);
const r = await q.executeTakeFirst();
if (!r) throw new NotFoundError("list");
return toList(r);
}
async function items(k: Executor, listId: string): Promise<ListItem[]> {
const rows = await k
.selectFrom("list_items")
.selectAll()
.where("list_id", "=", listId)
.orderBy("position")
.orderBy("id")
.execute();
return rows.map(toItem);
}
/** Rewrite positions to 0..n-1 in the given order. */
async function renumber(k: Executor, ordered: ListItem[]): Promise<void> {
for (const [i, it] of ordered.entries()) {
if (it.position !== i) await k.updateTable("list_items").set({ position: i }).where("id", "=", it.id).execute();
}
}
/** Run `change` on a list (live, user's), then bump `updated_at` and record history. */
async function mutate<T>(
k: Executor,
userId: string,
listId: string,
actor: Actor,
change: (l: List) => Promise<T>,
opts: { includeDeleted?: boolean } = {},
): Promise<T> {
const before = await load(k, userId, listId, opts.includeDeleted);
const beforeSnap = await snapshot(k, before);
const result = await change(before);
await k.updateTable("lists").set({ updated_at: nowIso() }).where("id", "=", listId).execute();
const after = await load(k, userId, listId, true);
await recordHistory(k, {
userId,
entity: "list",
entityId: listId,
before: beforeSnap,
after: await snapshot(k, after),
actor,
});
return result;
}
/** Create a list. Default kind `list`, visibility from user preferences. */
export async function createList(
db: Db,
userId: string,
input: Partial<ListPatch> & { name: string },
actor: Actor = "user",
tx?: Executor,
): Promise<List> {
return inTx(db, tx, async (k) => {
const p = normalise(input);
if (p.name === undefined) throw new ValidationError("name", "required");
const now = nowIso();
const row: Row = {
id: newId(),
user_id: userId,
name: p.name,
description: p.description ?? "",
kind: p.kind ?? "list",
visibility: p.visibility ?? (await defaultVisibility(k, userId)),
rating: p.rating ?? null,
review: p.review ?? null,
created_at: now,
updated_at: now,
deleted_at: null,
};
checkGrouping(row);
await k.insertInto("lists").values(row).execute();
const list = toList(row);
await recordHistory(k, {
userId,
entity: "list",
entityId: list.id,
before: null,
after: await snapshot(k, list),
actor,
});
return list;
});
}
/** Patch a list's own fields. */
export async function updateList(
db: Db,
userId: string,
listId: string,
patch: Partial<ListPatch>,
actor: Actor = "user",
tx?: Executor,
): Promise<List> {
return inTx(db, tx, async (k) => {
const p = normalise(patch);
await mutate(k, userId, listId, actor, async (l) => {
checkGrouping({ ...l, ...p });
const cols = toColumns(p);
if (Object.keys(cols).length) await k.updateTable("lists").set(cols).where("id", "=", listId).execute();
});
return load(k, userId, listId);
});
}
/** Soft-delete a list (its items stay, for restore). */
export async function softDeleteList(
db: Db,
userId: string,
listId: string,
actor: Actor = "user",
tx?: Executor,
): Promise<void> {
await inTx(db, tx, (k) =>
mutate(k, userId, listId, actor, async () => {
await k.updateTable("lists").set({ deleted_at: nowIso() }).where("id", "=", listId).execute();
}),
);
}
/** Add a work to a list, at `position` (0-based; default: the end). */
export async function addListItem(
db: Db,
userId: string,
listId: string,
input: { workId: string; note?: string | null; position?: number },
actor: Actor = "user",
tx?: Executor,
): Promise<ListItem> {
return inTx(db, tx, (k) =>
mutate(k, userId, listId, actor, async () => {
const work = await resolveVisibleWork(k, userId, input.workId);
const note = input.note === undefined ? null : checkText("note", input.note);
const current = await items(k, listId);
const at = clampIndex(input.position ?? current.length, current.length);
const item: ListItem = { id: newId(), listId, workId: work.id, position: current.length, note };
await k
.insertInto("list_items")
.values({ id: item.id, list_id: listId, work_id: item.workId, position: item.position, note })
.execute();
current.splice(at, 0, item);
await renumber(k, current);
return { ...item, position: at };
}),
);
}
function clampIndex(i: number, max: number): number {
if (typeof i !== "number" || !Number.isInteger(i)) throw new ValidationError("position", "not_integer");
return Math.max(0, Math.min(i, max));
}
/** Load an item of one of the user's live lists. */
async function loadItem(k: Executor, userId: string, itemId: string): Promise<ListItem> {
const r = await k
.selectFrom("list_items as i")
.innerJoin("lists as l", "l.id", "i.list_id")
.selectAll("i")
.where("i.id", "=", itemId)
.where("l.user_id", "=", userId)
.where("l.deleted_at", "is", null)
.executeTakeFirst();
if (!r) throw new NotFoundError("list_item");
return toItem(r);
}
/** Move an item to `toIndex` (clamped to the list bounds). */
export async function moveListItem(
db: Db,
userId: string,
itemId: string,
toIndex: number,
actor: Actor = "user",
tx?: Executor,
): Promise<void> {
await inTx(db, tx, async (k) => {
const item = await loadItem(k, userId, itemId);
await mutate(k, userId, item.listId, actor, async () => {
const current = (await items(k, item.listId)).filter((i) => i.id !== itemId);
current.splice(clampIndex(toIndex, current.length), 0, item);
await renumber(k, current);
});
});
}
/** Remove an item. The list's history keeps it, so `restoreVersion` can bring it back. */
export async function removeListItem(
db: Db,
userId: string,
itemId: string,
actor: Actor = "user",
tx?: Executor,
): Promise<void> {
await inTx(db, tx, async (k) => {
const item = await loadItem(k, userId, itemId);
await mutate(k, userId, item.listId, actor, async () => {
await k.deleteFrom("list_items").where("id", "=", itemId).execute();
await renumber(k, await items(k, item.listId));
});
});
}
/** A live list of the user's with its items in order. */
export async function getList(db: Db, userId: string, listId: string): Promise<ListWithItems> {
const list = await load(db.k, userId, listId);
const rows = await db.k
.selectFrom("list_items as i")
.innerJoin("works as w", "w.id", "i.work_id")
.selectAll("i")
.select(["w.title", "w.media_type", "w.year", "w.cover_url"])
.where("i.list_id", "=", listId)
.orderBy("i.position")
.orderBy("i.id")
.execute();
return {
...list,
items: rows.map((r) => ({
...toItem(r),
work: { id: r.work_id, title: r.title, mediaType: r.media_type, year: r.year, coverUrl: r.cover_url },
})),
};
}
/** The user's live lists, most recently updated first. */
export async function listLists(db: Db, userId: string, filter: { kind?: ListKind } = {}): Promise<List[]> {
let q = db.k.selectFrom("lists").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null);
if (filter.kind) q = q.where("kind", "=", filter.kind);
const rows = await q.orderBy("updated_at", "desc").orderBy("id", "desc").execute();
return rows.map(toList);
}
/** Called by `restoreVersion`: re-apply a history row's `before` (fields, deletion and items) as actor "user". */
export async function restoreListFromHistory(
k: Executor,
userId: string,
listId: string,
before: Record<string, unknown>,
): Promise<void> {
const patch: Record<string, unknown> = {};
for (const key of LIST_KEYS) if (key in before) patch[key] = before[key];
const p = normalise(patch as Partial<ListPatch>);
await mutate(
k,
userId,
listId,
"user",
async (l) => {
checkGrouping({ ...l, ...p });
const cols: Partial<Row> = toColumns(p);
if ("deletedAt" in before) cols.deleted_at = (before.deletedAt as string | null) ?? null;
if (Object.keys(cols).length) await k.updateTable("lists").set(cols).where("id", "=", listId).execute();
if (Array.isArray(before.items)) {
await k.deleteFrom("list_items").where("list_id", "=", listId).execute();
for (const [position, it] of (before.items as { id: string; workId: string; note: string | null }[]).entries()) {
await k
.insertInto("list_items")
.values({ id: it.id, list_id: listId, work_id: it.workId, position, note: it.note ?? null })
.execute();
}
}
},
{ includeDeleted: true },
);
}