@syncular/client 0.4.1 → 0.5.1

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.
package/dist/client.js CHANGED
@@ -12,13 +12,13 @@ import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow,
12
12
  import { clearPendingUpload, computeBlobId, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, reconcileBlobRefcounts, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
13
13
  import { registerDevtools } from './devtools.js';
14
14
  import { ClientSyncError } from './errors.js';
15
- import { Invalidation, InvalidationEmitter, } from './invalidation.js';
15
+ import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
16
16
  import { singleOwnerLock, } from './leader-lock.js';
17
17
  import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, OutboxEncodeError, } from './outbox.js';
18
18
  import { assertReadOnlyQuery } from './query-guard.js';
19
19
  import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
20
- import { deleteSubscription, getMeta, getSubscription, loadSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
21
- import { deletePendingEviction, deleteWindowUnit, deriveSubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
20
+ import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
21
+ import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
22
22
  /**
23
23
  * True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
24
24
  * registered and not pending. A unit with zero server rows still becomes
@@ -93,10 +93,14 @@ export class SyncClient {
93
93
  * (the fast-bail the delta path reads).
94
94
  */
95
95
  #syncOutstanding = false;
96
+ /** Retry policy belongs to the operation that classified the failure. */
97
+ #retryDelayMs = 250;
96
98
  #hasBlobs;
97
99
  /** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
98
100
  #presence = new Map();
99
- /** TODO 3.1 / I1: the ONE apply-path invalidation listener set. */
101
+ /** SPEC §7.5: exact core-originated observer transaction batches. */
102
+ #changes = new ChangeEmitter();
103
+ /** Compatibility projection from exact batches; never bridge-inferred. */
100
104
  #invalidation = new InvalidationEmitter();
101
105
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
102
106
  #presenceListeners = new Set();
@@ -136,8 +140,15 @@ export class SyncClient {
136
140
  if (this.#hasBlobs)
137
141
  ensureBlobSchema(this.#db);
138
142
  const persisted = getMeta(this.#db, 'clientId');
139
- this.#clientId = this.#config.clientId ?? persisted ?? crypto.randomUUID();
140
- if (persisted !== this.#clientId) {
143
+ if (persisted !== undefined &&
144
+ this.#config.clientId !== undefined &&
145
+ persisted !== this.#config.clientId) {
146
+ await this.#lease.release();
147
+ this.#lease = undefined;
148
+ throw new ClientSyncError('client.identity_mismatch', `this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`);
149
+ }
150
+ this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
151
+ if (persisted === undefined) {
141
152
  setMeta(this.#db, 'clientId', this.#clientId);
142
153
  }
143
154
  // §7.3.5: restore the persisted lease so leaseState survives restart.
@@ -151,6 +162,20 @@ export class SyncClient {
151
162
  // already at the generated version.
152
163
  this.#detectAndResetSchema();
153
164
  this.#started = true;
165
+ // A persisted active subscription needs one catch-up round on every open:
166
+ // realtime only covers changes after the socket connects, and an
167
+ // idempotent setWindow/subscribe call correctly creates no new command
168
+ // effect. Pending outbox work has the same restart requirement. Surface
169
+ // this as an exact core-owned intent so hosts never need a startup poll or
170
+ // an application-issued sync() call.
171
+ const startupWork = this.#schemaFloor === undefined &&
172
+ (listOutbox(this.#db).length > 0 ||
173
+ loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
174
+ if (startupWork) {
175
+ this.#needsPull = true;
176
+ this.#config.onSyncNeeded?.('startup');
177
+ this.#config.onSyncIntent?.({ kind: 'interactive' });
178
+ }
154
179
  // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
155
180
  this.#devtoolsUnregister = registerDevtools({
156
181
  kind: 'client',
@@ -205,15 +230,35 @@ export class SyncClient {
205
230
  // The stop state is over: this client now ships a servable schema. The
206
231
  // outbox is re-applied optimistically over the (now empty) tables so
207
232
  // pending offline writes stay visible across the bump (§7.4.5).
208
- this.#schemaFloor = undefined;
233
+ this.#setSchemaFloor(undefined);
209
234
  this.#replayOutbox();
210
235
  }
211
236
  #setUpgrading(upgrading) {
212
237
  if (this.#upgrading === upgrading)
213
238
  return;
214
- this.#upgrading = upgrading;
239
+ this.#applyBatch((batch) => {
240
+ this.#upgrading = upgrading;
241
+ batch.status();
242
+ });
215
243
  this.#config.onUpgrading?.(upgrading);
216
244
  }
245
+ #setSyncNeeded(syncNeeded) {
246
+ if (this.#needsPull === syncNeeded)
247
+ return;
248
+ this.#applyBatch((batch) => {
249
+ this.#needsPull = syncNeeded;
250
+ batch.status();
251
+ });
252
+ }
253
+ #setSchemaFloor(schemaFloor) {
254
+ const current = JSON.stringify(this.#schemaFloor);
255
+ if (current === JSON.stringify(schemaFloor))
256
+ return;
257
+ this.#applyBatch((batch) => {
258
+ this.#schemaFloor = schemaFloor;
259
+ batch.status();
260
+ });
261
+ }
217
262
  async close() {
218
263
  this.#devtoolsUnregister?.();
219
264
  this.#devtoolsUnregister = undefined;
@@ -244,6 +289,57 @@ export class SyncClient {
244
289
  assertReadOnlyQuery(sql);
245
290
  return stripSyncColumns(this.#db.query(sql, params));
246
291
  }
292
+ /** Current durable local observer revision (SPEC §7.5). */
293
+ get localRevision() {
294
+ this.#requireStarted();
295
+ return getLocalRevision(this.#db);
296
+ }
297
+ /**
298
+ * Read rows, window answerability, and revision from one SQLite snapshot.
299
+ * Reactive integrations use this instead of composing `query()` and
300
+ * `windowState()` across separate worker/IPC calls.
301
+ */
302
+ querySnapshot(spec) {
303
+ this.#requireStarted();
304
+ assertReadOnlyQuery(spec.sql);
305
+ return this.#db.transaction(() => {
306
+ const revision = getLocalRevision(this.#db);
307
+ const rows = stripSyncColumns(this.#db.query(spec.sql, spec.params));
308
+ const pending = [];
309
+ const missing = [];
310
+ for (const requested of spec.coverage ?? []) {
311
+ const baseKey = windowBaseKey(requested.base);
312
+ const live = new Map(loadWindowUnits(this.#db, baseKey).map((entry) => [
313
+ entry.unit,
314
+ entry.subId,
315
+ ]));
316
+ for (const unit of new Set(requested.units)) {
317
+ const subId = live.get(unit);
318
+ const ref = { baseKey, unit };
319
+ if (subId === undefined) {
320
+ missing.push(ref);
321
+ continue;
322
+ }
323
+ const sub = getSubscription(this.#db, subId);
324
+ if (sub === undefined ||
325
+ sub.status !== 'active' ||
326
+ sub.cursor < 0 ||
327
+ sub.bootstrapState !== undefined) {
328
+ pending.push(ref);
329
+ }
330
+ }
331
+ }
332
+ return {
333
+ revision,
334
+ rows,
335
+ coverage: {
336
+ complete: pending.length === 0 && missing.length === 0,
337
+ pending,
338
+ missing,
339
+ },
340
+ };
341
+ });
342
+ }
247
343
  // -- live-query invalidation (TODO 3.1 / DESIGN-eviction I1–I4) -----------
248
344
  /**
249
345
  * Subscribe to fine-grained invalidation. The callback fires ONCE per
@@ -259,6 +355,24 @@ export class SyncClient {
259
355
  onInvalidate(listener) {
260
356
  return this.#invalidation.on(listener);
261
357
  }
358
+ /** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
359
+ onChange(listener) {
360
+ return this.#changes.on(listener);
361
+ }
362
+ /** One call for the complete status domain used by reactive hosts. */
363
+ statusSnapshot() {
364
+ this.#requireStarted();
365
+ return this.#statusSnapshot();
366
+ }
367
+ #statusSnapshot() {
368
+ return {
369
+ outbox: listOutbox(this.#db).length,
370
+ upgrading: this.#upgrading,
371
+ leaseState: this.#leaseState,
372
+ schemaFloor: this.#schemaFloor,
373
+ syncNeeded: this.#needsPull,
374
+ };
375
+ }
262
376
  /**
263
377
  * Run `fn` as one apply batch: install a fresh accumulator, collect every
264
378
  * touched key, then emit exactly one coalesced event if anything changed.
@@ -268,17 +382,38 @@ export class SyncClient {
268
382
  #applyBatch(fn) {
269
383
  if (this.#batch !== undefined)
270
384
  return fn(this.#batch);
271
- const batch = new Invalidation();
272
- this.#batch = batch;
385
+ const batch = new ChangeAccumulator();
386
+ let revision;
387
+ let status;
388
+ let result;
273
389
  try {
274
- return fn(batch);
390
+ this.#db.transaction(() => {
391
+ this.#batch = batch;
392
+ try {
393
+ result = fn(batch);
394
+ if (batch.touched) {
395
+ revision = bumpLocalRevision(this.#db);
396
+ if (batch.statusChanged)
397
+ status = this.#statusSnapshot();
398
+ }
399
+ }
400
+ finally {
401
+ this.#batch = undefined;
402
+ }
403
+ });
275
404
  }
276
- finally {
405
+ catch (error) {
277
406
  this.#batch = undefined;
278
- const event = batch.finish();
279
- if (event !== undefined)
280
- this.#invalidation.emit(event);
407
+ throw error;
408
+ }
409
+ if (revision !== undefined) {
410
+ const event = batch.finish(revision, status);
411
+ this.#changes.emit(event);
412
+ const legacy = invalidationFromChange(event);
413
+ if (legacy !== undefined)
414
+ this.#invalidation.emit(legacy);
281
415
  }
416
+ return result;
282
417
  }
283
418
  /**
284
419
  * Run `fn` as the next link in the operation-serialization chain
@@ -295,22 +430,6 @@ export class SyncClient {
295
430
  this.#opChain = next.then(() => undefined, () => undefined);
296
431
  return next;
297
432
  }
298
- /** Async twin of {@link #applyBatch} for the pull/delta apply round. */
299
- async #applyBatchAsync(fn) {
300
- if (this.#batch !== undefined)
301
- return fn(this.#batch);
302
- const batch = new Invalidation();
303
- this.#batch = batch;
304
- try {
305
- return await fn(batch);
306
- }
307
- finally {
308
- this.#batch = undefined;
309
- const event = batch.finish();
310
- if (event !== undefined)
311
- this.#invalidation.emit(event);
312
- }
313
- }
314
433
  // -- blobs (§5.9) ---------------------------------------------------------
315
434
  /**
316
435
  * Stage a blob for attachment (§5.9.7): hash the bytes into the content
@@ -607,6 +726,10 @@ export class SyncClient {
607
726
  * re-registers realtime at round end (§8.7). No socket cycle needed.
608
727
  */
609
728
  async setWindow(base, units) {
729
+ await this.setWindowCommand(base, units);
730
+ }
731
+ /** Exact core command result consumed by automatic host loops (§7.5). */
732
+ async setWindowCommand(base, units) {
610
733
  this.#requireStarted();
611
734
  const table = this.#table(base.table);
612
735
  if (!table.scopeColumnByVariable.has(base.variable)) {
@@ -615,6 +738,8 @@ export class SyncClient {
615
738
  // Serialize the whole window edit: it spans an `await deriveSubId` between
616
739
  // db transactions, so without the chain a delta apply (or a concurrent
617
740
  // setWindow) could interleave its transactions and corrupt the registry.
741
+ let changed = false;
742
+ let widened = false;
618
743
  await this.#serialize(async () => {
619
744
  const baseKey = windowBaseKey(base);
620
745
  const wanted = new Set(units);
@@ -625,27 +750,37 @@ export class SyncClient {
625
750
  if (liveByUnit.has(unit))
626
751
  continue;
627
752
  const subId = await deriveSubId(base, unit);
628
- this.#db.transaction(() => {
629
- // Re-entry cancels any deferred eviction for this sub id.
630
- deletePendingEviction(this.#db, subId);
631
- insertWindowUnit(this.#db, baseKey, unit, subId);
632
- saveSubscription(this.#db, {
633
- id: subId,
634
- table: base.table,
635
- scopes: unitScopes(base, unit),
636
- ...(base.params !== undefined ? { params: base.params } : {}),
637
- cursor: -1,
638
- status: 'active',
753
+ this.#applyBatch((batch) => {
754
+ this.#db.transaction(() => {
755
+ // Re-entry cancels any deferred eviction for this sub id.
756
+ deletePendingEviction(this.#db, subId);
757
+ insertWindowUnit(this.#db, baseKey, unit, subId);
758
+ saveSubscription(this.#db, {
759
+ id: subId,
760
+ table: base.table,
761
+ scopes: unitScopes(base, unit),
762
+ ...(base.params !== undefined ? { params: base.params } : {}),
763
+ cursor: -1,
764
+ status: 'active',
765
+ });
639
766
  });
767
+ batch.window(baseKey, base.table, unit);
640
768
  });
769
+ changed = true;
770
+ widened = true;
641
771
  }
642
772
  // Shrink: units live but not wanted → unsubscribe fused with eviction.
643
773
  for (const { unit, subId } of live) {
644
774
  if (wanted.has(unit))
645
775
  continue;
646
776
  this.#evictUnit(baseKey, base, unit, subId);
777
+ changed = true;
647
778
  }
648
779
  });
780
+ const effects = {
781
+ sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
782
+ };
783
+ return { value: undefined, effects };
649
784
  }
650
785
  /**
651
786
  * The completeness oracle (§4.8 I3): which units of a base are windowed-in
@@ -665,6 +800,7 @@ export class SyncClient {
665
800
  for (const { unit, subId } of live) {
666
801
  const sub = getSubscription(this.#db, subId);
667
802
  if (sub === undefined ||
803
+ sub.status !== 'active' ||
668
804
  sub.cursor < 0 ||
669
805
  sub.bootstrapState !== undefined) {
670
806
  pending.push(unit);
@@ -701,9 +837,8 @@ export class SyncClient {
701
837
  deletePendingEviction(this.#db, subId);
702
838
  }
703
839
  });
704
- // I1: eviction is a bulk delete — a query over the evicted unit re-runs.
705
- batch.table(table.name);
706
840
  batch.scopeMap(table, effective);
841
+ batch.window(baseKey, table.name, unit);
707
842
  });
708
843
  }
709
844
  /**
@@ -729,7 +864,6 @@ export class SyncClient {
729
864
  if (!deferred)
730
865
  deletePendingEviction(this.#db, entry.subId);
731
866
  });
732
- batch.table(table.name);
733
867
  batch.scopeMap(table, entry.effective);
734
868
  });
735
869
  }
@@ -792,10 +926,18 @@ export class SyncClient {
792
926
  this.#db.transaction(() => {
793
927
  appendOutboxCommit(this.#db, clientCommitId, operations, this.#now());
794
928
  this.#applyOperationsLocally(operations, batch);
929
+ batch.status();
795
930
  });
796
931
  });
797
932
  return clientCommitId;
798
933
  }
934
+ /** Host-facing mutation result with explicit network work intent (§7.5). */
935
+ mutateCommand(mutations) {
936
+ return {
937
+ value: this.mutate(mutations),
938
+ effects: { sync: { kind: 'interactive' } },
939
+ };
940
+ }
799
941
  /**
800
942
  * Partial-update convenience over the §6.1 full-row wire: read the
801
943
  * current LOCAL row, merge `partial` over it, and record one full-row
@@ -831,11 +973,21 @@ export class SyncClient {
831
973
  },
832
974
  ]);
833
975
  }
976
+ /** Host-facing patch result with explicit network work intent (§7.5). */
977
+ patchCommand(table, rowId, partial, options) {
978
+ return {
979
+ value: this.patch(table, rowId, partial, options),
980
+ effects: { sync: { kind: 'interactive' } },
981
+ };
982
+ }
834
983
  // -- lease state (§7.3.5) ---------------------------------------------------
835
984
  /** Merge and persist the lease state (opaque, §7.3.5). */
836
985
  #setLeaseState(next) {
837
- this.#leaseState = next;
838
- setMeta(this.#db, 'leaseState', JSON.stringify(next));
986
+ this.#applyBatch((batch) => {
987
+ this.#leaseState = next;
988
+ setMeta(this.#db, 'leaseState', JSON.stringify(next));
989
+ batch.status();
990
+ });
839
991
  }
840
992
  /** The request-level lease error codes (§7.3.4): stop-and-surface. */
841
993
  #isLeaseErrorCode(code) {
@@ -889,7 +1041,7 @@ export class SyncClient {
889
1041
  * purely-optimistic rows are undone, and a rejection record is raised.
890
1042
  */
891
1043
  #dropIncompatibleCommit(commit, message) {
892
- this.#db.transaction(() => {
1044
+ this.#applyBatch((batch) => {
893
1045
  deleteOutboxCommit(this.#db, commit.clientCommitId);
894
1046
  for (const operation of commit.operations) {
895
1047
  if (operation.op !== 'upsert')
@@ -899,19 +1051,24 @@ export class SyncClient {
899
1051
  continue;
900
1052
  const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
901
1053
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
1054
+ if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
1055
+ batch.table(table.name);
1056
+ }
902
1057
  deleteLocalRow(this.#db, table, operation.rowId);
903
1058
  }
904
1059
  }
905
- });
906
- this.#rejections.push({
907
- clientCommitId: commit.clientCommitId,
908
- opIndex: 0,
909
- code: OUTBOX_INCOMPATIBLE_CODE,
910
- message,
911
- retryable: false,
912
- ...(commit.operations[0] !== undefined
913
- ? { operation: commit.operations[0] }
914
- : {}),
1060
+ this.#rejections.push({
1061
+ clientCommitId: commit.clientCommitId,
1062
+ opIndex: 0,
1063
+ code: OUTBOX_INCOMPATIBLE_CODE,
1064
+ message,
1065
+ retryable: false,
1066
+ ...(commit.operations[0] !== undefined
1067
+ ? { operation: commit.operations[0] }
1068
+ : {}),
1069
+ });
1070
+ batch.status();
1071
+ batch.rejections();
915
1072
  });
916
1073
  }
917
1074
  // -- sync -------------------------------------------------------------------
@@ -946,7 +1103,7 @@ export class SyncClient {
946
1103
  // Cleared before the round, not after: a wake-up (or a delta dropped
947
1104
  // because this pull is mid-flight) that lands during the round must
948
1105
  // survive it — the reference server keeps no replay buffer (§8.2).
949
- this.#needsPull = false;
1106
+ this.#setSyncNeeded(false);
950
1107
  try {
951
1108
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
952
1109
  // them, so the server-side existence check (§6.6) passes.
@@ -1001,10 +1158,11 @@ export class SyncClient {
1001
1158
  // §4.8 E1: the push half may have drained commits that pinned rows of
1002
1159
  // a shrunk window unit — retry any deferred evictions now.
1003
1160
  this.#drainPendingEvictions();
1161
+ this.#retryDelayMs = 250;
1004
1162
  if (deferred > 0) {
1005
1163
  // §6.1 splitBatch remainder: more queued commits than this request
1006
1164
  // could carry — keep the sync-needed signal raised for the host.
1007
- this.#needsPull = true;
1165
+ this.#setSyncNeeded(true);
1008
1166
  return { ...summary, deferredCommits: deferred };
1009
1167
  }
1010
1168
  return summary;
@@ -1022,6 +1180,22 @@ export class SyncClient {
1022
1180
  errorCode: code,
1023
1181
  });
1024
1182
  }
1183
+ const explicitlyRetryable = error.retryable;
1184
+ const retryable = explicitlyRetryable === true ||
1185
+ (explicitlyRetryable === undefined && typeof code !== 'string');
1186
+ if (retryable) {
1187
+ const intent = {
1188
+ kind: 'background',
1189
+ delayMs: this.#retryDelayMs,
1190
+ };
1191
+ this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
1192
+ try {
1193
+ this.#config.onSyncIntent?.(intent);
1194
+ }
1195
+ catch {
1196
+ // An observer cannot alter sync correctness.
1197
+ }
1198
+ }
1025
1199
  throw error;
1026
1200
  }
1027
1201
  finally {
@@ -1160,14 +1334,14 @@ export class SyncClient {
1160
1334
  const event = parsed.event;
1161
1335
  if (event.event === 'hello') {
1162
1336
  if (event.data.requiresSync) {
1163
- this.#needsPull = true;
1337
+ this.#setSyncNeeded(true);
1164
1338
  this.#config.onSyncNeeded?.('hello');
1165
1339
  }
1166
1340
  return;
1167
1341
  }
1168
1342
  if (event.event === 'sync') {
1169
1343
  // §8.3: any wake-up means "run a pull soon", never data.
1170
- this.#needsPull = true;
1344
+ this.#setSyncNeeded(true);
1171
1345
  this.#config.onSyncNeeded?.(event.data.reason);
1172
1346
  return;
1173
1347
  }
@@ -1221,7 +1395,7 @@ export class SyncClient {
1221
1395
  // worth it. (An optimization; the op chain below is the correctness
1222
1396
  // mechanism — it also excludes a delta from racing a `setWindow` or a
1223
1397
  // sync round that started between this check and the apply.)
1224
- this.#needsPull = true;
1398
+ this.#setSyncNeeded(true);
1225
1399
  return;
1226
1400
  }
1227
1401
  // Serialize the apply on the operation chain: a delta must never
@@ -1237,7 +1411,7 @@ export class SyncClient {
1237
1411
  }
1238
1412
  catch {
1239
1413
  // A delta that cannot be applied is recovered by a pull (§8.3).
1240
- this.#needsPull = true;
1414
+ this.#setSyncNeeded(true);
1241
1415
  this.#config.onSyncNeeded?.('catchup-required');
1242
1416
  }
1243
1417
  });
@@ -1274,140 +1448,166 @@ export class SyncClient {
1274
1448
  // version this client sends. The §7.4.2 trigger-2 convergence runs
1275
1449
  // when the APP updates (recreating the client with a new generated
1276
1450
  // schema), which fires the boot-time §7.4.1 marker check instead.
1277
- this.#schemaFloor = {
1451
+ const schemaFloor = {
1278
1452
  requiredSchemaVersion: header.requiredSchemaVersion,
1279
1453
  ...(header.latestSchemaVersion !== undefined
1280
1454
  ? { latestSchemaVersion: header.latestSchemaVersion }
1281
1455
  : {}),
1282
1456
  };
1457
+ this.#setSchemaFloor(schemaFloor);
1283
1458
  return {
1284
1459
  ...summary,
1285
1460
  bootstrapping: [],
1286
- schemaFloor: this.#schemaFloor,
1461
+ schemaFloor,
1287
1462
  };
1288
1463
  }
1289
1464
  let section;
1290
1465
  let errorFrame;
1291
1466
  let deltaCursor = -1;
1292
- // One apply batch per pull/delta round (I1): COMMIT + segment applies,
1293
- // the revocation purge, and the optimistic replay all coalesce into a
1294
- // single invalidation event emitted when this batch unwinds.
1295
- await this.#applyBatchAsync(async () => {
1296
- try {
1297
- for (const frame of message.frames.slice(1)) {
1298
- switch (frame.type) {
1299
- case 'RESP_HEADER':
1300
- break;
1301
- case 'LEASE':
1302
- // §7.3.5: persist the opaque lease and clear any prior lease
1303
- // error — a fresh lease means the outage/revocation is over.
1304
- this.#setLeaseState({
1305
- leaseId: frame.leaseId,
1306
- expiresAtMs: frame.expiresAtMs,
1307
- });
1308
- break;
1309
- case 'PUSH_RESULT':
1310
- this.#handlePushResult(frame, commitsById, summary);
1311
- break;
1312
- case 'SUB_START': {
1313
- const sub = subsById.get(frame.id);
1314
- const fresh = sub !== undefined &&
1315
- sub.cursor < 0 &&
1316
- sub.bootstrapState === undefined &&
1317
- frame.bootstrap;
1318
- const skip = sub === undefined ||
1319
- (mode === 'delta' &&
1320
- (sub.status !== 'active' ||
1321
- sub.bootstrapState !== undefined));
1322
- section = { start: frame, sub, fresh, skip, cleared: false };
1323
- break;
1467
+ // Each durable observer transaction emits its own revisioned batch.
1468
+ // Async decrypt/download work happens outside SQLite transactions.
1469
+ try {
1470
+ for (const frame of message.frames.slice(1)) {
1471
+ switch (frame.type) {
1472
+ case 'RESP_HEADER':
1473
+ break;
1474
+ case 'LEASE':
1475
+ // §7.3.5: persist the opaque lease and clear any prior lease
1476
+ // error — a fresh lease means the outage/revocation is over.
1477
+ this.#setLeaseState({
1478
+ leaseId: frame.leaseId,
1479
+ expiresAtMs: frame.expiresAtMs,
1480
+ });
1481
+ break;
1482
+ case 'PUSH_RESULT':
1483
+ this.#applyBatch((batch) => this.#handlePushResult(frame, commitsById, summary, batch));
1484
+ break;
1485
+ case 'SUB_START': {
1486
+ const sub = subsById.get(frame.id);
1487
+ const fresh = sub !== undefined &&
1488
+ sub.cursor < 0 &&
1489
+ sub.bootstrapState === undefined &&
1490
+ frame.bootstrap;
1491
+ const skip = sub === undefined ||
1492
+ (mode === 'delta' &&
1493
+ (sub.status !== 'active' || sub.bootstrapState !== undefined));
1494
+ section = { start: frame, sub, fresh, skip, cleared: false };
1495
+ break;
1496
+ }
1497
+ case 'COMMIT':
1498
+ if (section !== undefined && !section.skip) {
1499
+ await this.#applyCommit(frame, summary);
1324
1500
  }
1325
- case 'COMMIT':
1326
- if (section !== undefined && !section.skip) {
1327
- await this.#applyCommit(frame, summary);
1328
- }
1329
- break;
1330
- case 'SEGMENT_INLINE': {
1331
- if (section === undefined ||
1332
- section.skip ||
1333
- section.sub === undefined) {
1334
- break;
1335
- }
1336
- const segment = decodeRowsSegment(frame.payload);
1337
- await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, { clearFirst, effective }, this.#encryption), section.fresh && !section.cleared);
1501
+ break;
1502
+ case 'SEGMENT_INLINE': {
1503
+ if (section === undefined ||
1504
+ section.skip ||
1505
+ section.sub === undefined) {
1338
1506
  break;
1339
1507
  }
1340
- case 'SEGMENT_REF': {
1341
- if (section === undefined ||
1342
- section.skip ||
1343
- section.sub === undefined) {
1344
- break;
1345
- }
1346
- // §4.2: a descriptor whose mediaType was not advertised is a
1347
- // broken server — fail loud, never skip or guess.
1348
- if (frame.mediaType === 'sqlite' &&
1349
- (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
1350
- throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
1351
- }
1352
- const bytes = await this.#downloadSegment(frame, section.sub);
1353
- if (frame.mediaType === 'sqlite') {
1354
- // §5.3: images are whole-table — a paged descriptor is
1355
- // invalid, and the image is always its table's first page.
1356
- if (frame.rowCursor !== undefined ||
1357
- frame.nextRowCursor !== undefined) {
1358
- throw new ClientSyncError('sync.invalid_request', 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
1508
+ const segment = decodeRowsSegment(frame.payload);
1509
+ await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
1510
+ clearFirst,
1511
+ effective,
1512
+ transaction: (fn) => this.#applyBatch((batch) => {
1513
+ if (segment.blocks.some((block) => block.length > 0) ||
1514
+ (clearFirst &&
1515
+ this.#scopedRowsExist(table, effective))) {
1516
+ batch.table(table.name);
1359
1517
  }
1360
- await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applySqliteSegment(this.#db, this.#schema, table, bytes, {
1361
- table: frame.table,
1362
- rowCount: frame.rowCount,
1363
- asOfCommitSeq: frame.asOfCommitSeq,
1364
- scopeDigest: frame.scopeDigest,
1365
- }, { clearFirst, effective }), section.fresh && !section.cleared);
1366
- }
1367
- else {
1368
- const segment = decodeRowsSegment(bytes);
1369
- await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, { clearFirst, effective }, this.#encryption), section.fresh &&
1370
- !section.cleared &&
1371
- frame.rowCursor === undefined);
1372
- }
1518
+ return fn();
1519
+ }),
1520
+ }, this.#encryption), section.fresh && !section.cleared);
1521
+ break;
1522
+ }
1523
+ case 'SEGMENT_REF': {
1524
+ if (section === undefined ||
1525
+ section.skip ||
1526
+ section.sub === undefined) {
1373
1527
  break;
1374
1528
  }
1375
- case 'SUB_END': {
1376
- if (section !== undefined &&
1377
- !section.skip &&
1378
- section.sub !== undefined) {
1379
- const applied = this.#finishSection(section.sub, section.start, frame.nextCursor, frame.bootstrapState, summary);
1380
- if (mode === 'delta' && applied) {
1381
- deltaCursor = Math.max(deltaCursor, frame.nextCursor);
1382
- }
1529
+ // §4.2: a descriptor whose mediaType was not advertised is a
1530
+ // broken server fail loud, never skip or guess.
1531
+ if (frame.mediaType === 'sqlite' &&
1532
+ (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
1533
+ throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
1534
+ }
1535
+ const bytes = await this.#downloadSegment(frame, section.sub);
1536
+ if (frame.mediaType === 'sqlite') {
1537
+ // §5.3: images are whole-table — a paged descriptor is
1538
+ // invalid, and the image is always its table's first page.
1539
+ if (frame.rowCursor !== undefined ||
1540
+ frame.nextRowCursor !== undefined) {
1541
+ throw new ClientSyncError('sync.invalid_request', 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)');
1383
1542
  }
1384
- section = undefined;
1385
- break;
1543
+ await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applySqliteSegment(this.#db, this.#schema, table, bytes, {
1544
+ table: frame.table,
1545
+ rowCount: frame.rowCount,
1546
+ asOfCommitSeq: frame.asOfCommitSeq,
1547
+ scopeDigest: frame.scopeDigest,
1548
+ }, {
1549
+ clearFirst,
1550
+ effective,
1551
+ transaction: (fn) => this.#applyBatch((batch) => {
1552
+ if (frame.rowCount > 0 ||
1553
+ (clearFirst &&
1554
+ this.#scopedRowsExist(table, effective))) {
1555
+ batch.table(table.name);
1556
+ }
1557
+ return fn();
1558
+ }),
1559
+ }), section.fresh && !section.cleared);
1386
1560
  }
1387
- case 'ERROR':
1388
- // §1.4 rule 5 / §1.6: the request failed; the open
1389
- // subscription's SUB_END values are never persisted.
1390
- errorFrame = new ClientSyncError(frame.code, frame.message, frame.retryable);
1391
- section = undefined;
1392
- break;
1393
- case 'UNKNOWN':
1394
- break; // §1.2 rule 2: skipped, never interpreted
1561
+ else {
1562
+ const segment = decodeRowsSegment(bytes);
1563
+ await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
1564
+ clearFirst,
1565
+ effective,
1566
+ transaction: (fn) => this.#applyBatch((batch) => {
1567
+ if (segment.blocks.some((block) => block.length > 0) ||
1568
+ (clearFirst &&
1569
+ this.#scopedRowsExist(table, effective))) {
1570
+ batch.table(table.name);
1571
+ }
1572
+ return fn();
1573
+ }),
1574
+ }, this.#encryption), section.fresh &&
1575
+ !section.cleared &&
1576
+ frame.rowCursor === undefined);
1577
+ }
1578
+ break;
1579
+ }
1580
+ case 'SUB_END': {
1581
+ if (section !== undefined &&
1582
+ !section.skip &&
1583
+ section.sub !== undefined) {
1584
+ const applied = this.#finishSection(section.sub, section.start, frame.nextCursor, frame.bootstrapState, summary);
1585
+ if (mode === 'delta' && applied) {
1586
+ deltaCursor = Math.max(deltaCursor, frame.nextCursor);
1587
+ }
1588
+ }
1589
+ section = undefined;
1590
+ break;
1395
1591
  }
1396
- if (errorFrame !== undefined)
1592
+ case 'ERROR':
1593
+ // §1.4 rule 5 / §1.6: the request failed; the open
1594
+ // subscription's SUB_END values are never persisted.
1595
+ errorFrame = new ClientSyncError(frame.code, frame.message, frame.retryable);
1596
+ section = undefined;
1397
1597
  break;
1598
+ case 'UNKNOWN':
1599
+ break; // §1.2 rule 2: skipped, never interpreted
1398
1600
  }
1601
+ if (errorFrame !== undefined)
1602
+ break;
1399
1603
  }
1400
- finally {
1401
- // §7.1: local reads see outbox state applied optimistically — replay
1402
- // the still-pending commits on top of the freshly applied server
1403
- // state (the simple reconciliation mandated for B3).
1404
- this.#replayOutbox();
1405
- // §5.9.7 B1: after every apply/replay, refcounts follow the live rows.
1406
- // A benign apply retains zero-ref bodies (LRU default); the revocation
1407
- // purge below deletes orphaned bodies with deleteOrphans (B2).
1408
- this.#reconcileBlobs(false);
1409
- }
1410
- });
1604
+ }
1605
+ finally {
1606
+ // §7.1: local reads see outbox state applied optimistically replay
1607
+ // the still-pending commits on top of the freshly applied server state.
1608
+ this.#replayOutbox();
1609
+ this.#reconcileBlobs(false);
1610
+ }
1411
1611
  if (errorFrame !== undefined)
