@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.
package/src/client.ts CHANGED
@@ -62,9 +62,17 @@ import { registerDevtools } from './devtools';
62
62
  import type { EncryptionConfig } from './encryption';
63
63
  import { ClientSyncError } from './errors';
64
64
  import {
65
- Invalidation,
65
+ ChangeAccumulator,
66
+ ChangeEmitter,
67
+ type ClientChangeListener,
68
+ type CommandEffects,
69
+ type CommandResult,
66
70
  InvalidationEmitter,
67
71
  type InvalidationListener,
72
+ invalidationFromChange,
73
+ type LocalRevision,
74
+ type SyncIntent,
75
+ type SyncStatusSnapshot,
68
76
  } from './invalidation';
69
77
  import {
70
78
  type LeaderLease,
@@ -101,7 +109,9 @@ import {
101
109
  stripSyncColumns,
102
110
  } from './schema';
103
111
  import {
112
+ bumpLocalRevision,
104
113
  deleteSubscription,
114
+ getLocalRevision,
105
115
  getMeta,
106
116
  getSubscription,
107
117
  loadSubscriptions,
@@ -120,6 +130,7 @@ import {
120
130
  deletePendingEviction,
121
131
  deleteWindowUnit,
122
132
  deriveSubId,
133
+ getWindowUnitBySubId,
123
134
  insertWindowUnit,
124
135
  loadPendingEvictions,
125
136
  loadWindowUnits,
@@ -260,7 +271,10 @@ export interface SyncClientConfig {
260
271
  readonly limits?: SyncClientLimits;
261
272
  readonly now?: () => number;
262
273
  /** §8: hello `requiresSync` or a wake-up — run a pull soon. */
263
- readonly onSyncNeeded?: (reason: 'hello' | WakeReason) => void;
274
+ readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
275
+ /** Exact core-owned scheduling intent. Hosts consume this to run an
276
+ * event-driven retry deadline without polling or inferring sync state. */
277
+ readonly onSyncIntent?: (intent: SyncIntent) => void;
264
278
  readonly onConflict?: (conflict: ConflictRecord) => void;
265
279
  /**
266
280
  * §7.4.5: the schema-bump `upgrading` state changed. `true` when a reset
@@ -319,6 +333,35 @@ export interface WindowState {
319
333
  readonly pending: readonly string[];
320
334
  }
321
335
 
336
+ /** One generated/raw query's required window units (SPEC §7.5). */
337
+ export interface WindowCoverage {
338
+ readonly base: WindowBase;
339
+ readonly units: readonly string[];
340
+ }
341
+
342
+ export interface WindowUnitRef {
343
+ readonly baseKey: string;
344
+ readonly unit: string;
345
+ }
346
+
347
+ export interface CoverageSnapshot {
348
+ readonly complete: boolean;
349
+ readonly pending: readonly WindowUnitRef[];
350
+ readonly missing: readonly WindowUnitRef[];
351
+ }
352
+
353
+ export interface QueryReadSpec {
354
+ readonly sql: string;
355
+ readonly params?: readonly SqlValue[];
356
+ readonly coverage?: readonly WindowCoverage[];
357
+ }
358
+
359
+ export interface QuerySnapshot<Row = SqlRow> {
360
+ readonly revision: LocalRevision;
361
+ readonly rows: readonly Row[];
362
+ readonly coverage: CoverageSnapshot;
363
+ }
364
+
322
365
  /**
323
366
  * True iff `unit` is windowed-in AND its bootstrap completed (§4.8 I3):
324
367
  * registered and not pending. A unit with zero server rows still becomes
@@ -433,15 +476,19 @@ export class SyncClient {
433
476
  * (the fast-bail the delta path reads).
434
477
  */
435
478
  #syncOutstanding = false;
479
+ /** Retry policy belongs to the operation that classified the failure. */
480
+ #retryDelayMs = 250;
436
481
  readonly #hasBlobs: boolean;
437
482
  /** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
438
483
  readonly #presence = new Map<string, Map<string, PresencePeer>>();
439
- /** TODO 3.1 / I1: the ONE apply-path invalidation listener set. */
484
+ /** SPEC §7.5: exact core-originated observer transaction batches. */
485
+ readonly #changes = new ChangeEmitter();
486
+ /** Compatibility projection from exact batches; never bridge-inferred. */
440
487
  readonly #invalidation = new InvalidationEmitter();
441
488
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
442
489
  readonly #presenceListeners = new Set<(scopeKey: string) => void>();
443
490
  /** The batch accumulator; non-undefined only inside `#applyBatch`. */
444
- #batch: Invalidation | undefined;
491
+ #batch: ChangeAccumulator | undefined;
445
492
  /**
446
493
  * Operation-serialization mutex (the core owns one loop). Every
447
494
  * transaction-entering ASYNC operation — `sync`, the delta-apply body, and
@@ -479,8 +526,20 @@ export class SyncClient {
479
526
  ensureLocalSchema(this.#db, this.#schema);
480
527
  if (this.#hasBlobs) ensureBlobSchema(this.#db);
481
528
  const persisted = getMeta(this.#db, 'clientId');
482
- this.#clientId = this.#config.clientId ?? persisted ?? crypto.randomUUID();
483
- if (persisted !== this.#clientId) {
529
+ if (
530
+ persisted !== undefined &&
531
+ this.#config.clientId !== undefined &&
532
+ persisted !== this.#config.clientId
533
+ ) {
534
+ await this.#lease.release();
535
+ this.#lease = undefined;
536
+ throw new ClientSyncError(
537
+ 'client.identity_mismatch',
538
+ `this client database belongs to ${JSON.stringify(persisted)}; refusing to rebind it to ${JSON.stringify(this.#config.clientId)}`,
539
+ );
540
+ }
541
+ this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
542
+ if (persisted === undefined) {
484
543
  setMeta(this.#db, 'clientId', this.#clientId);
485
544
  }
486
545
  // §7.3.5: restore the persisted lease so leaseState survives restart.
@@ -494,6 +553,21 @@ export class SyncClient {
494
553
  // already at the generated version.
495
554
  this.#detectAndResetSchema();
496
555
  this.#started = true;
556
+ // A persisted active subscription needs one catch-up round on every open:
557
+ // realtime only covers changes after the socket connects, and an
558
+ // idempotent setWindow/subscribe call correctly creates no new command
559
+ // effect. Pending outbox work has the same restart requirement. Surface
560
+ // this as an exact core-owned intent so hosts never need a startup poll or
561
+ // an application-issued sync() call.
562
+ const startupWork =
563
+ this.#schemaFloor === undefined &&
564
+ (listOutbox(this.#db).length > 0 ||
565
+ loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
566
+ if (startupWork) {
567
+ this.#needsPull = true;
568
+ this.#config.onSyncNeeded?.('startup');
569
+ this.#config.onSyncIntent?.({ kind: 'interactive' });
570
+ }
497
571
  // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
498
572
  this.#devtoolsUnregister = registerDevtools({
499
573
  kind: 'client',
@@ -552,16 +626,36 @@ export class SyncClient {
552
626
  // The stop state is over: this client now ships a servable schema. The
553
627
  // outbox is re-applied optimistically over the (now empty) tables so
554
628
  // pending offline writes stay visible across the bump (§7.4.5).
555
- this.#schemaFloor = undefined;
629
+ this.#setSchemaFloor(undefined);
556
630
  this.#replayOutbox();
557
631
  }
558
632
 
559
633
  #setUpgrading(upgrading: boolean): void {
560
634
  if (this.#upgrading === upgrading) return;
561
- this.#upgrading = upgrading;
635
+ this.#applyBatch((batch) => {
636
+ this.#upgrading = upgrading;
637
+ batch.status();
638
+ });
562
639
  this.#config.onUpgrading?.(upgrading);
563
640
  }
564
641
 
642
+ #setSyncNeeded(syncNeeded: boolean): void {
643
+ if (this.#needsPull === syncNeeded) return;
644
+ this.#applyBatch((batch) => {
645
+ this.#needsPull = syncNeeded;
646
+ batch.status();
647
+ });
648
+ }
649
+
650
+ #setSchemaFloor(schemaFloor: SchemaFloor | undefined): void {
651
+ const current = JSON.stringify(this.#schemaFloor);
652
+ if (current === JSON.stringify(schemaFloor)) return;
653
+ this.#applyBatch((batch) => {
654
+ this.#schemaFloor = schemaFloor;
655
+ batch.status();
656
+ });
657
+ }
658
+
565
659
  async close(): Promise<void> {
566
660
  this.#devtoolsUnregister?.();
567
661
  this.#devtoolsUnregister = undefined;
@@ -597,6 +691,65 @@ export class SyncClient {
597
691
  return stripSyncColumns(this.#db.query(sql, params));
598
692
  }
599
693
 
694
+ /** Current durable local observer revision (SPEC §7.5). */
695
+ get localRevision(): LocalRevision {
696
+ this.#requireStarted();
697
+ return getLocalRevision(this.#db);
698
+ }
699
+
700
+ /**
701
+ * Read rows, window answerability, and revision from one SQLite snapshot.
702
+ * Reactive integrations use this instead of composing `query()` and
703
+ * `windowState()` across separate worker/IPC calls.
704
+ */
705
+ querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> {
706
+ this.#requireStarted();
707
+ assertReadOnlyQuery(spec.sql);
708
+ return this.#db.transaction(() => {
709
+ const revision = getLocalRevision(this.#db);
710
+ const rows = stripSyncColumns(
711
+ this.#db.query(spec.sql, spec.params),
712
+ ) as unknown as readonly Row[];
713
+ const pending: WindowUnitRef[] = [];
714
+ const missing: WindowUnitRef[] = [];
715
+ for (const requested of spec.coverage ?? []) {
716
+ const baseKey = windowBaseKey(requested.base);
717
+ const live = new Map(
718
+ loadWindowUnits(this.#db, baseKey).map((entry) => [
719
+ entry.unit,
720
+ entry.subId,
721
+ ]),
722
+ );
723
+ for (const unit of new Set(requested.units)) {
724
+ const subId = live.get(unit);
725
+ const ref = { baseKey, unit };
726
+ if (subId === undefined) {
727
+ missing.push(ref);
728
+ continue;
729
+ }
730
+ const sub = getSubscription(this.#db, subId);
731
+ if (
732
+ sub === undefined ||
733
+ sub.status !== 'active' ||
734
+ sub.cursor < 0 ||
735
+ sub.bootstrapState !== undefined
736
+ ) {
737
+ pending.push(ref);
738
+ }
739
+ }
740
+ }
741
+ return {
742
+ revision,
743
+ rows,
744
+ coverage: {
745
+ complete: pending.length === 0 && missing.length === 0,
746
+ pending,
747
+ missing,
748
+ },
749
+ };
750
+ });
751
+ }
752
+
600
753
  // -- live-query invalidation (TODO 3.1 / DESIGN-eviction I1–I4) -----------
601
754
 
602
755
  /**
@@ -614,23 +767,63 @@ export class SyncClient {
614
767
  return this.#invalidation.on(listener);
615
768
  }
616
769
 
770
+ /** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
771
+ onChange(listener: ClientChangeListener): () => void {
772
+ return this.#changes.on(listener);
773
+ }
774
+
775
+ /** One call for the complete status domain used by reactive hosts. */
776
+ statusSnapshot(): SyncStatusSnapshot {
777
+ this.#requireStarted();
778
+ return this.#statusSnapshot();
779
+ }
780
+
781
+ #statusSnapshot(): SyncStatusSnapshot {
782
+ return {
783
+ outbox: listOutbox(this.#db).length,
784
+ upgrading: this.#upgrading,
785
+ leaseState: this.#leaseState,
786
+ schemaFloor: this.#schemaFloor,
787
+ syncNeeded: this.#needsPull,
788
+ };
789
+ }
790
+
617
791
  /**
618
792
  * Run `fn` as one apply batch: install a fresh accumulator, collect every
619
793
  * touched key, then emit exactly one coalesced event if anything changed.
620
794
  * Re-entrant calls share the outer batch so a nested apply never
621
795
  * double-emits (e.g. purge → blob reconcile → replay inside one round).
622
796
  */
623
- #applyBatch<T>(fn: (batch: Invalidation) => T): T {
797
+ #applyBatch<T>(fn: (batch: ChangeAccumulator) => T): T {
624
798
  if (this.#batch !== undefined) return fn(this.#batch);
625
- const batch = new Invalidation();
626
- this.#batch = batch;
799
+ const batch = new ChangeAccumulator();
800
+ let revision: LocalRevision | undefined;
801
+ let status: SyncStatusSnapshot | undefined;
802
+ let result!: T;
627
803
  try {
628
- return fn(batch);
629
- } finally {
804
+ this.#db.transaction(() => {
805
+ this.#batch = batch;
806
+ try {
807
+ result = fn(batch);
808
+ if (batch.touched) {
809
+ revision = bumpLocalRevision(this.#db);
810
+ if (batch.statusChanged) status = this.#statusSnapshot();
811
+ }
812
+ } finally {
813
+ this.#batch = undefined;
814
+ }
815
+ });
816
+ } catch (error) {
630
817
  this.#batch = undefined;
631
- const event = batch.finish();
632
- if (event !== undefined) this.#invalidation.emit(event);
818
+ throw error;
819
+ }
820
+ if (revision !== undefined) {
821
+ const event = batch.finish(revision, status);
822
+ this.#changes.emit(event);
823
+ const legacy = invalidationFromChange(event);
824
+ if (legacy !== undefined) this.#invalidation.emit(legacy);
633
825
  }
826
+ return result;
634
827
  }
635
828
 
636
829
  /**
@@ -652,22 +845,6 @@ export class SyncClient {
652
845
  return next;
653
846
  }
654
847
 
655
- /** Async twin of {@link #applyBatch} for the pull/delta apply round. */
656
- async #applyBatchAsync<T>(
657
- fn: (batch: Invalidation) => Promise<T>,
658
- ): Promise<T> {
659
- if (this.#batch !== undefined) return fn(this.#batch);
660
- const batch = new Invalidation();
661
- this.#batch = batch;
662
- try {
663
- return await fn(batch);
664
- } finally {
665
- this.#batch = undefined;
666
- const event = batch.finish();
667
- if (event !== undefined) this.#invalidation.emit(event);
668
- }
669
- }
670
-
671
848
  // -- blobs (§5.9) ---------------------------------------------------------
672
849
 
673
850
  /**
@@ -1035,6 +1212,14 @@ export class SyncClient {
1035
1212
  * re-registers realtime at round end (§8.7). No socket cycle needed.
1036
1213
  */
1037
1214
  async setWindow(base: WindowBase, units: readonly string[]): Promise<void> {
1215
+ await this.setWindowCommand(base, units);
1216
+ }
1217
+
1218
+ /** Exact core command result consumed by automatic host loops (§7.5). */
1219
+ async setWindowCommand(
1220
+ base: WindowBase,
1221
+ units: readonly string[],
1222
+ ): Promise<CommandResult<void>> {
1038
1223
  this.#requireStarted();
1039
1224
  const table = this.#table(base.table);
1040
1225
  if (!table.scopeColumnByVariable.has(base.variable)) {
@@ -1046,6 +1231,8 @@ export class SyncClient {
1046
1231
  // Serialize the whole window edit: it spans an `await deriveSubId` between
1047
1232
  // db transactions, so without the chain a delta apply (or a concurrent
1048
1233
  // setWindow) could interleave its transactions and corrupt the registry.
1234
+ let changed = false;
1235
+ let widened = false;
1049
1236
  await this.#serialize(async () => {
1050
1237
  const baseKey = windowBaseKey(base);
1051
1238
  const wanted = new Set(units);
@@ -1056,27 +1243,37 @@ export class SyncClient {
1056
1243
  for (const unit of wanted) {
1057
1244
  if (liveByUnit.has(unit)) continue;
1058
1245
  const subId = await deriveSubId(base, unit);
1059
- this.#db.transaction(() => {
1060
- // Re-entry cancels any deferred eviction for this sub id.
1061
- deletePendingEviction(this.#db, subId);
1062
- insertWindowUnit(this.#db, baseKey, unit, subId);
1063
- saveSubscription(this.#db, {
1064
- id: subId,
1065
- table: base.table,
1066
- scopes: unitScopes(base, unit),
1067
- ...(base.params !== undefined ? { params: base.params } : {}),
1068
- cursor: -1,
1069
- status: 'active',
1246
+ this.#applyBatch((batch) => {
1247
+ this.#db.transaction(() => {
1248
+ // Re-entry cancels any deferred eviction for this sub id.
1249
+ deletePendingEviction(this.#db, subId);
1250
+ insertWindowUnit(this.#db, baseKey, unit, subId);
1251
+ saveSubscription(this.#db, {
1252
+ id: subId,
1253
+ table: base.table,
1254
+ scopes: unitScopes(base, unit),
1255
+ ...(base.params !== undefined ? { params: base.params } : {}),
1256
+ cursor: -1,
1257
+ status: 'active',
1258
+ });
1070
1259
  });
1260
+ batch.window(baseKey, base.table, unit);
1071
1261
  });
1262
+ changed = true;
1263
+ widened = true;
1072
1264
  }
1073
1265
 
1074
1266
  // Shrink: units live but not wanted → unsubscribe fused with eviction.
1075
1267
  for (const { unit, subId } of live) {
1076
1268
  if (wanted.has(unit)) continue;
1077
1269
  this.#evictUnit(baseKey, base, unit, subId);
1270
+ changed = true;
1078
1271
  }
1079
1272
  });
1273
+ const effects: CommandEffects = {
1274
+ sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1275
+ };
1276
+ return { value: undefined, effects };
1080
1277
  }
1081
1278
 
1082
1279
  /**
@@ -1098,6 +1295,7 @@ export class SyncClient {
1098
1295
  const sub = getSubscription(this.#db, subId);
1099
1296
  if (
1100
1297
  sub === undefined ||
1298
+ sub.status !== 'active' ||
1101
1299
  sub.cursor < 0 ||
1102
1300
  sub.bootstrapState !== undefined
1103
1301
  ) {
@@ -1140,9 +1338,8 @@ export class SyncClient {
1140
1338
  deletePendingEviction(this.#db, subId);
1141
1339
  }
1142
1340
  });
1143
- // I1: eviction is a bulk delete — a query over the evicted unit re-runs.
1144
- batch.table(table.name);
1145
1341
  batch.scopeMap(table, effective);
1342
+ batch.window(baseKey, table.name, unit);
1146
1343
  });
1147
1344
  }
1148
1345
 
@@ -1167,7 +1364,6 @@ export class SyncClient {
1167
1364
  deferred = evictScopedRows(this.#db, table, entry.effective, pinned);
1168
1365
  if (!deferred) deletePendingEviction(this.#db, entry.subId);
1169
1366
  });
1170
- batch.table(table.name);
1171
1367
  batch.scopeMap(table, entry.effective);
1172
1368
  });
1173
1369
  }
@@ -1235,11 +1431,20 @@ export class SyncClient {
1235
1431
  this.#db.transaction(() => {
1236
1432
  appendOutboxCommit(this.#db, clientCommitId, operations, this.#now());
1237
1433
  this.#applyOperationsLocally(operations, batch);
1434
+ batch.status();
1238
1435
  });
1239
1436
  });
1240
1437
  return clientCommitId;
1241
1438
  }
1242
1439
 
1440
+ /** Host-facing mutation result with explicit network work intent (§7.5). */
1441
+ mutateCommand(mutations: readonly MutationInput[]): CommandResult<string> {
1442
+ return {
1443
+ value: this.mutate(mutations),
1444
+ effects: { sync: { kind: 'interactive' } },
1445
+ };
1446
+ }
1447
+
1243
1448
  /**
1244
1449
  * Partial-update convenience over the §6.1 full-row wire: read the
1245
1450
  * current LOCAL row, merge `partial` over it, and record one full-row
@@ -1287,12 +1492,28 @@ export class SyncClient {
1287
1492
  ]);
1288
1493
  }
1289
1494
 
1495
+ /** Host-facing patch result with explicit network work intent (§7.5). */
1496
+ patchCommand(
1497
+ table: string,
1498
+ rowId: string,
1499
+ partial: Readonly<Record<string, unknown>>,
1500
+ options?: { readonly baseVersion?: number },
1501
+ ): CommandResult<string> {
1502
+ return {
1503
+ value: this.patch(table, rowId, partial, options),
1504
+ effects: { sync: { kind: 'interactive' } },
1505
+ };
1506
+ }
1507
+
1290
1508
  // -- lease state (§7.3.5) ---------------------------------------------------
1291
1509
 
1292
1510
  /** Merge and persist the lease state (opaque, §7.3.5). */
1293
1511
  #setLeaseState(next: LeaseState): void {
1294
- this.#leaseState = next;
1295
- setMeta(this.#db, 'leaseState', JSON.stringify(next));
1512
+ this.#applyBatch((batch) => {
1513
+ this.#leaseState = next;
1514
+ setMeta(this.#db, 'leaseState', JSON.stringify(next));
1515
+ batch.status();
1516
+ });
1296
1517
  }
1297
1518
 
1298
1519
  /** The request-level lease error codes (§7.3.4): stop-and-surface. */
@@ -1357,7 +1578,7 @@ export class SyncClient {
1357
1578
  * purely-optimistic rows are undone, and a rejection record is raised.
1358
1579
  */
1359
1580
  #dropIncompatibleCommit(commit: OutboxCommit, message: string): void {
1360
- this.#db.transaction(() => {
1581
+ this.#applyBatch((batch) => {
1361
1582
  deleteOutboxCommit(this.#db, commit.clientCommitId);
1362
1583
  for (const operation of commit.operations) {
1363
1584
  if (operation.op !== 'upsert') continue;
@@ -1368,19 +1589,24 @@ export class SyncClient {
1368
1589
  [operation.rowId],
1369
1590
  )[0];
1370
1591
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
1592
+ if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
1593
+ batch.table(table.name);
1594
+ }
1371
1595
  deleteLocalRow(this.#db, table, operation.rowId);
1372
1596
  }
1373
1597
  }
1374
- });
1375
- this.#rejections.push({
1376
- clientCommitId: commit.clientCommitId,
1377
- opIndex: 0,
1378
- code: OUTBOX_INCOMPATIBLE_CODE,
1379
- message,
1380
- retryable: false,
1381
- ...(commit.operations[0] !== undefined
1382
- ? { operation: commit.operations[0] }
1383
- : {}),
1598
+ this.#rejections.push({
1599
+ clientCommitId: commit.clientCommitId,
1600
+ opIndex: 0,
1601
+ code: OUTBOX_INCOMPATIBLE_CODE,
1602
+ message,
1603
+ retryable: false,
1604
+ ...(commit.operations[0] !== undefined
1605
+ ? { operation: commit.operations[0] }
1606
+ : {}),
1607
+ });
1608
+ batch.status();
1609
+ batch.rejections();
1384
1610
  });
