@jarenjs/db 0.34.0 → 0.43.1

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
@@ -66,6 +66,11 @@ binds arrays.
66
66
  generated columns. The plan carries both the CREATE statements and
67
67
  the structural facts an existing table must match (`JD0002` when it
68
68
  does not — nothing is ever altered).
69
+ - `src/derive.js` — the one place a declared `derive` becomes a value.
70
+ A geohash cell or a bounding-box edge from `@jarenjs/core/geo`, and
71
+ the deterministic SQL functions a generated column's expression
72
+ calls. The SAME functions serve both physical mappings, so the two
73
+ branches cannot drift into different answers.
69
74
  - `src/patch-sql.js` — RFC 6902 to JSON-set primitives, discriminated
70
75
  against the live document (a pointer cannot say array-or-object on
71
76
  its own). Untranslatable operations fall back to a whole-document
@@ -127,7 +132,9 @@ below, and that table's reasons are what `explain()` reports.
127
132
 
128
133
  ### The translated set
129
134
 
130
- A single-binding FLWOR over the collection (`$for: { it: '$[*]' }`)
135
+ A single-binding FLWOR over the collection (`$for: { <name>: '$[*]' }`
136
+ — the examples here write `it`, but the binding is the **document's** to
137
+ name and nothing translates differently under another one)
131
138
  with: comparison predicates (`$eq $ne $lt $le $gt $ge`) between a
132
139
  singular member path and a literal or external; `$and`/`$or`/`$not`
133
140
  composition; `$exists`/`$empty`; `$starts-with`/`$ends-with`/
@@ -136,7 +143,15 @@ composition; `$exists`/`$empty`; `$starts-with`/`$ends-with`/
136
143
  collation); a top-level `$subsequence` window with literal bounds; the
137
144
  top-level aggregates `$count` (bare-binding return only) and
138
145
  `$sum`/`$avg`/`$min`/`$max` over a singular schema-typed path; and the
139
- whole-document projection `$return: '$it'`.
146
+ whole-document projection that returns the bare binding.
147
+
148
+ Plus the spatial predicates a **derived** index makes decidable:
149
+ `$bbox-intersects` against a literal or external region, a geohash
150
+ prefix no longer than a `derive: 'geohash'` column's precision, and the
151
+ nine-cell neighbourhood probe over that same column — each over a
152
+ member the schema types as an array or an object. Those are exact; the
153
+ spatial predicates that only NARROW are rows in the residual table, and
154
+ the implied-conjunct table below carries every proof.
140
155
 
141
156
  ### The deliberate-residual table
142
157
 
@@ -150,7 +165,11 @@ whole-document projection `$return: '$it'`.
150
165
  | string operators with an external pattern | the pattern's type is unknowable at plan time and the engine ERRORS on non-string patterns |
151
166
  | comparisons where both sides are paths | join territory |
152
167
  | array/object literals in comparisons | deep-equality has no guarded native form |
153
- | spatial predicates | no spatial index vocabulary in the model format |
168
+ | `$within` over a `derive: 'bbox'` column | a bounding-box pre-filter is pushed; exact containment refines in the engine |
169
+ | a bounded `$distance` over a `derive: 'bbox'` column | a geodesic-circle box pre-filter is pushed; the exact distance refines in the engine |
170
+ | 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
+ | 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
+ | 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 |
154
173
 
155
174
  ### The type truth table
156
175
 
@@ -189,10 +208,20 @@ generated column where one exists), `?` = the bound operand:
189
208
  | `$eq` path, external | `(jt = 'text' AND typeof(?) = 'text' AND v = ?) OR (jt IN ('integer','real') AND typeof(?) IN ('integer','real') AND v = ?)` |
190
209
  | `$ne` path, external | `jt IS NOT NULL AND NOT (…the $eq form…)` |
191
210
  | ordering vs external | the same two-branch form with `op` |
192
- | `$starts-with` path, string | `jt = 'text' AND substr(v, 1, length(?)) = ?` |
211
+ | `$starts-with` path, string | `jt = 'text' AND (v >= ? AND v < ?)` the prefix and its code-point successor |
193
212
  | `$ends-with` path, string | `jt = 'text' AND (length(?) = 0 OR substr(v, -length(?)) = ?)` |
194
213
  | `$contains` path, string | `jt = 'text' AND instr(v, ?) > 0` |
195
214
 
