@syncular/client 0.40.0 → 0.41.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
@@ -14,7 +14,7 @@ import { ProgressEmitter, } from './progress.js';
14
14
  * ownership behind `LeaderLock`. One combined push+pull request per
15
15
  * `sync()` round (§7.2); local reads go straight to the database.
16
16
  */
17
- import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodeRow, encodeSparseRow, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
17
+ import { canonicalScopeJson, decodeMessage, DecodeError, decodeRow, decodeRowsSegment, encodeMessage, encodeRow, encodeSparseRow, encodePresencePublish, isRoundFailureCode, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
18
18
  import { EncryptError } from '@syncular/core';
19
19
  import { EncodeError } from '@syncular/core';
20
20
  import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, localScopePredicate, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
@@ -199,6 +199,12 @@ export class SyncClient {
199
199
  /** Retry policy belongs to the operation that classified the failure. */
200
200
  #retryDelayMs = 250;
201
201
  #roundRetryDelayMs;
202
+ /**
203
+ * §1.5: the code of the last round that ended without a complete, decodable
204
+ * response. It rides `ROUND_FAILURE` on every request until a round
205
+ * completes; a newer failure replaces it.
206
+ */
207
+ #roundFailureCode;
202
208
  #hasBlobs;
203
209
  /** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
204
210
  #presence = new Map();
@@ -2835,8 +2841,14 @@ export class SyncClient {
2835
2841
  // budget, and the reset epoch must be the one the response apply checks.
2836
2842
  const resetEpoch = this.#localResetEpoch;
2837
2843
  const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
2844
+ // §1.5: a kept round-failure code rides every request until a round
2845
+ // completes, immediately after REQ_HEADER.
2846
+ const report = this.#roundFailureCode;
2838
2847
  const fixedFrames = [
2839
2848
  header,
2849
+ ...(report !== undefined
2850
+ ? [{ type: 'ROUND_FAILURE', code: report }]
2851
+ : []),
2840
2852
  pull,
2841
2853
  ...subs.map((sub) => definedObject({
2842
2854
  type: 'SUBSCRIPTION',
@@ -2848,6 +2860,9 @@ export class SyncClient {
2848
2860
  bootstrapState: sub.bootstrapState,
2849
2861
  })),
2850
2862
  ];
2863
+ // The header [+ the report frame] lead the request; PUSH_COMMIT frames
2864
+ // sit between them and the pull/subscription tail (§1.5 ordering).
2865
+ const lead = fixedFrames.slice(0, report !== undefined ? 2 : 1);
2851
2866
  let admitted = pushFrames.length;
2852
2867
  let deferred = encodedDeferred;
2853
2868
  if (byteCap !== undefined) {
@@ -2876,9 +2891,9 @@ export class SyncClient {
2876
2891
  outbox.length = admitted;
2877
2892
  }
2878
2893
  const frames = [
2879
- fixedFrames[0],
2894
+ ...lead,
2880
2895
  ...pushFrames,
2881
- ...fixedFrames.slice(1),
2896
+ ...fixedFrames.slice(lead.length),
2882
2897
  ];
2883
2898
  const requestBytes = encodeMessage({
2884
2899
  wireVersion: PROTOCOL_WIRE_VERSION,
@@ -2890,6 +2905,8 @@ export class SyncClient {
2890
2905
  if (message.msgKind !== 'response') {
2891
2906
  throw invalidRequest('transport returned a non-response message');
2892
2907
  }
2908
+ // §1.5: a complete, decodable response ends the report obligation.
2909
+ this.#roundFailureCode = undefined;
2893
2910
  const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
2894
2911
  // §4.8 E1: the push half may have drained commits that pinned rows of
2895
2912
  // a shrunk window unit — retry any deferred evictions now.
@@ -2907,6 +2924,11 @@ export class SyncClient {
2907
2924
  return summary;
2908
2925
  }
2909
2926
  catch (error) {
2927
+ // §1.5: a round that ended without a complete, decodable response is a
2928
+ // failure the server could not see — keep its code for the next request.
2929
+ const unseen = this.#unseenRoundFailureCode(error);
2930
+ if (unseen !== undefined)
2931
+ this.#roundFailureCode = unseen;
2910
2932
  // §7.3.5: a request-level lease code stops-and-surfaces — record it
2911
2933
  // in leaseState (no local-data purge, §7.3.4) and re-throw. Not a
2912
2934
  // silent retry: the app drives recovery to a live resolver. The
@@ -3115,6 +3137,25 @@ export class SyncClient {
3115
3137
  throw new ClientSyncError('sync.transport_failed', 'transport round failed', true, { causeKind: 'unknown' });
3116
3138
  });
3117
3139
  }
3140
+ /**
3141
+ * §1.5: the code to report for a round that ended without a complete,
3142
+ * decodable response — an undecodable/truncated body, a rejected fetch, a
3143
+ * failed body read, or a socket round lost mid-flight. An HTTP-status
3144
+ * rejection and an in-band `ERROR` frame were recorded by the server and
3145
+ * stay unreported.
3146
+ */
3147
+ #unseenRoundFailureCode(error) {
3148
+ const code = error instanceof DecodeError
3149
+ ? error.code
3150
+ : error instanceof ClientSyncError &&
3151
+ error.code === 'sync.transport_failed' &&
3152
+ error.details?.['causeKind'] !== 'status'
3153
+ ? error.code
3154
+ : undefined;
3155
+ // §1.5: the frame carries only codes matching its grammar; a code that
3156
+ // does not match would turn the whole request into a decode error.
3157
+ return code !== undefined && isRoundFailureCode(code) ? code : undefined;
3158
+ }
3118
3159
  /** Abort the in-flight socket round (socket closed or disconnected). */
3119
3160
  #abortPendingRound(reason) {
3120
3161
  const round = this.#pendingRound;
@@ -3230,6 +3271,20 @@ export class SyncClient {
3230
3271
  ? 'unsupported'
3231
3272
  : 'disconnected');
3232
3273
  }
