Server API
Use the server entrypoint to validate sync requests and connect them to your application's database handlers.
Create an endpoint
Import the server entrypoint:
import { rejectSync, valtioSync } from 'valtio-sync/server'
Create a server with the same schema used by the client and export its handle method:
const syncServer = valtioSync({
schema: { account, todos },
getContext: async (request) => ({
user: await requireUser(request),
}),
handlers: {
account: {
update: async ({ ctx, patch }) => {
const row = await updateAccount(ctx.user.id, patch)
return { serverVersion: row.version, record: row }
},
},
todos: {
readChanges: async ({ ctx, since }) => readTodoChanges(ctx.user.id, since),
create: async ({ ctx, record }) => {
const row = await insertTodo(ctx.user.id, record)
return { serverVersion: row.version, record: row }
},
update: async ({ ctx, op, patch }) => {
const row = await updateTodo(ctx.user.id, op.id, patch)
return { serverVersion: row.version, record: row }
},
delete: async ({ ctx, op }) => {
const version = await deleteTodo(ctx.user.id, op.id)
return { serverVersion: version }
},
},
},
})
export const POST = syncServer.handle
The authentication and database functions in this example are application code that you must provide.
Authenticate requests
getContext runs once per request. Use it for authentication, tenant lookup, and dependencies needed by that request.
Handlers receive { request, ctx } plus data for the operation.
Handle mutations
Mutation handlers receive validated data:
- Account
update:{ op, patch }. - Collection
create:{ op, record }. - Collection
update:{ op, patch }. - Collection
delete:{ op }.
Return { serverVersion, record? }. Include record when the server canonicalizes, fills defaults, or wants the client to replace its local value with the server value.
Handle retries without duplicate writes
Use op.mutationId to recognize an operation you have already accepted for this user. If the response is lost, the client may send the same operation again.
Common approaches are:
- Store processed mutations in a table keyed by user and mutation ID.
- Make create and update handlers return the original accepted result when they receive a duplicate.
This also matters during signup, when an anonymous cache may upload many new records at once.
Return changes to the client
Incremental changes
Use readChanges when you have a durable sequence or event log:
readChanges: async ({ ctx, since }) => ({
serverSeq: await readLatestSeq(ctx.user.id),
changes: {
upserted: [{ id: 'todo_1', serverVersion: 12, record: { id: 'todo_1', title: 'Remote' } }],
deleted: [],
},
})
Omit mode for normal incremental results. Incremental changes upsert and delete only
the listed records, leaving other local records alone.
First sync on a device
First sync for a new device sends since: null. If a collection defines
readChanges, that handler must treat since: null as a bootstrap read and
return enough data to initialize the collection for the authenticated user. When
the change feed cannot reconstruct complete state for a cursorless client,
return an authoritative snapshot from readChanges by setting
changes.mode: "snapshot".
Full snapshots
Use readSnapshot when the server can only return a full snapshot:
readSnapshot: async ({ ctx }) => ({
serverSeq: await readLatestVersion(ctx.user.id),
changes: {
upserted: (await readAllTodos(ctx.user.id)).map((row) => ({
id: row.id,
serverVersion: row.version,
record: row,
})),
deleted: [],
},
})
readSnapshot results are marked as mode: "snapshot" by the server handler. A
snapshot describes the complete server state for that collection. The client:
- Applies the listed records.
- Applies explicit deletes.
- Removes clean local records missing from the snapshot.
Dirty or rejected local records are preserved.
If both readChanges and readSnapshot are defined for a collection,
readChanges is used; readSnapshot is not an automatic fallback for
since: null.
Sync event retention
Your app decides how long to keep sync events and how to clean them up. It also owns the database tables and any tracking of client cursors.
A cursor is the server sequence up to which a client has saved changes. If the events after that cursor are no longer available, return a full snapshot.
Keep a small change feed
Treat a sync event table as a retained change feed, not as a permanent audit log. Keep rows small:
sync_events
account_id or user_id
seq
collection
record_id
op
created_at
For upserts, readChanges can read the current application row by record_id.
For deletes, keep the delete event until it is past the retained floor, or fall
back to an authoritative snapshot for stale clients.
Fall back to a snapshot after cleanup
A simple retention policy is:
- Keep events for 30ā90 days, or keep the latest N events per account.
- Record the oldest cursor that the remaining events can answer.
- Return
mode: "snapshot"whensinceis older than that cursor.
When readChanges uses a retained event table, return an authoritative snapshot
if the client has no cursor or if its since cursor is older than the retained
floor. In readChanges, mode belongs inside changes:
readChanges: async ({ ctx, since }) => {
if (since === null || since < (await readRetainedFloorSeq(ctx.user.id))) {
return {
serverSeq: await readLatestSeq(ctx.user.id),
changes: {
mode: 'snapshot',
upserted: (await readAllTodos(ctx.user.id)).map((row) => ({
id: row.id,
serverVersion: row.version,
record: row,
})),
deleted: [],
},
}
}
return {
serverSeq: await readLatestSeq(ctx.user.id),
changes: await readTodoChanges(ctx.user.id, since),
}
}
Track active clients for tighter cleanup
Optionally, track active clients:
sync_clients
account_id or user_id
client_id
last_server_seq
last_seen_at
Update sync_clients.last_server_seq from the client's incoming
lastServerSeq, not from the new serverSeq being returned. The response might
not reach the client, so the next request is the first proof that the client
durably observed that cursor.
A cleanup job can delete events at or below the minimum last_server_seq for
active clients. Ignore clients whose last_seen_at is older than the offline
window your application supports, then rely on snapshot fallback if they return
later.
Reject an operation
Reject an operation with an app-defined reason:
if (!canEdit(ctx.user, op.id)) {
rejectSync('forbidden', 'No edit permission')
}
if (op.baseServerVersion !== row.version) {
rejectSync('conflict', 'Base version is stale', {
serverVersion: row.version,
serverRecord: row,
})
}
Request and response validation
The server validates sync requests before calling handlers and validates returned changes before
responding. Invalid operations are returned in rejected rather than crashing the whole sync
request.
IDs must agree throughout each operation:
- A create operation's
idmust matchvalue.id. - Returned records and change-feed upserts must match the ID that accompanies them.
- Account operations and changes must use the
singletonID.
Mismatched client input is rejected before application handlers run. Mismatched handler output is reported as a server error.