@fgv/ts-agent-memory-sqlite-vec 5.1.0-44 → 5.1.0-46

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.
Files changed (25) hide show
  1. package/.rush/temp/{e0139967d914fcbb502d556358d7bfdfe7ff91d6.tar.log → b39f85377e1ce7a5b4a0aad208a6f48e4fba124b.tar.log} +2 -2
  2. package/.rush/temp/chunked-rush-logs/ts-agent-memory-sqlite-vec.build.chunks.jsonl +2 -2
  3. package/.rush/temp/operation/build/all.log +2 -2
  4. package/.rush/temp/operation/build/log-chunks.jsonl +2 -2
  5. package/.rush/temp/operation/build/state.json +1 -1
  6. package/README.md +16 -2
  7. package/dist/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js +144 -26
  8. package/dist/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js.map +1 -1
  9. package/dist/test/unit/sqliteVecFragmentIndex.test.js +168 -7
  10. package/dist/test/unit/sqliteVecFragmentIndex.test.js.map +1 -1
  11. package/dist/ts-agent-memory-sqlite-vec.d.ts +68 -11
  12. package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.d.ts +68 -11
  13. package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.d.ts.map +1 -1
  14. package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js +144 -26
  15. package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js.map +1 -1
  16. package/lib/test/unit/sqliteVecFragmentIndex.test.js +168 -7
  17. package/lib/test/unit/sqliteVecFragmentIndex.test.js.map +1 -1
  18. package/package.json +7 -7
  19. package/rush-logs/ts-agent-memory-sqlite-vec.build.cache.log +1 -1
  20. package/rush-logs/ts-agent-memory-sqlite-vec.build.log +2 -2
  21. package/src/packlets/sqlite-vec-index/sqliteVecFragmentIndex.ts +184 -25
  22. package/src/test/unit/sqliteVecFragmentIndex.test.ts +236 -11
  23. package/temp/build/lint/_eslint-5eVG3S6w.json +2 -2
  24. package/temp/build/typescript/ts_8nwakTlr.json +1 -1
  25. package/temp/ts-agent-memory-sqlite-vec.api.json +2 -2
@@ -9,6 +9,7 @@ import { Result, captureResult, fail, succeed } from '@fgv/ts-utils';
9
9
  import {
10
10
  IEdgeTarget,
11
11
  IEmbeddedFragment,
12
+ IFragmentLocator,
12
13
  IFragmentVectorIndex,
13
14
  IVectorQueryHit,
14
15
  MemoryId,
@@ -23,20 +24,49 @@ const DEFAULT_TABLE_NAME: string = 'memory_fragments';
23
24
  /** A simple SQL identifier — the only shape allowed for the table name (it is interpolated into DDL). */
24
25
  const IDENTIFIER_RE: RegExp = /^[A-Za-z_][A-Za-z0-9_]*$/;
25
26
 
27
+ /**
28
+ * The auxiliary (`+`-prefixed) columns this version of the index writes. A table
29
+ * created by an earlier version carries a different set; see
30
+ * {@link SqliteVecFragmentIndex._readExistingDimension} for why that has to be
31
+ * detected explicitly rather than migrated.
32
+ */
33
+ const AUXILIARY_COLUMNS: ReadonlyArray<string> = ['start_off', 'end_off', 'fragment_id'];
34
+
35
+ /**
36
+ * Matches one `+name` auxiliary-column declaration in a `vec0` `CREATE VIRTUAL TABLE`
37
+ * statement. Only ever consumed via `String.matchAll`, which iterates a clone rather
38
+ * than advancing this instance's `lastIndex`, so the shared `/g` regex is reusable.
39
+ */
40
+ const AUXILIARY_COLUMN_RE: RegExp = /\+\s*([A-Za-z_][A-Za-z0-9_]*)/g;
41
+
26
42
  /**
27
43
  * One KNN row as returned by the fragment `vec0` MATCH query. The offset columns are
28
44
  * typed `number | bigint` because `better-sqlite3` returns integer columns as
29
45
  * `bigint` when a consumer enables its safe-integer mode (`defaultSafeIntegers`);
30
46
  * {@link SqliteVecFragmentIndex._toOffset} coerces them to a plain `number` (and
31
47
  * fails loudly on an out-of-safe-range value) before they reach the public locator.
48
+ * All three identity columns are nullable: a fragment stored without a locator has
49
+ * `NULL` offsets, and one stored without a `fragmentId` has a `NULL` `fragment_id`.
32
50
  */
33
51
  interface IKnnRow {
34
52
  readonly target_key: string;
35
- readonly start_off: number | bigint;
36
- readonly end_off: number | bigint;
53
+ // eslint-disable-next-line @rushstack/no-new-null -- SQLite returns NULL (not undefined) for an absent locator offset
54
+ readonly start_off: number | bigint | null;
55
+ // eslint-disable-next-line @rushstack/no-new-null -- SQLite returns NULL (not undefined) for an absent locator offset
56
+ readonly end_off: number | bigint | null;
57
+ // eslint-disable-next-line @rushstack/no-new-null -- SQLite returns NULL (not undefined) for an absent fragment id
58
+ readonly fragment_id: string | null;
37
59
  readonly distance: number;
38
60
  }
39
61
 
62
+ /**
63
+ * The identity fields of a fragment hit, in `IVectorQueryHit` shape: a field the
64
+ * stored fragment did not carry is *absent*, never present-but-`undefined`, so a hit
65
+ * for a fragment stored without a `fragmentId` is structurally identical to one this
66
+ * index produced before `fragment_id` existed.
67
+ */
68
+ type FragmentIdentity = Pick<IVectorQueryHit, 'locator' | 'fragmentId'>;
69
+
40
70
  /**
41
71
  * A persistent, `sqlite-vec`-backed `IFragmentVectorIndex` (from
42
72
  * `@fgv/ts-agent-memory`) — the fragment-granular sibling of
@@ -46,12 +76,25 @@ interface IKnnRow {
46
76
  * @remarks
47
77
  * Where {@link SqliteVecVectorIndex} keys one vector per record on a
48
78
  * `target_key` primary key, this index holds **many** vectors per record — one per
49
- * in-record `[start, end)` span — so it keys the `vec0` table on `target_key` as a
50
- * **`PARTITION KEY`** (many rows may share it) and stores each fragment's locator
51
- * offsets in two auxiliary columns (`+start_off`, `+end_off`) that ride alongside
52
- * the vector and are returned on query but never filtered. A query is a brute-force
53
- * `vec0` KNN scan across all partitions returning per-fragment hits, each carrying
54
- * its record `target` and the matched `locator`.
79
+ * fragment — so it keys the `vec0` table on `target_key` as a **`PARTITION KEY`**
80
+ * (many rows may share it) and stores each fragment's identity in three auxiliary
81
+ * columns (`+start_off`, `+end_off`, `+fragment_id`) that ride alongside the vector
82
+ * and are returned on query but never filtered in particular `fragment_id` is
83
+ * stored and returned verbatim, never parsed and never part of the query path. A
84
+ * query is a brute-force `vec0` KNN scan across all partitions returning per-fragment
85
+ * hits, each carrying its record `target` plus whichever identity fields the stored
86
+ * fragment was added with (a fragment must carry at least one).
87
+ *
88
+ * **`vec0` schema changes require a drop-and-re-index.** A
89
+ * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table (SQLite
90
+ * does not compare schemas) and `vec0` has no `ALTER TABLE ADD COLUMN`, so a database written by an
91
+ * earlier version of this package keeps its old auxiliary columns. `create` detects
92
+ * that by parsing the stored `CREATE VIRTUAL TABLE` SQL and fails with an actionable
93
+ * message naming the expected and found columns, rather than letting a widened
94
+ * `INSERT` surface an opaque `no such column` at statement-prepare time. There are no
95
+ * in-place migrations: drop the table (or use a fresh `tableName`) and re-index.
96
+ * Fragment vectors are re-derivable from the records, so this costs embedding time,
97
+ * never data.
55
98
  *
56
99
  * Semantics match `InMemoryFragmentCosineIndex` exactly: `addFragments` is
57
100
  * whole-record-replace (a single transaction deletes every prior fragment of the
@@ -104,12 +147,15 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
104
147
  /**
105
148
  * Family-convention factory. Loads the `sqlite-vec` extension onto the supplied
106
149
  * `better-sqlite3` connection and, if the fragment table already exists (a
107
- * reopened persistent file), recovers its established dimension so no
108
- * re-embedding is needed on open.
150
+ * reopened persistent file), verifies its auxiliary-column set matches this
151
+ * version's and recovers its established dimension so no re-embedding is needed on
152
+ * open.
109
153
  *
110
154
  * @param params - See {@link ISqliteVecFragmentIndexCreateParams}.
111
155
  * @returns `Success` with the index, or `Failure` if the table name is not a
112
- * simple identifier or the extension fails to load.
156
+ * simple identifier, the extension fails to load, or the existing table was
157
+ * written by a version with a different auxiliary-column set (which requires a
158
+ * drop-and-re-index — `vec0` cannot be altered in place).
113
159
  */
114
160
  public static create(params: ISqliteVecFragmentIndexCreateParams): Promise<Result<SqliteVecFragmentIndex>> {
115
161
  const table: string = params.tableName ?? DEFAULT_TABLE_NAME;
@@ -146,11 +192,25 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
146
192
  if (fragment.vector.length === 0) {
147
193
  return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));
148
194
  }
195
+ // A fragment carrying neither identity cannot be resolved back to anything by a
196
+ // consumer holding the hit — the same invariant `embeddedFragmentConverter`
197
+ // enforces at the untyped boundary, re-checked here at the index seam.
198
+ if (fragment.locator === undefined && fragment.fragmentId === undefined) {
199
+ return Promise.resolve(
200
+ fail(
201
+ `fragment index: cannot add '${key}': fragment requires at least one of 'locator' or 'fragmentId'`
202
+ )
203
+ );
204
+ }
149
205
  // Locator offsets are persisted as SQLite integers (bound via BigInt). Reject a
150
206
  // non-safe-integer offset up front with a clear message, rather than letting
151
207
  // `BigInt(nonInteger)` throw cryptically inside the write transaction OR storing
152
208
  // a value the read-side `_toOffset` guard would later reject on every query.
153
- if (!Number.isSafeInteger(fragment.locator.start) || !Number.isSafeInteger(fragment.locator.end)) {
209
+ // An absent locator persists as a NULL offset pair and skips the check.
210
+ if (
211
+ fragment.locator !== undefined &&
212
+ (!Number.isSafeInteger(fragment.locator.start) || !Number.isSafeInteger(fragment.locator.end))
213
+ ) {
154
214
  return Promise.resolve(
155
215
  fail(
156
216
  `fragment index: cannot add '${key}': locator [${fragment.locator.start}, ${fragment.locator.end}) offsets must be safe integers`
@@ -256,10 +316,7 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
256
316
  hits.push({
257
317
  target: SqliteVecFragmentIndex._parseKey(key),
258
318
  score: 1 - row.distance,
259
- locator: {
260
- start: SqliteVecFragmentIndex._toOffset(row.start_off, key),
261
- end: SqliteVecFragmentIndex._toOffset(row.end_off, key)
262
- }
319
+ ...SqliteVecFragmentIndex._toIdentity(row, key)
263
320
  });
264
321
  }
265
322
  return hits;
@@ -267,12 +324,16 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
267
324
  );
268
325
  }
269
326
 
270
- /** Create the fragment `vec0` virtual table with the established dimension. */
327
+ /**
328
+ * Create the fragment `vec0` virtual table with the established dimension. The
329
+ * auxiliary columns must stay in sync with `AUXILIARY_COLUMNS`, which
330
+ * `create` compares against an existing table's stored DDL.
331
+ */
271
332
  private _createTable(dimension: number): void {
272
333
  this._db.exec(
273
334
  `CREATE VIRTUAL TABLE IF NOT EXISTS "${this._table}" USING vec0(` +
274
335
  `target_key TEXT PARTITION KEY, embedding float[${dimension}] distance_metric=cosine, ` +
275
- `+start_off integer, +end_off integer)`
336
+ `+start_off integer, +end_off integer, +fragment_id text)`
276
337
  );
277
338
  }
278
339
 
@@ -282,7 +343,8 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
282
343
  `DELETE FROM "${this._table}" WHERE target_key = ?`
283
344
  );
284
345
  const ins: BetterSqlite3.Statement = this._db.prepare(
285
- `INSERT INTO "${this._table}"(target_key, embedding, start_off, end_off) VALUES (?, ?, ?, ?)`
346
+ `INSERT INTO "${this._table}"(target_key, embedding, start_off, end_off, fragment_id) ` +
347
+ `VALUES (?, ?, ?, ?, ?)`
286
348
  );
287
349
  // Whole-record replace: drop every prior fragment of the target, then insert the
288
350
  // new set, atomically. An empty set collapses to a pure delete.
@@ -294,9 +356,13 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
294
356
  ins.run(
295
357
  key,
296
358
  SqliteVecFragmentIndex._toBlob(fragment.vector),
297
- // vec0 typed columns reject a JS float; bind the offsets as integers.
298
- BigInt(fragment.locator.start),
299
- BigInt(fragment.locator.end)
359
+ // vec0 typed columns reject a JS float; bind the offsets as integers. An
360
+ // absent locator binds the pair as NULL — never a partial pair, so the read
361
+ // side can treat a half-NULL pair as corruption rather than a legal shape.
362
+ fragment.locator === undefined ? null : BigInt(fragment.locator.start),
363
+ fragment.locator === undefined ? null : BigInt(fragment.locator.end),
364
+ // Stored verbatim and never parsed; absent binds as NULL.
365
+ fragment.fragmentId ?? null
300
366
  );
301
367
  }
302
368
  });
@@ -306,7 +372,8 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
306
372
  replaceTxn(key, fragments);
307
373
  },
308
374
  query: this._db.prepare(
309
- `SELECT target_key, start_off, end_off, distance FROM "${this._table}" WHERE embedding MATCH ? AND k = ?`
375
+ `SELECT target_key, start_off, end_off, fragment_id, distance FROM "${this._table}" ` +
376
+ `WHERE embedding MATCH ? AND k = ?`
310
377
  ),
311
378
  fragmentCount: this._db.prepare(`SELECT count(*) AS c FROM "${this._table}"`),
312
379
  recordCount: this._db.prepare(`SELECT count(DISTINCT target_key) AS c FROM "${this._table}"`)
@@ -315,8 +382,15 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
315
382
 
316
383
  /**
317
384
  * Recover the established dimension of an existing fragment `vec0` table from its
318
- * stored `CREATE VIRTUAL TABLE` SQL (`float[<n>]`). Returns `undefined` when the
385
+ * stored `CREATE VIRTUAL TABLE` SQL (`float[<n>]`), after checking that the table's
386
+ * auxiliary columns match `AUXILIARY_COLUMNS`. Returns `undefined` when the
319
387
  * table does not exist yet (a fresh database — dimension is set by the first add).
388
+ *
389
+ * Throws when a table of that name exists but is not a usable fragment index (a
390
+ * mismatched auxiliary-column set, or no `vec0` embedding column); the caller runs
391
+ * this inside `captureResult`, so it surfaces as a loud `Failure` from `create`.
392
+ * The same stored DDL answers every one of those questions, so the checks cost
393
+ * nothing extra.
320
394
  */
