Networked access

Device sync

Keep an end-user device's local database in step with a server, offline-first and bidirectional, pushing local writes, pulling everyone else's writes live, resolving conflicts on both sides, and reporting what the loop is doing.

Table of Contents

Device sync keeps an end-user device's local database and a server database in step, offline-first and bidirectional. A device pushes its own writes to the server and pulls everyone else's writes live over a WebSocket, and both sides apply changes through the same conflict resolvers that replication uses. It differs from distributed replication: replication moves primary-owned changes between servers over gRPC, while device sync connects one end-user device to a server over HTTP and WebSocket. A device isn't a replication peer and holds no primary authority. Each user has their own database file, and that file is the unit of sync, while the tables option names the tables whose live changes a device follows.

This page builds two files, server.ts and device.ts, and every step reprints the whole of the file it changes. Device sync recovery carries on from the device.ts this page finishes with, and covers the three situations a device can't sync its way out of.

Device sync is new and not yet proven in production, so treat this as an early feature whose API may still change before it stabilises.

Serve the tables a device syncs

The device-sync routes are built into the server. Watch the tables you want to sync, then start the server the usual way. It serves the push route, the live pull with acknowledgements and echo suppression, snapshots, the migration handshake, and the capabilities endpoint without extra configuration. Start server.ts here.

server.ts
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
 
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('notes', 'notes.db')
 
await db.execute(
  'CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, author TEXT NOT NULL, body TEXT NOT NULL)'
)
await db.watch('notes')
 
const server = createServer(sirannon, { port: 9876 })
await server.listen()

watch turns on the change log that device sync reads from, so watch every synced table on the server. The server owns the notes database and its notes table; a device opens a database with the same id and copies it.

Connect a device and start the sync loop

On the device, open a local database with the browser driver, watch the same tables, then drive the sync loop with a SyncController. Construct it with the server URL, the database id, and the tables to sync. Later sections add the callbacks that report what the loop is doing. The server.ts tab still holds the file from the previous step; the device.ts tab is the new file you add now.

import { Sirannon } from '@delali/sirannon-db'
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
import { SyncController } from '@delali/sirannon-db/client'
 
const driver = waSqlite()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('notes', 'notes.db')
 
await db.execute(
  'CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, author TEXT NOT NULL, body TEXT NOT NULL)'
)
await db.watch('notes')
 
const sync = new SyncController(db, {
  url: 'http://localhost:9876',
  databaseId: 'notes',
  tables: ['notes'],
})
 
await sync.start()
 
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-amara-1',
  'Amara Okafor',
  'Draft the release note',
])

start() fetches the server's capabilities, reconciles the migration handshake, opens the live pull, and starts the push loop, then returns once the loop is running. The final insert writes a note to the device's own database, and the push loop sends it to the server on the next tick. The controller applies changes from other devices to the local database itself, which the next two sections build on. Run server.ts on Node, and load device.ts in your web app to sync against it.

The server runs on Node with the better-sqlite3 driver, and the device runs in the browser with the waSqlite driver over IndexedDB. A React Native app would use the expoSqlite driver from @delali/sirannon-db/driver/expo instead; the getting started driver table lists every runtime. Give a synced table a primary key that stays unique across devices, such as a text id. Two devices then never generate the same key for different rows.

Push local writes to the server

The device stamps each local write in the transaction that performs it, recording the write's origin, a transaction id, and a timestamp on the new rows. That stamp lets the device read back only its own unpushed writes, and it lets the server suppress echoes.

Sending those writes takes no code at all. start() starts a push loop that drains the device's unpushed writes every pushIntervalMs (default 1000), and drains once immediately as well. Write to the local database as you normally would. The loop sends what you wrote, so your application only ever writes.

The loop sends in batches of up to batchSize (default 100). It tracks how far it has pushed in a durable cursor and advances that cursor as the server acknowledges each batch, so a crash mid-push resends only the unacknowledged batches. A failed push backs off exponentially to maxPushRetryDelayMs (default 30000). When the server refuses a push with MIGRATION_REQUIRED, the controller reconciles the migrations and retries.

Save a second note and watch the backlog clear on its own. The loop does the sending here; waitUntilSent polls only so that the example prints a settled number instead of racing it.

device.ts
import { Sirannon } from '@delali/sirannon-db'
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
import { SyncController } from '@delali/sirannon-db/client'
 
const driver = waSqlite()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('notes', 'notes.db')
 
