@jarenjs/db 0.56.0 → 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 (68) hide show
  1. package/ARCHITECTURE.md +393 -56
  2. package/README.md +585 -53
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +122 -14
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +744 -64
  8. package/package.json +21 -7
  9. package/schemas/jaren-model.draft-07.schema.json +224 -162
  10. package/schemas/jaren-model.schema.json +224 -162
  11. package/src/algebra.js +227 -9
  12. package/src/backup.js +161 -0
  13. package/src/cancellation.js +48 -0
  14. package/src/capture.js +218 -45
  15. package/src/cli.js +165 -59
  16. package/src/cursor.js +411 -0
  17. package/src/dag-job.js +154 -21
  18. package/src/ddl.js +102 -8
  19. package/src/dialect.js +267 -112
  20. package/src/dialects/expression-read.js +158 -0
  21. package/src/dialects/postgres.js +618 -0
  22. package/src/dialects/rtree-ddl.js +129 -0
  23. package/src/dialects/sqlite.js +243 -11
  24. package/src/document-files.js +311 -0
  25. package/src/document-steps.js +422 -0
  26. package/src/documents.js +335 -0
  27. package/src/driver.js +448 -61
  28. package/src/drivers/bun.js +37 -1
  29. package/src/drivers/indexeddb-snapshot.js +149 -0
  30. package/src/drivers/node-pool.js +11 -0
  31. package/src/drivers/node-worker-endpoint.js +105 -0
  32. package/src/drivers/node-worker.js +204 -0
  33. package/src/drivers/node.js +41 -7
  34. package/src/drivers/postgres.js +331 -0
  35. package/src/drivers/wasm-oo1.js +97 -0
  36. package/src/drivers/wasm-session.js +67 -0
  37. package/src/drivers/wasm.js +17 -83
  38. package/src/drivers/worker-pool.js +183 -0
  39. package/src/drivers/worker-protocol.js +79 -0
  40. package/src/drivers/worker-queue.js +60 -0
  41. package/src/emit.js +339 -48
  42. package/src/entity.js +20 -22
  43. package/src/errors.js +422 -19
  44. package/src/expression.js +284 -0
  45. package/src/graph.js +64 -8
  46. package/src/index.js +46 -17
  47. package/src/introspect.js +583 -0
  48. package/src/jobs.js +843 -107
  49. package/src/json-bytes.js +58 -0
  50. package/src/maintenance.js +175 -0
  51. package/src/migrate.js +248 -181
  52. package/src/model.js +68 -0
  53. package/src/plan.js +1119 -138
  54. package/src/pragmas.js +314 -0
  55. package/src/profile.js +151 -3
  56. package/src/query.js +1634 -323
  57. package/src/residual.js +17 -0
  58. package/src/series.js +12 -4
  59. package/src/store.js +1505 -264
  60. package/src/tracker.js +203 -29
  61. package/src/udf.js +88 -7
  62. package/types/index.d.ts +1097 -25
  63. package/types/node-pool.d.ts +28 -0
  64. package/types/node-worker.d.ts +54 -0
  65. package/types/node.d.ts +69 -2
  66. package/types/postgres.d.ts +46 -0
  67. package/types/typed.d.ts +25 -3
  68. package/types/wasm.d.ts +14 -0
package/src/store.js CHANGED
@@ -20,15 +20,20 @@
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, isDuplicateKeyError } from './errors.js';
27
+ import { DbCompileError, DbRuntimeError, wrapDriverError, isDriverError, classifyDriverError } from './errors.js';
27
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
39
  import { createTracker, membershipKeys } from './tracker.js';
