Sync — cross-device replication

@civility/cloud pushes and pulls your local state to a Civility server, so a user’s data follows them across devices. Swap your Store for a CloudStore — it is a Store, so nothing about how you declare or use entities changes — and your documents and collections replicate automatically. How you read and write data doesn’t change: local stays the source of truth, sync runs in the background.

Every store write is a CRDT change, so concurrent offline edits on two devices merge deterministically on the next sync — you never write merge logic. Sync is also where end-to-end encryption lives, since it’s what keeps the server from reading the data it holds.

Protocol detail: sync spec, encryption spec. Full API: jsr.io/@civility/cloud/doc.


1. Install #

deno add jsr:@civility/store
deno add jsr:@civility/store/idb
deno add jsr:@civility/cloud
deno add jsr:@civility/ui
# only if you also sync binary data:
deno add jsr:@civility/blobs

2. Swap in CloudStore #

In your app store, replace new Store(...) with new CloudStore(...) and add a cloud block. Every declared entity syncs by default. The collection/document names become the storeName keys used on the server — keep them stable across releases.

// www/store/app.ts
import { CloudStore } from '@civility/cloud'

const cloud = new CloudStore(backend, {
  documents: ['settings'],
  collections: ['items'],
  versions: [{ version: '1.0.0', schema }],
  cloud: {
    appId: 'me.example.myapp', // reverse-DNS id; must match the dashboard app
  },
})

export class App {
  /** Exposed for `<civ-sync-input>` and the UI shell's connectivity wiring. */
  readonly cloud = cloud
  settings = cloud.document<Settings>('settings', defaults)
  items = cloud.collection<Item>('items')

  async init() {
    await cloud.restore() // resume a saved session, if there is one
  }

  dispose() {
    cloud.dispose() // sync and store together
  }
}

Store options stay at the top level; server options live under cloud:

| Field (under cloud) | Type | Default | Notes | | ——————— | ———————————— | —–– | —————————————————————– | | appId | string | — | Reverse-DNS app id (e.g. me.bpev.cook). | | manifest | AppManifest | — | Registered on first sync — see Registration. | | blobStore | BlobStore | — | Enables blob sync — see Blobs over sync. | | blobPolicy | 'eager' \| 'lazy' \| (ref) => bool | eager | When to fetch blob bodies. Lazy = on demand. | | syncInterval | number | 30000 | Background sync period (ms). | | selectiveSync | string[] | — | Narrow which entities sync; the rest stay device-local. |

encryptionContext sits at the top level (it is a Store option) and covers both at-rest and on-the-wire encryption — see Encryption.


3. Add the sync UI #

Drop <civ-sync-input> into your settings route. It drives the whole connect-and-authorize flow — the user enters a server URL, signs in, and grants the app its scope:

// www/routes/settings.ts
import '@civility/ui'
import app from '../store/app.ts'

const APP_ID = 'me.example.myapp' // the same id you passed in `cloud.appId`

// in your template:
html`
  <civ-sync-input app-id="${APP_ID}" .synced="${app.cloud}"></civ-sync-input>
  <civ-sync-state .synced="${app.cloud}"></civ-sync-state>
`

| Binding | Kind | Purpose | | ——— | ——— | ——————————————————— | | app-id | attribute | The app’s id (must match cloud.appId). | | scope | attribute | Optional permission scope to request (default *). | | .synced | property | The live CloudStore — the component drives it directly. |

<civ-sync-state> shows live status: last-synced time, pending changes, errors.

<civ-usage .synced="${app.cloud}"> adds a storage meter — bytes used against the account’s quota, refreshed after every sync — so users see a full account coming before pushes start failing with quota.* errors. It renders nothing until connected, so it can sit permanently in the settings route.


4. Connection, control, and events #

You rarely call connect / login yourself — <civ-sync-input> does. Use the getters and events to reflect state; use the control methods for the occasional manual push or to pause the loop.

// connection & auth (mostly driven by <civ-sync-input>)
cloud.connect(serverUrl) / cloud.login(email, password) / cloud.signup(...) / cloud.logout()
cloud.restore()                              // resume a saved session + startSync
cloud.saveConnection(url, token) / restoreConnection() / clearConnection()
cloud.connected / authenticated / syncing / lastSync / appId   // getters

// sync control & blobs
cloud.startSync() / stopSync()
cloud.blockSync() / unblockSync()          // pause the loop (e.g. while locked)
cloud.sync(): Promise<void>                 // one cycle now
cloud.forcePush() / forcePull()
cloud.blob(hash) / cloud.evictBlob(hash) / cloud.compactBlobs(opts?)
cloud.dispose()

Events (cloud.addEventListener(...)):

| Event | Detail | | –––––––––––––– | ————————————————————————— | | connected / disconnected | — | | auth | login/logout/token change | | sync | SyncEvent{ pushed, pulled, serverHLC } | | error | SyncErrorEvent{ error, context } (phase: push/pull/blob_*/decrypt/…) | | encryption-reconcile | EncryptionReconcileEvent |


5. Offline-first habits #

Sync is background reconciliation, not request/response:

  • Local is the source of truth. Never block the UI on a round-trip — a freshly-written record is queryable before it has ever reached the server.
  • Make the app work with no server at all. Sync is opt-in per user; everything should work before they connect.
  • Reflect sync state, don’t gate on it. Show status via the getters/events and <civ-sync-state>; don’t disable features when offline.
  • Precache the shell in worker.ts so the PWA opens offline (see the README).

Blobs over sync #

Passing a cloud.blobStore replicates binary bodies alongside change data. With blobPolicy: 'lazy', metadata syncs immediately but bodies download on demand — important on mobile.