1412
1612
  throw errorFrame;
1413
1613
  if (mode === 'delta') {
@@ -1427,7 +1627,7 @@ export class SyncClient {
1427
1627
  }
1428
1628
  return { ...summary, bootstrapping };
1429
1629
  }
1430
- #handlePushResult(frame, commitsById, summary) {
1630
+ #handlePushResult(frame, commitsById, summary, batch) {
1431
1631
  const commit = commitsById.get(frame.clientCommitId);
1432
1632
  if (commit === undefined)
1433
1633
  return;
@@ -1435,6 +1635,7 @@ export class SyncClient {
1435
1635
  // §6.3: applied and cached both drain the outbox — cached means
1436
1636
  // "already applied, you may have missed the ack".
1437
1637
  deleteOutboxCommit(this.#db, frame.clientCommitId);
1638
+ batch.status();
1438
1639
  summary.applied.push(frame.clientCommitId);
1439
1640
  return;
1440
1641
  }
@@ -1463,6 +1664,7 @@ export class SyncClient {
1463
1664
  ...(operation !== undefined ? { operation } : {}),
1464
1665
  };
1465
1666
  this.#conflicts.push(conflict);
1667
+ batch.conflicts();
1466
1668
  summary.conflicts.push(conflict);
1467
1669
  this.#config.onConflict?.(conflict);
1468
1670
  }
