@jarenjs/db 0.43.3 → 0.46.5

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/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; and an external in
88
- * the document that reaches no slot at all still forces the diversion,
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
- * @returns {Map<string, 'plain' | 'derived'>}
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) => (entry.externalSlotKinds.get(name) === 'derived'
353
- ? probeBox(externals[name]) === null
354
- : !bindable(externals[name])));
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
- if (mustDivert(entry, externals)) {
393
- return chain(fullScanOf(entry), (statement) =>
394
- chain(statement.all(fullScanParams(entry)), (rows) =>
395
- setResidualOf(entry, document)(rowsToDocs(checkRowBound(entry, rows)), externals)));
396
- }
397
- if (entry.planned.mode === 'set') {
398
- // the narrowed statement fetches candidates; the full document
399
- // then re-applies its own predicates (idempotent narrowing)
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' || mustDivert(entry, externals)) {
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), () => chain(
456
- diverted ? fullScanOf(entry) : statementOf(entry),
457
- (statement) => chain(
458
- statement.all(diverted ? fullScanParams(entry) : bindParams(entry, externals)),
459
- (rows) => {
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 = physicalPlan.expected.indexes
556
- .filter((index) => index.columns.some((column) => touchedColumns.has(column)))
557
- .map((index) => index.name);
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 phase A's: a missing external must
697
- // raise the ENGINE's error, a boolean or null cannot bind natively
698
- for (const slot of entry.slots) {
699
- if ('external' in slot && !bindable(externals[slot.external]))
700
- return runResidual(entry, document, externals);
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, derivedValue, memberAt,
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 storage
62
- * vocabulary. `derive` says what is COMPUTED from the selected member,
63
- * never how the member is selected, so the singular-path rule and its
64
- * `JD0004` are untouched; these are the refusals a derived index adds.
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
- return { kind: null, precision: undefined };
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 'bbox'), got ${JSON.stringify(declared)} — `
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 path declares several`,
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
- `a ${declared} index is never unique: distinct positions share a cell (and a box edge) by construction`,
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
- return { kind: 'bbox', precision: undefined };
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
- if (mapping !== null) {
870
- for (const name of Object.keys(mapping.entities))
871
- entityPlans.set(name, planEntity(name, mapping.entities[name], mapping, dialect));
872
- for (const joinName of Object.keys(mapping.joinTables)) {
873
- entityPlans.set(joinName,
874
- planJoinTable(joinName, mapping.joinTables[joinName], mapping, dialect));
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), () =>