@jarenjs/db 0.49.2 → 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.
Files changed (70) hide show
  1. package/ARCHITECTURE.md +27 -15
  2. package/README.md +141 -41
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +38 -9
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +232 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/capture.js +66 -28
  11. package/src/cli.js +225 -44
  12. package/src/ddl.js +23 -3
  13. package/src/dialects/sqlite.js +2 -1
  14. package/src/driver.js +63 -16
  15. package/src/drivers/wasm.js +1 -0
  16. package/src/emit-model.js +14 -0
  17. package/src/emit.js +10 -3
  18. package/src/entity.js +92 -47
  19. package/src/errors.js +25 -0
  20. package/src/index.js +2 -2
  21. package/src/jobs.js +40 -5
  22. package/src/live-time.js +12 -3
  23. package/src/live.js +11 -1
  24. package/src/migrate.js +397 -191
  25. package/src/model.js +173 -8
  26. package/src/plan.js +135 -38
  27. package/src/query.js +138 -13
  28. package/src/store.js +221 -66
  29. package/src/tracker.js +173 -48
  30. package/types/index.d.ts +152 -10
  31. package/types/node.d.ts +3 -1
  32. package/types/typed.d.ts +58 -2
  33. package/types/wasm.d.ts +7 -0
  34. package/dist/types/algebra.d.ts +0 -230
  35. package/dist/types/app.d.ts +0 -49
  36. package/dist/types/capture.d.ts +0 -85
  37. package/dist/types/cli.d.ts +0 -2
  38. package/dist/types/dag-job.d.ts +0 -40
  39. package/dist/types/ddl.d.ts +0 -229
  40. package/dist/types/derive.d.ts +0 -250
  41. package/dist/types/dialect.d.ts +0 -154
  42. package/dist/types/dialects/sqlite.d.ts +0 -9
  43. package/dist/types/driver.d.ts +0 -110
  44. package/dist/types/drivers/bun.d.ts +0 -47
  45. package/dist/types/drivers/node.d.ts +0 -37
  46. package/dist/types/drivers/wasm.d.ts +0 -65
  47. package/dist/types/emit-model.d.ts +0 -44
  48. package/dist/types/emit.d.ts +0 -75
  49. package/dist/types/entity.d.ts +0 -23
  50. package/dist/types/errors.d.ts +0 -170
  51. package/dist/types/graph.d.ts +0 -28
  52. package/dist/types/index.d.ts +0 -37
  53. package/dist/types/jobs.d.ts +0 -140
  54. package/dist/types/knn.d.ts +0 -69
  55. package/dist/types/live-time.d.ts +0 -141
  56. package/dist/types/live.d.ts +0 -64
  57. package/dist/types/migrate.d.ts +0 -170
  58. package/dist/types/model.d.ts +0 -36
  59. package/dist/types/patch-sql.d.ts +0 -37
  60. package/dist/types/plan.d.ts +0 -142
  61. package/dist/types/profile.d.ts +0 -80
  62. package/dist/types/query.d.ts +0 -112
  63. package/dist/types/residual.d.ts +0 -64
  64. package/dist/types/series.d.ts +0 -227
  65. package/dist/types/store.d.ts +0 -60
  66. package/dist/types/tracker.d.ts +0 -43
  67. package/dist/types/typed.d.ts +0 -15
  68. package/dist/types/types.d.ts +0 -26
  69. package/dist/types/udf.d.ts +0 -75
  70. package/dist/types/window.d.ts +0 -52
package/src/store.js CHANGED
@@ -23,20 +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
37
  import { normalizeEventTime } from './live-time.js';
38
38
  import { createJobEngine } from './jobs.js';