215
+ **The prefix predicate is index-usable, and the other two are not.**
216
+ `$starts-with` emits a half-open range over the value, so a declared
217
+ index on that path is *seeked*, not scanned — worth declaring one for.
218
+ The bounds are computed at emit time, which the planner's own rule
219
+ makes exact: a string operator translates only with a literal,
220
+ non-empty pattern, so its code-point successor is known there. (A
221
+ pattern of nothing but U+10FFFF has no successor and falls back to the
222
+ scannable `substr` form.) `$ends-with` and `$contains` have no
223
+ index-usable spelling and read every row of the collection.
224
+
196
225
  The guards make the forms sound for typed AND untyped paths alike —
197
226
  the schema type's job is choosing the generated COLUMN (the index),
198
227
  never weakening the guard. Two documented preconditions: string
@@ -209,14 +238,78 @@ matters to you.
209
238
  execute time, if any referenced external is missing or not a string or
210
239
  finite number, the call runs the always-compiled set residual instead
211
240
  of the native statement — same answer, one branch, no wrong-typed SQL.
241
+ A **derived** slot is the exception that proves it: a GeoJSON region is
242
+ not bindable at all, so what binds is one edge of its bounding box per
243
+ slot, computed at bind time; such a call diverts only when the bound
244
+ value has no box.
245
+
246
+ ### The implied conjunct (spatial)
247
+
248
+ Every conjunct above is a conjunct **of the document**: it translates
249
+ exactly or it does not. A spatial predicate mostly cannot — there is no
250
+ `ST_Within` in SQLite and this package does not build one — but it
251
+ *implies* one that can be, over the columns a model declares with
252
+ `indexes[].derive` (MODEL-FORMAT §2.1).
253
+
254
+ > An **implied conjunct** is a predicate the planner ADDS to the SQL
255
+ > that is not in the document, proven below to be implied by one that
256
+ > is. It narrows; it never decides. The conjunct it came from stays in
257
+ > the residual.
258
+
259
+ Three properties make that safe, and each is asserted by test: **no
260
+ false negatives** (every row the document's predicate keeps passes the
261
+ implied one — the proofs below); **idempotent refinement** (the residual
262
+ re-runs the original predicate over the narrowed candidates, which is
263
+ why an implied conjunct forces the SET residual and not the row one);
264
+ and **`strict: true` refuses it** (an implied conjunct leaves its own
265
+ reason behind, so the plan is never native and `JD0010` names it).
266
+
267
+ Throughout, `B(v)` is the value's bounding box and `<c>_w`/`_s`/`_e`/`_n`
268
+ are a `bbox` index's columns; `<c>` is a `geohash` index's column at its
269
+ declared precision `k`. Every promotion requires the schema to type the
270
+ member as an array or an object **and nothing else** — §8.14 answers
271
+ `JQ2001` for a non-geographic operand, and a union that also admits
272
+ `null` is not geography.
273
+
274
+ | Jaren predicate | pushed | exact? | why it is implied |
275
+ |---|---|---|---|
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 ∎ |
278
+ | `{$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
+ | `{$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
+ | the same, cell length > k | the cell truncated to k | implied | the column can only confirm its own first k characters |
281
+ | `{$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
+
283
+ The implied forms carry no `json_type` guard — a derived column IS the
284
+ value — but each is TOTAL through its own `IS NOT NULL`, so a row with
285
+ 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
287
+ superset is a subset, and that drops rows. That guard also makes the
288
+ leading term a two-sided range, which is what SQLite will actually
289
+ **seek**: with a one-sided range it prefers a table scan, and a scan over
290
+ a virtual generated column pays a registered-function call per row.
291
+
292
+ **A pre-filter decides which rows the engine SEES, so it also decides
293
+ which rows can raise.** The row set is unchanged — that is what the
294
+ table above proves — but a row the pre-filter excludes never reaches
295
+ the engine and therefore never throws: a stored `null` under a member
296
+ the schema types as geography, or a value with no bounded position
297
+ under §8.14's membership recipe (`$index-of` refuses an empty search
298
+ item). This is the same class as the string-operator and aggregate
299
+ preconditions above, and the same answer: keep `compileSchema` injected
300
+ if that distinction matters to you.
212
301
 
213
302
  ### The two residual modes
214
303
 
215
304
  - **Row residual** — only the projection is untranslated: predicates,
216
305
  ordering and the window are fully pushed; each fetched row runs
217
- `{ $for: { it: '$[*]' }, $return: [ <the document's $return> ] }`
218
- (the array wrapper keeps array-valued items unambiguous) and the
219
- items concatenate in row order. Streams.
306
+ `{ $for: { <the document's own binding>: '$[*]' },
307
+ $return: [ <the document's $return> ] }` (the array wrapper keeps
308
+ array-valued items unambiguous) and the items concatenate in row
309
+ order. Streams. **The planner emits that one-row document whole**,
310
+ binding included, rather than handing the projection to be re-wrapped
311
+ elsewhere: the wrapper must bind what the projection references, and
312
+ only the planner knows what the caller called it.
220
313
  - **Set residual** — anything else: the pushed predicate conjuncts
221
314
  narrow candidates (`$and` splits; a partially translatable `$or`
222
315
  does not), and the WHOLE original compiled document runs over the
@@ -227,13 +320,18 @@ of the native statement — same answer, one branch, no wrong-typed SQL.
227
320
 
228
321
  Extends `compileJsonQuery(...).explain()`'s shape — `{ externals,
229
322
  operators, functions, collations, limits }` — with `{ sql, params,
230
- indexes, residual, barriers, scanNarrative }`. `params` lists the
231
- bound slots in order (external names and literal markers — values are
232
- ALWAYS bound, never interpolated). `indexes` names the declared
323
+ indexes, prefilters, residual, barriers, scanNarrative }`. `params`
324
+ lists the bound slots in order (external names, literal markers, and
325
+ derived slots naming the external and box axis they compute — values
326
+ are ALWAYS bound, never interpolated). `indexes` names the declared
233
327
  indexes whose generated columns the pushed predicates and ordering
234
328
  touch, and the `scanNarrative` is the database's own `EXPLAIN QUERY
235
329
  PLAN` prose so the claim is checkable against the engine that will run
236
- it. `estimatedRows` is ABSENT on SQLite drivers the capability slot
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
237
335
  is empty and no number is fabricated. `residual` is `null` or
238
336
  `{ mode: 'row' | 'set', reasons: [{ construct, reason }] }` with
239
337
  reasons drawn from the deliberate-residual table. With
@@ -247,7 +345,12 @@ predicate conjunct as a deterministic function used in the `WHERE`
247
345
  clause. Gated on `capabilities.userFunctions` (absent on Bun by
248
346
  construction) and applied only to fragments with no externals, no
249
347
  functions and no collations — deterministic and side-effect-free by
250
- analysis, not by hope. Registration is keyed by `contentKey(fragment)`
348
+ analysis, not by hope. The fragment is a raw conjunct over the caller's
349
+ collection binding, so the planner passes that binding's **name** in:
350
+ wrapped under any other name every reference would read as an external,
351
+ the determinism rule would reject the fragment, and the hatch would
352
+ silently not engage — no error and no reason in `explain()`.
353
+ Registration is keyed by `contentKey(fragment)`
251
354
  so identical fragments share one registration, and the planner MUST
252
355
  produce a correct plan with the capability disabled (tested that way).
253
356
  Preference order: native SQL → deterministic function → residual, and
package/README.md CHANGED
@@ -86,6 +86,53 @@ const adults = await users.execute({
86
86
  plus real indexes, typed from the collection's schema. Opening an
87
87
  existing database verifies the declared shape and refuses to alter
88
88
  it — reshaping is the migration story.
89
+ - **Spatial members get indexable columns.** A position is an array and
90
+ a geometry is an object, so neither is indexable as it stands. An
91
+ index declaring `derive: 'geohash'` (with a required `precision`) or
92
+ `derive: 'bbox'` materializes the cell, or the four box edges, as
93
+ columns computed by `@jarenjs/core/geo` — a generated column over a
94
+ registered deterministic function where the driver can index one, and
95
+ a stored column the store writes where it cannot.
96
+ - **A spatial query is two stages, and `explain()` names both.** A
97
+ `$within`, a `$bbox-intersects`, a bounded `$distance` or a geohash
98
+ probe over such a collection narrows **in SQLite** through the index
99
+ and refines **in the engine**. `$bbox-intersects` and a geohash cell
100
+ test are exact and need no refinement; `$within` and a bounded
101
+ `$distance` push a bounding box the truth table proves they imply,
102
+ and the exact predicate re-runs over the narrowed candidates —
103
+ `explain().prefilters` says which, over what columns, and whether it
104
+ decided or merely narrowed.
105
+
106
+ ```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
+ const places = store.collection('places');
111
+ const nearby = {
112
+ $for: { p: '$[*]' },
113
+ $where: { $within: ['$p.at', '$region'] },
114
+ $return: '$p',
115
+ };
116
+ await places.execute(nearby, { externals: { region } });
117
+
118
+ const how = await places.explain(nearby, { externals: { region } });
119
+ how.prefilters;
120
+ // [{ construct: '$within',
121
+ // columns: ['gx_at_bbox_w', 'gx_at_bbox_e', 'gx_at_bbox_s', 'gx_at_bbox_n'],
122
+ // exact: false }]
123
+ how.residual.reasons[0].reason;
124
+ // 'a bounding-box pre-filter is pushed; exact containment refines in the engine'
125
+ how.scanNarrative;
126
+ // 'SEARCH places USING INDEX places_by_box (gx_at_bbox_w>? AND gx_at_bbox_w<?); …'
127
+ ```
128
+
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.
89
136
  - **Migrations are documents.** `planMigration` diffs two models into
90
137
  rendered-DDL + JSLT-transform + assertion steps; a shadow database
91
138
  replays the whole chain before the real store is touched; a
@@ -13,7 +13,7 @@
13
13
  * design (see ARCHITECTURE.md's deliberate-residual table).
14
14
  */
15
15
  /** The plan format version, carried on every plan. */
16
- export declare const PLAN_VERSION = 1;
16
+ export declare const PLAN_VERSION = 2;
17
17
  export type PlanRef = {
18
18
  segments: ({
19
19
  name: string;
@@ -56,6 +56,27 @@ export type PlanPredicate = ({
56
56
  p: 'udf';
57
57
  name: string;
58
58
  key: string;
59
+ } | {
60
+ p: 'bboxOverlap';
61
+ columns: {
62
+ w: string;
63
+ s: string;
64
+ e: string;
65
+ n: string;
66
+ };
67
+ probe: {
68
+ box: number[];
69
+ } | {
70
+ ext: string;
71
+ };
72
+ } | {
73
+ p: 'cellIn';
74
+ column: string;
75
+ cells: string[];
76
+ } | {
77
+ p: 'cellPrefix';
78
+ column: string;
79
+ prefix: string;
59
80
  });
60
81
  export type PlanOrderTerm = {
61
82
  ref: PlanRef;
@@ -96,8 +117,23 @@ export type Plan = {
96
117
  * { p: 'strop', kind: 'starts' | 'ends' | 'contains',
97
118
  * ref: PlanRef, operand: PlanOperand } |
98
119
  * { p: 'const', value: boolean } |
99
- * { p: 'udf', name: string, key: string }
120
+ * { p: 'udf', name: string, key: string } |
121
+ * { p: 'bboxOverlap', columns: { w: string, s: string, e: string,
122
+ * n: string }, probe: { box: number[] } | { ext: string } } |
123
+ * { p: 'cellIn', column: string, cells: string[] } |
124
+ * { p: 'cellPrefix', column: string, prefix: string }
100
125
  * )} PlanPredicate
126
+ * The last three are the SPATIAL forms: predicates over the derived
127
+ * index columns a model declares, which a spatial conjunct either
128
+ * translates to exactly or is proven to IMPLY. `bboxOverlap` is true
129
+ * when the row's stored box meets the probe's (touching edges count,
130
+ * as the kernel's `bboxIntersects` does); `cellIn` when the row's
131
+ * cell is one of the listed ones (the nine-cell neighbourhood, or a
132
+ * single whole cell); `cellPrefix` when it begins with a shorter one.
133
+ * None carries a `json_type` guard — the derived column IS the value
134
+ * — but each is TOTAL through its own `IS NOT NULL`, so a row with no
135
+ * box or no cell answers FALSE rather than SQL's NULL and negation
136
+ * still composes classically.
101
137
  *
102
138
  * @typedef {{ ref: PlanRef, desc: boolean, emptyGreatest: boolean }} PlanOrderTerm
103
139
  *
@@ -27,10 +27,20 @@ export declare function compileIndexPath(expression: string, docPath: string): {
27
27
  canonical: string;
28
28
  };
29
29
  /**
30
- * The declared schema type at a segment path, walked structurally
31
- * through `properties` / `items` / `prefixItems`. The collection's
32
- * schema is the type source — that is why the physical mapping needs
33
- * no engine-side inference.
30
+ * The schema subschema at a segment path, walked structurally through
31
+ * `properties` / `items` / `prefixItems`. The collection's schema is
32
+ * the type source — that is why the physical mapping needs no
33
+ * engine-side inference. `undefined` where the walk leaves the schema.
34
+ * @param {any} schema
35
+ * @param {import('./dialect.js').JsonPathSegment[]} segments
36
+ * @returns {any}
37
+ */
38
+ export declare function schemaNodeAt(schema: any, segments: import('./dialect.js').JsonPathSegment[]): any;
39
+ /**
40
+ * The declared schema type at a segment path: the first non-`null`
41
+ * member of a union, which is the type a COLUMN takes its storage
42
+ * from. A caller that must know the whole union (a promotion refusing
43
+ * a member that may also be `null`) reads {@link schemaNodeAt}.
34
44
  * @param {any} schema
35
45
  * @param {import('./dialect.js').JsonPathSegment[]} segments
36
46
  * @returns {string | undefined}
@@ -40,16 +50,29 @@ export declare function schemaTypeAt(schema: any, segments: import('./dialect.js
40
50
  * Plan one collection's physical shape: the DDL statements to create
41
51
  * it and the structural facts an existing table must match (the
42
52
  * `JD0002` comparison set).
53
+ * A derived index (`derive: 'geohash' | 'bbox'`) maps to the same
54
+ * shape through a registered deterministic function, EXCEPT where the
55
+ * driver cannot index one (`capabilities.deterministicIndexableFunctions`
56
+ * is false): there the columns are ordinary ones the store writes. The
57
+ * two mappings produce different declared text on purpose — a database
58
+ * built under one and opened under the other really does disagree, and
59
+ * `verifyShape` says so rather than papering over it.
43
60
  * @param {string} name - The collection name (also the table name)
44
61
  * @param {{ schema: any, keySegments: { name: string }[] | null,
45
62
  * identity: string, indexes: { name: string, paths: string[],
46
- * unique: boolean, docPath: string }[] }} collection - normalized
63
+ * unique: boolean, derive?: string | null, precision?: number,
64
+ * docPath: string }[] }} collection - normalized
47
65
  * @param {any} dialect
66
+ * @param {{ derived?: 'virtual' | 'stored' }} [options] - the physical
67
+ * mapping for derived columns; `'virtual'` (a generated column over a
68
+ * registered function) unless the driver says it cannot index one
48
69
  * @returns {{
49
70
  * table: string, keyColumn: string, docColumn: string,
50
71
  * keyType: string,
51
72
  * generated: { name: string, type: string, pathText: string,
52
73
  * canonical: string }[],
74
+ * derived: { name: string, derive: string, precision?: number,
75
+ * component?: string, segments: any[] }[],
53
76
  * columnByCanonical: Map<string, string>,
54
77
  * createSql: string[],
55
78
  * expected: { columns: { name: string, type: string,
@@ -67,9 +90,13 @@ export declare function planCollection(name: string, collection: {
67
90
  name: string;
68
91
  paths: string[];
69
92
  unique: boolean;
93
+ derive?: string | null;
94
+ precision?: number;
70
95
  docPath: string;
71
96
  }[];
72
- }, dialect: any): {
97
+ }, dialect: any, options?: {
98
+ derived?: 'virtual' | 'stored';
99
+ }): {
73
100
  table: string;
74
101
  keyColumn: string;
75
102
  docColumn: string;
@@ -80,6 +107,13 @@ export declare function planCollection(name: string, collection: {
80
107
  pathText: string;
81
108
  canonical: string;
82
109
  }[];
110
+ derived: {
111
+ name: string;
112
+ derive: string;
113
+ precision?: number;
114
+ component?: string;
115
+ segments: any[];
116
+ }[];
83
117
  columnByCanonical: Map<string, string>;
84
118
  createSql: string[];
85
119
  expected: {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @file Derived index columns: the one place a declared
3
+ * `indexes[].derive` becomes a value. A spatial member is an array of
4
+ * numbers or an object, and a generated column must be a scalar, so a
5
+ * geohash cell or a bounding-box edge is what actually gets indexed.
6
+ *
7
+ * Every cell and every box comes from `@jarenjs/core/geo`; nothing
8
+ * here computes spatial arithmetic of its own. The same functions
9
+ * serve BOTH physical mappings — registered as deterministic SQL
10
+ * functions inside a virtual generated column's expression where the
11
+ * driver can index them, and called directly on the write path where
12
+ * it cannot — so the two branches cannot drift into different answers.
13
+ *
14
+ * It is also this package's ONLY seam onto `@jarenjs/core/geo` (D1 —
15
+ * one home for spatial arithmetic, grep-proven by test): the planner's
16
+ * probe geometry — the box of a literal or bound region, the box of a
17
+ * bounded-distance circle, a cell's neighbourhood — is computed by the
18
+ * helpers below rather than by an import of its own.
19
+ *
20
+ * Determinism is the contract, not a convenience: a value here is a
21
+ * pure function of the document bytes. Nothing reads the clock, a
22
+ * random source or store state, because an INDEX over a function that
23
+ * did would make the database unreadable from a connection whose
24
+ * function answered differently — and unreadable, not merely wrong:
25
+ * a connection that has not registered the function at all cannot even
26
+ * SELECT the table (probed). That hazard is why the mapping is
27
+ * capability-gated rather than always-on.
28
+ */
29
+ /** The closed set of derive kinds. */
30
+ export declare const DERIVE_KINDS: Set<string>;
31
+ /** A bbox index's four columns, in the order they are declared —
32
+ * `[west, south, east, north]`, the order the kernel's boxes carry. */
33
+ export declare const BBOX_COMPONENTS: readonly string[];
34
+ /**
35
+ * The order a bbox index COVERS its four columns, which is not the
36
+ * order they are declared in: an intersection test reads
37
+ * `w <= ? AND e >= ? AND s <= ? AND n >= ?`, so the two longitude
38
+ * bounds sit together at the front of the index where a leading-column
39
+ * range can use them. `(a,b)` and `(b,a)` are different indexes.
40
+ */
41
+ export declare const BBOX_INDEX_ORDER: readonly string[];
42
+ /** The declared geohash precision range (characters). */
43
+ export declare const PRECISION_MIN = 1;
44
+ export declare const PRECISION_MAX = 12;
45
+ /**
46
+ * The geohash cell of a value at a precision, or null when the value
47
+ * carries no bounded position.
48
+ * @param {any} value
49
+ * @param {number} precision
50
+ * @returns {string | null}
51
+ */
52
+ export declare function deriveGeohash(value: any, precision: number): string | null;
53
+ /**
54
+ * One edge of a value's bounding box, or null when it has none —
55
+ * including the D6 case where a non-finite coordinate refuses the box
56
+ * rather than producing one that does not bound its input.
57
+ * @param {any} value
58
+ * @param {string} component - `'w'`, `'s'`, `'e'` or `'n'`
59
+ * @returns {number | null}
60
+ */
61
+ export declare function deriveBboxEdge(value: any, component: string): number | null;
62
+ /**
63
+ * A member as the database will hold it. A derived value must be a
64
+ * function of the STORED document, not of the object handed to the
65
+ * write: JSON has no `NaN` and no `Infinity`, so a non-finite
66
+ * coordinate becomes `null` on the way in and is no longer a position
67
+ * at all. Computing from the in-memory value would make the two
68
+ * physical mappings answer differently for the same document, which is
69
+ * the one thing they may never do.
70
+ *
71
+ * Only the indexed MEMBER round-trips, not the whole document — it is
72
+ * the only part a derived column reads.
73
+ * @param {any} member
74
+ * @returns {any} the member as stored, or `undefined` when there is none
75
+ */
76
+ export declare function storedMemberForm(member: any): any;
77
+ /**
78
+ * The value of one derived column for a document member.
79
+ * @param {{ derive: string, precision?: number, component?: string }} column
80
+ * @param {any} member - the value at the index path, or `undefined`
81
+ * @returns {string | number | null}
82
+ */
83
+ export declare function derivedValue(column: {
84
+ derive: string;
85
+ precision?: number;
86
+ component?: string;
87
+ }, member: any): string | number | null;
88
+ /**
89
+ * Read the member one derived column is computed from, walking the
90
+ * same typed segments the physical mapping was planned over.
91
+ * @param {any} doc
92
+ * @param {import('./dialect.js').JsonPathSegment[]} segments
93
+ * @returns {any} the member, or `undefined`
94
+ */
95
+ export declare function memberAt(doc: any, segments: import('./dialect.js').JsonPathSegment[]): any;
96
+ /**
97
+ * Register the deterministic functions the virtual generated columns
98
+ * call. Idempotent per connection by SQLite's own semantics (a second
99
+ * registration replaces the first with the identical implementation),
100
+ * and a no-op where the driver cannot index a registered function —
101
+ * that branch stores the columns instead.
102
+ *
103
+ * The four bbox edges share a one-entry memo of the last text they
104
+ * were asked about, because they are called back to back with the same
105
+ * argument for one row and the box costs a full walk of the geometry.
106
+ * The memo is keyed on the argument itself, so it changes no answer.
107
+ * @param {any} connection
108
+ * @returns {any} value-or-promise
109
+ */
110
+ export declare function registerDeriveFunctions(connection: any): any;
111
+ /**
112
+ * The bounding box of a probe value, or `null` when it has none — the
113
+ * D6 refusal included, which is what makes an unbounded probe divert to
114
+ * the full scan instead of narrowing with a box that does not bound it.
115
+ * @param {any} value - a GeoJSON value or a `[lon, lat]` position
116
+ * @returns {number[] | null} `[west, south, east, north]`
117
+ */
118
+ export declare function probeBox(value: any): number[] | null;
119
+ /**
120
+ * The representative position §8.14 measures a probe value by — the
121
+ * same rule the derived columns use, so the pushed filter and the
122
+ * engine cannot disagree about where a value IS. `null` when it has no
123
+ * bounded position.
124
+ * @param {any} value
125
+ * @returns {number[] | null}
126
+ */
127
+ export declare function probePosition(value: any): number[] | null;
128
+ /**
129
+ * The bounding box of the circle of `metres` around a position — the
130
+ * box a `$distance <= r` predicate narrows with, on the same sphere and
131
+ * the same radius constant the engine measures with. `null` when the
132
+ * radius is not a finite non-negative number, or when the circle
133
+ * reaches a pole, where there is no longitude bound to give.
134
+ * @param {number[]} position
135
+ * @param {number} metres
136
+ * @returns {number[] | null} `[west, south, east, north]`, NOT wrapped
137
+ * into `[-180, 180]`: a circle spanning the antimeridian answers a
138
+ * west below -180, which is how the planner detects it
139
+ */
140
+ export declare function probeCircleBox(position: number[], metres: number): number[] | null;
141
+ /**
142
+ * A cell and its neighbours, the nine-cell probe D7 requires — a single
143
+ * prefix is bucketing, never proximity.
144
+ * @param {string} cell
145
+ * @returns {string[]} up to nine cells (fewer past a pole)
146
+ */
147
+ export declare function cellNeighbourhood(cell: string): string[];
148
+ /**
149
+ * The value one DERIVED parameter slot binds: an axis of a bound
150
+ * external's bounding box, computed at bind time because a GeoJSON
151
+ * object is not a value any database can bind. `null` when the value
152
+ * has no box, which is what tells the caller to divert.
153
+ * @param {{ kind: string, external: string, axis: string }} derived
154
+ * @param {any} value - the bound external
155
+ * @returns {number | null}
156
+ */
157
+ export declare function derivedSlotValue(derived: {
158
+ kind: string;
159
+ external: string;
160
+ axis: string;
161
+ }, value: any): number | null;
@@ -44,6 +44,8 @@ export type JsonPathSegment = {
44
44
  * limitClause: (limit: number, offset?: number) => string,
45
45
  * jsonPathText: (segments: JsonPathSegment[]) => string | null,
46
46
  * jsonExtract: (columnSql: string, pathText: string) => string,
47
+ * derivedExpression?: (memberSql: string, column: { derive: string,
48
+ * precision?: number, component?: string }) => string,
47
49
  * jsonSet: (exprSql: string, pathText: string, valueSql: string) => string,
48
50
  * jsonRemove: (exprSql: string, pathText: string) => string,
49
51
  * jsonAppend: (exprSql: string, arrayPathText: string, valueSql: string) => string,
@@ -52,7 +54,8 @@ export type JsonPathSegment = {
52
54
  * jsonAgg: (exprSql: string) => string,
53
55
  * jsonTypeOf: (columnSql: string, pathText: string) => string,
54
56
  * valueTypeOf: (paramSql: string) => string,
55
- * strStartsWith: (valueSql: string, patternA: string, patternB: string) => string,
57
+ * strStartsWith: (valueSql: string, lowerParamSql: string, upperParamSql: string) => string,
58
+ * strStartsWithExact: (valueSql: string, patternA: string, patternB: string) => string,
56
59
  * strEndsWith: (valueSql: string, patternA: string, patternB: string, patternC: string) => string,
57
60
  * strContains: (valueSql: string, patternSql: string) => string,
58
61
  * orderNulls: (nullsFirst: boolean) => string,
@@ -88,6 +91,11 @@ export declare function createDialect(spec: {
88
91
  limitClause: (limit: number, offset?: number) => string;
89
92
  jsonPathText: (segments: JsonPathSegment[]) => string | null;
90
93
  jsonExtract: (columnSql: string, pathText: string) => string;
94
+ derivedExpression?: (memberSql: string, column: {
95
+ derive: string;
96
+ precision?: number;
97
+ component?: string;
98
+ }) => string;
91
99
  jsonSet: (exprSql: string, pathText: string, valueSql: string) => string;
92
100
  jsonRemove: (exprSql: string, pathText: string) => string;
93
101
  jsonAppend: (exprSql: string, arrayPathText: string, valueSql: string) => string;
@@ -96,7 +104,8 @@ export declare function createDialect(spec: {
96
104
  jsonAgg: (exprSql: string) => string;
97
105
  jsonTypeOf: (columnSql: string, pathText: string) => string;
98
106
  valueTypeOf: (paramSql: string) => string;
99
- strStartsWith: (valueSql: string, patternA: string, patternB: string) => string;
107
+ strStartsWith: (valueSql: string, lowerParamSql: string, upperParamSql: string) => string;
108
+ strStartsWithExact: (valueSql: string, patternA: string, patternB: string) => string;
100
109
  strEndsWith: (valueSql: string, patternA: string, patternB: string, patternC: string) => string;
101
110
  strContains: (valueSql: string, patternSql: string) => string;
102
111
  orderNulls: (nullsFirst: boolean) => string;
@@ -28,6 +28,7 @@
28
28
  * them without a contract change; pretending SQLite has them is the
29
29
  * silent degradation this suite refuses.
30
30
  */
31
+ import { isThenable, chain, toPromise } from '@jarenjs/core/function';
31
32
  /** The minimum SQLite the store accepts, asserted at open. */
32
33
  export declare const SQLITE_FLOOR = "3.45.0";
33
34
  /**
@@ -38,26 +39,7 @@ export declare const SQLITE_FLOOR = "3.45.0";
38
39
  * busy timeout) or another transaction on this one.
39
40
  */
40
41
  export declare const DEFAULT_QUEUE_TIMEOUT = 5000;
41
- /**
42
- * @param {any} value
43
- * @returns {boolean} true when the value is a thenable
44
- */
45
- export declare function isThenable(value: any): boolean;
46
- /**
47
- * Sync-capable-async composition: apply `next` to a driver result
48
- * without allocating a promise when the result is already a value.
49
- * @param {any} value - A driver return: a value or a promise
50
- * @param {(value: any) => any} next
51
- * @returns {any} `next`'s result, promise-wrapped only if the input was
52
- */
53
- export declare function chain(value: any, next: (value: any) => any): any;
54
- /**
55
- * Lift a driver result into a promise — the ONE allocation the public
56
- * asynchronous surface pays per call.
57
- * @param {any} value
58
- * @returns {Promise<any>}
59
- */
60
- export declare function toPromise(value: any): Promise<any>;
42
+ export { isThenable, chain, toPromise };
61
43
  /**
62
44
  * Compare two dotted version strings numerically.
63
45
  * @param {string} a
@@ -18,10 +18,13 @@ export type ParamSlot = {
18
18
  external: string;
19
19
  } | {
20
20
  literal: unknown;
21
+ } | {
22
+ derived: {
23
+ kind: 'bboxAxis';
24
+ external: string;
25
+ axis: 'w' | 's' | 'e' | 'n';
26
+ };
21
27
  };
22
- /**
23
- * @typedef {{ external: string } | { literal: unknown }} ParamSlot
24
- */
25
28
  /**
26
29
  * Emit one plan as SQL plus its ordered parameter slots.
27
30
  * @param {import('./algebra.js').Plan} plan