Device sync copies writes both ways between an end-user device's local database and a server database, so an application whose device loses its connection still writes to the local copy. The device sends its own writes to the server and receives everyone else's live over a WebSocket. Both sides then settle a contested row through the same conflict resolvers that replication uses. Distributed replication differs from device sync, because it 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 syncs as a client, so the primary copy of the data is the server's. Each user has a database file of their own, and that file is the unit of sync. The tables option lists the tables whose live changes reach the device.
You build two files on this page, server.ts and device.ts, and every step reprints the whole of the file it changes. Device sync recovery starts from the device.ts you finish with here, and it covers the three situations that need a recovery step.
Device sync is in beta, and production systems use it. Its API can still change before 1.0, so read the changelog for the release you move to.
Follow one note from the laptop it starts on to the phone that confirms it.
Blessing's laptop
- notes: Draft the release note
waiting to push 1 ยท pushed through 0
The server
- change log: empty
next sequence 1
Mikael's phone
- notes: empty
applied through 0
Her laptop writes the row into its own table first, inside a transaction that also stamps it with her device id and an HLC timestamp. That stamp is what lets the loop read back her unpushed writes.
Serve the tables a device syncs
The server includes the device-sync routes and serves them once you set acceptDeviceSync: true: the push route, the live pull with acknowledgements and echo suppression, snapshots, and the migration handshake. A device can write rows into any table of the database, so a server with that option refuses to start unless you also give it an authenticate hook that names each caller. Watch the tables you want to sync, then start the server. This step starts server.ts.
import { HookDeniedError, RequestDeniedError, Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
interface Account {
userId: string
}
const SESSIONS = new Map<string, Account>([
['session-blessing', { userId: 'u_blessing' }],
['session-mikael', { userId: 'u_mikael' }],
])
const deviceOwners = new Map<string, string>()
const claimDevice = (hook: string, deviceId: string | undefined, identity: unknown): void => {
if (deviceId === undefined) return
const { userId } = identity as Account
const owner = deviceOwners.get(deviceId) ?? userId
if (owner !== userId) throw new HookDeniedError(hook, 'This device belongs to another account')
deviceOwners.set(deviceId, owner)
}
const readSession = (headers: Record<string, string>): string | undefined => {
const offered = headers['sec-websocket-protocol']?.split(',').map((value) => value.trim())
return headers.authorization?.replace(/^Bearer /, '') ?? offered?.find((value) => value !== 'sirannon.v1')
}
const driver = betterSqlite3()
const sirannon = new Sirannon({
driver,
hooks: {
onBeforePush: ({ deviceId, identity }) => claimDevice('beforePush', deviceId, identity),
onBeforeSubscribe: ({ deviceId, identity }) => claimDevice('beforeSubscribe', deviceId, identity),
},
})
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<Account>(sirannon, {
port: 9876,
cors: true,
acceptDeviceSync: true,
authenticate: ({ headers }) => {
const account = SESSIONS.get(readSession(headers) ?? '')
if (account === undefined) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Sign in to sync')
return account
},
})
await server.listen()watch turns on the change log that supplies device sync, so watch every synced table on the server. The notes database and its notes table are the server's, and a device opens a database with the same id to copy them.
authenticate turns the session that a request presents into an account, and the SESSIONS map stands in for whatever issues sessions in your application. claimDevice ties each device id to the first account that syncs from it. The server calls onBeforePush before it writes a pushed batch and onBeforeSubscribe before it opens a device's pull, so once a device belongs to Blessing, another account can neither write through it nor read its feed. Keep that record in your own database, because the map in this example empties whenever the process restarts.
The server refuses a cross-origin request until you set cors, and a browser device is always cross-origin against a server on its own port. A device without it fails at the capabilities call and never opens the pull. cors: true allows every origin, which suits a local example, and the security guide covers naming the origins you trust.
Connect a device and start the sync loop
On the device, open a local database with the browser driver, watch the same tables, then start the sync loop with a SyncController. Construct it with the server URL, the database id, the tables to sync, and the signed-in user's session. The session goes in headers for the HTTP requests and in webSocketProtocols for the pull, because a browser attaches no header to a WebSocket, as the last section explains. Later sections add the callbacks that report what the loop is doing. The server.ts tab shows the file from the previous step unchanged; 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 SESSION = 'session-blessing'
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'],
headers: { Authorization: `Bearer ${SESSION}` },
webSocketProtocols: [SESSION],
})
await sync.start()
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
'note-blessing-1',
'Blessing Eze',
'Draft the release note',
])start() fetches the server's capabilities, reconciles the migration handshake, opens the live pull, and starts the push loop. It returns once the loop is running. When the server is unreachable, start() still returns and records the failure in status().lastError, and the controller keeps trying to connect, which device sync recovery covers. 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, and the next two sections build on that. Start server.ts on Node, and load device.ts in your web app to sync against it.
The server uses the better-sqlite3 driver on Node, and the device uses the waSqlite driver in the browser over
IndexedDB. A React Native app would use the expoSqlite driver from @delali/sirannon-db/driver/expo; 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. Every device then generates a key of its own for each row it writes.
Push local writes to the server
The device stamps each local write inside the transaction that performs it, and that stamp records the write's origin, a transaction id, and a timestamp on the new rows. The 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 write, 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 after a crash mid-push the device resends the unacknowledged batches alone. The loop backs off exponentially after a failed push, up to maxPushRetryDelayMs (default 30000). It makes no try while the browser reports no network through navigator.onLine, and it pushes at once when the browser fires its online event. When the server refuses a push with MIGRATION_REQUIRED, the controller reconciles the migrations and retries. A push refused with HOOK_DENIED, or with DEVICE_CLOCK_AHEAD because a change is stamped more than five minutes ahead of the server's clock, stays on the device, and the controller records the code in status().lastError and retries on the same back-off.
Save a second note, then read the count of writes still waiting. The loop sends both notes, and nothing in the file waits for it.
import { Sirannon } from '@delali/sirannon-db'
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
import { SyncController } from '@delali/sirannon-db/client'
const SESSION = 'session-blessing'
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'],
headers: { Authorization: `Bearer ${SESSION}` },
webSocketProtocols: [SESSION],
})
await sync.start()
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
'note-blessing-1',
'Blessing Eze',
'Draft the release note',
])
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
'note-deepa-1',
'Deepa Nair',
'Book the venue',
])
console.log('waiting to send:', (await sync.status()).pendingPushCount)waiting to send: 2Both notes commit locally before the loop's next tick, so the status call reports 2. The count falls to 0 once the loop drains and the server acknowledges the batch. The status callback reports each of those transitions as it happens, so a Save button can show Saving and then All changes saved from that callback alone.
A second of latency is too long for a Save button that should stop spinning at once, and sync.triggerPush() starts 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 opens one connection and receives everything over it. It's the same live subscription the client SDK uses. The device subscribes once for all of its tables, so a save that spans several tables arrives in one ordered stream. The subscription includes the device's own identity, and the server uses that identity to leave out every write whose origin is that device. Leaving them out is echo suppression, so a device receives everyone else's writes and none of its own. The server leaves out one further set of rows, the ones a migration writes, because the device produces those itself when it applies the same migration.
The server groups the changes it sends by the transaction that wrote them. Every change includes the id of its transaction, and the server marks the last one in each group txEnd. The device writes each change straight into a holding table as it arrives, and it applies a whole group once the marked change is there. The same local transaction records the device's new position in the stream. The next section covers what the holding table does for the device. If applying a group fails, the device closes the connection and opens it again from that same position, and it waits longer before each retry.
The controller applies each pulled change itself, so onChange is a notification. It fires once the transaction commits, so the handler re-reads the table and writes nothing. Add an onChange callback that queries the table and redraws. Because the row is already committed when the callback fires, the query returns it.
import { Sirannon } from '@delali/sirannon-db'
import { waSqlite } from '@delali/sirannon-db/driver/wa-sqlite'
import { SyncController } from '@delali/sirannon-db/client'
const SESSION = 'session-blessing'
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'],
headers: { Authorization: `Bearer ${SESSION}` },
webSocketProtocols: [SESSION],
onChange: () => {
void renderNotes()
},
})
await sync.start()
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
'note-blessing-1',
'Blessing Eze',
'Draft the release note',
])
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
'note-deepa-1',
'Deepa Nair',
'Book the venue',
])
console.log('waiting to send:', (await sync.status()).pendingPushCount)waiting to send: 2
notes on this device (3):
Blessing Eze: Draft the release note
Deepa Nair: Book the venue
Mikael Johansson: Confirm the cateringThe two local writes raise no callback, because a device receives none of its own writes. The redraw in that output comes from a third note, saved on another device against the same server database while this one was open, so the count reaches 3 on its own.
The callback takes no argument here, because renderNotes re-reads the whole table. Declare event and read event.table 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 device that presents a cursor with a stale epoch forces a resync, which the controller reports through onResyncRequired. A resync signal leaves the pull position where it is. The device records that a resync is due, and that record stays on disk across a restart, so a device that restarts before it downloads the snapshot still marks its copy as stale.
Hold each change before applying it
A phone can lose its connection mid-save, and a browser tab can close while a group of forty changes is half delivered. The device applies a save only in full, so it holds each change until the whole save is there.
The device writes every arriving change into _sirannon_staged_changes, a table Sirannon creates in the device's own database. That is staging, and the device stages each batch of arrivals in one local transaction of its own. It applies the group only once the change marked txEnd has been staged. One local transaction then loads the staged rows, resolves any conflicts, writes the rows, advances the pull cursor, and deletes the staged rows it has applied.
Three changes of a five-change save arrive
pull cursor 11
Staged
Applied to the notes table
The device stages the batch in one local transaction of its own and applies none of it, because the change marked txEnd has not arrived.
The tab closes mid-save
pull cursor 11, confirmed through 14
Staged
Applied to the notes table
Acknowledging 14 is safe, because those rows are on disk. The notes table shows nothing of this save, so a query on the device returns none of its rows.
The device opens again
resumes from 14
Staged
Applied to the notes table
It drops staged rows the cursor already covers and finds no complete group to apply, so it keeps the tail and requests everything after 14.
The rest of the save arrives, and the server marks 16 txEnd
pull cursor 11
Staged
Applied to the notes table
The group is complete, so the device may now apply the whole of it.
One transaction applies the group
pull cursor 16
Staged
Applied to the notes table
That same transaction resolves conflicts, writes the rows, advances the pull cursor, and deletes the staged rows. onChange fires for each change once it commits.
Three consequences 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 requests the changes after whichever is higher, the cursor or the highest sequence it holds staged, so every change arrives exactly once.
onChangefires after the group commits, and a change staged before a restart raises it too. The handler therefore reads committed rows every time, whether the change arrived a second ago or a week ago.- A device acknowledges a sequence once it has committed it, staged or applied. Acknowledging staged work is safe because the staging table stays on disk across a restart, so the server may stop sending those changes again.
Staging also lets the server pace delivery more finely. A staging device sets stagedStream: true when it subscribes, and it sends that flag only to a server whose capabilities list sync.staged-stream. Such a server packs several changes into each frame, and it measures the delivery window per change. A single save of a million rows therefore moves through a window of a thousand. Where a server's capabilities leave sync.staged-stream out, the device sends no flag and the server 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, and the same rule on both sides settles every device on one version of the row.
Picture one note that two people can edit. Blessing has it open on her laptop, and Mikael has the same note on his phone.
| What happens | Blessing's laptop shows | Mikael's phone shows |
|---|---|---|
| Blessing writes the note while both devices are online | Draft the release note | Draft the release note |
| Both devices go offline, and Blessing edits the note | Draft the release note before the launch | Draft the release note |
| Mikael then edits the same note, still offline | Draft the release note before the launch | Draft the release note and the changelog |
| Both devices come back online | Draft the release note and the changelog | Draft the release note and the changelog |
Mikael edits second, so the default rule keeps his version and discards Blessing's wording. Her laptop applies that same rule to his change when it arrives over the pull, and the rule keeps the later of the two timestamps. Both devices then show the same text.
That rule is a conflict resolver, and it applies whenever a change arrives for a row that already exists. The server applies it to what a device pushes, and the device applies it to what it pulls. For device sync the server always uses last-writer-wins, so the rule you choose is the device's. Set it through the controller's resolver option, which stays last-writer-wins until you name another.
The default rule discards one of the two versions. Device sync offers the same three resolvers that distributed replication uses, imported from @delali/sirannon-db/client. Import them from there, because a browser build cannot resolve @delali/sirannon-db/replication, which imports Node's events and crypto. The /client entry is browser-safe and exports the ConflictResolver, ConflictContext, and ConflictResolution types as well, so a custom rule takes no other import.
| Strategy | Class | Behaviour |
|---|---|---|
| Last-writer-wins | LWWResolver | The 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 the row stays deleted. It's the default and takes no arguments. |
| Field-level merge | FieldMergeResolver | The resolver merges non-overlapping columns and uses per-column HLC metadata for the overlapping ones. Construct it with a getColumnVersions callback. |
| Primary wins | PrimaryWinsResolver | The resolver keeps the version written on the primary node id you configure. Construct it with that id. |
The other two resolvers change what a device shows, and either one can leave that device disagreeing with the server. Take the same note again, where Blessing changes one column while both people are offline and Mikael then changes a different column of the same row. The table below reports the outcome of each choice, measured by applying all five rules to that one conflict.
| Rule on Blessing's laptop | Her laptop ends up showing | The server settles on |
|---|---|---|
LWWResolver | Mikael's whole row, so her column reverts | the same row as her laptop |
FieldMergeResolver with column versions | both edits together, her column and his | Mikael's row, without her column |
FieldMergeResolver with no column versions | Mikael's whole row, exactly as last-writer-wins | the same row as her laptop |
PrimaryWinsResolver naming Mikael's device | Mikael's whole row | the same row as her laptop |
PrimaryWinsResolver naming her own device | her whole row, refusing his edit | Mikael's row |
Two of those rows are the warning: the field merge with column versions, and the primary-wins rule naming her own device. 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 beyond last-writer-wins can leave a device showing one row while the server stores another. In the run behind this table, the two sides still store different rows after eight seconds of settling.
FieldMergeResolver takes a getColumnVersions callback that returns a per-column timestamp for a row, and your application supplies that record itself, because the package has none. Given an empty map, the resolver falls back to whole-row last-writer-wins, which is the third row above. PrimaryWinsResolver compares the node id on the change. A change pulled through device sync includes the id of the device that wrote it, so a primary you name on a device is always one particular device, and never 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.
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 SESSION = 'session-blessing'
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'],
headers: { Authorization: `Bearer ${SESSION}` },
webSocketProtocols: [SESSION],
resolver: new LWWResolver(),
onChange: () => {
void renderNotes()
},
})
await sync.start()
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
'note-blessing-1',
'Blessing Eze',
'Draft the release note',
])
await db.execute('INSERT INTO notes (id, author, body) VALUES (?, ?, ?)', [
'note-deepa-1',
'Deepa Nair',
'Book the venue',
])
console.log('waiting to send:', (await sync.status()).pendingPushCount)That line makes the rule explicit in the file and changes no behaviour, because LWWResolver is the rule the controller already applies. The output matches the previous step. The conflict resolution section covers how each resolver picks a version and how to set one per table.
The server passes each pushed batch to applyChanges, the method every database has for applying changes another node wrote. It records every change it applies, so a repeated batch applies each change once, and it reports what it does:
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. That route accepts inserts, updates, and deletes alone, and it refuses anything else with INVALID_REQUEST. Table changes reach a device through the migration handshake, which device sync recovery covers.
How a device confirms what it has received
Every change the server sends out has a number, and those 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 it means one thing exactly: the device now holds everything up to that number.
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 the changes above it. Once every device has confirmed a change, the server can delete it, and those deletions are what hold the change log to a workable size.
A device sends these replies on its own, so you write no code for them. It delays each acknowledgement so that a busy spell produces one reply, and that delay is ackIntervalMs, two seconds by default. Where changes arrive faster than that, it 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. It buffers none of those changes. The change log already holds them, and the server loads them from there again after the next reply, so they arrive in their original order.
How the server counts that gap depends on what the device requests when it subscribes. For a device that sets stagedStream, the server counts change by change, so it can pause part-way through a save and a save larger than the gap still moves. For a device without that flag, the server counts transaction by transaction and keeps each transaction whole, so ten rows saved together arrive together even where ten is more than the gap it allows.
Twelve changes are waiting in the change log.
maxUnacknowledgedChanges 4, default 1000
The server sends through 7 and then waits, because sending change 8 would leave 5 changes unconfirmed, one more than the window allows.
A device can fall behind faster still, until the WebSocket buffer for that one connection passes the bound the server guide sets. The server then closes the connection with code 4290. The device recovers on its own, because it reconnects, sends the last number it confirmed, and continues from there. With the gap set to 1, 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.
import { HookDeniedError, RequestDeniedError, Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
interface Account {
userId: string
}
const SESSIONS = new Map<string, Account>([
['session-blessing', { userId: 'u_blessing' }],
['session-mikael', { userId: 'u_mikael' }],
])
const deviceOwners = new Map<string, string>()
const claimDevice = (hook: string, deviceId: string | undefined, identity: unknown): void => {
if (deviceId === undefined) return
const { userId } = identity as Account
const owner = deviceOwners.get(deviceId) ?? userId
if (owner !== userId) throw new HookDeniedError(hook, 'This device belongs to another account')
deviceOwners.set(deviceId, owner)
}
const readSession = (headers: Record<string, string>): string | undefined => {
const offered = headers['sec-websocket-protocol']?.split(',').map((value) => value.trim())
return headers.authorization?.replace(/^Bearer /, '') ?? offered?.find((value) => value !== 'sirannon.v1')
}
const driver = betterSqlite3()
const sirannon = new Sirannon({
driver,
hooks: {
onBeforePush: ({ deviceId, identity }) => claimDevice('beforePush', deviceId, identity),
onBeforeSubscribe: ({ deviceId, identity }) => claimDevice('beforeSubscribe', deviceId, identity),
},
})
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<Account>(sirannon, {
port: 9876,
cors: true,
acceptDeviceSync: true,
maxUnacknowledgedChanges: 500,
authenticate: ({ headers }) => {
const account = SESSIONS.get(readSession(headers) ?? '')
if (account === undefined) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Sign in to sync')
return account
},
})
await server.listen()The device has one matching option, immediateAckAfterChanges, which replaces the halfway default. Against the server above, a device that leaves this option unset acknowledges once 250 changes are outstanding, and the line below moves that to 100.
const sync = new SyncController(db, {
url: 'http://localhost:9876',
databaseId: 'notes',
tables: ['notes'],
headers: { Authorization: `Bearer ${SESSION}` },
webSocketProtocols: [SESSION],
immediateAckAfterChanges: 100,
})Whichever numbers you pick, lastPulledSeq is the highest number the device has saved, which is also the highest it has confirmed. In the three-note example, the server numbers this device's own two notes 1 and 2, and the note from the other device 3, so a device that has applied the pulled change reports a lastPulledSeq of 3. That field is a bigint, which is JavaScript's type for whole numbers with no size limit. Wrap it in String() before printing it, and convert it before 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 once the outbox is empty. A local write turns it false straight away, and the next drain turns it true again.
const status = await sync.status()
console.log(status.state, status.pendingPushCount, String(status.lastPushedSeq))Node prints a bigint with an n suffix, so print lastPushedSeq through String() as above. A device that has pushed its own two notes reports the state running, a push cursor of 2, and a pending count of 0 once the loop has drained.
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.
Follow the status without polling
status() answers at the moment you call it, which is what a check inside a loop needs. Keeping a user interface current that way would mean calling it on a timer, so onStatusChange reports each change as it happens and your listener receives the same SyncStatus object. Pass a listener to the controller alongside the options the earlier steps set.
const sync = new SyncController(db, {
url: 'http://localhost:9876',
databaseId: 'notes',
tables: ['notes'],
headers: { Authorization: `Bearer ${SESSION}` },
webSocketProtocols: [SESSION],
onStatusChange: (status) => {
console.log(status.state === 'running' && status.pushCaughtUp ? 'All changes saved' : 'Saving')
},
})The controller reports a status whenever its state changes, whenever it applies a pulled batch, whenever a resync becomes required, and whenever it records or clears an error. A successful start() therefore reports starting and then running, and the listener above prints All changes saved once the push cursor has caught up.
The controller captures those statuses as they happen and passes them to your listener on a microtask, in the order they occur, so your listener still fires for a transition that ends before the next turn of the event loop. It catches whatever your listener throws and continues with the rest of the queue.
pendingPushCount is the one field that can lag the rest of a reported status. Counting the outbox takes a read through the write gate, so the controller refreshes that count in the background at most once every 100 milliseconds, and it reports a further status when the count has moved. The controller starts that background count only when you give it a listener.
Controller options
Every option except url, databaseId, and tables has a default.
| Option | Default | Description |
|---|---|---|
url | required | Give the base URL of the Sirannon server, over HTTP or HTTPS. |
databaseId | required | Name the server database to sync, using the same id the server opened it with. |
tables | required | List the tables whose live changes reach the device, and watch them on both sides. |
headers | none | Send extra HTTP headers, such as an Authorization bearer token. Under Node and Bun these reach the pull upgrade too. |
webSocketProtocols | none | Offer WebSocket subprotocols on the pull upgrade, which is how a browser device sends a credential. The controller offers sirannon.v1 ahead of your values. |
batchSize | 100 | Cap the number of changes per push batch. |
pushIntervalMs | 1000 | Set the delay between push attempts, in milliseconds. |
ackIntervalMs | 2000 | Set the debounce before acknowledging pulled changes, in milliseconds. |
maxPushRetryDelayMs | 30000 | Cap the push and pull retry back-off, in milliseconds. |
requestTimeout | 30000 | Set the timeout for each HTTP request, in milliseconds. |
autoResync | true | Download a snapshot automatically when the device falls too far behind. |
snapshotRetryDelayMs | 5000 | Set the first delay before retrying a failed snapshot download, in milliseconds. |
maxSnapshotRetryDelayMs | 300000 | Cap the snapshot retry back-off, in milliseconds. |
snapshotPageSize | 500 | Set how many rows each snapshot page requests. |
immediateAckAfterChanges | half the server's window | Acknowledge straight away once this many changes are outstanding. It falls back to 500 where the server sends no window. |
resolver | LWWResolver | Resolve conflicts on pulled changes. Pass one resolver, or a function that returns one for a given table name. |
onChange | none | Handle each pulled ChangeEvent after the controller has committed it. |
onStatusChange | none | Receive the device's status whenever its state, pending push count, resync flag, or last error changes. |
onResyncRequired | none | Warn the app before a snapshot replaces local data. |
onSnapshotProgress | none | Track table and row progress during a snapshot load. |
onSnapshotComplete | none | Report 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 are for the situations device sync recovery covers, where a device needs its first copy of the database, or a schema that matches the server's.
How a browser device sends its credential
A browser builds a WebSocket from the global constructor, and that constructor accepts no handshake header. Your headers therefore reach the controller's HTTP requests alone. In such a runtime, a controller built with headers and no webSocketProtocols fails with INVALID_ARGUMENT before the first connection, and the message names webSocketProtocols. Node and Bun attach the headers to the upgrade, so the same controller works there.
A browser device therefore sends a short-lived ticket in webSocketProtocols and keeps its bearer header for the HTTP requests, exactly as the client SDK does. The security guide covers minting and checking that ticket.
Each subprotocol you offer uses the characters a header token allows, which leaves out a space, a comma, a quotation mark, and the empty string. Every entry has to differ from the others. A value that breaks either rule fails at construction with INVALID_ARGUMENT. The message names the position of the offending entry and leaves the value itself out, because that value is a credential.