await db.execute(
  'CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, author TEXT NOT NULL, body TEXT NOT NULL)'
)
await db.watch('notes')
 
const sync = new SyncController(db, {
  url: 'http://localhost:9876',
  databaseId: 'notes',
  tables: ['notes'],
})
 
await sync.start()
 
const waitUntilSent = async (): Promise<void> => { 
  while ((await sync.status()).pendingPushCount > 0) {
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
}
 
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-amara-1',
  'Amara Okafor',
  'Draft the release note',
])
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [ 
  'note-priya-1',
  'Priya Raghunathan',
  'Book the venue',
])
 
console.log('waiting to send:', (await sync.status()).pendingPushCount) 
await waitUntilSent()
console.log('waiting to send:', (await sync.status()).pendingPushCount)
waiting to send: 2
waiting to send: 0

Both notes were written locally first and queued, which is why the count starts at 2. It reaches 0 on the next tick, once the server has acknowledged the batch, and the loop runs to its own schedule throughout.

One escape hatch covers the case where a second of latency is too long, such as a Save button that should stop spinning at once. sync.triggerPush() runs the same drain immediately. It returns void and works in the background, so call it without await. Treat it as a way to hurry the loop along, because the loop sends the same writes with or without it.

Receive everyone else's writes

A device holds one connection open and receives everything over it. It's the same live subscription the client SDK uses, and the device subscribes once for all of its tables rather than once per table, which keeps a save that touched several tables together in one ordered stream. The subscription carries the device's own identity, and the server uses that identity to leave out the writes the device made itself. Leaving them out is echo suppression: a device receives everyone else's writes and none of its own. The server holds back one further set of rows, those a migration created, because a device gains them by running the same migration itself.

Changes arrive grouped by the save that made them. Every change carries the id of its transaction, and the last one in each group is marked txEnd. The device writes each change straight into a holding table as it arrives, then applies a whole group once the marked change has arrived, recording its new position in the stream as part of that same save. The next section covers what the holding table buys. If applying a group fails, the device closes the connection and opens it again from that same position, waiting longer before each retry.

The controller applies each pulled change itself, so onChange is a notification. It fires once the transaction commits, which makes the handler a re-read and never a write. Add an onChange callback that queries the table and redraws. Because the row is already committed when the callback runs, the query returns it.

The two local notes push as before and raise no callback, because a device never receives its own writes. The single redraw in the output comes from a third note, written on another device against the same server database. waitForNotes holds the example open until that note arrives, so the run ends at a known point instead of whenever you stop it.

device.ts
import { Sirannon } from '@delali/sirannon-db'
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
import { SyncController } from '@delali/sirannon-db/client'
 
const driver = waSqlite()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('notes', 'notes.db')
 
await db.execute(
  'CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, author TEXT NOT NULL, body TEXT NOT NULL)'
)
await db.watch('notes')
 
interface NoteRow { 
  id: string
  author: string
  body: string
}
 
const renderNotes = async (): Promise<void> => { 
  const notes = await db.query<NoteRow>('SELECT id, author, body FROM notes ORDER BY id')
  console.log(`notes on this device (${notes.length}):`)
  for (const note of notes) console.log(`  ${note.author}: ${note.body}`)
}
 
const sync = new SyncController(db, {
  url: 'http://localhost:9876',
  databaseId: 'notes',
  tables: ['notes'],
  onChange: () => { 
    void renderNotes()
  },
})
 
await sync.start()
 
const waitUntilSent = async (): Promise<void> => {
  while ((await sync.status()).pendingPushCount > 0) {
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
}
 
const waitForNotes = async (count: number): Promise<void> => { 
  while ((await db.query<NoteRow>('SELECT id FROM notes')).length < count) {
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
}
 
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-amara-1',
  'Amara Okafor',
  'Draft the release note',
])
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-priya-1',
  'Priya Raghunathan',
  'Book the venue',
])
 
console.log('waiting to send:', (await sync.status()).pendingPushCount)
await waitUntilSent()
console.log('waiting to send:', (await sync.status()).pendingPushCount)
 
await waitForNotes(3) 
waiting to send: 2
waiting to send: 0
notes on this device (3):
  Amara Okafor: Draft the release note
  Priya Raghunathan: Book the venue
  Tomas Lindqvist: Confirm the catering

The callback takes no argument here, because renderNotes re-reads the whole table. Declare event and read event.table instead when you keep a separate view per table and want to redraw only the one that changed.

