@syncular/client 0.30.5 → 0.30.7

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
@@ -2327,7 +2327,8 @@ export class SyncClient {
2327
2327
  };
2328
2328
  this.#emitDiagnostics();
2329
2329
  return summary;
2330
- }, (error) => {
2330
+ }, (caught) => {
2331
+ const error = classifySqliteFailure(caught).error;
2331
2332
  const completedAtMs = this.#now();
2332
2333
  const code = error.code;
2333
2334
  this.#progress.update({
@@ -63,4 +63,7 @@ export declare function assertImageAlias(alias: string): void;
63
63
  */
64
64
  export declare function runTransaction<T>(depthHolder: {
65
65
  depth: number;
66
+ aborted?: {
67
+ error: unknown;
68
+ };
66
69
  }, run: (sql: string) => void, fn: () => T): T;
package/dist/database.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { classifySqliteFailure } from './errors.js';
1
2
  /** Attach-schema aliases are code-chosen, never user input — enforce it. */
2
3
  export function assertImageAlias(alias) {
3
4
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
@@ -10,6 +11,10 @@ export function assertImageAlias(alias) {
10
11
  */
11
12
  export function runTransaction(depthHolder, run, fn) {
12
13
  const depth = depthHolder.depth;
14
+ if (depth === 0)
15
+ delete depthHolder.aborted;
16
+ else if (depthHolder.aborted)
17
+ throw depthHolder.aborted.error;
13
18
  const savepoint = `syncular_sp_${depth}`;
14
19
  if (depth === 0)
15
20
  run('BEGIN');
@@ -18,6 +23,9 @@ export function runTransaction(depthHolder, run, fn) {
18
23
  depthHolder.depth = depth + 1;
19
24
  try {
20
25
  const result = fn();
26
+ const aborted = depthHolder.aborted;
27
+ if (aborted)
28
+ throw aborted.error;
21
29
  depthHolder.depth = depth;
22
30
  if (depth === 0)
23
31
  run('COMMIT');
@@ -25,15 +33,26 @@ export function runTransaction(depthHolder, run, fn) {
25
33
  run(`RELEASE ${savepoint}`);
26
34
  return result;
27
35
  }
28
- catch (error) {
36
+ catch (caught) {
37
+ const aborted = depthHolder.aborted;
38
+ const error = aborted?.error ?? caught;
29
39
  depthHolder.depth = depth;
30
- if (depth === 0) {
31
- run('ROLLBACK');
40
+ try {
41
+ if (depth === 0)
42
+ run('ROLLBACK');
43
+ else {
44
+ run(`ROLLBACK TO ${savepoint}`);
45
+ run(`RELEASE ${savepoint}`);
46
+ }
32
47
  }
33
- else {
34
- run(`ROLLBACK TO ${savepoint}`);
35
- run(`RELEASE ${savepoint}`);
48
+ catch (rollbackError) {
49
+ // SQLite can end the whole transaction after SQLITE_FULL or SQLITE_IOERR.
50
+ // Poison remaining nested scopes until the outer owner unwinds.
51
+ depthHolder.aborted = { error };
52
+ if (error instanceof Error && !('rollbackError' in error)) {
53
+ Object.defineProperty(error, 'rollbackError', { value: rollbackError });
54
+ }
36
55
  }
37
- throw error;
56
+ throw classifySqliteFailure(error).error;
38
57
  }
39
58
  }
@@ -113,7 +113,7 @@ export interface DiagnosticQueryFailure {
113
113
  readonly id: string;
114
114
  /** Distinct generated table names the query depends on, ascending. */
115
115
  readonly tables: readonly string[];
116
- readonly code: 'client.storage_corrupt' | 'client.storage_io' | 'client.query_failed';
116
+ readonly code: 'client.storage_corrupt' | 'client.storage_full' | 'client.storage_io' | 'client.query_failed';
117
117
  /** SQLite extended result code, when the driver exposed one. */
118
118
  readonly sqliteCode?: number;
119
119
  /** First failure since this id's last successful read. */
package/dist/errors.d.ts CHANGED
@@ -5,6 +5,8 @@ export declare const STORAGE_BUSY_CODE = "client.storage_busy";
5
5
  export declare const STORAGE_UNAVAILABLE_CODE = "client.storage_unavailable";
6
6
  /** A local read hit SQLITE_CORRUPT or SQLITE_NOTADB (SPEC §7.5). */
7
7
  export declare const STORAGE_CORRUPT_CODE = "client.storage_corrupt";
8
+ /** Local SQLite storage has no space for the write. */
9
+ export declare const STORAGE_FULL_CODE = "client.storage_full";
8
10
  /** A local read hit SQLITE_IOERR (SPEC §7.5). */
9
11
  export declare const STORAGE_IO_CODE = "client.storage_io";
10
12
  /** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
@@ -20,7 +22,8 @@ export declare class ClientSyncError extends Error {
20
22
  readonly name: string;
21
23
  readonly code: string;
22
24
  readonly retryable: boolean;
23
- constructor(code: string, message: string, retryable?: boolean);
25
+ readonly details: Readonly<Record<string, unknown>> | undefined;
26
+ constructor(code: string, message: string, retryable?: boolean, details?: Readonly<Record<string, unknown>>);
24
27
  }
25
28
  /**
26
29
  * SPEC §7.5: the latest sync attempt failed while a live query still waited
@@ -60,6 +63,6 @@ export declare class RealtimeUnavailableError extends ClientSyncError {
60
63
  */
61
64
  export declare function classifySqliteFailure(error: unknown): {
62
65
  readonly sqliteCode: number | undefined;
63
- readonly code: typeof STORAGE_CORRUPT_CODE | typeof STORAGE_IO_CODE | undefined;
66
+ readonly code: typeof STORAGE_CORRUPT_CODE | typeof STORAGE_FULL_CODE | typeof STORAGE_IO_CODE | undefined;
64
67
  readonly error: unknown;
65
68
  };
package/dist/errors.js CHANGED
@@ -4,6 +4,8 @@ export const STORAGE_BUSY_CODE = 'client.storage_busy';
4
4
  export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
5
5
  /** A local read hit SQLITE_CORRUPT or SQLITE_NOTADB (SPEC §7.5). */
6
6
  export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
7
+ /** Local SQLite storage has no space for the write. */
8
+ export const STORAGE_FULL_CODE = 'client.storage_full';
7
9
  /** A local read hit SQLITE_IOERR (SPEC §7.5). */
8
10
  export const STORAGE_IO_CODE = 'client.storage_io';
9
11
  /** SPEC §8.8: a round under `realtimePolicy: 'required'` found no socket. */
@@ -19,10 +21,12 @@ export class ClientSyncError extends Error {
19
21
  name = 'ClientSyncError';
20
22
  code;
21
23
  retryable;
22
- constructor(code, message, retryable = false) {
24
+ details;
25
+ constructor(code, message, retryable = false, details) {
23
26
  super(message);
24
27
  this.code = code;
25
28
  this.retryable = retryable;
29
+ this.details = details;
26
30
  }
27
31
  }
28
32
  /**
@@ -71,6 +75,31 @@ export class RealtimeUnavailableError extends ClientSyncError {
71
75
  * original value.
72
76
  */
73
77
  export function classifySqliteFailure(error) {
78
+ if (error instanceof ClientSyncError) {
79
+ const sqliteCode = error.details?.sqliteCode;
80
+ let retained = error;
81
+ if ('rollbackError' in error &&
82
+ error.details?.rollbackFailure === undefined) {
83
+ const rollback = error.rollbackError;
84
+ retained = new ClientSyncError(error.code, error.message, error.retryable, {
85
+ ...error.details,
86
+ rollbackFailure: {
87
+ message: rollback instanceof Error ? rollback.message : String(rollback),
88
+ sqliteCode: classifySqliteFailure(rollback).sqliteCode,
89
+ },
90
+ });
91
+ Object.defineProperty(retained, 'cause', { value: error });
92
+ }
93
+ return {
94
+ sqliteCode: typeof sqliteCode === 'number' ? sqliteCode : undefined,
95
+ code: error.code === STORAGE_FULL_CODE ||
96
+ error.code === STORAGE_IO_CODE ||
97
+ error.code === STORAGE_CORRUPT_CODE
98
+ ? error.code
99
+ : undefined,
100
+ error: retained,
101
+ };
102
+ }
74
103
  const sqliteCode = error instanceof Error
75
104
  ? error.name === 'SQLiteError' &&
76
105
  'errno' in error &&
@@ -85,19 +114,34 @@ export function classifySqliteFailure(error) {
85
114
  : undefined
86
115
  : undefined;
87
116
  const primary = sqliteCode === undefined ? undefined : sqliteCode & 0xff;
88
- if (primary === 11 || primary === 26) {
89
- return {
90
- sqliteCode,
91
- code: STORAGE_CORRUPT_CODE,
92
- error: new ClientSyncError(STORAGE_CORRUPT_CODE, 'local SQLite storage is corrupt'),
93
- };
94
- }
95
- if (primary === 10) {
96
- return {
97
- sqliteCode,
98
- code: STORAGE_IO_CODE,
99
- error: new ClientSyncError(STORAGE_IO_CODE, 'local SQLite storage I/O failed'),
100
- };
101
- }
102
- return { sqliteCode, code: undefined, error };
117
+ const code = primary === 13
118
+ ? STORAGE_FULL_CODE
119
+ : primary === 10
120
+ ? STORAGE_IO_CODE
121
+ : primary === 11 || primary === 26
122
+ ? STORAGE_CORRUPT_CODE
123
+ : undefined;
124
+ if (code === undefined)
125
+ return { sqliteCode, code, error };
126
+ const rollback = error instanceof Error && 'rollbackError' in error
127
+ ? error.rollbackError
128
+ : undefined;
129
+ const failure = new ClientSyncError(code, primary === 13
130
+ ? 'local SQLite storage is full'
131
+ : primary === 10
132
+ ? 'local SQLite storage I/O failed'
133
+ : 'local SQLite storage is corrupt', false, {
134
+ sqliteCode,
135
+ sqliteMessage: error instanceof Error ? error.message : String(error),
136
+ ...(rollback !== undefined
137
+ ? {
138
+ rollbackFailure: {
139
+ message: rollback instanceof Error ? rollback.message : String(rollback),
140
+ sqliteCode: classifySqliteFailure(rollback).sqliteCode,
141
+ },
142
+ }
143
+ : {}),
144
+ });
145
+ Object.defineProperty(failure, 'cause', { value: error });
146
+ return { sqliteCode, code, error: failure };
103
147
  }
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',
@@ -16,16 +16,18 @@
16
16
  import { SyncClient } from './client.js';
17
17
  import { withClientDiagnosticsHost, } from './diagnostics.js';
18
18
  import { encryptionConfigFromKeyring } from './encryption.js';
19
- import { ClientSyncError, STORAGE_BUSY_CODE } from './errors.js';
19
+ import { classifySqliteFailure, ClientSyncError, STORAGE_BUSY_CODE, } from './errors.js';
20
20
  import { httpBlobTransport, httpSegmentDownloader, httpSyncTransport, webSocketRealtimeConnector, } from './http.js';
21
21
  import { openPersistentWasmDatabase } from './wasm-database.js';
22
22
  import { WORKER_FAILED_CODE, } from './worker-protocol.js';
23
- function toErrorShape(error) {
23
+ function toErrorShape(caught) {
24
+ const error = classifySqliteFailure(caught).error;
24
25
  if (error instanceof ClientSyncError) {
25
26
  return {
26
27
  code: error.code,
27
28
  message: error.message,
28
29
  retryable: error.retryable,
30
+ ...(error.details !== undefined ? { details: error.details } : {}),
29
31
  };
30
32
  }
31
33
  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.7",
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.7"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.5",
95
+ "@syncular/server": "0.30.7",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
package/src/client.ts CHANGED
@@ -3391,7 +3391,8 @@ export class SyncClient {
3391
3391
  this.#emitDiagnostics();
3392
3392
  return summary;
3393
3393
  },
3394
- (error: unknown) => {
3394
+ (caught: unknown) => {
3395
+ const error = classifySqliteFailure(caught).error;
3395
3396
  const completedAtMs = this.#now();
3396
3397
  const code = (error as { code?: unknown }).code;
3397
3398
  this.#progress.update({
package/src/database.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { classifySqliteFailure } from './errors';
2
+
1
3
  /**
2
4
  * Storage abstraction: the client core runs on any SQLite that
3
5
  * implements this minimal synchronous surface. Tests use bun:sqlite
@@ -75,29 +77,43 @@ export function assertImageAlias(alias: string): void {
75
77
  * `run` executes a parameterless statement.
76
78
  */
77
79
  export function runTransaction<T>(
78
- depthHolder: { depth: number },
80
+ depthHolder: { depth: number; aborted?: { error: unknown } },
79
81
  run: (sql: string) => void,
80
82
  fn: () => T,
81
83
  ): T {
82
84
  const depth = depthHolder.depth;
85
+ if (depth === 0) delete depthHolder.aborted;
86
+ else if (depthHolder.aborted) throw depthHolder.aborted.error;
83
87
  const savepoint = `syncular_sp_${depth}`;
84
88
  if (depth === 0) run('BEGIN');
85
89
  else run(`SAVEPOINT ${savepoint}`);
86
90
  depthHolder.depth = depth + 1;
87
91
  try {
88
92
  const result = fn();
93
+ const aborted = depthHolder.aborted as { error: unknown } | undefined;
94
+ if (aborted) throw aborted.error;
89
95
  depthHolder.depth = depth;
90
96
  if (depth === 0) run('COMMIT');
91
97
  else run(`RELEASE ${savepoint}`);
92
98
  return result;
93
- } catch (error) {
99
+ } catch (caught) {
100
+ const aborted = depthHolder.aborted as { error: unknown } | undefined;
101
+ const error = aborted?.error ?? caught;
94
102
  depthHolder.depth = depth;
95
- if (depth === 0) {
96
- run('ROLLBACK');
97
- } else {
98
- run(`ROLLBACK TO ${savepoint}`);
99
- run(`RELEASE ${savepoint}`);
103
+ try {
104
+ if (depth === 0) run('ROLLBACK');
105
+ else {
106
+ run(`ROLLBACK TO ${savepoint}`);
107
+ run(`RELEASE ${savepoint}`);
108
+ }
109
+ } catch (rollbackError) {
110
+ // SQLite can end the whole transaction after SQLITE_FULL or SQLITE_IOERR.
111
+ // Poison remaining nested scopes until the outer owner unwinds.
112
+ depthHolder.aborted = { error };
113
+ if (error instanceof Error && !('rollbackError' in error)) {
114
+ Object.defineProperty(error, 'rollbackError', { value: rollbackError });
115
+ }
100
116
  }
101
- throw error;
117
+ throw classifySqliteFailure(error).error;
102
118
  }
103
119
  }
@@ -151,6 +151,7 @@ export interface DiagnosticQueryFailure {
151
151
  readonly tables: readonly string[];
152
152
  readonly code:
153
153
  | 'client.storage_corrupt'
154
+ | 'client.storage_full'
154
155
  | 'client.storage_io'
155
156
  | 'client.query_failed';
156
157
  /** SQLite extended result code, when the driver exposed one. */
package/src/errors.ts CHANGED
@@ -9,6 +9,9 @@ export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
9
9
  /** A local read hit SQLITE_CORRUPT or SQLITE_NOTADB (SPEC §7.5). */
10
10
  export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
11
11
 
12
+ /** Local SQLite storage has no space for the write. */
13
+ export const STORAGE_FULL_CODE = 'client.storage_full';
14
+
12
15
  /** A local read hit SQLITE_IOERR (SPEC §7.5). */
13
16
  export const STORAGE_IO_CODE = 'client.storage_io';
14
17
 
@@ -27,11 +30,18 @@ export class ClientSyncError extends Error {
27
30
  override readonly name: string = 'ClientSyncError';
28
31
  readonly code: string;
29
32
  readonly retryable: boolean;
33
+ readonly details: Readonly<Record<string, unknown>> | undefined;
30
34
 
31
- constructor(code: string, message: string, retryable = false) {
35
+ constructor(
36
+ code: string,
37
+ message: string,
38
+ retryable = false,
39
+ details?: Readonly<Record<string, unknown>>,
40
+ ) {
32
41
  super(message);
33
42
  this.code = code;
34
43
  this.retryable = retryable;
44
+ this.details = details;
35
45
  }
36
46
  }
37
47
 
@@ -100,10 +110,45 @@ export function classifySqliteFailure(error: unknown): {
100
110
  readonly sqliteCode: number | undefined;
101
111
  readonly code:
102
112
  | typeof STORAGE_CORRUPT_CODE
113
+ | typeof STORAGE_FULL_CODE
103
114
  | typeof STORAGE_IO_CODE
104
115
  | undefined;
105
116
  readonly error: unknown;
106
117
  } {
118
+ if (error instanceof ClientSyncError) {
119
+ const sqliteCode = error.details?.sqliteCode;
120
+ let retained = error;
121
+ if (
122
+ 'rollbackError' in error &&
123
+ error.details?.rollbackFailure === undefined
124
+ ) {
125
+ const rollback = error.rollbackError;
126
+ retained = new ClientSyncError(
127
+ error.code,
128
+ error.message,
129
+ error.retryable,
130
+ {
131
+ ...error.details,
132
+ rollbackFailure: {
133
+ message:
134
+ rollback instanceof Error ? rollback.message : String(rollback),
135
+ sqliteCode: classifySqliteFailure(rollback).sqliteCode,
136
+ },
137
+ },
138
+ );
139
+ Object.defineProperty(retained, 'cause', { value: error });
140
+ }
141
+ return {
142
+ sqliteCode: typeof sqliteCode === 'number' ? sqliteCode : undefined,
143
+ code:
144
+ error.code === STORAGE_FULL_CODE ||
145
+ error.code === STORAGE_IO_CODE ||
146
+ error.code === STORAGE_CORRUPT_CODE
147
+ ? error.code
148
+ : undefined,
149
+ error: retained,
150
+ };
151
+ }
107
152
  const sqliteCode =
108
153
  error instanceof Error
109
154
  ? error.name === 'SQLiteError' &&
@@ -119,25 +164,41 @@ export function classifySqliteFailure(error: unknown): {
119
164
  : undefined
120
165
  : undefined;
121
166
  const primary = sqliteCode === undefined ? undefined : sqliteCode & 0xff;
122
- if (primary === 11 || primary === 26) {
123
- return {
124
- sqliteCode,
125
- code: STORAGE_CORRUPT_CODE,
126
- error: new ClientSyncError(
127
- STORAGE_CORRUPT_CODE,
128
- 'local SQLite storage is corrupt',
129
- ),
130
- };
131
- }
132
- if (primary === 10) {
133
- return {
167
+ const code =
168
+ primary === 13
169
+ ? STORAGE_FULL_CODE
170
+ : primary === 10
171
+ ? STORAGE_IO_CODE
172
+ : primary === 11 || primary === 26
173
+ ? STORAGE_CORRUPT_CODE
174
+ : undefined;
175
+ if (code === undefined) return { sqliteCode, code, error };
176
+ const rollback =
177
+ error instanceof Error && 'rollbackError' in error
178
+ ? error.rollbackError
179
+ : undefined;
180
+ const failure = new ClientSyncError(
181
+ code,
182
+ primary === 13
183
+ ? 'local SQLite storage is full'
184
+ : primary === 10
185
+ ? 'local SQLite storage I/O failed'
186
+ : 'local SQLite storage is corrupt',
187
+ false,
188
+ {
134
189
  sqliteCode,
135
- code: STORAGE_IO_CODE,
136
- error: new ClientSyncError(
137
- STORAGE_IO_CODE,
138
- 'local SQLite storage I/O failed',
139
- ),
140
- };
141
- }
142
- return { sqliteCode, code: undefined, error };
190
+ sqliteMessage: error instanceof Error ? error.message : String(error),
191
+ ...(rollback !== undefined
192
+ ? {
193
+ rollbackFailure: {
194
+ message:
195
+ rollback instanceof Error ? rollback.message : String(rollback),
196
+ sqliteCode: classifySqliteFailure(rollback).sqliteCode,
197
+ },
198
+ }
199
+ : {}),
200
+ },
201
+ );
202
+ Object.defineProperty(failure, 'cause', { value: error });
203
+ return { sqliteCode, code, error: failure };
143
204
  }
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',
@@ -20,7 +20,11 @@ import {
20
20
  withClientDiagnosticsHost,
21
21
  } from './diagnostics';
22
22
  import { encryptionConfigFromKeyring } from './encryption';
23
- import { ClientSyncError, STORAGE_BUSY_CODE } from './errors';
23
+ import {
24
+ classifySqliteFailure,
25
+ ClientSyncError,
26
+ STORAGE_BUSY_CODE,
27
+ } from './errors';
24
28
  import {
25
29
  httpBlobTransport,
26
30
  httpSegmentDownloader,
@@ -77,12 +81,14 @@ interface WorkerScope {
77
81
  ): void;
78
82
  }
79
83
 
80
- function toErrorShape(error: unknown): WorkerErrorShape {
84
+ function toErrorShape(caught: unknown): WorkerErrorShape {
85
+ const error = classifySqliteFailure(caught).error;
81
86
  if (error instanceof ClientSyncError) {
82
87
  return {
83
88
  code: error.code,
84
89
  message: error.message,
85
90
  retryable: error.retryable,
91
+ ...(error.details !== undefined ? { details: error.details } : {}),
86
92
  };
87
93
  }
88
94
  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;