PowerSync Sync Middleware & Custom SharedWorker
July 10, 2026 · View on GitHub
This document explains the data transformation middleware built on top of PowerSync and the custom SharedWorker required to make it work with multi-tab support.
Overview
PowerSync syncs data from the server (PostgreSQL) to the local SQLite database. By default, sync data arrives from the server and is written to SQLite as-is. The middleware layer intercepts sync data before it is written, allowing transformations such as decoding, normalization, or decryption.
The implementation uses AES-256-GCM decryption to decrypt all encrypted columns before local storage. See e2e-encryption.md for the full encryption architecture.
Architecture
Key Files
| File | Role |
|---|---|
| src/db/powersync/TransformableBucketStorage.ts | Extends SqliteBucketStorage to intercept sync data and run the transformer pipeline |
| src/db/powersync/ThunderboltPowerSyncDatabase.ts | Extends PowerSyncDatabase to inject TransformableBucketStorage as the storage adapter |
| src/db/powersync/middleware/EncryptionMiddleware.ts | Decrypts all encrypted columns (defined in encryptedColumnsMap) using AES-256-GCM via the codec |
| src/db/powersync/worker/ThunderboltSharedSyncImplementation.ts | Extends SharedSyncImplementation to inject TransformableBucketStorage inside the SharedWorker |
| src/db/powersync/worker/ThunderboltSharedSyncImplementation.worker.ts | SharedWorker entry point — mirrors PowerSync's original but uses the custom implementation |
| src/db/powersync/database.ts | Database config — wires up the custom SharedWorker for Chrome/Edge/Firefox |
Data Flow
Two distinct paths exist depending on the platform. Both end at the same point — decrypted data in SQLite — but the interception happens in different execution contexts.
Chrome / Edge / Firefox (SharedWorker Path)
flowchart TD
Server["PowerSync service<br/>(encrypted data)"]
subgraph MainThread["Main Thread"]
TPS["ThunderboltPowerSyncDatabase<br/>extends PowerSyncDatabase<br/><br/>generateBucketStorageAdapter()<br/>→ creates TransformableBucketStorage<br/> (unused in SharedWorker path)"]
Drizzle["Drizzle / DAL<br/>(reads decrypted data)"]
SQLite[("SQLite<br/>(decrypted)")]
end
subgraph SharedWorker["SharedWorker — ThunderboltSharedSyncImplementation.worker.ts"]
TSSI["ThunderboltSharedSyncImplementation<br/>extends SharedSyncImplementation<br/><br/>generateStreamingImplementation()<br/>→ creates TransformableBucketStorage<br/> + registers encryptionMiddleware"]
TBS["TransformableBucketStorage<br/>extends SqliteBucketStorage<br/><br/>control(PROCESS_TEXT_LINE | PROCESS_BSON_LINE)<br/>→ parse → transform → super.control()"]
MW["encryptionMiddleware<br/>(AES-GCM decryption via codec)"]
SBS["SqliteBucketStorage<br/>super.control()"]
end
Server -->|"sync stream<br/>(TEXT_LINE or BSON_LINE)"| TSSI
TSSI --> TBS
TBS -->|"SyncDataBucket"| MW
MW -->|"transformed bucket"| TBS
TBS --> SBS
SBS -->|"writes"| SQLite
SQLite --> Drizzle
Safari / Tauri (Dedicated Worker Path)
flowchart TD
Server["PowerSync service<br/>(encrypted data)"]
subgraph MainThread["Main Thread"]
TPS["ThunderboltPowerSyncDatabase<br/>extends PowerSyncDatabase<br/><br/>generateBucketStorageAdapter()<br/>→ creates TransformableBucketStorage<br/> + registers encryptionMiddleware"]
TBS["TransformableBucketStorage<br/>extends SqliteBucketStorage<br/><br/>control(PROCESS_TEXT_LINE | PROCESS_BSON_LINE)<br/>→ parse → transform → super.control()"]
MW["encryptionMiddleware<br/>(AES-GCM decryption via codec)"]
SBS["SqliteBucketStorage<br/>super.control()"]
Drizzle["Drizzle / DAL<br/>(reads decrypted data)"]
SQLite[("SQLite<br/>(decrypted)")]
end
subgraph DedicatedWorker["Dedicated Worker — WASQLiteDB.umd.js"]
WASM["Rust sync client (WASM)<br/>OPFSCoopSyncVFS"]
end
Server -->|"sync stream"| WASM
WASM -->|"control(PROCESS_TEXT_LINE)<br/>via MessagePort"| TPS
TPS --> TBS
TBS -->|"SyncDataBucket"| MW
MW -->|"transformed bucket"| TBS
TBS --> SBS
SBS -->|"writes"| SQLite
SQLite --> Drizzle
How TransformableBucketStorage Works
PowerSync's Rust sync client sends incoming data to the storage adapter via:
adapter.control(PROCESS_TEXT_LINE, jsonPayload) // NDJSON responses
adapter.control(PROCESS_BSON_LINE, bsonPayload) // BSON responses (preferred since @powersync/web 1.37+)
TransformableBucketStorage overrides control() to intercept both formats. The Rust sync client delivers one bucket per control() call (one NDJSON line, or one BSON line). When a sync data command arrives, it:
- Parses the payload (JSON string or BSON binary) to extract the
SyncDataBucket - Runs it through the registered transformer pipeline (each transformer receives the output of the previous)
- Re-encodes the transformed bucket in the same format as the original (JSON → JSON, BSON → BSON)
- Passes the result to
super.control()→SqliteBucketStorage→ SQLite
All other control commands (START, STOP, etc.) pass through unchanged.
Why both formats? Starting with
@powersync/web1.37, the HTTP sync stream sends anAcceptheader preferring BSON over NDJSON. If the server supports BSON, sync data arrives as binaryUint8Arraypayloads viaPROCESS_BSON_LINEinstead of JSON strings viaPROCESS_TEXT_LINE. The middleware must handle both to ensure transformations run regardless of server response type.
Middleware Interface
type DataTransformMiddleware = {
transform(bucket: SyncDataBucket): Promise<SyncDataBucket> | SyncDataBucket
}
Transformers operate on a single SyncDataBucket → OplogEntry[] (via bucket.data). Each entry has:
object_type— table name (e.g."tasks")object_id— row IDdata— JSON string of the row (modify this to transform field values)op—INSERT/UPDATE/DELETE
Why per-bucket and not per-batch? The Rust sync client streams data one bucket at a time via
control(PROCESS_*_LINE). The legacySyncDataBatchabstraction belonged to the (now-removed) JavaScript sync client, which buffered multiple buckets before writing. Our middleware never sees more than one bucket per invocation.
Registering Transformers
Pass them via ThunderboltPowerSyncDatabaseOptions.transformers in getPowerSyncOptions():
transformers: [encryptionMiddleware]
ThunderboltPowerSyncDatabase.generateBucketStorageAdapter() picks these up and registers them on TransformableBucketStorage.
The Multi-Tab Problem
PowerSync defaults to enableMultiTabs: true on Chrome/Edge/Firefox. This launches a SharedWorker that:
- Manages a single sync connection shared across all browser tabs
- Deduplicates CRUD uploads (only one tab uploads at a time)
The problem: the SharedWorker creates its own SqliteBucketStorage instance internally (in SharedSyncImplementation.generateStreamingImplementation()). It completely ignores any custom BucketStorageAdapter configured on the main thread. Setting enableMultiTabs: false was the original workaround — but this sacrifices cross-tab sync efficiency.
The root cause is architectural:
SharedSyncImplementationhardcodesnew SqliteBucketStorage(...)with no injection hook- Transformer functions cannot be serialized across the worker boundary (Comlink limitation)
- The
adapterfield is explicitly omitted from theSharedSyncInitOptionstype passed to the worker
Solution: Custom SharedWorker
Instead of disabling multi-tab, we provide a custom SharedWorker that embeds the transformer logic at bundle time.
How It Works
ThunderboltSharedSyncImplementation extends SharedSyncImplementation and overrides generateStreamingImplementation() — the one protected method that controls which storage adapter is used. The override is a direct copy of the parent method with SqliteBucketStorage replaced by TransformableBucketStorage + encryptionMiddleware.
ThunderboltSharedSyncImplementation.worker.ts is the SharedWorker entry point, mirroring PowerSync's original SharedSyncImplementation.worker.ts but instantiating the custom class.
In database.ts, the default config (Chrome/Edge/Firefox) points PowerSync to this custom worker via:
sync: {
worker: () =>
new SharedWorker(
new URL('./worker/ThunderboltSharedSyncImplementation.worker.ts', import.meta.url),
{ type: 'module', name: `shared-sync-${dbFilename}` },
),
}
Vite detects the new SharedWorker(new URL(...)) pattern and bundles the worker file as a separate ES module chunk.
Why This Works
- Transformer logic lives in the worker bundle (compiled at build time) — no serialization needed
- The content key (CK) is accessed directly via IndexedDB inside the SharedWorker — no
postMessageneeded - Multi-tab sync efficiency is preserved: SharedWorker still manages a single connection
Accessing SharedSyncImplementation Internals
SharedSyncImplementation is marked @internal and not in @powersync/web's public exports map. We access it via a Vite alias and a matching TypeScript paths entry that both point to the compiled lib output:
vite.config.ts:
resolve: {
alias: {
'powersync-web-internal': path.resolve(__dirname, 'node_modules/@powersync/web/lib/src'),
}
}
tsconfig.json:
"paths": {
"powersync-web-internal/*": ["./node_modules/@powersync/web/lib/src/*"]
}
Imports then look like:
import { SharedSyncImplementation } from 'powersync-web-internal/worker/sync/SharedSyncImplementation.js'
Upgrade note: When upgrading
@powersync/web, verify thatgenerateStreamingImplementation()innode_modules/@powersync/web/src/worker/sync/SharedSyncImplementation.tshasn't changed. If it has, update the override inThunderboltSharedSyncImplementation.tsto match.
Safari / Tauri: Why It Stays Different
The Safari and Tauri config keeps enableMultiTabs: false and the main-thread transformer path. This is not a limitation of our middleware — it's an inherent constraint of the environment:
- OPFSCoopSyncVFS (required on Safari/iOS for stack size reasons) does not support SharedWorker
- Tauri (
tauri://protocol) blocks SharedWorker andimport.meta.url-based worker loading - SharedWorker itself exceeds iOS WKWebView memory limits and causes black-screen crashes
For Safari/Tauri, ThunderboltPowerSyncDatabase.generateBucketStorageAdapter() is called on the main thread and the transformer runs there via the dedicated worker path.
Adding Encrypted Columns
To encrypt a new column, add the table and column name to encryptedColumnsMap in src/db/encryption/config.ts. The existing encryptionMiddleware handles all columns in the map automatically — both download decryption and upload encryption.
See e2e-encryption.md for details.
Adding a Non-Encryption Transformer
If you need a non-encryption transformation (e.g. data normalization, decompression):
- Create a file in
src/db/powersync/middleware/implementingDataTransformMiddleware. - Register it in two places (both paths must be kept in sync):
getPowerSyncOptions()in src/db/powersync/database.ts —transformers: [encryptionMiddleware, myMiddleware](used by the Safari/Tauri main-thread path)ThunderboltSharedSyncImplementation.generateStreamingImplementation()in src/db/powersync/worker/ThunderboltSharedSyncImplementation.ts —storage.addTransformer(myMiddleware)(used by the Chrome/Edge/Firefox SharedWorker path)
CK Access in the SharedWorker
The SharedWorker has direct indexedDB access, so the codec loads the content key (CK) lazily from IndexedDB without needing postMessage. The CK is cached in a module-scoped variable inside the worker for the process lifetime.
invalidateCKCache() uses a BroadcastChannel to propagate cache invalidation across all contexts (main thread, SharedWorker, other tabs), so the worker clears its stale CK immediately on sign-out.
See e2e-encryption.md for the full encryption architecture.