@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.
Files changed (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -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/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. 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
- 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)) {
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
- let out;
598
- try {
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
- try {
677
- statement.run([...params, ...derivedFor(next), key]);
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 pair = joinName.split('_');
1111
- 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' }));
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.keys(mapping?.joinTables ?? {})
1189
- .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);
1190
1212
  const nextJoin = (i) => {
1191
1213
  if (i >= joins.length) return null;
1192
- const joinName = joins[i];
1193
- const pair = joinName.split('_');
1194
- 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(', ')} `
1195
1218
  + `FROM ${dialect.quoteIdentifier(joinName)} `
1196
- + `WHERE ${dialect.quoteIdentifier(`${entityName}_key`)} = ${dialect.parameterRef(1, 'v')}`;
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(key === undefined ? undefined : core.get(key), (before) =>
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
- 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,
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.schema)
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) => chain(core.create(doc),
1372
- (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
+ },
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) => 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
+ },
1587
1742
  entity(name) {
1588
- const ops = trackedOpsFor(name);
1589
- const untracked = Object.freeze({
1590
- get: (key) => ops.noTracking.get(key),
1591
- load: (spec) => ops.noTracking.load(spec),
1592
- });
1593
- return Object.freeze({
1594
- create: (doc) => ops.create(doc),
1595
- get: (key) => ops.get(key),
1596
- update: (key, changes) => ops.update(key, changes),
1597
- delete: (key) => ops.delete(key),
1598
- load: (spec) => ops.load(spec),
1599
- explainLoad: ops.explainLoad,
1600
- add: ops.add,
1601
- put: ops.put,
1602
- remove: ops.remove,
1603
- discard: ops.discard,
1604
- asNoTracking: () => untracked,
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,