@@ -36,11 +41,15 @@ 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';
44
+ import { introspectModel } from './introspect.js';
39
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 === '')) {
@@ -379,24 +424,48 @@ function requireKey(key, collection, docPath) {
379
424
  * @returns {DbRuntimeError}
380
425
  */
381
426
  function wrapWriteError(error, plan, collection, docPath, key) {
382
- // an error that already carries a code (a closed store, a refused
383
- // document) is the error; only the driver's own failures are wrapped
384
- if (typeof error?.code === 'string' && error.code.startsWith('JD')) return error;
385
- if (isDuplicateKeyError(error, plan.table, plan.keyColumn)) {
386
- return new DbRuntimeError('JD2001',
387
- `a document already exists under key '${String(key)}'`,
388
- { docPath, collection, key, cause: error });
389
- }
390
- return new DbRuntimeError('JD2005',
391
- `the database rejected the operation: ${error?.message ?? String(error)}`,
392
- key === undefined
393
- ? { docPath, collection, cause: error }
394
- : { 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
+ });
395
464
  }
396
465
 
397
466
  /**
398
467
  * Create or verify every collection's physical shape, inside one
399
- * transaction.
468
+ * immediate transaction.
400
469
  * @param {any} connection
401
470
  * @param {Map<string, any>} collections
402
471
  * @param {Map<string, any>} plans
@@ -405,7 +474,9 @@ function wrapWriteError(error, plan, collection, docPath, key) {
405
474
  function ensureShape(connection, collections, plans, readOnly) {
406
475
  const dialect = connection.dialect;
407
476
  const names = [...collections.keys()];
408
- 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(() => {
409
480
  const step = (i) => {
410
481
  if (i >= names.length) return null;
411
482
  const name = names[i];
@@ -421,7 +492,7 @@ function ensureShape(connection, collections, plans, readOnly) {
421
492
  }
422
493
  const run = (j) => (j >= plan.createSql.length
423
494
  ? null
424
- : chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
495
+ : chain(connection.exec(dialect.ddl.idempotent(plan.createSql[j])), () => run(j + 1)));
425
496
  return chain(run(0), () => step(i + 1));
426
497
  }
427
498
  return chain(verifyShape(connection, plan, name, collection.docPath),
@@ -447,7 +518,8 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
447
518
  if (entityPlans.size === 0) return null;
448
519
  const dialect = connection.dialect;
449
520
  const names = [...entityPlans.keys()];
450
- return connection.transaction(() => {
521
+ const bracket = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
522
+ return bracket(() => {
451
523
  const step = (i) => {
452
524
  if (i >= names.length) return null;
453
525
  const name = names[i];
@@ -463,7 +535,7 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
463
535
  }
464
536
  const run = (j) => (j >= plan.createSql.length
465
537
  ? null
466
- : chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
538
+ : chain(connection.exec(dialect.ddl.idempotent(plan.createSql[j])), () => run(j + 1)));
467
539
  return chain(run(0), () => step(i + 1));
468
540
  }
469
541
  return chain(verifyShape(connection, plan, name, docPath), () =>
@@ -510,10 +582,11 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
510
582
  * @param {any} plan
511
583
  * @param {((doc: any) => any) | null} validate
512
584
  * @param {any} queryState - the store-wide statement cache and UDF set
513
- * @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
514
587
  * @returns {any}
515
588
  */
516
- function collectionCore(connection, collection, plan, validate, queryState, storeProfileRef) {
589
+ function collectionCore(connection, collection, plan, validate, queryState, storeProfileRef, runtime) {
517
590
  const dialect = connection.dialect;
518
591
  // the STORED branch (a driver that cannot index a registered
519
592
  // function): the derived columns are ordinary ones, so every write
@@ -562,7 +635,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
562
635
  const stats = { patchTranslated: 0, patchFallback: 0 };
563
636
  const engine = createQueryEngine({
564
637
  connection, state: queryState, collection, physicalPlan: plan,
565
- profile: storeProfileRef.profile,
638
+ profile: storeProfileRef.profile, roots: storeProfileRef.roots,
566
639
  });
567
640
 
568
641
  const checkValid = (doc) => {
@@ -585,7 +658,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
585
658
  }
586
659
  if (explicitKey !== undefined)
587
660
  return requireKey(explicitKey, collection.name, collection.docPath);
588
- if (collection.identity === 'uuid') return crypto.randomUUID();
661
+ if (collection.identity === 'uuid') return runtime.uuid();
589
662
  return null; // integer: the database allocates
590
663
  };
591
664
 
@@ -606,9 +679,12 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
606
679
  explain: (document, options) => engine.explain(document, options),
607
680
  get(key) {
608
681
  requireKey(key, collection.name, collection.docPath);
609
- 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) =>
610
685
  chain(statement.get([key]),
611
- (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 }));
612
688
  },
613
689
  insert(doc) {
614
690
  checkValid(doc);
@@ -726,20 +802,6 @@ function asyncCollection(core, live) {
726
802
  });
727
803
  }
728
804
 
729
- /**
730
- * Resolve the store's operator seam (Ring 2) to a single
731
- * `{ functions, extensions }` or `null`. Accepts `options.operators` (a
732
- * registry from `@jarenjs/json/jslt`'s `createJsltRegistry()`) and/or
733
- * raw `options.functions` / `options.extensions`. A registered operator
734
- * becomes engine vocabulary the query planner recognises and the
735
- * residual evaluates — it runs correctly in JavaScript over the fetched
736
- * rows, and is never pushed to SQL in this ring (that is Ring 3). Bad
737
- * input is API misuse (a synchronous `TypeError`), consistent with the
738
- * driver check. When no registry is threaded the return is `null`, and
739
- * the whole query engine is byte-identical to before.
740
- * @param {any} options
741
- * @returns {{ functions: any, extensions: any } | null}
742
- */
743
805
  /**
744
806
  * The schema a WRITE validates against. A store-allocated key (`default:
745
807
  * "auto"`) is absent from the document the injected hook sees — the
@@ -760,6 +822,50 @@ function writeSchemaOf(entity) {
760
822
  return out;
761
823
  }
762
824
 
825
+ /**
826
+ * Resolve the store's operator seam (Ring 2) to a single
827
+ * `{ functions, extensions }` or `null`. Accepts `options.operators` (a
828
+ * registry from `@jarenjs/json/jslt`'s `createJsltRegistry()`) and/or
829
+ * raw `options.functions` / `options.extensions`. A registered operator
830
+ * becomes engine vocabulary the query planner recognises and the
831
+ * residual evaluates — it runs correctly in JavaScript over the fetched
832
+ * rows, and is never pushed to SQL in this ring (that is Ring 3). Bad
833
+ * input is API misuse (a synchronous `TypeError`), consistent with the
834
+ * driver check. When no registry is threaded the return is `null`, and
835
+ * the whole query engine is byte-identical to before.
836
+ * @param {any} options
837
+ * @returns {{ functions: any, extensions: any } | null}
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
+
763
869
  function resolveOperators(options) {
764
870
  const registry = options.operators;
765
871
  const hasRegistry = registry !== undefined && registry !== null;
@@ -772,6 +878,11 @@ function resolveOperators(options) {
772
878
  // UDFs where the driver supports them. Raw (registry-free) extensions
773
879
  // are never pushed — only a registry declares pushability.
774
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();
775
886
  if (hasRegistry) {
776
887
  if (typeof registry.toOptions !== 'function') {
777
888
  throw new TypeError('openStore: operators must be a registry '
@@ -785,12 +896,13 @@ function resolveOperators(options) {
785
896
  if (typeof registry.forSql === 'function') {
786
897
  for (const [name, meta] of Object.entries(registry.forSql())) {
787
898
  if (meta.pushable === 'scalar') pushableScalar.add(name);
899
+ else if (meta.pushable === 'aggregate') pushableAggregate.set(name, aggregateSpec(name, meta));
788
900
  }
789
901
  }
790
902
  }
791
903
  if (options.functions !== undefined) functions = { ...functions, ...options.functions };
792
904
  if (options.extensions !== undefined) extensions = { ...extensions, ...options.extensions };
793
- return Object.freeze({ functions, extensions, pushableScalar });
905
+ return Object.freeze({ functions, extensions, pushableScalar, pushableAggregate });
794
906
  }
795
907
 
796
908
  /**
@@ -798,8 +910,12 @@ function resolveOperators(options) {
798
910
  * @param {any} model - A `jaren-model` document (the 0.1 subset)
799
911
  * @param {{ driver: any, path?: string, compileSchema?: Function,
800
912
  * busyTimeout?: number, queueTimeout?: number, journalMode?: string,
913
+ * synchronous?: string, walAutocheckpoint?: number,
914
+ * journalSizeLimit?: number, cacheSize?: number, mmapSize?: number,
915
+ * tempStore?: string,
801
916
  * statementCacheBound?: number, profile?: any, operators?: any,
802
917
  * functions?: any, extensions?: any, zoneProvider?: any,
918
+ * runtime?: Partial<import('@jarenjs/core/runtime').Runtime>,
803
919
  * readOnly?: boolean }} options
804
920
  * `zoneProvider` is D7's injected clock: a named zone in a temporal
805
921
  * spec (`{ "every": "P1M", "zone": "Europe/Amsterdam" }`) is host code
@@ -807,6 +923,20 @@ function resolveOperators(options) {
807
923
  * such a document (`JQ0003`) rather than answering it in UTC. It
808
924
  * reaches every residual compilation, which is where the calendar
809
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.
810
940
  * @returns {Promise<any>}
811
941
  */
