Valet

Common Patterns

Recipes for multi-tenant, shared, and public data patterns.

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

export default defineSchema({
    teams: defineTable({
        name: v.string(),
        memberIds: v.array(v.string()),
    }).sync({
        filter: (q, ctx) => q.arrayContains('memberIds', ctx.auth.userId),
        mode: 'full',
    }),

    projects: defineTable({
        title: v.string(),
        teamId: v.id('teams'),
        status: v.string(),
    }).sync({
        filter: (q, ctx) =>
            q.in('teamId',
                q.select('_id').from('teams').where(
                    q.arrayContains('memberIds', ctx.auth.userId)
                )
            ),
        mode: 'full',
    }),

    tasks: defineTable({
        title: v.string(),
        projectId: v.id('projects'),
        assigneeId: v.optional(v.string()),
        completed: v.number(),
        createdAt: v.number(),
    }).sync({
        filter: (q, ctx) =>
            q.in('projectId',
                q.select('_id').from('projects').where(
                    q.in('teamId',
                        q.select('_id').from('teams').where(
                            q.arrayContains('memberIds', ctx.auth.userId)
                        )
                    )
                )
            ),
        mode: 'full',
    }),
})

This schema models a team workspace where users see teams they belong to, projects in those teams, and tasks in those projects. The sync filters chain through subqueries to enforce access at every level.

User-owned data

The most common pattern: each user sees their own documents.

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

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

The sync filter q.eq('userId', ctx.auth.userId) ensures each user's device receives only their documents. When a user creates a note, only their device (and any other devices they are logged into) receives the sync delta.

Team and workspace data

Use a subquery to sync documents that belong to a team the user is a member of:

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

export default defineSchema({
    teams: defineTable({
        name: v.string(),
        memberIds: v.array(v.string()),
    }).sync({
        filter: (q, ctx) => q.arrayContains('memberIds', ctx.auth.userId),
        mode: 'full',
    }),

    documents: defineTable({
        title: v.string(),
        content: v.string(),
        teamId: v.id('teams'),
    }).sync({
        filter: (q, ctx) =>
            q.in('teamId',
                q.select('_id').from('teams').where(
                    q.arrayContains('memberIds', ctx.auth.userId)
                )
            ),
        mode: 'full',
    }),
})

The documents table uses q.in() with a subquery: sync a document if its teamId is in the set of team _id values where the user is a member. When a user is added to a team, they automatically start receiving that team's documents.

Public data

For data that all users can see (feature flags, app configuration, public content), omit the filter and use mode: 'full':

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

Every connected client receives every document in the table. When no filter is provided with mode: 'full', all documents in the table sync to every client.

Server-only tables

For data that should never sync to clients (analytics events, audit logs, server-side configuration), use mode: 'none':

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

export default defineSchema({
    analyticsEvents: defineTable({
        eventName: v.string(),
        userId: v.string(),
        payload: v.string(),
        timestamp: v.number(),
    }).sync({
        mode: 'none',
    }),
})

Server-only tables are accessible in execution: 'server' queries and mutations but are never sent to clients. The server does not create change-tracking triggers for these tables.

Windowed sync

For tables with high document volume where clients only need recent data, use windowed sync:

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

export default defineSchema({
    messages: defineTable({
        conversationId: v.id('conversations'),
        authorId: v.string(),
        body: v.string(),
        createdAt: v.number(),
    }).sync({
        filter: (q, ctx) =>
            q.in('conversationId',
                q.select('_id').from('conversations').where(
                    q.arrayContains('participantIds', ctx.auth.userId)
                )
            ),
        mode: 'windowed',
        window: { field: 'createdAt', duration: 7 * 24 * 60 * 60 * 1000 },
    }),

    activityFeed: defineTable({
        userId: v.string(),
        action: v.string(),
        targetId: v.string(),
        createdAt: v.number(),
    }).sync({
        filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
        mode: 'windowed',
        window: { field: 'createdAt', duration: 30 * 24 * 60 * 60 * 1000 },
    }),
})

