convex-verify 1.0.5 → 1.1.0

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 CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  Type-safe verification and validation for Convex database operations.
4
4
 
5
+
5
6
  ## Features
6
7
 
7
8
  - **Type-safe insert/patch** - Full TypeScript inference for your schema
8
9
  - **Default values** - Make fields optional in `insert()` with automatic defaults
9
10
  - **Protected columns** - Prevent accidental updates to critical fields in `patch()`
10
- - **Validation plugins** - Unique row/column enforcement, custom validators
11
- - **Extensible** - Create your own validation plugins
11
+ - **Unique constraints** - Enforce unique rows and columns using your indexes
12
+ - **Extensible** - Create your own validation extensions
12
13
 
13
14
  ## Installation
14
15
 
@@ -18,7 +19,7 @@ pnpm install convex-verify
18
19
 
19
20
  **Peer Dependencies:**
20
21
 
21
- - `convex` >= 1.31.3
22
+ - `convex` >= 1.34.1
22
23
 
23
24
  ## Quick Start
24
25
 
@@ -34,28 +35,21 @@ import {
34
35
  import schema from "./schema";
35
36
 
36
37
  export const { insert, patch, dangerouslyPatch } = verifyConfig(schema, {
37
- // Make fields optional with defaults
38
- defaultValues: defaultValuesConfig(schema, () => ({
38
+ defaultValues: defaultValuesConfig(schema, {
39
39
  posts: { status: "draft", views: 0 },
40
- })),
40
+ }),
41
41
 
42
- // Prevent patching critical fields
43
42
  protectedColumns: protectedColumnsConfig(schema, {
44
43
  posts: ["authorId"],
45
44
  }),
46
45
 
47
- // Enforce unique row combinations
48
46
  uniqueRow: uniqueRowConfig(schema, {
49
47
  posts: ["by_author_slug"],
50
48
  }),
51
49
 
52
- // Enforce unique column values
53
50
  uniqueColumn: uniqueColumnConfig(schema, {
54
51
  users: ["by_email", "by_username"],
55
52
  }),
56
-
57
- // Custom/third-party plugins (optional)
58
- plugins: [],
59
53
  });
60
54
  ```
61
55
 
@@ -67,7 +61,7 @@ import { insert, patch } from "./verify";
67
61
  export const createPost = mutation({
68
62
  args: { title: v.string(), content: v.string() },
69
63
  handler: async (ctx, args) => {
70
- // status and views are optional - defaults are applied
64
+ // status and views are optional since defaults have been set
71
65
  return await insert(ctx, "posts", {
72
66
  title: args.title,
73
67
  content: args.content,
@@ -82,6 +76,7 @@ export const updatePost = mutation({
82
76
  // authorId is protected - TypeScript won't allow it here
83
77
  await patch(ctx, "posts", args.id, {
84
78
  title: args.title,
79
+ // authorId: "someone_else", // TypeScript error!
85
80
  });
86
81
  },
87
82
  });
@@ -96,17 +91,12 @@ export const updatePost = mutation({
96
91
  Main configuration function that returns typed `insert`, `patch`, and `dangerouslyPatch` functions.
97
92
 
98
93
  ```ts
99
- const { insert, patch, dangerouslyPatch, configs } = verifyConfig(schema, {
100
- // Type-affecting configs
94
+ const { insert, patch, dangerouslyPatch } = verifyConfig(schema, {
101
95
  defaultValues?: DefaultValuesConfig,
102
96
  protectedColumns?: ProtectedColumnsConfig,
103
-
104
- // Built-in validation configs
105
97
  uniqueRow?: UniqueRowConfig,
106
98
  uniqueColumn?: UniqueColumnConfig,
107
-
108
- // Custom/third-party plugins
109
- plugins?: ValidatePlugin[],
99
+ extensions?: Extension[],
110
100
  });
111
101
  ```
112
102
 
@@ -114,42 +104,35 @@ const { insert, patch, dangerouslyPatch, configs } = verifyConfig(schema, {
114
104
 
115
105
  | Function | Description |
116
106
  | ------------------ | ----------------------------------------------------------------------------------- |
117
- | `insert` | Insert with default values applied and validation plugins run |
118
- | `patch` | Patch with protected columns removed from type and validation plugins run |
107
+ | `insert` | Insert with default values applied and extensions run |
108
+ | `patch` | Patch with protected columns removed and extensions run |
119
109
  | `dangerouslyPatch` | Patch with full access to all columns (bypasses protected columns type restriction) |
120
- | `configs` | The original config object (for debugging) |
121
110
 
122
111
  ---
123
112
 
124
- ## Transforms
113
+ ## `defaultValuesConfig`
125
114
 
126
- Transforms modify the input type of `insert()`.
127
-
128
- ### `defaultValuesConfig(schema, config)`
129
-
130
- Makes specified fields optional in `insert()` by providing default values.
115
+ Makes specified fields optional in `insert()` by providing default values. The types update automatically - fields with defaults become optional.
131
116
 
132
117
  ```ts
133
118
  import { defaultValuesConfig } from "convex-verify";
134
- // or
135
- import { defaultValuesConfig } from "convex-verify/transforms";
136
119
  ```
137
120
 
138
- #### Static Config
121
+ ### Static Values
139
122
 
140
123
  ```ts
141
- const defaults = defaultValuesConfig(schema, {
124
+ const config = defaultValuesConfig(schema, {
142
125
  posts: { status: "draft", views: 0 },
143
126
  comments: { likes: 0 },
144
127
  });
145
128
  ```
146
129
 
147
- #### Dynamic Config (Fresh Values)
130
+ ### Dynamic Values
148
131
 
149
- Use a function for values that should be generated fresh on each insert:
132
+ Use a function when values should be generated fresh on each insert:
150
133
 
151
134
  ```ts
152
- const defaults = defaultValuesConfig(schema, () => ({
135
+ const config = defaultValuesConfig(schema, () => ({
153
136
  posts: {
154
137
  status: "draft",
155
138
  slug: generateRandomSlug(),
@@ -158,10 +141,10 @@ const defaults = defaultValuesConfig(schema, () => ({
158
141
  }));
159
142
  ```
160
143
 
161
- #### Async Config
144
+ ### Async Values
162
145
 
163
146
  ```ts
164
- const defaults = defaultValuesConfig(schema, async () => ({
147
+ const config = defaultValuesConfig(schema, async () => ({
165
148
  posts: {
166
149
  category: await fetchDefaultCategory(),
167
150
  },
@@ -170,249 +153,234 @@ const defaults = defaultValuesConfig(schema, async () => ({
170
153
 
171
154
  ---
172
155
 
173
- ## Configs
174
-
175
- Configs modify the input type of `patch()`.
176
-
177
- ### `protectedColumnsConfig(schema, config)`
156
+ ## `protectedColumnsConfig`
178
157
 
179
- Removes specified columns from the `patch()` input type, preventing accidental updates.
158
+ Removes specified columns from the `patch()` input type, preventing accidental updates to critical fields like `authorId` or `createdAt`.
180
159
 
181
160
  ```ts
182
161
  import { protectedColumnsConfig } from "convex-verify";
183
- // or
184
- import { protectedColumnsConfig } from "convex-verify/configs";
185
162
  ```
186
163
 
187
- #### Example
164
+ ### Usage
188
165
 
189
166
  ```ts
190
- const protected = protectedColumnsConfig(schema, {
167
+ const config = protectedColumnsConfig(schema, {
191
168
  posts: ["authorId", "createdAt"],
192
169
  comments: ["postId", "authorId"],
193
170
  });
194
171
  ```
195
172
 
196
- #### Bypassing Protection
173
+ ### Bypassing Protection
197
174
 
198
- Use `dangerouslyPatch()` when you need to update protected columns:
175
+ Use `dangerouslyPatch()` when you legitimately need to update protected columns:
199
176
 
200
177
  ```ts
201
178
  // Regular patch - authorId not allowed
202
179
  await patch(ctx, "posts", id, {
203
- authorId: newAuthorId, // TypeScript error
204
- title: "New Title", // OK
180
+ authorId: newAuthorId, // TypeScript error!
181
+ title: "New Title", // OK
205
182
  });
206
183
 
207
184
  // Dangerous patch - full access
208
185
  await dangerouslyPatch(ctx, "posts", id, {
209
- authorId: newAuthorId, // OK (bypasses protection)
186
+ authorId: newAuthorId, // OK (bypasses type restriction)
210
187
  title: "New Title",
211
188
  });
212
189
  ```
213
190
 
214
- **Note:** `dangerouslyPatch()` still runs validation plugins - only the type restriction is bypassed.
191
+ **Note:** `dangerouslyPatch()` still runs validation extensions - only the type restriction is bypassed.
215
192
 
216
193
  ---
217
194
 
218
- ## Validation
195
+ ## `uniqueRowConfig`
219
196
 
220
- Validation configs check data during `insert()` and `patch()` operations. They run after transforms and can throw errors to prevent the operation.
221
-
222
- ### `uniqueRowConfig(schema, config)`
223
-
224
- Enforces uniqueness across multiple columns using composite indexes.
197
+ Enforces uniqueness across multiple columns using composite indexes. Useful for things like "unique slug per author" or "unique name per organization".
225
198
 
226
199
  ```ts
227
200
  import { uniqueRowConfig } from "convex-verify";
228
- // or
229
- import { uniqueRowConfig } from "convex-verify/plugins";
230
- ```
231
-
232
- #### Usage
233
-
234
- ```ts
235
- // As a named config key (recommended)
236
- verifyConfig(schema, {
237
- uniqueRow: uniqueRowConfig(schema, {
238
- posts: ["by_author_slug"],
239
- }),
240
- });
241
-
242
- // Or in the plugins array
243
- verifyConfig(schema, {
244
- plugins: [
245
- uniqueRowConfig(schema, {
246
- posts: ["by_author_slug"],
247
- }),
248
- ],
249
- });
250
201
  ```
251
202
 
252
- #### Shorthand (Index Names)
203
+ ### Usage
253
204
 
254
205
  ```ts
255
- const uniqueRows = uniqueRowConfig(schema, {
256
- posts: ["by_author_slug"], // Unique author + slug combo
257
- projects: ["by_org_slug"], // Unique org + slug combo
206
+ const config = uniqueRowConfig(schema, {
207
+ posts: ["by_author_slug"], // Unique author + slug combination
208
+ projects: ["by_org_name"], // Unique org + name combination
258
209
  });
259
210
  ```
260
211
 
261
- #### With Options
212
+ ### With Options
262
213
 
263
214
  ```ts
264
- const uniqueRows = uniqueRowConfig(schema, {
215
+ const config = uniqueRowConfig(schema, {
265
216
  posts: [
266
217
  {
267
218
  index: "by_author_slug",
268
- identifiers: ["_id", "authorId"], // Fields to check for "same document"
219
+ identifiers: ["_id", "authorId"], // Fields that identify "same document"
269
220
  },
270
221
  ],
271
222
  });
272
223
  ```
273
224
 
274
- ### `uniqueColumnConfig(schema, config)`
225
+ The `identifiers` option controls which fields are checked when determining if a conflicting row is actually the same document (useful during patch operations).
226
+
227
+ ---
228
+
229
+ ## `uniqueColumnConfig`
275
230
 
276
- Enforces uniqueness on single columns using indexes.
231
+ Enforces uniqueness on single columns using indexes. Useful for email addresses, usernames, slugs, etc.
277
232
 
278
233
  ```ts
279
234
  import { uniqueColumnConfig } from "convex-verify";
280
- // or
281
- import { uniqueColumnConfig } from "convex-verify/plugins";
282
235
  ```
283
236
 
284
- #### Usage
237
+ ### Usage
285
238
 
286
239
  ```ts
287
- // As a named config key (recommended)
288
- verifyConfig(schema, {
289
- uniqueColumn: uniqueColumnConfig(schema, {
290
- users: ["by_email", "by_username"],
291
- }),
292
- });
293
-
294
- // Or in the plugins array
295
- verifyConfig(schema, {
296
- plugins: [
297
- uniqueColumnConfig(schema, {
298
- users: ["by_email", "by_username"],
299
- }),
300
- ],
240
+ const config = uniqueColumnConfig(schema, {
241
+ users: ["by_email", "by_username"],
242
+ organizations: ["by_slug"],
301
243
  });
302
244
  ```
303
245
 
304
- The column name is derived from the index name by removing `by_` prefix:
246
+ The column name is derived from the index name by removing the `by_` prefix:
305
247
 
306
248
  - `by_username` → checks `username` column
307
249
  - `by_email` → checks `email` column
308
250
 
309
- #### Shorthand (Index Names)
251
+ ### With Options
310
252
 
311
253
  ```ts
312
- const uniqueColumns = uniqueColumnConfig(schema, {
313
- users: ["by_username", "by_email"],
314
- organizations: ["by_slug"],
315
- });
316
- ```
317
-
318
- #### With Options
319
-
320
- ```ts
321
- const uniqueColumns = uniqueColumnConfig(schema, {
254
+ const config = uniqueColumnConfig(schema, {
322
255
  users: [
323
- "by_username", // shorthand
324
- { index: "by_email", identifiers: ["_id", "clerkId"] }, // with options
256
+ "by_username",
257
+ { index: "by_email", identifiers: ["_id", "clerkId"] },
325
258
  ],
326
259
  });
327
260
  ```
328
261
 
329
- ## Custom Plugins
262
+ ---
330
263
 
331
- The `plugins` array accepts custom validation plugins for extensibility.
264
+ ## Custom Extensions
332
265
 
333
- ### `createValidatePlugin(name, config, handlers)`
266
+ Custom extensions let you add your own validation and transformation logic that runs during `insert()` and `patch()` operations.
334
267
 
335
- Create custom validation plugins.
268
+ ### Use Cases
336
269
 
337
- ```ts
338
- import { createValidatePlugin } from "convex-verify";
339
- // or
340
- import { createValidatePlugin } from "convex-verify/core";
341
- ```
270
+ - **Authorization checks** - Verify the user has permission to create/modify a document
271
+ - **Data validation** - Check that values meet business rules (e.g., positive numbers, valid URLs)
272
+ - **Cross-field validation** - Ensure fields are consistent with each other
273
+ - **Normalization / sanitization** - Lowercase emails, trim slugs, clean incoming strings
274
+ - **External validation** - Check against external APIs or services
275
+ - **Audit logging** - Log operations before they complete
342
276
 
343
- #### Example: Required Fields Plugin
277
+ ### Limitations
344
278
 
345
- ```ts
346
- const requiredFields = createValidatePlugin(
347
- "requiredFields",
348
- { fields: ["title", "content"] },
349
- {
350
- insert: (context, data) => {
351
- for (const field of context.config.fields) {
352
- if (!data[field]) {
353
- throw new ConvexError({
354
- message: `Missing required field: ${field}`,
355
- });
356
- }
357
- }
358
- return data;
359
- },
360
- },
361
- );
362
- ```
279
+ - Extensions run **after** type-affecting configs (like `defaultValues`) have been applied
280
+ - Extensions **cannot modify types** - they can change runtime data, but not the TypeScript types
281
+ - Extensions may **return modified data** - use this to sanitize, normalize, or enrich payloads
282
+ - Custom extensions from `extensions: []` run **before** built-in `uniqueRow` / `uniqueColumn` configs
283
+ - `patch()` still strips protected columns at runtime; use `dangerouslyPatch()` if an extension must change them
284
+ - Extension errors should use `ConvexError` for proper error handling on the client
363
285
 
364
- #### Example: Async Validation
286
+ ### Creating an Extension
287
+
288
+ Use `createExtension`. If you want schema-aware typing in the callback, pass your schema type as the generic:
365
289
 
366
290
  ```ts
367
- const checkOwnership = createValidatePlugin(
368
- "checkOwnership",
369
- {},
370
- {
371
- patch: async (context, data) => {
372
- const existing = await context.ctx.db.get(context.patchId);
373
- const user = await getCurrentUser(context.ctx);
374
-
375
- if (existing?.authorId !== user._id) {
376
- throw new ConvexError({
377
- message: "Not authorized to edit this document",
378
- });
379
- }
380
- return data;
381
- },
382
- },
383
- );
291
+ import { createExtension } from "convex-verify";
292
+ import { ConvexError } from "convex/values";
293
+
294
+ const normalizeEmail = createExtension<typeof schema>((input) => {
295
+ if (input.tableName !== "users") {
296
+ return input.data;
297
+ }
298
+
299
+ if (input.operation === "insert") {
300
+ return {
301
+ ...input.data,
302
+ email: input.data.email.toLowerCase().trim(),
303
+ };
304
+ }
305
+
306
+ return {
307
+ ...input.data,
308
+ ...(input.data.email !== undefined && {
309
+ email: input.data.email.toLowerCase().trim(),
310
+ }),
311
+ };
312
+ });
384
313
  ```
385
314
 
386
- #### Plugin Context
315
+ Use a single `input` parameter instead of destructuring when you want narrowing.
316
+ That lets TypeScript narrow `data` from both `tableName` and `operation`.
317
+
318
+ ### Extension Context
387
319
 
388
- Plugins receive a `ValidateContext` object:
320
+ Your extension function receives:
389
321
 
390
322
  ```ts
391
- type ValidateContext = {
392
- ctx: GenericMutationCtx; // Convex mutation context
323
+ type ExtensionInput = {
324
+ ctx: GenericMutationCtx; // Convex mutation context (has ctx.db, etc.)
393
325
  tableName: string; // Table being operated on
394
326
  operation: "insert" | "patch";
395
327
  patchId?: GenericId; // Document ID (patch only)
396
- onFail?: OnFailCallback; // Callback for failure details
397
- schema?: SchemaDefinition; // Schema reference
328
+ schema: SchemaDefinition; // Schema reference
329
+ data: unknown;
398
330
  };
399
331
  ```
400
332
 
401
- ---
333
+ ### Example: Required Fields
402
334
 
403
- ## Subpath Imports
335
+ ```ts
336
+ const requiredFields = createExtension<typeof schema>((input) => {
337
+ if (input.tableName !== "posts" || input.operation === "patch") {
338
+ return input.data;
339
+ }
340
+
341
+ for (const field of ["title", "content"]) {
342
+ if (!input.data[field]) {
343
+ throw new ConvexError({
344
+ code: "VALIDATION_ERROR",
345
+ message: `Missing required field: ${field}`,
346
+ });
347
+ }
348
+ }
404
349
 
405
- For smaller bundle sizes, you can import from specific subpaths:
350
+ return input.data;
351
+ });
352
+ ```
353
+
354
+ ### Example: Async Authorization Check
406
355
 
407
356
  ```ts
408
- // Import everything from root
409
- import { uniqueRowConfig, verifyConfig } from "convex-verify";
410
- import { protectedColumnsConfig } from "convex-verify/configs";
411
- // Or import from specific subpaths
412
- import { createValidatePlugin, verifyConfig } from "convex-verify/core";
413
- import { uniqueColumnConfig, uniqueRowConfig } from "convex-verify/plugins";
414
- import { defaultValuesConfig } from "convex-verify/transforms";
415
- import { getTableIndexes } from "convex-verify/utils";
357
+ const ownership = createExtension<typeof schema>(async (input) => {
358
+ if (input.tableName !== "posts" || input.operation !== "patch") {
359
+ return input.data;
360
+ }
361
+
362
+ const doc = await input.ctx.db.get(input.patchId);
363
+ const identity = await input.ctx.auth.getUserIdentity();
364
+
365
+ if (doc?.ownerId !== identity?.subject) {
366
+ throw new ConvexError({
367
+ code: "UNAUTHORIZED",
368
+ message: "You don't have permission to edit this document",
369
+ });
370
+ }
371
+
372
+ return input.data;
373
+ });
374
+ ```
375
+
376
+ ### Using Custom Extensions
377
+
378
+ Add extensions to the `extensions` array in your config:
379
+
380
+ ```ts
381
+ const { insert, patch } = verifyConfig(schema, {
382
+ extensions: [requiredFields, ownership],
383
+ });
416
384
  ```
417
385
 
418
386
  ---
@@ -421,7 +389,7 @@ import { getTableIndexes } from "convex-verify/utils";
421
389
 
422
390
  ### `onFail` Callback
423
391
 
424
- All operations accept an optional `onFail` callback for handling validation failures:
392
+ Operations accept an optional `onFail` callback for handling validation failures:
425
393
 
426
394
  ```ts
427
395
  await insert(ctx, "posts", data, {
@@ -438,7 +406,7 @@ await insert(ctx, "posts", data, {
438
406
 
439
407
  ### Error Types
440
408
 
441
- Validation plugins throw `ConvexError` with specific codes:
409
+ Built-in validation extensions throw `ConvexError` with these codes:
442
410
 
443
411
  - `UNIQUE_ROW_VERIFICATION_ERROR` - Duplicate row detected
444
412
  - `UNIQUE_COLUMN_VERIFICATION_ERROR` - Duplicate column value detected
@@ -447,11 +415,11 @@ Validation plugins throw `ConvexError` with specific codes:
447
415
 
448
416
  ## TypeScript
449
417
 
450
- This library is written in TypeScript and provides full type inference:
418
+ This library provides full type inference:
451
419
 
452
420
  - `insert()` types reflect optional fields from `defaultValues`
453
421
  - `patch()` types exclude protected columns
454
- - Plugin configs are type-checked against your schema
422
+ - All configs are type-checked against your schema
455
423
  - Index names are validated against your schema's indexes
456
424
 
457
425
  ---
@@ -1,5 +1,5 @@
1
1
  import { SchemaDefinition, GenericSchema, DataModelFromSchemaDefinition, WithoutSystemFields } from 'convex/server';
2
- import { k as DMGeneric } from '../types-_64SXyva.mjs';
2
+ import { k as DMGeneric } from '../types-DvJMYubf.mjs';
3
3
 
4
4
  /**
5
5
  * Config data type for protected columns.
@@ -1,5 +1,5 @@
1
1
  import { SchemaDefinition, GenericSchema, DataModelFromSchemaDefinition, WithoutSystemFields } from 'convex/server';
2
- import { k as DMGeneric } from '../types-_64SXyva.js';
2
+ import { k as DMGeneric } from '../types-DvJMYubf.js';
3
3
 
4
4
  /**
5
5
  * Config data type for protected columns.