812
942
  export function openStore(model, options) {
@@ -817,6 +947,10 @@ export function openStore(model, options) {
817
947
  if (options.compileSchema !== undefined && typeof options.compileSchema !== 'function')
818
948
  throw new TypeError('openStore: compileSchema must be a function when present');
819
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;
820
954
 
821
955
  // API misuse (above) throws; a defective MODEL rejects, per the
822
956
  // asynchronous contract
@@ -824,7 +958,7 @@ export function openStore(model, options) {
824
958
  let entities;
825
959
  let mapping;
826
960
  try {
827
- collections = normalizeModel(model);
961
+ collections = normalizeModel(model, options.expressions);
828
962
  entities = normalizeEntities(model);
829
963
  mapping = entities.size > 0 ? explainMapping(model) : null;
830
964
  if (collections.size === 0 && entities.size === 0) {
@@ -836,17 +970,53 @@ export function openStore(model, options) {
836
970
  return Promise.reject(error);
837
971
  }
838
972
  const path = options.path ?? ':memory:';
839
- const busyTimeout = options.busyTimeout ?? 5000;
840
- const journalMode = options.journalMode ?? 'wal';
841
973
  const memory = path === ':memory:' || path === '';
842
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);
843
988
  const storeProfile = options.profile === undefined
844
989
  ? null
845
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
+ };
846
1015
 
847
1016
  return toPromise(chain(
848
- options.driver.open(path,
1017
+ attempt(() => options.driver.open(path,
849
1018
  { timeout: busyTimeout, readOnly, queueTimeout: options.queueTimeout }),
1019
+ (failure) => (isDriverError(failure) ? openFailure(failure) : failure)),
850
1020
  (opened) => {
851
1021
  /**
852
1022
  * The transaction SCOPE that currently owns the driver connection,
@@ -857,12 +1027,68 @@ export function openStore(model, options) {
857
1027
  */
858
1028
  let scope = null;
859
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
+
860
1081
  /**
861
1082
  * What the cores talk to: the owning transaction's scope while one
862
- * is open, the driver connection otherwise. One indirection here
863
- * instead of a parallel set of handles per transaction — and it is
864
- * why a write inside a transaction callback runs immediately as the
865
- * 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.
866
1092
  */
867
1093
  const connection = Object.freeze({
868
1094
  get synchronous() { return opened.synchronous; },
@@ -871,51 +1097,113 @@ export function openStore(model, options) {
871
1097
  /** @param {string} sql */
872
1098
  exec: (sql) => (scope ?? opened).exec(sql),
873
1099
  /** @param {string} sql */
874
- prepare: (sql) => (scope ?? opened).prepare(sql),
1100
+ prepare: (sql, metadata) => (scope ?? opened).prepare(sql, metadata),
875
1101
  /** Internal transaction users (jobs, checkpoints, migrations)
876
1102
  * nest when a transaction is open and take the gate when not. */
877
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
+ },
878
1117
  registerFunction: opened.registerFunction === null ? null
879
1118
  : (/** @type {string} */ name, /** @type {any} */ o, /** @type {Function} */ fn) =>
880
1119
  opened.registerFunction(name, o, fn),
1120
+ registerAggregate: opened.registerAggregate === null ? null
1121
+ : (/** @type {string} */ name, /** @type {any} */ spec) =>
1122
+ opened.registerAggregate(name, spec),
881
1123
  session: opened.session === null ? null
882
1124
  : (/** @type {any} */ table) => opened.session(table),
1125
+ // the online-backup primitives, when the binding has them
1126
+ backup: opened.backup ?? null,
883
1127
  close: () => opened.close(),
884
1128
  });
885
1129
 
886
1130
  /**
887
1131
  * Run `fn` as a transaction opened by `open`, with `scope` bound to
888
- * 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.
889
1143
  * @param {(inner: (s: any) => any) => any} open - the driver's
890
1144
  * `transaction`, gated (top level) or nesting (inner)
891
- * @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
892
1150
  */
893
- function withScope(open, fn) {
1151
+ function withScope(open, fn, ownWork) {
894
1152
  return open((inner) => {
895
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 = {};
896
1164
  scope = inner;
897
- 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
+ };
898
1182
  let out;
899
1183
  try {
900
- out = fn(scopedStore());
1184
+ out = fn(inner, identity);
901
1185
  }
902
1186
  catch (error) {
903
- restore();
1187
+ restore(false);
904
1188
  throw error;
905
1189
  }
906
1190
  if (!isThenable(out)) {
907
- restore();
1191
+ restore(true);
908
1192
  return out;
909
1193
  }
910
1194
  return out.then(
911
- (value) => { restore(); return value; },
912
- (error) => { restore(); throw error; });
1195
+ (value) => { restore(true); return value; },
1196
+ (error) => { restore(false); throw error; });
913
1197
  });
914
1198
  }
915
1199
 
916
- /** Set once the store object exists; the transaction callback's
917
- * argument, whose `transaction` NESTS instead of queueing. */
918
- 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;
919
1207
 
920
1208
  /**
921
1209
  * A TOP-LEVEL store transaction. It takes the connection's gate
@@ -924,8 +1212,75 @@ export function openStore(model, options) {
924
1212
  * — a rollback then undoes the translated patch together with the
925
1213
  * rows it describes.
926
1214
  * @param {(store: any) => any} fn
1215
+ * @param {AbortSignal} [signal]
1216
+ * @param {any} [ownWork]
1217
+ */
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]
927
1267
  */
928
- let topLevelTransaction = (fn) => withScope(opened.transaction, fn);
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
+ };
929
1284
 
930
1285
  // ————— the rejection boundary around an ACQUIRED connection —————
931
1286
  // Initialization continues for a long way past `driver.open`:
@@ -943,7 +1298,11 @@ export function openStore(model, options) {
943
1298
  * @param {any} error
944
1299
  * @returns {Promise<never>}
945
1300
  */
946
- 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;
947
1306
  if (closed) return Promise.reject(error);
948
1307
  closed = true;
949
1308
  /** @param {any} closeError */
@@ -967,8 +1326,9 @@ export function openStore(model, options) {
967
1326
  // property of the driver that created the file, so a database
968
1327
  // built under one and opened under the other legitimately reports
969
1328
  // drift — that is a migration, not an open.
970
- const derivedMapping = connection.capabilities.deterministicIndexableFunctions === true
971
- ? 'virtual' : 'stored';
1329
+ const registersFunctions =
1330
+ connection.capabilities.deterministicIndexableFunctions === true;
1331
+ const derivedMapping = registersFunctions ? 'virtual' : 'stored';
972
1332
  // the SECOND physical branch, and the same posture: a build
973
1333
  // without the R*Tree module maps `physical: 'rtree'` back onto
974
1334
  // the B-tree over the four columns and SAYS so through
@@ -988,7 +1348,8 @@ export function openStore(model, options) {
988
1348
  try {
989
1349
  for (const [name, collection] of collections)
990
1350
  plans.set(name, planCollection(name, collection, dialect,
991
- { derived: derivedMapping, rtree: rtreeCapable }));
1351
+ { derived: derivedMapping, rtree: rtreeCapable,
1352
+ expressions: options.expressions, registered: registersFunctions }));
992
1353
  if (mapping !== null) {
993
1354
  for (const name of Object.keys(mapping.entities))
994
1355
  entityPlans.set(name, planEntity(name, mapping.entities[name], mapping, dialect));
@@ -1009,17 +1370,34 @@ export function openStore(model, options) {
1009
1370
  const needsDeriveFunctions = derivedMapping === 'virtual'
1010
1371
  && [...plans.values()].some((plan) => plan.generated.some(
1011
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
+ : [];
1012
1383
 
1013
- const pragmas = chain(
1014
- memory
1015
- ? null
1016
- : chain(connection.exec(dialect.pragma.busyTimeout(busyTimeout)),
1017
- // a journal-mode change writes; a read-only store keeps
1018
- // whatever mode the file already has
1019
- () => (readOnly ? null : connection.exec(dialect.pragma.journalMode(journalMode)))),
1020
- // referential integrity is real only when the pragma is ON —
1021
- // it defaults off, so set it AND verify it per connection
1022
- () => 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)), () =>
1023
1401
  chain(connection.prepare(dialect.introspect.foreignKeysOn()), (statement) =>
1024
1402
  chain(statement.get([]), (row) => {
1025
1403
  if (Number(row?.enabled) !== 1) {
@@ -1027,9 +1405,12 @@ export function openStore(model, options) {
1027
1405
  'this connection cannot enforce foreign keys (PRAGMA foreign_keys stayed off)');
1028
1406
  }
1029
1407
  return null;
1030
- }))));
1408
+ })));
1409
+ });
1031
1410
 
1032
- const opening = () => chain(pragmas, () =>
1411
+ const opening = () => chain(pragmas(), () =>
1412
+ chain(registerExpressionFunctions(connection, expressionNames,
1413
+ options.expressions ?? {}), () =>
1033
1414
  chain(needsDeriveFunctions ? registerDeriveFunctions(connection) : null, () =>
1034
1415
  chain(ensureShape(connection, collections, plans, readOnly), () =>
1035
1416
  chain(ensureEntityShape(connection, entityPlans, entities, readOnly), () => {
@@ -1051,7 +1432,7 @@ export function openStore(model, options) {
1051
1432
  throw new TypeError('openStore: compileSchema must return a validation function');
1052
1433
  core = captureCollection(name, collectionCore(connection, collection,
1053
1434
  plans.get(name), validate, queryState,
1054
- { profile: storeProfile }));
1435
+ { profile: storeProfile, roots: declaredRoots }, runtime));
1055
1436
  cores.set(name, core);
1056
1437
  }
1057
1438
  return core;
@@ -1065,6 +1446,16 @@ export function openStore(model, options) {
1065
1446
  let captureMode = 'none';
1066
1447
  if (captureRequested !== null) {
1067
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
+ }
1068
1459
  const hasSessions = connection.capabilities.sessions === true
1069
1460
  && typeof connection.session === 'function';
1070
1461
  if (wanted === 'session' && !hasSessions) {
@@ -1127,18 +1518,43 @@ export function openStore(model, options) {
1127
1518
  });
1128
1519
  }
1129
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);
1130
1528
  const capture = captureMode === 'none' ? null : createCaptureEngine({
1131
1529
  connection,
1530
+ bracket: firstOpen,
1132
1531
  shapes: captureShapes,
1133
1532
  mode: captureMode,
1134
1533
  log: captureRequested.log === true
1135
1534
  || (captureRequested.log !== undefined && captureRequested.log !== false),
1136
1535
  retention: captureRequested.log?.retention ?? DEFAULT_RETENTION,
1536
+ now: runtime.now,
1137
1537
  });
1138
- 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' }));
1139
1546
  if (capture !== null) {
1140
- topLevelTransaction = (fn) => withScope(opened.transaction,
1141
- (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);
1142
1558
  }
1143
1559
  // the live registry rides the capture stream; its dispatcher
1144
1560
  // registers FIRST so maintenance sees every record before any
@@ -1153,11 +1569,24 @@ export function openStore(model, options) {
1153
1569
  // the durable job queue (JOBS-FORMAT), opt-in per store
1154
1570
  const jobsRequested = options.jobs === true
1155
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
+ }
1156
1577
  const jobsEngine = !jobsRequested ? null : createJobEngine({
1157
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),
1158
1586
  now: typeof options.jobs === 'object' ? options.jobs.now : undefined,
1159
1587
  random: typeof options.jobs === 'object' ? options.jobs.random : undefined,
1160
1588
  defaults: typeof options.jobs === 'object' ? options.jobs : undefined,
1589
+ runtime,
1161
1590
  });
1162
1591
  /** Register a collection live query (LIVE-FORMAT §7). */
1163
1592
  const refuseAsyncLive = () => {
@@ -1175,7 +1604,7 @@ export function openStore(model, options) {
1175
1604
  const classification = liveOptions?.mode === 'rerun'
1176
1605
  ? { strategy: 'rerun', reason: 'rerun was requested' }
1177
1606
  : classifyLiveQuery(document, core.queryShape, keyed, eventTime);
1178
- return /** @type {any} */ (liveRegistry).register({
1607
+ return closeOnRollback(/** @type {any} */ (liveRegistry).register({
1179
1608
  name: core.model.name,
1180
1609
  tables: new Set([core.model.name]),
1181
1610
  document,
@@ -1186,8 +1615,17 @@ export function openStore(model, options) {
1186
1615
  readRow: (token) => core.get(token),
1187
1616
  keyOf: (doc) => String(extractKey(doc, core.model.keySegments,
1188
1617
  core.model.key, core.model.name, core.model.docPath)),
1189
- });
1618
+ }));
1190
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
+ });
1191
1629
  /** Strip relation members before journal diffs — sessions
1192
1630
  * never see them (they are not stored), so the two modes
1193
1631
  * stay identical. */
@@ -1310,11 +1748,42 @@ export function openStore(model, options) {
1310
1748
  };
1311
1749
  };
1312
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
+
1313
1761
  const capabilities = Object.freeze({
1314
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
+ }),
1315
1776
  validated: options.compileSchema !== undefined,
1316
- busyTimeoutMs: memory ? null : busyTimeout,
1317
- 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,
1318
1787
  readOnly,
1319
1788
  profiled: storeProfile !== null,
1320
1789
  // the registered operator vocabulary (Ring 2): the names a
@@ -1333,7 +1802,7 @@ export function openStore(model, options) {
1333
1802
  // ZONE will compile at all here. Without one the document is
1334
1803
  // refused (`JQ0003`) rather than answered in UTC, and a
1335
1804
  // consumer that wants to know before it asks reads this
1336
- zoneProvider: options.zoneProvider !== undefined && options.zoneProvider !== null,
1805
+ zoneProvider: zoneProvider !== undefined && zoneProvider !== null,
1337
1806
  capture: captureMode,
1338
1807
  captureLog: captureMode !== 'none'
1339
1808
  && (captureRequested.log === true
@@ -1346,9 +1815,10 @@ export function openStore(model, options) {
1346
1815
  });
1347
1816
 
1348
1817
  const queryState = createQueryState(options.statementCacheBound, operators,
1349
- options.zoneProvider);
1818
+ zoneProvider, runtime.now);
1350
1819
  const entityEngine = entities.size > 0
1351
- ? createEntityQueryEngine({ connection, entities, mapping, state: queryState })
1820
+ ? createEntityQueryEngine({ connection, entities, mapping, state: queryState,
1821
+ profile: storeProfile, roots: declaredRoots })
1352
1822
  : null;
1353
1823
  /** @type {Map<string, any>} */
1354
1824
  const loadEngines = new Map();
@@ -1361,7 +1831,8 @@ export function openStore(model, options) {
1361
1831
  { docPath: '/entities', collection: name });
1362
1832
  }
1363
1833
  engine = createLoadEngine(
1364
- { connection, entities, mapping, state: queryState }, name);
1834
+ { connection, entities, mapping, state: queryState, coreFor: entityCoreFor,
1835
+ profile: storeProfile, roots: declaredRoots }, name);
1365
1836
  loadEngines.set(name, engine);
1366
1837
  }
1367
1838
  return engine;
@@ -1383,30 +1854,46 @@ export function openStore(model, options) {
1383
1854
  if (validate !== null && typeof validate !== 'function')
1384
1855
  throw new TypeError('openStore: compileSchema must return a validation function');
1385
1856
  core = captureEntity(name, entityCore(connection, entity,
1386
- mapping.entities[name], validate));
1857
+ mapping.entities[name], validate, runtime));
1387
1858
  entityCores.set(name, core);
1388
1859
  }
