Valet

Schema Migrations

Add, remove, and transform fields in your schema.

// valet/schema.ts (before)
import { defineSchema, defineTable, v } from 'valet-dev/server'

export default defineSchema({
    todos: defineTable({
        title: v.string(),
        completed: v.number(),
        userId: v.string(),
    }).sync({
        filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
        mode: 'full',
    }),
})
// valet/schema.ts (after -- added priority field)
import { defineSchema, defineTable, v } from 'valet-dev/server'

export default defineSchema({
    todos: defineTable({
        title: v.string(),
        completed: v.number(),
        userId: v.string(),
        priority: v.number(),
    }).sync({
        filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
        mode: 'full',
    }),
})

Add the field to defineTable, run codegen, and the server applies the migration automatically. No migration files, no CLI commands, no downtime.

How migrations work

When you run codegen, the schema is pushed to the server. The server diffs the new schema against the existing database and applies changes:

  • New tables are created with all columns.
  • New columns are added via ALTER TABLE ADD COLUMN.
  • Removed columns are kept in the database (never dropped) but excluded from types.
  • Type changes are rejected as breaking changes.

The server computes a deterministic hash of the schema. Pushing the same schema twice is a no-op.

Adding fields

Add the new field to your defineTable call:

// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'

export default defineSchema({
    todos: defineTable({
        title: v.string(),
        completed: v.number(),
        userId: v.string(),
        priority: v.number(),    // new field
        dueDate: v.optional(v.string()), // new optional field
    }).sync({
        filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
        mode: 'full',
    }),
})

Run codegen to push the schema and regenerate types:

npx valet-dev codegen

The server output shows the migration result:

Schema pushed successfully
  Table "todos":
    Added columns: priority, dueDate

Existing documents have null for the new columns until they are updated. Use v.optional() for fields that may not exist on older documents, or use a backfill to populate them.

Removing fields with .deprecated()

Call .deprecated() on the validator to remove a field from generated types while keeping it in the database:

// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'

export default defineSchema({
    todos: defineTable({
        title: v.string(),
        completed: v.number(),
        userId: v.string(),
        priority: v.string().deprecated(), // kept in DB, excluded from types
        priorityNum: v.number(),           // new replacement field
    }).sync({
        filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
        mode: 'full',
    }),
})

After running codegen, the priority field no longer appears in the generated TypeScript types. The column remains in the database so existing data is preserved and older clients still work.

Backfills with .backfill()

Use .backfill() to derive new column values from existing data during migration. This is the safe way to populate a new column based on existing fields.

Here is a complete example that adds a numeric priorityNum field derived from a string priority field:

// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'

export default defineSchema({
    todos: defineTable({
        title: v.string(),
        completed: v.number(),
        userId: v.string(),
        priority: v.string().deprecated(),
        priorityNum: v.number(),
    })
        .sync({
            filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
            mode: 'full',
        })
        .backfill('priorityNum', (row) => {
            const mapping: Record<string, number> = {
                high: 3,
                medium: 2,
                low: 1,
            }
            return mapping[row.priority as string] ?? 1
        }),
})

When codegen pushes this schema:

  1. The server adds the priorityNum column.
  2. The backfill runs once for every existing document, setting priorityNum based on the old priority string.
  3. The backfill is recorded in _valet_meta so it never runs again, even if the same schema is pushed multiple times.

The server output confirms the backfill:

Schema pushed successfully
  Table "todos":
    Added columns: priorityNum
    Backfill "priorityNum": 142 rows updated

Backfill rules

  • Backfills are idempotent. Each backfill runs exactly once per column, tracked by the key backfill:{table}:{column} in the server metadata.
  • Backfill functions receive the full document as an argument, including deprecated fields.
  • If a backfill throws an error for any document, the entire backfill fails and retries on the next push.
  • Backfill code should be deterministic -- it runs on both the server and client.

Type changes are not allowed

Changing a column's type (for example, from v.string() to v.number()) is a breaking change. The server rejects it because SQLite does not support ALTER TABLE ALTER COLUMN.

To change a field's type, add a new column with the desired type and backfill it:

// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'

export default defineSchema({
    todos: defineTable({
        title: v.string(),
        completed: v.number(),
        userId: v.string(),
        priority: v.string().deprecated(),  // old type, deprecated
        priorityNum: v.number(),            // new type
    })
        .sync({
            filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
            mode: 'full',
        })
        .backfill('priorityNum', (row) => {
            const mapping: Record<string, number> = {
                high: 3,
                medium: 2,
                low: 1,
            }
            return mapping[row.priority as string] ?? 1
        }),
})

Update your queries and mutations to use the new field, then remove the deprecated field in a future release.

Running codegen during development

Run codegen in watch mode so migrations apply automatically as you edit the schema:

npx valet-dev codegen --watch

Every time you save valet/schema.ts, codegen pushes the updated schema, applies any pending migrations, and regenerates your TypeScript types. This creates a tight feedback loop: edit schema, see updated types, write code against them.

Migration lifecycle summary

1. Edit schema.ts (add/remove/backfill fields)
2. Run `npx valet-dev codegen`
3. Codegen parses schema, generates schema.json
4. schema.json is pushed to the server
5. Server computes schema hash
   - If hash matches existing → no-op
   - If new → apply migrations:
     a. Create new tables
     b. Add missing columns
     c. Run backfills
     d. Store new schema hash
6. Codegen regenerates TypeScript types
7. Client-side SyncEngine also applies migrations locally on next connect

On this page