@libar-dev/zod-convex-builders
v0.1.0
Published
Authentication-aware function builders for Convex with Zod v4
Maintainers
Readme
@libar-dev/zod-convex-builders
Authentication-aware function builders for Convex with zero-runtime validation using generated PropertyValidators.
Why This Package?
Zero-Cast Integration
- ✅ HTTP actions: Function pattern eliminates FunctionReference casts
- ✅ Log payloads: Fixed
Record<string, unknown>(no generic needed) - ✅ 3 generic parameters: Avoids generic explosion (DataModel, TUserId, TMembership)
- ✅ 50-70% code reduction: Similar to convex-workflows migration
Core Features
- 90% Bundle Reduction - Zero runtime Zod (60-65MB → 10-15MB) via generated PropertyValidators
- Full Type Safety - Type-only Zod imports with complete type inference
- Authentication Builders - Create auth-aware queries, mutations, actions, AND HTTP endpoints with branded types
- HTTP Authentication - Centralized CORS + auth for HTTP actions (zero manual infrastructure, zero casts)
- Return Validation - Optional runtime validation for function boundaries
- Test Utilities - Helpers for testing functions with VObject return types
Installation
npm install @libar-dev/zod-convex-builders zod@^4.1.0Peer Dependencies:
convex>=1.30.0 <2.0.0zod^4.1.0 (build-time only, zero runtime cost)
Dependencies:
@libar-dev/zod-convex-core- Core conversion engine@libar-dev/zod-convex-codecs- Bidirectional codecs
Build-Time Tools:
@libar-dev/zod-convex-gen- Validator generation CLI (REQUIRED for 90% reduction)
Quick Start
1. Define Schema (Build-Time Only)
// src/validation-schemas/getUser.ts
import { z } from 'zod';
import { createIdValidators } from '@libar-dev/zod-convex-ids';
const ids = createIdValidators({ user: 'users' } as const);
export const GetUserArgsSchema = z.object({
userId: ids.userId(),
}).strict();
export type GetUserArgs = z.infer<typeof GetUserArgsSchema>; // REQUIRED2. Generate Validators
npm run generate:validators
# Creates: convex/generatedValidators/getUser.ts3. Use in Convex Functions
// convex/users.ts
import type { GetUserArgs } from '../src/validation-schemas/getUser';
import { getUserArgsFields } from './generatedValidators/getUser';
import { zQuery } from '@libar-dev/zod-convex-builders';
import { query } from './_generated/server';
export const getUser = zQuery(
query,
getUserArgsFields, // PropertyValidators (NO runtime Zod)
async (ctx, args: GetUserArgs) => {
return await ctx.db.get(args.userId);
}
);👉 For complete end-to-end guide, see examples/withReturns.ts
Core Features
Function Wrappers
| Wrapper | Visibility | Use Case |
|---------|-----------|----------|
| zQuery, zMutation, zAction | Public | Client-callable functions |
| zInternalQuery, zInternalMutation, zInternalAction | Internal | Server-only functions |
| zCustomQuery, zCustomMutation, zCustomAction | Configurable | Custom context extension |
Authentication Builders
Create auth-aware functions with zero-cast integration:
import { createAuthWrappers } from '@libar-dev/zod-convex-builders';
const authWrappers = createAuthWrappers<DataModel, UserId, Membership>(
{
getAuthenticatedUserId: async (ctx) => asUserId(identity.subject),
getUserMemberships: async (ctx, userId) => getMemberships(ctx, userId),
workflowUserMarker: asWorkflowUserId('WORKFLOW_USER'),
isAdminRole: (membership) => membership.role === 'admin',
// ... more config
},
{ query, mutation, action }
);
export const zAuthenticatedQuery = authWrappers.zAuthenticatedQuery;
export const zAuthenticatedMutation = authWrappers.zAuthenticatedMutation;
// ... more exportsAvailable Builders:
zAuthenticatedQuery/Mutation/Action- Requires authenticationzOptionalAuthQuery/Mutation- Optional authenticationzAdminQuery/Mutation- Admin-only access
👉 For complete authentication setup, see examples/README.md
HTTP Authentication Wrappers
Create auth-aware HTTP actions with centralized CORS and zero infrastructure code:
import { createHttpAuthWrappers } from '@libar-dev/zod-convex-builders';
import { httpAction } from './_generated/server';
import { internal } from './_generated/api';
const httpAuthWrappers = createHttpAuthWrappers<DataModel, UserId, Membership>(
{
getUserIdFromIdentity: (subject) => asUserId(subject),
// Function pattern (NOT FunctionReference) - consumer writes wrapper
getUserMemberships: async (ctx, userId) => {
return await ctx.runQuery(internal.auth.getMemberships, { userId });
},
verifyEntityAccess: async (ctx, userId, entityId) => {
return await ctx.runQuery(internal.auth.checkEntityAccess, { userId, entityId });
},
cors: {
clientOrigin: process.env.CLIENT_ORIGIN,
fallbackOrigin: 'http://localhost:3000',
},
log: {
info: (_ctx, data) => console.log(data),
error: (_ctx, data) => console.error(data),
},
},
httpAction
);
export const { httpAuthenticatedAction } = httpAuthWrappers;
// Usage - ZERO authentication/CORS code in handler!
export const streamAiMessage = httpAuthenticatedAction({
handler: async (ctx, request) => {
// ctx.userId is UserId (typed, authenticated)
// ctx.memberships is Membership[] (typed)
// CORS headers added automatically
// ZERO casts!
const body = await request.json();
// ... business logic only
return new Response(JSON.stringify({ success: true }));
}
});Available Builders:
httpAuthenticatedAction- Requires authentication, returns 401 if not authenticatedhttpOptionalAuthAction- Optional authentication (userId/memberships null if not authenticated)httpAdminAction- Admin-only access, returns 403 if not admin
Key Benefits:
- ✅ Zero manual authentication - Wrapper handles identity verification
- ✅ Automatic CORS headers - Centralized configuration
- ✅ Typed contexts - Branded UserId and Membership types flow automatically
- ✅ Optional authorization -
ctx.verifyEntityAccess(entityId)helper - ✅ Response-based errors - 401/403 responses, not exceptions
- ✅ Zero casts - Function pattern eliminates all type casts
Why Function Pattern Instead of FunctionReference?
Design Decision: HTTP wrappers use function pattern (not FunctionReference) for zero-cast integration.
Reason: HTTP actions cannot access ctx.db directly. Auth wrappers have a unique requirement that workflows don't: wrappers must call these functions internally with generic-typed arguments and preserve perfect type flow.
Pattern Comparison
| Aspect | FunctionReference Pattern | Function Pattern (Used Here) |
|--------|--------------------------|------------------------------|
| Wrapper Casts | 3 casts to any required | 0 casts ✅ |
| Consumer Code | Simple (direct reference) | One-time wrapper (~3 lines) |
| Type Safety | ⚠️ Casts break type safety | ✅ Full inference chain |
| Pattern Alignment | ✅ Framework-native type | ✅ Zero-cast goal (broader principle) |
| Use Case | Pass reference to engine | Wrapper calls function directly |
Key Insight: Context Matters
The Convex-Native pattern recommends FunctionReference as a concrete framework type. However, auth wrappers differ from workflows:
Workflows (where FunctionReference works):
- Pass FunctionReference to engine (engine handles calls)
- No type preservation needed
- FunctionReference pattern is perfect
Auth Wrappers (why function pattern is needed):
- Wrapper calls function directly with generic-typed
TUserId - Must preserve
TUserId→TMembership[]type flow - FunctionReference requires casts that defeat zero-cast goal
Consumer writes one-time wrapper:
getUserMemberships: async (ctx, userId) => {
return await ctx.runQuery(internal.auth.getMemberships, { userId });
}
// ✅ Zero casts! Types flow automatically: TUserId → TMembership[]Trade-off Analysis:
- FunctionReference: Simple config (direct reference) but 3 casts in wrapper internals
- Function Pattern: One-time wrapper (~3 lines per function) but zero casts everywhere
Decision: Function pattern prioritizes the broader Convex-Native goal (zero-cast integration, perfect type flow) over strict framework type usage. This is a pragmatic deviation that serves the pattern's core principle.
Benefits:
- ✅ Zero casts (vs 3 casts with FunctionReference pattern)
- ✅ Consistent with query/mutation wrappers pattern (same function approach)
- ✅ Type-safe at all boundaries - complete inference chain
- ✅ Consumer controls integration layer - explicit wrapper code
👉 For HTTP action patterns, see examples/README.md
Return Type Validation
Add runtime validation to function boundaries:
export const getUser = zQuery(
query,
getUserArgsFields,
async (ctx, args) => {
return await ctx.db.get(args.userId);
},
{
returns: v.union(v.object(userDocFields), v.null()), // Runtime validation
}
);👉 For return validators guide, see examples/withReturns.ts
Test Utilities
Handle VObject types in tests:
import { unwrapForTest, isNonNull, extractPage } from '@libar-dev/zod-convex-builders/testUtils';
// Option 1: Type annotation (recommended)
const result = (await mutation(...)) as MessageCreationResult;
// Option 2: Unwrap helper
const result = unwrapForTest<MessageCreationResult>(await mutation(...));
// Nullable results
if (isNonNull(result)) {
expect(result.name).toBe('John');
}
// Pagination
const items = extractPage(paginatedResult);Available Utilities:
unwrapForTest<T>()- Unwrap VObject typesunwrapAsyncForTest<T>()- Async unwrapisNonNull<T>()- Type guard for nullableextractPage<T>()- Extract pagination arrayisDiscriminatedAs()- Type guard for unions
👉 For complete testing patterns, see examples/testPatterns.ts
Branded Types
Type-safe branded types for domain modeling:
import {
type UserId,
type WorkflowUserId,
asUserId,
asWorkflowUserId,
isWorkflowUserId,
} from '@libar-dev/zod-convex-builders';
const userId = asUserId('user_123');
const workflowId = asWorkflowUserId('WORKFLOW_USER');
if (isWorkflowUserId(ctx.userId)) {
// Handle workflow context
}Examples & Resources
Complete Guides
| File | Description | Lines |
|------|-------------|-------|
| examples/withReturns.ts | Complete end-to-end flow from schema → validator → function → test | 397 |
| examples/testPatterns.ts | 7 comprehensive testing patterns for all return types | 519 |
| examples/commonTypes.ts | Reusable schema library (pagination, results, CRUD, etc.) | 438 |
| examples/README.md | Navigation guide and troubleshooting | 323 |
Quick Navigation
New to the package?
- Read
examples/withReturns.tsfor complete understanding - Reference
examples/README.mdfor common scenarios
Writing tests?
→ See examples/testPatterns.ts for all 7 testing patterns
Building schemas?
→ See examples/commonTypes.ts for reusable patterns
Migrating from v0.0.x?
→ See Migration from Runtime Zod below + examples/README.md
Troubleshooting?
→ See examples/README.md#troubleshooting
API Reference
Function Wrappers
import {
zQuery,
zInternalQuery,
zMutation,
zInternalMutation,
zAction,
zInternalAction,
} from '@libar-dev/zod-convex-builders';
// Usage: zQuery(builder, argsFields, handler, options?)
export const getUser = zQuery(query, getUserArgsFields, async (ctx, args) => {
return await ctx.db.get(args.userId);
});Authentication Wrappers Factory
import { createAuthWrappers } from '@libar-dev/zod-convex-builders';
const wrappers = createAuthWrappers<DataModel, UserId, Membership>(
config,
{ query, mutation, action }
);
// Returns:
// - zAuthenticatedQuery, zAuthenticatedMutation, zAuthenticatedAction
// - zOptionalAuthQuery, zOptionalAuthMutation
// - zAdminQuery, zAdminMutation (if isAdminRole provided)HTTP Authentication Wrappers Factory
import { createHttpAuthWrappers } from '@libar-dev/zod-convex-builders';
const httpWrappers = createHttpAuthWrappers<DataModel, UserId, Membership>(
{
getUserIdFromIdentity: (subject) => asUserId(subject),
// Function pattern for zero-cast integration
getUserMemberships: async (ctx, userId) => {
return await ctx.runQuery(internal.auth.getMemberships, { userId });
},
verifyEntityAccess: async (ctx, userId, entityId) => {
return await ctx.runQuery(internal.auth.checkEntityAccess, { userId, entityId });
},
cors: {
clientOrigin: process.env.CLIENT_ORIGIN,
fallbackOrigin: 'http://localhost:3000',
},
log: {
info: (_ctx, data) => console.log(data),
error: (_ctx, data) => console.error(data),
},
},
httpAction
);
// Returns:
// - httpAuthenticatedAction - Requires authentication
// - httpOptionalAuthAction - Optional authentication
// - httpAdminAction - Admin-only (if isAdminRole provided)Custom Function Integration
import { zCustomQuery, zCustomMutation, zCustomAction, NoOp } from '@libar-dev/zod-convex-builders';
export const zq = zCustomQuery(query, NoOp);
export const zm = zCustomMutation(mutation, NoOp);Test Utilities (TEST CODE ONLY)
import {
unwrapForTest,
unwrapAsyncForTest,
isNonNull,
extractPage,
isDiscriminatedAs,
} from '@libar-dev/zod-convex-builders/testUtils';Branded Types
import {
type UserId,
type WorkflowUserId,
type MembershipId,
type AnyUserId,
asUserId,
asWorkflowUserId,
asMembershipId,
isWorkflowUserId,
} from '@libar-dev/zod-convex-builders';Memory Optimization
PropertyValidators-Only Pattern achieves massive bundle reduction:
- Before: ~60-65MB bundle (runtime Zod validation)
- After: ~10-15MB bundle (generated PropertyValidators)
- Reduction: 75-83% decrease in memory usage
How It Works
- Define Zod schemas in
src/validation-schemas/(outside Convex bundle) - Run
npm run generate:validatorsto create PropertyValidators - Import type-only Zod (
import type { z }) in Convex functions - Use generated validators (
getUserArgsFields) at runtime
Result: Zero runtime Zod, full type safety maintained.
Verify Bundle Reduction
# Build and check bundle size
npx convex dev --once --debug-bundle-path
ls -lh .convex/bundle/ # Expected: ~10-15MB
# Compare with runtime Zod (before migration)
du -sh .convex/bundle/ # Should show 75-83% reductionTested environment: 56+ table schemas, 100+ functions, production Convex app
Migration from Runtime Zod
If upgrading from v0.0.x (runtime Zod pattern):
Before (v0.0.x - DEPRECATED)
import { z } from 'zod'; // Runtime import ❌
import { zQuery } from '@libar-dev/zod-convex-builders';
export const getUser = zQuery(
query,
z.object({ userId: z.string() }), // Runtime Zod ❌
async (ctx, args) => { /* ... */ }
);After (v0.1.0+ - REQUIRED)
// 1. Move schema to src/validation-schemas/getUser.ts
export const GetUserArgsSchema = z.object({ userId: ids.userId() }).strict();
export type GetUserArgs = z.infer<typeof GetUserArgsSchema>; // ✅ REQUIRED
// 2. Generate: npm run generate:validators
// 3. Use PropertyValidators
import type { GetUserArgs } from '../src/validation-schemas/getUser'; // ✅ Type-only
import { getUserArgsFields } from './generatedValidators/getUser'; // ✅ Generated
export const getUser = zQuery(query, getUserArgsFields, async (ctx, args: GetUserArgs) => {
return await ctx.db.get(args.userId);
});Migration Checklist
- [ ] Move Zod schemas from
convex/tosrc/validation-schemas/ - [ ] Add type alias exports to all schemas (
export type) - [ ] Run
npm run generate:validators - [ ] Update functions to use generated validators
- [ ] Change
import { z }→import type { z }in Convex files - [ ] Verify bundle: Should be ~10-15MB (down from 60-65MB)
👉 For complete migration guide, see examples/README.md
Convex-Native Pattern
This package uses optional generic parameters with defaults for zero-cast integration:
export interface AuthConfig<
DataModel extends GenericDataModel,
TUserId extends string = string, // ✅ Default: backward compatible
TMembership = unknown // ✅ Flexible default
>Convex-Native Pattern: Limited to 3 type parameters maximum:
DataModel(Convex GenericDataModel)TUserId(User ID type, default: string)TMembership(Membership type, default: unknown)
Note: Previous versions had 4 parameters (including TLogPayload), but this was removed to avoid generic explosion. Log payloads now use fixed Record<string, unknown> type.
Benefits:
- ✅ Zero casts - Types flow from factory config through all consumers
- ✅ Centralized config - Configure once, use everywhere
- ✅ Backward compatible - Defaults work for existing code
- ✅ Type safety - Full branded type support with IntelliSense
- ✅ 50-70% code reduction - Function pattern eliminates wrapper overhead
Development
Building from Source
# Monorepo development
npm run build:packages # Builds all packages
# Standalone development
npm run build # Uses committed validatorsModifying Schemas
# After editing src/schemas/
npm run generate:validators
git add src/generatedValidators/
npm run buildPublished Package: Ships with pre-built dist/ - no build step required.
Performance
Validation Overhead
- Validation happens once at function boundary
- Return validation is optional - use when needed
- Codecs are cached - no repeated parsing
- Type inference is compile-time - zero runtime overhead
PropertyValidators Performance
PropertyValidators Path (RECOMMENDED):
- Zero Zod parsing - validators passed directly to Convex runtime
- Single validation - Convex validates once at function entry
- No codec conversion - args passed through without transformation
- Result: 75-83% memory reduction (60-65MB → 10-15MB)
Validation Strength
PropertyValidators provide complete validation guarantees:
- Structural validation (types, IDs, nested objects)
- Business rules (email format, string length, refinements)
Key Insight: PropertyValidators use Convex's optimized validator system, generated from Zod schemas. Business rules preserved during generation.
Visibility Preservation
The builders correctly preserve Convex's visibility type system:
// Public - appears in api.queries
export const myPublicQuery = zAuthenticatedQuery({ ... });
// Internal - appears in api.internal.queries only
export const myInternalQuery = zInternalAuthenticatedQuery({ ... });Runtime properties:
myPublicQuery.isPublic === true;
myInternalQuery.isInternal === true;Related Packages
@libar-dev/zod-convex-gen- Generate Convex validators (REQUIRED for 90% reduction)@libar-dev/zod-convex-core- Core conversion engine@libar-dev/zod-convex-ids- Type-safe ID validation@libar-dev/zod-convex-codecs- Bidirectional codecs@libar-dev/zod-convex-tables- Table utilities
License
MIT License - See LICENSE file for details.
Copyright (c) 2025 Libar AI