1389
1860
  return core;
1390
1861
  };
1391
1862
 
1392
- const tracker = entities.size > 0
1393
- ? createTracker({
1394
- connection, entities, mapping, coreFor: entityCoreFor,
1395
- captureRecord: capture === null || capture.mode !== 'journal'
1396
- ? undefined
1397
- : (table, keyParts, before, after) => capture.record(table, keyParts,
1398
- before === undefined ? undefined : stripRelations(table, before),
1399
- stripRelations(table, after)),
1400
- captureJoinDelete: captureJoinDelete ?? undefined,
1401
- })
1402
- : null;
1403
- /** @type {Map<string, any>} */
1404
- const trackedOps = new Map();
1405
- // the unit-of-work surface (§11): reads register frozen
1406
- // snapshots; add/put/remove are LOCAL bookkeeping (no
1407
- // database round trip, deliberately synchronous on both
1408
- // surfaces); asNoTracking() reads retain nothing
1409
- 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) => {
1410
1897
  let ops = trackedOps.get(name);
1411
1898
  if (ops !== undefined) return ops;
1412
1899
  const core = entityCoreFor(name);
@@ -1491,9 +1978,24 @@ export function openStore(model, options) {
1491
1978
  tracker.discard(name, key);
1492
1979
  return done;
1493
1980
  }),
