@syncular/client 0.30.5 → 0.30.6
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/errors.d.ts +2 -1
- package/dist/errors.js +3 -1
- package/dist/http.js +37 -20
- package/dist/multi-tab.d.ts +1 -0
- package/dist/multi-tab.js +4 -1
- package/dist/reactive-store.js +10 -3
- package/dist/worker-entry.js +1 -0
- package/dist/worker-host.js +1 -1
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/errors.ts +8 -1
- package/src/http.ts +53 -22
- package/src/multi-tab.ts +10 -1
- package/src/reactive-store.ts +13 -3
- package/src/worker-entry.ts +1 -0
- package/src/worker-host.ts +1 -0
- package/src/worker-protocol.ts +1 -0
package/dist/errors.d.ts
CHANGED
|
@@ -20,7 +20,8 @@ export declare class ClientSyncError extends Error {
|
|
|
20
20
|
readonly name: string;
|
|
21
21
|
readonly code: string;
|
|
22
22
|
readonly retryable: boolean;
|
|
23
|
-
|
|
23
|
+
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
24
|
+
constructor(code: string, message: string, retryable?: boolean, details?: Readonly<Record<string, unknown>>);
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
27
|
* SPEC §7.5: the latest sync attempt failed while a live query still waited
|
package/dist/errors.js
CHANGED
|
@@ -19,10 +19,12 @@ export class ClientSyncError extends Error {
|
|
|
19
19
|
name = 'ClientSyncError';
|
|
20
20
|
code;
|
|
21
21
|
retryable;
|
|
22
|
-
|
|
22
|
+
details;
|
|
23
|
+
constructor(code, message, retryable = false, details) {
|
|
23
24
|
super(message);
|
|
24
25
|
this.code = code;
|
|
25
26
|
this.retryable = retryable;
|
|
27
|
+
this.details = details;
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
/**
|
package/dist/http.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { SSP2_CONTENT_TYPE } from './content-type.js';
|
|
2
2
|
import { ClientSyncError } from './errors.js';
|
|
3
|
-
async function throwHttpError(response) {
|
|
3
|
+
async function throwHttpError(response, details) {
|
|
4
4
|
let code = 'sync.transport_failed';
|
|
5
|
-
let message =
|
|
5
|
+
let message = 'HTTP request failed';
|
|
6
6
|
let retryable = response.status >= 500 || response.status === 429;
|
|
7
7
|
try {
|
|
8
8
|
const body = (await response.json());
|
|
@@ -16,7 +16,10 @@ async function throwHttpError(response) {
|
|
|
16
16
|
catch {
|
|
17
17
|
// non-JSON error body — keep the HTTP-status defaults
|
|
18
18
|
}
|
|
19
|
-
throw new ClientSyncError(code, message, retryable
|
|
19
|
+
throw new ClientSyncError(code, message, retryable, {
|
|
20
|
+
...details,
|
|
21
|
+
httpStatus: response.status,
|
|
22
|
+
});
|
|
20
23
|
}
|
|
21
24
|
function hostHeaders(options) {
|
|
22
25
|
const headers = options?.headers;
|
|
@@ -136,25 +139,39 @@ async function readSegmentBody(response, onProgress) {
|
|
|
136
139
|
}
|
|
137
140
|
export function httpSegmentDownloader(segmentsBaseUrl, options) {
|
|
138
141
|
const doFetch = options?.fetch ?? fetch;
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
142
|
+
const download = async (url, init, onProgress, signed = false) => {
|
|
143
|
+
// A capability's query/fragment and URL credentials never enter details.
|
|
144
|
+
const path = URL.canParse(url)
|
|
145
|
+
? new URL(url).pathname
|
|
146
|
+
: url.split(/[?#]/)[0];
|
|
147
|
+
let response;
|
|
148
|
+
try {
|
|
149
|
+
response = await doFetch(url, init);
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
if (!signed)
|
|
152
|
+
await throwHttpError(response, { path });
|
|
153
|
+
throw new ClientSyncError('sync.transport_failed', 'signed segment request failed; invalidate the descriptor and re-pull', true, { path, httpStatus: response.status });
|
|
154
|
+
}
|
|
155
|
+
return await readSegmentBody(response, onProgress);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error instanceof ClientSyncError)
|
|
159
|
+
throw error;
|
|
160
|
+
throw new ClientSyncError('sync.transport_failed', 'segment transfer failed', true, {
|
|
161
|
+
path,
|
|
162
|
+
...(response !== undefined ? { httpStatus: response.status } : {}),
|
|
163
|
+
causeMessage: error instanceof Error ? error.message : String(error),
|
|
164
|
+
});
|
|
155
165
|
}
|
|
156
|
-
return readSegmentBody(response, onProgress);
|
|
157
166
|
};
|
|
167
|
+
const direct = (request) => download(`${segmentsBaseUrl}/${encodeURIComponent(request.segmentId)}`, {
|
|
168
|
+
headers: {
|
|
169
|
+
'X-Syncular-Scopes': request.requestedScopesJson,
|
|
170
|
+
...hostHeaders(options),
|
|
171
|
+
},
|
|
172
|
+
}, request.onProgress);
|
|
173
|
+
// Deliberately headerless: the URL is the bearer grant (§5.4).
|
|
174
|
+
const fetchUrl = (url, onProgress) => download(url, undefined, onProgress, true);
|
|
158
175
|
return Object.assign(direct, { fetchUrl });
|
|
159
176
|
}
|
|
160
177
|
/**
|
package/dist/multi-tab.d.ts
CHANGED
package/dist/multi-tab.js
CHANGED
|
@@ -206,6 +206,9 @@ export class LeaderBridge {
|
|
|
206
206
|
code: error.code,
|
|
207
207
|
message: error.message,
|
|
208
208
|
retryable: error.retryable,
|
|
209
|
+
...(error.details !== undefined
|
|
210
|
+
? { details: error.details }
|
|
211
|
+
: {}),
|
|
209
212
|
}
|
|
210
213
|
: {
|
|
211
214
|
code: WORKER_FAILED_CODE,
|
|
@@ -470,7 +473,7 @@ export class FollowerLink {
|
|
|
470
473
|
}
|
|
471
474
|
else {
|
|
472
475
|
const err = message.error;
|
|
473
|
-
inflight.reject(new ClientSyncError(err?.code ?? WORKER_FAILED_CODE, err?.message ?? 'follower request failed', err?.retryable ?? false));
|
|
476
|
+
inflight.reject(new ClientSyncError(err?.code ?? WORKER_FAILED_CODE, err?.message ?? 'follower request failed', err?.retryable ?? false, err?.details));
|
|
474
477
|
}
|
|
475
478
|
return;
|
|
476
479
|
}
|
package/dist/reactive-store.js
CHANGED
|
@@ -246,6 +246,13 @@ class ObservationCache {
|
|
|
246
246
|
}
|
|
247
247
|
const EMPTY_ROWS = Object.freeze([]);
|
|
248
248
|
const unreadQuerySnapshots = new Map();
|
|
249
|
+
/** Schema floors stop network transfer; the retained local replica remains readable.
|
|
250
|
+
* Leadership stops still refuse local access through the unavailable owner. */
|
|
251
|
+
function localReadsBlocked(availability) {
|
|
252
|
+
return (availability.state === 'blocked' &&
|
|
253
|
+
(availability.reason === 'leader-unreachable' ||
|
|
254
|
+
availability.reason === 'leader-incompatible'));
|
|
255
|
+
}
|
|
249
256
|
/** One frozen snapshot per phase and availability for entries that have no
|
|
250
257
|
* successful read, so switching between unread entries keeps the snapshot and
|
|
251
258
|
* `rows` identity. */
|
|
@@ -458,7 +465,7 @@ class QueryEntry {
|
|
|
458
465
|
}
|
|
459
466
|
#onAvailabilityChange() {
|
|
460
467
|
const availability = this.store.availabilitySnapshot();
|
|
461
|
-
if (availability
|
|
468
|
+
if (localReadsBlocked(availability)) {
|
|
462
469
|
this.#publish({
|
|
463
470
|
...this.#state,
|
|
464
471
|
phase: 'blocked',
|
|
@@ -512,7 +519,7 @@ class QueryEntry {
|
|
|
512
519
|
try {
|
|
513
520
|
do {
|
|
514
521
|
this.#requested = false;
|
|
515
|
-
if (this.store.availabilitySnapshot()
|
|
522
|
+
if (localReadsBlocked(this.store.availabilitySnapshot()))
|
|
516
523
|
break;
|
|
517
524
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
518
525
|
return;
|
|
@@ -537,7 +544,7 @@ class QueryEntry {
|
|
|
537
544
|
if (this.#claimError !== undefined)
|
|
538
545
|
throw this.#claimError;
|
|
539
546
|
const availability = this.store.availabilitySnapshot();
|
|
540
|
-
if (availability
|
|
547
|
+
if (localReadsBlocked(availability)) {
|
|
541
548
|
this.#publish({
|
|
542
549
|
...this.#state,
|
|
543
550
|
phase: 'blocked',
|
package/dist/worker-entry.js
CHANGED
package/dist/worker-host.js
CHANGED
|
@@ -466,7 +466,7 @@ async function startWorkerCore(options) {
|
|
|
466
466
|
case 'error': {
|
|
467
467
|
const entry = pending.get(message.id);
|
|
468
468
|
pending.delete(message.id);
|
|
469
|
-
entry?.reject(new ClientSyncError(message.error.code, message.error.message, message.error.retryable));
|
|
469
|
+
entry?.reject(new ClientSyncError(message.error.code, message.error.message, message.error.retryable, message.error.details));
|
|
470
470
|
break;
|
|
471
471
|
}
|
|
472
472
|
case 'event':
|
|
@@ -175,6 +175,7 @@ export type MainToWorkerMessage = {
|
|
|
175
175
|
readonly config: WorkerInitConfig;
|
|
176
176
|
} | WorkerCallMessage;
|
|
177
177
|
export interface WorkerErrorShape {
|
|
178
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
178
179
|
readonly code: string;
|
|
179
180
|
readonly message: string;
|
|
180
181
|
readonly retryable: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.6",
|
|
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",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.30.
|
|
92
|
+
"@syncular/core": "0.30.6"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.30.
|
|
95
|
+
"@syncular/server": "0.30.6",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/errors.ts
CHANGED
|
@@ -27,11 +27,18 @@ export class ClientSyncError extends Error {
|
|
|
27
27
|
override readonly name: string = 'ClientSyncError';
|
|
28
28
|
readonly code: string;
|
|
29
29
|
readonly retryable: boolean;
|
|
30
|
+
readonly details: Readonly<Record<string, unknown>> | undefined;
|
|
30
31
|
|
|
31
|
-
constructor(
|
|
32
|
+
constructor(
|
|
33
|
+
code: string,
|
|
34
|
+
message: string,
|
|
35
|
+
retryable = false,
|
|
36
|
+
details?: Readonly<Record<string, unknown>>,
|
|
37
|
+
) {
|
|
32
38
|
super(message);
|
|
33
39
|
this.code = code;
|
|
34
40
|
this.retryable = retryable;
|
|
41
|
+
this.details = details;
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
|
package/src/http.ts
CHANGED
|
@@ -15,9 +15,12 @@ import type {
|
|
|
15
15
|
SyncTransport,
|
|
16
16
|
} from './transport';
|
|
17
17
|
|
|
18
|
-
async function throwHttpError(
|
|
18
|
+
async function throwHttpError(
|
|
19
|
+
response: Response,
|
|
20
|
+
details?: Readonly<Record<string, unknown>>,
|
|
21
|
+
): Promise<never> {
|
|
19
22
|
let code = 'sync.transport_failed';
|
|
20
|
-
let message =
|
|
23
|
+
let message = 'HTTP request failed';
|
|
21
24
|
let retryable = response.status >= 500 || response.status === 429;
|
|
22
25
|
try {
|
|
23
26
|
const body = (await response.json()) as {
|
|
@@ -31,7 +34,10 @@ async function throwHttpError(response: Response): Promise<never> {
|
|
|
31
34
|
} catch {
|
|
32
35
|
// non-JSON error body — keep the HTTP-status defaults
|
|
33
36
|
}
|
|
34
|
-
throw new ClientSyncError(code, message, retryable
|
|
37
|
+
throw new ClientSyncError(code, message, retryable, {
|
|
38
|
+
...details,
|
|
39
|
+
httpStatus: response.status,
|
|
40
|
+
});
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export interface HttpTransportOptions {
|
|
@@ -192,12 +198,49 @@ export function httpSegmentDownloader(
|
|
|
192
198
|
options?: HttpTransportOptions,
|
|
193
199
|
): SegmentDownloader {
|
|
194
200
|
const doFetch = options?.fetch ?? fetch;
|
|
195
|
-
const
|
|
201
|
+
const download = async (
|
|
202
|
+
url: string,
|
|
203
|
+
init?: RequestInit,
|
|
204
|
+
onProgress?: (bytesReceived: number) => void,
|
|
205
|
+
signed = false,
|
|
206
|
+
) => {
|
|
207
|
+
// A capability's query/fragment and URL credentials never enter details.
|
|
208
|
+
const path = URL.canParse(url)
|
|
209
|
+
? new URL(url).pathname
|
|
210
|
+
: url.split(/[?#]/)[0];
|
|
211
|
+
let response: Response | undefined;
|
|
212
|
+
try {
|
|
213
|
+
response = await doFetch(url, init);
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
if (!signed) await throwHttpError(response, { path });
|
|
216
|
+
throw new ClientSyncError(
|
|
217
|
+
'sync.transport_failed',
|
|
218
|
+
'signed segment request failed; invalidate the descriptor and re-pull',
|
|
219
|
+
true,
|
|
220
|
+
{ path, httpStatus: response.status },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return await readSegmentBody(response, onProgress);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error instanceof ClientSyncError) throw error;
|
|
226
|
+
throw new ClientSyncError(
|
|
227
|
+
'sync.transport_failed',
|
|
228
|
+
'segment transfer failed',
|
|
229
|
+
true,
|
|
230
|
+
{
|
|
231
|
+
path,
|
|
232
|
+
...(response !== undefined ? { httpStatus: response.status } : {}),
|
|
233
|
+
causeMessage: error instanceof Error ? error.message : String(error),
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const direct = (request: {
|
|
196
239
|
readonly segmentId: string;
|
|
197
240
|
readonly requestedScopesJson: string;
|
|
198
241
|
readonly onProgress?: (bytesReceived: number) => void;
|
|
199
|
-
}) =>
|
|
200
|
-
|
|
242
|
+
}) =>
|
|
243
|
+
download(
|
|
201
244
|
`${segmentsBaseUrl}/${encodeURIComponent(request.segmentId)}`,
|
|
202
245
|
{
|
|
203
246
|
headers: {
|
|
@@ -205,25 +248,13 @@ export function httpSegmentDownloader(
|
|
|
205
248
|
...hostHeaders(options),
|
|
206
249
|
},
|
|
207
250
|
},
|
|
251
|
+
request.onProgress,
|
|
208
252
|
);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
};
|
|
212
|
-
const fetchUrl = async (
|
|
253
|
+
// Deliberately headerless: the URL is the bearer grant (§5.4).
|
|
254
|
+
const fetchUrl = (
|
|
213
255
|
url: string,
|
|
214
256
|
onProgress?: (bytesReceived: number) => void,
|
|
215
|
-
) =>
|
|
216
|
-
// Deliberately headerless: the URL is the bearer grant (§5.4).
|
|
217
|
-
const response = await doFetch(url);
|
|
218
|
-
if (!response.ok) {
|
|
219
|
-
throw new ClientSyncError(
|
|
220
|
-
'sync.transport_failed',
|
|
221
|
-
`signed-URL fetch failed with HTTP ${response.status} (§5.4: descriptor invalidated, re-pull to recover)`,
|
|
222
|
-
true,
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
return readSegmentBody(response, onProgress);
|
|
226
|
-
};
|
|
257
|
+
) => download(url, undefined, onProgress, true);
|
|
227
258
|
return Object.assign(direct, { fetchUrl });
|
|
228
259
|
}
|
|
229
260
|
|
package/src/multi-tab.ts
CHANGED
|
@@ -171,7 +171,12 @@ interface ResMessage {
|
|
|
171
171
|
readonly reqId: number;
|
|
172
172
|
readonly ok: boolean;
|
|
173
173
|
readonly value?: unknown;
|
|
174
|
-
readonly error?: {
|
|
174
|
+
readonly error?: {
|
|
175
|
+
code: string;
|
|
176
|
+
message: string;
|
|
177
|
+
retryable: boolean;
|
|
178
|
+
details?: Readonly<Record<string, unknown>>;
|
|
179
|
+
};
|
|
175
180
|
}
|
|
176
181
|
|
|
177
182
|
interface EventMessage {
|
|
@@ -336,6 +341,9 @@ export class LeaderBridge {
|
|
|
336
341
|
code: error.code,
|
|
337
342
|
message: error.message,
|
|
338
343
|
retryable: error.retryable,
|
|
344
|
+
...(error.details !== undefined
|
|
345
|
+
? { details: error.details }
|
|
346
|
+
: {}),
|
|
339
347
|
}
|
|
340
348
|
: {
|
|
341
349
|
code: WORKER_FAILED_CODE,
|
|
@@ -682,6 +690,7 @@ export class FollowerLink {
|
|
|
682
690
|
err?.code ?? WORKER_FAILED_CODE,
|
|
683
691
|
err?.message ?? 'follower request failed',
|
|
684
692
|
err?.retryable ?? false,
|
|
693
|
+
err?.details,
|
|
685
694
|
),
|
|
686
695
|
);
|
|
687
696
|
}
|
package/src/reactive-store.ts
CHANGED
|
@@ -371,6 +371,16 @@ class ObservationCache {
|
|
|
371
371
|
const EMPTY_ROWS: readonly never[] = Object.freeze([]);
|
|
372
372
|
const unreadQuerySnapshots = new Map<string, LiveQueryResult<never>>();
|
|
373
373
|
|
|
374
|
+
/** Schema floors stop network transfer; the retained local replica remains readable.
|
|
375
|
+
* Leadership stops still refuse local access through the unavailable owner. */
|
|
376
|
+
function localReadsBlocked(availability: SyncAvailability): boolean {
|
|
377
|
+
return (
|
|
378
|
+
availability.state === 'blocked' &&
|
|
379
|
+
(availability.reason === 'leader-unreachable' ||
|
|
380
|
+
availability.reason === 'leader-incompatible')
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
374
384
|
/** One frozen snapshot per phase and availability for entries that have no
|
|
375
385
|
* successful read, so switching between unread entries keeps the snapshot and
|
|
376
386
|
* `rows` identity. */
|
|
@@ -598,7 +608,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
598
608
|
|
|
599
609
|
#onAvailabilityChange(): void {
|
|
600
610
|
const availability = this.store.availabilitySnapshot();
|
|
601
|
-
if (availability
|
|
611
|
+
if (localReadsBlocked(availability)) {
|
|
602
612
|
this.#publish({
|
|
603
613
|
...this.#state,
|
|
604
614
|
phase: 'blocked',
|
|
@@ -654,7 +664,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
654
664
|
try {
|
|
655
665
|
do {
|
|
656
666
|
this.#requested = false;
|
|
657
|
-
if (this.store.availabilitySnapshot()
|
|
667
|
+
if (localReadsBlocked(this.store.availabilitySnapshot())) break;
|
|
658
668
|
|
|
659
669
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
660
670
|
return;
|
|
@@ -681,7 +691,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
681
691
|
return;
|
|
682
692
|
if (this.#claimError !== undefined) throw this.#claimError;
|
|
683
693
|
const availability = this.store.availabilitySnapshot();
|
|
684
|
-
if (availability
|
|
694
|
+
if (localReadsBlocked(availability)) {
|
|
685
695
|
this.#publish({
|
|
686
696
|
...this.#state,
|
|
687
697
|
phase: 'blocked',
|
package/src/worker-entry.ts
CHANGED
package/src/worker-host.ts
CHANGED
package/src/worker-protocol.ts
CHANGED