on-zero 0.1.39 → 0.1.41

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 (84) hide show
  1. package/dist/cjs/createUseQuery.cjs +3 -2
  2. package/dist/cjs/createUseQuery.js +2 -2
  3. package/dist/cjs/createUseQuery.js.map +1 -1
  4. package/dist/cjs/createUseQuery.native.js +3 -2
  5. package/dist/cjs/createUseQuery.native.js.map +1 -1
  6. package/dist/cjs/createZeroClient.cjs +28 -5
  7. package/dist/cjs/createZeroClient.js +19 -4
  8. package/dist/cjs/createZeroClient.js.map +1 -1
  9. package/dist/cjs/createZeroClient.native.js +29 -5
  10. package/dist/cjs/createZeroClient.native.js.map +1 -1
  11. package/dist/cjs/createZeroServer.cjs +5 -2
  12. package/dist/cjs/createZeroServer.js +5 -2
  13. package/dist/cjs/createZeroServer.js.map +1 -1
  14. package/dist/cjs/createZeroServer.native.js +5 -2
  15. package/dist/cjs/createZeroServer.native.js.map +1 -1
  16. package/dist/cjs/generate.cjs +458 -39
  17. package/dist/cjs/generate.js +485 -31
  18. package/dist/cjs/generate.js.map +2 -2
  19. package/dist/cjs/generate.native.js +812 -51
  20. package/dist/cjs/generate.native.js.map +1 -1
  21. package/dist/cjs/generate.test.cjs +251 -0
  22. package/dist/cjs/generate.test.js +252 -0
  23. package/dist/cjs/generate.test.js.map +1 -1
  24. package/dist/cjs/generate.test.native.js +251 -0
  25. package/dist/cjs/generate.test.native.js.map +1 -1
  26. package/dist/cjs/helpers/createMutators.cjs +21 -8
  27. package/dist/cjs/helpers/createMutators.js +16 -6
  28. package/dist/cjs/helpers/createMutators.js.map +1 -1
  29. package/dist/cjs/helpers/createMutators.native.js +28 -10
  30. package/dist/cjs/helpers/createMutators.native.js.map +1 -1
  31. package/dist/esm/createUseQuery.js +3 -3
  32. package/dist/esm/createUseQuery.js.map +1 -1
  33. package/dist/esm/createUseQuery.mjs +4 -3
  34. package/dist/esm/createUseQuery.mjs.map +1 -1
  35. package/dist/esm/createUseQuery.native.js +4 -3
  36. package/dist/esm/createUseQuery.native.js.map +1 -1
  37. package/dist/esm/createZeroClient.js +19 -4
  38. package/dist/esm/createZeroClient.js.map +1 -1
  39. package/dist/esm/createZeroClient.mjs +28 -5
  40. package/dist/esm/createZeroClient.mjs.map +1 -1
  41. package/dist/esm/createZeroClient.native.js +29 -5
  42. package/dist/esm/createZeroClient.native.js.map +1 -1
  43. package/dist/esm/createZeroServer.js +5 -2
  44. package/dist/esm/createZeroServer.js.map +1 -1
  45. package/dist/esm/createZeroServer.mjs +5 -2
  46. package/dist/esm/createZeroServer.mjs.map +1 -1
  47. package/dist/esm/createZeroServer.native.js +5 -2
  48. package/dist/esm/createZeroServer.native.js.map +1 -1
  49. package/dist/esm/generate.js +486 -32
  50. package/dist/esm/generate.js.map +2 -2
  51. package/dist/esm/generate.mjs +459 -40
  52. package/dist/esm/generate.mjs.map +1 -1
  53. package/dist/esm/generate.native.js +813 -52
  54. package/dist/esm/generate.native.js.map +1 -1
  55. package/dist/esm/generate.test.js +252 -0
  56. package/dist/esm/generate.test.js.map +1 -1
  57. package/dist/esm/generate.test.mjs +251 -0
  58. package/dist/esm/generate.test.mjs.map +1 -1
  59. package/dist/esm/generate.test.native.js +251 -0
  60. package/dist/esm/generate.test.native.js.map +1 -1
  61. package/dist/esm/helpers/createMutators.js +6 -4
  62. package/dist/esm/helpers/createMutators.js.map +1 -1
  63. package/dist/esm/helpers/createMutators.mjs +6 -4
  64. package/dist/esm/helpers/createMutators.mjs.map +1 -1
  65. package/dist/esm/helpers/createMutators.native.js +13 -6
  66. package/dist/esm/helpers/createMutators.native.js.map +1 -1
  67. package/package.json +2 -2
  68. package/readme.md +110 -2
  69. package/src/createUseQuery.tsx +15 -6
  70. package/src/createZeroClient.tsx +42 -6
  71. package/src/createZeroServer.ts +9 -0
  72. package/src/generate.test.ts +340 -0
  73. package/src/generate.ts +863 -43
  74. package/src/helpers/createMutators.ts +22 -8
  75. package/types/createUseQuery.d.ts +2 -1
  76. package/types/createUseQuery.d.ts.map +1 -1
  77. package/types/createZeroClient.d.ts +10 -1
  78. package/types/createZeroClient.d.ts.map +1 -1
  79. package/types/createZeroServer.d.ts +7 -1
  80. package/types/createZeroServer.d.ts.map +1 -1
  81. package/types/generate.d.ts +1 -0
  82. package/types/generate.d.ts.map +1 -1
  83. package/types/helpers/createMutators.d.ts +3 -1
  84. package/types/helpers/createMutators.d.ts.map +1 -1
