@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.
@@ -71,29 +71,145 @@ The store runs over SQLite — on Node (`@jarenjs/db/node`), on Bun
71
71
  An invalid model document is `JD0005` with a `docPath` pointing at the
72
72
  offending member. Model checking happens before any database work.
73
73
 
74
- ### 2.1 Derived indexes (spatial storage)
74
+ ### 2.1 Derived indexes (spatial and vector storage)
75
75
 
76
76
  A generated column must be a scalar (§3), and a GeoJSON position is an
77
77
  array of numbers while a geometry is an object. No path over spatial
78
78
  data is therefore indexable as written. `derive` supplies the missing
79
- vocabulary: it says what indexable scalar is computed from the member.
79
+ vocabulary: it says what is computed from the member — an indexable
80
+ scalar for the spatial kinds, and for an embedding (an array of
81
+ hundreds of numbers, which no B-tree could seek) the packed form a
82
+ fetch-and-rank plan reads whole.
80
83
 
81
84
  ```jsonc
82
85
  "indexes": [
83
- { "name": "by_cell", "path": "$.at", "derive": "geohash", "precision": 7 },
84
- { "name": "by_box", "path": "$.geometry", "derive": "bbox" }
86
+ { "name": "by_cell", "path": "$.at", "derive": "geohash", "precision": 7 },
87
+ { "name": "by_box", "path": "$.geometry", "derive": "bbox" },
88
+ { "name": "by_vec", "path": "$.embedding", "derive": "vector", "dims": 768 }
85
89
  ]
86
90
  ```
87
91
 
88
- | `derive` | `path` selects | `precision` | columns | type |
92
+ | `derive` | `path` selects | `precision` / `dims` | columns | type |
89
93
  |---|---|---|---|---|
90
- | `"geohash"` | a position `[lon, lat]`, a `Point`, or any value with a representative position (the mean of its vertices) | 1..12, **required** | one, `<column>` | `TEXT` |
94
+ | `"geohash"` | a position `[lon, lat]`, a `Point`, or any value with a representative position (the mean of its vertices) | `precision` 1..12, **required** | one, `<column>` | `TEXT` |
91
95
  | `"bbox"` | any GeoJSON value | — | four: `<column>_w`, `<column>_s`, `<column>_e`, `<column>_n` | `REAL` |
96
+ | `"vector"` | an array of exactly `dims` finite numbers, typed `array` by the schema | `dims` 1..8192, **required** | one, `<column>_v<dims>` — a COLUMN, not an index (below) | `BLOB` |
92
97
 
93
- `derive` is a CLOSED set of those two values. An open "expression"
98
+ `derive` is a CLOSED set of those three values. An open "expression"
94
99
  member would be a second query language inside the model document,
95
100
  which this format does not have and will not grow.
96
101
 
