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
11 changes: 11 additions & 0 deletions .changeset/lazy-schema-tojsonschema-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': patch
---

fix(spec): keep `lazySchema` proxies identity-compatible with `z.toJSONSchema` (objectui#2561)

zod's `toJSONSchema` keys its `seen` map on the node object it traverses — the `lazySchema` Proxy wherever a schema is referenced lazily (`z.lazy(() => X)` recursion getters, direct conversion roots) — while its wrapper-type processors (pipe/lazy/optional/default/…) look themselves up via the REAL instance captured at construction (`inst._zod.processJSONSchema = (ctx, …) => pipeProcessor(inst, …)`). The identity mismatch crashed conversion with `Cannot set properties of undefined (setting 'ref')`.

This stayed latent while lazy-referenced schemas were plain objects (the object processor never looks itself up); ADR-0089 D3a turned `PageComponentSchema` / `FormFieldSchema` into `.strict().transform(…)` **pipes**, which broke ObjectUI Studio's spec-derived Page/View inspector JSONSchema derivation under spec 15.

Fix: the proxy now serves a memoised `_zod` facade that prototype-delegates to the real internals and wraps only `processJSONSchema` to alias the proxy's `seen` entry onto the real instance before delegating. Parse behavior is unchanged; `OS_EAGER_SCHEMAS=1` remains the bypass. Regression tests cover the D3a pipe shape, recursion through `z.lazy(() => proxy)`, mixed proxy+real traversal, and the full `PageSchema` / `ViewSchema` Studio derivation paths.
61 changes: 61 additions & 0 deletions packages/spec/src/shared/lazy-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,64 @@ describe('lazySchema', () => {
}
});
});

