@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.
@@ -80,7 +80,7 @@
80
80
  "additionalProperties": false
81
81
  },
82
82
  "index": {
83
- "description": "One index over a singular JSONPath expression (or a composite over several). A non-singular path is rejected at open (JD0004).",
83
+ "description": "One index over a singular JSONPath expression (or a composite over several). A non-singular path is rejected at open (JD0004). A derived index computes indexable scalars from the selected member instead of indexing it directly.",
84
84
  "type": "object",
85
85
  "properties": {
86
86
  "name": {
@@ -108,6 +108,19 @@
108
108
  "unique": {
109
109
  "description": "Whether the index enforces uniqueness. Defaults to false.",
110
110
  "type": "boolean"
111
+ },
112
+ "derive": {
113
+ "description": "Derive indexable columns from the selected spatial member instead of indexing it: 'geohash' (one TEXT cell column, precision required) or 'bbox' (four REAL columns — west, south, east, north). A derived index is never unique.",
114
+ "enum": [
115
+ "geohash",
116
+ "bbox"
117
+ ]
118
+ },
119
+ "precision": {
120
+ "description": "Geohash cell length in characters, 1-12. Required beside derive: 'geohash' and refused anywhere else — there is no safe default, because the right cell size depends on the query radius, which the model cannot know.",
121
+ "type": "integer",
122
+ "minimum": 1,
123
+ "maximum": 12
111
124
  }
112
125
  },
113
126
  "required": [
@@ -21,7 +21,7 @@
21
21
  }
22
22
  },
23
23
  "entities": {
24
- "description": "Entity name (an identifier) to entity declaration. The x-entity vocabulary lives INSIDE each entity schema's property subschemas and is documented in MODEL-FORMAT.md \u00a79; stripping it leaves a plain JSON Schema.",
24
+ "description": "Entity name (an identifier) to entity declaration. The x-entity vocabulary lives INSIDE each entity schema's property subschemas and is documented in MODEL-FORMAT.md §9; stripping it leaves a plain JSON Schema.",
25
25
  "type": "object",
26
26
  "minProperties": 1,
27
27
  "propertyNames": {
@@ -46,7 +46,7 @@
46
46
  "type": "object"
47
47
  },
48
48
  "key": {
49
- "description": "Where the caller-supplied key lives in the document, as an RFC 6901 pointer selecting at least one member \u2014 or null when the store allocates.",
49
+ "description": "Where the caller-supplied key lives in the document, as an RFC 6901 pointer selecting at least one member or null when the store allocates.",
50
50
  "type": [
51
51
  "string",
52
52
  "null"
@@ -54,7 +54,7 @@
54
54
  "pattern": "^/"
55
55
  },
56
56
  "identity": {
57
- "description": "How keys come to be: 'caller' (the default when a key pointer is declared), or \u2014 only with \"key\": null \u2014 'uuid' (crypto.randomUUID) or 'integer' (database-allocated).",
57
+ "description": "How keys come to be: 'caller' (the default when a key pointer is declared), or only with \"key\": null 'uuid' (crypto.randomUUID) or 'integer' (database-allocated).",
58
58
  "enum": [
59
59
  "caller",
60
60
  "uuid",
@@ -80,7 +80,7 @@
80
80
  "additionalProperties": false
81
81
  },
82
82
  "index": {
83
- "description": "One index over a singular JSONPath expression (or a composite over several). A non-singular path is rejected at open (JD0004).",
83
+ "description": "One index over a singular JSONPath expression (or a composite over several). A non-singular path is rejected at open (JD0004). A derived index computes indexable scalars from the selected member instead of indexing it directly.",
84
84
  "type": "object",
85
85
  "properties": {
86
86
  "name": {
@@ -108,6 +108,19 @@
108
108
  "unique": {
109
109
  "description": "Whether the index enforces uniqueness. Defaults to false.",
110
110
  "type": "boolean"
111
+ },
112
+ "derive": {
113
+ "description": "Derive indexable columns from the selected spatial member instead of indexing it: 'geohash' (one TEXT cell column, precision required) or 'bbox' (four REAL columns — west, south, east, north). A derived index is never unique.",
114
+ "enum": [
115
+ "geohash",
116
+ "bbox"
117
+ ]
118
+ },
119
+ "precision": {
120
+ "description": "Geohash cell length in characters, 1-12. Required beside derive: 'geohash' and refused anywhere else — there is no safe default, because the right cell size depends on the query radius, which the model cannot know.",
121
+ "type": "integer",
122
+ "minimum": 1,
123
+ "maximum": 12
111
124
  }
112
125
  },
113
126
  "required": [
@@ -125,7 +138,7 @@
125
138
  },
126
139
  "x-rename": {
127
140
  "type": "string",
128
- "description": "Declares this entity as the rename of the named from-model entity (MIGRATION-FORMAT \u00a79): a rename is declared, never inferred."
141
+ "description": "Declares this entity as the rename of the named from-model entity (MIGRATION-FORMAT §9): a rename is declared, never inferred."
129
142
  }
130
143
  },
131
144
  "required": [
package/src/algebra.js CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  /** The plan format version, carried on every plan. */
18
- export const PLAN_VERSION = 1;
18
+ export const PLAN_VERSION = 2;
19
19
 
20
20
  /**
21
21
  * @typedef {{ segments: ({ name: string } | { index: number })[],
@@ -35,8 +35,23 @@ export const PLAN_VERSION = 1;
35
35
  * { p: 'strop', kind: 'starts' | 'ends' | 'contains',
36
36
  * ref: PlanRef, operand: PlanOperand } |
37
37
  * { p: 'const', value: boolean } |
38
- * { p: 'udf', name: string, key: string }
38
+ * { p: 'udf', name: string, key: string } |
39
+ * { p: 'bboxOverlap', columns: { w: string, s: string, e: string,
40
+ * n: string }, probe: { box: number[] } | { ext: string } } |
41
+ * { p: 'cellIn', column: string, cells: string[] } |
42
+ * { p: 'cellPrefix', column: string, prefix: string }
39
43
  * )} PlanPredicate
44
+ * The last three are the SPATIAL forms: predicates over the derived
45
+ * index columns a model declares, which a spatial conjunct either
46
+ * translates to exactly or is proven to IMPLY. `bboxOverlap` is true
47
+ * when the row's stored box meets the probe's (touching edges count,
48
+ * as the kernel's `bboxIntersects` does); `cellIn` when the row's
49
+ * cell is one of the listed ones (the nine-cell neighbourhood, or a
50
+ * single whole cell); `cellPrefix` when it begins with a shorter one.
51
+ * None carries a `json_type` guard — the derived column IS the value
52
+ * — but each is TOTAL through its own `IS NOT NULL`, so a row with no
53
+ * box or no cell answers FALSE rather than SQL's NULL and negation
54
+ * still composes classically.
40
55
  *
41
56
  * @typedef {{ ref: PlanRef, desc: boolean, emptyGreatest: boolean }} PlanOrderTerm
42
57
  *
package/src/ddl.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import { analyzeQuery } from '@jarenjs/json/query';
18
18
  import { DbCompileError } from './errors.js';
19
19
  import { chain } from './driver.js';
20
+ import { BBOX_COMPONENTS, BBOX_INDEX_ORDER } from './derive.js';
20
21
 
21
22
  /** The fixed physical column names of the 0.1 mapping. */
22
23
  export const KEY_COLUMN = 'key';
@@ -76,15 +77,15 @@ export function compileIndexPath(expression, docPath) {
76
77
  }
77
78
 
78
79
  /**
79
- * The declared schema type at a segment path, walked structurally
80
- * through `properties` / `items` / `prefixItems`. The collection's
81
- * schema is the type source — that is why the physical mapping needs
82
- * no engine-side inference.
80
+ * The schema subschema at a segment path, walked structurally through
81
+ * `properties` / `items` / `prefixItems`. The collection's schema is
82
+ * the type source — that is why the physical mapping needs no
83
+ * engine-side inference. `undefined` where the walk leaves the schema.
83
84
  * @param {any} schema
84
85
  * @param {import('./dialect.js').JsonPathSegment[]} segments
85
- * @returns {string | undefined}
86
+ * @returns {any}
86
87
  */
87
- export function schemaTypeAt(schema, segments) {
88
+ export function schemaNodeAt(schema, segments) {
88
89
  let node = schema;
89
90
  for (const segment of segments) {
90
91
  if (node === null || typeof node !== 'object') return undefined;
@@ -92,7 +93,21 @@ export function schemaTypeAt(schema, segments) {
92
93
  ? node.properties?.[segment.name]
93
94
  : node.prefixItems?.[segment.index] ?? node.items;
94
95
  }
95
- if (node === null || typeof node !== 'object') return undefined;
96
+ return node === null || typeof node !== 'object' ? undefined : node;
97
+ }
98
+
99
+ /**
100
+ * The declared schema type at a segment path: the first non-`null`
101
+ * member of a union, which is the type a COLUMN takes its storage
102
+ * from. A caller that must know the whole union (a promotion refusing
103
+ * a member that may also be `null`) reads {@link schemaNodeAt}.
104
+ * @param {any} schema
105
+ * @param {import('./dialect.js').JsonPathSegment[]} segments
106
+ * @returns {string | undefined}
107
+ */
108
+ export function schemaTypeAt(schema, segments) {
109
+ const node = schemaNodeAt(schema, segments);
110
+ if (node === undefined) return undefined;
96
111
  if (typeof node.type === 'string') return node.type;
97
112
  if (Array.isArray(node.type)) {
98
113
  return node.type.find((t) => typeof t === 'string' && t !== 'null');
@@ -100,39 +115,139 @@ export function schemaTypeAt(schema, segments) {
100
115
  return undefined;
101
116
  }
102
117
 
118
+ /** The scalar schema types a derived index cannot be declared over. */
119
+ const SCALAR_TYPES = new Set(['string', 'integer', 'number', 'boolean']);
120
+
103
121
  /**
104
122
  * A stable generated-column name for a canonical path: readable where
105
123
  * the path is tame, disambiguated by suffix where sanitizing collides.
124
+ *
125
+ * A DERIVED index names its columns from the same stem plus what makes
126
+ * the derivation distinct — the kind, and the geohash precision, since
127
+ * two precisions over one path are legitimately two column sets (a
128
+ * coarse bucketing index and a fine proximity one). A bbox derivation
129
+ * owns FOUR columns under one stem, so every one of them is claimed
130
+ * before the stem is accepted.
106
131
  * @param {string} canonical
107
- * @param {Map<string, string>} byCanonical - canonical -> column name
132
+ * @param {Map<string, string>} byKey - identity -> column name (or stem)
108
133
  * @param {Set<string>} taken
134
+ * @param {{ key?: string, suffix?: string, parts?: readonly string[] }} [options]
109
135
  * @returns {string}
110
136
  */
111
- function generatedColumnName(canonical, byCanonical, taken) {
112
- const existing = byCanonical.get(canonical);
137
+ function generatedColumnName(canonical, byKey, taken, options = undefined) {
138
+ const key = options?.key ?? canonical;
139
+ const existing = byKey.get(key);
113
140
  if (existing !== undefined) return existing;
114
- const base = `gx_${canonical.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '')}`;
141
+ const stem = `gx_${canonical.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '')}`;
142
+ const base = options?.suffix === undefined ? stem : `${stem}_${options.suffix}`;
143
+ const parts = options?.parts ?? [''];
144
+ const claims = (/** @type {string} */ candidate) =>
145
+ parts.map((part) => (part === '' ? candidate : `${candidate}_${part}`));
115
146
  let name = base;
116
- for (let i = 2; taken.has(name); i++) name = `${base}_${i}`;
117
- byCanonical.set(canonical, name);
118
- taken.add(name);
147
+ for (let i = 2; claims(name).some((claimed) => taken.has(claimed)); i++)
148
+ name = `${base}_${i}`;
149
+ byKey.set(key, name);
150
+ for (const claimed of claims(name)) taken.add(claimed);
119
151
  return name;
120
152
  }
121
153
 
154
+ /**
155
+ * The columns one derived index contributes, appended to the plan's
156
+ * `generated` (the physical column list) and `derived` (what the write
157
+ * path and the migration backfill need to recompute a value).
158
+ *
159
+ * `derive` changes what is COMPUTED from the member, never how the
160
+ * member is selected — so the schema is still the type source, and a
161
+ * derivation over a path the schema types as a scalar is refused here
162
+ * rather than at the first query that returns nothing.
163
+ * @param {any} index - the normalized index declaration
164
+ * @param {any} context
165
+ * @returns {string[]} the column names the index covers, in order
166
+ */
167
+ function deriveColumns(index, context) {
168
+ const {
169
+ collection, dialect, stored, segments, canonical, pathText,
170
+ columnByCanonical, taken, generated, derived,
171
+ } = context;
172
+ const declaredType = schemaTypeAt(collection.schema, segments);
173
+ if (declaredType !== undefined && SCALAR_TYPES.has(declaredType)) {
174
+ throw new DbCompileError('JD0004',
175
+ `the index path '${index.paths[0]}' is typed '${declaredType}' by the schema, and a `
176
+ + `${index.derive} index derives from a position or a geometry — an array or an object`,
177
+ `${index.docPath}/derive`);
178
+ }
179
+ const isGeohash = index.derive === 'geohash';
180
+ // the identity that decides column SHARING: two indexes over the same
181
+ // path with the same derivation and the same precision are one column
182
+ // set; two precisions over one path are two, and legitimately so
183
+ const key = `${canonical}|${index.derive}|${index.precision ?? ''}`;
184
+ const stem = generatedColumnName(canonical, columnByCanonical, taken, {
185
+ key,
186
+ suffix: isGeohash ? `gh${index.precision}` : 'bbox',
187
+ parts: isGeohash ? undefined : BBOX_COMPONENTS,
188
+ });
189
+ const type = dialect.typeFor(isGeohash ? 'string' : 'number', 'generated');
190
+ const components = isGeohash ? [undefined] : BBOX_COMPONENTS;
191
+ const nameOf = (/** @type {string | undefined} */ component) =>
192
+ (component === undefined ? stem : `${stem}_${component}`);
193
+ if (!generated.some((column) => column.name === nameOf(components[0]))) {
194
+ for (const component of components) {
195
+ const column = {
196
+ name: nameOf(component),
197
+ type,
198
+ pathText,
199
+ canonical,
200
+ derive: index.derive,
201
+ precision: index.precision,
202
+ component,
203
+ stored,
204
+ };
205
+ generated.push({
206
+ ...column,
207
+ expression: stored ? null : dialect.derivedColumn(
208
+ dialect.quoteIdentifier(DOC_COLUMN), pathText, column),
209
+ });
210
+ derived.push({
211
+ name: column.name,
212
+ derive: index.derive,
213
+ precision: index.precision,
214
+ component,
215
+ segments,
216
+ });
217
+ }
218
+ }
219
+ // the index COVERS its columns in (w, e, s, n) order, which is not
220
+ // their declaration order — see BBOX_INDEX_ORDER
221
+ return isGeohash ? [stem] : BBOX_INDEX_ORDER.map((component) => nameOf(component));
222
+ }
223
+
122
224
  /**
123
225
  * Plan one collection's physical shape: the DDL statements to create
124
226
  * it and the structural facts an existing table must match (the
125
227
  * `JD0002` comparison set).
228
+ * A derived index (`derive: 'geohash' | 'bbox'`) maps to the same
229
+ * shape through a registered deterministic function, EXCEPT where the
230
+ * driver cannot index one (`capabilities.deterministicIndexableFunctions`
231
+ * is false): there the columns are ordinary ones the store writes. The
232
+ * two mappings produce different declared text on purpose — a database
233
+ * built under one and opened under the other really does disagree, and
234
+ * `verifyShape` says so rather than papering over it.
126
235
  * @param {string} name - The collection name (also the table name)
127
236
  * @param {{ schema: any, keySegments: { name: string }[] | null,
128
237
  * identity: string, indexes: { name: string, paths: string[],
129
- * unique: boolean, docPath: string }[] }} collection - normalized
238
+ * unique: boolean, derive?: string | null, precision?: number,
239
+ * docPath: string }[] }} collection - normalized
130
240
  * @param {any} dialect
241
+ * @param {{ derived?: 'virtual' | 'stored' }} [options] - the physical
242
+ * mapping for derived columns; `'virtual'` (a generated column over a
243
+ * registered function) unless the driver says it cannot index one
131
244
  * @returns {{
132
245
  * table: string, keyColumn: string, docColumn: string,
133
246
  * keyType: string,
134
247
  * generated: { name: string, type: string, pathText: string,
135
248
  * canonical: string }[],
249
+ * derived: { name: string, derive: string, precision?: number,
250
+ * component?: string, segments: any[] }[],
136
251
  * columnByCanonical: Map<string, string>,
137
252
  * createSql: string[],
138
253
  * expected: { columns: { name: string, type: string,
@@ -140,7 +255,8 @@ function generatedColumnName(canonical, byCanonical, taken) {
140
255
  * columns: string[] }[] },
141
256
  * }}
142
257
  */
143
- export function planCollection(name, collection, dialect) {
258
+ export function planCollection(name, collection, dialect, options = undefined) {
259
+ const stored = options?.derived === 'stored';
144
260
  const keyType = collection.identity === 'integer'
145
261
  ? dialect.typeFor('integer', 'key')
146
262
  : collection.identity === 'uuid'
@@ -154,6 +270,8 @@ export function planCollection(name, collection, dialect) {
154
270
  const taken = new Set([KEY_COLUMN, DOC_COLUMN]);
155
271
  /** @type {{ name: string, type: string, pathText: string, canonical: string }[]} */
156
272
  const generated = [];
273
+ /** @type {any[]} */
274
+ const derived = [];
157
275
  /** @type {{ name: string, unique: boolean, columns: string[] }[]} */
158
276
  const indexes = [];
159
277
 
@@ -168,6 +286,13 @@ export function planCollection(name, collection, dialect) {
168
286
  `the index path '${index.paths[i]}' names a member the dialect's JSON path grammar cannot carry`,
169
287
  pathDocPath);
170
288
  }
289
+ if (index.derive) {
290
+ columns.push(...deriveColumns(index, {
291
+ collection, dialect, stored, segments, canonical, pathText,
292
+ columnByCanonical, taken, generated, derived,
293
+ }));
294
+ continue;
295
+ }
171
296
  const known = columnByCanonical.has(canonical);
172
297
  const columnName = generatedColumnName(canonical, columnByCanonical, taken);
173
298
  if (!known) {
@@ -210,13 +335,17 @@ export function planCollection(name, collection, dialect) {
210
335
  docColumn: DOC_COLUMN,
211
336
  keyType,
212
337
  generated,
338
+ derived,
213
339
  columnByCanonical,
214
340
  createSql,
215
341
  expected: {
216
342
  columns: [
217
343
  { name: KEY_COLUMN, type: keyType, generated: false },
218
344
  { name: DOC_COLUMN, type: dialect.docColumnType, generated: false },
219
- ...generated.map((g) => ({ name: g.name, type: g.type, generated: true })),
345
+ // a STORED derived column is an ordinary one: the flag is what
346
+ // `pragma_table_xinfo` reports, and it is the difference a file
347
+ // moved between the two physical mappings shows up as
348
+ ...generated.map((g) => ({ name: g.name, type: g.type, generated: g.stored !== true })),
220
349
  ],
221
350
  indexes: indexes
222
351
  // the COLUMNS keep their declared order — an index is ordered, and
package/src/derive.js ADDED
@@ -0,0 +1,284 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Derived index columns: the one place a declared
4
+ * `indexes[].derive` becomes a value. A spatial member is an array of
5
+ * numbers or an object, and a generated column must be a scalar, so a
6
+ * geohash cell or a bounding-box edge is what actually gets indexed.
7
+ *
8
+ * Every cell and every box comes from `@jarenjs/core/geo`; nothing
9
+ * here computes spatial arithmetic of its own. The same functions
10
+ * serve BOTH physical mappings — registered as deterministic SQL
11
+ * functions inside a virtual generated column's expression where the
12
+ * driver can index them, and called directly on the write path where
13
+ * it cannot — so the two branches cannot drift into different answers.
14
+ *
15
+ * It is also this package's ONLY seam onto `@jarenjs/core/geo` (D1 —
16
+ * one home for spatial arithmetic, grep-proven by test): the planner's
17
+ * probe geometry — the box of a literal or bound region, the box of a
18
+ * bounded-distance circle, a cell's neighbourhood — is computed by the
19
+ * helpers below rather than by an import of its own.
20
+ *
21
+ * Determinism is the contract, not a convenience: a value here is a
22
+ * pure function of the document bytes. Nothing reads the clock, a
23
+ * random source or store state, because an INDEX over a function that
24
+ * did would make the database unreadable from a connection whose
25
+ * function answered differently — and unreadable, not merely wrong:
26
+ * a connection that has not registered the function at all cannot even
27
+ * SELECT the table (probed). That hazard is why the mapping is
28
+ * capability-gated rather than always-on.
29
+ */
30
+
31
+ import {
32
+ bboxOf, centroidOf, circleBounds, geohashEncode, geohashNeighbours,
33
+ } from '@jarenjs/core/geo';
34
+
35
+ import { chain } from './driver.js';
36
+
37
+ /** The closed set of derive kinds. */
38
+ export const DERIVE_KINDS = new Set(['geohash', 'bbox']);
39
+
40
+ /** A bbox index's four columns, in the order they are declared —
41
+ * `[west, south, east, north]`, the order the kernel's boxes carry. */
42
+ export const BBOX_COMPONENTS = Object.freeze(['w', 's', 'e', 'n']);
43
+
44
+ /**
45
+ * The order a bbox index COVERS its four columns, which is not the
46
+ * order they are declared in: an intersection test reads
47
+ * `w <= ? AND e >= ? AND s <= ? AND n >= ?`, so the two longitude
48
+ * bounds sit together at the front of the index where a leading-column
49
+ * range can use them. `(a,b)` and `(b,a)` are different indexes.
50
+ */
51
+ export const BBOX_INDEX_ORDER = Object.freeze(['w', 'e', 's', 'n']);
52
+
53
+ /** Where each component sits in `[west, south, east, north]`. */
54
+ const BBOX_AT = { w: 0, s: 1, e: 2, n: 3 };
55
+
56
+ /** The declared geohash precision range (characters). */
57
+ export const PRECISION_MIN = 1;
58
+ export const PRECISION_MAX = 12;
59
+
60
+ /**
61
+ * The representative position of any GeoJSON value: the position
62
+ * itself for a bare `[lon, lat]` or a Point, the mean vertex
63
+ * otherwise. Null when there is no position, and null when any
64
+ * position is non-finite — `centroidOf` propagates a NaN through the
65
+ * mean, so the finiteness of the result is the whole-value test.
66
+ * @param {any} value
67
+ * @returns {number[] | null}
68
+ */
69
+ function representativePosition(value) {
70
+ const centre = centroidOf(value);
71
+ if (centre === null) return null;
72
+ return Number.isFinite(centre[0]) && Number.isFinite(centre[1]) ? centre : null;
73
+ }
74
+
75
+ /**
76
+ * The geohash cell of a value at a precision, or null when the value
77
+ * carries no bounded position.
78
+ * @param {any} value
79
+ * @param {number} precision
80
+ * @returns {string | null}
81
+ */
82
+ export function deriveGeohash(value, precision) {
83
+ const position = representativePosition(value);
84
+ return position === null ? null : geohashEncode(position[0], position[1], precision);
85
+ }
86
+
87
+ /**
88
+ * One edge of a value's bounding box, or null when it has none —
89
+ * including the D6 case where a non-finite coordinate refuses the box
90
+ * rather than producing one that does not bound its input.
91
+ * @param {any} value
92
+ * @param {string} component - `'w'`, `'s'`, `'e'` or `'n'`
93
+ * @returns {number | null}
94
+ */
95
+ export function deriveBboxEdge(value, component) {
96
+ const box = bboxOf(value);
97
+ return box === null ? null : box[BBOX_AT[component]];
98
+ }
99
+
100
+ /**
101
+ * A member as the database will hold it. A derived value must be a
102
+ * function of the STORED document, not of the object handed to the
103
+ * write: JSON has no `NaN` and no `Infinity`, so a non-finite
104
+ * coordinate becomes `null` on the way in and is no longer a position
105
+ * at all. Computing from the in-memory value would make the two
106
+ * physical mappings answer differently for the same document, which is
107
+ * the one thing they may never do.
108
+ *
109
+ * Only the indexed MEMBER round-trips, not the whole document — it is
110
+ * the only part a derived column reads.
111
+ * @param {any} member
112
+ * @returns {any} the member as stored, or `undefined` when there is none
113
+ */
114
+ export function storedMemberForm(member) {
115
+ if (member === undefined) return undefined;
116
+ const text = JSON.stringify(member);
117
+ return text === undefined ? undefined : JSON.parse(text);
118
+ }
119
+
120
+ /**
121
+ * The value of one derived column for a document member.
122
+ * @param {{ derive: string, precision?: number, component?: string }} column
123
+ * @param {any} member - the value at the index path, or `undefined`
124
+ * @returns {string | number | null}
125
+ */
126
+ export function derivedValue(column, member) {
127
+ if (member === undefined || member === null) return null;
128
+ return column.derive === 'geohash'
129
+ ? deriveGeohash(member, /** @type {number} */ (column.precision))
130
+ : deriveBboxEdge(member, /** @type {string} */ (column.component));
131
+ }
132
+
133
+ /**
134
+ * Read the member one derived column is computed from, walking the
135
+ * same typed segments the physical mapping was planned over.
136
+ * @param {any} doc
137
+ * @param {import('./dialect.js').JsonPathSegment[]} segments
138
+ * @returns {any} the member, or `undefined`
139
+ */
140
+ export function memberAt(doc, segments) {
141
+ let node = doc;
142
+ for (const segment of segments) {
143
+ if (node === null || typeof node !== 'object') return undefined;
144
+ node = 'name' in segment ? node[segment.name] : node[segment.index];
145
+ }
146
+ return node;
147
+ }
148
+
149
+ /**
150
+ * Parse the JSON text a derived column's expression hands the function.
151
+ * SQLite passes SQL NULL for a member the document does not have, and
152
+ * `json()` of an extracted member is unambiguous JSON text otherwise.
153
+ * @param {any} text
154
+ * @returns {any} the value, or `undefined` when there is none
155
+ */
156
+ function parseMember(text) {
157
+ if (typeof text !== 'string') return undefined;
158
+ try {
159
+ return JSON.parse(text);
160
+ }
161
+ catch {
162
+ return undefined;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Register the deterministic functions the virtual generated columns
168
+ * call. Idempotent per connection by SQLite's own semantics (a second
169
+ * registration replaces the first with the identical implementation),
170
+ * and a no-op where the driver cannot index a registered function —
171
+ * that branch stores the columns instead.
172
+ *
173
+ * The four bbox edges share a one-entry memo of the last text they
174
+ * were asked about, because they are called back to back with the same
175
+ * argument for one row and the box costs a full walk of the geometry.
176
+ * The memo is keyed on the argument itself, so it changes no answer.
177
+ * @param {any} connection
178
+ * @returns {any} value-or-promise
179
+ */
180
+ export function registerDeriveFunctions(connection) {
181
+ if (connection.capabilities?.deterministicIndexableFunctions !== true
182
+ || typeof connection.registerFunction !== 'function') return null;
183
+ /** @type {any} */
184
+ let memoText = null;
185
+ /** @type {number[] | null} */
186
+ let memoBox = null;
187
+ const boxOf = (/** @type {any} */ text) => {
188
+ if (text === memoText) return memoBox;
189
+ const value = parseMember(text);
190
+ memoBox = value === undefined ? null : bboxOf(value);
191
+ memoText = text;
192
+ return memoBox;
193
+ };
194
+ /** @type {[string, Function][]} */
195
+ const registrations = [
196
+ ['jaren_geohash', (/** @type {any} */ text, /** @type {any} */ precision) => {
197
+ const value = parseMember(text);
198
+ return value === undefined ? null : deriveGeohash(value, Number(precision));
199
+ }],
200
+ ];
201
+ for (const component of BBOX_COMPONENTS) {
202
+ registrations.push([`jaren_bbox_${component}`, (/** @type {any} */ text) => {
203
+ const box = boxOf(text);
204
+ return box === null ? null : box[BBOX_AT[component]];
205
+ }]);
206
+ }
207
+ const step = (i) => (i >= registrations.length
208
+ ? null
209
+ : chain(connection.registerFunction(registrations[i][0],
210
+ { deterministic: true }, registrations[i][1]), () => step(i + 1)));
211
+ return step(0);
212
+ }
213
+
214
+ // ————— Probe geometry: what a PUSHED predicate compares against —————
215
+ //
216
+ // A promoted spatial predicate narrows through the derived columns by
217
+ // comparing them with a box (or a cell) computed from the query's own
218
+ // operand. That operand is known either at plan time (a literal region)
219
+ // or at bind time (an external one), and both go through here so the
220
+ // two answer identically and neither reaches past this file for its
221
+ // arithmetic.
222
+
223
+ /**
224
+ * The bounding box of a probe value, or `null` when it has none — the
225
+ * D6 refusal included, which is what makes an unbounded probe divert to
226
+ * the full scan instead of narrowing with a box that does not bound it.
227
+ * @param {any} value - a GeoJSON value or a `[lon, lat]` position
228
+ * @returns {number[] | null} `[west, south, east, north]`
229
+ */
230
+ export function probeBox(value) {
231
+ return bboxOf(value);
232
+ }
233
+
234
+ /**
235
+ * The representative position §8.14 measures a probe value by — the
236
+ * same rule the derived columns use, so the pushed filter and the
237
+ * engine cannot disagree about where a value IS. `null` when it has no
238
+ * bounded position.
239
+ * @param {any} value
240
+ * @returns {number[] | null}
241
+ */
242
+ export function probePosition(value) {
243
+ return representativePosition(value);
244
+ }
245
+
246
+ /**
247
+ * The bounding box of the circle of `metres` around a position — the
248
+ * box a `$distance <= r` predicate narrows with, on the same sphere and
249
+ * the same radius constant the engine measures with. `null` when the
250
+ * radius is not a finite non-negative number, or when the circle
251
+ * reaches a pole, where there is no longitude bound to give.
252
+ * @param {number[]} position
253
+ * @param {number} metres
254
+ * @returns {number[] | null} `[west, south, east, north]`, NOT wrapped
255
+ * into `[-180, 180]`: a circle spanning the antimeridian answers a
256
+ * west below -180, which is how the planner detects it
257
+ */
258
+ export function probeCircleBox(position, metres) {
259
+ return circleBounds(position[0], position[1], metres);
260
+ }
261
+
262
+ /**
263
+ * A cell and its neighbours, the nine-cell probe D7 requires — a single
264
+ * prefix is bucketing, never proximity.
265
+ * @param {string} cell
266
+ * @returns {string[]} up to nine cells (fewer past a pole)
267
+ */
268
+ export function cellNeighbourhood(cell) {
269
+ return geohashNeighbours(cell);
270
+ }
271
+
272
+ /**
273
+ * The value one DERIVED parameter slot binds: an axis of a bound
274
+ * external's bounding box, computed at bind time because a GeoJSON
275
+ * object is not a value any database can bind. `null` when the value
276
+ * has no box, which is what tells the caller to divert.
277
+ * @param {{ kind: string, external: string, axis: string }} derived
278
+ * @param {any} value - the bound external
279
+ * @returns {number | null}
280
+ */
281
+ export function derivedSlotValue(derived, value) {
282
+ const box = bboxOf(value);
283
+ return box === null ? null : box[BBOX_AT[derived.axis]];
284
+ }