@jarenjs/db 0.75.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 (50) hide show
  1. package/ARCHITECTURE.md +20 -0
  2. package/README.md +25 -0
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +4 -0
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +132 -1
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/SEARCH.md +55 -0
  10. package/package.json +8 -4
  11. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  12. package/schemas/jaren-migration.schema.json +49 -0
  13. package/schemas/jaren-model.authoring.schema.json +360 -0
  14. package/schemas/jaren-model.draft-07.schema.json +128 -0
  15. package/schemas/jaren-model.schema.json +128 -0
  16. package/src/algebra.js +17 -1
  17. package/src/backup.js +12 -7
  18. package/src/cursor.js +27 -4
  19. package/src/dag-job.js +2 -1
  20. package/src/ddl.js +13 -0
  21. package/src/dialect.js +10 -0
  22. package/src/dialects/check-read.js +3 -3
  23. package/src/dialects/invariant-sql.js +117 -0
  24. package/src/dialects/postgres.js +14 -2
  25. package/src/dialects/sqlite.js +18 -2
  26. package/src/driver.js +1 -0
  27. package/src/drivers/bun.js +22 -4
  28. package/src/emit.js +80 -13
  29. package/src/entity.js +98 -41
  30. package/src/errors.js +8 -0
  31. package/src/graph.js +8 -1
  32. package/src/index.js +3 -0
  33. package/src/introspect.js +44 -7
  34. package/src/invariants.js +45 -0
  35. package/src/jobs.js +39 -6
  36. package/src/live.js +4 -1
  37. package/src/migrate.js +136 -22
  38. package/src/model.js +12 -0
  39. package/src/mutation.js +165 -0
  40. package/src/physical.js +147 -0
  41. package/src/plan.js +114 -20
  42. package/src/query.js +144 -70
  43. package/src/search.js +144 -0
  44. package/src/sql.js +60 -0
  45. package/src/store.js +49 -13
  46. package/src/tracker.js +63 -39
  47. package/src/window.js +1 -0
  48. package/types/index.d.ts +58 -3
  49. package/types/search.d.ts +20 -0
  50. 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
 
@@ -292,7 +294,11 @@ function segmentsOf(ref) {
292
294
  * @param {string} suffix
293
295
  * @returns {any[]} the item, or nothing where the member is absent
294
296
  */
295
- 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
+ }
296
302
  const type = row[`t${suffix}`];
297
303
  if (type === null || type === undefined) return [];
298
304
  const value = row[`v${suffix}`];