Reconnection, sequence cursors, and forced resyncs follow the same rules as subscription resumption. A cursor presented with a stale epoch forces a resync, which the controller surfaces through onResyncRequired. A resync signal leaves the pull position where it is. The device records the resync it owes, and that record survives a restart, so a device that restarts before the snapshot runs still treats its copy as stale.

Hold each change before applying it

A phone loses its connection mid-save. A browser tab closes while a group of forty changes is half delivered. Neither may leave the device holding half a save, so the device never applies a change the moment it arrives.

Instead it writes every arriving change into _sirannon_staged_changes, a table Sirannon keeps in the device's own database. That is staging, and each batch of arrivals is staged in one local transaction of its own. Only once the change marked txEnd has been staged does the device apply the group: one local transaction reads the staged rows, resolves any conflicts, writes the rows, advances the pull cursor, and deletes the staged rows it has now applied.

Three things follow from that order.

  • A device that stops part-way keeps its staged tail. When it opens again it drops staged rows the cursor already covers, applies every group that is complete, and keeps an incomplete group for the resumed subscription. It then asks the server to continue from whichever is higher, the cursor or the highest sequence it holds staged, so no change is asked for twice and none is skipped.
  • onChange fires after the group commits, which includes a change staged before a restart. The handler therefore always sees committed rows, whether the change arrived a second ago or a week ago.
  • A device confirms a sequence once it has committed it, staged or applied. Confirming staged work is safe precisely because the staging table survives the restart, so the server can stop resending changes the device has not applied yet.

Staging also lets the server pace delivery more finely. A device that stages declares stagedStream: true when it subscribes, and it sends that only to a server announcing sync.staged-stream in its capabilities. Such a server packs several changes into each frame rather than sending one frame per change, and measures the delivery window per change rather than per transaction. A single save of a million rows therefore moves through a window of a thousand, which it could not do if the window counted whole transactions. A server that announces no sync.staged-stream receives no declaration and sends one change per frame, so an older server still syncs with a current device.

What happens when two people edit the same note

Two devices can edit the same row while they're apart. Both edits reach the server in the end, the same rule runs on both sides, and every device settles on one version of the row.

Picture one note that two people can reach. Amara has it open on her laptop, and Tomas has the same note on his phone.

What happensAmara's laptop showsTomas's phone shows
Amara writes the note while both devices are onlineDraft the release noteDraft the release note
Both devices go offline, and Amara edits the noteDraft the release note before FridayDraft the release note
A moment later Tomas edits the same note, still offlineDraft the release note before FridayDraft the release note and the changelog
Both devices come back onlineDraft the release note and the changelogDraft the release note and the changelog

Tomas edited second, so the default rule keeps his version and Amara's wording is gone. Her laptop reached the same answer on its own: his change arrived over the pull, the rule compared the two timestamps, and the later one replaced the local row. Both devices now show the same text.

That rule is a conflict resolver, and it runs whenever a change arrives for a row that already exists. Both sides run one. The server runs it on what a device pushes, and the device runs it on what it pulls. For device sync the server always uses last-writer-wins, so the rule you get to choose is the device's, through the controller's resolver option, which is last-writer-wins until you set another.

Losing a version is the price of the default rule. To choose a different one, device sync offers the same three resolvers that distributed replication uses, imported from @delali/sirannon-db/client. Import them from there rather than from @delali/sirannon-db/replication, which a browser build can't resolve because it reaches for Node's events and crypto. The /client entry is browser-safe and carries the ConflictResolver, ConflictContext, and ConflictResolution types as well, so a custom rule takes no other import.

StrategyClassBehaviour
Last-writer-winsLWWResolverThe resolver keeps the change with the higher HLC timestamp and breaks ties by device id. It accepts a remote delete whatever the timestamps are, so a delete beats a concurrent update and a deleted row never returns. It's the default and takes no arguments.
Field-level mergeFieldMergeResolverThe resolver merges non-overlapping columns and uses per-column HLC metadata for the overlapping ones. Construct it with a getColumnVersions callback.
Primary winsPrimaryWinsResolverThe resolver keeps the version authored by a configured primary node id. Construct it with that id.

Picking one of the other two changes what a device shows, and it can also leave that device disagreeing with the server. Take the same note, edited by both people while they're apart, with Amara changing one column and Tomas changing a different column of the same row a moment later. Here is what each choice produces, measured by running all five against that one conflict.