Windowed sync sends documents where the window.field value falls within duration milliseconds of the current time. Older documents are not synced, keeping the client's local database small. The messages table above syncs the last 7 days; activityFeed syncs the last 30 days.

Nested relationships with subqueries

Model parent-child relationships by chaining subqueries in sync filters. A common pattern is conversations with messages:

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

export default defineSchema({
    conversations: defineTable({
        title: v.string(),
        participantIds: v.array(v.string()),
        lastMessageAt: v.number(),
    }).sync({
        filter: (q, ctx) => q.arrayContains('participantIds', ctx.auth.userId),
        mode: 'full',
    }),

    messages: defineTable({
        conversationId: v.id('conversations'),
        authorId: v.string(),
        body: v.string(),
        createdAt: v.number(),
    }).sync({
        filter: (q, ctx) =>
            q.in('conversationId',
                q.select('_id').from('conversations').where(
                    q.arrayContains('participantIds', ctx.auth.userId)
                )
            ),
        mode: 'windowed',
        window: { field: 'createdAt', duration: 7 * 24 * 60 * 60 * 1000 },
    }),
})

The user sees conversations they participate in, and messages in those conversations. Adding a user to a conversation's participantIds array grants them access to that conversation's messages.

Combining filters

Use q.and() and q.or() to compose multiple conditions:

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

export default defineSchema({
    tasks: defineTable({
        title: v.string(),
        assigneeId: v.string(),
        teamId: v.id('teams'),
        isPublic: v.number(),
    }).sync({
        filter: (q, ctx) =>
            q.or(
                q.eq('assigneeId', ctx.auth.userId),
                q.and(
                    q.eq('isPublic', 1),
                    q.in('teamId',
                        q.select('_id').from('teams').where(
                            q.arrayContains('memberIds', ctx.auth.userId)
                        )
                    )
                )
            ),
        mode: 'full',
    }),
})

This syncs a task if it is assigned to the user OR if it is public and belongs to one of the user's teams.

Denormalization for local queries

Local queries (execution: 'local') run against the client's SQLite database. They cannot join across tables. If a query needs data from multiple tables, denormalize at write time:

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

export default defineSchema({
    messages: defineTable({
        conversationId: v.id('conversations'),
        authorId: v.string(),
        authorName: v.string(),  // denormalized from users table
        body: v.string(),
        createdAt: v.number(),
    }).sync({
        filter: (q, ctx) =>
            q.in('conversationId',
                q.select('_id').from('conversations').where(
                    q.arrayContains('participantIds', ctx.auth.userId)
                )
            ),
        mode: 'windowed',
        window: { field: 'createdAt', duration: 7 * 24 * 60 * 60 * 1000 },
    }),
})
// valet/messages.ts
import { defineMutation, v } from 'valet-dev/server'

export const send = defineMutation({
    args: {
        conversationId: v.string(),
        body: v.string(),
    },
    handler: async (ctx, args) => {
        const user = await ctx.db.get(ctx.auth?.userId as any)
        return ctx.db.insert('messages', {
            conversationId: args.conversationId,
            authorId: ctx.auth?.userId ?? '',
            authorName: (user as any)?.name ?? 'Unknown',
            body: args.body,
            createdAt: Date.now(),
        })
    },
})

The mutation handler reads the user's name at write time and stores it on the message document. The local query can then display the author name without needing to look up the users table:

// valet/messages.ts
import { defineQuery, v } from 'valet-dev/server'

export const list = defineQuery({
    args: { conversationId: v.string() },
    execution: 'local',
    handler: async (ctx, args) => {
        return ctx.db.query('messages')
            .filter((q) => q.eq('conversationId', args.conversationId))
            .order('createdAt', 'asc')
            .collect()
    },
})

The tradeoff: if a user changes their name, existing messages still show the old name. Update the denormalized field in a mutation if you need it to stay current.

On this page