@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.
- package/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- 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,
|
|
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 {
|
|
32
|
-
import {
|
|
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
|
-
//
|
|
383
|
-
// document)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
863
|
-
*
|
|
864
|
-
* why
|
|
865
|
-
* owner rather than waiting for a commit it is
|
|
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 {(
|
|
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
|
-
|
|
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(
|
|
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
|
|
917
|
-
* argument
|
|
918
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
|
971
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|
1450
|
+
const captureOption = options.capture ?? (options.replication === undefined ? undefined : true);
|
|
1451
|
+
const captureRequested = captureOption === true
|
|
1062
1452
|
? {}
|
|
1063
|
-
: (
|
|
1064
|
-
? null :
|
|
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
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
1317
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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
|
-
|
|
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) =>
|
|
2334
|
+
: (document, queryOptions) =>
|
|
2335
|
+
gated(() => entityEngine.execute(document, queryOptions)),
|
|
1583
2336
|
explain: entityEngine === null ? undefined
|
|
1584
|
-
: lift((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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
*
|
|
1687
|
-
*
|
|
1688
|
-
|
|
1689
|
-
|
|
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
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
2995
|
+
let handle = gatedSyncCollections.get(name);
|
|
1714
2996
|
if (handle === undefined) {
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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 =
|
|
3016
|
+
let handle = gatedSyncEntities.get(name);
|
|
1744
3017
|
if (handle === undefined) {
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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
|
-
|
|
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) =>
|
|
3038
|
+
: (document, queryOptions) =>
|
|
3039
|
+
gatedSync(() => entityEngine.execute(document, queryOptions)),
|
|
1781
3040
|
explain: entityEngine === null ? undefined
|
|
1782
|
-
: (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
|
-
|
|
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
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
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
|
}
|