Rule on Amara's laptopHer laptop ends up showingThe server settles on
LWWResolverTomas's whole row, so her column revertsthe same row as her laptop
FieldMergeResolver with column versionsboth edits together, her column and hisTomas's row, without her column
FieldMergeResolver with no column versionsTomas's whole row, exactly as last-writer-winsthe same row as her laptop
PrimaryWinsResolver naming Tomas's deviceTomas's whole rowthe same row as her laptop
PrimaryWinsResolver naming her own deviceher whole row, refusing his editTomas's row

The last two rows of that table are the warning. A device applies its own rule to what it pulls, and the server always applies last-writer-wins to what a device pushes, so any rule other than last-writer-wins can leave a device showing a row the server doesn't hold. Both of those runs still disagreed after eight seconds of settling.

FieldMergeResolver takes a getColumnVersions callback that returns a per-column timestamp for a row, and the package has nothing that supplies one, so the application has to keep that record itself. Hand it an empty map and the resolver falls back to whole-row last-writer-wins, which is the third row above. PrimaryWinsResolver compares the node id on the change, and a change pulled through device sync carries the id of the device that wrote it, so naming a primary on a device means naming one particular device rather than the server.

Name the rule in the file so that the next person reading it can see which one is in force. Pass one resolver to cover every table, or a function that picks one per table name.

device.ts
import { Sirannon } from '@delali/sirannon-db'
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
import { SyncController } from '@delali/sirannon-db/client'
import { LWWResolver, SyncController } from '@delali/sirannon-db/client'
 
const driver = waSqlite()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('notes', 'notes.db')
 
await db.execute(
  'CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, author TEXT NOT NULL, body TEXT NOT NULL)'
)
await db.watch('notes')
 
interface NoteRow {
  id: string
  author: string
  body: string
}
 
const renderNotes = async (): Promise<void> => {
  const notes = await db.query<NoteRow>('SELECT id, author, body FROM notes ORDER BY id')
  console.log(`notes on this device (${notes.length}):`)
  for (const note of notes) console.log(`  ${note.author}: ${note.body}`)
}
 
const sync = new SyncController(db, {
  url: 'http://localhost:9876',
  databaseId: 'notes',
  tables: ['notes'],
  resolver: new LWWResolver(), 
  onChange: () => {
    void renderNotes()
  },
})
 
await sync.start()
 
const waitUntilSent = async (): Promise<void> => {
  while ((await sync.status()).pendingPushCount > 0) {
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
}
 
const waitForNotes = async (count: number): Promise<void> => {
  while ((await db.query<NoteRow>('SELECT id FROM notes')).length < count) {
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
}
 
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-amara-1',
  'Amara Okafor',
  'Draft the release note',
])
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-priya-1',
  'Priya Raghunathan',
  'Book the venue',
])
 
console.log('waiting to send:', (await sync.status()).pendingPushCount)
await waitUntilSent()
console.log('waiting to send:', (await sync.status()).pendingPushCount)
 
await waitForNotes(3)
waiting to send: 2
waiting to send: 0
notes on this device (3):
  Amara Okafor: Draft the release note
  Priya Raghunathan: Book the venue
  Tomas Lindqvist: Confirm the catering

The output matches the previous step, because LWWResolver is the rule the controller already applied. The conflict resolution section covers how each resolver picks a version and how to set one per table.

Underneath, the server hands each pushed batch to applyChanges, the method every database has for applying changes made somewhere else. It notes every change it applies, so the same batch arriving twice does no harm, and it reports what it did:

interface ApplyResult {
  applied: number
  skipped: number
  conflicts: number
  droppedTables: string[]
}

The /db/:id/changes route sends applied, skipped, and conflicts back to the device. droppedTables stays empty on that route, because it fills only when a batch drops a table, and the route accepts inserts, updates, and deletes alone, refusing anything else with INVALID_REQUEST. Table changes reach a device through the migration handshake instead, which device sync recovery covers.

How a device confirms what it has received

Every change the server sends out carries a number, and the numbers only ever go up. Once a device has saved a batch of changes into its own database, it sends the highest of those numbers back. That reply is an acknowledgement, and its meaning is exact: everything up to this number is now safely on the device.

The server keeps that number for two reasons. It's the point the device starts from again after a dropped connection, so the server resends only what the device is still missing. And once every device has confirmed a change, the server can delete it, which is what keeps its log of changes to a workable size.

A device sends these replies on its own, so you write no code for them. It waits a moment first so that a busy spell turns into one reply rather than fifty, and that wait is ackIntervalMs, two seconds by default. If changes are arriving faster than that, it stops waiting and replies at once, as soon as the number of unconfirmed changes passes immediateAckAfterChanges, which starts at half the gap the server allows.