@@ -321,7 +327,8 @@ function leafItems(row, suffix) {
321
327
  function projectedTreeItems(project, row, prefix = '') {
322
328
  const build = (node) => {
323
329
  if (node.p === 'lit') return [node.value];
324
- 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);
325
332
  if (node.p === 'object') {
326
333
  /** @type {any} */
327
334
  const out = {};
@@ -338,6 +345,47 @@ function projectedTreeItems(project, row, prefix = '') {
338
345
  return build(project.tree);
339
346
  }
340
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
+
341
389
  /**
342
390
  * The query engine for one collection.
343
391
  * @param {{ connection: any, state: any, collection: any,
@@ -517,7 +565,6 @@ export function createQueryEngine(context) {
517
565
  : { ...planned.series, mode: 'engine', index: null, prefix: [] },
518
566
  };
519
567
  }
520
-
521
568
  if (profile !== null) {
522
569
  // the member allow-list: what the caller may OBTAIN, checked
523
570
  // against every member path the document references, before a
@@ -849,46 +896,6 @@ export function createQueryEngine(context) {
849
896
  return items;
850
897
  };
851
898
 
852
- /**
853
- * The items a GENERAL grouping answers: one per group row, built from
854
- * the group's keys and aggregates through the plan's own tree. A key
855
- * comes back with its JSON type beside it, so an absent key leaves
856
- * its member out exactly as the object constructor does; an aggregate
857
- * over no values follows the mapping the plan recorded — `0` for a
858
- * count or a sum, the empty sequence for the other three, which is
859
- * the ENGINE's answer, not SQL's `NULL`.
860
- * @param {any} entry
861
- * @param {any[]} rows
862
- * @returns {any[]}
863
- */
864
- const groupItems = (entry, rows) => {
865
- const group = entry.plan.group;
866
- const aggregateItems = (row, index) => {
867
- const value = row[`a${index}`] ?? null;
868
- const empty = group.aggregates[index].empty;
869
- if (value === null) return empty === 'omit' ? [] : [empty === 'zero' ? 0 : null];
870
- return [value];
871
- };
872
- const build = (node, row) => {
873
- if (node.p === 'lit') return [node.value];
874
- if (node.p === 'key') return leafItems(row, `k${node.index}`);
875
- if (node.p === 'agg') return aggregateItems(row, node.index);
876
- if (node.p === 'object') {
877
- /** @type {any} */
878
- const out = {};
879
- for (const member of node.members) {
880
- const items = build(member.node, row);
881
- if (items.length > 0) out[member.name] = items[0];
882
- }
883
- return [out];
884
- }
885
- const items = [];
886
- for (const item of node.items) items.push(...build(item, row));
887
- return [items];
888
- };
889
- return rows.flatMap((row) => build(group.tree, row));
890
- };
891
-
892
899
  /** How many ITEMS an engine result carries (its own shape rule). */
893
900
  const itemCount = (answer) => (answer === undefined ? 0
894
901
  : Array.isArray(answer) ? answer.length : 1);
@@ -1111,7 +1118,7 @@ export function createQueryEngine(context) {
1111
1118
  }
1112
1119
  if (entry.plan.group !== null) {
1113
1120
  return chain(runAll(entry, externals, statement), (rows) =>
1114
- answerOf(entry, groupItems(entry, checkRowBound(entry, rows))));
1121
+ answerOf(entry, groupItems(entry.plan.group, checkRowBound(entry, rows))));
1115
1122
  }
1116
1123
  if (entry.plan.bucket !== null) {
1117
1124
  return chain(runAll(entry, externals, statement), (rows) => {
@@ -1213,7 +1220,7 @@ export function createQueryEngine(context) {
1213
1220
  return createCursor({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1214
1221
  materialize: () => chain(guardScan(entry), () => chain(statementOf(entry), (statement) =>
1215
1222
  chain(runAll(entry, externals, statement), (rows) =>
1216
- groupItems(entry, checkRowBound(entry, rows))))) });
1223
+ groupItems(entry.plan.group, checkRowBound(entry, rows))))) });
1217
1224
  }
1218
1225
  if (entry.plan.bucket !== null) {
1219
1226
  // a native bucket is a barrier: the groups are the answer
@@ -1599,7 +1606,11 @@ export function createEntityQueryEngine(context) {
1599
1606
  const q = dialect.quoteIdentifier;
1600
1607
  const physicalOf = (name) => ({ table: mapping.entities[name].table,
1601
1608
  // a join-table root has no document column of its own (§10.7)
1602
- 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
+ }) });
1603
1614
  // the relation tables of every root this engine serves (§10.1): the
1604
1615
  // engine is the scope every entity set of the store shares, so a
1605
1616
  // producer holding one set can follow a hop into another root
@@ -1642,6 +1653,10 @@ export function createEntityQueryEngine(context) {
1642
1653
  planned = { ...planned, mode: 'set', plan: null,
1643
1654
  reasons: [{ construct: 'pushdown', reason: BIND_REASONS.pushdown }] };
1644
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
+ }
1645
1660
  const docPath = entities.get(planned.referenced[0])?.docPath;
1646
1661
  // the profile, applied exactly as on a collection (MODEL-FORMAT §8):
1647
1662
  // the roots a document may read, the references it may make, the
@@ -1700,6 +1715,8 @@ export function createEntityQueryEngine(context) {
1700
1715
  residualLimits: profile === null ? undefined : profile.limits,
1701
1716
  needsScanCheck: profile !== null && profile.refuseFullScan === true,
1702
1717
  scanChecked: false,
1718
+ admitted: null,
1719
+ runtimeReason: null,
1703
1720
  };
1704
1721
  if (planned.mode === 'native') {
1705
1722
  let plan = planned.plan;
@@ -1709,6 +1726,9 @@ export function createEntityQueryEngine(context) {
1709
1726
  const predicate = mandatory.get(binding.entity);
1710
1727
  return predicate === undefined ? entry : { ...entry, filter: conjoin(entry.filter, predicate) };
1711
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) } };
1712
1732
  }