package/readme.md CHANGED
@@ -338,6 +338,8 @@ generates all files needed to connect your models and queries:
338
338
  - `tables.ts` - exports table schemas (separate to avoid circular types)
339
339
  - `syncedQueries.ts` - generates synced query definitions with valibot
340
340
  validators
341
+ - `syncedMutations.ts` - generates valibot validators for mutation args
342
+ (auto-validation on server)
341
343
 
342
344
  **options:**
343
345
 
@@ -411,8 +413,12 @@ the generator:
411
413
  3. parses TypeScript AST to extract parameter types
412
414
  4. converts types to valibot schemas using typebox-codegen
413
415
  5. wraps query functions in `syncedQuery()` with validators
414
- 6. handles special cases (void params, user userPublic mapping)
415
- 7. groups query imports by source file
416
+ 6. extracts mutation handler param types using the TS type checker (resolves
417
+ imports, aliases, and cross-file references)
418
+ 7. generates `syncedMutations.ts` with valibot validators for CRUD and custom
419
+ mutation args
420
+ 8. handles special cases (void params, user → userPublic mapping)
421
+ 9. groups query imports by source file
416
422
 
417
423
  queries with no parameters get wrapped in `v.parser(v.tuple([]))` while queries
418
424
  with params get validators like `v.parser(v.tuple([v.object({ ... })]))`.
@@ -451,12 +457,14 @@ server:
451
457
  ```ts
452
458
  import { createZeroServer } from 'on-zero/server'
453
459
  import { syncedQueries } from '~/data/generated/syncedQueries'
460
+ import { mutationValidators } from '~/data/generated/syncedMutations'
454
461
 
455
462
  export const zeroServer = createZeroServer({
456
463
  schema,
457
464
  models,
458
465
  database: process.env.DATABASE_URL,
459
466
  queries: syncedQueries, // required for synced queries / pull endpoint
467
+ mutations: mutationValidators, // auto-validates mutation args with valibot
460
468
  createServerActions: () => ({
461
469
  sendEmail: async (to, subject, body) => { ... }
462
470
  })
@@ -522,6 +530,56 @@ export const zeroServer = createZeroServer({
522
530
  })
523
531
  ```
524
532
 
