@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.
package/README.md CHANGED
@@ -543,6 +543,14 @@ Node 22.13 or newer. No SQLite package or native addon is required. Both
543
543
  adapters support synchronous `exec`, `query`, nested transactions, boolean
544
544
  bindings, `null`, `Uint8Array` BLOB values, and §5.3 SQLite-image attachment.
545
545
 
546
+ Persistent Bun and Node databases use SQLite WAL journaling with
547
+ `synchronous=FULL`. Each application or sync commit retains its own durable
548
+ transaction. SQLite keeps in-memory databases on its memory journal. Use a
549
+ local filesystem path; WAL uses adjacent `-wal` and `-shm` files. Close every
550
+ connection before copying the database file, or use SQLite's backup facilities
551
+ while it is open. Copying only the main file while writers are active can omit
552
+ committed WAL contents.
553
+
546
554
  Runtime-specific imports remain available:
547
555
 
548
556
  ```ts
@@ -580,3 +588,45 @@ Tests drive the real worker entry in a bun `Worker` with bun:sqlite
580
588
  injected through the bootstrap's database-factory override
581
589
  (`test/worker-rpc.test.ts`); the OPFS path itself is browser-only and is
582
590
  exercised by `apps/demo`.
591
+
592
+ ## Snapshot API migration
593
+
594
+ This source-breaking revision uses methods for application reads across the
595
+ direct client, worker leaders and followers, Tauri, and React Native. Replace
596
+ `client.conflicts`, `client.rejections`, and `client.securityLifecycle` on the
597
+ direct client with method calls. Replace `schemaFloor`, `leaseState`,
598
+ `upgrading`, and `syncNeeded` getters or bridge methods with fields from one
599
+ `statusSnapshot()` call:
600
+
601
+ ```ts
602
+ const status = await client.statusSnapshot();
603
+ if (status.schemaFloor) showUpgradeRequired(status.schemaFloor);
604
+ const conflicts = await client.conflicts();
605
+ const outcome = await client.commitOutcome(commitId);
606
+ ```
607
+
608
+ The direct client returns snapshots synchronously. Worker and native bridges
609
+ return promises; `await` works with both. `querySnapshot` returns rows, coverage,
610
+ and revision from one read. `diagnosticsSnapshot`, `commitOutcome`,
611
+ `commitOutcomes`, and `resolveCommitOutcome` retain their existing arguments.
612
+ The shared `ClientSnapshotMethods` and `PromiseMethods` types describe these
613
+ contracts. Key-bearing security activation stays on each concrete host type.
614
+
615
+ React uses the supplied client directly; `useSyncClient()` preserves its
616
+ identity. Remove imports of `normalizeClient` and the
617
+ `@syncular/client/realtime-supervisor-observation` forwarding utility. Pass the
618
+ client to `SyncProvider` and use `realtimeSupervisorSnapshot(client)` to inspect
619
+ an attached supervisor. Custom React clients must implement the snapshot
620
+ methods and method-form collection reads. See the [React migration](https://syncular.dev/platform-react/)
621
+ for the `onEnqueued` callback rename.
622
+
623
+ ## Outbox read costs
624
+
625
+ Request encoding pins the pending count and highest local sequence before its
626
+ first asynchronous step. It reads keyset pages of 32 raw records, decodes only
627
+ the consumed prefix, and stops at the first whole commit that exceeds the
628
+ remaining operation budget. Mutations appended during encoding enter the next
629
+ request. Status and diagnostics use `COUNT(*)` without parsing pending bodies.
630
+ Optimistic replay still reads the remaining outbox after each response. The
631
+ 100/1,000/10,000-commit workload and measured limits are recorded in
632
+ [the reliability RFC](../../docs/RFC-RELIABILITY-DX.md#9-implementation-evidence-2026-09-05).
@@ -20,6 +20,17 @@ export class BunClientDatabase {
20
20
  #tx = { depth: 0 };
21
21
  constructor(path = ':memory:') {
22
22
  this.db = new Database(path);
23
+ // Match native Rust persistence: append durable commits to the WAL
24
+ // instead of creating and syncing a rollback journal per transaction.
25
+ // SQLite retains its in-memory journal for :memory: databases.
26
+ try {
27
+ this.db.run('PRAGMA journal_mode = WAL');
28
+ this.db.run('PRAGMA synchronous = FULL');
29
+ }
30
+ catch (error) {
31
+ this.db.close();
32
+ throw error;
33
+ }
23
34
  }
24
35
  exec(sql, params = []) {
25
36
  this.db.query(sql).run(...coerceParams(params));
package/dist/client.d.ts CHANGED
@@ -231,6 +231,16 @@ export interface QuerySnapshot<Row = SqlRow> {
231
231
  * complete once its bootstrap round finishes — emptiness ≠ pendency.
232
232
  */
233
233
  export declare function windowComplete(state: WindowState, unit: string): boolean;
234
+ /** Canonical client reads, shared by synchronous cores and promise hosts. */
235
+ export type ClientSnapshotMethods = Pick<SyncClient, 'querySnapshot' | 'statusSnapshot' | 'diagnosticsSnapshot' | 'conflicts' | 'rejections' | 'commitOutcome' | 'commitOutcomes' | 'resolveCommitOutcome'>;
236
+ /** Project a method contract across an asynchronous host boundary. */
237
+ export type PromiseMethods<Methods> = {
238
+ [Key in keyof Methods]: Methods[Key] extends (...args: infer Args) => infer Result ? (...args: Args) => Promise<Awaited<Result>> : never;
239
+ };
240
+ /** A reader can execute locally or cross a worker/native boundary. */
241
+ export type ClientSnapshotReader = {
242
+ [Key in keyof ClientSnapshotMethods]: (...args: Parameters<ClientSnapshotMethods[Key]>) => ReturnType<ClientSnapshotMethods[Key]> | Promise<ReturnType<ClientSnapshotMethods[Key]>>;
243
+ };
234
244
  export declare class SyncClient {
235
245
  #private;
236
246
  constructor(config: SyncClientConfig);
@@ -238,7 +248,7 @@ export declare class SyncClient {
238
248
  start(): Promise<void>;
239
249
  close(): Promise<void>;
240
250
  /** Current fail-closed local-replica security state. */
241
- get securityLifecycle(): SecurityLifecycle;
251
+ securityLifecycle(): SecurityLifecycle;
242
252
  /**
243
253
  * Block new protected operations immediately, then wait for every already
244
254
  * serialized database/network operation to settle before releasing key
@@ -320,8 +330,8 @@ export declare class SyncClient {
320
330
  fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
321
331
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
322
332
  flushBlobUploads(): Promise<void>;
323
- get conflicts(): readonly ConflictRecord[];
324
- get rejections(): readonly RejectionRecord[];
333
+ conflicts(): readonly ConflictRecord[];
334
+ rejections(): readonly RejectionRecord[];
325
335
  /** One durable final outcome by the originating client commit id. */
326
336
  commitOutcome(clientCommitId: string): CommitOutcome | undefined;
327
337
  /** Newest-first durable outcome journal. */
@@ -333,28 +343,11 @@ export declare class SyncClient {
333
343
  * dismissed. The transition is one-way and survives restart.
334
344
  */
335
345
  resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
336
- /** Non-undefined once the server declared a schema floor (§1.6). */
337
- get schemaFloor(): SchemaFloor | undefined;
338
- /**
339
- * §7.4.5: true while a schema-bump reset + first re-bootstrap is in
340
- * flight — the app's "upgrading…" cue. Clears when the first post-reset
341
- * bootstrap round reaches idle (every subscription past its fresh
342
- * bootstrap).
343
- */
344
- get upgrading(): boolean;
345
- /**
346
- * §7.3.5: the current auth-lease state (opaque). Undefined until a
347
- * `LEASE` frame arrives. `errorCode` is set when a round was rejected
348
- * with a request-level lease code — syncing on the lease has stopped.
349
- */
350
- get leaseState(): LeaseState | undefined;
351
346
  /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
352
347
  * `undefined` if no lease is held. Negative once expired. */
353
348
  leaseRemainingMs(now?: number): number | undefined;
354
349
  /** True when syncing is stopped pending a client upgrade. */
355
350
  get stopped(): boolean;
356
- /** §8: a hello/wake-up asked for a pull that has not run yet. */
357
- get syncNeeded(): boolean;
358
351
  /**
359
352
  * §8.6 presence on a scope key: the current peers present there (a map
360
353
  * of `actorId clientId` → peer). Empty for a key with no present peers.
package/dist/client.js CHANGED
@@ -18,7 +18,7 @@ import { singleOwnerLock, } from './leader-lock.js';
18
18
  import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
19
19
  import { compileLocalDataRebootstrap, localDataRebootstrapMetaKey, } from './local-rebootstrap.js';
20
20
  import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, } from './local-rebootstrap-receipt.js';
21
- import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
21
+ import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
22
22
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
23
23
  import { assertReadOnlyQuery } from './query-guard.js';
24
24
  import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
@@ -232,7 +232,7 @@ export class SyncClient {
232
232
  // this as an exact core-owned intent so hosts never need a startup poll or
233
233
  // an application-issued sync() call.
234
234
  const startupWork = this.#schemaFloor === undefined &&
235
- (listOutbox(this.#db).length > 0 ||
235
+ (countOutbox(this.#db) > 0 ||
236
236
  subscriptions.some((sub) => sub.status === 'active'));
237
237
  if (startupWork && this.#securityLifecycle === 'active') {
238
238
  this.#needsPull = true;
@@ -247,10 +247,10 @@ export class SyncClient {
247
247
  role: () => 'direct',
248
248
  outbox: async () => this.pendingCommits().length,
249
249
  subscriptions: async () => this.subscriptions(),
250
- conflicts: async () => this.conflicts.length,
251
- rejections: async () => this.rejections.length,
252
- syncNeeded: async () => this.syncNeeded,
253
- upgrading: async () => this.upgrading,
250
+ conflicts: async () => this.conflicts().length,
251
+ rejections: async () => this.rejections().length,
252
+ syncNeeded: async () => this.statusSnapshot().syncNeeded,
253
+ upgrading: async () => this.statusSnapshot().upgrading,
254
254
  onInvalidate: (listener) => this.onInvalidate(listener),
255
255
  });
256
256
  this.#emitDiagnostics();
@@ -395,7 +395,7 @@ export class SyncClient {
395
395
  }
396
396
  }
397
397
  /** Current fail-closed local-replica security state. */
398
- get securityLifecycle() {
398
+ securityLifecycle() {
399
399
  return this.#securityLifecycle;
400
400
  }
401
401
  /**
@@ -439,7 +439,7 @@ export class SyncClient {
439
439
  this.#encryption = options.encryption;
440
440
  this.#securityLifecycle = 'active';
441
441
  const startupWork = this.#schemaFloor === undefined &&
442
- (listOutbox(this.#db).length > 0 ||
442
+ (countOutbox(this.#db) > 0 ||
443
443
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
444
444
  if (startupWork) {
445
445
  this.#setSyncNeeded(true);
@@ -666,7 +666,7 @@ export class SyncClient {
666
666
  replica: {
667
667
  localRevision: getLocalRevision(this.#db).toString(),
668
668
  syncNeeded: this.#needsPull,
669
- pendingOutbox: listOutbox(this.#db).length,
669
+ pendingOutbox: countOutbox(this.#db),
670
670
  },
671
671
  lease: leaseState,
672
672
  subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
@@ -757,7 +757,7 @@ export class SyncClient {
757
757
  #statusSnapshot(outboxCount) {
758
758
  return {
759
759
  currentSchemaVersion: this.#config.schema.version,
760
- outbox: outboxCount ?? listOutbox(this.#db).length,
760
+ outbox: outboxCount ?? countOutbox(this.#db),
761
761
  upgrading: this.#upgrading,
762
762
  leaseState: this.#leaseState,
763
763
  schemaFloor: this.#schemaFloor,
@@ -770,7 +770,7 @@ export class SyncClient {
770
770
  * Re-entrant calls share the outer batch so a nested apply never
771
771
  * double-emits (e.g. purge → blob reconcile → replay inside one round).
772
772
  */
773
- #applyBatch(fn, statusSnapshotOverride) {
773
+ #applyBatch(fn, statusSnapshotOverride, onRollback) {
774
774
  if (this.#batch !== undefined)
775
775
  return fn(this.#batch);
776
776
  const batch = new ChangeAccumulator();
@@ -796,6 +796,7 @@ export class SyncClient {
796
796
  }
797
797
  catch (error) {
798
798
  this.#batch = undefined;
799
+ onRollback?.(error);
799
800
  throw error;
800
801
  }
801
802
  if (revision !== undefined) {
@@ -929,6 +930,9 @@ export class SyncClient {
929
930
  throw new ClientSyncError('sync.invalid_request', `blob content address mismatch for ${blobId} (§5.9.5)`);
930
931
  }
931
932
  putCachedBlob(this.#db, blobId, bytes, this.#now());
933
+ // The referencing row can arrive before its body. Pin the new cache entry
934
+ // from current visible references before applying the size cap (§5.9.7 B1).
935
+ this.#reconcileBlobs(false);
932
936
  this.#enforceBlobCacheCap();
933
937
  const stored = getCachedBlob(this.#db, blobId);
934
938
  if (stored === undefined) {
@@ -994,11 +998,11 @@ export class SyncClient {
994
998
  }
995
999
  await transport.upload(blobId, bytes, mediaType);
996
1000
  }
997
- get conflicts() {
1001
+ conflicts() {
998
1002
  this.#requireActive();
999
1003
  return this.#conflicts;
1000
1004
  }
1001
- get rejections() {
1005
+ rejections() {
1002
1006
  this.#requireActive();
1003
1007
  return this.#rejections;
1004
1008
  }
@@ -1062,27 +1066,6 @@ export class SyncClient {
1062
1066
  return resolved;
1063
1067
  });
1064
1068
  }
1065
- /** Non-undefined once the server declared a schema floor (§1.6). */
1066
- get schemaFloor() {
1067
- return this.#schemaFloor;
1068
- }
1069
- /**
1070
- * §7.4.5: true while a schema-bump reset + first re-bootstrap is in
1071
- * flight — the app's "upgrading…" cue. Clears when the first post-reset
1072
- * bootstrap round reaches idle (every subscription past its fresh
1073
- * bootstrap).
1074
- */
1075
- get upgrading() {
1076
- return this.#upgrading;
1077
- }
1078
- /**
1079
- * §7.3.5: the current auth-lease state (opaque). Undefined until a
1080
- * `LEASE` frame arrives. `errorCode` is set when a round was rejected
1081
- * with a request-level lease code — syncing on the lease has stopped.
1082
- */
1083
- get leaseState() {
1084
- return this.#leaseState;
1085
- }
1086
1069
  /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
1087
1070
  * `undefined` if no lease is held. Negative once expired. */
1088
1071
  leaseRemainingMs(now = this.#now()) {
@@ -1093,10 +1076,6 @@ export class SyncClient {
1093
1076
  get stopped() {
1094
1077
  return this.#schemaFloor !== undefined;
1095
1078
  }
1096
- /** §8: a hello/wake-up asked for a pull that has not run yet. */
1097
- get syncNeeded() {
1098
- return this.#needsPull;
1099
- }
1100
1079
  /**
1101
1080
  * §8.6 presence on a scope key: the current peers present there (a map
1102
1081
  * of `actorId clientId` → peer). Empty for a key with no present peers.
@@ -1731,21 +1710,27 @@ export class SyncClient {
1731
1710
  * the encoded push frames index-aligned with the surviving `outbox`.
1732
1711
  */
1733
1712
  async #encodeOutboxForPush() {
1734
- const pending = listOutbox(this.#db);
1713
+ // Pin before the first encryption await: mutations can append while a
1714
+ // round is encoding, and belong to the next request.
1715
+ const bounds = this.#db.query('SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox')[0];
1716
+ const pendingCount = bounds.count;
1717
+ const throughSeq = bounds.last_seq ?? 0;
1735
1718
  const pushFrames = [];
1736
1719
  const outbox = [];
1737
1720
  let deferred = 0;
1738
1721
  let ops = 0;
1739
- for (const commit of pending) {
1722
+ let processed = 0;
1723
+ for (const commit of iterateOutbox(this.#db, throughSeq)) {
1740
1724
  // §6.1 splitBatch: whole commits in commit order, stopping before the
1741
1725
  // per-request operation cap. A first commit that alone exceeds the cap
1742
1726
  // is sent alone — the server rejects it loudly rather than the queue
1743
1727
  // wedging silently. Deferred commits stay queued for the next round.
1744
1728
  if (outbox.length > 0 &&
1745
1729
  ops + commit.operations.length > MAX_OPS_PER_REQUEST) {
1746
- deferred += 1;
1747
- continue;
1730
+ deferred = pendingCount - processed;
1731
+ break;
1748
1732
  }
1733
+ processed += 1;
1749
1734
  try {
1750
1735
  pushFrames.push(
1751
1736
  // §5.11: encrypted columns are encrypted at this encode-at-send
@@ -2003,7 +1988,8 @@ export class SyncClient {
2003
1988
  last.segmentRowsApplied === 0 &&
2004
1989
  last.bootstrapping.length === 0 &&
2005
1990
  last.resets.length === 0 &&
2006
- (last.deferredCommits ?? 0) === 0) {
1991
+ (last.deferredCommits ?? 0) === 0 &&
1992
+ !this.#needsPull) {
2007
1993
  return last;
2008
1994
  }
2009
1995
  }
@@ -2085,8 +2071,16 @@ export class SyncClient {
2085
2071
  }
2086
2072
  let openedSocket;
2087
2073
  const socket = await connector({
2088
- onText: (text) => this.#handleRealtimeText(text),
2089
- onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
2074
+ onText: (text) => {
2075
+ if (generation !== this.#realtimeGeneration || !this.#started)
2076
+ return;
2077
+ this.#handleRealtimeText(text);
2078
+ },
2079
+ onBinary: (bytes) => {
2080
+ if (generation !== this.#realtimeGeneration || !this.#started)
2081
+ return;
2082
+ this.#routeRealtimeBinary(bytes);
2083
+ },
2090
2084
  onClose: () => {
2091
2085
  if (openedSocket === undefined || this.#socket !== openedSocket)
2092
2086
  return;
@@ -2329,12 +2323,14 @@ export class SyncClient {
2329
2323
  let section;
2330
2324
  let errorFrame;
2331
2325
  let deltaCursor = -1;
2332
- let responseOutboxCount;
2333
2326
  // Each durable observer transaction emits its own revisioned batch.
2334
2327
  // Async decrypt/download work happens outside SQLite transactions.
2335
2328
  this.#beginDiagnosticsDeferral();
2336
2329
  try {
2337
- for (const frame of message.frames.slice(1)) {
2330
+ for (let index = 1; index < message.frames.length; index += 1) {
2331
+ const frame = message.frames[index];
2332
+ if (frame === undefined)
2333
+ break;
2338
2334
  switch (frame.type) {
2339
2335
  case 'RESP_HEADER':
2340
2336
  break;
@@ -2347,13 +2343,41 @@ export class SyncClient {
2347
2343
  });
2348
2344
  break;
2349
2345
  case 'PUSH_RESULT': {
2350
- let outboxCount = responseOutboxCount ?? listOutbox(this.#db).length;
2346
+ const results = [frame];
2347
+ if (frame.status !== 'rejected') {
2348
+ while (index + 1 < message.frames.length) {
2349
+ const next = message.frames[index + 1];
2350
+ if (next?.type !== 'PUSH_RESULT' || next.status === 'rejected')
2351
+ break;
2352
+ results.push(next);
2353
+ index += 1;
2354
+ }
2355
+ }
2356
+ const conflictCount = this.#conflicts.length;
2357
+ const rejectionCount = this.#rejections.length;
2358
+ let outboxCount = countOutbox(this.#db);
2351
2359
  this.#applyBatch((batch) => {
2352
- const drained = this.#handlePushResult(frame, commitsById, summary, batch, rejectionDetailsByCommit.get(frame.clientCommitId), frame === lastFinalPushResult);
2353
- if (drained)
2354
- outboxCount -= 1;
2355
- }, () => this.#statusSnapshot(outboxCount));
2356
- responseOutboxCount = outboxCount;
2360
+ let drained = false;
2361
+ for (const result of results) {
2362
+ if (this.#handlePushResult(result, commitsById, summary, batch, rejectionDetailsByCommit.get(result.clientCommitId))) {
2363
+ outboxCount -= 1;
2364
+ drained = true;
2365
+ }
2366
+ }
2367
+ if (drained &&
2368
+ results.some((result) => result === lastFinalPushResult)) {
2369
+ pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2370
+ }
2371
+ }, () => this.#statusSnapshot(outboxCount), (cause) => {
2372
+ this.#conflicts.length = conflictCount;
2373
+ this.#rejections.length = rejectionCount;
2374
+ const error = new ClientSyncError('client.outcome_persistence_failed', 'local commit outcome could not be persisted');
2375
+ error.cause = cause;
2376
+ throw error;
2377
+ });
2378
+ for (const conflict of this.#conflicts.slice(conflictCount)) {
2379
+ this.#config.onConflict?.(conflict);
2380
+ }
2357
2381
  break;
2358
2382
  }
2359
2383
  case 'PUSH_RESULT_DETAILS':
@@ -2519,7 +2543,7 @@ export class SyncClient {
2519
2543
  }
2520
2544
  return { ...summary, bootstrapping };
2521
2545
  }
2522
- #handlePushResult(frame, commitsById, summary, batch, rejectionDetails, pruneOutcomes) {
2546
+ #handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
2523
2547
  const commit = commitsById.get(frame.clientCommitId);
2524
2548
  if (commit === undefined)
2525
2549
  return false;
@@ -2543,9 +2567,6 @@ export class SyncClient {
2543
2567
  })),
2544
2568
  });
2545
2569
  deleteOutboxCommit(this.#db, frame.clientCommitId);
2546
- if (pruneOutcomes) {
2547
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2548
- }
2549
2570
  batch.status();
2550
2571
  batch.outcomes();
2551
2572
  summary.applied.push(frame.clientCommitId);
@@ -2580,7 +2601,6 @@ export class SyncClient {
2580
2601
  outcomeResults.push({ status: 'conflict', conflict });
2581
2602
  batch.conflicts();
2582
2603
  summary.conflicts.push(conflict);
2583
- this.#config.onConflict?.(conflict);
2584
2604
  }
2585
2605
  else if (result.status === 'error') {
2586
2606
  const details = rejectionDetails?.get(result.opIndex);
@@ -2610,9 +2630,6 @@ export class SyncClient {
2610
2630
  results: outcomeResults,
2611
2631
  operations: commit.operations,
2612
2632
  });
2613
- if (pruneOutcomes) {
2614
- pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
2615
- }
2616
2633
  batch.outcomes();
2617
2634
  // §7.2: remove the rejected optimistic layer. Before-images restore
2618
2635
  // validator-rejected updates even when the server emitted no new COMMIT;
@@ -32,6 +32,14 @@ export class NodeClientDatabase {
32
32
  #tx = { depth: 0 };
33
33
  constructor(path = ':memory:') {
34
34
  this.db = new DatabaseSync(path);
35
+ try {
36
+ this.db.exec('PRAGMA journal_mode = WAL');
37
+ this.db.exec('PRAGMA synchronous = FULL');
38
+ }
39
+ catch (error) {
40
+ this.db.close();
41
+ throw error;
42
+ }
35
43
  }
36
44
  exec(sql, params = []) {
37
45
  this.db.prepare(sql).run(...coerceParams(params));
package/dist/outbox.d.ts CHANGED
@@ -38,8 +38,12 @@ export interface OutboxBeforeImage {
38
38
  readonly values?: Readonly<Record<string, JsonRowValue>>;
39
39
  }
40
40
  export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
41
- /** Pending commits in FIFO creation order (§7.1). */
41
+ /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
42
42
  export declare function listOutbox(db: ClientDatabase): OutboxCommit[];
43
+ /** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
44
+ export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
45
+ /** Routine status reads never load operation bodies. */
46
+ export declare function countOutbox(db: ClientDatabase): number;
43
47
  export declare function deleteOutboxCommit(db: ClientDatabase, clientCommitId: string): void;
44
48
  export declare function listOutboxBeforeImages(db: ClientDatabase, clientCommitId: string): OutboxBeforeImage[];
45
49
  export declare function replaceOutboxBeforeImages(db: ClientDatabase, clientCommitId: string, replacements: readonly OutboxBeforeImage[]): void;
package/dist/outbox.js CHANGED
@@ -26,17 +26,40 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
26
26
  ]);
27
27
  }
28
28
  }
29
- /** Pending commits in FIFO creation order (§7.1). */
29
+ /** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
30
30
  export function listOutbox(db) {
31
31
  return db
32
32
  .query(`SELECT seq, client_commit_id, created_at_ms, operations