1385
1611
  }
1386
1612
 
@@ -1423,7 +1649,7 @@ export class SyncClient {
1423
1649
  // Cleared before the round, not after: a wake-up (or a delta dropped
1424
1650
  // because this pull is mid-flight) that lands during the round must
1425
1651
  // survive it — the reference server keeps no replay buffer (§8.2).
1426
- this.#needsPull = false;
1652
+ this.#setSyncNeeded(false);
1427
1653
  try {
1428
1654
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
1429
1655
  // them, so the server-side existence check (§6.6) passes.
@@ -1491,10 +1717,11 @@ export class SyncClient {
1491
1717
  // §4.8 E1: the push half may have drained commits that pinned rows of
1492
1718
  // a shrunk window unit — retry any deferred evictions now.
1493
1719
  this.#drainPendingEvictions();
1720
+ this.#retryDelayMs = 250;
1494
1721
  if (deferred > 0) {
1495
1722
  // §6.1 splitBatch remainder: more queued commits than this request
1496
1723
  // could carry — keep the sync-needed signal raised for the host.
1497
- this.#needsPull = true;
1724
+ this.#setSyncNeeded(true);
1498
1725
  return { ...summary, deferredCommits: deferred };
1499
1726
  }
1500
1727
  return summary;
@@ -1511,6 +1738,22 @@ export class SyncClient {
1511
1738
  errorCode: code,
1512
1739
  });
1513
1740
  }
1741
+ const explicitlyRetryable = (error as { retryable?: unknown }).retryable;
1742
+ const retryable =
1743
+ explicitlyRetryable === true ||
1744
+ (explicitlyRetryable === undefined && typeof code !== 'string');
1745
+ if (retryable) {
1746
+ const intent: SyncIntent = {
1747
+ kind: 'background',
1748
+ delayMs: this.#retryDelayMs,
1749
+ };
1750
+ this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
1751
+ try {
1752
+ this.#config.onSyncIntent?.(intent);
1753
+ } catch {
1754
+ // An observer cannot alter sync correctness.
1755
+ }
1756
+ }
1514
1757
  throw error;
1515
1758
  } finally {
1516
1759
  this.#syncing = false;
@@ -1670,14 +1913,14 @@ export class SyncClient {
1670
1913
  const event = parsed.event;
1671
1914
  if (event.event === 'hello') {
1672
1915
  if (event.data.requiresSync) {
1673
- this.#needsPull = true;
1916
+ this.#setSyncNeeded(true);
1674
1917
  this.#config.onSyncNeeded?.('hello');
1675
1918
  }
1676
1919
  return;
1677
1920
  }
1678
1921
  if (event.event === 'sync') {
1679
1922
  // §8.3: any wake-up means "run a pull soon", never data.
1680
- this.#needsPull = true;
1923
+ this.#setSyncNeeded(true);
1681
1924
  this.#config.onSyncNeeded?.(event.data.reason);
1682
1925
  return;
1683
1926
  }
@@ -1735,7 +1978,7 @@ export class SyncClient {
1735
1978
  // worth it. (An optimization; the op chain below is the correctness
1736
1979
  // mechanism — it also excludes a delta from racing a `setWindow` or a
1737
1980
  // sync round that started between this check and the apply.)
1738
- this.#needsPull = true;
1981
+ this.#setSyncNeeded(true);
1739
1982
  return;
1740
1983
  }
1741
1984
  // Serialize the apply on the operation chain: a delta must never
@@ -1749,7 +1992,7 @@ export class SyncClient {
1749
1992
  await this.#processResponse(message, [], undefined, 'delta');
1750
1993
  } catch {
1751
1994
  // A delta that cannot be applied is recovered by a pull (§8.3).
1752
- this.#needsPull = true;
1995
+ this.#setSyncNeeded(true);
1753
1996
  this.#config.onSyncNeeded?.('catchup-required');
1754
1997
  }
1755
1998
  });
