@syncular/client 0.30.15 → 0.31.0

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.
@@ -153,6 +153,8 @@ export interface DiagnosticQueryFailure {
153
153
  | 'client.storage_corrupt'
154
154
  | 'client.storage_full'
155
155
  | 'client.storage_io'
156
+ | 'client.storage_busy'
157
+ | 'sync.local_corrupt'
156
158
  | 'client.query_failed';
157
159
  /** SQLite extended result code, when the driver exposed one. */
158
160
  readonly sqliteCode?: number;
package/src/errors.ts CHANGED
@@ -120,6 +120,7 @@ export function classifySqliteFailure(error: unknown): {
120
120
  | typeof STORAGE_CORRUPT_CODE
121
121
  | typeof STORAGE_FULL_CODE
122
122
  | typeof STORAGE_IO_CODE
123
+ | typeof STORAGE_BUSY_CODE
123
124
  | undefined;
124
125
  readonly error: unknown;
125
126
  } {
@@ -151,7 +152,8 @@ export function classifySqliteFailure(error: unknown): {
151
152
  code:
152
153
  error.code === STORAGE_FULL_CODE ||
153
154
  error.code === STORAGE_IO_CODE ||
154
- error.code === STORAGE_CORRUPT_CODE
155
+ error.code === STORAGE_CORRUPT_CODE ||
156
+ error.code === STORAGE_BUSY_CODE
155
157
  ? error.code
156
158
  : undefined,
157
159
  error: retained,
@@ -179,7 +181,9 @@ export function classifySqliteFailure(error: unknown): {
179
181
  ? STORAGE_IO_CODE
180
182
  : primary === 11 || primary === 26
181
183
  ? STORAGE_CORRUPT_CODE
182
- : undefined;
184
+ : primary === 5 || primary === 6
185
+ ? STORAGE_BUSY_CODE
186
+ : undefined;
183
187
  if (code === undefined) return { sqliteCode, code, error };
184
188
  const rollback =
185
189
  error instanceof Error && 'rollbackError' in error
@@ -191,8 +195,10 @@ export function classifySqliteFailure(error: unknown): {
191
195
  ? 'local SQLite storage is full'
192
196
  : primary === 10
193
197
  ? 'local SQLite storage I/O failed'
194
- : 'local SQLite storage is corrupt',
195
- false,
198
+ : code === STORAGE_BUSY_CODE
199
+ ? 'local SQLite storage is busy'
200
+ : 'local SQLite storage is corrupt',
201
+ code === STORAGE_BUSY_CODE,
196
202
  {
197
203
  sqliteCode,
198
204
  sqliteMessage: error instanceof Error ? error.message : String(error),
@@ -185,19 +185,6 @@ function retainedRows(
185
185
  );
186
186
  }
187
187
 
188
- function retainedTable(
189
- schema: CompiledClientSchema,
190
- name: string,
191
- ): CompiledClientTable {
192
- const table = schema.tables.get(name);
193
- if (!table)
194
- throw new ClientSyncError(
195
- 'sync.unknown_table',
196
- 'retained table is unknown',
197
- );
198
- return table;
199
- }
200
-
201
188
  export function restoreFailedBases(
202
189
  db: ClientDatabase,
203
190
  schema: CompiledClientSchema,
@@ -208,7 +195,11 @@ export function restoreFailedBases(
208
195
  const key = JSON.stringify([row.tbl, row.id]);
209
196
  if (seen.has(key)) continue;
210
197
  seen.add(key);
211
- const table = retainedTable(schema, row.tbl as string);
198
+ // §7.4.4: a retained row whose table the current schema removed has no
199
+ // mirror to project; its persisted intent and outcome stay available, only
200
+ // this projection is skipped.
201
+ const table = schema.tables.get(row.tbl as string);
202
+ if (table === undefined) continue;
212
203
  if (row.base === null) deleteLocalRow(db, table, row.id as string, false);
213
204
  else {
214
205
  const values: Record<string, JsonRowValue> = JSON.parse(String(row.base));
@@ -245,7 +236,11 @@ export function failedOverlayCommits(
245
236
  const id = row.commit_id as string;
246
237
  let operation: OutboxOperation = JSON.parse(row.op as string);
247
238
  if (schema && operation.op === 'upsert') {
248
- const table = retainedTable(schema, operation.table);
239
+ // §7.4.4: the table this retained row belongs to is gone; the persisted
240
+ // outcome and intent stay available, only the removed-table projection is
241
+ // skipped.
242
+ const table = schema.tables.get(operation.table);
243
+ if (table === undefined) continue;
249
244
  if (acknowledged) {
250
245
  if (
251
246
  !db.query(
package/src/http.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Browser transport bindings (§1.1, §5.4/§5.5, §8.1): fetch-based sync
3
- * transport, segment download with signed-URL preference and direct-serve
4
- * fallback, and a WebSocket realtime connector. Core tests never use these
5
- * (the loopback doctrine); the browser fixture exercises them in a real browser.
3
+ * transport, descriptor-selected signed-URL or direct segment delivery, and
4
+ * WebSocket realtime connectors. Adapter tests inject fetch and socket surfaces;
5
+ * protocol core conformance uses loopback.
6
6
  */
7
7
  import type { BlobTransport } from './blob';
8
8
  import { SSP2_CONTENT_TYPE } from './content-type';
@@ -15,27 +15,60 @@ import type {
15
15
  SyncTransport,
16
16
  } from './transport';
17
17
 
18
+ async function transfer<T>(
19
+ message: string,
20
+ operation: () => Promise<T>,
21
+ httpStatus?: number,
22
+ ): Promise<T> {
23
+ try {
24
+ return await operation();
25
+ } catch (error) {
26
+ // Classify only known exception kinds. Injected errors, including
27
+ // ClientSyncError, are untrusted and never become the returned cause.
28
+ const causeKind =
29
+ error instanceof DOMException && error.name === 'TimeoutError'
30
+ ? 'timeout'
31
+ : error instanceof DOMException && error.name === 'AbortError'
32
+ ? 'aborted'
33
+ : httpStatus !== undefined
34
+ ? 'body'
35
+ : error instanceof TypeError
36
+ ? 'network'
37
+ : 'unknown';
38
+ throw new ClientSyncError('sync.transport_failed', message, true, {
39
+ causeKind,
40
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
41
+ });
42
+ }
43
+ }
44
+
18
45
  async function throwHttpError(
19
46
  response: Response,
20
- details?: Readonly<Record<string, unknown>>,
47
+ defaultCode = 'sync.transport_failed',
21
48
  ): Promise<never> {
22
- let code = 'sync.transport_failed';
49
+ let code = defaultCode;
23
50
  let message = 'HTTP request failed';
24
- let retryable = response.status >= 500 || response.status === 429;
51
+ let retryable =
52
+ response.status >= 500 ||
53
+ response.status === 429 ||
54
+ defaultCode === 'sync.auth_required';
25
55
  try {
26
56
  const body = (await response.json()) as {
27
57
  code?: string;
28
58
  message?: string;
29
59
  retryable?: boolean;
30
60
  };
31
- if (typeof body.code === 'string') code = body.code;
32
- if (typeof body.message === 'string') message = body.message;
33
- if (typeof body.retryable === 'boolean') retryable = body.retryable;
61
+ // Only the blob download endpoint can establish absence.
62
+ if (body.code !== 'blob.not_found' || defaultCode === 'blob.not_found') {
63
+ if (typeof body.code === 'string') code = body.code;
64
+ if (typeof body.message === 'string') message = body.message;
65
+ if (typeof body.retryable === 'boolean') retryable = body.retryable;
66
+ }
34
67
  } catch {
35
68
  // non-JSON error body — keep the HTTP-status defaults
36
69
  }
37
70
  throw new ClientSyncError(code, message, retryable, {
38
- ...details,
71
+ causeKind: 'status',
39
72
  httpStatus: response.status,
40
73
  });
41
74
  }
@@ -66,16 +99,24 @@ export function httpSyncTransport(
66
99
  ): SyncTransport {
67
100
  const doFetch = options?.fetch ?? fetch;
68
101
  return async (request) => {
69
- const response = await doFetch(syncUrl, {
70
- method: 'POST',
71
- headers: {
72
- 'Content-Type': SSP2_CONTENT_TYPE,
73
- ...hostHeaders(options),
74
- },
75
- body: request.slice().buffer as ArrayBuffer,
76
- });
102
+ const response = await transfer('sync request failed', () =>
103
+ doFetch(syncUrl, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': SSP2_CONTENT_TYPE,
107
+ ...hostHeaders(options),
108
+ },
109
+ body: request.slice().buffer as ArrayBuffer,
110
+ }),
111
+ );
77
112
  if (!response.ok) await throwHttpError(response);
78
- return new Uint8Array(await response.arrayBuffer());
113
+ return new Uint8Array(
114
+ await transfer(
115
+ 'response body read failed',
116
+ () => response.arrayBuffer(),
117
+ response.status,
118
+ ),
119
+ );
79
120
  };
80
121
  }
81
122
 
@@ -86,33 +127,58 @@ export function httpRemoteOperationTransport(
86
127
  ): RemoteOperationTransport {
87
128
  const doFetch = options?.fetch ?? fetch;
88
129
  return async (request) => {
89
- const response = await doFetch(operationsUrl, {
90
- method: 'POST',
91
- headers: {
92
- 'Content-Type': 'application/vnd.syncular.operations.v1+json',
93
- ...hostHeaders(options),
94
- },
95
- body: request.slice().buffer as ArrayBuffer,
96
- });
130
+ const response = await transfer('remote operation request failed', () =>
131
+ doFetch(operationsUrl, {
132
+ method: 'POST',
133
+ headers: {
134
+ 'Content-Type': 'application/vnd.syncular.operations.v1+json',
135
+ ...hostHeaders(options),
136
+ },
137
+ body: request.slice().buffer as ArrayBuffer,
138
+ }),
139
+ );
97
140
  if (!response.ok) await throwHttpError(response);
98
- return new Uint8Array(await response.arrayBuffer());
141
+ return new Uint8Array(
142
+ await transfer(
143
+ 'response body read failed',
144
+ () => response.arrayBuffer(),
145
+ response.status,
146
+ ),
147
+ );
99
148
  };
100
149
  }
101
150
 
151
+ function socketOperation<T>(message: string, operation: () => T): T {
152
+ try {
153
+ return operation();
154
+ } catch {
155
+ throw new ClientSyncError('sync.transport_failed', message, true, {
156
+ causeKind: 'unknown',
157
+ });
158
+ }
159
+ }
160
+
102
161
  /** WebSocket connector for registered query snapshots. */
103
162
  export function webSocketRemoteOperationConnector(
104
163
  realtimeUrl: string,
105
164
  ): RemoteOperationRealtimeConnector {
106
165
  return (handlers) =>
107
166
  new Promise((resolve, reject) => {
108
- const socket = new WebSocket(realtimeUrl);
167
+ const socket = socketOperation(
168
+ 'realtime socket failed to connect',
169
+ () => new WebSocket(realtimeUrl),
170
+ );
109
171
  let opened = false;
110
172
  socket.binaryType = 'arraybuffer';
111
173
  socket.onopen = () => {
112
174
  opened = true;
113
175
  resolve({
114
- send: (bytes) => socket.send(bytes.slice().buffer as ArrayBuffer),
115
- close: () => socket.close(),
176
+ send: (bytes) =>
177
+ socketOperation('realtime send failed', () =>
178
+ socket.send(bytes.slice().buffer as ArrayBuffer),
179
+ ),
180
+ close: () =>
181
+ socketOperation('realtime close failed', () => socket.close()),
116
182
  });
117
183
  };
118
184
  socket.onmessage = (event) => {
@@ -204,36 +270,23 @@ export function httpSegmentDownloader(
204
270
  onProgress?: (bytesReceived: number) => void,
205
271
  signed = false,
206
272
  ) => {
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;
273
+ const response = await transfer('segment transfer failed', () =>
274
+ doFetch(url, init),
275
+ );
276
+ if (!response.ok) {
277
+ if (!signed) await throwHttpError(response);
226
278
  throw new ClientSyncError(
227
279
  'sync.transport_failed',
228
- 'segment transfer failed',
280
+ 'signed segment request failed; invalidate the descriptor and re-pull',
229
281
  true,
230
- {
231
- path,
232
- ...(response !== undefined ? { httpStatus: response.status } : {}),
233
- causeMessage: error instanceof Error ? error.message : String(error),
234
- },
282
+ { causeKind: 'status', httpStatus: response.status },
235
283
  );
236
284
  }
285
+ return transfer(
286
+ 'segment transfer failed',
287
+ () => readSegmentBody(response, onProgress),
288
+ response.status,
289
+ );
237
290
  };
238
291
  const direct = (request: {
239
292
  readonly segmentId: string;
@@ -273,26 +326,44 @@ export function httpBlobTransport(
273
326
  `${blobsBaseUrl}/${encodeURIComponent(blobId)}`;
274
327
  return {
275
328
  upload: async (blobId, bytes, mediaType) => {
276
- const response = await doFetch(blobUrl(blobId), {
277
- method: 'PUT',
278
- headers: {
279
- 'Content-Type': mediaType ?? 'application/octet-stream',
280
- ...hostHeaders(options),
281
- },
282
- body: bytes.slice().buffer as ArrayBuffer,
283
- });
329
+ const response = await transfer('blob upload failed', () =>
330
+ doFetch(blobUrl(blobId), {
331
+ method: 'PUT',
332
+ headers: {
333
+ 'Content-Type': mediaType ?? 'application/octet-stream',
334
+ ...hostHeaders(options),
335
+ },
336
+ body: bytes.slice().buffer as ArrayBuffer,
337
+ }),
338
+ );
284
339
  if (!response.ok) await throwHttpError(response);
285
340
  },
286
341
  download: async (blobId) => {
287
- const response = await doFetch(blobUrl(blobId), {
288
- headers: { ...hostHeaders(options) },
289
- });
290
- if (!response.ok) await throwHttpError(response);
342
+ const response = await transfer('blob download failed', () =>
343
+ doFetch(blobUrl(blobId), {
344
+ headers: { ...hostHeaders(options) },
345
+ }),
346
+ );
347
+ if (!response.ok)
348
+ await throwHttpError(
349
+ response,
350
+ response.status === 404
351
+ ? 'blob.not_found'
352
+ : response.status === 403
353
+ ? 'blob.forbidden'
354
+ : response.status === 401
355
+ ? 'sync.auth_required'
356
+ : 'sync.transport_failed',
357
+ );
291
358
  // §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
292
359
  // an octet-stream body is inline bytes.
293
360
  const contentType = response.headers.get('content-type') ?? '';
294
361
  if (contentType.includes('application/json')) {
295
- const body = (await response.json()) as {
362
+ const body = (await transfer(
363
+ 'blob download body read failed',
364
+ () => response.json(),
365
+ response.status,
366
+ )) as {
296
367
  url?: string;
297
368
  urlExpiresAtMs?: number;
298
369
  };
@@ -308,36 +379,57 @@ export function httpBlobTransport(
308
379
  }
309
380
  return {
310
381
  kind: 'bytes',
311
- bytes: new Uint8Array(await response.arrayBuffer()),
382
+ bytes: new Uint8Array(
383
+ await transfer(
384
+ 'blob download body read failed',
385
+ () => response.arrayBuffer(),
386
+ response.status,
387
+ ),
388
+ ),
312
389
  };
313
390
  },
314
391
  // §5.9.5: bare GET of the signed URL — no host auth (the URL is the grant).
315
392
  fetchUrl: async (url) => {
316
- const response = await doFetch(url);
393
+ const response = await transfer('signed blob download failed', () =>
394
+ doFetch(url),
395
+ );
317
396
  if (!response.ok) {
318
397
  throw new ClientSyncError(
319
398
  'sync.transport_failed',
320
- `blob signed-URL fetch failed with HTTP ${response.status} (§5.9.5: re-request to recover)`,
399
+ 'signed blob download failed; re-request to recover',
321
400
  true,
401
+ { causeKind: 'status', httpStatus: response.status },
322
402
  );
323
403
  }
324
- return new Uint8Array(await response.arrayBuffer());
404
+ return new Uint8Array(
405
+ await transfer(
406
+ 'response body read failed',
407
+ () => response.arrayBuffer(),
408
+ response.status,
409
+ ),
410
+ );
325
411
  },
326
412
  // §5.9.3: presigned-upload grant.
327
413
  uploadGrant: async (blobId, byteLength, mediaType) => {
328
- const response = await doFetch(`${blobUrl(blobId)}/upload-grant`, {
329
- method: 'POST',
330
- headers: {
331
- 'Content-Type': 'application/json',
332
- ...hostHeaders(options),
333
- },
334
- body: JSON.stringify({
335
- byteLength,
336
- ...(mediaType !== undefined ? { mediaType } : {}),
414
+ const response = await transfer('blob upload grant request failed', () =>
415
+ doFetch(`${blobUrl(blobId)}/upload-grant`, {
416
+ method: 'POST',
417
+ headers: {
418
+ 'Content-Type': 'application/json',
419
+ ...hostHeaders(options),
420
+ },
421
+ body: JSON.stringify({
422
+ byteLength,
423
+ ...(mediaType !== undefined ? { mediaType } : {}),
424
+ }),
337
425
  }),
338
- });
426
+ );
339
427
  if (!response.ok) await throwHttpError(response);
340
- const body = (await response.json()) as {
428
+ const body = (await transfer(
429
+ 'blob upload grant body read failed',
430
+ () => response.json(),
431
+ response.status,
432
+ )) as {
341
433
  url?: string;
342
434
  urlExpiresAtMs?: number;
343
435
  present?: boolean;
@@ -356,18 +448,21 @@ export function httpBlobTransport(
356
448
  },
357
449
  // §5.9.3: direct-to-storage PUT — no host auth (the URL is the grant).
358
450
  uploadToUrl: async (url, bytes, mediaType) => {
359
- const response = await doFetch(url, {
360
- method: 'PUT',
361
- headers: {
362
- 'Content-Type': mediaType ?? 'application/octet-stream',
363
- },
364
- body: bytes.slice().buffer as ArrayBuffer,
365
- });
451
+ const response = await transfer('signed blob upload failed', () =>
452
+ doFetch(url, {
453
+ method: 'PUT',
454
+ headers: {
455
+ 'Content-Type': mediaType ?? 'application/octet-stream',
456
+ },
457
+ body: bytes.slice().buffer as ArrayBuffer,
458
+ }),
459
+ );
366
460
  if (!response.ok) {
367
461
  throw new ClientSyncError(
368
462
  'sync.transport_failed',
369
- `blob presigned PUT failed with HTTP ${response.status} (§5.9.3: re-request a grant or stream direct)`,
463
+ 'signed blob upload failed; re-request a grant or stream direct',
370
464
  true,
465
+ { causeKind: 'status', httpStatus: response.status },
371
466
  );
372
467
  }
373
468
  },
@@ -380,17 +475,24 @@ export function webSocketRealtimeConnector(
380
475
  ): RealtimeConnector {
381
476
  return (handlers) =>
382
477
  new Promise((resolve, reject) => {
383
- const socket = new WebSocket(realtimeUrl);
478
+ const socket = socketOperation(
479
+ 'realtime socket failed to connect',
480
+ () => new WebSocket(realtimeUrl),
481
+ );
384
482
  let opened = false;
385
483
  socket.binaryType = 'arraybuffer';
386
484
  socket.onopen = () => {
387
485
  opened = true;
388
486
  resolve({
389
- send: (text) => socket.send(text),
487
+ send: (text) =>
488
+ socketOperation('realtime send failed', () => socket.send(text)),
390
489
  sendBytes: (bytes) => {
391
- socket.send(bytes.slice().buffer as ArrayBuffer);
490
+ socketOperation('realtime send failed', () =>
491
+ socket.send(bytes.slice().buffer as ArrayBuffer),
492
+ );
392
493
  },
393
- close: () => socket.close(),
494
+ close: () =>
495
+ socketOperation('realtime close failed', () => socket.close()),
394
496
  });
395
497
  };
396
498
  socket.onmessage = (event) => {
package/src/index.ts CHANGED
@@ -8,11 +8,10 @@
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
10
10
  */
11
- export {
12
- defineAuthorityReads,
13
- type AuthorityReadPolicy,
14
- type AuthorityReadDeclaration,
15
- type AuthoritySnapshot,
11
+ export type {
12
+ AuthorityReadPolicy,
13
+ AuthorityReadDeclaration,
14
+ AuthoritySnapshot,
16
15
  } from './authority';
17
16
  export * from './apply';
18
17
  export * from './availability';