# pocketbase-zod-schema

Define your PocketBase collections using Zod schemas and automatically generate migration files.

## Features

- **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
- **View collections** - Read-only collections backed by a SQL query, declared alongside the rest
- **Permission templates** - Built-in templates for common permission patterns
- **Index definitions** - Declare indexes alongside your schema
- **Execution engine** - Current state is reconstructed by *executing* your migrations in a
  simulated PocketBase JSVM, so loops, helper functions and computed values are understood
- **Verification & linting** - Round-trip `up()`/`down()` before writing a migration, and catch
  JavaScript that PocketBase's goja runtime cannot run
- **CLI & programmatic API** - Use the CLI for quick generation or the API for custom workflows

## Installation

```bash
npm install pocketbase-zod-schema
# or
yarn add pocketbase-zod-schema
# or
pnpm add pocketbase-zod-schema
```

## Quick Start

### 1. Create a schema file

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";

// 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",
  },
});
```

### 2. Configure the CLI

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",
  },
};
```

### 3. Generate 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`.

### 4. Generate TypeScript types

Generate type-safe TypeScript definitions from your schemas:

```bash
# Generate types to pocketbase-types.ts
npx pocketbase-migrate generate-types

# Or specify a custom output path
npx pocketbase-migrate generate-types --output ./src/types/pocketbase.ts
```

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 usage

**Usage 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

### High-Level Collection Definition

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";

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 calls

**Export 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<typeof PostSchema>`) and validation
- `PostCollection` - Used by the migration generator (has collection metadata)

### Field Types

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?, options?)` | json | JSON field with optional schema and size cap | `metadata: JSONField({ maxSize: "5M" })` |
| `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.ZodBoolean`

**NumberField(options?)**
- `min?: number` - Minimum value constraint
- `max?: number` - Maximum value constraint
- `noDecimal?: boolean` - Disallow decimal values (integers only)
- Returns: `z.ZodNumber`