@@ -1800,16 +2043,17 @@ export class SyncClient {
1800
2043
  // version this client sends. The §7.4.2 trigger-2 convergence runs
1801
2044
  // when the APP updates (recreating the client with a new generated
1802
2045
  // schema), which fires the boot-time §7.4.1 marker check instead.
1803
- this.#schemaFloor = {
2046
+ const schemaFloor: SchemaFloor = {
1804
2047
  requiredSchemaVersion: header.requiredSchemaVersion,
1805
2048
  ...(header.latestSchemaVersion !== undefined
1806
2049
  ? { latestSchemaVersion: header.latestSchemaVersion }
1807
2050
  : {}),
1808
2051
  };
2052
+ this.#setSchemaFloor(schemaFloor);
1809
2053
  return {
1810
2054
  ...summary,
1811
2055
  bootstrapping: [],
1812
- schemaFloor: this.#schemaFloor,
2056
+ schemaFloor,
1813
2057
  };
1814
2058
  }
1815
2059
 
@@ -1817,55 +2061,151 @@ export class SyncClient {
1817
2061
  let errorFrame: ClientSyncError | undefined;
1818
2062
  let deltaCursor = -1;
1819
2063
 
1820
- // One apply batch per pull/delta round (I1): COMMIT + segment applies,
1821
- // the revocation purge, and the optimistic replay all coalesce into a
1822
- // single invalidation event emitted when this batch unwinds.
1823
- await this.#applyBatchAsync(async () => {
1824
- try {
1825
- for (const frame of message.frames.slice(1)) {
1826
- switch (frame.type) {
1827
- case 'RESP_HEADER':
1828
- break;
1829
- case 'LEASE':
1830
- // §7.3.5: persist the opaque lease and clear any prior lease
1831
- // error — a fresh lease means the outage/revocation is over.
1832
- this.#setLeaseState({
1833
- leaseId: frame.leaseId,
1834
- expiresAtMs: frame.expiresAtMs,
1835
- });
1836
- break;
1837
- case 'PUSH_RESULT':
1838
- this.#handlePushResult(frame, commitsById, summary);
1839
- break;
1840
- case 'SUB_START': {
1841
- const sub = subsById.get(frame.id);
1842
- const fresh =
1843
- sub !== undefined &&
1844
- sub.cursor < 0 &&
1845
- sub.bootstrapState === undefined &&
1846
- frame.bootstrap;
1847
- const skip =
1848
- sub === undefined ||
1849
- (mode === 'delta' &&
1850
- (sub.status !== 'active' ||
1851
- sub.bootstrapState !== undefined));
1852
- section = { start: frame, sub, fresh, skip, cleared: false };
2064
+ // Each durable observer transaction emits its own revisioned batch.
2065
+ // Async decrypt/download work happens outside SQLite transactions.
2066
+ try {
2067
+ for (const frame of message.frames.slice(1)) {
2068
+ switch (frame.type) {
2069
+ case 'RESP_HEADER':
2070
+ break;
2071
+ case 'LEASE':
2072
+ // §7.3.5: persist the opaque lease and clear any prior lease
2073
+ // error — a fresh lease means the outage/revocation is over.
2074
+ this.#setLeaseState({
2075
+ leaseId: frame.leaseId,
2076
+ expiresAtMs: frame.expiresAtMs,
2077
+ });
2078
+ break;
2079
+ case 'PUSH_RESULT':
2080
+ this.#applyBatch((batch) =>
2081
+ this.#handlePushResult(frame, commitsById, summary, batch),
2082
+ );
2083
+ break;
2084
+ case 'SUB_START': {
2085
+ const sub = subsById.get(frame.id);
2086
+ const fresh =
2087
+ sub !== undefined &&
2088
+ sub.cursor < 0 &&
2089
+ sub.bootstrapState === undefined &&
2090
+ frame.bootstrap;
2091
+ const skip =
2092
+ sub === undefined ||
2093
+ (mode === 'delta' &&
2094
+ (sub.status !== 'active' || sub.bootstrapState !== undefined));
2095
+ section = { start: frame, sub, fresh, skip, cleared: false };
2096
+ break;
2097
+ }
2098
+ case 'COMMIT':
2099
+ if (section !== undefined && !section.skip) {
2100
+ await this.#applyCommit(frame, summary);
2101
+ }
2102
+ break;
2103
+ case 'SEGMENT_INLINE': {
2104
+ if (
2105
+ section === undefined ||
2106
+ section.skip ||
2107
+ section.sub === undefined
2108
+ ) {
1853
2109
  break;
1854
2110
  }
1855
- case 'COMMIT':
1856
- if (section !== undefined && !section.skip) {
1857
- await this.#applyCommit(frame, summary);
1858
- }
2111
+ const segment = decodeRowsSegment(frame.payload);
2112
+ await this.#applySegmentOrFail(
2113
+ section,
2114
+ summary,
2115
+ (table, clearFirst, effective) =>
2116
+ applyRowsSegment(
2117
+ this.#db,
2118
+ this.#schema,
2119
+ table,
2120
+ segment,
2121
+ {
2122
+ clearFirst,
2123
+ effective,
2124
+ transaction: (fn) =>
2125
+ this.#applyBatch((batch) => {
2126
+ if (
2127
+ segment.blocks.some((block) => block.length > 0) ||
2128
+ (clearFirst &&
2129
+ this.#scopedRowsExist(table, effective))
2130
+ ) {
2131
+ batch.table(table.name);
2132
+ }
2133
+ return fn();
2134
+ }),
2135
+ },
2136
+ this.#encryption,
2137
+ ),
2138
+ section.fresh && !section.cleared,
2139
+ );
2140
+ break;
2141
+ }
2142
+ case 'SEGMENT_REF': {
2143
+ if (
2144
+ section === undefined ||
2145
+ section.skip ||
2146
+ section.sub === undefined
2147
+ ) {
1859
2148
  break;
1860
- case 'SEGMENT_INLINE': {
2149
+ }
2150
+ // §4.2: a descriptor whose mediaType was not advertised is a
2151
+ // broken server — fail loud, never skip or guess.
2152
+ if (
2153
+ frame.mediaType === 'sqlite' &&
2154
+ (this.#acceptMask() & ACCEPT_SQLITE) === 0
2155
+ ) {
2156
+ throw new ClientSyncError(
2157
+ 'sync.invalid_request',
2158
+ 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
2159
+ );
2160
+ }
2161
+ const bytes = await this.#downloadSegment(frame, section.sub);
2162
+ if (frame.mediaType === 'sqlite') {
2163
+ // §5.3: images are whole-table — a paged descriptor is
2164
+ // invalid, and the image is always its table's first page.
1861
2165
  if (
1862
- section === undefined ||
1863
- section.skip ||
1864
- section.sub === undefined
2166
+ frame.rowCursor !== undefined ||
2167
+ frame.nextRowCursor !== undefined
1865
2168
  ) {
1866
- break;
2169
+ throw new ClientSyncError(
2170
+ 'sync.invalid_request',
2171
+ 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
2172
+ );
1867
2173
  }
1868
- const segment = decodeRowsSegment(frame.payload);
2174
+ await this.#applySegmentOrFail(
2175
+ section,
2176
+ summary,
2177
+ (table, clearFirst, effective) =>
2178
+ applySqliteSegment(
2179
+ this.#db,
2180
+ this.#schema,
2181
+ table,
2182
+ bytes,
2183
+ {
2184
+ table: frame.table,
2185
+ rowCount: frame.rowCount,
2186
+ asOfCommitSeq: frame.asOfCommitSeq,
2187
+ scopeDigest: frame.scopeDigest,
2188
+ },
2189
+ {
2190
+ clearFirst,
2191
+ effective,
2192
+ transaction: (fn) =>
2193
+ this.#applyBatch((batch) => {
2194
+ if (
2195
+ frame.rowCount > 0 ||
2196
+ (clearFirst &&
2197
+ this.#scopedRowsExist(table, effective))
2198
+ ) {
2199
+ batch.table(table.name);
2200
+ }
2201
+ return fn();
2202
+ }),
2203
+ },
2204
+ ),
2205
+ section.fresh && !section.cleared,
2206
+ );
2207
+ } else {
2208
+ const segment = decodeRowsSegment(bytes);
1869
2209
  await this.#applySegmentOrFail(
1870
2210
  section,
1871
2211
  summary,
@@ -1875,131 +2215,71 @@ export class SyncClient {
1875
2215
  this.#schema,
1876
2216
  table,
1877
2217
  segment,
1878
- { clearFirst, effective },
2218
+ {
2219
+ clearFirst,
2220
+ effective,
2221
+ transaction: (fn) =>
2222
+ this.#applyBatch((batch) => {
2223
+ if (
2224
+ segment.blocks.some((block) => block.length > 0) ||
2225
+ (clearFirst &&
2226
+ this.#scopedRowsExist(table, effective))
2227
+ ) {
2228
+ batch.table(table.name);
2229
+ }
2230
+ return fn();
2231
+ }),
2232
+ },
1879
2233
  this.#encryption,
1880
2234
  ),
1881
- section.fresh && !section.cleared,
2235
+ section.fresh &&
2236
+ !section.cleared &&
2237
+ frame.rowCursor === undefined,
1882
2238
  );
1883
- break;
1884
2239
  }
