@jarenjs/db 0.34.2 → 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/src/dialect.js CHANGED
@@ -42,6 +42,8 @@
42
42
  * limitClause: (limit: number, offset?: number) => string,
43
43
  * jsonPathText: (segments: JsonPathSegment[]) => string | null,
44
44
  * jsonExtract: (columnSql: string, pathText: string) => string,
45
+ * derivedExpression?: (memberSql: string, column: { derive: string,
46
+ * precision?: number, component?: string }) => string,
45
47
  * jsonSet: (exprSql: string, pathText: string, valueSql: string) => string,
46
48
  * jsonRemove: (exprSql: string, pathText: string) => string,
47
49
  * jsonAppend: (exprSql: string, arrayPathText: string, valueSql: string) => string,
@@ -50,7 +52,8 @@
50
52
  * jsonAgg: (exprSql: string) => string,
51
53
  * jsonTypeOf: (columnSql: string, pathText: string) => string,
52
54
  * valueTypeOf: (paramSql: string) => string,
53
- * strStartsWith: (valueSql: string, patternA: string, patternB: string) => string,
55
+ * strStartsWith: (valueSql: string, lowerParamSql: string, upperParamSql: string) => string,
56
+ * strStartsWithExact: (valueSql: string, patternA: string, patternB: string) => string,
54
57
  * strEndsWith: (valueSql: string, patternA: string, patternB: string, patternC: string) => string,
55
58
  * strContains: (valueSql: string, patternSql: string) => string,
56
59
  * orderNulls: (nullsFirst: boolean) => string,
