diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4766a8..16699ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ user layer or files a Changes-inbox event. |---|---|---| | `apps/server` | `Bun.serve`: `/api/v1`, `/ingest/`, job worker, static web | planned | | `apps/web` | React app (HTML imports) | planned | -| `packages/core` | domain services: entries, matching, change engine, history, export | planned | +| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`. Change engine and export planned | exists (partial) | | `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) | | `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned | | `plugins/` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b1787f4..4c69ff3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,6 +4,50 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin One entry per decision a future maintainer would otherwise have to reverse-engineer. No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number). +## 2026-10-30: User layer: per-user moves merge entries, keep aliases, and roll the library up by hierarchy +**Status:** accepted +**Context:** Task 6 built the user layer (`packages/core/src/{entries,engagements,history,lists,copies}.ts`). +Several behaviours are not obvious from the spec and would otherwise have to be reverse-engineered. +**Decision:** +- **Merge on move.** `moveEntryToWork` onto a work where the user already has an entry merges the two: + - Live target: the target's values win. Empty target fields are filled from the moved entry, carrying + the moved entry's `field_sources`. Tags are unioned and custom fields and overrides merged; their + `field_sources` is `"user"` if either side's was, otherwise the target's. + - Differing non-empty `review`/`private_notes` are concatenated (target, `\n\n---\n\n`, moved). + - A conflicting rating keeps the target's; the moved rating is recorded in the history row's `before`. + - Engagements are re-parented, each with an `entryId` history row (restorable), and the moved entry is + soft-deleted. + - Soft-deleted target: revived with the moved entry's values and `field_sources` as-is. +- **Alias bookkeeping.** A move writes `work_aliases (user, old → target)`, re-points aliases that led to + the old work, drops any alias leaving the target (no cycles), tombstones the user's own private draft, + and re-points this user's copies (with history) and list items. Writes to an aliased or merged work + (`upsertEntry`, copies, list items) resolve to the target. **Undoing a move** via `restoreVersion` deletes + the forward alias and writes no reverse alias, so later syncs onto the target land on the target. +- **Library cursor.** `listEntries` cursors are base64 JSON `{ sort, sortValue, id }`. The sort is carried + so a cursor cannot be replayed under another sort. For the title sort, `sortValue` is the SQL + `lower(w.title)` of the last row, never a JS lowercase, so SQL and cursor agree on non-ASCII titles. +- **Custom fields patches replace the whole map** (`customFields` in an entry, engagement or copy patch). + This makes restore exact. `field_sources` tracks the key `customFields` as a whole. Restore does not + re-check values against today's definitions, because it is the user's own past data. +- **List items are hard-deleted.** `list_items` has no `deleted_at`. The list's `user_history` snapshot + holds the ordered items, so `restoreVersion` brings removed items back. +- **Hierarchy visibility.** `childSummary` counts the user's live entries up to three levels below the + entry's work (child, grandchild, great-grandchild). The default library view (`parentId: null`) lists + entries with no ancestor, up to three levels, on which the user has a live entry. An episode whose show + is untracked therefore stays visible, and is rolled up once the show gets an entry. Drilling in + (`parentId: X`) applies the same rule relative to X: descendants up to three levels with no live-entry + work strictly between them and X. `childSummary` still counts every descendant entry. +- **Concurrent creates.** `upsertEntry` inserts with `ON CONFLICT (user_id, work_id) DO NOTHING`, then + patches through the update path, so a concurrent creator never aborts the caller's transaction. + +**Alternatives:** +- Refusing a move onto an existing entry pushes the merge onto the user. +- Recursive CTEs for hierarchy are portable but heavier; three levels cover show → season → episode. +- A JS-lowercased cursor breaks on SQLite, whose `lower()` is ASCII-only. + +**Consequences:** Undoing a move does not move copies or list items back, and hierarchy deeper than three +levels is not rolled up (both in `docs/TODO.md`). + ## 2026-10-30: A single Kysely dialect over Bun.SQL for SQLite and Postgres **Status:** accepted **Context:** Kysely ships no driver for Bun. `Bun.SQL` speaks both `sqlite://` and `postgres://` behind one API. diff --git a/docs/TODO.md b/docs/TODO.md index eec9f67..75fa908 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,4 +27,9 @@ Small, specific, independently actionable. Remove only when actually done. - [ ] Admin merge does not merge colliding children recursively; colliding children stay under the tombstoned parent. - [ ] Matching: a shared parent resolved without external IDs or position (title/year reuse in `packages/core/src/matching.ts`) has no unique key, so concurrent matches can create it twice. +- [ ] Library search (`q`) and the title sort use the work's title, not the user's `overrides.title` + (overrides are a JSON text column). The `tag` filter matches the JSON text with LIKE. +- [ ] Library hierarchy rollup and orphan detection look three levels deep; deeper trees are not rolled up. +- [ ] Undoing a per-user move does not move copies or list items back, nor restore aliases that the move + re-pointed. - [ ] `bun run typecheck` fails with TS18003 until source files exist under `apps/`, `packages/` or `plugins/`. diff --git a/packages/core/src/copies.ts b/packages/core/src/copies.ts new file mode 100644 index 0000000..a8231cf --- /dev/null +++ b/packages/core/src/copies.ts @@ -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; + 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>(r.custom_fields, {}), + sourceRef: r.source_ref, + createdAt: r.created_at, + updatedAt: r.updated_at, + deletedAt: r.deleted_at, + }; +} + +function snapshot(c: Copy): Record { + const s: Record = { 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, + currentWorkId: string | null, +): Promise> { + checkKeys("patch", patch, COPY_KEYS); + const c: Partial = {}; + 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); + c.custom_fields = toJson(patch.customFields); + } + return c; +} + +async function load(k: Executor, userId: string, copyId: string, includeDeleted = false): Promise { + 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, + actor: Actor, + extra: Partial> = {}, +): Promise { + 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): Pick { + 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, + actor: Actor = "user", + tx?: Executor, +): Promise { + 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, + actor: Actor = "user", + tx?: Executor, +): Promise { + 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 { + 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 { + 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, +): Promise { + const current = await load(k, userId, copyId, true); + const patch: Record = {}; + 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, "user", extra); +} diff --git a/packages/core/src/custom-fields.ts b/packages/core/src/custom-fields.ts new file mode 100644 index 0000000..229dcab --- /dev/null +++ b/packages/core/src/custom-fields.ts @@ -0,0 +1,176 @@ +import type { Kysely } from "kysely"; +import type { Db } from "@sttracker/db/db"; +import { newId, nowIso } from "@sttracker/db/ids"; +import { fromJson, toJson } from "@sttracker/db/json"; +import type { CustomFieldLevel, Database } from "@sttracker/db/schema"; +import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date"; +import type { MediaType } from "@sttracker/plugin-sdk/types"; +import { ValidationError } from "./errors.ts"; + +export const CUSTOM_FIELD_TYPES = [ + "text", + "long_text", + "number", + "date", + "boolean", + "single_select", + "multi_select", + "url", + "rating", +] as const; +export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number]; + +const LEVELS: readonly CustomFieldLevel[] = ["entry", "engagement", "copy"]; + +/** + * A user-defined field. Values live in the `custom_fields` JSON of the matching row + * (entry, engagement or copy), keyed by definition **id** so renaming a field keeps its values. + */ +export interface CustomFieldDef { + id: string; + userId: string; + name: string; + type: CustomFieldType; + options: string[]; + appliesTo: string[] | "all"; + level: CustomFieldLevel; + createdAt: string; +} + +export interface CustomFieldInput { + name: string; + type: CustomFieldType; + options?: string[]; + appliesTo?: MediaType[] | string[] | "all"; + level?: CustomFieldLevel; +} + +type Row = Database["custom_field_definitions"]; + +function toDef(r: { [K in keyof Row]: Row[K] }): CustomFieldDef { + return { + id: r.id, + userId: r.user_id, + name: r.name, + type: r.type as CustomFieldType, + options: fromJson(r.options, []), + appliesTo: fromJson(r.applies_to, "all"), + level: r.level, + createdAt: r.created_at, + }; +} + +/** Create a custom field definition. Names are unique per user and level (case-insensitive). */ +export async function defineCustomField(db: Db, userId: string, input: CustomFieldInput): Promise { + const name = typeof input.name === "string" ? input.name.trim() : ""; + if (!name) throw new ValidationError("name", "required"); + if (!CUSTOM_FIELD_TYPES.includes(input.type)) throw new ValidationError("type", "invalid"); + const level = input.level ?? "entry"; + if (!LEVELS.includes(level)) throw new ValidationError("level", "invalid"); + const isSelect = input.type === "single_select" || input.type === "multi_select"; + const options = [...new Set((input.options ?? []).map((o) => String(o).trim()).filter(Boolean))]; + if (isSelect && options.length === 0) throw new ValidationError("options", "required"); + const appliesTo = input.appliesTo ?? "all"; + if (appliesTo !== "all" && (!Array.isArray(appliesTo) || appliesTo.length === 0)) { + throw new ValidationError("appliesTo", "invalid"); + } + + const existing = await db.k + .selectFrom("custom_field_definitions") + .select("name") + .where("user_id", "=", userId) + .where("level", "=", level) + .execute(); + if (existing.some((r) => r.name.toLowerCase() === name.toLowerCase())) { + throw new ValidationError("name", "duplicate"); + } + + const row: Row = { + id: newId(), + user_id: userId, + name, + type: input.type, + options: toJson(isSelect ? options : []), + applies_to: toJson(appliesTo), + level, + created_at: nowIso(), + }; + await db.k.insertInto("custom_field_definitions").values(row).execute(); + return toDef(row); +} + +/** A user's definitions, optionally only those applying to `mediaType` (and/or one level). */ +export async function listCustomFields( + db: Db, + userId: string, + mediaType?: string, + level?: CustomFieldLevel, +): Promise { + return customFieldsFor(db.k, userId, { mediaType, level }); +} + +/** Same as `listCustomFields`, usable with a transaction handle. */ +export async function customFieldsFor( + k: Kysely, + userId: string, + opts: { mediaType?: string | null; level?: CustomFieldLevel }, +): Promise { + let q = k.selectFrom("custom_field_definitions").selectAll().where("user_id", "=", userId); + if (opts.level) q = q.where("level", "=", opts.level); + const rows = await q.orderBy("created_at").orderBy("id").execute(); + return rows + .map(toDef) + .filter((d) => opts.mediaType == null || d.appliesTo === "all" || d.appliesTo.includes(opts.mediaType)); +} + +/** + * Check `values` (keyed by definition id) against `defs`. `null` clears a value and is always allowed. + * Throws `ValidationError("customFields.", reason)`. + */ +export function validateCustomValues(defs: CustomFieldDef[], values: Record): void { + if (values === null || typeof values !== "object" || Array.isArray(values)) { + throw new ValidationError("customFields", "not_object"); + } + const byId = new Map(defs.map((d) => [d.id, d])); + for (const [key, v] of Object.entries(values)) { + const def = byId.get(key); + if (!def) throw new ValidationError(`customFields.${key}`, "unknown_field"); + if (v === null) continue; + const reason = checkValue(def, v); + if (reason) throw new ValidationError(`customFields.${def.name}`, reason); + } +} + +function checkValue(def: CustomFieldDef, v: unknown): string | null { + switch (def.type) { + case "text": + case "long_text": + return typeof v === "string" ? null : "not_text"; + case "number": + return typeof v === "number" && Number.isFinite(v) ? null : "not_number"; + case "date": + return typeof v === "string" && parsePartialDate(v) ? null : "not_date"; + case "boolean": + return typeof v === "boolean" ? null : "not_boolean"; + case "single_select": + if (typeof v !== "string") return "not_text"; + return def.options.includes(v) ? null : "unknown_option"; + case "multi_select": + if (!Array.isArray(v)) return "not_list"; + if (!v.every((x) => typeof x === "string" && def.options.includes(x))) return "unknown_option"; + return new Set(v).size === v.length ? null : "duplicate_option"; + case "url": + return typeof v === "string" && isHttpUrl(v) ? null : "not_url"; + case "rating": + return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 100 ? null : "out_of_range"; + } +} + +function isHttpUrl(s: string): boolean { + try { + const u = new URL(s); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} diff --git a/packages/core/src/engagements.ts b/packages/core/src/engagements.ts new file mode 100644 index 0000000..e758308 --- /dev/null +++ b/packages/core/src/engagements.ts @@ -0,0 +1,276 @@ +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; + platform: string | null; + rating: number | null; + notes: string | null; + customFields: Record; +} +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>(r.progress, {}), + platform: r.platform, + rating: r.rating, + notes: r.notes, + customFields: fromJson>(r.custom_fields, {}), + fieldSources: fromJson(r.field_sources, {}), + createdAt: r.created_at, + updatedAt: r.updated_at, + deletedAt: r.deleted_at, + }; +} + +function snapshot(e: Engagement): Record { + const s: Record = { entryId: e.entryId, 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, +): Promise> { + checkKeys("patch", patch, ENGAGEMENT_PATCH_KEYS); + const c: Partial = {}; + 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); + 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).where("e.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, + actor: Actor, + extra: Partial> = {}, +): Promise { + 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, + actor: Actor, + tx?: Executor, +): Promise { + 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, + actor: Actor, + tx?: Executor, +): Promise { + 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 { + 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 { + 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, +): Promise { + const { eg, mediaType } = await load(k, userId, engagementId, { includeDeleted: true }); + const patch: Record = {}; + for (const key of ENGAGEMENT_PATCH_KEYS) if (key in before) patch[key] = before[key]; + const extra: Partial> = + "deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {}; + if (typeof before.entryId === "string" && before.entryId !== eg.entryId) { + // Undo a merge's re-parenting; the old entry must be this user's. + const owner = await k + .selectFrom("entries") + .select(["id", "deleted_at"]) + .where("id", "=", before.entryId) + .where("user_id", "=", userId) + .executeTakeFirst(); + if (!owner) throw new NotFoundError("entry"); + if (owner.deleted_at !== null) throw new ValidationError("entryId", "target entry is deleted"); + extra.entry_id = before.entryId; + } + await write(k, userId, eg, mediaType, patch as Partial, "user", extra); +} diff --git a/packages/core/src/entries.ts b/packages/core/src/entries.ts new file mode 100644 index 0000000..28d5110 --- /dev/null +++ b/packages/core/src/entries.ts @@ -0,0 +1,967 @@ +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; + +/** 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 { + 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; + 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(r.tags, []), + customFields: fromJson>(r.custom_fields, {}), + visibility: r.visibility, + overrides: fromJson(r.overrides, {}), + mediaTypeOverride: r.media_type_override, + fieldSources: fromJson(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 { + const s: Record = { workId: e.workId, deletedAt: e.deletedAt }; + for (const k of ENTRY_PATCH_KEYS) s[k] = e[k]; + return s; +} + +function patchToColumns(p: Partial): Partial { + const c: Partial = {}; + 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, + opts: { trustCustomFields?: boolean } = {}, +): Promise> { + checkKeys("patch", patch, ENTRY_PATCH_KEYS); + const out: Partial = {}; + 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; + 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>; + /** Per-key `field_sources` for patched keys instead of `actor`; `undefined` removes the key. */ + sources?: Record; + /** History `before` override: `null` records a creation. */ + historyBefore?: Record | null; + /** Extra keys merged into the history row's before/after snapshots. */ + historyExtra?: { before: Record; after: Record }; +} + +/** + * 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, + actor: Actor, + opts: WriteOpts = {}, +): Promise { + const fieldSources: Record = { ...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 { + 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 { + 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, + actor: Actor, + tx?: Executor, +): Promise { + 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 { + 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, +): Promise { + const current = await loadEntry(k, userId, entryId, { includeDeleted: true }); + const patch: Record = {}; + 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, { + 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 { + 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 { + 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 = {}; + for (const key of ENTRY_PATCH_KEYS) (all as Record)[key] = src[key]; + const sources: Record = {}; + 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 = {}; + const sources: Record = {}; + 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, actor, { sources, historyExtra }); + await moveEngagements(k, userId, src.id, dst.id, actor); + await writeEntry(k, src, {}, actor, { extra: { deleted_at: nowIso() } }); + } + 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 { + 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 { + 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`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) => + 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`(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`case when exists (select 1 from external_ids x where x.work_id = w.id) then 0 else 1 end`.as( + "is_draft", + ), + sql`(select count(*) ${DESCENDANTS})`.as("child_total"), + sql`(select count(*) ${DESCENDANTS} and c.status = 'completed')`.as("child_completed"), + sql`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 { + 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`not ${HAS_ENTRY_ANCESTOR}` : underParent(parentId)); + if (filter.mediaType) q = q.where(sql`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`lower(e.tags) like ${needle} escape '\\'`); + } + if (filter.draft !== undefined) { + const has = sql`exists (select 1 from external_ids x where x.work_id = w.id)`; + q = q.where(filter.draft ? sql`not ${has}` : has); + } + if (filter.q?.trim()) { + const p = `%${escapeLike(filter.q.trim().toLowerCase())}%`; + q = q.where( + sql`(lower(w.title) like ${p} escape '\\' or lower(w.original_title) like ${p} escape '\\')`, + ); + } + + const titleKey = sql`lower(w.title)`; + const ratingNull = sql`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 { + 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 { + 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(); +} diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts new file mode 100644 index 0000000..34b8da6 --- /dev/null +++ b/packages/core/src/history.ts @@ -0,0 +1,136 @@ +import type { Kysely } from "kysely"; +import type { Db } from "@sttracker/db/db"; +import { newId, nowIso } from "@sttracker/db/ids"; +import { fromJson, toJson } from "@sttracker/db/json"; +import type { Database } from "@sttracker/db/schema"; +import { restoreCopyFromHistory } from "./copies.ts"; +import { restoreEngagementFromHistory } from "./engagements.ts"; +import { restoreEntryFromHistory } from "./entries.ts"; +import { NotFoundError } from "./errors.ts"; +import { restoreListFromHistory } from "./lists.ts"; + +/** Who made a change. Plugin actors are `plugin::`; API tokens are `token:`. */ +export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`; + +/** User-layer entities that write `user_history`. */ +export type HistoryEntity = "entry" | "engagement" | "copy" | "list"; + +/** A Kysely handle: the root instance or a transaction. */ +export type Executor = Kysely; + +export interface HistoryRecord { + id: string; + entity: string; + entityId: string; + before: Record; + after: Record; + actor: string; + createdAt: string; +} + +/** + * Run `fn` in the caller's transaction when given, otherwise in a new one. + * Inside `fn` only the passed handle may be used (SQLite deadlocks on `db.k` mid-transaction). + */ +export function inTx(db: Db, tx: Executor | undefined, fn: (tx: Executor) => Promise): Promise { + return tx ? fn(tx) : db.k.transaction().execute((t) => fn(t)); +} + +/** + * Write a `user_history` row holding only the keys whose values differ between `before` and `after` + * (compared as JSON). `null` before/after means the entity did not exist / no longer exists. + * Returns the history id, or null when nothing changed (no row written). + */ +export async function recordHistory( + tx: Executor | Db, + input: { + userId: string; + entity: HistoryEntity; + entityId: string; + before: Record | null; + after: Record | null; + actor: Actor; + }, +): Promise { + const k = "k" in tx ? tx.k : tx; + const before = input.before ?? {}; + const after = input.after ?? {}; + const b: Record = {}; + const a: Record = {}; + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (toJson(before[key]) === toJson(after[key])) continue; + if (key in before) b[key] = before[key]; + if (key in after) a[key] = after[key]; + } + if (Object.keys(a).length === 0 && Object.keys(b).length === 0) return null; + const id = newId(); + await k + .insertInto("user_history") + .values({ + id, + user_id: input.userId, + entity: input.entity, + entity_id: input.entityId, + before: toJson(b), + after: toJson(a), + actor: input.actor, + created_at: nowIso(), + }) + .execute(); + return id; +} + +/** The user's history for one entity, newest first. Another user's entity yields `[]`. */ +export async function getHistory(db: Db, userId: string, entity: string, entityId: string): Promise { + const rows = await db.k + .selectFrom("user_history") + .selectAll() + .where("user_id", "=", userId) + .where("entity", "=", entity) + .where("entity_id", "=", entityId) + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .execute(); + return rows.map(toRecord); +} + +/** + * Re-apply a history row's `before` values as actor `user` (which writes a new history row and sets + * `field_sources` for the restored fields to `"user"`). Throws `NotFoundError` for another user's row. + */ +export async function restoreVersion(db: Db, userId: string, historyId: string): Promise { + const row = await db.k + .selectFrom("user_history") + .selectAll() + .where("id", "=", historyId) + .where("user_id", "=", userId) + .executeTakeFirst(); + if (!row) throw new NotFoundError("history"); + const h = toRecord(row); + await db.k.transaction().execute(async (tx) => { + switch (h.entity as HistoryEntity) { + case "entry": + return restoreEntryFromHistory(tx, userId, h.entityId, h.before); + case "engagement": + return restoreEngagementFromHistory(tx, userId, h.entityId, h.before); + case "copy": + return restoreCopyFromHistory(tx, userId, h.entityId, h.before); + case "list": + return restoreListFromHistory(tx, userId, h.entityId, h.before); + default: + throw new NotFoundError("history"); + } + }); +} + +function toRecord(r: Database["user_history"]): HistoryRecord { + return { + id: r.id, + entity: r.entity, + entityId: r.entity_id, + before: fromJson>(r.before, {}), + after: fromJson>(r.after, {}), + actor: r.actor, + createdAt: r.created_at, + }; +} diff --git a/packages/core/src/lists.ts b/packages/core/src/lists.ts new file mode 100644 index 0000000..53b8e86 --- /dev/null +++ b/packages/core/src/lists.ts @@ -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> { + const s: Record = { 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): Partial { + checkKeys("patch", patch, LIST_KEYS); + const out: Partial = {}; + 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): 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): Partial { + const c: Partial = {}; + 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 { + 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 { + 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 { + 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( + k: Executor, + userId: string, + listId: string, + actor: Actor, + change: (l: List) => Promise, + opts: { includeDeleted?: boolean } = {}, +): Promise { + 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 & { name: string }, + actor: Actor = "user", + tx?: Executor, +): Promise { + 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, + actor: Actor = "user", + tx?: Executor, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +): Promise { + const patch: Record = {}; + for (const key of LIST_KEYS) if (key in before) patch[key] = before[key]; + const p = normalise(patch as Partial); + await mutate( + k, + userId, + listId, + "user", + async (l) => { + checkGrouping({ ...l, ...p }); + const cols: Partial = 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 }, + ); +} diff --git a/packages/core/test/copies.test.ts b/packages/core/test/copies.test.ts new file mode 100644 index 0000000..8b3c45f --- /dev/null +++ b/packages/core/test/copies.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { createCopy, listCopies, softDeleteCopy, updateCopy } from "../src/copies.ts"; +import { defineCustomField } from "../src/custom-fields.ts"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import { getHistory, restoreVersion } from "../src/history.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("copies", (getDb) => { + test("createCopy stores price in cents and partial acquired date", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const c = await createCopy(db, userId, { + workId, + format: "hardback", + acquiredFrom: "Waterstones", + acquiredAt: { value: "2019-03", precision: "month" }, + priceCents: 1299, + currency: "GBP", + }); + expect(c.priceCents).toBe(1299); + expect(c.acquiredAt).toEqual({ value: "2019-03", precision: "month" }); + const row = await db.k.selectFrom("copies").selectAll().where("id", "=", c.id).executeTakeFirstOrThrow(); + expect(row.price_cents).toBe(1299); + expect(row.acquired_precision).toBe("month"); + + await expect(createCopy(db, userId, { workId, priceCents: 12.5 })).rejects.toThrow(ValidationError); + await expect(createCopy(db, userId, { workId, currency: "pounds" })).rejects.toThrow(ValidationError); + await expect(createCopy(db, userId, {})).rejects.toThrow(ValidationError); + }); + + test("unmatched copy keeps its title hint; listCopies filters by work", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const steam = await createCopy(db, userId, { titleHint: "HALF-LIFE 2", format: "Steam", sourceRef: "steam:220" }, "plugin:steam:c1"); + const book = await createCopy(db, userId, { workId }); + expect(steam.workId).toBeNull(); + expect(steam.titleHint).toBe("HALF-LIFE 2"); + expect((await listCopies(db, userId, {})).map((c) => c.id).sort()).toEqual([steam.id, book.id].sort()); + expect((await listCopies(db, userId, { workId })).map((c) => c.id)).toEqual([book.id]); + }); + + test("updateCopy writes history; soft delete and restore", async () => { + const db = getDb(); + const userId = await makeUser(db); + const def = await defineCustomField(db, userId, { name: "Signed", type: "boolean", level: "copy" }); + const c = await createCopy(db, userId, { workId: await makeWork(db), notes: "shelf 3" }); + const up = await updateCopy(db, userId, c.id, { notes: "shelf 4", customFields: { [def.id]: true } }); + expect(up.notes).toBe("shelf 4"); + expect(up.customFields).toEqual({ [def.id]: true }); + await expect(updateCopy(db, userId, c.id, { customFields: { [def.id]: "yes" } })).rejects.toThrow(ValidationError); + const [h] = await getHistory(db, userId, "copy", c.id); + expect(h!.before).toEqual({ notes: "shelf 3", customFields: {} }); + + await softDeleteCopy(db, userId, c.id); + expect(await listCopies(db, userId, {})).toEqual([]); + const [del] = await getHistory(db, userId, "copy", c.id); + await restoreVersion(db, userId, del!.id); + expect((await listCopies(db, userId, {})).map((x) => x.id)).toEqual([c.id]); + }); + + test("another user's copies are not found", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const c = await createCopy(db, ada, { titleHint: "x" }); + await expect(updateCopy(db, bob, c.id, { notes: "y" })).rejects.toThrow(NotFoundError); + await expect(softDeleteCopy(db, bob, c.id)).rejects.toThrow(NotFoundError); + expect(await listCopies(db, bob, {})).toEqual([]); + await expect(createCopy(db, bob, { workId: await makeWork(db, { ownerUserId: ada }) })).rejects.toThrow( + NotFoundError, + ); + }); +}); diff --git a/packages/core/test/custom-fields.test.ts b/packages/core/test/custom-fields.test.ts new file mode 100644 index 0000000..06b3b9e --- /dev/null +++ b/packages/core/test/custom-fields.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { ValidationError } from "../src/errors.ts"; +import { defineCustomField, listCustomFields, validateCustomValues } from "../src/custom-fields.ts"; +import { makeUser } from "./user-layer-fixtures.ts"; + +forEachDialect("custom fields", (getDb) => { + test("custom select field rejects unknown option", async () => { + const db = getDb(); + const userId = await makeUser(db); + const mood = await defineCustomField(db, userId, { + name: "Mood", + type: "single_select", + options: ["cosy", "grim"], + }); + expect(() => validateCustomValues([mood], { [mood.id]: "cosy" })).not.toThrow(); + let err: unknown; + try { + validateCustomValues([mood], { [mood.id]: "chaotic" }); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).field).toBe("customFields.Mood"); + expect((err as ValidationError).reason).toBe("unknown_option"); + }); + + test("listCustomFields filters by media type and stays per user", async () => { + const db = getDb(); + const userId = await makeUser(db); + const other = await makeUser(db, "bob"); + await defineCustomField(db, userId, { name: "Translator", type: "text", appliesTo: ["book", "manga"] }); + await defineCustomField(db, userId, { name: "Recommended by", type: "text" }); + await defineCustomField(db, other, { name: "Bob field", type: "text" }); + + expect((await listCustomFields(db, userId)).map((d) => d.name).sort()).toEqual(["Recommended by", "Translator"]); + expect((await listCustomFields(db, userId, "game")).map((d) => d.name)).toEqual(["Recommended by"]); + expect((await listCustomFields(db, userId, "manga")).map((d) => d.name).sort()).toEqual([ + "Recommended by", + "Translator", + ]); + }); + + test("defineCustomField rejects bad input", async () => { + const db = getDb(); + const userId = await makeUser(db); + await expect(defineCustomField(db, userId, { name: " ", type: "text" })).rejects.toThrow(ValidationError); + await expect(defineCustomField(db, userId, { name: "X", type: "colour" as never })).rejects.toThrow( + ValidationError, + ); + await expect(defineCustomField(db, userId, { name: "S", type: "single_select" })).rejects.toThrow( + ValidationError, + ); + await defineCustomField(db, userId, { name: "Mood", type: "text" }); + await expect(defineCustomField(db, userId, { name: "mood", type: "text" })).rejects.toThrow(ValidationError); + }); +}); + +test("validateCustomValues checks every type", () => { + const def = (id: string, type: string, options: string[] = []) => ({ + id, + userId: "u", + name: id, + type: type as never, + options, + appliesTo: "all" as const, + level: "entry" as const, + createdAt: "", + }); + const defs = [ + def("t", "text"), + def("n", "number"), + def("d", "date"), + def("b", "boolean"), + def("m", "multi_select", ["a", "b"]), + def("u", "url"), + def("r", "rating"), + ]; + expect(() => + validateCustomValues(defs, { t: "x", n: 1.5, d: "2019", b: true, m: ["a"], u: "https://x.org", r: 80 }), + ).not.toThrow(); + expect(() => validateCustomValues(defs, { t: null })).not.toThrow(); + const bad: Array<[Record, string]> = [ + [{ nope: 1 }, "unknown_field"], + [{ t: 3 }, "not_text"], + [{ n: "3" }, "not_number"], + [{ d: "2019-13" }, "not_date"], + [{ b: 1 }, "not_boolean"], + [{ m: ["c"] }, "unknown_option"], + [{ m: "a" }, "not_list"], + [{ u: "ftp:x" }, "not_url"], + [{ r: 101 }, "out_of_range"], + ]; + for (const [values, reason] of bad) { + let err: unknown; + try { + validateCustomValues(defs, values); + } catch (e) { + err = e; + } + expect((err as ValidationError).reason).toBe(reason); + } +}); diff --git a/packages/core/test/engagements.test.ts b/packages/core/test/engagements.test.ts new file mode 100644 index 0000000..6b20545 --- /dev/null +++ b/packages/core/test/engagements.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { defineCustomField } from "../src/custom-fields.ts"; +import { + addEngagement, + listEngagements, + softDeleteEngagement, + updateEngagement, +} from "../src/engagements.ts"; +import { moveEntryToWork, softDeleteEntry, upsertEntry } from "../src/entries.ts"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import { getHistory, restoreVersion } from "../src/history.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("engagements", (getDb) => { + async function setup(mediaType = "book") { + const db = getDb(); + const userId = await makeUser(db); + const entry = await upsertEntry(db, userId, await makeWork(db, { mediaType }), { status: "completed" }, "user"); + return { db, userId, entry }; + } + + test("engagement with year precision keeps '2019'", async () => { + const { db, userId, entry } = await setup(); + const eg = await addEngagement( + db, + userId, + entry.id, + { finishedAt: { value: "2019", precision: "year" }, platform: "Kindle" }, + "user", + ); + expect(eg.finishedAt).toEqual({ value: "2019", precision: "year" }); + expect(eg.startedAt).toBeNull(); + expect(eg.fieldSources).toEqual({ finishedAt: "user", platform: "user" }); + const row = await db.k.selectFrom("engagements").selectAll().where("id", "=", eg.id).executeTakeFirstOrThrow(); + expect(row.finished_at).toBe("2019"); + expect(row.finished_precision).toBe("year"); + expect(row.started_at).toBeNull(); + expect(row.started_precision).toBe("unknown"); + const [listed] = await listEngagements(db, userId, entry.id); + expect(listed!.finishedAt).toEqual({ value: "2019", precision: "year" }); + }); + + test("partial dates must match their precision", async () => { + const { db, userId, entry } = await setup(); + const bad = [ + { value: "2019-03", precision: "year" }, + { value: "2019-02-30", precision: "day" }, + { value: "", precision: "day" }, + { value: "2019", precision: "bogus" }, + ]; + for (const d of bad) { + await expect(addEngagement(db, userId, entry.id, { startedAt: d as never }, "user")).rejects.toThrow( + ValidationError, + ); + } + const ok = await addEngagement( + db, + userId, + entry.id, + { startedAt: { value: "2019-03-04T20:15:00Z", precision: "day" }, finishedAt: { value: "", precision: "unknown" } }, + "user", + ); + expect(ok.startedAt).toEqual({ value: "2019-03-04T20:15:00Z", precision: "day" }); + expect(ok.finishedAt).toBeNull(); + }); + + test("updateEngagement touches only patched keys and writes history", async () => { + const { db, userId, entry } = await setup(); + const eg = await addEngagement(db, userId, entry.id, { rating: 70, notes: "great" }, "user"); + const up = await updateEngagement(db, userId, eg.id, { rating: 90, progress: { page: 120 } }, "plugin:hardcover:c1"); + expect(up.notes).toBe("great"); + expect(up.rating).toBe(90); + expect(up.progress).toEqual({ page: 120 }); + expect(up.fieldSources).toEqual({ rating: "plugin:hardcover:c1", notes: "user", progress: "plugin:hardcover:c1" }); + const [h] = await getHistory(db, userId, "engagement", eg.id); + expect(h!.before).toEqual({ rating: 70, progress: {} }); + expect(h!.after).toEqual({ rating: 90, progress: { page: 120 } }); + await restoreVersion(db, userId, h!.id); + const [back] = await listEngagements(db, userId, entry.id); + expect(back!.rating).toBe(70); + expect(back!.fieldSources.rating).toBe("user"); + await expect(updateEngagement(db, userId, eg.id, { rating: -1 }, "user")).rejects.toThrow(ValidationError); + await expect(updateEngagement(db, userId, eg.id, { bogus: 1 } as never, "user")).rejects.toThrow(ValidationError); + }); + + test("engagement custom fields use engagement-level definitions", async () => { + const { db, userId, entry } = await setup(); + const entryLevel = await defineCustomField(db, userId, { name: "Mood", type: "text" }); + const def = await defineCustomField(db, userId, { name: "Format", type: "single_select", options: ["audio"], level: "engagement" }); + const eg = await addEngagement(db, userId, entry.id, { customFields: { [def.id]: "audio" } }, "user"); + expect(eg.customFields).toEqual({ [def.id]: "audio" }); + await expect( + addEngagement(db, userId, entry.id, { customFields: { [entryLevel.id]: "x" } }, "user"), + ).rejects.toThrow(ValidationError); + }); + + test("softDeleteEngagement hides it; restore brings it back", async () => { + const { db, userId, entry } = await setup(); + const eg = await addEngagement(db, userId, entry.id, { platform: "Switch" }, "user"); + await softDeleteEngagement(db, userId, eg.id, "user"); + expect(await listEngagements(db, userId, entry.id)).toEqual([]); + await expect(updateEngagement(db, userId, eg.id, { platform: "PC" }, "user")).rejects.toThrow(NotFoundError); + const [del] = await getHistory(db, userId, "engagement", eg.id); + await restoreVersion(db, userId, del!.id); + expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([eg.id]); + }); + + test("engagements sort by start date, undated last", async () => { + const { db, userId, entry } = await setup(); + const undated = await addEngagement(db, userId, entry.id, {}, "user"); + const later = await addEngagement(db, userId, entry.id, { startedAt: { value: "2021-05", precision: "month" } }, "user"); + const early = await addEngagement(db, userId, entry.id, { startedAt: { value: "2019", precision: "year" } }, "user"); + expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([early.id, later.id, undated.id]); + }); + + test("engagements of a soft-deleted entry cannot be changed", async () => { + const { db, userId, entry } = await setup(); + const eg = await addEngagement(db, userId, entry.id, {}, "user"); + await softDeleteEntry(db, userId, entry.id, "user"); + await expect(updateEngagement(db, userId, eg.id, { rating: 1 }, "user")).rejects.toThrow(NotFoundError); + await expect(softDeleteEngagement(db, userId, eg.id, "user")).rejects.toThrow(NotFoundError); + }); + + test("restoring a re-parenting onto a soft-deleted entry is rejected", async () => { + const { db, userId, entry } = await setup(); + const other = await upsertEntry(db, userId, await makeWork(db, { title: "Other" }), {}, "user"); + const eg = await addEngagement(db, userId, entry.id, {}, "user"); + await moveEntryToWork(db, userId, entry.id, other.workId, "user"); + const [h] = await getHistory(db, userId, "engagement", eg.id); + expect(h!.before).toEqual({ entryId: entry.id }); + // the merged-away entry is soft-deleted, so the engagement cannot go back to it + const err = await restoreVersion(db, userId, h!.id).catch((e: unknown) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err).toMatchObject({ field: "entryId", reason: "target entry is deleted" }); + }); + + test("another user's engagements are not found", async () => { + const { db, userId, entry } = await setup(); + const bob = await makeUser(db, "bob"); + const eg = await addEngagement(db, userId, entry.id, {}, "user"); + await expect(addEngagement(db, bob, entry.id, {}, "user")).rejects.toThrow(NotFoundError); + await expect(updateEngagement(db, bob, eg.id, { rating: 1 }, "user")).rejects.toThrow(NotFoundError); + await expect(softDeleteEngagement(db, bob, eg.id, "user")).rejects.toThrow(NotFoundError); + await expect(listEngagements(db, bob, entry.id)).rejects.toThrow(NotFoundError); + }); +}); diff --git a/packages/core/test/entries.test.ts b/packages/core/test/entries.test.ts new file mode 100644 index 0000000..08dbafb --- /dev/null +++ b/packages/core/test/entries.test.ts @@ -0,0 +1,389 @@ +import { expect, test } from "bun:test"; +import { newId } from "@sttracker/db/ids"; +import { forEachDialect } from "@sttracker/db/testing"; +import { defineCustomField } from "../src/custom-fields.ts"; +import { addEngagement, listEngagements } from "../src/engagements.ts"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import { + getEntry, + listEntries, + moveEntryToWork, + quickAdd, + softDeleteEntry, + upsertEntry, +} from "../src/entries.ts"; +import { getHistory } from "../src/history.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("entries", (getDb) => { + test("upsertEntry records field_sources and history", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const created = await upsertEntry(db, userId, workId, { status: "planned", tags: ["sci-fi"] }, "user"); + expect(created.status).toBe("planned"); + expect(created.visibility).toBe("private"); + expect(created.fieldSources).toEqual({ status: "user", tags: "user" }); + + const updated = await upsertEntry(db, userId, workId, { rating: 80, status: "completed" }, "plugin:trakt:c1"); + expect(updated.id).toBe(created.id); + expect(updated.rating).toBe(80); + expect(updated.tags).toEqual(["sci-fi"]); + expect(updated.fieldSources).toEqual({ status: "plugin:trakt:c1", tags: "user", rating: "plugin:trakt:c1" }); + + const history = await getHistory(db, userId, "entry", created.id); + expect(history).toHaveLength(2); + expect(history[0]!.before).toEqual({ status: "planned", rating: null }); + expect(history[0]!.after).toEqual({ status: "completed", rating: 80 }); + expect(history[0]!.actor).toBe("plugin:trakt:c1"); + }); + + test("upsertEntry works inside a caller's transaction", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + await expect( + db.k.transaction().execute(async (tx) => { + await upsertEntry(db, userId, workId, { status: "planned" }, "user", tx); + throw new Error("rollback"); + }), + ).rejects.toThrow("rollback"); + expect((await listEntries(db, userId, {})).items).toHaveLength(0); + const e = await db.k.transaction().execute((tx) => upsertEntry(db, userId, workId, { favorite: true }, "user", tx)); + expect(e.favorite).toBe(true); + expect(e.status).toBe("none"); + }); + + test("visibility default comes from user preferences", async () => { + const db = getDb(); + const userId = await makeUser(db, "pub", { defaultVisibility: "public" }); + const e = await upsertEntry(db, userId, await makeWork(db), {}, "user"); + expect(e.visibility).toBe("public"); + expect(e.fieldSources).toEqual({}); + }); + + test("rating 101 rejected", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + await expect(upsertEntry(db, userId, workId, { rating: 101 }, "user")).rejects.toThrow(ValidationError); + await expect(upsertEntry(db, userId, workId, { rating: 7.5 }, "user")).rejects.toThrow(ValidationError); + await expect(upsertEntry(db, userId, workId, { status: "done" as never }, "user")).rejects.toThrow( + ValidationError, + ); + expect((await listEntries(db, userId, {})).items).toHaveLength(0); + }); + + test("custom field values are validated against the user's entry definitions", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db, { mediaType: "book" }); + const def = await defineCustomField(db, userId, { name: "Mood", type: "single_select", options: ["cosy"] }); + const gameOnly = await defineCustomField(db, userId, { name: "Hours", type: "number", appliesTo: ["game"] }); + const e = await upsertEntry(db, userId, workId, { customFields: { [def.id]: "cosy" } }, "user"); + expect(e.customFields).toEqual({ [def.id]: "cosy" }); + await expect(upsertEntry(db, userId, workId, { customFields: { [def.id]: "grim" } }, "user")).rejects.toThrow( + ValidationError, + ); + await expect(upsertEntry(db, userId, workId, { customFields: { [gameOnly.id]: 3 } }, "user")).rejects.toThrow( + ValidationError, + ); + }); + + test("quickAdd creates draft entry", async () => { + const db = getDb(); + const userId = await makeUser(db); + const view = await quickAdd(db, userId, { title: "Zine I found" }); + expect(view.draft).toBe(true); + expect(view.work.title).toBe("Zine I found"); + expect(view.work.mediaType).toBe("other"); + expect(view.status).toBe("none"); + const work = await db.k.selectFrom("works").selectAll().where("id", "=", view.workId).executeTakeFirstOrThrow(); + expect(work.owner_user_id).toBe(userId); + + const shared = await upsertEntry(db, userId, await makeWork(db, { externalIds: { isbn13: "1" } }), {}, "user"); + expect((await getEntry(db, userId, shared.id)).draft).toBe(false); + expect((await listEntries(db, userId, { draft: true })).items.map((i) => i.id)).toEqual([view.id]); + await expect(quickAdd(db, userId, { title: " " })).rejects.toThrow(ValidationError); + await expect(quickAdd(db, userId, { title: "x", mediaType: "zine" as never })).rejects.toThrow(ValidationError); + }); + + test("listEntries rolls up children", async () => { + const db = getDb(); + const userId = await makeUser(db); + const show = await makeWork(db, { title: "Severance", mediaType: "show" }); + const showEntry = await upsertEntry(db, userId, show, { status: "in_progress" }, "user"); + for (const status of ["completed", "completed", "in_progress"] as const) { + const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: show }); + await upsertEntry(db, userId, ep, { status }, "user"); + } + const lone = await upsertEntry(db, userId, await makeWork(db, { title: "Dune" }), {}, "user"); + + const top = await listEntries(db, userId, {}); + expect(top.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id].sort()); + const showView = top.items.find((i) => i.id === showEntry.id)!; + expect(showView.childSummary).toEqual({ total: 3, completed: 2 }); + expect(top.items.find((i) => i.id === lone.id)!.childSummary).toBeNull(); + + const children = await listEntries(db, userId, { parentId: show }); + expect(children.items).toHaveLength(3); + expect(children.items.every((i) => i.work.mediaType === "episode")).toBe(true); + + // grandchildren: show → season (no entry) → episodes still roll up to the show + const show2 = await makeWork(db, { title: "The Expanse", mediaType: "show" }); + const show2Entry = await upsertEntry(db, userId, show2, {}, "user"); + const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show2 }); + for (const status of ["completed", "planned"] as const) { + const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: season }); + await upsertEntry(db, userId, ep, { status }, "user"); + } + const top2 = await listEntries(db, userId, {}); + expect(top2.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id, show2Entry.id].sort()); + expect(top2.items.find((i) => i.id === show2Entry.id)!.childSummary).toEqual({ total: 2, completed: 1 }); + + // drilling into the show lists the episodes, since the season between them has no entry + const drill = await listEntries(db, userId, { parentId: show2 }); + expect(drill.items.map((i) => i.work.mediaType)).toEqual(["episode", "episode"]); + expect(await listEntries(db, userId, { parentId: show2, q: "nothing matches" })).toEqual({ items: [], nextCursor: null }); + + // once the season has an entry, the show lists the season and the season lists the episodes + const seasonEntry = await upsertEntry(db, userId, season, { status: "in_progress" }, "user"); + expect((await listEntries(db, userId, { parentId: show2 })).items.map((i) => i.id)).toEqual([seasonEntry.id]); + const seasonDrill = await listEntries(db, userId, { parentId: season }); + expect(seasonDrill.items.map((i) => i.work.mediaType)).toEqual(["episode", "episode"]); + expect(seasonDrill.items.find((i) => i.id === seasonEntry.id)).toBeUndefined(); + }); + + test("orphaned child entries are top level until an ancestor has an entry", async () => { + const db = getDb(); + const userId = await makeUser(db); + const show = await makeWork(db, { title: "Severance", mediaType: "show" }); + const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show }); + const ep = await makeWork(db, { title: "Good News About Hell", mediaType: "episode", parentId: season }); + const epEntry = await upsertEntry(db, userId, ep, { status: "completed" }, "user"); + expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]); + + // another user's show entry does not hide it + const bob = await makeUser(db, "bob"); + await upsertEntry(db, bob, show, {}, "user"); + expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]); + + const showEntry = await upsertEntry(db, userId, show, {}, "user"); + expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([showEntry.id]); + // a soft-deleted ancestor entry does not hide it + await softDeleteEntry(db, userId, showEntry.id, "user"); + expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]); + }); + + test("title pagination does not skip non-ASCII titles", async () => { + const db = getDb(); + const userId = await makeUser(db); + const titles = ["Éclair", "Ölund", "Zeta", "alpha", "日本", "éclair 2", "BETA"]; + for (const title of titles) await upsertEntry(db, userId, await makeWork(db, { title }), {}, "user"); + const seen: string[] = []; + let cursor: string | undefined; + for (let i = 0; i < 20; i++) { + const page = await listEntries(db, userId, { sort: "title", limit: 1, cursor }); + seen.push(...page.items.map((x) => x.work.title)); + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + expect(seen).toHaveLength(titles.length); + expect([...seen].sort()).toEqual([...titles].sort()); + }); + + test("concurrent upserts of a new entry yield one entry with both patches", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const [a, b] = await Promise.all([ + upsertEntry(db, userId, workId, { rating: 70 }, "plugin:trakt:c1"), + upsertEntry(db, userId, workId, { tags: ["cosy"] }, "user"), + ]); + expect(a.id).toBe(b.id); + const rows = await db.k.selectFrom("entries").selectAll().where("user_id", "=", userId).execute(); + expect(rows).toHaveLength(1); + const view = await getEntry(db, userId, a.id); + expect(view.rating).toBe(70); + expect(view.tags).toEqual(["cosy"]); + expect(view.fieldSources).toEqual({ rating: "plugin:trakt:c1", tags: "user" }); + }); + + test("listEntries filters, sorts and paginates", async () => { + const db = getDb(); + const userId = await makeUser(db); + const other = await makeUser(db, "bob"); + const titles = ["Alpha", "bravo", "Charlie", "delta", "Echo"]; + const ids: string[] = []; + for (const [i, title] of titles.entries()) { + const w = await makeWork(db, { title, mediaType: i % 2 ? "game" : "book" }); + const e = await upsertEntry( + db, + userId, + w, + { rating: i === 4 ? null : i * 10, tags: i < 2 ? ["Cosy"] : [], status: i === 0 ? "completed" : "planned" }, + "user", + ); + ids.push(e.id); + await upsertEntry(db, other, w, { status: "completed" }, "user"); + } + // updated sort: newest first, keyset pagination + const p1 = await listEntries(db, userId, { limit: 2 }); + expect(p1.items).toHaveLength(2); + expect(p1.nextCursor).not.toBeNull(); + const p2 = await listEntries(db, userId, { limit: 2, cursor: p1.nextCursor! }); + const p3 = await listEntries(db, userId, { limit: 2, cursor: p2.nextCursor! }); + expect(p3.nextCursor).toBeNull(); + expect([...p1.items, ...p2.items, ...p3.items].map((i) => i.id)).toEqual([...ids].reverse()); + + const byTitle = await listEntries(db, userId, { sort: "title", limit: 3 }); + const byTitle2 = await listEntries(db, userId, { sort: "title", limit: 3, cursor: byTitle.nextCursor! }); + expect([...byTitle.items, ...byTitle2.items].map((i) => i.work.title)).toEqual(titles); + + const byRating = await listEntries(db, userId, { sort: "rating", limit: 2 }); + const byRating2 = await listEntries(db, userId, { sort: "rating", limit: 10, cursor: byRating.nextCursor! }); + expect([...byRating.items, ...byRating2.items].map((i) => i.rating)).toEqual([30, 20, 10, 0, null]); + + expect((await listEntries(db, userId, { mediaType: "game" })).items).toHaveLength(2); + expect((await listEntries(db, userId, { status: "completed" })).items.map((i) => i.id)).toEqual([ids[0]!]); + expect((await listEntries(db, userId, { tag: "cosy" })).items).toHaveLength(2); + expect((await listEntries(db, userId, { q: "RAV" })).items.map((i) => i.id)).toEqual([ids[1]!]); + expect((await listEntries(db, userId, { q: "100%" })).items).toHaveLength(0); + await expect(listEntries(db, userId, { cursor: "garbage" })).rejects.toThrow(ValidationError); + await expect(listEntries(db, userId, { sort: "title", cursor: p1.nextCursor! })).rejects.toThrow( + ValidationError, + ); + }); + + test("overrides apply to the work view", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db, { title: "Dune", year: 1965 }); + const e = await upsertEntry(db, userId, workId, { overrides: { title: "Dune (my edition)", year: 1990 } }, "user"); + const view = await getEntry(db, userId, e.id); + expect(view.work.title).toBe("Dune (my edition)"); + expect(view.work.year).toBe(1990); + expect(view.work.coverUrl).toBe(""); + await expect(upsertEntry(db, userId, workId, { overrides: { colour: "red" } as never }, "user")).rejects.toThrow( + ValidationError, + ); + }); + + test("other users' entries and private works are not found", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const draft = await quickAdd(db, ada, { title: "Secret" }); + await expect(getEntry(db, bob, draft.id)).rejects.toThrow(NotFoundError); + await expect(upsertEntry(db, bob, draft.workId, {}, "user")).rejects.toThrow(NotFoundError); + await expect(softDeleteEntry(db, bob, draft.id, "user")).rejects.toThrow(NotFoundError); + await expect(moveEntryToWork(db, bob, draft.id, await makeWork(db), "user")).rejects.toThrow(NotFoundError); + await expect(getEntry(db, ada, newId())).rejects.toThrow(NotFoundError); + }); + + test("softDeleteEntry hides the entry and upsert revives it", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const e = await upsertEntry(db, userId, workId, { rating: 40 }, "user"); + await softDeleteEntry(db, userId, e.id, "user"); + await expect(getEntry(db, userId, e.id)).rejects.toThrow(NotFoundError); + expect((await listEntries(db, userId, {})).items).toHaveLength(0); + const again = await upsertEntry(db, userId, workId, { status: "planned" }, "user"); + expect(again.id).toBe(e.id); + expect(again.deletedAt).toBeNull(); + expect(again.rating).toBe(40); + }); + + test("moveEntryToWork leaves alias", async () => { + const db = getDb(); + const userId = await makeUser(db); + const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" }); + const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "9780441013593" } }); + const moved = await moveEntryToWork(db, userId, draft.id, shared, "user"); + expect(moved.id).toBe(draft.id); + expect(moved.workId).toBe(shared); + + const alias = await db.k + .selectFrom("work_aliases") + .selectAll() + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + expect(alias.from_work_id).toBe(draft.workId); + expect(alias.to_work_id).toBe(shared); + const old = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow(); + expect(old.merged_into_id).toBe(shared); + expect((await getEntry(db, userId, draft.id)).draft).toBe(false); + const [h] = await getHistory(db, userId, "entry", draft.id); + expect(h!.after).toEqual({ workId: shared }); + }); + + test("moveEntryToWork merges into an existing entry on the target", async () => { + const db = getDb(); + const userId = await makeUser(db); + const a = await makeWork(db, { title: "A" }); + const b = await makeWork(db, { title: "B" }); + const src = await upsertEntry(db, userId, a, { rating: 70, tags: ["x"] }, "user"); + const dst = await upsertEntry(db, userId, b, { status: "completed", tags: ["y"] }, "user"); + const merged = await moveEntryToWork(db, userId, src.id, b, "user"); + expect(merged.id).toBe(dst.id); + expect(merged.status).toBe("completed"); + expect(merged.rating).toBe(70); + expect(merged.tags.sort()).toEqual(["x", "y"]); + await expect(getEntry(db, userId, src.id)).rejects.toThrow(NotFoundError); + }); + + test("merge keeps 'user' field_sources on combined fields", async () => { + const db = getDb(); + const userId = await makeUser(db); + const a = await makeWork(db, { title: "A" }); + const b = await makeWork(db, { title: "B" }); + const src = await upsertEntry(db, userId, a, { tags: ["x"], overrides: { year: 1 } }, "plugin:trakt:c1"); + await upsertEntry(db, userId, b, { tags: ["y"], status: "completed" }, "user"); + await upsertEntry(db, userId, b, { overrides: { title: "B!" } }, "plugin:hardcover:c2"); + const merged = await moveEntryToWork(db, userId, src.id, b, "plugin:steam:c3"); + expect(merged.tags.sort()).toEqual(["x", "y"]); + expect(merged.overrides).toEqual({ year: 1, title: "B!" }); + expect(merged.fieldSources).toEqual({ tags: "user", status: "user", overrides: "plugin:hardcover:c2" }); + }); + + test("merge into a soft-deleted entry carries the moved field_sources as-is", async () => { + const db = getDb(); + const userId = await makeUser(db); + const a = await makeWork(db, { title: "A" }); + const b = await makeWork(db, { title: "B" }); + const src = await upsertEntry(db, userId, a, { rating: 60, review: "mine" }, "user"); + const dst = await upsertEntry(db, userId, b, { status: "dropped", tags: ["old"] }, "plugin:trakt:c1"); + await softDeleteEntry(db, userId, dst.id, "user"); + const merged = await moveEntryToWork(db, userId, src.id, b, "plugin:trakt:c2"); + expect(merged.id).toBe(dst.id); + expect(merged.deletedAt).toBeNull(); + expect(merged.rating).toBe(60); + expect(merged.status).toBe("none"); + expect(merged.tags).toEqual([]); + expect(merged.fieldSources).toEqual({ rating: "user", review: "user" }); + }); + + test("merge concatenates differing text, records the dropped rating and moves engagements with history", async () => { + const db = getDb(); + const userId = await makeUser(db); + const a = await makeWork(db, { title: "A" }); + const b = await makeWork(db, { title: "B" }); + const src = await upsertEntry(db, userId, a, { review: "from A", privateNotes: "same", rating: 40 }, "user"); + const dst = await upsertEntry(db, userId, b, { review: "from B", privateNotes: "same", rating: 90 }, "user"); + const eg = await addEngagement(db, userId, src.id, { platform: "Kindle" }, "user"); + const merged = await moveEntryToWork(db, userId, src.id, b, "user"); + expect(merged.review).toBe("from B\n\n---\n\nfrom A"); + expect(merged.privateNotes).toBe("same"); + expect(merged.rating).toBe(90); + const [h] = await getHistory(db, userId, "entry", dst.id); + expect(h!.before.rating).toBe(40); + expect(h!.after.rating).toBe(90); + expect(h!.before.review).toBe("from B"); + + expect((await listEngagements(db, userId, dst.id)).map((e) => e.id)).toEqual([eg.id]); + const [egh] = await getHistory(db, userId, "engagement", eg.id); + expect(egh!.before).toEqual({ entryId: src.id }); + expect(egh!.after).toEqual({ entryId: dst.id }); + }); +}); diff --git a/packages/core/test/history.test.ts b/packages/core/test/history.test.ts new file mode 100644 index 0000000..de30484 --- /dev/null +++ b/packages/core/test/history.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { defineCustomField } from "../src/custom-fields.ts"; +import { NotFoundError } from "../src/errors.ts"; +import { getEntry, moveEntryToWork, quickAdd, softDeleteEntry, upsertEntry } from "../src/entries.ts"; +import { getHistory, recordHistory, restoreVersion } from "../src/history.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("history", (getDb) => { + test("recordHistory stores only the changed keys", async () => { + const db = getDb(); + const userId = await makeUser(db); + const id = await recordHistory(db.k, { + userId, + entity: "entry", + entityId: "e1", + before: { status: "planned", rating: 50, tags: ["a"] }, + after: { status: "completed", rating: 50, tags: ["a"] }, + actor: "user", + }); + expect(id).not.toBeNull(); + const none = await recordHistory(db.k, { + userId, + entity: "entry", + entityId: "e1", + before: { rating: 1 }, + after: { rating: 1 }, + actor: "user", + }); + expect(none).toBeNull(); + const rows = await getHistory(db, userId, "entry", "e1"); + expect(rows).toHaveLength(1); + expect(rows[0]!.before).toEqual({ status: "planned" }); + expect(rows[0]!.after).toEqual({ status: "completed" }); + expect(rows[0]!.actor).toBe("user"); + }); + + test("restoreVersion brings back previous review", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const e = await upsertEntry(db, userId, workId, { review: "first take" }, "user"); + await upsertEntry(db, userId, workId, { review: "second take", rating: 60 }, "plugin:trakt:c1"); + + const history = await getHistory(db, userId, "entry", e.id); + expect(history).toHaveLength(2); + const latest = history[0]!; // newest first + expect(latest.before).toEqual({ review: "first take", rating: null }); + expect(latest.actor).toBe("plugin:trakt:c1"); + + await restoreVersion(db, userId, latest.id); + const view = await getEntry(db, userId, e.id); + expect(view.review).toBe("first take"); + expect(view.rating).toBeNull(); + expect(view.fieldSources.review).toBe("user"); + expect(view.fieldSources.rating).toBe("user"); + expect(await getHistory(db, userId, "entry", e.id)).toHaveLength(3); + }); + + test("restoreVersion undoes a soft delete", async () => { + const db = getDb(); + const userId = await makeUser(db); + const e = await upsertEntry(db, userId, await makeWork(db), { status: "planned" }, "user"); + await softDeleteEntry(db, userId, e.id, "user"); + const [del] = await getHistory(db, userId, "entry", e.id); + expect(del!.after.deletedAt).toBeString(); + await restoreVersion(db, userId, del!.id); + expect((await getEntry(db, userId, e.id)).status).toBe("planned"); + }); + + test("restoreVersion undoes a move back to the draft work", async () => { + const db = getDb(); + const userId = await makeUser(db); + const draft = await quickAdd(db, userId, { title: "Dune??" }); + const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } }); + await moveEntryToWork(db, userId, draft.id, shared, "user"); + const [move] = await getHistory(db, userId, "entry", draft.id); + await restoreVersion(db, userId, move!.id); + const back = await getEntry(db, userId, draft.id); + expect(back.workId).toBe(draft.workId); + expect(back.draft).toBe(true); + const work = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow(); + expect(work.merged_into_id).toBeNull(); + // undoing removes the forward alias and writes no reverse alias + expect(await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).execute()).toEqual([]); + // a later sync onto the shared work lands on the shared work, not on the draft + const synced = await upsertEntry(db, userId, shared, { rating: 80 }, "plugin:hardcover:c1"); + expect(synced.workId).toBe(shared); + expect(synced.id).not.toBe(draft.id); + expect((await getEntry(db, userId, draft.id)).rating).toBeNull(); + }); + + test("restoreVersion does not re-check custom fields against today's definitions", async () => { + const db = getDb(); + const userId = await makeUser(db); + const def = await defineCustomField(db, userId, { name: "Mood", type: "text" }); + const e = await upsertEntry(db, userId, await makeWork(db), { customFields: { [def.id]: "cosy" } }, "user"); + await upsertEntry(db, userId, e.workId, { customFields: {} }, "user"); + await db.k.deleteFrom("custom_field_definitions").where("id", "=", def.id).execute(); + const [h] = await getHistory(db, userId, "entry", e.id); + await restoreVersion(db, userId, h!.id); + expect((await getEntry(db, userId, e.id)).customFields).toEqual({ [def.id]: "cosy" }); + }); + + test("another user's history is invisible", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const e = await upsertEntry(db, ada, await makeWork(db), { review: "x" }, "user"); + expect(await getHistory(db, bob, "entry", e.id)).toEqual([]); + const [h] = await getHistory(db, ada, "entry", e.id); + await expect(restoreVersion(db, bob, h!.id)).rejects.toThrow(NotFoundError); + }); +}); diff --git a/packages/core/test/lists.test.ts b/packages/core/test/lists.test.ts new file mode 100644 index 0000000..5cba41f --- /dev/null +++ b/packages/core/test/lists.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import { getHistory, restoreVersion } from "../src/history.ts"; +import { + addListItem, + createList, + getList, + listLists, + moveListItem, + removeListItem, + softDeleteList, + updateList, +} from "../src/lists.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("lists", (getDb) => { + test("grouping list can be rated", async () => { + const db = getDb(); + const userId = await makeUser(db); + const g = await createList(db, userId, { name: "Silent Hill, everything", kind: "grouping", rating: 90, review: "Dread." }); + expect(g.kind).toBe("grouping"); + expect(g.rating).toBe(90); + expect(g.review).toBe("Dread."); + expect(g.visibility).toBe("private"); + const up = await updateList(db, userId, g.id, { rating: 95 }); + expect(up.rating).toBe(95); + + await expect(createList(db, userId, { name: "Plain", kind: "list", rating: 50 })).rejects.toThrow(ValidationError); + await expect(createList(db, userId, { name: "Bad", kind: "grouping", rating: 101 })).rejects.toThrow( + ValidationError, + ); + await expect(updateList(db, userId, g.id, { kind: "thread" })).rejects.toThrow(ValidationError); + await expect(createList(db, userId, { name: " " })).rejects.toThrow(ValidationError); + await expect(createList(db, userId, { name: "x", kind: "box" as never })).rejects.toThrow(ValidationError); + }); + + test("items keep order through add, move and remove", async () => { + const db = getDb(); + const userId = await makeUser(db); + const l = await createList(db, userId, { name: "Cosmic dread", kind: "thread" }); + const [a, b, c] = [await makeWork(db, { title: "A" }), await makeWork(db, { title: "B" }), await makeWork(db, { title: "C" })]; + const ia = await addListItem(db, userId, l.id, { workId: a, note: "start here" }); + const ib = await addListItem(db, userId, l.id, { workId: b }); + const ic = await addListItem(db, userId, l.id, { workId: c }); + const titles = async () => (await getList(db, userId, l.id)).items.map((i) => i.work.title); + expect(await titles()).toEqual(["A", "B", "C"]); + expect((await getList(db, userId, l.id)).items[0]!.note).toBe("start here"); + + await moveListItem(db, userId, ic.id, 0); + expect(await titles()).toEqual(["C", "A", "B"]); + await moveListItem(db, userId, ic.id, 99); + expect(await titles()).toEqual(["A", "B", "C"]); + await removeListItem(db, userId, ib.id); + expect(await titles()).toEqual(["A", "C"]); + expect((await getList(db, userId, l.id)).items.map((i) => i.position)).toEqual([0, 1]); + + const inserted = await addListItem(db, userId, l.id, { workId: b, position: 1 }); + expect(await titles()).toEqual(["A", "B", "C"]); + expect(inserted.position).toBe(1); + expect(ia.position).toBe(0); + }); + + test("removing an item is recorded and restorable", async () => { + const db = getDb(); + const userId = await makeUser(db); + const l = await createList(db, userId, { name: "Reading order" }); + await addListItem(db, userId, l.id, { workId: await makeWork(db, { title: "A" }) }); + const ib = await addListItem(db, userId, l.id, { workId: await makeWork(db, { title: "B" }) }); + await removeListItem(db, userId, ib.id); + const [h] = await getHistory(db, userId, "list", l.id); + await restoreVersion(db, userId, h!.id); + expect((await getList(db, userId, l.id)).items.map((i) => i.work.title)).toEqual(["A", "B"]); + }); + + test("listLists and soft delete", async () => { + const db = getDb(); + const userId = await makeUser(db); + const l1 = await createList(db, userId, { name: "One" }); + const l2 = await createList(db, userId, { name: "Two", kind: "grouping" }); + expect((await listLists(db, userId)).map((l) => l.id).sort()).toEqual([l1.id, l2.id].sort()); + expect((await listLists(db, userId, { kind: "grouping" })).map((l) => l.id)).toEqual([l2.id]); + await softDeleteList(db, userId, l1.id); + expect((await listLists(db, userId)).map((l) => l.id)).toEqual([l2.id]); + await expect(getList(db, userId, l1.id)).rejects.toThrow(NotFoundError); + }); + + test("another user's lists and private works are not found", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const l = await createList(db, ada, { name: "Mine" }); + const item = await addListItem(db, ada, l.id, { workId: await makeWork(db) }); + await expect(getList(db, bob, l.id)).rejects.toThrow(NotFoundError); + await expect(updateList(db, bob, l.id, { name: "x" })).rejects.toThrow(NotFoundError); + await expect(addListItem(db, bob, l.id, { workId: await makeWork(db) })).rejects.toThrow(NotFoundError); + await expect(moveListItem(db, bob, item.id, 0)).rejects.toThrow(NotFoundError); + await expect(removeListItem(db, bob, item.id)).rejects.toThrow(NotFoundError); + expect(await listLists(db, bob)).toEqual([]); + const adaPrivate = await makeWork(db, { ownerUserId: ada }); + const bobList = await createList(db, bob, { name: "Bob's" }); + await expect(addListItem(db, bob, bobList.id, { workId: adaPrivate })).rejects.toThrow(NotFoundError); + }); +}); diff --git a/packages/core/test/suggestions.test.ts b/packages/core/test/suggestions.test.ts new file mode 100644 index 0000000..aadb31a --- /dev/null +++ b/packages/core/test/suggestions.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import type { Db } from "@sttracker/db/db"; +import { newId, nowIso } from "@sttracker/db/ids"; +import { forEachDialect } from "@sttracker/db/testing"; +import { acceptSuggestion, getEntry, quickAdd, rejectSuggestion } from "../src/entries.ts"; +import { createCopy, listCopies } from "../src/copies.ts"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import { getHistory } from "../src/history.ts"; +import { addListItem, createList, getList } from "../src/lists.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +async function suggest(db: Db, userId: string, workId: string, candidateWorkId: string): Promise { + const id = newId(); + await db.k + .insertInto("match_suggestions") + .values({ id, user_id: userId, work_id: workId, candidate_work_id: candidateWorkId, score: 0.9, status: "pending", created_at: nowIso() }) + .execute(); + return id; +} + +async function statusOf(db: Db, id: string): Promise { + return (await db.k.selectFrom("match_suggestions").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status; +} + +forEachDialect("suggestions", (getDb) => { + test("acceptSuggestion moves the entry and rejects the other pending suggestions", async () => { + const db = getDb(); + const userId = await makeUser(db); + const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" }); + const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } }); + const other = await makeWork(db, { title: "Dune Messiah" }); + const s1 = await suggest(db, userId, draft.workId, good); + const s2 = await suggest(db, userId, draft.workId, other); + await acceptSuggestion(db, userId, s1); + expect(await statusOf(db, s1)).toBe("accepted"); + expect(await statusOf(db, s2)).toBe("rejected"); + const view = await getEntry(db, userId, draft.id); + expect(view.workId).toBe(good); + expect(view.draft).toBe(false); + }); + + test("rejectSuggestion; other users get NotFoundError", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const s = await suggest(db, ada, await makeWork(db), await makeWork(db)); + await expect(acceptSuggestion(db, bob, s)).rejects.toThrow(NotFoundError); + await expect(rejectSuggestion(db, bob, s)).rejects.toThrow(NotFoundError); + expect(await statusOf(db, s)).toBe("pending"); + await rejectSuggestion(db, ada, s); + expect(await statusOf(db, s)).toBe("rejected"); + await expect(rejectSuggestion(db, ada, s)).rejects.toThrow(ValidationError); + await expect(acceptSuggestion(db, ada, s)).rejects.toThrow(ValidationError); + }); + + test("accepting moves this user's copies and list items to the target work", async () => { + const db = getDb(); + const userId = await makeUser(db); + const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" }); + const target = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } }); + const copy = await createCopy(db, userId, { workId: draft.workId, format: "paperback" }); + const list = await createList(db, userId, { name: "Desert" }); + await addListItem(db, userId, list.id, { workId: draft.workId }); + await acceptSuggestion(db, userId, await suggest(db, userId, draft.workId, target)); + expect((await listCopies(db, userId, { workId: target })).map((c) => c.id)).toEqual([copy.id]); + const [ch] = await getHistory(db, userId, "copy", copy.id); + expect(ch!.after).toEqual({ workId: target }); + expect((await getList(db, userId, list.id)).items.map((i) => i.work.title)).toEqual(["Dune"]); + }); +}); diff --git a/packages/core/test/user-layer-fixtures.ts b/packages/core/test/user-layer-fixtures.ts new file mode 100644 index 0000000..90c86d0 --- /dev/null +++ b/packages/core/test/user-layer-fixtures.ts @@ -0,0 +1,65 @@ +import type { Db } from "@sttracker/db/db"; +import { newId, nowIso } from "@sttracker/db/ids"; +import { toJson } from "@sttracker/db/json"; + +/** Insert a user and return its id. */ +export async function makeUser(db: Db, username = "ada", preferences: Record = {}): Promise { + const id = newId(); + const now = nowIso(); + await db.k + .insertInto("users") + .values({ + id, + username, + display_name: username, + password_hash: "x", + role: "member", + preferences: toJson(preferences), + created_at: now, + updated_at: now, + }) + .execute(); + return id; +} + +/** Insert a work (shared unless `ownerUserId` is set) and return its id. */ +export async function makeWork( + db: Db, + opts: { + title?: string; + mediaType?: string; + year?: number | null; + parentId?: string | null; + ownerUserId?: string | null; + externalIds?: Record; + } = {}, +): Promise { + const id = newId(); + const now = nowIso(); + await db.k + .insertInto("works") + .values({ + id, + media_type: opts.mediaType ?? "book", + title: opts.title ?? "Dune", + original_title: "", + year: opts.year ?? null, + release_date: null, + creators: toJson([]), + description: "", + cover_url: "", + cover_attachment_id: null, + metadata: toJson({}), + parent_id: opts.parentId ?? null, + position: null, + owner_user_id: opts.ownerUserId ?? null, + merged_into_id: null, + created_at: now, + updated_at: now, + }) + .execute(); + for (const [namespace, value] of Object.entries(opts.externalIds ?? {})) { + await db.k.insertInto("external_ids").values({ id: newId(), work_id: id, namespace, value }).execute(); + } + return id; +}