321
395
  private static _readExistingDimension(db: BetterSqlite3.Database, table: string): number | undefined {
322
396
  const row: { sql: string } | undefined = db
@@ -325,8 +399,93 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
325
399
  if (row === undefined) {
326
400
  return undefined;
327
401
  }
402
+ SqliteVecFragmentIndex._verifyAuxiliaryColumns(row.sql, table);
328
403
  const match: RegExpMatchArray | null = row.sql.match(/float\[(\d+)\]/);
329
- return match === null ? undefined : Number(match[1]);
404
+ if (match === null) {
405
+ // The auxiliary columns matched but there is no `float[<n>]` embedding column,
406
+ // so this is not a usable fragment index table. Same remedy as a column
407
+ // mismatch — and failing here beats handing back a dimensionless index whose
408
+ // first add would `CREATE VIRTUAL TABLE IF NOT EXISTS` into a no-op.
409
+ throw new Error(
410
+ `existing table '${table}' has no vec0 embedding column, so it is not a usable fragment ` +
411
+ `index table. Drop it (or pass a fresh tableName) and re-add every fragment.`
412
+ );
413
+ }
414
+ return Number(match[1]);
415
+ }
416
+
417
+ /**
418
+ * Compare an existing table's auxiliary columns against `AUXILIARY_COLUMNS`.
419
+ *
420
+ * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table (SQLite
421
+ * never compares schemas) and `vec0` has no `ALTER TABLE ADD COLUMN`, so a table
422
+ * written by an earlier version of this package silently keeps its old columns and
423
+ * only fails later — as an opaque `no such column` when the widened `INSERT` is
424
+ * prepared. Detect it here instead and say what to do about it. Order is not
425
+ * compared: every statement names its columns explicitly, so only the set matters.
426
+ */
427
+ private static _verifyAuxiliaryColumns(sql: string, table: string): void {
428
+ const found: string[] = Array.from(sql.matchAll(AUXILIARY_COLUMN_RE), (m) => m[1]);
429
+ const expected: ReadonlyArray<string> = AUXILIARY_COLUMNS;
430
+ const matches: boolean =
431
+ found.length === expected.length && expected.every((column) => found.includes(column));
432
+ if (!matches) {
433
+ throw new Error(
434
+ `existing table '${table}' has auxiliary columns [${found.join(', ')}] but this index ` +
435
+ `requires [${expected.join(', ')}] — it was written by a different version of ` +
436
+ `@fgv/ts-agent-memory-sqlite-vec, or it is not a fragment index table at all. vec0 virtual ` +
437
+ `tables cannot be altered in place, so this requires a drop-and-re-index: DROP TABLE ` +
438
+ `"${table}" (or pass a fresh tableName) and re-add every fragment. Fragment vectors are ` +
439
+ `re-derivable from the records, so this costs embedding time, never data.`
440
+ );
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Rebuild the identity fields of a hit from a persisted row, omitting each field
446
+ * the stored fragment did not carry (so a hit is structurally identical to one this
447
+ * index produced before `fragment_id` existed).
448
+ *
449
+ * A row carrying neither identity violates the write-side invariant and could not
450
+ * be resolved by the caller, so it fails loudly instead of yielding an anonymous
451
+ * hit.
452
+ */
453
+ private static _toIdentity(row: IKnnRow, key: string): FragmentIdentity {
454
+ const locator: IFragmentLocator | undefined = SqliteVecFragmentIndex._toLocator(row, key);
455
+ if (locator === undefined && row.fragment_id === null) {
456
+ throw new Error(
457
+ `fragment '${key}': row carries neither a locator nor a fragment id (corrupt persisted data)`
458
+ );
459
+ }
460
+ return {
461
+ ...(locator !== undefined ? { locator } : {}),
462
+ ...(row.fragment_id !== null ? { fragmentId: row.fragment_id } : {})
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Rebuild a fragment's locator from its persisted offsets, or `undefined` when the
468
+ * fragment was stored without one (both offsets `NULL`).
469
+ *
470
+ * The pair is written all-or-nothing, so a half-`NULL` pair can only come from
471
+ * corrupt / externally-edited data. Throw rather than coerce — `Number(null)` is
472
+ * `0`, which would silently fabricate a span starting at the top of the body.
473
+ */
474
+ private static _toLocator(row: IKnnRow, key: string): IFragmentLocator | undefined {
475
+ const start: number | bigint | null = row.start_off;
476
+ const end: number | bigint | null = row.end_off;
477
+ if (start === null && end === null) {
478
+ return undefined;
479
+ }
480
+ if (start === null || end === null) {
481
+ throw new Error(
482
+ `fragment '${key}': locator has only one of its start/end offsets (corrupt persisted data)`
483
+ );
484
+ }
485
+ return {
486
+ start: SqliteVecFragmentIndex._toOffset(start, key),
487
+ end: SqliteVecFragmentIndex._toOffset(end, key)
488
+ };
330
489
  }
331
490
 
332
491
  /** Pack a `Float32Array` as the little-endian byte blob `vec0` stores. Copies, so the caller may reuse its buffer. */
@@ -6,6 +6,7 @@
6
6
  import '@fgv/ts-utils-jest';
7
7
 
8
8
  import BetterSqlite3 from 'better-sqlite3';
9
+ import { load as loadSqliteVec } from 'sqlite-vec';
9
10
  import * as fs from 'fs';
10
11
  import * as os from 'os';
11
12
  import * as path from 'path';
@@ -68,14 +69,77 @@ describe('SqliteVecFragmentIndex', () => {
68
69
  expect(await SqliteVecFragmentIndex.create({ database: closed })).toFailWith(/failed to initialize/i);
69
70
  });
70
71
 
71
- test('recovers no dimension from a pre-existing non-vec0 table of the same name', async () => {
72
+ test('rejects a pre-existing non-vec0 table of the same name rather than adopting it', async () => {
72
73
  db.exec('CREATE TABLE memory_fragments (foo TEXT)');
73
- expect(await SqliteVecFragmentIndex.create({ database: db })).toSucceedAndSatisfy(
74
- (index: SqliteVecFragmentIndex) => {
75
- expect(index.recordCount).toBe(0);
76
- }
74
+ expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(
75
+ /auxiliary columns \[\] but this index requires \[start_off, end_off, fragment_id\]/i
77
76
  );
78
77
  });
78
+
79
+ describe('auxiliary-column schema detection', () => {
80
+ // `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table and
81
+ // vec0 has no `ALTER TABLE ADD COLUMN`, so a database written by an earlier
82
+ // version keeps its narrower column set. Without detection the widened INSERT
83
+ // surfaces an opaque `no such column: fragment_id` at statement-prepare time.
84
+ function createLegacyTable(database: BetterSqlite3.Database): void {
85
+ loadSqliteVec(database);
86
+ database.exec(
87
+ 'CREATE VIRTUAL TABLE memory_fragments USING vec0(' +
88
+ 'target_key TEXT PARTITION KEY, embedding float[2] distance_metric=cosine, ' +
89
+ '+start_off integer, +end_off integer)'
90
+ );
91
+ }
92
+
93
+ test('fails with an actionable message naming expected and found columns', async () => {
94
+ createLegacyTable(db);
95
+ expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(
96
+ /auxiliary columns \[start_off, end_off\] but this index requires \[start_off, end_off, fragment_id\]/i
97
+ );
98
+ });
99
+
100
+ test('states that a drop-and-re-index is required and that no data is lost', async () => {
101
+ createLegacyTable(db);
102
+ expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(
103
+ /cannot be altered in place[\s\S]*drop-and-re-index[\s\S]*DROP TABLE "memory_fragments"[\s\S]*costs embedding time, never data/i
104
+ );
105
+ });
106
+
107
+ test('does not surface the opaque sqlite "no such column" error', async () => {
108
+ createLegacyTable(db);
109
+ const created = await SqliteVecFragmentIndex.create({ database: db });
110
+ expect(created).toFail();
111
+ expect(created.isFailure() && created.message).not.toMatch(/no such column/i);
112
+ });
113
+
114
+ test('accepts a table whose auxiliary columns match, regardless of declaration order', async () => {
115
+ loadSqliteVec(db);
116
+ db.exec(
117
+ 'CREATE VIRTUAL TABLE memory_fragments USING vec0(' +
118
+ 'target_key TEXT PARTITION KEY, embedding float[3] distance_metric=cosine, ' +
119
+ '+fragment_id text, +end_off integer, +start_off integer)'
120
+ );
121
+ expect(await SqliteVecFragmentIndex.create({ database: db })).toSucceed();
122
+ });
123
+
124
+ test('rejects a table that mimics the auxiliary columns but has no vec0 embedding column', async () => {
125
+ db.exec(
126
+ 'CREATE TABLE memory_fragments ("+start_off" integer, "+end_off" integer, "+fragment_id" text)'
127
+ );
128
+ expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(/no vec0 embedding column/i);
129
+ });
130
+
131
+ test('rejects a table with the right number of columns but a different one', async () => {
132
+ loadSqliteVec(db);
133
+ db.exec(
134
+ 'CREATE VIRTUAL TABLE memory_fragments USING vec0(' +
135
+ 'target_key TEXT PARTITION KEY, embedding float[2] distance_metric=cosine, ' +
136
+ '+start_off integer, +end_off integer, +frag_id text)'
137
+ );
138
+ expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(
139
+ /auxiliary columns \[start_off, end_off, frag_id\]/i
140
+ );
141
+ });
142
+ });
79
143
  });
80
144
 
81
145
  describe('addFragments', () => {
@@ -179,13 +243,11 @@ describe('SqliteVecFragmentIndex', () => {
179
243
 
180
244
  test('fails loudly (never silently corrupts) when the table name collides with a non-vec0 table', async () => {
181
245
  // A plain table already occupies the default name. `CREATE VIRTUAL TABLE IF NOT
182
- // EXISTS` no-ops against it, so preparing the vector INSERT against the missing
183
- // columns must fail loudly rather than corrupt state.
246
+ // EXISTS` no-ops against it, so the collision must fail loudly rather than
247
+ // corrupt state `create` now catches it up front via the auxiliary-column
248
+ // check, so the index is never handed out at all.
184
249
  db.exec('CREATE TABLE memory_fragments (foo TEXT)');
185
- const index = (await SqliteVecFragmentIndex.create({ database: db })).orThrow();
186
- expect(await index.addFragments(target('knowledge', 'doc-a'), [frag(0, 5, 1, 0)])).toFailWith(
187
- /cannot add 'knowledge\0doc-a'/i
188
- );
250
+ expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(/auxiliary columns/i);
189
251
  });
190
252
  });
191
253
 
@@ -366,6 +428,46 @@ describe('SqliteVecFragmentIndex', () => {
366
428
  ).run(key, toBlob(...vec), start, end);
367
429
  }
368
430
 
431
+ /** Insert a row with arbitrary (possibly NULL) identity columns, bypassing the write-side guards. */
432
+ function insertRawIdentity(
433
+ key: string,
434
+ start: bigint | null,
435
+ end: bigint | null,
436
+ fragmentId: string | null,
437
+ ...vec: number[]
438
+ ): void {
439
+ db.prepare(
440
+ 'INSERT INTO memory_fragments(target_key, embedding, start_off, end_off, fragment_id) ' +
441
+ 'VALUES (?, ?, ?, ?, ?)'
442
+ ).run(key, toBlob(...vec), start, end, fragmentId);
443
+ }
444
+
445
+ test('fails loudly when a stored locator has a start offset but no end offset', async () => {
446
+ // `Number(null)` is 0, so coercing a half-NULL pair would silently fabricate a
447
+ // span rather than surface the corruption.
448
+ const index = await seededWithRow();
449
+ insertRawIdentity('knowledge\0doc-b', BigInt(3), null, 'frag-1', 0, 1);
450
+ expect(await index.query(Float32Array.from([0, 1]), 5)).toFailWith(
451
+ /locator has only one of its start\/end offsets/i
452
+ );
453
+ });
454
+
455
+ test('fails loudly when a stored locator has an end offset but no start offset', async () => {
456
+ const index = await seededWithRow();
457
+ insertRawIdentity('knowledge\0doc-b', null, BigInt(9), 'frag-1', 0, 1);
458
+ expect(await index.query(Float32Array.from([0, 1]), 5)).toFailWith(
459
+ /locator has only one of its start\/end offsets/i
460
+ );
461
+ });
462
+
463
+ test('fails loudly when a stored row carries neither a locator nor a fragment id', async () => {
464
+ const index = await seededWithRow();
465
+ insertRawIdentity('knowledge\0doc-b', null, null, null, 0, 1);
466
+ expect(await index.query(Float32Array.from([0, 1]), 5)).toFailWith(
467
+ /carries neither a locator nor a fragment id/i
468
+ );
469
+ });
470
+
369
471
  test('coerces bigint offsets (better-sqlite3 safe-integer mode) to number locators', async () => {
370
472
  const index = await seededWithRow();
371
473
  // Under safe-integer mode every integer column comes back as a bigint.
@@ -398,6 +500,129 @@ describe('SqliteVecFragmentIndex', () => {
398
500
  });
399
501
  });
400
502
 
503
+ describe('fragment identity', () => {
504
+ /** A fragment identified only by an opaque id — no honest body span. */
505
+ function idFrag(fragmentId: string, ...values: number[]): IEmbeddedFragment {
506
+ return { fragmentId, vector: Float32Array.from(values) };
507
+ }
508
+
509
+ test('rejects a fragment carrying neither a locator nor a fragmentId', async () => {
510
+ const index = await makeIndex();
511
+ expect(
512
+ await index.addFragments(target('knowledge', 'doc-a'), [{ vector: Float32Array.from([1, 0]) }])
513
+ ).toFailWith(/at least one of 'locator' or 'fragmentId'/i);
514
+ expect(index.fragmentCount).toBe(0);
515
+ });
516
+
517
+ test('carries an opaque fragmentId through to the query hit verbatim', async () => {
518
+ const index = await makeIndex();
519
+ const opaque = 'urn:frag:9f8e::{not-parsed}';
520
+ (await index.addFragments(target('knowledge', 'doc-a'), [idFrag(opaque, 1, 0)])).orThrow();
521
+ expect(await index.query(Float32Array.from([1, 0]), 1)).toSucceedAndSatisfy(
522
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
523
+ expect(hits[0].fragmentId).toBe(opaque);
524
+ expect(hits[0].locator).toBeUndefined();
525
+ }
526
+ );
527
+ });
528
+
529
+ test('carries both identities when a fragment supplies both', async () => {
530
+ const index = await makeIndex();
531
+ (
532
+ await index.addFragments(target('knowledge', 'doc-a'), [
533
+ { ...frag(2, 8, 1, 0), fragmentId: 'frag-1' }
534
+ ])
535
+ ).orThrow();
536
+ expect(await index.query(Float32Array.from([1, 0]), 1)).toSucceedAndSatisfy(
537
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
538
+ expect(hits[0].locator).toEqual(loc(2, 8));
539
+ expect(hits[0].fragmentId).toBe('frag-1');
540
+ }
541
+ );
542
+ });
543
+
544
+ test('a locator-only fragment produces a hit with no fragmentId key at all', async () => {
545
+ // Byte-identical to what this index produced before `fragment_id` existed: the
546
+ // key is absent, not present-and-undefined, so an existing caller's structural
547
+ // comparisons are unaffected by the addition.
548
+ const index = await makeIndex();
549
+ (await index.addFragments(target('knowledge', 'doc-a'), [frag(0, 5, 1, 0)])).orThrow();
550
+ expect(await index.query(Float32Array.from([1, 0]), 1)).toSucceedAndSatisfy(
551
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
552
+ expect(Object.keys(hits[0]).sort()).toEqual(['locator', 'score', 'target']);
553
+ expect(hits[0]).toStrictEqual({
554
+ target: target('knowledge', 'doc-a'),
555
+ score: hits[0].score,
556
+ locator: loc(0, 5)
557
+ });
558
+ }
559
+ );
560
+ });
561
+
562
+ test('an id-only fragment produces a hit with no locator key at all', async () => {
563
+ const index = await makeIndex();
564
+ (await index.addFragments(target('knowledge', 'doc-a'), [idFrag('frag-1', 1, 0)])).orThrow();
565
+ expect(await index.query(Float32Array.from([1, 0]), 1)).toSucceedAndSatisfy(
566
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
567
+ expect(Object.keys(hits[0]).sort()).toEqual(['fragmentId', 'score', 'target']);
568
+ }
569
+ );
570
+ });
571
+
572
+ test('skips the safe-integer offset check for a fragment with no locator', async () => {
573
+ // The write-side offset validation must not fire (or throw on `BigInt(undefined)`)
574
+ // when there is no locator to validate.
575
+ const index = await makeIndex();
576
+ expect(await index.addFragments(target('knowledge', 'doc-a'), [idFrag('frag-1', 1, 0)])).toSucceedWith(
577
+ 1
578
+ );
579
+ });
580
+
581
+ test('still rejects a non-safe-integer offset when a locator IS supplied', async () => {
582
+ const index = await makeIndex();
583
+ expect(
584
+ await index.addFragments(target('knowledge', 'doc-a'), [
585
+ { locator: loc(0.5, 5), fragmentId: 'frag-1', vector: Float32Array.from([1, 0]) }
586
+ ])
587
+ ).toFailWith(/offsets must be safe integers/i);
588
+ });
589
+
590
+ test('round-trips every identity shape across a close + reopen', async () => {
591
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'svfragid-'));
592
+ const dbPath = path.join(dir, 'fragments.db');
593
+ const first = new BetterSqlite3(dbPath);
594
+ try {
595
+ const writeIndex = (await SqliteVecFragmentIndex.create({ database: first })).orThrow();
596
+ (
597
+ await writeIndex.addFragments(target('knowledge', 'doc-a'), [
598
+ frag(0, 5, 1, 0),
599
+ idFrag('frag-rewritten', 0, 1),
600
+ { ...frag(5, 9, 0.9, 0.1), fragmentId: 'frag-both' }
601
+ ])
602
+ ).orThrow();
603
+ } finally {
604
+ first.close();
605
+ }
606
+
607
+ const second = new BetterSqlite3(dbPath);
608
+ try {
609
+ const reopened = (await SqliteVecFragmentIndex.create({ database: second })).orThrow();
610
+ expect(reopened.fragmentCount).toBe(3);
611
+ expect(await reopened.query(Float32Array.from([1, 0]), 3)).toSucceedAndSatisfy(
612
+ (hits: ReadonlyArray<IVectorQueryHit>) => {
613
+ const byShape = new Map(hits.map((h) => [h.fragmentId ?? '<none>', h]));
614
+ expect(byShape.get('<none>')?.locator).toEqual(loc(0, 5));
615
+ expect(byShape.get('frag-rewritten')?.locator).toBeUndefined();
616
+ expect(byShape.get('frag-both')?.locator).toEqual(loc(5, 9));
617
+ }
618
+ );
619
+ } finally {
620
+ second.close();
621
+ fs.rmSync(dir, { recursive: true, force: true });
622
+ }
623
+ });
624
+ });
625
+
401
626
  describe('custom table name', () => {
402
627
  test('two fragment indexes on distinct tables in one database are independent', async () => {
403
628
  const a = (await SqliteVecFragmentIndex.create({ database: db, tableName: 'frag_a' })).orThrow();
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  [
13
13
  "packlets/sqlite-vec-index/sqliteVecFragmentIndex.ts",
14
- "88a8d56b74db20fe3fe2afbd23b47f5b244b53d8c62ea2ad8d824b842eb5e96d_B592XbdjJPfwmjGGtzeNtHae18Y="
14
+ "033c31d058e5ab9a24ba21bcd70971fe3d757071fd5019e030c9be1313110cd4_B592XbdjJPfwmjGGtzeNtHae18Y="
15
15
  ],
16
16
  [
17
17
  "packlets/sqlite-vec-index/index.ts",
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  [
25
25
  "test/unit/sqliteVecFragmentIndex.test.ts",
26
- "9a66a8ef6e5ee615b7efdaf537f36c9ec06161c36a047285da6fba5815cfccfa_tjT+aXFBH4Gc6UgYzDdGTpLJ+SY="
26
+ "f99104ddbd95b2673f19035335ee47a4117727d60cfb7a7578e2207f001e6a84_tjT+aXFBH4Gc6UgYzDdGTpLJ+SY="
27
27
  ],
28
28
  [
29
29
  "test/unit/sqliteVecVectorIndex.test.ts",