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