Those replies also set the pace of delivery, so the server sends only as fast as a device confirms. The server tracks the gap between what it has sent and what the device has confirmed. Once the gap passes maxUnacknowledgedChanges, 1000 by default, the server stops sending and waits for the next reply. The changes it holds stay in its log and arrive later, in the order they were made.

How the server counts that gap depends on what the device asked for when it subscribed. A device that declared stagedStream is counted change by change, so the server can pause part-way through a save and a save larger than the gap still moves. A device that did not is counted transaction by transaction, and the server keeps each transaction whole, so ten rows saved together arrive together even when ten is more than the gap it normally allows.

A device can fall behind faster still, until the queue held for it grows past that gap. The server then closes the connection with code 4290, the same one the server guide covers. Recovery is automatic: the device reconnects, presents the last number it confirmed, and carries on from there. With the gap set to 1, the tightest value the server accepts, forty changes still arrive in order, two or three at a time.

You choose the gap on the server. Make it smaller when devices are slow to save and you want the server to hold back sooner. Keep it big enough that a device working at a normal pace stays connected.

server.ts
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
 
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('notes', 'notes.db')
 
await db.execute(
  'CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, author TEXT NOT NULL, body TEXT NOT NULL)'
)
await db.watch('notes')
 
const server = createServer(sirannon, { port: 9876 }) 
const server = createServer(sirannon, { port: 9876, maxUnacknowledgedChanges: 500 }) 
await server.listen()

The device has one matching option, immediateAckAfterChanges, which replaces the halfway default. Whichever numbers you pick, lastPulledSeq is the last number the device saved, which is also the last number it confirmed.

device.ts
import { Sirannon } from '@delali/sirannon-db'
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
import { LWWResolver, SyncController } from '@delali/sirannon-db/client'
 
const driver = waSqlite()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('notes', 'notes.db')
 
await db.execute(
  'CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, author TEXT NOT NULL, body TEXT NOT NULL)'
)
await db.watch('notes')
 
interface NoteRow {
  id: string
  author: string
  body: string
}
 
const renderNotes = async (): Promise<void> => {
  const notes = await db.query<NoteRow>('SELECT id, author, body FROM notes ORDER BY id')
  console.log(`notes on this device (${notes.length}):`)
  for (const note of notes) console.log(`  ${note.author}: ${note.body}`)
}
 
const sync = new SyncController(db, {
  url: 'http://localhost:9876',
  databaseId: 'notes',
  tables: ['notes'],
  resolver: new LWWResolver(),
  onChange: () => {
    void renderNotes()
  },
})
 
await sync.start()
 
const waitUntilSent = async (): Promise<void> => {
  while ((await sync.status()).pendingPushCount > 0) {
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
}
 
const waitForNotes = async (count: number): Promise<void> => {
  while ((await db.query<NoteRow>('SELECT id FROM notes')).length < count) {
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
}
 
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-amara-1',
  'Amara Okafor',
  'Draft the release note',
])
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-priya-1',
  'Priya Raghunathan',
  'Book the venue',
])
 
console.log('waiting to send:', (await sync.status()).pendingPushCount)
await waitUntilSent()
console.log('waiting to send:', (await sync.status()).pendingPushCount)
 
await waitForNotes(3)
 
console.log('applied up to', String((await sync.status()).lastPulledSeq)) 
waiting to send: 2
waiting to send: 0
notes on this device (3):
  Amara Okafor: Draft the release note
  Priya Raghunathan: Book the venue
  Tomas Lindqvist: Confirm the catering
applied up to 3

The server numbers the device's own two notes 1 and 2, and the note from the other device 3. The device pulled only that third change, and its cursor stands at 3 because 3 is the last number it has covered. lastPulledSeq is a bigint, which is JavaScript's type for whole numbers with no size limit. Wrap it in String() before printing it, and keep it away from JSON.stringify, which throws on a bigint.

Read the sync status

await sync.status() returns the loop's current status:

interface SyncStatus {
  state: 'stopped' | 'starting' | 'running' | 'paused' | 'snapshotting'
  deviceId: string | null
  serverCapabilities: string[] | null
  schemaVersion: number | null
  pendingPushCount: number
  lastPushedSeq: bigint
  lastPulledSeq: bigint | null
  pushCaughtUp: boolean
  resyncRequired: boolean
  lastError: { code: string; message: string } | null
}