**TextField(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.ZodString`

**EmailField()**
- No options (includes email validation)
- Returns: `z.ZodString`

**URLField()**
- No options (includes URL validation)
- Returns: `z.ZodString`

**EditorField()**
- No options
- Returns: `z.ZodString`

**DateField(options?)**
- `min?: Date | string` - Minimum date constraint
- `max?: Date | string` - Maximum date constraint
- Returns: `z.ZodString`

**AutodateField(options?)**
- `onCreate?: boolean` - Set date automatically on record creation
- `onUpdate?: boolean` - Update date automatically on record update
- Returns: `z.ZodString`

**SelectField(values, options?)**
- `values: [string, ...string[]]` - Array of allowed values (required)
- `maxSelect?: number` - Maximum selections (default: 1, >1 enables multiple selection)
- Returns: `z.ZodEnum<T>` or `z.ZodArray<z.ZodEnum<T>>`

**FileField(options?)**
- `mimeTypes?: string[]` - Allowed MIME types (e.g., `["image/*", "application/pdf"]`)
- `maxSize?: ByteSize` - Maximum file size: a number of bytes, or a string with a `K`/`M`/`G`
  suffix (`"5M"`, `"1G"`; max `"8G"`)
- `thumbs?: string[]` - Thumbnail sizes to generate (e.g., `["100x100", "200x200"]`)
- `protected?: boolean` - Whether file requires auth to access
- Returns: `z.ZodType<string, File | string>` — accepts a `File` on input, parses to the stored
  filename string

**FilesField(options?)**
- All `FileField` options plus:
- `minSelect?: number` - Minimum number of files required
- `maxSelect?: number` - Maximum number of files allowed
- Returns: `z.ZodType<string[], (File | string)[]>`

**JSONField(schema?, options?)**
- `schema?: z.ZodTypeAny` - Optional Zod schema for JSON structure validation
- `options?.maxSize?: number | "5M"` - Maximum serialized size, in bytes or with a `K`/`M`/`G`
  suffix. PocketBase applies a 1MB default when unset, so larger payloads must set it. Either
  argument may be passed alone: `JSONField({ maxSize: "5M" })`
- Returns: `T | z.ZodRecord<z.ZodString, z.ZodAny>`

**GeoPointField()**
- 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";

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.

### Defining Relations

Use `RelationField()` for single relations and `RelationsField()` for multiple relations:

```typescript
import { RelationField, RelationsField } from "pocketbase-zod-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`)
- `displayFields?: string[] | null` - Fields to display in the PocketBase admin UI

**`RelationsField(config)`** - Multiple relations
- All `RelationField` options plus:
- `minSelect?: number` - Minimum number of relations required (default: `0`)
- `maxSelect?: number` - Maximum number of relations allowed (default: `999`)

> Relations are explicit-only. There is no naming-convention fallback — a field is a relation only
> when it's built with `RelationField()`/`RelationsField()`; a bare `z.array(z.string())` maps to a
> `json` field, not a relation.

### Defining View Collections

Use `defineView()` for a read-only collection backed by a SQL query. PocketBase derives the
collection's fields by running the query, so the Zod schema describes the row shape for TypeScript
only:

```typescript
import { z } from "zod";
import { baseSchema, defineView, sql } from "pocketbase-zod-schema";

export const ProductStatsSchema = z
  .object({
    vendor: z.string(),
    productCount: z.number(),
  })
  .extend(baseSchema);

export default defineView({
  collectionName: "ProductStats",
  schema: ProductStatsSchema,
  viewQuery: sql`
    SELECT p.vendor AS id,
           p.vendor AS vendor,
           COUNT(*) AS productCount
      FROM products p
     GROUP BY p.vendor
  `,
  permissions: {
    listRule: "vendor.owner = @request.auth.id",
    viewRule: "vendor.owner = @request.auth.id",
  },
});
```

- Views are read-only: `defineView()` accepts only `listRule` and `viewRule`, and rejects indexes.
- The outermost `SELECT` must expose a unique `id` column.
- The generated migration contains the query and **no** `fields`/`indexes` array.
- Editing the SQL produces an in-place update that keeps the collection id stable; re-indenting it
  produces no migration at all.
- Deleting a view is not a destructive change — a view stores no data.

`defineCollection({ type: "view", viewQuery })` is equivalent, but `defineView()` turns those
constraints into compile errors instead of apply-time failures.

### Defining Permissions

Attach API rules to a collection through `defineCollection({ permissions })`:

```typescript
import { defineCollection } from "pocketbase-zod-schema";

// Explicit rules
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 a template
const ProjectCollection = defineCollection({
  collectionName: "projects",
  schema: z.object({ title: z.string(), owner: RelationField({ collection: "users" }) }),
  permissions: {
    template: "owner-only",
    ownerField: "owner",
  },
});
```

#### 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 (`ownerField`, default `"User"`) |
| `"admin-only"` | Requires `@request.auth.<roleField> = "admin"` (`roleField`, default `"role"`) |
| `"read-public"` | Public read, authenticated write |
| `"custom"` | No base rules — `customRules` supplies everything |

Two more have no template name; call them and pass the result as `permissions`:

```typescript
import { PermissionTemplates } from "pocketbase-zod-schema";

PermissionTemplates.locked();                 // every rule null (superusers only)
PermissionTemplates.readOnlyAuthenticated();  // authenticated read, writes locked
```

Rule values: `null` = superusers only, `""` = public, any other string = a PocketBase filter
expression. `manageRule` applies to auth collections only.

#### Template with Custom Overrides

```typescript
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
    },
  },
});
```

### Defining Indexes

Declare database indexes through `defineCollection({ indexes })`:

```typescript
import { defineCollection } from "pocketbase-zod-schema";

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)',
  ],
});
```

---

## CLI Reference

### Commands

```bash
# Generate migration from schema changes
npx pocketbase-migrate generate

# Show what would be generated without writing files
npx pocketbase-migrate status

# Compare the files on disk against PocketBase's _migrations table
npx pocketbase-migrate status --verify

# Check migrations against PocketBase's goja runtime (Node-only globals, async, ...)
npx pocketbase-migrate lint

# Generate TypeScript definitions from schemas
npx pocketbase-migrate generate-types

# Force generation even with destructive changes
npx pocketbase-migrate generate --force

# Round-trip up() and down() before writing; refuse a migration that doesn't roll back
npx pocketbase-migrate generate --verify

# Restrict the diff to matching collection or field names (regex supported)
npx pocketbase-migrate generate Posts Comments
```

### `generate` Command

```bash
pocketbase-migrate generate [filters...] [options]

Options:
  -o, --output <directory>  Output directory for migration files
  -f, --force               Force generation even with destructive changes or duplicates
  --dry-run                 Show what would be generated without writing files
  --schema-dir <directory>  Directory containing Zod schema files
  --verify                  Execute up() and down() before writing
  --no-verify               Skip verification even when enabled in the config file
```

One file is written per collection operation.

### `status` Command

```bash
pocketbase-migrate status [options]

Options:
  --schema-dir <directory>  Directory containing Zod schema files
  --json                    Output status as JSON
  --verify                  Compare disk against PocketBase's _migrations table; exit non-zero on drift
  --pb-data <path>          PocketBase data directory or data.db file
```

`--verify` needs Node >= 22.5 (`node:sqlite`).

### `lint` Command

```bash
pocketbase-migrate lint [files...] [options]

Options:
  -o, --output <directory>  Directory containing migration files
  --no-execute              Static checks only
```

Catches JavaScript that runs in Node but not in goja: `require`, `process`, `fetch`, `setTimeout`,
`async`/`await`, `import`/`export`, class fields. Exits non-zero on any error-severity finding.

### Global Options

```
  -c, --config <path>   Configuration file path
  -v, --version         Print the version   ← -v is version, not verbose
      --verbose         Enable verbose logging
      --quiet           Suppress non-essential output
      --no-color        Disable colored output
```

### `generate-types` Command

Generate type-safe TypeScript definitions from your Zod schemas.

```bash
pocketbase-migrate generate-types [options]

Options:
  -o, --output <path>        Output file path (default: pocketbase-types.ts)
  --schema-dir <directory>   Directory containing Zod schema files
```

**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<PostsResponse>;
     collection(idOrName: "users"): RecordService<UsersResponse>;
     // ... 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
```

### Configuration Options

```javascript
// pocketbase-migrate.config.js
export default {
  schema: {
    // Directory containing your Zod schema files
    directory: "./src/schema",

    // Files to exclude from schema discovery (replaces the default list)
    exclude: ["*.test.ts", "*.spec.ts", "base.ts", "index.ts"],
  },
  migrations: {
    // Directory to output migration files, and to reconstruct state from
    directory: "./pocketbase/pb_migrations",

    // Round-trip up()/down() before writing a migration (default: false)
    verify: false,

    // PocketBase data directory, for reading the _migrations table.
    // "" means the pb_data directory next to the migrations directory.
    dataDirectory: "",
  },
  diff: {
    // Warn when collections or fields would be deleted
    warnOnDelete: true,

    // Require --force flag for destructive changes
    requireForceForDestructive: true,
  },
  typeGen: {
    // Default output for `generate-types`
    outPath: "pocketbase-types.ts",
  },
};
```

There is no snapshot file to configure. The current database state is reconstructed by executing
the migration files: the newest `*_collections_snapshot.js` plus everything after it.

Environment overrides: `MIGRATION_SCHEMA_DIR`, `MIGRATION_SCHEMA_EXCLUDE`, `MIGRATION_OUTPUT_DIR`,
`MIGRATION_VERIFY`, `MIGRATION_DATA_DIR`, `MIGRATION_REQUIRE_FORCE`. Precedence is
CLI > environment > config file > defaults.

---

## Programmatic API

For custom workflows, use the programmatic API from `pocketbase-zod-schema/server`:

```typescript
import {
  parseSchemaFiles,
  compare,
  generate,
  loadSnapshotWithMigrations,
} from "pocketbase-zod-schema/server";

async function generateMigration() {
  const schemaDir = "./src/schema";
  const migrationsDir = "./pocketbase/pb_migrations";

  // Parse all schema files
  const currentSchema = await parseSchemaFiles({ schemaDir });

  // Reconstruct the current database state by executing the existing migrations.
  // Returns null when there is nothing to replay; throws SnapshotError if a
  // migration cannot be executed.
  const previousSnapshot = loadSnapshotWithMigrations({
    migrationsPath: migrationsDir,
  });

  // Compare schemas and detect changes
  const diff = compare(currentSchema, previousSnapshot);

  // Write one migration file per collection operation; returns the paths written
  const paths = generate(diff, migrationsDir);

  console.log(paths.length ? `Migrations created:\n${paths.join("\n")}` : "No changes detected");
}
```

The execution engine, round-trip verification and the goja lint are also available directly from
`pocketbase-zod-schema/server` — see the
[Execution Engine guide](https://github.com/dastron/pocketbase-zod-schema/blob/main/docs/EXECUTION_ENGINE.md).

---

## 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";

// 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",
  type: "auth", // auth collections must opt in explicitly; it is never inferred
  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";

// 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";

// 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'",
  },
});
```

---

## Documentation

Full guides live in the repository:

- [API Reference](https://github.com/dastron/pocketbase-zod-schema/blob/main/docs/API.md)
- [Execution Engine](https://github.com/dastron/pocketbase-zod-schema/blob/main/docs/EXECUTION_ENGINE.md)
- [Configuration](https://github.com/dastron/pocketbase-zod-schema/blob/main/docs/CONFIGURATION.md)
- [Migration Guide](https://github.com/dastron/pocketbase-zod-schema/blob/main/docs/MIGRATION_GUIDE.md)
- [Type Mapping](https://github.com/dastron/pocketbase-zod-schema/blob/main/docs/TYPE_MAPPING.md)
- [View Collections](https://github.com/dastron/pocketbase-zod-schema/blob/main/docs/VIEW_COLLECTIONS.md)
- [Naming Conventions](https://github.com/dastron/pocketbase-zod-schema/blob/main/docs/NAMING_CONVENTIONS.md)

## Requirements

Node.js 20 or higher. `status --verify`, which reads PocketBase's `_migrations` table, additionally
requires Node 22.5+ for `node:sqlite`.

## License

MIT
