@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/src/migrate.js CHANGED
@@ -39,12 +39,15 @@ import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
39
39
  * generates them; one that cannot has them written, which is why the
40
40
  * planner emits a backfill for the second and not the first.
41
41
  * @param {any} connection
42
- * @returns {{ derived: 'virtual' | 'stored' }}
42
+ * @returns {{ derived: 'virtual' | 'stored', rtree: boolean }}
43
43
  */
44
44
  function mappingFor(connection) {
45
45
  return {
46
46
  derived: connection.capabilities?.deterministicIndexableFunctions === true
47
47
  ? 'virtual' : 'stored',
48
+ // the same reasoning for the R*Tree mapping: a build without the
49
+ // module plans (and verifies) the B-tree shape
50
+ rtree: connection.capabilities?.rtree === true,
48
51
  };
49
52
  }
50
53
 
@@ -102,6 +105,7 @@ function deriveStep(collection, plan, columnNames, note) {
102
105
  const entry = { name: column.name, derive: column.derive, segments: column.segments };
103
106
  if (column.precision !== undefined) entry.precision = column.precision;
104
107
  if (column.component !== undefined) entry.component = column.component;
108
+ if (column.dims !== undefined) entry.dims = column.dims;
105
109
  return entry;
106
110
  });
107
111
  return { kind: 'derive', collection, columns, note };
@@ -121,7 +125,7 @@ function deriveStep(collection, plan, columnNames, note) {
121
125
  * @param {any} fromModel
122
126
  * @param {any} toModel
123
127
  * @param {{ id?: string, dialect?: any,
124
- * derived?: 'virtual' | 'stored' }} [options]
128
+ * derived?: 'virtual' | 'stored', rtree?: boolean }} [options]
125
129
  * @returns {{ migration: any, report: {
126
130
  * renamed: { from: string, to: string }[],
127
131
  * added: string[], removed: string[],
@@ -132,7 +136,7 @@ export function planMigration(fromModel, toModel, options = undefined) {
132
136
  const dialect = options?.dialect ?? null;
133
137
  if (dialect === null || typeof dialect !== 'object')
134
138
  throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
135
- const mapping = { derived: options?.derived ?? 'virtual' };
139
+ const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false };
136
140
  const fromCollections = normalizeModel(fromModel);
137
141
  const toCollections = normalizeModel(toModel);
138
142
 
@@ -215,6 +219,33 @@ export function planMigration(fromModel, toModel, options = undefined) {
215
219
  // database refuses to drop a column under a live index) — then
216
220
  // everything runs in dependency order: drop indexes, drop columns,
217
221
  // add columns, create indexes
222
+ // the R*Tree half of the physical shape (MODEL-FORMAT §2.1,
223
+ // `physical`). Changing it in either direction is a physical change
224
+ // and needs a migration, not an open-time alteration: the virtual
225
+ // table and its three triggers leave BEFORE the columns they read
226
+ // are disturbed, and arrive AFTER them with a backfill — a
227
+ // generated column arrives populated, an R*Tree does not.
228
+ //
229
+ // A virtual table is compared by NAME and not by declared text,
230
+ // which is where a column and an index are different: the name is
231
+ // built from the column stem, the module and its column list are
232
+ // fixed, and the three triggers are built from that same stem — so
233
+ // under this dialect a table present on both sides cannot differ,
234
+ // and one whose stem moved has a different name. `verifyShape`
235
+ // compares the declared text at open, which is the backstop if that
236
+ // ever stops being true.
237
+ const fromVirtual = new Map(fromPlan.virtualTables.map((v) => [v.name, v]));
238
+ const toVirtual = new Map(toPlan.virtualTables.map((v) => [v.name, v]));
239
+ for (const [virtualName, from] of fromVirtual) {
240
+ if (toVirtual.has(virtualName)) continue;
241
+ for (const trigger of from.triggers) {
242
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropTrigger(trigger.name),
243
+ note: `drop sync trigger '${trigger.name}' on '${name}'` });
244
+ }
245
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropVirtualTable(virtualName),
246
+ note: `drop the R*Tree '${virtualName}' (and its shadow tables) on '${name}'` });
247
+ }
248
+
218
249
  const disturbedColumns = new Set();