@@ -1475,6 +1677,7 @@ export class SyncClient {
1475
1677
  retryable: result.retryable,
1476
1678
  ...(operation !== undefined ? { operation } : {}),
1477
1679
  });
1680
+ batch.rejections();
1478
1681
  }
1479
1682
  }
1480
1683
  // §7.2: stop optimistic display and decide about dependents — the
@@ -1491,10 +1694,14 @@ export class SyncClient {
1491
1694
  continue;
1492
1695
  const row = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [operation.rowId])[0];
1493
1696
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
1697
+ if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
1698
+ batch.table(table.name);
1699
+ }
1494
1700
  deleteLocalRow(this.#db, table, operation.rowId);
1495
1701
  }
1496
1702
  }
1497
1703
  });
1704
+ batch.status();
1498
1705
  summary.rejected.push(frame.clientCommitId);
1499
1706
  }
1500
1707
  #decodeServerRow(tableName, payload) {
@@ -1511,14 +1718,14 @@ export class SyncClient {
1511
1718
  return record;
1512
1719
  }
1513
1720
  async #applyCommit(frame, summary) {
1514
- await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption);
1721
+ await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption, (fn) => this.#applyBatch((batch) => {
1722
+ this.#recordCommitChanges(batch, frame);
1723
+ return fn();
1724
+ }));
1515
1725
  summary.commitsApplied += 1;
