@jarenjs/db 0.43.3 → 0.46.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -153,6 +153,14 @@ member the schema types as an array or an object. Those are exact; the
153
153
  spatial predicates that only NARROW are rows in the residual table, and
154
154
  the implied-conjunct table below carries every proof.
155
155
 
156
+ Plus one ORDERING a **`derive: 'vector'`** column makes cheap without
157
+ making it native: the k-nearest composition — `$orderby` on a
158
+ `$similarity` key, descending, `$empty: 'least'`, under a `$subsequence`
159
+ window with a finite limit — over the member the column stores. It is
160
+ never pushed as an `ORDER BY`: the column CUTS the candidate set and the
161
+ engine DECIDES the order ("The k-nearest plan" below), which is a fourth
162
+ mode, `knn`, beside native, row and set.
163
+
156
164
  ### The deliberate-residual table
157
165
 
158
166
  | construct | reason |
@@ -170,6 +178,9 @@ the implied-conjunct table below carries every proof.
170
178
  | a geohash prefix LONGER than the column's precision | a cell-range pre-filter over the derived column's precision is pushed; the longer prefix refines in the engine |
171
179
  | a spatial predicate over a member with no matching derived index, or one the schema does not type as an array or an object | nothing is proven; the whole predicate runs in the engine (the deterministic-function hatch may still take it) |
172
180
  | an unbounded `$distance` (`>= r`), a circle reaching a pole or crossing the antimeridian, a probe with no bounding box | no conservative box exists — pushing nothing is correct, pushing a wrong box is not |
181
+ | the k-nearest ordering over a `derive: 'vector'` column | the column cuts the candidates; the engine orders them (mode `knn`, "The k-nearest plan" below) — engine work, so `strict: true` refuses it |
182
+ | `$similarity` anywhere else — a threshold in `$where`, a score in `$return`, a second ordering key | no native spelling; runs in the residual over whatever the rest of the document pushed |
183
+ | the k-nearest shape with no finite window, ascending, `$empty: 'greatest'`, a probe that is neither a literal vector nor an external, a literal probe of another width, or a selection not pushed whole (a residual conjunct, an implied one, a `$let` before the where) | nothing is proven; the whole document runs in the engine, and `explain()` names which precondition failed — a k-nearest query never falls to the full scan silently |
173
184
 
174
185
  ### The type truth table
175
186
 
@@ -273,17 +284,31 @@ member as an array or an object **and nothing else** — §8.14 answers
273
284
 
274
285
  | Jaren predicate | pushed | exact? | why it is implied |
275
286
  |---|---|---|---|
276
- | `$bbox-intersects(<path>, <literal\|external>)` | `w <= L_e AND e >= L_w AND s <= L_n AND n >= L_s` | **exact** | the derived columns ARE `B(row)`, so box overlap is fully decidable. `<=`/`>=`, not `<`/`>`: the kernel counts touching edges as intersecting, and a strict comparison would disagree on every shared edge |
277
- | `$within(<path>, <literal\|external>)` | the same four comparisons against `B(area)` | implied | the representative position is inside `B(subject)` — a bare position IS the box, and a centroid is a mean of positions, which lies within their min/max and inside the area's surface implies inside `B(area)`; so the two boxes share at least that position |
287
+ | `$bbox-intersects(<path>, <literal\|external>)`, `physical: 'columns'` | `w <= L_e AND e >= L_w AND s <= L_n AND n >= L_s` | **exact** | the derived columns ARE `B(row)`, so box overlap is fully decidable. `<=`/`>=`, not `<`/`>`: the kernel counts touching edges as intersecting, and a strict comparison would disagree on every shared edge |
288
+ | the same, `physical: 'rtree'` | the same four comparisons, spelled as `rowid IN (SELECT id FROM <c>_rtree WHERE minx <= L_e AND maxx >= L_w AND miny <= L_n AND maxy >= L_s)` | implied | an R\*Tree stores coordinates as **32-bit floats rounded OUTWARD**, so what it holds is `B(row)` widened — a superset, by ~3 cm in x and ~84 cm in y at 52°N. A superset has no false negatives, which is all a pre-filter needs; it is not a decision, so the exact box test refines and `strict: true` is `JD0010` here where the column mapping was native |
289
+ | `$within(<path>, <literal\|external>)` | the same four comparisons against `B(area)`, in whichever spelling the mapping takes | implied | the representative position is inside `B(subject)` — a bare position IS the box, and a centroid is a mean of positions, which lies within their min/max — and inside the area's surface implies inside `B(area)`; so the two boxes share at least that position ∎ |
278
290
  | `{$le\|$lt: [{$distance: [<path>, <literal>]}, r]}` | the same four comparisons against `circleBounds(probe, r)` | implied | every position within `r` metres lies inside the circle's box, which the kernel computes on the same sphere and the same `EARTH_RADIUS` the engine measures with — so the two cannot disagree by model. `$ge`/`$gt` is NOT promoted: no box narrows "farther than r" |
279
291
  | `{$starts-with: [{$geohash: [<path>, k]}, "<cell>"]}`, cell length ≤ k | `<c> IN ("<cell>")` or `<c> >= "<cell>" AND <c> < successor` | **exact** | the column HOLDS `$geohash(row, k)`, and geohash is a prefix code, so a prefix test on the expression is the same test on the column |
280
292
  | the same, cell length > k | the cell truncated to k | implied | the column can only confirm its own first k characters |
