@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.
- package/README.md +118 -6
- package/dist/apply.d.ts +16 -7
- package/dist/apply.js +83 -37
- package/dist/bun-database.d.ts +1 -1
- package/dist/bun-database.js +2 -2
- package/dist/client.d.ts +7 -4
- package/dist/client.js +121 -35
- package/dist/database.d.ts +3 -2
- 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/node-database.d.ts +1 -1
- package/dist/node-database.js +2 -2
- package/dist/progress.d.ts +24 -0
- package/dist/progress.js +42 -0
- package/dist/reactive-store.js +49 -8
- package/dist/schema.js +15 -4
- package/dist/transport.d.ts +2 -1
- package/dist/wasm-database.js +44 -4
- 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 +99 -47
- package/src/bun-database.ts +6 -2
- package/src/client.ts +153 -38
- package/src/database.ts +7 -2
- package/src/http.ts +41 -3
- package/src/index.ts +6 -0
- package/src/node-database.ts +6 -2
- package/src/progress.ts +62 -0
- package/src/reactive-store.ts +58 -8
- package/src/schema.ts +22 -4
- package/src/transport.ts +5 -1
- package/src/wasm-database.ts +86 -4
- package/src/worker-entry.ts +38 -7
- package/src/worker-host.ts +17 -1
- package/src/worker-protocol.ts +2 -0
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
|
*
|
|
@@ -39,6 +44,7 @@ import {
|
|
|
39
44
|
deleteLocalRow,
|
|
40
45
|
deleteScopedRows,
|
|
41
46
|
evictScopedRows,
|
|
47
|
+
yieldToHost,
|
|
42
48
|
upsertLocalRow,
|
|
43
49
|
} from './apply';
|
|
44
50
|
import {
|
|
@@ -281,8 +287,8 @@ export interface SyncClientLimits {
|
|
|
281
287
|
readonly maxSnapshotPages?: number;
|
|
282
288
|
/**
|
|
283
289
|
* §4.2 accept bitmask; defaults to inline + external rows (0b0011)
|
|
284
|
-
* plus
|
|
285
|
-
*
|
|
290
|
+
* plus SQLite images and signed URLs when supported. Both image chunks
|
|
291
|
+
* and rows blocks commit before yielding to local reads (§7.7).
|
|
286
292
|
*/
|
|
287
293
|
readonly accept?: number;
|
|
288
294
|
/**
|
|
@@ -584,6 +590,8 @@ export class SyncClient {
|
|
|
584
590
|
#realtimeGeneration = 0;
|
|
585
591
|
#pendingRound: PendingRound | undefined;
|
|
586
592
|
#needsPull = false;
|
|
593
|
+
readonly #progress = new ProgressEmitter();
|
|
594
|
+
#progressAttempt = 0;
|
|
587
595
|
#syncing = false;
|
|
588
596
|
/**
|
|
589
597
|
* True from the synchronous entry of `sync()` until its serialized round
|
|
@@ -741,6 +749,7 @@ export class SyncClient {
|
|
|
741
749
|
const startupWork =
|
|
742
750
|
this.#schemaFloor === undefined &&
|
|
743
751
|
(countOutbox(this.#db) > 0 ||
|
|
752
|
+
loadPendingEvictions(this.#db).length > 0 ||
|
|
744
753
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
745
754
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
746
755
|
this.#needsPull = true;
|
|
@@ -873,6 +882,7 @@ export class SyncClient {
|
|
|
873
882
|
await this.#lease?.release();
|
|
874
883
|
this.#lease = undefined;
|
|
875
884
|
this.#started = false;
|
|
885
|
+
this.#progress.clear();
|
|
876
886
|
this.#syncNeededListeners.clear();
|
|
877
887
|
this.#syncIntentListeners.clear();
|
|
878
888
|
}
|
|
@@ -1073,6 +1083,14 @@ export class SyncClient {
|
|
|
1073
1083
|
}
|
|
1074
1084
|
|
|
1075
1085
|
/** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
|
|
1086
|
+
onProgress(listener: SyncProgressListener): () => void {
|
|
1087
|
+
return this.#progress.on(listener);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
progressSnapshot(): SyncProgress | undefined {
|
|
1091
|
+
return this.#progress.snapshot();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1076
1094
|
onChange(listener: ClientChangeListener): () => void {
|
|
1077
1095
|
return this.#changes.on(listener);
|
|
1078
1096
|
}
|
|
@@ -1804,23 +1822,17 @@ export class SyncClient {
|
|
|
1804
1822
|
socket.send(encodePresencePublish(scopeKey, doc));
|
|
1805
1823
|
}
|
|
1806
1824
|
|
|
1807
|
-
/**
|
|
1808
|
-
* §4.2 accept mask: the configured override, or the rows baseline plus
|
|
1809
|
-
* bit 2 when the backend can import sqlite images (§5.3) and a segment
|
|
1810
|
-
* downloader exists (sqlite segments are never inline, §5.7), plus
|
|
1811
|
-
* bit 3 when the downloader exposes a direct URL fetch (§5.4
|
|
1812
|
-
* capability negotiation).
|
|
1813
|
-
*/
|
|
1825
|
+
/** Advertise supported segment formats; all imports yield between commits. */
|
|
1814
1826
|
#acceptMask(): number {
|
|
1815
1827
|
const configured = this.#config.limits?.accept;
|
|
1816
1828
|
if (configured !== undefined) return configured;
|
|
1817
1829
|
const segments = this.#config.segments;
|
|
1818
|
-
const sqliteCapable =
|
|
1819
|
-
typeof this.#db.withSqliteImage === 'function' && segments !== undefined;
|
|
1820
1830
|
const urlCapable = typeof segments?.fetchUrl === 'function';
|
|
1821
1831
|
return (
|
|
1822
1832
|
ACCEPT_ROWS_BASELINE |
|
|
1823
|
-
(
|
|
1833
|
+
(typeof this.#db.withSqliteImage === 'function' && segments !== undefined
|
|
1834
|
+
? ACCEPT_SQLITE
|
|
1835
|
+
: 0) |
|
|
1824
1836
|
(urlCapable ? ACCEPT_SIGNED_URLS : 0)
|
|
1825
1837
|
);
|
|
1826
1838
|
}
|
|
@@ -1893,8 +1905,8 @@ export class SyncClient {
|
|
|
1893
1905
|
* Set the live window units for a base (§4.8): a value-sharded family of
|
|
1894
1906
|
* subscriptions, one per unit. Computes the diff against the registry —
|
|
1895
1907
|
* added units get fresh subscriptions (image-lane bootstrap on the next
|
|
1896
|
-
* sync); removed units are unsubscribed
|
|
1897
|
-
* transaction (E1–E4). Idempotent: calling with the same units is a
|
|
1908
|
+
* sync); removed units are unsubscribed with their first eviction chunk
|
|
1909
|
+
* in one local transaction (E1–E4). Idempotent: calling with the same units is a
|
|
1898
1910
|
* no-op. Re-entry (a unit removed then re-added) cancels any deferred
|
|
1899
1911
|
* eviction and fresh-bootstraps.
|
|
1900
1912
|
*
|
|
@@ -1959,7 +1971,7 @@ export class SyncClient {
|
|
|
1959
1971
|
// Shrink: units live but not wanted → unsubscribe fused with eviction.
|
|
1960
1972
|
for (const { unit, subId } of live) {
|
|
1961
1973
|
if (wanted.has(unit)) continue;
|
|
1962
|
-
this.#evictUnit(baseKey, base, unit, subId);
|
|
1974
|
+
await this.#evictUnit(baseKey, base, unit, subId);
|
|
1963
1975
|
changed = true;
|
|
1964
1976
|
}
|
|
1965
1977
|
});
|
|
@@ -2002,8 +2014,8 @@ export class SyncClient {
|
|
|
2002
2014
|
}
|
|
2003
2015
|
|
|
2004
2016
|
/**
|
|
2005
|
-
* §4.8 E1–E4: evict one departing unit
|
|
2006
|
-
*
|
|
2017
|
+
* §4.8 E1–E4: evict one departing unit in committed chunks, fusing
|
|
2018
|
+
* its first chunk with unsubscription. Deletes the unit's rows EXCEPT those pinned by a
|
|
2007
2019
|
* pending outbox commit (E1); if any pin remains, records a deferred
|
|
2008
2020
|
* eviction retried on the next outbox drain. Discards the subscription's
|
|
2009
2021
|
* cursor/resume/effective-echo (E3) and its version state with the rows
|
|
@@ -2011,24 +2023,26 @@ export class SyncClient {
|
|
|
2011
2023
|
* with no local scope-column mapping, surfaces a configuration error and
|
|
2012
2024
|
* evicts nothing (§4.8/§3.3).
|
|
2013
2025
|
*/
|
|
2014
|
-
#evictUnit(
|
|
2026
|
+
async #evictUnit(
|
|
2015
2027
|
baseKey: string,
|
|
2016
2028
|
base: WindowBase,
|
|
2017
2029
|
unit: string,
|
|
2018
2030
|
subId: string,
|
|
2019
|
-
): void {
|
|
2031
|
+
): Promise<void> {
|
|
2020
2032
|
const table = this.#table(base.table);
|
|
2021
2033
|
const sub = getSubscription(this.#db, subId);
|
|
2022
2034
|
// The rows a unit holds live under its LAST effective scopes if it ever
|
|
2023
2035
|
// synced; before first sync, the requested unit scopes are the match.
|
|
2024
2036
|
const effective = sub?.effectiveScopes ?? unitScopes(base, unit);
|
|
2025
2037
|
const pinned = this.#pinnedRowIds(base.table);
|
|
2038
|
+
let remaining = false;
|
|
2026
2039
|
this.#applyBatch((batch) => {
|
|
2027
2040
|
this.#db.transaction(() => {
|
|
2028
|
-
const
|
|
2041
|
+
const result = evictScopedRows(this.#db, table, effective, pinned);
|
|
2042
|
+
remaining = result.remaining;
|
|
2029
2043
|
deleteWindowUnit(this.#db, baseKey, unit);
|
|
2030
2044
|
deleteSubscription(this.#db, subId);
|
|
2031
|
-
if (deferred) {
|
|
2045
|
+
if (result.deferred) {
|
|
2032
2046
|
savePendingEviction(this.#db, subId, base.table, effective);
|
|
2033
2047
|
} else {
|
|
2034
2048
|
deletePendingEviction(this.#db, subId);
|
|
@@ -2039,6 +2053,10 @@ export class SyncClient {
|
|
|
2039
2053
|
this.#needsPull = true;
|
|
2040
2054
|
batch.status();
|
|
2041
2055
|
});
|
|
2056
|
+
if (remaining) {
|
|
2057
|
+
await yieldToHost();
|
|
2058
|
+
await this.#drainPendingEvictions();
|
|
2059
|
+
}
|
|
2042
2060
|
}
|
|
2043
2061
|
|
|
2044
2062
|
/**
|
|
@@ -2046,7 +2064,7 @@ export class SyncClient {
|
|
|
2046
2064
|
* unit's rows are removed once no pending commit references them; a unit
|
|
2047
2065
|
* that re-entered the window in the meantime has no pending record left.
|
|
2048
2066
|
*/
|
|
2049
|
-
#drainPendingEvictions(): void {
|
|
2067
|
+
async #drainPendingEvictions(): Promise<void> {
|
|
2050
2068
|
const pending = loadPendingEvictions(this.#db);
|
|
2051
2069
|
if (pending.length === 0) return;
|
|
2052
2070
|
for (const entry of pending) {
|
|
@@ -2055,15 +2073,30 @@ export class SyncClient {
|
|
|
2055
2073
|
deletePendingEviction(this.#db, entry.subId);
|
|
2056
2074
|
continue;
|
|
2057
2075
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2076
|
+
let remaining: boolean;
|
|
2077
|
+
do {
|
|
2078
|
+
if (
|
|
2079
|
+
!loadPendingEvictions(this.#db).some(
|
|
2080
|
+
(item) => item.subId === entry.subId,
|
|
2081
|
+
)
|
|
2082
|
+
)
|
|
2083
|
+
break;
|
|
2084
|
+
// Local writes may add pins while the worker yields between chunks.
|
|
2085
|
+
const pinned = this.#pinnedRowIds(entry.table);
|
|
2086
|
+
remaining = false;
|
|
2087
|
+
this.#applyBatch((batch) => {
|
|
2088
|
+
const result = evictScopedRows(
|
|
2089
|
+
this.#db,
|
|
2090
|
+
table,
|
|
2091
|
+
entry.effective,
|
|
2092
|
+
pinned,
|
|
2093
|
+
);
|
|
2094
|
+
remaining = result.remaining;
|
|
2095
|
+
if (!result.deferred) deletePendingEviction(this.#db, entry.subId);
|
|
2096
|
+
batch.scopeMap(table, entry.effective);
|
|
2064
2097
|
});
|
|
2065
|
-
|
|
2066
|
-
});
|
|
2098
|
+
if (remaining) await yieldToHost();
|
|
2099
|
+
} while (remaining);
|
|
2067
2100
|
}
|
|
2068
2101
|
}
|
|
2069
2102
|
|
|
@@ -2655,6 +2688,13 @@ export class SyncClient {
|
|
|
2655
2688
|
(error: unknown) => {
|
|
2656
2689
|
const completedAtMs = this.#now();
|
|
2657
2690
|
const code = (error as { code?: unknown }).code;
|
|
2691
|
+
this.#progress.update({
|
|
2692
|
+
state: 'failed',
|
|
2693
|
+
errorCode:
|
|
2694
|
+
typeof code === 'string'
|
|
2695
|
+
? this.#diagnosticCode(code)
|
|
2696
|
+
: 'client.unknown_failure',
|
|
2697
|
+
});
|
|
2658
2698
|
this.#lastRound = {
|
|
2659
2699
|
status: 'failed',
|
|
2660
2700
|
startedAtMs,
|
|
@@ -2709,7 +2749,15 @@ export class SyncClient {
|
|
|
2709
2749
|
}
|
|
2710
2750
|
|
|
2711
2751
|
async #runSync(): Promise<SyncSummary> {
|
|
2752
|
+
this.#progress.emit({
|
|
2753
|
+
attempt: ++this.#progressAttempt,
|
|
2754
|
+
state: 'running',
|
|
2755
|
+
phase: 'request',
|
|
2756
|
+
bytesReceived: 0,
|
|
2757
|
+
rowsProcessed: 0,
|
|
2758
|
+
});
|
|
2712
2759
|
if (this.#schemaFloor !== undefined) {
|
|
2760
|
+
this.#progress.update({ state: 'complete' });
|
|
2713
2761
|
return {
|
|
2714
2762
|
...emptySummary(0),
|
|
2715
2763
|
bootstrapping: [],
|
|
@@ -2722,6 +2770,7 @@ export class SyncClient {
|
|
|
2722
2770
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
2723
2771
|
this.#setSyncNeeded(false);
|
|
2724
2772
|
try {
|
|
2773
|
+
await this.#drainPendingEvictions();
|
|
2725
2774
|
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
2726
2775
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
2727
2776
|
// them, so the server-side existence check (§6.6) passes.
|
|
@@ -2799,7 +2848,12 @@ export class SyncClient {
|
|
|
2799
2848
|
);
|
|
2800
2849
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
2801
2850
|
// a shrunk window unit — retry any deferred evictions now.
|
|
2802
|
-
this.#drainPendingEvictions();
|
|
2851
|
+
await this.#drainPendingEvictions();
|
|
2852
|
+
this.#progress.update(
|
|
2853
|
+
summary.failed.length > 0
|
|
2854
|
+
? { state: 'failed', errorCode: 'sync.scope_revoked' }
|
|
2855
|
+
: { state: 'complete' },
|
|
2856
|
+
);
|
|
2803
2857
|
this.#retryDelayMs = 250;
|
|
2804
2858
|
if (deferred > 0) {
|
|
2805
2859
|
// §6.1 splitBatch remainder: more queued commits than this request
|
|
@@ -2843,10 +2897,27 @@ export class SyncClient {
|
|
|
2843
2897
|
* Pull repeatedly until quiescent: no commits delivered, no bootstrap
|
|
2844
2898
|
* pages pending, no resets to recover (§4.5 "pull again" SHOULD).
|
|
2845
2899
|
*/
|
|
2846
|
-
async syncUntilIdle(maxRounds
|
|
2900
|
+
async syncUntilIdle(maxRounds?: number): Promise<SyncSummary> {
|
|
2901
|
+
const budget = maxRounds ?? 20;
|
|
2847
2902
|
let last: SyncSummary | undefined;
|
|
2848
|
-
for (let round = 0; round <
|
|
2903
|
+
for (let round = 0; round < budget; round++) {
|
|
2904
|
+
const before = JSON.stringify(
|
|
2905
|
+
loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]),
|
|
2906
|
+
);
|
|
2849
2907
|
last = await this.sync();
|
|
2908
|
+
if (
|
|
2909
|
+
maxRounds === undefined &&
|
|
2910
|
+
last.segmentRowsApplied > 0 &&
|
|
2911
|
+
last.bootstrapping.length > 0 &&
|
|
2912
|
+
before !==
|
|
2913
|
+
JSON.stringify(
|
|
2914
|
+
loadSubscriptions(this.#db).map((sub) => [
|
|
2915
|
+
sub.id,
|
|
2916
|
+
sub.bootstrapState,
|
|
2917
|
+
]),
|
|
2918
|
+
)
|
|
2919
|
+
)
|
|
2920
|
+
round = -1;
|
|
2850
2921
|
if (last.schemaFloor !== undefined) return last;
|
|
2851
2922
|
if (
|
|
2852
2923
|
last.commitsApplied === 0 &&
|
|
@@ -2861,7 +2932,7 @@ export class SyncClient {
|
|
|
2861
2932
|
}
|
|
2862
2933
|
throw new ClientSyncError(
|
|
2863
2934
|
'sync.invalid_request',
|
|
2864
|
-
|
|
2935
|
+
'sync did not reach idle within the round budget',
|
|
2865
2936
|
);
|
|
2866
2937
|
}
|
|
2867
2938
|
|
|
@@ -3368,6 +3439,19 @@ export class SyncClient {
|
|
|
3368
3439
|
break;
|
|
3369
3440
|
}
|
|
3370
3441
|
const segment = decodeRowsSegment(frame.payload);
|
|
3442
|
+
this.#progress.update({
|
|
3443
|
+
phase: 'import',
|
|
3444
|
+
subscriptionId: section.sub.id,
|
|
3445
|
+
table: section.sub.table,
|
|
3446
|
+
segmentId: undefined,
|
|
3447
|
+
bytesReceived: frame.payload.byteLength,
|
|
3448
|
+
bytesTotal: frame.payload.byteLength,
|
|
3449
|
+
rowsProcessed: 0,
|
|
3450
|
+
rowsTotal: segment.blocks.reduce(
|
|
3451
|
+
(n, block) => n + block.length,
|
|
3452
|
+
0,
|
|
3453
|
+
),
|
|
3454
|
+
});
|
|
3371
3455
|
await this.#applySegmentOrFail(
|
|
3372
3456
|
section,
|
|
3373
3457
|
summary,
|
|
@@ -3379,6 +3463,9 @@ export class SyncClient {
|
|
|
3379
3463
|
segment,
|
|
3380
3464
|
{
|
|
3381
3465
|
clearFirst,
|
|
3466
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3467
|
+
onProgress: (rowsProcessed) =>
|
|
3468
|
+
this.#progress.update({ rowsProcessed }),
|
|
3382
3469
|
effective,
|
|
3383
3470
|
transaction: (fn) =>
|
|
3384
3471
|
this.#applyBatch((batch) => {
|
|
@@ -3389,7 +3476,9 @@ export class SyncClient {
|
|
|
3389
3476
|
) {
|
|
3390
3477
|
batch.table(table.name);
|
|
3391
3478
|
}
|
|
3392
|
-
|
|
3479
|
+
const result = fn();
|
|
3480
|
+
this.#replayOutbox();
|
|
3481
|
+
return result;
|
|
3393
3482
|
}),
|
|
3394
3483
|
},
|
|
3395
3484
|
this.#encryption,
|
|
@@ -3418,6 +3507,7 @@ export class SyncClient {
|
|
|
3418
3507
|
);
|
|
3419
3508
|
}
|
|
3420
3509
|
const bytes = await this.#downloadSegment(frame, section.sub);
|
|
3510
|
+
this.#progress.update({ phase: 'import' });
|
|
3421
3511
|
if (frame.mediaType === 'sqlite') {
|
|
3422
3512
|
// §5.3: images are whole-table — a paged descriptor is
|
|
3423
3513
|
// invalid, and the image is always its table's first page.
|
|
@@ -3447,6 +3537,9 @@ export class SyncClient {
|
|
|
3447
3537
|
},
|
|
3448
3538
|
{
|
|
3449
3539
|
clearFirst,
|
|
3540
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3541
|
+
onProgress: (rowsProcessed) =>
|
|
3542
|
+
this.#progress.update({ rowsProcessed }),
|
|
3450
3543
|
effective,
|
|
3451
3544
|
transaction: (fn) =>
|
|
3452
3545
|
this.#applyBatch((batch) => {
|
|
@@ -3457,7 +3550,9 @@ export class SyncClient {
|
|
|
3457
3550
|
) {
|
|
3458
3551
|
batch.table(table.name);
|
|
3459
3552
|
}
|
|
3460
|
-
|
|
3553
|
+
const result = fn();
|
|
3554
|
+
this.#replayOutbox();
|
|
3555
|
+
return result;
|
|
3461
3556
|
}),
|
|
3462
3557
|
},
|
|
3463
3558
|
),
|
|
@@ -3476,6 +3571,9 @@ export class SyncClient {
|
|
|
3476
3571
|
segment,
|
|
3477
3572
|
{
|
|
3478
3573
|
clearFirst,
|
|
3574
|
+
isCurrent: () => this.#localResetEpoch === resetEpoch,
|
|
3575
|
+
onProgress: (rowsProcessed) =>
|
|
3576
|
+
this.#progress.update({ rowsProcessed }),
|
|
3479
3577
|
effective,
|
|
3480
3578
|
transaction: (fn) =>
|
|
3481
3579
|
this.#applyBatch((batch) => {
|
|
@@ -3486,7 +3584,9 @@ export class SyncClient {
|
|
|
3486
3584
|
) {
|
|
3487
3585
|
batch.table(table.name);
|
|
3488
3586
|
}
|
|
3489
|
-
|
|
3587
|
+
const result = fn();
|
|
3588
|
+
this.#replayOutbox();
|
|
3589
|
+
return result;
|
|
3490
3590
|
}),
|
|
3491
3591
|
},
|
|
3492
3592
|
this.#encryption,
|
|
@@ -3857,6 +3957,19 @@ export class SyncClient {
|
|
|
3857
3957
|
'received SEGMENT_REF but no segment downloader is configured',
|
|
3858
3958
|
);
|
|
3859
3959
|
}
|
|
3960
|
+
this.#progress.update({
|
|
3961
|
+
phase: 'download',
|
|
3962
|
+
subscriptionId: sub.id,
|
|
3963
|
+
table: frame.table,
|
|
3964
|
+
segmentId: frame.segmentId,
|
|
3965
|
+
bytesReceived: 0,
|
|
3966
|
+
bytesTotal: frame.byteLength,
|
|
3967
|
+
rowsProcessed: 0,
|
|
3968
|
+
rowsTotal: frame.rowCount,
|
|
3969
|
+
});
|
|
3970
|
+
const onProgress = (bytesReceived: number): void => {
|
|
3971
|
+
this.#progress.update({ bytesReceived });
|
|
3972
|
+
};
|
|
3860
3973
|
let bytes: Uint8Array;
|
|
3861
3974
|
if (frame.url !== undefined) {
|
|
3862
3975
|
// §5.4: a url-carrying descriptor MUST be fetched from that URL —
|
|
@@ -3884,17 +3997,19 @@ export class SyncClient {
|
|
|
3884
3997
|
true,
|
|
3885
3998
|
);
|
|
3886
3999
|
}
|
|
3887
|
-
bytes = await fetchUrl(frame.url);
|
|
4000
|
+
bytes = await fetchUrl(frame.url, onProgress);
|
|
3888
4001
|
} else {
|
|
3889
4002
|
bytes = await downloader({
|
|
3890
4003
|
segmentId: frame.segmentId,
|
|
3891
4004
|
table: frame.table,
|
|
3892
4005
|
requestedScopesJson: canonicalScopeJson(sub.scopes),
|
|
4006
|
+
onProgress,
|
|
3893
4007
|
});
|
|
3894
4008
|
}
|
|
3895
4009
|
// §5.1: verify the content address before applying; on mismatch the
|
|
3896
4010
|
// segment is discarded and the cursor/resume token stay unpersisted,
|
|
3897
4011
|
// so the next pull re-delivers.
|
|
4012
|
+
onProgress(bytes.byteLength);
|
|
3898
4013
|
const hash = await sha256Hex(bytes);
|
|
3899
4014
|
if (`sha256:${hash}` !== frame.segmentId) {
|
|
3900
4015
|
throw new ClientSyncError(
|
package/src/database.ts
CHANGED
|
@@ -22,11 +22,16 @@ export interface ClientDatabase {
|
|
|
22
22
|
/**
|
|
23
23
|
* Optional sqlite-image capability (SPEC §5.3): expose `bytes` as an
|
|
24
24
|
* attached read-only database schema named `alias` for the duration of
|
|
25
|
-
* `fn`,
|
|
25
|
+
* `fn`, awaiting its result before detaching. No transaction spans its awaits.
|
|
26
|
+
* Presence of this method is what makes the client
|
|
26
27
|
* advertise `accept` bit 2 (§4.2). MUST be called outside any open
|
|
27
28
|
* transaction (SQLite cannot ATTACH inside one); `fn` may open its own.
|
|
28
29
|
*/
|
|
29
|
-
withSqliteImage?<T>(
|
|
30
|
+
withSqliteImage?<T>(
|
|
31
|
+
bytes: Uint8Array,
|
|
32
|
+
alias: string,
|
|
33
|
+
fn: () => T | Promise<T>,
|
|
34
|
+
): Promise<T>;
|
|
30
35
|
close(): void;
|
|
31
36
|
}
|
|
32
37
|
|
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/node-database.ts
CHANGED
|
@@ -63,7 +63,11 @@ export class NodeClientDatabase implements ClientDatabase {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** §5.3 image import through a private file attached for one callback. */
|
|
66
|
-
withSqliteImage<T>(
|
|
66
|
+
async withSqliteImage<T>(
|
|
67
|
+
bytes: Uint8Array,
|
|
68
|
+
alias: string,
|
|
69
|
+
fn: () => T | Promise<T>,
|
|
70
|
+
): Promise<T> {
|
|
67
71
|
assertImageAlias(alias);
|
|
68
72
|
const dir = mkdtempSync(join(tmpdir(), 'syncular-image-'));
|
|
69
73
|
const path = join(dir, 'segment.db');
|
|
@@ -71,7 +75,7 @@ export class NodeClientDatabase implements ClientDatabase {
|
|
|
71
75
|
writeFileSync(path, bytes);
|
|
72
76
|
this.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(path);
|
|
73
77
|
try {
|
|
74
|
-
return fn();
|
|
78
|
+
return await fn();
|
|
75
79
|
} finally {
|
|
76
80
|
this.db.prepare(`DETACH DATABASE ${alias}`).run();
|
|
77
81
|
}
|
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
|
+
}
|