Files
STTracker/packages/core/src/entries.ts
T

1009 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, EntryStatus, Visibility } from "@sttracker/db/schema";
import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
import type { MediaType, PartialDate } from "@sttracker/plugin-sdk/types";
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
// ---------------------------------------------------------------------------------------------
// Shared user-layer helpers (also used by engagements, lists and copies).
// ---------------------------------------------------------------------------------------------
export const STATUSES: readonly EntryStatus[] = ["planned", "in_progress", "paused", "completed", "dropped", "none"];
export const VISIBILITIES: readonly Visibility[] = ["private", "friends", "public"];
/** Built-in media types. Works always carry one; user-defined types live in `entries.media_type_override`. */
export const MEDIA_TYPES = [
"book", "comic", "manga", "game", "movie", "show", "season", "episode", "podcast",
"podcast_episode", "audio_drama", "artist", "album", "track", "video", "other",
] as const satisfies readonly MediaType[];
const PRECISIONS = ["day", "month", "year", "unknown"] as const;
/** A map from patched field name to the actor that last set it. The sync engine reads this. */
export type FieldSources = Record<string, Actor>;
/** Throw unless `v` is null or an integer 0–100. */
export function checkRating(field: string, v: unknown): number | null {
if (v === null) return null;
if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 100) {
throw new ValidationError(field, "out_of_range");
}
return v;
}
/** Throw unless `v` is null or a string. */
export function checkText(field: string, v: unknown): string | null {
if (v === null) return null;
if (typeof v !== "string") throw new ValidationError(field, "not_text");
return v;
}
/** Throw on any key not in `allowed`. */
export function checkKeys(field: string, obj: object, allowed: readonly string[]): void {
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) throw new ValidationError(field, "not_object");
for (const k of Object.keys(obj)) {
if (!allowed.includes(k)) throw new ValidationError(k, "unknown_field");
}
}
/**
* Validate a partial date and convert it to its stored pair. `null` (or precision "unknown")
* stores `{ value: null, precision: "unknown" }`. Never goes through `Date`.
*/
export function toStoredDate(field: string, d: unknown): { value: string | null; precision: string } {
if (d === null) return { value: null, precision: "unknown" };
if (typeof d !== "object" || Array.isArray(d)) throw new ValidationError(field, "not_date");
const { value, precision } = d as PartialDate;
if (!PRECISIONS.includes(precision)) throw new ValidationError(field, "bad_precision");
if (precision === "unknown") {
if (value !== "" && value != null) throw new ValidationError(field, "bad_precision");
return { value: null, precision: "unknown" };
}
const parsed = typeof value === "string" ? parsePartialDate(value) : null;
if (!parsed || parsed.value !== value || parsed.precision !== precision) {
throw new ValidationError(field, "not_date");
}
return { value, precision };
}
/** Read a stored date pair back; a null value is a null date. */
export function fromStoredDate(value: string | null, precision: string): PartialDate | null {
return value === null ? null : { value, precision: precision as PartialDate["precision"] };
}
/** The user's default visibility preference (`preferences.defaultVisibility`), else "private". */
export async function defaultVisibility(k: Executor, userId: string): Promise<Visibility> {
const u = await k.selectFrom("users").select("preferences").where("id", "=", userId).executeTakeFirst();
const v = fromJson<{ defaultVisibility?: unknown }>(u?.preferences, {}).defaultVisibility;
return VISIBILITIES.includes(v as Visibility) ? (v as Visibility) : "private";
}
/**
* Follow the user's `work_aliases` and global `merged_into_id` tombstones from `workId`, then check
* the result is visible to the user (shared, or their own private work). Throws `NotFoundError`.
*/
export async function resolveVisibleWork(
k: Executor,
userId: string,
workId: string,
): Promise<{ id: string; media_type: string; owner_user_id: string | null }> {
let id = workId;
for (let hop = 0; hop < 16; hop++) {
const alias = await k
.selectFrom("work_aliases")
.select("to_work_id")
.where("user_id", "=", userId)
.where("from_work_id", "=", id)
.executeTakeFirst();
if (alias) {
id = alias.to_work_id;
continue;
}
const w = await k
.selectFrom("works")
.select(["id", "media_type", "owner_user_id", "merged_into_id"])
.where("id", "=", id)
.executeTakeFirst();
if (!w || (w.owner_user_id !== null && w.owner_user_id !== userId)) throw new NotFoundError("work");
if (w.merged_into_id) {
id = w.merged_into_id;
continue;
}
return w;
}
throw new NotFoundError("work");
}
// ---------------------------------------------------------------------------------------------
// Entries
// ---------------------------------------------------------------------------------------------
export interface EntryOverrides {
title?: string;
coverUrl?: string;
year?: number | null;
}
const OVERRIDE_KEYS = ["title", "coverUrl", "year"] as const;
export interface EntryPatch {
status: EntryStatus;
rating: number | null;
review: string | null;
privateNotes: string | null;
favorite: boolean;
tags: string[];
customFields: Record<string, unknown>;
visibility: Visibility;
overrides: EntryOverrides;
mediaTypeOverride: string | null;
}
export const ENTRY_PATCH_KEYS = [
"status",
"rating",
"review",
"privateNotes",
"favorite",
"tags",
"customFields",
"visibility",
"overrides",
"mediaTypeOverride",
] as const satisfies readonly (keyof EntryPatch)[];
export interface Entry extends EntryPatch {
id: string;
userId: string;
workId: string;
fieldSources: FieldSources;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface EntryView extends Entry {
/** The work as this user sees it (title, cover and year overlaid with `overrides`). */
work: {
id: string;
mediaType: string;
title: string;
originalTitle: string;
year: number | null;
coverUrl: string;
parentId: string | null;
};
/** The work has no external IDs. */
draft: boolean;
/** Non-deleted child entries (by work `parent_id`), or null when there are none. */
childSummary: { total: number; completed: number } | null;
}
type EntryRow = Database["entries"];
function toEntry(r: EntryRow): Entry {
return {
id: r.id,
userId: r.user_id,
workId: r.work_id,
status: r.status,
rating: r.rating,
review: r.review,
privateNotes: r.private_notes,
favorite: Boolean(r.favorite),
tags: fromJson<string[]>(r.tags, []),
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
visibility: r.visibility,
overrides: fromJson<EntryOverrides>(r.overrides, {}),
mediaTypeOverride: r.media_type_override,
fieldSources: fromJson<FieldSources>(r.field_sources, {}),
createdAt: r.created_at,
updatedAt: r.updated_at,
deletedAt: r.deleted_at,
};
}
/** The history snapshot of an entry: its patchable fields plus `workId` and `deletedAt`. */
function snapshot(e: Entry): Record<string, unknown> {
const s: Record<string, unknown> = { workId: e.workId, deletedAt: e.deletedAt };
for (const k of ENTRY_PATCH_KEYS) s[k] = e[k];
return s;
}
function patchToColumns(p: Partial<EntryPatch>): Partial<EntryRow> {
const c: Partial<EntryRow> = {};
if ("status" in p) c.status = p.status!;
if ("rating" in p) c.rating = p.rating!;
if ("review" in p) c.review = p.review!;
if ("privateNotes" in p) c.private_notes = p.privateNotes!;
if ("favorite" in p) c.favorite = p.favorite ? 1 : 0;
if ("tags" in p) c.tags = toJson(p.tags);
if ("customFields" in p) c.custom_fields = toJson(p.customFields);
if ("visibility" in p) c.visibility = p.visibility!;
if ("overrides" in p) c.overrides = toJson(p.overrides);
if ("mediaTypeOverride" in p) c.media_type_override = p.mediaTypeOverride!;
return c;
}
/** Validate and normalise a patch. Custom fields are checked against the user's entry-level definitions. */
async function normalisePatch(
k: Executor,
userId: string,
mediaType: string,
patch: Partial<EntryPatch>,
opts: { trustCustomFields?: boolean } = {},
): Promise<Partial<EntryPatch>> {
checkKeys("patch", patch, ENTRY_PATCH_KEYS);
const out: Partial<EntryPatch> = {};
if ("status" in patch) {
if (!STATUSES.includes(patch.status as EntryStatus)) throw new ValidationError("status", "invalid");
out.status = patch.status;
}
if ("rating" in patch) out.rating = checkRating("rating", patch.rating);
if ("review" in patch) out.review = checkText("review", patch.review);
if ("privateNotes" in patch) out.privateNotes = checkText("privateNotes", patch.privateNotes);
if ("favorite" in patch) {
if (typeof patch.favorite !== "boolean") throw new ValidationError("favorite", "not_boolean");
out.favorite = patch.favorite;
}
if ("tags" in patch) {
if (!Array.isArray(patch.tags) || !patch.tags.every((t) => typeof t === "string")) {
throw new ValidationError("tags", "not_list");
}
out.tags = [...new Set(patch.tags.map((t) => t.trim()).filter(Boolean))];
}
if ("visibility" in patch) {
if (!VISIBILITIES.includes(patch.visibility as Visibility)) throw new ValidationError("visibility", "invalid");
out.visibility = patch.visibility;
}
if ("mediaTypeOverride" in patch) {
const m = checkText("mediaTypeOverride", patch.mediaTypeOverride);
out.mediaTypeOverride = m?.trim() || null;
}
if ("overrides" in patch) out.overrides = checkOverrides(patch.overrides);
if ("customFields" in patch) {
const cf = patch.customFields;
if (cf === null || typeof cf !== "object" || Array.isArray(cf)) throw new ValidationError("customFields", "not_object");
if (!opts.trustCustomFields) {
const effective = out.mediaTypeOverride ?? mediaType;
const defs = await customFieldsFor(k, userId, { mediaType: effective, level: "entry" });
validateCustomValues(defs, cf);
}
out.customFields = cf;
}
return out;
}
function checkOverrides(o: unknown): EntryOverrides {
checkKeys("overrides", o as object, OVERRIDE_KEYS);
const v = o as Record<string, unknown>;
const out: EntryOverrides = {};
if (v.title !== undefined) {
if (typeof v.title !== "string" || !v.title.trim()) throw new ValidationError("overrides.title", "not_text");
out.title = v.title.trim();
}
if (v.coverUrl !== undefined) {
if (typeof v.coverUrl !== "string") throw new ValidationError("overrides.coverUrl", "not_text");
out.coverUrl = v.coverUrl;
}
if (v.year !== undefined) {
if (v.year !== null && (typeof v.year !== "number" || !Number.isInteger(v.year))) {
throw new ValidationError("overrides.year", "not_integer");
}
out.year = v.year;
}
return out;
}
interface WriteOpts {
/** Non-patch columns. */
extra?: Partial<Pick<EntryRow, "work_id" | "deleted_at">>;
/** Per-key `field_sources` for patched keys instead of `actor`; `undefined` removes the key. */
sources?: Record<string, Actor | undefined>;
/** History `before` override: `null` records a creation. */
historyBefore?: Record<string, unknown> | null;
/** Extra keys merged into the history row's before/after snapshots. */
historyExtra?: { before: Record<string, unknown>; after: Record<string, unknown> };
}
/**
* Write a patch to an entry, set `field_sources[key] = actor` for each patched key (unless
* `opts.sources` says otherwise), and record history.
*/
async function writeEntry(
k: Executor,
current: Entry,
patch: Partial<EntryPatch>,
actor: Actor,
opts: WriteOpts = {},
): Promise<Entry> {
const fieldSources: Record<string, Actor | undefined> = { ...current.fieldSources };
for (const key of Object.keys(patch)) {
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : actor;
if (fieldSources[key] === undefined) delete fieldSources[key];
}
const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() };
await k.updateTable("entries").set(cols).where("id", "=", current.id).execute();
const after = await loadEntry(k, current.userId, current.id, { includeDeleted: true });
const before = opts.historyBefore === undefined ? snapshot(current) : opts.historyBefore;
await recordHistory(k, {
userId: current.userId,
entity: "entry",
entityId: current.id,
before: before === null ? null : { ...before, ...opts.historyExtra?.before },
after: { ...snapshot(after), ...opts.historyExtra?.after },
actor,
});
return after;
}
async function loadEntry(
k: Executor,
userId: string,
entryId: string,
opts: { includeDeleted?: boolean } = {},
): Promise<Entry> {
let q = k.selectFrom("entries").selectAll().where("id", "=", entryId).where("user_id", "=", userId);
if (!opts.includeDeleted) q = q.where("deleted_at", "is", null);
const r = await q.executeTakeFirst();
if (!r) throw new NotFoundError("entry");
return toEntry(r);
}
/** The user's entry (live or not) for a work, if any. */
async function findEntry(k: Executor, userId: string, workId: string): Promise<Entry | null> {
const r = await k
.selectFrom("entries")
.selectAll()
.where("user_id", "=", userId)
.where("work_id", "=", workId)
.executeTakeFirst();
return r ? toEntry(r) : null;
}
/** Verify the entry belongs to the user and is live; returns its work's media type. Used by engagements. */
export async function requireEntry(k: Executor, userId: string, entryId: string): Promise<{ mediaType: string }> {
const r = await k
.selectFrom("entries as e")
.innerJoin("works as w", "w.id", "e.work_id")
.select(["e.media_type_override", "w.media_type"])
.where("e.id", "=", entryId)
.where("e.user_id", "=", userId)
.where("e.deleted_at", "is", null)
.executeTakeFirst();
if (!r) throw new NotFoundError("entry");
return { mediaType: r.media_type_override ?? r.media_type };
}
/**
* Create or update the user's entry for a work. Only the keys present in `patch` are touched, and
* each gets `field_sources[key] = actor`. A soft-deleted entry is revived. Aliased or merged works
* resolve to their target. Writes history.
*/
export async function upsertEntry(
db: Db,
userId: string,
workId: string,
patch: Partial<EntryPatch>,
actor: Actor,
tx?: Executor,
): Promise<Entry> {
return inTx(db, tx, async (k) => {
const work = await resolveVisibleWork(k, userId, workId);
let existing = await findEntry(k, userId, work.id);
const p = await normalisePatch(k, userId, existing?.mediaTypeOverride ?? work.media_type, patch);
let created = false;
if (!existing) {
// Insert-or-ignore, then patch through the update path: a concurrent creator never aborts our
// transaction (Postgres poisons a transaction on a unique violation).
const now = nowIso();
const id = newId();
await k
.insertInto("entries")
.values({
id,
user_id: userId,
work_id: work.id,
status: "none",
rating: null,
review: null,
private_notes: null,
favorite: 0,
tags: toJson([]),
custom_fields: toJson({}),
visibility: await defaultVisibility(k, userId),
overrides: toJson({}),
media_type_override: null,
field_sources: toJson({}),
created_at: now,
updated_at: now,
deleted_at: null,
})
.onConflict((oc) => oc.columns(["user_id", "work_id"]).doNothing())
.execute();
existing = (await findEntry(k, userId, work.id))!;
created = existing.id === id;
}
if (!created && Object.keys(p).length === 0 && existing.deletedAt === null) return existing;
return writeEntry(k, existing, p, actor, {
extra: existing.deletedAt === null ? {} : { deleted_at: null },
historyBefore: created ? null : undefined,
});
});
}
/** Soft-delete the user's entry. Writes history. */
export async function softDeleteEntry(
db: Db,
userId: string,
entryId: string,
actor: Actor,
tx?: Executor,
): Promise<void> {
await inTx(db, tx, async (k) => {
const e = await loadEntry(k, userId, entryId);
await writeEntry(k, e, {}, actor, { extra: { deleted_at: nowIso() } });
});
}
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
export async function restoreEntryFromHistory(
k: Executor,
userId: string,
entryId: string,
before: Record<string, unknown>,
): Promise<void> {
const current = await loadEntry(k, userId, entryId, { includeDeleted: true });
const patch: Record<string, unknown> = {};
for (const key of ENTRY_PATCH_KEYS) if (key in before) patch[key] = before[key];
if (typeof before.workId === "string" && before.workId !== current.workId) {
// Undo a move: drop the forward alias and un-tombstone the user's own private work, then move back
// without writing a reverse alias (later syncs onto the target must not land on the old work).
await k
.deleteFrom("work_aliases")
.where("user_id", "=", userId)
.where("from_work_id", "=", before.workId)
.where("to_work_id", "=", current.workId)
.execute();
await k
.updateTable("works")
.set({ merged_into_id: null, updated_at: nowIso() })
.where("id", "=", before.workId)
.where("owner_user_id", "=", userId)
.execute();
await moveEntry(k, userId, entryId, before.workId, "user", { undo: true });
}
const fresh = await loadEntry(k, userId, entryId, { includeDeleted: true });
const work = await k.selectFrom("works").select("media_type").where("id", "=", fresh.workId).executeTakeFirstOrThrow();
// The user's own past data: custom values are not re-checked against today's definitions.
const p = await normalisePatch(k, userId, fresh.mediaTypeOverride ?? work.media_type, patch as Partial<EntryPatch>, {
trustCustomFields: true,
});
const extra = "deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {};
await writeEntry(k, fresh, p, "user", { extra });
}
const isEmpty = (v: unknown) =>
v === null || v === false || v === "none" || v === "" || (Array.isArray(v) && v.length === 0) ||
(typeof v === "object" && v !== null && Object.keys(v).length === 0);
/** Separator used when two entries' review or private notes are combined by a merge. */
export const MERGE_TEXT_SEPARATOR = "\n\n---\n\n";
/**
* Move the user's entry to another work (per-user merge, spec §6.1). See `moveEntry`.
* Returns the surviving entry.
*/
export async function moveEntryToWork(
db: Db,
userId: string,
entryId: string,
targetWorkId: string,
actor: Actor,
tx?: Executor,
): Promise<Entry> {
return inTx(db, tx, (k) => moveEntry(k, userId, entryId, targetWorkId, actor));
}
/** The field_sources value for a combined field: "user" if either side was the user's, else the destination's. */
function combinedSource(src: Entry, dst: Entry, key: string): Actor | undefined {
return src.fieldSources[key] === "user" || dst.fieldSources[key] === "user" ? "user" : dst.fieldSources[key];
}
/**
* Move an entry to `targetWorkId`.
* - No entry on the target: the entry's `work_id` changes.
* - A live entry on the target: merge into it. Its values win; empty fields are filled from the moved
* entry; tags/custom fields/overrides are combined; differing review/private notes are concatenated;
* a conflicting rating is kept but the moved rating is recorded in the history row's `before`.
* Engagements are re-parented (with history) and the moved entry is soft-deleted.
* - A soft-deleted entry on the target: revived with the moved entry's values and field_sources.
* Then (unless `undo`): this user's copies and list items follow to the target, a `work_aliases` row
* maps the old work to the target, and the user's own private draft is tombstoned into the target.
*/
async function moveEntry(
k: Executor,
userId: string,
entryId: string,
targetWorkId: string,
actor: Actor,
opts: { undo?: boolean } = {},
): Promise<Entry> {
const src = await loadEntry(k, userId, entryId);
const target = await resolveVisibleWork(k, userId, targetWorkId);
if (target.id === src.workId) throw new ValidationError("targetWorkId", "same_work");
const dst = await findEntry(k, userId, target.id);
let result: Entry;
if (!dst) {
result = await writeEntry(k, src, {}, actor, { extra: { work_id: target.id } });
} else if (dst.deletedAt !== null) {
const all: Partial<EntryPatch> = {};
for (const key of ENTRY_PATCH_KEYS) (all as Record<string, unknown>)[key] = src[key];
const sources: Record<string, Actor | undefined> = {};
for (const key of ENTRY_PATCH_KEYS) sources[key] = src.fieldSources[key];
result = await writeEntry(k, dst, all, actor, { extra: { deleted_at: null }, sources });
await moveEngagements(k, userId, src.id, dst.id, actor);
await writeEntry(k, src, {}, actor, { extra: { deleted_at: nowIso() } });
} else {
const merged: Record<string, unknown> = {};
const sources: Record<string, Actor | undefined> = {};
let historyExtra: WriteOpts["historyExtra"];
for (const key of ENTRY_PATCH_KEYS) {
const s = src[key];
const d = dst[key];
if (key === "tags") {
const tags = [...new Set([...dst.tags, ...src.tags])];
if (tags.length !== dst.tags.length) {
merged[key] = tags;
sources[key] = combinedSource(src, dst, key);
}
} else if (key === "customFields" || key === "overrides") {
const combined = { ...(s as object), ...(d as object) };
if (Object.keys(combined).length !== Object.keys(d as object).length) {
merged[key] = combined;
sources[key] = combinedSource(src, dst, key);
}
} else if (isEmpty(d) && !isEmpty(s)) {
merged[key] = s;
sources[key] = src.fieldSources[key];
} else if ((key === "review" || key === "privateNotes") && !isEmpty(s) && s !== d) {
merged[key] = `${d as string}${MERGE_TEXT_SEPARATOR}${s as string}`;
sources[key] = combinedSource(src, dst, key);
} else if (key === "rating" && s !== null && s !== d) {
historyExtra = { before: { rating: s }, after: { rating: d } };
}
}
result = await writeEntry(k, dst, merged as Partial<EntryPatch>, actor, { sources, historyExtra });
await moveEngagements(k, userId, src.id, dst.id, actor);
await writeEntry(k, src, {}, actor, { extra: { deleted_at: nowIso() } });
}
if (result.id !== src.id) {
// The moved entry merged into another one: source records that mapped to it keep syncing into the
// survivor. Engagements keep their ids when re-parented, so their mappings stay valid.
await k
.updateTable("source_records")
.set({ target_id: result.id })
.where("target_entity", "=", "entry")
.where("target_id", "=", src.id)
.execute();
}
if (opts.undo) return result;
await repointCopiesAndListItems(k, userId, src.workId, target.id, actor);
// Aliases: re-point any that led to the old work, drop one leaving the target (no cycles), add ours.
await k
.updateTable("work_aliases")
.set({ to_work_id: target.id })
.where("user_id", "=", userId)
.where("to_work_id", "=", src.workId)
.execute();
await k.deleteFrom("work_aliases").where("user_id", "=", userId).where("from_work_id", "=", target.id).execute();
const alias = await k
.selectFrom("work_aliases")
.select("id")
.where("user_id", "=", userId)
.where("from_work_id", "=", src.workId)
.executeTakeFirst();
if (alias) {
await k.updateTable("work_aliases").set({ to_work_id: target.id }).where("id", "=", alias.id).execute();
} else {
await k
.insertInto("work_aliases")
.values({ id: newId(), user_id: userId, from_work_id: src.workId, to_work_id: target.id })
.execute();
}
// A private draft stays private; it is tombstoned and points to the target (spec §6.1).
await k
.updateTable("works")
.set({ merged_into_id: target.id, updated_at: nowIso() })
.where("id", "=", src.workId)
.where("owner_user_id", "=", userId)
.execute();
return result;
}
/** Re-parent an entry's live and deleted engagements, writing an `entryId` history row for each. */
async function moveEngagements(k: Executor, userId: string, fromEntryId: string, toEntryId: string, actor: Actor) {
const rows = await k.selectFrom("engagements").select("id").where("entry_id", "=", fromEntryId).execute();
for (const { id } of rows) {
await k.updateTable("engagements").set({ entry_id: toEntryId, updated_at: nowIso() }).where("id", "=", id).execute();
await recordHistory(k, {
userId,
entity: "engagement",
entityId: id,
before: { entryId: fromEntryId },
after: { entryId: toEntryId },
actor,
});
}
}
/** Point this user's copies (with history) and the items of this user's lists at the new work. */
async function repointCopiesAndListItems(k: Executor, userId: string, fromWorkId: string, toWorkId: string, actor: Actor) {
const copies = await k
.selectFrom("copies")
.select("id")
.where("user_id", "=", userId)
.where("work_id", "=", fromWorkId)
.execute();
for (const { id } of copies) {
await k.updateTable("copies").set({ work_id: toWorkId, updated_at: nowIso() }).where("id", "=", id).execute();
await recordHistory(k, {
userId,
entity: "copy",
entityId: id,
before: { workId: fromWorkId },
after: { workId: toWorkId },
actor,
});
}
await k
.updateTable("list_items")
.set({ work_id: toWorkId })
.where("work_id", "=", fromWorkId)
.where("list_id", "in", k.selectFrom("lists").select("id").where("user_id", "=", userId))
.execute();
}
/** Insert a private draft work owned by the user (no external IDs). Returns its id. */
async function insertPrivateWork(k: Executor, userId: string, title: string, mediaType: string): Promise<string> {
const id = newId();
const now = nowIso();
await k
.insertInto("works")
.values({
id,
media_type: mediaType,
title,
original_title: "",
year: null,
release_date: null,
creators: toJson([]),
description: "",
cover_url: "",
cover_attachment_id: null,
metadata: toJson({}),
parent_id: null,
position: null,
owner_user_id: userId,
merged_into_id: null,
created_at: now,
updated_at: now,
})
.execute();
return id;
}
/** Create a private draft work and the user's entry on it, in one transaction. */
export async function quickAdd(
db: Db,
userId: string,
input: { title: string; mediaType?: MediaType },
): Promise<EntryView> {
const title = typeof input.title === "string" ? input.title.trim() : "";
if (!title) throw new ValidationError("title", "required");
const mediaType = input.mediaType ?? "other";
if (!(MEDIA_TYPES as readonly string[]).includes(mediaType)) throw new ValidationError("mediaType", "invalid");
const entry = await db.k.transaction().execute(async (k) => {
const workId = await insertPrivateWork(k, userId, title, mediaType);
return upsertEntry(db, userId, workId, {}, "user", k);
});
return getEntry(db, userId, entry.id);
}
// ---------------------------------------------------------------------------------------------
// Reading: views and the library list
// ---------------------------------------------------------------------------------------------
/**
* FROM/WHERE for this user's live entries on works below `e.work_id`, down to great-grandchildren
* (show → season → episode, series → volume → chapter). Portable: three fixed self-joins.
*/
const DESCENDANTS = sql`from entries c
inner join works cw on cw.id = c.work_id
left join works p1 on p1.id = cw.parent_id
left join works p2 on p2.id = p1.parent_id
where c.user_id = e.user_id and c.deleted_at is null
and (cw.parent_id = e.work_id or p1.parent_id = e.work_id or p2.parent_id = e.work_id)`;
/**
* True when one of the work's ancestors (parent, grandparent, great-grandparent) carries a live entry
* of this user, i.e. the entry is rolled up under that ancestor's entry.
*/
const HAS_ENTRY_ANCESTOR = sql<boolean>`exists (select 1 from works a1
left join works a2 on a2.id = a1.parent_id
left join works a3 on a3.id = a2.parent_id
inner join entries pe on pe.user_id = e.user_id and pe.deleted_at is null
and (pe.work_id = a1.id or pe.work_id = a2.id or pe.work_id = a3.id)
where a1.id = w.parent_id)`;
/**
* True when the entry's work is a descendant of `parentId` (up to three levels) with no work strictly
* between them carrying a live entry of this user: the top-level rule, relative to `parentId`.
*/
function underParent(parentId: string) {
const liveEntryOn = (ids: ReturnType<typeof sql>) =>
sql`exists (select 1 from entries pe where pe.user_id = e.user_id and pe.deleted_at is null and pe.work_id in (${ids}))`;
return sql<boolean>`(w.parent_id = ${parentId}
or exists (select 1 from works p1 where p1.id = w.parent_id and p1.parent_id = ${parentId}
and not ${liveEntryOn(sql`p1.id`)})
or exists (select 1 from works p1 inner join works p2 on p2.id = p1.parent_id
where p1.id = w.parent_id and p2.parent_id = ${parentId}
and not ${liveEntryOn(sql`p1.id, p2.id`)}))`;
}
function viewQuery(k: Executor, userId: string) {
return k
.selectFrom("entries as e")
.innerJoin("works as w", "w.id", "e.work_id")
.selectAll("e")
.select([
"w.media_type as w_media_type",
"w.title as w_title",
"w.original_title as w_original_title",
"w.year as w_year",
"w.cover_url as w_cover_url",
"w.parent_id as w_parent_id",
sql<number>`case when exists (select 1 from external_ids x where x.work_id = w.id) then 0 else 1 end`.as(
"is_draft",
),
sql<number | string>`(select count(*) ${DESCENDANTS})`.as("child_total"),
sql<number | string>`(select count(*) ${DESCENDANTS} and c.status = 'completed')`.as("child_completed"),
sql<string>`lower(w.title)`.as("title_key"),
])
.where("e.user_id", "=", userId)
.where("e.deleted_at", "is", null);
}
type ViewRow = EntryRow & {
w_media_type: string;
w_title: string;
w_original_title: string;
w_year: number | null;
w_cover_url: string;
w_parent_id: string | null;
is_draft: number | string;
child_total: number | string;
child_completed: number | string;
title_key: string;
};
function toView(r: ViewRow): EntryView {
const e = toEntry(r);
const o = e.overrides;
const total = Number(r.child_total);
return {
...e,
work: {
id: r.work_id,
mediaType: r.w_media_type,
title: o.title ?? r.w_title,
originalTitle: r.w_original_title,
year: o.year !== undefined ? o.year : r.w_year,
coverUrl: o.coverUrl ?? r.w_cover_url,
parentId: r.w_parent_id,
},
draft: Number(r.is_draft) === 1,
childSummary: total > 0 ? { total, completed: Number(r.child_completed) } : null,
};
}
/** One live entry of the user's, with its work view. */
export async function getEntry(db: Db, userId: string, entryId: string, tx?: Executor): Promise<EntryView> {
const r = await viewQuery(tx ?? db.k, userId).where("e.id", "=", entryId).executeTakeFirst();
if (!r) throw new NotFoundError("entry");
return toView(r as ViewRow);
}
export interface EntryFilter {
mediaType?: string;
status?: EntryStatus;
tag?: string;
draft?: boolean;
q?: string;
/**
* Default `null`: the top of the user's library, i.e. entries with no ancestor work (up to three
* levels) carrying a live entry of this user. A work id applies the same rule relative to that work:
* its descendants (up to three levels) with no live-entry work strictly between them and it.
*/
parentId?: string | null;
sort?: "updated" | "title" | "rating";
limit?: number;
cursor?: string;
}
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
/**
* The user's live entries, keyset-paginated. `cursor` is base64 JSON `{ sort, sortValue, id }`; for the
* title sort `sortValue` is the SQL `lower(w.title)` of the last row, so SQL and cursor always agree.
* Sorts: `updated` (newest first), `title` (A–Z, case-insensitive), `rating` (highest first, unrated last).
*/
export async function listEntries(
db: Db,
userId: string,
filter: EntryFilter,
): Promise<{ items: EntryView[]; nextCursor: string | null }> {
const sort = filter.sort ?? "updated";
if (!["updated", "title", "rating"].includes(sort)) throw new ValidationError("sort", "invalid");
const limit = filter.limit ?? 50;
if (!Number.isInteger(limit) || limit < 1) throw new ValidationError("limit", "invalid");
const take = Math.min(limit, 200);
let q = viewQuery(db.k, userId);
const parentId = filter.parentId === undefined ? null : filter.parentId;
q = q.where(parentId === null ? sql<boolean>`not ${HAS_ENTRY_ANCESTOR}` : underParent(parentId));
if (filter.mediaType) q = q.where(sql<string>`coalesce(e.media_type_override, w.media_type)`, "=", filter.mediaType);
if (filter.status) q = q.where("e.status", "=", filter.status);
if (filter.tag) {
const needle = `%${escapeLike(JSON.stringify(filter.tag.trim().toLowerCase()))}%`;
q = q.where(sql<boolean>`lower(e.tags) like ${needle} escape '\\'`);
}
if (filter.draft !== undefined) {
const has = sql<boolean>`exists (select 1 from external_ids x where x.work_id = w.id)`;
q = q.where(filter.draft ? sql<boolean>`not ${has}` : has);
}
if (filter.q?.trim()) {
const p = `%${escapeLike(filter.q.trim().toLowerCase())}%`;
q = q.where(
sql<boolean>`(lower(w.title) like ${p} escape '\\' or lower(w.original_title) like ${p} escape '\\')`,
);
}
const titleKey = sql<string>`lower(w.title)`;
const ratingNull = sql<number>`case when e.rating is null then 1 else 0 end`;
if (filter.cursor !== undefined) {
const c = decodeCursor(filter.cursor, sort);
if (sort === "updated") {
const v = c.sortValue as string;
q = q.where((eb) =>
eb.or([eb("e.updated_at", "<", v), eb.and([eb("e.updated_at", "=", v), eb("e.id", "<", c.id)])]),
);
} else if (sort === "title") {
const v = c.sortValue as string;
q = q.where((eb) => eb.or([eb(titleKey, ">", v), eb.and([eb(titleKey, "=", v), eb("e.id", ">", c.id)])]));
} else if (c.sortValue === null) {
q = q.where("e.rating", "is", null).where("e.id", "<", c.id);
} else {
const v = c.sortValue as number;
q = q.where((eb) =>
eb.or([
eb("e.rating", "is", null),
eb("e.rating", "<", v),
eb.and([eb("e.rating", "=", v), eb("e.id", "<", c.id)]),
]),
);
}
}
if (sort === "updated") q = q.orderBy("e.updated_at", "desc").orderBy("e.id", "desc");
else if (sort === "title") q = q.orderBy(titleKey, "asc").orderBy("e.id", "asc");
else q = q.orderBy(ratingNull, "asc").orderBy("e.rating", "desc").orderBy("e.id", "desc");
const rows = (await q.limit(take + 1).execute()) as ViewRow[];
const page = rows.slice(0, take);
let nextCursor: string | null = null;
if (rows.length > take) {
const last = page[page.length - 1]!;
const sortValue = sort === "updated" ? last.updated_at : sort === "title" ? last.title_key : last.rating;
nextCursor = Buffer.from(JSON.stringify({ sort, sortValue, id: last.id })).toString("base64");
}
return { items: page.map(toView), nextCursor };
}
function decodeCursor(cursor: string, sort: string): { sortValue: string | number | null; id: string } {
const c = fromJson<{ sort?: unknown; sortValue?: unknown; id?: unknown } | null>(
Buffer.from(cursor, "base64").toString("utf8"),
null,
);
const ok =
c !== null &&
typeof c === "object" &&
c.sort === sort &&
typeof c.id === "string" &&
(sort === "rating"
? c.sortValue === null || typeof c.sortValue === "number"
: typeof c.sortValue === "string");
if (!ok) throw new ValidationError("cursor", "invalid");
return { sortValue: c.sortValue as string | number | null, id: c.id as string };
}
// ---------------------------------------------------------------------------------------------
// Match suggestions
// ---------------------------------------------------------------------------------------------
async function loadSuggestion(k: Executor, userId: string, suggestionId: string) {
const s = await k
.selectFrom("match_suggestions")
.selectAll()
.where("id", "=", suggestionId)
.where("user_id", "=", userId)
.executeTakeFirst();
if (!s) throw new NotFoundError("suggestion");
return s;
}
/**
* Accept a match suggestion: move the user's entry from `work_id` to `candidate_work_id` (actor "user"),
* mark it accepted and reject the user's other pending suggestions for the same work.
*/
export async function acceptSuggestion(db: Db, userId: string, suggestionId: string): Promise<void> {
await db.k.transaction().execute(async (k) => {
const s = await loadSuggestion(k, userId, suggestionId);
if (s.status !== "pending") throw new ValidationError("status", "not_pending");
const entry = await findEntry(k, userId, s.work_id);
if (entry && entry.deletedAt === null) {
await moveEntry(k, userId, entry.id, s.candidate_work_id, "user");
}
await k.updateTable("match_suggestions").set({ status: "accepted" }).where("id", "=", s.id).execute();
await k
.updateTable("match_suggestions")
.set({ status: "rejected" })
.where("user_id", "=", userId)
.where("work_id", "=", s.work_id)
.where("status", "=", "pending")
.execute();
});
}
/** Reject a match suggestion. */
export async function rejectSuggestion(db: Db, userId: string, suggestionId: string): Promise<void> {
const s = await loadSuggestion(db.k, userId, suggestionId);
if (s.status !== "pending") throw new ValidationError("status", "not_pending");
await db.k.updateTable("match_suggestions").set({ status: "rejected" }).where("id", "=", s.id).execute();
}
export interface MatchSuggestion {
id: string;
workId: string;
candidateWorkId: string;
score: number;
status: "pending" | "accepted" | "rejected";
createdAt: string;
}
/** The user's pending match suggestions for a work, best first. Throws `NotFoundError` if the work is not visible. */
export async function listSuggestions(db: Db, userId: string, workId: string): Promise<MatchSuggestion[]> {
await resolveVisibleWork(db.k, userId, workId);
const rows = await db.k
.selectFrom("match_suggestions")
.selectAll()
.where("user_id", "=", userId)
.where("work_id", "=", workId)
.where("status", "=", "pending")
.orderBy("score", "desc")
.orderBy("id")
.execute();
return rows.map((r) => ({
id: r.id,
workId: r.work_id,
candidateWorkId: r.candidate_work_id,
score: r.score,
status: r.status,
createdAt: r.created_at,
}));
}