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
59 changes: 59 additions & 0 deletions .changeset/8317-strip-imported-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
'@object-ui/types': minor
---

The validator stops writing values into an author's document on the keys it imports —
**this mirror authors no default, imported subschemas included** (objectui#8317,
director ruling, decision batch #90, 2026-09-08, under the maintainer's standing
delegation).

Decision batch #69 (objectui#7735) ruled a principle: *a validator validates; it does
not write values into an author's document.* PR #8299 delivered it for the 41
`.default()` call sites written in this package's own mirrors. Measured afterwards, **57
`ZodDefault` nodes were still reachable from the published `@object-ui/types/zod`
barrel**, every one inside a subschema imported by reference from `@objectstack/spec` —
so `safeValidateSchema` went on substituting on those keys, with 41 stripped and 57 not
and no way to tell which was which from the document. Batch #90 ruled that the
principle holds for **every** key the validator answers, and those 57 are now stripped
where the spec enters this package (`.removeDefault()`, the established local pattern,
applied through `zod/imported-defaults.ts`).

**What changes.** `safeValidateSchema` / `validateSchema` return the author's document
instead of the author's document plus keys they did not write:

```js
safeValidateSchema({ type: 'object-view', objectName: 'account', navigation: {} })
// before → navigation: { mode: 'page', preventNavigation: false, openNewTab: false, size: 'auto' }
// after → navigation: {}
```

The affected families: `app`'s `active` / `isDefault` · `object-view`'s
`navigation.{mode, preventNavigation, openNewTab, size}` · `list-view`'s `sharing.type`,
`userActions.*`, `addRecord.*`, `appearance.*`, `chart.chartType`, `tabs[].*`,
`timeline.scale` · `kanban`'s `grouping.fields[].{order, collapsed}` · `page`'s `kind`
and the whole `interfaceConfig` subtree · dashboard `chartConfig.*`, `header.*` and
`globalFilters[].scope` · `object-gallery`'s `gallery.*` · `contextSelectors[].*` ·
`prefix.type`, `pagination.pageSize`, `selection.type` and the HTTP `method`.

**The accept set does not move, and that is measured, not asserted.** Every one of these
keys stays omissible — `.default(v)` carries optionality as well as a value, so the
boundary re-optionalises what `.removeDefault()` hands back. A differential against the
raw `@objectstack/spec` schemas (a permanent pin, since both sides are importable) plus
a run over this repository's own 1,077-document corpus found **zero** documents whose
acceptance changed, on the tolerant face and on the strict authoring face alike. Keys,
value vocabularies and every `.refine()` / `.superRefine()` the spec installed are
carried through unchanged.

**Migration.** If you read a value off `result.data` and relied on it being present
without having written it, read it off your own fallback instead — batch #69 already
ruled that the renderer's fallback is *the* authoritative default, and the renderers in
this workspace already carry theirs (`navigation?.mode ?? 'page'`,
`userActions.search !== false`). A census of every consumer of this barrel found no such
read: three production importers, one of which reads `result.data` at all, and it reads
only `type` / `id` / `label` / `title` / `children` — none of which carries a default on
any of the 107 component arms.

⛔ **Not taken:** changing `@objectstack/spec` itself (1,546 call sites on another
repository's release train). This is reversible into it — every strip becomes a no-op
the day the spec adopts the same principle, because the boundary is the identity
function on a subtree with nothing to strip.
495 changes: 495 additions & 0 deletions packages/types/src/__tests__/imported-defaults-8317.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
} from '@objectstack/spec/ui';

