@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/README.md
CHANGED
|
@@ -457,10 +457,17 @@ still happen during rapid hot-module replacement, or in an embedded/test host
|
|
|
457
457
|
that shares OPFS data without sharing the same Web Locks and BroadcastChannel
|
|
458
458
|
coordination domain.
|
|
459
459
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
460
|
+
Persistent worker startup retries retryable `client.storage_busy` failures
|
|
461
|
+
while retaining the leader lease. It opens the same directory at most seven
|
|
462
|
+
times, waiting 50, 100, 200, 400, 800, and 1000 ms between attempts. These delays
|
|
463
|
+
total 2550 ms; browser scheduling and storage operations can extend elapsed time.
|
|
464
|
+
Other errors fail immediately. Direct `openPersistentWasmDatabase` calls remain
|
|
465
|
+
single attempts.
|
|
466
|
+
|
|
467
|
+
If ownership remains unavailable, handle creation rejects with `ClientSyncError`,
|
|
468
|
+
code `client.storage_busy`, and `retryable === true`, then releases the worker
|
|
469
|
+
and leader lease. Close the competing instance or let it finish shutting down,
|
|
470
|
+
then create the handle again. **Do not delete, rename, or silently replace the database with an
|
|
464
471
|
in-memory one**; the local database and pending outbox may be perfectly healthy.
|
|
465
472
|
Missing or obsolete OPFS APIs instead use the non-retryable
|
|
466
473
|
`client.storage_unavailable` code.
|
|
@@ -584,8 +591,96 @@ they own their buffer.
|
|
|
584
591
|
|
|
585
592
|
Tests drive the real worker entry in a bun `Worker` with bun:sqlite
|
|
586
593
|
injected through the bootstrap's database-factory override
|
|
587
|
-
(`test/worker-rpc.test.ts`)
|
|
588
|
-
|
|
594
|
+
(`test/worker-rpc.test.ts`). The separate Chromium gate exercises actual
|
|
595
|
+
sqlite-wasm, OPFS SAH pools and FTS through the production worker and HTTP paths:
|
|
596
|
+
|
|
597
|
+
```sh
|
|
598
|
+
cd packages/web-client
|
|
599
|
+
bunx --no-install playwright install chromium
|
|
600
|
+
cd ../..
|
|
601
|
+
bun run test:browser
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
`test/opfs-bootstrap.browser.test.ts` covers five interruption boundaries:
|
|
605
|
+
|
|
606
|
+
- Partial segment download: hold the asynchronous transfer, then terminate the worker.
|
|
607
|
+
- Before image import: inject an exception before applying the verified image.
|
|
608
|
+
- Mid-import: terminate the worker after an actual OPFS database page write during
|
|
609
|
+
`INSERT SELECT`. A commit notification must not precede termination.
|
|
610
|
+
- After import: inject an exception after the transaction commits, before `SUB_END`.
|
|
611
|
+
- After checkpoint: complete the round before terminating the worker.
|
|
612
|
+
|
|
613
|
+
Each case reloads the page using the same replica identity and requires successful
|
|
614
|
+
startup in the same browser session. It checks SQLite and FTS integrity, search
|
|
615
|
+
results, exact rows, persisted client identity, and subscription checkpoints.
|
|
616
|
+
Incomplete sections must download again; completed sections must not. A held
|
|
617
|
+
synchronous test request after a physical page write keeps the mid-import crash
|
|
618
|
+
boundary stable until worker termination. Two additional ownership cases verify
|
|
619
|
+
recovery when a competing owner closes during retry and bounded failure while
|
|
620
|
+
that owner remains live, including pending outbox preservation.
|
|
621
|
+
|
|
622
|
+
Each case owns a temporary browser profile and a loopback server. A small SQLite
|
|
623
|
+
page cache forces dirty pages to disk before COMMIT. Tests use no timing sleeps,
|
|
624
|
+
COOP/COEP requirement, existing profiles or application replicas. Failed profiles
|
|
625
|
+
and `recovery-test.json` remain at the path printed by the test; CI retains them
|
|
626
|
+
as an artifact. Successful profiles are removed. `bun run check` keeps its
|
|
627
|
+
Bun-only test lane; CI runs the browser gate separately.
|
|
628
|
+
|
|
629
|
+
`openPersistentWasmDatabase` corrects the SAH-pool VFS's reserved-lock callback
|
|
630
|
+
before the first SQL statement. sqlite-wasm 3.53 reports a reserved lock
|
|
631
|
+
unconditionally, suppressing SQLite's hot-journal recovery after an interrupted
|
|
632
|
+
write. The pool already excludes other owners. Syncular reports no competing
|
|
633
|
+
writer through SQLite's I/O method binding, matching the other OPFS VFS, and
|
|
634
|
+
retains that callback across database closes and pool opens. The correction ships
|
|
635
|
+
in the client package and preserves the existing database format, DELETE journal
|
|
636
|
+
mode and FULL synchronous mode.
|
|
637
|
+
|
|
638
|
+
On 2026-09-12, Chromium 153 with sqlite-wasm 3.53.0 failed the mid-import case
|
|
639
|
+
without this correction and passed all five cases with it. The fixture serves
|
|
640
|
+
the unmodified dependency. Bounded worker startup retries handle transient
|
|
641
|
+
`client.storage_busy` while the browser releases old handles. This correction enables
|
|
642
|
+
rollback from an intact hot journal; it does not repair existing corruption
|
|
643
|
+
whose recovery journal has already been lost or overwritten.
|
|
644
|
+
|
|
645
|
+
## Live sync progress
|
|
646
|
+
|
|
647
|
+
`onProgress` observes work during a sync round. It immediately delivers the latest
|
|
648
|
+
snapshot when one exists and returns an unsubscribe function. `progressSnapshot()`
|
|
649
|
+
reads the cached snapshot synchronously, including on worker handles. Worker
|
|
650
|
+
leaders and followers forward events while download and import are running.
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
const stop = client.onProgress((progress) => {
|
|
654
|
+
if (progress.phase === 'download') {
|
|
655
|
+
renderDownload(progress.bytesReceived, progress.bytesTotal);
|
|
656
|
+
} else if (progress.phase === 'import') {
|
|
657
|
+
renderImport(progress.rowsProcessed, progress.rowsTotal);
|
|
658
|
+
}
|
|
659
|
+
if (progress.state === 'failed') showError(progress.errorCode);
|
|
660
|
+
});
|
|
661
|
+
// Stop listening when the view is disposed. Sync continues.
|
|
662
|
+
stop();
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
Each snapshot identifies the round with `attempt`, and the current payload with
|
|
666
|
+
`subscriptionId`, `table`, and optional `segmentId`. Counters reset for each
|
|
667
|
+
payload; `syncUntilIdle` starts a new attempt for each round. `phase` is `request`,
|
|
668
|
+
`download`, or `import`; `state` is `running`, `complete`, or `failed`. Unknown
|
|
669
|
+
totals remain absent. A new attempt clears prior errors and counters.
|
|
670
|
+
|
|
671
|
+
HTTP downloads report decoded body bytes at 64 KiB intervals or larger network
|
|
672
|
+
chunks. Custom segment downloaders receive `request.onProgress`; signed-URL
|
|
673
|
+
implementations receive an optional second callback argument. Call it with the
|
|
674
|
+
cumulative received byte count. Buffered custom transports report a final count
|
|
675
|
+
through the core, without intermediate download updates.
|
|
676
|
+
|
|
677
|
+
Import reports every 1,024 rows and its final count. `rowsProcessed` includes work
|
|
678
|
+
inside the current transaction; a failed import can roll those rows back. SQLite
|
|
679
|
+
images retain one atomic transaction. `complete` follows checkpoint persistence
|
|
680
|
+
and optimistic-state reconciliation for the round. More bootstrap pages can
|
|
681
|
+
remain; use subscription coverage to determine whether a query is complete.
|
|
682
|
+
Listener exceptions do not fail sync. Listeners should hand UI work off and avoid
|
|
683
|
+
reentering database commands during an import.
|
|
589
684
|
|
|
590
685
|
## Snapshot API migration
|
|
591
686
|
|
package/dist/apply.d.ts
CHANGED
|
@@ -68,6 +68,7 @@ export declare function applySqliteSegment(db: ClientDatabase, schema: CompiledC
|
|
|
68
68
|
readonly clearFirst: boolean;
|
|
69
69
|
readonly effective: ScopeMap;
|
|
70
70
|
readonly transaction?: ApplyTransaction;
|
|
71
|
+
readonly onProgress?: (rowsProcessed: number) => void;
|
|
71
72
|
}): number;
|
|
72
73
|
/**
|
|
73
74
|
* Apply a decoded rows segment: each block in one local transaction
|
|
@@ -81,4 +82,5 @@ export declare function applyRowsSegment(db: ClientDatabase, schema: CompiledCli
|
|
|
81
82
|
readonly clearFirst: boolean;
|
|
82
83
|
readonly effective: ScopeMap;
|
|
83
84
|
readonly transaction?: ApplyTransaction;
|
|
85
|
+
readonly onProgress?: (rowsProcessed: number) => void;
|
|
84
86
|
}, encryption?: EncryptionConfig): Promise<number>;
|
package/dist/apply.js
CHANGED
|
@@ -255,17 +255,38 @@ export function applySqliteSegment(db, schema, table, bytes, descriptor, options
|
|
|
255
255
|
if (options.clearFirst) {
|
|
256
256
|
deleteScopedRows(db, table, options.effective);
|
|
257
257
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
258
|
+
const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
|
|
259
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
260
|
+
let after;
|
|
261
|
+
let applied = 0;
|
|
262
|
+
for (;;) {
|
|
263
|
+
const boundary = db.query(`SELECT ${primaryKey} AS boundary FROM ${source}
|
|
264
|
+
${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
|
|
265
|
+
ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`, after === undefined ? [] : [after])[0]?.boundary;
|
|
266
|
+
const predicates = [];
|
|
267
|
+
const params = [];
|
|
268
|
+
if (after !== undefined) {
|
|
269
|
+
predicates.push(`${primaryKey} > ?`);
|
|
270
|
+
params.push(after);
|
|
271
|
+
}
|
|
272
|
+
if (boundary !== undefined) {
|
|
273
|
+
predicates.push(`${primaryKey} <= ?`);
|
|
274
|
+
params.push(boundary);
|
|
275
|
+
}
|
|
276
|
+
db.exec(`INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
|
|
277
|
+
SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
|
|
278
|
+
FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
|
|
279
|
+
ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`, params);
|
|
280
|
+
applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
|
|
281
|
+
if (boundary === undefined)
|
|
282
|
+
break;
|
|
283
|
+
options.onProgress?.(applied);
|
|
284
|
+
after = boundary;
|
|
285
|
+
}
|
|
266
286
|
if (applied !== descriptor.rowCount) {
|
|
267
287
|
imageInvalid(`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`);
|
|
268
288
|
}
|
|
289
|
+
options.onProgress?.(applied);
|
|
269
290
|
return applied;
|
|
270
291
|
});
|
|
271
292
|
});
|
|
@@ -308,8 +329,11 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
|
|
|
308
329
|
for (const row of rows) {
|
|
309
330
|
upsertLocalRow(db, table, row.values, row.serverVersion);
|
|
310
331
|
applied += 1;
|
|
332
|
+
if (applied % 1024 === 0)
|
|
333
|
+
options.onProgress?.(applied);
|
|
311
334
|
}
|
|
312
335
|
});
|
|
313
336
|
}
|
|
337
|
+
options.onProgress?.(applied);
|
|
314
338
|
return applied;
|
|
315
339
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SyncProgress, type SyncProgressListener } from './progress.js';
|
|
1
2
|
/**
|
|
2
3
|
* SyncClient implements the client side of SPEC.md §§3–8.
|
|
3
4
|
*
|
|
@@ -294,6 +295,8 @@ export declare class SyncClient {
|
|
|
294
295
|
*/
|
|
295
296
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
296
297
|
/** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
|
|
298
|
+
onProgress(listener: SyncProgressListener): () => void;
|
|
299
|
+
progressSnapshot(): SyncProgress | undefined;
|
|
297
300
|
onChange(listener: ClientChangeListener): () => void;
|
|
298
301
|
/** Subscribe to host wake signals raised by startup and realtime. */
|
|
299
302
|
onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
|
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
|
*
|
|
@@ -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
|
|
@@ -359,6 +362,7 @@ export class SyncClient {
|
|
|
359
362
|
await this.#lease?.release();
|
|
360
363
|
this.#lease = undefined;
|
|
361
364
|
this.#started = false;
|
|
365
|
+
this.#progress.clear();
|
|
362
366
|
this.#syncNeededListeners.clear();
|
|
363
367
|
this.#syncIntentListeners.clear();
|
|
364
368
|
}
|
|
@@ -537,6 +541,12 @@ export class SyncClient {
|
|
|
537
541
|
return this.#invalidation.on(listener);
|
|
538
542
|
}
|
|
539
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
|
+
}
|
|
540
550
|
onChange(listener) {
|
|
541
551
|
return this.#changes.on(listener);
|
|
542
552
|
}
|
|
@@ -1837,6 +1847,12 @@ export class SyncClient {
|
|
|
1837
1847
|
}, (error) => {
|
|
1838
1848
|
const completedAtMs = this.#now();
|
|
1839
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
|
+
});
|
|
1840
1856
|
this.#lastRound = {
|
|
1841
1857
|
status: 'failed',
|
|
1842
1858
|
startedAtMs,
|
|
@@ -1883,7 +1899,15 @@ export class SyncClient {
|
|
|
1883
1899
|
: 'client.unknown_failure';
|
|
1884
1900
|
}
|
|
1885
1901
|
async #runSync() {
|
|
1902
|
+
this.#progress.emit({
|
|
1903
|
+
attempt: ++this.#progressAttempt,
|
|
1904
|
+
state: 'running',
|
|
1905
|
+
phase: 'request',
|
|
1906
|
+
bytesReceived: 0,
|
|
1907
|
+
rowsProcessed: 0,
|
|
1908
|
+
});
|
|
1886
1909
|
if (this.#schemaFloor !== undefined) {
|
|
1910
|
+
this.#progress.update({ state: 'complete' });
|
|
1887
1911
|
return {
|
|
1888
1912
|
...emptySummary(0),
|
|
1889
1913
|
bootstrapping: [],
|
|
@@ -1958,6 +1982,9 @@ export class SyncClient {
|
|
|
1958
1982
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
1959
1983
|
// a shrunk window unit — retry any deferred evictions now.
|
|
1960
1984
|
this.#drainPendingEvictions();
|
|
1985
|
+
this.#progress.update(summary.failed.length > 0
|
|
1986
|
+
? { state: 'failed', errorCode: 'sync.scope_revoked' }
|
|
1987
|
+
: { state: 'complete' });
|
|
1961
1988
|
this.#retryDelayMs = 250;
|
|
1962
1989
|
if (deferred > 0) {
|
|
1963
1990
|
// §6.1 splitBatch remainder: more queued commits than this request
|
|
@@ -2430,8 +2457,19 @@ export class SyncClient {
|
|
|
2430
2457
|
break;
|
|
2431
2458
|
}
|
|
2432
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
|
+
});
|
|
2433
2470
|
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
2434
2471
|
clearFirst,
|
|
2472
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
2435
2473
|
effective,
|
|
2436
2474
|
transaction: (fn) => this.#applyBatch((batch) => {
|
|
2437
2475
|
if (segment.blocks.some((block) => block.length > 0) ||
|
|
@@ -2457,6 +2495,7 @@ export class SyncClient {
|
|
|
2457
2495
|
throw new ClientSyncError('sync.invalid_request', 'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
|
|
2458
2496
|
}
|
|
2459
2497
|
const bytes = await this.#downloadSegment(frame, section.sub);
|
|
2498
|
+
this.#progress.update({ phase: 'import' });
|
|
2460
2499
|
if (frame.mediaType === 'sqlite') {
|
|
2461
2500
|
// §5.3: images are whole-table — a paged descriptor is
|
|
2462
2501
|
// invalid, and the image is always its table's first page.
|
|
@@ -2471,6 +2510,7 @@ export class SyncClient {
|
|
|
2471
2510
|
scopeDigest: frame.scopeDigest,
|
|
2472
2511
|
}, {
|
|
2473
2512
|
clearFirst,
|
|
2513
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
2474
2514
|
effective,
|
|
2475
2515
|
transaction: (fn) => this.#applyBatch((batch) => {
|
|
2476
2516
|
if (frame.rowCount > 0 ||
|
|
@@ -2486,6 +2526,7 @@ export class SyncClient {
|
|
|
2486
2526
|
const segment = decodeRowsSegment(bytes);
|
|
2487
2527
|
await this.#applySegmentOrFail(section, summary, (table, clearFirst, effective) => applyRowsSegment(this.#db, this.#schema, table, segment, {
|
|
2488
2528
|
clearFirst,
|
|
2529
|
+
onProgress: (rowsProcessed) => this.#progress.update({ rowsProcessed }),
|
|
2489
2530
|
effective,
|
|
2490
2531
|
transaction: (fn) => this.#applyBatch((batch) => {
|
|
2491
2532
|
if (segment.blocks.some((block) => block.length > 0) ||
|
|
@@ -2790,6 +2831,19 @@ export class SyncClient {
|
|
|
2790
2831
|
if (downloader === undefined) {
|
|
2791
2832
|
throw new ClientSyncError('sync.invalid_request', 'received SEGMENT_REF but no segment downloader is configured');
|
|
2792
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
|
+
};
|
|
2793
2847
|
let bytes;
|
|
2794
2848
|
if (frame.url !== undefined) {
|
|
2795
2849
|
// §5.4: a url-carrying descriptor MUST be fetched from that URL —
|
|
@@ -2806,18 +2860,20 @@ export class SyncClient {
|
|
|
2806
2860
|
// §5.4: MUST NOT start a fetch at/past expiry.
|
|
2807
2861
|
throw new ClientSyncError('sync.segment_expired', `signed URL for segment ${frame.segmentId} expired before fetch — re-pull mints fresh descriptors (§5.4)`, true);
|
|
2808
2862
|
}
|
|
2809
|
-
bytes = await fetchUrl(frame.url);
|
|
2863
|
+
bytes = await fetchUrl(frame.url, onProgress);
|
|
2810
2864
|
}
|
|
2811
2865
|
else {
|
|
2812
2866
|
bytes = await downloader({
|
|
2813
2867
|
segmentId: frame.segmentId,
|
|
2814
2868
|
table: frame.table,
|
|
2815
2869
|
requestedScopesJson: canonicalScopeJson(sub.scopes),
|
|
2870
|
+
onProgress,
|
|
2816
2871
|
});
|
|
2817
2872
|
}
|
|
2818
2873
|
// §5.1: verify the content address before applying; on mismatch the
|
|
2819
2874
|
// segment is discarded and the cursor/resume token stay unpersisted,
|
|
2820
2875
|
// so the next pull re-delivers.
|
|
2876
|
+
onProgress(bytes.byteLength);
|
|
2821
2877
|
const hash = await sha256Hex(bytes);
|
|
2822
2878
|
if (`sha256:${hash}` !== frame.segmentId) {
|
|
2823
2879
|
throw new ClientSyncError('sync.invalid_request', `segment ${frame.segmentId} failed content-address verification (§5.1)`, true);
|
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
|
@@ -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/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;
|