@jarenjs/db 0.56.0 → 0.67.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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
package/src/errors.js CHANGED
@@ -13,34 +13,28 @@
13
13
 
14
14
  import { CodedError } from '@jarenjs/core/errors';
15
15
 
16
+ /** The driver's own settlement envelope; arbitrary caller aggregates stay opaque. */
17
+ export class TransactionFailure extends AggregateError {
18
+ /** @param {any} error @param {any} cleanupError */
19
+ constructor(error, cleanupError) {
20
+ super([error, cleanupError], 'the transaction failed, and rolling it back failed too');
21
+ }
22
+ }
23
+
16
24
  /**
17
25
  * The runtime code table (the `CSV_CODES` shape): one entry per code
18
26
  * this package can raise, proven in sync with MODEL-FORMAT.md §7's
19
27
  * normative table by a test.
20
28
  */
21
- /**
22
- * Whether a database error is the unique-key collision of `table.column`
23
- * — the one failure every insert path reports as `JD2001` rather than
24
- * the generic `JD2005`. One detector for the collection cores, the
25
- * entity cores and the unit of work, so no path wraps it differently.
26
- * @param {any} error
27
- * @param {string} table
28
- * @param {string} column
29
- * @returns {boolean}
30
- */
31
- export function isDuplicateKeyError(error, table, column) {
32
- if (error?.errcode === 1555) return true;
33
- return typeof error?.message === 'string'
34
- && error.message.includes('UNIQUE constraint failed')
35
- && error.message.includes(`${table}.${column}`);
36
- }
37
-
38
29
  export const DB_CODES = Object.freeze({
39
30
  JD0001: 'the SQLite library is below the supported floor',
40
- JD0002: 'the declared model disagrees with the existing database',
31
+ JD0002: 'the existing database disagrees with the declared model, or the open failed in the driver',
41
32
  JD0003: 'the driver binding is unavailable on this runtime',
42
33
  JD0004: 'a declared index cannot be mapped to a column',
43
34
  JD0005: 'the model document is invalid',
35
+ JD0006: 'an open option named a pragma this store does not configure',
36
+ JD0007: 'the pragma cannot be applied on this driver or store',
37
+ JD0008: 'a pragma did not take: the read-back disagrees with the request',
44
38
  JD0010: 'strict mode refused a residual',
45
39
  JD0011: 'the profile refused the document',
46
40
  JD0012: 'work waited too long for the open transaction to settle',
@@ -48,15 +42,28 @@ export const DB_CODES = Object.freeze({
48
42
  JD0031: 'relation declarations contradict each other',
49
43
  JD0032: 'the include specification is invalid',
50
44
  JD0033: 'an entity query names no entity array',
45
+ JD0034: 'a tracked cursor needs a bare entity return',
46
+ JD0035: 'the continuation does not belong to this ordering',
47
+ JD0036: 'a snapshot page needs an immutable ordering',
48
+ JD0037: 'strictStreaming refused a plan that buffers',
51
49
  JD0040: 'the save spans a relation cycle',
52
50
  JD0050: 'live queries require change capture',
53
51
  JD0051: 'the demanded live mode is unavailable',
54
52
  JD0052: 'the live-query bound was reached',
55
53
  JD0053: 'the live event-time declaration is invalid',
54
+ JD0060: 'the replication document is invalid',
55
+ JD2100: 'replication encountered a sequence or causal gap',
56
+ JD2101: 'an envelope identity has a different payload or origin',
57
+ JD2102: 'the replica identity or model revision disagrees',
58
+ JD2103: 'the conflict resolver returned an invalid decision',
59
+ JD2104: 'a logical row disagrees with its replication history',
60
+ JD2105: 'replication requires an explicit snapshot reset',
61
+ JD2106: 'replication exceeded its operation bound',
56
62
  JD0020: "the migration's from-shape does not match the database",
57
63
  JD0021: 'the migration is missing a required data transform',
58
64
  JD0022: 'an applied migration disagrees with the history record',
59
65
  JD0023: 'a migration step failed',
66
+ JD0024: 'a document source or target could not be read or written',
60
67
  JD2001: 'insert found the key already present',
61
68
  JD2002: 'a usable key could not be resolved for the write',
62
69
  JD2003: 'the write failed schema validation',
@@ -71,6 +78,37 @@ export const DB_CODES = Object.freeze({
71
78
  JD2061: 'another context owns the database',
72
79
  JD2062: 'the store closed with job handlers still in flight',
73
80
  JD2063: 'the store is closed',
81
+ JD2064: 'the call was aborted while it waited for the open transaction',
82
+ JD2065: 'the job is not leased — it is unknown, or already settled',
83
+ JD2066: 'the lease was superseded by a newer claim or renewal',
84
+ JD2067: 'the lease expired before the call',
85
+ JD2068: 'a settling call needs the lease the claim returned',
86
+ JD2069: 'a resumed run does not match the workflow or input it was checkpointed under',
87
+ JD2070: 'the transaction handle does not belong to the live scope',
88
+ JD2071: 'the savepoint label is blank, duplicate or unknown',
89
+ JD2072: 'the call was aborted before its next row',
90
+ JD2073: 'an include exceeded its per-root bound',
91
+ JD2074: 'an item exceeds the page byte bound',
92
+ JD2075: 'the deadline passed before the next unit of work',
93
+ JD2076: 'an item exceeds the profile byte bound',
94
+ JD2077: 'the maintenance operation is unavailable on this store',
95
+ JD2078: 'the maintenance operation failed',
96
+ JD2079: 'the backup was cancelled',
97
+ JD2080: 'the migration was cancelled between steps',
98
+ JD2081: 'the maintenance operation was cancelled',
99
+ JD2082: 'the database or its disk is full',
100
+ JD2083: 'the database is read-only',
101
+ JD2084: 'a disk I/O error',
102
+ JD2085: 'the database file is corrupt or not a database',
103
+ JD2086: 'a seek anchor came back with a type the plan did not declare',
104
+ JD2087: 'the connection to the database was lost',
105
+ JD2088: 'the transaction was aborted by an earlier failure in it',
106
+ JD2089: 'the statement was cancelled by the server',
107
+ JD2090: 'the worker connection generation was lost',
108
+ JD2091: 'the worker admission queue is full',
109
+ JD2092: 'a worker transport bound was exceeded',
110
+ JD2093: 'the worker protocol frame is invalid',
111
+ JD2094: 'the durable snapshot failed and the connection is invalid',
74
112
  });
75
113
 
76
114
  /**
@@ -82,7 +120,10 @@ export const DB_CODES = Object.freeze({
82
120
  * supported floor; the reason names the version found
83
121
  * - `JD0002` — a declared collection already exists in the database
84
122
  * with a different shape; nothing was altered — changing shape is
85
- * the migration story, a later capability
123
+ * the migration story, a later capability. Also a driver failure
124
+ * anywhere in the open sequence (an unopenable path, a corrupt or
125
+ * locked file): the error carries `class`/`retryable` from the
126
+ * classifier and the driver's error as `cause`
86
127
  * - `JD0003` — the runtime builtin behind a driver could not be
87
128
  * loaded here (Node cannot resolve `bun:`; Bun ships no
88
129
  * `node:sqlite`), or an injected handle is missing
@@ -93,6 +134,15 @@ export const DB_CODES = Object.freeze({
93
134
  * names the expression or the member and `docPath` points at it
94
135
  * - `JD0005` — the model document is invalid; `docPath` points at
95
136
  * the offending member
137
+ * - `JD0006` — an `openStore` option named a pragma outside the closed
138
+ * configurable set (`foreign_keys`, `page_size`, a snake-case
139
+ * spelling of a member); the reason names the option and the set
140
+ * - `JD0007` — a requested pragma cannot be applied here: the driver's
141
+ * binding does not declare it, or the store kind refuses it (a
142
+ * journal-mode write on a read-only store)
143
+ * - `JD0008` — a requested pragma did not take: the value read back
144
+ * from the connection after the open sequence disagrees with the
145
+ * request; the reason carries both, and the store did not open
96
146
  * - `JD0010` — `strict: true` and part of the query would have run
97
147
  * outside the database; the reason names the forcing construct
98
148
  * - `JD0011` — the active profile refused the document before any
@@ -177,7 +227,349 @@ export class DbCompileError extends CodedError {
177
227
  * - `JD2063` — a call after `close()`: every entry point of a closed
178
228
  * store refuses by name rather than leaking the driver's own error,
179
229
  * and a second `close()` is a no-op on every driver
230
+ * - `JD2077` — a maintenance operation (`checkpoint`, `integrityCheck`,
231
+ * `foreignKeyCheck`, `optimize`, `backupTo`) is unavailable here:
232
+ * the driver's binding does not declare it, or the store is read-only
233
+ * and the operation writes; `capabilities.maintenance` says which
234
+ * - `JD2078` — a maintenance operation failed in the driver; the
235
+ * original error is the `cause`. Corruption an integrity check finds
236
+ * is its RESULT, never this error
237
+ * - `JD2079` — a backup was cancelled through its signal between
238
+ * pages: the temporary file was removed and the target path was not
239
+ * written; the signal's reason is the `cause`
240
+ * - `JD2080` — a migration was cancelled through its signal between
241
+ * migrations, steps or batches: the migration in flight rolled back
242
+ * whole, the completed ones stand, a rerun resumes from the recorded
243
+ * position
244
+ * - `JD2081` — a maintenance operation was cancelled through its signal
245
+ * before its statement ran
246
+ * - `JD2086` — a seek anchor came back from the database with a type
247
+ * the plan did not declare: the plan reads an anchor from a column
248
+ * of declared type and binds it through a TYPED slot, so a value of
249
+ * another type is a defect below the store, refused before the
250
+ * statement it would have bound runs
251
+ * - `JD2082`/`JD2083`/`JD2084`/`JD2085` — the driver reported a full
252
+ * disk, a read-only database, an I/O error, a corrupt file: one
253
+ * classifier (`classifyDriverError`) assigns them, whichever path met
254
+ * the failure, and every classified error carries `class`,
255
+ * `retryable` and the driver's error as `cause`; a busy or locked
256
+ * database stays `JD2005` with `class: 'busy'` and `retryable: true`
257
+ */
258
+
259
+ /**
260
+ * The ONE classification of a driver failure. Every path that
261
+ * wraps a driver error — the collection and entity writes, the job
262
+ * queue, the query path, the maintenance operations, the backup, the
263
+ * open sequence — consults this table and nothing else, so a locked
264
+ * database, a full disk, a read-only file, an I/O error and a corrupt
265
+ * file each arrive under one code with one stable `class` and one
266
+ * `retryable` verdict, whichever path met them.
267
+ *
268
+ * The engine's primary result code is the low byte of the extended
269
+ * one the bindings expose (`errcode` on node:sqlite, `errno` on
270
+ * bun:sqlite, `resultCode` on the wasm build's `SQLite3Error`); the
271
+ * message is consulted for the two facts a code alone does not carry —
272
+ * an int64 overflow is a generic `SQLITE_ERROR` (1) with the words
273
+ * `integer overflow`, and a duplicate key is a UNIQUE constraint whose
274
+ * message names the table and column.
275
+ *
276
+ * The engine is discriminated by the EVIDENCE the error carries, not by
277
+ * a table threaded down from the caller: a SQLite binding attaches a
278
+ * numeric result code, a PostgreSQL one attaches a five-character
279
+ * SQLSTATE. Both land in the same closed set of classes and the same
280
+ * `retryable` verdict, so a caller that already handles a busy SQLite
281
+ * database handles a deadlocked PostgreSQL transaction with no new
282
+ * branch. Neither table ever reads a connection string, so nothing a
283
+ * URL carried can reach a message.
284
+ */
285
+
286
+ /** SQLite primary result codes, by name, as the table reads them. */
287
+ const RESULT = Object.freeze({
288
+ ERROR: 1, BUSY: 5, LOCKED: 6, READONLY: 8, IOERR: 10, CORRUPT: 11, FULL: 13,
289
+ CANTOPEN: 14, CONSTRAINT: 19, NOTADB: 26,
290
+ });
291
+
292
+ /**
293
+ * The classes, in the order they are tried; `code` is the runtime code
294
+ * a wrapped error carries (`null` for the overflow row, which the query
295
+ * path answers by re-running the document in the engine rather than by
296
+ * raising) and `reason` the sentence the wrapped error leads with.
180
297
  */
298
+ const CLASSES = Object.freeze([
299
+ Object.freeze({ class: 'busy', primaries: [RESULT.BUSY, RESULT.LOCKED], code: 'JD2005',
300
+ retryable: true, reason: 'the database is busy or locked' }),
301
+ Object.freeze({ class: 'full', primaries: [RESULT.FULL], code: 'JD2082',
302
+ retryable: false, reason: 'the database or its disk is full' }),
303
+ Object.freeze({ class: 'readonly', primaries: [RESULT.READONLY], code: 'JD2083',
304
+ retryable: false, reason: 'the database is read-only' }),
305
+ Object.freeze({ class: 'io', primaries: [RESULT.IOERR], code: 'JD2084',
306
+ retryable: false, reason: 'a disk I/O error' }),
307
+ Object.freeze({ class: 'corrupt', primaries: [RESULT.CORRUPT, RESULT.NOTADB], code: 'JD2085',
308
+ retryable: false, reason: 'the database file is corrupt or not a database' }),
309
+ Object.freeze({ class: 'cantopen', primaries: [RESULT.CANTOPEN], code: 'JD2005',
310
+ retryable: false, reason: 'the database file could not be opened' }),
311
+ Object.freeze({ class: 'constraint', primaries: [RESULT.CONSTRAINT], code: 'JD2005',
312
+ retryable: false, reason: 'the database rejected the operation' }),
313
+ ]);
314
+
315
+ const FALLBACK = Object.freeze({ class: 'error', code: 'JD2005', retryable: false,
316
+ reason: 'the database rejected the operation' });
317
+
318
+ /**
319
+ * PostgreSQL SQLSTATEs, into the SAME classes. Exact codes first, then
320
+ * the two-character class for everything else in a family — which is
321
+ * how a server-side condition nobody enumerated still lands somewhere
322
+ * honest rather than in the fallback.
323
+ *
324
+ * `duplicate` is decided by the calling path, exactly as it is on
325
+ * SQLite: a `unique_violation` is only the KEY's collision when the
326
+ * constraint the server names is the key's own.
327
+ */
328
+ const SQLSTATE = Object.freeze({
329
+ // 23 — integrity constraint violation
330
+ '23505': { class: 'constraint', code: 'JD2005', retryable: false,
331
+ reason: 'the database rejected the operation' },
332
+ // 40 — transaction rollback: both are the caller's to retry
333
+ '40001': { class: 'busy', code: 'JD2005', retryable: true,
334
+ reason: 'the transaction could not be serialized' },
335
+ '40P01': { class: 'busy', code: 'JD2005', retryable: true,
336
+ reason: 'the transaction deadlocked' },
337
+ // 55 — object not in prerequisite state
338
+ '55P03': { class: 'busy', code: 'JD2005', retryable: true,
339
+ reason: 'the database is busy or locked' },
340
+ '55006': { class: 'busy', code: 'JD2005', retryable: true,
341
+ reason: 'the database is busy or locked' },
342
+ // 57 — operator intervention
343
+ '57014': { class: 'cancelled', code: 'JD2089', retryable: false,
344
+ reason: 'the statement was cancelled by the server' },
345
+ '57P01': { class: 'connection', code: 'JD2087', retryable: true,
346
+ reason: 'the connection to the database was lost' },
347
+ '57P02': { class: 'connection', code: 'JD2087', retryable: true,
348
+ reason: 'the connection to the database was lost' },
349
+ '57P03': { class: 'connection', code: 'JD2087', retryable: true,
350
+ reason: 'the connection to the database was lost' },
351
+ // 53 — insufficient resources
352
+ '53100': { class: 'full', code: 'JD2082', retryable: false,
353
+ reason: 'the database or its disk is full' },
354
+ '53300': { class: 'busy', code: 'JD2005', retryable: true,
355
+ reason: 'the database is busy or locked' },
356
+ // 58 — system error
357
+ '58030': { class: 'io', code: 'JD2084', retryable: false, reason: 'a disk I/O error' },
358
+ // 25 — invalid transaction state
359
+ '25P02': { class: 'aborted', code: 'JD2088', retryable: false,
360
+ reason: 'the transaction was aborted by an earlier failure in it' },
361
+ '25006': { class: 'readonly', code: 'JD2083', retryable: false,
362
+ reason: 'the database is read-only' },
363
+ // 3D/3F — the catalog or schema the connection named does not exist
364
+ '3D000': { class: 'cantopen', code: 'JD2005', retryable: false,
365
+ reason: 'the database could not be opened' },
366
+ '3F000': { class: 'cantopen', code: 'JD2005', retryable: false,
367
+ reason: 'the database could not be opened' },
368
+ // 42501 — insufficient privilege reads as read-only: the operation is
369
+ // refused for want of write rights, which is what the class means
370
+ '42501': { class: 'readonly', code: 'JD2083', retryable: false,
371
+ reason: 'the database is read-only' },
372
+ // XX — internal error
373
+ 'XX001': { class: 'corrupt', code: 'JD2085', retryable: false,
374
+ reason: 'the database file is corrupt or not a database' },
375
+ 'XX002': { class: 'corrupt', code: 'JD2085', retryable: false,
376
+ reason: 'the database file is corrupt or not a database' },
377
+ });
378
+
379
+ /** The family fallbacks, by SQLSTATE class (the first two characters). */
380
+ const SQLSTATE_FAMILY = Object.freeze({
381
+ '23': { class: 'constraint', code: 'JD2005', retryable: false,
382
+ reason: 'the database rejected the operation' },
383
+ '40': { class: 'busy', code: 'JD2005', retryable: true,
384
+ reason: 'the transaction could not be completed' },
385
+ '08': { class: 'connection', code: 'JD2087', retryable: true,
386
+ reason: 'the connection to the database was lost' },
387
+ '53': { class: 'full', code: 'JD2082', retryable: false,
388
+ reason: 'the database or its disk is full' },
389
+ '55': { class: 'busy', code: 'JD2005', retryable: true,
390
+ reason: 'the database is busy or locked' },
391
+ '57': { class: 'connection', code: 'JD2087', retryable: true,
392
+ reason: 'the connection to the database was lost' },
393
+ '58': { class: 'io', code: 'JD2084', retryable: false, reason: 'a disk I/O error' },
394
+ 'XX': { class: 'corrupt', code: 'JD2085', retryable: false,
395
+ reason: 'the database file is corrupt or not a database' },
396
+ });
397
+
398
+ /** A five-character SQLSTATE, or `null` for an error that carries none. */
399
+ const SQLSTATE_SHAPE = /^[0-9A-Z]{5}$/;
400
+
401
+ /**
402
+ * @param {any} error
403
+ * @returns {string | null}
404
+ */
405
+ function sqlStateOf(error) {
406
+ if (error === null || typeof error !== 'object') return null;
407
+ const code = error.code;
408
+ return typeof code === 'string' && SQLSTATE_SHAPE.test(code) ? code : null;
409
+ }
410
+
411
+ /**
412
+ * The numeric result code a binding attached, or `null` for an error
413
+ * that is not a driver's.
414
+ * @param {any} error
415
+ * @returns {number | null}
416
+ */
417
+ function resultCodeOf(error) {
418
+ if (error === null || typeof error !== 'object') return null;
419
+ for (const member of ['errcode', 'errno', 'resultCode']) {
420
+ const value = error[member];
421
+ if (typeof value === 'number' && Number.isInteger(value)) return value;
422
+ }
423
+ return null;
424
+ }
425
+
426
+ /**
427
+ * Whether an error is a SQLite driver's own: it carries a numeric
428
+ * result code, or the shape node:sqlite gives its errors.
429
+ * @param {any} error
430
+ * @returns {boolean}
431
+ */
432
+ export function isDriverError(error) {
433
+ return resultCodeOf(error) !== null
434
+ || sqlStateOf(error) !== null
435
+ || error?.code === 'ERR_SQLITE_ERROR'
436
+ || error?.name === 'SQLiteError' || error?.name === 'SQLite3Error';
437
+ }
438
+
439
+ /**
440
+ * Classify a driver failure.
441
+ * @param {any} error - the driver's error
442
+ * @param {{ table: string, column: string }} [unique] - the unique key
443
+ * a write was inserting under, so its collision classifies as
444
+ * `duplicate` (the `JD2001` every insert path reports)
445
+ * @returns {{ class: string, code: string | null, retryable: boolean, reason: string }}
446
+ */
447
+ export function classifyDriverError(error, unique = undefined) {
448
+ const state = sqlStateOf(error);
449
+ if (state !== null) return classifySqlState(state, error, unique);
450
+ const extended = resultCodeOf(error);
451
+ const primary = extended === null ? null : extended & 0xff;
452
+ const message = typeof error?.message === 'string' ? error.message : '';
453
+ // the key's own collision: a PRIMARY KEY conflict (1555), or a UNIQUE
454
+ // conflict whose message names the key column — a unique INDEX over
455
+ // another column is a constraint failure, not a duplicate key
456
+ if (unique !== undefined && (extended === 1555
457
+ || (message.includes('UNIQUE constraint failed') && message.includes(`${unique.table}.${unique.column}`)))) {
458
+ return { class: 'duplicate', code: 'JD2001', retryable: false, reason: 'the key is already present' };
459
+ }
460
+ if (primary === RESULT.ERROR && message.includes('integer overflow')) {
461
+ return { class: 'overflow', code: null, retryable: false,
462
+ reason: 'an integer aggregate overflowed int64' };
463
+ }
464
+ // a locked database reported by a binding that attaches no code
465
+ if (primary === null && /database is locked|database table is locked/.test(message)) {
466
+ return { ...CLASSES[0] };
467
+ }
468
+ for (const row of CLASSES) {
469
+ if (primary !== null && row.primaries.includes(primary))
470
+ return { class: row.class, code: row.code, retryable: row.retryable, reason: row.reason };
471
+ }
472
+ return { ...FALLBACK };
473
+ }
474
+
475
+ /**
476
+ * The PostgreSQL half: a SQLSTATE into the same closed classes. The
477
+ * server names the constraint it rejected against, so the key's own
478
+ * collision is decided by NAME rather than by parsing a message —
479
+ * `<table>_pkey` is the primary key a collection's key column carries,
480
+ * and a unique INDEX over another column stays a constraint failure.
481
+ * @param {string} state
482
+ * @param {any} error
483
+ * @param {{ table: string, column: string }} [unique]
484
+ * @returns {{ class: string, code: string | null, retryable: boolean, reason: string }}
485
+ */
486
+ function classifySqlState(state, error, unique) {
487
+ if (state === '23505' && unique !== undefined) {
488
+ const constraint = typeof error?.constraint === 'string' ? error.constraint : '';
489
+ if (constraint === `${unique.table}_pkey` || constraint === `${unique.table}_${unique.column}_key`) {
490
+ return { class: 'duplicate', code: 'JD2001', retryable: false,
491
+ reason: 'the key is already present' };
492
+ }
493
+ }
494
+ // an integer that will not fit the column: SQLite reports it as a
495
+ // generic error naming the overflow, PostgreSQL as numeric_value_out_of_range
496
+ if (state === '22003') {
497
+ return { class: 'overflow', code: null, retryable: false,
498
+ reason: 'an integer aggregate overflowed int64' };
499
+ }
500
+ const exact = SQLSTATE[state];
501
+ if (exact !== undefined) return { ...exact };
502
+ const family = SQLSTATE_FAMILY[state.slice(0, 2)];
503
+ if (family !== undefined) return { ...family };
504
+ return { ...FALLBACK };
505
+ }
506
+
507
+ /**
508
+ * Wrap a driver failure as the coded runtime error its class calls
509
+ * for, with `class` and `retryable` as own members and the original as
510
+ * `cause`. Anything that is not a driver's own error — a coded refusal
511
+ * of this package or of the query engine (a `JQ` error thrown inside a
512
+ * pushed user function), an API-misuse `TypeError` — is the error, and
513
+ * passes through untouched.
514
+ * @param {any} error
515
+ * @param {{ docPath?: string, collection?: string, key?: string | number,
516
+ * unique?: { table: string, column: string },
517
+ * duplicateReason?: string, code?: string, reason?: string,
518
+ * always?: boolean }} details - `duplicateReason` is the `JD2001`
519
+ * sentence the calling path composes (it names the key); `code` and
520
+ * `reason` let a lifecycle that owns its failure code (a maintenance
521
+ * operation's `JD2078`) keep its code and leading sentence while the
522
+ * class and the verdict still come from the one table; `always`
523
+ * wraps even a failure that is not a driver's (a closed handle's
524
+ * state error, a file-system refusal) under that lifecycle code, with
525
+ * `class: 'error'` — a lifecycle that promises a coded failure keeps
526
+ * the promise for every uncoded error it meets
527
+ * @returns {Error}
528
+ */
529
+ export function wrapDriverError(error, details = {}) {
530
+ if (error instanceof TransactionFailure) {
531
+ let first = error;
532
+ const seen = new Set();
533
+ while (first instanceof TransactionFailure) {
534
+ if (seen.has(first)) return error;
535
+ seen.add(first);
536
+ first = first.errors[0];
537
+ }
538
+ const primary = wrapDriverError(first, details);
539
+ if (typeof primary?.code === 'string' && /^J[A-Z]\d{4}$/.test(primary.code)) {
540
+ // Keep the original and the cleanup failure together, while callers
541
+ // continue to branch on the original classified failure and its cause.
542
+ for (const member of ['code', 'reason', 'class', 'retryable', 'docPath', 'collection', 'key', 'cause']) {
543
+ if (Object.hasOwn(primary, member)) error[member] = primary[member];
544
+ }
545
+ }
546
+ return error;
547
+ }
548
+ if (typeof error?.code === 'string' && /^J[A-Z]\d{4}$/.test(error.code)) return error;
549
+ if (!isDriverError(error)) {
550
+ if (details.always !== true) return error;
551
+ const generic = new DbRuntimeError(details.code ?? 'JD2005',
552
+ `${details.reason ?? FALLBACK.reason}: ${error?.message ?? String(error)}`, { cause: error });
553
+ generic.class = 'error';
554
+ generic.retryable = false;
555
+ return generic;
556
+ }
557
+ const classified = classifyDriverError(error, details.unique);
558
+ const message = error?.message ?? String(error);
559
+ /** @type {any} */
560
+ const own = { cause: error };
561
+ if (details.docPath !== undefined) own.docPath = details.docPath;
562
+ if (details.collection !== undefined) own.collection = details.collection;
563
+ if (details.key !== undefined) own.key = details.key;
564
+ const wrapped = classified.class === 'duplicate'
565
+ ? new DbRuntimeError('JD2001', details.duplicateReason ?? classified.reason, own)
566
+ : new DbRuntimeError(details.code ?? classified.code ?? 'JD2005',
567
+ `${details.reason ?? classified.reason}: ${message}`, own);
568
+ wrapped.class = classified.class;
569
+ wrapped.retryable = classified.retryable;
570
+ return wrapped;
571
+ }
572
+
181
573
  export class DbRuntimeError extends CodedError {
182
574
  /**
183
575
  * @param {string} code
@@ -201,3 +593,22 @@ export class DbRuntimeError extends CodedError {
201
593
  if (details?.errors !== undefined) this.errors = details.errors;
202
594
  }
203
595
  }
596
+
597
+ /** Cloneable driver errors keep the native classification fields.
598
+ * @param {any} error @returns {any}
599
+ */
600
+ export function cloneDriverError(error) {
601
+ const out = { message: String(error?.message ?? error), name: String(error?.name ?? 'Error') };
602
+ for (const key of ['code', 'errcode', 'errstr', 'errno', 'resultCode', 'class', 'retryable', 'generation', 'depth']) {
603
+ const value = error?.[key];
604
+ if (['string', 'number', 'boolean'].includes(typeof value)) out[key] = value;
605
+ }
606
+ return out;
607
+ }
608
+
609
+ /** Convert a returned SQLite C result into the one native error vocabulary.
610
+ * @param {number} rc @param {string} operation
611
+ */
612
+ export function sqliteResultError(rc, operation) {
613
+ return Object.assign(new Error(`${operation} failed (${rc})`), { resultCode: rc });
614
+ }