@@ -77,6 +80,24 @@ export function createDialect(spec) {
77
80
  const q = spec.quoteIdentifier;
78
81
  const p = spec.parameterRef;
79
82
 
83
+ /**
84
+ * One planned column's definition. A path column is a VIRTUAL
85
+ * generated column over the document; a DERIVED column is the same
86
+ * shape over a registered deterministic function, except where the
87
+ * driver cannot index one — there the store writes the value and the
88
+ * column is an ordinary one.
89
+ * @param {string} docColumn
90
+ * @param {{ name: string, type: string, pathText: string,
91
+ * expression?: string | null, stored?: boolean }} column
92
+ * @returns {string}
93
+ */
94
+ const generatedColumnSql = (docColumn, column) => {
95
+ if (column.stored === true) return `${q(column.name)} ${column.type}`;
96
+ const expression = column.expression
97
+ ?? spec.jsonExtract(q(docColumn), column.pathText);
98
+ return `${q(column.name)} ${column.type} GENERATED ALWAYS AS (${expression}) VIRTUAL`;
99
+ };
100
+
80
101
  const ddl = Object.freeze({
81
102
  /**
82
103
  * One collection's physical table: a key column, the JSON document
@@ -90,9 +111,7 @@ export function createDialect(spec) {
90
111
  const columns = [
91
112
  `${q(keyColumn)} ${keyType} PRIMARY KEY`,
92
113
  `${q(docColumn)} ${spec.docColumnType} NOT NULL`,
93
- ...generated.map((g) =>
94
- `${q(g.name)} ${g.type} GENERATED ALWAYS AS `
95
- + `(${spec.jsonExtract(q(docColumn), g.pathText)}) VIRTUAL`),
114
+ ...generated.map((g) => generatedColumnSql(docColumn, g)),
96
115
  ];
97
116
  return `CREATE TABLE ${q(table)} (${columns.join(', ')})${spec.tableSuffix}`;
98
117
  },
@@ -113,8 +132,7 @@ export function createDialect(spec) {
113
132
  * @returns {string}
114
133
  */
115
134
  addGeneratedColumn({ table, docColumn, column }) {
116
- return `ALTER TABLE ${q(table)} ADD COLUMN ${q(column.name)} ${column.type} `
117
- + `GENERATED ALWAYS AS (${spec.jsonExtract(q(docColumn), column.pathText)}) VIRTUAL`;
135
+ return `ALTER TABLE ${q(table)} ADD COLUMN ${generatedColumnSql(docColumn, column)}`;
118
136
  },
119
137
  /**
120
138
  * @param {string} table
@@ -217,26 +235,47 @@ export function createDialect(spec) {
217
235
  });
218
236
 
219
237
  const dml = Object.freeze({
220
- /** @param {{ table: string, keyColumn: string, docColumn: string }} s */
221
- insert({ table, keyColumn, docColumn }) {
222
- return `INSERT INTO ${q(table)} (${q(keyColumn)}, ${q(docColumn)}) `
223
- + `VALUES (${p(1, 'key')}, ${spec.jsonEncode(p(2, 'doc'))})`;
238
+ /**
239
+ * A collection insert. `stored` names the derived columns the
240
+ * store computes itself empty on every driver that can index a
241
+ * registered function, because there the column generates itself.
242
+ * @param {{ table: string, keyColumn: string, docColumn: string,
243
+ * stored?: string[] }} s
244
+ */
245
+ insert({ table, keyColumn, docColumn, stored }) {
246
+ const extra = stored ?? [];
247
+ const names = [q(keyColumn), q(docColumn), ...extra.map(q)];
248
+ const values = [p(1, 'key'), spec.jsonEncode(p(2, 'doc')),
249
+ ...extra.map((name, i) => p(i + 3, name))];
250
+ return `INSERT INTO ${q(table)} (${names.join(', ')}) VALUES (${values.join(', ')})`;
224
251
  },
225
252
  /**
226
253
  * Insert with a database-allocated key, read back in the same
227
254
  * statement.
228
- * @param {{ table: string, keyColumn: string, docColumn: string }} s
255
+ * @param {{ table: string, keyColumn: string, docColumn: string,
256
+ * stored?: string[] }} s
229
257
  */
230
- insertAllocated({ table, keyColumn, docColumn }) {
231
- return `INSERT INTO ${q(table)} (${q(docColumn)}) `
232
- + `VALUES (${spec.jsonEncode(p(1, 'doc'))}) RETURNING ${q(keyColumn)} AS ${q('key')}`;
258
+ insertAllocated({ table, keyColumn, docColumn, stored }) {
259
+ const extra = stored ?? [];
260
+ const names = [q(docColumn), ...extra.map(q)];
261
+ const values = [spec.jsonEncode(p(1, 'doc')),
262
+ ...extra.map((name, i) => p(i + 2, name))];
263
+ return `INSERT INTO ${q(table)} (${names.join(', ')}) VALUES (${values.join(', ')}) `
264
+ + `RETURNING ${q(keyColumn)} AS ${q('key')}`;
233
265
  },
234
- /** @param {{ table: string, keyColumn: string, docColumn: string }} s */
235
- upsert({ table, keyColumn, docColumn }) {
236
- return `INSERT INTO ${q(table)} (${q(keyColumn)}, ${q(docColumn)}) `
237
- + `VALUES (${p(1, 'key')}, ${spec.jsonEncode(p(2, 'doc'))}) `
238
- + `ON CONFLICT (${q(keyColumn)}) DO UPDATE SET `
239
- + `${q(docColumn)} = ${spec.excludedRef(q(docColumn))}`;
266
+ /**
267
+ * @param {{ table: string, keyColumn: string, docColumn: string,
268
+ * stored?: string[] }} s
269
+ */
270
+ upsert({ table, keyColumn, docColumn, stored }) {
271
+ const extra = stored ?? [];
272
+ const names = [q(keyColumn), q(docColumn), ...extra.map(q)];
273
+ const values = [p(1, 'key'), spec.jsonEncode(p(2, 'doc')),
274
+ ...extra.map((name, i) => p(i + 3, name))];
275
+ const assignments = [q(docColumn), ...extra.map(q)]
276
+ .map((column) => `${column} = ${spec.excludedRef(column)}`);
277
+ return `INSERT INTO ${q(table)} (${names.join(', ')}) VALUES (${values.join(', ')}) `
278
+ + `ON CONFLICT (${q(keyColumn)}) DO UPDATE SET ${assignments.join(', ')}`;
240
279
  },
241
280
  /** @param {{ table: string, keyColumn: string, docColumn: string }} s */
242
281
  get({ table, keyColumn, docColumn }) {
@@ -250,13 +289,26 @@ export function createDialect(spec) {
250
289
  /**
251
290
  * Rewrite the document column through a JSON-set expression chain
252
291
  * (the translated-patch path) or a bound parameter (the fallback).
253
- * @param {{ table: string, keyColumn: string, docColumn: string }} s
292
+ * Stored derived columns are rewritten with it they are computed
293
+ * FROM the document, so leaving them behind would let a query read a
294
+ * value the document no longer carries.
295
+ *
296
+ * `nextParam` is the first FREE parameter slot after the
297
+ * expression's own: the derived values take it and the ones after
298
+ * it, and the key binds LAST. That order is the statement's TEXT
299
+ * order, which is what a positional dialect numbers by — and with
300
+ * no derived columns it degenerates to the key at `nextParam`.
301
+ * @param {{ table: string, keyColumn: string, docColumn: string,
302
+ * stored?: string[] }} s
254
303
  * @param {string} expression - SQL over the document column
255
- * @param {number} keyIndex - 1-based position of the key parameter
304
+ * @param {number} nextParam - 1-based first free parameter slot
256
305
  */
257
- updateDoc({ table, keyColumn, docColumn }, expression, keyIndex) {
258
- return `UPDATE ${q(table)} SET ${q(docColumn)} = ${expression} `
259
- + `WHERE ${q(keyColumn)} = ${p(keyIndex, 'key')}`;
306
+ updateDoc({ table, keyColumn, docColumn, stored }, expression, nextParam) {
307
+ const extra = stored ?? [];
308
+ const assignments = [`${q(docColumn)} = ${expression}`,
309
+ ...extra.map((name, i) => `${q(name)} = ${p(nextParam + i, name)}`)];
310
+ return `UPDATE ${q(table)} SET ${assignments.join(', ')} `
311
+ + `WHERE ${q(keyColumn)} = ${p(nextParam + extra.length, 'key')}`;
260
312
  },
261
313
  });
262
314
 
@@ -272,6 +324,17 @@ export function createDialect(spec) {
272
324
  limitClause: spec.limitClause,
273
325
  jsonPathText: spec.jsonPathText,
274
326
  jsonExtract: spec.jsonExtract,
327
+ /**
328
+ * The expression a DERIVED column is generated from: the member at
329
+ * the index path, as JSON text, handed to the deterministic
330
+ * function that computes the cell or the box edge.
331
+ * @param {string} docColumnSql
332
+ * @param {string} pathText
333
+ * @param {{ derive: string, precision?: number, component?: string }} column
334
+ * @returns {string}
335
+ */
336
+ derivedColumn: (docColumnSql, pathText, column) => spec.derivedExpression(
337
+ spec.jsonText(spec.jsonExtract(docColumnSql, pathText)), column),
275
338
  jsonSet: spec.jsonSet,
276
339
  jsonRemove: spec.jsonRemove,
277
340
  jsonAppend: spec.jsonAppend,
@@ -281,6 +344,7 @@ export function createDialect(spec) {
281
344
  jsonTypeOf: spec.jsonTypeOf,
282
345
  valueTypeOf: spec.valueTypeOf,
283
346
  strStartsWith: spec.strStartsWith,
347
+ strStartsWithExact: spec.strStartsWithExact,
284
348
  strEndsWith: spec.strEndsWith,
285
349
  strContains: spec.strContains,
286
350
  orderNulls: spec.orderNulls,
@@ -101,6 +101,14 @@ export const sqliteDialect = createDialect({
101
101
  jsonPathText,
102
102
  jsonExtract: (columnSql, pathText) =>
103
103
  `jsonb_extract(${columnSql}, ${stringLiteral(pathText)})`,
104
+ // a DERIVED column's expression: the member as JSON text handed to
105
+ // the deterministic function the store registers at open. The
106
+ // precision is a LITERAL, not a parameter — a generated column's
107
+ // expression takes none — which is also what makes two precisions
108
+ // over one path two different columns by declared text.
109
+ derivedExpression: (memberSql, column) => (column.derive === 'geohash'
110
+ ? `jaren_geohash(${memberSql}, ${Math.trunc(Number(column.precision))})`
111
+ : `jaren_bbox_${column.component}(${memberSql})`),
104
112
  jsonSet: (exprSql, pathText, valueSql) =>
105
113
  `jsonb_set(${exprSql}, ${stringLiteral(pathText)}, ${valueSql})`,
106
114
  jsonRemove: (exprSql, pathText) =>
@@ -113,7 +121,14 @@ export const sqliteDialect = createDialect({
113
121
  jsonTypeOf: (columnSql, pathText) =>
114
122
  `json_type(${columnSql}, ${stringLiteral(pathText)})`,
115
123
  valueTypeOf: (paramSql) => `typeof(${paramSql})`,
116
- strStartsWith: (valueSql, patternA, patternB) =>
124
+ // the half-open range over the prefix, which an index on the value
125
+ // can seek; `substr(value, 1, n) = p` and `value LIKE 'p%'` both read
126
+ // every row. SQLite's default BINARY collation compares UTF-8 bytes,
127
+ // which orders code points, so the range holds exactly the values
128
+ // that begin with the prefix
129
+ strStartsWith: (valueSql, lowerParamSql, upperParamSql) =>
130
+ `(${valueSql} >= ${lowerParamSql} AND ${valueSql} < ${upperParamSql})`,
131
+ strStartsWithExact: (valueSql, patternA, patternB) =>
117
132
  `substr(${valueSql}, 1, length(${patternA})) = ${patternB}`,
118
133
  strEndsWith: (valueSql, patternA, patternB, patternC) =>
119
134
  `(length(${patternA}) = 0 OR substr(${valueSql}, -length(${patternB})) = ${patternC})`,
package/src/driver.js CHANGED
@@ -30,6 +30,8 @@
30
30
  * silent degradation this suite refuses.
31
31
  */
32
32
 
33
+ import { isThenable, chain, toPromise } from '@jarenjs/core/function';
34
+
33
35
  import { DbCompileError } from './errors.js';
34
36
 
35
37
  /** The minimum SQLite the store accepts, asserted at open. */
@@ -44,34 +46,10 @@ export const SQLITE_FLOOR = '3.45.0';
44
46
  */
45
47
  export const DEFAULT_QUEUE_TIMEOUT = 5000;
46
48
 
47
- /**
48
- * @param {any} value
49
- * @returns {boolean} true when the value is a thenable
50
- */
51
- export function isThenable(value) {
52
- return value !== null && typeof value === 'object' && typeof value.then === 'function';
53
- }
54
-
55
- /**
56
- * Sync-capable-async composition: apply `next` to a driver result
57
- * without allocating a promise when the result is already a value.
58
- * @param {any} value - A driver return: a value or a promise
59
- * @param {(value: any) => any} next
60
- * @returns {any} `next`'s result, promise-wrapped only if the input was
61
- */
62
- export function chain(value, next) {
63
- return isThenable(value) ? value.then(next) : next(value);
64
- }
65
-
66
- /**
67
- * Lift a driver result into a promise — the ONE allocation the public
68
- * asynchronous surface pays per call.
69
- * @param {any} value
70
- * @returns {Promise<any>}
71
- */
72
- export function toPromise(value) {
73
- return isThenable(value) ? value : Promise.resolve(value);
74
- }
49
+ // the sync-capable-async helpers are `@jarenjs/core/function`'s (one
50
+ // implementation in the suite); re-exported here because every store
51
+ // module and the public `@jarenjs/db` surface reach them through this seam
52
+ export { isThenable, chain, toPromise };
75
53
 
76
54
  /**
77
55
  * Compare two dotted version strings numerically.
package/src/emit.js CHANGED
@@ -16,10 +16,84 @@
16
16
  * logic never decides a row.
17
17
  */
18
18
 
19
+ import { codePointPrefixSuccessor } from '@jarenjs/core/string';
20
+
19
21
  /**
20
- * @typedef {{ external: string } | { literal: unknown }} ParamSlot
22
+ * @typedef {{ external: string } | { literal: unknown } |
23
+ * { derived: { kind: 'bboxAxis', external: string,
24
+ * axis: 'w' | 's' | 'e' | 'n' } }} ParamSlot
25
+ * Three kinds, closed. A DERIVED slot is the escape for a value SQL
26
+ * cannot bind at all: a GeoJSON region arrives as an external object,
27
+ * and what the statement needs is one edge of its bounding box, so
28
+ * the binder computes that edge from the bound value. It is the same
29
+ * shape the prefix successor uses — compute in JavaScript what SQL
30
+ * cannot, bind an ordinary parameter — and it stays closed on
31
+ * purpose: a general expression slot would be a second query language
32
+ * living in the emitter.
21
33
  */
22
34
 
35
+ /**
36
+ * The SQL for one string predicate. The prefix case is the only one
37
+ * with an index-usable spelling: a function of the value (`substr`) or
38
+ * a pattern match (`LIKE`) can only be scanned, while the half-open
39
+ * range `value >= p AND value < successor(p)` is a seek, and holds
40
+ * exactly the values beginning with `p` under a code-point ordering.
41
+ * The planner reaches here only with a non-empty literal pattern, so
42
+ * the successor is computable at emit time and needs no slot kind of
43
+ * its own; the one pattern that has no successor keeps the scannable
44
+ * form, which is correct, merely slow.
45
+ * @param {any} dialect
46
+ * @param {(slot: ParamSlot) => string} param
47
+ * @param {string} valueSql
48
+ * @param {any} pred
49
+ * @returns {string}
50
+ */
51
+ function stropForm(dialect, param, valueSql, pred) {
52
+ const pattern = pred.operand.lit;
53
+ const bind = () => param({ literal: pattern });
54
+ if (pred.kind === 'ends')
55
+ return dialect.strEndsWith(valueSql, bind(), bind(), bind());
56
+ if (pred.kind === 'contains')
57
+ return dialect.strContains(valueSql, bind());
58
+ const upper = codePointPrefixSuccessor(pattern);
59
+ return upper === null
60
+ ? dialect.strStartsWithExact(valueSql, bind(), bind())
61
+ : dialect.strStartsWith(valueSql, bind(), param({ literal: upper }));
62
+ }
63
+
64
+ /**
65
+ * The name a dialect uses for one slot's parameter reference.
66
+ * @param {ParamSlot} slot
67
+ * @returns {string}
68
+ */
69
+ function slotName(slot) {
70
+ if ('external' in slot) return slot.external;
71
+ if ('derived' in slot) return slot.derived.external;
72
+ return 'value';
73
+ }
74
+
75
+ /** Where each edge sits in a `[west, south, east, north]` box. */
76
+ const BOX_AT = { w: 0, s: 1, e: 2, n: 3 };
77
+
78
+ /**
79
+ * Bind one edge of the probe box: a plan-time literal box binds its own
80
+ * number, an external one binds a derived slot the binder computes from
81
+ * the value at call time.
82
+ *
83
+ * A positional dialect numbers parameters by the statement's TEXT
84
+ * order, so this is called in the order the placeholders appear and
85
+ * never in the order the box carries its edges.
86
+ * @param {any} probe
87
+ * @param {(slot: ParamSlot) => string} param
88
+ * @param {'w' | 's' | 'e' | 'n'} axis
89
+ * @returns {string}
90
+ */
91
+ function probeEdge(probe, param, axis) {
92
+ return 'box' in probe
93
+ ? param({ literal: probe.box[BOX_AT[axis]] })
94
+ : param({ derived: { kind: 'bboxAxis', external: probe.ext, axis } });
95
+ }
96
+
23
97
  /**
24
98
  * Emit one plan as SQL plus its ordered parameter slots.
25
99
  * @param {import('./algebra.js').Plan} plan
@@ -34,7 +108,7 @@ export function emitPlan(plan, dialect, physical) {
34
108
  const slots = [];
35
109
  const param = (slot) => {
36
110
  slots.push(slot);
37
- return dialect.parameterRef(slots.length, 'external' in slot ? slot.external : 'value');
111
+ return dialect.parameterRef(slots.length, slotName(slot));
38
112
  };
39
113
 
40
114
  /** SQL for a ref's VALUE: the generated column when one exists. */
@@ -132,15 +206,51 @@ export function emitPlan(plan, dialect, physical) {
132
206
  return `${pred.name}(${dialect.jsonText(docColumn)})`;
133
207
  case 'strop': {
134
208
  const jt = typeOf(pred.ref);
135
- const value = valueOf(pred.ref);
136
- const bind = () => param({ literal: /** @type {any} */ (pred.operand).lit });
137
- const form = pred.kind === 'starts'
138
- ? dialect.strStartsWith(value, bind(), bind())
139
- : pred.kind === 'ends'
140
- ? dialect.strEndsWith(value, bind(), bind(), bind())
141
- : dialect.strContains(value, bind());
209
+ const form = stropForm(dialect, param, valueOf(pred.ref), pred);
142
210
  return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
143
211
  }
212
+ case 'bboxOverlap': {
213
+ // Two boxes meet when neither is wholly past the other, and
214
+ // TOUCHING counts (`bboxIntersects`), so these are `<=`/`>=`:
215
+ // a strict comparison would disagree with the engine on every
216
+ // shared edge. Emitted in the index's covered column order —
217
+ // (w, e, s, n) — so the leading longitude bound sits in front.
218
+ // The leading `IS NOT NULL` keeps the form TOTAL — a row with no
219
+ // box answers FALSE, not NULL, so a negation over an exact box
220
+ // test still composes classically — and it is also what makes
221
+ // the term SEEKABLE: it bounds the leading column from below,
222
+ // and a one-sided range alone loses to a table scan in SQLite's
223
+ // cost model, which prices a virtual generated column as a free
224
+ // column read when it is a host function call per row.
225
+ const c = pred.columns;
226
+ const edge = (axis) => probeEdge(pred.probe, param, axis);
227
+ return `(${q(c.w)} IS NOT NULL AND ${q(c.w)} <= ${edge('e')}`
228
+ + ` AND ${q(c.e)} >= ${edge('w')}`
229
+ + ` AND ${q(c.s)} <= ${edge('n')} AND ${q(c.n)} >= ${edge('s')})`;
230
+ }
231
+ case 'cellIn': {
232
+ // The cells are whole values of the column, so this is an
233
+ // equality set — and `IN` is the spelling that keeps it one:
234
+ // nine OR-ed ranges defeat SQLite's multi-index OR optimization
235
+ // (it gives up past a handful of terms) and fall back to a scan,
236
+ // which for a virtual generated column is a host function call
237
+ // per row.
238
+ const column = q(pred.column);
239
+ const list = pred.cells.map((cell) => param({ literal: cell })).join(', ');
240
+ return `(${column} IS NOT NULL AND ${column} IN (${list}))`;
241
+ }
242
+ case 'cellPrefix': {
243
+ // A cell SHORTER than the column's own: the half-open range an
244
+ // index seeks. Every cell is base-32, so the code-point
245
+ // successor always exists.
246
+ const column = q(pred.column);
247
+ const upper = codePointPrefixSuccessor(pred.prefix);
248
+ if (upper === null)
249
+ throw new Error('emit: a geohash cell has no code-point successor');
250
+ const range = dialect.strStartsWith(column, param({ literal: pred.prefix }),
251
+ param({ literal: upper }));
252
+ return `(${column} IS NOT NULL AND ${range})`;
253
+ }
144
254
  default:
145
255
  throw new Error(`emit: unknown predicate node '${/** @type {any} */ (pred).p}'`);
146
256
  }
@@ -209,12 +319,7 @@ export function createEntityPredicateEmitters(dialect, param) {
209
319
  : `(${jt} IS NOT NULL AND ${jt} NOT IN (${list}))`;
210
320
  }
211
321
  if (pred.p === 'strop') {
212
- const bind = () => param({ literal: pred.operand.lit });
213
- const form = pred.kind === 'starts'
214
- ? dialect.strStartsWith(value, bind(), bind())
215
- : pred.kind === 'ends'
216
- ? dialect.strEndsWith(value, bind(), bind(), bind())
217
- : dialect.strContains(value, bind());
322
+ const form = stropForm(dialect, param, value, pred);
218
323
  return `(${jt} IS NOT NULL AND ${jt} = ${sl('text')} AND ${form})`;
219
324
  }
220
325
  const lit = pred.operand.lit;
@@ -243,12 +348,7 @@ export function createEntityPredicateEmitters(dialect, param) {
243
348
  : `(${column} IS NOT NULL AND ${column} <> ${param({ literal: wanted })})`;
244
349
  }
245
350
  if (pred.p === 'strop') {
246
- const bind = () => param({ literal: pred.operand.lit });
247
- const form = pred.kind === 'starts'
248
- ? dialect.strStartsWith(column, bind(), bind())
249
- : pred.kind === 'ends'
250
- ? dialect.strEndsWith(column, bind(), bind(), bind())
251
- : dialect.strContains(column, bind());
351
+ const form = stropForm(dialect, param, column, pred);
252
352
  return `(${column} IS NOT NULL AND ${form})`;
253
353
  }
254
354
  const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
@@ -330,7 +430,7 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
330
430
  const slots = [];
331
431
  const param = (slot) => {
332
432
  slots.push(slot);
333
- return dialect.parameterRef(slots.length, 'external' in slot ? slot.external : 'value');
433
+ return dialect.parameterRef(slots.length, slotName(slot));
334
434
  };
335
435
 
336
436
  const aliases = new Map(plan.bindings.map((binding, i) => [
package/src/errors.js CHANGED
@@ -22,7 +22,7 @@ export const DB_CODES = Object.freeze({
22
22
  JD0001: 'the SQLite library is below the supported floor',
23
23
  JD0002: 'the declared model disagrees with the existing database',
24
24
  JD0003: 'the driver binding is unavailable on this runtime',
25
- JD0004: 'an index path is not a singular member selection',
25
+ JD0004: 'a declared index cannot be mapped to a column',
26
26
  JD0005: 'the model document is invalid',
27
27
  JD0010: 'strict mode refused a residual',
28
28
  JD0011: 'the profile refused the document',
@@ -66,9 +66,11 @@ export const DB_CODES = Object.freeze({
66
66
  * - `JD0003` — the runtime builtin behind a driver could not be
67
67
  * loaded here (Node cannot resolve `bun:`; Bun ships no
68
68
  * `node:sqlite`), or an injected handle is missing
69
- * - `JD0004` — an index path does not select exactly one member
70
- * (wildcards, slices, filters and descendants are not indexable);
71
- * the reason names the expression
69
+ * - `JD0004` — a declared index cannot be mapped to a column: its
70
+ * path does not select exactly one member (wildcards, slices,
71
+ * filters and descendants are not indexable), or its `derive`
72
+ * declaration is not one the storage vocabulary carries; the reason
73
+ * names the expression or the member and `docPath` points at it
72
74
  * - `JD0005` — the model document is invalid; `docPath` points at
73
75
  * the offending member
74
76
  * - `JD0010` — `strict: true` and part of the query would have run
package/src/index.js CHANGED
@@ -29,6 +29,11 @@ export { selectPlan, conjoin, assertNoSqlText, PLAN_VERSION } from './algebra.js
29
29
  export { typeOfPath, isNumericType } from './types.js';
30
30
  export { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
31
31
  export { deterministicFragment, registerFragment } from './udf.js';
32
+ export {
33
+ DERIVE_KINDS, BBOX_COMPONENTS, BBOX_INDEX_ORDER, PRECISION_MIN, PRECISION_MAX,
34
+ deriveGeohash, deriveBboxEdge, derivedValue, memberAt, storedMemberForm,
35
+ registerDeriveFunctions,
36
+ } from './derive.js';
32
37
  export {
33
38
  createQueryEngine, createQueryState, createEntityQueryEngine,
34
39
  createLoadEngine, INCLUDE_DEPTH_DEFAULT,