@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/dialect.js CHANGED
@@ -34,6 +34,7 @@
34
34
  * capabilities: Record<string, any>,
35
35
  * tableSuffix: string,
36
36
  * docColumnType: string,
37
+ * packedVectorType: string,
37
38
  * quoteIdentifier: (s: string) => string,
38
39
  * parameterRef: (i: number, name: string) => string,
39
40
  * stringLiteral: (s: string) => string,
@@ -43,7 +44,7 @@
43
44
  * jsonPathText: (segments: JsonPathSegment[]) => string | null,
44
45
  * jsonExtract: (columnSql: string, pathText: string) => string,
45
46
  * derivedExpression?: (memberSql: string, column: { derive: string,
46
- * precision?: number, component?: string }) => string,
47
+ * precision?: number, component?: string, dims?: number }) => string,
47
48
  * jsonSet: (exprSql: string, pathText: string, valueSql: string) => string,
48
49
  * jsonRemove: (exprSql: string, pathText: string) => string,
49
50
  * jsonAppend: (exprSql: string, arrayPathText: string, valueSql: string) => string,
@@ -58,6 +59,8 @@
58
59
  * strContains: (valueSql: string, patternSql: string) => string,
59
60
  * orderNulls: (nullsFirst: boolean) => string,
60
61
  * rowIdentity: () => string,
62
+ * identityIn: (identitySql: string, paramSqls: string[]) => string,
63
+ * rtree?: { module: string, columns: readonly string[] },
61
64
  * explainQuery: (sql: string) => string,
62
65
  * excludedRef: (columnSql: string) => string,
