MainSDK
Search⌘K
Interfaces

Data

Offline-first replication

sync

Keep a local copy of a person's rows on their device, work offline, and merge changes back to the server without losing edits.

Building for 1.1ships as a experimental APIPhase 3 · Mobile and regulatedSpec syncFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

sync keeps a local copy of a person's rows on their device, so the app works with no network and merges back when it is online. Reach for it for notes, tasks and field work.

The app pushes what it did and pulls what others did when it flushes. Two devices that changed the same row while apart keep both changes, field by field. Every row is checked for the caller's tenant and access on its way in and on its way out, and erasing a person reaches every device.

At a glance

sync:
  driver: local
  tables:
    notes: { version: 1, conflicts: last-writer-wins }
  quota: { rows: 100000, bytes: 50MB, batch: 500 }
  max-clock-drift: 5m
  page: 500

Calls

  • sync.open({ tables, for, transport, storage?, now?, ids? })
  • replica.status()
  • replica.flush({ signal })
  • replica.conflicts()
  • replica.close()
  • table.insert(values, { id? })
  • table.update(id, patch)
  • table.delete(id)
  • table.get(id)
  • table.list()
  • server.push(who, request)
  • server.pull(who, request)
  • server.erase(subject, { tenant })
  • server.transport(who)
  • server.handler(who)
  • httpTransport(url, { fetch })
  • memoryStorage()

Guarantees

Whatever the driver.

  • A device holds only rows its person may read in their tenant, checked on every pushed and pulled row
  • Offline edits are never dropped silently; two edits to different fields both survive and every clash is recorded
  • Deletes travel as tombstones, and erasing a person reaches every device replica
  • A device on another schema version is refused and keeps its queue

Drivers

3 drivers. Select one to see its line in main.yaml.

