@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 +2 -1
- package/dist/database.d.ts +3 -0
- package/dist/database.js +26 -7
- package/dist/diagnostics.d.ts +1 -1
- package/dist/errors.d.ts +5 -2
- package/dist/errors.js +60 -16
- package/dist/http.js +37 -20
- package/dist/multi-tab.d.ts +1 -0
- package/dist/multi-tab.js +4 -1
- package/dist/reactive-store.js +10 -3
- package/dist/worker-entry.js +4 -2
- package/dist/worker-host.js +1 -1
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/client.ts +2 -1
- package/src/database.ts +24 -8
- package/src/diagnostics.ts +1 -0
- package/src/errors.ts +82 -21
- package/src/http.ts +53 -22
- package/src/multi-tab.ts +10 -1
- package/src/reactive-store.ts +13 -3
- package/src/worker-entry.ts +8 -2
- package/src/worker-host.ts +1 -0
- package/src/worker-protocol.ts +1 -0
package/dist/client.js
CHANGED
|
@@ -2327,7 +2327,8 @@ export class SyncClient {
|
|
|
2327
2327
|
};
|
|
2328
2328
|
this.#emitDiagnostics();
|
|
2329
2329
|
return summary;
|
|
2330
|
-
}, (
|
|
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({
|
package/dist/database.d.ts
CHANGED
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 (
|
|
36
|
+
catch (caught) {
|
|
37
|
+
const aborted = depthHolder.aborted;
|
|
38
|
+
const error = aborted?.error ?? caught;
|
|
29
39
|
depthHolder.depth = depth;
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
try {
|
|
41
|
+
if (depth === 0)
|
|
42
|
+
run('ROLLBACK');
|
|
43
|
+
else {
|
|
44
|
+
run(`ROLLBACK TO ${savepoint}`);
|
|
45
|
+
run(`RELEASE ${savepoint}`);
|
|
46
|
+
}
|
|
32
47
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
}
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
return {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 =
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
/**
|
package/dist/multi-tab.d.ts
CHANGED
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
|
}
|
package/dist/reactive-store.js
CHANGED
|
@@ -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
|
|
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()
|
|
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
|
|
547
|
+
if (localReadsBlocked(availability)) {
|
|
541
548
|
this.#publish({
|
|
542
549
|
...this.#state,
|
|
543
550
|
phase: 'blocked',
|
package/dist/worker-entry.js
CHANGED
|
@@ -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(
|
|
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 {
|
package/dist/worker-host.js
CHANGED
|
@@ -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.
|
|
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.
|
|
92
|
+
"@syncular/core": "0.30.7"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.30.
|
|
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
|
-
(
|
|
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 (
|
|
99
|
+
} catch (caught) {
|
|
100
|
+
const aborted = depthHolder.aborted as { error: unknown } | undefined;
|
|
101
|
+
const error = aborted?.error ?? caught;
|
|
94
102
|
depthHolder.depth = depth;
|
|
95
|
-
|
|
96
|
-
run('ROLLBACK');
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
}
|
package/src/diagnostics.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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?: {
|
|
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
|
}
|
package/src/reactive-store.ts
CHANGED
|
@@ -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
|
|
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()
|
|
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
|
|
694
|
+
if (localReadsBlocked(availability)) {
|
|
685
695
|
this.#publish({
|
|
686
696
|
...this.#state,
|
|
687
697
|
phase: 'blocked',
|
package/src/worker-entry.ts
CHANGED
|
@@ -20,7 +20,11 @@ import {
|
|
|
20
20
|
withClientDiagnosticsHost,
|
|
21
21
|
} from './diagnostics';
|
|
22
22
|
import { encryptionConfigFromKeyring } from './encryption';
|
|
23
|
-
import {
|
|
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(
|
|
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 {
|
package/src/worker-host.ts
CHANGED
package/src/worker-protocol.ts
CHANGED