From bd5af8877e4a0a9c526c60347097c7dd92f53cf1 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:17:05 +0300 Subject: [PATCH] feat(core-ts,api): store and API checks for typed fields (RFC-0041 step 2) - Site writes run the definition checks and caps. Removing a key or a select option that entries still use is refused (field_change_needs_operation), checked before the write and guarded on the site CAS. - Entry writes check changed values, refuse unknown keys once a collection has definitions, refuse non-localized values on translations, check requiredToPublish on review and publish, cap fields at 256 KB, and check image and rich-text asset references in the same site, recorded in asset_refs so a used asset is not deleted. - Search indexes searchable text and rich-text fields; rows of sites without them are byte-identical. - API: new codes with details.issues (422/400/409), README. Shared suites on sqlite, D1 and Durable Object SQLite. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/typed-fields-store.md | 12 + docs/guides/search.md | 1 + packages/api/README.md | 18 +- packages/api/src/entry-fields.ts | 29 ++ packages/api/src/errors.ts | 12 + packages/api/src/routes/entries.ts | 19 +- packages/api/src/testing-fields.ts | 164 ++++++++ packages/api/test/fields-api.test.ts | 32 ++ packages/core-ts/src/assets.ts | 24 +- packages/core-ts/src/errors.ts | 23 +- packages/core-ts/src/fields/index.ts | 1 + packages/core-ts/src/fields/refusal.ts | 50 +++ packages/core-ts/src/media/field-refs.ts | 44 ++ packages/core-ts/src/media/refs-rebuild.ts | 38 ++ packages/core-ts/src/search-fields.ts | 62 +++ packages/core-ts/src/search-rows.ts | 36 +- packages/core-ts/src/search.ts | 29 +- packages/core-ts/src/store/entries.ts | 10 +- packages/core-ts/src/store/entry-fields.ts | 192 +++++++++ packages/core-ts/src/store/site-fields.ts | 106 +++++ packages/core-ts/src/store/sites.ts | 7 +- packages/core-ts/src/sync.ts | 3 +- packages/core-ts/src/testing-fields.ts | 445 +++++++++++++++++++++ packages/core-ts/src/testing.ts | 2 + scripts/size-baseline.json | 2 +- 25 files changed, 1317 insertions(+), 44 deletions(-) create mode 100644 .changeset/typed-fields-store.md create mode 100644 packages/api/src/entry-fields.ts create mode 100644 packages/api/src/testing-fields.ts create mode 100644 packages/api/test/fields-api.test.ts create mode 100644 packages/core-ts/src/fields/refusal.ts create mode 100644 packages/core-ts/src/media/field-refs.ts create mode 100644 packages/core-ts/src/media/refs-rebuild.ts create mode 100644 packages/core-ts/src/search-fields.ts create mode 100644 packages/core-ts/src/store/entry-fields.ts create mode 100644 packages/core-ts/src/store/site-fields.ts create mode 100644 packages/core-ts/src/testing-fields.ts diff --git a/.changeset/typed-fields-store.md b/.changeset/typed-fields-store.md new file mode 100644 index 00000000..68113a75 --- /dev/null +++ b/.changeset/typed-fields-store.md @@ -0,0 +1,12 @@ +--- +"@cloudhal/core-ts": minor +"@cloudhal/api": minor +--- + +Typed custom fields, step 2 (RFC-0041 / ADR-0043): the store and the API enforce field definitions. + +- **Site writes** (`updateSite`, `createSite`, `PUT /api/sites/:id/collections/:key`, dry run included) check definitions: reserved (`field_key_reserved`) and duplicate keys or options, `minimum` above `maximum`, 30 fields per collection and 200 per site (`too_many_fields`), 256 KB of collection settings (`too_large`). A plain edit never loses a value: changing a field's `type`, `localized` or `multiple`, or dropping a key or a select option that an entry still holds, is refused with `409 field_change_needs_operation`, naming the field operation to use. Other site writes do not re-run the checks. +- **Entry writes** check only the field values that changed: `422 invalid_fields`, `unknown_field` (a new key without a definition, once the collection has definitions; stored unknown keys are kept), `field_not_localized` (a translation's own copy of a non-localized value) and `required_field` (review or published without a `requiredToPublish` value; drafts save freely). `fields` is capped at 256 KB per entry in every collection when it changes (`too_large`). Image values must be image assets of the site, and image and rich-text asset references are recorded in `asset_refs` under `fields.`, so a used file cannot be deleted. Every refusal lists its problems with paths in `details.issues` (`fields/startsAt/at`). +- **API inputs** remove a key with `null` in a collection that has definitions. Collections without definitions keep `fields` as any JSON, exactly as before. +- **Search**: `text` and `richText` fields marked `searchable` add their words to the entry's search text at the next publish (off by default; sites without searchable fields index byte for byte as before). +- `refuseFieldIssues` and `fieldRefusalCode` are exported from `@cloudhal/core-ts/fields`. diff --git a/docs/guides/search.md b/docs/guides/search.md index d3689fa7..470efe48 100644 --- a/docs/guides/search.md +++ b/docs/guides/search.md @@ -14,6 +14,7 @@ The design and its reasons are in [RFC-0029](../rfcs/0029-search-providers-and-s - **Published content only.** - Found: articles, pages, categories and tags, and authors. - Never found: drafts, pages marked `noindex`, embargoed or expired content outside its time window. +- **Custom fields** (RFC-0041) are not searched unless a text or rich-text field is marked *searchable* in its collection's settings. Then its words count as part of the article's text from the next publish on. A translation without its own value uses the original's. - **Categories, tags and authors** are found only while at least one article readers can see carries them. This is the same rule that decides whether their archive page exists. - **Matching.** Every word must start a word of the title, summary or text, and title matches come first. Accents and Turkish letters are folded, so "cay" finds "Çay". - **Language.** Results come in the site's default language. diff --git a/packages/api/README.md b/packages/api/README.md index f07f50d6..bc011e98 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -12,12 +12,26 @@ 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) | +| `/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/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/snapshots` | create (`{label, publish}`), list, `:id/publish`, `:id/rollback`, `:a/diff/:b` | | `/api/agent-keys` | API keys for agents: `POST {name, scopes}` (key shown once), `GET`, `DELETE /:id` — signed-in humans only | | `/mcp` | the Cloudhal MCP server (ADR-0011); session cookie → human, `x-api-key` or `Authorization: Bearer ` → agent limited to the key's scopes (REST answers agent keys with a pointer to `/mcp`) | -Errors: `400 validation` (contract errors with ajv details), `409 conflict` (`expectedRev`/`actualRev`), `409 unique`, `404 not_found`, `401 unauthorized`. +Errors: `400 validation` (contract errors with ajv details), `409 conflict` (`expectedRev`/`actualRev`), `409 unique`, `404 not_found`, `401 unauthorized`. Store invariants answer with their code (`src/errors.ts`, `INVARIANT_STATUS`). + +Typed fields (RFC-0041) answer with every problem and its path in `details.issues` (`[{ path: "fields/startsAt/at", code, message }]`): + +| Code | Status | When | +|---|---|---| +| `invalid_fields` | 422 | values that do not fit their definitions (or several kinds of problem at once) | +| `unknown_field` | 422 | a new key without a definition, in a collection that has definitions | +| `field_not_localized` | 422 | a translation's own copy of a non-localized value (set it on the original) | +| `required_field` | 422 | an entry in review or published without a `requiredToPublish` value | +| `too_large` | 409 | `fields` over 256 KB, or collection settings over 256 KB | +| `field_key_reserved`, `field_key_duplicate`, `field_option_duplicate`, `field_bounds` | 400 | definitions that can never be saved as written | +| `too_many_fields` | 422 | over 30 fields per collection, 200 per site or 50 options per select | +| `field_change_needs_operation` | 409 | changing a field's type, localization or `multiple`, or dropping a key or option that entries still hold | ```ts import { createApi } from "@cloudhal/api"; diff --git a/packages/api/src/entry-fields.ts b/packages/api/src/entry-fields.ts new file mode 100644 index 00000000..fec8a1e8 --- /dev/null +++ b/packages/api/src/entry-fields.ts @@ -0,0 +1,29 @@ +/** + * RFC-0041 §2: `null` is never stored. In a collection with typed fields an API input uses `null` + * to remove a key, so the entry routes drop such keys before the store checks the values + * (`invalid_fields`, `unknown_field`, … with each issue's path in `details.issues`). Collections + * without definitions keep `fields` as "any JSON", nulls included, as before. + */ + +import type { SiteGraphStore } from "@cloudhal/core-ts"; +import { fieldDefinitions } from "@cloudhal/core-ts/fields"; + +const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** `input` with `null` field values removed when its collection defines fields. */ +export async function withoutNullFields( + store: SiteGraphStore, + siteId: string, + input: T, +): Promise { + const fields = input.fields; + if (!isRecord(fields) || !Object.values(fields).includes(null)) return input; + if (typeof input.collection !== "string") return input; + const site = await store.getSite(siteId); + if (!site || fieldDefinitions(site, input.collection).length === 0) return input; + return { + ...input, + fields: Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== null)), + }; +} diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts index 7d95486a..ccbffc3e 100644 --- a/packages/api/src/errors.ts +++ b/packages/api/src/errors.ts @@ -69,6 +69,18 @@ export const INVARIANT_STATUS: Record = new Hono() .post("/", async (c) => { const siteId = param(c, "siteId"); if (!(await c.get("store").getSite(siteId))) throw notFound("site not found"); - const input = (await c.req.json()) as Omit< - ContentEntry, - "id" | "rev" | "createdAt" | "updatedAt" | "schemaVersion" - >; + // RFC-0041: in a collection with typed fields, `null` removes a key. + const input = await withoutNullFields( + c.get("store"), + siteId, + (await c.req.json()) as Omit< + ContentEntry, + "id" | "rev" | "createdAt" | "updatedAt" | "schemaVersion" + >, + ); // RFC-0040 §5: a new collection is declared first (Studio's New collection). const registry = await c.get("store").listCollections(siteId); if (typeof input.collection === "string" && !isRegistered(registry, input.collection)) @@ -104,9 +110,10 @@ export const entries: Hono = new Hono() return c.json(entry); }) .put("/:entryId", async (c) => { - const body = (await c.req.json()) as ContentEntry; - if (body.id !== param(c, "entryId") || body.siteId !== param(c, "siteId")) + const raw = (await c.req.json()) as ContentEntry; + if (raw.id !== param(c, "entryId") || raw.siteId !== param(c, "siteId")) throw badRequest("ids must match the URL"); + const body = await withoutNullFields(c.get("store"), raw.siteId, raw); const limit = entryLimit(c.get("principal")); if (limit) { const stored = await c.get("store").getContentEntry(body.id); diff --git a/packages/api/src/testing-fields.ts b/packages/api/src/testing-fields.ts new file mode 100644 index 00000000..e4f460f9 --- /dev/null +++ b/packages/api/src/testing-fields.ts @@ -0,0 +1,164 @@ +/** + * Shared API suite for RFC-0041 step 2: entry routes validate typed field values through the store + * and answer with each issue's path (`details.issues`); `null` removes a key in a collection with + * definitions; `PUT /collections/:key` takes definitions with the §6 guard (dry run included). + * Runs against any SiteGraphStore backend, like `testing.ts`. + */ +import type { 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 FIELDS: FieldDefinition[] = [ + { + key: "venue", + type: "text", + labels: { en: { label: "Venue" } }, + localized: true, + requiredToPublish: true, + }, + { + key: "seats", + type: "number", + labels: { en: { label: "Seats" } }, + localized: false, + integer: true, + }, + { + key: "level", + type: "select", + labels: { en: { label: "Level" } }, + localized: false, + options: [ + { value: "beginner", labels: { en: "Beginner" } }, + { value: "expert", labels: { en: "Expert" } }, + ], + }, +]; +const EVENTS = { labels: { en: { singular: "Event", plural: "Events" } }, fields: FIELDS }; + +export function describeFieldsApi(label: string, factory: ApiStoreFactory): void { + describe(`API typed fields: ${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, query = "") => + call(`/api/sites/${siteId}/collections/events${query}`, { + method: "PUT", + body: JSON.stringify({ settings, rev: (await site()).rev }), + }); + const create = (fields: Json, status = "draft") => + call(`/api/sites/${siteId}/entries`, { + method: "POST", + body: JSON.stringify({ + collection: "events", + slug: `e-${Math.random().toString(36).slice(2, 8)}`, + locale: "en", + status, + title: "Launch", + body: { format: "markdown", value: "Body" }, + fields, + tags: [], + }), + }); + 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: "Fields", slug: "fields" })).id; + app = createApi({ + store, + resolveActor: async (c) => (c.req.header("x-test-user") ? human : undefined), + }); + }); + + afterAll(async () => { + await factory.dispose?.(); + }); + + it("declares fields; a dry run checks them and writes nothing", async () => { + const dry = await put(EVENTS, "?dryRun=1"); + expect(dry.status, JSON.stringify(dry.body)).toBe(200); + expect((await site()).collections?.events).toBeUndefined(); + const bad = await put({ fields: [{ ...FIELDS[0], key: "title" }] }, "?dryRun=1"); + expect(bad.status).toBe(400); + expect(bad.body.error).toBe("field_key_reserved"); + expect(issues(bad.body)).toEqual(["collections/events/fields/0/key field_key_reserved"]); + const saved = await put(EVENTS); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + expect((await site()).collections?.events?.fields).toEqual(FIELDS); + }); + + it("refuses a definition change that would lose stored values (409)", async () => { + expect((await put(EVENTS)).status).toBe(200); + expect((await create({ level: "expert" })).status).toBe(201); + const dropped = await put({ ...EVENTS, fields: FIELDS.filter((f) => f.key !== "level") }); + expect(dropped.status).toBe(409); + expect(dropped.body.error).toBe("field_change_needs_operation"); + expect(dropped.body.message).toContain("remove_field"); + const retyped = await put({ + ...EVENTS, + fields: FIELDS.map((f) => + f.key === "seats" + ? { key: "seats", type: "text", labels: f.labels, localized: false } + : f, + ), + }); + expect(retyped.status).toBe(409); + expect(retyped.body.error).toBe("field_change_needs_operation"); + }); + + it("entry routes answer value problems with paths (422)", async () => { + expect((await put(EVENTS)).status).toBe(200); + const bad = await create({ seats: 1.5, level: "master" }); + expect(bad.status).toBe(422); + expect(bad.body.error).toBe("invalid_fields"); + expect(issues(bad.body).sort()).toEqual([ + "fields/level invalid_value", + "fields/seats invalid_value", + ]); + const unknown = await create({ colour: "red" }); + expect(unknown.status).toBe(422); + expect(unknown.body.error).toBe("unknown_field"); + const required = await create({ seats: 10 }, "review"); + expect(required.status).toBe(422); + expect(required.body.error).toBe("required_field"); + expect(issues(required.body)).toEqual(["fields/venue required_field"]); + }); + + it("null removes a key in a collection with fields, on create and update", async () => { + expect((await put(EVENTS)).status).toBe(200); + const made = await create({ venue: "Hall", seats: 10, level: null }); + expect(made.status, JSON.stringify(made.body)).toBe(201); + expect(made.body.fields).toEqual({ venue: "Hall", seats: 10 }); + const updated = await call(`/api/sites/${siteId}/entries/${made.body.id}`, { + method: "PUT", + body: JSON.stringify({ ...made.body, fields: { venue: "Hall", seats: null } }), + }); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + expect(updated.body.fields).toEqual({ venue: "Hall" }); + }); + + it("collections without fields keep any JSON, nulls included", async () => { + expect((await put({ labels: EVENTS.labels })).status).toBe(200); + const loose = { anything: [1, null], gone: null }; + const made = await create(loose); + expect(made.status, JSON.stringify(made.body)).toBe(201); + expect(made.body.fields).toEqual(loose); + }); + }); +} diff --git a/packages/api/test/fields-api.test.ts b/packages/api/test/fields-api.test.ts new file mode 100644 index 00000000..c0788b40 --- /dev/null +++ b/packages/api/test/fields-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 { describeFieldsApi } from "../src/testing-fields.ts"; + +describeFieldsApi("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(); +describeFieldsApi("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/assets.ts b/packages/core-ts/src/assets.ts index 546ee3fb..d482d3b0 100644 --- a/packages/core-ts/src/assets.ts +++ b/packages/core-ts/src/assets.ts @@ -31,6 +31,7 @@ import { ID_PREFIX } from "./ids.ts"; import { RASTER_TYPES } from "./media/image.ts"; import { type InspectOptions, inspectUpload } from "./media/ingest.ts"; import { type AssetRefUse, assetRefsOf, canonicalImageRefs } from "./media/refs.ts"; +import { refsRebuild } from "./media/refs-rebuild.ts"; import { sniff } from "./media/sniff.ts"; import { assetKindFor, @@ -1021,7 +1022,7 @@ export class AssetEngine { /** Rebuilds a site's `asset_refs` from its current nodes (sync import, repair). */ async rebuildRefsStatements(siteId: string): Promise { const rows = await this.#sql.all<{ id: string; kind: string; body: string }>( - "SELECT id, kind, body FROM nodes WHERE site_id = ? AND kind IN ('page', 'contentEntry', 'term')", + "SELECT id, kind, body FROM nodes WHERE site_id = ? AND kind IN ('page', 'contentEntry', 'term', 'site')", [siteId], ); return refsRebuild(siteId, rows); @@ -1354,27 +1355,6 @@ function refsKey(refs: readonly AssetRefUse[]): string { return refs.map((r) => `${r.assetId}\t${r.field}`).join("\n"); } -/** `asset_refs` for a whole site from node rows (DELETE + one INSERT). */ -export function refsRebuild( - siteId: string, - rows: readonly { id: string; kind: string; body: string }[], -): SqlStatement[] { - const all: { a: string; n: string; k: string; f: string }[] = []; - for (const r of rows) - for (const ref of assetRefsOf(r.kind, JSON.parse(r.body))) - all.push({ a: ref.assetId, n: r.id, k: r.kind, f: ref.field }); - const out: SqlStatement[] = [ - { sql: "DELETE FROM asset_refs WHERE site_id = ?", params: [siteId] }, - ]; - if (all.length > 0) - out.push({ - sql: `INSERT OR IGNORE INTO asset_refs (site_id, asset_id, node_id, node_kind, field) - SELECT ?, json_extract(value, '$.a'), json_extract(value, '$.n'), json_extract(value, '$.k'), json_extract(value, '$.f') FROM json_each(?)`, - params: [siteId, json(all)], - }); - return out; -} - function encodeCursor(at: string, id: string): string { return btoa(`${at}|${id}`).replace(/=+$/, ""); } diff --git a/packages/core-ts/src/errors.ts b/packages/core-ts/src/errors.ts index 8ef2771d..779ede6c 100644 --- a/packages/core-ts/src/errors.ts +++ b/packages/core-ts/src/errors.ts @@ -174,7 +174,28 @@ export type InvariantCode = /** A declared collection with entries (or named by a taxonomy) cannot be undeclared. */ | "collection_in_use" /** `create_collection` for a key that is already declared. */ - | "collection_exists"; + | "collection_exists" + // ── RFC-0041 (typed custom fields); `details.issues` lists every problem with its path ── + /** Field values that do not fit their definitions (or several kinds of problem at once). */ + | "invalid_fields" + /** A new key without a definition in a collection that has definitions. */ + | "unknown_field" + /** A translation holds its own copy of a non-localized value. */ + | "field_not_localized" + /** An entry in review or published lacks a `requiredToPublish` value. */ + | "required_field" + /** A field key that is a built-in entry property or JavaScript name. */ + | "field_key_reserved" + /** A field key used twice in one collection. */ + | "field_key_duplicate" + /** A select option value listed twice. */ + | "field_option_duplicate" + /** A number field's `minimum` above its `maximum`. */ + | "field_bounds" + /** More fields than a collection (30) or a site (200) may have, or options than a select (50). */ + | "too_many_fields" + /** A definition change that could lose or reinterpret stored values; it needs a field operation. */ + | "field_change_needs_operation"; export class InvariantError extends Error { override readonly name = "InvariantError"; diff --git a/packages/core-ts/src/fields/index.ts b/packages/core-ts/src/fields/index.ts index af665179..89189e30 100644 --- a/packages/core-ts/src/fields/index.ts +++ b/packages/core-ts/src/fields/index.ts @@ -22,6 +22,7 @@ export { fieldsPropsSchema, INSTANT_PATTERN, } from "./props.ts"; +export { fieldRefusalCode, refuseFieldIssues } from "./refusal.ts"; export { type FieldsEntry, type FieldsOriginal, diff --git a/packages/core-ts/src/fields/refusal.ts b/packages/core-ts/src/fields/refusal.ts new file mode 100644 index 00000000..d6f2c9dc --- /dev/null +++ b/packages/core-ts/src/fields/refusal.ts @@ -0,0 +1,50 @@ +/** + * RFC-0041 §3, §8: field issues as the store's refusal. Every issue keeps its slash path in + * `details.issues`, so the API and MCP answer with each problem where it sits. The error's code is + * the issues' shared code (`invalid_value` reads `invalid_fields`); mixed value problems are + * `invalid_fields`, and mixed definition problems take the first one's code, with + * `field_change_needs_operation` first because it names what to do. + */ +import { type InvariantCode, InvariantError } from "../errors.ts"; +import type { FieldIssue, FieldIssueCode } from "./types.ts"; + +/** Issue codes about definitions (site writes) rather than values (entry writes). */ +const DEFINITION_CODES = new Set([ + "field_key_reserved", + "field_key_duplicate", + "field_option_duplicate", + "field_bounds", + "too_many_fields", + "field_change_needs_operation", +]); + +const asInvariant = (code: FieldIssueCode): InvariantCode => + code === "invalid_value" || code === "field_value_unreadable" ? "invalid_fields" : code; + +/** The refusal's code for a non-empty list of issues. */ +export function fieldRefusalCode(issues: readonly FieldIssue[]): InvariantCode { + const first = issues[0]; + if (!first) return "invalid_fields"; + if (issues.every((i) => i.code === first.code)) return asInvariant(first.code); + if (issues.some((i) => i.code === "field_change_needs_operation")) + return "field_change_needs_operation"; + const definition = issues.find((i) => DEFINITION_CODES.has(i.code)); + return definition ? asInvariant(definition.code) : "invalid_fields"; +} + +/** Throws the store's refusal for `issues`; returns when there are none. */ +export function refuseFieldIssues( + issues: readonly FieldIssue[], + details: Record = {}, +): void { + if (issues.length === 0) return; + const shown = issues + .slice(0, 5) + .map((i) => `${i.path}: ${i.message}`) + .join("; "); + const more = issues.length > 5 ? ` (and ${issues.length - 5} more)` : ""; + throw new InvariantError(fieldRefusalCode(issues), `${shown}${more}`, { + ...details, + issues: [...issues], + }); +} diff --git a/packages/core-ts/src/media/field-refs.ts b/packages/core-ts/src/media/field-refs.ts new file mode 100644 index 00000000..56118493 --- /dev/null +++ b/packages/core-ts/src/media/field-refs.ts @@ -0,0 +1,44 @@ +/** + * RFC-0041 §2: typed custom fields refer to assets too. An `image` field's value is an asset id, + * and a `richText` field's `asset:` links and images count like a body's (RFC-0004 §4.4). Each is + * recorded in `asset_refs` under `fields.`, so a used asset cannot be deleted. Only fields a + * collection defines count: in a collection without definitions `fields` stays "any JSON". + * Worker-safe. + */ +import type { FieldDefinition } from "@cloudhal/core-contracts"; +import { ASSET_ID_PATTERN } from "../fields/props.ts"; +import { type AssetRefUse, markdownAssetIds } from "./refs.ts"; + +const ASSET_ID = new RegExp(ASSET_ID_PATTERN); + +/** Every asset reference of an entry's field values under `defs`, sorted like `assetRefsOf`. */ +export function fieldAssetRefs( + defs: readonly FieldDefinition[], + values: Readonly> | undefined, +): AssetRefUse[] { + if (!values || defs.length === 0) return []; + const out = new Map(); + const add = (assetId: string, field: string) => + out.set(`${assetId}\n${field}`, { assetId, field }); + for (const def of defs) { + const value = values[def.key]; + if (typeof value !== "string") continue; + const field = `fields.${def.key}`; + if (def.type === "image" && ASSET_ID.test(value)) add(value, field); + else if (def.type === "richText" && value.includes("asset:")) + for (const id of markdownAssetIds(value)) add(id, field); + } + return [...out.values()].sort( + (a, b) => a.assetId.localeCompare(b.assetId) || a.field.localeCompare(b.field), + ); +} + +/** Field definitions per collection of a site node (`{}` when it has none). */ +export function siteFieldDefinitions(site: { + collections?: Record | undefined; +}): Record { + const out: Record = {}; + for (const [key, settings] of Object.entries(site.collections ?? {})) + if (settings?.fields?.length) out[key] = settings.fields; + return out; +} diff --git a/packages/core-ts/src/media/refs-rebuild.ts b/packages/core-ts/src/media/refs-rebuild.ts new file mode 100644 index 00000000..cadbd04c --- /dev/null +++ b/packages/core-ts/src/media/refs-rebuild.ts @@ -0,0 +1,38 @@ +/** + * `asset_refs` for a whole site from its node rows (RFC-0004 §4.4): sync import and repair. Entry + * rows also count their typed fields' references (RFC-0041 §2) when the site's own row is among + * them. Worker-safe. + */ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import type { SqlStatement } from "@cloudhal/storage"; +import { fieldAssetRefs, siteFieldDefinitions } from "./field-refs.ts"; +import { assetRefsOf } from "./refs.ts"; + +/** `asset_refs` for a whole site from node rows (DELETE + one INSERT). */ +export function refsRebuild( + siteId: string, + rows: readonly { id: string; kind: string; body: string }[], +): SqlStatement[] { + const siteRow = rows.find((r) => r.kind === "site" && r.id === siteId); + const defs = siteRow ? siteFieldDefinitions(JSON.parse(siteRow.body) as Site) : {}; + const all: { a: string; n: string; k: string; f: string }[] = []; + for (const r of rows) { + const node = JSON.parse(r.body); + const refs = assetRefsOf(r.kind, node); + if (r.kind === "contentEntry") { + const entry = node as ContentEntry; + refs.push(...fieldAssetRefs(defs[entry.collection] ?? [], entry.fields)); + } + for (const ref of refs) all.push({ a: ref.assetId, n: r.id, k: r.kind, f: ref.field }); + } + const out: SqlStatement[] = [ + { sql: "DELETE FROM asset_refs WHERE site_id = ?", params: [siteId] }, + ]; + if (all.length > 0) + out.push({ + sql: `INSERT OR IGNORE INTO asset_refs (site_id, asset_id, node_id, node_kind, field) + SELECT ?, json_extract(value, '$.a'), json_extract(value, '$.n'), json_extract(value, '$.k'), json_extract(value, '$.f') FROM json_each(?)`, + params: [siteId, JSON.stringify(all)], + }); + return out; +} diff --git a/packages/core-ts/src/search-fields.ts b/packages/core-ts/src/search-fields.ts new file mode 100644 index 00000000..8043cbcc --- /dev/null +++ b/packages/core-ts/src/search-fields.ts @@ -0,0 +1,62 @@ +/** + * RFC-0041 §10: `text` and `richText` fields marked `searchable` add their words to an entry's + * search text (no new column, no FTS5 change). Off by default: a theme may not show a field, and + * a snippet would leak it. Values are read as `resolveFields` reads them — a translation's empty + * localized field and its non-localized ones come from the original when it is passed — but only + * strings count, so the site Worker needs no checker here. Worker-safe, imports types only. + */ +import type { ContentEntry, FieldDefinition } from "@cloudhal/core-contracts"; +import type { PermalinkSite } from "./permalinks.ts"; + +type Original = Pick; + +/** The collection's searchable text fields (empty for most sites, which then index as before). */ +export function searchableFields( + site: PermalinkSite | undefined, + collection: string, +): FieldDefinition[] { + return (site?.collections?.[collection]?.fields ?? []).filter( + (d) => (d.type === "text" || d.type === "richText") && d.searchable === true, + ); +} + +/** True when the entry reads values from `original` (a translation in the same site). */ +function readsOriginal(entry: ContentEntry, original: Original | undefined): boolean { + const t = entry.translation; + if (!t || t.group === entry.id || t.source?.siteId !== undefined) return false; + return original?.id === t.group; +} + +/** The entry's searchable field values in definition order, as markdown or plain text. */ +export function searchableFieldTexts( + site: PermalinkSite | undefined, + entry: ContentEntry, + original?: Original, +): string[] { + const defs = searchableFields(site, entry.collection); + if (defs.length === 0) return []; + const fromOriginal = readsOriginal(entry, original); + const out: string[] = []; + for (const def of defs) { + const own = entry.fields?.[def.key]; + const theirs = fromOriginal ? original?.fields?.[def.key] : undefined; + const value = !def.localized && fromOriginal ? theirs : (own ?? theirs); + if (typeof value === "string" && value.trim() !== "") out.push(value); + } + return out; +} + +/** Whether the searchable definitions differ between two site versions (every entry row is rewritten). */ +export function searchableChanged( + before: PermalinkSite | undefined, + after: PermalinkSite | undefined, +): boolean { + const keys = (site: PermalinkSite | undefined) => + JSON.stringify( + Object.entries(site?.collections ?? {}) + .map(([c]) => [c, searchableFields(site, c).map((d) => [d.key, d.localized])]) + .filter(([, defs]) => (defs as unknown[]).length > 0) + .sort(), + ); + return keys(before) !== keys(after); +} diff --git a/packages/core-ts/src/search-rows.ts b/packages/core-ts/src/search-rows.ts index 0b978faa..08525cc3 100644 --- a/packages/core-ts/src/search-rows.ts +++ b/packages/core-ts/src/search-rows.ts @@ -7,6 +7,7 @@ import type { ContentEntry, Page, Taxonomy, Term } from "@cloudhal/core-contract import { termName } from "./names.ts"; import { entryPath, type PermalinkSite } from "./permalinks.ts"; import { MARK_CLOSE, MARK_OPEN, SEARCH_TEXT_MAX, type SearchRow } from "./search-common.ts"; +import { searchableFieldTexts } from "./search-fields.ts"; import { termSlug } from "./term-locales.ts"; const iso = (value: string | undefined) => { @@ -67,13 +68,34 @@ function entryTermIds(entry: ContentEntry): string[] { return [...new Set(Object.values(entry.terms ?? {}).flatMap((a) => a?.ids ?? []))]; } +/** + * An article's body text, then the words of its `searchable` fields (RFC-0041 §10), when it has + * any; exactly `searchText(body)` otherwise. + */ +function entryText( + entry: ContentEntry, + site: PermalinkSite | undefined, + original: Pick | undefined, +): string { + const body = searchText(entry.body); + const fields = searchableFieldTexts(site, entry, original); + if (fields.length === 0) return body; + const words = fields.map((value) => searchText({ format: "markdown", value })); + return clip([body, ...words].filter(Boolean).join(" ")); +} + /** * The index row of an article, or undefined when readers never see it (not published; with - * `drafts`, as `cloudhal dev` shows them, only archived ones are left out). + * `drafts`, as `cloudhal dev` shows them, only archived ones are left out). `original` is the + * translation group's original, read for a translation's searchable fields. */ export function searchRowOf( entry: ContentEntry, - options: { drafts?: boolean; site?: PermalinkSite | undefined } = {}, + options: { + drafts?: boolean; + site?: PermalinkSite | undefined; + original?: Pick | undefined; + } = {}, ): SearchRow | undefined { if (!shown(entry.status, options.drafts)) return undefined; const terms = entryTermIds(entry); @@ -83,7 +105,7 @@ export function searchRowOf( kind: "article", title: entry.title, summary: entry.summary ?? "", - body: searchText(entry.body), + body: entryText(entry, options.site, options.original), href: entryPath(options.site, entry), publishedAt: iso(entry.publishedAt), gateFrom: iso(entry.availability?.from), @@ -248,8 +270,14 @@ export function searchDocuments(input: { const options = { drafts: input.drafts ?? false }; const entryOptions = { ...options, site: input.site }; const byId = new Map(input.taxonomies.map((t) => [t.id, t])); + const entries = new Map(input.entries.map((e) => [e.id, e])); const rows: (SearchRow | undefined)[] = [ - ...input.entries.map((e) => searchRowOf(e, entryOptions)), + ...input.entries.map((e) => + searchRowOf(e, { + ...entryOptions, + original: e.translation ? entries.get(e.translation.group) : undefined, + }), + ), ...input.pages.map((p) => pageSearchRow(p, options)), ]; for (const term of input.terms) { diff --git a/packages/core-ts/src/search.ts b/packages/core-ts/src/search.ts index 1c821f18..e6d75941 100644 --- a/packages/core-ts/src/search.ts +++ b/packages/core-ts/src/search.ts @@ -34,6 +34,7 @@ import { searchKinds, searchPaging, } from "./search-common.ts"; +import { searchableChanged, searchableFields } from "./search-fields.ts"; import { pageSearchRow, searchRowOf, termSearchRows } from "./search-rows.ts"; export * from "./search-common.ts"; @@ -200,8 +201,11 @@ export async function planSearch(input: { : undefined; for (const ref of rewrite) { const node = await input.read(ref); - if (ref.kind === "contentEntry") pushRow(rows, searchRowOf(node as ContentEntry, { site })); - else if (ref.kind === "page") pushRow(rows, pageSearchRow(node as Page)); + if (ref.kind === "contentEntry") { + const entry = node as ContentEntry; + const original = await originalFor(entry, site, target, input.read); + pushRow(rows, searchRowOf(entry, { site, original })); + } else if (ref.kind === "page") pushRow(rows, pageSearchRow(node as Page)); else if (archives) { const term = node as Term; const taxonomy = archives.taxonomies.get(term.taxonomyId); @@ -225,7 +229,10 @@ export async function planSearch(input: { }; } -/** Whether the site node's collection templates differ between `base` and `target`. */ +/** + * Whether every entry row changes with the site node between `base` and `target`: a moved + * collection template (RFC-0038, new hrefs) or other searchable fields (RFC-0041 §10, new words). + */ async function permalinksMoved( target: Snap, base: Snap | undefined, @@ -235,7 +242,21 @@ async function permalinksMoved( const before = base?.nodes.find((n) => n.kind === "site"); if (!after || !before || after.hash === before.hash) return false; const [a, b] = [(await read(after)) as Site, (await read(before)) as Site]; - return changedPermalinks(b, a).length > 0; + return changedPermalinks(b, a).length > 0 || searchableChanged(b, a); +} + +/** A translation's original in `target`, read only when its collection has searchable fields. */ +async function originalFor( + entry: ContentEntry, + site: Site | undefined, + target: Snap, + read: (ref: NodeRef) => Promise, +): Promise { + const group = entry.translation?.group; + if (!group || group === entry.id || searchableFields(site, entry.collection).length === 0) + return undefined; + const ref = target.nodes.find((n) => n.id === group && n.kind === "contentEntry"); + return ref ? ((await read(ref)) as ContentEntry) : undefined; } function pushRow(rows: SearchRow[], row: SearchRow | undefined): void { diff --git a/packages/core-ts/src/store/entries.ts b/packages/core-ts/src/store/entries.ts index 9a1943ab..54373c20 100644 --- a/packages/core-ts/src/store/entries.ts +++ b/packages/core-ts/src/store/entries.ts @@ -13,6 +13,7 @@ import { entryPath } from "../permalinks.ts"; import { canonicalTerms } from "../taxonomy.ts"; import { entersCollection, planNewCollection } from "./collection-paths.ts"; import type { StoreContext } from "./context.ts"; +import { planEntryFields } from "./entry-fields.ts"; import { checkLocalizedNode, checkTranslationLinks } from "./translations.ts"; import type { NodeWriteOptions } from "./types.ts"; import { checkBodyDirectives } from "./validation.ts"; @@ -59,6 +60,8 @@ export class EntryEngine { assertValid("content-entry", canonicalTerms(draft)); checkLocalizedNode("contentEntry", site, draft); await checkTranslationLinks(this.#ctx, { kind: "contentEntry", site, node: draft, stored }); + // RFC-0041 §3: typed field values, requiredToPublish and their asset references. + const fields = await planEntryFields(this.#ctx, site, draft, stored); if (stored) await assertUnlocked(this.#ctx.sql, stored.siteId, [stored.id], options.releaseEdit); if (draft.body.format === "markdown") { @@ -95,8 +98,9 @@ export class EntryEngine { const opening = entersCollection(stored, entry) ? await planNewCollection(this.#ctx, site, entry.collection, entry.id) : undefined; - const guard = and(prepared.guard, refs.guard, opening?.guard); - const extra = refs.statements(await nodeHash(entry)); + const guard = and(prepared.guard, refs.guard, opening?.guard, fields.guard); + const hash = await nodeHash(entry); + const extra = [...refs.statements(hash), ...fields.statements(hash)]; const key = uniquenessKey("contentEntry", entry); const sameLocale = stored?.locale === entry.locale; const plan = await this.#ctx.tax.planPathChange(site, ts, { @@ -120,6 +124,7 @@ export class EntryEngine { throw new ConflictError("contentEntry", entry.id, stored.rev, rev); await this.#ctx.tax.diagnoseEntry(entry); await refs.diagnose(); + await fields.diagnose(); await opening?.diagnose(); throw new ConflictError("contentEntry", entry.id, stored?.rev ?? 0, rev); } @@ -138,6 +143,7 @@ export class EntryEngine { diagnose: async () => { await this.#ctx.tax.diagnoseEntry(entry); await refs.diagnose(); + await fields.diagnose(); await opening?.diagnose(); }, }); diff --git a/packages/core-ts/src/store/entry-fields.ts b/packages/core-ts/src/store/entry-fields.ts new file mode 100644 index 00000000..24444879 --- /dev/null +++ b/packages/core-ts/src/store/entry-fields.ts @@ -0,0 +1,192 @@ +/** + * RFC-0041 §2, §3, §5: the typed-field part of an entry write, run by `EntryEngine.write`. + * + * - **Values:** `checkFieldValues` on the keys that changed against the stored entry (all keys for + * a new entry or one that moves collection), unknown keys refused once the collection has + * definitions, and a translation's own copy of a non-localized value refused. + * - **Size:** the 256 KB cap applies in every collection (§2, "checked by the store"), but only + * when the write changes `fields`, so an entry stored before the cap still takes other edits. + * - **`requiredToPublish`:** an entry written in review or published needs every required value + * (a translation reads non-localized ones from its original), like `required_term`. + * - **Assets:** an image value is an image asset of the same site, and rich text's `asset:` links + * name files of the site; both are recorded in `asset_refs` under `fields.`, in the write's + * batch and guarded like `planRefs` (rule 4), so a used asset cannot be deleted. + * + * Sites without any field definition run only the size check and add no statements. + */ +import type { Asset, ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts"; +import type { SqlStatement } from "@cloudhal/storage"; +import { + checkFieldValues, + checkRequiredToPublish, + type FieldIssue, + fieldDefinitions, + refuseFieldIssues, +} from "../fields/index.ts"; +import { sameJson } from "../fields/types.ts"; +import { fieldAssetRefs } from "../media/field-refs.ts"; +import type { AssetRefUse } from "../media/refs.ts"; +import type { Cond } from "../node-sql.ts"; +import type { StoreContext } from "./context.ts"; + +/** What the field checks add to an entry write. */ +export interface EntryFieldsPlan { + readonly guard: Cond | undefined; + /** `asset_refs` rows under `fields.*`, anchored on the entry's new hash (after `planRefs`'s). */ + statements(hash: string): SqlStatement[]; + /** After a write that matched no row: the field-specific reason, if an asset moved. */ + diagnose(): Promise; +} + +const NOTHING: EntryFieldsPlan = { + guard: undefined, + statements: () => [], + diagnose: async () => {}, +}; + +const ANCHOR = "EXISTS (SELECT 1 FROM nodes WHERE id = ? AND hash = ?)"; + +const hasDefinitions = (site: Site) => + Object.values(site.collections ?? {}).some((c) => (c?.fields?.length ?? 0) > 0); + +/** The translation group's original in the same site, when `entry` is a translation. */ +async function originalOf( + ctx: StoreContext, + entry: ContentEntry, +): Promise { + const t = entry.translation; + if (!t || t.group === entry.id || t.source?.siteId !== undefined) return undefined; + const original = await ctx.graph.getContentEntry(t.group); + return original?.siteId === entry.siteId ? original : undefined; +} + +/** Checks the field part of an entry write and plans its asset references; throws on issues. */ +export async function planEntryFields( + ctx: StoreContext, + site: Site, + draft: ContentEntry, + stored: ContentEntry | undefined, +): Promise { + const defs = fieldDefinitions(site, draft.collection); + const values = draft.fields ?? {}; + const sameCollection = stored?.collection === draft.collection; + if (defs.length === 0) { + if (!stored || !sameJson(stored.fields ?? {}, values)) + refuseFieldIssues(checkFieldValues([], { values })); + return hasDefinitions(site) ? refsPlan(ctx, { site, draft, refs: [], kept: [] }) : NOTHING; + } + const original = await originalOf(ctx, draft); + const previous = sameCollection ? (stored?.fields ?? {}) : undefined; + const issues = checkFieldValues(defs, { + values, + previous, + original: original ? { id: original.id, values: original.fields ?? {} } : undefined, + blocks: ctx.graph.blocks, + }); + const refs = fieldAssetRefs(defs, values); + const kept = stored + ? fieldAssetRefs(fieldDefinitions(site, stored.collection), stored.fields) + : []; + const invalid = new Set(issues.map((i) => i.path.split("/").slice(0, 2).join("/"))); + const fresh = newRefs(refs, kept).filter((r) => !invalid.has(r.field.replace(".", "/"))); + issues.push(...(await assetIssues(ctx, { site, defs, refs: fresh, kept }))); + issues.push(...checkRequiredToPublish(site, draft, original)); + refuseFieldIssues(issues); + return refsPlan(ctx, { site, draft, refs, kept, defs }); +} + +/** References a write adds: not held by the stored entry under the same field. */ +function newRefs(refs: readonly AssetRefUse[], kept: readonly AssetRefUse[]): AssetRefUse[] { + const held = new Set(kept.map((r) => `${r.assetId}\n${r.field}`)); + return refs.filter((r) => !held.has(`${r.assetId}\n${r.field}`)); +} + +interface AssetCheck { + site: Site; + defs: readonly FieldDefinition[]; + refs: readonly AssetRefUse[]; + kept: readonly AssetRefUse[]; +} + +/** RFC-0041 §2: new references name files of the site; an image field's is an image, not retired. */ +async function assetIssues(ctx: StoreContext, check: AssetCheck): Promise { + const { site, defs, refs, kept } = check; + if (refs.length === 0) return []; + const assets = await ctx.assets.getAssets( + site.id, + refs.map((r) => r.assetId), + ); + const keptIds = new Set(kept.map((r) => r.assetId)); + const types = new Map(defs.map((d) => [d.key, d.type])); + const out: FieldIssue[] = []; + for (const ref of refs) { + const key = ref.field.slice("fields.".length); + const problem = assetProblem(assets.get(ref.assetId), types.get(key), keptIds.has(ref.assetId)); + if (problem) + out.push({ + path: `fields/${key}`, + code: "invalid_value", + message: `${ref.assetId} ${problem}`, + }); + } + return out; +} + +function assetProblem( + asset: Asset | undefined, + type: FieldDefinition["type"] | undefined, + kept: boolean, +): string | undefined { + if (!asset) return "is not a file of this site"; + if (type === "image" && asset.kind !== "image") return `is a ${asset.kind} file, not an image`; + if (asset.status === "retired" && !kept) return "was retired and cannot be newly used"; + return undefined; +} + +interface RefsInput { + site: Site; + draft: ContentEntry; + refs: readonly AssetRefUse[]; + kept: readonly AssetRefUse[]; + defs?: readonly FieldDefinition[]; +} + +/** The `fields.*` rows of `asset_refs`, rewritten with every write in a site that has fields. */ +function refsPlan(ctx: StoreContext, input: RefsInput): EntryFieldsPlan { + const { site, draft, refs, kept } = input; + const nodeId = draft.id; + const ids = [...new Set(refs.map((r) => r.assetId))]; + const keptIds = [...new Set(kept.map((r) => r.assetId))]; + const statements = (hash: string): SqlStatement[] => { + const out: SqlStatement[] = [ + { + sql: `DELETE FROM asset_refs WHERE node_id = ? AND field LIKE 'fields.%' AND ${ANCHOR}`, + params: [nodeId, nodeId, hash], + }, + ]; + if (refs.length > 0) + out.push({ + sql: `INSERT INTO asset_refs (site_id, asset_id, node_id, node_kind, field) + SELECT ?, json_extract(value, '$.a'), ?, 'contentEntry', json_extract(value, '$.f') FROM json_each(?) WHERE ${ANCHOR}`, + params: [ + site.id, + nodeId, + JSON.stringify(refs.map((r) => ({ a: r.assetId, f: r.field }))), + nodeId, + hash, + ], + }); + return out; + }; + if (ids.length === 0) return { guard: undefined, statements, diagnose: async () => {} }; + // The files still exist, and are active unless the entry already used them (as `planRefs`). + const guard: Cond = { + sql: "(SELECT count(*) FROM nodes WHERE kind = 'asset' AND site_id = ? AND id IN (SELECT value FROM json_each(?)) AND (json_extract(body, '$.status') = 'active' OR id IN (SELECT value FROM json_each(?)))) = ?", + params: [site.id, JSON.stringify(ids), JSON.stringify(keptIds), ids.length], + }; + const diagnose = async () => + refuseFieldIssues( + await assetIssues(ctx, { site, defs: input.defs ?? [], refs: newRefs(refs, kept), kept }), + ); + return { guard, statements, diagnose }; +} diff --git a/packages/core-ts/src/store/site-fields.ts b/packages/core-ts/src/store/site-fields.ts new file mode 100644 index 00000000..4093a32c --- /dev/null +++ b/packages/core-ts/src/store/site-fields.ts @@ -0,0 +1,106 @@ +/** + * RFC-0041 §1, §4, §6 for site writes: field definitions follow the code rules and caps + * (`checkFieldDefinitions`), and a plain edit never loses a value. Changes no plain edit may make + * (type, localization, `multiple`, the migration marker) are refused outright; dropping a key or a + * select option is refused only while an entry of the collection still holds it, counted with one + * `json_type` / `json_each` query per change (sqlite and D1 alike) and kept true by a guard on the + * site CAS (rule 4). Refusals name the field operation to use (`field_change_needs_operation`). + */ +import type { Site } from "@cloudhal/core-contracts"; +import { + checkFieldDefinitions, + type FieldDefinitionChange, + type FieldIssue, + fieldDefinitionChanges, + refuseFieldIssues, +} from "../fields/index.ts"; +import { sameJson } from "../fields/types.ts"; +import { and, type Cond } from "../node-sql.ts"; +import type { StoreContext } from "./context.ts"; + +/** What a site write's definition changes add: the guard that keeps "no entry uses it" true. */ +export interface SiteFieldsPlan { + readonly guard: Cond | undefined; + diagnose(): Promise; +} + +type DataChange = Extract; + +const IN_COLLECTION = + "site_id = ? AND kind = 'contentEntry' AND json_extract(body, '$.collection') = ?"; + +/** Entries of the collection that hold the dropped key, or one of the dropped options. */ +function usage(siteId: string, c: DataChange): Cond { + const path = `$.fields.${c.key}`; + if (c.change === "removed") + return { + sql: `SELECT count(*) AS n FROM nodes WHERE ${IN_COLLECTION} AND json_type(body, ?) IS NOT NULL`, + params: [siteId, c.collection, path], + }; + // A single select stores a string, a multiple one a list: json_each reads both. + return { + sql: `SELECT count(*) AS n FROM nodes WHERE ${IN_COLLECTION} AND json_type(body, ?) IS NOT NULL + AND EXISTS (SELECT 1 FROM json_each(body, ?) v WHERE v.value IN (SELECT value FROM json_each(?)))`, + params: [siteId, c.collection, path, path, JSON.stringify(c.options)], + }; +} + +function refusal(c: DataChange, count: number): FieldIssue { + const entries = `${count} ${count === 1 ? "entry" : "entries"}`; + return c.change === "removed" + ? { + path: `collections/${c.collection}/fields/${c.key}`, + code: "field_change_needs_operation", + message: `${entries} of ${c.collection} still hold ${c.key}; use remove_field or rename_field`, + } + : { + path: `collections/${c.collection}/fields/${c.key}/options`, + code: "field_change_needs_operation", + message: `${entries} of ${c.collection} use ${c.options.join(", ")}; change them first, or use change_field_type`, + }; +} + +/** Counts what each data-dependent change would lose; throws when anything is in use. */ +async function checkUsage(ctx: StoreContext, siteId: string, changes: readonly DataChange[]) { + const issues: FieldIssue[] = []; + for (const c of changes) { + const q = usage(siteId, c); + const row = await ctx.sql.get<{ n: number }>(q.sql, q.params); + const n = Number(row?.n ?? 0); + if (n > 0) issues.push(refusal(c, n)); + } + refuseFieldIssues(issues); +} + +/** + * Checks `next`'s field definitions against `stored` (throws on any issue) and returns the guard + * for drops that depend on stored values; undefined when there are none. + */ +export async function planFieldDefinitions( + ctx: StoreContext, + stored: Site | undefined, + next: Site, +): Promise { + if (!stored?.collections && !next.collections) return undefined; + // Only a change is checked: other site writes never re-run the caps on stored settings. + if (stored && sameJson(stored.collections, next.collections)) return undefined; + refuseFieldIssues(checkFieldDefinitions(next, stored)); + if (!stored) return undefined; + // A collection that goes away entirely is RFC-0040's (`collection_in_use` while it has entries). + const changes = fieldDefinitionChanges(stored, next).filter( + (c): c is DataChange => + (c.change === "removed" || c.change === "optionsRemoved") && + next.collections?.[c.collection] !== undefined, + ); + if (changes.length === 0) return undefined; + await checkUsage(ctx, next.id, changes); + return { + guard: and( + ...changes.map((c) => { + const q = usage(next.id, c); + return { sql: `(${q.sql}) = 0`, params: q.params }; + }), + ), + diagnose: () => checkUsage(ctx, next.id, changes), + }; +} diff --git a/packages/core-ts/src/store/sites.ts b/packages/core-ts/src/store/sites.ts index 20294867..a7b309d7 100644 --- a/packages/core-ts/src/store/sites.ts +++ b/packages/core-ts/src/store/sites.ts @@ -15,6 +15,7 @@ import type { SiteGuard } from "../search.ts"; 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 { planPermalinks } from "./site-permalinks.ts"; import { checkThemeConfig } from "./validation.ts"; @@ -38,6 +39,7 @@ export class SiteEngine { assertValid("site", site); checkTimeZone(site.timeZone); checkThemeConfig(this.#ctx.graph.themeSettings, site.theme.config, undefined); + await planFieldDefinitions(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); @@ -103,6 +105,8 @@ export class SiteEngine { const permalinks = await planPermalinks(this.#ctx, stored, next); // RFC-0040 §3: a collection in use stays declared; a new one claims a free prefix. 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); const refs = await this.#ctx.assets.planRefs("site", site.id, next, stored); const written = refs.node; if (options.dryRun) return written; @@ -110,7 +114,7 @@ export class SiteEngine { "site", written, site.rev, - and(refs.guard, permalinks?.guard, declared?.guard), + and(refs.guard, permalinks?.guard, declared?.guard, fields?.guard), ); const hash = await nodeHash(written); const more = [ @@ -125,6 +129,7 @@ export class SiteEngine { if (rev === site.rev) { await permalinks?.diagnose(); await declared?.diagnose(); + await fields?.diagnose(); await refs.diagnose(); } throw new ConflictError("site", site.id, site.rev, rev); diff --git a/packages/core-ts/src/sync.ts b/packages/core-ts/src/sync.ts index 8b983519..2d4d151e 100644 --- a/packages/core-ts/src/sync.ts +++ b/packages/core-ts/src/sync.ts @@ -11,11 +11,12 @@ import { type Asset, assertValid, type Site } from "@cloudhal/core-contracts"; import type { BlobStore, SqlDriver, SqlStatement } from "@cloudhal/storage"; -import { mediaBlobKey, refsRebuild } from "./assets.ts"; +import { mediaBlobKey } from "./assets.ts"; import { checkAvailability, checkTimeZone, type GateSupport, isTimeZone } from "./availability.ts"; import { InvariantError } from "./errors.ts"; import { canonicalJson, nodeHash, sha256Hex } from "./hash.ts"; import { checkHosts, hostStatements, hostsOf } from "./hosts.ts"; +import { refsRebuild } from "./media/refs-rebuild.ts"; import { validateMenuTree } from "./menu.ts"; import { isNodeKind, NODE_SPEC, type NodeKind, type NodeTypes } from "./nodes.ts"; import { keepTargetPublished } from "./sync-published.ts"; diff --git a/packages/core-ts/src/testing-fields.ts b/packages/core-ts/src/testing-fields.ts new file mode 100644 index 00000000..1075a928 --- /dev/null +++ b/packages/core-ts/src/testing-fields.ts @@ -0,0 +1,445 @@ +/** + * RFC-0041 step 2 behavioural suite: typed custom fields in the store. Site writes check + * definitions and refuse drops that would lose stored values; entry writes check changed values, + * unknown keys, non-localized copies on translations, `requiredToPublish`, the size cap and image + * assets (with `asset_refs`); searchable fields reach the search index on publish. Called by + * `describeSiteGraphStore`, so it runs on every storage backend (sqlite, Miniflare D1, DO SQLite). + */ +import type { + Actor, + ContentEntry, + FieldDefinition, + Site, + Translation, +} from "@cloudhal/core-contracts"; +import { describe, expect, it } from "vitest"; +import { InvariantError } from "./errors.ts"; +import { makeJpeg, text } from "./media/fixtures.ts"; +import type { SiteGraphStore } from "./store.ts"; +import { exportSite, importSite } from "./sync.ts"; +import type { SuiteContext } from "./testing-taxonomy.ts"; + +const person: Actor = { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" }; +const L = (label: string) => ({ en: { label } }); + +const FIELDS: [FieldDefinition, ...FieldDefinition[]] = [ + { + key: "venue", + type: "text", + labels: L("Venue"), + localized: true, + requiredToPublish: true, + maxLength: 40, + searchable: true, + }, + { key: "notes", type: "text", labels: L("Notes"), localized: true }, + { key: "price", type: "number", labels: L("Price"), localized: false, minimum: 0 }, + { + key: "startsAt", + type: "datetime", + labels: L("Starts"), + localized: false, + requiredToPublish: true, + }, + { key: "poster", type: "image", labels: L("Poster"), localized: false }, + { key: "agenda", type: "richText", labels: L("Agenda"), localized: true }, + { + key: "format", + type: "select", + labels: L("Format"), + localized: false, + multiple: true, + options: [ + { value: "talk", labels: { en: "Talk" } }, + { value: "workshop", labels: { en: "Workshop" } }, + { value: "panel", labels: { en: "Panel" } }, + ], + }, + { + key: "level", + type: "select", + labels: L("Level"), + localized: false, + options: [ + { value: "beginner", labels: { en: "Beginner" } }, + { value: "expert", labels: { en: "Expert" } }, + ], + }, +]; + +const STARTS = { at: "2026-11-05T16:30:00Z", timeZone: "Europe/Istanbul" }; + +interface Refusal { + code: string; + issues: { path: string; code: string }[]; +} + +async function refusal(p: Promise): Promise { + try { + await p; + } catch (e) { + if (!(e instanceof InvariantError)) throw e; + return { code: e.code, issues: (e.details.issues ?? []) as Refusal["issues"] }; + } + throw new Error("expected a rejection"); +} + +export function fieldsSuite(ctx: SuiteContext): void { + describe("RFC-0041 typed custom fields in the store", () => { + let n = 0; + const store = (): SiteGraphStore => ctx.store(); + + async function setup(fields: FieldDefinition[] | null = FIELDS): Promise { + n++; + const project = await store().createProject({ + name: "Fields", + slug: `fields-${n}`, + ownerId: person.id, + siteIds: [], + }); + return store().createSite({ + projectId: project.id, + name: "Fields", + slug: `fields-${n}`, + defaultLocale: "en", + locales: ["en", "tr"], + domains: [], + pageIds: [], + theme: { name: "blog" }, + collections: { + events: { + labels: { en: { singular: "Event", plural: "Events" } }, + ...(fields ? { fields: fields as [FieldDefinition, ...FieldDefinition[]] } : {}), + }, + }, + }); + } + const fresh = async (site: Site) => (await store().getSite(site.id)) as Site; + const entry = (site: Site, extra: Partial = {}) => + store().createContentEntry({ + siteId: site.id, + collection: "events", + slug: `e-${Math.random().toString(36).slice(2, 8)}`, + locale: "en", + status: "draft", + title: "Launch", + body: { format: "markdown", value: "Body" }, + fields: {}, + tags: [], + author: person, + ...extra, + } as never); + const withFields = async (site: Site, fields: FieldDefinition[] | undefined) => { + const s = await fresh(site); + const events = { ...s.collections?.events, fields } as never; + return store().updateSite({ ...s, collections: { ...s.collections, events } }); + }; + const upload = (site: Site, bytes = makeJpeg({ width: 40, height: 30 }), name = "p.jpg") => + store().ingestAsset({ + siteId: site.id, + bytes, + filename: name, + uploadedBy: person, + source: { kind: "upload" }, + }); + const refsOf = async (nodeId: string) => + ( + await ctx + .sql() + .all<{ asset_id: string; field: string }>( + "SELECT asset_id, field FROM asset_refs WHERE node_id = ? ORDER BY field", + [nodeId], + ) + ).map((r) => `${r.field}=${r.asset_id}`); + + it("collections without definitions keep fields as any JSON, nulls included", async () => { + const site = await setup(null); + const loose = { anything: { nested: [1, null] }, gone: null, "kebab-key": true }; + const e = await entry(site, { fields: loose, status: "published" }); + expect(e.fields).toEqual(loose); + const again = await store().updateContentEntry({ ...e, fields: { other: 1 } }); + expect(again.fields).toEqual({ other: 1 }); + expect(await refsOf(e.id)).toEqual([]); + }); + + it("site writes check definitions: reserved and duplicate keys, bounds, caps", async () => { + const site = await setup(null); + const text = (key: string): FieldDefinition => ({ + key, + type: "text", + labels: L(key), + localized: true, + }); + const reserved = await refusal(withFields(site, [text("title")])); + expect(reserved.code).toBe("field_key_reserved"); + expect(reserved.issues[0]?.path).toBe("collections/events/fields/0/key"); + expect((await refusal(withFields(site, [text("a"), text("a")]))).code).toBe( + "field_key_duplicate", + ); + const bounds = await refusal( + withFields(site, [ + { key: "n", type: "number", labels: L("n"), localized: false, minimum: 5, maximum: 1 }, + ]), + ); + expect(bounds.code).toBe("field_bounds"); + // A dry run runs the same checks and writes nothing. + const s = await fresh(site); + const dry = store().updateSite( + { + ...s, + collections: { events: { ...s.collections?.events, fields: [text("title")] } as never }, + }, + { dryRun: true }, + ); + expect((await refusal(dry)).code).toBe("field_key_reserved"); + expect((await fresh(site)).rev).toBe(s.rev); + // A new site is checked too. + const project = await store().createProject({ + name: "x", + slug: "fields-new", + ownerId: person.id, + siteIds: [], + }); + const created = store().createSite({ + projectId: project.id, + name: "x", + slug: "fields-new", + defaultLocale: "en", + locales: ["en"], + domains: [], + pageIds: [], + theme: { name: "blog" }, + collections: { events: { fields: [text("slug")] } as never }, + }); + expect((await refusal(created)).code).toBe("field_key_reserved"); + }); + + it("type, localization and multiple changes need an operation, values or not", async () => { + const site = await setup(); + const swap = (i: number, over: Record) => + withFields( + site, + FIELDS.map((d, j) => (j === i ? ({ ...d, ...over } as FieldDefinition) : d)), + ); + const type = await refusal(swap(1, { type: "richText" })); + expect(type.code).toBe("field_change_needs_operation"); + expect(type.issues[0]?.path).toBe("collections/events/fields/notes"); + expect((await refusal(swap(1, { localized: false }))).code).toBe( + "field_change_needs_operation", + ); + expect((await refusal(swap(6, { multiple: false }))).code).toBe( + "field_change_needs_operation", + ); + // Free edits: labels, order, maxLength, searchable, requiredToPublish, new options. + const edited = await withFields(site, [ + { ...FIELDS[1], labels: L("Remarks") } as FieldDefinition, + { ...FIELDS[0], maxLength: 10, searchable: false, requiredToPublish: false } as never, + ...FIELDS.slice(2), + ]); + expect(edited.collections?.events?.fields?.[0]?.key).toBe("notes"); + }); + + it("dropping a key or an option is refused only while an entry holds it", async () => { + const site = await setup(); + const without = (key: string) => FIELDS.filter((d) => d.key !== key); + // Nothing stored yet: both drops are plain edits. + await withFields(site, without("notes")); + await withFields(site, FIELDS); + await entry(site, { + fields: { notes: "keep me", format: ["talk", "panel"], level: "expert" }, + }); + const dropped = await refusal(withFields(site, without("notes"))); + expect(dropped.code).toBe("field_change_needs_operation"); + expect(dropped.issues[0]?.path).toBe("collections/events/fields/notes"); + const options = (key: string, keep: string[]) => + FIELDS.map((d) => + d.key === key && d.type === "select" + ? { ...d, options: d.options.filter((o) => keep.includes(o.value)) } + : d, + ) as FieldDefinition[]; + // A multiple select (list) and a single one (string), both read by json_each. + expect((await refusal(withFields(site, options("format", ["talk", "workshop"])))).code).toBe( + "field_change_needs_operation", + ); + expect((await refusal(withFields(site, options("level", ["beginner"])))).code).toBe( + "field_change_needs_operation", + ); + // Unused options and keys go freely. + const kept = await withFields(site, options("format", ["talk", "panel"])); + expect(kept.collections?.events?.fields?.find((d) => d.key === "format")).toBeDefined(); + await withFields( + site, + options("format", ["talk", "panel"]).filter((d) => d.key !== "price"), + ); + }); + + it("entry values: checked, located, unknown keys refused, null never stored", async () => { + const site = await setup(); + const bad = await refusal(entry(site, { fields: { price: -1, startsAt: { at: "x" } } })); + expect(bad.code).toBe("invalid_fields"); + expect(bad.issues.map((i) => i.path).sort()).toEqual( + expect.arrayContaining(["fields/price"]), + ); + const unknown = await refusal(entry(site, { fields: { colour: "red" } })); + expect(unknown).toMatchObject({ + code: "unknown_field", + issues: [{ path: "fields/colour", code: "unknown_field" }], + }); + expect((await refusal(entry(site, { fields: { notes: null } }))).code).toBe("invalid_fields"); + const ok = await entry(site, { + fields: { venue: "Hall", price: 12.5, startsAt: STARTS, format: ["talk"] }, + }); + expect(ok.fields).toMatchObject({ venue: "Hall", price: 12.5 }); + }); + + it("only changed values are checked; stored unknown keys are kept", async () => { + const site = await setup(null); + const old = await entry(site, { fields: { legacy: true, venue: "A very long venue name" } }); + await withFields(site, [{ ...FIELDS[0], maxLength: 10 } as FieldDefinition]); + // An unrelated edit keeps the too-long venue and the unknown key. + const edited = await store().updateContentEntry({ ...old, title: "Renamed" }); + expect(edited.fields).toEqual({ legacy: true, venue: "A very long venue name" }); + const longer = await refusal( + store().updateContentEntry({ + ...edited, + fields: { ...edited.fields, venue: "Also too long" }, + }), + ); + expect(longer).toMatchObject({ code: "invalid_fields", issues: [{ path: "fields/venue" }] }); + expect( + ( + await refusal( + store().updateContentEntry({ ...edited, fields: { ...edited.fields, x: 1 } }), + ) + ).code, + ).toBe("unknown_field"); + // Removing keys is always allowed. + expect((await store().updateContentEntry({ ...edited, fields: {} })).fields).toEqual({}); + }); + + it("requiredToPublish: drafts save freely, review and published need the values", async () => { + const site = await setup(); + const draft = await entry(site); + const review = await refusal(store().updateContentEntry({ ...draft, status: "review" })); + expect(review.code).toBe("required_field"); + expect(review.issues.map((i) => i.path).sort()).toEqual(["fields/startsAt", "fields/venue"]); + expect((await refusal(entry(site, { status: "published" }))).code).toBe("required_field"); + const blank = await refusal( + store().updateContentEntry({ + ...draft, + status: "review", + fields: { venue: " ", startsAt: STARTS }, + }), + ); + expect(blank).toMatchObject({ code: "required_field", issues: [{ path: "fields/venue" }] }); + const ready = await store().updateContentEntry({ + ...draft, + status: "published", + fields: { venue: "Hall", startsAt: STARTS }, + }); + expect(ready.status).toBe("published"); + }); + + it("translations: non-localized values live on the original only", async () => { + const site = await setup(); + const original = await entry(site, { + status: "published", + fields: { venue: "Hall", startsAt: STARTS, price: 10 }, + }); + const member = (await store().translationGroup(site, original.id)).members.find( + (m) => m.id === original.id, + ); + const translation = { + group: original.id, + method: "human", + source: { id: original.id, locale: "en", rev: original.rev, hash: member?.hash as string }, + } as Translation; + const tr = (fields: Record, status: ContentEntry["status"] = "draft") => + entry(site, { locale: "tr", slug: original.slug, translation, fields, status }); + const copy = await refusal(tr({ price: 11 })); + expect(copy).toMatchObject({ + code: "field_not_localized", + issues: [{ path: "fields/price", code: "field_not_localized" }], + }); + // An equal copy passes; localized values are the translation's own; required values that + // are not localized (startsAt) and localized ones left empty (venue) come from the original. + const made = await tr({ price: 10, notes: "Türkçe" }, "published"); + expect(made.fields).toEqual({ price: 10, notes: "Türkçe" }); + }); + + it("the 256 KB cap applies to every collection, when fields change", async () => { + const site = await setup(null); + const big = { blob: "x".repeat(256 * 1024) }; + expect((await refusal(entry(site, { fields: big }))).code).toBe("too_large"); + const typed = await setup(); + const huge = { notes: "x".repeat(5000) }; + const many = Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`k${i}`, huge.notes])); + expect((await refusal(entry(typed, { fields: many }))).issues[0]?.code).toBe("too_large"); + }); + + it("image values are image assets of the site, recorded in asset_refs", async () => { + const site = await setup(); + const { asset } = await upload(site); + const csv = await upload(site, text("a,b\n1,2\n"), "data.csv"); + const missing = await refusal(entry(site, { fields: { poster: "asset_01HZX8K3Q9ZZ" } })); + expect(missing).toMatchObject({ + code: "invalid_fields", + issues: [{ path: "fields/poster" }], + }); + const notImage = await refusal(entry(site, { fields: { poster: csv.asset.id } })); + expect(notImage.issues[0]?.path).toBe("fields/poster"); + const e = await entry(site, { + fields: { poster: asset.id, agenda: `See [the data](asset:${csv.asset.id}).` }, + }); + expect(await refsOf(e.id)).toEqual([ + `fields.agenda=${csv.asset.id}`, + `fields.poster=${asset.id}`, + ]); + expect((await refusal(store().deleteAsset(asset))).code).toBe("in_use"); + // An unrelated edit keeps the rows; clearing the value frees the file. + const edited = await store().updateContentEntry({ ...e, title: "Renamed" }); + expect(await refsOf(e.id)).toHaveLength(2); + const cleared = await store().updateContentEntry({ ...edited, fields: {} }); + expect(await refsOf(e.id)).toEqual([]); + await store().deleteAsset((await store().getAsset(asset.id)) ?? asset); + expect(cleared.fields).toEqual({}); + }); + + it("an import rebuilds the field references with the rest", async () => { + const site = await setup(); + const { asset } = await upload(site); + const e = await entry(site, { fields: { poster: asset.id } }); + const bundle = await exportSite({ sql: ctx.sql(), blobs: ctx.blobs() }, site.id); + await ctx.sql().run("DELETE FROM asset_refs WHERE site_id = ?", [site.id]); + await importSite({ sql: ctx.sql(), blobs: ctx.blobs() }, bundle, { mode: "replace" }); + expect(await refsOf(e.id)).toEqual([`fields.poster=${asset.id}`]); + }); + + it("searchable text fields reach the index on publish; others never do", async () => { + const site = await setup(); + await entry(site, { + status: "published", + title: "Launch", + fields: { venue: "Moda Sahnesi", notes: "secret backstage code", startsAt: STARTS }, + }); + const publish = async () => { + const snap = await store().createSnapshot(site.id, { actor: person, reason: "publish" }); + await store().publish(await fresh(site), snap.id); + }; + const find = async (q: string) => + (await store().search(site.id, q, { locale: "en", now: Date.UTC(2027, 0, 1) })).total; + await publish(); + expect(await find("sahnesi")).toBe(1); + expect(await find("backstage")).toBe(0); + // Marking notes searchable rewrites every entry row at the next publish. + await withFields( + site, + FIELDS.map((d) => + d.key === "notes" ? ({ ...d, searchable: true } as FieldDefinition) : d, + ), + ); + await publish(); + expect(await find("backstage")).toBe(1); + }); + }); +} diff --git a/packages/core-ts/src/testing.ts b/packages/core-ts/src/testing.ts index 7dfa68ec..a80484a5 100644 --- a/packages/core-ts/src/testing.ts +++ b/packages/core-ts/src/testing.ts @@ -9,6 +9,7 @@ import { nodeHash } from "./hash.ts"; import { SiteGraphStore } from "./store.ts"; import { assetsSuite } from "./testing-assets.ts"; import { collectionsSuite } from "./testing-collections.ts"; +import { fieldsSuite } from "./testing-fields.ts"; import { menuSuite } from "./testing-menu.ts"; import { permalinksSuite } from "./testing-permalinks.ts"; import { racingDriver, type SuiteContext, taxonomySuite } from "./testing-taxonomy.ts"; @@ -263,5 +264,6 @@ export function describeSiteGraphStore(label: string, factory: StoreFactory): vo assetsSuite(suite); permalinksSuite(suite); collectionsSuite(suite); + fieldsSuite(suite); }); } diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index ee43f1fc..2ae62579 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -17,7 +17,7 @@ "packages/comments/src/pipeline.ts": 898, "packages/comments/src/sql/store.ts": 2111, "packages/comments/src/testing.ts": 1665, - "packages/core-ts/src/assets.ts": 1390, + "packages/core-ts/src/assets.ts": 1370, "packages/core-ts/src/automations.ts": 1003, "packages/core-ts/src/menu.ts": 928, "packages/core-ts/src/releases.ts": 996,