@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 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
- constructor(code: string, message: string, retryable?: boolean);
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
- constructor(code, message, retryable = false) {
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 = `HTTP ${response.status}`;
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 direct = async (request) => {
140
- const response = await doFetch(`${segmentsBaseUrl}/${encodeURIComponent(request.segmentId)}`, {
141
- headers: {
142
- 'X-Syncular-Scopes': request.requestedScopesJson,
143
- ...hostHeaders(options),
144
- },
145
- });
146
- if (!response.ok)
147
- await throwHttpError(response);
148
- return readSegmentBody(response, request.onProgress);
149
- };
150
- const fetchUrl = async (url, onProgress) => {
151
- // Deliberately headerless: the URL is the bearer grant (§5.4).
152
- const response = await doFetch(url);
153
- if (!response.ok) {
154
- throw new ClientSyncError('sync.transport_failed', `signed-URL fetch failed with HTTP ${response.status} (§5.4: descriptor invalidated, re-pull to recover)`, true);
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
  /**
@@ -86,6 +86,7 @@ interface ResMessage {
86
86
  code: string;
87
87
  message: string;
88
88
  retryable: boolean;
89
+ details?: Readonly<Record<string, unknown>>;
89
90
  };
90
91
  }
91
92
  interface EventMessage {
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
  }
@@ -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.state === 'blocked') {
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().state === 'blocked')
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.state === 'blocked') {
547
+ if (localReadsBlocked(availability)) {
541
548
  this.#publish({
542
549
  ...this.#state,
543
550
  phase: 'blocked',
@@ -26,6 +26,7 @@ function toErrorShape(error) {
26
26
  code: error.code,
27
27
  message: error.message,
28
28
  retryable: error.retryable,
29
+ ...(error.details !== undefined ? { details: error.details } : {}),
29
30
  };
30
31
  }
31
32
  return {
@@ -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.5",
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.5"
92
+ "@syncular/core": "0.30.6"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.5",
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(code: string, message: string, retryable = false) {
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(response: Response): Promise<never> {
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 = `HTTP ${response.status}`;
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 direct = async (request: {
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
- const response = await doFetch(
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
- if (!response.ok) await throwHttpError(response);
210
- return readSegmentBody(response, request.onProgress);
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?: { code: string; message: string; retryable: boolean };
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
  }
@@ -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.state === 'blocked') {
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().state === 'blocked') break;
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.state === 'blocked') {
694
+ if (localReadsBlocked(availability)) {
685
695
  this.#publish({
686
696
  ...this.#state,
687
697
  phase: 'blocked',
@@ -83,6 +83,7 @@ function toErrorShape(error: unknown): WorkerErrorShape {
83
83
  code: error.code,
84
84
  message: error.message,
85
85
  retryable: error.retryable,
86
+ ...(error.details !== undefined ? { details: error.details } : {}),
86
87
  };
87
88
  }
88
89
  return {
@@ -843,6 +843,7 @@ async function startWorkerCore(options: {
843
843
  message.error.code,
844
844
  message.error.message,
845
845
  message.error.retryable,
846
+ message.error.details,
846
847
  ),
847
848
  );
848
849
  break;
@@ -233,6 +233,7 @@ export type MainToWorkerMessage =
233
233
  | WorkerCallMessage;
234
234
 
235
235
  export interface WorkerErrorShape {
236
+ readonly details?: Readonly<Record<string, unknown>>;
236
237
  readonly code: string;
237
238
  readonly message: string;
238
239
  readonly retryable: boolean;