@syncular/client 0.30.4 → 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
@@ -187,7 +187,10 @@ export class LeaderBridge {
187
187
  });
188
188
  return;
189
189
  }
190
+ // A call can outlive close(); a closed BroadcastChannel throws on post.
190
191
  this.#invoke(method, args).then((value) => {
192
+ if (this.#closed)
193
+ return;
191
194
  this.#channel.postMessage({
192
195
  t: 'res',
193
196
  epoch: this.#epoch,
@@ -196,11 +199,16 @@ export class LeaderBridge {
196
199
  value,
197
200
  });
198
201
  }, (error) => {
202
+ if (this.#closed)
203
+ return;
199
204
  const shape = error instanceof ClientSyncError
200
205
  ? {
201
206
  code: error.code,
202
207
  message: error.message,
203
208
  retryable: error.retryable,
209
+ ...(error.details !== undefined
210
+ ? { details: error.details }
211
+ : {}),
204
212
  }
205
213
  : {
206
214
  code: WORKER_FAILED_CODE,
@@ -465,7 +473,7 @@ export class FollowerLink {
465
473
  }
466
474
  else {
467
475
  const err = message.error;
468
- 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));
469
477
  }
470
478
  return;
471
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.4",
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.4"
92
+ "@syncular/core": "0.30.6"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.4",
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 {
@@ -316,8 +321,10 @@ export class LeaderBridge {
316
321
  });
317
322
  return;
318
323
  }
324
+ // A call can outlive close(); a closed BroadcastChannel throws on post.
319
325
  this.#invoke(method, args).then(
320
326
  (value) => {
327
+ if (this.#closed) return;
321
328
  this.#channel.postMessage({
322
329
  t: 'res',
323
330
  epoch: this.#epoch,
@@ -327,12 +334,16 @@ export class LeaderBridge {
327
334
  });
328
335
  },
329
336
  (error: unknown) => {
337
+ if (this.#closed) return;
330
338
  const shape =
331
339
  error instanceof ClientSyncError
332
340
  ? {
333
341
  code: error.code,
334
342
  message: error.message,
335
343
  retryable: error.retryable,
344
+ ...(error.details !== undefined
345
+ ? { details: error.details }
346
+ : {}),
336
347
  }
337
348
  : {
338
349
  code: WORKER_FAILED_CODE,
@@ -679,6 +690,7 @@ export class FollowerLink {
679
690
  err?.code ?? WORKER_FAILED_CODE,
680
691
  err?.message ?? 'follower request failed',
681
692
  err?.retryable ?? false,
693
+ err?.details,
682
694
  ),
683
695
  );
684
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;