PowerSync Sync Middleware & Custom SharedWorker
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 every __enc:-prefixed string value in the sync stream using AES-256-GCM via the codec |
| src/db/powersync/worker/ThunderboltSharedSyncImplementation.ts | Extends SharedSyncImplementation to inject TransformableBucketStorage into the worker’s sync pipeline — used by both worker paths |
| src/db/powersync/worker/ThunderboltSharedSyncImplementation.worker.ts | SharedWorker entry point — mirrors PowerSync’s original but uses the custom implementation |
| src/db/powersync/worker/ThunderboltDedicatedSyncImplementation.worker.ts | Dedicated-Worker entry point for Safari/Tauri — hosts the same ThunderboltSharedSyncImplementation over a single Comlink endpoint (self) |
| src/db/powersync/database.ts | Database config — picks the SharedWorker (Chrome/Edge/Firefox) or the dedicated Worker (Safari/Tauri) |
Data Flow
Two paths exist depending on the platform. Both run the same ThunderboltSharedSyncImplementation off the main thread and end at the same point — decrypted data in SQLite. They differ only in how that worker is hosted (SharedWorker vs dedicated Worker) and in which VFS backs SQLite.
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/> (not on the sync-stream 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 --> DrizzleSafari / 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/> (not on the sync-stream path)"] SWSSI["SharedWebStreamingSyncImplementation<br/>drives the sync worker over Comlink"] Drizzle["Drizzle / DAL<br/>(reads decrypted data)"] end
subgraph SyncWorker["Dedicated Worker — ThunderboltDedicatedSyncImplementation.worker.ts"] TSSI["ThunderboltSharedSyncImplementation<br/>(the same class as the SharedWorker path)<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
subgraph DbWorker["Dedicated Worker — WASQLiteDB.umd.js"] SQLite[("SQLite<br/>OPFSCoopSyncVFS<br/>(decrypted)")] end
TPS --> SWSSI SWSSI -->|"Comlink over MessagePort"| TSSI 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 --> DrizzleHow TransformableBucketStorage Works
PowerSync’s Rust sync client sends incoming data to the storage adapter via:
adapter.control(PROCESS_TEXT_LINE, jsonPayload) // NDJSON responsesadapter.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— one ofPUT,REMOVE,MOVE,CLEAR(OpTypeJSONin the sync protocol, not SQL verbs)
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 ThunderboltPowerSyncOptions.transformers in getPowerSyncOptions():
transformers: [encryptionMiddleware]ThunderboltPowerSyncDatabase.generateBucketStorageAdapter() picks these up and registers them on the main thread’s TransformableBucketStorage. That adapter is not the one the sync stream writes through on either platform — the stream runs in a worker, which builds its own adapter in ThunderboltSharedSyncImplementation.generateStreamingImplementation(). See Adding a Non-Encryption Transformer for why a new transformer has to be registered in both places.
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
What stays different on Safari and Tauri is the worker hosting, not the transform. The environment rules out a SharedWorker:
- OPFSCoopSyncVFS (required on Safari/iOS for stack size reasons) does not support SharedWorker
- Tauri (
tauri://protocol) blocks SharedWorker andimport.meta.url-based worker loading fornode_modulesworkers, so the DB worker is loaded from an explicit UMD path (/@powersync/worker/WASQLiteDB.umd.js) - SharedWorker itself exceeds iOS WKWebView memory limits and causes black-screen crashes
So the config sets enableMultiTabs: false on the inner WASQLiteOpenFactory (the DB worker) but enableMultiTabs: true at the PowerSyncDatabase level. The true is what selects SharedWebStreamingSyncImplementation, which drives a worker over Comlink; createDedicatedSyncWorker() then hands it a dedicated Worker in place of the SharedWorker, mapping .close() onto .terminate() because a dedicated Worker exposes only the latter. Inside that worker, ThunderboltDedicatedSyncImplementation.worker.ts instantiates the same ThunderboltSharedSyncImplementation the SharedWorker path uses — the transform runs off the main thread everywhere (THU-777).
Adding Encrypted Columns
To encrypt a new column, add the table and column name to encryptedColumnsMap in src/db/encryption/config.ts. The map is the upload side’s source of truth: encodeForUpload (src/db/encryption/upload-encoder.ts), called from the connector, encrypts exactly the columns listed there.
Download decryption needs no map entry. encryptionMiddleware is deliberately data-driven: it decrypts any string value carrying the __enc: prefix, whatever column it came from. That is what lets a stale client — one whose bundled map predates the new column — still read rows a newer client encrypted, instead of writing ciphertext into SQLite.
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 must be kept in sync):
getPowerSyncOptions()in src/db/powersync/database.ts —transformers: [encryptionMiddleware, myMiddleware], in both thedefaultandsafari-tauribranches. This feedsThunderboltPowerSyncDatabase.generateBucketStorageAdapter(), i.e. the main-thread adapter, not the sync stream.ThunderboltSharedSyncImplementation.generateStreamingImplementation()in src/db/powersync/worker/ThunderboltSharedSyncImplementation.ts —storage.addTransformer(myMiddleware). This is the sync-stream adapter, and it covers both platforms: the SharedWorker and the dedicated Worker host the same class.
CK Access in the Sync Worker
Both worker types have 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() posts to a BroadcastChannel (thunderbolt-ck-invalidation) to propagate cache invalidation across all contexts (main thread, sync worker, other tabs), so the worker clears its stale CK immediately. resetCodecState() does the same for sign-out/wipe and additionally clears the setup flag.
See e2e-encryption.md for the full encryption architecture.