@syncular/client 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  * same bootstrap, the same RPC, a different SQLite.
10
10
  *
11
11
  * The §8.4 host loop lives HERE: wake-ups (hello/`sync` events, delta
12
- * drops) coalesce into one jittered `syncUntilIdle` round in the worker;
12
+ * drops) coalesce into one intent-driven `syncUntilIdle` round in the worker;
13
13
  * RPC-driven and auto-driven sync rounds serialize on one queue because
14
14
  * the core owns exactly one loop.
15
15
  */
@@ -80,34 +80,66 @@ export function startSyncWorker(overrides = {}) {
80
80
  syncChain = next.then(() => undefined, () => undefined);
81
81
  return next;
82
82
  }
83
- // -- §8.4 host loop: coalesced, jittered, inside the worker --------------
83
+ // -- SPEC §7.5 host loop: event-driven intents, no interactive polling --
84
84
  let autoSync = true;
85
- let wakeJitterMs = 200;
86
85
  let autoSyncScheduled = false;
87
- function scheduleAutoSync() {
88
- if (!autoSync || autoSyncScheduled || closed || client === undefined) {
86
+ let backgroundTimer;
87
+ let backgroundDue = Number.POSITIVE_INFINITY;
88
+ function runAutoSync() {
89
+ autoSyncScheduled = false;
90
+ if (closed || client === undefined)
91
+ return;
92
+ const running = client;
93
+ void serializedSync(() => running.syncUntilIdle())
94
+ .then((summary) => {
95
+ if (!closed)
96
+ post({ t: 'event', event: { kind: 'synced', summary } });
97
+ })
98
+ .catch((error) => {
99
+ if (!closed) {
100
+ post({
101
+ t: 'event',
102
+ event: { kind: 'synced', error: toErrorShape(error) },
103
+ });
104
+ }
105
+ });
106
+ }
107
+ function consumeSyncIntent(intent) {
108
+ if (!autoSync || closed || client === undefined || intent.kind === 'none') {
89
109
  return;
90
110
  }
91
- autoSyncScheduled = true;
92
- setTimeout(() => {
93
- autoSyncScheduled = false;
94
- if (closed || client === undefined)
111
+ if (intent.kind === 'background') {
112
+ if (autoSyncScheduled)
113
+ return;
114
+ const due = Date.now() + Math.max(0, intent.delayMs);
115
+ if (backgroundTimer !== undefined && due >= backgroundDue)
95
116
  return;
96
- const running = client;
97
- void serializedSync(() => running.syncUntilIdle())
98
- .then((summary) => {
99
- if (!closed)
100
- post({ t: 'event', event: { kind: 'synced', summary } });
101
- })
102
- .catch((error) => {
103
- if (!closed) {
104
- post({
105
- t: 'event',
106
- event: { kind: 'synced', error: toErrorShape(error) },
107
- });
117
+ if (backgroundTimer !== undefined)
118
+ clearTimeout(backgroundTimer);
119
+ backgroundDue = due;
120
+ backgroundTimer = setTimeout(() => {
121
+ backgroundTimer = undefined;
122
+ backgroundDue = Number.POSITIVE_INFINITY;
123
+ if (!autoSyncScheduled) {
124
+ autoSyncScheduled = true;
125
+ runAutoSync();
108
126
  }
109
- });
110
- }, Math.floor(Math.random() * wakeJitterMs));
127
+ }, Math.max(0, due - Date.now()));
128
+ return;
129
+ }
130
+ if (backgroundTimer !== undefined) {
131
+ clearTimeout(backgroundTimer);
132
+ backgroundTimer = undefined;
133
+ backgroundDue = Number.POSITIVE_INFINITY;
134
+ }
135
+ if (!autoSync || autoSyncScheduled || closed || client === undefined) {
136
+ return;
137
+ }
138
+ autoSyncScheduled = true;
139
+ queueMicrotask(runAutoSync);
140
+ }
141
+ function consumeEffects(effects) {
142
+ consumeSyncIntent(effects.sync);
111
143
  }
112
144
  function requireClient() {
113
145
  if (client === undefined) {
@@ -145,7 +177,6 @@ export function startSyncWorker(overrides = {}) {
145
177
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
146
178
  }
147
179
  autoSync = config.autoSync ?? true;
148
- wakeJitterMs = config.wakeJitterMs ?? 200;
149
180
  database =
150
181
  overrides.openDatabase !== undefined
151
182
  ? await overrides.openDatabase(config)
@@ -187,8 +218,9 @@ export function startSyncWorker(overrides = {}) {
187
218
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
188
219
  onSyncNeeded: (reason) => {
189
220
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
190
- scheduleAutoSync();
221
+ consumeSyncIntent({ kind: 'interactive' });
191
222
  },
223
+ onSyncIntent: consumeSyncIntent,
192
224
  onConflict: (conflict) => {
193
225
  post({ t: 'event', event: { kind: 'conflict', conflict } });
194
226
  },
@@ -201,11 +233,9 @@ export function startSyncWorker(overrides = {}) {
201
233
  post({ t: 'event', event: { kind: 'presence', scopeKey } });
202
234
  },
203
235
  });
204
- // TODO 3.1 / I1: forward every coalesced apply-batch invalidation to the
205
- // UI thread so `SyncClientHandle.onInvalidate` fires with worker parity.
206
- started.onInvalidate((event) => {
236
+ started.onChange((batch) => {
207
237
  if (!closed)
208
- post({ t: 'event', event: { kind: 'invalidate', event } });
238
+ post({ t: 'event', event: { kind: 'change', batch } });
209
239
  });
210
240
  await started.start();
211
241
  realtimeConnector =
@@ -215,41 +245,32 @@ export function startSyncWorker(overrides = {}) {
215
245
  ? webSocketRealtimeConnector(config.endpoints.realtimeUrl.replace('{clientId}', encodeURIComponent(started.clientId)))
216
246
  : undefined;
217
247
  client = started;
248
+ // `start()` may discover persisted subscriptions/outbox work. Its callback
249
+ // fires before this worker publishes the initialized client, so consume
250
+ // the durable state once here as well; coalescing makes this a single task.
251
+ if (started.syncNeeded)
252
+ consumeSyncIntent({ kind: 'interactive' });
218
253
  return { clientId: started.clientId };
219
254
  }
220
255
  const api = {
221
256
  subscribe: (input) => requireClient().subscribe(input),
222
257
  unsubscribe: (id) => requireClient().unsubscribe(id),
223
258
  setWindow: async (base, units) => {
224
- await requireClient().setWindow(base, units);
225
- // A window change adds/removes value-sharded subscriptions; a widened
226
- // unit needs a bootstrap pull to become visible. In autoSync mode the
227
- // host loop owns rounds, so schedule one now — otherwise the new unit
228
- // waits for an unrelated server wake-up (§8.4). A no-op change still
229
- // schedules a harmless idempotent round.
230
- scheduleAutoSync();
259
+ const result = await requireClient().setWindowCommand(base, units);
260
+ consumeEffects(result.effects);
231
261
  },
232
262
  windowState: (base) => requireClient().windowState(base),
233
263
  mutate: (mutations) => {
234
- const id = requireClient().mutate(mutations);
235
- // In autoSync mode the host loop owns rounds (§8.4): a local write must
236
- // push without the app orchestrating sync, so schedule a jittered round
237
- // to drain the outbox promptly. `mutate` is synchronous — its outbox
238
- // write has committed before this line — and the round itself serializes
239
- // on SyncClient's operation chain, so no ordering hack is needed here;
240
- // the §8.4 jitter in `scheduleAutoSync` is the only deferral, and it
241
- // coalesces bursts. (Historically this used setTimeout to dodge a
242
- // nested-transaction flake; that race is fixed at the source now — the
243
- // real cause was cross-operation interleaving, serialized in the core.)
244
- scheduleAutoSync();
245
- return id;
264
+ const result = requireClient().mutateCommand(mutations);
265
+ consumeEffects(result.effects);
266
+ return result.value;
246
267
  },
247
268
  patch: (table, rowId, partial, options) => {
248
269
  // Same §8.4 rule as `mutate`: a local write must push without the app
249
- // orchestrating sync, so schedule a jittered round to drain the outbox.
250
- const id = requireClient().patch(table, rowId, partial, options);
251
- scheduleAutoSync();
252
- return id;
270
+ // orchestrating sync, so consume the core's immediate intent.
271
+ const result = requireClient().patchCommand(table, rowId, partial, options);
272
+ consumeEffects(result.effects);
273
+ return result.value;
253
274
  },
254
275
  sync: () => {
255
276
  const running = requireClient();
@@ -260,6 +281,9 @@ export function startSyncWorker(overrides = {}) {
260
281
  return serializedSync(() => running.syncUntilIdle(maxRounds));
261
282
  },
262
283
  query: (sql, params) => requireClient().query(sql, params),
284
+ querySnapshot: (spec) => requireClient().querySnapshot(spec),
285
+ localRevision: () => requireClient().localRevision,
286
+ statusSnapshot: () => requireClient().statusSnapshot(),
263
287
  conflicts: () => requireClient().conflicts,
264
288
  rejections: () => requireClient().rejections,
265
289
  schemaFloor: () => requireClient().schemaFloor,
@@ -282,6 +306,8 @@ export function startSyncWorker(overrides = {}) {
282
306
  },
283
307
  close: async () => {
284
308
  closed = true;
309
+ if (backgroundTimer !== undefined)
310
+ clearTimeout(backgroundTimer);
285
311
  await client?.close();
286
312
  database?.close();
287
313
  client = undefined;
@@ -23,9 +23,9 @@
23
23
  */
24
24
  import type { WakeReason } from '@syncular/core';
25
25
  import type { BlobRef, CachedBlob } from './blob.js';
26
- import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
26
+ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
27
27
  import type { SqlRow, SqlValue } from './database.js';
28
- import { InvalidationEmitter, type InvalidationListener } from './invalidation.js';
28
+ import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
29
29
  import { type LeaderLease, type LeaderLock } from './leader-lock.js';
30
30
  import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
31
31
  import type { OutboxCommit } from './outbox.js';
@@ -49,7 +49,6 @@ export interface SyncClientHandleConfig {
49
49
  readonly limits?: SyncClientLimits;
50
50
  /** Worker-side host loop (§8.4); default true. */
51
51
  readonly autoSync?: boolean;
52
- readonly wakeJitterMs?: number;
53
52
  /** Default: Web Locks when available, else single-owner. */
54
53
  readonly leaderLock?: LeaderLock;
55
54
  readonly lockName?: string;
@@ -67,7 +66,7 @@ export interface SyncClientHandleConfig {
67
66
  readonly followerCallTimeoutMs?: number;
68
67
  /** Fires when this handle's role changes (follower → leader on promotion). */
69
68
  readonly onRoleChange?: (role: HandleRole) => void;
70
- readonly onSyncNeeded?: (reason: 'hello' | WakeReason) => void;
69
+ readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
71
70
  readonly onConflict?: (conflict: ConflictRecord) => void;
72
71
  /** A worker-side autoSync round finished (or failed). */
73
72
  readonly onSynced?: (result: {
@@ -110,6 +109,7 @@ export declare class SyncClientHandle {
110
109
  core?: LeaderCore;
111
110
  follower?: FollowerLink;
112
111
  invalidation: InvalidationEmitter;
112
+ changes: ChangeEmitter;
113
113
  presence: Set<(scopeKey: string) => void>;
114
114
  roleListeners?: Set<(role: HandleRole) => void>;
115
115
  });
@@ -124,6 +124,7 @@ export declare class SyncClientHandle {
124
124
  * unsubscribe function.
125
125
  */
126
126
  onInvalidate(listener: InvalidationListener): () => void;
127
+ onChange(listener: ClientChangeListener): () => void;
127
128
  /**
128
129
  * §8.6: subscribe to presence changes — the identical surface as
129
130
  * `SyncClient.onPresence`. Returns an unsubscribe function.
@@ -143,6 +144,9 @@ export declare class SyncClientHandle {
143
144
  sync(): Promise<SyncSummary>;
144
145
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
145
146
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
147
+ querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
148
+ localRevision(): Promise<LocalRevision>;
149
+ statusSnapshot(): Promise<SyncStatusSnapshot>;
146
150
  conflicts(): Promise<readonly ConflictRecord[]>;
147
151
  rejections(): Promise<readonly RejectionRecord[]>;
148
152
  schemaFloor(): Promise<SchemaFloor | undefined>;
@@ -1,6 +1,6 @@
1
1
  import { registerDevtools } from './devtools.js';
2
2
  import { ClientSyncError } from './errors.js';
3
- import { InvalidationEmitter } from './invalidation.js';
3
+ import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
4
4
  import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
5
5
  import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
6
6
  import { NOT_LEADER_CODE, WORKER_FAILED_CODE, } from './worker-protocol.js';
@@ -32,6 +32,7 @@ export class SyncClientHandle {
32
32
  #core;
33
33
  #follower;
34
34
  #invalidation;
35
+ #changes;
35
36
  #presence;
36
37
  #roleListeners;
37
38
  #devtoolsUnregister;
@@ -43,6 +44,7 @@ export class SyncClientHandle {
43
44
  this.#core = internals.core;
44
45
  this.#follower = internals.follower;
45
46
  this.#invalidation = internals.invalidation;
47
+ this.#changes = internals.changes;
46
48
  this.#presence = internals.presence;
47
49
  this.#roleListeners = internals.roleListeners ?? new Set();
48
50
  // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
@@ -88,8 +90,11 @@ export class SyncClientHandle {
88
90
  }
89
91
  }
90
92
  }
91
- else if (event.kind === 'invalidate') {
92
- this.#invalidation.emit(event.event);
93
+ else if (event.kind === 'change') {
94
+ this.#changes.emit(event.batch);
95
+ const legacy = invalidationFromChange(event.batch);
96
+ if (legacy !== undefined)
97
+ this.#invalidation.emit(legacy);
93
98
  }
94
99
  }
95
100
  /**
@@ -101,6 +106,9 @@ export class SyncClientHandle {
101
106
  onInvalidate(listener) {
102
107
  return this.#invalidation.on(listener);
103
108
  }
109
+ onChange(listener) {
110
+ return this.#changes.on(listener);
111
+ }
104
112
  /**
105
113
  * §8.6: subscribe to presence changes — the identical surface as
106
114
  * `SyncClient.onPresence`. Returns an unsubscribe function.
@@ -163,6 +171,15 @@ export class SyncClientHandle {
163
171
  query(sql, params) {
164
172
  return this.#call('query', [sql, params]);
165
173
  }
174
+ querySnapshot(spec) {
175
+ return this.#call('querySnapshot', [spec]);
176
+ }
177
+ localRevision() {
178
+ return this.#call('localRevision', []);
179
+ }
180
+ statusSnapshot() {
181
+ return this.#call('statusSnapshot', []);
182
+ }
166
183
  conflicts() {
167
184
  return this.#call('conflicts', []);
168
185
  }
@@ -344,9 +361,6 @@ function buildInitConfig(config) {
344
361
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
345
362
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
346
363
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
347
- ...(config.wakeJitterMs !== undefined
348
- ? { wakeJitterMs: config.wakeJitterMs }
349
- : {}),
350
364
  };
351
365
  }
352
366
  /** Route a worker event to the config-level callbacks (leader visibility). */
@@ -381,6 +395,7 @@ export async function createSyncClientHandle(config) {
381
395
  const lock = config.leaderLock ?? defaultLeaderLock();
382
396
  const lockName = config.lockName ?? 'syncular-leader';
383
397
  const invalidation = new InvalidationEmitter();
398
+ const changes = new ChangeEmitter();
384
399
  const presence = new Set();
385
400
  const roleListeners = new Set();
386
401
  if (config.onRoleChange !== undefined)
@@ -398,6 +413,7 @@ export async function createSyncClientHandle(config) {
398
413
  return await bootLeader(config, lockName, lease, {
399
414
  epoch: 0,
400
415
  invalidation,
416
+ changes,
401
417
  presence,
402
418
  roleListeners,
403
419
  });
@@ -409,6 +425,7 @@ export async function createSyncClientHandle(config) {
409
425
  role: 'follower',
410
426
  clientId: '',
411
427
  invalidation,
428
+ changes,
412
429
  presence,
413
430
  roleListeners,
414
431
  });
@@ -416,6 +433,7 @@ export async function createSyncClientHandle(config) {
416
433
  // ---- Follower: proxy to the leader; contest + promote on its close. ----
417
434
  return await bootFollower(config, lockName, lock, {
418
435
  invalidation,
436
+ changes,
419
437
  presence,
420
438
  roleListeners,
421
439
  });
@@ -453,6 +471,7 @@ async function bootLeader(config, lockName, lease, parts) {
453
471
  clientId: core.clientId,
454
472
  core,
455
473
  invalidation: parts.invalidation,
474
+ changes: parts.changes,
456
475
  presence: parts.presence,
457
476
  roleListeners: parts.roleListeners,
458
477
  });
@@ -536,6 +555,7 @@ async function bootFollower(config, lockName, lock, parts) {
536
555
  clientId: '',
537
556
  follower,
538
557
  invalidation: parts.invalidation,
558
+ changes: parts.changes,
539
559
  presence: parts.presence,
540
560
  roleListeners: parts.roleListeners,
541
561
  });
@@ -12,15 +12,15 @@
12
12
  *
13
13
  * Scheduling note (SPEC §8.4): the sync-needed signal is host-driven,
14
14
  * and in worker mode the HOST LOOP LIVES IN THE WORKER — wake-ups
15
- * coalesce into jittered `syncUntilIdle` rounds there (`autoSync`),
15
+ * coalesce into intent-driven `syncUntilIdle` rounds there (`autoSync`),
16
16
  * so the UI thread never has to react to keep data flowing. Events are
17
17
  * still forwarded to the handle for visibility.
18
18
  */
19
19
  import type { WakeReason } from '@syncular/core';
20
20
  import type { BlobRef, CachedBlob } from './blob.js';
21
- import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
21
+ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
22
22
  import type { SqlRow, SqlValue } from './database.js';
23
- import type { InvalidationEvent } from './invalidation.js';
23
+ import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
24
24
  import type { OutboxCommit } from './outbox.js';
25
25
  import type { ClientSchema } from './schema.js';
26
26
  import type { SubscriptionRecord } from './state.js';
@@ -63,12 +63,11 @@ export interface WorkerInitConfig {
63
63
  readonly clientId?: string;
64
64
  readonly limits?: SyncClientLimits;
65
65
  /**
66
- * Worker-side host loop (§8.4): coalesce wake-ups into jittered
66
+ * Worker-side host loop (§8.4): coalesce interactive work immediately and
67
+ * honor explicit background retry deadlines
67
68
  * `syncUntilIdle` rounds inside the worker. Default true.
68
69
  */
69
70
  readonly autoSync?: boolean;
70
- /** Max jitter before a coalesced auto-sync round (default 200 ms). */
71
- readonly wakeJitterMs?: number;
72
71
  }
73
72
  /** Successful init reply. */
74
73
  export interface WorkerInitResult {
@@ -89,6 +88,9 @@ export interface WorkerApi {
89
88
  sync(): Promise<SyncSummary>;
90
89
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
91
90
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
91
+ querySnapshot(spec: QueryReadSpec): QuerySnapshot;
92
+ localRevision(): LocalRevision;
93
+ statusSnapshot(): SyncStatusSnapshot;
92
94
  conflicts(): readonly ConflictRecord[];
93
95
  rejections(): readonly RejectionRecord[];
94
96
  schemaFloor(): SchemaFloor | undefined;
@@ -138,7 +140,7 @@ export interface WorkerErrorShape {
138
140
  }
139
141
  export type SyncWorkerEvent = {
140
142
  readonly kind: 'sync-needed';
141
- readonly reason: 'hello' | WakeReason;
143
+ readonly reason: 'startup' | 'hello' | WakeReason;
142
144
  } | {
143
145
  readonly kind: 'conflict';
144
146
  readonly conflict: ConflictRecord;
@@ -156,13 +158,9 @@ export type SyncWorkerEvent = {
156
158
  readonly kind: 'presence';
157
159
  readonly scopeKey: string;
158
160
  } | {
159
- /**
160
- * TODO 3.1 / I1: one coalesced apply-batch invalidation. `Set`s
161
- * structured-clone across the worker boundary, so the event crosses
162
- * verbatim — the handle re-emits it to `onInvalidate` listeners.
163
- */
164
- readonly kind: 'invalidate';
165
- readonly event: InvalidationEvent;
161
+ /** Exact revisioned core transaction; Sets and bigint clone directly. */
162
+ readonly kind: 'change';
163
+ readonly batch: ClientChangeBatch;
166
164
  };
167
165
  export type WorkerToMainMessage = {
168
166
  readonly t: 'ready';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.3.1"
84
+ "@syncular/core": "0.5.0"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.3.1",
95
+ "@syncular/server": "0.5.0",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/apply.ts CHANGED
@@ -22,6 +22,9 @@ import {
22
22
  toSqlValue,
23
23
  } from './schema';
24
24
 
25
+ /** Lets the client wrap the physical write in its revision transaction. */
26
+ export type ApplyTransaction = <T>(fn: () => T) => T;
27
+
25
28
  function upsertSql(table: CompiledClientTable): string {
26
29
  const names = [
27
30
  ...table.columns.map((column) => quoteIdent(column.name)),
@@ -68,6 +71,7 @@ export async function applyCommitFrame(
68
71
  schema: CompiledClientSchema,
69
72
  frame: CommitFrame,
70
73
  encryption?: EncryptionConfig,
74
+ transaction: ApplyTransaction = (fn) => db.transaction(fn),
71
75
  ): Promise<void> {
72
76
  type Resolved =
73
77
  | { op: 'delete'; table: CompiledClientTable; rowId: string }
@@ -115,7 +119,7 @@ export async function applyCommitFrame(
115
119
  rowVersion: change.rowVersion,
116
120
  });
117
121
  }
118
- db.transaction(() => {
122
+ transaction(() => {
119
123
  for (const change of resolved) {
120
124
  if (change.op === 'delete') {
121
125
  deleteLocalRow(db, change.table, change.rowId);
@@ -290,7 +294,11 @@ export function applySqliteSegment(
290
294
  table: CompiledClientTable,
291
295
  bytes: Uint8Array,
292
296
  descriptor: SqliteSegmentDescriptor,
293
- options: { readonly clearFirst: boolean; readonly effective: ScopeMap },
297
+ options: {
298
+ readonly clearFirst: boolean;
299
+ readonly effective: ScopeMap;
300
+ readonly transaction?: ApplyTransaction;
301
+ },
294
302
  ): number {
295
303
  const withImage = db.withSqliteImage?.bind(db);
296
304
  if (withImage === undefined) {
@@ -359,7 +367,7 @@ export function applySqliteSegment(
359
367
 
360
368
  // 3. One transaction: fresh-bootstrap clear, then replace-or-upsert.
361
369
  const names = table.columns.map((column) => quoteIdent(column.name));
362
- return db.transaction(() => {
370
+ return (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
363
371
  if (options.clearFirst) {
364
372
  deleteScopedRows(db, table, options.effective);
365
373
  }
@@ -396,7 +404,11 @@ export async function applyRowsSegment(
396
404
  schema: CompiledClientSchema,
397
405
  table: CompiledClientTable,
398
406
  segment: RowsSegment,
399
- options: { readonly clearFirst: boolean; readonly effective: ScopeMap },
407
+ options: {
408
+ readonly clearFirst: boolean;
409
+ readonly effective: ScopeMap;
410
+ readonly transaction?: ApplyTransaction;
411
+ },
400
412
  encryption?: EncryptionConfig,
401
413
  ): Promise<number> {
402
414
  validateSegmentColumns(schema, table, segment);
@@ -421,7 +433,8 @@ export async function applyRowsSegment(
421
433
  }
422
434
  const clearThisBlock = first && options.clearFirst;
423
435
  first = false;
424
- db.transaction(() => {
436
+ if (!clearThisBlock && rows.length === 0) continue;
437
+ (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
425
438
  if (clearThisBlock) {
426
439
  deleteScopedRows(db, table, options.effective);
427
440
  }