Skip to content

Commit 300cdf8

Browse files
committed
feat: introduce lazySchema for deferred Zod schema construction
- Wrapped existing Zod schema exports in lazySchema to defer evaluation until first use, reducing memory usage during module load. - Added lazySchema utility to shared directory, allowing for lazy evaluation of schemas. - Updated multiple schema definitions across view.zod.ts and widget.zod.ts to utilize lazySchema. - Implemented a script (lazify-schemas.ts) to automate the wrapping of schema exports in lazySchema. - Added tests for lazySchema to ensure correct behavior and performance.
1 parent 88aa7c6 commit 300cdf8

208 files changed

Lines changed: 3940 additions & 3353 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/spec/scripts/build-schemas.ts

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

3+
// Force eager Zod construction so lazySchema() Proxies resolve immediately —
4+
// JSON Schema generation walks `_def` recursively and needs real schemas, not
5+
// lazy stubs. See packages/spec/src/shared/lazy-schema.ts.
6+
process.env.OBJECTSTACK_EAGER_SCHEMAS = '1';
7+
38
import fs from 'fs';
49
import path from 'path';
510
import { z } from 'zod';
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Lazify codemod — wrap every `export const XxxSchema = <expr>;` in
4+
// `lazySchema(() => <expr>)` so Zod construction is deferred until first use.
5+
//
6+
// Usage:
7+
// pnpm tsx scripts/lazify-schemas.ts # transform src/**/*.zod.ts
8+
// pnpm tsx scripts/lazify-schemas.ts --dry # preview, do not write
9+
// pnpm tsx scripts/lazify-schemas.ts src/data # restrict to a subdir
10+
//
11+
// Strategy: parse each .zod.ts as text, find top-level
12+
// `export const NAME(\s*:\s*Type)? = <expr>;` declarations, and rewrite to
13+
// `export const NAME$1 = lazySchema(() => <expr>);`. Brace/paren/bracket/template
14+
// depth is tracked to find the terminating semicolon. String literals and
15+
// comments are skipped to avoid false matches.
16+
//
17+
// Skipped:
18+
// - lines whose RHS is exactly `z.lazy(...)` — already lazy
19+
// - already-wrapped declarations (RHS starts with `lazySchema(`)
20+
// - non-`Schema`-suffixed identifiers
21+
//
22+
// The codemod also injects:
23+
// `import { lazySchema } from '<relative path>/shared/lazy-schema';`
24+
// if not already present.
25+
26+
import fs from 'fs';
27+
import path from 'path';
28+
import { fileURLToPath } from 'url';
29+
30+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
31+
const repoRoot = path.resolve(__dirname, '..');
32+
const srcRoot = path.join(repoRoot, 'src');
33+
const sharedDir = path.join(srcRoot, 'shared');
34+
35+
interface Args {
36+
dry: boolean;
37+
targets: string[];
38+
}
39+
40+
function parseArgs(argv: string[]): Args {
41+
const dry = argv.includes('--dry');
42+
const targets = argv.filter((a) => !a.startsWith('--'));
43+
if (targets.length === 0) targets.push(srcRoot);
44+
return { dry, targets: targets.map((t) => path.resolve(t)) };
45+
}
46+
47+
function listZodFiles(root: string): string[] {
48+
const out: string[] = [];
49+
const visit = (dir: string) => {
50+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
51+
if (ent.name === 'node_modules' || ent.name === 'dist' || ent.name.startsWith('.')) continue;
52+
const p = path.join(dir, ent.name);
53+
if (ent.isDirectory()) visit(p);
54+
else if (ent.isFile() && ent.name.endsWith('.zod.ts')) out.push(p);
55+
}
56+
};
57+
if (fs.statSync(root).isDirectory()) visit(root);
58+
else out.push(root);
59+
return out;
60+
}
61+
62+
interface Decl {
63+
start: number; // index of `export`
64+
rhsStart: number; // index just after `=`
65+
end: number; // index of `;` (inclusive)
66+
name: string;
67+
signature: string; // text from `export const NAME(:Type)? = ` (the prefix to keep)
68+
rhs: string; // raw RHS without trailing `;`
69+
}
70+
71+
// Find top-level `export const NAME(:Type)? = <expr>;` declarations in source.
72+
// Honors string/comment/template boundaries so braces inside them don't
73+
// confuse depth tracking.
74+
function findDeclarations(src: string): Decl[] {
75+
const decls: Decl[] = [];
76+
const len = src.length;
77+
let i = 0;
78+
let depth = 0; // top-level when 0
79+
// scan top-level only; inside any nested block we skip
80+
while (i < len) {
81+
// Skip whitespace
82+
if (src[i] === ' ' || src[i] === '\t' || src[i] === '\n' || src[i] === '\r') { i++; continue; }
83+
// Skip line comment
84+
if (src[i] === '/' && src[i + 1] === '/') {
85+
while (i < len && src[i] !== '\n') i++;
86+
continue;
87+
}
88+
// Skip block comment
89+
if (src[i] === '/' && src[i + 1] === '*') {
90+
i += 2;
91+
while (i < len && !(src[i] === '*' && src[i + 1] === '/')) i++;
92+
i += 2;
93+
continue;
94+
}
95+
// String / template literal
96+
if (src[i] === '"' || src[i] === "'" || src[i] === '`') {
97+
i = skipString(src, i);
98+
continue;
99+
}
100+
// Brace-aware nesting at top level
101+
if (src[i] === '{' || src[i] === '(' || src[i] === '[') { depth++; i++; continue; }
102+
if (src[i] === '}' || src[i] === ')' || src[i] === ']') { depth--; i++; continue; }
103+
104+
if (depth === 0 && src.startsWith('export', i)) {
105+
const m = matchExportConst(src, i);
106+
if (m) {
107+
decls.push(m);
108+
i = m.end + 1;
109+
continue;
110+
}
111+
}
112+
i++;
113+
}
114+
return decls;
115+
}
116+
117+
function skipString(src: string, start: number): number {
118+
const quote = src[start];
119+
let i = start + 1;
120+
while (i < src.length) {
121+
if (src[i] === '\\') { i += 2; continue; }
122+
if (quote === '`' && src[i] === '$' && src[i + 1] === '{') {
123+
// template expression — skip balanced
124+
i += 2;
125+
let d = 1;
126+
while (i < src.length && d > 0) {
127+
if (src[i] === '"' || src[i] === "'" || src[i] === '`') { i = skipString(src, i); continue; }
128+
if (src[i] === '{') d++;
129+
else if (src[i] === '}') d--;
130+
i++;
131+
}
132+
continue;
133+
}
134+
if (src[i] === quote) return i + 1;
135+
i++;
136+
}
137+
return i;
138+
}
139+
140+
const HEADER_RE = /^export\s+const\s+([A-Za-z_$][\w$]*)(\s*:\s*[^=]+?)?\s*=\s*/;
141+
142+
function matchExportConst(src: string, start: number): Decl | null {
143+
const slice = src.slice(start, start + 4096);
144+
const m = HEADER_RE.exec(slice);
145+
if (!m) return null;
146+
const name = m[1];
147+
if (!name.endsWith('Schema') && !name.endsWith('Schemas')) {
148+
// Only target *Schema / *Schemas exports
149+
return null;
150+
}
151+
const rhsStart = start + m[0].length;
152+
153+
// Walk RHS until balanced top-level `;`
154+
const len = src.length;
155+
let i = rhsStart;
156+
let depth = 0;
157+
while (i < len) {
158+
const c = src[i];
159+
if (c === '/' && src[i + 1] === '/') { while (i < len && src[i] !== '\n') i++; continue; }
160+
if (c === '/' && src[i + 1] === '*') {
161+
i += 2; while (i < len && !(src[i] === '*' && src[i + 1] === '/')) i++; i += 2; continue;
162+
}
163+
if (c === '"' || c === "'" || c === '`') { i = skipString(src, i); continue; }
164+
if (c === '{' || c === '(' || c === '[') { depth++; i++; continue; }
165+
if (c === '}' || c === ')' || c === ']') { depth--; i++; continue; }
166+
if (c === ';' && depth === 0) {
167+
const rhs = src.slice(rhsStart, i).trimEnd();
168+
return {
169+
start, rhsStart, end: i, name,
170+
signature: src.slice(start, rhsStart),
171+
rhs,
172+
};
173+
}
174+
i++;
175+
}
176+
return null;
177+
}
178+
179+
function shouldSkip(rhs: string): boolean {
180+
const trimmed = rhs.trim();
181+
if (trimmed.startsWith('lazySchema(')) return true;
182+
if (/^z\.lazy\s*\(/.test(trimmed)) return true;
183+
return false;
184+
}
185+
186+
function relImportPath(file: string): string {
187+
const fileDir = path.dirname(file);
188+
const target = path.join(sharedDir, 'lazy-schema');
189+
let rel = path.relative(fileDir, target);
190+
if (!rel.startsWith('.')) rel = './' + rel;
191+
return rel;
192+
}
193+
194+
function ensureLazyImport(src: string, file: string): string {
195+
if (/from ['"][^'"]*shared\/lazy-schema['"]/.test(src)) return src;
196+
const importPath = relImportPath(file);
197+
const importLine = `import { lazySchema } from '${importPath}';\n`;
198+
199+
// Insert after the last top-level `import ... from '...';` in the file header.
200+
// Stop at the first non-import, non-comment statement.
201+
const importBlock = /^(?:[ \t]*\/\/[^\n]*\n|[ \t]*\/\*[\s\S]*?\*\/\s*\n|[ \t]*import[\s\S]*?from[ \t]*['"][^'"]+['"];[ \t]*\n|[ \t]*\n)+/.exec(src);
202+
if (importBlock) {
203+
const idx = importBlock[0].length;
204+
return src.slice(0, idx) + importLine + src.slice(idx);
205+
}
206+
return importLine + src;
207+
}
208+
209+
function transform(src: string, file: string): { out: string; changed: number; skipped: number } {
210+
const decls = findDeclarations(src);
211+
if (decls.length === 0) return { out: src, changed: 0, skipped: 0 };
212+
213+
// Process in reverse so offsets stay valid
214+
let out = src;
215+
let changed = 0;
216+
let skipped = 0;
217+
for (let k = decls.length - 1; k >= 0; k--) {
218+
const d = decls[k];
219+
if (shouldSkip(d.rhs)) { skipped++; continue; }
220+
const wrapped = `lazySchema(() => ${d.rhs})`;
221+
out = out.slice(0, d.rhsStart) + wrapped + out.slice(d.end);
222+
changed++;
223+
}
224+
225+
if (changed > 0) out = ensureLazyImport(out, file);
226+
return { out, changed, skipped };
227+
}
228+
229+
function main() {
230+
const { dry, targets } = parseArgs(process.argv.slice(2));
231+
const files = targets.flatMap(listZodFiles);
232+
console.log(`Found ${files.length} .zod.ts files in ${targets.length} target(s)`);
233+
234+
let totalChanged = 0;
235+
let totalSkipped = 0;
236+
let filesChanged = 0;
237+
238+
for (const file of files) {
239+
// Don't transform the lazy-schema utility itself
240+
if (file.endsWith('lazy-schema.ts') || file.endsWith('lazy-schema.test.ts')) continue;
241+
242+
const src = fs.readFileSync(file, 'utf8');
243+
const { out, changed, skipped } = transform(src, file);
244+
totalChanged += changed;
245+
totalSkipped += skipped;
246+
if (changed > 0) {
247+
filesChanged++;
248+
const rel = path.relative(repoRoot, file);
249+
console.log(` ${dry ? '[dry] ' : ''}${rel}: +${changed} wrapped, ${skipped} skipped`);
250+
if (!dry) fs.writeFileSync(file, out);
251+
}
252+
}
253+
254+
console.log(`\n${dry ? '[DRY] ' : ''}Done. ${filesChanged} files, ${totalChanged} schemas wrapped, ${totalSkipped} skipped.`);
255+
}
256+
257+
main();

0 commit comments

Comments
 (0)