Backups
The sync tables live in your database, so you back them up with it: snapshots, pg_dump, point-in- time recovery, VACUUM INTO or Litestream for SQLite. Restoring one takes one more step than usual, because devices may be ahead of the backup.
Restoring
-
Stop serving sync: take the route down, or
close()every instance. Devices retry, and keep working offline meanwhile. -
Note the highest sync id the lost database handed out. If it still opens, read it there (the table prefix is
ferry_by default):SELECT last_sync_id FROM ferry_state;If it does not, take it from your logs, or pick a generous number above it: skipping a billion ids costs nothing.
-
Restore the backup: the sync tables and your own tables together, from the same moment.
-
Run
afterRestoreonce, against the restored database, before any device reaches it:scripts/after-restore.ts import \{ syncServer \} from './sync-server'; const \{ lastSyncId \} = await syncServer.afterRestore(\{ highestSyncId: 48_210 \}); console.log(`Sync resumes after sync id ${lastSyncId}`); -
Delete again the users deleted since the backup, with
deleteUser: the backup still holds their data. -
Redo what must not be lost from the time between the backup and the failure, if you know it: membership removals first (a user removed from a space then is a member again), then additions, then your backend's writes.
-
Serve again.
What devices do
On its next sync, every device downloads its data again, as one that was offline longer than the retention does. Nothing to do in the app, and nothing to tell the user beyond what a full download takes:
- Changes not sent yet are kept and sent afterwards. So are changes whose answer was lost on the way back.
- A device whose earlier changes the server lost sends its pending ones under a new client id, by itself: the server sees that the device's changes start past a gap. A device the backup did not know at all is answered as a forgotten one: it confirms what the restored data shows, and sends the rest again.
- A change the restored database processed but no longer has the answer to (its retention forgot it before the backup) is settled from the rows the device downloads, as without a restore: confirmed only if they show it.
- Rows from after the backup disappear from devices: the database no longer has them.
What is lost
Everything the database had committed after the backup was taken:
| Lost | What to know |
|---|---|
| Changes from apps the server had confirmed after the backup | The app was told they were confirmed. They vanish on the next sync |
Writes from your backend (write, log, backfill) | Redo them if your app keeps a record of them |
| Spaces founded after the backup, with their rows | A change still waiting on a device can found the space again, if claimSpace lets its row found it |
| Membership changes | Undone: redo them, removals first |
| Deleted users | Back with their data: delete them again (step 5) |
Point-in-time recovery shrinks all of these to the last few seconds before the failure.
Why afterRestore
Devices that synced after the backup hold positions in the log that the restored database has not reached yet. As it takes new changes, it hands out those same positions again, to other changes. A device asks for the changes after its own position, so it would skip the new ones in between and keep rows the server no longer has, without an error anywhere. A change it sent could even count as confirmed because another change took its position.
afterRestore moves the database past every position the lost database handed out, and drops the
log of changes (not the rows). Every device's position is then behind the log, and the server tells
each one to download again.
Gotchas
- Run it before the first request. A device that syncs with the restored database before it may already have skipped changes. Run it even if you think no device synced after the backup: it costs each device one download.
- Too low a
highestSyncIdleaves devices whose position is above it at risk of skipping changes. Too high costs nothing, up to JavaScript's largest safe integer (2^53 - 1):afterRestorethrows aRangeErrorfor a number it cannot add one to, or a negative one. - Once per restore. Running it again makes every device download once more, and nothing worse.
- Several instances need no restart: run it from one of them, or from a script with the same store.
- On D1, restore with Time Travel and run
afterRestoreinside the coordinator (from an admin route of the Durable Object): a script with ad1Storeof its own would be a second writer. No restart is needed. The coordinator still remembers the newer database, but the restore rolled back the token it checks every commit against, so its first request after the restore fails withD1ConflictErrorand writes nothing, and it reads D1 again. If that first request isafterRestore, run it again. - A device restored from its own backup (a phone from iCloud, a copied app folder) is another case, which Ferry handles by itself: the copy gets a new client id the first time it sends a change the original already sent.
Deploy
Deploying Ferry is deploying your backend: the sync route ships with it, on its database. There is no service, worker or cron job to add. A few platforms need a setting or two, listed below.
Deleting users
When someone deletes their account, their data has to go: the law says so in many places (the GDPR's right to erasure, for one), and so does their trust. deleteUser removes everything sync keeps of one user, in one transaction: