on-zero 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/cjs/cli.cjs +17 -424
  2. package/dist/cjs/cli.js +7 -402
  3. package/dist/cjs/cli.js.map +2 -2
  4. package/dist/cjs/cli.native.js +15 -519
  5. package/dist/cjs/cli.native.js.map +1 -1
  6. package/dist/cjs/generate.cjs +370 -0
  7. package/dist/cjs/generate.js +339 -0
  8. package/dist/cjs/generate.js.map +6 -0
  9. package/dist/cjs/generate.native.js +464 -0
  10. package/dist/cjs/generate.native.js.map +1 -0
  11. package/dist/cjs/generate.test.cjs +113 -0
  12. package/dist/cjs/generate.test.js +126 -0
  13. package/dist/cjs/generate.test.js.map +6 -0
  14. package/dist/cjs/generate.test.native.js +116 -0
  15. package/dist/cjs/generate.test.native.js.map +1 -0
  16. package/dist/cjs/vite-plugin.cjs +37 -121
  17. package/dist/cjs/vite-plugin.js +41 -100
  18. package/dist/cjs/vite-plugin.js.map +1 -1
  19. package/dist/cjs/vite-plugin.native.js +47 -157
  20. package/dist/cjs/vite-plugin.native.js.map +1 -1
  21. package/dist/esm/cli.js +8 -388
  22. package/dist/esm/cli.js.map +2 -2
  23. package/dist/esm/cli.mjs +17 -402
  24. package/dist/esm/cli.mjs.map +1 -1
  25. package/dist/esm/cli.native.js +15 -497
  26. package/dist/esm/cli.native.js.map +1 -1
  27. package/dist/esm/generate.js +317 -0
  28. package/dist/esm/generate.js.map +6 -0
  29. package/dist/esm/generate.mjs +335 -0
  30. package/dist/esm/generate.mjs.map +1 -0
  31. package/dist/esm/generate.native.js +426 -0
  32. package/dist/esm/generate.native.js.map +1 -0
  33. package/dist/esm/generate.test.js +130 -0
  34. package/dist/esm/generate.test.js.map +6 -0
  35. package/dist/esm/generate.test.mjs +114 -0
  36. package/dist/esm/generate.test.mjs.map +1 -0
  37. package/dist/esm/generate.test.native.js +114 -0
  38. package/dist/esm/generate.test.native.js.map +1 -0
  39. package/dist/esm/vite-plugin.js +42 -102
  40. package/dist/esm/vite-plugin.js.map +1 -1
  41. package/dist/esm/vite-plugin.mjs +37 -121
  42. package/dist/esm/vite-plugin.mjs.map +1 -1
  43. package/dist/esm/vite-plugin.native.js +47 -157
  44. package/dist/esm/vite-plugin.native.js.map +1 -1
  45. package/package.json +6 -3
  46. package/readme.md +0 -29
  47. package/src/cli.ts +9 -646
  48. package/src/generate.test.ts +201 -0
  49. package/src/generate.ts +491 -0
  50. package/src/vite-plugin.ts +61 -189
  51. package/types/generate.d.ts +21 -0
  52. package/types/generate.d.ts.map +1 -0
  53. package/types/generate.test.d.ts +2 -0
  54. package/types/generate.test.d.ts.map +1 -0
  55. package/types/vite-plugin.d.ts +6 -29
  56. package/types/vite-plugin.d.ts.map +1 -1
