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
37 changes: 37 additions & 0 deletions .changeset/view-door-viewitem-record-config-rung.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/lint": minor
---

feat(lint): a standalone ViewItem record's nested `config.sort` / `config.searchableFields` reach the runtime publish gate (#10001)

An `active`-state `view` save through `saveMetaItem` (Studio, REST `/meta`
item CRUD, an MCP/AI author) whose body is a standalone ViewItem RECORD —
`ViewMetadataSchema`'s member 1, `{ name, object, viewKind: 'list', config }`,
the shape a Studio-saved view takes and the shape objectui's `updateView`
round-trips on every pin/reorder toggle — is now refused with the existing
422 `invalid_metadata` envelope when its `config.sort` / `config.searchableFields`
declares a field the bound object cannot honor: an unknown name, a virtual
(`formula`) sort target with no stored column to ORDER BY, or a search
narrowing the #4254 ingress gate would refuse on every toolbar search. #9313
closed the same gap for the flattened list overlay, one union member over;
the record's declarations live one level down, inside `config`, and were
judged by neither list-view field rule — so a record write carrying
`config.sort: [{ field: '' }]` published in silence and answered
`400 INVALID_SORT` (#6994/#7095) on the view's first fetch, every load.

Walk-only, by design: #9313 already widened the reference-integrity suite
entry and exactly these two members onto `view` writes, so this change adds
the RECORD rung to both twin walks — recognised by the wire union's own
member discrimination (`viewKind: 'list'` AND a record-shaped `config`; the
flattened-overlay rung keeps its `no nested config` guard, a strict container
carries neither key, and a `form` record has no list-field surface), judged
against `listViewObject(config) ?? record.object` at path
`views[i].config.sort[…]` / `views[i].config.searchableFields[…]`. The
per-member granularity split is unchanged: no further suite member crosses
onto `view`. Measured before shipping: 0 refusals and 0 advisories over 39
record-shaped console round-trip bodies (one per shipped list surface,
`config.sort[].id` decorations and `isPinned`/`sortOrder` riding along, the
shape `saveMetaItem` really stores) across the four shipped stacks — a lower
bound, as every authored corpus is. Draft saves are untouched (D1), stored
rows keep being served (ADR-0087 asymmetry), and
`OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to a loud log.
131 changes: 117 additions & 14 deletions packages/lint/src/runtime-gate.view-writes.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #9313 — the two list-view FIELD rules at the runtime publish gate, on the
// flattened standalone list overlay.
// flattened standalone list overlay. #10001 extends the same door onto the
// standalone ViewItem RECORD (`config.sort` / `config.searchableFields`, one
// level down) — the boundary marker #9313 left here is flipped in the record
// block below.
//
// The card's own trap, restated because every test here exists to spring it:
// widening the reference-integrity suite's `runtimeTypes` to `view` is
Expand DownExpand Up@@ -176,24 +179,124 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => {
expect(offFlow).toEqual([]);
});

// ── the shapes this card deliberately does not judge ──
// ── [#10001] the ViewItem RECORD rung — the #9313 boundary marker, flipped ──
//
// The test that stood here pinned the opposite: "a ViewItem RECORD's nested
// `config.sort` is not judged here — recorded scope, not a rung that fell
// off". That marker kept the gap RECORDED until its own card; #10001 is that
// card, and this block is the rung's proof. The record is
// `ViewMetadataSchema`'s member 1 (`ViewItemWireSchema`,
// `{ name, object, viewKind: 'list', config }`) — the shape a Studio-saved
// view takes through `PUT /api/v1/meta/view`, and a hot one: objectui's
// `updateView` GETs the stored record and PUTs `{ ...current, ...partial }`
// (`view.zod.ts`'s #5074 trace), so every pin/reorder toggle round-trips the
// whole record, `config` included.

it('a ViewItem RECORD\'s nested `config.sort` is not judged here — recorded scope, not a rung that fell off', () => {
// `{ name, object, viewKind, config }` is `ViewMetadataSchema`'s member 1;
// its bad sort lives one level down, in `config`, which no walk reads.
// #9313's scope is the flattened overlay (the fence in the claim), and the
// record shape is filed as its own follow-up — this pin is the boundary
// marker that keeps the gap RECORDED instead of rediscovered.
const record = {
name: 'crm_case.pipeline',
/** A ViewItem record as `saveMetaItem` stores it (original body, verbatim). */
const record = (
configPatch: Record<string, unknown>,
patch: Record<string, unknown> = {},
) => ({
name: 'crm_case.pipeline',
object: 'crm_case',
viewKind: 'list',
config: { type: 'grid', columns: ['name'], ...configPatch },
...patch,
});

it('REFUSES a record\'s `config.sort` naming an unknown field — the flipped boundary marker (#10001)', () => {
const { errors } = gate(record({ sort: [{ field: 'amout', order: 'desc' }] }));
const f = errors.find((e) => e.rule === SORT_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].config.sort[0]');
expect(f!.where).toContain('ViewItem record');
});

it('REFUSES a record\'s `config.sort` naming a formula field — no column to ORDER BY (#10001)', () => {
const { errors } = gate(record({ sort: [{ field: 'days_open', order: 'desc' }] }));
const f = errors.find((e) => e.rule === SORT_FIELD_UNSORTABLE);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].config.sort[0]');
});

it('REFUSES a record\'s `config.searchableFields` entry that resolves to no field (#10001)', () => {
const { errors } = gate(record({ searchableFields: ['name', 'budget'] }));
const f = errors.find((e) => e.rule === SEARCHABLE_FIELD_UNKNOWN);
expect(f, JSON.stringify(errors)).toBeDefined();
expect(f!.path).toBe('views[0].config.searchableFields[1]');
});

it('honors the record config\'s own `data.object` binding over the record\'s `object` (#10001)', () => {
// ADR-0047's explicit retarget, resolved on the CONFIG (where a record's
// data binding lives), ahead of the record's top-level `object` — the
// same order every other list-view rung reads.
const { errors } = gate(record(
{ data: { provider: 'object', object: 'crm_case' }, sort: 'nonexistent_field desc' },
{ object: 'crm_other' },
));
expect(errors.map((e) => e.rule)).toContain(SORT_FIELD_UNKNOWN);
});

it('a console-shaped record round-trip publishes clean — `updateView`\'s merged PUT, decorations and all (#10001)', () => {
// `{ ...current, ...partial }`: `isPinned`/`sortOrder` at the top,
// `config.sort[].id` carrying objectui's `crypto.randomUUID()` row ids
// (#5074) — `saveMetaItem` persists the original body, so the gate judges
// exactly this shape.
const result = gate(record(
{
sort: [{ id: 'a2b4c86e-1111-4111-8111-000000000003', field: 'status', order: 'asc' }],
searchableFields: ['name'],
},
{ isPinned: true, sortOrder: 2 },
));
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.rulesRun).toContain('validateReferenceIntegrity');
});

it('judges a record on its `config` rung ONLY — a stray top-level `sort` is not judged as an overlay (#10001)', () => {
// The rung-split control. The overlay rung's `!isRec(config)` guard keeps
// record bodies out, and the record rung reads `config` alone — so a
// record carrying a stray top-level `sort` (the wire schema strips the
// key, but `saveMetaItem` persists the ORIGINAL body) yields exactly one
// finding, on the config path. Two findings here = the rungs leaked into
// each other's shapes; a top-level-path finding = the record was read as
// an overlay. Both are the drift this control exists to catch.
const { errors } = gate(record(
{ sort: [{ field: 'amout', order: 'desc' }] },
{ sort: [{ field: 'also_not_a_field', order: 'asc' }] },
));
const sortFindings = errors.filter((e) => e.rule === SORT_FIELD_UNKNOWN);
expect(sortFindings, JSON.stringify(errors)).toHaveLength(1);
expect(sortFindings[0].path).toBe('views[0].config.sort[0]');
});

it('a FORM record\'s config declares no list-field surface and is not judged (#10001)', () => {
// The rung keys on `viewKind: 'list'`, mirroring the overlay rung and the
// wire union's own arms: a `form` record carries `FormViewSchema` config,
// which has no `sort` / `searchableFields` — a stray one riding in the
// stored body must not be judged by a list-view rule.
const { errors } = gate({
name: 'crm_case.edit',
object: 'crm_case',
viewKind: 'list',
config: { type: 'grid', columns: ['name'], sort: [{ field: 'amout', order: 'desc' }] },
};
const { errors } = gate(record);
viewKind: 'form',
config: { type: 'simple', fields: ['name'], sort: [{ field: 'amout', order: 'desc' }] },
});
expect(errors, JSON.stringify(errors)).toEqual([]);
});

// ── positive control: the pre-#10001 rungs behave EXACTLY as before ──

it('the flattened overlay is judged exactly once, on its top-level path — no record-rung leak (#10001)', () => {
// Passes on origin/main BEFORE the record rung and must keep passing
// after: one finding, top-level path. A `config`-rung leak into the
// overlay shape would move the path; a double judgment would add one.
const { errors } = gate(overlay({ sort: [{ field: 'amout', order: 'desc' }] }));
const sortFindings = errors.filter((e) => e.rule === SORT_FIELD_UNKNOWN);
expect(sortFindings, JSON.stringify(errors)).toHaveLength(1);
expect(sortFindings[0].path).toBe('views[0].sort[0]');
expect(sortFindings[0].where).toContain('flattened list overlay');
});

// ── the D4 differential, on the newly reachable rules ──

it('does not blame a `view` write for a stored object\'s own bad list view', () => {
Expand Down
59 changes: 56 additions & 3 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -351,9 +351,15 @@ describe('validateSearchableFields — list views that narrow the set', () => {
expect(findings[0].path).toBe('views[0].searchableFields[1]');
});

it('does NOT read a ViewItem record\'s top level as an overlay (#9313)', () => {
// The record shape carries its set in `config` — a different rung,
// deliberately not walked (recorded scope; see the sort twin's module note).
// ── [#10001] the RECORD rung: a standalone ViewItem record ──
//
// The record shape (`ViewMetadataSchema`'s member 1) carries its set one
// level down, in `config`. The test that stood here pinned the #9313
// boundary ("a different rung, deliberately not walked"); #10001 closes
// that recorded scope — recogniser and binding order mirrored from the
// sort twin, which carries the full note.

it('flags a stale entry on a ViewItem record\'s nested `config.searchableFields` (#10001)', () => {
const findings = validateSearchableFields({
objects: [objectWithFields],
views: [
Expand All@@ -365,6 +371,53 @@ describe('validateSearchableFields — list views that narrow the set', () => {
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNKNOWN);
expect(findings[0].path).toBe('views[0].config.searchableFields[0]');
expect(findings[0].where).toBe('view "crm_account.pipeline" (ViewItem record)');
});

it('judges a record\'s set as a NARROWING — the #4830 admissibility applies (#10001)', () => {
// A lookup-typed entry in the record's config set is echoed as the
// `$searchFields` override on the view's toolbar search, the same as
// every other list-view surface.
const findings = validateSearchableFields({
objects: [
{
name: 'crm_case',
fields: { name: { type: 'text' }, account_id: { type: 'lookup' } },
},
],
views: [
{
name: 'crm_case.mine',
object: 'crm_case',
viewKind: 'list',
config: { type: 'grid', searchableFields: ['name', 'account_id'] },
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
expect(findings[0].path).toBe('views[0].config.searchableFields[1]');
});

it('still does NOT read a record\'s top level as an overlay (#10001)', () => {
// A record carrying a stray top-level set (`saveMetaItem` persists the
// original body) is judged on `config.searchableFields` alone — the
// overlay rung's `!isRec(config)` guard holds, exactly as before #10001.
const findings = validateSearchableFields({
objects: [objectWithFields],
views: [
{
name: 'crm_account.pipeline',
object: 'crm_account',
viewKind: 'list',
searchableFields: ['not_a_field'],
config: { type: 'grid', columns: ['name'], searchableFields: ['name'] },
},
],
});
expect(findings).toEqual([]);
});

Expand Down
35 changes: 29 additions & 6 deletions packages/lint/src/validate-searchable-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -530,10 +530,11 @@ export function checkSearchableFieldList(

/**
* Validate every `searchableFields` declaration in the stack — the object's own
* (the canonical set, ADR-0061) and the list views that narrow it, including a
* flattened standalone list overlay authored as a `views[]` entry itself
* (#9313 — the `PUT /api/v1/meta/view` shape the runtime publish gate
* snapshots). Returns findings (empty = clean).
* (the canonical set, ADR-0061) and the list views that narrow it, including
* the two standalone `views[]` shapes the `PUT /api/v1/meta/view` door
* carries and the runtime publish gate snapshots: the flattened list overlay
* (#9313, top-level set) and the ViewItem record (#10001,
* `config.searchableFields` one level down). Returns findings (empty = clean).
*
* The react page surface (`<ListView searchableFields={…}>`) is deliberately
* NOT walked here: its declaration lives inside JSX source, and
Expand DownExpand Up@@ -622,8 +623,8 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[
// recognises it (`validate-sortable-fields.ts` carries the full note):
// `viewKind: 'list'` (required on the overlay arm since #7741, refused by
// name on the strict container schema) with no nested `config` (that
// shape is a ViewItem RECORD — its `config.searchableFields` is a
// different rung, deliberately not walked here). A `narrowing`, like
// shape is a ViewItem RECORD — judged by its own record rung below since
// #10001). A `narrowing`, like
// every list-view surface: the overlay's set is echoed verbatim as the
// `$searchFields` override and judged by the #4254 ingress gate.
if (view.viewKind === 'list' && !isRec(view.config)) {
Expand All@@ -637,6 +638,28 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[
);
}

// ── [#10001] The RECORD rung: a standalone ViewItem record ──
//
// The self rung's structural complement — `ViewMetadataSchema`'s member 1
// (`ViewItemWireSchema`, `{ name, object, viewKind: 'list', config }`),
// the Studio-saved-view shape through the same door, its set one level
// down inside `config`. Recogniser, binding order and the deliberate
// non-reading of the record's top level are mirrored from the sort twin
// (`validate-sortable-fields.ts`), which carries the full note. A
// `narrowing` for the same reason as every list-view surface: the
// record's config set is echoed as the `$searchFields` override on that
// view's toolbar search and judged by the #4254 ingress gate.
if (view.viewKind === 'list' && isRec(view.config)) {
check(
view.config.searchableFields,
listViewObject(view.config) ?? viewObject,
`view "${viewLabel}" (ViewItem record)`,
`views[${vi}].config.searchableFields`,
'list-view searchableFields',
'narrowing',
);
}

if (isRec(view.list)) {
check(
view.list.searchableFields,
Expand Down
Loading
Loading