@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/store.js CHANGED
@@ -20,27 +20,36 @@
20
20
  * never a silent one. `@jarenjs/validate` is never imported here.
21
21
  */
22
22
 
23
+ import { resolveRuntime } from '@jarenjs/core/runtime';
23
24
  import { applyJSONPatch } from '@jarenjs/json/patch';
24
25
  import { parseJSONPointer } from '@jarenjs/json/pointer';
25
26
 
26
- import { DbCompileError, DbRuntimeError } from './errors.js';
27
- import { chain, toPromise, isThenable } from './driver.js';
27
+ import { DbCompileError, DbRuntimeError, wrapDriverError, isDriverError, classifyDriverError } from './errors.js';
28
+ import { chain, toPromise, isThenable, attempt } from './driver.js';
28
29
  import { planCollection, planEntity, planJoinTable, verifyShape } from './ddl.js';
29
30
  import { translatePatch } from './patch-sql.js';
30
31
  import { createQueryEngine, createQueryState, createEntityQueryEngine, createLoadEngine } from './query.js';
31
- import { normalizeProfile } from './profile.js';
32
+ import { admitCursor, admitSyncCursor } from './cursor.js';
33
+ import { refuseUnsupportedPragmaKeys, resolvePragmaRequests, configurePragmas } from './pragmas.js';
34
+ import { createMaintenance } from './maintenance.js';
35
+ import { createBackup } from './backup.js';
36
+ import { normalizeProfile, assertProfileRoots } from './profile.js';
32
37
  import { normalizeEntities, explainMapping } from './model.js';
33
38
  import { entityCore } from './entity.js';
34
- import { createTracker } from './tracker.js';
39
+ import { createTracker, membershipKeys } from './tracker.js';
35
40
  import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
36
41
  import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js';
37
42
  import { normalizeEventTime } from './live-time.js';
38
43
  import { createJobEngine } from './jobs.js';
39
- import { collectEntityRoots } from './plan.js';
44
+ import { introspectModel } from './introspect.js';
45
+ import { collectEntityRoots, entityRoot } from './plan.js';
40
46
  import {
41
47
  DERIVE_KINDS, PHYSICAL_KINDS, PRECISION_MIN, PRECISION_MAX, DIMS_MIN, DIMS_MAX,
42
48
  derivedValue, memberAt, storedMemberForm, registerDeriveFunctions,
43
49
  } from './derive.js';
50
+ import {
51
+ normalizeExpression, canonicalExpression, expressionMembers, registerExpressionFunctions,
52
+ } from './expression.js';
44
53
 
45
54
  /** The model format version this store implements. */
46
55
  export const MODEL_VERSION = '0.1';
