@syncular/client 0.19.0 → 0.20.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.
@@ -359,7 +359,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
359
359
  #running = false;
360
360
  #requested = false;
361
361
  #desiredRevision = 0n;
362
- #claimReady: Promise<void> = Promise.resolve();
362
+ #claimError: Error | undefined;
363
+ #claimPending = false;
363
364
  #offStatus: (() => void) | undefined;
364
365
 
365
366
  constructor(
@@ -397,10 +398,25 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
397
398
  ),
398
399
  );
399
400
  }
400
- this.#claimReady = Promise.all(claims).then(() => undefined);
401
- // A render can lose its last subscriber before the read loop starts.
402
- // The loop still observes the original rejection while it owns the claim.
403
- void this.#claimReady.catch(() => undefined);
401
+ const generation = this.#generation;
402
+ this.#claimPending = claims.length > 0;
403
+ if (claims.length > 0)
404
+ void Promise.all(claims).then(
405
+ () => {
406
+ if (generation !== this.#generation || this.#listeners.size === 0)
407
+ return;
408
+ this.#claimPending = false;
409
+ if (!this.#running && this.#state.phase !== 'ready')
410
+ this.#requestRead();
411
+ },
412
+ (error: unknown) => {
413
+ if (generation !== this.#generation || this.#listeners.size === 0)
414
+ return;
415
+ this.#claimPending = false;
416
+ this.#claimError = errorOf(error);
417
+ this.#requestRead();
418
+ },
419
+ );
404
420
  }
405
421
  this.#requestRead();
406
422
  }
@@ -426,7 +442,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
426
442
  this.#running = false;
427
443
  this.#requested = false;
428
444
  this.#desiredRevision = 0n;
429
- this.#claimReady = Promise.resolve();
445
+ this.#claimError = undefined;
446
+ this.#claimPending = false;
430
447
  this.#state = {
431
448
  rows: [],
432
449
  phase: 'loading',
@@ -520,9 +537,11 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
520
537
  do {
521
538
  this.#requested = false;
522
539
  if (this.store.availabilitySnapshot().state === 'blocked') break;
523
- await this.#claimReady;
540
+
524
541
  if (generation !== this.#generation || this.#listeners.size === 0)
525
542
  return;
543
+ if (this.#claimError !== undefined) throw this.#claimError;
544
+ const claimWasPending = this.#claimPending;
526
545
  const snapshot = await this.store.client.querySnapshot<
527
546
  Record<string, SqlValue>
528
547
  >({
@@ -536,6 +555,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
536
555
  });
537
556
  if (generation !== this.#generation || this.#listeners.size === 0)
538
557
  return;
558
+ if (this.#claimError !== undefined) throw this.#claimError;
539
559
  const availability = this.store.availabilitySnapshot();
540
560
  if (availability.state === 'blocked') {
541
561
  this.#publish({
@@ -550,6 +570,12 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
550
570
  this.#requested = true;
551
571
  continue;
552
572
  }
573
+ if (
574
+ !snapshot.coverage.complete &&
575
+ claimWasPending &&
576
+ !this.#claimPending
577
+ )
578
+ this.#requested = true;
553
579
  const mappedRows =
554
580
  this.spec.mapRow === undefined
555
581
  ? (snapshot.rows as readonly Row[])
@@ -625,6 +651,8 @@ interface WindowClaimGroup {
625
651
  readonly base: WindowBase;
626
652
  readonly claims: Map<symbol, ReadonlySet<string>>;
627
653
  appliedKey: string;
654
+ appliedUnits: ReadonlySet<string>;
655
+ inFlightUnits?: ReadonlySet<string> | undefined;
628
656
  scheduled: boolean;
629
657
  running: boolean;
630
658
  requested: boolean;
@@ -889,6 +917,7 @@ export class ReactiveClientStore {
889
917
  base,
890
918
  claims: new Map(),
891
919
  appliedKey: '',
920
+ appliedUnits: new Set(),
892
921
  scheduled: false,
893
922
  running: false,
894
923
  requested: false,
@@ -897,6 +926,17 @@ export class ReactiveClientStore {
897
926
  this.#windowClaims.set(key, group);
898
927
  }
899
928
  group.claims.set(owner, new Set(units));
929
+ const alreadyHeld =
930
+ units.length > 0 &&
931
+ units.every(
932
+ (unit) =>
933
+ group.appliedUnits.has(unit) &&
934
+ (group.inFlightUnits === undefined || group.inFlightUnits.has(unit)),
935
+ );
936
+ if (alreadyHeld) {
937
+ this.#scheduleWindow(group);
938
+ return Promise.resolve();
939
+ }
900
940
  const result = new Promise<void>((resolve, reject) => {
901
941
  group?.waiters.push({ resolve, reject });
902
942
  });
@@ -932,13 +972,23 @@ export class ReactiveClientStore {
932
972
  ].sort();
933
973
  const key = canonicalValue(units);
934
974
  if (key !== group.appliedKey) {
935
- await this.client.setWindow(group.base, units);
975
+ group.inFlightUnits = new Set(units);
976
+ try {
977
+ await this.client.setWindow(group.base, units);
978
+ } finally {
979
+ group.inFlightUnits = undefined;
980
+ }
936
981
  if (this.#windowClaims.get(baseKey) !== group) return;
937
982
  group.appliedKey = key;
983
+ group.appliedUnits = new Set(units);
938
984
  }
939
985
  }
940
986
  for (const waiter of group.waiters.splice(0)) waiter.resolve();
941
987
  } catch (error) {
988
+ // A failed window edit may already have committed its first eviction chunk.
989
+ // Reconcile through the core before acknowledging any later claim.
990
+ group.appliedKey = '';
991
+ group.appliedUnits = new Set();
942
992
  for (const waiter of group.waiters.splice(0)) waiter.reject(error);
943
993
  } finally {
944
994
  group.running = false;
package/src/schema.ts CHANGED
@@ -374,6 +374,14 @@ function createFtsProjection(
374
374
  `CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`,
375
375
  );
376
376
 
377
+ const mapping = quoteIdent(`_syncular_fts_${index.name}`);
378
+ const mapped =
379
+ db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
380
+ `_syncular_fts_${index.name}`,
381
+ ]).length > 0;
382
+ db.exec(
383
+ `CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`,
384
+ );
377
385
  const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
378
386
  const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
379
387
  const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
@@ -386,9 +394,10 @@ function createFtsProjection(
386
394
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
387
395
  ].join(', ');
388
396
  const deleteFor = (value: string) =>
389
- `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
397
+ `DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
390
398
  const deleteDisplaced = (select: string) =>
391
- `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
399
+ `DELETE FROM ${quoteIdent(index.name)} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select})); DELETE FROM ${mapping} WHERE source_id IN (${select})`;
400
+ const insertNew = `INSERT INTO ${mapping}(source_id) VALUES (${newSourceId}); INSERT INTO ${quoteIdent(index.name)} (rowid, ${projectionColumns}) VALUES ((SELECT id FROM ${mapping} WHERE source_id = ${newSourceId}), ${newValues})`;
392
401
 
393
402
  // A clean insert cannot already have a projection row because the source
394
403
  // primary key is unique. Keep clean inserts linear by moving replacement
@@ -441,13 +450,13 @@ function createFtsProjection(
441
450
  `CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
442
451
  );
443
452
  db.exec(
444
- `CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
453
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`,
445
454
  );
446
455
  db.exec(
447
456
  `CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`,
448
457
  );
449
458
  db.exec(
450
- `CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
459
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`,
451
460
  );
452
461
  // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
453
462
  // different-PK row about to be displaced through a secondary unique index by
@@ -468,6 +477,12 @@ function createFtsProjection(
468
477
  `INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`,
469
478
  );
470
479
  }
480
+ if (!mapped || !existed) {
481
+ db.exec(`DELETE FROM ${mapping}`);
482
+ db.exec(
483
+ `INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`,
484
+ );
485
+ }
471
486
  }
