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.
Files changed (68) hide show
  1. package/dist/cjs/cli.cjs +17 -420
  2. package/dist/cjs/cli.js +7 -398
  3. package/dist/cjs/cli.js.map +2 -2
  4. package/dist/cjs/cli.native.js +15 -514
  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/helpers/createMutators.cjs +4 -3
  12. package/dist/cjs/helpers/createMutators.js +12 -9
  13. package/dist/cjs/helpers/createMutators.js.map +1 -1
  14. package/dist/cjs/helpers/createMutators.native.js +25 -21
  15. package/dist/cjs/helpers/createMutators.native.js.map +1 -1
  16. package/dist/cjs/mutations.cjs +34 -4
  17. package/dist/cjs/mutations.js +29 -4
  18. package/dist/cjs/mutations.js.map +1 -1
  19. package/dist/cjs/mutations.native.js +36 -4
  20. package/dist/cjs/mutations.native.js.map +1 -1
  21. package/dist/cjs/vite-plugin.cjs +84 -0
  22. package/dist/cjs/vite-plugin.js +86 -0
  23. package/dist/cjs/vite-plugin.js.map +6 -0
  24. package/dist/cjs/vite-plugin.native.js +99 -0
  25. package/dist/cjs/vite-plugin.native.js.map +1 -0
  26. package/dist/esm/cli.js +8 -384
  27. package/dist/esm/cli.js.map +2 -2
  28. package/dist/esm/cli.mjs +17 -398
  29. package/dist/esm/cli.mjs.map +1 -1
  30. package/dist/esm/cli.native.js +15 -492
  31. package/dist/esm/cli.native.js.map +1 -1
  32. package/dist/esm/generate.js +317 -0
  33. package/dist/esm/generate.js.map +6 -0
  34. package/dist/esm/generate.mjs +335 -0
  35. package/dist/esm/generate.mjs.map +1 -0
  36. package/dist/esm/generate.native.js +426 -0
  37. package/dist/esm/generate.native.js.map +1 -0
  38. package/dist/esm/helpers/createMutators.js +12 -9
  39. package/dist/esm/helpers/createMutators.js.map +1 -1
  40. package/dist/esm/helpers/createMutators.mjs +4 -3
  41. package/dist/esm/helpers/createMutators.mjs.map +1 -1
  42. package/dist/esm/helpers/createMutators.native.js +25 -21
  43. package/dist/esm/helpers/createMutators.native.js.map +1 -1
  44. package/dist/esm/mutations.js +29 -4
  45. package/dist/esm/mutations.js.map +1 -1
  46. package/dist/esm/mutations.mjs +34 -4
  47. package/dist/esm/mutations.mjs.map +1 -1
  48. package/dist/esm/mutations.native.js +35 -3
  49. package/dist/esm/mutations.native.js.map +1 -1
  50. package/dist/esm/vite-plugin.js +71 -0
  51. package/dist/esm/vite-plugin.js.map +6 -0
  52. package/dist/esm/vite-plugin.mjs +59 -0
  53. package/dist/esm/vite-plugin.mjs.map +1 -0
  54. package/dist/esm/vite-plugin.native.js +71 -0
  55. package/dist/esm/vite-plugin.native.js.map +1 -0
  56. package/package.json +7 -2
  57. package/readme.md +42 -32
  58. package/src/cli.ts +9 -638
  59. package/src/generate.ts +490 -0
  60. package/src/helpers/createMutators.ts +14 -8
  61. package/src/mutations.ts +57 -4
  62. package/src/vite-plugin.ts +110 -0
  63. package/types/generate.d.ts +21 -0
  64. package/types/generate.d.ts.map +1 -0
  65. package/types/helpers/createMutators.d.ts.map +1 -1
  66. package/types/mutations.d.ts.map +1 -1
  67. package/types/vite-plugin.d.ts +16 -0
  68. package/types/vite-plugin.d.ts.map +1 -0
@@ -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, exportValue] of Object.entries(moduleExports)) {
163
- if (typeof exportValue === 'function') {
164
- const fullName = `${moduleName}.${name}`
165
- result[moduleName][name] = withDevelopmentLogging(
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
- withTimeoutGuard(
168
- fullName,
169
- withValidation(moduleName, name, withContext(exportValue))
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 = Object.freeze({
178
+ const finalMutations = {
126
179
  ...mutations,
127
180
  // overwrite regular mutations but call them if they are defined by user
128
181
  ...crudMutations,
129
- // expose permissions for usePermission hook
130
- }) as any as Mutations
182
+ } as any as Mutations
131
183
 
132
184
  setMutationsPermissions(tableName, permissions)
133
185
 
134
- return finalMutations
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