diff --git a/.changeset/collections-stage3-store.md b/.changeset/collections-stage3-store.md new file mode 100644 index 00000000..f217c74e --- /dev/null +++ b/.changeset/collections-stage3-store.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/core-ts": minor +"@cloudhal/api": minor +--- + +Collections stage 3 in the store and the API (RFC-0042 step 2, ADR-0044). `updateSite` checks the new collection settings when they change: `listFields` and `expiry.field` name fields of the collection (`unknown_field`), the expiry field is a date or datetime field (`invalid_expiry`), and a structured-data preset passes `checkStructuredData` (`structured_data_invalid`; warnings are not refused). Unlisting, dropping or retyping a list field, or dropping a select option, that `contentList` sections still filter or sort by is refused as `list_field_in_use` naming the pages, kept true by a guard on the site CAS. Page writes check changed `contentList` `where`/`orderBy` against the collection's list fields (`invalid_query`, with `sections//…` paths). Field operations follow: `rename_field` renames the field in `listFields`, `expiry`, structured-data sources and sections' queries in phase A's batch (and back on revert); `remove_field` refuses while any of them names the field; `change_field_type` refuses when the new type would break them. New `queryContentEntries` (and `applyFieldQuery`) filters and orders a collection's entries by list fields with the shared evaluator over resolved values. `GET /api/sites/:id/entries` takes `where` (JSON), `orderBy` (JSON), `limit` (1–100) and `cursor`; with `limit` it answers `{ entries, total, nextCursor? }`, without it the plain array as before. New error codes `invalid_query`, `structured_data_invalid`, `invalid_expiry` (422) and `list_field_in_use` (409). Sites without the new settings behave as before. diff --git a/packages/api/README.md b/packages/api/README.md index 0cfc8851..b62fb17e 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -12,7 +12,7 @@ The core routes are below; the feature routes (taxonomy, menus, media, comments, | `GET/PUT /api/sites[/:id]` | sites (PUT = full document, CAS on `rev`) | | `GET /api/sites/:id/pending` | what a publish would carry, as counts (Studio's badge beside Publish) | | `/api/sites/:id/pages[/:pageId]` | pages (POST creates and appends to `Site.pageIds` atomically; DELETE needs `?rev=`) | -| `/api/sites/:id/entries[/:entryId]` | content entries (`?collection=` filter); `fields` is checked against the collection's typed fields (RFC-0041), and `null` removes a key there | +| `/api/sites/:id/entries[/:entryId]` | content entries (`?collection=`, `locale`, `status`, `filter`); `fields` is checked against the collection's typed fields (RFC-0041), and `null` removes a key there. RFC-0042 §4: with `collection`, `where` (a JSON list of up to 4 `{field, op, value?/values?}`; ops by type, `value: "now"` for date/datetime fields) and `orderBy` (JSON `{field, direction: "asc"\|"desc"}`) filter and sort by the collection's `listFields`, e.g. `?collection=events&where=[{"field":"startsAt","op":"gte","value":"now"}]&orderBy={"field":"startsAt","direction":"asc"}` (URL-encoded). `limit` (1–100) pages the answer as `{entries, total, nextCursor?}`, continued with `cursor=`; without `limit` the answer stays the plain array | | `/api/sites/:id/collections[/:key]` | the collection registry; `PUT /:key {settings, rev}` (`?dryRun=1`) declares a collection or changes its settings, typed `fields` included; `DELETE /:key?rev=` | | `/api/sites/:id/collections/:key/fields/operations` | RFC-0041 §6 field operations (`site:settings` and `content:write`): `POST {op, …, dryRun?}` with `op` one of `rename_field {from, to}`, `change_field_type {key, type, options?, multiple?, onInvalid?: "clear"}`, `set_field_localized {key, localized}`, `remove_field {key}` → `{operationId, done, report, rewritten, skipped}` (a dry run writes nothing; `done: false` when the 2,000-entry / 20 s budget ran out, then `POST ?continue=`); `GET /:operationId` → state and pending entries; `POST /:operationId/revert` (again while `done: false`). Only the draft graph changes | | `/api/sites/:id/snapshots` | create (`{label, publish}`), list, `:id/publish`, `:id/rollback`, `:a/diff/:b` | @@ -37,6 +37,16 @@ Typed fields (RFC-0041) answer with every problem and its path in `details.issue | `field_values_unconvertible` | 422 | a type change would drop values the conversion table cannot convert, without `onInvalid: "clear"` (`details.report` is the dry run) | | `field_operation_incomplete` | 409 | reverting an operation that has not finished, or was already reverted | +Collections stage 3 (RFC-0042) answers the same way (`details.issues`, slash paths): + +| Code | Status | When | +|---|---|---| +| `invalid_query` | 422 | an entry list's `where`/`orderBy`/`limit`/`cursor`, or a `contentList` section's (`sections/2/where/0/value`), names an unlisted field, an operator or value its type does not take, or breaks a bound | +| `unknown_field` | 422 | `listFields` or `expiry.field` names no field of the collection | +| `invalid_expiry` | 422 | `expiry.field` is not a date or datetime field | +| `structured_data_invalid` | 422 | a `structuredData` preset that does not fit the type's table or the collection's fields (warnings are not refused) | +| `list_field_in_use` | 409 | unlisting, dropping or retyping a list field (or dropping a select option) that `contentList` sections still use (`details.pages`); `remove_field` of a field named by `listFields`, `expiry` or `structuredData`; a type change that would break those sections. `rename_field` renames those references instead | + ```ts import { createApi } from "@cloudhal/api"; import { createAuth } from "@cloudhal/api/auth"; diff --git a/packages/api/src/entry-list.ts b/packages/api/src/entry-list.ts new file mode 100644 index 00000000..c06e5c61 --- /dev/null +++ b/packages/api/src/entry-list.ts @@ -0,0 +1,105 @@ +/** + * RFC-0042 §4: the entry list's field query in a query string. `where` is a JSON list of + * conditions and `orderBy` a JSON object (the contract's `FieldCondition` / `FieldOrder`), `limit` + * pages (1–100) and `cursor` continues. Without `limit` the answer stays today's unpaged array; + * with it, `{ entries, total, nextCursor? }`. Problems answer `422 invalid_query` with paths. + */ +import type { ContentEntry, FieldCondition, FieldOrder } from "@cloudhal/core-contracts"; +import { + type FieldEntryListQuery, + InvariantError, + queryContentEntries, + type SiteGraphStore, +} from "@cloudhal/core-ts"; + +export interface FieldListParams { + where?: string | undefined; + orderBy?: string | undefined; + limit?: string | undefined; + cursor?: string | undefined; +} + +export interface EntryPage { + entries: ContentEntry[]; + total: number; + nextCursor?: string; +} + +const invalid = (path: string, message: string): never => { + throw new InvariantError("invalid_query", `${path}: ${message}`, { + issues: [{ path, code: "invalid_query", message }], + }); +}; + +function json(raw: string, path: string): unknown { + try { + return JSON.parse(raw); + } catch { + return invalid(path, "not valid JSON"); + } +} + +/** An opaque cursor: the offset of the next page. */ +const encodeCursor = (offset: number) => btoa(`o:${offset}`).replace(/=+$/, ""); +function decodeCursor(raw: string): number { + let text = ""; + try { + text = atob(raw); + } catch { + // falls through to the refusal below + } + const m = /^o:(\d{1,9})$/.exec(text); + return m ? Number(m[1]) : invalid("cursor", "not a cursor this list returned"); +} + +/** True when the request uses any of the field-query parameters. */ +export const usesFieldQuery = (p: FieldListParams): boolean => + p.where !== undefined || + p.orderBy !== undefined || + p.limit !== undefined || + p.cursor !== undefined; + +function parseParams(p: FieldListParams) { + const where = p.where === undefined ? undefined : json(p.where, "where"); + if (where !== undefined && !Array.isArray(where)) invalid("where", "a JSON list of conditions"); + const orderBy = p.orderBy === undefined ? undefined : json(p.orderBy, "orderBy"); + if ( + orderBy !== undefined && + (typeof orderBy !== "object" || orderBy === null || Array.isArray(orderBy)) + ) + invalid("orderBy", 'a JSON object such as {"field":"startsAt","direction":"asc"}'); + const limit = + p.limit === undefined ? undefined : /^\d{1,4}$/.test(p.limit) ? Number(p.limit) : Number.NaN; + if (p.cursor !== undefined && limit === undefined) invalid("cursor", "a cursor needs limit"); + const offset = p.cursor === undefined ? undefined : decodeCursor(p.cursor); + return { + where: where as FieldCondition[] | undefined, + orderBy: orderBy as FieldOrder | undefined, + limit: limit === undefined ? undefined : Number.isNaN(limit) ? -1 : limit, + offset, + }; +} + +/** The entry list with a field query: an array without `limit`, a page with it. */ +export async function listEntriesByField( + store: SiteGraphStore, + siteId: string, + base: Omit, + params: FieldListParams, +): Promise { + const q = parseParams(params); + const result = await queryContentEntries(store, siteId, { + ...base, + ...(q.where ? { where: q.where } : {}), + ...(q.orderBy ? { orderBy: q.orderBy } : {}), + ...(q.limit !== undefined ? { limit: q.limit } : {}), + ...(q.offset !== undefined ? { offset: q.offset } : {}), + }); + if (q.limit === undefined) return result.entries; + const next = (q.offset ?? 0) + result.entries.length; + return { + entries: result.entries, + total: result.total, + ...(next < result.total ? { nextCursor: encodeCursor(next) } : {}), + }; +} diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts index cdaf86e0..8f040fe2 100644 --- a/packages/api/src/errors.ts +++ b/packages/api/src/errors.ts @@ -85,6 +85,12 @@ export const INVARIANT_STATUS: Record = new Hono() const filter = c.req.query("filter") || undefined; const locale = c.req.query("locale") || undefined; const status = statusList(c.req.query("status")); + const base = { + ...(collection ? { collection } : {}), + ...(filter ? { filter } : {}), + ...(locale ? { locale } : {}), + ...(status ? { status } : {}), + }; + // RFC-0042 §4: `where`/`orderBy` by list fields, `limit`/`cursor` paging (`422 invalid_query`). + const fieldQuery = { + where: c.req.query("where"), + orderBy: c.req.query("orderBy"), + limit: c.req.query("limit"), + cursor: c.req.query("cursor"), + }; + if (usesFieldQuery(fieldQuery)) + return c.json( + await listEntriesByField( + c.get("store"), + param(c, "siteId"), + { ...base, now: c.get("now")().getTime() }, + fieldQuery, + ), + ); // `filter` is the RFC-0002 §5 language; a FilterError maps to 400 with suggestions. - return c.json( - await c.get("store").listContentEntries(param(c, "siteId"), { - ...(collection ? { collection } : {}), - ...(filter ? { filter } : {}), - ...(locale ? { locale } : {}), - ...(status ? { status } : {}), - }), - ); + return c.json(await c.get("store").listContentEntries(param(c, "siteId"), base)); }) .post("/", async (c) => { const siteId = param(c, "siteId"); diff --git a/packages/api/src/testing-listings.ts b/packages/api/src/testing-listings.ts new file mode 100644 index 00000000..3819b737 --- /dev/null +++ b/packages/api/src/testing-listings.ts @@ -0,0 +1,244 @@ +/** + * Shared API suite for RFC-0042 step 2: `PUT /collections/:key` checks list fields, the expiry + * field and the structured-data preset, and refuses dropping a list field sections use (409); + * page writes check `contentList` `where`/`orderBy` (422); `GET /entries` takes `where`, + * `orderBy`, `limit` and `cursor` with the injected clock as `now`, and answers as before without + * them. Runs against any SiteGraphStore backend, like `testing.ts`. + */ +import type { CollectionSettings, FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { type SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { createApi } from "./app.ts"; +import type { ApiStoreFactory } from "./testing.ts"; + +const human = { kind: "human" as const, id: "user_apitest00001", name: "Tester" }; +type Json = Record; +const L = (label: string) => ({ en: { label } }); + +const FIELDS: [FieldDefinition, ...FieldDefinition[]] = [ + { key: "startsAt", type: "datetime", labels: L("Starts"), localized: false }, + { key: "day", type: "date", labels: L("Day"), localized: false }, + { key: "city", type: "text", labels: L("City"), localized: false }, + { key: "price", type: "number", labels: L("Price"), localized: false }, +]; +const EVENTS: CollectionSettings = { + labels: { en: { singular: "Event", plural: "Events" } }, + fields: FIELDS, + listFields: ["startsAt", "day", "price"], + expiry: { field: "startsAt", action: "unlist" }, + structuredData: { + type: "Event", + properties: { + startDate: { kind: "field", field: "startsAt" }, + "location.name": { kind: "field", field: "city" }, + "location.address.addressLocality": { kind: "field", field: "city" }, + }, + }, +}; +/** 2026-11-05 23:00 in Istanbul, 2026-11-06 10:00 in Kiritimati. */ +const NOW = new Date("2026-11-05T20:00:00Z"); +const at = (iso: string) => ({ at: iso, timeZone: "Europe/Istanbul" }); + +export function describeListingsApi(label: string, factory: ApiStoreFactory): void { + describe(`API collections stage 3: ${label}`, () => { + let store: SiteGraphStore; + let siteId: string; + let app: ReturnType; + const headers = { "content-type": "application/json", "x-test-user": "1" }; + const call = async (path: string, init: RequestInit = {}) => { + const res = await app.request(path, { headers, ...init }); + return { status: res.status, body: (await res.json()) as Json }; + }; + const site = async () => (await call(`/api/sites/${siteId}`)).body as unknown as Site; + const put = async (settings: unknown) => + call(`/api/sites/${siteId}/collections/events`, { + method: "PUT", + body: JSON.stringify({ settings, rev: (await site()).rev }), + }); + const entry = (slug: string, fields: Json) => + call(`/api/sites/${siteId}/entries`, { + method: "POST", + body: JSON.stringify({ + collection: "events", + slug, + locale: "en", + status: "draft", + title: slug, + body: { format: "markdown", value: "Body" }, + fields, + tags: [], + }), + }); + const page = (sections: unknown[]) => + call(`/api/sites/${siteId}/pages`, { + method: "POST", + body: JSON.stringify({ + path: "/upcoming", + title: "Upcoming", + locale: "en", + status: "draft", + sections, + }), + }); + const list = (query: Record) => { + const q = new URLSearchParams({ collection: "events" }); + for (const [k, v] of Object.entries(query)) + q.set(k, typeof v === "string" ? v : JSON.stringify(v)); + return call(`/api/sites/${siteId}/entries?${q}`); + }; + const slugs = (body: unknown) => + (Array.isArray(body) ? body : (body as { entries: Json[] }).entries).map((e) => e.slug); + const issues = (body: Json) => + ((body.details as { issues?: { path: string; code: string }[] }).issues ?? []).map( + (i) => `${i.path} ${i.code}`, + ); + + beforeEach(async () => { + store = await factory.create(); + siteId = (await seedStarterSite(store, { name: "Stage 3", slug: "stage3" })).id; + app = createApi({ + store, + now: () => NOW, + resolveActor: async (c) => (c.req.header("x-test-user") ? human : undefined), + }); + }); + + afterAll(async () => { + await factory.dispose?.(); + }); + + async function seeded() { + expect((await put(EVENTS)).status).toBe(200); + for (const [slug, fields] of [ + ["a", { startsAt: at("2026-11-05T16:30:00Z"), day: "2026-11-05", price: 10 }], + ["b", { startsAt: at("2026-11-10T09:00:00Z"), day: "2026-11-10", price: 5 }], + ["c", { price: 20 }], + ] as const) + expect((await entry(slug, fields)).status).toBe(201); + } + + it("PUT /collections checks list fields, expiry and the preset", async () => { + const unknown = await put({ ...EVENTS, listFields: ["nope"] }); + expect([unknown.status, unknown.body.error]).toEqual([422, "unknown_field"]); + expect(issues(unknown.body)).toEqual(["collections/events/listFields/0 unknown_field"]); + const expiry = await put({ ...EVENTS, expiry: { field: "city", action: "hide" } }); + expect([expiry.status, expiry.body.error]).toEqual([422, "invalid_expiry"]); + const preset = await put({ + ...EVENTS, + structuredData: { + type: "Event", + properties: { startDate: { kind: "field", field: "price" } }, + }, + }); + expect([preset.status, preset.body.error]).toEqual([422, "structured_data_invalid"]); + expect(issues(preset.body)).toContain( + "collections/events/structuredData/properties/startDate/field source_mismatch", + ); + expect((await put(EVENTS)).status).toBe(200); + }); + + it("page writes check where/orderBy; list fields in use are kept (409)", async () => { + expect((await put(EVENTS)).status).toBe(200); + const section = { + id: "sec_upcoming0001", + kind: "contentList", + collection: "events", + limit: 6, + sort: "newest", + }; + const bad = await page([{ ...section, where: [{ field: "city", op: "eq", value: "x" }] }]); + expect([bad.status, bad.body.error]).toEqual([422, "invalid_query"]); + expect(issues(bad.body)).toEqual(["sections/0/where/0/field invalid_query"]); + const good = await page([ + { + ...section, + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, + }, + ]); + expect(good.status, JSON.stringify(good.body)).toBe(201); + const unlisted = await put({ ...EVENTS, listFields: ["day", "price"] }); + expect([unlisted.status, unlisted.body.error]).toEqual([409, "list_field_in_use"]); + expect((unlisted.body.details as { pages: Json[] }).pages).toMatchObject([ + { path: "/upcoming" }, + ]); + }); + + it("GET /entries without the new parameters answers exactly as before", async () => { + await seeded(); + const plain = await call(`/api/sites/${siteId}/entries?collection=events`); + expect(plain.body).toEqual( + JSON.parse( + JSON.stringify(await store.listContentEntries(siteId, { collection: "events" })), + ), + ); + }); + + it("GET /entries filters and sorts by list fields, with the clock as now", async () => { + await seeded(); + const upcoming = await list({ where: [{ field: "startsAt", op: "gte", value: "now" }] }); + expect([upcoming.status, slugs(upcoming.body)]).toEqual([200, ["b"]]); + const byPrice = await list({ orderBy: { field: "price", direction: "desc" } }); + expect(slugs(byPrice.body)).toEqual(["c", "a", "b"]); + const byStart = await list({ orderBy: { field: "startsAt", direction: "asc" } }); + expect(slugs(byStart.body)).toEqual(["a", "b", "c"]); + // `now` on a date field is today in the site's zone. + const today = { where: [{ field: "day", op: "gte", value: "now" }] }; + expect(slugs((await list(today)).body).sort()).toEqual(["a", "b"]); + const s = await site(); + const moved = await call(`/api/sites/${siteId}`, { + method: "PUT", + body: JSON.stringify({ ...s, timeZone: "Pacific/Kiritimati" }), + }); + expect(moved.status, JSON.stringify(moved.body)).toBe(200); + expect(slugs((await list(today)).body)).toEqual(["b"]); + }); + + it("GET /entries pages with limit and cursor", async () => { + await seeded(); + const orderBy = { field: "startsAt", direction: "asc" }; + const first = await list({ orderBy, limit: "2" }); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ total: 3 }); + expect(slugs(first.body)).toEqual(["a", "b"]); + const cursor = first.body.nextCursor as string; + expect(typeof cursor).toBe("string"); + const second = await list({ orderBy, limit: "2", cursor }); + expect(slugs(second.body)).toEqual(["c"]); + expect(second.body.nextCursor).toBeUndefined(); + // Paging alone keeps the store's order. + const unordered = await list({ limit: "1" }); + expect(unordered.body).toMatchObject({ total: 3 }); + }); + + it("GET /entries refuses invalid queries as 422 invalid_query", async () => { + await seeded(); + const cases: [Record, string][] = [ + [{ where: "[{" }, "where invalid_query"], + [{ where: { field: "price" } }, "where invalid_query"], + [{ where: [{ field: "city", op: "eq", value: "x" }] }, "where/0/field invalid_query"], + [{ where: [{ field: "price", op: "gt", value: "ten" }] }, "where/0/value invalid_query"], + [{ orderBy: { field: "city", direction: "asc" } }, "orderBy/field invalid_query"], + [{ limit: "0" }, "limit invalid_query"], + [{ limit: "lots" }, "limit invalid_query"], + [{ cursor: "abc" }, "cursor invalid_query"], + [{ limit: "2", cursor: "%%%" }, "cursor invalid_query"], + ]; + for (const [query, expected] of cases) { + const r = await list(query); + expect([r.status, r.body.error, issues(r.body)], JSON.stringify(query)).toEqual([ + 422, + "invalid_query", + [expected], + ]); + } + const noCollection = await call( + `/api/sites/${siteId}/entries?where=${encodeURIComponent('[{"field":"price","op":"set"}]')}`, + ); + expect([noCollection.status, issues(noCollection.body)]).toEqual([ + 422, + ["collection invalid_query"], + ]); + }); + }); +} diff --git a/packages/api/test/listings-api.test.ts b/packages/api/test/listings-api.test.ts new file mode 100644 index 00000000..689eb614 --- /dev/null +++ b/packages/api/test/listings-api.test.ts @@ -0,0 +1,32 @@ +import { SiteGraphStore } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { D1Driver } from "@cloudhal/storage/d1"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { createMiniflareD1, createTableReset } from "@cloudhal/storage/testing"; +import { describeListingsApi } from "../src/testing-listings.ts"; + +describeListingsApi("better-sqlite3", { + async create() { + const store = new SiteGraphStore({ + sql: await BetterSqlite3Driver.open(":memory:"), + blobs: new MemoryBlobStore(), + }); + await store.init(); + return store; + }, +}); + +let d1: Awaited> | undefined; +const reset = createTableReset(); +describeListingsApi("Cloudflare D1 (Miniflare)", { + async create() { + d1 ??= await createMiniflareD1(); + const sql = new D1Driver(d1.db); + const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore() }); + await reset(sql, () => store.init()); + return store; + }, + async dispose() { + await d1?.dispose(); + }, +}); diff --git a/packages/core-ts/src/entry-query.ts b/packages/core-ts/src/entry-query.ts new file mode 100644 index 00000000..22fdece0 --- /dev/null +++ b/packages/core-ts/src/entry-query.ts @@ -0,0 +1,142 @@ +/** + * RFC-0042 §4 "API and MCP": a collection's entries filtered and sorted by its list fields, over + * the store's collection prefilter (`listContentEntries`: collection, locale, status, the RFC-0002 + * term filter). Field conditions run in memory with the shared evaluator (`fields/query.ts`) on + * resolved values (a translation reads its original's non-localized ones), so SQL and memory + * cannot drift. Ties fall back to the collection's `sort`, then `id`; entries without a value sort + * last. `now` is injected (epoch ms), never read from a clock here. + */ +import type { ContentEntry, FieldCondition, FieldOrder } from "@cloudhal/core-contracts"; +import { InvariantError, NotFoundError } from "./errors.ts"; +import { + compareByField, + type FieldQueryIssue, + type FieldsOriginal, + matchesFieldQuery, + type ParsedFieldQuery, + parseFieldQuery, + type ResolvedFields, + resolveFields, +} from "./fields/index.ts"; +import type { SiteGraphStore } from "./store.ts"; +import type { EntryQuery } from "./taxonomy.ts"; + +/** At most this many entries per page (RFC-0042 §4, the theme API's bound). */ +export const ENTRY_PAGE_LIMIT = 100; + +export interface FieldEntryListQuery extends EntryQuery { + readonly where?: readonly FieldCondition[] | undefined; + readonly orderBy?: FieldOrder | undefined; + /** Page size (1–100); absent: every match. */ + readonly limit?: number | undefined; + readonly offset?: number | undefined; + /** The instant `now` compares with (epoch ms). */ + readonly now: number; +} + +export interface FieldEntryList { + readonly entries: ContentEntry[]; + /** Matches before paging. */ + readonly total: number; +} + +const refuse = (issues: FieldQueryIssue[]): never => { + const shown = issues + .slice(0, 5) + .map((i) => `${i.path}: ${i.message}`) + .join("; "); + throw new InvariantError("invalid_query", shown, { issues }); +}; + +/** The query's own bounds: a collection to read fields from, page size and offset. */ +function checkBounds(q: FieldEntryListQuery): FieldQueryIssue[] { + const issues: FieldQueryIssue[] = []; + const at = (path: string, message: string) => + issues.push({ path, code: "invalid_query", message }); + if ((q.where !== undefined || q.orderBy !== undefined) && q.collection === undefined) + at("collection", "where and orderBy need a collection"); + const { limit, offset } = q; + if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > ENTRY_PAGE_LIMIT)) + at("limit", `an integer from 1 to ${ENTRY_PAGE_LIMIT}`); + if (offset !== undefined && (!Number.isInteger(offset) || offset < 0)) + at("offset", "a non-negative integer"); + return issues; +} + +type Sort = "newest" | "oldest" | "title"; +const published = (e: ContentEntry) => e.publishedAt ?? e.createdAt; + +/** The collection's `sort` as a comparator (RFC-0040: absent is `newest`). */ +function bySort(sort: Sort): (a: ContentEntry, b: ContentEntry) => number { + const cmp = (x: string, y: string) => (x < y ? -1 : x > y ? 1 : 0); + if (sort === "title") return (a, b) => cmp(a.title, b.title); + if (sort === "oldest") return (a, b) => cmp(published(a), published(b)); + return (a, b) => cmp(published(b), published(a)); +} + +/** Originals of same-site translations that the prefilter left out (another locale). */ +async function originals( + store: SiteGraphStore, + entries: readonly ContentEntry[], +): Promise> { + const byId = new Map(entries.map((e) => [e.id, e])); + for (const e of entries) { + const t = e.translation; + if (!t || t.group === e.id || t.source?.siteId !== undefined || byId.has(t.group)) continue; + const original = await store.getContentEntry(t.group); + if (original && original.siteId === e.siteId) byId.set(original.id, original); + } + return byId; +} + +/** Filters and orders `entries` by a parsed field query at `now`. Pure. */ +export function applyFieldQuery( + entries: readonly { entry: ContentEntry; resolved: ResolvedFields }[], + query: ParsedFieldQuery, + options: { now: number; sort: Sort }, +): ContentEntry[] { + const matched = entries.filter((e) => matchesFieldQuery(e.resolved, query, options.now)); + if (!query.orderBy) return matched.map((e) => e.entry); + const tie = bySort(options.sort); + return matched + .sort( + (a, b) => + compareByField(a.resolved, b.resolved, query) || + tie(a.entry, b.entry) || + (a.entry.id < b.entry.id ? -1 : a.entry.id > b.entry.id ? 1 : 0), + ) + .map((e) => e.entry); +} + +/** + * The entries of `q.collection` matching `q.where`, ordered by `q.orderBy` (else the store's + * order), paged by `limit`/`offset`. Refuses as `invalid_query` (with paths) when the query does + * not check against the collection's list fields. + */ +export async function queryContentEntries( + store: SiteGraphStore, + siteId: string, + q: FieldEntryListQuery, +): Promise { + const bounds = checkBounds(q); + if (bounds.length > 0) refuse(bounds); + const { where, orderBy, limit, offset, now, ...prefilter } = q; + let entries = await store.listContentEntries(siteId, prefilter); + if (q.collection !== undefined && (where !== undefined || orderBy !== undefined)) { + const site = await store.getSite(siteId); + if (!site) throw new NotFoundError("site", siteId); + const parsed = parseFieldQuery(site, q.collection, { where, orderBy }); + if (!parsed.ok) return refuse(parsed.issues); + const byId = await originals(store, entries); + const rows = entries.map((entry) => { + const group = entry.translation?.group; + const original = group && group !== entry.id ? byId.get(group) : undefined; + return { entry, resolved: resolveFields(site, entry, original) }; + }); + const sort = site.collections?.[q.collection]?.sort ?? "newest"; + entries = applyFieldQuery(rows, parsed.query, { now, sort }); + } + const start = offset ?? 0; + const page = limit === undefined ? entries.slice(start) : entries.slice(start, start + limit); + return { entries: page, total: entries.length }; +} diff --git a/packages/core-ts/src/errors.ts b/packages/core-ts/src/errors.ts index 2aa9c529..a4cf4901 100644 --- a/packages/core-ts/src/errors.ts +++ b/packages/core-ts/src/errors.ts @@ -201,7 +201,16 @@ export type InvariantCode = /** RFC-0041 §6: values a type change cannot convert, without `onInvalid: "clear"` (`details.report`). */ | "field_values_unconvertible" /** RFC-0041 §6: the field operation has not finished (revert waits for it), or was already reverted. */ - | "field_operation_incomplete"; + | "field_operation_incomplete" + // ── RFC-0042 (collections stage 3); `details.issues` lists every problem with its path ── + /** §4: a listing's `where`/`orderBy` names an unlisted field, an operator or value its type does not take. */ + | "invalid_query" + /** §4: a field `contentList` sections or the collection's settings filter, sort, expire or map by would be dropped, unlisted or retyped (`details.pages`). */ + | "list_field_in_use" + /** §2: a structured-data preset that does not fit the type's table or the collection's fields. */ + | "structured_data_invalid" + /** §3: `expiry.field` is not a date or datetime field of the collection. */ + | "invalid_expiry"; export class InvariantError extends Error { override readonly name = "InvariantError"; diff --git a/packages/core-ts/src/fields-operations/plan.ts b/packages/core-ts/src/fields-operations/plan.ts index dbc4c1ff..0d6ce54b 100644 --- a/packages/core-ts/src/fields-operations/plan.ts +++ b/packages/core-ts/src/fields-operations/plan.ts @@ -8,6 +8,7 @@ import type { FieldDefinition, FieldMigration, Site } from "@cloudhal/core-contr import { InvariantError } from "../errors.ts"; import { RESERVED_FIELD_KEYS } from "../fields/types.ts"; import type { Cond } from "../node-sql.ts"; +import { renameSettingsReferences } from "../store/collection-settings.ts"; import { FIELD_OPERATIONS, type FieldOperation, finalKey, operationKey } from "./types.ts"; const KEY = /^[a-z][A-Za-z0-9]{0,39}$/; @@ -123,12 +124,18 @@ export function withFields(site: Site, collection: string, fields: FieldDefiniti return { ...site, collections: { ...site.collections, [collection]: next } }; } -/** Phase A: the target definition (or, for a removal, the field as it is) with the marker. */ +/** + * Phase A: the target definition (or, for a removal, the field as it is) with the marker; a rename + * also renames the field in the collection's stage 3 settings (RFC-0042 §4). + */ export function phaseASite(site: Site, op: FieldOperation, id: string): Site { const { defs, def, index } = currentDefinition(site, op); const marked = { ...(targetDefinition(def, op) ?? def), migration: markerOf(id, def, op) }; const fields = defs.map((d, i) => (i === index ? (marked as FieldDefinition) : d)); - return withFields(site, op.collection, fields); + const next = withFields(site, op.collection, fields); + return op.op === "rename_field" + ? renameSettingsReferences(next, op.collection, op.from, op.to) + : next; } /** The field carrying the operation's marker, if the site still has it. */ diff --git a/packages/core-ts/src/fields-operations/references.ts b/packages/core-ts/src/fields-operations/references.ts new file mode 100644 index 00000000..8c24b235 --- /dev/null +++ b/packages/core-ts/src/fields-operations/references.ts @@ -0,0 +1,125 @@ +/** + * RFC-0042 §4 and ADR-0044 "Mapping drift" for RFC-0041's field operations: what an operation + * does to the fields other settings name. + * + * - `rename_field` renames them: the collection's list fields, expiry field and structured-data + * field sources in phase A's site write (`renameSettingsReferences` in `phaseASite`), and the + * `where`/`orderBy` of `contentList` sections in the same batch (`renamedPages`); + * - `remove_field` refuses while settings or sections name the field (`list_field_in_use`); + * - `change_field_type` refuses when the new type would break them: sections' queries that no + * longer check (`list_field_in_use`), or the settings' own checks (`invalid_expiry`, + * `structured_data_invalid`). + * + * A revert runs the same checks for its inverse operation. + */ +import type { Page, Site } from "@cloudhal/core-contracts"; +import { InvariantError } from "../errors.ts"; +import { checkFieldQuery, fieldDefinitions } from "../fields/index.ts"; +import { assertUnlocked } from "../holds.ts"; +import { checkCollectionSettings, settingsUses } from "../store/collection-settings.ts"; +import { pagesUsing, renameInSections, sectionUse } from "../store/section-uses.ts"; +import { phaseASite, phaseCSite } from "./plan.ts"; +import { type FieldOperation, type OperationContext, operationKey } from "./types.ts"; + +const END = "snap_fieldendstate"; + +/** The site as the operation leaves it: definitions and, for a rename, the settings' references. */ +function endSite(site: Site, op: FieldOperation): Site { + return phaseCSite(phaseASite(site, op, END), op.collection, END) ?? site; +} + +function inUse(message: string, details: Record): never { + throw new InvariantError("list_field_in_use", message, details); +} + +/** Settings that name the field: a removal refuses, a type change re-runs the settings checks. */ +export function checkSettingsReferences(site: Site, op: FieldOperation): void { + if (op.op === "remove_field") { + const paths = settingsUses(site, op.collection, op.key); + if (paths.length === 0) return; + inUse(`${op.collection}.${op.key} is named by ${paths.join(", ")}; remove it there first`, { + issues: paths.map((path) => ({ + path, + code: "list_field_in_use", + message: `names ${op.key}`, + })), + }); + } + if (op.op === "change_field_type") checkCollectionSettings(endSite(site, op), op.collection); +} + +const pageRef = (p: Page) => ({ id: p.id, path: p.path, locale: p.locale }); + +/** Problems of the pages' queries on the collection against `site`, at slash paths. */ +function queryIssues(site: Site, collection: string, pages: readonly Page[]) { + const defs = fieldDefinitions(site, collection); + const listFields = site.collections?.[collection]?.listFields; + return pages.flatMap((page) => + page.sections.flatMap((section, i) => + section.kind === "contentList" && section.collection === collection + ? checkFieldQuery(defs, listFields, section).map((issue) => ({ + ...issue, + code: "list_field_in_use", + path: `pages/${page.id}/sections/${i}/${issue.path}`, + })) + : [], + ), + ); +} + +/** + * Sections that filter or sort by the field: a removal refuses, a type change refuses when their + * queries would not check against the new type, and a rename needs every such page unlocked + * (RFC-0008 §6.2), since phase A rewrites them. + */ +export async function checkSectionReferences( + ctx: OperationContext, + site: Site, + op: FieldOperation, +): Promise { + if (op.op === "set_field_localized") return; + const key = operationKey(op); + const pages = await pagesUsing(ctx.store.sql, sectionUse(site.id, op.collection, key)); + if (pages.length === 0) return; + const named = pages.map((p) => p.path).join(", "); + if (op.op === "rename_field") + return assertUnlocked( + ctx.store.sql, + site.id, + pages.map((p) => p.id), + ); + if (op.op === "remove_field") + inUse(`contentList sections on ${named} filter or sort by ${key}; change them first`, { + issues: [], + pages: pages.map(pageRef), + }); + const issues = queryIssues(endSite(site, op), op.collection, pages); + if (issues.length > 0) + inUse(`as a ${op.type} field, ${key} would break contentList sections on ${named}`, { + issues, + pages: pages.map(pageRef), + }); +} + +/** A page rewritten in the same batch as a site write (CAS from `prev.rev`). */ +export interface PageRewrite { + readonly prev: Page; + readonly next: Page; +} + +/** The pages whose sections name `from`, with it renamed to `to`. */ +export async function renamedPages( + ctx: OperationContext, + site: Site, + rename: { collection: string; from: string; to: string }, +): Promise { + const { collection, from, to } = rename; + const pages = await pagesUsing(ctx.store.sql, sectionUse(site.id, collection, from)); + const stamp = ctx.stamp(); + return pages.flatMap((prev) => { + const sections = renameInSections(prev, collection, from, to); + return sections + ? [{ prev, next: { ...prev, sections, rev: prev.rev + 1, updatedAt: stamp } }] + : []; + }); +} diff --git a/packages/core-ts/src/fields-operations/revert.ts b/packages/core-ts/src/fields-operations/revert.ts index 0255405f..13f8e158 100644 --- a/packages/core-ts/src/fields-operations/revert.ts +++ b/packages/core-ts/src/fields-operations/revert.ts @@ -15,7 +15,9 @@ import type { ContentEntry, FieldDefinition, FieldMigration, Site } from "@cloud import { InvariantError } from "../errors.ts"; import { convertContext, rewriteFields } from "../fields/migration.ts"; import { sameJson } from "../fields/types.ts"; +import { renameSettingsReferences } from "../store/collection-settings.ts"; import { markedField, markerGuard, pendingCondition, phaseCSite, withFields } from "./plan.ts"; +import { checkSectionReferences, checkSettingsReferences, renamedPages } from "./references.ts"; import { type Decision, loadEntries, type Row, rewritePass } from "./rewrite.ts"; import { beforeSiteOf } from "./run.ts"; import { @@ -67,7 +69,53 @@ function restoredSite(site: Site, op: FieldOperation, beforeSite: Site, id: stri const def = (marker ? { ...earlier, migration: marker } : earlier) as FieldDefinition; if (at >= 0) defs[at] = def; else defs.splice(Math.min(index, defs.length), 0, def); - return withFields(site, op.collection, defs); + const restored = withFields(site, op.collection, defs); + // RFC-0042 §4: a rename's references in the stage 3 settings follow the key back. + return op.op === "rename_field" + ? renameSettingsReferences(restored, op.collection, op.to, op.from) + : restored; +} + +/** The operation that undoes `op` as far as references are concerned (RFC-0042 §4). */ +function inverseOf(op: FieldOperation, beforeSite: Site): FieldOperation | undefined { + if (op.op === "rename_field") return { ...op, from: op.to, to: op.from }; + if (op.op !== "change_field_type") return undefined; + const was = beforeSite.collections?.[op.collection]?.fields?.find((d) => d.key === op.key); + if (!was) return undefined; + return { + op: "change_field_type", + collection: op.collection, + key: op.key, + type: was.type, + ...(was.type === "select" ? { options: was.options, multiple: was.multiple === true } : {}), + }; +} + +/** Step 1's site write, with a rename's sections renamed back in the same batch. */ +async function restoreDefinition( + ctx: OperationContext, + record: { op: FieldOperation; beforeSite: Site; id: string }, +): Promise { + const { op, beforeSite, id } = record; + const site = await requireSite(ctx, beforeSite.id); + const inverse = inverseOf(op, beforeSite); + const current = site.collections?.[op.collection]?.fields?.some( + (d) => inverse && d.key === operationKey(inverse), + ); + if (inverse && current) { + await checkSectionReferences(ctx, site, inverse); + checkSettingsReferences(site, inverse); + } + const sections = + inverse?.op === "rename_field" + ? (s: Site) => + renamedPages(ctx, s, { + collection: op.collection, + from: inverse.from, + to: inverse.to, + }) + : undefined; + await writeSite(ctx, site.id, (s) => restoredSite(s, op, beforeSite, id), { pages: sections }); } /** The entry without what an operation writes: values, the source it points at, stamps. */ @@ -134,7 +182,7 @@ export async function revertFieldOperation( if (!restoredDef) { const running = (site.collections?.[op.collection]?.fields ?? []).find((d) => d.migration); if (running?.migration) refuseRunning(op.collection, running.migration.id); - await writeSite(ctx, siteId, (s) => restoredSite(s, op, beforeSite, operationId)); + await restoreDefinition(ctx, { op, beforeSite, id: operationId }); } const snaps: Snapshots = { before: new Map(before.nodes.map((n) => [n.id, n.hash])), @@ -180,12 +228,9 @@ export async function revertFieldOperation( const marked = markedField(site, op.collection, operationId); if (marked) { const pending = pendingCondition(siteId, op.collection, marked.def); - const wrote = await writeSite( - ctx, - siteId, - (s) => phaseCSite(s, op.collection, operationId), - nothingPending(pending), - ); + const wrote = await writeSite(ctx, siteId, (s) => phaseCSite(s, op.collection, operationId), { + guard: nothingPending(pending), + }); if (wrote === "guarded") return out; } const snapshot = await store.createSnapshot(siteId, { diff --git a/packages/core-ts/src/fields-operations/run.ts b/packages/core-ts/src/fields-operations/run.ts index 6641c311..ab176b6e 100644 --- a/packages/core-ts/src/fields-operations/run.ts +++ b/packages/core-ts/src/fields-operations/run.ts @@ -20,6 +20,7 @@ import { runningMarker, targetDefinition, } from "./plan.ts"; +import { checkSectionReferences, checkSettingsReferences, renamedPages } from "./references.ts"; import { buildReport } from "./report.ts"; import { type Decide, loadEntries, rewritePass } from "./rewrite.ts"; import { @@ -123,6 +124,9 @@ export async function runFieldOperation( checkOperation(site, op); const running = runningMarker(site.collections?.[op.collection]?.fields ?? []); if (running) refuseRunning(op.collection, running.id); + // RFC-0042 §4: what list fields, expiry, structured data and sections make of the change. + await checkSectionReferences(ctx, site, op); + checkSettingsReferences(site, op); const report = await dryRun(ctx, site, op); if (options.dryRun) return { @@ -212,7 +216,12 @@ async function proceed( if (!isApplied(site, op, targetDefinition(def, op))) { const running = runningMarker(site.collections?.[op.collection]?.fields ?? []); if (running) refuseRunning(op.collection, running.id); - await writeSite(ctx, siteId, (s) => phaseASite(s, op, id)); + const sections = + op.op === "rename_field" + ? (s: Site) => + renamedPages(ctx, s, { collection: op.collection, from: op.from, to: op.to }) + : undefined; + await writeSite(ctx, siteId, (s) => phaseASite(s, op, id), { pages: sections }); site = await requireSite(ctx, siteId); } } @@ -240,12 +249,9 @@ async function proceed( const marked = markedField(site, op.collection, id); if (marked) { const pending = pendingCondition(siteId, op.collection, marked.def); - const wrote = await writeSite( - ctx, - siteId, - (s) => phaseCSite(s, op.collection, id), - nothingPending(pending), - ); + const wrote = await writeSite(ctx, siteId, (s) => phaseCSite(s, op.collection, id), { + guard: nothingPending(pending), + }); if (wrote === "guarded") return result(record, report, { rewritten, skipped, done: false }); } const after = await ctx.store.createSnapshot(siteId, { diff --git a/packages/core-ts/src/fields-operations/state.ts b/packages/core-ts/src/fields-operations/state.ts index 6b222759..2e0074a8 100644 --- a/packages/core-ts/src/fields-operations/state.ts +++ b/packages/core-ts/src/fields-operations/state.ts @@ -8,7 +8,9 @@ import { assertValid, type Site, type Snapshot } from "@cloudhal/core-contracts"; import { ConflictError, InvariantError, NotFoundError } from "../errors.ts"; import { checkFieldDefinitions, refuseFieldIssues } from "../fields/index.ts"; -import { type Cond, casUpdateStatement } from "../node-sql.ts"; +import { ID_PREFIX, newId } from "../ids.ts"; +import { type Cond, casUpdateStatement, OpBatch, runOpBatch } from "../node-sql.ts"; +import type { PageRewrite } from "./references.ts"; import { FIELD_MIGRATION, type FieldOperation, @@ -71,8 +73,13 @@ export async function writeSite( ctx: OperationContext, siteId: string, transform: (site: Site) => Site | undefined, - guard?: Cond, + options: { + guard?: Cond | undefined; + /** Pages rewritten in the same batch (a rename's `contentList` sections, RFC-0042 §4). */ + pages?: ((site: Site) => Promise) | undefined; + } = {}, ): Promise { + const { guard, pages } = options; let rev: number | undefined; for (let attempt = 0; attempt < 5; attempt++) { const site = await requireSite(ctx, siteId); @@ -81,15 +88,44 @@ export async function writeSite( const next: Site = { ...changed, rev: site.rev + 1, updatedAt: ctx.stamp() }; assertValid("site", next); refuseFieldIssues(checkFieldDefinitions(next)); - const s = await casUpdateStatement("site", next, site.rev, guard); - const r = await ctx.store.sql.run(s.sql, s.params ?? []); - if (r.changes === 1) return "written"; + const rewrites = (await pages?.(site)) ?? []; + if (rewrites.length > 0) { + if (await writeWithPages(ctx, { site, next, guard, rewrites })) return "written"; + } else { + const s = await casUpdateStatement("site", next, site.rev, guard); + const r = await ctx.store.sql.run(s.sql, s.params ?? []); + if (r.changes === 1) return "written"; + } rev = (await ctx.store.getSite(siteId))?.rev; - if (rev === site.rev) return "guarded"; + if (rev === site.rev && rewrites.length === 0) return "guarded"; } throw new ConflictError("site", siteId, rev ?? 0, rev); } +/** + * The site CAS and the pages' CAS in one batch (RFC-0042 §4: a rename's sections), asserted by + * `op_guards` so that either all land or none (rule 4); false when a node moved meanwhile. + */ +async function writeWithPages( + ctx: OperationContext, + write: { site: Site; next: Site; guard: Cond | undefined; rewrites: readonly PageRewrite[] }, +): Promise { + const batch = new OpBatch(newId(ID_PREFIX.operation), write.site.id, write.next.updatedAt); + await batch.update("site", write.site, write.next, write.guard); + for (const p of write.rewrites) { + assertValid("page", p.next); + await batch.update("page", p.prev, p.next); + } + try { + await runOpBatch(ctx.store.sql, batch); + return true; + } catch (e) { + // An `OperationConflictError` is a ConflictError too. + if (e instanceof ConflictError) return false; + throw e; + } +} + /** `NOT EXISTS` over a pending-entries condition (phase C's guard). */ export function nothingPending(pending: Cond | undefined): Cond | undefined { if (!pending) return undefined; diff --git a/packages/core-ts/src/index.ts b/packages/core-ts/src/index.ts index 99558dad..60dc8981 100644 --- a/packages/core-ts/src/index.ts +++ b/packages/core-ts/src/index.ts @@ -32,6 +32,13 @@ export * from "./cross-site.ts"; export * from "./design.ts"; export * from "./domains.ts"; export * from "./editions.ts"; +export { + applyFieldQuery, + ENTRY_PAGE_LIMIT, + type FieldEntryList, + type FieldEntryListQuery, + queryContentEntries, +} from "./entry-query.ts"; export * from "./errors.ts"; export * from "./fields-operations/index.ts"; export * from "./filter.ts"; diff --git a/packages/core-ts/src/store/collection-settings.ts b/packages/core-ts/src/store/collection-settings.ts new file mode 100644 index 00000000..6f42ad16 --- /dev/null +++ b/packages/core-ts/src/store/collection-settings.ts @@ -0,0 +1,154 @@ +/** + * RFC-0042 §8 step 2: the stage 3 collection settings checked on every site write that changes + * them (`updateSite`, its dry run, and the field operations' end state). Pure. + * + * - `listFields` keys are fields of the collection (`unknown_field`); every field type can be + * listed (§4's table gives each at least `set`/`unset`); + * - `expiry.field` is a date or datetime field of the collection (`unknown_field`, + * `invalid_expiry`); + * - a structured-data preset passes `checkStructuredData` (`structured_data_invalid`); its + * warnings are reported by the checker, never refused. + */ +import type { FieldDefinition } from "@cloudhal/core-contracts"; +import { type InvariantCode, InvariantError } from "../errors.ts"; +import { isBeingRemoved } from "../fields/migration.ts"; +import type { SiteFields } from "../fields/types.ts"; +import { checkPreset, structuredDataErrors } from "../structured-data/check.ts"; + +/** One refused setting, at a slash path (`collections/events/expiry/field`). */ +export interface SettingsIssue { + path: string; + code: string; + message: string; +} + +interface Refusal { + readonly code: InvariantCode; + readonly issues: SettingsIssue[]; +} + +const live = (defs: readonly FieldDefinition[] | undefined) => + new Map((defs ?? []).filter((d) => !isBeingRemoved(d)).map((d) => [d.key, d])); + +function listFieldIssues(key: string, settings: CollectionSettingsLike): SettingsIssue[] { + const defs = live(settings.fields); + return (settings.listFields ?? []).flatMap((field, i) => + defs.has(field) + ? [] + : [ + { + path: `collections/${key}/listFields/${i}`, + code: "unknown_field", + message: `"${field}" is not a field of ${key}`, + }, + ], + ); +} + +function expiryIssues(key: string, settings: CollectionSettingsLike): SettingsIssue[] { + const rule = settings.expiry; + if (!rule) return []; + const def = live(settings.fields).get(rule.field); + const path = `collections/${key}/expiry/field`; + if (!def) + return [{ path, code: "unknown_field", message: `"${rule.field}" is not a field of ${key}` }]; + if (def.type === "date" || def.type === "datetime") return []; + return [ + { + path, + code: "invalid_expiry", + message: `expiry reads a date or datetime field; ${rule.field} is a ${def.type} field`, + }, + ]; +} + +type CollectionSettingsLike = NonNullable[string]>; + +/** + * The refusals of a site's stage 3 settings, in the order they are reported: list fields, expiry, + * structured data. `only` limits the check to one collection. + */ +export function collectionSettingsRefusals(site: SiteFields, only?: string): Refusal[] { + const unknown: SettingsIssue[] = []; + const expiry: SettingsIssue[] = []; + const preset: SettingsIssue[] = []; + for (const [key, settings] of Object.entries(site.collections ?? {})) { + if (!settings || (only !== undefined && key !== only)) continue; + unknown.push(...listFieldIssues(key, settings)); + for (const i of expiryIssues(key, settings)) + (i.code === "unknown_field" ? unknown : expiry).push(i); + if (settings.structuredData) + preset.push( + ...structuredDataErrors( + checkPreset( + settings.structuredData, + settings.fields ?? [], + `collections/${key}/structuredData`, + ), + ).map(({ path, code, message }) => ({ path, code, message })), + ); + } + const out: Refusal[] = []; + if (unknown.length) out.push({ code: "unknown_field", issues: unknown }); + if (expiry.length) out.push({ code: "invalid_expiry", issues: expiry }); + if (preset.length) out.push({ code: "structured_data_invalid", issues: preset }); + return out; +} + +/** Throws the first refusal of `collectionSettingsRefusals` (every issue of it in `details.issues`). */ +export function checkCollectionSettings(site: SiteFields, only?: string): void { + const first = collectionSettingsRefusals(site, only)[0]; + if (!first) return; + const shown = first.issues + .slice(0, 5) + .map((i) => `${i.path}: ${i.message}`) + .join("; "); + const more = first.issues.length > 5 ? ` (and ${first.issues.length - 5} more)` : ""; + throw new InvariantError(first.code, `${shown}${more}`, { issues: first.issues }); +} + +/** + * RFC-0042 §4 with RFC-0041 §6: the collection's stage 3 settings with field `from` renamed to + * `to` (list fields, the expiry field, structured-data field sources). A rename's phase A writes + * this with the new definition; its revert writes the inverse. + */ +export function renameSettingsReferences( + site: S, + collection: string, + from: string, + to: string, +): S { + const settings = site.collections?.[collection]; + if (!settings) return site; + const rename = (key: string) => (key === from ? to : key); + const next = { ...settings }; + if (settings.listFields) + next.listFields = settings.listFields.map(rename) as typeof settings.listFields; + if (settings.expiry) next.expiry = { ...settings.expiry, field: rename(settings.expiry.field) }; + if (settings.structuredData) { + const properties = Object.fromEntries( + Object.entries(settings.structuredData.properties).map(([path, source]) => [ + path, + source?.kind === "field" ? { ...source, field: rename(source.field) } : source, + ]), + ); + next.structuredData = { ...settings.structuredData, properties }; + } + return { ...site, collections: { ...site.collections, [collection]: next } }; +} + +/** Slash paths of the collection's stage 3 settings that name `key`. */ +export function settingsUses(site: SiteFields, collection: string, key: string): string[] { + const settings = site.collections?.[collection]; + if (!settings) return []; + const at = `collections/${collection}`; + const out: string[] = []; + settings.listFields?.forEach((k, i) => { + if (k === key) out.push(`${at}/listFields/${i}`); + }); + if (settings.expiry?.field === key) out.push(`${at}/expiry/field`); + for (const [path, source] of Object.entries(settings.structuredData?.properties ?? {})) + if (source?.kind === "field" && source.field === key) + out.push(`${at}/structuredData/properties/${path}/field`); + return out; +} diff --git a/packages/core-ts/src/store/page-queries.ts b/packages/core-ts/src/store/page-queries.ts new file mode 100644 index 00000000..c780ac2b --- /dev/null +++ b/packages/core-ts/src/store/page-queries.ts @@ -0,0 +1,52 @@ +/** + * RFC-0042 §4 for page writes: a `contentList` section's `where`/`orderBy` must run against its + * collection's list fields (`checkFieldQuery`), or the write is refused as `invalid_query` with + * every problem at its slash path (`sections/2/where/0/value`). Only sections whose query changed + * are checked, so a page stays editable after a definition change elsewhere; the caller guards its + * CAS on the site it checked against (rule 4). + */ +import type { Page, Section, Site } from "@cloudhal/core-contracts"; +import { InvariantError } from "../errors.ts"; +import { checkFieldQuery, type FieldQueryIssue, fieldDefinitions } from "../fields/index.ts"; +import { sameJson } from "../fields/types.ts"; + +type ListSection = Extract; + +const queryOf = (s: ListSection) => ({ + collection: s.collection, + where: s.where, + orderBy: s.orderBy, +}); + +/** True when the section filters or sorts by a field, and did not do so the same way before. */ +function changed(section: ListSection, before: Section | undefined): boolean { + if (section.where === undefined && section.orderBy === undefined) return false; + if (before?.kind !== "contentList") return true; + return !sameJson(queryOf(section), queryOf(before)); +} + +/** + * Throws `invalid_query` when a changed `contentList` section's field query does not check against + * `site`; true when the page has such a section (the write then guards on the site's hash). + */ +export function checkPageQueries(site: Site, page: Page, stored?: Page): boolean { + const before = new Map((stored?.sections ?? []).map((s) => [s.id, s])); + const issues: FieldQueryIssue[] = []; + let checked = false; + page.sections.forEach((section, i) => { + if (section.kind !== "contentList" || !changed(section, before.get(section.id))) return; + checked = true; + const defs = fieldDefinitions(site, section.collection); + const listFields = site.collections?.[section.collection]?.listFields; + for (const issue of checkFieldQuery(defs, listFields, section)) + issues.push({ ...issue, path: `sections/${i}/${issue.path}` }); + }); + if (issues.length > 0) { + const shown = issues + .slice(0, 5) + .map((i) => `${i.path}: ${i.message}`) + .join("; "); + throw new InvariantError("invalid_query", shown, { issues }); + } + return checked; +} diff --git a/packages/core-ts/src/store/pages.ts b/packages/core-ts/src/store/pages.ts index 87849fb0..769d5f1b 100644 --- a/packages/core-ts/src/store/pages.ts +++ b/packages/core-ts/src/store/pages.ts @@ -6,9 +6,10 @@ import { nodeHash } from "../hash.ts"; import { assertUnlocked } from "../holds.ts"; import { ID_PREFIX } from "../ids.ts"; import { fillMissingSrc } from "../media/refs.ts"; -import { and, casUpdateStatement, OpBatch, runOpBatch } from "../node-sql.ts"; +import { and, casUpdateStatement, hashIs, OpBatch, runOpBatch } from "../node-sql.ts"; import { type Creatable, uniquenessKey } from "../nodes.ts"; import type { StoreContext } from "./context.ts"; +import { checkPageQueries } from "./page-queries.ts"; import { checkPagePath, pageGuard } from "./site-permalinks.ts"; import { checkLocalizedNode, checkTranslationLinks } from "./translations.ts"; import type { NodeWriteOptions } from "./types.ts"; @@ -42,6 +43,8 @@ export class PageEngine { if (draft.siteId !== site.id) throw new Error("page.siteId must match site.id"); checkReservedPath(draft.path); checkLocalizedNode("page", site, draft); + // RFC-0042 §4: field queries run against the site this batch's site CAS writes over. + checkPageQueries(site, draft); await checkTranslationLinks(this.#ctx, { kind: "page", site, node: draft }); await this.#ctx.checkGates(site.id, undefined, draft.availability); const refs = await this.#ctx.assets.planRefs("page", site.id, draft, undefined); @@ -97,6 +100,10 @@ export class PageEngine { const site = await this.#ctx.graph.getSite(next.siteId); if (!site) throw new NotFoundError("site", next.siteId); checkLocalizedNode("page", site, next); + // RFC-0042 §4: a changed field query holds only against the site it was checked with. + const siteGuard = checkPageQueries(site, next, stored) + ? hashIs(site.id, await nodeHash(site)) + : undefined; await checkTranslationLinks(this.#ctx, { kind: "page", site, node: next, stored }); const sameLocale = stored.locale === next.locale; const plan = await this.#ctx.tax.planPathChange(site, ts, { @@ -115,7 +122,7 @@ export class PageEngine { "page", next, page.rev, - and(pageGuard(this.#ctx, next), refs.guard), + and(pageGuard(this.#ctx, next), refs.guard, siteGuard), ), "page", key, @@ -132,7 +139,12 @@ export class PageEngine { return next; } const batch = new OpBatch(this.#ctx.newId(ID_PREFIX.operation), site.id, ts); - await batch.update("page", stored, next, and(pageGuard(this.#ctx, next), refs.guard)); + await batch.update( + "page", + stored, + next, + and(pageGuard(this.#ctx, next), refs.guard, siteGuard), + ); batch.append(...extra); await plan.emit(batch); await runOpBatch(this.#ctx.sql, batch, { diff --git a/packages/core-ts/src/store/section-uses.ts b/packages/core-ts/src/store/section-uses.ts new file mode 100644 index 00000000..bf2be937 --- /dev/null +++ b/packages/core-ts/src/store/section-uses.ts @@ -0,0 +1,81 @@ +/** + * RFC-0042 §4: which pages' `contentList` sections filter or sort a collection by a field. One SQL + * predicate over a page body (`json_each` over its sections and their `where`), shared by + * `updateSite`'s `list_field_in_use` refusal and its CAS guard (rule 4), and by the field + * operations (rename rewrites those sections, removal and retyping refuse). sqlite and D1 alike. + */ +import type { Page } from "@cloudhal/core-contracts"; +import type { SqlDriver } from "@cloudhal/storage"; +import type { Cond } from "../node-sql.ts"; + +const LIST_SECTION = + "json_extract(s.value, '$.kind') = 'contentList' AND json_extract(s.value, '$.collection') = ?"; + +/** + * Pages of the site (table alias `p`) with a `contentList` section of `collection` that names + * `field` in its `where` or `orderBy`; with `values`, only conditions on `field` whose `value` or + * `values` hold one of them (a dropped select option). + */ +export function sectionUse( + siteId: string, + collection: string, + field: string, + values?: readonly string[], +): Cond { + const head = + "p.site_id = ? AND p.kind = 'page' AND EXISTS (SELECT 1 FROM json_each(p.body, '$.sections') s WHERE"; + if (values) { + const list = JSON.stringify(values); + return { + sql: `${head} ${LIST_SECTION} AND EXISTS (SELECT 1 FROM json_each(s.value, '$.where') w + WHERE json_extract(w.value, '$.field') = ? AND (json_extract(w.value, '$.value') IN (SELECT value FROM json_each(?)) + OR EXISTS (SELECT 1 FROM json_each(w.value, '$.values') v WHERE v.value IN (SELECT value FROM json_each(?))))))`, + params: [siteId, collection, field, list, list], + }; + } + return { + sql: `${head} ${LIST_SECTION} AND (json_extract(s.value, '$.orderBy.field') = ? + OR EXISTS (SELECT 1 FROM json_each(s.value, '$.where') w WHERE json_extract(w.value, '$.field') = ?)))`, + params: [siteId, collection, field, field], + }; +} + +/** `NOT EXISTS` over a use: the guard that keeps "no section uses it" true until the CAS. */ +export const noSectionUse = (use: Cond): Cond => ({ + sql: `NOT EXISTS (SELECT 1 FROM nodes p WHERE ${use.sql})`, + params: use.params, +}); + +/** The pages a use matches, by path (what refusals name). */ +export async function pagesUsing(sql: SqlDriver, use: Cond): Promise { + const rows = await sql.all<{ body: string }>( + `SELECT p.body FROM nodes p WHERE ${use.sql} ORDER BY json_extract(p.body, '$.path'), p.id`, + use.params, + ); + return rows.map((r) => JSON.parse(r.body) as Page); +} + +/** A page's `contentList` sections of `collection` with `from` renamed to `to`; undefined when none name it. */ +export function renameInSections( + page: Page, + collection: string, + from: string, + to: string, +): Page["sections"] | undefined { + let changed = false; + const sections = page.sections.map((section) => { + if (section.kind !== "contentList" || section.collection !== collection) return section; + const uses = + section.orderBy?.field === from || (section.where ?? []).some((c) => c.field === from); + if (!uses) return section; + changed = true; + return { + ...section, + ...(section.where + ? { where: section.where.map((c) => (c.field === from ? { ...c, field: to } : c)) } + : {}), + ...(section.orderBy?.field === from ? { orderBy: { ...section.orderBy, field: to } } : {}), + } as typeof section; + }); + return changed ? (sections as Page["sections"]) : undefined; +} diff --git a/packages/core-ts/src/store/site-listings.ts b/packages/core-ts/src/store/site-listings.ts new file mode 100644 index 00000000..c0617941 --- /dev/null +++ b/packages/core-ts/src/store/site-listings.ts @@ -0,0 +1,113 @@ +/** + * RFC-0042 §4 and §8 step 2 for site writes: the stage 3 settings are checked when they change + * (`checkCollectionSettings`), and a list field `contentList` sections still filter or sort by + * cannot be unlisted, dropped or retyped, nor can a select option they compare with be dropped + * (`list_field_in_use`, naming the pages). The refusal counts with one query per change, and a + * guard on the site CAS keeps "no section uses it" true (rule 4). + */ +import type { FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { InvariantError } from "../errors.ts"; +import { isBeingRemoved } from "../fields/migration.ts"; +import { sameJson } from "../fields/types.ts"; +import { and, type Cond } from "../node-sql.ts"; +import { checkCollectionSettings } from "./collection-settings.ts"; +import type { StoreContext } from "./context.ts"; +import { noSectionUse, pagesUsing, sectionUse } from "./section-uses.ts"; + +export interface SiteListingsPlan { + readonly guard: Cond | undefined; + diagnose(): Promise; +} + +/** A list field that stops being usable as it was, and how sections may still use it. */ +export interface ListFieldChange { + readonly collection: string; + readonly key: string; + /** What happened, for the message. */ + readonly why: string; + /** Only conditions comparing with these values break (dropped select options). */ + readonly values?: readonly string[]; +} + +const liveDef = (site: Site, collection: string, key: string): FieldDefinition | undefined => + site.collections?.[collection]?.fields?.find((d) => d.key === key && !isBeingRemoved(d)); + +/** How one stored list field changes in `next`; undefined when sections keep working. */ +function changeOf(stored: Site, next: Site, collection: string, key: string) { + const before = liveDef(stored, collection, key); + if (!before) return undefined; + const settings = next.collections?.[collection]; + if (!settings) return { collection, key, why: `${collection} is no longer declared` }; + const after = liveDef(next, collection, key); + if (!after) return { collection, key, why: `${key} is no longer a field of ${collection}` }; + if (!settings.listFields?.includes(key)) + return { collection, key, why: `${key} is no longer a list field of ${collection}` }; + if (after.type !== before.type) + return { collection, key, why: `${key} would change from ${before.type} to ${after.type}` }; + if (before.type !== "select" || after.type !== "select") return undefined; + const kept = new Set(after.options.map((o) => o.value)); + const values = before.options.map((o) => o.value).filter((v) => !kept.has(v)); + return values.length + ? { collection, key, why: `${key} would lose ${values.join(", ")}`, values } + : undefined; +} + +/** Every stored list field `next` unlists, drops or retypes (and every dropped option). */ +export function listFieldChanges(stored: Site, next: Site): ListFieldChange[] { + const out: ListFieldChange[] = []; + for (const [collection, settings] of Object.entries(stored.collections ?? {})) + for (const key of settings?.listFields ?? []) { + const change = changeOf(stored, next, collection, key); + if (change) out.push(change); + } + return out; +} + +const useOf = (siteId: string, c: ListFieldChange) => + sectionUse(siteId, c.collection, c.key, c.values); + +/** Throws `list_field_in_use` naming the pages whose sections still use a changed list field. */ +export async function refuseListFieldUses( + ctx: Pick, + siteId: string, + changes: readonly ListFieldChange[], +): Promise { + const issues: { path: string; code: string; message: string }[] = []; + const pages = new Map(); + for (const c of changes) { + const using = await pagesUsing(ctx.sql, useOf(siteId, c)); + if (using.length === 0) continue; + for (const p of using) pages.set(p.id, { id: p.id, path: p.path, locale: p.locale }); + issues.push({ + path: `collections/${c.collection}/listFields`, + code: "list_field_in_use", + message: `${c.why}, but contentList sections on ${using.map((p) => p.path).join(", ")} filter or sort by it; change those sections first`, + }); + } + if (issues.length === 0) return; + throw new InvariantError("list_field_in_use", issues.map((i) => i.message).join("; "), { + issues, + pages: [...pages.values()], + }); +} + +/** + * Checks `next`'s stage 3 settings (when they change) and refuses list-field changes that + * sections still depend on; returns the CAS guard, undefined when nothing needs one. + */ +export async function planListings( + ctx: StoreContext, + stored: Site | undefined, + next: Site, +): Promise { + if (stored && sameJson(stored.collections, next.collections)) return undefined; + checkCollectionSettings(next); + if (!stored) return undefined; + const changes = listFieldChanges(stored, next); + if (changes.length === 0) return undefined; + await refuseListFieldUses(ctx, next.id, changes); + return { + guard: and(...changes.map((c) => noSectionUse(useOf(next.id, c)))), + diagnose: () => refuseListFieldUses(ctx, next.id, changes), + }; +} diff --git a/packages/core-ts/src/store/sites.ts b/packages/core-ts/src/store/sites.ts index a7b309d7..205e3231 100644 --- a/packages/core-ts/src/store/sites.ts +++ b/packages/core-ts/src/store/sites.ts @@ -16,6 +16,7 @@ import { checkPageSurfaces, createCatalog } from "../surfaces.ts"; import { planDeclarations } from "./collection-paths.ts"; import type { StoreContext } from "./context.ts"; import { planFieldDefinitions } from "./site-fields.ts"; +import { planListings } from "./site-listings.ts"; import { planPermalinks } from "./site-permalinks.ts"; import { checkThemeConfig } from "./validation.ts"; @@ -40,6 +41,7 @@ export class SiteEngine { checkTimeZone(site.timeZone); checkThemeConfig(this.#ctx.graph.themeSettings, site.theme.config, undefined); await planFieldDefinitions(this.#ctx, undefined, site); + await planListings(this.#ctx, undefined, site); // RFC-0034 §4: a hostname belongs to one site of the installation. const hosts = hostsOf(site); await checkHosts(this.#ctx.sql, hosts); @@ -107,6 +109,8 @@ export class SiteEngine { const declared = await planDeclarations(this.#ctx, stored, next); // RFC-0041 §6: field definitions follow their rules, and a plain edit never loses a value. const fields = await planFieldDefinitions(this.#ctx, stored, next); + // RFC-0042 §4: stage 3 settings fit the fields, and sections keep the list fields they use. + const listings = await planListings(this.#ctx, stored, next); const refs = await this.#ctx.assets.planRefs("site", site.id, next, stored); const written = refs.node; if (options.dryRun) return written; @@ -114,7 +118,7 @@ export class SiteEngine { "site", written, site.rev, - and(refs.guard, permalinks?.guard, declared?.guard, fields?.guard), + and(refs.guard, permalinks?.guard, declared?.guard, fields?.guard, listings?.guard), ); const hash = await nodeHash(written); const more = [ @@ -130,6 +134,7 @@ export class SiteEngine { await permalinks?.diagnose(); await declared?.diagnose(); await fields?.diagnose(); + await listings?.diagnose(); await refs.diagnose(); } throw new ConflictError("site", site.id, site.rev, rev); diff --git a/packages/core-ts/src/testing-entry-query.ts b/packages/core-ts/src/testing-entry-query.ts new file mode 100644 index 00000000..18bed715 --- /dev/null +++ b/packages/core-ts/src/testing-entry-query.ts @@ -0,0 +1,260 @@ +/** + * RFC-0042 step 2 behavioural suite: `queryContentEntries`, a collection's entries filtered and + * sorted by its list fields with the shared evaluator over resolved values — operators by type, + * `now` (injected) on datetime and date fields in the site's zone, missing values last, ties on + * the collection's sort, paging, translations reading their original's non-localized values, and + * `invalid_query`. Called by `describeSiteGraphStore`, so it runs on every storage backend. + */ +import type { + Actor, + ContentEntry, + FieldCondition, + FieldOrder, + Site, + Translation, +} from "@cloudhal/core-contracts"; +import { describe, expect, it } from "vitest"; +import { type FieldEntryListQuery, queryContentEntries } from "./entry-query.ts"; +import { InvariantError } from "./errors.ts"; +import type { SiteGraphStore } from "./store.ts"; +import { STAGE3_EVENTS } from "./testing-listings.ts"; +import type { SuiteContext } from "./testing-taxonomy.ts"; + +const person: Actor = { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" }; +/** 2026-11-05 23:00 in Istanbul, 2026-11-06 10:00 in Kiritimati. */ +const NOW = Date.parse("2026-11-05T20:00:00Z"); +const at = (iso: string) => ({ at: iso, timeZone: "Europe/Istanbul" }); + +export function entryQuerySuite(ctx: SuiteContext): void { + describe("RFC-0042 §4 entry queries by list field", () => { + let n = 0; + const store = (): SiteGraphStore => ctx.store(); + + async function world() { + n++; + const project = await store().createProject({ + name: "Query", + slug: `query-${n}`, + ownerId: person.id, + siteIds: [], + }); + const site = await store().createSite({ + projectId: project.id, + name: "Query", + slug: `query-${n}`, + defaultLocale: "en", + locales: ["en", "tr"], + domains: [], + pageIds: [], + theme: { name: "blog" }, + timeZone: "Europe/Istanbul", + collections: { events: STAGE3_EVENTS }, + }); + const entry = (slug: string, fields: Record, extra = {}) => + store().createContentEntry({ + siteId: site.id, + collection: "events", + slug, + locale: "en", + status: "published", + title: slug, + body: { format: "markdown", value: "Body" }, + fields, + tags: [], + author: person, + ...extra, + } as never); + const a = await entry( + "a", + { + startsAt: at("2026-11-05T16:30:00Z"), + price: 10, + eventStatus: "scheduled", + day: "2026-11-05", + }, + { publishedAt: "2026-10-01T00:00:00.000Z" }, + ); + const b = await entry( + "b", + { + startsAt: at("2026-11-10T09:00:00Z"), + price: 5, + eventStatus: "cancelled", + day: "2026-11-10", + }, + { publishedAt: "2026-10-03T00:00:00.000Z" }, + ); + const c = await entry("c", { price: 20 }, { publishedAt: "2026-10-04T00:00:00.000Z" }); + const d = await entry( + "d", + { startsAt: at("2026-11-01T09:00:00Z"), price: 10, day: "2026-11-01" }, + { publishedAt: "2026-10-02T00:00:00.000Z" }, + ); + return { site, a, b, c, d }; + } + const slugs = (r: { entries: ContentEntry[] }) => r.entries.map((e) => e.slug); + const query = ( + site: Site, + where?: FieldCondition[], + orderBy?: FieldOrder, + extra: Partial = {}, + ) => + queryContentEntries(store(), site.id, { + collection: "events", + now: NOW, + ...(where ? { where } : {}), + ...(orderBy ? { orderBy } : {}), + ...extra, + }); + async function refused(p: Promise) { + try { + await p; + } catch (e) { + if (!(e instanceof InvariantError)) throw e; + const issues = (e.details.issues ?? []) as { path: string }[]; + return `${e.code} ${issues.map((i) => i.path).join(",")}`; + } + throw new Error("expected a rejection"); + } + + it("without where/orderBy answers exactly what listContentEntries does", async () => { + const { site } = await world(); + const plain = await store().listContentEntries(site.id, { collection: "events" }); + const r = await query(site); + expect(r.entries).toEqual(plain); + expect(r.total).toBe(4); + }); + + it("filters by type: datetime against now, numbers, selects, set and unset", async () => { + const { site } = await world(); + const upcoming = { field: "startsAt", op: "gte", value: "now" } as const; + expect(slugs(await query(site, [upcoming]))).toEqual(["b"]); + expect( + slugs( + await query(site, [{ field: "startsAt", op: "lte", value: "2026-11-05T19:30:00+03:00" }]), + ), + ).toEqual(["a", "d"]); + expect(slugs(await query(site, [{ field: "price", op: "lte", value: "10" }])).sort()).toEqual( + ["a", "b", "d"], + ); + expect( + slugs(await query(site, [{ field: "eventStatus", op: "eq", value: "cancelled" }])), + ).toEqual(["b"]); + // `ne` is "not eq": entries without a value match it. + expect( + slugs(await query(site, [{ field: "eventStatus", op: "ne", value: "cancelled" }])).sort(), + ).toEqual(["a", "c", "d"]); + expect( + slugs( + await query(site, [ + { field: "eventStatus", op: "in", values: ["scheduled", "cancelled"] }, + ]), + ).sort(), + ).toEqual(["a", "b"]); + expect(slugs(await query(site, [{ field: "startsAt", op: "unset" }]))).toEqual(["c"]); + // Conditions are ANDed. + expect( + slugs( + await query(site, [ + { field: "startsAt", op: "set" }, + { field: "price", op: "eq", value: "10" }, + ]), + ).sort(), + ).toEqual(["a", "d"]); + }); + + it("`now` on a date field is today in the site's zone", async () => { + const { site } = await world(); + const today = [{ field: "day", op: "gte", value: "now" }] as FieldCondition[]; + expect(slugs(await query(site, today)).sort()).toEqual(["a", "b"]); + const s = (await store().getSite(site.id)) as Site; + await store().updateSite({ ...s, timeZone: "Pacific/Kiritimati" }); + expect(slugs(await query(site, today))).toEqual(["b"]); + expect(slugs(await query(site, [{ field: "day", op: "eq", value: "2026-11-05" }]))).toEqual([ + "a", + ]); + }); + + it("orders by a field, missing values last, ties on the collection's sort", async () => { + const { site } = await world(); + const asc = await query(site, undefined, { field: "startsAt", direction: "asc" }); + expect(slugs(asc)).toEqual(["d", "a", "b", "c"]); + const desc = await query(site, undefined, { field: "startsAt", direction: "desc" }); + expect(slugs(desc)).toEqual(["b", "a", "d", "c"]); + // a and d cost 10: the collection's sort (newest) puts d (published later) first. + const price = await query(site, undefined, { field: "price", direction: "asc" }); + expect(slugs(price)).toEqual(["b", "d", "a", "c"]); + const s = (await store().getSite(site.id)) as Site; + const events = { ...s.collections?.events, sort: "oldest" } as never; + await store().updateSite({ ...s, collections: { ...s.collections, events } }); + const oldest = await query(site, undefined, { field: "price", direction: "asc" }); + expect(slugs(oldest)).toEqual(["b", "a", "d", "c"]); + }); + + it("pages with limit and offset; total counts every match", async () => { + const { site } = await world(); + const order = { field: "startsAt", direction: "asc" } as const; + const first = await query(site, undefined, order, { limit: 3 }); + expect([slugs(first), first.total]).toEqual([["d", "a", "b"], 4]); + const second = await query(site, undefined, order, { limit: 3, offset: 3 }); + expect([slugs(second), second.total]).toEqual([["c"], 4]); + const filtered = await query(site, [{ field: "price", op: "eq", value: "10" }], order, { + limit: 1, + }); + expect([slugs(filtered), filtered.total]).toEqual([["d"], 2]); + }); + + it("a translation reads its original's non-localized values", async () => { + const { site, a } = await world(); + const member = (await store().translationGroup(site, a.id)).members.find( + (m) => m.id === a.id, + ); + const translation = { + group: a.id, + method: "human", + source: { id: a.id, locale: "en", rev: a.rev, hash: member?.hash as string }, + } as Translation; + await store().createContentEntry({ + siteId: site.id, + collection: "events", + slug: "a", + locale: "tr", + status: "draft", + title: "a-tr", + body: { format: "markdown", value: "Gövde" }, + fields: { venue: "Salon" }, + tags: [], + author: person, + translation, + } as never); + const tr = await query(site, [{ field: "price", op: "eq", value: "10" }], undefined, { + locale: "tr", + }); + expect(tr.entries.map((e) => e.title)).toEqual(["a-tr"]); + }); + + it("refuses invalid queries as invalid_query with paths", async () => { + const { site } = await world(); + expect(await refused(query(site, [{ field: "city", op: "eq", value: "x" }]))).toBe( + "invalid_query where/0/field", + ); + expect(await refused(query(site, [{ field: "price", op: "gt", value: "ten" }]))).toBe( + "invalid_query where/0/value", + ); + expect( + await refused( + queryContentEntries(store(), site.id, { + now: NOW, + where: [{ field: "price", op: "set" }], + }), + ), + ).toBe("invalid_query collection"); + expect(await refused(query(site, undefined, undefined, { limit: 101 }))).toBe( + "invalid_query limit", + ); + expect( + await refused(query(site, undefined, { field: "eventStatus", direction: "asc" })), + ).toBe("invalid_query orderBy/field"); + }); + }); +} diff --git a/packages/core-ts/src/testing-listings.ts b/packages/core-ts/src/testing-listings.ts new file mode 100644 index 00000000..60dfcdbf --- /dev/null +++ b/packages/core-ts/src/testing-listings.ts @@ -0,0 +1,415 @@ +/** + * RFC-0042 step 2 behavioural suite: collections stage 3 in the store. `updateSite` checks list + * fields, the expiry field and the structured-data preset, and refuses list-field changes that + * `contentList` sections still use (`list_field_in_use`, with a guard on the site CAS); page writes + * check sections' `where`/`orderBy` (`invalid_query`); RFC-0041's field operations rename, refuse + * or re-check those references. Called by `describeSiteGraphStore`, so it runs on every backend. + */ +import type { + Actor, + CollectionSettings, + FieldDefinition, + Page, + Section, + Site, +} from "@cloudhal/core-contracts"; +import { describe, expect, it } from "vitest"; +import { InvariantError } from "./errors.ts"; +import { + type FieldOperation, + revertFieldOperation, + runFieldOperation, +} from "./fields-operations/index.ts"; +import type { SiteGraphStore } from "./store.ts"; +import type { SuiteContext } from "./testing-taxonomy.ts"; + +const person: Actor = { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" }; +const L = (label: string) => ({ en: { label } }); + +export const STAGE3_FIELDS: [FieldDefinition, ...FieldDefinition[]] = [ + { key: "startsAt", type: "datetime", labels: L("Starts"), localized: false }, + { key: "endsAt", type: "datetime", labels: L("Ends"), localized: false }, + { key: "day", type: "date", labels: L("Day"), localized: false }, + { key: "venue", type: "text", labels: L("Venue"), localized: true }, + { key: "city", type: "text", labels: L("City"), localized: false }, + { key: "price", type: "number", labels: L("Price"), localized: false }, + { + key: "eventStatus", + type: "select", + labels: L("Status"), + localized: false, + options: [ + { value: "scheduled", labels: { en: "Scheduled" } }, + { value: "cancelled", labels: { en: "Cancelled" } }, + { value: "sold-out", labels: { en: "Sold out" } }, + ], + }, + { key: "notes", type: "richText", labels: L("Notes"), localized: true }, +]; + +export const STAGE3_PRESET: NonNullable = { + type: "Event", + properties: { + startDate: { kind: "field", field: "startsAt" }, + endDate: { kind: "field", field: "endsAt" }, + "location.name": { kind: "field", field: "venue" }, + "location.address.addressLocality": { kind: "field", field: "city" }, + eventStatus: { kind: "field", field: "eventStatus" }, + "offers.price": { kind: "field", field: "price" }, + "offers.priceCurrency": { kind: "constant", value: "TRY" }, + }, +}; + +const LABELS = { en: { singular: "Event", plural: "Events" } }; + +/** The full stage 3 settings of the `events` collection. */ +export const STAGE3_EVENTS: CollectionSettings = { + labels: LABELS, + fields: STAGE3_FIELDS, + listFields: ["startsAt", "eventStatus", "price", "day"], + expiry: { field: "endsAt", action: "unlist", afterMinutes: 60 }, + structuredData: STAGE3_PRESET, +}; + +interface Refusal { + code: string; + issues: { path: string; code: string }[]; + details: Record; +} + +async function refusal(p: Promise): Promise { + try { + await p; + } catch (e) { + if (!(e instanceof InvariantError)) throw e; + const issues = (e.details.issues ?? []) as Refusal["issues"]; + return { code: e.code, issues, details: e.details as Record }; + } + throw new Error("expected a rejection"); +} + +const list = (extra: Partial
= {}): Section => + ({ + id: "sec_upcoming0001", + kind: "contentList", + collection: "events", + limit: 10, + sort: "newest", + ...extra, + }) as Section; + +const UPCOMING = { + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, +}; + +export function listingsSuite(ctx: SuiteContext): void { + describe("RFC-0042 collections stage 3 in the store", () => { + let n = 0; + const store = (): SiteGraphStore => ctx.store(); + const fresh = async (site: Site) => (await store().getSite(site.id)) as Site; + + async function setup(events: CollectionSettings | null = STAGE3_EVENTS): Promise { + n++; + const project = await store().createProject({ + name: "Listings", + slug: `listings-${n}`, + ownerId: person.id, + siteIds: [], + }); + return store().createSite({ + projectId: project.id, + name: "Listings", + slug: `listings-${n}`, + defaultLocale: "en", + locales: ["en", "tr"], + domains: [], + pageIds: [], + theme: { name: "blog" }, + timeZone: "Europe/Istanbul", + collections: events ? { events } : {}, + }); + } + const withEvents = async (site: Site, patch: Record, dryRun = false) => { + const s = await fresh(site); + const events = { ...s.collections?.events, ...patch } as CollectionSettings; + return store().updateSite({ ...s, collections: { ...s.collections, events } }, { dryRun }); + }; + const page = async (site: Site, sections: Section[], path = "/upcoming") => + ( + await store().createPage(await fresh(site), { + siteId: site.id, + path, + title: "Upcoming", + locale: "en", + status: "draft", + sections, + }) + ).page; + const run = (site: Site, op: FieldOperation) => + runFieldOperation(store(), site.id, op, { actor: person }); + + it("accepts the stage 3 settings; sites without them keep saving as before", async () => { + const site = await setup(); + expect((await fresh(site)).collections?.events).toEqual(STAGE3_EVENTS); + const plain = await setup({ labels: LABELS, fields: STAGE3_FIELDS }); + const renamed = await store().updateSite({ ...(await fresh(plain)), name: "Renamed" }); + expect(renamed.collections?.events).toEqual({ + labels: LABELS, + fields: STAGE3_FIELDS, + }); + // A plain contentList section is stored exactly as written. + const p = await page(plain, [list()]); + expect(p.sections).toEqual([list()]); + }); + + it("refuses list fields and expiry fields that do not fit, dry run included", async () => { + const site = await setup(); + const unknown = await refusal(withEvents(site, { listFields: ["startsAt", "nope"] })); + expect(unknown.code).toBe("unknown_field"); + expect(unknown.issues.map((i) => i.path)).toEqual(["collections/events/listFields/1"]); + const text = await refusal(withEvents(site, { expiry: { field: "city", action: "hide" } })); + expect(text.code).toBe("invalid_expiry"); + expect(text.issues[0]?.path).toBe("collections/events/expiry/field"); + const missing = await refusal( + withEvents(site, { expiry: { field: "gone", action: "unlist" } }, true), + ); + expect(missing.code).toBe("unknown_field"); + // A date field works as well as a datetime. + const day = await withEvents(site, { expiry: { field: "day", action: "hide" } }); + expect(day.collections?.events?.expiry).toEqual({ field: "day", action: "hide" }); + // Dropping a field the settings name is refused by the same checks. + const fields = STAGE3_FIELDS.filter((f) => f.key !== "day"); + expect((await refusal(withEvents(site, { fields }))).code).toBe("unknown_field"); + // A new site is checked too. + const project = await store().createProject({ + name: "x", + slug: "listings-new", + ownerId: person.id, + siteIds: [], + }); + const bad = store().createSite({ + projectId: project.id, + name: "x", + slug: "listings-new", + defaultLocale: "en", + locales: ["en"], + domains: [], + pageIds: [], + theme: { name: "blog" }, + collections: { events: { ...STAGE3_EVENTS, listFields: ["nope"] } }, + }); + expect((await refusal(bad)).code).toBe("unknown_field"); + }); + + it("refuses structured-data errors; warnings are saved", async () => { + const site = await setup(); + const { startDate: _s, ...rest } = STAGE3_PRESET.properties; + const noStart = await refusal( + withEvents(site, { structuredData: { type: "Event", properties: rest } }), + ); + expect(noStart.code).toBe("structured_data_invalid"); + expect(noStart.issues.map((i) => `${i.path} ${i.code}`)).toEqual([ + "collections/events/structuredData/properties/startDate required_missing", + ]); + const mismatch = await refusal( + withEvents(site, { + structuredData: { + ...STAGE3_PRESET, + properties: { + ...STAGE3_PRESET.properties, + startDate: { kind: "field", field: "city" }, + }, + }, + }), + ); + expect(mismatch.code).toBe("structured_data_invalid"); + expect(mismatch.issues[0]?.code).toBe("source_mismatch"); + // "sold-out" is outside eventStatus's vocabulary: a warning, saved (setup already did). + expect((await fresh(site)).collections?.events?.structuredData).toEqual(STAGE3_PRESET); + }); + + it("page writes check contentList where/orderBy against the list fields", async () => { + const site = await setup(); + const bad = await refusal( + page(site, [ + list({ id: "sec_first0000001" } as Partial
), + list({ + where: [ + { field: "city", op: "eq", value: "Izmir" }, + { field: "price", op: "in", values: ["1"] }, + { field: "day", op: "gte", value: "tomorrow" }, + ], + orderBy: { field: "eventStatus", direction: "asc" }, + } as Partial
), + ]), + ); + expect(bad.code).toBe("invalid_query"); + expect(bad.issues.map((i) => i.path)).toEqual([ + "sections/1/where/0/field", + "sections/1/where/1/op", + "sections/1/where/2/value", + "sections/1/orderBy/field", + ]); + const ok = await page(site, [list(UPCOMING as Partial
)]); + expect(ok.sections[0]).toMatchObject(UPCOMING); + // Updates are checked too; an unchanged query is not re-checked. + const stored = (await store().getPage(ok.id)) as Page; + const worse = list({ where: [{ field: "venue", op: "set" }] } as Partial
); + expect((await refusal(store().updatePage({ ...stored, sections: [worse] }))).code).toBe( + "invalid_query", + ); + const retitled = await store().updatePage({ ...stored, title: "Soon" }); + expect(retitled.sections).toEqual(stored.sections); + }); + + it("list_field_in_use: sections keep the list fields and options they use", async () => { + const site = await setup(); + const p = await page(site, [ + list({ + where: [ + { field: "startsAt", op: "gte", value: "now" }, + { field: "eventStatus", op: "in", values: ["scheduled"] }, + ], + orderBy: { field: "price", direction: "desc" }, + } as Partial
), + ]); + const unlisted = await refusal(withEvents(site, { listFields: ["eventStatus", "price"] })); + expect(unlisted.code).toBe("list_field_in_use"); + expect(unlisted.details.pages).toEqual([{ id: p.id, path: "/upcoming", locale: "en" }]); + expect(unlisted.issues[0]?.path).toBe("collections/events/listFields"); + // orderBy counts as a use as well. + expect( + (await refusal(withEvents(site, { listFields: ["startsAt", "eventStatus", "day"] }))).code, + ).toBe("list_field_in_use"); + // An option the sections compare with cannot be dropped; one they do not use can. + const status = STAGE3_FIELDS[6] as Extract; + const without = (value: string) => + STAGE3_FIELDS.map((f) => + f.key === "eventStatus" + ? { ...status, options: status.options.filter((o) => o.value !== value) } + : f, + ); + expect((await refusal(withEvents(site, { fields: without("scheduled") }))).code).toBe( + "list_field_in_use", + ); + await withEvents(site, { fields: without("sold-out") }); + // A field no section uses can be unlisted. + const unlistDay = await withEvents(site, { + listFields: ["startsAt", "eventStatus", "price"], + }); + expect(unlistDay.collections?.events?.listFields).toEqual([ + "startsAt", + "eventStatus", + "price", + ]); + }); + + it("the site CAS guard catches a section added while the site write was planned", async () => { + const site = await setup(); + const p = await page(site, [list()]); + const racing = ctx.racing(async () => { + const stored = (await store().getPage(p.id)) as Page; + await store().updatePage({ ...stored, sections: [list(UPCOMING as Partial
)] }); + }); + const s = await fresh(site); + const events = { ...s.collections?.events, listFields: ["price"] } as CollectionSettings; + const raced = await refusal( + racing.updateSite({ ...s, collections: { ...s.collections, events } }), + ); + expect(raced.code).toBe("list_field_in_use"); + expect((await fresh(site)).collections?.events?.listFields).toEqual(STAGE3_EVENTS.listFields); + }); + + it("rename_field renames settings and sections in phase A, and back on revert", async () => { + const site = await setup(); + const p = await page(site, [list(UPCOMING as Partial
)]); + const op: FieldOperation = { + op: "rename_field", + collection: "events", + from: "startsAt", + to: "beginsAt", + }; + const done = await run(site, op); + expect(done.done).toBe(true); + const events = (await fresh(site)).collections?.events; + expect(events?.listFields).toEqual(["beginsAt", "eventStatus", "price", "day"]); + expect(events?.structuredData?.properties.startDate).toEqual({ + kind: "field", + field: "beginsAt", + }); + const renamed = (await store().getPage(p.id)) as Page; + expect(renamed.rev).toBe(p.rev + 1); + expect(renamed.sections[0]).toMatchObject({ + where: [{ field: "beginsAt", op: "gte", value: "now" }], + orderBy: { field: "beginsAt", direction: "asc" }, + }); + // The expiry field follows a rename as well. + const ends = await run(site, { + op: "rename_field", + collection: "events", + from: "endsAt", + to: "finishesAt", + }); + expect((await fresh(site)).collections?.events?.expiry?.field).toBe("finishesAt"); + await revertFieldOperation(store(), site.id, ends.operationId, { actor: person }); + expect((await fresh(site)).collections?.events?.expiry?.field).toBe("endsAt"); + const reverted = await revertFieldOperation(store(), site.id, done.operationId, { + actor: person, + }); + expect(reverted.done).toBe(true); + expect((await fresh(site)).collections?.events).toEqual(STAGE3_EVENTS); + expect(((await store().getPage(p.id)) as Page).sections[0]).toMatchObject(UPCOMING); + }); + + it("remove_field refuses while settings or sections name the field", async () => { + const site = await setup(); + const remove = (key: string) => run(site, { op: "remove_field", collection: "events", key }); + const listed = await refusal(remove("day")); + expect(listed.code).toBe("list_field_in_use"); + expect(listed.issues.map((i) => i.path)).toEqual(["collections/events/listFields/3"]); + const mapped = await refusal(remove("city")); + expect(mapped.issues.map((i) => i.path)).toEqual([ + "collections/events/structuredData/properties/location.address.addressLocality/field", + ]); + expect((await refusal(remove("endsAt"))).issues.map((i) => i.path)).toEqual([ + "collections/events/expiry/field", + "collections/events/structuredData/properties/endDate/field", + ]); + // Sections are named first (a dry run refuses too). + const bare = await setup({ fields: STAGE3_FIELDS, listFields: ["price"] }); + await page(bare, [ + list({ orderBy: { field: "price", direction: "asc" } } as Partial
), + ]); + const used = await refusal( + runFieldOperation( + store(), + bare.id, + { op: "remove_field", collection: "events", key: "price" }, + { actor: person, dryRun: true }, + ), + ); + expect(used.code).toBe("list_field_in_use"); + expect(used.details.pages).toMatchObject([{ path: "/upcoming" }]); + // A field nothing names is removed as before. + const gone = await run(site, { op: "remove_field", collection: "events", key: "notes" }); + expect(gone.done).toBe(true); + }); + + it("change_field_type refuses when the new type breaks settings or sections", async () => { + const site = await setup(); + const retype = (key: string, type: FieldDefinition["type"]) => + run(site, { op: "change_field_type", collection: "events", key, type }); + expect((await refusal(retype("endsAt", "text"))).code).toBe("invalid_expiry"); + expect((await refusal(retype("startsAt", "text"))).code).toBe("structured_data_invalid"); + await page(site, [ + list({ where: [{ field: "day", op: "gte", value: "now" }] } as Partial
), + ]); + const broken = await refusal(retype("day", "text")); + expect(broken.code).toBe("list_field_in_use"); + expect(broken.issues[0]?.path).toMatch(/^pages\/page_\w+\/sections\/0\/where\/0\/op$/); + // date → datetime keeps `gte now` valid and the expiry rule readable. + expect((await retype("day", "datetime")).done).toBe(true); + }); + }); +} diff --git a/packages/core-ts/src/testing.ts b/packages/core-ts/src/testing.ts index d6711dfe..8943fa58 100644 --- a/packages/core-ts/src/testing.ts +++ b/packages/core-ts/src/testing.ts @@ -9,8 +9,10 @@ import { nodeHash } from "./hash.ts"; import { SiteGraphStore } from "./store.ts"; import { assetsSuite } from "./testing-assets.ts"; import { collectionsSuite } from "./testing-collections.ts"; +import { entryQuerySuite } from "./testing-entry-query.ts"; import { fieldOperationsSuite } from "./testing-field-operations.ts"; import { fieldsSuite } from "./testing-fields.ts"; +import { listingsSuite } from "./testing-listings.ts"; import { menuSuite } from "./testing-menu.ts"; import { permalinksSuite } from "./testing-permalinks.ts"; import { racingDriver, type SuiteContext, taxonomySuite } from "./testing-taxonomy.ts"; @@ -267,5 +269,7 @@ export function describeSiteGraphStore(label: string, factory: StoreFactory): vo collectionsSuite(suite); fieldsSuite(suite); fieldOperationsSuite(suite); + listingsSuite(suite); + entryQuerySuite(suite); }); }