1713
1733
  if (entry.rowBound !== null && plan.aggregate === null) {
1714
1734
  const cap = entry.rowBound + 1;
@@ -1734,6 +1754,13 @@ export function createEntityQueryEngine(context) {
1734
1754
  }
1735
1755
  return rows;
1736
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
+ };
1737
1764
  /** One merged entity document against the profile's byte bound
1738
1765
  * (JD2076): measured after the merge, because the mapped scalars live
1739
1766
  * in columns and the row's own JSON text holds only the rest. */
@@ -1789,11 +1816,15 @@ export function createEntityQueryEngine(context) {
1789
1816
  const limit = entry.rowBound === null ? '' : ` ${dialect.limitClause(entry.rowBound + 1, undefined)}`;
1790
1817
  return {
1791
1818
  name,
1792
- sql: `SELECT ${q('t')}.*, ${mapping.entities[name].document === false
1793
- ? dialect.stringLiteral('{}')
1794
- : 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')}`} `
1795
1822
  + `FROM ${q(mapping.entities[name].table)} AS ${q('t')}${where} `
1796
- + `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}`,
1797
1828
  params: slots.map((slot) => slot.literal),
1798
1829
  statement: null,
1799
1830
  };
@@ -1807,6 +1838,7 @@ export function createEntityQueryEngine(context) {
1807
1838
  if (fetcher.statement === null) fetcher.statement = connection.prepare(fetcher.sql, { readOnly: true });
1808
1839
  return chain(fetcher.statement, (statement) =>
1809
1840
  chain(statement.all(fetcher.params), (rows) => {
1841
+ admittedRows(entry, rows);
1810
1842
  root[fetcher.name] = checkRows(entry, rows, fetcher.name).map((row) =>
1811
1843
  checkBytes(entry, mergeEntityRow(mapping.entities[fetcher.name], row, '__doc'), fetcher.name));
1812
1844
  return next(i + 1);
@@ -1825,6 +1857,8 @@ export function createEntityQueryEngine(context) {
1825
1857
  requireCallable(options, state.now);
1826
1858
  const { externals, strict, pushdown, profile } = callState(options);
1827
1859
  const entry = entryFor(document, pushdown, profile);
1860
+ entry.admitted = { statements: 0, rows: 0, bytes: 0 };
1861
+ entry.runtimeReason = null;
1828
1862
  if (entry.planned.mode !== 'native') {
1829
1863
  if (strict) {
1830
1864
  const forcing = entry.planned.reasons[0];
@@ -1844,12 +1878,30 @@ export function createEntityQueryEngine(context) {
1844
1878
  if (entry.statement === null) entry.statement = connection.prepare(entry.sql, { readOnly: true });
1845
1879
  return chain(guardEntityScan(entry), () => chain(entry.statement, (statement) => {
1846
1880
  if (entry.planned.plan.aggregate === 'count')
1847
- 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); });
1848
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
+ }
1849
1900
  const project = entry.planned.plan.project;
1850
1901
  if (project != null) {
1851
1902
  return answerOf(entry,
1852
- 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])));
1853
1905
  }
1854
1906
  const retEntity = entry.planned.plan.bindings
1855
1907
  .find((binding) => binding.name === entry.planned.plan.ret).entity;
@@ -1900,6 +1952,8 @@ export function createEntityQueryEngine(context) {
1900
1952
  if (entry.planned.wrapped === true) {
1901
1953
  return buffered({ construct: 'window', reason: BIND_REASONS.wrappedWindow });
1902
1954
  }
1955
+ if (entry.planned.plan?.group)
1956
+ return buffered({ construct: '$groupby', reason: 'group results are validated and reconstructed together' });
1903
1957
  if (entry.planned.mode !== 'native') {
1904
1958
  const forcing = entry.planned.reasons[0]
1905
1959
  ?? { construct: 'residual', reason: BIND_REASONS.untranslated };
@@ -1947,14 +2001,17 @@ export function createEntityQueryEngine(context) {
1947
2001
  refuseBuffered(options, classified, entities.get(entry.planned.retEntity ?? '')?.docPath);
1948
2002
  const signal = options?.signal;
1949
2003
  const deadline = options?.deadline;
1950
- if (entry.planned.wrapped === true) {
2004
+ if (entry.planned.wrapped === true || entry.planned.plan?.group) {
1951
2005
  return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1952
- 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]) });
1953
2008
  }
1954
2009
  if (classified.barrier !== null) {
1955
2010
  return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1956
- materialize: () => chain(fetchRoot(entry), (root) =>
1957
- 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
+ } });
1958
2015
  }
1959
2016
  const params = entry.slots.map((slot) => slotValue(slot, externals));
1960
2017
  // the statement is prepared by the first PULL, not here: a root
@@ -1967,7 +2024,11 @@ export function createEntityQueryEngine(context) {
1967
2024
  if (entry.planned.plan.aggregate === 'count') {
1968
2025
  return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1969
2026
  materialize: () => chain(guardEntityScan(entry), () => chain(prepared(), (statement) =>
1970
- 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
+ }))) });
1971
2032
  }
1972
2033
  const rowEntity = entry.planned.plan.ret === null ? null
1973
2034
  : entry.planned.plan.bindings
@@ -1976,17 +2037,23 @@ export function createEntityQueryEngine(context) {
1976
2037
  // a statement of its own per cursor: two live iterators over one
1977
2038
  // cached statement invalidate each other at the driver
1978
2039
  return cursorFactory({ ...classified, signal, deadline, now: state.now, wrap: driverWrap,
1979
- open: () => chain(guardEntityScan(entry), () => chain(connection.prepare(entry.sql, { readOnly: true, ephemeral: true }),
1980
- (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
+ },
1981
2045
  items: (row) => {
1982
2046
  pulledRows++;
2047
+ entry.admitted.bytes += utf8Length(JSON.stringify(row)) + (entry.admitted.rows ? 1 : 0);
2048
+ entry.admitted.rows++;
1983
2049
  if (entry.rowBound !== null && pulledRows > entry.rowBound) {
1984
2050
  throw new DbRuntimeError('JD2007',
1985
2051
  `the fetch crossed the profile's maxRows bound of ${entry.rowBound}`,