219
250
  for (const [columnName, fromColumn] of fromColumns) {
220
251
  const target = toColumns.get(columnName);
@@ -271,6 +302,19 @@ export function planMigration(fromModel, toModel, options = undefined) {
271
302
  });
272
303
  }
273
304
  }
305
+ for (const [virtualName, target] of toVirtual) {
306
+ if (fromVirtual.has(virtualName)) continue;
307
+ steps.push({ kind: 'ddl', sql: target.createSql,
308
+ note: `create the R*Tree '${virtualName}' on '${name}'` });
309
+ for (const trigger of target.triggers) {
310
+ steps.push({ kind: 'ddl', sql: trigger.sql,
311
+ note: `create sync trigger '${trigger.name}' on '${name}'` });
312
+ }
313
+ // the triggers fire on WRITES; the rows already stored need the
314
+ // backfill, and without it a probe silently returns nothing
315
+ steps.push({ kind: 'sql', sql: target.fillSql,
316
+ note: `backfill the R*Tree '${virtualName}' from the stored documents` });
317
+ }
274
318
 
275
319
  if (canonicalizeJson(fromCollection.schema) !== canonicalizeJson(toCollection.schema)) {
276
320
  report.schemaChanged.push(name);
@@ -298,7 +342,7 @@ export function planMigration(fromModel, toModel, options = undefined) {
298
342
  }
299
343
  }
300
344
 
301
- for (const [name] of fromCollections) {
345
+ for (const [name, fromCollection] of fromCollections) {
302
346
  if (toCollections.has(name) || consumedOldNames.has(name)) continue;
303
347
  report.removed.push(name);
304
348
  report.destructive = true;
@@ -309,6 +353,13 @@ export function planMigration(fromModel, toModel, options = undefined) {
309
353
  + 'A rename is declared with x-rename on the target collection; without '
310
354
  + 'one, this is a drop plus a create.',
311
355
  });
356
+ // DROP TABLE takes the collection's own triggers with it and leaves
357
+ // the R*Tree — and its three shadow tables — standing. A leftover
358
+ // virtual table is a stale index a recreated collection would probe
359
+ for (const virtual of planCollection(name, fromCollection, dialect, mapping).virtualTables) {
360
+ steps.push({ kind: 'ddl', sql: dialect.ddl.dropVirtualTable(virtual.name),
361
+ note: `drop the R*Tree '${virtual.name}' that belonged to '${name}'` });
362
+ }
312
363
  }
313
364
 
314
365
  planEntityChanges(fromModel, toModel, dialect, steps, report);
package/src/plan.js CHANGED
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * The outcome of planning one document:
13
13
  *