102
+ **`physical` — the shape a `bbox` index takes on disk.** `derive` says
103
+ what is COMPUTED; `physical` says how it is STORED, and the two are
104
+ separable:
105
+
106
+ ```jsonc
107
+ { "name": "by_box", "path": "$.geometry", "derive": "bbox", "physical": "rtree" }
108
+ ```
109
+
110
+ | `physical` | shape | index |
111
+ |---|---|---|
112
+ | `"columns"` (the default, and what an absent member means) | the four derived columns | one B-tree over them, in `(w, e, s, n)` order |
113
+ | `"rtree"` | the same four columns, plus an R\*Tree virtual table `<collection>_<column stem>_rtree` and three triggers that keep it in sync | the virtual table; **no B-tree over the columns** |
114
+
115
+ `physical` is a CLOSED set of those two values and is refused anywhere
116
+ but on a `derive: 'bbox'` index (rule 7 below). The **logical** meaning
117
+ of `derive: 'bbox'` is identical either way — same rows, same answers,
118
+ proven entry by entry by the spatial corpus in all three executors — and
119
+ that is the whole reason it is spelled separately from the derivation.
120
+
121
+ Three things follow, and none of them is optional:
122
+
123
+ - **The sync is three DECLARED triggers, not a second write path.** A
124
+ trigger is inside the writing transaction by construction, no write
125
+ path can bypass it (`insert`, `upsert`, a translated patch, the patch
126
+ fallback, a delete and a migration backfill all fire it), and it
127
+ belongs to the collection table — so an rtree-mapped file opened with
128
+ a `columns` model reports `JD0002` naming the trigger, and the reverse
129
+ direction likewise, through the drift check that was already there.
130
+ The trigger body READS the derived columns, so the box keeps ONE
131
+ definition and the same trigger text works on the stored-column branch
132
+ (§3.1) unchanged.
133
+ - **The virtual table is named from the COLUMN STEM, not the index
134
+ name**, because rule 5 lets two indexes share one column set: there is
135
+ one R\*Tree per column set, never one per index. Two indexes over one
136
+ column set asking for two shapes is `JD0004`.
137
+ - **`$bbox-intersects` stops being EXACT under this mapping.** An
138
+ R\*Tree stores coordinates as 32-bit floats rounded OUTWARD, so the
139
+ stored box is a superset of the row's — by about 3 cm in longitude and
140
+ 84 cm in latitude at Dutch latitudes. That is safe for a pre-filter (a
141
+ superset has no false negatives, which is what the implied conjunct
142
+ needs) and it is not a decision, so the exact box test keeps a
143
+ refinement and `strict: true` is `JD0010` where the column mapping
144
+ reported a native plan. `ARCHITECTURE.md`'s truth table carries the
145
+ per-mapping cell.
146
+
147
+ **What it costs, both halves** (`benchmark/spatial.js`, the store's own
148
+ rows over 50 000 points): the same `$within` measures <!--bm:spatial.rtreeStore-->0.46 ms against 2 ms — 4.3× in the R\*Tree's favour<!--/bm-->, and loading them
149
+ costs <!--bm:spatial.rtreeLoad-->718 ms against 399 ms for 50,000 documents in one transaction — 1.8× the write cost<!--/bm-->. Isolated from the store on a raw
150
+ connection, the same probe is <!--bm:spatial.rtree-->0.3 ms against 1.9 ms — 6.4× in the R\*Tree's favour<!--/bm-->. Read speed bought with write cost and a
151
+ second table: choose it deliberately, per index, which is why it is
152
+ neither automatic nor a store-wide option.
153
+
154
+ **`vector` — one packed column, never a B-tree.** The column holds the
155
+ member **l2-normalized** and packed as little-endian IEEE 754 binary32:
156
+ `4·dims` bytes, computed by `@jarenjs/core/vector` (`l2Normalize`, then
157
+ `packVector`) — the same kernels the query engine compares with, and
158
+ the one place this package reaches for them. It is normalized because
159
+ over unit vectors the dot product IS the cosine: the engine computes
160
+ `$similarity` (QUERY-FORMAT §8.15) as the cosine of the RAW members,
161
+ and a plan that ranks over the column computes the dot product of the
162
+ stored forms — `cos(raw) ≡ dot(normalized)` is the agreement the two
163
+ must keep, entry by entry, and the reason the column stores the
164
+ normalized form rather than the bytes of the member as written.
165
+
166
+ Three things follow:
167
+
168
+ - **No B-tree is created over the column**, and the `indexes` entry
169
+ therefore names a column, not an index: nothing seeks a blob of
170
+ floats, and a ranking plan reads the column whole and orders in the
171
+ engine. The verification set (§3) carries the column and no index.
172
+ - **It is a stored column on every driver** — §3.1 says why, and why
173
+ that is a deliberate divergence from the spatial kinds.
174
+ - **`dims` is the identity of the column.** Two widths over one path
175
+ are two columns (`<column>_v768` beside `<column>_v1536`), exactly as
176
+ two geohash precisions are; and the width is what makes a stored
177
+ vector comparable at all — a column that accepted any width would be
178
+ ranking vectors from different models against each other, which
179
+ produces plausible garbage rather than an error.
180
+
181
+ **What it buys, and what it costs.** Measured against the same query
182
+ over a collection with no such column — the whole embedding parsed out
183
+ of the stored JSON per row — the column is worth <!--bm:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/bm-->
184
+ on the read, and costs <!--bm:vector.write-->10.6 s against 5.0 s for 50,000 documents in one transaction — 2.1× the write cost<!--/bm-->
185
+ on the way in, because every write pays a JSON round trip of the member
186
+ plus the normalize and the pack. On
187
+ disk it is <!--bm:vector.storage-->3,072 B packed against 16,141 B as a JSON number array inside the document — 5.3× smaller<!--/bm-->
188
+ per vector — smaller than the member, and *added* to it, since the
189
+ document still carries what the column is derived from. Choose it the
190
+ way `physical: 'rtree'` is chosen: per index, with both halves in view
191
+ (`benchmark/vector.js`; the store's README carries the whole table, the
192
+ losses and the brute-force ceiling).
193
+
194
+ **What the write path does with a member that is not a vector of
195
+ `dims`** — absent, the wrong width, a non-finite component, not an
196
+ array — is store the document and write `NULL` to the column (§3.2).
197
+ The write is never refused on the column's account: refusing would
198
+ make a schema-valid document unstorable. The declaration that DOES
199
+ refuse a wrong-width vector at the write is the collection's own
200
+ schema, and a model that declares `dims` should constrain the member
201
+ to match:
202
+
203
+ ```jsonc
204
+ "embedding": { "type": "array", "items": { "type": "number" },
205
+ "minItems": 768, "maxItems": 768 }
206
+ ```
207
+
208
+ With `compileSchema` injected, a document whose embedding has 767
209
+ components is then `JD2003` at the write; without it, the document
210
+ stores and is simply unrankable. Both are honest; only the second is
211
+ silent, and the schema is where the author chooses.
212
+
97
213
  Every rule below is `JD0004` with a `docPath` at the offending member:
98
214
 
99
215
  1. **`precision` is required for `geohash`, and refused anywhere
@@ -102,7 +218,8 @@ Every rule below is `JD0004` with a `docPath` at the offending member:
102
218
  index for city-scale work) and precision 4 is ~20 km. The right
103
219
  value follows from the query radius, which the model cannot know.
104
220
  2. **A derived index is never `unique`.** Two distinct positions share
105
- a cell — and share a box edge — by construction.
221
+ a cell — and share a box edge — by construction, and a vector column
222
+ is one nothing seeks.
106
223
  3. **The path must still be singular, and there must be exactly one of
107
224
  it.** `derive` changes what is computed from the member, never how
108
225
  the member is selected: a wildcard path is refused exactly as it is
@@ -119,16 +236,46 @@ Every rule below is `JD0004` with a `docPath` at the offending member:
119
236
  a non-geographic operand and a pushed filter would simply not see
120
237
  the row. Declaring the type is what turns a derived index from
121
238
  storage into a plan.
122
- 5. **Two indexes with the same `(path, derive, precision)` share one
123
- column set**, extending §3's rule for undecorated paths. Two
124
- `geohash` indexes over one path at DIFFERENT precisions are two
125
- column sets, and legitimately so: a coarse bucketing index and a
126
- fine proximity one are different indexes.
239
+ 5. **Two indexes with the same `(path, derive, precision)` or
240
+ `(path, derive, dims)` — share one column set**, extending §3's rule
241
+ for undecorated paths. Two `geohash` indexes over one path at
242
+ DIFFERENT precisions are two column sets, and legitimately so: a
243
+ coarse bucketing index and a fine proximity one are different
244
+ indexes; two vector widths likewise.
127
245
  6. **A `bbox` index covers its four columns in `(w, e, s, n)` order** —
128
246
  not the order they are declared in. An intersection test reads
129
247
  `w <= ? AND e >= ? AND s <= ? AND n >= ?`, so the two longitude
130
248
  bounds sit together at the front of the index where a leading-column
131
249
  range can use them; `(a,b)` and `(b,a)` are different indexes (§3).
250
+ Under `physical: 'rtree'` that B-tree is **not created at all** — the
251
+ virtual table is the index, and paying for both would be paying
252
+ twice.
253
+ 7. **`physical` belongs to a `bbox` index and to nothing else.** It is
254
+ refused on a `geohash` index (an R\*Tree carries numbers and a cell
255
+ is text), on a `vector` index (which has one shape on disk, §3.1),
256
+ on an undecorated index (which has only one shape), and for any
257
+ value outside `{"columns", "rtree"}`. It is a property of the
258
+ COLUMN SET, so two indexes sharing one set must agree about it.
259
+ 8. **`dims` is required for `vector`, and refused anywhere else** — on
260
+ a `geohash` or `bbox` index and on an undecorated one — and must be
261
+ an integer 1..8192. `precision` is refused on a `vector` index for
262
+ the mirror reason: a vector has a width, not a cell size.
263
+ 9. **A `vector` index needs a member the schema types `array` and
264
+ nothing else** — `"type": "array"` or `["array"]`; a member the
265
+ schema does not type, or types `object`, or `["array", "null"]`, is
266
+ refused. Rule 4's leniency for an untyped spatial member does not
267
+ carry over: a column over a member the schema lets be a string is a
268
+ column that lies about some documents, and every refusal here says
269
+ how to declare the member (`items`, `minItems`, `maxItems`).
270
+
271
+ *Why the model and not an `openStore` option:* the store must know which
272
+ shape to expect in order to **verify without altering** (§3). With the
273
+ choice in the model, the file and the model agree by construction; with
274
+ it at a call site, a caller who forgets the option gets `JD0002` on a
275
+ database that is perfectly correct. *Why not automatic whenever the
276
+ driver can:* it would change the physical shape of every existing
277
+ spatially-indexed database on a version bump, and the write cost above
278
+ is not something a store may opt a consumer into.
132
279
 