472
487
 
473
488
  /**
@@ -602,6 +617,9 @@ export function dropAndRecreateSyncedTables(
602
617
  AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
603
618
  );
604
619
  for (const row of virtualTables) {
620
+ db.exec(
621
+ `DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`,
622
+ );
605
623
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
606
624
  }
607
625
  const existing = db.query(
package/src/transport.ts CHANGED
@@ -32,6 +32,7 @@ export interface SegmentFetchRequest {
32
32
  readonly table: string;
33
33
  /** Canonical JSON (§11.2) of the requested scope map (§5.5 header). */
34
34
  readonly requestedScopesJson: string;
35
+ readonly onProgress?: (bytesReceived: number) => void;
35
36
  }
36
37
 
37
38
  /**
@@ -44,7 +45,10 @@ export interface SegmentFetchRequest {
44
45
  */
45
46
  export interface SegmentDownloader {
46
47
  (request: SegmentFetchRequest): Promise<Uint8Array>;
47
- readonly fetchUrl?: (url: string) => Promise<Uint8Array>;
48
+ readonly fetchUrl?: (
49
+ url: string,
50
+ onProgress?: (bytesReceived: number) => void,
51
+ ) => Promise<Uint8Array>;
48
52
  }
49
53
 
50
54
  export interface RealtimeHandlers {
@@ -53,6 +53,13 @@ interface SahPoolUtil {
53
53
  OpfsSAHPoolDb: new (filename: string) => Oo1Database;
54
54
  }
55
55
 
56
+ interface SqliteIoMethods {
57
+ installMethod(
58
+ name: 'xCheckReservedLock',
59
+ callback: (file: number, output: number) => number,
60
+ ): unknown;
61
+ }
62
+
56
63
  interface Sqlite3Static {
57
64
  oo1: {
58
65
  DB: new (filename: string, flags?: string) => Oo1Database;
@@ -60,6 +67,18 @@ interface Sqlite3Static {
60
67
  capi: {
61
68
  SQLITE_DESERIALIZE_FREEONCLOSE: number;
62
69
  SQLITE_DESERIALIZE_READONLY: number;
70
+ SQLITE_FCNTL_FILE_POINTER: number;
71
+ sqlite3_file_control(
72
+ db: number,
73
+ schema: string,
74
+ operation: number,
75
+ output: number,
76
+ ): number;
77
+ sqlite3_file: new (pointer: number) => {
78
+ readonly $pMethods: number;
79
+ dispose(): void;
80
+ };
81
+ sqlite3_io_methods: new (pointer: number) => SqliteIoMethods;
63
82
  sqlite3_deserialize(
64
83
  db: number,
65
84
  schema: string,
@@ -71,6 +90,13 @@ interface Sqlite3Static {
71
90
  };
72
91
  wasm: {
73
92
  allocFromTypedArray(bytes: Uint8Array): number;
93
+ peekPtr(pointer: number): number;
94
+ poke32(pointer: number, value: number): unknown;
95
+ pstack: {
96
+ readonly pointer: number;
97
+ allocPtr(): number;
98
+ restore(pointer: number): void;
99
+ };
74
100
  };
75
101
  installOpfsSAHPoolVfs(options: {
76
102
  name?: string;
@@ -125,9 +151,13 @@ class WasmClientDatabase implements ClientDatabase {
125
151
  * §5.3 image import at near-file-copy speed: attach an empty in-memory
126
152
  * schema, then `sqlite3_deserialize` the image bytes into it (the
127
153
  * documented sqlite-wasm import path — no OPFS round-trip, no SQL-level
128
- * row shuttling before the single INSERT…SELECT the caller runs).
154
+ * row shuttling before the chunked INSERT…SELECT statements the caller runs).
129
155
  */
130
- withSqliteImage<T>(bytes: Uint8Array, alias: string, fn: () => T): T {
156
+ async withSqliteImage<T>(
157
+ bytes: Uint8Array,
158
+ alias: string,
159
+ fn: () => T | Promise<T>,
160
+ ): Promise<T> {
131
161
  assertImageAlias(alias);
132
162
  const pointer = this.#db.pointer;
133
163
  if (pointer === undefined) {
@@ -156,7 +186,7 @@ class WasmClientDatabase implements ClientDatabase {
156
186
  `sqlite3_deserialize failed with code ${rc} (§5.3)`,
157
187
  );
158
188
  }
159
- return fn();
189
+ return await fn();
160
190
  } finally {
161
191
  this.exec(`DETACH ${alias}`);
162
192
  }
@@ -217,6 +247,10 @@ function inWorkerContext(): boolean {
217
247
  /** One registered VFS per pool directory, reused across opens. */
218
248
  const sahPools = new Map<string, Promise<SahPoolUtil>>();
219
249
 
250
+ // SAH pools share an I/O method table within the WASM runtime. Keep the
251
+ // installed callback alive for that runtime, including across database closes.
252
+ const sahIoMethods = new Map<number, SqliteIoMethods>();
253
+
220
254
  function opfsSahPoolError(error: unknown, directory: string): ClientSyncError {
221
255
  const detail = error instanceof Error ? error.message : String(error);
222
256
  const normalized = detail.toLowerCase();
@@ -306,5 +340,53 @@ export async function openPersistentWasmDatabase(
306
340
  sahPools.set(directory, pool);
307
341
  }
308
342
  const util = await pool;
309
- return new WasmClientDatabase(new util.OpfsSAHPoolDb(`/${name}.db`), sqlite3);
343
+ const db = new util.OpfsSAHPoolDb(`/${name}.db`);
344
+ const { capi, wasm } = sqlite3;
345
+ const stack = wasm.pstack.pointer;
346
+ try {
347
+ // Do this before the first SQL statement, when SQLite checks for a hot
348
+ // journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved
349
+ // lock unconditionally, which suppresses crash rollback. The pool already
350
+ // excludes other owners; report no competing writer, as the other OPFS
351
+ // VFS does. Keep DELETE/FULL and the existing database/journal files.
352
+ const output = wasm.pstack.allocPtr();
353
+ if (
354
+ db.pointer === undefined ||
355
+ capi.sqlite3_file_control(
356
+ db.pointer,
357
+ 'main',
358
+ capi.SQLITE_FCNTL_FILE_POINTER,
359
+ output,
360
+ ) !== 0 ||
361
+ !wasm.peekPtr(output)
362
+ ) {
363
+ throw new ClientSyncError(
364
+ STORAGE_UNAVAILABLE_CODE,
365
+ 'Could not configure OPFS crash recovery',
366
+ );
367
+ }
368
+ const file = new capi.sqlite3_file(wasm.peekPtr(output));
369
+ const methodsPointer = file.$pMethods;
370
+ file.dispose(); // Borrowed struct view; SQLite still owns the file.
371
+ if (!methodsPointer) {
372
+ throw new ClientSyncError(
373
+ STORAGE_UNAVAILABLE_CODE,
374
+ 'Could not configure OPFS crash recovery',
375
+ );
376
+ }
377
+ if (!sahIoMethods.has(methodsPointer)) {
378
+ const methods = new capi.sqlite3_io_methods(methodsPointer);
379
+ methods.installMethod('xCheckReservedLock', (_file, reserved) => {
380
+ wasm.poke32(reserved, 0);
381
+ return 0;
382
+ });
383
+ sahIoMethods.set(methodsPointer, methods);
384
+ }
385
+ return new WasmClientDatabase(db, sqlite3);
386
+ } catch (error) {
387
+ db.close();
388
+ throw error;
389
+ } finally {
390
+ wasm.pstack.restore(stack);
391
+ }
310
392
  }
@@ -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;