1885
- case 'SEGMENT_REF': {
1886
- if (
1887
- section === undefined ||
1888
- section.skip ||
1889
- section.sub === undefined
1890
- ) {
1891
- break;
1892
- }
1893
- // §4.2: a descriptor whose mediaType was not advertised is a
1894
- // broken server — fail loud, never skip or guess.
1895
- if (
1896
- frame.mediaType === 'sqlite' &&
1897
- (this.#acceptMask() & ACCEPT_SQLITE) === 0
1898
- ) {
1899
- throw new ClientSyncError(
1900
- 'sync.invalid_request',
1901
- 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
1902
- );
1903
- }
1904
- const bytes = await this.#downloadSegment(frame, section.sub);
1905
- if (frame.mediaType === 'sqlite') {
1906
- // §5.3: images are whole-table — a paged descriptor is
1907
- // invalid, and the image is always its table's first page.
1908
- if (
1909
- frame.rowCursor !== undefined ||
1910
- frame.nextRowCursor !== undefined
1911
- ) {
1912
- throw new ClientSyncError(
1913
- 'sync.invalid_request',
1914
- 'sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)',
1915
- );
1916
- }
1917
- await this.#applySegmentOrFail(
1918
- section,
1919
- summary,
1920
- (table, clearFirst, effective) =>
1921
- applySqliteSegment(
1922
- this.#db,
1923
- this.#schema,
1924
- table,
1925
- bytes,
1926
- {
1927
- table: frame.table,
1928
- rowCount: frame.rowCount,
1929
- asOfCommitSeq: frame.asOfCommitSeq,
1930
- scopeDigest: frame.scopeDigest,
1931
- },
1932
- { clearFirst, effective },
1933
- ),
1934
- section.fresh && !section.cleared,
1935
- );
1936
- } else {
1937
- const segment = decodeRowsSegment(bytes);
1938
- await this.#applySegmentOrFail(
1939
- section,
1940
- summary,
1941
- (table, clearFirst, effective) =>
1942
- applyRowsSegment(
1943
- this.#db,
1944
- this.#schema,
1945
- table,
1946
- segment,
1947
- { clearFirst, effective },
1948
- this.#encryption,
1949
- ),
1950
- section.fresh &&
1951
- !section.cleared &&
1952
- frame.rowCursor === undefined,
1953
- );
1954
- }
1955
- break;
1956
- }
1957
- case 'SUB_END': {
1958
- if (
1959
- section !== undefined &&
1960
- !section.skip &&
1961
- section.sub !== undefined
1962
- ) {
1963
- const applied = this.#finishSection(
1964
- section.sub,
1965
- section.start,
1966
- frame.nextCursor,
1967
- frame.bootstrapState,
1968
- summary,
1969
- );
1970
- if (mode === 'delta' && applied) {
1971
- deltaCursor = Math.max(deltaCursor, frame.nextCursor);
1972
- }
2240
+ break;
2241
+ }
2242
+ case 'SUB_END': {
2243
+ if (
2244
+ section !== undefined &&
2245
+ !section.skip &&
2246
+ section.sub !== undefined
2247
+ ) {
2248
+ const applied = this.#finishSection(
2249
+ section.sub,
2250
+ section.start,
2251
+ frame.nextCursor,
2252
+ frame.bootstrapState,
2253
+ summary,
2254
+ );
2255
+ if (mode === 'delta' && applied) {
2256
+ deltaCursor = Math.max(deltaCursor, frame.nextCursor);
1973
2257
  }
1974
- section = undefined;
1975
- break;
1976
2258
  }
