@syncular/client 0.18.0 → 0.20.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.
@@ -20,7 +20,7 @@ import {
20
20
  withClientDiagnosticsHost,
21
21
  } from './diagnostics';
22
22
  import { encryptionConfigFromKeyring } from './encryption';
23
- import { ClientSyncError } from './errors';
23
+ import { ClientSyncError, STORAGE_BUSY_CODE } from './errors';
24
24
  import {
25
25
  httpBlobTransport,
26
26
  httpSegmentDownloader,
@@ -51,6 +51,8 @@ export interface SyncWorkerOverrides {
51
51
  readonly openDatabase?: (
52
52
  config: WorkerInitConfig,
53
53
  ) => Promise<ClientDatabase> | ClientDatabase;
54
+ /** Retry deadline indirection for deterministic worker-host tests. */
55
+ readonly waitForStorageRetry?: (delayMs: number) => Promise<void>;
54
56
  readonly createTransport?: (config: WorkerInitConfig) => SyncTransport;
55
57
  readonly createSegments?: (
56
58
  config: WorkerInitConfig,
@@ -244,9 +246,12 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
244
246
  return Object.assign(gateOffline(inner), {
245
247
  ...(fetchUrl !== undefined
246
248
  ? {
247
- fetchUrl: (url: string) => {
249
+ fetchUrl: (
250
+ url: string,
251
+ onProgress?: (bytesReceived: number) => void,
252
+ ) => {
248
253
  assertOnline();
249
- return fetchUrl(url);
254
+ return fetchUrl(url, onProgress);
250
255
  },
251
256
  }
252
257
  : {}),
@@ -262,10 +267,33 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
262
267
  }
263
268
  autoSync = config.autoSync ?? true;
264
269
 
265
- database =
266
- overrides.openDatabase !== undefined
267
- ? await overrides.openDatabase(config)
268
- : await defaultOpenDatabase(config);
270
+ for (let attempt = 0; ; attempt++) {
271
+ try {
272
+ database =
273
+ overrides.openDatabase !== undefined
274
+ ? await overrides.openDatabase(config)
275
+ : await defaultOpenDatabase(config);
276
+ break;
277
+ } catch (error) {
278
+ if (
279
+ config.database.mode !== 'persistent' ||
280
+ !(error instanceof ClientSyncError) ||
281
+ error.code !== STORAGE_BUSY_CODE ||
282
+ !error.retryable ||
283
+ attempt === 6
284
+ ) {
285
+ throw error;
286
+ }
287
+ // Retain the leader lease while the previous worker's OPFS handles
288
+ // drain. Only opening is retried; core initialization has not started.
289
+ const delayMs = Math.min(50 * 2 ** attempt, 1000);
290
+ if (overrides.waitForStorageRetry !== undefined) {
291
+ await overrides.waitForStorageRetry(delayMs);
292
+ } else {
293
+ await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
294
+ }
295
+ }
296
+ }
269
297
  const transport = gateOffline(
270
298
  overrides.createTransport !== undefined
271
299
  ? overrides.createTransport(config)
@@ -336,6 +364,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
336
364
  post({ t: 'event', event: { kind: 'presence', scopeKey } });
337
365
  },
338
366
  });
367
+ started.onProgress((progress) => {
368
+ if (!closed) post({ t: 'event', event: { kind: 'progress', progress } });
369
+ });
339
370
  started.onChange((batch) => {
340
371
  if (!closed) post({ t: 'event', event: { kind: 'change', batch } });
341
372
  });
@@ -1,3 +1,8 @@
1
+ import {
2
+ ProgressEmitter,
3
+ type SyncProgress,
4
+ type SyncProgressListener,
5
+ } from './progress';
1
6
  import type { PromiseMethods } from './client';
2
7
  /**
3
8
  * Main-thread side of the worker mode and the
@@ -273,6 +278,7 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
273
278
  #core: LeaderCore | undefined;
274
279
  #follower: FollowerLink | undefined;
275
280
  readonly #invalidation: InvalidationEmitter;
281
+ readonly #progress = new ProgressEmitter();
276
282
  readonly #changes: ChangeEmitter;
277
283
  readonly #presence: Set<(scopeKey: string) => void>;
278
284
  readonly #diagnostics: ClientDiagnosticsEmitter;
@@ -376,7 +382,9 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
376
382
 
377
383
  /** @internal — dispatch a worker/relayed event to handle-local listeners. */
378
384
  __dispatchEvent(event: SyncWorkerEvent): void {
379
- if (event.kind === 'presence') {
385
+ if (event.kind === 'progress') {
386
+ this.#progress.emit(event.progress);
387
+ } else if (event.kind === 'presence') {
380
388
  for (const listener of this.#presence) {
381
389
  try {
382
390
  listener(event.scopeKey);
@@ -409,6 +417,13 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
409
417
  return this.#invalidation.on(listener);
410
418
  }
411
419
 
420
+ onProgress(listener: SyncProgressListener): () => void {
421
+ return this.#progress.on(listener);
422
+ }
423
+ progressSnapshot(): SyncProgress | undefined {
424
+ return this.#progress.snapshot();
425
+ }
426
+
412
427
  onChange(listener: ClientChangeListener): () => void {
413
428
  return this.#changes.on(listener);
414
429
  }
@@ -646,6 +661,7 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
646
661
  async close(): Promise<void> {
647
662
  if (this.#closed) return;
648
663
  this.#closed = true;
664
+ this.#progress.clear();
649
665
  this.#devtoolsUnregister();
650
666
  if (this.#follower !== undefined) {
651
667
  this.#follower.close();
@@ -1,3 +1,4 @@
1
+ import type { SyncProgress } from './progress';
1
2
  /**
2
3
  * The worker RPC protocol: the whole
3
4
  * client core runs in a Web Worker; the UI thread talks to it through
@@ -206,6 +207,7 @@ export interface WorkerErrorShape {
206
207
  }
207
208
 
208
209
  export type SyncWorkerEvent =
210
+ | { readonly kind: 'progress'; readonly progress: SyncProgress }
209
211
  | {
210
212
  readonly kind: 'sync-needed';
211
213
  readonly reason: 'startup' | 'hello' | WakeReason;