14
- * { plan, mode: 'native' | 'row' | 'set', reasons, rowReturn,
14
+ * { plan, mode: 'native' | 'row' | 'set' | 'knn', reasons, rowReturn,
15
15
  * prefilters }
16
16
  *
17
17
  * - `native` — everything translated; the plan alone answers.
@@ -22,6 +22,11 @@
22
22
  * wrapper built anywhere else would have to guess it.
23
23
  * - `set` — the pushed conjuncts narrow candidates; the WHOLE
24
24
  * compiled document runs over the materialized candidates.
25
+ * - `knn` — the pushed conjuncts narrow, the vector column CUTS the
26
+ * candidates of a k-nearest window (`plan.rank`), and the whole
27
+ * compiled document runs over the cut — a set residual whose
28
+ * candidate set an ordering, not a predicate, chose (see "The
29
+ * k-nearest promotion" below).
25
30
  *
26
31
  * `reasons` names every construct that forced work off the database,
27
32
  * with reason text drawn from the deliberate-residual table.
@@ -42,8 +47,9 @@ import { typeOfPath, isNumericType } from './types.js';
42
47
  import { schemaNodeAt } from './ddl.js';
43
48
  import {
44
49
  BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
45
- probeBox, probePosition, probeCircleBox, cellNeighbourhood,
50
+ probeBox, probePosition, probeCircleBox, cellNeighbourhood, probeVector,
46
51
  } from './derive.js';
52
+ import { KNN_MARGIN } from './knn.js';
47
53
 
48
54
  /** Comparison operator names → plan ops. */
49
55
  const COMPARISONS = new Map([
@@ -368,6 +374,9 @@ const SPATIAL_REASONS = {
368
374
  + 'disjuncts this suite\'s box convention does not carry — '
369
375
  + 'pushing nothing is correct, pushing a wrong box is not',
370
376
  precision: "the cell length must match the derived column's precision",
377
+ rtreeBox: "the box is stored in an R*Tree, whose coordinates are 32-bit floats rounded "
378
+ + 'OUTWARD, so the stored box is a superset of the row\'s; the exact box test refines '
379
+ + 'in the engine',
371
380
  };
372
381
 
373
382
  /**
@@ -416,16 +425,77 @@ function constantOf(node) {
416
425
  * @param {number} [precision]
417
426
  * @returns {string | { w: string, s: string, e: string, n: string } | null}
418
427
  */
428
+ /**
429
+ * The R\*Tree virtual table a `bbox` column set is realized as, or
430
+ * `null` when it is realized as four columns under a B-tree — which is
431
+ * also the answer on a driver whose build carries no R\*Tree module,
432
+ * because the physical plan already fell back there (MODEL-FORMAT §4).
433
+ * @param {any} shape
434
+ * @param {string} canonical
435
+ * @returns {{ name: string, columns: string[] } | null}
436
+ */
437
+ function rtreeTableOf(shape, canonical) {
438
+ const stem = shape.columnByCanonical?.get(`${canonical}|bbox|`);
439
+ if (stem === undefined) return null;
440
+ return shape.virtualByStem?.get(stem) ?? null;
441
+ }
442
+
443
+ /**
444
+ * The pushed box conjunct for one column set, under whichever physical
445
+ * mapping the collection declares — the same box either way, because
446
+ * the implied-conjunct proof is a proof about BOXES and not about SQL.
447
+ *
448
+ * Under `'rtree'` it is a `rowid` subquery over the virtual table: a
449
+ * conjunct on the collection table, so the `FROM` clause, the residual
450
+ * machinery and `prefilters` are all untouched. A join would be 6 %
451
+ * faster and would need join support the emitter does not have; a
452
+ * correlated `EXISTS` defeats the virtual table's index entirely and is
453
+ * 85x worse than the subquery.
454
+ * @param {{ w: string, s: string, e: string, n: string }} columns
455
+ * @param {{ name: string, columns: string[] } | null} virtual
456
+ * @param {any} probe
457
+ * @returns {{ pred: any, via: 'columns' | 'rtree', columns: string[],
458
+ * inexact: boolean }}
459
+ */
460
+ function boxConjunct(columns, virtual, probe) {
461
+ if (virtual === null) {
462
+ return { pred: { p: 'bboxOverlap', columns, probe },
463
+ via: 'columns', columns: boxColumnList(columns), inexact: false };
464
+ }
465
+ return { pred: { p: 'bboxRtree', table: virtual.name, columns: virtual.columns, probe },
466
+ via: 'rtree', columns: [...virtual.columns], inexact: true };
467
+ }
468
+
419
469
  function derivedColumnsOf(shape, canonical, derive, precision) {
470
+ // `precision` is the identity's third component: a geohash column's
471
+ // precision, a vector column's `dims`
420
472
  const stem = shape.columnByCanonical?.get(`${canonical}|${derive}|${precision ?? ''}`);
421
473
  if (stem === undefined) return null;
422
- if (derive === 'geohash') return stem;
474
+ if (derive === 'geohash' || derive === 'vector') return stem;
423
475
  /** @type {any} */
424
476
  const columns = {};
425
477
  for (const component of BBOX_COMPONENTS) columns[`${component}`] = `${stem}_${component}`;
426
478
  return columns;
427
479
  }
428
480
 
481
+ /**
482
+ * The vector columns declared over one canonical path, by width. The
483
+ * identity key is `<canonical>|vector|<dims>`, so one path may carry
484
+ * one column per declared width and a probe chooses by its own.
485
+ * @param {any} shape
486
+ * @param {string} canonical
487
+ * @returns {{ column: string, dims: number }[]}
488
+ */
489
+ function vectorColumnsOf(shape, canonical) {
490
+ const prefix = `${canonical}|vector|`;
491
+ const found = [];
492
+ for (const [key, column] of shape.columnByCanonical ?? []) {
493
+ if (key.startsWith(prefix))
494
+ found.push({ column, dims: Number(key.slice(prefix.length)) });
495
+ }
496
+ return found;
497
+ }
498
+
429
499
  /**
430
500
  * A spatial SUBJECT: a singular member path on the binding that the
431
501
  * schema types as geography. Answers the canonical path a derived
@@ -499,11 +569,19 @@ function planBoxPredicate(node, itSlot, shape) {
499
569
  if (box === null) return { refusal: refusal(construct, SPATIAL_REASONS.unbounded) };
500
570
  probe = { box };
501
571
  }
502
- const pred = { p: 'bboxOverlap', columns, probe };
503
- const exact = construct === '$bbox-intersects';
504
- return promotion(pred,
505
- { construct, columns: boxColumnList(columns), exact },
506
- exact ? [] : [refusal('$within', SPATIAL_REASONS.within)]);
572
+ const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical), probe);
573
+ // `$bbox-intersects` is EXACT over the four columns — they ARE
574
+ // `B(row)` — and only IMPLIED over an R*Tree, whose 32-bit float
575
+ // coordinates round outward: the stored box is a superset. No false
576
+ // negatives either way, which is what D8 needs; but a superset does
577
+ // not DECIDE, so the exact box test keeps its refinement and
578
+ // `strict: true` is JD0010 where the column mapping ran native
579
+ const exact = construct === '$bbox-intersects' && !conjunct.inexact;
580
+ const refinements = construct === '$bbox-intersects'
581
+ ? (conjunct.inexact ? [refusal(construct, SPATIAL_REASONS.rtreeBox)] : [])
582
+ : [refusal('$within', SPATIAL_REASONS.within)];
583
+ return promotion(conjunct.pred,
584
+ { construct, via: conjunct.via, columns: conjunct.columns, exact }, refinements);
507
585
  }
508
586
 
509
587
  /**
@@ -555,8 +633,9 @@ function planDistanceBound(node, itSlot, shape) {
555
633
  if (box[0] < -180 || box[2] > 180)
556
634
  return { refusal: refusal('$distance', SPATIAL_REASONS.wrapped) };
557
635
 
558
- return promotion({ p: 'bboxOverlap', columns, probe: { box } },
559
- { construct: '$distance', columns: boxColumnList(columns), exact: false },
636
+ const conjunct = boxConjunct(columns, rtreeTableOf(shape, subject.canonical), { box });
637
+ return promotion(conjunct.pred,
638
+ { construct: '$distance', via: conjunct.via, columns: conjunct.columns, exact: false },
560
639
  [refusal('$distance', SPATIAL_REASONS.distance)]);
561
640
  }
562
641
 
@@ -617,7 +696,7 @@ function planCellPrefix(node, itSlot, shape) {
617
696
  ? { p: 'cellIn', column: derivation.column, cells: [cell] }
618
697
  : { p: 'cellPrefix', column: derivation.column, prefix: cell };
619
698
  return promotion(pred,
620
- { construct: '$starts-with', columns: [derivation.column], exact },
699
+ { construct: '$starts-with', via: 'columns', columns: [derivation.column], exact },
621
700
  exact ? [] : [refusal('$starts-with', SPATIAL_REASONS.prefix)]);
622
701
  }
623
702
 
@@ -652,7 +731,130 @@ function planCellNeighbourhood(node, itSlot, shape) {
652
731
  if (cells.length === 0)
653
732
  return { refusal: refusal('$geohash-neighbours', SPATIAL_REASONS.operand) };
654
733
  return promotion({ p: 'cellIn', column: derivation.column, cells },
655
- { construct: '$geohash-neighbours', columns: [derivation.column], exact: true });
734
+ { construct: '$geohash-neighbours', via: 'columns', columns: [derivation.column],
735
+ exact: true });
736
+ }
737
+
738
+ // ————— The k-nearest promotion: an ORDERING the column pre-filters —————
739
+ //
740
+ // `$orderby` on a `$similarity` key, descending, `$empty: 'least'`,
741
+ // under a `$subsequence` window with a finite limit, over a member a
742
+ // `derive: 'vector'` column stores: the one ordering the planner
743
+ // promotes. Not to SQL — no ORDER BY is emitted, no per-row similarity
744
+ // call, no LIMIT: every SQL spelling of the rank measured slower than
745
+ // fetching the packed column and ranking in the engine, and none of
746
+ // them runs where no function can be registered — but to a CUT. The
747
+ // statement projects (identity, column) under the pushed WHERE; the
748
+ // engine scores every row's column against the probe; the rows whose
749
+ // score is within `KNN_MARGIN` of the `offset + limit`-th best are the
750
+ // candidates. Then the ENGINE decides: the original document — its
751
+ // whole `$orderby` (the similarity key over the raw member, every
752
+ // secondary key, `$empty`), its window, its `$return` — runs as the set
753
+ // residual over exactly those documents.
754
+ //
755
+ // What makes that exact is the implied-conjunct argument, applied to an
756
+ // ordering. The column's score (a dot product over binary32-normalized
757
+ // forms) and the engine's key (the cosine of the raw doubles) differ by
758
+ // at most ~1e-8, measured; with a margin two orders of magnitude wider
759
+ // the engine's top `offset + limit` is a SUBSET of the candidates — a
760
+ // row the engine ranks inside the window that the column left out would
761
+ // need two true cosines to differ by more than the column can mis-order
762
+ // them. Ties at the boundary are included by construction, and the
763
+ // engine breaks them by the document's own secondary keys, which is why
764
+ // no row-identity tie rule exists here: row identity serves the fetch,
765
+ // never the order.
766
+ //
767
+ // Two shapes reach the window's tail that no cut can order: fewer than
768
+ // `offset + limit` rows scored — a small collection, or NULL columns
769
+ // (absent, wrong-width, non-finite members) — so the window reaches the
770
+ // unrankable rows, which `$empty: 'least'` places LAST in the engine's
771
+ // own secondary order. The candidate set is then every row, and the
772
+ // collection is no larger than the window.
773
+ //
774
+ // Preconditions, each a named refusal. The selection must be pushed
775
+ // WHOLE — every `$where` conjunct exact, nothing before it — because a
776
+ // conjunct left to the residual could drop a candidate the cut counted,
777
+ // and an implied one narrows to a superset the residual then shrinks;
778
+ // either could leave the window short. The probe must be a literal
779
+ // vector of the column's width, or an external: what an external
780
+ // carries is the binder's to check at call time, and a bound value that
781
+ // is not a vector of that width DIVERTS the call to the residual, where
782
+ // the engine answers what it answers everywhere (empty keys for another
783
+ // width, its own refusal of a non-array) — never a plan-side error the
784
+ // engine would not raise.
785
+
786
+ const KNN_REASONS = {
787
+ rank: 'k-nearest rank is engine work: the vector column cuts the candidates and the engine orders them',
788
+ direction: 'k-nearest is promoted only descending (higher similarity first)',
789
+ empties: "k-nearest is promoted only under $empty: 'least' (unrankable rows last, where the cut can reach them)",
790
+ collation: 'a collation on a similarity key is refused, not approximated',
791
+ subject: 'the similarity key must compare a singular member path on the binding with a probe',
792
+ probe: 'the probe must be a literal vector (an array of finite numbers) or an external',
793
+ selection: 'k-nearest ranks over the column only when the selection is pushed whole (every $where conjunct exact, no $let or $as before it)',
794
+ window: 'k-nearest needs a window with a finite limit; an unbounded ranking is a full sort in the engine',
795
+ /** @param {string} path @param {number | null} dims */
796
+ noColumn: (path, dims) =>
797
+ `no vector column over ${path}${dims === null ? '' : ` at width ${dims}`}`,
798
+ /** @param {string} path @param {number} want @param {number[]} have */
799
+ dims: (path, want, have) =>
800
+ `the literal probe has ${want} components but the vector column over ${path} is declared at ${have.join(', ')}`,
801
+ /** @param {string} path */
802
+ widths: (path) =>
803
+ `several vector widths are declared over ${path}; an external probe cannot choose one at plan time`,
804
+ };
805
+
806
+ /**
807
+ * Recognize the k-nearest ordering, or say why not. `null` when the
808
+ * first key is not a `$similarity` at all — an ordinary ordering the
809
+ * caller plans as before. Further key specs are the engine's business
810
+ * (they order the candidates), so nothing here reads them.
811
+ * @param {any} orderby - the flwor's orderby node
812
+ * @param {number} itSlot
813
+ * @param {any} shape
814
+ * @param {boolean} selectionPushed - the WHERE pushed whole and exact,
815
+ * with nothing before it
816
+ * @returns {{ rank: { column: string, dims: number,
817
+ * probe: { lit: number[] } | { ext: string } } }
818
+ * | { refusal: { construct: string, reason: string } } | null}
819
+ */
820
+ function planKnnOrder(orderby, itSlot, shape, selectionPushed) {
821
+ const first = orderby.specs[0];
822
+ const key = first.key;
823
+ if (key.kind !== 'op' || key.name !== '$similarity') return null;
824
+ const refuse = (reason) => ({ refusal: refusal('$similarity', reason) });
825
+ if (first.desc !== true) return refuse(KNN_REASONS.direction);
826
+ if (first.emptyGreatest === true) return refuse(KNN_REASONS.empties);
827
+ if (first.collation !== null || first.collationName !== null)
828
+ return refuse(KNN_REASONS.collation);
829
+ // the subject may be either operand; the other is the probe
830
+ let subjectAt = 0;
831
+ if (memberPath(key.args[0], itSlot) === null && memberPath(key.args[1], itSlot) !== null)
832
+ subjectAt = 1;
833
+ const subject = memberPath(key.args[subjectAt], itSlot);
834
+ if (subject === null) return refuse(KNN_REASONS.subject);
835
+ const path = `$${subject.canonical}`;
836
+ const probeNode = key.args[subjectAt === 0 ? 1 : 0];
837
+ const declared = vectorColumnsOf(shape, subject.canonical);
838
+
839
+ if (probeNode.kind === 'var' && probeNode.external === true) {
840
+ if (declared.length === 0) return refuse(KNN_REASONS.noColumn(path, null));
841
+ if (declared.length > 1) return refuse(KNN_REASONS.widths(path));
842
+ if (!selectionPushed) return refuse(KNN_REASONS.selection);
843
+ return { rank: { column: declared[0].column, dims: declared[0].dims,
844
+ probe: { ext: probeNode.name } } };
845
+ }
846
+ const constant = constantOf(probeNode);
847
+ if (constant === null || !Array.isArray(constant.value)) return refuse(KNN_REASONS.probe);
848
+ const dims = constant.value.length;
849
+ if (probeVector(constant.value, dims) === null) return refuse(KNN_REASONS.probe);
850
+ const column = derivedColumnsOf(shape, subject.canonical, 'vector', dims);
851
+ if (column === null) {
852
+ return refuse(declared.length === 0
853
+ ? KNN_REASONS.noColumn(path, dims)
854
+ : KNN_REASONS.dims(path, dims, declared.map((entry) => entry.dims)));
855
+ }
856
+ if (!selectionPushed) return refuse(KNN_REASONS.selection);
857
+ return { rank: { column: /** @type {string} */ (column), dims, probe: { lit: constant.value } } };
656
858
  }
657
859
 
658
860
  /**
@@ -823,7 +1025,10 @@ function planPredicate(node, itSlot, shape) {
823
1025
  * reasons: { construct: string, reason: string }[],
824
1026
  * whereFullyPushed: boolean, orderPushed: boolean,
825
1027
  * projectionNative: boolean, itSlot: number, itName: string | null,
826
- * udfs: string[], prefilters: any[] }}
1028
+ * udfs: string[], prefilters: any[],
1029
+ * knn: { column: string, dims: number, probe: any } | null }}
1030
+ * `knn` is the recognized k-nearest ordering, pending the window
1031
+ * the caller peels; its ordering is then never pushed
827
1032
  */
828
1033
  function planFlwor(node, shape, rawFlwor, udfHook) {
829
1034
  const reasons = [];
@@ -843,7 +1048,7 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
843
1048
  reasons.push(refusal('$for',
844
1049
  'only a single plain binding over the whole collection is translated'));
845
1050
  return { plan, reasons, whereFullyPushed: false, orderPushed: false,
846
- projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [] };
1051
+ projectionNative: false, itSlot: -1, itName: null, udfs: [], prefilters: [], knn: null };
847
1052
  }
848
1053
  const itSlot = binding.slot;
849
1054
  // the document's own name for the collection binding. The residual and
@@ -905,9 +1110,19 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
905
1110
  }
906
1111
  }
