@jarenjs/db 0.73.0 → 0.83.2

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 (53) hide show
  1. package/ARCHITECTURE.md +70 -7
  2. package/README.md +69 -6
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +52 -13
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +163 -15
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/REPLICATION-FORMAT.md +19 -13
  10. package/docs/SEARCH.md +55 -0
  11. package/package.json +8 -4
  12. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  13. package/schemas/jaren-migration.schema.json +49 -0
  14. package/schemas/jaren-model.authoring.schema.json +360 -0
  15. package/schemas/jaren-model.draft-07.schema.json +128 -0
  16. package/schemas/jaren-model.schema.json +128 -0
  17. package/src/algebra.js +26 -4
  18. package/src/backup.js +12 -7
  19. package/src/cursor.js +27 -4
  20. package/src/dag-job.js +2 -1
  21. package/src/ddl.js +13 -0
  22. package/src/derive.js +14 -3
  23. package/src/dialect.js +12 -0
  24. package/src/dialects/check-read.js +151 -0
  25. package/src/dialects/invariant-sql.js +117 -0
  26. package/src/dialects/postgres.js +28 -4
  27. package/src/dialects/sqlite.js +23 -3
  28. package/src/driver.js +1 -0
  29. package/src/drivers/bun.js +22 -4
  30. package/src/emit.js +133 -25
  31. package/src/entity.js +98 -41
  32. package/src/errors.js +8 -0
  33. package/src/graph.js +8 -1
  34. package/src/index.js +3 -0
  35. package/src/introspect.js +81 -12
  36. package/src/invariants.js +45 -0
  37. package/src/jobs.js +39 -6
  38. package/src/live-nested.js +27 -10
  39. package/src/live.js +51 -136
  40. package/src/migrate.js +136 -22
  41. package/src/model.js +12 -0
  42. package/src/mutation.js +165 -0
  43. package/src/physical.js +147 -0
  44. package/src/plan.js +275 -64
  45. package/src/query.js +175 -78
  46. package/src/search.js +144 -0
  47. package/src/sql.js +60 -0
  48. package/src/store.js +49 -13
  49. package/src/tracker.js +63 -39
  50. package/src/window.js +1 -0
  51. package/types/index.d.ts +59 -4
  52. package/types/search.d.ts +20 -0
  53. package/types/typed.d.ts +1 -0
package/src/query.js CHANGED
@@ -31,6 +31,8 @@
31
31
  * the whole document runs over them.
32
32
  */
33
33
 
34
+ import { physicalSelection, columnCodec } from './physical.js';
35
+
34
36
  import { createSemanticCache } from '@jarenjs/core/cache';
35
37
  import { analyzeQuery } from '@jarenjs/json/query';
36
38
 
@@ -47,7 +49,7 @@ import {
47
49
  } from './residual.js';
48
50
  import { createCursor, createSyncCursor, drainPage, utf8Length, PAGE_LIMIT_DEFAULT, rowClassOf } from './cursor.js';
49
51
  import { deepFreeze } from '@jarenjs/core/object';
50
- import { derivedSlotValue, probeBox, probeVector, columnScore } from './derive.js';
52
+ import { derivedSlotValue, probeVector, columnScore } from './derive.js';
51
53
  import { cutCandidates, identityBatches } from './knn.js';
