@syncular/client 0.24.0 → 0.25.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.
- package/README.md +6 -1
- package/dist/client.d.ts +10 -0
- package/dist/client.js +96 -38
- package/dist/diagnostics.d.ts +14 -0
- package/dist/diagnostics.js +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +42 -0
- package/dist/reactive-store.js +8 -3
- package/package.json +3 -3
- package/src/client.ts +118 -43
- package/src/diagnostics.ts +18 -0
- package/src/errors.ts +59 -0
- package/src/reactive-store.ts +10 -3
package/README.md
CHANGED
|
@@ -288,7 +288,12 @@ and generated table names, never scopes. It lets a support screen distinguish
|
|
|
288
288
|
an absent registration from a legitimate zero-row completed bootstrap. The
|
|
289
289
|
snapshot also distinguishes reset, revocation, failure, schema floor, lease
|
|
290
290
|
stop, pending outbox, offline transport, and storage pressure/unreadability.
|
|
291
|
-
|
|
291
|
+
`queryFailures` retains at most 256 failed owned snapshot reads until each query
|
|
292
|
+
reads successfully. An entry contains only its stable PHI-free owner id,
|
|
293
|
+
generated table names, a stable code, an optional numeric SQLite result code,
|
|
294
|
+
and the first failure time. It excludes SQL, parameters, rows, and SQLite error
|
|
295
|
+
prose. Worker leaders and followers return identical evidence with their honest
|
|
296
|
+
role.
|
|
292
297
|
|
|
293
298
|
The contract intentionally excludes scope values, rows and clinical row
|
|
294
299
|
counts, SQL, paths, client/actor/lease ids, auth, keys, mutation bodies, stack
|
package/dist/client.d.ts
CHANGED
|
@@ -246,6 +246,16 @@ export interface QueryReadSpec {
|
|
|
246
246
|
readonly sql: string;
|
|
247
247
|
readonly params?: readonly SqlValue[];
|
|
248
248
|
readonly coverage?: readonly WindowCoverage[];
|
|
249
|
+
/**
|
|
250
|
+
* SPEC §7.5: the owning query. A failed owned read is reported in
|
|
251
|
+
* `diagnosticsSnapshot().queryFailures` until the owner reads successfully.
|
|
252
|
+
* The id is application-owned and must never contain PHI.
|
|
253
|
+
*/
|
|
254
|
+
readonly owner?: QueryOwner;
|
|
255
|
+
}
|
|
256
|
+
export interface QueryOwner {
|
|
257
|
+
readonly id: string;
|
|
258
|
+
readonly tables: readonly string[];
|
|
249
259
|
}
|
|
250
260
|
export interface QuerySnapshot<Row = SqlRow> {
|
|
251
261
|
readonly revision: LocalRevision;
|
package/dist/client.js
CHANGED
|
@@ -13,8 +13,8 @@ import { EncryptError } from '@syncular/core';
|
|
|
13
13
|
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
|
|
14
14
|
import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
15
15
|
import { registerDevtools } from './devtools.js';
|
|
16
|
-
import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, } from './diagnostics.js';
|
|
17
|
-
import { ClientSyncError } from './errors.js';
|
|
16
|
+
import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, MAX_DIAGNOSTIC_QUERY_FAILURES, } from './diagnostics.js';
|
|
17
|
+
import { ClientSyncError, classifySqliteFailure } from './errors.js';
|
|
18
18
|
import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
19
19
|
import { singleOwnerLock, } from './leader-lock.js';
|
|
20
20
|
import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
|
|
@@ -182,6 +182,8 @@ export class SyncClient {
|
|
|
182
182
|
#diagnosticsPending = false;
|
|
183
183
|
#lastRound;
|
|
184
184
|
#lastChange;
|
|
185
|
+
/** SPEC §7.6: owner id -> latest failed owned read, oldest first. */
|
|
186
|
+
#queryFailures = new Map();
|
|
185
187
|
/** The batch accumulator; non-undefined only inside `#applyBatch`. */
|
|
186
188
|
#batch;
|
|
187
189
|
/**
|
|
@@ -604,7 +606,12 @@ export class SyncClient {
|
|
|
604
606
|
assertReadOnlyQuery(sql);
|
|
605
607
|
// RFC 0005's container lives in a separate database file, so this
|
|
606
608
|
// connection cannot see it and there is no table-access guard to run.
|
|
607
|
-
|
|
609
|
+
try {
|
|
610
|
+
return stripSyncColumns(this.#db.query(sql, params));
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
throw classifySqliteFailure(error).error;
|
|
614
|
+
}
|
|
608
615
|
}
|
|
609
616
|
/** Current durable local observer revision (SPEC §7.5). */
|
|
610
617
|
get localRevision() {
|
|
@@ -618,44 +625,81 @@ export class SyncClient {
|
|
|
618
625
|
*/
|
|
619
626
|
querySnapshot(spec) {
|
|
620
627
|
this.#requireActive();
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
628
|
+
const owner = spec.owner;
|
|
629
|
+
let snapshot;
|
|
630
|
+
try {
|
|
631
|
+
assertReadOnlyQuery(spec.sql);
|
|
632
|
+
snapshot = this.#db.transaction(() => {
|
|
633
|
+
const revision = getLocalRevision(this.#db);
|
|
634
|
+
const rows = stripSyncColumns(this.#db.query(spec.sql, spec.params));
|
|
635
|
+
const pending = [];
|
|
636
|
+
const missing = [];
|
|
637
|
+
for (const requested of spec.coverage ?? []) {
|
|
638
|
+
const baseKey = windowBaseKey(requested.base);
|
|
639
|
+
const live = new Map(loadWindowUnits(this.#db, baseKey).map((entry) => [
|
|
640
|
+
entry.unit,
|
|
641
|
+
entry.subId,
|
|
642
|
+
]));
|
|
643
|
+
for (const unit of new Set(requested.units)) {
|
|
644
|
+
const subId = live.get(unit);
|
|
645
|
+
const ref = { baseKey, unit };
|
|
646
|
+
if (subId === undefined) {
|
|
647
|
+
missing.push(ref);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const sub = getSubscription(this.#db, subId);
|
|
651
|
+
if (sub === undefined ||
|
|
652
|
+
sub.status !== 'active' ||
|
|
653
|
+
sub.cursor < 0 ||
|
|
654
|
+
sub.bootstrapState !== undefined) {
|
|
655
|
+
pending.push(ref);
|
|
656
|
+
}
|
|
639
657
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
pending.
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
revision,
|
|
661
|
+
rows,
|
|
662
|
+
coverage: {
|
|
663
|
+
complete: pending.length === 0 && missing.length === 0,
|
|
664
|
+
pending,
|
|
665
|
+
missing,
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
const failure = classifySqliteFailure(error);
|
|
672
|
+
const sqliteCode = failure.sqliteCode;
|
|
673
|
+
const code = failure.code ?? 'client.query_failed';
|
|
674
|
+
if (owner !== undefined) {
|
|
675
|
+
const tables = [...new Set(owner.tables)].sort();
|
|
676
|
+
const previous = this.#queryFailures.get(owner.id);
|
|
677
|
+
if (previous === undefined ||
|
|
678
|
+
previous.code !== code ||
|
|
679
|
+
previous.sqliteCode !== sqliteCode ||
|
|
680
|
+
previous.tables.join('\0') !== tables.join('\0')) {
|
|
681
|
+
this.#queryFailures.delete(owner.id);
|
|
682
|
+
this.#queryFailures.set(owner.id, {
|
|
683
|
+
id: owner.id,
|
|
684
|
+
tables,
|
|
685
|
+
code,
|
|
686
|
+
...(sqliteCode !== undefined ? { sqliteCode } : {}),
|
|
687
|
+
atMs: this.#now(),
|
|
688
|
+
});
|
|
689
|
+
for (const id of this.#queryFailures.keys()) {
|
|
690
|
+
if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
|
|
691
|
+
break;
|
|
692
|
+
this.#queryFailures.delete(id);
|
|
646
693
|
}
|
|
694
|
+
this.#emitDiagnostics();
|
|
647
695
|
}
|
|
648
696
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
missing,
|
|
656
|
-
},
|
|
657
|
-
};
|
|
658
|
-
});
|
|
697
|
+
throw failure.error;
|
|
698
|
+
}
|
|
699
|
+
if (owner !== undefined && this.#queryFailures.delete(owner.id)) {
|
|
700
|
+
this.#emitDiagnostics();
|
|
701
|
+
}
|
|
702
|
+
return snapshot;
|
|
659
703
|
}
|
|
660
704
|
// -- previous-version context (RFC 0005) ----------------------------------
|
|
661
705
|
/**
|
|
@@ -999,6 +1043,7 @@ export class SyncClient {
|
|
|
999
1043
|
? { lastChange: this.#lastChange }
|
|
1000
1044
|
: {}),
|
|
1001
1045
|
storage: this.#diagnosticStorage(),
|
|
1046
|
+
queryFailures: [...this.#queryFailures.values()],
|
|
1002
1047
|
};
|
|
1003
1048
|
}
|
|
1004
1049
|
#diagnosticLease(nowMs) {
|
|
@@ -1822,7 +1867,20 @@ export class SyncClient {
|
|
|
1822
1867
|
)`, [clientCommitId, blobId, blobId]);
|
|
1823
1868
|
}
|
|
1824
1869
|
}
|
|
1825
|
-
|
|
1870
|
+
try {
|
|
1871
|
+
this.#applyOperationsLocally(operations, batch);
|
|
1872
|
+
}
|
|
1873
|
+
catch (error) {
|
|
1874
|
+
if (error instanceof Error &&
|
|
1875
|
+
error.message.includes('UNIQUE constraint failed:') &&
|
|
1876
|
+
(('errno' in error && error.errno === 2067) ||
|
|
1877
|
+
('errcode' in error && error.errcode === 2067) ||
|
|
1878
|
+
('resultCode' in error &&
|
|
1879
|
+
(error.resultCode === 19 || error.resultCode === 2067)))) {
|
|
1880
|
+
throw new ClientSyncError('sync.constraint_violation', 'local write violates a unique constraint');
|
|
1881
|
+
}
|
|
1882
|
+
throw error;
|
|
1883
|
+
}
|
|
1826
1884
|
batch.status();
|
|
1827
1885
|
});
|
|
1828
1886
|
this.#needsPull = true;
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { SecurityLifecycle } from './client.js';
|
|
|
9
9
|
export declare const CLIENT_DIAGNOSTICS_VERSION: 1;
|
|
10
10
|
export declare const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
|
|
11
11
|
export declare const MAX_DIAGNOSTIC_DOMAINS = 256;
|
|
12
|
+
export declare const MAX_DIAGNOSTIC_QUERY_FAILURES = 256;
|
|
12
13
|
export type ClientDiagnosticsHostKind = 'direct' | 'worker' | 'tauri' | 'react-native';
|
|
13
14
|
export type ClientDiagnosticsHostRole = 'single' | 'leader' | 'follower' | 'unknown';
|
|
14
15
|
export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
|
|
@@ -95,6 +96,18 @@ export interface ClientDiagnosticsStorage {
|
|
|
95
96
|
readonly blobCacheBytesApprox?: number;
|
|
96
97
|
readonly pressureReasonCode?: 'client.blob_cache_over_limit';
|
|
97
98
|
}
|
|
99
|
+
/** The latest owned snapshot read of one query id failed (SPEC §7.6). */
|
|
100
|
+
export interface DiagnosticQueryFailure {
|
|
101
|
+
/** Application-owned stable query id. It must never contain PHI. */
|
|
102
|
+
readonly id: string;
|
|
103
|
+
/** Distinct generated table names the query depends on, ascending. */
|
|
104
|
+
readonly tables: readonly string[];
|
|
105
|
+
readonly code: 'client.storage_corrupt' | 'client.storage_io' | 'client.query_failed';
|
|
106
|
+
/** SQLite extended result code, when the driver exposed one. */
|
|
107
|
+
readonly sqliteCode?: number;
|
|
108
|
+
/** First failure since this id's last successful read. */
|
|
109
|
+
readonly atMs: number;
|
|
110
|
+
}
|
|
98
111
|
export interface ClientDiagnosticsSnapshot {
|
|
99
112
|
readonly version: typeof CLIENT_DIAGNOSTICS_VERSION;
|
|
100
113
|
readonly capturedAtMs: number;
|
|
@@ -122,6 +135,7 @@ export interface ClientDiagnosticsSnapshot {
|
|
|
122
135
|
readonly lastRound?: DiagnosticLastRound;
|
|
123
136
|
readonly lastChange?: DiagnosticLastChange;
|
|
124
137
|
readonly storage: ClientDiagnosticsStorage;
|
|
138
|
+
readonly queryFailures: readonly DiagnosticQueryFailure[];
|
|
125
139
|
}
|
|
126
140
|
export type ClientDiagnosticsListener = (snapshot: ClientDiagnosticsSnapshot) => void;
|
|
127
141
|
export declare class ClientDiagnosticsEmitter {
|
package/dist/diagnostics.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export const CLIENT_DIAGNOSTICS_VERSION = 1;
|
|
2
2
|
export const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
|
|
3
3
|
export const MAX_DIAGNOSTIC_DOMAINS = 256;
|
|
4
|
+
export const MAX_DIAGNOSTIC_QUERY_FAILURES = 256;
|
|
4
5
|
export class ClientDiagnosticsEmitter {
|
|
5
6
|
#listeners = new Set();
|
|
6
7
|
get observed() {
|
package/dist/errors.d.ts
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
export declare const STORAGE_BUSY_CODE = "client.storage_busy";
|
|
3
3
|
/** The browser cannot provide the persistent storage APIs Syncular requires. */
|
|
4
4
|
export declare const STORAGE_UNAVAILABLE_CODE = "client.storage_unavailable";
|
|
5
|
+
/** A local read hit SQLITE_CORRUPT or SQLITE_NOTADB (SPEC §7.5). */
|
|
6
|
+
export declare const STORAGE_CORRUPT_CODE = "client.storage_corrupt";
|
|
7
|
+
/** A local read hit SQLITE_IOERR (SPEC §7.5). */
|
|
8
|
+
export declare const STORAGE_IO_CODE = "client.storage_io";
|
|
5
9
|
/**
|
|
6
10
|
* Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
|
|
7
11
|
* unchanged. Host/runtime-only conditions may use the separate `client.*`
|
|
@@ -13,3 +17,15 @@ export declare class ClientSyncError extends Error {
|
|
|
13
17
|
readonly retryable: boolean;
|
|
14
18
|
constructor(code: string, message: string, retryable?: boolean);
|
|
15
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* SPEC §7.5: classify a local SQLite read failure by the numeric result code
|
|
22
|
+
* the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm
|
|
23
|
+
* `resultCode`), never by message text. `error` is what the read throws: a
|
|
24
|
+
* stable `ClientSyncError` for corruption or I/O failure, otherwise the
|
|
25
|
+
* original value.
|
|
26
|
+
*/
|
|
27
|
+
export declare function classifySqliteFailure(error: unknown): {
|
|
28
|
+
readonly sqliteCode: number | undefined;
|
|
29
|
+
readonly code: typeof STORAGE_CORRUPT_CODE | typeof STORAGE_IO_CODE | undefined;
|
|
30
|
+
readonly error: unknown;
|
|
31
|
+
};
|
package/dist/errors.js
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
export const STORAGE_BUSY_CODE = 'client.storage_busy';
|
|
3
3
|
/** The browser cannot provide the persistent storage APIs Syncular requires. */
|
|
4
4
|
export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
|
|
5
|
+
/** A local read hit SQLITE_CORRUPT or SQLITE_NOTADB (SPEC §7.5). */
|
|
6
|
+
export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
|
|
7
|
+
/** A local read hit SQLITE_IOERR (SPEC §7.5). */
|
|
8
|
+
export const STORAGE_IO_CODE = 'client.storage_io';
|
|
5
9
|
/**
|
|
6
10
|
* Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
|
|
7
11
|
* unchanged. Host/runtime-only conditions may use the separate `client.*`
|
|
@@ -17,3 +21,41 @@ export class ClientSyncError extends Error {
|
|
|
17
21
|
this.retryable = retryable;
|
|
18
22
|
}
|
|
19
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* SPEC §7.5: classify a local SQLite read failure by the numeric result code
|
|
26
|
+
* the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm
|
|
27
|
+
* `resultCode`), never by message text. `error` is what the read throws: a
|
|
28
|
+
* stable `ClientSyncError` for corruption or I/O failure, otherwise the
|
|
29
|
+
* original value.
|
|
30
|
+
*/
|
|
31
|
+
export function classifySqliteFailure(error) {
|
|
32
|
+
const sqliteCode = error instanceof Error
|
|
33
|
+
? error.name === 'SQLiteError' &&
|
|
34
|
+
'errno' in error &&
|
|
35
|
+
typeof error.errno === 'number'
|
|
36
|
+
? error.errno
|
|
37
|
+
: 'errcode' in error && typeof error.errcode === 'number'
|
|
38
|
+
? error.errcode
|
|
39
|
+
: error.name === 'SQLite3Error' &&
|
|
40
|
+
'resultCode' in error &&
|
|
41
|
+
typeof error.resultCode === 'number'
|
|
42
|
+
? error.resultCode
|
|
43
|
+
: undefined
|
|
44
|
+
: undefined;
|
|
45
|
+
const primary = sqliteCode === undefined ? undefined : sqliteCode & 0xff;
|
|
46
|
+
if (primary === 11 || primary === 26) {
|
|
47
|
+
return {
|
|
48
|
+
sqliteCode,
|
|
49
|
+
code: STORAGE_CORRUPT_CODE,
|
|
50
|
+
error: new ClientSyncError(STORAGE_CORRUPT_CODE, 'local SQLite storage is corrupt'),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (primary === 10) {
|
|
54
|
+
return {
|
|
55
|
+
sqliteCode,
|
|
56
|
+
code: STORAGE_IO_CODE,
|
|
57
|
+
error: new ClientSyncError(STORAGE_IO_CODE, 'local SQLite storage I/O failed'),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { sqliteCode, code: undefined, error };
|
|
61
|
+
}
|
package/dist/reactive-store.js
CHANGED
|
@@ -445,6 +445,10 @@ class QueryEntry {
|
|
|
445
445
|
...(this.spec.coverage !== undefined
|
|
446
446
|
? { coverage: this.spec.coverage }
|
|
447
447
|
: {}),
|
|
448
|
+
owner: {
|
|
449
|
+
id: this.spec.id,
|
|
450
|
+
tables: this.spec.dependencies.map((dependency) => dependency.table),
|
|
451
|
+
},
|
|
448
452
|
});
|
|
449
453
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
450
454
|
return;
|
|
@@ -490,11 +494,12 @@ class QueryEntry {
|
|
|
490
494
|
catch (error) {
|
|
491
495
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
492
496
|
return;
|
|
493
|
-
|
|
497
|
+
// SPEC §7.5: a failed latest read is never `ready`; the last
|
|
498
|
+
// successful rows and revision stay available beside the error.
|
|
494
499
|
this.#publish({
|
|
495
500
|
...this.#state,
|
|
496
|
-
phase:
|
|
497
|
-
error:
|
|
501
|
+
phase: 'error',
|
|
502
|
+
error: errorOf(error),
|
|
498
503
|
isRefreshing: false,
|
|
499
504
|
availability: this.store.availabilitySnapshot(),
|
|
500
505
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
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.
|
|
92
|
+
"@syncular/core": "0.25.0"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.25.0",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -76,13 +76,15 @@ import {
|
|
|
76
76
|
type ClientDiagnosticsStorage,
|
|
77
77
|
type DiagnosticLastChange,
|
|
78
78
|
type DiagnosticLastRound,
|
|
79
|
+
type DiagnosticQueryFailure,
|
|
79
80
|
type DiagnosticRoundCounters,
|
|
80
81
|
type DiagnosticSubscription,
|
|
81
82
|
MAX_DIAGNOSTIC_DOMAINS,
|
|
82
83
|
MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
84
|
+
MAX_DIAGNOSTIC_QUERY_FAILURES,
|
|
83
85
|
} from './diagnostics';
|
|
84
86
|
import type { EncryptionConfig } from './encryption';
|
|
85
|
-
import { ClientSyncError } from './errors';
|
|
87
|
+
import { ClientSyncError, classifySqliteFailure } from './errors';
|
|
86
88
|
import {
|
|
87
89
|
ChangeAccumulator,
|
|
88
90
|
ChangeEmitter,
|
|
@@ -480,6 +482,17 @@ export interface QueryReadSpec {
|
|
|
480
482
|
readonly sql: string;
|
|
481
483
|
readonly params?: readonly SqlValue[];
|
|
482
484
|
readonly coverage?: readonly WindowCoverage[];
|
|
485
|
+
/**
|
|
486
|
+
* SPEC §7.5: the owning query. A failed owned read is reported in
|
|
487
|
+
* `diagnosticsSnapshot().queryFailures` until the owner reads successfully.
|
|
488
|
+
* The id is application-owned and must never contain PHI.
|
|
489
|
+
*/
|
|
490
|
+
readonly owner?: QueryOwner;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface QueryOwner {
|
|
494
|
+
readonly id: string;
|
|
495
|
+
readonly tables: readonly string[];
|
|
483
496
|
}
|
|
484
497
|
|
|
485
498
|
export interface QuerySnapshot<Row = SqlRow> {
|
|
@@ -757,6 +770,8 @@ export class SyncClient {
|
|
|
757
770
|
#diagnosticsPending = false;
|
|
758
771
|
#lastRound: DiagnosticLastRound | undefined;
|
|
759
772
|
#lastChange: DiagnosticLastChange | undefined;
|
|
773
|
+
/** SPEC §7.6: owner id -> latest failed owned read, oldest first. */
|
|
774
|
+
readonly #queryFailures = new Map<string, DiagnosticQueryFailure>();
|
|
760
775
|
/** The batch accumulator; non-undefined only inside `#applyBatch`. */
|
|
761
776
|
#batch: ChangeAccumulator | undefined;
|
|
762
777
|
/**
|
|
@@ -1250,7 +1265,11 @@ export class SyncClient {
|
|
|
1250
1265
|
assertReadOnlyQuery(sql);
|
|
1251
1266
|
// RFC 0005's container lives in a separate database file, so this
|
|
1252
1267
|
// connection cannot see it and there is no table-access guard to run.
|
|
1253
|
-
|
|
1268
|
+
try {
|
|
1269
|
+
return stripSyncColumns(this.#db.query(sql, params));
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
throw classifySqliteFailure(error).error;
|
|
1272
|
+
}
|
|
1254
1273
|
}
|
|
1255
1274
|
|
|
1256
1275
|
/** Current durable local observer revision (SPEC §7.5). */
|
|
@@ -1266,50 +1285,88 @@ export class SyncClient {
|
|
|
1266
1285
|
*/
|
|
1267
1286
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> {
|
|
1268
1287
|
this.#requireActive();
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1288
|
+
const owner = spec.owner;
|
|
1289
|
+
let snapshot: QuerySnapshot<Row>;
|
|
1290
|
+
try {
|
|
1291
|
+
assertReadOnlyQuery(spec.sql);
|
|
1292
|
+
snapshot = this.#db.transaction(() => {
|
|
1293
|
+
const revision = getLocalRevision(this.#db);
|
|
1294
|
+
const rows = stripSyncColumns(
|
|
1295
|
+
this.#db.query(spec.sql, spec.params),
|
|
1296
|
+
) as unknown as readonly Row[];
|
|
1297
|
+
const pending: WindowUnitRef[] = [];
|
|
1298
|
+
const missing: WindowUnitRef[] = [];
|
|
1299
|
+
for (const requested of spec.coverage ?? []) {
|
|
1300
|
+
const baseKey = windowBaseKey(requested.base);
|
|
1301
|
+
const live = new Map(
|
|
1302
|
+
loadWindowUnits(this.#db, baseKey).map((entry) => [
|
|
1303
|
+
entry.unit,
|
|
1304
|
+
entry.subId,
|
|
1305
|
+
]),
|
|
1306
|
+
);
|
|
1307
|
+
for (const unit of new Set(requested.units)) {
|
|
1308
|
+
const subId = live.get(unit);
|
|
1309
|
+
const ref = { baseKey, unit };
|
|
1310
|
+
if (subId === undefined) {
|
|
1311
|
+
missing.push(ref);
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
const sub = getSubscription(this.#db, subId);
|
|
1315
|
+
if (
|
|
1316
|
+
sub === undefined ||
|
|
1317
|
+
sub.status !== 'active' ||
|
|
1318
|
+
sub.cursor < 0 ||
|
|
1319
|
+
sub.bootstrapState !== undefined
|
|
1320
|
+
) {
|
|
1321
|
+
pending.push(ref);
|
|
1322
|
+
}
|
|
1291
1323
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1324
|
+
}
|
|
1325
|
+
return {
|
|
1326
|
+
revision,
|
|
1327
|
+
rows,
|
|
1328
|
+
coverage: {
|
|
1329
|
+
complete: pending.length === 0 && missing.length === 0,
|
|
1330
|
+
pending,
|
|
1331
|
+
missing,
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
});
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
const failure = classifySqliteFailure(error);
|
|
1337
|
+
const sqliteCode = failure.sqliteCode;
|
|
1338
|
+
const code = failure.code ?? 'client.query_failed';
|
|
1339
|
+
if (owner !== undefined) {
|
|
1340
|
+
const tables = [...new Set(owner.tables)].sort();
|
|
1341
|
+
const previous = this.#queryFailures.get(owner.id);
|
|
1342
|
+
if (
|
|
1343
|
+
previous === undefined ||
|
|
1344
|
+
previous.code !== code ||
|
|
1345
|
+
previous.sqliteCode !== sqliteCode ||
|
|
1346
|
+
previous.tables.join('\0') !== tables.join('\0')
|
|
1347
|
+
) {
|
|
1348
|
+
this.#queryFailures.delete(owner.id);
|
|
1349
|
+
this.#queryFailures.set(owner.id, {
|
|
1350
|
+
id: owner.id,
|
|
1351
|
+
tables,
|
|
1352
|
+
code,
|
|
1353
|
+
...(sqliteCode !== undefined ? { sqliteCode } : {}),
|
|
1354
|
+
atMs: this.#now(),
|
|
1355
|
+
});
|
|
1356
|
+
for (const id of this.#queryFailures.keys()) {
|
|
1357
|
+
if (this.#queryFailures.size <= MAX_DIAGNOSTIC_QUERY_FAILURES)
|
|
1358
|
+
break;
|
|
1359
|
+
this.#queryFailures.delete(id);
|
|
1300
1360
|
}
|
|
1361
|
+
this.#emitDiagnostics();
|
|
1301
1362
|
}
|
|
1302
1363
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
missing,
|
|
1310
|
-
},
|
|
1311
|
-
};
|
|
1312
|
-
});
|
|
1364
|
+
throw failure.error;
|
|
1365
|
+
}
|
|
1366
|
+
if (owner !== undefined && this.#queryFailures.delete(owner.id)) {
|
|
1367
|
+
this.#emitDiagnostics();
|
|
1368
|
+
}
|
|
1369
|
+
return snapshot;
|
|
1313
1370
|
}
|
|
1314
1371
|
|
|
1315
1372
|
// -- previous-version context (RFC 0005) ----------------------------------
|
|
@@ -1729,6 +1786,7 @@ export class SyncClient {
|
|
|
1729
1786
|
? { lastChange: this.#lastChange }
|
|
1730
1787
|
: {}),
|
|
1731
1788
|
storage: this.#diagnosticStorage(),
|
|
1789
|
+
queryFailures: [...this.#queryFailures.values()],
|
|
1732
1790
|
};
|
|
1733
1791
|
}
|
|
1734
1792
|
|
|
@@ -2751,7 +2809,24 @@ export class SyncClient {
|
|
|
2751
2809
|
);
|
|
2752
2810
|
}
|
|
2753
2811
|
}
|
|
2754
|
-
|
|
2812
|
+
try {
|
|
2813
|
+
this.#applyOperationsLocally(operations, batch);
|
|
2814
|
+
} catch (error) {
|
|
2815
|
+
if (
|
|
2816
|
+
error instanceof Error &&
|
|
2817
|
+
error.message.includes('UNIQUE constraint failed:') &&
|
|
2818
|
+
(('errno' in error && error.errno === 2067) ||
|
|
2819
|
+
('errcode' in error && error.errcode === 2067) ||
|
|
2820
|
+
('resultCode' in error &&
|
|
2821
|
+
(error.resultCode === 19 || error.resultCode === 2067)))
|
|
2822
|
+
) {
|
|
2823
|
+
throw new ClientSyncError(
|
|
2824
|
+
'sync.constraint_violation',
|
|
2825
|
+
'local write violates a unique constraint',
|
|
2826
|
+
);
|
|
2827
|
+
}
|
|
2828
|
+
throw error;
|
|
2829
|
+
}
|
|
2755
2830
|
batch.status();
|
|
2756
2831
|
});
|
|
2757
2832
|
this.#needsPull = true;
|
package/src/diagnostics.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { SecurityLifecycle } from './client';
|
|
|
10
10
|
export const CLIENT_DIAGNOSTICS_VERSION = 1 as const;
|
|
11
11
|
export const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
|
|
12
12
|
export const MAX_DIAGNOSTIC_DOMAINS = 256;
|
|
13
|
+
export const MAX_DIAGNOSTIC_QUERY_FAILURES = 256;
|
|
13
14
|
|
|
14
15
|
export type ClientDiagnosticsHostKind =
|
|
15
16
|
| 'direct'
|
|
@@ -127,6 +128,22 @@ export interface ClientDiagnosticsStorage {
|
|
|
127
128
|
readonly pressureReasonCode?: 'client.blob_cache_over_limit';
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
/** The latest owned snapshot read of one query id failed (SPEC §7.6). */
|
|
132
|
+
export interface DiagnosticQueryFailure {
|
|
133
|
+
/** Application-owned stable query id. It must never contain PHI. */
|
|
134
|
+
readonly id: string;
|
|
135
|
+
/** Distinct generated table names the query depends on, ascending. */
|
|
136
|
+
readonly tables: readonly string[];
|
|
137
|
+
readonly code:
|
|
138
|
+
| 'client.storage_corrupt'
|
|
139
|
+
| 'client.storage_io'
|
|
140
|
+
| 'client.query_failed';
|
|
141
|
+
/** SQLite extended result code, when the driver exposed one. */
|
|
142
|
+
readonly sqliteCode?: number;
|
|
143
|
+
/** First failure since this id's last successful read. */
|
|
144
|
+
readonly atMs: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
export interface ClientDiagnosticsSnapshot {
|
|
131
148
|
readonly version: typeof CLIENT_DIAGNOSTICS_VERSION;
|
|
132
149
|
readonly capturedAtMs: number;
|
|
@@ -154,6 +171,7 @@ export interface ClientDiagnosticsSnapshot {
|
|
|
154
171
|
readonly lastRound?: DiagnosticLastRound;
|
|
155
172
|
readonly lastChange?: DiagnosticLastChange;
|
|
156
173
|
readonly storage: ClientDiagnosticsStorage;
|
|
174
|
+
readonly queryFailures: readonly DiagnosticQueryFailure[];
|
|
157
175
|
}
|
|
158
176
|
|
|
159
177
|
export type ClientDiagnosticsListener = (
|
package/src/errors.ts
CHANGED
|
@@ -4,6 +4,12 @@ export const STORAGE_BUSY_CODE = 'client.storage_busy';
|
|
|
4
4
|
/** The browser cannot provide the persistent storage APIs Syncular requires. */
|
|
5
5
|
export const STORAGE_UNAVAILABLE_CODE = 'client.storage_unavailable';
|
|
6
6
|
|
|
7
|
+
/** A local read hit SQLITE_CORRUPT or SQLITE_NOTADB (SPEC §7.5). */
|
|
8
|
+
export const STORAGE_CORRUPT_CODE = 'client.storage_corrupt';
|
|
9
|
+
|
|
10
|
+
/** A local read hit SQLITE_IOERR (SPEC §7.5). */
|
|
11
|
+
export const STORAGE_IO_CODE = 'client.storage_io';
|
|
12
|
+
|
|
7
13
|
/**
|
|
8
14
|
* Client-side errors. Protocol codes from the SPEC.md §10 catalog are surfaced
|
|
9
15
|
* unchanged. Host/runtime-only conditions may use the separate `client.*`
|
|
@@ -20,3 +26,56 @@ export class ClientSyncError extends Error {
|
|
|
20
26
|
this.retryable = retryable;
|
|
21
27
|
}
|
|
22
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* SPEC §7.5: classify a local SQLite read failure by the numeric result code
|
|
32
|
+
* the driver exposes (bun:sqlite `errno`, node:sqlite `errcode`, sqlite-wasm
|
|
33
|
+
* `resultCode`), never by message text. `error` is what the read throws: a
|
|
34
|
+
* stable `ClientSyncError` for corruption or I/O failure, otherwise the
|
|
35
|
+
* original value.
|
|
36
|
+
*/
|
|
37
|
+
export function classifySqliteFailure(error: unknown): {
|
|
38
|
+
readonly sqliteCode: number | undefined;
|
|
39
|
+
readonly code:
|
|
40
|
+
| typeof STORAGE_CORRUPT_CODE
|
|
41
|
+
| typeof STORAGE_IO_CODE
|
|
42
|
+
| undefined;
|
|
43
|
+
readonly error: unknown;
|
|
44
|
+
} {
|
|
45
|
+
const sqliteCode =
|
|
46
|
+
error instanceof Error
|
|
47
|
+
? error.name === 'SQLiteError' &&
|
|
48
|
+
'errno' in error &&
|
|
49
|
+
typeof error.errno === 'number'
|
|
50
|
+
? error.errno
|
|
51
|
+
: 'errcode' in error && typeof error.errcode === 'number'
|
|
52
|
+
? error.errcode
|
|
53
|
+
: error.name === 'SQLite3Error' &&
|
|
54
|
+
'resultCode' in error &&
|
|
55
|
+
typeof error.resultCode === 'number'
|
|
56
|
+
? error.resultCode
|
|
57
|
+
: undefined
|
|
58
|
+
: undefined;
|
|
59
|
+
const primary = sqliteCode === undefined ? undefined : sqliteCode & 0xff;
|
|
60
|
+
if (primary === 11 || primary === 26) {
|
|
61
|
+
return {
|
|
62
|
+
sqliteCode,
|
|
63
|
+
code: STORAGE_CORRUPT_CODE,
|
|
64
|
+
error: new ClientSyncError(
|
|
65
|
+
STORAGE_CORRUPT_CODE,
|
|
66
|
+
'local SQLite storage is corrupt',
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (primary === 10) {
|
|
71
|
+
return {
|
|
72
|
+
sqliteCode,
|
|
73
|
+
code: STORAGE_IO_CODE,
|
|
74
|
+
error: new ClientSyncError(
|
|
75
|
+
STORAGE_IO_CODE,
|
|
76
|
+
'local SQLite storage I/O failed',
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { sqliteCode, code: undefined, error };
|
|
81
|
+
}
|
package/src/reactive-store.ts
CHANGED
|
@@ -572,6 +572,12 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
572
572
|
...(this.spec.coverage !== undefined
|
|
573
573
|
? { coverage: this.spec.coverage }
|
|
574
574
|
: {}),
|
|
575
|
+
owner: {
|
|
576
|
+
id: this.spec.id,
|
|
577
|
+
tables: this.spec.dependencies.map(
|
|
578
|
+
(dependency) => dependency.table,
|
|
579
|
+
),
|
|
580
|
+
},
|
|
575
581
|
});
|
|
576
582
|
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
577
583
|
return;
|
|
@@ -621,11 +627,12 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
621
627
|
} while (this.#requested && this.#listeners.size > 0);
|
|
622
628
|
} catch (error) {
|
|
623
629
|
if (generation !== this.#generation || this.#listeners.size === 0) return;
|
|
624
|
-
|
|
630
|
+
// SPEC §7.5: a failed latest read is never `ready`; the last
|
|
631
|
+
// successful rows and revision stay available beside the error.
|
|
625
632
|
this.#publish({
|
|
626
633
|
...this.#state,
|
|
627
|
-
phase:
|
|
628
|
-
error:
|
|
634
|
+
phase: 'error',
|
|
635
|
+
error: errorOf(error),
|
|
629
636
|
isRefreshing: false,
|
|
630
637
|
availability: this.store.availabilitySnapshot(),
|
|
631
638
|
});
|