@jarenjs/db 0.49.2 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +27 -15
- package/README.md +141 -41
- package/docs/JOBS-FORMAT.md +24 -8
- package/docs/LIVE-FORMAT.md +38 -9
- package/docs/MIGRATION-FORMAT.md +118 -36
- package/docs/MODEL-FORMAT.md +232 -30
- package/package.json +4 -5
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/src/capture.js +66 -28
- package/src/cli.js +225 -44
- package/src/ddl.js +23 -3
- package/src/dialects/sqlite.js +2 -1
- package/src/driver.js +63 -16
- package/src/drivers/wasm.js +1 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +10 -3
- package/src/entity.js +92 -47
- package/src/errors.js +25 -0
- package/src/index.js +2 -2
- package/src/jobs.js +40 -5
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/migrate.js +397 -191
- package/src/model.js +173 -8
- package/src/plan.js +135 -38
- package/src/query.js +138 -13
- package/src/store.js +221 -66
- package/src/tracker.js +173 -48
- package/types/index.d.ts +152 -10
- package/types/node.d.ts +3 -1
- package/types/typed.d.ts +58 -2
- package/types/wasm.d.ts +7 -0
- package/dist/types/algebra.d.ts +0 -230
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -154
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -170
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
package/src/query.js
CHANGED
|
@@ -39,7 +39,7 @@ import { chain } from './driver.js';
|
|
|
39
39
|
import {
|
|
40
40
|
planQuery, planEntityQuery, entityShape, planEntityPredicate, entityPathRef,
|
|
41
41
|
} from './plan.js';
|
|
42
|
-
import { emitPlan, emitEntityPlan, createEntityPredicateEmitters } from './emit.js';
|
|
42
|
+
import { emitPlan, emitEntityPlan, createEntityPredicateEmitters, UnrepresentablePath } from './emit.js';
|
|
43
43
|
import { selectPlan } from './algebra.js';
|
|
44
44
|
import { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
|
|
45
45
|
import { derivedSlotValue, probeBox, probeVector, columnScore } from './derive.js';
|
|
@@ -77,6 +77,33 @@ export function createQueryState(bound = undefined, operators = null,
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The answer for a native selection's items: the engine's result shape
|
|
82
|
+
* (`undefined | item | items`), or — when the document is a chain's
|
|
83
|
+
* element WINDOW, `[<phrase>]` (plan.js, `wrapped`) — the items as the
|
|
84
|
+
* ONE array that constructor yields, never singleton-unwrapped: an empty
|
|
85
|
+
* selection is `[]`, one row is `[row]`. Exactly the engine's answer for
|
|
86
|
+
* the same document, which is what lets a chain's `toArray()` push.
|
|
87
|
+
* @param {any} entry
|
|
88
|
+
* @param {any[]} items
|
|
89
|
+
* @returns {any}
|
|
90
|
+
*/
|
|
91
|
+
function answerOf(entry, items) {
|
|
92
|
+
return entry.planned.wrapped === true ? items : sequenceResult(items);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The same for one aggregate value: `[value]` under the window, and `[]`
|
|
97
|
+
* for an aggregate that answers nothing.
|
|
98
|
+
* @param {any} entry
|
|
99
|
+
* @param {any} value
|
|
100
|
+
* @returns {any}
|
|
101
|
+
*/
|
|
102
|
+
function wrapValue(entry, value) {
|
|
103
|
+
if (entry.planned.wrapped !== true) return value;
|
|
104
|
+
return value === undefined ? [] : [value];
|
|
105
|
+
}
|
|
106
|
+
|
|
80
107
|
/** @param {any} value - a bindable native parameter? */
|
|
81
108
|
function bindable(value) {
|
|
82
109
|
return typeof value === 'string'
|
|
@@ -299,8 +326,26 @@ export function createQueryEngine(context) {
|
|
|
299
326
|
return out;
|
|
300
327
|
};
|
|
301
328
|
|
|
302
|
-
|
|
303
|
-
|
|
329
|
+
let plan = shapePlan(planned.plan ?? selectPlan(collection.name));
|
|
330
|
+
let emitted;
|
|
331
|
+
try {
|
|
332
|
+
emitted = emitPlan(plan, dialect, physical);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (!(error instanceof UnrepresentablePath)) throw error;
|
|
336
|
+
// a member name the dialect cannot spell: the whole document runs
|
|
337
|
+
// in the set residual, named — and strict mode refuses it by name
|
|
338
|
+
if (strict) {
|
|
339
|
+
throw new DbCompileError('JD0010',
|
|
340
|
+
`strict mode refused a residual: 'path' — ${error.message}`, collection.docPath);
|
|
341
|
+
}
|
|
342
|
+
planned = {
|
|
343
|
+
...planned, plan: null, mode: 'set', rowReturn: null, udfs: [], prefilters: [],
|
|
344
|
+
series: null, reasons: [{ construct: 'path', reason: error.message }, ...planned.reasons],
|
|
345
|
+
};
|
|
346
|
+
plan = shapePlan(selectPlan(collection.name));
|
|
347
|
+
emitted = emitPlan(plan, dialect, physical);
|
|
348
|
+
}
|
|
304
349
|
const externalNames = planned.analysis.externals.map((e) => e.name);
|
|
305
350
|
const limits = profile === null ? undefined : profile.limits;
|
|
306
351
|
const entry = {
|
|
@@ -549,6 +594,12 @@ export function createQueryEngine(context) {
|
|
|
549
594
|
*/
|
|
550
595
|
const candidatesOf = (entry, externals, diverted) => {
|
|
551
596
|
if (diverted) {
|
|
597
|
+
// a diversion IS a full-table scan; the plan-shape check above
|
|
598
|
+
// only ever saw the native statement
|
|
599
|
+
if (entry.needsScanCheck) {
|
|
600
|
+
throw profileRefusal(`the profile refuses a full-table scan of '${collection.name}' `
|
|
601
|
+
+ '(a bound external the database cannot take diverted the call to the whole collection)');
|
|
602
|
+
}
|
|
552
603
|
if (entry.planned.mode === 'knn') knnStats.diverted++;
|
|
553
604
|
return chain(fullScanOf(entry), (statement) =>
|
|
554
605
|
chain(statement.all(fullScanParams(entry)), (rows) =>
|
|
@@ -612,26 +663,26 @@ export function createQueryEngine(context) {
|
|
|
612
663
|
const items = [];
|
|
613
664
|
for (const row of checkRowBound(entry, rows))
|
|
614
665
|
items.push(...entry.rowResidual(JSON.parse(row.doc), externals));
|
|
615
|
-
return
|
|
666
|
+
return answerOf(entry, items);
|
|
616
667
|
}));
|
|
617
668
|
}
|
|
618
669
|
return chain(statementOf(entry), (statement) => {
|
|
619
670
|
if (entry.plan.aggregate !== null) {
|
|
620
671
|
return chain(statement.get(bindParams(entry, externals)),
|
|
621
|
-
(row) => aggregateResult(entry, row));
|
|
672
|
+
(row) => wrapValue(entry, aggregateResult(entry, row)));
|
|
622
673
|
}
|
|
623
674
|
if (entry.plan.bucket !== null) {
|
|
624
675
|
return chain(statement.all(bindParams(entry, externals)), (rows) => {
|
|
625
676
|
const items = bucketItems(entry, checkRowBound(entry, rows));
|
|
626
677
|
if (items === null) return divertBucket(entry, document, externals);
|
|
627
678
|
countSeries(entry, 1, rows.length, items.length);
|
|
628
|
-
return
|
|
679
|
+
return answerOf(entry, items);
|
|
629
680
|
});
|
|
630
681
|
}
|
|
631
682
|
return chain(statement.all(bindParams(entry, externals)), (rows) => {
|
|
632
683
|
const docs = rowsToDocs(checkRowBound(entry, rows));
|
|
633
684
|
countSeries(entry, 1, docs.length, docs.length);
|
|
634
|
-
return
|
|
685
|
+
return answerOf(entry, docs);
|
|
635
686
|
});
|
|
636
687
|
});
|
|
637
688
|
});
|
|
@@ -664,6 +715,21 @@ export function createQueryEngine(context) {
|
|
|
664
715
|
if (done) return Promise.resolve({ done: true, value: undefined });
|
|
665
716
|
if (bufferedAt < buffered.length) return Promise.resolve(nextFromBuffer());
|
|
666
717
|
|
|
718
|
+
if (entry.planned.wrapped === true) {
|
|
719
|
+
// a chain's element window is ONE item — the array — whatever
|
|
720
|
+
// the plan mode; the cursor hands it over as `execute` answers it
|
|
721
|
+
if (materialized === null) {
|
|
722
|
+
materialized = Promise.resolve(chain(execute(document, options), (value) => {
|
|
723
|
+
buffered = [value];
|
|
724
|
+
bufferedAt = 0;
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
return materialized.then(() => {
|
|
728
|
+
if (bufferedAt < buffered.length) return nextFromBuffer();
|
|
729
|
+
done = true;
|
|
730
|
+
return { done: true, value: undefined };
|
|
731
|
+
});
|
|
732
|
+
}
|
|
667
733
|
if (entry.planned.mode === 'set' || entry.planned.mode === 'knn'
|
|
668
734
|
|| mustDivert(entry, externals)) {
|
|
669
735
|
// the barrier: materialize candidates, pack the result items
|
|
@@ -827,11 +893,14 @@ export function createQueryEngine(context) {
|
|
|
827
893
|
return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
|
|
828
894
|
chain(statement.all(eqpParams), (rows) => ({
|
|
829
895
|
mode: entry.planned.mode,
|
|
896
|
+
// a chain's element window (`[<phrase>]`): the phrase planned as
|
|
897
|
+
// if bare, its rows answered as the one array item
|
|
898
|
+
wrapped: entry.planned.wrapped === true,
|
|
830
899
|
externals: [...entry.externalNames],
|
|
831
900
|
operators: [...entry.dependencies.operators],
|
|
832
901
|
functions: [...entry.dependencies.functions],
|
|
833
902
|
collations: [...entry.dependencies.collations],
|
|
834
|
-
limits: entry.limits,
|
|
903
|
+
limits: entry.residualLimits ?? entry.limits,
|
|
835
904
|
sql: entry.sql,
|
|
836
905
|
params,
|
|
837
906
|
indexes,
|
|
@@ -876,6 +945,7 @@ export function createQueryEngine(context) {
|
|
|
876
945
|
// ————— The entity query surface (the second document kind) —————
|
|
877
946
|
|
|
878
947
|
import { mergeEntityRow, parseGraphRow } from './graph.js';
|
|
948
|
+
import { relationTables } from './model.js';
|
|
879
949
|
|
|
880
950
|
/** The default include depth bound (D14: printed, never silent). */
|
|
881
951
|
export const INCLUDE_DEPTH_DEFAULT = 3;
|
|
@@ -897,6 +967,10 @@ export function createEntityQueryEngine(context) {
|
|
|
897
967
|
const dialect = connection.dialect;
|
|
898
968
|
const q = dialect.quoteIdentifier;
|
|
899
969
|
const physicalOf = (name) => ({ table: mapping.entities[name].table });
|
|
970
|
+
// the relation tables of every root this engine serves (§10.1): the
|
|
971
|
+
// engine is the scope every entity set of the store shares, so a
|
|
972
|
+
// producer holding one set can follow a hop into another root
|
|
973
|
+
const relations = relationTables(entities);
|
|
900
974
|
|
|
901
975
|
const entryFor = (document, pushdown) => {
|
|
902
976
|
const key = ['E', document, dialect.name, pushdown];
|
|
@@ -907,6 +981,14 @@ export function createEntityQueryEngine(context) {
|
|
|
907
981
|
}
|
|
908
982
|
state.counters.misses++;
|
|
909
983
|
let planned = planEntityQuery(document, entities, mapping, operators);
|
|
984
|
+
if (planned.referenced.length === 0) {
|
|
985
|
+
// `$[*]` over the entity MAP answered the rows of every entity,
|
|
986
|
+
// mixed, and explain() named no table read; the root is the map
|
|
987
|
+
// of entity arrays, and a query ranges over one of them by name
|
|
988
|
+
throw new DbCompileError('JD0033',
|
|
989
|
+
'an entity query ranges over a declared entity array ($.<Entity>[*]); this '
|
|
990
|
+
+ 'document names none, so it has no rows to answer', '/entities');
|
|
991
|
+
}
|
|
910
992
|
if (!pushdown) {
|
|
911
993
|
planned = { ...planned, mode: 'set', plan: null,
|
|
912
994
|
reasons: [{ construct: 'pushdown', reason: 'disabled by the harness switch' }] };
|
|
@@ -986,11 +1068,11 @@ export function createEntityQueryEngine(context) {
|
|
|
986
1068
|
if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
|
|
987
1069
|
return chain(entry.statement, (statement) => {
|
|
988
1070
|
if (entry.planned.plan.aggregate === 'count')
|
|
989
|
-
return chain(statement.get(params), (row) => row?.value ?? 0);
|
|
1071
|
+
return chain(statement.get(params), (row) => wrapValue(entry, row?.value ?? 0));
|
|
990
1072
|
return chain(statement.all(params), (rows) => {
|
|
991
1073
|
const retEntity = entry.planned.plan.bindings
|
|
992
1074
|
.find((binding) => binding.name === entry.planned.plan.ret).entity;
|
|
993
|
-
return
|
|
1075
|
+
return answerOf(entry, rows.map((row) =>
|
|
994
1076
|
mergeEntityRow(mapping.entities[retEntity], row, '__doc')));
|
|
995
1077
|
});
|
|
996
1078
|
});
|
|
@@ -1001,6 +1083,7 @@ export function createEntityQueryEngine(context) {
|
|
|
1001
1083
|
const entry = entryFor(document, pushdown);
|
|
1002
1084
|
const base = {
|
|
1003
1085
|
mode: entry.planned.mode,
|
|
1086
|
+
wrapped: entry.planned.wrapped === true,
|
|
1004
1087
|
referenced: [...entry.planned.referenced],
|
|
1005
1088
|
reasons: entry.planned.reasons,
|
|
1006
1089
|
sql: entry.sql,
|
|
@@ -1018,7 +1101,7 @@ export function createEntityQueryEngine(context) {
|
|
|
1018
1101
|
})));
|
|
1019
1102
|
};
|
|
1020
1103
|
|
|
1021
|
-
return { execute, explain };
|
|
1104
|
+
return { execute, explain, relations };
|
|
1022
1105
|
}
|
|
1023
1106
|
|
|
1024
1107
|
/**
|
|
@@ -1048,6 +1131,11 @@ export function createLoadEngine(context, entityName) {
|
|
|
1048
1131
|
const refuse = (reason, path) => new DbCompileError('JD0032',
|
|
1049
1132
|
`${reason} (include path: ${path.join('.') || '<root>'})`,
|
|
1050
1133
|
entities.get(entityName)?.docPath);
|
|
1134
|
+
const isWindowBound = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
1135
|
+
/** An include's window inside its subquery: LIMIT, and OFFSET for a
|
|
1136
|
+
* `skip` — per parent row, since the subquery is correlated (§10.4). */
|
|
1137
|
+
const windowClause = (child) => (child.take !== undefined || (child.skip !== undefined && child.skip > 0)
|
|
1138
|
+
? ` ${dialect.limitClause(child.take ?? null, child.skip)}` : '');
|
|
1051
1139
|
|
|
1052
1140
|
/** Compile a where EXPRESSION over `$it` against one entity. */
|
|
1053
1141
|
const compileWhere = (expression, entity, path) => {
|
|
@@ -1118,6 +1206,7 @@ export function createLoadEngine(context, entityName) {
|
|
|
1118
1206
|
where: spec?.where !== undefined ? compileWhere(spec.where, entity, path) : null,
|
|
1119
1207
|
order: spec?.orderBy !== undefined ? compileOrder(spec.orderBy, entity, path) : null,
|
|
1120
1208
|
take: spec?.take,
|
|
1209
|
+
skip: spec?.skip,
|
|
1121
1210
|
includes: [],
|
|
1122
1211
|
};
|
|
1123
1212
|
const includeSpec = spec?.include;
|
|
@@ -1133,6 +1222,26 @@ export function createLoadEngine(context, entityName) {
|
|
|
1133
1222
|
[...path, relationName]);
|
|
1134
1223
|
}
|
|
1135
1224
|
const childSpec = includeSpec[relationName] === true ? {} : includeSpec[relationName];
|
|
1225
|
+
if (childSpec.count === true) {
|
|
1226
|
+
// a count counts EVERY related row; a where/take beside it was
|
|
1227
|
+
// dropped without a word, and the number answered was the total
|
|
1228
|
+
const dropped = ['where', 'orderBy', 'take', 'skip', 'include', 'after']
|
|
1229
|
+
.filter((member) => childSpec[member] !== undefined);
|
|
1230
|
+
if (dropped.length > 0) {
|
|
1231
|
+
throw refuse(`count: true counts every related row and takes no ${dropped.join('/')} — `
|
|
1232
|
+
+ 'load the rows to count a subset', [...path, relationName]);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
for (const member of ['take', 'skip']) {
|
|
1236
|
+
if (childSpec[member] !== undefined && !isWindowBound(childSpec[member]))
|
|
1237
|
+
throw refuse(`${member} must be a non-negative integer`, [...path, relationName]);
|
|
1238
|
+
}
|
|
1239
|
+
// a keyset cursor is one position in ONE ordered set; an include is
|
|
1240
|
+
// a set per parent, so it windows with skip/take and never seeks
|
|
1241
|
+
if (childSpec.after !== undefined) {
|
|
1242
|
+
throw refuse("'after' (keyset pagination) paginates the root — an include windows with skip and take",
|
|
1243
|
+
[...path, relationName]);
|
|
1244
|
+
}
|
|
1136
1245
|
const childName = relation.to;
|
|
1137
1246
|
const include = {
|
|
1138
1247
|
name: relationName,
|
|
@@ -1186,6 +1295,17 @@ export function createLoadEngine(context, entityName) {
|
|
|
1186
1295
|
const parentKey = parentNode.entityMapping.keys[0];
|
|
1187
1296
|
if (include.count === true) {
|
|
1188
1297
|
const childTable = mapping.entities[relation.to].table;
|
|
1298
|
+
if (relation.kind === 'manyToMany') {
|
|
1299
|
+
const join = mapping.joinTables[relation.joinTable];
|
|
1300
|
+
const own = join.left.entity === parentNode.entity.name ? join.left : join.right;
|
|
1301
|
+
return `(SELECT COUNT(*) FROM ${q(relation.joinTable)} AS ${q(childAlias)} `
|
|
1302
|
+
+ `WHERE ${q(childAlias)}.${q(own.column)} = ${q(parentAlias)}.${q(parentKey)})`;
|
|
1303
|
+
}
|
|
1304
|
+
if (relation.kind === 'oneToOne') {
|
|
1305
|
+
const childKey = mapping.entities[relation.to].keys[0];
|
|
1306
|
+
return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
1307
|
+
+ `WHERE ${q(childAlias)}.${q(childKey)} = ${q(parentAlias)}.${q(relation.via)})`;
|
|
1308
|
+
}
|
|
1189
1309
|
return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
1190
1310
|
+ `WHERE ${q(childAlias)}.${q(relation.via)} = ${q(parentAlias)}.${q(parentKey)})`;
|
|
1191
1311
|
}
|
|
@@ -1220,10 +1340,10 @@ export function createLoadEngine(context, entityName) {
|
|
|
1220
1340
|
+ (child.where !== null
|
|
1221
1341
|
? ` AND ${emitters.emitPred(rendered.aliasSql, rendered.docSql, child.where)}` : '')
|
|
1222
1342
|
+ ` ORDER BY ${orderSql.join(', ')}`
|
|
1223
|
-
+ (child
|
|
1343
|
+
+ windowClause(child)
|
|
1224
1344
|
: `SELECT ${rendered.aliasSql}.* FROM ${q(childTable)} AS ${q(childAlias)} `
|
|
1225
1345
|
+ `WHERE ${conditions.join(' AND ')} ORDER BY ${orderSql.join(', ')}`
|
|
1226
|
-
+ (child
|
|
1346
|
+
+ windowClause(child);
|
|
1227
1347
|
if (relation.kind === 'oneToOne') {
|
|
1228
1348
|
return `(SELECT json_object(${rendered.projection()}) FROM `
|
|
1229
1349
|
+ `(${inner} ${dialect.limitClause(1, undefined)}) AS ${q(childAlias)})`;
|
|
@@ -1250,6 +1370,11 @@ export function createLoadEngine(context, entityName) {
|
|
|
1250
1370
|
};
|
|
1251
1371
|
const emitters = createEntityPredicateEmitters(dialect, param);
|
|
1252
1372
|
const maxDepth = spec?.maxDepth ?? INCLUDE_DEPTH_DEFAULT;
|
|
1373
|
+
for (const member of ['take', 'skip']) {
|
|
1374
|
+
// interpolated into LIMIT/OFFSET as written: a string ran as SQL
|
|
1375
|
+
if (spec?.[member] !== undefined && !isWindowBound(spec[member]))
|
|
1376
|
+
throw refuse(`${member} must be a non-negative integer`, []);
|
|
1377
|
+
}
|
|
1253
1378
|
const tree = buildTree(entityName, spec ?? {}, 0, maxDepth, [], new Set());
|
|
1254
1379
|
const rendered = render(tree, 'r', param, emitters);
|
|
1255
1380
|
|