Summary
Automatic CRUD upload does not fire on local writes when the SDK's internal upload-trigger watch is the only active tableUpdates subscriber. Queued CRUD ops sit in ps_crud indefinitely until something else forces an upload (e.g. a disconnect→connect). Adding any second concurrent db.watch on ps_crud reliably revives automatic uploads.
Reproduced on 1.14.1 and 1.14.4 (powersync-sqlite-core 0.4.13, CSQLite 3.51.2), iOS 26.5 Simulator, plain PowerSyncDatabase(schema:dbFilename:) (native connection pool, not GRDB).
Symptom
connect(connector:) — initial download sync completes, currentStatus.connected == true.
- Perform a local write via
writeTransaction (INSERT into a synced view → INSTEAD OF trigger writes ps_crud). ps_crud now has rows.
- The connector's
uploadData is never invoked automatically. currentStatus.connected stays true; the ops just sit in the queue (observed 60s+).
- A
disconnect() + connect() drains the queue immediately (via the connect-time upload path), so the connector and the server are fine.
Root cause (as far as I can tell from the source)
StreamingSyncClient.uploadLoop triggers uploads from an internal live query:
let watch = try db.watch(options: WatchOptions(sql: "SELECT 1 FROM ps_crud LIMIT 1",
throttle: options.crudThrottle, ...))
.dropFirst()
let allTriggers = AsyncAlgorithms.merge(watch, signals.signalCrudUpload.subscribe())
for try await _ in allTriggers { try await uploadAllCrud() ... }
watchImpl resolves the watched tables ({ps_crud}) and fires when pool.tableUpdates reports a changed table in that set. When this is the sole subscriber to the pool's tableUpdates (BroadcastStream), the loop does not observe ps_crud changes from writes — so uploadAllCrud() is never triggered.
Minimal repro / evidence
In an app-side probe I subscribed a second, no-op watch on the same table:
Task {
for try await _ in try db.watch(sql: "SELECT count(*) FROM ps_crud",
parameters: nil, mapper: { try $0.getInt(name: 0) }) {
// no-op
}
}
- Without this probe: a confirmed write leaves
ps_crud non-empty for 60s+; uploadData never called.
- With this probe: the same write triggers
uploadData within the throttle window and ps_crud drains to 0 (verified 4/4 attempts). Removing it reproduces the stall.
So the presence of a second tableUpdates subscriber is what makes the SDK's own upload watch fire.
Workaround
Keep an app-owned db.watch on ps_crud alive for the connected session (e.g. one that also drives a pending-count indicator). This restores automatic uploads reliably.
Environment
- powersync-swift: 1.14.1 and 1.14.4 (same behavior)
- powersync-sqlite-core-swift: 0.4.13, CSQLite: 3.51.2
- Platform: iOS 26.5 Simulator (iPad), Swift concurrency, SwiftUI
- DB: default
PowerSyncDatabase(schema:dbFilename:), native (non-GRDB) connection pool
- Writes via
writeTransaction/execute on synced views (standard INSTEAD OF → ps_crud)
Happy to provide a stripped-down sample project if useful.
Summary
Automatic CRUD upload does not fire on local writes when the SDK's internal upload-trigger watch is the only active
tableUpdatessubscriber. Queued CRUD ops sit inps_crudindefinitely until something else forces an upload (e.g. adisconnect→connect). Adding any second concurrentdb.watchonps_crudreliably revives automatic uploads.Reproduced on 1.14.1 and 1.14.4 (powersync-sqlite-core 0.4.13, CSQLite 3.51.2), iOS 26.5 Simulator, plain
PowerSyncDatabase(schema:dbFilename:)(native connection pool, not GRDB).Symptom
connect(connector:)— initial download sync completes,currentStatus.connected == true.writeTransaction(INSERT into a synced view → INSTEAD OF trigger writesps_crud).ps_crudnow has rows.uploadDatais never invoked automatically.currentStatus.connectedstaystrue; the ops just sit in the queue (observed 60s+).disconnect()+connect()drains the queue immediately (via the connect-time upload path), so the connector and the server are fine.Root cause (as far as I can tell from the source)
StreamingSyncClient.uploadLooptriggers uploads from an internal live query:watchImplresolves the watched tables ({ps_crud}) and fires whenpool.tableUpdatesreports a changed table in that set. When this is the sole subscriber to the pool'stableUpdates(BroadcastStream), the loop does not observeps_crudchanges from writes — souploadAllCrud()is never triggered.Minimal repro / evidence
In an app-side probe I subscribed a second, no-op watch on the same table:
ps_crudnon-empty for 60s+;uploadDatanever called.uploadDatawithin the throttle window andps_cruddrains to 0 (verified 4/4 attempts). Removing it reproduces the stall.So the presence of a second
tableUpdatessubscriber is what makes the SDK's own upload watch fire.Workaround
Keep an app-owned
db.watchonps_crudalive for the connected session (e.g. one that also drives a pending-count indicator). This restores automatic uploads reliably.Environment
PowerSyncDatabase(schema:dbFilename:), native (non-GRDB) connection poolwriteTransaction/executeon synced views (standard INSTEAD OF →ps_crud)Happy to provide a stripped-down sample project if useful.