@gmickel/gno 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,13 +10,16 @@ import type { StoreResult } from "../store/types";
10
10
  import type {
11
11
  BacklogItem,
12
12
  VectorIndexPort,
13
- VectorRow,
14
13
  VectorStatsPort,
15
14
  } from "../store/vector";
16
15
 
17
- import { formatDocForEmbedding } from "../pipeline/contextual";
18
16
  import { err, ok } from "../store/types";
19
- import { embedTextsWithRecovery } from "./batch";
17
+ import { getEmbeddingFingerprint } from "./fingerprint";
18
+ import {
19
+ chunkRetryKey,
20
+ embedAndStoreBatch,
21
+ MAX_EMBED_CHUNK_ATTEMPTS,
22
+ } from "./retry";
20
23
 
21
24
  // ─────────────────────────────────────────────────────────────────────────────
22
25
  // Types
@@ -56,19 +59,86 @@ export async function embedBacklog(
56
59
  ): Promise<StoreResult<EmbedBacklogResult>> {
57
60
  const { statsPort, embedPort, vectorIndex, modelUri, collection } = deps;
58
61
  const batchSize = deps.batchSize ?? 32;
62
+ const embedFingerprint = getEmbeddingFingerprint({
63
+ modelUri,
64
+ dimensions: vectorIndex.dimensions,
65
+ });
59
66
 
60
67
  let embedded = 0;
61
68
  let errors = 0;
62
69
  let cursor: Cursor | undefined;
70
+ const retryQueue = new Map<string, { item: BacklogItem; attempts: number }>();
71
+
72
+ const enqueueRetryItems = (items: BacklogItem[], attempts: number): void => {
73
+ for (const item of items) {
74
+ const key = chunkRetryKey(item);
75
+ const existing = retryQueue.get(key);
76
+ retryQueue.set(key, {
77
+ item,
78
+ attempts: Math.max(existing?.attempts ?? 0, attempts),
79
+ });
80
+ }
81
+ };
82
+
83
+ const drainRetryQueue = async (): Promise<number> => {
84
+ if (retryQueue.size === 0) {
85
+ return 0;
86
+ }
87
+
88
+ let retryEmbedded = 0;
89
+ const entries = [...retryQueue.values()].filter(
90
+ (entry) => entry.attempts < MAX_EMBED_CHUNK_ATTEMPTS
91
+ );
92
+
93
+ for (let idx = 0; idx < entries.length; idx += batchSize) {
94
+ const slice = entries.slice(idx, idx + batchSize);
95
+ for (const entry of slice) {
96
+ retryQueue.delete(chunkRetryKey(entry.item));
97
+ entry.attempts += 1;
98
+ }
99
+
100
+ const retryResult = await embedAndStoreBatch({
101
+ embedPort,
102
+ vectorIndex,
103
+ items: slice.map((entry) => entry.item),
104
+ modelUri,
105
+ embedFingerprint,
106
+ });
107
+
108
+ embedded += retryResult.embedded;
109
+ errors += retryResult.errors;
110
+ retryEmbedded += retryResult.embedded;
111
+
112
+ const retryByKey = new Set(
113
+ retryResult.retryItems.map((item) => chunkRetryKey(item))
114
+ );
115
+ for (const entry of slice) {
116
+ if (!retryByKey.has(chunkRetryKey(entry.item))) {
117
+ continue;
118
+ }
119
+ if (entry.attempts >= MAX_EMBED_CHUNK_ATTEMPTS) {
120
+ errors += 1;
121
+ } else {
122
+ retryQueue.set(chunkRetryKey(entry.item), entry);
123
+ }
124
+ }
125
+ }
126
+
127
+ return retryEmbedded;
128
+ };
63
129
 
64
130
  try {
65
131
  while (true) {
66
132
  // Get next batch using seek pagination
67
- const batchResult = await statsPort.getBacklog(modelUri, {
68
- limit: batchSize,
69
- after: cursor,
70
- collection,
71
- });
133
+ const batchResult = await statsPort.getBacklog(
134
+ modelUri,
135
+ embedFingerprint,
136
+ {
137
+ limit: batchSize,
138
+ after: cursor,
139
+ collection,
140
+ }
141
+ );
72
142
 
73
143
  if (!batchResult.ok) {
74
144
  return err("QUERY_FAILED", batchResult.error.message);
@@ -85,48 +155,25 @@ export async function embedBacklog(
85
155
  cursor = { mirrorHash: lastItem.mirrorHash, seq: lastItem.seq };
86
156
  }
87
157
 
88
- // Embed batch with contextual formatting (title prefix)
89
- const embedResult = await embedTextsWithRecovery(
158
+ const beforeEmbedded = embedded;
159
+ const batchStoreResult = await embedAndStoreBatch({
90
160
  embedPort,
91
- batch.map((b: BacklogItem) =>
92
- formatDocForEmbedding(
93
- b.text,
94
- b.title ?? undefined,
95
- embedPort.modelUri
96
- )
97
- )
98
- );
99
-
100
- if (!embedResult.ok) {
101
- errors += batch.length;
102
- continue;
103
- }
104
-
105
- const vectors: VectorRow[] = [];
106
- for (const [idx, item] of batch.entries()) {
107
- const embedding = embedResult.value.vectors[idx];
108
- if (!embedding) {
109
- errors += 1;
110
- continue;
111
- }
112
- vectors.push({
113
- mirrorHash: item.mirrorHash,
114
- seq: item.seq,
115
- model: modelUri,
116
- embedding: new Float32Array(embedding),
117
- });
118
- }
161
+ vectorIndex,
162
+ items: batch,
163
+ modelUri,
164
+ embedFingerprint,
165
+ });
166
+ embedded += batchStoreResult.embedded;
167
+ errors += batchStoreResult.errors;
168
+ enqueueRetryItems(batchStoreResult.retryItems, 1);
119
169
 
120
- if (vectors.length > 0) {
121
- const storeResult = await vectorIndex.upsertVectors(vectors);
122
- if (!storeResult.ok) {
123
- errors += vectors.length;
124
- continue;
125
- }
126
- embedded += vectors.length;
170
+ if (embedded > beforeEmbedded) {
171
+ await drainRetryQueue();
127
172
  }
128
173
  }
129
174
 
175
+ await drainRetryQueue();
176
+
130
177
  // Sync vec index once at end if any vec0 writes failed
131
178
  let syncError: string | undefined;
132
179
  if (vectorIndex.vecDirty) {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Embedding freshness fingerprint.
3
+ *
4
+ * @module src/embed/fingerprint
5
+ */
6
+
7
+ import { getEmbeddingCompatibilityProfile } from "../llm/embedding-compatibility";
8
+
9
+ export const EMBEDDING_CONTEXTUAL_FORMAT_VERSION = "contextual-embedding-v1";
10
+ export const EMBEDDING_CHUNKING_STRATEGY_VERSION = "markdown-char-semantic-v1";
11
+
12
+ export interface EmbeddingFingerprintInput {
13
+ modelUri: string;
14
+ dimensions?: number;
15
+ }
16
+
17
+ export function getEmbeddingFingerprint(
18
+ input: EmbeddingFingerprintInput
19
+ ): string {
20
+ const profile = getEmbeddingCompatibilityProfile(input.modelUri);
21
+ const payload = {
22
+ chunking: EMBEDDING_CHUNKING_STRATEGY_VERSION,
23
+ contextualFormatting: EMBEDDING_CONTEXTUAL_FORMAT_VERSION,
24
+ dimensions: input.dimensions ?? null,
25
+ modelUri: input.modelUri,
26
+ profile: {
27
+ batchEmbeddingTrusted: profile.batchEmbeddingTrusted,
28
+ documentFormat: profile.documentFormat,
29
+ id: profile.id,
30
+ queryFormat: profile.queryFormat,
31
+ },
32
+ };
33
+
34
+ return new Bun.CryptoHasher("sha256")
35
+ .update(JSON.stringify(payload))
36
+ .digest("hex");
37
+ }
@@ -0,0 +1,137 @@
1
+ import type { EmbeddingPort } from "../llm/types";
2
+ import type { BacklogItem, VectorIndexPort, VectorRow } from "../store/vector";
3
+
4
+ import { formatDocForEmbedding } from "../pipeline/contextual";
5
+ import { embedTextsWithRecovery } from "./batch";
6
+
7
+ export const MAX_EMBED_CHUNK_ATTEMPTS = 2;
8
+ export const MAX_EMBED_FAILURE_SAMPLES = 5;
9
+
10
+ export interface EmbedStoreBatchResult {
11
+ embedded: number;
12
+ errors: number;
13
+ retryItems: BacklogItem[];
14
+ errorSamples: string[];
15
+ suggestion?: string;
16
+ batchFailed: boolean;
17
+ batchError?: string;
18
+ }
19
+
20
+ export function chunkRetryKey(item: Pick<BacklogItem, "mirrorHash" | "seq">) {
21
+ return `${item.mirrorHash}\0${item.seq}`;
22
+ }
23
+
24
+ export function addUniqueSamples(target: string[], samples: string[]): void {
25
+ for (const sample of samples) {
26
+ if (target.length >= MAX_EMBED_FAILURE_SAMPLES) {
27
+ break;
28
+ }
29
+ if (!target.includes(sample)) {
30
+ target.push(sample);
31
+ }
32
+ }
33
+ }
34
+
35
+ export function formatLlmFailure(
36
+ error: { message: string; cause?: unknown } | undefined
37
+ ): string {
38
+ if (!error) {
39
+ return "Unknown embedding failure";
40
+ }
41
+ const cause =
42
+ error.cause &&
43
+ typeof error.cause === "object" &&
44
+ "message" in error.cause &&
45
+ typeof error.cause.message === "string"
46
+ ? error.cause.message
47
+ : typeof error.cause === "string"
48
+ ? error.cause
49
+ : "";
50
+ return cause && cause !== error.message
51
+ ? `${error.message} - ${cause}`
52
+ : error.message;
53
+ }
54
+
55
+ export async function embedAndStoreBatch(params: {
56
+ embedPort: EmbeddingPort;
57
+ vectorIndex: VectorIndexPort;
58
+ items: BacklogItem[];
59
+ modelUri: string;
60
+ embedFingerprint: string;
61
+ }): Promise<EmbedStoreBatchResult> {
62
+ const { embedPort, vectorIndex, items, modelUri, embedFingerprint } = params;
63
+ const embedResult = await embedTextsWithRecovery(
64
+ embedPort,
65
+ items.map((item) =>
66
+ formatDocForEmbedding(item.text, item.title ?? undefined, modelUri)
67
+ )
68
+ );
69
+
70
+ if (!embedResult.ok) {
71
+ const formattedError = formatLlmFailure(embedResult.error);
72
+ return {
73
+ embedded: 0,
74
+ errors: embedResult.error.retryable ? 0 : items.length,
75
+ retryItems: embedResult.error.retryable ? items : [],
76
+ errorSamples: [formattedError],
77
+ suggestion: embedResult.error.retryable
78
+ ? "Try rerunning the same command. If failures persist, rerun with `gno --verbose embed --batch-size 1` to isolate failing chunks."
79
+ : embedResult.error.suggestion,
80
+ batchFailed: true,
81
+ batchError: formattedError,
82
+ };
83
+ }
84
+
85
+ const vectors: VectorRow[] = [];
86
+ const retryItems: BacklogItem[] = [];
87
+ for (const [idx, item] of items.entries()) {
88
+ const embedding = embedResult.value.vectors[idx];
89
+ if (!embedding) {
90
+ retryItems.push(item);
91
+ continue;
92
+ }
93
+ vectors.push({
94
+ mirrorHash: item.mirrorHash,
95
+ seq: item.seq,
96
+ model: modelUri,
97
+ embedFingerprint,
98
+ embedding: new Float32Array(embedding),
99
+ });
100
+ }
101
+
102
+ if (vectors.length === 0) {
103
+ return {
104
+ embedded: 0,
105
+ errors: 0,
106
+ retryItems,
107
+ errorSamples: embedResult.value.failureSamples,
108
+ suggestion: embedResult.value.retrySuggestion,
109
+ batchFailed: embedResult.value.batchFailed,
110
+ batchError: embedResult.value.batchError,
111
+ };
112
+ }
113
+
114
+ const storeResult = await vectorIndex.upsertVectors(vectors);
115
+ if (!storeResult.ok) {
116
+ return {
117
+ embedded: 0,
118
+ errors: vectors.length,
119
+ retryItems,
120
+ errorSamples: [storeResult.error.message],
121
+ suggestion:
122
+ "Store write failed. Rerun `gno embed` once more; if it repeats, run `gno doctor` and `gno vec sync`.",
123
+ batchFailed: embedResult.value.batchFailed,
124
+ batchError: embedResult.value.batchError,
125
+ };
126
+ }
127
+
128
+ return {
129
+ embedded: vectors.length,
130
+ errors: 0,
131
+ retryItems,
132
+ errorSamples: embedResult.value.failureSamples,
133
+ suggestion: embedResult.value.retrySuggestion,
134
+ batchFailed: embedResult.value.batchFailed,
135
+ batchError: embedResult.value.batchError,
136
+ };
137
+ }
@@ -456,7 +456,11 @@ export const queryInputSchema = z.object({
456
456
  noGraph: z
457
457
  .boolean()
458
458
  .optional()
459
- .describe("Disable bounded one-hop graph neighbor expansion"),
459
+ .describe("Compatibility no-op unless graph is also true"),
460
+ graph: z
461
+ .boolean()
462
+ .optional()
463
+ .describe("Enable bounded one-hop graph neighbor expansion"),
460
464
  tagsAll: z.array(z.string()).optional().describe("Require ALL of these tags"),
461
465
  tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"),
462
466
  });
@@ -51,6 +51,7 @@ interface QueryInput {
51
51
  expand?: boolean;
52
52
  rerank?: boolean;
53
53
  noGraph?: boolean;
54
+ graph?: boolean;
54
55
  tagsAll?: string[];
55
56
  tagsAny?: string[];
56
57
  }
@@ -272,6 +273,7 @@ export function handleQuery(
272
273
  author: args.author,
273
274
  noExpand,
274
275
  noRerank,
276
+ graph: args.graph === true,
275
277
  noGraph: args.noGraph || args.fast,
276
278
  queryModes,
277
279
  tagsAll: normalizeTagFilters(args.tagsAll),
@@ -554,7 +554,7 @@ export async function searchHybrid(
554
554
  includeSimilar: vectorAvailable,
555
555
  limit,
556
556
  candidateLimit,
557
- disabled: options.noGraph,
557
+ disabled: !options.graph || options.noGraph,
558
558
  lang: options.lang,
559
559
  tagsAll: options.tagsAll,
560
560
  tagsAny: options.tagsAny,
@@ -174,7 +174,9 @@ export type HybridSearchOptions = SearchOptions & {
174
174
  candidateLimit?: number;
175
175
  /** Enable explain output */
176
176
  explain?: boolean;
177
- /** Disable bounded one-hop graph candidate expansion */
177
+ /** Enable bounded one-hop graph candidate expansion */
178
+ graph?: boolean;
179
+ /** Compatibility no-op unless graph is also true */
178
180
  noGraph?: boolean;
179
181
  /** Language hint for prompt selection (does NOT filter retrieval, only affects expansion prompts) */
180
182
  queryLanguageHint?: string;
package/src/sdk/embed.ts CHANGED
@@ -19,15 +19,15 @@ import type {
19
19
  import type { GnoEmbedOptions, GnoEmbedResult } from "./types";
20
20
 
21
21
  import { embedBacklog } from "../embed";
22
- import { embedTextsWithRecovery } from "../embed/batch";
22
+ import { getEmbeddingFingerprint } from "../embed/fingerprint";
23
+ import {
24
+ chunkRetryKey,
25
+ embedAndStoreBatch,
26
+ MAX_EMBED_CHUNK_ATTEMPTS,
27
+ } from "../embed/retry";
23
28
  import { resolveModelUri } from "../llm/registry";
24
- import { formatDocForEmbedding } from "../pipeline/contextual";
25
29
  import { err, ok } from "../store/types";
26
- import {
27
- createVectorIndexPort,
28
- createVectorStatsPort,
29
- type VectorRow,
30
- } from "../store/vector";
30
+ import { createVectorIndexPort, createVectorStatsPort } from "../store/vector";
31
31
  import { sdkError } from "./errors";
32
32
 
33
33
  interface EmbedRuntimeOptions {
@@ -121,6 +121,68 @@ async function forceEmbedAll(
121
121
  let embedded = 0;
122
122
  let errors = 0;
123
123
  let cursor: { mirrorHash: string; seq: number } | undefined;
124
+ const retryQueue = new Map<string, { item: BacklogItem; attempts: number }>();
125
+ const embedFingerprint = getEmbeddingFingerprint({
126
+ modelUri,
127
+ dimensions: vectorIndex.dimensions,
128
+ });
129
+
130
+ const enqueueRetryItems = (items: BacklogItem[], attempts: number): void => {
131
+ for (const item of items) {
132
+ const key = chunkRetryKey(item);
133
+ const existing = retryQueue.get(key);
134
+ retryQueue.set(key, {
135
+ item,
136
+ attempts: Math.max(existing?.attempts ?? 0, attempts),
137
+ });
138
+ }
139
+ };
140
+
141
+ const drainRetryQueue = async (): Promise<number> => {
142
+ if (retryQueue.size === 0) {
143
+ return 0;
144
+ }
145
+
146
+ let retryEmbedded = 0;
147
+ const entries = [...retryQueue.values()].filter(
148
+ (entry) => entry.attempts < MAX_EMBED_CHUNK_ATTEMPTS
149
+ );
150
+
151
+ for (let idx = 0; idx < entries.length; idx += batchSize) {
152
+ const slice = entries.slice(idx, idx + batchSize);
153
+ for (const entry of slice) {
154
+ retryQueue.delete(chunkRetryKey(entry.item));
155
+ entry.attempts += 1;
156
+ }
157
+
158
+ const retryResult = await embedAndStoreBatch({
159
+ embedPort,
160
+ vectorIndex,
161
+ items: slice.map((entry) => entry.item),
162
+ modelUri,
163
+ embedFingerprint,
164
+ });
165
+ embedded += retryResult.embedded;
166
+ errors += retryResult.errors;
167
+ retryEmbedded += retryResult.embedded;
168
+
169
+ const retryByKey = new Set(
170
+ retryResult.retryItems.map((item) => chunkRetryKey(item))
171
+ );
172
+ for (const entry of slice) {
173
+ if (!retryByKey.has(chunkRetryKey(entry.item))) {
174
+ continue;
175
+ }
176
+ if (entry.attempts >= MAX_EMBED_CHUNK_ATTEMPTS) {
177
+ errors += 1;
178
+ } else {
179
+ retryQueue.set(chunkRetryKey(entry.item), entry);
180
+ }
181
+ }
182
+ }
183
+
184
+ return retryEmbedded;
185
+ };
124
186
 
125
187
  while (true) {
126
188
  const batchResult = await getActiveChunks(db, batchSize, cursor);
@@ -140,45 +202,27 @@ async function forceEmbedAll(
140
202
  cursor = { mirrorHash: lastItem.mirrorHash, seq: lastItem.seq };
141
203
  }
142
204
 
143
- const embedResult = await embedTextsWithRecovery(
205
+ const beforeEmbedded = embedded;
206
+ const embedResult = await embedAndStoreBatch({
144
207
  embedPort,
145
- batch.map((item) =>
146
- formatDocForEmbedding(
147
- item.text,
148
- item.title ?? undefined,
149
- embedPort.modelUri
150
- )
151
- )
152
- );
153
-
154
- if (!embedResult.ok) {
155
- errors += batch.length;
156
- continue;
157
- }
208
+ vectorIndex,
209
+ items: batch,
210
+ modelUri,
211
+ embedFingerprint,
212
+ });
213
+ embedded += embedResult.embedded;
214
+ errors += embedResult.errors;
215
+ enqueueRetryItems(embedResult.retryItems, 1);
158
216
 
159
- const vectors: VectorRow[] = [];
160
- for (const [idx, item] of batch.entries()) {
161
- const embedding = embedResult.value.vectors[idx];
162
- if (!embedding) {
163
- errors += 1;
164
- continue;
165
- }
166
- vectors.push({
167
- mirrorHash: item.mirrorHash,
168
- seq: item.seq,
169
- model: modelUri,
170
- embedding: new Float32Array(embedding),
171
- });
217
+ if (embedded > beforeEmbedded) {
218
+ await drainRetryQueue();
172
219
  }
220
+ }
173
221
 
174
- if (vectors.length > 0) {
175
- const storeResult = await vectorIndex.upsertVectors(vectors);
176
- if (!storeResult.ok) {
177
- errors += vectors.length;
178
- continue;
179
- }
180
- embedded += vectors.length;
181
- }
222
+ await drainRetryQueue();
223
+ if (retryQueue.size > 0) {
224
+ errors += retryQueue.size;
225
+ retryQueue.clear();
182
226
  }
183
227
 
184
228
  if (vectorIndex.vecDirty) {
@@ -217,24 +261,25 @@ export async function runEmbed(
217
261
  const db = runtime.store.getRawDb();
218
262
  const stats: VectorStatsPort = createVectorStatsPort(db);
219
263
 
220
- const backlogResult = force
221
- ? await getActiveChunkCount(db)
222
- : await stats.countBacklog(modelUri, { collection: options.collection });
223
- if (!backlogResult.ok) {
224
- throw sdkError("STORE", backlogResult.error.message, {
225
- cause: backlogResult.error.cause,
226
- });
227
- }
264
+ let totalToEmbed = 0;
265
+ if (force) {
266
+ const forceCount = await getActiveChunkCount(db);
267
+ if (!forceCount.ok) {
268
+ throw sdkError("STORE", forceCount.error.message, {
269
+ cause: forceCount.error.cause,
270
+ });
271
+ }
228
272
 
229
- const totalToEmbed = backlogResult.value;
230
- if (totalToEmbed === 0 || dryRun) {
231
- return {
232
- embedded: totalToEmbed,
233
- errors: 0,
234
- duration: 0,
235
- model: modelUri,
236
- searchAvailable: await checkVecAvailable(db),
237
- };
273
+ totalToEmbed = forceCount.value;
274
+ if (totalToEmbed === 0 || dryRun) {
275
+ return {
276
+ embedded: totalToEmbed,
277
+ errors: 0,
278
+ duration: 0,
279
+ model: modelUri,
280
+ searchAvailable: await checkVecAvailable(db),
281
+ };
282
+ }
238
283
  }
239
284
 
240
285
  const embedResult = await runtime.llm.createEmbeddingPort(modelUri, {
@@ -266,6 +311,36 @@ export async function runEmbed(
266
311
  }
267
312
 
268
313
  const vectorIndex = vectorResult.value;
314
+ if (!force) {
315
+ const embedFingerprint = getEmbeddingFingerprint({
316
+ modelUri,
317
+ dimensions: vectorIndex.dimensions,
318
+ });
319
+ const backlogResult = await stats.countBacklog(
320
+ modelUri,
321
+ embedFingerprint,
322
+ {
323
+ collection: options.collection,
324
+ }
325
+ );
326
+ if (!backlogResult.ok) {
327
+ throw sdkError("STORE", backlogResult.error.message, {
328
+ cause: backlogResult.error.cause,
329
+ });
330
+ }
331
+
332
+ totalToEmbed = backlogResult.value;
333
+ if (totalToEmbed === 0 || dryRun) {
334
+ return {
335
+ embedded: totalToEmbed,
336
+ errors: 0,
337
+ duration: 0,
338
+ model: modelUri,
339
+ searchAvailable: vectorIndex.searchAvailable,
340
+ };
341
+ }
342
+ }
343
+
269
344
  const startedAt = Date.now();
270
345
  let result: { embedded: number; errors: number };
271
346
  if (force) {
@@ -173,6 +173,7 @@ export interface QueryRequestBody {
173
173
  noExpand?: boolean;
174
174
  noRerank?: boolean;
175
175
  noGraph?: boolean;
176
+ graph?: boolean;
176
177
  /** Comma-separated tags - filter to docs having ALL (AND) */
177
178
  tagsAll?: string;
178
179
  /** Comma-separated tags - filter to docs having ANY (OR) */
@@ -3311,6 +3312,7 @@ export async function handleQuery(
3311
3312
  queryModes: normalizedQueryModes,
3312
3313
  noExpand: body.noExpand,
3313
3314
  noRerank: body.noRerank,
3315
+ graph: body.graph === true,
3314
3316
  noGraph: body.noGraph,
3315
3317
  tagsAll,
3316
3318
  tagsAny,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Migration: vector embedding freshness fingerprints.
3
+ *
4
+ * @module src/store/migrations/008-vector-fingerprints
5
+ */
6
+
7
+ import type { Database } from "bun:sqlite";
8
+
9
+ import type { Migration } from "./runner";
10
+
11
+ export const migration: Migration = {
12
+ version: 8,
13
+ name: "vector_fingerprints",
14
+
15
+ up(db: Database): void {
16
+ db.exec(`
17
+ ALTER TABLE content_vectors ADD COLUMN embed_fingerprint TEXT NOT NULL DEFAULT ''
18
+ `);
19
+
20
+ db.exec(`
21
+ CREATE INDEX IF NOT EXISTS idx_vectors_freshness
22
+ ON content_vectors(model, embed_fingerprint, mirror_hash, seq, embedded_at)
23
+ `);
24
+ },
25
+ };
@@ -21,6 +21,7 @@ import { migration as m004 } from "./004-doc-links";
21
21
  import { migration as m005 } from "./005-graph-indexes";
22
22
  import { migration as m006 } from "./006-document-metadata";
23
23
  import { migration as m007 } from "./007-document-date-fields";
24
+ import { migration as m008 } from "./008-vector-fingerprints";
24
25
 
25
26
  /** All migrations in order */
26
- export const migrations = [m001, m002, m003, m004, m005, m006, m007];
27
+ export const migrations = [m001, m002, m003, m004, m005, m006, m007, m008];