zodvex 0.7.4 → 0.7.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zodvex",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Codec-first Zod v4 integration for Convex -- type-safe validation, encoding, DB wrapping, and codegen.",
5
5
  "keywords": ["zod", "convex", "validators", "codec", "mapping", "schema", "validation"],
6
6
  "homepage": "https://github.com/panzacoder/zodvex#readme",
@@ -1,14 +1,83 @@
1
- import type { GenericActionCtx, GenericDataModel } from 'convex/server'
2
- import type { BoundaryHelpersOptions } from './boundaryHelpers'
1
+ import type { GenericActionCtx, GenericDataModel, Scheduler } from 'convex/server'
2
+ import type { BoundaryHelpers, BoundaryHelpersOptions } from './boundaryHelpers'
3
3
  import { createBoundaryHelpers } from './boundaryHelpers'
4
4
  import type { AnyRegistry } from './types'
5
5
 
6
6
  /**
7
- * Wraps an action context's runQuery/runMutation with automatic
7
+ * Wraps a `runQuery`/`runMutation` function so it encodes codec args
8
+ * (runtime -> wire) before the call and decodes the result (wire -> runtime)
9
+ * after. Functions not in the registry pass through unchanged.
10
+ */
11
+ function wrapRun(fn: (ref: any, ...rest: any[]) => Promise<any>, codec: BoundaryHelpers) {
12
+ return async (ref: any, ...restArgs: any[]) => {
13
+ const wireArgs = codec.encodeArgs(ref, restArgs[0])
14
+ const wireResult = await fn(ref, wireArgs)
15
+ return codec.decodeResult(ref, wireResult)
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Wraps a {@link Scheduler} so `runAfter`/`runAt` encode codec args to wire
21
+ * before scheduling.
22
+ *
23
+ * A scheduled function crosses the Convex boundary exactly like `runMutation`:
24
+ * the caller holds *decoded* (runtime) values, but Convex serializes the args
25
+ * against the target's *wire* validator. A non-serializable runtime value (e.g.
26
+ * a Symbol-valued codec field) cannot cross at all, and branded/`SensitiveField`
27
+ * codecs are the wrong shape — so the args must be encoded first.
28
+ *
29
+ * Return values are NOT decoded: the scheduler returns the scheduled-function
30
+ * id, not the target's result. Other members (e.g. `cancel`) pass through.
31
+ */
32
+ function wrapScheduler(scheduler: Scheduler, codec: BoundaryHelpers): Scheduler {
33
+ const wrapped: any = { ...scheduler }
34
+ wrapped.runAfter = (delayMs: number, ref: any, ...restArgs: any[]) => {
35
+ const wireArgs = codec.encodeArgs(ref, restArgs[0])
36
+ return (scheduler.runAfter as any)(delayMs, ref, ...(restArgs.length ? [wireArgs] : []))
37
+ }
38
+ wrapped.runAt = (timestamp: number | Date, ref: any, ...restArgs: any[]) => {
39
+ const wireArgs = codec.encodeArgs(ref, restArgs[0])
40
+ return (scheduler.runAt as any)(timestamp, ref, ...(restArgs.length ? [wireArgs] : []))
41
+ }
42
+ return wrapped as Scheduler
43
+ }
44
+
45
+ /**
46
+ * Builds ctx overrides that auto-encode codec args at outbound call sites:
47
+ * - `runQuery` / `runMutation`: encode args, decode result.
48
+ * - `scheduler.runAfter` / `scheduler.runAt`: encode args.
49
+ *
50
+ * Only the members present on the given ctx are included, so this serves both
51
+ * action ctx (run* + scheduler) and mutation ctx (scheduler only).
52
+ *
53
+ * @internal Used by initZodvex when the `registry` option is provided.
54
+ */
55
+ export function createCodecCallOverrides(
56
+ registry: AnyRegistry,
57
+ ctx: { runQuery?: unknown; runMutation?: unknown; scheduler?: Scheduler },
58
+ options?: BoundaryHelpersOptions
59
+ ): Record<string, unknown> {
60
+ const codec = createBoundaryHelpers(registry, options)
61
+ const overrides: Record<string, unknown> = {}
62
+ if (typeof ctx.runQuery === 'function') {
63
+ overrides.runQuery = wrapRun(ctx.runQuery as any, codec)
64
+ }
65
+ if (typeof ctx.runMutation === 'function') {
66
+ overrides.runMutation = wrapRun(ctx.runMutation as any, codec)
67
+ }
68
+ if (ctx.scheduler) {
69
+ overrides.scheduler = wrapScheduler(ctx.scheduler, codec)
70
+ }
71
+ return overrides
72
+ }
73
+
74
+ /**
75
+ * Wraps an action context's runQuery/runMutation and scheduler with automatic
8
76
  * codec transforms via the zodvex registry.
9
77
  *
10
78
  * - Args are encoded (runtime -> wire) before calling the inner function
11
79
  * - Results are decoded (wire -> runtime) before returning to the handler
80
+ * (runQuery/runMutation only — the scheduler returns a scheduled-function id)
12
81
  * - Functions not in the registry pass through unchanged
13
82
  *
14
83
  * @internal Used by initZodvex when registry option is provided.
@@ -18,19 +87,5 @@ export function createZodvexActionCtx<DM extends GenericDataModel>(
18
87
  ctx: GenericActionCtx<DM>,
19
88
  options?: BoundaryHelpersOptions
20
89
  ): GenericActionCtx<DM> {
21
- const codec = createBoundaryHelpers(registry, options)
22
-
23
- return {
24
- ...ctx,
25
- runQuery: async (ref: any, ...restArgs: any[]) => {
26
- const wireArgs = codec.encodeArgs(ref, restArgs[0])
27
- const wireResult = await ctx.runQuery(ref, wireArgs)
28
- return codec.decodeResult(ref, wireResult)
29
- },
30
- runMutation: async (ref: any, ...restArgs: any[]) => {
31
- const wireArgs = codec.encodeArgs(ref, restArgs[0])
32
- const wireResult = await ctx.runMutation(ref, wireArgs)
33
- return codec.decodeResult(ref, wireResult)
34
- }
35
- } as GenericActionCtx<DM>
90
+ return { ...ctx, ...createCodecCallOverrides(registry, ctx, options) } as GenericActionCtx<DM>
36
91
  }
@@ -10,7 +10,7 @@ import type {
10
10
  } from 'convex/server'
11
11
  import { NoOp } from 'convex-helpers/server/customFunctions'
12
12
  import type { z } from 'zod'
13
- import { createZodvexActionCtx } from './actionCtx'
13
+ import { createCodecCallOverrides } from './actionCtx'
14
14
  import type { CustomBuilder } from './custom'
15
15
  import { zCustomAction, zCustomMutation, zCustomQuery } from './custom'
16
16
  import { createZodvexCustomization } from './customization'
@@ -285,10 +285,11 @@ export function initZodvex(
285
285
  const noOp = createNoOpCustomization()
286
286
  const wrap = options?.wrapDb !== false
287
287
 
288
- const actionCust = createActionCustomization(options?.registry, noOp)
288
+ const registryThunk = options?.registry
289
+ const actionCust = createActionCustomization(registryThunk, noOp)
289
290
  const customizations = {
290
291
  query: wrap ? codec.query : noOp,
291
- mutation: wrap ? codec.mutation : noOp,
292
+ mutation: createMutationCustomization(wrap ? codec.mutation : noOp, registryThunk),
292
293
  action: actionCust
293
294
  }
294
295
 
@@ -309,10 +310,38 @@ function createActionCustomization(
309
310
 
310
311
  return {
311
312
  args: {} as Record<string, never>,
312
- input: async (ctx: any) => {
313
- const wrapped = createZodvexActionCtx(registryThunk(), ctx)
313
+ input: async (ctx: any) => ({
314
+ // Auto-encode codec args at outbound call sites: runQuery/runMutation
315
+ // (encode args, decode result) and scheduler.runAfter/runAt (encode args).
316
+ ctx: createCodecCallOverrides(registryThunk(), ctx),
317
+ args: {}
318
+ })
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Composes the codec DB customization with outbound codec-arg encoding for the
324
+ * mutation builders. Mutations expose `ctx.scheduler` (runAfter/runAt), so when
325
+ * a registry is provided those calls auto-encode decoded codec args to wire —
326
+ * symmetric with the inbound decode the receiving function already performs.
327
+ *
328
+ * Without a registry, the DB customization is returned unchanged.
329
+ */
330
+ function createMutationCustomization(
331
+ dbCust: InternalCustomization,
332
+ registryThunk: (() => AnyRegistry) | undefined
333
+ ): InternalCustomization {
334
+ if (!registryThunk) {
335
+ return dbCust
336
+ }
337
+
338
+ return {
339
+ args: {} as Record<string, never>,
340
+ input: async (ctx: any, _args: any, extra?: any) => {
341
+ const dbResult = await dbCust.input(ctx, {}, extra)
342
+ const callOverrides = createCodecCallOverrides(registryThunk(), ctx)
314
343
  return {
315
- ctx: { runQuery: wrapped.runQuery, runMutation: wrapped.runMutation },
344
+ ctx: { ...dbResult.ctx, ...callOverrides },
316
345
  args: {}
317
346
  }
318
347
  }
@@ -1,4 +1,4 @@
1
- import type { AuthTokenFetcher } from 'convex/browser'
1
+ import type { AuthTokenFetcher, ConnectionState, MutationOptions } from 'convex/browser'
2
2
  import { ConvexClient } from 'convex/browser'
3
3
  import type { FunctionArgs, FunctionReference, FunctionReturnType } from 'convex/server'
4
4
  import type { BoundaryHelpersOptions } from '../../internal/boundaryHelpers'
@@ -21,6 +21,7 @@ export class ZodvexClient<R extends AnyRegistry = AnyRegistry> {
21
21
  private innerClient?: ConvexClient
22
22
  private url?: string
23
23
  private pendingAuthFetcher?: AuthTokenFetcher
24
+ private pendingAuthOnChange?: (isAuthenticated: boolean) => void
24
25
 
25
26
  constructor(registry: R, options: ZodvexClientOptions) {
26
27
  this.codec = createBoundaryHelpers(registry, { onDecodeError: options.onDecodeError })
@@ -42,7 +43,7 @@ export class ZodvexClient<R extends AnyRegistry = AnyRegistry> {
42
43
 
43
44
  const client = new ConvexClient(this.getUrl())
44
45
  if (this.pendingAuthFetcher) {
45
- client.setAuth(this.pendingAuthFetcher)
46
+ client.setAuth(this.pendingAuthFetcher, this.pendingAuthOnChange)
46
47
  }
47
48
  this.innerClient = client
48
49
  return client
@@ -65,11 +66,33 @@ export class ZodvexClient<R extends AnyRegistry = AnyRegistry> {
65
66
 
66
67
  async mutate<M extends FunctionReference<'mutation', any, any, any>>(
67
68
  ref: M,
68
- args: M['_args']
69
+ args: M['_args'],
70
+ options?: MutationOptions
69
71
  ): Promise<M['_returnType']> {
70
72
  const wireResult = await this.getConvex().mutation(
71
73
  ref,
72
- this.codec.encodeArgs(ref, args) as FunctionArgs<M>
74
+ this.codec.encodeArgs(ref, args) as FunctionArgs<M>,
75
+ options
76
+ )
77
+ return this.codec.decodeResult(ref, wireResult)
78
+ }
79
+
80
+ /** Alias for {@link mutate} — matches `ConvexClient.mutation` / `ZodvexReactClient.mutation`. */
81
+ mutation<M extends FunctionReference<'mutation', any, any, any>>(
82
+ ref: M,
83
+ args: M['_args'],
84
+ options?: MutationOptions
85
+ ): Promise<M['_returnType']> {
86
+ return this.mutate(ref, args, options)
87
+ }
88
+
89
+ async action<A extends FunctionReference<'action', any, any, any>>(
90
+ ref: A,
91
+ args: A['_args']
92
+ ): Promise<A['_returnType']> {
93
+ const wireResult = await this.getConvex().action(
94
+ ref,
95
+ this.codec.encodeArgs(ref, args) as FunctionArgs<A>
73
96
  )
74
97
  return this.codec.decodeResult(ref, wireResult)
75
98
  }
@@ -77,22 +100,104 @@ export class ZodvexClient<R extends AnyRegistry = AnyRegistry> {
77
100
  subscribe<Q extends FunctionReference<'query', any, any, any>>(
78
101
  ref: Q,
79
102
  args: Q['_args'],
80
- callback: (result: Q['_returnType']) => void
103
+ callback: (result: Q['_returnType']) => void,
104
+ onError?: (e: Error) => void
105
+ ): () => void {
106
+ const wireArgs = this.codec.encodeArgs(ref, args) as FunctionArgs<Q>
107
+ return this.getConvex().onUpdate(
108
+ ref,
109
+ wireArgs,
110
+ (wireResult: FunctionReturnType<Q>) => {
111
+ callback(this.codec.decodeResult(ref, wireResult))
112
+ },
113
+ onError
114
+ )
115
+ }
116
+
117
+ /** Alias for {@link subscribe} — matches `ConvexClient.onUpdate`. */
118
+ onUpdate<Q extends FunctionReference<'query', any, any, any>>(
119
+ ref: Q,
120
+ args: Q['_args'],
121
+ callback: (result: Q['_returnType']) => void,
122
+ onError?: (e: Error) => void
81
123
  ): () => void {
124
+ return this.subscribe(ref, args, callback, onError)
125
+ }
126
+
127
+ /**
128
+ * Experimental paginated subscription. Encodes args to wire and decodes each
129
+ * page item through the registry, mirroring {@link subscribe}.
130
+ */
131
+ onPaginatedUpdate_experimental<Q extends FunctionReference<'query', any, any, any>>(
132
+ ref: Q,
133
+ args: Q['_args'],
134
+ options: { initialNumItems: number },
135
+ callback: (result: {
136
+ page: Q['_returnType'][]
137
+ isDone: boolean
138
+ continueCursor: string
139
+ [key: string]: unknown
140
+ }) => void,
141
+ onError?: (e: Error) => void
142
+ ): ReturnType<ConvexClient['onPaginatedUpdate_experimental']> {
82
143
  const wireArgs = this.codec.encodeArgs(ref, args) as FunctionArgs<Q>
83
- return this.getConvex().onUpdate(ref, wireArgs, (wireResult: FunctionReturnType<Q>) => {
84
- callback(this.codec.decodeResult(ref, wireResult))
85
- })
144
+ return this.getConvex().onPaginatedUpdate_experimental(
145
+ ref,
146
+ wireArgs,
147
+ options,
148
+ (wireResult: any) => {
149
+ callback({
150
+ ...wireResult,
151
+ page: wireResult.page.map((item: any) => this.codec.decodeResult(ref, item))
152
+ })
153
+ },
154
+ onError
155
+ ) as ReturnType<ConvexClient['onPaginatedUpdate_experimental']>
86
156
  }
87
157
 
88
- setAuth(token: string | null) {
89
- const fetcher = async () => token
158
+ /**
159
+ * Set the auth token. Accepts either a raw token string (convenience) or a
160
+ * Convex `AuthTokenFetcher` plus optional `onChange` callback (parity with
161
+ * `ConvexClient.setAuth`).
162
+ */
163
+ setAuth(token: string | null): void
164
+ setAuth(fetchToken: AuthTokenFetcher, onChange?: (isAuthenticated: boolean) => void): void
165
+ setAuth(
166
+ tokenOrFetcher: string | null | AuthTokenFetcher,
167
+ onChange?: (isAuthenticated: boolean) => void
168
+ ): void {
169
+ const fetcher: AuthTokenFetcher =
170
+ typeof tokenOrFetcher === 'function' ? tokenOrFetcher : async () => tokenOrFetcher
90
171
  this.pendingAuthFetcher = fetcher
172
+ this.pendingAuthOnChange = onChange
91
173
  if (this.innerClient) {
92
- this.innerClient.setAuth(fetcher)
174
+ this.innerClient.setAuth(fetcher, onChange)
93
175
  }
94
176
  }
95
177
 
178
+ /** Returns the current auth token and its decoded claims, if authenticated. */
179
+ getAuth(): { token: string; decoded: Record<string, any> } | undefined {
180
+ return this.getConvex().getAuth()
181
+ }
182
+
183
+ /** Whether this client has been closed. False before the inner client is created. */
184
+ get closed(): boolean {
185
+ return this.innerClient?.closed ?? false
186
+ }
187
+
188
+ /** Whether this client is disabled. False before the inner client is created. */
189
+ get disabled(): boolean {
190
+ return this.innerClient?.disabled ?? false
191
+ }
192
+
193
+ connectionState(): ConnectionState {
194
+ return this.getConvex().connectionState()
195
+ }
196
+
197
+ subscribeToConnectionState(cb: (state: ConnectionState) => void): () => void {
198
+ return this.getConvex().subscribeToConnectionState(cb)
199
+ }
200
+
96
201
  async close() {
97
202
  await this.getConvex().close()
98
203
  }
@@ -84,6 +84,19 @@ export class ZodvexReactClient<R extends AnyRegistry = AnyRegistry> {
84
84
  return this.codec.decodeResult(ref, wireResult)
85
85
  }
86
86
 
87
+ /**
88
+ * Indicates likely future interest in a query subscription. Encodes args to
89
+ * wire before delegating, mirroring {@link watchQuery}.
90
+ */
91
+ prewarmQuery<Q extends FunctionReference<'query', any, any, any>>(queryOptions: {
92
+ query: Q
93
+ args: Q['_args']
94
+ extendSubscriptionFor?: number
95
+ }): void {
96
+ const wireArgs = this.codec.encodeArgs(queryOptions.query, queryOptions.args) as FunctionArgs<Q>
97
+ this.getConvex().prewarmQuery({ ...queryOptions, args: wireArgs })
98
+ }
99
+
87
100
  watchQuery<Q extends FunctionReference<'query', any, any, any>>(
88
101
  ref: Q,
89
102
  args: Q['_args'],
@@ -139,6 +152,10 @@ export class ZodvexReactClient<R extends AnyRegistry = AnyRegistry> {
139
152
  return this.getUrl()
140
153
  }
141
154
 
155
+ get logger(): ConvexReactClient['logger'] {
156
+ return this.getConvex().logger
157
+ }
158
+
142
159
  connectionState() {
143
160
  return this.getConvex().connectionState()
144
161
  }