@syncular/client 0.15.47 → 0.16.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/src/client.ts CHANGED
@@ -119,6 +119,8 @@ import {
119
119
  dropOutboxCommitsInScope,
120
120
  encodeOutboxCommit,
121
121
  listOutbox,
122
+ iterateOutbox,
123
+ countOutbox,
122
124
  listOutboxBeforeImages,
123
125
  type OutboxBeforeImage,
124
126
  type OutboxCommit,
@@ -513,6 +515,8 @@ function emptySummary(pushed: number): MutableSummary {
513
515
  };
514
516
  }
515
517
 
518
+ const LOG_EPOCH_META_KEY = 'logEpoch';
519
+
516
520
  function isFinalPushResult(frame: PushResultFrame): boolean {
517
521
  return (
518
522
  frame.status !== 'rejected' ||
@@ -525,6 +529,37 @@ function isFinalPushResult(frame: PushResultFrame): boolean {
525
529
  );
526
530
  }
527
531
 
532
+ /** Canonical client reads, shared by synchronous cores and promise hosts. */
533
+ export type ClientSnapshotMethods = Pick<
534
+ SyncClient,
535
+ | 'querySnapshot'
536
+ | 'statusSnapshot'
537
+ | 'diagnosticsSnapshot'
538
+ | 'conflicts'
539
+ | 'rejections'
540
+ | 'commitOutcome'
541
+ | 'commitOutcomes'
542
+ | 'resolveCommitOutcome'
543
+ >;
544
+
545
+ /** Project a method contract across an asynchronous host boundary. */
546
+ export type PromiseMethods<Methods> = {
547
+ [Key in keyof Methods]: Methods[Key] extends (
548
+ ...args: infer Args
549
+ ) => infer Result
550
+ ? (...args: Args) => Promise<Awaited<Result>>
551
+ : never;
552
+ };
553
+
554
+ /** A reader can execute locally or cross a worker/native boundary. */
555
+ export type ClientSnapshotReader = {
556
+ [Key in keyof ClientSnapshotMethods]: (
557
+ ...args: Parameters<ClientSnapshotMethods[Key]>
558
+ ) =>
559
+ | ReturnType<ClientSnapshotMethods[Key]>
560
+ | Promise<ReturnType<ClientSnapshotMethods[Key]>>;
561
+ };
562
+
528
563
  export class SyncClient {
529
564
  readonly #config: SyncClientConfig;
530
565
  readonly #db: ClientDatabase;
@@ -579,6 +614,10 @@ export class SyncClient {
579
614
  readonly #invalidation = new InvalidationEmitter();
580
615
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
581
616
  readonly #presenceListeners = new Set<(scopeKey: string) => void>();
617
+ readonly #syncNeededListeners = new Set<
618
+ (reason: 'startup' | 'hello' | WakeReason) => void
619
+ >();
620
+ readonly #syncIntentListeners = new Set<(intent: SyncIntent) => void>();
582
621
  readonly #diagnostics = new ClientDiagnosticsEmitter();
583
622
  #diagnosticsDeferralDepth = 0;
584
623
  #diagnosticsPending = false;
@@ -701,12 +740,12 @@ export class SyncClient {
701
740
  // an application-issued sync() call.
702
741
  const startupWork =
703
742
  this.#schemaFloor === undefined &&
704
- (listOutbox(this.#db).length > 0 ||
743
+ (countOutbox(this.#db) > 0 ||
705
744
  subscriptions.some((sub) => sub.status === 'active'));
706
745
  if (startupWork && this.#securityLifecycle === 'active') {
707
746
  this.#needsPull = true;
708
- this.#config.onSyncNeeded?.('startup');
709
- this.#config.onSyncIntent?.({ kind: 'interactive' });
747
+ this.#emitSyncNeeded('startup');
748
+ this.#emitSyncIntent({ kind: 'interactive' });
710
749
  }
711
750
  // Console introspection is a no-op outside a dev page.
712
751
  this.#devtoolsUnregister = registerDevtools({
@@ -716,10 +755,10 @@ export class SyncClient {
716
755
  role: () => 'direct',
717
756
  outbox: async () => this.pendingCommits().length,
718
757
  subscriptions: async () => this.subscriptions(),
719
- conflicts: async () => this.conflicts.length,
720
- rejections: async () => this.rejections.length,
721
- syncNeeded: async () => this.syncNeeded,
722
- upgrading: async () => this.upgrading,
758
+ conflicts: async () => this.conflicts().length,
759
+ rejections: async () => this.rejections().length,
760
+ syncNeeded: async () => this.statusSnapshot().syncNeeded,
761
+ upgrading: async () => this.statusSnapshot().upgrading,
723
762
  onInvalidate: (listener) => this.onInvalidate(listener),
724
763
  });
725
764
  this.#emitDiagnostics();
@@ -776,6 +815,29 @@ export class SyncClient {
776
815
  this.#replayOutbox();
777
816
  }
778
817
 
818
+ /** §2.1 reset after the server reports a different log continuity. */
819
+ #runLogEpochReset(logEpoch: string): string[] {
820
+ const subscriptions = loadSubscriptions(this.#db);
821
+ const pending = listOutbox(this.#db);
822
+ this.#setUpgrading(true);
823
+ this.#applyBatch((batch) => {
824
+ this.#db.transaction(() => {
825
+ dropAndRecreateSyncedTables(this.#db, this.#schema);
826
+ resetSubscriptionsForBump(this.#db);
827
+ setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
828
+ for (const commit of pending) {
829
+ this.#applyOperationsLocally(commit.operations, batch);
830
+ }
831
+ });
832
+ for (const table of this.#schema.tables.values()) batch.table(table.name);
833
+ });
834
+ this.#localResetEpoch += 1;
835
+ this.#setSyncNeeded(true);
836
+ this.#emitSyncNeeded('startup');
837
+ this.#emitSyncIntent({ kind: 'interactive' });
838
+ return subscriptions.map((subscription) => subscription.id);
839
+ }
840
+
779
841
  #setUpgrading(upgrading: boolean): void {
780
842
  if (this.#upgrading === upgrading) return;
781
843
  this.#applyBatch((batch) => {
@@ -803,6 +865,7 @@ export class SyncClient {
803
865
  }
804
866
 
805
867
  async close(): Promise<void> {
868
+ this.#emitSyncIntent({ kind: 'none' });
806
869
  this.#devtoolsUnregister?.();
807
870
  this.#devtoolsUnregister = undefined;
808
871
  this.disconnectRealtime();
@@ -810,10 +873,42 @@ export class SyncClient {
810
873
  await this.#lease?.release();
811
874
  this.#lease = undefined;
812
875
  this.#started = false;
876
+ this.#syncNeededListeners.clear();
877
+ this.#syncIntentListeners.clear();
878
+ }
879
+
880
+ #emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
881
+ try {
882
+ this.#config.onSyncNeeded?.(reason);
883
+ } catch {
884
+ // An observer cannot alter sync correctness.
885
+ }
886
+ for (const listener of this.#syncNeededListeners) {
887
+ try {
888
+ listener(reason);
889
+ } catch {
890
+ // An observer cannot alter sync correctness.
891
+ }
892
+ }
893
+ }
894
+
895
+ #emitSyncIntent(intent: SyncIntent): void {
896
+ try {
897
+ this.#config.onSyncIntent?.(intent);
898
+ } catch {
899
+ // An observer cannot alter sync correctness.
900
+ }
901
+ for (const listener of this.#syncIntentListeners) {
902
+ try {
903
+ listener(intent);
904
+ } catch {
905
+ // An observer cannot alter sync correctness.
906
+ }
907
+ }
813
908
  }
814
909
 
815
910
  /** Current fail-closed local-replica security state. */
816
- get securityLifecycle(): SecurityLifecycle {
911
+ securityLifecycle(): SecurityLifecycle {
817
912
  return this.#securityLifecycle;
818
913
  }
819
914
 
@@ -865,12 +960,12 @@ export class SyncClient {
865
960
  this.#securityLifecycle = 'active';
866
961
  const startupWork =
867
962
  this.#schemaFloor === undefined &&
868
- (listOutbox(this.#db).length > 0 ||
963
+ (countOutbox(this.#db) > 0 ||
869
964
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
870
965
  if (startupWork) {
871
966
  this.#setSyncNeeded(true);
872
- this.#config.onSyncNeeded?.('startup');
873
- this.#config.onSyncIntent?.({ kind: 'interactive' });
967
+ this.#emitSyncNeeded('startup');
968
+ this.#emitSyncIntent({ kind: 'interactive' });
874
969
  }
875
970
  this.#emitDiagnostics();
876
971
  }
@@ -982,6 +1077,24 @@ export class SyncClient {
982
1077
  return this.#changes.on(listener);
983
1078
  }
984
1079
 
1080
+ /** Subscribe to host wake signals raised by startup and realtime. */
1081
+ onSyncNeeded(
1082
+ listener: (reason: 'startup' | 'hello' | WakeReason) => void,
1083
+ ): () => void {
1084
+ this.#syncNeededListeners.add(listener);
1085
+ return () => {
1086
+ this.#syncNeededListeners.delete(listener);
1087
+ };
1088
+ }
1089
+
1090
+ /** Subscribe to exact core-owned scheduling instructions. */
1091
+ onSyncIntent(listener: (intent: SyncIntent) => void): () => void {
1092
+ this.#syncIntentListeners.add(listener);
1093
+ return () => {
1094
+ this.#syncIntentListeners.delete(listener);
1095
+ };
1096
+ }
1097
+
985
1098
  /** Subscribe to complete, privacy-safe diagnostic snapshots. */
986
1099
  onDiagnostics(listener: ClientDiagnosticsListener): () => void {
987
1100
  return this.#diagnostics.on(listener);
@@ -1114,7 +1227,7 @@ export class SyncClient {
1114
1227
  replica: {
1115
1228
  localRevision: getLocalRevision(this.#db).toString(),
1116
1229
  syncNeeded: this.#needsPull,
1117
- pendingOutbox: listOutbox(this.#db).length,
1230
+ pendingOutbox: countOutbox(this.#db),
1118
1231
  },
1119
1232
  lease: leaseState,
1120
1233
  subscriptions: allSubscriptions.slice(
@@ -1230,7 +1343,7 @@ export class SyncClient {
1230
1343
  #statusSnapshot(outboxCount?: number): SyncStatusSnapshot {
1231
1344
  return {
1232
1345
  currentSchemaVersion: this.#config.schema.version,
1233
- outbox: outboxCount ?? listOutbox(this.#db).length,
1346
+ outbox: outboxCount ?? countOutbox(this.#db),
1234
1347
  upgrading: this.#upgrading,
1235
1348
  leaseState: this.#leaseState,
1236
1349
  schemaFloor: this.#schemaFloor,
@@ -1526,12 +1639,12 @@ export class SyncClient {
1526
1639
  await transport.upload(blobId, bytes, mediaType);
1527
1640
  }
1528
1641
 
1529
- get conflicts(): readonly ConflictRecord[] {
1642
+ conflicts(): readonly ConflictRecord[] {
1530
1643
  this.#requireActive();
1531
1644
  return this.#conflicts;
1532
1645
  }
1533
1646
 
1534
- get rejections(): readonly RejectionRecord[] {
1647
+ rejections(): readonly RejectionRecord[] {
1535
1648
  this.#requireActive();
1536
1649
  return this.#rejections;
1537
1650
  }
@@ -1622,30 +1735,6 @@ export class SyncClient {
1622
1735
  });
1623
1736
  }
1624
1737
 
1625
- /** Non-undefined once the server declared a schema floor (§1.6). */
1626
- get schemaFloor(): SchemaFloor | undefined {
1627
- return this.#schemaFloor;
1628
- }
1629
-
1630
- /**
1631
- * §7.4.5: true while a schema-bump reset + first re-bootstrap is in
1632
- * flight — the app's "upgrading…" cue. Clears when the first post-reset
1633
- * bootstrap round reaches idle (every subscription past its fresh
1634
- * bootstrap).
1635
- */
1636
- get upgrading(): boolean {
1637
- return this.#upgrading;
1638
- }
1639
-
1640
- /**
1641
- * §7.3.5: the current auth-lease state (opaque). Undefined until a
1642
- * `LEASE` frame arrives. `errorCode` is set when a round was rejected
1643
- * with a request-level lease code — syncing on the lease has stopped.
1644
- */
1645
- get leaseState(): LeaseState | undefined {
1646
- return this.#leaseState;
1647
- }
1648
-
1649
1738
  /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
1650
1739
  * `undefined` if no lease is held. Negative once expired. */
1651
1740
  leaseRemainingMs(now: number = this.#now()): number | undefined {
@@ -1658,11 +1747,6 @@ export class SyncClient {
1658
1747
  return this.#schemaFloor !== undefined;
1659
1748
  }
1660
1749
 
1661
- /** §8: a hello/wake-up asked for a pull that has not run yet. */
1662
- get syncNeeded(): boolean {
1663
- return this.#needsPull;
1664
- }
1665
-
1666
1750
  /**
1667
1751
  * §8.6 presence on a scope key: the current peers present there (a map
1668
1752
  * of `actorId clientId` → peer). Empty for a key with no present peers.
@@ -1781,12 +1865,17 @@ export class SyncClient {
1781
1865
  cursor: -1,
1782
1866
  status: 'active',
1783
1867
  });
1868
+ this.#setSyncNeeded(true);
1869
+ this.#emitSyncIntent({ kind: 'interactive' });
1784
1870
  this.#emitDiagnostics();
1785
1871
  }
1786
1872
 
1787
1873
  unsubscribe(id: string): void {
1788
1874
  this.#requireActive();
1875
+ if (getSubscription(this.#db, id) === undefined) return;
1789
1876
  deleteSubscription(this.#db, id);
1877
+ this.#setSyncNeeded(true);
1878
+ this.#emitSyncIntent({ kind: 'interactive' });
1790
1879
  this.#emitDiagnostics();
1791
1880
  }
1792
1881
 
@@ -1851,7 +1940,9 @@ export class SyncClient {
1851
1940
  status: 'active',
1852
1941
  });
1853
1942
  });
1943
+ this.#needsPull = true;
1854
1944
  batch.window(baseKey, base.table, unit);
1945
+ batch.status();
1855
1946
  });
1856
1947
  changed = true;
1857
1948
  widened = true;
@@ -1867,6 +1958,9 @@ export class SyncClient {
1867
1958
  const effects: CommandEffects = {
1868
1959
  sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1869
1960
  };
1961
+ if (effects.sync.kind === 'interactive') {
1962
+ this.#emitSyncIntent(effects.sync);
1963
+ }
1870
1964
  return { value: undefined, effects };
1871
1965
  }
1872
1966
 
@@ -1934,6 +2028,8 @@ export class SyncClient {
1934
2028
  });
1935
2029
  batch.scopeMap(table, effective);
1936
2030
  batch.window(baseKey, table.name, unit);
2031
+ this.#needsPull = true;
2032
+ batch.status();
1937
2033
  });
1938
2034
  }
1939
2035
 
@@ -2043,7 +2139,9 @@ export class SyncClient {
2043
2139
  this.#applyOperationsLocally(operations, batch);
2044
2140
  batch.status();
2045
2141
  });
2142
+ this.#needsPull = true;
2046
2143
  });
2144
+ this.#emitSyncIntent({ kind: 'interactive' });
2047
2145
  return clientCommitId;
2048
2146
  }
2049
2147
 
@@ -2320,8 +2418,8 @@ export class SyncClient {
2320
2418
  this.#localResetEpoch += 1;
2321
2419
 
2322
2420
  if (!priorUpgrading) this.#config.onUpgrading?.(true);
2323
- this.#config.onSyncNeeded?.('startup');
2324
- this.#config.onSyncIntent?.({ kind: 'interactive' });
2421
+ this.#emitSyncNeeded('startup');
2422
+ this.#emitSyncIntent({ kind: 'interactive' });
2325
2423
  return {
2326
2424
  alreadyApplied: false,
2327
2425
  retainedCommits: pending.length,
@@ -2408,12 +2506,19 @@ export class SyncClient {
2408
2506
  outbox: OutboxCommit[];
2409
2507
  deferred: number;
2410
2508
  }> {
2411
- const pending = listOutbox(this.#db);
2509
+ // Pin before the first encryption await: mutations can append while a
2510
+ // round is encoding, and belong to the next request.
2511
+ const bounds = this.#db.query(
2512
+ 'SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox',
2513
+ )[0]!;
2514
+ const pendingCount = bounds.count as number;
2515
+ const throughSeq = (bounds.last_seq as number | null) ?? 0;
2412
2516
  const pushFrames: RequestFrame[] = [];
2413
2517
  const outbox: OutboxCommit[] = [];
2414
2518
  let deferred = 0;
2415
2519
  let ops = 0;
2416
- for (const commit of pending) {
2520
+ let processed = 0;
2521
+ for (const commit of iterateOutbox(this.#db, throughSeq)) {
2417
2522
  // §6.1 splitBatch: whole commits in commit order, stopping before the
2418
2523
  // per-request operation cap. A first commit that alone exceeds the cap
2419
2524
  // is sent alone — the server rejects it loudly rather than the queue
@@ -2422,9 +2527,10 @@ export class SyncClient {
2422
2527
  outbox.length > 0 &&
2423
2528
  ops + commit.operations.length > MAX_OPS_PER_REQUEST
2424
2529
  ) {
2425
- deferred += 1;
2426
- continue;
2530
+ deferred = pendingCount - processed;
2531
+ break;
2427
2532
  }
2533
+ processed += 1;
2428
2534
  try {
2429
2535
  pushFrames.push(
2430
2536
  // §5.11: encrypted columns are encrypted at this encode-at-send
@@ -2585,9 +2691,14 @@ export class SyncClient {
2585
2691
  // survive it — the reference server keeps no replay buffer (§8.2).
2586
2692
  this.#setSyncNeeded(false);
2587
2693
  try {
2694
+ const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
2588
2695
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
2589
2696
  // them, so the server-side existence check (§6.6) passes.
2590
- if (this.#hasBlobs && this.#config.blobs !== undefined) {
2697
+ if (
2698
+ logEpoch !== undefined &&
2699
+ this.#hasBlobs &&
2700
+ this.#config.blobs !== undefined
2701
+ ) {
2591
2702
  await this.flushBlobUploads();
2592
2703
  }
2593
2704
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
@@ -2596,7 +2707,9 @@ export class SyncClient {
2596
2707
  // the queue. `pushFrames` and `outbox` stay index-aligned for result
2597
2708
  // mapping.
2598
2709
  const { pushFrames, outbox, deferred } =
2599
- await this.#encodeOutboxForPush();
2710
+ logEpoch === undefined
2711
+ ? { pushFrames: [], outbox: [], deferred: 0 }
2712
+ : await this.#encodeOutboxForPush();
2600
2713
  // Captured together with the subscription state below: the response
2601
2714
  // apply persists SUB_END cursors only while this epoch is current.
2602
2715
  const resetEpoch = this.#localResetEpoch;
@@ -2609,6 +2722,7 @@ export class SyncClient {
2609
2722
  type: 'REQ_HEADER',
2610
2723
  clientId: this.#clientId,
2611
2724
  schemaVersion: this.#schema.version,
2725
+ ...(logEpoch !== undefined ? { logEpoch } : {}),
2612
2726
  },
2613
2727
  ...pushFrames,
2614
2728
  {
@@ -2686,11 +2800,7 @@ export class SyncClient {
2686
2800
  delayMs: this.#retryDelayMs,
2687
2801
  };
2688
2802
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
2689
- try {
2690
- this.#config.onSyncIntent?.(intent);
2691
- } catch {
2692
- // An observer cannot alter sync correctness.
2693
- }
2803
+ this.#emitSyncIntent(intent);
2694
2804
  }
2695
2805
  throw error;
2696
2806
  } finally {
@@ -2712,7 +2822,8 @@ export class SyncClient {
2712
2822
  last.segmentRowsApplied === 0 &&
2713
2823
  last.bootstrapping.length === 0 &&
2714
2824
  last.resets.length === 0 &&
2715
- (last.deferredCommits ?? 0) === 0
2825
+ (last.deferredCommits ?? 0) === 0 &&
2826
+ !this.#needsPull
2716
2827
  ) {
2717
2828
  return last;
2718
2829
  }
@@ -2916,14 +3027,14 @@ export class SyncClient {
2916
3027
  if (event.event === 'hello') {
2917
3028
  if (event.data.requiresSync) {
2918
3029
  this.#setSyncNeeded(true);
2919
- this.#config.onSyncNeeded?.('hello');
3030
+ this.#emitSyncNeeded('hello');
2920
3031
  }
2921
3032
  return;
2922
3033
  }
2923
3034
  if (event.event === 'sync') {
2924
3035
  // §8.3: any wake-up means "run a pull soon", never data.
2925
3036
  this.#setSyncNeeded(true);
2926
- this.#config.onSyncNeeded?.(event.data.reason);
3037
+ this.#emitSyncNeeded(event.data.reason);
2927
3038
  return;
2928
3039
  }
2929
3040
  if (event.event === 'presence') {
@@ -2995,7 +3106,7 @@ export class SyncClient {
2995
3106
  } catch {
2996
3107
  // A delta that cannot be applied is recovered by a pull (§8.3).
2997
3108
  this.#setSyncNeeded(true);
2998
- this.#config.onSyncNeeded?.('catchup-required');
3109
+ this.#emitSyncNeeded('catchup-required');
2999
3110
  }
3000
3111
  });
3001
3112
  }
@@ -3057,6 +3168,16 @@ export class SyncClient {
3057
3168
  if (header?.type !== 'RESP_HEADER') {
3058
3169
  throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
3059
3170
  }
3171
+ if (
3172
+ message.wireVersion < 2 ||
3173
+ header.logEpoch === undefined ||
3174
+ header.resetRequired === undefined
3175
+ ) {
3176
+ throw new ClientSyncError(
3177
+ 'client.invalid_host_response',
3178
+ 'the server response does not carry wire version 2 log-epoch state',
3179
+ );
3180
+ }
3060
3181
  if (header.requiredSchemaVersion !== undefined) {
3061
3182
  // §1.6 schema floor: nothing else was processed — stop syncing and
3062
3183
  // surface the upgrade requirement. A live-round floor always stops:
@@ -3078,6 +3199,26 @@ export class SyncClient {
3078
3199
  schemaFloor,
3079
3200
  };
3080
3201
  }
3202
+ const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
3203
+ if (header.resetRequired) {
3204
+ if (mode !== 'pull' || message.frames.length !== 1) {
3205
+ throw new ClientSyncError(
3206
+ 'client.invalid_host_response',
3207
+ 'a log-epoch reset response must contain only RESP_HEADER',
3208
+ );
3209
+ }
3210
+ return {
3211
+ ...summary,
3212
+ resets: this.#runLogEpochReset(header.logEpoch),
3213
+ bootstrapping: [],
3214
+ };
3215
+ }
3216
+ if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
3217
+ throw new ClientSyncError(
3218
+ 'client.invalid_host_response',
3219
+ 'the server changed logEpoch without requiring a reset',
3220
+ );
3221
+ }
3081
3222
 
3082
3223
  let section: OpenSection | undefined;
3083
3224
  let errorFrame: ClientSyncError | undefined;
@@ -3101,8 +3242,7 @@ export class SyncClient {
3101
3242
  });
3102
3243
  break;
3103
3244
  case 'PUSH_RESULT': {
3104
- let outboxCount =
3105
- responseOutboxCount ?? listOutbox(this.#db).length;
3245
+ let outboxCount = responseOutboxCount ?? countOutbox(this.#db);
3106
3246
  this.#applyBatch(
3107
3247
  (batch) => {
3108
3248
  const drained = this.#handlePushResult(
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from './realtime-supervisor';
35
35
  export * from './schema';
36
36
  export * from './sql-tag';
37
37
  export * from './state';
38
+ export * from './sync-scheduler';
38
39
  export * from './transport';
39
40
  export * from './window';
40
41
  export * from './worker-host';
package/src/outbox.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type PushOperation,
12
12
  type ScopeMap,
13
13
  } from '@syncular/core';
14
- import type { ClientDatabase } from './database';
14
+ import type { ClientDatabase, SqlRow } from './database';
15
15
  import type { EncryptionConfig } from './encryption';
16
16
  import { ClientSyncError } from './errors';
17
17
  import {
@@ -85,19 +85,50 @@ export function appendOutboxCommit(
85
85
  }
86
86
  }
87
87
 
88
- /** Pending commits in FIFO creation order (§7.1). */
88
+ /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
89
89
  export function listOutbox(db: ClientDatabase): OutboxCommit[] {
90
90
  return db
91
91
  .query(
92
92
  `SELECT seq, client_commit_id, created_at_ms, operations
93
93
  FROM _syncular_outbox ORDER BY seq ASC`,
94
94
  )
95
- .map((row) => ({
96
- seq: row.seq as number,
97
- clientCommitId: row.client_commit_id as string,
98
- createdAtMs: row.created_at_ms as number,
99
- operations: JSON.parse(row.operations as string) as OutboxOperation[],
100
- }));
95
+ .map(decodeOutboxRow);
96
+ }
97
+
98
+ function decodeOutboxRow(row: SqlRow): OutboxCommit {
99
+ return {
100
+ seq: row.seq as number,
101
+ clientCommitId: row.client_commit_id as string,
102
+ createdAtMs: row.created_at_ms as number,
103
+ operations: JSON.parse(row.operations as string) as OutboxOperation[],
104
+ };
105
+ }
106
+
107
+ /** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
108
+ export function* iterateOutbox(
109
+ db: ClientDatabase,
110
+ throughSeq: number,
111
+ ): Generator<OutboxCommit> {
112
+ let afterSeq = 0;
113
+ while (afterSeq < throughSeq) {
114
+ const rows = db.query(
115
+ `SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox
116
+ WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`,
117
+ [afterSeq, throughSeq],
118
+ );
119
+ if (rows.length === 0) return;
120
+ for (const row of rows) {
121
+ const commit = decodeOutboxRow(row);
122
+ afterSeq = commit.seq;
123
+ yield commit;
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Routine status reads never load operation bodies. */
129
+ export function countOutbox(db: ClientDatabase): number {
130
+ return db.query('SELECT COUNT(*) AS count FROM _syncular_outbox')[0]!
131
+ .count as number;
101
132
  }
102
133
 
103
134
  export function deleteOutboxCommit(