/**
* zod's `toJSONSchema` keys its `seen` map on the traversed node (the Proxy),
* while its wrapper-type processors (pipe/lazy/optional/…) look themselves up
* via the construction-time REAL instance. Without the `_zod` facade aliasing
* the two identities, any lazySchema wrapping a non-object root — e.g. the
* ADR-0089 D3a `.strict().transform(…)` pipes — crashed with
* `Cannot set properties of undefined (setting 'ref')` (objectui#2561).
*/
describe('lazySchema × z.toJSONSchema identity', () => {
const TO_JSON = { io: 'input', unrepresentable: 'any' } as const;

it('converts a lazy `.strict().transform(…)` pipe (ADR-0089 D3a shape)', () => {
const schema = lazySchema(() =>
z.object({ name: z.string() }).strict().transform((v) => v),
);
const json = z.toJSONSchema(schema, TO_JSON) as Record<string, any>;
expect(json.properties?.name).toBeDefined();
});

it('converts recursion reaching the pipe through `z.lazy(() => proxy)` (FormFieldSchema shape)', () => {
const NodeSchema: z.ZodType<any> = lazySchema(() =>
z
.object({
field: z.string(),
fields: z.array(z.lazy(() => NodeSchema)).optional(),
})
.strict()
.transform((v) => v),
);
const json = z.toJSONSchema(NodeSchema, TO_JSON);
expect(JSON.stringify(json)).toContain('field');
});

it('does not crash when one conversion sees both the proxy and the real instance', () => {
const Leaf: z.ZodType<any> = lazySchema(() =>
z.object({ id: z.string() }).strict().transform((v) => v),
);
// `.optional()` resolves through the proxy and captures the REAL pipe as
// its innerType; the `z.lazy` getter hands zod the PROXY — one traversal
// meets both identities in either order.
const DocA = z.object({
a: (Leaf as any).optional(),
b: z.lazy(() => Leaf),
});
const DocB = z.object({
b: z.lazy(() => Leaf),
a: (Leaf as any).optional(),
});
for (const Doc of [DocA, DocB]) {
const json = z.toJSONSchema(Doc, TO_JSON) as Record<string, any>;
expect(json.properties?.a).toBeDefined();
expect(json.properties?.b).toBeDefined();
}
});

it('memoises the `_zod` facade (identity-stable across accesses)', () => {
const schema = lazySchema(() => z.object({ x: z.number() }).strict().transform((v) => v));
expect((schema as any)._zod).toBe((schema as any)._zod);
});
});
48 changes: 48 additions & 0 deletions packages/spec/src/shared/lazy-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,57 @@ export function lazySchema<T extends z.ZodTypeAny>(factory: () => T): T {

const target = function lazyZod() {} as unknown as T;

/**
* Memoised `_zod` facade (one per proxy — `_zod.run` is the parse hot path).
*
* Why it exists: zod's `toJSONSchema` traversal keys its `seen` map on the
* node object it was handed — this Proxy, wherever the schema is referenced
* lazily (a `z.lazy(() => X)` recursion getter, or a direct conversion
* root). But zod's per-type JSON-Schema hooks close over the REAL instance
* at construction time (`inst._zod.processJSONSchema = (ctx, …) =>
* pipeProcessor(inst, ctx, …)`), and the wrapper-type processors
* (pipe/lazy/optional/default/…) then resolve `ctx.seen.get(inst)` — which
* misses when the entry was keyed on the Proxy, crashing with
* `Cannot set properties of undefined (setting 'ref')`. Plain-object
* schemas never look themselves up, which kept this latent until ADR-0089
* D3a turned FormFieldSchema / PageComponentSchema into
* `.strict().transform(…)` pipes.
*
* The facade prototype-delegates every `_zod` read to the real internals,
* wrapping only `processJSONSchema` to alias the Proxy's `seen` entry onto
* the real instance before delegating, so both identities resolve to the
* same entry. If the real instance was already traversed under its own
* identity it keeps its entry (alias, never clobber).
*/
let zodFacade: object | undefined;
const makeZodFacade = (real: T): object | undefined => {
const realZod = (real as unknown as { _zod?: Record<string, unknown> })._zod;
if (!realZod || typeof realZod.processJSONSchema !== 'function') {
return realZod;
}
const delegate = realZod.processJSONSchema as (...a: unknown[]) => unknown;
return Object.create(realZod as object, {
processJSONSchema: {
enumerable: true,
value: (ctx: { seen?: Map<unknown, unknown> }, json: unknown, params: unknown) => {
const seen = ctx?.seen;
if (seen && typeof seen.get === 'function') {
const entry = seen.get(proxy);
if (entry !== undefined && !seen.has(real)) seen.set(real, entry);
}
return delegate(ctx, json, params);
},
},
}) as object;
};

const proxy = new Proxy(target as object, {
get(_t, prop) {
const real = resolve() as unknown as Record<PropertyKey, unknown>;
if (prop === '_zod') {
zodFacade ??= makeZodFacade(real as unknown as T);
return zodFacade;
}
const value = real[prop];
if (typeof value === 'function') {
return (value as (...a: unknown[]) => unknown).bind(real);
Expand Down
23 changes: 23 additions & 0 deletions packages/spec/src/ui/page.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
PageSchema,
PAGE_TYPE_ROADMAP,
Expand Down Expand Up @@ -1043,3 +1044,25 @@ describe('ADR-0089 D3a — strict page component schema (loud mis-layered keys)'
expect(res.success).toBe(false);
});
});

/**
* ObjectUI Studio derives the Page inspector's authoring JSONSchema straight
* from these zod schemas via `z.toJSONSchema` (objectui#2561). The lazySchema
* proxy must stay identity-compatible with zod's converter — the D3a
* `.strict().transform(…)` pipe on PageComponentSchema is exactly the shape
* that crashed it before the `_zod` facade fix.
*/
describe('PageSchema → JSON Schema derivation (Studio inspector path)', () => {
const TO_JSON = { io: 'input', unrepresentable: 'any' } as const;

it('derives the input-io JSONSchema for the whole Page document', () => {
const json = z.toJSONSchema(PageSchema, TO_JSON);
const text = JSON.stringify(json);
expect(text).toContain('interfaceConfig');
expect(text).toContain('regions');
});

it('derives the input-io JSONSchema for a bare PageComponent', () => {
expect(() => z.toJSONSchema(PageComponentSchema, TO_JSON)).not.toThrow();
});
});
20 changes: 20 additions & 0 deletions packages/spec/src/ui/view.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
ViewSchema,
ListViewSchema,
Expand Down Expand Up @@ -2577,3 +2578,22 @@ describe('ADR-0089 D3a — strict view form schemas (loud mis-layered keys)', ()
expect(() => FormSectionSchema.parse({ label: 'S', visibleOn: 'record.a == 1', fields: [] })).not.toThrow();
});
});

/**
* ObjectUI Studio derives the View inspector's authoring JSONSchema from
* ViewSchema via `z.toJSONSchema` (objectui#2561). FormFieldSchema is a
* self-recursive `.strict().transform(…)` pipe reached through
* `z.lazy(() => FormFieldSchema)` — the exact lazySchema-proxy identity shape
* that crashed zod's converter before the `_zod` facade fix.
*/
describe('ViewSchema → JSON Schema derivation (Studio inspector path)', () => {
const TO_JSON = { io: 'input', unrepresentable: 'any' } as const;

it('derives the input-io JSONSchema for the whole View document', () => {
expect(() => z.toJSONSchema(ViewSchema, TO_JSON)).not.toThrow();
});

it('derives the input-io JSONSchema for the recursive FormFieldSchema', () => {
expect(() => z.toJSONSchema(FormFieldSchema, TO_JSON)).not.toThrow();
});
});