From 5c5f29cd3a08f5707e11263351444909f45a6f78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:06:00 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix(spec):=20reconcile=20the=20metadata=20f?= =?UTF-8?q?orms=20against=20their=20Zod=20=E2=80=94=20four=20silent=20drif?= =?UTF-8?q?ts=20(#3786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every entry in METADATA_FORM_REGISTRY is a hand-written `defineForm` layout naming keys of a Zod schema it never imports: two descriptions of one key set, a "keep in sync" comment, and no mechanism. #3786 asked for a sweep of that shape. Four of the seventeen forms had already drifted, each silently — the schemas are deliberately not `.strict()`, so a key they do not declare parses clean and is stripped on the way to storage (the ADR-0104 failure class the FieldSchema prune tombstone already names in prose). What an author saw before this change: object `capabilities` is not an ObjectSchema key (it is `enable`), so the whole Capabilities section — 7 toggles — saved nothing. object the inline column grid offered 16 keys FieldSchema never declared: renames the schema had made (referenceFilter→lookupFilters, cascadeDelete→deleteBehavior, formula→expression, displayFormat→ autonumberFormat, summaryType/summaryField→summaryOperations) and keys pruned as dead in both layers (indexed #2377, and the auditTrail/dataQuality/encryptionConfig family). PII, Encrypted, Indexed and Immutable were switches that saved nothing. report `aria` + `performance` were pruned from ReportSchema by #3496; the form kept rendering both. hook, `body.memoryMb` was unauthorable — named in hook.form.ts's own doc action comment, absent from the list below it. page `interfaceConfig.sort` was unauthorable, so a page's default sort order could not be set in Studio at all. The mechanism is metadata-form-zod-reconciliation.test.ts, which walks every registered form and reconciles it against getMetadataTypeSchema(). The two directions are deliberately asymmetric: form-only (a control whose value is discarded) is always a defect and is not ledgerable; zod-only is ledgerable with a reason, for a deprecated key held back from new authoring or a curated quick-add subset. Ledger entries are checked for non-vacuity and for still resolving on both sides, per the #4045 / #4040 discipline. Verified by mutation: re-adding a stripped key, dropping a covered key, and offering a ledgered omission each turn the gate red. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb --- packages/spec/src/data/hook.form.ts | 1 + packages/spec/src/data/object.form.ts | 80 +++-- .../metadata-form-zod-reconciliation.test.ts | 279 ++++++++++++++++++ packages/spec/src/ui/action.form.ts | 1 + packages/spec/src/ui/page.form.ts | 8 +- packages/spec/src/ui/report.form.ts | 15 +- 6 files changed, 349 insertions(+), 35 deletions(-) create mode 100644 packages/spec/src/system/metadata-form-zod-reconciliation.test.ts diff --git a/packages/spec/src/data/hook.form.ts b/packages/spec/src/data/hook.form.ts index c0bf4ad467..5a1c1aae89 100644 --- a/packages/spec/src/data/hook.form.ts +++ b/packages/spec/src/data/hook.form.ts @@ -43,6 +43,7 @@ export const hookForm = defineForm({ { field: 'source', type: 'code', language: 'javascript', required: true, helpText: 'Function body source — no top-level imports' }, { field: 'capabilities', type: 'tags', helpText: 'Allowed ctx APIs (api.read, api.write, crypto.uuid, log, …)' }, { field: 'timeoutMs', type: 'number', helpText: 'Per-invocation timeout (ms)' }, + { field: 'memoryMb', type: 'number', helpText: 'Per-invocation memory cap (MB, max 256)' }, ], }, ], diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 84d147cca3..94ad63cfd7 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -101,15 +101,20 @@ export const objectForm = defineForm({ { field: 'description', type: 'textarea', helpText: 'Developer documentation for this column' }, { field: 'required', type: 'boolean', helpText: 'Must be set on every record' }, { field: 'unique', type: 'boolean', helpText: 'Disallow duplicate values' }, - { field: 'indexed', type: 'boolean', helpText: 'Create a database index for faster querying' }, + // `indexed` removed: a field-level index flag built no index and was + // pruned from FieldSchema in the 16.x line (#2377, ADR-0049). + // Declare the index in the object's `indexes[]` instead. { field: 'readonly', type: 'boolean', helpText: 'Visible but never user-editable' }, - { field: 'immutable', type: 'boolean', helpText: 'Editable on create, locked thereafter' }, + // `immutable` removed: never a FieldSchema key. The mechanism is the + // `readonlyWhen` predicate below. { field: 'hidden', type: 'boolean', helpText: 'Hidden from default UI' }, { field: 'searchable', type: 'boolean', helpText: 'Include in full-text search' }, { field: 'sortable', type: 'boolean', helpText: 'Allow sorting on this column' }, - { field: 'filterable', type: 'boolean', helpText: 'Allow filtering on this column' }, + // `filterable` removed: never a FieldSchema key (only `sortable` and + // `searchable` exist); every declared column is filterable. { field: 'defaultValue', type: 'text', helpText: 'Default value for new records (JSON literal)' }, - { field: 'placeholder', type: 'text', helpText: 'Placeholder hint' }, + // `placeholder` removed: never a FieldSchema key. Author hint text + // through `inlineHelpText` / `description`. // Text constraints { field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" }, @@ -138,36 +143,62 @@ export const objectForm = defineForm({ // Relational { field: 'reference', type: 'text', helpText: 'Target object name', visibleWhen: "type in ['lookup','master_detail','tree']" }, - { field: 'referenceFilter', type: 'code', language: 'expression', helpText: 'CEL filter applied to the picker', visibleWhen: "type in ['lookup','master_detail']" }, - { field: 'cascadeDelete', type: 'boolean', helpText: 'Delete children when parent is deleted', visibleWhen: "type == 'master_detail'" }, + // `lookupFilters`, not `referenceFilter`: an array of + // {field, operator, value} rules, not a CEL string. + { field: 'lookupFilters', widget: 'json', helpText: 'Filter rules applied to the picker ({field, operator, value})', visibleWhen: "type in ['lookup','master_detail']" }, + // `deleteBehavior`, not a `cascadeDelete` boolean: the schema models + // three outcomes, and only one of them is "cascade". + { field: 'deleteBehavior', type: 'select', helpText: 'What happens when the referenced record is deleted', visibleWhen: "type in ['lookup','master_detail']", options: [ + { label: 'Set null', value: 'set_null' }, + { label: 'Cascade (delete children)', value: 'cascade' }, + { label: 'Restrict (block the delete)', value: 'restrict' }, + ] }, { field: 'multiple', type: 'boolean', helpText: 'Allow selecting multiple records', visibleWhen: "type in ['lookup']" }, // Formula / summary - { field: 'formula', type: 'code', language: 'expression', helpText: 'CEL formula expression', visibleWhen: "type == 'formula'" }, + // `expression`, not `formula` — the key is named for what it holds, + // not for the field type that uses it. + { field: 'expression', type: 'code', language: 'expression', helpText: 'CEL formula expression', visibleWhen: "type == 'formula'" }, { field: 'returnType', type: 'select', helpText: 'Result type for formulas', visibleWhen: "type == 'formula'", options: [ { label: 'Text', value: 'text' }, { label: 'Number', value: 'number' }, { label: 'Boolean', value: 'boolean' }, { label: 'Date', value: 'date' }, { label: 'Datetime', value: 'datetime' }, { label: 'Currency', value: 'currency' }, ] }, - { field: 'summaryType', type: 'select', helpText: 'Aggregation', visibleWhen: "type == 'summary'", options: [ - { label: 'Count', value: 'count' }, { label: 'Sum', value: 'sum' }, { label: 'Avg', value: 'avg' }, - { label: 'Min', value: 'min' }, { label: 'Max', value: 'max' }, - ] }, - { field: 'summaryField', type: 'text', helpText: 'Field on child object to aggregate', visibleWhen: "type == 'summary'" }, + // A roll-up is ONE key — `summaryOperations` {object, field, function}. + // The flat `summaryType` / `summaryField` pair named neither of them + // and also lost `object`, so a roll-up authored here saved nothing. + { + field: 'summaryOperations', + type: 'composite', + helpText: 'Roll-up: which child object, which field, which aggregation', + visibleWhen: "type == 'summary'", + fields: [ + { field: 'object', type: 'text', required: true, helpText: 'Source child object name' }, + { field: 'field', type: 'text', required: true, helpText: 'Field on the child object to aggregate (ignored for count)' }, + { field: 'function', type: 'select', required: true, helpText: 'Aggregation function', options: [ + { label: 'Count', value: 'count' }, { label: 'Sum', value: 'sum' }, { label: 'Avg', value: 'avg' }, + { label: 'Min', value: 'min' }, { label: 'Max', value: 'max' }, + ] }, + ], + }, - // Autonumber - { field: 'displayFormat', type: 'text', helpText: 'e.g. "INV-{0000}"', visibleWhen: "type == 'autonumber'" }, - { field: 'startingNumber', type: 'number', helpText: 'Starting sequence value', visibleWhen: "type == 'autonumber'" }, + // Autonumber — `autonumberFormat`, not `displayFormat`. There is no + // `startingNumber`: the counter resets per rendered prefix, which the + // format string itself determines (e.g. AD{YYYYMMDD}{0000} resets daily). + { field: 'autonumberFormat', type: 'text', helpText: 'e.g. "INV-{0000}"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported', visibleWhen: "type == 'autonumber'" }, // Code language { field: 'language', type: 'text', helpText: 'Editor language (e.g. sql, javascript)', visibleWhen: "type == 'code'" }, - // Validation / governance - { field: 'validation', type: 'code', language: 'expression', helpText: 'CEL predicate — must evaluate true' }, - { field: 'errorMessage', type: 'text', helpText: 'Shown when validation fails' }, - { field: 'audit', type: 'boolean', helpText: 'Audit changes to this field' }, - { field: 'trackHistory', type: 'boolean', helpText: 'Keep change history' }, - { field: 'pii', type: 'boolean', helpText: 'Personally identifiable information' }, - { field: 'encrypted', type: 'boolean', helpText: 'Encrypt at rest' }, + // Governance. `validation` / `errorMessage` are not FieldSchema keys — + // a record-level predicate is a `validation` metadata item on the + // object, which carries its own message. `audit` / `pii` / `encrypted` + // named the `auditTrail` / `dataQuality` / `encryptionConfig` family + // pruned in 2026-06 as dead in both layers (see the FieldSchema + // tombstone); at-rest protection is `type: 'secret'`, not a flag. + { field: 'trackHistory', type: 'boolean', helpText: 'Summarize this field on the record activity timeline' }, + { field: 'visibleWhen', type: 'code', language: 'expression', helpText: 'CEL predicate — field is shown only when TRUE' }, + { field: 'readonlyWhen', type: 'code', language: 'expression', helpText: 'CEL predicate — field is read-only when TRUE (enforced server-side)' }, + { field: 'requiredWhen', type: 'code', language: 'expression', helpText: 'CEL predicate — field is required when TRUE (enforced server-side)' }, ], }, ], @@ -180,7 +211,10 @@ export const objectForm = defineForm({ collapsed: true, fields: [ { - field: 'capabilities', + // The key is `enable` (ObjectCapabilities). This block named + // `capabilities` — a key ObjectSchema has never declared — so every + // toggle below was stripped on save and the section did nothing. + field: 'enable', type: 'composite', helpText: 'Enable/disable system features', fields: [ diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts new file mode 100644 index 0000000000..22ca905ecd --- /dev/null +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **Metadata form ↔ Zod reconciliation** (#3786). + * + * Every entry in {@link METADATA_FORM_REGISTRY} is a hand-written `defineForm` + * layout that names keys of a Zod schema it never imports. That is the + * hand-copied-list shape #3786 was filed about: two descriptions of one key set, + * a comment asking the next author to keep them in step, and nothing that fails + * when they don't. Four of the seventeen forms had already drifted when this + * file was written, each silently: + * + * | form | drift | what an author saw | + * |---|---|---| + * | `object` | `capabilities` — no such key (`enable`) | the whole Capabilities section saved nothing | + * | `object` | 16 keys `FieldSchema` never declared | PII / Encrypted / Indexed / … toggles saved nothing | + * | `report` | `aria`, `performance` pruned by #3496 | two Advanced fields saved nothing | + * | `hook`, `action` | `body.memoryMb` absent | the L2 memory cap was unauthorable | + * | `page` | `interfaceConfig.sort` absent | a page's default sort was unauthorable | + * + * All of it failed the same way: **no error**. `FieldSchema` / `ObjectSchema` are + * deliberately not `.strict()`, so a key the schema does not declare parses clean + * and is stripped on the way to storage — the ADR-0104 failure class the + * `field.zod.ts` prune tombstone already names in prose. + * + * ## The two directions are not symmetric + * + * - **form-only** (the form offers a key the Zod does not accept) is *always* a + * defect. There is no design under which an author should be shown a control + * whose value is discarded. Not ledgerable. + * - **zod-only** (the Zod accepts a key the form does not offer) is sometimes + * deliberate: a deprecated key kept out of new authoring, or a curated + * quick-add subset that defers to a fuller editor. Ledgerable — with a reason, + * checked below for non-vacuity and for still resolving on both sides, the + * #4045 / #4040 ledger discipline. + * + * @see control-flow-form-zod-ledger.test.ts — same pattern for the flow designer + */ + +import { describe, it, expect } from 'vitest'; + +import { METADATA_FORM_REGISTRY } from './metadata-form-registry'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +// ──────────────────────────────────────────────────────────────────────────── +// Ledger — deliberate zod-only omissions. `omit` names one key; `subset` +// declares a whole nested list as a curated subset (coverage unenforced there, +// the form-only direction still is). +// ──────────────────────────────────────────────────────────────────────────── + +type OmitEntry = { kind: 'omit'; type: string; path: string; key: string; why: string }; +type SubsetEntry = { kind: 'subset'; type: string; path: string; why: string }; + +const LEDGER: ReadonlyArray = [ + { + kind: 'omit', + type: 'page', + path: 'interfaceConfig', + key: 'sourceView', + why: '@deprecated legacy named-view inheritance, honored at runtime as a fallback but deliberately not offered to new authors — a page defines columns/sort/filterBy directly (ADR-0047 revised)', + }, + { + kind: 'omit', + type: 'object', + path: 'enable', + key: 'apiMethods', + why: 'the Capabilities block is a toggle grid; apiMethods is a method whitelist (array of ApiMethod) that needs its own control, and is authored on the object body rather than as a switch', + }, + { + kind: 'subset', + type: 'object', + path: 'fields', + why: "the object editor's inline column grid is a QUICK-ADD surface covering the common authoring keys; the full per-field editor is `field.form.ts` (registered as the `field` metadata type), which is where the long tail of FieldSchema is authored", + }, +]; + +// ──────────────────────────────────────────────────────────────────────────── +// Zod introspection — reads `.def` directly so the test needs no `zod` import +// beyond what the schemas already are, and tolerates the `lazySchema` proxy. +// ──────────────────────────────────────────────────────────────────────────── + +/** Peel wrapper nodes until an object/union/record-value node is reached. */ +function unwrap(schema: unknown, depth = 0): any { + const s = schema as any; + if (!s || depth > 25) return s; + const d = s.def ?? s._def; + if (!d) return s; + switch (d.type) { + case 'optional': + case 'nullable': + case 'default': + case 'prefault': + case 'readonly': + case 'catch': + case 'nonoptional': + return unwrap(d.innerType, depth + 1); + case 'array': + return unwrap(d.element, depth + 1); + case 'record': + return unwrap(d.valueType, depth + 1); + case 'lazy': + return unwrap(d.getter(), depth + 1); + case 'pipe': + return unwrap(d.in, depth + 1); + default: + return s; + } +} + +/** + * Keys an object node accepts, or `null` when the node is not key-bearing. + * A union contributes the union of its members' keys — an author may legally + * write any member's key, so offering one is not a form-only defect. + */ +function keysOf(schema: unknown): string[] | null { + const u = unwrap(schema); + const d = u?.def ?? u?._def; + if (d?.type === 'object') return Object.keys(d.shape ?? u.shape ?? {}).sort(); + if (d?.type === 'union' || d?.type === 'discriminated_union') { + const all = new Set(); + let keyBearing = false; + for (const option of d.options ?? []) { + const k = keysOf(option); + if (k) { + keyBearing = true; + for (const key of k) all.add(key); + } + } + return keyBearing ? Array.from(all).sort() : null; + } + return null; +} + +/** The sub-schema stored under `key`, looking through union members. */ +function subSchemaOf(schema: unknown, key: string): unknown { + const u = unwrap(schema); + const d = u?.def ?? u?._def; + if (d?.type === 'object') return (d.shape ?? u.shape ?? {})[key]; + if (d?.type === 'union' || d?.type === 'discriminated_union') { + for (const option of d.options ?? []) { + const found = subSchemaOf(option, key); + if (found) return found; + } + } + return undefined; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Form introspection +// ──────────────────────────────────────────────────────────────────────────── + +type FormEntry = { field?: string; fields?: FormEntry[]; keyField?: { field?: string } }; + +/** Top-level `field:` names a form offers, across every section. */ +function topLevelFields(form: any): string[] { + const names: string[] = []; + for (const section of form.sections ?? []) { + for (const entry of (section.fields ?? []) as FormEntry[]) { + if (entry?.field) names.push(entry.field); + } + } + return names.sort(); +} + +/** + * Every nested list a form spells out by hand — `{ field, fields: [...] }` under + * a composite / repeater / record entry. These are the hand-copied lists; an + * entry with no `fields` is derived from the schema by the renderer and cannot + * drift. + */ +function nestedLists(form: any): Array<{ path: string; offered: string[] }> { + const out: Array<{ path: string; offered: string[] }> = []; + for (const section of form.sections ?? []) { + for (const entry of (section.fields ?? []) as FormEntry[]) { + if (!entry?.field || !Array.isArray(entry.fields) || entry.fields.length === 0) continue; + const offered = entry.fields.map((f) => f?.field).filter((f): f is string => !!f); + // A record editor authors its map key through `keyField`, so that name is + // offered even though it is not in the `fields` array. + if (entry.keyField?.field) offered.push(entry.keyField.field); + out.push({ path: entry.field, offered: offered.sort() }); + } + } + return out; +} + +const TYPES = Object.keys(METADATA_FORM_REGISTRY); +const ledgerFor = (type: string, path: string) => + LEDGER.filter((e) => e.type === type && e.path === path); +const isSubset = (type: string, path: string) => + ledgerFor(type, path).some((e) => e.kind === 'subset'); +const omittedAt = (type: string, path: string) => + ledgerFor(type, path).flatMap((e) => (e.kind === 'omit' ? [e.key] : [])); + +describe('metadata form ↔ Zod reconciliation (#3786)', () => { + it('the registry is non-empty and every form resolves a schema', () => { + // Without this the per-type assertions below would pass over an empty set — + // the failure mode a reconciliation test must not have. + expect(TYPES.length).toBeGreaterThan(10); + for (const type of TYPES) { + expect(getMetadataTypeSchema(type), `no Zod schema registered for '${type}'`).toBeDefined(); + } + }); + + it.each(TYPES)('%s: every field the form offers is a key the Zod accepts', (type) => { + const root = getMetadataTypeSchema(type); + const rootKeys = keysOf(root); + expect(rootKeys, `${type}: root schema is not key-bearing`).toBeTruthy(); + + // An offered key the schema does not declare is silently stripped on save — + // the author fills the control and the value never lands. + expect( + topLevelFields(METADATA_FORM_REGISTRY[type]).filter((f) => !rootKeys!.includes(f)), + `${type}: offered by the form but not declared by the Zod (saved value is dropped)`, + ).toEqual([]); + }); + + it.each(TYPES)('%s: every hand-written nested list matches its sub-schema', (type) => { + const root = getMetadataTypeSchema(type); + + for (const { path, offered } of nestedLists(METADATA_FORM_REGISTRY[type])) { + const sub = subSchemaOf(root, path); + const subKeys = keysOf(sub); + // A non-key-bearing sub-schema (a plain array of scalars, say) has nothing + // to reconcile against — but a hand-written list under it is then + // unanchored, so say so rather than skipping silently. + expect(subKeys, `${type}.${path}: hand-written sub-list over a non-key-bearing schema`).toBeTruthy(); + + expect( + offered.filter((k) => !subKeys!.includes(k)), + `${type}.${path}: offered by the form but not declared by the Zod (saved value is dropped)`, + ).toEqual([]); + + if (isSubset(type, path)) continue; + const excused = omittedAt(type, path); + expect( + subKeys!.filter((k) => !offered.includes(k) && !excused.includes(k)), + `${type}.${path}: accepted by the Zod but unauthorable in the form — offer it, or add a ledger entry`, + ).toEqual([]); + } + }); + + it('every ledger entry still resolves on both sides', () => { + // Stops the ledger rotting into references to keys that were renamed or + // removed, and stops an `omit` outliving the omission it excuses. + for (const entry of LEDGER) { + const root = getMetadataTypeSchema(entry.type); + expect(root, `ledger references unknown metadata type '${entry.type}'`).toBeDefined(); + + const lists = nestedLists(METADATA_FORM_REGISTRY[entry.type]); + const list = lists.find((l) => l.path === entry.path); + expect(list, `${entry.type}.${entry.path}: no hand-written list at this path any more`).toBeDefined(); + + const subKeys = keysOf(subSchemaOf(root, entry.path)); + expect(subKeys, `${entry.type}.${entry.path}: sub-schema is not key-bearing any more`).toBeTruthy(); + + if (entry.kind === 'omit') { + expect(subKeys, `${entry.type}.${entry.path}.${entry.key}: not in the Zod any more`).toContain(entry.key); + expect( + list!.offered, + `${entry.type}.${entry.path}.${entry.key}: the form offers it now — drop the ledger entry`, + ).not.toContain(entry.key); + } else { + // A `subset` that covers everything is no longer a subset. + expect( + subKeys!.filter((k) => !list!.offered.includes(k)).length, + `${entry.type}.${entry.path}: the form now covers the whole schema — drop the ledger entry`, + ).toBeGreaterThan(0); + } + } + }); + + it('the ledger is not vacuous and every entry carries a reason', () => { + expect(LEDGER.length).toBeGreaterThan(0); + for (const entry of LEDGER) { + const label = `${entry.type}.${entry.path}${entry.kind === 'omit' ? `.${entry.key}` : ''}`; + expect(entry.why.length, `${label} needs a reason a reader can act on`).toBeGreaterThan(20); + } + }); +}); diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 6c393cbc9d..5292f80fdf 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -48,6 +48,7 @@ export const actionForm = defineForm({ { field: 'source', type: 'code', language: 'javascript', required: true, helpText: 'Function body source — no top-level imports' }, { field: 'capabilities', type: 'tags', helpText: 'Allowed ctx APIs (api.read, api.write, crypto.uuid, log, …)' }, { field: 'timeoutMs', type: 'number', helpText: 'Per-invocation timeout (ms)' }, + { field: 'memoryMb', type: 'number', helpText: 'Per-invocation memory cap (MB, max 256)' }, ], }, { field: 'params', type: 'repeater', helpText: 'User input parameters (show form before executing)' }, diff --git a/packages/spec/src/ui/page.form.ts b/packages/spec/src/ui/page.form.ts index cd58204e01..b4f6db878c 100644 --- a/packages/spec/src/ui/page.form.ts +++ b/packages/spec/src/ui/page.form.ts @@ -59,7 +59,8 @@ export const pageForm = defineForm({ // Explicit sub-fields so `source` can use the `ref:component` picker — // a variable's `source` names the component (by `id`) that writes it, // so it's chosen from the page's real canvas components, not typed by - // hand. Keep in sync with PageVariableSchema. + // hand. Reconciled against PageVariableSchema by + // metadata-form-zod-reconciliation.test.ts (#3786). fields: [ { field: 'name', required: true, helpText: 'Variable name — exposed to expressions as `page.`' }, { field: 'type', helpText: 'Value type' }, @@ -103,7 +104,9 @@ export const pageForm = defineForm({ // None maps to ABSENCE of userFilters — the protocol stores // "no filter bar" as omission, not a literal element: 'none'. // (`element: 'toggle'` stays valid but deprecated — not offered.) - // Keep this list in sync with InterfacePageConfigSchema. + // Reconciled against InterfacePageConfigSchema by + // metadata-form-zod-reconciliation.test.ts (#3786), which is what + // caught `sort` missing here while the schema declared it. fields: [ // ── Data ── the page defines its own data surface directly. { field: 'source', widget: 'ref:object', helpText: 'Object this page reads from' }, @@ -111,6 +114,7 @@ export const pageForm = defineForm({ // 'source'` tells the picker which object's fields to offer. { field: 'columns', widget: 'field-multi', dependsOn: 'source', helpText: 'Columns to show — defined directly on the page (blank = all object fields)' }, { field: 'filterBy', widget: 'filter-builder', dependsOn: 'source', helpText: 'Always-on base filter for the page — same visual builder as the list toolbar.' }, + { field: 'sort', type: 'repeater', dependsOn: 'source', helpText: 'Default sort order for the page, defined directly on the page.' }, { field: 'levels', helpText: 'Hierarchy levels to display (tree-like sources)' }, // ── Appearance ── { field: 'appearance', type: 'composite', disclosure: 'popover', helpText: 'Allowed visualizations (Grid / Kanban / Calendar / …) and description visibility' }, diff --git a/packages/spec/src/ui/report.form.ts b/packages/spec/src/ui/report.form.ts index 4591a38d75..9118d9d0d8 100644 --- a/packages/spec/src/ui/report.form.ts +++ b/packages/spec/src/ui/report.form.ts @@ -67,15 +67,10 @@ export const reportForm = defineForm({ { field: 'chart', type: 'composite', helpText: 'Chart config (type, legend, colors)' }, ], }, - { - label: 'Advanced', - description: 'Accessibility and performance tuning.', - collapsible: true, - collapsed: true, - fields: [ - { field: 'aria', type: 'composite', helpText: 'Accessibility labels' }, - { field: 'performance', type: 'composite', helpText: 'Caching and optimization' }, - ], - }, + // An "Advanced" section offering `aria` + `performance` used to sit here. + // #3496 pruned both keys from ReportSchema (dead in both layers) but left + // this form untouched, so the section rendered two controls whose values + // ReportSchema silently stripped on save. Removed with the keys they named; + // metadata-form-zod-reconciliation.test.ts fails on the next such prune. ], }); From 2286bb086fe6c6af9d79aee5ad4f4c4ca0b6c371 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:19:49 +0000 Subject: [PATCH 2/4] fix(spec,i18n): regenerate the form translation bundles; retire a comment whose source was deleted (#3786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ons from the form reconciliation. The metadata-form translation bundles are DERIVED from METADATA_FORM_REGISTRY, so correcting the forms moved their key set. Regenerated all four locales: the dead controls take their translations with them (fields.pii, fields.encrypted, fields.indexed, fields.filterable, fields.placeholder, fields.cascadeDelete, fields.summaryType, fields.displayFormat) and the schema-backed keys arrive (deleteBehavior, lookupFilters, expression, autonumberFormat, summaryOperations). Worth noting what those bundles were: four locales of translated labels for switches that saved nothing — the drift had propagated into a generated artifact and been dutifully translated there. `ActionAiCategorySchema` carried the same pattern in its terminal state. Its comment said it mirrored `ToolCategorySchema` in ai/tool.zod and told the next author to "update both sides" — but #3896 deleted `ToolCategorySchema` along with the inert `tool.category` key it typed. The instruction had been pointing at a source that no longer exists, sending any reader looking for a second side there is none of. The enum is canonical now and says so; no gate, because there is nothing left to reconcile against. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb --- .../en.metadata-forms.generated.ts | 130 +++++++-------- .../es-ES.metadata-forms.generated.ts | 148 ++++++++---------- .../ja-JP.metadata-forms.generated.ts | 148 ++++++++---------- .../zh-CN.metadata-forms.generated.ts | 148 ++++++++---------- packages/spec/src/ui/action.zod.ts | 12 +- 5 files changed, 255 insertions(+), 331 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index fa1e2aed99..1c80a8c336 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -78,18 +78,10 @@ export const enMetadataForms: NonNullable = { label: "Unique", helpText: "Disallow duplicate values" }, - "fields.indexed": { - label: "Indexed", - helpText: "Create a database index for faster querying" - }, "fields.readonly": { label: "Readonly", helpText: "Visible but never user-editable" }, - "fields.immutable": { - label: "Immutable", - helpText: "Editable on create, locked thereafter" - }, "fields.hidden": { label: "Hidden", helpText: "Hidden from default UI" @@ -102,18 +94,10 @@ export const enMetadataForms: NonNullable = { label: "Sortable", helpText: "Allow sorting on this column" }, - "fields.filterable": { - label: "Filterable", - helpText: "Allow filtering on this column" - }, "fields.defaultValue": { label: "Default Value", helpText: "Default value for new records (JSON literal)" }, - "fields.placeholder": { - label: "Placeholder", - helpText: "Placeholder hint" - }, "fields.maxLength": { label: "Max Length", helpText: "Max characters" @@ -162,93 +146,89 @@ export const enMetadataForms: NonNullable = { label: "Reference", helpText: "Target object (for lookup/master_detail)" }, - "fields.referenceFilter": { - label: "Reference Filter", - helpText: "CEL filter applied to the picker" + "fields.lookupFilters": { + label: "Lookup Filters", + helpText: "Filter rules applied to the picker ({field, operator, value})" }, - "fields.cascadeDelete": { - label: "Cascade Delete", - helpText: "Delete children when parent is deleted" + "fields.deleteBehavior": { + label: "Delete Behavior", + helpText: "What happens when the referenced record is deleted" }, "fields.multiple": { label: "Multiple", helpText: "Allow selecting multiple records" }, - "fields.formula": { - label: "Formula", + "fields.expression": { + label: "Expression", helpText: "CEL formula expression" }, "fields.returnType": { label: "Return Type", helpText: "Result type for formulas" }, - "fields.summaryType": { - label: "Summary Type", - helpText: "Aggregation" + "fields.summaryOperations": { + label: "Summary Operations", + helpText: "Roll-up: which child object, which field, which aggregation" + }, + "fields.summaryOperations.object": { + label: "Object", + helpText: "Source child object name" }, - "fields.summaryField": { - label: "Summary Field", - helpText: "Field on child object to aggregate" + "fields.summaryOperations.field": { + label: "Field", + helpText: "Field on the child object to aggregate (ignored for count)" }, - "fields.displayFormat": { - label: "Display Format", - helpText: "e.g. \"INV-{0000}\"" + "fields.summaryOperations.function": { + label: "Function", + helpText: "Aggregation function" }, - "fields.startingNumber": { - label: "Starting Number", - helpText: "Starting sequence value" + "fields.autonumberFormat": { + label: "Autonumber Format", + helpText: "e.g. \"INV-{0000}\"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported" }, "fields.language": { label: "Language", helpText: "Editor language (e.g. sql, javascript)" }, - "fields.validation": { - label: "Validation", - helpText: "CEL predicate — must evaluate true" - }, - "fields.errorMessage": { - label: "Error Message", - helpText: "Shown when validation fails" - }, - "fields.audit": { - label: "Audit", - helpText: "Audit changes to this field" - }, "fields.trackHistory": { label: "Track History", helpText: "Keep change history" }, - "fields.pii": { - label: "Pii", - helpText: "Personally identifiable information" + "fields.visibleWhen": { + label: "Visible When", + helpText: "CEL predicate — field is shown only when TRUE" }, - "fields.encrypted": { - label: "Encrypted", - helpText: "Encrypt at rest" + "fields.readonlyWhen": { + label: "Readonly When", + helpText: "CEL predicate — field is read-only when TRUE (enforced server-side)" }, - capabilities: { - label: "Capabilities", + "fields.requiredWhen": { + label: "Required When", + helpText: "CEL predicate — field is required when TRUE (enforced server-side)" + }, + enable: { + label: "Enable", helpText: "Enable/disable system features" }, - "capabilities.trackHistory": { + "enable.trackHistory": { label: "Track History" }, - "capabilities.searchable": { + "enable.searchable": { label: "Searchable" }, - "capabilities.apiEnabled": { + "enable.apiEnabled": { label: "Api Enabled" }, - "capabilities.files": { + "enable.files": { label: "Files" }, - "capabilities.feeds": { + "enable.feeds": { label: "Feeds" }, - "capabilities.activities": { + "enable.activities": { label: "Activities" }, - "capabilities.clone": { + "enable.clone": { label: "Clone" }, datasource: { @@ -529,6 +509,10 @@ export const enMetadataForms: NonNullable = { label: "Timeout Ms", helpText: "Per-invocation timeout (ms)" }, + "body.memoryMb": { + label: "Memory Mb", + helpText: "Per-invocation memory cap (MB, max 256)" + }, handler: { label: "Handler", helpText: "Handler function name (deprecated — prefer `body`)" @@ -819,6 +803,10 @@ export const enMetadataForms: NonNullable = { label: "Filter By", helpText: "Always-on base filter for the page — same visual builder as the list toolbar." }, + "interfaceConfig.sort": { + label: "Sort", + helpText: "Default sort order for the page, defined directly on the page." + }, "interfaceConfig.levels": { label: "Levels", helpText: "Hierarchy levels to display (tree-like sources)" @@ -1111,6 +1099,10 @@ export const enMetadataForms: NonNullable = { label: "Timeout Ms", helpText: "Per-invocation timeout (ms)" }, + "body.memoryMb": { + label: "Memory Mb", + helpText: "Per-invocation memory cap (MB, max 256)" + }, params: { label: "Params", helpText: "User input parameters (show form before executing)" @@ -1179,10 +1171,6 @@ export const enMetadataForms: NonNullable = { filter_and_chart: { label: "Filter & chart", description: "Report-level filters and chart presentation." - }, - advanced: { - label: "Advanced", - description: "Accessibility and performance tuning." } }, fields: { @@ -1235,14 +1223,6 @@ export const enMetadataForms: NonNullable = { chart: { label: "Chart", helpText: "Chart config (type, legend, colors)" - }, - aria: { - label: "Aria", - helpText: "Accessibility labels" - }, - performance: { - label: "Performance", - helpText: "Caching and optimization" } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 2971e5b629..5b7712bdfc 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -78,18 +78,10 @@ export const esESMetadataForms: NonNullable = label: "Único", helpText: "No permite valores duplicados" }, - "fields.indexed": { - label: "Indexado", - helpText: "Crea un índice de base de datos para consultas más rápidas" - }, "fields.readonly": { label: "Solo lectura", helpText: "Visible, pero nunca editable por usuarios" }, - "fields.immutable": { - label: "Inmutable", - helpText: "Editable al crear; bloqueado después" - }, "fields.hidden": { label: "Oculto", helpText: "Oculto en la UI predeterminada" @@ -102,18 +94,10 @@ export const esESMetadataForms: NonNullable = label: "Ordenable", helpText: "Permitir ordenar por esta columna" }, - "fields.filterable": { - label: "Filtrable", - helpText: "Permitir filtrar por esta columna" - }, "fields.defaultValue": { label: "Valor predeterminado", helpText: "Valor predeterminado para registros nuevos (literal JSON)" }, - "fields.placeholder": { - label: "Marcador", - helpText: "Texto de marcador" - }, "fields.maxLength": { label: "Longitud máxima", helpText: "Máximo de caracteres" @@ -162,94 +146,90 @@ export const esESMetadataForms: NonNullable = label: "Referencia", helpText: "Objeto de destino (para lookup/master_detail)" }, - "fields.referenceFilter": { - label: "Filtro de referencia", - helpText: "Filtro CEL aplicado al selector" + "fields.lookupFilters": { + label: "Lookup Filters", + helpText: "Filter rules applied to the picker ({field, operator, value})" }, - "fields.cascadeDelete": { - label: "Eliminación en cascada", - helpText: "Eliminar registros hijos cuando se elimine el padre" + "fields.deleteBehavior": { + label: "Delete Behavior", + helpText: "What happens when the referenced record is deleted" }, "fields.multiple": { label: "Selección múltiple", helpText: "Permitir seleccionar varios registros" }, - "fields.formula": { - label: "Fórmula", - helpText: "Expresión de fórmula CEL" + "fields.expression": { + label: "Expression", + helpText: "CEL formula expression" }, "fields.returnType": { label: "Tipo de retorno", helpText: "Tipo de resultado para fórmulas" }, - "fields.summaryType": { - label: "Tipo de resumen", - helpText: "Agregación" + "fields.summaryOperations": { + label: "Summary Operations", + helpText: "Roll-up: which child object, which field, which aggregation" }, - "fields.summaryField": { - label: "Campo de resumen", - helpText: "Campo del objeto hijo que se agregará" + "fields.summaryOperations.object": { + label: "Object", + helpText: "Source child object name" + }, + "fields.summaryOperations.field": { + label: "Field", + helpText: "Field on the child object to aggregate (ignored for count)" }, - "fields.displayFormat": { - label: "Formato de visualización", - helpText: "p. ej. \"INV-{0000}\"" + "fields.summaryOperations.function": { + label: "Function", + helpText: "Aggregation function" }, - "fields.startingNumber": { - label: "Número inicial", - helpText: "Valor inicial de la secuencia" + "fields.autonumberFormat": { + label: "Autonumber Format", + helpText: "e.g. \"INV-{0000}\"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported" }, "fields.language": { label: "Idioma", helpText: "Lenguaje del editor (p. ej. sql, javascript)" }, - "fields.validation": { - label: "Validación", - helpText: "Predicado CEL; debe evaluar a true" - }, - "fields.errorMessage": { - label: "Mensaje de error", - helpText: "Se muestra cuando falla la validación" - }, - "fields.audit": { - label: "Auditoría", - helpText: "Auditar cambios en este campo" - }, "fields.trackHistory": { label: "Seguimiento de historial", helpText: "Conservar historial de cambios" }, - "fields.pii": { - label: "Información personal", - helpText: "Información de identificación personal" + "fields.visibleWhen": { + label: "Visible When", + helpText: "CEL predicate — field is shown only when TRUE" }, - "fields.encrypted": { - label: "Cifrado", - helpText: "Cifrar en reposo" + "fields.readonlyWhen": { + label: "Readonly When", + helpText: "CEL predicate — field is read-only when TRUE (enforced server-side)" }, - capabilities: { - label: "Capacidades", - helpText: "Activa/desactiva funciones del sistema" + "fields.requiredWhen": { + label: "Required When", + helpText: "CEL predicate — field is required when TRUE (enforced server-side)" }, - "capabilities.trackHistory": { - label: "Seguimiento de historial" + enable: { + label: "Enable", + helpText: "Enable/disable system features" }, - "capabilities.searchable": { - label: "Buscable" + "enable.trackHistory": { + label: "Track History" }, - "capabilities.apiEnabled": { - label: "API activada" + "enable.searchable": { + label: "Searchable" }, - "capabilities.files": { - label: "Archivos" + "enable.apiEnabled": { + label: "Api Enabled" }, - "capabilities.feeds": { - label: "Feed de actividad" + "enable.files": { + label: "Files" }, - "capabilities.activities": { - label: "Actividades" + "enable.feeds": { + label: "Feeds" }, - "capabilities.clone": { - label: "Clonar" + "enable.activities": { + label: "Activities" + }, + "enable.clone": { + label: "Clone" }, datasource: { label: "Fuente de datos", @@ -529,6 +509,10 @@ export const esESMetadataForms: NonNullable = label: "Tiempo de espera (ms)", helpText: "Tiempo de espera por invocación (ms)" }, + "body.memoryMb": { + label: "Memory Mb", + helpText: "Per-invocation memory cap (MB, max 256)" + }, handler: { label: "Manejador", helpText: "Nombre de función manejadora (obsoleto — preferir `body`)" @@ -819,6 +803,10 @@ export const esESMetadataForms: NonNullable = label: "Filter By", helpText: "Always-on base filter for the page — same visual builder as the list toolbar." }, + "interfaceConfig.sort": { + label: "Sort", + helpText: "Default sort order for the page, defined directly on the page." + }, "interfaceConfig.levels": { label: "Levels", helpText: "Hierarchy levels to display (tree-like sources)" @@ -1111,6 +1099,10 @@ export const esESMetadataForms: NonNullable = label: "Timeout Ms", helpText: "Per-invocation timeout (ms)" }, + "body.memoryMb": { + label: "Memory Mb", + helpText: "Per-invocation memory cap (MB, max 256)" + }, params: { label: "Parámetros", helpText: "Parámetros de entrada de usuario (muestra el formulario antes de ejecutar)" @@ -1179,10 +1171,6 @@ export const esESMetadataForms: NonNullable = filter_and_chart: { label: "Filtro y gráfico", description: "Filtros a nivel de informe y presentación del gráfico." - }, - advanced: { - label: "Avanzado", - description: "Ajustes de accesibilidad y rendimiento." } }, fields: { @@ -1235,14 +1223,6 @@ export const esESMetadataForms: NonNullable = chart: { label: "Gráfico", helpText: "Configuración de gráfico (type, legend, colors)" - }, - aria: { - label: "Accesibilidad", - helpText: "Etiquetas de accesibilidad" - }, - performance: { - label: "Rendimiento", - helpText: "Caché y optimización" } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 2d397e42bf..f43215c40c 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -78,18 +78,10 @@ export const jaJPMetadataForms: NonNullable = label: "一意", helpText: "重複値を許可しない" }, - "fields.indexed": { - label: "インデックス済み", - helpText: "高速検索のためにデータベースインデックスを作成" - }, "fields.readonly": { label: "読み取り専用", helpText: "表示されるがユーザーは編集不可" }, - "fields.immutable": { - label: "変更不可", - helpText: "作成時のみ編集可能、その後はロック" - }, "fields.hidden": { label: "非表示", helpText: "既定 UI では非表示" @@ -102,18 +94,10 @@ export const jaJPMetadataForms: NonNullable = label: "並び替え可能", helpText: "この列での並び替えを許可" }, - "fields.filterable": { - label: "フィルター可能", - helpText: "この列でのフィルターを許可" - }, "fields.defaultValue": { label: "既定値", helpText: "新規レコードの既定値(JSON リテラル)" }, - "fields.placeholder": { - label: "プレースホルダー", - helpText: "プレースホルダーのヒント" - }, "fields.maxLength": { label: "最大長", helpText: "最大文字数" @@ -162,94 +146,90 @@ export const jaJPMetadataForms: NonNullable = label: "参照", helpText: "対象オブジェクト(lookup/master_detail 用)" }, - "fields.referenceFilter": { - label: "参照フィルター", - helpText: "ピッカーに適用する CEL フィルター" + "fields.lookupFilters": { + label: "Lookup Filters", + helpText: "Filter rules applied to the picker ({field, operator, value})" }, - "fields.cascadeDelete": { - label: "カスケード削除", - helpText: "親の削除時に子も削除" + "fields.deleteBehavior": { + label: "Delete Behavior", + helpText: "What happens when the referenced record is deleted" }, "fields.multiple": { label: "複数選択", helpText: "複数レコードの選択を許可" }, - "fields.formula": { - label: "数式", - helpText: "CEL 公式式" + "fields.expression": { + label: "Expression", + helpText: "CEL formula expression" }, "fields.returnType": { label: "戻り値の型", helpText: "公式の結果型" }, - "fields.summaryType": { - label: "集計タイプ", - helpText: "集計方法" + "fields.summaryOperations": { + label: "Summary Operations", + helpText: "Roll-up: which child object, which field, which aggregation" }, - "fields.summaryField": { - label: "集計フィールド", - helpText: "集計対象の子オブジェクトのフィールド" + "fields.summaryOperations.object": { + label: "Object", + helpText: "Source child object name" + }, + "fields.summaryOperations.field": { + label: "Field", + helpText: "Field on the child object to aggregate (ignored for count)" }, - "fields.displayFormat": { - label: "表示形式", - helpText: "例: \"INV-{0000}\"" + "fields.summaryOperations.function": { + label: "Function", + helpText: "Aggregation function" }, - "fields.startingNumber": { - label: "開始番号", - helpText: "連番の開始値" + "fields.autonumberFormat": { + label: "Autonumber Format", + helpText: "e.g. \"INV-{0000}\"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported" }, "fields.language": { label: "言語", helpText: "エディター言語(例: sql, javascript)" }, - "fields.validation": { - label: "検証", - helpText: "CEL 述語。true と評価される必要があります" - }, - "fields.errorMessage": { - label: "エラーメッセージ", - helpText: "検証失敗時に表示" - }, - "fields.audit": { - label: "監査", - helpText: "このフィールドの変更を監査" - }, "fields.trackHistory": { label: "履歴追跡", helpText: "変更履歴を保持" }, - "fields.pii": { - label: "個人情報", - helpText: "個人識別情報" + "fields.visibleWhen": { + label: "Visible When", + helpText: "CEL predicate — field is shown only when TRUE" }, - "fields.encrypted": { - label: "暗号化", - helpText: "保存時に暗号化" + "fields.readonlyWhen": { + label: "Readonly When", + helpText: "CEL predicate — field is read-only when TRUE (enforced server-side)" }, - capabilities: { - label: "機能", - helpText: "システム機能の有効/無効" + "fields.requiredWhen": { + label: "Required When", + helpText: "CEL predicate — field is required when TRUE (enforced server-side)" }, - "capabilities.trackHistory": { - label: "履歴追跡" + enable: { + label: "Enable", + helpText: "Enable/disable system features" }, - "capabilities.searchable": { - label: "検索可能" + "enable.trackHistory": { + label: "Track History" }, - "capabilities.apiEnabled": { - label: "API 有効" + "enable.searchable": { + label: "Searchable" }, - "capabilities.files": { - label: "ファイル" + "enable.apiEnabled": { + label: "Api Enabled" }, - "capabilities.feeds": { - label: "フィード" + "enable.files": { + label: "Files" }, - "capabilities.activities": { - label: "活動" + "enable.feeds": { + label: "Feeds" }, - "capabilities.clone": { - label: "複製" + "enable.activities": { + label: "Activities" + }, + "enable.clone": { + label: "Clone" }, datasource: { label: "データソース", @@ -529,6 +509,10 @@ export const jaJPMetadataForms: NonNullable = label: "タイムアウト(ms)", helpText: "呼び出しごとのタイムアウト(ms)" }, + "body.memoryMb": { + label: "Memory Mb", + helpText: "Per-invocation memory cap (MB, max 256)" + }, handler: { label: "ハンドラー", helpText: "ハンドラー関数名(非推奨 — `body` を推奨)" @@ -819,6 +803,10 @@ export const jaJPMetadataForms: NonNullable = label: "Filter By", helpText: "Always-on base filter for the page — same visual builder as the list toolbar." }, + "interfaceConfig.sort": { + label: "Sort", + helpText: "Default sort order for the page, defined directly on the page." + }, "interfaceConfig.levels": { label: "Levels", helpText: "Hierarchy levels to display (tree-like sources)" @@ -1111,6 +1099,10 @@ export const jaJPMetadataForms: NonNullable = label: "Timeout Ms", helpText: "Per-invocation timeout (ms)" }, + "body.memoryMb": { + label: "Memory Mb", + helpText: "Per-invocation memory cap (MB, max 256)" + }, params: { label: "パラメーター", helpText: "ユーザー入力パラメーター(実行前にフォームを表示)" @@ -1179,10 +1171,6 @@ export const jaJPMetadataForms: NonNullable = filter_and_chart: { label: "フィルターとチャート", description: "レポートレベルのフィルターとチャート表示。" - }, - advanced: { - label: "詳細", - description: "アクセシビリティとパフォーマンス調整。" } }, fields: { @@ -1235,14 +1223,6 @@ export const jaJPMetadataForms: NonNullable = chart: { label: "チャート", helpText: "チャート設定(type, legend, colors)" - }, - aria: { - label: "アクセシビリティ", - helpText: "アクセシビリティラベル" - }, - performance: { - label: "パフォーマンス", - helpText: "キャッシュと最適化" } } }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index bc567dd234..94999027f2 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -78,18 +78,10 @@ export const zhCNMetadataForms: NonNullable = label: "唯一", helpText: "不允许重复值" }, - "fields.indexed": { - label: "已索引", - helpText: "创建数据库索引以加快查询" - }, "fields.readonly": { label: "只读", helpText: "可见,但用户不可编辑" }, - "fields.immutable": { - label: "不可变", - helpText: "创建时可编辑,之后锁定" - }, "fields.hidden": { label: "隐藏", helpText: "在默认界面中隐藏" @@ -102,18 +94,10 @@ export const zhCNMetadataForms: NonNullable = label: "可排序", helpText: "允许按此列排序" }, - "fields.filterable": { - label: "可筛选", - helpText: "允许按此列筛选" - }, "fields.defaultValue": { label: "默认值", helpText: "新记录的默认值(JSON 字面量)" }, - "fields.placeholder": { - label: "占位符", - helpText: "占位提示" - }, "fields.maxLength": { label: "最大长度", helpText: "最大字符数" @@ -162,94 +146,90 @@ export const zhCNMetadataForms: NonNullable = label: "引用对象", helpText: "目标对象(用于 lookup / master_detail)" }, - "fields.referenceFilter": { - label: "引用过滤", - helpText: "应用于选择器的 CEL 过滤条件" + "fields.lookupFilters": { + label: "Lookup Filters", + helpText: "Filter rules applied to the picker ({field, operator, value})" }, - "fields.cascadeDelete": { - label: "级联删除", - helpText: "删除父记录时一并删除子记录" + "fields.deleteBehavior": { + label: "Delete Behavior", + helpText: "What happens when the referenced record is deleted" }, "fields.multiple": { label: "多选", helpText: "允许选择多条记录" }, - "fields.formula": { - label: "公式", - helpText: "CEL 公式表达式" + "fields.expression": { + label: "Expression", + helpText: "CEL formula expression" }, "fields.returnType": { label: "返回类型", helpText: "公式结果类型" }, - "fields.summaryType": { - label: "汇总类型", - helpText: "聚合方式" + "fields.summaryOperations": { + label: "Summary Operations", + helpText: "Roll-up: which child object, which field, which aggregation" }, - "fields.summaryField": { - label: "汇总字段", - helpText: "要聚合的子对象字段" + "fields.summaryOperations.object": { + label: "Object", + helpText: "Source child object name" + }, + "fields.summaryOperations.field": { + label: "Field", + helpText: "Field on the child object to aggregate (ignored for count)" }, - "fields.displayFormat": { - label: "显示格式", - helpText: "例如 \"INV-{0000}\"" + "fields.summaryOperations.function": { + label: "Function", + helpText: "Aggregation function" }, - "fields.startingNumber": { - label: "起始编号", - helpText: "序列起始值" + "fields.autonumberFormat": { + label: "Autonumber Format", + helpText: "e.g. \"INV-{0000}\"; date tokens {YYYY}/{MM}/{DD} and {field_name} interpolation supported" }, "fields.language": { label: "语言", helpText: "编辑器语言(如 sql、javascript)" }, - "fields.validation": { - label: "校验", - helpText: "CEL 谓词,必须求值为 true" - }, - "fields.errorMessage": { - label: "错误消息", - helpText: "校验失败时显示" - }, - "fields.audit": { - label: "审计", - helpText: "审计此字段的变更" - }, "fields.trackHistory": { label: "历史跟踪", helpText: "保留变更历史" }, - "fields.pii": { - label: "个人信息", - helpText: "个人身份信息" + "fields.visibleWhen": { + label: "Visible When", + helpText: "CEL predicate — field is shown only when TRUE" }, - "fields.encrypted": { - label: "加密", - helpText: "静态加密存储" + "fields.readonlyWhen": { + label: "Readonly When", + helpText: "CEL predicate — field is read-only when TRUE (enforced server-side)" }, - capabilities: { - label: "功能", - helpText: "启用或禁用系统功能" + "fields.requiredWhen": { + label: "Required When", + helpText: "CEL predicate — field is required when TRUE (enforced server-side)" }, - "capabilities.trackHistory": { - label: "历史跟踪" + enable: { + label: "Enable", + helpText: "Enable/disable system features" }, - "capabilities.searchable": { - label: "可搜索" + "enable.trackHistory": { + label: "Track History" }, - "capabilities.apiEnabled": { - label: "启用 API" + "enable.searchable": { + label: "Searchable" }, - "capabilities.files": { - label: "文件" + "enable.apiEnabled": { + label: "Api Enabled" }, - "capabilities.feeds": { - label: "动态" + "enable.files": { + label: "Files" }, - "capabilities.activities": { - label: "活动" + "enable.feeds": { + label: "Feeds" }, - "capabilities.clone": { - label: "克隆" + "enable.activities": { + label: "Activities" + }, + "enable.clone": { + label: "Clone" }, datasource: { label: "数据源", @@ -529,6 +509,10 @@ export const zhCNMetadataForms: NonNullable = label: "超时(毫秒)", helpText: "单次调用超时时间(毫秒)" }, + "body.memoryMb": { + label: "Memory Mb", + helpText: "Per-invocation memory cap (MB, max 256)" + }, handler: { label: "处理器", helpText: "处理器函数名(已废弃——建议使用 body)" @@ -819,6 +803,10 @@ export const zhCNMetadataForms: NonNullable = label: "Filter By", helpText: "Always-on base filter for the page — same visual builder as the list toolbar." }, + "interfaceConfig.sort": { + label: "Sort", + helpText: "Default sort order for the page, defined directly on the page." + }, "interfaceConfig.levels": { label: "Levels", helpText: "Hierarchy levels to display (tree-like sources)" @@ -1111,6 +1099,10 @@ export const zhCNMetadataForms: NonNullable = label: "Timeout Ms", helpText: "Per-invocation timeout (ms)" }, + "body.memoryMb": { + label: "Memory Mb", + helpText: "Per-invocation memory cap (MB, max 256)" + }, params: { label: "参数", helpText: "执行前向用户收集的输入参数" @@ -1179,10 +1171,6 @@ export const zhCNMetadataForms: NonNullable = filter_and_chart: { label: "筛选与图表", description: "条件与图表展示" - }, - advanced: { - label: "高级设置", - description: "性能与无障碍" } }, fields: { @@ -1235,14 +1223,6 @@ export const zhCNMetadataForms: NonNullable = chart: { label: "图表", helpText: "图表类型与配置" - }, - aria: { - label: "无障碍", - helpText: "无障碍标签与角色" - }, - performance: { - label: "性能", - helpText: "性能与缓存策略" } } }, diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index ba62a4f649..be03e58b72 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -292,10 +292,14 @@ export type ActionLocation = z.infer; /** * Tool category values for {@link ActionAiSchema.category}. * - * Mirrors `ToolCategorySchema` in `../ai/tool.zod`. Kept **inline** rather - * than imported to avoid a `ui → ai` import cycle (`ai/*.form.ts` already - * imports `defineForm` from `ui/view.zod`). If you change the canonical - * tool categories, update both sides. + * **Canonical.** This was a hand-copy of `ToolCategorySchema` in + * `../ai/tool.zod`, kept inline rather than imported to avoid a `ui → ai` + * cycle, under a comment telling the next author to update both sides. #3896 + * removed `ToolCategorySchema` along with the inert `tool.category` key it + * typed — which left that instruction pointing at a source that no longer + * exists, and a reader hunting for a second side there is none of. This enum + * is now the only declaration of the vocabulary: change it here, nowhere + * else. (#3786 — comments are not a mechanism, and they rot silently.) */ const ActionAiCategorySchema = z.enum([ 'data', From 58d7204761fc683d462d06cbcc9226d62ecc88f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:24:31 +0000 Subject: [PATCH 3/4] refactor(spec,rest): derive the translatable-metadata-type set from the translator dispatch (#3786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rest-server.ts` carried `TRANSLATABLE_META_TYPES` — a literal `Set(['view','action','object','app','dashboard','page'])` under a comment asking the next author to keep it in step with `translateMetadataDocument`'s type dispatch in spec. The two agreed today, but nothing made them: adding a translator in spec would have left the REST boundary serving that type untranslated, silently, which is the #3786 shape exactly. `translateMetadataDocument`'s `if` chain becomes a dispatch table, and the type list is exported as `TRANSLATABLE_METADATA_TYPES` derived from its keys. rest reads that instead of restating it. One declaration, no gate needed — the second list is gone rather than checked, which is the better half of the derive-or-gate prescription. The lookup stays lazily imported and is now memoised, so `spec/system` remains off rest's module-init path exactly as before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb --- packages/rest/src/rest-server.ts | 29 ++++++++++++---- packages/spec/api-surface.json | 1 + packages/spec/src/system/i18n-resolver.ts | 40 +++++++++++++++++++---- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c8d4b940fb..840b6b8f0e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -47,11 +47,28 @@ const logError = (...args: unknown[]) => (globalThis as any).console?.error(...a const logWarn = (...args: unknown[]) => ((globalThis as any).console?.warn ?? (globalThis as any).console?.error)?.(...args); /** - * Metadata types whose user-facing labels are localized at the REST boundary - * via `translateMetadataDocument`. Keep in sync with the type dispatch in - * `@objectstack/spec/system`'s `translateMetadataDocument`. + * Whether a metadata type's user-facing labels are localized at the REST + * boundary by `translateMetadataDocument`. + * + * DERIVED from the spec's translator dispatch. This used to be a hand-copied + * literal set under a "keep in sync with the type dispatch" comment — the + * shape #3786 was filed about: adding a translator in spec silently left the + * REST boundary serving that type untranslated, with no error anywhere. + * Reading the answer from `TRANSLATABLE_METADATA_TYPES` means there is no + * second list to forget. + * + * Resolved lazily and memoised, so `@objectstack/spec/system` stays off the + * module-init path exactly as it was before — the same `await import` the + * translate helpers below already perform, and a module-cache hit after the + * first call. */ -const TRANSLATABLE_META_TYPES = new Set(['view', 'action', 'object', 'app', 'dashboard', 'page']); +let translatableMetaTypes: ReadonlySet | undefined; +async function isTranslatableMetaType(type: string): Promise { + if (!translatableMetaTypes) { + ({ TRANSLATABLE_METADATA_TYPES: translatableMetaTypes } = await import('@objectstack/spec/system')); + } + return translatableMetaTypes.has(type); +} /** @@ -1897,7 +1914,7 @@ export class RestServer { */ private async translateMetaItem(req: any, type: string, environmentId: string | undefined, item: any, i18nService?: any): Promise { if (!item || typeof item !== 'object') return item; - if (!TRANSLATABLE_META_TYPES.has(type)) return item; + if (!(await isTranslatableMetaType(type))) return item; // The cached read path resolves the i18n service up-front (to build a // locale-aware ETag) and passes it here so we don't repeat the // potentially registry-hitting lookup on every request. @@ -1926,7 +1943,7 @@ export class RestServer { * Translate a list of metadata documents using `translateMetaItem`. */ private async translateMetaItems(req: any, type: string, environmentId: string | undefined, items: any): Promise { - if (!TRANSLATABLE_META_TYPES.has(type)) return items; + if (!(await isTranslatableMetaType(type))) return items; // `getMetaItems` may hand back a bare array or an `{ items: [...] }` // envelope. Unwrap so list responses are localized the same way the // single-item route is; a non-array, non-envelope value is returned diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index afec02227a..f08064b165 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1187,6 +1187,7 @@ "SystemObjectName (type)", "SystemUserId (type)", "TASK_PRIORITY_VALUES (const)", + "TRANSLATABLE_METADATA_TYPES (const)", "TRANSLATE_PLACEHOLDER (const)", "Task (type)", "TaskExecutionResult (type)", diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 985601e81f..f17aa3885a 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -354,6 +354,37 @@ export function translateAction( }; } +/** + * The dispatch table {@link translateMetadataDocument} routes on — and the one + * declaration of "which metadata types get localized at all". + * + * It is a table rather than an `if` chain so the type list is readable as data + * by callers that need to know the answer BEFORE calling. `@objectstack/rest` + * needed exactly that and kept a hand-copied `TRANSLATABLE_META_TYPES` set + * under a "keep in sync with the type dispatch" comment; it is now derived from + * {@link TRANSLATABLE_METADATA_TYPES} instead, so adding a translator here + * reaches the REST boundary with nothing else to remember (#3786). + */ +const METADATA_DOCUMENT_TRANSLATORS: Record< + string, + (doc: any, bundle: TranslationBundle | undefined, opts?: ResolveOptions) => any +> = { + view: translateView, + action: translateAction, + object: translateObject, + app: translateApp, + dashboard: translateDashboard, + page: translatePage, +}; + +/** + * Metadata types whose user-facing labels {@link translateMetadataDocument} + * localizes. Derived from the dispatch table — never restate it. + */ +export const TRANSLATABLE_METADATA_TYPES: ReadonlySet = new Set( + Object.keys(METADATA_DOCUMENT_TRANSLATORS), +); + /** * Generic metadata translator: dispatches to `translateView` / * `translateAction` based on metadata type. Returns the original document @@ -371,13 +402,8 @@ export function translateMetadataDocument( opts?: ResolveOptions, ): any { if (!doc || typeof doc !== 'object') return doc; - if (type === 'view') return translateView(doc, bundle, opts); - if (type === 'action') return translateAction(doc, bundle, opts); - if (type === 'object') return translateObject(doc, bundle, opts); - if (type === 'app') return translateApp(doc, bundle, opts); - if (type === 'dashboard') return translateDashboard(doc, bundle, opts); - if (type === 'page') return translatePage(doc, bundle, opts); - return doc; + const translate = METADATA_DOCUMENT_TRANSLATORS[type]; + return translate ? translate(doc, bundle, opts) : doc; } // ──────────────────────────────────────────────────────────────────────────── From 0258ba3c51f67caa931c0bc68996e0c02159bc21 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:27:05 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20changeset=20for=20the=20metadata?= =?UTF-8?q?=20form=20=E2=86=94=20Zod=20reconciliation=20(#3786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb --- .../metadata-form-zod-reconciliation.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .changeset/metadata-form-zod-reconciliation.md diff --git a/.changeset/metadata-form-zod-reconciliation.md b/.changeset/metadata-form-zod-reconciliation.md new file mode 100644 index 0000000000..92c60435b1 --- /dev/null +++ b/.changeset/metadata-form-zod-reconciliation.md @@ -0,0 +1,79 @@ +--- +"@objectstack/spec": minor +"@objectstack/rest": patch +"@objectstack/platform-objects": patch +--- + +fix(spec,rest): the metadata forms save what they show — form ↔ Zod reconciliation (#3786) + +Every entry in `METADATA_FORM_REGISTRY` is a hand-written `defineForm` layout +that names keys of a Zod schema it never imports: two descriptions of one key +set, a comment asking the next author to keep them in step, and nothing that +fails when they don't. #3786 asked for a sweep of that shape across the repo. +**Four of the seventeen forms had already drifted, every one of them silently.** + +The silence is the point. `ObjectSchema` / `FieldSchema` are deliberately not +`.strict()`, so a key the schema does not declare parses clean and is stripped +on the way to storage — the same ADR-0104 failure class the `field.zod.ts` +prune tombstone already describes in prose. An admin toggled a switch in +Studio, got no error, and the value never landed. + +**What was broken, from an author's seat:** + +- **Object → Capabilities.** The block bound to `capabilities`; the + `ObjectSchema` key is `enable`. All seven toggles (Track history, Searchable, + API enabled, Files, Feeds, Activities, Clone) saved nothing. +- **Object → Fields.** The inline column grid offered 16 keys `FieldSchema` has + never declared. `PII`, `Encrypted`, `Indexed`, `Immutable`, `Filterable`, + `Placeholder`, `Validation`/`Error message` and `Starting number` were + controls with no storage behind them at all; the rest named keys the schema + had **renamed** and the form never followed: + `referenceFilter` → `lookupFilters`, `cascadeDelete` → `deleteBehavior` + (a three-way enum, not a boolean), `formula` → `expression`, + `displayFormat` → `autonumberFormat`, and the flat `summaryType` / + `summaryField` pair → the single `summaryOperations` object, which also + restores the `object` key the flat pair had no slot for. Roll-ups authored in + that grid saved nothing. +- **Report → Advanced.** `aria` and `performance` were pruned from + `ReportSchema` by #3496; the form kept rendering both. +- **Hook / Action → Body.** `memoryMb` was unauthorable — named in + `hook.form.ts`'s own doc comment, absent from the list beneath it. +- **Page → Interface.** `interfaceConfig.sort` was unauthorable, so a page's + default sort order could not be set in Studio at all. + +**No authored metadata changes and nothing you can write is removed.** These +were UI controls that never persisted; every corrected key is one `FieldSchema` +/ `ObjectSchema` already accepted. Metadata authored in YAML/TS was always +validated against the real schema and is unaffected. If you had been filling +those Studio controls expecting them to stick, they now either work (the +renamed five) or are gone rather than lying to you. + +The metadata-form translation bundles are derived from the registry, so all +four locales are regenerated. Worth naming what they contained: translated +labels, in four languages, for switches that saved nothing — the drift had +propagated into a generated artifact and been dutifully translated there. + +**The mechanism.** `metadata-form-zod-reconciliation.test.ts` walks every +registered form and reconciles it against `getMetadataTypeSchema()`. The two +directions are deliberately asymmetric: **form-only** (a control whose value is +discarded) is always a defect and cannot be excused, because no design wants +one; **zod-only** is ledgerable with a reason, for a deprecated key held back +from new authoring or a curated quick-add subset that defers to a fuller +editor. Ledger entries are checked for non-vacuity and for still resolving on +both sides, per the #4045 / #4040 discipline. Verified by mutation — re-adding +a stripped key, dropping a covered key, and offering a ledgered omission each +turn the gate red. + +**New export: `TRANSLATABLE_METADATA_TYPES`** (`@objectstack/spec/system`), the +set of metadata types whose labels `translateMetadataDocument` localizes, +derived from its dispatch table rather than restated. `@objectstack/rest` had +been carrying a hand-copied literal set under a "keep in sync with the type +dispatch" comment; it now reads this instead. Registering a translator in spec +reaches the REST boundary with nothing else to remember — the second list is +deleted rather than checked, which is the better half of derive-or-gate. + +Also corrected: `ActionAiCategorySchema`'s comment claimed it mirrored +`ToolCategorySchema` in `ai/tool.zod` and told the next author to update both +sides — but #3896 deleted `ToolCategorySchema` along with the inert +`tool.category` key it typed. The instruction had been pointing at a source +that no longer exists. The enum is canonical now and says so.