const cloud = new CloudStore(backend, {
  collections: ['photos'],
  versions: [{ version: '1.0.0', schema }],
  cloud: { appId: APP_ID, blobStore: blobs, blobPolicy: 'lazy' },
})

const body = await cloud.blob(hash) // lazy-fetch a missing body
await cloud.compactBlobs() // GC bodies no live reference points at (after a grace period)

See the Blobs guide for the store-plus-reference pattern and the withBlobProxy worker plugin, and the gallery recipe for a full example.


Encryption #

E2E encryption keeps the server from reading user data. It only means anything once you sync, which is why it lives here rather than as its own feature.

The user’s secret is a single generated 6-word passphrase, which is also their recovery — there is no second secret behind it. It’s normally typed at the server’s own origin, in the authorize popup, so your app never handles it.

If your manifest sets requiresEncryption, your <civ-sync-input> scope must include encryption:manage. The popup then delivers the sync token and the unlocked key together; without it the server rejects every push the app makes.

Turn it on with one config line. encryption: {} on your CloudStore gives you cloud.encryption, which owns the whole lifecycle and installs every key it derives on both halves of the store — at rest and on the wire. <civ-locked-screen> (@civility/ui) is the boot-time overlay. Architecture: encryption spec.

const cloud = new CloudStore(new IDBStorage({ dbName: APP_NAME }), {
  collections: ['notes'],
  versions,
  cloud: { appId: APP_ID },
  encryption: {},
})

Lock screen on boot

Place <civ-locked-screen> once in your app shell, then reconcile after the user authenticates:

cloud.addEventListener('auth', async () => {
  // A device the user chose to stay unlocked on is already done (§4.12.7).
  if (await cloud.encryption.restoreDevice()) return cloud.startSync()

  const result = await cloud.encryption.reconcile()
  if (result.action === 'unlock-required') {
    cloud.blockSync() // don't pull ciphertext we can't decrypt yet
    // The 6-word phrase is also recovery — there is no second unlock mode.
    lockScreen.unlocker = {
      unlock: (phrase) => cloud.encryption.unlock(phrase),
    }
  }
})

lockScreen.addEventListener('unlocked', () => {
  cloud.unblockSync()
  cloud.startSync()
})

Note what is not here: no HTTP client to build, no LocalEncryptionStore to point at the right backend, and no setEncryptionContext call after the unlock. cloud.encryption resolves the first two from the connection the store already has, and installs the context itself.

reconcile() returns a ReconcileResult:

| Action | Meaning | What to do | | —————– | –––––––––––––––––––––– | –––––––––––––––––––––– | | noop | Both sides agree (or neither has encryption) | Unblock sync, proceed normally | | unlock-required | Server (or local cache) has wrapped state | Show the lock screen | | push-pending | Local state exists but not pushed to server | Unblock sync; user can push via settings | | collision | Local and server have different keys | Unblock sync; user must resolve via settings |

unlock() returns false when the account has no encryption at all and throws when the passphrase is wrong — the two call for different UI, so they are different outcomes.

Key lifecycle

Wire these into settings-page actions:

// Setup (first time) — show the 6-word passphrase to the user exactly once.
// It is their only encryption secret, and it doubles as recovery.
const { passphrase } = await cloud.encryption.setup()

// Rotation replaces "password change" and "recovery regeneration" both. It
// needs a key it can re-wrap, which a remembered-device unlock deliberately
// cannot give (§4.12.7) — re-unlock with the current phrase first.
const { passphrase: fresh } = await cloud.encryption.rotate()

// "Stay unlocked on this device", optionally behind a short PIN.
await cloud.encryption.remember({ pin })
await cloud.encryption.forgetDevice()

cloud.encryption.lock() // drop the in-memory key; stored data untouched

There is deliberately no disable(). Turning encryption off means decrypting everything at rest, and there is no migration in that direction — a method for it could only strand your data behind a key nobody can derive again. Clear the server wrap through cloud.encryption.http.disableEncryption() if you mean to, and move the data with export() / import().

Offline unlock

Unlock checks the local wrapped-state cache before the network, so a cold launch after a previous unlock needs no round-trip and a new device fetches once, then caches. Only the wrapped key is cached — a stolen device still needs the passphrase. The cache lives in cloud.metaBackend, alongside URL/token/HLC, so it has exactly the lifetime of the session it unlocks.

restoreDevice() needs no network and no passphrase at all.

Unlocking through the popup works offline too, and needs nothing from you — but only for app/server pairs that unlocked online at least once before. For an encrypted app, that makes two things load-bearing: precache the shell (@civility/workers), and get the user through a first unlock during onboarding rather than the first time they’re on a plane.


Devices #

<civ-devices> lists the account’s signed-in devices with rename and sign-out, so users can end a lost device’s session without leaving your app.

Add sessions:manage to your <civ-sync-input> scope — the server refuses these calls without it, and full data access deliberately doesn’t imply it. Then hand the component a client built from the saved connection:

import { createSessionsHttp } from '@civility/cloud/api'

const conn = await cloud.restoreConnection()
if (conn) {
  document.querySelector('civ-devices').source = createSessionsHttp(
    conn.url,
    conn.token,
  )
}

Tokens minted before you added the scope don’t carry it, so anyone who connected earlier gets a 401 here until they reconnect. Worth catching: a listSessions() that throws is better rendered as “reconnect to enable this” than as the raw error.


Next #

  • Store — the local state sync replicates.
  • Blobs — sync binary data too.
  • UI — the civ-sync-input / civ-sync-state / civ-usage / civ-locked-screen components.
  • Services — call AI and other credentialed services through the same server.