281
293
  | `{$exists: {$index-of: [{$geohash-neighbours: "<cell>"}, {$geohash: [<path>, k]}]}}`, cell length = k | `<c> IN (…the nine cells…)` | **exact** | the membership test compares whole strings and the column is exactly one of them. Nine cells, never one: two points ten metres apart can differ in the FIRST character of their cell (D7), so a single prefix is bucketing and only the neighbourhood is proximity |
282
294
 
295
+ **The proof is a proof about BOXES, not about SQL**, so the physical
296
+ mapping (MODEL-FORMAT §2.1, `physical`) does not enter it: the same box
297
+ is computed either way and only its spelling changes. The `rtree`
298
+ spelling is a `rowid` subquery — a conjunct on the collection table, so
299
+ the `FROM` clause, the residual machinery and `prefilters` are all
300
+ untouched. It is not a join (which would need join support the emitter
301
+ does not have, for 6 % ) and emphatically not a correlated `EXISTS`,
302
+ which defeats the virtual table's index entirely and measured 85× worse
303
+ than the subquery.
304
+
283
305
  The implied forms carry no `json_type` guard — a derived column IS the
284
306
  value — but each is TOTAL through its own `IS NOT NULL`, so a row with
285
307
  no box answers `FALSE` rather than SQL's `NULL` and negation composes
286
- classically. An implied conjunct may not be negated at all: negating a
308
+ classically. The `rtree` form is total for the same reason by a
309
+ different route: a row with no box was never inserted into the virtual
310
+ table (the sync trigger's guard, MODEL-FORMAT §3.2), so it is simply not
311
+ in the list. An implied conjunct may not be negated at all: negating a
287
312
  superset is a subset, and that drops rows. That guard also makes the
288
313
  leading term a two-sided range, which is what SQLite will actually
289
314
  **seek**: with a one-sided range it prefers a table scan, and a scan over
@@ -299,6 +324,100 @@ item). This is the same class as the string-operator and aggregate
299
324
  preconditions above, and the same answer: keep `compileSchema` injected
300
325
  if that distinction matters to you.
301
326
 
327
+ ### The k-nearest plan (vector)
328
+
329
+ A `derive: 'vector'` column (MODEL-FORMAT §2.1) holds each row's member
330
+ as a packed, l2-normalized binary32 vector. Nothing in SQL ranks over
331
+ it, and the reasons are portability and correctness rather than speed:
332
+ none of the `ORDER BY`-over-a-function spellings runs where no function
333
+ can be registered, and an ordering decided in SQL cannot break a tie by
334
+ the document's own secondary keys — which the engine executor has to,
335
+ for the three executors to agree. (On speed the two are close: re-measured
336
+ against the real column with the probe hoisted out of the per-row call,
337
+ `ORDER BY` over a registered function sits at rough parity with fetching
338
+ the column and ranking in the engine — `benchmark/vector.js` publishes
339
+ the band, and the store does not emit it anyway.) The k-nearest
340
+ composition is therefore planned as **a cut the engine finishes**, the
341
+ implied-conjunct pattern applied to an ordering instead of a predicate:
342
+
343
+ | stage | what | where |
344
+ |---|---|---|
345
+ | narrow | the pushed `$where` conjuncts, exactly as in every other mode | SQL |
346
+ | fetch | `(row identity, packed column)` for every narrowed row — no `ORDER BY`, no `LIMIT`, no similarity call; the probe never binds into the statement | SQL |
347
+ | score | every column unpacked and dotted with the l2-normalized probe (the cosine of the raw vectors, up to binary32 rounding); a `NULL` column scores nothing | engine (`@jarenjs/core/vector`, through `derive.js`) |
348
+ | cut | with `m = offset + limit`: every scored row whose score is within `margin` (`1e-6`) of the m-th best is a candidate; when fewer than `m` rows scored, EVERY row is | engine (`knn.js`) |
349
+ | fetch | the candidates' documents, by identity, in identity order, through the dialect's by-identities statement (batched under every build's parameter cap) | SQL |
350
+ | decide | the ORIGINAL document — its whole `$orderby` (the `$similarity` key over the raw member, every secondary key, `$empty`), its window, its `$return` — as the set residual over exactly those documents | engine |
351
+
352
+ **Why the cut is exact.** The column's score and the engine's key are
353
+ not the same number: one is a dot product over binary32-normalized
354
+ forms, the other the cosine of the raw doubles, and they differ by up
355
+ to ~1e-8 (measured over the corpus and over thousands of random 768-d
356
+ pairs). A plan that CUT by the column's score alone could therefore
357
+ pick a different m-th row than the engine whenever two true cosines lie
358
+ within that distance. With a margin of at least twice the divergence
359
+ the engine's top `m` is a subset of the candidates: if a row the engine
360
+ ranks inside the window were cut, some candidate the engine ranks
361
+ outside it would have to score higher by the column and lower by the
362
+ engine, which two scores within half the margin of each other cannot
363
+ do. `1e-6` is a hundred times the bound; in practice it admits only true
364
+ ties, and the residual re-ranks a handful of documents — microseconds.
365
+
366
+ **What the engine's decision buys.** Ties break by the document's OWN
367
+ secondary keys, never by row identity, which the engine executor cannot
368
+ see (row identity serves the fetch, never the order — a stable sort
369
+ over the candidates in identity order sees what it would have seen over
370
+ the whole collection). Offsets, nested windows and every secondary key
371
+ compose for free. And the unrankable tail is right by construction:
372
+ `$empty: 'least'` places a row whose key is empty LAST, so a window
373
+ wider than the scored rows must produce those rows in the engine's own
374
+ secondary order — which is exactly why the cut takes every row when
375
+ fewer than `m` scored (the collection is then no larger than the window)
376
+ rather than dropping what the column cannot rank.
377
+
378
+ **What can raise, and where** — the spatial ERRORS rule, restated for
379
+ the probe path. §8.15 raises `JQ2001` for a member that is not an array
380
+ (a string, an object, a typed array held as an object); the column is
381
+ `NULL` for such a member (MODEL-FORMAT §3.2), so below the scored cut
382
+ the row is never fetched and never raises, and past it (the full fetch)
383
+ the engine raises as it would have. An ABSENT member is not that case:
384
+ its key is the empty sequence before the operand is checked, the column
385
+ is `NULL`, and both executors place the row in the tail. A store that
386
+ wants the engine's refusal for every row keeps `compileSchema` injected
387
+ — the vector column only exists over a member the schema types `array`
388
+ and nothing else, which is what makes the two agree on every row it
389
+ does fetch.
390
+
391
+ **The probe.** A literal vector must be the column's width at plan time
392
+ (another width is not recognized, and the reason says both widths). An
393
+ external probe is checked at call time by the binder's own rule: a
394
+ bound value that is not a vector of the column's width — another width,
395
+ a non-finite component, not an array at all — DIVERTS the call to the
396
+ full-collection residual, where the engine answers what it answers
397
+ everywhere (empty keys, so the secondary keys order every row; or its
398
+ own `JQ2001` for a non-array). The plan never raises on the engine's
399
+ behalf.
400
+
401
+ That diversion is correct and it is expensive: the residual reads every
402
+ document, and `explain()` still reports `knn`, because the plan is the
403
+ shape and the bound value is not part of it. So the fallback is
404
+ COUNTED — `collection.stats().knn.diverted` — which is the only surface
405
+ on which a probe arriving at the wrong width from a model or a form is
406
+ distinguishable from a query that ran the cut. A rising `diverted` beside
407
+ a flat `queries` is a caller embedding through the wrong model.
408
+
409
+ **Preconditions, each named in `explain()`.** The selection must be
410
+ pushed whole — every `$where` conjunct exact, nothing before it — because
411
+ a conjunct left to the residual could drop a candidate the cut counted,
412
+ and an implied conjunct narrows to a superset the residual then shrinks;
413
+ either could leave the window short. The ordering's first key must be
414
+ the `$similarity`, descending, under `$empty: 'least'`; the window must
415
+ have a finite limit (an unbounded ranking is a full sort, and the engine
416
+ does it over the whole collection, said so). Further keys are the
417
+ engine's business. With `strict: true` the shape is `JD0010` naming the
418
+ rank: the order is engine work, the same honesty as a spatial
419
+ refinement.
420
+
302
421
  ### The two residual modes
303
422
 
304
423
  - **Row residual** — only the projection is untranslated: predicates,
@@ -315,25 +434,40 @@ if that distinction matters to you.
315
434
  does not), and the WHOLE original compiled document runs over the