1977
- case 'ERROR':
1978
- // §1.4 rule 5 / §1.6: the request failed; the open
1979
- // subscription's SUB_END values are never persisted.
1980
- errorFrame = new ClientSyncError(
1981
- frame.code,
1982
- frame.message,
1983
- frame.retryable,
1984
- );
1985
- section = undefined;
1986
- break;
1987
- case 'UNKNOWN':
1988
- break; // §1.2 rule 2: skipped, never interpreted
2259
+ section = undefined;
2260
+ break;
1989
2261
  }
1990
- if (errorFrame !== undefined) break;
2262
+ case 'ERROR':
2263
+ // §1.4 rule 5 / §1.6: the request failed; the open
2264
+ // subscription's SUB_END values are never persisted.
2265
+ errorFrame = new ClientSyncError(
2266
+ frame.code,
2267
+ frame.message,
2268
+ frame.retryable,
2269
+ );
2270
+ section = undefined;
2271
+ break;
2272
+ case 'UNKNOWN':
2273
+ break; // §1.2 rule 2: skipped, never interpreted
1991
2274
  }
1992
- } finally {
1993
- // §7.1: local reads see outbox state applied optimistically — replay
1994
- // the still-pending commits on top of the freshly applied server
1995
- // state (the simple reconciliation mandated for B3).
1996
- this.#replayOutbox();
1997
- // §5.9.7 B1: after every apply/replay, refcounts follow the live rows.
1998
- // A benign apply retains zero-ref bodies (LRU default); the revocation
1999
- // purge below deletes orphaned bodies with deleteOrphans (B2).
2000
- this.#reconcileBlobs(false);
2275
+ if (errorFrame !== undefined) break;
2001
2276
  }