52
54
  import {
53
55
  deterministicFragment, registerFragment, registerAggregateOperator,
@@ -149,7 +151,8 @@ function slotValue(slot, externals, anchors = null) {
149
151
  return value === undefined ? undefined : JSON.stringify(value);
150
152
  }
151
153
  if ('derived' in slot)
152
- return derivedSlotValue(slot.derived, externals[slot.derived.external]);
154
+ return derivedSlotValue(slot.derived, slot.derived.kind === 'bboxAxis'
155
+ ? externals[slot.derived.external] : externals);
153
156
  if ('typed' in slot) {
154
157
  // a typed slot is only ever emitted beside the seek that fills it,
155
158
  // so a bind that never resolved the seeks is a defect in the
@@ -179,8 +182,14 @@ function externalSlotKinds(slots, rank) {
179
182
  const kinds = new Map();
180
183
  for (const slot of slots) {
181
184
  if ('external' in slot) kinds.set(slot.external, 'plain');
182
- else if ('derived' in slot && !kinds.has(slot.derived.external))
183
- kinds.set(slot.derived.external, 'derived');
185
+ else if ('derived' in slot) {
186
+ const inputs = slot.derived.kind === 'bboxAxis'
187
+ ? [slot.derived] : [slot.derived.centre, slot.derived.radius];
188
+ for (const input of inputs) {
189
+ if ('external' in input && !kinds.has(input.external))
190
+ kinds.set(input.external, 'derived');
191
+ }
192
+ }
184
193
  }
185
194
  if (rank !== null && 'ext' in rank.probe && !kinds.has(rank.probe.ext))
186
195
  kinds.set(rank.probe.ext, 'probe');
@@ -285,7 +294,11 @@ function segmentsOf(ref) {
285
294
  * @param {string} suffix
286
295
  * @returns {any[]} the item, or nothing where the member is absent
287
296
  */
288
- function leafItems(row, suffix) {
297
+ function leafItems(row, suffix, column = undefined) {
298
+ if (column !== undefined) {
299
+ const value = columnCodec(column).decode(row[`v${suffix}`]);
300
+ return value === undefined ? [] : [value];
301
+ }
289
302
  const type = row[`t${suffix}`];
290
303
  if (type === null || type === undefined) return [];
291
304
  const value = row[`v${suffix}`];
@@ -314,7 +327,8 @@ function leafItems(row, suffix) {
314
327
  function projectedTreeItems(project, row, prefix = '') {
315
328
  const build = (node) => {
316
329
  if (node.p === 'lit') return [node.value];
317
- if (node.p === 'leaf') return leafItems(row, `${prefix}${node.index}`);
330
+ if (node.p === 'leaf') return leafItems(row, `${prefix}${node.index}`,
331
+ project.leaves[node.index].ref?.codecColumn);
318
332
  if (node.p === 'object') {
319
333
  /** @type {any} */
320
334
  const out = {};
@@ -331,6 +345,47 @@ function projectedTreeItems(project, row, prefix = '') {
331
345
  return build(project.tree);
332
346
  }
333
347
 
348
+ /**
349
+ * The items a GENERAL grouping answers: one per group row, built from
350
+ * the group's keys and aggregates through the plan's own tree. A key
351
+ * comes back with its JSON type beside it, so an absent key leaves
352
+ * its member out exactly as the object constructor does; an aggregate
353
+ * over no values follows the mapping the plan recorded — `0` for a
354
+ * count or a sum, the empty sequence for the other three, which is
355
+ * the ENGINE's answer, not SQL's `NULL`.
356
+ * @param {any} entry
357
+ * @param {any[]} rows
358
+ * @returns {any[]}
359
+ */
360
+ function groupItems(group, rows) {
361
+ const aggregateItems = (row, index) => {
362
+ const value = row[`a${index}`] ?? null;
363
+ const empty = group.aggregates[index].empty;
364
+ if (value === null) return empty === 'omit' ? [] : [empty === 'zero' ? 0 : null];
365
+ const aggregate = group.aggregates[index];
366
+ return [aggregate.ref?.codecColumn && ['min', 'max'].includes(aggregate.fn)
367
+ ? columnCodec(aggregate.ref.codecColumn).decode(value) : value];
368
+ };
369
+ const build = (node, row) => {
370
+ if (node.p === 'lit') return [node.value];
371
+ if (node.p === 'key') return leafItems(row, `k${node.index}`, group.keys[node.index].ref.codecColumn);
372
+ if (node.p === 'agg') return aggregateItems(row, node.index);
373
+ if (node.p === 'object') {
374
+ /** @type {any} */
375
+ const out = {};
376
+ for (const member of node.members) {
377
+ const items = build(member.node, row);
378
+ if (items.length > 0) out[member.name] = items[0];
379
+ }
380
+ return [out];
381
+ }
382
+ const items = [];
383
+ for (const item of node.items) items.push(...build(item, row));
384
+ return [items];
385
+ };
386
+ return rows.flatMap((row) => build(group.tree, row));
387
+ }
388
+
334
389
  /**
335
390
  * The query engine for one collection.
336
391
  * @param {{ connection: any, state: any, collection: any,
@@ -510,7 +565,6 @@ export function createQueryEngine(context) {
510
565
  : { ...planned.series, mode: 'engine', index: null, prefix: [] },
511
566
  };
512
567
  }
513
-
514
568
  if (profile !== null) {
515
569
  // the member allow-list: what the caller may OBTAIN, checked
516
570
  // against every member path the document references, before a
@@ -792,7 +846,15 @@ export function createQueryEngine(context) {
792
846
  const divertingExternal = (entry, externals) =>
793
847
  entry.externalNames.find((name) => {
794
848
  const kind = entry.externalSlotKinds.get(name);
795
- if (kind === 'derived') return probeBox(externals[name]) === null;
849
+ const invalidDerived = entry.slots.some((slot) => {
850
+ if (!('derived' in slot)) return false;
851
+ const inputs = slot.derived.kind === 'bboxAxis'
852
+ ? [slot.derived] : [slot.derived.centre, slot.derived.radius];
853
+ return inputs.some((input) => 'external' in input && input.external === name)
854
+ && !bindable(slotValue(slot, externals));
855
+ });
856
+ if (invalidDerived) return true;
857
+ if (kind === 'derived') return false;
796
858
  // a probe binds when SOME declared width takes it; a width the
797
859
  // model does not declare is the diversion it always was
798
860
  if (kind === 'probe') return rankAlternativeFor(entry, externals[name]) === null;
@@ -834,46 +896,6 @@ export function createQueryEngine(context) {
834
896
  return items;
835
897
  };
836
898
 
837
- /**
838
- * The items a GENERAL grouping answers: one per group row, built from
839
- * the group's keys and aggregates through the plan's own tree. A key
840
- * comes back with its JSON type beside it, so an absent key leaves
841
- * its member out exactly as the object constructor does; an aggregate
842
- * over no values follows the mapping the plan recorded — `0` for a
843
- * count or a sum, the empty sequence for the other three, which is
844
- * the ENGINE's answer, not SQL's `NULL`.
845
- * @param {any} entry
846
- * @param {any[]} rows
847
- * @returns {any[]}
848
- */
849
- const groupItems = (entry, rows) => {
850
- const group = entry.plan.group;
851
- const aggregateItems = (row, index) => {
852
- const value = row[`a${index}`] ?? null;
853
- const empty = group.aggregates[index].empty;
854
- if (value === null) return empty === 'omit' ? [] : [empty === 'zero' ? 0 : null];
855
- return [value];
856
- };
857
- const build = (node, row) => {
858
- if (node.p === 'lit') return [node.value];
859
- if (node.p === 'key') return leafItems(row, `k${node.index}`);
860
- if (node.p === 'agg') return aggregateItems(row, node.index);
861
- if (node.p === 'object') {
862
- /** @type {any} */
863
- const out = {};
864
- for (const member of node.members) {
865
- const items = build(member.node, row);
866
- if (items.length > 0) out[member.name] = items[0];
867
- }
868
- return [out];
869
- }
870
- const items = [];
871
- for (const item of node.items) items.push(...build(item, row));
872
- return [items];
873
- };
874
- return rows.flatMap((row) => build(group.tree, row));
875
- };
876
-
877
899
  /** How many ITEMS an engine result carries (its own shape rule). */
878
900
  const itemCount = (answer) => (answer === undefined ? 0
879
901
  : Array.isArray(answer) ? answer.length : 1);
@@ -1096,7 +1118,7 @@ export function createQueryEngine(context) {
1096
1118
  }
1097
1119
  if (entry.plan.group !== null) {
1098
1120
  return chain(runAll(entry, externals, statement), (rows) =>
1099
- answerOf(entry, groupItems(entry, checkRowBound(entry, rows))));
1121
+ answerOf(entry, groupItems(entry.plan.group, checkRowBound(entry, rows))));
1100
1122
  }
1101
1123
  if (entry.plan.bucket !== null) {
1102
1124
  return chain(runAll(entry, externals, statement), (rows) => {
@@ -1193,12 +1215,12 @@ export function createQueryEngine(context) {
1193
1215
  return items;
1194
1216
  })) });
1195
1217
  }
1196
- if (entry.plan.group !== null) {
1218
+ if (entry.plan.group !== null && entry.plan.aggregate === null) {
1197
1219
  // a native grouping is a barrier: the groups are the answer
1198
1220
  return createCursor({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1199
1221
  materialize: () => chain(guardScan(entry), () => chain(statementOf(entry), (statement) =>
1200
1222
  chain(runAll(entry, externals, statement), (rows) =>
1201
- groupItems(entry, checkRowBound(entry, rows))))) });
1223
+ groupItems(entry.plan.group, checkRowBound(entry, rows))))) });
1202
1224
  }
1203
1225
  if (entry.plan.bucket !== null) {
1204
1226
  // a native bucket is a barrier: the groups are the answer
@@ -1295,6 +1317,10 @@ export function createQueryEngine(context) {
1295
1317
  else if (pred.p === 'bboxRtree') touchedVirtual.add(pred.table);
1296
1318
  else if (pred.p === 'cellIn' || pred.p === 'cellPrefix')
1297
1319
  touchedColumns.add(pred.column);
1320
+ else if (pred.p === 'refCmp') {
1321
+ if (pred.left.column) touchedColumns.add(pred.left.column);
1322
+ if (pred.right.column) touchedColumns.add(pred.right.column);
1323
+ }
1298
1324
  else if ('ref' in pred && pred.ref?.column) touchedColumns.add(pred.ref.column);
1299
1325
  };
1300
1326
  collectColumns(entry.plan.filter);
@@ -1383,7 +1409,10 @@ export function createQueryEngine(context) {
1383
1409
  fn: entry2.fn, path: entry2.ref === null ? null : segmentsOf(entry2.ref) })),
1384
1410
  order: entry.plan.group.order === 'first-seen' ? 'first-seen'
1385
1411
  : entry.plan.group.order.map((term) => ({
1386
- key: entry.plan.group.keys[term.index].as, desc: term.desc })),
1412
+ ...(term.aggregate === undefined ? { key: entry.plan.group.keys[term.index].as }
1413
+ : { aggregate: entry.plan.group.aggregates[term.aggregate].fn,
1414
+ path: entry.plan.group.aggregates[term.aggregate].ref?.segments ?? null }),
1415
+ desc: term.desc })),
1387
1416
  },
