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:

  1. Applies the listed records.
  2. Applies explicit deletes.
  3. 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:

  1. Keep events for 30–90 days, or keep the latest N events per account.
  2. Record the oldest cursor that the remaining events can answer.
  3. Return mode: "snapshot" when since is 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 id must match value.id.
  • Returned records and change-feed upserts must match the ID that accompanies them.
  • Account operations and changes must use the singleton ID.

Mismatched client input is rejected before application handlers run. Mismatched handler output is reported as a server error.