316
435
  materialized candidate array. Re-applying pushed conjuncts is
317
436
  idempotent, so pushdown is pure narrowing. Reported as a barrier.
437
+ - **Set residual over a cut** (`knn`) — the same whole-document
438
+ re-run, over a candidate set an ORDERING chose rather than a
439
+ predicate (the k-nearest plan above). A barrier too, and
440
+ `stats().knn` counts the rows scored and the candidates kept per
441
+ query, so a collection of many exact duplicates is visible rather
442
+ than merely slow.
318
443
 
319
444
  ### `explain()`
320
445
 
321
446
  Extends `compileJsonQuery(...).explain()`'s shape — `{ externals,
322
- operators, functions, collations, limits }` — with `{ sql, params,
323
- indexes, prefilters, residual, barriers, scanNarrative }`. `params`
447
+ operators, functions, collations, limits }` — with `{ mode, sql, params,
448
+ indexes, prefilters, rank, residual, barriers, scanNarrative }`. `mode`
449
+ is `'native' | 'row' | 'set' | 'knn'`. `params`
324
450
  lists the bound slots in order (external names, literal markers, and
325
451
  derived slots naming the external and box axis they compute — values
326
452
  are ALWAYS bound, never interpolated). `indexes` names the declared
327
453
  indexes whose generated columns the pushed predicates and ordering
328
454
  touch, and the `scanNarrative` is the database's own `EXPLAIN QUERY
