zodvex 0.2.0 → 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 +366 -380
- package/dist/index.d.mts +218 -82
- package/dist/index.d.ts +218 -82
- package/dist/index.js +125 -205
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +111 -192
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/builders.ts +310 -0
- package/src/custom.ts +4 -84
- package/src/index.ts +4 -1
- package/src/mapping/core.ts +16 -0
- package/src/tables.ts +34 -32
- package/src/utils.ts +9 -6
- package/src/wrappers.ts +60 -52
package/README.md
CHANGED
|
@@ -4,269 +4,309 @@
|
|
|
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
|
-
export const getUser = zQuery(
|
|
52
|
-
query,
|
|
53
|
-
{ id: z.string() },
|
|
54
|
-
async (ctx, { id }) => {
|
|
55
|
-
return await ctx.db.get(id);
|
|
56
|
-
},
|
|
57
|
-
);
|
|
49
|
+
### 2. Use builders to create type-safe functions
|
|
58
50
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
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'
|
|
57
|
+
|
|
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
|
|
101
|
+
```ts
|
|
102
|
+
// convex/schema.ts
|
|
103
|
+
import { z } from 'zod'
|
|
104
|
+
import { zodTable, zid } from 'zodvex'
|
|
103
105
|
|
|
104
|
-
|
|
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
|
+
```
|
|
105
120
|
|
|
106
|
-
|
|
121
|
+
## Building Your Schema
|
|
107
122
|
|
|
108
|
-
|
|
109
|
-
- Date: `z.date()` → `v.float64()` (timestamp), encoded/decoded automatically
|
|
110
|
-
- Literals: `z.literal(x)` → `v.literal(x)`
|
|
111
|
-
- Enums: `z.enum([...])` → `v.union(v.literal(...))`
|
|
112
|
-
- Arrays: `z.array(T)` → `v.array(T')`
|
|
113
|
-
- Objects: `z.object({...})` → `v.object({...})`
|
|
114
|
-
- Records: `z.record(T)` or `z.record(z.string(), T)` → `v.record(v.string(), T')` (string keys only)
|
|
115
|
-
- Unions: `z.union([...])` (members must be supported types)
|
|
116
|
-
- Optional/nullable wrappers: `.optional()` → `v.optional(T')`, `.nullable()` → `v.union(T', v.null())`
|
|
117
|
-
- Convex IDs: `zid('table')` → `v.id('table')`
|
|
123
|
+
Use `zodTable().table` in your Convex schema:
|
|
118
124
|
|
|
119
|
-
|
|
125
|
+
```ts
|
|
126
|
+
// convex/schema.ts
|
|
127
|
+
import { defineSchema } from 'convex/server'
|
|
128
|
+
import { Users } from './tables/users'
|
|
129
|
+
import { Teams } from './tables/teams'
|
|
120
130
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
```
|
|
125
141
|
|
|
126
|
-
|
|
142
|
+
## Defining Functions
|
|
127
143
|
|
|
128
|
-
|
|
144
|
+
Use your builders from `util.ts` to create type-safe functions:
|
|
129
145
|
|
|
130
|
-
|
|
146
|
+
```ts
|
|
147
|
+
import { z } from 'zod'
|
|
148
|
+
import { zid } from 'zodvex'
|
|
149
|
+
import { zq, zm } from './util'
|
|
150
|
+
import { Users } from './tables/users'
|
|
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
|
+
})
|
|
160
|
+
|
|
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
|
+
})
|
|
179
|
+
```
|
|
131
180
|
|
|
132
|
-
|
|
181
|
+
## Working with Subsets
|
|
133
182
|
|
|
134
|
-
|
|
135
|
-
import { z } from "zod";
|
|
136
|
-
import { zodToConvex, zodToConvexFields, pickShape, safePick } from "zodvex";
|
|
183
|
+
Pick a subset of fields for focused operations:
|
|
137
184
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
185
|
+
```ts
|
|
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
|
|
202
|
+
},
|
|
203
|
+
handler: async (ctx, { id, ...fields }) => {
|
|
204
|
+
await ctx.db.patch(id, fields)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Or inline for simple cases
|
|
209
|
+
export const updateUserName = zm({
|
|
210
|
+
args: {
|
|
211
|
+
id: zid('users'),
|
|
212
|
+
name: z.string()
|
|
213
|
+
},
|
|
214
|
+
handler: async (ctx, { id, name }) => {
|
|
215
|
+
await ctx.db.patch(id, { name })
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
```
|
|
141
219
|
|
|
142
|
-
|
|
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()) }
|
|
220
|
+
## Form Validation
|
|
148
221
|
|
|
149
|
-
|
|
150
|
-
// IMPORTANT: Avoid Zod's .pick() on large schemas (30+ fields) as it can cause
|
|
151
|
-
// TypeScript instantiation depth errors. Use pickShape/safePick instead.
|
|
222
|
+
Use your schemas with form libraries like react-hook-form:
|
|
152
223
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ❌ DON'T use Zod's .pick() on large schemas (type instantiation issues)
|
|
162
|
-
// const Clerk = User.pick({ email: true, firstName: true, lastName: true });
|
|
163
|
-
|
|
164
|
-
// ✅ DO use pickShape to extract a plain object shape
|
|
165
|
-
const clerkShape = pickShape(User, ["email", "firstName", "lastName"]);
|
|
166
|
-
// Use directly in wrappers or build a new z.object()
|
|
167
|
-
const Clerk = z.object(clerkShape);
|
|
168
|
-
|
|
169
|
-
// Alternative: safePick builds the z.object() for you
|
|
170
|
-
const ClerkAlt = safePick(User, { email: true, firstName: true, lastName: true });
|
|
171
|
-
// Both Clerk and ClerkAlt are equivalent - use whichever is more readable
|
|
172
|
-
```
|
|
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'
|
|
173
231
|
|
|
174
|
-
|
|
232
|
+
// Create form schema from your table schema
|
|
233
|
+
const CreateUserForm = z.object(Users.shape)
|
|
234
|
+
type CreateUserForm = z.infer<typeof CreateUserForm>
|
|
175
235
|
|
|
176
|
-
|
|
236
|
+
function UserForm() {
|
|
237
|
+
const createUser = useMutation(api.users.createUser)
|
|
177
238
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
import { zQuery, zMutation, zAction } from "zodvex";
|
|
239
|
+
const { register, handleSubmit, formState: { errors } } = useForm<CreateUserForm>({
|
|
240
|
+
resolver: zodResolver(CreateUserForm)
|
|
241
|
+
})
|
|
182
242
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{ id: z.string() },
|
|
187
|
-
async (ctx, { id }) => ctx.db.get(id),
|
|
188
|
-
{
|
|
189
|
-
returns: z.object({
|
|
190
|
-
name: z.string(),
|
|
191
|
-
createdAt: z.date(),
|
|
192
|
-
}),
|
|
193
|
-
},
|
|
194
|
-
);
|
|
243
|
+
const onSubmit = async (data: CreateUserForm) => {
|
|
244
|
+
await createUser(data)
|
|
245
|
+
}
|
|
195
246
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
id: z.string(),
|
|
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
|
|
212
|
-
},
|
|
213
|
-
);
|
|
247
|
+
return (
|
|
248
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
249
|
+
<input {...register('name')} />
|
|
250
|
+
{errors.name && <span>{errors.name.message}</span>}
|
|
214
251
|
|
|
215
|
-
|
|
216
|
-
|
|
252
|
+
<input {...register('email')} />
|
|
253
|
+
{errors.email && <span>{errors.email.message}</span>}
|
|
217
254
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
255
|
+
<button type="submit">Create User</button>
|
|
256
|
+
</form>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
221
259
|
```
|
|
222
260
|
|
|
223
|
-
|
|
261
|
+
## API Reference
|
|
224
262
|
|
|
225
|
-
|
|
263
|
+
### Builders
|
|
226
264
|
|
|
265
|
+
**Basic builders** - Create type-safe functions without auth:
|
|
227
266
|
```ts
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
267
|
+
zQueryBuilder(query) // Creates query builder
|
|
268
|
+
zMutationBuilder(mutation) // Creates mutation builder
|
|
269
|
+
zActionBuilder(action) // Creates action builder
|
|
270
|
+
```
|
|
231
271
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
272
|
+
**Custom builders** - Add auth or custom context:
|
|
273
|
+
```ts
|
|
274
|
+
import { customCtx } from 'zodvex'
|
|
235
275
|
|
|
236
|
-
|
|
237
|
-
export const listUsers = zQuery(
|
|
276
|
+
const authQuery = zCustomQueryBuilder(
|
|
238
277
|
query,
|
|
239
|
-
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
278
|
+
customCtx(async (ctx) => {
|
|
279
|
+
const user = await getUserOrThrow(ctx)
|
|
280
|
+
return { user }
|
|
281
|
+
})
|
|
282
|
+
)
|
|
283
|
+
|
|
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
|
+
})
|
|
293
|
+
```
|
|
247
294
|
|
|
248
|
-
|
|
249
|
-
export const createdBounds = zQuery(
|
|
250
|
-
query,
|
|
251
|
-
{},
|
|
252
|
-
async (ctx) => {
|
|
253
|
-
const first = await ctx.db.query("users").order("asc").first();
|
|
254
|
-
const last = await ctx.db.query("users").order("desc").first();
|
|
255
|
-
return { first: first?.createdAt ?? null, last: last?.createdAt ?? null };
|
|
256
|
-
},
|
|
257
|
-
{ returns: z.object({ first: z.date().nullable(), last: z.date().nullable() }) }
|
|
258
|
-
);
|
|
295
|
+
### Mapping Helpers
|
|
259
296
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)
|
|
297
|
+
```ts
|
|
298
|
+
import { zodToConvex, zodToConvexFields } from 'zodvex'
|
|
299
|
+
|
|
300
|
+
// Convert single Zod type to Convex validator
|
|
301
|
+
const validator = zodToConvex(z.string().optional())
|
|
302
|
+
// → v.optional(v.string())
|
|
303
|
+
|
|
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()) }
|
|
270
310
|
```
|
|
271
311
|
|
|
272
312
|
### Codecs
|
|
@@ -274,228 +314,174 @@ export const listUsersOk = zQuery(
|
|
|
274
314
|
Convert between Zod-shaped data and Convex-safe JSON:
|
|
275
315
|
|
|
276
316
|
```ts
|
|
277
|
-
import { convexCodec } from
|
|
278
|
-
import { z } from "zod";
|
|
317
|
+
import { convexCodec } from 'zodvex'
|
|
279
318
|
|
|
280
319
|
const UserSchema = z.object({
|
|
281
320
|
name: z.string(),
|
|
282
|
-
birthday: z.date().optional()
|
|
283
|
-
|
|
284
|
-
});
|
|
321
|
+
birthday: z.date().optional()
|
|
322
|
+
})
|
|
285
323
|
|
|
286
|
-
const codec = convexCodec(UserSchema)
|
|
324
|
+
const codec = convexCodec(UserSchema)
|
|
287
325
|
|
|
288
|
-
//
|
|
289
|
-
const validators = codec.toConvexSchema();
|
|
290
|
-
|
|
291
|
-
// Encode: Zod data → Convex JSON (Date → timestamp, omit undefined)
|
|
326
|
+
// Encode: Date → timestamp, omit undefined
|
|
292
327
|
const encoded = codec.encode({
|
|
293
|
-
name:
|
|
294
|
-
birthday: new Date(
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// → { name: 'Alice', birthday: Date('1990-01-01'), metadata: { role: 'admin' } }
|
|
302
|
-
|
|
303
|
-
// Create sub-codecs (ZodObject only)
|
|
304
|
-
const nameCodec = codec.pick({ name: true });
|
|
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') }
|
|
305
336
|
```
|
|
306
337
|
|
|
307
|
-
###
|
|
338
|
+
### Supported Types
|
|
308
339
|
|
|
309
|
-
|
|
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())` |
|
|
310
356
|
|
|
357
|
+
**Convex IDs:**
|
|
311
358
|
```ts
|
|
312
|
-
import {
|
|
313
|
-
import { zodTable, zid } from "zodvex";
|
|
314
|
-
import { defineSchema } from "convex/server";
|
|
315
|
-
|
|
316
|
-
// Define your schema as a plain object
|
|
317
|
-
const postShape = {
|
|
318
|
-
title: z.string(),
|
|
319
|
-
content: z.string(),
|
|
320
|
-
authorId: zid("users"), // Convex ID reference
|
|
321
|
-
published: z.boolean().default(false),
|
|
322
|
-
tags: z.array(z.string()).optional(),
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
// Create table helper - pass the plain object shape
|
|
326
|
-
export const Posts = zodTable("posts", postShape);
|
|
327
|
-
|
|
328
|
-
// Access properties
|
|
329
|
-
Posts.table; // → Table definition for defineSchema
|
|
330
|
-
Posts.shape; // → Original plain object shape
|
|
331
|
-
Posts.zDoc; // → ZodObject with _id and _creationTime system fields
|
|
359
|
+
import { zid } from 'zodvex'
|
|
332
360
|
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
posts: Posts.table
|
|
336
|
-
.index("by_author", ["authorId"])
|
|
337
|
-
.index("by_published", ["published"]),
|
|
338
|
-
});
|
|
361
|
+
zid('tableName') // → v.id('tableName')
|
|
362
|
+
zid('tableName').optional() // → v.optional(v.id('tableName'))
|
|
339
363
|
```
|
|
340
364
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
For large schemas (30+ fields), the plain object pattern is recommended:
|
|
344
|
-
|
|
345
|
-
```ts
|
|
346
|
-
// Define as plain object for better maintainability
|
|
347
|
-
export const users = {
|
|
348
|
-
// Meta
|
|
349
|
-
tokenId: z.string(),
|
|
350
|
-
type: z.literal("member"),
|
|
351
|
-
isAdmin: z.boolean(),
|
|
352
|
-
|
|
353
|
-
// User info
|
|
354
|
-
email: z.string(),
|
|
355
|
-
firstName: z.string().optional(),
|
|
356
|
-
lastName: z.string().optional(),
|
|
357
|
-
phone: z.string().optional(),
|
|
358
|
-
|
|
359
|
-
// References
|
|
360
|
-
favoriteUsers: z.array(zid("users")).optional(),
|
|
361
|
-
activeDancerId: zid("dancers").optional(),
|
|
362
|
-
// ... 40+ more fields
|
|
363
|
-
};
|
|
364
|
-
|
|
365
|
-
// Create table
|
|
366
|
-
export const Users = zodTable("users", users);
|
|
367
|
-
|
|
368
|
-
// Extract subsets without Zod's .pick() to avoid type complexity
|
|
369
|
-
const zClerkFields = z.object(pickShape(users, ["email", "firstName", "lastName", "tokenId"]));
|
|
370
|
-
// Use zClerkFields in function wrappers or validators
|
|
371
|
-
```
|
|
365
|
+
## Advanced Usage
|
|
372
366
|
|
|
373
|
-
|
|
367
|
+
### Custom Context Builders
|
|
374
368
|
|
|
375
|
-
|
|
369
|
+
Create builders with injected auth, permissions, or other context:
|
|
376
370
|
|
|
377
371
|
```ts
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
content: z.string(),
|
|
381
|
-
authorId: zid("users"),
|
|
382
|
-
});
|
|
372
|
+
import { zCustomQueryBuilder, zCustomMutationBuilder, customCtx } from 'zodvex'
|
|
373
|
+
import { query, mutation } from './_generated/server'
|
|
383
374
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
375
|
+
// Add user to all queries
|
|
376
|
+
export const authQuery = zCustomQueryBuilder(
|
|
377
|
+
query,
|
|
378
|
+
customCtx(async (ctx) => {
|
|
379
|
+
const user = await getUserOrThrow(ctx)
|
|
380
|
+
return { user }
|
|
381
|
+
})
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
// Add user + permissions to mutations
|
|
385
|
+
export const authMutation = zCustomMutationBuilder(
|
|
386
|
+
mutation,
|
|
387
|
+
customCtx(async (ctx) => {
|
|
388
|
+
const user = await getUserOrThrow(ctx)
|
|
389
|
+
const permissions = await getPermissions(ctx, user)
|
|
390
|
+
return { user, permissions }
|
|
391
|
+
})
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
// Use them
|
|
395
|
+
export const updateProfile = authMutation({
|
|
396
|
+
args: { name: z.string() },
|
|
397
|
+
returns: z.null(),
|
|
398
|
+
handler: async (ctx, { name }) => {
|
|
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
|
|
405
|
+
}
|
|
406
|
+
})
|
|
391
407
|
```
|
|
392
408
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
### Custom Validators with Zod
|
|
396
|
-
|
|
397
|
-
```ts
|
|
398
|
-
import { z } from "zod";
|
|
399
|
-
import { zCustomQuery } from "zodvex";
|
|
400
|
-
import { customQuery } from "convex-helpers/server/customFunctions";
|
|
401
|
-
|
|
402
|
-
// Use with custom function builders
|
|
403
|
-
export const authenticatedQuery = zCustomQuery(
|
|
404
|
-
customQuery(query, {
|
|
405
|
-
args: { sessionId: v.string() },
|
|
406
|
-
input: async (ctx, { sessionId }) => {
|
|
407
|
-
const user = await getUser(ctx, sessionId);
|
|
408
|
-
return { user };
|
|
409
|
-
},
|
|
410
|
-
}),
|
|
411
|
-
{ postId: z.string() },
|
|
412
|
-
async (ctx, { postId }) => {
|
|
413
|
-
// ctx.user is available from custom input
|
|
414
|
-
return ctx.db.get(postId);
|
|
415
|
-
},
|
|
416
|
-
);
|
|
417
|
-
```
|
|
409
|
+
### Date Handling
|
|
418
410
|
|
|
419
|
-
|
|
411
|
+
Dates are automatically converted to timestamps:
|
|
420
412
|
|
|
421
413
|
```ts
|
|
422
|
-
// Dates are automatically converted to timestamps
|
|
423
414
|
const eventShape = {
|
|
424
415
|
title: z.string(),
|
|
425
416
|
startDate: z.date(),
|
|
426
|
-
endDate: z.date().nullable()
|
|
427
|
-
}
|
|
417
|
+
endDate: z.date().nullable()
|
|
418
|
+
}
|
|
428
419
|
|
|
429
|
-
const Events = zodTable(
|
|
420
|
+
export const Events = zodTable('events', eventShape)
|
|
430
421
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
z.object(eventShape),
|
|
435
|
-
async (ctx, event) => {
|
|
422
|
+
export const createEvent = zm({
|
|
423
|
+
args: eventShape,
|
|
424
|
+
handler: async (ctx, event) => {
|
|
436
425
|
// event.startDate is a Date object
|
|
437
|
-
//
|
|
438
|
-
return ctx.db.insert(
|
|
439
|
-
}
|
|
440
|
-
)
|
|
426
|
+
// Automatically converted to timestamp for storage
|
|
427
|
+
return await ctx.db.insert('events', event)
|
|
428
|
+
}
|
|
429
|
+
})
|
|
441
430
|
```
|
|
442
431
|
|
|
443
|
-
###
|
|
432
|
+
### Return Type Helpers
|
|
444
433
|
|
|
445
434
|
```ts
|
|
446
|
-
import {
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
+
})
|
|
454
446
|
```
|
|
455
447
|
|
|
456
|
-
|
|
448
|
+
### Working with Large Schemas
|
|
457
449
|
|
|
458
|
-
|
|
450
|
+
zodvex provides `pickShape` and `safePick` helpers as alternatives to Zod's `.pick()`:
|
|
459
451
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
| `z.string()` | `v.string()` |
|
|
463
|
-
| `z.number()` | `v.float64()` |
|
|
464
|
-
| `z.bigint()` | `v.int64()` |
|
|
465
|
-
| `z.boolean()` | `v.boolean()` |
|
|
466
|
-
| `z.date()` | `v.float64()` (timestamp) |
|
|
467
|
-
| `z.null()` | `v.null()` |
|
|
468
|
-
| `z.array(T)` | `v.array(T)` |
|
|
469
|
-
| `z.object({...})` | `v.object({...})` |
|
|
470
|
-
| `z.record(T)` | `v.record(v.string(), T)` |
|
|
471
|
-
| `z.union([...])` | `v.union(...)` |
|
|
472
|
-
| `z.literal(x)` | `v.literal(x)` |
|
|
473
|
-
| `z.enum([...])` | `v.union(literals...)` |
|
|
474
|
-
| `z.optional(T)` | `v.optional(T)` |
|
|
475
|
-
| `z.nullable(T)` | `v.union(T, v.null())` |
|
|
476
|
-
|
|
477
|
-
### Important Notes
|
|
452
|
+
```ts
|
|
453
|
+
import { pickShape, safePick } from 'zodvex'
|
|
478
454
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
- **Transforms**: Zod transforms (`.transform()`, `.refine()`) are not supported in schema mapping and fall back to `v.any()`.
|
|
482
|
-
- **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 })
|
|
483
457
|
|
|
484
|
-
|
|
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)
|
|
485
462
|
|
|
486
|
-
|
|
463
|
+
// Or use safePick (convenience wrapper that does the same thing)
|
|
464
|
+
const UserUpdate = safePick(User, { email: true, firstName: true, lastName: true })
|
|
465
|
+
```
|
|
487
466
|
|
|
488
|
-
##
|
|
467
|
+
## Why zodvex?
|
|
489
468
|
|
|
490
|
-
- **
|
|
491
|
-
-
|
|
492
|
-
-
|
|
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
|
|
493
476
|
|
|
494
|
-
##
|
|
477
|
+
## Compatibility
|
|
495
478
|
|
|
496
|
-
|
|
479
|
+
- **Zod**: ^4.1.0 or later
|
|
480
|
+
- **Convex**: >= 1.27.0
|
|
481
|
+
- **convex-helpers**: >= 0.1.104
|
|
482
|
+
- **TypeScript**: Full type inference support
|
|
497
483
|
|
|
498
|
-
##
|
|
484
|
+
## License
|
|
499
485
|
|
|
500
486
|
MIT
|
|
501
487
|
|