- migration 0002: unique index on works (parent_id, position) - matchWork retries the whole transaction on a unique violation (3 attempts) - addMissingExternalIds uses ON CONFLICT DO NOTHING - ID matches only return shared works or the user's own; private works cannot carry external ids - adminMergeWorks requires shared works and repoints non-colliding children - draft is computed for the aliased work id Co-Authored-By: Claude Opus 5.5 <[email protected]>
331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
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<WorksTable, "creators" | "metadata" | "position"> {
|
|
creators: Creator[];
|
|
metadata: Record<string, unknown>;
|
|
position: Position | null;
|
|
}
|
|
|
|
/** Any Kysely handle: the root one or a transaction. */
|
|
export type K = Kysely<Database>;
|
|
|
|
/** 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<Creator[]>(r.creators, []),
|
|
metadata: fromJson<Record<string, unknown>>(r.metadata, {}),
|
|
position: fromJson<Position | null>(r.position, null),
|
|
};
|
|
}
|
|
|
|
export async function createWorkIn(
|
|
k: K,
|
|
input: { hint: WorkHint; ownerUserId?: string | null; parentId?: string | null },
|
|
actor: string,
|
|
): Promise<string> {
|
|
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<string> {
|
|
return db.k.transaction().execute((tx) => createWorkIn(tx, input, actor));
|
|
}
|
|
|
|
export async function getWorkIn(k: K, id: string): Promise<Work | null> {
|
|
const seen = new Set<string>();
|
|
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<Work | null> {
|
|
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<string> {
|
|
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<string, string>,
|
|
userId: string | null = null,
|
|
): Promise<string | null> {
|
|
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<string, string>, userId: string | null = null): Promise<string | null> {
|
|
return findWorkByExternalIdsIn(db.k, ids, userId);
|
|
}
|
|
|
|
async function addMissingExternalIds(k: K, workId: string, ids: Record<string, string>): Promise<string[]> {
|
|
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<void> {
|
|
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<string, unknown> = {};
|
|
const after: Record<string, unknown> = {};
|
|
const patch: Partial<WorksTable> = {};
|
|
const set = <F extends "title" | "year" | "creators" | "description" | "cover_url">(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<void> {
|
|
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<Work[]> {
|
|
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<boolean>`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<string> {
|
|
const seen = new Set<string>();
|
|
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<string> {
|
|
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<void> {
|
|
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();
|
|
});
|
|
}
|