329
455
  PLAN` prose so the claim is checkable against the engine that will run
330
- it. `prefilters` is the implied conjuncts — `{ construct, columns,
331
- exact }` each — because whether a declared index is earning its keep is
332
- not readable from `sql` alone, and because `indexes` says what a
333
- predicate TOUCHES while the narrative says what the database will
334
- DO. `estimatedRows` is ABSENT on SQLite drivers — the capability slot
456
+ it. `prefilters` is the implied conjuncts —
457
+ `{ construct, via, columns, exact }` each — because whether a declared
458
+ index is earning its keep is not readable from `sql` alone, and because
459
+ `indexes` says what a predicate TOUCHES while the narrative says what
460
+ the database will DO. `via` is `'columns'` or `'rtree'`: which physical
461
+ realization of a `bbox` column set actually ran, which is not always
462
+ what the model declared — a build without the R\*Tree module falls back
463
+ and this is where it says so (MODEL-FORMAT §4). Under `'rtree'` the
464
+ `columns` member names the virtual table's own columns and `indexes`
465
+ names the virtual table. `rank` is `null` or the k-nearest stage —
466
+ `{ column, dims, probe, limit, offset, margin, decides: 'engine' }` —
467
+ what the fetch reads, the window the cut serves, the margin it keeps,
468
+ and who decides the order (always the engine). `estimatedRows` is ABSENT on SQLite drivers — the capability slot
335
469
  is empty and no number is fabricated. `residual` is `null` or
336
- `{ mode: 'row' | 'set', reasons: [{ construct, reason }] }` with
470
+ `{ mode: 'row' | 'set' | 'knn', reasons: [{ construct, reason }] }` with
337
471
  reasons drawn from the deliberate-residual table. With
338
472
  `strict: true`, any residual is instead the compile error `JD0010`
339
473
  naming the forcing construct.
@@ -457,7 +591,13 @@ delete inside the visible slice answered without re-query), running
457
591
  over contributions when the extremum's holder leaves), per-group
458
592
  **deltas** (the accumulator machinery once per group), and **re-run**
459
593
  for everything else — declared, reported through `live.mode`, never
460
- silent. Invalidation matches a record by table plus pointer prefix,
594
+ silent. A plan that fell to the set residual ONLY for a spatial
595
+ refinement (a pushed box or cell range with the exact predicate left
596
+ to the engine) is still the rows strategy — the geofence: the fetch is
597
+ index-narrowed and per-row re-evaluation IS the exact test — while an
598
+ ordering or an aggregate beside such a refinement re-runs, the
599
+ refinement never being the reason named. Invalidation matches a record
600
+ by table plus pointer prefix,
461
601
  over-approximating toward re-evaluation (a missed update would be a
462
602
  correctness bug; an extra one is only slower). Emissions preserve
463
603
  reference identity for untouched rows — the O(k) renderer's contract —
@@ -497,4 +637,8 @@ the ENVIRONMENT — OPFS persistence across reloads, the owner topology
497
637
  (one context holds the sole connection, tabs are clients over a
498
638
  BroadcastChannel), and the second-writer refusal — across Chromium,
499
639
  Firefox and WebKit, with the memory fallback stated where OPFS is
500
- absent.
640
+ absent — and the spatial corpus, run entry by entry through the data
641
+ studio's throwaway-store operation, holds the wasm build to the
642
+ JavaScript engine's recorded answers in every one of those engines,
643
+ OPFS or not, because an entry seeds its own store and needs execution,
644
+ not persistence.
package/README.md CHANGED
@@ -56,6 +56,15 @@ const adults = await users.execute({
56
56
  });
57
57
  ```
58
58
 
59
+ `execute` answers in the ENGINE's result shape (QUERY-FORMAT §1,
60
+ "singleton ≡ item"): `undefined` for no rows, the document itself for
61
+ exactly one, an array for more — typed `SequenceResult<R>`, with `R`
62
+ stated per call (`users.execute<User>(…)`) because only the caller
63
+ knows what its `$return` produces. `query()` answers the same document
64
+ as an item cursor (`for await`), one item per pull and never unwrapped —
65
+ the read to use when an item may itself be an array. The handle's own
66
+ shape binds at `store.collection<User>('users')`.
67
+
59
68
  - **The pushdown planner with `explain()`.** A query compiles through
60
69
  the engine's published AST into a dialect-neutral plan and renders
61
70
  to guarded, parameter-bound SQL; whatever cannot be proven