133
280
  Approximate geohash cell size by precision (the kernel's
134
281
  `geohashCellSize` computes the degree figures; the metric ones are at
@@ -158,9 +305,11 @@ no SQL text exists outside a dialect. On SQLite:
158
305
  - one **virtual generated column** per distinct indexed path, typed
159
306
  from the collection's schema at that path (`string` → `TEXT`,
160
307
  `integer` → `INTEGER`, `number` → `REAL`, `boolean` → `INTEGER`,
161
- undeclared → `ANY`), and
308
+ undeclared → `ANY`), one derived column set per spatial `derive`
309
+ (§3.1), one stored `BLOB` column per `derive: 'vector'` width, and
162
310
  - one index per `indexes` entry, named `<collection>_<index name>`,
163
- over the generated columns of its paths.
311
+ over the generated columns of its paths — except a `vector` entry,
312
+ which names its column and creates no index (§2.1).
164
313
 
165
314
  Index paths are analyzed through the query engine's published AST: a
166
315
  path is indexable exactly when the analysis reports it singular and
@@ -210,10 +359,38 @@ mapping BRANCHES on what the driver declares. That is what the
210
359
  capability table (§4) is for: a driver that cannot do a thing says so
211
360
  rather than degrading silently.
212
361
 
213
- | `capabilities.deterministicIndexableFunctions` | mapping | drivers |
214
- |---|---|---|
215
- | `true` | a **virtual generated column** whose expression calls a deterministic function the store registers at open — `jaren_geohash(<member>, <precision>)`, `jaren_bbox_w(<member>)`, … over `json(jsonb_extract("doc", '<path>'))` | `node`, `wasm` |
216
- | `false` | a **stored column** the store writes on every insert, upsert and patch, computed in JavaScript from the same kernel call | `bun` |
362
+ | capability | value | mapping | drivers |
363
+ |---|---|---|---|
364
+ | `deterministicIndexableFunctions` | `true` | a **virtual generated column** whose expression calls a deterministic function the store registers at open — `jaren_geohash(<member>, <precision>)`, `jaren_bbox_w(<member>)`, … over `json(jsonb_extract("doc", '<path>'))` | `node`, `wasm` |
365
+ | `deterministicIndexableFunctions` | `false` | a **stored column** the store writes on every insert, upsert and patch, computed in JavaScript from the same kernel call | `bun` |
366
+ | `rtree` | `false` | a `derive: 'bbox'` column set that declared `physical: 'rtree'` (§2.1) is planned, created and verified as the **B-tree over its four columns**, and `explain().prefilters[].via` reports `'columns'` beside `store.capabilities.rtree === false` | any build without `ENABLE_RTREE` |
367
+
368
+ The two branches are independent: the `physical` mapping composes with
369
+ either derived-column mapping, and the sync triggers read the derived
370
+ columns either way, so their text is identical on both.
371
+
372
+ **The vector column does not branch.** A `derive: 'vector'` column is
373
+ the stored `BLOB` column under BOTH rows of the table — the store
374
+ writes it on every insert, upsert and patch, on every driver, computed
375
+ in JavaScript through the same seam a migration backfill uses — and
376
+ **no function is ever registered for it**. That is a deliberate
377
+ divergence from the spatial kinds, for three measured reasons:
378
+
379
+ - a virtual generated column is a host-function call per row, measured
380
+ at 150× against a stored read in the spatial work — and a vector's
381
+ call would re-derive a whole array per row, not a scalar;
382
+ - a stored column is readable without any registration, so a plain
383
+ `SELECT`, a backup and a foreign tool can never hit `unknown
384
+ function` over it, which the first consequence below shows a virtual
385
+ column can;
386
+ - `bun` has no function API at all, so one mapping is the only way
387
+ every driver stores, verifies and reads the same column.
388
+
389
+ Two things follow that are NOT true of a spatial column: a database
390
+ created under `node` opens under `bun` with no drift on the vector
391
+ column's account (the same declared text on both), and the migration
392
+ planner emits the backfill step for it under either `derived` setting
393
+ (MIGRATION-FORMAT §2.1).
217
394
 
218
395
  Three consequences, each normative:
219
396
 
@@ -231,12 +408,22 @@ Three consequences, each normative:
231
408
  `NaN` and no `Infinity`, so the write path computes from the member
232
409
  as it will be held rather than from the object handed in. Without
233
410
  that the two mappings would answer differently for one document.
234
- - **The same model document produces two different physical shapes**,
235
- and *match* is by declared text (above). A database created under
236
- `node` and opened under `bun` therefore reports `JD0002` naming the
237
- derived column — correctly: the column really is different. The
238
- physical mapping is a property of the driver that CREATED the file,
239
- and moving a file between the two is a migration, not an open.
411
+ - **The same model document produces two different physical shapes**
412
+ for a spatial column, and *match* is by declared text (above). A
413
+ database created under `node` and opened under `bun` therefore
414
+ reports `JD0002` naming the derived column — correctly: the column
415
+ really is different. The physical mapping is a property of the driver
416
+ that CREATED the file, and moving a file between the two is a
417
+ migration, not an open.
418
+ - **The R\*Tree fallback is a REPORT, not a degradation.** §4 promises
419
+ that a model declaring a spatial index is portable across all three
420
+ drivers and that the physical shape it produces is not, so refusing at
421
+ open on a build without the module would break a stated property of
422
+ the format for the sake of tidiness. The answers do not change — the
423
+ refinement is what makes them identical — and the surface a consumer
424
+ reads says which shape ran. A store that reported `via: 'rtree'` while
425
+ running columns, or that said nothing at all, is the one behaviour
426
+ this format forbids.
240
427
 
