zodvex 0.2.4 → 0.3.1

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,16 +1,8 @@
1
1
  {
2
2
  "name": "zodvex",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Zod <=> Convex integration, supporting Zod 4",
5
- "keywords": [
6
- "zod",
7
- "convex",
8
- "validators",
9
- "codec",
10
- "mapping",
11
- "schema",
12
- "validation"
13
- ],
5
+ "keywords": ["zod", "convex", "validators", "codec", "mapping", "schema", "validation"],
14
6
  "homepage": "https://github.com/panzacoder/zodvex#readme",
15
7
  "bugs": {
16
8
  "url": "https://github.com/panzacoder/zodvex/issues"
@@ -21,24 +13,22 @@
21
13
  },
22
14
  "license": "MIT",
23
15
  "author": "Jake Hebert",
16
+ "type": "module",
24
17
  "main": "./dist/index.js",
25
- "module": "./dist/index.js",
26
18
  "types": "./dist/index.d.ts",
27
- "files": [
28
- "dist",
29
- "src",
30
- "README.md",
31
- "LICENSE"
32
- ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js",
23
+ "default": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": ["dist", "src", "README.md", "LICENSE"],
33
27
  "scripts": {
34
28
  "build": "tsup",
35
29
  "dev": "bun run tsup --watch",
36
30
  "type-check": "bun run tsc --noEmit",
37
31
  "test": "bun test",
38
- "test:vitest": "bun run vitest",
39
- "test:types": "bun run vitest --typecheck.only",
40
- "test:all": "bun test && bun run test:types",
41
- "test:coverage": "bun run vitest run --coverage",
42
32
  "lint": "bun x biome check src __tests__",
43
33
  "lint:fix": "bun x biome check --write src __tests__",
44
34
  "format": "bun x biome format --write src __tests__",
@@ -54,10 +44,9 @@
54
44
  "@biomejs/biome": "^2.2.6",
55
45
  "@types/bun": "^1.3.0",
56
46
  "@types/node": "^24.8.0",
57
- "@vitest/coverage-v8": "^3.2.4",
47
+ "ai": "^6.0.6",
58
48
  "tsup": "^8.5.0",
59
- "typescript": "^5.9.3",
60
- "vitest": "^3.2.4"
49
+ "typescript": "^5.9.3"
61
50
  },
62
51
  "engines": {
63
52
  "node": ">=20",
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Compile-time type tests for InferReturns (Issue #19)
3
+ *
4
+ * These assertions cause TypeScript errors if types don't match expectations.
5
+ * This file is type-checked but not included in the bundle.
6
+ */
7
+ import { z } from 'zod'
8
+ import type { InferReturns } from '../types'
9
+
10
+ // Test helper: causes TS error if assigned `any`
11
+ declare function expectNotAny<T>(value: 0 extends 1 & T ? never : T): void
12
+
13
+ // --- Simple schemas should infer correctly ---
14
+
15
+ declare const stringResult: InferReturns<z.ZodString>
16
+ expectNotAny(stringResult)
17
+
18
+ declare const objectResult: InferReturns<ReturnType<typeof z.object<{ name: z.ZodString }>>>
19
+ expectNotAny(objectResult)
20
+
21
+ // --- Union schemas: this is the Issue #19 bug ---
22
+ // If InferReturns bails to `any` for unions, these will fail
23
+
24
+ declare const unionSchema: z.ZodUnion<[z.ZodString, z.ZodNumber]>
25
+ declare const unionResult: InferReturns<typeof unionSchema>
26
+ // @ts-expect-error - If this errors with "unused directive", Result is `any` (the bug)
27
+ const _unionInvalid: typeof unionResult = { notStringOrNumber: true }
28
+
29
+ declare const literalUnionSchema: z.ZodUnion<[z.ZodLiteral<'a'>, z.ZodLiteral<'b'>]>
30
+ declare const literalUnionResult: InferReturns<typeof literalUnionSchema>
31
+ // @ts-expect-error - If this errors with "unused directive", Result is `any` (the bug)
32
+ const _literalInvalid: typeof literalUnionResult = { notAOrB: true }
33
+
34
+ // --- Complex/deeply nested schemas (stress tests for TS depth limits) ---
35
+ // These tests ensure removing bailouts doesn't cause "Type instantiation is
36
+ // excessively deep and possibly infinite" errors.
37
+
38
+ // Deeply nested object (5 levels)
39
+ const deeplyNestedSchema = z.object({
40
+ level1: z.object({
41
+ level2: z.object({
42
+ level3: z.object({
43
+ level4: z.object({
44
+ level5: z.object({
45
+ value: z.string()
46
+ })
47
+ })
48
+ })
49
+ })
50
+ })
51
+ })
52
+ declare const deeplyNestedResult: InferReturns<typeof deeplyNestedSchema>
53
+ expectNotAny(deeplyNestedResult)
54
+
55
+ // Large discriminated union (10 variants) - common in real apps
56
+ const largeDiscriminatedUnion = z.discriminatedUnion('type', [
57
+ z.object({ type: z.literal('variant1'), data: z.string() }),
58
+ z.object({ type: z.literal('variant2'), count: z.number() }),
59
+ z.object({ type: z.literal('variant3'), flag: z.boolean() }),
60
+ z.object({ type: z.literal('variant4'), items: z.array(z.string()) }),
61
+ z.object({ type: z.literal('variant5'), nested: z.object({ a: z.number() }) }),
62
+ z.object({ type: z.literal('variant6'), opt: z.string().optional() }),
63
+ z.object({ type: z.literal('variant7'), nullable: z.string().nullable() }),
64
+ z.object({ type: z.literal('variant8'), tuple: z.tuple([z.string(), z.number()]) }),
65
+ z.object({ type: z.literal('variant9'), record: z.record(z.string(), z.number()) }),
66
+ z.object({ type: z.literal('variant10'), union: z.union([z.string(), z.number()]) })
67
+ ])
68
+ declare const largeUnionResult: InferReturns<typeof largeDiscriminatedUnion>
69
+ expectNotAny(largeUnionResult)
70
+
71
+ // Nested unions (union containing objects with union fields)
72
+ const nestedUnionSchema = z.object({
73
+ outer: z.union([
74
+ z.object({
75
+ kind: z.literal('a'),
76
+ inner: z.union([z.literal('x'), z.literal('y')])
77
+ }),
78
+ z.object({
79
+ kind: z.literal('b'),
80
+ inner: z.union([z.literal('p'), z.literal('q')])
81
+ })
82
+ ])
83
+ })
84
+ declare const nestedUnionResult: InferReturns<typeof nestedUnionSchema>
85
+ expectNotAny(nestedUnionResult)
86
+
87
+ // Array of unions
88
+ const arrayOfUnionsSchema = z.array(
89
+ z.union([
90
+ z.object({ type: z.literal('item'), value: z.string() }),
91
+ z.object({ type: z.literal('group'), children: z.array(z.string()) })
92
+ ])
93
+ )
94
+ declare const arrayOfUnionsResult: InferReturns<typeof arrayOfUnionsSchema>
95
+ expectNotAny(arrayOfUnionsResult)
96
+
97
+ // Real-world pattern: API response with polymorphic data
98
+ const apiResponseSchema = z.object({
99
+ success: z.boolean(),
100
+ data: z
101
+ .union([
102
+ z.object({
103
+ type: z.literal('user'),
104
+ id: z.string(),
105
+ name: z.string(),
106
+ email: z.string().email(),
107
+ roles: z.array(z.enum(['admin', 'user', 'guest']))
108
+ }),
109
+ z.object({
110
+ type: z.literal('organization'),
111
+ id: z.string(),
112
+ name: z.string(),
113
+ members: z.array(
114
+ z.object({
115
+ userId: z.string(),
116
+ role: z.enum(['owner', 'admin', 'member'])
117
+ })
118
+ )
119
+ }),
120
+ z.object({
121
+ type: z.literal('error'),
122
+ code: z.number(),
123
+ message: z.string(),
124
+ details: z.record(z.string(), z.unknown()).optional()
125
+ })
126
+ ])
127
+ .nullable(),
128
+ meta: z.object({
129
+ timestamp: z.number(),
130
+ requestId: z.string()
131
+ })
132
+ })
133
+ declare const apiResponseResult: InferReturns<typeof apiResponseSchema>
134
+ expectNotAny(apiResponseResult)
135
+
136
+ // Recursive-like pattern (not truly recursive, but deep)
137
+ const treeNodeSchema = z.object({
138
+ id: z.string(),
139
+ value: z.union([z.string(), z.number(), z.boolean()]),
140
+ children: z.array(
141
+ z.object({
142
+ id: z.string(),
143
+ value: z.union([z.string(), z.number(), z.boolean()]),
144
+ children: z.array(
145
+ z.object({
146
+ id: z.string(),
147
+ value: z.union([z.string(), z.number(), z.boolean()])
148
+ })
149
+ )
150
+ })
151
+ )
152
+ })
153
+ declare const treeNodeResult: InferReturns<typeof treeNodeSchema>
154
+ expectNotAny(treeNodeResult)
package/src/custom.ts CHANGED
@@ -178,13 +178,15 @@ export function customFnBuilder<
178
178
 
179
179
  return function customBuilder(fn: any): any {
180
180
  const { args, handler = fn, returns: maybeObject, ...extra } = fn
181
+ const skipConvexValidation = fn.skipConvexValidation ?? false
181
182
 
182
183
  const returns =
183
184
  maybeObject && !(maybeObject instanceof z.ZodType) ? z.object(maybeObject) : maybeObject
185
+ // Only generate Convex return validator when not skipping Convex validation
184
186
  const returnValidator =
185
- returns && !fn.skipConvexValidation ? { returns: zodToConvex(returns) } : undefined
187
+ returns && !skipConvexValidation ? { returns: zodToConvex(returns) } : undefined
186
188
 
187
- if (args && !fn.skipConvexValidation) {
189
+ if (args) {
188
190
  let argsValidator = args
189
191
  let argsSchema: z.ZodObject<any>
190
192
 
@@ -202,9 +204,13 @@ export function customFnBuilder<
202
204
  argsSchema = z.object(argsValidator)
203
205
  }
204
206
 
205
- const convexValidator = zodToConvexFields(argsValidator)
207
+ // Only generate Convex args validator when not skipping Convex validation
208
+ const convexArgs = skipConvexValidation
209
+ ? inputArgs
210
+ : { ...zodToConvexFields(argsValidator), ...inputArgs }
211
+
206
212
  return builder({
207
- args: { ...convexValidator, ...inputArgs },
213
+ args: convexArgs,
208
214
  ...returnValidator,
209
215
  handler: async (ctx: Ctx, allArgs: any) => {
210
216
  const added: any = await customInput(
@@ -215,6 +221,7 @@ export function customFnBuilder<
215
221
  const argKeys = Object.keys(argsValidator)
216
222
  const rawArgs = pick(allArgs, argKeys)
217
223
  const decoded = fromConvexJS(rawArgs, argsSchema)
224
+ // Always run Zod validation, regardless of skipConvexValidation
218
225
  const parsed = argsSchema.safeParse(decoded)
219
226
  if (!parsed.success) {
220
227
  handleZodValidationError(parsed.error, 'args')
@@ -224,7 +231,8 @@ export function customFnBuilder<
224
231
  const addedArgs = (added?.args as Record<string, unknown>) ?? {}
225
232
  const finalArgs = { ...baseArgs, ...addedArgs }
226
233
  const ret = await handler(finalCtx, finalArgs)
227
- if (returns && !fn.skipConvexValidation) {
234
+ // Always run Zod return validation when returns schema is provided
235
+ if (returns) {
228
236
  let validated: any
229
237
  try {
230
238
  validated = (returns as z.ZodTypeAny).parse(ret)
@@ -261,7 +269,8 @@ export function customFnBuilder<
261
269
  const addedArgs = (added?.args as Record<string, unknown>) ?? {}
262
270
  const finalArgs = { ...baseArgs, ...addedArgs }
263
271
  const ret = await handler(finalCtx, finalArgs)
264
- if (returns && !fn.skipConvexValidation) {
272
+ // Always run Zod return validation when returns schema is provided
273
+ if (returns) {
265
274
  let validated: any
266
275
  try {
267
276
  validated = (returns as z.ZodTypeAny).parse(ret)
package/src/ids.ts CHANGED
@@ -16,23 +16,21 @@ export const registryHelpers = {
16
16
  /**
17
17
  * Create a Zod validator for a Convex Id
18
18
  *
19
- * Uses the string transform brand pattern for proper type narrowing with ctx.db.get()
20
- * This aligns with Zod v4 best practices and matches convex-helpers implementation
19
+ * Compatible with AI SDK and other tools that don't support transforms.
20
+ * Uses type-level branding instead of runtime transforms for GenericId<T> compatibility.
21
+ *
22
+ * @param tableName - The Convex table name for this ID
23
+ * @returns A Zod string validator typed as GenericId<TableName>
21
24
  */
22
25
  export function zid<TableName extends string>(
23
26
  tableName: TableName
24
27
  ): z.ZodType<GenericId<TableName>> & { _tableName: TableName } {
25
- // Use the string transform brand pattern (aligned with Zod v4 best practices)
28
+ // Create base string validator with refinement (no transform or brand)
26
29
  const baseSchema = z
27
30
  .string()
28
31
  .refine(val => typeof val === 'string' && val.length > 0, {
29
32
  message: `Invalid ID for table "${tableName}"`
30
33
  })
31
- .transform(val => {
32
- // Cast to GenericId while keeping the string value
33
- return val as string & GenericId<TableName>
34
- })
35
- .brand(`ConvexId_${tableName}`) // Use native Zod v4 .brand() method
36
34
  // Add a human-readable marker for client-side introspection utilities
37
35
  // used in apps/native (e.g., to detect relationship fields in dynamic forms).
38
36
  .describe(`convexId:${tableName}`)
@@ -47,6 +45,8 @@ export function zid<TableName extends string>(
47
45
  const branded = baseSchema as any
48
46
  branded._tableName = tableName
49
47
 
48
+ // Type assertion provides GenericId<TableName> typing without runtime transform
49
+ // This maintains type safety while being compatible with AI SDK and similar tools
50
50
  return branded as z.ZodType<GenericId<TableName>> & { _tableName: TableName }
51
51
  }
52
52
 
package/src/registry.ts CHANGED
@@ -56,3 +56,174 @@ export function isDateSchema(schema: any): boolean {
56
56
 
57
57
  return false
58
58
  }
59
+
60
+ // ============================================================================
61
+ // JSON Schema Override Support
62
+ // ============================================================================
63
+ // Zod's toJSONSchema doesn't support transforms, brands, and other "unrepresentable"
64
+ // types by default. This module provides overrides for zodvex-managed types
65
+ // so they can be used with AI SDKs and other JSON Schema-based tools.
66
+
67
+ /**
68
+ * Checks if a schema is a zid (Convex ID) schema by looking at its description.
69
+ * zid schemas are marked with "convexId:{tableName}" in their description.
70
+ */
71
+ export function isZidSchema(schema: z.ZodTypeAny): boolean {
72
+ const description = schema.description
73
+ return typeof description === 'string' && description.startsWith('convexId:')
74
+ }
75
+
76
+ /**
77
+ * Extracts the table name from a zid schema's description.
78
+ * Returns undefined if not a zid schema.
79
+ */
80
+ export function getZidTableName(schema: z.ZodTypeAny): string | undefined {
81
+ const description = schema.description
82
+ if (typeof description === 'string' && description.startsWith('convexId:')) {
83
+ return description.slice('convexId:'.length)
84
+ }
85
+ return undefined
86
+ }
87
+
88
+ /**
89
+ * Context object passed to the JSON Schema override function.
90
+ * Uses 'any' types for compatibility with Zod's internal types.
91
+ */
92
+ export interface JSONSchemaOverrideContext {
93
+ zodSchema: any // Zod's internal $ZodTypes
94
+ jsonSchema: any // Zod's JSONSchema.BaseSchema
95
+ }
96
+
97
+ /**
98
+ * Override function for z.toJSONSchema that handles zodvex-managed types.
99
+ *
100
+ * Handles:
101
+ * - zid schemas: Converts to { type: "string" } with convexId format
102
+ * - z.date(): Converts to { type: "string", format: "date-time" }
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * import { z } from 'zod'
107
+ * import { zid, zodvexJSONSchemaOverride } from 'zodvex'
108
+ *
109
+ * const schema = z.object({
110
+ * userId: zid('users'),
111
+ * name: z.string()
112
+ * })
113
+ *
114
+ * const jsonSchema = z.toJSONSchema(schema, {
115
+ * unrepresentable: 'any',
116
+ * override: zodvexJSONSchemaOverride
117
+ * })
118
+ * // => { type: "object", properties: { userId: { type: "string" }, name: { type: "string" } } }
119
+ * ```
120
+ */
121
+ export function zodvexJSONSchemaOverride(ctx: JSONSchemaOverrideContext): void {
122
+ const { zodSchema, jsonSchema } = ctx
123
+
124
+ // Handle zid schemas (transforms with convexId description)
125
+ if (isZidSchema(zodSchema)) {
126
+ const tableName = getZidTableName(zodSchema)
127
+ // Set our properties - don't clear existing ones set by user overrides
128
+ // When unrepresentable: 'any', Zod already gives us {} so no clearing needed
129
+ jsonSchema.type = 'string'
130
+ if (tableName) {
131
+ jsonSchema.format = `convex-id:${tableName}`
132
+ }
133
+ // Preserve the description from .describe() - this is what the LLM sees
134
+ if (zodSchema.description) {
135
+ jsonSchema.description = zodSchema.description
136
+ }
137
+ return
138
+ }
139
+
140
+ // Handle z.date() - convert to ISO 8601 string format
141
+ // Zod v4 passes real schema instances here (ZodDate has `type === 'date'`).
142
+ if (zodSchema instanceof z.ZodDate || (zodSchema as any).type === 'date') {
143
+ jsonSchema.type = 'string'
144
+ jsonSchema.format = 'date-time'
145
+ return
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Composes multiple JSON Schema override functions into one.
151
+ * Overrides run in order - first override runs first.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * import { composeOverrides, zodvexJSONSchemaOverride } from 'zodvex'
156
+ *
157
+ * const myOverride = (ctx) => {
158
+ * if (ctx.zodSchema.description?.startsWith('myType:')) {
159
+ * ctx.jsonSchema.type = 'string'
160
+ * ctx.jsonSchema.format = 'my-format'
161
+ * }
162
+ * }
163
+ *
164
+ * // User's override runs first, then zodvex's
165
+ * z.toJSONSchema(schema, {
166
+ * unrepresentable: 'any',
167
+ * override: composeOverrides(myOverride, zodvexJSONSchemaOverride)
168
+ * })
169
+ * ```
170
+ */
171
+ export function composeOverrides(
172
+ ...overrides: Array<((ctx: JSONSchemaOverrideContext) => void) | undefined>
173
+ ): (ctx: JSONSchemaOverrideContext) => void {
174
+ return (ctx: JSONSchemaOverrideContext) => {
175
+ for (const override of overrides) {
176
+ override?.(ctx)
177
+ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Options for toJSONSchema, matching Zod's interface.
183
+ */
184
+ export interface ToJSONSchemaOptions {
185
+ target?: 'draft-4' | 'draft-7' | 'draft-2020-12' | 'openapi-3.0'
186
+ unrepresentable?: 'throw' | 'any'
187
+ cycles?: 'ref' | 'throw'
188
+ reused?: 'ref' | 'inline'
189
+ io?: 'input' | 'output'
190
+ override?: (ctx: JSONSchemaOverrideContext) => void
191
+ }
192
+
193
+ /**
194
+ * Converts a Zod schema to JSON Schema with zodvex-aware overrides.
195
+ *
196
+ * This is a convenience wrapper around z.toJSONSchema that automatically
197
+ * handles zodvex-managed types like zid and dates.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * import { zid, toJSONSchema } from 'zodvex'
202
+ *
203
+ * const schema = z.object({
204
+ * userId: zid('users'),
205
+ * createdAt: z.date(),
206
+ * name: z.string()
207
+ * })
208
+ *
209
+ * const jsonSchema = toJSONSchema(schema)
210
+ * // Works with AI SDK's generateObject, etc.
211
+ * ```
212
+ */
213
+ export function toJSONSchema<T extends z.ZodTypeAny>(
214
+ schema: T,
215
+ options?: ToJSONSchemaOptions
216
+ ): Record<string, any> {
217
+ const userOverride = options?.override
218
+
219
+ return z.toJSONSchema(schema, {
220
+ ...options,
221
+ // Default to 'any' so transforms don't throw
222
+ unrepresentable: options?.unrepresentable ?? 'any',
223
+ // Chain our override with user's override
224
+ override: ctx => {
225
+ zodvexJSONSchemaOverride(ctx)
226
+ userOverride?.(ctx)
227
+ }
228
+ })
229
+ }