Valet

Local-First Architecture

Client-side SQLite, offline sync, and the rebase model.

valet/tasks.ts
import { defineQuery } from '../_generated/valet/api'

export const list = defineQuery({
    args: {},
    execution: 'local',
    handler: async (ctx) => ctx.db.query('tasks').collect(),
})

This query reads from a SQLite database on the client. No network round-trip. No loading spinner. Works offline.

What local-first means

Traditional client-server apps fetch data over the network on every read. Every interaction waits for a round-trip. If the network drops, the app breaks.

Local-first apps store data on the client. Reads hit a local database and return instantly. Writes apply locally first, then sync to the server in the background. The app keeps working whether the network is available or not.

Valet stores your data in SQLite running on the client (via WebAssembly on web, native SQLite on React Native). The UI renders from local data, and Valet handles synchronization transparently.

The client stack

Three components make up the client-side architecture:

LocalDatabase is a SQLite database running on the client. On web, it uses wa-sqlite compiled to WebAssembly. On React Native, it uses native SQLite. All operations are serialized through a mutex for safe concurrent access.

SyncEngine bridges the local database and the server. It applies incoming deltas to local SQLite, tracks which documents have been synced, and manages the mutation log for offline replay.

ValetClient manages the WebSocket connection, handles authentication, and coordinates queries and mutations between client and server.

How local queries work

When you set execution: 'local' on a query, the handler runs against the client's SQLite database:

valet/tasks.ts
import { defineQuery, v } from '../_generated/valet/api'

export const listCompleted = defineQuery({
    args: { completed: v.number() },
    execution: 'local',
    handler: async (ctx, args) => {
        return ctx.db
            .query('tasks')
            .filter((q) => q.eq('completed', args.completed))
            .collect()
    },
})

The data in the local database comes from sync filters you define in your schema. Valet syncs documents from the server based on those filters, and local queries read from that synced data. No network call. Instant results. Works offline.

How mutations work

Mutations are server-authoritative. The server is the source of truth for all writes.

valet/tasks.ts
import { defineMutation, v } from '../_generated/valet/api'

export const create = defineMutation({
    args: { title: v.string() },
    handler: async (ctx, args) => {
        return ctx.db.insert('tasks', {
            title: args.title,
            completed: 0,
            userId: ctx.auth?.userId ?? 'anonymous',
        })
    },
})

When online, the mutation is sent to the server. The server executes the handler, persists the result, and broadcasts deltas to all connected clients (including the one that sent the mutation).

When offline (with SyncEngine configured), the handler runs locally against the client's SQLite database as an optimistic update. The mutation is also recorded in the mutation log for later sync.

Offline flow

  1. The handler executes against local SQLite (optimistic update)
  2. The UI updates immediately with the local result
  3. The mutation is recorded in the mutation log with captured environment
  4. On reconnect, the client receives fresh server state via sync
  5. Pending mutations are rebased against the fresh state (see below)
  6. Rebased mutations are sent to the server for execution
  7. The server confirms or rejects each mutation
  8. On rejection, the local optimistic write rolls back

Conflict resolution via rebase

When a client reconnects after being offline, its pending mutations may be based on stale data. Valet resolves this with a rebase model, conceptually similar to git rebase.

On reconnect:

  1. The server sends fresh state via the active sync subscriptions
  2. The SyncEngine writes the fresh state to local SQLite
  3. Pending mutations from the mutation log are replayed against the fresh state
  4. Each handler re-executes against current data, producing updated results
  5. The rebased mutations are sent to the server

Why rebase instead of merge

Merge-based approaches reconcile values. Rebase reconciles intent.

Consider a counter increment. Two clients both read a counter value of 5 and write 6:

ApproachClient A writesClient B writesResult
Merge (last-write-wins)666 (one increment lost)
Rebase6reads 6, writes 77 (both increments preserved)

With merge, the system sees two independent writes of 6 and picks one. With rebase, Client B's handler re-executes against Client A's already-committed state, reads 6, and writes 7. The intent (increment) is preserved.

Deterministic replay

Replaying a handler must produce the same non-deterministic values (IDs, timestamps, random numbers) as the original execution. Valet captures these values on first execution and replays them identically:

GlobalBehavior
crypto.randomUUID()Captured on first execution, replayed identically
Math.random()Captured on first execution, replayed identically
Date.now()Frozen to the captured timestamp
new Date()Uses the patched Date.now(), so also deterministic

You write standard JavaScript. No special APIs required:

valet/tasks.ts
import { defineMutation, v } from '../_generated/valet/api'

export const create = defineMutation({
    args: { title: v.string() },
    handler: async (ctx, args) => {
        const id = crypto.randomUUID() // deterministic on replay
        const now = Date.now()          // deterministic on replay

        return ctx.db.insert('tasks', {
            title: args.title,
            externalId: id,
            createdAt: now,
        })
    },
})

Online concurrent writes

Rebase handles the offline case. For concurrent writes from multiple online clients, Valet provides two mechanisms:

Field-level last-write-wins: When clients update different fields or don't specify a version, the last write the server processes is the value that persists. All clients converge to the same state with no conflict errors or merge dialogs.

Optimistic locking with _version: When clients need to prevent lost updates to the same document, they can use the _version field. Each document has a _version that increments on every update. If a client specifies a _version in a write operation and it doesn't match the current version, the server returns a VERSION_CONFLICT error. The client must handle this error and retry with fresh data.

Example of optimistic locking:

// Read current document
const doc = await ctx.db.get('todos', todoId)

// Update with version check
try {
  await ctx.db.update('todos', todoId, {
    _version: doc._version,  // Fails if another client updated it
    completed: 1
  })
} catch (err) {
  if (err.code === 'VERSION_CONFLICT') {
    // Another client modified this document
    // Re-read and retry
  }
}

Optimistic locking is complementary to rebase:

  • Rebase resolves conflicts for offline mutations by replaying operations against fresh state
  • Optimistic locking prevents conflicts for online writes by detecting stale versions and failing fast

When to use server execution mode

Not all queries and mutations should run locally. Use execution: 'server' when:

  • The query needs data the client does not have. Cross-user aggregations, full-text search, or analytics queries that span more data than any single client syncs.
  • The mutation must validate server-side. Payments, permission checks, or any operation where the server must enforce rules before persisting.
  • The dataset is too large to sync to the client. If syncing all matching documents to the client is impractical, run the query on the server and subscribe to the results.

Server queries subscribe via WebSocket and receive delta updates. Server mutations wait for the server response before updating the UI.

See How Sync Works for details on the sync protocol.

On this page