@jarenjs/db 0.46.5 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +133 -17
- package/README.md +270 -36
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +139 -7
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +251 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/algebra.js +22 -3
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +21 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +42 -9
- package/src/entity.js +92 -47
- package/src/errors.js +28 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +605 -0
- package/src/live.js +52 -9
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +834 -47
- package/src/query.js +296 -22
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +243 -69
- package/src/tracker.js +173 -48
- package/types/index.d.ts +206 -12
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -199
- 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 -149
- 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 -167
- 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.d.ts +0 -62
- 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 -140
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -111
- package/dist/types/residual.d.ts +0 -61
- package/dist/types/store.d.ts +0 -53
- 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
|
@@ -23,19 +23,20 @@
|
|
|
23
23
|
import { applyJSONPatch } from '@jarenjs/json/patch';
|
|
24
24
|
import { parseJSONPointer } from '@jarenjs/json/pointer';
|
|
25
25
|
|
|
26
|
-
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
27
|
-
import { chain, toPromise, isThenable } from './driver.js';
|
|
26
|
+
import { DbCompileError, DbRuntimeError, isDuplicateKeyError } from './errors.js';
|
|
27
|
+
import { chain, toPromise, isThenable, attempt } from './driver.js';
|
|
28
28
|
import { planCollection, planEntity, planJoinTable, verifyShape } from './ddl.js';
|
|
29
29
|
import { translatePatch } from './patch-sql.js';
|
|
30
30
|
import { createQueryEngine, createQueryState, createEntityQueryEngine, createLoadEngine } from './query.js';
|
|
31
31
|
import { normalizeProfile } from './profile.js';
|
|
32
32
|
import { normalizeEntities, explainMapping } from './model.js';
|
|
33
33
|
import { entityCore } from './entity.js';
|
|
34
|
-
import { createTracker } from './tracker.js';
|
|
34
|
+
import { createTracker, membershipKeys } from './tracker.js';
|
|
35
35
|
import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
|
|
36
36
|
import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js';
|
|
37
|
+
import { normalizeEventTime } from './live-time.js';
|
|
37
38
|
import { createJobEngine } from './jobs.js';
|
|
38
|
-
import { collectEntityRoots } from './plan.js';
|
|
39
|
+
import { collectEntityRoots, entityRoot } from './plan.js';
|
|
39
40
|
import {
|
|
40
41
|
DERIVE_KINDS, PHYSICAL_KINDS, PRECISION_MIN, PRECISION_MAX, DIMS_MIN, DIMS_MAX,
|
|
41
42
|
derivedValue, memberAt, storedMemberForm, registerDeriveFunctions,
|
|
@@ -368,13 +369,6 @@ function requireKey(key, collection, docPath) {
|
|
|
368
369
|
* @param {string} keyColumn
|
|
369
370
|
* @returns {boolean}
|
|
370
371
|
*/
|
|
371
|
-
function isDuplicateKey(error, table, keyColumn) {
|
|
372
|
-
if (error?.errcode === 1555) return true;
|
|
373
|
-
return typeof error?.message === 'string'
|
|
374
|
-
&& error.message.includes('UNIQUE constraint failed')
|
|
375
|
-
&& error.message.includes(`${table}.${keyColumn}`);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
372
|
/**
|
|
379
373
|
* Wrap a database failure for one collection operation.
|
|
380
374
|
* @param {any} error
|
|
@@ -385,7 +379,10 @@ function isDuplicateKey(error, table, keyColumn) {
|
|
|
385
379
|
* @returns {DbRuntimeError}
|
|
386
380
|
*/
|
|
387
381
|
function wrapWriteError(error, plan, collection, docPath, key) {
|
|
388
|
-
|
|
382
|
+
// an error that already carries a code (a closed store, a refused
|
|
383
|
+
// document) is the error; only the driver's own failures are wrapped
|
|
384
|
+
if (typeof error?.code === 'string' && error.code.startsWith('JD')) return error;
|
|
385
|
+
if (isDuplicateKeyError(error, plan.table, plan.keyColumn)) {
|
|
389
386
|
return new DbRuntimeError('JD2001',
|
|
390
387
|
`a document already exists under key '${String(key)}'`,
|
|
391
388
|
{ docPath, collection, key, cause: error });
|
|
@@ -593,16 +590,9 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
|
|
|
593
590
|
};
|
|
594
591
|
|
|
595
592
|
const runWrite = (statementName, sql, params, key, reads) => {
|
|
596
|
-
return chain(prepared(statementName, sql), (statement) =>
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
out = reads ? statement.get(params) : statement.run(params);
|
|
600
|
-
}
|
|
601
|
-
catch (error) {
|
|
602
|
-
throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
|
|
603
|
-
}
|
|
604
|
-
return out;
|
|
605
|
-
});
|
|
593
|
+
return chain(prepared(statementName, sql), (statement) =>
|
|
594
|
+
attempt(() => (reads ? statement.get(params) : statement.run(params)),
|
|
595
|
+
(error) => wrapWriteError(error, plan, collection.name, collection.docPath, key)));
|
|
606
596
|
};
|
|
607
597
|
|
|
608
598
|
const core = {
|
|
@@ -672,15 +662,10 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
|
|
|
672
662
|
const { expression, params } = translated.build(
|
|
673
663
|
dialect.quoteIdentifier(plan.docColumn), 1);
|
|
674
664
|
const sql = dialect.dml.updateDoc(shape, expression, params.length + 1);
|
|
675
|
-
return chain(prepared(`patch:${sql}`, sql), (statement) =>
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
catch (error) {
|
|
680
|
-
throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
|
|
681
|
-
}
|
|
682
|
-
return next;
|
|
683
|
-
});
|
|
665
|
+
return chain(prepared(`patch:${sql}`, sql), (statement) =>
|
|
666
|
+
chain(attempt(() => statement.run([...params, ...derivedFor(next), key]),
|
|
667
|
+
(error) => wrapWriteError(error, plan, collection.name, collection.docPath, key)),
|
|
668
|
+
() => next));
|
|
684
669
|
});
|
|
685
670
|
},
|
|
686
671
|
delete(key) {
|
|
@@ -755,6 +740,26 @@ function asyncCollection(core, live) {
|
|
|
755
740
|
* @param {any} options
|
|
756
741
|
* @returns {{ functions: any, extensions: any } | null}
|
|
757
742
|
*/
|
|
743
|
+
/**
|
|
744
|
+
* The schema a WRITE validates against. A store-allocated key (`default:
|
|
745
|
+
* "auto"`) is absent from the document the injected hook sees — the
|
|
746
|
+
* database allocates it after validation — so it cannot be required of a
|
|
747
|
+
* write, and the generated input type already marks it optional; every
|
|
748
|
+
* other member is the schema's own, defaults filled (§9.6). The read
|
|
749
|
+
* shape is untouched: the document the store answers carries the key.
|
|
750
|
+
* @param {any} entity - a normalized entity
|
|
751
|
+
* @returns {any}
|
|
752
|
+
*/
|
|
753
|
+
function writeSchemaOf(entity) {
|
|
754
|
+
const schema = entity.schema;
|
|
755
|
+
const auto = entity.keys.find((key) => entity.properties.get(key).default === 'auto');
|
|
756
|
+
if (auto === undefined || !Array.isArray(schema?.required) || !schema.required.includes(auto))
|
|
757
|
+
return schema;
|
|
758
|
+
const out = { ...schema, required: schema.required.filter((name) => name !== auto) };
|
|
759
|
+
if (out.required.length === 0) delete out.required;
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
|
|
758
763
|
function resolveOperators(options) {
|
|
759
764
|
const registry = options.operators;
|
|
760
765
|
const hasRegistry = registry !== undefined && registry !== null;
|
|
@@ -794,8 +799,14 @@ function resolveOperators(options) {
|
|
|
794
799
|
* @param {{ driver: any, path?: string, compileSchema?: Function,
|
|
795
800
|
* busyTimeout?: number, queueTimeout?: number, journalMode?: string,
|
|
796
801
|
* statementCacheBound?: number, profile?: any, operators?: any,
|
|
797
|
-
* functions?: any, extensions?: any,
|
|
802
|
+
* functions?: any, extensions?: any, zoneProvider?: any,
|
|
798
803
|
* readOnly?: boolean }} options
|
|
804
|
+
* `zoneProvider` is D7's injected clock: a named zone in a temporal
|
|
805
|
+
* spec (`{ "every": "P1M", "zone": "Europe/Amsterdam" }`) is host code
|
|
806
|
+
* the database cannot have, so a store that never received one refuses
|
|
807
|
+
* such a document (`JQ0003`) rather than answering it in UTC. It
|
|
808
|
+
* reaches every residual compilation, which is where the calendar
|
|
809
|
+
* ladder actually walks.
|
|
799
810
|
* @returns {Promise<any>}
|
|
800
811
|
*/
|
|
801
812
|
export function openStore(model, options) {
|
|
@@ -1107,8 +1118,9 @@ export function openStore(model, options) {
|
|
|
1107
1118
|
});
|
|
1108
1119
|
}
|
|
1109
1120
|
for (const joinName of Object.keys(mapping?.joinTables ?? {})) {
|
|
1110
|
-
const
|
|
1111
|
-
const columns =
|
|
1121
|
+
const join = mapping.joinTables[joinName];
|
|
1122
|
+
const columns = [join.left, join.right]
|
|
1123
|
+
.map((side) => ({ name: side.column, role: 'key' }));
|
|
1112
1124
|
captureShapes.set(joinName, {
|
|
1113
1125
|
kind: 'join', columns,
|
|
1114
1126
|
keyIndexes: columns.map((_, i) => i), docIndex: -1,
|
|
@@ -1148,12 +1160,21 @@ export function openStore(model, options) {
|
|
|
1148
1160
|
defaults: typeof options.jobs === 'object' ? options.jobs : undefined,
|
|
1149
1161
|
});
|
|
1150
1162
|
/** Register a collection live query (LIVE-FORMAT §7). */
|
|
1163
|
+
const refuseAsyncLive = () => {
|
|
1164
|
+
if (connection.synchronous !== true) {
|
|
1165
|
+
throw new DbCompileError('JD0051',
|
|
1166
|
+
'live queries are not maintained over an asynchronous connection — a '
|
|
1167
|
+
+ 'synchronous driver (node, bun, a synchronous wasm handle) keeps them');
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1151
1170
|
const registerCollectionLive = (core, document, liveOptions) => {
|
|
1171
|
+
refuseAsyncLive();
|
|
1152
1172
|
const externals = liveOptions?.externals ?? {};
|
|
1153
1173
|
const keyed = core.model.keySegments !== null;
|
|
1174
|
+
const eventTime = normalizeEventTime(liveOptions, core.model.name);
|
|
1154
1175
|
const classification = liveOptions?.mode === 'rerun'
|
|
1155
1176
|
? { strategy: 'rerun', reason: 'rerun was requested' }
|
|
1156
|
-
: classifyLiveQuery(document, core.queryShape, keyed);
|
|
1177
|
+
: classifyLiveQuery(document, core.queryShape, keyed, eventTime);
|
|
1157
1178
|
return /** @type {any} */ (liveRegistry).register({
|
|
1158
1179
|
name: core.model.name,
|
|
1159
1180
|
tables: new Set([core.model.name]),
|
|
@@ -1185,26 +1206,39 @@ export function openStore(model, options) {
|
|
|
1185
1206
|
const captureJoinDelete = capture === null || capture.mode !== 'journal'
|
|
1186
1207
|
? null
|
|
1187
1208
|
: (entityName, keyParts) => {
|
|
1188
|
-
const joins = Object.
|
|
1189
|
-
.filter((
|
|
1209
|
+
const joins = Object.entries(mapping?.joinTables ?? {})
|
|
1210
|
+
.filter(([, join]) => join.left.entity === entityName
|
|
1211
|
+
|| join.right.entity === entityName);
|
|
1190
1212
|
const nextJoin = (i) => {
|
|
1191
1213
|
if (i >= joins.length) return null;
|
|
1192
|
-
const joinName = joins[i];
|
|
1193
|
-
const
|
|
1194
|
-
const
|
|
1214
|
+
const [joinName, join] = joins[i];
|
|
1215
|
+
const columns = [join.left.column, join.right.column];
|
|
1216
|
+
const own = join.left.entity === entityName ? join.left : join.right;
|
|
1217
|
+
const sql = `SELECT ${columns.map(dialect.quoteIdentifier).join(', ')} `
|
|
1195
1218
|
+ `FROM ${dialect.quoteIdentifier(joinName)} `
|
|
1196
|
-
+ `WHERE ${dialect.quoteIdentifier(
|
|
1219
|
+
+ `WHERE ${dialect.quoteIdentifier(own.column)} = ${dialect.parameterRef(1, 'v')}`;
|
|
1197
1220
|
return chain(connection.prepare(sql), (statement) =>
|
|
1198
1221
|
chain(statement.all([keyParts[0]]), (rows) => {
|
|
1199
1222
|
for (const row of rows) {
|
|
1200
|
-
capture.record(joinName,
|
|
1201
|
-
pair.map((part) => row[`${part}_key`]), undefined, null);
|
|
1223
|
+
capture.record(joinName, columns.map((column) => row[column]), undefined, null);
|
|
1202
1224
|
}
|
|
1203
1225
|
return nextJoin(i + 1);
|
|
1204
1226
|
}));
|
|
1205
1227
|
};
|
|
1206
1228
|
return nextJoin(0);
|
|
1207
1229
|
};
|
|
1230
|
+
/** The document a keyed `put` replaces, for the journal's
|
|
1231
|
+
* before-image: resolved through the collection's own key when
|
|
1232
|
+
* the caller passed none — a plain upsert recorded as an
|
|
1233
|
+
* `add` of the whole document, and a no-op put as a record. */
|
|
1234
|
+
const readBefore = (core, doc, key) => {
|
|
1235
|
+
const resolved = key !== undefined ? key
|
|
1236
|
+
: core.model.keySegments !== null
|
|
1237
|
+
? extractKey(doc, core.model.keySegments, core.model.key,
|
|
1238
|
+
core.model.name, core.model.docPath)
|
|
1239
|
+
: undefined;
|
|
1240
|
+
return resolved === undefined ? undefined : core.get(resolved);
|
|
1241
|
+
};
|
|
1208
1242
|
/** Journal-mode write wrappers for a collection core. */
|
|
1209
1243
|
const captureCollection = (collectionName, core) => {
|
|
1210
1244
|
if (capture === null) return core;
|
|
@@ -1216,7 +1250,7 @@ export function openStore(model, options) {
|
|
|
1216
1250
|
return key;
|
|
1217
1251
|
})),
|
|
1218
1252
|
put: (doc, key) => guard(() => (journal
|
|
1219
|
-
? chain(
|
|
1253
|
+
? chain(readBefore(core, doc, key), (before) =>
|
|
1220
1254
|
chain(core.put(doc, key), (storedKey) => {
|
|
1221
1255
|
capture.record(collectionName, [storedKey], before ?? null, doc);
|
|
1222
1256
|
return storedKey;
|
|
@@ -1295,16 +1329,24 @@ export function openStore(model, options) {
|
|
|
1295
1329
|
pushableOperators: operators === null || connection.capabilities.userFunctions !== true
|
|
1296
1330
|
? Object.freeze([])
|
|
1297
1331
|
: Object.freeze([...operators.pushableScalar]),
|
|
1332
|
+
// D7's injected clock: whether a temporal spec naming a
|
|
1333
|
+
// ZONE will compile at all here. Without one the document is
|
|
1334
|
+
// refused (`JQ0003`) rather than answered in UTC, and a
|
|
1335
|
+
// consumer that wants to know before it asks reads this
|
|
1336
|
+
zoneProvider: options.zoneProvider !== undefined && options.zoneProvider !== null,
|
|
1298
1337
|
capture: captureMode,
|
|
1299
1338
|
captureLog: captureMode !== 'none'
|
|
1300
1339
|
&& (captureRequested.log === true
|
|
1301
1340
|
|| (captureRequested.log !== undefined && captureRequested.log !== false)),
|
|
1302
|
-
|
|
1341
|
+
// maintenance reads rows synchronously; an asynchronous
|
|
1342
|
+
// connection is never maintained (LIVE-FORMAT §12) and says so
|
|
1343
|
+
live: captureMode !== 'none' && connection.synchronous === true,
|
|
1303
1344
|
jobs: options.jobs === true
|
|
1304
1345
|
|| (options.jobs !== undefined && options.jobs !== false),
|
|
1305
1346
|
});
|
|
1306
1347
|
|
|
1307
|
-
const queryState = createQueryState(options.statementCacheBound, operators
|
|
1348
|
+
const queryState = createQueryState(options.statementCacheBound, operators,
|
|
1349
|
+
options.zoneProvider);
|
|
1308
1350
|
const entityEngine = entities.size > 0
|
|
1309
1351
|
? createEntityQueryEngine({ connection, entities, mapping, state: queryState })
|
|
1310
1352
|
: null;
|
|
@@ -1336,8 +1378,10 @@ export function openStore(model, options) {
|
|
|
1336
1378
|
{ docPath: '/entities', collection: name });
|
|
1337
1379
|
}
|
|
1338
1380
|
const validate = options.compileSchema !== undefined
|
|
1339
|
-
? options.compileSchema(entity
|
|
1381
|
+
? options.compileSchema(writeSchemaOf(entity))
|
|
1340
1382
|
: null;
|
|
1383
|
+
if (validate !== null && typeof validate !== 'function')
|
|
1384
|
+
throw new TypeError('openStore: compileSchema must return a validation function');
|
|
1341
1385
|
core = captureEntity(name, entityCore(connection, entity,
|
|
1342
1386
|
mapping.entities[name], validate));
|
|
1343
1387
|
entityCores.set(name, core);
|
|
@@ -1367,9 +1411,78 @@ export function openStore(model, options) {
|
|
|
1367
1411
|
if (ops !== undefined) return ops;
|
|
1368
1412
|
const core = entityCoreFor(name);
|
|
1369
1413
|
const loads = loadEngineFor(name);
|
|
1414
|
+
/**
|
|
1415
|
+
* The many-to-many memberships a document carries, as join
|
|
1416
|
+
* rows: `create()` attaches them after the insert, in the
|
|
1417
|
+
* same transaction — the `<Name>Input` type and `add()` say
|
|
1418
|
+
* a membership array is writable, and `create()` refusing it
|
|
1419
|
+
* made the generated type a lie.
|
|
1420
|
+
* @param {any} doc
|
|
1421
|
+
*/
|
|
1422
|
+
const membershipsOf = (doc) => {
|
|
1423
|
+
const out = [];
|
|
1424
|
+
for (const property of entities.get(name).properties.values()) {
|
|
1425
|
+
const relation = property.relation;
|
|
1426
|
+
if (relation?.kind !== 'manyToMany') continue;
|
|
1427
|
+
const join = mapping.joinTables[relation.joinTable];
|
|
1428
|
+
const own = join.left.entity === name ? join.left : join.right;
|
|
1429
|
+
const target = own === join.left ? join.right : join.left;
|
|
1430
|
+
const keys = [...new Set(membershipKeys(doc?.[property.name],
|
|
1431
|
+
target.referencesKey, property.name,
|
|
1432
|
+
(reason) => new DbRuntimeError('JD2003', reason,
|
|
1433
|
+
{ docPath: entities.get(name).docPath, collection: name })))];
|
|
1434
|
+
if (keys.length > 0) out.push({ table: relation.joinTable, join, own, target, keys });
|
|
1435
|
+
}
|
|
1436
|
+
return out;
|
|
1437
|
+
};
|
|
1438
|
+
const attach = (made, memberships) => {
|
|
1439
|
+
const ownKey = made[mapping.entities[name].keys[0]];
|
|
1440
|
+
const next = (i) => {
|
|
1441
|
+
if (i >= memberships.length) return null;
|
|
1442
|
+
const { table, join, own, target, keys } = memberships[i];
|
|
1443
|
+
const sql = `INSERT INTO ${dialect.quoteIdentifier(table)} `
|
|
1444
|
+
+ `(${dialect.quoteIdentifier(own.column)}, ${dialect.quoteIdentifier(target.column)}) `
|
|
1445
|
+
+ `VALUES (${dialect.parameterRef(1, 'v')}, ${dialect.parameterRef(2, 'v')})`;
|
|
1446
|
+
return chain(connection.prepare(sql), (statement) => {
|
|
1447
|
+
const row = (j) => {
|
|
1448
|
+
if (j >= keys.length) return next(i + 1);
|
|
1449
|
+
let ran;
|
|
1450
|
+
try {
|
|
1451
|
+
ran = statement.run([ownKey, keys[j]]);
|
|
1452
|
+
}
|
|
1453
|
+
catch (error) {
|
|
1454
|
+
throw new DbRuntimeError('JD2005',
|
|
1455
|
+
`the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
|
|
1456
|
+
{ docPath: entities.get(name).docPath, collection: name, key: ownKey, cause: error });
|
|
1457
|
+
}
|
|
1458
|
+
return chain(ran, () => {
|
|
1459
|
+
if (capture !== null && capture.mode === 'journal') {
|
|
1460
|
+
const value = { [own.column]: ownKey, [target.column]: keys[j] };
|
|
1461
|
+
const ordered = {};
|
|
1462
|
+
for (const column of [join.left.column, join.right.column])
|
|
1463
|
+
ordered[column] = value[column];
|
|
1464
|
+
capture.record(table, Object.values(ordered), null, ordered);
|
|
1465
|
+
}
|
|
1466
|
+
return row(j + 1);
|
|
1467
|
+
});
|
|
1468
|
+
};
|
|
1469
|
+
return row(0);
|
|
1470
|
+
});
|
|
1471
|
+
};
|
|
1472
|
+
return next(0);
|
|
1473
|
+
};
|
|
1370
1474
|
ops = {
|
|
1371
|
-
create: (doc) =>
|
|
1372
|
-
|
|
1475
|
+
create: (doc) => {
|
|
1476
|
+
const memberships = membershipsOf(doc);
|
|
1477
|
+
if (memberships.length === 0)
|
|
1478
|
+
return chain(core.create(doc), (made) => tracker.register(name, made));
|
|
1479
|
+
// one capture scope and one transaction around the row and
|
|
1480
|
+
// its join rows: a membership the database refuses rolls the
|
|
1481
|
+
// row back too, and journal capture records the join rows
|
|
1482
|
+
return chain(guard(() => connection.transaction(() =>
|
|
1483
|
+
chain(core.create(doc), (made) => chain(attach(made, memberships), () => made)))),
|
|
1484
|
+
(made) => tracker.register(name, made));
|
|
1485
|
+
},
|
|
1373
1486
|
get: (key) => chain(core.get(key), (doc) =>
|
|
1374
1487
|
(doc === undefined ? undefined : tracker.register(name, doc))),
|
|
1375
1488
|
update: (key, changes) => chain(core.update(key, changes),
|
|
@@ -1385,6 +1498,10 @@ export function openStore(model, options) {
|
|
|
1385
1498
|
put: (next) => tracker.put(name, next),
|
|
1386
1499
|
remove: (keyOrDoc) => tracker.remove(name, keyOrDoc),
|
|
1387
1500
|
discard: (keyOrDoc) => tracker.discard(name, keyOrDoc),
|
|
1501
|
+
// membership (§11.7): local bookkeeping like add/put/remove;
|
|
1502
|
+
// the join rows are written by saveChanges()
|
|
1503
|
+
link: (own, member, target) => tracker.link(name, own, member, target),
|
|
1504
|
+
unlink: (own, member, target) => tracker.unlink(name, own, member, target),
|
|
1388
1505
|
noTracking: {
|
|
1389
1506
|
get: (key) => core.get(key),
|
|
1390
1507
|
load: (spec) => loads.load(spec),
|
|
@@ -1435,7 +1552,22 @@ export function openStore(model, options) {
|
|
|
1435
1552
|
put: ops.put,
|
|
1436
1553
|
remove: ops.remove,
|
|
1437
1554
|
discard: ops.discard,
|
|
1555
|
+
link: ops.link,
|
|
1556
|
+
unlink: ops.unlink,
|
|
1438
1557
|
asNoTracking: () => untracked,
|
|
1558
|
+
// the provider contract over ONE entity root (MODEL-FORMAT
|
|
1559
|
+
// §10.1): the document is over the multi-entity root and
|
|
1560
|
+
// goes to the entity engine whole; `root` is the hint a
|
|
1561
|
+
// chain binds its items through, `scope` the identity two
|
|
1562
|
+
// sets of one store share so their documents may be joined
|
|
1563
|
+
// (it carries every root's `relations`, so a hop may chain),
|
|
1564
|
+
// `relations` this entity's own relation table (§10.1).
|
|
1565
|
+
// `execute` stays value-or-promise (D2), as a collection's
|
|
1566
|
+
execute: (document, queryOptions) => entityEngine.execute(document, queryOptions),
|
|
1567
|
+
explain: lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
|
|
1568
|
+
root: entityRoot(name),
|
|
1569
|
+
scope: entityEngine,
|
|
1570
|
+
relations: entityEngine.relations[name],
|
|
1439
1571
|
});
|
|
1440
1572
|
asyncEntityHandles.set(name, handle);
|
|
1441
1573
|
}
|
|
@@ -1443,11 +1575,15 @@ export function openStore(model, options) {
|
|
|
1443
1575
|
},
|
|
1444
1576
|
saveChanges: entities.size === 0 ? undefined
|
|
1445
1577
|
: lift(() => guard(() => tracker.saveChanges())),
|
|
1446
|
-
// entity DOCUMENTS query the multi-entity root at the store
|
|
1578
|
+
// entity DOCUMENTS query the multi-entity root at the store;
|
|
1579
|
+
// `roots` names the entity arrays this provider serves, so a
|
|
1580
|
+
// chain asked to iterate the store itself can refuse by name
|
|
1447
1581
|
execute: entityEngine === null ? undefined
|
|
1448
1582
|
: (document, queryOptions) => entityEngine.execute(document, queryOptions),
|
|
1449
1583
|
explain: entityEngine === null ? undefined
|
|
1450
1584
|
: lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
|
|
1585
|
+
roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
|
|
1586
|
+
relations: entityEngine === null ? undefined : entityEngine.relations,
|
|
1451
1587
|
// entity live queries re-run on invalidation — declared,
|
|
1452
1588
|
// not attempted (LIVE-FORMAT §7)
|
|
1453
1589
|
live: entityEngine === null ? undefined
|
|
@@ -1456,6 +1592,12 @@ export function openStore(model, options) {
|
|
|
1456
1592
|
throw new DbCompileError('JD0050',
|
|
1457
1593
|
'live queries require change capture — open the store with { capture: true }');
|
|
1458
1594
|
}
|
|
1595
|
+
refuseAsyncLive();
|
|
1596
|
+
if (liveOptions?.eventTime !== undefined) {
|
|
1597
|
+
throw new DbCompileError('JD0053',
|
|
1598
|
+
'live eventTime maintains a collection view — an entity document re-runs, '
|
|
1599
|
+
+ 'so a watermark would describe nothing (LIVE-FORMAT §13)');
|
|
1600
|
+
}
|
|
1459
1601
|
const roots = collectEntityRoots(document, entities);
|
|
1460
1602
|
if (roots.size === 0) {
|
|
1461
1603
|
throw new TypeError(
|
|
@@ -1564,6 +1706,8 @@ export function openStore(model, options) {
|
|
|
1564
1706
|
if (connection.synchronous) {
|
|
1565
1707
|
/** @type {Map<string, any>} */
|
|
1566
1708
|
const syncHandles = new Map();
|
|
1709
|
+
/** @type {Map<string, any>} */
|
|
1710
|
+
const syncEntityHandles = new Map();
|
|
1567
1711
|
store.sync = Object.freeze({
|
|
1568
1712
|
collection(name) {
|
|
1569
1713
|
let handle = syncHandles.get(name);
|
|
@@ -1583,31 +1727,61 @@ export function openStore(model, options) {
|
|
|
1583
1727
|
}
|
|
1584
1728
|
return handle;
|
|
1585
1729
|
},
|
|
1586
|
-
transaction: (fn) =>
|
|
1730
|
+
transaction: (fn) => {
|
|
1731
|
+
// the synchronous surface answers values: while a
|
|
1732
|
+
// transaction owns the connection it could only QUEUE,
|
|
1733
|
+
// which handed a Promise back under a value's type
|
|
1734
|
+
if (opened.mustQueue) {
|
|
1735
|
+
throw new DbCompileError('JD0012',
|
|
1736
|
+
'the synchronous transaction cannot wait for the open transaction to '
|
|
1737
|
+
+ 'settle — nest through the store the callback received, or use the '
|
|
1738
|
+
+ 'asynchronous store.transaction()');
|
|
1739
|
+
}
|
|
1740
|
+
return topLevelTransaction(fn);
|
|
1741
|
+
},
|
|
1587
1742
|
entity(name) {
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1743
|
+
let handle = syncEntityHandles.get(name);
|
|
1744
|
+
if (handle === undefined) {
|
|
1745
|
+
const ops = trackedOpsFor(name);
|
|
1746
|
+
const untracked = Object.freeze({
|
|
1747
|
+
get: (key) => ops.noTracking.get(key),
|
|
1748
|
+
load: (spec) => ops.noTracking.load(spec),
|
|
1749
|
+
});
|
|
1750
|
+
handle = Object.freeze({
|
|
1751
|
+
create: (doc) => ops.create(doc),
|
|
1752
|
+
get: (key) => ops.get(key),
|
|
1753
|
+
update: (key, changes) => ops.update(key, changes),
|
|
1754
|
+
delete: (key) => ops.delete(key),
|
|
1755
|
+
load: (spec) => ops.load(spec),
|
|
1756
|
+
explainLoad: ops.explainLoad,
|
|
1757
|
+
add: ops.add,
|
|
1758
|
+
put: ops.put,
|
|
1759
|
+
remove: ops.remove,
|
|
1760
|
+
discard: ops.discard,
|
|
1761
|
+
link: ops.link,
|
|
1762
|
+
unlink: ops.unlink,
|
|
1763
|
+
asNoTracking: () => untracked,
|
|
1764
|
+
// the same provider members as the asynchronous handle,
|
|
1765
|
+
// answering values; one handle per name, so two chains
|
|
1766
|
+
// over one set share one source identity
|
|
1767
|
+
execute: (document, queryOptions) => entityEngine.execute(document, queryOptions),
|
|
1768
|
+
explain: (document, queryOptions) => entityEngine.explain(document, queryOptions),
|
|
1769
|
+
root: entityRoot(name),
|
|
1770
|
+
scope: entityEngine,
|
|
1771
|
+
relations: entityEngine.relations[name],
|
|
1772
|
+
});
|
|
1773
|
+
syncEntityHandles.set(name, handle);
|
|
1774
|
+
}
|
|
1775
|
+
return handle;
|
|
1606
1776
|
},
|
|
1607
1777
|
saveChanges: entities.size === 0 ? undefined
|
|
1608
1778
|
: () => guard(() => tracker.saveChanges()),
|
|
1609
1779
|
execute: entityEngine === null ? undefined
|
|
1610
1780
|
: (document, queryOptions) => entityEngine.execute(document, queryOptions),
|
|
1781
|
+
explain: entityEngine === null ? undefined
|
|
1782
|
+
: (document, queryOptions) => entityEngine.explain(document, queryOptions),
|
|
1783
|
+
roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
|
|
1784
|
+
relations: entityEngine === null ? undefined : entityEngine.relations,
|
|
1611
1785
|
});
|
|
1612
1786
|
}
|
|
1613
1787
|
return chain(capture === null ? null : capture.ready,
|