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.
- package/dist/cjs/generate-helpers.cjs +309 -0
- package/dist/cjs/generate-helpers.native.js +451 -0
- package/dist/cjs/generate-helpers.native.js.map +1 -0
- package/dist/cjs/generate-lite.cjs +150 -0
- package/dist/cjs/generate-lite.native.js +269 -0
- package/dist/cjs/generate-lite.native.js.map +1 -0
- package/dist/cjs/generate-lite.test.cjs +229 -0
- package/dist/cjs/generate-lite.test.native.js +234 -0
- package/dist/cjs/generate-lite.test.native.js.map +1 -0
- package/dist/cjs/generate.cjs +16 -285
- package/dist/cjs/generate.native.js +18 -432
- package/dist/cjs/generate.native.js.map +1 -1
- package/dist/esm/generate-helpers.mjs +272 -0
- package/dist/esm/generate-helpers.mjs.map +1 -0
- package/dist/esm/generate-helpers.native.js +411 -0
- package/dist/esm/generate-helpers.native.js.map +1 -0
- package/dist/esm/generate-lite.mjs +127 -0
- package/dist/esm/generate-lite.mjs.map +1 -0
- package/dist/esm/generate-lite.native.js +243 -0
- package/dist/esm/generate-lite.native.js.map +1 -0
- package/dist/esm/generate-lite.test.mjs +230 -0
- package/dist/esm/generate-lite.test.mjs.map +1 -0
- package/dist/esm/generate-lite.test.native.js +232 -0
- package/dist/esm/generate-lite.test.native.js.map +1 -0
- package/dist/esm/generate.mjs +6 -275
- package/dist/esm/generate.mjs.map +1 -1
- package/dist/esm/generate.native.js +9 -423
- package/dist/esm/generate.native.js.map +1 -1
- package/package.json +7 -2
- package/src/generate-helpers.ts +440 -0
- package/src/generate-lite.test.ts +310 -0
- package/src/generate-lite.ts +333 -0
- package/src/generate.ts +23 -415
- package/types/generate-helpers.d.ts +42 -0
- package/types/generate-helpers.d.ts.map +1 -0
- package/types/generate-lite.d.ts +40 -0
- package/types/generate-lite.d.ts.map +1 -0
- package/types/generate-lite.test.d.ts +2 -0
- package/types/generate-lite.test.d.ts.map +1 -0
- package/types/generate.d.ts +1 -6
- 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
|
+
}
|