241
428
  ### 3.2 When a derived column is `NULL`
242
429
 
@@ -260,12 +447,53 @@ raise. The promotion therefore requires the schema to type the member
260
447
  as an array or an object and nothing else; a store that wants the
261
448
  engine's refusal instead keeps `compileSchema` injected.
262
449
 
450
+ Under `physical: 'rtree'` the same rule is enforced in SQL, by the
451
+ `WHEN <stem>_w IS NOT NULL` guard on the sync triggers: **a document
452
+ with no bounded position is ABSENT from the virtual table**, so the
453
+ pushed subquery simply does not list it — the same answer the column
454
+ mapping's leading `IS NOT NULL` produces. The guard is load-bearing and
455
+ not defensive: an R\*Tree coerces a `NULL` (or a text) coordinate to
456
+ `0.0` without complaining, so without it every unbounded document would
457
+ be indexed at `[0, 0]`.
458
+
263
459
  Traversal and validity stay separate concerns, as they do in the
264
460
  kernel: a value that carries SOME positions is bounded by the positions
265
461
  it has. A `LineString` whose second vertex did not survive as a
266
462
  position is bounded by its first — a document the GeoJSON meta-schema
267
463
  refuses in the first place — and both mappings agree about it.
268
464
 
465
+ **A `vector` column is `NULL` when the member is not a vector of
466
+ exactly `dims` finite numbers:**
467
+
468
+ | the stored member | column |
469
+ |---|---|
470
+ | absent | `NULL` |
471
+ | an array of another width (767 where `dims` is 768) | `NULL` |
472
+ | a component that is not finite — JSON has no `NaN`, so it arrives as `null` | `NULL` |
473
+ | not an array: a string, an object, a typed array (which JSON holds as an object), an array of arrays | `NULL` |
474
+ | the zero vector | **stored** — `4·dims` zero bytes; it has no direction and scores 0 against everything, which the query format publishes as a score, not a refusal |
475
+ | an array of `dims` finite numbers | the packed, l2-normalized form |
476
+
477
+ The document itself stores in every row of that table; the write is
478
+ never refused on the column's account (§2.1). The consequence for the
479
+ k-nearest plan (ARCHITECTURE.md, "The k-nearest plan") is stated here:
480
+ **a row whose vector column is `NULL` scores nothing, so it is never
481
+ among the rows the column's cut keeps** — unrankable, not wrong. That
482
+ agrees with the engine on every row of the table above but one:
483
+ `$similarity` over an absent, wrong-width or non-finite member answers
484
+ the empty sequence (QUERY-FORMAT §8.15), and under `$empty: 'least'`
485
+ such a row sorts LAST, where only a window wider than the scored rows
486
+ reaches it — and there the plan fetches every row and the engine orders
487
+ the tail itself. The one row that differs is the non-array member (a
488
+ string, an object): §8.15 raises `JQ2001` for it where the column holds
489
+ `NULL`, so a window that never reaches the tail never raises for it and
490
+ one that does raises as the engine would. That is why rule 9 (§2.1)
491
+ makes the column exist only over a member the schema types `array` and
492
+ nothing else; a store that wants the refusal for every row keeps
493
+ `compileSchema` injected. What the column cannot do is refuse a
494
+ wrong-width vector at the write — that is the schema's job, and §2.1
495
+ shows the declaration.
496
+
269
497
  ## 4. The driver contract and the synchronous fast path
270
498
 
271
499
  A driver is `{ name, dialect, open(path, options) }`; `open` returns a
@@ -307,6 +535,14 @@ the derived-column mapping branches on (§3.1), so a model that declares
307
535
  a spatial index is portable across all three drivers and the physical
308
536
  shape it produces is not.
309
537
 
538
+ `rtree` is read from the library's compile options (`ENABLE_RTREE`) and
539
+ is the second mapping branch: a `derive: 'bbox'` index that declares
540
+ `physical: 'rtree'` (§2.1) opens on a build without the module as the
541
+ B-tree over its four columns, and `explain().prefilters[].via` names
542
+ the shape that actually ran. That is this table's posture applied to a
543
+ mapping rather than to a method — the store says which shape it used,
544
+ and never claims one it did not.
545
+
310
546
  A library below SQLite **3.45** fails at open with `JD0001` naming