@@ -0,0 +1,201 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
6
+
7
+ import { generate } from './generate'
8
+
9
+ const testDir = join(tmpdir(), 'on-zero-test-' + Date.now())
10
+
11
+ beforeEach(() => {
12
+ mkdirSync(join(testDir, 'models'), { recursive: true })
13
+ mkdirSync(join(testDir, 'queries'), { recursive: true })
14
+ })
15
+
16
+ afterEach(() => {
17
+ rmSync(testDir, { recursive: true, force: true })
18
+ })
19
+
20
+ describe('generate', () => {
21
+ test('generates models.ts, types.ts, tables.ts from model files', async () => {
22
+ writeFileSync(
23
+ join(testDir, 'models/post.ts'),
24
+ `
25
+ import { table, string, boolean } from 'on-zero'
26
+
27
+ export const schema = table('post', {
28
+ id: string(),
29
+ title: string(),
30
+ published: boolean(),
31
+ })
32
+ `
33
+ )
34
+
35
+ writeFileSync(
36
+ join(testDir, 'models/comment.ts'),
37
+ `
38
+ import { table, string } from 'on-zero'
39
+
40
+ export const schema = table('comment', {
41
+ id: string(),
42
+ postId: string(),
43
+ body: string(),
44
+ })
45
+ `
46
+ )
47
+
48
+ const result = await generate({ dir: testDir, silent: true })
49
+
50
+ expect(result.modelCount).toBe(2)
51
+ expect(result.schemaCount).toBe(2)
52
+ expect(result.filesChanged).toBeGreaterThan(0)
53
+
54
+ // check generated files exist
55
+ expect(existsSync(join(testDir, 'generated/models.ts'))).toBe(true)
56
+ expect(existsSync(join(testDir, 'generated/types.ts'))).toBe(true)
57
+ expect(existsSync(join(testDir, 'generated/tables.ts'))).toBe(true)
58
+
59
+ // check models.ts content
60
+ const modelsContent = readFileSync(join(testDir, 'generated/models.ts'), 'utf-8')
61
+ expect(modelsContent).toContain("import * as comment from '../models/comment'")
62
+ expect(modelsContent).toContain("import * as post from '../models/post'")
63
+ expect(modelsContent).toContain('export const models = {')
64
+
65
+ // check types.ts content
66
+ const typesContent = readFileSync(join(testDir, 'generated/types.ts'), 'utf-8')
67
+ expect(typesContent).toContain(
68
+ 'export type Post = TableInsertRow<typeof schema.post>'
69
+ )
70
+ expect(typesContent).toContain(
71
+ 'export type Comment = TableInsertRow<typeof schema.comment>'
72
+ )
73
+
74
+ // check tables.ts content
75
+ const tablesContent = readFileSync(join(testDir, 'generated/tables.ts'), 'utf-8')
76
+ expect(tablesContent).toContain("export { schema as post } from '../models/post'")
77
+ expect(tablesContent).toContain(
78
+ "export { schema as comment } from '../models/comment'"
79
+ )
80
+ })
81
+
82
+ test('generates query validators from query files', async () => {
83
+ // need at least one model
84
+ writeFileSync(
85
+ join(testDir, 'models/post.ts'),
86
+ `export const schema = table('post', { id: string() })`
87
+ )
88
+
89
+ writeFileSync(
90
+ join(testDir, 'queries/post.ts'),
91
+ `
92
+ import { zero } from '../zero'
93
+
94
+ export const allPosts = () => zero.query.post
95
+
96
+ export const postById = ({ id }: { id: string }) => zero.query.post.where('id', id)
97
+
98
+ export const postsByAuthor = ({ authorId, limit }: { authorId: string; limit?: number }) =>
99
+ zero.query.post.where('authorId', authorId).limit(limit ?? 10)
100
+ `
101
+ )
102
+
103
+ const result = await generate({ dir: testDir, silent: true })
104
+
105
+ expect(result.queryCount).toBe(3)
106
+
107
+ // check query files exist
108
+ expect(existsSync(join(testDir, 'generated/groupedQueries.ts'))).toBe(true)
109
+ expect(existsSync(join(testDir, 'generated/syncedQueries.ts'))).toBe(true)
110
+
111
+ // check groupedQueries.ts
112
+ const groupedContent = readFileSync(
113
+ join(testDir, 'generated/groupedQueries.ts'),
114
+ 'utf-8'
115
+ )
116
+ expect(groupedContent).toContain("export * as post from '../queries/post'")
117
+
118
+ // check syncedQueries.ts has validators
119
+ const syncedContent = readFileSync(
120
+ join(testDir, 'generated/syncedQueries.ts'),
121
+ 'utf-8'
122
+ )
123
+ expect(syncedContent).toContain('allPosts: defineQuery')
124
+ expect(syncedContent).toContain('postById: defineQuery')
125
+ expect(syncedContent).toContain('postsByAuthor: defineQuery')
126
+ expect(syncedContent).toContain('v.object')
127
+ })
128
+
129
+ test('skips permission exports in queries', async () => {
130
+ writeFileSync(
131
+ join(testDir, 'models/post.ts'),
132
+ `export const schema = table('post', { id: string() })`
133
+ )
134
+
135
+ writeFileSync(
136
+ join(testDir, 'queries/post.ts'),
137
+ `
138
+ export const permission = () => ({ canRead: true })
139
+ export const allPosts = () => zero.query.post
140
+ `
141
+ )
142
+
143
+ const result = await generate({ dir: testDir, silent: true })
144
+
145
+ expect(result.queryCount).toBe(1)
146
+
147
+ const syncedContent = readFileSync(
148
+ join(testDir, 'generated/syncedQueries.ts'),
149
+ 'utf-8'
150
+ )
151
+ expect(syncedContent).toContain('allPosts')
152
+ expect(syncedContent).not.toContain('permission:')
153
+ })
154
+
155
+ test('handles user model special case (userPublic)', async () => {
156
+ writeFileSync(
157
+ join(testDir, 'models/user.ts'),
158
+ `export const schema = table('user', { id: string(), name: string() })`
159
+ )
160
+
161
+ await generate({ dir: testDir, silent: true })
162
+
163
+ const modelsContent = readFileSync(join(testDir, 'generated/models.ts'), 'utf-8')
164
+ expect(modelsContent).toContain("import * as userPublic from '../models/user'")
165
+ expect(modelsContent).toContain('userPublic,')
166
+
167
+ const typesContent = readFileSync(join(testDir, 'generated/types.ts'), 'utf-8')
168
+ expect(typesContent).toContain('typeof schema.userPublic')
169
+ })
170
+
171
+ test('runs after command when files change', async () => {
172
+ writeFileSync(
173
+ join(testDir, 'models/post.ts'),
174
+ `export const schema = table('post', { id: string() })`
175
+ )
176
+
177
+ // use a command that creates a marker file
178
+ const markerFile = join(testDir, 'after-ran')
179
+ const result = await generate({
180
+ dir: testDir,
181
+ silent: true,
182
+ after: `touch ${markerFile}`,
183
+ })
184
+
185
+ expect(result.filesChanged).toBeGreaterThan(0)
186
+ expect(existsSync(markerFile)).toBe(true)
187
+ })
188
+
189
+ test('does not regenerate when nothing changed', async () => {
190
+ writeFileSync(
191
+ join(testDir, 'models/post.ts'),
192
+ `export const schema = table('post', { id: string() })`
193
+ )
194
+
195
+ const first = await generate({ dir: testDir, silent: true })
196
+ expect(first.filesChanged).toBeGreaterThan(0)
197
+
198
+ const second = await generate({ dir: testDir, silent: true })
199
+ expect(second.filesChanged).toBe(0)
200
+ })
201
+ })
@@ -0,0 +1,491 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
3
+ import { basename, resolve } from 'node:path'
4
+
5
+ const hash = (s: string) => createHash('sha256').update(s).digest('hex')
6
+
7
+ let generateCache: Record<string, string> = {}
8
+ let generateCachePath = ''
9
+
10
+ function getCacheDir() {
11
+ let dir = process.cwd()
12
+ while (dir !== '/') {
13
+ const nm = resolve(dir, 'node_modules')
14
+ if (existsSync(nm)) {
15
+ const cacheDir = resolve(nm, '.on-zero')
16
+ if (!existsSync(cacheDir)) {
17
+ mkdirSync(cacheDir, { recursive: true })
18
+ }
19
+ return cacheDir
20
+ }
21
+ dir = resolve(dir, '..')
22
+ }
23
+ return null
24
+ }
25
+
26
+ function loadCache() {
27
+ const cacheDir = getCacheDir()
28
+ if (!cacheDir) return
29
+ generateCachePath = resolve(cacheDir, 'generate-cache.json')
30
+ try {
31
+ generateCache = JSON.parse(readFileSync(generateCachePath, 'utf-8'))
32
+ } catch {
33
+ generateCache = {}
34
+ }
35
+ }
36
+
37
+ function saveCache() {
38
+ if (generateCachePath) {
39
+ writeFileSync(generateCachePath, JSON.stringify(generateCache) + '\n', 'utf-8')
40
+ }
41
+ }
42
+
43
+ function writeFileIfChanged(filePath: string, content: string): boolean {
44
+ const contentHash = hash(content)
45
+ const cachedHash = generateCache[filePath]
46
+
47
+ if (cachedHash === contentHash && existsSync(filePath)) {
48
+ return false
49
+ }
50
+
51
+ writeFileSync(filePath, content, 'utf-8')
52
+ generateCache[filePath] = contentHash
53
+ return true
54
+ }
55
+
56
+ function generateModelsFile(modelFiles: string[]) {
57
+ const modelNames = modelFiles.map((f) => basename(f, '.ts')).sort()
58
+ const getImportName = (name: string) => (name === 'user' ? 'userPublic' : name)
59
+
60
+ const imports = modelNames
61
+ .map((name) => `import * as ${getImportName(name)} from '../models/${name}'`)
62
+ .join('\n')
63
+
64
+ const sortedByImportName = [...modelNames].sort((a, b) =>
65
+ getImportName(a).localeCompare(getImportName(b))
66
+ )
67
+ const modelsObj = `export const models = {\n${sortedByImportName.map((name) => ` ${getImportName(name)},`).join('\n')}\n}`
68
+
69
+ const hmrBoundary = `
70
+ if (import.meta.hot) {
71
+ import.meta.hot.accept()
72
+ }
73
+ `
74
+
75
+ return `// auto-generated by: on-zero generate\n${imports}\n\n${modelsObj}\n${hmrBoundary}`
76
+ }
77
+
78
+ function generateTypesFile(modelFiles: string[]) {
79
+ const modelNames = modelFiles.map((f) => basename(f, '.ts')).sort()
80
+ const getSchemaName = (name: string) => (name === 'user' ? 'userPublic' : name)
81
+
82
+ const typeExports = modelNames
83
+ .map((name) => {
84
+ const pascalName = name.charAt(0).toUpperCase() + name.slice(1)
85
+ const schemaName = getSchemaName(name)
86
+ return `export type ${pascalName} = TableInsertRow<typeof schema.${schemaName}>\nexport type ${pascalName}Update = TableUpdateRow<typeof schema.${schemaName}>`
87
+ })
88
+ .join('\n\n')
89
+
90
+ return `import type { TableInsertRow, TableUpdateRow } from 'on-zero'\nimport type * as schema from './tables'\n\n${typeExports}\n`
91
+ }
92
+
93
+ function generateTablesFile(modelFiles: string[]) {
94
+ const modelNames = modelFiles.map((f) => basename(f, '.ts')).sort()
95
+ const getExportName = (name: string) => (name === 'user' ? 'userPublic' : name)
96
+
97
+ const exports = modelNames
98
+ .map((name) => `export { schema as ${getExportName(name)} } from '../models/${name}'`)
99
+ .join('\n')
100
+
101
+ return `// auto-generated by: on-zero generate\n// this is separate from models as otherwise you end up with circular types :/\n\n${exports}\n`
102
+ }
103
+
104
+ function generateReadmeFile() {
105
+ return `# generated
106
+
107
+ this folder is auto-generated by on-zero. do not edit files here directly.
108
+
109
+ ## what's generated
110
+
111
+ - \`models.ts\` - exports all models from ../models
112
+ - \`types.ts\` - typescript types derived from table schemas
113
+ - \`tables.ts\` - exports table schemas for type inference
114
+ - \`groupedQueries.ts\` - namespaced query re-exports for client setup
115
+ - \`syncedQueries.ts\` - namespaced syncedQuery wrappers for server setup
116
+
117
+ ## usage guidelines
118
+
119
+ **do not import generated files outside of the data folder.**
120
+
121
+ ### queries
122
+
123
+ write your queries as plain functions in \`../queries/\` and import them directly:
124
+
125
+ \`\`\`ts
126
+ // ✅ good - import from queries
127
+ import { channelMessages } from '~/data/queries/message'
128
+ \`\`\`
129
+
130
+ the generated query files are only used internally by zero client/server setup.
131
+
132
+ ### types
133
+
134
+ you can import types from this folder, but prefer re-exporting from \`../types.ts\`:
135
+
136
+ \`\`\`ts
137
+ // ❌ okay but not preferred
138
+ import type { Message } from '~/data/generated/types'
139
+
140
+ // ✅ better - re-export from types.ts
141
+ import type { Message } from '~/data/types'
142
+ \`\`\`
143
+
144
+ ## regeneration
145
+
146
+ files are regenerated when you run:
147
+
148
+ \`\`\`bash
149
+ bun on-zero generate
150
+ \`\`\`
151
+
152
+ or in watch mode:
153
+
154
+ \`\`\`bash
155
+ bun on-zero generate --watch
156
+ \`\`\`
157
+
158
+ ## more info
159
+
160
+ see the [on-zero readme](./node_modules/on-zero/README.md) for full documentation.
161
+ `
162
+ }
163
+
164
+ function generateGroupedQueriesFile(
165
+ queries: Array<{ name: string; sourceFile: string }>
166
+ ) {
167
+ const sortedFiles = [...new Set(queries.map((q) => q.sourceFile))].sort()
168
+
169
+ const exports = sortedFiles
170
+ .map((file) => `export * as ${file} from '../queries/${file}'`)
171
+ .join('\n')
172
+
173
+ return `/**
174
+ * auto-generated by: on-zero generate
175
+ *
176
+ * grouped query re-exports for minification-safe query identity.
177
+ * this file re-exports all query modules - while this breaks tree-shaking,
178
+ * queries are typically small and few in number even in larger apps.
179
+ */
180
+ ${exports}
181
+ `
182
+ }
183
+
184
+ function generateSyncedQueriesFile(
185
+ queries: Array<{
186
+ name: string
187
+ params: string
188
+ valibotCode: string
189
+ sourceFile: string
190
+ }>
191
+ ) {
192
+ const queryByFile = new Map<string, typeof queries>()
193
+ for (const q of queries) {
194
+ if (!queryByFile.has(q.sourceFile)) {
195
+ queryByFile.set(q.sourceFile, [])
196
+ }
197
+ queryByFile.get(q.sourceFile)!.push(q)
198
+ }
199
+
200
+ const sortedFiles = Array.from(queryByFile.keys()).sort()
201
+
202
+ const imports = `// auto-generated by: on-zero generate
203
+ // server-side query definitions with validators
204
+ import { defineQuery, defineQueries } from '@rocicorp/zero'
205
+ import * as v from 'valibot'
206
+ import * as Queries from './groupedQueries'
207
+ `
208
+
209
+ const namespaceDefs = sortedFiles
210
+ .map((file) => {
211
+ const fileQueries = queryByFile
212
+ .get(file)!
213
+ .sort((a, b) => a.name.localeCompare(b.name))
214
+
215
+ const queryDefs = fileQueries
216
+ .map((q) => {
217
+ const lines = q.valibotCode.split('\n').filter((l) => l.trim())
218
+ const schemaLineIndex = lines.findIndex((l) =>
219
+ l.startsWith('export const QueryParams')
220
+ )
221
+
222
+ let validatorDef = ''
223
+ if (schemaLineIndex !== -1) {
224
+ const schemaLines: string[] = []
225
+ let openBraces = 0
226
+ let started = false
227
+
228
+ for (let i = schemaLineIndex; i < lines.length; i++) {
229
+ const line = lines[i]!
230
+ const cleaned = started
231
+ ? line
232
+ : line.replace('export const QueryParams = ', '')
233
+ schemaLines.push(cleaned)
234
+ started = true
235
+
236
+ openBraces += (cleaned.match(/\{/g) || []).length
237
+ openBraces -= (cleaned.match(/\}/g) || []).length
238
+ openBraces += (cleaned.match(/\(/g) || []).length
239
+ openBraces -= (cleaned.match(/\)/g) || []).length
240
+
241
+ if (openBraces === 0 && schemaLines.length > 0) {
242
+ break
243
+ }
244
+ }
245
+ validatorDef = schemaLines.join('\n')
246
+ }
247
+
248
+ if (q.params === 'void' || !validatorDef) {
249
+ return ` ${q.name}: defineQuery(() => Queries.${file}.${q.name}()),`
250
+ }
251
+
252
+ const indentedValidator = validatorDef
253
+ .split('\n')
254
+ .map((line, i) => (i === 0 ? line : ` ${line}`))
255
+ .join('\n')
256
+
257
+ return ` ${q.name}: defineQuery(
258
+ ${indentedValidator},
259
+ ({ args }) => Queries.${file}.${q.name}(args)
260
+ ),`
261
+ })
262
+ .join('\n')
263
+
264
+ return `const ${file} = {\n${queryDefs}\n}`
265
+ })
266
+ .join('\n\n')
267
+
268
+ const queriesObject = sortedFiles.map((file) => ` ${file},`).join('\n')
269
+
270
+ return `${imports}
271
+ ${namespaceDefs}
272
+
273
+ export const queries = defineQueries({
274
+ ${queriesObject}
275
+ })
276
+ `
277
+ }
278
+
279
+ export interface GenerateOptions {
280
+ /** base data directory */
281
+ dir: string
282
+ /** run after generation */
283
+ after?: string
284
+ /** suppress output */
285
+ silent?: boolean
286
+ }
287
+
288
+ export interface WatchOptions extends GenerateOptions {
289
+ /** debounce delay in ms */
290
+ debounce?: number
291
+ }
292
+
293
+ export interface GenerateResult {
294
+ filesChanged: number
295
+ modelCount: number
296
+ schemaCount: number
297
+ queryCount: number
298
+ }
299
+
300
+ export async function generate(options: GenerateOptions): Promise<GenerateResult> {
301
+ const { dir, after, silent } = options
302
+ const baseDir = resolve(dir)
303
+ const modelsDir = resolve(baseDir, 'models')
304
+ const generatedDir = resolve(baseDir, 'generated')
305
+ const queriesDir = resolve(baseDir, 'queries')
306
+
307
+ if (!existsSync(generatedDir)) {
308
+ mkdirSync(generatedDir, { recursive: true })
309
+ }
310
+
311
+ loadCache()
312
+
313
+ const allModelFiles = readdirSync(modelsDir)
314
+ .filter((f) => f.endsWith('.ts'))
315
+ .sort()
316
+
317
+ const filesWithSchema = allModelFiles.filter((f) =>
318
+ readFileSync(resolve(modelsDir, f), 'utf-8').includes('export const schema = table(')
319
+ )
320
+
321
+ const writeResults = [
322
+ writeFileIfChanged(
323
+ resolve(generatedDir, 'models.ts'),
324
+ generateModelsFile(allModelFiles)
325
+ ),
326
+ writeFileIfChanged(
327
+ resolve(generatedDir, 'types.ts'),
328
+ generateTypesFile(filesWithSchema)
329
+ ),
330
+ writeFileIfChanged(
331
+ resolve(generatedDir, 'tables.ts'),
332
+ generateTablesFile(filesWithSchema)
333
+ ),
334
+ writeFileIfChanged(resolve(generatedDir, 'README.md'), generateReadmeFile()),
335
+ ]
336
+
337
+ let filesChanged = writeResults.filter(Boolean).length
338
+ let queryCount = 0
339
+
340
+ // generate query files if queries directory exists
341
+ if (existsSync(queriesDir)) {
342
+ const ts = await import('typescript')
343
+ const { ModelToValibot } = await import('@sinclair/typebox-codegen/model/index.js')
344
+ const { TypeScriptToModel } =
345
+ await import('@sinclair/typebox-codegen/typescript/index.js')
346
+
347
+ const queryFiles = readdirSync(queriesDir).filter((f) => f.endsWith('.ts'))
348
+
349
+ const allQueries: Array<{
350
+ name: string
351
+ params: string
352
+ valibotCode: string
353
+ sourceFile: string
354
+ }> = []
355
+
356
+ for (const file of queryFiles) {
357
+ const filePath = resolve(queriesDir, file)
358
+ const fileBaseName = basename(file, '.ts')
359
+
360
+ try {
361
+ const content = readFileSync(filePath, 'utf-8')
362
+ const sourceFile = ts.createSourceFile(
363
+ filePath,
364
+ content,
365
+ ts.ScriptTarget.Latest,
366
+ true
367
+ )
368
+
369
+ ts.forEachChild(sourceFile, (node) => {
370
+ if (ts.isVariableStatement(node)) {
371
+ const exportModifier = node.modifiers?.find(
372
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword
373
+ )
374
+ if (!exportModifier) return
375
+
376
+ const declaration = node.declarationList.declarations[0]
377
+ if (!declaration || !ts.isVariableDeclaration(declaration)) return
378
+
379
+ const name = declaration.name.getText(sourceFile)
380
+ if (name === 'permission') return
381
+
382
+ if (declaration.initializer && ts.isArrowFunction(declaration.initializer)) {
383
+ const params = declaration.initializer.parameters
384
+ let paramType = 'void'
385
+
386
+ if (params.length > 0) {
387
+ const param = params[0]!
388
+ paramType = param.type?.getText(sourceFile) || 'unknown'
389
+ }
390
+
391
+ try {
392
+ const typeString = `type QueryParams = ${paramType}`
393
+ const model = TypeScriptToModel.Generate(typeString)
394
+ const valibotCode = ModelToValibot.Generate(model)
395
+
396
+ allQueries.push({
397
+ name,
398
+ params: paramType,
399
+ valibotCode,
400
+ sourceFile: fileBaseName,
401
+ })
402
+ } catch (err) {
403
+ if (!silent) console.error(`✗ ${name}: ${err}`)
404
+ }
405
+ }
406
+ }
407
+ })
408
+ } catch (err) {
409
+ if (!silent) console.error(`Error processing ${file}:`, err)
410
+ }
411
+ }
412
+
413
+ queryCount = allQueries.length
414
+
415
+ const groupedChanged = writeFileIfChanged(
416
+ resolve(generatedDir, 'groupedQueries.ts'),
417
+ generateGroupedQueriesFile(allQueries)
418
+ )
419
+ const syncedChanged = writeFileIfChanged(
420
+ resolve(generatedDir, 'syncedQueries.ts'),
421
+ generateSyncedQueriesFile(allQueries)
422
+ )
423
+
424
+ if (groupedChanged) filesChanged++
425
+ if (syncedChanged) filesChanged++
426
+ }
427
+
428
+ if (filesChanged > 0 && !silent) {
429
+ console.info(
430
+ `✓ ${allModelFiles.length} models (${filesWithSchema.length} schemas)${queryCount ? `, ${queryCount} queries` : ''}`
431
+ )
432
+ }
433
+
434
+ // run after command
435
+ if (filesChanged > 0 && after) {
436
+ const { execSync } = await import('node:child_process')
437
+ try {
438
+ execSync(after, {
439
+ stdio: 'inherit',
440
+ env: { ...process.env, ON_ZERO_GENERATED_DIR: generatedDir },
441
+ })
442
+ } catch (err) {
443
+ if (!silent) console.error(`Error running after command: ${err}`)
444
+ }
445
+ }
446
+
447
+ saveCache()
448
+
449
+ return {
450
+ filesChanged,
451
+ modelCount: allModelFiles.length,
452
+ schemaCount: filesWithSchema.length,
453
+ queryCount,
454
+ }
455
+ }
456
+
457
+ export async function watch(options: WatchOptions) {
458
+ const { dir, debounce = 1000 } = options
459
+ const baseDir = resolve(dir)
460
+ const modelsDir = resolve(baseDir, 'models')
461
+ const queriesDir = resolve(baseDir, 'queries')
462
+ const generatedDir = resolve(baseDir, 'generated')
463
+
464
+ // initial run (silent)
465
+ await generate({ ...options, silent: true })
466
+ console.info('👀 watching...\n')
467
+
468
+ const chokidar = await import('chokidar')
469
+
470
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
471
+
472
+ const debouncedRegenerate = (path: string, event: string) => {
473
+ if (debounceTimer) clearTimeout(debounceTimer)
474
+ console.info(`\n${event} ${path}`)
475
+ debounceTimer = setTimeout(() => {
476
+ generate({ ...options, silent: false })
477
+ }, debounce)
478
+ }
479
+
480
+ const watcher = chokidar.watch([modelsDir, queriesDir], {
481
+ persistent: true,
482
+ ignoreInitial: true,
483
+ ignored: [generatedDir],
484
+ })
485
+
486
+ watcher.on('change', (path) => debouncedRegenerate(path, '📝'))
487
+ watcher.on('add', (path) => debouncedRegenerate(path, '➕'))
488
+ watcher.on('unlink', (path) => debouncedRegenerate(path, '🗑️ '))
489
+
490
+ return watcher
491
+ }