Sync Rules
Control which documents sync to which users.
// 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),
}),
})Sync filters control which documents from a table sync to each user's client-side SQLite replica. Without a sync filter, no documents sync for that table. The filter runs on the server and determines document membership per user -- the client never sees documents it should not have.
Configuring sync
Call .sync() on a table definition with a mode and a filter:
// 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),
}),
})The filter callback receives a filter builder (q) and a context object (ctx) with the authenticated user's information. Return a filter expression that matches the documents this user should receive.
Sync modes
Full sync
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
settings: defineTable({
theme: v.string(),
userId: v.string(),
}).sync({
mode: 'full',
filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
}),
})With mode: 'full', all matching documents sync to the client and stay in sync. Insertions, updates, and deletions are pushed in real time. Use full sync for small-to-medium datasets where the client needs all matching documents.
Windowed sync
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
messages: defineTable({
text: v.string(),
conversationId: v.id('conversations'),
createdAt: v.number(),
}).sync({
mode: 'windowed',
filter: (q, ctx) => q.eq('conversationId', ctx.auth.activeConversation),
window: { field: 'createdAt', duration: 7 * 24 * 60 * 60 * 1000 },
}),
})With mode: 'windowed', the client receives documents matching the filter whose specified field falls within the duration window (in milliseconds) relative to the current time. As documents age past the window, they are evicted from the client replica. Use windowed sync for large or append-heavy tables (chat messages, activity feeds).
No sync
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
analytics: defineTable({
event: v.string(),
timestamp: v.number(),
}).sync({
mode: 'none',
}),
})With mode: 'none', no documents sync to the client. The table is server-only. Use this for data that clients should never see (analytics, audit logs, internal state).
Filter builder
The sync filter builder supports the same operators as query filters, plus .in() and .select() for subqueries.
Comparison operators
| Operator | Description |
|---|---|
q.eq(field, v) | Field equals value |
q.neq(field, v) | Field does not equal value |
q.lt(field, v) | Field is less than value |
q.lte(field, v) | Field is less than or equal to value |
q.gt(field, v) | Field is greater than value |
q.gte(field, v) | Field is greater than or equal to value |
Collection operators
| Operator | Description |
|---|---|
q.oneOf(field, values) | Field is one of the given values |
q.in(field, subquery) | Field is in the result of a subquery |
q.arrayContains(field, v) | Array field contains value |
Null checks
| Operator | Description |
|---|---|
q.isNull(field) | Field is null |
q.isNotNull(field) | Field is not null |
Logical operators
| Operator | Description |
|---|---|
q.and(a, b, ...) | All conditions must match |
q.or(a, b, ...) | At least one condition must match |
q.not(expr) | Negate a condition |
Subqueries
Use q.select() and q.in() for sync filters that depend on another table. This is how you handle many-to-many relationships or indirect access.
Sync messages in conversations that a user belongs to:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
conversations: defineTable({
name: v.string(),
}),
members: defineTable({
conversationId: v.id('conversations'),
userId: v.string(),
}).sync({
mode: 'full',
filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
}),
messages: defineTable({
conversationId: v.id('conversations'),
text: v.string(),
authorId: v.string(),
createdAt: v.number(),
}).sync({
mode: 'windowed',
filter: (q, ctx) =>
q.in(
'conversationId',
q.select('conversationId')
.from('members')
.where((mq) => mq.eq('userId', ctx.auth.userId)),
),
window: { field: 'createdAt', duration: 30 * 24 * 60 * 60 * 1000 },
}),
})q.select('conversationId').from('members').where(...) produces the set of conversation IDs the user belongs to. q.in('conversationId', subquery) matches messages whose conversationId is in that set.
Auth context
The sync filter's ctx provides access to the authenticated user:
| Property | Type | Description |
|---|---|---|
ctx.auth.userId | string | The authenticated user's ID |
ctx.auth.claims | Record<string, any> | Custom claims from the JWT token |
If the user is not authenticated, ctx.auth is undefined and the sync filter receives no documents.
Common patterns
User-owned documents
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(),
body: v.string(),
userId: v.string(),
}).sync({
mode: 'full',
filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
}),
})Team-based access
Users see documents belonging to their team, using a claim from the JWT:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
projects: defineTable({
name: v.string(),
teamId: v.string(),
}).sync({
mode: 'full',
filter: (q, ctx) => q.eq('teamId', ctx.auth.claims.teamId),
}),
})Public documents
All authenticated users see the same documents:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
announcements: defineTable({
title: v.string(),
body: v.string(),
publishedAt: v.number(),
}).sync({
mode: 'full',
filter: (q, ctx) => q.isNotNull('publishedAt'),
}),
})Mixed access
Combine conditions with q.or() for documents with multiple access patterns:
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
documents: defineTable({
title: v.string(),
ownerId: v.string(),
isPublic: v.boolean(),
}).sync({
mode: 'full',
filter: (q, ctx) =>
q.or(
q.eq('ownerId', ctx.auth.userId),
q.eq('isPublic', true),
),
}),
})