Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/25681-repeater-untyped-json.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 28 additions & 3 deletions content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="warn">
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.
</Callout>

### `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`
Expand Down
8 changes: 5 additions & 3 deletions content/docs/data-modeling/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
6 changes: 4 additions & 2 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
</Callout>

Fields sit on the same row because the section says how many columns it has, and
Expand Down
118 changes: 118 additions & 0 deletions packages/lint/src/data-model-rules.embedded-sub-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => [
{ 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<string, unknown>;
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);
}
});
});
27 changes: 24 additions & 3 deletions packages/lint/src/data-model-rules.fix-lines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
// 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.

import { describe, expect, it } from 'vitest';

import {
FIELD_EMBEDDED_SUB_FIELDS,
lintDataModel,
lintLegacyOrganizationComposites,
lintUniqueDeclarations,
Expand All @@ -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';
Expand Down Expand Up @@ -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 " +
Expand All @@ -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();
Expand Down
63 changes: 63 additions & 0 deletions packages/lint/src/data-model-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
//
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading
Loading