@jarenjs/db 0.34.2 → 0.43.3

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/query.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  import { emitPlan, emitEntityPlan, createEntityPredicateEmitters } from './emit.js';
32
32
  import { selectPlan } from './algebra.js';
33
33
  import { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
34
+ import { derivedSlotValue, probeBox } from './derive.js';
34
35
  import { deterministicFragment, registerFragment } from './udf.js';
35
36
  import {
36
37
  normalizeProfile, translateProfilePredicate,
@@ -63,6 +64,43 @@ function bindable(value) {
63
64
  || (typeof value === 'number' && Number.isFinite(value));
64
65
  }
65
66
 
67
+ /**
68
+ * The value one parameter slot binds for a call. A DERIVED slot holds
69
+ * no value of its own: it names one edge of a bound external's
70
+ * bounding box, computed here because a GeoJSON object is not
71
+ * something any database binds.
72
+ * @param {import('./emit.js').ParamSlot} slot
73
+ * @param {any} externals
74
+ * @returns {any}
75
+ */
76
+ function slotValue(slot, externals) {
77
+ if ('literal' in slot) return slot.literal;
78
+ if ('derived' in slot)
79
+ return derivedSlotValue(slot.derived, externals[slot.derived.external]);
80
+ return externals[slot.external];
81
+ }
82
+
83
+ /**
84
+ * How one external reaches the statement. An external reached ONLY
85
+ * through derived slots is bindable when its bound value HAS a box —
86
+ * the object itself never had to be bindable. One reached directly
87
+ * must be a string or a finite number, as before; and an external in
88
+ * the document that reaches no slot at all still forces the diversion,
89
+ * because the residual needs the engine's own semantics for it.
90
+ * @param {import('./emit.js').ParamSlot[]} slots
91
+ * @returns {Map<string, 'plain' | 'derived'>}
92
+ */
93
+ function externalSlotKinds(slots) {
94
+ /** @type {Map<string, 'plain' | 'derived'>} */
95
+ const kinds = new Map();
96
+ for (const slot of slots) {
97
+ if ('external' in slot) kinds.set(slot.external, 'plain');
98
+ else if ('derived' in slot && !kinds.has(slot.derived.external))
99
+ kinds.set(slot.derived.external, 'derived');
100
+ }
101
+ return kinds;
102
+ }
103
+
66
104
  /**
67
105
  * The query engine for one collection.
68
106
  * @param {{ connection: any, state: any, collection: any,
@@ -109,9 +147,9 @@ export function createQueryEngine(context) {
109
147
  };
110
148
 
111
149
  const udfHook = connection.capabilities.userFunctions
112
- ? (fragment) => {
150
+ ? (/** @type {any} */ fragment, /** @type {string} */ binding) => {
113
151
  // Ring 3: admit the registry's pushable:'scalar' operators too
114
- const qualified = deterministicFragment(fragment, operators);
152
+ const qualified = deterministicFragment(fragment, operators, binding);
115
153
  if (qualified === null) return null;
116
154
  // the store owns the final name: a fingerprint clash between two
117
155
  // distinct fragments is disambiguated at registration
@@ -165,6 +203,7 @@ export function createQueryEngine(context) {
165
203
  reasons: [{ construct: 'pushdown', reason: 'disabled by the harness switch' }],
166
204
  rowReturn: null,
167
205
  udfs: [],
206
+ prefilters: [],
168
207
  };
169
208
  }
170
209
 
@@ -213,6 +252,7 @@ export function createQueryEngine(context) {
213
252
  sql: emitted.sql,
214
253
  slots: emitted.slots,
215
254
  externalNames,
255
+ externalSlotKinds: externalSlotKinds(emitted.slots),
216
256
  dependencies: planned.analysis.dependencies,
217
257
  limits: planned.analysis.limits,
218
258
  residualLimits: limits,
@@ -305,11 +345,13 @@ export function createQueryEngine(context) {
305
345
 
306
346
  /** Bind slots against the call's externals. */
307
347
  const bindParams = (entry, externals) =>
308
- entry.slots.map((slot) => ('literal' in slot ? slot.literal : externals[slot.external]));
348
+ entry.slots.map((slot) => slotValue(slot, externals));
309
349
 
310
350
  /** Must this call divert to the residual? */
311
351
  const mustDivert = (entry, externals) =>
312
- entry.externalNames.some((name) => !bindable(externals[name]));
352
+ entry.externalNames.some((name) => (entry.externalSlotKinds.get(name) === 'derived'
353
+ ? probeBox(externals[name]) === null
354
+ : !bindable(externals[name])));
313
355
 
314
356
  const rowsToDocs = (rows) => rows.map((row) => JSON.parse(row.doc));
315
357
 
@@ -499,6 +541,10 @@ export function createQueryEngine(context) {
499
541
  if (pred === null) return;
500
542
  if (pred.p === 'and' || pred.p === 'or') pred.items.forEach(collectColumns);
501
543
  else if (pred.p === 'not') collectColumns(pred.item);
544
+ else if (pred.p === 'bboxOverlap')
545
+ for (const column of Object.values(pred.columns)) touchedColumns.add(column);
546
+ else if (pred.p === 'cellIn' || pred.p === 'cellPrefix')
547
+ touchedColumns.add(pred.column);
502
548
  else if ('ref' in pred && pred.ref?.column) touchedColumns.add(pred.ref.column);
503
549
  };
504
550
  collectColumns(entry.plan.filter);
@@ -510,11 +556,15 @@ export function createQueryEngine(context) {
510
556
  .filter((index) => index.columns.some((column) => touchedColumns.has(column)))
511
557
  .map((index) => index.name);
512
558
 
513
- const params = entry.slots.map((slot) =>
514
- ('external' in slot ? { external: slot.external } : { literal: slot.literal }));
515
- const eqpParams = entry.slots.map((slot) => ('literal' in slot
516
- ? slot.literal
517
- : bindable(externals[slot.external]) ? externals[slot.external] : null));
559
+ const params = entry.slots.map((slot) => {
560
+ if ('external' in slot) return { external: slot.external };
561
+ if ('derived' in slot) return { derived: { ...slot.derived } };
562
+ return { literal: slot.literal };
563
+ });
564
+ const eqpParams = entry.slots.map((slot) => {
565
+ const value = slotValue(slot, externals);
566
+ return bindable(value) ? value : null;
567
+ });
518
568
 
519
569
  return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
520
570
  chain(statement.all(eqpParams), (rows) => ({
@@ -526,6 +576,8 @@ export function createQueryEngine(context) {
526
576
  sql: entry.sql,
527
577
  params,
528
578
  indexes,
579
+ prefilters: entry.planned.prefilters.map((prefilter) => ({ ...prefilter,
580
+ columns: [...prefilter.columns] })),
529
581
  residual: entry.planned.mode === 'native'
530
582
  ? null
531
583
  : { mode: entry.planned.mode, reasons: entry.planned.reasons },
package/src/residual.js CHANGED
@@ -10,10 +10,17 @@
10
10
  * materialized candidate array. Re-applying pushed conjuncts is
11
11
  * idempotent, so SQL-side narrowing never changes the answer.
12
12
  * - `row` — only the projection stayed behind: each fetched row runs
13
- * `{ $for: { it: '$[*]' }, $return: [ <ret> ] }` over the one-row
14
- * array; the array wrapper packs the item sequence so an
15
- * array-VALUED item stays unambiguous, and the per-row results
16
- * concatenate in row order (streamable).
13
+ * the planner's one-row document over the one-row array; the array
14
+ * wrapper packs the item sequence so an array-VALUED item stays
15
+ * unambiguous, and the per-row results concatenate in row order
16
+ * (streamable).
17
+ *
18
+ * Neither mode builds a query document here. The collection binding is
19
+ * named by the caller's document — `it`, `user`, anything — so a wrapper
20
+ * synthesized in this file could only guess it, and a guess that
21
+ * disagreed with the projection's references would surface as an
22
+ * unbound-external error at request time rather than at compile time.
23
+ * The planner knows the name and hands both modes something complete.
17
24
  */
18
25
 
19
26
  import { compileJsonQuery } from '@jarenjs/json/query';
@@ -59,16 +66,17 @@ export function compileSetResidual(document, limits, operators) {
59
66
 
60
67
  /**
61
68
  * Compile the per-row projection for row-mode evaluation.
62
- * @param {any} returnExpression - The document's raw `$return` value
69
+ * @param {any} rowDocument - The planner's complete one-row document
70
+ * (`{ $for: { <the document's own binding>: '$[*]' },
71
+ * $return: [ <its $return> ] }`). It arrives whole because the binding
72
+ * and the projection that references it must agree, and the planner is
73
+ * the only place that knows the name.
63
74
  * @param {any} [limits]
64
75
  * @param {{ functions?: any, extensions?: any } | null} [operators]
65
76
  * @returns {(row: any, externals: any) => any[]} the row's items
66
77
  */
67
- export function compileRowResidual(returnExpression, limits, operators) {
68
- const compiled = compileJsonQuery({
69
- $for: { it: '$[*]' },
70
- $return: [returnExpression],
71
- }, residualOptions(limits, operators));
78
+ export function compileRowResidual(rowDocument, limits, operators) {
79
+ const compiled = compileJsonQuery(rowDocument, residualOptions(limits, operators));
72
80
  return (row, externals) => {
73
81
  const packed = compiled([row], externals);
74
82
  // one binding → exactly one packed array of that row's items
package/src/store.js CHANGED
@@ -36,6 +36,10 @@ import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
36
36
  import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js';
37
37
  import { createJobEngine } from './jobs.js';
38
38
  import { collectEntityRoots } from './plan.js';
39
+ import {
40
+ DERIVE_KINDS, PRECISION_MIN, PRECISION_MAX, derivedValue, memberAt,
41
+ storedMemberForm, registerDeriveFunctions,
42
+ } from './derive.js';
39
43
 
40
44
  /** The model format version this store implements. */
41
45
  export const MODEL_VERSION = '0.1';
@@ -53,6 +57,70 @@ function modelError(code, reason, docPath) {
53
57
  return new DbCompileError(code, reason, docPath);
54
58
  }
55
59
 
60
+ /**
61
+ * Normalize one index's `derive` declaration — the spatial storage
62
+ * vocabulary. `derive` says what is COMPUTED from the selected member,
63
+ * never how the member is selected, so the singular-path rule and its
64
+ * `JD0004` are untouched; these are the refusals a derived index adds.
65
+ *
66
+ * Every one of them is a mistake worth catching at open rather than at
67
+ * the first query that quietly returns nothing.
68
+ * @param {any} index - the declared index member
69
+ * @param {string[]} paths
70
+ * @param {string} docPath
71
+ * @returns {{ kind: string | null, precision: number | undefined }}
72
+ */
73
+ function normalizeDerive(index, paths, docPath) {
74
+ const declared = index.derive;
75
+ if (declared === undefined) {
76
+ if (index.precision !== undefined) {
77
+ throw modelError('JD0004',
78
+ "precision belongs to a derived index — declare derive: 'geohash' beside it",
79
+ `${docPath}/precision`);
80
+ }
81
+ return { kind: null, precision: undefined };
82
+ }
83
+ if (typeof declared !== 'string' || !DERIVE_KINDS.has(declared)) {
84
+ throw modelError('JD0004',
85
+ `derive is a closed set ('geohash' or 'bbox'), got ${JSON.stringify(declared)} — `
86
+ + 'an open expression member would be a second query language inside the model',
87
+ `${docPath}/derive`);
88
+ }
89
+ if (paths.length !== 1) {
90
+ throw modelError('JD0004',
91
+ `a ${declared} index derives its columns from ONE member; a composite path declares several`,
92
+ `${docPath}/path`);
93
+ }
94
+ if (index.unique === true) {
95
+ throw modelError('JD0004',
96
+ `a ${declared} index is never unique: distinct positions share a cell (and a box edge) by construction`,
97
+ `${docPath}/unique`);
98
+ }
99
+ if (declared === 'bbox') {
100
+ if (index.precision !== undefined) {
101
+ throw modelError('JD0004',
102
+ 'precision applies to a geohash index; a bbox index has no cell size',
103
+ `${docPath}/precision`);
104
+ }
105
+ return { kind: 'bbox', precision: undefined };
106
+ }
107
+ const precision = index.precision;
108
+ if (precision === undefined) {
109
+ throw modelError('JD0004',
110
+ `a geohash index must declare precision (${PRECISION_MIN}..${PRECISION_MAX} characters) — `
111
+ + 'there is no safe default: the right cell size depends on the query radius, '
112
+ + 'which the model cannot know',
113
+ `${docPath}/precision`);
114
+ }
115
+ if (typeof precision !== 'number' || !Number.isInteger(precision)
116
+ || precision < PRECISION_MIN || precision > PRECISION_MAX) {
117
+ throw modelError('JD0004',
118
+ `precision must be an integer ${PRECISION_MIN}..${PRECISION_MAX}, got ${JSON.stringify(precision)}`,
119
+ `${docPath}/precision`);
120
+ }
121
+ return { kind: 'geohash', precision };
122
+ }
123
+
56
124
  /**
57
125
  * Normalize and check a model document. Every failure is `JD0005` with
58
126
  * a `docPath` into the model.
@@ -155,10 +223,13 @@ export function normalizeModel(model) {
155
223
  'an index path must be a JSONPath string (a composite index takes a non-empty array of them)',
156
224
  `${indexDocPath}/path`);
157
225
  }
226
+ const derive = normalizeDerive(index, paths, indexDocPath);
158
227
  indexes.push({
159
228
  name: index.name,
160
229
  paths,
161
230
  unique: index.unique === true,
231
+ derive: derive.kind,
232
+ precision: derive.precision,
162
233
  docPath: indexDocPath,
163
234
  });
164
235
  }
@@ -377,10 +448,39 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
377
448
  */
378
449
  function collectionCore(connection, collection, plan, validate, queryState, storeProfileRef) {
379
450
  const dialect = connection.dialect;
451
+ // the STORED branch (a driver that cannot index a registered
452
+ // function): the derived columns are ordinary ones, so every write
453
+ // carries their values. Empty everywhere else, and the statements are
454
+ // then byte-identical to what they were before derived indexes existed
455
+ const storedNames = new Set(plan.generated
456
+ .filter((column) => column.stored === true).map((column) => column.name));
457
+ const storedDerived = plan.derived.filter((column) => storedNames.has(column.name));
380
458
  const shape = {
381
459
  table: plan.table,
382
460
  keyColumn: plan.keyColumn,
383
461
  docColumn: plan.docColumn,
462
+ stored: storedDerived.length > 0
463
+ ? storedDerived.map((column) => column.name) : undefined,
464
+ };
465
+ /**
466
+ * The stored derived values for one document, in column order. The
467
+ * four columns of one bbox index share a segments array by identity,
468
+ * so the member is read and normalized once per index, not once per
469
+ * column.
470
+ * @param {any} doc
471
+ * @returns {any[]}
472
+ */
473
+ const derivedFor = (doc) => {
474
+ /** @type {Map<any, any>} */
475
+ const members = new Map();
476
+ return storedDerived.map((column) => {
477
+ let member = members.get(column.segments);
478
+ if (member === undefined) {
479
+ member = { value: storedMemberForm(memberAt(doc, column.segments)) };
480
+ members.set(column.segments, member);
481
+ }
482
+ return derivedValue(column, member.value);
483
+ });
384
484
  };
385
485
  /** @type {Map<string, any>} */
386
486
  const statements = new Map();
@@ -456,12 +556,12 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
456
556
  if (key === null) {
457
557
  return chain(
458
558
  runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
459
- [JSON.stringify(doc)], undefined, true),
559
+ [JSON.stringify(doc), ...derivedFor(doc)], undefined, true),
460
560
  (row) => row.key);
461
561
  }
462
562
  return chain(
463
563
  runWrite('insert', dialect.dml.insert(shape),
464
- [key, JSON.stringify(doc)], key, false),
564
+ [key, JSON.stringify(doc), ...derivedFor(doc)], key, false),
465
565
  () => key);
466
566
  },
467
567
  put(doc, explicitKey) {
@@ -470,12 +570,12 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
470
570
  if (key === null) {
471
571
  return chain(
472
572
  runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
473
- [JSON.stringify(doc)], undefined, true),
573
+ [JSON.stringify(doc), ...derivedFor(doc)], undefined, true),
474
574
  (row) => row.key);
475
575
  }
476
576
  return chain(
477
577
  runWrite('upsert', dialect.dml.upsert(shape),
478
- [key, JSON.stringify(doc)], key, false),
578
+ [key, JSON.stringify(doc), ...derivedFor(doc)], key, false),
479
579
  () => key);
480
580
  },
481
581
  patch(key, ops) {
@@ -495,7 +595,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
495
595
  return chain(
496
596
  runWrite('patchFallback',
497
597
  dialect.dml.updateDoc(shape, dialect.jsonEncode(dialect.parameterRef(1, 'doc')), 2),
498
- [JSON.stringify(next), key], key, false),
598
+ [JSON.stringify(next), ...derivedFor(next), key], key, false),
499
599
  () => next);
500
600
  }
501
601
  stats.patchTranslated++;
@@ -504,7 +604,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
504
604
  const sql = dialect.dml.updateDoc(shape, expression, params.length + 1);
505
605
  return chain(prepared(`patch:${sql}`, sql), (statement) => {
506
606
  try {
507
- statement.run([...params, key]);
607
+ statement.run([...params, ...derivedFor(next), key]);
508
608
  }
509
609
  catch (error) {
510
610
  throw wrapWriteError(error, plan, collection.name, collection.docPath, key);
@@ -747,10 +847,23 @@ export function openStore(model, options) {
747
847
  let topLevelTransaction = (fn) => withScope(opened.transaction, fn);
748
848
 
749
849
  const dialect = connection.dialect;
850
+ // The PHYSICAL MAPPING BRANCH for derived index columns. A driver
851
+ // that can index a registered deterministic function generates
852
+ // them; one that cannot has the store write them. It is a
853
+ // property of the driver that created the file, so a database
854
+ // built under one and opened under the other legitimately reports
855
+ // drift — that is a migration, not an open.
856
+ const derivedMapping = connection.capabilities.deterministicIndexableFunctions === true
857
+ ? 'virtual' : 'stored';
750
858
  /** @type {Map<string, any>} */
751
859
  const plans = new Map();
752
860
  for (const [name, collection] of collections)
753
- plans.set(name, planCollection(name, collection, dialect));
861
+ plans.set(name, planCollection(name, collection, dialect, { derived: derivedMapping }));
862
+ // a table whose column expression calls a function this connection
863
+ // has not registered cannot even be SELECTed (probed), so the
864
+ // registration precedes every statement over it
865
+ const needsDeriveFunctions = derivedMapping === 'virtual'
866
+ && [...plans.values()].some((plan) => plan.derived.length > 0);
754
867
  /** @type {Map<string, any>} */
755
868
  const entityPlans = new Map();
756
869
  if (mapping !== null) {
@@ -815,6 +928,7 @@ export function openStore(model, options) {
815
928
  : Promise.reject(error);
816
929
  };
817
930
  const opening = () => chain(pragmas, () =>
931
+ chain(needsDeriveFunctions ? registerDeriveFunctions(connection) : null, () =>
818
932
  chain(ensureShape(connection, collections, plans, readOnly), () =>
819
933
  chain(ensureEntityShape(connection, entityPlans, entities, readOnly), () => {
820
934
  /** @type {Map<string, any>} */
@@ -1408,7 +1522,7 @@ export function openStore(model, options) {
1408
1522
  return chain(capture === null ? null : capture.ready,
1409
1523
  () => chain(jobsEngine === null ? null : jobsEngine.ready,
1410
1524
  () => Object.freeze(store)));
1411
- })));
1525
+ }))));
1412
1526
 
1413
1527
  let opened_;
1414
1528
  try {
package/src/udf.js CHANGED
@@ -55,16 +55,22 @@ const functionNameFor = (identity) => `jaren_p_${hashContent(identity)}`;
55
55
  * [operators] - the store's registered operators (Ring 3); only its
56
56
  * `pushable:'scalar'` subset is admitted. `null`/absent keeps the
57
57
  * original engine-internal-only rule (no host function pushes).
58
+ * @param {string} [binding='it'] - the name the caller's document gave
59
+ * the collection binding. The fragment references it, so the wrapper
60
+ * below must bind it: under any other name every reference reads as an
61
+ * external, the determinism check below rejects the fragment, and the
62
+ * hatch silently never engages.
58
63
  * @returns {{ key: string, name: string,
59
64
  * compile: () => (docText: string) => number } | null}
60
65
  */
61
- export function deterministicFragment(fragment, operators = null) {
66
+ export function deterministicFragment(fragment, operators = null, binding = 'it') {
62
67
  const analyzeOpts = operators === null
63
68
  ? undefined
64
69
  : { functions: operators.functions, extensions: operators.extensions };
70
+ const wrap = (/** @type {any} */ body) => ({ $let: { [binding]: '$' }, $return: body });
65
71
  let dependencies;
66
72
  try {
67
- dependencies = analyzeQuery({ $let: { it: '$' }, $return: fragment }, analyzeOpts).dependencies;
73
+ dependencies = analyzeQuery(wrap(fragment), analyzeOpts).dependencies;
68
74
  }
69
75
  catch {
70
76
  return null;
@@ -101,7 +107,10 @@ export function deterministicFragment(fragment, operators = null) {
101
107
  key,
102
108
  name: functionNameFor(key),
103
109
  compile: () => {
104
- const compiled = compileJsonQuery({ $let: { it: '$' }, $return: fragment }, analyzeOpts);
110
+ // the SAME wrapper the analysis ran over: a compile that bound a
111
+ // different name than the analysis would judge one document and
112
+ // run another
113
+ const compiled = compileJsonQuery(wrap(fragment), analyzeOpts);
105
114
  return (docText) => (compiled.ebv(JSON.parse(docText)) ? 1 : 0);
106
115
  },
107
116
  };