@syncular/client 0.45.0 → 0.46.3

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/dist/client.d.ts CHANGED
@@ -95,6 +95,16 @@ export interface SyncSummary {
95
95
  export interface SyncIdleResult extends SyncSummary {
96
96
  readonly budgetExhausted: boolean;
97
97
  }
98
+ export interface SyncIdleOptions {
99
+ /** §7.7: a strict positive round count. Default: 20. */
100
+ readonly maxRounds?: number;
101
+ /**
102
+ * Total wall-clock budget for one drive, across every round. When it
103
+ * elapses the drive rejects with `sync.idle_timeout`; a socket round in
104
+ * flight is aborted. Default: 60,000 ms. `Infinity` disables the bound.
105
+ */
106
+ readonly timeoutMs?: number;
107
+ }
98
108
  export interface SyncClientLimits {
99
109
  readonly limitCommits?: number;
100
110
  readonly limitSnapshotRows?: number;
@@ -663,8 +673,13 @@ export declare class SyncClient {
663
673
  * caller may have mutated the outbox after the in-flight drive captured it.
664
674
  * Callers that arrive during the same drive with the same budget share that
665
675
  * single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
676
+ *
677
+ * §7.7: one drive is bounded by a total deadline (`timeoutMs`, default
678
+ * 60 s). A drive that reaches it rejects with `sync.idle_timeout` and
679
+ * aborts a socket round in flight; the round budget does not bound elapsed
680
+ * time, and a transport that never answers must not hang the caller.
666
681
  */
667
- syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
682
+ syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult>;
668
683
  connectRealtime(): Promise<void>;
669
684
  disconnectRealtime(): void;
670
685
  }
package/dist/client.js CHANGED
@@ -86,6 +86,8 @@ function emptySummary(pushed) {
86
86
  };
87
87
  }
88
88
  const LOG_EPOCH_META_KEY = 'logEpoch';
89
+ /** §7.7: one `syncUntilIdle` drive's default total deadline. */
90
+ const SYNC_IDLE_TIMEOUT_MS = 60_000;
89
91
  /** RFC 0005 defaults (D2/A4/D7). */
90
92
  const PREVIOUS_VERSION_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
91
93
  const PREVIOUS_VERSION_DEFAULT_MAX_ROWS = 20_000;
@@ -181,8 +183,9 @@ export class SyncClient {
181
183
  /**
182
184
  * Idle drives run one after another (§8.4): `#syncIdleTail` settles when
183
185
  * the last scheduled drive settles, and `#syncIdlePending` holds the
184
- * scheduled drives that have not started, keyed by round budget, so a
185
- * caller joins a waiting drive with its budget instead of queueing another.
186
+ * scheduled drives that have not started, keyed by round budget and
187
+ * deadline, so a caller joins a waiting drive with its budget instead of
188
+ * queueing another.
186
189
  */
187
190
  #syncIdleTail;
188
191
  #syncIdlePending = new Map();
@@ -2959,7 +2962,22 @@ export class SyncClient {
2959
2962
  const explicitlyRetryable = error.retryable;
2960
2963
  const retryable = explicitlyRetryable === true ||
2961
2964
  (explicitlyRetryable === undefined && typeof code !== 'string');
2962
- if (retryable) {
2965
+ // §1.8: a refused or unreachable endpoint (`connect` from the native
2966
+ // transport, `network` from a browser fetch) is not a round worth
2967
+ // backing off for. Stop without scheduling an intent or growing the
2968
+ // delay; the host's own connectivity signal re-arms the drive.
2969
+ const details = error instanceof ClientSyncError
2970
+ ? error.details
2971
+ : typeof error === 'object' && error !== null && 'details' in error
2972
+ ? error.details
2973
+ : undefined;
2974
+ const causeKind = typeof details === 'object' &&
2975
+ details !== null &&
2976
+ 'causeKind' in details
2977
+ ? details.causeKind
2978
+ : undefined;
2979
+ const unreachable = causeKind === 'connect' || causeKind === 'network';
2980
+ if (retryable && !unreachable) {
2963
2981
  const realtimeUnavailable = error instanceof RealtimeUnavailableError;
2964
2982
  const delay = realtimeUnavailable
2965
2983
  ? error.retryDelayMs
@@ -2994,27 +3012,40 @@ export class SyncClient {
2994
3012
  * caller may have mutated the outbox after the in-flight drive captured it.
2995
3013
  * Callers that arrive during the same drive with the same budget share that
2996
3014
  * single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
3015
+ *
3016
+ * §7.7: one drive is bounded by a total deadline (`timeoutMs`, default
3017
+ * 60 s). A drive that reaches it rejects with `sync.idle_timeout` and
3018
+ * aborts a socket round in flight; the round budget does not bound elapsed
3019
+ * time, and a transport that never answers must not hang the caller.
2997
3020
  */
2998
- async syncUntilIdle(maxRounds) {
3021
+ async syncUntilIdle(options) {
3022
+ const maxRounds = typeof options === 'number' ? options : options?.maxRounds;
3023
+ const timeoutMs = typeof options === 'number'
3024
+ ? SYNC_IDLE_TIMEOUT_MS
3025
+ : (options?.timeoutMs ?? SYNC_IDLE_TIMEOUT_MS);
2999
3026
  // §7.7: an explicit budget is a strict positive round count. Zero,
3000
3027
  // fractional, and out-of-range values are caller errors.
3001
3028
  if (maxRounds !== undefined &&
3002
3029
  (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff))
3003
3030
  throw invalidRequest('maxRounds must be a positive integer');
3004
- const pending = this.#syncIdlePending.get(maxRounds);
3031
+ if (timeoutMs !== Number.POSITIVE_INFINITY &&
3032
+ !(Number.isFinite(timeoutMs) && timeoutMs > 0))
3033
+ throw invalidRequest('timeoutMs must be a positive number or Infinity');
3034
+ const key = `${maxRounds ?? 'default'}:${timeoutMs}`;
3035
+ const pending = this.#syncIdlePending.get(key);
3005
3036
  if (pending !== undefined)
3006
3037
  return pending;
3007
3038
  const previous = this.#syncIdleTail;
3008
3039
  let drive;
3009
3040
  if (previous === undefined) {
3010
- drive = this.#syncIdleRounds(maxRounds);
3041
+ drive = this.#syncIdleRounds(maxRounds, timeoutMs);
3011
3042
  }
3012
3043
  else {
3013
3044
  drive = previous.then(() => {
3014
- this.#syncIdlePending.delete(maxRounds);
3015
- return this.#syncIdleRounds(maxRounds);
3045
+ this.#syncIdlePending.delete(key);
3046
+ return this.#syncIdleRounds(maxRounds, timeoutMs);
3016
3047
  });
3017
- this.#syncIdlePending.set(maxRounds, drive);
3048
+ this.#syncIdlePending.set(key, drive);
3018
3049
  }
3019
3050
  const tail = drive.then(() => undefined, () => undefined);
3020
3051
  this.#syncIdleTail = tail;
@@ -3025,7 +3056,7 @@ export class SyncClient {
3025
3056
  return drive;
3026
3057
  }
3027
3058
  /** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
3028
- async #syncIdleRounds(maxRounds) {
3059
+ async #syncIdleRounds(maxRounds, timeoutMs) {
3029
3060
  const budget = maxRounds ?? 20;
3030
3061
  const aggregate = {
3031
3062
  pushed: 0,
@@ -3062,46 +3093,76 @@ export class SyncClient {
3062
3093
  : {}),
3063
3094
  budgetExhausted,
3064
3095
  });
3065
- for (let round = 0; round < budget; round++) {
3066
- const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
3067
- const last = await this.sync();
3068
- // Historical counters accumulate; current readiness (`bootstrapping`,
3069
- // `deferredCommits`, `schemaFloor`) describes the latest round.
3070
- aggregate.pushed += last.pushed;
3071
- aggregate.applied.push(...last.applied);
3072
- aggregate.rejected.push(...last.rejected);
3073
- aggregate.retryable.push(...last.retryable);
3074
- aggregate.conflicts.push(...last.conflicts);
3075
- aggregate.commitsApplied += last.commitsApplied;
3076
- aggregate.segmentRowsApplied += last.segmentRowsApplied;
3077
- aggregate.bootstrapping = [...last.bootstrapping];
3078
- aggregate.resets.push(...last.resets);
3079
- aggregate.revoked.push(...last.revoked);
3080
- aggregate.failed.push(...last.failed);
3081
- aggregate.deferredCommits = last.deferredCommits ?? 0;
3082
- aggregate.schemaFloor = last.schemaFloor;
3083
- // A disabled transport or a schema floor ends the loop, not the budget.
3084
- if (!this.#transportEnabled || last.schemaFloor !== undefined)
3085
- return result(false);
3086
- if (maxRounds === undefined &&
3087
- last.segmentRowsApplied > 0 &&
3088
- last.bootstrapping.length > 0 &&
3089
- before !==
3090
- JSON.stringify(loadSubscriptions(this.#db).map((sub) => [
3091
- sub.id,
3092
- sub.bootstrapState,
3093
- ])))
3094
- round = -1;
3095
- if (last.commitsApplied === 0 &&
3096
- last.segmentRowsApplied === 0 &&
3097
- last.bootstrapping.length === 0 &&
3098
- last.resets.length === 0 &&
3099
- (last.deferredCommits ?? 0) === 0 &&
3100
- !this.#needsPull) {
3101
- return result(false);
3096
+ let round = 0;
3097
+ let expired = false;
3098
+ let timer;
3099
+ const deadline = timeoutMs === Number.POSITIVE_INFINITY
3100
+ ? undefined
3101
+ : new Promise((_, reject) => {
3102
+ timer = setTimeout(() => {
3103
+ expired = true;
3104
+ // The transport may never answer; release the round so the
3105
+ // client can run another one after the caller sees the error.
3106
+ this.#abortPendingRound('syncUntilIdle deadline exceeded');
3107
+ reject(new ClientSyncError('sync.idle_timeout', 'syncUntilIdle exceeded its deadline', true, { timeoutMs, rounds: round }));
3108
+ }, timeoutMs);
3109
+ });
3110
+ const drive = (async () => {
3111
+ for (; round < budget; round++) {
3112
+ // The race already rejected; stop spending rounds on a dead drive.
3113
+ if (expired)
3114
+ throw new ClientSyncError('sync.idle_timeout', 'syncUntilIdle exceeded its deadline', true, { timeoutMs, rounds: round });
3115
+ const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [
3116
+ sub.id,
3117
+ sub.bootstrapState,
3118
+ ]));
3119
+ const last = await this.sync();
3120
+ // Historical counters accumulate; current readiness (`bootstrapping`,
3121
+ // `deferredCommits`, `schemaFloor`) describes the latest round.
3122
+ aggregate.pushed += last.pushed;
3123
+ aggregate.applied.push(...last.applied);
3124
+ aggregate.rejected.push(...last.rejected);
3125
+ aggregate.retryable.push(...last.retryable);
3126
+ aggregate.conflicts.push(...last.conflicts);
3127
+ aggregate.commitsApplied += last.commitsApplied;
3128
+ aggregate.segmentRowsApplied += last.segmentRowsApplied;
3129
+ aggregate.bootstrapping = [...last.bootstrapping];
3130
+ aggregate.resets.push(...last.resets);
3131
+ aggregate.revoked.push(...last.revoked);
3132
+ aggregate.failed.push(...last.failed);
3133
+ aggregate.deferredCommits = last.deferredCommits ?? 0;
3134
+ aggregate.schemaFloor = last.schemaFloor;
3135
+ // A disabled transport or a schema floor ends the loop, not the budget.
3136
+ if (!this.#transportEnabled || last.schemaFloor !== undefined)
3137
+ return result(false);
3138
+ if (maxRounds === undefined &&
3139
+ last.segmentRowsApplied > 0 &&
3140
+ last.bootstrapping.length > 0 &&
3141
+ before !==
3142
+ JSON.stringify(loadSubscriptions(this.#db).map((sub) => [
3143
+ sub.id,
3144
+ sub.bootstrapState,
3145
+ ])))
3146
+ round = -1;
3147
+ if (last.commitsApplied === 0 &&
3148
+ last.segmentRowsApplied === 0 &&
3149
+ last.bootstrapping.length === 0 &&
3150
+ last.resets.length === 0 &&
3151
+ (last.deferredCommits ?? 0) === 0 &&
3152
+ !this.#needsPull) {
3153
+ return result(false);
3154
+ }
3102
3155
  }
3156
+ return result(true);
3157
+ })();
3158
+ try {
3159
+ return await (deadline === undefined
3160
+ ? drive
3161
+ : Promise.race([drive, deadline]));
3162
+ }
3163
+ finally {
3164
+ clearTimeout(timer);
3103
3165
  }
3104
- return result(true);
3105
3166
  }
3106
3167
  /**
3107
3168
  * One request/response round trip (§8.7): over the socket whenever it
@@ -367,9 +367,9 @@ export function startSyncWorker(overrides = {}) {
367
367
  const running = requireClient();
368
368
  return serializedSync(() => running.sync());
369
369
  },
370
- syncUntilIdle: (maxRounds) => {
370
+ syncUntilIdle: (options) => {
371
371
  const running = requireClient();
372
- return serializedSync(() => running.syncUntilIdle(maxRounds));
372
+ return serializedSync(() => running.syncUntilIdle(options));
373
373
  },
374
374
  resetClientId: () => requireClient().resetClientId(),
375
375
  localRevision: () => requireClient().localRevision,
@@ -25,7 +25,7 @@ import type { PromiseMethods } from './client.js';
25
25
  */
26
26
  import type { WakeReason } from '@syncular/core';
27
27
  import type { BlobRef, CachedBlob } from './blob.js';
28
- import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SnapshotRead, SnapshotReadRequest, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
28
+ import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SnapshotRead, SnapshotReadRequest, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleOptions, SyncIdleResult, WindowState } from './client.js';
29
29
  import type { SqlRow, SqlValue } from './database.js';
30
30
  import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot, type RealtimePolicy } from './diagnostics.js';
31
31
  import type { EncryptionKeyringConfig } from './encryption.js';
@@ -245,7 +245,7 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
245
245
  discarded: boolean;
246
246
  }>;
247
247
  sync(): Promise<SyncSummary>;
248
- syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
248
+ syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult>;
249
249
  /**
250
250
  * Rotate the client id (§1.5) after a `sync.invalid_client_id` refusal.
251
251
  * The worker's next round carries the new id; the replica and outbox stay
@@ -329,8 +329,8 @@ export class SyncClientHandle {
329
329
  sync() {
330
330
  return this.#call('sync', []);
331
331
  }
332
- syncUntilIdle(maxRounds) {
333
- return this.#call('syncUntilIdle', [maxRounds]);
332
+ syncUntilIdle(options) {
333
+ return this.#call('syncUntilIdle', [options]);
334
334
  }
335
335
  /**
336
336
  * Rotate the client id (§1.5) after a `sync.invalid_client_id` refusal.
@@ -19,7 +19,7 @@ import type { SyncProgress } from './progress.js';
19
19
  */
20
20
  import type { WakeReason } from '@syncular/core';
21
21
  import type { BlobRef, CachedBlob } from './blob.js';
22
- import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
22
+ import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleOptions, SyncIdleResult, WindowState } from './client.js';
23
23
  import type { SqlRow, SqlValue } from './database.js';
24
24
  import type { ClientDiagnosticsSnapshot, RealtimePolicy } from './diagnostics.js';
25
25
  import type { EncryptionKeyringConfig } from './encryption.js';
@@ -138,7 +138,7 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
138
138
  discarded: boolean;
139
139
  };
140
140
  sync(): Promise<SyncSummary>;
141
- syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
141
+ syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult>;
142
142
  resetClientId(): string;
143
143
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
144
144
  authoritySnapshot(): import('./authority.js').AuthoritySnapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.45.0",
3
+ "version": "0.46.3",
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",
@@ -97,10 +97,10 @@
97
97
  },
98
98
  "dependencies": {
99
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
100
- "@syncular/core": "0.45.0"
100
+ "@syncular/core": "0.46.3"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.45.0",
103
+ "@syncular/server": "0.46.3",
104
104
  "playwright": "1.63.0",
105
105
  "@electric-sql/pglite": "^0.5.4"
106
106
  }
package/src/client.ts CHANGED
@@ -354,6 +354,17 @@ export interface SyncIdleResult extends SyncSummary {
354
354
  readonly budgetExhausted: boolean;
355
355
  }
356
356
 
357
+ export interface SyncIdleOptions {
358
+ /** §7.7: a strict positive round count. Default: 20. */
359
+ readonly maxRounds?: number;
360
+ /**
361
+ * Total wall-clock budget for one drive, across every round. When it
362
+ * elapses the drive rejects with `sync.idle_timeout`; a socket round in
363
+ * flight is aborted. Default: 60,000 ms. `Infinity` disables the bound.
364
+ */
365
+ readonly timeoutMs?: number;
366
+ }
367
+
357
368
  export interface SyncClientLimits {
358
369
  readonly limitCommits?: number;
359
370
  readonly limitSnapshotRows?: number;
@@ -730,6 +741,9 @@ function emptySummary(pushed: number): MutableSummary {
730
741
 
731
742
  const LOG_EPOCH_META_KEY = 'logEpoch';
732
743
 
744
+ /** §7.7: one `syncUntilIdle` drive's default total deadline. */
745
+ const SYNC_IDLE_TIMEOUT_MS = 60_000;
746
+
733
747
  /** RFC 0005 defaults (D2/A4/D7). */
734
748
  const PREVIOUS_VERSION_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
735
749
  const PREVIOUS_VERSION_DEFAULT_MAX_ROWS = 20_000;
@@ -898,14 +912,12 @@ export class SyncClient {
898
912
  /**
899
913
  * Idle drives run one after another (§8.4): `#syncIdleTail` settles when
900
914
  * the last scheduled drive settles, and `#syncIdlePending` holds the
901
- * scheduled drives that have not started, keyed by round budget, so a
902
- * caller joins a waiting drive with its budget instead of queueing another.
915
+ * scheduled drives that have not started, keyed by round budget and
916
+ * deadline, so a caller joins a waiting drive with its budget instead of
917
+ * queueing another.
903
918
  */
904
919
  #syncIdleTail: Promise<void> | undefined;
905
- readonly #syncIdlePending = new Map<
906
- number | undefined,
907
- Promise<SyncIdleResult>
908
- >();
920
+ readonly #syncIdlePending = new Map<string, Promise<SyncIdleResult>>();
909
921
  /**
910
922
  * Bumped by every local projection reset (`rebootstrapLocalData`). A sync
911
923
  * round captures this epoch together with its subscription state; a
@@ -4270,7 +4282,24 @@ export class SyncClient {
4270
4282
  const retryable =
4271
4283
  explicitlyRetryable === true ||
4272
4284
  (explicitlyRetryable === undefined && typeof code !== 'string');
4273
- if (retryable) {
4285
+ // §1.8: a refused or unreachable endpoint (`connect` from the native
4286
+ // transport, `network` from a browser fetch) is not a round worth
4287
+ // backing off for. Stop without scheduling an intent or growing the
4288
+ // delay; the host's own connectivity signal re-arms the drive.
4289
+ const details =
4290
+ error instanceof ClientSyncError
4291
+ ? error.details
4292
+ : typeof error === 'object' && error !== null && 'details' in error
4293
+ ? error.details
4294
+ : undefined;
4295
+ const causeKind =
4296
+ typeof details === 'object' &&
4297
+ details !== null &&
4298
+ 'causeKind' in details
4299
+ ? details.causeKind
4300
+ : undefined;
4301
+ const unreachable = causeKind === 'connect' || causeKind === 'network';
4302
+ if (retryable && !unreachable) {
4274
4303
  const realtimeUnavailable = error instanceof RealtimeUnavailableError;
4275
4304
  const delay = realtimeUnavailable
4276
4305
  ? error.retryDelayMs
@@ -4307,8 +4336,21 @@ export class SyncClient {
4307
4336
  * caller may have mutated the outbox after the in-flight drive captured it.
4308
4337
  * Callers that arrive during the same drive with the same budget share that
4309
4338
  * single follow-up. `sync()` keeps rejecting a concurrent `sync()`.
4339
+ *
4340
+ * §7.7: one drive is bounded by a total deadline (`timeoutMs`, default
4341
+ * 60 s). A drive that reaches it rejects with `sync.idle_timeout` and
4342
+ * aborts a socket round in flight; the round budget does not bound elapsed
4343
+ * time, and a transport that never answers must not hang the caller.
4310
4344
  */
4311
- async syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult> {
4345
+ async syncUntilIdle(
4346
+ options?: number | SyncIdleOptions,
4347
+ ): Promise<SyncIdleResult> {
4348
+ const maxRounds =
4349
+ typeof options === 'number' ? options : options?.maxRounds;
4350
+ const timeoutMs =
4351
+ typeof options === 'number'
4352
+ ? SYNC_IDLE_TIMEOUT_MS
4353
+ : (options?.timeoutMs ?? SYNC_IDLE_TIMEOUT_MS);
4312
4354
  // §7.7: an explicit budget is a strict positive round count. Zero,
4313
4355
  // fractional, and out-of-range values are caller errors.
4314
4356
  if (
@@ -4316,18 +4358,24 @@ export class SyncClient {
4316
4358
  (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff)
4317
4359
  )
4318
4360
  throw invalidRequest('maxRounds must be a positive integer');
4319
- const pending = this.#syncIdlePending.get(maxRounds);
4361
+ if (
4362
+ timeoutMs !== Number.POSITIVE_INFINITY &&
4363
+ !(Number.isFinite(timeoutMs) && timeoutMs > 0)
4364
+ )
4365
+ throw invalidRequest('timeoutMs must be a positive number or Infinity');
4366
+ const key = `${maxRounds ?? 'default'}:${timeoutMs}`;
4367
+ const pending = this.#syncIdlePending.get(key);
4320
4368
  if (pending !== undefined) return pending;
4321
4369
  const previous = this.#syncIdleTail;
4322
4370
  let drive: Promise<SyncIdleResult>;
4323
4371
  if (previous === undefined) {
4324
- drive = this.#syncIdleRounds(maxRounds);
4372
+ drive = this.#syncIdleRounds(maxRounds, timeoutMs);
4325
4373
  } else {
4326
4374
  drive = previous.then(() => {
4327
- this.#syncIdlePending.delete(maxRounds);
4328
- return this.#syncIdleRounds(maxRounds);
4375
+ this.#syncIdlePending.delete(key);
4376
+ return this.#syncIdleRounds(maxRounds, timeoutMs);
4329
4377
  });
4330
- this.#syncIdlePending.set(maxRounds, drive);
4378
+ this.#syncIdlePending.set(key, drive);
4331
4379
  }
4332
4380
  const tail = drive.then(
4333
4381
  () => undefined,
@@ -4343,6 +4391,7 @@ export class SyncClient {
4343
4391
  /** The rounds loop of one idle drive; `syncUntilIdle` owns coalescing. */
4344
4392
  async #syncIdleRounds(
4345
4393
  maxRounds: number | undefined,
4394
+ timeoutMs: number,
4346
4395
  ): Promise<SyncIdleResult> {
4347
4396
  const budget = maxRounds ?? 20;
4348
4397
  const aggregate = {
@@ -4380,54 +4429,96 @@ export class SyncClient {
4380
4429
  : {}),
4381
4430
  budgetExhausted,
4382
4431
  });
4383
- for (let round = 0; round < budget; round++) {
4384
- const before = JSON.stringify(
4385
- loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
4386
- );
4387
- const last = await this.sync();
4388
- // Historical counters accumulate; current readiness (`bootstrapping`,
4389
- // `deferredCommits`, `schemaFloor`) describes the latest round.
4390
- aggregate.pushed += last.pushed;
4391
- aggregate.applied.push(...last.applied);
4392
- aggregate.rejected.push(...last.rejected);
4393
- aggregate.retryable.push(...last.retryable);
4394
- aggregate.conflicts.push(...last.conflicts);
4395
- aggregate.commitsApplied += last.commitsApplied;
4396
- aggregate.segmentRowsApplied += last.segmentRowsApplied;
4397
- aggregate.bootstrapping = [...last.bootstrapping];
4398
- aggregate.resets.push(...last.resets);
4399
- aggregate.revoked.push(...last.revoked);
4400
- aggregate.failed.push(...last.failed);
4401
- aggregate.deferredCommits = last.deferredCommits ?? 0;
4402
- aggregate.schemaFloor = last.schemaFloor;
4403
- // A disabled transport or a schema floor ends the loop, not the budget.
4404
- if (!this.#transportEnabled || last.schemaFloor !== undefined)
4405
- return result(false);
4406
- if (
4407
- maxRounds === undefined &&
4408
- last.segmentRowsApplied > 0 &&
4409
- last.bootstrapping.length > 0 &&
4410
- before !==
4411
- JSON.stringify(
4412
- loadSubscriptions(this.#db).map((sub) => [
4413
- sub.id,
4414
- sub.bootstrapState,
4415
- ]),
4416
- )
4417
- )
4418
- round = -1;
4419
- if (
4420
- last.commitsApplied === 0 &&
4421
- last.segmentRowsApplied === 0 &&
4422
- last.bootstrapping.length === 0 &&
4423
- last.resets.length === 0 &&
4424
- (last.deferredCommits ?? 0) === 0 &&
4425
- !this.#needsPull
4426
- ) {
4427
- return result(false);
4432
+ let round = 0;
4433
+ let expired = false;
4434
+ let timer: ReturnType<typeof setTimeout> | undefined;
4435
+ const deadline =
4436
+ timeoutMs === Number.POSITIVE_INFINITY
4437
+ ? undefined
4438
+ : new Promise<never>((_, reject) => {
4439
+ timer = setTimeout(() => {
4440
+ expired = true;
4441
+ // The transport may never answer; release the round so the
4442
+ // client can run another one after the caller sees the error.
4443
+ this.#abortPendingRound('syncUntilIdle deadline exceeded');
4444
+ reject(
4445
+ new ClientSyncError(
4446
+ 'sync.idle_timeout',
4447
+ 'syncUntilIdle exceeded its deadline',
4448
+ true,
4449
+ { timeoutMs, rounds: round },
4450
+ ),
4451
+ );
4452
+ }, timeoutMs);
4453
+ });
4454
+ const drive = (async (): Promise<SyncIdleResult> => {
4455
+ for (; round < budget; round++) {
4456
+ // The race already rejected; stop spending rounds on a dead drive.
4457
+ if (expired)
4458
+ throw new ClientSyncError(
4459
+ 'sync.idle_timeout',
4460
+ 'syncUntilIdle exceeded its deadline',
4461
+ true,
4462
+ { timeoutMs, rounds: round },
4463
+ );
4464
+ const before = JSON.stringify(
4465
+ loadSubscriptions(this.#db).map((sub) => [
4466
+ sub.id,
4467
+ sub.bootstrapState,
4468
+ ]),
4469
+ );
4470
+ const last = await this.sync();
4471
+ // Historical counters accumulate; current readiness (`bootstrapping`,
4472
+ // `deferredCommits`, `schemaFloor`) describes the latest round.
4473
+ aggregate.pushed += last.pushed;
4474
+ aggregate.applied.push(...last.applied);
4475
+ aggregate.rejected.push(...last.rejected);
4476
+ aggregate.retryable.push(...last.retryable);
4477
+ aggregate.conflicts.push(...last.conflicts);
4478
+ aggregate.commitsApplied += last.commitsApplied;
4479
+ aggregate.segmentRowsApplied += last.segmentRowsApplied;
4480
+ aggregate.bootstrapping = [...last.bootstrapping];
4481
+ aggregate.resets.push(...last.resets);
4482
+ aggregate.revoked.push(...last.revoked);
4483
+ aggregate.failed.push(...last.failed);
4484
+ aggregate.deferredCommits = last.deferredCommits ?? 0;
4485
+ aggregate.schemaFloor = last.schemaFloor;
4486
+ // A disabled transport or a schema floor ends the loop, not the budget.
4487
+ if (!this.#transportEnabled || last.schemaFloor !== undefined)
4488
+ return result(false);
4489
+ if (
4490
+ maxRounds === undefined &&
4491
+ last.segmentRowsApplied > 0 &&
4492
+ last.bootstrapping.length > 0 &&
4493
+ before !==
4494
+ JSON.stringify(
4495
+ loadSubscriptions(this.#db).map((sub) => [
4496
+ sub.id,
4497
+ sub.bootstrapState,
4498
+ ]),
4499
+ )
4500
+ )
4501
+ round = -1;
4502
+ if (
4503
+ last.commitsApplied === 0 &&
4504
+ last.segmentRowsApplied === 0 &&
4505
+ last.bootstrapping.length === 0 &&
4506
+ last.resets.length === 0 &&
4507
+ (last.deferredCommits ?? 0) === 0 &&
4508
+ !this.#needsPull
4509
+ ) {
4510
+ return result(false);
4511
+ }
4428
4512
  }
4513
+ return result(true);
4514
+ })();
4515
+ try {
4516
+ return await (deadline === undefined
4517
+ ? drive
4518
+ : Promise.race([drive, deadline]));
4519
+ } finally {
4520
+ clearTimeout(timer);
4429
4521
  }
4430
- return result(true);
4431
4522
  }
4432
4523
 
4433
4524
  /**
@@ -488,9 +488,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
488
488
  const running = requireClient();
489
489
  return serializedSync(() => running.sync());
490
490
  },
491
- syncUntilIdle: (maxRounds) => {
491
+ syncUntilIdle: (options) => {
492
492
  const running = requireClient();
493
- return serializedSync(() => running.syncUntilIdle(maxRounds));
493
+ return serializedSync(() => running.syncUntilIdle(options));
494
494
  },
495
495
  resetClientId: () => requireClient().resetClientId(),
496
496
  localRevision: () => requireClient().localRevision,
@@ -43,6 +43,7 @@ import type {
43
43
  SyncClientConfig,
44
44
  SyncClientLimits,
45
45
  SyncSummary,
46
+ SyncIdleOptions,
46
47
  SyncIdleResult,
47
48
  WindowState,
48
49
  } from './client';
@@ -657,8 +658,8 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
657
658
  return this.#call('sync', []);
658
659
  }
659
660
 
660
- syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult> {
661
- return this.#call('syncUntilIdle', [maxRounds]);
661
+ syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult> {
662
+ return this.#call('syncUntilIdle', [options]);
662
663
  }
663
664
 
664
665
  /**
@@ -31,6 +31,7 @@ import type {
31
31
  SyncClientConfig,
32
32
  SyncClientLimits,
33
33
  SyncSummary,
34
+ SyncIdleOptions,
34
35
  SyncIdleResult,
35
36
  WindowState,
36
37
  } from './client';
@@ -187,7 +188,7 @@ export interface WorkerApi extends Omit<
187
188
  /** RFC 0005 D9: the executable rollback step — drop the container. */
188
189
  previousVersionDiscard(): { present: boolean; discarded: boolean };
189
190
  sync(): Promise<SyncSummary>;
190
- syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
191
+ syncUntilIdle(options?: number | SyncIdleOptions): Promise<SyncIdleResult>;
191
192
  resetClientId(): string;
192
193
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
193
194
  authoritySnapshot(): import('./authority').AuthoritySnapshot;