@cosmocoder/mcp-web-docs 2.0.9 → 2.0.11
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/build/index.js +8 -16
- package/build/index.js.map +1 -1
- package/build/storage/storage.d.ts +41 -10
- package/build/storage/storage.js +625 -177
- package/build/storage/storage.js.map +1 -1
- package/build/storage/storage.test.js +877 -2
- package/build/storage/storage.test.js.map +1 -1
- package/build/types.d.ts +6 -1
- package/package.json +1 -1
package/build/storage/storage.js
CHANGED
|
@@ -2,12 +2,20 @@ import sqlite3 from 'sqlite3';
|
|
|
2
2
|
import { open } from 'sqlite';
|
|
3
3
|
import * as lancedb from '@lancedb/lancedb';
|
|
4
4
|
import { PhraseQuery, MatchQuery, BooleanQuery, Occur } from '@lancedb/lancedb';
|
|
5
|
-
import { Field, FixedSizeList, Float32, Schema, Utf8, Int32 } from 'apache-arrow';
|
|
5
|
+
import { Bool, Field, FixedSizeList, Float32, Schema, Utf8, Int32 } from 'apache-arrow';
|
|
6
6
|
import QuickLRU from 'quick-lru';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
9
|
import { mkdir } from 'fs/promises';
|
|
8
10
|
import { dirname } from 'path';
|
|
9
11
|
import { logger } from '../util/logger.js';
|
|
10
12
|
import { escapeFilterValue } from '../util/security.js';
|
|
13
|
+
const SEARCH_VISIBILITY_RETRY_WINDOW_MS = 2_000;
|
|
14
|
+
const SEARCH_VISIBILITY_RETRY_MAX_BACKOFF_MS = 25;
|
|
15
|
+
// Long enough for normal Lance writes; renewed while staging is in flight.
|
|
16
|
+
const REPLACEMENT_LEASE_MS = 30_000;
|
|
17
|
+
const REPLACEMENT_HEARTBEAT_MS = 10_000;
|
|
18
|
+
const REPLACEMENT_LEASE_POLL_MS = 25;
|
|
11
19
|
/**
|
|
12
20
|
* Preprocesses a search query - keeps it generic for any documentation type.
|
|
13
21
|
* Only extracts explicitly quoted phrases, otherwise passes through to LanceDB's
|
|
@@ -35,10 +43,14 @@ export class DocumentStore {
|
|
|
35
43
|
vectorDbPath;
|
|
36
44
|
embeddings;
|
|
37
45
|
sqliteDb;
|
|
46
|
+
sqliteReadDb;
|
|
47
|
+
sqliteLeaseDb;
|
|
38
48
|
lanceConn;
|
|
39
49
|
lanceTable;
|
|
40
50
|
searchCache;
|
|
41
51
|
ftsIndexCreated = false;
|
|
52
|
+
// ponytail: one per-store FIFO; split only if mutation throughput becomes a measured bottleneck.
|
|
53
|
+
mutationTail = Promise.resolve();
|
|
42
54
|
constructor(dbPath, vectorDbPath, embeddings, maxCacheSize = 1000) {
|
|
43
55
|
this.dbPath = dbPath;
|
|
44
56
|
this.vectorDbPath = vectorDbPath;
|
|
@@ -50,6 +62,28 @@ export class DocumentStore {
|
|
|
50
62
|
});
|
|
51
63
|
this.searchCache = new QuickLRU({ maxSize: maxCacheSize });
|
|
52
64
|
}
|
|
65
|
+
runMutation(mutation, signal) {
|
|
66
|
+
if (signal?.aborted) {
|
|
67
|
+
return Promise.reject(signal.reason);
|
|
68
|
+
}
|
|
69
|
+
let onAbort;
|
|
70
|
+
const result = this.mutationTail.then(() => {
|
|
71
|
+
if (onAbort) {
|
|
72
|
+
signal?.removeEventListener('abort', onAbort);
|
|
73
|
+
}
|
|
74
|
+
signal?.throwIfAborted();
|
|
75
|
+
return mutation();
|
|
76
|
+
});
|
|
77
|
+
this.mutationTail = result.then(() => undefined, () => undefined);
|
|
78
|
+
if (!signal) {
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
onAbort = () => reject(signal.reason);
|
|
83
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
84
|
+
result.then(resolve, reject);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
53
87
|
async initialize() {
|
|
54
88
|
logger.debug(`[DocumentStore] Starting initialization with paths:`, {
|
|
55
89
|
dbPath: this.dbPath,
|
|
@@ -95,6 +129,20 @@ export class DocumentStore {
|
|
|
95
129
|
`);
|
|
96
130
|
// Run database migrations
|
|
97
131
|
await this.runMigrations();
|
|
132
|
+
// Reads use a separate connection so they never observe this instance's
|
|
133
|
+
// uncommitted publication transaction.
|
|
134
|
+
this.sqliteReadDb = await open({
|
|
135
|
+
filename: this.dbPath,
|
|
136
|
+
driver: sqlite3.Database,
|
|
137
|
+
});
|
|
138
|
+
await this.sqliteReadDb.exec('PRAGMA busy_timeout = 5000;');
|
|
139
|
+
// Lease renewal uses its own writer so slow Lance staging cannot block
|
|
140
|
+
// heartbeats behind this instance's publication connection.
|
|
141
|
+
this.sqliteLeaseDb = await open({
|
|
142
|
+
filename: this.dbPath,
|
|
143
|
+
driver: sqlite3.Database,
|
|
144
|
+
});
|
|
145
|
+
await this.sqliteLeaseDb.exec('PRAGMA busy_timeout = 5000;');
|
|
98
146
|
// Initialize LanceDB with error handling
|
|
99
147
|
try {
|
|
100
148
|
logger.debug(`[DocumentStore] Connecting to LanceDB at ${this.vectorDbPath}`);
|
|
@@ -108,6 +156,8 @@ export class DocumentStore {
|
|
|
108
156
|
// Define schema using Apache Arrow
|
|
109
157
|
const vectorType = new FixedSizeList(this.embeddings.dimensions, new Field('item', new Float32(), true));
|
|
110
158
|
const schema = new Schema([
|
|
159
|
+
new Field('generation', new Utf8(), false),
|
|
160
|
+
new Field('published', new Bool(), false),
|
|
111
161
|
new Field('url', new Utf8(), false),
|
|
112
162
|
new Field('title', new Utf8(), false),
|
|
113
163
|
new Field('content', new Utf8(), false),
|
|
@@ -127,15 +177,17 @@ export class DocumentStore {
|
|
|
127
177
|
// Create empty table with schema
|
|
128
178
|
this.lanceTable = await this.lanceConn.createEmptyTable('chunks', schema, { mode: 'create' });
|
|
129
179
|
logger.debug(`[DocumentStore] New chunks table created successfully`);
|
|
130
|
-
// Create FTS index for better text search
|
|
131
|
-
await this.createFTSIndex();
|
|
132
180
|
}
|
|
133
181
|
else {
|
|
134
182
|
logger.debug(`[DocumentStore] Using existing chunks table`);
|
|
135
183
|
this.lanceTable = await this.lanceConn.openTable('chunks');
|
|
136
|
-
//
|
|
137
|
-
await this.
|
|
184
|
+
// Tables created before replacement journaling need a generation marker.
|
|
185
|
+
await this.ensureLanceColumn('generation', "'legacy'");
|
|
186
|
+
await this.ensureLanceColumn('published', 'true');
|
|
138
187
|
}
|
|
188
|
+
await this.recoverDocumentReplacements();
|
|
189
|
+
await this.reapOrphanedUnpublishedGenerations();
|
|
190
|
+
await this.createFTSIndex();
|
|
139
191
|
// Verify table is accessible
|
|
140
192
|
const rowCount = await this.lanceTable.countRows();
|
|
141
193
|
logger.debug(`[DocumentStore] Chunks table initialized, contains ${rowCount} rows`);
|
|
@@ -148,9 +200,58 @@ export class DocumentStore {
|
|
|
148
200
|
}
|
|
149
201
|
catch (error) {
|
|
150
202
|
logger.error('[DocumentStore] Error initializing storage:', error);
|
|
203
|
+
try {
|
|
204
|
+
await this.close();
|
|
205
|
+
}
|
|
206
|
+
catch (cleanupError) {
|
|
207
|
+
logger.warn('[DocumentStore] Storage cleanup after initialization failure was incomplete:', cleanupError);
|
|
208
|
+
}
|
|
151
209
|
throw error;
|
|
152
210
|
}
|
|
153
211
|
}
|
|
212
|
+
async close() {
|
|
213
|
+
const errors = [];
|
|
214
|
+
const closeResource = async (name, resource, clear) => {
|
|
215
|
+
if (!resource) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
await resource.close();
|
|
220
|
+
clear();
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
errors.push(error);
|
|
224
|
+
logger.warn(`[DocumentStore] Failed to close ${name}:`, error);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
await closeResource('LanceDB table', this.lanceTable, () => (this.lanceTable = undefined));
|
|
228
|
+
await closeResource('LanceDB connection', this.lanceConn, () => (this.lanceConn = undefined));
|
|
229
|
+
await closeResource('SQLite reader', this.sqliteReadDb, () => (this.sqliteReadDb = undefined));
|
|
230
|
+
await closeResource('SQLite lease writer', this.sqliteLeaseDb, () => (this.sqliteLeaseDb = undefined));
|
|
231
|
+
await closeResource('SQLite writer', this.sqliteDb, () => (this.sqliteDb = undefined));
|
|
232
|
+
if (errors.length > 0) {
|
|
233
|
+
throw new AggregateError(errors, 'Failed to close one or more storage resources');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async ensureLanceColumn(name, valueSql) {
|
|
237
|
+
if (!this.lanceTable) {
|
|
238
|
+
throw new Error('Storage not initialized');
|
|
239
|
+
}
|
|
240
|
+
const schema = await this.lanceTable.schema();
|
|
241
|
+
if (schema.fields.some((field) => field.name === name)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
await this.lanceTable.addColumns([{ name, valueSql }]);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
await this.lanceTable.checkoutLatest();
|
|
249
|
+
const refreshedSchema = await this.lanceTable.schema();
|
|
250
|
+
if (!refreshedSchema.fields.some((field) => field.name === name)) {
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
154
255
|
/**
|
|
155
256
|
* Database migrations list.
|
|
156
257
|
* Each migration has a unique version number and SQL statements to execute.
|
|
@@ -213,6 +314,20 @@ export class DocumentStore {
|
|
|
213
314
|
CREATE INDEX IF NOT EXISTS idx_collection_documents_url ON collection_documents(url);
|
|
214
315
|
`,
|
|
215
316
|
},
|
|
317
|
+
{
|
|
318
|
+
version: 5,
|
|
319
|
+
description: 'Add document replacement journal',
|
|
320
|
+
sql: `
|
|
321
|
+
CREATE TABLE IF NOT EXISTS document_replacements (
|
|
322
|
+
url TEXT PRIMARY KEY,
|
|
323
|
+
generation TEXT NOT NULL,
|
|
324
|
+
state TEXT NOT NULL CHECK (state IN ('prepared', 'published', 'deleting')),
|
|
325
|
+
owner_id TEXT NOT NULL,
|
|
326
|
+
lease_expires_at INTEGER NOT NULL,
|
|
327
|
+
cleanup_generations TEXT NOT NULL
|
|
328
|
+
);
|
|
329
|
+
`,
|
|
330
|
+
},
|
|
216
331
|
];
|
|
217
332
|
/**
|
|
218
333
|
* Run pending database migrations.
|
|
@@ -264,7 +379,7 @@ export class DocumentStore {
|
|
|
264
379
|
}
|
|
265
380
|
}
|
|
266
381
|
// Record successful migration
|
|
267
|
-
await this.sqliteDb.run('INSERT INTO schema_migrations (version, applied_at, description) VALUES (?, ?, ?)', [
|
|
382
|
+
await this.sqliteDb.run('INSERT OR IGNORE INTO schema_migrations (version, applied_at, description) VALUES (?, ?, ?)', [
|
|
268
383
|
migration.version,
|
|
269
384
|
new Date().toISOString(),
|
|
270
385
|
migration.description,
|
|
@@ -277,87 +392,380 @@ export class DocumentStore {
|
|
|
277
392
|
}
|
|
278
393
|
}
|
|
279
394
|
}
|
|
280
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Stage a complete generation in LanceDB, then publish it through SQLite.
|
|
397
|
+
* Search only reads the published generation, so failed staging never leaks.
|
|
398
|
+
*/
|
|
399
|
+
addDocument(doc, options = {}) {
|
|
400
|
+
return this.runMutation(() => this.addDocumentUnlocked(doc, options), options.signal);
|
|
401
|
+
}
|
|
402
|
+
async addDocumentUnlocked(doc, options) {
|
|
403
|
+
const { signal, tags } = options;
|
|
281
404
|
logger.debug(`[DocumentStore] Starting addDocument for:`, {
|
|
282
405
|
url: doc.metadata.url,
|
|
283
406
|
title: doc.metadata.title,
|
|
284
407
|
chunks: doc.chunks.length,
|
|
285
408
|
});
|
|
286
|
-
// Add diagnostic logging for vector dimensions
|
|
287
409
|
if (doc.chunks.length > 0) {
|
|
288
410
|
logger.debug(`[DocumentStore] Sample vector dimensions: ${doc.chunks[0].vector.length}`);
|
|
289
411
|
logger.debug(`[DocumentStore] Sample vector first 5 values: ${doc.chunks[0].vector.slice(0, 5)}`);
|
|
290
412
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
throw new Error('
|
|
295
|
-
}
|
|
296
|
-
if (!this.lanceTable) {
|
|
297
|
-
logger.debug('[DocumentStore] LanceDB not initialized during addDocument');
|
|
298
|
-
throw new Error('LanceDB storage not initialized');
|
|
413
|
+
const sqliteDb = this.sqliteDb;
|
|
414
|
+
const sqliteReadDb = this.sqliteReadDb;
|
|
415
|
+
if (!sqliteDb || !sqliteReadDb || !this.sqliteLeaseDb || !this.lanceTable) {
|
|
416
|
+
throw new Error('Storage not initialized');
|
|
299
417
|
}
|
|
418
|
+
const url = doc.metadata.url;
|
|
419
|
+
const generation = randomUUID();
|
|
420
|
+
const ownerId = randomUUID();
|
|
421
|
+
const newRows = this.toLanceRows(doc, generation);
|
|
422
|
+
const lease = await this.acquireReplacementLease(url, generation, ownerId, signal);
|
|
423
|
+
let stopHeartbeat = () => Promise.resolve();
|
|
424
|
+
let transactionStarted = false;
|
|
300
425
|
try {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
426
|
+
const preparedJournal = await this.captureCleanupGenerations(lease);
|
|
427
|
+
stopHeartbeat = this.startReplacementHeartbeat(preparedJournal);
|
|
428
|
+
// The committed journal advances reader data_version before staged rows
|
|
429
|
+
// can become queryable.
|
|
430
|
+
signal?.throwIfAborted();
|
|
431
|
+
if (newRows.length > 0) {
|
|
432
|
+
await this.lanceTable.add(newRows);
|
|
305
433
|
}
|
|
306
|
-
|
|
307
|
-
await this.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// Serialize code blocks and props as JSON strings
|
|
337
|
-
codeBlocks: JSON.stringify(chunk.metadata.codeBlocks || []),
|
|
338
|
-
props: JSON.stringify(chunk.metadata.props || []),
|
|
339
|
-
}));
|
|
340
|
-
logger.debug(`[DocumentStore] Adding ${rows.length} chunks to LanceDB`);
|
|
341
|
-
await this.lanceTable.add(rows);
|
|
342
|
-
// Verify data was added
|
|
343
|
-
const rowCount = await this.lanceTable.countRows();
|
|
344
|
-
logger.debug(`[DocumentStore] Table now contains ${rowCount} rows`);
|
|
345
|
-
// Commit transaction
|
|
346
|
-
await this.sqliteDb.run('COMMIT');
|
|
347
|
-
logger.debug(`[DocumentStore] Committed transaction`);
|
|
348
|
-
// Clear search cache for this URL
|
|
349
|
-
this.clearCacheForUrl(doc.metadata.url);
|
|
434
|
+
await this.renewReplacementLease(preparedJournal);
|
|
435
|
+
await this.lanceTable.update({
|
|
436
|
+
where: `url = '${escapeFilterValue(url)}' AND generation = '${escapeFilterValue(generation)}'`,
|
|
437
|
+
values: { published: true },
|
|
438
|
+
});
|
|
439
|
+
await stopHeartbeat();
|
|
440
|
+
await this.renewReplacementLease(preparedJournal);
|
|
441
|
+
await sqliteDb.run('BEGIN TRANSACTION');
|
|
442
|
+
transactionStarted = true;
|
|
443
|
+
signal?.throwIfAborted();
|
|
444
|
+
await this.upsertMetadata(doc.metadata);
|
|
445
|
+
if (tags !== undefined) {
|
|
446
|
+
await this.replaceDocumentTags(url, tags);
|
|
447
|
+
}
|
|
448
|
+
signal?.throwIfAborted();
|
|
449
|
+
const publicationLeaseExpiresAt = Date.now() + REPLACEMENT_LEASE_MS;
|
|
450
|
+
const publication = await sqliteDb.run(`UPDATE document_replacements
|
|
451
|
+
SET state = 'published', lease_expires_at = ?
|
|
452
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = 'prepared'`, [publicationLeaseExpiresAt, url, generation, ownerId]);
|
|
453
|
+
if (publication.changes !== 1) {
|
|
454
|
+
throw new Error(`Replacement lease lost for ${url}`);
|
|
455
|
+
}
|
|
456
|
+
signal?.throwIfAborted();
|
|
457
|
+
await sqliteDb.run('COMMIT');
|
|
458
|
+
transactionStarted = false;
|
|
459
|
+
await this.finishDocumentReplacementBestEffort({
|
|
460
|
+
...preparedJournal,
|
|
461
|
+
state: 'published',
|
|
462
|
+
lease_expires_at: publicationLeaseExpiresAt,
|
|
463
|
+
});
|
|
350
464
|
}
|
|
351
465
|
catch (error) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
466
|
+
await stopHeartbeat().catch((heartbeatError) => {
|
|
467
|
+
logger.warn(`[DocumentStore] Replacement heartbeat stopped with an error for ${url}:`, heartbeatError);
|
|
468
|
+
});
|
|
469
|
+
if (transactionStarted) {
|
|
470
|
+
try {
|
|
471
|
+
await sqliteDb.run('ROLLBACK');
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// The transaction may already have been rolled back by SQLite.
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
let durableJournal;
|
|
478
|
+
try {
|
|
479
|
+
durableJournal = await sqliteReadDb.get('SELECT * FROM document_replacements WHERE url = ? AND generation = ? AND owner_id = ?', [url, generation, ownerId]);
|
|
480
|
+
}
|
|
481
|
+
catch (journalError) {
|
|
482
|
+
logger.error(`[DocumentStore] Could not determine durable replacement state for ${url}:`, journalError);
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
if (durableJournal) {
|
|
486
|
+
await this.finishDocumentReplacementBestEffort(durableJournal);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
await this.deleteUnpublishedGenerationBestEffort(url, generation);
|
|
355
490
|
}
|
|
356
491
|
logger.error('[DocumentStore] Error adding document:', error);
|
|
357
492
|
throw error;
|
|
358
493
|
}
|
|
359
494
|
}
|
|
495
|
+
async acquireReplacementLease(url, generation, ownerId, signal) {
|
|
496
|
+
if (!this.sqliteLeaseDb) {
|
|
497
|
+
throw new Error('Storage not initialized');
|
|
498
|
+
}
|
|
499
|
+
while (true) {
|
|
500
|
+
signal?.throwIfAborted();
|
|
501
|
+
const now = Date.now();
|
|
502
|
+
const leaseExpiresAt = now + REPLACEMENT_LEASE_MS;
|
|
503
|
+
const insertion = await this.sqliteLeaseDb.run(`INSERT OR IGNORE INTO document_replacements
|
|
504
|
+
(url, generation, state, owner_id, lease_expires_at, cleanup_generations)
|
|
505
|
+
VALUES (?, ?, 'prepared', ?, ?, '[]')`, [url, generation, ownerId, leaseExpiresAt]);
|
|
506
|
+
if (insertion.changes === 1) {
|
|
507
|
+
return { url, generation, state: 'prepared', owner_id: ownerId, lease_expires_at: leaseExpiresAt, cleanup_generations: '[]' };
|
|
508
|
+
}
|
|
509
|
+
const existing = await this.sqliteLeaseDb.get('SELECT * FROM document_replacements WHERE url = ?', [url]);
|
|
510
|
+
if (!existing) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (existing.lease_expires_at <= now) {
|
|
514
|
+
const claim = await this.sqliteLeaseDb.run(`UPDATE document_replacements
|
|
515
|
+
SET owner_id = ?, lease_expires_at = ?
|
|
516
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = ? AND lease_expires_at <= ?`, [ownerId, leaseExpiresAt, url, existing.generation, existing.owner_id, existing.state, now]);
|
|
517
|
+
if (claim.changes === 1) {
|
|
518
|
+
await this.finishDocumentReplacement({ ...existing, owner_id: ownerId, lease_expires_at: leaseExpiresAt });
|
|
519
|
+
}
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
await delay(Math.max(1, Math.min(REPLACEMENT_LEASE_POLL_MS, existing.lease_expires_at - now)), undefined, { signal });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async captureCleanupGenerations(journal) {
|
|
526
|
+
if (!this.lanceTable || !this.sqliteLeaseDb) {
|
|
527
|
+
throw new Error('Storage not initialized');
|
|
528
|
+
}
|
|
529
|
+
await this.lanceTable.checkoutLatest();
|
|
530
|
+
const rows = await this.lanceTable
|
|
531
|
+
.query()
|
|
532
|
+
.where(`url = '${escapeFilterValue(journal.url)}'`)
|
|
533
|
+
.select(['generation'])
|
|
534
|
+
.toArray();
|
|
535
|
+
const cleanupGenerations = JSON.stringify([...new Set(rows.map((row) => String(row.generation)))].sort());
|
|
536
|
+
const leaseExpiresAt = Date.now() + REPLACEMENT_LEASE_MS;
|
|
537
|
+
const update = await this.sqliteLeaseDb.run(`UPDATE document_replacements
|
|
538
|
+
SET cleanup_generations = ?, lease_expires_at = ?
|
|
539
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = 'prepared'`, [cleanupGenerations, leaseExpiresAt, journal.url, journal.generation, journal.owner_id]);
|
|
540
|
+
if (update.changes !== 1) {
|
|
541
|
+
throw new Error(`Replacement lease lost for ${journal.url}`);
|
|
542
|
+
}
|
|
543
|
+
return { ...journal, cleanup_generations: cleanupGenerations, lease_expires_at: leaseExpiresAt };
|
|
544
|
+
}
|
|
545
|
+
startReplacementHeartbeat(journal) {
|
|
546
|
+
let stopped = false;
|
|
547
|
+
let timer;
|
|
548
|
+
let renewal = Promise.resolve();
|
|
549
|
+
let renewalError;
|
|
550
|
+
const schedule = () => {
|
|
551
|
+
timer = setTimeout(() => {
|
|
552
|
+
renewal = this.renewReplacementLease(journal)
|
|
553
|
+
.catch((error) => {
|
|
554
|
+
renewalError = error;
|
|
555
|
+
})
|
|
556
|
+
.finally(() => {
|
|
557
|
+
if (!stopped && !renewalError) {
|
|
558
|
+
schedule();
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
}, REPLACEMENT_HEARTBEAT_MS);
|
|
562
|
+
timer.unref();
|
|
563
|
+
};
|
|
564
|
+
schedule();
|
|
565
|
+
return async () => {
|
|
566
|
+
stopped = true;
|
|
567
|
+
if (timer) {
|
|
568
|
+
clearTimeout(timer);
|
|
569
|
+
}
|
|
570
|
+
await renewal;
|
|
571
|
+
if (renewalError) {
|
|
572
|
+
throw renewalError;
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
async renewReplacementLease(journal) {
|
|
577
|
+
if (!this.sqliteLeaseDb) {
|
|
578
|
+
throw new Error('Storage not initialized');
|
|
579
|
+
}
|
|
580
|
+
const renewal = await this.sqliteLeaseDb.run(`UPDATE document_replacements
|
|
581
|
+
SET lease_expires_at = ?
|
|
582
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = ?`, [Date.now() + REPLACEMENT_LEASE_MS, journal.url, journal.generation, journal.owner_id, journal.state]);
|
|
583
|
+
if (renewal.changes !== 1) {
|
|
584
|
+
throw new Error(`Replacement lease lost for ${journal.url}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
toLanceRows(doc, generation) {
|
|
588
|
+
const lastUpdated = new Date().toISOString();
|
|
589
|
+
return doc.chunks.map((chunk) => ({
|
|
590
|
+
generation,
|
|
591
|
+
published: false,
|
|
592
|
+
url: doc.metadata.url,
|
|
593
|
+
title: doc.metadata.title,
|
|
594
|
+
content: chunk.content,
|
|
595
|
+
path: chunk.path,
|
|
596
|
+
startLine: chunk.startLine,
|
|
597
|
+
endLine: chunk.endLine,
|
|
598
|
+
vector: chunk.vector,
|
|
599
|
+
type: chunk.metadata.type,
|
|
600
|
+
lastUpdated,
|
|
601
|
+
version: chunk.metadata.version ?? '',
|
|
602
|
+
framework: chunk.metadata.framework ?? '',
|
|
603
|
+
language: chunk.metadata.language ?? '',
|
|
604
|
+
codeBlocks: JSON.stringify(chunk.metadata.codeBlocks || []),
|
|
605
|
+
props: JSON.stringify(chunk.metadata.props || []),
|
|
606
|
+
}));
|
|
607
|
+
}
|
|
608
|
+
async upsertMetadata(metadata) {
|
|
609
|
+
if (!this.sqliteDb) {
|
|
610
|
+
throw new Error('Storage not initialized');
|
|
611
|
+
}
|
|
612
|
+
await this.sqliteDb.run(`INSERT INTO documents (url, title, favicon, last_indexed, requires_auth, auth_domain, version)
|
|
613
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
614
|
+
ON CONFLICT(url) DO UPDATE SET
|
|
615
|
+
title = excluded.title,
|
|
616
|
+
favicon = excluded.favicon,
|
|
617
|
+
last_indexed = excluded.last_indexed,
|
|
618
|
+
requires_auth = excluded.requires_auth,
|
|
619
|
+
auth_domain = excluded.auth_domain,
|
|
620
|
+
version = excluded.version`, [
|
|
621
|
+
metadata.url,
|
|
622
|
+
metadata.title,
|
|
623
|
+
metadata.favicon ?? null,
|
|
624
|
+
metadata.lastIndexed.toISOString(),
|
|
625
|
+
metadata.requiresAuth ? 1 : 0,
|
|
626
|
+
metadata.authDomain ?? null,
|
|
627
|
+
metadata.version ?? null,
|
|
628
|
+
]);
|
|
629
|
+
}
|
|
630
|
+
async recoverDocumentReplacements() {
|
|
631
|
+
if (!this.sqliteReadDb || !this.sqliteLeaseDb || !this.lanceTable) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const now = Date.now();
|
|
635
|
+
const journals = await this.sqliteReadDb.all('SELECT * FROM document_replacements WHERE lease_expires_at <= ?', [now]);
|
|
636
|
+
for (const journal of journals) {
|
|
637
|
+
const ownerId = randomUUID();
|
|
638
|
+
const leaseExpiresAt = Date.now() + REPLACEMENT_LEASE_MS;
|
|
639
|
+
const claim = await this.sqliteLeaseDb.run(`UPDATE document_replacements
|
|
640
|
+
SET owner_id = ?, lease_expires_at = ?
|
|
641
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = ? AND lease_expires_at <= ?`, [ownerId, leaseExpiresAt, journal.url, journal.generation, journal.owner_id, journal.state, Date.now()]);
|
|
642
|
+
if (claim.changes === 1) {
|
|
643
|
+
await this.finishDocumentReplacement({ ...journal, owner_id: ownerId, lease_expires_at: leaseExpiresAt });
|
|
644
|
+
logger.info(`[DocumentStore] Recovered ${journal.state} replacement for ${journal.url}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async reapOrphanedUnpublishedGenerations() {
|
|
649
|
+
if (!this.sqliteReadDb || !this.lanceTable) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
await this.lanceTable.checkoutLatest();
|
|
654
|
+
const rows = await this.lanceTable.query().where('published = false').select(['generation']).toArray();
|
|
655
|
+
const orphaned = new Set(rows.map((row) => String(row.generation)));
|
|
656
|
+
if (orphaned.size === 0) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const journals = await this.sqliteReadDb.all('SELECT generation FROM document_replacements');
|
|
660
|
+
for (const { generation } of journals) {
|
|
661
|
+
orphaned.delete(generation);
|
|
662
|
+
}
|
|
663
|
+
if (orphaned.size > 0) {
|
|
664
|
+
const generations = [...orphaned].map((generation) => `'${escapeFilterValue(generation)}'`).join(', ');
|
|
665
|
+
await this.lanceTable.delete(`published = false AND generation IN (${generations})`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
logger.warn('[DocumentStore] Orphaned unpublished generation cleanup deferred:', error);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async finishDocumentReplacement(journal) {
|
|
673
|
+
if (!this.sqliteLeaseDb || !this.lanceTable) {
|
|
674
|
+
throw new Error('Storage not initialized');
|
|
675
|
+
}
|
|
676
|
+
await this.renewReplacementLease(journal);
|
|
677
|
+
const url = escapeFilterValue(journal.url);
|
|
678
|
+
const cleanupGenerations = journal.state === 'prepared' ? [journal.generation] : this.parseCleanupGenerations(journal.cleanup_generations);
|
|
679
|
+
for (const generation of cleanupGenerations) {
|
|
680
|
+
await this.lanceTable.delete(`url = '${url}' AND generation = '${escapeFilterValue(generation)}'`);
|
|
681
|
+
}
|
|
682
|
+
const deletion = await this.sqliteLeaseDb.run('DELETE FROM document_replacements WHERE url = ? AND generation = ? AND owner_id = ? AND state = ?', [journal.url, journal.generation, journal.owner_id, journal.state]);
|
|
683
|
+
if (deletion.changes !== 1) {
|
|
684
|
+
throw new Error(`Replacement lease lost for ${journal.url}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
parseCleanupGenerations(value) {
|
|
688
|
+
const parsed = JSON.parse(value);
|
|
689
|
+
if (!Array.isArray(parsed) || !parsed.every((generation) => typeof generation === 'string')) {
|
|
690
|
+
throw new Error('Invalid replacement cleanup generations');
|
|
691
|
+
}
|
|
692
|
+
return [...new Set(parsed)];
|
|
693
|
+
}
|
|
694
|
+
async deleteUnpublishedGenerationBestEffort(url, generation) {
|
|
695
|
+
try {
|
|
696
|
+
await this.lanceTable?.delete(`url = '${escapeFilterValue(url)}' AND generation = '${escapeFilterValue(generation)}' AND published = false`);
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
logger.warn(`[DocumentStore] Stale replacement generation cleanup deferred for ${url}:`, error);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async finishDocumentReplacementBestEffort(journal) {
|
|
703
|
+
try {
|
|
704
|
+
await this.finishDocumentReplacement(journal);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
logger.warn(`[DocumentStore] ${journal.state} replacement cleanup deferred for ${journal.url}:`, error);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async getJournalVisibilityFilter() {
|
|
711
|
+
if (!this.sqliteReadDb) {
|
|
712
|
+
throw new Error('Storage not initialized');
|
|
713
|
+
}
|
|
714
|
+
const journals = await this.sqliteReadDb.all('SELECT * FROM document_replacements');
|
|
715
|
+
const journalFilters = journals
|
|
716
|
+
.map(({ url, generation, state }) => {
|
|
717
|
+
const escapedUrl = escapeFilterValue(url);
|
|
718
|
+
const escapedGeneration = escapeFilterValue(generation);
|
|
719
|
+
if (state === 'prepared') {
|
|
720
|
+
return `(url != '${escapedUrl}' OR generation != '${escapedGeneration}')`;
|
|
721
|
+
}
|
|
722
|
+
if (state === 'published') {
|
|
723
|
+
return `(url != '${escapedUrl}' OR generation = '${escapedGeneration}')`;
|
|
724
|
+
}
|
|
725
|
+
return `url != '${escapedUrl}'`;
|
|
726
|
+
})
|
|
727
|
+
.join(' AND ');
|
|
728
|
+
return journalFilters ? `published = true AND ${journalFilters}` : 'published = true';
|
|
729
|
+
}
|
|
730
|
+
async withStableSearch(operation) {
|
|
731
|
+
let deadline;
|
|
732
|
+
let backoffMs = 1;
|
|
733
|
+
while (true) {
|
|
734
|
+
const dataVersion = await this.getDataVersion();
|
|
735
|
+
if (!this.lanceTable) {
|
|
736
|
+
throw new Error('Storage not initialized');
|
|
737
|
+
}
|
|
738
|
+
// Other DocumentStore instances hold their own Lance table handle; refresh
|
|
739
|
+
// it to the latest committed table version before evaluating this snapshot.
|
|
740
|
+
await this.lanceTable.checkoutLatest();
|
|
741
|
+
const result = await operation(dataVersion);
|
|
742
|
+
if (dataVersion === (await this.getDataVersion())) {
|
|
743
|
+
return { result, dataVersion };
|
|
744
|
+
}
|
|
745
|
+
deadline ??= Date.now() + SEARCH_VISIBILITY_RETRY_WINDOW_MS;
|
|
746
|
+
if (Date.now() >= deadline) {
|
|
747
|
+
throw new Error('Search visibility kept changing; please retry');
|
|
748
|
+
}
|
|
749
|
+
await delay(backoffMs);
|
|
750
|
+
backoffMs = Math.min(backoffMs * 2, SEARCH_VISIBILITY_RETRY_MAX_BACKOFF_MS);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
async getDataVersion() {
|
|
754
|
+
if (!this.sqliteReadDb) {
|
|
755
|
+
throw new Error('Storage not initialized');
|
|
756
|
+
}
|
|
757
|
+
// PRAGMA data_version is connection-relative: comparing successive reads
|
|
758
|
+
// on this reader detects commits from this instance's writer and other processes.
|
|
759
|
+
const row = await this.sqliteReadDb.get('PRAGMA data_version');
|
|
760
|
+
if (!row) {
|
|
761
|
+
throw new Error('Could not read SQLite data version');
|
|
762
|
+
}
|
|
763
|
+
return row.data_version;
|
|
764
|
+
}
|
|
360
765
|
async searchDocuments(queryVector, options = {}) {
|
|
766
|
+
return (await this.withStableSearch(() => this.searchDocumentsOnce(queryVector, options))).result;
|
|
767
|
+
}
|
|
768
|
+
async searchDocumentsOnce(queryVector, options) {
|
|
361
769
|
if (!this.lanceTable) {
|
|
362
770
|
throw new Error('Storage not initialized');
|
|
363
771
|
}
|
|
@@ -418,10 +826,9 @@ export class DocumentStore {
|
|
|
418
826
|
}
|
|
419
827
|
logger.debug(`[DocumentStore] Tag filter matched ${tagFilteredUrls.length} documents for vector search`);
|
|
420
828
|
}
|
|
421
|
-
// Create search query
|
|
422
|
-
let query = this.lanceTable.search(queryVector).limit(limit);
|
|
423
829
|
// Build WHERE conditions
|
|
424
|
-
const
|
|
830
|
+
const visibilityFilter = await this.getJournalVisibilityFilter();
|
|
831
|
+
const conditions = [`(${visibilityFilter})`];
|
|
425
832
|
if (filterByType) {
|
|
426
833
|
conditions.push(`type = '${escapeFilterValue(filterByType)}'`);
|
|
427
834
|
}
|
|
@@ -429,9 +836,7 @@ export class DocumentStore {
|
|
|
429
836
|
const urlConditions = tagFilteredUrls.map((u) => `url = '${escapeFilterValue(u)}'`).join(' OR ');
|
|
430
837
|
conditions.push(`(${urlConditions})`);
|
|
431
838
|
}
|
|
432
|
-
|
|
433
|
-
query = query.where(conditions.join(' AND '));
|
|
434
|
-
}
|
|
839
|
+
const query = this.lanceTable.search(queryVector).where(conditions.join(' AND ')).limit(limit);
|
|
435
840
|
const results = await query.toArray();
|
|
436
841
|
logger.debug(`[DocumentStore] Found ${results.length} results`);
|
|
437
842
|
// Log the first result for debugging if available
|
|
@@ -524,14 +929,23 @@ export class DocumentStore {
|
|
|
524
929
|
}
|
|
525
930
|
}
|
|
526
931
|
async searchByText(query, options = {}) {
|
|
932
|
+
const { result, dataVersion } = await this.withStableSearch((version) => this.searchByTextOnce(query, options, version));
|
|
933
|
+
this.searchCache.set(this.textSearchCacheKey(query, options, dataVersion), result);
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
textSearchCacheKey(query, options, dataVersion) {
|
|
937
|
+
return `text:${dataVersion}:${query}:${JSON.stringify(options)}`;
|
|
938
|
+
}
|
|
939
|
+
async searchByTextOnce(query, options, dataVersion) {
|
|
527
940
|
logger.debug(`[DocumentStore] Searching documents by text:`, { query, options });
|
|
528
|
-
const cacheKey =
|
|
941
|
+
const cacheKey = this.textSearchCacheKey(query, options, dataVersion);
|
|
529
942
|
const cached = this.searchCache.get(cacheKey);
|
|
530
943
|
if (cached) {
|
|
531
944
|
logger.debug(`[DocumentStore] Returning cached results`);
|
|
532
945
|
return cached;
|
|
533
946
|
}
|
|
534
947
|
const { limit = 10, filterByType, filterUrl, filterByTags } = options;
|
|
948
|
+
const visibilityFilter = await this.getJournalVisibilityFilter();
|
|
535
949
|
// If filtering by tags, get the list of URLs that have all those tags
|
|
536
950
|
let tagFilteredUrls;
|
|
537
951
|
if (filterByTags && filterByTags.length > 0) {
|
|
@@ -540,14 +954,13 @@ export class DocumentStore {
|
|
|
540
954
|
// No documents match the tag filter, cache and return empty results
|
|
541
955
|
logger.debug(`[DocumentStore] No documents match tag filter:`, filterByTags);
|
|
542
956
|
const emptyResults = [];
|
|
543
|
-
this.searchCache.set(cacheKey, emptyResults);
|
|
544
957
|
return emptyResults;
|
|
545
958
|
}
|
|
546
959
|
logger.debug(`[DocumentStore] Tag filter matched ${tagFilteredUrls.length} documents`);
|
|
547
960
|
}
|
|
548
961
|
// Build WHERE clause for filtering (using escaped values to prevent injection)
|
|
549
962
|
const buildWhereClause = () => {
|
|
550
|
-
const conditions = [];
|
|
963
|
+
const conditions = [`(${visibilityFilter})`];
|
|
551
964
|
if (filterByType) {
|
|
552
965
|
conditions.push(`type = '${escapeFilterValue(filterByType)}'`);
|
|
553
966
|
}
|
|
@@ -562,7 +975,7 @@ export class DocumentStore {
|
|
|
562
975
|
const urlConditions = tagFilteredUrls.map((u) => `url = '${escapeFilterValue(u)}'`).join(' OR ');
|
|
563
976
|
conditions.push(`(${urlConditions})`);
|
|
564
977
|
}
|
|
565
|
-
return conditions.
|
|
978
|
+
return conditions.join(' AND ');
|
|
566
979
|
};
|
|
567
980
|
const whereClause = buildWhereClause();
|
|
568
981
|
try {
|
|
@@ -589,25 +1002,22 @@ export class DocumentStore {
|
|
|
589
1002
|
queries.push([Occur.Should, new MatchQuery(processedQuery.cleanedQuery, 'content', { fuzziness: 1 })]);
|
|
590
1003
|
}
|
|
591
1004
|
const boolQuery = new BooleanQuery(queries);
|
|
592
|
-
|
|
1005
|
+
const ftsQuery = this.lanceTable
|
|
593
1006
|
.query()
|
|
594
1007
|
.fullTextSearch(boolQuery)
|
|
1008
|
+
.where(whereClause)
|
|
595
1009
|
.limit(limit * 2);
|
|
596
|
-
if (whereClause) {
|
|
597
|
-
ftsQuery = ftsQuery.where(whereClause);
|
|
598
|
-
}
|
|
599
1010
|
const ftsResults = await ftsQuery.toArray();
|
|
600
1011
|
logger.debug(`[DocumentStore] Phrase-based FTS returned ${ftsResults.length} results`);
|
|
601
1012
|
if (ftsResults.length > 0) {
|
|
602
1013
|
// Combine with vector search for semantic relevance
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1014
|
+
const vectorQuery = this.lanceTable
|
|
1015
|
+
.search(queryVector)
|
|
1016
|
+
.where(whereClause)
|
|
1017
|
+
.limit(limit * 2);
|
|
607
1018
|
const vectorResults = await vectorQuery.toArray();
|
|
608
1019
|
const mergedResults = this.mergeAndRankResults(ftsResults, vectorResults, limit);
|
|
609
1020
|
const searchResults = this.formatSearchResults(mergedResults);
|
|
610
|
-
this.searchCache.set(cacheKey, searchResults);
|
|
611
1021
|
return searchResults;
|
|
612
1022
|
}
|
|
613
1023
|
}
|
|
@@ -622,27 +1032,24 @@ export class DocumentStore {
|
|
|
622
1032
|
// LanceDB's FTS already handles stop words and stemming
|
|
623
1033
|
// Add fuzziness for typo tolerance
|
|
624
1034
|
const matchQuery = new MatchQuery(processedQuery.cleanedQuery, 'content', { fuzziness: 1 });
|
|
625
|
-
|
|
1035
|
+
const ftsQuery = this.lanceTable
|
|
626
1036
|
.query()
|
|
627
1037
|
.fullTextSearch(matchQuery)
|
|
1038
|
+
.where(whereClause)
|
|
628
1039
|
.limit(limit * 2);
|
|
629
|
-
if (whereClause) {
|
|
630
|
-
ftsQuery = ftsQuery.where(whereClause);
|
|
631
|
-
}
|
|
632
1040
|
const ftsResults = await ftsQuery.toArray();
|
|
633
1041
|
logger.debug(`[DocumentStore] FTS returned ${ftsResults.length} results`);
|
|
634
1042
|
// Always combine with vector search for best results
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1043
|
+
const vectorQuery = this.lanceTable
|
|
1044
|
+
.search(queryVector)
|
|
1045
|
+
.where(whereClause)
|
|
1046
|
+
.limit(limit * 2);
|
|
639
1047
|
const vectorResults = await vectorQuery.toArray();
|
|
640
1048
|
logger.debug(`[DocumentStore] Vector search returned ${vectorResults.length} results`);
|
|
641
1049
|
// Merge using RRF even if one is empty - ensures we get results
|
|
642
1050
|
const mergedResults = this.mergeAndRankResults(ftsResults, vectorResults, limit);
|
|
643
1051
|
if (mergedResults.length > 0) {
|
|
644
1052
|
const searchResults = this.formatSearchResults(mergedResults);
|
|
645
|
-
this.searchCache.set(cacheKey, searchResults);
|
|
646
1053
|
return searchResults;
|
|
647
1054
|
}
|
|
648
1055
|
}
|
|
@@ -653,9 +1060,7 @@ export class DocumentStore {
|
|
|
653
1060
|
}
|
|
654
1061
|
// Strategy 3: Fallback to pure vector search (semantic similarity)
|
|
655
1062
|
logger.debug('[DocumentStore] Falling back to pure vector search');
|
|
656
|
-
|
|
657
|
-
this.searchCache.set(cacheKey, results);
|
|
658
|
-
return results;
|
|
1063
|
+
return this.searchDocumentsOnce(queryVector, options);
|
|
659
1064
|
}
|
|
660
1065
|
catch (error) {
|
|
661
1066
|
logger.error('[DocumentStore] Error searching documents by text:', error);
|
|
@@ -731,15 +1136,16 @@ export class DocumentStore {
|
|
|
731
1136
|
});
|
|
732
1137
|
}
|
|
733
1138
|
async listDocuments() {
|
|
734
|
-
if (!this.
|
|
1139
|
+
if (!this.sqliteReadDb) {
|
|
735
1140
|
throw new Error('Storage not initialized');
|
|
736
1141
|
}
|
|
737
1142
|
logger.debug(`[DocumentStore] Listing documents`);
|
|
738
1143
|
try {
|
|
739
|
-
const rows = await this.
|
|
1144
|
+
const rows = await this.sqliteReadDb.all(`SELECT d.url, d.title, d.favicon, d.last_indexed, d.requires_auth, d.auth_domain, d.version,
|
|
1145
|
+
COALESCE((SELECT json_group_array(dt.tag) FROM document_tags dt WHERE dt.url = d.url), '[]') AS tags_json
|
|
1146
|
+
FROM documents d
|
|
1147
|
+
ORDER BY d.last_indexed DESC`);
|
|
740
1148
|
logger.debug(`[DocumentStore] Found ${rows.length} documents`);
|
|
741
|
-
// Fetch tags for all documents
|
|
742
|
-
const tagsMap = await this.getAllDocumentTags();
|
|
743
1149
|
return rows.map((row) => ({
|
|
744
1150
|
url: row.url,
|
|
745
1151
|
title: row.title,
|
|
@@ -747,7 +1153,7 @@ export class DocumentStore {
|
|
|
747
1153
|
lastIndexed: new Date(row.last_indexed),
|
|
748
1154
|
requiresAuth: row.requires_auth === 1,
|
|
749
1155
|
authDomain: row.auth_domain ?? undefined,
|
|
750
|
-
tags:
|
|
1156
|
+
tags: this.parseDocumentTags(row.tags_json),
|
|
751
1157
|
version: row.version ?? undefined,
|
|
752
1158
|
}));
|
|
753
1159
|
}
|
|
@@ -756,60 +1162,78 @@ export class DocumentStore {
|
|
|
756
1162
|
throw error;
|
|
757
1163
|
}
|
|
758
1164
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
*/
|
|
762
|
-
async getAllDocumentTags() {
|
|
763
|
-
if (!this.sqliteDb) {
|
|
764
|
-
return new Map();
|
|
765
|
-
}
|
|
766
|
-
const rows = await this.sqliteDb.all('SELECT url, tag FROM document_tags ORDER BY url, tag');
|
|
767
|
-
const tagsMap = new Map();
|
|
768
|
-
for (const row of rows) {
|
|
769
|
-
const existing = tagsMap.get(row.url) || [];
|
|
770
|
-
existing.push(row.tag);
|
|
771
|
-
tagsMap.set(row.url, existing);
|
|
772
|
-
}
|
|
773
|
-
return tagsMap;
|
|
1165
|
+
deleteDocument(url) {
|
|
1166
|
+
return this.runMutation(() => this.deleteDocumentUnlocked(url));
|
|
774
1167
|
}
|
|
775
|
-
async
|
|
776
|
-
|
|
1168
|
+
async deleteDocumentUnlocked(url) {
|
|
1169
|
+
const sqliteDb = this.sqliteDb;
|
|
1170
|
+
const sqliteReadDb = this.sqliteReadDb;
|
|
1171
|
+
if (!sqliteDb || !sqliteReadDb || !this.sqliteLeaseDb || !this.lanceTable) {
|
|
777
1172
|
throw new Error('Storage not initialized');
|
|
778
1173
|
}
|
|
779
1174
|
logger.debug(`[DocumentStore] Deleting document: ${url}`);
|
|
1175
|
+
const generation = `delete:${randomUUID()}`;
|
|
1176
|
+
const ownerId = randomUUID();
|
|
1177
|
+
const lease = await this.acquireReplacementLease(url, generation, ownerId);
|
|
1178
|
+
let transactionStarted = false;
|
|
780
1179
|
try {
|
|
781
|
-
await this.
|
|
1180
|
+
const preparedJournal = await this.captureCleanupGenerations(lease);
|
|
1181
|
+
await this.renewReplacementLease(preparedJournal);
|
|
1182
|
+
await sqliteDb.run('BEGIN TRANSACTION');
|
|
1183
|
+
transactionStarted = true;
|
|
782
1184
|
// Delete tags first (in case foreign key cascade isn't enabled)
|
|
783
|
-
await
|
|
784
|
-
await
|
|
785
|
-
|
|
786
|
-
await
|
|
787
|
-
|
|
1185
|
+
await sqliteDb.run('DELETE FROM document_tags WHERE url = ?', [url]);
|
|
1186
|
+
await sqliteDb.run('DELETE FROM documents WHERE url = ?', [url]);
|
|
1187
|
+
const leaseExpiresAt = Date.now() + REPLACEMENT_LEASE_MS;
|
|
1188
|
+
const transition = await sqliteDb.run(`UPDATE document_replacements
|
|
1189
|
+
SET state = 'deleting', lease_expires_at = ?
|
|
1190
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = 'prepared'`, [leaseExpiresAt, url, generation, ownerId]);
|
|
1191
|
+
if (transition.changes !== 1) {
|
|
1192
|
+
throw new Error(`Replacement lease lost for ${url}`);
|
|
1193
|
+
}
|
|
1194
|
+
await sqliteDb.run('COMMIT');
|
|
1195
|
+
transactionStarted = false;
|
|
1196
|
+
await this.finishDocumentReplacementBestEffort({
|
|
1197
|
+
...preparedJournal,
|
|
1198
|
+
state: 'deleting',
|
|
1199
|
+
lease_expires_at: leaseExpiresAt,
|
|
1200
|
+
});
|
|
788
1201
|
this.clearCacheForUrl(url);
|
|
789
1202
|
logger.debug(`[DocumentStore] Document deleted successfully`);
|
|
790
1203
|
}
|
|
791
1204
|
catch (error) {
|
|
792
|
-
if (
|
|
793
|
-
|
|
1205
|
+
if (transactionStarted) {
|
|
1206
|
+
try {
|
|
1207
|
+
await sqliteDb.run('ROLLBACK');
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
// The transaction may already have been rolled back by SQLite.
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const durableJournal = await sqliteReadDb.get('SELECT * FROM document_replacements WHERE url = ? AND generation = ? AND owner_id = ?', [url, generation, ownerId]);
|
|
1214
|
+
if (durableJournal) {
|
|
1215
|
+
await this.finishDocumentReplacementBestEffort(durableJournal);
|
|
1216
|
+
if (durableJournal.state === 'deleting') {
|
|
1217
|
+
this.clearCacheForUrl(url);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
794
1220
|
}
|
|
795
1221
|
logger.error('[DocumentStore] Error deleting document:', error);
|
|
796
1222
|
throw error;
|
|
797
1223
|
}
|
|
798
1224
|
}
|
|
799
1225
|
async getDocument(url) {
|
|
800
|
-
if (!this.
|
|
1226
|
+
if (!this.sqliteReadDb) {
|
|
801
1227
|
throw new Error('Storage not initialized');
|
|
802
1228
|
}
|
|
803
1229
|
logger.debug(`[DocumentStore] Getting document: ${url}`);
|
|
804
1230
|
try {
|
|
805
|
-
// Check if SQLite is properly initialized
|
|
806
|
-
if (!this.sqliteDb) {
|
|
807
|
-
logger.debug('[DocumentStore] SQLite not initialized during getDocument');
|
|
808
|
-
throw new Error('Storage not initialized');
|
|
809
|
-
}
|
|
810
1231
|
// Log the query being executed
|
|
811
1232
|
logger.debug(`[DocumentStore] Executing SQLite query for URL: ${url}`);
|
|
812
|
-
const row = await this.
|
|
1233
|
+
const row = await this.sqliteReadDb.get(`SELECT d.url, d.title, d.favicon, d.last_indexed, d.requires_auth, d.auth_domain, d.version,
|
|
1234
|
+
COALESCE((SELECT json_group_array(dt.tag) FROM document_tags dt WHERE dt.url = d.url), '[]') AS tags_json
|
|
1235
|
+
FROM documents d
|
|
1236
|
+
WHERE d.url = ?`, [url]);
|
|
813
1237
|
if (!row) {
|
|
814
1238
|
logger.debug(`[DocumentStore] Document not found in SQLite: ${url}`);
|
|
815
1239
|
return null;
|
|
@@ -819,8 +1243,6 @@ export class DocumentStore {
|
|
|
819
1243
|
const chunks = await this.lanceTable.countRows(`url = '${escapeFilterValue(url)}'`);
|
|
820
1244
|
logger.debug(`[DocumentStore] Found ${chunks} chunks in LanceDB for ${url}`);
|
|
821
1245
|
}
|
|
822
|
-
// Fetch tags for this document
|
|
823
|
-
const tags = await this.getDocumentTags(url);
|
|
824
1246
|
logger.debug(`[DocumentStore] Document found in SQLite:`, row);
|
|
825
1247
|
return {
|
|
826
1248
|
url: row.url,
|
|
@@ -829,7 +1251,7 @@ export class DocumentStore {
|
|
|
829
1251
|
lastIndexed: new Date(row.last_indexed),
|
|
830
1252
|
requiresAuth: row.requires_auth === 1,
|
|
831
1253
|
authDomain: row.auth_domain ?? undefined,
|
|
832
|
-
tags,
|
|
1254
|
+
tags: this.parseDocumentTags(row.tags_json),
|
|
833
1255
|
version: row.version ?? undefined,
|
|
834
1256
|
};
|
|
835
1257
|
}
|
|
@@ -838,22 +1260,34 @@ export class DocumentStore {
|
|
|
838
1260
|
throw error;
|
|
839
1261
|
}
|
|
840
1262
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1263
|
+
parseDocumentTags(value) {
|
|
1264
|
+
try {
|
|
1265
|
+
const parsed = JSON.parse(value);
|
|
1266
|
+
return Array.isArray(parsed) && parsed.every((tag) => typeof tag === 'string') ? parsed.sort() : [];
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
846
1269
|
return [];
|
|
847
1270
|
}
|
|
848
|
-
|
|
849
|
-
|
|
1271
|
+
}
|
|
1272
|
+
async replaceDocumentTags(url, tags) {
|
|
1273
|
+
if (!this.sqliteDb) {
|
|
1274
|
+
throw new Error('Storage not initialized');
|
|
1275
|
+
}
|
|
1276
|
+
await this.sqliteDb.run('DELETE FROM document_tags WHERE url = ?', [url]);
|
|
1277
|
+
const normalizedTags = [...new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean))];
|
|
1278
|
+
for (const tag of normalizedTags) {
|
|
1279
|
+
await this.sqliteDb.run('INSERT INTO document_tags (url, tag) VALUES (?, ?)', [url, tag]);
|
|
1280
|
+
}
|
|
850
1281
|
}
|
|
851
1282
|
/**
|
|
852
1283
|
* Set tags for a documentation site. Replaces any existing tags.
|
|
853
1284
|
* @param url - The URL of the documentation site
|
|
854
1285
|
* @param tags - Array of tags to assign (empty array removes all tags)
|
|
855
1286
|
*/
|
|
856
|
-
|
|
1287
|
+
setTags(url, tags) {
|
|
1288
|
+
return this.runMutation(() => this.setTagsUnlocked(url, tags));
|
|
1289
|
+
}
|
|
1290
|
+
async setTagsUnlocked(url, tags) {
|
|
857
1291
|
if (!this.sqliteDb) {
|
|
858
1292
|
throw new Error('Storage not initialized');
|
|
859
1293
|
}
|
|
@@ -866,13 +1300,7 @@ export class DocumentStore {
|
|
|
866
1300
|
await this.sqliteDb.run('ROLLBACK');
|
|
867
1301
|
throw new Error('Documentation not found');
|
|
868
1302
|
}
|
|
869
|
-
|
|
870
|
-
await this.sqliteDb.run('DELETE FROM document_tags WHERE url = ?', [url]);
|
|
871
|
-
// Insert new tags (deduplicated and normalized)
|
|
872
|
-
const uniqueTags = [...new Set(tags.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))];
|
|
873
|
-
for (const tag of uniqueTags) {
|
|
874
|
-
await this.sqliteDb.run('INSERT INTO document_tags (url, tag) VALUES (?, ?)', [url, tag]);
|
|
875
|
-
}
|
|
1303
|
+
await this.replaceDocumentTags(url, tags);
|
|
876
1304
|
await this.sqliteDb.run('COMMIT');
|
|
877
1305
|
// Clear cached search results that may be affected by tag changes
|
|
878
1306
|
this.clearCacheForUrl(url);
|
|
@@ -896,12 +1324,12 @@ export class DocumentStore {
|
|
|
896
1324
|
* @returns Array of tags with counts, sorted by count descending
|
|
897
1325
|
*/
|
|
898
1326
|
async listAllTags() {
|
|
899
|
-
if (!this.
|
|
1327
|
+
if (!this.sqliteReadDb) {
|
|
900
1328
|
throw new Error('Storage not initialized');
|
|
901
1329
|
}
|
|
902
1330
|
logger.debug(`[DocumentStore] Listing all tags`);
|
|
903
1331
|
try {
|
|
904
|
-
const rows = await this.
|
|
1332
|
+
const rows = await this.sqliteReadDb.all('SELECT tag, COUNT(*) as count FROM document_tags GROUP BY tag ORDER BY count DESC, tag ASC');
|
|
905
1333
|
logger.debug(`[DocumentStore] Found ${rows.length} unique tags`);
|
|
906
1334
|
return rows;
|
|
907
1335
|
}
|
|
@@ -916,7 +1344,7 @@ export class DocumentStore {
|
|
|
916
1344
|
* @returns Array of matching document URLs
|
|
917
1345
|
*/
|
|
918
1346
|
async getUrlsByTags(tags) {
|
|
919
|
-
if (!this.
|
|
1347
|
+
if (!this.sqliteReadDb || tags.length === 0) {
|
|
920
1348
|
return [];
|
|
921
1349
|
}
|
|
922
1350
|
// Normalize tags
|
|
@@ -935,7 +1363,7 @@ export class DocumentStore {
|
|
|
935
1363
|
GROUP BY url
|
|
936
1364
|
HAVING COUNT(DISTINCT tag) = ?
|
|
937
1365
|
`;
|
|
938
|
-
const rows = await this.
|
|
1366
|
+
const rows = await this.sqliteReadDb.all(query, [...normalizedTags, normalizedTags.length]);
|
|
939
1367
|
logger.debug(`[DocumentStore] Found ${rows.length} URLs matching all tags`);
|
|
940
1368
|
return rows.map((row) => row.url);
|
|
941
1369
|
}
|
|
@@ -951,7 +1379,10 @@ export class DocumentStore {
|
|
|
951
1379
|
* @param description - Optional description
|
|
952
1380
|
* @throws Error if collection already exists
|
|
953
1381
|
*/
|
|
954
|
-
|
|
1382
|
+
createCollection(name, description) {
|
|
1383
|
+
return this.runMutation(() => this.createCollectionUnlocked(name, description));
|
|
1384
|
+
}
|
|
1385
|
+
async createCollectionUnlocked(name, description) {
|
|
955
1386
|
if (!this.sqliteDb) {
|
|
956
1387
|
throw new Error('Storage not initialized');
|
|
957
1388
|
}
|
|
@@ -981,7 +1412,10 @@ export class DocumentStore {
|
|
|
981
1412
|
* @param name - Name of the collection to delete
|
|
982
1413
|
* @throws Error if collection doesn't exist
|
|
983
1414
|
*/
|
|
984
|
-
|
|
1415
|
+
deleteCollection(name) {
|
|
1416
|
+
return this.runMutation(() => this.deleteCollectionUnlocked(name));
|
|
1417
|
+
}
|
|
1418
|
+
async deleteCollectionUnlocked(name) {
|
|
985
1419
|
if (!this.sqliteDb) {
|
|
986
1420
|
throw new Error('Storage not initialized');
|
|
987
1421
|
}
|
|
@@ -1000,7 +1434,10 @@ export class DocumentStore {
|
|
|
1000
1434
|
* @param updates - Fields to update
|
|
1001
1435
|
* @throws Error if collection doesn't exist
|
|
1002
1436
|
*/
|
|
1003
|
-
|
|
1437
|
+
updateCollection(name, updates) {
|
|
1438
|
+
return this.runMutation(() => this.updateCollectionUnlocked(name, updates));
|
|
1439
|
+
}
|
|
1440
|
+
async updateCollectionUnlocked(name, updates) {
|
|
1004
1441
|
if (!this.sqliteDb) {
|
|
1005
1442
|
throw new Error('Storage not initialized');
|
|
1006
1443
|
}
|
|
@@ -1077,11 +1514,11 @@ export class DocumentStore {
|
|
|
1077
1514
|
* @returns Array of collections sorted by name
|
|
1078
1515
|
*/
|
|
1079
1516
|
async listCollections() {
|
|
1080
|
-
if (!this.
|
|
1517
|
+
if (!this.sqliteReadDb) {
|
|
1081
1518
|
throw new Error('Storage not initialized');
|
|
1082
1519
|
}
|
|
1083
1520
|
logger.debug(`[DocumentStore] Listing collections`);
|
|
1084
|
-
const rows = await this.
|
|
1521
|
+
const rows = await this.sqliteReadDb.all(`
|
|
1085
1522
|
SELECT c.name, c.description, c.created_at, c.updated_at,
|
|
1086
1523
|
COUNT(cd.url) as document_count
|
|
1087
1524
|
FROM collections c
|
|
@@ -1104,27 +1541,26 @@ export class DocumentStore {
|
|
|
1104
1541
|
* @returns Collection with documents, or null if not found
|
|
1105
1542
|
*/
|
|
1106
1543
|
async getCollection(name) {
|
|
1107
|
-
if (!this.
|
|
1544
|
+
if (!this.sqliteReadDb) {
|
|
1108
1545
|
throw new Error('Storage not initialized');
|
|
1109
1546
|
}
|
|
1110
1547
|
const normalizedName = name.trim();
|
|
1111
1548
|
logger.debug(`[DocumentStore] Getting collection: ${normalizedName}`);
|
|
1112
1549
|
// Get collection metadata
|
|
1113
|
-
const row = await this.
|
|
1550
|
+
const row = await this.sqliteReadDb.get('SELECT name, description, created_at, updated_at FROM collections WHERE name = ?', [normalizedName]);
|
|
1114
1551
|
if (!row) {
|
|
1115
1552
|
logger.debug(`[DocumentStore] Collection not found: ${normalizedName}`);
|
|
1116
1553
|
return null;
|
|
1117
1554
|
}
|
|
1118
1555
|
// Get documents in the collection
|
|
1119
|
-
const docRows = await this.
|
|
1120
|
-
SELECT d.url, d.title, d.favicon, d.last_indexed, d.requires_auth, d.auth_domain, d.version
|
|
1556
|
+
const docRows = await this.sqliteReadDb.all(`
|
|
1557
|
+
SELECT d.url, d.title, d.favicon, d.last_indexed, d.requires_auth, d.auth_domain, d.version,
|
|
1558
|
+
COALESCE((SELECT json_group_array(dt.tag) FROM document_tags dt WHERE dt.url = d.url), '[]') AS tags_json
|
|
1121
1559
|
FROM documents d
|
|
1122
1560
|
INNER JOIN collection_documents cd ON d.url = cd.url
|
|
1123
1561
|
WHERE cd.collection_name = ?
|
|
1124
1562
|
ORDER BY d.title ASC
|
|
1125
1563
|
`, [normalizedName]);
|
|
1126
|
-
// Fetch tags for all documents
|
|
1127
|
-
const tagsMap = await this.getAllDocumentTags();
|
|
1128
1564
|
const documents = docRows.map((doc) => ({
|
|
1129
1565
|
url: doc.url,
|
|
1130
1566
|
title: doc.title,
|
|
@@ -1132,7 +1568,7 @@ export class DocumentStore {
|
|
|
1132
1568
|
lastIndexed: new Date(doc.last_indexed),
|
|
1133
1569
|
requiresAuth: doc.requires_auth === 1,
|
|
1134
1570
|
authDomain: doc.auth_domain ?? undefined,
|
|
1135
|
-
tags:
|
|
1571
|
+
tags: this.parseDocumentTags(doc.tags_json),
|
|
1136
1572
|
version: doc.version ?? undefined,
|
|
1137
1573
|
}));
|
|
1138
1574
|
logger.debug(`[DocumentStore] Collection "${normalizedName}" has ${documents.length} documents`);
|
|
@@ -1151,7 +1587,10 @@ export class DocumentStore {
|
|
|
1151
1587
|
* @param urls - URLs of documents to add
|
|
1152
1588
|
* @throws Error if collection doesn't exist
|
|
1153
1589
|
*/
|
|
1154
|
-
|
|
1590
|
+
addToCollection(name, urls) {
|
|
1591
|
+
return this.runMutation(() => this.addToCollectionUnlocked(name, urls));
|
|
1592
|
+
}
|
|
1593
|
+
async addToCollectionUnlocked(name, urls) {
|
|
1155
1594
|
if (!this.sqliteDb) {
|
|
1156
1595
|
throw new Error('Storage not initialized');
|
|
1157
1596
|
}
|
|
@@ -1219,7 +1658,10 @@ export class DocumentStore {
|
|
|
1219
1658
|
* @param urls - URLs of documents to remove
|
|
1220
1659
|
* @throws Error if collection doesn't exist
|
|
1221
1660
|
*/
|
|
1222
|
-
|
|
1661
|
+
removeFromCollection(name, urls) {
|
|
1662
|
+
return this.runMutation(() => this.removeFromCollectionUnlocked(name, urls));
|
|
1663
|
+
}
|
|
1664
|
+
async removeFromCollectionUnlocked(name, urls) {
|
|
1223
1665
|
if (!this.sqliteDb) {
|
|
1224
1666
|
throw new Error('Storage not initialized');
|
|
1225
1667
|
}
|
|
@@ -1270,12 +1712,12 @@ export class DocumentStore {
|
|
|
1270
1712
|
* @returns Array of document URLs
|
|
1271
1713
|
*/
|
|
1272
1714
|
async getCollectionUrls(name) {
|
|
1273
|
-
if (!this.
|
|
1715
|
+
if (!this.sqliteReadDb) {
|
|
1274
1716
|
throw new Error('Storage not initialized');
|
|
1275
1717
|
}
|
|
1276
1718
|
const normalizedName = name.trim();
|
|
1277
1719
|
logger.debug(`[DocumentStore] Getting URLs for collection: ${normalizedName}`);
|
|
1278
|
-
const rows = await this.
|
|
1720
|
+
const rows = await this.sqliteReadDb.all('SELECT url FROM collection_documents WHERE collection_name = ?', [
|
|
1279
1721
|
normalizedName,
|
|
1280
1722
|
]);
|
|
1281
1723
|
return rows.map((row) => row.url);
|
|
@@ -1299,15 +1741,17 @@ export class DocumentStore {
|
|
|
1299
1741
|
throw new Error('Storage not initialized');
|
|
1300
1742
|
}
|
|
1301
1743
|
try {
|
|
1744
|
+
const visibilityFilter = await this.getJournalVisibilityFilter();
|
|
1302
1745
|
// Get total row count
|
|
1303
|
-
const rowCount = await this.lanceTable.countRows();
|
|
1746
|
+
const rowCount = await this.lanceTable.countRows(visibilityFilter);
|
|
1304
1747
|
logger.debug(`[DocumentStore] Vector validation: Table contains ${rowCount} rows`);
|
|
1305
1748
|
if (rowCount === 0) {
|
|
1306
1749
|
logger.debug('[DocumentStore] Vector validation: No rows found in vector table');
|
|
1307
1750
|
return false;
|
|
1308
1751
|
}
|
|
1309
1752
|
// Get a sample row using a query
|
|
1310
|
-
const
|
|
1753
|
+
const sampleQuery = this.lanceTable.query().where(visibilityFilter).limit(1);
|
|
1754
|
+
const sample = await sampleQuery.toArray();
|
|
1311
1755
|
if (sample.length === 0) {
|
|
1312
1756
|
logger.debug('[DocumentStore] Vector validation: No rows returned from query');
|
|
1313
1757
|
return false;
|
|
@@ -1323,7 +1767,8 @@ export class DocumentStore {
|
|
|
1323
1767
|
// Try a simple vector search with a random vector
|
|
1324
1768
|
const testVector = new Array(this.embeddings.dimensions).fill(0).map(() => Math.random());
|
|
1325
1769
|
logger.debug(`[DocumentStore] Testing vector search with random vector of length ${testVector.length}`);
|
|
1326
|
-
const
|
|
1770
|
+
const searchQuery = this.lanceTable.search(testVector).where(visibilityFilter).limit(1);
|
|
1771
|
+
const searchResults = await searchQuery.toArray();
|
|
1327
1772
|
logger.debug(`[DocumentStore] Vector search test returned ${searchResults.length} results`);
|
|
1328
1773
|
if (searchResults.length > 0) {
|
|
1329
1774
|
logger.debug('[DocumentStore] Vector search test result:', {
|
|
@@ -1352,7 +1797,10 @@ export class DocumentStore {
|
|
|
1352
1797
|
*
|
|
1353
1798
|
* @returns Promise with optimization statistics
|
|
1354
1799
|
*/
|
|
1355
|
-
|
|
1800
|
+
optimize() {
|
|
1801
|
+
return this.runMutation(() => this.optimizeUnlocked());
|
|
1802
|
+
}
|
|
1803
|
+
async optimizeUnlocked() {
|
|
1356
1804
|
if (!this.lanceTable) {
|
|
1357
1805
|
logger.debug('[DocumentStore] Cannot optimize: Storage not initialized');
|
|
1358
1806
|
return { compacted: false, cleanedUp: false, error: 'Storage not initialized' };
|