Skip to content

Commit aeaaa44

Browse files
fix(spec): a lazySchema reference keeps its authored description in z.toJSONSchema (#19934)
Fixes #19101 Clause-②: no <sub>Rewritten short by the `domain:spec#5` seat (2026-09-24T01:31Z; one count cut after record `5805815738`). The dev reports are on #19101 (`5805027961`, `5805539465`), and the seat ruling is `5805064236`.</sub> A schema referenced through a `lazySchema` Proxy lost its authored `.describe()` text in `z.toJSONSchema`. zod reads metadata from a registry keyed on object identity; the node it holds is the Proxy, while the description was registered on the real instance. The shipped JSON Schemas were therefore built lazily and missed those descriptions. Under `OS_EAGER_SCHEMAS=1`, where there is no Proxy, they were present. ## What changed - `packages/spec/src/shared/lazy-schema.ts`: the facade's `processJSONSchema` wrapper registers the real instance's metadata on the Proxy in the registry the conversion reads. It does this once, never over an existing entry, and leaves out `id`: aliasing `id` makes zod throw "Duplicate schema id". - Measured by the dev, lazy after the fix against eager, leaf by leaf, with 0 diffs in each corpus: - the `openapi.json` components gain 2 descriptions; - the `os generate` IDE schema gains 445. Only `description` moves, and eager output is unchanged. - Pins in `lazy-schema.test.ts`. Deleting the alias call turns 4 of them red; deleting the `id` exclusion turns the `id` pin red. - Changeset: `@objectstack/spec` patch. ## Not in this PR - An eager run of `gen:openapi` still crashes on a `src` import cycle, and the same cycle crashes the published `/api` and `/data` entries under `OS_EAGER_SCHEMAS=1`: #19930. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6a5ecb commit aeaaa44

3 files changed

Lines changed: 165 additions & 2 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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. 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:
12+
13+
- `@objectstack/spec/openapi.json`, and the `GET …/openapi.json` document served from it, gains 2 (`ListRecordResponse.data[]` and `BulkRequest.records[]`);
14+
- the `os generate` IDE schema gains 445;
15+
- the approval-node and schemaless node-config schemas are unchanged.
16+
17+
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 unknown 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 unknown 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, 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 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)