1494
- load: (spec) => chain(loads.load(spec),
1981
+ load: (spec, loadOptions) => chain(loads.load(spec, loadOptions),
1495
1982
  (docs) => tracker.registerGraph(loads.treeFor(spec), docs)),
1496
- 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),
1497
1999
  add: (doc) => tracker.add(name, doc),
1498
2000
  put: (next) => tracker.put(name, next),
1499
2001
  remove: (keyOrDoc) => tracker.remove(name, keyOrDoc),
@@ -1504,49 +2006,35 @@ export function openStore(model, options) {
1504
2006
  unlink: (own, member, target) => tracker.unlink(name, own, member, target),
1505
2007
  noTracking: {
1506
2008
  get: (key) => core.get(key),
1507
- load: (spec) => loads.load(spec),
2009
+ load: (spec, loadOptions) => loads.load(spec, loadOptions),
1508
2010
  },
1509
2011
  };
1510
2012
  trackedOps.set(name, ops);
1511
2013
  return ops;
1512
- };
2014
+ };
1513
2015
 
1514
- /** @type {Map<string, any>} */
1515
- const asyncHandles = new Map();
1516
- /** @type {Map<string, any>} */
1517
- const asyncEntityHandles = new Map();
1518
- const store = {
1519
- capabilities,
1520
- stats: () => ({
1521
- statementCache: { ...queryState.counters },
1522
- udfRegistrations: queryState.registered.size,
1523
- tracker: tracker === null ? null : tracker.counts(),
1524
- liveQueries: liveRegistry === null ? 0 : liveRegistry.count(),
1525
- }),
1526
- dialect,
1527
- collection(name) {
1528
- let handle = asyncHandles.get(name);
1529
- if (handle === undefined) {
1530
- handle = asyncCollection(coreFor(name),
1531
- liveRegistry === null ? null : registerCollectionLive);
1532
- asyncHandles.set(name, handle);
1533
- }
1534
- return handle;
1535
- },
1536
- entity(name) {
1537
- let handle = asyncEntityHandles.get(name);
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);
1538
2024
  if (handle === undefined) {
1539
2025
  const ops = trackedOpsFor(name);
1540
2026
  const untracked = Object.freeze({
1541
2027
  get: lift((key) => ops.noTracking.get(key)),
1542
- load: lift((spec) => ops.noTracking.load(spec)),
2028
+ load: lift((spec, loadOptions) => ops.noTracking.load(spec, loadOptions)),
1543
2029
  });
1544
2030
  handle = Object.freeze({
1545
2031
  create: lift((doc) => ops.create(doc)),
1546
2032
  get: lift((key) => ops.get(key)),
1547
2033
  update: lift((key, changes) => ops.update(key, changes)),
1548
2034
  delete: lift((key) => ops.delete(key)),
1549
- load: lift((spec) => ops.load(spec)),
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)),
1550
2038
  explainLoad: ops.explainLoad,
1551
2039
  add: ops.add,
1552
2040
  put: ops.put,
@@ -1564,67 +2052,299 @@ export function openStore(model, options) {
1564
2052
  // `relations` this entity's own relation table (§10.1).
1565
2053
  // `execute` stays value-or-promise (D2), as a collection's
1566
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),
1567
2064
  explain: lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
1568
2065
  root: entityRoot(name),
1569
2066
  scope: entityEngine,
1570
2067
  relations: entityEngine.relations[name],
1571
2068
  });
