on-zero 0.1.21 → 0.1.23
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/cli.cjs +17 -420
- package/dist/cjs/cli.js +7 -398
- package/dist/cjs/cli.js.map +2 -2
- package/dist/cjs/cli.native.js +15 -514
- package/dist/cjs/cli.native.js.map +1 -1
- package/dist/cjs/generate.cjs +370 -0
- package/dist/cjs/generate.js +339 -0
- package/dist/cjs/generate.js.map +6 -0
- package/dist/cjs/generate.native.js +464 -0
- package/dist/cjs/generate.native.js.map +1 -0
- package/dist/cjs/helpers/createMutators.cjs +4 -3
- package/dist/cjs/helpers/createMutators.js +12 -9
- package/dist/cjs/helpers/createMutators.js.map +1 -1
- package/dist/cjs/helpers/createMutators.native.js +25 -21
- package/dist/cjs/helpers/createMutators.native.js.map +1 -1
- package/dist/cjs/mutations.cjs +34 -4
- package/dist/cjs/mutations.js +29 -4
- package/dist/cjs/mutations.js.map +1 -1
- package/dist/cjs/mutations.native.js +36 -4
- package/dist/cjs/mutations.native.js.map +1 -1
- package/dist/cjs/vite-plugin.cjs +84 -0
- package/dist/cjs/vite-plugin.js +86 -0
- package/dist/cjs/vite-plugin.js.map +6 -0
- package/dist/cjs/vite-plugin.native.js +99 -0
- package/dist/cjs/vite-plugin.native.js.map +1 -0
- package/dist/esm/cli.js +8 -384
- package/dist/esm/cli.js.map +2 -2
- package/dist/esm/cli.mjs +17 -398
- package/dist/esm/cli.mjs.map +1 -1
- package/dist/esm/cli.native.js +15 -492
- package/dist/esm/cli.native.js.map +1 -1
- package/dist/esm/generate.js +317 -0
- package/dist/esm/generate.js.map +6 -0
- package/dist/esm/generate.mjs +335 -0
- package/dist/esm/generate.mjs.map +1 -0
- package/dist/esm/generate.native.js +426 -0
- package/dist/esm/generate.native.js.map +1 -0
- package/dist/esm/helpers/createMutators.js +12 -9
- package/dist/esm/helpers/createMutators.js.map +1 -1
- package/dist/esm/helpers/createMutators.mjs +4 -3
- package/dist/esm/helpers/createMutators.mjs.map +1 -1
- package/dist/esm/helpers/createMutators.native.js +25 -21
- package/dist/esm/helpers/createMutators.native.js.map +1 -1
- package/dist/esm/mutations.js +29 -4
- package/dist/esm/mutations.js.map +1 -1
- package/dist/esm/mutations.mjs +34 -4
- package/dist/esm/mutations.mjs.map +1 -1
- package/dist/esm/mutations.native.js +35 -3
- package/dist/esm/mutations.native.js.map +1 -1
- package/dist/esm/vite-plugin.js +71 -0
- package/dist/esm/vite-plugin.js.map +6 -0
- package/dist/esm/vite-plugin.mjs +59 -0
- package/dist/esm/vite-plugin.mjs.map +1 -0
- package/dist/esm/vite-plugin.native.js +71 -0
- package/dist/esm/vite-plugin.native.js.map +1 -0
- package/package.json +7 -2
- package/readme.md +42 -32
- package/src/cli.ts +9 -638
- package/src/generate.ts +490 -0
- package/src/helpers/createMutators.ts +14 -8
- package/src/mutations.ts +57 -4
- package/src/vite-plugin.ts +110 -0
- package/types/generate.d.ts +21 -0
- package/types/generate.d.ts.map +1 -0
- package/types/helpers/createMutators.d.ts.map +1 -1
- package/types/mutations.d.ts.map +1 -1
- package/types/vite-plugin.d.ts +16 -0
- package/types/vite-plugin.d.ts.map +1 -0
package/src/generate.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
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')
|
|
344
|
+
const { TypeScriptToModel } = await import('@sinclair/typebox-codegen/typescript')
|
|
345
|
+
|
|
346
|
+
const queryFiles = readdirSync(queriesDir).filter((f) => f.endsWith('.ts'))
|
|
347
|
+
|
|
348
|
+
const allQueries: Array<{
|
|
349
|
+
name: string
|
|
350
|
+
params: string
|
|
351
|
+
valibotCode: string
|
|
352
|
+
sourceFile: string
|
|
353
|
+
}> = []
|
|
354
|
+
|
|
355
|
+
for (const file of queryFiles) {
|
|
356
|
+
const filePath = resolve(queriesDir, file)
|
|
357
|
+
const fileBaseName = basename(file, '.ts')
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
361
|
+
const sourceFile = ts.createSourceFile(
|
|
362
|
+
filePath,
|
|
363
|
+
content,
|
|
364
|
+
ts.ScriptTarget.Latest,
|
|
365
|
+
true
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
369
|
+
if (ts.isVariableStatement(node)) {
|
|
370
|
+
const exportModifier = node.modifiers?.find(
|
|
371
|
+
(m) => m.kind === ts.SyntaxKind.ExportKeyword
|
|
372
|
+
)
|
|
373
|
+
if (!exportModifier) return
|
|
374
|
+
|
|
375
|
+
const declaration = node.declarationList.declarations[0]
|
|
376
|
+
if (!declaration || !ts.isVariableDeclaration(declaration)) return
|
|
377
|
+
|
|
378
|
+
const name = declaration.name.getText(sourceFile)
|
|
379
|
+
if (name === 'permission') return
|
|
380
|
+
|
|
381
|
+
if (declaration.initializer && ts.isArrowFunction(declaration.initializer)) {
|
|
382
|
+
const params = declaration.initializer.parameters
|
|
383
|
+
let paramType = 'void'
|
|
384
|
+
|
|
385
|
+
if (params.length > 0) {
|
|
386
|
+
const param = params[0]!
|
|
387
|
+
paramType = param.type?.getText(sourceFile) || 'unknown'
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const typeString = `type QueryParams = ${paramType}`
|
|
392
|
+
const model = TypeScriptToModel.Generate(typeString)
|
|
393
|
+
const valibotCode = ModelToValibot.Generate(model)
|
|
394
|
+
|
|
395
|
+
allQueries.push({
|
|
396
|
+
name,
|
|
397
|
+
params: paramType,
|
|
398
|
+
valibotCode,
|
|
399
|
+
sourceFile: fileBaseName,
|
|
400
|
+
})
|
|
401
|
+
} catch (err) {
|
|
402
|
+
if (!silent) console.error(`✗ ${name}: ${err}`)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
} catch (err) {
|
|
408
|
+
if (!silent) console.error(`Error processing ${file}:`, err)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
queryCount = allQueries.length
|
|
413
|
+
|
|
414
|
+
const groupedChanged = writeFileIfChanged(
|
|
415
|
+
resolve(generatedDir, 'groupedQueries.ts'),
|
|
416
|
+
generateGroupedQueriesFile(allQueries)
|
|
417
|
+
)
|
|
418
|
+
const syncedChanged = writeFileIfChanged(
|
|
419
|
+
resolve(generatedDir, 'syncedQueries.ts'),
|
|
420
|
+
generateSyncedQueriesFile(allQueries)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if (groupedChanged) filesChanged++
|
|
424
|
+
if (syncedChanged) filesChanged++
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (filesChanged > 0 && !silent) {
|
|
428
|
+
console.info(
|
|
429
|
+
`✓ ${allModelFiles.length} models (${filesWithSchema.length} schemas)${queryCount ? `, ${queryCount} queries` : ''}`
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// run after command
|
|
434
|
+
if (filesChanged > 0 && after) {
|
|
435
|
+
const { execSync } = await import('node:child_process')
|
|
436
|
+
try {
|
|
437
|
+
execSync(after, {
|
|
438
|
+
stdio: 'inherit',
|
|
439
|
+
env: { ...process.env, ON_ZERO_GENERATED_DIR: generatedDir },
|
|
440
|
+
})
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (!silent) console.error(`Error running after command: ${err}`)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
saveCache()
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
filesChanged,
|
|
450
|
+
modelCount: allModelFiles.length,
|
|
451
|
+
schemaCount: filesWithSchema.length,
|
|
452
|
+
queryCount,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function watch(options: WatchOptions) {
|
|
457
|
+
const { dir, debounce = 1000 } = options
|
|
458
|
+
const baseDir = resolve(dir)
|
|
459
|
+
const modelsDir = resolve(baseDir, 'models')
|
|
460
|
+
const queriesDir = resolve(baseDir, 'queries')
|
|
461
|
+
const generatedDir = resolve(baseDir, 'generated')
|
|
462
|
+
|
|
463
|
+
// initial run (silent)
|
|
464
|
+
await generate({ ...options, silent: true })
|
|
465
|
+
console.info('👀 watching...\n')
|
|
466
|
+
|
|
467
|
+
const chokidar = await import('chokidar')
|
|
468
|
+
|
|
469
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
470
|
+
|
|
471
|
+
const debouncedRegenerate = (path: string, event: string) => {
|
|
472
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
473
|
+
console.info(`\n${event} ${path}`)
|
|
474
|
+
debounceTimer = setTimeout(() => {
|
|
475
|
+
generate({ ...options, silent: false })
|
|
476
|
+
}, debounce)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const watcher = chokidar.watch([modelsDir, queriesDir], {
|
|
480
|
+
persistent: true,
|
|
481
|
+
ignoreInitial: true,
|
|
482
|
+
ignored: [generatedDir],
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
watcher.on('change', (path) => debouncedRegenerate(path, '📝'))
|
|
486
|
+
watcher.on('add', (path) => debouncedRegenerate(path, '➕'))
|
|
487
|
+
watcher.on('unlink', (path) => debouncedRegenerate(path, '🗑️ '))
|
|
488
|
+
|
|
489
|
+
return watcher
|
|
490
|
+
}
|
|
@@ -159,17 +159,23 @@ export function createMutators<Models extends GenericModels>({
|
|
|
159
159
|
|
|
160
160
|
for (const [moduleName, moduleExports] of Object.entries(modules)) {
|
|
161
161
|
result[moduleName] = {}
|
|
162
|
-
for (const [name
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
162
|
+
for (const [name] of Object.entries(moduleExports)) {
|
|
163
|
+
const fullName = `${moduleName}.${name}`
|
|
164
|
+
// look up function dynamically to support HMR
|
|
165
|
+
// modules[moduleName] is a proxy that returns updated implementations
|
|
166
|
+
const getDynamicFn = () => modules[moduleName][name]
|
|
167
|
+
|
|
168
|
+
result[moduleName][name] = withDevelopmentLogging(
|
|
169
|
+
fullName,
|
|
170
|
+
withTimeoutGuard(
|
|
166
171
|
fullName,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
172
|
+
withValidation(
|
|
173
|
+
moduleName,
|
|
174
|
+
name,
|
|
175
|
+
withContext((...args: any[]) => getDynamicFn()(...args))
|
|
170
176
|
)
|
|
171
177
|
)
|
|
172
|
-
|
|
178
|
+
)
|
|
173
179
|
}
|
|
174
180
|
}
|
|
175
181
|
|
package/src/mutations.ts
CHANGED
|
@@ -10,6 +10,59 @@ import type {
|
|
|
10
10
|
} from './types'
|
|
11
11
|
import type { TableBuilderWithColumns } from '@rocicorp/zero'
|
|
12
12
|
|
|
13
|
+
// HMR registry - stores mutation implementations and proxies by table name
|
|
14
|
+
// allows hot-swapping implementations without changing object references
|
|
15
|
+
// stored on globalThis to persist across HMR module reloads
|
|
16
|
+
const registryKey = '__onZeroMutationRegistry__'
|
|
17
|
+
const proxyKey = '__onZeroProxyRegistry__'
|
|
18
|
+
|
|
19
|
+
// @ts-ignore
|
|
20
|
+
const mutationRegistry: Map<string, Record<string, Function>> = globalThis[registryKey] ||
|
|
21
|
+
(globalThis[registryKey] = new Map())
|
|
22
|
+
// @ts-ignore
|
|
23
|
+
const proxyRegistry: Map<string, any> =
|
|
24
|
+
globalThis[proxyKey] || (globalThis[proxyKey] = new Map())
|
|
25
|
+
|
|
26
|
+
// get or create a proxy that delegates to the registry
|
|
27
|
+
// returns the SAME proxy object on subsequent calls so HMR works
|
|
28
|
+
function getOrCreateMutationProxy<T extends Record<string, Function>>(
|
|
29
|
+
tableName: string,
|
|
30
|
+
implementations: T
|
|
31
|
+
): T {
|
|
32
|
+
// always update implementations (supports HMR)
|
|
33
|
+
mutationRegistry.set(tableName, implementations)
|
|
34
|
+
|
|
35
|
+
// return existing proxy if we have one (HMR case)
|
|
36
|
+
const existing = proxyRegistry.get(tableName)
|
|
37
|
+
if (existing) {
|
|
38
|
+
return existing as T
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// first time - create the proxy
|
|
42
|
+
const proxy = new Proxy({} as T, {
|
|
43
|
+
get(_, key: string) {
|
|
44
|
+
return mutationRegistry.get(tableName)?.[key]
|
|
45
|
+
},
|
|
46
|
+
ownKeys() {
|
|
47
|
+
const current = mutationRegistry.get(tableName)
|
|
48
|
+
return current ? Object.keys(current) : []
|
|
49
|
+
},
|
|
50
|
+
getOwnPropertyDescriptor(_, key: string) {
|
|
51
|
+
const current = mutationRegistry.get(tableName)
|
|
52
|
+
if (current && key in current) {
|
|
53
|
+
return { enumerable: true, configurable: true, value: current[key] }
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
has(_, key: string) {
|
|
57
|
+
const current = mutationRegistry.get(tableName)
|
|
58
|
+
return current ? key in current : false
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
proxyRegistry.set(tableName, proxy)
|
|
63
|
+
return proxy
|
|
64
|
+
}
|
|
65
|
+
|
|
13
66
|
// two ways to use it:
|
|
14
67
|
// - mutations({}) which doesn't add the "allowed" helper or add CRUD
|
|
15
68
|
// - mutation('tableName', permissions) adds CRUD with permissions, adds allowed
|
|
@@ -122,16 +175,16 @@ export function mutations<
|
|
|
122
175
|
upsert: createCRUDMutation('upsert'),
|
|
123
176
|
}
|
|
124
177
|
|
|
125
|
-
const finalMutations =
|
|
178
|
+
const finalMutations = {
|
|
126
179
|
...mutations,
|
|
127
180
|
// overwrite regular mutations but call them if they are defined by user
|
|
128
181
|
...crudMutations,
|
|
129
|
-
|
|
130
|
-
}) as any as Mutations
|
|
182
|
+
} as any as Mutations
|
|
131
183
|
|
|
132
184
|
setMutationsPermissions(tableName, permissions)
|
|
133
185
|
|
|
134
|
-
return
|
|
186
|
+
// return proxy for HMR support - allows swapping implementations at runtime
|
|
187
|
+
return getOrCreateMutationProxy(tableName, finalMutations)
|
|
135
188
|
}
|
|
136
189
|
|
|
137
190
|
// no schema/permissions don't add CRUD
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { generate, type GenerateOptions } from './generate'
|
|
4
|
+
|
|
5
|
+
import type { Plugin } from 'vite'
|
|
6
|
+
|
|
7
|
+
export interface OnZeroPluginOptions extends Omit<GenerateOptions, 'dir' | 'silent'> {
|
|
8
|
+
/** base data directory. defaults to src/data */
|
|
9
|
+
dir?: string
|
|
10
|
+
/** additional paths to apply HMR fix to */
|
|
11
|
+
hmrInclude?: string[]
|
|
12
|
+
/** disable code generation (HMR only) */
|
|
13
|
+
disableGenerate?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createOnZeroHmrPlugin(hmrInclude: string[] = []): Plugin {
|
|
17
|
+
const hmrPaths = ['/models/', '/generated/', '/queries/', ...hmrInclude]
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
name: 'on-zero:hmr',
|
|
21
|
+
apply: 'serve',
|
|
22
|
+
enforce: 'post',
|
|
23
|
+
|
|
24
|
+
transform(code, id) {
|
|
25
|
+
if (!hmrPaths.some((p) => id.includes(p)) || !/\.tsx?$/.test(id)) return
|
|
26
|
+
if (!code.includes('import.meta.hot.invalidate')) return
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
code: code.replace(
|
|
30
|
+
/if\s*\(invalidateMessage\)\s*import\.meta\.hot\.invalidate\(invalidateMessage\);?/g,
|
|
31
|
+
'/* on-zero: HMR invalidate disabled */'
|
|
32
|
+
),
|
|
33
|
+
map: null,
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isWithinDirectory(file: string, dir: string): boolean {
|
|
40
|
+
const rel = relative(dir, file)
|
|
41
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function onZeroPlugin(options: OnZeroPluginOptions = {}): Plugin[] {
|
|
45
|
+
const dir = options.dir ?? 'src/data'
|
|
46
|
+
|
|
47
|
+
let dataDir: string
|
|
48
|
+
let modelsDir: string
|
|
49
|
+
let queriesDir: string
|
|
50
|
+
|
|
51
|
+
const runGenerate = (silent: boolean) =>
|
|
52
|
+
generate({
|
|
53
|
+
dir: dataDir,
|
|
54
|
+
after: options.after,
|
|
55
|
+
silent,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
name: 'on-zero:serve',
|
|
61
|
+
apply: 'serve',
|
|
62
|
+
|
|
63
|
+
configResolved(config) {
|
|
64
|
+
dataDir = resolve(config.root, dir)
|
|
65
|
+
modelsDir = resolve(dataDir, 'models')
|
|
66
|
+
queriesDir = resolve(dataDir, 'queries')
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async buildStart() {
|
|
70
|
+
if (!options.disableGenerate) await runGenerate(false)
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
configureServer(server) {
|
|
74
|
+
if (options.disableGenerate) return
|
|
75
|
+
|
|
76
|
+
const handler = async (file: string) => {
|
|
77
|
+
if (!/\.tsx?$/.test(file)) return
|
|
78
|
+
if (isWithinDirectory(file, modelsDir) || isWithinDirectory(file, queriesDir)) {
|
|
79
|
+
await runGenerate(false)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
server.watcher.on('change', handler)
|
|
84
|
+
server.watcher.on('add', handler)
|
|
85
|
+
server.watcher.on('unlink', handler)
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
name: 'on-zero:build',
|
|
91
|
+
apply: 'build',
|
|
92
|
+
|
|
93
|
+
configResolved(config) {
|
|
94
|
+
dataDir = resolve(config.root, dir)
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async buildStart() {
|
|
98
|
+
if (!options.disableGenerate) await runGenerate(true)
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
createOnZeroHmrPlugin(options.hmrInclude),
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const onZeroHmrPlugin = (options?: { include?: string[] }): Plugin => {
|
|
107
|
+
return createOnZeroHmrPlugin(options?.include)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default onZeroPlugin
|