907
1112
 
908
- // ORDER BY: all terms or none — a partially pushed ordering is wrong
1113
+ // ORDER BY: all terms or none — a partially pushed ordering is wrong.
1114
+ // A k-nearest ordering is the third outcome: not pushed, but
1115
+ // recognized for the column to pre-filter (the reason is the
1116
+ // caller's to name once the window is known)
909
1117
  let orderPushed = false;
910
- if (node.orderby !== null) {
1118
+ let knn = null;
1119
+ const ranked = node.orderby === null ? null
1120
+ : planKnnOrder(node.orderby, itSlot, shape, whereFullyPushed && structureClean);
1121
+ if (ranked !== null) {
1122
+ if ('rank' in ranked) knn = ranked.rank;
1123
+ else reasons.push(ranked.refusal);
1124
+ }
1125
+ else if (node.orderby !== null) {
911
1126
  const terms = [];
912
1127
  let refused = null;
913
1128
  for (const spec of node.orderby.specs) {
@@ -953,9 +1168,35 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
953
1168
  itName,
954
1169
  udfs,
955
1170
  prefilters,
1171
+ knn,
956
1172
  };
957
1173
  }
958
1174
 
1175
+ /**
1176
+ * Compose peeled `$subsequence` windows into one: the innermost applies
1177
+ * first, so offsets add and each outer limit is cut to what the inner
1178
+ * one left. `null` when nothing was peeled.
1179
+ * @param {{ offset: number, limit: number | null }[]} windows -
1180
+ * outermost first, as peeled
1181
+ * @returns {{ offset: number, limit: number | null } | null}
1182
+ */
1183
+ function composeWindows(windows) {
1184
+ if (windows.length === 0) return null;
1185
+ let offset = 0;
1186
+ let limit = null;
1187
+ for (let i = windows.length - 1; i >= 0; i--) {
1188
+ const w = windows[i];
1189
+ offset += w.offset;
1190
+ if (w.limit !== null) {
1191
+ limit = limit === null ? w.limit : Math.min(Math.max(limit - w.offset, 0), w.limit);
1192
+ }
1193
+ else if (limit !== null) {
1194
+ limit = Math.max(limit - w.offset, 0);
1195
+ }
1196
+ }
1197
+ return { offset, limit };
1198
+ }
1199
+
959
1200
  /**
960
1201
  * Plan a whole document against one collection.
961
1202
  * @param {any} document - The raw query document (kept beside the AST
@@ -965,11 +1206,12 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
965
1206
  * @returns {{
966
1207
  * analysis: any,
967
1208
  * plan: import('./algebra.js').Plan | null,
968
- * mode: 'native' | 'row' | 'set',
1209
+ * mode: 'native' | 'row' | 'set' | 'knn',
969
1210
  * reasons: { construct: string, reason: string }[],
970
1211
  * rowReturn: any,
971
1212
  * udfs: string[],
972
- * prefilters: { construct: string, columns: string[], exact: boolean }[],
1213
+ * prefilters: { construct: string, via: 'columns' | 'rtree',
1214
+ * columns: string[], exact: boolean }[],
973
1215
  * }}
974
1216
  */
975
1217
  function planCollectionCore(document, shape, options = undefined) {
@@ -1026,6 +1268,22 @@ function planCollectionCore(document, shape, options = undefined) {
1026
1268
  const { plan } = flwor;
1027
1269
  const fullyPushed = flwor.whereFullyPushed && flwor.orderPushed;
1028
1270
 
1271
+ if (flwor.knn !== null) {
1272
+ // the k-nearest mode: the recognized ordering under a window with
1273
+ // a finite limit, composed exactly as pushed windows are. The plan
1274
+ // keeps no order and no window — both are the engine's over the
1275
+ // cut — and the reason strict mode names is the rank itself
1276
+ const window = aggregate === null ? composeWindows(windows) : null;
1277
+ if (window !== null && window.limit !== null) {
1278
+ plan.rank = { ...flwor.knn, offset: window.offset, limit: window.limit,
1279
+ margin: KNN_MARGIN };
1280
+ return { analysis, plan, mode: 'knn',
1281
+ reasons: [refusal('$orderby', KNN_REASONS.rank), ...flwor.reasons],
1282
+ rowReturn: null, udfs: flwor.udfs, prefilters: flwor.prefilters };
1283
+ }
1284
+ flwor.reasons.unshift(refusal('$subsequence', KNN_REASONS.window));
1285
+ }
1286
+
1029
1287
  if (aggregate !== null) {
1030
1288
  // aggregates need the WHOLE selection native (their input is the
1031
1289
  // full sequence, not a narrowed candidate set)
@@ -1064,22 +1322,7 @@ function planCollectionCore(document, shape, options = undefined) {
1064
1322
  }
1065
1323
 
1066
1324
  // windows push only onto a fully pushed selection
1067
- if (windows.length > 0 && fullyPushed) {
1068
- // innermost window applies first; compose offsets/limits
1069
- let offset = 0;
1070
- let limit = null;
1071
- for (let i = windows.length - 1; i >= 0; i--) {
1072
- const w = windows[i];
1073
- offset += w.offset;
1074
- if (w.limit !== null) {
1075
- limit = limit === null ? w.limit : Math.min(Math.max(limit - w.offset, 0), w.limit);
1076
- }
1077
- else if (limit !== null) {
1078
- limit = Math.max(limit - w.offset, 0);
1079
- }
1080
- }
1081
- plan.window = { offset, limit };
1082
- }
1325
+ if (windows.length > 0 && fullyPushed) plan.window = composeWindows(windows);
1083
1326
 
1084
1327
  if (fullyPushed && flwor.projectionNative && (windows.length === 0 || plan.window !== null)) {
1085
1328
  return { analysis, plan, mode: 'native', reasons: [], rowReturn: null,
@@ -1127,11 +1370,12 @@ function planCollectionCore(document, shape, options = undefined) {
1127
1370
  * @returns {{
1128
1371
  * analysis: any,
1129
1372
  * plan: import('./algebra.js').Plan | null,
1130
- * mode: 'native' | 'row' | 'set',
1373
+ * mode: 'native' | 'row' | 'set' | 'knn',
1131
1374
  * reasons: { construct: string, reason: string }[],
1132
1375
  * rowReturn: any,
1133
1376
  * udfs: string[],
1134
- * prefilters: { construct: string, columns: string[], exact: boolean }[],
1377
+ * prefilters: { construct: string, via: 'columns' | 'rtree',
1378
+ * columns: string[], exact: boolean }[],
1135
1379
  * }}
1136
1380
  */
1137
1381
  export function planQuery(document, shape, options = undefined) {