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 codegenThe server output shows the migration result:
Schema pushed successfully
Table "todos":
Added columns: priority, dueDateExisting 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:
- The server adds the
priorityNumcolumn. - The backfill runs once for every existing document, setting
priorityNumbased on the oldprioritystring. - The backfill is recorded in
_valet_metaso 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 updatedBackfill 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 --watchEvery 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