@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 +60 -3
- package/package.json +3 -3
- package/src/client.ts +63 -2
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
|
-
|
|
2894
|
+
...lead,
|
|
2880
2895
|
...pushFrames,
|
|
2881
|
-
...fixedFrames.slice(
|
|
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.
|
|
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.
|
|
100
|
+
"@syncular/core": "0.41.1"
|
|
101
101
|
},
|
|
102
102
|
"devDependencies": {
|
|
103
|
-
"@syncular/server": "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
|
-
|
|
4197
|
+
...lead,
|
|
4181
4198
|
...pushFrames,
|
|
4182
|
-
...fixedFrames.slice(
|
|
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
|
);
|