1572
- asyncEntityHandles.set(name, handle);
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 });
2117
+ };
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
+
2124
+ /** @type {Map<string, any>} */
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
+
2226
+ /** @type {Map<string, any>} */
2227
+ const gatedCollections = new Map();
2228
+ /** @type {Map<string, any>} */
2229
+ const gatedEntities = new Map();
2230
+
2231
+ const store = {
2232
+ capabilities,
2233
+ // the ROOT's bookkeeping, always: an open own-unit
2234
+ // transaction changes what its own view reports, never this
2235
+ stats: () => ({
2236
+ statementCache: { ...queryState.counters },
2237
+ udfRegistrations: queryState.registered.size,
2238
+ tracker: rootWork.tracker === null ? null : rootWork.tracker.counts(),
2239
+ liveQueries: liveRegistry === null ? 0 : liveRegistry.count(),
2240
+ }),
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.
2247
+ collection(name) {
2248
+ let handle = gatedCollections.get(name);
2249
+ if (handle === undefined) {
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);
2265
+ }
2266
+ return handle;
2267
+ },
2268
+ entity(name) {
2269
+ let handle = gatedEntities.get(name);
2270
+ if (handle === undefined) {
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']);
2282
+ handle = Object.freeze({
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'),
2288
+ asNoTracking: () => untracked,
2289
+ });
2290
+ gatedEntities.set(name, handle);
1573
2291
  }
1574
2292
  return handle;
1575
2293
  },