1388
1417
  // a chain's element window (`[<phrase>]`): the phrase planned as
1389
1418
  // if bare, its rows answered as the one array item
@@ -1577,7 +1606,11 @@ export function createEntityQueryEngine(context) {
1577
1606
  const q = dialect.quoteIdentifier;
1578
1607
  const physicalOf = (name) => ({ table: mapping.entities[name].table,
1579
1608
  // a join-table root has no document column of its own (§10.7)
1580
- document: mapping.entities[name].document !== false });
1609
+ document: mapping.entities[name].document !== false,
1610
+ ...(entities.get(name)?.physical == null ? {} : {
1611
+ mapping: mapping.entities[name],
1612
+ keys: mapping.entities[name].keys.map((key) => mapping.entities[name].columns.find((c) => c.name === key).physical),
1613
+ }) });
1581
1614
  // the relation tables of every root this engine serves (§10.1): the
1582
1615
  // engine is the scope every entity set of the store shares, so a
1583
1616
  // producer holding one set can follow a hop into another root
@@ -1620,6 +1653,10 @@ export function createEntityQueryEngine(context) {
1620
1653
  planned = { ...planned, mode: 'set', plan: null,
1621
1654
  reasons: [{ construct: 'pushdown', reason: BIND_REASONS.pushdown }] };
1622
1655
  }
