Multi-Device Sync

August 7, 2026 · View on GitHub

Thunderbolt's multi-device sync is built on PowerSync. Every device holds a local SQLite database; the sync service streams deltas between SQLite and the backend's PostgreSQL. Writes happen locally first, so the app stays snappy offline.

Note. Cross-device sync and optional end-to-end encryption are both in Preview.

How It Works

┌──────────┐   reads/writes   ┌──────────┐   transform        ┌────────────┐
│  Client  │ ───────────────▶ │  SQLite  │ ──(sync worker)──▶ │ PowerSync  │
│          │                  │  (local) │                    │  Service   │
└──────────┘                  └──────────┘                    └─────┬──────┘
                                                                    │ logical
                                                                    │ replication

                                                             ┌──────────────┐
                                                             │  PostgreSQL  │
                                                             │  (backend)   │
                                                             └──────────────┘
  • Every synced table has a user_id column. PowerSync's sync rules scope every row to the authenticated user.
  • The backend issues short-lived JWTs that PowerSync validates. Rotate POWERSYNC_JWT_SECRET to invalidate every outstanding token.
  • Client writes go to local SQLite first, then upload to the backend through PUT /v1/powersync/upload. The backend applies them in a PostgreSQL transaction.
  • A transform-middleware pipeline sits between PowerSync and SQLite. The built-in encryptionMiddleware decrypts encrypted columns on download and encrypts them on upload. See End-to-End Encryption.

Two Sync Paths

There are two distinct pipelines depending on the runtime. Both end with decrypted rows in local SQLite — the transform runs in a different execution context.

RuntimePathWhy this path
Chrome · Edge · FirefoxCustom SharedWorker ThunderboltSharedSyncImplementationOne sync connection shared across tabs; the CK stays in the worker
Safari · iOS · TauriMain-thread transformerOPFSCoopSyncVFS doesn't support SharedWorker; Tauri blocks it too

The full write-up is in powersync-sync-middleware.md, including the Vite alias (powersync-web-internal) that lets the custom SharedWorker reach into @powersync/web's @internal classes.

Synced Tables

From shared/powersync-tables.ts:

TablePurposePrimary key
settingsPer-user preferences(key, user_id)
chat_threadsConversation metadataid
chat_messagesIndividual messages within threadsid
tasksTodo / task items (defaults seeded per user)(id, user_id)
modelsConfigured model profiles (defaults seeded per user)(id, user_id)
promptsSaved prompt templates (defaults seeded per user)(id, user_id)
model_profilesPer-model tuning (temperature, prompt overrides) seeded per user(id, user_id)
mcp_serversRegistered Model Context Protocol serversid
triggersAutomationsid
devicesRegistered devices for the current accountid

Default-data tables use composite primary keys so multiple users can hold the same default id — see composite-primary-keys-and-default-data.md.

Offline Behavior

  • Everything you do offline — new chats, sent messages, edits — writes to local SQLite immediately.
  • On reconnect, the sync worker replays queued operations through the backend. Conflicts resolve last-writer-wins at the row level.
  • Settings → Devices shows each device's last-seen time; a stale value means the device hasn't reconnected yet.

Adding a New Synced Table

Adding a table touches both clients and the backend plus the PowerSync sync rules. To avoid races where clients expect rows the sync service won't stream, ship the change in two PRs:

  1. Backend + sync rules PR

    • Add the table to backend/src/db/powersync-schema.ts with a Drizzle migration.
    • Register in shared/powersync-tables.ts (powersyncTableNames + powersyncTableToQueryKeys).
    • Add the sync rule to all three configs: powersync-service/config/config.yaml, deploy/config/powersync-config.yaml, and deploy/k8s/templates/configmaps.yaml.
    • Merge and deploy this first. On merge, CI publishes a new ghcr.io/thunderbird/thunderbolt/thunderbolt-powersync image; roll the Render powersync service to that new tag before PR 2 merges.
  2. Frontend + feature PR

    • Add the table to src/db/tables.ts and src/db/powersync/schema.ts.
    • Wire up DAL, defaults, reconciliation, and UI.
    • Merge only after PR 1's sync rules are live.

Deploying the frontend before sync rules are live causes silent sync failure — the table works locally but rows never replicate.

Indexing Strategy

The backend PostgreSQL schema uses a minimal index strategy:

  • Primary keys (required)
  • A single user_id index per table (required for PowerSync sync rules)
  • No composite foreign keys
  • No active/soft-delete indexes
  • No secondary indexes on encrypted columns

Why: the backend is a sync server, not a query engine. Heavy queries run on the client's SQLite. Indexes on encrypted columns would be useless anyway, and fewer indexes means faster writes during sync. Full rationale in composite-primary-keys-and-default-data.md.