import { ObjectViewSchema } from '../zod/objectql.zod';
import { stripImportedDefaults } from '../zod/imported-defaults.js';
import { ViewSwitcherSchema } from '../zod/views.zod';
import { safeValidateSchema } from '../zod/index.zod';
import type { ObjectViewSchema as TsObjectViewSchema, NamedListView } from '../objectql';
Expand Down Expand Up @@ -322,11 +323,19 @@ describe('objectui#7779 — the zod mirror declares the eight keys and the tombs

describe('objectui#7779 — the three spec-modelled keys are the spec\'s own slots BY REFERENCE', () => {
it.each(SPEC_REFERENCED)('`%s` IS `SpecListViewSchema.shape.%s` — the same object, not a copy', (key) => {
// ⭐ objectui#8317 (decision batch #90): the spec enters this package through
// `stripImportedDefaults`, so the object to compare against is
// `SpecListViewSchema` AS IT ARRIVES HERE. That hop removes the imported
// `ZodDefault`s and nothing else — a slot with none comes back
// reference-equal, which is why `searchableFields` and `filterableFields`
// are unchanged by it and `navigation` (four defaults) is not.
// ⛔ Still `toBe`: a local restatement is the drift objectui#4588 measured.
expect(
shapeMember(ObjectViewSchema, key),
`ObjectViewSchema.${key} must be SpecListViewSchema.shape.${key} by reference — a local ` +
'restatement is the drift objectui#4588 measured; if the spec slot is wrong, fix the spec',
).toBe(shapeMember(SpecListViewSchema, key));
`ObjectViewSchema.${key} must be stripImportedDefaults(SpecListViewSchema).shape.${key} by ` +
'reference — a local restatement is the drift objectui#4588 measured; if the spec slot is ' +
'wrong, fix the spec',
).toBe(shapeMember(stripImportedDefaults(SpecListViewSchema), key));
});

it.each(SPEC_REFERENCED)('`%s` is also the slot `ObjectListViewSchema` carries under that name (the spec models it on both view faces)', (key) => {
Expand All @@ -342,13 +351,29 @@ describe('objectui#7779 — the three spec-modelled keys are the spec\'s own slo
it('`navigation` parses exactly as the spec\'s `NavigationConfigSchema` does (the slot is that schema, optional)', () => {
const slot = shapeMember(ObjectViewSchema, 'navigation') as { safeParse(v: unknown): { success: boolean; data?: unknown } };
// The spec declares `mode: NavigationModeSchema.default('page')`, so a
// config that lets the mode default is legal authored metadata — the exact
// input the hand copy of objectui#4588 refused.
// config that omits the mode is legal authored metadata — the exact input
// the hand copy of objectui#4588 refused. ⭐ It is STILL accepted, and since
// objectui#8317 (decision batch #90) the parsed document no longer carries a
// `mode` the author did not write: acceptance unchanged, substitution gone.
const defaulted = slot.safeParse({ view: 'summary_view' });
expect(defaulted.success).toBe(true);
expect((defaulted.data as { mode?: string }).mode).toBe('page');
expect((defaulted.data as { mode?: string }).mode).toBeUndefined();
expect(defaulted.data).toEqual({ view: 'summary_view' });
// …and a document that DOES write it round-trips unchanged, which is what
// separates "stopped substituting" from "stopped declaring".
expect(slot.safeParse({ view: 'summary_view', mode: 'page' }).data)
.toEqual({ view: 'summary_view', mode: 'page' });
// ⚠️ Two comparisons, deliberately. The first is against the spec slot AS IT
// ENTERS THIS PACKAGE — the object actually under test. The second is
// against the RAW spec slot, and it is the measurement that says the strip
// moved no accept set: every probe agrees with upstream, defaults or not.
const enteredSlot = stripImportedDefaults(SpecNavigationConfigSchema).optional();
for (const probe of [{ mode: 'drawer' }, { mode: 'bogus' }, 'page', { mode: 'page', bogus: 1 }, undefined]) {
expect(slot.safeParse(probe).success, JSON.stringify(probe)).toBe(SpecNavigationConfigSchema.optional().safeParse(probe).success);
expect(slot.safeParse(probe).success, JSON.stringify(probe)).toBe(enteredSlot.safeParse(probe).success);
expect(
slot.safeParse(probe).success,
`${JSON.stringify(probe)} — the strip must move no accept set`,
).toBe(SpecNavigationConfigSchema.optional().safeParse(probe).success);
}
// A string is refused at `navigation`, an unknown mode at `navigation.mode`:
// the spec's strict object, not a local `z.any()`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,44 @@ describe('AppContextSelectorSchema derives from the spec', () => {
expect(localKeys.filter((k) => !specKeys.includes(k))).toEqual([]);
});

it('keeps the spec keys the old hand copy restated, defaults included', () => {
const parsed = AppContextSelectorSchema.parse({
it('keeps the spec keys the old hand copy restated — DECLARED, and no longer written for the author', () => {
// ⭐ INVERTED by objectui#8317 (decision batch #90). This pin used to read
// the substituted values back out of the parse output —
// `optionsSource.valueKey === 'id'`, `labelKey === 'name'`,
// `persist === 'query'` — and that reading is exactly the defect batch #69
// ruled against: a validator writing values into an author's document. The
// three keys are spec-declared and still accepted; what changed is that an
// author who did not write them does not get them back.
//
// ⚠️ The comment below this block has said the important half all along —
// "a materialised default is indistinguishable from an authored value" —
// and that indistinguishability is what objectui#8317 removed. Keep both
// directions asserted, or "stopped substituting" and "stopped declaring"
// read the same from here.
const authored = {
id: 'active_package',
label: 'Package',
optionsSource: { endpoint: '/api/packages' },
});
expect(parsed.optionsSource.valueKey).toBe('id');
expect(parsed.optionsSource.labelKey).toBe('name');
expect(parsed.persist).toBe('query');
};
expect(AppContextSelectorSchema.parse(authored)).toEqual(authored);

const spelled = {
id: 'active_package',
label: 'Package',
persist: 'query',
optionsSource: { endpoint: '/api/packages', valueKey: 'id', labelKey: 'name' },
};
expect(AppContextSelectorSchema.parse(spelled)).toEqual(spelled);

// Still DECLARED — membership is what the spec derivation owes, and it is
// not readable off parse output any more (`BaseSchema` is passthrough, so
// an undeclared key would survive a parse too).
const optionsSource = shapeOf(AppContextSelectorSchema).optionsSource;
for (const key of ['valueKey', 'labelKey']) {
expect(Object.keys(shapeOf(optionsSource)), `optionsSource.${key} must stay declared`).toContain(key);
}
expect(Object.keys(shapeOf(AppContextSelectorSchema))).toContain('persist');

// `includeAll` and `placement` were asserted here until spec 17.0.0
// removed them (framework#4509 / objectui#3208). Both carried schema
// defaults, which is exactly why the liveness lint could not flag them:
Expand Down
88 changes: 77 additions & 11 deletions packages/types/src/__tests__/spec-subschema-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@
* re-exports too until the spec retired its whole theme module — see the
* retirement block below.) These tests pin:
*
* 1. Reference identity for every direct re-export. `toBe` — not structural
* equality — so a "faithful copy" fails too: a copy is a fork.
* 1. Identity for every direct re-export — `toBe`, not structural equality,
* so a "faithful copy" fails too: a copy is a fork. ⭐ Since objectui#8317
* (decision batch #90) the subject of that identity is the spec object AS
* IT ENTERS THIS PACKAGE — `stripImportedDefaults(spec)` — not the raw
* spec object. That call is the whole of the sanctioned difference: it
* removes the imported `ZodDefault`s so this validator stops writing
* values into an author's document, and changes nothing else. A subtree
* with no default to strip comes back REFERENCE-EQUAL to the spec's own
* object, and three pairs below are exactly that, which is what keeps this
* assertion from degrading into "equals whatever the boundary produced".
* 2. The one *derived* schema (`ListColumnSchema`): every spec field flows in,
* the local-extension set is exactly the sanctioned one, and the `summary`
* broadening keeps the spec enum as its first (by-reference) union arm.
Expand Down Expand Up @@ -49,6 +57,7 @@ import {
PaginationConfigSchema,
} from '../zod/objectql.zod.js';
import { ChartTypeSchema } from '../zod/data-display.zod.js';
import { stripImportedDefaults } from '../zod/imported-defaults.js';
import { PageTypeSchema } from '../zod/layout.zod.js';

describe('spec sub-schema re-exports are the spec objects (by reference)', () => {
Expand Down Expand Up @@ -85,7 +94,49 @@ describe('spec sub-schema re-exports are the spec objects (by reference)', () =>

it.each(pairs.map(([name]) => [name] as const))('%s', (name) => {
const [, oui, spec] = pairs.find(([n]) => n === name)!;
expect(oui, `${name} must be the spec schema itself, not a copy`).toBe(spec);
expect(
oui,
`${name} must be the spec schema as it enters this package — ` +
'`stripImportedDefaults(<spec schema>)` and nothing else. A local copy is a fork ' +
'(#2231); a copy with any OTHER edit is the same fork wearing the boundary\'s name.',
).toBe(stripImportedDefaults(spec as never));
});

/**
* ⭐ The control that keeps the assertion above from being vacuous.
*
* `stripImportedDefaults` is the identity function on a subtree with nothing
* to strip (see its walker's identity property), so for a schema that carries
* no `ZodDefault` the assertion above degenerates into `toBe(spec)` — the
* pre-objectui#8317 pin, unchanged and still load-bearing. These three are
* that case, asserted directly so it is a MEASUREMENT rather than a claim in
* a docblock: if the boundary ever started cloning unconditionally, this goes
* red and the pin above would not.
*/
it.each([
['HttpMethodSchema', HttpMethodSchema, SpecHttpMethodSubsetSchema],
['ChartTypeSchema', ChartTypeSchema, SpecChartTypeSchema],
['PageTypeSchema', PageTypeSchema, SpecPageTypeSchema],
] as const)('%s carries no imported default, so it is still the raw spec object', (name, oui, spec) => {
expect(oui, `${name} has nothing to strip and must stay reference-equal to the spec object`).toBe(spec);
});

/**
* …and the other half of the same control: the four that DID carry an
* imported default are NOT the raw spec object any more. That is the
* behaviour change objectui#8317 shipped, pinned so it cannot be undone by
* quietly reverting the boundary — and so a reader who lands on the
* `toBe(stripImportedDefaults(...))` line above can see that the call is
* doing something.
*/
it.each([
['HttpRequestSchema', HttpRequestSchema, SpecHttpRequestSchema],
['ViewDataSchema', ViewDataSchema, SpecViewDataSchema],
['SelectionConfigSchema', SelectionConfigSchema, SpecSelectionConfigSchema],
['PaginationConfigSchema', PaginationConfigSchema, SpecPaginationConfigSchema],
] as const)('%s carried an imported default, so it is the boundary derivation', (name, oui, spec) => {
expect(oui, `${name} still IS the raw spec object — the import boundary was bypassed`).not.toBe(spec);
expect(oui).toBe(stripImportedDefaults(spec as never));
});
});

Expand Down Expand Up @@ -147,9 +198,10 @@ describe('ListColumnSchema is the spec schema (the extension collapsed)', () =>
it('is the spec schema itself, not a copy or an extension', () => {
expect(
ListColumnSchema,
'ListColumnSchema must be SpecListColumnSchema by reference — if a local field ' +
'is needed again, promote it into @objectstack/spec instead of re-extending here',
).toBe(SpecListColumnSchema);
'ListColumnSchema must be SpecListColumnSchema as it enters this package — if a local ' +
'field is needed again, promote it into @objectstack/spec instead of re-extending here. ' +
'The `stripImportedDefaults` hop is objectui#8317 and is the only sanctioned difference.',
).toBe(stripImportedDefaults(SpecListColumnSchema));
});

it('carries no objectui-only fields', () => {
Expand Down Expand Up @@ -209,11 +261,25 @@ describe('ListColumnSchema is the spec schema (the extension collapsed)', () =>
expect(ListColumnSchema.shape.summary.safeParse('median').success).toBe(false);
});

it('prefix parses the compound-cell form and defaults `type` to text', () => {
const parsed = ListColumnSchema.shape.prefix.parse({ field: 'status' });
// spec v17 made `type` a ZodDefault, so ObjectGrid's cell renderer always
// gets a value where the old objectui-local schema left it undefined.
expect(parsed).toEqual({ field: 'status', type: 'text' });
it('prefix parses the compound-cell form and no longer WRITES `type` for the author', () => {
// ⭐ INVERTED by objectui#8317 (decision batch #90). spec v17 made `type` a
// `ZodDefault`, and this pin used to assert the substitution: an author who
// wrote `{ field: 'status' }` got back `{ field: 'status', type: 'text' }`.
// That is precisely the "one authored document, two shapes" defect
// objectui#7735 was opened about, arriving through an imported subschema,
// and batch #90 ruled it out for imported keys as well as local ones. The
// author's document comes back as the author wrote it.
//
// ⛔ Not an accept-set change: the key stays omissible (that is what the
// boundary's re-optionalisation is for) and the value vocabulary is
// untouched — the two probes below are unchanged from the pre-#8317 pin.
// ⚠️ ObjectGrid's cell renderer is where the `'text'` fallback belongs, and
// batch #69 already ruled that the renderer's fallback is THE authoritative
// default; the mirror describing it is not the mirror writing it.
expect(ListColumnSchema.shape.prefix.parse({ field: 'status' })).toEqual({ field: 'status' });
// …and a document that DOES write it round-trips unchanged.
expect(ListColumnSchema.shape.prefix.parse({ field: 'status', type: 'text' }))
.toEqual({ field: 'status', type: 'text' });
expect(ListColumnSchema.shape.prefix.safeParse({ field: 'status', type: 'badge' }).success).toBe(true);
expect(ListColumnSchema.shape.prefix.safeParse({ field: 'status', type: 'pill' }).success).toBe(false);
});
Expand Down
Loading
Loading