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
10 changes: 10 additions & 0 deletions .changeset/zod-array-element-reader-5872.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
---

Internal test-support only: `@object-ui/test-support` gains `arrayElementSchema`
(objectui#5872 class (2)), and the three disagreeing hand-written array-element
walks in `plugin-detail`, `app-shell` (previews) and `app-shell`
(clientValidation) converge onto it, along with one `.unwrap().options` read in
`data-objectstack`. No published runtime source changes: the diff is test files
plus a `private: true` workspace package that ships in no bundle, so this
declaration carries an EMPTY frontmatter — nothing to version.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
*/

import { describe, it, expect } from 'vitest';
import { arrayElementSchema } from '@object-ui/test-support';
import { validateMetadataDraft, hasClientValidator } from './clientValidation';

// ── Fixtures ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -342,23 +343,23 @@ describe('binding parity — the wired schema is the one the platform binds', ()
const isStrict = (s: unknown): boolean =>
(s as ZodInternals)?._zod?.def?.catchall?._zod?.def?.type === 'never';

/** Unwrap optional/default/lazy wrappers down to the schema that carries a shape. */
const unwrap = (s: unknown, depth = 0): unknown => {
const def = (s as { _zod?: { def?: Record<string, unknown> } })?._zod?.def;
if (!def || depth > 6) return s;
const kind = def.type as string | undefined;
if (kind && ['optional', 'nullable', 'default', 'readonly', 'lazy'].includes(kind)) {
const inner = def.innerType ?? (typeof def.getter === 'function' ? def.getter() : undefined);
return unwrap(inner, depth + 1);
}
return s;
};

/** The element schema of an `z.array(X)` collection on the stack schema. */
const arrayElement = (s: unknown): unknown => {
const outer = unwrap(s) as { _zod?: { def?: { type?: string; element?: unknown } } };
return outer?._zod?.def?.type === 'array' ? unwrap(outer._zod.def.element) : undefined;
};
/**
* The element schema of a `z.array(X)` collection on the stack schema —
* `@object-ui/test-support`'s reader, not a fourth local copy of the walk
* (objectui#5872 class (2)). This file carried the strictest of the three
* censused spellings, and the strictness is what the shared reader adopted:
* `undefined` for a node that is not an array, so the
* `expect(element, '… must be an array collection').toBeDefined()` assertions
* below stay discriminating instead of passing for every input.
*
* The one limb NOT carried over is this copy's second unwrap of the element
* itself. Measured on the installed pin: of the 35 array members of
* `ObjectStackSchema`, ZERO have a wrapped element, so the limb never fired;
* and it cannot be added safely, because `zod@4.4.3` puts `unwrap()` on
* `ZodArray` itself, so "unwrap the element too" would descend into an
* array-of-arrays and answer with the wrong entry shape. A wrapped element
* now fails loudly at the `shapeKeys` comparison instead.
*/

it('translation → the schema the kernel metadata-type registry binds', async () => {
const { getMetadataTypeSchema } = await import('@objectstack/spec/kernel');
Expand All @@ -375,7 +376,7 @@ describe('binding parity — the wired schema is the one the platform binds', ()
it('sharing_rule → the schema `ObjectStackSchema.sharingRules` binds', async () => {
const { ObjectStackSchema } = await import('@objectstack/spec');
const { SharingRuleSchema } = await import('@objectstack/spec/security');
const element = arrayElement(ObjectStackSchema.shape.sharingRules);
const element = arrayElementSchema(ObjectStackSchema.shape.sharingRules);
expect(element, 'sharingRules must be an array collection').toBeDefined();
expect(shapeKeys(element)).toEqual(shapeKeys(SharingRuleSchema));
expect(isStrict(element)).toBe(isStrict(SharingRuleSchema));
Expand All @@ -389,7 +390,7 @@ describe('binding parity — the wired schema is the one the platform binds', ()
const { DeclarativeConnectorEntrySchema, ConnectorSchema } = await import(
'@objectstack/spec/integration'
);
const element = arrayElement(ObjectStackSchema.shape.connectors);
const element = arrayElementSchema(ObjectStackSchema.shape.connectors);
expect(element, 'connectors must be an array collection').toBeDefined();
expect(shapeKeys(element)).toEqual(shapeKeys(DeclarativeConnectorEntrySchema));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
RecordDetailsProps,
} from '@objectstack/spec/ui';
import {
arrayElementSchema,
enumOptions,
isShapeKeyTombstoned,
listedShapeKeys,
resolvePropsShape,
shapeMemberTypeName,
} from '@object-ui/test-support';
import { BLOCK_CONFIG, blockHasConfig, type PlaceholderSpec } from '../block-config';
Expand Down Expand Up @@ -81,21 +83,20 @@ describe('block-config', () => {
* time the spec grows a section key the inspector never learns to author.
*/
describe('record:details sections ↔ spec section-entry coverage (#3819)', () => {
/** The spec's authorable keys for one `sections[]` entry, read off the Zod shape. */
const specSectionKeys: string[] = (() => {
const props = RecordDetailsProps as unknown as { shape?: Record<string, unknown> };
const sections = props.shape?.sections as { _def?: Record<string, any> } | undefined;
// sections: ZodOptional< ZodArray< ZodObject > > — unwrap to the element object.
let node: any = sections;
for (let i = 0; i < 6 && node; i++) {
const def = node._def ?? {};
if (def.innerType) { node = def.innerType; continue; }
if (def.element) { node = def.element; continue; }
break;
}
const shape = node?.shape;
return shape ? Object.keys(shape) : [];
})();
/**
* The spec's authorable keys for one `sections[]` entry, read off the Zod shape.
*
* `sections` is `ZodOptional< ZodArray< ZodObject > >`, so this needs both a
* wrapper walk and an array-element read. Both are `@object-ui/test-support`'s
* (objectui#5872 class (2)); the six-iteration `_def.innerType` / `_def.element`
* loop that used to stand here was one of the three disagreeing copies that
* card censused. `arrayElementSchema` answers `undefined` for a non-array,
* which `listedShapeKeys` turns into `[]` — the case the non-vacuity
* assertion below exists to catch.
*/
const specSectionKeys: string[] = listedShapeKeys(
arrayElementSchema(resolvePropsShape(RecordDetailsProps)?.sections),
);

/** The inspector's item editors for one section. */
const sectionsField = BLOCK_CONFIG['record:details'].find((f) => f.name === 'sections') as
Expand Down
1 change: 1 addition & 0 deletions packages/data-objectstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@objectstack/spec": "^17.2.0"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
* user-visible half of this bug.
*/
import { describe, it, expect } from 'vitest';
import { enumOptions } from '@object-ui/test-support';
import { GetMetaItemLayeredResponseSchema } from '@objectstack/spec/api';
import type { MetadataLayered, MetadataOverlayScope } from './metadata-client';

Expand Down Expand Up @@ -66,7 +67,14 @@ const specOverlayScope = GetMetaItemLayeredResponseSchema.shape.overlayScope;

describe('MetadataLayered.overlayScope tracks the spec enum (objectui#4982)', () => {
it('the producer vocabulary is exactly org | env, plus null', () => {
expect(specOverlayScope.unwrap().options).toEqual(['org', 'env']);
// Read through `@object-ui/test-support` rather than `.unwrap().options`
// (objectui#5872 class (4)). The bare spelling was a SEVENTH way to ask this
// question and the only one with neither a cast nor a guard: it answered
// correctly only while `overlayScope` stayed wrapped exactly once and Zod
// kept `.options` where it is. `enumOptions` walks the wrappers and answers
// `[]` when it cannot read — and `[]` fails this assertion, which is the
// non-vacuity duty that reader's docblock leaves with every caller.
expect(enumOptions(specOverlayScope)).toEqual(['org', 'env']);
expect(specOverlayScope.safeParse(null).success).toBe(true);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { ComponentRegistry } from '@object-ui/core';
import {
arrayElementSchema,
authorableShapeKeys,
isShapeKeyTombstoned,
listedShapeKeys,
Expand All @@ -58,21 +59,21 @@ import '../index';

const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');

/** One entry of `.shape`, unwrapped past `.optional()`. */
function shapeMember(schema: unknown, key: string): unknown {
const member = resolvePropsShape(schema)?.[key] as { unwrap?: () => unknown } | undefined;
return typeof member?.unwrap === 'function' ? member.unwrap() : member;
}

/** The element schema of a `z.array(...)`, through both spellings. */
function arrayElement(schema: unknown): unknown {
const arr = schema as {
element?: unknown;
def?: { element?: unknown };
_def?: { type?: unknown; element?: unknown };
} | undefined;
return arr?.element ?? arr?.def?.element ?? arr?._def?.element ?? arr?._def?.type;
}
/**
* The element schema of `RecordDetailsProps.<key>`, a `z.array(...)`.
*
* The wrapper walk and the element read are `@object-ui/test-support`'s
* (objectui#5872 class (2)), not this file's. What stood here was one of three
* disagreeing hand copies, and its last limb — `arr?._def?.type`, Zod 3's
* spelling for a `ZodArray`'s element — is a landmine on the installed
* `zod@4.4.3`, where `_def.type` is the type-name STRING `'array'`: had the
* three limbs before it ever missed, this would have handed `listedShapeKeys`
* a string and derived the empty set in silence. `arrayElementSchema` keeps
* that Zod 3 spelling behind the `_def.typeName === 'ZodArray'` discriminator
* so it can only answer where it is actually right.
*/
const specArrayElement = (key: string): unknown =>
arrayElementSchema(resolvePropsShape(RecordDetailsProps)?.[key]);

/** Top-level keys of the spec's `RecordDetailsProps`, INCLUDING tombstones. */
const specTopLevelKeys = (): string[] => listedShapeKeys(RecordDetailsProps);
Expand Down Expand Up @@ -103,7 +104,7 @@ const specAcceptedTopLevelKeys = (): string[] => authorableShapeKeys(RecordDetai

/** Member keys of one `sections[]` entry, per the spec. */
const specSectionKeys = (): string[] =>
listedShapeKeys(arrayElement(shapeMember(RecordDetailsProps, 'sections')));
listedShapeKeys(specArrayElement('sections'));

/**
* Section keys the `sections` description may NOT teach. Read off
Expand Down Expand Up @@ -364,7 +365,7 @@ describe('record:details — registry inputs vs @objectstack/spec', () => {
// contract to advertise. Every in-repo producer passes strings
// (`synth/buildDefaultPageSchema.ts:557-562` types it `string[]`), so the
// tolerant arm is unexercised drift rather than a live dialect.
const element = arrayElement(shapeMember(RecordDetailsProps, 'hideFields'));
const element = specArrayElement('hideFields');
expect(listedShapeKeys(element)).toEqual([]);
expect(RecordDetailsProps.safeParse({ hideFields: ['phone'] }).success).toBe(true);

Expand Down Expand Up @@ -442,7 +443,7 @@ describe('record:details — registry inputs vs @objectstack/spec', () => {
// Top-level `fields` is `z.array(z.string())`: there is no member shape to
// publish, and the renderer's tolerance for `{name}` / `{field}` entries is
// not a second contract to advertise — the spec rejects those values.
const element = arrayElement(shapeMember(RecordDetailsProps, 'fields'));
const element = specArrayElement('fields');
expect(listedShapeKeys(element)).toEqual([]);
expect(RecordDetailsProps.safeParse({ fields: ['phone'] }).success).toBe(true);
expect(RecordDetailsProps.safeParse({ fields: [{ name: 'phone' }] }).success).toBe(false);
Expand Down
33 changes: 30 additions & 3 deletions packages/test-support/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,30 @@ code imports — nothing in `src/` of a released package may import this.
exactly one wrapper walk in this repository and a third entry point must not
change that.
- Of the other Zod-internals reader classes #5872 censused, the wrapper-key
list is now shared as DATA (below, objectui#6923); array-element unwrapping
is NOT confined here yet and is still hand-copied — converting it is a
separate round, one reader class at a time.
list is now shared as DATA (below, objectui#6923) and array-element
unwrapping is `src/spec-array-element.ts` (below). What is still hand-copied
are the readers that ask a DIFFERENT question — union arms, walk-until-a-
shape-is-reachable, and the deliberately one-level-deep reads that carry
their reason in a comment. Those are not copies of these walks and must not
be swept onto them.
- `src/spec-zod-wrappers.ts` — THE wrapper walk, `firstInWrapperChain`, and the
`MAX_WRAPPER_DEPTH` bound. Not exported from the package index: it is plumbing
between the two readers below, not something a gate should hold. It exists
because a second reader class arrived that needed the same steps with a
different question asked at each one, and writing that walk out again inside
the new reader would have been this package's own failure mode reintroduced by
the package that exists to end it.
- `src/spec-array-element.ts` — the array-element reader, `arrayElementSchema`
(objectui#5872 class (2)). Answers "what shape does ONE entry of this array
have?", past `.optional()` / `.default()` / `.nullable()` / `.readonly()` /
`z.lazy()`, and `undefined` when the node is not an array. Unlike class (1)'s
four byte-identical copies, this class's three copies DISAGREED — about
whether to walk at all, which `def` spelling to read, and whether a non-array
answers `undefined` — so the module records which site each choice came from
and what was measured before it was made. Consumed by
`packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts`,
`packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts`
and `packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts`.
- `src/zod-wrapper-keys.json` + `src/zod-wrapper-keys.ts` — the Zod wrapper-key
vocabulary, exported as `ZOD_WRAPPER_KEYS` (objectui#6923, ruled 2026-08-31 —
objectui#5872 class (3)). The `.json` holds the data and the `.ts` holds the
Expand Down Expand Up @@ -118,6 +139,12 @@ code imports — nothing in `src/` of a released package may import this.
stamp, a non-empty check against the four real `@objectstack/spec` pairs, and
the same three halves again for `enumOptions` — including the pin that the two
entry points agree, which is what makes the delegation observable.
- `src/__tests__/spec-array-element.test.ts` — the calibration for the
array-element reader: one synthetic fixture per wrapper spelling it walks, the
`undefined` cases that keep a consuming suite's `toBeDefined()` from being a
rubber stamp, the three choices made between the disagreeing copies pinned one
by one (including that Zod 4's `_def.type` STRING is never returned as a
schema), and the non-empty check against the real `RecordDetailsProps.sections`.
- `src/__tests__/spec-tombstones.test.ts` — the calibration for that judge: one
synthetic fixture per recognition channel (so neither can quietly stop
working), plus a cross-check of the structural verdict against what the
Expand Down
Loading
Loading