import { sql, type Kysely } from "kysely"; import type { MediaType, WorkHint } from "@sttracker/plugin-sdk/types"; import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { fromJson, toJson } from "@sttracker/db/json"; import type { Database, WorksTable } from "@sttracker/db/schema"; import { AuthError, NotFoundError, ValidationError } from "./errors.ts"; export type Creator = { name: string; role?: string }; export type Position = { season?: number; episode?: number; index?: number }; /** A `works` row with JSON columns parsed. Other fields keep their column names. */ export interface Work extends Omit { creators: Creator[]; metadata: Record; position: Position | null; } /** Any Kysely handle: the root one or a transaction. */ export type K = Kysely; /** Normalised JSON for `works.position`: keys in a fixed order (season, episode, index), or null when empty. */ export function normalizePosition(p: Position | undefined | null): string | null { if (!p) return null; const o: Position = {}; if (p.season !== undefined) o.season = p.season; if (p.episode !== undefined) o.episode = p.episode; if (p.index !== undefined) o.index = p.index; return Object.keys(o).length === 0 ? null : toJson(o); } export function toWork(r: WorksTable): Work { return { ...r, creators: fromJson(r.creators, []), metadata: fromJson>(r.metadata, {}), position: fromJson(r.position, null), }; } export async function createWorkIn( k: K, input: { hint: WorkHint; ownerUserId?: string | null; parentId?: string | null }, actor: string, ): Promise { const { hint } = input; if (hint.title.trim() === "") throw new ValidationError("title", "required"); if (input.ownerUserId && Object.keys(hint.externalIds).length > 0) { throw new ValidationError("externalIds", "private works cannot carry external ids"); } const id = newId(); const now = nowIso(); await k .insertInto("works") .values({ id, media_type: hint.mediaType, title: hint.title, original_title: "", year: hint.year ?? null, release_date: null, creators: toJson(hint.creators ?? []), description: hint.description ?? "", cover_url: hint.coverUrl ?? "", cover_attachment_id: null, metadata: "{}", parent_id: input.parentId ?? null, position: normalizePosition(hint.position), owner_user_id: input.ownerUserId ?? null, merged_into_id: null, created_at: now, updated_at: now, }) .execute(); for (const [namespace, value] of Object.entries(hint.externalIds)) { await k.insertInto("external_ids").values({ id: newId(), work_id: id, namespace, value }).execute(); } await k .insertInto("work_history") .values({ id: newId(), work_id: id, before: "{}", after: toJson({ created: true, title: hint.title }), actor, created_at: now }) .execute(); return id; } /** Insert the work and its external IDs. */ export function createWork( db: Db, input: { hint: WorkHint; ownerUserId?: string | null; parentId?: string | null }, actor: string, ): Promise { return db.k.transaction().execute((tx) => createWorkIn(tx, input, actor)); } export async function getWorkIn(k: K, id: string): Promise { const seen = new Set(); let cur: string | null = id; while (cur && !seen.has(cur)) { seen.add(cur); const row: WorksTable | undefined = await k.selectFrom("works").selectAll().where("id", "=", cur).executeTakeFirst(); if (!row) return null; if (!row.merged_into_id) return toWork(row); cur = row.merged_into_id; } return null; } /** Fetch a work, following `merged_into_id` tombstones to the surviving one. */ export function getWork(db: Db, id: string): Promise { return getWorkIn(db.k, id); } /** Follow `merged_into_id` to the surviving id (the input id if it is not merged). */ export async function finalWorkId(k: K, id: string): Promise { return (await getWorkIn(k, id))?.id ?? id; } /** * First work matching any (namespace, value) pair, following merges. Only works visible to `userId` * count (shared, or private to that user); with no user, only shared works. Others are treated as absent. */ export async function findWorkByExternalIdsIn( k: K, ids: Record, userId: string | null = null, ): Promise { for (const [namespace, value] of Object.entries(ids)) { const row = await k .selectFrom("external_ids") .select("work_id") .where("namespace", "=", namespace) .where("value", "=", value) .executeTakeFirst(); if (!row) continue; const work = await getWorkIn(k, row.work_id); if (work && (work.owner_user_id === null || work.owner_user_id === userId)) return work.id; } return null; } /** First work visible to `userId` (shared works only when omitted) matching any (namespace, value) pair. */ export function findWorkByExternalIds(db: Db, ids: Record, userId: string | null = null): Promise { return findWorkByExternalIdsIn(db.k, ids, userId); } async function addMissingExternalIds(k: K, workId: string, ids: Record): Promise { const added: string[] = []; for (const [namespace, value] of Object.entries(ids)) { const exists = await k .selectFrom("external_ids") .select("id") .where("namespace", "=", namespace) .where("value", "=", value) .executeTakeFirst(); if (exists) continue; // A concurrent sync may have added the same pair since the check: skip it rather than fail. const res = await k .insertInto("external_ids") .values({ id: newId(), work_id: workId, namespace, value }) .onConflict((oc) => oc.columns(["namespace", "value"]).doNothing()) .executeTakeFirst(); if (Number(res.numInsertedOrUpdatedRows ?? 0n) > 0) added.push(`${namespace}:${value}`); } return added; } export async function updateWorkFromHintIn(k: K, workId: string, hint: WorkHint, actor: string): Promise { const row = await k.selectFrom("works").selectAll().where("id", "=", workId).executeTakeFirst(); if (!row || row.owner_user_id !== null) return; // private works are never touched by syncs const before: Record = {}; const after: Record = {}; const patch: Partial = {}; const set = (field: F, next: WorksTable[F] | undefined, empty: boolean) => { if (next === undefined || empty || next === row[field]) return; before[field] = row[field]; after[field] = next; patch[field] = next; }; set("title", hint.title, hint.title.trim() === ""); set("year", hint.year, hint.year === undefined); set("creators", hint.creators ? toJson(hint.creators) : undefined, !hint.creators || hint.creators.length === 0); set("description", hint.description, !hint.description); set("cover_url", hint.coverUrl, !hint.coverUrl); const addedIds = await addMissingExternalIds(k, workId, hint.externalIds); if (addedIds.length > 0) after.externalIds = addedIds; if (Object.keys(after).length === 0) return; const now = nowIso(); if (Object.keys(patch).length > 0) { await k.updateTable("works").set({ ...patch, updated_at: now }).where("id", "=", workId).execute(); } await k .insertInto("work_history") .values({ id: newId(), work_id: workId, before: toJson(before), after: toJson(after), actor, created_at: now }) .execute(); } /** Fill or update a shared work's display fields from a hint, add missing external IDs, log to `work_history`. */ export function updateWorkFromHint(db: Db, workId: string, hint: WorkHint, actor: string): Promise { return db.k.transaction().execute((tx) => updateWorkFromHintIn(tx, workId, hint, actor)); } function escapeLike(s: string): string { return s.replace(/[!%_]/g, (c) => `!${c}`); } /** Shared works plus the user's own private ones, case-insensitive substring match on title. */ export async function searchWorks( db: Db, userId: string, q: string, opts: { mediaType?: MediaType; limit?: number } = {}, ): Promise { let qb = db.k .selectFrom("works") .selectAll() .where("merged_into_id", "is", null) .where((eb) => eb.or([eb("owner_user_id", "is", null), eb("owner_user_id", "=", userId)])) .where(sql`lower(title) like ${`%${escapeLike(q.toLowerCase())}%`} escape '!'`); if (opts.mediaType) qb = qb.where("media_type", "=", opts.mediaType); const rows = await qb.orderBy("title").limit(opts.limit ?? 50).execute(); return rows.map(toWork); } /** Follow the user's aliases and global merge tombstones to the work id they should see. */ export async function resolveAliasIn(k: K, userId: string, workId: string): Promise { const seen = new Set(); let cur = workId; while (!seen.has(cur)) { seen.add(cur); const alias = await k .selectFrom("work_aliases") .select("to_work_id") .where("user_id", "=", userId) .where("from_work_id", "=", cur) .executeTakeFirst(); if (alias) { cur = alias.to_work_id; continue; } const w = await k.selectFrom("works").select("merged_into_id").where("id", "=", cur).executeTakeFirst(); if (!w?.merged_into_id) break; cur = w.merged_into_id; } return cur; } export function resolveAlias(db: Db, userId: string, workId: string): Promise { return resolveAliasIn(db.k, userId, workId); } /** * Admin-only global merge of shared work `fromId` into `toId`. Leaves a `merged_into_id` tombstone and * moves external IDs and the children whose position `toId` does not already have. Both works must * be shared. Users without an entry on `toId` get their entry repointed; users who already * have one keep their `from` entry and get a pending `merged` change event. */ export async function adminMergeWorks(db: Db, adminUserId: string, fromId: string, toId: string): Promise { await db.k.transaction().execute(async (tx) => { const admin = await tx.selectFrom("users").select("role").where("id", "=", adminUserId).executeTakeFirst(); if (admin?.role !== "admin") throw new AuthError("forbidden"); if (fromId === toId) throw new ValidationError("toId", "cannot merge a work into itself"); const from = await tx.selectFrom("works").selectAll().where("id", "=", fromId).executeTakeFirst(); const to = await tx.selectFrom("works").selectAll().where("id", "=", toId).executeTakeFirst(); if (!from) throw new NotFoundError("work"); if (!to) throw new NotFoundError("work"); if (from.merged_into_id) throw new ValidationError("fromId", "already merged"); if (to.merged_into_id) throw new ValidationError("toId", "already merged"); if (from.owner_user_id !== null) throw new ValidationError("fromId", "only shared works can be merged"); if (to.owner_user_id !== null) throw new ValidationError("toId", "only shared works can be merged"); const now = nowIso(); await tx.updateTable("works").set({ merged_into_id: toId, updated_at: now }).where("id", "=", fromId).execute(); const moved = await tx.selectFrom("external_ids").select(["namespace", "value"]).where("work_id", "=", fromId).execute(); await tx.updateTable("external_ids").set({ work_id: toId }).where("work_id", "=", fromId).execute(); // Repoint children so matching through `toId` finds them. A child whose position `toId` already // has stays under `fromId` (unique parent_id+position); it remains reachable via the tombstone. const toPositions = new Set( (await tx.selectFrom("works").select("position").where("parent_id", "=", toId).where("position", "is not", null).execute()).map( (r) => r.position, ), ); const children = await tx.selectFrom("works").select(["id", "position"]).where("parent_id", "=", fromId).execute(); const movedChildren: string[] = []; for (const c of children) { if (c.position !== null && toPositions.has(c.position)) continue; await tx.updateTable("works").set({ parent_id: toId, updated_at: now }).where("id", "=", c.id).execute(); movedChildren.push(c.id); } const entries = await tx.selectFrom("entries").select(["id", "user_id"]).where("work_id", "=", fromId).execute(); for (const e of entries) { const existing = await tx .selectFrom("entries") .select("id") .where("user_id", "=", e.user_id) .where("work_id", "=", toId) .executeTakeFirst(); if (existing) { await tx .insertInto("change_events") .values({ id: newId(), user_id: e.user_id, connection_id: null, run_id: null, record_id: null, type: "merged", status: "pending", target_entity: "entry", target_id: e.id, payload: toJson({ fromWorkId: fromId, toWorkId: toId }), created_at: now, resolved_at: null, }) .execute(); } else { await tx.updateTable("entries").set({ work_id: toId, updated_at: now }).where("id", "=", e.id).execute(); } } await tx .insertInto("work_history") .values({ id: newId(), work_id: fromId, before: toJson({ merged_into_id: null }), after: toJson({ merged_into_id: toId }), actor: adminUserId, created_at: now }) .execute(); await tx .insertInto("work_history") .values({ id: newId(), work_id: toId, before: "{}", after: toJson({ mergedFrom: fromId, externalIds: moved, children: movedChildren }), actor: adminUserId, created_at: now }) .execute(); }); }