@syncular/client 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -19
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +32 -8
- package/dist/blob.d.ts +12 -39
- package/dist/blob.js +63 -134
- package/dist/client.d.ts +6 -3
- package/dist/client.js +95 -58
- 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/outbox.js +3 -0
- package/dist/progress.d.ts +24 -0
- package/dist/progress.js +42 -0
- package/dist/schema.js +3 -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/blob.ts +86 -157
- package/src/client.ts +110 -69
- package/src/http.ts +41 -3
- package/src/index.ts +6 -0
- package/src/outbox.ts +3 -0
- package/src/progress.ts +62 -0
- package/src/schema.ts +5 -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/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ProgressEmitter, } from './progress.js';
|
|
1
2
|
/**
|
|
2
3
|
* SyncClient implements the client side of SPEC.md §§3–8.
|
|
3
4
|
*
|
|
@@ -9,7 +10,7 @@
|
|
|
9
10
|
*/
|
|
10
11
|
import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
11
12
|
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, upsertLocalRow, } from './apply.js';
|
|
12
|
-
import { clearPendingUpload, computeBlobId, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob,
|
|
13
|
+
import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
13
14
|
import { registerDevtools } from './devtools.js';
|
|
14
15
|
import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, } from './diagnostics.js';
|
|
15
16
|
import { ClientSyncError } from './errors.js';
|
|
@@ -102,6 +103,8 @@ export class SyncClient {
|
|
|
102
103
|
#realtimeGeneration = 0;
|
|
103
104
|
#pendingRound;
|
|
104
105
|
#needsPull = false;
|
|
106
|
+
#progress = new ProgressEmitter();
|
|
107
|
+
#progressAttempt = 0;
|
|
105
108
|
#syncing = false;
|
|
106
109
|
/**
|
|
107
110
|
* True from the synchronous entry of `sync()` until its serialized round
|
|
@@ -191,39 +194,8 @@ export class SyncClient {
|
|
|
191
194
|
// Do not materialize new app indexes/FTS projections yet: on a version
|
|
192
195
|
// bump they may reference columns that only exist after the reset.
|
|
193
196
|
ensureLocalBookkeepingSchema(this.#db);
|
|
194
|
-
this.#
|
|
195
|
-
|
|
196
|
-
this.#db.exec(`DROP TRIGGER IF EXISTS _syncular_blob_commit_${trigger}`);
|
|
197
|
-
if (this.#hasBlobs) {
|
|
198
|
-
ensureBlobSchema(this.#db);
|
|
199
|
-
const columns = JSON.stringify([...this.#schema.tables.values()].flatMap((table) => table.columns
|
|
200
|
-
.filter((column) => column.type === 'blob_ref')
|
|
201
|
-
.map((column) => [table.name, column.name]))).replaceAll("'", "''");
|
|
202
|
-
const references = `SELECT o.client_commit_id, b.blob_id
|
|
203
|
-
FROM _syncular_outbox o JOIN json_each(o.operations) op
|
|
204
|
-
JOIN json_each(op.value, '$.values') v JOIN json_each('${columns}') c
|
|
205
|
-
JOIN _syncular_blobs b ON b.blob_id = CASE WHEN v.type = 'text' AND json_valid(v.value) THEN json_extract(v.value, '$.blobId') END
|
|
206
|
-
WHERE json_extract(op.value, '$.op') = 'upsert'
|
|
207
|
-
AND json_extract(op.value, '$.table') = json_extract(c.value, '$[0]')
|
|
208
|
-
AND v.key = json_extract(c.value, '$[1]')`;
|
|
209
|
-
this.#db
|
|
210
|
-
.exec(`CREATE TRIGGER _syncular_blob_commit_insert AFTER INSERT ON _syncular_outbox BEGIN
|
|
211
|
-
INSERT OR IGNORE INTO _syncular_blob_commit_refs ${references} AND o.client_commit_id = NEW.client_commit_id; END`);
|
|
212
|
-
this.#db
|
|
213
|
-
.exec(`CREATE TRIGGER _syncular_blob_commit_update AFTER UPDATE OF operations ON _syncular_outbox BEGIN
|
|
214
|
-
DELETE FROM _syncular_blob_commit_refs WHERE commit_id = OLD.client_commit_id;
|
|
215
|
-
INSERT OR IGNORE INTO _syncular_blob_commit_refs ${references} AND o.client_commit_id = NEW.client_commit_id; END`);
|
|
216
|
-
this.#db
|
|
217
|
-
.exec(`CREATE TRIGGER _syncular_blob_commit_delete BEFORE DELETE ON _syncular_outbox BEGIN
|
|
218
|
-
DELETE FROM _syncular_blob_uploads WHERE blob_id IN (SELECT blob_id FROM _syncular_blob_commit_refs WHERE commit_id = OLD.client_commit_id)
|
|
219
|
-
AND NOT EXISTS (SELECT 1 FROM _syncular_blob_commit_refs r WHERE r.blob_id = _syncular_blob_uploads.blob_id AND r.commit_id != OLD.client_commit_id);
|
|
220
|
-
DELETE FROM _syncular_blob_commit_refs WHERE commit_id = OLD.client_commit_id; END`);
|
|
221
|
-
this.#db
|
|
222
|
-
.exec(`CREATE TRIGGER _syncular_blob_commit_cache AFTER INSERT ON _syncular_blobs BEGIN
|
|
223
|
-
INSERT OR IGNORE INTO _syncular_blob_commit_refs ${references} AND b.blob_id = NEW.blob_id; END`);
|
|
224
|
-
this.#db.exec(`INSERT OR IGNORE INTO _syncular_blob_commit_refs ${references}`);
|
|
225
|
-
}
|
|
226
|
-
});
|
|
197
|
+
if (this.#hasBlobs)
|
|
198
|
+
ensureBlobSchema(this.#db);
|
|
227
199
|
this.#db.transaction(() => {
|
|
228
200
|
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
229
201
|
});
|
|
@@ -390,6 +362,7 @@ export class SyncClient {
|
|
|
390
362
|
await this.#lease?.release();
|
|
391
363
|
this.#lease = undefined;
|
|
392
364
|
this.#started = false;
|
|
365
|
+
this.#progress.clear();
|
|
393
366
|
this.#syncNeededListeners.clear();
|
|
394
367
|
this.#syncIntentListeners.clear();
|
|
395
368
|
}
|
|
@@ -568,6 +541,12 @@ export class SyncClient {
|
|
|
568
541
|
return this.#invalidation.on(listener);
|
|
569
542
|
}
|
|
570
543
|
/** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
|
|
544
|
+
onProgress(listener) {
|
|
545
|
+
return this.#progress.on(listener);
|
|
546
|
+
}
|
|
547
|
+
progressSnapshot() {
|
|
548
|
+
return this.#progress.snapshot();
|
|
549
|
+
}
|
|
571
550
|
onChange(listener) {
|
|
572
551
|
return this.#changes.on(listener);
|
|
573
552
|
}
|
|
@@ -893,13 +872,11 @@ export class SyncClient {
|
|
|
893
872
|
throw new ClientSyncError('sync.invalid_request', 'uploadBlob requires a blob transport (SyncClientConfig.blobs, §5.9)');
|
|
894
873
|
}
|
|
895
874
|
const blobId = await computeBlobId(bytes);
|
|
875
|
+
const nowMs = this.#now();
|
|
896
876
|
this.#db.transaction(() => {
|
|
897
|
-
putCachedBlob(this.#db, blobId, bytes,
|
|
898
|
-
recordPendingUpload(this.#db, blobId,
|
|
877
|
+
putCachedBlob(this.#db, blobId, bytes, nowMs, options?.mediaType);
|
|
878
|
+
recordPendingUpload(this.#db, blobId, nowMs, options?.mediaType);
|
|
899
879
|
});
|
|
900
|
-
// §5.9.7 B1: a staged upload is pinned (recordPendingUpload), so the cap
|
|
901
|
-
// trim below will never evict it — but a stage may push other zero-ref
|
|
902
|
-
// bodies over the cap, so run the trim.
|
|
903
880
|
this.#enforceBlobCacheCap();
|
|
904
881
|
return {
|
|
905
882
|
blobId,
|
|
@@ -927,7 +904,7 @@ export class SyncClient {
|
|
|
927
904
|
const blobId = blobIdOrRef.startsWith('sha256:')
|
|
928
905
|
? blobIdOrRef
|
|
929
906
|
: parseBlobRef(blobIdOrRef).blobId;
|
|
930
|
-
const cached = getCachedBlob(this.#db, blobId
|
|
907
|
+
const cached = getCachedBlob(this.#db, blobId);
|
|
931
908
|
if (cached !== undefined)
|
|
932
909
|
return cached;
|
|
933
910
|
const transport = this.#config.blobs;
|
|
@@ -962,9 +939,7 @@ export class SyncClient {
|
|
|
962
939
|
throw new ClientSyncError('sync.invalid_request', `blob content address mismatch for ${blobId} (§5.9.5)`);
|
|
963
940
|
}
|
|
964
941
|
putCachedBlob(this.#db, blobId, bytes, this.#now());
|
|
965
|
-
|
|
966
|
-
// from current visible references before applying the size cap (§5.9.7 B1).
|
|
967
|
-
this.#reconcileBlobs(false, blobId);
|
|
942
|
+
this.#db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
968
943
|
this.#enforceBlobCacheCap();
|
|
969
944
|
const stored = getCachedBlob(this.#db, blobId);
|
|
970
945
|
if (stored === undefined) {
|
|
@@ -977,7 +952,7 @@ export class SyncClient {
|
|
|
977
952
|
const cap = this.#config.blobCacheMaxBytes;
|
|
978
953
|
if (cap === undefined)
|
|
979
954
|
return;
|
|
980
|
-
enforceBlobCacheCap(this.#db, cap);
|
|
955
|
+
enforceBlobCacheCap(this.#db, this.#schema, cap);
|
|
981
956
|
}
|
|
982
957
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
983
958
|
flushBlobUploads() {
|
|
@@ -1446,6 +1421,31 @@ export class SyncClient {
|
|
|
1446
1421
|
this.#applyBatch((batch) => {
|
|
1447
1422
|
this.#db.transaction(() => {
|
|
1448
1423
|
appendOutboxCommit(this.#db, clientCommitId, operations, this.#now(), this.#captureBeforeImages(operations));
|
|
1424
|
+
for (const operation of operations) {
|
|
1425
|
+
if (operation.op !== 'upsert')
|
|
1426
|
+
continue;
|
|
1427
|
+
const table = this.#schema.tables.get(operation.table);
|
|
1428
|
+
if (table === undefined)
|
|
1429
|
+
continue;
|
|
1430
|
+
for (const column of table.columns) {
|
|
1431
|
+
if (column.type !== 'blob_ref')
|
|
1432
|
+
continue;
|
|
1433
|
+
const raw = operation.values?.[column.name];
|
|
1434
|
+
if (typeof raw !== 'string')
|
|
1435
|
+
continue;
|
|
1436
|
+
let blobId;
|
|
1437
|
+
try {
|
|
1438
|
+
blobId = parseBlobRef(raw).blobId;
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
this.#db.exec(`INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
|
|
1444
|
+
SELECT ?, ? WHERE EXISTS (
|
|
1445
|
+
SELECT 1 FROM _syncular_blobs WHERE blob_id = ?
|
|
1446
|
+
)`, [clientCommitId, blobId, blobId]);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
1449
|
this.#applyOperationsLocally(operations, batch);
|
|
1450
1450
|
batch.status();
|
|
1451
1451
|
});
|
|
@@ -1604,7 +1604,7 @@ export class SyncClient {
|
|
|
1604
1604
|
batch.rejections();
|
|
1605
1605
|
batch.outcomes();
|
|
1606
1606
|
}
|
|
1607
|
-
this.#
|
|
1607
|
+
this.#deleteUnreferencedBlobs();
|
|
1608
1608
|
setMeta(this.#db, metaKey, purge.canonicalPlan);
|
|
1609
1609
|
return {
|
|
1610
1610
|
alreadyApplied: false,
|
|
@@ -1847,6 +1847,12 @@ export class SyncClient {
|
|
|
1847
1847
|
}, (error) => {
|
|
1848
1848
|
const completedAtMs = this.#now();
|
|
1849
1849
|
const code = error.code;
|
|
1850
|
+
this.#progress.update({
|
|
1851
|
+
state: 'failed',
|
|
1852
|
+
errorCode: typeof code === 'string'
|
|
1853
|
+
? this.#diagnosticCode(code)
|
|
1854
|
+
: 'client.unknown_failure',
|
|
1855
|
+
});
|
|
1850
1856
|
this.#lastRound = {
|
|
1851
1857
|
status: 'failed',
|
|
1852
1858
|
startedAtMs,
|
|
@@ -1893,7 +1899,15 @@ export class SyncClient {
|
|
|
1893
1899
|
: 'client.unknown_failure';
|
|
1894
1900
|
}
|
|
1895
1901
|
async #runSync() {
|
|
1902
|
+
this.#progress.emit({
|
|
1903
|
+
attempt: ++this.#progressAttempt,
|
|
1904
|
+
state: 'running',
|
|
1905
|
+
phase: 'request',
|
|
1906
|
+
bytesReceived: 0,
|
|
1907
|
+
rowsProcessed: 0,
|
|
1908
|
+
});
|
|
1896
1909
|
if (this.#schemaFloor !== undefined) {
|
|
1910
|
+
this.#progress.update({ state: 'complete' });
|
|
1897
1911
|
return {
|
|
1898
1912
|
...emptySummary(0),
|
|
1899
1913
|
bootstrapping: [],
|
|
@@ -1968,6 +1982,9 @@ export class SyncClient {
|
|
|
1968
1982
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
1969
1983
|
// a shrunk window unit — retry any deferred evictions now.
|
|
1970
1984
|
this.#drainPendingEvictions();
|
|
1985
|
+
this.#progress.update(summary.failed.length > 0
|
|
1986
|
+
? { state: 'failed', errorCode: 'sync.scope_revoked' }
|
|
1987
|
+
: { state: 'complete' });
|
|
1971
1988
|
this.#retryDelayMs = 250;
|
|
1972
1989
|
if (deferred > 0) {
|
|
1973
1990
|
// §6.1 splitBatch remainder: more queued commits than this request
|
|
@@ -2440,8 +2457,19 @@ export class SyncClient {
|
|
|
2440
2457
|
break;
|
|
2441
2458
|
}
|
|
2442
2459
|
const segment = decodeRowsSegment(frame.payload);
|
|
2460
|
+
this.#progress.update({
|
|
2461
|
+
phase: 'import',
|
|
2462
|
+
subscriptionId: section.sub.id,
|
|
2463
|
+
table: section.sub.table,
|
|
2464
|
+
segmentId: undefined,
|
|
2465
|
+
bytesReceived: frame.payload.byteLength,
|
|
2466
|
+
bytesTotal: frame.payload.byteLength,
|
|
2467
|
+
rowsProcessed: 0,
|
|
2468
|
+
rowsTotal: segment.blocks.reduce((n, block) => n + block.length, 0),
|
|
2469
|
+
});
|
|
2443
2470
|
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
2444
2471
|
clearFirst,
|
|
2472
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
2445
2473
|
effective,
|
|
2446
2474
|
transaction: (fn) => this.#applyBatch((batch) => {
|
|
2447
2475
|
if (segment.blocks.some((block) => block.length > 0) ||
|
|
@@ -2467,6 +2495,7 @@ export class SyncClient {
|
|
|
2467
2495
|
throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
|
|
2468
2496
|
}
|
|
2469
2497
|
const bytes = await this.#downloadSegment(frame, section.sub);
|
|
2498
|
+
this.#progress.update({ phase: 'import' });
|
|
2470
2499
|
if (frame.mediaType === 'sqlite') {
|
|
2471
2500
|
// §5.3: images are whole-table — a paged descriptor is
|
|
2472
2501
|
// invalid, and the image is always its table's first page.
|
|
@@ -2481,6 +2510,7 @@ export class SyncClient {
|
|
|
2481
2510
|
scopeDigest: frame.scopeDigest,
|
|
2482
2511
|
}, {
|
|
2483
2512
|
clearFirst,
|
|
2513
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
2484
2514
|
effective,
|
|
2485
2515
|
transaction: (fn) => this.#applyBatch((batch) => {
|
|
2486
2516
|
if (frame.rowCount > 0 ||
|
|
@@ -2496,6 +2526,7 @@ export class SyncClient {
|
|
|
2496
2526
|
const segment = decodeRowsSegment(bytes);
|
|
2497
2527
|
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
2498
2528
|
clearFirst,
|
|
2529
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
2499
2530
|
effective,
|
|
2500
2531
|
transaction: (fn) => this.#applyBatch((batch) => {
|
|
2501
2532
|
if (segment.blocks.some((block) => block.length > 0) ||
|
|
@@ -2546,7 +2577,6 @@ export class SyncClient {
|
|
|
2546
2577
|
// §7.1: local reads see outbox state applied optimistically — replay
|
|
2547
2578
|
// the still-pending commits on top of the freshly applied server state.
|
|
2548
2579
|
this.#replayOutbox();
|
|
2549
|
-
this.#reconcileBlobs(false);
|
|
2550
2580
|
}
|
|
2551
2581
|
finally {
|
|
2552
2582
|
this.#endDiagnosticsDeferral();
|
|
@@ -2801,6 +2831,19 @@ export class SyncClient {
|
|
|
2801
2831
|
if (downloader === undefined) {
|
|
2802
2832
|
throw new ClientSyncError('sync.invalid_request', 'received SEGMENT_REF but no segment downloader is configured');
|
|
2803
2833
|
}
|
|
2834
|
+
this.#progress.update({
|
|
2835
|
+
phase: 'download',
|
|
2836
|
+
subscriptionId: sub.id,
|
|
2837
|
+
table: frame.table,
|
|
2838
|
+
segmentId: frame.segmentId,
|
|
2839
|
+
bytesReceived: 0,
|
|
2840
|
+
bytesTotal: frame.byteLength,
|
|
2841
|
+
rowsProcessed: 0,
|
|
2842
|
+
rowsTotal: frame.rowCount,
|
|
2843
|
+
});
|
|
2844
|
+
const onProgress = (bytesReceived) => {
|
|
2845
|
+
this.#progress.update({ bytesReceived });
|
|
2846
|
+
};
|
|
2804
2847
|
let bytes;
|
|
2805
2848
|
if (frame.url !== undefined) {
|
|
2806
2849
|
// §5.4: a url-carrying descriptor MUST be fetched from that URL —
|
|
@@ -2817,18 +2860,20 @@ export class SyncClient {
|
|
|
2817
2860
|
// §5.4: MUST NOT start a fetch at/past expiry.
|
|
2818
2861
|
throw new ClientSyncError('sync.segment_expired', `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`, true);
|
|
2819
2862
|
}
|
|
2820
|
-
bytes = await fetchUrl(frame.url);
|
|
2863
|
+
bytes = await fetchUrl(frame.url, onProgress);
|
|
2821
2864
|
}
|
|
2822
2865
|
else {
|
|
2823
2866
|
bytes = await downloader({
|
|
2824
2867
|
segmentId: frame.segmentId,
|
|
2825
2868
|
table: frame.table,
|
|
2826
2869
|
requestedScopesJson: canonicalScopeJson(sub.scopes),
|
|
2870
|
+
onProgress,
|
|
2827
2871
|
});
|
|
2828
2872
|
}
|
|
2829
2873
|
// §5.1: verify the content address before applying; on mismatch the
|
|
2830
2874
|
// segment is discarded and the cursor/resume token stay unpersisted,
|
|
2831
2875
|
// so the next pull re-delivers.
|
|
2876
|
+
onProgress(bytes.byteLength);
|
|
2832
2877
|
const hash = await sha256Hex(bytes);
|
|
2833
2878
|
if (`sha256:${hash}` !== frame.segmentId) {
|
|
2834
2879
|
throw new ClientSyncError('sync.invalid_request', `segment ${frame.segmentId} failed content-address verification (§5.1)`, true);
|
|
@@ -2935,7 +2980,7 @@ export class SyncClient {
|
|
|
2935
2980
|
batch.rejections();
|
|
2936
2981
|
batch.outcomes();
|
|
2937
2982
|
}
|
|
2938
|
-
this.#
|
|
2983
|
+
this.#deleteUnreferencedBlobs();
|
|
2939
2984
|
}
|
|
2940
2985
|
catch (error) {
|
|
2941
2986
|
if (error instanceof ClientSyncError &&
|
|
@@ -3107,18 +3152,10 @@ export class SyncClient {
|
|
|
3107
3152
|
});
|
|
3108
3153
|
});
|
|
3109
3154
|
}
|
|
3110
|
-
|
|
3111
|
-
* §5.9.7 B1/B2: recompute blob-cache refcounts from live `blob_ref`
|
|
3112
|
-
* columns. No-op unless the schema has blob columns. `deleteOrphans`
|
|
3113
|
-
* triggers the revocation-side body deletion (B2).
|
|
3114
|
-
*/
|
|
3115
|
-
#reconcileBlobs(deleteOrphans, blobId) {
|
|
3155
|
+
#deleteUnreferencedBlobs() {
|
|
3116
3156
|
if (!this.#hasBlobs)
|
|
3117
3157
|
return;
|
|
3118
|
-
|
|
3119
|
-
deleteOrphans,
|
|
3120
|
-
...(blobId === undefined ? {} : { blobId }),
|
|
3121
|
-
});
|
|
3158
|
+
deleteUnreferencedCachedBlobs(this.#db, this.#schema);
|
|
3122
3159
|
}
|
|
3123
3160
|
// -- helpers -----------------------------------------------------------------
|
|
3124
3161
|
#table(name) {
|
package/dist/http.d.ts
CHANGED
|
@@ -16,14 +16,6 @@ export declare function httpSyncTransport(syncUrl: string, options?: HttpTranspo
|
|
|
16
16
|
export declare function httpRemoteOperationTransport(operationsUrl: string, options?: HttpTransportOptions): RemoteOperationTransport;
|
|
17
17
|
/** WebSocket connector for registered query snapshots. */
|
|
18
18
|
export declare function webSocketRemoteOperationConnector(realtimeUrl: string): RemoteOperationRealtimeConnector;
|
|
19
|
-
/**
|
|
20
|
-
* §5.5 direct endpoint with the `X-Syncular-Scopes` re-authorization
|
|
21
|
-
* header, plus the §5.4 `fetchUrl` capability (advertises accept bit 3).
|
|
22
|
-
* `fetchUrl` sends NO headers at all — the signed URL is the entire
|
|
23
|
-
* grant, and host auth must never leak to CDN/object hosts (§5.4).
|
|
24
|
-
* Resolution (which path a descriptor takes, expiry, no fall-through)
|
|
25
|
-
* lives in the client core, not here.
|
|
26
|
-
*/
|
|
27
19
|
export declare function httpSegmentDownloader(segmentsBaseUrl: string, options?: HttpTransportOptions): SegmentDownloader;
|
|
28
20
|
/**
|
|
29
21
|
* §5.9.3/§5.9.5 blob transport: host-authenticated `PUT`/`GET
|
package/dist/http.js
CHANGED
|
@@ -97,6 +97,39 @@ export function webSocketRemoteOperationConnector(realtimeUrl) {
|
|
|
97
97
|
* Resolution (which path a descriptor takes, expiry, no fall-through)
|
|
98
98
|
* lives in the client core, not here.
|
|
99
99
|
*/
|
|
100
|
+
async function readSegmentBody(response, onProgress) {
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let received = 0;
|
|
103
|
+
let reported = 0;
|
|
104
|
+
const reader = response.body?.getReader();
|
|
105
|
+
if (reader !== undefined) {
|
|
106
|
+
try {
|
|
107
|
+
for (;;) {
|
|
108
|
+
const { done, value } = await reader.read();
|
|
109
|
+
if (done)
|
|
110
|
+
break;
|
|
111
|
+
chunks.push(value);
|
|
112
|
+
received += value.byteLength;
|
|
113
|
+
if (received - reported >= 64 * 1024) {
|
|
114
|
+
onProgress?.(received);
|
|
115
|
+
reported = received;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
reader.releaseLock();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (received !== reported)
|
|
124
|
+
onProgress?.(received);
|
|
125
|
+
const bytes = new Uint8Array(received);
|
|
126
|
+
let offset = 0;
|
|
127
|
+
for (const chunk of chunks) {
|
|
128
|
+
bytes.set(chunk, offset);
|
|
129
|
+
offset += chunk.byteLength;
|
|
130
|
+
}
|
|
131
|
+
return bytes;
|
|
132
|
+
}
|
|
100
133
|
export function httpSegmentDownloader(segmentsBaseUrl, options) {
|
|
101
134
|
const doFetch = options?.fetch ?? fetch;
|
|
102
135
|
const direct = async (request) => {
|
|
@@ -108,15 +141,15 @@ export function httpSegmentDownloader(segmentsBaseUrl, options) {
|
|
|
108
141
|
});
|
|
109
142
|
if (!response.ok)
|
|
110
143
|
await throwHttpError(response);
|
|
111
|
-
return
|
|
144
|
+
return readSegmentBody(response, request.onProgress);
|
|
112
145
|
};
|
|
113
|
-
const fetchUrl = async (url) => {
|
|
146
|
+
const fetchUrl = async (url, onProgress) => {
|
|
114
147
|
// Deliberately headerless: the URL is the bearer grant (§5.4).
|
|
115
148
|
const response = await doFetch(url);
|
|
116
149
|
if (!response.ok) {
|
|
117
150
|
throw new ClientSyncError('sync.transport_failed', `signed-URL fetch failed with HTTP ${response.status} (§5.4: descriptor invalidated, re-pull to recover)`, true);
|
|
118
151
|
}
|
|
119
|
-
return
|
|
152
|
+
return readSegmentBody(response, onProgress);
|
|
120
153
|
};
|
|
121
154
|
return Object.assign(direct, { fetchUrl });
|
|
122
155
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/outbox.js
CHANGED
|
@@ -62,6 +62,9 @@ export function countOutbox(db) {
|
|
|
62
62
|
.count;
|
|
63
63
|
}
|
|
64
64
|
export function deleteOutboxCommit(db, clientCommitId) {
|
|
65
|
+
db.exec('DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?', [
|
|
66
|
+
clientCommitId,
|
|
67
|
+
]);
|
|
65
68
|
db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?', [clientCommitId]);
|
|
66
69
|
db.exec('DELETE FROM _syncular_outbox WHERE client_commit_id = ?', [
|
|
67
70
|
clientCommitId,
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
export type SyncProgressListener = (progress: SyncProgress) => void;
|
|
16
|
+
/** Shared by the direct client and event-forwarding handles. */
|
|
17
|
+
export declare class ProgressEmitter {
|
|
18
|
+
#private;
|
|
19
|
+
clear(): void;
|
|
20
|
+
snapshot(): SyncProgress | undefined;
|
|
21
|
+
on(listener: SyncProgressListener): () => void;
|
|
22
|
+
emit(progress: SyncProgress): void;
|
|
23
|
+
update(update: Partial<SyncProgress>): void;
|
|
24
|
+
}
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Shared by the direct client and event-forwarding handles. */
|
|
2
|
+
export class ProgressEmitter {
|
|
3
|
+
#snapshot;
|
|
4
|
+
#listeners = new Set();
|
|
5
|
+
clear() {
|
|
6
|
+
this.#listeners.clear();
|
|
7
|
+
this.#snapshot = undefined;
|
|
8
|
+
}
|
|
9
|
+
snapshot() {
|
|
10
|
+
return this.#snapshot;
|
|
11
|
+
}
|
|
12
|
+
on(listener) {
|
|
13
|
+
this.#listeners.add(listener);
|
|
14
|
+
if (this.#snapshot !== undefined) {
|
|
15
|
+
try {
|
|
16
|
+
listener(this.#snapshot);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* Observers cannot interrupt sync. */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return () => {
|
|
23
|
+
this.#listeners.delete(listener);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
emit(progress) {
|
|
27
|
+
const snapshot = Object.freeze({ ...progress });
|
|
28
|
+
this.#snapshot = snapshot;
|
|
29
|
+
for (const listener of [...this.#listeners]) {
|
|
30
|
+
try {
|
|
31
|
+
listener(snapshot);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* Observers cannot interrupt sync. */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
update(update) {
|
|
39
|
+
if (this.#snapshot?.state === 'running')
|
|
40
|
+
this.emit({ ...this.#snapshot, ...update });
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/schema.js
CHANGED
|
@@ -339,6 +339,9 @@ export function ensureLocalBookkeepingSchema(db) {
|
|
|
339
339
|
db.transaction(() => {
|
|
340
340
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
|
|
341
341
|
key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
342
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
|
|
343
|
+
commit_id TEXT NOT NULL, blob_id TEXT NOT NULL, PRIMARY KEY(commit_id, blob_id))`);
|
|
344
|
+
db.exec('CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)');
|
|
342
345
|
db.exec(`INSERT OR IGNORE INTO _syncular_meta(key, value) VALUES ('localRevision', '0')`);
|
|
343
346
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox(
|
|
344
347
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
package/dist/transport.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface SegmentFetchRequest {
|
|
|
22
22
|
readonly table: string;
|
|
23
23
|
/** Canonical JSON (§11.2) of the requested scope map (§5.5 header). */
|
|
24
24
|
readonly requestedScopesJson: string;
|
|
25
|
+
readonly onProgress?: (bytesReceived: number) => void;
|
|
25
26
|
}
|
|
26
27
|
/**
|
|
27
28
|
* Fetch segment bytes from the direct endpoint (§5.5). `fetchUrl`, when
|
|
@@ -33,7 +34,7 @@ export interface SegmentFetchRequest {
|
|
|
33
34
|
*/
|
|
34
35
|
export interface SegmentDownloader {
|
|
35
36
|
(request: SegmentFetchRequest): Promise<Uint8Array>;
|
|
36
|
-
readonly fetchUrl?: (url: string) => Promise<Uint8Array>;
|
|
37
|
+
readonly fetchUrl?: (url: string, onProgress?: (bytesReceived: number) => void) => Promise<Uint8Array>;
|
|
37
38
|
}
|
|
38
39
|
export interface RealtimeHandlers {
|
|
39
40
|
/** JSON control frame (§8.1): hello / sync / heartbeat / unknown. */
|
package/dist/wasm-database.js
CHANGED
|
@@ -122,6 +122,9 @@ function inWorkerContext() {
|
|
|
122
122
|
}
|
|
123
123
|
/** One registered VFS per pool directory, reused across opens. */
|
|
124
124
|
const sahPools = new Map();
|
|
125
|
+
// SAH pools share an I/O method table within the WASM runtime. Keep the
|
|
126
|
+
// installed callback alive for that runtime, including across database closes.
|
|
127
|
+
const sahIoMethods = new Map();
|
|
125
128
|
function opfsSahPoolError(error, directory) {
|
|
126
129
|
const detail = error instanceof Error ? error.message : String(error);
|
|
127
130
|
const normalized = detail.toLowerCase();
|
|
@@ -187,5 +190,42 @@ export async function openPersistentWasmDatabase(name, options) {
|
|
|
187
190
|
sahPools.set(directory, pool);
|
|
188
191
|
}
|
|
189
192
|
const util = await pool;
|
|
190
|
-
|
|
193
|
+
const db = new util.OpfsSAHPoolDb(`/${name}.db`);
|
|
194
|
+
const { capi, wasm } = sqlite3;
|
|
195
|
+
const stack = wasm.pstack.pointer;
|
|
196
|
+
try {
|
|
197
|
+
// Do this before the first SQL statement, when SQLite checks for a hot
|
|
198
|
+
// journal. The SAH-pool callback in sqlite-wasm 3.53 reports a reserved
|
|
199
|
+
// lock unconditionally, which suppresses crash rollback. The pool already
|
|
200
|
+
// excludes other owners; report no competing writer, as the other OPFS
|
|
201
|
+
// VFS does. Keep DELETE/FULL and the existing database/journal files.
|
|
202
|
+
const output = wasm.pstack.allocPtr();
|
|
203
|
+
if (db.pointer === undefined ||
|
|
204
|
+
capi.sqlite3_file_control(db.pointer, 'main', capi.SQLITE_FCNTL_FILE_POINTER, output) !== 0 ||
|
|
205
|
+
!wasm.peekPtr(output)) {
|
|
206
|
+
throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
|
|
207
|
+
}
|
|
208
|
+
const file = new capi.sqlite3_file(wasm.peekPtr(output));
|
|
209
|
+
const methodsPointer = file.$pMethods;
|
|
210
|
+
file.dispose(); // Borrowed struct view; SQLite still owns the file.
|
|
211
|
+
if (!methodsPointer) {
|
|
212
|
+
throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Could not configure OPFS crash recovery');
|
|
213
|
+
}
|
|
214
|
+
if (!sahIoMethods.has(methodsPointer)) {
|
|
215
|
+
const methods = new capi.sqlite3_io_methods(methodsPointer);
|
|
216
|
+
methods.installMethod('xCheckReservedLock', (_file, reserved) => {
|
|
217
|
+
wasm.poke32(reserved, 0);
|
|
218
|
+
return 0;
|
|
219
|
+
});
|
|
220
|
+
sahIoMethods.set(methodsPointer, methods);
|
|
221
|
+
}
|
|
222
|
+
return new WasmClientDatabase(db, sqlite3);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
db.close();
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
wasm.pstack.restore(stack);
|
|
230
|
+
}
|
|
191
231
|
}
|
package/dist/worker-entry.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { type WorkerInitConfig } from './worker-protocol.js';
|
|
|
4
4
|
export interface SyncWorkerOverrides {
|
|
5
5
|
/** Database factory indirection: tests inject bun:sqlite here. */
|
|
6
6
|
readonly openDatabase?: (config: WorkerInitConfig) => Promise<ClientDatabase> | ClientDatabase;
|
|
7
|
+
/** Retry deadline indirection for deterministic worker-host tests. */
|
|
8
|
+
readonly waitForStorageRetry?: (delayMs: number) => Promise<void>;
|
|
7
9
|
readonly createTransport?: (config: WorkerInitConfig) => SyncTransport;
|
|
8
10
|
readonly createSegments?: (config: WorkerInitConfig) => SegmentDownloader | undefined;
|
|
9
11
|
readonly createRealtime?: (config: WorkerInitConfig, clientId: string) => RealtimeConnector | undefined;
|
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 } });
|