@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.
- package/README.md +101 -6
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +32 -8
- package/dist/client.d.ts +3 -0
- package/dist/client.js +57 -1
- package/dist/http.d.ts +0 -8
- package/dist/http.js +36 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/progress.d.ts +24 -0
- package/dist/progress.js +42 -0
- package/dist/transport.d.ts +2 -1
- package/dist/wasm-database.js +41 -1
- package/dist/worker-entry.d.ts +2 -0
- package/dist/worker-entry.js +34 -7
- package/dist/worker-host.d.ts +3 -0
- package/dist/worker-host.js +13 -1
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +4 -3
- package/src/apply.ts +39 -13
- package/src/client.ts +72 -1
- package/src/http.ts +41 -3
- package/src/index.ts +6 -0
- package/src/progress.ts +62 -0
- package/src/transport.ts +5 -1
- package/src/wasm-database.ts +79 -1
- package/src/worker-entry.ts +38 -7
- package/src/worker-host.ts +17 -1
- package/src/worker-protocol.ts +2 -0
package/dist/worker-entry.js
CHANGED
|
@@ -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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 } });
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/worker-host.js
CHANGED
|
@@ -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 === '
|
|
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.
|
|
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.
|
|
92
|
+
"@syncular/core": "0.20.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
196
|
+
return readSegmentBody(response, request.onProgress);
|
|
162
197
|
};
|
|
163
|
-
const fetchUrl = async (
|
|
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
|
|
211
|
+
return readSegmentBody(response, onProgress);
|
|
174
212
|
};
|
|
175
213
|
return Object.assign(direct, { fetchUrl });
|
|
176
214
|
}
|
package/src/index.ts
CHANGED
package/src/progress.ts
ADDED
|
@@ -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?: (
|
|
48
|
+
readonly fetchUrl?: (
|
|
49
|
+
url: string,
|
|
50
|
+
onProgress?: (bytesReceived: number) => void,
|
|
51
|
+
) => Promise<Uint8Array>;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
export interface RealtimeHandlers {
|