63
66
  * tx: { begin: string, beginImmediate: string, commit: string,
@@ -85,7 +88,9 @@ export function createDialect(spec) {
85
88
  * generated column over the document; a DERIVED column is the same
86
89
  * shape over a registered deterministic function, except where the
87
90
  * driver cannot index one — there the store writes the value and the
88
- * column is an ordinary one.
91
+ * column is an ordinary one. A packed vector column is ALWAYS that
92
+ * ordinary stored column (its type is `packedVectorType`), on every
93
+ * driver: it never has an expression to spell.
89
94
  * @param {string} docColumn
90
95
  * @param {{ name: string, type: string, pathText: string,
91
96
  * expression?: string | null, stored?: boolean }} column
@@ -167,6 +172,104 @@ export function createDialect(spec) {
167
172
  dropIndex(name) {
168
173
  return `DROP INDEX ${q(name)}`;
169
174
  },
175
+ /**
176
+ * The second physical realization of a `derive: 'bbox'` column set
177
+ * (MODEL-FORMAT §2.1, `physical: 'rtree'`): an R\*Tree virtual
178
+ * table beside the collection, keyed by the collection's row id and
179
+ * carrying the four box edges as `(minx, maxx, miny, maxy)`.
180
+ *
181
+ * The coordinates are 32-bit floats rounded OUTWARD, so the stored
182
+ * box is a superset of the row's — no false negatives, which is
183
+ * what an implied conjunct needs, and the reason `$bbox-intersects`
184
+ * stops being exact under this mapping.
185
+ * @param {{ name: string }} shape
186
+ * @returns {string}
187
+ */
188
+ createVirtualTable({ name }) {
189
+ return `CREATE VIRTUAL TABLE ${q(name)} USING ${spec.rtree.module}(`
190
+ + `${spec.rtree.columns.map(q).join(', ')})`;
191
+ },
192
+ /**
193
+ * @param {string} name
194
+ * @returns {string}
195
+ */
196
+ dropVirtualTable(name) {
197
+ return `DROP TABLE ${q(name)}`;
198
+ },
199
+ /**
200
+ * @param {string} name
201
+ * @returns {string}
202
+ */
203
+ dropTrigger(name) {
204
+ return `DROP TRIGGER ${q(name)}`;
205
+ },
206
+ /**
207
+ * Fill an R\*Tree from the documents already stored — the migration
208
+ * step that turns a `columns` collection into an `rtree` one. The
209
+ * `IS NOT NULL` is §3.2's rule in SQL: a row with no bounded
210
+ * position is ABSENT from the index, not at `[0, 0]`.
211
+ * @param {{ table: string, virtualTable: string,
212
+ * edges: { name: string }[] }} shape
213
+ * @returns {string}
214
+ */
215
+ fillVirtualTable({ table, virtualTable, edges }) {
216
+ const columns = spec.rtree.columns;
217
+ const sources = [spec.rowIdentity(), ...edges.map((edge) => q(edge.name))];
218
+ return `INSERT INTO ${q(virtualTable)} (${columns.map(q).join(', ')}) `
219
+ + `SELECT ${sources.join(', ')} FROM ${q(table)} `
220
+ + `WHERE ${q(edges[0].name)} IS NOT NULL`;
221
+ },
222
+ /**
223
+ * The three triggers that keep an R\*Tree in sync with its
224
+ * collection — insert, update, delete — as DECLARED objects of the
225
+ * collection table.
226
+ *
227
+ * Declared, and not a second write path in JavaScript: a trigger is
228
+ * inside the writing transaction by construction (SQLite cannot
229
+ * separate them), no write path can bypass it (`insert`,
230
+ * `insertAllocated`, `upsert`, a translated patch, the patch
231
+ * fallback, a delete and a migration backfill all fire it), and it
232
+ * belongs to the collection table, so the existing declared-text
233
+ * drift check sees it for free.
234
+ *
235
+ * The body reads the DERIVED COLUMNS through `NEW` rather than
236
+ * restating the box expression, so the columns stay the box's one
237
+ * definition — and that text works unchanged on the stored-column
238
+ * branch, where those columns are ordinary ones.
239
+ *
240
+ * The `IS NOT NULL` guard is load-bearing: an R\*Tree coerces a
241
+ * `NULL` coordinate to `0.0` without complaint, so without it every
242
+ * unbounded document would land on Null Island instead of being
243
+ * absent (MODEL-FORMAT §3.2).
244
+ * @param {{ table: string, virtualTable: string, prefix: string,
245
+ * edges: { name: string }[] }} shape - `edges` are the four
246
+ * derived columns in `(w, e, s, n)` order, which is the order the
247
+ * virtual table's `(minx, maxx, miny, maxy)` carry
248
+ * @returns {{ name: string, sql: string }[]}
249
+ */
250
+ createSyncTriggers({ table, virtualTable, prefix, edges }) {
251
+ const rid = spec.rowIdentity();
252
+ const target = `${q(virtualTable)} (${spec.rtree.columns.map(q).join(', ')})`;
253
+ const guard = `${q(edges[0].name)} IS NOT NULL`;
254
+ const values = (row) => [`${row}.${rid}`,
255
+ ...edges.map((edge) => `${row}.${q(edge.name)}`)].join(', ');
256
+ return [
257
+ { name: `${prefix}_ai`,
258
+ sql: `CREATE TRIGGER ${q(`${prefix}_ai`)} AFTER INSERT ON ${q(table)} `
259
+ + `WHEN NEW.${guard} BEGIN `
260
+ + `INSERT INTO ${target} VALUES (${values('NEW')}); END` },
261
+ // one trigger, not two: the old id leaves and the new box
262
+ // arrives only when it exists, so a document that loses its
263
+ // geometry leaves the index rather than keeping a stale box
264
+ { name: `${prefix}_au`,
265
+ sql: `CREATE TRIGGER ${q(`${prefix}_au`)} AFTER UPDATE ON ${q(table)} BEGIN `
266
+ + `DELETE FROM ${q(virtualTable)} WHERE ${q(spec.rtree.columns[0])} = OLD.${rid}; `
267
+ + `INSERT INTO ${target} SELECT ${values('NEW')} WHERE NEW.${guard}; END` },
268
+ { name: `${prefix}_ad`,
269
+ sql: `CREATE TRIGGER ${q(`${prefix}_ad`)} AFTER DELETE ON ${q(table)} BEGIN `
270
+ + `DELETE FROM ${q(virtualTable)} WHERE ${q(spec.rtree.columns[0])} = OLD.${rid}; END` },
271
+ ];
272
+ },
170
273
  /**
171
274
  * @param {string} table
172
275
  * @returns {string}
@@ -286,6 +389,25 @@ export function createDialect(spec) {
286
389
  del({ table, keyColumn }) {
287
390
  return `DELETE FROM ${q(table)} WHERE ${q(keyColumn)} = ${p(1, 'key')}`;
288
391
  },
392
+ /**
393
+ * The documents of a list of row identities, in identity order —
394
+ * the fetch of a k-nearest plan's candidates after the engine's
395
+ * cut. `count` placeholders; a caller with fewer identities binds
396
+ * `null` for the rest, which the membership test matches to no row.
397
+ * Identity order is the collection's own order, so the engine's
398
+ * stable sort over the fetched documents sees what it would have
399
+ * seen over the whole collection.
400
+ * @param {{ table: string, docColumn: string }} s
401
+ * @param {number} count
402
+ * @returns {string}
403
+ */
404
+ selectByIdentities({ table, docColumn }, count) {
405
+ const rid = spec.rowIdentity();
406
+ const placeholders = [];
407
+ for (let i = 1; i <= count; i++) placeholders.push(p(i, 'rid'));
408
+ return `SELECT ${spec.jsonText(q(docColumn))} AS ${q('doc')} FROM ${q(table)} `
409
+ + `WHERE ${spec.identityIn(rid, placeholders)} ORDER BY ${rid}`;
410
+ },
289
411
  /**
290
412
  * Rewrite the document column through a JSON-set expression chain
291
413
  * (the translated-patch path) or a bound parameter (the fallback).
@@ -316,6 +438,9 @@ export function createDialect(spec) {
316
438
  name: spec.name,
317
439
  capabilities: Object.freeze({ ...spec.capabilities }),
318
440
  docColumnType: spec.docColumnType,
441
+ // the declared type of a `derive: 'vector'` column — the bytes of
442
+ // the packed form, spelled by the dialect like every other type
443
+ packedVectorType: spec.packedVectorType,
319
444
  quoteIdentifier: q,
320
445
  parameterRef: p,
321
446
  stringLiteral: spec.stringLiteral,
@@ -349,6 +474,13 @@ export function createDialect(spec) {
349
474
  strContains: spec.strContains,
350
475
  orderNulls: spec.orderNulls,
351
476
  rowIdentity: spec.rowIdentity,
477
+ /**
478
+ * The R\*Tree spelling: the module name and the virtual table's own
479
+ * column list, in `(id, minx, maxx, miny, maxy)` order. Read only
480
+ * where a `physical: 'rtree'` column set is planned, so a spelling
481
+ * spec that omits it simply cannot carry that mapping.
482
+ */
483
+ rtree: Object.freeze({ ...spec.rtree }),
352
484
  explainQuery: spec.explainQuery,
353
485
  excludedRef: spec.excludedRef,
354
486
  epochFromRfc3339: spec.epochFromRfc3339,
@@ -90,6 +90,10 @@ export const sqliteDialect = createDialect({
90
90
  epochFromRfc3339: (valueSql) =>
91
91
  `CAST(round((julianday(${valueSql}) - 2440587.5) * 86400000.0) AS INTEGER)`,
92
92
  docColumnType: 'BLOB',
93
+ // a `derive: 'vector'` column holds the packed little-endian binary32
94
+ // form (`4·dims` bytes); it is a stored column on every driver, so
95
+ // this is the whole of its SQL
96
+ packedVectorType: 'BLOB',
93
97
  quoteIdentifier,
94
98
  parameterRef: () => '?',
95
99
  stringLiteral,
@@ -105,10 +109,24 @@ export const sqliteDialect = createDialect({
105
109
  // the deterministic function the store registers at open. The
106
110
  // precision is a LITERAL, not a parameter — a generated column's
107
111
  // expression takes none — which is also what makes two precisions
108
- // over one path two different columns by declared text.
109
- derivedExpression: (memberSql, column) => (column.derive === 'geohash'
110
- ? `jaren_geohash(${memberSql}, ${Math.trunc(Number(column.precision))})`
111
- : `jaren_bbox_${column.component}(${memberSql})`),
112
+ // over one path two different columns by declared text. Exhaustive
113
+ // over the kind: a vector column is stored, never generated, so
114
+ // asking for its expression is a planner defect, and an unknown kind
115
+ // is refused rather than spelled as `jaren_bbox_undefined(...)`
116
+ derivedExpression: (memberSql, column) => {
117
+ switch (column.derive) {
118
+ case 'geohash':
119
+ return `jaren_geohash(${memberSql}, ${Math.trunc(Number(column.precision))})`;
120
+ case 'bbox':
121
+ return `jaren_bbox_${column.component}(${memberSql})`;
122
+ case 'vector':
123
+ throw new TypeError(
124
+ "sqlite dialect: a derive: 'vector' column is stored on every driver and has no generated expression");
125
+ default:
126
+ throw new TypeError(
127
+ `sqlite dialect: no generated-column expression for derive kind '${column.derive}'`);
128
+ }
129
+ },
112
130
  jsonSet: (exprSql, pathText, valueSql) =>
113
131
  `jsonb_set(${exprSql}, ${stringLiteral(pathText)}, ${valueSql})`,
114
132
  jsonRemove: (exprSql, pathText) =>
@@ -135,6 +153,17 @@ export const sqliteDialect = createDialect({
135
153
  strContains: (valueSql, patternSql) => `instr(${valueSql}, ${patternSql}) > 0`,
136
154
  orderNulls: (nullsFirst) => (nullsFirst ? ' NULLS FIRST' : ' NULLS LAST'),
137
155
  rowIdentity: () => '"rowid"',
156
+ // membership of the row identity in a bound list — the fetch of a
157
+ // k-nearest plan's candidates. `IN` over the rowid is a primary-key
158
+ // lookup per value; a NULL in the list matches no row, which is what
159
+ // lets a caller pad a batch
160
+ identityIn: (identitySql, paramSqls) => `${identitySql} IN (${paramSqls.join(', ')})`,
161
+ // the R*Tree module and the shape this store gives it: the row id
162
+ // and the four box edges in (minx, maxx, miny, maxy) order, which is
163
+ // the (w, e, s, n) a bbox index covers its columns in. The three
164
+ // shadow tables SQLite creates beside a virtual table are its own
165
+ // storage — deterministic from the name, and dropped with it.
166
+ rtree: { module: 'rtree', columns: ['id', 'minx', 'maxx', 'miny', 'maxy'] },
138
167
  explainQuery: (sql) => `EXPLAIN QUERY PLAN ${sql}`,
139
168
  excludedRef: (columnSql) => `excluded.${columnSql}`,
140
169
  tx: {
package/src/emit.js CHANGED
@@ -228,6 +228,23 @@ export function emitPlan(plan, dialect, physical) {
228
228
  + ` AND ${q(c.e)} >= ${edge('w')}`
229
229
  + ` AND ${q(c.s)} <= ${edge('n')} AND ${q(c.n)} >= ${edge('s')})`;
230
230
  }
231
+ case 'bboxRtree': {
232
+ // The same box test, over the same box, in the shape a
233
+ // `physical: 'rtree'` column set stores it: a LIST subquery over
234
+ // the virtual table, which is a conjunct on the collection table
235
+ // — so the FROM clause, the residual machinery and `prefilters`
236
+ // are all untouched, and only this one case knows the mapping.
237
+ //
238
+ // Total by construction: a row with no box was never inserted
239
+ // into the virtual table (the sync trigger's `IS NOT NULL`
240
+ // guard), so it is simply not in the list — the same answer the
241
+ // column mapping's leading `IS NOT NULL` produces.
242
+ const [id, minx, maxx, miny, maxy] = [dialect.rtree.columns[0], ...pred.columns];
243
+ const edge = (axis) => probeEdge(pred.probe, param, axis);
244
+ return `${dialect.rowIdentity()} IN (SELECT ${q(id)} FROM ${q(pred.table)}`
245
+ + ` WHERE ${q(minx)} <= ${edge('e')} AND ${q(maxx)} >= ${edge('w')}`
246
+ + ` AND ${q(miny)} <= ${edge('n')} AND ${q(maxy)} >= ${edge('s')})`;
247
+ }
231
248
  case 'cellIn': {
232
249
  // The cells are whole values of the column, so this is an
233
250
  // equality set — and `IN` is the spelling that keeps it one:
@@ -256,15 +273,22 @@ export function emitPlan(plan, dialect, physical) {
256
273
  }
257
274
  };
258
275
 
259
- const selection = plan.aggregate === null
260
- ? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
261
- : plan.aggregate.fn === 'count'
262
- ? `COUNT(*) AS ${q('value')}`
263
- : `${plan.aggregate.fn.toUpperCase()}(${valueOf(plan.aggregate.ref)}) AS ${q('value')}`;
276
+ const selection = plan.rank !== null
277
+ // the k-nearest fetch: the row identity and the packed column
278
+ // under the pushed WHERE, and nothing that orders or limits — the
279
+ // engine scores, cuts and ranks (measured: every SQL spelling of
280
+ // the rank loses to fetching the column and ranking in the engine,
281
+ // and none of them runs where no function can be registered)
282
+ ? `${dialect.rowIdentity()} AS ${q('rid')}, ${q(plan.rank.column)} AS ${q('vec')}`
283
+ : plan.aggregate === null
284
+ ? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
285
+ : plan.aggregate.fn === 'count'
286
+ ? `COUNT(*) AS ${q('value')}`
287
+ : `${plan.aggregate.fn.toUpperCase()}(${valueOf(plan.aggregate.ref)}) AS ${q('value')}`;
264
288
 
265
289
  let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
266
290
  if (plan.filter !== null) sql += ` WHERE ${emitPred(plan.filter)}`;
267
- if (plan.aggregate === null) {
291
+ if (plan.aggregate === null && plan.rank === null) {
268
292
  const terms = (plan.order ?? []).map((term) => {
269
293
  // Jaren's default sorts an empty key least: NULLS FIRST when
270
294
  // ascending, NULLS LAST when descending — and mirrored for
package/src/index.js CHANGED
@@ -30,10 +30,12 @@ export { typeOfPath, isNumericType } from './types.js';
30
30
  export { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
31
31
  export { deterministicFragment, registerFragment } from './udf.js';
32
32
  export {
33
- DERIVE_KINDS, BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
34
- deriveGeohash, deriveBboxEdge, derivedValue, memberAt, storedMemberForm,
35
- registerDeriveFunctions,
33
+ DERIVE_KINDS, DERIVE_MAPPING, PHYSICAL_KINDS, BBOX_COMPONENTS, BBOX_INDEX_ORDER,
34
+ PRECISION_MIN, PRECISION_MAX, DIMS_MIN, DIMS_MAX,
35
+ deriveGeohash, deriveBboxEdge, deriveVector, derivedValue, derivedMappingFor, memberAt,
36
+ storedMemberForm, registerDeriveFunctions, probeVector, columnScore,
36
37
  } from './derive.js';
38
+ export { KNN_MARGIN, IDENTITY_CHUNK, cutCandidates, identityBatches } from './knn.js';
37
39
  export {
38
40
  createQueryEngine, createQueryState, createEntityQueryEngine,
39
41
  createLoadEngine, INCLUDE_DEPTH_DEFAULT,
package/src/knn.js ADDED
@@ -0,0 +1,96 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The candidate cut of a k-nearest plan: the arithmetic between
4
+ * the scores a statement fetched and the row identities the engine
5
+ * will decide over. No vector arithmetic lives here — a score is
6
+ * `derive.js`'s, through `@jarenjs/core/vector` — and no SQL: the
7
+ * fetch of the winners is the dialect's. This is the one place the
8
+ * margin is applied and the one place candidate identities are
9
+ * batched for it.
10
+ */
11
+
12
+ /**
13
+ * The inclusive score margin of the cut. The column's score is a dot
14
+ * product over binary32-normalized forms; the engine's key is the
15
+ * cosine of the raw doubles; measured, the two differ by at most
16
+ * ~1e-8. Any margin of at least twice that makes the engine's top
17
+ * `offset + limit` a SUBSET of the candidates: were a row the engine
18
+ * ranks inside the window cut, some candidate the engine ranks outside
19
+ * it would have to score higher by the column and lower by the engine,
20
+ * which two scores within half the margin of each other cannot do.
21
+ * This is a hundred times that bound — it admits, in practice, only
22
+ * true ties, and those the engine breaks by the document's own keys.
23
+ */
24
+ export const KNN_MARGIN = 1e-6;
25
+
26
+ /**
27
+ * The most identities one fetch statement binds: under the parameter
28
+ * cap of every SQLite build the store runs on. A guard, not a design —
29
+ * a k-nearest window is a handful of rows, and this only matters for a
30
+ * collection of many exact duplicates.
31
+ */
32
+ export const IDENTITY_CHUNK = 512;
33
+
34
+ /** @param {any} a @param {any} b */
35
+ const byIdentity = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
36
+
37
+ /**
38
+ * The rows a k-nearest window can contain, from the scored fetch.
39
+ *
40
+ * `m = offset + limit` rows are needed. When at least `m` rows scored,
41
+ * the candidates are every scored row within `margin` of the m-th best
42
+ * score — ties at the boundary included by construction. When fewer
43
+ * did, the window reaches the unrankable tail (NULL columns, or a
44
+ * collection smaller than the window), which only the documents can
45
+ * order: every row is then a candidate, and the collection is no
46
+ * larger than the window.
47
+ * @param {{ identity: any, score: number | null }[]} rows - one per
48
+ * fetched row; `score` is `null` where the column held no vector
49
+ * @param {number} m - `offset + limit`
50
+ * @param {number} margin
51
+ * @returns {{ identities: any[], scored: number, full: boolean }} the
52
+ * candidate identities in ascending identity order — the
53
+ * collection's own order, which a stable sort over the candidates
54
+ * must see — with how many rows scored and whether every row was
55
+ * taken
56
+ */
57
+ export function cutCandidates(rows, m, margin) {
58
+ let scored = 0;
59
+ for (const row of rows) if (row.score !== null) scored++;
60
+ if (scored < m) {
61
+ return { identities: rows.map((row) => row.identity).sort(byIdentity), scored, full: true };
62
+ }
63
+ if (m <= 0) return { identities: [], scored, full: false };
64
+ const scores = new Float64Array(scored);
65
+ let at = 0;
66
+ for (const row of rows) if (row.score !== null) scores[at++] = row.score;
67
+ scores.sort();
68
+ const threshold = scores[scored - m] - margin;
69
+ const identities = [];
70
+ for (const row of rows) {
71
+ if (row.score !== null && row.score >= threshold) identities.push(row.identity);
72
+ }
73
+ identities.sort(byIdentity);
74
+ return { identities, scored, full: false };
75
+ }
76
+
77
+ /**
78
+ * The identities sliced into fetch batches: at most `IDENTITY_CHUNK`
79
+ * each, and each padded with `null` to the next power of two — a NULL
80
+ * in an IN list matches no row — so a handful of prepared statements
81
+ * serve every candidate count instead of one per count seen.
82
+ * @param {any[]} identities - in the order they should be fetched
83
+ * @returns {{ size: number, params: any[] }[]}
84
+ */
85
+ export function identityBatches(identities) {
86
+ const batches = [];
87
+ for (let start = 0; start < identities.length; start += IDENTITY_CHUNK) {
88
+ const slice = identities.slice(start, start + IDENTITY_CHUNK);
89
+ let size = 1;
90
+ while (size < slice.length) size *= 2;
91
+ const params = [...slice];
92
+ while (params.length < size) params.push(null);
93
+ batches.push({ size, params });
94
+ }
95
+ return batches;
96
+ }
package/src/live.js CHANGED
@@ -46,7 +46,8 @@ const segmentsOf = (path) => path.split('/').slice(1).map(decodeJSONPointerSegme
46
46
  * @param {any} document
47
47
  */
48
48
  function unwrapDocument(document) {
49
- let doc = Array.isArray(document) && document.length === 1 ? document[0] : document;
49
+ const whole = Array.isArray(document) && document.length === 1 ? document[0] : document;
50
+ let doc = whole;
50
51
  let offset = 0;
51
52
  let limit = null;
52
53
  let windowed = false;
@@ -74,7 +75,7 @@ function unwrapDocument(document) {
74
75
  }
75
76
  }
76
77
  }
77
- return { inner: doc, windowed, offset, limit, aggregate };
78
+ return { inner: doc, whole, windowed, offset, limit, aggregate };
78
79
  }
79
80
 
80
81
  /** The single for-binding name of a canonical flwor, or null. */
@@ -179,6 +180,55 @@ function memberDeps(root) {
179
180
  return deps;
180
181
  }
181
182
 
183
+ /**
184
+ * The residual reasons a plan carries once every spatial REFINEMENT is
185
+ * taken out. A refinement is a pushed, non-exact pre-filter (`$within`,
186
+ * a bounded `$distance`, an over-long cell prefix): the fetch is still
187
+ * SQL-narrowed and the exact predicate stays in the residual, which is
188
+ * the per-row evaluation maintenance runs anyway. One reason is removed
189
+ * per non-exact pre-filter, matched by construct, so a spatial
190
+ * predicate the planner REFUSED (no derived index on the member) still
191
+ * counts as a reason and still re-runs.
192
+ * @param {any} planned
193
+ * @returns {{ construct: string, reason: string }[]}
194
+ */
195
+ function unrefinedReasons(planned) {
196
+ const remaining = [...planned.reasons];
197
+ for (const prefilter of planned.prefilters) {
198
+ if (prefilter.exact) continue;
199
+ const index = remaining.findIndex((reason) => reason.construct === prefilter.construct);
200
+ if (index !== -1) remaining.splice(index, 1);
201
+ }
202
+ return remaining;
203
+ }
204
+
205
+ /**
206
+ * Whether a plan fell to the set residual ONLY because a spatial
207
+ * pre-filter refines in the engine (§7's spatial rows). `allowReturn`
208
+ * admits the per-row projection reason beside the refinement — the
209
+ * rows strategy re-evaluates the whole document per row, so a
210
+ * projected return is exact there and nowhere else.
211
+ * @param {any} planned
212
+ * @param {boolean} allowReturn
213
+ */
214
+ function refinedOnly(planned, allowReturn) {
215
+ if (planned.mode !== 'set' || !planned.prefilters.some((prefilter) => !prefilter.exact)) {
216
+ return false;
217
+ }
218
+ const remaining = unrefinedReasons(planned)
219
+ .filter((reason) => !(allowReturn && reason.construct === '$return'));
220
+ return remaining.length === 0;
221
+ }
222
+
223
+ const SPATIAL_RERUN = {
224
+ aggregate: 'a spatial aggregate re-runs: the accumulator needs a fully translated '
225
+ + 'selection, and a refined spatial predicate leaves the exact test to the engine',
226
+ group: 'a spatial group re-runs: the group filter needs a fully translated selection, '
227
+ + 'and a refined spatial predicate leaves the exact test to the engine',
228
+ order: "'$orderby' — an ordering over a refined spatial selection re-runs "
229
+ + '(no window is maintained beside a pre-filter that only narrows)',
230
+ };
231
+
182
232
  /**
183
233
  * Classify a collection query document against §7's table. Pure —
184
234
  * given the document and the collection's planner shape, returns the
@@ -192,18 +242,20 @@ function memberDeps(root) {
192
242
  */
193
243
  export function classifyLiveQuery(document, queryShape, keyed) {
194
244
  const rerun = (reason) => ({ strategy: 'rerun', reason });
245
+ // the reason named is the first one that is NOT a spatial refinement:
246
+ // a refinement narrows and never forces a re-run by itself
195
247
  const plannerReason = (planned) => {
196
- const forcing = planned.reasons[0]
248
+ const forcing = unrefinedReasons(planned)[0] ?? planned.reasons[0]
197
249
  ?? { construct: 'residual', reason: 'the document did not translate' };
198
250
  return `'${forcing.construct}' — ${forcing.reason}`;
199
251
  };
200
- const { inner, windowed, offset, limit, aggregate } = unwrapDocument(document);
252
+ const { inner, whole, windowed, offset, limit, aggregate } = unwrapDocument(document);
201
253
 
202
254
  if (aggregate !== null) {
203
255
  if (windowed) return rerun('a windowed aggregate maintains no accumulator');
204
256
  const planned = planQuery({ [aggregate.name]: inner }, queryShape, {});
205
257
  if (planned.mode !== 'native' || planned.plan.aggregate === null) {
206
- return rerun(plannerReason(planned));
258
+ return rerun(refinedOnly(planned, false) ? SPATIAL_RERUN.aggregate : plannerReason(planned));
207
259
  }
208
260
  if (!keyed) return rerun('rows without a document key cannot be tracked');
209
261
  return {
@@ -220,7 +272,9 @@ export function classifyLiveQuery(document, queryShape, keyed) {
220
272
  const carrier = documentsSource(group.binding, group.where);
221
273
  const planned = planQuery(carrier, queryShape, {});
222
274
  if (planned.mode !== 'native') {
223
- return rerun(`the group filter did not translate: ${plannerReason(planned)}`);
275
+ return rerun(refinedOnly(planned, false)
276
+ ? SPATIAL_RERUN.group
277
+ : `the group filter did not translate: ${plannerReason(planned)}`);
224
278
  }
225
279
  if (!keyed) return rerun('rows without a document key cannot be tracked');
226
280
  const rowDocument = {
@@ -239,8 +293,34 @@ export function classifyLiveQuery(document, queryShape, keyed) {
239
293
  };
240
294
  }
241
295
 
296
+ // a k-nearest window is planned WITH its window (the cut needs the
297
+ // limit), so the whole document is asked before the inner one: the
298
+ // column cuts the candidates and the engine orders them, and no
299
+ // maintained window has that shape — a ranking re-runs, named
300
+ if (windowed && planQuery(whole, queryShape, {}).mode === 'knn')
301
+ return rerun('a k-nearest ranking re-runs (the vector column cuts the candidates and the engine orders them)');
302
+
242
303
  const planned = planQuery(inner, queryShape, {});
243
- if (planned.mode === 'set') return rerun(plannerReason(planned));
304
+ if (planned.mode === 'set') {
305
+ // §7's spatial rows: the fetch is SQL-narrowed by the pushed box or
306
+ // cell range and the exact predicate is what per-row re-evaluation
307
+ // runs — so a `$where` that fell to the set residual ONLY for a
308
+ // refinement is maintained as rows. An ordering beside it is not
309
+ // (the set residual drops the planner's order terms), and neither
310
+ // is a window; both re-run, named.
311
+ if (!refinedOnly(planned, true)) return rerun(plannerReason(planned));
312
+ if (!keyed) return rerun('rows without a document key cannot be tracked');
313
+ // a translated ordering leaves no reason behind, but the set residual
314
+ // dropped its terms — the window is not maintainable here
315
+ if (isJsonObject(inner) && inner.$orderby !== undefined) return rerun(SPATIAL_RERUN.order);
316
+ if (windowed) return rerun('a limit without an order is not deterministic to maintain');
317
+ return {
318
+ strategy: 'rows',
319
+ inner,
320
+ projected: planned.reasons.some((reason) => reason.construct === '$return'),
321
+ deps: { whole: true, members: new Set() },
322
+ };
323
+ }
244
324
  if (!keyed) return rerun('rows without a document key cannot be tracked');
245
325
 
246
326
  if (planned.plan.order !== null) {