@jarenjs/db 0.43.3 → 0.46.4
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 +157 -13
- package/README.md +319 -33
- package/dist/types/algebra.d.ts +32 -2
- package/dist/types/ddl.d.ts +31 -6
- package/dist/types/derive.d.ts +105 -16
- package/dist/types/dialect.d.ts +11 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/knn.d.ts +69 -0
- package/dist/types/migrate.d.ts +2 -1
- package/dist/types/plan.d.ts +11 -4
- package/dist/types/query.d.ts +11 -0
- package/docs/LIVE-FORMAT.md +30 -0
- package/docs/MIGRATION-FORMAT.md +10 -0
- package/docs/MODEL-FORMAT.md +298 -27
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +8 -2
- package/schemas/jaren-migration.schema.json +8 -2
- package/schemas/jaren-model.draft-07.schema.json +16 -2
- package/schemas/jaren-model.schema.json +16 -2
- package/src/algebra.js +20 -2
- package/src/ddl.js +234 -26
- package/src/derive.js +158 -19
- package/src/dialect.js +134 -2
- package/src/dialects/sqlite.js +33 -4
- package/src/emit.js +30 -6
- package/src/index.js +5 -3
- package/src/knn.js +96 -0
- package/src/live.js +87 -7
- package/src/migrate.js +55 -4
- package/src/plan.js +280 -36
- package/src/query.js +177 -44
- package/src/store.js +151 -60
package/src/query.js
CHANGED
|
@@ -18,6 +18,17 @@
|
|
|
18
18
|
* residual over the full collection instead of the native statement —
|
|
19
19
|
* SQLite cannot bind a boolean, a `null` needs Jaren's semantics, and
|
|
20
20
|
* a missing external must raise the ENGINE's error, not a driver's.
|
|
21
|
+
* Two externals never bind at all and divert by their own rule: a
|
|
22
|
+
* region reaching the statement through derived slots diverts when it
|
|
23
|
+
* has no box, and a k-nearest probe — which the plan scores in the
|
|
24
|
+
* engine, never in SQL — diverts when it is not a vector of the
|
|
25
|
+
* column's width, so the engine answers what it answers everywhere.
|
|
26
|
+
*
|
|
27
|
+
* The k-nearest mode (`plan.rank`) is a set residual whose candidates
|
|
28
|
+
* an ordering chose: the statement fetches (identity, column) under
|
|
29
|
+
* the pushed WHERE, the engine scores and cuts (`knn.js`), the
|
|
30
|
+
* winners' documents are fetched by identity through the dialect, and
|
|
31
|
+
* the whole document runs over them.
|
|
21
32
|
*/
|
|
22
33
|
|
|
23
34
|
import { createSemanticCache } from '@jarenjs/core/cache';
|
|
@@ -31,7 +42,8 @@ import {
|
|
|
31
42
|
import { emitPlan, emitEntityPlan, createEntityPredicateEmitters } from './emit.js';
|
|
32
43
|
import { selectPlan } from './algebra.js';
|
|
33
44
|
import { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
|
|
34
|
-
import { derivedSlotValue, probeBox } from './derive.js';
|
|
45
|
+
import { derivedSlotValue, probeBox, probeVector, columnScore } from './derive.js';
|
|
46
|
+
import { cutCandidates, identityBatches } from './knn.js';
|
|
35
47
|
import { deterministicFragment, registerFragment } from './udf.js';
|
|
36
48
|
import {
|
|
37
49
|
normalizeProfile, translateProfilePredicate,
|
|
@@ -84,20 +96,25 @@ function slotValue(slot, externals) {
|
|
|
84
96
|
* How one external reaches the statement. An external reached ONLY
|
|
85
97
|
* through derived slots is bindable when its bound value HAS a box —
|
|
86
98
|
* the object itself never had to be bindable. One reached directly
|
|
87
|
-
* must be a string or a finite number, as before;
|
|
88
|
-
*
|
|
99
|
+
* must be a string or a finite number, as before; a k-nearest PROBE
|
|
100
|
+
* reaches no slot (it is scored in the engine) and is bindable when
|
|
101
|
+
* its value is a vector of the column's width; and an external in the
|
|
102
|
+
* document that reaches nothing at all still forces the diversion,
|
|
89
103
|
* because the residual needs the engine's own semantics for it.
|
|
90
104
|
* @param {import('./emit.js').ParamSlot[]} slots
|
|
91
|
-
* @
|
|
105
|
+
* @param {import('./algebra.js').PlanRank | null} rank
|
|
106
|
+
* @returns {Map<string, 'plain' | 'derived' | 'probe'>}
|
|
92
107
|
*/
|
|
93
|
-
function externalSlotKinds(slots) {
|
|
94
|
-
/** @type {Map<string, 'plain' | 'derived'>} */
|
|
108
|
+
function externalSlotKinds(slots, rank) {
|
|
109
|
+
/** @type {Map<string, 'plain' | 'derived' | 'probe'>} */
|
|
95
110
|
const kinds = new Map();
|
|
96
111
|
for (const slot of slots) {
|
|
97
112
|
if ('external' in slot) kinds.set(slot.external, 'plain');
|
|
98
113
|
else if ('derived' in slot && !kinds.has(slot.derived.external))
|
|
99
114
|
kinds.set(slot.derived.external, 'derived');
|
|
100
115
|
}
|
|
116
|
+
if (rank !== null && 'ext' in rank.probe && !kinds.has(rank.probe.ext))
|
|
117
|
+
kinds.set(rank.probe.ext, 'probe');
|
|
101
118
|
return kinds;
|
|
102
119
|
}
|
|
103
120
|
|
|
@@ -123,6 +140,12 @@ export function createQueryEngine(context) {
|
|
|
123
140
|
collection: collection.name,
|
|
124
141
|
schema: collection.schema,
|
|
125
142
|
columnByCanonical: physicalPlan.columnByCanonical,
|
|
143
|
+
// the R*Tree virtual tables this collection's `bbox` column sets are
|
|
144
|
+
// realized as, by stem — empty under `physical: 'columns'`, and
|
|
145
|
+
// empty on a driver whose build carries no R*Tree module, because
|
|
146
|
+
// the physical plan already fell back there
|
|
147
|
+
virtualByStem: new Map((physicalPlan.virtualTables ?? [])
|
|
148
|
+
.map((virtual) => [virtual.stem, { name: virtual.name, columns: virtual.columns }])),
|
|
126
149
|
operators,
|
|
127
150
|
};
|
|
128
151
|
const physical = {
|
|
@@ -130,6 +153,18 @@ export function createQueryEngine(context) {
|
|
|
130
153
|
keyColumn: physicalPlan.keyColumn,
|
|
131
154
|
docColumn: physicalPlan.docColumn,
|
|
132
155
|
};
|
|
156
|
+
/** The k-nearest counters `stats()` reports: how many rows the
|
|
157
|
+
* fetch scored and how many candidates the cut kept, so a
|
|
158
|
+
* duplicate-heavy collection is visible rather than merely slow —
|
|
159
|
+
* plus `diverted`, the calls whose plan WAS the cut and whose bound
|
|
160
|
+
* probe was not a vector of the column's width, so the whole
|
|
161
|
+
* collection was read instead. A plan is recognized once and bound
|
|
162
|
+
* many times; without that counter a probe of the wrong width turns
|
|
163
|
+
* a k-nearest query into a full scan that `explain()` still calls
|
|
164
|
+
* `knn`, which is the one thing this mode may not do quietly. */
|
|
165
|
+
const knnStats = { queries: 0, rows: 0, candidates: 0, fullFetches: 0, diverted: 0 };
|
|
166
|
+
/** The by-identities fetch statements, one per batch size. */
|
|
167
|
+
const identityFetch = new Map();
|
|
133
168
|
|
|
134
169
|
/** The `compileJsonQuery` options for an inline residual: the
|
|
135
170
|
* profile's engine limits plus the store's registered operators. */
|
|
@@ -252,7 +287,11 @@ export function createQueryEngine(context) {
|
|
|
252
287
|
sql: emitted.sql,
|
|
253
288
|
slots: emitted.slots,
|
|
254
289
|
externalNames,
|
|
255
|
-
externalSlotKinds: externalSlotKinds(emitted.slots),
|
|
290
|
+
externalSlotKinds: externalSlotKinds(emitted.slots, plan.rank),
|
|
291
|
+
// a literal probe is normalized once, here; an external one per
|
|
292
|
+
// call, from the bound value
|
|
293
|
+
probe: plan.rank !== null && 'lit' in plan.rank.probe
|
|
294
|
+
? probeVector(plan.rank.probe.lit, plan.rank.dims) : null,
|
|
256
295
|
dependencies: planned.analysis.dependencies,
|
|
257
296
|
limits: planned.analysis.limits,
|
|
258
297
|
residualLimits: limits,
|
|
@@ -349,12 +388,90 @@ export function createQueryEngine(context) {
|
|
|
349
388
|
|
|
350
389
|
/** Must this call divert to the residual? */
|
|
351
390
|
const mustDivert = (entry, externals) =>
|
|
352
|
-
entry.externalNames.some((name) =>
|
|
353
|
-
|
|
354
|
-
|
|
391
|
+
entry.externalNames.some((name) => {
|
|
392
|
+
const kind = entry.externalSlotKinds.get(name);
|
|
393
|
+
if (kind === 'derived') return probeBox(externals[name]) === null;
|
|
394
|
+
if (kind === 'probe') return probeVector(externals[name], entry.plan.rank.dims) === null;
|
|
395
|
+
return !bindable(externals[name]);
|
|
396
|
+
});
|
|
355
397
|
|
|
356
398
|
const rowsToDocs = (rows) => rows.map((row) => JSON.parse(row.doc));
|
|
357
399
|
|
|
400
|
+
/**
|
|
401
|
+
* The documents of the given row identities, in identity order,
|
|
402
|
+
* through the dialect's by-identities statement — batched, and each
|
|
403
|
+
* batch padded to a prepared size (`identityBatches`).
|
|
404
|
+
* @param {any[]} identities
|
|
405
|
+
* @returns {any} value-or-promise of the documents
|
|
406
|
+
*/
|
|
407
|
+
const fetchByIdentities = (identities) => {
|
|
408
|
+
const batches = identityBatches(identities);
|
|
409
|
+
/** @type {any[]} */
|
|
410
|
+
const docs = [];
|
|
411
|
+
const next = (i) => {
|
|
412
|
+
if (i >= batches.length) return docs;
|
|
413
|
+
const batch = batches[i];
|
|
414
|
+
let statement = identityFetch.get(batch.size);
|
|
415
|
+
if (statement === undefined) {
|
|
416
|
+
statement = connection.prepare(dialect.dml.selectByIdentities(physical, batch.size));
|
|
417
|
+
identityFetch.set(batch.size, statement);
|
|
418
|
+
}
|
|
419
|
+
return chain(statement, (prepared) => chain(prepared.all(batch.params), (rows) => {
|
|
420
|
+
for (const row of rows) docs.push(JSON.parse(row.doc));
|
|
421
|
+
return next(i + 1);
|
|
422
|
+
}));
|
|
423
|
+
};
|
|
424
|
+
return next(0);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The k-nearest candidates: every fetched row's column scored
|
|
429
|
+
* against the probe, the cut applied at `offset + limit` with the
|
|
430
|
+
* plan's margin, and the winners' documents fetched by identity.
|
|
431
|
+
* The engine then decides over them (the set residual).
|
|
432
|
+
* @param {any} entry
|
|
433
|
+
* @param {any} externals
|
|
434
|
+
* @returns {any} value-or-promise of the candidate documents
|
|
435
|
+
*/
|
|
436
|
+
const knnCandidates = (entry, externals) => {
|
|
437
|
+
const rank = entry.plan.rank;
|
|
438
|
+
const probe = entry.probe ?? probeVector(externals[rank.probe.ext], rank.dims);
|
|
439
|
+
return chain(statementOf(entry), (statement) =>
|
|
440
|
+
chain(statement.all(bindParams(entry, externals)), (rows) => {
|
|
441
|
+
checkRowBound(entry, rows);
|
|
442
|
+
const scored = rows.map((row) =>
|
|
443
|
+
({ identity: row.rid, score: columnScore(row.vec, rank.dims, probe) }));
|
|
444
|
+
const cut = cutCandidates(scored, rank.offset + rank.limit, rank.margin);
|
|
445
|
+
knnStats.queries++;
|
|
446
|
+
knnStats.rows += rows.length;
|
|
447
|
+
knnStats.candidates += cut.identities.length;
|
|
448
|
+
if (cut.full) knnStats.fullFetches++;
|
|
449
|
+
return fetchByIdentities(cut.identities);
|
|
450
|
+
}));
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The documents a residual runs over: the whole collection when the
|
|
455
|
+
* call diverts (still wearing the profile's mandatory predicate and
|
|
456
|
+
* row bound), the narrowed fetch in set mode, the cut in knn mode.
|
|
457
|
+
* @param {any} entry
|
|
458
|
+
* @param {any} externals
|
|
459
|
+
* @param {boolean} diverted
|
|
460
|
+
* @returns {any} value-or-promise of the documents
|
|
461
|
+
*/
|
|
462
|
+
const candidatesOf = (entry, externals, diverted) => {
|
|
463
|
+
if (diverted) {
|
|
464
|
+
if (entry.planned.mode === 'knn') knnStats.diverted++;
|
|
465
|
+
return chain(fullScanOf(entry), (statement) =>
|
|
466
|
+
chain(statement.all(fullScanParams(entry)), (rows) =>
|
|
467
|
+
rowsToDocs(checkRowBound(entry, rows))));
|
|
468
|
+
}
|
|
469
|
+
if (entry.planned.mode === 'knn') return knnCandidates(entry, externals);
|
|
470
|
+
return chain(statementOf(entry), (statement) =>
|
|
471
|
+
chain(statement.all(bindParams(entry, externals)), (rows) =>
|
|
472
|
+
rowsToDocs(checkRowBound(entry, rows))));
|
|
473
|
+
};
|
|
474
|
+
|
|
358
475
|
const aggregateResult = (entry, row) => {
|
|
359
476
|
const fn = entry.plan.aggregate.fn;
|
|
360
477
|
const value = row?.value ?? null;
|
|
@@ -389,17 +506,14 @@ export function createQueryEngine(context) {
|
|
|
389
506
|
const entry = entryFor(document, strict, profile, pushdown);
|
|
390
507
|
|
|
391
508
|
return chain(guardScan(entry), () => {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
return chain(statementOf(entry), (statement) =>
|
|
401
|
-
chain(statement.all(bindParams(entry, externals)), (rows) =>
|
|
402
|
-
setResidualOf(entry, document)(rowsToDocs(checkRowBound(entry, rows)), externals)));
|
|
509
|
+
const diverted = mustDivert(entry, externals);
|
|
510
|
+
if (diverted || entry.planned.mode === 'set' || entry.planned.mode === 'knn') {
|
|
511
|
+
// the candidates — the whole collection, the narrowed fetch, or
|
|
512
|
+
// the k-nearest cut — and the full document over them, which
|
|
513
|
+
// re-applies its own predicates and ordering (idempotent
|
|
514
|
+
// narrowing: the fetch decided nothing)
|
|
515
|
+
return chain(candidatesOf(entry, externals, diverted), (docs) =>
|
|
516
|
+
setResidualOf(entry, document)(docs, externals));
|
|
403
517
|
}
|
|
404
518
|
if (entry.planned.mode === 'row') {
|
|
405
519
|
return chain(statementOf(entry), (statement) =>
|
|
@@ -448,19 +562,16 @@ export function createQueryEngine(context) {
|
|
|
448
562
|
if (done) return Promise.resolve({ done: true, value: undefined });
|
|
449
563
|
if (bufferedAt < buffered.length) return Promise.resolve(nextFromBuffer());
|
|
450
564
|
|
|
451
|
-
if (entry.planned.mode === 'set' ||
|
|
565
|
+
if (entry.planned.mode === 'set' || entry.planned.mode === 'knn'
|
|
566
|
+
|| mustDivert(entry, externals)) {
|
|
452
567
|
// the barrier: materialize candidates, pack the result items
|
|
453
568
|
if (materialized === null) {
|
|
454
569
|
const diverted = mustDivert(entry, externals);
|
|
455
|
-
materialized = Promise.resolve(chain(guardScan(entry), () =>
|
|
456
|
-
diverted
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
buffered = packedResidualOf(entry, document)(
|
|
461
|
-
rowsToDocs(checkRowBound(entry, rows)), externals);
|
|
462
|
-
bufferedAt = 0;
|
|
463
|
-
}))));
|
|
570
|
+
materialized = Promise.resolve(chain(guardScan(entry), () =>
|
|
571
|
+
chain(candidatesOf(entry, externals, diverted), (docs) => {
|
|
572
|
+
buffered = packedResidualOf(entry, document)(docs, externals);
|
|
573
|
+
bufferedAt = 0;
|
|
574
|
+
})));
|
|
464
575
|
}
|
|
465
576
|
return materialized.then(() => {
|
|
466
577
|
if (bufferedAt < buffered.length) return nextFromBuffer();
|
|
@@ -537,12 +648,17 @@ export function createQueryEngine(context) {
|
|
|
537
648
|
const entry = entryFor(document, strict, profile, pushdown);
|
|
538
649
|
|
|
539
650
|
const touchedColumns = new Set();
|
|
651
|
+
// an R*Tree probe touches no generated column at all — the index it
|
|
652
|
+
// reads IS a table — so the virtual tables are collected beside the
|
|
653
|
+
// declared indexes and named in the same `indexes` list
|
|
654
|
+
const touchedVirtual = new Set();
|
|
540
655
|
const collectColumns = (pred) => {
|
|
541
656
|
if (pred === null) return;
|
|
542
657
|
if (pred.p === 'and' || pred.p === 'or') pred.items.forEach(collectColumns);
|
|
543
658
|
else if (pred.p === 'not') collectColumns(pred.item);
|
|
544
659
|
else if (pred.p === 'bboxOverlap')
|
|
545
660
|
for (const column of Object.values(pred.columns)) touchedColumns.add(column);
|
|
661
|
+
else if (pred.p === 'bboxRtree') touchedVirtual.add(pred.table);
|
|
546
662
|
else if (pred.p === 'cellIn' || pred.p === 'cellPrefix')
|
|
547
663
|
touchedColumns.add(pred.column);
|
|
548
664
|
else if ('ref' in pred && pred.ref?.column) touchedColumns.add(pred.ref.column);
|
|
@@ -552,9 +668,12 @@ export function createQueryEngine(context) {
|
|
|
552
668
|
if (term.ref.column !== null) touchedColumns.add(term.ref.column);
|
|
553
669
|
}
|
|
554
670
|
if (entry.plan.aggregate?.ref?.column) touchedColumns.add(entry.plan.aggregate.ref.column);
|
|
555
|
-
const indexes =
|
|
556
|
-
.
|
|
557
|
-
|
|
671
|
+
const indexes = [
|
|
672
|
+
...physicalPlan.expected.indexes
|
|
673
|
+
.filter((index) => index.columns.some((column) => touchedColumns.has(column)))
|
|
674
|
+
.map((index) => index.name),
|
|
675
|
+
...[...touchedVirtual].sort(),
|
|
676
|
+
];
|
|
558
677
|
|
|
559
678
|
const params = entry.slots.map((slot) => {
|
|
560
679
|
if ('external' in slot) return { external: slot.external };
|
|
@@ -566,8 +685,10 @@ export function createQueryEngine(context) {
|
|
|
566
685
|
return bindable(value) ? value : null;
|
|
567
686
|
});
|
|
568
687
|
|
|
688
|
+
const rank = entry.plan.rank;
|
|
569
689
|
return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
|
|
570
690
|
chain(statement.all(eqpParams), (rows) => ({
|
|
691
|
+
mode: entry.planned.mode,
|
|
571
692
|
externals: [...entry.externalNames],
|
|
572
693
|
operators: [...entry.dependencies.operators],
|
|
573
694
|
functions: [...entry.dependencies.functions],
|
|
@@ -578,10 +699,22 @@ export function createQueryEngine(context) {
|
|
|
578
699
|
indexes,
|
|
579
700
|
prefilters: entry.planned.prefilters.map((prefilter) => ({ ...prefilter,
|
|
580
701
|
columns: [...prefilter.columns] })),
|
|
702
|
+
// the k-nearest stage, when the plan has one: what the fetch
|
|
703
|
+
// reads, the window the cut serves, the margin it keeps, and
|
|
704
|
+
// who decides the order — always the engine
|
|
705
|
+
rank: rank === null ? null : {
|
|
706
|
+
column: rank.column,
|
|
707
|
+
dims: rank.dims,
|
|
708
|
+
probe: 'lit' in rank.probe ? { literal: [...rank.probe.lit] } : { external: rank.probe.ext },
|
|
709
|
+
limit: rank.limit,
|
|
710
|
+
offset: rank.offset,
|
|
711
|
+
margin: rank.margin,
|
|
712
|
+
decides: 'engine',
|
|
713
|
+
},
|
|
581
714
|
residual: entry.planned.mode === 'native'
|
|
582
715
|
? null
|
|
583
716
|
: { mode: entry.planned.mode, reasons: entry.planned.reasons },
|
|
584
|
-
barriers: entry.planned.mode === 'set'
|
|
717
|
+
barriers: entry.planned.mode === 'set' || entry.planned.mode === 'knn'
|
|
585
718
|
? entry.planned.reasons.map((r) => ({ operator: r.construct, reason: r.reason }))
|
|
586
719
|
: [],
|
|
587
720
|
udfs: [...entry.planned.udfs],
|
|
@@ -589,7 +722,7 @@ export function createQueryEngine(context) {
|
|
|
589
722
|
})));
|
|
590
723
|
};
|
|
591
724
|
|
|
592
|
-
return { execute, query, explain, shape };
|
|
725
|
+
return { execute, query, explain, shape, stats: () => ({ knn: { ...knnStats } }) };
|
|
593
726
|
}
|
|
594
727
|
|
|
595
728
|
// ————— The entity query surface (the second document kind) —————
|
|
@@ -693,15 +826,15 @@ export function createEntityQueryEngine(context) {
|
|
|
693
826
|
}
|
|
694
827
|
return runResidual(entry, document, externals);
|
|
695
828
|
}
|
|
696
|
-
// bind-time diversion, exactly
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
829
|
+
// bind-time diversion, exactly the collection engine's: every slot
|
|
830
|
+
// binds through `slotValue` — a derived slot included — and a value
|
|
831
|
+
// the database cannot take (a missing external, a boolean, a null,
|
|
832
|
+
// a region with no box) sends the call to the residual, where the
|
|
833
|
+
// ENGINE raises its own error or answers with its own semantics
|
|
834
|
+
const params = entry.slots.map((slot) => slotValue(slot, externals));
|
|
835
|
+
if (params.some((value) => !bindable(value)))
|
|
836
|
+
return runResidual(entry, document, externals);
|
|
702
837
|
if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
|
|
703
|
-
const params = entry.slots.map((slot) =>
|
|
704
|
-
('literal' in slot ? slot.literal : externals[slot.external]));
|
|
705
838
|
return chain(entry.statement, (statement) => {
|
|
706
839
|
if (entry.planned.plan.aggregate === 'count')
|
|
707
840
|
return chain(statement.get(params), (row) => row?.value ?? 0);
|
package/src/store.js
CHANGED
|
@@ -37,8 +37,8 @@ import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js'
|
|
|
37
37
|
import { createJobEngine } from './jobs.js';
|
|
38
38
|
import { collectEntityRoots } from './plan.js';
|
|
39
39
|
import {
|
|
40
|
-
DERIVE_KINDS, PRECISION_MIN, PRECISION_MAX,
|
|
41
|
-
storedMemberForm, registerDeriveFunctions,
|
|
40
|
+
DERIVE_KINDS, PHYSICAL_KINDS, PRECISION_MIN, PRECISION_MAX, DIMS_MIN, DIMS_MAX,
|
|
41
|
+
derivedValue, memberAt, storedMemberForm, registerDeriveFunctions,
|
|
42
42
|
} from './derive.js';
|
|
43
43
|
|
|
44
44
|
/** The model format version this store implements. */
|
|
@@ -58,51 +58,119 @@ function modelError(code, reason, docPath) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Normalize one index's `derive` declaration — the spatial
|
|
62
|
-
* vocabulary. `derive` says what is COMPUTED from the selected
|
|
63
|
-
* never how the member is selected, so the singular-path rule
|
|
64
|
-
* `JD0004` are untouched; these are the refusals a derived
|
|
61
|
+
* Normalize one index's `derive` declaration — the spatial and vector
|
|
62
|
+
* storage vocabulary. `derive` says what is COMPUTED from the selected
|
|
63
|
+
* member, never how the member is selected, so the singular-path rule
|
|
64
|
+
* and its `JD0004` are untouched; these are the refusals a derived
|
|
65
|
+
* index adds.
|
|
65
66
|
*
|
|
66
67
|
* Every one of them is a mistake worth catching at open rather than at
|
|
67
68
|
* the first query that quietly returns nothing.
|
|
68
69
|
* @param {any} index - the declared index member
|
|
69
70
|
* @param {string[]} paths
|
|
70
71
|
* @param {string} docPath
|
|
71
|
-
* @returns {{ kind: string | null, precision: number | undefined
|
|
72
|
+
* @returns {{ kind: string | null, precision: number | undefined,
|
|
73
|
+
* physical: string | undefined, dims: number | undefined }}
|
|
72
74
|
*/
|
|
73
75
|
function normalizeDerive(index, paths, docPath) {
|
|
74
76
|
const declared = index.derive;
|
|
77
|
+
const none = { precision: undefined, physical: undefined, dims: undefined };
|
|
75
78
|
if (declared === undefined) {
|
|
76
79
|
if (index.precision !== undefined) {
|
|
77
80
|
throw modelError('JD0004',
|
|
78
81
|
"precision belongs to a derived index — declare derive: 'geohash' beside it",
|
|
79
82
|
`${docPath}/precision`);
|
|
80
83
|
}
|
|
81
|
-
|
|
84
|
+
if (index.physical !== undefined) {
|
|
85
|
+
throw modelError('JD0004',
|
|
86
|
+
"physical names the shape a derive: 'bbox' index takes on disk; an undecorated "
|
|
87
|
+
+ 'index has only one shape',
|
|
88
|
+
`${docPath}/physical`);
|
|
89
|
+
}
|
|
90
|
+
if (index.dims !== undefined) {
|
|
91
|
+
throw modelError('JD0004',
|
|
92
|
+
`index '${index.name}': dims is the width of a derive: 'vector' column — declare `
|
|
93
|
+
+ 'derive beside it; an undecorated index has no width',
|
|
94
|
+
`${docPath}/dims`);
|
|
95
|
+
}
|
|
96
|
+
return { ...none, kind: null };
|
|
82
97
|
}
|
|
83
98
|
if (typeof declared !== 'string' || !DERIVE_KINDS.has(declared)) {
|
|
84
99
|
throw modelError('JD0004',
|
|
85
|
-
`derive is a closed set ('geohash' or '
|
|
100
|
+
`index '${index.name}': derive is a closed set ('geohash', 'bbox' or 'vector'), got `
|
|
101
|
+
+ `${JSON.stringify(declared)} — `
|
|
86
102
|
+ 'an open expression member would be a second query language inside the model',
|
|
87
103
|
`${docPath}/derive`);
|
|
88
104
|
}
|
|
89
105
|
if (paths.length !== 1) {
|
|
90
106
|
throw modelError('JD0004',
|
|
91
|
-
`a ${declared} index derives its columns from ONE member; a composite
|
|
107
|
+
`index '${index.name}': a ${declared} index derives its columns from ONE member; a composite `
|
|
108
|
+
+ 'path declares several',
|
|
92
109
|
`${docPath}/path`);
|
|
93
110
|
}
|
|
94
111
|
if (index.unique === true) {
|
|
95
112
|
throw modelError('JD0004',
|
|
96
|
-
|
|
113
|
+
declared === 'vector'
|
|
114
|
+
? `index '${index.name}': a vector index is never unique — it is a column nothing seeks, `
|
|
115
|
+
+ 'and two documents may carry one embedding'
|
|
116
|
+
: `index '${index.name}': a ${declared} index is never unique: distinct positions share a `
|
|
117
|
+
+ 'cell (and a box edge) by construction',
|
|
97
118
|
`${docPath}/unique`);
|
|
98
119
|
}
|
|
120
|
+
if (declared === 'vector') {
|
|
121
|
+
if (index.precision !== undefined) {
|
|
122
|
+
throw modelError('JD0004',
|
|
123
|
+
`index '${index.name}': precision applies to a geohash index; a vector column has a `
|
|
124
|
+
+ 'width (dims), not a cell size',
|
|
125
|
+
`${docPath}/precision`);
|
|
126
|
+
}
|
|
127
|
+
if (index.physical !== undefined) {
|
|
128
|
+
throw modelError('JD0004',
|
|
129
|
+
`index '${index.name}': physical applies to a bbox index; a vector column has one `
|
|
130
|
+
+ 'shape on disk — a stored packed column, on every driver',
|
|
131
|
+
`${docPath}/physical`);
|
|
132
|
+
}
|
|
133
|
+
const dims = index.dims;
|
|
134
|
+
if (dims === undefined) {
|
|
135
|
+
throw modelError('JD0004',
|
|
136
|
+
`index '${index.name}': a vector index must declare dims (${DIMS_MIN}..${DIMS_MAX}) — `
|
|
137
|
+
+ 'the width is the identity of the column, and a column that accepted any width '
|
|
138
|
+
+ 'would rank vectors from different models against each other',
|
|
139
|
+
`${docPath}/dims`);
|
|
140
|
+
}
|
|
141
|
+
if (typeof dims !== 'number' || !Number.isInteger(dims) || dims < DIMS_MIN || dims > DIMS_MAX) {
|
|
142
|
+
throw modelError('JD0004',
|
|
143
|
+
`index '${index.name}': dims must be an integer ${DIMS_MIN}..${DIMS_MAX}, got ${JSON.stringify(dims)}`,
|
|
144
|
+
`${docPath}/dims`);
|
|
145
|
+
}
|
|
146
|
+
return { ...none, kind: 'vector', dims };
|
|
147
|
+
}
|
|
148
|
+
if (index.dims !== undefined) {
|
|
149
|
+
throw modelError('JD0004',
|
|
150
|
+
`index '${index.name}': dims is the width of a derive: 'vector' column; a ${declared} `
|
|
151
|
+
+ 'index has no width',
|
|
152
|
+
`${docPath}/dims`);
|
|
153
|
+
}
|
|
99
154
|
if (declared === 'bbox') {
|
|
100
155
|
if (index.precision !== undefined) {
|
|
101
156
|
throw modelError('JD0004',
|
|
102
157
|
'precision applies to a geohash index; a bbox index has no cell size',
|
|
103
158
|
`${docPath}/precision`);
|
|
104
159
|
}
|
|
105
|
-
|
|
160
|
+
const physical = index.physical;
|
|
161
|
+
if (physical !== undefined
|
|
162
|
+
&& (typeof physical !== 'string' || !PHYSICAL_KINDS.has(physical))) {
|
|
163
|
+
throw modelError('JD0004',
|
|
164
|
+
`physical is a closed set ('columns' or 'rtree'), got ${JSON.stringify(physical)}`,
|
|
165
|
+
`${docPath}/physical`);
|
|
166
|
+
}
|
|
167
|
+
return { ...none, kind: 'bbox', physical };
|
|
168
|
+
}
|
|
169
|
+
if (index.physical !== undefined) {
|
|
170
|
+
throw modelError('JD0004',
|
|
171
|
+
"physical applies to a bbox index; an R*Tree carries numbers, and a geohash cell "
|
|
172
|
+
+ 'is text',
|
|
173
|
+
`${docPath}/physical`);
|
|
106
174
|
}
|
|
107
175
|
const precision = index.precision;
|
|
108
176
|
if (precision === undefined) {
|
|
@@ -118,7 +186,7 @@ function normalizeDerive(index, paths, docPath) {
|
|
|
118
186
|
`precision must be an integer ${PRECISION_MIN}..${PRECISION_MAX}, got ${JSON.stringify(precision)}`,
|
|
119
187
|
`${docPath}/precision`);
|
|
120
188
|
}
|
|
121
|
-
return { kind: 'geohash', precision };
|
|
189
|
+
return { ...none, kind: 'geohash', precision };
|
|
122
190
|
}
|
|
123
191
|
|
|
124
192
|
/**
|
|
@@ -230,6 +298,8 @@ export function normalizeModel(model) {
|
|
|
230
298
|
unique: index.unique === true,
|
|
231
299
|
derive: derive.kind,
|
|
232
300
|
precision: derive.precision,
|
|
301
|
+
physical: derive.physical,
|
|
302
|
+
dims: derive.dims,
|
|
233
303
|
docPath: indexDocPath,
|
|
234
304
|
});
|
|
235
305
|
}
|
|
@@ -536,7 +606,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
|
|
|
536
606
|
};
|
|
537
607
|
|
|
538
608
|
const core = {
|
|
539
|
-
stats: () => ({ ...stats }),
|
|
609
|
+
stats: () => ({ ...stats, ...engine.stats() }),
|
|
540
610
|
model: collection,
|
|
541
611
|
queryShape: engine.shape,
|
|
542
612
|
// the D2 provider: value-or-promise, deliberately NOT lifted — a
|
|
@@ -846,6 +916,39 @@ export function openStore(model, options) {
|
|
|
846
916
|
*/
|
|
847
917
|
let topLevelTransaction = (fn) => withScope(opened.transaction, fn);
|
|
848
918
|
|
|
919
|
+
// ————— the rejection boundary around an ACQUIRED connection —————
|
|
920
|
+
// Initialization continues for a long way past `driver.open`:
|
|
921
|
+
// pragmas, shape verification, capture, jobs, readiness. Every one
|
|
922
|
+
// of those can refuse, and a refusal that walks away from the open
|
|
923
|
+
// handle leaks it — on Windows the database file simply stays
|
|
924
|
+
// locked, which is how three of these showed up as `EPERM` while
|
|
925
|
+
// a temporary directory was being removed. So: close exactly once
|
|
926
|
+
// on any failure after acquisition, and keep the initialization
|
|
927
|
+
// error primary — a close that also fails is retained beside it
|
|
928
|
+
// rather than replacing the reason the open was refused.
|
|
929
|
+
let closed = false;
|
|
930
|
+
/**
|
|
931
|
+
* Release the handle and re-reject with the original failure.
|
|
932
|
+
* @param {any} error
|
|
933
|
+
* @returns {Promise<never>}
|
|
934
|
+
*/
|
|
935
|
+
const failClosed = (error) => {
|
|
936
|
+
if (closed) return Promise.reject(error);
|
|
937
|
+
closed = true;
|
|
938
|
+
/** @param {any} closeError */
|
|
939
|
+
const both = (closeError) => Promise.reject(new AggregateError([error, closeError],
|
|
940
|
+
'the store failed to open, and closing the acquired connection failed too'));
|
|
941
|
+
let closing;
|
|
942
|
+
try {
|
|
943
|
+
closing = opened.close();
|
|
944
|
+
}
|
|
945
|
+
catch (closeError) {
|
|
946
|
+
return both(closeError);
|
|
947
|
+
}
|
|
948
|
+
return isThenable(closing)
|
|
949
|
+
? closing.then(() => Promise.reject(error), both)
|
|
950
|
+
: Promise.reject(error);
|
|
951
|
+
};
|
|
849
952
|
const dialect = connection.dialect;
|
|
850
953
|
// The PHYSICAL MAPPING BRANCH for derived index columns. A driver
|
|
851
954
|
// that can index a registered deterministic function generates
|
|
@@ -855,25 +958,46 @@ export function openStore(model, options) {
|
|
|
855
958
|
// drift — that is a migration, not an open.
|
|
856
959
|
const derivedMapping = connection.capabilities.deterministicIndexableFunctions === true
|
|
857
960
|
? 'virtual' : 'stored';
|
|
961
|
+
// the SECOND physical branch, and the same posture: a build
|
|
962
|
+
// without the R*Tree module maps `physical: 'rtree'` back onto
|
|
963
|
+
// the B-tree over the four columns and SAYS so through
|
|
964
|
+
// `explain().prefilters[].via` (MODEL-FORMAT §4). Refusing at open
|
|
965
|
+
// would break the format's stated portability promise; a silent
|
|
966
|
+
// fallback would break its stated honesty one
|
|
967
|
+
const rtreeCapable = connection.capabilities.rtree === true;
|
|
858
968
|
/** @type {Map<string, any>} */
|
|
859
969
|
const plans = new Map();
|
|
860
|
-
for (const [name, collection] of collections)
|
|
861
|
-
plans.set(name, planCollection(name, collection, dialect, { derived: derivedMapping }));
|
|
862
|
-
// a table whose column expression calls a function this connection
|
|
863
|
-
// has not registered cannot even be SELECTed (probed), so the
|
|
864
|
-
// registration precedes every statement over it
|
|
865
|
-
const needsDeriveFunctions = derivedMapping === 'virtual'
|
|
866
|
-
&& [...plans.values()].some((plan) => plan.derived.length > 0);
|
|
867
970
|
/** @type {Map<string, any>} */
|
|
868
971
|
const entityPlans = new Map();
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
972
|
+
// Planning can REFUSE — a derived index over a member the schema
|
|
973
|
+
// does not type as geography is JD0004 here, not at normalization,
|
|
974
|
+
// because the physical mapping it plans is a property of the
|
|
975
|
+
// driver that opened the connection. It is inside the boundary
|
|
976
|
+
// for that reason: the refusal is raised after acquisition.
|
|
977
|
+
try {
|
|
978
|
+
for (const [name, collection] of collections)
|
|
979
|
+
plans.set(name, planCollection(name, collection, dialect,
|
|
980
|
+
{ derived: derivedMapping, rtree: rtreeCapable }));
|
|
981
|
+
if (mapping !== null) {
|
|
982
|
+
for (const name of Object.keys(mapping.entities))
|
|
983
|
+
entityPlans.set(name, planEntity(name, mapping.entities[name], mapping, dialect));
|
|
984
|
+
for (const joinName of Object.keys(mapping.joinTables)) {
|
|
985
|
+
entityPlans.set(joinName,
|
|
986
|
+
planJoinTable(joinName, mapping.joinTables[joinName], mapping, dialect));
|
|
987
|
+
}
|
|
875
988
|
}
|
|
876
989
|
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
return failClosed(error);
|
|
992
|
+
}
|
|
993
|
+
// a table whose column expression calls a function this connection
|
|
994
|
+
// has not registered cannot even be SELECTed (probed), so the
|
|
995
|
+
// registration precedes every statement over it. Only a VIRTUAL
|
|
996
|
+
// derived column needs one: a vector column is stored on every
|
|
997
|
+
// driver and a model with nothing else registers nothing
|
|
998
|
+
const needsDeriveFunctions = derivedMapping === 'virtual'
|
|
999
|
+
&& [...plans.values()].some((plan) => plan.generated.some(
|
|
1000
|
+
(column) => column.derive !== undefined && column.stored !== true));
|
|
877
1001
|
|
|
878
1002
|
const pragmas = chain(
|
|
879
1003
|
memory
|
|
@@ -894,39 +1018,6 @@ export function openStore(model, options) {
|
|
|
894
1018
|
return null;
|
|
895
1019
|
}))));
|
|
896
1020
|
|
|
897
|
-
// ————— the rejection boundary around an ACQUIRED connection —————
|
|
898
|
-
// Initialization continues for a long way past `driver.open`:
|
|
899
|
-
// pragmas, shape verification, capture, jobs, readiness. Every one
|
|
900
|
-
// of those can refuse, and a refusal that walks away from the open
|
|
901
|
-
// handle leaks it — on Windows the database file simply stays
|
|
902
|
-
// locked, which is how three of these showed up as `EPERM` while
|
|
903
|
-
// a temporary directory was being removed. So: close exactly once
|
|
904
|
-
// on any failure after acquisition, and keep the initialization
|
|
905
|
-
// error primary — a close that also fails is retained beside it
|
|
906
|
-
// rather than replacing the reason the open was refused.
|
|
907
|
-
let closed = false;
|
|
908
|
-
/**
|
|
909
|
-
* Release the handle and re-reject with the original failure.
|
|
910
|
-
* @param {any} error
|
|
911
|
-
* @returns {Promise<never>}
|
|
912
|
-
*/
|
|
913
|
-
const failClosed = (error) => {
|
|
914
|
-
if (closed) return Promise.reject(error);
|
|
915
|
-
closed = true;
|
|
916
|
-
/** @param {any} closeError */
|
|
917
|
-
const both = (closeError) => Promise.reject(new AggregateError([error, closeError],
|
|
918
|
-
'the store failed to open, and closing the acquired connection failed too'));
|
|
919
|
-
let closing;
|
|
920
|
-
try {
|
|
921
|
-
closing = opened.close();
|
|
922
|
-
}
|
|
923
|
-
catch (closeError) {
|
|
924
|
-
return both(closeError);
|
|
925
|
-
}
|
|
926
|
-
return isThenable(closing)
|
|
927
|
-
? closing.then(() => Promise.reject(error), both)
|
|
928
|
-
: Promise.reject(error);
|
|
929
|
-
};
|
|
930
1021
|
const opening = () => chain(pragmas, () =>
|
|
931
1022
|
chain(needsDeriveFunctions ? registerDeriveFunctions(connection) : null, () =>
|
|
932
1023
|
chain(ensureShape(connection, collections, plans, readOnly), () =>
|