@jarenjs/db 0.49.2 → 0.66.1

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