@@ -194,9 +203,13 @@ function normalizeDerive(index, paths, docPath) {
194
203
  * Normalize and check a model document. Every failure is `JD0005` with
195
204
  * a `docPath` into the model.
196
205
  * @param {any} model
206
+ * @param {Record<string, any>} [expressions] - the host's declared
207
+ * index-expression functions, by name: a model that names one is
208
+ * resolved against them here, so an unknown, wrong-arity or
209
+ * non-deterministic function is `JD0004` before any DDL
197
210
  * @returns {Map<string, any>} collection name -> normalized collection
198
211
  */
199
- export function normalizeModel(model) {
212
+ export function normalizeModel(model, expressions = undefined) {
200
213
  if (model === null || typeof model !== 'object' || Array.isArray(model))
201
214
  throw modelError('JD0005', 'the model document must be an object', '');
202
215
  if (model.$model !== MODEL_VERSION) {
@@ -285,6 +298,38 @@ export function normalizeModel(model) {
285
298
  `duplicate index name '${index.name}'`, `${indexDocPath}/name`);
286
299
  }
287
300
  indexNames.add(index.name);
301
+ // an EXPRESSION index names what it computes, not which member it
302
+ // reads, so it is mutually exclusive with both of the other two
303
+ // ways an index is declared: an index cannot be over a member AND
304
+ // over a function of one, and a derived spatial column is already
305
+ // an expression this format spells for you
306
+ if (index.expression !== undefined) {
307
+ if (index.path !== undefined) {
308
+ throw modelError('JD0004',
309
+ 'an index declares a path OR an expression: an expression names the members it '
310
+ + 'reads itself', `${indexDocPath}/path`);
311
+ }
312
+ if (index.derive !== undefined) {
313
+ throw modelError('JD0004',
314
+ 'a derived index IS an expression this format spells; declare one or the other',
315
+ `${indexDocPath}/derive`);
316
+ }
317
+ const expression = normalizeExpression(index.expression,
318
+ `${indexDocPath}/expression`, expressions);
319
+ indexes.push({
320
+ name: index.name,
321
+ paths: expressionMembers(expression),
322
+ expression,
323
+ canonical: canonicalExpression(expression),
324
+ unique: index.unique === true,
325
+ derive: null,
326
+ precision: undefined,
327
+ physical: undefined,
328
+ dims: undefined,
329
+ docPath: indexDocPath,
330
+ });
331
+ continue;
332
+ }
288
333
  const paths = Array.isArray(index.path) ? index.path : [index.path];
289
334
  if (paths.length === 0
290
335
  || paths.some((p) => typeof p !== 'string' || p === '')) {
@@ -369,13 +414,6 @@ function requireKey(key, collection, docPath) {
369
414
  * @param {string} keyColumn
370
415
  * @returns {boolean}
371
416
  */
372
- function isDuplicateKey(error, table, keyColumn) {
373
- if (error?.errcode === 1555) return true;
374
- return typeof error?.message === 'string'
375
- && error.message.includes('UNIQUE constraint failed')
376
- && error.message.includes(`${table}.${keyColumn}`);
377
- }
378
-
379
417
  /**
380
418
  * Wrap a database failure for one collection operation.
381
419
  * @param {any} error
@@ -386,21 +424,48 @@ function isDuplicateKey(error, table, keyColumn) {
386
424
  * @returns {DbRuntimeError}
387
425
  */
388
426
  function wrapWriteError(error, plan, collection, docPath, key) {
389
- if (isDuplicateKey(error, plan.table, plan.keyColumn)) {
390
- return new DbRuntimeError('JD2001',
391
- `a document already exists under key '${String(key)}'`,
392
- { docPath, collection, key, cause: error });
393
- }
394
- return new DbRuntimeError('JD2005',
395
- `the database rejected the operation: ${error?.message ?? String(error)}`,
396
- key === undefined
397
- ? { docPath, collection, cause: error }
398
- : { docPath, collection, key, cause: error });
427
+ // one classifier for every path: a coded error (a closed store, a
428
+ // refused document) passes through it untouched
429
+ return wrapDriverError(error, {
430
+ docPath, collection, ...(key === undefined ? undefined : { key }),
431
+ unique: { table: plan.table, column: plan.keyColumn },
432
+ duplicateReason: `a document already exists under key '${String(key)}'`,
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Run `fn` inside an IMMEDIATE transaction on an otherwise idle
438
+ * connection — the open path's shape work. A deferred transaction (a
439
+ * bare savepoint) takes its write lock only when the first write
440
+ * arrives, and a concurrent commit between the probe and the CREATE
441
+ * turns that upgrade into the one SQLITE_BUSY the busy handler cannot
442
+ * retry; taking the write lock first makes the wait an ordinary busy
443
+ * wait the timeout covers. Exactly the bracket the migration runner
444
+ * uses; the driver's savepoint machinery is not involved, because at
445
+ * open nothing else holds the connection.
446
+ * @param {any} connection
447
+ * @param {() => any} fn - value-or-promise
448
+ * @returns {any} value-or-promise
449
+ */
450
+ function immediately(connection, fn) {
451
+ const dialect = connection.dialect;
452
+ const commit = (value) => chain(connection.exec(dialect.tx.commit), () => value);
453
+ const rollback = (error) => chain(connection.exec(dialect.tx.rollback), () => { throw error; });
454
+ return chain(connection.exec(dialect.tx.beginImmediate), () => {
455
+ let out;
456
+ try {
457
+ out = fn();
458
+ }
459
+ catch (error) {
460
+ return rollback(error);
461
+ }
462
+ return isThenable(out) ? out.then(commit, rollback) : commit(out);
463
+ });
399
464
  }
400
465
 
401
466
  /**
402
467
  * Create or verify every collection's physical shape, inside one
403
- * transaction.
468
+ * immediate transaction.
404
469
  * @param {any} connection
405
470
  * @param {Map<string, any>} collections
406
471
  * @param {Map<string, any>} plans
@@ -409,7 +474,9 @@ function wrapWriteError(error, plan, collection, docPath, key) {
409
474
  function ensureShape(connection, collections, plans, readOnly) {
410
475
  const dialect = connection.dialect;
411
476
  const names = [...collections.keys()];
412
- return connection.transaction(() => {
477
+ // a read-only store creates nothing, and cannot take a write lock
478
+ const bracket = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
479
+ return bracket(() => {
413
480
  const step = (i) => {
414
481
  if (i >= names.length) return null;
415
482
  const name = names[i];
@@ -425,7 +492,7 @@ function ensureShape(connection, collections, plans, readOnly) {
425
492
  }
426
493
  const run = (j) => (j >= plan.createSql.length
427
494
  ? null
428
- : chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
495
+ : chain(connection.exec(dialect.ddl.idempotent(plan.createSql[j])), () => run(j + 1)));
429
496
  return chain(run(0), () => step(i + 1));
430
497
  }
431
498
  return chain(verifyShape(connection, plan, name, collection.docPath),
@@ -451,7 +518,8 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
451
518
  if (entityPlans.size === 0) return null;
452
519
  const dialect = connection.dialect;
453
520
  const names = [...entityPlans.keys()];
454
- return connection.transaction(() => {
521
+ const bracket = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
522
+ return bracket(() => {
455
523
  const step = (i) => {
456
524
  if (i >= names.length) return null;
457
525
  const name = names[i];
@@ -467,7 +535,7 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
467
535
  }
468
536
  const run = (j) => (j >= plan.createSql.length
469
537
  ? null
470
- : chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
538
+ : chain(connection.exec(dialect.ddl.idempotent(plan.createSql[j])), () => run(j + 1)));
471
539
  return chain(run(0), () => step(i + 1));
472
540
  }
473
541
  return chain(verifyShape(connection, plan, name, docPath), () =>
@@ -514,10 +582,11 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
514
582
  * @param {any} plan
515
583
  * @param {((doc: any) => any) | null} validate
516
584
  * @param {any} queryState - the store-wide statement cache and UDF set
517
- * @param {{ profile: any }} storeProfileRef - the store-level profile
585
+ * @param {{ profile: any, roots: readonly string[] }} storeProfileRef - the
586
+ * store-level profile and every root a member allow-list may name
518
587
  * @returns {any}
519
588
  */
520
- function collectionCore(connection, collection, plan, validate, queryState, storeProfileRef) {
589
+ function collectionCore(connection, collection, plan, validate, queryState, storeProfileRef, runtime) {
521
590
  const dialect = connection.dialect;
522
591
  // the STORED branch (a driver that cannot index a registered
523
592
  // function): the derived columns are ordinary ones, so every write
@@ -566,7 +635,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
566
635
  const stats = { patchTranslated: 0, patchFallback: 0 };
567
636
  const engine = createQueryEngine({
568
637
  connection, state: queryState, collection, physicalPlan: plan,
569
- profile: storeProfileRef.profile,
638
+ profile: storeProfileRef.profile, roots: storeProfileRef.roots,
570
639
  });
571
640
 
572
641
  const checkValid = (doc) => {
@@ -589,21 +658,14 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
589
658
  }
590
659
  if (explicitKey !== undefined)
591
660
  return requireKey(explicitKey, collection.name, collection.docPath);
592
- if (collection.identity === 'uuid') return crypto.randomUUID();
661
+ if (collection.identity === 'uuid') return runtime.uuid();
593
662
  return null; // integer: the database allocates
594
663
  };
595
664
 
596
665
  const runWrite = (statementName, sql, params, key, reads) => {
597
- return chain(prepared(statementName, sql), (statement) => {
598
- let out;
599
- try {
600
- out = reads ? statement.get(params) : statement.run(params);
601
- }
602
- catch (error) {
603
- throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
604
- }
605
- return out;
606
- });
666
+ return chain(prepared(statementName, sql), (statement) =>
667
+ attempt(() => (reads ? statement.get(params) : statement.run(params)),
668
+ (error) => wrapWriteError(error, plan, collection.name, collection.docPath, key)));
607
669
  };
608
670
 
609
671
  const core = {
@@ -617,9 +679,12 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
617
679
  explain: (document, options) => engine.explain(document, options),
618
680
  get(key) {
619
681
  requireKey(key, collection.name, collection.docPath);
620
- return chain(prepared('get', dialect.dml.get(shape)), (statement) =>
682
+ // a point read meets the same failures a statement of the query
683
+ // engine does (a corrupt page, a locked file): classified, never raw
684
+ return attempt(() => chain(prepared('get', dialect.dml.get(shape)), (statement) =>
621
685
  chain(statement.get([key]),
622
- (row) => (row === undefined ? undefined : JSON.parse(row.doc))));
686
+ (row) => (row === undefined ? undefined : JSON.parse(row.doc)))),
687
+ (error) => wrapDriverError(error, { docPath: collection.docPath, collection: collection.name, key }));
623
688
  },
624
689
  insert(doc) {
625
690
  checkValid(doc);
@@ -673,15 +738,10 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
673
738
  const { expression, params } = translated.build(
674
739
  dialect.quoteIdentifier(plan.docColumn), 1);
675
740
  const sql = dialect.dml.updateDoc(shape, expression, params.length + 1);
676
- return chain(prepared(`patch:${sql}`, sql), (statement) => {
677
- try {
678
- statement.run([...params, ...derivedFor(next), key]);
679
- }
680
- catch (error) {
681
- throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
682
- }
683
- return next;
684
- });
741
+ return chain(prepared(`patch:${sql}`, sql), (statement) =>
742
+ chain(attempt(() => statement.run([...params, ...derivedFor(next), key]),
743
+ (error) => wrapWriteError(error, plan, collection.name, collection.docPath, key)),
744
+ () => next));
685
745
  });
686
746
  },
687
747
  delete(key) {
@@ -742,6 +802,26 @@ function asyncCollection(core, live) {
742
802
  });
743
803
  }
744
804
 
805
+ /**
806
+ * The schema a WRITE validates against. A store-allocated key (`default:
807
+ * "auto"`) is absent from the document the injected hook sees — the
808
+ * database allocates it after validation — so it cannot be required of a
809
+ * write, and the generated input type already marks it optional; every
810
+ * other member is the schema's own, defaults filled (§9.6). The read
811
+ * shape is untouched: the document the store answers carries the key.
812
+ * @param {any} entity - a normalized entity
813
+ * @returns {any}
814
+ */
815
+ function writeSchemaOf(entity) {
816
+ const schema = entity.schema;
817
+ const auto = entity.keys.find((key) => entity.properties.get(key).default === 'auto');
818
+ if (auto === undefined || !Array.isArray(schema?.required) || !schema.required.includes(auto))
819
+ return schema;
820
+ const out = { ...schema, required: schema.required.filter((name) => name !== auto) };
821
+ if (out.required.length === 0) delete out.required;
822
+ return out;
823
+ }
824
+
745
825
  /**
746
826
  * Resolve the store's operator seam (Ring 2) to a single
747
827
  * `{ functions, extensions }` or `null`. Accepts `options.operators` (a
@@ -756,6 +836,36 @@ function asyncCollection(core, live) {
756
836
  * @param {any} options
757
837
  * @returns {{ functions: any, extensions: any } | null}
758
838
  */
839
+ /**
840
+ * The declaration rules a registry aggregate must satisfy to be lowered
841
+ * to a SQL aggregate, checked once at open: `kind: 'agg'`, ONE leading
842
+ * sequence operand (the fold's input) and nothing else, and a scalar
843
+ * result. The arity rule is not fussiness — a SQL aggregate's `result`
844
+ * step sees only what the row steps accumulated, so a second operand
845
+ * simply does not reach a fold over zero rows, and an aggregate that
846
+ * answered a different value for an empty input than the engine does
847
+ * would be worse than one that stays where it is. A pack that marks
848
+ * something else `pushable: 'aggregate'` is a host configuration
849
+ * error, loud here rather than a silent non-promotion.
850
+ * @param {string} name
851
+ * @param {any} meta - the registry's `forSql()` entry
852
+ * @returns {{ fn: Function }}
853
+ */
854
+ function aggregateSpec(name, meta) {
855
+ const kinds = (Array.isArray(meta.signature) ? meta.signature : [])
856
+ .map((token) => (typeof token === 'string' && token.startsWith('seq') ? 'seq' : 'scalar'));
857
+ const wellFormed = meta.kind === 'agg'
858
+ && kinds.length === 1 && kinds[0] === 'seq'
859
+ && meta.signature[0] === 'seq<number>'
860
+ && meta.result === 'number'
861
+ && typeof meta.fn === 'function';
862
+ if (!wellFormed) {
863
+ throw new TypeError(`openStore: operator '${name}' declares pushable: 'aggregate', which `
864
+ + "needs kind: 'agg' with exactly one operand, 'seq<number>', and result: 'number'");
865
+ }
866
+ return { fn: meta.fn };
867
+ }
868
+
759
869
  function resolveOperators(options) {
760
870
  const registry = options.operators;
761
871
  const hasRegistry = registry !== undefined && registry !== null;
@@ -768,6 +878,11 @@ function resolveOperators(options) {
768
878
  // UDFs where the driver supports them. Raw (registry-free) extensions
769
879
  // are never pushed — only a registry declares pushability.
770
880
  const pushableScalar = new Set();
881
+ // the SQL-pushable AGGREGATE subset (Ring 3): a registry `agg` entry
882
+ // marked `pushable: 'aggregate'`, which promises a fold over the
883
+ // multiset alone — a SQL aggregate visits rows in an order nothing
884
+ // specifies — and a finite-or-empty scalar result
885
+ const pushableAggregate = new Map();
771
886
  if (hasRegistry) {
772
887
  if (typeof registry.toOptions !== 'function') {
773
888
  throw new TypeError('openStore: operators must be a registry '
@@ -781,12 +896,13 @@ function resolveOperators(options) {
781
896
  if (typeof registry.forSql === 'function') {
782
897
  for (const [name, meta] of Object.entries(registry.forSql())) {
783
898
  if (meta.pushable === 'scalar') pushableScalar.add(name);
899
+ else if (meta.pushable === 'aggregate') pushableAggregate.set(name, aggregateSpec(name, meta));
784
900
  }
785
901
  }
786
902
  }
787
903
  if (options.functions !== undefined) functions = { ...functions, ...options.functions };
788
904
  if (options.extensions !== undefined) extensions = { ...extensions, ...options.extensions };
789
- return Object.freeze({ functions, extensions, pushableScalar });
905
+ return Object.freeze({ functions, extensions, pushableScalar, pushableAggregate });
790
906
  }
791
907
 
792
908
  /**
@@ -794,8 +910,12 @@ function resolveOperators(options) {
794
910
  * @param {any} model - A `jaren-model` document (the 0.1 subset)
795
911
  * @param {{ driver: any, path?: string, compileSchema?: Function,
796
912
  * busyTimeout?: number, queueTimeout?: number, journalMode?: string,
913
+ * synchronous?: string, walAutocheckpoint?: number,
914
+ * journalSizeLimit?: number, cacheSize?: number, mmapSize?: number,
915
+ * tempStore?: string,
797
916
  * statementCacheBound?: number, profile?: any, operators?: any,
798
917
  * functions?: any, extensions?: any, zoneProvider?: any,
918
+ * runtime?: Partial<import('@jarenjs/core/runtime').Runtime>,
799
919
  * readOnly?: boolean }} options
800
920
  * `zoneProvider` is D7's injected clock: a named zone in a temporal
801
921
  * spec (`{ "every": "P1M", "zone": "Europe/Amsterdam" }`) is host code
@@ -803,6 +923,20 @@ function resolveOperators(options) {
803
923
  * such a document (`JQ0003`) rather than answering it in UTC. It
804
924
  * reaches every residual compilation, which is where the calendar
805
925
  * ladder actually walks.
926
+ * The connection pragmas — `busyTimeout`, `journalMode`, `synchronous`,
927
+ * `walAutocheckpoint`, `journalSizeLimit`, `cacheSize`, `mmapSize`,
928
+ * `tempStore` — are a closed, validated set (`pragmas.js`): an option
929
+ * naming any other pragma is refused `JD0006`, one the driver or the
930
+ * store kind cannot apply `JD0007`, and every value is read back after
931
+ * the open sequence and reported on `capabilities.pragmas` — a value
932
+ * the engine did not take is `JD0008`, never a silent divergence.
933
+ * `runtime` is the host's runtime record (`@jarenjs/core/runtime`):
934
+ * the clock the capture log and the job queue stamp, the identifier
935
+ * a `uuid` identity and a `default: 'uuid'` allocate, the job queue's
936
+ * backoff jitter, and the zone provider — each read only where the
937
+ * store has no explicit option for it (`zoneProvider`, `jobs.now`,
938
+ * `jobs.random` win), and handed on to the job engine so a consumer
939
+ * configures it once.
806
940
  * @returns {Promise<any>}
807
941
  */
808
942
  export function openStore(model, options) {
@@ -813,6 +947,10 @@ export function openStore(model, options) {
813
947
  if (options.compileSchema !== undefined && typeof options.compileSchema !== 'function')
814
948
  throw new TypeError('openStore: compileSchema must be a function when present');
815
949
  const operators = resolveOperators(options);
950
+ const runtime = resolveRuntime(options.runtime);
951
+ // the explicit option wins over the record's member, and an explicit
952
+ // `null` is a deliberate "none" rather than a fall-through
953
+ const zoneProvider = options.zoneProvider !== undefined ? options.zoneProvider : runtime.zoneProvider;
816
954
 
817
955
  // API misuse (above) throws; a defective MODEL rejects, per the
818
956
  // asynchronous contract
@@ -820,7 +958,7 @@ export function openStore(model, options) {
820
958
  let entities;
821
959
  let mapping;
822
960
  try {
823
- collections = normalizeModel(model);
961
+ collections = normalizeModel(model, options.expressions);
824
962
  entities = normalizeEntities(model);
825
963
  mapping = entities.size > 0 ? explainMapping(model) : null;
826
964
  if (collections.size === 0 && entities.size === 0) {
@@ -832,17 +970,53 @@ export function openStore(model, options) {
832
970
  return Promise.reject(error);
833
971
  }
834
972
  const path = options.path ?? ':memory:';
835
- const busyTimeout = options.busyTimeout ?? 5000;
836
- const journalMode = options.journalMode ?? 'wal';
837
973
  const memory = path === ':memory:' || path === '';
838
974
  const readOnly = options.readOnly === true;
975
+ // the connection pragmas are a closed, validated set: a pragma outside
976
+ // it is JD0006, one this store kind cannot take is JD0007, and a bad
977
+ // value is API misuse — all settled before the driver opens, so a
978
+ // refused configuration never acquires a handle
979
+ let pragmaRequests;
980
+ try {
981
+ refuseUnsupportedPragmaKeys(options);
982
+ pragmaRequests = resolvePragmaRequests(options, { memory, readOnly });
983
+ }
984
+ catch (error) {
985
+ return Promise.reject(error);
986
+ }
987
+ const busyTimeout = /** @type {number} */ (pragmaRequests.get('busyTimeout')?.value);
839
988
  const storeProfile = options.profile === undefined
840
989
  ? null
841
990
  : normalizeProfile(options.profile);
991
+ // every root a profile's member allow-list may name — the model's own
992
+ // collections and entities — so a policy that names something the
993
+ // model does not declare is refused rather than applied to nothing
994
+ const declaredRoots = Object.freeze([...collections.keys(), ...entities.keys()]);
995
+ try {
996
+ assertProfileRoots(storeProfile, declaredRoots);
997
+ }
998
+ catch (error) {
999
+ return Promise.reject(error);
1000
+ }
1001
+
1002
+ /** A driver failure at or after `driver.open` as the open's own
1003
+ * refusal: `JD0002`, with the classifier's `class`/`retryable` and the
1004
+ * driver's error as `cause`.
1005
+ * @param {any} failure */
1006
+ const openFailure = (failure) => {
1007
+ const classified = classifyDriverError(failure);
1008
+ const wrapped = new DbCompileError('JD0002',
1009
+ `the store could not be opened (${classified.reason}): ${failure?.message ?? String(failure)}`,
1010
+ undefined, failure);
1011
+ wrapped.class = classified.class;
1012
+ wrapped.retryable = classified.retryable;
1013
+ return wrapped;
1014
+ };
842
1015
 
843
1016
  return toPromise(chain(
844
- options.driver.open(path,
1017
+ attempt(() => options.driver.open(path,
845
1018
  { timeout: busyTimeout, readOnly, queueTimeout: options.queueTimeout }),
1019
+ (failure) => (isDriverError(failure) ? openFailure(failure) : failure)),
846
1020
  (opened) => {
847
1021
  /**
848
1022
  * The transaction SCOPE that currently owns the driver connection,
@@ -853,12 +1027,68 @@ export function openStore(model, options) {
853
1027
  */
854
1028
  let scope = null;
855
1029
 
1030
+ /**
1031
+ * The IDENTITY of the scope that is current right now, or `null`.
1032
+ * One fresh identity per `withScope` invocation: it is what every
1033
+ * transaction view is pinned to (§5.1's exact-scope rule), and the
1034
+ * comparison `currentScope === identity` is the whole lifetime
1035
+ * check — a view whose identity is not current has either settled
1036
+ * or been crossed by an inner scope, and refuses `JD2070` before
1037
+ * reading tracker state or issuing a statement. It must never fall
1038
+ * through to the root and never follow a newer scope.
1039
+ * @type {any}
1040
+ */
1041
+ let currentScope = null;
1042
+
1043
+ /**
1044
+ * What the OPEN transaction owes its in-memory callers once the
1045
+ * database has agreed, in registration order, or `null` when no
1046
+ * transaction is open. One list, owned by the outermost scope: a
1047
+ * nested savepoint remembers only where it started, so rolling it
1048
+ * back takes back exactly what it registered and releasing it hands
1049
+ * those effects to the scope that outlives it.
1050
+ *
1051
+ * It exists because an in-memory claim about what the database
1052
+ * holds may not become true before the database does — the unit of
1053
+ * work's snapshots are such a claim, and a savepoint release is not
1054
+ * a commit.
1055
+ * @type {{ commit: () => void, rollback: () => void }[] | null}
1056
+ */
1057
+ let settlements = null;
1058
+
1059
+ /**
1060
+ * The unit of work the OPEN scope writes through, and the store's
1061
+ * own. A nested savepoint inherits whatever is in force — it is the
1062
+ * same unit of work one level down — while a transaction asked for
1063
+ * `unitOfWork: 'own'` gets a fresh one for its callback's lifetime.
1064
+ * Both are set once the model's cores exist.
1065
+ * @type {any}
1066
+ */
1067
+ let work = null;
1068
+ /** @type {any} */
1069
+ let rootWork = null;
1070
+
1071
+ /**
1072
+ * Run what the open transaction owes on its COMMIT, in registration
1073
+ * order, and empty the list. Idempotent, and safe to re-enter: each
1074
+ * effect is taken off the list before it runs.
1075
+ */
1076
+ const flushSettlements = () => {
1077
+ if (settlements === null) return;
1078
+ for (const effect of settlements.splice(0)) effect.commit?.();
1079
+ };
1080
+
856
1081
  /**
857
1082
  * What the cores talk to: the owning transaction's scope while one
858
- * is open, the driver connection otherwise. One indirection here
859
- * instead of a parallel set of handles per transaction — and it is
860
- * why a write inside a transaction callback runs immediately as the
861
- * owner rather than waiting for a commit it is part of.
1083
+ * is open, the driver connection otherwise. One indirection, so a
1084
+ * core issues its statements wherever the caller that reached it is
1085
+ * — and it is why work inside a transaction callback runs
1086
+ * immediately as the owner rather than waiting for a commit it is
1087
+ * part of.
1088
+ *
1089
+ * It is NOT what separates a store-level caller from the
1090
+ * transaction: that is the gate below, which the store's own
1091
+ * handles take and a scope-bound handle does not.
862
1092
  */
863
1093
  const connection = Object.freeze({
864
1094
  get synchronous() { return opened.synchronous; },
@@ -867,51 +1097,113 @@ export function openStore(model, options) {
867
1097
  /** @param {string} sql */
868
1098
  exec: (sql) => (scope ?? opened).exec(sql),
869
1099
  /** @param {string} sql */
870
- prepare: (sql) => (scope ?? opened).prepare(sql),
1100
+ prepare: (sql, metadata) => (scope ?? opened).prepare(sql, metadata),
871
1101
  /** Internal transaction users (jobs, checkpoints, migrations)
872
1102
  * nest when a transaction is open and take the gate when not. */
873
1103
  transaction: (fn) => withScope((scope ?? opened).transaction, fn),
1104
+ /**
1105
+ * Register what settling the OPEN transaction owes an in-memory
1106
+ * caller: `commit` when it commits, `rollback` when it rolls back,
1107
+ * either half optional. With nothing open the statements are
1108
+ * already durable, so `commit` runs at once and the rollback is
1109
+ * discarded. The effects are bookkeeping — they run after the last
1110
+ * statement of their scope and must issue none.
1111
+ * @param {{ commit?: () => void, rollback?: () => void }} effects
1112
+ */
1113
+ onSettle: (effects) => {
1114
+ if (settlements === null) effects.commit?.();
1115
+ else settlements.push(effects);
1116
+ },
874
1117
  registerFunction: opened.registerFunction === null ? null
875
1118
  : (/** @type {string} */ name, /** @type {any} */ o, /** @type {Function} */ fn) =>
876
1119
  opened.registerFunction(name, o, fn),
1120
+ registerAggregate: opened.registerAggregate === null ? null
1121
+ : (/** @type {string} */ name, /** @type {any} */ spec) =>
1122
+ opened.registerAggregate(name, spec),
877
1123
  session: opened.session === null ? null
878
1124
  : (/** @type {any} */ table) => opened.session(table),
1125
+ // the online-backup primitives, when the binding has them
1126
+ backup: opened.backup ?? null,
879
1127
  close: () => opened.close(),
880
1128
  });
881
1129
 
882
1130
  /**
883
1131
  * Run `fn` as a transaction opened by `open`, with `scope` bound to
884
- * it for the callback's whole lifetime and restored afterwards.
1132
+ * it for the callback's whole lifetime and restored afterwards. The
1133
+ * scope also settles what was registered against it: commits in
1134
+ * registration order when it keeps, rollbacks in reverse when it
1135
+ * does not, and only its own when it is an inner savepoint.
1136
+ *
1137
+ * `fn` receives the driver scope and this invocation's fresh
1138
+ * IDENTITY. A user-facing caller builds the transaction view from
1139
+ * the pair; internal nesting (a save's own transaction, a capture
1140
+ * scope, a membership attach) ignores both and runs through the
1141
+ * dynamic connection, which after the view's `JD2070` check is
1142
+ * exactly its own scope.
885
1143
  * @param {(inner: (s: any) => any) => any} open - the driver's
886
1144
  * `transaction`, gated (top level) or nesting (inner)
887
- * @param {(store: any) => any} fn
1145
+ * @param {(inner: any, identity: any) => any} fn
1146
+ * @param {any} [ownWork] - a unit of work for this scope alone;
1147
+ * without one the scope writes through whatever is already in
1148
+ * force, which is what makes an inner savepoint part of the same
1149
+ * unit of work as the transaction around it
888
1150
  */
889
- function withScope(open, fn) {
1151
+ function withScope(open, fn, ownWork) {
890
1152
  return open((inner) => {
891
1153
  const outer = scope;
1154
+ const outerWork = work;
1155
+ const outerIdentity = currentScope;
1156
+ const outermost = settlements === null;
1157
+ if (outermost) settlements = [];
1158
+ const list = /** @type {any[]} */ (settlements);
1159
+ // where this scope's own effects begin: a rollback takes back
1160
+ // from here, and everything before it belongs to a scope that
1161
+ // is still open
1162
+ const mark = list.length;
1163
+ const identity = {};
892
1164
  scope = inner;
893
- const restore = () => { scope = outer; };
1165
+ currentScope = identity;
1166
+ if (ownWork !== undefined) work = ownWork;
1167
+ const kept = () => {
1168
+ if (outermost) flushSettlements();
1169
+ };
1170
+ const undone = () => {
1171
+ const mine = list.splice(mark);
1172
+ for (let i = mine.length - 1; i >= 0; i--) mine[i].rollback?.();
1173
+ };
1174
+ const restore = (settled) => {
1175
+ if (settled) kept();
1176
+ else undone();
1177
+ scope = outer;
1178
+ work = outerWork;
1179
+ currentScope = outerIdentity;
1180
+ if (outermost) settlements = null;
1181
+ };
894
1182
  let out;
895
1183
  try {
896
- out = fn(scopedStore());
1184
+ out = fn(inner, identity);
897
1185
  }
898
1186
  catch (error) {
899
- restore();
1187
+ restore(false);
900
1188
  throw error;
901
1189
  }
902
1190
  if (!isThenable(out)) {
903
- restore();
1191
+ restore(true);
904
1192
  return out;
905
1193
  }
906
1194
  return out.then(
907
- (value) => { restore(); return value; },
908
- (error) => { restore(); throw error; });
1195
+ (value) => { restore(true); return value; },
1196
+ (error) => { restore(false); throw error; });
909
1197
  });
910
1198
  }
911
1199
 
912
- /** Set once the store object exists; the transaction callback's
913
- * argument, whose `transaction` NESTS instead of queueing. */
914
- let scopedStore = () => undefined;
1200
+ /** Set once the store object exists: builds the transaction
1201
+ * callback's argument for ONE exact scope a fresh view per
1202
+ * `withScope` invocation, pinned to its identity, whose
1203
+ * `transaction` NESTS instead of queueing. Assigned before any
1204
+ * user-facing transaction can run, so no placeholder is needed.
1205
+ * @type {(driverScope: any, identity: any) => any} */
1206
+ let scopedStore;
915
1207
 
916
1208
  /**
917
1209
  * A TOP-LEVEL store transaction. It takes the connection's gate
@@ -920,8 +1212,75 @@ export function openStore(model, options) {
920
1212
  * — a rollback then undoes the translated patch together with the
921
1213
  * rows it describes.
922
1214
  * @param {(store: any) => any} fn
1215
+ * @param {AbortSignal} [signal]
1216
+ * @param {any} [ownWork]
923
1217
  */
924
- let topLevelTransaction = (fn) => withScope(opened.transaction, fn);
1218
+ // a driver failure of the transaction itself — a `BEGIN IMMEDIATE`
1219
+ // that outwaits the busy timeout — is classified like a statement's
1220
+ // (`wrapDriverError` passes a callback's own error through untouched)
1221
+ const beginTransaction = (inner, signal, mode) =>
1222
+ attempt(() => opened.transaction(inner, signal, mode),
1223
+ (error) => wrapDriverError(error, { docPath: '/transaction' }));
1224
+ let topLevelTransaction = (fn, signal, ownWork, mode) =>
1225
+ withScope((inner) => beginTransaction(inner, signal, mode),
1226
+ (inner, identity) => fn(scopedStore(inner, identity)), ownWork);
1227
+
1228
+ /**
1229
+ * How a store-level call behaves when another caller's transaction
1230
+ * owns the connection: `'wait'` queues behind it under the
1231
+ * connection's `queueTimeout`, `'strict'` refuses at once. A host
1232
+ * that would rather see the contention than pay for it asks for
1233
+ * strict; the default keeps a contended call correct instead of
1234
+ * fast.
1235
+ */
1236
+ const strictTransactions = options.transactions === 'strict';
1237
+ if (options.transactions !== undefined && options.transactions !== 'wait'
1238
+ && options.transactions !== 'strict') {
1239
+ return Promise.reject(new TypeError(
1240
+ "openStore: transactions must be 'wait' or 'strict'"));
1241
+ }
1242
+
1243
+ /** The refusal a contended store-level call gets when it cannot
1244
+ * wait. It names the scope-bound spelling, because a caller that
1245
+ * meant to be inside the transaction has one and a caller that did
1246
+ * not has to wait for the commit either way. */
1247
+ const contended = (why) => new DbCompileError('JD0012',
1248
+ `a transaction owns this store's connection and ${why}. Work that belongs `
1249
+ + 'INSIDE the transaction goes through the store the callback received '
1250
+ + '(tx.collection / tx.entity / tx.saveChanges); work that does not belongs '
1251
+ + 'after it commits.');
1252
+
1253
+ /**
1254
+ * Run one STORE-LEVEL call: a caller that is not inside whatever
1255
+ * transaction is open. It holds the connection for its own extent,
1256
+ * so its statements can never fall inside a stranger's transaction
1257
+ * and share a rollback it knows nothing about — the defect that
1258
+ * made "one store per concurrent writer" the only safe advice.
1259
+ *
1260
+ * Root jobs and worker control I/O take exactly this gate too:
1261
+ * an unrelated enqueue, claim, renewal or settlement waits for the
1262
+ * open transaction instead of joining its fate, and a `signal`
1263
+ * (a worker winding down) abandons a call still in the queue.
1264
+ * @param {() => any} fn
1265
+ * @param {string} [what] - what is waiting, for the timeout message
1266
+ * @param {AbortSignal} [signal]
1267
+ */
1268
+ const gated = (fn, what, signal) => {
1269
+ if (strictTransactions && opened.mustQueue)
1270
+ throw contended("{ transactions: 'strict' } refuses to queue behind it");
1271
+ return withScope((inner) => opened.exclusively(inner, what, signal), fn);
1272
+ };
1273
+
1274
+ /** The synchronous surface's gate. It cannot wait — waiting hands
1275
+ * a Promise back under a value's type — so a contended call is a
1276
+ * refusal whatever the mode. */
1277
+ const gatedSync = (fn) => {
1278
+ if (opened.mustQueue) {
1279
+ throw contended('the synchronous surface answers values, so it cannot '
1280
+ + 'wait for the commit');
1281
+ }
1282
+ return withScope(opened.exclusively, fn);
1283
+ };
925
1284
 
926
1285
  // ————— the rejection boundary around an ACQUIRED connection —————
927
1286
  // Initialization continues for a long way past `driver.open`:
@@ -939,7 +1298,11 @@ export function openStore(model, options) {
939
1298
  * @param {any} error
940
1299
  * @returns {Promise<never>}
941
1300
  */
942
- const failClosed = (error) => {
1301
+ const failClosed = (failure) => {
1302
+ // a driver failure inside the open sequence (a locked or corrupt
1303
+ // file, an unopenable path) is the open's refusal, classed; a
1304
+ // coded refusal or API misuse is itself
1305
+ const error = isDriverError(failure) ? openFailure(failure) : failure;
943
1306
  if (closed) return Promise.reject(error);
944
1307
  closed = true;
945
1308
  /** @param {any} closeError */
@@ -963,8 +1326,9 @@ export function openStore(model, options) {
963
1326
  // property of the driver that created the file, so a database
964
1327
  // built under one and opened under the other legitimately reports
965
1328
  // drift — that is a migration, not an open.
966
- const derivedMapping = connection.capabilities.deterministicIndexableFunctions === true
967
- ? 'virtual' : 'stored';
1329
+ const registersFunctions =
1330
+ connection.capabilities.deterministicIndexableFunctions === true;
1331
+ const derivedMapping = registersFunctions ? 'virtual' : 'stored';
968
1332
  // the SECOND physical branch, and the same posture: a build
969
1333
  // without the R*Tree module maps `physical: 'rtree'` back onto
970
1334
  // the B-tree over the four columns and SAYS so through
@@ -984,7 +1348,8 @@ export function openStore(model, options) {
984
1348
  try {
985
1349
  for (const [name, collection] of collections)
986
1350
  plans.set(name, planCollection(name, collection, dialect,
987
- { derived: derivedMapping, rtree: rtreeCapable }));
1351
+ { derived: derivedMapping, rtree: rtreeCapable,
1352
+ expressions: options.expressions, registered: registersFunctions }));
988
1353
  if (mapping !== null) {
989
1354
  for (const name of Object.keys(mapping.entities))
990
1355
  entityPlans.set(name, planEntity(name, mapping.entities[name], mapping, dialect));
@@ -1005,17 +1370,34 @@ export function openStore(model, options) {
1005
1370
  const needsDeriveFunctions = derivedMapping === 'virtual'
1006
1371
  && [...plans.values()].some((plan) => plan.generated.some(
1007
1372
  (column) => column.derive !== undefined && column.stored !== true));
1373
+ // A table whose column expression calls a declared function cannot
1374
+ // be SELECTed from — let alone written to — by a connection that
1375
+ // has not registered it, so the registration precedes every
1376
+ // statement over it. Only where this connection COMPUTES the
1377
+ // expression: where the engine calls its own immutable function
1378
+ // there is nothing to register.
1379
+ const expressionNames = registersFunctions
1380
+ ? [...new Set([...plans.values()].flatMap((plan) =>
1381
+ plan.expressions.flatMap((entry) => entry.functions)))].sort()
1382
+ : [];
1008
1383
 
1009
- const pragmas = chain(
1010
- memory
1011
- ? null
1012
- : chain(connection.exec(dialect.pragma.busyTimeout(busyTimeout)),
1013
- // a journal-mode change writes; a read-only store keeps
1014
- // whatever mode the file already has
1015
- () => (readOnly ? null : connection.exec(dialect.pragma.journalMode(journalMode)))),
1016
- // referential integrity is real only when the pragma is ON —
1017
- // it defaults off, so set it AND verify it per connection
1018
- () => chain(connection.exec(dialect.pragma.foreignKeys(true)), () =>
1384
+ /** The effective connection pragmas, read back after the open
1385
+ * sequence applied them — what the capability report carries.
1386
+ * @type {any} */
1387
+ let effectivePragmas = null;
1388
+ // the closed configuration set, applied in table order and then
1389
+ // read back in full (JD0007 for a pragma this binding cannot
1390
+ // apply, JD0008 for one the engine did not take); then referential
1391
+ // integrity, which is real only when the pragma is ON — it
1392
+ // defaults off, so it is set AND verified per connection. Built
1393
+ // inside `opening` so a synchronous refusal reaches `failClosed`
1394
+ const pragmas = () => chain(configurePragmas(connection, pragmaRequests), (effective) => {
1395
+ effectivePragmas = effective;
1396
+ // an engine that always enforces referential integrity has no
1397
+ // switch to set and nothing to read back; SQLite's defaults OFF,
1398
+ // so there it is set AND verified per connection
1399
+ if (dialect.capabilities.foreignKeysAlwaysOn === true) return null;
1400
+ return chain(connection.exec(dialect.pragma.foreignKeys(true)), () =>
1019
1401
  chain(connection.prepare(dialect.introspect.foreignKeysOn()), (statement) =>
1020
1402
  chain(statement.get([]), (row) => {
1021
1403
  if (Number(row?.enabled) !== 1) {
@@ -1023,9 +1405,12 @@ export function openStore(model, options) {
1023
1405
  'this connection cannot enforce foreign keys (PRAGMA foreign_keys stayed off)');
1024
1406
  }
1025
1407
  return null;
1026
- }))));
1408
+ })));
1409
+ });
1027
1410
 
1028
- const opening = () => chain(pragmas, () =>
1411
+ const opening = () => chain(pragmas(), () =>
1412
+ chain(registerExpressionFunctions(connection, expressionNames,
1413
+ options.expressions ?? {}), () =>
1029
1414
  chain(needsDeriveFunctions ? registerDeriveFunctions(connection) : null, () =>
1030
1415
  chain(ensureShape(connection, collections, plans, readOnly), () =>
1031
1416
  chain(ensureEntityShape(connection, entityPlans, entities, readOnly), () => {
@@ -1047,7 +1432,7 @@ export function openStore(model, options) {
1047
1432
  throw new TypeError('openStore: compileSchema must return a validation function');
1048
1433
  core = captureCollection(name, collectionCore(connection, collection,
1049
1434
  plans.get(name), validate, queryState,
1050
- { profile: storeProfile }));
1435
+ { profile: storeProfile, roots: declaredRoots }, runtime));
1051
1436
  cores.set(name, core);
1052
1437
  }
1053
1438
  return core;
@@ -1061,6 +1446,16 @@ export function openStore(model, options) {
1061
1446
  let captureMode = 'none';
1062
1447
  if (captureRequested !== null) {
1063
1448
  const wanted = captureRequested.mode ?? 'auto';
1449
+ // the ledger and its journal are SQLite spellings; a
1450
+ // connection that says it has no change capture is refused
1451
+ // by name rather than at the first statement over a table
1452
+ // this store would never have created there
1453
+ if (connection.capabilities.changeCapture !== true) {
1454
+ throw new DbCompileError('JD0051',
1455
+ 'change capture is unavailable on this driver: it declares no change '
1456
+ + 'ledger, so neither a changeset journal nor a live query can be built '
1457
+ + 'on it');
1458
+ }
1064
1459
  const hasSessions = connection.capabilities.sessions === true
1065
1460
  && typeof connection.session === 'function';
1066
1461
  if (wanted === 'session' && !hasSessions) {
@@ -1114,26 +1509,52 @@ export function openStore(model, options) {
1114
1509
  });
1115
1510
  }
1116
1511
  for (const joinName of Object.keys(mapping?.joinTables ?? {})) {
1117
- const pair = joinName.split('_');
1118
- const columns = pair.map((part) => ({ name: `${part}_key`, role: 'key' }));
1512
+ const join = mapping.joinTables[joinName];
1513
+ const columns = [join.left, join.right]
1514
+ .map((side) => ({ name: side.column, role: 'key' }));
1119
1515
  captureShapes.set(joinName, {
1120
1516
  kind: 'join', columns,
1121
1517
  keyIndexes: columns.map((_, i) => i), docIndex: -1,
1122
1518
  });
1123
1519
  }
1124
1520
  }
1521
+ // every first-open object — the change log and its state row
1522
+ // here, the job tables below — is created or verified under
1523
+ // the same immediate bracket as the collections' shape, so two
1524
+ // processes opening one fresh file cannot race the seed row or
1525
+ // a column upgrade; a read-only store creates nothing and takes
1526
+ // no lock
1527
+ const firstOpen = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
1125
1528
  const capture = captureMode === 'none' ? null : createCaptureEngine({
1126
1529
  connection,
1530
+ bracket: firstOpen,
1127
1531
  shapes: captureShapes,
1128
1532
  mode: captureMode,
1129
1533
  log: captureRequested.log === true
1130
1534
  || (captureRequested.log !== undefined && captureRequested.log !== false),
1131
1535
  retention: captureRequested.log?.retention ?? DEFAULT_RETENTION,
1536
+ now: runtime.now,
1132
1537
  });
1133
- const guard = capture === null ? (fn) => fn() : capture.wrap;
1538
+ // the capture scope around a write runs statements of its own
1539
+ // (a session's changeset read, the journal's old-row read, the
1540
+ // log's allocation); a driver failure there is classified as
1541
+ // the write's would be
1542
+ const guard = capture === null
1543
+ ? (fn) => fn()
1544
+ : (fn) => attempt(() => capture.wrap(fn),
1545
+ (error) => wrapDriverError(error, { docPath: '/capture' }));
1134
1546
  if (capture !== null) {
1135
- topLevelTransaction = (fn) => withScope(opened.transaction,
1136
- (tx) => capture.nest(() => fn(tx)));
1547
+ // capture changes how records are TRANSLATED, never queue
1548
+ // cancellation or tracker ownership: the replacement has the
1549
+ // ordinary function's exact signature and forwards `signal`
1550
+ // and `ownWork`, with `capture.nest` inside the opened scope.
1551
+ // The view is built from the scope capture's wrap opens —
1552
+ // the INNERMOST one, the exact scope the callback runs in.
1553
+ topLevelTransaction = (fn, signal, ownWork, mode) =>
1554
+ withScope((inner) => beginTransaction(inner, signal, mode),
1555
+ () => capture.nest((innerScope, identity) =>
1556
+ fn(scopedStore(innerScope, identity))),
1557
+ ownWork);
1137
1558
  }
1138
1559
  // the live registry rides the capture stream; its dispatcher
1139
1560
  // registers FIRST so maintenance sees every record before any
@@ -1148,21 +1569,42 @@ export function openStore(model, options) {
1148
1569
  // the durable job queue (JOBS-FORMAT), opt-in per store
1149
1570
  const jobsRequested = options.jobs === true
1150
1571
  || (options.jobs !== undefined && options.jobs !== false);
1572
+ if (jobsRequested && connection.capabilities.jobs !== true) {
1573
+ throw new DbCompileError('JD0003',
1574
+ 'the durable job queue is unavailable on this driver: it declares no job '
1575
+ + 'queue, and the queue writes its own SQLite statements');
1576
+ }
1151
1577
  const jobsEngine = !jobsRequested ? null : createJobEngine({
1152
1578
  connection,
1579
+ bracket: firstOpen,
1580
+ // the WORKER's control-plane I/O (claims, renewals, its
1581
+ // checkpoint stores, its settlements) is root-owned and takes
1582
+ // the store gate, so it can never join an open application
1583
+ // transaction; `tx.jobs` bypasses this by running as the
1584
+ // exact scope, which is the transactional-outbox spelling
1585
+ gate: (fn, what, signal) => gated(fn, what, signal),
1153
1586
  now: typeof options.jobs === 'object' ? options.jobs.now : undefined,
1154
1587
  random: typeof options.jobs === 'object' ? options.jobs.random : undefined,
1155
1588
  defaults: typeof options.jobs === 'object' ? options.jobs : undefined,
1589
+ runtime,
1156
1590
  });
1157
1591
  /** Register a collection live query (LIVE-FORMAT §7). */
1592
+ const refuseAsyncLive = () => {
1593
+ if (connection.synchronous !== true) {
1594
+ throw new DbCompileError('JD0051',
1595
+ 'live queries are not maintained over an asynchronous connection — a '
1596
+ + 'synchronous driver (node, bun, a synchronous wasm handle) keeps them');
1597
+ }
1598
+ };
1158
1599
  const registerCollectionLive = (core, document, liveOptions) => {
1600
+ refuseAsyncLive();
1159
1601
  const externals = liveOptions?.externals ?? {};
1160
1602
  const keyed = core.model.keySegments !== null;
1161
1603
  const eventTime = normalizeEventTime(liveOptions, core.model.name);
1162
1604
  const classification = liveOptions?.mode === 'rerun'
1163
1605
  ? { strategy: 'rerun', reason: 'rerun was requested' }
1164
1606
  : classifyLiveQuery(document, core.queryShape, keyed, eventTime);
1165
- return /** @type {any} */ (liveRegistry).register({
1607
+ return closeOnRollback(/** @type {any} */ (liveRegistry).register({
1166
1608
  name: core.model.name,
1167
1609
  tables: new Set([core.model.name]),
1168
1610
  document,
@@ -1173,8 +1615,17 @@ export function openStore(model, options) {
1173
1615
  readRow: (token) => core.get(token),
1174
1616
  keyOf: (doc) => String(extractKey(doc, core.model.keySegments,
1175
1617
  core.model.key, core.model.name, core.model.docPath)),
1176
- });
1618
+ }));
1177
1619
  };
1620
+ /** A live query registered INSIDE a transaction initialized from
1621
+ * that transaction's rows; if the transaction rolls back, those
1622
+ * rows never existed and the query is closed with them rather
1623
+ * than left maintaining a result nothing committed. Registered
1624
+ * at the root, the scope settles at once and nothing is owed. */
1625
+ const closeOnRollback = (registered) => chain(registered, (live) => {
1626
+ connection.onSettle({ rollback: () => live.close() });
1627
+ return live;
1628
+ });
1178
1629
  /** Strip relation members before journal diffs — sessions
1179
1630
  * never see them (they are not stored), so the two modes
1180
1631
  * stay identical. */
@@ -1193,26 +1644,39 @@ export function openStore(model, options) {
1193
1644
  const captureJoinDelete = capture === null || capture.mode !== 'journal'
1194
1645
  ? null
1195
1646
  : (entityName, keyParts) => {
1196
- const joins = Object.keys(mapping?.joinTables ?? {})
1197
- .filter((joinName) => joinName.split('_').includes(entityName));
1647
+ const joins = Object.entries(mapping?.joinTables ?? {})
1648
+ .filter(([, join]) => join.left.entity === entityName
1649
+ || join.right.entity === entityName);
1198
1650
  const nextJoin = (i) => {
1199
1651
  if (i >= joins.length) return null;
1200
- const joinName = joins[i];
1201
- const pair = joinName.split('_');
1202
- const sql = `SELECT ${pair.map((part) => dialect.quoteIdentifier(`${part}_key`)).join(', ')} `
1652
+ const [joinName, join] = joins[i];
1653
+ const columns = [join.left.column, join.right.column];
1654
+ const own = join.left.entity === entityName ? join.left : join.right;
1655
+ const sql = `SELECT ${columns.map(dialect.quoteIdentifier).join(', ')} `
1203
1656
  + `FROM ${dialect.quoteIdentifier(joinName)} `
1204
- + `WHERE ${dialect.quoteIdentifier(`${entityName}_key`)} = ${dialect.parameterRef(1, 'v')}`;
1657
+ + `WHERE ${dialect.quoteIdentifier(own.column)} = ${dialect.parameterRef(1, 'v')}`;
1205
1658
  return chain(connection.prepare(sql), (statement) =>
1206
1659
  chain(statement.all([keyParts[0]]), (rows) => {
1207
1660
  for (const row of rows) {
1208
- capture.record(joinName,
1209
- pair.map((part) => row[`${part}_key`]), undefined, null);
1661
+ capture.record(joinName, columns.map((column) => row[column]), undefined, null);
1210
1662
  }
1211
1663
  return nextJoin(i + 1);
1212
1664
  }));
1213
1665
  };
1214
1666
  return nextJoin(0);
1215
1667
  };
1668
+ /** The document a keyed `put` replaces, for the journal's
1669
+ * before-image: resolved through the collection's own key when
1670
+ * the caller passed none — a plain upsert recorded as an
1671
+ * `add` of the whole document, and a no-op put as a record. */
1672
+ const readBefore = (core, doc, key) => {
1673
+ const resolved = key !== undefined ? key
1674
+ : core.model.keySegments !== null
1675
+ ? extractKey(doc, core.model.keySegments, core.model.key,
1676
+ core.model.name, core.model.docPath)
1677
+ : undefined;
1678
+ return resolved === undefined ? undefined : core.get(resolved);
1679
+ };
1216
1680
  /** Journal-mode write wrappers for a collection core. */
1217
1681
  const captureCollection = (collectionName, core) => {
1218
1682
  if (capture === null) return core;
@@ -1224,7 +1688,7 @@ export function openStore(model, options) {
1224
1688
  return key;
1225
1689
  })),
1226
1690
  put: (doc, key) => guard(() => (journal
1227
- ? chain(key === undefined ? undefined : core.get(key), (before) =>
1691
+ ? chain(readBefore(core, doc, key), (before) =>
1228
1692
  chain(core.put(doc, key), (storedKey) => {
1229
1693
  capture.record(collectionName, [storedKey], before ?? null, doc);
1230
1694
  return storedKey;
@@ -1284,11 +1748,42 @@ export function openStore(model, options) {
1284
1748
  };
1285
1749
  };
1286
1750
 
1751
+ // the maintenance operations over this connection; the store
1752
+ // gates each call below, exactly as a root write is gated
1753
+ const maintenance = createMaintenance({ connection, readOnly, now: runtime.now });
1754
+ // the online backup over the same connection: its checkpoint
1755
+ // boundary takes the gate, its copy runs off it
1756
+ const backup = createBackup({
1757
+ connection, readOnly, gated, checkpoint: maintenance.checkpoint, random: runtime.random,
1758
+ now: runtime.now,
1759
+ });
1760
+
1287
1761
  const capabilities = Object.freeze({
1288
1762
  ...connection.capabilities,
1763
+ // per-operation availability: the binding's declaration, and
1764
+ // for the two that write the store's read-only flag — `false`
1765
+ // exactly where a call is refused (`JD2077`)
1766
+ maintenance: Object.freeze({ ...maintenance.capabilities, backup: backup.capability }),
1767
+ // where a cancellation takes effect, per lifecycle — the
1768
+ // granularity the driver actually has. `midStatement` is a
1769
+ // filled slot, not an absent one: no shipped SQLite binding
1770
+ // exposes an interrupt, and a driver that grows one flips
1771
+ // exactly this member
1772
+ cancellation: Object.freeze({
1773
+ query: 'row', queue: true, migration: 'step', maintenance: 'statement',
1774
+ backup: 'page', midStatement: false,
1775
+ }),
1289
1776
  validated: options.compileSchema !== undefined,
1290
- busyTimeoutMs: memory ? null : busyTimeout,
1291
- journalMode: memory || readOnly ? null : journalMode,
1777
+ // the effective connection configuration, read back after the
1778
+ // open sequence applied it: every pragma of the closed set by
1779
+ // option name, `null` where the binding declares the pragma
1780
+ // absent or the engine answers nothing (a memory database's
1781
+ // `mmapSize`)
1782
+ pragmas: effectivePragmas,
1783
+ // the two long-published members, sourced from that same
1784
+ // read-back — never from the request
1785
+ busyTimeoutMs: effectivePragmas.busyTimeout,
1786
+ journalMode: effectivePragmas.journalMode,
1292
1787
  readOnly,
1293
1788
  profiled: storeProfile !== null,
1294
1789
  // the registered operator vocabulary (Ring 2): the names a
@@ -1307,20 +1802,23 @@ export function openStore(model, options) {
1307
1802
  // ZONE will compile at all here. Without one the document is
1308
1803
  // refused (`JQ0003`) rather than answered in UTC, and a
1309
1804
  // consumer that wants to know before it asks reads this
1310
- zoneProvider: options.zoneProvider !== undefined && options.zoneProvider !== null,
1805
+ zoneProvider: zoneProvider !== undefined && zoneProvider !== null,
1311
1806
  capture: captureMode,
1312
1807
  captureLog: captureMode !== 'none'
1313
1808
  && (captureRequested.log === true
1314
1809
  || (captureRequested.log !== undefined && captureRequested.log !== false)),
1315
- live: captureMode !== 'none',
1810
+ // maintenance reads rows synchronously; an asynchronous
1811
+ // connection is never maintained (LIVE-FORMAT §12) and says so
1812
+ live: captureMode !== 'none' && connection.synchronous === true,
1316
1813
  jobs: options.jobs === true
1317
1814
  || (options.jobs !== undefined && options.jobs !== false),
1318
1815
  });
1319
1816
 
1320
1817
  const queryState = createQueryState(options.statementCacheBound, operators,
1321
- options.zoneProvider);
1818
+ zoneProvider, runtime.now);
1322
1819
  const entityEngine = entities.size > 0
1323
- ? createEntityQueryEngine({ connection, entities, mapping, state: queryState })
1820
+ ? createEntityQueryEngine({ connection, entities, mapping, state: queryState,
1821
+ profile: storeProfile, roots: declaredRoots })
1324
1822
  : null;
1325
1823
  /** @type {Map<string, any>} */
1326
1824
  const loadEngines = new Map();
@@ -1333,7 +1831,8 @@ export function openStore(model, options) {
1333
1831
  { docPath: '/entities', collection: name });
1334
1832
  }
1335
1833
  engine = createLoadEngine(
1336
- { connection, entities, mapping, state: queryState }, name);
1834
+ { connection, entities, mapping, state: queryState, coreFor: entityCoreFor,
1835
+ profile: storeProfile, roots: declaredRoots }, name);
1337
1836
  loadEngines.set(name, engine);
1338
1837
  }
1339
1838
  return engine;
@@ -1350,40 +1849,127 @@ export function openStore(model, options) {
1350
1849
  { docPath: '/entities', collection: name });
1351
1850
  }
1352
1851
  const validate = options.compileSchema !== undefined
1353
- ? options.compileSchema(entity.schema)
1852
+ ? options.compileSchema(writeSchemaOf(entity))
1354
1853
  : null;
1854
+ if (validate !== null && typeof validate !== 'function')
1855
+ throw new TypeError('openStore: compileSchema must return a validation function');
1355
1856
  core = captureEntity(name, entityCore(connection, entity,
1356
- mapping.entities[name], validate));
1857
+ mapping.entities[name], validate, runtime));
1357
1858
  entityCores.set(name, core);
1358
1859
  }
1359
1860
  return core;
1360
1861
  };
1361
1862
 
1362
- const tracker = entities.size > 0
1363
- ? createTracker({
1364
- connection, entities, mapping, coreFor: entityCoreFor,
1365
- captureRecord: capture === null || capture.mode !== 'journal'
1366
- ? undefined
1367
- : (table, keyParts, before, after) => capture.record(table, keyParts,
1368
- before === undefined ? undefined : stripRelations(table, before),
1369
- stripRelations(table, after)),
1370
- captureJoinDelete: captureJoinDelete ?? undefined,
1371
- })
1372
- : null;
1373
- /** @type {Map<string, any>} */
1374
- const trackedOps = new Map();
1375
- // the unit-of-work surface (§11): reads register frozen
1376
- // snapshots; add/put/remove are LOCAL bookkeeping (no
1377
- // database round trip, deliberately synchronous on both
1378
- // surfaces); asNoTracking() reads retain nothing
1379
- const trackedOpsFor = (name) => {
1863
+ /**
1864
+ * ONE unit of work and the tracked operations that write through
1865
+ * it. The store has one; a transaction may be given its own, so
1866
+ * two concurrent handlers hold two records for the same entity
1867
+ * key and neither can see the other's pending state — which is
1868
+ * what makes one store safe for a handler per request.
1869
+ *
1870
+ * The cores, engines and plans below it are shared: what a
1871
+ * second unit of work costs is its own map of records, not a
1872
+ * second copy of the model.
1873
+ */
1874
+ const createUnitOfWork = () => {
1875
+ const tracker = entities.size > 0
1876
+ ? createTracker({
1877
+ connection, entities, mapping, coreFor: entityCoreFor,
1878
+ captureRecord: capture === null || capture.mode !== 'journal'
1879
+ ? undefined
1880
+ : (table, keyParts, before, after) => capture.record(table, keyParts,
1881
+ before === undefined ? undefined : stripRelations(table, before),
1882
+ stripRelations(table, after)),
1883
+ captureJoinDelete: captureJoinDelete ?? undefined,
1884
+ })
1885
+ : null;
1886
+ /** @type {Map<string, any>} */
1887
+ const trackedOps = new Map();
1888
+ /** @type {Map<string, any>} */
1889
+ const entityHandles = new Map();
1890
+ /** @type {Map<string, any>} */
1891
+ const syncEntityHandles = new Map();
1892
+ // the unit-of-work surface (§11): reads register frozen
1893
+ // snapshots; add/put/remove are LOCAL bookkeeping (no
1894
+ // database round trip, deliberately synchronous on both
1895
+ // surfaces); asNoTracking() reads retain nothing
1896
+ const trackedOpsFor = (name) => {
1380
1897
  let ops = trackedOps.get(name);
1381
1898
  if (ops !== undefined) return ops;
1382
1899
  const core = entityCoreFor(name);
1383
1900
  const loads = loadEngineFor(name);
1901
+ /**
1902
+ * The many-to-many memberships a document carries, as join
1903
+ * rows: `create()` attaches them after the insert, in the
1904
+ * same transaction — the `<Name>Input` type and `add()` say
1905
+ * a membership array is writable, and `create()` refusing it
1906
+ * made the generated type a lie.
1907
+ * @param {any} doc
1908
+ */
1909
+ const membershipsOf = (doc) => {
1910
+ const out = [];
1911
+ for (const property of entities.get(name).properties.values()) {
1912
+ const relation = property.relation;
1913
+ if (relation?.kind !== 'manyToMany') continue;
1914
+ const join = mapping.joinTables[relation.joinTable];
1915
+ const own = join.left.entity === name ? join.left : join.right;
1916
+ const target = own === join.left ? join.right : join.left;
1917
+ const keys = [...new Set(membershipKeys(doc?.[property.name],
1918
+ target.referencesKey, property.name,
1919
+ (reason) => new DbRuntimeError('JD2003', reason,
1920
+ { docPath: entities.get(name).docPath, collection: name })))];
1921
+ if (keys.length > 0) out.push({ table: relation.joinTable, join, own, target, keys });
1922
+ }
1923
+ return out;
1924
+ };
1925
+ const attach = (made, memberships) => {
1926
+ const ownKey = made[mapping.entities[name].keys[0]];
1927
+ const next = (i) => {
1928
+ if (i >= memberships.length) return null;
1929
+ const { table, join, own, target, keys } = memberships[i];
1930
+ const sql = `INSERT INTO ${dialect.quoteIdentifier(table)} `
1931
+ + `(${dialect.quoteIdentifier(own.column)}, ${dialect.quoteIdentifier(target.column)}) `
1932
+ + `VALUES (${dialect.parameterRef(1, 'v')}, ${dialect.parameterRef(2, 'v')})`;
1933
+ return chain(connection.prepare(sql), (statement) => {
1934
+ const row = (j) => {
1935
+ if (j >= keys.length) return next(i + 1);
1936
+ let ran;
1937
+ try {
1938
+ ran = statement.run([ownKey, keys[j]]);
1939
+ }
1940
+ catch (error) {
1941
+ throw new DbRuntimeError('JD2005',
1942
+ `the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
1943
+ { docPath: entities.get(name).docPath, collection: name, key: ownKey, cause: error });
1944
+ }
1945
+ return chain(ran, () => {
1946
+ if (capture !== null && capture.mode === 'journal') {
1947
+ const value = { [own.column]: ownKey, [target.column]: keys[j] };
1948
+ const ordered = {};
1949
+ for (const column of [join.left.column, join.right.column])
1950
+ ordered[column] = value[column];
1951
+ capture.record(table, Object.values(ordered), null, ordered);
1952
+ }
1953
+ return row(j + 1);
1954
+ });
1955
+ };
1956
+ return row(0);
1957
+ });
1958
+ };
1959
+ return next(0);
1960
+ };
1384
1961
  ops = {
1385
- create: (doc) => chain(core.create(doc),
1386
- (made) => tracker.register(name, made)),
1962
+ create: (doc) => {
1963
+ const memberships = membershipsOf(doc);
1964
+ if (memberships.length === 0)
1965
+ return chain(core.create(doc), (made) => tracker.register(name, made));
1966
+ // one capture scope and one transaction around the row and
1967
+ // its join rows: a membership the database refuses rolls the
1968
+ // row back too, and journal capture records the join rows
1969
+ return chain(guard(() => connection.transaction(() =>
1970
+ chain(core.create(doc), (made) => chain(attach(made, memberships), () => made)))),
1971
+ (made) => tracker.register(name, made));
1972
+ },
1387
1973
  get: (key) => chain(core.get(key), (doc) =>
1388
1974
  (doc === undefined ? undefined : tracker.register(name, doc))),
1389
1975
  update: (key, changes) => chain(core.update(key, changes),
@@ -1392,116 +1978,373 @@ export function openStore(model, options) {
1392
1978
  tracker.discard(name, key);
1393
1979
  return done;
1394
1980
  }),
1395
- load: (spec) => chain(loads.load(spec),
1981
+ load: (spec, loadOptions) => chain(loads.load(spec, loadOptions),
1396
1982
  (docs) => tracker.registerGraph(loads.treeFor(spec), docs)),
1397
- explainLoad: (spec) => loads.explainLoad(spec),
1983
+ // the graph cursor registers nothing unless asked: a
1984
+ // snapshot per yielded root is a tracker that grows with the
1985
+ // result, so it is the caller's decision (`tracking: true`)
1986
+ syncLoadCursor: (spec, cursorOptions) => loads.syncLoadCursor(spec, cursorOptions,
1987
+ cursorOptions?.tracking === true
1988
+ ? (tree, doc) => tracker.registerGraph(tree, [doc])[0] : undefined),
1989
+ syncPage: (spec, pageOptions) => loads.syncPage(spec, pageOptions,
1990
+ pageOptions?.tracking === true
1991
+ ? (tree, doc) => tracker.registerGraph(tree, [doc])[0] : undefined),
1992
+ loadCursor: (spec, cursorOptions) => loads.loadCursor(spec, cursorOptions,
1993
+ cursorOptions?.tracking === true
1994
+ ? (tree, doc) => tracker.registerGraph(tree, [doc])[0] : undefined),
1995
+ page: (spec, pageOptions) => loads.page(spec, pageOptions,
1996
+ pageOptions?.tracking === true
1997
+ ? (tree, doc) => tracker.registerGraph(tree, [doc])[0] : undefined),
1998
+ explainLoad: (spec, loadOptions) => loads.explainLoad(spec, loadOptions),
1398
1999
  add: (doc) => tracker.add(name, doc),
1399
2000
  put: (next) => tracker.put(name, next),
1400
2001
  remove: (keyOrDoc) => tracker.remove(name, keyOrDoc),
1401
2002
  discard: (keyOrDoc) => tracker.discard(name, keyOrDoc),
2003
+ // membership (§11.7): local bookkeeping like add/put/remove;
2004
+ // the join rows are written by saveChanges()
2005
+ link: (own, member, target) => tracker.link(name, own, member, target),
2006
+ unlink: (own, member, target) => tracker.unlink(name, own, member, target),
1402
2007
  noTracking: {
1403
2008
  get: (key) => core.get(key),
1404
- load: (spec) => loads.load(spec),
2009
+ load: (spec, loadOptions) => loads.load(spec, loadOptions),
1405
2010
  },
1406
2011
  };
1407
2012
  trackedOps.set(name, ops);
1408
2013
  return ops;
2014
+ };
2015
+
2016
+ /**
2017
+ * An entity handle over THIS unit of work, bound to whatever
2018
+ * scope is open when it runs — the tracked surface, its
2019
+ * untracked twin, and the provider members.
2020
+ * @param {string} name
2021
+ */
2022
+ const entityFor = (name) => {
2023
+ let handle = entityHandles.get(name);
2024
+ if (handle === undefined) {
2025
+ const ops = trackedOpsFor(name);
2026
+ const untracked = Object.freeze({
2027
+ get: lift((key) => ops.noTracking.get(key)),
2028
+ load: lift((spec, loadOptions) => ops.noTracking.load(spec, loadOptions)),
2029
+ });
2030
+ handle = Object.freeze({
2031
+ create: lift((doc) => ops.create(doc)),
2032
+ get: lift((key) => ops.get(key)),
2033
+ update: lift((key, changes) => ops.update(key, changes)),
2034
+ delete: lift((key) => ops.delete(key)),
2035
+ load: lift((spec, loadOptions) => ops.load(spec, loadOptions)),
2036
+ loadCursor: (spec, cursorOptions) => ops.loadCursor(spec, cursorOptions),
2037
+ page: lift((spec, pageOptions) => ops.page(spec, pageOptions)),
2038
+ explainLoad: ops.explainLoad,
2039
+ add: ops.add,
2040
+ put: ops.put,
2041
+ remove: ops.remove,
2042
+ discard: ops.discard,
2043
+ link: ops.link,
2044
+ unlink: ops.unlink,
2045
+ asNoTracking: () => untracked,
2046
+ // the provider contract over ONE entity root (MODEL-FORMAT
2047
+ // §10.1): the document is over the multi-entity root and
2048
+ // goes to the entity engine whole; `root` is the hint a
2049
+ // chain binds its items through, `scope` the identity two
2050
+ // sets of one store share so their documents may be joined
2051
+ // (it carries every root's `relations`, so a hop may chain),
2052
+ // `relations` this entity's own relation table (§10.1).
2053
+ // `execute` stays value-or-promise (D2), as a collection's
2054
+ execute: (document, queryOptions) => entityEngine.execute(document, queryOptions),
2055
+ // the item cursor over the same document: one row per
2056
+ // pull, the statement released on break. It registers
2057
+ // NO snapshot by default — a cursor that tracked every
2058
+ // row it yielded would be an unbounded tracker — and
2059
+ // `tracking: true` opts in per call, documented as
2060
+ // unbounded in the result size
2061
+ cursor: (document, queryOptions) => entityEngine.query(document, queryOptions,
2062
+ queryOptions?.tracking === true
2063
+ ? (entity, doc) => tracker.register(entity, doc) : undefined),
2064
+ explain: lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
2065
+ root: entityRoot(name),
2066
+ scope: entityEngine,
2067
+ relations: entityEngine.relations[name],
2068
+ });
2069
+ entityHandles.set(name, handle);
2070
+ }
2071
+ return handle;
2072
+ };
2073
+
2074
+ /** The same set, answering values. */
2075
+ const syncEntityFor = (name) => {
2076
+ let handle = syncEntityHandles.get(name);
2077
+ if (handle === undefined) {
2078
+ const ops = trackedOpsFor(name);
2079
+ const untracked = Object.freeze({
2080
+ get: (key) => ops.noTracking.get(key),
2081
+ load: (spec, loadOptions) => ops.noTracking.load(spec, loadOptions),
2082
+ });
2083
+ handle = Object.freeze({
2084
+ create: (doc) => ops.create(doc),
2085
+ get: (key) => ops.get(key),
2086
+ update: (key, changes) => ops.update(key, changes),
2087
+ delete: (key) => ops.delete(key),
2088
+ load: (spec, loadOptions) => ops.load(spec, loadOptions),
2089
+ loadCursor: (spec, cursorOptions) => ops.syncLoadCursor(spec, cursorOptions),
2090
+ page: (spec, pageOptions) => ops.syncPage(spec, pageOptions),
2091
+ cursor: (document, queryOptions) => entityEngine.syncQuery(document, queryOptions,
2092
+ queryOptions?.tracking === true
2093
+ ? (entity, doc) => tracker.register(entity, doc) : undefined),
2094
+ explainLoad: ops.explainLoad,
2095
+ add: ops.add,
2096
+ put: ops.put,
2097
+ remove: ops.remove,
2098
+ discard: ops.discard,
2099
+ link: ops.link,
2100
+ unlink: ops.unlink,
2101
+ asNoTracking: () => untracked,
2102
+ // the same provider members as the asynchronous handle,
2103
+ // answering values; one handle per name, so two chains
2104
+ // over one set share one source identity
2105
+ execute: (document, queryOptions) => entityEngine.execute(document, queryOptions),
2106
+ explain: (document, queryOptions) => entityEngine.explain(document, queryOptions),
2107
+ root: entityRoot(name),
2108
+ scope: entityEngine,
2109
+ relations: entityEngine.relations[name],
2110
+ });
2111
+ syncEntityHandles.set(name, handle);
2112
+ }
2113
+ return handle;
2114
+ };
2115
+
2116
+ return Object.freeze({ tracker, entityFor, syncEntityFor });
1409
2117
  };
1410
2118
 
2119
+ // the store's own unit of work: what a store-level handle and a
2120
+ // transaction that did not ask for its own both write through
2121
+ rootWork = createUnitOfWork();
2122
+ work = rootWork;
2123
+
1411
2124
  /** @type {Map<string, any>} */
1412
2125
  const asyncHandles = new Map();
2126
+
2127
+ /**
2128
+ * A collection handle BOUND to whatever scope is open when it
2129
+ * runs. It is what a transaction callback gets, and what the
2130
+ * store-level handle wraps in the gate. Collections carry no
2131
+ * unit of work, so one handle per name serves every scope.
2132
+ * @param {string} name
2133
+ */
2134
+ function boundCollection(name) {
2135
+ let handle = asyncHandles.get(name);
2136
+ if (handle === undefined) {
2137
+ handle = asyncCollection(coreFor(name),
2138
+ liveRegistry === null ? null : registerCollectionLive);
2139
+ asyncHandles.set(name, handle);
2140
+ }
2141
+ return handle;
2142
+ }
2143
+
2144
+ /** The engine's own commit counter, read wherever the caller
2145
+ * is: the store wraps it in the gate, a transaction view in
2146
+ * its scope check. An engine that keeps no such counter — one
2147
+ * where "another connection has written since you last looked"
2148
+ * is not a question a single number answers — refuses by name
2149
+ * rather than by a TypeError on a statement it cannot spell. */
2150
+ const readDataVersion = () => {
2151
+ if (typeof dialect.introspect.dataVersion !== 'function') {
2152
+ throw new DbRuntimeError('JD2077',
2153
+ 'this store has no data version: the dialect keeps no commit counter, so '
2154
+ + 'there is no single number that changes when another connection writes');
2155
+ }
2156
+ return chain(connection.prepare(dialect.introspect.dataVersion()),
2157
+ (statement) => chain(statement.get([]), (row) => Number(row.v)));
2158
+ };
2159
+
2160
+ /** Register an entity-root live query (LIVE-FORMAT §7) — the
2161
+ * store's `live` and a transaction view's share one body. */
2162
+ const registerEntityLive = (document, liveOptions) => {
2163
+ if (liveRegistry === null) {
2164
+ throw new DbCompileError('JD0050',
2165
+ 'live queries require change capture — open the store with { capture: true }');
2166
+ }
2167
+ refuseAsyncLive();
2168
+ if (liveOptions?.eventTime !== undefined) {
2169
+ throw new DbCompileError('JD0053',
2170
+ 'live eventTime maintains a collection view — an entity document re-runs, '
2171
+ + 'so a watermark would describe nothing (LIVE-FORMAT §13)');
2172
+ }
2173
+ const roots = collectEntityRoots(document, entities);
2174
+ if (roots.size === 0) {
2175
+ throw new TypeError(
2176
+ 'store.live takes an entity-root document — for a collection, '
2177
+ + 'use store.collection(name).live');
2178
+ }
2179
+ return closeOnRollback(liveRegistry.register({
2180
+ name: [...roots].join('+'),
2181
+ tables: roots,
2182
+ document,
2183
+ externals: liveOptions?.externals ?? {},
2184
+ demanded: liveOptions?.mode,
2185
+ classification: {
2186
+ strategy: 'rerun',
2187
+ reason: 'entity queries re-run in this version',
2188
+ },
2189
+ execute: (doc, executeOptions) => entityEngine.execute(doc, executeOptions),
2190
+ readRow: null,
2191
+ keyOf: null,
2192
+ }));
2193
+ };
2194
+
2195
+ /**
2196
+ * Every member of a bound handle that issues a statement,
2197
+ * wrapped in the store-level gate. The rest — local unit-of-work
2198
+ * bookkeeping, cached stats, the provider's identity members —
2199
+ * touches no connection and is passed through as it is.
2200
+ *
2201
+ * `valued` names the members that answer value-or-promise
2202
+ * rather than always a promise: the provider contract keeps a
2203
+ * chain over a synchronous driver synchronous, so those must
2204
+ * not be lifted (D2). They still answer a promise while another
2205
+ * caller's transaction holds the connection — which is what
2206
+ * waiting for a commit means.
2207
+ * @param {any} handle
2208
+ * @param {string[]} names - members that answer a promise
2209
+ * @param {string[]} [valued] - members that answer value-or-promise
2210
+ */
2211
+ const gatedMembers = (handle, names, valued = []) => {
2212
+ const out = { ...handle };
2213
+ for (const member of names) {
2214
+ if (typeof handle[member] !== 'function') continue;
2215
+ out[member] = (/** @type {any[]} */ ...args) =>
2216
+ lift(() => gated(() => handle[member](...args)))();
2217
+ }
2218
+ for (const member of valued) {
2219
+ if (typeof handle[member] !== 'function') continue;
2220
+ out[member] = (/** @type {any[]} */ ...args) =>
2221
+ gated(() => handle[member](...args));
2222
+ }
2223
+ return Object.freeze(out);
2224
+ };
2225
+
1413
2226
  /** @type {Map<string, any>} */
1414
- const asyncEntityHandles = new Map();
2227
+ const gatedCollections = new Map();
2228
+ /** @type {Map<string, any>} */
2229
+ const gatedEntities = new Map();
2230
+
1415
2231
  const store = {
1416
2232
  capabilities,
2233
+ // the ROOT's bookkeeping, always: an open own-unit
2234
+ // transaction changes what its own view reports, never this
1417
2235
  stats: () => ({
1418
2236
  statementCache: { ...queryState.counters },
1419
2237
  udfRegistrations: queryState.registered.size,
1420
- tracker: tracker === null ? null : tracker.counts(),
2238
+ tracker: rootWork.tracker === null ? null : rootWork.tracker.counts(),
1421
2239
  liveQueries: liveRegistry === null ? 0 : liveRegistry.count(),
1422
2240
  }),
1423
2241
  dialect,
2242
+ // A STORE-LEVEL handle. It reaches the driver connection, never
2243
+ // a transaction it is not part of: a caller here is unrelated
2244
+ // to whatever is open, so its statements wait for the commit
2245
+ // instead of joining a rollback it knows nothing about. Inside
2246
+ // a transaction callback, use the store the callback received.
1424
2247
  collection(name) {
1425
- let handle = asyncHandles.get(name);
2248
+ let handle = gatedCollections.get(name);
1426
2249
  if (handle === undefined) {
1427
- handle = asyncCollection(coreFor(name),
1428
- liveRegistry === null ? null : registerCollectionLive);
1429
- asyncHandles.set(name, handle);
2250
+ const inner = boundCollection(name);
2251
+ // `query` borrows the gate PER PULL rather than for the
2252
+ // cursor's life: holding it for the caller's whole loop
2253
+ // would block every transaction for as long as a consumer
2254
+ // reads slowly, while an ungated pull could read a row a
2255
+ // stranger's transaction has not committed. Construction
2256
+ // (preflight, compilation) touches no connection.
2257
+ handle = Object.freeze({
2258
+ ...gatedMembers(inner,
2259
+ ['get', 'insert', 'put', 'patch', 'delete', 'explain', 'live'],
2260
+ ['execute']),
2261
+ query: (document, queryOptions) => admitCursor(inner.query(document, queryOptions),
2262
+ gated, queryOptions?.signal, 'a root collection cursor pull'),
2263
+ });
2264
+ gatedCollections.set(name, handle);
1430
2265
  }
1431
2266
  return handle;
1432
2267
  },
1433
2268
  entity(name) {
1434
- let handle = asyncEntityHandles.get(name);
2269
+ let handle = gatedEntities.get(name);
1435
2270
  if (handle === undefined) {
1436
- const ops = trackedOpsFor(name);
1437
- const untracked = Object.freeze({
1438
- get: lift((key) => ops.noTracking.get(key)),
1439
- load: lift((spec) => ops.noTracking.load(spec)),
1440
- });
2271
+ // ALWAYS the root unit of work: a store-level handle
2272
+ // constructed while an own-unit transaction happens to be
2273
+ // open must not capture that transaction's tracker
2274
+ const inner = rootWork.entityFor(name);
2275
+ // `cursor` and `loadCursor` borrow the gate per pull, as a
2276
+ // collection's `query` does: admitted one item at a time,
2277
+ // never held across the caller's loop
2278
+ handle = gatedMembers(inner,
2279
+ ['create', 'get', 'update', 'delete', 'load', 'page', 'explain'],
2280
+ ['execute']);
2281
+ const untracked = gatedMembers(inner.asNoTracking(), ['get', 'load']);
1441
2282
  handle = Object.freeze({
1442
- create: lift((doc) => ops.create(doc)),
1443
- get: lift((key) => ops.get(key)),
1444
- update: lift((key, changes) => ops.update(key, changes)),
1445
- delete: lift((key) => ops.delete(key)),
1446
- load: lift((spec) => ops.load(spec)),
1447
- explainLoad: ops.explainLoad,
1448
- add: ops.add,
1449
- put: ops.put,
1450
- remove: ops.remove,
1451
- discard: ops.discard,
2283
+ ...handle,
2284
+ cursor: (document, queryOptions) => admitCursor(inner.cursor(document, queryOptions),
2285
+ gated, queryOptions?.signal, 'a root entity cursor pull'),
2286
+ loadCursor: (spec, cursorOptions) => admitCursor(inner.loadCursor(spec, cursorOptions),
2287
+ gated, cursorOptions?.signal, 'a root graph cursor pull'),
1452
2288
  asNoTracking: () => untracked,
1453
2289
  });
1454
- asyncEntityHandles.set(name, handle);
2290
+ gatedEntities.set(name, handle);
1455
2291
  }
1456
2292
  return handle;
1457
2293
  },
1458
2294
  saveChanges: entities.size === 0 ? undefined
1459
- : lift(() => guard(() => tracker.saveChanges())),
1460
- // entity DOCUMENTS query the multi-entity root at the store
2295
+ : lift(() => gated(() => guard(() => rootWork.tracker.saveChanges()))),
2296
+ // entity DOCUMENTS query the multi-entity root at the store;
2297
+ // `roots` names the entity arrays this provider serves, so a
2298
+ // chain asked to iterate the store itself can refuse by name
1461
2299
  execute: entityEngine === null ? undefined
1462
- : (document, queryOptions) => entityEngine.execute(document, queryOptions),
2300
+ : (document, queryOptions) =>
2301
+ gated(() => entityEngine.execute(document, queryOptions)),
1463
2302
  explain: entityEngine === null ? undefined
1464
- : lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
2303
+ : lift((document, queryOptions) =>
2304
+ gated(() => entityEngine.explain(document, queryOptions))),
2305
+ roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
2306
+ relations: entityEngine === null ? undefined : entityEngine.relations,
1465
2307
  // entity live queries re-run on invalidation — declared,
1466
- // not attempted (LIVE-FORMAT §7)
2308
+ // not attempted (LIVE-FORMAT §7). Registration takes the
2309
+ // store gate through its INITIAL query, like a collection's
2310
+ // `live`: the registration is local, the first result is a
2311
+ // statement, and a statement here must not read a row a
2312
+ // stranger's transaction has not committed. The live handle
2313
+ // then runs on committed writes alone.
1467
2314
  live: entityEngine === null ? undefined
1468
- : lift((document, liveOptions) => {
1469
- if (liveRegistry === null) {
1470
- throw new DbCompileError('JD0050',
1471
- 'live queries require change capture — open the store with { capture: true }');
1472
- }
1473
- if (liveOptions?.eventTime !== undefined) {
1474
- throw new DbCompileError('JD0053',
1475
- 'live eventTime maintains a collection view — an entity document re-runs, '
1476
- + 'so a watermark would describe nothing (LIVE-FORMAT §13)');
1477
- }
1478
- const roots = collectEntityRoots(document, entities);
1479
- if (roots.size === 0) {
1480
- throw new TypeError(
1481
- 'store.live takes an entity-root document — for a collection, '
1482
- + 'use store.collection(name).live');
1483
- }
1484
- return liveRegistry.register({
1485
- name: [...roots].join('+'),
1486
- tables: roots,
1487
- document,
1488
- externals: liveOptions?.externals ?? {},
1489
- demanded: liveOptions?.mode,
1490
- classification: {
1491
- strategy: 'rerun',
1492
- reason: 'entity queries re-run in this version',
1493
- },
1494
- execute: (doc, executeOptions) => entityEngine.execute(doc, executeOptions),
1495
- readRow: null,
1496
- keyOf: null,
1497
- });
1498
- }),
2315
+ : lift((document, liveOptions) =>
2316
+ gated(() => registerEntityLive(document, liveOptions), 'a root live registration')),
1499
2317
  // A TOP-LEVEL transaction: it takes the connection's gate, so
1500
2318
  // it never shares a savepoint stack with another one. To nest,
1501
2319
  // use the store the callback RECEIVES — the outer store cannot
1502
2320
  // tell an inner transaction from an unrelated caller, and an
1503
- // unrelated caller must wait for the commit.
1504
- transaction: lift((fn) => topLevelTransaction(fn)),
2321
+ // unrelated caller must wait for the commit. A `signal` gives
2322
+ // up the QUEUE, never a transaction already running.
2323
+ //
2324
+ // `unitOfWork: 'own'` gives the callback a tracker of its own,
2325
+ // so two concurrent handlers hold two records for one entity
2326
+ // key and neither sees the other's pending state. It is opt-in
2327
+ // because the shared default is what lets a caller add() a
2328
+ // document outside the transaction and save it inside.
2329
+ // `mode: 'immediate'` takes the write lock up front (`BEGIN
2330
+ // IMMEDIATE`): a body that reads before it writes never meets
2331
+ // the read→write upgrade busy the handler cannot retry. The
2332
+ // default stays the deferred savepoint; nesting is a savepoint
2333
+ // under either.
2334
+ transaction: lift((fn, transactionOptions) => {
2335
+ const wanted = transactionOptions?.unitOfWork;
2336
+ if (wanted !== undefined && wanted !== 'own' && wanted !== 'shared') {
2337
+ throw new TypeError(
2338
+ "store.transaction: unitOfWork must be 'shared' or 'own'");
2339
+ }
2340
+ const mode = transactionOptions?.mode;
2341
+ if (mode !== undefined && mode !== 'deferred' && mode !== 'immediate') {
2342
+ throw new TypeError(
2343
+ "store.transaction: mode must be 'deferred' or 'immediate'");
2344
+ }
2345
+ return topLevelTransaction(fn, transactionOptions?.signal,
2346
+ wanted === 'own' ? createUnitOfWork() : undefined, mode);
2347
+ }),
1505
2348
  observe: (fn) => {
1506
2349
  if (capture === null) {
1507
2350
  throw new TypeError(
@@ -1510,19 +2353,79 @@ export function openStore(model, options) {
1510
2353
  return capture.observe(fn);
1511
2354
  },
1512
2355
  changesSince: capture === null ? undefined
1513
- : lift((after) => capture.changesSince(after)),
1514
- dataVersion: lift(() => chain(
1515
- connection.prepare(dialect.introspect.dataVersion()),
1516
- (statement) => chain(statement.get([]), (row) => Number(row.v)))),
2356
+ : lift((after) => gated(() => capture.changesSince(after))),
2357
+ // the bounded reader (LIVE-FORMAT §5): watermarks, and pages
2358
+ // that report a retention gap instead of a misleading suffix
2359
+ changes: capture === null || !capture.logged ? undefined : Object.freeze({
2360
+ bounds: lift(() => gated(() => capture.bounds())),
2361
+ page: lift((pageOptions) => gated(() => capture.page(pageOptions))),
2362
+ }),
2363
+ dataVersion: lift(() => gated(() => readDataVersion())),
2364
+ // Database → model, read-only: what this database's shape
2365
+ // says the model is, beside a report of everything it
2366
+ // cannot say. It holds the store gate for its extent, like
2367
+ // every other read, and it issues no DDL and no DML — the
2368
+ // derived model is an ANSWER, and applying it is the
2369
+ // migration planner's job and the operator's decision
2370
+ introspect: lift((introspectOptions) =>
2371
+ gated(() => introspectModel(connection, introspectOptions))),
2372
+ // the maintenance surface: each operation holds the store
2373
+ // gate for its own extent, so a checkpoint can never
2374
+ // interleave an in-flight write; none takes a transaction
2375
+ checkpoint: lift((maintenanceOptions) =>
2376
+ gated(() => maintenance.checkpoint(maintenanceOptions), 'a checkpoint')),
2377
+ integrityCheck: lift((maintenanceOptions) =>
2378
+ gated(() => maintenance.integrityCheck(maintenanceOptions), 'an integrity check')),
2379
+ foreignKeyCheck: lift((maintenanceOptions) =>
2380
+ gated(() => maintenance.foreignKeyCheck(maintenanceOptions), 'a foreign-key check')),
2381
+ optimize: lift((maintenanceOptions) =>
2382
+ gated(() => maintenance.optimize(maintenanceOptions), 'an optimize')),
2383
+ // the online backup: NOT held under the gate for its whole
2384
+ // extent — writers proceed while the copy runs — only its
2385
+ // checkpoint boundary is
2386
+ backupTo: lift((targetPath, backupOptions) => backup.backupTo(targetPath, backupOptions)),
2387
+ // The ROOT jobs surface: every finite call takes the store
2388
+ // gate, exactly as a root collection write does, so an
2389
+ // unrelated enqueue, claim, checkpoint or settlement can
2390
+ // never join an open application transaction's fate. The
2391
+ // transactional-outbox spelling is the explicit `tx.jobs` a
2392
+ // transaction callback receives.
1517
2393
  jobs: jobsEngine === null ? undefined : Object.freeze({
1518
- enqueue: lift(jobsEngine.enqueue),
1519
- get: lift(jobsEngine.get),
1520
- counts: lift(jobsEngine.counts),
1521
- claim: lift(jobsEngine.claim),
1522
- complete: lift(jobsEngine.complete),
1523
- fail: lift(jobsEngine.fail),
1524
- checkpointsFor: jobsEngine.checkpointsFor,
2394
+ enqueue: lift((...args) => gated(() => jobsEngine.enqueue(...args), 'a root job enqueue')),
2395
+ get: lift((...args) => gated(() => jobsEngine.get(...args), 'a root job read')),
2396
+ counts: lift(() => gated(() => jobsEngine.counts(), 'a root job read')),
2397
+ claim: lift((...args) => gated(() => jobsEngine.claim(...args), 'a root job claim')),
2398
+ renew: lift((...args) => gated(() => jobsEngine.renew(...args), 'a root lease renewal')),
2399
+ complete: lift((...args) => gated(() => jobsEngine.complete(...args), 'a root job settlement')),
2400
+ fail: lift((...args) => gated(() => jobsEngine.fail(...args), 'a root job settlement')),
2401
+ // a checkpoint store keeps its creator's ROOT ownership:
2402
+ // its later calls take the gate too, never a scope. They
2403
+ // stay value-or-promise like the engine's own — the gate
2404
+ // answers a value when nothing is contended
2405
+ checkpointsFor: (job) => {
2406
+ const inner = jobsEngine.checkpointsFor(job);
2407
+ return Object.freeze({
2408
+ load: (runId) => gated(() => inner.load(runId), 'a root checkpoint read'),
2409
+ save: (runId, nodeId, value) =>
2410
+ gated(() => inner.save(runId, nodeId, value), 'a root checkpoint save'),
2411
+ complete: (runId, result) =>
2412
+ gated(() => inner.complete(runId, result), 'a root checkpoint settlement'),
2413
+ });
2414
+ },
1525
2415
  createWorker: jobsEngine.createWorker,
2416
+ // administration (JOBS-FORMAT §10): mechanism, never schedule.
2417
+ // `page` borrows the gate per pull like every root cursor;
2418
+ // `cancel` settles under the gate and then waits OUTSIDE it
2419
+ // for a local attempt to wind up — the handler's own
2420
+ // settlement calls take the gate, so waiting inside it
2421
+ // would wait for itself
2422
+ page: (pageOptions) => admitCursor(jobsEngine.page(pageOptions),
2423
+ gated, pageOptions?.signal, 'a root job page pull'),
2424
+ cancel: lift((id, cancelOptions) => chain(
2425
+ gated(() => jobsEngine.cancel(id, cancelOptions), 'a root job cancellation'),
2426
+ (outcome) => chain(jobsEngine.settledLocally(id), () => outcome))),
2427
+ requeue: lift((...args) => gated(() => jobsEngine.requeue(...args), 'a root job requeue')),
2428
+ sweep: lift((...args) => gated(() => jobsEngine.sweep(...args), 'a root job sweep')),
1526
2429
  }),
1527
2430
  /**
1528
2431
  * Close the store. Job workers are asked to stop and given a
@@ -1552,95 +2455,588 @@ export function openStore(model, options) {
1552
2455
  }),
1553
2456
  };
1554
2457
 
1555
- // The transaction callback's argument. It is the store, with one
1556
- // difference that matters: its `transaction` NESTS through the
1557
- // owning savepoint instead of queueing behind it. Everything
1558
- // else already reaches the open transaction, because the cores
1559
- // read the active scope.
1560
- /** @type {any} */
1561
- let txStore = null;
1562
- /** Nest through the savepoint that owns the connection now. The
1563
- * capture scope goes INSIDE the savepoint, so a rollback undoes
1564
- * the translated patch with the rows it describes. */
1565
- const nested = (/** @type {any} */ fn) => withScope(scope.transaction,
1566
- (tx) => (capture === null ? fn(tx) : capture.nest(() => fn(tx))));
2458
+ /**
2459
+ * The `JD2070` lifetime check every stateful member of a
2460
+ * transaction view runs FIRST before reading or mutating
2461
+ * tracker state, and before any statement. A view is pinned to
2462
+ * the exact scope that created it: an identity that is not
2463
+ * current has either settled (the handle escaped its callback)
2464
+ * or been crossed by an inner scope (an outer handle used while
2465
+ * an async inner savepoint is open). It never falls through to
2466
+ * the root and never follows a newer scope.
2467
+ * @param {any} identity
2468
+ */
2469
+ const requireScope = (identity) => {
2470
+ if (currentScope === identity) return;
2471
+ throw new DbRuntimeError('JD2070',
2472
+ 'this transaction handle is pinned to a scope that is not current: '
2473
+ + 'its transaction settled, or an inner transaction is open. Use the '
2474
+ + 'store the LIVE transaction callback received (tx.collection / '
2475
+ + 'tx.entity / tx.saveChanges / tx.jobs) — a handle never outlives '
2476
+ + 'or crosses its own scope.');
2477
+ };
2478
+
2479
+ /**
2480
+ * Every stateful member of a scope-view handle, checked against
2481
+ * the exact scope before it runs. `lifted` members answer a
2482
+ * promise (the check rejects); `direct` members answer values
2483
+ * or value-or-promise (the check throws) — the unit-of-work
2484
+ * bookkeeping and the D2 provider members among them.
2485
+ * @param {any} identity
2486
+ * @param {any} handle
2487
+ * @param {string[]} lifted
2488
+ * @param {string[]} [direct]
2489
+ */
2490
+ const scopedMembers = (identity, handle, lifted, direct = []) => {
2491
+ const out = { ...handle };
2492
+ for (const member of lifted) {
2493
+ if (typeof handle[member] !== 'function') continue;
2494
+ out[member] = (/** @type {any[]} */ ...args) =>
2495
+ lift(() => {
2496
+ requireScope(identity);
2497
+ return handle[member](...args);
2498
+ })();
2499
+ }
2500
+ for (const member of direct) {
2501
+ if (typeof handle[member] !== 'function') continue;
2502
+ out[member] = (/** @type {any[]} */ ...args) => {
2503
+ requireScope(identity);
2504
+ return handle[member](...args);
2505
+ };
2506
+ }
2507
+ return out;
2508
+ };
2509
+
2510
+ /** A cursor pinned to one exact scope: it opens under the
2511
+ * scope check, and `next()` re-checks the scope on every pull,
2512
+ * so iteration can neither begin nor continue once that exact
2513
+ * scope settled. The classification (`streaming`, `barrier`)
2514
+ * is the inner cursor's own. */
2515
+ const scopedCursor = (identity, open) => {
2516
+ requireScope(identity);
2517
+ const cursor = open();
2518
+ const step = (/** @type {string} */ member) => () => {
2519
+ try {
2520
+ requireScope(identity);
2521
+ }
2522
+ catch (error) {
2523
+ return Promise.reject(error);
2524
+ }
2525
+ return cursor[member]();
2526
+ };
2527
+ /** @type {any} */
2528
+ const wrapped = {
2529
+ streaming: cursor.streaming,
2530
+ barrier: cursor.barrier,
2531
+ next: step('next'),
2532
+ return: step('return'),
2533
+ [Symbol.asyncIterator]: () => wrapped,
2534
+ };
2535
+ return Object.freeze(wrapped);
2536
+ };
2537
+
2538
+ /** A collection handle pinned to one exact scope, its lazy
2539
+ * cursor included. */
2540
+ const scopedCollection = (identity, name) => {
2541
+ const inner = boundCollection(name);
2542
+ const out = scopedMembers(identity, inner,
2543
+ ['get', 'insert', 'put', 'patch', 'delete', 'explain', 'live'],
2544
+ ['execute']);
2545
+ out.query = (/** @type {any} */ document, /** @type {any} */ queryOptions) =>
2546
+ scopedCursor(identity, () => inner.query(document, queryOptions));
2547
+ return Object.freeze(out);
2548
+ };
2549
+
2550
+ /** An entity handle over `unit`, pinned to one exact scope —
2551
+ * the statement members and the local tracker bookkeeping both
2552
+ * carry the identity (`add()` on a settled handle is `JD2070`,
2553
+ * not a document smuggled into a later scope's unit of work). */
2554
+ const scopedEntity = (identity, unit, name) => {
2555
+ const inner = unit.entityFor(name);
2556
+ const untracked = Object.freeze(
2557
+ scopedMembers(identity, inner.asNoTracking(), ['get', 'load']));
2558
+ return Object.freeze({
2559
+ ...scopedMembers(identity, inner,
2560
+ ['create', 'get', 'update', 'delete', 'load', 'page', 'explain'],
2561
+ ['execute', 'add', 'put', 'remove', 'discard', 'link', 'unlink']),
2562
+ cursor: (/** @type {any} */ document, /** @type {any} */ queryOptions) =>
2563
+ scopedCursor(identity, () => inner.cursor(document, queryOptions)),
2564
+ loadCursor: (/** @type {any} */ spec, /** @type {any} */ cursorOptions) =>
2565
+ scopedCursor(identity, () => inner.loadCursor(spec, cursorOptions)),
2566
+ asNoTracking: () => untracked,
2567
+ });
2568
+ };
2569
+
2570
+ /** The synchronous twin, answering values. */
2571
+ const scopedSyncEntity = (identity, unit, name) => {
2572
+ const inner = unit.syncEntityFor(name);
2573
+ const untracked = Object.freeze(
2574
+ scopedMembers(identity, inner.asNoTracking(), [], ['get', 'load']));
2575
+ return Object.freeze({
2576
+ ...scopedMembers(identity, inner, [],
2577
+ ['create', 'get', 'update', 'delete', 'load', 'page', 'execute', 'explain',
2578
+ 'add', 'put', 'remove', 'discard', 'link', 'unlink']),
2579
+ cursor: (document, options) => {
2580
+ requireScope(identity);
2581
+ return admitSyncCursor(inner.cursor(document, options), (fn) => { requireScope(identity); return fn(); });
2582
+ },
2583
+ loadCursor: (spec, options) => {
2584
+ requireScope(identity);
2585
+ return admitSyncCursor(inner.loadCursor(spec, options), (fn) => { requireScope(identity); return fn(); });
2586
+ },
2587
+ asNoTracking: () => untracked,
2588
+ });
2589
+ };
2590
+
2591
+ /** Shared synchronous collection handles over the cores; set
2592
+ * with `store.sync` when the driver is synchronous. The gated
2593
+ * store-level surface and each scope view wrap the same ones.
2594
+ * @type {((name: string) => any) | undefined} */
2595
+ let syncCollectionFor;
2596
+
1567
2597
  /** The overriding member on a view of the FROZEN store: plain
1568
2598
  * assignment cannot shadow a non-writable inherited property. */
1569
2599
  const override = (/** @type {any} */ value) =>
1570
2600
  ({ value, writable: false, enumerable: true, configurable: false });
1571
- scopedStore = () => {
1572
- if (txStore === null) {
1573
- const members = { transaction: override(nested) };
1574
- if (store.sync !== undefined) {
1575
- members.sync = override(Object.create(store.sync,
1576
- { transaction: override(nested) }));
2601
+
2602
+ // The transaction callback's argument, and the ONLY handle that
2603
+ // is inside the transaction: ONE view per exact scope, pinned to
2604
+ // its identity. Its `collection`, `entity`, `sync`, unit of work
2605
+ // and `jobs` run as the owner instead of waiting for a commit
2606
+ // they are part of; its `transaction` NESTS through the owning
2607
+ // savepoint; its `savepoints` move the manual checkpoint stack.
2608
+ // The store's own handles are, by construction, somebody else —
2609
+ // and a view used outside its exact live scope is `JD2070`.
2610
+ scopedStore = (driverScope, identity) => {
2611
+ /** The unit of work in force for THIS scope, captured once:
2612
+ * the view's tracker surface never follows a later scope. */
2613
+ const myWork = work;
2614
+ /** @type {Map<string, any>} */
2615
+ const myCollections = new Map();
2616
+ /** @type {Map<string, any>} */
2617
+ const myEntities = new Map();
2618
+ const collectionFor = (/** @type {string} */ name) => {
2619
+ let handle = myCollections.get(name);
2620
+ if (handle === undefined) {
2621
+ handle = scopedCollection(identity, name);
2622
+ myCollections.set(name, handle);
1577
2623
  }
1578
- txStore = Object.freeze(Object.create(store, members));
2624
+ return handle;
2625
+ };
2626
+ const entityFor = (/** @type {string} */ name) => {
2627
+ let handle = myEntities.get(name);
2628
+ if (handle === undefined) {
2629
+ handle = scopedEntity(identity, myWork, name);
2630
+ myEntities.set(name, handle);
2631
+ }
2632
+ return handle;
2633
+ };
2634
+
2635
+ /** Nest through THIS scope's savepoint. The capture scope
2636
+ * goes INSIDE the savepoint, so a rollback undoes the
2637
+ * translated patch with the rows it describes. */
2638
+ const nested = (/** @type {any} */ fn) => {
2639
+ requireScope(identity);
2640
+ return withScope(driverScope.transaction,
2641
+ (inner, innerIdentity) => (capture === null
2642
+ ? fn(scopedStore(inner, innerIdentity))
2643
+ : capture.nest(() => fn(scopedStore(inner, innerIdentity)))));
2644
+ };
2645
+
2646
+ // ————— named savepoints (MODEL-FORMAT §5.2) —————
2647
+ // One per-exact-scope map from the caller's LABEL to an
2648
+ // opaque driver checkpoint plus the settlement-list mark and,
2649
+ // in journal capture mode, the capture mark. The label is a
2650
+ // map key and diagnostic only — the driver generates the
2651
+ // `jaren_sp_*` identifier structured nesting already uses, so
2652
+ // a label can never become SQL, and both savepoint kinds
2653
+ // share one engine stack. The scope's settlement (commit or
2654
+ // rollback) invalidates whatever names were left active,
2655
+ // because the view itself is then `JD2070`.
2656
+ /** @type {Map<string, any>} */
2657
+ const checkpoints = new Map();
2658
+ const requireLabel = (/** @type {any} */ label, /** @type {string} */ verb) => {
2659
+ if (typeof label === 'string' && label !== '') return;
2660
+ throw new DbRuntimeError('JD2071',
2661
+ `savepoints.${verb}: a savepoint label must be a non-empty string — `
2662
+ + 'it is a map key and diagnostic for this exact transaction, never SQL');
2663
+ };
2664
+ const resolveLabel = (/** @type {string} */ label, /** @type {string} */ verb) => {
2665
+ requireLabel(label, verb);
2666
+ const entry = checkpoints.get(label);
2667
+ if (entry !== undefined) return entry;
2668
+ throw new DbRuntimeError('JD2071',
2669
+ `savepoints.${verb}: no active savepoint '${label}' in this exact `
2670
+ + 'transaction — it was never created here, or a rollback past it or a '
2671
+ + 'release already invalidated it');
2672
+ };
2673
+ const savepointCreate = (/** @type {string} */ label) => {
2674
+ requireScope(identity);
2675
+ requireLabel(label, 'create');
2676
+ if (checkpoints.has(label)) {
2677
+ throw new DbRuntimeError('JD2071',
2678
+ `savepoints.create: the label '${label}' is already active in this `
2679
+ + 'transaction — release it, or roll back to it, before creating it again');
2680
+ }
2681
+ // SAVEPOINT first; the entry is recorded only after success,
2682
+ // so a refused statement leaves label map and marks untouched
2683
+ return chain(driverScope.savepoint(), (checkpoint) => {
2684
+ checkpoints.set(label, {
2685
+ checkpoint,
2686
+ settleMark: settlements === null ? 0 : settlements.length,
2687
+ captureMark: capture === null ? null : capture.mark(),
2688
+ });
2689
+ return undefined;
2690
+ });
2691
+ };
2692
+ const savepointRollbackTo = (/** @type {string} */ label) => {
2693
+ requireScope(identity);
2694
+ const entry = resolveLabel(label, 'rollbackTo');
2695
+ // ROLLBACK TO first; only after database success do the
2696
+ // in-memory effects follow. The target stays active with
2697
+ // the same now-current marks, so repeated rollback is
2698
+ // defined; entries created after it are gone from the
2699
+ // engine stack and invalidated here.
2700
+ return chain(driverScope.rollbackTo(entry.checkpoint), () => {
2701
+ if (settlements !== null) {
2702
+ const withdrawn = settlements.splice(entry.settleMark);
2703
+ for (let i = withdrawn.length - 1; i >= 0; i--) withdrawn[i].rollback?.();
2704
+ }
2705
+ if (capture !== null) capture.truncate(entry.captureMark);
2706
+ let seen = false;
2707
+ for (const key of [...checkpoints.keys()]) {
2708
+ if (seen) checkpoints.delete(key);
2709
+ if (key === label) seen = true;
2710
+ }
2711
+ return undefined;
2712
+ });
2713
+ };
2714
+ const savepointRelease = (/** @type {string} */ label) => {
2715
+ requireScope(identity);
2716
+ const entry = resolveLabel(label, 'release');
2717
+ // RELEASE removes the target and every later entry WITHOUT
2718
+ // running rollback effects: those rows remain part of the
2719
+ // owning transaction, so their tracker withdrawals stay
2720
+ // registered until outer settlement — the engine semantics,
2721
+ // exactly (both SQLite and PostgreSQL discard the target
2722
+ // and the savepoints nested after it, keeping their rows)
2723
+ return chain(driverScope.release(entry.checkpoint), () => {
2724
+ let seen = false;
2725
+ for (const key of [...checkpoints.keys()]) {
2726
+ if (key === label) seen = true;
2727
+ if (seen) checkpoints.delete(key);
2728
+ }
2729
+ return undefined;
2730
+ });
2731
+ };
2732
+
2733
+ const members = {
2734
+ transaction: override((/** @type {any} */ fn) => lift(() => nested(fn))()),
2735
+ collection: override(collectionFor),
2736
+ entity: override(entityFor),
2737
+ // THIS scope's bookkeeping, whatever scope is current later
2738
+ stats: override(() => ({
2739
+ statementCache: { ...queryState.counters },
2740
+ udfRegistrations: queryState.registered.size,
2741
+ tracker: myWork.tracker === null ? null : myWork.tracker.counts(),
2742
+ liveQueries: liveRegistry === null ? 0 : liveRegistry.count(),
2743
+ })),
2744
+ dataVersion: override(lift(() => {
2745
+ requireScope(identity);
2746
+ return readDataVersion();
2747
+ })),
2748
+ savepoints: override(Object.freeze({
2749
+ create: lift(savepointCreate),
2750
+ rollbackTo: lift(savepointRollbackTo),
2751
+ release: lift(savepointRelease),
2752
+ })),
2753
+ // a transaction view does not own the store lifetime: the
2754
+ // member is ABSENT rather than a second way to close the
2755
+ // raw connection under its own savepoint
2756
+ close: override(undefined),
2757
+ // nor does it run maintenance: a checkpoint inside an open
2758
+ // transaction is a no-op the engine answers quietly, and
2759
+ // the other three are store-level operations — ABSENT here
2760
+ checkpoint: override(undefined),
2761
+ integrityCheck: override(undefined),
2762
+ foreignKeyCheck: override(undefined),
2763
+ optimize: override(undefined),
2764
+ backupTo: override(undefined),
2765
+ };
2766
+ if (entities.size > 0) {
2767
+ members.saveChanges = override(lift(() => {
2768
+ requireScope(identity);
2769
+ return guard(() => myWork.tracker.saveChanges());
2770
+ }));
2771
+ }
2772
+ if (entityEngine !== null) {
2773
+ members.execute = override(
2774
+ (/** @type {any} */ document, /** @type {any} */ queryOptions) => {
2775
+ requireScope(identity);
2776
+ return entityEngine.execute(document, queryOptions);
2777
+ });
2778
+ members.explain = override(lift(
2779
+ (/** @type {any} */ document, /** @type {any} */ queryOptions) => {
2780
+ requireScope(identity);
2781
+ return entityEngine.explain(document, queryOptions);
2782
+ }));
2783
+ members.live = override(lift(
2784
+ (/** @type {any} */ document, /** @type {any} */ liveOptions) => {
2785
+ requireScope(identity);
2786
+ return registerEntityLive(document, liveOptions);
2787
+ }));
2788
+ }
2789
+ if (capture !== null) {
2790
+ members.changesSince = override(lift((/** @type {any} */ after) => {
2791
+ requireScope(identity);
2792
+ return capture.changesSince(after);
2793
+ }));
2794
+ if (capture.logged) members.changes = override(Object.freeze({
2795
+ bounds: lift(() => {
2796
+ requireScope(identity);
2797
+ return capture.bounds();
2798
+ }),
2799
+ page: lift((/** @type {any} */ pageOptions) => {
2800
+ requireScope(identity);
2801
+ return capture.page(pageOptions);
2802
+ }),
2803
+ }));
2804
+ }
2805
+ if (jobsEngine !== null) {
2806
+ // the transactional-outbox spelling: these run as the exact
2807
+ // scope, so an enqueue or settlement here co-commits with
2808
+ // the domain transaction — and a retained handle is JD2070
2809
+ members.jobs = override(Object.freeze({
2810
+ enqueue: lift((/** @type {any[]} */ ...args) => {
2811
+ requireScope(identity);
2812
+ return jobsEngine.enqueue(...args);
2813
+ }),
2814
+ get: lift((/** @type {any[]} */ ...args) => {
2815
+ requireScope(identity);
2816
+ return jobsEngine.get(...args);
2817
+ }),
2818
+ counts: lift(() => {
2819
+ requireScope(identity);
2820
+ return jobsEngine.counts();
2821
+ }),
2822
+ claim: lift((/** @type {any[]} */ ...args) => {
2823
+ requireScope(identity);
2824
+ return jobsEngine.claim(...args);
2825
+ }),
2826
+ renew: lift((/** @type {any[]} */ ...args) => {
2827
+ requireScope(identity);
2828
+ return jobsEngine.renew(...args);
2829
+ }),
2830
+ complete: lift((/** @type {any[]} */ ...args) => {
2831
+ requireScope(identity);
2832
+ return jobsEngine.complete(...args);
2833
+ }),
2834
+ fail: lift((/** @type {any[]} */ ...args) => {
2835
+ requireScope(identity);
2836
+ return jobsEngine.fail(...args);
2837
+ }),
2838
+ // a checkpoint store keeps its creator's SCOPE ownership:
2839
+ // its later calls cannot switch scopes, and outlive none
2840
+ checkpointsFor: (/** @type {any} */ job) => {
2841
+ const inner = jobsEngine.checkpointsFor(job);
2842
+ return Object.freeze({
2843
+ load: lift((/** @type {any} */ runId) => {
2844
+ requireScope(identity);
2845
+ return inner.load(runId);
2846
+ }),
2847
+ save: lift((/** @type {any} */ runId, /** @type {any} */ nodeId,
2848
+ /** @type {any} */ value) => {
2849
+ requireScope(identity);
2850
+ return inner.save(runId, nodeId, value);
2851
+ }),
2852
+ complete: lift((/** @type {any} */ runId, /** @type {any} */ result) => {
2853
+ requireScope(identity);
2854
+ return inner.complete(runId, result);
2855
+ }),
2856
+ });
2857
+ },
2858
+ // a worker is a ROOT-owned long-lived component wherever
2859
+ // it is created: its future loop takes the store gate and
2860
+ // never binds to the transaction that constructed it
2861
+ createWorker: jobsEngine.createWorker,
2862
+ }));
2863
+ }
2864
+ if (connection.synchronous && syncCollectionFor !== undefined) {
2865
+ /** @type {Map<string, any>} */
2866
+ const mySyncCollections = new Map();
2867
+ /** @type {Map<string, any>} */
2868
+ const mySyncEntities = new Map();
2869
+ const forSync = /** @type {(name: string) => any} */ (syncCollectionFor);
2870
+ members.sync = override(Object.freeze({
2871
+ collection: (/** @type {string} */ name) => {
2872
+ let handle = mySyncCollections.get(name);
2873
+ if (handle === undefined) {
2874
+ handle = Object.freeze(scopedMembers(identity, forSync(name), [],
2875
+ ['get', 'insert', 'put', 'patch', 'delete', 'execute', 'explain']));
2876
+ mySyncCollections.set(name, handle);
2877
+ }
2878
+ return handle;
2879
+ },
2880
+ entity: (/** @type {string} */ name) => {
2881
+ let handle = mySyncEntities.get(name);
2882
+ if (handle === undefined) {
2883
+ handle = scopedSyncEntity(identity, myWork, name);
2884
+ mySyncEntities.set(name, handle);
2885
+ }
2886
+ return handle;
2887
+ },
2888
+ transaction: nested,
2889
+ savepoints: Object.freeze({
2890
+ create: savepointCreate,
2891
+ rollbackTo: savepointRollbackTo,
2892
+ release: savepointRelease,
2893
+ }),
2894
+ saveChanges: entities.size === 0 ? undefined
2895
+ : () => {
2896
+ requireScope(identity);
2897
+ return guard(() => myWork.tracker.saveChanges());
2898
+ },
2899
+ execute: entityEngine === null ? undefined
2900
+ : (/** @type {any} */ document, /** @type {any} */ queryOptions) => {
2901
+ requireScope(identity);
2902
+ return entityEngine.execute(document, queryOptions);
2903
+ },
2904
+ explain: entityEngine === null ? undefined
2905
+ : (/** @type {any} */ document, /** @type {any} */ queryOptions) => {
2906
+ requireScope(identity);
2907
+ return entityEngine.explain(document, queryOptions);
2908
+ },
2909
+ roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
2910
+ relations: entityEngine === null ? undefined : entityEngine.relations,
2911
+ }));
1579
2912
  }
1580
- return txStore;
2913
+ return Object.freeze(Object.create(store, members));
1581
2914
  };
1582
2915
 
1583
2916
  if (connection.synchronous) {
1584
2917
  /** @type {Map<string, any>} */
1585
2918
  const syncHandles = new Map();
2919
+ syncCollectionFor = (name) => {
2920
+ let handle = syncHandles.get(name);
2921
+ if (handle === undefined) {
2922
+ const core = coreFor(name);
2923
+ handle = Object.freeze({
2924
+ stats: () => core.stats(),
2925
+ get: (/** @type {any} */ key) => core.get(key),
2926
+ insert: (/** @type {any} */ doc) => core.insert(doc),
2927
+ put: (/** @type {any} */ doc, /** @type {any} */ key) => core.put(doc, key),
2928
+ patch: (/** @type {any} */ key, /** @type {any} */ ops) => core.patch(key, ops),
2929
+ delete: (/** @type {any} */ key) => core.delete(key),
2930
+ execute: (/** @type {any} */ document, /** @type {any} */ o) =>
2931
+ core.execute(document, o),
2932
+ explain: (/** @type {any} */ document, /** @type {any} */ o) =>
2933
+ core.explain(document, o),
2934
+ });
2935
+ syncHandles.set(name, handle);
2936
+ }
2937
+ return handle;
2938
+ };
2939
+ const forSync = syncCollectionFor;
2940
+
2941
+ /** Store-level synchronous members, each holding the
2942
+ * connection for its own extent. A contended one refuses
2943
+ * rather than queueing: this surface answers values, and a
2944
+ * queue answers a Promise. */
2945
+ const syncGatedMembers = (handle, names) => {
2946
+ const out = { ...handle };
2947
+ for (const member of names) {
2948
+ if (typeof handle[member] !== 'function') continue;
2949
+ out[member] = (/** @type {any[]} */ ...args) =>
2950
+ gatedSync(() => handle[member](...args));
2951
+ }
2952
+ return Object.freeze(out);
2953
+ };
2954
+ /** @type {Map<string, any>} */
2955
+ const gatedSyncCollections = new Map();
2956
+ /** @type {Map<string, any>} */
2957
+ const gatedSyncEntities = new Map();
1586
2958
  store.sync = Object.freeze({
1587
2959
  collection(name) {
1588
- let handle = syncHandles.get(name);
2960
+ let handle = gatedSyncCollections.get(name);
1589
2961
  if (handle === undefined) {
1590
- const core = coreFor(name);
1591
- handle = Object.freeze({
1592
- stats: () => core.stats(),
1593
- get: (key) => core.get(key),
1594
- insert: (doc) => core.insert(doc),
1595
- put: (doc, key) => core.put(doc, key),
1596
- patch: (key, ops) => core.patch(key, ops),
1597
- delete: (key) => core.delete(key),
1598
- execute: (document, options) => core.execute(document, options),
1599
- explain: (document, options) => core.explain(document, options),
1600
- });
1601
- syncHandles.set(name, handle);
2962
+ handle = syncGatedMembers(forSync(name),
2963
+ ['get', 'insert', 'put', 'patch', 'delete', 'execute', 'explain']);
2964
+ gatedSyncCollections.set(name, handle);
1602
2965
  }
1603
2966
  return handle;
1604
2967
  },
1605
- transaction: (fn) => topLevelTransaction(fn),
2968
+ transaction: (fn) => {
2969
+ // the synchronous surface answers values: while a
2970
+ // transaction owns the connection it could only QUEUE,
2971
+ // which handed a Promise back under a value's type
2972
+ if (opened.mustQueue) {
2973
+ throw new DbCompileError('JD0012',
2974
+ 'the synchronous transaction cannot wait for the open transaction to '
2975
+ + 'settle — nest through the store the callback received, or use the '
2976
+ + 'asynchronous store.transaction()');
2977
+ }
2978
+ return topLevelTransaction(fn);
2979
+ },
1606
2980
  entity(name) {
1607
- const ops = trackedOpsFor(name);
1608
- const untracked = Object.freeze({
1609
- get: (key) => ops.noTracking.get(key),
1610
- load: (spec) => ops.noTracking.load(spec),
1611
- });
1612
- return Object.freeze({
1613
- create: (doc) => ops.create(doc),
1614
- get: (key) => ops.get(key),
1615
- update: (key, changes) => ops.update(key, changes),
1616
- delete: (key) => ops.delete(key),
1617
- load: (spec) => ops.load(spec),
1618
- explainLoad: ops.explainLoad,
1619
- add: ops.add,
1620
- put: ops.put,
1621
- remove: ops.remove,
1622
- discard: ops.discard,
1623
- asNoTracking: () => untracked,
1624
- });
2981
+ let handle = gatedSyncEntities.get(name);
2982
+ if (handle === undefined) {
2983
+ // the ROOT unit of work, whatever transaction happens
2984
+ // to be open when the handle is first constructed
2985
+ const inner = rootWork.syncEntityFor(name);
2986
+ const untracked = syncGatedMembers(inner.asNoTracking(), ['get', 'load']);
2987
+ handle = Object.freeze({
2988
+ ...syncGatedMembers(inner,
2989
+ ['create', 'get', 'update', 'delete', 'load', 'page', 'execute', 'explain']),
2990
+ cursor: (document, options) => gatedSync(() =>
2991
+ admitSyncCursor(inner.cursor(document, options), gatedSync)),
2992
+ loadCursor: (spec, options) => gatedSync(() =>
2993
+ admitSyncCursor(inner.loadCursor(spec, options), gatedSync)),
2994
+ asNoTracking: () => untracked,
2995
+ });
2996
+ gatedSyncEntities.set(name, handle);
2997
+ }
2998
+ return handle;
1625
2999
  },
1626
3000
  saveChanges: entities.size === 0 ? undefined
1627
- : () => guard(() => tracker.saveChanges()),
3001
+ : () => gatedSync(() => guard(() => rootWork.tracker.saveChanges())),
1628
3002
  execute: entityEngine === null ? undefined
1629
- : (document, queryOptions) => entityEngine.execute(document, queryOptions),
3003
+ : (document, queryOptions) =>
3004
+ gatedSync(() => entityEngine.execute(document, queryOptions)),
3005
+ explain: entityEngine === null ? undefined
3006
+ : (document, queryOptions) =>
3007
+ gatedSync(() => entityEngine.explain(document, queryOptions)),
3008
+ roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
3009
+ relations: entityEngine === null ? undefined : entityEngine.relations,
1630
3010
  });
1631
3011
  }
1632
3012
  return chain(capture === null ? null : capture.ready,
1633
3013
  () => chain(jobsEngine === null ? null : jobsEngine.ready,
1634
3014
  () => Object.freeze(store)));
1635
- }))));
3015
+ })))));
1636
3016
 
1637
- let opened_;
1638
- try {
1639
- opened_ = opening();
1640
- }
1641
- catch (error) {
1642
- return failClosed(error);
1643
- }
1644
- return isThenable(opened_) ? opened_.then((value) => value, failClosed) : opened_;
3017
+ /**
3018
+ * The open sequence, with ONE retry when it fails classed busy: the
3019
+ * race window is another process's shape transaction on a fresh
3020
+ * file, and one retry after it commits is the straggler case the
3021
+ * immediate transaction cannot cover (the journal-mode write itself).
3022
+ * Every step is idempotent, so a second pass re-applies nothing that
3023
+ * matters; a second busy failure propagates classed.
3024
+ * @param {boolean} retry
3025
+ */
3026
+ const attemptOpen = (retry) => {
3027
+ const again = (error) => (retry && isDriverError(error)
3028
+ && classifyDriverError(error).class === 'busy'
3029
+ ? attemptOpen(false)
3030
+ : failClosed(error));
3031
+ let opened_;
3032
+ try {
3033
+ opened_ = opening();
3034
+ }
3035
+ catch (error) {
3036
+ return again(error);
3037
+ }
3038
+ return isThenable(opened_) ? opened_.then((value) => value, again) : opened_;
3039
+ };
3040
+ return attemptOpen(true);
1645
3041
  }));
1646
3042
  }