@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/driver.js CHANGED
@@ -19,20 +19,28 @@
19
19
  * specifiers. `open()` is where "this driver does not exist here"
20
20
  * becomes the coded `JD0003` instead of a module-load crash.
21
21
  *
22
- * `capabilities` is read once at open — from the library's version
23
- * report, its compile options and the binding's declaration — and is
24
- * the single source of truth for feature gating; never a `typeof`
25
- * sniff at a call site. Two slots are deliberately EMPTY on every
26
- * SQLite driver: `statementTimeout` (no interrupt or progress handler
27
- * exists to build one on) and `rowEstimates` (the query plan is prose,
28
- * not numbers). They exist so a driver that has the facts can fill
22
+ * `capabilities` is read once at open by a PROBE — the SQLite one by
23
+ * default, another engine's through `options.probe` — and is the single
24
+ * source of truth for feature gating; never a `typeof` sniff at a call
25
+ * site. {@link baseCapabilities} gives every slot the conservative
26
+ * answer, so a probe that says nothing about a feature says `false`
27
+ * rather than `undefined`. Two slots are deliberately EMPTY on every
28
+ * driver this package ships: `statementTimeout` (SQLite has no
29
+ * interrupt or progress handler to build one on, and a PostgreSQL
30
+ * server-side timeout is not the same promise as the store's
31
+ * `AbortSignal`) and `rowEstimates` (SQLite's query plan is prose, not
32
+ * numbers). They exist so a driver that has the facts can fill
29
33
  * them without a contract change; pretending SQLite has them is the
30
- * silent degradation this suite refuses.
34
+ * silent degradation this suite refuses. A third, `lazyIteration`, is
35
+ * probed rather than declared: whether the binding's statements carry
36
+ * a native row iterator, which is what lets a cursor classify itself
37
+ * honestly — a binding without one gets `iterate` composed over `all()`
38
+ * here, and the cursor over it says it buffers.
31
39
  */
32
40
 
33
41
  import { isThenable, chain, toPromise } from '@jarenjs/core/function';
34
42
 
35
- import { DbCompileError } from './errors.js';
43
+ import { DbCompileError, DbRuntimeError, TransactionFailure } from './errors.js';
36
44
 
37
45
  /** The minimum SQLite the store accepts, asserted at open. */
38
46
  export const SQLITE_FLOOR = '3.45.0';
@@ -46,6 +54,19 @@ export const SQLITE_FLOOR = '3.45.0';
46
54
  */
47
55
  export const DEFAULT_QUEUE_TIMEOUT = 5000;
48
56
 
57
+ /**
58
+ * Why a queued caller gave up. It is always a coded refusal — a caller
59
+ * has to be able to tell "you were cancelled before you ran" from every
60
+ * other failure — and the host's own `reason` rides along as the cause,
61
+ * so a cancellation that meant something specific still says it.
62
+ * @param {AbortSignal} [signal]
63
+ */
64
+ function abortReason(signal) {
65
+ return new DbRuntimeError('JD2064',
66
+ 'the call was aborted while it waited for the open transaction to settle; '
67
+ + 'it ran no statement', { cause: signal?.reason });
68
+ }
69
+
49
70
  // the sync-capable-async helpers are `@jarenjs/core/function`'s (one
50
71
  // implementation in the suite); re-exported here because every store
51
72
  // module and the public `@jarenjs/db` surface reach them through this seam