state moves through stopped, starting, running, paused, and snapshotting. pendingPushCount is how many local writes are still waiting to push, counted at the moment you ask, and pushCaughtUp is that same count read as a boolean, true when nothing is waiting. A local write turns it false straight away, and the next drain turns it back.

device.ts
import { Sirannon } from '@delali/sirannon-db'
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
import { LWWResolver, SyncController } from '@delali/sirannon-db/client'
 
const driver = waSqlite()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('notes', 'notes.db')
 
await db.execute(
  'CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, author TEXT NOT NULL, body TEXT NOT NULL)'
)
await db.watch('notes')
 
interface NoteRow {
  id: string
  author: string
  body: string
}
 
const renderNotes = async (): Promise<void> => {
  const notes = await db.query<NoteRow>('SELECT id, author, body FROM notes ORDER BY id')
  console.log(`notes on this device (${notes.length}):`)
  for (const note of notes) console.log(`  ${note.author}: ${note.body}`)
}
 
const sync = new SyncController(db, {
  url: 'http://localhost:9876',
  databaseId: 'notes',
  tables: ['notes'],
  resolver: new LWWResolver(),
  onChange: () => {
    void renderNotes()
  },
})
 
await sync.start()
 
const waitUntilSent = async (): Promise<void> => {
  while ((await sync.status()).pendingPushCount > 0) {
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
}
 
const waitForNotes = async (count: number): Promise<void> => {
  while ((await db.query<NoteRow>('SELECT id FROM notes')).length < count) {
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
}
 
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-amara-1',
  'Amara Okafor',
  'Draft the release note',
])
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
  'note-priya-1',
  'Priya Raghunathan',
  'Book the venue',
])
 
console.log('waiting to send:', (await sync.status()).pendingPushCount)
await waitUntilSent()
console.log('waiting to send:', (await sync.status()).pendingPushCount)
 
await waitForNotes(3)
 
console.log('applied up to', String((await sync.status()).lastPulledSeq))
 
const status = await sync.status() 
console.log(status.state, status.pendingPushCount, status.lastPushedSeq)
waiting to send: 2
waiting to send: 0
notes on this device (3):
  Amara Okafor: Draft the release note
  Priya Raghunathan: Book the venue
  Tomas Lindqvist: Confirm the catering
applied up to 3
running 0 2n

Node prints a bigint with an n suffix, which is why lastPushedSeq reads 2n rather than 2. The device pushed two notes of its own, so its push cursor stands at 2 while its pull cursor stands at 3.

Call sync.pause() to tear the loops down and keep the cursors, sync.resume() to start them again, and sync.stop() when the app closes. A paused or stopped controller keeps its durable cursors, so a later start() resumes from where it left off.

Controller options

Every option except url, databaseId, and tables has a default.

OptionDefaultDescription
urlrequiredGive the base URL of the Sirannon server, over HTTP or HTTPS.
databaseIdrequiredName the server database to sync, using the same id the server opened it with.
tablesrequiredList the tables whose live changes the device follows, watched on both sides.
headersnoneSend extra HTTP headers, such as an Authorization bearer token.
batchSize100Cap the number of changes per push batch.
pushIntervalMs1000Set the delay between push attempts, in milliseconds.
ackIntervalMs2000Set the debounce before acknowledging pulled changes, in milliseconds.
maxPushRetryDelayMs30000Cap the push and pull retry back-off, in milliseconds.
requestTimeout30000Set the timeout for each HTTP request, in milliseconds.
autoResynctrueDownload a snapshot automatically when the device falls too far behind.
snapshotRetryDelayMs5000Set the first delay before retrying a failed snapshot download, in milliseconds.
maxSnapshotRetryDelayMs300000Cap the snapshot retry back-off, in milliseconds.
snapshotPageSize500Set how many rows each snapshot page requests.
immediateAckAfterChangeshalf the server's windowAcknowledge straight away once this many changes are outstanding. It falls back to 500 when the server announces no window.
resolverLWWResolverResolve conflicts on pulled changes. Pass one resolver, or a function that returns one for a given table name.
onChangenoneHandle each pulled ChangeEvent after the controller has committed it.
onResyncRequirednoneWarn the app before a snapshot replaces local data.
onSnapshotProgressnoneTrack table and row progress during a snapshot load.
onSnapshotCompletenoneReport the outcome once a snapshot load ends, including whether the local database serves reads and writes again.

The four snapshot options and the three snapshot callbacks belong to the situations device sync recovery covers, where a device has no copy of the database yet, or no schema to match the server's.