---
title: "Offline-first replication"
description: "Keep a local copy of a person's rows on their device, work offline, and merge changes back to the server without losing edits."
section: "interfaces"
status: "Building for 1.1 · ships as a experimental API"
spec: "sync"
canonical: "https://mainsdk.dev/docs/interfaces/sync"
updated: "2026-10-28"
---

# Offline-first replication

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

Status: Building for 1.1 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/sync · 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.

> **Note:** Experimental. The device half is `mainsdk/experimental/sync` and the server half is `mainsdk/experimental/sync-server`. Text documents and presence stay in `collab`. The CLI command `mainsdk sync` is a different thing: it plans `main.yaml` changes.

## At a glance

- Status: Building for 1.1 · ships as a experimental API
- Phase: 3 (Mobile and regulated)
- Milestone: 1.1 Mobile
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/sync.md
- Category: Data

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()`

In main.yaml:

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

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

Pick one in main.yaml with `sync.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Local (in memory | `local` | yes |  |
| ElectricSQL | `electric` |  |  |
| PowerSync | `powersync` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-SYNC-0007` | check | 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` | runtime |  |
| `MAIN-SYNC-0002` | runtime |  |
| `MAIN-SYNC-0003` | runtime |  |
| `MAIN-SYNC-0004` | runtime |  |
| `MAIN-SYNC-0005` | runtime |  |
| `MAIN-SYNC-0006` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| sync@1 contract and the sync main.yaml section | 3 | 1.1 | `sync#contract` |
| Field-level merge on hybrid logical clocks | 3 | 1.1 | `sync#merge` |
| Conflict rules per table | 3 | 1.1 | `sync#merge` |
| Tenant and policy checked on every row | 3 | 1.1 | `sync#policy` |
| Deletes as tombstones | 3 | 1.1 | `sync#tombstones` |
| Schema version refusal | 3 | 1.1 | `sync#schema-version` |
| Erasure on every replica | 3 | 1.1 | `sync#erase` |
| Per-tenant quotas | 3 | 1.1 | `sync#quotas` |
| Local replica store and testing rig | 3 | 1.1 | `sync#local` |
| HTTP handler and transport | 3 | 1.1 | `sync#http` |
| Sync checks | 3 | 1.1 | `sync#checks` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
sync:
  driver: local
  tables:
    notes: { version: 1, conflicts: last-writer-wins }
  quota: { rows: 100000, bytes: 50MB, batch: 500 }
  max-clock-drift: 5m
```

```ts title="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

| 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 `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

```yaml title="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

| 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](https://mainsdk.dev/docs/reference/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](https://mainsdk.dev/docs/guides/testing).

## 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

- [Use offline-first replication in your app](https://mainsdk.dev/docs/guides/use-sync): the steps, end to end.
- [Realtime](https://mainsdk.dev/docs/interfaces/realtime)
- [Access](https://mainsdk.dev/docs/interfaces/access)
