@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/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, reconcileBlobRefcounts, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
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.#db.transaction(() => {
195
- for (const trigger of ['insert', 'update', 'delete', 'cache'])
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, this.#now(), options?.mediaType);
898
- recordPendingUpload(this.#db, blobId, this.#now(), options?.mediaType);
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, this.#now());
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
- // The referencing row can arrive before its body. Pin the new cache entry
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.#reconcileBlobs(true);
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.#reconcileBlobs(true);
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
- reconcileBlobRefcounts(this.#db, this.#schema, {
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 new Uint8Array(await response.arrayBuffer());
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 new Uint8Array(await response.arrayBuffer());
152
+ return readSegmentBody(response, onProgress);
120
153
  };
121
154
  return Object.assign(direct, { fetchUrl });
122
155
  }
package/dist/index.d.ts CHANGED
@@ -40,3 +40,4 @@ export * from './transport.js';
40
40
  export * from './window.js';
41
41
  export * from './worker-host.js';
42
42
  export * from './worker-protocol.js';
43
+ export { ProgressEmitter, type SyncProgress, type SyncProgressListener, } from './progress.js';
package/dist/index.js CHANGED
@@ -40,3 +40,4 @@ export * from './transport.js';
40
40
  export * from './window.js';
41
41
  export * from './worker-host.js';
42
42
  export * from './worker-protocol.js';
43
+ export { ProgressEmitter, } from './progress.js';
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
+ }
@@ -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,
@@ -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. */
@@ -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
- return new WasmClientDatabase(new util.OpfsSAHPoolDb(`/${name}.db`), sqlite3);
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
  }
@@ -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;
@@ -16,7 +16,7 @@
16
16
  import { SyncClient } from './client.js';
17
17
  import { withClientDiagnosticsHost, } from './diagnostics.js';
18
18
  import { encryptionConfigFromKeyring } from './encryption.js';
19
- import { ClientSyncError } from './errors.js';
19
+ import { ClientSyncError, STORAGE_BUSY_CODE } from './errors.js';
20
20
  import { httpBlobTransport, httpSegmentDownloader, httpSyncTransport, webSocketRealtimeConnector, } from './http.js';
21
21
  import { openPersistentWasmDatabase } from './wasm-database.js';
22
22
  import { WORKER_FAILED_CODE, } from './worker-protocol.js';
@@ -170,9 +170,9 @@ export function startSyncWorker(overrides = {}) {
170
170
  return Object.assign(gateOffline(inner), {
171
171
  ...(fetchUrl !== undefined
172
172
  ? {
173
- fetchUrl: (url) => {
173
+ fetchUrl: (url, onProgress) => {
174
174
  assertOnline();
175
- return fetchUrl(url);
175
+ return fetchUrl(url, onProgress);
176
176
  },
177
177
  }
178
178
  : {}),
@@ -183,10 +183,33 @@ export function startSyncWorker(overrides = {}) {
183
183
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
184
184
  }
185
185
  autoSync = config.autoSync ?? true;
186
- database =
187
- overrides.openDatabase !== undefined
188
- ? await overrides.openDatabase(config)
189
- : await defaultOpenDatabase(config);
186
+ for (let attempt = 0;; attempt++) {
187
+ try {
188
+ database =
189
+ overrides.openDatabase !== undefined
190
+ ? await overrides.openDatabase(config)
191
+ : await defaultOpenDatabase(config);
192
+ break;
193
+ }
194
+ catch (error) {
195
+ if (config.database.mode !== 'persistent' ||
196
+ !(error instanceof ClientSyncError) ||
197
+ error.code !== STORAGE_BUSY_CODE ||
198
+ !error.retryable ||
199
+ attempt === 6) {
200
+ throw error;
201
+ }
202
+ // Retain the leader lease while the previous worker's OPFS handles
203
+ // drain. Only opening is retried; core initialization has not started.
204
+ const delayMs = Math.min(50 * 2 ** attempt, 1000);
205
+ if (overrides.waitForStorageRetry !== undefined) {
206
+ await overrides.waitForStorageRetry(delayMs);
207
+ }
208
+ else {
209
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
210
+ }
211
+ }
212
+ }
190
213
  const transport = gateOffline(overrides.createTransport !== undefined
191
214
  ? overrides.createTransport(config)
192
215
  : httpSyncTransport(config.endpoints.syncUrl));
@@ -245,6 +268,10 @@ export function startSyncWorker(overrides = {}) {
245
268
  post({ t: 'event', event: { kind: 'presence', scopeKey } });
246
269
  },
247
270
  });
271
+ started.onProgress((progress) => {
272
+ if (!closed)
273
+ post({ t: 'event', event: { kind: 'progress', progress } });
274
+ });
248
275
  started.onChange((batch) => {
249
276
  if (!closed)
250
277
  post({ t: 'event', event: { kind: 'change', batch } });