@jarenjs/db 0.56.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
package/src/store.js CHANGED
@@ -20,27 +20,41 @@
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 { normalizeEntities, explainMapping } from './model.js';
32
+ import { admitCursor, admitSyncCursor, createCursor, drainPage, utf8Length } 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';
37
+ import { normalizeEntities, explainMapping, joinTableRoots } from './model.js';
33
38
  import { entityCore } from './entity.js';
34
39
  import { createTracker, membershipKeys } from './tracker.js';
35
40
  import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
41
+ import { createReplicationEngine } from './replication.js';
42
+ import { REPLICATION_DEFAULTS } from './replication-format.js';
43
+ import { createLogicalRows } from './logical-rows.js';
44
+ import { shapeHash } from './migrate.js';
36
45
  import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js';
46
+ import { classifyEntityLive } from './live-join.js';
37
47
  import { normalizeEventTime } from './live-time.js';
38
48
  import { createJobEngine } from './jobs.js';
49
+ import { introspectModel } from './introspect.js';
39
50
  import { collectEntityRoots, entityRoot } from './plan.js';
40
51
  import {
41
52
  DERIVE_KINDS, PHYSICAL_KINDS, PRECISION_MIN, PRECISION_MAX, DIMS_MIN, DIMS_MAX,
42
53
  derivedValue, memberAt, storedMemberForm, registerDeriveFunctions,
43
54
  } from './derive.js';
55
+ import {
56
+ normalizeExpression, canonicalExpression, expressionMembers, registerExpressionFunctions,
57
+ } from './expression.js';
44
58
 
45
59
  /** The model format version this store implements. */
46
60
  export const MODEL_VERSION = '0.1';
