Skip to content

Commit 7ee3312

Browse files
committed
fix(spec): a lazySchema reference keeps its authored description in z.toJSONSchema
zod reads .describe()/.meta() from its registry by node identity; a lazy reference is the Proxy while the metadata sits on the real instance, so every lazy conversion dropped the description an eager run keeps. The _zod facade now aliases the real instance's metadata (less id) onto the Proxy in the registry the conversion reads. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent fdeeea0 commit 7ee3312

3 files changed

Lines changed: 166 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
JSON Schemas converted from a `lazySchema()` reference now carry the `description` the schema authored, the same as an `OS_EAGER_SCHEMAS=1` run (#19101).
6+
7+
Clause-②: no
8+
9+
zod reads `.describe()` / `.meta()` from its registry by node identity. A lazily built schema is referenced through a Proxy, while the metadata sits on the real instance behind it, so `z.toJSONSchema` found nothing and dropped the text. The published result depended on the evaluation mode, and every runtime producer runs lazily. The Proxy now answers the real instance's metadata to that lookup, less `id`, which stays on the real instance so that zod's duplicate-id refusal is never triggered.
10+
11+
What changes: descriptions reappear. Nothing else does. Measured lazy against eager, leaf by leaf, across the four affected surfaces:
12+
13+
- `@objectstack/spec/openapi.json`, and the `GET …/openapi.json` document served from it, gains 2 (`ListRecordResponse.data[]` and `BulkRequest.records[]`);
14+
- the `/meta/types` JSON Schemas gain 170 across 11 of 26 types;
15+
- the `os generate` IDE schema gains 445;
16+
- the approval-node and schemaless node-config schemas are unchanged.
17+
18+
No other key differs in any of them, and the eager outputs are byte-identical before and after. The accept set does not change: `description` is an annotation, never a validation keyword.

‎packages/spec/src/shared/lazy-schema.test.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { execFileSync } from 'node:child_process';
4+
import { fileURLToPath } from 'node:url';
35
import { describe, it, expect, vi } from 'vitest';
46
import { z } from 'zod';
57
import { lazySchema } from './lazy-schema';
@@ -117,3 +119,100 @@ describe('lazySchema × z.toJSONSchema identity', () => {
117119
expect((schema as any)._zod).toBe((schema as any)._zod);
118120
});
119121
});
122+
123+
/**
124+
* #19101 — a schema referenced through the Proxy converts with the SAME
125+
* metadata as the eager instance. zod reads `.describe()` / `.meta()` from its
126+
* registry by node identity; the node is the Proxy, the metadata sits on the
127+
* real instance, so before the facade aliased it every lazy reference lost its
128+
* `description` while `OS_EAGER_SCHEMAS=1` (no Proxy at all) kept it.
129+
*/
130+
describe('lazySchema × z.toJSONSchema metadata (#19101)', () => {
131+
it('a lazy reference converts exactly like the eager instance — nested and as the root', () => {
132+
const factory = () => z.record(z.string(), z.unknown()).describe('lazy-described record');
133+
const lazy = lazySchema(factory);
134+
const eager = factory();
135+
expect(z.toJSONSchema(z.object({ rows: z.array(lazy) }))).toEqual(
136+
z.toJSONSchema(z.object({ rows: z.array(eager) })),
137+
);
138+
expect(z.toJSONSchema(lazy)).toEqual(z.toJSONSchema(eager));
139+
});
140+
141+
it("the real instance's own describe wins over the one it inherits from its parent", () => {
142+
const Base = z.string().describe('inherited');
143+
const lazy = lazySchema(() => Base.describe('own'));
144+
const json = z.toJSONSchema(z.object({ v: lazy })) as { properties: { v: { description?: string } } };
145+
expect(json.properties.v.description).toBe('own');
146+
});
147+
148+
it('`id` is NOT aliased onto the Proxy — aliasing it makes zod throw "Duplicate schema id"', () => {
149+
const Leaf: z.ZodType<any> = lazySchema(() =>
150+
z.object({ x: z.string() }).meta({ id: 'LazySchemaIdProbe19101', description: 'probe' }),
151+
);
152+
const Doc = z.object({ a: (Leaf as any).optional(), b: z.lazy(() => Leaf) });
153+
const json = z.toJSONSchema(Doc) as { properties: { b: { description?: string } } };
154+
expect(json.properties.b.description).toBe('probe');
155+
expect(z.globalRegistry.get(Leaf)?.id).toBeUndefined();
156+
});
157+
});
158+
159+
/**
160+
* The same property on the real contract, against a REAL eager run: a child
161+
* process imports the spec with `OS_EAGER_SCHEMAS=1` (no Proxy anywhere) and
162+
* prints the conversions; this process converts the same schemas lazily. The
163+
* child enters through `kernel/metadata-type-schemas.ts`, because an eager
164+
* load that starts at `api/` or `data/` dies on the filter.zod → strict-object
165+
* → suggestions.zod → field.zod cycle filed as #19930.
166+
*
167+
* Measured at the fix (lazy before → after, eager unchanged): the nine OpenAPI
168+
* components gain 2 descriptions, every `/meta/types` schema 170, the
169+
* `os generate` IDE schema 445; description is the only key that moved.
170+
*/
171+
describe('lazy == eager on the real contract (#19101)', () => {
172+
const PKG_ROOT = fileURLToPath(new URL('../..', import.meta.url));
173+
const CONTRACT = new URL('../api/contract.zod.ts', import.meta.url).href;
174+
const METADATA_TYPES = new URL('../kernel/metadata-type-schemas.ts', import.meta.url).href;
175+
const OPTS = { unrepresentable: 'any' } as const;
176+
// tsx compiles .ts to CJS, so a namespace may arrive under `default`.
177+
const pick = (mod: any, key: string): any => mod[key] ?? mod.default?.[key];
178+
179+
it('RecordDataSchema inside ListRecordResponse, and the `dataset` /meta/types schema', async () => {
180+
const eager = JSON.parse(
181+
execFileSync(
182+
process.execPath,
183+
['--import', 'tsx', '--input-type=module', '-e',
184+
`import ${JSON.stringify(METADATA_TYPES)};
185+
const { z } = await import('zod');
186+
const pick = (mod, key) => mod[key] ?? mod.default?.[key];
187+
const contract = await import(${JSON.stringify(CONTRACT)});
188+
const types = await import(${JSON.stringify(METADATA_TYPES)});
189+
const opts = ${JSON.stringify(OPTS)};
190+
process.stdout.write(JSON.stringify({
191+
listRecordResponse: z.toJSONSchema(pick(contract, 'ListRecordResponseSchema'), opts),
192+
dataset: z.toJSONSchema(pick(types, 'getMetadataTypeSchema')('dataset'), opts),
193+
}));`],
194+
{
195+
cwd: PKG_ROOT,
196+
env: { ...process.env, OS_EAGER_SCHEMAS: '1' },
197+
encoding: 'utf8',
198+
maxBuffer: 64 * 1024 * 1024,
199+
stdio: ['ignore', 'pipe', 'pipe'],
200+
},
201+
),
202+
);
203+
204+
const contract = await import('../api/contract.zod');
205+
const types = await import('../kernel/metadata-type-schemas');
206+
const lazy = JSON.parse(JSON.stringify({
207+
listRecordResponse: z.toJSONSchema(pick(contract, 'ListRecordResponseSchema'), OPTS),
208+
dataset: z.toJSONSchema(pick(types, 'getMetadataTypeSchema')('dataset'), OPTS),
209+
}));
210+
211+
// The named leaf first, for a failure that says what went missing.
212+
const declared = pick(contract, 'RecordDataSchema').description;
213+
expect(typeof declared === 'string' && declared.length > 0).toBe(true);
214+
expect(eager.listRecordResponse.properties.data.items.description).toBe(declared);
215+
expect(lazy.listRecordResponse.properties.data.items.description).toBe(declared);
216+
expect(lazy).toStrictEqual(eager);
217+
}, 60_000);
218+
});

