diff --git a/.changeset/25681-repeater-untyped-json.md b/.changeset/25681-repeater-untyped-json.md new file mode 100644 index 00000000000..ddff5400ff0 --- /dev/null +++ b/.changeset/25681-repeater-untyped-json.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +feat(spec,lint): object-level `composite` / `repeater` are untyped JSON — a typed list is a child object + +Clause-②: yes (widening) +Metadata-protocol: semantic +Ruling-ref: 6386744473 + +On an object field, `composite` and `repeater` are untyped JSON containers of CMS lineage (a Strapi component, an ACF group or repeater). The `FieldType` comments used to promise "declared sub-fields", but `FieldSchema` has no member that declares them, so nothing gave the inner keys labels, types, options, required flags or translations, and nothing could validate or address one row. The comments and the field-types docs now say so, and point a list that needs typed columns to a child object: a `master_detail` field pointing back at the parent, with `inlineEdit: 'grid'` to edit the rows inline on the parent's form. + +The strict field schema already refused `fields`, `subFields` and `columns` on a field, but named only the key. The refusal now names the route: a typed list is a child object (`columns` also names the live `lookupColumns`, `relatedListColumns` and `inlineColumns`). This changes the message, not what parses. + +New `@objectstack/lint` rule `field/embedded-sub-fields` (error): a `composite` / `repeater` object field carrying `fields`, `subFields` or `columns`. It fires on the doors that never parse (`os lint` on a raw object-literal config, `defineStack(x, { strict: false })`, the metadata-generation rubric); every parsed door refuses the field first. `os explain field/embedded-sub-fields` prints the reasoning. A form view's `fields` sub-rows and the Studio metadata forms are not judged. + +What to do: nothing for a field that declares no sub-field key — the type is unchanged. A field that carried one never parsed under `defineStack`; model the list as a child object. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 522831f1b8f..464456dcc24 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -511,18 +511,43 @@ guarantee as a separate requirement to satisfy explicitly, not something These types store structured values as JSON on the parent row — no separate table or foreign key. + +On an object field, `composite` and `repeater` are **untyped JSON** containers of CMS lineage (similar +to a Strapi component, an ACF group or an ACF repeater). A field declares no sub-fields: `fields`, +`subFields` and `columns` are refused on the field, so nothing gives the inner keys labels, types, +options, required flags or translations, and nothing validates or addresses a single row. + +**A list that needs typed columns is a child object** with a [`master_detail`](#master_detail) field +pointing back at the parent — the shape Salesforce, Dynamics, ServiceNow and Odoo use for line items. +The child's own fields carry the types and validation, `inlineEdit: 'grid'` edits the rows as an +inline grid on the parent's form, and every row is a record a lookup, a roll-up or a flow can reach. + + ### `composite` -Single embedded sub-object with declared sub-fields (similar to a Strapi component / ACF group). +A single embedded JSON object, untyped — no declared sub-fields. ```typescript { name: 'dimensions', label: 'Dimensions', type: 'composite' } ``` ### `repeater` -Repeating embedded sub-object array with declared sub-fields (similar to a Strapi repeatable component / ACF repeater). +An array of embedded JSON objects, untyped — no declared sub-fields or columns. For typed rows, use a +child object (see above). ```typescript -{ name: 'line_items', label: 'Line Items', type: 'repeater' } +{ name: 'import_log', label: 'Import Log', type: 'repeater' } +``` + +```typescript +// A typed list: a child object, edited inline on the parent's form +{ + name: 'invoice_line', + fields: { + invoice: { type: 'master_detail', reference: 'invoice', required: true, deleteBehavior: 'cascade', inlineEdit: 'grid' }, + product: { type: 'text', label: 'Product' }, + quantity: { type: 'number', label: 'Quantity' }, + }, +} ``` ### `record` diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 8ae639c2a56..5dafb264e30 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -391,9 +391,11 @@ application-level emptiness check is needed. ## Embedded Structured Types These types store structured values as JSON on the parent row — no separate table and no -foreign key. Sub-field shapes are declared on the field, and the stored value is validated -as an open object map (`valueSchemaFor`), so sub-keys are not constrained by the field type -itself. +foreign key. A `composite` / `repeater` field declares no sub-fields (`fields`, `subFields` and +`columns` are refused on it), and the stored value is validated as an open object map +(`valueSchemaFor`), so sub-keys are not constrained at all. A list that needs typed, validated +columns is a child object with a `master_detail` field — see +[Embedded Structured Types](/docs/data-modeling/field-types#embedded-structured-types). ### `composite` diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index b032149ab1a..e8e4d22589b 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -465,8 +465,10 @@ sections: Both examples were doing something the declared shape already does — see the replacement below. For a value that is genuinely one composite thing rather - than neighbouring fields, the grouping belongs on the **field**: a field of - type `address` (or `composite`) carries its own `fields` list. + than neighbouring fields, the grouping belongs on the **field**: a form field of + type `address` (or `composite`) carries its own `fields` list. That list is the + form's; the object field itself declares no sub-fields (see + [Embedded Structured Types](/docs/data-modeling/field-types#embedded-structured-types)). Fields sit on the same row because the section says how many columns it has, and diff --git a/packages/lint/src/data-model-rules.embedded-sub-fields.test.ts b/packages/lint/src/data-model-rules.embedded-sub-fields.test.ts new file mode 100644 index 00000000000..a747d649427 --- /dev/null +++ b/packages/lint/src/data-model-rules.embedded-sub-fields.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `field/embedded-sub-fields` — an object-level `composite` / `repeater` is +// untyped JSON (ruling 6386744473), so declaring `fields` / `subFields` / +// `columns` on one is an error whose route is a child object. +// +// The rule reaches only the doors that never parse (its docblock's intake +// table): the strict field schema refuses the same keys first, with the same +// route. Both halves are pinned below, so neither can be credited for the +// other's door. +import { describe, expect, it } from 'vitest'; +import { FieldSchema } from '@objectstack/spec/data'; + +import { FIELD_EMBEDDED_SUB_FIELDS, lintDataModel } from './data-model-rules.js'; +import { explainRule } from './rule-explanations.js'; + +const RULE = 'field/embedded-sub-fields'; + +/** One object carrying `checklist` as `def`, in the record shape authors write. */ +const audit = (def: Record) => [ + { name: 'supplier_audit', fields: { name: { type: 'text' }, checklist: def } }, +]; +const findings = (objects: unknown[]) => lintDataModel(objects as any[]).filter((i) => i.rule === RULE); + +const SUB_ROWS = [{ name: 'item', label: 'Item', type: 'text' }]; + +describe('field/embedded-sub-fields — refuses a typed list on an embedded-JSON field', () => { + for (const type of ['repeater', 'composite'] as const) { + for (const [key, value] of [['fields', SUB_ROWS], ['subFields', SUB_ROWS], ['columns', ['item', 'score']]] as const) { + it(`${type} + \`${key}\` is an error at that key`, () => { + const [issue, ...rest] = findings(audit({ type, [key]: value })); + expect(rest).toEqual([]); + expect(issue).toMatchObject({ + severity: 'error', + rule: RULE, + path: `objects[0].fields.checklist.${key}`, + }); + }); + } + } + + it('the array field shape is judged the same as the record shape', () => { + const objects = [{ name: 'supplier_audit', fields: [{ name: 'checklist', type: 'repeater', fields: SUB_ROWS }] }]; + expect(findings(objects)).toHaveLength(1); + }); + + it('several keys on one field are one finding naming every key, located at the first', () => { + const [issue, ...rest] = findings(audit({ type: 'repeater', fields: SUB_ROWS, columns: ['item'] })); + expect(rest).toEqual([]); + expect(issue!.path).toBe('objects[0].fields.checklist.fields'); + expect(issue!.message).toContain('`fields`, `columns`'); + }); +}); + +describe('field/embedded-sub-fields — stays silent outside its scope', () => { + it('a bare composite / repeater is untyped JSON, not an error', () => { + expect(findings(audit({ type: 'repeater' }))).toEqual([]); + expect(findings(audit({ type: 'composite' }))).toEqual([]); + }); + + it('other field types are not judged here, whatever they carry', () => { + expect(findings(audit({ type: 'json', fields: SUB_ROWS }))).toEqual([]); + expect(findings(audit({ type: 'record', fields: SUB_ROWS }))).toEqual([]); + expect(findings(audit({ type: 'lookup', reference: 'account', columns: ['name'] }))).toEqual([]); + }); + + it('a key that merely shares a name with an inherited property is not authored', () => { + const inherited = Object.create({ fields: SUB_ROWS }) as Record; + inherited.type = 'repeater'; + expect(findings(audit(inherited))).toEqual([]); + }); +}); + +describe('field/embedded-sub-fields — the parsed doors refuse first, with the same route', () => { + it('the strict field schema refuses each key by name and names `master_detail`', () => { + for (const key of ['fields', 'subFields', 'columns']) { + const r = FieldSchema.safeParse({ name: 'checklist', type: 'repeater', [key]: SUB_ROWS }); + expect(r.success, key).toBe(false); + if (r.success) continue; + const issue = r.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, key).toBeTruthy(); + expect(issue!.message, key).toContain('`master_detail`'); + } + }); +}); + +describe('field/embedded-sub-fields — one-line verdict, `os explain` carries the rest', () => { + it('the published constant spells the id the findings carry', () => { + expect(FIELD_EMBEDDED_SUB_FIELDS).toBe(RULE); + }); + + it('the verdict names the type, the field and the keys, on one line of at most 200 characters', () => { + const [issue] = findings(audit({ type: 'repeater', fields: SUB_ROWS })); + expect(issue!.message).toBe( + 'repeater field "supplier_audit.checklist" declares `fields`, but an object-level repeater is untyped ' + + 'JSON with no sub-fields', + ); + expect(issue!.message).not.toContain('\n'); + expect(issue!.message.length).toBeLessThanOrEqual(200); + }); + + it('`os explain` carries the route, the exclusions and the reach', () => { + const entry = explainRule(RULE); + expect(entry, 'no `os explain field/embedded-sub-fields` entry').toBeDefined(); + const text = entry!.paragraphs.join('\n'); + for (const fact of [ + 'untyped JSON', + 'child object', + '`master_detail`', + 'inline grid', + 'form view', + 'refuses all three keys at the parse', + 'never parse', + ]) { + expect(text, fact).toContain(fact); + } + }); +}); diff --git a/packages/lint/src/data-model-rules.fix-lines.test.ts b/packages/lint/src/data-model-rules.fix-lines.test.ts index 7e4fad66152..f2e646f6290 100644 --- a/packages/lint/src/data-model-rules.fix-lines.test.ts +++ b/packages/lint/src/data-model-rules.fix-lines.test.ts @@ -5,8 +5,9 @@ // three ADR-0120 uniqueness ids carry it as the `hint` `os validate` and // `os build` print. What the hints used to explain is `os explain` text. // -// The three `lintDataModel` sweep ids (`object/missing-name-field`, -// `field/select-missing-options`, `rollup/non-numeric-aggregand`) are never +// The four `lintDataModel` sweep ids (`object/missing-name-field`, +// `field/select-missing-options`, `rollup/non-numeric-aggregand`, +// `field/embedded-sub-fields`) are never // adapted into an `AuthoringFinding`, so a census that reads `hint` never sees // them. This file reads `fix` directly, over every arm each rule has, with long // object and field names where a name is interpolated. @@ -14,6 +15,7 @@ import { describe, expect, it } from 'vitest'; import { + FIELD_EMBEDDED_SUB_FIELDS, lintDataModel, lintLegacyOrganizationComposites, lintUniqueDeclarations, @@ -36,6 +38,7 @@ const IDS = [ MISSING_NAME_FIELD, SELECT_MISSING_OPTIONS, ROLLUP_NON_NUMERIC_AGGREGAND, + FIELD_EMBEDDED_SUB_FIELDS, ]; const LONG_OBJECT = 'acme_customer_purchase_order_header'; @@ -170,6 +173,24 @@ describe('[#22161] data-model-rules.ts — one remedy line per fix', () => { } }); + it('field/embedded-sub-fields: one line, whatever the type, the keys or the names', () => { + const fix = + 'Make the typed list a child object whose `master_detail` field points back here ' + + "(`inlineEdit: 'grid'` edits it inline), and remove the key."; + for (const type of ['repeater', 'composite']) { + const objects = [ + { + name: LONG_OBJECT, + fields: { + name: { type: 'text' }, + [LONG_FIELD]: { type, fields: [{ name: 'item' }], subFields: [{ name: 'item' }], columns: ['item'] }, + }, + }, + ]; + expect(fixOf(record(lintDataModel(objects)), FIELD_EMBEDDED_SUB_FIELDS)).toBe(fix); + } + }); + it('rollup/non-numeric-aggregand: one line naming the child type, not the parent', () => { expect(fixOf(record(lintDataModel(rollup('datetime', LONG_OBJECT))), ROLLUP_NON_NUMERIC_AGGREGAND)).toBe( "Aggregate a numeric or boolean child field, or use function: 'count'; a roll-up cannot store a " + @@ -181,7 +202,7 @@ describe('[#22161] data-model-rules.ts — one remedy line per fix', () => { }); it('every fix the cases above fired is one line of at most 200 characters', () => { - // The coverage control first: all six ids fired. + // The coverage control first: all seven ids fired. expect([...new Set(fired.map((f) => f.rule))].sort()).toEqual([...IDS].sort()); for (const f of fired) { expect(f.fix, f.rule).toBeTruthy(); diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index d1f3e083323..678d7d76660 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -261,6 +261,50 @@ export const RELATIONSHIP_DELETE_BEHAVIOR = 'relationship/delete-behavior'; /** The rule id R13 reports under (a `min`/`max` roll-up over a non-numeric child). */ export const ROLLUP_NON_NUMERIC_AGGREGAND = 'rollup/non-numeric-aggregand'; +/** + * The rule id R14 reports under: an object field of type `composite` / + * `repeater` that declares sub-fields or columns. + * + * Ruling 6386744473: an object-level `composite` / `repeater` is untyped JSON, + * no sub-field member is added to `FieldSchema`, and a list that needs typed + * columns is a child object (`master_detail`). A form view's `fields` sub-rows + * (`view.zod.ts`) and the Studio metadata forms are other schemas and are not + * judged here. + * + * ## Intake — which doors reach this rule at all + * + * `FieldSchema` is strict, and its `FIELD_KEY_GUIDANCE` rows for these three + * keys make the parse refusal name the child-object route. So on every door + * that PARSES first this rule is unreachable by construction — the object + * never arrives. Measured on the source tree this rule shipped against: + * + * | door | reaches the rule? | + * |--------------------------------------------------------------|-------------------------| + * | `defineStack(x)` (strict default), hence `os validate` / | no — refused at load | + * | `os build` / `os lint` on such a config | | + * | `saveMetaItem` (Studio / REST `/meta` / MCP) | no — 422 at the parse | + * | **`os lint` on a RAW object-literal config** (never parses) | **yes — fires** | + * | **`defineStack(x, { strict: false })`** (keeps the key) | **yes — fires** | + * | **the metadata-generation rubric** (`scoreMetadata`), beside | **yes — fires** | + * | its own schema verdict | | + * | **`lintDataModel` called directly with an unparsed stack** | **yes — fires** | + * + * It is not a second opinion on the parse: it exists so the unparsed doors + * name the same route instead of letting the key ride to `os build`. A + * consumer crediting it as coverage on a `defineStack`-authored app is + * crediting the wrong gate — there the credit belongs to the schema. + */ +export const FIELD_EMBEDDED_SUB_FIELDS = 'field/embedded-sub-fields'; + +/** The two embedded-JSON field types R14 judges. */ +const EMBEDDED_JSON_FIELD_TYPES = new Set(['composite', 'repeater']); + +/** + * The keys an author writes when they expect a typed list on R14's types — the + * three the ruling names, the same three `FIELD_KEY_GUIDANCE` answers. + */ +const SUB_FIELD_KEYS = ['fields', 'subFields', 'columns'] as const; + /** * [#22161] The consequence every `controlled_by_parent` arm of R2 ends with — * one wording, so the three shapes cannot drift. Why such a record is @@ -778,6 +822,25 @@ export function lintDataModel(objects: any[]): LintIssue[] { } } + // R14 — an object-level `composite` / `repeater` declares no sub-fields + // (see FIELD_EMBEDDED_SUB_FIELDS for the ruling and the doors it reaches). + if (EMBEDDED_JSON_FIELD_TYPES.has(type)) { + const declared = SUB_FIELD_KEYS.filter((k) => Object.prototype.hasOwnProperty.call(def, k)); + if (declared.length > 0) { + issues.push({ + severity: 'error', + rule: FIELD_EMBEDDED_SUB_FIELDS, + message: + `${type} field "${obj.name}.${fieldName}" declares \`${declared.join('`, `')}\`, but an ` + + `object-level ${type} is untyped JSON with no sub-fields`, + path: `${fieldPath}.${declared[0]}`, + fix: + 'Make the typed list a child object whose `master_detail` field points back here ' + + "(`inlineEdit: 'grid'` edits it inline), and remove the key.", + }); + } + } + // R13 — a `min`/`max` roll-up must aggregate a child field whose ANSWER // fits the column the roll-up is stored into. // diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 211e012a8a1..81a359e0902 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -1009,6 +1009,7 @@ export { RELATIONSHIP_MASTER_DETAIL_REQUIRED, RELATIONSHIP_DELETE_BEHAVIOR, ROLLUP_NON_NUMERIC_AGGREGAND, + FIELD_EMBEDDED_SUB_FIELDS, } from './data-model-rules.js'; export type { LintIssue, LocatedLintIssue, Severity } from './data-model-rules.js'; diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 1ed3de20a02..c3597dfad10 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2958,6 +2958,32 @@ const ROLLUP_NON_NUMERIC_AGGREGAND_EXPLANATION: RuleExplanation = { ], }; +const FIELD_EMBEDDED_SUB_FIELDS_EXPLANATION: RuleExplanation = { + rule: 'field/embedded-sub-fields', + covers: 'why a composite or repeater field has no sub-fields', + paragraphs: [ + 'On an object field, `composite` and `repeater` are untyped JSON containers of CMS lineage (a ' + + 'Strapi component, an ACF group or repeater): the value is stored as JSON on the parent row, ' + + 'and `FieldSchema` has no member that declares its inner keys. So nothing gives a row its ' + + 'labels, per-column types, options, required flags or translations, nothing validates one, ' + + 'and nothing — a flow included — can address a single row. Declaring `fields`, `subFields` ' + + 'or `columns` on one promises a typed list the platform does not build.', + 'A list that needs typed columns is a child object: its own fields carry the types, labels, ' + + 'options and validation, and a `master_detail` field on it points back at the parent. The ' + + 'parent\'s record page lists the children as a related list, and `inlineEdit: \'grid\'` on that ' + + 'field edits them as an inline grid on the parent\'s form. Each row then has a record identity ' + + 'that a lookup, a roll-up or a flow can reach. Keep a `composite` / `repeater` only for JSON ' + + 'whose shape the platform does not need to know.', + 'Not judged: a form view\'s `fields` sub-rows and the metadata forms Studio renders — those are ' + + 'other schemas that do declare sub-rows.', + 'Reach: the strict field schema refuses all three keys at the parse with this same route, so ' + + '`defineStack`, `os validate`, `os build` and the metadata save door never hand such a field ' + + 'to this rule. It fires only on the doors that never parse — `os lint` on a raw object-literal ' + + 'config, `defineStack(x, { strict: false })`, and the metadata-generation rubric beside its own ' + + 'schema verdict — where without it the key would ride unnamed to `os build`.', + ], +}; + const RELATIONSHIP_DELETE_BEHAVIOR_EXPLANATION: RuleExplanation = { rule: 'relationship/delete-behavior', covers: 'what deleting a master does to its details', @@ -4997,6 +5023,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [RELATIONSHIP_MASTER_DETAIL_REQUIRED_EXPLANATION.rule]: RELATIONSHIP_MASTER_DETAIL_REQUIRED_EXPLANATION, [ROLLUP_NON_NUMERIC_AGGREGAND_EXPLANATION.rule]: ROLLUP_NON_NUMERIC_AGGREGAND_EXPLANATION, [RELATIONSHIP_DELETE_BEHAVIOR_EXPLANATION.rule]: RELATIONSHIP_DELETE_BEHAVIOR_EXPLANATION, + [FIELD_EMBEDDED_SUB_FIELDS_EXPLANATION.rule]: FIELD_EMBEDDED_SUB_FIELDS_EXPLANATION, [DEFAULT_AGENT_LEGACY_ALIAS_EXPLANATION.rule]: DEFAULT_AGENT_LEGACY_ALIAS_EXPLANATION, [DEFAULT_AGENT_OUTSIDE_ROSTER_EXPLANATION.rule]: DEFAULT_AGENT_OUTSIDE_ROSTER_EXPLANATION, [AGENT_AUTHORING_WITHDRAWN_EXPLANATION.rule]: AGENT_AUTHORING_WITHDRAWN_EXPLANATION, diff --git a/packages/spec/src/data/authoring-key-lint.test.ts b/packages/spec/src/data/authoring-key-lint.test.ts index 11564166872..2a3500c53e9 100644 --- a/packages/spec/src/data/authoring-key-lint.test.ts +++ b/packages/spec/src/data/authoring-key-lint.test.ts @@ -164,3 +164,52 @@ describe('the `id_field` retirement is reached, not merely declared', () => { expect(shapeKeys(InlineGridColumnSchema)).toContain('idField'); }); }); + +/** + * An object-level `composite` / `repeater` is untyped JSON by ruling + * 6386744473, and a typed list is a child object. The parse already refused + * these keys before the rows existed; what the rows add is the ROUTE, so that + * is what is pinned — on the same channel as the `id_field` block above. + */ +describe('a typed list declared on an embedded-JSON field is refused with the child-object route', () => { + const unrecognized = (value: unknown) => { + const r = FieldSchema.safeParse(value); + expect(r.success).toBe(false); + if (r.success) throw new Error('unreachable'); + const issue = r.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, 'the field parse did not refuse the unknown key at all').toBeTruthy(); + return issue!; + }; + const SUB_ROWS = [{ name: 'item', label: 'Item', type: 'text' }]; + const CASES: Array<[string, unknown]> = [ + ['fields', SUB_ROWS], + ['subFields', SUB_ROWS], + ['columns', ['item', 'score']], + ]; + + for (const type of ['repeater', 'composite'] as const) { + for (const [key, value] of CASES) { + it(`${type} + \`${key}\`: refused by name, answered with this table's sentence`, () => { + const issue = unrecognized({ name: 'checklist', label: 'Checklist', type, [key]: value }); + expect((issue as { keys?: string[] }).keys).toEqual([key]); + expect(issue.message).toContain(FIELD_KEY_GUIDANCE[key]!.why!); + expect(issue.message).toContain('`master_detail`'); + expect(issue.message).not.toContain('Did you mean'); + }); + } + } + + it('the field itself, with no sub-field key, still parses — the type is untyped, not refused', () => { + for (const type of ['repeater', 'composite'] as const) { + expect(FieldSchema.safeParse({ name: 'checklist', label: 'Checklist', type }).success).toBe(true); + } + }); + + it('`columns` names the three live column keys, and each really is declared', () => { + const why = FIELD_KEY_GUIDANCE.columns!.why!; + for (const live of ['lookupColumns', 'relatedListColumns', 'inlineColumns']) { + expect(why).toContain(`\`${live}\``); + expect(shapeKeys(FieldSchema)).toContain(live); + } + }); +}); diff --git a/packages/spec/src/data/authoring-key-lint.ts b/packages/spec/src/data/authoring-key-lint.ts index 07d4f26c4fb..a2d3a01b682 100644 --- a/packages/spec/src/data/authoring-key-lint.ts +++ b/packages/spec/src/data/authoring-key-lint.ts @@ -72,6 +72,16 @@ export interface UnknownAuthoringKeyFinding { guidance?: string; } +/** + * The route for a typed list, shared by the sub-field rows below. Module-private: + * the rows are the published face, and `@objectstack/lint` words its own finding. + */ +const EMBEDDED_JSON_TYPED_LIST = + 'An object-level `composite` / `repeater` stores untyped JSON — no per-column labels, types, ' + + 'options or required flags. A list that needs typed columns is a child object whose ' + + '`master_detail` field points back at this object; `inlineEdit: \'grid\'` on that field edits ' + + 'its rows as an inline grid on this object\'s form.'; + /** * Semantic near-misses on `FieldSchema` — a different **word** for the same * intent, or a key that was retired outright. @@ -144,6 +154,18 @@ export const FIELD_KEY_GUIDANCE: Readonly< // ⛔ Do not "normalise" this to `idField` to match its neighbours; the // reachability test in `authoring-key-lint.test.ts` fails if you do. id_field: { why: '`id_field` was never a FieldSchema key: a lookup stores the referenced record\'s id, and which field holds that value is not an authored per-field choice — the picker resolves record identity itself. Use `displayField` to change which field is SHOWN as a candidate\'s label, and a seed dataset\'s `externalId` to author references by a portable natural key. The live `idField` is the one on an `inlineColumns` entry — the GridColumn mirror in `field.zod.ts`, which is a DIFFERENT schema.' }, + // ── A typed list declared on an embedded-JSON field (ruling 6386744473) ── + // An object-level `composite` / `repeater` is untyped JSON BY RULING: no + // sub-field member is added, and a list that needs typed columns is a child + // object. These are the spellings an author reaches for when they expect one + // (the three the ruling names; objectui's structured cell documents `fields` / + // `subFields` as refused). Without a row the parse names the key and nothing + // else, so the author learns the key is wrong but not where the list goes. + // `@objectstack/lint`'s `field/embedded-sub-fields` says the same on the doors + // that never parse. A form view's `fields` sub-rows are a different schema. + fields: { why: `\`fields\` is not a field key: a field declares no sub-fields. ${EMBEDDED_JSON_TYPED_LIST}` }, + subFields: { why: `\`subFields\` is not a field key: a field declares no sub-fields. ${EMBEDDED_JSON_TYPED_LIST}` }, + columns: { why: `\`columns\` is not a field key. A lookup's picker columns are \`lookupColumns\`; a \`master_detail\` field's related-list columns are \`relatedListColumns\` and its inline-grid columns \`inlineColumns\`. ${EMBEDDED_JSON_TYPED_LIST}` }, }); /** diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 043eb27d3ec..445bb270381 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -92,9 +92,16 @@ export const FieldType = z.enum([ 'image', 'file', 'avatar', 'video', 'audio', // Calculated / System 'formula', 'summary', 'autonumber', - // Embedded structured values (stored as JSON on the parent row — no separate table / FK) - 'composite', // Single embedded sub-object with declared sub-fields (≈ Strapi component / ACF group) - 'repeater', // Repeating embedded sub-object array with declared sub-fields (≈ Strapi repeatable component / ACF repeater) + // Embedded structured values (stored as JSON on the parent row — no separate table / FK). + // On an OBJECT field, `composite` / `repeater` are UNTYPED JSON containers of CMS lineage + // (≈ Strapi component / ACF group / ACF repeater): `FieldSchema` declares no sub-field + // member, so nothing types, labels, validates or addresses the inner keys or rows. + // A list that needs typed columns is a CHILD OBJECT whose `master_detail` field points + // back at the parent; `inlineEdit: 'grid'` edits its rows inline on the parent's form + // (ruling 6386744473). + // A form view's `fields` sub-rows (`ui/view.zod.ts`) are a different surface. + 'composite', // Single embedded JSON object, untyped — no declared sub-fields + 'repeater', // Array of embedded JSON objects, untyped — no declared sub-fields or columns 'record', // Name-keyed map of embedded sub-objects (Record). Insertion order = display order. Used for collections where each item has a stable machine name (e.g. object.fields). See ADR-0007. // Enhanced Types 'location', // GPS coordinates