@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/README.md CHANGED
@@ -14,6 +14,9 @@ server-authoritative commands, and live query watches through the remote
14
14
  operation transport. See [remote server operations](https://syncular.dev/guide-remote-operations/).
15
15
  Its schema and sync transport are optional for query-only or command-only
16
16
  processes.
17
+ Ordinary commits use wire version 1 until the caller supplies an acquired
18
+ partition `logEpoch`. Set `logEpoch` after a restore rotation requires epoch
19
+ validation.
17
20
 
18
21
  ## Client-local FTS5 projections
19
22
 
@@ -69,6 +72,20 @@ SPEC §8.4); the supported page-level realtime supervisor owns reconnect and
69
72
  resume policy. The main thread gets `onSyncNeeded` / `onConflict` / `onSynced`
70
73
  events for rendering.
71
74
 
75
+ A direct `SyncClient` used by a long-running service exposes
76
+ `onSyncNeeded()` and `onSyncIntent()`. Install the shared single-flight loop
77
+ instead of maintaining host timers:
78
+
79
+ ```ts
80
+ const scheduler = installSyncScheduler(client, { onError: reportSyncError });
81
+ // During shutdown:
82
+ scheduler.stop();
83
+ ```
84
+
85
+ Creation-time window helpers produce immutable UTC month scope values.
86
+ `creationTimeBucket(createdAtMs, 'month')` returns `YYYY-MM`, and
87
+ `last(3, 'month')` returns the current and preceding two buckets oldest first.
88
+
72
89
  OPFS is best effort until the browser grants origin persistence. The page owns
73
90
  that decision because `StorageManager.persist()` is a Window API and should be
74
91
  requested from a user action:
@@ -563,3 +580,45 @@ Tests drive the real worker entry in a bun `Worker` with bun:sqlite
563
580
  injected through the bootstrap's database-factory override
564
581
  (`test/worker-rpc.test.ts`); the OPFS path itself is browser-only and is
565
582
  exercised by `apps/demo`.