1516
- // I1/I2: record touched tables and precise `prefix:value` scope keys —
1517
- // COMMIT changes carry per-row stored scopes (§4.5), the finest honest
1518
- // invalidation the wire provides.
1519
- const batch = this.#batch;
1520
- if (batch === undefined)
1521
- return;
1726
+ }
1727
+ /** Record before + after scope keys while the commit transaction is open. */
1728
+ #recordCommitChanges(batch, frame) {
1522
1729
  for (const change of frame.changes) {
1523
1730
  const tableName = frame.tables[change.tableIndex];
1524
1731
  if (tableName === undefined)
@@ -1526,9 +1733,53 @@ export class SyncClient {
1526
1733
  const table = this.#schema.tables.get(tableName);
1527
1734
  if (table === undefined)
1528
1735
  continue;
1529
- batch.table(tableName);
1736
+ let precise = this.#recordStoredRowScopes(batch, table, change.rowId);
1737
+ for (const variable of Object.keys(change.scopes)) {
1738
+ if (table.scopePrefixByVariable.has(variable))
1739
+ precise = true;
1740
+ }
1530
1741
  batch.changeScopes(table, change.scopes);
1742
+ if (!precise)
1743
+ batch.table(tableName);
1744
+ }
1745
+ }
1746
+ /** Add the currently materialized row's scope keys; returns whether known. */
1747
+ #recordStoredRowScopes(batch, table, rowId) {
1748
+ const mappings = [...table.scopeColumnByVariable].filter(([variable]) => table.scopePrefixByVariable.has(variable));
1749
+ if (mappings.length === 0)
1750
+ return false;
1751
+ const row = this.#db.query(`SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
1752
+ FROM ${quoteIdent(table.name)}
1753
+ WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
1754
+ if (row === undefined)
1755
+ return false;
1756
+ let recorded = false;
1757
+ for (const [variable, column] of mappings) {
1758
+ const value = row[column];
1759
+ const prefix = table.scopePrefixByVariable.get(variable);
1760
+ if (value != null && prefix !== undefined) {
1761
+ batch.scope(table.name, `${prefix}:${String(value)}`);
1762
+ recorded = true;
1763
+ }
1531
1764
  }