1986
2052
  { docPath: entities.get(rowEntity)?.docPath, collection: rowEntity });
1987
2053
  }
1988
2054
  const project = entry.planned.plan.project;
1989
- 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]));
1990
2057
  return [each(checkBytes(entry, mergeEntityRow(mapping.entities[rowEntity], row, '__doc'), rowEntity))];
1991
2058
  } });
1992
2059
  };
@@ -2001,21 +2068,23 @@ export function createEntityQueryEngine(context) {
2001
2068
  const classified = cursorClass(entry, options?.externals ?? null);
2002
2069
  const diverted = entry.planned.mode === 'native' && classified.barrier !== null
2003
2070
  && classified.barrier.construct === 'external';
2004
- const mode = diverted ? 'set' : entry.planned.mode;
2005
- 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
2006
2074
  ? [classified.barrier, ...entry.planned.reasons] : entry.planned.reasons;
2007
2075
  const base = {
2008
2076
  mode,
2009
2077
  streaming: classified.streaming,
2010
- barrier: classified.barrier,
2078
+ barrier: runtime ?? classified.barrier,
2011
2079
  budget: budgetOf(profile, profileSource, connection.capabilities),
2012
2080
  wrapped: entry.planned.wrapped === true,
2013
2081
  referenced: [...entry.planned.referenced],
2082
+ admitted: entry.admitted === null ? null : { ...entry.admitted },
2014
2083
  reasons,
2015
2084
  // the same effective-order vocabulary the collection engine and
2016
2085
  // the graph loader report; `null` when no statement answers
2017
- order: diverted ? null : planOrder(entry.planned.plan),
2018
- sql: diverted ? null : entry.sql,
2086
+ order: mode === 'native' ? planOrder(entry.planned.plan) : null,
2087
+ sql: mode === 'native' ? entry.sql : null,
2019
2088
  residual: mode === 'native'
2020
2089
  ? null
2021
2090
  : { mode: 'set', reasons },
@@ -2449,6 +2518,8 @@ export function createLoadEngine(context, entityName) {
2449
2518
  if (spec?.[member] !== undefined && !isWindowBound(spec[member]))
2450
2519
  throw refuse(`${member} must be a non-negative integer`, []);
2451
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', []);
2452
2523
  const tree = buildTree(entityName, spec ?? {}, 0, maxDepth, [], new Set(), profile);
2453
2524
  const rendered = render(tree, 'r', param, emitters);
2454
2525
 
@@ -2538,7 +2609,8 @@ export function createLoadEngine(context, entityName) {
2538
2609
  pagination = 'offset';
2539
2610
  }
2540
2611
 
2541
- 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')}`}`
2542
2614
  + includeSql
2543
2615
  + ` FROM ${q(tree.entityMapping.table)} AS ${rendered.aliasSql}`;
2544
2616
  if (conditions.length > 0) sql += ` WHERE ${conditions.join(' AND ')}`;
@@ -2552,7 +2624,7 @@ export function createLoadEngine(context, entityName) {
2552
2624
  const nullsFirst = term.emptyGreatest === term.desc;
2553
2625
  return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
2554
2626
  });
2555
- 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()}`]));
2556
2628
  sql += ` ORDER BY ${orderSql.join(', ')}`;
2557
2629
  // the profile's row bound rides the root as LIMIT maxRows + 1, so
2558
2630
  // a load past it is detected at the bound and refused (JD2007)
@@ -2704,7 +2776,7 @@ export function createLoadEngine(context, entityName) {
2704
2776
  * the truth either way.
2705
2777
  * @param {any} spec
2706
2778
  * @param {{ limit?: number, after?: any, maxBytes?: number | null,
2707
- * consistency?: 'live' | 'snapshot', signal?: AbortSignal }} [options]
2779
+ * consistency?: 'live' | 'snapshot', signal?: AbortSignal, lookahead?: boolean }} [options]
2708
2780
  * @param {((tree: any, doc: any) => any) | undefined} [register]
2709
2781
  */
2710
2782
  page(spec, options = undefined, register = undefined, cursorFactory = createCursor) {
@@ -2723,7 +2795,9 @@ export function createLoadEngine(context, entityName) {
2723
2795
  if (spec?.take !== undefined || spec?.skip !== undefined)
2724
2796
  throw refuse('page() windows by its limit and continuation — a take or skip in the spec is refused', []);
2725
2797
  const after = options?.after ?? spec?.after ?? undefined;
2726
- 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) };
2727
2801
  if (after === undefined) delete paged.after;
2728
2802
  else paged.after = after;
2729
2803
  const entry = buildLoad(paged, true, profileOf(options));
@@ -2741,7 +2815,7 @@ export function createLoadEngine(context, entityName) {
2741
2815
  // of work
2742
2816
  const cursor = openCursor(entry, options?.signal, undefined, options?.deadline, cursorFactory);
2743
2817
  return chain(drainPage(cursor, {
2744
- limit, maxBytes, after: after ?? null,
2818
+ limit, maxBytes, after: after ?? null, lookahead: options?.lookahead,
2745
2819
  sizeOf: (doc) => utf8Length(JSON.stringify(doc)),
2746
2820
  continuationOf: (doc) => continuationOf(entry, doc),
2747
2821
  }), (page) => ({
package/src/search.js ADDED
@@ -0,0 +1,144 @@
1
+ //@ts-check
2
+ /** Optional lexical persistence and bounded execution over authoritative entity snapshots. */
3
+ import { compileLexical } from '@jarenjs/core/search';
4
+ import { createLexicalProvider } from '@jarenjs/json/query';
5
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
6
+ import { utf8ByteLength } from '@jarenjs/core/string';
7
+ import { isCursorBudgetError } from './cursor.js';
8
+
9
+ /** Atomic snapshot storage over a host-declared collection of {id, payload} documents.
10
+ * Snapshot rows are derived caches and never answer catalog queries.
11
+ * @param {any} store @param {string} collection @param {{maxBytes?:number}} [options] */
12
+ export function createDbSearchStorage(store, collection, options = {}) {
13
+ const maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
14
+ if (typeof collection !== 'string' || !collection || !Number.isSafeInteger(maxBytes) || maxBytes < 1)
15
+ throw new TypeError('Invalid lexical snapshot storage');
16
+ return {
17
+ async load(id) { const row = await store.collection(collection).get(id);
18
+ if (!row) return null;
19
+ if (typeof row.payload !== 'string' || row.payload.length > maxBytes || utf8ByteLength(row.payload) > maxBytes)
20
+ throw new RangeError('Snapshot storage byte credits');
21
+ return row.payload;
22
+ },
23
+ async save(id, payload) {
24
+ if (typeof id !== 'string' || !id || typeof payload !== 'string' || payload.length > maxBytes || utf8ByteLength(payload) > maxBytes)
25
+ throw new RangeError('Snapshot storage byte credits');
26
+ return store.transaction(async (tx) => {
27
+ const target = tx.collection(collection), old = await target.get(id);
28
+ if (old?.payload === payload) return { changes: 0 };
29
+ await target.put({ id, payload }); return { changes: 1 };
30
+ });
31
+ },
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Read a bounded, complete entity snapshot inside the store transaction; captured
37
+ * commits invalidate it. External SQL invalidates through dataVersion on request.
38
+ * A SHA-256 source-content revision also detects uncaptured edits across reopen.
39
+ * Native FTS is deliberately refused: this adapter executes the shared ranker.
40
+ * @param {any} store @param {string} entity
41
+ * @param {import('@jarenjs/core/search').LexicalDefinition} definition
42
+ * @param {{source:string, maxRows?:number, maxBytes?:number, storage?:{load:(key:string)=>Promise<string|null>,save:(key:string,payload:string)=>Promise<any>}, snapshotKey?:string}} options
43
+ * @returns {Promise<any>}
44
+ */
45
+ export async function createDbSearch(store, entity, definition, options) {
46
+ const maxRows = options?.maxRows ?? 10000, maxBytes = options?.maxBytes ?? 8 * 1024 * 1024;
47
+ if (typeof options?.source !== 'string' || !options.source || !Number.isSafeInteger(maxRows) || maxRows < 1
48
+ || !Number.isSafeInteger(maxBytes) || maxBytes < 2) throw new TypeError('Invalid lexical source credits');
49
+ if (!store.capabilities?.capture || store.capabilities.capture === 'none') throw new TypeError('Lexical freshness requires committed capture');
50
+ const compiled = compileLexical(definition), index = compiled.create(), storage = options.storage;
51
+ const snapshotKey = options.snapshotKey ?? `${options.source}:${entity}`;
52
+ let rows = new Map(), sourceRevision = '', dataVersion, dirty = true, disposed = false, epoch = 0, busy = null;
53
+ let reads = 0, writes = 0, restores = 0, rebuilds = 0, sourceBytes = 0, recovery = null, pendingSnapshot = null;
54
+ const observers = new Set(), controller = new AbortController();
55
+ const invalidate = (reason) => {
56
+ dirty = true; epoch++;
57
+ for (const observer of observers) { try { observer({ type: 'reset', reason, revision: epoch, sourceRevision }); }
58
+ catch { /* An observer cannot suppress a sibling's invalidation. */ } }
59
+ };
60
+ const unsubscribe = store.observe((record) => { if (record.collections.includes(entity)) invalidate('source-changed'); });
61
+ const refusal = (state, reason) => ({ state, reason, hits: [], total: null, sourceRevision });
62
+ const refresh = async () => {
63
+ if (disposed) return refusal('error', 'disposed');
64
+ if (busy) return busy;
65
+ const run = async () => {
66
+ try {
67
+ const loaded = await store.transaction(async (tx) => {
68
+ const current = await tx.dataVersion();
69
+ if (dataVersion !== undefined && current !== dataVersion) invalidate('external-source-changed');
70
+ dataVersion = current;
71
+ if (!dirty) return null;
72
+ const page = await tx.entity(entity).page({ orderBy: '$it.id' },
73
+ { limit: maxRows + 1, maxBytes, lookahead: false, signal: controller.signal });
74
+ reads++;
75
+ if (page.hasMore !== false || page.items.length > maxRows) throw new RangeError('Lexical source exceeds row credits');
76
+ const source = canonicalizeJson(page.items);
77
+ if (utf8ByteLength(source) > maxBytes) throw new RangeError('Lexical source exceeds byte credits');
78
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
79
+ const revision = `${options.source}:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')}`;
80
+ return { items: page.items, revision, bytes: utf8ByteLength(source), epoch };
81
+ }, { signal: controller.signal });
82
+ if (disposed) return refusal('error', 'disposed');
83
+ if (!loaded) return { state: 'complete', changes: 0, sourceRevision };
84
+ if (loaded.epoch !== epoch) return refusal('invalidated', 'source-changed');
85
+ if (loaded.revision === sourceRevision) {
86
+ if (pendingSnapshot !== null) { writes += (await storage.save(snapshotKey, pendingSnapshot)).changes; pendingSnapshot = null; }
87
+ if (disposed || loaded.epoch !== epoch) return refusal(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
88
+ dirty = false; return { state: 'complete', changes: 0, sourceRevision };
89
+ }
90
+ const nextRows = new Map(loaded.items.map((row) => [row.id, Object.freeze(row)]));
91
+ if (nextRows.size !== loaded.items.length) throw new TypeError('Duplicate lexical source IDs');
92
+ let result;
93
+ if (!sourceRevision && storage) {
94
+ const saved = await storage.load(snapshotKey);
95
+ if (saved !== null) {
96
+ result = index.restore(saved, { sourceRevision: loaded.revision });
97
+ if (result.state === 'complete') restores++; else recovery = result.reason;
98
+ }
99
+ }
100
+ if (disposed || loaded.epoch !== epoch) return refusal(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
101
+ if (result?.state !== 'complete') {
102
+ result = index.rebuild(loaded.items, { sourceRevision: loaded.revision }); rebuilds++;
103
+ }
104
+ if (result.state !== 'complete') return { ...result, hits: [], total: null };
105
+ rows = nextRows; sourceRevision = loaded.revision; sourceBytes = loaded.bytes; dirty = false;
106
+ if (storage) { pendingSnapshot = index.snapshot(); writes += (await storage.save(snapshotKey, pendingSnapshot)).changes; pendingSnapshot = null; }
107
+ if (disposed || loaded.epoch !== epoch) return refusal(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
108
+ return { state: 'complete', changes: result.changes, sourceRevision };
109
+ }
110
+ catch (error) { dirty = true; return refusal(error instanceof RangeError || isCursorBudgetError(error)
111
+ ? 'budget-exhausted' : 'error', disposed ? 'disposed' : error?.message ?? String(error)); }
112
+ };
113
+ busy = run().finally(() => { busy = null; }); return busy;
114
+ };
115
+ const service = {
116
+ refresh,
117
+ get sourceRevision() { return sourceRevision; },
118
+ async search(text, spec = {}) {
119
+ const ready = await refresh(); if (ready.state !== 'complete') return ready;
120
+ return createLexicalProvider(index, { row: (id) => rows.get(id) }).compile(spec)(text);
121
+ },
122
+ /** Access a row only under its published search revision. */
123
+ row(id, revision) {
124
+ if (disposed || dirty || revision !== sourceRevision) throw new Error('Lexical source snapshot changed');
125
+ return structuredClone(rows.get(id));
126
+ },
127
+ subscribe(fn) {
128
+ if (disposed || typeof fn !== 'function') throw new TypeError('Invalid lexical observer');
129
+ if (observers.size >= 8) throw new RangeError('Lexical subscription credits');
130
+ observers.add(fn); return () => observers.delete(fn);
131
+ },
132
+ explain() { return { mode: 'resident', nativeFTS: false, reason: 'native-token-rank-parity-unqualified',
133
+ maxRows, maxBytes, capture: store.capabilities.capture, externalChanges: 'dataVersion plus authoritative SHA-256 on refresh' }; },
134
+ stats() { return { ...index.stats(), sourceRows: rows.size, sourceBytes, reads, writes, restores, rebuilds,
135
+ recovery, dirty, pending: busy ? 1 : 0, subscriptions: observers.size }; },
136
+ async dispose() {
137
+ disposed = true; controller.abort(); unsubscribe(); observers.clear();
138
+ await busy; index.dispose(); rows.clear(); sourceBytes = 0; pendingSnapshot = null;
139
+ },
140
+ };
141
+ const ready = await refresh();
142
+ if (ready.state !== 'complete') { await service.dispose(); throw new Error(`Lexical source: ${ready.reason}`); }
143
+ return service;
144
+ }
package/src/sql.js ADDED
@@ -0,0 +1,60 @@
1
+ //@ts-check
2
+ /** Trusted prepared statements, restricted to a live transaction's authority. */
3
+ import { sqlTokens } from './dialects/check-read.js';
4
+ import { chain, attempt } from './driver.js';
5
+ import { DbRuntimeError, wrapDriverError } from './errors.js';
6
+
7
+ /** @param {any} context @returns {any} a scoped SQL capability */
8
+ export function trustedSql({ connection, requireScope, beforeWrite, afterWrite, readOnly }) {
9
+ return Object.freeze({
10
+ /** Compile one statement. Access is explicit; this is trusted application SQL,
11
+ * not a sandbox for untrusted query text or side-effecting host functions.
12
+ * @param {string} sql @param {{ access: 'read' | 'write', affects?: readonly string[] }} options */
13
+ prepare(sql, options) {
14
+ requireScope();
15
+ const refuse = (why) => { throw new DbRuntimeError('JD2095', why); };
16
+ if (typeof sql !== 'string' || !['read', 'write'].includes(options?.access)) refuse('SQL prepare requires text and explicit read/write access');
17
+ const tokens = sqlTokens(sql);
18
+ const words = tokens.filter((t) => t.kind === 'word').map((t) => t.value.toUpperCase());
19
+ if (!tokens.length || tokens[0].kind !== 'word' || !['SELECT', 'WITH', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE'].includes(words[0])) refuse('trusted SQL accepts a single SELECT or data mutation');
20
+ if (tokens.some((t, i) => t.value === ';' && t.kind === 'symbol' && i !== tokens.length - 1)
21
+ || words.some((w) => /^(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|ATTACH|DETACH|PRAGMA|CREATE|ALTER|DROP|VACUUM)$/.test(w))) refuse('SQL cannot change transaction ownership, schema or connection configuration');
22
+ const writing = options.access === 'write';
23
+ if (!writing && words.some((w) => ['INSERT', 'UPDATE', 'DELETE', 'REPLACE'].includes(w))) refuse('a mutation requires write access');
24
+ if (writing && readOnly) refuse('this store grants no SQL write authority');
25
+ if (options.affects !== undefined && (!Array.isArray(options.affects) || options.affects.some((v) => typeof v !== 'string'))) refuse('affects is an array of entity names');
26
+ let closed = false;
27
+ const check = () => {
28
+ requireScope();
29
+ if (closed) refuse('the prepared SQL statement is closed');
30
+ };
31
+ const statement = connection.prepare(sql, { readOnly: !writing });
32
+ const run = (method, params = []) => {
33
+ check();
34
+ if (!Array.isArray(params)) refuse('SQL parameters must be an array');
35
+ return attempt(() => chain(statement, (s) => {
36
+ check();
37
+ if (writing) beforeWrite(options.affects);
38
+ return chain(s[method](params), (result) => {
39
+ if (writing) afterWrite(options.affects);
40
+ return result;
41
+ });
42
+ }), (error) => wrapDriverError(error, { docPath: '/sql' }));
43
+ };
44
+ return Object.freeze({ run: (params) => run('run', params),
45
+ get: (params) => run('get', params), all: (params) => run('all', params),
46
+ close: () => { requireScope(); closed = true; } });
47
+ },
48
+ });
49
+ }
50
+
51
+ /** A synchronous transaction ends at callback return, including a thenable return.
52
+ * @param {Function} fn @param {any} tx @returns {any} */
53
+ export function synchronousBody(fn, tx) {
54
+ const value = fn(tx);
55
+ if (value != null && typeof value.then === 'function') {
56
+ Promise.resolve(value).catch(() => {});
57
+ throw new DbRuntimeError('JD2095', 'a synchronous transaction callback must not return a thenable');
58
+ }
59
+ return value;
60
+ }