on-zero 0.4.1 → 0.4.2

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 (41) hide show
  1. package/dist/cjs/generate-helpers.cjs +309 -0
  2. package/dist/cjs/generate-helpers.native.js +451 -0
  3. package/dist/cjs/generate-helpers.native.js.map +1 -0
  4. package/dist/cjs/generate-lite.cjs +150 -0
  5. package/dist/cjs/generate-lite.native.js +269 -0
  6. package/dist/cjs/generate-lite.native.js.map +1 -0
  7. package/dist/cjs/generate-lite.test.cjs +229 -0
  8. package/dist/cjs/generate-lite.test.native.js +234 -0
  9. package/dist/cjs/generate-lite.test.native.js.map +1 -0
  10. package/dist/cjs/generate.cjs +16 -285
  11. package/dist/cjs/generate.native.js +18 -432
  12. package/dist/cjs/generate.native.js.map +1 -1
  13. package/dist/esm/generate-helpers.mjs +272 -0
  14. package/dist/esm/generate-helpers.mjs.map +1 -0
  15. package/dist/esm/generate-helpers.native.js +411 -0
  16. package/dist/esm/generate-helpers.native.js.map +1 -0
  17. package/dist/esm/generate-lite.mjs +127 -0
  18. package/dist/esm/generate-lite.mjs.map +1 -0
  19. package/dist/esm/generate-lite.native.js +243 -0
  20. package/dist/esm/generate-lite.native.js.map +1 -0
  21. package/dist/esm/generate-lite.test.mjs +230 -0
  22. package/dist/esm/generate-lite.test.mjs.map +1 -0
  23. package/dist/esm/generate-lite.test.native.js +232 -0
  24. package/dist/esm/generate-lite.test.native.js.map +1 -0
  25. package/dist/esm/generate.mjs +6 -275
  26. package/dist/esm/generate.mjs.map +1 -1
  27. package/dist/esm/generate.native.js +9 -423
  28. package/dist/esm/generate.native.js.map +1 -1
  29. package/package.json +7 -2
  30. package/src/generate-helpers.ts +440 -0
  31. package/src/generate-lite.test.ts +310 -0
  32. package/src/generate-lite.ts +333 -0
  33. package/src/generate.ts +23 -415
  34. package/types/generate-helpers.d.ts +42 -0
  35. package/types/generate-helpers.d.ts.map +1 -0
  36. package/types/generate-lite.d.ts +40 -0
  37. package/types/generate-lite.d.ts.map +1 -0
  38. package/types/generate-lite.test.d.ts +2 -0
  39. package/types/generate-lite.test.d.ts.map +1 -0
  40. package/types/generate.d.ts +1 -6
  41. package/types/generate.d.ts.map +1 -1
