Skip to content
19 changes: 19 additions & 0 deletions .changeset/19332-g2a-fieldgroups-indexes-form-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@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.

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<Record<string, FormView>>`, 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.
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,34 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
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"
Expand Down Expand Up @@ -269,6 +297,22 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,34 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
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"
Expand Down Expand Up @@ -269,6 +297,22 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,34 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
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: "このオブジェクトが保存する列を追加"
Expand Down Expand Up @@ -269,6 +297,22 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
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 として解決されます。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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']) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1132,7 +1132,12 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
// (`name`, `label`, `icon`, `view`, `filter`, `order`, `pinned`,
// `isDefault`, `visible`), ten leaves authored in all three locales, out of
// the catalog.
expect(translated.length, `${locale} positive control`).toBe(623);
// 634 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(634);
}
// ⭐ 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
Expand Down
Loading
Loading