533
+ ### mutation arg validation
534
+
535
+ on-zero can auto-generate valibot validators for all mutation arguments. the
536
+ generator uses the TypeScript type checker to deeply resolve param types -
537
+ including imported types, aliases, and cross-file references - then converts them
538
+ to valibot schemas.
539
+
540
+ pass the generated `mutationValidators` to `createZeroServer`:
541
+
542
+ ```ts
543
+ import { mutationValidators } from '~/data/generated/syncedMutations'
544
+
545
+ export const zeroServer = createZeroServer({
546
+ // ...
547
+ mutations: mutationValidators,
548
+ })
549
+ ```
550
+
551
+ this auto-validates args before every mutation runs. for a model like:
552
+
553
+ ```ts
554
+ export const mutate = mutations(schema, permissions, {
555
+ async send(ctx, props: { content: string; channelId: string }) {
556
+ // ...
557
+ },
558
+ })
559
+ ```
560
+
561
+ the generator produces validators for both the CRUD operations (derived from the
562
+ schema columns) and custom mutations (derived from handler param types). if
563
+ validation fails, the mutation throws before executing.
564
+
565
+ the generated `syncedMutations.ts` looks like:
566
+
567
+ ```ts
568
+ import * as v from 'valibot'
569
+
570
+ export const mutationValidators = {
571
+ message: {
572
+ insert: v.object({ id: v.string(), content: v.string(), ... }),
573
+ update: v.object({ id: v.string(), content: v.optional(v.string()), ... }),
574
+ delete: v.object({ id: v.string() }),
575
+ send: v.object({ content: v.string(), channelId: v.string() }),
576
+ },
577
+ }
578
+ ```
579
+
580
+ validation runs before the `validateMutation` hook, so both layers stack:
581
+ valibot validates shape/types, then your custom hook can add business logic.
582
+
525
583
  type augmentation:
526
584
 
527
585
  ```ts
@@ -648,6 +706,23 @@ on-zero run is smart:
648
706
  - on server, uses server `zero.run()`
649
707
  - in a mutation, uses `tx.run()`
650
708
 
709
+ **getQuery — resolve a query object directly:**
710
+
711
+ use `getQuery` when you need the raw zero query object rather than subscribing via `useQuery`. useful for passing to third-party hooks that accept zero query objects directly (e.g. virtualized list hooks):
712
+
713
+ ```ts
714
+ import { getQuery } from '~/zero/client'
715
+ import { postById } from '~/data/queries/post'
716
+
717
+ // returns the zero query object — same as what useQuery resolves internally
718
+ const query = getQuery(postById, { postId: '123' })
719
+
720
+ // pass to any hook that accepts a zero query directly
721
+ const [rows] = useRows(getQuery(feedPosts, { limit: 50 }))
722
+ ```
723
+
724
+ same signature as `useQuery` — `getQuery(fn, params?)`.
725
+
651
726
  **preloading data (client only):**
652
727
 
653
728
  preload query results into cache without subscribing:
@@ -674,6 +749,39 @@ for ad-hoc queries that don't use query functions:
674
749
  const user = await zeroServer.query((q) => q.user.where('id', userId).one())
675
750
  ```
676
751
 
752
+ **controlling queries with `ControlQueries`:**
753
+
754
+ disable all `useQuery` and `usePermission` calls within a subtree. useful for
755
+ hiding screens, background tabs, or any UI where you want to pause syncing:
756
+
757
+ ```tsx
758
+ import { ControlQueries } from '~/zero/client'
759
+
760
+ // disable queries, returns null for all useQuery/usePermission calls
761
+ <ControlQueries action="disable">
762
+ <ExpensiveScreen />
763
+ </ControlQueries>
764
+
765
+ // disable but keep returning the last value (no flash to empty)
766
+ <ControlQueries action="disable" whenDisabled="last-value">
767
+ <ExpensiveScreen />
768
+ </ControlQueries>
769
+
770
+ // re-enable inside a disabled subtree
771
+ <ControlQueries action="disable" whenDisabled="last-value">
772
+ <ControlQueries action="enable">
773
+ <AlwaysLiveWidget />
774
+ </ControlQueries>
775
+ </ControlQueries>
776
+ ```
777
+
778
+ props:
779
+
780
+ - `action` — `'enable' | 'disable'` (default `'disable'`)
781
+ - `whenDisabled` — `'empty' | 'last-value'` (default `'empty'`)
782
+ - `'empty'` — queries return `[null, { type: 'unknown' }]`
783
+ - `'last-value'` — queries return their most recent result
784
+
677
785
  **batch processing:**
