@syncular/client 0.30.16 → 0.32.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.
@@ -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_full' | 'client.storage_io' | 'client.query_failed';
116
+ readonly code: 'client.storage_corrupt' | 'client.storage_full' | 'client.storage_io' | 'client.storage_busy' | 'sync.local_corrupt' | '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
@@ -65,6 +65,6 @@ export declare class RealtimeUnavailableError extends ClientSyncError {
65
65
  */
66
66
  export declare function classifySqliteFailure(error: unknown): {
67
67
  readonly sqliteCode: number | undefined;
68
- readonly code: typeof STORAGE_CORRUPT_CODE | typeof STORAGE_FULL_CODE | typeof STORAGE_IO_CODE | undefined;
68
+ readonly code: typeof STORAGE_CORRUPT_CODE | typeof STORAGE_FULL_CODE | typeof STORAGE_IO_CODE | typeof STORAGE_BUSY_CODE | undefined;
69
69
  readonly error: unknown;
70
70
  };
package/dist/errors.js CHANGED
@@ -98,7 +98,8 @@ export function classifySqliteFailure(error) {
98
98
  sqliteCode: typeof sqliteCode === 'number' ? sqliteCode : undefined,
99
99
  code: error.code === STORAGE_FULL_CODE ||
100
100
  error.code === STORAGE_IO_CODE ||
101
- error.code === STORAGE_CORRUPT_CODE
101
+ error.code === STORAGE_CORRUPT_CODE ||
102
+ error.code === STORAGE_BUSY_CODE
102
103
  ? error.code
103
104
  : undefined,
104
105
  error: retained,
@@ -124,7 +125,9 @@ export function classifySqliteFailure(error) {
124
125
  ? STORAGE_IO_CODE
125
126
  : primary === 11 || primary === 26
126
127
  ? STORAGE_CORRUPT_CODE
127
- : undefined;
128
+ : primary === 5 || primary === 6
129
+ ? STORAGE_BUSY_CODE
130
+ : undefined;
128
131
  if (code === undefined)
129
132
  return { sqliteCode, code, error };
130
133
  const rollback = error instanceof Error && 'rollbackError' in error
@@ -134,7 +137,9 @@ export function classifySqliteFailure(error) {
134
137
  ? 'local SQLite storage is full'
135
138
  : primary === 10
136
139
  ? 'local SQLite storage I/O failed'
137
- : 'local SQLite storage is corrupt', false, {
140
+ : code === STORAGE_BUSY_CODE
141
+ ? 'local SQLite storage is busy'
142
+ : 'local SQLite storage is corrupt', code === STORAGE_BUSY_CODE, {
138
143
  sqliteCode,
139
144
  sqliteMessage: error instanceof Error ? error.message : String(error),
140
145
  ...(rollback !== undefined
@@ -96,12 +96,6 @@ export function overlayScopePredicate(table, id) {
96
96
  function retainedRows(db, scope, acknowledged) {
97
97
  return db.query(`SELECT * FROM ${RETAINED_ROWS} WHERE (${overlayScopePredicate('tbl', 'id')})${acknowledged === undefined ? '' : ` AND commit_seq IS${acknowledged ? ' NOT' : ''} NULL`} ORDER BY commit_seq,at,commit_id,idx`, [scope ? JSON.stringify(scope) : null]);
98
98
  }
99
- function retainedTable(schema, name) {
100
- const table = schema.tables.get(name);
101
- if (!table)
102
- throw new ClientSyncError('sync.unknown_table', 'retained table is unknown');
103
- return table;
104
- }
105
99
  export function restoreFailedBases(db, schema, scope) {
106
100
  const seen = new Set();
107
101
  for (const row of retainedRows(db, scope)) {
@@ -109,7 +103,12 @@ export function restoreFailedBases(db, schema, scope) {
109
103
  if (seen.has(key))
110
104
  continue;
111
105
  seen.add(key);
112
- const table = retainedTable(schema, row.tbl);
106
+ // §7.4.4: a retained row whose table the current schema removed has no
107
+ // mirror to project; its persisted intent and outcome stay available, only
108
+ // this projection is skipped.
109
+ const table = schema.tables.get(row.tbl);
110
+ if (table === undefined)
111
+ continue;
113
112
  if (row.base === null)
114
113
  deleteLocalRow(db, table, row.id, false);
115
114
  else {
@@ -125,7 +124,12 @@ export function failedOverlayCommits(db, acknowledged = false, scope, schema) {
125
124
  const id = row.commit_id;
126
125
  let operation = JSON.parse(row.op);
127
126
  if (schema && operation.op === 'upsert') {
128
- const table = retainedTable(schema, operation.table);
127
+ // §7.4.4: the table this retained row belongs to is gone; the persisted
128
+ // outcome and intent stay available, only the removed-table projection is
129
+ // skipped.
130
+ const table = schema.tables.get(operation.table);
131
+ if (table === undefined)
132
+ continue;
129
133
  if (acknowledged) {
130
134
  if (!db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [operation.rowId]).length)
131
135
  operation = {
package/dist/http.d.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.js';
8
8
  import type { RealtimeConnector, RemoteOperationTransport, RemoteOperationRealtimeConnector, SegmentDownloader, SyncTransport } from './transport.js';
package/dist/http.js CHANGED
@@ -1,23 +1,50 @@
1
1
  import { SSP2_CONTENT_TYPE } from './content-type.js';
2
2
  import { ClientSyncError } from './errors.js';
3
- async function throwHttpError(response, details) {
4
- let code = 'sync.transport_failed';
3
+ async function transfer(message, operation, httpStatus) {
4
+ try {
5
+ return await operation();
6
+ }
7
+ catch (error) {
8
+ // Classify only known exception kinds. Injected errors, including
9
+ // ClientSyncError, are untrusted and never become the returned cause.
10
+ const causeKind = error instanceof DOMException && error.name === 'TimeoutError'
11
+ ? 'timeout'
12
+ : error instanceof DOMException && error.name === 'AbortError'
13
+ ? 'aborted'
14
+ : httpStatus !== undefined
15
+ ? 'body'
16
+ : error instanceof TypeError
17
+ ? 'network'
18
+ : 'unknown';
19
+ throw new ClientSyncError('sync.transport_failed', message, true, {
20
+ causeKind,
21
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
22
+ });
23
+ }
24
+ }
25
+ async function throwHttpError(response, defaultCode = 'sync.transport_failed') {
26
+ let code = defaultCode;
5
27
  let message = 'HTTP request failed';
6
- let retryable = response.status >= 500 || response.status === 429;
28
+ let retryable = response.status >= 500 ||
29
+ response.status === 429 ||
30
+ defaultCode === 'sync.auth_required';
7
31
  try {
8
32
  const body = (await response.json());
9
- if (typeof body.code === 'string')
10
- code = body.code;
11
- if (typeof body.message === 'string')
12
- message = body.message;
13
- if (typeof body.retryable === 'boolean')
14
- retryable = body.retryable;
33
+ // Only the blob download endpoint can establish absence.
34
+ if (body.code !== 'blob.not_found' || defaultCode === 'blob.not_found') {
35
+ if (typeof body.code === 'string')
36
+ code = body.code;
37
+ if (typeof body.message === 'string')
38
+ message = body.message;
39
+ if (typeof body.retryable === 'boolean')
40
+ retryable = body.retryable;
41
+ }
15
42
  }
16
43
  catch {
17
44
  // non-JSON error body — keep the HTTP-status defaults
18
45
  }
19
46
  throw new ClientSyncError(code, message, retryable, {
20
- ...details,
47
+ causeKind: 'status',
21
48
  httpStatus: response.status,
22
49
  });
23
50
  }
@@ -29,47 +56,57 @@ function hostHeaders(options) {
29
56
  export function httpSyncTransport(syncUrl, options) {
30
57
  const doFetch = options?.fetch ?? fetch;
31
58
  return async (request) => {
32
- const response = await doFetch(syncUrl, {
59
+ const response = await transfer('sync request failed', () => doFetch(syncUrl, {
33
60
  method: 'POST',
34
61
  headers: {
35
62
  'Content-Type': SSP2_CONTENT_TYPE,
36
63
  ...hostHeaders(options),
37
64
  },
38
65
  body: request.slice().buffer,
39
- });
66
+ }));
40
67
  if (!response.ok)
41
68
  await throwHttpError(response);
42
- return new Uint8Array(await response.arrayBuffer());
69
+ return new Uint8Array(await transfer('response body read failed', () => response.arrayBuffer(), response.status));
43
70
  };
44
71
  }
45
72
  /** POST one registered authoritative operation to `<mount>/operations`. */
46
73
  export function httpRemoteOperationTransport(operationsUrl, options) {
47
74
  const doFetch = options?.fetch ?? fetch;
48
75
  return async (request) => {
49
- const response = await doFetch(operationsUrl, {
76
+ const response = await transfer('remote operation request failed', () => doFetch(operationsUrl, {
50
77
  method: 'POST',
51
78
  headers: {
52
79
  'Content-Type': 'application/vnd.syncular.operations.v1+json',
53
80
  ...hostHeaders(options),
54
81
  },
55
82
  body: request.slice().buffer,
56
- });
83
+ }));
57
84
  if (!response.ok)
58
85
  await throwHttpError(response);
59
- return new Uint8Array(await response.arrayBuffer());
86
+ return new Uint8Array(await transfer('response body read failed', () => response.arrayBuffer(), response.status));
60
87
  };
61
88
  }
89
+ function socketOperation(message, operation) {
90
+ try {
91
+ return operation();
92
+ }
93
+ catch {
94
+ throw new ClientSyncError('sync.transport_failed', message, true, {
95
+ causeKind: 'unknown',
96
+ });
97
+ }
98
+ }
62
99
  /** WebSocket connector for registered query snapshots. */
63
100
  export function webSocketRemoteOperationConnector(realtimeUrl) {
64
101
  return (handlers) => new Promise((resolve, reject) => {
65
- const socket = new WebSocket(realtimeUrl);
102
+ const socket = socketOperation('realtime socket failed to connect', () => new WebSocket(realtimeUrl));
66
103
  let opened = false;
67
104
  socket.binaryType = 'arraybuffer';
68
105
  socket.onopen = () => {
69
106
  opened = true;
70
107
  resolve({
71
- send: (bytes) => socket.send(bytes.slice().buffer),
72
- close: () => socket.close(),
108
+ send: (bytes) => socketOperation('realtime send failed', () => socket.send(bytes.slice().buffer)),
109
+ close: () => socketOperation('realtime close failed', () => socket.close()),
73
110
  });
74
111
  };
75
112
  socket.onmessage = (event) => {
@@ -140,29 +177,13 @@ async function readSegmentBody(response, onProgress) {
140
177
  export function httpSegmentDownloader(segmentsBaseUrl, options) {
141
178
  const doFetch = options?.fetch ?? fetch;
142
179
  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
- });
180
+ const response = await transfer('segment transfer failed', () => doFetch(url, init));
181
+ if (!response.ok) {
182
+ if (!signed)
183
+ await throwHttpError(response);
184
+ throw new ClientSyncError('sync.transport_failed', 'signed segment request failed; invalidate the descriptor and re-pull', true, { causeKind: 'status', httpStatus: response.status });
165
185
  }
186
+ return transfer('segment transfer failed', () => readSegmentBody(response, onProgress), response.status);
166
187
  };
167
188
  const direct = (request) => download(`${segmentsBaseUrl}/${encodeURIComponent(request.segmentId)}`, {
168
189
  headers: {
@@ -185,28 +206,34 @@ export function httpBlobTransport(blobsBaseUrl, options) {
185
206
  const blobUrl = (blobId) => `${blobsBaseUrl}/${encodeURIComponent(blobId)}`;
186
207
  return {
187
208
  upload: async (blobId, bytes, mediaType) => {
188
- const response = await doFetch(blobUrl(blobId), {
209
+ const response = await transfer('blob upload failed', () => doFetch(blobUrl(blobId), {
189
210
  method: 'PUT',
190
211
  headers: {
191
212
  'Content-Type': mediaType ?? 'application/octet-stream',
192
213
  ...hostHeaders(options),
193
214
  },
194
215
  body: bytes.slice().buffer,
195
- });
216
+ }));
196
217
  if (!response.ok)
197
218
  await throwHttpError(response);
198
219
  },
199
220
  download: async (blobId) => {
200
- const response = await doFetch(blobUrl(blobId), {
221
+ const response = await transfer('blob download failed', () => doFetch(blobUrl(blobId), {
201
222
  headers: { ...hostHeaders(options) },
202
- });
223
+ }));
203
224
  if (!response.ok)
204
- await throwHttpError(response);
225
+ await throwHttpError(response, response.status === 404
226
+ ? 'blob.not_found'
227
+ : response.status === 403
228
+ ? 'blob.forbidden'
229
+ : response.status === 401
230
+ ? 'sync.auth_required'
231
+ : 'sync.transport_failed');
205
232
  // §5.9.5 always-issue: a JSON body with `url` means presigned delivery;
206
233
  // an octet-stream body is inline bytes.
207
234
  const contentType = response.headers.get('content-type') ?? '';
208
235
  if (contentType.includes('application/json')) {
209
- const body = (await response.json());
236
+ const body = (await transfer('blob download body read failed', () => response.json(), response.status));
210
237
  if (typeof body.url === 'string') {
211
238
  return {
212
239
  kind: 'url',
@@ -219,20 +246,20 @@ export function httpBlobTransport(blobsBaseUrl, options) {
219
246
  }
220
247
  return {
221
248
  kind: 'bytes',
222
- bytes: new Uint8Array(await response.arrayBuffer()),
249
+ bytes: new Uint8Array(await transfer('blob download body read failed', () => response.arrayBuffer(), response.status)),
223
250
  };
224
251
  },
225
252
  // §5.9.5: bare GET of the signed URL — no host auth (the URL is the grant).
226
253
  fetchUrl: async (url) => {
227
- const response = await doFetch(url);
254
+ const response = await transfer('signed blob download failed', () => doFetch(url));
228
255
  if (!response.ok) {
229
- throw new ClientSyncError('sync.transport_failed', `blob signed-URL fetch failed with HTTP ${response.status} (§5.9.5: re-request to recover)`, true);
256
+ throw new ClientSyncError('sync.transport_failed', 'signed blob download failed; re-request to recover', true, { causeKind: 'status', httpStatus: response.status });
230
257
  }
231
- return new Uint8Array(await response.arrayBuffer());
258
+ return new Uint8Array(await transfer('response body read failed', () => response.arrayBuffer(), response.status));
232
259
  },
233
260
  // §5.9.3: presigned-upload grant.
234
261
  uploadGrant: async (blobId, byteLength, mediaType) => {
235
- const response = await doFetch(`${blobUrl(blobId)}/upload-grant`, {
262
+ const response = await transfer('blob upload grant request failed', () => doFetch(`${blobUrl(blobId)}/upload-grant`, {
236
263
  method: 'POST',
237
264
  headers: {
238
265
  'Content-Type': 'application/json',
@@ -242,10 +269,10 @@ export function httpBlobTransport(blobsBaseUrl, options) {
242
269
  byteLength,
243
270
  ...(mediaType !== undefined ? { mediaType } : {}),
244
271
  }),
245
- });
272
+ }));
246
273
  if (!response.ok)
247
274
  await throwHttpError(response);
248
- const body = (await response.json());
275
+ const body = (await transfer('blob upload grant body read failed', () => response.json(), response.status));
249
276
  if (typeof body.url === 'string') {
250
277
  return {
251
278
  kind: 'url',
@@ -261,15 +288,15 @@ export function httpBlobTransport(blobsBaseUrl, options) {
261
288
  },
262
289
  // §5.9.3: direct-to-storage PUT — no host auth (the URL is the grant).
263
290
  uploadToUrl: async (url, bytes, mediaType) => {
264
- const response = await doFetch(url, {
291
+ const response = await transfer('signed blob upload failed', () => doFetch(url, {
265
292
  method: 'PUT',
266
293
  headers: {
267
294
  'Content-Type': mediaType ?? 'application/octet-stream',
268
295
  },
269
296
  body: bytes.slice().buffer,
270
- });
297
+ }));
271
298
  if (!response.ok) {
272
- throw new ClientSyncError('sync.transport_failed', `blob presigned PUT failed with HTTP ${response.status} (§5.9.3: re-request a grant or stream direct)`, true);
299
+ throw new ClientSyncError('sync.transport_failed', 'signed blob upload failed; re-request a grant or stream direct', true, { causeKind: 'status', httpStatus: response.status });
273
300
  }
274
301
  },
275
302
  };
@@ -277,17 +304,17 @@ export function httpBlobTransport(blobsBaseUrl, options) {
277
304
  /** WebSocket realtime connector (§8.1): text = control, binary = deltas. */
278
305
  export function webSocketRealtimeConnector(realtimeUrl) {
279
306
  return (handlers) => new Promise((resolve, reject) => {
280
- const socket = new WebSocket(realtimeUrl);
307
+ const socket = socketOperation('realtime socket failed to connect', () => new WebSocket(realtimeUrl));
281
308
  let opened = false;
282
309
  socket.binaryType = 'arraybuffer';
283
310
  socket.onopen = () => {
284
311
  opened = true;
285
312
  resolve({
286
- send: (text) => socket.send(text),
313
+ send: (text) => socketOperation('realtime send failed', () => socket.send(text)),
287
314
  sendBytes: (bytes) => {
288
- socket.send(bytes.slice().buffer);
315
+ socketOperation('realtime send failed', () => socket.send(bytes.slice().buffer));
289
316
  },
290
- close: () => socket.close(),
317
+ close: () => socketOperation('realtime close failed', () => socket.close()),
291
318
  });
292
319
  };
293
320
  socket.onmessage = (event) => {
@@ -7,7 +7,7 @@ export declare const OUTCOMES_TABLE = "_syncular_commit_outcomes";
7
7
  * restart can never turn "rejected" into an inferred success. Conflict payloads
8
8
  * deliberately stay local; retention never deletes an unresolved failure.
9
9
  */
10
- import type { RejectionDetails, RowValue } from '@syncular/core';
10
+ import { type RejectionDetails, type RowValue } from '@syncular/core';
11
11
  import type { ClientDatabase } from './database.js';
12
12
  import type { OutboxOperation } from './outbox.js';
13
13
  export interface ConflictRecord {
@@ -97,8 +97,16 @@ export interface ResolveCommitOutcomeInput {
97
97
  readonly resolution: Exclude<CommitOutcomeResolution, 'active'>;
98
98
  readonly replacementClientCommitId?: string;
99
99
  }
100
+ export declare function isStoredOperation(value: unknown): value is OutboxOperation;
100
101
  export declare function recordCommitOutcome(db: ClientDatabase, outcome: Omit<CommitOutcome, 'sequence' | 'resolution'>): CommitOutcome;
101
102
  export declare function commitOutcome(db: ClientDatabase, clientCommitId: string): CommitOutcome | undefined;
103
+ /**
104
+ * §7.5 persisted-journal view: the stored outcome WITHOUT the owner-derived
105
+ * `retainedRows` images. A malformed journal row (invalid status, resolution,
106
+ * results, or operations JSON) raises the same static `sync.local_corrupt` the
107
+ * native sidecar reports, never a raw `SyntaxError`.
108
+ */
109
+ export declare function persistedCommitOutcome(db: ClientDatabase, clientCommitId: string): CommitOutcome | undefined;
102
110
  export declare function listCommitOutcomes(db: ClientDatabase, query?: CommitOutcomeQuery): CommitOutcome[];
103
111
  export declare function persistCommitOutcomeResolution(db: ClientDatabase, input: ResolveCommitOutcomeInput, nowMs: number): CommitOutcome | undefined;
104
112
  /**