3274
+ /**
3275
+ * §8.7: a round that ended without its response `END` leaves the rest of
3276
+ * that stream on the socket, and the next round would read it as its own.
3277
+ * Close the socket and record the loss; later rounds use the configured
3278
+ * transport until the supervisor reconnects.
3279
+ */
3280
+ #loseSocket() {
3281
+ const socket = this.#socket;
3282
+ this.#socket = undefined;
3283
+ this.#presence.clear(); // §8.6.1: presence is per-connection
3284
+ this.#setRealtimeState('lost', REALTIME_LOST_CODE);
3285
+ socket?.close();
3286
+ this.#emitDiagnostics();
3287
+ }
3233
3288
  /**
3234
3289
  * §8.7 channel-tag routing (synchronous, so chunk order is preserved):
3235
3290
  * `0x01` chunks feed the in-flight round's assembler; `0x00` messages
@@ -3250,6 +3305,7 @@ export class SyncClient {
3250
3305
  }
3251
3306
  catch {
3252
3307
  this.#pendingRound = undefined;
3308
+ this.#loseSocket();
3253
3309
  round.reject(invalidRequest('malformed round response stream (§8.7)'));
3254
3310
  return;
3255
3311
  }
@@ -3257,6 +3313,7 @@ export class SyncClient {
3257
3313
  return;
3258
3314
  this.#pendingRound = undefined;
3259
3315
  if (done.excess > 0) {
3316
+ this.#loseSocket();
3260
3317
  round.reject(invalidRequest('response bytes past END of the round stream (§8.7)'));
3261
3318
  return;
3262
3319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.40.0",
3
+ "version": "0.41.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.40.0"
100
+ "@syncular/core": "0.41.1"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.40.0",
103
+ "@syncular/server": "0.41.1",
104
104
  "playwright": "1.63.0",
105
105
  "@electric-sql/pglite": "^0.5.4"
106
106
  }
package/src/client.ts CHANGED
@@ -30,12 +30,14 @@ import {
30
30
  type CommitFrame,
31
31
  canonicalScopeJson,
32
32
  decodeMessage,
33
+ DecodeError,
33
34
  decodeRow,
34
35
  decodeRowsSegment,
35
36
  encodeMessage,
36
37
  encodeRow,
37
38
  encodeSparseRow,
38
39
  encodePresencePublish,
40
+ isRoundFailureCode,
39
41
  MessageStreamScanner,
40
42
  PROTOCOL_WIRE_VERSION,
41
43
  type PresenceKind,
@@ -917,6 +919,12 @@ export class SyncClient {
917
919
  /** Retry policy belongs to the operation that classified the failure. */
918
920
  #retryDelayMs = 250;
919
921
  #roundRetryDelayMs: number | undefined;
922
+ /**
923
+ * §1.5: the code of the last round that ended without a complete, decodable
924
+ * response. It rides `ROUND_FAILURE` on every request until a round
925
+ * completes; a newer failure replaces it.
926
+ */
927
+ #roundFailureCode: string | undefined;
920
928
  readonly #hasBlobs: boolean;