main.yamlLocal (in memory (default)
sync:
  driver: local

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-SYNC-0007Checkmainsdk check, before deployA table under sync.tables has no whole-number version, or a conflicts value that is not last-writer-wins, server-wins or action:<name>
MAIN-SYNC-0001RuntimeError responses and logsSee the spec
MAIN-SYNC-0002RuntimeError responses and logsSee the spec
MAIN-SYNC-0003RuntimeError responses and logsSee the spec
MAIN-SYNC-0004RuntimeError responses and logsSee the spec
MAIN-SYNC-0005RuntimeError responses and logsSee the spec
MAIN-SYNC-0006RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
sync@1 contract and the sync main.yaml sectionPhase 31.1sync#contract
Field-level merge on hybrid logical clocksPhase 31.1sync#merge
Conflict rules per tablePhase 31.1sync#merge
Tenant and policy checked on every rowPhase 31.1sync#policy
Deletes as tombstonesPhase 31.1sync#tombstones
Schema version refusalPhase 31.1sync#schema-version
Erasure on every replicaPhase 31.1sync#erase
Per-tenant quotasPhase 31.1sync#quotas
Local replica store and testing rigPhase 31.1sync#local
HTTP handler and transportPhase 31.1sync#http
Sync checksPhase 31.1sync#checks

Quick start

main.yaml
sync:
  driver: local
  tables:
    notes: { version: 1, conflicts: last-writer-wins }
  quota: { rows: 100000, bytes: 50MB, batch: 500 }
  max-clock-drift: 5m
app/sync.ts
import { sync } from "mainsdk/experimental/sync";
import { SyncServer } from "mainsdk/experimental/sync-server";

declare const config: Record<string, unknown>;        // the sync section of main.yaml
declare const access: ConstructorParameters<typeof SyncServer>[0]["access"];

const server = new SyncServer({ config, access });
const who = { id: "u_1", tenant: "t1" };

const replica = await sync.open({ tables: { notes: { version: 1 } }, for: who, transport: server.transport(who) });
await replica.notes.insert({ text: "works offline" });
await replica.flush();

In an app the device and the server are different machines: the server serves server.handler(who) over HTTP and the device opens its replica with httpTransport(url, { fetch }).

Configuration

KeyDefaultMeaning
driverlocalWhere the server keeps rows. local is in memory, for tests and mainsdk dev
tablesnoneEach synced table with its schema version and its conflicts rule
tables.<name>.conflictslast-writer-winslast-writer-wins, server-wins, or action:<name> to settle a clash with your own action
quota100000 rows, 50MB, 500The rows, bytes and changes in one push a tenant may use
max-clock-drift5mHow far ahead of the server's clock a stamp may be
page500Rows in one pull

What you get

CallWhat it does
sync.open({ tables, for, transport, storage?, now?, ids? })Open the person's replica. Each table gets insert, update, delete, get and list
replica.status()The state (idle, syncing, offline, blocked, erased), the rows waiting to be pushed and the conflicts kept
replica.flush({ signal })Push the queue and pull what changed. A lost network returns offline: true and leaves the queue as it was
replica.conflicts()The clashes and refusals recorded, with both values
replica.close()Save the replica's bookkeeping and stop taking calls
table.insert(values, { id? })Add a row now; it is pushed on the next flush
table.update(id, patch)Change fields of a row
table.delete(id)Delete a row with a tombstone
table.get(id)Read one row from the local copy
table.list()Read the rows of the table from the local copy
server.push(who, request)Merge a device's changes, checking tenant, access, version, clock and quota
server.pull(who, request)Return the rows changed since a cursor that the person may read
server.erase(subject, { tenant })Remove the rows a person owns and tell every device
server.transport(who)The two calls a replica needs, in process
server.handler(who)A fetch handler for POST /push and POST /pull
httpTransport(url, { fetch })The device side of that handler
memoryStorage()The local replica store, in memory

Always:

  • Reads and writes never wait for the network. A write is saved to the replica's store before the call returns.
  • Two edits to different fields of one row both survive. Two edits to the same field keep the later one and record the clash.
  • The server checks the tenant and the person's access on every pushed and pulled row, with the same function collab uses.
  • A device on another schema version of a table is refused and keeps its queue.
  • Erasing a person removes their rows from every device, and wipes the devices of that person.

Examples

Keep the server's value on a table

main.yaml
sync:
  tables:
    prices: { version: 1, conflicts: server-wins }
    tags: { version: 1, conflicts: "action:merge-tags" }

With server-wins a device's clashing write is recorded in replica.conflicts() and the server's value stays. With action:merge-tags pass resolve to SyncServer; it receives the table, the field and both values and returns what the field becomes.

Store a replica on the device

ts
import { type SyncStorage } from "mainsdk/experimental/sync";

declare const indexedDb: SyncStorage;       // your IndexedDB or Expo SQLite driver

export const store: SyncStorage = indexedDb;

A store is five calls: get, set, delete, keys(prefix) and clear. Pass it as storage to sync.open.

Errors and checks

CodeWhenFix
MAIN-SYNC-0001A change names another tenant (403), or the person may not write a row (that row is reported)Grant access to the row, or sync with the right tenant
MAIN-SYNC-0002The device's schema version of a table differs from sync.tables (409)Update the app so its table version matches
MAIN-SYNC-0003The tenant is over sync.quota (429); the push changes nothingDelete rows, or raise the quota
MAIN-SYNC-0004A change or call is not well formed, or names a table that is not synced (400)Declare the table and use JSON values
MAIN-SYNC-0005A change is stamped further ahead than max-clock-drift; that row is droppedFix the device clock
MAIN-SYNC-0006The person was erased (410); the device wipes its copySign in as a new person

mainsdk check reports MAIN-SYNC-0007 for a synced table with no whole-number version or a conflicts value it does not know, with the line in main.yaml. Each code's cause and fix is in check codes.

Swapping drivers

The server keeps rows through a store port and the device through a replica store port. local keeps both in memory. A database driver for the server and IndexedDB and Expo SQLite stores for devices plug into the same ports, and ElectricSQL and PowerSync come later as drivers. The calls do not change.

Testing

syncRig() from @mainsdk/testing gives a server on a simulated clock, devices whose network you switch off and whose clocks you skew, and an access rule you set. No network and no timers: time moves when the test moves it. See Test your app.

Security and privacy

  • A pushed change stamped for another tenant fails the whole push. Every row is checked for write on the way in and for read on the way out.
  • The server sets a row's owner from the caller, never from the device, and refuses stamps from the future.
  • privacy.erase removes a person's rows here when the server is built with privacy and tenant; devices learn on their next pull.
  • Rows are stored in the tenant's region record. Fields carry no author, so erasing removes the rows a person owns, not their edits inside rows others own.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet