@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/ARCHITECTURE.md +157 -13
- package/README.md +319 -33
- package/dist/types/algebra.d.ts +32 -2
- package/dist/types/ddl.d.ts +31 -6
- package/dist/types/derive.d.ts +105 -16
- package/dist/types/dialect.d.ts +11 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/knn.d.ts +69 -0
- package/dist/types/migrate.d.ts +2 -1
- package/dist/types/plan.d.ts +11 -4
- package/dist/types/query.d.ts +11 -0
- package/docs/LIVE-FORMAT.md +30 -0
- package/docs/MIGRATION-FORMAT.md +10 -0
- package/docs/MODEL-FORMAT.md +298 -27
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +8 -2
- package/schemas/jaren-migration.schema.json +8 -2
- package/schemas/jaren-model.draft-07.schema.json +16 -2
- package/schemas/jaren-model.schema.json +16 -2
- package/src/algebra.js +20 -2
- package/src/ddl.js +234 -26
- package/src/derive.js +158 -19
- package/src/dialect.js +134 -2
- package/src/dialects/sqlite.js +33 -4
- package/src/emit.js +30 -6
- package/src/index.js +5 -3
- package/src/knn.js +96 -0
- package/src/live.js +87 -7
- package/src/migrate.js +55 -4
- package/src/plan.js +280 -36
- package/src/query.js +177 -44
- package/src/store.js +151 -60
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)
|
|
129
|
-
* owns FOUR columns under one stem, so
|
|
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
|
|
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,
|
|
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
|
-
`
|
|
176
|
-
+ `${index.derive} index derives from
|
|
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]
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
collection, dialect,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|