Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit 3fde86d

Browse files
DoctorFTBymc9
andauthored
feat(cli): implement watch mode for generate (#554)
* feat(cli): implement watch mode for generate * chore(root): update pnpm-lock.yaml * chore(cli): track all model declaration and removed paths, logs in past tense * fix(cli): typo, unused double array from * fix(orm): preserve zod validation errors when validating custom json types * update * chore(cli): move import, fix parallel generation on watch * feat(common-helpers): implement single-debounce * chore(cli): use single-debounce for debouncing * feat(common-helpers): implement single-debounce * fix(common-helpers): re run single-debounce --------- Co-authored-by: Yiming Cao <yiming@whimslab.io> Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com>
1 parent 446a483 commit 3fde86d

File tree

9 files changed

+173
-7
lines changed

9 files changed

+173
-7
lines changed

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@zenstackhq/common-helpers": "workspace:*",
4040
"@zenstackhq/language": "workspace:*",
4141
"@zenstackhq/sdk": "workspace:*",
42+
"chokidar": "^5.0.0",
4243
"colors": "1.4.0",
4344
"commander": "^8.3.0",
4445
"execa": "^9.6.0",

packages/cli/src/actions/generate.ts

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { invariant } from '@zenstackhq/common-helpers';
2-
import { isPlugin, LiteralExpr, Plugin, type Model } from '@zenstackhq/language/ast';
1+
import { invariant, singleDebounce } from '@zenstackhq/common-helpers';
2+
import { ZModelLanguageMetaData } from '@zenstackhq/language';
3+
import { type AbstractDeclaration, isPlugin, LiteralExpr, Plugin, type Model } from '@zenstackhq/language/ast';
34
import { getLiteral, getLiteralArray } from '@zenstackhq/language/utils';
45
import { type CliPlugin } from '@zenstackhq/sdk';
56
import colors from 'colors';
67
import { createJiti } from 'jiti';
78
import fs from 'node:fs';
89
import path from 'node:path';
910
import { pathToFileURL } from 'node:url';
11+
import { watch } from 'chokidar';
1012
import ora, { type Ora } from 'ora';
1113
import { CliError } from '../cli-error';
1214
import * as corePlugins from '../plugins';
@@ -16,6 +18,7 @@ type Options = {
1618
schema?: string;
1719
output?: string;
1820
silent: boolean;
21+
watch: boolean;
1922
lite: boolean;
2023
liteOnly: boolean;
2124
};
@@ -24,6 +27,96 @@ type Options = {
2427
* CLI action for generating code from schema
2528
*/
2629
export async function run(options: Options) {
30+
const model = await pureGenerate(options, false);
31+
32+
if (options.watch) {
33+
const logsEnabled = !options.silent;
34+
35+
if (logsEnabled) {
36+
console.log(colors.green(`\nEnabled watch mode!`));
37+
}
38+
39+
const schemaExtensions = ZModelLanguageMetaData.fileExtensions;
40+
41+
// Get real models file path (cuz its merged into single document -> we need use cst nodes)
42+
const getRootModelWatchPaths = (model: Model) => new Set<string>(
43+
(
44+
model.declarations.filter(
45+
(v) =>
46+
v.$cstNode?.parent?.element.$type === 'Model' &&
47+
!!v.$cstNode.parent.element.$document?.uri?.fsPath,
48+
) as AbstractDeclaration[]
49+
).map((v) => v.$cstNode!.parent!.element.$document!.uri!.fsPath),
50+
);
51+
52+
const watchedPaths = getRootModelWatchPaths(model);
53+
54+
if (logsEnabled) {
55+
const logPaths = [...watchedPaths].map((at) => `- ${at}`).join('\n');
56+
console.log(`Watched file paths:\n${logPaths}`);
57+
}
58+
59+
const watcher = watch([...watchedPaths], {
60+
alwaysStat: false,
61+
ignoreInitial: true,
62+
ignorePermissionErrors: true,
63+
ignored: (at) => !schemaExtensions.some((ext) => at.endsWith(ext)),
64+
});
65+
66+
// prevent save multiple files and run multiple times
67+
const reGenerateSchema = singleDebounce(async () => {
68+
if (logsEnabled) {
69+
console.log('Got changes, run generation!');
70+
}
71+
72+
try {
73+
const newModel = await pureGenerate(options, true);
74+
const allModelsPaths = getRootModelWatchPaths(newModel);
75+
const newModelPaths = [...allModelsPaths].filter((at) => !watchedPaths.has(at));
76+
const removeModelPaths = [...watchedPaths].filter((at) => !allModelsPaths.has(at));
77+
78+
if (newModelPaths.length) {
79+
if (logsEnabled) {
80+
const logPaths = newModelPaths.map((at) => `- ${at}`).join('\n');
81+
console.log(`Added file(s) to watch:\n${logPaths}`);
82+
}
83+
84+
newModelPaths.forEach((at) => watchedPaths.add(at));
85+
watcher.add(newModelPaths);
86+
}
87+
88+
if (removeModelPaths.length) {
89+
if (logsEnabled) {
90+
const logPaths = removeModelPaths.map((at) => `- ${at}`).join('\n');
91+
console.log(`Removed file(s) from watch:\n${logPaths}`);
92+
}
93+
94+
removeModelPaths.forEach((at) => watchedPaths.delete(at));
95+
watcher.unwatch(removeModelPaths);
96+
}
97+
} catch (e) {
98+
console.error(e);
99+
}
100+
}, 500, true);
101+
102+
watcher.on('unlink', (pathAt) => {
103+
if (logsEnabled) {
104+
console.log(`Removed file from watch: ${pathAt}`);
105+
}
106+
107+
watchedPaths.delete(pathAt);
108+
watcher.unwatch(pathAt);
109+
110+
reGenerateSchema();
111+
});
112+
113+
watcher.on('change', () => {
114+
reGenerateSchema();
115+
});
116+
}
117+
}
118+
119+
async function pureGenerate(options: Options, fromWatch: boolean) {
27120
const start = Date.now();
28121

29122
const schemaFile = getSchemaFile(options.schema);
@@ -35,7 +128,9 @@ export async function run(options: Options) {
35128

36129
if (!options.silent) {
37130
console.log(colors.green(`Generation completed successfully in ${Date.now() - start}ms.\n`));
38-
console.log(`You can now create a ZenStack client with it.
131+
132+
if (!fromWatch) {
133+
console.log(`You can now create a ZenStack client with it.
39134
40135
\`\`\`ts
41136
import { ZenStackClient } from '@zenstackhq/orm';
@@ -47,7 +142,10 @@ const client = new ZenStackClient(schema, {
47142
\`\`\`
48143
49144
Check documentation: https://zenstack.dev/docs/`);
145+
}
50146
}
147+
148+
return model;
51149
}
52150

53151
function getOutputPath(options: Options, schemaFile: string) {

packages/cli/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ function createProgram() {
6868
.addOption(schemaOption)
6969
.addOption(noVersionCheckOption)
7070
.addOption(new Option('-o, --output <path>', 'default output directory for code generation'))
71+
.addOption(new Option('-w, --watch', 'enable watch mode').default(false))
7172
.addOption(new Option('--lite', 'also generate a lite version of schema without attributes').default(false))
7273
.addOption(new Option('--lite-only', 'only generate lite version of schema without attributes').default(false))
7374
.addOption(new Option('--silent', 'suppress all output except errors').default(false))
@@ -220,6 +221,11 @@ async function main() {
220221
}
221222
}
222223

224+
if (program.args.includes('generate') && (program.args.includes('-w') || program.args.includes('--watch'))) {
225+
// A "hack" way to prevent the process from terminating because we don't want to stop it.
226+
return;
227+
}
228+
223229
if (telemetry.isTracking) {
224230
// give telemetry a chance to send events before exit
225231
setTimeout(() => {

packages/common-helpers/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './is-plain-object';
44
export * from './lower-case-first';
55
export * from './param-case';
66
export * from './safe-json-stringify';
7+
export * from './single-debounce';
78
export * from './sleep';
89
export * from './tiny-invariant';
910
export * from './upper-case-first';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export function singleDebounce(cb: () => void | PromiseLike<void>, debounceMc: number, reRunOnInProgressCall: boolean = false) {
2+
let timeout: ReturnType<typeof setTimeout> | undefined;
3+
let inProgress = false;
4+
let pendingInProgress = false;
5+
6+
const run = async () => {
7+
if (inProgress) {
8+
if (reRunOnInProgressCall) {
9+
pendingInProgress = true;
10+
}
11+
12+
return;
13+
}
14+
15+
inProgress = true;
16+
pendingInProgress = false;
17+
18+
try {
19+
await cb();
20+
} finally {
21+
inProgress = false;
22+
23+
if (pendingInProgress) {
24+
await run();
25+
}
26+
}
27+
};
28+
29+
return () => {
30+
clearTimeout(timeout);
31+
32+
timeout = setTimeout(run, debounceMc);
33+
}
34+
}

packages/orm/src/client/crud/validator/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -402,9 +402,13 @@ export class InputValidator<Schema extends SchemaDef> {
402402
// zod doesn't preserve object field order after parsing, here we use a
403403
// validation-only custom schema and use the original data if parsing
404404
// is successful
405-
const finalSchema = z.custom((v) => {
406-
return schema.safeParse(v).success;
405+
const finalSchema = z.any().superRefine((value, ctx) => {
406+
const parseResult = schema.safeParse(value);
407+
if (!parseResult.success) {
408+
parseResult.error.issues.forEach((issue) => ctx.addIssue(issue as any));
409+
}
407410
});
411+
408412
this.setSchemaCache(key!, finalSchema);
409413
return finalSchema;
410414
}
@@ -515,7 +519,7 @@ export class InputValidator<Schema extends SchemaDef> {
515519
}
516520

517521
// expression builder
518-
fields['$expr'] = z.custom((v) => typeof v === 'function').optional();
522+
fields['$expr'] = z.custom((v) => typeof v === 'function', { error: '"$expr" must be a function' }).optional();
519523

520524
// logical operators
521525
fields['AND'] = this.orArray(

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/e2e/orm/client-api/typed-json-fields.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ model User {
121121
},
122122
},
123123
}),
124-
).rejects.toThrow(/invalid/i);
124+
).rejects.toThrow('data.identity.providers[0].id');
125125
});
126126

127127
it('works with find', async () => {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createTestClient } from '@zenstackhq/testtools';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('Regression for issue #558', () => {
5+
it('verifies issue 558', async () => {
6+
const db = await createTestClient(`
7+
type Foo {
8+
x Int
9+
}
10+
11+
model Model {
12+
id String @id @default(cuid())
13+
foo Foo @json
14+
}
15+
`);
16+
17+
await expect(db.model.create({ data: { foo: { x: 'hello' } } })).rejects.toThrow('data.foo.x');
18+
});
19+
});

0 commit comments

Comments
 (0)