From a6f19eb2f2cfed295c6edb067c04bfcab4622c38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:05:59 +0000 Subject: [PATCH 1/6] feat(spec): curated fieldGroups and indexes repeaters on the object form (#19332, flight G2a) The object form offers object.fieldGroups and object.indexes as repeaters with declared sub-rows: the six canonical field-group keys, and the three keys the SQL driver reads for an index, with unique offering only the global and organization scopes. The three deprecated collapse aliases of a field group get nested omit rows in the reconciliation ledger. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- packages/spec/src/data/object.form.ts | 78 +++++++++++++++++++ .../metadata-form-zod-reconciliation.test.ts | 30 +++++++ 2 files changed, 108 insertions(+) diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 55a62eb637b..763c53eabda 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -59,6 +59,49 @@ export const objectForm = defineForm({ // `searchable-field-unknown`, both at `error`), and so does `os validate`. { field: 'highlightFields', widget: 'string-tags', colSpan: 2, helpText: 'Field names of this object, most important first — the first entry wins where only one fits (ADR-0085). Drives the default list columns, cards, child-record previews and the detail highlight strip. A name that is not a field of this object is refused at publish.' }, { field: 'searchableFields', widget: 'string-tags', colSpan: 2, helpText: 'Field names the $search query matches (ADR-0061): the default for the record picker, list quick-search and global search; a view may narrow it. Unset, search uses the name/title field plus short-text fields. Each entry must name a stored field of this object — an unknown name or a virtual formula field is refused at publish.' }, + // #19332 (flight G2a of ruling record 5861442317) — the object's field + // groups, the ADR-0085 layout role beside `nameField` / `highlightFields`. + // A repeater whose sub-rows are DECLARED, the `fields.options` repeater's + // face below, for two reasons: + // + // - A derived repeater renders every key of the entry schema, and three + // of its nine are the `[DEPRECATED → collapse]` aliases + // (`defaultExpanded`, `collapsible`, `collapsed`). Declaring the six + // canonical keys keeps the aliases off screen; each alias carries a + // nested `omit` row in the reconciliation ledger. + // - A declared sub-row's label and help text reach the translation + // catalogs, which a schema-derived one never does. + // + // Each sub-row copies the face a registered row already gives the same + // node: `key` / `label` / `icon` the plain text rows above, `description` + // the textarea row above, `collapse` a select over an enum whose three + // members are all spellable option values, and `visibleWhen` the + // `type: 'code'` / `language: 'expression'` predicate rows of the + // `fields` grid. An edit merges into the stored entry, so an alias an + // entry already carries survives a save, and a `collapse` set here + // outranks it (the parse derives `collapse` from an alias only when + // `collapse` is absent). + // + // No field-name list lives here: a field joins a group through its own + // `group` key, so there is no name for a misspelling to hide in. + { + field: 'fieldGroups', + type: 'repeater', + colSpan: 2, + helpText: 'Ordered sections that group this object\'s fields on the entry form and the record detail page (ADR-0085); array order is display order. A field joins a group by naming its key in the field\'s own group setting. Fields in no group follow the groups, and a group no field joins is not drawn.', + fields: [ + { field: 'key', label: 'Key', type: 'text', required: true, helpText: 'Machine key in snake_case, unique within this object — the schema refuses anything else. Fields join the group by naming this key, so renaming it leaves them ungrouped.' }, + { field: 'label', label: 'Label', type: 'text', required: true, helpText: 'Header text of the group\'s section.' }, + { field: 'icon', label: 'Icon', type: 'text', helpText: 'Lucide icon name shown beside the header on the record detail page (e.g. "banknote"). The entry form does not show it.' }, + { field: 'description', label: 'Description', type: 'textarea', helpText: 'Text shown under the header, on the entry form and the record detail page.' }, + { field: 'collapse', label: 'Collapse', type: 'select', helpText: 'Whether the section can be collapsed, on the entry form and the record detail page. Unset: none.', options: [ + { label: 'None — always open, no toggle', value: 'none' }, + { label: 'Expanded — collapsible, starts open', value: 'expanded' }, + { label: 'Collapsed — collapsible, starts closed', value: 'collapsed' }, + ] }, + { field: 'visibleWhen', label: 'Visible When', type: 'code', language: 'expression', helpText: 'CEL predicate over the record (e.g. record.type == \'invoice\') — the entry form shows the whole group, header included, only while it is TRUE.' }, + ], + }, ], }, { @@ -423,6 +466,41 @@ export const objectForm = defineForm({ // row's price of admission. { field: 'validations', widget: 'json', helpText: 'Object-level validation rules — an array of rule objects, e.g. [{ "type": "script", "name": "amount_positive", "condition": "amount > 0", "message": "Amount must be positive" }]. State-machine transition tables are declared here too (ADR-0020)' }, { field: 'datasource', type: 'text', helpText: 'Target datasource ID (default: "default")' }, + // #19332 (flight G2a of ruling record 5861442317) — the object's declared + // indexes, beside `datasource`: storage. A repeater with declared + // sub-rows, as `fieldGroups` above, over the three keys the driver reads + // (`name`, `fields`, `unique`); `type` and `partial` are tombstones and + // need no row. + // + // `fields` is a free-text list, the `highlightFields` row's face. No + // authoring door judges its names: not the schema parse, not the publish + // door, not `os validate` (`validate-object-field-refs` leaves it to the + // storage layer by design). The SQL driver's `syncDeclaredIndexes` skips + // an index naming a column the table does not have, logging a warning, + // so the help text claims that and no refusal. + // + // `unique` is a select over `global` / `organization` ONLY, as the + // ruling says. The node is `boolean | 'global' | 'organization'`: a + // derived face takes the union's first arm, the boolean, and would offer + // a switch that writes the deprecated bare `true`; and no option can + // spell a boolean anyway (`FormSelectOptionSchema.value` is a lowercase + // identifier). So the select writes only the two scopes the parse + // accepts now and after protocol 18. A stored `true` / `false` is shown + // by no option, and it is not rewritten: an edit merges into the stored + // entry, so `unique` changes only when the author picks a scope. + { + field: 'indexes', + type: 'repeater', + helpText: 'Database indexes on this object\'s table. The SQL driver creates each one the table lacks when it syncs the table; a sync never drops an index.', + fields: [ + { field: 'name', label: 'Name', type: 'text', helpText: 'Physical index name. Unset: generated from the table and the columns (e.g. idx_task_status).' }, + { field: 'fields', label: 'Fields', widget: 'string-tags', required: true, helpText: 'Column names of this object, in key order (e.g. status, owner). Nothing checks them when you save or publish: a name that is not a stored column makes the SQL driver skip the whole index, with a warning in the server log.' }, + { field: 'unique', label: 'Unique', type: 'select', helpText: 'Uniqueness scope (ADR-0120). Unset: not unique. The deprecated bare true (it means global) is not offered; an index that carries it keeps it until you pick a scope.', options: [ + { label: 'Global — one holder across the installation, over exactly these columns', value: 'global' }, + { label: 'Organization — one holder per organization (the driver prepends the organization column)', value: 'organization' }, + ] }, + ], + }, // #19331 — five more declared scalars with no control. Each enum gets an // explicit `options` list because the bare member reads as a word and // the choice it stands for is a security or lifecycle contract; the copy diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index d2fe718f77e..b0d819e5be5 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -228,6 +228,36 @@ const LEDGER: ReadonlyArray = [ key: 'onlyWhen', why: "the mirror of `retention.onlyWhen` — one shape by design (`lifecycleOnlyWhenSchema`, object.zod.ts) — with the same boundary: a row-filter map with no scalar rendering among the ttl block's text inputs, and its one writer today is the code-declared sys_session object (`revoked_at: { $null: true }`). Offering it needs a structured control, a form-face addition rather than a reconciliation", }, + // ── The `fieldGroups` repeater (ruling record 5861442317, #19332) ── + // + // The three `[DEPRECATED → collapse]` aliases of a field group, the + // `page.interfaceConfig.sourceView` precedent at the top of this ledger one + // list down. The parse still accepts each and derives `collapse` from it + // when `collapse` is absent (`normalizeSemanticRoleAliases`, object.zod.ts), + // so a stored entry that carries one keeps its meaning. The repeater offers + // the canonical `collapse` select instead, and a set `collapse` outranks + // every alias, at parse and in `deriveFieldGroupLayout` alike. + { + kind: 'omit', + type: 'object', + path: 'fieldGroups', + key: 'defaultExpanded', + why: "`[DEPRECATED → collapse]` alias (ADR-0085), deliberately not offered to new authors (ruling record 5861442317, #19332): the parse maps it onto `collapse` only when `collapse` is absent (`true` → 'expanded', `false` → 'collapsed'), this repeater offers the canonical `collapse` select, and a second control beside it would teach the retired spelling", + }, + { + kind: 'omit', + type: 'object', + path: 'fieldGroups', + key: 'collapsible', + why: "`[DEPRECATED → collapse]` UI-dialect alias (ADR-0085), one half of the `collapsible` / `collapsed` pair, deliberately not offered to new authors (ruling record 5861442317, #19332): the parse maps the pair onto `collapse` only when `collapse` is absent, this repeater offers the canonical `collapse` select, and the pair can spell the contradictions the enum replaced", + }, + { + kind: 'omit', + type: 'object', + path: 'fieldGroups', + key: 'collapsed', + why: "`[DEPRECATED → collapse]` UI-dialect alias (ADR-0085), the other half of the `collapsible` / `collapsed` pair, deliberately not offered to new authors (ruling record 5861442317, #19332): the parse maps it onto `collapse` only when `collapse` is absent (`true` → 'collapsed' on its own), and this repeater offers the canonical `collapse` select", + }, // ── The root coordinate (#19333): top-level keys no form may offer ── // // Three reasons, each read off the key's own `describe()` or its liveness From 41b21eb119b601631e084a19ebbbf8b8cbb157fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:16:11 +0000 Subject: [PATCH 2/6] i18n(platform-objects): catalogue rows for the fieldGroups and indexes repeaters The 22 new metadata-form leaves are regenerated from the object form, and authored in zh-CN, ja-JP and es-ES instead of left as English fills. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../en.metadata-forms.generated.ts | 44 +++++++++++++++++++ .../es-ES.metadata-forms.generated.ts | 44 +++++++++++++++++++ .../ja-JP.metadata-forms.generated.ts | 44 +++++++++++++++++++ .../zh-CN.metadata-forms.generated.ts | 44 +++++++++++++++++++ 4 files changed, 176 insertions(+) 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 dd38be2d098..4098f0bd894 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 @@ -72,6 +72,34 @@ export const enMetadataForms: NonNullable = { label: "Searchable Fields", helpText: "Field names the $search query matches (ADR-0061): the default for the record picker, list quick-search and global search; a view may narrow it. Unset, search uses the name/title field plus short-text fields. Each entry must name a stored field of this object — an unknown name or a virtual formula field is refused at publish." }, + fieldGroups: { + label: "Field Groups", + helpText: "Ordered sections that group this object's fields on the entry form and the record detail page (ADR-0085); array order is display order. A field joins a group by naming its key in the field's own group setting. Fields in no group follow the groups, and a group no field joins is not drawn." + }, + "fieldGroups.key": { + label: "Key", + helpText: "Machine key in snake_case, unique within this object — the schema refuses anything else. Fields join the group by naming this key, so renaming it leaves them ungrouped." + }, + "fieldGroups.label": { + label: "Label", + helpText: "Header text of the group's section." + }, + "fieldGroups.icon": { + label: "Icon", + helpText: "Lucide icon name shown beside the header on the record detail page (e.g. \"banknote\"). The entry form does not show it." + }, + "fieldGroups.description": { + label: "Description", + helpText: "Text shown under the header, on the entry form and the record detail page." + }, + "fieldGroups.collapse": { + label: "Collapse", + helpText: "Whether the section can be collapsed, on the entry form and the record detail page. Unset: none." + }, + "fieldGroups.visibleWhen": { + label: "Visible When", + helpText: "CEL predicate over the record (e.g. record.type == 'invoice') — the entry form shows the whole group, header included, only while it is TRUE." + }, fields: { label: "Fields", helpText: "Add the columns this object will store" @@ -269,6 +297,22 @@ export const enMetadataForms: NonNullable = { label: "Datasource", helpText: "Target datasource ID (default: \"default\")" }, + indexes: { + label: "Indexes", + helpText: "Database indexes on this object's table. The SQL driver creates each one the table lacks when it syncs the table; a sync never drops an index." + }, + "indexes.name": { + label: "Name", + helpText: "Physical index name. Unset: generated from the table and the columns (e.g. idx_task_status)." + }, + "indexes.fields": { + label: "Fields", + helpText: "Column names of this object, in key order (e.g. status, owner). Nothing checks them when you save or publish: a name that is not a stored column makes the SQL driver skip the whole index, with a warning in the server log." + }, + "indexes.unique": { + label: "Unique", + helpText: "Uniqueness scope (ADR-0120). Unset: not unique. The deprecated bare true (it means global) is not offered; an index that carries it keeps it until you pick a scope." + }, ownership: { label: "Ownership", helpText: "Record-ownership model. Absent resolves to user." 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 a4904f3f112..a0e20e1f819 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 @@ -72,6 +72,34 @@ export const esESMetadataForms: NonNullable = label: "Campos buscables", helpText: "Nombres de campo con los que coincide la consulta $search (ADR-0061): el valor predeterminado del selector de registros, la búsqueda rápida de listas y la búsqueda global; una vista puede acotarlo. Sin definir, la búsqueda usa el campo de nombre/título más los campos de texto corto. Cada entrada debe nombrar un campo almacenado de este objeto: un nombre desconocido o un campo formula virtual se rechaza al publicar." }, + fieldGroups: { + label: "Grupos de campos", + helpText: "Secciones ordenadas que agrupan los campos de este objeto en el formulario de entrada y en la página de detalle del registro (ADR-0085); el orden del array es el orden de visualización. Un campo se une a un grupo indicando su clave en el ajuste group del propio campo. Los campos sin grupo van después de los grupos, y un grupo al que no se une ningún campo no se dibuja." + }, + "fieldGroups.key": { + label: "Clave", + helpText: "Clave de máquina en snake_case, única dentro de este objeto: el esquema rechaza cualquier otra cosa. Los campos se unen al grupo indicando esta clave, así que renombrarla los deja sin grupo." + }, + "fieldGroups.label": { + label: "Etiqueta", + helpText: "Texto del encabezado de la sección del grupo." + }, + "fieldGroups.icon": { + label: "Icono", + helpText: "Nombre de icono Lucide que se muestra junto al encabezado en la página de detalle del registro (p. ej., \"banknote\"). El formulario de entrada no lo muestra." + }, + "fieldGroups.description": { + label: "Descripción", + helpText: "Texto que se muestra bajo el encabezado, en el formulario de entrada y en la página de detalle del registro." + }, + "fieldGroups.collapse": { + label: "Contraer", + helpText: "Si la sección se puede contraer, en el formulario de entrada y en la página de detalle del registro. Sin definir: none." + }, + "fieldGroups.visibleWhen": { + label: "Condición de visibilidad", + helpText: "Predicado CEL sobre el registro (p. ej., record.type == 'invoice'): el formulario de entrada muestra el grupo entero, encabezado incluido, solo mientras sea TRUE." + }, fields: { label: "Campos", helpText: "Añade las columnas que almacenará este objeto" @@ -269,6 +297,22 @@ export const esESMetadataForms: NonNullable = label: "Fuente de datos", helpText: "ID de fuente de datos de destino (valor predeterminado: \"default\")" }, + indexes: { + label: "Índices", + helpText: "Índices de base de datos de la tabla de este objeto. El driver SQL crea cada uno que le falte a la tabla cuando la sincroniza; una sincronización nunca elimina un índice." + }, + "indexes.name": { + label: "Nombre", + helpText: "Nombre físico del índice. Sin definir: se genera a partir de la tabla y las columnas (p. ej., idx_task_status)." + }, + "indexes.fields": { + label: "Campos", + helpText: "Nombres de columna de este objeto, en el orden de la clave (p. ej., status, owner). Nada los comprueba al guardar ni al publicar: un nombre que no sea una columna almacenada hace que el driver SQL omita el índice entero, con una advertencia en el registro del servidor." + }, + "indexes.unique": { + label: "Único", + helpText: "Ámbito de unicidad (ADR-0120). Sin definir: no es único. El true desnudo obsoleto (significa global) no se ofrece; un índice que lo lleve lo conserva hasta que elijas un ámbito." + }, ownership: { label: "Propiedad", helpText: "Modelo de propiedad del registro. Si se omite, se resuelve como user." 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 0b4790ab6f3..1848e44d9c1 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 @@ -72,6 +72,34 @@ export const jaJPMetadataForms: NonNullable = label: "検索対象項目", helpText: "$search クエリが照合する項目名(ADR-0061)。レコードピッカー、一覧のクイック検索、グローバル検索の既定値で、ビューで絞り込めます。未設定の場合は名前/タイトル項目と短いテキスト項目で検索します。各項目はこのオブジェクトの保存済みの項目を指す必要があり、不明な名前や仮想の formula 項目は公開時に拒否されます。" }, + fieldGroups: { + label: "フィールドグループ", + helpText: "このオブジェクトのフィールドを入力フォームとレコード詳細ページでまとめる、順序付きのセクション(ADR-0085)。配列の順序が表示順です。フィールドは自身の group 設定にグループのキーを指定するとそのグループに加わります。どのグループにも属さないフィールドはグループの後に続き、フィールドが 1 つも属さないグループは表示されません。" + }, + "fieldGroups.key": { + label: "キー", + helpText: "snake_case のマシンキーで、このオブジェクト内で一意です。それ以外はスキーマが拒否します。フィールドはこのキーを指定してグループに加わるため、キーを変更するとそれらのフィールドはどのグループにも属さなくなります。" + }, + "fieldGroups.label": { + label: "表示名", + helpText: "グループのセクションの見出しテキスト。" + }, + "fieldGroups.icon": { + label: "アイコン", + helpText: "レコード詳細ページで見出しの横に表示される Lucide アイコン名(例:\"banknote\")。入力フォームには表示されません。" + }, + "fieldGroups.description": { + label: "説明", + helpText: "見出しの下に表示されるテキスト。入力フォームとレコード詳細ページに表示されます。" + }, + "fieldGroups.collapse": { + label: "折りたたみ", + helpText: "セクションを折りたためるかどうか。入力フォームとレコード詳細ページに適用されます。未設定の場合は none です。" + }, + "fieldGroups.visibleWhen": { + label: "表示条件", + helpText: "レコードに対する CEL 述語(例:record.type == 'invoice')。TRUE の間だけ、入力フォームに見出しを含むグループ全体が表示されます。" + }, fields: { label: "フィールド", helpText: "このオブジェクトが保存する列を追加" @@ -269,6 +297,22 @@ export const jaJPMetadataForms: NonNullable = label: "データソース", helpText: "対象データソース ID(既定: \"default\")" }, + indexes: { + label: "インデックス", + helpText: "このオブジェクトのテーブルのデータベースインデックス。SQL ドライバーはテーブルを同期するとき、テーブルにまだないインデックスを作成します。同期でインデックスが削除されることはありません。" + }, + "indexes.name": { + label: "名前", + helpText: "物理インデックス名。未設定の場合は、テーブル名と列名から生成されます(例:idx_task_status)。" + }, + "indexes.fields": { + label: "フィールド", + helpText: "このオブジェクトの列名を、キーの順に指定します(例:status、owner)。保存時にも公開時にも検査されません。保存される列ではない名前があると、SQL ドライバーはそのインデックス全体をスキップし、サーバーログに警告を出します。" + }, + "indexes.unique": { + label: "一意", + helpText: "一意性のスコープ(ADR-0120)。未設定の場合は一意ではありません。非推奨の裸の true(global を意味します)は選択肢にありません。true を持つインデックスは、スコープを選ぶまでその値を保持します。" + }, ownership: { label: "所有権モデル", helpText: "レコードの所有権モデル。未設定の場合は user として解決されます。" 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 e86f86bef74..2157a932a30 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 @@ -72,6 +72,34 @@ export const zhCNMetadataForms: NonNullable = label: "可搜索字段", helpText: "$search 查询匹配的字段名(ADR-0061):记录选择器、列表快速搜索和全局搜索的默认范围,视图可以再收窄。未设置时,搜索使用名称/标题字段加上短文本字段。每一项都必须是本对象已存储的字段——未知的名称或虚拟的 formula 字段会在发布时被拒绝。" }, + fieldGroups: { + label: "字段分组", + helpText: "对本对象的字段进行分组的有序分区,用于录入表单和记录详情页(ADR-0085);数组顺序即显示顺序。字段在自身的 group 设置中填写分组的键即可加入该分组。未归入任何分组的字段排在各分组之后;没有任何字段加入的分组不会显示。" + }, + "fieldGroups.key": { + label: "键", + helpText: "snake_case 格式的机器键,在本对象内唯一——其他写法会被 schema 拒绝。字段通过填写此键加入分组,因此重命名它会使这些字段变为未分组。" + }, + "fieldGroups.label": { + label: "显示名称", + helpText: "该分组所在分区的标题文字。" + }, + "fieldGroups.icon": { + label: "图标", + helpText: "在记录详情页的标题旁显示的 Lucide 图标名称(例如 \"banknote\")。录入表单不显示它。" + }, + "fieldGroups.description": { + label: "描述", + helpText: "显示在标题下方的文字,出现在录入表单和记录详情页上。" + }, + "fieldGroups.collapse": { + label: "折叠", + helpText: "该分区能否折叠,作用于录入表单和记录详情页。未设置:none。" + }, + "fieldGroups.visibleWhen": { + label: "可见条件", + helpText: "基于记录的 CEL 谓词(例如 record.type == 'invoice')——仅当其为 TRUE 时,录入表单才显示整个分组(包括标题)。" + }, fields: { label: "字段", helpText: "添加该对象将存储的列" @@ -269,6 +297,22 @@ export const zhCNMetadataForms: NonNullable = label: "数据源", helpText: "目标数据源 ID(默认:\"default\")" }, + indexes: { + label: "索引", + helpText: "本对象数据表上的数据库索引。SQL 驱动在同步数据表时创建表中尚不存在的索引;同步从不删除索引。" + }, + "indexes.name": { + label: "名称", + helpText: "物理索引名。未设置:根据表名和列名生成(例如 idx_task_status)。" + }, + "indexes.fields": { + label: "字段", + helpText: "本对象的列名,按键的顺序排列(例如 status、owner)。保存或发布时不会检查它们:若某个名称不是已存储的列,SQL 驱动会跳过整个索引,并在服务器日志中记录一条警告。" + }, + "indexes.unique": { + label: "唯一", + helpText: "唯一性范围(ADR-0120)。未设置:不唯一。已弃用的裸 true(等同于 global)不在选项中;带有它的索引会保留该值,直到你选择一个范围。" + }, ownership: { label: "归属模型", helpText: "记录归属模型。留空按 user 处理。" From 1a3473f6a7f7edba892c83055e14b9111529935f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:17:09 +0000 Subject: [PATCH 3/6] test(platform-objects): move the echo-decision population pins for the two repeaters; add the changeset The object form's new fieldGroups (basics) and indexes (advanced) repeaters move three measured population pins mechanically: the open section leaves 100 -> 114, the collapsed section leaves 61 -> 69 (advanced 52 -> 60), and the translated-label control 633 -> 644. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../19332-g2a-fieldgroups-indexes-form-rows.md | 17 +++++++++++++++++ ...ct-collapsed-sections-echo-decisions.test.ts | 14 +++++++++++--- ...bject-lifecycle-panel-echo-decisions.test.ts | 7 ++++++- 3 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 .changeset/19332-g2a-fieldgroups-indexes-form-rows.md diff --git a/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md b/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md new file mode 100644 index 00000000000..9b8b7f73069 --- /dev/null +++ b/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": patch +--- + +Clause-②: no + +Two live structured object keys are authorable in the metadata form: `fieldGroups` and `indexes`. Each was **declared** by `ObjectSchema`, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each is now a `type: 'repeater'` row on the object form whose sub-rows are declared by hand rather than derived from the schema: + +- `fieldGroups` (Basics, beside `highlightFields`) — six sub-rows, one per canonical group key: `key` and `label` (required text), `icon` (text), `description` (textarea), `collapse` (a `none` / `expanded` / `collapsed` select) and `visibleWhen` (`type: 'code'`, `language: 'expression'`, the `fields` grid's predicate rows). The three `[DEPRECATED → collapse]` aliases (`defaultExpanded`, `collapsible`, `collapsed`) are **not** offered; the metadata-form reconciliation ledger records a nested `omit` row for each. The parse still accepts them and derives `collapse` from one only when `collapse` is absent, so a stored entry keeps its meaning, and a `collapse` set in the form outranks any alias it carries. +- `indexes` (Advanced, beside `datasource`) — three sub-rows over the keys the SQL driver reads: `name` (text), `fields` (`widget: 'string-tags'`, required) and `unique`, a select offering **only** `global` and `organization`. The deprecated bare `unique: true` is never offered: a schema-derived control would take the union's first arm and render a switch that writes it. An edit merges into the stored entry, so an index that already carries `true` or `false` keeps it until the author picks a scope, and the select can write only the two values the parse accepts. `type` and `partial` are tombstones and have no row. + +The help text states what the runtime does with each value. `indexes[].fields` is free text, and no authoring door judges its names: not the schema parse, not the publish door, not `os validate`. A name that is not a stored column makes the SQL driver skip the whole index at sync with a warning in the server log, and the help text says exactly that. A field group has no field-name list: a field joins a group through its own `group` key. + +⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 22 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + +⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers and three nested ledger rows only. diff --git a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts index 18f64042c7f..ae25091ee36 100644 --- a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts @@ -900,10 +900,15 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape', // `requiredPermissions` — a label and a help text each, three rows and six // leaves, all eighteen translated leaves authored by the same card. `advanced` // reads 46 → 52; `capabilities` is untouched. - expect(PANEL_LEAVES.length).toBe(61); + // 69 since #19332 flight G2a: that flight gave `advanced` the `indexes` + // repeater beside `datasource` — the row and its three declared sub-rows + // (`name`, `fields`, `unique`), a label and a help text each, four rows and + // eight leaves, all twenty-four translated leaves authored by the same + // flight. `advanced` reads 52 → 60; `capabilities` is untouched. + expect(PANEL_LEAVES.length).toBe(69); expect(PANEL_LEAVES.every((l) => l.prop === 'label' || l.prop === 'helpText')).toBe(true); expect(PANEL_LEAVES.filter((l) => l.section === 'capabilities').length).toBe(9); - expect(PANEL_LEAVES.filter((l) => l.section === 'advanced').length).toBe(52); + expect(PANEL_LEAVES.filter((l) => l.section === 'advanced').length).toBe(60); }); it('⭐ DARK, OUTWARD — the open sections are excluded, and `fields.placeholder` is the one that proves it', () => { @@ -917,7 +922,10 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape', // gave `basics` the object's two own field-name lists, `highlightFields` and // `searchableFields`, beside `nameField` — four leaves, their twelve // translated leaves authored by that card, so `openEchoes` stays at zero. - expect(OPEN_LEAVES.length).toBe(100); + // 114 since #19332 flight G2a gave `basics` the `fieldGroups` repeater — the + // row and its six declared sub-rows, fourteen leaves, their forty-two + // translated leaves authored by that flight, so `openEchoes` stays at zero. + expect(OPEN_LEAVES.length).toBe(114); expect(PANEL_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(false); expect(OPEN_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(true); for (const path of ['name', 'label', 'fields', 'fields.valueDomain', 'fields.deleteBehavior', 'fields.expression']) { diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index a8c63c6cdd8..a9454c52ffe 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1126,7 +1126,12 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // `requiredWhen` and `requiredPermissions`, and the action form's // `patch`, `description`, `errorMessage`, `requiredPermissions` and // `bodyExtra` — authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(633); + // 644 since #19332 flight G2a: eleven new row labels — the object form's + // `fieldGroups` repeater (and its `key`, `label`, `icon`, `description`, + // `collapse` and `visibleWhen` sub-rows) and its `indexes` repeater (and + // its `name`, `fields` and `unique` sub-rows) — authored in all three + // locales. + expect(translated.length, `${locale} positive control`).toBe(644); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the From 92fb68dae63dfbea834490b08dbe75fafaa0148f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:27:59 +0000 Subject: [PATCH 4/6] test(spec): the object form's icon inputs are two again, the second a field group's The fieldGroups repeater's icon sub-row (a group header's Lucide icon) is a second, different subject from the withdrawn options-repeater icon; the pin now names both by where they sit. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../field-rows-option-description.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/data/field-rows-option-description.test.ts b/packages/spec/src/data/field-rows-option-description.test.ts index 4835a08347c..eb3a3cc87df 100644 --- a/packages/spec/src/data/field-rows-option-description.test.ts +++ b/packages/spec/src/data/field-rows-option-description.test.ts @@ -328,13 +328,28 @@ describe('#13671 — the object.form options repeater offers only keys the door } }); - it('declares exactly one `icon` input in total — the OBJECT-level row, a different subject', () => { + it('declares exactly two `icon` inputs in total — the OBJECT-level row and the field-group row, different subjects', () => { // The object form declares two `icon` inputs in total before #13671 and // one after. The survivor is the object's own Lucide icon in Basics, which // `ObjectSchema` declares and Studio renders; an over-broad deletion that // took it out would otherwise look like a pass above. + // + // Two again since #19332 (flight G2a), and the second is not the withdrawn + // one: the `fieldGroups` repeater's `icon` sub-row, a group header's Lucide + // icon, which `ObjectFieldGroupSchema` declares and the record detail page + // renders. Each is pinned by where it sits, so neither can stand in for an + // `icon` input returning to the options repeater (asserted above). const iconRows = findSpecs(objectForm, (s) => s.field === 'icon'); - expect(iconRows).toHaveLength(1); - expect(String(iconRows[0].helpText ?? '')).toContain('Lucide icon name'); + expect(iconRows).toHaveLength(2); + const topLevel = (((objectForm as FormSpec).sections ?? []) as FormSpec[]) + .flatMap((sec) => ((sec.fields ?? []) as FormSpec[])) + .filter((s) => s.field === 'icon'); + const groups = findSpecs(objectForm, (s) => s.field === 'fieldGroups' && s.type === 'repeater'); + expect(groups, 'the object form no longer declares exactly one `fieldGroups` repeater').toHaveLength(1); + const groupIcon = (groups[0].fields as FormSpec[] | undefined)?.find((s) => s.field === 'icon'); + expect(topLevel).toHaveLength(1); + expect(groupIcon).toBeDefined(); + expect(iconRows).toEqual(expect.arrayContaining([topLevel[0], groupIcon])); + for (const row of iconRows) expect(String(row.helpText ?? '')).toContain('Lucide icon name'); }); }); From e8bdb1ad738d1db11021427751904f8aaad09fb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:28:13 +0000 Subject: [PATCH 5/6] feat(spec): JSON Schema titles on the index and field-group row properties The fieldGroups and indexes repeaters' row schemas carry a title on every authorable property, as the repeater-title class guard requires: IndexSchema name / fields / unique and ObjectFieldGroupSchema's nine keys, the three deprecated collapse aliases included. Titles only; no accept set moves. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- packages/spec/src/data/object.zod.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 76886c7afe5..ab9b5aa5b42 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -459,8 +459,8 @@ export const IndexSchema = lazySchema(() => strictObject({ 'does for `sys_metadata`).', }, }, { - name: z.string().optional().describe('Index name (auto-generated if not provided)'), - fields: z.array(z.string()).describe('Fields included in the index'), + name: z.string().optional().describe('Index name (auto-generated if not provided)').meta({ title: 'Name' }), + fields: z.array(z.string()).describe('Fields included in the index').meta({ title: 'Fields' }), // Unique scope on a DECLARED index (ADR-0120 D1, amending #3696): // // - `'global'` — the VERBATIM contract: materialized over exactly the @@ -487,7 +487,7 @@ export const IndexSchema = lazySchema(() => strictObject({ // `fields: ['organization_id', 'code']`" survives as valid legacy input, // but new code says `unique: 'organization'` — the hand-written composite // is NOT NULL-safe (#5030). - unique: DeclaredIndexUniqueScopeSchema.optional().default(false).describe("Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18) — state the scope. 'tenant'/'org' are rejected — the word is 'organization'"), + unique: DeclaredIndexUniqueScopeSchema.optional().default(false).describe("Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18) — state the scope. 'tenant'/'org' are rejected — the word is 'organization'").meta({ title: 'Unique' }), // ── Tombstones (ADR-0049 / ADR-0087) ───────────────────────────────── // Kept LAST in the shape on purpose — see the #5606 note in the block @@ -1159,16 +1159,16 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ */ key: z.string().regex(FIELD_GROUP_KEY_PATTERN, { message: 'Field group key must be lowercase snake_case (e.g., "contact_info", "billing", "system")', - }).describe('Group machine key (snake_case). Referenced by Field.group, and by a layout section\'s `group`.'), + }).describe('Group machine key (snake_case). Referenced by Field.group, and by a layout section\'s `group`.').meta({ title: 'Key' }), /** Human-readable label displayed as the group header. */ - label: z.string().describe('Group display label'), + label: z.string().describe('Group display label').meta({ title: 'Label' }), /** Optional Lucide/Material icon name for the group header. */ - icon: z.string().optional().describe('Icon name (Lucide/Material) for the group header'), + icon: z.string().optional().describe('Icon name (Lucide/Material) for the group header').meta({ title: 'Icon' }), /** Optional description / help text shown under the group header. */ - description: z.string().optional().describe('Optional description shown under the group header'), + description: z.string().optional().describe('Optional description shown under the group header').meta({ title: 'Description' }), /** * [ADR-0085 §5] Section visibility predicate — CEL, the ADR-0089 canonical @@ -1185,7 +1185,7 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ */ visibleWhen: EvaluatedExpressionInputSchema.optional().describe( "Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'`", - ), + ).meta({ title: 'Visible When' }), /** * [ADR-0085] Collapse behaviour of the group's rendered section, on every @@ -1196,18 +1196,18 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ * keys the spec rejected). */ collapse: z.enum(['none', 'expanded', 'collapsed']).optional().default('none') - .describe("[ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed)."), + .describe("[ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed).").meta({ title: 'Collapse' }), /** * @deprecated [ADR-0085 → `collapse`] Accepted as a parse-time alias: * `defaultExpanded: false` maps to `collapse: 'collapsed'`, `true` to * `'expanded'`, when `collapse` is absent. New metadata sets `collapse`. */ - defaultExpanded: z.boolean().optional().describe("[DEPRECATED → collapse] true → 'expanded', false → 'collapsed'."), + defaultExpanded: z.boolean().optional().describe("[DEPRECATED → collapse] true → 'expanded', false → 'collapsed'.").meta({ title: 'Default Expanded' }), /** @deprecated [ADR-0085 → `collapse`] UI-dialect alias (pair with `collapsed`); mapped onto `collapse` at parse. */ - collapsible: z.boolean().optional().describe("[DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum."), + collapsible: z.boolean().optional().describe("[DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum.").meta({ title: 'Collapsible' }), /** @deprecated [ADR-0085 → `collapse`] UI-dialect alias (pair with `collapsible`); mapped onto `collapse` at parse. */ - collapsed: z.boolean().optional().describe("[DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no `collapsible` and outranks `collapsible: false`; false → 'none', or 'expanded' beside `collapsible: true`. Use the `collapse` enum."), + collapsed: z.boolean().optional().describe("[DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no `collapsible` and outranks `collapsible: false`; false → 'none', or 'expanded' beside `collapsible: true`. Use the `collapse` enum.").meta({ title: 'Collapsed' }), })); export type ObjectFieldGroup = z.input; From c22fc1e2f5e74f32d6bb64befe03dba065027ae5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:42:12 +0000 Subject: [PATCH 6/6] chore(changeset): the G2a changeset names the row-schema titles Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .changeset/19332-g2a-fieldgroups-indexes-form-rows.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md b/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md index 9b8b7f73069..978548abd07 100644 --- a/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md +++ b/.changeset/19332-g2a-fieldgroups-indexes-form-rows.md @@ -12,6 +12,8 @@ Two live structured object keys are authorable in the metadata form: `fieldGroup The help text states what the runtime does with each value. `indexes[].fields` is free text, and no authoring door judges its names: not the schema parse, not the publish door, not `os validate`. A name that is not a stored column makes the SQL driver skip the whole index at sync with a warning in the server log, and the help text says exactly that. A field group has no field-name list: a field joins a group through its own `group` key. -⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 22 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. +The two row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: `IndexSchema` on `name`, `fields` and `unique`, and `ObjectFieldGroupSchema` on its nine keys, the three deprecated aliases included. A property panel that reads the served schema's titles therefore shows a named column instead of a raw key. Each title is a `.meta({ title })` call and nothing more. + +⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves (its rows, and the titles above in its JSON Schema) and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 22 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. ⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers and three nested ledger rows only.