@syncular/client 0.19.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.
@@ -16,7 +16,7 @@
16
16
  import { SyncClient } from './client.js';
17
17
  import { withClientDiagnosticsHost, } from './diagnostics.js';
18
18
  import { encryptionConfigFromKeyring } from './encryption.js';
19
- import { ClientSyncError } from './errors.js';
19
+ import { ClientSyncError, STORAGE_BUSY_CODE } from './errors.js';
20
20
  import { httpBlobTransport, httpSegmentDownloader, httpSyncTransport, webSocketRealtimeConnector, } from './http.js';
21
21
  import { openPersistentWasmDatabase } from './wasm-database.js';
22
22
  import { WORKER_FAILED_CODE, } from './worker-protocol.js';
@@ -170,9 +170,9 @@ export function startSyncWorker(overrides = {}) {
170
170
  return Object.assign(gateOffline(inner), {
171
171
  ...(fetchUrl !== undefined
172
172
  ? {
173
- fetchUrl: (url) => {
173
+ fetchUrl: (url, onProgress) => {
174
174
  assertOnline();
175
- return fetchUrl(url);
175
+ return fetchUrl(url, onProgress);
176
176
  },
177
177
  }
178
178
  : {}),
@@ -183,10 +183,33 @@ export function startSyncWorker(overrides = {}) {
183
183
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
184
184
  }
185
185
  autoSync = config.autoSync ?? true;
186
- database =
187
- overrides.openDatabase !== undefined
188
- ? await overrides.openDatabase(config)
189
- : await defaultOpenDatabase(config);
186
+ for (let attempt = 0;; attempt++) {
187
+ try {
188
+ database =
189
+ overrides.openDatabase !== undefined
190
+ ? await overrides.openDatabase(config)
191
+ : await defaultOpenDatabase(config);
192
+ break;
193
+ }
194
+ catch (error) {
195
+ if (config.database.mode !== 'persistent' ||
196
+ !(error instanceof ClientSyncError) ||
197
+ error.code !== STORAGE_BUSY_CODE ||
198
+ !error.retryable ||
199
+ attempt === 6) {
200
+ throw error;
201
+ }
202
+ // Retain the leader lease while the previous worker's OPFS handles
203
+ // drain. Only opening is retried; core initialization has not started.
204
+ const delayMs = Math.min(50 * 2 ** attempt, 1000);
205
+ if (overrides.waitForStorageRetry !== undefined) {
206
+ await overrides.waitForStorageRetry(delayMs);
207
+ }
208
+ else {
209
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
210
+ }
211
+ }
212
+ }
190
213
  const transport = gateOffline(overrides.createTransport !== undefined
191
214
  ? overrides.createTransport(config)
192
215
  : httpSyncTransport(config.endpoints.syncUrl));
@@ -245,6 +268,10 @@ export function startSyncWorker(overrides = {}) {
245
268
  post({ t: 'event', event: { kind: 'presence', scopeKey } });
246
269
  },
247
270
  });
271
+ started.onProgress((progress) => {
272
+ if (!closed)
273
+ post({ t: 'event', event: { kind: 'progress', progress } });
274
+ });
248
275
  started.onChange((batch) => {
249
276
  if (!closed)
250
277
  post({ t: 'event', event: { kind: 'change', batch } });
@@ -1,3 +1,4 @@
1
+ import { type SyncProgress, type SyncProgressListener } from './progress.js';
1
2
  import type { PromiseMethods } from './client.js';
2
3
  /**
3
4
  * Main-thread side of the worker mode and the
@@ -179,6 +180,8 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
179
180
  * unsubscribe function.
180
181
  */
181
182
  onInvalidate(listener: InvalidationListener): () => void;
183
+ onProgress(listener: SyncProgressListener): () => void;
184
+ progressSnapshot(): SyncProgress | undefined;
182
185
  onChange(listener: ClientChangeListener): () => void;
183
186
  onDiagnostics(listener: ClientDiagnosticsListener): () => void;
184
187
  /**
@@ -1,3 +1,4 @@
1
+ import { ProgressEmitter, } from './progress.js';
1
2
  import { registerDevtools } from './devtools.js';
2
3
  import { ClientDiagnosticsEmitter, withClientDiagnosticsHost, } from './diagnostics.js';
3
4
  import { ClientSyncError } from './errors.js';
@@ -68,6 +69,7 @@ export class SyncClientHandle {
68
69
  #core;
69
70
  #follower;
70
71
  #invalidation;
72
+ #progress = new ProgressEmitter();
71
73
  #changes;
72
74
  #presence;
73
75
  #diagnostics;
@@ -156,7 +158,10 @@ export class SyncClientHandle {
156
158
  }
157
159
  /** @internal — dispatch a worker/relayed event to handle-local listeners. */
158
160
  __dispatchEvent(event) {
159
- if (event.kind === 'presence') {
161
+ if (event.kind === 'progress') {
162
+ this.#progress.emit(event.progress);
163
+ }
164
+ else if (event.kind === 'presence') {
160
165
  for (const listener of this.#presence) {
161
166
  try {
162
167
  listener(event.scopeKey);
@@ -189,6 +194,12 @@ export class SyncClientHandle {
189
194
  onInvalidate(listener) {
190
195
  return this.#invalidation.on(listener);
191
196
  }
197
+ onProgress(listener) {
198
+ return this.#progress.on(listener);
199
+ }
200
+ progressSnapshot() {
201
+ return this.#progress.snapshot();
202
+ }
192
203
  onChange(listener) {
193
204
  return this.#changes.on(listener);
194
205
  }
@@ -349,6 +360,7 @@ export class SyncClientHandle {
349
360
  if (this.#closed)
350
361
  return;
351
362
  this.#closed = true;
363
+ this.#progress.clear();
352
364
  this.#devtoolsUnregister();
353
365
  if (this.#follower !== undefined) {
354
366
  this.#follower.close();
@@ -1,3 +1,4 @@
1
+ import type { SyncProgress } from './progress.js';
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
@@ -151,6 +152,9 @@ export interface WorkerErrorShape {
151
152
  readonly retryable: boolean;
152
153
  }
153
154
  export type SyncWorkerEvent = {
155
+ readonly kind: 'progress';
156
+ readonly progress: SyncProgress;
157
+ } | {
154
158
  readonly kind: 'sync-needed';
155
159
  readonly reason: 'startup' | 'hello' | WakeReason;
156
160
  } | {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
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",
@@ -89,9 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.19.0"
92
+ "@syncular/core": "0.20.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.19.0"
95
+ "@syncular/server": "0.20.0",
96
+ "playwright": "1.63.0"
96
97
  }
97
98
  }
package/src/apply.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type ScopeMap,
12
12
  type SegmentRow,
13
13
  } from '@syncular/core';
14
- import type { ClientDatabase } from './database';
14
+ import type { ClientDatabase, SqlValue } from './database';
15
15
  import type { EncryptionConfig } from './encryption';
16
16
  import { ClientSyncError } from './errors';
17
17
  import {
@@ -303,6 +303,7 @@ export function applySqliteSegment(
303
303
  readonly clearFirst: boolean;
304
304
  readonly effective: ScopeMap;
305
305
  readonly transaction?: ApplyTransaction;
306
+ readonly onProgress?: (rowsProcessed: number) => void;
306
307
  },
307
308
  ): number {
308
309
  const withImage = db.withSqliteImage?.bind(db);
@@ -381,23 +382,45 @@ export function applySqliteSegment(
381
382
  if (options.clearFirst) {
382
383
  deleteScopedRows(db, table, options.effective);
383
384
  }
384
- db.exec(
385
- `INSERT INTO ${quoteIdent(table.name)}
386
- (${insertNames.join(', ')})
387
- SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
388
- FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}
389
- WHERE true
390
- ON CONFLICT (${quoteIdent(table.primaryKey)}) DO UPDATE SET ${updates}`,
391
- );
392
- const counted = db.query(
393
- `SELECT count(*) AS n FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`,
394
- )[0];
395
- const applied = Number(counted?.n ?? 0);
385
+ const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
386
+ const primaryKey = quoteIdent(table.primaryKey);
387
+ let after: SqlValue | undefined;
388
+ let applied = 0;
389
+ for (;;) {
390
+ const boundary = db.query(
391
+ `SELECT ${primaryKey} AS boundary FROM ${source}
392
+ ${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
393
+ ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
394
+ after === undefined ? [] : [after],
395
+ )[0]?.boundary;
396
+ const predicates: string[] = [];
397
+ const params: SqlValue[] = [];
398
+ if (after !== undefined) {
399
+ predicates.push(`${primaryKey} > ?`);
400
+ params.push(after);
401
+ }
402
+ if (boundary !== undefined) {
403
+ predicates.push(`${primaryKey} <= ?`);
404
+ params.push(boundary);
405
+ }
406
+ db.exec(
407
+ `INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
408
+ SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
409
+ FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
410
+ ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`,
411
+ params,
412
+ );
413
+ applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
414
+ if (boundary === undefined) break;
415
+ options.onProgress?.(applied);
416
+ after = boundary;
417
+ }
396
418
  if (applied !== descriptor.rowCount) {
397
419
  imageInvalid(
398
420
  `image holds ${applied} rows, descriptor says ${descriptor.rowCount}`,
399
421
  );
400
422
  }
423
+ options.onProgress?.(applied);
401
424
  return applied;
402
425
  });
403
426
  });
@@ -420,6 +443,7 @@ export async function applyRowsSegment(
420
443
  readonly clearFirst: boolean;
421
444
  readonly effective: ScopeMap;
422
445
  readonly transaction?: ApplyTransaction;
446
+ readonly onProgress?: (rowsProcessed: number) => void;
423
447
  },
424
448
  encryption?: EncryptionConfig,
425
449
  ): Promise<number> {
@@ -453,8 +477,10 @@ export async function applyRowsSegment(
453
477
  for (const row of rows) {
454
478
  upsertLocalRow(db, table, row.values, row.serverVersion);
455
479
  applied += 1;
480
+ if (applied % 1024 === 0) options.onProgress?.(applied);
456
481
  }
457
482
  });
458
483
  }
484
+ options.onProgress?.(applied);
459
485
  return applied;
460
486
  }
package/src/client.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ ProgressEmitter,
3
+ type SyncProgress,
4
+ type SyncProgressListener,
5
+ } from './progress';
1
6
  /**
2
7
  * SyncClient implements the client side of SPEC.md §§3–8.
3
8
  *
@@ -584,6 +589,8 @@ export class SyncClient {
584
589
  #realtimeGeneration = 0;
585
590
  #pendingRound: PendingRound | undefined;
586
591
  #needsPull = false;
592
+ readonly #progress = new ProgressEmitter();
593
+ #progressAttempt = 0;
587
594
  #syncing = false;
588
595
  /**
589
596
  * True from the synchronous entry of `sync()` until its serialized round
@@ -873,6 +880,7 @@ export class SyncClient {
873
880
  await this.#lease?.release();
874
881
  this.#lease = undefined;
875
882
  this.#started = false;
883
+ this.#progress.clear();
876
884
  this.#syncNeededListeners.clear();
877
885
  this.#syncIntentListeners.clear();
878
886
  }
@@ -1073,6 +1081,14 @@ export class SyncClient {
1073
1081
  }
1074
1082
 
1075
1083
  /** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
1084
+ onProgress(listener: SyncProgressListener): () => void {
1085
+ return this.#progress.on(listener);
1086
+ }
1087
+
1088
+ progressSnapshot(): SyncProgress | undefined {
1089
+ return this.#progress.snapshot();
1090
+ }
1091
+
1076
1092
  onChange(listener: ClientChangeListener): () => void {
1077
1093
  return this.#changes.on(listener);
1078
1094
  }
@@ -2655,6 +2671,13 @@ export class SyncClient {
2655
2671
  (error: unknown) => {
2656
2672
  const completedAtMs = this.#now();
2657
2673
  const code = (error as { code?: unknown }).code;
2674
+ this.#progress.update({
2675
+ state: 'failed',
2676
+ errorCode:
2677
+ typeof code === 'string'
2678
+ ? this.#diagnosticCode(code)
2679
+ : 'client.unknown_failure',
2680
+ });
2658
2681
  this.#lastRound = {
2659
2682
  status: 'failed',
2660
2683
  startedAtMs,
@@ -2709,7 +2732,15 @@ export class SyncClient {
2709
2732
  }
2710
2733
 
2711
2734
  async #runSync(): Promise<SyncSummary> {
2735
+ this.#progress.emit({
2736
+ attempt: ++this.#progressAttempt,
2737
+ state: 'running',
2738
+ phase: 'request',
2739
+ bytesReceived: 0,
2740
+ rowsProcessed: 0,
2741
+ });
2712
2742
  if (this.#schemaFloor !== undefined) {
2743
+ this.#progress.update({ state: 'complete' });
2713
2744
  return {
2714
2745
  ...emptySummary(0),
2715
2746
  bootstrapping: [],
@@ -2800,6 +2831,11 @@ export class SyncClient {
2800
2831
  // §4.8 E1: the push half may have drained commits that pinned rows of
2801
2832
  // a shrunk window unit — retry any deferred evictions now.
2802
2833
  this.#drainPendingEvictions();
2834
+ this.#progress.update(
2835
+ summary.failed.length > 0
2836
+ ? { state: 'failed', errorCode: 'sync.scope_revoked' }
2837
+ : { state: 'complete' },
2838
+ );
2803
2839
  this.#retryDelayMs = 250;
2804
2840
  if (deferred > 0) {
2805
2841
  // §6.1 splitBatch remainder: more queued commits than this request
@@ -3368,6 +3404,19 @@ export class SyncClient {
3368
3404
  break;
3369
3405
  }
3370
3406
  const segment = decodeRowsSegment(frame.payload);
3407
+ this.#progress.update({
3408
+ phase: 'import',
3409
+ subscriptionId: section.sub.id,
3410
+ table: section.sub.table,
3411
+ segmentId: undefined,
3412
+ bytesReceived: frame.payload.byteLength,
3413
+ bytesTotal: frame.payload.byteLength,
3414
+ rowsProcessed: 0,
3415
+ rowsTotal: segment.blocks.reduce(
3416
+ (n, block) => n + block.length,
3417
+ 0,
3418
+ ),
3419
+ });
3371
3420
  await this.#applySegmentOrFail(
3372
3421
  section,
3373
3422
  summary,
@@ -3379,6 +3428,8 @@ export class SyncClient {
3379
3428
  segment,
3380
3429
  {
3381
3430
  clearFirst,
3431
+ onProgress: (rowsProcessed) =>
3432
+ this.#progress.update({ rowsProcessed }),
3382
3433
  effective,
3383
3434
  transaction: (fn) =>
3384
3435
  this.#applyBatch((batch) => {
@@ -3418,6 +3469,7 @@ export class SyncClient {
3418
3469
  );
3419
3470
  }
3420
3471
  const bytes = await this.#downloadSegment(frame, section.sub);
3472
+ this.#progress.update({ phase: 'import' });
3421
3473
  if (frame.mediaType === 'sqlite') {
3422
3474
  // §5.3: images are whole-table — a paged descriptor is
3423
3475
  // invalid, and the image is always its table's first page.
@@ -3447,6 +3499,8 @@ export class SyncClient {
3447
3499
  },
3448
3500
  {
3449
3501
  clearFirst,
3502
+ onProgress: (rowsProcessed) =>
3503
+ this.#progress.update({ rowsProcessed }),
3450
3504
  effective,
3451
3505
  transaction: (fn) =>
3452
3506
  this.#applyBatch((batch) => {
@@ -3476,6 +3530,8 @@ export class SyncClient {
3476
3530
  segment,
3477
3531
  {
3478
3532
  clearFirst,
3533
+ onProgress: (rowsProcessed) =>
3534
+ this.#progress.update({ rowsProcessed }),
3479
3535
  effective,
3480
3536
  transaction: (fn) =>
3481
3537
  this.#applyBatch((batch) => {
@@ -3857,6 +3913,19 @@ export class SyncClient {
3857
3913
  'received SEGMENT_REF but no segment downloader is configured',
3858
3914
  );
3859
3915
  }
3916
+ this.#progress.update({
3917
+ phase: 'download',
3918
+ subscriptionId: sub.id,
3919
+ table: frame.table,
3920
+ segmentId: frame.segmentId,
3921
+ bytesReceived: 0,
3922
+ bytesTotal: frame.byteLength,
3923
+ rowsProcessed: 0,
3924
+ rowsTotal: frame.rowCount,
3925
+ });
3926
+ const onProgress = (bytesReceived: number): void => {
3927
+ this.#progress.update({ bytesReceived });
3928
+ };
3860
3929
  let bytes: Uint8Array;
3861
3930
  if (frame.url !== undefined) {
3862
3931
  // §5.4: a url-carrying descriptor MUST be fetched from that URL —
@@ -3884,17 +3953,19 @@ export class SyncClient {
3884
3953
  true,
3885
3954
  );
3886
3955
  }
3887
- bytes = await fetchUrl(frame.url);
3956
+ bytes = await fetchUrl(frame.url, onProgress);
3888
3957
  } else {
3889
3958
  bytes = await downloader({
3890
3959
  segmentId: frame.segmentId,
3891
3960
  table: frame.table,
3892
3961
  requestedScopesJson: canonicalScopeJson(sub.scopes),
3962
+ onProgress,
3893
3963
  });
3894
3964
  }
3895
3965
  // §5.1: verify the content address before applying; on mismatch the
3896
3966
  // segment is discarded and the cursor/resume token stay unpersisted,
3897
3967
  // so the next pull re-delivers.
3968
+ onProgress(bytes.byteLength);
3898
3969
  const hash = await sha256Hex(bytes);
3899
3970
  if (`sha256:${hash}` !== frame.segmentId) {
3900
3971
  throw new ClientSyncError(
package/src/http.ts CHANGED
@@ -139,6 +139,40 @@ export function webSocketRemoteOperationConnector(
139
139
  * Resolution (which path a descriptor takes, expiry, no fall-through)
140
140
  * lives in the client core, not here.
141
141
  */
142
+ async function readSegmentBody(
143
+ response: Response,
144
+ onProgress?: (bytesReceived: number) => void,
145
+ ): Promise<Uint8Array> {
146
+ const chunks: Uint8Array[] = [];
147
+ let received = 0;
148
+ let reported = 0;
149
+ const reader = response.body?.getReader();
150
+ if (reader !== undefined) {
151
+ try {
152
+ for (;;) {
153
+ const { done, value } = await reader.read();
154
+ if (done) break;
155
+ chunks.push(value);
156
+ received += value.byteLength;
157
+ if (received - reported >= 64 * 1024) {
158
+ onProgress?.(received);
159
+ reported = received;
160
+ }
161
+ }
162
+ } finally {
163
+ reader.releaseLock();
164
+ }
165
+ }
166
+ if (received !== reported) onProgress?.(received);
167
+ const bytes = new Uint8Array(received);
168
+ let offset = 0;
169
+ for (const chunk of chunks) {
170
+ bytes.set(chunk, offset);
171
+ offset += chunk.byteLength;
172
+ }
173
+ return bytes;
174
+ }
175
+
142
176
  export function httpSegmentDownloader(
143
177
  segmentsBaseUrl: string,
144
178
  options?: HttpTransportOptions,
@@ -147,6 +181,7 @@ export function httpSegmentDownloader(
147
181
  const direct = async (request: {
148
182
  readonly segmentId: string;
149
183
  readonly requestedScopesJson: string;
184
+ readonly onProgress?: (bytesReceived: number) => void;
150
185
  }) => {
151
186
  const response = await doFetch(
152
187
  `${segmentsBaseUrl}/${encodeURIComponent(request.segmentId)}`,
@@ -158,9 +193,12 @@ export function httpSegmentDownloader(
158
193
  },
159
194
  );
160
195
  if (!response.ok) await throwHttpError(response);
161
- return new Uint8Array(await response.arrayBuffer());
196
+ return readSegmentBody(response, request.onProgress);
162
197
  };
163
- const fetchUrl = async (url: string) => {
198
+ const fetchUrl = async (
199
+ url: string,
200
+ onProgress?: (bytesReceived: number) => void,
201
+ ) => {
164
202
  // Deliberately headerless: the URL is the bearer grant (§5.4).
165
203
  const response = await doFetch(url);
166
204
  if (!response.ok) {
@@ -170,7 +208,7 @@ export function httpSegmentDownloader(
170
208
  true,
171
209
  );
172
210
  }
173
- return new Uint8Array(await response.arrayBuffer());
211
+ return readSegmentBody(response, onProgress);
174
212
  };
175
213
  return Object.assign(direct, { fetchUrl });
176
214
  }
package/src/index.ts CHANGED
@@ -40,3 +40,9 @@ export * from './transport';
40
40
  export * from './window';
41
41
  export * from './worker-host';
42
42
  export * from './worker-protocol';
43
+
44
+ export {
45
+ ProgressEmitter,
46
+ type SyncProgress,
47
+ type SyncProgressListener,
48
+ } from './progress';
@@ -0,0 +1,62 @@
1
+ /** Ephemeral sync work, independent of committed change notifications (SPEC §7.6). */
2
+ export interface SyncProgress {
3
+ readonly attempt: number;
4
+ readonly state: 'running' | 'complete' | 'failed';
5
+ readonly phase: 'request' | 'download' | 'import';
6
+ readonly subscriptionId?: string;
7
+ readonly table?: string;
8
+ readonly segmentId?: string | undefined;
9
+ readonly bytesReceived: number;
10
+ readonly bytesTotal?: number;
11
+ readonly rowsProcessed: number;
12
+ readonly rowsTotal?: number;
13
+ readonly errorCode?: string;
14
+ }
15
+
16
+ export type SyncProgressListener = (progress: SyncProgress) => void;
17
+
18
+ /** Shared by the direct client and event-forwarding handles. */
19
+ export class ProgressEmitter {
20
+ #snapshot: SyncProgress | undefined;
21
+ readonly #listeners = new Set<SyncProgressListener>();
22
+
23
+ clear(): void {
24
+ this.#listeners.clear();
25
+ this.#snapshot = undefined;
26
+ }
27
+
28
+ snapshot(): SyncProgress | undefined {
29
+ return this.#snapshot;
30
+ }
31
+
32
+ on(listener: SyncProgressListener): () => void {
33
+ this.#listeners.add(listener);
34
+ if (this.#snapshot !== undefined) {
35
+ try {
36
+ listener(this.#snapshot);
37
+ } catch {
38
+ /* Observers cannot interrupt sync. */
39
+ }
40
+ }
41
+ return () => {
42
+ this.#listeners.delete(listener);
43
+ };
44
+ }
45
+
46
+ emit(progress: SyncProgress): void {
47
+ const snapshot = Object.freeze({ ...progress });
48
+ this.#snapshot = snapshot;
49
+ for (const listener of [...this.#listeners]) {
50
+ try {
51
+ listener(snapshot);
52
+ } catch {
53
+ /* Observers cannot interrupt sync. */
54
+ }
55
+ }
56
+ }
57
+
58
+ update(update: Partial<SyncProgress>): void {
59
+ if (this.#snapshot?.state === 'running')
60
+ this.emit({ ...this.#snapshot, ...update });
61
+ }
62
+ }
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 {