@@ -96,18 +117,103 @@ export function lazyOpen(specifier, reason, use, args) {
96
117
  * @returns {{ run: Function, get: Function, all: Function,
97
118
  * iterate: Function }}
98
119
  */
99
- export function wrapStatement(statement) {
120
+ export function wrapStatement(statement, guard = undefined, active = undefined) {
121
+ const before = guard ?? (() => {});
122
+ const track = (source) => {
123
+ if (active === undefined) return source;
124
+ let done = false;
125
+ const iterator = {
126
+ next: () => {
127
+ before();
128
+ if (done) return { done: true, value: undefined };
129
+ return chain(source.next(), (step) => {
130
+ if (step.done) { done = true; active.delete(iterator); }
131
+ return step;
132
+ });
133
+ },
134
+ return: (value) => {
135
+ if (done) return { done: true, value };
136
+ done = true;
137
+ active.delete(iterator);
138
+ return source.return?.(value) ?? { done: true, value };
139
+ },
140
+ [Symbol.iterator]: () => iterator,
141
+ [Symbol.asyncIterator]: () => iterator,
142
+ };
143
+ active.add(iterator);
144
+ return iterator;
145
+ };
100
146
  return {
101
- run: (params = []) => statement.run(params),
102
- get: (params = []) => statement.get(params),
103
- all: (params = []) => statement.all(params),
147
+ run: (params = []) => { before(); return statement.run(params); },
148
+ get: (params = []) => { before(); return statement.get(params); },
149
+ all: (params = []) => { before(); return statement.all(params); },
104
150
  iterate: typeof statement.iterate === 'function'
105
- ? (params = []) => /** @type {Function} */ (statement.iterate)(params)
106
- : (params = []) => chain(statement.all(params),
107
- (rows) => rows[Symbol.iterator]()),
151
+ ? (params = []) => { before(); return chain(/** @type {Function} */ (statement.iterate)(params), track); }
152
+ : (params = []) => { before(); return chain(statement.all(params),
153
+ (rows) => track(rows[Symbol.iterator]())); },
108
154
  };
109
155
  }
110
156
 
157
+ /**
158
+ * Run a driver call and map its failure — thrown OR rejected — through
159
+ * `wrap`. A `try/catch` around a value-or-promise call saw only the
160
+ * synchronous throw: on an asynchronous driver the failure arrived as a
161
+ * rejection nothing handled, so a rejected write resolved as a success
162
+ * and surfaced later as an unhandled rejection.
163
+ * @template T
164
+ * @param {() => T | Promise<T>} call
165
+ * @param {(error: any) => Error} wrap
166
+ * @returns {T | Promise<T>}
167
+ */
168
+ export function attempt(call, wrap) {
169
+ let out;
170
+ try {
171
+ out = call();
172
+ }
173
+ catch (error) {
174
+ throw wrap(error);
175
+ }
176
+ return isThenable(out)
177
+ ? /** @type {Promise<T>} */ (out).then(undefined, (error) => { throw wrap(error); })
178
+ : out;
179
+ }
180
+
181
+ /**
182
+ * Settle a transaction only after its commit succeeds. Deferred constraints
183
+ * can refuse COMMIT or RELEASE after the body returned successfully; that
184
+ * failure owes the same rollback as a failing body.
185
+ * @param {() => any} body
186
+ * @param {() => any} commit
187
+ * @param {() => any} rollback
188
+ * @returns {any}
189
+ */
190
+ function settleTransaction(body, commit, rollback) {
191
+ const fail = (error) => chain(attempt(rollback, (cleanupError) =>
192
+ // Losing one remote generation also loses its rollback channel.
193
+ // That is one failure, not two independent transaction defects.
194
+ error === cleanupError || error?.code === 'JD2090' && cleanupError?.code === 'JD2090'
195
+ && error.generation === cleanupError.generation
196
+ ? error : new TransactionFailure(error, cleanupError)), () => { throw error; });
197
+ const succeed = (value) => {
198
+ let committed;
199
+ try {
200
+ committed = commit();
201
+ }
202
+ catch (error) {
203
+ return fail(error);
204
+ }
205
+ return isThenable(committed) ? committed.then(() => value, fail) : value;
206
+ };
207
+ let out;
208
+ try {
209
+ out = body();
210
+ }
211
+ catch (error) {
212
+ return fail(error);
213
+ }
214
+ return isThenable(out) ? out.then(succeed, fail) : succeed(out);
215
+ }
216
+
111
217
  /**
112
218
  * Finish a raw binding into the connection contract: probe the library
113
219
  * once, assert the version floor, freeze the capability table, and
@@ -115,23 +221,118 @@ export function wrapStatement(statement) {
115
221
  *
116
222
  * The raw shape a binding supplies:
117
223
  * `{ exec(sql), prepare(sql) -> { run, get, all, iterate? }, close(),
118
- * registerFunction?, registerAggregate?, session? }` — every method
119
- * value-or-promise.
224
+ * registerFunction?, registerAggregate?, session?, backup? }` — every
225
+ * method value-or-promise. `backup` is the online-backup primitive
226
+ * triple `{ copy(path, { rate, progress }), rename(from, to),
227
+ * remove(path) }` a binding with a platform backup API and a file
228
+ * system supplies; the store's `backupTo` is built on it and never
229
+ * touches a builtin itself.
120
230
  *
121
231
  * @param {any} raw
122
232
  * @param {{ dialect: any, synchronous?: boolean, queueTimeout?: number,
233
+ * probe?: (raw: any, dialect: any, declared: any) => any,
123
234
  * declared?: { sessions?: boolean, userFunctions?: boolean,
124
235
  * deterministicIndexableFunctions?: boolean,
125
- * aggregateFunctions?: boolean } }} options
236
+ * aggregateFunctions?: boolean,
237
+ * pragmas?: readonly string[],
238
+ * maintenance?: Record<string, boolean>,
239
+ * backup?: boolean } }} options - `declared.pragmas`
240
+ * names the configuration pragmas the binding can apply (the store's
241
+ * closed set, by option name); the store refuses a request outside it
242
+ * and reads every declared one back after open. `declared.maintenance`
243
+ * narrows the maintenance operations (`checkpoint`, `integrityCheck`,
244
+ * `foreignKeyCheck`, `optimize`): every SQLite library runs them, so
245
+ * an operation is available unless the binding declares it `false`
126
246
  * @returns {any} a Connection, or a promise of one
127
247
  */
128
248
  export function openConnection(raw, options) {
129
249
  const { dialect } = options;
130
250
  const synchronous = options.synchronous === true;
131
- const declared = options.declared ?? {};
251
+ const probe = options.probe ?? sqliteProbe;
252
+ // The binding has already acquired its handle. A failed probe must
253
+ // release it here, before a store can take ownership of the connection.
254
+ /** @param {any} failure */
255
+ const failClosed = (failure) => {
256
+ /** @param {any} closeError */
257
+ const both = (closeError) => new AggregateError([failure, closeError],
258
+ 'the connection failed to open, and closing its handle failed too');
259
+ const closed = attempt(() => raw.close(), both);
260
+ return chain(closed, () => { throw failure; });
261
+ };
262
+ let opened;
263
+ try {
264
+ opened = chain(probe(raw, dialect, options.declared ?? {}), (capabilities) =>
265
+ finishConnection(raw, dialect, synchronous, capabilities,
266
+ options.queueTimeout ?? DEFAULT_QUEUE_TIMEOUT));
267
+ }
268
+ catch (failure) {
269
+ return failClosed(failure);
270
+ }
271
+ return isThenable(opened) ? opened.then(undefined, failClosed) : opened;
272
+ }
273
+
274
+ /**
275
+ * The capability answers every connection carries, each defaulted to
276
+ * the conservative one. A probe fills in what its engine and binding
277
+ * actually supply; a slot it says nothing about reads `false` rather
278
+ * than `undefined`, so a feature gate is never a `typeof` sniff at a
279
+ * call site.
280
+ * @returns {Record<string, any>}
281
+ */
282
+ export function baseCapabilities() {
283
+ return {
284
+ version: '',
285
+ jsonb: false,
286
+ generatedColumns: false,
287
+ returning: false,
288
+ upsert: false,
289
+ savepoints: false,
290
+ rtree: false,
291
+ fts: false,
292
+ sessions: false,
293
+ sessionReason: null,
294
+ worker: false,
295
+ pooling: false,
296
+ poolReaders: 0,
297
+ poolWriters: 0,
298
+ userFunctions: false,
299
+ deterministicIndexableFunctions: false,
300
+ aggregateFunctions: false,
301
+ configurablePragmas: Object.freeze([]),
302
+ backup: false,
303
+ maintenance: Object.freeze({
304
+ checkpoint: false, integrityCheck: false, foreignKeyCheck: false, optimize: false,
305
+ }),
306
+ alterTableFull: false,
307
+ statementTimeout: false,
308
+ rowEstimates: false,
309
+ lazyIteration: false,
310
+ // the job queue, the change ledger and the live registry are built
311
+ // on SQLite's own spellings; a store on another engine reports them
312
+ // absent by name rather than failing at the first statement
313
+ jobs: false,
314
+ changeCapture: false,
315
+ };
316
+ }
317
+
318
+ /**
319
+ * The SQLite probe: the library's version report and compile options,
320
+ * plus what the binding declares it can do. It is the DEFAULT probe
321
+ * because every binding this package ships is a SQLite one; a driver
322
+ * for another engine passes its own through `options.probe`, and the
323
+ * version floor asserted here goes with it.
324
+ * @param {any} raw
325
+ * @param {any} dialect
326
+ * @param {Record<string, any>} declared
327
+ * @returns {any} value-or-promise of the frozen capability table
328
+ */
329
+ export function sqliteProbe(raw, dialect, declared) {
132
330
  return chain(raw.prepare(dialect.introspect.version()), (versionStatement) =>
133
331
  chain(versionStatement.get([]), (versionRow) => {
134
332
  const version = String(versionRow.version);
333
+ // the binding's statements either carry a lazy iterator or they
334
+ // do not; the probe statement is one of them
335
+ const lazyIteration = typeof versionStatement.iterate === 'function';
135
336
  if (compareVersions(version, SQLITE_FLOOR) < 0) {
136
337
  throw new DbCompileError('JD0001',
137
338
  `the SQLite library is ${version}, below the supported floor ${SQLITE_FLOOR}`);
@@ -139,7 +340,8 @@ export function openConnection(raw, options) {
139
340
  return chain(raw.prepare(dialect.introspect.compileOptions()), (optionsStatement) =>
140
341
  chain(optionsStatement.all([]), (rows) => {
141
342
  const compiled = new Set(rows.map((row) => String(row.name)));
142
- const capabilities = Object.freeze({
343
+ return Object.freeze({
344
+ ...baseCapabilities(),
143
345
  version,
144
346
  // guaranteed by the version floor
145
347
  jsonb: true,
@@ -155,6 +357,7 @@ export function openConnection(raw, options) {
155
357
  sessions: declared.sessions === true
156
358
  && typeof raw.session === 'function'
157
359
  && compiled.has('ENABLE_SESSION'),
360
+ sessionReason: raw.sessionReason ?? null,
158
361
  userFunctions: declared.userFunctions === true
159
362
  && typeof raw.registerFunction === 'function',
160
363
  deterministicIndexableFunctions:
@@ -166,15 +369,40 @@ export function openConnection(raw, options) {
166
369
  // method AND declare it.
167
370
  aggregateFunctions: declared.aggregateFunctions === true
168
371
  && typeof raw.registerAggregate === 'function',
372
+ // the configuration pragmas the binding applies, by the
373
+ // store's option name — a request outside this list is a
374
+ // coded refusal at open, never a silently skipped member
375
+ configurablePragmas: Object.freeze([...(declared.pragmas ?? [])]),
376
+ // the online backup: the binding must supply the primitive
377
+ // triple AND declare it — the platform API and the file
378
+ // system it needs are the binding's, not the library's
379
+ backup: declared.backup === true && raw.backup !== null && typeof raw.backup === 'object'
380
+ && typeof raw.backup.copy === 'function' && typeof raw.backup.rename === 'function'
381
+ && typeof raw.backup.remove === 'function',
382
+ // the maintenance pragmas are the library's, not the
383
+ // binding's: available unless declared absent, and the
384
+ // store's report says so per operation
385
+ maintenance: Object.freeze({
386
+ checkpoint: declared.maintenance?.checkpoint !== false,
387
+ integrityCheck: declared.maintenance?.integrityCheck !== false,
388
+ foreignKeyCheck: declared.maintenance?.foreignKeyCheck !== false,
389
+ optimize: declared.maintenance?.optimize !== false,
390
+ }),
169
391
  // structural SQLite limits — stated, not worked around
170
392
  alterTableFull: false,
171
393
  // the slots every SQLite driver leaves EMPTY (no
172
394
  // interrupt, no progress handler, no estimate API)
173
395
  statementTimeout: false,
174
396
  rowEstimates: false,
397
+ // whether a cursor can pull one row at a time, or the driver
398
+ // materialises the result on the first pull (declared, so
399
+ // the cursor's own report is honest about it)
400
+ lazyIteration,
401
+ // the job queue and the change ledger are SQLite spellings,
402
+ // and every SQLite binding runs them
403
+ jobs: true,
404
+ changeCapture: true,
175
405
  });
176
- return finishConnection(raw, dialect, synchronous, capabilities,
177
- options.queueTimeout ?? DEFAULT_QUEUE_TIMEOUT);
178
406
  }));
179
407
  }));
180
408
  }
@@ -203,14 +431,26 @@ export function openConnection(raw, options) {
203
431
  * @param {any} dialect
204
432
  * @param {boolean} synchronous
205
433
  * @param {Readonly<Record<string, any>>} capabilities
434
+ * @param {number} queueTimeout
206
435
  * @returns {any}
207
436
  */
208
- function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout) {
437
+ export function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout) {
438
+ const activeIterators = new Set();
209
439
  /** Savepoint names are never reused, so a stale name can never be
210
440
  * mistaken for a live one in an error or a log. */
211
441
  let savepointSeq = 0;
212
442
  /** Whether a top-level transaction currently owns the connection. */
213
443
  let owned = false;
444
+ /**
445
+ * Whether a transaction BLOCK is open on this connection right now.
446
+ *
447
+ * Only an engine whose `SAVEPOINT` does not start a transaction needs
448
+ * the answer, and it needs it for one decision: a checkpoint asked
449
+ * for outside a block has to open the block first, because there is
450
+ * nothing for it to be a checkpoint OF. On SQLite a bare `SAVEPOINT`
451
+ * IS the block, and the flag is never read.
452
+ */
453
+ let inBlock = false;
214
454
  /**
215
455
  * Whether an owning callback is on the stack RIGHT NOW — set around the
216
456
  * synchronous extent of every transaction body, cleared the moment it
@@ -223,6 +463,17 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
223
463
  let onStack = false;
224
464
  /** @type {Array<() => void>} FIFO of work waiting for the owner. */
225
465
  const waiting = [];
466
+ /** Set by `close()`: every later call is refused by name rather than
467
+ * leaking the binding's own error (or, on a build that tolerates it,
468
+ * running against a closed handle). */
469
+ let closed = false;
470
+ let closeResult;
471
+ const requireOpen = () => {
472
+ if (closed) {
473
+ throw new DbRuntimeError('JD2063',
474
+ 'the store is closed — a call after close() has no connection to run on');
475
+ }
476
+ };
226
477
 
227
478
  /** Hand the connection to the next waiter, in arrival order. */
228
479
  const release = () => {
@@ -242,28 +493,46 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
242
493
  * instead of the scope it was handed (never — it is waiting for itself).
243
494
  * The bound turns the second case from a silent hang into a coded error
244
495
  * that names the fix, the same trade SQLite's own busy timeout makes.
496
+ * A caller that gives up while queued — an aborted signal — is taken
497
+ * off the queue and rejected with its own reason, and `work` never
498
+ * runs at all. That is the difference between cancelling a request
499
+ * and cancelling its effect.
245
500
  * @param {() => any} work
246
501
  * @param {string} what - what is waiting, for the timeout message
502
+ * @param {AbortSignal} [signal] - abandons the wait when it aborts
247
503
  * @returns {any} value-or-promise
248
504
  */
249
- const whenFree = (work, what) => {
505
+ const whenFree = (work, what, signal) => {
506
+ if (signal?.aborted === true) return Promise.reject(abortReason(signal));
250
507
  if (!owned) return work();
251
508
  return new Promise((resolve, reject) => {
252
509
  let done = false;
253
- const timer = setTimeout(() => {
510
+ /** Leave the queue without running: the turn passes to the next
511
+ * waiter when the owner releases, exactly as a timeout's does. */
512
+ const abandon = (error) => {
254
513
  done = true;
255
514
  const index = waiting.indexOf(run);
256
515
  if (index >= 0) waiting.splice(index, 1);
257
- reject(new DbCompileError('JD0012',
258
- `${what} waited ${queueTimeout}ms for the open transaction to settle. `
259
- + 'A transaction owns its connection until it commits; work that belongs '
260
- + 'INSIDE it must go through the scope the callback received '
261
- + '(scope.transaction / the store passed to your callback), not the '
262
- + 'outer connection that request waits for itself.'));
263
- }, queueTimeout);
516
+ // a wait that ends by timeout owes the signal its listener back
517
+ signal?.removeEventListener('abort', cancelled);
518
+ reject(error);
519
+ };
520
+ const timer = setTimeout(() => abandon(new DbCompileError('JD0012',
521
+ `${what} waited ${queueTimeout}ms for the open transaction to settle. `
522
+ + 'A transaction owns its connection until it commits; work that belongs '
523
+ + 'INSIDE it must go through the store or client the callback received '
524
+ + '(tx.collection / tx.entity / tx.entities / tx.transaction), not the '
525
+ + 'outer one — that request waits for itself.')),
526
+ queueTimeout);
527
+ const cancelled = () => {
528
+ clearTimeout(timer);
529
+ abandon(abortReason(signal));
530
+ };
531
+ signal?.addEventListener('abort', cancelled, { once: true });
264
532
  const run = () => {
265
533
  if (done) { release(); return; } // already rejected: pass the turn on
266
534
  clearTimeout(timer);
535
+ signal?.removeEventListener('abort', cancelled);
267
536
  done = true;
268
537
  let out;
269
538
  try {
@@ -279,33 +548,136 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
279
548
  });
280
549
  };
281
550
 
282
- /** Open one savepoint around `fn`, at whatever depth we are. `fn`
283
- * receives the scope so nested work can name itself.
551
+ /**
552
+ * Hold the connection for `fn`'s whole extent without opening a
553
+ * savepoint: what an UNRELATED caller needs so its statements cannot
554
+ * fall inside a transaction it is not part of. A store-level read or
555
+ * write is exactly that caller — it waits for the owner, owns the
556
+ * connection while it runs, and hands it on.
557
+ *
558
+ * `fn` receives a scope for the same reason a transaction callback
559
+ * does: work that must nest inside this one (a membership attach, a
560
+ * unit of work's own transaction) says so through the scope rather
561
+ * than queueing behind the caller it is part of.
284
562
  * @param {(scope: any) => any} fn
563
+ * @param {string} [what] - what is waiting, for the timeout message
564
+ * @param {AbortSignal} [signal]
285
565
  */
286
- const savepointAround = (fn) => {
287
- const name = `jaren_sp_${savepointSeq++}`;
288
- const succeed = (result) => chain(raw.exec(dialect.tx.release(name)), () => result);
289
- const fail = (error) => chain(raw.exec(dialect.tx.rollbackTo(name)), () =>
290
- chain(raw.exec(dialect.tx.release(name)), () => {
291
- throw error;
292
- }));
293
- return chain(raw.exec(dialect.tx.savepoint(name)), () => {
294
- let out;
566
+ const exclusively = (fn, what, signal) => {
567
+ requireOpen();
568
+ // a synchronous extent inside an owning callback cannot interleave
569
+ // with anything, so there is nothing to wait for
570
+ if (onStack) return fn(scopeFor());
571
+ return whenFree(() => {
572
+ owned = true;
295
573
  const wasOnStack = onStack;
296
574
  onStack = true;
575
+ let out;
297
576
  try {
298
577
  out = fn(scopeFor());
299
578
  }
300
579
  catch (error) {
301
580
  onStack = wasOnStack;
302
- return fail(error);
581
+ release();
582
+ throw error;
583
+ }
584
+ onStack = wasOnStack;
585
+ if (!isThenable(out)) {
586
+ release();
587
+ return out;
303
588
  }
304
- onStack = wasOnStack; // the body has returned or awaited
305
- return isThenable(out) ? out.then(succeed, fail) : succeed(out);
589
+ return out.then(
590
+ (value) => { release(); return value; },
591
+ (error) => { release(); throw error; });
592
+ }, what ?? 'a store-level call', signal);
593
+ };
594
+
595
+ /**
596
+ * The ONE checkpoint primitive both savepoint kinds are built on: a
597
+ * structured `transaction()` nesting opens one and settles it around
598
+ * its callback, and a scope's manual `savepoint()` opens one the
599
+ * caller settles by name. Every checkpoint wears a generated
600
+ * monotonic identifier from the same sequence, so the two kinds share
601
+ * one engine stack and no caller-supplied label ever reaches SQL.
602
+ * The handle is opaque: the store's label bookkeeping lives above it.
603
+ * @returns {any} value-or-promise of `{ name }`
604
+ */
605
+ const openCheckpoint = () => {
606
+ const name = `jaren_sp_${savepointSeq++}`;
607
+ return chain(raw.exec(dialect.tx.savepoint(name)),
608
+ () => Object.freeze({ name }));
609
+ };
610
+ /** `ROLLBACK TO` a checkpoint: the engine keeps the target active and
611
+ * discards every savepoint opened after it.
612
+ * @param {{ name: string }} checkpoint */
613
+ const rollbackToCheckpoint = (checkpoint) =>
614
+ raw.exec(dialect.tx.rollbackTo(checkpoint.name));
615
+ /** `RELEASE` a checkpoint: the engine removes the target and every
616
+ * savepoint opened after it, keeping their rows.
617
+ * @param {{ name: string }} checkpoint */
618
+ const releaseCheckpoint = (checkpoint) =>
619
+ raw.exec(dialect.tx.release(checkpoint.name));
620
+
621
+ /** Only the synchronous extent of the callback may implicitly nest. */
622
+ const callBody = (fn) => {
623
+ const wasOnStack = onStack;
624
+ onStack = true;
625
+ try {
626
+ return fn(scopeFor());
627
+ }
628
+ finally {
629
+ onStack = wasOnStack;
630
+ }
631
+ };
632
+
633
+ /**
634
+ * Own the connection inside a transaction BLOCK: `BEGIN` (or `BEGIN
635
+ * IMMEDIATE`) around `fn`, committed or rolled back as a whole.
636
+ *
637
+ * The `immediate` mode takes the write lock up front. A body
638
+ * that reads before it writes — a claim: read the record, decide,
639
+ * insert — otherwise meets the read→write upgrade `SQLITE_BUSY` the
640
+ * busy handler cannot retry when another connection commits in
641
+ * between; taking the lock first makes that wait an ordinary busy
642
+ * wait the timeout covers. Nesting inside it is savepoints, as always.
643
+ * @param {(scope: any) => any} fn
644
+ */
645
+ const blockAround = (fn, mode) => {
646
+ const begin = mode === 'immediate' ? dialect.tx.beginImmediate : dialect.tx.begin;
647
+ return chain(raw.exec(begin), () => {
648
+ let out;
649
+ const wasInBlock = inBlock;
650
+ inBlock = true;
651
+ const restore = (value) => { inBlock = wasInBlock; return value; };
652
+ try {
653
+ out = settleTransaction(() => callBody(fn),
654
+ () => raw.exec(dialect.tx.commit), () => raw.exec(dialect.tx.rollback));
655
+ }
656
+ catch (error) {
657
+ restore(undefined);
658
+ throw error;
659
+ }
660
+ return isThenable(out)
661
+ ? out.then(restore, (error) => { restore(undefined); throw error; })
662
+ : restore(out);
306
663
  });
307
664
  };
308
665
 
666
+ /** Open one savepoint around `fn`, at whatever depth we are. `fn`
667
+ * receives the scope so nested work can name itself.
668
+ * @param {(scope: any) => any} fn
669
+ */
670
+ const savepointAround = (fn) => {
671
+ // an engine that refuses a savepoint outside a transaction gets the
672
+ // block it needs; on every other one this is the same statement it
673
+ // always was
674
+ if (!inBlock && dialect.capabilities.savepointStartsTransaction !== true)
675
+ return blockAround(fn, 'deferred');
676
+ return chain(openCheckpoint(), (checkpoint) => settleTransaction(() => callBody(fn),
677
+ () => releaseCheckpoint(checkpoint),
678
+ () => chain(rollbackToCheckpoint(checkpoint), () => releaseCheckpoint(checkpoint))));
679
+ };
680
+
309
681
  /** The scope handed to a transaction callback: the owner's direct
310
682
  * access to the connection, plus nesting. Deliberately narrow —
311
683
  * registering a function or opening a change session belongs to store
@@ -315,12 +687,26 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
315
687
  capabilities,
316
688
  dialect,
317
689
  /** @param {string} sql */
318
- exec: (sql) => raw.exec(sql),
690
+ exec: (sql) => { requireOpen(); return raw.exec(sql); },
319
691
  /** @param {string} sql */
320
- prepare: (sql) => chain(raw.prepare(sql), wrapStatement),
692
+ prepare: (sql, metadata) => { requireOpen(); return chain(raw.prepare(sql, metadata), (s) => wrapStatement(s, requireOpen, activeIterators)); },
321
693
  /** A nested savepoint inside this transaction.
322
694
  * @param {(scope: any) => any} fn */
323
695
  transaction: (fn) => savepointAround(fn),
696
+ /** A MANUAL checkpoint at the current depth, settled by the caller
697
+ * through {@link rollbackTo}/{@link release} rather than around a
698
+ * callback. It is the same primitive structured nesting uses — one
699
+ * generated-identifier stack — so the two kinds cannot cross-release
700
+ * each other by name, and no caller-supplied label reaches SQL. */
701
+ savepoint: () => { requireOpen(); return openCheckpoint(); },
702
+ /** `ROLLBACK TO` a manual checkpoint: the target stays active; every
703
+ * savepoint opened after it is discarded with its rows.
704
+ * @param {{ name: string }} checkpoint */
705
+ rollbackTo: (checkpoint) => { requireOpen(); return rollbackToCheckpoint(checkpoint); },
706
+ /** `RELEASE` a manual checkpoint: the target and every savepoint
707
+ * opened after it are removed; their rows remain.
708
+ * @param {{ name: string }} checkpoint */
709
+ release: (checkpoint) => { requireOpen(); return releaseCheckpoint(checkpoint); },
324
710
  });
325
711
 
326
712
  return Object.freeze({
@@ -328,22 +714,27 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
328
714
  capabilities,
329
715
  dialect,
330
716
  /** @param {string} sql */
331
- // NOT gated. A statement issued while a transaction is open joins it,
332
- // because a SQLite connection has no per-statement transaction scope
333
- // and every caller inside a transaction reaches the connection this
334
- // way. Two consequences, both documented in MODEL-FORMAT §Transactions:
335
- // work inside a callback runs immediately as the owner (right), and an
336
- // UNRELATED caller's bare write on a shared store joins that
337
- // transaction and shares its fate (a single connection cannot tell the
338
- // two apart give each concurrent writer its own store to separate
339
- // them). What the gate below does guarantee is that two TRANSACTIONS
340
- // never interleave, which is what made commits report failure.
341
- exec: (sql) => raw.exec(sql),
717
+ // NOT gated, because a SQLite connection has no per-statement
718
+ // transaction scope: a statement issued here joins whatever is open.
719
+ // That is right for work INSIDE the transaction, which is why the
720
+ // scope exposes the same two methods. An unrelated caller must not
721
+ // reach them it takes `exclusively` instead, which holds the
722
+ // connection for its own extent, and the store's handles do exactly
723
+ // that (MODEL-FORMAT §5.1). What the gate guarantees on top is that
724
+ // two TRANSACTIONS never interleave, which is what made commits
725
+ // report failure.
726
+ exec: (sql) => { requireOpen(); return raw.exec(sql); },
342
727
  /** @param {string} sql */
343
- prepare: (sql) => chain(raw.prepare(sql), wrapStatement),
728
+ prepare: (sql, metadata) => { requireOpen(); return chain(raw.prepare(sql, metadata), (s) => wrapStatement(s, requireOpen, activeIterators)); },
729
+ /** Whether a transaction issued NOW would have to queue: an owner
730
+ * holds the connection and no owning callback is on the stack (a
731
+ * synchronous call from inside the callback nests instead). What a
732
+ * synchronous surface must know before it would hand back a Promise. */
733
+ get mustQueue() { return owned && !onStack; },
344
734
  /**
345
735
  * A transaction. `fn`'s value is returned; a throw rolls back exactly
346
- * this level and rethrows. No implicit retry.
736
+ * this level and rethrows. A refused COMMIT or RELEASE also rolls
737
+ * back before the next owner runs. No implicit retry.
347
738
  *
348
739
  * Two shapes, decided here rather than by the caller:
349
740
  *
@@ -361,14 +752,26 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
361
752
  * queues behind the transaction the caller is part of, and
362
753
  * {@link DEFAULT_QUEUE_TIMEOUT} turns that into `JD0012`.
363
754
  * @param {(scope: any) => any} fn
755
+ * @param {AbortSignal} [signal] - abandons a QUEUED transaction; a
756
+ * transaction that has already taken the connection runs on
757
+ * @param {'deferred' | 'immediate'} [mode] - `'immediate'` takes the
758
+ * write lock up front (a top-level transaction only; a nested call
759
+ * is a savepoint whichever mode the root chose)
364
760
  */
365
- transaction(fn) {
761
+ transaction(fn, signal, mode = 'deferred') {
762
+ requireOpen();
366
763
  if (onStack) return savepointAround(fn);
367
764
  return whenFree(() => {
368
765
  owned = true;
369
766
  let out;
370
767
  try {
371
- out = savepointAround(fn);
768
+ // A top-level transaction is one CHECKPOINT where a savepoint
769
+ // opens a transaction of its own, and a BLOCK where it does
770
+ // not: an engine that refuses `SAVEPOINT` outside a
771
+ // transaction has to be told one is starting.
772
+ out = mode === 'immediate' || dialect.capabilities.savepointStartsTransaction !== true
773
+ ? blockAround(fn, mode)
774
+ : savepointAround(fn);
372
775
  }
373
776
  catch (error) {
374
777
  release();
@@ -381,17 +784,48 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
381
784
  return out.then(
382
785
  (value) => { release(); return value; },
383
786
  (error) => { release(); throw error; });
384
- }, 'a transaction');
787
+ }, 'a transaction', signal);
788
+ },
789
+ /** Hold the connection for one unrelated caller's whole extent,
790
+ * without a savepoint: what a store-level read or write takes so it
791
+ * cannot fall inside a transaction it is not part of. */
792
+ exclusively,
793
+ // idempotent: the second close is a no-op on every driver, not a
794
+ // raw error on one and a resolved promise on another
795
+ close: () => {
796
+ if (closed) return closeResult;
797
+ closed = true;
798
+ // Remote hosts own cursor cleanup within their bounded shutdown.
799
+ // Waiting for a row here would postpone that deadline indefinitely.
800
+ if (raw.closeDrainsIterators === true) {
801
+ activeIterators.clear();
802
+ return closeResult = raw.close();
803
+ }
804
+ const pending = [];
805
+ for (const iterator of activeIterators) {
806
+ try { pending.push(iterator.return()); }
807
+ catch (error) { pending.push(Promise.reject(error)); }
808
+ }
809
+ if (pending.some(isThenable)) return closeResult = Promise.allSettled(pending).then(() => raw.close());
810
+ return closeResult = raw.close();
385
811
  },
386
- close: () => raw.close(),
387
812
  registerFunction: typeof raw.registerFunction === 'function'
388
- ? (name, functionOptions, fn) => raw.registerFunction(name, functionOptions, fn)
813
+ ? (name, functionOptions, fn) => { requireOpen(); return raw.registerFunction(name, functionOptions, fn); }
389
814
  : null,
390
815
  registerAggregate: typeof raw.registerAggregate === 'function'
391
- ? (name, spec) => raw.registerAggregate(name, spec)
816
+ ? (name, spec) => { requireOpen(); return raw.registerAggregate(name, spec); }
392
817
  : null,
393
818
  session: typeof raw.session === 'function'
394
- ? (table) => raw.session(table)
819
+ ? (table) => { requireOpen(); return raw.session(table); }
820
+ : null,
821
+ // the backup primitives, each refused by name once the store is
822
+ // closed exactly as a statement is
823
+ backup: capabilities.backup === true
824
+ ? Object.freeze({
825
+ copy: (path, options) => { requireOpen(); return raw.backup.copy(path, options); },
826
+ rename: (from, to) => { requireOpen(); return raw.backup.rename(from, to); },
827
+ remove: (path) => { requireOpen(); return raw.backup.remove(path); },
828
+ })
395
829
  : null,
396
830
  });
397
831
  }