@jarenjs/db 0.43.1 → 0.46.4

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/ddl.js CHANGED
@@ -17,7 +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
+ import { BBOX_COMPONENTS, BBOX_INDEX_ORDER, derivedMappingFor } from './derive.js';
21
21
 
22
22
  /** The fixed physical column names of the 0.1 mapping. */
23
23
  export const KEY_COLUMN = 'key';
@@ -118,6 +118,21 @@ export function schemaTypeAt(schema, segments) {
118
118
  /** The scalar schema types a derived index cannot be declared over. */
119
119
  const SCALAR_TYPES = new Set(['string', 'integer', 'number', 'boolean']);
120
120
 
121
+ /**
122
+ * Whether a schema node types its value as `array` and nothing else —
123
+ * `type: 'array'` or `type: ['array']`. A vector column over a member
124
+ * the schema also lets be a string (or does not type at all) is a
125
+ * column that lies: some documents would carry a member the column
126
+ * silently ignores.
127
+ * @param {any} node
128
+ * @returns {boolean}
129
+ */
130
+ function typedArrayOnly(node) {
131
+ if (node === undefined) return false;
132
+ if (node.type === 'array') return true;
133
+ return Array.isArray(node.type) && node.type.length === 1 && node.type[0] === 'array';
134
+ }
135
+
121
136
  /**
122
137
  * A stable generated-column name for a canonical path: readable where
123
138
  * the path is tame, disambiguated by suffix where sanitizing collides.
@@ -125,9 +140,9 @@ const SCALAR_TYPES = new Set(['string', 'integer', 'number', 'boolean']);
125
140
  * A DERIVED index names its columns from the same stem plus what makes
126
141
  * the derivation distinct — the kind, and the geohash precision, since
127
142
  * 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.
143
+ * coarse bucketing index and a fine proximity one); a vector width
144
+ * likewise. A bbox derivation owns FOUR columns under one stem, so
145
+ * every one of them is claimed before the stem is accepted.
131
146
  * @param {string} canonical
132
147
  * @param {Map<string, string>} byKey - identity -> column name (or stem)
133
148
  * @param {Set<string>} taken
@@ -151,6 +166,57 @@ function generatedColumnName(canonical, byKey, taken, options = undefined) {
151
166
  return name;
152
167
  }
153
168
 
169
+ /**
170
+ * The one column a `derive: 'vector'` index contributes: the packed,
171
+ * l2-normalized member as `dialect.packedVectorType`, STORED on every
172
+ * driver (`derivedMappingFor`), with no B-tree over it — nothing seeks
173
+ * a blob of floats; a fetch-and-rank plan reads the whole column — so
174
+ * the index declaration names a column, not an index, and contributes
175
+ * nothing to `expected.indexes`. `dims` is part of the identity for
176
+ * the reason `precision` is: a different width is a different column.
177
+ * @param {any} index - the normalized index declaration
178
+ * @param {any} context
179
+ * @returns {null} — no index covers the column
180
+ */
181
+ function vectorColumn(index, context) {
182
+ const {
183
+ collection, dialect, segments, canonical, pathText, columnByCanonical, taken,
184
+ generated, derived,
185
+ } = context;
186
+ const node = schemaNodeAt(collection.schema, segments);
187
+ if (!typedArrayOnly(node)) {
188
+ const typed = node?.type === undefined ? 'not typed' : `typed ${JSON.stringify(node.type)}`;
189
+ throw new DbCompileError('JD0004',
190
+ `index '${index.name}': the path '${index.paths[0]}' is ${typed} by the schema, and a `
191
+ + "vector column needs a member the schema types 'array' and nothing else — a column over "
192
+ + 'a member that may also be a string, an object or null is a column that lies about '
193
+ + 'some documents (declare items: { type: \'number\' } and minItems/maxItems equal to '
194
+ + 'dims beside it)',
195
+ `${index.docPath}/derive`);
196
+ }
197
+ if (typeof dialect.packedVectorType !== 'string') {
198
+ throw new TypeError(
199
+ `ddl: the '${dialect.name}' dialect declares no packedVectorType, so it cannot hold a vector column`);
200
+ }
201
+ const key = `${canonical}|vector|${index.dims}`;
202
+ const name = generatedColumnName(canonical, columnByCanonical, taken,
203
+ { key, suffix: `v${index.dims}` });
204
+ if (!generated.some((column) => column.name === name)) {
205
+ generated.push({
206
+ name,
207
+ type: dialect.packedVectorType,
208
+ pathText,
209
+ canonical,
210
+ derive: 'vector',
211
+ dims: index.dims,
212
+ stored: true,
213
+ expression: null,
214
+ });
215
+ derived.push({ name, derive: 'vector', dims: index.dims, segments });
216
+ }
217
+ return null;
218
+ }
219
+
154
220
  /**
155
221
  * The columns one derived index contributes, appended to the plan's
156
222
  * `generated` (the physical column list) and `derived` (what the write
@@ -162,25 +228,46 @@ function generatedColumnName(canonical, byKey, taken, options = undefined) {
162
228
  * rather than at the first query that returns nothing.
163
229
  * @param {any} index - the normalized index declaration
164
230
  * @param {any} context
165
- * @returns {string[]} the column names the index covers, in order
231
+ * @returns {string[] | null} the column names the index covers, in
232
+ * order — or `null` when no B-tree covers them (an R\*Tree, or a
233
+ * vector column)
166
234
  */
167
235
  function deriveColumns(index, context) {
168
236
  const {
169
- collection, dialect, stored, segments, canonical, pathText,
170
- columnByCanonical, taken, generated, derived,
237
+ collection, table, dialect, mapping, segments, canonical, pathText,
238
+ columnByCanonical, taken, generated, derived, rtreeCapable, virtualTables,
239
+ physicalByKey,
171
240
  } = context;
241
+ // the per-kind override: a vector column is stored on every driver
242
+ const stored = derivedMappingFor(index.derive, mapping) === 'stored';
172
243
  const declaredType = schemaTypeAt(collection.schema, segments);
173
244
  if (declaredType !== undefined && SCALAR_TYPES.has(declaredType)) {
174
245
  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`,
246
+ `index '${index.name}': the path '${index.paths[0]}' is typed '${declaredType}' by the schema, and a `
247
+ + `${index.derive} index derives from ${index.derive === 'vector'
248
+ ? 'an array of numbers' : 'a position or a geometry — an array or an object'}`,
177
249
  `${index.docPath}/derive`);
178
250
  }
251
+ if (index.derive === 'vector') return vectorColumn(index, context);
179
252
  const isGeohash = index.derive === 'geohash';
180
253
  // the identity that decides column SHARING: two indexes over the same
181
254
  // path with the same derivation and the same precision are one column
182
255
  // set; two precisions over one path are two, and legitimately so
183
256
  const key = `${canonical}|${index.derive}|${index.precision ?? ''}`;
257
+ // `physical` is a property of the COLUMN SET, not of the index: rule 5
258
+ // lets two indexes share one set, and one set has one shape on disk.
259
+ // Two indexes asking for two shapes is a model that cannot be built,
260
+ // and saying so beats silently honouring whichever came first.
261
+ const wanted = isGeohash ? 'columns' : (index.physical ?? 'columns');
262
+ const agreed = physicalByKey.get(key);
263
+ if (agreed !== undefined && agreed !== wanted) {
264
+ throw new DbCompileError('JD0004',
265
+ `the index path '${index.paths[0]}' already has a ${index.derive} column set declared `
266
+ + `physical: '${agreed}', and this index declares '${wanted}' — two indexes over one `
267
+ + 'path share one column set, and a column set has one shape on disk',
268
+ `${index.docPath}/physical`);
269
+ }
270
+ physicalByKey.set(key, wanted);
184
271
  const stem = generatedColumnName(canonical, columnByCanonical, taken, {
185
272
  key,
186
273
  suffix: isGeohash ? `gh${index.precision}` : 'bbox',
@@ -216,6 +303,31 @@ function deriveColumns(index, context) {
216
303
  });
217
304
  }
218
305
  }
306
+ if (wanted === 'rtree' && rtreeCapable) {
307
+ // the R*Tree mapping: the four columns stay (the triggers read them,
308
+ // and they are the box's one definition) and the B-tree over them
309
+ // does NOT get built — that is where part of the write cost is
310
+ // repaid, and it is what makes the two shapes differ in `expected`.
311
+ // One virtual table per COLUMN SET, never per index (rule 5).
312
+ if (!virtualTables.some((virtual) => virtual.stem === stem)) {
313
+ const name = `${table}_${stem}_rtree`;
314
+ // (w, e, s, n) — the order the virtual table's
315
+ // (minx, maxx, miny, maxy) carry, and the order the index covers
316
+ const edges = BBOX_INDEX_ORDER.map((component) => ({ name: nameOf(component) }));
317
+ const triggers = dialect.ddl.createSyncTriggers(
318
+ { table, virtualTable: name, prefix: name, edges });
319
+ virtualTables.push({
320
+ stem,
321
+ name,
322
+ columns: [...dialect.rtree.columns].slice(1),
323
+ edges: edges.map((edge) => edge.name),
324
+ createSql: dialect.ddl.createVirtualTable({ name }),
325
+ fillSql: dialect.ddl.fillVirtualTable({ table, virtualTable: name, edges }),
326
+ triggers,
327
+ });
328
+ }
329
+ return null;
330
+ }
219
331
  // the index COVERS its columns in (w, e, s, n) order, which is not
220
332
  // their declaration order — see BBOX_INDEX_ORDER
221
333
  return isGeohash ? [stem] : BBOX_INDEX_ORDER.map((component) => nameOf(component));
@@ -231,32 +343,44 @@ function deriveColumns(index, context) {
231
343
  * is false): there the columns are ordinary ones the store writes. The
232
344
  * two mappings produce different declared text on purpose — a database
233
345
  * built under one and opened under the other really does disagree, and
234
- * `verifyShape` says so rather than papering over it.
346
+ * `verifyShape` says so rather than papering over it. A
347
+ * `derive: 'vector'` column is the stored shape under BOTH mappings
348
+ * (`derivedMappingFor`), so for it the two agree.
235
349
  * @param {string} name - The collection name (also the table name)
236
350
  * @param {{ schema: any, keySegments: { name: string }[] | null,
237
351
  * identity: string, indexes: { name: string, paths: string[],
238
352
  * unique: boolean, derive?: string | null, precision?: number,
239
- * docPath: string }[] }} collection - normalized
353
+ * dims?: number, docPath: string }[] }} collection - normalized
240
354
  * @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
355
+ * @param {{ derived?: 'virtual' | 'stored', rtree?: boolean }} [options]
356
+ * - `derived` is the physical mapping for derived columns:
357
+ * `'virtual'` (a generated column over a registered function) unless
358
+ * the driver says it cannot index one. `rtree` is whether the driver
359
+ * carries the R\*Tree module; when it does not, a column set that
360
+ * declared `physical: 'rtree'` falls back to the B-tree over its
361
+ * columns and `explain().prefilters[].via` reports which shape ran
362
+ * (MODEL-FORMAT §4) — a report, not a silent degradation
244
363
  * @returns {{
245
364
  * table: string, keyColumn: string, docColumn: string,
246
365
  * keyType: string,
247
366
  * generated: { name: string, type: string, pathText: string,
248
367
  * canonical: string }[],
249
368
  * derived: { name: string, derive: string, precision?: number,
250
- * component?: string, segments: any[] }[],
369
+ * component?: string, dims?: number, segments: any[] }[],
251
370
  * columnByCanonical: Map<string, string>,
252
371
  * createSql: string[],
372
+ * virtualTables: { stem: string, name: string, columns: string[],
373
+ * edges: string[], createSql: string, fillSql: string,
374
+ * triggers: { name: string, sql: string }[] }[],
253
375
  * expected: { columns: { name: string, type: string,
254
376
  * generated: boolean }[], indexes: { name: string, unique: boolean,
255
377
  * columns: string[] }[] },
256
378
  * }}
257
379
  */
258
380
  export function planCollection(name, collection, dialect, options = undefined) {
259
- const stored = options?.derived === 'stored';
381
+ /** @type {'virtual' | 'stored'} */
382
+ const mapping = options?.derived === 'stored' ? 'stored' : 'virtual';
383
+ const rtreeCapable = options?.rtree !== false;
260
384
  const keyType = collection.identity === 'integer'
261
385
  ? dialect.typeFor('integer', 'key')
262
386
  : collection.identity === 'uuid'
@@ -274,9 +398,14 @@ export function planCollection(name, collection, dialect, options = undefined) {
274
398
  const derived = [];
275
399
  /** @type {{ name: string, unique: boolean, columns: string[] }[]} */
276
400
  const indexes = [];
401
+ /** @type {any[]} */
402
+ const virtualTables = [];
403
+ /** @type {Map<string, string>} */
404
+ const physicalByKey = new Map();
277
405
 
278
406
  for (const index of collection.indexes) {
279
407
  const columns = [];
408
+ let noBtree = false;
280
409
  for (let i = 0; i < index.paths.length; i++) {
281
410
  const pathDocPath = `${index.docPath}/path`;
282
411
  const { segments, canonical } = compileIndexPath(index.paths[i], pathDocPath);
@@ -287,10 +416,13 @@ export function planCollection(name, collection, dialect, options = undefined) {
287
416
  pathDocPath);
288
417
  }
289
418
  if (index.derive) {
290
- columns.push(...deriveColumns(index, {
291
- collection, dialect, stored, segments, canonical, pathText,
292
- columnByCanonical, taken, generated, derived,
293
- }));
419
+ const contributed = deriveColumns(index, {
420
+ collection, table: name, dialect, mapping, segments, canonical, pathText,
421
+ columnByCanonical, taken, generated, derived, rtreeCapable, virtualTables,
422
+ physicalByKey,
423
+ });
424
+ if (contributed === null) noBtree = true;
425
+ else columns.push(...contributed);
294
426
  continue;
295
427
  }
296
428
  const known = columnByCanonical.has(canonical);
@@ -305,6 +437,11 @@ export function planCollection(name, collection, dialect, options = undefined) {
305
437
  }
306
438
  columns.push(columnName);
307
439
  }
440
+ // a column set realized as an R*Tree has no B-tree over it: the
441
+ // virtual table IS the index, and the four columns stay only as the
442
+ // box's one definition (the triggers read them). A vector column
443
+ // has none either: it is fetched whole and ranked, never sought
444
+ if (noBtree) continue;
308
445
  indexes.push({
309
446
  name: `${name}_${index.name}`,
310
447
  unique: index.unique,
@@ -327,7 +464,25 @@ export function planCollection(name, collection, dialect, options = undefined) {
327
464
  columns: index.columns,
328
465
  unique: index.unique,
329
466
  })),
467
+ ...virtualTables.flatMap((virtual) =>
468
+ [virtual.createSql, ...virtual.triggers.map((trigger) => trigger.sql)]),
330
469
  ];
470
+ // the virtual table and its triggers are named from the column stem,
471
+ // and an index is named from the model's own index name — two
472
+ // namespaces that meet in `sqlite_schema`. A collision would have one
473
+ // object silently standing in for another, so it is refused here
474
+ const objectNames = [name, ...indexes.map((index) => index.name),
475
+ ...virtualTables.flatMap((virtual) =>
476
+ [virtual.name, ...virtual.triggers.map((trigger) => trigger.name)])];
477
+ const seen = new Set();
478
+ for (const objectName of objectNames) {
479
+ if (seen.has(objectName)) {
480
+ throw new DbCompileError('JD0004',
481
+ `collection '${name}' would declare two schema objects named '${objectName}'`,
482
+ collection.docPath ?? `/collections/${name}`);
483
+ }
484
+ seen.add(objectName);
485
+ }
331
486
 
332
487
  return {
333
488
  table: name,
@@ -337,6 +492,7 @@ export function planCollection(name, collection, dialect, options = undefined) {
337
492
  generated,
338
493
  derived,
339
494
  columnByCanonical,
495
+ virtualTables,
340
496
  createSql,
341
497
  expected: {
342
498
  columns: [
@@ -464,13 +620,40 @@ export function comparableDeclaredSql(sql) {
464
620
  function verifyDeclaredSql(connection, plan, disagree) {
465
621
  const dialect = connection.dialect;
466
622
  const planned = new Map();
623
+ // an R*Tree virtual table is NOT owned by the collection table —
624
+ // `declaredSql` is scoped to `tbl_name`, and a virtual table's is
625
+ // itself — so it is checked by its own look below rather than
626
+ // reported as an object the database is missing. Its three triggers
627
+ // ARE the collection's, so drift in both directions falls out of this
628
+ // comparison for free.
629
+ const owned = new Set(plan.virtualTables?.map((virtual) => virtual.name) ?? []);
467
630
  for (const sql of plan.createSql) {
468
631
  const comparable = comparableDeclaredSql(sql);
469
632
  // the object's name is the first quoted identifier in the statement
470
633
  const name = /"((?:[^"]|"")*)"/.exec(comparable)?.[1]?.replace(/""/g, '"');
471
- if (name === undefined) continue;
634
+ if (name === undefined || owned.has(name)) continue;
472
635
  planned.set(name, comparable);
473
636
  }
637
+ /** The virtual tables, each by its own name. */
638
+ const verifyVirtual = (i) => {
639
+ const list = plan.virtualTables ?? [];
640
+ if (i >= list.length) return null;
641
+ const virtual = list[i];
642
+ return chain(connection.prepare(dialect.introspect.declaredSql(virtual.name)),
643
+ (statement) => chain(statement.all([]), (rows) => {
644
+ const row = rows.find((candidate) => String(candidate.name) === virtual.name);
645
+ if (row === undefined) {
646
+ disagree(`the model declares the virtual table '${virtual.name}', `
647
+ + 'which the database does not have');
648
+ }
649
+ const have = comparableDeclaredSql(row.sql);
650
+ const wanted = comparableDeclaredSql(virtual.createSql);
651
+ if (have !== wanted) {
652
+ disagree(`'${virtual.name}' is declared as\n ${have}\nand the model declares\n ${wanted}`);
653
+ }
654
+ return verifyVirtual(i + 1);
655
+ }));
656
+ };
474
657
  return chain(connection.prepare(dialect.introspect.declaredSql(plan.table)),
475
658
  (statement) => chain(statement.all([]), (rows) => {
476
659
  /** @type {Map<string, string>} */
@@ -490,7 +673,7 @@ function verifyDeclaredSql(connection, plan, disagree) {
490
673
  + 'an undeclared index or trigger changes deletion semantics and query plans');
491
674
  }
492
675
  }
493
- return null;
676
+ return verifyVirtual(0);
494
677
  }));
495
678
  }
496
679
 
@@ -523,8 +706,17 @@ export function verifyShape(connection, plan, collection, docPath) {
523
706
  const expected = [...plan.expected.columns]
524
707
  .map((c) => ({ ...c, type: c.type.toUpperCase() }))
525
708
  .sort((a, b) => (a.name < b.name ? -1 : 1));
526
- if (actual.length !== expected.length)
527
- disagree(`${actual.length} columns exist, the model declares ${expected.length}`);
709
+ if (actual.length !== expected.length) {
710
+ // name what is missing or extra: a count alone sends the reader
711
+ // to a pragma to learn which column a foreign tool dropped
712
+ const actualNames = new Set(actual.map((column) => column.name));
713
+ const expectedNames = new Set(expected.map((column) => column.name));
714
+ const missing = expected.filter((column) => !actualNames.has(column.name));
715
+ const extra = actual.filter((column) => !expectedNames.has(column.name));
716
+ disagree(`${actual.length} columns exist, the model declares ${expected.length}`
717
+ + (missing.length > 0 ? `; missing: ${missing.map((c) => `'${c.name}'`).join(', ')}` : '')
718
+ + (extra.length > 0 ? `; undeclared: ${extra.map((c) => `'${c.name}'`).join(', ')}` : ''));
719
+ }
528
720
  for (let i = 0; i < expected.length; i++) {
529
721
  const want = expected[i];
530
722
  const have = actual[i];
@@ -541,8 +733,24 @@ export function verifyShape(connection, plan, collection, docPath) {
541
733
  .map((row) => ({ name: String(row.name), unique: Number(row.uniq) !== 0 }))
542
734
  .sort((a, b) => (a.name < b.name ? -1 : 1));
543
735
  const wantedIndexes = plan.expected.indexes;
544
- if (created.length !== wantedIndexes.length)
545
- disagree(`${created.length} declared indexes exist, the model declares ${wantedIndexes.length}`);
736
+ if (created.length !== wantedIndexes.length) {
737
+ // the COUNT is the fact, but the NAMES are what a reader
738
+ // needs: a file moved between two physical mappings differs
739
+ // by exactly one index, and saying which one is the whole
740
+ // diagnosis
741
+ const have = new Set(created.map((index) => index.name));
742
+ const want = new Set(wantedIndexes.map((index) => index.name));
743
+ const missing = [...want].filter((index) => !have.has(index));
744
+ const extra = [...have].filter((index) => !want.has(index));
745
+ disagree(`${created.length} declared indexes exist, the model declares `
746
+ + `${wantedIndexes.length}`
747
+ + (missing.length > 0
748
+ ? ` — the model declares ${missing.join(', ')}, which the database does not have`
749
+ : '')
750
+ + (extra.length > 0
751
+ ? ` — the database has ${extra.join(', ')}, which the model does not declare`
752
+ : ''));
753
+ }
546
754
  const collectColumns = (i) => {
547
755
  if (i >= created.length) return null;
548
756
  const have = created[i];
package/src/derive.js CHANGED
@@ -3,20 +3,36 @@
3
3
  * @file Derived index columns: the one place a declared
4
4
  * `indexes[].derive` becomes a value. A spatial member is an array of
5
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.
6
+ * geohash cell or a bounding-box edge is what actually gets indexed;
7
+ * an embedding is an array of hundreds of numbers, and what gets
8
+ * stored is its packed, l2-normalized Float32 form.
7
9
  *
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.
10
+ * Every cell and every box comes from `@jarenjs/core/geo`, and every
11
+ * normalization and packing from `@jarenjs/core/vector`; nothing here
12
+ * computes arithmetic of its own. The spatial functions serve BOTH
13
+ * physical mappings registered as deterministic SQL functions inside
14
+ * a virtual generated column's expression where the driver can index
15
+ * them, and called directly on the write path where it cannot so
16
+ * the two branches cannot drift into different answers. The vector
17
+ * kind has ONE mapping, stored everywhere (`DERIVE_MAPPING`), and
18
+ * registers no function at all: a whole array re-derived per row as a
19
+ * host call is the cost the spatial work measured at 150×, a stored
20
+ * column is readable without any registration, and bun has no
21
+ * function API — one mapping is the only way every driver agrees.
14
22
  *
15
23
  * 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 neighbourhoodis computed by the
19
- * helpers below rather than by an import of its own.
24
+ * one home for spatial arithmetic, grep-proven by test) and onto
25
+ * `@jarenjs/core/vector` (the same rule, one home for vector
26
+ * arithmetic): the planner's probe geometry the box of a literal or
27
+ * bound region, the box of a bounded-distance circle, a cell's
28
+ * neighbourhood — and the k-nearest plan's probe vector and column
29
+ * score are computed by the helpers below rather than by an import of
30
+ * their own.
31
+ *
32
+ * The switches over the kind are EXHAUSTIVE: a kind with no rule
33
+ * throws, here and in the dialect, so that adding a kind without
34
+ * teaching both is a failure at the first call rather than a bbox
35
+ * edge of the first two floats and `jaren_bbox_undefined(...)` SQL.
20
36
  *
21
37
  * Determinism is the contract, not a convenience: a value here is a
22
38
  * pure function of the document bytes. Nothing reads the clock, a
@@ -31,11 +47,47 @@
31
47
  import {
32
48
  bboxOf, centroidOf, circleBounds, geohashEncode, geohashNeighbours,
33
49
  } from '@jarenjs/core/geo';
50
+ import {
51
+ isVector, l2Normalize, packVector, unpackVector, dotProduct,
52
+ } from '@jarenjs/core/vector';
34
53
 
35
54
  import { chain } from './driver.js';
36
55
 
37
56
  /** The closed set of derive kinds. */
38
- export const DERIVE_KINDS = new Set(['geohash', 'bbox']);
57
+ export const DERIVE_KINDS = new Set(['geohash', 'bbox', 'vector']);
58
+
59
+ /**
60
+ * The per-kind PHYSICAL MAPPING override. A spatial kind takes the
61
+ * mapping the driver's capability selects (`null` here); the vector
62
+ * kind is `'stored'` on every driver, for the three reasons in the
63
+ * file header, and never joins `registerDeriveFunctions`.
64
+ * @type {Readonly<Record<string, 'stored' | null>>}
65
+ */
66
+ export const DERIVE_MAPPING = Object.freeze({ geohash: null, bbox: null, vector: 'stored' });
67
+
68
+ /**
69
+ * The physical mapping one derived column takes: the kind's override
70
+ * where it has one, the driver's mapping otherwise.
71
+ * @param {string} kind
72
+ * @param {'virtual' | 'stored'} driverMapping
73
+ * @returns {'virtual' | 'stored'}
74
+ */
75
+ export function derivedMappingFor(kind, driverMapping) {
76
+ if (!DERIVE_KINDS.has(kind)) throw new TypeError(`derive: unknown derive kind '${kind}'`);
77
+ return DERIVE_MAPPING[kind] ?? driverMapping;
78
+ }
79
+
80
+ /**
81
+ * The closed set of PHYSICAL realizations a `derive: 'bbox'` index may
82
+ * ask for. `'columns'` is the default and what an absent member means:
83
+ * four generated columns under one B-tree. `'rtree'` is the same four
84
+ * columns (they stay the box's one definition) beside a SQLite R\*Tree
85
+ * virtual table kept in sync by declared triggers, with no B-tree over
86
+ * them. The LOGICAL meaning of `derive: 'bbox'` is identical either
87
+ * way — same rows, same answers — which is the whole point of naming
88
+ * the shape separately from the derivation.
89
+ */
90
+ export const PHYSICAL_KINDS = new Set(['columns', 'rtree']);
39
91
 
40
92
  /** A bbox index's four columns, in the order they are declared —
41
93
  * `[west, south, east, north]`, the order the kernel's boxes carry. */
@@ -57,6 +109,10 @@ const BBOX_AT = { w: 0, s: 1, e: 2, n: 3 };
57
109
  export const PRECISION_MIN = 1;
58
110
  export const PRECISION_MAX = 12;
59
111
 
112
+ /** The declared vector width range (components). */
113
+ export const DIMS_MIN = 1;
114
+ export const DIMS_MAX = 8192;
115
+
60
116
  /**
61
117
  * The representative position of any GeoJSON value: the position
62
118
  * itself for a bare `[lon, lat]` or a Point, the mean vertex
@@ -97,6 +153,47 @@ export function deriveBboxEdge(value, component) {
97
153
  return box === null ? null : box[BBOX_AT[component]];
98
154
  }
99
155
 
156
+ /**
157
+ * The packed column value of a member ALREADY in its stored form (see
158
+ * {@link storedMemberForm}): the l2-normalized vector as little-endian
159
+ * binary32 bytes, or `null` when the member is not a vector of exactly
160
+ * `dims` finite numbers — absent, the wrong width, a non-finite
161
+ * component (which JSON turned into `null`), not an array at all. An
162
+ * unrankable document is INVISIBLE to the column, never a stored-but-
163
+ * wrong one; the document itself stores fine.
164
+ *
165
+ * Normalized because the stored form is what a fetch-and-rank plan
166
+ * sweeps, and over unit vectors the dot product IS the cosine — the
167
+ * agreement invariant between this column and an engine computing
168
+ * cosine over the raw member. A zero vector normalizes to itself and
169
+ * is stored (it scores 0 against everything; the kernel documents it
170
+ * and the query format publishes it), not refused.
171
+ * @param {any} stored - the member as the database holds it
172
+ * @param {number} dims
173
+ * @returns {Uint8Array | null}
174
+ */
175
+ function packedVector(stored, dims) {
176
+ if (!isVector(stored, dims)) return null;
177
+ return packVector(l2Normalize(stored));
178
+ }
179
+
180
+ /**
181
+ * The value of a `derive: 'vector'` column for a document member — the
182
+ * ONE seam from this package onto `@jarenjs/core/vector`. The member
183
+ * round-trips through its stored form first, exactly as the spatial
184
+ * kinds do: a `Float32Array` in the document is held as an object and
185
+ * a `NaN` as `null`, and the column must be a function of what is
186
+ * held, so that a write, a migration backfill and a second open can
187
+ * never disagree about one row.
188
+ * @param {any} member - the value at the index path, or `undefined`
189
+ * @param {number} dims - the declared width
190
+ * @returns {Uint8Array | null} `4·dims` bytes, or `null` when the
191
+ * member is not a vector of that width
192
+ */
193
+ export function deriveVector(member, dims) {
194
+ return packedVector(storedMemberForm(member), dims);
195
+ }
196
+
100
197
  /**
101
198
  * A member as the database will hold it. A derived value must be a
102
199
  * function of the STORED document, not of the object handed to the
@@ -118,16 +215,27 @@ export function storedMemberForm(member) {
118
215
  }
119
216
 
120
217
  /**
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}
218
+ * The value of one derived column for a document member, which the
219
+ * caller hands over in its STORED form (`storedMemberForm`): the write
220
+ * path round-trips it, and a migration backfill reads it from the row.
221
+ * Exhaustive over the kind see the file header.
222
+ * @param {{ derive: string, precision?: number, component?: string,
223
+ * dims?: number }} column
224
+ * @param {any} member - the stored value at the index path, or `undefined`
225
+ * @returns {string | number | Uint8Array | null}
125
226
  */
126
227
  export function derivedValue(column, member) {
127
228
  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));
229
+ switch (column.derive) {
230
+ case 'geohash':
231
+ return deriveGeohash(member, /** @type {number} */ (column.precision));
232
+ case 'bbox':
233
+ return deriveBboxEdge(member, /** @type {string} */ (column.component));
234
+ case 'vector':
235
+ return packedVector(member, /** @type {number} */ (column.dims));
236
+ default:
237
+ throw new TypeError(`derive: no value rule for derive kind '${column.derive}'`);
238
+ }
131
239
  }
132
240
 
133
241
  /**
@@ -282,3 +390,34 @@ export function derivedSlotValue(derived, value) {
282
390
  const box = bboxOf(value);
283
391
  return box === null ? null : box[BBOX_AT[derived.axis]];
284
392
  }
393
+
394
+ /**
395
+ * The probe a k-nearest plan scores the column against: a literal or
396
+ * bound vector, l2-normalized once, so that its dot product with the
397
+ * column's normalized form IS the cosine of the raw vectors. `null`
398
+ * when the value is not a vector of exactly `dims` finite numbers —
399
+ * the binder's signal to divert the call to the residual, where the
400
+ * engine answers what it answers everywhere for such a probe (empty
401
+ * keys for another width, its own refusal for a non-array).
402
+ * @param {unknown} value
403
+ * @param {number} dims - the column's declared width
404
+ * @returns {Float32Array | null}
405
+ */
406
+ export function probeVector(value, dims) {
407
+ return isVector(value, dims) ? l2Normalize(value) : null;
408
+ }
409
+
410
+ /**
411
+ * One row's column score against a prepared probe: the packed column
412
+ * unpacked at the declared width and dotted with the probe. `null` for
413
+ * a row whose column holds no vector — SQL `NULL`, or bytes of another
414
+ * length — because such a row is unrankable and 0 is a real score.
415
+ * @param {unknown} bytes - the column value as the driver returns it
416
+ * @param {number} dims
417
+ * @param {Float32Array} probe - from {@link probeVector}
418
+ * @returns {number | null}
419
+ */
420
+ export function columnScore(bytes, dims, probe) {
421
+ const vector = unpackVector(/** @type {any} */ (bytes), dims);
422
+ return vector === null ? null : dotProduct(vector, probe);
423
+ }