@@ -0,0 +1,310 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { generateLite } from './generate-lite'
4
+ import type { LiteParseFn, LiteParsedFile } from './generate-lite'
5
+
6
+ // minimal hand-rolled "parser" backed by a lookup table. the real caller
7
+ // (e.g. a browser worker) will plug in acorn+acorn-typescript here; for the
8
+ // test we just return pre-baked lite ast shapes keyed by file path, which
9
+ // keeps the test focused on generate-lite's wiring rather than ast walking.
10
+ function makeParse(table: Record<string, LiteParsedFile>): LiteParseFn {
11
+ return (_src, path) => {
12
+ const entry = table[path]
13
+ if (!entry) {
14
+ throw new Error(`no lite ast fixture for ${path}`)
15
+ }
16
+ return entry
17
+ }
18
+ }
19
+
20
+ const DIR = '/proj/src/data'
21
+
22
+ describe('generateLite', () => {
23
+ test('emits models.ts, syncedMutations.ts, and README.md from inline types', () => {
24
+ const files: Record<string, string> = {
25
+ [`${DIR}/models/todo.ts`]: '// fake source, parser returns fixture',
26
+ [`${DIR}/models/user.ts`]: '// fake source, parser returns fixture',
27
+ }
28
+
29
+ const fixtures: Record<string, LiteParsedFile> = {
30
+ [`${DIR}/models/todo.ts`]: {
31
+ mutations: [
32
+ {
33
+ modelName: 'todo',
34
+ handlers: [
35
+ {
36
+ name: 'toggle',
37
+ paramTypeText: '{ id: string; isActive: boolean }',
38
+ },
39
+ {
40
+ name: 'rename',
41
+ paramTypeText: '{ id: string; title: string }',
42
+ },
43
+ ],
44
+ schema: null,
45
+ },
46
+ ],
47
+ queries: [],
48
+ },
49
+ [`${DIR}/models/user.ts`]: {
50
+ mutations: [
51
+ {
52
+ modelName: 'user',
53
+ handlers: [
54
+ {
55
+ name: 'finishOnboarding',
56
+ // null = no second param / void
57
+ paramTypeText: null,
58
+ },
59
+ ],
60
+ schema: null,
61
+ },
62
+ ],
63
+ queries: [],
64
+ },
65
+ }
66
+
67
+ const result = generateLite({
68
+ files,
69
+ dir: DIR,
70
+ parse: makeParse(fixtures),
71
+ })
72
+
73
+ // output file set
74
+ expect(Object.keys(result.files).sort()).toEqual([
75
+ 'README.md',
76
+ 'models.ts',
77
+ 'syncedMutations.ts',
78
+ ])
79
+
80
+ // models.ts: imports both model files, handles user → userPublic
81
+ const models = result.files['models.ts']!
82
+ expect(models).toContain("import * as todo from '../models/todo'")
83
+ expect(models).toContain("import * as userPublic from '../models/user'")
84
+ expect(models).toContain('export const models = {')
85
+
86
+ // syncedMutations.ts: inline types resolve to v.object validators
87
+ const syncedMutations = result.files['syncedMutations.ts']!
88
+ expect(syncedMutations).toContain('toggle:')
89
+ expect(syncedMutations).toContain('v.object({')
90
+ expect(syncedMutations).toContain('id: v.string()')
91
+ expect(syncedMutations).toContain('isActive: v.boolean()')
92
+ expect(syncedMutations).toContain('rename:')
93
+ expect(syncedMutations).toContain('title: v.string()')
94
+ // null param → v.void_()
95
+ expect(syncedMutations).toContain('finishOnboarding: v.void_()')
96
+
97
+ expect(result.modelCount).toBe(2)
98
+ expect(result.schemaCount).toBe(0)
99
+ expect(result.mutationCount).toBe(3)
100
+ })
101
+
102
+ test('falls back to v.unknown() for type references', () => {
103
+ const files: Record<string, string> = {
104
+ [`${DIR}/models/post.ts`]: '// fake',
105
+ }
106
+
107
+ const fixtures: Record<string, LiteParsedFile> = {
108
+ [`${DIR}/models/post.ts`]: {
109
+ mutations: [
110
+ {
111
+ modelName: 'post',
112
+ handlers: [
113
+ {
114
+ name: 'archive',
115
+ // bare type reference — parseTypeString returns null, so
116
+ // generate-lite should fall back to v.unknown() rather than
117
+ // attempting cross-file type resolution.
118
+ paramTypeText: 'ArchiveParams',
119
+ },
120
+ {
121
+ name: 'publish',
122
+ // primitive, should resolve
123
+ paramTypeText: 'string',
124
+ },
125
+ ],
126
+ schema: null,
127
+ },
128
+ ],
129
+ queries: [],
130
+ },
131
+ }
132
+
133
+ const result = generateLite({
134
+ files,
135
+ dir: DIR,
136
+ parse: makeParse(fixtures),
137
+ })
138
+
139
+ const synced = result.files['syncedMutations.ts']!
140
+ expect(synced).toContain('archive: v.unknown()')
141
+ expect(synced).toContain('publish: v.string()')
142
+ })
143
+
144
+ test('emits query files with v.unknown() fallback for references', () => {
145
+ const files: Record<string, string> = {
146
+ [`${DIR}/models/post.ts`]: '// fake',
147
+ [`${DIR}/queries/post.ts`]: '// fake',
148
+ }
149
+
150
+ const fixtures: Record<string, LiteParsedFile> = {
151
+ [`${DIR}/models/post.ts`]: {
152
+ mutations: [],
153
+ queries: [],
154
+ },
155
+ [`${DIR}/queries/post.ts`]: {
156
+ mutations: [],
157
+ queries: [
158
+ // no-arg query → void
159
+ { name: 'allPosts', paramTypeText: null },
160
+ // inline object → real validator
161
+ { name: 'postById', paramTypeText: '{ id: string }' },
162
+ // primitive
163
+ { name: 'byAuthorId', paramTypeText: 'string' },
164
+ // type reference → fallback
165
+ { name: 'filtered', paramTypeText: 'PostFilter' },
166
+ // permission should be skipped
167
+ { name: 'permission', paramTypeText: null },
168
+ ],
169
+ },
170
+ }
171
+
172
+ const result = generateLite({
173
+ files,
174
+ dir: DIR,
175
+ parse: makeParse(fixtures),
176
+ })
177
+
178
+ // expect both query files
179
+ expect(result.files['groupedQueries.ts']).toBeDefined()
180
+ expect(result.files['syncedQueries.ts']).toBeDefined()
181
+
182
+ const grouped = result.files['groupedQueries.ts']!
183
+ expect(grouped).toContain("export * as post from '../queries/post'")
184
+
185
+ const synced = result.files['syncedQueries.ts']!
186
+ expect(synced).toContain('allPosts: defineQuery(() => Queries.post.allPosts())')
187
+ expect(synced).toContain('postById: defineQuery(')
188
+ expect(synced).toContain('id: v.string()')
189
+ expect(synced).toContain('byAuthorId: defineQuery(')
190
+ // primitive string param shows up as v.string() validator
191
+ expect(synced).toMatch(/byAuthorId: defineQuery\(\s*v\.string\(\)/)
192
+ // type reference fallback
193
+ expect(synced).toContain('filtered: defineQuery(')
194
+ expect(synced).toMatch(/filtered: defineQuery\(\s*v\.unknown\(\)/)
195
+ // permission export skipped
196
+ expect(synced).not.toContain('permission: defineQuery')
197
+
198
+ expect(result.queryCount).toBe(4) // permission excluded
199
+ })
200
+
201
+ test('emits types.ts and tables.ts when a model declares a schema inline', () => {
202
+ const files: Record<string, string> = {
203
+ [`${DIR}/models/task.ts`]: '// fake',
204
+ }
205
+
206
+ const fixtures: Record<string, LiteParsedFile> = {
207
+ [`${DIR}/models/task.ts`]: {
208
+ mutations: [
209
+ {
210
+ modelName: 'task',
211
+ handlers: [],
212
+ schema: {
213
+ tableName: 'task',
214
+ primaryKeys: ['id'],
215
+ columns: [
216
+ { name: 'id', builderText: 'string()' },
217
+ { name: 'title', builderText: 'string()' },
218
+ { name: 'priority', builderText: 'number()' },
219
+ { name: 'done', builderText: 'boolean()' },
220
+ { name: 'note', builderText: 'string().optional()' },
221
+ ],
222
+ },
223
+ },
224
+ ],
225
+ queries: [],
226
+ },
227
+ }
228
+
229
+ const result = generateLite({
230
+ files,
231
+ dir: DIR,
232
+ parse: makeParse(fixtures),
233
+ })
234
+
235
+ expect(result.schemaCount).toBe(1)
236
+ expect(result.files['types.ts']).toBeDefined()
237
+ expect(result.files['tables.ts']).toBeDefined()
238
+
239
+ const types = result.files['types.ts']!
240
+ expect(types).toContain('export type Task = TableInsertRow<typeof schema.task>')
241
+
242
+ const tables = result.files['tables.ts']!
243
+ expect(tables).toContain("export { schema as task } from '../models/task'")
244
+
245
+ // crud validators emitted in syncedMutations.ts
246
+ const synced = result.files['syncedMutations.ts']!
247
+ expect(synced).toContain('insert:')
248
+ expect(synced).toContain('update:')
249
+ expect(synced).toContain('delete:')
250
+ // crud count is 3
251
+ expect(result.mutationCount).toBe(3)
252
+ })
253
+
254
+ test('ignores nested files and non-ts files inside the models directory', () => {
255
+ const files: Record<string, string> = {
256
+ [`${DIR}/models/post.ts`]: '// fake',
257
+ [`${DIR}/models/README.md`]: 'not a model',
258
+ [`${DIR}/models/helpers/util.ts`]: 'nested should be ignored',
259
+ [`${DIR}/models/post.d.ts`]: 'declaration file, ignored',
260
+ }
261
+
262
+ const fixtures: Record<string, LiteParsedFile> = {
263
+ [`${DIR}/models/post.ts`]: {
264
+ mutations: [{ modelName: 'post', handlers: [], schema: null }],
265
+ queries: [],
266
+ },
267
+ }
268
+
269
+ const result = generateLite({
270
+ files,
271
+ dir: DIR,
272
+ parse: makeParse(fixtures),
273
+ })
274
+
275
+ expect(result.modelCount).toBe(1)
276
+ const models = result.files['models.ts']!
277
+ expect(models).toContain("import * as post from '../models/post'")
278
+ expect(models).not.toContain('util')
279
+ expect(models).not.toContain('README')
280
+ })
281
+
282
+ test('infers mutations/ directory when present', () => {
283
+ const files: Record<string, string> = {
284
+ [`${DIR}/mutations/post.ts`]: '// fake',
285
+ }
286
+
287
+ const fixtures: Record<string, LiteParsedFile> = {
288
+ [`${DIR}/mutations/post.ts`]: {
289
+ mutations: [
290
+ {
291
+ modelName: 'post',
292
+ handlers: [{ name: 'publish', paramTypeText: '{ id: string }' }],
293
+ schema: null,
294
+ },
295
+ ],
296
+ queries: [],
297
+ },
298
+ }
299
+
300
+ const result = generateLite({
301
+ files,
302
+ dir: DIR,
303
+ parse: makeParse(fixtures),
304
+ })
305
+
306
+ // should use mutations dir path in re-exports
307
+ const models = result.files['models.ts']!
308
+ expect(models).toContain("from '../mutations/post'")
309
+ })
310
+ })
@@ -0,0 +1,333 @@
1
+ // browser-safe entry point for the on-zero generator.
2
+ //
3
+ // unlike `./generate.ts`, this module:
4
+ // - does not import `typescript`, `node:fs`, `node:path`, or any node builtin
5
+ // - does not read or write files (returns a map of filename → content)
6
+ // - is parser-agnostic: callers supply a `parse` function that extracts the
7
+ // small amount of ast info on-zero needs (a "lite" ast). callers typically
8
+ // back that parse function with acorn + acorn-typescript, oxc, swc, or any
9
+ // other ts-aware parser they already bundle.
10
+ // - falls back to `v.unknown()` for any parameter whose type annotation text
11
+ // can't be parsed by on-zero's existing string-based `parseTypeString`
12
+ // helper. there is no cross-file type resolution.
13
+ //
14
+ // this makes the generator runnable inside web workers and other browser
15
+ // contexts without pulling in ~10mb of typescript.
16
+
17
+ import {
18
+ generateGroupedQueriesFile,
19
+ generateModelsFile,
20
+ generateReadmeFile,
21
+ generateSyncedMutationsFile,
22
+ generateSyncedQueriesFile,
23
+ generateTablesFile,
24
+ generateTypesFile,
25
+ parseColumnType,
26
+ parseTypeString,
27
+ } from './generate-helpers'
28
+ import type { ModelMutations, SchemaColumn } from './generate-helpers'
29
+
30
+ // public types
31
+
32
+ // minimal ast info about a single mutation handler file (e.g. `models/post.ts`)
33
+ export type LiteMutationExport = {
34
+ // the first arg to `mutations('NAME', ...)` — string literal from the source.
35
+ // not currently emitted anywhere in the output; the model file basename is
36
+ // used for the top-level key in syncedMutations instead. kept for parity with
37
+ // what callers naturally extract, and to leave room for future use.
38
+ modelName: string
39
+ // handlers = keys of the last object literal arg to `mutations(...)`
40
+ handlers: Array<{
41
+ // handler property name, e.g. 'toggleActive'
42
+ name: string
43
+ // text of the second parameter's type annotation, if present.
44
+ // e.g. '{ id: string; isActive: boolean }' or 'ToggleArgs' or null.
45
+ // for inline type literals parseable by `parseTypeString`, on-zero emits
46
+ // real v.object(...) validators. for references/generics/null, it falls
47
+ // back to `v.unknown()`.
48
+ paramTypeText: string | null
49
+ }>
50
+ // for models that declare `export const schema = table(...).columns(...)`,
51
+ // the caller extracts the table name + column builder text. null if the
52
+ // model doesn't declare a schema inline (most templates use drizzle-zero,
53
+ // so null is common).
54
+ schema: LiteSchemaInfo | null
55
+ }
56
+
57
+ export type LiteSchemaInfo = {
58
+ tableName: string
59
+ primaryKeys: string[]
60
+ // per-column: name + the column builder chain as source text, e.g.
61
+ // `"string().optional()"`. `parseColumnType` turns these into SchemaColumn.
62
+ columns: Array<{ name: string; builderText: string }>
63
+ }
64
+
65
+ export type LiteQueryExport = {
66
+ // exported variable name, e.g. 'flightById'
67
+ name: string
68
+ // text of the first parameter's type annotation, if present.
69
+ // e.g. 'string' | '{ id: string }' | null. null means the query takes no
70
+ // args and is treated as a void query in the generated output.
71
+ paramTypeText: string | null
72
+ }
73
+
74
+ // what the caller returns for each source file.
75
+ export type LiteParsedFile = {
76
+ // a model file exports at most one `mutate = mutations(...)`, but an array
77
+ // keeps the shape uniform and leaves room for future multi-export support.
78
+ mutations: LiteMutationExport[]
79
+ queries: LiteQueryExport[]
80
+ }
81
+
82
+ // the parser function signature the caller provides. pure: given source text
83
+ // and a path (for error messages), return the lite ast.
84
+ export type LiteParseFn = (sourceCode: string, filePath: string) => LiteParsedFile
85
+
86
+ export type LiteGenerateOptions = {
87
+ // file path → source content. paths are treated as opaque keys; generate-lite
88
+ // matches them by prefix against `{dir}/{modelsDir}/` and `{dir}/queries/`
89
+ // using forward-slash string comparison. pass whatever path shape you have
90
+ // (absolute, virtual, etc.), as long as you're consistent with `dir`.
91
+ files: Record<string, string>
92
+ // base data directory, e.g. '/proj/src/data'. generate-lite scans `files`
93
+ // for keys matching `{dir}/{modelsDir}/*.ts` and `{dir}/queries/*.ts`.
94
+ dir: string
95
+ // 'mutations' if `{dir}/mutations` exists, else 'models'. if the caller
96
+ // knows, they pass it; else generate-lite infers it from the file keys
97
+ // (prefers 'mutations' if any file is under it).
98
+ modelsDir?: 'mutations' | 'models'
99
+ parse: LiteParseFn
100
+ }
101
+
102
+ export type LiteGenerateResult = {
103
+ // relative paths under `{dir}/generated/`, e.g. 'models.ts',
104
+ // 'syncedMutations.ts'. callers decide where to write.
105
+ files: Record<string, string>
106
+ modelCount: number
107
+ queryCount: number
108
+ mutationCount: number
109
+ schemaCount: number
110
+ }
111
+
112
+ // path helpers — deliberately string-only, no node:path
113
+
114
+ function stripTrailingSlash(s: string): string {
115
+ return s.endsWith('/') ? s.slice(0, -1) : s
116
+ }
117
+
118
+ // returns the last '/' segment, stripped of a trailing `.ts` if present.
119
+ // does not depend on node:path.basename.
120
+ function baseName(path: string, ext?: string): string {
121
+ const idx = path.lastIndexOf('/')
122
+ let base = idx >= 0 ? path.slice(idx + 1) : path
123
+ if (ext && base.endsWith(ext)) base = base.slice(0, -ext.length)
124
+ return base
125
+ }
126
+
127
+ // returns files whose path is an immediate child of `dirPrefix` and ends in
128
+ // `.ts` (but not `.d.ts`, test files, or anything nested further down).
129
+ function listDirectTsFiles(files: Record<string, string>, dirPrefix: string): string[] {
130
+ const prefix = stripTrailingSlash(dirPrefix) + '/'
131
+ const out: string[] = []
132
+ for (const path of Object.keys(files)) {
133
+ if (!path.startsWith(prefix)) continue
134
+ const rest = path.slice(prefix.length)
135
+ // must be a direct child (no further slashes)
136
+ if (rest.includes('/')) continue
137
+ if (!rest.endsWith('.ts')) continue
138
+ if (rest.endsWith('.d.ts')) continue
139
+ out.push(path)
140
+ }
141
+ return out.sort()
142
+ }
143
+
144
+ // main entry point
145
+
146
+ export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult {
147
+ const { files, parse } = opts
148
+ const baseDir = stripTrailingSlash(opts.dir)
149
+
150
+ // determine models dir (mutations vs models)
151
+ let modelsDirName: 'mutations' | 'models'
152
+ if (opts.modelsDir) {
153
+ modelsDirName = opts.modelsDir
154
+ } else {
155
+ // infer: prefer 'mutations' if any file lives under {dir}/mutations/
156
+ const mutationsPrefix = `${baseDir}/mutations/`
157
+ const hasMutationsDir = Object.keys(files).some((p) => p.startsWith(mutationsPrefix))
158
+ modelsDirName = hasMutationsDir ? 'mutations' : 'models'
159
+ }
160
+
161
+ const modelsDirPath = `${baseDir}/${modelsDirName}`
162
+ const queriesDirPath = `${baseDir}/queries`
163
+
164
+ const modelFilePaths = listDirectTsFiles(files, modelsDirPath)
165
+ const queryFilePaths = listDirectTsFiles(files, queriesDirPath)
166
+
167
+ // parse each model file and build ModelMutations records for the emitter
168
+ const allModelMutations: ModelMutations[] = []
169
+ const modelNamesWithSchema: string[] = []
170
+
171
+ for (const filePath of modelFilePaths) {
172
+ const modelName = baseName(filePath, '.ts')
173
+ const content = files[filePath]!
174
+ const parsed = parse(content, filePath)
175
+
176
+ // a model file has at most one mutate export, but the lite ast is an array
177
+ const mutationExport = parsed.mutations[0] ?? null
178
+
179
+ // extract schema info if present
180
+ const columns: Record<string, SchemaColumn> = {}
181
+ const primaryKeys: string[] = []
182
+ let hasSchema = false
183
+
184
+ if (mutationExport?.schema) {
185
+ hasSchema = true
186
+ modelNamesWithSchema.push(modelName)
187
+ for (const pk of mutationExport.schema.primaryKeys) primaryKeys.push(pk)
188
+ for (const col of mutationExport.schema.columns) {
189
+ columns[col.name] = parseColumnType(col.builderText)
190
+ }
191
+ }
192
+
193
+ // a model participates in crud only when it has a schema AND its mutate
194
+ // call is `mutations(schema, perm)` / `mutations(schema, perm, { ... })`.
195
+ // in the lite ast, we don't know the call arity directly, so we use the
196
+ // presence of a schema as the signal. this matches the real generator's
197
+ // behavior for schemas-with-mutate: hasCRUD is true whenever both exist.
198
+ //
199
+ // models without `export const mutate` still appear in the output as
200
+ // empty entries — see the test "treats models without export const mutate
201
+ // as empty mutations".
202
+ const hasCRUD = hasSchema && mutationExport !== null
203
+
204
+ const custom = (mutationExport?.handlers ?? []).map((h) => {
205
+ // null or empty annotation → void (no second param) → v.void_()
206
+ if (h.paramTypeText == null) {
207
+ return { name: h.name, paramType: 'void', valibotCode: '' }
208
+ }
209
+
210
+ const paramType = h.paramTypeText.trim()
211
+
212
+ // unknown explicitly → no validator (becomes v.void_ in the emitter,
213
+ // matching the existing generator's behavior for 'unknown')
214
+ if (paramType === 'unknown') {
215
+ return { name: h.name, paramType: 'unknown', valibotCode: '' }
216
+ }
217
+
218
+ // try the pure string parser. if it fails, fall back to v.unknown()
219
+ // (no cross-file type resolution in lite mode).
220
+ let valibotCode: string | null = null
221
+ try {
222
+ valibotCode = parseTypeString(paramType)
223
+ } catch {
224
+ valibotCode = null
225
+ }
226
+
227
+ return {
228
+ name: h.name,
229
+ paramType,
230
+ valibotCode: valibotCode ?? 'v.unknown()',
231
+ }
232
+ })
233
+
234
+ allModelMutations.push({
235
+ modelName,
236
+ hasCRUD,
237
+ columns,
238
+ primaryKeys,
239
+ custom,
240
+ })
241
+ }
242
+
243
+ // parse each query file
244
+ const allQueries: Array<{
245
+ name: string
246
+ params: string
247
+ valibotCode: string
248
+ sourceFile: string
249
+ }> = []
250
+
251
+ for (const filePath of queryFilePaths) {
252
+ const fileBaseName = baseName(filePath, '.ts')
253
+ const content = files[filePath]!
254
+ const parsed = parse(content, filePath)
255
+
256
+ for (const q of parsed.queries) {
257
+ // permission exports are filtered upstream in existing behavior
258
+ if (q.name === 'permission') continue
259
+
260
+ // null annotation → no first arg → void query
261
+ if (q.paramTypeText == null) {
262
+ allQueries.push({
263
+ name: q.name,
264
+ params: 'void',
265
+ valibotCode: '',
266
+ sourceFile: fileBaseName,
267
+ })
268
+ continue
269
+ }
270
+
271
+ const paramType = q.paramTypeText.trim()
272
+
273
+ // try to parse the annotation. if we can't, fall back to v.unknown()
274
+ // so the query still makes it into the output (consistent with
275
+ // mutations). the existing node generator silently drops queries
276
+ // whose types can't be resolved; in lite mode we emit them with a
277
+ // permissive validator rather than losing them.
278
+ let valibotCode: string | null = null
279
+ try {
280
+ valibotCode = parseTypeString(paramType)
281
+ } catch {
282
+ valibotCode = null
283
+ }
284
+
285
+ allQueries.push({
286
+ name: q.name,
287
+ params: paramType,
288
+ valibotCode: valibotCode ?? 'v.unknown()',
289
+ sourceFile: fileBaseName,
290
+ })
291
+ }
292
+ }
293
+
294
+ // emit files
295
+ const modelNames = modelFilePaths.map((p) => baseName(p, '.ts'))
296
+ const out: Record<string, string> = {}
297
+
298
+ out['models.ts'] = generateModelsFile(modelNames, modelsDirName)
299
+
300
+ if (modelNamesWithSchema.length > 0) {
301
+ out['types.ts'] = generateTypesFile(modelNamesWithSchema)
302
+ out['tables.ts'] = generateTablesFile(modelNamesWithSchema, modelsDirName)
303
+ }
304
+
305
+ out['README.md'] = generateReadmeFile()
306
+
307
+ if (queryFilePaths.length > 0) {
308
+ out['groupedQueries.ts'] = generateGroupedQueriesFile(allQueries)
309
+ out['syncedQueries.ts'] = generateSyncedQueriesFile(allQueries)
310
+ }
311
+
312
+ if (allModelMutations.length > 0) {
313
+ out['syncedMutations.ts'] = generateSyncedMutationsFile(allModelMutations)
314
+ }
315
+
316
+ // count mutations the same way `generate()` does: 3 per crud model plus
317
+ // non-crud custom mutations (the crud-ops set is excluded when hasCRUD).
318
+ let mutationCount = 0
319
+ for (const m of allModelMutations) {
320
+ if (m.hasCRUD) mutationCount += 3
321
+ mutationCount += m.custom.filter(
322
+ (mut) => !m.hasCRUD || !['insert', 'update', 'delete', 'upsert'].includes(mut.name),
323
+ ).length
324
+ }
325
+
326
+ return {
327
+ files: out,
328
+ modelCount: modelNames.length,
329
+ queryCount: allQueries.length,
330
+ mutationCount,
331
+ schemaCount: modelNamesWithSchema.length,
332
+ }
333
+ }