1656
+ if (planned.plan?.group && dialect.name !== 'sqlite') {
1657
+ planned = { ...planned, mode: 'set', plan: null,
1658
+ reasons: [{ construct: '$groupby', reason: 'entity grouping runtime guards are qualified for SQLite' }] };
1659
+ }
1623
1660
  const docPath = entities.get(planned.referenced[0])?.docPath;
1624
1661
  // the profile, applied exactly as on a collection (MODEL-FORMAT §8):
1625
1662
  // the roots a document may read, the references it may make, the
@@ -1678,6 +1715,8 @@ export function createEntityQueryEngine(context) {
1678
1715
  residualLimits: profile === null ? undefined : profile.limits,
1679
1716
  needsScanCheck: profile !== null && profile.refuseFullScan === true,
1680
1717
  scanChecked: false,
1718
+ admitted: null,
1719
+ runtimeReason: null,
1681
1720
  };
1682
1721
  if (planned.mode === 'native') {
1683
1722
  let plan = planned.plan;
@@ -1687,6 +1726,9 @@ export function createEntityQueryEngine(context) {
1687
1726
  const predicate = mandatory.get(binding.entity);
1688
1727
  return predicate === undefined ? entry : { ...entry, filter: conjoin(entry.filter, predicate) };
1689
1728
  }) };
1729
+ if (plan.project) plan = { ...plan, project: { ...plan.project, leaves: plan.project.leaves.map((leaf) =>
1730
+ leaf.count && mandatory.has(leaf.count.entity) ? { ...leaf, count: { ...leaf.count,
1731
+ filter: conjoin(leaf.count.filter, mandatory.get(leaf.count.entity)) } } : leaf) } };
1690
1732
  }
1691
1733
  if (entry.rowBound !== null && plan.aggregate === null) {
1692
1734
  const cap = entry.rowBound + 1;
@@ -1712,6 +1754,13 @@ export function createEntityQueryEngine(context) {
1712
1754
  }
1713
1755
  return rows;
1714
1756
  };
