@syncular/client 0.38.0 → 0.39.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/dist/client.js CHANGED
@@ -3443,6 +3443,50 @@ export class SyncClient {
3443
3443
  let section;
3444
3444
  let errorFrame;
3445
3445
  let deltaCursor = -1;
3446
+ // §5.4/§5.5: issue every segment download of this response before the
3447
+ // apply loop reaches the first one, so a paged bootstrap overlaps its page
3448
+ // fetches instead of paying one blocking round trip per page. Eligibility
3449
+ // mirrors the loop: a descriptor outside an applied section, inside a
3450
+ // skipped delta section, or from an unconfigured downloader is never
3451
+ // fetched, and a prefetched failure is only observed where the loop awaits
3452
+ // it. A promise left over because an earlier frame ended apply is handled
3453
+ // at creation, never unhandled.
3454
+ const prefetched = new Map();
3455
+ let reporting;
3456
+ {
3457
+ let openSub;
3458
+ for (const frame of message.frames) {
3459
+ if (frame.type === 'SUB_START') {
3460
+ const sub = subsById.get(frame.id);
3461
+ openSub =
3462
+ sub === undefined ||
3463
+ (mode === 'delta' &&
3464
+ (sub.status !== 'active' || sub.bootstrapState !== undefined))
3465
+ ? undefined
3466
+ : sub;
3467
+ }
3468
+ else if (frame.type === 'SUB_END') {
3469
+ openSub = undefined;
3470
+ }
3471
+ else if (frame.type === 'ERROR') {
3472
+ break; // the round fails at this frame: later refs are not fetched
3473
+ }
3474
+ else if (frame.type === 'SEGMENT_REF' &&
3475
+ openSub !== undefined &&
3476
+ this.#config.segments !== undefined &&
3477
+ !(frame.mediaType === 'sqlite' &&
3478
+ (this.#acceptMask() & ACCEPT_SQLITE) === 0)) {
3479
+ const pending = this.#downloadSegment(frame, openSub, (bytes) => {
3480
+ // Only the descriptor the loop is applying reports bytes; a
3481
+ // sibling still in flight would overwrite its progress.
3482
+ if (reporting === frame)
3483
+ this.#progress.update({ bytesReceived: bytes });
3484
+ });
3485
+ pending.catch(() => { });
3486
+ prefetched.set(frame, pending);
3487
+ }
3488
+ }
3489
+ }
3446
3490
  // Each durable observer transaction emits its own revisioned batch.
3447
3491
  // Async decrypt/download work happens outside SQLite transactions.
3448
3492
  this.#beginDiagnosticsDeferral();
@@ -3533,9 +3577,27 @@ export class SyncClient {
3533
3577
  (this.#acceptMask() & ACCEPT_SQLITE) === 0) {
3534
3578
  throw invalidRequest('SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)');
3535
3579
  }
3536
- const bytes = frame.type === 'SEGMENT_REF'
3537
- ? await this.#downloadSegment(frame, section.sub)
3538
- : frame.payload;
3580
+ let bytes;
3581
+ if (frame.type === 'SEGMENT_REF') {
3582
+ this.#progress.update({
3583
+ phase: 'download',
3584
+ subscriptionId: section.sub.id,
3585
+ table: frame.table,
3586
+ segmentId: frame.segmentId,
3587
+ bytesReceived: 0,
3588
+ bytesTotal: frame.byteLength,
3589
+ rowsProcessed: 0,
3590
+ rowsTotal: frame.rowCount,
3591
+ });
3592
+ const prefetch = prefetched.get(frame);
3593
+ if (prefetch !== undefined)
3594
+ reporting = frame;
3595
+ bytes = await (prefetch ??
3596
+ this.#downloadSegment(frame, section.sub, (bytesReceived) => this.#progress.update({ bytesReceived })));
3597
+ }
3598
+ else {
3599
+ bytes = frame.payload;
3600
+ }
3539
3601
  if (frame.type === 'SEGMENT_REF')
3540
3602
  this.#progress.update({ phase: 'import' });
3541
3603
  if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
@@ -3896,24 +3958,11 @@ export class SyncClient {
3896
3958
  throw error;
3897
3959
  }
3898
3960
  }
3899
- async #downloadSegment(frame, sub) {
3961
+ async #downloadSegment(frame, sub, onProgress) {
3900
3962
  const downloader = this.#config.segments;
3901
3963
  if (downloader === undefined) {
3902
3964
  throw invalidRequest('received SEGMENT_REF but no segment downloader is configured');
3903
3965
  }
3904
- this.#progress.update({
3905
- phase: 'download',
3906
- subscriptionId: sub.id,
3907
- table: frame.table,
3908
- segmentId: frame.segmentId,
3909
- bytesReceived: 0,
3910
- bytesTotal: frame.byteLength,
3911
- rowsProcessed: 0,
3912
- rowsTotal: frame.rowCount,
3913
- });
3914
- const onProgress = (bytesReceived) => {
3915
- this.#progress.update({ bytesReceived });
3916
- };
3917
3966
  let bytes;
3918
3967
  if (frame.url !== undefined) {
3919
3968
  // §5.4: a url-carrying descriptor MUST be fetched from that URL —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.38.0",
3
+ "version": "0.39.1",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -97,10 +97,10 @@
97
97
  },
98
98
  "dependencies": {
99
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
100
- "@syncular/core": "0.38.0"
100
+ "@syncular/core": "0.39.1"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.38.0",
103
+ "@syncular/server": "0.39.1",
104
104
  "playwright": "1.63.0",
105
105
  "@electric-sql/pglite": "^0.5.4"
106
106
  }
