Valet

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

ValidatorTypeScript typeDescription
v.string()stringUTF-8 string
v.number()number64-bit floating-point number
v.int()number64-bit integer
v.boolean()booleantrue or false
v.bytes()ArrayBufferBinary data
v.null()nullThe value null

Reference validators

ValidatorTypeScript typeDescription
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

ValidatorTypeScript typeDescription
v.array(v.string())string[]Array of values
v.object({ k: v.string() }){ k: string }Nested object
v.optional(v.string())string | undefinedField may be absent
v.union(v.string(), v.number())string | numberOne 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 needed

Sync 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 codegen

Codegen 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

On this page