2002
- });
2277
+ } finally {
2278
+ // §7.1: local reads see outbox state applied optimistically — replay
2279
+ // the still-pending commits on top of the freshly applied server state.
2280
+ this.#replayOutbox();
2281
+ this.#reconcileBlobs(false);
2282
+ }
2003
2283
 
2004
2284
  if (errorFrame !== undefined) throw errorFrame;
2005
2285
 
@@ -2026,6 +2306,7 @@ export class SyncClient {
2026
2306
  frame: PushResultFrame,
2027
2307
  commitsById: ReadonlyMap<string, OutboxCommit>,
2028
2308
  summary: MutableSummary,
2309
+ batch: ChangeAccumulator,
2029
2310
  ): void {
2030
2311
  const commit = commitsById.get(frame.clientCommitId);
2031
2312
  if (commit === undefined) return;
@@ -2033,6 +2314,7 @@ export class SyncClient {
2033
2314
  // §6.3: applied and cached both drain the outbox — cached means
2034
2315
  // "already applied, you may have missed the ack".
2035
2316
  deleteOutboxCommit(this.#db, frame.clientCommitId);
2317
+ batch.status();
2036
2318
  summary.applied.push(frame.clientCommitId);
2037
2319
  return;
2038
2320
  }
@@ -2064,6 +2346,7 @@ export class SyncClient {
2064
2346
  ...(operation !== undefined ? { operation } : {}),
2065
2347
  };
2066
2348
  this.#conflicts.push(conflict);
2349
+ batch.conflicts();
2067
2350
  summary.conflicts.push(conflict);
2068
2351
  this.#config.onConflict?.(conflict);
2069
2352
  } else if (result.status === 'error') {
@@ -2075,6 +2358,7 @@ export class SyncClient {
2075
2358
  retryable: result.retryable,
2076
2359
  ...(operation !== undefined ? { operation } : {}),
2077
2360
  });
2361
+ batch.rejections();
2078
2362
  }
2079
2363
  }
2080
2364
  // §7.2: stop optimistic display and decide about dependents — the
@@ -2092,10 +2376,14 @@ export class SyncClient {
2092
2376
  [operation.rowId],
2093
2377
  )[0];
2094
2378
  if (row !== undefined && row.v === OPTIMISTIC_VERSION) {
2379
+ if (!this.#recordStoredRowScopes(batch, table, operation.rowId)) {
2380
+ batch.table(table.name);
2381
+ }
2095
2382
  deleteLocalRow(this.#db, table, operation.rowId);
2096
2383
  }
2097
2384
  }
2098
2385
  });
2386
+ batch.status();
2099
2387
  summary.rejected.push(frame.clientCommitId);
2100
2388
  }
2101
2389
 