678
786
 
679
787
  ```ts
@@ -1,5 +1,5 @@
1
1
  import { useQuery as zeroUseQuery } from '@rocicorp/zero/react'
2
- import { use, useMemo, type Context } from 'react'
2
+ import { use, useMemo, useRef, type Context } from 'react'
3
3
 
4
4
  import { useZeroDebug } from './helpers/useZeroDebug'
5
5
  import { resolveQuery, type PlainQueryFn } from './resolveQuery'
@@ -11,6 +11,9 @@ import type {
11
11
  Schema as ZeroSchema,
12
12
  } from '@rocicorp/zero'
13
13
 
14
+ // false = enabled, 'empty' = disabled (return null), 'last-value' = disabled (return cached)
15
+ export type QueryControlMode = false | 'empty' | 'last-value'
16
+
14
17
  export type UseQueryOptions = {
15
18
  enabled?: boolean | undefined
16
19
  ttl?: 'always' | 'never' | number | undefined
@@ -42,11 +45,12 @@ export function createUseQuery<Schema extends ZeroSchema>({
42
45
  DisabledContext,
43
46
  customQueries,
44
47
  }: {
45
- DisabledContext: Context<boolean>
48
+ DisabledContext: Context<QueryControlMode>
46
49
  customQueries: AnyQueryRegistry
47
50
  }): UseQueryHook<Schema> {
48
51
  function useQuery(...args: any[]): any {
49
- const disabled = use(DisabledContext)
52
+ const disableMode = use(DisabledContext)
53
+ const lastRef = useRef<any>(EMPTY_RESPONSE)
50
54
  const [fn, paramsOrOptions, optionsArg] = args
51
55
 
52
56
  const { queryRequest, options } = useMemo(() => {
@@ -74,11 +78,16 @@ export function createUseQuery<Schema extends ZeroSchema>({
74
78
  useZeroDebug(queryRequest, options, out)
75
79
  }
76
80
 
77
- if (disabled) {
78
- return EMPTY_RESPONSE
81
+ if (!disableMode) {
82
+ lastRef.current = out
83
+ return out
84
+ }
85
+
86
+ if (disableMode === 'last-value') {
87
+ return lastRef.current
79
88
  }
80
89
 
81
- return out
90
+ return EMPTY_RESPONSE
82
91
  }
83
92
 
84
93
  return useQuery as UseQueryHook<Schema>
@@ -12,7 +12,7 @@ import {
12
12
  } from 'react'
13
13
 
14
14
  import { createPermissions } from './createPermissions'
15
- import { createUseQuery } from './createUseQuery'
15
+ import { createUseQuery, type QueryControlMode } from './createUseQuery'
16
16
  import { createMutators } from './helpers/createMutators'
17
17
  import { getAuth } from './helpers/getAuth'
18
18
  import { getAllMutationsPermissions, getMutationsPermissions } from './modelRegistry'
@@ -156,7 +156,7 @@ export function createZeroClient<
156
156
  // register for global run() helper
157
157
  setCustomQueries(customQueries)
158
158
 
159
- const DisabledContext = createContext(false)
159
+ const DisabledContext = createContext<QueryControlMode>(false)
160
160
 
161
161
  let latestZeroInstance: ZeroInstance | null = null
162
162
 
@@ -190,14 +190,15 @@ export function createZeroClient<
190
190
  enabled = typeof objOrId !== 'undefined',
191
191
  debug = false
192
192
  ): boolean | null {
193
- const disabled = use(DisabledContext)
193
+ const disableMode = use(DisabledContext)
194
+ const lastRef = useRef<boolean | null>(null)
194
195
  const tableStr = table as string
195
196
  const checkFn = permissionCheckFns[tableStr]
196
197
 
197
198
  const [data, status] = useQuery(
198
199
  checkFn as any,
199
200
  { objOrId: objOrId as any },
200
- { enabled: Boolean(!disabled && enabled && objOrId && checkFn) }
201
+ { enabled: Boolean(!disableMode && enabled && objOrId && checkFn) }
201
202
  )
202
203
 
203
204
  if (debug) {
@@ -207,9 +208,18 @@ export function createZeroClient<
207
208
  if (!objOrId) return false
208
209
 
209
210
  // null while loading, then server's authoritative answer
210
- if (status.type === 'unknown') return null
211
+ const result = status.type === 'unknown' ? null : Boolean(data)
211
212
 
212
- return Boolean(data)
213
+ if (!disableMode) {
214
+ lastRef.current = result
215
+ return result
216
+ }
217
+
218
+ if (disableMode === 'last-value') {
219
+ return lastRef.current
220
+ }
221
+
222
+ return null
213
223
  }
214
224
 
215
225
  const ProvideZero = ({
@@ -334,12 +344,38 @@ export function createZeroClient<
334
344
  return zero.preload(queryRequest as any, options)
335
345
  }
336
346
 
347
+ function getQuery<TArg, TTable extends keyof Schema['tables'] & string, TReturn>(
348
+ fn: PlainQueryFn<TArg, Query<TTable, Schema, TReturn>>,
349
+ params: TArg
350
+ ): ReturnType<typeof resolveQuery<Schema>>
351
+ function getQuery<TTable extends keyof Schema['tables'] & string, TReturn>(
352
+ fn: PlainQueryFn<void, Query<TTable, Schema, TReturn>>
353
+ ): ReturnType<typeof resolveQuery<Schema>>
354
+ function getQuery(fn: any, params?: any) {
355
+ return resolveQuery({ customQueries, fn, params })
356
+ }
357
+
358
+ function ControlQueries({
359
+ children,
360
+ action = 'disable',
361
+ whenDisabled = 'empty',
362
+ }: {
363
+ children: ReactNode
364
+ action?: 'enable' | 'disable'
365
+ whenDisabled?: 'empty' | 'last-value'
366
+ }) {
367
+ const mode: QueryControlMode = action === 'enable' ? false : whenDisabled
368
+ return <DisabledContext.Provider value={mode}>{children}</DisabledContext.Provider>
369
+ }
370
+
337
371
  return {
338
372
  zeroEvents,
339
373
  ProvideZero,
374
+ ControlQueries,
340
375
  useQuery,
341
376
  usePermission,
342
377
  zero,
343
378
  preload,
379
+ getQuery,
344
380
  }
345
381
  }
@@ -75,6 +75,7 @@ export function createZeroServer<
75
75
  schema,
76
76
  models,
77
77
  queries,
78
+ mutations: mutationValidators,
78
79
  validateQuery,
79
80
  validateMutation,
80
81
  defaultAllowAdminRole = 'all',
@@ -88,6 +89,12 @@ export function createZeroServer<
88
89
  models: Models
89
90
  createServerActions: () => ServerActions
90
91
  queries?: AnyQueryRegistry
92
+ /**
93
+ * Generated valibot validators for mutation args, keyed by model.mutationName.
94
+ * Pass the `mutationValidators` export from generated syncedMutations.ts.
95
+ * Args are auto-validated before running the mutation.
96
+ */
97
+ mutations?: Record<string, Record<string, any>>
91
98
  /**
92
99
  * Hook to validate queries before execution. Throw to reject.
93
100
  * Must be synchronous.
@@ -163,6 +170,7 @@ export function createZeroServer<
163
170
  models,
164
171
  authData,
165
172
  validateMutation,
173
+ mutationValidators,
166
174
  })
167
175
 
168
176
  // @ts-expect-error type is ok but config in monorepo
@@ -274,6 +282,7 @@ export function createZeroServer<
274
282
  createServerActions,
275
283
  can: permissions.can,
276
284
  validateMutation,
285
+ mutationValidators,
277
286
  })
278
287
 
279
288
  const modelMutators = mutators[modelName as keyof typeof mutators] as Record<
@@ -199,3 +199,343 @@ export const allPosts = () => zero.query.post
199
199
  expect(second.filesChanged).toBe(0)
200
200
  })
201
201
  })
202
+
203
+ describe('mutations', () => {
204
+ test('generates validators for inline mutation param types', async () => {
205
+ writeFileSync(
206
+ join(testDir, 'models/post.ts'),
207
+ `
208
+ import { table, string } from 'on-zero'
209
+ import { mutations, serverWhere } from 'on-zero'
210
+
211
+ export const schema = table('post').columns({
212
+ id: string(),
213
+ title: string(),
214
+ }).primaryKey('id')
215
+
216
+ const perm = serverWhere('post', () => true)
217
+
218
+ export const mutate = mutations(schema, perm, {
219
+ archive: async ({ tx }, { id, reason }: { id: string; reason: string }) => {
220
+ await tx.mutate.post.update({ id, archived: true })
221
+ },
222
+ })
223
+ `
224
+ )
225
+
226
+ const result = await generate({ dir: testDir, silent: true })
227
+
228
+ expect(result.mutationCount).toBeGreaterThan(0)
229
+ expect(existsSync(join(testDir, 'generated/syncedMutations.ts'))).toBe(true)
230
+
231
+ const content = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
232
+ expect(content).toContain('archive')
233
+ expect(content).toContain('v.object')
234
+ expect(content).toContain('v.string()')
235
+ })
236
+
237
+ test('generates CRUD validators from schema columns', async () => {
238
+ writeFileSync(
239
+ join(testDir, 'models/task.ts'),
240
+ `
241
+ import { table, string, number, boolean } from 'on-zero'
242
+ import { mutations, serverWhere } from 'on-zero'
243
+
244
+ export const schema = table('task').columns({
245
+ id: string(),
246
+ title: string(),
247
+ priority: number(),
248
+ done: boolean(),
249
+ note: string().optional(),
250
+ }).primaryKey('id')
251
+
252
+ const perm = serverWhere('task', () => true)
253
+
254
+ export const mutate = mutations(schema, perm)
255
+ `
256
+ )
257
+
258
+ const result = await generate({ dir: testDir, silent: true })
259
+
260
+ const content = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
261
+
262
+ // insert: all columns present
263
+ expect(content).toContain('insert:')
264
+ expect(content).toMatch(/insert:.*v\.object/)
265
+
266
+ // update: id required, rest optional
267
+ expect(content).toContain('update:')
268
+
269
+ // delete: only PK
270
+ expect(content).toContain('delete:')
271
+ })
272
+
273
+ test('skips models without export const mutate', async () => {
274
+ writeFileSync(
275
+ join(testDir, 'models/readonly.ts'),
276
+ `
277
+ import { table, string } from 'on-zero'
278
+
279
+ export const schema = table('readonly').columns({
280
+ id: string(),
281
+ name: string(),
282
+ }).primaryKey('id')
283
+ `
284
+ )
285
+
286
+ const result = await generate({ dir: testDir, silent: true })
287
+ expect(result.mutationCount).toBe(0)
288
+ })
289
+
290
+ test('extracts custom mutations from bare mutations({})', async () => {
291
+ writeFileSync(
292
+ join(testDir, 'models/admin.ts'),
293
+ `
294
+ import { mutations } from 'on-zero'
295
+
296
+ export const mutate = mutations({
297
+ reset: async ({ tx }, { targetId }: { targetId: string }) => {
298
+ await tx.mutate.admin.delete({ id: targetId })
299
+ },
300
+ })
301
+ `
302
+ )
303
+
304
+ const result = await generate({ dir: testDir, silent: true })
305
+
306
+ const content = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
307
+ expect(content).toContain('reset')
308
+ expect(content).toContain('v.string()')
309
+ })
310
+
311
+ test('handles mutations with only context param (void)', async () => {
312
+ writeFileSync(
313
+ join(testDir, 'models/user.ts'),
314
+ `
315
+ import { table, string } from 'on-zero'
316
+ import { mutations, serverWhere } from 'on-zero'
317
+
318
+ export const schema = table('user').columns({
319
+ id: string(),
320
+ name: string(),
321
+ }).primaryKey('id')
322
+
323
+ const perm = serverWhere('user', () => true)
324
+
325
+ export const mutate = mutations(schema, perm, {
326
+ finishOnboarding: async ({ tx }) => {
327
+ // no second param
328
+ },
329
+ })
330
+ `
331
+ )
332
+
333
+ const result = await generate({ dir: testDir, silent: true })
334
+
335
+ const content = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
336
+ // should not crash, void mutations get no validator
337
+ expect(content).toContain('finishOnboarding')
338
+ })
339
+
340
+ test('handles primitive param type', async () => {
341
+ writeFileSync(
342
+ join(testDir, 'models/user.ts'),
343
+ `
344
+ import { table, string } from 'on-zero'
345
+ import { mutations, serverWhere } from 'on-zero'
346
+
347
+ export const schema = table('user').columns({
348
+ id: string(),
349
+ name: string(),
350
+ }).primaryKey('id')
351
+
352
+ const perm = serverWhere('user', () => true)
353
+
354
+ export const mutate = mutations(schema, perm, {
355
+ completeSignup: async ({ tx }, userId: string) => {
356
+ await tx.mutate.user.update({ id: userId })
357
+ },
358
+ })
359
+ `
360
+ )
361
+
362
+ const result = await generate({ dir: testDir, silent: true })
363
+
364
+ const content = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
365
+ expect(content).toContain('completeSignup')
366
+ expect(content).toContain('v.string()')
367
+ })
368
+
369
+ test('handles array param type', async () => {
370
+ writeFileSync(
371
+ join(testDir, 'models/batch.ts'),
372
+ `
373
+ import { mutations } from 'on-zero'
374
+
375
+ export const mutate = mutations({
376
+ bulkDelete: async ({ tx }, ids: Array<{ id: string }>) => {
377
+ for (const { id } of ids) await tx.mutate.batch.delete({ id })
378
+ },
379
+ })
380
+ `
381
+ )
382
+
383
+ const result = await generate({ dir: testDir, silent: true })
384
+
385
+ const content = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
386
+ expect(content).toContain('bulkDelete')
387
+ expect(content).toContain('v.array')
388
+ })
389
+
390
+ test('populates mutationCount and caching works', async () => {
391
+ writeFileSync(
392
+ join(testDir, 'models/item.ts'),
393
+ `
394
+ import { table, string } from 'on-zero'
395
+ import { mutations, serverWhere } from 'on-zero'
396
+
397
+ export const schema = table('item').columns({
398
+ id: string(),
399
+ name: string(),
400
+ }).primaryKey('id')
401
+
402
+ const perm = serverWhere('item', () => true)
403
+
404
+ export const mutate = mutations(schema, perm, {
405
+ rename: async ({ tx }, { id, name }: { id: string; name: string }) => {
406
+ await tx.mutate.item.update({ id, name })
407
+ },
408
+ })
409
+ `
410
+ )
411
+
412
+ const first = await generate({ dir: testDir, silent: true })
413
+ expect(first.mutationCount).toBeGreaterThan(0)
414
+
415
+ const second = await generate({ dir: testDir, silent: true })
416
+ expect(second.filesChanged).toBe(0)
417
+ expect(second.mutationCount).toBe(first.mutationCount)
418
+ })
419
+ })
420
+
421
+ describe('type resolution', () => {
422
+ test('resolves imported type references for mutations', async () => {
423
+ // types file that the model imports from
424
+ writeFileSync(
425
+ join(testDir, 'models/types.ts'),
426
+ `
427
+ export type ArchiveParams = {
428
+ id: string
429
+ reason: string
430
+ archived: boolean
431
+ }
432
+ `
433
+ )
434
+
435
+ writeFileSync(
436
+ join(testDir, 'models/post.ts'),
437
+ `
438
+ import { table, string, boolean } from 'on-zero'
439
+ import { mutations } from 'on-zero'
440
+ import type { ArchiveParams } from './types'
441
+
442
+ export const mutate = mutations({
443
+ archive: async ({ tx }, params: ArchiveParams) => {
444
+ await tx.mutate.post.update(params)
445
+ },
446
+ })
447
+ `
448
+ )
449
+
450
+ const result = await generate({ dir: testDir, silent: true })
451
+
452
+ const content = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
453
+ // the imported ArchiveParams type should be resolved to its fields
454
+ expect(content).toContain('v.string()')
455
+ expect(content).toContain('v.boolean()')
456
+ expect(content).toContain('reason')
457
+ expect(content).toContain('archived')
458
+ })
459
+
460
+ test('resolves utility types like Pick', async () => {
461
+ writeFileSync(
462
+ join(testDir, 'models/types.ts'),
463
+ `
464
+ export type Item = {
465
+ id: string
466
+ name: string
467
+ description: string
468
+ count: number
469
+ }
470
+ `
471
+ )
472
+
473
+ writeFileSync(
474
+ join(testDir, 'models/item.ts'),
475
+ `
476
+ import { table, string, number } from 'on-zero'
477
+ import { mutations, serverWhere } from 'on-zero'
478
+ import type { Item } from './types'
479
+
480
+ export const schema = table('item').columns({
481
+ id: string(),
482
+ name: string(),
483
+ description: string(),
484
+ count: number(),
485
+ }).primaryKey('id')
486
+
487
+ const perm = serverWhere('item', () => true)
488
+
489
+ export const mutate = mutations(schema, perm, {
490
+ rename: async ({ tx }, updates: Pick<Item, 'id' | 'name'>) => {
491
+ await tx.mutate.item.update(updates)
492
+ },
493
+ })
494
+ `
495
+ )
496
+
497
+ const result = await generate({ dir: testDir, silent: true })
498
+
499
+ const content = readFileSync(join(testDir, 'generated/syncedMutations.ts'), 'utf-8')
500
+ // Pick should resolve to only id and name
501
+ expect(content).toContain('id')
502
+ expect(content).toContain('name')
503
+ // description and count should NOT be in the rename validator
504
+ expect(content).not.toMatch(/rename:[\s\S]*description/)
505
+ expect(content).not.toMatch(/rename:[\s\S]*count/)
506
+ })
507
+
508
+ test('resolves imported types in query params', async () => {
509
+ writeFileSync(
510
+ join(testDir, 'models/post.ts'),
511
+ `export const schema = table('post', { id: string() })`
512
+ )
513
+
514
+ writeFileSync(
515
+ join(testDir, 'queries/types.ts'),
516
+ `
517
+ export type PostFilter = {
518
+ authorId: string
519
+ published: boolean
520
+ }
521
+ `
522
+ )
523
+
524
+ writeFileSync(
525
+ join(testDir, 'queries/post.ts'),
526
+ `
527
+ import type { PostFilter } from './types'
528
+
529
+ export const filteredPosts = (filter: PostFilter) => zero.query.post
530
+ `
531
+ )
532
+
533
+ const result = await generate({ dir: testDir, silent: true })
534
+
535
+ const content = readFileSync(join(testDir, 'generated/syncedQueries.ts'), 'utf-8')
536
+ expect(content).toContain('filteredPosts')
537
+ expect(content).toContain('v.object')
538
+ expect(content).toContain('authorId')
539
+ expect(content).toContain('v.boolean()')
540
+ })
541
+ })