@jarenjs/db 0.75.0 → 0.83.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +20 -0
- package/README.md +25 -0
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +4 -0
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +132 -1
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +17 -1
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/dialect.js +10 -0
- package/src/dialects/check-read.js +3 -3
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +14 -2
- package/src/dialects/sqlite.js +18 -2
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +80 -13
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +44 -7
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live.js +4 -1
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +114 -20
- package/src/query.js +144 -70
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +58 -3
- package/types/search.d.ts +20 -0
- 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 ${
|
|
1793
|
-
? dialect.stringLiteral('{}')
|
|
1794
|
-
|
|
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 ${
|
|
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) =>
|
|
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: () =>
|
|
1957
|
-
|
|
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) =>
|
|
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: () =>
|
|
1980
|
-
|
|
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
|
|
2005
|
-
const
|
|
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:
|
|
2018
|
-
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 ${
|
|
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
|
-
|
|
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
|
+
}
|