Offline-first replication
syncKeep a local copy of a person's rows on their device, work offline, and merge changes back to the server without losing edits.
syncFree in your app, Apache-2.0Status 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.
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: 500import { sync } from "mainsdk/experimental/sync";
const replica = await sync.open({ tables: ["notes"], for: user, transport }); // local copy, scoped to the user's tenant
await replica.notes.insert({ text: "works offline" }); // merged when the device is back online
await replica.flush();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.
sync:
driver: localChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-SYNC-0007 | Checkmainsdk check, before deploy | A 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-0001 | RuntimeError responses and logs | See the spec |
MAIN-SYNC-0002 | RuntimeError responses and logs | See the spec |
MAIN-SYNC-0003 | RuntimeError responses and logs | See the spec |
MAIN-SYNC-0004 | RuntimeError responses and logs | See the spec |
MAIN-SYNC-0005 | RuntimeError responses and logs | See the spec |
MAIN-SYNC-0006 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| sync@1 contract and the sync main.yaml section | Phase 3 | 1.1 | sync#contract |
| Field-level merge on hybrid logical clocks | Phase 3 | 1.1 | sync#merge |
| Conflict rules per table | Phase 3 | 1.1 | sync#merge |
| Tenant and policy checked on every row | Phase 3 | 1.1 | sync#policy |
| Deletes as tombstones | Phase 3 | 1.1 | sync#tombstones |
| Schema version refusal | Phase 3 | 1.1 | sync#schema-version |
| Erasure on every replica | Phase 3 | 1.1 | sync#erase |
| Per-tenant quotas | Phase 3 | 1.1 | sync#quotas |
| Local replica store and testing rig | Phase 3 | 1.1 | sync#local |
| HTTP handler and transport | Phase 3 | 1.1 | sync#http |
| Sync checks | Phase 3 | 1.1 | sync#checks |
Quick start
sync:
driver: local
tables:
notes: { version: 1, conflicts: last-writer-wins }
quota: { rows: 100000, bytes: 50MB, batch: 500 }
max-clock-drift: 5mimport { 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
| Key | Default | Meaning |
|---|---|---|
driver | local | Where the server keeps rows. local is in memory, for tests and mainsdk dev |
tables | none | Each synced table with its schema version and its conflicts rule |
tables.<name>.conflicts | last-writer-wins | last-writer-wins, server-wins, or action:<name> to settle a clash with your own action |
quota | 100000 rows, 50MB, 500 | The rows, bytes and changes in one push a tenant may use |
max-clock-drift | 5m | How far ahead of the server's clock a stamp may be |
page | 500 | Rows in one pull |
What you get
| Call | What 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
collabuses. - 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
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
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
| Code | When | Fix |
|---|---|---|
MAIN-SYNC-0001 | A 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-0002 | The device's schema version of a table differs from sync.tables (409) | Update the app so its table version matches |
MAIN-SYNC-0003 | The tenant is over sync.quota (429); the push changes nothing | Delete rows, or raise the quota |
MAIN-SYNC-0004 | A 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-0005 | A change is stamped further ahead than max-clock-drift; that row is dropped | Fix the device clock |
MAIN-SYNC-0006 | The person was erased (410); the device wipes its copy | Sign 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.eraseremoves a person's rows here when the server is built withprivacyandtenant; 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
- Use offline-first replication in your app: the steps, end to end.
- Realtime
- Access
Was this page helpful?