@jarenjs/db 0.43.3 → 0.46.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +157 -13
- package/README.md +319 -33
- package/dist/types/algebra.d.ts +32 -2
- package/dist/types/ddl.d.ts +31 -6
- package/dist/types/derive.d.ts +105 -16
- package/dist/types/dialect.d.ts +11 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/knn.d.ts +69 -0
- package/dist/types/migrate.d.ts +2 -1
- package/dist/types/plan.d.ts +11 -4
- package/dist/types/query.d.ts +11 -0
- package/docs/LIVE-FORMAT.md +30 -0
- package/docs/MIGRATION-FORMAT.md +10 -0
- package/docs/MODEL-FORMAT.md +298 -27
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +8 -2
- package/schemas/jaren-migration.schema.json +8 -2
- package/schemas/jaren-model.draft-07.schema.json +16 -2
- package/schemas/jaren-model.schema.json +16 -2
- package/src/algebra.js +20 -2
- package/src/ddl.js +234 -26
- package/src/derive.js +158 -19
- package/src/dialect.js +134 -2
- package/src/dialects/sqlite.js +33 -4
- package/src/emit.js +30 -6
- package/src/index.js +5 -3
- package/src/knn.js +96 -0
- package/src/live.js +87 -7
- package/src/migrate.js +55 -4
- package/src/plan.js +280 -36
- package/src/query.js +177 -44
- package/src/store.js +151 -60
package/docs/MODEL-FORMAT.md
CHANGED
|
@@ -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
|
|
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",
|
|
84
|
-
{ "name": "by_box", "path": "$.geometry",
|
|
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
|
|
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)`
|
|
123
|
-
column set**, extending §3's rule
|
|
124
|
-
`geohash` indexes over one path at
|
|
125
|
-
column sets, and legitimately so: a
|
|
126
|
-
fine proximity one are different
|
|
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`),
|
|
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
|
-
|
|
|
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
|
|
236
|
-
`node` and opened under `bun` therefore
|
|
237
|
-
derived column — correctly: the column
|
|
238
|
-
physical mapping is a property of the driver
|
|
239
|
-
and moving a file between the two is a
|
|
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
|
|
523
|
-
It is never silently
|
|
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.
|
|
4
|
+
"version": "0.46.4",
|
|
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.
|
|
75
|
-
"@jarenjs/json": "^0.
|
|
76
|
-
"@jarenjs/validate": "^0.
|
|
74
|
+
"@jarenjs/core": "^0.46.4",
|
|
75
|
+
"@jarenjs/json": "^0.46.4",
|
|
76
|
+
"@jarenjs/validate": "^0.46.4"
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
14
|
-
*
|
|
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
|
};
|