1765
+ return recorded;
1766
+ }
1767
+ /** Whether a fresh-bootstrap clear would remove at least one local row. */
1768
+ #scopedRowsExist(table, effective) {
1769
+ const entries = Object.entries(effective);
1770
+ if (entries.length === 0)
1771
+ return false;
1772
+ const clauses = [];
1773
+ const params = [];
1774
+ for (const [variable, values] of entries) {
1775
+ const column = table.scopeColumnByVariable.get(variable);
1776
+ if (column === undefined || values.length === 0)
1777
+ return false;
1778
+ clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
1779
+ params.push(...values);
1780
+ }
1781
+ return (this.#db.query(`SELECT 1 FROM ${quoteIdent(table.name)}
1782
+ WHERE ${clauses.join(' AND ')} LIMIT 1`, params).length > 0);
1532
1783
  }
1533
1784
  /**
1534
1785
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
@@ -1543,26 +1794,27 @@ export class SyncClient {
1543
1794
  try {
1544
1795
  summary.segmentRowsApplied += await apply(table, clearFirst, section.start.effectiveScopes);
1545
1796
  section.cleared = true;
1546
- // I1/I2: segments carry only a table + scopeDigest, never per-row
1547
- // scope keys — invalidate the table plus the subscription's effective
1548
- // scope keys (the coarsest honest key for bulk data).
1549
- this.#batch?.table(table.name);
1550
- this.#batch?.scopeMap(table, section.start.effectiveScopes);
1551
1797
  }
1552
1798
  catch (error) {
1553
1799
  if (error instanceof ClientSyncError &&
1554
1800
  error.code === 'sync.scope_revoked') {
1555
- saveSubscription(this.#db, {
1556
- id: sub.id,
1557
- table: sub.table,
1558
- scopes: sub.scopes,
1559
- ...(sub.params !== undefined ? { params: sub.params } : {}),
1560
- cursor: sub.cursor,
1561
- ...(sub.effectiveScopes !== undefined
1562
- ? { effectiveScopes: sub.effectiveScopes }
1563
- : {}),
1564
- status: 'failed',
1565
- reasonCode: 'sync.scope_revoked',
1801
+ const registered = getWindowUnitBySubId(this.#db, sub.id);
1802
+ this.#applyBatch((batch) => {
1803
+ saveSubscription(this.#db, {
1804
+ id: sub.id,
1805
+ table: sub.table,
1806
+ scopes: sub.scopes,
1807
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
1808
+ cursor: sub.cursor,
1809
+ ...(sub.effectiveScopes !== undefined
1810
+ ? { effectiveScopes: sub.effectiveScopes }
1811
+ : {}),
1812
+ status: 'failed',
1813
+ reasonCode: 'sync.scope_revoked',
1814
+ });
1815
+ if (registered !== undefined) {
1816
+ batch.window(registered.baseKey, sub.table, registered.unit);
1817
+ }
1566
1818
  });
1567
1819
  summary.failed.push(sub.id);
1568
1820
  section.skip = true;
@@ -1618,38 +1870,47 @@ export class SyncClient {
1618
1870
  // An absent bootstrapState clears any previous resume token (§4.4:
1619
1871
  // absent = bootstrap complete, or not bootstrapping).
1620
1872
  const wasPending = sub.cursor < 0 || sub.bootstrapState !== undefined;
1621
- saveSubscription(this.#db, {
1622
- id: sub.id,
1623
- table: sub.table,
1624
- scopes: sub.scopes,
1625
- ...(sub.params !== undefined ? { params: sub.params } : {}),
1626
- cursor: nextCursor,
1627
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
1628
- effectiveScopes: start.effectiveScopes,
1629
- status: 'active',
1873
+ const completed = wasPending && nextCursor >= 0 && bootstrapState === undefined;
1874
+ const registered = getWindowUnitBySubId(this.#db, sub.id);
1875
+ this.#applyBatch((batch) => {
1876
+ saveSubscription(this.#db, {
1877
+ id: sub.id,
1878
+ table: sub.table,
1879
+ scopes: sub.scopes,
1880
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
1881
+ cursor: nextCursor,
1882
+ ...(bootstrapState !== undefined ? { bootstrapState } : {}),
1883
+ effectiveScopes: start.effectiveScopes,
1884
+ status: 'active',
1885
+ });
1886
+ if (completed && registered !== undefined) {
1887
+ // A zero-row bootstrap is a window-domain transition, not a fake
1888
+ // row/table change (SPEC §4.8 / §7.5).
1889
+ batch.window(registered.baseKey, sub.table, registered.unit);
1890
+ }
1630
1891
  });
1631
- if (wasPending && nextCursor >= 0 && bootstrapState === undefined) {
1632
- // §4.8: the completeness verdict flipped pending → complete. A
1633
- // zero-row bootstrap applies nothing, so the flip itself must reach
1634
- // live oracles through the choke point (shares the pull's batch).
1635
- this.#applyBatch((batch) => batch.table(sub.table));
1636
- }
1637
1892
  return true;
1638
1893
  }
1639
1894
  if (start.status === 'reset') {
1640
1895
  // §4.6: discard cursor + resume token, keep local rows, re-bootstrap
1641
1896
  // with cursor = -1 on the next pull. Staleness, not a purge.
1642
- saveSubscription(this.#db, {
1643
- id: sub.id,
1644
- table: sub.table,
1645
- scopes: sub.scopes,
1646
- ...(sub.params !== undefined ? { params: sub.params } : {}),
1647
- cursor: -1,
1648
- ...(sub.effectiveScopes !== undefined
1649
- ? { effectiveScopes: sub.effectiveScopes }
1650
- : {}),
1651
- status: 'active',
1652
- reasonCode: start.reasonCode,
1897
+ const registered = getWindowUnitBySubId(this.#db, sub.id);
1898
+ this.#applyBatch((batch) => {
1899
+ saveSubscription(this.#db, {
1900
+ id: sub.id,
1901
+ table: sub.table,
1902
+ scopes: sub.scopes,
1903
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
1904
+ cursor: -1,
1905
+ ...(sub.effectiveScopes !== undefined
1906
+ ? { effectiveScopes: sub.effectiveScopes }
1907
+ : {}),
1908
+ status: 'active',
1909
+ reasonCode: start.reasonCode,
1910
+ });
1911
+ if (registered !== undefined) {
1912
+ batch.window(registered.baseKey, sub.table, registered.unit);
1913
+ }
1653
1914
  });
1654
1915
  summary.resets.push(sub.id);
1655
1916
  return false;
@@ -1658,44 +1919,44 @@ export class SyncClient {
1658
1919
  // (never the requested map), drop doomed outbox commits, stop pulling.
1659
1920
  const table = this.#table(sub.table);
1660
1921
  const lastEffective = sub.effectiveScopes;
1922
+ const registered = getWindowUnitBySubId(this.#db, sub.id);
1661
1923
  let failed = false;
1662
- if (lastEffective !== undefined && Object.keys(lastEffective).length > 0) {
1663
- try {
1664
- this.#db.transaction(() => {
1924
+ this.#applyBatch((batch) => {
1925
+ if (lastEffective !== undefined &&
1926
+ Object.keys(lastEffective).length > 0) {
1927
+ try {
1665
1928
  deleteScopedRows(this.#db, table, lastEffective);
1666
- });
1667
- // I1: the §3.3 purge is a bulk delete — invalidate the table + the
1668
- // purged effective scope keys so live queries over them re-run.
1669
- this.#batch?.table(table.name);
1670
- this.#batch?.scopeMap(table, lastEffective);
1671
- dropOutboxCommitsInScope(this.#db, table, lastEffective);
1672
- // §5.9.7 B2: revocation deletes now-unauthorized blob bodies —
1673
- // reconcile with deleteOrphans (evicted ≠ revoked).
1674
- this.#reconcileBlobs(true);
1675
- }
1676
- catch (error) {
1677
- if (error instanceof ClientSyncError &&
1678
- error.code === 'sync.scope_revoked') {
1679
- // Fail closed: no local mapping — surface a fatal configuration
1680
- // error and stop syncing the table without clearing anything.
1681
- failed = true;
1929
+ batch.scopeMap(table, lastEffective);
1930
+ if (dropOutboxCommitsInScope(this.#db, table, lastEffective).length > 0) {
1931
+ batch.status();
1932
+ }
1933
+ this.#reconcileBlobs(true);
1682
1934
  }
1683
- else {
1684
- throw error;
1935
+ catch (error) {
1936
+ if (error instanceof ClientSyncError &&
1937
+ error.code === 'sync.scope_revoked') {
1938
+ failed = true;
1939
+ }
1940
+ else {
1941
+ throw error;
1942
+ }
1685
1943
  }
1686
1944
  }
1687
- }
1688
- saveSubscription(this.#db, {
1689
- id: sub.id,
1690
- table: sub.table,
1691
- scopes: sub.scopes,
1692
- ...(sub.params !== undefined ? { params: sub.params } : {}),
1693
- cursor: nextCursor,
1694
- ...(lastEffective !== undefined
1695
- ? { effectiveScopes: lastEffective }
1696
- : {}),
1697
- status: failed ? 'failed' : 'revoked',
1698
- reasonCode: start.reasonCode,
1945
+ saveSubscription(this.#db, {
1946
+ id: sub.id,
1947
+ table: sub.table,
1948
+ scopes: sub.scopes,
1949
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
1950
+ cursor: nextCursor,
1951
+ ...(lastEffective !== undefined
1952
+ ? { effectiveScopes: lastEffective }
1953
+ : {}),
1954
+ status: failed ? 'failed' : 'revoked',
1955
+ reasonCode: start.reasonCode,
1956
+ });
1957
+ if (registered !== undefined) {
1958
+ batch.window(registered.baseKey, sub.table, registered.unit);
1959
+ }
1699
1960
  });
1700
1961
  summary.revoked.push(sub.id);
1701
1962
  if (failed)
@@ -1706,8 +1967,12 @@ export class SyncClient {
1706
1967
  #applyOperationsLocally(operations, batch) {
1707
1968
  for (const op of operations) {
1708
1969
  const table = this.#table(op.table);
1709
- batch?.table(op.table);
1970
+ let precise = batch === undefined
1971
+ ? false
1972
+ : this.#recordStoredRowScopes(batch, table, op.rowId);
1710
1973
  if (op.op === 'delete') {
1974
+ if (batch !== undefined && !precise)
1975
+ batch.table(op.table);
1711
1976
  deleteLocalRow(this.#db, table, op.rowId);
1712
1977
  continue;
1713
1978
  }
@@ -1722,9 +1987,12 @@ export class SyncClient {
1722
1987
  const cell = idx === undefined ? undefined : values[idx];
1723
1988
  const prefix = table.scopePrefixByVariable.get(variable);
1724
1989
  if (prefix !== undefined && cell != null) {
1725
- batch.scopeKey(`${prefix}:${String(cell)}`);
1990
+ batch.scope(table.name, `${prefix}:${String(cell)}`);
1991
+ precise = true;
1726
1992
  }
1727
1993
  }
1994
+ if (!precise)
1995
+ batch.table(table.name);
1728
1996
  }
1729
1997
  const existing = this.#db.query(`SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [op.rowId])[0];
1730
1998
  const version = existing === undefined ? OPTIMISTIC_VERSION : existing.v;