@syncular/client 0.19.0 → 0.20.1

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