39
- import { collectEntityRoots } from './plan.js';
39
+ import { collectEntityRoots, entityRoot } from './plan.js';
40
40
  import {
41
41
  DERIVE_KINDS, PHYSICAL_KINDS, PRECISION_MIN, PRECISION_MAX, DIMS_MIN, DIMS_MAX,
42
42
  derivedValue, memberAt, storedMemberForm, registerDeriveFunctions,
@@ -369,13 +369,6 @@ function requireKey(key, collection, docPath) {
369
369
  * @param {string} keyColumn
370
370
  * @returns {boolean}
371
371
  */
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
372
  /**
380
373
  * Wrap a database failure for one collection operation.
381
374
  * @param {any} error
@@ -386,7 +379,10 @@ function isDuplicateKey(error, table, keyColumn) {
386
379
  * @returns {DbRuntimeError}
387
380
  */
388
381
  function wrapWriteError(error, plan, collection, docPath, key) {
389
- if (isDuplicateKey(error, plan.table, plan.keyColumn)) {
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)) {
390
386
  return new DbRuntimeError('JD2001',
391
387
  `a document already exists under key '${String(key)}'`,
392
388
  { docPath, collection, key, cause: error });
@@ -594,16 +590,9 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
594
590
  };
595
591
 
596
592
  const runWrite = (statementName, sql, params, key, reads) => {
597
- return chain(prepared(statementName, sql), (statement) => {
598
- let out;
599
- try {
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
- });
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)));
607
596
  };
608
597
 
609
598
  const core = {
@@ -673,15 +662,10 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
673
662
  const { expression, params } = translated.build(
674
663
  dialect.quoteIdentifier(plan.docColumn), 1);
675
664
  const sql = dialect.dml.updateDoc(shape, expression, params.length + 1);
676
- return chain(prepared(`patch:${sql}`, sql), (statement) => {
677
- try {
678
- statement.run([...params, ...derivedFor(next), key]);
679
- }
680
- catch (error) {
681
- throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
682
- }
683
- return next;
684
- });
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));
685
669
  });
686
670
  },