583
+
584
+ ## Snapshot API migration
585
+
586
+ This source-breaking revision uses methods for application reads across the
587
+ direct client, worker leaders and followers, Tauri, and React Native. Replace
588
+ `client.conflicts`, `client.rejections`, and `client.securityLifecycle` on the
589
+ direct client with method calls. Replace `schemaFloor`, `leaseState`,
590
+ `upgrading`, and `syncNeeded` getters or bridge methods with fields from one
591
+ `statusSnapshot()` call:
592
+
593
+ ```ts
594
+ const status = await client.statusSnapshot();
595
+ if (status.schemaFloor) showUpgradeRequired(status.schemaFloor);
596
+ const conflicts = await client.conflicts();
597
+ const outcome = await client.commitOutcome(commitId);
598
+ ```
599
+
600
+ The direct client returns snapshots synchronously. Worker and native bridges
601
+ return promises; `await` works with both. `querySnapshot` returns rows, coverage,
602
+ and revision from one read. `diagnosticsSnapshot`, `commitOutcome`,
603
+ `commitOutcomes`, and `resolveCommitOutcome` retain their existing arguments.
604
+ The shared `ClientSnapshotMethods` and `PromiseMethods` types describe these
605
+ contracts. Key-bearing security activation stays on each concrete host type.
606
+
607
+ React uses the supplied client directly; `useSyncClient()` preserves its
608
+ identity. Remove imports of `normalizeClient` and the
609
+ `@syncular/client/realtime-supervisor-observation` forwarding utility. Pass the
610
+ client to `SyncProvider` and use `realtimeSupervisorSnapshot(client)` to inspect
611
+ an attached supervisor. Custom React clients must implement the snapshot
612
+ methods and method-form collection reads. See the [React migration](https://syncular.dev/platform-react/)
613
+ for the `onEnqueued` callback rename.
614
+
615
+ ## Outbox read costs
616
+
617
+ Request encoding pins the pending count and highest local sequence before its
618
+ first asynchronous step. It reads keyset pages of 32 raw records, decodes only
619
+ the consumed prefix, and stops at the first whole commit that exceeds the
620
+ remaining operation budget. Mutations appended during encoding enter the next
621
+ request. Status and diagnostics use `COUNT(*)` without parsing pending bodies.
622
+ Optimistic replay still reads the remaining outbox after each response. The
623
+ 100/1,000/10,000-commit workload and measured limits are recorded in
624
+ [the reliability RFC](../../docs/RFC-RELIABILITY-DX.md#9-implementation-evidence-2026-09-05).
@@ -5,6 +5,11 @@
5
5
  */
6
6
  import { Database } from 'bun:sqlite';
7
7
  import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
8
+ declare module 'bun:sqlite' {
9
+ interface Database {
10
+ clearQueryCache(): void;
11
+ }
12
+ }
8
13
  export declare class BunClientDatabase implements ClientDatabase {
9
14
  #private;
10
15
  readonly db: Database;
@@ -23,6 +23,11 @@ export class BunClientDatabase {
23
23
  }
24
24
  exec(sql, params = []) {
25
25
  this.db.query(sql).run(...coerceParams(params));
26
+ // `Database.query()` caches prepared statements. Clear that cache after
27
+ // schema DDL so a reset does not reprepare every later row upsert.
28
+ if (/^\s*(?:CREATE|DROP|ALTER)\b/i.test(sql)) {
29
+ this.db.clearQueryCache();
30
+ }
26
31
  }
27
32
  query(sql, params = []) {
28
33
  return this.db.query(sql).all(...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
@@ -285,6 +295,10 @@ export declare class SyncClient {
285
295
  onInvalidate(listener: InvalidationListener): () => void;
286
296
  /** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
287
297
  onChange(listener: ClientChangeListener): () => void;
298
+ /** Subscribe to host wake signals raised by startup and realtime. */
299
+ onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
300
+ /** Subscribe to exact core-owned scheduling instructions. */
301
+ onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
288
302
  /** Subscribe to complete, privacy-safe diagnostic snapshots. */
289
303
  onDiagnostics(listener: ClientDiagnosticsListener): () => void;
290
304
  /**
@@ -316,8 +330,8 @@ export declare class SyncClient {
316
330
  fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
317
331
  /** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
318
332
  flushBlobUploads(): Promise<void>;
319
- get conflicts(): readonly ConflictRecord[];
320
- get rejections(): readonly RejectionRecord[];
333
+ conflicts(): readonly ConflictRecord[];
334
+ rejections(): readonly RejectionRecord[];
321
335
  /** One durable final outcome by the originating client commit id. */
322
336
  commitOutcome(clientCommitId: string): CommitOutcome | undefined;
323
337
  /** Newest-first durable outcome journal. */
@@ -329,28 +343,11 @@ export declare class SyncClient {
329
343
  * dismissed. The transition is one-way and survives restart.
330
344
  */
331
345
  resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
332
- /** Non-undefined once the server declared a schema floor (§1.6). */
333
- get schemaFloor(): SchemaFloor | undefined;
334
- /**
335
- * §7.4.5: true while a schema-bump reset + first re-bootstrap is in
336
- * flight — the app's "upgrading…" cue. Clears when the first post-reset
337
- * bootstrap round reaches idle (every subscription past its fresh
338
- * bootstrap).
339
- */
340
- get upgrading(): boolean;
341
- /**
342
- * §7.3.5: the current auth-lease state (opaque). Undefined until a
343
- * `LEASE` frame arrives. `errorCode` is set when a round was rejected
344
- * with a request-level lease code — syncing on the lease has stopped.
345
- */
346
- get leaseState(): LeaseState | undefined;
347
346
  /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
348
347
  * `undefined` if no lease is held. Negative once expired. */
349
348
  leaseRemainingMs(now?: number): number | undefined;
350
349
  /** True when syncing is stopped pending a client upgrade. */
351
350
  get stopped(): boolean;
352
- /** §8: a hello/wake-up asked for a pull that has not run yet. */
353
- get syncNeeded(): boolean;
354
351
  /**
355
352
  * §8.6 presence on a scope key: the current peers present there (a map
356
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';
@@ -71,6 +71,7 @@ function emptySummary(pushed) {
71
71
  failed: [],
72
72
  };
73
73
  }
74
+ const LOG_EPOCH_META_KEY = 'logEpoch';
74
75
  function isFinalPushResult(frame) {
75
76
  return (frame.status !== 'rejected' ||
76
77
  !frame.results.some((result) => result.status === 'error' &&
@@ -131,6 +132,8 @@ export class SyncClient {
131
132
  #invalidation = new InvalidationEmitter();
132
133
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
133
134
  #presenceListeners = new Set();
135
+ #syncNeededListeners = new Set();
136
+ #syncIntentListeners = new Set();
134
137
  #diagnostics = new ClientDiagnosticsEmitter();
135
138
  #diagnosticsDeferralDepth = 0;
136
139
  #diagnosticsPending = false;
@@ -229,12 +232,12 @@ export class SyncClient {
229
232
  // this as an exact core-owned intent so hosts never need a startup poll or
230
233
  // an application-issued sync() call.
231
234
  const startupWork = this.#schemaFloor === undefined &&
232
- (listOutbox(this.#db).length > 0 ||
235
+ (countOutbox(this.#db) > 0 ||
233
236
  subscriptions.some((sub) => sub.status === 'active'));
234
237
  if (startupWork && this.#securityLifecycle === 'active') {
235
238
  this.#needsPull = true;
236
- this.#config.onSyncNeeded?.('startup');
237
- this.#config.onSyncIntent?.({ kind: 'interactive' });
239
+ this.#emitSyncNeeded('startup');
240
+ this.#emitSyncIntent({ kind: 'interactive' });
238
241
  }
239
242
  // Console introspection is a no-op outside a dev page.
240
243
  this.#devtoolsUnregister = registerDevtools({
@@ -244,10 +247,10 @@ export class SyncClient {
244
247
  role: () => 'direct',
245
248
  outbox: async () => this.pendingCommits().length,
246
249
  subscriptions: async () => this.subscriptions(),
247
- conflicts: async () => this.conflicts.length,
248
- rejections: async () => this.rejections.length,
249
- syncNeeded: async () => this.syncNeeded,
250
- 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,
251
254
  onInvalidate: (listener) => this.onInvalidate(listener),
252
255
  });
253
256
  this.#emitDiagnostics();
@@ -298,6 +301,29 @@ export class SyncClient {
298
301
  this.#setSchemaFloor(undefined);
299
302
  this.#replayOutbox();
300
303
  }
304
+ /** §2.1 reset after the server reports a different log continuity. */
305
+ #runLogEpochReset(logEpoch) {
306
+ const subscriptions = loadSubscriptions(this.#db);
307
+ const pending = listOutbox(this.#db);
308
+ this.#setUpgrading(true);
309
+ this.#applyBatch((batch) => {
310
+ this.#db.transaction(() => {
311
+ dropAndRecreateSyncedTables(this.#db, this.#schema);
312
+ resetSubscriptionsForBump(this.#db);
313
+ setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
314
+ for (const commit of pending) {
315
+ this.#applyOperationsLocally(commit.operations, batch);
316
+ }
317
+ });
318
+ for (const table of this.#schema.tables.values())
319
+ batch.table(table.name);
320
+ });
321
+ this.#localResetEpoch += 1;
322
+ this.#setSyncNeeded(true);
323
+ this.#emitSyncNeeded('startup');
324
+ this.#emitSyncIntent({ kind: 'interactive' });
325
+ return subscriptions.map((subscription) => subscription.id);
326
+ }
301
327
  #setUpgrading(upgrading) {
302
328
  if (this.#upgrading === upgrading)
303
329
  return;
@@ -325,6 +351,7 @@ export class SyncClient {
325
351
  });
326
352
  }
327
353
  async close() {
354
+ this.#emitSyncIntent({ kind: 'none' });
328
355
  this.#devtoolsUnregister?.();
329
356
  this.#devtoolsUnregister = undefined;
330
357
  this.disconnectRealtime();
@@ -332,9 +359,43 @@ export class SyncClient {
332
359
  await this.#lease?.release();
333
360
  this.#lease = undefined;
334
361
  this.#started = false;
362
+ this.#syncNeededListeners.clear();
363
+ this.#syncIntentListeners.clear();
364
+ }
365
+ #emitSyncNeeded(reason) {
366
+ try {
367
+ this.#config.onSyncNeeded?.(reason);
368
+ }
369
+ catch {
370
+ // An observer cannot alter sync correctness.
371
+ }
372
+ for (const listener of this.#syncNeededListeners) {
373
+ try {
374
+ listener(reason);
375
+ }
376
+ catch {
377
+ // An observer cannot alter sync correctness.
378
+ }
379
+ }
380
+ }
381
+ #emitSyncIntent(intent) {
382
+ try {
383
+ this.#config.onSyncIntent?.(intent);
384
+ }
385
+ catch {
386
+ // An observer cannot alter sync correctness.
387
+ }
388
+ for (const listener of this.#syncIntentListeners) {
389
+ try {
390
+ listener(intent);
391
+ }
392
+ catch {
393
+ // An observer cannot alter sync correctness.
394
+ }
395
+ }
335
396
  }
336
397
  /** Current fail-closed local-replica security state. */
337
- get securityLifecycle() {
398
+ securityLifecycle() {
338
399
  return this.#securityLifecycle;
339
400
  }
340
401
  /**
@@ -378,12 +439,12 @@ export class SyncClient {
378
439
  this.#encryption = options.encryption;
379
440
  this.#securityLifecycle = 'active';
380
441
  const startupWork = this.#schemaFloor === undefined &&
381
- (listOutbox(this.#db).length > 0 ||
442
+ (countOutbox(this.#db) > 0 ||
382
443
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
383
444
  if (startupWork) {
384
445
  this.#setSyncNeeded(true);
385
- this.#config.onSyncNeeded?.('startup');
386
- this.#config.onSyncIntent?.({ kind: 'interactive' });
446
+ this.#emitSyncNeeded('startup');
447
+ this.#emitSyncIntent({ kind: 'interactive' });
387
448
  }
388
449
  this.#emitDiagnostics();
389
450
  }
@@ -479,6 +540,20 @@ export class SyncClient {
479
540
  onChange(listener) {
480
541
  return this.#changes.on(listener);
481
542
  }
543
+ /** Subscribe to host wake signals raised by startup and realtime. */
544
+ onSyncNeeded(listener) {
545
+ this.#syncNeededListeners.add(listener);
546
+ return () => {
547
+ this.#syncNeededListeners.delete(listener);
548
+ };
549
+ }
550
+ /** Subscribe to exact core-owned scheduling instructions. */
551
+ onSyncIntent(listener) {
552
+ this.#syncIntentListeners.add(listener);
553
+ return () => {
554
+ this.#syncIntentListeners.delete(listener);
555
+ };
556
+ }
482
557
  /** Subscribe to complete, privacy-safe diagnostic snapshots. */
483
558
  onDiagnostics(listener) {
484
559
  return this.#diagnostics.on(listener);
@@ -591,7 +666,7 @@ export class SyncClient {
591
666
  replica: {
592
667
  localRevision: getLocalRevision(this.#db).toString(),
593
668
  syncNeeded: this.#needsPull,
594
- pendingOutbox: listOutbox(this.#db).length,
669
+ pendingOutbox: countOutbox(this.#db),
595
670
  },
596
671
  lease: leaseState,
597
672
  subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
@@ -682,7 +757,7 @@ export class SyncClient {
682
757
  #statusSnapshot(outboxCount) {
683
758
  return {
684
759
  currentSchemaVersion: this.#config.schema.version,
685
- outbox: outboxCount ?? listOutbox(this.#db).length,
760
+ outbox: outboxCount ?? countOutbox(this.#db),
686
761
  upgrading: this.#upgrading,
687
762
  leaseState: this.#leaseState,
688
763
  schemaFloor: this.#schemaFloor,
@@ -919,11 +994,11 @@ export class SyncClient {
919
994
  }
920
995
  await transport.upload(blobId, bytes, mediaType);
921
996
  }
922
- get conflicts() {
997
+ conflicts() {
923
998
  this.#requireActive();
924
999
  return this.#conflicts;
925
1000
  }
926
- get rejections() {
1001
+ rejections() {
927
1002
  this.#requireActive();
928
1003
  return this.#rejections;
929
1004
  }
@@ -987,27 +1062,6 @@ export class SyncClient {
987
1062
  return resolved;
988
1063
  });
989
1064
  }
990
- /** Non-undefined once the server declared a schema floor (§1.6). */
991
- get schemaFloor() {
992
- return this.#schemaFloor;
993
- }
994
- /**
995
- * §7.4.5: true while a schema-bump reset + first re-bootstrap is in
996
- * flight — the app's "upgrading…" cue. Clears when the first post-reset
997
- * bootstrap round reaches idle (every subscription past its fresh
998
- * bootstrap).
999
- */
1000
- get upgrading() {
1001
- return this.#upgrading;
1002
- }
1003
- /**
1004
- * §7.3.5: the current auth-lease state (opaque). Undefined until a
1005
- * `LEASE` frame arrives. `errorCode` is set when a round was rejected
1006
- * with a request-level lease code — syncing on the lease has stopped.
1007
- */
1008
- get leaseState() {
1009
- return this.#leaseState;
1010
- }
1011
1065
  /** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
1012
1066
  * `undefined` if no lease is held. Negative once expired. */
1013
1067
  leaseRemainingMs(now = this.#now()) {
@@ -1018,10 +1072,6 @@ export class SyncClient {
1018
1072
  get stopped() {
1019
1073
  return this.#schemaFloor !== undefined;
1020
1074
  }
1021
- /** §8: a hello/wake-up asked for a pull that has not run yet. */
1022
- get syncNeeded() {
1023
- return this.#needsPull;
1024
- }
1025
1075
  /**
1026
1076
  * §8.6 presence on a scope key: the current peers present there (a map
1027
1077
  * of `actorId clientId` → peer). Empty for a key with no present peers.
@@ -1119,11 +1169,17 @@ export class SyncClient {
1119
1169
  cursor: -1,
1120
1170
  status: 'active',
1121
1171
  });
1172
+ this.#setSyncNeeded(true);
1173
+ this.#emitSyncIntent({ kind: 'interactive' });
1122
1174
  this.#emitDiagnostics();
1123
1175
  }
1124
1176
  unsubscribe(id) {
1125
1177
  this.#requireActive();
1178
+ if (getSubscription(this.#db, id) === undefined)
1179
+ return;
1126
1180
  deleteSubscription(this.#db, id);
1181
+ this.#setSyncNeeded(true);
1182
+ this.#emitSyncIntent({ kind: 'interactive' });
1127
1183
  this.#emitDiagnostics();
1128
1184
  }
1129
1185
  // -- windowed subscriptions (§4.8) ------------------------------------------
@@ -1179,7 +1235,9 @@ export class SyncClient {
1179
1235
  status: 'active',
1180
1236
  });
1181
1237
  });
1238
+ this.#needsPull = true;
1182
1239
  batch.window(baseKey, base.table, unit);
1240
+ batch.status();
1183
1241
  });
1184
1242
  changed = true;
1185
1243
  widened = true;
@@ -1195,6 +1253,9 @@ export class SyncClient {
1195
1253
  const effects = {
1196
1254
  sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1197
1255
  };
1256
+ if (effects.sync.kind === 'interactive') {
1257
+ this.#emitSyncIntent(effects.sync);
1258
+ }
1198
1259
  return { value: undefined, effects };
1199
1260
  }
1200
1261
  /**
@@ -1254,6 +1315,8 @@ export class SyncClient {
1254
1315
  });
1255
1316
  batch.scopeMap(table, effective);
1256
1317
  batch.window(baseKey, table.name, unit);
1318
+ this.#needsPull = true;
1319
+ batch.status();
1257
1320
  });
1258
1321
  }
1259
1322
  /**
@@ -1349,7 +1412,9 @@ export class SyncClient {
1349
1412
  this.#applyOperationsLocally(operations, batch);
1350
1413
  batch.status();
1351
1414
  });
1415
+ this.#needsPull = true;
1352
1416
  });
1417
+ this.#emitSyncIntent({ kind: 'interactive' });
1353
1418
  return clientCommitId;
1354
1419
  }
1355
1420
  /** Host-facing mutation result with explicit network work intent (§7.5). */
@@ -1577,8 +1642,8 @@ export class SyncClient {
1577
1642
  this.#localResetEpoch += 1;
1578
1643
  if (!priorUpgrading)
1579
1644
  this.#config.onUpgrading?.(true);
1580
- this.#config.onSyncNeeded?.('startup');
1581
- this.#config.onSyncIntent?.({ kind: 'interactive' });
1645
+ this.#emitSyncNeeded('startup');
1646
+ this.#emitSyncIntent({ kind: 'interactive' });
1582
1647
  return {
1583
1648
  alreadyApplied: false,
1584
1649
  retainedCommits: pending.length,
@@ -1641,21 +1706,27 @@ export class SyncClient {
1641
1706
  * the encoded push frames index-aligned with the surviving `outbox`.
1642
1707
  */
1643
1708
  async #encodeOutboxForPush() {
1644
- const pending = listOutbox(this.#db);
1709
+ // Pin before the first encryption await: mutations can append while a
1710
+ // round is encoding, and belong to the next request.
1711
+ const bounds = this.#db.query('SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox')[0];
1712
+ const pendingCount = bounds.count;
1713
+ const throughSeq = bounds.last_seq ?? 0;
1645
1714
  const pushFrames = [];
1646
1715
  const outbox = [];
1647
1716
  let deferred = 0;
1648
1717
  let ops = 0;
1649
- for (const commit of pending) {
1718
+ let processed = 0;
1719
+ for (const commit of iterateOutbox(this.#db, throughSeq)) {
1650
1720
  // §6.1 splitBatch: whole commits in commit order, stopping before the
1651
1721
  // per-request operation cap. A first commit that alone exceeds the cap
1652
1722
  // is sent alone — the server rejects it loudly rather than the queue
1653
1723
  // wedging silently. Deferred commits stay queued for the next round.
1654
1724
  if (outbox.length > 0 &&
1655
1725
  ops + commit.operations.length > MAX_OPS_PER_REQUEST) {
1656
- deferred += 1;
1657
- continue;
1726
+ deferred = pendingCount - processed;
1727
+ break;
1658
1728
  }
1729
+ processed += 1;
1659
1730
  try {
1660
1731
  pushFrames.push(
1661
1732
  // §5.11: encrypted columns are encrypted at this encode-at-send
@@ -1798,9 +1869,12 @@ export class SyncClient {
1798
1869
  // survive it — the reference server keeps no replay buffer (§8.2).
1799
1870
  this.#setSyncNeeded(false);
1800
1871
  try {
1872
+ const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
1801
1873
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
1802
1874
  // them, so the server-side existence check (§6.6) passes.
1803
- if (this.#hasBlobs && this.#config.blobs !== undefined) {
1875
+ if (logEpoch !== undefined &&
1876
+ this.#hasBlobs &&
1877
+ this.#config.blobs !== undefined) {
1804
1878
  await this.flushBlobUploads();
1805
1879
  }
1806
1880
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
@@ -1808,7 +1882,9 @@ export class SyncClient {
1808
1882
  // is removed from the push and surfaced as a rejection, never wedging
1809
1883
  // the queue. `pushFrames` and `outbox` stay index-aligned for result
1810
1884
  // mapping.
1811
- const { pushFrames, outbox, deferred } = await this.#encodeOutboxForPush();
1885
+ const { pushFrames, outbox, deferred } = logEpoch === undefined
1886
+ ? { pushFrames: [], outbox: [], deferred: 0 }
1887
+ : await this.#encodeOutboxForPush();
1812
1888
  // Captured together with the subscription state below: the response
1813
1889
  // apply persists SUB_END cursors only while this epoch is current.
1814
1890
  const resetEpoch = this.#localResetEpoch;
@@ -1819,6 +1895,7 @@ export class SyncClient {
1819
1895
  type: 'REQ_HEADER',
1820
1896
  clientId: this.#clientId,
1821
1897
  schemaVersion: this.#schema.version,
1898
+ ...(logEpoch !== undefined ? { logEpoch } : {}),
1822
1899
  },
1823
1900
  ...pushFrames,
1824
1901
  {
@@ -1885,12 +1962,7 @@ export class SyncClient {
1885
1962
  delayMs: this.#retryDelayMs,
1886
1963
  };
1887
1964
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
1888
- try {
1889
- this.#config.onSyncIntent?.(intent);
1890
- }
1891
- catch {
1892
- // An observer cannot alter sync correctness.
1893
- }
1965
+ this.#emitSyncIntent(intent);
1894
1966
  }
1895
1967
  throw error;
1896
1968
  }
@@ -1912,7 +1984,8 @@ export class SyncClient {
1912
1984
  last.segmentRowsApplied === 0 &&
1913
1985
  last.bootstrapping.length === 0 &&
1914
1986
  last.resets.length === 0 &&
1915
- (last.deferredCommits ?? 0) === 0) {
1987
+ (last.deferredCommits ?? 0) === 0 &&
1988
+ !this.#needsPull) {
1916
1989
  return last;
1917
1990
  }
1918
1991
  }
@@ -2078,14 +2151,14 @@ export class SyncClient {
2078
2151
  if (event.event === 'hello') {
2079
2152
  if (event.data.requiresSync) {
2080
2153
  this.#setSyncNeeded(true);
2081
- this.#config.onSyncNeeded?.('hello');
2154
+ this.#emitSyncNeeded('hello');
2082
2155
  }
2083
2156
  return;
2084
2157
  }
2085
2158
  if (event.event === 'sync') {
2086
2159
  // §8.3: any wake-up means "run a pull soon", never data.
2087
2160
  this.#setSyncNeeded(true);
2088
- this.#config.onSyncNeeded?.(event.data.reason);
2161
+ this.#emitSyncNeeded(event.data.reason);
2089
2162
  return;
2090
2163
  }
2091
2164
  if (event.event === 'presence') {
@@ -2155,7 +2228,7 @@ export class SyncClient {
2155
2228
  catch {
2156
2229
  // A delta that cannot be applied is recovered by a pull (§8.3).
2157
2230
  this.#setSyncNeeded(true);
2158
- this.#config.onSyncNeeded?.('catchup-required');
2231
+ this.#emitSyncNeeded('catchup-required');
2159
2232
  }
2160
2233
  });
2161
2234
  }
@@ -2195,6 +2268,11 @@ export class SyncClient {
2195
2268
  if (header?.type !== 'RESP_HEADER') {
2196
2269
  throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
2197
2270
  }
2271
+ if (message.wireVersion < 2 ||
2272
+ header.logEpoch === undefined ||
2273
+ header.resetRequired === undefined) {
2274
+ throw new ClientSyncError('client.invalid_host_response', 'the server response does not carry wire version 2 log-epoch state');
2275
+ }
2198
2276
  if (header.requiredSchemaVersion !== undefined) {
2199
2277
  // §1.6 schema floor: nothing else was processed — stop syncing and
2200
2278
  // surface the upgrade requirement. A live-round floor always stops:
@@ -2216,6 +2294,20 @@ export class SyncClient {
2216
2294
  schemaFloor,
2217
2295
  };
2218
2296
  }
2297
+ const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
2298
+ if (header.resetRequired) {
2299
+ if (mode !== 'pull' || message.frames.length !== 1) {
2300
+ throw new ClientSyncError('client.invalid_host_response', 'a log-epoch reset response must contain only RESP_HEADER');
2301
+ }
2302
+ return {
2303
+ ...summary,
2304
+ resets: this.#runLogEpochReset(header.logEpoch),
2305
+ bootstrapping: [],
2306
+ };
2307
+ }
2308
+ if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
2309
+ throw new ClientSyncError('client.invalid_host_response', 'the server changed logEpoch without requiring a reset');
2310
+ }
2219
2311
  let section;
2220
2312
  let errorFrame;
2221
2313
  let deltaCursor = -1;
@@ -2237,7 +2329,7 @@ export class SyncClient {
2237
2329
  });
2238
2330
  break;
2239
2331
  case 'PUSH_RESULT': {
2240
- let outboxCount = responseOutboxCount ?? listOutbox(this.#db).length;
2332
+ let outboxCount = responseOutboxCount ?? countOutbox(this.#db);
2241
2333
  this.#applyBatch((batch) => {
2242
2334
  const drained = this.#handlePushResult(frame, commitsById, summary, batch, rejectionDetailsByCommit.get(frame.clientCommitId), frame === lastFinalPushResult);
2243
2335
  if (drained)
package/dist/index.d.ts CHANGED
@@ -35,6 +35,7 @@ export * from './realtime-supervisor.js';
35
35
  export * from './schema.js';
36
36
  export * from './sql-tag.js';
37
37
  export * from './state.js';
38
+ export * from './sync-scheduler.js';
38
39
  export * from './transport.js';
39
40
  export * from './window.js';
40
41
  export * from './worker-host.js';
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ export * from './realtime-supervisor.js';
35
35
  export * from './schema.js';
36
36
  export * from './sql-tag.js';
37
37
  export * from './state.js';
38
+ export * from './sync-scheduler.js';
38
39
  export * from './transport.js';
39
40
  export * from './window.js';
40
41
  export * from './worker-host.js';
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;