Schema
Tables, validators, indexes, and codegen.
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
todos: defineTable({
title: v.string(),
completed: v.boolean(),
priority: v.number(),
tags: v.array(v.string()),
createdAt: v.number(),
})
.index('by_priority', ['priority'])
.index('by_created', ['createdAt']),
users: defineTable({
name: v.string(),
email: v.string(),
avatar: v.optional(v.string()),
}).index('by_email', ['email']),
})A schema defines the tables in your database, the validators for each field, and the indexes for efficient queries. Every Valet project has a single schema.ts file in the valet/ directory.
Defining tables
Call defineSchema() with an object where each key is a table name and each value is a defineTable() call:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
todos: defineTable({
title: v.string(),
completed: v.boolean(),
}),
})Each table automatically gets an _id field of type Id<"tableName">. You do not declare _id in the schema -- it is generated for you.
Validators
Validators define the shape of each field in a table. Use the v object to create validators.
Primitive validators
| Validator | TypeScript type | Description |
|---|---|---|
v.string() | string | UTF-8 string |
v.number() | number | 64-bit floating-point number |
v.int() | number | 64-bit integer |
v.boolean() | boolean | true or false |
v.bytes() | ArrayBuffer | Binary data |
v.null() | null | The value null |
Reference validators
| Validator | TypeScript type | Description |
|---|---|---|
v.id("todos") | Id<"todos"> | A document ID referencing a table |
Use v.id() to create a typed reference to a document in another table:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
todos: defineTable({
title: v.string(),
completed: v.boolean(),
userId: v.id('users'),
}),
users: defineTable({
name: v.string(),
}),
})The Id<"users"> type is a branded string. Codegen produces this type so you cannot accidentally pass an Id<"todos"> where an Id<"users"> is expected.
Compound validators
| Validator | TypeScript type | Description |
|---|---|---|
v.array(v.string()) | string[] | Array of values |
v.object({ k: v.string() }) | { k: string } | Nested object |
v.optional(v.string()) | string | undefined | Field may be absent |
v.union(v.string(), v.number()) | string | number | One of several types |
v.literal("active") | "active" | Exact value |
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
tasks: defineTable({
title: v.string(),
metadata: v.object({
source: v.string(),
importedAt: v.number(),
}),
tags: v.array(v.string()),
status: v.union(
v.literal('active'),
v.literal('completed'),
v.literal('archived')
),
assignee: v.optional(v.id('users')),
}),
users: defineTable({
name: v.string(),
}),
})Deprecating fields
Use .deprecated() on a validator to mark a field as deprecated. Deprecated fields still validate but codegen omits them from the generated types, encouraging code to stop using them:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
todos: defineTable({
title: v.string(),
completed: v.boolean(),
done: v.boolean().deprecated(),
}),
})Indexes
Indexes make queries faster. Define them by chaining .index() on a table:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
todos: defineTable({
title: v.string(),
completed: v.boolean(),
priority: v.number(),
createdAt: v.number(),
})
.index('by_priority', ['priority'])
.index('by_completed_and_created', ['completed', 'createdAt']),
})The first argument is the index name. The second is an array of field names to include in the index. Compound indexes (multiple fields) support queries that filter or sort on those fields in order.
Search indexes
Search indexes enable full-text search on string fields:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
articles: defineTable({
title: v.string(),
body: v.string(),
category: v.string(),
}).searchIndex('search_articles', { searchField: 'body' }),
})The searchField is the field to index for full-text search.
The _id field
Every document has an _id field of type Id<"tableName">. This is a globally unique identifier generated by Valet when a document is inserted.
In TypeScript, Id<"todos"> is a branded string. You can use it as a string, but the type system prevents mixing document IDs from different tables:
// valet/todos.ts
import { defineQuery, defineMutation, v } from './_generated/valet/api'
export const get = defineQuery({
args: { id: v.id('todos') },
execution: 'local',
handler: async (ctx, args) => {
return ctx.db.get(args.id)
},
})
export const remove = defineMutation({
args: { id: v.id('todos') },
handler: async (ctx, args) => {
await ctx.db.delete(args.id)
},
})Converting between string and Id
Id<"tableName"> is a branded string — at runtime it is just a string, but the brand is a compile-time guard that prevents mixing IDs from different tables. When you receive a plain string from an external source (URL params, local storage, a third-party API), cast it to the appropriate Id type:
import type { Id } from './_generated/valet/api'
// From a URL parameter
const todoId = params.id as Id<'todos'>
const todo = await ctx.db.get(todoId)
// From local storage
const lastViewedId = localStorage.getItem('lastViewed') as Id<'todos'>Going the other direction is automatic — Id<"todos"> is assignable to string with no cast needed:
const id: Id<'todos'> = todo._id
const asString: string = id // works, no cast neededSync configuration
Tables need sync configuration to be synced to clients. Call .sync() on a table definition to control which documents sync to each user's device. Without .sync(), the table is not synced and is only accessible via server-side queries and mutations.
Syncing all rows
Use mode: 'full' to sync every row in the table to all clients. When no filter is provided, all rows sync to every user:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
featureFlags: defineTable({
name: v.string(),
enabled: v.number(),
}).sync({ mode: 'full' }),
})You can also write this explicitly with q.all():
}).sync({ mode: 'full', filter: (q) => q.all() }),Syncing with a filter
Pass a filter function to control which rows sync to each user. The filter receives a filter builder (q) and a context object (ctx) with the authenticated user's information:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
todos: defineTable({
title: v.string(),
completed: v.boolean(),
userId: v.string(),
}).sync({
mode: 'full',
filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
}),
})Each user's device only receives the documents that match their filter. See Sync Rules for the full filter builder API, subqueries, and advanced patterns.
Server-only tables
Use mode: 'none' to prevent a table from syncing to any client. Server-only tables are accessible in execution: 'server' queries and mutations but never leave the server:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
analyticsEvents: defineTable({
eventName: v.string(),
userId: v.string(),
timestamp: v.number(),
}).sync({ mode: 'none' }),
})Valet's built-in authTables use this mode — they are server-only and never synced to clients. See Auth tables for details.
Chaining with indexes
.sync() chains alongside .index() and .searchIndex():
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
todos: defineTable({
title: v.string(),
completed: v.boolean(),
userId: v.string(),
createdAt: v.number(),
})
.index('by_created', ['createdAt'])
.sync({
mode: 'full',
filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
}),
})Running codegen
After changing your schema, run codegen to regenerate types:
bunx valet-dev codegenCodegen reads valet/schema.ts, produces typed hooks and API objects in _generated/valet/, and pushes the schema to the server. If the schema has breaking changes (like changing a field's type), the push fails with an error explaining what changed.
During development, use watch mode to regenerate automatically on save:
bunx valet-dev codegen --watch