zodvex 0.2.1 → 0.2.2
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/README.md +349 -409
- package/dist/index.d.mts +1 -20
- package/dist/index.d.ts +1 -20
- package/dist/index.js +0 -15
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +1 -1
package/README.md
CHANGED
|
@@ -4,544 +4,484 @@
|
|
|
4
4
|
|
|
5
5
|
Type-safe Convex functions with Zod schemas. Preserve Convex's optional/nullable semantics while leveraging Zod's powerful validation.
|
|
6
6
|
|
|
7
|
-
>
|
|
7
|
+
> Built on top of [convex-helpers](https://github.com/get-convex/convex-helpers)
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Table of Contents
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
- [Installation](#installation)
|
|
12
|
+
- [Quick Start](#quick-start)
|
|
13
|
+
- [Defining Schemas](#defining-schemas)
|
|
14
|
+
- [Table Definitions](#table-definitions)
|
|
15
|
+
- [Building Your Schema](#building-your-schema)
|
|
16
|
+
- [Defining Functions](#defining-functions)
|
|
17
|
+
- [Working with Subsets](#working-with-subsets)
|
|
18
|
+
- [Form Validation](#form-validation)
|
|
19
|
+
- [API Reference](#api-reference)
|
|
20
|
+
- [Advanced Usage](#advanced-usage)
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
yarn add zodvex zod convex convex-helpers
|
|
21
|
-
```
|
|
22
|
+
## Installation
|
|
22
23
|
|
|
23
24
|
```bash
|
|
24
|
-
|
|
25
|
+
npm install zodvex zod@^4.1.0 convex convex-helpers
|
|
25
26
|
```
|
|
26
27
|
|
|
27
28
|
**Peer dependencies:**
|
|
29
|
+
- `zod` (^4.1.0 or later)
|
|
30
|
+
- `convex` (>= 1.27.0)
|
|
31
|
+
- `convex-helpers` (>= 0.1.104)
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
- `convex` (>= 1.27)
|
|
31
|
-
- `convex-helpers` (>= 0.1.101-alpha.1)
|
|
33
|
+
## Quick Start
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
### 1. Set up your builders
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
Create a `convex/util.ts` file with reusable builders ([copy full example](./examples/queries.ts)):
|
|
36
38
|
|
|
37
39
|
```ts
|
|
38
|
-
// convex/
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import { zQuery, zMutation } from "zodvex";
|
|
40
|
+
// convex/util.ts
|
|
41
|
+
import { query, mutation, action } from './_generated/server'
|
|
42
|
+
import { zQueryBuilder, zMutationBuilder, zActionBuilder } from 'zodvex'
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
age: z.number().optional(),
|
|
48
|
-
});
|
|
44
|
+
export const zq = zQueryBuilder(query)
|
|
45
|
+
export const zm = zMutationBuilder(mutation)
|
|
46
|
+
export const za = zActionBuilder(action)
|
|
47
|
+
```
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
49
|
+
### 2. Use builders to create type-safe functions
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// convex/users.ts
|
|
53
|
+
import { z } from 'zod'
|
|
54
|
+
import { zid } from 'zodvex'
|
|
55
|
+
import { zq, zm } from './util'
|
|
56
|
+
import { Users } from './schemas/users'
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
export const getUser = zq({
|
|
59
|
+
args: { id: zid('users') },
|
|
60
|
+
returns: Users.zDoc.nullable(),
|
|
61
|
+
handler: async (ctx, { id }) => {
|
|
62
|
+
return await ctx.db.get(id)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
export const createUser = zm({
|
|
67
|
+
args: Users.shape,
|
|
68
|
+
returns: zid('users'),
|
|
69
|
+
handler: async (ctx, user) => {
|
|
70
|
+
// user is fully typed and validated
|
|
71
|
+
return await ctx.db.insert('users', user)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
64
74
|
```
|
|
65
75
|
|
|
66
|
-
|
|
76
|
+
## Defining Schemas
|
|
77
|
+
|
|
78
|
+
Define your Zod schemas as plain objects for best type inference:
|
|
67
79
|
|
|
68
80
|
```ts
|
|
69
|
-
|
|
70
|
-
import {
|
|
71
|
-
import { z } from "zod";
|
|
72
|
-
import { zodTable } from "zodvex";
|
|
81
|
+
import { z } from 'zod'
|
|
82
|
+
import { zid } from 'zodvex'
|
|
73
83
|
|
|
74
|
-
//
|
|
75
|
-
const
|
|
84
|
+
// Plain object shape - recommended
|
|
85
|
+
export const userShape = {
|
|
76
86
|
name: z.string(),
|
|
77
87
|
email: z.string().email(),
|
|
78
|
-
age: z.number().optional(),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
export const Users = zodTable("users", usersShape);
|
|
88
|
+
age: z.number().optional(),
|
|
89
|
+
avatarUrl: z.string().url().nullable(),
|
|
90
|
+
teamId: zid('teams').optional() // Convex ID reference
|
|
91
|
+
}
|
|
83
92
|
|
|
84
|
-
//
|
|
85
|
-
export
|
|
86
|
-
users: Users.table.index("by_email", ["email"]).searchIndex("search_name", {
|
|
87
|
-
searchField: "name",
|
|
88
|
-
}),
|
|
89
|
-
});
|
|
93
|
+
// Can also use z.object() if preferred
|
|
94
|
+
export const User = z.object(userShape)
|
|
90
95
|
```
|
|
91
96
|
|
|
92
|
-
##
|
|
97
|
+
## Table Definitions
|
|
93
98
|
|
|
94
|
-
|
|
99
|
+
Use `zodTable` as a drop-in replacement for Convex's `Table`:
|
|
95
100
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
- **Type-safe function wrappers**: Full TypeScript inference for inputs and outputs
|
|
101
|
-
- **Date handling**: Automatic conversion between JavaScript `Date` objects and Convex timestamps
|
|
102
|
-
- **CRUD scaffolding**: Generate complete CRUD operations from a single schema
|
|
103
|
-
|
|
104
|
-
### Supported Types
|
|
105
|
-
|
|
106
|
-
This library intentionally supports only Zod types that map cleanly to Convex validators. Anything outside this list is unsupported (or best-effort with caveats).
|
|
101
|
+
```ts
|
|
102
|
+
// convex/schema.ts
|
|
103
|
+
import { z } from 'zod'
|
|
104
|
+
import { zodTable, zid } from 'zodvex'
|
|
107
105
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
106
|
+
export const Users = zodTable('users', {
|
|
107
|
+
name: z.string(),
|
|
108
|
+
email: z.string().email(),
|
|
109
|
+
age: z.number().optional(), // → v.optional(v.float64())
|
|
110
|
+
deletedAt: z.date().nullable(), // → v.union(v.float64(), v.null())
|
|
111
|
+
teamId: zid('teams').optional()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Access the underlying table
|
|
115
|
+
Users.table // Convex table definition
|
|
116
|
+
Users.shape // Original Zod shape
|
|
117
|
+
Users.zDoc // Zod schema with _id and _creationTime
|
|
118
|
+
Users.docArray // z.array(zDoc) for return types
|
|
119
|
+
```
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
## Building Your Schema
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
- Intersections — combining object shapes widens overlapping fields; not equivalent to true intersection
|
|
123
|
-
- Transforms/effects/pipelines — not used for validator mapping; if you use them, conversions happen at runtime only
|
|
124
|
-
- Lazy, function, promise, set, map, symbol, branded/readonly, NaN/catch — unsupported
|
|
123
|
+
Use `zodTable().table` in your Convex schema:
|
|
125
124
|
|
|
126
|
-
|
|
125
|
+
```ts
|
|
126
|
+
// convex/schema.ts
|
|
127
|
+
import { defineSchema } from 'convex/server'
|
|
128
|
+
import { Users } from './tables/users'
|
|
129
|
+
import { Teams } from './tables/teams'
|
|
127
130
|
|
|
128
|
-
|
|
131
|
+
export default defineSchema({
|
|
132
|
+
users: Users.table
|
|
133
|
+
.index('by_email', ['email'])
|
|
134
|
+
.index('by_team', ['teamId'])
|
|
135
|
+
.searchIndex('search_name', { searchField: 'name' }),
|
|
136
|
+
|
|
137
|
+
teams: Teams.table
|
|
138
|
+
.index('by_created', ['_creationTime'])
|
|
139
|
+
})
|
|
140
|
+
```
|
|
129
141
|
|
|
130
|
-
|
|
142
|
+
## Defining Functions
|
|
131
143
|
|
|
132
|
-
|
|
144
|
+
Use your builders from `util.ts` to create type-safe functions:
|
|
133
145
|
|
|
134
146
|
```ts
|
|
135
|
-
import { z } from
|
|
136
|
-
import {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const validator = zodToConvex(z.string().optional());
|
|
140
|
-
// → v.optional(v.string())
|
|
141
|
-
|
|
142
|
-
// Convert a Zod object shape to Convex field validators
|
|
143
|
-
const fields = zodToConvexFields({
|
|
144
|
-
name: z.string(),
|
|
145
|
-
age: z.number().nullable(),
|
|
146
|
-
});
|
|
147
|
-
// → { name: v.string(), age: v.union(v.float64(), v.null()) }
|
|
147
|
+
import { z } from 'zod'
|
|
148
|
+
import { zid } from 'zodvex'
|
|
149
|
+
import { zq, zm } from './util'
|
|
150
|
+
import { Users } from './tables/users'
|
|
148
151
|
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
+
// Query with return type validation
|
|
153
|
+
export const listUsers = zq({
|
|
154
|
+
args: {},
|
|
155
|
+
returns: Users.docArray,
|
|
156
|
+
handler: async (ctx) => {
|
|
157
|
+
return await ctx.db.query('users').collect()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
152
160
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// Both Clerk and ClerkAlt are equivalent - use whichever is more readable
|
|
161
|
+
// Mutation with Convex ID
|
|
162
|
+
export const deleteUser = zm({
|
|
163
|
+
args: { id: zid('users') },
|
|
164
|
+
returns: z.null(),
|
|
165
|
+
handler: async (ctx, { id }) => {
|
|
166
|
+
await ctx.db.delete(id)
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Using the full schema
|
|
172
|
+
export const createUser = zm({
|
|
173
|
+
args: Users.shape,
|
|
174
|
+
returns: zid('users'),
|
|
175
|
+
handler: async (ctx, user) => {
|
|
176
|
+
return await ctx.db.insert('users', user)
|
|
177
|
+
}
|
|
178
|
+
})
|
|
172
179
|
```
|
|
173
180
|
|
|
174
|
-
|
|
181
|
+
## Working with Subsets
|
|
175
182
|
|
|
176
|
-
|
|
183
|
+
Pick a subset of fields for focused operations:
|
|
177
184
|
|
|
178
185
|
```ts
|
|
179
|
-
import { z } from
|
|
180
|
-
import {
|
|
181
|
-
import {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
186
|
+
import { z } from 'zod'
|
|
187
|
+
import { zid } from 'zodvex'
|
|
188
|
+
import { zm } from './util'
|
|
189
|
+
import { Users, zUsers } from './tables/users'
|
|
190
|
+
|
|
191
|
+
// Use Zod's .pick() to select fields
|
|
192
|
+
const UpdateFields = zUsers.pick({
|
|
193
|
+
firstName: true,
|
|
194
|
+
lastName: true,
|
|
195
|
+
email: true
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
export const updateUserProfile = zm({
|
|
199
|
+
args: {
|
|
200
|
+
id: zid('users'),
|
|
201
|
+
...UpdateFields.shape
|
|
193
202
|
},
|
|
194
|
-
)
|
|
203
|
+
handler: async (ctx, { id, ...fields }) => {
|
|
204
|
+
await ctx.db.patch(id, fields)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
195
207
|
|
|
196
|
-
//
|
|
197
|
-
export const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
name: z.string().min(1),
|
|
202
|
-
}),
|
|
203
|
-
async (ctx, { id, name }) => ctx.db.patch(id, { name }),
|
|
204
|
-
);
|
|
205
|
-
|
|
206
|
-
// Action with single value (normalizes to { value })
|
|
207
|
-
export const sendEmail = zAction(
|
|
208
|
-
action,
|
|
209
|
-
z.string().email(),
|
|
210
|
-
async (ctx, { value: email }) => {
|
|
211
|
-
// Send email to the address
|
|
208
|
+
// Or inline for simple cases
|
|
209
|
+
export const updateUserName = zm({
|
|
210
|
+
args: {
|
|
211
|
+
id: zid('users'),
|
|
212
|
+
name: z.string()
|
|
212
213
|
},
|
|
213
|
-
)
|
|
214
|
+
handler: async (ctx, { id, name }) => {
|
|
215
|
+
await ctx.db.patch(id, { name })
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
```
|
|
214
219
|
|
|
215
|
-
|
|
216
|
-
import { zInternalQuery, zInternalMutation, zInternalAction } from "zodvex";
|
|
220
|
+
## Form Validation
|
|
217
221
|
|
|
218
|
-
|
|
219
|
-
// When `returns` is provided, your handler must return z.input<typeof returns>
|
|
220
|
-
// (domain values like Date). zodvex validates and encodes to Convex Values for you.
|
|
221
|
-
```
|
|
222
|
+
Use your schemas with form libraries like react-hook-form:
|
|
222
223
|
|
|
223
|
-
|
|
224
|
+
```tsx
|
|
225
|
+
import { useForm } from 'react-hook-form'
|
|
226
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
227
|
+
import { z } from 'zod'
|
|
228
|
+
import { useMutation } from 'convex/react'
|
|
229
|
+
import { api } from '../convex/_generated/api'
|
|
230
|
+
import { Users } from '../convex/tables/users'
|
|
224
231
|
|
|
225
|
-
|
|
232
|
+
// Create form schema from your table schema
|
|
233
|
+
const CreateUserForm = z.object(Users.shape)
|
|
234
|
+
type CreateUserForm = z.infer<typeof CreateUserForm>
|
|
226
235
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
import { zQuery, zodTable, returnsAs } from "zodvex";
|
|
230
|
-
import { query } from "./_generated/server";
|
|
236
|
+
function UserForm() {
|
|
237
|
+
const createUser = useMutation(api.users.createUser)
|
|
231
238
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
createdAt: z.date()
|
|
236
|
-
});
|
|
239
|
+
const { register, handleSubmit, formState: { errors } } = useForm<CreateUserForm>({
|
|
240
|
+
resolver: zodResolver(CreateUserForm)
|
|
241
|
+
})
|
|
237
242
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
{},
|
|
242
|
-
async (ctx) => {
|
|
243
|
-
const rows = await ctx.db.query("users").collect();
|
|
244
|
-
// No cast needed — handler returns domain values; wrapper encodes to Convex Values
|
|
245
|
-
return rows;
|
|
246
|
-
},
|
|
247
|
-
{ returns: Users.docArray }
|
|
248
|
-
);
|
|
243
|
+
const onSubmit = async (data: CreateUserForm) => {
|
|
244
|
+
await createUser(data)
|
|
245
|
+
}
|
|
249
246
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
async (ctx) => {
|
|
255
|
-
const first = await ctx.db.query("users").order("asc").first();
|
|
256
|
-
const last = await ctx.db.query("users").order("desc").first();
|
|
257
|
-
return { first: first?.createdAt ?? null, last: last?.createdAt ?? null };
|
|
258
|
-
},
|
|
259
|
-
{ returns: z.object({ first: z.date().nullable(), last: z.date().nullable() }) }
|
|
260
|
-
);
|
|
247
|
+
return (
|
|
248
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
249
|
+
<input {...register('name')} />
|
|
250
|
+
{errors.name && <span>{errors.name.message}</span>}
|
|
261
251
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
},
|
|
270
|
-
{ returns: Users.docArray }
|
|
271
|
-
);
|
|
252
|
+
<input {...register('email')} />
|
|
253
|
+
{errors.email && <span>{errors.email.message}</span>}
|
|
254
|
+
|
|
255
|
+
<button type="submit">Create User</button>
|
|
256
|
+
</form>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
272
259
|
```
|
|
273
260
|
|
|
274
|
-
|
|
261
|
+
## API Reference
|
|
275
262
|
|
|
276
|
-
|
|
263
|
+
### Builders
|
|
277
264
|
|
|
265
|
+
**Basic builders** - Create type-safe functions without auth:
|
|
278
266
|
```ts
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
name: z.string(),
|
|
284
|
-
birthday: z.date().optional(),
|
|
285
|
-
metadata: z.record(z.string()),
|
|
286
|
-
});
|
|
267
|
+
zQueryBuilder(query) // Creates query builder
|
|
268
|
+
zMutationBuilder(mutation) // Creates mutation builder
|
|
269
|
+
zActionBuilder(action) // Creates action builder
|
|
270
|
+
```
|
|
287
271
|
|
|
288
|
-
|
|
272
|
+
**Custom builders** - Add auth or custom context:
|
|
273
|
+
```ts
|
|
274
|
+
import { customCtx } from 'zodvex'
|
|
289
275
|
|
|
290
|
-
|
|
291
|
-
|
|
276
|
+
const authQuery = zCustomQueryBuilder(
|
|
277
|
+
query,
|
|
278
|
+
customCtx(async (ctx) => {
|
|
279
|
+
const user = await getUserOrThrow(ctx)
|
|
280
|
+
return { user }
|
|
281
|
+
})
|
|
282
|
+
)
|
|
292
283
|
|
|
293
|
-
//
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const decoded = codec.decode(encoded);
|
|
303
|
-
// → { name: 'Alice', birthday: Date('1990-01-01'), metadata: { role: 'admin' } }
|
|
304
|
-
|
|
305
|
-
// Create sub-codecs (ZodObject only)
|
|
306
|
-
const nameCodec = codec.pick({ name: true });
|
|
284
|
+
// Use with automatic context injection
|
|
285
|
+
export const getMyProfile = authQuery({
|
|
286
|
+
args: {},
|
|
287
|
+
returns: Users.zDoc.nullable(),
|
|
288
|
+
handler: async (ctx) => {
|
|
289
|
+
if (!ctx.user) return null
|
|
290
|
+
return ctx.db.get(ctx.user._id)
|
|
291
|
+
}
|
|
292
|
+
})
|
|
307
293
|
```
|
|
308
294
|
|
|
309
|
-
###
|
|
310
|
-
|
|
311
|
-
Define Convex tables from Zod schemas. `zodTable` accepts a **plain object shape** for best type inference:
|
|
295
|
+
### Mapping Helpers
|
|
312
296
|
|
|
313
297
|
```ts
|
|
314
|
-
import {
|
|
315
|
-
import { zodTable, zid } from "zodvex";
|
|
316
|
-
import { defineSchema } from "convex/server";
|
|
298
|
+
import { zodToConvex, zodToConvexFields } from 'zodvex'
|
|
317
299
|
|
|
318
|
-
//
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
content: z.string(),
|
|
322
|
-
authorId: zid("users"), // Convex ID reference
|
|
323
|
-
published: z.boolean().default(false),
|
|
324
|
-
tags: z.array(z.string()).optional(),
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
// Create table helper - pass the plain object shape
|
|
328
|
-
export const Posts = zodTable("posts", postShape);
|
|
329
|
-
|
|
330
|
-
// Access properties
|
|
331
|
-
Posts.table; // → Table definition for defineSchema
|
|
332
|
-
Posts.shape; // → Original plain object shape
|
|
333
|
-
Posts.zDoc; // → ZodObject with _id and _creationTime system fields
|
|
334
|
-
Posts.docArray; // → z.array(zDoc) for return type validation
|
|
335
|
-
|
|
336
|
-
// Use in schema.ts
|
|
337
|
-
export default defineSchema({
|
|
338
|
-
posts: Posts.table
|
|
339
|
-
.index("by_author", ["authorId"])
|
|
340
|
-
.index("by_published", ["published"]),
|
|
341
|
-
});
|
|
300
|
+
// Convert single Zod type to Convex validator
|
|
301
|
+
const validator = zodToConvex(z.string().optional())
|
|
302
|
+
// → v.optional(v.string())
|
|
342
303
|
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
);
|
|
304
|
+
// Convert object shape to Convex field validators
|
|
305
|
+
const fields = zodToConvexFields({
|
|
306
|
+
name: z.string(),
|
|
307
|
+
age: z.number().nullable()
|
|
308
|
+
})
|
|
309
|
+
// → { name: v.string(), age: v.union(v.float64(), v.null()) }
|
|
350
310
|
```
|
|
351
311
|
|
|
352
|
-
|
|
312
|
+
### Codecs
|
|
353
313
|
|
|
354
|
-
|
|
314
|
+
Convert between Zod-shaped data and Convex-safe JSON:
|
|
355
315
|
|
|
356
316
|
```ts
|
|
357
|
-
|
|
358
|
-
export const users = {
|
|
359
|
-
// Meta
|
|
360
|
-
tokenId: z.string(),
|
|
361
|
-
type: z.literal("member"),
|
|
362
|
-
isAdmin: z.boolean(),
|
|
363
|
-
|
|
364
|
-
// User info
|
|
365
|
-
email: z.string(),
|
|
366
|
-
firstName: z.string().optional(),
|
|
367
|
-
lastName: z.string().optional(),
|
|
368
|
-
phone: z.string().optional(),
|
|
369
|
-
|
|
370
|
-
// References
|
|
371
|
-
favoriteUsers: z.array(zid("users")).optional(),
|
|
372
|
-
activeDancerId: zid("dancers").optional(),
|
|
373
|
-
// ... 40+ more fields
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
// Create table
|
|
377
|
-
export const Users = zodTable("users", users);
|
|
378
|
-
|
|
379
|
-
// Extract subsets without Zod's .pick() to avoid type complexity
|
|
380
|
-
const zClerkFields = z.object(pickShape(users, ["email", "firstName", "lastName", "tokenId"]));
|
|
381
|
-
// Use zClerkFields in function wrappers or validators
|
|
382
|
-
```
|
|
317
|
+
import { convexCodec } from 'zodvex'
|
|
383
318
|
|
|
384
|
-
|
|
319
|
+
const UserSchema = z.object({
|
|
320
|
+
name: z.string(),
|
|
321
|
+
birthday: z.date().optional()
|
|
322
|
+
})
|
|
385
323
|
|
|
386
|
-
|
|
324
|
+
const codec = convexCodec(UserSchema)
|
|
387
325
|
|
|
388
|
-
|
|
389
|
-
|
|
326
|
+
// Encode: Date → timestamp, omit undefined
|
|
327
|
+
const encoded = codec.encode({
|
|
328
|
+
name: 'Alice',
|
|
329
|
+
birthday: new Date('1990-01-01')
|
|
330
|
+
})
|
|
331
|
+
// → { name: 'Alice', birthday: 631152000000 }
|
|
332
|
+
|
|
333
|
+
// Decode: timestamp → Date
|
|
334
|
+
const decoded = codec.decode(encoded)
|
|
335
|
+
// → { name: 'Alice', birthday: Date('1990-01-01') }
|
|
336
|
+
```
|
|
390
337
|
|
|
391
|
-
|
|
392
|
-
import { query, mutation } from "./_generated/server";
|
|
393
|
-
import { zQueryBuilder, zMutationBuilder } from "zodvex";
|
|
338
|
+
### Supported Types
|
|
394
339
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
340
|
+
| Zod Type | Convex Validator |
|
|
341
|
+
| ----------------- | ------------------------- |
|
|
342
|
+
| `z.string()` | `v.string()` |
|
|
343
|
+
| `z.number()` | `v.float64()` |
|
|
344
|
+
| `z.bigint()` | `v.int64()` |
|
|
345
|
+
| `z.boolean()` | `v.boolean()` |
|
|
346
|
+
| `z.date()` | `v.float64()` (timestamp) |
|
|
347
|
+
| `z.null()` | `v.null()` |
|
|
348
|
+
| `z.array(T)` | `v.array(T)` |
|
|
349
|
+
| `z.object({...})` | `v.object({...})` |
|
|
350
|
+
| `z.record(T)` | `v.record(v.string(), T)` |
|
|
351
|
+
| `z.union([...])` | `v.union(...)` |
|
|
352
|
+
| `z.literal(x)` | `v.literal(x)` |
|
|
353
|
+
| `z.enum([...])` | `v.union(literals...)` |
|
|
354
|
+
| `z.optional(T)` | `v.optional(T)` |
|
|
355
|
+
| `z.nullable(T)` | `v.union(T, v.null())` |
|
|
398
356
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
handler: async (ctx, { id }) => {
|
|
403
|
-
return ctx.db.get(id);
|
|
404
|
-
}
|
|
405
|
-
});
|
|
357
|
+
**Convex IDs:**
|
|
358
|
+
```ts
|
|
359
|
+
import { zid } from 'zodvex'
|
|
406
360
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
handler: async (ctx, { id, name }) => {
|
|
410
|
-
return ctx.db.patch(id, { name });
|
|
411
|
-
}
|
|
412
|
-
});
|
|
361
|
+
zid('tableName') // → v.id('tableName')
|
|
362
|
+
zid('tableName').optional() // → v.optional(v.id('tableName'))
|
|
413
363
|
```
|
|
414
364
|
|
|
415
|
-
|
|
365
|
+
## Advanced Usage
|
|
416
366
|
|
|
417
|
-
|
|
418
|
-
|
|
367
|
+
### Custom Context Builders
|
|
368
|
+
|
|
369
|
+
Create builders with injected auth, permissions, or other context:
|
|
419
370
|
|
|
420
371
|
```ts
|
|
421
|
-
import {
|
|
422
|
-
import {
|
|
372
|
+
import { zCustomQueryBuilder, zCustomMutationBuilder, customCtx } from 'zodvex'
|
|
373
|
+
import { query, mutation } from './_generated/server'
|
|
423
374
|
|
|
424
|
-
//
|
|
375
|
+
// Add user to all queries
|
|
425
376
|
export const authQuery = zCustomQueryBuilder(
|
|
426
377
|
query,
|
|
427
378
|
customCtx(async (ctx) => {
|
|
428
|
-
const user = await getUserOrThrow(ctx)
|
|
429
|
-
return { user }
|
|
379
|
+
const user = await getUserOrThrow(ctx)
|
|
380
|
+
return { user }
|
|
430
381
|
})
|
|
431
|
-
)
|
|
382
|
+
)
|
|
432
383
|
|
|
433
|
-
//
|
|
384
|
+
// Add user + permissions to mutations
|
|
434
385
|
export const authMutation = zCustomMutationBuilder(
|
|
435
386
|
mutation,
|
|
436
387
|
customCtx(async (ctx) => {
|
|
437
|
-
const user = await getUserOrThrow(ctx)
|
|
438
|
-
|
|
388
|
+
const user = await getUserOrThrow(ctx)
|
|
389
|
+
const permissions = await getPermissions(ctx, user)
|
|
390
|
+
return { user, permissions }
|
|
439
391
|
})
|
|
440
|
-
)
|
|
441
|
-
|
|
442
|
-
// Use them with automatic type-safe context
|
|
443
|
-
export const getProfile = authQuery({
|
|
444
|
-
args: {},
|
|
445
|
-
handler: async (ctx) => {
|
|
446
|
-
// ctx.user is automatically available and typed!
|
|
447
|
-
return ctx.db.query("users").filter(q => q.eq(q.field("_id"), ctx.user._id)).first();
|
|
448
|
-
}
|
|
449
|
-
});
|
|
392
|
+
)
|
|
450
393
|
|
|
394
|
+
// Use them
|
|
451
395
|
export const updateProfile = authMutation({
|
|
452
396
|
args: { name: z.string() },
|
|
397
|
+
returns: z.null(),
|
|
453
398
|
handler: async (ctx, { name }) => {
|
|
454
|
-
// ctx.user
|
|
455
|
-
|
|
399
|
+
// ctx.user and ctx.permissions are available
|
|
400
|
+
if (!ctx.permissions.canEdit) {
|
|
401
|
+
throw new Error('No permission')
|
|
402
|
+
}
|
|
403
|
+
await ctx.db.patch(ctx.user._id, { name })
|
|
404
|
+
return null
|
|
456
405
|
}
|
|
457
|
-
})
|
|
406
|
+
})
|
|
458
407
|
```
|
|
459
408
|
|
|
460
|
-
|
|
461
|
-
- `zCustomQueryBuilder` - for queries with custom context
|
|
462
|
-
- `zCustomMutationBuilder` - for mutations with custom context
|
|
463
|
-
- `zCustomActionBuilder` - for actions with custom context
|
|
409
|
+
### Date Handling
|
|
464
410
|
|
|
465
|
-
|
|
411
|
+
Dates are automatically converted to timestamps:
|
|
466
412
|
|
|
467
413
|
```ts
|
|
468
|
-
// Dates are automatically converted to timestamps
|
|
469
414
|
const eventShape = {
|
|
470
415
|
title: z.string(),
|
|
471
416
|
startDate: z.date(),
|
|
472
|
-
endDate: z.date().nullable()
|
|
473
|
-
}
|
|
417
|
+
endDate: z.date().nullable()
|
|
418
|
+
}
|
|
474
419
|
|
|
475
|
-
const Events = zodTable(
|
|
420
|
+
export const Events = zodTable('events', eventShape)
|
|
476
421
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
z.object(eventShape),
|
|
481
|
-
async (ctx, event) => {
|
|
422
|
+
export const createEvent = zm({
|
|
423
|
+
args: eventShape,
|
|
424
|
+
handler: async (ctx, event) => {
|
|
482
425
|
// event.startDate is a Date object
|
|
483
|
-
//
|
|
484
|
-
return ctx.db.insert(
|
|
485
|
-
}
|
|
486
|
-
)
|
|
426
|
+
// Automatically converted to timestamp for storage
|
|
427
|
+
return await ctx.db.insert('events', event)
|
|
428
|
+
}
|
|
429
|
+
})
|
|
487
430
|
```
|
|
488
431
|
|
|
489
|
-
###
|
|
432
|
+
### Return Type Helpers
|
|
490
433
|
|
|
491
434
|
```ts
|
|
492
|
-
import {
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
435
|
+
import { returnsAs } from 'zodvex'
|
|
436
|
+
|
|
437
|
+
export const listUsers = zq({
|
|
438
|
+
args: {},
|
|
439
|
+
handler: async (ctx) => {
|
|
440
|
+
const rows = await ctx.db.query('users').collect()
|
|
441
|
+
// Use returnsAs for type hint in tricky inference spots
|
|
442
|
+
return returnsAs<typeof Users.docArray>()(rows)
|
|
443
|
+
},
|
|
444
|
+
returns: Users.docArray
|
|
445
|
+
})
|
|
500
446
|
```
|
|
501
447
|
|
|
502
|
-
|
|
448
|
+
### Working with Large Schemas
|
|
503
449
|
|
|
504
|
-
|
|
450
|
+
zodvex provides `pickShape` and `safePick` helpers as alternatives to Zod's `.pick()`:
|
|
505
451
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
| `z.string()` | `v.string()` |
|
|
509
|
-
| `z.number()` | `v.float64()` |
|
|
510
|
-
| `z.bigint()` | `v.int64()` |
|
|
511
|
-
| `z.boolean()` | `v.boolean()` |
|
|
512
|
-
| `z.date()` | `v.float64()` (timestamp) |
|
|
513
|
-
| `z.null()` | `v.null()` |
|
|
514
|
-
| `z.array(T)` | `v.array(T)` |
|
|
515
|
-
| `z.object({...})` | `v.object({...})` |
|
|
516
|
-
| `z.record(T)` | `v.record(v.string(), T)` |
|
|
517
|
-
| `z.union([...])` | `v.union(...)` |
|
|
518
|
-
| `z.literal(x)` | `v.literal(x)` |
|
|
519
|
-
| `z.enum([...])` | `v.union(literals...)` |
|
|
520
|
-
| `z.optional(T)` | `v.optional(T)` |
|
|
521
|
-
| `z.nullable(T)` | `v.union(T, v.null())` |
|
|
522
|
-
|
|
523
|
-
### Important Notes
|
|
452
|
+
```ts
|
|
453
|
+
import { pickShape, safePick } from 'zodvex'
|
|
524
454
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
- **Transforms**: Zod transforms (`.transform()`, `.refine()`) are not supported in schema mapping and fall back to `v.any()`.
|
|
528
|
-
- **Return encoding**: Return values are always encoded to Convex Values. When `returns` is specified, values are validated and then encoded according to the schema; without `returns`, values are still encoded (e.g., Date → timestamp) for runtime safety.
|
|
455
|
+
// Standard Zod .pick() works great for most schemas
|
|
456
|
+
const UserUpdate = User.pick({ email: true, firstName: true, lastName: true })
|
|
529
457
|
|
|
530
|
-
|
|
458
|
+
// If you hit TypeScript instantiation depth limits (rare, 100+ fields),
|
|
459
|
+
// use pickShape or safePick:
|
|
460
|
+
const userShape = pickShape(User, ['email', 'firstName', 'lastName'])
|
|
461
|
+
const UserUpdate = z.object(userShape)
|
|
531
462
|
|
|
532
|
-
|
|
463
|
+
// Or use safePick (convenience wrapper that does the same thing)
|
|
464
|
+
const UserUpdate = safePick(User, { email: true, firstName: true, lastName: true })
|
|
465
|
+
```
|
|
533
466
|
|
|
534
|
-
##
|
|
467
|
+
## Why zodvex?
|
|
535
468
|
|
|
536
|
-
- **
|
|
537
|
-
-
|
|
538
|
-
-
|
|
469
|
+
- **Correct optional/nullable semantics** - Preserves Convex's distinction
|
|
470
|
+
- `.optional()` → `v.optional(T)` (field can be omitted)
|
|
471
|
+
- `.nullable()` → `v.union(T, v.null())` (required but can be null)
|
|
472
|
+
- Both → `v.optional(v.union(T, v.null()))`
|
|
473
|
+
- **Superior type safety** - Builders provide better type inference than wrapper functions
|
|
474
|
+
- **Date handling** - Automatic `Date` ↔ timestamp conversion
|
|
475
|
+
- **End-to-end validation** - Same schema from database to frontend forms
|
|
539
476
|
|
|
540
|
-
##
|
|
477
|
+
## Compatibility
|
|
541
478
|
|
|
542
|
-
|
|
479
|
+
- **Zod**: ^4.1.0 or later
|
|
480
|
+
- **Convex**: >= 1.27.0
|
|
481
|
+
- **convex-helpers**: >= 0.1.104
|
|
482
|
+
- **TypeScript**: Full type inference support
|
|
543
483
|
|
|
544
|
-
##
|
|
484
|
+
## License
|
|
545
485
|
|
|
546
486
|
MIT
|
|
547
487
|
|