@@ -2118,23 +2406,88 @@ export class SyncClient {
2118
2406
  frame: CommitFrame,
2119
2407
  summary: MutableSummary,
2120
2408
  ): Promise<void> {
2121
- await applyCommitFrame(this.#db, this.#schema, frame, this.#encryption);
2409
+ await applyCommitFrame(
2410
+ this.#db,
2411
+ this.#schema,
2412
+ frame,
2413
+ this.#encryption,
2414
+ (fn) =>
2415
+ this.#applyBatch((batch) => {
2416
+ this.#recordCommitChanges(batch, frame);
2417
+ return fn();
2418
+ }),
2419
+ );
2122
2420
  summary.commitsApplied += 1;
2123
- // I1/I2: record touched tables and precise `prefix:value` scope keys —
2124
- // COMMIT changes carry per-row stored scopes (§4.5), the finest honest
2125
- // invalidation the wire provides.
2126
- const batch = this.#batch;
2127
- if (batch === undefined) return;
2421
+ }
2422
+
2423
+ /** Record before + after scope keys while the commit transaction is open. */
2424
+ #recordCommitChanges(batch: ChangeAccumulator, frame: CommitFrame): void {
2128
2425
  for (const change of frame.changes) {
2129
2426
  const tableName = frame.tables[change.tableIndex];
2130
2427
  if (tableName === undefined) continue;
2131
2428
  const table = this.#schema.tables.get(tableName);
2132
2429
  if (table === undefined) continue;
2133
- batch.table(tableName);
2430
+ let precise = this.#recordStoredRowScopes(batch, table, change.rowId);
2431
+ for (const variable of Object.keys(change.scopes)) {
2432
+ if (table.scopePrefixByVariable.has(variable)) precise = true;
2433
+ }
2134
2434
  batch.changeScopes(table, change.scopes);
2435
+ if (!precise) batch.table(tableName);
2135
2436
  }
2136
2437
  }
2137
2438
 