1576
2294
  saveChanges: entities.size === 0 ? undefined
1577
- : lift(() => guard(() => tracker.saveChanges())),
2295
+ : lift(() => gated(() => guard(() => rootWork.tracker.saveChanges()))),
1578
2296
  // entity DOCUMENTS query the multi-entity root at the store;
1579
2297
  // `roots` names the entity arrays this provider serves, so a
1580
2298
  // chain asked to iterate the store itself can refuse by name
1581
2299
  execute: entityEngine === null ? undefined
1582
- : (document, queryOptions) => entityEngine.execute(document, queryOptions),
2300
+ : (document, queryOptions) =>
2301
+ gated(() => entityEngine.execute(document, queryOptions)),
1583
2302
  explain: entityEngine === null ? undefined
1584
- : lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
2303
+ : lift((document, queryOptions) =>
2304
+ gated(() => entityEngine.explain(document, queryOptions))),
1585
2305
  roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
1586
2306
  relations: entityEngine === null ? undefined : entityEngine.relations,
1587
2307
  // entity live queries re-run on invalidation — declared,
1588
- // 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.
1589
2314
  live: entityEngine === null ? undefined
1590
- : lift((document, liveOptions) => {
1591
- if (liveRegistry === null) {
1592
- throw new DbCompileError('JD0050',
1593
- 'live queries require change capture — open the store with { capture: true }');
1594
- }
1595
- refuseAsyncLive();
1596
- if (liveOptions?.eventTime !== undefined) {
1597
- throw new DbCompileError('JD0053',
1598
- 'live eventTime maintains a collection view — an entity document re-runs, '
1599
- + 'so a watermark would describe nothing (LIVE-FORMAT §13)');
1600
- }
1601
- const roots = collectEntityRoots(document, entities);
1602
- if (roots.size === 0) {
1603
- throw new TypeError(
1604
- 'store.live takes an entity-root document — for a collection, '
1605
- + 'use store.collection(name).live');
1606
- }
1607
- return liveRegistry.register({
1608
- name: [...roots].join('+'),
1609
- tables: roots,
1610
- document,
1611
- externals: liveOptions?.externals ?? {},
1612
- demanded: liveOptions?.mode,
1613
- classification: {
1614
- strategy: 'rerun',
1615
- reason: 'entity queries re-run in this version',
1616
- },
1617
- execute: (doc, executeOptions) => entityEngine.execute(doc, executeOptions),
1618
- readRow: null,
1619
- keyOf: null,
1620
- });
1621
- }),
2315
+ : lift((document, liveOptions) =>
2316
+ gated(() => registerEntityLive(document, liveOptions), 'a root live registration')),
1622
2317
  // A TOP-LEVEL transaction: it takes the connection's gate, so
1623
2318
  // it never shares a savepoint stack with another one. To nest,
1624
2319
  // use the store the callback RECEIVES — the outer store cannot
1625
2320
  // tell an inner transaction from an unrelated caller, and an
1626
- // unrelated caller must wait for the commit.
1627
- 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
+ }),
1628
2348
  observe: (fn) => {
1629
2349
  if (capture === null) {
1630
2350
  throw new TypeError(
@@ -1633,19 +2353,79 @@ export function openStore(model, options) {
1633
2353
  return capture.observe(fn);
1634
2354
  },
1635
2355
  changesSince: capture === null ? undefined
1636
- : lift((after) => capture.changesSince(after)),
1637
- dataVersion: lift(() => chain(
1638
- connection.prepare(dialect.introspect.dataVersion()),
1639
- (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.
1640
2393
  jobs: jobsEngine === null ? undefined : Object.freeze({
1641
- enqueue: lift(jobsEngine.enqueue),
1642
- get: lift(jobsEngine.get),
1643
- counts: lift(jobsEngine.counts),
1644
- claim: lift(jobsEngine.claim),
1645
- complete: lift(jobsEngine.complete),
1646
- fail: lift(jobsEngine.fail),
1647
- 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
+ },
1648
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')),
1649
2429
  }),
1650
2430
  /**
1651
2431
  * Close the store. Job workers are asked to stop and given a
@@ -1675,55 +2455,513 @@ export function openStore(model, options) {
1675
2455
  }),
1676
2456
  };
1677
2457
 
1678
- // The transaction callback's argument. It is the store, with one
1679
- // difference that matters: its `transaction` NESTS through the
1680
- // owning savepoint instead of queueing behind it. Everything
1681
- // else already reaches the open transaction, because the cores
1682
- // read the active scope.
1683
- /** @type {any} */
1684
- let txStore = null;
1685
- /** Nest through the savepoint that owns the connection now. The
1686
- * capture scope goes INSIDE the savepoint, so a rollback undoes
1687
- * the translated patch with the rows it describes. */
1688
- const nested = (/** @type {any} */ fn) => withScope(scope.transaction,
1689
- (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
+
1690
2597
  /** The overriding member on a view of the FROZEN store: plain
1691
2598
  * assignment cannot shadow a non-writable inherited property. */
1692
2599
  const override = (/** @type {any} */ value) =>
1693
2600
  ({ value, writable: false, enumerable: true, configurable: false });
1694
- scopedStore = () => {
1695
- if (txStore === null) {
1696
- const members = { transaction: override(nested) };
1697
- if (store.sync !== undefined) {
1698
- members.sync = override(Object.create(store.sync,
1699
- { 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);
2623
+ }
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);
1700
2631
  }
1701
- txStore = Object.freeze(Object.create(store, members));
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
+ }));
1702
2863
  }
1703
- return txStore;
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
+ }));
2912
+ }
2913
+ return Object.freeze(Object.create(store, members));
1704
2914
  };
