@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 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
- Worker leaders and followers return identical evidence with their honest role.
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
- return stripSyncColumns(this.#db.query(sql, params));
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
- assertReadOnlyQuery(spec.sql);
622
- return this.#db.transaction(() => {
623
- const revision = getLocalRevision(this.#db);
624
- const rows = stripSyncColumns(this.#db.query(spec.sql, spec.params));
625
- const pending = [];
626
- const missing = [];
627
- for (const requested of spec.coverage ?? []) {
628
- const baseKey = windowBaseKey(requested.base);
629
- const live = new Map(loadWindowUnits(this.#db, baseKey).map((entry) => [
630
- entry.unit,
631
- entry.subId,
632
- ]));
633
- for (const unit of new Set(requested.units)) {
634
- const subId = live.get(unit);
635
- const ref = { baseKey, unit };
636
- if (subId === undefined) {
637
- missing.push(ref);
638
- continue;
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
- const sub = getSubscription(this.#db, subId);
641
- if (sub === undefined ||
642
- sub.status !== 'active' ||
643
- sub.cursor < 0 ||
644
- sub.bootstrapState !== undefined) {
645
- pending.push(ref);
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
- return {
650
- revision,
651
- rows,
652
- coverage: {
653
- complete: pending.length === 0 && missing.length === 0,
654
- pending,
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
- this.#applyOperationsLocally(operations, batch);
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;
@@ -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 {
@@ -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
+ }
@@ -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
- const wrapped = errorOf(error);
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: this.#state.revision === undefined ? 'error' : this.#state.phase,
497
- error: wrapped,
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.24.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.24.0"
92
+ "@syncular/core": "0.25.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.24.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
- return stripSyncColumns(this.#db.query(sql, params));
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
- assertReadOnlyQuery(spec.sql);
1270
- return this.#db.transaction(() => {
1271
- const revision = getLocalRevision(this.#db);
1272
- const rows = stripSyncColumns(
1273
- this.#db.query(spec.sql, spec.params),
1274
- ) as unknown as readonly Row[];
1275
- const pending: WindowUnitRef[] = [];
1276
- const missing: WindowUnitRef[] = [];
1277
- for (const requested of spec.coverage ?? []) {
1278
- const baseKey = windowBaseKey(requested.base);
1279
- const live = new Map(
1280
- loadWindowUnits(this.#db, baseKey).map((entry) => [
1281
- entry.unit,
1282
- entry.subId,
1283
- ]),
1284
- );
1285
- for (const unit of new Set(requested.units)) {
1286
- const subId = live.get(unit);
1287
- const ref = { baseKey, unit };
1288
- if (subId === undefined) {
1289
- missing.push(ref);
1290
- continue;
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
- const sub = getSubscription(this.#db, subId);
1293
- if (
1294
- sub === undefined ||
1295
- sub.status !== 'active' ||
1296
- sub.cursor < 0 ||
1297
- sub.bootstrapState !== undefined
1298
- ) {
1299
- pending.push(ref);
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
- return {
1304
- revision,
1305
- rows,
1306
- coverage: {
1307
- complete: pending.length === 0 && missing.length === 0,
1308
- pending,
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
- this.#applyOperationsLocally(operations, batch);
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;
@@ -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
+ }
@@ -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
- const wrapped = errorOf(error);
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: this.#state.revision === undefined ? 'error' : this.#state.phase,
628
- error: wrapped,
634
+ phase: 'error',
635
+ error: errorOf(error),
629
636
  isRefreshing: false,
630
637
  availability: this.store.availabilitySnapshot(),
631
638
  });