PocketBase migration generator using Zod schemas for type-safe database migrations
npm install pocketbase-zod-schemaDefine your PocketBase collections using Zod schemas and automatically generate migration files.
- Type-safe schema definitions - Use Zod to define your PocketBase collections with full TypeScript support
- Automatic migrations - Generate PocketBase-compatible migration files from your schema changes
- Relation support - Easily define single and multiple relations between collections
- Permission templates - Built-in templates for common permission patterns
- Index definitions - Declare indexes alongside your schema
- CLI & programmatic API - Use the CLI for quick generation or the API for custom workflows
``bash`
npm install pocketbase-zod-schemaor
yarn add pocketbase-zod-schemaor
pnpm add pocketbase-zod-schema
Create a schema file in your project (e.g., src/schema/post.ts):
`typescript
import { z } from "zod";
import {
defineCollection,
TextField,
EditorField,
BoolField,
RelationField,
RelationsField,
} from "pocketbase-zod-schema/schema";
// Define the Zod schema
export const PostSchema = z.object({
title: TextField({ min: 1, max: 200 }),
content: EditorField(),
published: BoolField(),
// Single relation to users collection
author: RelationField({ collection: "users" }),
// Multiple relations to tags collection
tags: RelationsField({ collection: "tags", maxSelect: 10 }),
});
// Define the collection with permissions
export const PostCollection = defineCollection({
collectionName: "posts",
schema: PostSchema,
permissions: {
listRule: '@request.auth.id != ""',
viewRule: "",
createRule: '@request.auth.id != ""',
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id",
},
});
`
Create pocketbase-migrate.config.js at your project root:
`javascript`
export default {
schema: {
directory: "./src/schema",
exclude: [".test.ts", ".spec.ts"],
},
migrations: {
directory: "./pocketbase/pb_migrations",
},
};
`bash`
npx pocketbase-migrate generate
This will create a migration file in your PocketBase migrations directory.
TypeScript Support: You can use TypeScript (.ts) schema files directly - no compilation needed! The tool automatically handles TypeScript files using tsx.
Generate type-safe TypeScript definitions from your schemas:
`bashGenerate types to pocketbase-types.ts
npx pocketbase-migrate generate-types
This creates a
pocketbase-types.ts file with:
- Type-safe record interfaces for each collection (e.g., PostsRecord, UsersRecord)
- Response types with expand support (e.g., PostsResponse, UsersResponse)
- A TypedPocketBase interface for type-safe PocketBase client usageUsage in your application:
`typescript
import PocketBase from "pocketbase";
import { TypedPocketBase } from "./pocketbase-types";const pb = new PocketBase("http://localhost:8090") as TypedPocketBase;
// Full type safety with autocomplete!
const post = await pb.collection("posts").getOne("post-id");
// post is typed as PostsResponse with full autocomplete
// Expand relations with type safety
const postWithAuthor = await pb.collection("posts").getOne("post-id", {
expand: "author"
});
// postWithAuthor.expand?.author is typed as UsersResponse
`---
Schema Definition
$3
The recommended way to define collections is using
defineCollection(), which provides a single entry point for collection name, schema, permissions, indexes, and future features:`typescript
import { z } from "zod";
import { defineCollection, TextField, EditorField, RelationField } from "pocketbase-zod-schema/schema";export const PostCollectionSchema = z.object({
title: TextField({ min: 1, max: 200 }),
content: EditorField(),
author: RelationField({ collection: "users" }),
});
export const PostCollection = defineCollection({
collectionName: "posts",
schema: PostCollectionSchema,
permissions: {
template: "owner-only",
ownerField: "author",
},
indexes: [
"CREATE INDEX idx_posts_author ON posts (author)",
],
});
`Benefits of
defineCollection():
- Explicit collection name - No need to rely on filename conventions
- All metadata in one place - Schema, permissions, indexes together
- Future-proof - Easy to extend with new features
- Cleaner syntax - No nested function callsExport Pattern: It's recommended to export both the schema and collection definition:
`typescript
// Define the Zod schema (for type inference and validation)
export const PostSchema = z.object({
title: z.string(),
content: z.string(),
author: RelationField({ collection: "users" }),
});// Define the collection (used by migration generator, includes metadata)
export const PostCollection = defineCollection({
collectionName: "posts",
schema: PostSchema,
permissions: { / ... / },
});
`This pattern allows:
-
PostSchema - Used for type inference (z.infer) and validation
- PostCollection - Used by the migration generator (has collection metadata)Note: You can still use
withPermissions() and withIndexes() separately if you prefer, but defineCollection() is recommended for new code.$3
The library provides explicit field helper functions for all PocketBase field types. These helpers embed PocketBase-specific metadata and provide type-safe configuration options.
#### Field Helper Functions
| Field Helper | PocketBase Type | Description | Example |
|--------------|-----------------|-------------|---------|
|
BoolField() | bool | Boolean field | active: BoolField() |
| NumberField(options?) | number | Number field with optional constraints | price: NumberField({ min: 0 }) |
| TextField(options?) | text | Text field with optional constraints | name: TextField({ min: 1, max: 200 }) |
| EmailField() | email | Email field with validation | email: EmailField() |
| URLField() | url | URL field with validation | website: URLField() |
| EditorField() | editor | Rich text editor field | content: EditorField() |
| DateField(options?) | date | Date field with optional constraints | birthdate: DateField() |
| AutodateField(options?) | autodate | Auto-managed timestamp field | createdAt: AutodateField({ onCreate: true }) |
| SelectField(values, options?) | select | Single or multiple select field | status: SelectField(["draft", "published"]) |
| FileField(options?) | file | Single file upload field | avatar: FileField({ mimeTypes: ["image/*"] }) |
| FilesField(options?) | file | Multiple file upload field | images: FilesField({ maxSelect: 5 }) |
| JSONField(schema?) | json | JSON field with optional schema | metadata: JSONField() |
| GeoPointField() | geoPoint | Geographic coordinates field | location: GeoPointField() |
| RelationField(config) | relation | Single relation field | author: RelationField({ collection: "users" }) |
| RelationsField(config) | relation | Multiple relation field | tags: RelationsField({ collection: "tags" }) |#### Field Options
BoolField()
- No options
- Returns:
z.ZodBooleanNumberField(options?)
-
min?: number - Minimum value constraint
- max?: number - Maximum value constraint
- noDecimal?: boolean - Disallow decimal values (integers only)
- Returns: z.ZodNumberTextField(options?)
-
min?: number - Minimum length constraint
- max?: number - Maximum length constraint
- pattern?: RegExp | string - Pattern constraint (regex)
- autogeneratePattern?: string - Auto-generate pattern (e.g., "[A-Z]{3}-[0-9]{6}")
- Returns: z.ZodStringEmailField()
- No options (includes email validation)
- Returns:
z.ZodStringURLField()
- No options (includes URL validation)
- Returns:
z.ZodStringEditorField()
- No options
- Returns:
z.ZodStringDateField(options?)
-
min?: Date | string - Minimum date constraint
- max?: Date | string - Maximum date constraint
- Returns: z.ZodStringAutodateField(options?)
-
onCreate?: boolean - Set date automatically on record creation
- onUpdate?: boolean - Update date automatically on record update
- Returns: z.ZodStringSelectField(values, options?)
-
values: [string, ...string[]] - Array of allowed values (required)
- maxSelect?: number - Maximum selections (default: 1, >1 enables multiple selection)
- Returns: z.ZodEnum or z.ZodArrayFileField(options?)
-
mimeTypes?: string[] - Allowed MIME types (e.g., ["image/*", "application/pdf"])
- maxSize?: number - Maximum file size in bytes
- thumbs?: string[] - Thumbnail sizes to generate (e.g., ["100x100", "200x200"])
- protected?: boolean - Whether file requires auth to access
- Returns: z.ZodTypeFilesField(options?)
- All
FileField options plus:
- minSelect?: number - Minimum number of files required
- maxSelect?: number - Maximum number of files allowed
- Returns: z.ZodArrayJSONField(schema?)
-
schema?: z.ZodTypeAny - Optional Zod schema for JSON structure validation
- Returns: T | z.ZodRecordGeoPointField()
- No options
- Returns:
z.ZodObject<{ lon: z.ZodNumber; lat: z.ZodNumber }>#### Field Helper Examples
`typescript
import { z } from "zod";
import {
defineCollection,
BoolField,
NumberField,
TextField,
EmailField,
URLField,
EditorField,
DateField,
AutodateField,
SelectField,
FileField,
FilesField,
JSONField,
GeoPointField,
RelationField,
RelationsField,
} from "pocketbase-zod-schema/schema";const ProductSchema = z.object({
// Text fields
name: TextField({ min: 1, max: 200 }),
sku: TextField({ autogeneratePattern: "[A-Z]{3}-[0-9]{6}" }),
description: EditorField(),
website: URLField().optional(),
// Number fields
price: NumberField({ min: 0 }),
quantity: NumberField({ min: 0, noDecimal: true }),
rating: NumberField({ min: 0, max: 5 }).optional(),
// Boolean field
active: BoolField(),
featured: BoolField().optional(),
// Date fields
releaseDate: DateField().optional(),
createdAt: AutodateField({ onCreate: true }),
updatedAt: AutodateField({ onUpdate: true }),
// Select fields
status: SelectField(["draft", "published", "archived"]),
categories: SelectField(["electronics", "clothing", "food"], { maxSelect: 3 }),
// File fields
thumbnail: FileField({
mimeTypes: ["image/*"],
maxSize: 5242880, // 5MB
thumbs: ["100x100", "200x200"],
}),
images: FilesField({
mimeTypes: ["image/*"],
maxSelect: 5,
}),
// JSON field
metadata: JSONField(),
settings: JSONField(z.object({
theme: z.string(),
notifications: z.boolean(),
})).optional(),
// GeoPoint field
location: GeoPointField().optional(),
// Relation fields
vendor: RelationField({ collection: "vendors" }),
tags: RelationsField({ collection: "tags", maxSelect: 10 }),
});
export const ProductCollection = defineCollection({
collectionName: "products",
schema: ProductSchema,
permissions: {
listRule: "",
viewRule: "",
createRule: '@request.auth.id != ""',
updateRule: "vendor.owner = @request.auth.id",
deleteRule: "vendor.owner = @request.auth.id",
},
});
`#### Backward Compatibility
The library still supports plain Zod types for backward compatibility. The migration generator will infer PocketBase field types from Zod types when field helpers are not used:
| Zod Type | PocketBase Type | Example |
|----------|-----------------|---------|
|
z.string() | text | title: z.string() |
| z.string().email() | email | email: z.string().email() |
| z.string().url() | url | website: z.string().url() |
| z.number() | number | price: z.number() |
| z.boolean() | bool | active: z.boolean() |
| z.date() | date | birthdate: z.date() |
| z.enum([...]) | select | status: z.enum(["draft", "published"]) |
| z.instanceof(File) | file | avatar: z.instanceof(File) |Recommendation: Use field helpers for new schemas to get explicit field type declarations and access to PocketBase-specific options.
$3
Use
RelationField() for single relations and RelationsField() for multiple relations:`typescript
import { RelationField, RelationsField } from "pocketbase-zod-schema/schema";const ProjectSchema = z.object({
name: z.string(),
// Single relation (maxSelect: 1)
owner: RelationField({ collection: "users" }),
// Single relation with cascade delete
category: RelationField({
collection: "categories",
cascadeDelete: true,
}),
// Multiple relations (maxSelect: 999 by default)
collaborators: RelationsField({ collection: "users" }),
// Multiple relations with constraints
tags: RelationsField({
collection: "tags",
minSelect: 1,
maxSelect: 5,
}),
});
`#### Relation Options
RelationField(config) - Single relation
- collection: string - Target collection name (required)
- cascadeDelete?: boolean - Delete related records when this record is deleted (default: false)
RelationsField(config) - Multiple relations
- collection: string - Target collection name (required)
- cascadeDelete?: boolean - Delete related records when this record is deleted (default: false)
- minSelect?: number - Minimum number of relations required (default: 0)
- maxSelect?: number - Maximum number of relations allowed (default: 999)$3
Use
defineCollection() with permissions, or withPermissions() to attach API rules to your schema:`typescript
import { defineCollection, withPermissions } from "pocketbase-zod-schema/schema";// Using defineCollection (recommended)
const PostCollection = defineCollection({
collectionName: "posts",
schema: z.object({ title: z.string() }),
permissions: {
listRule: '@request.auth.id != ""', // Authenticated users can list
viewRule: "", // Anyone can view (public)
createRule: '@request.auth.id != ""', // Authenticated users can create
updateRule: "author = @request.auth.id", // Only author can update
deleteRule: "author = @request.auth.id", // Only author can delete
},
});
// Using templates with defineCollection
const ProjectCollection = defineCollection({
collectionName: "projects",
schema: z.object({ title: z.string(), owner: RelationField({ collection: "users" }) }),
permissions: {
template: "owner-only",
ownerField: "owner",
},
});
// Using withPermissions (alternative approach)
const PostSchemaAlt = withPermissions(
z.object({ title: z.string() }),
{
listRule: '@request.auth.id != ""',
viewRule: "",
createRule: '@request.auth.id != ""',
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id",
}
);
`#### Permission Templates
| Template | Description |
|----------|-------------|
|
"public" | All operations are public (no auth required) |
| "authenticated" | All operations require authentication |
| "owner-only" | Only the owner can perform operations |#### Template with Custom Overrides
`typescript
// Using defineCollection (recommended)
const PostCollection = defineCollection({
collectionName: "posts",
schema: z.object({ title: z.string(), author: RelationField({ collection: "users" }) }),
permissions: {
template: "owner-only",
ownerField: "author",
customRules: {
listRule: '@request.auth.id != ""', // Override just the list rule
viewRule: "", // Make viewing public
},
},
});// Using withPermissions (alternative)
const PostSchemaAlt = withPermissions(schema, {
template: "owner-only",
ownerField: "author",
customRules: {
listRule: '@request.auth.id != ""',
viewRule: "",
},
});
`$3
Use
defineCollection() with indexes, or withIndexes() to define database indexes:`typescript
import { defineCollection, withIndexes, withPermissions } from "pocketbase-zod-schema/schema";// Using defineCollection (recommended)
const UserCollection = defineCollection({
collectionName: "users",
schema: z.object({
email: z.string().email(),
username: z.string(),
}),
permissions: {
template: "authenticated",
},
indexes: [
'CREATE UNIQUE INDEX idx_users_email ON users (email)',
'CREATE INDEX idx_users_username ON users (username)',
],
});
// Using withIndexes (alternative)
const UserSchemaAlt = withIndexes(
withPermissions(
z.object({
email: z.string().email(),
username: z.string(),
}),
{ template: "authenticated" }
),
[
'CREATE UNIQUE INDEX idx_users_email ON users (email)',
'CREATE INDEX idx_users_username ON users (username)',
]
);
`---
CLI Reference
$3
`bash
Generate migration from schema changes
npx pocketbase-migrate generateShow what would be generated without writing files
npx pocketbase-migrate statusGenerate TypeScript definitions from schemas
npx pocketbase-migrate generate-typesForce generation even with destructive changes
npx pocketbase-migrate generate --force
`$3
Generate type-safe TypeScript definitions from your Zod schemas.
`bash
pocketbase-migrate generate-types [options]Options:
-c, --config Configuration file path
-o, --output Output file path (default: pocketbase-types.ts)
--schema-dir Directory containing Zod schema files
-v, --verbose Enable verbose logging
`What it generates:
The
generate-types command analyzes your Zod schemas and generates a TypeScript file (default: pocketbase-types.ts) containing:1. Record interfaces - Type-safe interfaces for each collection's data structure
`typescript
export interface PostsRecord {
id: string;
created: string;
updated: string;
collectionId: string;
collectionName: "posts";
title: string;
content: string;
// ... other fields
}
`2. Response types - Extended types with expand support for relations
`typescript
export interface PostsResponse extends PostsRecord {
expand?: {
author?: UsersResponse;
tags?: TagsResponse[];
};
}
`3. TypedPocketBase interface - Type-safe PocketBase client wrapper
`typescript
export interface TypedPocketBase extends PocketBase {
collection(idOrName: "posts"): RecordService;
collection(idOrName: "users"): RecordService;
// ... other collections
}
`Example usage:
`typescript
import PocketBase from "pocketbase";
import { TypedPocketBase } from "./pocketbase-types";const pb = new PocketBase("http://localhost:8090") as TypedPocketBase;
// Full type safety!
const post = await pb.collection("posts").getOne("post-id");
// Type: PostsResponse
// Expand relations with type safety
const postWithAuthor = await pb.collection("posts").getOne("post-id", {
expand: "author"
});
// postWithAuthor.expand?.author is typed as UsersResponse | undefined
`$3
`javascript
// pocketbase-migrate.config.js
export default {
schema: {
// Directory containing your Zod schema files
directory: "./src/schema",
// Files to exclude from schema discovery
exclude: [".test.ts", ".spec.ts", "base.ts", "index.ts"],
},
migrations: {
// Directory to output migration files
directory: "./pocketbase/pb_migrations",
},
diff: {
// Warn when collections or fields would be deleted
warnOnDelete: true,
// Require --force flag for destructive changes
requireForceForDestructive: true,
},
};
`---
Programmatic API
For custom workflows, use the programmatic API:
`typescript
import {
parseSchemaFiles,
compare,
generate,
loadSnapshotIfExists,
} from "pocketbase-zod-schema/migration";async function generateMigration() {
const schemaDir = "./src/schema";
const migrationsDir = "./pocketbase/pb_migrations";
// Parse all schema files
const currentSchema = await parseSchemaFiles(schemaDir);
// Load the last known state from existing migrations
const previousSnapshot = loadSnapshotIfExists({
migrationsPath: migrationsDir
});
// Compare schemas and detect changes
const diff = compare(currentSchema, previousSnapshot);
// Generate migration file
if (diff.collectionsToCreate.length > 0 ||
diff.collectionsToModify.length > 0 ||
diff.collectionsToDelete.length > 0) {
const migrationPath = generate(diff, migrationsDir);
console.log(
Migration created: ${migrationPath});
} else {
console.log("No changes detected");
}
}
`---
Complete Example
Here's a complete example of a blog schema with users, posts, and comments:
`typescript
// src/schema/user.ts
import { z } from "zod";
import { baseSchema, defineCollection, TextField, EmailField } from "pocketbase-zod-schema/schema";// Input schema for forms (includes passwordConfirm for validation)
export const UserInputSchema = z.object({
name: TextField({ max: 100 }).optional(),
email: EmailField(),
password: TextField({ min: 8 }),
passwordConfirm: z.string(),
avatar: z.instanceof(File).optional(),
});
// Database schema (excludes passwordConfirm)
const UserCollectionSchema = z.object({
name: TextField({ max: 100 }).optional(),
email: EmailField(),
password: TextField({ min: 8 }),
avatar: z.instanceof(File).optional(),
});
// Full schema with base fields for type inference (includes id, collectionId, etc.)
export const UserSchema = UserCollectionSchema.extend(baseSchema);
// Collection definition with permissions and indexes
export const UserCollection = defineCollection({
collectionName: "Users",
schema: UserSchema,
permissions: {
listRule: "id = @request.auth.id",
viewRule: "id = @request.auth.id",
createRule: "",
updateRule: "id = @request.auth.id",
deleteRule: "id = @request.auth.id",
},
indexes: [
'CREATE UNIQUE INDEX idx_users_email ON users (email)',
],
});
``typescript
// src/schema/post.ts
import { z } from "zod";
import {
defineCollection,
TextField,
EditorField,
BoolField,
DateField,
RelationField,
RelationsField,
} from "pocketbase-zod-schema/schema";// Define the Zod schema
export const PostSchema = z.object({
title: TextField({ min: 1, max: 200 }),
slug: TextField({ pattern: /^[a-z0-9-]+$/ }),
content: EditorField(),
excerpt: TextField({ max: 500 }).optional(),
published: BoolField(),
publishedAt: DateField().optional(),
// Relations
author: RelationField({ collection: "users" }),
category: RelationField({ collection: "categories" }),
tags: RelationsField({ collection: "tags", maxSelect: 10 }),
});
// Define the collection with permissions
export const PostCollection = defineCollection({
collectionName: "posts",
schema: PostSchema,
permissions: {
listRule: 'published = true || author = @request.auth.id',
viewRule: 'published = true || author = @request.auth.id',
createRule: '@request.auth.id != ""',
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id",
},
});
``typescript
// src/schema/comment.ts
import { z } from "zod";
import { defineCollection, TextField, RelationField } from "pocketbase-zod-schema/schema";// Define the Zod schema
export const CommentSchema = z.object({
content: TextField({ min: 1 }),
// Relations with cascade delete
post: RelationField({ collection: "posts", cascadeDelete: true }),
author: RelationField({ collection: "users" }),
});
// Define the collection with permissions
export const CommentCollection = defineCollection({
collectionName: "comments",
schema: CommentSchema,
permissions: {
listRule: "",
viewRule: "",
createRule: '@request.auth.id != ""',
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id || @request.auth.role = 'admin'",
},
});
``---
MIT