1705
2915
 
1706
2916
  if (connection.synchronous) {
1707
2917
  /** @type {Map<string, any>} */
1708
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
+ };
1709
2954
  /** @type {Map<string, any>} */
1710
- const syncEntityHandles = new Map();
2955
+ const gatedSyncCollections = new Map();
2956
+ /** @type {Map<string, any>} */
2957
+ const gatedSyncEntities = new Map();
1711
2958
  store.sync = Object.freeze({
1712
2959
  collection(name) {
1713
- let handle = syncHandles.get(name);
2960
+ let handle = gatedSyncCollections.get(name);
1714
2961
  if (handle === undefined) {
1715
- const core = coreFor(name);
1716
- handle = Object.freeze({
1717
- stats: () => core.stats(),
1718
- get: (key) => core.get(key),
1719
- insert: (doc) => core.insert(doc),
1720
- put: (doc, key) => core.put(doc, key),
1721
- patch: (key, ops) => core.patch(key, ops),
1722
- delete: (key) => core.delete(key),
1723
- execute: (document, options) => core.execute(document, options),
1724
- explain: (document, options) => core.explain(document, options),
1725
- });
1726
- syncHandles.set(name, handle);
2962
+ handle = syncGatedMembers(forSync(name),
2963
+ ['get', 'insert', 'put', 'patch', 'delete', 'execute', 'explain']);
2964
+ gatedSyncCollections.set(name, handle);
1727
2965
  }
1728
2966
  return handle;
1729
2967
  },
@@ -1740,46 +2978,33 @@ export function openStore(model, options) {
1740
2978
  return topLevelTransaction(fn);
1741
2979
  },
1742
2980
  entity(name) {
1743
- let handle = syncEntityHandles.get(name);
2981
+ let handle = gatedSyncEntities.get(name);
1744
2982
  if (handle === undefined) {
1745
- const ops = trackedOpsFor(name);
1746
- const untracked = Object.freeze({
1747
- get: (key) => ops.noTracking.get(key),
1748
- load: (spec) => ops.noTracking.load(spec),
1749
- });
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']);
1750
2987
  handle = Object.freeze({
1751
- create: (doc) => ops.create(doc),
1752
- get: (key) => ops.get(key),
1753
- update: (key, changes) => ops.update(key, changes),
1754
- delete: (key) => ops.delete(key),
1755
- load: (spec) => ops.load(spec),
1756
- explainLoad: ops.explainLoad,
1757
- add: ops.add,
1758
- put: ops.put,
1759
- remove: ops.remove,
1760
- discard: ops.discard,
1761
- link: ops.link,
1762
- unlink: ops.unlink,
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)),
1763
2994
  asNoTracking: () => untracked,
1764
- // the same provider members as the asynchronous handle,
1765
- // answering values; one handle per name, so two chains
1766
- // over one set share one source identity
1767
- execute: (document, queryOptions) => entityEngine.execute(document, queryOptions),
1768
- explain: (document, queryOptions) => entityEngine.explain(document, queryOptions),
1769
- root: entityRoot(name),
1770
- scope: entityEngine,
1771
- relations: entityEngine.relations[name],
1772
2995
  });
1773
- syncEntityHandles.set(name, handle);
2996
+ gatedSyncEntities.set(name, handle);
1774
2997
  }
1775
2998
  return handle;
1776
2999
  },
1777
3000
  saveChanges: entities.size === 0 ? undefined
1778
- : () => guard(() => tracker.saveChanges()),
3001
+ : () => gatedSync(() => guard(() => rootWork.tracker.saveChanges())),
1779
3002
  execute: entityEngine === null ? undefined
1780
- : (document, queryOptions) => entityEngine.execute(document, queryOptions),
3003
+ : (document, queryOptions) =>
3004
+ gatedSync(() => entityEngine.execute(document, queryOptions)),
1781
3005
  explain: entityEngine === null ? undefined
1782
- : (document, queryOptions) => entityEngine.explain(document, queryOptions),
3006
+ : (document, queryOptions) =>
3007
+ gatedSync(() => entityEngine.explain(document, queryOptions)),
1783
3008
  roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
1784
3009
  relations: entityEngine === null ? undefined : entityEngine.relations,
1785
3010
  });
@@ -1787,15 +3012,31 @@ export function openStore(model, options) {
1787
3012
  return chain(capture === null ? null : capture.ready,
1788
3013
  () => chain(jobsEngine === null ? null : jobsEngine.ready,
1789
3014
  () => Object.freeze(store)));
1790
- }))));
3015
+ })))));
1791
3016
 
1792
- let opened_;
1793
- try {
1794
- opened_ = opening();
1795
- }
1796
- catch (error) {
1797
- return failClosed(error);
1798
- }
1799
- 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);
1800
3041
  }));
1801
3042
  }