33
33
  FROM _syncular_outbox ORDER BY seq ASC`)
34
- .map((row) => ({
34
+ .map(decodeOutboxRow);
35
+ }
36
+ function decodeOutboxRow(row) {
37
+ return {
35
38
  seq: row.seq,
36
39
  clientCommitId: row.client_commit_id,
37
40
  createdAtMs: row.created_at_ms,
38
41
  operations: JSON.parse(row.operations),
39
- }));
42
+ };
43
+ }
44
+ /** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
45
+ export function* iterateOutbox(db, throughSeq) {
46
+ let afterSeq = 0;
47
+ while (afterSeq < throughSeq) {
48
+ const rows = db.query(`SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox
49
+ WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`, [afterSeq, throughSeq]);
50
+ if (rows.length === 0)
51
+ return;
52
+ for (const row of rows) {
53
+ const commit = decodeOutboxRow(row);
54
+ afterSeq = commit.seq;
55
+ yield commit;
56
+ }
57
+ }
58
+ }
59
+ /** Routine status reads never load operation bodies. */
60
+ export function countOutbox(db) {
61
+ return db.query('SELECT COUNT(*) AS count FROM _syncular_outbox')[0]
62
+ .count;
40
63
  }
41
64
  export function deleteOutboxCommit(db, clientCommitId) {
42
65
  db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?', [clientCommitId]);
@@ -1,5 +1,5 @@
1
1
  import { type SyncAvailability } from './availability.js';
2
- import type { CommitOutcome, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
2
+ import type { CommitOutcome, ClientSnapshotReader, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
3
3
  import type { SqlValue } from './database.js';
4
4
  import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
5
5
  import type { LeadershipState } from './multi-tab.js';
@@ -27,16 +27,14 @@ export interface LiveQueryResult<Row> {
27
27
  readonly isRefreshing: boolean;
28
28
  readonly availability: SyncAvailability;
29
29
  }
30
- export interface ReactiveQueryClient {
30
+ export interface ReactiveQueryClient extends Pick<ClientSnapshotReader, 'statusSnapshot' | 'commitOutcomes'> {
31
31
  readonly currentSchemaVersion?: number;
32
32
  onChange(listener: ClientChangeListener): () => void;
33
33
  querySnapshot<Row = Record<string, SqlValue>>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
34
- statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
35
34
  leadershipSnapshot?(): LeadershipState | undefined;
36
35
  onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
37
- readonly conflicts: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
38
- readonly rejections: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
39
- commitOutcomes(): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
36
+ conflicts(): readonly unknown[] | Promise<readonly unknown[]>;
37
+ rejections(): readonly unknown[] | Promise<readonly unknown[]>;
40
38
  setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
41
39
  windowState(base: WindowBase): WindowState | Promise<WindowState>;
42
40
  }
@@ -85,6 +83,14 @@ export declare class ReactiveClientStore {
85
83
  window(base: WindowBase): ExternalStoreEntry<WindowState>;
86
84
  setWindowClaim(owner: symbol, base: WindowBase, units: readonly string[]): Promise<void>;
87
85
  releaseWindowClaims(owner: symbol): void;
86
+ /** Retained observation counts for diagnostics and resource benchmarks. */
87
+ cacheStats(): {
88
+ queries: number;
89
+ activeQueries: number;
90
+ windows: number;
91
+ activeWindows: number;
92
+ windowClaims: number;
93
+ };
88
94
  start(): void;
89
95
  dispose(): void;
90
96
  }