From 82956ea6d0d78b948ddbe481289afcc9c1e86b89 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sat, 12 Sep 2026 03:38:54 +0300 Subject: [PATCH 1/6] fix(nextly): a function default cannot read a field the caller may not write Field write access now runs before the defaults as well as after the hooks, the two passes the read path already runs. A function `defaultValue` receives the data built so far, and it ran while a field this caller is denied was still in the record, so a default could read the forbidden value and carry it into a field the caller IS allowed to write. The denied field was stripped and its value persisted anyway, one column across, which is the field rule defeated by proxy. Both passes share one grants resolver, so the caller's roles and permissions are still read once. A config reload re-registers the live config's field functions. The registry holds every field's access rules, hooks, validate and function defaultValue, none of which survive being stored, and a reload never went back through service registration: an edit to any of them kept running the version from process start until the dev server was restarted. Wrong in the direction that matters most for an access rule, since a rule tightened in the config was not the one enforced. Applied on the same optimistic terms as the field-type registry, and put back by the same undo when a reload is abandoned. The registry keyed only named entries, so every rule, hook and validator declared inside an unnamed presentational group was dropped. `defineCollection` refuses a field with no name, but the plugin declaration gate checks field TYPES and not their names, so the shape reaches the live config. Flattened through the repository's own addressable-fields walk, which already knows an unnamed group stores its children at its own level while an unnamed repeater does not. A Single's first read no longer invents an incomplete group: filling one for the sake of a defaulted child while a required sibling has no value stored a document the next write would refuse, and that insert runs no validation pass. The docs no longer claim a field group's children take their defaults before the entry's hooks; they are written in their own pass afterwards. --- .../a-default-cannot-read-a-denied-field.md | 37 ++++++++ docs/configuration/fields.mdx | 9 +- ...ot-read-a-denied-field.integration.test.ts | 94 +++++++++++++++++++ .../services/collection-mutation-service.ts | 34 +++++++ ...t-read-nested-defaults.integration.test.ts | 61 ++++++++++++ .../singles/services/single-query-service.ts | 48 ++++++++++ packages/nextly/src/init/reload-config.ts | 37 ++++++++ .../field-functions-snapshot.test.ts | 61 ++++++++++++ .../src/shared/lib/field-level-registry.ts | 92 ++++++++++++++++-- 9 files changed, 463 insertions(+), 10 deletions(-) create mode 100644 .changeset/a-default-cannot-read-a-denied-field.md create mode 100644 packages/nextly/src/collections/__tests__/default-cannot-read-a-denied-field.integration.test.ts create mode 100644 packages/nextly/src/shared/lib/__tests__/field-functions-snapshot.test.ts diff --git a/.changeset/a-default-cannot-read-a-denied-field.md b/.changeset/a-default-cannot-read-a-denied-field.md new file mode 100644 index 0000000000..bb22948578 --- /dev/null +++ b/.changeset/a-default-cannot-read-a-denied-field.md @@ -0,0 +1,37 @@ +--- +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/builder": patch +"create-nextly-app": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/module-specifiers": patch +"nextly": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/ui": patch +--- + +A function default cannot read a field the caller may not write. Field write access now runs before the defaults as well as after the hooks, the two passes the read path already runs: a `defaultValue` written as a function receives the data built so far, and it ran while a field this caller is denied was still in the record, so a default could read the forbidden value and carry it into a field the caller IS allowed to write. The denied field was stripped and its value was persisted anyway, one column across. + +A config reload re-registers the live config's field functions. The field-level registry holds every field's `access` rules, hooks, `validate` and function `defaultValue`, none of which survive being stored, and a reload never went back through service registration: an edit to any of them kept running the version from process start until the dev server was restarted. That is wrong in the direction that matters most for an access rule, since a rule tightened in the config was not the one being enforced. Applied on the same optimistic terms as the field-type registry, and restored by the same undo when a reload is abandoned. + +An access rule declared inside an unnamed container is captured. The registry keyed only named entries, so every rule, hook and validator inside an unnamed presentational group was dropped. `defineCollection` refuses a field with no name, but a plugin contributing raw config is checked on its field TYPES and not their names, so the shape reaches the live config. + +A Single's first read no longer invents an incomplete group. Filling a group for the sake of one defaulted child, while a required sibling has no default and no value, stored a document the next create or update would refuse; that insert runs no validation pass of its own. The group is left absent instead. + +The documentation no longer claims a field group's children take their defaults before the entry's hooks. They are written in their own pass afterwards, so the parent's hooks see such a child as absent. diff --git a/docs/configuration/fields.mdx b/docs/configuration/fields.mdx index d84f45d8bb..60b24c662e 100644 --- a/docs/configuration/fields.mdx +++ b/docs/configuration/fields.mdx @@ -1025,9 +1025,12 @@ Static value or `(data) => value` function. The function form receives the docum Both forms are applied on the server, so a default holds for every write path that applies defaults, not only for the admin form. On an ordinary collection create through the REST or Direct API, an omitted field takes its -default before any hook runs, at every depth: a group's children, each -supplied repeater row's children, and the children of each new field-group -instance. A function default is read from the live config, since a +default before any hook runs, at every depth: a group's children and each +supplied repeater row's children. A new field-group instance also fills its own +children's defaults, but LATER: a field group's rows are written in their own +pass after the entry's hooks have run, so the parent's hooks see such a child as +absent and cannot read or derive from the value that will be stored. A function +default is read from the live config, since a collection's stored definition cannot hold a function, and it receives the data built so far at its own level: the document for a top-level field, the group or the row for a nested one. Rows are never invented; `minRows` is a diff --git a/packages/nextly/src/collections/__tests__/default-cannot-read-a-denied-field.integration.test.ts b/packages/nextly/src/collections/__tests__/default-cannot-read-a-denied-field.integration.test.ts new file mode 100644 index 0000000000..e379bc0898 --- /dev/null +++ b/packages/nextly/src/collections/__tests__/default-cannot-read-a-denied-field.integration.test.ts @@ -0,0 +1,94 @@ +/** + * A function default is evaluated on what the caller was ALLOWED to send. + * + * Function defaults receive the data built so far. They ran before field write + * access, so a field this caller may not create was still in the record while + * they ran: a default could read the forbidden value and carry it into a field + * the caller IS allowed to write. The denied field was stripped from the + * record and its value was persisted anyway, one column across, which is the + * field rule defeated by proxy. + * + * Write access now runs before the defaults as well as after the hooks, the + * two passes the read path runs for the same reason: the first decides what + * the caller may put in, the second catches a denied key a hook put back. + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { defineCollection, text } from "../../config"; +import { createTestNextly, type TestNextly } from "../../plugins/test-nextly"; +import type { CollectionsHandler } from "../../services/collections-handler"; + +let current: TestNextly | undefined; +afterEach(async () => { + await current?.destroy(); + current = undefined; +}); + +const CALLER = { id: "u1", email: "u@x.test", roles: [] }; + +async function boot() { + current = await createTestNextly({ + collections: [ + defineCollection({ + slug: "leaky", + fields: [ + text({ name: "title" }), + text({ name: "secret", access: { create: () => false } }), + text({ + name: "derived", + defaultValue: d => `saw:${String(d.secret)}`, + }), + text({ name: "open" }), + text({ + name: "fromOpen", + defaultValue: d => `saw:${String(d.open)}`, + }), + ], + }), + ], + }); + return current.getService<"collectionsHandler">("collectionsHandler"); +} + +describe("a function default cannot read a field the caller may not write", () => { + it("evaluates on the authorized record, not the raw request", async () => { + const handler = (await boot()) as unknown as CollectionsHandler; + + const created = await handler.createEntry( + { + collectionName: "leaky", + // The route gate has run; the FIELD rules are the ones under test. + routeAuthorized: true, + user: CALLER, + }, + { title: "t", secret: "forbidden-value", open: "fine" } + ); + + expect(created.success, JSON.stringify(created)).toBe(true); + const row = created.data as Record; + // The denied field is stripped, as it always was. + expect(row.secret ?? null).toBeNull(); + // And its value did not reach the database through the default either. + expect(row.derived).not.toContain("forbidden-value"); + expect(row.derived).toBe("saw:undefined"); + // A default reading an ALLOWED field still sees it: the pass removes what + // the caller may not send, not everything they sent. + expect(row.fromOpen).toBe("saw:fine"); + }); + + it("still lets a trusted caller's default read the field", async () => { + const handler = (await boot()) as unknown as CollectionsHandler; + + const created = await handler.createEntry( + { collectionName: "leaky", overrideAccess: true }, + { title: "t", secret: "system-value", open: "fine" } + ); + + expect(created.success, JSON.stringify(created)).toBe(true); + const row = created.data as Record; + // `overrideAccess` means the field rules do not apply, so the value is + // both stored and visible to the default. + expect(row.secret).toBe("system-value"); + expect(row.derived).toBe("saw:system-value"); + }); +}); diff --git a/packages/nextly/src/domains/collections/services/collection-mutation-service.ts b/packages/nextly/src/domains/collections/services/collection-mutation-service.ts index 200940f0f5..2f80a3ddd4 100644 --- a/packages/nextly/src/domains/collections/services/collection-mutation-service.ts +++ b/packages/nextly/src/domains/collections/services/collection-mutation-service.ts @@ -83,6 +83,7 @@ import { applyFieldDefaults } from "../../../shared/lib/field-defaults"; import { applyFieldReadAccess, applyFieldWriteAccess, + writeAccessGrants, attachFieldValidators, getFieldFunctions, runFieldHooks, @@ -2903,6 +2904,37 @@ export class CollectionMutationService extends BaseService { // of write access matches generation, so a field the caller may not // create is not reintroduced. const seededBody: Record = { ...body }; + // One resolver for both write-access passes over this record, so the + // caller's roles and permissions are read once and both judge with one + // authority. + const writeGrants = writeAccessGrants( + params.user, + params.authenticatedScope + ); + // Field write access runs HERE as well as below, the two passes the read + // path runs around its hooks and for the same reason. + // + // A function default receives the data built so far. Judged only after + // the defaults, a field this caller may not create would still be sitting + // in the record while those functions ran, so a default could read the + // forbidden value and carry it into a field the caller IS allowed to + // write. The denied field was stripped and its value persisted anyway, + // one column across. Stripping first means the functions see what this + // caller was actually allowed to send. + // + // The second pass below is not redundant: the hooks in between may put a + // denied key back, and that pass judges the key by its own rule on the + // value the hook wrote. + await applyFieldWriteAccess({ + kind: "collection", + slug: params.collectionName, + data: seededBody, + operation: "create", + user: params.user, + authenticatedScope: params.authenticatedScope, + overrideAccess: params.overrideAccess, + grants: writeGrants, + }); // The stored fields carry constant defaults; a function default exists // only in the live config, which the registry captured at boot. applyFieldDefaults( @@ -2995,7 +3027,9 @@ export class CollectionMutationService extends BaseService { data: finalData, operation: "create", user: params.user, + authenticatedScope: params.authenticatedScope, overrideAccess: params.overrideAccess, + grants: writeGrants, }); // Field-level beforeValidate hooks transform values ahead of the diff --git a/packages/nextly/src/domains/singles/__tests__/single-first-read-nested-defaults.integration.test.ts b/packages/nextly/src/domains/singles/__tests__/single-first-read-nested-defaults.integration.test.ts index b3e384269f..1da34d6d0b 100644 --- a/packages/nextly/src/domains/singles/__tests__/single-first-read-nested-defaults.integration.test.ts +++ b/packages/nextly/src/domains/singles/__tests__/single-first-read-nested-defaults.integration.test.ts @@ -168,4 +168,65 @@ describe("a Single's first read fills nested defaults (integration)", () => { expect(messages).toContain("must be a page document"); }); + it("leaves an invented group absent when a required child has no value", async () => { + current = await createTestNextly({ + singles: [ + defineSingle({ + slug: "partial", + fields: [ + group({ + name: "contact", + fields: [ + text({ name: "label", defaultValue: "Support" }), + // Required, with no default and nothing to supply it on a + // first read: a group created for `label` alone would be a + // document the next write refuses. + text({ name: "email", required: true }), + ], + }), + ], + }), + ], + }); + + const doc = (await current.nextly.findSingle({ + slug: "partial", + overrideAccess: true, + })) as { contact?: unknown } | null; + + expect(doc?.contact ?? null).toBeNull(); + }); + + it("still fills a group whose required children are all satisfied", async () => { + current = await createTestNextly({ + singles: [ + defineSingle({ + slug: "complete", + fields: [ + group({ + name: "contact", + fields: [ + text({ name: "label", defaultValue: "Support" }), + text({ + name: "email", + required: true, + defaultValue: "support@example.test", + }), + ], + }), + ], + }), + ], + }); + + const doc = (await current.nextly.findSingle({ + slug: "complete", + overrideAccess: true, + })) as { contact?: { label?: string; email?: string } } | null; + + expect(doc?.contact).toEqual({ + label: "Support", + email: "support@example.test", + }); + }); }); diff --git a/packages/nextly/src/domains/singles/services/single-query-service.ts b/packages/nextly/src/domains/singles/services/single-query-service.ts index 177f67a110..9391368fd6 100644 --- a/packages/nextly/src/domains/singles/services/single-query-service.ts +++ b/packages/nextly/src/domains/singles/services/single-query-service.ts @@ -423,6 +423,38 @@ function hasFieldName( return typeof field.name === "string" && field.name.length > 0; } +/** + * Whether a filled container is missing a child its own declaration requires. + * + * Only asked of a container the default fill created, so "missing" means no + * default reached it and no caller supplied it. A required child inside a + * nested container counts as well: an incomplete group two levels down is + * still a document the write path would refuse. + */ +function missesARequiredChild( + fields: readonly ValidatableField[] | undefined, + filled: unknown +): boolean { + if (!fields) return false; + if (Array.isArray(filled)) { + return filled.some(row => missesARequiredChild(fields, row)); + } + if (!isPlainRecord(filled)) return false; + for (const child of fields) { + if (!child.name) { + // A layout container holds no value; its children are on this object. + if (missesARequiredChild(child.fields, filled)) return true; + continue; + } + const value = filled[child.name]; + if (child.required && (value === undefined || value === null)) return true; + if (value !== undefined && missesARequiredChild(child.fields, value)) { + return true; + } + } + return false; +} + /** * A record the nested walk can read children off: an array is a repeater's * rows and is walked as rows, and a primitive holds no children at all. @@ -1581,6 +1613,22 @@ export class SingleQueryService extends BaseService { applyFieldDefaults(logicalDefaults, [source]); const after = logicalDefaults[field.name]; if (after === before) return; + // A group the fill INVENTED has to be complete to be stored. This insert + // is direct and runs no validation pass, so a group created for the sake + // of one defaulted child, while a required sibling has no default and no + // value, would persist a document that the next create or update refuses. + // Left absent instead, which is where it stood before anything was + // filled, rather than stored knowing it is invalid. + if ( + before === undefined && + missesARequiredChild( + (source as { fields?: readonly ValidatableField[] }).fields, + after + ) + ) { + delete logicalDefaults[field.name]; + return; + } await assertNestedDefaultsValid( (source as { fields?: readonly ValidatableField[] }).fields, after, diff --git a/packages/nextly/src/init/reload-config.ts b/packages/nextly/src/init/reload-config.ts index aab9a677bb..3da9e36602 100644 --- a/packages/nextly/src/init/reload-config.ts +++ b/packages/nextly/src/init/reload-config.ts @@ -1549,6 +1549,43 @@ async function applyReload(opts?: { singles: newConfig?.singles, fieldGroups: newConfig?.fieldGroups, }); + + // The field-level registry holds the function-bearing half of the live + // config: a field's `access` rules, its hooks, its `validate`, and a + // `defaultValue` written as a function. None of it survives being stored, + // so it is captured from the config object at boot and read from here on + // every write and every read. + // + // A reload never goes back through `registerServices`, so that capture was + // never refreshed. An edit to any of it kept running the version from + // process start until the dev server was restarted, which is wrong in the + // direction that matters most for an access rule: a rule TIGHTENED in the + // config was not the one being enforced. + // + // Applied on the same optimistic terms as the field types above, and put + // back by the same undo, so a reload that is abandoned leaves the retained + // config's functions in force. + const { + registerFieldFunctions, + snapshotFieldFunctions, + restoreFieldFunctions, + } = await import("../shared/lib/field-level-registry"); + const previousFieldFunctions = snapshotFieldFunctions(); + reloadUndo.push(() => restoreFieldFunctions(previousFieldFunctions)); + for (const collection of newConfig?.collections ?? []) { + const slug = (collection as { slug?: string }).slug; + const fields = (collection as { fields?: unknown[] }).fields; + if (slug && Array.isArray(fields)) { + registerFieldFunctions("collection", slug, fields); + } + } + for (const single of newConfig?.singles ?? []) { + const slug = (single as { slug?: string }).slug; + const fields = (single as { fields?: unknown[] }).fields; + if (slug && Array.isArray(fields)) { + registerFieldFunctions("single", slug, fields); + } + } } catch (err) { // The registry was rebuilt from the config that just failed; put the // working set back so the retained config keeps the behavior it was diff --git a/packages/nextly/src/shared/lib/__tests__/field-functions-snapshot.test.ts b/packages/nextly/src/shared/lib/__tests__/field-functions-snapshot.test.ts new file mode 100644 index 0000000000..8e6a958be6 --- /dev/null +++ b/packages/nextly/src/shared/lib/__tests__/field-functions-snapshot.test.ts @@ -0,0 +1,61 @@ +/** + * The field-function registry can be snapshotted and put back. + * + * A config reload re-registers from the newly read config before it knows the + * reload will land, on the same optimistic terms as the field-type registry. + * A reload that is then abandoned has to leave the retained config's functions + * in force: an access rule from a config that never took effect must not be + * the one deciding writes. + */ +import { describe, expect, it } from "vitest"; + +import { + getFieldFunctions, + registerFieldFunctions, + restoreFieldFunctions, + snapshotFieldFunctions, +} from "../field-level-registry"; + +describe("snapshotFieldFunctions / restoreFieldFunctions", () => { + it("puts back what was registered, and drops what the abandoned run added", () => { + registerFieldFunctions("collection", "kept", [ + { name: "tone", type: "text", defaultValue: () => "before" }, + ]); + const snapshot = snapshotFieldFunctions(); + + // What an abandoned reload would have done: change one entity and add one. + registerFieldFunctions("collection", "kept", [ + { name: "tone", type: "text", defaultValue: () => "after" }, + ]); + registerFieldFunctions("collection", "added", [ + { name: "tone", type: "text", defaultValue: () => "new" }, + ]); + expect( + getFieldFunctions("collection", "kept")?.tone?.defaultValue?.({}) + ).toBe("after"); + expect(getFieldFunctions("collection", "added")).toBeDefined(); + + restoreFieldFunctions(snapshot); + + expect( + getFieldFunctions("collection", "kept")?.tone?.defaultValue?.({}) + ).toBe("before"); + // The entity the abandoned run introduced is gone, not merely stale. + expect(getFieldFunctions("collection", "added")).toBeUndefined(); + }); + + it("is a copy, so registering after the snapshot does not change it", () => { + registerFieldFunctions("collection", "copied", [ + { name: "tone", type: "text", defaultValue: () => "one" }, + ]); + const snapshot = snapshotFieldFunctions(); + registerFieldFunctions("collection", "copied", [ + { name: "tone", type: "text", defaultValue: () => "two" }, + ]); + + restoreFieldFunctions(snapshot); + expect( + getFieldFunctions("collection", "copied")?.tone?.defaultValue?.({}) + ).toBe("one"); + }); +}); diff --git a/packages/nextly/src/shared/lib/field-level-registry.ts b/packages/nextly/src/shared/lib/field-level-registry.ts index 6db6f07d92..980a22d664 100644 --- a/packages/nextly/src/shared/lib/field-level-registry.ts +++ b/packages/nextly/src/shared/lib/field-level-registry.ts @@ -40,6 +40,7 @@ import { listEffectivePermissions, listRoleSlugsForUser, } from "../../services/lib/permissions"; +import { addressableFields } from "../addressable-fields"; import { detachData } from "./detach"; import type { ValidatableField } from "./entry-validation"; @@ -220,14 +221,46 @@ function extractFieldFunctions( return hasAny ? out : undefined; } +/** + * An unnamed GROUP is layout: its children are stored at the level the group + * sits in, which is the level this map is keyed by. An unnamed REPEATER is not: + * its children are stored PER ROW, so flattening them here would file a row's + * field under the parent's name and hand its rules the wrong object. + * + * The same distinction `collection-sources` draws, and it has to be made DURING + * the walk: the walk emits the children themselves, so by the time a predicate + * could read the result, a field reached through an unnamed repeater is + * indistinguishable from one reached through an unnamed group. + */ +function storedAtThisLevel(container: { type?: unknown }): boolean { + return container.type === "group"; +} + +/** + * The function-bearing fields at one level, keyed by name. + * + * Flattened through the shared `addressableFields` walk rather than a loop over + * the array, because an unnamed presentational container stores its children at + * the level it sits in. A loop over named entries drops every rule declared + * inside one: not just a `defaultValue`, but the `access` rule deciding whether + * a caller may read or write that field, and the hooks that shape it. The data + * side already flattens these containers, so the two now agree. + * + * `defineCollection` refuses a field with no name, so the documented code-first + * path cannot build this shape. A plugin contributing raw config can: + * `assertPluginFieldDeclarations` checks each field's TYPE and not its name, so + * an unnamed `group` reaches the live config and this map. Silently dropping + * the access rules inside it is the failure worth spending a walk to avoid. + */ function collectFieldFunctions( fields: unknown[] ): Record | undefined { const map: Record = {}; let hasAny = false; - for (const raw of fields) { - if (raw === null || typeof raw !== "object") continue; - const field = raw as Record; + for (const raw of addressableFields(fields, { + descendInto: storedAtThisLevel, + })) { + const field = raw as unknown as Record; if (typeof field.name !== "string" || !field.name) continue; const fns = extractFieldFunctions(field); if (fns) { @@ -255,6 +288,27 @@ export function registerFieldFunctions( } } +/** + * The whole registry as it stands, and the means to put it back. + * + * A config reload re-reads the live config and re-registers from it, which has + * to happen before the reload knows it will land: the same optimistic terms the + * field-type registry and the config's hooks are applied on. A reload that is + * then abandoned must leave the retained config's functions in force, or an + * access rule, hook or default from a config that never took effect would be + * the one deciding writes. + */ +export function snapshotFieldFunctions(): Store { + return new Map(store()); +} + +/** Put a {@link snapshotFieldFunctions} result back, discarding what is there. */ +export function restoreFieldFunctions(snapshot: Store): void { + const current = store(); + current.clear(); + for (const [k, v] of snapshot) current.set(k, v); +} + export function getFieldFunctions( kind: EntityKind, slug: string @@ -432,6 +486,13 @@ export async function applyFieldWriteAccess(opts: { authenticatedScope?: AuthenticatedScope; overrideAccess?: boolean; id?: string; + /** + * A resolver shared with another pass over the same write, so the caller's + * roles and permissions are read once and both passes judge with one + * authority. Build it with {@link writeAccessGrants}. Omitted, this pass + * builds its own, which is right for a write judged only once. + */ + grants?: () => Promise; }): Promise { // Bypass for a caller that has SAID it is trusted, and only that. The read // side gates on the same single condition, and the two must agree: a rule @@ -458,10 +519,12 @@ export async function applyFieldWriteAccess(opts: { // answers `{ permissions: [], roles: [] }` — so a rule written as // `({ permissions }) => permissions.includes(...)` refuses it, which is // the correct answer rather than an accident of the shape. - grants: grantsResolver( - typeof opts.user?.id === "string" ? opts.user.id : undefined, - opts.authenticatedScope - ), + grants: + opts.grants ?? + grantsResolver( + typeof opts.user?.id === "string" ? opts.user.id : undefined, + opts.authenticatedScope + ), }); } @@ -681,6 +744,21 @@ export function readAccessGrants( ); } +/** + * The caller's grants resolver for a write, to hand to every field-access pass + * over one record so roles and permissions are read once. The counterpart to + * {@link readAccessGrants}, for the write path's two passes. + */ +export function writeAccessGrants( + user: Record | undefined, + authenticatedScope?: AuthenticatedScope +): () => Promise { + return grantsResolver( + typeof user?.id === "string" ? user.id : undefined, + authenticatedScope + ); +} + /** * Enforce field-level read access on a serialized entry: fields whose * `access.read` denies are removed from the response, at every depth. From 9b65954c2c3352845a46e3f0236b57ea84e8e0bd Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sat, 12 Sep 2026 12:59:37 +0300 Subject: [PATCH 2/6] feat(nextly): a field-group child takes a function default (#1822) A field group's fields are read from its stored definition on every write, and a function does not survive being stored, so only a constant default applied there. A `defaultValue` written as a function on a field-group child was silently dropped on every write, which #1809 documented as an exception rather than closed. The field-level registry already solves exactly this for collections and Singles: it captures the function-bearing half of the live config at boot, and the write path reads the function from there. Field groups now join it. The configs were already in hand at the point of registration, so nothing new is loaded: `NextlyServiceConfig` carries `fieldGroups`, and the same function that registers every collection and every single registers them beside it. The config-reload path re-registers them too, so an edit is picked up without a restart. `prepareInstanceForWrite` takes the field group's slug, available at all six of its call sites, and hands the function map to `applyFieldDefaults` exactly as a collection create does. Only the default is wired. The same capture holds a field's access rules, hooks and validate, and nothing reads those for a field group, so registering them changes nothing about whether they are enforced. The entity kind's comment says so, rather than leaving the next reader to infer it. --- ...ld-group-child-takes-a-function-default.md | 31 ++++++++++ docs/configuration/fields.mdx | 11 ++-- packages/nextly/src/di/register.ts | 12 ++++ ...roup-instance-defaults.integration.test.ts | 57 +++++++++++++++++++ .../services/field-group-mutation-service.ts | 33 ++++++++--- packages/nextly/src/init/reload-config.ts | 7 +++ .../src/shared/lib/field-level-registry.ts | 12 +++- 7 files changed, 148 insertions(+), 15 deletions(-) create mode 100644 .changeset/a-field-group-child-takes-a-function-default.md diff --git a/.changeset/a-field-group-child-takes-a-function-default.md b/.changeset/a-field-group-child-takes-a-function-default.md new file mode 100644 index 0000000000..9c58277b33 --- /dev/null +++ b/.changeset/a-field-group-child-takes-a-function-default.md @@ -0,0 +1,31 @@ +--- +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/builder": patch +"create-nextly-app": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/module-specifiers": patch +"nextly": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/ui": patch +--- + +A `defaultValue` written as a function now works on a reusable field group's children. A field group's fields are read from its stored definition on every write, and a function does not survive being stored, so only constants applied there: a function default on a field-group child was silently dropped on every write. Field groups now get the same live-config capture collections and Singles have, so the function form resolves from the config at boot, and it is re-read when the dev server reloads the config. It resolves against the instance being built, so a child may compute from a sibling defaulted before it. + +Only the default is wired. The same capture holds a field's `access` rules, hooks and `validate`, and nothing reads those for a field group, so registering them changes nothing about whether they are enforced. diff --git a/docs/configuration/fields.mdx b/docs/configuration/fields.mdx index 60b24c662e..ba339f56d3 100644 --- a/docs/configuration/fields.mdx +++ b/docs/configuration/fields.mdx @@ -1036,12 +1036,11 @@ data built so far at its own level: the document for a top-level field, the group or the row for a nested one. Rows are never invented; `minRows` is a validation rule, not an instruction to create rows. -Inside a reusable field group, only a constant default applies. A field -group's fields are read from its stored definition on every write, and a -function cannot be stored, so a `defaultValue` written as a function on a -field-group child is not applied anywhere: not on a collection create, not on -a Single's first read, and not in the admin form. Give a field-group child a -constant default, or set the value from the parent's own field. +A field-group child takes its default the same way, constant or function. Its +fields are read from a stored definition, which cannot carry a function, so the +function form is read from the live config exactly as a collection's is. It +resolves against the instance being built, so a child may compute from a sibling +that was defaulted before it. A Single's first read creates its row with the same defaults, top-level and nested, with two exceptions: a default on a `fieldGroup` field itself is diff --git a/packages/nextly/src/di/register.ts b/packages/nextly/src/di/register.ts index c272f8c7c9..706f3bf825 100644 --- a/packages/nextly/src/di/register.ts +++ b/packages/nextly/src/di/register.ts @@ -1823,6 +1823,18 @@ async function registerConfigTablesInResolver( ); } } + + // Field groups: no table to resolve here (a field group's own table is + // provisioned by the field-group services), only the live config to capture. + // Its fields are read from the stored definition on every write, the same + // place a collection's are, so a `defaultValue` written as a function is + // dropped there and can only come from here. + for (const fieldGroup of config.fieldGroups ?? []) { + const slug = (fieldGroup as { slug?: string }).slug; + const fields = (fieldGroup as { fields?: unknown[] }).fields; + if (!slug || !Array.isArray(fields) || fields.length === 0) continue; + registerFieldFunctions("fieldGroup", slug, fields); + } } function logStorageConfiguration( diff --git a/packages/nextly/src/domains/field-groups/__tests__/field-group-instance-defaults.integration.test.ts b/packages/nextly/src/domains/field-groups/__tests__/field-group-instance-defaults.integration.test.ts index 07016de12c..ad76c2b8e9 100644 --- a/packages/nextly/src/domains/field-groups/__tests__/field-group-instance-defaults.integration.test.ts +++ b/packages/nextly/src/domains/field-groups/__tests__/field-group-instance-defaults.integration.test.ts @@ -166,4 +166,61 @@ describe("a new field-group instance takes its declared defaults (integration)", { heading: "Given", tone: "calm" }, ]); }); + it("applies a FUNCTION default declared on a field-group child", async () => { + current = await createTestNextly({ + fieldGroups: [ + defineFieldGroup({ + slug: "fnhero", + fields: [ + text({ name: "mode", defaultValue: "dark" }), + // A function cannot be stored, so the stored definition this write + // reads carries nothing for it. It resolves from the live config. + text({ + name: "label", + defaultValue: d => `mode-${String(d.mode)}`, + }), + ], + }), + ], + collections: [ + defineCollection({ + slug: "fnpages", + fields: [ + text({ name: "title" }), + fieldGroup({ name: "hero", component: "fnhero" }), + fieldGroup({ + name: "slides", + component: "fnhero", + repeatable: true, + }), + ], + }), + ], + }); + const handler = current.getService("collectionsHandler"); + + const created = await handler.createEntry( + { collectionName: "fnpages", overrideAccess: true }, + { title: "Home", hero: {}, slides: [{ mode: "light" }] } + ); + expect(created.success, JSON.stringify(created)).toBe(true); + + const read = await handler.getEntry({ + collectionName: "fnpages", + entryId: (created.data as { id: string }).id, + overrideAccess: true, + }); + const data = read.data as { + hero?: { mode?: string; label?: string }; + slides?: Array<{ mode?: string; label?: string }>; + }; + // Resolved against the instance built so far, as it is for a collection + // field: the constant before it is visible to the function after it. + expect(data.hero).toMatchObject({ mode: "dark", label: "mode-dark" }); + // A supplied sibling value is what the function reads, not the default. + expect(data.slides?.[0]).toMatchObject({ + mode: "light", + label: "mode-light", + }); + }); }); diff --git a/packages/nextly/src/domains/field-groups/services/field-group-mutation-service.ts b/packages/nextly/src/domains/field-groups/services/field-group-mutation-service.ts index 6c3af5a0c9..6875fe62c7 100644 --- a/packages/nextly/src/domains/field-groups/services/field-group-mutation-service.ts +++ b/packages/nextly/src/domains/field-groups/services/field-group-mutation-service.ts @@ -19,6 +19,7 @@ import type { FieldGroupRegistryService } from "../../../services/field-groups/f import { BaseService } from "../../../shared/base-service"; import { validateEntryData } from "../../../shared/lib/entry-validation"; import { applyFieldDefaults } from "../../../shared/lib/field-defaults"; +import { getFieldFunctions } from "../../../shared/lib/field-level-registry"; import { coerceDateFieldsToDate, normalizeRelationshipFields, @@ -667,7 +668,8 @@ export class FieldGroupMutationService extends BaseService { data, componentFields, existing.length > 0 ? "update" : "create", - req + req, + componentMeta.slug ); // i18n: split translatable values out of the main comp_ write — they live on the @@ -784,7 +786,8 @@ export class FieldGroupMutationService extends BaseService { data, componentFields, existing.length > 0 ? "update" : "create", - req + req, + componentMeta.slug ); // i18n: split translatable values out of the main comp_ write (companion-owned). @@ -898,7 +901,8 @@ export class FieldGroupMutationService extends BaseService { instance, componentFields, instanceId && existingMap.has(instanceId) ? "update" : "create", - req + req, + componentMeta.slug ); // i18n: split translatable values out per instance (companion-owned). The @@ -1032,7 +1036,8 @@ export class FieldGroupMutationService extends BaseService { instance, componentFields, instanceId && existingMap.has(instanceId) ? "update" : "create", - req + req, + componentMeta.slug ); // i18n: split translatable values out (companion-owned) per instance. @@ -1217,7 +1222,8 @@ export class FieldGroupMutationService extends BaseService { instance, componentFields, instanceId && globalExistingMap.has(instanceId) ? "update" : "create", - req + req, + meta.slug ); // i18n: split translatable values out per instance using its own component meta. @@ -1385,7 +1391,8 @@ export class FieldGroupMutationService extends BaseService { instance, componentFields, instanceId && globalExistingMap.has(instanceId) ? "update" : "create", - req + req, + meta.slug ); // i18n: split translatable values out per instance using its own component meta. @@ -1630,7 +1637,13 @@ export class FieldGroupMutationService extends BaseService { instance: ComponentInstanceData, componentFields: FieldConfig[], mode: "create" | "update", - req: Record = {} + req: Record = {}, + /** + * The field group's slug, so a `defaultValue` written as a FUNCTION can be + * read from the field-level registry. The fields above come from the + * stored definition, which carries constants and cannot carry a function. + */ + slug?: string ): Promise { // A component instance must be a plain object. A primitive (e.g. a bare // string sent for a non-repeatable component field) would make the field @@ -1653,7 +1666,11 @@ export class FieldGroupMutationService extends BaseService { // instance the caller could not have completed. An existing instance is // left alone: its absent keys are the caller's patch, not missing values. if (mode === "create") { - applyFieldDefaults(instance, componentFields); + applyFieldDefaults( + instance, + componentFields, + slug ? getFieldFunctions("fieldGroup", slug) : undefined + ); } // Reduced in place, and before validation, because both halves of the write // depend on it. A validator is written against a field's public value, the diff --git a/packages/nextly/src/init/reload-config.ts b/packages/nextly/src/init/reload-config.ts index a86b32bf62..566fe5123f 100644 --- a/packages/nextly/src/init/reload-config.ts +++ b/packages/nextly/src/init/reload-config.ts @@ -1562,6 +1562,13 @@ async function applyReload(opts?: { registerFieldFunctions("single", slug, fields); } } + for (const fieldGroup of newConfig?.fieldGroups ?? []) { + const slug = (fieldGroup as { slug?: string }).slug; + const fields = (fieldGroup as { fields?: unknown[] }).fields; + if (slug && Array.isArray(fields)) { + registerFieldFunctions("fieldGroup", slug, fields); + } + } } catch (err) { // The registry was rebuilt from the config that just failed; put the // working set back so the retained config keeps the behavior it was diff --git a/packages/nextly/src/shared/lib/field-level-registry.ts b/packages/nextly/src/shared/lib/field-level-registry.ts index 980a22d664..bf28f9aafc 100644 --- a/packages/nextly/src/shared/lib/field-level-registry.ts +++ b/packages/nextly/src/shared/lib/field-level-registry.ts @@ -157,7 +157,17 @@ export interface FieldFunctions { fields?: Record; } -type EntityKind = "collection" | "single"; +/** + * What a registration is keyed by. + * + * A field group joins the two entity kinds because it has the same problem and + * only the registry solves it: its fields are read from a stored definition on + * every write, and a function does not survive being stored. What is CONSUMED + * from a field group's entry today is the `defaultValue` alone. Its `access` + * rules and hooks are captured by the same walk but nothing reads them for a + * field group, so registering one does not mean they are enforced. + */ +type EntityKind = "collection" | "single" | "fieldGroup"; type Store = Map>; const GLOBAL_KEY = "__nextlyFieldFunctionRegistry"; From 19bf093ac9c5476f1a007055fec2bc0501ce4a4e Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sat, 12 Sep 2026 13:12:46 +0300 Subject: [PATCH 3/6] fix(nextly): judge a create's field rules on a copy, and swap the registry at the commit Three review findings on the access and reload changes. The pre-defaults access pass DELETED denied fields, and a create rule may depend on a sibling the caller omitted precisely BECAUSE it has a default (`data.kind === "public"` where `kind` defaults to `public`). The rule was judged before that default existed, denied, and the caller's value was gone before the pass that decides correctly could keep it. Measured: a supplied `note` came back null on a create that succeeded before. The rules now run over a COPY, and the copy is what the function defaults read. `applyFieldDefaults` takes that view as an argument and threads it through groups and repeater rows, so a nested default reads the nested view. The record itself is untouched until the authoritative pass, which runs after the defaults and therefore judges the condition they establish. The reload installed the new field functions as soon as the config was read, with an undo on the abandon paths. A reload whose DDL succeeds and whose later metadata sync fails does not commit and does not run those undos, so a rule from a config the process refused was deciding writes while every other service ran the previous one. It is now staged and installed by `commitReload`, the same boundary the config's hooks and the retention policies are published from. That install replaces the registry wholesale rather than overwriting the slugs it finds. A collection dropped from the config keeps its registry row and its table so an orphan sweep can find them later, so it stays addressable, and its entry here would have gone on running its old access rules, hooks, validators and defaults until the process restarted. --- .../a-default-cannot-read-a-denied-field.md | 5 +- ...ot-read-a-denied-field.integration.test.ts | 37 +++++++++ .../services/collection-mutation-service.ts | 32 ++++---- packages/nextly/src/init/reload-config.ts | 55 +++++++------ .../__tests__/field-functions-replace.test.ts | 80 +++++++++++++++++++ .../field-functions-snapshot.test.ts | 61 -------------- .../nextly/src/shared/lib/field-defaults.ts | 51 +++++++++--- .../src/shared/lib/field-level-registry.ts | 43 ++++++---- 8 files changed, 238 insertions(+), 126 deletions(-) create mode 100644 packages/nextly/src/shared/lib/__tests__/field-functions-replace.test.ts delete mode 100644 packages/nextly/src/shared/lib/__tests__/field-functions-snapshot.test.ts diff --git a/.changeset/a-default-cannot-read-a-denied-field.md b/.changeset/a-default-cannot-read-a-denied-field.md index bb22948578..33c672cbc2 100644 --- a/.changeset/a-default-cannot-read-a-denied-field.md +++ b/.changeset/a-default-cannot-read-a-denied-field.md @@ -24,11 +24,12 @@ "@nextlyhq/telemetry": patch "@nextlyhq/tsconfig": patch "@nextlyhq/ui": patch +"@nextlyhq/plugin-mcp": patch --- -A function default cannot read a field the caller may not write. Field write access now runs before the defaults as well as after the hooks, the two passes the read path already runs: a `defaultValue` written as a function receives the data built so far, and it ran while a field this caller is denied was still in the record, so a default could read the forbidden value and carry it into a field the caller IS allowed to write. The denied field was stripped and its value was persisted anyway, one column across. +A function default cannot read a field the caller may not write. A `defaultValue` written as a function receives the data built so far, and that data still held every value the caller sent, including one a field rule denies them, so a default could read the forbidden value and carry it into a field the caller IS allowed to write: the denied field was stripped and its value persisted anyway, one column across. The rules are now applied to a COPY, and the copy is what the functions read. The record itself is left alone until the pass that decides what is stored, because a rule may depend on a sibling the caller omitted precisely BECAUSE it has a default, and judging that rule before the defaults exist would deny it and lose the value for good. -A config reload re-registers the live config's field functions. The field-level registry holds every field's `access` rules, hooks, `validate` and function `defaultValue`, none of which survive being stored, and a reload never went back through service registration: an edit to any of them kept running the version from process start until the dev server was restarted. That is wrong in the direction that matters most for an access rule, since a rule tightened in the config was not the one being enforced. Applied on the same optimistic terms as the field-type registry, and restored by the same undo when a reload is abandoned. +A config reload installs the live config's field functions, replacing them wholesale at the point the reload commits. Replacing rather than adding, so an entity the new config no longer declares stops deciding anything: a collection dropped from the config keeps its registry row and its table so an orphan sweep can find them, so it stays addressable. At the commit point rather than when the config is read, so a reload whose DDL succeeds and whose later sync fails does not leave a rule from a config the process refused deciding writes. The field-level registry holds every field's `access` rules, hooks, `validate` and function `defaultValue`, none of which survive being stored, and a reload never went back through service registration: an edit to any of them kept running the version from process start until the dev server was restarted. That is wrong in the direction that matters most for an access rule, since a rule tightened in the config was not the one being enforced. Applied on the same optimistic terms as the field-type registry, and restored by the same undo when a reload is abandoned. An access rule declared inside an unnamed container is captured. The registry keyed only named entries, so every rule, hook and validator inside an unnamed presentational group was dropped. `defineCollection` refuses a field with no name, but a plugin contributing raw config is checked on its field TYPES and not their names, so the shape reaches the live config. diff --git a/packages/nextly/src/collections/__tests__/default-cannot-read-a-denied-field.integration.test.ts b/packages/nextly/src/collections/__tests__/default-cannot-read-a-denied-field.integration.test.ts index e379bc0898..b38346a428 100644 --- a/packages/nextly/src/collections/__tests__/default-cannot-read-a-denied-field.integration.test.ts +++ b/packages/nextly/src/collections/__tests__/default-cannot-read-a-denied-field.integration.test.ts @@ -91,4 +91,41 @@ describe("a function default cannot read a field the caller may not write", () = expect(row.secret).toBe("system-value"); expect(row.derived).toBe("saw:system-value"); }); + it("keeps a value whose permission a DEFAULT establishes", async () => { + current = await createTestNextly({ + collections: [ + defineCollection({ + slug: "gated", + fields: [ + text({ name: "title" }), + text({ name: "kind", defaultValue: "public" }), + text({ + name: "note", + access: { + create: ({ data }) => + (data as { kind?: string } | undefined)?.kind === "public", + }, + }), + ], + }), + ], + }); + const handler = current.getService<"collectionsHandler">( + "collectionsHandler" + ) as unknown as CollectionsHandler; + + const created = await handler.createEntry( + { collectionName: "gated", routeAuthorized: true, user: CALLER }, + // `kind` is omitted on purpose: its default is what satisfies the rule + // guarding `note`. + { title: "t", note: "should survive" } + ); + + expect(created.success, JSON.stringify(created)).toBe(true); + const row = created.data as Record; + expect(row.kind).toBe("public"); + // The rules that decide what is STORED run after the defaults, so the + // value the caller sent is still there to be allowed. + expect(row.note).toBe("should survive"); + }); }); diff --git a/packages/nextly/src/domains/collections/services/collection-mutation-service.ts b/packages/nextly/src/domains/collections/services/collection-mutation-service.ts index 2f80a3ddd4..cd647a641f 100644 --- a/packages/nextly/src/domains/collections/services/collection-mutation-service.ts +++ b/packages/nextly/src/domains/collections/services/collection-mutation-service.ts @@ -2911,24 +2911,27 @@ export class CollectionMutationService extends BaseService { params.user, params.authenticatedScope ); - // Field write access runs HERE as well as below, the two passes the read - // path runs around its hooks and for the same reason. + // What a function default is allowed to READ, which is not the same as + // what this write is allowed to STORE. // - // A function default receives the data built so far. Judged only after - // the defaults, a field this caller may not create would still be sitting - // in the record while those functions ran, so a default could read the - // forbidden value and carry it into a field the caller IS allowed to - // write. The denied field was stripped and its value persisted anyway, - // one column across. Stripping first means the functions see what this - // caller was actually allowed to send. + // A function default receives the data built so far, and that data still + // holds every value the caller sent, including one a field rule denies + // them. Left alone, a default could read the forbidden value and carry it + // into a field the caller IS allowed to write: the denied field would be + // stripped below and its value persisted anyway, one column across. // - // The second pass below is not redundant: the hooks in between may put a - // denied key back, and that pass judges the key by its own rule on the - // value the hook wrote. + // So the rules are applied to a COPY, and the copy is what the functions + // read. Applying them to the record itself would be wrong in the other + // direction: a rule may depend on a sibling the caller omitted BECAUSE it + // has a default (`data.kind === "public"` where `kind` defaults to + // `public`), and judging that rule before the defaults exist denies it. + // Deleting the value then would lose it for good, since the pass that + // decides correctly runs after the defaults and has nothing left to keep. + const readableByDefaults: Record = { ...seededBody }; await applyFieldWriteAccess({ kind: "collection", slug: params.collectionName, - data: seededBody, + data: readableByDefaults, operation: "create", user: params.user, authenticatedScope: params.authenticatedScope, @@ -2940,7 +2943,8 @@ export class CollectionMutationService extends BaseService { applyFieldDefaults( seededBody, fields, - getFieldFunctions("collection", params.collectionName) + getFieldFunctions("collection", params.collectionName), + readableByDefaults ); const beforeOpArgs = diff --git a/packages/nextly/src/init/reload-config.ts b/packages/nextly/src/init/reload-config.ts index a86b32bf62..e6d605a480 100644 --- a/packages/nextly/src/init/reload-config.ts +++ b/packages/nextly/src/init/reload-config.ts @@ -1450,6 +1450,15 @@ async function applyReload(opts?: { * registered as that work happens and run together on abandonment. */ const reloadUndo: Array<() => void> = []; + /** + * Installs the field-level registry the newly read config describes. + * + * Set once that config has been read and collected, and called only from + * `commitReload`. A reload abandoned before it is set, or one that never + * commits, leaves the previous registry in force by never having replaced + * it, so no rule from a refused config decides a write. + */ + let commitFieldFunctions: (() => void) | undefined; /** * Put the field-type registry and the config's hooks back when a reload does * not take effect. @@ -1538,30 +1547,27 @@ async function applyReload(opts?: { // direction that matters most for an access rule: a rule TIGHTENED in the // config was not the one being enforced. // - // Applied on the same optimistic terms as the field types above, and put - // back by the same undo, so a reload that is abandoned leaves the retained - // config's functions in force. - const { - registerFieldFunctions, - snapshotFieldFunctions, - restoreFieldFunctions, - } = await import("../shared/lib/field-level-registry"); - const previousFieldFunctions = snapshotFieldFunctions(); - reloadUndo.push(() => restoreFieldFunctions(previousFieldFunctions)); - for (const collection of newConfig?.collections ?? []) { - const slug = (collection as { slug?: string }).slug; - const fields = (collection as { fields?: unknown[] }).fields; - if (slug && Array.isArray(fields)) { - registerFieldFunctions("collection", slug, fields); - } - } - for (const single of newConfig?.singles ?? []) { - const slug = (single as { slug?: string }).slug; - const fields = (single as { fields?: unknown[] }).fields; - if (slug && Array.isArray(fields)) { - registerFieldFunctions("single", slug, fields); - } - } + // Staged here and installed by `commitReload`, the same boundary the + // config's hooks and the retention policies are published from. A reload + // whose DDL succeeds and whose metadata sync then fails does not commit, + // and a rule from a config the process refused must not be the one + // deciding writes in the meantime. + const { replaceFieldFunctions } = await import( + "../shared/lib/field-level-registry" + ); + const fieldFunctionSources = [ + ...(newConfig?.collections ?? []).map(entity => ({ + kind: "collection" as const, + slug: (entity as { slug?: string }).slug ?? "", + fields: (entity as { fields?: unknown[] }).fields ?? [], + })), + ...(newConfig?.singles ?? []).map(entity => ({ + kind: "single" as const, + slug: (entity as { slug?: string }).slug ?? "", + fields: (entity as { fields?: unknown[] }).fields ?? [], + })), + ]; + commitFieldFunctions = () => replaceFieldFunctions(fieldFunctionSources); } catch (err) { // The registry was rebuilt from the config that just failed; put the // working set back so the retained config keeps the behavior it was @@ -1621,6 +1627,7 @@ async function applyReload(opts?: { if (committed) return; committed = true; commitConfigHooks(); + commitFieldFunctions?.(); // Published here rather than when the file is read. A reload that is later // refused still parsed a valid config, and publishing early would leave a // policy the process explicitly rejected in force — deleting on windows diff --git a/packages/nextly/src/shared/lib/__tests__/field-functions-replace.test.ts b/packages/nextly/src/shared/lib/__tests__/field-functions-replace.test.ts new file mode 100644 index 0000000000..1490a466f0 --- /dev/null +++ b/packages/nextly/src/shared/lib/__tests__/field-functions-replace.test.ts @@ -0,0 +1,80 @@ +/** + * The field-function registry is REPLACED from the config in force. + * + * A reload re-reads the config and installs what it describes at the reload's + * commit point. Two things have to be true of that install. An entity the new + * config no longer declares must stop deciding anything: a collection dropped + * from `nextly.config.ts` keeps its registry row and its table so an orphan + * sweep can find them, so it stays addressable, and a rule left behind here + * would keep running. And nothing may be installed by a reload that does not + * commit, or a rule from a config the process refused would decide writes + * while every other service ran the previous one. + */ +import { describe, expect, it } from "vitest"; + +import { + getFieldFunctions, + registerFieldFunctions, + replaceFieldFunctions, +} from "../field-level-registry"; + +describe("replaceFieldFunctions", () => { + it("drops an entity the new config no longer declares", () => { + registerFieldFunctions("collection", "kept", [ + { name: "tone", type: "text", defaultValue: () => "before" }, + ]); + registerFieldFunctions("collection", "removed", [ + { name: "tone", type: "text", defaultValue: () => "gone" }, + ]); + + replaceFieldFunctions([ + { + kind: "collection", + slug: "kept", + fields: [{ name: "tone", type: "text", defaultValue: () => "after" }], + }, + ]); + + expect( + getFieldFunctions("collection", "kept")?.tone?.defaultValue?.({}) + ).toBe("after"); + // Not merely stale: absent, so nothing of it runs. + expect(getFieldFunctions("collection", "removed")).toBeUndefined(); + }); + + it("keeps the kinds apart", () => { + replaceFieldFunctions([ + { + kind: "collection", + slug: "shared", + fields: [{ name: "tone", type: "text", defaultValue: () => "coll" }], + }, + { + kind: "single", + slug: "shared", + fields: [{ name: "tone", type: "text", defaultValue: () => "single" }], + }, + ]); + + expect( + getFieldFunctions("collection", "shared")?.tone?.defaultValue?.({}) + ).toBe("coll"); + expect( + getFieldFunctions("single", "shared")?.tone?.defaultValue?.({}) + ).toBe("single"); + }); + + it("installs nothing for an entity that declares no functions", () => { + registerFieldFunctions("collection", "plain", [ + { name: "tone", type: "text", defaultValue: () => "x" }, + ]); + replaceFieldFunctions([ + { + kind: "collection", + slug: "plain", + fields: [{ name: "tone", type: "text" }], + }, + ]); + expect(getFieldFunctions("collection", "plain")).toBeUndefined(); + }); +}); diff --git a/packages/nextly/src/shared/lib/__tests__/field-functions-snapshot.test.ts b/packages/nextly/src/shared/lib/__tests__/field-functions-snapshot.test.ts deleted file mode 100644 index 8e6a958be6..0000000000 --- a/packages/nextly/src/shared/lib/__tests__/field-functions-snapshot.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * The field-function registry can be snapshotted and put back. - * - * A config reload re-registers from the newly read config before it knows the - * reload will land, on the same optimistic terms as the field-type registry. - * A reload that is then abandoned has to leave the retained config's functions - * in force: an access rule from a config that never took effect must not be - * the one deciding writes. - */ -import { describe, expect, it } from "vitest"; - -import { - getFieldFunctions, - registerFieldFunctions, - restoreFieldFunctions, - snapshotFieldFunctions, -} from "../field-level-registry"; - -describe("snapshotFieldFunctions / restoreFieldFunctions", () => { - it("puts back what was registered, and drops what the abandoned run added", () => { - registerFieldFunctions("collection", "kept", [ - { name: "tone", type: "text", defaultValue: () => "before" }, - ]); - const snapshot = snapshotFieldFunctions(); - - // What an abandoned reload would have done: change one entity and add one. - registerFieldFunctions("collection", "kept", [ - { name: "tone", type: "text", defaultValue: () => "after" }, - ]); - registerFieldFunctions("collection", "added", [ - { name: "tone", type: "text", defaultValue: () => "new" }, - ]); - expect( - getFieldFunctions("collection", "kept")?.tone?.defaultValue?.({}) - ).toBe("after"); - expect(getFieldFunctions("collection", "added")).toBeDefined(); - - restoreFieldFunctions(snapshot); - - expect( - getFieldFunctions("collection", "kept")?.tone?.defaultValue?.({}) - ).toBe("before"); - // The entity the abandoned run introduced is gone, not merely stale. - expect(getFieldFunctions("collection", "added")).toBeUndefined(); - }); - - it("is a copy, so registering after the snapshot does not change it", () => { - registerFieldFunctions("collection", "copied", [ - { name: "tone", type: "text", defaultValue: () => "one" }, - ]); - const snapshot = snapshotFieldFunctions(); - registerFieldFunctions("collection", "copied", [ - { name: "tone", type: "text", defaultValue: () => "two" }, - ]); - - restoreFieldFunctions(snapshot); - expect( - getFieldFunctions("collection", "copied")?.tone?.defaultValue?.({}) - ).toBe("one"); - }); -}); diff --git a/packages/nextly/src/shared/lib/field-defaults.ts b/packages/nextly/src/shared/lib/field-defaults.ts index 96785d49fa..23becbc6f5 100644 --- a/packages/nextly/src/shared/lib/field-defaults.ts +++ b/packages/nextly/src/shared/lib/field-defaults.ts @@ -62,14 +62,31 @@ const NON_COLUMN_TYPES: ReadonlySet = new Set(fieldGroupFieldTypes); export function applyFieldDefaults( data: Record, fields: readonly ValidatableField[], - functions?: DefaultFunctions + functions?: DefaultFunctions, + /** + * What a FUNCTION default reads, when that is not the record being filled. + * + * A function default receives the document built so far, and on a create + * that document still holds every value the caller sent, including one a + * field rule denies them. The caller passes a filtered view here so a + * default cannot read a value its writer was not allowed to send, while the + * record being filled keeps that value for the authoritative access pass to + * remove. Filtering the record itself instead would delete the value before + * the defaults that decide whether it is allowed have run at all. + * + * Defaults to the record, which is right wherever there is nothing to hide. + */ + view?: Record ): void { + const readFrom = view ?? data; for (const field of fields) { // A layout container (row, tabs, collapsible) groups fields visually // without holding a value: its children are stored on the parent, so they // are filled against the same object. if (!field.name) { - if (field.fields) applyFieldDefaults(data, field.fields, functions); + if (field.fields) { + applyFieldDefaults(data, field.fields, functions, readFrom); + } continue; } if (NON_COLUMN_TYPES.has(field.type)) continue; @@ -92,7 +109,7 @@ export function applyFieldDefaults( // values the caller did supply, and earlier defaults in this same pass. data[field.name] = cloneDefault( typeof declared === "function" - ? (declared as (d: Record) => unknown)(data) + ? (declared as (d: Record) => unknown)(readFrom) : declared ); } @@ -103,9 +120,9 @@ export function applyFieldDefaults( // before it runs or a required child fails on an entry the caller could // not have satisfied. if (field.type === "group") { - fillGroup(data, field.name, field.fields, own?.fields); + fillGroup(data, field.name, field.fields, own?.fields, readFrom); } else if (field.type === "repeater") { - fillRepeaterRows(data, field.name, field.fields, own?.fields); + fillRepeaterRows(data, field.name, field.fields, own?.fields, readFrom); } } } @@ -143,8 +160,11 @@ function fillGroup( data: Record, name: string, fields: readonly ValidatableField[], - functions: DefaultFunctions | undefined + functions: DefaultFunctions | undefined, + /** The parent's view; a child reads the view's matching container. */ + view?: Record ): void { + const childView = isPlainObject(view?.[name]) ? view[name] : undefined; const existing = Object.prototype.hasOwnProperty.call(data, name) ? data[name] : undefined; @@ -154,7 +174,7 @@ function fillGroup( // A shallow copy per level is enough, because each level down copies again // before it writes. const filled = { ...existing }; - applyFieldDefaults(filled, fields, functions); + applyFieldDefaults(filled, fields, functions, childView); data[name] = filled; return; } @@ -163,7 +183,7 @@ function fillGroup( if (existing !== undefined) return; const seeded: Record = {}; - applyFieldDefaults(seeded, fields, functions); + applyFieldDefaults(seeded, fields, functions, childView); if (Object.keys(seeded).length > 0) data[name] = seeded; } @@ -178,17 +198,26 @@ function fillRepeaterRows( data: Record, name: string, fields: readonly ValidatableField[], - functions: DefaultFunctions | undefined + functions: DefaultFunctions | undefined, + /** The parent's view; each row reads the view's row at the same index. */ + view?: Record ): void { + const viewRows = Array.isArray(view?.[name]) ? view[name] : undefined; const value = data[name]; if (!Array.isArray(value)) return; // Rows are the caller's objects, so each one that gains a default is filled // through a copy and the list is rebuilt from the results. let changed = false; - const rows = value.map(row => { + const rows = value.map((row, index) => { if (!isPlainObject(row)) return row; const filled = { ...row }; - applyFieldDefaults(filled, fields, functions); + const rowView = viewRows?.[index]; + applyFieldDefaults( + filled, + fields, + functions, + isPlainObject(rowView) ? rowView : undefined + ); changed = true; return filled; }); diff --git a/packages/nextly/src/shared/lib/field-level-registry.ts b/packages/nextly/src/shared/lib/field-level-registry.ts index 980a22d664..c5180fad69 100644 --- a/packages/nextly/src/shared/lib/field-level-registry.ts +++ b/packages/nextly/src/shared/lib/field-level-registry.ts @@ -288,25 +288,40 @@ export function registerFieldFunctions( } } +/** One entity's live config, for a wholesale registry replacement. */ +export interface FieldFunctionSource { + kind: EntityKind; + slug: string; + fields: unknown[]; +} + /** - * The whole registry as it stands, and the means to put it back. + * Rebuild the whole registry from the config that is now in force. + * + * Replacing rather than adding, because a reload can REMOVE an entity: a + * collection dropped from `nextly.config.ts` keeps its registry row and its + * table so an orphan sweep can find them later, so it stays addressable, and + * an entry left behind here would keep deciding its access, running its hooks + * and filling its defaults from a config that no longer declares it. * - * A config reload re-reads the live config and re-registers from it, which has - * to happen before the reload knows it will land: the same optimistic terms the - * field-type registry and the config's hooks are applied on. A reload that is - * then abandoned must leave the retained config's functions in force, or an - * access rule, hook or default from a config that never took effect would be - * the one deciding writes. + * Called at the reload's commit point, not when the new config is read. A + * reload that is refused after its DDL has run still parsed a valid config, + * and installing these early would leave a rule the process explicitly + * rejected deciding writes while every other service still ran the previous + * one. */ -export function snapshotFieldFunctions(): Store { - return new Map(store()); -} - -/** Put a {@link snapshotFieldFunctions} result back, discarding what is there. */ -export function restoreFieldFunctions(snapshot: Store): void { +export function replaceFieldFunctions( + sources: readonly FieldFunctionSource[] +): void { + const next: Store = new Map(); + for (const source of sources) { + if (!source.slug || !Array.isArray(source.fields)) continue; + const map = collectFieldFunctions(source.fields); + if (map) next.set(key(source.kind, source.slug), map); + } const current = store(); current.clear(); - for (const [k, v] of snapshot) current.set(k, v); + for (const [k, v] of next) current.set(k, v); } export function getFieldFunctions( From 3b8110a29706c8280ccd71241bcb690baf91d2b2 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sun, 13 Sep 2026 01:19:27 +0300 Subject: [PATCH 4/6] fix(nextly): the promote gate judges the draft the write commits (#1833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(builder): edit an instance's exposed link and visibility in the inspector (#1798) * feat(builder): offer the site's components in the insert panel, placed as one linked node The third tier of the palette, beside blocks and patterns. A component is offered from the definitions a host supplies — read nothing, as the pattern tier reads nothing — and placing one writes a SINGLE instance node carrying the definition's id. Nothing of the definition's content is copied into the page; the renderer inlines it at read time, which is what makes an edit to the definition reach every page that placed it. That is the opposite promise from a pattern's copy, so the tile says which: a visible badge for a sighted author choosing between look-alikes, and the same promise leading the description a screen reader hears after the name — not in the name, which stays exactly the visible label so a spoken command matches what is written on the tile. Placement is judged by the definition's ROOTS, through the rule patterns are already judged by. The instance node's own type is not a registered block and the nesting source answers "no restriction" for a type it cannot resolve, so judging the node by its type would let a component whose root may only live inside columns be placed at the page root — and render there. Rows the palette has nothing to place for are skipped rather than refused: a published definition saved without content, and a row holding something other than a component. The kind check is pinned by a fixture that HAS roots, since one without would be dropped by the empty-roots check first and the case would pass whether or not the kind was ever read. Click to place only, for now. The drag path judges a placement by one block name, and a component is judged by its roots; giving it the instance type would bypass that judgement, so it stays with the click path as a pattern does. * feat(plugin-page-builder): component definitions reach the editor, and a placed one renders The second half of placing a component. The builder could offer a component and write an instance node for it, and the canvas then drew the could-not-be-loaded marker: the renderer resolves an instance against a map of definitions, and the editor handed it none — every definition read lived on the served-page path. A component placed in the builder rendered on the site and nowhere an author could see while editing. The library route now carries the site's component definitions beside its patterns, and the document comes from a BY-ID read rather than the listing. That is the point, not a detail: the working-draft overlay lives in the service's by-id path and nowhere else, so a definition built from the listing would show an author a stale component beside the draft they just saved — a defect this repository has fixed once already on the served-page path. The plugin-facing collection service forwards no draft flag on its by-id read, so the route binds the Direct API's `findByID({ draft: true })` as the user: access rules decide what the caller may read, and the overlay is gated on the caller being allowed to edit, so a reader who may only read sees the live one. Two readers with different timing share the one route. The insert panel reads the whole library only while it is open, as before. The canvas needs definitions on every editor mount, and the pattern tier can run to the whole byte ceiling, so a `tier=components` query narrows the read the editor makes on mount. The route's paging walk and its admission under the ceiling are one implementation for both tiers, each handing in only how a listed row becomes an item; the two tiers spend one ceiling, patterns first. The editor reads the tier once and hands the same response to both consumers: the canvas gets a map by id, the panel a list — so the tile an author places is built from the very document the canvas then resolves the instance against. * fix(plugin-page-builder): definitions are read as the user, on their own route, at every state The component tier moves from a `?tier=` query on the pattern library to `GET …/library/components`, gated by the components collection's read permission and answering the canonical `{ items, meta }` envelope. One walk and one admission still serve both routes; a tier is a completion function and a permission, not a copy of the loop. Both component reads now say `overrideAccess: false`. `requireNextly()` defaults every call to `true`, under which naming a `user` narrows nothing: the by-id read overlaid the working draft without the update-capability probe and skipped every field-level read rule, so an author who could only read a component was handed its pending draft. The listing asks for `status: "all"` — the plugin-facing service forwards no lifecycle scope, and an untrusted read that states none is bounded to public states, so a never-published component could not be placed on a draft page. An API key's own scope travels on both reads. `LibraryComponent` extends `SavedComponent`, and the client hands the rows to the panel as received; a type test pins that the wire carries every key the panel reads. `pageRenderInputs` demands `definitions`, and the entry form's resting render reads the component library too, so a placed component stays drawn in the miniature after Done rather than becoming the could-not-be-loaded marker. The panel resolves each definition through the canvas's own lookup before offering it: a root that is itself an instance is judged by what it draws, and one the resolver had to leave standing is withheld. And the panel says, in a status region above the tiles, when the library was too large to load whole — naming the tier, and for components what a left-out one looks like on the page. * feat(builder): a selected component instance opens its own inspector, over its exposed properties An instance's type is not in the block registry, so the block inspector answered "select a block" to an author who had just clicked a component. `inspectInstance` now describes the selection — the library's title and usage for the definition, the node's own name and lock, and one row per exposed property read back from the resolver's own result, so a row shows what the page shows: the value in force, which layer supplied it, whether it was cleared, and which exposure shadows it when one does. Values stored for properties the definition no longer exposes are carried separately, and only those this instance holds. `InstanceInspectorPanel` draws that in place of the tabs: text and choice rows edit in place, an emptied text field clears the property with the `$unset` sentinel rather than writing `""`, every override carries a visible Reset that removes it (and drops the record with its last entry, never writing an empty one), rows of a type with no control yet show their value and say so, and the orphaned values are listed with a Discard. `overridesPatch` writes the whole props object so `componentId` survives every edit. The name and lock fields move to `identity-fields.tsx` so both inspectors draw one control, and every text field's draft follows the stored value through one `useStoredDraft`. `InspectorPanel` takes the host's component library as ONE prop — the lookup the canvas resolves against and the rows that name the definitions — and the editor hands it the same read the canvas gets. * fix(plugin-page-builder): the component read overlaps, reaches drafts, and says when it failed Every component's completion was a database round trip awaited one after another — three thousand in a row at the library's ceiling. The walk now completes rows in batches of eight, admitted in listing order so the ceiling cuts the same rows it always did, and starts no batch once it has. The by-id read asks for `status: "all"` as the listing does: an untrusted read that states nothing is bounded to public states, so the never-published row the listing found answered 404 and the component stayed unplaceable until published. The Direct API's `findByID` did not forward `status` at all — a caller passing it was silently ignored — so it does now, with `FindByIDArgs.status` declared and pinned by a test. Every field of an item now comes from the by-id row, the one with the working draft overlaid: a draft can rename a component or move it to another category, and an item labelled from the live listing was named by one version and drawn from another. The identity the reads run as is built by `buildUserContext`, now exported from `nextly/runtime` — the one builder of the user an access rule is evaluated against — so the caller's verified claims travel and the canonical identity wins. Roles are not on the route context; the service resolves them from the account. The component read's `pending`, `error` and `refetch` no longer vanish behind an empty map. The canvas and the resting miniature wait for the read and say when it failed, with a Try again; the insert panel tells a tier that was cut from one that could not be read, and offers the retry. The palette resolves a definition under the site's own document caps, handed through `documentLimits`, and withholds one whose root resolves to an empty forest. * fix(plugin-page-builder): a failed component read no longer takes the canvas away The component route refuses a role that may edit pages but not read components, and the gate added for a pending read treated that failure the same way — a least-privilege page editor lost the canvas for every page, block-only pages included, and the resting card refused to preview them. Only a PENDING component read gates now; a failed one draws the page with its instances as could-not-be-loaded and says so beside the canvas and beneath the miniature, with the retry. A component's own content field no longer offers the definition it is editing: placed, that instance points at the definition it sits in, and the resolver draws the loop as a cycle after the save. `withoutSelf` judges it on the opening document being a component and the form naming the row — a page's field is never inside a component, and a create form names no row. Placing a component now asks the resolver whether the PAGE has room for it, with the node in place: the apply counts stored nodes and an instance is one, but the composed tree spends one budget across every expansion, so a page near its cap accepted the node and rendered it unresolved. `compositionRefusal` composes the candidate document under the site's caps and turns a `budget` or depth reason into a sentence the panel raises as a notice, leaving reasons that are the tile's concern alone. * fix(builder): the style trace composes instances through the definitions the canvas draws with The builder's trace wrapper forwarded the site tier, the fetch policy, the resolver and the caps to the renderer's trace, and not the definitions — so on a page holding instances the cascade the inspector reads its provenance from described a tree with every instance left unresolved, while the canvas drew the composed one. The wrapper takes the map and the editor hands it the canvas's own. * fix(builder): a choice exposure with an empty option no longer crashes the inspector The validator accepts an empty string as an option value — a "none" beside class names — and the select control throws at render on an item whose value is the empty string, which it reserves for no selection. The empty value wears a NUL sentinel in the control and is taken off again on the way back to the document, so the option is offered and written as empty. * fix(builder): judge a component's room under the site's limits, for every instance left standing The insert preflight composed the candidate page the way the canvas does, but dry-ran the insert itself under the engine's default limits, so a page legal only under a raised cap threw out of the click before the resolver ran; and it refused only when the PLACED instance was left unresolved, so a nested instance the page had no room for, or an instance already on the page that the new one took the budget from, was placed and drew a placeholder. The dry run now takes the same limits the composition does, a refusal the apply itself makes is left to the editor's apply, and the refusal reads every room-reason instance the placement introduces — compared against the page composed without it, since an instance the page could not hold before the click is not the click's doing. * fix(builder): say when a library tier could not be reloaded, apart from one never read A host that reads its library afresh on every open keeps the last answer while it does, so a failed read can leave tiles standing — and beside them "none are offered" was false. A stale tier gets its own sentence: the tiles offered may be out of date, and a component on the page draws as it was. * fix(plugin-page-builder): keep the definitions map still across renders, and name a failed refresh The component read memoised its map and list on the route hook's refetch wrapper too, which the hook mints per render, so the canvas was handed a new map — and re-resolved every instance — on every render, keystrokes included. The map and the list are now derived from the data alone, and the retry keeps one identity for the life of the hook. And a read that answered once and then failed to answer again was called ready: the cached definitions drew with no sentence and no retry. Both library reads now tell that state apart as `stale` — the page still draws, from an answer an edit elsewhere may have overtaken — and the canvas note, the entry card and the insert panel say so with the retry, rather than the never-read sentences, which are false beside tiles that stand. * fix(plugin-page-builder): leave out every component that reaches the one being edited A component's content field left out only the row it was editing, so a component holding an instance of that row — or one holding that — was still offered, and placing it closed a loop nothing sees until the row is saved. Every candidate is now resolved through the canvas's own lookup, and one that reaches the row being edited at any depth is left out. * fix(plugin-page-builder): count the classes linked components render on the page The classes manager's on-this-page filter walked the stored document, where a linked component is one instance node, so a class applied inside the definition the canvas draws was missing from the filter. The walk now composes through the same map the canvas draws with, under the same limits; the usage record still asks what the document itself references. * fix(plugin-page-builder): list components in pages of one completion batch Every listed component is read again by id, so the listing's content is never used — and the service reads whole rows however a caller narrows the answer. A page of a hundred rows held while its batches completed was a hundred documents of content in memory at once. The component listing now pages by one completion batch, and the bound on reads is derived per tier from its page size so the tier still reaches the item ceiling. * fix(builder): offer the reset on the row that holds the override, not on the row it shadows Two exposures aimed at one target share a value, and the resolver reports the winning exposure's source on both — so a reset keyed on the source stood on the shadowed row too, and pressing it removed nothing. Each row now carries whether THIS instance's record holds a value under its id, and the reset is offered on that. * fix(builder): judge a moved component instance by the roots of the definition it draws A pointer move judged the dragged node by its type, and an instance's type is not a registered block, so the nesting rule answered "no restriction": a component whose root belongs only inside a Columns, refused at the insert, could be dragged into a paragraph afterwards. The drop query now carries the types the placement is judged by — a block's own, or an instance's resolved roots through the canvas's lookup under its caps — and the editor hands the drag the same map and caps the canvas draws with. * fix(builder): read an instance's definition under the resolver's own rule, published from the engine The inspector judged a supplied definition by its kind alone, where the resolver also refuses a format this build does not read and a list of nodes that is not one. A definition the canvas left standing as a placeholder therefore drew editable rows, and malformed nodes reached the exposure. The engine now publishes `readableDefinition`, the rule the resolver applies before inlining, and the inspector reads through it. * feat(builder): edit an instance's exposed link and visibility in the inspector Two more of the six exposed types get a control. A link exposure holds what its url prop holds, a string, and is edited in the same field a url prop gets in the block inspector, keyed for an address and clearing the property when emptied. A visibility exposure is a checkbox writing `true` or `false` — the two values the resolver reads there — with an inherited row reading as shown, the component's own rule until the definition gates the node itself. Rich text and image rows keep showing their value and source without a control. * fix(plugin-page-builder): read components from the store the plugin was told they live in A host may keep its definitions in a collection of its own and render from it (`createBlocksPage`'s `componentCollection` and `componentField`), and the plugin already lets the readiness notice follow that store through `componentReadiness.collection` and `.field`. The editor's library route read the plugin's own store regardless, so it drew a different definition for the same id than the page did, or none. One derivation of the store now feeds the route, its permission gate and the notice; a host whose definitions come from no collection at all still gets the plugin's store, which is the one thing a collection read can follow, and the option says so. * fix(plugin-page-builder): build the editor under the site's caps, as the preflight and canvas are The editor was built with no limits and so judged every apply under the engine's defaults, while the insert's preflight and the canvas judged under the site's. On a site that raised its cap, a placement the preflight and the canvas accepted was refused silently by the apply. The caps are read once, before the editor is built, and handed to it. * fix(builder): read a tile's roots without composing its definition The palette judged where a component may be placed by composing the whole definition through the canvas's lookup and reading the roots of the result, once per catalogue row, so a library of small wrappers around one large definition cloned that definition once per wrapper every time the panel opened — three thousand one-node wrappers around a five-thousand-node definition was fifteen million nodes. The engine now publishes `composedRootTypes`, which follows a root instance into the definition it names through the resolver's own reader and its own four refusals, reading each definition once and cloning nothing; the tile, the moved-instance rule and the component's own field's exclusion all read through it or through the stored graph. Room stays the click's question, asked of the resolver with the page in hand. * fix(builder): preflight a moved instance for room, and judge every edit under the editor's own caps A move applied without asking whether the page still composed with the node there: an instance carried ahead of another takes the budget that one had, and one carried into another's slot content nests a composition deeper, so the drop left a placeholder where a component stood. The preflight now takes an op rather than a placed node, the drop asks it for a move and says why when it refuses, and the caps it asks under are the editor's own — exposed on the editor state, since they are the ones its apply enforces — which the insert panel now reads too in place of a second reading handed in as a prop. * feat(nextly,plugin-sdk): a plugin route resolves the caller's identity once, roles included `PluginRouteCaller.identity()` resolves `readCaller(auth)` once per request — the user context with the roles a stored read rule reads, its claims, and an API key's scope — and `can()` reads through the same memo. The SDK publishes `PluginRouteIdentity` as its answer. A route that built its Direct API user from `ctx.user` carried no roles, so a role-based read rule on the collection it read refused the very caller the route's own gate had admitted, and the caller saw an empty library with nothing to say why. The `nextly/runtime` export of `buildUserContext` added for that route is gone again: the identity is the caller's to give. * fix(nextly): findByID on users offers no status argument `FindUserByIDArgs` omits `status` beside `collection` and `draft`. The users read has no lifecycle scope to narrow by, and an argument the call accepts and ignores reads as one it honours. Pinned by a type test the checker evaluates (`users.test-d.ts`, under `tsconfig.tests.json`), with `depth` as the control that still is offered. * fix(blocks-engine,builder): the tile offers only a definition the resolver would read The engine publishes `readableDefinition`, the resolver's own rule for a supplied definition — a plain record whose nodes are a list, of kind `component`, in this build's format — and `offerableDefinition` reads through it, so the tile and the canvas read one rule. Judged by kind alone, a definition in a format this build does not read was offered, placed and drawn as a placeholder; one whose nodes were not a list crashed the catalogue on `.length`. * fix(plugin-page-builder): the style trace compiles under the canvas's caps `pageStyleTrace` takes `canvasRender.limits` beside the canvas's definitions, so the cascade the inspector reads describes the composed tree on screen — neither one with every instance left unresolved, nor one composed past a cap the canvas stopped at. Handed the map alone, the trace defaulted to the engine's caps and reported declarations for nodes the canvas drew as a placeholder. * fix(plugin-page-builder): the component tier reads as the caller, failing on all but a missing row `directComponentReads` reads through `caller.identity()` on both reads — the user context with the roles a stored read rule reads, the verified claims, and an API key's own scope — so a role-based read rule sees the roles the route's gate resolved rather than an identity built from the account alone, which carried none and refused the caller the gate admitted. The by-id read no longer asks for `disableErrors`. It catches only not-found and forbidden — a row gone between the two reads, or one THIS caller may not read, which the walk reports as a cut library — and lets anything else fail the route, which the client reports as unavailable with a retry. Swallowed, a database fault read as a static ceiling with every instance on the page drawn as missing and nothing to retry. * fix(plugin-page-builder): a library row without a title is labelled by its id rather than dropped A page's renderer keeps a component by its id and its document field; a custom collection with no title field, or one whose title is redacted by field-level access, renders every instance on the public page. The editor dropped such rows — omitted from the component tier, counted as a cut — and drew a placeholder where the page draws the component. `identityOf` requires an id alone; a title only labels the row, and one without a readable title is labelled by its id, the one name a row is sure to have. One reader for both tiers, as before. * fix(builder,plugin-page-builder): room is judged at the editor's apply; the host owns the notices `useEditorState({ definitions, onRefused })` asks, after a fresh group applies (`apply` and `applyAll` alike, never an undo or a redo), whether the page still composes with the edit in — `compositionRefusal(before, after, definitions, limits)` compares the resolver's unresolved sets by instance id — and refuses with the sentence, committing nothing. The insert panel's and the drag's own preflights are removed: every surface reaches the page through this apply, so a duplicate, a paste, a pattern and a keyboard move are judged exactly as a drop is, and no surface can forget to ask. `BuilderShell` takes the host's `NoticeQueue`, and `useNoticeQueue`, `NoticeQueue` and `RaiseNotice` are published from the shell entry. The shell provides its sink only to what it renders, and the editor is built above it, so a raise made there reached nothing in production; the host builds the queue, hands it to the shell to draw, and raises into it. * fix(builder,plugin-page-builder): a keyboard move judges an instance by the roots it draws `nestingRefusalForMove` asks `placementVerdict(placementTypesOf(moving, definitions), …)` — the function and the types a drop is judged by — where it asked `blockAllowedAt(moving.type, …)`. An instance node's own type is not a registered block, so the nesting rule restricted it nowhere, and Alt+Arrow, the toolbar and the command palette could lift a component whose root belongs only inside a container up to the root, where the same instance's drop is refused. `BlockKeyboardActions` takes the definitions the canvas draws with, as it takes `nesting`, and the page-builder field passes its map. Without one an instance is judged by its own type, which is how a placeholder stays movable. * fix(plugin-page-builder): what a candidate reaches is read under the site's cap, failing closed `withoutSelf` leaves out of a component's own field every component whose stored graph reaches the one being edited. The walk read each definition with `componentIdsIn` — the engine's default node cap, and no word on whether the read was whole — so under a site cap other than the default, or a definition longer than the cap, it answered a prefix, and a candidate whose reference sat past it was cleared and could close a loop once saved. Every read in the walk now goes through `componentUsageIn` under the SITE's `maxNodes`, and a read the cap ends early counts as reaching: the candidate is left out. That refuses no legitimate offer — a definition the cap cannot read whole is one the resolver cannot inline under that cap either — and it is the direction to err in, because the other is a saved loop every page then draws as a placeholder. * fix(blocks-engine,builder): composed root types are distinct and each definition is walked once `composedRootTypes` walked a definition once per instance pointing at it and concatenated the answers, so a definition of two hundred instances of another holding two hundred more answered forty thousand roots, and walked the leaf as many times — on every insert-panel open, for every component in the library. It now answers the DISTINCT types at the composed roots, in the order first met, which is all `placementVerdict` reads, so the tile and the drop are judged as before and the answer is bounded by the registry rather than the forest. And it remembers each definition's answer for the query, reused at the same depth or a shallower one only: the composition cap refuses by depth, so what fit under an instance at depth one can be refused under one at depth four. A refusal is never remembered — a cycle or the cap is a property of where an instance sits — and the resolver's own refusals are still asked first, per instance. * fix(plugin-page-builder): the component tier is read in the language the document is edited in A component's document field can be localized, and the public renderer reads definitions in the page's locale; the editor's read named none, so an author editing German drew a canvas of English components, and the resting miniature too, from one locale-independent cache entry. `useComponentLibrary` reads the surrounding form's `useDocumentLocale()` and asks for `componentLibraryPath(code)`: `?locale=` for a non-default language, the bare path for the default or where the language cannot be known — how an absent `?locale=` reads everywhere in the admin. The path is the read hook's cache key, so a switch of language is a different read. The route reads the parameter, spelled once in the shared contract, into BOTH Direct API reads: a listing in one language completed by rows in another would label one version and draw the other. An empty value is none, and an unknown code resolves to the default in the Direct API. The changeset now describes the caller's resolved identity, the title rule and the locale, and no longer the `nextly/runtime` export this branch had added and then removed. * refactor(builder): the editor's room check is its own function, and its undo case can fail The room question moves out of `run` into `refusedForRoom`, which asks only when definitions were given and tells the host why when it refuses; `run` keeps one condition, a fresh edit. One branch fewer took `run` back under the complexity bound the audit holds this package to. Re-breaking it showed the undo-and-redo case could not fail: the library never changed between an edit and its replay, so a replay asked the room question would have been allowed anyway. The library now grows between them — a definition of three nodes becomes ten, which no longer fits — and a fresh insert under the grown library is the control that refuses. Asking on undo, on redo, or on both now fails the case. * refactor(blocks-engine): a definition's remembered root types are read by their own function `rootTypesOfNode` keeps the resolver's refusals — a malformed or gated instance, a missing or unreadable definition, a cycle, the cap — and hands the rest to `definitionRootTypes`, which serves an answer already given at the same depth or a deeper one, or walks the definition and remembers it. One branch fewer took the function back under the complexity bound the audit holds this package to; the four ways the memo can be wrong — never remembering, remembering across depths, answering before the refusals, and answering the forest's width — each still fail a named test. * fix(builder): the instance inspector reads a malformed instance and a definition's options safely Every read of an instance's props goes through one guard, as the resolver reads them: a stored instance whose `props` is missing or not a record — an import, a hook, a hand edit — is drawn on the canvas as a malformed placeholder, and selecting it threw on `node.props.componentId` where it now reads as naming no component. The override patch spreads the same guarded record. A row's `options` and `label` are normalised where they are consumed. The resolver checks the fields its own writer reads — id, node, path, type — so a stored or hook-shaped definition could hand the panel options that were not a list (`row.options.map` threw on selection), entries without a string value, a value offered twice (one choice drawn twice under one key), or a label that is an object. Options keep string values and labels, each value once; a label that is not a string reads as the property's id. * fix(builder): the instance panel spells each option apart and says what a structured value holds Every option value wears a one-character prefix in the select control, where only `""` wore a NUL sentinel. The control cannot take `""` — the primitive reserves it for "no selection" — but a definition's option is free text, so a sentinel for one value collides with the option that IS the sentinel: the two items shared one value, the control drew the later of them for either, and choosing one while the other was held changed nothing. A prefix on every value is injective and never empty. A read-only row and an orphaned override now summarise a structured value instead of reading it through the text field's reader, which turned a value that is there into the same nothing as one that is not: rich text as its words through the engine's reader, a list by its count, other data as its JSON, a cleared override as "(cleared)". * fix(builder): a rename that differs only in spaces hands the field back the stored name The name field compared a trimmed draft with the stored name and, when they matched, returned — leaving the field showing a draft with spaces the document does not hold. It now trims once, compares and stores the same value, and hands the field the stored name back when nothing changes. The field is the one both inspectors draw, so the block inspector's name behaves the same; the comparison moved here from it unchanged. * refactor(builder): the instance inspector's small text uses the type-scale token Three font sizes in the instance inspector's rules were written as `0.75rem` where the rest of the stylesheet names `var(--text-xs)`, the theme's step of exactly that size — so the rendering is unchanged and the rules follow the scale if it moves. * fix(builder): a component is offered only when the canvas holds its definition, judged by that copy `componentEntriesFrom` judged and previewed a tile from the row's own document, while the placed instance is resolved by the canvas against its lookup. A host of the published `InsertPanel` that passed `components` without `componentDefinitions` was offered tiles whose every placement landed as a missing component; and a row whose copy differed from the lookup's was judged by roots the canvas does not draw. The offer now reads the lookup's definition for the row — the drag and the keyboard already judge a placed instance through the lookup — and a row the lookup does not hold is not offered. The row still supplies the label, category and usage. * fix(plugin-page-builder): a component's own field fails closed on a reference a cut library omits The walk that keeps a component's own field from offering anything that reaches it treated an id the canvas's lookup does not hold as a dead end. From a library the ceiling or a permission cut, that id may be a component the store holds and the read left out — and it may name the definition being edited, so the candidate stayed offerable and a placement closed the loop once saved. The field now passes whether its read was whole; out of a cut library such an id counts as reaching and the candidate is left out, while out of a whole one it is a component nobody supplied and names nothing. `withoutSelf` takes the graph as one value (`ComponentGraph`: the lookup, the site's caps, and whether the read was whole), and each walk starts from the lookup's copy of the candidate, which is what the canvas draws. * fix(blocks-engine): a root the inliner would drop stands for no type `cloneDefinitionForest` skips a node it cannot clone — one that is not a record, or whose id is not a string — so such a root never lands on the page. `composedRootTypes` counted its type anyway, so a component was offered, and refused, by a root the page never gets; one whose every root is dropped was offered as placing something and placed nothing. The query now passes over exactly what the inliner drops, and still answers nothing for a root it keeps but cannot name. * fix(builder): a pattern's roots are resolved through the lookup, as a component's are A pattern is copied into the page as it stands, and one of its roots may be a component instance — saving a placed component as a pattern stores exactly that node. `rootsAllowedAt` judged the root's own type, which is not a registered block and which the nesting source therefore restricts nowhere, so a pattern wrapping a component whose root belongs only inside a Columns was offered at the page root while the component's own tile was refused there. Each root is now read through `placementTypesOf`, and the panel hands its lookup to `allowedEntries`. * fix(plugin-page-builder): a completed component keeps the id the listing named The by-id read returns a PRESENTATION of the row — an `afterRead` hook may rewrite or drop its `id` — while stored instances reference the id the collection holds, which is the one the listing named. Completed from the presentation, the client keyed its definitions by a name no instance uses, or dropped the component, and every instance of it drew as missing. The completion carries the listed id and takes the title, category, description and content from the by-id row; a row with no readable title is still labelled by that id, and a read that answered nothing is still a cut tier. * fix(builder): every inspector control takes an id of its own, minted per mount An exposed row's control and the block name and lock fields carried fixed ids. A page can hold two blocks fields, each with its own editor, and both labels then resolved to the FIRST editor's control: it carried two labels and a doubled accessible name, while the second editor's input had none and its label focused the other editor's field. Each control mints its id with `useId`, so a label points at the control beside it. The block inspector's own assertion no longer pins the literal id; it tells the identity field from an attribute's by the placeholder only it has. * fix(plugin-page-builder): a pattern that reaches the edited component is not offered Saving a selection as a pattern keeps the instance nodes it held, so a pattern is a second way to copy a component into itself. The component tier was filtered and the pattern tier was forwarded whole, and the two tiers are different rows out of different collections. Both are now judged by one graph and one rule: which components a candidate places, followed through the canvas's own lookup until the edited row is met. A pattern nobody could read whole under the site's node cap is left out rather than offered on an answer nobody has. * fix(blocks-engine): a pattern's instances are judged by what they draw `nextly/component-instance` is not a registered block, so every rule spelled over node types answered wrong for it in both directions. A parent rule found no restriction on the instance type and admitted a component whose root belongs only inside a Columns. A slot naming what it admits did not name the instance type, so every component was barred from every slot that lists its contents, including the ones drawing exactly what the slot asks for — the tile was offered and the click then planned nothing. The rule the palette already used for a node's drawn types now lives in the engine, beside the roots query it is built on, and the builder re-exports it. The two pattern rules — the destination's placement and the forest's own internal nesting — resolve through it whenever the caller holds a lookup, and the panel hands the same lookup to the catalogue and to the planner. Callers holding no library get the answer they got before. * fix(blocks-engine): a nested instance's own overrides reach its definition's roots `composedRootTypes` followed a root instance into its definition without asking what that instance decided about the definition's nodes. A `visibility` exposure overridden to false removes the node and its subtree from the composed forest — `cloneDefinitionNode` returns nothing at all for it — so the query reported a type the page never receives, and a component whose only root is hidden was offered as placing something and placed nothing. Overridden to true it deletes the node's own gate, so a gated root the author turned on is expanded rather than left standing. The decision comes from the resolver's own planner rather than a second reading of `props.overrides`, so the chosen variant's presets are folded in under the instance's answers exactly as the render folds them. A definition's remembered roots are a property of the definition, so an instance that decides neither reads that memo nor writes to it. * fix(plugin-page-builder): a completed component must be the row the listing named, read whole Two ways a by-id read answers with something that is not the component it was asked for, both of which the completion took at face value. A `beforeOperation` hook can redirect the read, so the record answering for one component may be another's. Keyed by the listing's id, that row's draft was served under the other component's name: the canvas drew one component's content wherever the other was placed, with nothing anywhere saying so. A row naming a different id is now left out and the tier reported cut. A row carrying NO id is still keyed by the listing — field-level access drops it in presentation, and that case is why the id comes from the listing at all. And an absent document field was flattened into the same `null` as a stored-empty one. The field layer writes an unset non-required field as SQL NULL and reads it back with the key present, so `null` is an answer and a legal row; no key at all means this caller did not read the row whole. Told apart, the second marks the tier cut instead of reporting a complete library the client has no definition for. * fix(plugin-page-builder): the library is paged from a row, not from an offset Both tiers walked their collection as independent offset queries against a collection other authors are editing. A row inserted or deleted before the next offset shifts every row after it, so one comes back twice and another never at all — and a row never listed is never completed, so `omitted` stays false and the response reports a whole library while the canvas has no definition for it and draws every instance as missing. The position is now the greatest id the last page named, and the next page is the rows after it: ordered by id, that means the same thing however many rows before it moved. What is left of the offset counts pages since the position last MOVED, for the one page whose every row arrives without a readable id — it names no position, and stepping past it is the only way to reach what is behind it. A page that names an id resets it, so an ordinary walk never carries an offset a mutation can move. * fix(blocks-engine): the roots query survives a node whose own fields throw The resolver composes a definition carrying a throwing property accessor without raising; this query read the same fields with no boundary. Its one caller builds the insert panel's catalogue, so a single such definition took the whole panel down over a component the canvas draws. The boundary is at the published entry point rather than per definition, because the document's own roots are read by the same walk. Caught rather than asked first, which is this module's usual answer to a stored shape: reading the property IS the raise, so there is no question to ask before it. The answer is `undefined` — the one already defined for a forest this cannot judge, and one its callers already withhold the tile for. * refactor: name the two questions each of these rules was asking `withDraftDocument` asked three: is this the row that was asked for, was it read whole, and what is it called. The first and third are now their own — and the third was spelled twice, once here and once on the listing row, so an unnamed component could have come to be called two things. `rootTypesOfNode` asked what a root stands for and, for an instance, what its definition draws. The second is its own function, reached once the first has decided it is an instance at all. Both were over fallow's CRAP bound, whose coverage is estimated and so cannot be tested down; both are under it by branch count now. * perf(plugin-page-builder): the resting card reads the component tier only when the page places one The miniature every entry form draws at rest read the whole component tier on mount, whatever the page held. That read is a listing over every row plus a read of its own per row to reach the working draft, bounded at sixteen mebibytes — so opening an entry whose page places no component paid for a library it resolves nothing against and draws identically without. The question is asked of the document, under the SITE's node cap, and an unread prefix counts as placing one: a document past the cap answers `complete: false`, and "names nothing" is what an unread document looks like too, so the read is still made rather than the largest pages on a site drawing as could-not-be-loaded markers. A disabled read reports `ready` rather than `pending`. A query that never runs never stops pending, and the card would have waited on an answer nobody asked for. * fix(blocks-engine): a visibility exposure reads the definition's own gate With no override written, a `visibility` exposure reported nothing in force — and something was. A definition node carrying entry-field conditions is not served: `survivesGating` with no plan says so, and the composed forest keeps the gate for the renderer's hidden-node pass to act on. Read as a blank, the inspector drew the row checked and told the author the node was shown on this page while the canvas withheld it. The row now carries the definition's own decision, with `source: "definition"` and no own override, so a surface can tell an inherited gate from an author's own hiding without a vocabulary of its own. A node with no gate still reads as nothing in force, because nothing is. * test(builder): the visibility checkbox reflects a gate the definition itself carries The rule is the engine's — a `visibility` exposure with no override now reads the definition's own decision — and this is the surface it was wrong on: the box drew checked and said the node was shown on this page while the renderer's hidden-node pass withheld it. Asserted here as well as in the engine because the panel is where an author reads it, and because the click that turns it on writes the one value that removes the component's gate for this instance. * fix(plugin-page-builder): gate a configured component store by the slug it reads Two rules about a store the plugin was told about rather than owns. The route resolved its permission through the scope helper, which follows the host's rename of a collection the PLUGIN declared. The handler reads the configured slug literally, so the two named different collections exactly when a host renamed the contributed `components` collection and pointed the store at another one called `components`: a caller holding read on the collection being read was refused, and one holding read on the renamed collection was let into a collection that permission says nothing about. The default store still resolves through the helper, which is what a rename needs. And a completed component now carries its `keywords`. `LibraryComponent` derives from `SavedComponent`, and the catalogue builds a component tile's search terms from that field exactly as it does a pattern's — through the same reader here, so an absent field stays absent and a stored NULL arrives as `null`, which is the shape the palette expects. * fix(plugin-page-builder): a pattern that reaches the edited component is not offered Saving a selection as a pattern keeps the instance nodes it held, so a pattern is a second way to copy a component into itself. The component tier was filtered and the pattern tier was forwarded whole, and the two tiers are different rows out of different collections. Both are now judged by one graph and one rule: which components a candidate places, followed through the canvas's own lookup until the edited row is met. A pattern nobody could read whole under the site's node cap is left out rather than offered on an answer nobody has. * fix(blocks-engine): a pattern's instances are judged by what they draw `nextly/component-instance` is not a registered block, so every rule spelled over node types answered wrong for it in both directions. A parent rule found no restriction on the instance type and admitted a component whose root belongs only inside a Columns. A slot naming what it admits did not name the instance type, so every component was barred from every slot that lists its contents, including the ones drawing exactly what the slot asks for — the tile was offered and the click then planned nothing. The rule the palette already used for a node's drawn types now lives in the engine, beside the roots query it is built on, and the builder re-exports it. The two pattern rules — the destination's placement and the forest's own internal nesting — resolve through it whenever the caller holds a lookup, and the panel hands the same lookup to the catalogue and to the planner. Callers holding no library get the answer they got before. * fix(blocks-engine): a nested instance's own overrides reach its definition's roots `composedRootTypes` followed a root instance into its definition without asking what that instance decided about the definition's nodes. A `visibility` exposure overridden to false removes the node and its subtree from the composed forest — `cloneDefinitionNode` returns nothing at all for it — so the query reported a type the page never receives, and a component whose only root is hidden was offered as placing something and placed nothing. Overridden to true it deletes the node's own gate, so a gated root the author turned on is expanded rather than left standing. The decision comes from the resolver's own planner rather than a second reading of `props.overrides`, so the chosen variant's presets are folded in under the instance's answers exactly as the render folds them. A definition's remembered roots are a property of the definition, so an instance that decides neither reads that memo nor writes to it. * fix(plugin-page-builder): a completed component must be the row the listing named, read whole Two ways a by-id read answers with something that is not the component it was asked for, both of which the completion took at face value. A `beforeOperation` hook can redirect the read, so the record answering for one component may be another's. Keyed by the listing's id, that row's draft was served under the other component's name: the canvas drew one component's content wherever the other was placed, with nothing anywhere saying so. A row naming a different id is now left out and the tier reported cut. A row carrying NO id is still keyed by the listing — field-level access drops it in presentation, and that case is why the id comes from the listing at all. And an absent document field was flattened into the same `null` as a stored-empty one. The field layer writes an unset non-required field as SQL NULL and reads it back with the key present, so `null` is an answer and a legal row; no key at all means this caller did not read the row whole. Told apart, the second marks the tier cut instead of reporting a complete library the client has no definition for. * fix(plugin-page-builder): the library is paged from a row, not from an offset Both tiers walked their collection as independent offset queries against a collection other authors are editing. A row inserted or deleted before the next offset shifts every row after it, so one comes back twice and another never at all — and a row never listed is never completed, so `omitted` stays false and the response reports a whole library while the canvas has no definition for it and draws every instance as missing. The position is now the greatest id the last page named, and the next page is the rows after it: ordered by id, that means the same thing however many rows before it moved. What is left of the offset counts pages since the position last MOVED, for the one page whose every row arrives without a readable id — it names no position, and stepping past it is the only way to reach what is behind it. A page that names an id resets it, so an ordinary walk never carries an offset a mutation can move. * fix(blocks-engine): the roots query survives a node whose own fields throw The resolver composes a definition carrying a throwing property accessor without raising; this query read the same fields with no boundary. Its one caller builds the insert panel's catalogue, so a single such definition took the whole panel down over a component the canvas draws. The boundary is at the published entry point rather than per definition, because the document's own roots are read by the same walk. Caught rather than asked first, which is this module's usual answer to a stored shape: reading the property IS the raise, so there is no question to ask before it. The answer is `undefined` — the one already defined for a forest this cannot judge, and one its callers already withhold the tile for. * refactor: name the two questions each of these rules was asking `withDraftDocument` asked three: is this the row that was asked for, was it read whole, and what is it called. The first and third are now their own — and the third was spelled twice, once here and once on the listing row, so an unnamed component could have come to be called two things. `rootTypesOfNode` asked what a root stands for and, for an instance, what its definition draws. The second is its own function, reached once the first has decided it is an instance at all. Both were over fallow's CRAP bound, whose coverage is estimated and so cannot be tested down; both are under it by branch count now. * perf(plugin-page-builder): the resting card reads the component tier only when the page places one The miniature every entry form draws at rest read the whole component tier on mount, whatever the page held. That read is a listing over every row plus a read of its own per row to reach the working draft, bounded at sixteen mebibytes — so opening an entry whose page places no component paid for a library it resolves nothing against and draws identically without. The question is asked of the document, under the SITE's node cap, and an unread prefix counts as placing one: a document past the cap answers `complete: false`, and "names nothing" is what an unread document looks like too, so the read is still made rather than the largest pages on a site drawing as could-not-be-loaded markers. A disabled read reports `ready` rather than `pending`. A query that never runs never stops pending, and the card would have waited on an answer nobody asked for. * fix(blocks-engine): a visibility exposure reads the definition's own gate With no override written, a `visibility` exposure reported nothing in force — and something was. A definition node carrying entry-field conditions is not served: `survivesGating` with no plan says so, and the composed forest keeps the gate for the renderer's hidden-node pass to act on. Read as a blank, the inspector drew the row checked and told the author the node was shown on this page while the canvas withheld it. The row now carries the definition's own decision, with `source: "definition"` and no own override, so a surface can tell an inherited gate from an author's own hiding without a vocabulary of its own. A node with no gate still reads as nothing in force, because nothing is. * fix(plugin-page-builder): gate a configured component store by the slug it reads Two rules about a store the plugin was told about rather than owns. The route resolved its permission through the scope helper, which follows the host's rename of a collection the PLUGIN declared. The handler reads the configured slug literally, so the two named different collections exactly when a host renamed the contributed `components` collection and pointed the store at another one called `components`: a caller holding read on the collection being read was refused, and one holding read on the renamed collection was let into a collection that permission says nothing about. The default store still resolves through the helper, which is what a rename needs. And a completed component now carries its `keywords`. `LibraryComponent` derives from `SavedComponent`, and the catalogue builds a component tile's search terms from that field exactly as it does a pattern's — through the same reader here, so an absent field stays absent and a stored NULL arrives as `null`, which is the shape the palette expects. * chore: a changeset for the component library's review round The round's own entry, since the PR it was raised against merged while this was being written and its changeset went with it. * fix(blocks-engine): a malformed slot entry refuses a placement, it does not raise A stored forest reaches the internal-nesting walk unvalidated — its own comment says a slot may hold anything — and asking what a node DRAWS reads one field further than asking its type did: an entry typed as an instance but carrying no `props` had its `componentId` read, where before only its type was. Read defensively now, and an entry with no readable type stands for NO type rather than for one spelled out of whatever was there: `placementVerdict` compares these against a registry, and a string like "undefined" is a name a slot's admissions list could be made to accept. The planners shape-check their forest before they reach this, so it is a boundary rather than the whole defence — what it must not be is a raise where a refusal belongs. * fix(plugin-page-builder,blocks-engine): a by-id row's own id must be the one asked for Four things a reader established about its own instruments rather than about the data. A row with no `id` KEY is the redaction case and is keyed by the listing. A row that HAS the key answered with an identity, and `null`, a number or an empty string is not "no id" — it is an answer, and not this one. Read as absence its content travelled under a name it never claimed. The lookup is now read INSIDE the roots query's exception boundary. It is a caller-supplied object rather than a map the engine builds, so a `get` that raises escaped to exactly the caller the boundary exists for. The listing's cursor is taken from the greatest READABLE id on a page, and the test stub now selects it the same way. Inspecting only the final row made the stub fall back to an offset where the route carries a cursor, so the pagination cases could pass about a walk that does not run — and the route's own selection had no test at all, because the component tier's listing is injected. It is asserted on the pattern tier, where the route's `page()` actually runs. And the two `withoutSelf` suites share one set of fixture builders. Two copies of what a component, an instance and the walk's inputs are is a second definition of the thing under test. * fix(builder): a value the inspector's control cannot hold is shown, not offered for editing `OverrideValue` is unconstrained, so a host block may declare a text or link prop as an object. A text field renders one as a blank — there is no string to show — and the first edit then replaces the whole value with whatever was typed into that blank, losing the rest of it with nothing reporting the loss. Such a row now takes the same note a type this panel cannot edit yet takes: the value is summarised and nothing overwrites it. A select and a visibility row always stay editable. Neither carries the old value into what it writes, so a value they cannot show is repairable by using the control, and hiding it would strand the property instead. * fix(nextly): a Single's publish re-judges the draft it promotes (#1826) * fix(nextly): a Single's publish re-judges the draft it promotes A Single's publish folds its held pending change into the live row. Field access and validation both ran when the caller's payload arrived, and for a publish that payload is just `{ status: "published" }`, so the draft's own content reached the live row having been judged only when it was SAVED. The collection publish path already gates its promotion; this is the same gate on the Singles path, and it covers all three publish routes because it sits in the shared promotion rather than in any one of them. Two things can have changed between saving and publishing. The publisher may not be the author, so a field rule can deny them a value the author was allowed to write. And the schema can have tightened under a value that was legal when it was held. Both gates run on the merged document the promotion will actually persist, and never on the live row: a schema change must not block someone from fixing and republishing content that has nothing to do with it. Validation refuses, naming each field and carrying the rule's own message. Access refuses too, which is a departure from the ordinary write, where a denied field is stripped. Stripping is right there, because the value is the caller's own input and dropping it costs them nothing. A promotion is not that: the value belongs to whoever saved the draft, and a successful publish CONSUMES the draft. Stripping would publish everything else, delete the pending change, and take the one edit this publisher may not write with it, leaving the author no draft, no value and no message. Measured: zero draft rows remained after such a publish. Refusing keeps the draft for someone who can write the field. Judged on what would CHANGE, not on what the snapshot holds. A draft snapshot is a full copy of the document, so a denied field appears in every one of them; only a denied field whose promoted value differs from the live row is a change this publisher is not allowed to make. Researched first. Payload enforces at the publish transition what a draft save skips; Strapi makes Publish its own RBAC action; WordPress re-checks `publish_posts` at the transition rather than reusing the edit check. Two cautions came from their bug trackers and are honoured here: Sanity's #11456 shows what happens when the check leaks onto the live document, and Strapi's #15636 shows a deferred check falling through to a raw database constraint instead of a field-scoped message. * refactor(nextly): one promote gate, outside the transaction, over a logical snapshot Codex found four defects in the first revision, three of them P1, and all four were real. This reworks it rather than patching around them. The gate ran INSIDE the write transaction. Both a field's `access` rule and its `validate` are user code, and resolving the caller's grants issues its own pooled queries, so on a small pool those queries wait for the connection the transaction is holding and the publish hangs. It now runs before the transaction opens, which is where the collection publish path has always run its own, and for this reason. It judged the snapshot as stored. A snapshot holds a JSON-backed value (a group, a repeater, `chips`, `json`) as the string that was written, and the validator for those fields expects the object. Measured: publishing an ordinary Single that merely HAS a group field was refused with "seo must be an object". The snapshot is now read in its logical shape first. My tests used text fields only, which is why this reached review. It covered one publish path. `SinglePublishAllService.promotePendingChanges` loops every language's pending change, writes each and deletes them all, and had no gate of its own, so publishing every language still promoted content forbidden to the publisher or invalid under the current schema. Both paths now call one shared `promote-gate` module: two gates would be two answers to one question, and the second is the one nobody remembers to update. It compared a denied field against the main-table row, which omits every localized value. An unchanged translation therefore compared against `undefined` and read as an edit, so a publisher without write access to that field could be refused a publish they were entitled to make. The comparison now reads the live document for the language the pending change belongs to. * fix(builder,blocks-engine,plugin-page-builder): a component is judged by what it draws (#1824) * fix(plugin-page-builder): a pattern that reaches the edited component is not offered Saving a selection as a pattern keeps the instance nodes it held, so a pattern is a second way to copy a component into itself. The component tier was filtered and the pattern tier was forwarded whole, and the two tiers are different rows out of different collections. Both are now judged by one graph and one rule: which components a candidate places, followed through the canvas's own lookup until the edited row is met. A pattern nobody could read whole under the site's node cap is left out rather than offered on an answer nobody has. * fix(blocks-engine): a pattern's instances are judged by what they draw `nextly/component-instance` is not a registered block, so every rule spelled over node types answered wrong for it in both directions. A parent rule found no restriction on the instance type and admitted a component whose root belongs only inside a Columns. A slot naming what it admits did not name the instance type, so every component was barred from every slot that lists its contents, including the ones drawing exactly what the slot asks for — the tile was offered and the click then planned nothing. The rule the palette already used for a node's drawn types now lives in the engine, beside the roots query it is built on, and the builder re-exports it. The two pattern rules — the destination's placement and the forest's own internal nesting — resolve through it whenever the caller holds a lookup, and the panel hands the same lookup to the catalogue and to the planner. Callers holding no library get the answer they got before. * fix(blocks-engine): a nested instance's own overrides reach its definition's roots `composedRootTypes` followed a root instance into its definition without asking what that instance decided about the definition's nodes. A `visibility` exposure overridden to false removes the node and its subtree from the composed forest — `cloneDefinitionNode` returns nothing at all for it — so the query reported a type the page never receives, and a component whose only root is hidden was offered as placing something and placed nothing. Overridden to true it deletes the node's own gate, so a gated root the author turned on is expanded rather than left standing. The decision comes from the resolver's own planner rather than a second reading of `props.overrides`, so the chosen variant's presets are folded in under the instance's answers exactly as the render folds them. A definition's remembered roots are a property of the definition, so an instance that decides neither reads that memo nor writes to it. * fix(plugin-page-builder): a completed component must be the row the listing named, read whole Two ways a by-id read answers with something that is not the component it was asked for, both of which the completion took at face value. A `beforeOperation` hook can redirect the read, so the record answering for one component may be another's. Keyed by the listing's id, that row's draft was served under the other component's name: the canvas drew one component's content wherever the other was placed, with nothing anywhere saying so. A row naming a different id is now left out and the tier reported cut. A row carrying NO id is still keyed by the listing — field-level access drops it in presentation, and that case is why the id comes from the listing at all. And an absent document field was flattened into the same `null` as a stored-empty one. The field layer writes an unset non-required field as SQL NULL and reads it back with the key present, so `null` is an answer and a legal row; no key at all means this caller did not read the row whole. Told apart, the second marks the tier cut instead of reporting a complete library the client has no definition for. * fix(plugin-page-builder): the library is paged from a row, not from an offset Both tiers walked their collection as independent offset queries against a collection other authors are editing. A row inserted or deleted before the next offset shifts every row after it, so one comes back twice and another never at all — and a row never listed is never completed, so `omitted` stays false and the response reports a whole library while the canvas has no definition for it and draws every instance as missing. The position is now the greatest id the last page named, and the next page is the rows after it: ordered by id, that means the same thing however many rows before it moved. What is left of the offset counts pages since the position last MOVED, for the one page whose every row arrives without a readable id — it names no position, and stepping past it is the only way to reach what is behind it. A page that names an id resets it, so an ordinary walk never carries an offset a mutation can move. * fix(blocks-engine): the roots query survives a node whose own fields throw The resolver composes a definition carrying a throwing property accessor without raising; this query read the same fields with no boundary. Its one caller builds the insert panel's catalogue, so a single such definition took the whole panel down over a component the canvas draws. The boundary is at the published entry point rather than per definition, because the document's own roots are read by the same walk. Caught rather than asked first, which is this module's usual answer to a stored shape: reading the property IS the raise, so there is no question to ask before it. The answer is `undefined` — the one already defined for a forest this cannot judge, and one its callers already withhold the tile for. * refactor: name the two questions each of these rules was asking `withDraftDocument` asked three: is this the row that was asked for, was it read whole, and what is it called. The first and third are now their own — and the third was spelled twice, once here and once on the listing row, so an unnamed component could have come to be called two things. `rootTypesOfNode` asked what a root stands for and, for an instance, what its definition draws. The second is its own function, reached once the first has decided it is an instance at all. Both were over fallow's CRAP bound, whose coverage is estimated and so cannot be tested down; both are under it by branch count now. * perf(plugin-page-builder): the resting card reads the component tier only when the page places one The miniature every entry form draws at rest read the whole component tier on mount, whatever the page held. That read is a listing over every row plus a read of its own per row to reach the working draft, bounded at sixteen mebibytes — so opening an entry whose page places no component paid for a library it resolves nothing against and draws identically without. The question is asked of the document, under the SITE's node cap, and an unread prefix counts as placing one: a document past the cap answers `complete: false`, and "names nothing" is what an unread document looks like too, so the read is still made rather than the largest pages on a site drawing as could-not-be-loaded markers. A disabled read reports `ready` rather than `pending`. A query that never runs never stops pending, and the card would have waited on an answer nobody asked for. * fix(blocks-engine): a visibility exposure reads the definition's own gate With no override written, a `visibility` exposure reported nothing in force — and something was. A definition node carrying entry-field conditions is not served: `survivesGating` with no plan says so, and the composed forest keeps the gate for the renderer's hidden-node pass to act on. Read as a blank, the inspector drew the row checked and told the author the node was shown on this page while the canvas withheld it. The row now carries the definition's own decision, with `source: "definition"` and no own override, so a surface can tell an inherited gate from an author's own hiding without a vocabulary of its own. A node with no gate still reads as nothing in force, because nothing is. * fix(plugin-page-builder): gate a configured component store by the slug it reads Two rules about a store the plugin was told about rather than owns. The route resolved its permission through the scope helper, which follows the host's rename of a collection the PLUGIN declared. The handler reads the configured slug literally, so the two named different collections exactly when a host renamed the contributed `components` collection and pointed the store at another one called `components`: a caller holding read on the collection being read was refused, and one holding read on the renamed collection was let into a collection that permission says nothing about. The default store still resolves through the helper, which is what a rename needs. And a completed component now carries its `keywords`. `LibraryComponent` derives from `SavedComponent`, and the catalogue builds a component tile's search terms from that field exactly as it does a pattern's — through the same reader here, so an absent field stays absent and a stored NULL arrives as `null`, which is the shape the palette expects. * chore: a changeset for the component library's review round The round's own entry, since the PR it was raised against merged while this was being written and its changeset went with it. * fix(blocks-engine): a malformed slot entry refuses a placement, it does not raise A stored forest reaches the internal-nesting walk unvalidated — its own comment says a slot may hold anything — and asking what a node DRAWS reads one field further than asking its type did: an entry typed as an instance but carrying no `props` had its `componentId` read, where before only its type was. Read defensively now, and an entry with no readable type stands for NO type rather than for one spelled out of whatever was there: `placementVerdict` compares these against a registry, and a string like "undefined" is a name a slot's admissions list could be made to accept. The planners shape-check their forest before they reach this, so it is a boundary rather than the whole defence — what it must not be is a raise where a refusal belongs. * fix(plugin-page-builder,blocks-engine): a by-id row's own id must be the one asked for Four things a reader established about its own instruments rather than about the data. A row with no `id` KEY is the redaction case and is keyed by the listing. A row that HAS the key answered with an identity, and `null`, a number or an empty string is not "no id" — it is an answer, and not this one. Read as absence its content travelled under a name it never claimed. The lookup is now read INSIDE the roots query's exception boundary. It is a caller-supplied object rather than a map the engine builds, so a `get` that raises escaped to exactly the caller the boundary exists for. The listing's cursor is taken from the greatest READABLE id on a page, and the test stub now selects it the same way. Inspecting only the final row made the stub fall back to an offset where the route carries a cursor, so the pagination cases could pass about a walk that does not run — and the route's own selection had no test at all, because the component tier's listing is injected. It is asserted on the pattern tier, where the route's `page()` actually runs. And the two `withoutSelf` suites share one set of fixture builders. Two copies of what a component, an instance and the walk's inputs are is a second definition of the thing under test. * feat(nextly): a plugin can update many entries in one call (#1819) * feat(nextly): a plugin can update many entries in one call `ctx.services.collections` ended at `createMany`. Plugin code could write many rows in one call and then had no way to change them in one, elevated or not, so the only batch update available to it was a loop of `updateEntry` calls, each with its own transaction, its own access pass and its own cache flush. The engine path was already there: `updateEntries` takes `overrideAccess`. `updateMany(slug, entries, opts?)` takes one `{ id, data }` per row, so a single call can apply a different patch to each row, and applying one patch to many rows is the same call with the patch repeated. It returns the `BatchOperationResult` `createMany` returns, so a failed row leaves the rest committed and is reported by the index of the entry the caller passed. Researched first. Payload, Strapi's query engine, Directus and Sanity all take a filter plus ONE shared patch, which cannot express a different patch per row and whose documented failure is a filter matching more than its author meant; Payload had to patch exactly that. WordPress's batch framework and this engine already take a list of per-item patches. A by-filter form would also need a second access, hook and revalidation pass to compute what it changed, and `listEntries` composes with this method to the same capability with the rows named, so there is one method rather than two. A `locale` is refused by name, as on `createMany`: the bulk pipeline takes no locale at all, and `forwardedFromContext` spreads one in where a spread suppresses excess-property checking, so an accepted locale would be dropped and every row written to the default language under a reported success. The harness note on the D56 suite said bulk paths could not run end to end in `createTestNextly`. They can: the new suite seeds with `createMany` and patches the result with `updateMany` through a real boot. The note is corrected rather than carried. * fix(nextly): type the batch-update fixture, export its input type, and page the docs recipe Three review findings on the new `updateMany`. The end-to-end suite typed its services fixture as `any`, so every call in it compiled whether or not `PluginCollectionService` declared the method: the suite existed to pin the published surface and would have stayed green while that surface lost it. Typed as `PluginContext["services"]` instead, which immediately refused a plain string where `AuthUserId` is required and an unknown property on `AuthUser`, both of which the `any` had been hiding. `BulkUpdateEntry` is the input half of a public method and was exported from neither the `nextly` root nor the SDK, so a plugin author could not name the contract without an internal path or a `Parameters<>` extraction. Exported beside `BatchOperationResult`, through both. The by-filter recipe in the docs read one page and presented itself as the way to update every match, so it silently stopped at 100 rows. It now loops, and says why re-reading page one is right for a patch that changes what the filter matches, and what to do instead when it does not. * chore: the batch-update changeset covers the new lockstep package * feat(nextly): a plugin can publish a widget source, and a slow one cannot hold the batch (#1823) * feat(nextly): a plugin can publish a widget source, and a slow one cannot hold the batch contributes.widgetSources takes a source and the function that answers it in one value, so a source nothing can answer is not a state a plugin can reach. The resolver type is the one core's own system sources already use rather than a second contract beside it, and both kinds now share one resolver store: two stores would be two answers to "what answers this id", and the invariant that matters is which kinds may carry a resolver at all -- never a collection or a single, whose rows the access-controlled Direct API is supposed to answer. The signature is the SSRF boundary rather than a convenience. A resolver is handed (query, caller) and reaches only what its own closure captured, and every name in that query was checked against the plugin's own declared field list first -- so there is no caller-controlled string for it to fetch. Directus mitigated an import-endpoint SSRF with an IP denylist and the denylist was then bypassed by DNS rebinding; this stays structural and never becomes a validated escape hatch. What the shape does NOT promise is that a resolver consults the caller. It cannot: the host hands the caller over and the plugin decides. WordPress has required a permission_callback on every REST route since 5.5 and plugins still ship __return_true for it. The docblocks say so rather than implying a guarantee the contract cannot make; this is the boundary a plugin's init, hooks and services already cross. The fold refuses a reserved namespace, a duplicate id and a missing resolver at BOOT, each naming the plugin, because which resolver owned an id would otherwise be decided by plugin load order with the loser silently never answering. Core registers its own sources first, so a plugin shadowing one fails naming the plugin rather than dying inside core's publication. The query endpoint bounds each slot. Promise.all made the batch as slow as its slowest member, so one query that never settled held every other card behind it and the reader saw nothing rather than the cards that answered. The race abandons rather than cancels, so the abandoned promise is marked handled and the timer is unref'd. Every declared source kind is now executable, which is why the final guard in resolveExecutableSource narrows to never. It is kept and widened through a string: the narrowing is a statement about TypeScript callers, not about the values a separately compiled plugin can hand over. * fix(nextly): a contributed widget source's resolver is handed its plugin's context The contract shipped as `(query, caller)`, and a plugin's data services are reachable through nothing else but its `PluginContext` -- so a contributed resolver could answer from constants and little more. The feature could be declared, validated and dispatched to, and could not do the one thing it exists for. Every other contributed FUNCTION on this surface already receives the context: `jobs.handler(ctx)`, `services[name](ctx)`. This one was the exception by oversight. The context arrives LAST, so a resolver that needs no data can ignore it, and the source half stays a plain object -- `contributes` is still readable without executing the plugin, which is what lets tooling work from config alone. Registration moves out of `resetWidgetRegistries` and into `initializePlugins`, because the reset runs before any context has been built. It sits beside the contributed-services registration and binds the same way: per plugin, so each resolver closes over ITS OWN context. Bound once outside that loop, every plugin would share the first one's `ctx.self` and read another plugin's entities believing they were its own. The fold still runs once across every plugin first, so a duplicate id or a reserved namespace is refused naming both owners before anything reaches a store. Core still publishes its own sources first, which is what makes a plugin shadowing a built-in fail on the PLUGIN's registration -- naming something an operator can act on. Proved through a real boot rather than a mock: a plugin whose resolver counts rows through `ctx.services.collections` answers 2 over two written rows. No stub could have failed that way, which is the point -- the two-argument contract passed every mocked test it had. * fix(nextly): say what the resolver contract bounds, and publish the type it takes Two corrections, both about what the published contract PROMISES. The SSRF claim was too strong. Validation checks a query's field names, its operators and its operand SHAPES against the source's own declaration; it does not constrain operand VALUES, and `where: { total: { equals: "http://..." } }` is a legal query whose bytes a caller who may place a widget chooses. So the docblocks no longer say a caller-supplied string cannot reach a resolver -- they say exactly what is bounded, what is not, and that a resolver must treat every operand as caller-controlled before using one as a destination. Stated as the contract's rule rather than a host-side filter, because Directus's IP denylist for CVE-2022-23080 was itself bypassed by DNS rebinding: a filter over a value the plugin then dereferences is one round of research from being none. And `WidgetSourceResolver` is core's own two-argument shape, so an author who followed the export and typed a standalone resolver with it had the context parameter rejected -- the one parameter through which a contributed resolver reads any data. `PluginSourceResolver` is published beside it, both names say which is which, and a compile-time test asserts the three-argument shape is what `contributes.widgetSources` takes. * fix(nextly): a plugin's managed read keeps the claims its caller was authenticated with `readCaller` spreads `auth.claims` onto the user it builds, so a caller handed to a plugin carries whatever its token proved -- a tenant, a plan, an entitlement -- and a collection's code-defined `access` rule may read exactly those. `resolveServiceOpts` rebuilt the identity from id, name, email and roles alone, so the rule was judged on a DIFFERENT caller than the endpoint authenticated. Both directions are wrong and one of them grants: a positive check on a claim denies a caller who holds it, and an absence-tolerant check such as `user.plan !== "suspended"` passes a caller whose token says otherwise. Taken as "whatever else the supplied identity carried", so a caller passing a plain `AuthUser` contributes nothing and is unaffected, and the declared fields still come from the identity rather than from the spread. * fix(nextly): a bulk write carries the localized split, so createMany reaches the companion (#1816) * fix(nextly): a bulk write carries the localized split, so createMany reaches the companion createMany failed EVERY row on a collection with localized: true - the shared create implementation wrote translatable values to the main table, which has no columns for them, so each row came back with the driver's own 'no column named' message. It now performs the same split as a single create and writes the companion row for the write's locale inside the same transaction. The shared update implementation had the identical gap one function down, so it upserts that row the way the single-entry update does - an upsert, not an insert: the parent exists and may or may not have a row in this language, and a patch that touches no translatable field writes nothing rather than claiming a translation nobody made. A held edit writes no live row and so no companion row either. Both paths still refuse a named locale, as they did: this is the default language's write. * refactor(nextly): both create paths write a new companion row through one insert The bulk create's companion insert repeated the single create's, down to the staleness stamp, which is how the two would come to disagree about what a new translation carries. insertCompanionRow states it once; the test asserts the stamp is on every row a bulk create writes, which nothing had asserted for either path. * fix(nextly): a bulk localized write records the whole language, and rolls back when it cannot Five findings from review, all on the write this PR added. Readiness is never resolved inside a caller's transaction: the split now reads the remembered verdict when it is handed an executor, as every other in-transaction caller does. Resolving issues a pooled query that waits for the connection the transaction holds, and on PostgreSQL a query against a missing relation aborts the whole transaction. The batch services already warm the verdict before they open one; the promoted-draft path, which passed an executor since before this PR, stops querying there too. A failure of the companion write is a write-integrity failure: the parent row is already written, and a soft per-item failure would let a batch commit a document whose translations never landed. What the write RECORDS is now the whole language: the companion row is read back on the transaction and overlaid with what this write set, so a patch naming one translatable field no longer records a document missing the rest - and the version is tagged with the locale those values belong to, without which history reads them as shared and a restore drops them. The values reattach under the field's name, not the column's, so a field whose column differs reaches hooks and snapshots as itself. * fix(nextly): a bulk write's events name the language they describe The write locale reached the companion upsert and stopped there, so every entry.created, entry.updated and status event a bulk localized write recorded carried no locale on its resource - and a receiver reads the language from there alone, so each was delivered as belonging to no translation in particular. The interactive create has always set it. * fix(nextly): a bulk write authorizes the translation it publishes, and reads the prior one first Four corners of the same write, all from review. A localized collection can keep its status per language, so the main row and a translation can disagree about whether that language is published - the state a reconcile leaves. The batch update judged the transition against the main row alone, where `published -> published` is a no-op needing no grant, and then wrote `_status` to the companion: an update-only caller could publish a translation, and the inverse could take one down. The decision now runs against both places through one implementation, so the two readings cannot drift. What the write reports as the previous state came from the main row, which on a migrated localized collection holds none of the translatable values. Every translation therefore read as appearing from nothing, the old value of the field that did change was lost, and a rename of a localized slug never busted the URL it replaced. The prior translation is read before the write instead, and is what `previous`, the previous slug and the status event's `from` are taken from. Reading it first also removes a window rather than handling it: the document recorded after the write is now derived from that read rather than fetched again, so no read stands between the write and the commit. A patch naming only a shared field wrote no companion row, so the failing read was unguarded, and a caller tolerating per-item failures committed the main row with no version and no event while reporting the item as failed. A create landing on published derives a lifecycle event, a curated event and a cache intent beside `entry.created`. Only the first named the language, so the rest described no translation in particular and the locale-specific tag was never flushed. Also stated, rather than claimed away: inside a caller's transaction the readiness verdict can only be remembered, and a verdict nobody warmed is indistinguishable from a collection that was never migrated. The entry points that open their own transaction warm first; the ones handed an open transaction cannot. * fix(nextly): a batch write reports the status of the translation it wrote, not the entry's A regression from the previous commit, found in review. Reading the prior status from the companion row was right, and left the other end of the move reading the main row. On a collection that keeps status per language those disagree exactly where it matters: the main row published, this translation still a draft after a reconcile. A patch touching only translated content then read `draft -> published` and announced `entry.published` and `entry.status_changed` for a translation nothing had published, while the recorded document and the version claimed it was live. Both ends now come from the row that owns this language's publication state. What the write leaves the companion holding is the value the patch set, or the one already there when the patch named no status, and that is overlaid onto the document the same way its values are - so the event, the snapshot and the companion row cannot disagree about whether a translation is public. Also verified and deliberately not changed here: both update paths give their hooks the bare main row, so a hook comparing a localized field sees undefined as the prior value. That is true of the single-entry update as much as this one, and it predates this change; hydrating only this path would make batch hooks see translations single-entry hooks do not. * fix(nextly): a bulk write converts its values, and records each publication once Four more from review, and all four came from the same place: the batch writers re-derived work the interactive path already does completely, and stopped short of it in a different way each time. The headline claim was still false for rich text. A create prepared its payload with two of the four conversions the interactive create runs, so a JSON-backed value reached the localized split as a live object and went into the companion column unconverted. The conversion is one method now, called by both, and the whole eleven-step preparation either path performs before its write is one method as well - which is the honest fix, because a list of steps maintained by hand in two places is exactly how one of them went missing. A translation's status is likewise decided in one place now, and it has a third case the batch path never had: a locale being translated for the first time has no companion row, so the upsert creates one and its status lands on the column default. Reported from the main row instead, a brand-new draft translation was announced as published. The publication events were collapsed into one, which got a case wrong in each direction. A content-only patch on a draft translation under a published entry announced a publication nothing performed; a patch that really did publish the main row, while that translation was already published, announced nothing at all. Main row and translation move independently, so each is recorded on its own terms and the main-row event is suppressed only when the companion write encodes the identical move. One reported case is not reachable and is left alone rather than guarded: `readCompanionLocalizedValues` resolves an absent translation to null rather than omitting it, so the localized document is never empty while a companion exists. Recorded where the predicate is read. * fix(nextly): a field hook fires for the translation the write touched, not its siblings From review, and introduced by the overlay the previous commit added. The whole language is put onto the updated row on purpose: the snapshot and the event describe a translation rather than the one field of it that moved. A field hook, though, runs for every key PRESENT in the row it is handed, so the untouched siblings carried along for the snapshot would run their afterChange handlers for values this write never changed - and those send mail, re-index and call out. They are taken off for the hook phase and put back after it, rather than the hook being handed a copy: a handler's return value is written back into the row it was given, and a copy would drop the transformations belonging to the fields this write did set. Restored in a `finally`, so a throwing handler cannot cost the response its translations either. The field registry performs the same remove-then-restore around its own snapshots. * fix(nextly): offer the collection step only where creating one would finish it (#1830) * fix(nextly): offer the collection step only where creating one would finish it The step completes when this reader can READ a collection, and `seedPermissionsForCollection` assigns a new collection's CRUD permissions to `super_admin` alone. So a caller holding the definition grant and nothing else was offered "create your first collection", created it, gained no `read-` for it, and found the step outstanding permanently -- the defect the predicate exists to prevent, moved one action later rather than fixed. It now asks both halves: may they define one, and would they read the result. A scoped API key is refused outright, because a key is judged on the scope stamped into it and gains nothing when a new collection appears. The test that documented the old state is replaced by a pair. A stamped key is offered NEITHER step and its checklist is complete, because it could finish neither. A session super admin on an install with no collections at all IS offered it -- the must-differ half, and the caller a fresh install actually has. The fixture carries no collections on purpose: a super admin reads every collection that exists, so any other fixture completes the step and completion short-circuits the predicate, passing without exercising it. * fix(nextly): derive who reads a new collection from the policy that seeds it The predicate hard-coded "receives read on a new collection" as "is a super admin", independently of the assignment `assignNewPermissionsToSuperAdmin` actually performs. The two could then drift in either direction with nothing reporting it: creation beginning to grant the creator would leave the checklist hiding a step that had become finishable, and the assignment moving to another role would leave it offering one that had stopped being. A reader would simply see a checklist that was wrong about them. `auth/new-entity-access-policy` declares the role a newly seeded entity's permissions are assigned to, and both ends read it: the seeder assigns by it, and `wouldReadOwnNewCollection` answers whether a caller is on the receiving end. The membership test stays `isSuperAdmin` because it resolves role inheritance, which a flat slug comparison would miss -- but it is now reached through the policy rather than asserted beside it. The API-key branch moves into the same place, where the reason belongs: a key is judged on the scope stamped into it when it was minted, so a role gaining a permission afterwards does not widen a key already issued. * fix(nextly): compute the eligibility from the recipient, and let a stamped key finish Sharing the slug was not sharing the policy. The predicate still tested super-admin membership by name, so moving the recipient would have moved the seeder and left the checklist behind -- the drift the constant was introduced to prevent, one level in. Membership is now READ from the declared recipient, so the constant is genuinely the single point of change. And refusing every API key hid a step some keys can finish. A key never gains a permission -- it is judged on the scope stamped into it when it was minted -- but a permission may be pre-seeded, and the read decision accepts a key's exact `read-` once that collection exists. So a key holding `read-reports` finishes this step by creating `reports`, and eligibility is that grant's presence rather than the actor type. Both halves break-verified: refusing every key fails the new stamped-key case alone, and pointing membership at another role fails the super-admin case alone. * fix(nextly): the promote gate judges the draft the write commits Seven review findings on the publish gate, all from one root: the gate ran BEFORE the write transaction, so it judged a copy of the world as it was, and a check that runs before the write can always disagree with it. It now runs inside that transaction, on the draft the transaction has locked. That closes three of the seven at once. A draft another writer saves between the check and the commit is now the draft that is judged. A `beforeChange` hook that turns a status-less edit into a publish no longer slips past a check that ran before the hooks. And `publishAllLocales`, which applies every language's snapshot to one main row, judges the combined outcome instead of each language against its own shared values, which could pass individually and land invalid. Resolving the caller's grants is the one thing that cannot happen inside a transaction: it queries the pooled connection the transaction is holding, and on a small pool the publish would hang rather than fail. Both callers resolve them beforehand and hand them in. A field rule on a child of a group or a repeater row is enforced. The copy was shallow, so the rules deleted the denied nested value from the copy and the original alike, and the top-level comparison then saw an unchanged container and reported nothing while the write persisted the forbidden edit. The copy is deep and the difference is found at any depth, so the refusal names `ops.runbook` rather than `ops`. A publish is no longer refused over a field nobody touched. The live side came from a read that expands an upload or a relationship into the document behind it, while the snapshot holds the identifier, so an untouched field of either kind compared unequal. Both sides now go through one conversion. A draft older than a newly required field is refused rather than published. The promoted document was judged as a patch, which skips absent properties by design, so a snapshot written before the field existed reported nothing. An API key is judged on the grants stamped on the key. There is no request-local scope on the Direct API, so the scope travels as an argument. --- .../a-component-is-judged-by-what-it-draws.md | 31 +- .../a-plugin-can-answer-a-widgets-question.md | 72 ++ ...-plugin-can-update-many-entries-at-once.md | 30 + .changeset/a-single-answers-as-one-row.md | 31 +- .../a-single-publish-revalidates-its-draft.md | 40 + .../a-step-offered-is-a-step-finishable.md | 38 + ...a-super-admins-key-copies-the-catalogue.md | 31 +- ...ance-can-be-shown-and-pointed-elsewhere.md | 38 + .../an-instance-shows-what-it-exposes.md | 31 +- .../bulk-writes-carry-the-localized-split.md | 36 + ...e-checklist-offers-what-a-reader-can-do.md | 1 + ...he-promote-gate-judges-the-locked-draft.md | 40 + docs/plugins/services.mdx | 66 +- .../src/instance-inspector-panel.test.tsx | 200 ++++ .../builder/src/instance-inspector-panel.tsx | 70 +- .../builder/src/instance-inspector.test.ts | 51 +- packages/builder/src/instance-inspector.ts | 18 +- packages/nextly/src/api/widget-query.test.ts | 167 ++++ packages/nextly/src/api/widget-query.ts | 123 ++- .../src/auth/new-entity-access-policy.ts | 70 ++ .../src/di/__tests__/register-widgets.test.ts | 37 + packages/nextly/src/di/register.ts | 31 + .../src/di/registrations/register-widgets.ts | 13 + .../auth/services/permission-seed-service.ts | 3 +- .../bulk-writes-localized.integration.test.ts | 942 ++++++++++++++++++ .../services/collection-mutation-service.ts | 820 +++++++++++---- .../services/collection-service.ts | 61 +- .../src/domains/i18n/companion-columns.ts | 36 + ...-revalidates-its-draft.integration.test.ts | 415 ++++++++ .../domains/singles/services/promote-gate.ts | 310 ++++++ .../singles/services/single-entry-service.ts | 8 +- .../services/single-mutation-service.ts | 59 ++ .../singles/services/single-publish-all.ts | 129 ++- .../__tests__/onboarding.integration.test.ts | 96 +- .../widgets/__tests__/plugin-sources.test.ts | 168 ++++ .../plugin-widget-source.integration.test.ts | 213 ++++ .../domains/widgets/caller-read-options.ts | 58 ++ .../src/domains/widgets/condition-probe.ts | 47 +- .../src/domains/widgets/executable-source.ts | 55 +- .../nextly/src/domains/widgets/execute.ts | 11 +- packages/nextly/src/domains/widgets/index.ts | 31 + .../src/domains/widgets/resolved-sources.ts | 169 ++++ .../src/domains/widgets/system-sources.ts | 97 +- packages/nextly/src/index.ts | 16 +- .../__tests__/service-d56.integration.test.ts | 15 +- .../service-update-many.integration.test.ts | 190 ++++ packages/nextly/src/plugins/contributions.ts | 28 + .../nextly/src/plugins/service-opts.test.ts | 41 + packages/nextly/src/plugins/service-opts.ts | 30 +- .../__tests__/collect-widget-sources.test.ts | 156 +++ .../plugins/widgets/collect-widget-sources.ts | 207 ++++ .../src/shared/lib/field-level-registry.ts | 2 +- packages/plugin-sdk/STABILITY.md | 81 +- .../__snapshots__/plugin-surface.test.ts.snap | 6 + packages/plugin-sdk/src/index.ts | 25 +- .../src/widget-source-exports.test-d.ts | 56 ++ scripts/doc-samples-baseline.json | 17 +- 57 files changed, 5413 insertions(+), 450 deletions(-) create mode 100644 .changeset/a-plugin-can-answer-a-widgets-question.md create mode 100644 .changeset/a-plugin-can-update-many-entries-at-once.md create mode 100644 .changeset/a-single-publish-revalidates-its-draft.md create mode 100644 .changeset/a-step-offered-is-a-step-finishable.md create mode 100644 .changeset/an-instance-can-be-shown-and-pointed-elsewhere.md create mode 100644 .changeset/bulk-writes-carry-the-localized-split.md create mode 100644 .changeset/the-promote-gate-judges-the-locked-draft.md create mode 100644 packages/nextly/src/auth/new-entity-access-policy.ts create mode 100644 packages/nextly/src/domains/collections/__tests__/bulk-writes-localized.integration.test.ts create mode 100644 packages/nextly/src/domains/singles/__tests__/single-publish-revalidates-its-draft.integration.test.ts create mode 100644 packages/nextly/src/domains/singles/services/promote-gate.ts create mode 100644 packages/nextly/src/domains/widgets/__tests__/plugin-sources.test.ts create mode 100644 packages/nextly/src/domains/widgets/__tests__/plugin-widget-source.integration.test.ts create mode 100644 packages/nextly/src/domains/widgets/caller-read-options.ts create mode 100644 packages/nextly/src/domains/widgets/resolved-sources.ts create mode 100644 packages/nextly/src/plugins/__tests__/service-update-many.integration.test.ts create mode 100644 packages/nextly/src/plugins/widgets/__tests__/collect-widget-sources.test.ts create mode 100644 packages/nextly/src/plugins/widgets/collect-widget-sources.ts create mode 100644 packages/plugin-sdk/src/widget-source-exports.test-d.ts diff --git a/.changeset/a-component-is-judged-by-what-it-draws.md b/.changeset/a-component-is-judged-by-what-it-draws.md index 256102cb9a..6b1d7c0488 100644 --- a/.changeset/a-component-is-judged-by-what-it-draws.md +++ b/.changeset/a-component-is-judged-by-what-it-draws.md @@ -1,29 +1,30 @@ --- -"@nextlyhq/adapter-drizzle": patch -"@nextlyhq/adapter-mysql": patch -"@nextlyhq/adapter-postgres": patch -"@nextlyhq/adapter-sqlite": patch +"nextly": patch +"create-nextly-app": patch "@nextlyhq/admin": patch "@nextlyhq/admin-css": patch "@nextlyhq/blocks-engine": patch "@nextlyhq/blocks-react": patch -"@nextlyhq/builder": patch -"create-nextly-app": patch -"@nextlyhq/eslint-config": patch -"@nextlyhq/eslint-plugin": patch -"@nextlyhq/module-specifiers": patch -"nextly": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch "@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-mcp": patch "@nextlyhq/plugin-page-builder": patch -"@nextlyhq/plugin-sdk": patch "@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch "@nextlyhq/prettier-config": patch -"@nextlyhq/storage-s3": patch -"@nextlyhq/storage-uploadthing": patch -"@nextlyhq/storage-vercel-blob": patch "@nextlyhq/telemetry": patch "@nextlyhq/tsconfig": patch -"@nextlyhq/ui": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch --- A component the editor offers, and one the canvas draws, are now judged diff --git a/.changeset/a-plugin-can-answer-a-widgets-question.md b/.changeset/a-plugin-can-answer-a-widgets-question.md new file mode 100644 index 0000000000..9165791bd9 --- /dev/null +++ b/.changeset/a-plugin-can-answer-a-widgets-question.md @@ -0,0 +1,72 @@ +--- +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/builder": patch +"create-nextly-app": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/module-specifiers": patch +"nextly": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-mcp": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/ui": patch +--- + +A plugin can contribute a dashboard-widget DATA SOURCE. `contributes.widgetSources` +takes a source and the server-side function that answers it, together: the +source declares its queryable fields and supported ops the way every other +source does, and the resolver is handed the query, the caller, and its own +plugin context to read through. + +What the validation bounds is the SHAPE of the question -- field names, +operators and operand shapes, all against the source's own declaration. It does +not constrain operand VALUES, so a resolver must treat every value in a query +as caller-controlled and must not use one as an outbound URL, a path, or a +table or column name without checking it against a closed set of its own. + +Both halves travel in one value, so a source that nothing can answer is not a +state a plugin can reach. A contributed id must sit in the `plugin:` namespace; +`collection:`, `single:` and `system:` are refused at boot, as is a duplicate id +or a missing resolver, each naming the plugin that declared it. A disabled +plugin contributes nothing, and a boot starts from an empty store, so a source +never outlives the plugin that published it. + +The dashboard query endpoint now bounds each slot. The batch answers with +`Promise.all`, so it was only ever as fast as its slowest query: one that never +settled held every other card behind it and the reader saw nothing at all. A +slot that exceeds its budget now fails on its own and its siblings still answer. + +`WidgetSourceResolver`, `PluginWidgetSource` and `ReadCaller` are published from +`@nextlyhq/plugin-sdk`, so a resolver can be written as a named function rather +than only inline. They are `@experimental` on the same ladder as the rest of the +widget contract. + +A contributed resolver is handed its plugin's own context, so it can read data +to answer with. A plugin's services are reachable through nothing else, so the +first shape of this contract could return constants and little more. + +The resolver type a plugin author writes against is published as +`PluginSourceResolver`. The existing `WidgetSourceResolver` is core's own +two-argument shape and rejects the context parameter a contributed resolver +needs, so typing one with it made the contract reachable only inline. + +A plugin's managed read keeps the claims the caller's token proved. The +identity was rebuilt from id, name, email and roles alone, so a collection's +code-defined access rule reading a tenant, plan or entitlement was judged on a +different caller than the request authenticated -- denying a positive check, +and granting an absence-tolerant one. diff --git a/.changeset/a-plugin-can-update-many-entries-at-once.md b/.changeset/a-plugin-can-update-many-entries-at-once.md new file mode 100644 index 0000000000..79339160ea --- /dev/null +++ b/.changeset/a-plugin-can-update-many-entries-at-once.md @@ -0,0 +1,30 @@ +--- +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/builder": patch +"create-nextly-app": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/module-specifiers": patch +"nextly": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/ui": patch +"@nextlyhq/plugin-mcp": patch +--- + +A plugin can update many entries in one call. `ctx.services.collections` ended at `createMany`: plugin code could write many rows in one call and then had no way to change them in one, elevated or not, so the only batch update available to it was a loop of `updateEntry` calls, each with its own transaction, its own access pass and its own cache flush. `updateMany(slug, entries, opts?)` takes one `{ id, data }` per row, so a single call can apply a different patch to each row, and returns the same `BatchOperationResult` `createMany` returns: partial success, with `errors[].index` indexing the array the caller passed. There is deliberately no by-filter form; `listEntries` and this method compose to the same thing with the rows named, and a filter that matches more than its author meant is the failure a batch write cannot take back. A `locale` is refused by name, as on `createMany`, because the bulk pipeline writes in one pass and cannot store a translation. `@experimental` on the plugin surface until a first-party plugin exercises it. diff --git a/.changeset/a-single-answers-as-one-row.md b/.changeset/a-single-answers-as-one-row.md index fda0ced3ed..1430418b3e 100644 --- a/.changeset/a-single-answers-as-one-row.md +++ b/.changeset/a-single-answers-as-one-row.md @@ -1,29 +1,30 @@ --- -"@nextlyhq/adapter-drizzle": patch -"@nextlyhq/adapter-mysql": patch -"@nextlyhq/adapter-postgres": patch -"@nextlyhq/adapter-sqlite": patch +"nextly": patch +"create-nextly-app": patch "@nextlyhq/admin": patch "@nextlyhq/admin-css": patch "@nextlyhq/blocks-engine": patch "@nextlyhq/blocks-react": patch -"@nextlyhq/builder": patch -"create-nextly-app": patch -"@nextlyhq/eslint-config": patch -"@nextlyhq/eslint-plugin": patch -"@nextlyhq/module-specifiers": patch -"nextly": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch "@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-mcp": patch "@nextlyhq/plugin-page-builder": patch -"@nextlyhq/plugin-sdk": patch "@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch "@nextlyhq/prettier-config": patch -"@nextlyhq/storage-s3": patch -"@nextlyhq/storage-uploadthing": patch -"@nextlyhq/storage-vercel-blob": patch "@nextlyhq/telemetry": patch "@nextlyhq/tsconfig": patch -"@nextlyhq/ui": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch --- A `single:` widget source is executable. diff --git a/.changeset/a-single-publish-revalidates-its-draft.md b/.changeset/a-single-publish-revalidates-its-draft.md new file mode 100644 index 0000000000..1bd8fc7182 --- /dev/null +++ b/.changeset/a-single-publish-revalidates-its-draft.md @@ -0,0 +1,40 @@ +--- +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/builder": patch +"create-nextly-app": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/module-specifiers": patch +"nextly": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/ui": patch +"@nextlyhq/plugin-mcp": patch +--- + +Publishing a Single now re-judges the pending change it is about to promote, against the schema and the permissions as they stand at that moment. + +A Single's publish folds its held draft into the live row. Both gates ran when the caller's payload arrived, and for a publish that payload is just the new status, so the draft's own content reached the live row having been judged only when it was SAVED. Two things could have changed since. The publisher may not be the author, and a field rule can deny them a value the author was allowed to write. And the schema can have tightened under a value that was legal when it was held. + +BEHAVIOUR CHANGE, and it is visible. A draft written under an older, looser schema is now REFUSED at the moment someone hits Publish, having raised no complaint when it was saved. The refusal names each field and carries the rule's own message, so the author can see what to fix. Publishing content that violates the current contract is the worse outcome. + +A pending change that edits a field the PUBLISHER may not write is refused too, rather than being quietly dropped from the write. A successful publish consumes the pending change, so dropping the value would have published everything else, deleted the draft, and destroyed the author's edit with it. Refusing keeps the draft intact for someone who can write that field. Only a field whose promoted value actually differs from the live row counts: a draft snapshot is a full copy of the document, so a denied field appears in every one of them. + +Both gates run over the snapshot being promoted and never over the live document, so a schema change cannot block someone from fixing and republishing unrelated content. They run over that snapshot in its logical shape, so a Single that merely HAS a group, repeater or JSON field is not refused for holding one. + +Both publish paths are covered by one gate: the ordinary publish and `publishAllLocales`, which promotes every language's pending change in its own loop and had no such check at all. diff --git a/.changeset/a-step-offered-is-a-step-finishable.md b/.changeset/a-step-offered-is-a-step-finishable.md new file mode 100644 index 0000000000..c96ef1343b --- /dev/null +++ b/.changeset/a-step-offered-is-a-step-finishable.md @@ -0,0 +1,38 @@ +--- +"nextly": patch +"create-nextly-app": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-mcp": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch +--- + +The setup checklist offers "create your first collection" only to a reader who +would be able to READ what they create. A new collection's permissions are +seeded to the super-admin role alone, so a caller holding the definition grant +and nothing else created the collection, gained no read on it, and found the +step outstanding permanently -- with the card pinned to their dashboard. + +An API key stamped with a read grant is offered the step it can finish: a key +never gains a permission, but a pre-seeded `read-` lets it create that +collection and read it afterwards. diff --git a/.changeset/a-super-admins-key-copies-the-catalogue.md b/.changeset/a-super-admins-key-copies-the-catalogue.md index 28b9351557..edeedd3025 100644 --- a/.changeset/a-super-admins-key-copies-the-catalogue.md +++ b/.changeset/a-super-admins-key-copies-the-catalogue.md @@ -1,29 +1,30 @@ --- -"@nextlyhq/adapter-drizzle": patch -"@nextlyhq/adapter-mysql": patch -"@nextlyhq/adapter-postgres": patch -"@nextlyhq/adapter-sqlite": patch +"nextly": patch +"create-nextly-app": patch "@nextlyhq/admin": patch "@nextlyhq/admin-css": patch "@nextlyhq/blocks-engine": patch "@nextlyhq/blocks-react": patch -"@nextlyhq/builder": patch -"create-nextly-app": patch -"@nextlyhq/eslint-config": patch -"@nextlyhq/eslint-plugin": patch -"@nextlyhq/module-specifiers": patch -"nextly": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch "@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-mcp": patch "@nextlyhq/plugin-page-builder": patch -"@nextlyhq/plugin-sdk": patch "@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch "@nextlyhq/prettier-config": patch -"@nextlyhq/storage-s3": patch -"@nextlyhq/storage-uploadthing": patch -"@nextlyhq/storage-vercel-blob": patch "@nextlyhq/telemetry": patch "@nextlyhq/tsconfig": patch -"@nextlyhq/ui": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch --- An API key created by a Super Admin copies the permission catalogue rather diff --git a/.changeset/an-instance-can-be-shown-and-pointed-elsewhere.md b/.changeset/an-instance-can-be-shown-and-pointed-elsewhere.md new file mode 100644 index 0000000000..64e2c555d2 --- /dev/null +++ b/.changeset/an-instance-can-be-shown-and-pointed-elsewhere.md @@ -0,0 +1,38 @@ +--- +"nextly": patch +"create-nextly-app": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-mcp": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch +--- + +The instance inspector now edits two more of a component's exposed properties. +A link is edited as the address its property holds, keyed for an address, and +clears the property when emptied rather than pointing it at an empty string. A +visibility property is a checkbox: checked serves the component's node on this +page, unchecked hides it, and an inherited row reads as shown because that is +the component's own rule until the definition gates the node itself. Rich text +and image rows still show their value and source without a control: a passage +is edited on the canvas, and an image needs a picker the builder cannot reach +yet. diff --git a/.changeset/an-instance-shows-what-it-exposes.md b/.changeset/an-instance-shows-what-it-exposes.md index 9a33505582..5bce041e9e 100644 --- a/.changeset/an-instance-shows-what-it-exposes.md +++ b/.changeset/an-instance-shows-what-it-exposes.md @@ -1,29 +1,30 @@ --- -"@nextlyhq/adapter-drizzle": patch -"@nextlyhq/adapter-mysql": patch -"@nextlyhq/adapter-postgres": patch -"@nextlyhq/adapter-sqlite": patch +"nextly": patch +"create-nextly-app": patch "@nextlyhq/admin": patch "@nextlyhq/admin-css": patch "@nextlyhq/blocks-engine": patch "@nextlyhq/blocks-react": patch -"@nextlyhq/builder": patch -"create-nextly-app": patch -"@nextlyhq/eslint-config": patch -"@nextlyhq/eslint-plugin": patch -"@nextlyhq/module-specifiers": patch -"nextly": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch "@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-mcp": patch "@nextlyhq/plugin-page-builder": patch -"@nextlyhq/plugin-sdk": patch "@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch "@nextlyhq/prettier-config": patch -"@nextlyhq/storage-s3": patch -"@nextlyhq/storage-uploadthing": patch -"@nextlyhq/storage-vercel-blob": patch "@nextlyhq/telemetry": patch "@nextlyhq/tsconfig": patch -"@nextlyhq/ui": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch --- Selecting a component instance in the builder now opens its own inspector in diff --git a/.changeset/bulk-writes-carry-the-localized-split.md b/.changeset/bulk-writes-carry-the-localized-split.md new file mode 100644 index 0000000000..8af58a2f60 --- /dev/null +++ b/.changeset/bulk-writes-carry-the-localized-split.md @@ -0,0 +1,36 @@ +--- +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/builder": patch +"create-nextly-app": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/module-specifiers": patch +"nextly": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/ui": patch +--- + +A bulk write now reaches a localized collection. `createMany` failed every row +on a collection with `localized: true` — the shared create implementation wrote +the translatable values to the main table, which has no columns for them, so +the driver's own message ("no column named …") came back per row. It performs +the same split as a single create and writes the companion row for the write's +locale, inside the same transaction; the shared update implementation, which +had the identical gap, upserts that row. Both refuse a named locale as before: +these paths write the default language. diff --git a/.changeset/the-checklist-offers-what-a-reader-can-do.md b/.changeset/the-checklist-offers-what-a-reader-can-do.md index 6368e0aa1d..00d0e8f477 100644 --- a/.changeset/the-checklist-offers-what-a-reader-can-do.md +++ b/.changeset/the-checklist-offers-what-a-reader-can-do.md @@ -24,6 +24,7 @@ "@nextlyhq/telemetry": patch "@nextlyhq/tsconfig": patch "@nextlyhq/ui": patch +"@nextlyhq/plugin-mcp": patch --- The setup checklist offers a reader only the steps they could actually finish. diff --git a/.changeset/the-promote-gate-judges-the-locked-draft.md b/.changeset/the-promote-gate-judges-the-locked-draft.md new file mode 100644 index 0000000000..d17ec31e90 --- /dev/null +++ b/.changeset/the-promote-gate-judges-the-locked-draft.md @@ -0,0 +1,40 @@ +--- +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/builder": patch +"create-nextly-app": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/module-specifiers": patch +"nextly": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/ui": patch +"@nextlyhq/plugin-mcp": patch +--- + +The promote gate judges the draft the write actually commits. + +The gate that re-judges a Single's pending change before it is published ran before the write transaction, where it could only judge a copy of the world as it was. It now runs inside that transaction, on the draft the transaction has locked, which closes what a pre-flight check could not: a draft saved by another writer between the check and the commit is now the draft that is judged; a `beforeChange` hook that turns a status-less edit into a publish no longer slips past a check that ran before the hooks; and `publishAllLocales`, which applies every language's snapshot to one row, now judges the combined result rather than each language against its own shared values. Resolving the caller's grants is the one thing that cannot happen inside a transaction, since it queries the pooled connection that transaction holds, so it is resolved beforehand and handed in. + +A field rule on a child of a group or a repeater row is enforced. The check copied the snapshot shallowly, so the rules deleted a denied nested value from the copy and the original alike and the comparison then saw an unchanged container. A denied child is now found at its own depth and named at its own path. + +A publish is no longer refused over a field nobody touched. The comparison read the live document through a read that expands an upload or a relationship into the document behind it, while the snapshot holds the identifier, so an untouched field of either kind looked like an edit. Both sides now go through one conversion. + +A draft older than a newly required field is refused rather than published. The check judged the promoted document as a patch, which skips absent properties by design, so a snapshot written before a field became required reported nothing and published a document that violates the contract. + +An API key is judged on the grants stamped on the key, not on the database roles of whoever owns it. diff --git a/docs/plugins/services.mdx b/docs/plugins/services.mdx index 3732e325f5..47f0ed5efb 100644 --- a/docs/plugins/services.mdx +++ b/docs/plugins/services.mdx @@ -109,6 +109,7 @@ read and is refused on a write, and a selector is refused on both. | `deleteEntry(slug, id, opts?)` | Delete one. Returns `{ message, item: { id }, warnings? }`. | | `count(slug, where?, opts?)` | Count matching rows. | | `createMany(slug, rows, opts?)` | Bulk insert → `BatchOperationResult`. | +| `updateMany(slug, entries, opts?)` | Bulk update, one `{ id, data }` per row → `BatchOperationResult`. | `ctx.services` also exposes `users`, `media`, and `email` services. @@ -138,14 +139,75 @@ populating relations and is cheaper. ## Bulk operations -`createMany` returns a `BatchOperationResult` with index-keyed errors, so a partial -failure tells you exactly which rows failed: +`createMany` and `updateMany` return a `BatchOperationResult`, so a partial failure +tells you exactly which rows failed and leaves the rest committed: ```ts const res = await ctx.services.collections.createMany(slug, rows, { as: "system" }); // { successful, failed, ids: string[], errors: [{ index, error }] } ``` +`updateMany` takes one `{ id, data }` per row, so a single call can apply a +**different** patch to each row. Applying the same patch to many rows is the same +call with the patch repeated: + +```ts +const res = await ctx.services.collections.updateMany( + slug, + [ + { id: "post-1", data: { status: "published" } }, + { id: "post-2", data: { status: "archived", pinned: false } }, + ], + { as: "system" } +); +``` + +`errors[].index` indexes **the array you passed**, so the row a failure is about is +`entries[index].id`. The result shape is shared with `createMany`, which has no +caller-supplied ids to key its failures by. + +To update every row matching a filter, list them first and map the ids. There is no +by-filter batch update, deliberately: a filter that matches more than the author meant +is the failure this surface has no way to take back. + +`listEntries` returns one page, so page through it. The loop below re-reads page 1 +each time on purpose: every updated row leaves the `draft` filter, so the rows that +still match move up into it. + +```ts +let updated = 0; +for (;;) { + const page = await ctx.services.collections.listEntries( + slug, + { where: { status: "draft" }, pagination: { limit: 100 }, depth: 0 }, + { as: "system" } + ); + if (page.data.length === 0) break; + + const res = await ctx.services.collections.updateMany( + slug, + page.data.map((row: { id: string }) => ({ + id: row.id, + data: { status: "published" }, + })), + { as: "system" } + ); + updated += res.successful; + // Nothing moved: the rest are failing rather than draining, so stop rather + // than read the same page forever. + if (res.successful === 0) break; +} +``` + +When the patch does NOT change what the filter matches, page with +`pagination: { page: n }` instead and stop when `pagination.hasMore` is false, or the +loop above would re-read the same rows. + +Neither takes a `locale`: the bulk pipeline writes in one pass and cannot store a +translation, so both refuse one by name rather than filing every row under the default +language. Write the rows one at a time with `createEntry` or `updateEntry` when a +language matters. + ## Writes return an envelope `createEntry`, `updateEntry` and `deleteEntry` resolve to diff --git a/packages/builder/src/instance-inspector-panel.test.tsx b/packages/builder/src/instance-inspector-panel.test.tsx index 86f8ff2f68..8ff37a46a9 100644 --- a/packages/builder/src/instance-inspector-panel.test.tsx +++ b/packages/builder/src/instance-inspector-panel.test.tsx @@ -383,6 +383,206 @@ describe("a choice with an empty option", () => { }); }); +describe("a link and a visibility exposure", () => { + const linked = () => + header({ + nodes: [ + { + id: "h1", + type: "acme/cta", + version: 1, + props: { text: "Read more", href: "/docs" }, + }, + ], + exposed: [ + { + id: "cta", + label: "Call to action", + nodeId: "h1", + propPath: "href", + type: "link", + }, + { + id: "shown", + label: "Newsletter form", + nodeId: "h1", + propPath: "visibility", + type: "visibility", + }, + ], + }); + + it("edits a link as the address it holds, and clears it when emptied", () => { + // The same field a url prop gets in the block inspector, keyed for an + // address. Emptied, it clears rather than writing "" — an href of "" + // points at the page itself. + const editor = mount(instance(), linked()); + const field = screen.getByRole("textbox", { + name: "Call to action", + }) as HTMLInputElement; + + expect(field.value).toBe("/docs"); + expect(field.inputMode).toBe("url"); + fireEvent.change(field, { target: { value: "https://example.test/go" } }); + fireEvent.blur(field); + + expect(appliedProps(editor)).toEqual({ + componentId: "header", + overrides: { cta: "https://example.test/go" }, + }); + cleanup(); + + const emptied = mount(instance({ overrides: { cta: "/go" } }), linked()); + const again = screen.getByRole("textbox", { name: "Call to action" }); + fireEvent.change(again, { target: { value: "" } }); + fireEvent.blur(again); + expect(appliedProps(emptied)).toEqual({ + componentId: "header", + overrides: { cta: { $unset: true } }, + }); + }); + + it("still offers a SELECT's options when its stored value is structured", () => { + // Unlike a text field, a select does not carry the old value into what it + // writes: choosing an option replaces it outright. So a junk value there is + // repairable by using the control, and hiding it would strand the property. + mount(instance({ overrides: { tone: { unexpected: true } } }), header()); + + expect(screen.getByRole("combobox", { name: "Tone" })).toBeDefined(); + }); + + it("still offers the field for a CLEARED text row, so the author can put a value back", () => { + // A clear is a blank the field is meant to show, not a value the control + // cannot hold — read as unrepresentable, clearing a property would make it + // permanently uneditable. + mount(instance({ overrides: { cta: { $unset: true } } }), linked()); + + expect( + screen.getByRole("textbox", { name: "Call to action" }) + ).toBeDefined(); + }); + + it("shows a STRUCTURED value rather than offering to overwrite it with text", () => { + /* + * `OverrideValue` is unconstrained, so a host block may declare a link prop + * as an object. A text control renders one as an empty field — there is no + * string to show — and the first edit replaces the whole value with + * whatever was typed into that blank, losing the rest of it silently. + * + * Shown read-only instead, through the same note a type this panel cannot + * edit yet uses: the author sees what is there and nothing overwrites it. + */ + const editor = mount( + instance({ overrides: { cta: { href: "/docs", rel: "nofollow" } } }), + linked() + ); + + expect( + screen.queryByRole("textbox", { name: "Call to action" }) + ).toBeNull(); + expect(screen.getByText(/nofollow/)).toBeDefined(); + // Nothing to type into means nothing written: the value is intact. + expect(editor.apply).not.toHaveBeenCalled(); + }); + + it("shows an inherited visibility row as shown, and hides it by writing false", () => { + // Inherited is the component's own rule, which is shown unless the + // definition gates the node itself; the resolver reads `false` as hidden + // and `true` as shown, and nothing else. + const editor = mount(instance(), linked()); + const box = screen.getByRole("checkbox", { name: "Newsletter form" }); + + expect(box.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(box); + + expect(appliedProps(editor)).toEqual({ + componentId: "header", + overrides: { shown: false }, + }); + }); + + it("shows a row the DEFINITION gates as not shown, and turns it on by writing true", () => { + /* + * The component's own rule is not always "shown". A definition node + * carrying entry-field conditions is withheld by the renderer's + * hidden-node pass, and the resolver keeps the gate for it to act on — so + * a checkbox drawn from "no override written" told the author the node was + * on this page while the canvas did not draw it. + * + * Clicking it writes `true`, which is the one thing that removes the + * component's gate for this instance. + */ + const gated = () => + header({ + nodes: [ + { + id: "h1", + type: "acme/cta", + version: 1, + props: { text: "Read more", href: "/docs" }, + visibility: { + conditions: [[{ field: "tier", op: "eq", value: "pro" }]], + }, + } as never, + ], + exposed: [ + { + id: "shown", + label: "Newsletter form", + nodeId: "h1", + propPath: "visibility", + type: "visibility", + }, + ], + }); + const editor = mount(instance(), gated()); + const box = screen.getByRole("checkbox", { name: "Newsletter form" }); + + expect(box.getAttribute("aria-checked")).toBe("false"); + // And not as the author's own doing: there is nothing of theirs to reset. + expect(screen.queryByRole("button", { name: /reset/i })).toBeNull(); + + fireEvent.click(box); + + expect(appliedProps(editor)).toEqual({ + componentId: "header", + overrides: { shown: true }, + }); + }); + + it("shows a hidden row unchecked — cleared included, which the page reads as hidden — and shows it again by writing true", () => { + const hidden = mount(instance({ overrides: { shown: false } }), linked()); + const box = screen.getByRole("checkbox", { name: "Newsletter form" }); + expect(box.getAttribute("aria-checked")).toBe("false"); + fireEvent.click(box); + expect(appliedProps(hidden)).toEqual({ + componentId: "header", + overrides: { shown: true }, + }); + cleanup(); + + mount(instance({ overrides: { shown: { $unset: true } } }), linked()); + expect( + screen + .getByRole("checkbox", { name: "Newsletter form" }) + .getAttribute("aria-checked") + ).toBe("false"); + expect(screen.getByText("Cleared")).toBeDefined(); + }); + + it("offers the reset on a visibility override, which returns it to the component's rule", () => { + const editor = mount(instance({ overrides: { shown: false } }), linked()); + + fireEvent.click( + screen.getByRole("button", { + name: "Reset Newsletter form to the component's value", + }) + ); + + expect(appliedProps(editor)).toStrictEqual({ componentId: "header" }); + }); +}); + /** * A select offering `""` and `"\u0000"`: both are legal option values, and a * spelling that gave `""` a sentinel of its own made the two one item value. diff --git a/packages/builder/src/instance-inspector-panel.tsx b/packages/builder/src/instance-inspector-panel.tsx index 49bc61b006..6104401cce 100644 --- a/packages/builder/src/instance-inspector-panel.tsx +++ b/packages/builder/src/instance-inspector-panel.tsx @@ -37,6 +37,7 @@ import { } from "@nextlyhq/blocks-engine"; import { Button, + Checkbox, Input, Label, Select, @@ -313,7 +314,14 @@ function ExposedControl({

); } - if (!row.supported) { + // A type this panel has no control for, OR a value its control could not + // represent. The second is not a shape problem to report: `OverrideValue` is + // unconstrained and a host block may declare a text or link prop as an + // object, which a text field renders as a blank — and the first edit then + // replaces the whole value with what was typed into that blank. Shown rather + // than offered for editing, so nothing is lost to a control that cannot hold + // it. + if (!row.supported || !representable(row)) { const shown = valueSummary(row.value); return (

@@ -322,6 +330,9 @@ function ExposedControl({

); } + if (row.type === "visibility") { + return ; + } if (row.type === "select") { return (