2439
+ /** Add the currently materialized row's scope keys; returns whether known. */
2440
+ #recordStoredRowScopes(
2441
+ batch: ChangeAccumulator,
2442
+ table: CompiledClientTable,
2443
+ rowId: string,
2444
+ ): boolean {
2445
+ const mappings = [...table.scopeColumnByVariable].filter(([variable]) =>
2446
+ table.scopePrefixByVariable.has(variable),
2447
+ );
2448
+ if (mappings.length === 0) return false;
2449
+ const row = this.#db.query(
2450
+ `SELECT ${mappings.map(([, column]) => quoteIdent(column)).join(', ')}
2451
+ FROM ${quoteIdent(table.name)}
2452
+ WHERE ${quoteIdent(table.primaryKey)} = ?`,
2453
+ [rowId],
2454
+ )[0];
2455
+ if (row === undefined) return false;
2456
+ let recorded = false;
2457
+ for (const [variable, column] of mappings) {
2458
+ const value = row[column];
2459
+ const prefix = table.scopePrefixByVariable.get(variable);
2460
+ if (value != null && prefix !== undefined) {
2461
+ batch.scope(table.name, `${prefix}:${String(value)}`);
2462
+ recorded = true;
2463
+ }
2464
+ }
2465
+ return recorded;
2466
+ }
2467
+
2468
+ /** Whether a fresh-bootstrap clear would remove at least one local row. */
2469
+ #scopedRowsExist(table: CompiledClientTable, effective: ScopeMap): boolean {
2470
+ const entries = Object.entries(effective);
2471
+ if (entries.length === 0) return false;
2472
+ const clauses: string[] = [];
2473
+ const params: string[] = [];
2474
+ for (const [variable, values] of entries) {
2475
+ const column = table.scopeColumnByVariable.get(variable);
2476
+ if (column === undefined || values.length === 0) return false;
2477
+ clauses.push(
2478
+ `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
2479
+ );
2480
+ params.push(...values);
2481
+ }
2482
+ return (
2483
+ this.#db.query(
2484
+ `SELECT 1 FROM ${quoteIdent(table.name)}
2485
+ WHERE ${clauses.join(' AND ')} LIMIT 1`,
2486
+ params,
2487
+ ).length > 0
2488
+ );
2489
+ }
2490
+
2138
2491
  /**
2139
2492
  * Apply a segment (rows or sqlite image); a §5.6/§3.3 fail-closed error
2140
2493
  * (no local scope-column mapping) marks the subscription `failed` and
@@ -2160,27 +2513,28 @@ export class SyncClient {
2160
2513
  section.start.effectiveScopes,
2161
2514
  );
2162
2515
  section.cleared = true;
2163
- // I1/I2: segments carry only a table + scopeDigest, never per-row
2164
- // scope keys — invalidate the table plus the subscription's effective
2165
- // scope keys (the coarsest honest key for bulk data).
2166
- this.#batch?.table(table.name);
2167
- this.#batch?.scopeMap(table, section.start.effectiveScopes);
2168
2516
  } catch (error) {
2169
2517
  if (
2170
2518
  error instanceof ClientSyncError &&
2171
2519
  error.code === 'sync.scope_revoked'
2172
2520
  ) {
2173
- saveSubscription(this.#db, {
2174
- id: sub.id,
2175
- table: sub.table,
2176
- scopes: sub.scopes,
2177
- ...(sub.params !== undefined ? { params: sub.params } : {}),
2178
- cursor: sub.cursor,
2179
- ...(sub.effectiveScopes !== undefined
2180
- ? { effectiveScopes: sub.effectiveScopes }
2181
- : {}),
2182
- status: 'failed',
2183
- reasonCode: 'sync.scope_revoked',
2521
+ const registered = getWindowUnitBySubId(this.#db, sub.id);
2522
+ this.#applyBatch((batch) => {
2523
+ saveSubscription(this.#db, {
2524
+ id: sub.id,
2525
+ table: sub.table,
2526
+ scopes: sub.scopes,
2527
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
2528
+ cursor: sub.cursor,
2529
+ ...(sub.effectiveScopes !== undefined
2530
+ ? { effectiveScopes: sub.effectiveScopes }
2531
+ : {}),
2532
+ status: 'failed',
2533
+ reasonCode: 'sync.scope_revoked',
2534
+ });
2535
+ if (registered !== undefined) {
2536
+ batch.window(registered.baseKey, sub.table, registered.unit);
2537
+ }
2184
2538
  });
2185
2539
  summary.failed.push(sub.id);
2186
2540
  section.skip = true;
@@ -2264,39 +2618,49 @@ export class SyncClient {
2264
2618
  // An absent bootstrapState clears any previous resume token (§4.4:
2265
2619
  // absent = bootstrap complete, or not bootstrapping).
2266
2620
  const wasPending = sub.cursor < 0 || sub.bootstrapState !== undefined;
2267
- saveSubscription(this.#db, {
2268
- id: sub.id,
2269
- table: sub.table,
2270
- scopes: sub.scopes,
2271
- ...(sub.params !== undefined ? { params: sub.params } : {}),
2272
- cursor: nextCursor,
2273
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
2274
- effectiveScopes: start.effectiveScopes,
2275
- status: 'active',
2621
+ const completed =
2622
+ wasPending && nextCursor >= 0 && bootstrapState === undefined;
2623
+ const registered = getWindowUnitBySubId(this.#db, sub.id);
2624
+ this.#applyBatch((batch) => {
2625
+ saveSubscription(this.#db, {
2626
+ id: sub.id,
2627
+ table: sub.table,
2628
+ scopes: sub.scopes,
2629
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
2630
+ cursor: nextCursor,
2631
+ ...(bootstrapState !== undefined ? { bootstrapState } : {}),
2632
+ effectiveScopes: start.effectiveScopes,
2633
+ status: 'active',
2634
+ });
2635
+ if (completed && registered !== undefined) {
2636
+ // A zero-row bootstrap is a window-domain transition, not a fake
2637
+ // row/table change (SPEC §4.8 / §7.5).
2638
+ batch.window(registered.baseKey, sub.table, registered.unit);
2639
+ }
2276
2640
  });
2277
- if (wasPending && nextCursor >= 0 && bootstrapState === undefined) {
2278
- // §4.8: the completeness verdict flipped pending → complete. A
2279
- // zero-row bootstrap applies nothing, so the flip itself must reach
2280
- // live oracles through the choke point (shares the pull's batch).
2281
- this.#applyBatch((batch) => batch.table(sub.table));
2282
- }
2283
2641
  return true;
2284
2642
  }
2285
2643
 
2286
2644
  if (start.status === 'reset') {
2287
2645
  // §4.6: discard cursor + resume token, keep local rows, re-bootstrap
2288
2646
  // with cursor = -1 on the next pull. Staleness, not a purge.
2289
- saveSubscription(this.#db, {
2290
- id: sub.id,
2291
- table: sub.table,
2292
- scopes: sub.scopes,
2293
- ...(sub.params !== undefined ? { params: sub.params } : {}),
2294
- cursor: -1,
2295
- ...(sub.effectiveScopes !== undefined
2296
- ? { effectiveScopes: sub.effectiveScopes }
2297
- : {}),
2298
- status: 'active',
2299
- reasonCode: start.reasonCode,
2647
+ const registered = getWindowUnitBySubId(this.#db, sub.id);
2648
+ this.#applyBatch((batch) => {
2649
+ saveSubscription(this.#db, {
2650
+ id: sub.id,
2651
+ table: sub.table,
2652
+ scopes: sub.scopes,
2653
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
2654
+ cursor: -1,
2655
+ ...(sub.effectiveScopes !== undefined
2656
+ ? { effectiveScopes: sub.effectiveScopes }
2657
+ : {}),
2658
+ status: 'active',
2659
+ reasonCode: start.reasonCode,
2660
+ });
2661
+ if (registered !== undefined) {
2662
+ batch.window(registered.baseKey, sub.table, registered.unit);
2663
+ }
2300
2664
  });
2301
2665
  summary.resets.push(sub.id);
2302
2666
  return false;
@@ -2306,44 +2670,48 @@ export class SyncClient {
2306
2670
  // (never the requested map), drop doomed outbox commits, stop pulling.
2307
2671
  const table = this.#table(sub.table);
2308
2672
  const lastEffective = sub.effectiveScopes;
2673
+ const registered = getWindowUnitBySubId(this.#db, sub.id);
2309
2674
  let failed = false;
2310
- if (lastEffective !== undefined && Object.keys(lastEffective).length > 0) {
2311
- try {
2312
- this.#db.transaction(() => {
2675
+ this.#applyBatch((batch) => {
2676
+ if (
2677
+ lastEffective !== undefined &&
2678
+ Object.keys(lastEffective).length > 0
2679
+ ) {
2680
+ try {
2313
2681
  deleteScopedRows(this.#db, table, lastEffective);
2314
- });
2315
- // I1: the §3.3 purge is a bulk delete — invalidate the table + the
2316
- // purged effective scope keys so live queries over them re-run.
2317
- this.#batch?.table(table.name);
2318
- this.#batch?.scopeMap(table, lastEffective);
2319
- dropOutboxCommitsInScope(this.#db, table, lastEffective);
2320
- // §5.9.7 B2: revocation deletes now-unauthorized blob bodies —
2321
- // reconcile with deleteOrphans (evicted ≠ revoked).
2322
- this.#reconcileBlobs(true);
2323
- } catch (error) {
2324
- if (
2325
- error instanceof ClientSyncError &&
2326
- error.code === 'sync.scope_revoked'
2327
- ) {
2328
- // Fail closed: no local mapping — surface a fatal configuration
2329
- // error and stop syncing the table without clearing anything.
2330
- failed = true;
2331
- } else {
2332
- throw error;
2682
+ batch.scopeMap(table, lastEffective);
2683
+ if (
2684
+ dropOutboxCommitsInScope(this.#db, table, lastEffective).length > 0
2685
+ ) {
2686
+ batch.status();
2687
+ }
2688
+ this.#reconcileBlobs(true);
2689
+ } catch (error) {
2690
+ if (
2691
+ error instanceof ClientSyncError &&
2692
+ error.code === 'sync.scope_revoked'
2693
+ ) {
2694
+ failed = true;
2695
+ } else {
2696
+ throw error;
2697
+ }
2333
2698
  }
2334
2699
  }
2335
- }
2336
- saveSubscription(this.#db, {
2337
- id: sub.id,
2338
- table: sub.table,
2339
- scopes: sub.scopes,
2340
- ...(sub.params !== undefined ? { params: sub.params } : {}),
2341
- cursor: nextCursor,
2342
- ...(lastEffective !== undefined
2343
- ? { effectiveScopes: lastEffective }
2344
- : {}),
2345
- status: failed ? 'failed' : 'revoked',
2346
- reasonCode: start.reasonCode,
2700
+ saveSubscription(this.#db, {
2701
+ id: sub.id,
2702
+ table: sub.table,
2703
+ scopes: sub.scopes,
2704
+ ...(sub.params !== undefined ? { params: sub.params } : {}),
2705
+ cursor: nextCursor,
2706
+ ...(lastEffective !== undefined
2707
+ ? { effectiveScopes: lastEffective }
2708
+ : {}),
2709
+ status: failed ? 'failed' : 'revoked',
2710
+ reasonCode: start.reasonCode,
2711
+ });
2712
+ if (registered !== undefined) {
2713
+ batch.window(registered.baseKey, sub.table, registered.unit);
2714
+ }
2347
2715
  });
2348
2716
  summary.revoked.push(sub.id);
2349
2717
  if (failed) summary.failed.push(sub.id);
@@ -2354,12 +2722,16 @@ export class SyncClient {
2354
2722
 
2355
2723
  #applyOperationsLocally(
2356
2724
  operations: readonly OutboxOperation[],
2357
- batch?: Invalidation,
2725
+ batch?: ChangeAccumulator,
2358
2726
  ): void {
2359
2727
  for (const op of operations) {
2360
2728
  const table = this.#table(op.table);
2361
- batch?.table(op.table);
2729
+ let precise =
2730
+ batch === undefined
2731
+ ? false
2732
+ : this.#recordStoredRowScopes(batch, table, op.rowId);
2362
2733
  if (op.op === 'delete') {
2734
+ if (batch !== undefined && !precise) batch.table(op.table);
2363
2735
  deleteLocalRow(this.#db, table, op.rowId);
2364
2736
  continue;
2365
2737
  }
@@ -2374,9 +2746,11 @@ export class SyncClient {
2374
2746
  const cell = idx === undefined ? undefined : values[idx];
2375
2747
  const prefix = table.scopePrefixByVariable.get(variable);
2376
2748
  if (prefix !== undefined && cell != null) {
2377
- batch.scopeKey(`${prefix}:${String(cell)}`);
2749
+ batch.scope(table.name, `${prefix}:${String(cell)}`);
2750
+ precise = true;
2378
2751
  }
2379
2752
  }
2753
+ if (!precise) batch.table(table.name);
2380
2754
  }
2381
2755
  const existing = this.#db.query(
2382
2756
  `SELECT ${quoteIdent(SYNC_VERSION_COLUMN)} AS v FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,