921
929
  /** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
922
930
  readonly #presence = new Map<string, Map<string, PresencePeer>>();
@@ -4129,8 +4137,14 @@ export class SyncClient {
4129
4137
  const subs = loadSubscriptions(this.#db).filter(
4130
4138
  (sub) => sub.status === 'active',
4131
4139
  );
4140
+ // §1.5: a kept round-failure code rides every request until a round
4141
+ // completes, immediately after REQ_HEADER.
4142
+ const report = this.#roundFailureCode;
4132
4143
  const fixedFrames: RequestFrame[] = [
4133
4144
  header,
4145
+ ...(report !== undefined
4146
+ ? [{ type: 'ROUND_FAILURE' as const, code: report }]
4147
+ : []),
4134
4148
  pull,
4135
4149
  ...subs.map(
4136
4150
  (sub): RequestFrame =>
@@ -4145,6 +4159,9 @@ export class SyncClient {
4145
4159
  }),
4146
4160
  ),
4147
4161
  ];
4162
+ // The header [+ the report frame] lead the request; PUSH_COMMIT frames
4163
+ // sit between them and the pull/subscription tail (§1.5 ordering).
4164
+ const lead = fixedFrames.slice(0, report !== undefined ? 2 : 1);
4148
4165
  let admitted = pushFrames.length;
4149
4166
  let deferred = encodedDeferred;
4150
4167
  if (byteCap !== undefined) {
@@ -4177,9 +4194,9 @@ export class SyncClient {
4177
4194
  outbox.length = admitted;
4178
4195
  }
4179
4196
  const frames: RequestFrame[] = [
4180
- fixedFrames[0]!,
4197
+ ...lead,
4181
4198
  ...pushFrames,
4182
- ...fixedFrames.slice(1),
4199
+ ...fixedFrames.slice(lead.length),
4183
4200
  ];
4184
4201
  const requestBytes = encodeMessage({
4185
4202
  wireVersion: PROTOCOL_WIRE_VERSION,
@@ -4191,6 +4208,8 @@ export class SyncClient {
4191
4208
  if (message.msgKind !== 'response') {
4192
4209
  throw invalidRequest('transport returned a non-response message');
4193
4210
  }
4211
+ // §1.5: a complete, decodable response ends the report obligation.
4212
+ this.#roundFailureCode = undefined;
4194
4213
  const summary = await this.#processResponse(
4195
4214
  message,
4196
4215
  outbox,
@@ -4215,6 +4234,10 @@ export class SyncClient {
4215
4234
  }
4216
4235
  return summary;
4217
4236
  } catch (error) {
4237
+ // §1.5: a round that ended without a complete, decodable response is a
4238
+ // failure the server could not see — keep its code for the next request.
4239
+ const unseen = this.#unseenRoundFailureCode(error);
4240
+ if (unseen !== undefined) this.#roundFailureCode = unseen;
4218
4241
  // §7.3.5: a request-level lease code stops-and-surfaces — record it
4219
4242
  // in leaseState (no local-data purge, §7.3.4) and re-throw. Not a
4220
4243
  // silent retry: the app drives recovery to a live resolver. The
@@ -4454,6 +4477,27 @@ export class SyncClient {
4454
4477
  });
4455
4478
  }
4456
4479
 
4480
+ /**
4481
+ * §1.5: the code to report for a round that ended without a complete,
4482
+ * decodable response — an undecodable/truncated body, a rejected fetch, a
4483
+ * failed body read, or a socket round lost mid-flight. An HTTP-status
4484
+ * rejection and an in-band `ERROR` frame were recorded by the server and
4485
+ * stay unreported.
4486
+ */
4487
+ #unseenRoundFailureCode(error: unknown): string | undefined {
4488
+ const code =
4489
+ error instanceof DecodeError
4490
+ ? error.code
4491
+ : error instanceof ClientSyncError &&
4492
+ error.code === 'sync.transport_failed' &&
4493
+ error.details?.['causeKind'] !== 'status'
4494
+ ? error.code
4495
+ : undefined;
4496
+ // §1.5: the frame carries only codes matching its grammar; a code that
4497
+ // does not match would turn the whole request into a decode error.
4498
+ return code !== undefined && isRoundFailureCode(code) ? code : undefined;
4499
+ }
4500
+
4457
4501
  /** Abort the in-flight socket round (socket closed or disconnected). */
4458
4502
  #abortPendingRound(reason: string): void {
4459
4503
  const round = this.#pendingRound;
@@ -4586,6 +4630,21 @@ export class SyncClient {
4586
4630
  );
4587
4631
  }
4588
4632
 
4633
+ /**
4634
+ * §8.7: a round that ended without its response `END` leaves the rest of
4635
+ * that stream on the socket, and the next round would read it as its own.
4636
+ * Close the socket and record the loss; later rounds use the configured
4637
+ * transport until the supervisor reconnects.
4638
+ */
4639
+ #loseSocket(): void {
4640
+ const socket = this.#socket;
4641
+ this.#socket = undefined;
4642
+ this.#presence.clear(); // §8.6.1: presence is per-connection
4643
+ this.#setRealtimeState('lost', REALTIME_LOST_CODE);
4644
+ socket?.close();
4645
+ this.#emitDiagnostics();
4646
+ }
4647
+
4589
4648
  /**
4590
4649
  * §8.7 channel-tag routing (synchronous, so chunk order is preserved):
4591
4650
  * `0x01` chunks feed the in-flight round's assembler; `0x00` messages
@@ -4603,12 +4662,14 @@ export class SyncClient {
4603
4662
  done = round.scanner.push(body);
4604
4663
  } catch {
4605
4664
  this.#pendingRound = undefined;
4665
+ this.#loseSocket();
4606
4666
  round.reject(invalidRequest('malformed round response stream (§8.7)'));
4607
4667
  return;
4608
4668
  }
4609
4669
  if (done === undefined) return;
4610
4670
  this.#pendingRound = undefined;
4611
4671
  if (done.excess > 0) {
4672
+ this.#loseSocket();
4612
4673
  round.reject(
4613
4674
  invalidRequest('response bytes past END of the round stream (§8.7)'),
4614
4675
  );