687
671
  delete(key) {
@@ -756,6 +740,26 @@ function asyncCollection(core, live) {
756
740
  * @param {any} options
757
741
  * @returns {{ functions: any, extensions: any } | null}
758
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
+
759
763
  function resolveOperators(options) {
760
764
  const registry = options.operators;
761
765
  const hasRegistry = registry !== undefined && registry !== null;
@@ -1114,8 +1118,9 @@ export function openStore(model, options) {
1114
1118
  });
1115
1119
  }
1116
1120
  for (const joinName of Object.keys(mapping?.joinTables ?? {})) {
1117
- const pair = joinName.split('_');
1118
- const columns = pair.map((part) => ({ name: `${part}_key`, role: 'key' }));
1121
+ const join = mapping.joinTables[joinName];
1122
+ const columns = [join.left, join.right]
1123
+ .map((side) => ({ name: side.column, role: 'key' }));
1119
1124
  captureShapes.set(joinName, {
1120
1125
  kind: 'join', columns,
1121
1126
  keyIndexes: columns.map((_, i) => i), docIndex: -1,
@@ -1155,7 +1160,15 @@ export function openStore(model, options) {
1155
1160
  defaults: typeof options.jobs === 'object' ? options.jobs : undefined,
1156
1161
  });
1157
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
+ };
1158
1170
  const registerCollectionLive = (core, document, liveOptions) => {
1171
+ refuseAsyncLive();
1159
1172
  const externals = liveOptions?.externals ?? {};
1160
1173
  const keyed = core.model.keySegments !== null;
1161
1174
  const eventTime = normalizeEventTime(liveOptions, core.model.name);
@@ -1193,26 +1206,39 @@ export function openStore(model, options) {
1193
1206
  const captureJoinDelete = capture === null || capture.mode !== 'journal'
1194
1207
  ? null
1195
1208
  : (entityName, keyParts) => {
1196
- const joins = Object.keys(mapping?.joinTables ?? {})
1197
- .filter((joinName) => joinName.split('_').includes(entityName));
1209
+ const joins = Object.entries(mapping?.joinTables ?? {})
1210
+ .filter(([, join]) => join.left.entity === entityName
1211
+ || join.right.entity === entityName);
1198
1212
  const nextJoin = (i) => {
1199
1213
  if (i >= joins.length) return null;
1200
- const joinName = joins[i];
1201
- const pair = joinName.split('_');
1202
- const sql = `SELECT ${pair.map((part) => dialect.quoteIdentifier(`${part}_key`)).join(', ')} `
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(', ')} `
1203
1218
  + `FROM ${dialect.quoteIdentifier(joinName)} `
1204
- + `WHERE ${dialect.quoteIdentifier(`${entityName}_key`)} = ${dialect.parameterRef(1, 'v')}`;
1219
+ + `WHERE ${dialect.quoteIdentifier(own.column)} = ${dialect.parameterRef(1, 'v')}`;
1205
1220
  return chain(connection.prepare(sql), (statement) =>
1206
1221
  chain(statement.all([keyParts[0]]), (rows) => {
1207
1222
  for (const row of rows) {
1208
- capture.record(joinName,
1209
- pair.map((part) => row[`${part}_key`]), undefined, null);
1223
+ capture.record(joinName, columns.map((column) => row[column]), undefined, null);
1210
1224
  }
1211
1225
  return nextJoin(i + 1);
1212
1226
  }));
1213
1227
  };
1214
1228
  return nextJoin(0);
1215
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
+ };
1216
1242
  /** Journal-mode write wrappers for a collection core. */
1217
1243
  const captureCollection = (collectionName, core) => {
1218
1244
  if (capture === null) return core;
@@ -1224,7 +1250,7 @@ export function openStore(model, options) {
1224
1250
  return key;
1225
1251
  })),
1226
1252
  put: (doc, key) => guard(() => (journal
1227
- ? chain(key === undefined ? undefined : core.get(key), (before) =>
1253
+ ? chain(readBefore(core, doc, key), (before) =>
1228
1254
  chain(core.put(doc, key), (storedKey) => {
1229
1255
  capture.record(collectionName, [storedKey], before ?? null, doc);
1230
1256
  return storedKey;
@@ -1312,7 +1338,9 @@ export function openStore(model, options) {
1312
1338
  captureLog: captureMode !== 'none'
1313
1339
  && (captureRequested.log === true
1314
1340
  || (captureRequested.log !== undefined && captureRequested.log !== false)),
1315
- live: captureMode !== 'none',
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,
1316
1344
  jobs: options.jobs === true
1317
1345
  || (options.jobs !== undefined && options.jobs !== false),
1318
1346
  });
@@ -1350,8 +1378,10 @@ export function openStore(model, options) {
1350
1378
  { docPath: '/entities', collection: name });
1351
1379
  }
1352
1380
  const validate = options.compileSchema !== undefined
1353
- ? options.compileSchema(entity.schema)
1381
+ ? options.compileSchema(writeSchemaOf(entity))
1354
1382
  : null;
1383
+ if (validate !== null && typeof validate !== 'function')
1384
+ throw new TypeError('openStore: compileSchema must return a validation function');
1355
1385
  core = captureEntity(name, entityCore(connection, entity,
1356
1386
  mapping.entities[name], validate));
1357
1387
  entityCores.set(name, core);
@@ -1381,9 +1411,78 @@ export function openStore(model, options) {
1381
1411
  if (ops !== undefined) return ops;
1382
1412
  const core = entityCoreFor(name);
1383
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
+ };
1384
1474
  ops = {
1385
- create: (doc) => chain(core.create(doc),
1386
- (made) => tracker.register(name, made)),
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
+ },
1387
1486
  get: (key) => chain(core.get(key), (doc) =>
1388
1487
  (doc === undefined ? undefined : tracker.register(name, doc))),
1389
1488
  update: (key, changes) => chain(core.update(key, changes),
@@ -1399,6 +1498,10 @@ export function openStore(model, options) {
1399
1498
  put: (next) => tracker.put(name, next),
1400
1499
  remove: (keyOrDoc) => tracker.remove(name, keyOrDoc),
1401
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),
1402
1505
  noTracking: {
1403
1506
  get: (key) => core.get(key),
1404
1507
  load: (spec) => loads.load(spec),
@@ -1449,7 +1552,22 @@ export function openStore(model, options) {
1449
1552
  put: ops.put,
1450
1553
  remove: ops.remove,
1451
1554
  discard: ops.discard,
1555
+ link: ops.link,
1556
+ unlink: ops.unlink,
1452
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],
1453
1571
  });
1454
1572
  asyncEntityHandles.set(name, handle);
1455
1573
  }
@@ -1457,11 +1575,15 @@ export function openStore(model, options) {
1457
1575
  },
1458
1576
  saveChanges: entities.size === 0 ? undefined
1459
1577
  : lift(() => guard(() => tracker.saveChanges())),
1460
- // 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
1461
1581
  execute: entityEngine === null ? undefined
1462
1582
  : (document, queryOptions) => entityEngine.execute(document, queryOptions),
1463
1583
  explain: entityEngine === null ? undefined
1464
1584
  : lift((document, queryOptions) => entityEngine.explain(document, queryOptions)),
1585
+ roots: entityEngine === null ? undefined : Object.freeze([...entities.keys()]),
1586
+ relations: entityEngine === null ? undefined : entityEngine.relations,
1465
1587
  // entity live queries re-run on invalidation — declared,
1466
1588
  // not attempted (LIVE-FORMAT §7)
1467
1589
  live: entityEngine === null ? undefined
@@ -1470,6 +1592,7 @@ export function openStore(model, options) {
1470
1592
  throw new DbCompileError('JD0050',
1471
1593
  'live queries require change capture — open the store with { capture: true }');
1472
1594
  }
1595
+ refuseAsyncLive();
1473
1596
  if (liveOptions?.eventTime !== undefined) {
1474
1597
  throw new DbCompileError('JD0053',
1475
1598
  'live eventTime maintains a collection view — an entity document re-runs, '
@@ -1583,6 +1706,8 @@ export function openStore(model, options) {
1583
1706
  if (connection.synchronous) {
1584
1707
  /** @type {Map<string, any>} */
1585
1708
  const syncHandles = new Map();
1709
+ /** @type {Map<string, any>} */
1710
+ const syncEntityHandles = new Map();
1586
1711
  store.sync = Object.freeze({
1587
1712
  collection(name) {
1588
1713
  let handle = syncHandles.get(name);
@@ -1602,31 +1727,61 @@ export function openStore(model, options) {
1602
1727
  }
1603
1728
  return handle;
1604
1729
  },
1605
- transaction: (fn) => topLevelTransaction(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
+ },
1606
1742
  entity(name) {
1607
- const ops = trackedOpsFor(name);
1608
- const untracked = Object.freeze({
1609
- get: (key) => ops.noTracking.get(key),
1610
- load: (spec) => ops.noTracking.load(spec),
1611
- });
1612
- return Object.freeze({
1613
- create: (doc) => ops.create(doc),
1614
- get: (key) => ops.get(key),
1615
- update: (key, changes) => ops.update(key, changes),
1616
- delete: (key) => ops.delete(key),
1617
- load: (spec) => ops.load(spec),
1618
- explainLoad: ops.explainLoad,
1619
- add: ops.add,
1620
- put: ops.put,
1621
- remove: ops.remove,
1622
- discard: ops.discard,
1623
- asNoTracking: () => untracked,
1624
- });
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;
1625
1776
  },
1626
1777
  saveChanges: entities.size === 0 ? undefined
1627
1778
  : () => guard(() => tracker.saveChanges()),
1628
1779
  execute: entityEngine === null ? undefined
1629
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,
1630
1785
  });
1631
1786
  }
1632
1787
  return chain(capture === null ? null : capture.ready,