@syncular/client 0.15.48 → 0.17.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.
@@ -18,15 +18,14 @@
18
18
  */
19
19
  import type { WakeReason } from '@syncular/core';
20
20
  import type { BlobRef, CachedBlob } from './blob.js';
21
- import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
21
+ import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
22
22
  import type { SqlRow, SqlValue } from './database.js';
23
- import type { ClientDiagnosticsRequest, ClientDiagnosticsSnapshot } from './diagnostics.js';
23
+ import type { ClientDiagnosticsSnapshot } from './diagnostics.js';
24
24
  import type { EncryptionKeyringConfig } from './encryption.js';
25
- import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
25
+ import type { ClientChangeBatch, LocalRevision } from './invalidation.js';
26
26
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
27
27
  import type { LocalDataRebootstrapInput, LocalDataRebootstrapResult } from './local-rebootstrap.js';
28
28
  import type { OutboxCommit } from './outbox.js';
29
- import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
30
29
  import type { ClientSchema } from './schema.js';
31
30
  import type { SubscriptionRecord } from './state.js';
32
31
  import type { WindowBase } from './window.js';
@@ -88,7 +87,7 @@ export interface WorkerInitResult {
88
87
  export interface WorkerSecurityActivation {
89
88
  readonly encryption?: EncryptionKeyringConfig;
90
89
  }
91
- export interface WorkerApi {
90
+ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'> {
92
91
  securityLifecycle(): SecurityLifecycle;
93
92
  beginSecurityPreflight(): Promise<void>;
94
93
  activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
@@ -112,19 +111,6 @@ export interface WorkerApi {
112
111
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
113
112
  querySnapshot(spec: QueryReadSpec): QuerySnapshot;
114
113
  localRevision(): LocalRevision;
115
- statusSnapshot(): SyncStatusSnapshot;
116
- diagnosticsSnapshot(request?: ClientDiagnosticsRequest): ClientDiagnosticsSnapshot;
117
- conflicts(): readonly ConflictRecord[];
118
- rejections(): readonly RejectionRecord[];
119
- commitOutcome(clientCommitId: string): CommitOutcome | undefined;
120
- commitOutcomes(query?: CommitOutcomeQuery): readonly CommitOutcome[];
121
- resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
122
- schemaFloor(): SchemaFloor | undefined;
123
- /** §7.3.5: the opaque auth-lease state, or undefined. */
124
- leaseState(): LeaseState | undefined;
125
- /** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
126
- upgrading(): boolean;
127
- syncNeeded(): boolean;
128
114
  pendingCommits(): OutboxCommit[];
129
115
  subscriptions(): SubscriptionRecord[];
130
116
  subscription(id: string): SubscriptionRecord | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.48",
3
+ "version": "0.17.0",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -34,14 +34,6 @@
34
34
  "default": "./dist/index.js"
35
35
  }
36
36
  },
37
- "./realtime-supervisor-observation": {
38
- "bun": "./src/realtime-supervisor-observation.ts",
39
- "browser": "./dist/realtime-supervisor-observation.js",
40
- "import": {
41
- "types": "./dist/realtime-supervisor-observation.d.ts",
42
- "default": "./dist/realtime-supervisor-observation.js"
43
- }
44
- },
45
37
  "./bun": {
46
38
  "bun": "./src/bun-database.ts",
47
39
  "browser": "./dist/bun-database.js",
@@ -97,9 +89,9 @@
97
89
  },
98
90
  "dependencies": {
99
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
100
- "@syncular/core": "0.15.48"
92
+ "@syncular/core": "0.17.0"
101
93
  },
102
94
  "devDependencies": {
103
- "@syncular/server": "0.15.48"
95
+ "@syncular/server": "0.17.0"
104
96
  }
105
97
  }
@@ -36,6 +36,16 @@ export class BunClientDatabase implements ClientDatabase {
36
36
 
37
37
  constructor(path = ':memory:') {
38
38
  this.db = new Database(path);
39
+ // Match native Rust persistence: append durable commits to the WAL
40
+ // instead of creating and syncing a rollback journal per transaction.
41
+ // SQLite retains its in-memory journal for :memory: databases.
42
+ try {
43
+ this.db.run('PRAGMA journal_mode = WAL');
44
+ this.db.run('PRAGMA synchronous = FULL');
45
+ } catch (error) {
46
+ this.db.close();
47
+ throw error;
48
+ }
39
49
  }
40
50
 
41
51
  exec(sql: string, params: readonly SqlValue[] = []): void {
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,
@@ -527,6 +529,37 @@ function isFinalPushResult(frame: PushResultFrame): boolean {
527
529
  );
528
530
  }
529
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
+
530
563
  export class SyncClient {
531
564
  readonly #config: SyncClientConfig;
532
565
  readonly #db: ClientDatabase;
@@ -707,7 +740,7 @@ export class SyncClient {
707
740
  // an application-issued sync() call.
708
741
  const startupWork =
709
742
  this.#schemaFloor === undefined &&
710
- (listOutbox(this.#db).length > 0 ||
743
+ (countOutbox(this.#db) > 0 ||
711
744
  subscriptions.some((sub) => sub.status === 'active'));
712
745
  if (startupWork && this.#securityLifecycle === 'active') {
713
746
  this.#needsPull = true;
@@ -722,10 +755,10 @@ export class SyncClient {
722
755
  role: () => 'direct',
723
756
  outbox: async () => this.pendingCommits().length,
724
757
  subscriptions: async () => this.subscriptions(),
725
- conflicts: async () => this.conflicts.length,
726
- rejections: async () => this.rejections.length,
727
- syncNeeded: async () => this.syncNeeded,
728
- 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,
729
762
  onInvalidate: (listener) => this.onInvalidate(listener),
730
763
  });
731
764
  this.#emitDiagnostics();
@@ -875,7 +908,7 @@ export class SyncClient {
875
908
  }
876
909
 
877
910
  /** Current fail-closed local-replica security state. */
878
- get securityLifecycle(): SecurityLifecycle {
911
+ securityLifecycle(): SecurityLifecycle {
879
912
  return this.#securityLifecycle;
880
913
  }
881
914
 
@@ -927,7 +960,7 @@ export class SyncClient {
927
960
  this.#securityLifecycle = 'active';
928
961
  const startupWork =
929
962
  this.#schemaFloor === undefined &&
930
- (listOutbox(this.#db).length > 0 ||
963
+ (countOutbox(this.#db) > 0 ||
931
964
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
932
965
  if (startupWork) {
933
966
  this.#setSyncNeeded(true);
@@ -1194,7 +1227,7 @@ export class SyncClient {
1194
1227
  replica: {
1195
1228
  localRevision: getLocalRevision(this.#db).toString(),
1196
1229
  syncNeeded: this.#needsPull,
1197
- pendingOutbox: listOutbox(this.#db).length,
1230
+ pendingOutbox: countOutbox(this.#db),
1198
1231
  },
1199
1232
  lease: leaseState,
1200
1233
  subscriptions: allSubscriptions.slice(
@@ -1310,7 +1343,7 @@ export class SyncClient {
1310
1343
  #statusSnapshot(outboxCount?: number): SyncStatusSnapshot {
1311
1344
  return {
1312
1345
  currentSchemaVersion: this.#config.schema.version,
1313
- outbox: outboxCount ?? listOutbox(this.#db).length,
1346
+ outbox: outboxCount ?? countOutbox(this.#db),
1314
1347
  upgrading: this.#upgrading,
1315
1348
  leaseState: this.#leaseState,
1316
1349
  schemaFloor: this.#schemaFloor,
@@ -1327,6 +1360,7 @@ export class SyncClient {
1327
1360
  #applyBatch<T>(
1328
1361
  fn: (batch: ChangeAccumulator) => T,
1329
1362
  statusSnapshotOverride?: () => SyncStatusSnapshot,
1363
+ onRollback?: (error: unknown) => never,
1330
1364
  ): T {
1331
1365
  if (this.#batch !== undefined) return fn(this.#batch);
1332
1366
  const batch = new ChangeAccumulator();
@@ -1350,6 +1384,7 @@ export class SyncClient {
1350
1384
  });
1351
1385
  } catch (error) {
1352
1386
  this.#batch = undefined;
1387
+ onRollback?.(error);
1353
1388
  throw error;
1354
1389
  }
1355
1390
  if (revision !== undefined) {
@@ -1519,6 +1554,9 @@ export class SyncClient {
1519
1554
  );
1520
1555
  }
1521
1556
  putCachedBlob(this.#db, blobId, bytes, this.#now());
1557
+ // The referencing row can arrive before its body. Pin the new cache entry
1558
+ // from current visible references before applying the size cap (§5.9.7 B1).
1559
+ this.#reconcileBlobs(false);
1522
1560
  this.#enforceBlobCacheCap();
1523
1561
  const stored = getCachedBlob(this.#db, blobId);
1524
1562
  if (stored === undefined) {
@@ -1606,12 +1644,12 @@ export class SyncClient {
1606
1644
  await transport.upload(blobId, bytes, mediaType);
1607
1645
  }
1608
1646
 
1609
- get conflicts(): readonly ConflictRecord[] {
1647
+ conflicts(): readonly ConflictRecord[] {
1610
1648
  this.#requireActive();
1611
1649
  return this.#conflicts;
1612
1650
  }
1613
1651
 
1614
- get rejections(): readonly RejectionRecord[] {
1652
+ rejections(): readonly RejectionRecord[] {
1615
1653
  this.#requireActive();
1616
1654
  return this.#rejections;
1617
1655
  }
@@ -1702,30 +1740,6 @@ export class SyncClient {
1702
1740
  });
1703
1741
  }
1704
1742
 
1705
- /** Non-undefined once the server declared a schema floor (§1.6). */
1706
- get schemaFloor(): SchemaFloor | undefined {
1707
- return this.#schemaFloor;
1708
- }
1709
-
1710
- /**
1711
- * §7.4.5: true while a schema-bump reset + first re-bootstrap is in
1712
- * flight — the app's "upgrading…" cue. Clears when the first post-reset
1713
- * bootstrap round reaches idle (every subscription past its fresh
1714
- * bootstrap).
1715
- */
1716
- get upgrading(): boolean {
1717
- return this.#upgrading;
1718
- }
1719
-
1720
- /**
1721
- * §7.3.5: the current auth-lease state (opaque). Undefined until a
1722
- * `LEASE` frame arrives. `errorCode` is set when a round was rejected
1723
- * with a request-level lease code — syncing on the lease has stopped.
1724
- */
1725
- get leaseState(): LeaseState | undefined {
1726
- return this.#leaseState;
1727
- }
1728
-
1729
1743
  /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
1730
1744
  * `undefined` if no lease is held. Negative once expired. */
1731
1745
  leaseRemainingMs(now: number = this.#now()): number | undefined {
@@ -1738,11 +1752,6 @@ export class SyncClient {
1738
1752
  return this.#schemaFloor !== undefined;
1739
1753
  }
1740
1754
 
1741
- /** §8: a hello/wake-up asked for a pull that has not run yet. */
1742
- get syncNeeded(): boolean {
1743
- return this.#needsPull;
1744
- }
1745
-
1746
1755
  /**
1747
1756
  * §8.6 presence on a scope key: the current peers present there (a map
1748
1757
  * of `actorId clientId` → peer). Empty for a key with no present peers.
@@ -2502,12 +2511,19 @@ export class SyncClient {
2502
2511
  outbox: OutboxCommit[];
2503
2512
  deferred: number;
2504
2513
  }> {
2505
- const pending = listOutbox(this.#db);
2514
+ // Pin before the first encryption await: mutations can append while a
2515
+ // round is encoding, and belong to the next request.
2516
+ const bounds = this.#db.query(
2517
+ 'SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox',
2518
+ )[0]!;
2519
+ const pendingCount = bounds.count as number;
2520
+ const throughSeq = (bounds.last_seq as number | null) ?? 0;
2506
2521
  const pushFrames: RequestFrame[] = [];
2507
2522
  const outbox: OutboxCommit[] = [];
2508
2523
  let deferred = 0;
2509
2524
  let ops = 0;
2510
- for (const commit of pending) {
2525
+ let processed = 0;
2526
+ for (const commit of iterateOutbox(this.#db, throughSeq)) {
2511
2527
  // §6.1 splitBatch: whole commits in commit order, stopping before the
2512
2528
  // per-request operation cap. A first commit that alone exceeds the cap
2513
2529
  // is sent alone — the server rejects it loudly rather than the queue
@@ -2516,9 +2532,10 @@ export class SyncClient {
2516
2532
  outbox.length > 0 &&
2517
2533
  ops + commit.operations.length > MAX_OPS_PER_REQUEST
2518
2534
  ) {
2519
- deferred += 1;
2520
- continue;
2535
+ deferred = pendingCount - processed;
2536
+ break;
2521
2537
  }
2538
+ processed += 1;
2522
2539
  try {
2523
2540
  pushFrames.push(
2524
2541
  // §5.11: encrypted columns are encrypted at this encode-at-send
@@ -2810,7 +2827,8 @@ export class SyncClient {
2810
2827
  last.segmentRowsApplied === 0 &&
2811
2828
  last.bootstrapping.length === 0 &&
2812
2829
  last.resets.length === 0 &&
2813
- (last.deferredCommits ?? 0) === 0
2830
+ (last.deferredCommits ?? 0) === 0 &&
2831
+ !this.#needsPull
2814
2832
  ) {
2815
2833
  return last;
2816
2834
  }
@@ -2918,8 +2936,14 @@ export class SyncClient {
2918
2936
  }
2919
2937
  let openedSocket: RealtimeSocket | undefined;
2920
2938
  const socket = await connector({
2921
- onText: (text) => this.#handleRealtimeText(text),
2922
- onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
2939
+ onText: (text) => {
2940
+ if (generation !== this.#realtimeGeneration || !this.#started) return;
2941
+ this.#handleRealtimeText(text);
2942
+ },
2943
+ onBinary: (bytes) => {
2944
+ if (generation !== this.#realtimeGeneration || !this.#started) return;
2945
+ this.#routeRealtimeBinary(bytes);
2946
+ },
2923
2947
  onClose: () => {
2924
2948
  if (openedSocket === undefined || this.#socket !== openedSocket) return;
2925
2949
  this.#socket = undefined;
@@ -3210,13 +3234,14 @@ export class SyncClient {
3210
3234
  let section: OpenSection | undefined;
3211
3235
  let errorFrame: ClientSyncError | undefined;
3212
3236
  let deltaCursor = -1;
3213
- let responseOutboxCount: number | undefined;
3214
3237
 
3215
3238
  // Each durable observer transaction emits its own revisioned batch.
3216
3239
  // Async decrypt/download work happens outside SQLite transactions.
3217
3240
  this.#beginDiagnosticsDeferral();
3218
3241
  try {
3219
- for (const frame of message.frames.slice(1)) {
3242
+ for (let index = 1; index < message.frames.length; index += 1) {
3243
+ const frame = message.frames[index];
3244
+ if (frame === undefined) break;
3220
3245
  switch (frame.type) {
3221
3246
  case 'RESP_HEADER':
3222
3247
  break;
@@ -3229,23 +3254,61 @@ export class SyncClient {
3229
3254
  });
3230
3255
  break;
3231
3256
  case 'PUSH_RESULT': {
3232
- let outboxCount =
3233
- responseOutboxCount ?? listOutbox(this.#db).length;
3257
+ const results: PushResultFrame[] = [frame];
3258
+ if (frame.status !== 'rejected') {
3259
+ while (index + 1 < message.frames.length) {
3260
+ const next = message.frames[index + 1];
3261
+ if (next?.type !== 'PUSH_RESULT' || next.status === 'rejected')
3262
+ break;
3263
+ results.push(next);
3264
+ index += 1;
3265
+ }
3266
+ }
3267
+ const conflictCount = this.#conflicts.length;
3268
+ const rejectionCount = this.#rejections.length;
3269
+ let outboxCount = countOutbox(this.#db);
3234
3270
  this.#applyBatch(
3235
3271
  (batch) => {
3236
- const drained = this.#handlePushResult(
3237
- frame,
3238
- commitsById,
3239
- summary,
3240
- batch,
3241
- rejectionDetailsByCommit.get(frame.clientCommitId),
3242
- frame === lastFinalPushResult,
3243
- );
3244
- if (drained) outboxCount -= 1;
3272
+ let drained = false;
3273
+ for (const result of results) {
3274
+ if (
3275
+ this.#handlePushResult(
3276
+ result,
3277
+ commitsById,
3278
+ summary,
3279
+ batch,
3280
+ rejectionDetailsByCommit.get(result.clientCommitId),
3281
+ )
3282
+ ) {
3283
+ outboxCount -= 1;
3284
+ drained = true;
3285
+ }
3286
+ }
3287
+ if (
3288
+ drained &&
3289
+ results.some((result) => result === lastFinalPushResult)
3290
+ ) {
3291
+ pruneCommitOutcomes(
3292
+ this.#db,
3293
+ this.#outcomeRetentionMaxEntries,
3294
+ );
3295
+ }
3245
3296
  },
3246
3297
  () => this.#statusSnapshot(outboxCount),
3298
+ (cause) => {
3299
+ this.#conflicts.length = conflictCount;
3300
+ this.#rejections.length = rejectionCount;
3301
+ const error = new ClientSyncError(
3302
+ 'client.outcome_persistence_failed',
3303
+ 'local commit outcome could not be persisted',
3304
+ );
3305
+ error.cause = cause;
3306
+ throw error;
3307
+ },
3247
3308
  );
3248
- responseOutboxCount = outboxCount;
3309
+ for (const conflict of this.#conflicts.slice(conflictCount)) {
3310
+ this.#config.onConflict?.(conflict);
3311
+ }
3249
3312
  break;
3250
3313
  }
3251
3314
  case 'PUSH_RESULT_DETAILS':
@@ -3494,7 +3557,6 @@ export class SyncClient {
3494
3557
  summary: MutableSummary,
3495
3558
  batch: ChangeAccumulator,
3496
3559
  rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
3497
- pruneOutcomes: boolean,
3498
3560
  ): boolean {
3499
3561
  const commit = commitsById.get(frame.clientCommitId);
3500
3562
  if (commit === undefined) return false;
@@ -3517,9 +3579,6 @@ export class SyncClient {
3517
3579
  })),
3518
3580
  });
3519
3581
  deleteOutboxCommit(this.#db, frame.clientCommitId);
3520
- if (pruneOutcomes) {
3521
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3522
- }
3523
3582
  batch.status();
3524
3583
  batch.outcomes();
3525
3584
  summary.applied.push(frame.clientCommitId);
@@ -3557,7 +3616,6 @@ export class SyncClient {
3557
3616
  outcomeResults.push({ status: 'conflict', conflict });
3558
3617
  batch.conflicts();
3559
3618
  summary.conflicts.push(conflict);
3560
- this.#config.onConflict?.(conflict);
3561
3619
  } else if (result.status === 'error') {
3562
3620
  const details = rejectionDetails?.get(result.opIndex);
3563
3621
  const rejection: RejectionRecord = {
@@ -3585,9 +3643,6 @@ export class SyncClient {
3585
3643
  results: outcomeResults,
3586
3644
  operations: commit.operations,
3587
3645
  });
3588
- if (pruneOutcomes) {
3589
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
3590
- }
3591
3646
  batch.outcomes();
3592
3647
  // §7.2: remove the rejected optimistic layer. Before-images restore
3593
3648
  // validator-rejected updates even when the server emitted no new COMMIT;
@@ -40,6 +40,13 @@ export class NodeClientDatabase implements ClientDatabase {
40
40
 
41
41
  constructor(path = ':memory:') {
42
42
  this.db = new DatabaseSync(path);
43
+ try {
44
+ this.db.exec('PRAGMA journal_mode = WAL');
45
+ this.db.exec('PRAGMA synchronous = FULL');
46
+ } catch (error) {
47
+ this.db.close();
48
+ throw error;
49
+ }
43
50
  }
44
51
 
45
52
  exec(sql: string, params: readonly SqlValue[] = []): void {
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(