Multi-Device Sync
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_idcolumn. PowerSync’s sync rules scope every row to the authenticated user. - The backend issues short-lived JWTs that PowerSync validates. Rotate
POWERSYNC_JWT_SECRETto 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
encryptionMiddlewaredecrypts encrypted columns on download and encrypts them on upload. See End-to-End Encryption.
Two Sync Paths
The runtime picks one of two pipelines. Both run the transform off the UI thread, in a worker hosting the same ThunderboltSharedSyncImplementation — only the worker kind differs.
| Runtime | Path | Why this path |
|---|---|---|
| Chrome · Edge · Firefox | Custom SharedWorker | One sync connection shared across tabs; the content key stays in the worker |
| Safari · iOS · Tauri | Dedicated Worker standing in for the SharedWorker | OPFSCoopSyncVFS and tauri:// rule out SharedWorker, so a dedicated Worker hosts the same sync stream |
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:
| Table | Purpose | Primary key |
|---|---|---|
settings |
Per-user preferences | (key, user_id) |
chat_threads |
Conversation metadata | id |
chat_messages |
Individual messages within threads | id |
tasks |
Todo / task items (defaults seeded per user) | (id, user_id) |
models |
Configured models — provider, endpoint, capabilities (defaults seeded per user) | (id, user_id) |
prompts |
Saved prompt templates (defaults seeded per user) | (id, user_id) |
skills |
Slash-command skills (defaults seeded per user) | (id, user_id) |
triggers |
Automations | id |
model_profiles |
Per-model tuning (temperature, prompt overrides) seeded per user | (id, user_id) |
devices |
Registered devices for the current account | id |
agents |
User-created ACP agents (built-ins and system agents are not rows) | (id, user_id) |
projects |
Project workspaces (durable instructions) | id |
Default-data tables use composite primary keys so multiple users can hold the same default id — see composite-primary-keys-and-default-data.md.
Local-Only Tables
src/db/powersync/schema.ts splits the client schema in two: syncedTables (the twelve above) and localOnlyTables, registered with options: { localOnly: true } so PowerSync creates them in SQLite but never streams them.
| Table | Holds |
|---|---|
models_secrets |
Model API keys |
integrations_secrets |
Google / Microsoft OAuth tokens |
mcp_servers |
MCP server configuration |
mcp_secrets |
MCP bearer tokens / API keys |
agents_system |
System-provided ACP agents, hydrated from the backend /agents discovery |
agents_secrets |
ACP agent credentials |
Three of them are the local half of a pair: models + models_secrets and agents + agents_secrets sync the config row and keep the credential on-device; mcp_servers + mcp_secrets pairs the same way but keeps both halves local. src/dal/mcp-servers.ts writes both halves in one transaction (createMcpServerWithCredentials, updateMcpServerWithCredentials) because the connection code reads the secret at connect time, so a partial write would either orphan the secret or connect unauthenticated.
mcp_servers is local-only for a different reason than the secrets tables: replicating a server entry without its (local-only) credentials would hand every other device a server it cannot connect to. agents_system is a cache of a backend response, so syncing it would duplicate a source the other device can fetch for itself.
The invariant: nothing carrying a credential may join syncedTables. Adding a table there means confirming it holds no secret. Only mcp_servers and mcp_secrets have a regression test (src/db/powersync/schema.test.ts); the other four rely on review.
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, the backend schema, and the PowerSync sync rules, so it ships as two PRs: backend schema + migration + shared/powersync-tables.ts + all three sync-rule configs first, then the frontend schema and feature code once the new thunderbolt-powersync image is live. Shipping the frontend first causes silent sync failure — the table works locally but rows never replicate.
The full procedure, including the user_id-index-only rule, which bucket (and therefore which sync priority) the new rule belongs in, and how to remove a table or add a column to an existing one, is in Adding a New Synced Table.
Indexing Strategy
The backend Postgres schema carries primary keys and one user_id index per table, and nothing else — it is a sync server, not a query engine, and heavy queries run against the client’s SQLite. Full rationale in Index Strategy: user_id Only.
Related Reading
- PowerSync, Account & Device Management — how devices are registered and revoked.
- End-to-End Encryption — how the sync pipeline encrypts data before it reaches the server.
- Quick Start and Testing — schema rules, composite keys, and the migration checklist.