From b17ee59a17eb447508ae974fbfdb2e71c3c2696e Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:55:37 +0300 Subject: [PATCH 1/2] feat(core): content references API Adds relation-definition routes and handlers (list/create/get/update/delete/translations) and content-entry reference edge routes for children and parents, with edge-read hardening and error handling. --- .changeset/content-references-api.md | 5 + packages/core/src/api/handlers/relations.ts | 442 ++++++++++++++++++ packages/core/src/api/schemas/index.ts | 1 + packages/core/src/api/schemas/relations.ts | 99 ++++ .../[id]/references/[relation]/children.ts | 92 ++++ .../[id]/references/[relation]/parents.ts | 43 ++ .../astro/routes/api/relations/[id]/index.ts | 84 ++++ .../routes/api/relations/[id]/translations.ts | 33 ++ .../src/astro/routes/api/relations/index.ts | 56 +++ .../core/src/database/repositories/content.ts | 28 ++ .../src/database/repositories/relation.ts | 95 ++++ .../integration/api/references-edges.test.ts | 294 ++++++++++++ .../api/relations-handlers.test.ts | 244 ++++++++++ 13 files changed, 1516 insertions(+) create mode 100644 .changeset/content-references-api.md create mode 100644 packages/core/src/api/handlers/relations.ts create mode 100644 packages/core/src/api/schemas/relations.ts create mode 100644 packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.ts create mode 100644 packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/parents.ts create mode 100644 packages/core/src/astro/routes/api/relations/[id]/index.ts create mode 100644 packages/core/src/astro/routes/api/relations/[id]/translations.ts create mode 100644 packages/core/src/astro/routes/api/relations/index.ts create mode 100644 packages/core/tests/integration/api/references-edges.test.ts create mode 100644 packages/core/tests/integration/api/relations-handlers.test.ts diff --git a/.changeset/content-references-api.md b/.changeset/content-references-api.md new file mode 100644 index 000000000..d073b6594 --- /dev/null +++ b/.changeset/content-references-api.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds an HTTP API for content references: relation-definition CRUD under `/_emdash/api/relations` (editor-readable, admin-writable) and directed reference edges on content entries under `/_emdash/api/content/:collection/:id/references/:relation/{children,parents}` (ownership-aware). References are stored only in the references table — no collection column. The edge reads are cursor-paginated (`?cursor`/`?limit`, default 50, max 100) and return `nextCursor`; each resolved entry carries the `locale` of the variant returned. diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts new file mode 100644 index 000000000..3e3be1cab --- /dev/null +++ b/packages/core/src/api/handlers/relations.ts @@ -0,0 +1,442 @@ +import type { Kysely } from "kysely"; + +import { ContentRepository } from "../../database/repositories/content.js"; +import { + RelationRepository, + type ContentReference, + type CreateRelationInput, + type Relation, +} from "../../database/repositories/relation.js"; +import { InvalidCursorError } from "../../database/repositories/types.js"; +import type { ContentItem } from "../../database/repositories/types.js"; +import type { Database } from "../../database/types.js"; +import { SchemaRegistry } from "../../schema/registry.js"; +import type { ApiResult } from "../types.js"; + +/** Map an edge-read failure: a bad pagination cursor is a 400 client error, + * everything else is the generic 500-shaped reference-read error. */ +function referencesGetError(error: unknown): ApiResult { + if (error instanceof InvalidCursorError) { + return { success: false, error: { code: "INVALID_CURSOR", message: error.message } }; + } + return { + success: false, + error: { code: "REFERENCES_GET_ERROR", message: "Failed to get references" }, + }; +} + +/** True for SQLite UNIQUE / Postgres unique_violation messages (matches the + * fingerprint used in the content handlers). Narrow enough not to catch NOT + * NULL / CHECK violations whose messages also say "constraint". */ +function isUniqueViolation(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : ""; + return message.includes("unique constraint failed") || message.includes("duplicate key"); +} + +export async function handleRelationCreate( + db: Kysely, + input: CreateRelationInput, +): Promise> { + try { + const repo = new RelationRepository(db); + + // Invariant: a relation must point at collections that exist. There is no + // SQL FK (group-linking precludes it), so a ghost collection would yield a + // structurally-valid-but-permanently-useless relation. Skip when + // `translationOf` is set — structural fields are then inherited from an + // already-validated source, and the input collections are ignored. + if (!input.translationOf) { + const registry = new SchemaRegistry(db); + for (const collection of [input.parentCollection, input.childCollection]) { + if (!(await registry.getCollection(collection))) { + return { + success: false, + error: { + code: "COLLECTION_NOT_FOUND", + message: `Collection '${collection}' not found`, + }, + }; + } + } + } + + const relation = await repo.create(input); + return { success: true, data: { relation } }; + } catch (error) { + // A bad `translationOf` makes the repo throw loudly rather than mint an + // unlinked relation — surface it as 404, not a generic 500. + if ( + error instanceof Error && + error.message.includes("Source relation for translation not found") + ) { + return { + success: false, + error: { code: "NOT_FOUND", message: "Source relation for translation not found" }, + }; + } + // UNIQUE(name, locale) collision, or a second translation for an + // already-present (translation_group, locale) — both are client conflicts. + if (isUniqueViolation(error)) { + return { + success: false, + error: { + code: "CONFLICT", + message: "A relation with this name or locale already exists", + }, + }; + } + return { + success: false, + error: { code: "RELATION_CREATE_ERROR", message: "Failed to create relation" }, + }; + } +} + +export async function handleRelationGet( + db: Kysely, + id: string, +): Promise> { + try { + const repo = new RelationRepository(db); + const relation = await repo.findById(id); + if (!relation) { + return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + } + return { success: true, data: { relation } }; + } catch { + return { + success: false, + error: { code: "RELATION_GET_ERROR", message: "Failed to get relation" }, + }; + } +} + +export async function handleRelationList( + db: Kysely, + opts: { locale?: string }, +): Promise> { + try { + const repo = new RelationRepository(db); + const relations = await repo.list(opts.locale); + return { success: true, data: { relations } }; + } catch { + return { + success: false, + error: { code: "RELATION_LIST_ERROR", message: "Failed to list relations" }, + }; + } +} + +export async function handleRelationUpdate( + db: Kysely, + id: string, + input: { parentLabel?: string; childLabel?: string }, +): Promise> { + try { + const repo = new RelationRepository(db); + const relation = await repo.update(id, input); + if (!relation) { + return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + } + return { success: true, data: { relation } }; + } catch { + return { + success: false, + error: { code: "RELATION_UPDATE_ERROR", message: "Failed to update relation" }, + }; + } +} + +export async function handleRelationDelete( + db: Kysely, + id: string, +): Promise> { + try { + const repo = new RelationRepository(db); + const deleted = await repo.delete(id); + if (!deleted) { + return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + } + return { success: true, data: { deleted: true } }; + } catch { + return { + success: false, + error: { code: "RELATION_DELETE_ERROR", message: "Failed to delete relation" }, + }; + } +} + +export async function handleRelationTranslations( + db: Kysely, + id: string, +): Promise< + ApiResult<{ + translationGroup: string; + translations: { + id: string; + name: string; + locale: string; + parentLabel: string; + childLabel: string; + }[]; + }> +> { + try { + const repo = new RelationRepository(db); + const relation = await repo.findById(id); + if (!relation) { + return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + } + const siblings = await repo.findTranslations(relation.translationGroup); + return { + success: true, + data: { + translationGroup: relation.translationGroup, + translations: siblings.map((r) => ({ + id: r.id, + name: r.name, + locale: r.locale, + parentLabel: r.parentLabel, + childLabel: r.childLabel, + })), + }, + }; + } catch { + return { + success: false, + error: { code: "RELATION_TRANSLATIONS_ERROR", message: "Failed to get translations" }, + }; + } +} + +export type EntryRef = { + id: string; + slug: string | null; + collection: string; + /** The actual locale of the resolved variant — see `pickVariant`. */ + locale: string | null; + sortOrder?: number; +}; + +/** Resolve a relation from an id OR its translation_group. */ +async function resolveRelation( + repo: RelationRepository, + idOrGroup: string, +): Promise { + const byId = await repo.findById(idOrGroup); + if (byId) return byId; + const group = await repo.findTranslations(idOrGroup); + return group[0] ?? null; +} + +/** + * Pick the locale variant matching `locale`, falling back to the first entry + * (lowest locale code). The fallback is intentional — an edge is keyed by + * `translation_group`, so a referenced entry that exists only in another locale + * is still a real reference — but the returned ref carries the variant's actual + * `locale` so callers never mistake a fallback for the requested locale. + */ +function pickVariant(items: ContentItem[], locale: string | null): ContentItem | undefined { + return items.find((i) => i.locale === locale) ?? items[0]; +} + +/** + * Resolve edge groups to loadable entries in `collection` at `locale`. + * Dangling groups (no surviving entry) are skipped — cleanup is a later slice. + * + * All groups are loaded in one batched query (chunked at `SQL_BATCH_SIZE`) + * rather than a `findTranslations` per edge, so a parent with N children costs + * a constant number of queries, not N+1. Edge order (the caller's `sort_order`) + * is preserved by iterating `edges`. + */ +async function resolveEntries( + content: ContentRepository, + collection: string, + edges: ContentReference[], + pick: (e: ContentReference) => string, + locale: string | null, +): Promise { + const groups = edges.map(pick); + const all = await content.findTranslationsForGroups(collection, groups); + + // Group the flat variant list by translation_group so each edge can pick its + // own locale variant. + const variantsByGroup = new Map(); + for (const item of all) { + if (item.translationGroup == null) continue; + const list = variantsByGroup.get(item.translationGroup); + if (list) list.push(item); + else variantsByGroup.set(item.translationGroup, [item]); + } + + const refs: EntryRef[] = []; + for (const edge of edges) { + const variants = variantsByGroup.get(pick(edge)); + if (!variants) continue; + const entry = pickVariant(variants, locale); + if (!entry) continue; + refs.push({ + id: entry.id, + slug: entry.slug, + collection, + locale: entry.locale, + sortOrder: edge.sortOrder, + }); + } + return refs; +} + +/** Pagination inputs for the edge read endpoints. */ +export type PageOptions = { limit?: number; cursor?: string }; + +export async function handleReferenceChildrenGet( + db: Kysely, + collection: string, + entryId: string, + relation: string, + page: PageOptions = {}, +): Promise> { + try { + const repo = new RelationRepository(db); + const content = new ContentRepository(db); + + const rel = await resolveRelation(repo, relation); + if (!rel) + return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + if (collection !== rel.parentCollection) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Entry is not the parent side of this relation", + }, + }; + } + + const entry = await content.findByIdOrSlug(collection, entryId); + if (!entry?.translationGroup) { + return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; + } + + const edges = await repo.getChildrenPage(rel.translationGroup, entry.translationGroup, page); + const children = await resolveEntries( + content, + rel.childCollection, + edges.items, + (e) => e.childGroup, + entry.locale, + ); + return { success: true, data: { children, nextCursor: edges.nextCursor } }; + } catch (error) { + return referencesGetError(error); + } +} + +export async function handleReferenceChildrenSet( + db: Kysely, + collection: string, + entryId: string, + relation: string, + childIds: string[], +): Promise> { + try { + const repo = new RelationRepository(db); + const content = new ContentRepository(db); + + const rel = await resolveRelation(repo, relation); + if (!rel) + return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + if (collection !== rel.parentCollection) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Entry is not the parent side of this relation", + }, + }; + } + + const entry = await content.findByIdOrSlug(collection, entryId); + if (!entry?.translationGroup) { + return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; + } + + // Resolve each child within the relation's child_collection. A child id + // that does not resolve there fails collection-agreement (invariant 3). + const childGroups: string[] = []; + for (const childId of childIds) { + const child = await content.findByIdOrSlug(rel.childCollection, childId); + if (!child?.translationGroup) { + return { + success: false, + error: { + code: "NOT_FOUND", + message: `Child entry '${childId}' not found in ${rel.childCollection}`, + }, + }; + } + childGroups.push(child.translationGroup); + } + + await repo.setChildren(rel.translationGroup, entry.translationGroup, childGroups); + + // Return the first page of the new set, mirroring the GET shape. + const edges = await repo.getChildrenPage(rel.translationGroup, entry.translationGroup); + const children = await resolveEntries( + content, + rel.childCollection, + edges.items, + (e) => e.childGroup, + entry.locale, + ); + return { success: true, data: { children, nextCursor: edges.nextCursor } }; + } catch { + return { + success: false, + error: { code: "REFERENCES_SET_ERROR", message: "Failed to set references" }, + }; + } +} + +export async function handleReferenceParentsGet( + db: Kysely, + collection: string, + entryId: string, + relation: string, + page: PageOptions = {}, +): Promise> { + try { + const repo = new RelationRepository(db); + const content = new ContentRepository(db); + + const rel = await resolveRelation(repo, relation); + if (!rel) + return { success: false, error: { code: "NOT_FOUND", message: "Relation not found" } }; + if (collection !== rel.childCollection) { + return { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Entry is not the child side of this relation", + }, + }; + } + + const entry = await content.findByIdOrSlug(collection, entryId); + if (!entry?.translationGroup) { + return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; + } + + const edges = await repo.getParentsPage(rel.translationGroup, entry.translationGroup, page); + const parents = await resolveEntries( + content, + rel.parentCollection, + edges.items, + (e) => e.parentGroup, + entry.locale, + ); + return { success: true, data: { parents, nextCursor: edges.nextCursor } }; + } catch (error) { + return referencesGetError(error); + } +} diff --git a/packages/core/src/api/schemas/index.ts b/packages/core/src/api/schemas/index.ts index eefefbe8e..1ca4579ac 100644 --- a/packages/core/src/api/schemas/index.ts +++ b/packages/core/src/api/schemas/index.ts @@ -16,3 +16,4 @@ export * from "./widgets.js"; export * from "./redirects.js"; export * from "./bylines.js"; export * from "./byline-fields.js"; +export * from "./relations.js"; diff --git a/packages/core/src/api/schemas/relations.ts b/packages/core/src/api/schemas/relations.ts new file mode 100644 index 000000000..1df9c3cc9 --- /dev/null +++ b/packages/core/src/api/schemas/relations.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; + +const slugPattern = /^[a-z][a-z0-9_]*$/; +const collectionSlug = z + .string() + .min(1) + .max(63) + .regex(slugPattern, "Invalid collection slug format"); + +export const createRelationBody = z + .object({ + name: z + .string() + .min(1) + .max(63) + .regex(slugPattern, "Name must be lowercase alphanumeric with underscores"), + parentCollection: collectionSlug, + childCollection: collectionSlug, + parentLabel: z.string().min(1).max(200), + childLabel: z.string().min(1).max(200), + locale: z.string().min(1).optional(), + translationOf: z.string().min(1).optional(), + }) + .meta({ id: "CreateRelationBody" }); + +export const updateRelationBody = z + .object({ + parentLabel: z.string().min(1).max(200).optional(), + childLabel: z.string().min(1).max(200).optional(), + }) + // Reject empty payloads: an update touching no field is a client mistake, not + // a successful no-op. Without this, `{}` validates and the handler returns 200 + // with the unchanged row, so a typo'd payload looks like it landed. + .refine((body) => body.parentLabel !== undefined || body.childLabel !== undefined, { + message: "At least one of parentLabel or childLabel is required", + }) + .meta({ id: "UpdateRelationBody" }); + +export const setReferenceChildrenBody = z + .object({ childIds: z.array(z.string().min(1)).max(1000) }) + .meta({ id: "SetReferenceChildrenBody" }); + +export const relationDefSchema = z + .object({ + id: z.string(), + name: z.string(), + parentCollection: z.string(), + childCollection: z.string(), + parentLabel: z.string(), + childLabel: z.string(), + locale: z.string(), + translationGroup: z.string(), + }) + .meta({ id: "RelationDef" }); + +export const relationListResponseSchema = z + .object({ relations: z.array(relationDefSchema) }) + .meta({ id: "RelationListResponse" }); + +export const relationResponseSchema = z + .object({ relation: relationDefSchema }) + .meta({ id: "RelationResponse" }); + +export const relationTranslationsSchema = z + .object({ + translationGroup: z.string(), + translations: z.array( + z.object({ + id: z.string(), + name: z.string(), + locale: z.string(), + parentLabel: z.string(), + childLabel: z.string(), + }), + ), + }) + .meta({ id: "RelationTranslations" }); + +export const entryRefSchema = z + .object({ + id: z.string(), + slug: z.string().nullable(), + collection: z.string(), + // The actual locale of the resolved variant. When no variant matches the + // requesting entry's locale, the ref falls back to another locale's row; + // this field makes that substitution explicit instead of silently + // presenting a wrong-locale entry under the requested context. + locale: z.string().nullable(), + sortOrder: z.number().int().optional(), + }) + .meta({ id: "ReferenceEntryRef" }); + +export const referenceChildrenResponseSchema = z + .object({ children: z.array(entryRefSchema), nextCursor: z.string().optional() }) + .meta({ id: "ReferenceChildrenResponse" }); + +export const referenceParentsResponseSchema = z + .object({ parents: z.array(entryRefSchema), nextCursor: z.string().optional() }) + .meta({ id: "ReferenceParentsResponse" }); diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.ts new file mode 100644 index 000000000..1e5c89818 --- /dev/null +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.ts @@ -0,0 +1,92 @@ +/** + * Content-entry reference children endpoint (parent side) + * + * GET /_emdash/api/content/:collection/:id/references/:relation/children + * POST /_emdash/api/content/:collection/:id/references/:relation/children + */ + +import type { APIRoute } from "astro"; + +import { requireOwnerPerm, requirePerm } from "#api/authorize.js"; +import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js"; +import { handleReferenceChildrenGet, handleReferenceChildrenSet } from "#api/handlers/relations.js"; +import { isParseError, parseBody, parseQuery } from "#api/parse.js"; +import { cursorPaginationQuery, setReferenceChildrenBody } from "#api/schemas.js"; +import { ContentRepository } from "#db/repositories/content.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + const { collection, id, relation } = params; + + const denied = requirePerm(user, "content:read"); + if (denied) return denied; + + if (!collection || !id || !relation) { + return apiError("VALIDATION_ERROR", "Collection, id, and relation required", 400); + } + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const query = parseQuery(new URL(request.url), cursorPaginationQuery); + if (isParseError(query)) return query; + + try { + const result = await handleReferenceChildrenGet(emdash.db, collection, id, relation, { + limit: query.limit, + cursor: query.cursor, + }); + return unwrapResult(result); + } catch (error) { + return handleError(error, "Failed to get references", "REFERENCES_GET_ERROR"); + } +}; + +export const POST: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + const { collection, id, relation } = params; + + if (!collection || !id || !relation) { + return apiError("VALIDATION_ERROR", "Collection, id, and relation required", 400); + } + + // Gate on the base edit capability BEFORE the existence lookup. Resolving the + // entry first would let a user with no edit permission distinguish real ids + // (403) from missing ids (404) — an existence oracle. Mirrors the taxonomy + // edge POST, which checks `content:edit_own` before fetching the entry. + const denied = requirePerm(user, "content:edit_own"); + if (denied) return denied; + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + // Resolve the parent entry to gate on its author (ownership-aware write). + const parent = await new ContentRepository(emdash.db).findByIdOrSlug(collection, id); + if (!parent) return apiError("NOT_FOUND", "Content not found", 404); + + const editDenied = requireOwnerPerm( + user, + parent.authorId ?? "", + "content:edit_own", + "content:edit_any", + ); + if (editDenied) return editDenied; + + try { + const body = await parseBody(request, setReferenceChildrenBody); + if (isParseError(body)) return body; + + const result = await handleReferenceChildrenSet( + emdash.db, + collection, + id, + relation, + body.childIds, + ); + return unwrapResult(result); + } catch (error) { + return handleError(error, "Failed to set references", "REFERENCES_SET_ERROR"); + } +}; diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/parents.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/parents.ts new file mode 100644 index 000000000..01de375c6 --- /dev/null +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/parents.ts @@ -0,0 +1,43 @@ +/** + * Content-entry reference parents endpoint (child side, read-only backlink) + * + * GET /_emdash/api/content/:collection/:id/references/:relation/parents + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js"; +import { handleReferenceParentsGet } from "#api/handlers/relations.js"; +import { isParseError, parseQuery } from "#api/parse.js"; +import { cursorPaginationQuery } from "#api/schemas.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + const { collection, id, relation } = params; + + const denied = requirePerm(user, "content:read"); + if (denied) return denied; + + if (!collection || !id || !relation) { + return apiError("VALIDATION_ERROR", "Collection, id, and relation required", 400); + } + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const query = parseQuery(new URL(request.url), cursorPaginationQuery); + if (isParseError(query)) return query; + + try { + const result = await handleReferenceParentsGet(emdash.db, collection, id, relation, { + limit: query.limit, + cursor: query.cursor, + }); + return unwrapResult(result); + } catch (error) { + return handleError(error, "Failed to get references", "REFERENCES_GET_ERROR"); + } +}; diff --git a/packages/core/src/astro/routes/api/relations/[id]/index.ts b/packages/core/src/astro/routes/api/relations/[id]/index.ts new file mode 100644 index 000000000..8c3cdd2b6 --- /dev/null +++ b/packages/core/src/astro/routes/api/relations/[id]/index.ts @@ -0,0 +1,84 @@ +/** + * Single relation definition endpoint + * + * GET /_emdash/api/relations/:id - Get a relation + * PATCH /_emdash/api/relations/:id - Update a relation's labels + * DELETE /_emdash/api/relations/:id - Delete a relation + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js"; +import { + handleRelationDelete, + handleRelationGet, + handleRelationUpdate, +} from "#api/handlers/relations.js"; +import { isParseError, parseBody } from "#api/parse.js"; +import { updateRelationBody } from "#api/schemas.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, locals }) => { + const { emdash, user } = locals; + const { id } = params; + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:read"); + if (denied) return denied; + + if (!id) return apiError("VALIDATION_ERROR", "Relation id required", 400); + + try { + const result = await handleRelationGet(emdash.db, id); + return unwrapResult(result); + } catch (error) { + return handleError(error, "Failed to get relation", "RELATION_GET_ERROR"); + } +}; + +export const PATCH: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + const { id } = params; + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:manage"); + if (denied) return denied; + + if (!id) return apiError("VALIDATION_ERROR", "Relation id required", 400); + + try { + const body = await parseBody(request, updateRelationBody); + if (isParseError(body)) return body; + + const result = await handleRelationUpdate(emdash.db, id, body); + return unwrapResult(result); + } catch (error) { + return handleError(error, "Failed to update relation", "RELATION_UPDATE_ERROR"); + } +}; + +export const DELETE: APIRoute = async ({ params, locals }) => { + const { emdash, user } = locals; + const { id } = params; + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:manage"); + if (denied) return denied; + + if (!id) return apiError("VALIDATION_ERROR", "Relation id required", 400); + + try { + const result = await handleRelationDelete(emdash.db, id); + return unwrapResult(result); + } catch (error) { + return handleError(error, "Failed to delete relation", "RELATION_DELETE_ERROR"); + } +}; diff --git a/packages/core/src/astro/routes/api/relations/[id]/translations.ts b/packages/core/src/astro/routes/api/relations/[id]/translations.ts new file mode 100644 index 000000000..f7e22ba88 --- /dev/null +++ b/packages/core/src/astro/routes/api/relations/[id]/translations.ts @@ -0,0 +1,33 @@ +/** + * Relation translations endpoint + * + * GET /_emdash/api/relations/:id/translations - List locale siblings of a relation + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js"; +import { handleRelationTranslations } from "#api/handlers/relations.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, locals }) => { + const { emdash, user } = locals; + const { id } = params; + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:read"); + if (denied) return denied; + + if (!id) return apiError("VALIDATION_ERROR", "Relation id required", 400); + + try { + const result = await handleRelationTranslations(emdash.db, id); + return unwrapResult(result); + } catch (error) { + return handleError(error, "Failed to get translations", "RELATION_TRANSLATIONS_ERROR"); + } +}; diff --git a/packages/core/src/astro/routes/api/relations/index.ts b/packages/core/src/astro/routes/api/relations/index.ts new file mode 100644 index 000000000..3bc3a01cb --- /dev/null +++ b/packages/core/src/astro/routes/api/relations/index.ts @@ -0,0 +1,56 @@ +/** + * Relation definitions endpoint + * + * GET /_emdash/api/relations[?locale=xx] - List relation definitions + * POST /_emdash/api/relations - Create a relation definition + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { handleError, requireDb, unwrapResult } from "#api/error.js"; +import { handleRelationCreate, handleRelationList } from "#api/handlers/relations.js"; +import { isParseError, parseBody, parseQuery } from "#api/parse.js"; +import { createRelationBody, localeFilterQuery } from "#api/schemas.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:read"); + if (denied) return denied; + + const query = parseQuery(new URL(request.url), localeFilterQuery); + if (isParseError(query)) return query; + + try { + const result = await handleRelationList(emdash.db, { locale: query.locale }); + return unwrapResult(result); + } catch (error) { + return handleError(error, "Failed to list relations", "RELATION_LIST_ERROR"); + } +}; + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:manage"); + if (denied) return denied; + + try { + const body = await parseBody(request, createRelationBody); + if (isParseError(body)) return body; + + const result = await handleRelationCreate(emdash.db, body); + return unwrapResult(result, 201); + } catch (error) { + return handleError(error, "Failed to create relation", "RELATION_CREATE_ERROR"); + } +}; diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 96e80915c..f26abd421 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -1,6 +1,7 @@ import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; +import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import { slugify } from "../../utils/slugify.js"; import type { Database } from "../types.js"; import { validateIdentifier } from "../validate.js"; @@ -1036,6 +1037,33 @@ export class ContentRepository { return result.rows.map((row) => this.mapRow(type, row)); } + /** + * Batch variant of {@link findTranslations}: every (non-deleted) locale + * variant for any of `translationGroups`, in one `WHERE translation_group IN + * (...)` query chunked at `SQL_BATCH_SIZE` for D1's bind-parameter limit. + * Lets callers resolve many edge groups without an N+1 per group. The caller + * groups the flat result by `translationGroup` itself. + */ + async findTranslationsForGroups( + type: string, + translationGroups: string[], + ): Promise { + if (translationGroups.length === 0) return []; + const tableName = getTableName(type); + + const items: ContentItem[] = []; + for (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) { + const result = await sql>` + SELECT * FROM ${sql.ref(tableName)} + WHERE translation_group IN (${sql.join(chunk)}) + AND deleted_at IS NULL + ORDER BY locale ASC + `.execute(this.db); + for (const row of result.rows) items.push(this.mapRow(type, row)); + } + return items; + } + /** * Publish the current draft * diff --git a/packages/core/src/database/repositories/relation.ts b/packages/core/src/database/repositories/relation.ts index 2401c2b1b..84fcd83dc 100644 --- a/packages/core/src/database/repositories/relation.ts +++ b/packages/core/src/database/repositories/relation.ts @@ -3,6 +3,7 @@ import { ulid } from "ulidx"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import type { Database, RelationTable, ContentReferenceTable } from "../types.js"; +import { decodeCursor, encodeCursor, type FindManyResult } from "./types.js"; export interface Relation { id: string; @@ -319,6 +320,56 @@ export class RelationRepository { return rows.map((row) => this.rowToReference(row)); } + /** + * Forward traversal, paginated: one page of a parent's children for a + * relation, ordered by `(sort_order, id)`. Use this on request paths — a + * parent's children are capped but still up to 1000, and an unbounded read + * scales poorly. Returns `{ items, nextCursor? }`; the cursor's order value is + * the row's `sort_order`. Default limit 50, max 100. + */ + async getChildrenPage( + relation: string, + parentGroup: string, + options: { limit?: number; cursor?: string } = {}, + ): Promise> { + const relationGroup = await this.resolveRelationGroup(relation); + if (!relationGroup) return { items: [] }; + + const limit = Math.min(options.limit || 50, 100); + + let query = this.db + .selectFrom("_emdash_content_references") + .selectAll() + .where("relation_group", "=", relationGroup) + .where("parent_group", "=", parentGroup); + + if (options.cursor) { + const decoded = decodeCursor(options.cursor); + const sortOrder = Number(decoded.orderValue); + query = query.where((eb) => + eb.or([ + eb("sort_order", ">", sortOrder), + eb.and([eb("sort_order", "=", sortOrder), eb("id", ">", decoded.id)]), + ]), + ); + } + + const rows = await query + .orderBy("sort_order", "asc") + .orderBy("id", "asc") + .limit(limit + 1) + .execute(); + + const hasMore = rows.length > limit; + const items = rows.slice(0, limit).map((row) => this.rowToReference(row)); + const result: FindManyResult = { items }; + const last = items.at(-1); + if (hasMore && last) { + result.nextCursor = encodeCursor(String(last.sortOrder), last.id); + } + return result; + } + /** Backlink traversal: the parents that reference a child for a relation. */ async getParents(relation: string, childGroup: string): Promise { const relationGroup = await this.resolveRelationGroup(relation); @@ -381,6 +432,50 @@ export class RelationRepository { .execute(); } + /** + * Backlink traversal, paginated: one page of the parents that reference a + * child for a relation, ordered by `id`. Unlike a parent's children, a + * child's backlinks are *unbounded* — one popular entry can be referenced by + * arbitrarily many parents — so this read must paginate. Returns + * `{ items, nextCursor? }`; the cursor's order value is the row `id`. Default + * limit 50, max 100. + */ + async getParentsPage( + relation: string, + childGroup: string, + options: { limit?: number; cursor?: string } = {}, + ): Promise> { + const relationGroup = await this.resolveRelationGroup(relation); + if (!relationGroup) return { items: [] }; + + const limit = Math.min(options.limit || 50, 100); + + let query = this.db + .selectFrom("_emdash_content_references") + .selectAll() + .where("relation_group", "=", relationGroup) + .where("child_group", "=", childGroup); + + if (options.cursor) { + const decoded = decodeCursor(options.cursor); + query = query.where("id", ">", decoded.id); + } + + const rows = await query + .orderBy("id", "asc") + .limit(limit + 1) + .execute(); + + const hasMore = rows.length > limit; + const items = rows.slice(0, limit).map((row) => this.rowToReference(row)); + const result: FindManyResult = { items }; + const last = items.at(-1); + if (hasMore && last) { + result.nextCursor = encodeCursor(last.id, last.id); + } + return result; + } + /** * Remove every edge where `group` is the parent OR the child — i.e. ensure no * orphaned reference edges survive when a content entry is deleted. The diff --git a/packages/core/tests/integration/api/references-edges.test.ts b/packages/core/tests/integration/api/references-edges.test.ts new file mode 100644 index 000000000..252d9fbdd --- /dev/null +++ b/packages/core/tests/integration/api/references-edges.test.ts @@ -0,0 +1,294 @@ +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import type { APIContext } from "astro"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + handleReferenceChildrenGet, + handleReferenceChildrenSet, + handleReferenceParentsGet, +} from "../../../src/api/handlers/relations.js"; +import { + GET as getChildren, + POST as setChildren, +} from "../../../src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { RelationRepository } from "../../../src/database/repositories/relation.js"; +import { + describeEachDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +function edgeCtx( + db: unknown, + params: { collection: string; id: string; relation: string }, + user: { id: string; role: RoleLevel }, + init?: { method?: string; body?: unknown }, +): APIContext { + const url = new URL( + `http://localhost/_emdash/api/content/${params.collection}/${params.id}/references/${params.relation}/children`, + ); + const request = new Request(url, { + method: init?.method ?? "GET", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: init?.body ? JSON.stringify(init.body) : undefined, + }); + return { params, url, request, locals: { emdash: { db }, user } } as unknown as APIContext; +} + +// setupForDialectWithCollections registers two collections: "post" and "page". +describeEachDialect("reference children handlers", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + afterEach(async () => { + await teardownForDialect(ctx); + }); + + async function makeRelation() { + // post (parent) -> page (child) + const repo = new RelationRepository(ctx.db); + return repo.create({ + name: "related_pages", + parentCollection: "post", + childCollection: "page", + parentLabel: "Post", + childLabel: "Related page", + }); + } + + it("set then get returns resolved child entries in order", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ type: "page", slug: "a", data: { title: "A" } }); + const b = await content.create({ type: "page", slug: "b", data: { title: "B" } }); + + const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id, b.id]); + expect(set.success).toBe(true); + if (!set.success) return; + expect(set.data.children.map((c) => c.slug)).toEqual(["a", "b"]); + expect(set.data.children.map((c) => c.sortOrder)).toEqual([0, 1]); + expect(set.data.children.every((c) => c.collection === "page")).toBe(true); + + const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id); + if (!get.success) return; + expect(get.data.children.map((c) => c.slug)).toEqual(["a", "b"]); + }); + + it("resolved children carry their actual locale", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ type: "page", slug: "a", data: { title: "A" } }); + + const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id]); + if (!set.success) return; + expect(set.data.children[0]?.locale).toBe("en"); + }); + + it("children GET paginates with a cursor", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ type: "page", slug: "a", data: { title: "A" } }); + const b = await content.create({ type: "page", slug: "b", data: { title: "B" } }); + const c = await content.create({ type: "page", slug: "c", data: { title: "C" } }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id, b.id, c.id]); + + const page1 = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, { limit: 2 }); + if (!page1.success) return; + expect(page1.data.children.map((ref) => ref.slug)).toEqual(["a", "b"]); + expect(page1.data.nextCursor).toBeDefined(); + + const page2 = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, { + limit: 2, + cursor: page1.data.nextCursor, + }); + if (!page2.success) return; + expect(page2.data.children.map((ref) => ref.slug)).toEqual(["c"]); + expect(page2.data.nextCursor).toBeUndefined(); + }); + + it("parents GET paginates over an unbounded backlink set", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const shared = await content.create({ type: "page", slug: "shared", data: { title: "S" } }); + // Three posts all reference the same page. + for (const slug of ["p1", "p2", "p3"]) { + const parent = await content.create({ type: "post", slug, data: { title: slug } }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [shared.id]); + } + + const page1 = await handleReferenceParentsGet(ctx.db, "page", shared.id, rel.id, { limit: 2 }); + if (!page1.success) return; + expect(page1.data.parents).toHaveLength(2); + expect(page1.data.nextCursor).toBeDefined(); + + const page2 = await handleReferenceParentsGet(ctx.db, "page", shared.id, rel.id, { + limit: 2, + cursor: page1.data.nextCursor, + }); + if (!page2.success) return; + expect(page2.data.parents).toHaveLength(1); + expect(page2.data.nextCursor).toBeUndefined(); + }); + + it("an invalid pagination cursor is INVALID_CURSOR", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const result = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, { + cursor: "!!!not-a-cursor!!!", + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("INVALID_CURSOR"); + }); + + it("unknown relation is NOT_FOUND", async () => { + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const result = await handleReferenceChildrenGet(ctx.db, "post", parent.id, "nope"); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("NOT_FOUND"); + }); + + it("entry on the wrong side (child collection) is VALIDATION_ERROR", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + // A "page" entry is the child side, not the parent — children route rejects it. + const page = await content.create({ type: "page", slug: "x", data: { title: "X" } }); + const result = await handleReferenceChildrenGet(ctx.db, "page", page.id, rel.id); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("a child whose collection != child_collection is rejected", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + // Another post can't be a child (child_collection is "page"). + const otherPost = await content.create({ type: "post", slug: "q", data: { title: "Q" } }); + const result = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [ + otherPost.id, + ]); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("NOT_FOUND"); + }); + + it("parents is the backlink view from the child side", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const child = await content.create({ type: "page", slug: "c", data: { title: "C" } }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [child.id]); + + const result = await handleReferenceParentsGet(ctx.db, "page", child.id, rel.id); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.parents.map((p) => p.slug)).toEqual(["p"]); + expect(result.data.parents.every((p) => p.collection === "post")).toBe(true); + }); + + it("parents rejects an entry on the parent side", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const result = await handleReferenceParentsGet(ctx.db, "post", parent.id, rel.id); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + }); +}); + +describeEachDialect("reference children route (auth + ownership)", (dialect) => { + let ctx: DialectTestContext; + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("GET requires content:read; POST gates on parent ownership", async () => { + const repo = new RelationRepository(ctx.db); + const rel = await repo.create({ + name: "related_pages", + parentCollection: "post", + childCollection: "page", + parentLabel: "Post", + childLabel: "Related page", + }); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ + type: "post", + slug: "p", + data: { title: "P" }, + authorId: "author-1", + }); + const child = await content.create({ type: "page", slug: "c", data: { title: "C" } }); + const params = { collection: "post", id: parent.id, relation: rel.id }; + + // A different AUTHOR cannot edit author-1's content. + const denied = await setChildren( + edgeCtx( + ctx.db, + params, + { id: "author-2", role: Role.AUTHOR as RoleLevel }, + { + method: "POST", + body: { childIds: [child.id] }, + }, + ), + ); + expect(denied.status).toBe(403); + + // The owner can. + const ok = await setChildren( + edgeCtx( + ctx.db, + params, + { id: "author-1", role: Role.AUTHOR as RoleLevel }, + { + method: "POST", + body: { childIds: [child.id] }, + }, + ), + ); + expect(ok.status).toBe(200); + + // Anyone with content:read can GET. + const read = await getChildren( + edgeCtx(ctx.db, params, { id: "sub", role: Role.SUBSCRIBER as RoleLevel }), + ); + expect(read.status).toBe(200); + }); + + it("POST gates the edit permission before the existence lookup (no oracle)", async () => { + const repo = new RelationRepository(ctx.db); + const rel = await repo.create({ + name: "related_pages", + parentCollection: "post", + childCollection: "page", + parentLabel: "Post", + childLabel: "Related page", + }); + // A SUBSCRIBER has no edit permission. Whether the parent id exists or not, + // they must get 403 — never a 404 that would reveal which ids are real. + const fake = edgeCtx( + ctx.db, + { collection: "post", id: "does-not-exist", relation: rel.id }, + { id: "sub", role: Role.SUBSCRIBER as RoleLevel }, + { method: "POST", body: { childIds: [] } }, + ); + const res = await setChildren(fake); + expect(res.status).toBe(403); + }); +}); diff --git a/packages/core/tests/integration/api/relations-handlers.test.ts b/packages/core/tests/integration/api/relations-handlers.test.ts new file mode 100644 index 000000000..7fcaf7ac8 --- /dev/null +++ b/packages/core/tests/integration/api/relations-handlers.test.ts @@ -0,0 +1,244 @@ +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import type { APIContext } from "astro"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + handleRelationCreate, + handleRelationGet, + handleRelationList, + handleRelationUpdate, + handleRelationDelete, + handleRelationTranslations, +} from "../../../src/api/handlers/relations.js"; +import { PATCH as patchRelation } from "../../../src/astro/routes/api/relations/[id]/index.js"; +import { + GET as listRelations, + POST as createRelation, +} from "../../../src/astro/routes/api/relations/index.js"; +import { + describeEachDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +// setupForDialectWithCollections registers two collections: "post" and "page". +// Relation create validates that both collections exist, so the handler tests +// use those real slugs rather than fabricated names. +const baseInput = { + name: "manages", + parentCollection: "post", + childCollection: "post", + parentLabel: "Manager", + childLabel: "Direct report", +}; + +describeEachDialect("relations definition handlers", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("create returns the new relation; get fetches it by id", async () => { + const created = await handleRelationCreate(ctx.db, { ...baseInput }); + expect(created.success).toBe(true); + if (!created.success) return; + expect(created.data.relation.name).toBe("manages"); + expect(created.data.relation.translationGroup).toBe(created.data.relation.id); + + const fetched = await handleRelationGet(ctx.db, created.data.relation.id); + expect(fetched.success).toBe(true); + if (!fetched.success) return; + expect(fetched.data.relation).toEqual(created.data.relation); + }); + + it("get returns NOT_FOUND for an unknown id", async () => { + const result = await handleRelationGet(ctx.db, "nope"); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("NOT_FOUND"); + }); + + it("list returns relations ordered by name, filtered by locale", async () => { + await handleRelationCreate(ctx.db, { ...baseInput, name: "writes", childCollection: "page" }); + await handleRelationCreate(ctx.db, { ...baseInput, name: "manages" }); + await handleRelationCreate(ctx.db, { ...baseInput, name: "supervises", locale: "fr" }); + + const all = await handleRelationList(ctx.db, {}); + expect(all.success).toBe(true); + if (!all.success) return; + expect(all.data.relations.map((r) => r.name)).toEqual(["manages", "supervises", "writes"]); + expect(all.data.relations.some((r) => r.locale === "fr")).toBe(true); + + const en = await handleRelationList(ctx.db, { locale: "en" }); + if (!en.success) return; + expect(en.data.relations.map((r) => r.name)).toEqual(["manages", "writes"]); + expect(en.data.relations.every((r) => r.locale === "en")).toBe(true); + expect(en.data.relations.some((r) => r.name === "supervises")).toBe(false); + }); + + it("update changes only labels; unknown id is NOT_FOUND", async () => { + const created = await handleRelationCreate(ctx.db, { ...baseInput }); + if (!created.success) return; + const updated = await handleRelationUpdate(ctx.db, created.data.relation.id, { + parentLabel: "Lead", + }); + expect(updated.success).toBe(true); + if (!updated.success) return; + expect(updated.data.relation.parentLabel).toBe("Lead"); + expect(updated.data.relation.name).toBe("manages"); + + const missing = await handleRelationUpdate(ctx.db, "nope", { parentLabel: "x" }); + expect(missing.success).toBe(false); + if (missing.success) return; + expect(missing.error.code).toBe("NOT_FOUND"); + }); + + it("delete removes the relation; unknown id is NOT_FOUND", async () => { + const created = await handleRelationCreate(ctx.db, { ...baseInput }); + if (!created.success) return; + const del = await handleRelationDelete(ctx.db, created.data.relation.id); + expect(del.success).toBe(true); + expect((await handleRelationGet(ctx.db, created.data.relation.id)).success).toBe(false); + + const missing = await handleRelationDelete(ctx.db, "nope"); + expect(missing.success).toBe(false); + if (missing.success) return; + expect(missing.error.code).toBe("NOT_FOUND"); + }); + + it("create against a non-existent collection is COLLECTION_NOT_FOUND", async () => { + const result = await handleRelationCreate(ctx.db, { ...baseInput, parentCollection: "ghost" }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("COLLECTION_NOT_FOUND"); + }); + + it("duplicate name+locale is CONFLICT, not a 500-shaped *_ERROR", async () => { + const first = await handleRelationCreate(ctx.db, { ...baseInput }); + expect(first.success).toBe(true); + const second = await handleRelationCreate(ctx.db, { ...baseInput }); + expect(second.success).toBe(false); + if (second.success) return; + expect(second.error.code).toBe("CONFLICT"); + }); + + it("a bogus translationOf is NOT_FOUND", async () => { + const result = await handleRelationCreate(ctx.db, { + ...baseInput, + locale: "fr", + translationOf: "does-not-exist", + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("NOT_FOUND"); + }); + + it("a second translation for an existing locale is CONFLICT", async () => { + const en = await handleRelationCreate(ctx.db, { ...baseInput }); + if (!en.success) return; + const fr = await handleRelationCreate(ctx.db, { + ...baseInput, + locale: "fr", + translationOf: en.data.relation.id, + }); + expect(fr.success).toBe(true); + // A second fr translation collides on (translation_group, locale). + const dup = await handleRelationCreate(ctx.db, { + ...baseInput, + locale: "fr", + translationOf: en.data.relation.id, + }); + expect(dup.success).toBe(false); + if (dup.success) return; + expect(dup.error.code).toBe("CONFLICT"); + }); + + it("translations returns every locale sibling for the group", async () => { + const en = await handleRelationCreate(ctx.db, { ...baseInput }); + if (!en.success) return; + await handleRelationCreate(ctx.db, { + ...baseInput, + locale: "fr", + parentLabel: "Responsable", + childLabel: "Subordonné", + translationOf: en.data.relation.id, + }); + + const result = await handleRelationTranslations(ctx.db, en.data.relation.id); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.translations.map((t) => t.locale)).toEqual(["en", "fr"]); + }); +}); + +function userAt(role: RoleLevel) { + return { id: "u", role }; +} + +function ctxFor( + db: unknown, + user: { id: string; role: RoleLevel }, + init?: { method?: string; body?: unknown }, +): APIContext { + const url = new URL("http://localhost/_emdash/api/relations"); + const request = new Request(url, { + method: init?.method ?? "GET", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: init?.body ? JSON.stringify(init.body) : undefined, + }); + return { params: {}, url, request, locals: { emdash: { db }, user } } as unknown as APIContext; +} + +describeEachDialect("relations routes (auth)", (dialect) => { + let ctx: DialectTestContext; + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("GET list requires schema:read (EDITOR)", async () => { + const denied = await listRelations(ctxFor(ctx.db, userAt(Role.AUTHOR as RoleLevel))); + expect(denied.status).toBe(403); + const ok = await listRelations(ctxFor(ctx.db, userAt(Role.EDITOR as RoleLevel))); + expect(ok.status).toBe(200); + }); + + it("POST create requires schema:manage (ADMIN)", async () => { + const body = { ...baseInput }; + const denied = await createRelation( + ctxFor(ctx.db, userAt(Role.EDITOR as RoleLevel), { method: "POST", body }), + ); + expect(denied.status).toBe(403); + const ok = await createRelation( + ctxFor(ctx.db, userAt(Role.ADMIN as RoleLevel), { method: "POST", body }), + ); + expect(ok.status).toBe(201); + }); + + it("PATCH with an empty body is a 400, not a silent 200 no-op", async () => { + const created = await handleRelationCreate(ctx.db, { ...baseInput }); + if (!created.success) return; + const id = created.data.relation.id; + + const url = new URL(`http://localhost/_emdash/api/relations/${id}`); + const request = new Request(url, { + method: "PATCH", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify({}), + }); + const res = await patchRelation({ + params: { id }, + url, + request, + locals: { emdash: { db: ctx.db }, user: userAt(Role.ADMIN as RoleLevel) }, + } as unknown as APIContext); + expect(res.status).toBe(400); + }); +}); From ee221006a67516b0df81f664366f50c3e248566b Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:02:21 +0300 Subject: [PATCH 2/2] Address adverserial review --- packages/core/src/api/handlers/relations.ts | 36 +- .../[id]/references/[relation]/children.ts | 13 +- .../[id]/references/[relation]/parents.ts | 13 +- .../core/src/database/repositories/content.ts | 84 ++++- .../src/database/repositories/relation.ts | 16 +- .../integration/api/references-edges.test.ts | 336 +++++++++++++++++- 6 files changed, 462 insertions(+), 36 deletions(-) diff --git a/packages/core/src/api/handlers/relations.ts b/packages/core/src/api/handlers/relations.ts index 3e3be1cab..1f9867855 100644 --- a/packages/core/src/api/handlers/relations.ts +++ b/packages/core/src/api/handlers/relations.ts @@ -248,6 +248,10 @@ function pickVariant(items: ContentItem[], locale: string | null): ContentItem | * rather than a `findTranslations` per edge, so a parent with N children costs * a constant number of queries, not N+1. Edge order (the caller's `sort_order`) * is preserved by iterating `edges`. + * + * `includeDrafts` is false for callers without `content:read_drafts`: the load + * is restricted to published entries so a draft/scheduled entry referenced by an + * edge is skipped exactly like a dangling one, never leaking its id/slug/locale. */ async function resolveEntries( content: ContentRepository, @@ -255,9 +259,12 @@ async function resolveEntries( edges: ContentReference[], pick: (e: ContentReference) => string, locale: string | null, + includeDrafts: boolean, ): Promise { const groups = edges.map(pick); - const all = await content.findTranslationsForGroups(collection, groups); + const all = await content.findTranslationsForGroups(collection, groups, { + publishedOnly: !includeDrafts, + }); // Group the flat variant list by translation_group so each edge can pick its // own locale variant. @@ -295,6 +302,7 @@ export async function handleReferenceChildrenGet( entryId: string, relation: string, page: PageOptions = {}, + includeDrafts = false, ): Promise> { try { const repo = new RelationRepository(db); @@ -314,7 +322,10 @@ export async function handleReferenceChildrenGet( } const entry = await content.findByIdOrSlug(collection, entryId); - if (!entry?.translationGroup) { + // A caller without draft access must not anchor on a non-published entry — + // return NOT_FOUND (not 403) so they can't probe draft ids by status code, + // mirroring the single-item content read. + if (!entry?.translationGroup || (!includeDrafts && entry.status !== "published")) { return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; } @@ -325,6 +336,7 @@ export async function handleReferenceChildrenGet( edges.items, (e) => e.childGroup, entry.locale, + includeDrafts, ); return { success: true, data: { children, nextCursor: edges.nextCursor } }; } catch (error) { @@ -361,11 +373,14 @@ export async function handleReferenceChildrenSet( return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; } - // Resolve each child within the relation's child_collection. A child id - // that does not resolve there fails collection-agreement (invariant 3). + // Resolve every child within the relation's child_collection in one batch + // (constant queries, not an N+1 of point lookups for a set up to 1000). A + // child id that does not resolve there fails collection-agreement + // (invariant 3); order is preserved by iterating the caller's `childIds`. + const resolvedChildren = await content.findManyByIdOrSlug(rel.childCollection, childIds); const childGroups: string[] = []; for (const childId of childIds) { - const child = await content.findByIdOrSlug(rel.childCollection, childId); + const child = resolvedChildren.get(childId); if (!child?.translationGroup) { return { success: false, @@ -380,7 +395,9 @@ export async function handleReferenceChildrenSet( await repo.setChildren(rel.translationGroup, entry.translationGroup, childGroups); - // Return the first page of the new set, mirroring the GET shape. + // Return the first page of the new set, mirroring the GET shape. The actor + // holds an edit permission (gated by the route), so draft children are + // included in the echo. const edges = await repo.getChildrenPage(rel.translationGroup, entry.translationGroup); const children = await resolveEntries( content, @@ -388,6 +405,7 @@ export async function handleReferenceChildrenSet( edges.items, (e) => e.childGroup, entry.locale, + true, ); return { success: true, data: { children, nextCursor: edges.nextCursor } }; } catch { @@ -404,6 +422,7 @@ export async function handleReferenceParentsGet( entryId: string, relation: string, page: PageOptions = {}, + includeDrafts = false, ): Promise> { try { const repo = new RelationRepository(db); @@ -423,7 +442,9 @@ export async function handleReferenceParentsGet( } const entry = await content.findByIdOrSlug(collection, entryId); - if (!entry?.translationGroup) { + // Same draft-anchor guard as the children read: a non-draft-reader anchoring + // on an unpublished entry gets NOT_FOUND, not its backlinks. + if (!entry?.translationGroup || (!includeDrafts && entry.status !== "published")) { return { success: false, error: { code: "NOT_FOUND", message: "Content entry not found" } }; } @@ -434,6 +455,7 @@ export async function handleReferenceParentsGet( edges.items, (e) => e.parentGroup, entry.locale, + includeDrafts, ); return { success: true, data: { parents, nextCursor: edges.nextCursor } }; } catch (error) { diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.ts index 1e5c89818..db04bdf35 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/children.ts @@ -5,6 +5,7 @@ * POST /_emdash/api/content/:collection/:id/references/:relation/children */ +import { hasPermission } from "@emdash-cms/auth"; import type { APIRoute } from "astro"; import { requireOwnerPerm, requirePerm } from "#api/authorize.js"; @@ -34,10 +35,14 @@ export const GET: APIRoute = async ({ params, request, locals }) => { if (isParseError(query)) return query; try { - const result = await handleReferenceChildrenGet(emdash.db, collection, id, relation, { - limit: query.limit, - cursor: query.cursor, - }); + const result = await handleReferenceChildrenGet( + emdash.db, + collection, + id, + relation, + { limit: query.limit, cursor: query.cursor }, + hasPermission(user, "content:read_drafts"), + ); return unwrapResult(result); } catch (error) { return handleError(error, "Failed to get references", "REFERENCES_GET_ERROR"); diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/parents.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/parents.ts index 01de375c6..ea1ab48b1 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/parents.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/references/[relation]/parents.ts @@ -4,6 +4,7 @@ * GET /_emdash/api/content/:collection/:id/references/:relation/parents */ +import { hasPermission } from "@emdash-cms/auth"; import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; @@ -32,10 +33,14 @@ export const GET: APIRoute = async ({ params, request, locals }) => { if (isParseError(query)) return query; try { - const result = await handleReferenceParentsGet(emdash.db, collection, id, relation, { - limit: query.limit, - cursor: query.cursor, - }); + const result = await handleReferenceParentsGet( + emdash.db, + collection, + id, + relation, + { limit: query.limit, cursor: query.cursor }, + hasPermission(user, "content:read_drafts"), + ); return unwrapResult(result); } catch (error) { return handleError(error, "Failed to get references", "REFERENCES_GET_ERROR"); diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index f26abd421..7afca039c 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -2,6 +2,7 @@ import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; +import { isMissingTableError } from "../../utils/db-errors.js"; import { slugify } from "../../utils/slugify.js"; import type { Database } from "../types.js"; import { validateIdentifier } from "../validate.js"; @@ -1043,25 +1044,98 @@ export class ContentRepository { * (...)` query chunked at `SQL_BATCH_SIZE` for D1's bind-parameter limit. * Lets callers resolve many edge groups without an N+1 per group. The caller * groups the flat result by `translationGroup` itself. + * + * `publishedOnly` restricts the result to `status = 'published'` — reference + * reads pass this for callers without `content:read_drafts` so draft/scheduled + * entries never leak through an edge traversal. + * + * A reference edge stores only a collection slug (no SQL FK), so the table may + * have been dropped since the edge was written. That is a tolerated dangling + * state, not an error: a missing table resolves to no rows, mirroring how the + * content read handlers treat `isMissingTableError`. */ async findTranslationsForGroups( type: string, translationGroups: string[], + options: { publishedOnly?: boolean } = {}, ): Promise { if (translationGroups.length === 0) return []; const tableName = getTableName(type); + const publishedFilter = options.publishedOnly ? sql`AND status = 'published'` : sql``; const items: ContentItem[] = []; - for (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) { - const result = await sql>` + try { + for (const chunk of chunks(translationGroups, SQL_BATCH_SIZE)) { + const result = await sql>` + SELECT * FROM ${sql.ref(tableName)} + WHERE translation_group IN (${sql.join(chunk)}) + AND deleted_at IS NULL + ${publishedFilter} + ORDER BY locale ASC + `.execute(this.db); + for (const row of result.rows) items.push(this.mapRow(type, row)); + } + } catch (error) { + if (isMissingTableError(error)) return []; + throw error; + } + return items; + } + + /** + * Batch variant of {@link findByIdOrSlug}: resolve many identifiers (each an + * id OR a slug) within `type` in a constant number of queries — one `WHERE id + * IN (...)` and one `WHERE slug IN (...)`, each chunked at `SQL_BATCH_SIZE`. + * Returns a map from the input identifier to its resolved item; identifiers + * that match nothing are absent. Used on write paths that accept a list of + * references, so a single request doesn't fan out to an N+1 of point lookups. + * + * Resolution mirrors {@link findByIdOrSlug}: a ULID-shaped identifier prefers + * the id match and falls back to slug; anything else prefers the slug match + * and falls back to id. Slug matches collapse to the lowest-locale variant + * (`ORDER BY locale ASC`), matching the slug-without-locale lookup. + */ + async findManyByIdOrSlug(type: string, identifiers: string[]): Promise> { + const resolved = new Map(); + const unique = [...new Set(identifiers)]; + if (unique.length === 0) return resolved; + + const tableName = getTableName(type); + const byId = new Map(); + const bySlug = new Map(); + + for (const chunk of chunks(unique, SQL_BATCH_SIZE)) { + const idRows = await sql>` + SELECT * FROM ${sql.ref(tableName)} + WHERE id IN (${sql.join(chunk)}) + AND deleted_at IS NULL + `.execute(this.db); + for (const row of idRows.rows) { + const item = this.mapRow(type, row); + byId.set(item.id, item); + } + + const slugRows = await sql>` SELECT * FROM ${sql.ref(tableName)} - WHERE translation_group IN (${sql.join(chunk)}) + WHERE slug IN (${sql.join(chunk)}) AND deleted_at IS NULL ORDER BY locale ASC `.execute(this.db); - for (const row of result.rows) items.push(this.mapRow(type, row)); + for (const row of slugRows.rows) { + const item = this.mapRow(type, row); + // First write wins → lowest locale, matching findBySlug without a locale. + if (item.slug != null && !bySlug.has(item.slug)) bySlug.set(item.slug, item); + } } - return items; + + for (const identifier of unique) { + const looksLikeUlid = ULID_PATTERN.test(identifier); + const item = looksLikeUlid + ? (byId.get(identifier) ?? bySlug.get(identifier)) + : (bySlug.get(identifier) ?? byId.get(identifier)); + if (item) resolved.set(identifier, item); + } + return resolved; } /** diff --git a/packages/core/src/database/repositories/relation.ts b/packages/core/src/database/repositories/relation.ts index 84fcd83dc..e03f59129 100644 --- a/packages/core/src/database/repositories/relation.ts +++ b/packages/core/src/database/repositories/relation.ts @@ -3,7 +3,7 @@ import { ulid } from "ulidx"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import type { Database, RelationTable, ContentReferenceTable } from "../types.js"; -import { decodeCursor, encodeCursor, type FindManyResult } from "./types.js"; +import { decodeCursor, encodeCursor, InvalidCursorError, type FindManyResult } from "./types.js"; export interface Relation { id: string; @@ -346,6 +346,11 @@ export class RelationRepository { if (options.cursor) { const decoded = decodeCursor(options.cursor); const sortOrder = Number(decoded.orderValue); + // `decodeCursor` only guarantees `orderValue` is a string; a hand-crafted + // cursor with a non-numeric order value would coerce to NaN and blow up at + // the driver bind as a 500. A bad cursor is a client error — surface it as + // INVALID_CURSOR (400). Server-issued cursors are always numeric here. + if (!Number.isFinite(sortOrder)) throw new InvalidCursorError(options.cursor); query = query.where((eb) => eb.or([ eb("sort_order", ">", sortOrder), @@ -397,6 +402,15 @@ export class RelationRepository { * relying on the insert's onConflict to silently drop them. Not wrapped in a * transaction: a crash between the delete and insert leaves the parent with * no children — acceptable for a replace-all, since a retry restores state. + * + * Concurrency: two simultaneous replace-all calls for the same (relation, + * parent) can interleave their deletes and inserts and merge into the union of + * both sets (a lost update — neither "replace" wins). This is non-corrupting — + * keyset pagination stays totally ordered via the `(sort_order, id)` tiebreak + * even with duplicate sort_orders — and a single client editing one parent's + * children serially never hits it. A D1-portable fix isn't available (no + * multi-statement transactions), so concurrent replace-all on one parent is + * unsupported by design rather than guarded here. */ async setChildren(relation: string, parentGroup: string, childGroups: string[]): Promise { const relationGroup = await this.resolveRelationGroup(relation); diff --git a/packages/core/tests/integration/api/references-edges.test.ts b/packages/core/tests/integration/api/references-edges.test.ts index 252d9fbdd..8ce2433f2 100644 --- a/packages/core/tests/integration/api/references-edges.test.ts +++ b/packages/core/tests/integration/api/references-edges.test.ts @@ -74,7 +74,7 @@ describeEachDialect("reference children handlers", (dialect) => { expect(set.data.children.map((c) => c.sortOrder)).toEqual([0, 1]); expect(set.data.children.every((c) => c.collection === "page")).toBe(true); - const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id); + const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true); if (!get.success) return; expect(get.data.children.map((c) => c.slug)).toEqual(["a", "b"]); }); @@ -99,15 +99,26 @@ describeEachDialect("reference children handlers", (dialect) => { const c = await content.create({ type: "page", slug: "c", data: { title: "C" } }); await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id, b.id, c.id]); - const page1 = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, { limit: 2 }); + const page1 = await handleReferenceChildrenGet( + ctx.db, + "post", + parent.id, + rel.id, + { limit: 2 }, + true, + ); if (!page1.success) return; expect(page1.data.children.map((ref) => ref.slug)).toEqual(["a", "b"]); expect(page1.data.nextCursor).toBeDefined(); - const page2 = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, { - limit: 2, - cursor: page1.data.nextCursor, - }); + const page2 = await handleReferenceChildrenGet( + ctx.db, + "post", + parent.id, + rel.id, + { limit: 2, cursor: page1.data.nextCursor }, + true, + ); if (!page2.success) return; expect(page2.data.children.map((ref) => ref.slug)).toEqual(["c"]); expect(page2.data.nextCursor).toBeUndefined(); @@ -123,15 +134,26 @@ describeEachDialect("reference children handlers", (dialect) => { await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [shared.id]); } - const page1 = await handleReferenceParentsGet(ctx.db, "page", shared.id, rel.id, { limit: 2 }); + const page1 = await handleReferenceParentsGet( + ctx.db, + "page", + shared.id, + rel.id, + { limit: 2 }, + true, + ); if (!page1.success) return; expect(page1.data.parents).toHaveLength(2); expect(page1.data.nextCursor).toBeDefined(); - const page2 = await handleReferenceParentsGet(ctx.db, "page", shared.id, rel.id, { - limit: 2, - cursor: page1.data.nextCursor, - }); + const page2 = await handleReferenceParentsGet( + ctx.db, + "page", + shared.id, + rel.id, + { limit: 2, cursor: page1.data.nextCursor }, + true, + ); if (!page2.success) return; expect(page2.data.parents).toHaveLength(1); expect(page2.data.nextCursor).toBeUndefined(); @@ -141,9 +163,14 @@ describeEachDialect("reference children handlers", (dialect) => { const rel = await makeRelation(); const content = new ContentRepository(ctx.db); const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); - const result = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, { - cursor: "!!!not-a-cursor!!!", - }); + const result = await handleReferenceChildrenGet( + ctx.db, + "post", + parent.id, + rel.id, + { cursor: "!!!not-a-cursor!!!" }, + true, + ); expect(result.success).toBe(false); if (result.success) return; expect(result.error.code).toBe("INVALID_CURSOR"); @@ -190,7 +217,7 @@ describeEachDialect("reference children handlers", (dialect) => { const child = await content.create({ type: "page", slug: "c", data: { title: "C" } }); await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [child.id]); - const result = await handleReferenceParentsGet(ctx.db, "page", child.id, rel.id); + const result = await handleReferenceParentsGet(ctx.db, "page", child.id, rel.id, {}, true); expect(result.success).toBe(true); if (!result.success) return; expect(result.data.parents.map((p) => p.slug)).toEqual(["p"]); @@ -206,6 +233,284 @@ describeEachDialect("reference children handlers", (dialect) => { if (result.success) return; expect(result.error.code).toBe("VALIDATION_ERROR"); }); + + it("set replaces the whole child set (set [a,b] then [c] yields [c])", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ type: "page", slug: "a", data: { title: "A" } }); + const b = await content.create({ type: "page", slug: "b", data: { title: "B" } }); + const c = await content.create({ type: "page", slug: "c", data: { title: "C" } }); + + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id, b.id]); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [c.id]); + + const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true); + if (!get.success) return; + expect(get.data.children.map((r) => r.slug)).toEqual(["c"]); + }); + + it("set with an empty list clears the child set", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ type: "page", slug: "a", data: { title: "A" } }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id]); + + const cleared = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, []); + if (!cleared.success) return; + expect(cleared.data.children).toEqual([]); + + const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true); + if (!get.success) return; + expect(get.data.children).toEqual([]); + }); + + it("set resolves a large mixed id/slug child set correctly (batched)", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const slugs = Array.from({ length: 60 }, (_, i) => `child-${i}`); + const ids: string[] = []; + for (const slug of slugs) { + const child = await content.create({ + type: "page", + slug, + data: { title: slug }, + status: "published", + }); + ids.push(child.id); + } + // Mix ids and slugs in the request to exercise both resolution paths. + const childIds = ids.map((id, i) => (i % 2 === 0 ? id : slugs[i]!)); + + const set = await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, childIds); + expect(set.success).toBe(true); + if (!set.success) return; + expect(set.data.children).toHaveLength(50); // first page + expect(set.data.nextCursor).toBeDefined(); + // sort_order is positional over the full deduped set. + expect(set.data.children.map((r) => r.sortOrder)).toEqual( + Array.from({ length: 50 }, (_, i) => i), + ); + }); + + it("a dangling edge (child deleted) is skipped, not surfaced as an error", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ type: "post", slug: "p", data: { title: "P" } }); + const a = await content.create({ + type: "page", + slug: "a", + data: { title: "A" }, + status: "published", + }); + const b = await content.create({ + type: "page", + slug: "b", + data: { title: "B" }, + status: "published", + }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [a.id, b.id]); + // Hard-delete the underlying row so the edge dangles. + await ctx.db + .deleteFrom("ec_page" as never) + .where("id" as never, "=", a.id) + .execute(); + + const get = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true); + expect(get.success).toBe(true); + if (!get.success) return; + expect(get.data.children.map((r) => r.slug)).toEqual(["b"]); + }); + + it("a hand-crafted cursor with a non-numeric order value is INVALID_CURSOR", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ + type: "post", + slug: "p", + data: { title: "P" }, + status: "published", + }); + // Base64 of {"orderValue":"x","id":"y"} — structurally valid, semantically bad. + const cursor = Buffer.from(JSON.stringify({ orderValue: "x", id: "y" })).toString("base64url"); + const result = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, { cursor }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("INVALID_CURSOR"); + }); +}); + +// The reference reads must honour the same draft-visibility boundary every other +// content read enforces: a caller without `content:read_drafts` may not see +// non-published entries — neither as resolved children/parents nor as the anchor. +describeEachDialect("reference reads: draft visibility", (dialect) => { + let ctx: DialectTestContext; + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + afterEach(async () => { + await teardownForDialect(ctx); + }); + + async function makeRelation() { + return new RelationRepository(ctx.db).create({ + name: "related_pages", + parentCollection: "post", + childCollection: "page", + parentLabel: "Post", + childLabel: "Related page", + }); + } + + it("hides a draft child from a caller without draft access", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ + type: "post", + slug: "p", + data: { title: "P" }, + status: "published", + }); + const published = await content.create({ + type: "page", + slug: "pub", + data: { title: "Pub" }, + status: "published", + }); + const draft = await content.create({ + type: "page", + slug: "secret-unpublished", + data: { title: "Draft" }, + status: "draft", + }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [published.id, draft.id]); + + // includeDrafts=false: only the published child is visible. + const subscriber = await handleReferenceChildrenGet( + ctx.db, + "post", + parent.id, + rel.id, + {}, + false, + ); + if (!subscriber.success) return; + expect(subscriber.data.children.map((r) => r.slug)).toEqual(["pub"]); + + // includeDrafts=true: both are visible. + const editor = await handleReferenceChildrenGet(ctx.db, "post", parent.id, rel.id, {}, true); + if (!editor.success) return; + expect( + editor.data.children.map((r) => r.slug).toSorted((a, b) => (a ?? "").localeCompare(b ?? "")), + ).toEqual(["pub", "secret-unpublished"]); + }); + + it("hides a draft parent (backlink) from a caller without draft access", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const child = await content.create({ + type: "page", + slug: "c", + data: { title: "C" }, + status: "published", + }); + const publishedParent = await content.create({ + type: "post", + slug: "pub-parent", + data: { title: "PP" }, + status: "published", + }); + const draftParent = await content.create({ + type: "post", + slug: "draft-parent", + data: { title: "DP" }, + status: "draft", + }); + await handleReferenceChildrenSet(ctx.db, "post", publishedParent.id, rel.id, [child.id]); + await handleReferenceChildrenSet(ctx.db, "post", draftParent.id, rel.id, [child.id]); + + const subscriber = await handleReferenceParentsGet(ctx.db, "page", child.id, rel.id, {}, false); + if (!subscriber.success) return; + expect(subscriber.data.parents.map((r) => r.slug)).toEqual(["pub-parent"]); + }); + + it("treats a draft anchor as NOT_FOUND for a caller without draft access", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const draftParent = await content.create({ + type: "post", + slug: "p", + data: { title: "P" }, + status: "draft", + }); + + const result = await handleReferenceChildrenGet( + ctx.db, + "post", + draftParent.id, + rel.id, + {}, + false, + ); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("NOT_FOUND"); + + // A caller with draft access can still anchor on it. + const withDrafts = await handleReferenceChildrenGet( + ctx.db, + "post", + draftParent.id, + rel.id, + {}, + true, + ); + expect(withDrafts.success).toBe(true); + }); + + it("route: a SUBSCRIBER cannot read a draft anchor's children (404)", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const draftParent = await content.create({ + type: "post", + slug: "p", + data: { title: "P" }, + status: "draft", + }); + const params = { collection: "post", id: draftParent.id, relation: rel.id }; + const res = await getChildren( + edgeCtx(ctx.db, params, { id: "sub", role: Role.SUBSCRIBER as RoleLevel }), + ); + expect(res.status).toBe(404); + }); + + it("route: a SUBSCRIBER does not see draft children of a published anchor", async () => { + const rel = await makeRelation(); + const content = new ContentRepository(ctx.db); + const parent = await content.create({ + type: "post", + slug: "p", + data: { title: "P" }, + status: "published", + }); + const draft = await content.create({ + type: "page", + slug: "secret-unpublished", + data: { title: "Draft" }, + status: "draft", + }); + await handleReferenceChildrenSet(ctx.db, "post", parent.id, rel.id, [draft.id]); + + const params = { collection: "post", id: parent.id, relation: rel.id }; + const res = await getChildren( + edgeCtx(ctx.db, params, { id: "sub", role: Role.SUBSCRIBER as RoleLevel }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { children: { slug: string }[] } }; + expect(body.data.children).toEqual([]); + }); }); describeEachDialect("reference children route (auth + ownership)", (dialect) => { @@ -232,6 +537,7 @@ describeEachDialect("reference children route (auth + ownership)", (dialect) => slug: "p", data: { title: "P" }, authorId: "author-1", + status: "published", }); const child = await content.create({ type: "page", slug: "c", data: { title: "C" } }); const params = { collection: "post", id: parent.id, relation: rel.id };