311
547
  the version found.
312
548
 
@@ -519,8 +755,15 @@ of the store's collections, no mandatory predicates, no scan refusal.
519
755
  2. **The mandatory row bound.** Every non-aggregate fetch carries a
520
756
  database-side `LIMIT` of `maxRows + 1`. A fetch that crosses
521
757
  `maxRows` — a result set, a residual's candidate set, a diverted
522
- full scan is the coded `JD2007` and the result is refused WHOLE.
523
- It is never silently truncated.
758
+ full scan, or a k-nearest plan's candidate scan is the coded
759
+ `JD2007` and the result is refused WHOLE. It is never silently
760
+ truncated. The k-nearest case is worth stating on its own, because
761
+ the number bounded is not the one the query asked for: the plan
762
+ scores every row the pushed `WHERE` admits before it can know which
763
+ `k` are nearest, so "the two nearest" over an unfiltered collection
764
+ of ten thousand is a ten-thousand-row fetch and a profile at
765
+ `maxRows: 1000` refuses it. Narrow it with a predicate, or raise the
766
+ bound for that query deliberately.
524
767
  3. **Reference containment.** The document may reference only the
525
768
  externals, host functions and collations the profile declares, and
526
769
  only collections the profile allows; an undeclared reference is the
@@ -672,6 +915,34 @@ to build one on — a crude guess would be dishonest. It pushes
672
915
  deterministically and this profile is published so the shape of the win
673
916
  is known; add a narrowing predicate or a `LIMIT` and the push pays.
674
917
 
918
+ **The same profile for a spatial predicate.** A `$within` against a
919
+ LITERAL region takes the hatch only on a collection that declares **no**
920
+ derived spatial index on the member: where one is declared the
921
+ promotion (§2.1, `explain().prefilters`) takes the conjunct first and
922
+ the hatch is consulted only for a refused one, and an external region
923
+ never qualifies — a deterministic function must not close over
924
+ changing state. Measured on the same terms as the `$sqrt` rows
925
+ (`benchmark/spatial.js`: 50 000 stored points, ~0.5 % selectivity,
926
+ mean of 20 executions after a warm one; the residual comparator
927
+ pushes everything BUT the spatial conjunct):
928
+
929
+ <!--bm:spatial.udfTable-->
930
+ | shape | pushed (ms) | residual (ms) | verdict |
931
+ |---|---|---|---|
932
+ | solo `$within` over a full scan | 94 | 80 | ~even |
933
+ | indexed `$eq` **and** `$within` (~5 % pass the index) | 5.7 | 52 | push **9.1×** |
934
+ | `$within` with `LIMIT 10` | 1.9 | 77 | push **41.0×** |
935
+ <!--/bm-->
936
+
937
+ So the spatial hatch <!--bm:spatial.udfVerdict-->earns its row: 9.1× beside the selective conjunct and 41.0× under the LIMIT<!--/bm-->,
938
+ by the same rule as `$sqrt`: a sole `$within` over a full scan is a
939
+ loss (the UDF re-parses every row in the callback, and the exact
940
+ containment test is dearer than a square root), a `$within` beside
941
+ something that narrows first is a large win. The solo shape is the one
942
+ a derived index answers — declare `derive: 'bbox'` on the member and
943
+ the same predicate becomes a box seek plus a refinement over the rows
944
+ it returns, which is faster than either column above.
945
+
675
946
  **The honest ceiling.** A `pushable:false` operator (a whole-series
676
947
  `$npv`, an `$sma`) is never a UDF — it stays the residual, `explain()`
677
948
  lists no `udfs` for it. Aggregate-UDF pushdown (`db.aggregate` step/final
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/db",
3
3
  "private": false,
4
- "version": "0.43.3",
4
+ "version": "0.46.5",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
@@ -71,9 +71,9 @@
71
71
  "prepack": "npm run build:types"
72
72
  },
73
73
  "dependencies": {
74
- "@jarenjs/core": "^0.43.3",
75
- "@jarenjs/json": "^0.43.3",
76
- "@jarenjs/validate": "^0.43.3"
74
+ "@jarenjs/core": "^0.46.5",
75
+ "@jarenjs/json": "^0.46.5",
76
+ "@jarenjs/validate": "^0.46.5"
77
77
  },
78
78
  "bin": {
79
79
  "jaren-db": "./src/cli.js"
@@ -144,7 +144,7 @@
144
144
  "additionalProperties": false
145
145
  },
