diff --git a/.changeset/lazy-schema-tojsonschema-identity.md b/.changeset/lazy-schema-tojsonschema-identity.md new file mode 100644 index 0000000000..fe906d21bd --- /dev/null +++ b/.changeset/lazy-schema-tojsonschema-identity.md @@ -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. diff --git a/packages/spec/src/shared/lazy-schema.test.ts b/packages/spec/src/shared/lazy-schema.test.ts index 516ea6b18b..5c0de97dd9 100644 --- a/packages/spec/src/shared/lazy-schema.test.ts +++ b/packages/spec/src/shared/lazy-schema.test.ts @@ -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; + expect(json.properties?.name).toBeDefined(); + }); + + it('converts recursion reaching the pipe through `z.lazy(() => proxy)` (FormFieldSchema shape)', () => { + const NodeSchema: z.ZodType = 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 = 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; + 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); + }); +}); diff --git a/packages/spec/src/shared/lazy-schema.ts b/packages/spec/src/shared/lazy-schema.ts index 9bed23508c..c1f5c14ea3 100644 --- a/packages/spec/src/shared/lazy-schema.ts +++ b/packages/spec/src/shared/lazy-schema.ts @@ -31,9 +31,57 @@ export function lazySchema(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 })._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 }, 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; + 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); diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 0cebfbac77..a1580478db 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { PageSchema, PAGE_TYPE_ROADMAP, @@ -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(); + }); +}); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 9bed1ecbfc..7bf9059622 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { ViewSchema, ListViewSchema, @@ -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(); + }); +});