feat(core): entries, user history and match suggestion accept/reject

Entries carry per-field field_sources, write user_history diffs, resolve
aliased/merged works, and support quick add, per-user moves (with alias and
draft tombstone) and keyset-paginated library listing with child rollups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 19:34:47 +01:00
co-authored by Claude Opus 5.5
parent 56b86e27da
commit 13b81b1cd5
5 changed files with 1340 additions and 0 deletions
+811
View File
@@ -0,0 +1,811 @@
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>,
): 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 effective = out.mediaTypeOverride ?? mediaType;
const defs = await customFieldsFor(k, userId, { mediaType: effective, level: "entry" });
validateCustomValues(defs, patch.customFields as Record<string, unknown>);
out.customFields = patch.customFields;
}
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;
}
/**
* Write `cols` to an entry, set `field_sources[key] = actor` for each patched key, and record history.
* `extra` carries non-patch columns (`work_id`, `deleted_at`).
*/
async function writeEntry(
k: Executor,
current: Entry,
patch: Partial<EntryPatch>,
actor: Actor,
extra: Partial<Pick<EntryRow, "work_id" | "deleted_at">> = {},
sources?: FieldSources,
): Promise<Entry> {
const fieldSources = { ...current.fieldSources };
for (const key of Object.keys(patch)) fieldSources[key] = sources?.[key] ?? actor;
const cols = { ...patchToColumns(patch), ...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 });
await recordHistory(k, {
userId: current.userId,
entity: "entry",
entityId: current.id,
before: snapshot(current),
after: snapshot(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);
const existing = await findEntry(k, userId, work.id);
const p = await normalisePatch(k, userId, existing?.mediaTypeOverride ?? work.media_type, patch);
if (existing) {
if (Object.keys(p).length === 0 && existing.deletedAt === null) return existing;
return writeEntry(k, existing, p, actor, existing.deletedAt === null ? {} : { deleted_at: null });
}
const now = nowIso();
const fieldSources: FieldSources = {};
for (const key of Object.keys(p)) fieldSources[key] = actor;
const row: EntryRow = {
id: newId(),
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,
...patchToColumns(p),
field_sources: toJson(fieldSources),
created_at: now,
updated_at: now,
deleted_at: null,
};
await k.insertInto("entries").values(row).execute();
const entry = toEntry(row);
await recordHistory(k, { userId, entity: "entry", entityId: entry.id, before: null, after: snapshot(entry), actor });
return entry;
});
}
/** 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, { 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 user's alias and un-tombstone their own private work, then move back.
await k
.deleteFrom("work_aliases")
.where("user_id", "=", userId)
.where("from_work_id", "=", before.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");
}
const fresh = await loadEntry(k, userId, entryId, { includeDeleted: true });
const work = await k.selectFrom("works").select("media_type").where("id", "=", fresh.workId).executeTakeFirstOrThrow();
const p = await normalisePatch(k, userId, fresh.mediaTypeOverride ?? work.media_type, patch as Partial<EntryPatch>);
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" || (Array.isArray(v) && v.length === 0) ||
(typeof v === "object" && v !== null && Object.keys(v).length === 0);
/**
* Move the user's entry to another work (per-user merge, spec §6.1). Leaves a `work_aliases` row so
* old references resolve, and tombstones the user's own private draft work into the target.
* If the user already has an entry on the target, the two merge: the target's values win, gaps are
* filled from the moved entry, tags/custom fields/overrides are combined, engagements are re-parented,
* and the moved entry is soft-deleted. (A soft-deleted target entry is revived with the moved values.)
* 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));
}
async function moveEntry(k: Executor, userId: string, entryId: string, targetWorkId: string, actor: Actor): 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, { work_id: target.id });
} else {
const merged: Partial<EntryPatch> = {};
const sources: FieldSources = {};
const take = <K extends keyof EntryPatch>(key: K, v: EntryPatch[K]) => {
merged[key] = v;
if (src.fieldSources[key]) sources[key] = src.fieldSources[key];
};
for (const key of ENTRY_PATCH_KEYS) {
if (dst.deletedAt !== null) {
take(key, src[key]);
} else if (key === "tags") {
const tags = [...new Set([...dst.tags, ...src.tags])];
if (tags.length !== dst.tags.length) take(key, tags);
} else if (key === "customFields" || key === "overrides") {
const combined = { ...src[key], ...dst[key] };
if (Object.keys(combined).length !== Object.keys(dst[key]).length) take(key, combined as never);
} else if (isEmpty(dst[key]) && !isEmpty(src[key])) {
take(key, src[key]);
}
}
result = await writeEntry(k, dst, merged, actor, dst.deletedAt === null ? {} : { deleted_at: null }, sources);
await k.updateTable("engagements").set({ entry_id: dst.id }).where("entry_id", "=", src.id).execute();
await writeEntry(k, src, {}, actor, { deleted_at: nowIso() });
}
// 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;
}
/** 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
// ---------------------------------------------------------------------------------------------
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(*) from entries c inner join works cw on cw.id = c.work_id
where c.user_id = e.user_id and c.deleted_at is null and cw.parent_id = e.work_id)`.as("child_total"),
sql<number | string>`(select count(*) from entries c inner join works cw on cw.id = c.work_id
where c.user_id = e.user_id and c.deleted_at is null and cw.parent_id = e.work_id
and c.status = 'completed')`.as("child_completed"),
])
.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;
};
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`: top-level works only. A work id lists that work's children. */
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 }`.
* 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 = parentId === null ? q.where("w.parent_id", "is", null) : q.where("w.parent_id", "=", 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.w_title.toLowerCase() : 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);
await db.k.updateTable("match_suggestions").set({ status: "rejected" }).where("id", "=", s.id).execute();
}
+127
View File
@@ -0,0 +1,127 @@
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 { restoreEntryFromHistory } from "./entries.ts";
import { NotFoundError } from "./errors.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);
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,
};
}
+255
View File
@@ -0,0 +1,255 @@
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 { 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);
});
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);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from "bun:test";
import { forEachDialect } from "@sttracker/db/testing";
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();
// the shared work now aliases back to the draft for this user; no alias cycle remains
const aliases = await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).execute();
expect(aliases.map((a) => [a.from_work_id, a.to_work_id])).toEqual([[shared, draft.workId]]);
});
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);
});
});
+50
View File
@@ -0,0 +1,50 @@
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 { NotFoundError } from "../src/errors.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");
});
});