146
146
  "deriveStep": {
147
- "description": "Recompute named STORED derived index columns from the documents already in a collection. Only the physical mapping that cannot index a registered deterministic function has these columns; the generated mapping populates them itself. The step is idempotent — a derived value is a pure function of the document — so a replay writes what the first run wrote.",
147
+ "description": "Recompute named STORED derived index columns from the documents already in a collection. A spatial column is stored only under the physical mapping that cannot index a registered deterministic function (the generated mapping populates itself); a vector column is stored under both. The step is idempotent — a derived value is a pure function of the document — so a replay writes what the first run wrote.",
148
148
  "type": "object",
149
149
  "properties": {
150
150
  "kind": {
@@ -168,7 +168,8 @@
168
168
  "derive": {
169
169
  "enum": [
170
170
  "geohash",
171
- "bbox"
171
+ "bbox",
172
+ "vector"
172
173
  ]
173
174
  },
174
175
  "precision": {
@@ -176,6 +177,11 @@
176
177
  "minimum": 1,
177
178
  "maximum": 12
178
179
  },
180
+ "dims": {
181
+ "type": "integer",
182
+ "minimum": 1,
183
+ "maximum": 8192
184
+ },
179
185
  "component": {
180
186
  "enum": [
181
187
  "w",
@@ -144,7 +144,7 @@
144
144
  "additionalProperties": false
145
145
  },
146
146
  "deriveStep": {
147
- "description": "Recompute named STORED derived index columns from the documents already in a collection. Only the physical mapping that cannot index a registered deterministic function has these columns; the generated mapping populates them itself. The step is idempotent — a derived value is a pure function of the document — so a replay writes what the first run wrote.",
147
+ "description": "Recompute named STORED derived index columns from the documents already in a collection. A spatial column is stored only under the physical mapping that cannot index a registered deterministic function (the generated mapping populates itself); a vector column is stored under both. The step is idempotent — a derived value is a pure function of the document — so a replay writes what the first run wrote.",
148
148
  "type": "object",
149
149
  "properties": {
150
150
  "kind": {
@@ -168,7 +168,8 @@
168
168
  "derive": {
169
169
  "enum": [
170
170
  "geohash",
171
- "bbox"
171
+ "bbox",
172
+ "vector"
172
173
  ]
173
174
  },
174
175
  "precision": {
@@ -176,6 +177,11 @@
176
177
  "minimum": 1,
177
178
  "maximum": 12
178
179
  },
180
+ "dims": {
181
+ "type": "integer",
182
+ "minimum": 1,
183
+ "maximum": 8192
184
+ },
179
185
  "component": {
180
186
  "enum": [
181
187
  "w",
@@ -110,10 +110,11 @@
110
110
  "type": "boolean"
111
111
  },
112
112
  "derive": {
113
- "description": "Derive indexable columns from the selected spatial member instead of indexing it: 'geohash' (one TEXT cell column, precision required) or 'bbox' (four REAL columns — west, south, east, north). A derived index is never unique.",
113
+ "description": "Derive columns from the selected member instead of indexing it: 'geohash' (one TEXT cell column, precision required), 'bbox' (four REAL columns — west, south, east, north), or 'vector' (one packed BLOB column holding the l2-normalized Float32 form of an array of numbers, dims required; stored on every driver, with no B-tree over it — a fetch-and-rank plan reads it whole). A derived index is never unique.",
114
114
  "enum": [
115
115
  "geohash",
116
- "bbox"
116
+ "bbox",
117
+ "vector"
117
118
  ]
118
119
  },
119
120
  "precision": {
@@ -121,6 +122,19 @@
121
122
  "type": "integer",
122
123
  "minimum": 1,
123
124
  "maximum": 12
125
+ },
126
+ "dims": {
127
+ "description": "The width of a derive: 'vector' column in components, 1-8192. Required beside derive: 'vector' and refused anywhere else: the width is the column's identity — two widths over one path are two columns — and a member that is not an array of exactly this many finite numbers stores NULL in the column (the document itself still stores). Constrain the member with minItems/maxItems equal to dims in the collection's schema so a wrong-width vector is refused at the write instead.",
128
+ "type": "integer",
129
+ "minimum": 1,
130
+ "maximum": 8192
131
+ },
132
+ "physical": {
133
+ "description": "The shape a derive: 'bbox' index takes on disk: 'columns' (the default) is four generated columns under one B-tree; 'rtree' keeps the same four columns and adds a SQLite R*Tree virtual table beside the collection, kept in sync by declared triggers, with no B-tree over the columns. The logical meaning of derive: 'bbox' is identical either way. Refused on any other index kind, and on a driver without the module it falls back to 'columns' and says so through explain().prefilters[].via.",
134
+ "enum": [
135
+ "columns",
136
+ "rtree"
137
+ ]
124
138
  }
125
139
  },
126
140
  "required": [
@@ -110,10 +110,11 @@
110
110
  "type": "boolean"
111
111
  },
112
112
  "derive": {
113
- "description": "Derive indexable columns from the selected spatial member instead of indexing it: 'geohash' (one TEXT cell column, precision required) or 'bbox' (four REAL columns — west, south, east, north). A derived index is never unique.",
113
+ "description": "Derive columns from the selected member instead of indexing it: 'geohash' (one TEXT cell column, precision required), 'bbox' (four REAL columns — west, south, east, north), or 'vector' (one packed BLOB column holding the l2-normalized Float32 form of an array of numbers, dims required; stored on every driver, with no B-tree over it — a fetch-and-rank plan reads it whole). A derived index is never unique.",
114
114
  "enum": [
115
115
  "geohash",
116
- "bbox"
116
+ "bbox",
117
+ "vector"
117
118
  ]
118
119
  },
119
120
  "precision": {
@@ -121,6 +122,19 @@
121
122
  "type": "integer",
122
123
  "minimum": 1,
123
124
  "maximum": 12
125
+ },
126
+ "dims": {
127
+ "description": "The width of a derive: 'vector' column in components, 1-8192. Required beside derive: 'vector' and refused anywhere else: the width is the column's identity — two widths over one path are two columns — and a member that is not an array of exactly this many finite numbers stores NULL in the column (the document itself still stores). Constrain the member with minItems/maxItems equal to dims in the collection's schema so a wrong-width vector is refused at the write instead.",
128
+ "type": "integer",
129
+ "minimum": 1,
130
+ "maximum": 8192
131
+ },
132
+ "physical": {
133
+ "description": "The shape a derive: 'bbox' index takes on disk: 'columns' (the default) is four generated columns under one B-tree; 'rtree' keeps the same four columns and adds a SQLite R*Tree virtual table beside the collection, kept in sync by declared triggers, with no B-tree over the columns. The logical meaning of derive: 'bbox' is identical either way. Refused on any other index kind, and on a driver without the module it falls back to 'columns' and says so through explain().prefilters[].via.",
134
+ "enum": [
135
+ "columns",
136
+ "rtree"
137
+ ]
124
138
  }
125
139
  },
126
140
  "required": [
package/src/algebra.js CHANGED
@@ -10,8 +10,10 @@
10
10
  *
11
11
  * One plan shape covers this version: a guarded selection over ONE
12
12
  * collection with optional ordering, window, aggregate and a
13
- * whole-document projection. Constructs beyond it are residuals by
14
- * design (see ARCHITECTURE.md's deliberate-residual table).
13
+ * whole-document projection or, instead of an ordering and a window,
14
+ * a k-nearest RANK the engine finishes over the rows the plan fetches.
15
+ * Constructs beyond it are residuals by design (see ARCHITECTURE.md's
16
+ * deliberate-residual table).
15
17
  */
16
18
 
17
19
  /** The plan format version, carried on every plan. */
@@ -55,6 +57,20 @@ export const PLAN_VERSION = 2;
55
57
  *
56
58
  * @typedef {{ ref: PlanRef, desc: boolean, emptyGreatest: boolean }} PlanOrderTerm
57
59
  *
60
+ * @typedef {{ column: string, dims: number,
61
+ * probe: { lit: number[] } | { ext: string },
62
+ * offset: number, limit: number, margin: number }} PlanRank
63
+ * The k-nearest stage: the packed vector column the ranking reads,
64
+ * its declared width, the probe (a plan-time literal vector, or the
65
+ * external that carries one at call time), the window the ENGINE
66
+ * will apply, and the inclusive score margin of the candidate cut.
67
+ * The column cuts — every row whose column score is within `margin`
68
+ * of the `offset + limit`-th best is a candidate — and the engine
69
+ * decides: the original document, its whole ordering and window
70
+ * included, runs over the candidates' documents. A plan carrying a
71
+ * rank carries no order and no window of its own: nothing in SQL
72
+ * orders or limits the fetch.
73
+ *
58
74
  * @typedef {{
59
75
  * planVersion: number,
60
76
  * alg: 'select',
@@ -62,6 +78,7 @@ export const PLAN_VERSION = 2;
62
78
  * filter: PlanPredicate | null,
63
79
  * order: PlanOrderTerm[] | null,
64
80
  * window: { offset: number, limit: number | null } | null,
81
+ * rank: PlanRank | null,
65
82
  * aggregate: { fn: 'count' | 'sum' | 'avg' | 'min' | 'max',
66
83
  * ref: PlanRef | null } | null,
67
84
  * project: 'document',
@@ -81,6 +98,7 @@ export function selectPlan(collection) {
81
98
  filter: null,
82
99
  order: null,
83
100
  window: null,
101
+ rank: null,
84
102
  aggregate: null,
85
103
  project: 'document',
86
104
  };