@@ -194,9 +208,13 @@ function normalizeDerive(index, paths, docPath) {
194
208
  * Normalize and check a model document. Every failure is `JD0005` with
195
209
  * a `docPath` into the model.
196
210
  * @param {any} model
211
+ * @param {Record<string, any>} [expressions] - the host's declared
212
+ * index-expression functions, by name: a model that names one is
213
+ * resolved against them here, so an unknown, wrong-arity or
214
+ * non-deterministic function is `JD0004` before any DDL
197
215
  * @returns {Map<string, any>} collection name -> normalized collection
198
216
  */
199
- export function normalizeModel(model) {
217
+ export function normalizeModel(model, expressions = undefined) {
200
218
  if (model === null || typeof model !== 'object' || Array.isArray(model))
201
219
  throw modelError('JD0005', 'the model document must be an object', '');
202
220
  if (model.$model !== MODEL_VERSION) {
@@ -285,6 +303,38 @@ export function normalizeModel(model) {
285
303
  `duplicate index name '${index.name}'`, `${indexDocPath}/name`);
286
304
  }
287
305
  indexNames.add(index.name);
306
+ // an EXPRESSION index names what it computes, not which member it
307
+ // reads, so it is mutually exclusive with both of the other two
308
+ // ways an index is declared: an index cannot be over a member AND
309
+ // over a function of one, and a derived spatial column is already
310
+ // an expression this format spells for you
311
+ if (index.expression !== undefined) {
312
+ if (index.path !== undefined) {
313
+ throw modelError('JD0004',
314
+ 'an index declares a path OR an expression: an expression names the members it '
315
+ + 'reads itself', `${indexDocPath}/path`);
316
+ }
317
+ if (index.derive !== undefined) {
318
+ throw modelError('JD0004',
319
+ 'a derived index IS an expression this format spells; declare one or the other',
320
+ `${indexDocPath}/derive`);
321
+ }
322
+ const expression = normalizeExpression(index.expression,
323
+ `${indexDocPath}/expression`, expressions);
324
+ indexes.push({
325
+ name: index.name,
326
+ paths: expressionMembers(expression),
327
+ expression,
328
+ canonical: canonicalExpression(expression),
329
+ unique: index.unique === true,
330
+ derive: null,
331
+ precision: undefined,
332
+ physical: undefined,
333
+ dims: undefined,
334
+ docPath: indexDocPath,
335
+ });
336
+ continue;
337
+ }
288
338
  const paths = Array.isArray(index.path) ? index.path : [index.path];
289
339
  if (paths.length === 0
290
340
  || paths.some((p) => typeof p !== 'string' || p === '')) {
@@ -379,24 +429,48 @@ function requireKey(key, collection, docPath) {
379
429
  * @returns {DbRuntimeError}
380
430
  */
381
431
  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 });
432
+ // one classifier for every path: a coded error (a closed store, a
433
+ // refused document) passes through it untouched
434
+ return wrapDriverError(error, {
435
+ docPath, collection, ...(key === undefined ? undefined : { key }),
436
+ unique: { table: plan.table, column: plan.keyColumn },
437
+ duplicateReason: `a document already exists under key '${String(key)}'`,
438
+ });
439
+ }
440
+
441
+ /**
442
+ * Run `fn` inside an IMMEDIATE transaction on an otherwise idle
443
+ * connection the open path's shape work. A deferred transaction (a
444
+ * bare savepoint) takes its write lock only when the first write
445
+ * arrives, and a concurrent commit between the probe and the CREATE
446
+ * turns that upgrade into the one SQLITE_BUSY the busy handler cannot
447
+ * retry; taking the write lock first makes the wait an ordinary busy
448
+ * wait the timeout covers. Exactly the bracket the migration runner
449
+ * uses; the driver's savepoint machinery is not involved, because at
450
+ * open nothing else holds the connection.
451
+ * @param {any} connection
452
+ * @param {() => any} fn - value-or-promise
453
+ * @returns {any} value-or-promise
454
+ */
455
+ function immediately(connection, fn) {
456
+ const dialect = connection.dialect;
457
+ const commit = (value) => chain(connection.exec(dialect.tx.commit), () => value);
458
+ const rollback = (error) => chain(connection.exec(dialect.tx.rollback), () => { throw error; });
459
+ return chain(connection.exec(dialect.tx.beginImmediate), () => {
460
+ let out;
461
+ try {
462
+ out = fn();
463
+ }
464
+ catch (error) {
465
+ return rollback(error);
466
+ }
467
+ return isThenable(out) ? out.then(commit, rollback) : commit(out);
468
+ });
395
469
  }
396
470
 
397
471
  /**
398
472
  * Create or verify every collection's physical shape, inside one
399
- * transaction.
473
+ * immediate transaction.
400
474
  * @param {any} connection
401
475
  * @param {Map<string, any>} collections
402
476
  * @param {Map<string, any>} plans
@@ -405,7 +479,9 @@ function wrapWriteError(error, plan, collection, docPath, key) {
405
479
  function ensureShape(connection, collections, plans, readOnly) {
406
480
  const dialect = connection.dialect;
407
481
  const names = [...collections.keys()];
408
- return connection.transaction(() => {
482
+ // a read-only store creates nothing, and cannot take a write lock
483
+ const bracket = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
484
+ return bracket(() => {
409
485
  const step = (i) => {
410
486
  if (i >= names.length) return null;
411
487
  const name = names[i];
@@ -421,7 +497,7 @@ function ensureShape(connection, collections, plans, readOnly) {
421
497
  }
422
498
  const run = (j) => (j >= plan.createSql.length
423
499
  ? null
424
- : chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
500
+ : chain(connection.exec(dialect.ddl.idempotent(plan.createSql[j])), () => run(j + 1)));
425
501
  return chain(run(0), () => step(i + 1));
426
502
  }
427
503
  return chain(verifyShape(connection, plan, name, collection.docPath),
@@ -447,7 +523,8 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
447
523
  if (entityPlans.size === 0) return null;
448
524
  const dialect = connection.dialect;
449
525
  const names = [...entityPlans.keys()];
450
- return connection.transaction(() => {
526
+ const bracket = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
527
+ return bracket(() => {
451
528
  const step = (i) => {
452
529
  if (i >= names.length) return null;
453
530
  const name = names[i];
@@ -463,7 +540,7 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
463
540
  }
464
541
  const run = (j) => (j >= plan.createSql.length
465
542
  ? null
466
- : chain(connection.exec(plan.createSql[j]), () => run(j + 1)));
543
+ : chain(connection.exec(dialect.ddl.idempotent(plan.createSql[j])), () => run(j + 1)));
467
544
  return chain(run(0), () => step(i + 1));
468
545
  }
469
546
  return chain(verifyShape(connection, plan, name, docPath), () =>
@@ -510,10 +587,11 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
510
587
  * @param {any} plan
511
588
  * @param {((doc: any) => any) | null} validate
512
589
  * @param {any} queryState - the store-wide statement cache and UDF set
513
- * @param {{ profile: any }} storeProfileRef - the store-level profile
590
+ * @param {{ profile: any, roots: readonly string[] }} storeProfileRef - the
591
+ * store-level profile and every root a member allow-list may name
514
592
  * @returns {any}
515
593
  */
516
- function collectionCore(connection, collection, plan, validate, queryState, storeProfileRef) {
594
+ function collectionCore(connection, collection, plan, validate, queryState, storeProfileRef, runtime) {
517
595
  const dialect = connection.dialect;
518
596
  // the STORED branch (a driver that cannot index a registered
519
597
  // function): the derived columns are ordinary ones, so every write
@@ -562,7 +640,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
562
640
  const stats = { patchTranslated: 0, patchFallback: 0 };
563
641
  const engine = createQueryEngine({
564
642
  connection, state: queryState, collection, physicalPlan: plan,
565
- profile: storeProfileRef.profile,
643
+ profile: storeProfileRef.profile, roots: storeProfileRef.roots,
566
644
  });
567
645
 
568
646
  const checkValid = (doc) => {
@@ -585,7 +663,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
585
663
  }
586
664
  if (explicitKey !== undefined)
587
665
  return requireKey(explicitKey, collection.name, collection.docPath);
588
- if (collection.identity === 'uuid') return crypto.randomUUID();
666
+ if (collection.identity === 'uuid') return runtime.uuid();
589
667
  return null; // integer: the database allocates
590
668
  };
591
669
 
@@ -606,9 +684,12 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
606
684
  explain: (document, options) => engine.explain(document, options),
607
685
  get(key) {
608
686
  requireKey(key, collection.name, collection.docPath);
609
- return chain(prepared('get', dialect.dml.get(shape)), (statement) =>
687
+ // a point read meets the same failures a statement of the query
688
+ // engine does (a corrupt page, a locked file): classified, never raw
689
+ return attempt(() => chain(prepared('get', dialect.dml.get(shape)), (statement) =>
610
690
  chain(statement.get([key]),
611
- (row) => (row === undefined ? undefined : JSON.parse(row.doc))));
691
+ (row) => (row === undefined ? undefined : JSON.parse(row.doc)))),
692
+ (error) => wrapDriverError(error, { docPath: collection.docPath, collection: collection.name, key }));
612
693
  },
613
694
  insert(doc) {
614
695
  checkValid(doc);
@@ -726,20 +807,6 @@ function asyncCollection(core, live) {
726
807
  });
727
808
  }
728
809
 
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
810
  /**
744
811
  * The schema a WRITE validates against. A store-allocated key (`default:
745
812
  * "auto"`) is absent from the document the injected hook sees — the
@@ -760,6 +827,50 @@ function writeSchemaOf(entity) {
760
827
  return out;
761
828
  }
762
829
 
830
+ /**
831
+ * Resolve the store's operator seam (Ring 2) to a single
832
+ * `{ functions, extensions }` or `null`. Accepts `options.operators` (a
833
+ * registry from `@jarenjs/json/jslt`'s `createJsltRegistry()`) and/or
834
+ * raw `options.functions` / `options.extensions`. A registered operator
835
+ * becomes engine vocabulary the query planner recognises and the
836
+ * residual evaluates — it runs correctly in JavaScript over the fetched
837
+ * rows, and is never pushed to SQL in this ring (that is Ring 3). Bad
838
+ * input is API misuse (a synchronous `TypeError`), consistent with the
839
+ * driver check. When no registry is threaded the return is `null`, and
840
+ * the whole query engine is byte-identical to before.
841
+ * @param {any} options
842
+ * @returns {{ functions: any, extensions: any } | null}
843
+ */
844
+ /**
845
+ * The declaration rules a registry aggregate must satisfy to be lowered
846
+ * to a SQL aggregate, checked once at open: `kind: 'agg'`, ONE leading
847
+ * sequence operand (the fold's input) and nothing else, and a scalar
848
+ * result. The arity rule is not fussiness — a SQL aggregate's `result`
849
+ * step sees only what the row steps accumulated, so a second operand
850
+ * simply does not reach a fold over zero rows, and an aggregate that
851
+ * answered a different value for an empty input than the engine does
852
+ * would be worse than one that stays where it is. A pack that marks
853
+ * something else `pushable: 'aggregate'` is a host configuration
854
+ * error, loud here rather than a silent non-promotion.
855
+ * @param {string} name
856
+ * @param {any} meta - the registry's `forSql()` entry
857
+ * @returns {{ fn: Function }}
858
+ */
859
+ function aggregateSpec(name, meta) {
860
+ const kinds = (Array.isArray(meta.signature) ? meta.signature : [])
861
+ .map((token) => (typeof token === 'string' && token.startsWith('seq') ? 'seq' : 'scalar'));
862
+ const wellFormed = meta.kind === 'agg'
863
+ && kinds.length === 1 && kinds[0] === 'seq'
864
+ && meta.signature[0] === 'seq<number>'
865
+ && meta.result === 'number'
866
+ && typeof meta.fn === 'function';
867
+ if (!wellFormed) {
868
+ throw new TypeError(`openStore: operator '${name}' declares pushable: 'aggregate', which `
869
+ + "needs kind: 'agg' with exactly one operand, 'seq<number>', and result: 'number'");
870
+ }
871
+ return { fn: meta.fn };
872
+ }
873
+
763
874
  function resolveOperators(options) {
764
875
  const registry = options.operators;
765
876
  const hasRegistry = registry !== undefined && registry !== null;
@@ -772,6 +883,11 @@ function resolveOperators(options) {
772
883
  // UDFs where the driver supports them. Raw (registry-free) extensions
773
884
  // are never pushed — only a registry declares pushability.
774
885
  const pushableScalar = new Set();
886
+ // the SQL-pushable AGGREGATE subset (Ring 3): a registry `agg` entry
887
+ // marked `pushable: 'aggregate'`, which promises a fold over the
888
+ // multiset alone — a SQL aggregate visits rows in an order nothing
889
+ // specifies — and a finite-or-empty scalar result
890
+ const pushableAggregate = new Map();
775
891
  if (hasRegistry) {
776
892
  if (typeof registry.toOptions !== 'function') {
777
893
  throw new TypeError('openStore: operators must be a registry '
@@ -785,12 +901,13 @@ function resolveOperators(options) {
785
901
  if (typeof registry.forSql === 'function') {
786
902
  for (const [name, meta] of Object.entries(registry.forSql())) {
787
903
  if (meta.pushable === 'scalar') pushableScalar.add(name);
904
+ else if (meta.pushable === 'aggregate') pushableAggregate.set(name, aggregateSpec(name, meta));
788
905
  }
789
906
  }
790
907
  }
791
908
  if (options.functions !== undefined) functions = { ...functions, ...options.functions };
792
909
  if (options.extensions !== undefined) extensions = { ...extensions, ...options.extensions };
793
- return Object.freeze({ functions, extensions, pushableScalar });
910
+ return Object.freeze({ functions, extensions, pushableScalar, pushableAggregate });
794
911
  }
795
912
 
796
913
  /**
@@ -798,8 +915,12 @@ function resolveOperators(options) {
798
915
  * @param {any} model - A `jaren-model` document (the 0.1 subset)
799
916
  * @param {{ driver: any, path?: string, compileSchema?: Function,
800
917
  * busyTimeout?: number, queueTimeout?: number, journalMode?: string,
918
+ * synchronous?: string, walAutocheckpoint?: number,
919
+ * journalSizeLimit?: number, cacheSize?: number, mmapSize?: number,
920
+ * tempStore?: string,
801
921
  * statementCacheBound?: number, profile?: any, operators?: any,
802
922
  * functions?: any, extensions?: any, zoneProvider?: any,
923
+ * runtime?: Partial<import('@jarenjs/core/runtime').Runtime>,
803
924
  * readOnly?: boolean }} options
804
925
  * `zoneProvider` is D7's injected clock: a named zone in a temporal
805
926
  * spec (`{ "every": "P1M", "zone": "Europe/Amsterdam" }`) is host code
@@ -807,6 +928,20 @@ function resolveOperators(options) {
807
928
  * such a document (`JQ0003`) rather than answering it in UTC. It
808
929
  * reaches every residual compilation, which is where the calendar
809
930
  * ladder actually walks.
931
+ * The connection pragmas — `busyTimeout`, `journalMode`, `synchronous`,
932
+ * `walAutocheckpoint`, `journalSizeLimit`, `cacheSize`, `mmapSize`,
933
+ * `tempStore` — are a closed, validated set (`pragmas.js`): an option
934
+ * naming any other pragma is refused `JD0006`, one the driver or the
935
+ * store kind cannot apply `JD0007`, and every value is read back after
936
+ * the open sequence and reported on `capabilities.pragmas` — a value
937
+ * the engine did not take is `JD0008`, never a silent divergence.
938
+ * `runtime` is the host's runtime record (`@jarenjs/core/runtime`):
939
+ * the clock the capture log and the job queue stamp, the identifier
940
+ * a `uuid` identity and a `default: 'uuid'` allocate, the job queue's
941
+ * backoff jitter, and the zone provider — each read only where the
942
+ * store has no explicit option for it (`zoneProvider`, `jobs.now`,
943
+ * `jobs.random` win), and handed on to the job engine so a consumer
944
+ * configures it once.
810
945
  * @returns {Promise<any>}
811
946
  */
812
947
  export function openStore(model, options) {
@@ -817,6 +952,10 @@ export function openStore(model, options) {
817
952
  if (options.compileSchema !== undefined && typeof options.compileSchema !== 'function')
818
953
  throw new TypeError('openStore: compileSchema must be a function when present');
819
954
  const operators = resolveOperators(options);
955
+ const runtime = resolveRuntime(options.runtime);
956
+ // the explicit option wins over the record's member, and an explicit
957
+ // `null` is a deliberate "none" rather than a fall-through
958
+ const zoneProvider = options.zoneProvider !== undefined ? options.zoneProvider : runtime.zoneProvider;
820
959
 
821
960
  // API misuse (above) throws; a defective MODEL rejects, per the
822
961
  // asynchronous contract
@@ -824,9 +963,12 @@ export function openStore(model, options) {
824
963
  let entities;
825
964
  let mapping;
826
965
  try {
827
- collections = normalizeModel(model);
966
+ collections = normalizeModel(model, options.expressions);
828
967
  entities = normalizeEntities(model);
829
968
  mapping = entities.size > 0 ? explainMapping(model) : null;
969
+ if (options.replication !== undefined && [...collections.keys(), ...entities.keys()]
970
+ .some((name) => name.toLowerCase().startsWith('_jaren_replica')))
971
+ throw new DbCompileError('JD0060', 'replication reserves table names beginning with _jaren_replica');
830
972
  if (collections.size === 0 && entities.size === 0) {
831
973
  throw modelError('JD0005',
832
974
  'the model must declare at least one collection or entity', '');
@@ -836,17 +978,53 @@ export function openStore(model, options) {
836
978
  return Promise.reject(error);
837
979
  }
838
980
  const path = options.path ?? ':memory:';
839
- const busyTimeout = options.busyTimeout ?? 5000;
840
- const journalMode = options.journalMode ?? 'wal';
841
981
  const memory = path === ':memory:' || path === '';
842
982
  const readOnly = options.readOnly === true;
983
+ // the connection pragmas are a closed, validated set: a pragma outside
984
+ // it is JD0006, one this store kind cannot take is JD0007, and a bad
985
+ // value is API misuse — all settled before the driver opens, so a
986
+ // refused configuration never acquires a handle
987
+ let pragmaRequests;
988
+ try {
989
+ refuseUnsupportedPragmaKeys(options);
990
+ pragmaRequests = resolvePragmaRequests(options, { memory, readOnly });
991
+ }
992
+ catch (error) {
993
+ return Promise.reject(error);
994
+ }
995
+ const busyTimeout = /** @type {number} */ (pragmaRequests.get('busyTimeout')?.value);
843
996
  const storeProfile = options.profile === undefined
844
997
  ? null
845
998
  : normalizeProfile(options.profile);
999
+ // every root a profile's member allow-list may name — the model's own
1000
+ // collections and entities — so a policy that names something the
1001
+ // model does not declare is refused rather than applied to nothing
1002
+ const declaredRoots = Object.freeze([...collections.keys(), ...entities.keys()]);
1003
+ try {
1004
+ assertProfileRoots(storeProfile, declaredRoots);
1005
+ }
1006
+ catch (error) {
1007
+ return Promise.reject(error);
1008
+ }
1009
+
1010
+ /** A driver failure at or after `driver.open` as the open's own
1011
+ * refusal: `JD0002`, with the classifier's `class`/`retryable` and the
1012
+ * driver's error as `cause`.
1013
+ * @param {any} failure */
1014
+ const openFailure = (failure) => {
1015
+ const classified = classifyDriverError(failure);
1016
+ const wrapped = new DbCompileError('JD0002',
1017
+ `the store could not be opened (${classified.reason}): ${failure?.message ?? String(failure)}`,
1018
+ undefined, failure);
1019
+ wrapped.class = classified.class;
1020
+ wrapped.retryable = classified.retryable;
1021
+ return wrapped;
1022
+ };
846
1023
 
847
1024
  return toPromise(chain(
848
- options.driver.open(path,
1025
+ attempt(() => options.driver.open(path,
849
1026
  { timeout: busyTimeout, readOnly, queueTimeout: options.queueTimeout }),
1027
+ (failure) => (isDriverError(failure) ? openFailure(failure) : failure)),
850
1028
  (opened) => {
851
1029
  /**
852
1030
  * The transaction SCOPE that currently owns the driver connection,
@@ -857,12 +1035,68 @@ export function openStore(model, options) {
857
1035
  */
858
1036
  let scope = null;
859
1037
 
1038
+ /**
1039
+ * The IDENTITY of the scope that is current right now, or `null`.
1040
+ * One fresh identity per `withScope` invocation: it is what every
1041
+ * transaction view is pinned to (§5.1's exact-scope rule), and the
1042
+ * comparison `currentScope === identity` is the whole lifetime
1043
+ * check — a view whose identity is not current has either settled
1044
+ * or been crossed by an inner scope, and refuses `JD2070` before
1045
+ * reading tracker state or issuing a statement. It must never fall
1046
+ * through to the root and never follow a newer scope.
1047
+ * @type {any}
1048
+ */
1049
+ let currentScope = null;
1050
+
1051
+ /**
1052
+ * What the OPEN transaction owes its in-memory callers once the
1053
+ * database has agreed, in registration order, or `null` when no
1054
+ * transaction is open. One list, owned by the outermost scope: a
1055
+ * nested savepoint remembers only where it started, so rolling it
1056
+ * back takes back exactly what it registered and releasing it hands
1057
+ * those effects to the scope that outlives it.
1058
+ *
1059
+ * It exists because an in-memory claim about what the database
1060
+ * holds may not become true before the database does — the unit of
1061
+ * work's snapshots are such a claim, and a savepoint release is not
1062
+ * a commit.
1063
+ * @type {{ commit: () => void, rollback: () => void }[] | null}
1064
+ */
1065
+ let settlements = null;
1066
+
1067
+ /**
1068
+ * The unit of work the OPEN scope writes through, and the store's
1069
+ * own. A nested savepoint inherits whatever is in force — it is the
1070
+ * same unit of work one level down — while a transaction asked for
1071
+ * `unitOfWork: 'own'` gets a fresh one for its callback's lifetime.
1072
+ * Both are set once the model's cores exist.
1073
+ * @type {any}
1074
+ */
1075
+ let work = null;
1076
+ /** @type {any} */
1077
+ let rootWork = null;
1078
+
1079
+ /**
1080
+ * Run what the open transaction owes on its COMMIT, in registration
1081
+ * order, and empty the list. Idempotent, and safe to re-enter: each
1082
+ * effect is taken off the list before it runs.
1083
+ */
1084
+ const flushSettlements = () => {
1085
+ if (settlements === null) return;
1086
+ for (const effect of settlements.splice(0)) effect.commit?.();
1087
+ };
1088
+
860
1089
  /**
861
1090
  * 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.
1091
+ * is open, the driver connection otherwise. One indirection, so a
1092
+ * core issues its statements wherever the caller that reached it is
1093
+ * — and it is why work inside a transaction callback runs
1094
+ * immediately as the owner rather than waiting for a commit it is
1095
+ * part of.
1096
+ *
1097
+ * It is NOT what separates a store-level caller from the
1098
+ * transaction: that is the gate below, which the store's own
1099
+ * handles take and a scope-bound handle does not.
866
1100
  */
867
1101
  const connection = Object.freeze({
868
1102
  get synchronous() { return opened.synchronous; },
@@ -871,51 +1105,113 @@ export function openStore(model, options) {
871
1105
  /** @param {string} sql */
872
1106
  exec: (sql) => (scope ?? opened).exec(sql),
873
1107
  /** @param {string} sql */
874
- prepare: (sql) => (scope ?? opened).prepare(sql),
1108
+ prepare: (sql, metadata) => (scope ?? opened).prepare(sql, metadata),
875
1109
  /** Internal transaction users (jobs, checkpoints, migrations)
876
1110
  * nest when a transaction is open and take the gate when not. */
877
1111
  transaction: (fn) => withScope((scope ?? opened).transaction, fn),
1112
+ /**
1113
+ * Register what settling the OPEN transaction owes an in-memory
1114
+ * caller: `commit` when it commits, `rollback` when it rolls back,
1115
+ * either half optional. With nothing open the statements are
1116
+ * already durable, so `commit` runs at once and the rollback is
1117
+ * discarded. The effects are bookkeeping — they run after the last
1118
+ * statement of their scope and must issue none.
1119
+ * @param {{ commit?: () => void, rollback?: () => void }} effects
1120
+ */
1121
+ onSettle: (effects) => {
1122
+ if (settlements === null) effects.commit?.();
1123
+ else settlements.push(effects);
1124
+ },
878
1125
  registerFunction: opened.registerFunction === null ? null
879
1126
  : (/** @type {string} */ name, /** @type {any} */ o, /** @type {Function} */ fn) =>
880
1127
  opened.registerFunction(name, o, fn),
1128
+ registerAggregate: opened.registerAggregate === null ? null
1129
+ : (/** @type {string} */ name, /** @type {any} */ spec) =>
1130
+ opened.registerAggregate(name, spec),
881
1131
  session: opened.session === null ? null
882
1132
  : (/** @type {any} */ table) => opened.session(table),
1133
+ // the online-backup primitives, when the binding has them
1134
+ backup: opened.backup ?? null,
883
1135
  close: () => opened.close(),
884
1136
  });
885
1137
 
886
1138
  /**
887
1139
  * Run `fn` as a transaction opened by `open`, with `scope` bound to
888
- * it for the callback's whole lifetime and restored afterwards.
1140
+ * it for the callback's whole lifetime and restored afterwards. The
1141
+ * scope also settles what was registered against it: commits in
1142
+ * registration order when it keeps, rollbacks in reverse when it
1143
+ * does not, and only its own when it is an inner savepoint.
1144
+ *
1145
+ * `fn` receives the driver scope and this invocation's fresh
1146
+ * IDENTITY. A user-facing caller builds the transaction view from
1147
+ * the pair; internal nesting (a save's own transaction, a capture
1148
+ * scope, a membership attach) ignores both and runs through the
1149
+ * dynamic connection, which after the view's `JD2070` check is
1150
+ * exactly its own scope.
889
1151
  * @param {(inner: (s: any) => any) => any} open - the driver's
890
1152
  * `transaction`, gated (top level) or nesting (inner)
891
- * @param {(store: any) => any} fn
1153
+ * @param {(inner: any, identity: any) => any} fn
1154
+ * @param {any} [ownWork] - a unit of work for this scope alone;
1155
+ * without one the scope writes through whatever is already in
1156
+ * force, which is what makes an inner savepoint part of the same
1157
+ * unit of work as the transaction around it
892
1158
  */
893
- function withScope(open, fn) {
1159
+ function withScope(open, fn, ownWork) {
894
1160
  return open((inner) => {
895
1161
  const outer = scope;
1162
+ const outerWork = work;
1163
+ const outerIdentity = currentScope;
1164
+ const outermost = settlements === null;
1165
+ if (outermost) settlements = [];
1166
+ const list = /** @type {any[]} */ (settlements);
1167
+ // where this scope's own effects begin: a rollback takes back
1168
+ // from here, and everything before it belongs to a scope that
1169
+ // is still open
1170
+ const mark = list.length;
1171
+ const identity = {};
896
1172
  scope = inner;
897
- const restore = () => { scope = outer; };
1173
+ currentScope = identity;
1174
+ if (ownWork !== undefined) work = ownWork;
1175
+ const kept = () => {
1176
+ if (outermost) flushSettlements();
1177
+ };
1178
+ const undone = () => {
1179
+ const mine = list.splice(mark);
1180
+ for (let i = mine.length - 1; i >= 0; i--) mine[i].rollback?.();
1181
+ };
1182
+ const restore = (settled) => {
1183
+ if (settled) kept();
1184
+ else undone();
1185
+ scope = outer;
1186
+ work = outerWork;
1187
+ currentScope = outerIdentity;
1188
+ if (outermost) settlements = null;
1189
+ };
898
1190
  let out;
899
1191
  try {
900
- out = fn(scopedStore());
1192
+ out = fn(inner, identity);
901
1193
  }
902
1194
  catch (error) {
903
- restore();
1195
+ restore(false);
904
1196
  throw error;
905
1197
  }
906
1198
  if (!isThenable(out)) {
907
- restore();
1199
+ restore(true);
908
1200
  return out;
909
1201
  }
910
1202
  return out.then(
911
- (value) => { restore(); return value; },
912
- (error) => { restore(); throw error; });
1203
+ (value) => { restore(true); return value; },
1204
+ (error) => { restore(false); throw error; });
913
1205
  });
914
1206
  }
915
1207
 
916
- /** Set once the store object exists; the transaction callback's
917
- * argument, whose `transaction` NESTS instead of queueing. */
918
- let scopedStore = () => undefined;
1208
+ /** Set once the store object exists: builds the transaction
1209
+ * callback's argument for ONE exact scope a fresh view per
1210
+ * `withScope` invocation, pinned to its identity, whose
1211
+ * `transaction` NESTS instead of queueing. Assigned before any
1212
+ * user-facing transaction can run, so no placeholder is needed.
1213
+ * @type {(driverScope: any, identity: any) => any} */
1214
+ let scopedStore;
919
1215
 
920
1216
  /**
921
1217
  * A TOP-LEVEL store transaction. It takes the connection's gate
@@ -924,8 +1220,75 @@ export function openStore(model, options) {
924
1220
  * — a rollback then undoes the translated patch together with the
925
1221
  * rows it describes.
926
1222
  * @param {(store: any) => any} fn
1223
+ * @param {AbortSignal} [signal]
1224
+ * @param {any} [ownWork]
1225
+ */
1226
+ // a driver failure of the transaction itself — a `BEGIN IMMEDIATE`
1227
+ // that outwaits the busy timeout — is classified like a statement's
1228
+ // (`wrapDriverError` passes a callback's own error through untouched)
1229
+ const beginTransaction = (inner, signal, mode) =>
1230
+ attempt(() => opened.transaction(inner, signal, mode),
1231
+ (error) => wrapDriverError(error, { docPath: '/transaction' }));
1232
+ let topLevelTransaction = (fn, signal, ownWork, mode) =>
1233
+ withScope((inner) => beginTransaction(inner, signal, mode),
1234
+ (inner, identity) => fn(scopedStore(inner, identity)), ownWork);
1235
+
1236
+ /**
1237
+ * How a store-level call behaves when another caller's transaction
1238
+ * owns the connection: `'wait'` queues behind it under the
1239
+ * connection's `queueTimeout`, `'strict'` refuses at once. A host
1240
+ * that would rather see the contention than pay for it asks for
1241
+ * strict; the default keeps a contended call correct instead of
1242
+ * fast.
1243
+ */
1244
+ const strictTransactions = options.transactions === 'strict';
1245
+ if (options.transactions !== undefined && options.transactions !== 'wait'
1246
+ && options.transactions !== 'strict') {
1247
+ return Promise.reject(new TypeError(
1248
+ "openStore: transactions must be 'wait' or 'strict'"));
1249
+ }
1250
+
1251
+ /** The refusal a contended store-level call gets when it cannot
1252
+ * wait. It names the scope-bound spelling, because a caller that
1253
+ * meant to be inside the transaction has one and a caller that did
1254
+ * not has to wait for the commit either way. */
1255
+ const contended = (why) => new DbCompileError('JD0012',
1256
+ `a transaction owns this store's connection and ${why}. Work that belongs `
1257
+ + 'INSIDE the transaction goes through the store the callback received '
1258
+ + '(tx.collection / tx.entity / tx.saveChanges); work that does not belongs '
1259
+ + 'after it commits.');
1260
+
1261
+ /**
1262
+ * Run one STORE-LEVEL call: a caller that is not inside whatever
1263
+ * transaction is open. It holds the connection for its own extent,
1264
+ * so its statements can never fall inside a stranger's transaction
1265
+ * and share a rollback it knows nothing about — the defect that
1266
+ * made "one store per concurrent writer" the only safe advice.
1267
+ *
1268
+ * Root jobs and worker control I/O take exactly this gate too:
1269
+ * an unrelated enqueue, claim, renewal or settlement waits for the
1270
+ * open transaction instead of joining its fate, and a `signal`
1271
+ * (a worker winding down) abandons a call still in the queue.
1272
+ * @param {() => any} fn
1273
+ * @param {string} [what] - what is waiting, for the timeout message
1274
+ * @param {AbortSignal} [signal]
927
1275
  */
928
- let topLevelTransaction = (fn) => withScope(opened.transaction, fn);
1276
+ const gated = (fn, what, signal) => {
1277
+ if (strictTransactions && opened.mustQueue)
1278
+ throw contended("{ transactions: 'strict' } refuses to queue behind it");
1279
+ return withScope((inner) => opened.exclusively(inner, what, signal), fn);
1280
+ };
1281
+
1282
+ /** The synchronous surface's gate. It cannot wait — waiting hands
1283
+ * a Promise back under a value's type — so a contended call is a
1284
+ * refusal whatever the mode. */
1285
+ const gatedSync = (fn) => {
1286
+ if (opened.mustQueue) {
1287
+ throw contended('the synchronous surface answers values, so it cannot '
1288
+ + 'wait for the commit');
1289
+ }
1290
+ return withScope(opened.exclusively, fn);
1291
+ };
929
1292
 
930
1293
  // ————— the rejection boundary around an ACQUIRED connection —————
931
1294
  // Initialization continues for a long way past `driver.open`:
@@ -943,7 +1306,11 @@ export function openStore(model, options) {
943
1306
  * @param {any} error
944
1307
  * @returns {Promise<never>}
945
1308
  */
946
- const failClosed = (error) => {
1309
+ const failClosed = (failure) => {
1310
+ // a driver failure inside the open sequence (a locked or corrupt
1311
+ // file, an unopenable path) is the open's refusal, classed; a
1312
+ // coded refusal or API misuse is itself
1313
+ const error = isDriverError(failure) ? openFailure(failure) : failure;
947
1314
  if (closed) return Promise.reject(error);
948
1315
  closed = true;
949
1316
  /** @param {any} closeError */
@@ -967,8 +1334,9 @@ export function openStore(model, options) {
967
1334
  // property of the driver that created the file, so a database
968
1335
  // built under one and opened under the other legitimately reports
969
1336
  // drift — that is a migration, not an open.
970
- const derivedMapping = connection.capabilities.deterministicIndexableFunctions === true
971
- ? 'virtual' : 'stored';
1337
+ const registersFunctions =
1338
+ connection.capabilities.deterministicIndexableFunctions === true;
1339
+ const derivedMapping = registersFunctions ? 'virtual' : 'stored';
972
1340
  // the SECOND physical branch, and the same posture: a build
973
1341
  // without the R*Tree module maps `physical: 'rtree'` back onto
974
1342
  // the B-tree over the four columns and SAYS so through
@@ -988,7 +1356,8 @@ export function openStore(model, options) {
988
1356
  try {
989
1357
  for (const [name, collection] of collections)
990
1358
  plans.set(name, planCollection(name, collection, dialect,
991
- { derived: derivedMapping, rtree: rtreeCapable }));
1359
+ { derived: derivedMapping, rtree: rtreeCapable,
1360
+ expressions: options.expressions, registered: registersFunctions }));
992
1361
  if (mapping !== null) {
993
1362
  for (const name of Object.keys(mapping.entities))
994
1363
  entityPlans.set(name, planEntity(name, mapping.entities[name], mapping, dialect));
@@ -1009,17 +1378,34 @@ export function openStore(model, options) {
1009
1378
  const needsDeriveFunctions = derivedMapping === 'virtual'
1010
1379
  && [...plans.values()].some((plan) => plan.generated.some(
1011
1380
  (column) => column.derive !== undefined && column.stored !== true));
1381
+ // A table whose column expression calls a declared function cannot
1382
+ // be SELECTed from — let alone written to — by a connection that
1383
+ // has not registered it, so the registration precedes every
1384
+ // statement over it. Only where this connection COMPUTES the
1385
+ // expression: where the engine calls its own immutable function
1386
+ // there is nothing to register.
1387
+ const expressionNames = registersFunctions
1388
+ ? [...new Set([...plans.values()].flatMap((plan) =>
1389
+ plan.expressions.flatMap((entry) => entry.functions)))].sort()
1390
+ : [];
1012
1391
 
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)), () =>
1392
+ /** The effective connection pragmas, read back after the open
1393
+ * sequence applied them — what the capability report carries.
1394
+ * @type {any} */
1395
+ let effectivePragmas = null;
1396
+ // the closed configuration set, applied in table order and then
1397
+ // read back in full (JD0007 for a pragma this binding cannot
1398
+ // apply, JD0008 for one the engine did not take); then referential
1399
+ // integrity, which is real only when the pragma is ON — it
1400
+ // defaults off, so it is set AND verified per connection. Built
1401
+ // inside `opening` so a synchronous refusal reaches `failClosed`
1402
+ const pragmas = () => chain(configurePragmas(connection, pragmaRequests), (effective) => {
1403
+ effectivePragmas = effective;
1404
+ // an engine that always enforces referential integrity has no
1405
+ // switch to set and nothing to read back; SQLite's defaults OFF,
1406
+ // so there it is set AND verified per connection
1407
+ if (dialect.capabilities.foreignKeysAlwaysOn === true) return null;
1408
+ return chain(connection.exec(dialect.pragma.foreignKeys(true)), () =>
1023
1409
  chain(connection.prepare(dialect.introspect.foreignKeysOn()), (statement) =>
1024
1410
  chain(statement.get([]), (row) => {
1025
1411
  if (Number(row?.enabled) !== 1) {
@@ -1027,9 +1413,12 @@ export function openStore(model, options) {
1027
1413
  'this connection cannot enforce foreign keys (PRAGMA foreign_keys stayed off)');
1028
1414
  }
1029
1415
  return null;
1030
- }))));
1416
+ })));
1417
+ });
1031
1418
 
1032
- const opening = () => chain(pragmas, () =>
1419
+ const opening = () => chain(pragmas(), () =>
1420
+ chain(registerExpressionFunctions(connection, expressionNames,
1421
+ options.expressions ?? {}), () =>
1033
1422
  chain(needsDeriveFunctions ? registerDeriveFunctions(connection) : null, () =>
1034
1423
  chain(ensureShape(connection, collections, plans, readOnly), () =>
1035
1424
  chain(ensureEntityShape(connection, entityPlans, entities, readOnly), () => {
@@ -1051,20 +1440,33 @@ export function openStore(model, options) {
1051
1440
  throw new TypeError('openStore: compileSchema must return a validation function');
1052
1441
  core = captureCollection(name, collectionCore(connection, collection,
1053
1442
  plans.get(name), validate, queryState,
1054
- { profile: storeProfile }));
1443
+ { profile: storeProfile, roots: declaredRoots }, runtime));
1055
1444
  cores.set(name, core);
1056
1445
  }
1057
1446
  return core;
1058
1447
  };
1059
1448
 
1060
1449
  // ————— change capture (LIVE-FORMAT §§1–6) —————
1061
- const captureRequested = options.capture === true
1450
+ const captureOption = options.capture ?? (options.replication === undefined ? undefined : true);
1451
+ const captureRequested = captureOption === true
1062
1452
  ? {}
1063
- : (options.capture === undefined || options.capture === false
1064
- ? null : options.capture);
1453
+ : (captureOption === undefined || captureOption === false
1454
+ ? null : captureOption);
1455
+ if (options.replication !== undefined && (captureRequested === null || readOnly))
1456
+ throw new TypeError('replication requires a writable store with capture enabled');
1065
1457
  let captureMode = 'none';
1066
1458
  if (captureRequested !== null) {
1067
1459
  const wanted = captureRequested.mode ?? 'auto';
1460
+ // the ledger and its journal are SQLite spellings; a
1461
+ // connection that says it has no change capture is refused
1462
+ // by name rather than at the first statement over a table
1463
+ // this store would never have created there
1464
+ if (connection.capabilities.changeCapture !== true) {
1465
+ throw new DbCompileError('JD0051',
1466
+ 'change capture is unavailable on this driver: it declares no change '
1467
+ + 'ledger, so neither a changeset journal nor a live query can be built '
1468
+ + 'on it');
1469
+ }
1068
1470
  const hasSessions = connection.capabilities.sessions === true
1069
1471
  && typeof connection.session === 'function';
1070
1472
  if (wanted === 'session' && !hasSessions) {
@@ -1077,6 +1479,9 @@ export function openStore(model, options) {
1077
1479
  ? (hasSessions ? 'session' : 'journal')
1078
1480
  : wanted;
1079
1481
  }
1482
+ if (options.replication !== undefined && captureMode === 'journal'
1483
+ && Object.values(mapping?.entities ?? {}).some((entity) => entity.foreignKeys.some((fk) => fk.onDelete !== 'restrict')))
1484
+ throw new DbCompileError('JD0051', 'journal replication cannot capture cascading or set-null child relations; use session capture');
1080
1485
  const captureShapes = new Map();
1081
1486
  if (captureMode !== 'none') {
1082
1487
  for (const [collectionName, plan] of plans) {
@@ -1127,18 +1532,45 @@ export function openStore(model, options) {
1127
1532
  });
1128
1533
  }
1129
1534
  }
1535
+ // every first-open object — the change log and its state row
1536
+ // here, the job tables below — is created or verified under
1537
+ // the same immediate bracket as the collections' shape, so two
1538
+ // processes opening one fresh file cannot race the seed row or
1539
+ // a column upgrade; a read-only store creates nothing and takes
1540
+ // no lock
1541
+ const firstOpen = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
1542
+ let replicationEngine = null;
1130
1543
  const capture = captureMode === 'none' ? null : createCaptureEngine({
1131
1544
  connection,
1545
+ bracket: firstOpen,
1132
1546
  shapes: captureShapes,
1133
1547
  mode: captureMode,
1134
1548
  log: captureRequested.log === true
1135
1549
  || (captureRequested.log !== undefined && captureRequested.log !== false),
1136
1550
  retention: captureRequested.log?.retention ?? DEFAULT_RETENTION,
1551
+ now: runtime.now,
1552
+ beforeCommit: (patch, context) => replicationEngine?.commit(patch, context),
1137
1553
  });
1138
- const guard = capture === null ? (fn) => fn() : capture.wrap;
1554
+ // the capture scope around a write runs statements of its own
1555
+ // (a session's changeset read, the journal's old-row read, the
1556
+ // log's allocation); a driver failure there is classified as
1557
+ // the write's would be
1558
+ const guard = capture === null
1559
+ ? (fn) => fn()
1560
+ : (fn) => attempt(() => capture.wrap(fn),
1561
+ (error) => wrapDriverError(error, { docPath: '/capture' }));
1139
1562
  if (capture !== null) {
1140
- topLevelTransaction = (fn) => withScope(opened.transaction,
1141
- (tx) => capture.nest(() => fn(tx)));
1563
+ // capture changes how records are TRANSLATED, never queue
1564
+ // cancellation or tracker ownership: the replacement has the
1565
+ // ordinary function's exact signature and forwards `signal`
1566
+ // and `ownWork`, with `capture.nest` inside the opened scope.
1567
+ // The view is built from the scope capture's wrap opens —
1568
+ // the INNERMOST one, the exact scope the callback runs in.
1569
+ topLevelTransaction = (fn, signal, ownWork, mode) =>
1570
+ withScope((inner) => beginTransaction(inner, signal, mode),
1571
+ () => capture.nest((innerScope, identity) =>
1572
+ fn(scopedStore(innerScope, identity))),
1573
+ ownWork);
1142
1574
  }
1143
1575
  // the live registry rides the capture stream; its dispatcher
1144
1576
  // registers FIRST so maintenance sees every record before any
@@ -1146,6 +1578,7 @@ export function openStore(model, options) {
1146
1578
  const liveRegistry = capture === null ? null : createLiveRegistry({
1147
1579
  maxQueries: options.live?.maxQueries ?? LIVE_DEFAULTS.maxQueries,
1148
1580
  maxMaintained: options.live?.maxMaintained ?? LIVE_DEFAULTS.maxMaintained,
1581
+ maxBytes: options.live?.maxBytes ?? LIVE_DEFAULTS.maxBytes,
1149
1582
  });
1150
1583
  if (capture !== null) {
1151
1584
  capture.observe((record) => /** @type {any} */ (liveRegistry).deliver(record));
@@ -1153,11 +1586,24 @@ export function openStore(model, options) {
1153
1586
  // the durable job queue (JOBS-FORMAT), opt-in per store
1154
1587
  const jobsRequested = options.jobs === true
1155
1588
  || (options.jobs !== undefined && options.jobs !== false);
1589
+ if (jobsRequested && connection.capabilities.jobs !== true) {
1590
+ throw new DbCompileError('JD0003',
1591
+ 'the durable job queue is unavailable on this driver: it declares no job '
1592
+ + 'queue, and the queue writes its own SQLite statements');
1593
+ }
1156
1594
  const jobsEngine = !jobsRequested ? null : createJobEngine({
1157
1595
  connection,
1596
+ bracket: firstOpen,
1597
+ // the WORKER's control-plane I/O (claims, renewals, its
1598
+ // checkpoint stores, its settlements) is root-owned and takes
1599
+ // the store gate, so it can never join an open application
1600
+ // transaction; `tx.jobs` bypasses this by running as the
1601
+ // exact scope, which is the transactional-outbox spelling
1602
+ gate: (fn, what, signal) => gated(fn, what, signal),
1158
1603
  now: typeof options.jobs === 'object' ? options.jobs.now : undefined,
1159
1604
  random: typeof options.jobs === 'object' ? options.jobs.random : undefined,
1160
1605
  defaults: typeof options.jobs === 'object' ? options.jobs : undefined,
1606
+ runtime,
1161
1607
  });
1162
1608
  /** Register a collection live query (LIVE-FORMAT §7). */
1163
1609
  const refuseAsyncLive = () => {
@@ -1175,7 +1621,7 @@ export function openStore(model, options) {
1175
1621
  const classification = liveOptions?.mode === 'rerun'
1176
1622
  ? { strategy: 'rerun', reason: 'rerun was requested' }
1177
1623
  : classifyLiveQuery(document, core.queryShape, keyed, eventTime);
1178
- return /** @type {any} */ (liveRegistry).register({
1624
+ return closeOnRollback(/** @type {any} */ (liveRegistry).register({
1179
1625
  name: core.model.name,
1180
1626
  tables: new Set([core.model.name]),
1181
1627
  document,
@@ -1184,10 +1630,21 @@ export function openStore(model, options) {
1184
1630
  classification,
1185
1631
  execute: (doc, executeOptions) => core.execute(doc, executeOptions),
1186
1632
  readRow: (token) => core.get(token),
1633
+ rowPosition: (token) => createLogicalRows({ connection, shapes: captureShapes, capture,
1634
+ collectionCore: coreFor, entityCore: entityCoreFor }).position(core.model.name, token),
1187
1635
  keyOf: (doc) => String(extractKey(doc, core.model.keySegments,
1188
1636
  core.model.key, core.model.name, core.model.docPath)),
1189
- });
1637
+ }));
1190
1638
  };
1639
+ /** A live query registered INSIDE a transaction initialized from
1640
+ * that transaction's rows; if the transaction rolls back, those
1641
+ * rows never existed and the query is closed with them rather
1642
+ * than left maintaining a result nothing committed. Registered
1643
+ * at the root, the scope settles at once and nothing is owed. */
1644
+ const closeOnRollback = (registered) => chain(registered, (live) => {
1645
+ connection.onSettle({ rollback: () => live.close() });
1646
+ return live;
1647
+ });
1191
1648
  /** Strip relation members before journal diffs — sessions
1192
1649
  * never see them (they are not stored), so the two modes
1193
1650
  * stay identical. */
@@ -1217,13 +1674,24 @@ export function openStore(model, options) {
1217
1674
  const sql = `SELECT ${columns.map(dialect.quoteIdentifier).join(', ')} `
1218
1675
  + `FROM ${dialect.quoteIdentifier(joinName)} `
1219
1676
  + `WHERE ${dialect.quoteIdentifier(own.column)} = ${dialect.parameterRef(1, 'v')}`;
1220
- return chain(connection.prepare(sql), (statement) =>
1221
- chain(statement.all([keyParts[0]]), (rows) => {
1677
+ const boundedRows = () => {
1678
+ const { maxOperations = REPLICATION_DEFAULTS.maxOperations, maxBytes = REPLICATION_DEFAULTS.maxBytes } = options.replication;
1679
+ const cursor = createCursor({ streaming: 'row', barrier: null,
1680
+ open: () => chain(connection.prepare(`${sql} LIMIT ?`), (statement) => statement.iterate([keyParts[0], maxOperations + 1])),
1681
+ items: (row) => [row] });
1682
+ return chain(drainPage(cursor, { limit: maxOperations, maxBytes,
1683
+ sizeOf: (row) => utf8Length(JSON.stringify(row)), continuationOf: () => null }), (page) => {
1684
+ if (page.hasMore) throw new DbRuntimeError('JD2106', 'membership cascade exceeds replication capacity');
1685
+ return page.items;
1686
+ });
1687
+ };
1688
+ return chain(options.replication === undefined
1689
+ ? chain(connection.prepare(sql), (statement) => statement.all([keyParts[0]])) : boundedRows(), (rows) => {
1222
1690
  for (const row of rows) {
1223
1691
  capture.record(joinName, columns.map((column) => row[column]), undefined, null);
1224
1692
  }
1225
1693
  return nextJoin(i + 1);
1226
- }));
1694
+ });
1227
1695
  };
1228
1696
  return nextJoin(0);
1229
1697
  };
@@ -1310,11 +1778,42 @@ export function openStore(model, options) {
1310
1778
  };
1311
1779
  };
1312
1780
 
1781
+ // the maintenance operations over this connection; the store
1782
+ // gates each call below, exactly as a root write is gated
1783
+ const maintenance = createMaintenance({ connection, readOnly, now: runtime.now });
1784
+ // the online backup over the same connection: its checkpoint
1785
+ // boundary takes the gate, its copy runs off it
1786
+ const backup = createBackup({
1787
+ connection, readOnly, gated, checkpoint: maintenance.checkpoint, random: runtime.random,
1788
+ now: runtime.now,
1789
+ });
1790
+
1313
1791
  const capabilities = Object.freeze({
1314
1792
  ...connection.capabilities,
1793
+ // per-operation availability: the binding's declaration, and
1794
+ // for the two that write the store's read-only flag — `false`
1795
+ // exactly where a call is refused (`JD2077`)
1796
+ maintenance: Object.freeze({ ...maintenance.capabilities, backup: backup.capability }),
1797
+ // where a cancellation takes effect, per lifecycle — the
1798
+ // granularity the driver actually has. `midStatement` is a
1799
+ // filled slot, not an absent one: no shipped SQLite binding
1800
+ // exposes an interrupt, and a driver that grows one flips
1801
+ // exactly this member
1802
+ cancellation: Object.freeze({
1803
+ query: 'row', queue: true, migration: 'step', maintenance: 'statement',
1804
+ backup: 'page', midStatement: false,
1805
+ }),
1315
1806
  validated: options.compileSchema !== undefined,
1316
- busyTimeoutMs: memory ? null : busyTimeout,
1317
- journalMode: memory || readOnly ? null : journalMode,
1807
+ // the effective connection configuration, read back after the
1808
+ // open sequence applied it: every pragma of the closed set by
1809
+ // option name, `null` where the binding declares the pragma
1810
+ // absent or the engine answers nothing (a memory database's
1811
+ // `mmapSize`)
1812
+ pragmas: effectivePragmas,
1813
+ // the two long-published members, sourced from that same
1814
+ // read-back — never from the request
1815
+ busyTimeoutMs: effectivePragmas.busyTimeout,
1816
+ journalMode: effectivePragmas.journalMode,
1318
1817
  readOnly,
1319
1818
  profiled: storeProfile !== null,
1320
1819
  // the registered operator vocabulary (Ring 2): the names a
@@ -1333,7 +1832,7 @@ export function openStore(model, options) {
1333
1832
  // ZONE will compile at all here. Without one the document is
1334
1833
  // refused (`JQ0003`) rather than answered in UTC, and a
1335
1834
  // consumer that wants to know before it asks reads this
1336
- zoneProvider: options.zoneProvider !== undefined && options.zoneProvider !== null,
1835
+ zoneProvider: zoneProvider !== undefined && zoneProvider !== null,
1337
1836
  capture: captureMode,
1338
1837
  captureLog: captureMode !== 'none'
1339
1838
  && (captureRequested.log === true
@@ -1346,9 +1845,10 @@ export function openStore(model, options) {
1346
1845
  });
1347
1846
 
1348
1847
  const queryState = createQueryState(options.statementCacheBound, operators,
1349
- options.zoneProvider);
1848
+ zoneProvider, runtime.now);
1350
1849
  const entityEngine = entities.size > 0
1351
- ? createEntityQueryEngine({ connection, entities, mapping, state: queryState })
1850
+ ? createEntityQueryEngine({ connection, entities, mapping, state: queryState,
1851
+ profile: storeProfile, roots: declaredRoots })
1352
1852
  : null;
1353
1853
  /** @type {Map<string, any>} */
1354
1854
  const loadEngines = new Map();
@@ -1361,7 +1861,8 @@ export function openStore(model, options) {
1361
1861
  { docPath: '/entities', collection: name });
1362
1862
  }
1363
1863
  engine = createLoadEngine(
1364
- { connection, entities, mapping, state: queryState }, name);
1864
+ { connection, entities, mapping, state: queryState, coreFor: entityCoreFor,
1865
+ profile: storeProfile, roots: declaredRoots }, name);
1365
1866
  loadEngines.set(name, engine);
1366
1867
  }
1367
1868
  return engine;
@@ -1383,30 +1884,46 @@ export function openStore(model, options) {
1383
1884
  if (validate !== null && typeof validate !== 'function')
1384
1885
  throw new TypeError('openStore: compileSchema must return a validation function');
1385
1886
  core = captureEntity(name, entityCore(connection, entity,
1386
- mapping.entities[name], validate));
1887
+ mapping.entities[name], validate, runtime));
1387
1888
  entityCores.set(name, core);
1388
1889
  }
1389
1890
  return core;
1390
1891
  };
1391
1892
 
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) => {
1893
+ /**
1894
+ * ONE unit of work and the tracked operations that write through
1895
+ * it. The store has one; a transaction may be given its own, so
1896
+ * two concurrent handlers hold two records for the same entity
1897
+ * key and neither can see the other's pending state — which is
1898
+ * what makes one store safe for a handler per request.
1899
+ *
1900
+ * The cores, engines and plans below it are shared: what a
1901
+ * second unit of work costs is its own map of records, not a
1902
+ * second copy of the model.
1903
+ */
1904
+ const createUnitOfWork = () => {
1905
+ const tracker = entities.size > 0
1906
+ ? createTracker({
1907
+ connection, entities, mapping, coreFor: entityCoreFor,
1908
+ captureRecord: capture === null || capture.mode !== 'journal'
1909
+ ? undefined
1910
+ : (table, keyParts, before, after) => capture.record(table, keyParts,
1911
+ before === undefined ? undefined : stripRelations(table, before),
1912
+ stripRelations(table, after)),
1913
+ captureJoinDelete: captureJoinDelete ?? undefined,
1914
+ })
1915
+ : null;
1916
+ /** @type {Map<string, any>} */
1917
+ const trackedOps = new Map();
1918
+ /** @type {Map<string, any>} */
1919
+ const entityHandles = new Map();
1920
+ /** @type {Map<string, any>} */
1921
+ const syncEntityHandles = new Map();
1922
+ // the unit-of-work surface (§11): reads register frozen
1923
+ // snapshots; add/put/remove are LOCAL bookkeeping (no
1924
+ // database round trip, deliberately synchronous on both
1925
+ // surfaces); asNoTracking() reads retain nothing
1926
+ const trackedOpsFor = (name) => {
1410
1927
  let ops = trackedOps.get(name);
1411
1928
  if (ops !== undefined) return ops;
1412
1929
  const core = entityCoreFor(name);
@@ -1491,9 +2008,24 @@ export function openStore(model, options) {
1491
2008
  tracker.discard(name, key);
1492
2009
  return done;
1493
2010
  }),
1494
- load: (spec) => chain(loads.load(spec),
2011
+ load: (spec, loadOptions) => chain(loads.load(spec, loadOptions),
1495
2012
  (docs) => tracker.registerGraph(loads.treeFor(spec), docs)),
1496
- explainLoad: (spec) => loads.explainLoad(spec),
2013
+ // the graph cursor registers nothing unless asked: a
2014
+ // snapshot per yielded root is a tracker that grows with the
2015
+ // result, so it is the caller's decision (`tracking: true`)
2016
+ syncLoadCursor: (spec, cursorOptions) => loads.syncLoadCursor(spec, cursorOptions,
2017
+ cursorOptions?.tracking === true
2018
+ ? (tree, doc) => tracker.registerGraph(tree, [doc])[0] : undefined),
2019
+ syncPage: (spec, pageOptions) => loads.syncPage(spec, pageOptions,
2020
+ pageOptions?.tracking === true
2021
+ ? (tree, doc) => tracker.registerGraph(tree, [doc])[0] : undefined),
2022
+ loadCursor: (spec, cursorOptions) => loads.loadCursor(spec, cursorOptions,
2023
+ cursorOptions?.tracking === true
2024
+ ? (tree, doc) => tracker.registerGraph(tree, [doc])[0] : undefined),
2025
+ page: (spec, pageOptions) => loads.page(spec, pageOptions,
2026
+ pageOptions?.tracking === true
2027
+ ? (tree, doc) => tracker.registerGraph(tree, [doc])[0] : undefined),
2028
+ explainLoad: (spec, loadOptions) => loads.explainLoad(spec, loadOptions),
1497
2029
  add: (doc) => tracker.add(name, doc),
1498
2030
  put: (next) => tracker.put(name, next),
1499
2031
  remove: (keyOrDoc) => tracker.remove(name, keyOrDoc),
@@ -1504,49 +2036,35 @@ export function openStore(model, options) {
1504
2036
  unlink: (own, member, target) => tracker.unlink(name, own, member, target),
1505
2037
  noTracking: {
1506
2038
  get: (key) => core.get(key),
1507
- load: (spec) => loads.load(spec),
2039
+ load: (spec, loadOptions) => loads.load(spec, loadOptions),
1508
2040
  },
1509
2041
  };
1510
2042
  trackedOps.set(name, ops);
1511
2043
  return ops;
1512
- };
2044
+ };
1513
2045
 
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);
2046
+ /**
2047
+ * An entity handle over THIS unit of work, bound to whatever
2048
+ * scope is open when it runs — the tracked surface, its
2049
+ * untracked twin, and the provider members.
2050
+ * @param {string} name
2051
+ */
2052
+ const entityFor = (name) => {
2053
+ let handle = entityHandles.get(name);
1538
2054
  if (handle === undefined) {
1539
2055
  const ops = trackedOpsFor(name);
1540
2056
  const untracked = Object.freeze({
1541
2057
  get: lift((key) => ops.noTracking.get(key)),
1542
- load: lift((spec) => ops.noTracking.load(spec)),
2058
+ load: lift((spec, loadOptions) => ops.noTracking.load(spec, loadOptions)),
1543
2059
  });
1544
2060
  handle = Object.freeze({
1545
2061
  create: lift((doc) => ops.create(doc)),
1546
2062
  get: lift((key) => ops.get(key)),
1547
2063
  update: lift((key, changes) => ops.update(key, changes)),
1548
2064
  delete: lift((key) => ops.delete(key)),
1549
- load: lift((spec) => ops.load(spec)),
2065
+ load: lift((spec, loadOptions) => ops.load(spec, loadOptions)),
2066
+ loadCursor: (spec, cursorOptions) => ops.loadCursor(spec, cursorOptions),
2067
+ page: lift((spec, pageOptions) => ops.page(spec, pageOptions)),
1550
2068
  explainLoad: ops.explainLoad,
1551
2069
  add: ops.add,
1552
2070
  put: ops.put,
@@ -1564,67 +2082,303 @@ export function openStore(model, options) {
1564
2082
  // `relations` this entity's own relation table (§10.1).
1565
2083
  // `execute` stays value-or-promise (D2), as a collection's
1566
2084
  execute: (document, queryOptions) => entityEngine.execute(document, queryOptions),
2085
+ // the item cursor over the same document: one row per
2086
+ // pull, the statement released on break. It registers
2087
+ // NO snapshot by default — a cursor that tracked every
2088
+ // row it yielded would be an unbounded tracker — and
2089
+ // `tracking: true` opts in per call, documented as
2090
+ // unbounded in the result size
2091
+ cursor: (document, queryOptions) => entityEngine.query(document, queryOptions,
2092
+ queryOptions?.tracking === true
2093
+ ? (entity, doc) => tracker.register(entity, doc) : undefined),
1567
2094
  explain: lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
1568
2095
  root: entityRoot(name),
1569
2096
  scope: entityEngine,
1570
2097
  relations: entityEngine.relations[name],
1571
2098
  });
1572
- asyncEntityHandles.set(name, handle);
2099
+ entityHandles.set(name, handle);
2100
+ }
2101
+ return handle;
2102
+ };
2103
+
2104
+ /** The same set, answering values. */
2105
+ const syncEntityFor = (name) => {
2106
+ let handle = syncEntityHandles.get(name);
2107
+ if (handle === undefined) {
2108
+ const ops = trackedOpsFor(name);
2109
+ const untracked = Object.freeze({
2110
+ get: (key) => ops.noTracking.get(key),
2111
+ load: (spec, loadOptions) => ops.noTracking.load(spec, loadOptions),
2112
+ });
2113
+ handle = Object.freeze({
2114
+ create: (doc) => ops.create(doc),
2115
+ get: (key) => ops.get(key),
2116
+ update: (key, changes) => ops.update(key, changes),
2117
+ delete: (key) => ops.delete(key),
2118
+ load: (spec, loadOptions) => ops.load(spec, loadOptions),
2119
+ loadCursor: (spec, cursorOptions) => ops.syncLoadCursor(spec, cursorOptions),
2120
+ page: (spec, pageOptions) => ops.syncPage(spec, pageOptions),
2121
+ cursor: (document, queryOptions) => entityEngine.syncQuery(document, queryOptions,
2122
+ queryOptions?.tracking === true
2123
+ ? (entity, doc) => tracker.register(entity, doc) : undefined),
2124
+ explainLoad: ops.explainLoad,
2125
+ add: ops.add,
2126
+ put: ops.put,
2127
+ remove: ops.remove,
2128
+ discard: ops.discard,
2129
+ link: ops.link,
2130
+ unlink: ops.unlink,
2131
+ asNoTracking: () => untracked,
2132
+ // the same provider members as the asynchronous handle,
2133
+ // answering values; one handle per name, so two chains
2134
+ // over one set share one source identity
2135
+ execute: (document, queryOptions) => entityEngine.execute(document, queryOptions),
2136
+ explain: (document, queryOptions) => entityEngine.explain(document, queryOptions),
2137
+ root: entityRoot(name),
2138
+ scope: entityEngine,
2139
+ relations: entityEngine.relations[name],
2140
+ });
2141
+ syncEntityHandles.set(name, handle);
2142
+ }
2143
+ return handle;
2144
+ };
2145
+
2146
+ return Object.freeze({ tracker, entityFor, syncEntityFor });
2147
+ };
2148
+
2149
+ // the store's own unit of work: what a store-level handle and a
2150
+ // transaction that did not ask for its own both write through
2151
+ rootWork = createUnitOfWork();
2152
+ work = rootWork;
2153
+
2154
+ /** @type {Map<string, any>} */
2155
+ const asyncHandles = new Map();
2156
+
2157
+ /**
2158
+ * A collection handle BOUND to whatever scope is open when it
2159
+ * runs. It is what a transaction callback gets, and what the
2160
+ * store-level handle wraps in the gate. Collections carry no
2161
+ * unit of work, so one handle per name serves every scope.
2162
+ * @param {string} name
2163
+ */
2164
+ function boundCollection(name) {
2165
+ let handle = asyncHandles.get(name);
2166
+ if (handle === undefined) {
2167
+ handle = asyncCollection(coreFor(name),
2168
+ liveRegistry === null ? null : registerCollectionLive);
2169
+ asyncHandles.set(name, handle);
2170
+ }
2171
+ return handle;
2172
+ }
2173
+
2174
+ /** The engine's own commit counter, read wherever the caller
2175
+ * is: the store wraps it in the gate, a transaction view in
2176
+ * its scope check. An engine that keeps no such counter — one
2177
+ * where "another connection has written since you last looked"
2178
+ * is not a question a single number answers — refuses by name
2179
+ * rather than by a TypeError on a statement it cannot spell. */
2180
+ const readDataVersion = () => {
2181
+ if (typeof dialect.introspect.dataVersion !== 'function') {
2182
+ throw new DbRuntimeError('JD2077',
2183
+ 'this store has no data version: the dialect keeps no commit counter, so '
2184
+ + 'there is no single number that changes when another connection writes');
2185
+ }
2186
+ return chain(connection.prepare(dialect.introspect.dataVersion()),
2187
+ (statement) => chain(statement.get([]), (row) => Number(row.v)));
2188
+ };
2189
+
2190
+ /** Register an entity-root live query (LIVE-FORMAT §7) — the
2191
+ * store's `live` and a transaction view's share one body. */
2192
+ const registerEntityLive = (document, liveOptions) => {
2193
+ if (liveRegistry === null) {
2194
+ throw new DbCompileError('JD0050',
2195
+ 'live queries require change capture — open the store with { capture: true }');
2196
+ }
2197
+ refuseAsyncLive();
2198
+ if (liveOptions?.eventTime !== undefined) {
2199
+ throw new DbCompileError('JD0053',
2200
+ 'live eventTime maintains a collection view — an entity document re-runs, '
2201
+ + 'so a watermark would describe nothing (LIVE-FORMAT §13)');
2202
+ }
2203
+ const roots = collectEntityRoots(document, new Map([...entities, ...joinTableRoots(entities, mapping).entities]));
2204
+ if (roots.size === 0) {
2205
+ throw new TypeError(
2206
+ 'store.live takes an entity-root document — for a collection, '
2207
+ + 'use store.collection(name).live');
2208
+ }
2209
+ const logicalRows = createLogicalRows({ connection, shapes: captureShapes, capture,
2210
+ collectionCore: coreFor, entityCore: entityCoreFor });
2211
+ return closeOnRollback(liveRegistry.register({
2212
+ name: [...roots].join('+'),
2213
+ tables: roots,
2214
+ document,
2215
+ externals: liveOptions?.externals ?? {},
2216
+ demanded: liveOptions?.mode,
2217
+ classification: liveOptions?.mode === 'rerun' ? {
2218
+ strategy: 'rerun',
2219
+ reason: 're-run mode was explicitly requested',
2220
+ } : classifyEntityLive(document, entities, mapping, operators),
2221
+ execute: (doc, executeOptions) => entityEngine.execute(doc, executeOptions),
2222
+ readDependency: logicalRows.read,
2223
+ dependencyPosition: logicalRows.position,
2224
+ readRow: null,
2225
+ keyOf: null,
2226
+ }));
2227
+ };
2228
+
2229
+ /**
2230
+ * Every member of a bound handle that issues a statement,
2231
+ * wrapped in the store-level gate. The rest — local unit-of-work
2232
+ * bookkeeping, cached stats, the provider's identity members —
2233
+ * touches no connection and is passed through as it is.
2234
+ *
2235
+ * `valued` names the members that answer value-or-promise
2236
+ * rather than always a promise: the provider contract keeps a
2237
+ * chain over a synchronous driver synchronous, so those must
2238
+ * not be lifted (D2). They still answer a promise while another
2239
+ * caller's transaction holds the connection — which is what
2240
+ * waiting for a commit means.
2241
+ * @param {any} handle
2242
+ * @param {string[]} names - members that answer a promise
2243
+ * @param {string[]} [valued] - members that answer value-or-promise
2244
+ */
2245
+ const gatedMembers = (handle, names, valued = []) => {
2246
+ const out = { ...handle };
2247
+ for (const member of names) {
2248
+ if (typeof handle[member] !== 'function') continue;
2249
+ out[member] = (/** @type {any[]} */ ...args) =>
2250
+ lift(() => gated(() => handle[member](...args)))();
2251
+ }
2252
+ for (const member of valued) {
2253
+ if (typeof handle[member] !== 'function') continue;
2254
+ out[member] = (/** @type {any[]} */ ...args) =>
2255
+ gated(() => handle[member](...args));
2256
+ }
2257
+ return Object.freeze(out);
2258
+ };
2259
+
2260
+ /** @type {Map<string, any>} */
2261
+ const gatedCollections = new Map();
2262
+ /** @type {Map<string, any>} */
2263
+ const gatedEntities = new Map();
2264
+
2265
+ const store = {
2266
+ capabilities,
2267
+ // the ROOT's bookkeeping, always: an open own-unit
2268
+ // transaction changes what its own view reports, never this
2269
+ stats: () => ({
2270
+ statementCache: { ...queryState.counters },
2271
+ udfRegistrations: queryState.registered.size,
2272
+ tracker: rootWork.tracker === null ? null : rootWork.tracker.counts(),
2273
+ liveQueries: liveRegistry === null ? 0 : liveRegistry.count(),
2274
+ }),
2275
+ dialect,
2276
+ // A STORE-LEVEL handle. It reaches the driver connection, never
2277
+ // a transaction it is not part of: a caller here is unrelated
2278
+ // to whatever is open, so its statements wait for the commit
2279
+ // instead of joining a rollback it knows nothing about. Inside
2280
+ // a transaction callback, use the store the callback received.
2281
+ collection(name) {
2282
+ let handle = gatedCollections.get(name);
2283
+ if (handle === undefined) {
2284
+ const inner = boundCollection(name);
2285
+ // `query` borrows the gate PER PULL rather than for the
2286
+ // cursor's life: holding it for the caller's whole loop
2287
+ // would block every transaction for as long as a consumer
2288
+ // reads slowly, while an ungated pull could read a row a
2289
+ // stranger's transaction has not committed. Construction
2290
+ // (preflight, compilation) touches no connection.
2291
+ handle = Object.freeze({
2292
+ ...gatedMembers(inner,
2293
+ ['get', 'insert', 'put', 'patch', 'delete', 'explain', 'live'],
2294
+ ['execute']),
2295
+ query: (document, queryOptions) => admitCursor(inner.query(document, queryOptions),
2296
+ gated, queryOptions?.signal, 'a root collection cursor pull'),
2297
+ });
2298
+ gatedCollections.set(name, handle);
2299
+ }
2300
+ return handle;
2301
+ },
2302
+ entity(name) {
2303
+ let handle = gatedEntities.get(name);
2304
+ if (handle === undefined) {
2305
+ // ALWAYS the root unit of work: a store-level handle
2306
+ // constructed while an own-unit transaction happens to be
2307
+ // open must not capture that transaction's tracker
2308
+ const inner = rootWork.entityFor(name);
2309
+ // `cursor` and `loadCursor` borrow the gate per pull, as a
2310
+ // collection's `query` does: admitted one item at a time,
2311
+ // never held across the caller's loop
2312
+ handle = gatedMembers(inner,
2313
+ ['create', 'get', 'update', 'delete', 'load', 'page', 'explain'],
2314
+ ['execute']);
2315
+ const untracked = gatedMembers(inner.asNoTracking(), ['get', 'load']);
2316
+ handle = Object.freeze({
2317
+ ...handle,
2318
+ cursor: (document, queryOptions) => admitCursor(inner.cursor(document, queryOptions),
2319
+ gated, queryOptions?.signal, 'a root entity cursor pull'),
2320
+ loadCursor: (spec, cursorOptions) => admitCursor(inner.loadCursor(spec, cursorOptions),
2321
+ gated, cursorOptions?.signal, 'a root graph cursor pull'),
2322
+ asNoTracking: () => untracked,
2323
+ });
2324
+ gatedEntities.set(name, handle);
1573
2325
  }
1574
2326
  return handle;
1575
2327
  },
1576
2328
  saveChanges: entities.size === 0 ? undefined
1577
- : lift(() => guard(() => tracker.saveChanges())),
2329
+ : lift(() => gated(() => guard(() => rootWork.tracker.saveChanges()))),
1578
2330
  // entity DOCUMENTS query the multi-entity root at the store;
1579
2331
  // `roots` names the entity arrays this provider serves, so a
1580
2332
  // chain asked to iterate the store itself can refuse by name
1581
2333
  execute: entityEngine === null ? undefined
1582
- : (document, queryOptions) => entityEngine.execute(document, queryOptions),
2334
+ : (document, queryOptions) =>
2335
+ gated(() => entityEngine.execute(document, queryOptions)),
1583
2336
  explain: entityEngine === null ? undefined
1584
- : lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
2337
+ : lift((document, queryOptions) =>
2338
+ gated(() => entityEngine.explain(document, queryOptions))),
1585
2339
  roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
1586
2340
  relations: entityEngine === null ? undefined : entityEngine.relations,
1587
2341
  // entity live queries re-run on invalidation — declared,
1588
- // not attempted (LIVE-FORMAT §7)
2342
+ // not attempted (LIVE-FORMAT §7). Registration takes the
2343
+ // store gate through its INITIAL query, like a collection's
2344
+ // `live`: the registration is local, the first result is a
2345
+ // statement, and a statement here must not read a row a
2346
+ // stranger's transaction has not committed. The live handle
2347
+ // then runs on committed writes alone.
1589
2348
  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
- }),
2349
+ : lift((document, liveOptions) =>
2350
+ gated(() => registerEntityLive(document, liveOptions), 'a root live registration')),
1622
2351
  // A TOP-LEVEL transaction: it takes the connection's gate, so
1623
2352
  // it never shares a savepoint stack with another one. To nest,
1624
2353
  // use the store the callback RECEIVES — the outer store cannot
1625
2354
  // tell an inner transaction from an unrelated caller, and an
1626
- // unrelated caller must wait for the commit.
1627
- transaction: lift((fn) => topLevelTransaction(fn)),
2355
+ // unrelated caller must wait for the commit. A `signal` gives
2356
+ // up the QUEUE, never a transaction already running.
2357
+ //
2358
+ // `unitOfWork: 'own'` gives the callback a tracker of its own,
2359
+ // so two concurrent handlers hold two records for one entity
2360
+ // key and neither sees the other's pending state. It is opt-in
2361
+ // because the shared default is what lets a caller add() a
2362
+ // document outside the transaction and save it inside.
2363
+ // `mode: 'immediate'` takes the write lock up front (`BEGIN
2364
+ // IMMEDIATE`): a body that reads before it writes never meets
2365
+ // the read→write upgrade busy the handler cannot retry. The
2366
+ // default stays the deferred savepoint; nesting is a savepoint
2367
+ // under either.
2368
+ transaction: lift((fn, transactionOptions) => {
2369
+ const wanted = transactionOptions?.unitOfWork;
2370
+ if (wanted !== undefined && wanted !== 'own' && wanted !== 'shared') {
2371
+ throw new TypeError(
2372
+ "store.transaction: unitOfWork must be 'shared' or 'own'");
2373
+ }
2374
+ const mode = transactionOptions?.mode;
2375
+ if (mode !== undefined && mode !== 'deferred' && mode !== 'immediate') {
2376
+ throw new TypeError(
2377
+ "store.transaction: mode must be 'deferred' or 'immediate'");
2378
+ }
2379
+ return topLevelTransaction(fn, transactionOptions?.signal,
2380
+ wanted === 'own' ? createUnitOfWork() : undefined, mode);
2381
+ }),
1628
2382
  observe: (fn) => {
1629
2383
  if (capture === null) {
1630
2384
  throw new TypeError(
@@ -1633,19 +2387,79 @@ export function openStore(model, options) {
1633
2387
  return capture.observe(fn);
1634
2388
  },
1635
2389
  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)))),
2390
+ : lift((after) => gated(() => capture.changesSince(after))),
2391
+ // the bounded reader (LIVE-FORMAT §5): watermarks, and pages
2392
+ // that report a retention gap instead of a misleading suffix
2393
+ changes: capture === null || !capture.logged ? undefined : Object.freeze({
2394
+ bounds: lift(() => gated(() => capture.bounds())),
2395
+ page: lift((pageOptions) => gated(() => capture.page(pageOptions))),
2396
+ }),
2397
+ dataVersion: lift(() => gated(() => readDataVersion())),
2398
+ // Database → model, read-only: what this database's shape
2399
+ // says the model is, beside a report of everything it
2400
+ // cannot say. It holds the store gate for its extent, like
2401
+ // every other read, and it issues no DDL and no DML — the
2402
+ // derived model is an ANSWER, and applying it is the
2403
+ // migration planner's job and the operator's decision
2404
+ introspect: lift((introspectOptions) =>
2405
+ gated(() => introspectModel(connection, introspectOptions))),
2406
+ // the maintenance surface: each operation holds the store
2407
+ // gate for its own extent, so a checkpoint can never
2408
+ // interleave an in-flight write; none takes a transaction
2409
+ checkpoint: lift((maintenanceOptions) =>
2410
+ gated(() => maintenance.checkpoint(maintenanceOptions), 'a checkpoint')),
2411
+ integrityCheck: lift((maintenanceOptions) =>
2412
+ gated(() => maintenance.integrityCheck(maintenanceOptions), 'an integrity check')),
2413
+ foreignKeyCheck: lift((maintenanceOptions) =>
2414
+ gated(() => maintenance.foreignKeyCheck(maintenanceOptions), 'a foreign-key check')),
2415
+ optimize: lift((maintenanceOptions) =>
2416
+ gated(() => maintenance.optimize(maintenanceOptions), 'an optimize')),
2417
+ // the online backup: NOT held under the gate for its whole
2418
+ // extent — writers proceed while the copy runs — only its
2419
+ // checkpoint boundary is
2420
+ backupTo: lift((targetPath, backupOptions) => backup.backupTo(targetPath, backupOptions)),
2421
+ // The ROOT jobs surface: every finite call takes the store
2422
+ // gate, exactly as a root collection write does, so an
2423
+ // unrelated enqueue, claim, checkpoint or settlement can
2424
+ // never join an open application transaction's fate. The
2425
+ // transactional-outbox spelling is the explicit `tx.jobs` a
2426
+ // transaction callback receives.
1640
2427
  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,
2428
+ enqueue: lift((...args) => gated(() => jobsEngine.enqueue(...args), 'a root job enqueue')),
2429
+ get: lift((...args) => gated(() => jobsEngine.get(...args), 'a root job read')),
2430
+ counts: lift(() => gated(() => jobsEngine.counts(), 'a root job read')),
2431
+ claim: lift((...args) => gated(() => jobsEngine.claim(...args), 'a root job claim')),
2432
+ renew: lift((...args) => gated(() => jobsEngine.renew(...args), 'a root lease renewal')),
2433
+ complete: lift((...args) => gated(() => jobsEngine.complete(...args), 'a root job settlement')),
2434
+ fail: lift((...args) => gated(() => jobsEngine.fail(...args), 'a root job settlement')),
2435
+ // a checkpoint store keeps its creator's ROOT ownership:
2436
+ // its later calls take the gate too, never a scope. They
2437
+ // stay value-or-promise like the engine's own — the gate
2438
+ // answers a value when nothing is contended
2439
+ checkpointsFor: (job) => {
2440
+ const inner = jobsEngine.checkpointsFor(job);
2441
+ return Object.freeze({
2442
+ load: (runId) => gated(() => inner.load(runId), 'a root checkpoint read'),
2443
+ save: (runId, nodeId, value) =>
2444
+ gated(() => inner.save(runId, nodeId, value), 'a root checkpoint save'),
2445
+ complete: (runId, result) =>
2446
+ gated(() => inner.complete(runId, result), 'a root checkpoint settlement'),
2447
+ });
2448
+ },
1648
2449
  createWorker: jobsEngine.createWorker,
2450
+ // administration (JOBS-FORMAT §10): mechanism, never schedule.
2451
+ // `page` borrows the gate per pull like every root cursor;
2452
+ // `cancel` settles under the gate and then waits OUTSIDE it
2453
+ // for a local attempt to wind up — the handler's own
2454
+ // settlement calls take the gate, so waiting inside it
2455
+ // would wait for itself
2456
+ page: (pageOptions) => admitCursor(jobsEngine.page(pageOptions),
2457
+ gated, pageOptions?.signal, 'a root job page pull'),
2458
+ cancel: lift((id, cancelOptions) => chain(
2459
+ gated(() => jobsEngine.cancel(id, cancelOptions), 'a root job cancellation'),
2460
+ (outcome) => chain(jobsEngine.settledLocally(id), () => outcome))),
2461
+ requeue: lift((...args) => gated(() => jobsEngine.requeue(...args), 'a root job requeue')),
2462
+ sweep: lift((...args) => gated(() => jobsEngine.sweep(...args), 'a root job sweep')),
1649
2463
  }),
1650
2464
  /**
1651
2465
  * Close the store. Job workers are asked to stop and given a
@@ -1675,55 +2489,514 @@ export function openStore(model, options) {
1675
2489
  }),
1676
2490
  };
1677
2491
 
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))));
2492
+ /**
2493
+ * The `JD2070` lifetime check every stateful member of a
2494
+ * transaction view runs FIRST before reading or mutating
2495
+ * tracker state, and before any statement. A view is pinned to
2496
+ * the exact scope that created it: an identity that is not
2497
+ * current has either settled (the handle escaped its callback)
2498
+ * or been crossed by an inner scope (an outer handle used while
2499
+ * an async inner savepoint is open). It never falls through to
2500
+ * the root and never follows a newer scope.
2501
+ * @param {any} identity
2502
+ */
2503
+ const requireScope = (identity) => {
2504
+ if (currentScope === identity) return;
2505
+ throw new DbRuntimeError('JD2070',
2506
+ 'this transaction handle is pinned to a scope that is not current: '
2507
+ + 'its transaction settled, or an inner transaction is open. Use the '
2508
+ + 'store the LIVE transaction callback received (tx.collection / '
2509
+ + 'tx.entity / tx.saveChanges / tx.jobs) — a handle never outlives '
2510
+ + 'or crosses its own scope.');
2511
+ };
2512
+
2513
+ /**
2514
+ * Every stateful member of a scope-view handle, checked against
2515
+ * the exact scope before it runs. `lifted` members answer a
2516
+ * promise (the check rejects); `direct` members answer values
2517
+ * or value-or-promise (the check throws) — the unit-of-work
2518
+ * bookkeeping and the D2 provider members among them.
2519
+ * @param {any} identity
2520
+ * @param {any} handle
2521
+ * @param {string[]} lifted
2522
+ * @param {string[]} [direct]
2523
+ */
2524
+ const scopedMembers = (identity, handle, lifted, direct = []) => {
2525
+ const out = { ...handle };
2526
+ for (const member of lifted) {
2527
+ if (typeof handle[member] !== 'function') continue;
2528
+ out[member] = (/** @type {any[]} */ ...args) =>
2529
+ lift(() => {
2530
+ requireScope(identity);
2531
+ return handle[member](...args);
2532
+ })();
2533
+ }
2534
+ for (const member of direct) {
2535
+ if (typeof handle[member] !== 'function') continue;
2536
+ out[member] = (/** @type {any[]} */ ...args) => {
2537
+ requireScope(identity);
2538
+ return handle[member](...args);
2539
+ };
2540
+ }
2541
+ return out;
2542
+ };
2543
+
2544
+ /** A cursor pinned to one exact scope: it opens under the
2545
+ * scope check, and `next()` re-checks the scope on every pull,
2546
+ * so iteration can neither begin nor continue once that exact
2547
+ * scope settled. The classification (`streaming`, `barrier`)
2548
+ * is the inner cursor's own. */
2549
+ const scopedCursor = (identity, open) => {
2550
+ requireScope(identity);
2551
+ const cursor = open();
2552
+ const step = (/** @type {string} */ member) => () => {
2553
+ try {
2554
+ requireScope(identity);
2555
+ }
2556
+ catch (error) {
2557
+ return Promise.reject(error);
2558
+ }
2559
+ return cursor[member]();
2560
+ };
2561
+ /** @type {any} */
2562
+ const wrapped = {
2563
+ streaming: cursor.streaming,
2564
+ barrier: cursor.barrier,
2565
+ next: step('next'),
2566
+ return: step('return'),
2567
+ [Symbol.asyncIterator]: () => wrapped,
2568
+ };
2569
+ return Object.freeze(wrapped);
2570
+ };
2571
+
2572
+ /** A collection handle pinned to one exact scope, its lazy
2573
+ * cursor included. */
2574
+ const scopedCollection = (identity, name) => {
2575
+ const inner = boundCollection(name);
2576
+ const out = scopedMembers(identity, inner,
2577
+ ['get', 'insert', 'put', 'patch', 'delete', 'explain', 'live'],
2578
+ ['execute']);
2579
+ out.query = (/** @type {any} */ document, /** @type {any} */ queryOptions) =>
2580
+ scopedCursor(identity, () => inner.query(document, queryOptions));
2581
+ return Object.freeze(out);
2582
+ };
2583
+
2584
+ /** An entity handle over `unit`, pinned to one exact scope —
2585
+ * the statement members and the local tracker bookkeeping both
2586
+ * carry the identity (`add()` on a settled handle is `JD2070`,
2587
+ * not a document smuggled into a later scope's unit of work). */
2588
+ const scopedEntity = (identity, unit, name) => {
2589
+ const inner = unit.entityFor(name);
2590
+ const untracked = Object.freeze(
2591
+ scopedMembers(identity, inner.asNoTracking(), ['get', 'load']));
2592
+ return Object.freeze({
2593
+ ...scopedMembers(identity, inner,
2594
+ ['create', 'get', 'update', 'delete', 'load', 'page', 'explain'],
2595
+ ['execute', 'add', 'put', 'remove', 'discard', 'link', 'unlink']),
2596
+ cursor: (/** @type {any} */ document, /** @type {any} */ queryOptions) =>
2597
+ scopedCursor(identity, () => inner.cursor(document, queryOptions)),
2598
+ loadCursor: (/** @type {any} */ spec, /** @type {any} */ cursorOptions) =>
2599
+ scopedCursor(identity, () => inner.loadCursor(spec, cursorOptions)),
2600
+ asNoTracking: () => untracked,
2601
+ });
2602
+ };
2603
+
2604
+ /** The synchronous twin, answering values. */
2605
+ const scopedSyncEntity = (identity, unit, name) => {
2606
+ const inner = unit.syncEntityFor(name);
2607
+ const untracked = Object.freeze(
2608
+ scopedMembers(identity, inner.asNoTracking(), [], ['get', 'load']));
2609
+ return Object.freeze({
2610
+ ...scopedMembers(identity, inner, [],
2611
+ ['create', 'get', 'update', 'delete', 'load', 'page', 'execute', 'explain',
2612
+ 'add', 'put', 'remove', 'discard', 'link', 'unlink']),
2613
+ cursor: (document, options) => {
2614
+ requireScope(identity);
2615
+ return admitSyncCursor(inner.cursor(document, options), (fn) => { requireScope(identity); return fn(); });
2616
+ },
2617
+ loadCursor: (spec, options) => {
2618
+ requireScope(identity);
2619
+ return admitSyncCursor(inner.loadCursor(spec, options), (fn) => { requireScope(identity); return fn(); });
2620
+ },
2621
+ asNoTracking: () => untracked,
2622
+ });
2623
+ };
2624
+
2625
+ /** Shared synchronous collection handles over the cores; set
2626
+ * with `store.sync` when the driver is synchronous. The gated
2627
+ * store-level surface and each scope view wrap the same ones.
2628
+ * @type {((name: string) => any) | undefined} */
2629
+ let syncCollectionFor;
2630
+
1690
2631
  /** The overriding member on a view of the FROZEN store: plain
1691
2632
  * assignment cannot shadow a non-writable inherited property. */
1692
2633
  const override = (/** @type {any} */ value) =>
1693
2634
  ({ 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) }));
2635
+
2636
+ // The transaction callback's argument, and the ONLY handle that
2637
+ // is inside the transaction: ONE view per exact scope, pinned to
2638
+ // its identity. Its `collection`, `entity`, `sync`, unit of work
2639
+ // and `jobs` run as the owner instead of waiting for a commit
2640
+ // they are part of; its `transaction` NESTS through the owning
2641
+ // savepoint; its `savepoints` move the manual checkpoint stack.
2642
+ // The store's own handles are, by construction, somebody else —
2643
+ // and a view used outside its exact live scope is `JD2070`.
2644
+ scopedStore = (driverScope, identity) => {
2645
+ /** The unit of work in force for THIS scope, captured once:
2646
+ * the view's tracker surface never follows a later scope. */
2647
+ const myWork = work;
2648
+ /** @type {Map<string, any>} */
2649
+ const myCollections = new Map();
2650
+ /** @type {Map<string, any>} */
2651
+ const myEntities = new Map();
2652
+ const collectionFor = (/** @type {string} */ name) => {
2653
+ let handle = myCollections.get(name);
2654
+ if (handle === undefined) {
2655
+ handle = scopedCollection(identity, name);
2656
+ myCollections.set(name, handle);
2657
+ }
2658
+ return handle;
2659
+ };
2660
+ const entityFor = (/** @type {string} */ name) => {
2661
+ let handle = myEntities.get(name);
2662
+ if (handle === undefined) {
2663
+ handle = scopedEntity(identity, myWork, name);
2664
+ myEntities.set(name, handle);
2665
+ }
2666
+ return handle;
2667
+ };
2668
+
2669
+ /** Nest through THIS scope's savepoint. The capture scope
2670
+ * goes INSIDE the savepoint, so a rollback undoes the
2671
+ * translated patch with the rows it describes. */
2672
+ const nested = (/** @type {any} */ fn) => {
2673
+ requireScope(identity);
2674
+ return withScope(driverScope.transaction,
2675
+ (inner, innerIdentity) => (capture === null
2676
+ ? fn(scopedStore(inner, innerIdentity))
2677
+ : capture.nest(() => fn(scopedStore(inner, innerIdentity)))));
2678
+ };
2679
+
2680
+ // ————— named savepoints (MODEL-FORMAT §5.2) —————
2681
+ // One per-exact-scope map from the caller's LABEL to an
2682
+ // opaque driver checkpoint plus the settlement-list mark and,
2683
+ // in journal capture mode, the capture mark. The label is a
2684
+ // map key and diagnostic only — the driver generates the
2685
+ // `jaren_sp_*` identifier structured nesting already uses, so
2686
+ // a label can never become SQL, and both savepoint kinds
2687
+ // share one engine stack. The scope's settlement (commit or
2688
+ // rollback) invalidates whatever names were left active,
2689
+ // because the view itself is then `JD2070`.
2690
+ /** @type {Map<string, any>} */
2691
+ const checkpoints = new Map();
2692
+ const requireLabel = (/** @type {any} */ label, /** @type {string} */ verb) => {
2693
+ if (typeof label === 'string' && label !== '') return;
2694
+ throw new DbRuntimeError('JD2071',
2695
+ `savepoints.${verb}: a savepoint label must be a non-empty string — `
2696
+ + 'it is a map key and diagnostic for this exact transaction, never SQL');
2697
+ };
2698
+ const resolveLabel = (/** @type {string} */ label, /** @type {string} */ verb) => {
2699
+ requireLabel(label, verb);
2700
+ const entry = checkpoints.get(label);
2701
+ if (entry !== undefined) return entry;
2702
+ throw new DbRuntimeError('JD2071',
2703
+ `savepoints.${verb}: no active savepoint '${label}' in this exact `
2704
+ + 'transaction — it was never created here, or a rollback past it or a '
2705
+ + 'release already invalidated it');
2706
+ };
2707
+ const savepointCreate = (/** @type {string} */ label) => {
2708
+ requireScope(identity);
2709
+ requireLabel(label, 'create');
2710
+ if (checkpoints.has(label)) {
2711
+ throw new DbRuntimeError('JD2071',
2712
+ `savepoints.create: the label '${label}' is already active in this `
2713
+ + 'transaction — release it, or roll back to it, before creating it again');
1700
2714
  }
1701
- txStore = Object.freeze(Object.create(store, members));
2715
+ // SAVEPOINT first; the entry is recorded only after success,
2716
+ // so a refused statement leaves label map and marks untouched
2717
+ return chain(driverScope.savepoint(), (checkpoint) => {
2718
+ checkpoints.set(label, {
2719
+ checkpoint,
2720
+ settleMark: settlements === null ? 0 : settlements.length,
2721
+ captureMark: capture === null ? null : capture.mark(),
2722
+ });
2723
+ return undefined;
2724
+ });
2725
+ };
2726
+ const savepointRollbackTo = (/** @type {string} */ label) => {
2727
+ requireScope(identity);
2728
+ const entry = resolveLabel(label, 'rollbackTo');
2729
+ // ROLLBACK TO first; only after database success do the
2730
+ // in-memory effects follow. The target stays active with
2731
+ // the same now-current marks, so repeated rollback is
2732
+ // defined; entries created after it are gone from the
2733
+ // engine stack and invalidated here.
2734
+ return chain(driverScope.rollbackTo(entry.checkpoint), () => {
2735
+ if (settlements !== null) {
2736
+ const withdrawn = settlements.splice(entry.settleMark);
2737
+ for (let i = withdrawn.length - 1; i >= 0; i--) withdrawn[i].rollback?.();
2738
+ }
2739
+ if (capture !== null) capture.truncate(entry.captureMark);
2740
+ let seen = false;
2741
+ for (const key of [...checkpoints.keys()]) {
2742
+ if (seen) checkpoints.delete(key);
2743
+ if (key === label) seen = true;
2744
+ }
2745
+ return undefined;
2746
+ });
2747
+ };
2748
+ const savepointRelease = (/** @type {string} */ label) => {
2749
+ requireScope(identity);
2750
+ const entry = resolveLabel(label, 'release');
2751
+ // RELEASE removes the target and every later entry WITHOUT
2752
+ // running rollback effects: those rows remain part of the
2753
+ // owning transaction, so their tracker withdrawals stay
2754
+ // registered until outer settlement — the engine semantics,
2755
+ // exactly (both SQLite and PostgreSQL discard the target
2756
+ // and the savepoints nested after it, keeping their rows)
2757
+ return chain(driverScope.release(entry.checkpoint), () => {
2758
+ let seen = false;
2759
+ for (const key of [...checkpoints.keys()]) {
2760
+ if (key === label) seen = true;
2761
+ if (seen) checkpoints.delete(key);
2762
+ }
2763
+ return undefined;
2764
+ });
2765
+ };
2766
+
2767
+ const members = {
2768
+ transaction: override((/** @type {any} */ fn) => lift(() => nested(fn))()),
2769
+ collection: override(collectionFor),
2770
+ entity: override(entityFor),
2771
+ // THIS scope's bookkeeping, whatever scope is current later
2772
+ stats: override(() => ({
2773
+ statementCache: { ...queryState.counters },
2774
+ udfRegistrations: queryState.registered.size,
2775
+ tracker: myWork.tracker === null ? null : myWork.tracker.counts(),
2776
+ liveQueries: liveRegistry === null ? 0 : liveRegistry.count(),
2777
+ })),
2778
+ dataVersion: override(lift(() => {
2779
+ requireScope(identity);
2780
+ return readDataVersion();
2781
+ })),
2782
+ savepoints: override(Object.freeze({
2783
+ create: lift(savepointCreate),
2784
+ rollbackTo: lift(savepointRollbackTo),
2785
+ release: lift(savepointRelease),
2786
+ })),
2787
+ // a transaction view does not own the store lifetime: the
2788
+ // member is ABSENT rather than a second way to close the
2789
+ // raw connection under its own savepoint
2790
+ close: override(undefined),
2791
+ replication: override(undefined),
2792
+ // nor does it run maintenance: a checkpoint inside an open
2793
+ // transaction is a no-op the engine answers quietly, and
2794
+ // the other three are store-level operations — ABSENT here
2795
+ checkpoint: override(undefined),
2796
+ integrityCheck: override(undefined),
2797
+ foreignKeyCheck: override(undefined),
2798
+ optimize: override(undefined),
2799
+ backupTo: override(undefined),
2800
+ };
2801
+ if (entities.size > 0) {
2802
+ members.saveChanges = override(lift(() => {
2803
+ requireScope(identity);
2804
+ return guard(() => myWork.tracker.saveChanges());
2805
+ }));
1702
2806
  }
1703
- return txStore;
2807
+ if (entityEngine !== null) {
2808
+ members.execute = override(
2809
+ (/** @type {any} */ document, /** @type {any} */ queryOptions) => {
2810
+ requireScope(identity);
2811
+ return entityEngine.execute(document, queryOptions);
2812
+ });
2813
+ members.explain = override(lift(
2814
+ (/** @type {any} */ document, /** @type {any} */ queryOptions) => {
2815
+ requireScope(identity);
2816
+ return entityEngine.explain(document, queryOptions);
2817
+ }));
2818
+ members.live = override(lift(
2819
+ (/** @type {any} */ document, /** @type {any} */ liveOptions) => {
2820
+ requireScope(identity);
2821
+ return registerEntityLive(document, liveOptions);
2822
+ }));
2823
+ }
2824
+ if (capture !== null) {
2825
+ members.changesSince = override(lift((/** @type {any} */ after) => {
2826
+ requireScope(identity);
2827
+ return capture.changesSince(after);
2828
+ }));
2829
+ if (capture.logged) members.changes = override(Object.freeze({
2830
+ bounds: lift(() => {
2831
+ requireScope(identity);
2832
+ return capture.bounds();
2833
+ }),
2834
+ page: lift((/** @type {any} */ pageOptions) => {
2835
+ requireScope(identity);
2836
+ return capture.page(pageOptions);
2837
+ }),
2838
+ }));
2839
+ }
2840
+ if (jobsEngine !== null) {
2841
+ // the transactional-outbox spelling: these run as the exact
2842
+ // scope, so an enqueue or settlement here co-commits with
2843
+ // the domain transaction — and a retained handle is JD2070
2844
+ members.jobs = override(Object.freeze({
2845
+ enqueue: lift((/** @type {any[]} */ ...args) => {
2846
+ requireScope(identity);
2847
+ return jobsEngine.enqueue(...args);
2848
+ }),
2849
+ get: lift((/** @type {any[]} */ ...args) => {
2850
+ requireScope(identity);
2851
+ return jobsEngine.get(...args);
2852
+ }),
2853
+ counts: lift(() => {
2854
+ requireScope(identity);
2855
+ return jobsEngine.counts();
2856
+ }),
2857
+ claim: lift((/** @type {any[]} */ ...args) => {
2858
+ requireScope(identity);
2859
+ return jobsEngine.claim(...args);
2860
+ }),
2861
+ renew: lift((/** @type {any[]} */ ...args) => {
2862
+ requireScope(identity);
2863
+ return jobsEngine.renew(...args);
2864
+ }),
2865
+ complete: lift((/** @type {any[]} */ ...args) => {
2866
+ requireScope(identity);
2867
+ return jobsEngine.complete(...args);
2868
+ }),
2869
+ fail: lift((/** @type {any[]} */ ...args) => {
2870
+ requireScope(identity);
2871
+ return jobsEngine.fail(...args);
2872
+ }),
2873
+ // a checkpoint store keeps its creator's SCOPE ownership:
2874
+ // its later calls cannot switch scopes, and outlive none
2875
+ checkpointsFor: (/** @type {any} */ job) => {
2876
+ const inner = jobsEngine.checkpointsFor(job);
2877
+ return Object.freeze({
2878
+ load: lift((/** @type {any} */ runId) => {
2879
+ requireScope(identity);
2880
+ return inner.load(runId);
2881
+ }),
2882
+ save: lift((/** @type {any} */ runId, /** @type {any} */ nodeId,
2883
+ /** @type {any} */ value) => {
2884
+ requireScope(identity);
2885
+ return inner.save(runId, nodeId, value);
2886
+ }),
2887
+ complete: lift((/** @type {any} */ runId, /** @type {any} */ result) => {
2888
+ requireScope(identity);
2889
+ return inner.complete(runId, result);
2890
+ }),
2891
+ });
2892
+ },
2893
+ // a worker is a ROOT-owned long-lived component wherever
2894
+ // it is created: its future loop takes the store gate and
2895
+ // never binds to the transaction that constructed it
2896
+ createWorker: jobsEngine.createWorker,
2897
+ }));
2898
+ }
2899
+ if (connection.synchronous && syncCollectionFor !== undefined) {
2900
+ /** @type {Map<string, any>} */
2901
+ const mySyncCollections = new Map();
2902
+ /** @type {Map<string, any>} */
2903
+ const mySyncEntities = new Map();
2904
+ const forSync = /** @type {(name: string) => any} */ (syncCollectionFor);
2905
+ members.sync = override(Object.freeze({
2906
+ collection: (/** @type {string} */ name) => {
2907
+ let handle = mySyncCollections.get(name);
2908
+ if (handle === undefined) {
2909
+ handle = Object.freeze(scopedMembers(identity, forSync(name), [],
2910
+ ['get', 'insert', 'put', 'patch', 'delete', 'execute', 'explain']));
2911
+ mySyncCollections.set(name, handle);
2912
+ }
2913
+ return handle;
2914
+ },
2915
+ entity: (/** @type {string} */ name) => {
2916
+ let handle = mySyncEntities.get(name);
2917
+ if (handle === undefined) {
2918
+ handle = scopedSyncEntity(identity, myWork, name);
2919
+ mySyncEntities.set(name, handle);
2920
+ }
2921
+ return handle;
2922
+ },
2923
+ transaction: nested,
2924
+ savepoints: Object.freeze({
2925
+ create: savepointCreate,
2926
+ rollbackTo: savepointRollbackTo,
2927
+ release: savepointRelease,
2928
+ }),
2929
+ saveChanges: entities.size === 0 ? undefined
2930
+ : () => {
2931
+ requireScope(identity);
2932
+ return guard(() => myWork.tracker.saveChanges());
2933
+ },
2934
+ execute: entityEngine === null ? undefined
2935
+ : (/** @type {any} */ document, /** @type {any} */ queryOptions) => {
2936
+ requireScope(identity);
2937
+ return entityEngine.execute(document, queryOptions);
2938
+ },
2939
+ explain: entityEngine === null ? undefined
2940
+ : (/** @type {any} */ document, /** @type {any} */ queryOptions) => {
2941
+ requireScope(identity);
2942
+ return entityEngine.explain(document, queryOptions);
2943
+ },
2944
+ roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
2945
+ relations: entityEngine === null ? undefined : entityEngine.relations,
2946
+ }));
2947
+ }
2948
+ return Object.freeze(Object.create(store, members));
1704
2949
  };
1705
2950
 
1706
2951
  if (connection.synchronous) {
1707
2952
  /** @type {Map<string, any>} */
1708
2953
  const syncHandles = new Map();
2954
+ syncCollectionFor = (name) => {
2955
+ let handle = syncHandles.get(name);
2956
+ if (handle === undefined) {
2957
+ const core = coreFor(name);
2958
+ handle = Object.freeze({
2959
+ stats: () => core.stats(),
2960
+ get: (/** @type {any} */ key) => core.get(key),
2961
+ insert: (/** @type {any} */ doc) => core.insert(doc),
2962
+ put: (/** @type {any} */ doc, /** @type {any} */ key) => core.put(doc, key),
2963
+ patch: (/** @type {any} */ key, /** @type {any} */ ops) => core.patch(key, ops),
2964
+ delete: (/** @type {any} */ key) => core.delete(key),
2965
+ execute: (/** @type {any} */ document, /** @type {any} */ o) =>
2966
+ core.execute(document, o),
2967
+ explain: (/** @type {any} */ document, /** @type {any} */ o) =>
2968
+ core.explain(document, o),
2969
+ });
2970
+ syncHandles.set(name, handle);
2971
+ }
2972
+ return handle;
2973
+ };
2974
+ const forSync = syncCollectionFor;
2975
+
2976
+ /** Store-level synchronous members, each holding the
2977
+ * connection for its own extent. A contended one refuses
2978
+ * rather than queueing: this surface answers values, and a
2979
+ * queue answers a Promise. */
2980
+ const syncGatedMembers = (handle, names) => {
2981
+ const out = { ...handle };
2982
+ for (const member of names) {
2983
+ if (typeof handle[member] !== 'function') continue;
2984
+ out[member] = (/** @type {any[]} */ ...args) =>
2985
+ gatedSync(() => handle[member](...args));
2986
+ }
2987
+ return Object.freeze(out);
2988
+ };
1709
2989
  /** @type {Map<string, any>} */
1710
- const syncEntityHandles = new Map();
2990
+ const gatedSyncCollections = new Map();
2991
+ /** @type {Map<string, any>} */
2992
+ const gatedSyncEntities = new Map();
1711
2993
  store.sync = Object.freeze({
1712
2994
  collection(name) {
1713
- let handle = syncHandles.get(name);
2995
+ let handle = gatedSyncCollections.get(name);
1714
2996
  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);
2997
+ handle = syncGatedMembers(forSync(name),
2998
+ ['get', 'insert', 'put', 'patch', 'delete', 'execute', 'explain']);
2999
+ gatedSyncCollections.set(name, handle);
1727
3000
  }
1728
3001
  return handle;
1729
3002
  },
@@ -1740,62 +3013,83 @@ export function openStore(model, options) {
1740
3013
  return topLevelTransaction(fn);
1741
3014
  },
1742
3015
  entity(name) {
1743
- let handle = syncEntityHandles.get(name);
3016
+ let handle = gatedSyncEntities.get(name);
1744
3017
  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
- });
3018
+ // the ROOT unit of work, whatever transaction happens
3019
+ // to be open when the handle is first constructed
3020
+ const inner = rootWork.syncEntityFor(name);
3021
+ const untracked = syncGatedMembers(inner.asNoTracking(), ['get', 'load']);
1750
3022
  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,
3023
+ ...syncGatedMembers(inner,
3024
+ ['create', 'get', 'update', 'delete', 'load', 'page', 'execute', 'explain']),
3025
+ cursor: (document, options) => gatedSync(() =>
3026
+ admitSyncCursor(inner.cursor(document, options), gatedSync)),
3027
+ loadCursor: (spec, options) => gatedSync(() =>
3028
+ admitSyncCursor(inner.loadCursor(spec, options), gatedSync)),
1763
3029
  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
3030
  });
1773
- syncEntityHandles.set(name, handle);
3031
+ gatedSyncEntities.set(name, handle);
1774
3032
  }
1775
3033
  return handle;
1776
3034
  },
1777
3035
  saveChanges: entities.size === 0 ? undefined
1778
- : () => guard(() => tracker.saveChanges()),
3036
+ : () => gatedSync(() => guard(() => rootWork.tracker.saveChanges())),
1779
3037
  execute: entityEngine === null ? undefined
1780
- : (document, queryOptions) => entityEngine.execute(document, queryOptions),
3038
+ : (document, queryOptions) =>
3039
+ gatedSync(() => entityEngine.execute(document, queryOptions)),
1781
3040
  explain: entityEngine === null ? undefined
1782
- : (document, queryOptions) => entityEngine.explain(document, queryOptions),
3041
+ : (document, queryOptions) =>
3042
+ gatedSync(() => entityEngine.explain(document, queryOptions)),
1783
3043
  roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
1784
3044
  relations: entityEngine === null ? undefined : entityEngine.relations,
1785
3045
  });
1786
3046
  }
1787
3047
  return chain(capture === null ? null : capture.ready,
1788
- () => chain(jobsEngine === null ? null : jobsEngine.ready,
1789
- () => Object.freeze(store)));
1790
- }))));
3048
+ () => chain(jobsEngine === null ? null : jobsEngine.ready, () => {
3049
+ if (options.replication !== undefined) {
3050
+ replicationEngine = createReplicationEngine({ connection, capture,
3051
+ config: options.replication, model: shapeHash(model), now: runtime.now, bracket: firstOpen,
3052
+ rows: createLogicalRows({ connection, shapes: captureShapes, capture,
3053
+ collectionCore: coreFor, entityCore: entityCoreFor, captureJoinDelete }),
3054
+ });
3055
+ store.replication = Object.freeze({
3056
+ frontier: lift(() => gated(() => replicationEngine.frontier())),
3057
+ page: lift((request) => topLevelTransaction(() => replicationEngine.page(request), request?.signal, undefined, 'immediate')),
3058
+ conflicts: lift((request) => gated(() => replicationEngine.conflicts(request), 'replication conflict read', request?.signal)),
3059
+ snapshot: lift((request) => topLevelTransaction(() => replicationEngine.snapshot(request), request?.signal, undefined, 'immediate')),
3060
+ reset: lift((snapshot, request) => replicationEngine.reset(snapshot, request,
3061
+ (fn) => topLevelTransaction(fn, request?.signal, createUnitOfWork(), 'immediate'))),
3062
+ apply: lift((envelope, request) => replicationEngine.apply(envelope, request,
3063
+ (fn) => topLevelTransaction(fn, request?.signal, createUnitOfWork(), 'immediate'))),
3064
+ });
3065
+ }
3066
+ return chain(replicationEngine === null ? null : replicationEngine.ready, () => Object.freeze(store));
3067
+ }));
3068
+ })))));
1791
3069
 
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_;
3070
+ /**
3071
+ * The open sequence, with ONE retry when it fails classed busy: the
3072
+ * race window is another process's shape transaction on a fresh
3073
+ * file, and one retry after it commits is the straggler case the
3074
+ * immediate transaction cannot cover (the journal-mode write itself).
3075
+ * Every step is idempotent, so a second pass re-applies nothing that
3076
+ * matters; a second busy failure propagates classed.
3077
+ * @param {boolean} retry
3078
+ */
3079
+ const attemptOpen = (retry) => {
3080
+ const again = (error) => (retry && isDriverError(error)
3081
+ && classifyDriverError(error).class === 'busy'
3082
+ ? attemptOpen(false)
3083
+ : failClosed(error));
3084
+ let opened_;
3085
+ try {
3086
+ opened_ = opening();
3087
+ }
3088
+ catch (error) {
3089
+ return again(error);
3090
+ }
3091
+ return isThenable(opened_) ? opened_.then((value) => value, again) : opened_;
3092
+ };
3093
+ return attemptOpen(true);
1800
3094
  }));
1801
3095
  }