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.
- package/dist/cjs/createUseQuery.cjs +3 -2
- package/dist/cjs/createUseQuery.js +2 -2
- package/dist/cjs/createUseQuery.js.map +1 -1
- package/dist/cjs/createUseQuery.native.js +3 -2
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createZeroClient.cjs +28 -5
- package/dist/cjs/createZeroClient.js +19 -4
- package/dist/cjs/createZeroClient.js.map +1 -1
- package/dist/cjs/createZeroClient.native.js +29 -5
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/createZeroServer.cjs +5 -2
- package/dist/cjs/createZeroServer.js +5 -2
- package/dist/cjs/createZeroServer.js.map +1 -1
- package/dist/cjs/createZeroServer.native.js +5 -2
- package/dist/cjs/createZeroServer.native.js.map +1 -1
- package/dist/cjs/generate.cjs +458 -39
- package/dist/cjs/generate.js +485 -31
- package/dist/cjs/generate.js.map +2 -2
- package/dist/cjs/generate.native.js +812 -51
- package/dist/cjs/generate.native.js.map +1 -1
- package/dist/cjs/generate.test.cjs +251 -0
- package/dist/cjs/generate.test.js +252 -0
- package/dist/cjs/generate.test.js.map +1 -1
- package/dist/cjs/generate.test.native.js +251 -0
- package/dist/cjs/generate.test.native.js.map +1 -1
- package/dist/cjs/helpers/createMutators.cjs +21 -8
- package/dist/cjs/helpers/createMutators.js +16 -6
- package/dist/cjs/helpers/createMutators.js.map +1 -1
- package/dist/cjs/helpers/createMutators.native.js +28 -10
- package/dist/cjs/helpers/createMutators.native.js.map +1 -1
- package/dist/esm/createUseQuery.js +3 -3
- package/dist/esm/createUseQuery.js.map +1 -1
- package/dist/esm/createUseQuery.mjs +4 -3
- package/dist/esm/createUseQuery.mjs.map +1 -1
- package/dist/esm/createUseQuery.native.js +4 -3
- package/dist/esm/createUseQuery.native.js.map +1 -1
- package/dist/esm/createZeroClient.js +19 -4
- package/dist/esm/createZeroClient.js.map +1 -1
- package/dist/esm/createZeroClient.mjs +28 -5
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +29 -5
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/createZeroServer.js +5 -2
- package/dist/esm/createZeroServer.js.map +1 -1
- package/dist/esm/createZeroServer.mjs +5 -2
- package/dist/esm/createZeroServer.mjs.map +1 -1
- package/dist/esm/createZeroServer.native.js +5 -2
- package/dist/esm/createZeroServer.native.js.map +1 -1
- package/dist/esm/generate.js +486 -32
- package/dist/esm/generate.js.map +2 -2
- package/dist/esm/generate.mjs +459 -40
- package/dist/esm/generate.mjs.map +1 -1
- package/dist/esm/generate.native.js +813 -52
- package/dist/esm/generate.native.js.map +1 -1
- package/dist/esm/generate.test.js +252 -0
- package/dist/esm/generate.test.js.map +1 -1
- package/dist/esm/generate.test.mjs +251 -0
- package/dist/esm/generate.test.mjs.map +1 -1
- package/dist/esm/generate.test.native.js +251 -0
- package/dist/esm/generate.test.native.js.map +1 -1
- package/dist/esm/helpers/createMutators.js +6 -4
- package/dist/esm/helpers/createMutators.js.map +1 -1
- package/dist/esm/helpers/createMutators.mjs +6 -4
- package/dist/esm/helpers/createMutators.mjs.map +1 -1
- package/dist/esm/helpers/createMutators.native.js +13 -6
- package/dist/esm/helpers/createMutators.native.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +110 -2
- package/src/createUseQuery.tsx +15 -6
- package/src/createZeroClient.tsx +42 -6
- package/src/createZeroServer.ts +9 -0
- package/src/generate.test.ts +340 -0
- package/src/generate.ts +863 -43
- package/src/helpers/createMutators.ts +22 -8
- package/types/createUseQuery.d.ts +2 -1
- package/types/createUseQuery.d.ts.map +1 -1
- package/types/createZeroClient.d.ts +10 -1
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/createZeroServer.d.ts +7 -1
- package/types/createZeroServer.d.ts.map +1 -1
- package/types/generate.d.ts +1 -0
- package/types/generate.d.ts.map +1 -1
- package/types/helpers/createMutators.d.ts +3 -1
- 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.
|
|
415
|
-
|
|
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
|
package/src/createUseQuery.tsx
CHANGED
|
@@ -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<
|
|
48
|
+
DisabledContext: Context<QueryControlMode>
|
|
46
49
|
customQueries: AnyQueryRegistry
|
|
47
50
|
}): UseQueryHook<Schema> {
|
|
48
51
|
function useQuery(...args: any[]): any {
|
|
49
|
-
const
|
|
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 (
|
|
78
|
-
|
|
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
|
|
90
|
+
return EMPTY_RESPONSE
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
return useQuery as UseQueryHook<Schema>
|
package/src/createZeroClient.tsx
CHANGED
|
@@ -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
|
|
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(!
|
|
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
|
-
|
|
211
|
+
const result = status.type === 'unknown' ? null : Boolean(data)
|
|
211
212
|
|
|
212
|
-
|
|
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
|
}
|
package/src/createZeroServer.ts
CHANGED
|
@@ -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<
|
package/src/generate.test.ts
CHANGED
|
@@ -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
|
+
})
|