merge: task 6 user layer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -16,7 +16,7 @@ user layer or files a Changes-inbox event.
|
||||
|---|---|---|
|
||||
| `apps/server` | `Bun.serve`: `/api/v1`, `/ingest/<plugin>`, 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/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { fromJson, toJson } from "@sttracker/db/json";
|
||||
import type { Database } from "@sttracker/db/schema";
|
||||
import type { PartialDate } from "@sttracker/plugin-sdk/types";
|
||||
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
|
||||
import { checkKeys, checkText, fromStoredDate, resolveVisibleWork, toStoredDate } from "./entries.ts";
|
||||
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
|
||||
|
||||
/** An owned copy (spec §6.7). Always private. `workId` is null until matched; then `titleHint` names it. */
|
||||
export interface CopyPatch {
|
||||
workId: string | null;
|
||||
titleHint: string | null;
|
||||
format: string | null;
|
||||
acquiredFrom: string | null;
|
||||
acquiredAt: PartialDate | null;
|
||||
/** Integer minor units (cents). */
|
||||
priceCents: number | null;
|
||||
/** ISO 4217 code, e.g. "GBP". */
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
customFields: Record<string, unknown>;
|
||||
sourceRef: string | null;
|
||||
}
|
||||
const COPY_KEYS = [
|
||||
"workId",
|
||||
"titleHint",
|
||||
"format",
|
||||
"acquiredFrom",
|
||||
"acquiredAt",
|
||||
"priceCents",
|
||||
"currency",
|
||||
"notes",
|
||||
"customFields",
|
||||
"sourceRef",
|
||||
] as const satisfies readonly (keyof CopyPatch)[];
|
||||
|
||||
export interface Copy extends CopyPatch {
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
type Row = Database["copies"];
|
||||
|
||||
function toCopy(r: Row): Copy {
|
||||
return {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
workId: r.work_id,
|
||||
titleHint: r.title_hint,
|
||||
format: r.format,
|
||||
acquiredFrom: r.acquired_from,
|
||||
acquiredAt: fromStoredDate(r.acquired_at, r.acquired_precision),
|
||||
priceCents: r.price_cents,
|
||||
currency: r.currency,
|
||||
notes: r.notes,
|
||||
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
|
||||
sourceRef: r.source_ref,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
deletedAt: r.deleted_at,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(c: Copy): Record<string, unknown> {
|
||||
const s: Record<string, unknown> = { deletedAt: c.deletedAt };
|
||||
for (const k of COPY_KEYS) s[k] = c[k];
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Validate a patch into columns. `workId` must be visible to the user; custom fields use copy-level definitions. */
|
||||
async function toColumns(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
patch: Partial<CopyPatch>,
|
||||
currentWorkId: string | null,
|
||||
): Promise<Partial<Row>> {
|
||||
checkKeys("patch", patch, COPY_KEYS);
|
||||
const c: Partial<Row> = {};
|
||||
let workId = currentWorkId;
|
||||
if ("workId" in patch) {
|
||||
workId = patch.workId === null ? null : (await resolveVisibleWork(k, userId, String(patch.workId))).id;
|
||||
c.work_id = workId;
|
||||
}
|
||||
if ("titleHint" in patch) c.title_hint = checkText("titleHint", patch.titleHint);
|
||||
if ("format" in patch) c.format = checkText("format", patch.format);
|
||||
if ("acquiredFrom" in patch) c.acquired_from = checkText("acquiredFrom", patch.acquiredFrom);
|
||||
if ("acquiredAt" in patch) {
|
||||
const d = toStoredDate("acquiredAt", patch.acquiredAt);
|
||||
c.acquired_at = d.value;
|
||||
c.acquired_precision = d.precision;
|
||||
}
|
||||
if ("priceCents" in patch) {
|
||||
const p = patch.priceCents;
|
||||
if (p !== null && (typeof p !== "number" || !Number.isInteger(p) || p < 0)) {
|
||||
throw new ValidationError("priceCents", "not_cents");
|
||||
}
|
||||
c.price_cents = p;
|
||||
}
|
||||
if ("currency" in patch) {
|
||||
const cur = checkText("currency", patch.currency);
|
||||
if (cur !== null && !/^[A-Z]{3}$/.test(cur)) throw new ValidationError("currency", "invalid");
|
||||
c.currency = cur;
|
||||
}
|
||||
if ("notes" in patch) c.notes = checkText("notes", patch.notes);
|
||||
if ("sourceRef" in patch) c.source_ref = checkText("sourceRef", patch.sourceRef);
|
||||
if ("customFields" in patch) {
|
||||
let mediaType: string | null = null;
|
||||
if (workId) {
|
||||
const w = await k.selectFrom("works").select("media_type").where("id", "=", workId).executeTakeFirst();
|
||||
mediaType = w?.media_type ?? null;
|
||||
}
|
||||
const defs = await customFieldsFor(k, userId, { mediaType, level: "copy" });
|
||||
validateCustomValues(defs, patch.customFields as Record<string, unknown>);
|
||||
c.custom_fields = toJson(patch.customFields);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
async function load(k: Executor, userId: string, copyId: string, includeDeleted = false): Promise<Copy> {
|
||||
let q = k.selectFrom("copies").selectAll().where("id", "=", copyId).where("user_id", "=", userId);
|
||||
if (!includeDeleted) q = q.where("deleted_at", "is", null);
|
||||
const r = await q.executeTakeFirst();
|
||||
if (!r) throw new NotFoundError("copy");
|
||||
return toCopy(r);
|
||||
}
|
||||
|
||||
async function write(
|
||||
k: Executor,
|
||||
current: Copy,
|
||||
patch: Partial<CopyPatch>,
|
||||
actor: Actor,
|
||||
extra: Partial<Pick<Row, "deleted_at">> = {},
|
||||
): Promise<Copy> {
|
||||
const cols = await toColumns(k, current.userId, patch, current.workId);
|
||||
const next = { ...current, ...patchFromCols(current, cols) };
|
||||
if (next.workId === null && !next.titleHint?.trim()) throw new ValidationError("titleHint", "required");
|
||||
await k
|
||||
.updateTable("copies")
|
||||
.set({ ...cols, ...extra, updated_at: nowIso() })
|
||||
.where("id", "=", current.id)
|
||||
.execute();
|
||||
const after = await load(k, current.userId, current.id, true);
|
||||
await recordHistory(k, {
|
||||
userId: current.userId,
|
||||
entity: "copy",
|
||||
entityId: current.id,
|
||||
before: snapshot(current),
|
||||
after: snapshot(after),
|
||||
actor,
|
||||
});
|
||||
return after;
|
||||
}
|
||||
|
||||
function patchFromCols(current: Copy, cols: Partial<Row>): Pick<Copy, "workId" | "titleHint"> {
|
||||
return {
|
||||
workId: "work_id" in cols ? (cols.work_id ?? null) : current.workId,
|
||||
titleHint: "title_hint" in cols ? (cols.title_hint ?? null) : current.titleHint,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create an owned copy. Needs a `workId` or a `titleHint`. Writes history. */
|
||||
export async function createCopy(
|
||||
db: Db,
|
||||
userId: string,
|
||||
input: Partial<CopyPatch>,
|
||||
actor: Actor = "user",
|
||||
tx?: Executor,
|
||||
): Promise<Copy> {
|
||||
return inTx(db, tx, async (k) => {
|
||||
const cols = await toColumns(k, userId, input, null);
|
||||
if (!cols.work_id && !cols.title_hint?.trim()) throw new ValidationError("titleHint", "required");
|
||||
const now = nowIso();
|
||||
const row: Row = {
|
||||
id: newId(),
|
||||
user_id: userId,
|
||||
work_id: null,
|
||||
title_hint: null,
|
||||
format: null,
|
||||
acquired_from: null,
|
||||
acquired_at: null,
|
||||
acquired_precision: "unknown",
|
||||
price_cents: null,
|
||||
currency: null,
|
||||
notes: null,
|
||||
custom_fields: toJson({}),
|
||||
source_ref: null,
|
||||
...cols,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
deleted_at: null,
|
||||
};
|
||||
await k.insertInto("copies").values(row).execute();
|
||||
const copy = toCopy(row);
|
||||
await recordHistory(k, { userId, entity: "copy", entityId: copy.id, before: null, after: snapshot(copy), actor });
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
/** Patch a live copy; only the keys present are touched. Writes history. */
|
||||
export async function updateCopy(
|
||||
db: Db,
|
||||
userId: string,
|
||||
copyId: string,
|
||||
patch: Partial<CopyPatch>,
|
||||
actor: Actor = "user",
|
||||
tx?: Executor,
|
||||
): Promise<Copy> {
|
||||
return inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), patch, actor));
|
||||
}
|
||||
|
||||
/** Soft-delete a copy. Writes history. */
|
||||
export async function softDeleteCopy(
|
||||
db: Db,
|
||||
userId: string,
|
||||
copyId: string,
|
||||
actor: Actor = "user",
|
||||
tx?: Executor,
|
||||
): Promise<void> {
|
||||
await inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), {}, actor, { deleted_at: nowIso() }));
|
||||
}
|
||||
|
||||
/** The user's live copies, optionally of one work, newest first. */
|
||||
export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise<Copy[]> {
|
||||
let q = db.k.selectFrom("copies").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null);
|
||||
if (filter.workId) q = q.where("work_id", "=", filter.workId);
|
||||
const rows = await q.orderBy("created_at", "desc").orderBy("id", "desc").execute();
|
||||
return rows.map(toCopy);
|
||||
}
|
||||
|
||||
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
|
||||
export async function restoreCopyFromHistory(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
copyId: string,
|
||||
before: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const current = await load(k, userId, copyId, true);
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const key of COPY_KEYS) if (key in before) patch[key] = before[key];
|
||||
const extra = "deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {};
|
||||
await write(k, current, patch as Partial<CopyPatch>, "user", extra);
|
||||
}
|
||||
@@ -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<string[]>(r.options, []),
|
||||
appliesTo: fromJson<string[] | "all">(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<CustomFieldDef> {
|
||||
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<CustomFieldDef[]> {
|
||||
return customFieldsFor(db.k, userId, { mediaType, level });
|
||||
}
|
||||
|
||||
/** Same as `listCustomFields`, usable with a transaction handle. */
|
||||
export async function customFieldsFor(
|
||||
k: Kysely<Database>,
|
||||
userId: string,
|
||||
opts: { mediaType?: string | null; level?: CustomFieldLevel },
|
||||
): Promise<CustomFieldDef[]> {
|
||||
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.<name or key>", reason)`.
|
||||
*/
|
||||
export function validateCustomValues(defs: CustomFieldDef[], values: Record<string, unknown>): 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, number | string>;
|
||||
platform: string | null;
|
||||
rating: number | null;
|
||||
notes: string | null;
|
||||
customFields: Record<string, unknown>;
|
||||
}
|
||||
export const ENGAGEMENT_PATCH_KEYS = [
|
||||
"startedAt",
|
||||
"finishedAt",
|
||||
"progress",
|
||||
"platform",
|
||||
"rating",
|
||||
"notes",
|
||||
"customFields",
|
||||
] as const satisfies readonly (keyof EngagementPatch)[];
|
||||
|
||||
export interface Engagement extends EngagementPatch {
|
||||
id: string;
|
||||
entryId: string;
|
||||
fieldSources: FieldSources;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
type Row = Database["engagements"];
|
||||
|
||||
function toEngagement(r: Row): Engagement {
|
||||
return {
|
||||
id: r.id,
|
||||
entryId: r.entry_id,
|
||||
startedAt: fromStoredDate(r.started_at, r.started_precision),
|
||||
finishedAt: fromStoredDate(r.finished_at, r.finished_precision),
|
||||
progress: fromJson<Record<string, number | string>>(r.progress, {}),
|
||||
platform: r.platform,
|
||||
rating: r.rating,
|
||||
notes: r.notes,
|
||||
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
|
||||
fieldSources: fromJson<FieldSources>(r.field_sources, {}),
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
deletedAt: r.deleted_at,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(e: Engagement): Record<string, unknown> {
|
||||
const s: Record<string, unknown> = { 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<EngagementPatch>,
|
||||
): Promise<Partial<Row>> {
|
||||
checkKeys("patch", patch, ENGAGEMENT_PATCH_KEYS);
|
||||
const c: Partial<Row> = {};
|
||||
if ("startedAt" in patch) {
|
||||
const d = toStoredDate("startedAt", patch.startedAt);
|
||||
c.started_at = d.value;
|
||||
c.started_precision = d.precision;
|
||||
}
|
||||
if ("finishedAt" in patch) {
|
||||
const d = toStoredDate("finishedAt", patch.finishedAt);
|
||||
c.finished_at = d.value;
|
||||
c.finished_precision = d.precision;
|
||||
}
|
||||
if ("progress" in patch) {
|
||||
const p = patch.progress;
|
||||
if (p === null || typeof p !== "object" || Array.isArray(p)) throw new ValidationError("progress", "not_object");
|
||||
for (const [key, v] of Object.entries(p)) {
|
||||
if (typeof v !== "string" && (typeof v !== "number" || !Number.isFinite(v))) {
|
||||
throw new ValidationError(`progress.${key}`, "invalid");
|
||||
}
|
||||
}
|
||||
c.progress = toJson(p);
|
||||
}
|
||||
if ("platform" in patch) c.platform = checkText("platform", patch.platform);
|
||||
if ("rating" in patch) c.rating = checkRating("rating", patch.rating);
|
||||
if ("notes" in patch) c.notes = checkText("notes", patch.notes);
|
||||
if ("customFields" in patch) {
|
||||
const defs = await customFieldsFor(k, userId, { mediaType, level: "engagement" });
|
||||
validateCustomValues(defs, patch.customFields as Record<string, unknown>);
|
||||
c.custom_fields = toJson(patch.customFields);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Load an engagement whose entry belongs to the user. */
|
||||
async function load(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
engagementId: string,
|
||||
opts: { includeDeleted?: boolean } = {},
|
||||
): Promise<{ eg: Engagement; mediaType: string }> {
|
||||
let q = k
|
||||
.selectFrom("engagements as g")
|
||||
.innerJoin("entries as e", "e.id", "g.entry_id")
|
||||
.innerJoin("works as w", "w.id", "e.work_id")
|
||||
.selectAll("g")
|
||||
.select(["e.media_type_override", "w.media_type"])
|
||||
.where("g.id", "=", engagementId)
|
||||
.where("e.user_id", "=", userId);
|
||||
if (!opts.includeDeleted) q = q.where("g.deleted_at", "is", null).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<EngagementPatch>,
|
||||
actor: Actor,
|
||||
extra: Partial<Pick<Row, "deleted_at" | "entry_id">> = {},
|
||||
): Promise<Engagement> {
|
||||
const cols = await toColumns(k, userId, mediaType, patch);
|
||||
const fieldSources = { ...current.fieldSources };
|
||||
for (const key of Object.keys(patch)) fieldSources[key] = actor;
|
||||
await k
|
||||
.updateTable("engagements")
|
||||
.set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() })
|
||||
.where("id", "=", current.id)
|
||||
.execute();
|
||||
const { eg } = await load(k, userId, current.id, { includeDeleted: true });
|
||||
await recordHistory(k, {
|
||||
userId,
|
||||
entity: "engagement",
|
||||
entityId: current.id,
|
||||
before: snapshot(current),
|
||||
after: snapshot(eg),
|
||||
actor,
|
||||
});
|
||||
return eg;
|
||||
}
|
||||
|
||||
/** Add an engagement to the user's live entry. Each patched key gets `field_sources[key] = actor`. */
|
||||
export async function addEngagement(
|
||||
db: Db,
|
||||
userId: string,
|
||||
entryId: string,
|
||||
patch: Partial<EngagementPatch>,
|
||||
actor: Actor,
|
||||
tx?: Executor,
|
||||
): Promise<Engagement> {
|
||||
return inTx(db, tx, async (k) => {
|
||||
const { mediaType } = await requireEntry(k, userId, entryId);
|
||||
const cols = await toColumns(k, userId, mediaType, patch);
|
||||
const fieldSources: FieldSources = {};
|
||||
for (const key of Object.keys(patch)) fieldSources[key] = actor;
|
||||
const now = nowIso();
|
||||
const row: Row = {
|
||||
id: newId(),
|
||||
entry_id: entryId,
|
||||
started_at: null,
|
||||
started_precision: "unknown",
|
||||
finished_at: null,
|
||||
finished_precision: "unknown",
|
||||
progress: toJson({}),
|
||||
platform: null,
|
||||
rating: null,
|
||||
notes: null,
|
||||
custom_fields: toJson({}),
|
||||
...cols,
|
||||
field_sources: toJson(fieldSources),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
deleted_at: null,
|
||||
};
|
||||
await k.insertInto("engagements").values(row).execute();
|
||||
const eg = toEngagement(row);
|
||||
await recordHistory(k, { userId, entity: "engagement", entityId: eg.id, before: null, after: snapshot(eg), actor });
|
||||
return eg;
|
||||
});
|
||||
}
|
||||
|
||||
/** Patch a live engagement; only the keys present are touched. Writes history. */
|
||||
export async function updateEngagement(
|
||||
db: Db,
|
||||
userId: string,
|
||||
engagementId: string,
|
||||
patch: Partial<EngagementPatch>,
|
||||
actor: Actor,
|
||||
tx?: Executor,
|
||||
): Promise<Engagement> {
|
||||
return inTx(db, tx, async (k) => {
|
||||
const { eg, mediaType } = await load(k, userId, engagementId);
|
||||
if (Object.keys(patch).length === 0) return eg;
|
||||
return write(k, userId, eg, mediaType, patch, actor);
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-delete an engagement. Writes history. */
|
||||
export async function softDeleteEngagement(
|
||||
db: Db,
|
||||
userId: string,
|
||||
engagementId: string,
|
||||
actor: Actor,
|
||||
tx?: Executor,
|
||||
): Promise<void> {
|
||||
await inTx(db, tx, async (k) => {
|
||||
const { eg, mediaType } = await load(k, userId, engagementId);
|
||||
await write(k, userId, eg, mediaType, {}, actor, { deleted_at: nowIso() });
|
||||
});
|
||||
}
|
||||
|
||||
/** The live engagements of the user's live entry, by start date (undated last), then creation. */
|
||||
export async function listEngagements(db: Db, userId: string, entryId: string): Promise<Engagement[]> {
|
||||
await requireEntry(db.k, userId, entryId);
|
||||
const rows = await db.k
|
||||
.selectFrom("engagements")
|
||||
.selectAll()
|
||||
.where("entry_id", "=", entryId)
|
||||
.where("deleted_at", "is", null)
|
||||
.orderBy(sql`case when started_at is null then 1 else 0 end`)
|
||||
.orderBy("started_at")
|
||||
.orderBy("created_at")
|
||||
.orderBy("id")
|
||||
.execute();
|
||||
return rows.map(toEngagement);
|
||||
}
|
||||
|
||||
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
|
||||
export async function restoreEngagementFromHistory(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
engagementId: string,
|
||||
before: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const { eg, mediaType } = await load(k, userId, engagementId, { includeDeleted: true });
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const key of ENGAGEMENT_PATCH_KEYS) if (key in before) patch[key] = before[key];
|
||||
const extra: Partial<Pick<Row, "deleted_at" | "entry_id">> =
|
||||
"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<EngagementPatch>, "user", extra);
|
||||
}
|
||||
@@ -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<string, Actor>;
|
||||
|
||||
/** Throw unless `v` is null or an integer 0–100. */
|
||||
export function checkRating(field: string, v: unknown): number | null {
|
||||
if (v === null) return null;
|
||||
if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 100) {
|
||||
throw new ValidationError(field, "out_of_range");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Throw unless `v` is null or a string. */
|
||||
export function checkText(field: string, v: unknown): string | null {
|
||||
if (v === null) return null;
|
||||
if (typeof v !== "string") throw new ValidationError(field, "not_text");
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Throw on any key not in `allowed`. */
|
||||
export function checkKeys(field: string, obj: object, allowed: readonly string[]): void {
|
||||
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) throw new ValidationError(field, "not_object");
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (!allowed.includes(k)) throw new ValidationError(k, "unknown_field");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a partial date and convert it to its stored pair. `null` (or precision "unknown")
|
||||
* stores `{ value: null, precision: "unknown" }`. Never goes through `Date`.
|
||||
*/
|
||||
export function toStoredDate(field: string, d: unknown): { value: string | null; precision: string } {
|
||||
if (d === null) return { value: null, precision: "unknown" };
|
||||
if (typeof d !== "object" || Array.isArray(d)) throw new ValidationError(field, "not_date");
|
||||
const { value, precision } = d as PartialDate;
|
||||
if (!PRECISIONS.includes(precision)) throw new ValidationError(field, "bad_precision");
|
||||
if (precision === "unknown") {
|
||||
if (value !== "" && value != null) throw new ValidationError(field, "bad_precision");
|
||||
return { value: null, precision: "unknown" };
|
||||
}
|
||||
const parsed = typeof value === "string" ? parsePartialDate(value) : null;
|
||||
if (!parsed || parsed.value !== value || parsed.precision !== precision) {
|
||||
throw new ValidationError(field, "not_date");
|
||||
}
|
||||
return { value, precision };
|
||||
}
|
||||
|
||||
/** Read a stored date pair back; a null value is a null date. */
|
||||
export function fromStoredDate(value: string | null, precision: string): PartialDate | null {
|
||||
return value === null ? null : { value, precision: precision as PartialDate["precision"] };
|
||||
}
|
||||
|
||||
/** The user's default visibility preference (`preferences.defaultVisibility`), else "private". */
|
||||
export async function defaultVisibility(k: Executor, userId: string): Promise<Visibility> {
|
||||
const u = await k.selectFrom("users").select("preferences").where("id", "=", userId).executeTakeFirst();
|
||||
const v = fromJson<{ defaultVisibility?: unknown }>(u?.preferences, {}).defaultVisibility;
|
||||
return VISIBILITIES.includes(v as Visibility) ? (v as Visibility) : "private";
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the user's `work_aliases` and global `merged_into_id` tombstones from `workId`, then check
|
||||
* the result is visible to the user (shared, or their own private work). Throws `NotFoundError`.
|
||||
*/
|
||||
export async function resolveVisibleWork(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
workId: string,
|
||||
): Promise<{ id: string; media_type: string; owner_user_id: string | null }> {
|
||||
let id = workId;
|
||||
for (let hop = 0; hop < 16; hop++) {
|
||||
const alias = await k
|
||||
.selectFrom("work_aliases")
|
||||
.select("to_work_id")
|
||||
.where("user_id", "=", userId)
|
||||
.where("from_work_id", "=", id)
|
||||
.executeTakeFirst();
|
||||
if (alias) {
|
||||
id = alias.to_work_id;
|
||||
continue;
|
||||
}
|
||||
const w = await k
|
||||
.selectFrom("works")
|
||||
.select(["id", "media_type", "owner_user_id", "merged_into_id"])
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
if (!w || (w.owner_user_id !== null && w.owner_user_id !== userId)) throw new NotFoundError("work");
|
||||
if (w.merged_into_id) {
|
||||
id = w.merged_into_id;
|
||||
continue;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
throw new NotFoundError("work");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Entries
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export interface EntryOverrides {
|
||||
title?: string;
|
||||
coverUrl?: string;
|
||||
year?: number | null;
|
||||
}
|
||||
const OVERRIDE_KEYS = ["title", "coverUrl", "year"] as const;
|
||||
|
||||
export interface EntryPatch {
|
||||
status: EntryStatus;
|
||||
rating: number | null;
|
||||
review: string | null;
|
||||
privateNotes: string | null;
|
||||
favorite: boolean;
|
||||
tags: string[];
|
||||
customFields: Record<string, unknown>;
|
||||
visibility: Visibility;
|
||||
overrides: EntryOverrides;
|
||||
mediaTypeOverride: string | null;
|
||||
}
|
||||
export const ENTRY_PATCH_KEYS = [
|
||||
"status",
|
||||
"rating",
|
||||
"review",
|
||||
"privateNotes",
|
||||
"favorite",
|
||||
"tags",
|
||||
"customFields",
|
||||
"visibility",
|
||||
"overrides",
|
||||
"mediaTypeOverride",
|
||||
] as const satisfies readonly (keyof EntryPatch)[];
|
||||
|
||||
export interface Entry extends EntryPatch {
|
||||
id: string;
|
||||
userId: string;
|
||||
workId: string;
|
||||
fieldSources: FieldSources;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface EntryView extends Entry {
|
||||
/** The work as this user sees it (title, cover and year overlaid with `overrides`). */
|
||||
work: {
|
||||
id: string;
|
||||
mediaType: string;
|
||||
title: string;
|
||||
originalTitle: string;
|
||||
year: number | null;
|
||||
coverUrl: string;
|
||||
parentId: string | null;
|
||||
};
|
||||
/** The work has no external IDs. */
|
||||
draft: boolean;
|
||||
/** Non-deleted child entries (by work `parent_id`), or null when there are none. */
|
||||
childSummary: { total: number; completed: number } | null;
|
||||
}
|
||||
|
||||
type EntryRow = Database["entries"];
|
||||
|
||||
function toEntry(r: EntryRow): Entry {
|
||||
return {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
workId: r.work_id,
|
||||
status: r.status,
|
||||
rating: r.rating,
|
||||
review: r.review,
|
||||
privateNotes: r.private_notes,
|
||||
favorite: Boolean(r.favorite),
|
||||
tags: fromJson<string[]>(r.tags, []),
|
||||
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
|
||||
visibility: r.visibility,
|
||||
overrides: fromJson<EntryOverrides>(r.overrides, {}),
|
||||
mediaTypeOverride: r.media_type_override,
|
||||
fieldSources: fromJson<FieldSources>(r.field_sources, {}),
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
deletedAt: r.deleted_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** The history snapshot of an entry: its patchable fields plus `workId` and `deletedAt`. */
|
||||
function snapshot(e: Entry): Record<string, unknown> {
|
||||
const s: Record<string, unknown> = { workId: e.workId, deletedAt: e.deletedAt };
|
||||
for (const k of ENTRY_PATCH_KEYS) s[k] = e[k];
|
||||
return s;
|
||||
}
|
||||
|
||||
function patchToColumns(p: Partial<EntryPatch>): Partial<EntryRow> {
|
||||
const c: Partial<EntryRow> = {};
|
||||
if ("status" in p) c.status = p.status!;
|
||||
if ("rating" in p) c.rating = p.rating!;
|
||||
if ("review" in p) c.review = p.review!;
|
||||
if ("privateNotes" in p) c.private_notes = p.privateNotes!;
|
||||
if ("favorite" in p) c.favorite = p.favorite ? 1 : 0;
|
||||
if ("tags" in p) c.tags = toJson(p.tags);
|
||||
if ("customFields" in p) c.custom_fields = toJson(p.customFields);
|
||||
if ("visibility" in p) c.visibility = p.visibility!;
|
||||
if ("overrides" in p) c.overrides = toJson(p.overrides);
|
||||
if ("mediaTypeOverride" in p) c.media_type_override = p.mediaTypeOverride!;
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Validate and normalise a patch. Custom fields are checked against the user's entry-level definitions. */
|
||||
async function normalisePatch(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
mediaType: string,
|
||||
patch: Partial<EntryPatch>,
|
||||
opts: { trustCustomFields?: boolean } = {},
|
||||
): Promise<Partial<EntryPatch>> {
|
||||
checkKeys("patch", patch, ENTRY_PATCH_KEYS);
|
||||
const out: Partial<EntryPatch> = {};
|
||||
if ("status" in patch) {
|
||||
if (!STATUSES.includes(patch.status as EntryStatus)) throw new ValidationError("status", "invalid");
|
||||
out.status = patch.status;
|
||||
}
|
||||
if ("rating" in patch) out.rating = checkRating("rating", patch.rating);
|
||||
if ("review" in patch) out.review = checkText("review", patch.review);
|
||||
if ("privateNotes" in patch) out.privateNotes = checkText("privateNotes", patch.privateNotes);
|
||||
if ("favorite" in patch) {
|
||||
if (typeof patch.favorite !== "boolean") throw new ValidationError("favorite", "not_boolean");
|
||||
out.favorite = patch.favorite;
|
||||
}
|
||||
if ("tags" in patch) {
|
||||
if (!Array.isArray(patch.tags) || !patch.tags.every((t) => typeof t === "string")) {
|
||||
throw new ValidationError("tags", "not_list");
|
||||
}
|
||||
out.tags = [...new Set(patch.tags.map((t) => t.trim()).filter(Boolean))];
|
||||
}
|
||||
if ("visibility" in patch) {
|
||||
if (!VISIBILITIES.includes(patch.visibility as Visibility)) throw new ValidationError("visibility", "invalid");
|
||||
out.visibility = patch.visibility;
|
||||
}
|
||||
if ("mediaTypeOverride" in patch) {
|
||||
const m = checkText("mediaTypeOverride", patch.mediaTypeOverride);
|
||||
out.mediaTypeOverride = m?.trim() || null;
|
||||
}
|
||||
if ("overrides" in patch) out.overrides = checkOverrides(patch.overrides);
|
||||
if ("customFields" in patch) {
|
||||
const cf = patch.customFields;
|
||||
if (cf === null || typeof cf !== "object" || Array.isArray(cf)) throw new ValidationError("customFields", "not_object");
|
||||
if (!opts.trustCustomFields) {
|
||||
const effective = out.mediaTypeOverride ?? mediaType;
|
||||
const defs = await customFieldsFor(k, userId, { mediaType: effective, level: "entry" });
|
||||
validateCustomValues(defs, cf);
|
||||
}
|
||||
out.customFields = cf;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkOverrides(o: unknown): EntryOverrides {
|
||||
checkKeys("overrides", o as object, OVERRIDE_KEYS);
|
||||
const v = o as Record<string, unknown>;
|
||||
const out: EntryOverrides = {};
|
||||
if (v.title !== undefined) {
|
||||
if (typeof v.title !== "string" || !v.title.trim()) throw new ValidationError("overrides.title", "not_text");
|
||||
out.title = v.title.trim();
|
||||
}
|
||||
if (v.coverUrl !== undefined) {
|
||||
if (typeof v.coverUrl !== "string") throw new ValidationError("overrides.coverUrl", "not_text");
|
||||
out.coverUrl = v.coverUrl;
|
||||
}
|
||||
if (v.year !== undefined) {
|
||||
if (v.year !== null && (typeof v.year !== "number" || !Number.isInteger(v.year))) {
|
||||
throw new ValidationError("overrides.year", "not_integer");
|
||||
}
|
||||
out.year = v.year;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface WriteOpts {
|
||||
/** Non-patch columns. */
|
||||
extra?: Partial<Pick<EntryRow, "work_id" | "deleted_at">>;
|
||||
/** Per-key `field_sources` for patched keys instead of `actor`; `undefined` removes the key. */
|
||||
sources?: Record<string, Actor | undefined>;
|
||||
/** History `before` override: `null` records a creation. */
|
||||
historyBefore?: Record<string, unknown> | null;
|
||||
/** Extra keys merged into the history row's before/after snapshots. */
|
||||
historyExtra?: { before: Record<string, unknown>; after: Record<string, unknown> };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a patch to an entry, set `field_sources[key] = actor` for each patched key (unless
|
||||
* `opts.sources` says otherwise), and record history.
|
||||
*/
|
||||
async function writeEntry(
|
||||
k: Executor,
|
||||
current: Entry,
|
||||
patch: Partial<EntryPatch>,
|
||||
actor: Actor,
|
||||
opts: WriteOpts = {},
|
||||
): Promise<Entry> {
|
||||
const fieldSources: Record<string, Actor | undefined> = { ...current.fieldSources };
|
||||
for (const key of Object.keys(patch)) {
|
||||
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : actor;
|
||||
if (fieldSources[key] === undefined) delete fieldSources[key];
|
||||
}
|
||||
const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() };
|
||||
await k.updateTable("entries").set(cols).where("id", "=", current.id).execute();
|
||||
const after = await loadEntry(k, current.userId, current.id, { includeDeleted: true });
|
||||
const before = opts.historyBefore === undefined ? snapshot(current) : opts.historyBefore;
|
||||
await recordHistory(k, {
|
||||
userId: current.userId,
|
||||
entity: "entry",
|
||||
entityId: current.id,
|
||||
before: before === null ? null : { ...before, ...opts.historyExtra?.before },
|
||||
after: { ...snapshot(after), ...opts.historyExtra?.after },
|
||||
actor,
|
||||
});
|
||||
return after;
|
||||
}
|
||||
|
||||
async function loadEntry(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
entryId: string,
|
||||
opts: { includeDeleted?: boolean } = {},
|
||||
): Promise<Entry> {
|
||||
let q = k.selectFrom("entries").selectAll().where("id", "=", entryId).where("user_id", "=", userId);
|
||||
if (!opts.includeDeleted) q = q.where("deleted_at", "is", null);
|
||||
const r = await q.executeTakeFirst();
|
||||
if (!r) throw new NotFoundError("entry");
|
||||
return toEntry(r);
|
||||
}
|
||||
|
||||
/** The user's entry (live or not) for a work, if any. */
|
||||
async function findEntry(k: Executor, userId: string, workId: string): Promise<Entry | null> {
|
||||
const r = await k
|
||||
.selectFrom("entries")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.where("work_id", "=", workId)
|
||||
.executeTakeFirst();
|
||||
return r ? toEntry(r) : null;
|
||||
}
|
||||
|
||||
/** Verify the entry belongs to the user and is live; returns its work's media type. Used by engagements. */
|
||||
export async function requireEntry(k: Executor, userId: string, entryId: string): Promise<{ mediaType: string }> {
|
||||
const r = await k
|
||||
.selectFrom("entries as e")
|
||||
.innerJoin("works as w", "w.id", "e.work_id")
|
||||
.select(["e.media_type_override", "w.media_type"])
|
||||
.where("e.id", "=", entryId)
|
||||
.where("e.user_id", "=", userId)
|
||||
.where("e.deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new NotFoundError("entry");
|
||||
return { mediaType: r.media_type_override ?? r.media_type };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update the user's entry for a work. Only the keys present in `patch` are touched, and
|
||||
* each gets `field_sources[key] = actor`. A soft-deleted entry is revived. Aliased or merged works
|
||||
* resolve to their target. Writes history.
|
||||
*/
|
||||
export async function upsertEntry(
|
||||
db: Db,
|
||||
userId: string,
|
||||
workId: string,
|
||||
patch: Partial<EntryPatch>,
|
||||
actor: Actor,
|
||||
tx?: Executor,
|
||||
): Promise<Entry> {
|
||||
return inTx(db, tx, async (k) => {
|
||||
const work = await resolveVisibleWork(k, userId, workId);
|
||||
let existing = await findEntry(k, userId, work.id);
|
||||
const p = await normalisePatch(k, userId, existing?.mediaTypeOverride ?? work.media_type, patch);
|
||||
let created = false;
|
||||
if (!existing) {
|
||||
// Insert-or-ignore, then patch through the update path: a concurrent creator never aborts our
|
||||
// transaction (Postgres poisons a transaction on a unique violation).
|
||||
const now = nowIso();
|
||||
const id = newId();
|
||||
await k
|
||||
.insertInto("entries")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
work_id: work.id,
|
||||
status: "none",
|
||||
rating: null,
|
||||
review: null,
|
||||
private_notes: null,
|
||||
favorite: 0,
|
||||
tags: toJson([]),
|
||||
custom_fields: toJson({}),
|
||||
visibility: await defaultVisibility(k, userId),
|
||||
overrides: toJson({}),
|
||||
media_type_override: null,
|
||||
field_sources: toJson({}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
deleted_at: null,
|
||||
})
|
||||
.onConflict((oc) => oc.columns(["user_id", "work_id"]).doNothing())
|
||||
.execute();
|
||||
existing = (await findEntry(k, userId, work.id))!;
|
||||
created = existing.id === id;
|
||||
}
|
||||
if (!created && Object.keys(p).length === 0 && existing.deletedAt === null) return existing;
|
||||
return writeEntry(k, existing, p, actor, {
|
||||
extra: existing.deletedAt === null ? {} : { deleted_at: null },
|
||||
historyBefore: created ? null : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-delete the user's entry. Writes history. */
|
||||
export async function softDeleteEntry(
|
||||
db: Db,
|
||||
userId: string,
|
||||
entryId: string,
|
||||
actor: Actor,
|
||||
tx?: Executor,
|
||||
): Promise<void> {
|
||||
await inTx(db, tx, async (k) => {
|
||||
const e = await loadEntry(k, userId, entryId);
|
||||
await writeEntry(k, e, {}, actor, { extra: { deleted_at: nowIso() } });
|
||||
});
|
||||
}
|
||||
|
||||
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
|
||||
export async function restoreEntryFromHistory(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
entryId: string,
|
||||
before: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const current = await loadEntry(k, userId, entryId, { includeDeleted: true });
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const key of ENTRY_PATCH_KEYS) if (key in before) patch[key] = before[key];
|
||||
if (typeof before.workId === "string" && before.workId !== current.workId) {
|
||||
// Undo a move: drop the forward alias and un-tombstone the user's own private work, then move back
|
||||
// without writing a reverse alias (later syncs onto the target must not land on the old work).
|
||||
await k
|
||||
.deleteFrom("work_aliases")
|
||||
.where("user_id", "=", userId)
|
||||
.where("from_work_id", "=", before.workId)
|
||||
.where("to_work_id", "=", current.workId)
|
||||
.execute();
|
||||
await k
|
||||
.updateTable("works")
|
||||
.set({ merged_into_id: null, updated_at: nowIso() })
|
||||
.where("id", "=", before.workId)
|
||||
.where("owner_user_id", "=", userId)
|
||||
.execute();
|
||||
await moveEntry(k, userId, entryId, before.workId, "user", { undo: true });
|
||||
}
|
||||
const fresh = await loadEntry(k, userId, entryId, { includeDeleted: true });
|
||||
const work = await k.selectFrom("works").select("media_type").where("id", "=", fresh.workId).executeTakeFirstOrThrow();
|
||||
// The user's own past data: custom values are not re-checked against today's definitions.
|
||||
const p = await normalisePatch(k, userId, fresh.mediaTypeOverride ?? work.media_type, patch as Partial<EntryPatch>, {
|
||||
trustCustomFields: true,
|
||||
});
|
||||
const extra = "deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {};
|
||||
await writeEntry(k, fresh, p, "user", { extra });
|
||||
}
|
||||
|
||||
const isEmpty = (v: unknown) =>
|
||||
v === null || v === false || v === "none" || v === "" || (Array.isArray(v) && v.length === 0) ||
|
||||
(typeof v === "object" && v !== null && Object.keys(v).length === 0);
|
||||
|
||||
/** Separator used when two entries' review or private notes are combined by a merge. */
|
||||
export const MERGE_TEXT_SEPARATOR = "\n\n---\n\n";
|
||||
|
||||
/**
|
||||
* Move the user's entry to another work (per-user merge, spec §6.1). See `moveEntry`.
|
||||
* Returns the surviving entry.
|
||||
*/
|
||||
export async function moveEntryToWork(
|
||||
db: Db,
|
||||
userId: string,
|
||||
entryId: string,
|
||||
targetWorkId: string,
|
||||
actor: Actor,
|
||||
tx?: Executor,
|
||||
): Promise<Entry> {
|
||||
return inTx(db, tx, (k) => moveEntry(k, userId, entryId, targetWorkId, actor));
|
||||
}
|
||||
|
||||
/** The field_sources value for a combined field: "user" if either side was the user's, else the destination's. */
|
||||
function combinedSource(src: Entry, dst: Entry, key: string): Actor | undefined {
|
||||
return src.fieldSources[key] === "user" || dst.fieldSources[key] === "user" ? "user" : dst.fieldSources[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an entry to `targetWorkId`.
|
||||
* - No entry on the target: the entry's `work_id` changes.
|
||||
* - A live entry on the target: merge into it. Its values win; empty fields are filled from the moved
|
||||
* entry; tags/custom fields/overrides are combined; differing review/private notes are concatenated;
|
||||
* a conflicting rating is kept but the moved rating is recorded in the history row's `before`.
|
||||
* Engagements are re-parented (with history) and the moved entry is soft-deleted.
|
||||
* - A soft-deleted entry on the target: revived with the moved entry's values and field_sources.
|
||||
* Then (unless `undo`): this user's copies and list items follow to the target, a `work_aliases` row
|
||||
* maps the old work to the target, and the user's own private draft is tombstoned into the target.
|
||||
*/
|
||||
async function moveEntry(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
entryId: string,
|
||||
targetWorkId: string,
|
||||
actor: Actor,
|
||||
opts: { undo?: boolean } = {},
|
||||
): Promise<Entry> {
|
||||
const src = await loadEntry(k, userId, entryId);
|
||||
const target = await resolveVisibleWork(k, userId, targetWorkId);
|
||||
if (target.id === src.workId) throw new ValidationError("targetWorkId", "same_work");
|
||||
const dst = await findEntry(k, userId, target.id);
|
||||
|
||||
let result: Entry;
|
||||
if (!dst) {
|
||||
result = await writeEntry(k, src, {}, actor, { extra: { work_id: target.id } });
|
||||
} else if (dst.deletedAt !== null) {
|
||||
const all: Partial<EntryPatch> = {};
|
||||
for (const key of ENTRY_PATCH_KEYS) (all as Record<string, unknown>)[key] = src[key];
|
||||
const sources: Record<string, Actor | undefined> = {};
|
||||
for (const key of ENTRY_PATCH_KEYS) sources[key] = src.fieldSources[key];
|
||||
result = await writeEntry(k, dst, all, actor, { extra: { deleted_at: null }, sources });
|
||||
await moveEngagements(k, userId, src.id, dst.id, actor);
|
||||
await writeEntry(k, src, {}, actor, { extra: { deleted_at: nowIso() } });
|
||||
} else {
|
||||
const merged: Record<string, unknown> = {};
|
||||
const sources: Record<string, Actor | undefined> = {};
|
||||
let historyExtra: WriteOpts["historyExtra"];
|
||||
for (const key of ENTRY_PATCH_KEYS) {
|
||||
const s = src[key];
|
||||
const d = dst[key];
|
||||
if (key === "tags") {
|
||||
const tags = [...new Set([...dst.tags, ...src.tags])];
|
||||
if (tags.length !== dst.tags.length) {
|
||||
merged[key] = tags;
|
||||
sources[key] = combinedSource(src, dst, key);
|
||||
}
|
||||
} else if (key === "customFields" || key === "overrides") {
|
||||
const combined = { ...(s as object), ...(d as object) };
|
||||
if (Object.keys(combined).length !== Object.keys(d as object).length) {
|
||||
merged[key] = combined;
|
||||
sources[key] = combinedSource(src, dst, key);
|
||||
}
|
||||
} else if (isEmpty(d) && !isEmpty(s)) {
|
||||
merged[key] = s;
|
||||
sources[key] = src.fieldSources[key];
|
||||
} else if ((key === "review" || key === "privateNotes") && !isEmpty(s) && s !== d) {
|
||||
merged[key] = `${d as string}${MERGE_TEXT_SEPARATOR}${s as string}`;
|
||||
sources[key] = combinedSource(src, dst, key);
|
||||
} else if (key === "rating" && s !== null && s !== d) {
|
||||
historyExtra = { before: { rating: s }, after: { rating: d } };
|
||||
}
|
||||
}
|
||||
result = await writeEntry(k, dst, merged as Partial<EntryPatch>, actor, { sources, historyExtra });
|
||||
await moveEngagements(k, userId, src.id, dst.id, actor);
|
||||
await writeEntry(k, src, {}, actor, { extra: { deleted_at: nowIso() } });
|
||||
}
|
||||
if (opts.undo) return result;
|
||||
|
||||
await repointCopiesAndListItems(k, userId, src.workId, target.id, actor);
|
||||
|
||||
// Aliases: re-point any that led to the old work, drop one leaving the target (no cycles), add ours.
|
||||
await k
|
||||
.updateTable("work_aliases")
|
||||
.set({ to_work_id: target.id })
|
||||
.where("user_id", "=", userId)
|
||||
.where("to_work_id", "=", src.workId)
|
||||
.execute();
|
||||
await k.deleteFrom("work_aliases").where("user_id", "=", userId).where("from_work_id", "=", target.id).execute();
|
||||
const alias = await k
|
||||
.selectFrom("work_aliases")
|
||||
.select("id")
|
||||
.where("user_id", "=", userId)
|
||||
.where("from_work_id", "=", src.workId)
|
||||
.executeTakeFirst();
|
||||
if (alias) {
|
||||
await k.updateTable("work_aliases").set({ to_work_id: target.id }).where("id", "=", alias.id).execute();
|
||||
} else {
|
||||
await k
|
||||
.insertInto("work_aliases")
|
||||
.values({ id: newId(), user_id: userId, from_work_id: src.workId, to_work_id: target.id })
|
||||
.execute();
|
||||
}
|
||||
// A private draft stays private; it is tombstoned and points to the target (spec §6.1).
|
||||
await k
|
||||
.updateTable("works")
|
||||
.set({ merged_into_id: target.id, updated_at: nowIso() })
|
||||
.where("id", "=", src.workId)
|
||||
.where("owner_user_id", "=", userId)
|
||||
.execute();
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Re-parent an entry's live and deleted engagements, writing an `entryId` history row for each. */
|
||||
async function moveEngagements(k: Executor, userId: string, fromEntryId: string, toEntryId: string, actor: Actor) {
|
||||
const rows = await k.selectFrom("engagements").select("id").where("entry_id", "=", fromEntryId).execute();
|
||||
for (const { id } of rows) {
|
||||
await k.updateTable("engagements").set({ entry_id: toEntryId, updated_at: nowIso() }).where("id", "=", id).execute();
|
||||
await recordHistory(k, {
|
||||
userId,
|
||||
entity: "engagement",
|
||||
entityId: id,
|
||||
before: { entryId: fromEntryId },
|
||||
after: { entryId: toEntryId },
|
||||
actor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Point this user's copies (with history) and the items of this user's lists at the new work. */
|
||||
async function repointCopiesAndListItems(k: Executor, userId: string, fromWorkId: string, toWorkId: string, actor: Actor) {
|
||||
const copies = await k
|
||||
.selectFrom("copies")
|
||||
.select("id")
|
||||
.where("user_id", "=", userId)
|
||||
.where("work_id", "=", fromWorkId)
|
||||
.execute();
|
||||
for (const { id } of copies) {
|
||||
await k.updateTable("copies").set({ work_id: toWorkId, updated_at: nowIso() }).where("id", "=", id).execute();
|
||||
await recordHistory(k, {
|
||||
userId,
|
||||
entity: "copy",
|
||||
entityId: id,
|
||||
before: { workId: fromWorkId },
|
||||
after: { workId: toWorkId },
|
||||
actor,
|
||||
});
|
||||
}
|
||||
await k
|
||||
.updateTable("list_items")
|
||||
.set({ work_id: toWorkId })
|
||||
.where("work_id", "=", fromWorkId)
|
||||
.where("list_id", "in", k.selectFrom("lists").select("id").where("user_id", "=", userId))
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Insert a private draft work owned by the user (no external IDs). Returns its id. */
|
||||
async function insertPrivateWork(k: Executor, userId: string, title: string, mediaType: string): Promise<string> {
|
||||
const id = newId();
|
||||
const now = nowIso();
|
||||
await k
|
||||
.insertInto("works")
|
||||
.values({
|
||||
id,
|
||||
media_type: mediaType,
|
||||
title,
|
||||
original_title: "",
|
||||
year: null,
|
||||
release_date: null,
|
||||
creators: toJson([]),
|
||||
description: "",
|
||||
cover_url: "",
|
||||
cover_attachment_id: null,
|
||||
metadata: toJson({}),
|
||||
parent_id: null,
|
||||
position: null,
|
||||
owner_user_id: userId,
|
||||
merged_into_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Create a private draft work and the user's entry on it, in one transaction. */
|
||||
export async function quickAdd(
|
||||
db: Db,
|
||||
userId: string,
|
||||
input: { title: string; mediaType?: MediaType },
|
||||
): Promise<EntryView> {
|
||||
const title = typeof input.title === "string" ? input.title.trim() : "";
|
||||
if (!title) throw new ValidationError("title", "required");
|
||||
const mediaType = input.mediaType ?? "other";
|
||||
if (!(MEDIA_TYPES as readonly string[]).includes(mediaType)) throw new ValidationError("mediaType", "invalid");
|
||||
const entry = await db.k.transaction().execute(async (k) => {
|
||||
const workId = await insertPrivateWork(k, userId, title, mediaType);
|
||||
return upsertEntry(db, userId, workId, {}, "user", k);
|
||||
});
|
||||
return getEntry(db, userId, entry.id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Reading: views and the library list
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* FROM/WHERE for this user's live entries on works below `e.work_id`, down to great-grandchildren
|
||||
* (show → season → episode, series → volume → chapter). Portable: three fixed self-joins.
|
||||
*/
|
||||
const DESCENDANTS = sql`from entries c
|
||||
inner join works cw on cw.id = c.work_id
|
||||
left join works p1 on p1.id = cw.parent_id
|
||||
left join works p2 on p2.id = p1.parent_id
|
||||
where c.user_id = e.user_id and c.deleted_at is null
|
||||
and (cw.parent_id = e.work_id or p1.parent_id = e.work_id or p2.parent_id = e.work_id)`;
|
||||
|
||||
/**
|
||||
* True when one of the work's ancestors (parent, grandparent, great-grandparent) carries a live entry
|
||||
* of this user, i.e. the entry is rolled up under that ancestor's entry.
|
||||
*/
|
||||
const HAS_ENTRY_ANCESTOR = sql<boolean>`exists (select 1 from works a1
|
||||
left join works a2 on a2.id = a1.parent_id
|
||||
left join works a3 on a3.id = a2.parent_id
|
||||
inner join entries pe on pe.user_id = e.user_id and pe.deleted_at is null
|
||||
and (pe.work_id = a1.id or pe.work_id = a2.id or pe.work_id = a3.id)
|
||||
where a1.id = w.parent_id)`;
|
||||
|
||||
/**
|
||||
* True when the entry's work is a descendant of `parentId` (up to three levels) with no work strictly
|
||||
* between them carrying a live entry of this user: the top-level rule, relative to `parentId`.
|
||||
*/
|
||||
function underParent(parentId: string) {
|
||||
const liveEntryOn = (ids: ReturnType<typeof sql>) =>
|
||||
sql`exists (select 1 from entries pe where pe.user_id = e.user_id and pe.deleted_at is null and pe.work_id in (${ids}))`;
|
||||
return sql<boolean>`(w.parent_id = ${parentId}
|
||||
or exists (select 1 from works p1 where p1.id = w.parent_id and p1.parent_id = ${parentId}
|
||||
and not ${liveEntryOn(sql`p1.id`)})
|
||||
or exists (select 1 from works p1 inner join works p2 on p2.id = p1.parent_id
|
||||
where p1.id = w.parent_id and p2.parent_id = ${parentId}
|
||||
and not ${liveEntryOn(sql`p1.id, p2.id`)}))`;
|
||||
}
|
||||
|
||||
function viewQuery(k: Executor, userId: string) {
|
||||
return k
|
||||
.selectFrom("entries as e")
|
||||
.innerJoin("works as w", "w.id", "e.work_id")
|
||||
.selectAll("e")
|
||||
.select([
|
||||
"w.media_type as w_media_type",
|
||||
"w.title as w_title",
|
||||
"w.original_title as w_original_title",
|
||||
"w.year as w_year",
|
||||
"w.cover_url as w_cover_url",
|
||||
"w.parent_id as w_parent_id",
|
||||
sql<number>`case when exists (select 1 from external_ids x where x.work_id = w.id) then 0 else 1 end`.as(
|
||||
"is_draft",
|
||||
),
|
||||
sql<number | string>`(select count(*) ${DESCENDANTS})`.as("child_total"),
|
||||
sql<number | string>`(select count(*) ${DESCENDANTS} and c.status = 'completed')`.as("child_completed"),
|
||||
sql<string>`lower(w.title)`.as("title_key"),
|
||||
])
|
||||
.where("e.user_id", "=", userId)
|
||||
.where("e.deleted_at", "is", null);
|
||||
}
|
||||
|
||||
type ViewRow = EntryRow & {
|
||||
w_media_type: string;
|
||||
w_title: string;
|
||||
w_original_title: string;
|
||||
w_year: number | null;
|
||||
w_cover_url: string;
|
||||
w_parent_id: string | null;
|
||||
is_draft: number | string;
|
||||
child_total: number | string;
|
||||
child_completed: number | string;
|
||||
title_key: string;
|
||||
};
|
||||
|
||||
function toView(r: ViewRow): EntryView {
|
||||
const e = toEntry(r);
|
||||
const o = e.overrides;
|
||||
const total = Number(r.child_total);
|
||||
return {
|
||||
...e,
|
||||
work: {
|
||||
id: r.work_id,
|
||||
mediaType: r.w_media_type,
|
||||
title: o.title ?? r.w_title,
|
||||
originalTitle: r.w_original_title,
|
||||
year: o.year !== undefined ? o.year : r.w_year,
|
||||
coverUrl: o.coverUrl ?? r.w_cover_url,
|
||||
parentId: r.w_parent_id,
|
||||
},
|
||||
draft: Number(r.is_draft) === 1,
|
||||
childSummary: total > 0 ? { total, completed: Number(r.child_completed) } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** One live entry of the user's, with its work view. */
|
||||
export async function getEntry(db: Db, userId: string, entryId: string, tx?: Executor): Promise<EntryView> {
|
||||
const r = await viewQuery(tx ?? db.k, userId).where("e.id", "=", entryId).executeTakeFirst();
|
||||
if (!r) throw new NotFoundError("entry");
|
||||
return toView(r as ViewRow);
|
||||
}
|
||||
|
||||
export interface EntryFilter {
|
||||
mediaType?: string;
|
||||
status?: EntryStatus;
|
||||
tag?: string;
|
||||
draft?: boolean;
|
||||
q?: string;
|
||||
/**
|
||||
* Default `null`: the top of the user's library, i.e. entries with no ancestor work (up to three
|
||||
* levels) carrying a live entry of this user. A work id applies the same rule relative to that work:
|
||||
* its descendants (up to three levels) with no live-entry work strictly between them and it.
|
||||
*/
|
||||
parentId?: string | null;
|
||||
sort?: "updated" | "title" | "rating";
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
|
||||
/**
|
||||
* The user's live entries, keyset-paginated. `cursor` is base64 JSON `{ sort, sortValue, id }`; for the
|
||||
* title sort `sortValue` is the SQL `lower(w.title)` of the last row, so SQL and cursor always agree.
|
||||
* Sorts: `updated` (newest first), `title` (A–Z, case-insensitive), `rating` (highest first, unrated last).
|
||||
*/
|
||||
export async function listEntries(
|
||||
db: Db,
|
||||
userId: string,
|
||||
filter: EntryFilter,
|
||||
): Promise<{ items: EntryView[]; nextCursor: string | null }> {
|
||||
const sort = filter.sort ?? "updated";
|
||||
if (!["updated", "title", "rating"].includes(sort)) throw new ValidationError("sort", "invalid");
|
||||
const limit = filter.limit ?? 50;
|
||||
if (!Number.isInteger(limit) || limit < 1) throw new ValidationError("limit", "invalid");
|
||||
const take = Math.min(limit, 200);
|
||||
|
||||
let q = viewQuery(db.k, userId);
|
||||
const parentId = filter.parentId === undefined ? null : filter.parentId;
|
||||
q = q.where(parentId === null ? sql<boolean>`not ${HAS_ENTRY_ANCESTOR}` : underParent(parentId));
|
||||
if (filter.mediaType) q = q.where(sql<string>`coalesce(e.media_type_override, w.media_type)`, "=", filter.mediaType);
|
||||
if (filter.status) q = q.where("e.status", "=", filter.status);
|
||||
if (filter.tag) {
|
||||
const needle = `%${escapeLike(JSON.stringify(filter.tag.trim().toLowerCase()))}%`;
|
||||
q = q.where(sql<boolean>`lower(e.tags) like ${needle} escape '\\'`);
|
||||
}
|
||||
if (filter.draft !== undefined) {
|
||||
const has = sql<boolean>`exists (select 1 from external_ids x where x.work_id = w.id)`;
|
||||
q = q.where(filter.draft ? sql<boolean>`not ${has}` : has);
|
||||
}
|
||||
if (filter.q?.trim()) {
|
||||
const p = `%${escapeLike(filter.q.trim().toLowerCase())}%`;
|
||||
q = q.where(
|
||||
sql<boolean>`(lower(w.title) like ${p} escape '\\' or lower(w.original_title) like ${p} escape '\\')`,
|
||||
);
|
||||
}
|
||||
|
||||
const titleKey = sql<string>`lower(w.title)`;
|
||||
const ratingNull = sql<number>`case when e.rating is null then 1 else 0 end`;
|
||||
if (filter.cursor !== undefined) {
|
||||
const c = decodeCursor(filter.cursor, sort);
|
||||
if (sort === "updated") {
|
||||
const v = c.sortValue as string;
|
||||
q = q.where((eb) =>
|
||||
eb.or([eb("e.updated_at", "<", v), eb.and([eb("e.updated_at", "=", v), eb("e.id", "<", c.id)])]),
|
||||
);
|
||||
} else if (sort === "title") {
|
||||
const v = c.sortValue as string;
|
||||
q = q.where((eb) => eb.or([eb(titleKey, ">", v), eb.and([eb(titleKey, "=", v), eb("e.id", ">", c.id)])]));
|
||||
} else if (c.sortValue === null) {
|
||||
q = q.where("e.rating", "is", null).where("e.id", "<", c.id);
|
||||
} else {
|
||||
const v = c.sortValue as number;
|
||||
q = q.where((eb) =>
|
||||
eb.or([
|
||||
eb("e.rating", "is", null),
|
||||
eb("e.rating", "<", v),
|
||||
eb.and([eb("e.rating", "=", v), eb("e.id", "<", c.id)]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (sort === "updated") q = q.orderBy("e.updated_at", "desc").orderBy("e.id", "desc");
|
||||
else if (sort === "title") q = q.orderBy(titleKey, "asc").orderBy("e.id", "asc");
|
||||
else q = q.orderBy(ratingNull, "asc").orderBy("e.rating", "desc").orderBy("e.id", "desc");
|
||||
|
||||
const rows = (await q.limit(take + 1).execute()) as ViewRow[];
|
||||
const page = rows.slice(0, take);
|
||||
let nextCursor: string | null = null;
|
||||
if (rows.length > take) {
|
||||
const last = page[page.length - 1]!;
|
||||
const sortValue = sort === "updated" ? last.updated_at : sort === "title" ? last.title_key : last.rating;
|
||||
nextCursor = Buffer.from(JSON.stringify({ sort, sortValue, id: last.id })).toString("base64");
|
||||
}
|
||||
return { items: page.map(toView), nextCursor };
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string, sort: string): { sortValue: string | number | null; id: string } {
|
||||
const c = fromJson<{ sort?: unknown; sortValue?: unknown; id?: unknown } | null>(
|
||||
Buffer.from(cursor, "base64").toString("utf8"),
|
||||
null,
|
||||
);
|
||||
const ok =
|
||||
c !== null &&
|
||||
typeof c === "object" &&
|
||||
c.sort === sort &&
|
||||
typeof c.id === "string" &&
|
||||
(sort === "rating"
|
||||
? c.sortValue === null || typeof c.sortValue === "number"
|
||||
: typeof c.sortValue === "string");
|
||||
if (!ok) throw new ValidationError("cursor", "invalid");
|
||||
return { sortValue: c.sortValue as string | number | null, id: c.id as string };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Match suggestions
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
async function loadSuggestion(k: Executor, userId: string, suggestionId: string) {
|
||||
const s = await k
|
||||
.selectFrom("match_suggestions")
|
||||
.selectAll()
|
||||
.where("id", "=", suggestionId)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (!s) throw new NotFoundError("suggestion");
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a match suggestion: move the user's entry from `work_id` to `candidate_work_id` (actor "user"),
|
||||
* mark it accepted and reject the user's other pending suggestions for the same work.
|
||||
*/
|
||||
export async function acceptSuggestion(db: Db, userId: string, suggestionId: string): Promise<void> {
|
||||
await db.k.transaction().execute(async (k) => {
|
||||
const s = await loadSuggestion(k, userId, suggestionId);
|
||||
if (s.status !== "pending") throw new ValidationError("status", "not_pending");
|
||||
const entry = await findEntry(k, userId, s.work_id);
|
||||
if (entry && entry.deletedAt === null) {
|
||||
await moveEntry(k, userId, entry.id, s.candidate_work_id, "user");
|
||||
}
|
||||
await k.updateTable("match_suggestions").set({ status: "accepted" }).where("id", "=", s.id).execute();
|
||||
await k
|
||||
.updateTable("match_suggestions")
|
||||
.set({ status: "rejected" })
|
||||
.where("user_id", "=", userId)
|
||||
.where("work_id", "=", s.work_id)
|
||||
.where("status", "=", "pending")
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
/** Reject a match suggestion. */
|
||||
export async function rejectSuggestion(db: Db, userId: string, suggestionId: string): Promise<void> {
|
||||
const s = await loadSuggestion(db.k, userId, suggestionId);
|
||||
if (s.status !== "pending") throw new ValidationError("status", "not_pending");
|
||||
await db.k.updateTable("match_suggestions").set({ status: "rejected" }).where("id", "=", s.id).execute();
|
||||
}
|
||||
@@ -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:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */
|
||||
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<Database>;
|
||||
|
||||
export interface HistoryRecord {
|
||||
id: string;
|
||||
entity: string;
|
||||
entityId: string;
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
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<T>(db: Db, tx: Executor | undefined, fn: (tx: Executor) => Promise<T>): Promise<T> {
|
||||
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<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
actor: Actor;
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const k = "k" in tx ? tx.k : tx;
|
||||
const before = input.before ?? {};
|
||||
const after = input.after ?? {};
|
||||
const b: Record<string, unknown> = {};
|
||||
const a: Record<string, unknown> = {};
|
||||
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<HistoryRecord[]> {
|
||||
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<void> {
|
||||
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<Record<string, unknown>>(r.before, {}),
|
||||
after: fromJson<Record<string, unknown>>(r.after, {}),
|
||||
actor: r.actor,
|
||||
createdAt: r.created_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import type { Database, ListKind, Visibility } from "@sttracker/db/schema";
|
||||
import { checkKeys, checkRating, checkText, defaultVisibility, resolveVisibleWork, VISIBILITIES } from "./entries.ts";
|
||||
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
|
||||
|
||||
const KINDS: readonly ListKind[] = ["list", "grouping", "thread"];
|
||||
|
||||
/** Spec §6.5. Only a `grouping` may carry `rating`/`review`. */
|
||||
export interface ListPatch {
|
||||
name: string;
|
||||
description: string;
|
||||
kind: ListKind;
|
||||
visibility: Visibility;
|
||||
rating: number | null;
|
||||
review: string | null;
|
||||
}
|
||||
const LIST_KEYS = ["name", "description", "kind", "visibility", "rating", "review"] as const satisfies readonly (keyof ListPatch)[];
|
||||
|
||||
export interface List extends ListPatch {
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
listId: string;
|
||||
workId: string;
|
||||
/** 0-based, dense. */
|
||||
position: number;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface ListWithItems extends List {
|
||||
items: (ListItem & { work: { id: string; title: string; mediaType: string; year: number | null; coverUrl: string } })[];
|
||||
}
|
||||
|
||||
type Row = Database["lists"];
|
||||
type ItemRow = Database["list_items"];
|
||||
|
||||
function toList(r: Row): List {
|
||||
return {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
kind: r.kind,
|
||||
visibility: r.visibility,
|
||||
rating: r.rating,
|
||||
review: r.review,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
deletedAt: r.deleted_at,
|
||||
};
|
||||
}
|
||||
|
||||
const toItem = (r: ItemRow): ListItem => ({
|
||||
id: r.id,
|
||||
listId: r.list_id,
|
||||
workId: r.work_id,
|
||||
position: r.position,
|
||||
note: r.note,
|
||||
});
|
||||
|
||||
/** The history snapshot: list fields, `deletedAt`, and the ordered items (so removals are restorable). */
|
||||
async function snapshot(k: Executor, l: List): Promise<Record<string, unknown>> {
|
||||
const s: Record<string, unknown> = { deletedAt: l.deletedAt };
|
||||
for (const key of LIST_KEYS) s[key] = l[key];
|
||||
s.items = (await items(k, l.id)).map((i) => ({ id: i.id, workId: i.workId, note: i.note }));
|
||||
return s;
|
||||
}
|
||||
|
||||
function normalise(patch: Partial<ListPatch>): Partial<ListPatch> {
|
||||
checkKeys("patch", patch, LIST_KEYS);
|
||||
const out: Partial<ListPatch> = {};
|
||||
if ("name" in patch) {
|
||||
if (typeof patch.name !== "string" || !patch.name.trim()) throw new ValidationError("name", "required");
|
||||
out.name = patch.name.trim();
|
||||
}
|
||||
if ("description" in patch) out.description = checkText("description", patch.description) ?? "";
|
||||
if ("kind" in patch) {
|
||||
if (!KINDS.includes(patch.kind as ListKind)) throw new ValidationError("kind", "invalid");
|
||||
out.kind = patch.kind;
|
||||
}
|
||||
if ("visibility" in patch) {
|
||||
if (!VISIBILITIES.includes(patch.visibility as Visibility)) throw new ValidationError("visibility", "invalid");
|
||||
out.visibility = patch.visibility;
|
||||
}
|
||||
if ("rating" in patch) out.rating = checkRating("rating", patch.rating);
|
||||
if ("review" in patch) out.review = checkText("review", patch.review);
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkGrouping(l: Pick<ListPatch, "kind" | "rating" | "review">): void {
|
||||
if (l.kind !== "grouping" && (l.rating !== null || l.review !== null)) {
|
||||
throw new ValidationError(l.rating !== null ? "rating" : "review", "grouping_only");
|
||||
}
|
||||
}
|
||||
|
||||
function toColumns(p: Partial<ListPatch>): Partial<Row> {
|
||||
const c: Partial<Row> = {};
|
||||
if (p.name !== undefined) c.name = p.name;
|
||||
if (p.description !== undefined) c.description = p.description;
|
||||
if (p.kind !== undefined) c.kind = p.kind;
|
||||
if (p.visibility !== undefined) c.visibility = p.visibility;
|
||||
if (p.rating !== undefined) c.rating = p.rating;
|
||||
if (p.review !== undefined) c.review = p.review;
|
||||
return c;
|
||||
}
|
||||
|
||||
async function load(k: Executor, userId: string, listId: string, includeDeleted = false): Promise<List> {
|
||||
let q = k.selectFrom("lists").selectAll().where("id", "=", listId).where("user_id", "=", userId);
|
||||
if (!includeDeleted) q = q.where("deleted_at", "is", null);
|
||||
const r = await q.executeTakeFirst();
|
||||
if (!r) throw new NotFoundError("list");
|
||||
return toList(r);
|
||||
}
|
||||
|
||||
async function items(k: Executor, listId: string): Promise<ListItem[]> {
|
||||
const rows = await k
|
||||
.selectFrom("list_items")
|
||||
.selectAll()
|
||||
.where("list_id", "=", listId)
|
||||
.orderBy("position")
|
||||
.orderBy("id")
|
||||
.execute();
|
||||
return rows.map(toItem);
|
||||
}
|
||||
|
||||
/** Rewrite positions to 0..n-1 in the given order. */
|
||||
async function renumber(k: Executor, ordered: ListItem[]): Promise<void> {
|
||||
for (const [i, it] of ordered.entries()) {
|
||||
if (it.position !== i) await k.updateTable("list_items").set({ position: i }).where("id", "=", it.id).execute();
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `change` on a list (live, user's), then bump `updated_at` and record history. */
|
||||
async function mutate<T>(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
listId: string,
|
||||
actor: Actor,
|
||||
change: (l: List) => Promise<T>,
|
||||
opts: { includeDeleted?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const before = await load(k, userId, listId, opts.includeDeleted);
|
||||
const beforeSnap = await snapshot(k, before);
|
||||
const result = await change(before);
|
||||
await k.updateTable("lists").set({ updated_at: nowIso() }).where("id", "=", listId).execute();
|
||||
const after = await load(k, userId, listId, true);
|
||||
await recordHistory(k, {
|
||||
userId,
|
||||
entity: "list",
|
||||
entityId: listId,
|
||||
before: beforeSnap,
|
||||
after: await snapshot(k, after),
|
||||
actor,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Create a list. Default kind `list`, visibility from user preferences. */
|
||||
export async function createList(
|
||||
db: Db,
|
||||
userId: string,
|
||||
input: Partial<ListPatch> & { name: string },
|
||||
actor: Actor = "user",
|
||||
tx?: Executor,
|
||||
): Promise<List> {
|
||||
return inTx(db, tx, async (k) => {
|
||||
const p = normalise(input);
|
||||
if (p.name === undefined) throw new ValidationError("name", "required");
|
||||
const now = nowIso();
|
||||
const row: Row = {
|
||||
id: newId(),
|
||||
user_id: userId,
|
||||
name: p.name,
|
||||
description: p.description ?? "",
|
||||
kind: p.kind ?? "list",
|
||||
visibility: p.visibility ?? (await defaultVisibility(k, userId)),
|
||||
rating: p.rating ?? null,
|
||||
review: p.review ?? null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
deleted_at: null,
|
||||
};
|
||||
checkGrouping(row);
|
||||
await k.insertInto("lists").values(row).execute();
|
||||
const list = toList(row);
|
||||
await recordHistory(k, {
|
||||
userId,
|
||||
entity: "list",
|
||||
entityId: list.id,
|
||||
before: null,
|
||||
after: await snapshot(k, list),
|
||||
actor,
|
||||
});
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
/** Patch a list's own fields. */
|
||||
export async function updateList(
|
||||
db: Db,
|
||||
userId: string,
|
||||
listId: string,
|
||||
patch: Partial<ListPatch>,
|
||||
actor: Actor = "user",
|
||||
tx?: Executor,
|
||||
): Promise<List> {
|
||||
return inTx(db, tx, async (k) => {
|
||||
const p = normalise(patch);
|
||||
await mutate(k, userId, listId, actor, async (l) => {
|
||||
checkGrouping({ ...l, ...p });
|
||||
const cols = toColumns(p);
|
||||
if (Object.keys(cols).length) await k.updateTable("lists").set(cols).where("id", "=", listId).execute();
|
||||
});
|
||||
return load(k, userId, listId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-delete a list (its items stay, for restore). */
|
||||
export async function softDeleteList(
|
||||
db: Db,
|
||||
userId: string,
|
||||
listId: string,
|
||||
actor: Actor = "user",
|
||||
tx?: Executor,
|
||||
): Promise<void> {
|
||||
await inTx(db, tx, (k) =>
|
||||
mutate(k, userId, listId, actor, async () => {
|
||||
await k.updateTable("lists").set({ deleted_at: nowIso() }).where("id", "=", listId).execute();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Add a work to a list, at `position` (0-based; default: the end). */
|
||||
export async function addListItem(
|
||||
db: Db,
|
||||
userId: string,
|
||||
listId: string,
|
||||
input: { workId: string; note?: string | null; position?: number },
|
||||
actor: Actor = "user",
|
||||
tx?: Executor,
|
||||
): Promise<ListItem> {
|
||||
return inTx(db, tx, (k) =>
|
||||
mutate(k, userId, listId, actor, async () => {
|
||||
const work = await resolveVisibleWork(k, userId, input.workId);
|
||||
const note = input.note === undefined ? null : checkText("note", input.note);
|
||||
const current = await items(k, listId);
|
||||
const at = clampIndex(input.position ?? current.length, current.length);
|
||||
const item: ListItem = { id: newId(), listId, workId: work.id, position: current.length, note };
|
||||
await k
|
||||
.insertInto("list_items")
|
||||
.values({ id: item.id, list_id: listId, work_id: item.workId, position: item.position, note })
|
||||
.execute();
|
||||
current.splice(at, 0, item);
|
||||
await renumber(k, current);
|
||||
return { ...item, position: at };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function clampIndex(i: number, max: number): number {
|
||||
if (typeof i !== "number" || !Number.isInteger(i)) throw new ValidationError("position", "not_integer");
|
||||
return Math.max(0, Math.min(i, max));
|
||||
}
|
||||
|
||||
/** Load an item of one of the user's live lists. */
|
||||
async function loadItem(k: Executor, userId: string, itemId: string): Promise<ListItem> {
|
||||
const r = await k
|
||||
.selectFrom("list_items as i")
|
||||
.innerJoin("lists as l", "l.id", "i.list_id")
|
||||
.selectAll("i")
|
||||
.where("i.id", "=", itemId)
|
||||
.where("l.user_id", "=", userId)
|
||||
.where("l.deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new NotFoundError("list_item");
|
||||
return toItem(r);
|
||||
}
|
||||
|
||||
/** Move an item to `toIndex` (clamped to the list bounds). */
|
||||
export async function moveListItem(
|
||||
db: Db,
|
||||
userId: string,
|
||||
itemId: string,
|
||||
toIndex: number,
|
||||
actor: Actor = "user",
|
||||
tx?: Executor,
|
||||
): Promise<void> {
|
||||
await inTx(db, tx, async (k) => {
|
||||
const item = await loadItem(k, userId, itemId);
|
||||
await mutate(k, userId, item.listId, actor, async () => {
|
||||
const current = (await items(k, item.listId)).filter((i) => i.id !== itemId);
|
||||
current.splice(clampIndex(toIndex, current.length), 0, item);
|
||||
await renumber(k, current);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an item. The list's history keeps it, so `restoreVersion` can bring it back. */
|
||||
export async function removeListItem(
|
||||
db: Db,
|
||||
userId: string,
|
||||
itemId: string,
|
||||
actor: Actor = "user",
|
||||
tx?: Executor,
|
||||
): Promise<void> {
|
||||
await inTx(db, tx, async (k) => {
|
||||
const item = await loadItem(k, userId, itemId);
|
||||
await mutate(k, userId, item.listId, actor, async () => {
|
||||
await k.deleteFrom("list_items").where("id", "=", itemId).execute();
|
||||
await renumber(k, await items(k, item.listId));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** A live list of the user's with its items in order. */
|
||||
export async function getList(db: Db, userId: string, listId: string): Promise<ListWithItems> {
|
||||
const list = await load(db.k, userId, listId);
|
||||
const rows = await db.k
|
||||
.selectFrom("list_items as i")
|
||||
.innerJoin("works as w", "w.id", "i.work_id")
|
||||
.selectAll("i")
|
||||
.select(["w.title", "w.media_type", "w.year", "w.cover_url"])
|
||||
.where("i.list_id", "=", listId)
|
||||
.orderBy("i.position")
|
||||
.orderBy("i.id")
|
||||
.execute();
|
||||
return {
|
||||
...list,
|
||||
items: rows.map((r) => ({
|
||||
...toItem(r),
|
||||
work: { id: r.work_id, title: r.title, mediaType: r.media_type, year: r.year, coverUrl: r.cover_url },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The user's live lists, most recently updated first. */
|
||||
export async function listLists(db: Db, userId: string, filter: { kind?: ListKind } = {}): Promise<List[]> {
|
||||
let q = db.k.selectFrom("lists").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null);
|
||||
if (filter.kind) q = q.where("kind", "=", filter.kind);
|
||||
const rows = await q.orderBy("updated_at", "desc").orderBy("id", "desc").execute();
|
||||
return rows.map(toList);
|
||||
}
|
||||
|
||||
/** Called by `restoreVersion`: re-apply a history row's `before` (fields, deletion and items) as actor "user". */
|
||||
export async function restoreListFromHistory(
|
||||
k: Executor,
|
||||
userId: string,
|
||||
listId: string,
|
||||
before: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const key of LIST_KEYS) if (key in before) patch[key] = before[key];
|
||||
const p = normalise(patch as Partial<ListPatch>);
|
||||
await mutate(
|
||||
k,
|
||||
userId,
|
||||
listId,
|
||||
"user",
|
||||
async (l) => {
|
||||
checkGrouping({ ...l, ...p });
|
||||
const cols: Partial<Row> = toColumns(p);
|
||||
if ("deletedAt" in before) cols.deleted_at = (before.deletedAt as string | null) ?? null;
|
||||
if (Object.keys(cols).length) await k.updateTable("lists").set(cols).where("id", "=", listId).execute();
|
||||
if (Array.isArray(before.items)) {
|
||||
await k.deleteFrom("list_items").where("list_id", "=", listId).execute();
|
||||
for (const [position, it] of (before.items as { id: string; workId: string; note: string | null }[]).entries()) {
|
||||
await k
|
||||
.insertInto("list_items")
|
||||
.values({ id: it.id, list_id: listId, work_id: it.workId, position, note: it.note ?? null })
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ includeDeleted: true },
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, unknown>, 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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}): Promise<string> {
|
||||
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<string, string>;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user