@@ -101,21 +110,75 @@ const adults = await users.execute({
101
110
  `$distance` push a bounding box the truth table proves they imply,
102
111
  and the exact predicate re-runs over the narrowed candidates —
103
112
  `explain().prefilters` says which, over what columns, and whether it
104
- decided or merely narrowed.
113
+ decided or merely narrowed. The worked example, the geofence and the
114
+ measured numbers are in [Spatial storage](#spatial-storage--the-model-the-plan-the-fence-the-numbers)
115
+ below.
116
+ - **Migrations are documents.** `planMigration` diffs two models into
117
+ rendered-DDL + JSLT-transform + assertion steps; a shadow database
118
+ replays the whole chain before the real store is touched; a
119
+ checksummed history refuses edited or reordered migrations; a
120
+ narrowing without an adequate transform is refused against the REAL
121
+ data, inside the transaction.
122
+ - **The safe profile.** Untrusted query documents run under composed
123
+ bounds: engine limits on the residual, a mandatory row bound that
124
+ refuses rather than truncates, reference allow-lists, optional
125
+ full-scan refusal, and per-collection mandatory predicates no
126
+ document shape can shed. Read-only stores refuse writes at the
127
+ driver.
128
+ - **Writes validate** through an injected hook; without one,
129
+ `store.capabilities.validated` is `false` and the docs say what that
130
+ costs. The public API is asynchronous (the browser's OPFS story
131
+ forces it) with a promise-free `store.sync` twin where the driver is
132
+ synchronous.
133
+
134
+ ## Spatial storage — the model, the plan, the fence, the numbers
135
+
136
+ A collection stores GeoJSON as it is — a position is an array, a
137
+ geometry is an object, nothing is wrapped — and declares what to index
138
+ over it:
139
+
140
+ ```js
141
+ const store = await openStore({
142
+ $model: '0.1',
143
+ collections: {
144
+ places: {
145
+ schema: {
146
+ type: 'object',
147
+ properties: {
148
+ id: { type: 'string' },
149
+ // typed as geography: a spatial predicate is only pushed onto a
150
+ // member the schema types as an array or an object and nothing else
151
+ at: { type: ['array', 'object'] },
152
+ },
153
+ },
154
+ key: '/id',
155
+ indexes: [
156
+ { name: 'by_box', path: '$.at', derive: 'bbox' },
157
+ { name: 'by_cell', path: '$.at', derive: 'geohash', precision: 6 },
158
+ ],
159
+ },
160
+ },
161
+ }, { driver: nodeDriver() });
162
+ ```
163
+
164
+ `derive: 'bbox'` materializes the member's bounding box as four
165
+ columns and `derive: 'geohash'` its cell, both computed by
166
+ `@jarenjs/core/geo` — generated columns over registered deterministic
167
+ functions where the driver can index one, stored columns the store
168
+ writes where it cannot (MODEL-FORMAT §§2.1, 3). The query a consumer
169
+ writes then narrows in SQLite and refines in the engine, and
170
+ `explain()` says so:
105
171
 
106
172
  ```js
107
- // the collection declares indexes: [{ name: 'by_box', path: '$.at',
108
- // derive: 'bbox' }] and types that member at: { type: ['array', 'object'] }
109
- // — a predicate is only PUSHED onto a member the schema types as geography
110
173
  const places = store.collection('places');
111
- const nearby = {
174
+ const inside = {
112
175
  $for: { p: '$[*]' },
113
176
  $where: { $within: ['$p.at', '$region'] },
114
177
  $return: '$p',
115
178
  };
116
- await places.execute(nearby, { externals: { region } });
179
+ await places.execute(inside, { externals: { region } });
117
180
 
118
- const how = await places.explain(nearby, { externals: { region } });
181
+ const how = await places.explain(inside, { externals: { region } });
119
182
  how.prefilters;
120
183
  // [{ construct: '$within',
121
184
  // columns: ['gx_at_bbox_w', 'gx_at_bbox_e', 'gx_at_bbox_s', 'gx_at_bbox_n'],
@@ -126,30 +189,261 @@ how.scanNarrative;
126
189
  // 'SEARCH places USING INDEX places_by_box (gx_at_bbox_w>? AND gx_at_bbox_w<?); …'
127
190
  ```
128
191
 
129
- The region arrives as a bound parameter: a GeoJSON object is not a
130
- value any database can bind, so what binds is one edge of its box per
131
- slot, computed at bind time from the same kernel the stored columns
132
- came from. A circle that reaches a pole or crosses the antimeridian
133
- pushes **nothing** there is no single box to push — and the answer
134
- is the same, reached by reading more rows. `explain()` is what makes
135
- that checkable rather than quoted.
136
- - **Migrations are documents.** `planMigration` diffs two models into
137
- rendered-DDL + JSLT-transform + assertion steps; a shadow database
138
- replays the whole chain before the real store is touched; a
139
- checksummed history refuses edited or reordered migrations; a
140
- narrowing without an adequate transform is refused against the REAL
141
- data, inside the transaction.
142
- - **The safe profile.** Untrusted query documents run under composed
143
- bounds: engine limits on the residual, a mandatory row bound that
144
- refuses rather than truncates, reference allow-lists, optional
145
- full-scan refusal, and per-collection mandatory predicates no
146
- document shape can shed. Read-only stores refuse writes at the
147
- driver.
148
- - **Writes validate** through an injected hook; without one,
149
- `store.capabilities.validated` is `false` and the docs say what that
150
- costs. The public API is asynchronous (the browser's OPFS story
151
- forces it) with a promise-free `store.sync` twin where the driver is
152
- synchronous.
192
+ The region arrives as a bound parameter: a GeoJSON object is not a
193
+ value any database can bind, so what binds is one edge of its box per
194
+ slot, computed at bind time from the same kernel the stored columns
195
+ came from. `$bbox-intersects` and a geohash cell test are exact and
196
+ need no refinement; `$within` and a bounded `$distance` push the box
197
+ they provably imply and re-run the exact predicate over the narrowed
198
+ candidates. A circle that reaches a pole or crosses the antimeridian
199
+ pushes **nothing** there is no single box to push — and the answer is
200
+ the same, reached by reading more rows. A proximity probe is **nine
201
+ cells** (`$geohash-neighbours`), never one prefix: two points ten
202
+ metres apart can differ in the first character of their cell, so a
203
+ single-cell range is bucketing, not proximity.
204
+
205
+ **The geofence.** Register the same document as a live query and it is
206
+ maintained as writes arrive the initial fetch narrows through the
207
+ index, and every touched row is re-evaluated by the engine's *exact*
208
+ predicate:
209
+
210
+ ```js
211
+ const fence = await places.live([{
212
+ $for: { p: '$[*]' },
213
+ $where: { $within: ['$p.at', '$region'] },
214
+ $return: '$p.id',
215
+ }], { externals: { region } });
216
+ fence.mode; // { strategy: 'rows', mode: 'incremental' }
217
+ fence.subscribe(({ patch }) => {
218
+ // add when a point enters the region
219
+ // remove when it leaves
220
+ // nothing while it moves within (a whole-document return sees a replace)
221
+ });
222
+ ```
223
+
224
+ That is per-row evaluation, not an incremental spatial index: every
225
+ write runs `$within` against the region once, on the store's
226
+ connection, and a large region at a high write rate pays for it on
227
+ every write (LIVE-FORMAT §7 states the cost). An ordering by
228
+ `$distance` or a spatial aggregate re-runs on invalidation with the
229
+ reason in `live.mode` — declared, never silent.
230
+
231
+ **The numbers, the loss included.** `benchmark/spatial.js` stores <!--bm:spatial.corpus-->50,000 points<!--/bm-->
232
+ over the Netherlands and probes one box at <!--bm:spatial.rows-->258 of 50,000 (0.5 %)<!--/bm--> selectivity,
233
+ asserting every plan case of the committed spatial corpus and every timed shape against the JavaScript
234
+ engine before a single timing is printed. The `$within` a consumer writes went from <!--bm:spatial.scan-->80 ms<!--/bm-->
235
+ as a full scan to <!--bm:spatial.within-->2 ms<!--/bm--> over the `bbox` index (<!--bm:spatial.scanVsIndexed-->40.0<!--/bm-->×);
236
+ `$bbox-intersects` is <!--bm:spatial.bboxIntersects-->1.9 ms<!--/bm-->, a bounded `$distance` <!--bm:spatial.distance-->1.5 ms<!--/bm-->;
237
+ one geohash cell answers in <!--bm:spatial.cellOne-->0.0068 ms for 0 row(s)<!--/bm--> and the honest nine-cell
238
+ probe in <!--bm:spatial.cellNine-->0.026 ms for 2 row(s)<!--/bm-->. The row the store had to win is the same
239
+ `$within` in the in-memory engine over the parsed array, no database at all: <!--bm:spatial.engine-->32 ms<!--/bm-->.
240
+ The indexed store is now <!--bm:spatial.engineVsIndexed-->16.1× faster than<!--/bm--> it — but the un-indexed scan
241
+ is not, and the comparison is not an even one either way: the engine starts from parsed objects where the
242
+ store starts from bytes on a page and pays JSON materialisation for every row it returns. Both rows stay
243
+ published. The deterministic-UDF hatch takes a literal `$within` on a collection with no derived index, and
244
+ its profile is measured on the same rows in MODEL-FORMAT §8.2 (a loss as a sole predicate, a large win
245
+ beside a selective conjunct or a `LIMIT`).
246
+
247
+ **Two shapes on disk for one declaration.** `derive: 'bbox'` has a
248
+ second physical realization: `physical: 'rtree'` keeps the same four
249
+ derived columns and stores the boxes in a SQLite R\*Tree beside the
250
+ collection, synced by three declared triggers, with no B-tree over the
251
+ columns (MODEL-FORMAT §2.1). The logical model is unchanged — the
252
+ spatial corpus runs every entry under both mappings, in all three
253
+ executors, with no special-cased entry — and the pushed conjunct becomes
254
+ a `rowid` subquery over the virtual table. Through the store the same
255
+ `$within` measures <!--bm:spatial.rtreeStore-->0.46 ms against 2 ms — 4.3× in the R\*Tree's favour<!--/bm-->; loading the same rows
256
+ costs <!--bm:spatial.rtreeLoad-->718 ms against 399 ms for 50,000 documents in one transaction — 1.8× the write cost<!--/bm-->, because the R\*Tree is a
257
+ second table written inside every write transaction. Isolated from the
258
+ store on a raw connection the probe is <!--bm:spatial.rtree-->0.3 ms against 1.9 ms — 6.4× in the R\*Tree's favour<!--/bm-->.
259
+ Both halves are published because both are the price. One honest
260
+ difference comes with it: an R\*Tree stores 32-bit floats rounded
261
+ outward, so its box is a superset and `$bbox-intersects` is refined
262
+ rather than exact there — same rows, and `strict: true` says so.
263
+
264
+ **One document, three executors, proven to agree.** The same spatial
265
+ query document runs in three places — the JavaScript engine
266
+ (`compileJsonQuery`), SQLite through the Node driver, and SQLite
267
+ compiled to wasm in a real browser tab — and one committed corpus holds
268
+ all three to the same answers. `test/json/fixtures/spatial-corpus.json`
269
+ records what the engine answers for every case (generated, never
270
+ hand-typed); `test/db/spatial-oracle.test.js` runs every entry through
271
+ the Node driver under all three mappings — the derived indexes as
272
+ columns, the same indexes as R\*Trees, and none; and
273
+ `packages/website/e2e/spatial-agreement.spec.js` drives the data
274
+ studio's Store pane to run the same entries through the wasm build in
275
+ Chromium, Firefox and WebKit, asserting every answer against the
276
+ fixture on disk and the number of entries run against the corpus. Each
277
+ runner names the executor, the entry and the query when it disagrees.
278
+ That is the whole claim — not faster than anyone, not PostGIS — and the
279
+ browser leg's limit is stated with it: **it proves execution, not
280
+ durability.** Where OPFS is unavailable the tab's store is in-memory,
281
+ which is a property of the host, not of the suite.
282
+
283
+ **No head-to-head rival, and saying so.** Nothing else in JavaScript
284
+ stores GeoJSON in SQLite from a JSON query document, so the suite
285
+ invents none. The rivals to know about: MongoDB (`$geoWithin`, `$near`,
286
+ a `2dsphere` index) has a GeoJSON-native query document and a real
287
+ spatial index, and runs on a server — no browser execution, and no
288
+ second engine to agree with; DuckDB-wasm with `spatial` runs in a tab
289
+ with a real index and the overlay operations this store refuses to
290
+ build, and its query is SQL, not a document. Neither runs one document
291
+ through three executors proven to agree, and neither validates ring
292
+ closure in a schema.
293
+
294
+ ## Vector storage — the column, the cut, the price, the ceiling
295
+
296
+ A collection can declare that one member is an embedding, and the store
297
+ keeps it as a packed column beside the document:
298
+
299
+ ```js
300
+ const store = await openStore({
301
+ $model: '0.1',
302
+ collections: {
303
+ memories: {
304
+ schema: {
305
+ type: 'object',
306
+ properties: {
307
+ id: { type: 'string' },
308
+ text: { type: 'string' },
309
+ // typed `array` and nothing else: a column over a member that
310
+ // may also be a string or null is a column that lies about
311
+ // some documents. minItems/maxItems make a wrong-width write
312
+ // a validation error instead of an unrankable row.
313
+ embedding: { type: 'array', items: { type: 'number' },
314
+ minItems: 768, maxItems: 768 },
315
+ },
316
+ },
317
+ key: '/id',
318
+ indexes: [{ name: 'by_vec', path: '$.embedding', derive: 'vector', dims: 768 }],
319
+ },
320
+ },
321
+ }, { driver: nodeDriver() });
322
+ ```
323
+
324
+ `derive: 'vector'` materializes the member **l2-normalized and packed as
325
+ little-endian binary32** — `4·dims` bytes, computed by
326
+ `@jarenjs/core/vector` — into one **stored** column on every driver, with
327
+ **no B-tree over it and no registered function** (MODEL-FORMAT §§2.1,
328
+ 3.1). Nothing seeks a blob of floats, so the entry names a column rather
329
+ than an index; and because the column is stored rather than generated, a
330
+ plain `SELECT`, a backup or a foreign tool can read the table without
331
+ registering anything — which is also what lets `bun`, whose SQLite
332
+ binding has no function API at all, store and read the same bytes.
333
+
334
+ **"The k most similar" is not a keyword.** It is the query language's own
335
+ ordering and window (QUERY-FORMAT §8.15) — `$orderby` on a `$similarity`
336
+ key, descending, under a `$subsequence`:
337
+
338
+ ```js
339
+ const memories = store.collection('memories');
340
+ const nearest = {
341
+ $subsequence: [{
342
+ $for: { m: '$[*]' },
343
+ $where: { $eq: ['$m.topic', 'deploys'] },
344
+ $orderby: [{ $key: { $similarity: ['$m.embedding', '$q'] }, $dir: 'desc', $empty: 'least' },
345
+ '$m.id'],
346
+ $return: '$m',
347
+ }, 0, 10],
348
+ };
349
+ await memories.execute(nearest, { externals: { q: probe } });
350
+
351
+ const how = await memories.explain(nearest, { externals: { q: probe } });
352
+ how.mode; // 'knn'
353
+ how.rank; // { column: 'gx_embedding_v768', dims: 768,
354
+ // probe: { external: 'q' }, offset: 0, limit: 10,
355
+ // margin: 1e-6, decides: 'engine' }
356
+ how.sql; // SELECT "rowid", "gx_embedding_v768" … WHERE …
357
+ // — no ORDER BY, no LIMIT, no similarity call
358
+ memories.stats().knn; // { queries, rows, candidates, fullFetches, diverted }
359
+ ```
360
+
361
+ The pushed `$where` narrows in SQL exactly as in any other mode; the
362
+ statement then projects `(row identity, packed column)` and nothing else;
363
+ the **engine** unpacks, scores and keeps every row within `1e-6` of the
364
+ `offset + limit`-th best; the candidates' documents are fetched by
365
+ identity, and the ORIGINAL document — its whole `$orderby`, its window,
366
+ its `$return` — runs over exactly those. So **the column cuts and the
367
+ engine decides**: ties break by the document's own secondary keys,
368
+ offsets and nested windows compose for free, and the rows the column
369
+ cannot rank still appear where `$empty: 'least'` puts them. `strict: true`
370
+ refuses the shape with `JD0010` naming the rank, because the ordering is
371
+ engine work — the same honesty the spatial refinement gets. A probe of
372
+ the wrong width, or one that is not an array of numbers, **diverts** to
373
+ the residual, so a query can never quietly become an O(table) scan nobody
374
+ counted (ARCHITECTURE, "The k-nearest plan"). A *literal* probe of the
375
+ wrong width is refused at plan time and `explain()` says why; an
376
+ **external** one is only knowable when it is bound, so the plan stays
377
+ `knn` and the fallback is counted instead — `stats().knn.diverted` is
378
+ that count, and a consumer who binds probes from a model should watch it.
379
+
380
+ **The numbers, the losses included.** `benchmark/vector.js` measures one
381
+ k-nearest query every physical way it can run — over <!--bm:vector.grid-->10,000 and 50,000 vectors at 384 and 768 dimensions, k = 10, the median of 10 probes<!--/bm--> —
382
+ and asserts that every path returns the identical top-k, ids and order,
383
+ on every probe before a single timing prints. The flagship row is the
384
+ plan a consumer's own document runs, which measures <!--bm:vector.plan-->206 ms at 50,000 × 768<!--/bm-->:
385
+ <!--bm:vector.table-->
386
+ | path (ms) | 10,000 × 384 | 10,000 × 768 | 50,000 × 384 | 50,000 × 768 |
387
+ |---|---:|---:|---:|---:|
388
+ | engine resident sweep (no database) | 3.3 | 6.7 | 17 | 32 |
389
+ | **the k-nearest plan (the store's own)** | 22 | 33 | 139 | 206 |
390
+ | raw fetch + engine sweep (the plan's statement) | 20 | 31 | 130 | 202 |
391
+ | `ORDER BY` over a registered function | 18 | 31 | 121 | 180 |
392
+ | JSON-doc sweep (no vector column) | 249 | 501 | — | — |
393
+ | sqlite-vec | 3.6 | 7.5 | 18 | 38 |
394
+ <!--/bm-->
395
+
396
+ The row the column exists to beat is the last one that has no column: the
397
+ same query document over a collection that stores the embedding only
398
+ inside the document costs <!--bm:vector.jsonDoc-->15.0× the plan at 10,000 × 768<!--/bm-->,
399
+ because every row's vector is parsed out of JSON before it can be
400
+ compared. The row the store **cannot** beat is the one with no database
401
+ in it: the same top-k over a resident `Float32Array` is <!--bm:vector.resident-->32 ms, which the plan is 6.4× slower than<!--/bm-->.
402
+ That comparison is not an even one and the direction is the point — the
403
+ sweep starts from decoded floats in RAM and pays nothing for durability,
404
+ for filters that compose with the ranking, or for a process that can
405
+ restart — but it stays published, because a store that is worth its
406
+ price should be able to say what the price is.
407
+
408
+ **Both halves of the price.** The column costs on the way in as well as
409
+ saving on the way out: writing the same documents with
410
+ the index costs <!--bm:vector.write-->10.6 s against 5.0 s for 50,000 documents in one transaction — 2.1× the write cost<!--/bm-->,
411
+ because every write pays a JSON round trip of the member plus a
412
+ normalize and a pack. On disk one vector is <!--bm:vector.storage-->3,072 B packed against 16,141 B as a JSON number array inside the document — 5.3× smaller<!--/bm--> —
413
+ smaller, but *added*, since the document still carries the member the
414
+ column is derived from.
415
+
416
+ **Pushing the rank into SQL, re-measured.** A registered similarity
417
+ function inside an `ORDER BY … LIMIT k` is the obvious alternative, and
418
+ the suite measures it against the real column with the probe hoisted out
419
+ of the per-row call: <!--bm:vector.udf-->180 ms against 202 ms at 50,000 × 768, and 0.87–1.00× the fetch-and-rank across the grid — rough parity on speed<!--/bm-->.
420
+ The plan does not emit it, and after that measurement the reasons are not
421
+ speed: `bun` has no user-function API, so a plan that needed one would
422
+ exclude an executor outright; and an ordering decided in SQL cannot break
423
+ a tie by the document's own secondary keys, which is what the three
424
+ executors have to agree on.
425
+
426
+ **The rival, and the ceiling.** `sqlite-vec` is the extension built for
427
+ exactly this, and it is measured rather than described: it answers the
428
+ same probes in <!--bm:vector.rival-->38 ms against 206 ms at 50,000 × 768 — 5.4× in sqlite-vec's favour, out of a database 6.6× smaller that holds no documents<!--/bm-->,
429
+ over <!--bm:vector.agreement-->40 probes, no disagreements<!--/bm-->. It is a
430
+ loadable native extension, which is the one thing this store will not
431
+ require — it would exclude the wasm tab and stock `bun`, half the
432
+ execution story — so the comparison is published as what it is: a faster
433
+ engine you may prefer, and a dependency this one does not take. What
434
+ neither of them is, is an approximate index. Exact brute force is linear
435
+ in `n · d`, and the suite states the envelope as arithmetic rather than
436
+ opinion: <!--bm:vector.ceiling-->5.546 ns per vector component — one query reaches 100 ms at about 22,000 vectors of 768 dimensions and one second at about 234,000<!--/bm-->.
437
+ Past that this design is the wrong tool and no margin changes it; what
438
+ lies beyond is an approximate index, and this store does not have one.
439
+
440
+ **One document, three executors, proven to agree.** As with the spatial
441
+ family, the k-nearest shapes of a committed corpus
442
+ (`test/json/fixtures/vector-corpus.json`) run through the JavaScript
443
+ engine, SQLite through the Node driver, and a real wasm build — indexed
444
+ and unindexed — and every entry must answer identically, including a
445
+ deliberate one-binary32-ulp near-tie and the windows that reach past the
446
+ scored rows into the tail the column cannot rank.
153
447
 
154
448
  ## What SQLite-only means, frankly
155
449
 
@@ -202,8 +496,9 @@ SQLite's own story (WAL plus a busy timeout, both set and visible on
202
496
  - **Live queries** (LIVE-FORMAT §§7–12): `collection.live(document)`
203
497
  maintains a result as writes arrive and emits patches — incremental
204
498
  for `where`/`select`/`orderBy`+`limit`/aggregates/single-level
205
- `groupBy` (the normative maintenance table), re-run for everything
206
- else, **declared, never silent** (`live.mode` names the reason).
499
+ `groupBy` and a spatial `where` over a derived index (the geofence;
500
+ the normative maintenance table), re-run for everything else,
501
+ **declared, never silent** (`live.mode` names the reason).
207
502
  Unaffected rows stay reference-identical; a seeded oracle holds the
208
503
  maintained result equal to a fresh re-query after every mutation.
209
504
  - **Durable runs and the job queue** (JOBS-FORMAT, FLOW-FORMAT §7.6):