package/src/client.ts CHANGED
@@ -4832,6 +4832,52 @@ export class SyncClient {
4832
4832
  let errorFrame: ClientSyncError | undefined;
4833
4833
  let deltaCursor = -1;
4834
4834
 
4835
+ // §5.4/§5.5: issue every segment download of this response before the
4836
+ // apply loop reaches the first one, so a paged bootstrap overlaps its page
4837
+ // fetches instead of paying one blocking round trip per page. Eligibility
4838
+ // mirrors the loop: a descriptor outside an applied section, inside a
4839
+ // skipped delta section, or from an unconfigured downloader is never
4840
+ // fetched, and a prefetched failure is only observed where the loop awaits
4841
+ // it. A promise left over because an earlier frame ended apply is handled
4842
+ // at creation, never unhandled.
4843
+ const prefetched = new Map<SegmentRefFrame, Promise<Uint8Array>>();
4844
+ let reporting: SegmentRefFrame | undefined;
4845
+ {
4846
+ let openSub: SubscriptionRecord | undefined;
4847
+ for (const frame of message.frames) {
4848
+ if (frame.type === 'SUB_START') {
4849
+ const sub = subsById.get(frame.id);
4850
+ openSub =
4851
+ sub === undefined ||
4852
+ (mode === 'delta' &&
4853
+ (sub.status !== 'active' || sub.bootstrapState !== undefined))
4854
+ ? undefined
4855
+ : sub;
4856
+ } else if (frame.type === 'SUB_END') {
4857
+ openSub = undefined;
4858
+ } else if (frame.type === 'ERROR') {
4859
+ break; // the round fails at this frame: later refs are not fetched
4860
+ } else if (
4861
+ frame.type === 'SEGMENT_REF' &&
4862
+ openSub !== undefined &&
4863
+ this.#config.segments !== undefined &&
4864
+ !(
4865
+ frame.mediaType === 'sqlite' &&
4866
+ (this.#acceptMask() & ACCEPT_SQLITE) === 0
4867
+ )
4868
+ ) {
4869
+ const pending = this.#downloadSegment(frame, openSub, (bytes) => {
4870
+ // Only the descriptor the loop is applying reports bytes; a
4871
+ // sibling still in flight would overwrite its progress.
4872
+ if (reporting === frame)
4873
+ this.#progress.update({ bytesReceived: bytes });
4874
+ });
4875
+ pending.catch(() => {});
4876
+ prefetched.set(frame, pending);
4877
+ }
4878
+ }
4879
+ }
4880
+
4835
4881
  // Each durable observer transaction emits its own revisioned batch.
4836
4882
  // Async decrypt/download work happens outside SQLite transactions.
4837
4883
  this.#beginDiagnosticsDeferral();
@@ -4949,10 +4995,27 @@ export class SyncClient {
4949
4995
  'SEGMENT_REF mediaType sqlite was not advertised in accept (§4.2)',
4950
4996
  );
4951
4997
  }
4952
- const bytes =
4953
- frame.type === 'SEGMENT_REF'
4954
- ? await this.#downloadSegment(frame, section.sub)
4955
- : frame.payload;
4998
+ let bytes: Uint8Array;
4999
+ if (frame.type === 'SEGMENT_REF') {
5000
+ this.#progress.update({
5001
+ phase: 'download',
5002
+ subscriptionId: section.sub.id,
5003
+ table: frame.table,
5004
+ segmentId: frame.segmentId,
5005
+ bytesReceived: 0,
5006
+ bytesTotal: frame.byteLength,
5007
+ rowsProcessed: 0,
5008
+ rowsTotal: frame.rowCount,
5009
+ });
5010
+ const prefetch = prefetched.get(frame);
5011
+ if (prefetch !== undefined) reporting = frame;
5012
+ bytes = await (prefetch ??
5013
+ this.#downloadSegment(frame, section.sub, (bytesReceived) =>
5014
+ this.#progress.update({ bytesReceived }),
5015
+ ));
5016
+ } else {
5017
+ bytes = frame.payload;
5018
+ }
4956
5019
  if (frame.type === 'SEGMENT_REF')
4957
5020
  this.#progress.update({ phase: 'import' });
4958
5021
  if (frame.type === 'SEGMENT_REF' && frame.mediaType === 'sqlite') {
@@ -5458,6 +5521,7 @@ export class SyncClient {
5458
5521
  async #downloadSegment(
5459
5522
  frame: SegmentRefFrame,
5460
5523
  sub: SubscriptionRecord,
5524
+ onProgress: (bytesReceived: number) => void,
5461
5525
  ): Promise<Uint8Array> {
5462
5526
  const downloader = this.#config.segments;
5463
5527
  if (downloader === undefined) {
@@ -5465,19 +5529,6 @@ export class SyncClient {
5465
5529
  'received SEGMENT_REF but no segment downloader is configured',
5466
5530
  );
5467
5531
  }
5468
- this.#progress.update({
5469
- phase: 'download',
5470
- subscriptionId: sub.id,
5471
- table: frame.table,
5472
- segmentId: frame.segmentId,
5473
- bytesReceived: 0,
5474
- bytesTotal: frame.byteLength,
5475
- rowsProcessed: 0,
5476
- rowsTotal: frame.rowCount,
5477
- });
5478
- const onProgress = (bytesReceived: number): void => {
5479
- this.#progress.update({ bytesReceived });
5480
- };
5481
5532
  let bytes: Uint8Array;
5482
5533
  if (frame.url !== undefined) {
5483
5534
  // §5.4: a url-carrying descriptor MUST be fetched from that URL —