1757
+ const admittedRows = (entry, rows) => {
1758
+ entry.admitted ??= { statements: 0, rows: 0, bytes: 0 };
1759
+ entry.admitted.statements++;
1760
+ entry.admitted.rows += rows.length;
1761
+ entry.admitted.bytes += utf8Length(JSON.stringify(rows));
1762
+ return rows;
1763
+ };
1715
1764
  /** One merged entity document against the profile's byte bound
1716
1765
  * (JD2076): measured after the merge, because the mapped scalars live
1717
1766
  * in columns and the row's own JSON text holds only the rest. */
@@ -1767,11 +1816,15 @@ export function createEntityQueryEngine(context) {
1767
1816
  const limit = entry.rowBound === null ? '' : ` ${dialect.limitClause(entry.rowBound + 1, undefined)}`;
1768
1817
  return {
1769
1818
  name,
1770
- sql: `SELECT ${q('t')}.*, ${mapping.entities[name].document === false
1771
- ? dialect.stringLiteral('{}')
1772
- : dialect.jsonText(`${q('t')}.${q('doc')}`)} AS ${q('__doc')} `
1819
+ sql: `SELECT ${entities.get(name)?.physical != null ? physicalSelection(mapping.entities[name], dialect, `${q('t')}.`)
1820
+ : `${q('t')}.*, ${mapping.entities[name].document === false ? dialect.stringLiteral('{}')
1821
+ : dialect.jsonText(`${q('t')}.${q('doc')}`)} AS ${q('__doc')}`} `
1773
1822
  + `FROM ${q(mapping.entities[name].table)} AS ${q('t')}${where} `
1774
- + `ORDER BY ${q('t')}.${dialect.rowIdentity()}${limit}`,
1823
+ + `ORDER BY ${entities.get(name)?.physical != null ? mapping.entities[name].keys.map((k) => {
1824
+ const column = mapping.entities[name].columns.find((c) => c.name === k);
1825
+ const value = `${q('t')}.${q(column.physical)}`;
1826
+ return column.codec === 'text' ? dialect.codepoint(value) : value;
1827
+ }).join(', ') : `${q('t')}.${dialect.rowIdentity()}`}${limit}`,
1775
1828
  params: slots.map((slot) => slot.literal),
1776
1829
  statement: null,
1777
1830
  };
@@ -1785,6 +1838,7 @@ export function createEntityQueryEngine(context) {
1785
1838
  if (fetcher.statement === null) fetcher.statement = connection.prepare(fetcher.sql, { readOnly: true });
1786
1839
  return chain(fetcher.statement, (statement) =>
1787
1840
  chain(statement.all(fetcher.params), (rows) => {
1841
+ admittedRows(entry, rows);
1788
1842
  root[fetcher.name] = checkRows(entry, rows, fetcher.name).map((row) =>
1789
1843
  checkBytes(entry, mergeEntityRow(mapping.entities[fetcher.name], row, '__doc'), fetcher.name));
1790
1844
  return next(i + 1);
@@ -1803,6 +1857,8 @@ export function createEntityQueryEngine(context) {
1803
1857
  requireCallable(options, state.now);
1804
1858
  const { externals, strict, pushdown, profile } = callState(options);
1805
1859
  const entry = entryFor(document, pushdown, profile);
1860
+ entry.admitted = { statements: 0, rows: 0, bytes: 0 };
1861
+ entry.runtimeReason = null;
1806
1862
  if (entry.planned.mode !== 'native') {
1807
1863
  if (strict) {
1808
1864
  const forcing = entry.planned.reasons[0];
@@ -1822,12 +1878,30 @@ export function createEntityQueryEngine(context) {
1822
1878
  if (entry.statement === null) entry.statement = connection.prepare(entry.sql, { readOnly: true });
1823
1879
  return chain(guardEntityScan(entry), () => chain(entry.statement, (statement) => {
1824
1880
  if (entry.planned.plan.aggregate === 'count')
1825
- return chain(statement.get(params), (row) => wrapValue(entry, row?.value ?? 0));
1881
+ return chain(statement.get(params), (row) => { admittedRows(entry, row ? [row] : []); return wrapValue(entry, row?.value ?? 0); });
1826
1882
  return chain(statement.all(params), (rows) => {
1883
+ admittedRows(entry, rows);
1884
+ checkRows(entry, rows, entry.planned.referenced[0]);
1885
+ if (entry.planned.plan.group) {
1886
+ const group = entry.planned.plan.group;
1887
+ if (rows.some((row) => row._valid === 0))
1888
+ throw new DbRuntimeError('JD2003', 'a grouped physical column refuses a lossy or invalid value');
1889
+ if (rows.some((row) => group.aggregates.some((aggregate, i) =>
1890
+ ['sum', 'avg'].includes(aggregate.fn) && row[`_safe${i}`] !== 1))) {
1891
+ entry.runtimeReason = { construct: '$groupby', reason: 'integer accumulation exceeded its runtime exactness bound' };
1892
+ if (strict) throw new DbCompileError('JD0010', entry.runtimeReason.reason);
1893
+ if (profile?.refuseFullScan) throw profileEntityRefusal(
1894
+ 'the profile refuses the decoded scan required by the integer accumulation bound', '/entities');
1895
+ return runResidual(entry, document, externals);
1896
+ }
1897
+ return answerOf(entry, groupItems(group, rows)
1898
+ .map((row) => checkBytes(entry, row, entry.planned.referenced[0])));
1899
+ }
1827
1900
  const project = entry.planned.plan.project;
1828
1901
  if (project != null) {
1829
1902
  return answerOf(entry,
1830
- rows.flatMap((row) => projectedTreeItems(project, row, 'p')));
1903
+ rows.flatMap((row) => projectedTreeItems(project, row, 'p'))
1904
+ .map((row) => checkBytes(entry, row, entry.planned.referenced[0])));
1831
1905
  }
1832
1906
  const retEntity = entry.planned.plan.bindings
1833
1907
  .find((binding) => binding.name === entry.planned.plan.ret).entity;
@@ -1856,7 +1930,8 @@ export function createEntityQueryEngine(context) {
1856
1930
  for (const slot of entry.slots) {
1857
1931
  if (bindable(slotValue(slot, externals))) continue;
1858
1932
  const name = 'external' in slot ? slot.external
1859
- : 'derived' in slot ? slot.derived.external : null;
1933
+ : 'derived' in slot ? (slot.derived.kind === 'bboxAxis' ? slot.derived.external
1934
+ : [slot.derived.centre, slot.derived.radius].find((input) => 'external' in input)?.external) : null;
1860
1935
  return { construct: 'external', reason: BIND_REASONS.external(name, 'root') };
1861
1936
  }
1862
1937
  return null;
@@ -1877,6 +1952,8 @@ export function createEntityQueryEngine(context) {
1877
1952
  if (entry.planned.wrapped === true) {
1878
1953
  return buffered({ construct: 'window', reason: BIND_REASONS.wrappedWindow });
1879
1954
  }
1955
+ if (entry.planned.plan?.group)
1956
+ return buffered({ construct: '$groupby', reason: 'group results are validated and reconstructed together' });
1880
1957
  if (entry.planned.mode !== 'native') {
1881
1958
  const forcing = entry.planned.reasons[0]
1882
1959
  ?? { construct: 'residual', reason: BIND_REASONS.untranslated };
@@ -1924,14 +2001,17 @@ export function createEntityQueryEngine(context) {
1924
2001
  refuseBuffered(options, classified, entities.get(entry.planned.retEntity ?? '')?.docPath);
1925
2002
  const signal = options?.signal;
1926
2003
  const deadline = options?.deadline;
1927
- if (entry.planned.wrapped === true) {
2004
+ if (entry.planned.wrapped === true || entry.planned.plan?.group) {
1928
2005
  return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1929
- materialize: () => chain(execute(document, options), (value) => [value]) });
2006
+ materialize: () => chain(execute(document, options), (value) => entry.planned.wrapped === true
2007
+ ? [value] : value === undefined ? [] : Array.isArray(value) ? value : [value]) });
1930
2008
  }
1931
2009
  if (classified.barrier !== null) {
1932
2010
  return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1933
- materialize: () => chain(fetchRoot(entry), (root) =>
1934
- packedResidualOf(entry, document)(root, externals).map(each)) });
2011
+ materialize: () => {
2012
+ entry.admitted = { statements: 0, rows: 0, bytes: 0 };
2013
+ return chain(fetchRoot(entry), (root) => packedResidualOf(entry, document)(root, externals).map(each));
2014
+ } });
1935
2015
  }
1936
2016
  const params = entry.slots.map((slot) => slotValue(slot, externals));
1937
2017
  // the statement is prepared by the first PULL, not here: a root
@@ -1944,7 +2024,11 @@ export function createEntityQueryEngine(context) {
1944
2024
  if (entry.planned.plan.aggregate === 'count') {
1945
2025
  return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1946
2026
  materialize: () => chain(guardEntityScan(entry), () => chain(prepared(), (statement) =>
1947
- chain(statement.get(params), (row) => [row?.value ?? 0]))) });
2027
+ chain(statement.get(params), (row) => {
2028
+ entry.admitted = { statements: 0, rows: 0, bytes: 0 };
2029
+ admittedRows(entry, row ? [row] : []);
2030
+ return [row?.value ?? 0];
2031
+ }))) });
1948
2032
  }
1949
2033
  const rowEntity = entry.planned.plan.ret === null ? null
1950
2034
  : entry.planned.plan.bindings
@@ -1953,17 +2037,23 @@ export function createEntityQueryEngine(context) {
1953
2037
  // a statement of its own per cursor: two live iterators over one
1954
2038
  // cached statement invalidate each other at the driver
1955
2039
  return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1956
- open: () => chain(guardEntityScan(entry), () => chain(connection.prepare(entry.sql, { readOnly: true, ephemeral: true }),
1957
- (statement) => statement.iterate(params))),
2040
+ open: () => {
2041
+ entry.admitted = { statements: 1, rows: 0, bytes: 2 };
2042
+ return chain(guardEntityScan(entry), () => chain(connection.prepare(entry.sql, { readOnly: true, ephemeral: true }),
2043
+ (statement) => statement.iterate(params)));
2044
+ },
1958
2045
  items: (row) => {
1959
2046
  pulledRows++;
2047
+ entry.admitted.bytes += utf8Length(JSON.stringify(row)) + (entry.admitted.rows ? 1 : 0);
2048
+ entry.admitted.rows++;
1960
2049
  if (entry.rowBound !== null && pulledRows > entry.rowBound) {
1961
2050
  throw new DbRuntimeError('JD2007',
1962
2051
  `the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
1963
2052
  { docPath: entities.get(rowEntity)?.docPath, collection: rowEntity });
1964
2053
  }
1965
2054
  const project = entry.planned.plan.project;
1966
- if (project != null) return projectedTreeItems(project, row, 'p');
2055
+ if (project != null) return projectedTreeItems(project, row, 'p')
2056
+ .map((item) => checkBytes(entry, item, entry.planned.referenced[0]));
1967
2057
  return [each(checkBytes(entry, mergeEntityRow(mapping.entities[rowEntity], row, '__doc'), rowEntity))];
1968
2058
  } });
1969
2059
  };
@@ -1978,21 +2068,23 @@ export function createEntityQueryEngine(context) {
1978
2068
  const classified = cursorClass(entry, options?.externals ?? null);
1979
2069
  const diverted = entry.planned.mode === 'native' && classified.barrier !== null
1980
2070
  && classified.barrier.construct === 'external';
1981
- const mode = diverted ? 'set' : entry.planned.mode;
1982
- const reasons = diverted && classified.barrier !== null
2071
+ const runtime = entry.runtimeReason;
2072
+ const mode = diverted || runtime ? 'set' : entry.planned.mode;
2073
+ const reasons = runtime ? [runtime, ...entry.planned.reasons] : diverted && classified.barrier !== null
1983
2074
  ? [classified.barrier, ...entry.planned.reasons] : entry.planned.reasons;
1984
2075
  const base = {
1985
2076
  mode,
1986
2077
  streaming: classified.streaming,
1987
- barrier: classified.barrier,
2078
+ barrier: runtime ?? classified.barrier,
1988
2079
  budget: budgetOf(profile, profileSource, connection.capabilities),
1989
2080
  wrapped: entry.planned.wrapped === true,
1990
2081
  referenced: [...entry.planned.referenced],
2082
+ admitted: entry.admitted === null ? null : { ...entry.admitted },
1991
2083
  reasons,
1992
2084
  // the same effective-order vocabulary the collection engine and
1993
2085
  // the graph loader report; `null` when no statement answers
1994
- order: diverted ? null : planOrder(entry.planned.plan),
1995
- sql: diverted ? null : entry.sql,
2086
+ order: mode === 'native' ? planOrder(entry.planned.plan) : null,
2087
+ sql: mode === 'native' ? entry.sql : null,
1996
2088
  residual: mode === 'native'
1997
2089
  ? null
1998
2090
  : { mode: 'set', reasons },
@@ -2426,6 +2518,8 @@ export function createLoadEngine(context, entityName) {
2426
2518
  if (spec?.[member] !== undefined && !isWindowBound(spec[member]))
2427
2519
  throw refuse(`${member} must be a non-negative integer`, []);
2428
2520
  }
2521
+ if (entities.get(entityName)?.physical != null && (keyset || spec?.after !== undefined))
2522
+ throw refuse('column codecs require decoded identities; physical keyset continuation is not qualified', []);
2429
2523
  const tree = buildTree(entityName, spec ?? {}, 0, maxDepth, [], new Set(), profile);
2430
2524
  const rendered = render(tree, 'r', param, emitters);
2431
2525
 
@@ -2515,7 +2609,8 @@ export function createLoadEngine(context, entityName) {
2515
2609
  pagination = 'offset';
2516
2610
  }
2517
2611
 
2518
- let sql = `SELECT ${rendered.aliasSql}.*, ${dialect.jsonText(rendered.docSql)} AS ${q('__doc')}`
2612
+ let sql = `SELECT ${tree.entity.physical != null ? physicalSelection(tree.entityMapping, dialect, `${rendered.aliasSql}.`)
2613
+ : `${rendered.aliasSql}.*, ${tree.entityMapping.document === false ? dialect.stringLiteral('{}') : dialect.jsonText(rendered.docSql)} AS ${q('__doc')}`}`
2519
2614
  + includeSql
2520
2615
  + ` FROM ${q(tree.entityMapping.table)} AS ${rendered.aliasSql}`;
2521
2616
  if (conditions.length > 0) sql += ` WHERE ${conditions.join(' AND ')}`;
@@ -2529,7 +2624,7 @@ export function createLoadEngine(context, entityName) {
2529
2624
  const nullsFirst = term.emptyGreatest === term.desc;
2530
2625
  return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
2531
2626
  });
2532
- if (identity === null) orderSql.push(`${rendered.aliasSql}.${dialect.rowIdentity()}`);
2627
+ if (identity === null) orderSql.push(...(tree.entity.physical != null ? tree.entityMapping.keys.map((k) => `${rendered.aliasSql}.${q(tree.entityMapping.columns.find((c) => c.name === k).physical)}`) : [`${rendered.aliasSql}.${dialect.rowIdentity()}`]));
2533
2628
  sql += ` ORDER BY ${orderSql.join(', ')}`;
2534
2629
  // the profile's row bound rides the root as LIMIT maxRows + 1, so
2535
2630
  // a load past it is detected at the bound and refused (JD2007)
@@ -2681,7 +2776,7 @@ export function createLoadEngine(context, entityName) {
2681
2776
  * the truth either way.
2682
2777
  * @param {any} spec
2683
2778
  * @param {{ limit?: number, after?: any, maxBytes?: number | null,
2684
- * consistency?: 'live' | 'snapshot', signal?: AbortSignal }} [options]
2779
+ * consistency?: 'live' | 'snapshot', signal?: AbortSignal, lookahead?: boolean }} [options]
2685
2780
  * @param {((tree: any, doc: any) => any) | undefined} [register]
2686
2781
  */
2687
2782
  page(spec, options = undefined, register = undefined, cursorFactory = createCursor) {
@@ -2700,7 +2795,9 @@ export function createLoadEngine(context, entityName) {
2700
2795
  if (spec?.take !== undefined || spec?.skip !== undefined)
2701
2796
  throw refuse('page() windows by its limit and continuation — a take or skip in the spec is refused', []);
2702
2797
  const after = options?.after ?? spec?.after ?? undefined;
2703
- const paged = { ...(spec ?? {}), take: limit + 1 };
2798
+ if (options?.lookahead !== undefined && typeof options.lookahead !== 'boolean')
2799
+ throw refuse('page() lookahead must be boolean', []);
2800
+ const paged = { ...(spec ?? {}), take: limit + (options?.lookahead === false ? 0 : 1) };
2704
2801
  if (after === undefined) delete paged.after;
2705
2802
  else paged.after = after;
2706
2803
  const entry = buildLoad(paged, true, profileOf(options));
@@ -2718,7 +2815,7 @@ export function createLoadEngine(context, entityName) {
2718
2815
  // of work
2719
2816
  const cursor = openCursor(entry, options?.signal, undefined, options?.deadline, cursorFactory);
2720
2817
  return chain(drainPage(cursor, {
2721
- limit, maxBytes, after: after ?? null,
2818
+ limit, maxBytes, after: after ?? null, lookahead: options?.lookahead,
2722
2819
  sizeOf: (doc) => utf8Length(JSON.stringify(doc)),
2723
2820
  continuationOf: (doc) => continuationOf(entry, doc),
2724
2821
  }), (page) => ({