‎packages/spec/src/shared/lazy-schema.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
import type { z } from 'zod';
44

5+
/** The slice of zod's metadata registry (`$ZodRegistry`) the facade uses. */
6+
interface MetadataRegistryLike {
7+
get(schema: unknown): Record<string, unknown> | undefined;
8+
has(schema: unknown): boolean;
9+
add(schema: unknown, meta: Record<string, unknown>): unknown;
10+
}
11+
12+
/** The slice of zod's `toJSONSchema` context a `processJSONSchema` hook receives. */
13+
interface JsonSchemaHookContext {
14+
seen?: Map<unknown, unknown>;
15+
metadataRegistry?: MetadataRegistryLike;
16+
}
17+
518
/**
619
* Wrap a Zod schema constructor so its body is only evaluated on first use.
720
*
@@ -52,6 +65,17 @@ export function lazySchema<T extends z.ZodTypeAny>(factory: () => T): T {
5265
* the real instance before delegating, so both identities resolve to the
5366
* same entry. If the real instance was already traversed under its own
5467
* identity it keeps its entry (alias, never clobber).
68+
*
69+
* The same wrapper aliases the real instance's METADATA onto the Proxy
70+
* (#19101). zod reads a node's `.describe()` / `.meta()` with
71+
* `ctx.metadataRegistry.get(node)` right after this hook returns — a
72+
* WeakMap keyed on identity — and the node it holds is the Proxy while the
73+
* metadata was registered on the real instance, so every lazySchema
74+
* referenced by identity lost its authored `description` in lazy mode and
75+
* kept it under `OS_EAGER_SCHEMAS=1`, where no Proxy exists. The two modes
76+
* then published different JSON Schemas from one source: the OpenAPI
77+
* artifact, `/meta/types` and the `os generate` IDE schema all shipped the
78+
* lazy, description-less answer.
5579
*/
5680
let zodFacade: object | undefined;
5781
const makeZodFacade = (real: T): object | undefined => {
@@ -63,18 +87,41 @@ export function lazySchema<T extends z.ZodTypeAny>(factory: () => T): T {
6387
return Object.create(realZod as object, {
6488
processJSONSchema: {
6589
enumerable: true,
66-
value: (ctx: { seen?: Map<unknown, unknown> }, json: unknown, params: unknown) => {
90+
value: (ctx: JsonSchemaHookContext, json: unknown, params: unknown) => {
6791
const seen = ctx?.seen;
6892
if (seen && typeof seen.get === 'function') {
6993
const entry = seen.get(proxy);
7094
if (entry !== undefined && !seen.has(real)) seen.set(real, entry);
7195
}
72-
return delegate(ctx, json, params);
96+
const emitted = delegate(ctx, json, params);
97+
aliasMetadataOntoProxy(ctx?.metadataRegistry, real);
98+
return emitted;
7399
},
74100
},
75101
}) as object;
76102
};
77103

104+
/**
105+
* Register the real instance's metadata under the Proxy identity in the
106+
* registry this conversion reads, once, and only when nothing is registered
107+
* under the Proxy already (alias, never clobber). The registry merges a
108+
* node's `_zod.parent` chain on read, which the Proxy shares with the real
109+
* instance, so the Proxy then answers exactly what the real instance does —
110+
* less `id`.
111+
*/
112+
const aliasMetadataOntoProxy = (registry: MetadataRegistryLike | undefined, real: T): void => {
113+
if (!registry || typeof registry.get !== 'function' || typeof registry.has !== 'function'
114+
|| typeof registry.add !== 'function' || registry.has(proxy)) {
115+
return;
116+
}
117+
const meta = registry.get(real);
118+
if (!meta) return;
119+
const aliased: Record<string, unknown> = { ...meta };
120+
// `id` stays un-aliased: the Proxy and the real instance both enter one conversion's seen map, and zod throws "Duplicate schema id" when two of its nodes share an id.
121+
delete aliased.id;
122+
if (Object.keys(aliased).length > 0) registry.add(proxy, aliased);
123+
};
124+
78125
const proxy = new Proxy(target as object, {
79126
get(_t, prop) {
80127
const real = resolve() as unknown as Record<PropertyKey, unknown>;

0 commit comments

Comments
 (0)