@cosmocoder/mcp-web-docs 2.0.8 → 2.0.10
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/crawler/auth.js +117 -7
- package/build/crawler/auth.js.map +1 -1
- package/build/crawler/auth.test.js +333 -83
- package/build/crawler/auth.test.js.map +1 -1
- package/build/crawler/browser-config.d.ts +1 -1
- package/build/crawler/browser-config.js +7 -1
- package/build/crawler/browser-config.js.map +1 -1
- package/build/crawler/browser-config.test.js +21 -25
- package/build/crawler/browser-config.test.js.map +1 -1
- package/build/crawler/crawlee-crawler.d.ts +3 -0
- package/build/crawler/crawlee-crawler.js +155 -12
- package/build/crawler/crawlee-crawler.js.map +1 -1
- package/build/crawler/crawlee-crawler.test.js +190 -143
- package/build/crawler/crawlee-crawler.test.js.map +1 -1
- package/build/crawler/github.js +3 -2
- package/build/crawler/github.js.map +1 -1
- package/build/crawler/llms-txt.js +2 -1
- package/build/crawler/llms-txt.js.map +1 -1
- package/build/index.js +21 -24
- package/build/index.js.map +1 -1
- package/build/setupTests.js +44 -0
- package/build/setupTests.js.map +1 -1
- package/build/storage/storage.d.ts +30 -10
- package/build/storage/storage.js +567 -170
- package/build/storage/storage.js.map +1 -1
- package/build/storage/storage.test.js +721 -2
- package/build/storage/storage.test.js.map +1 -1
- package/build/types.d.ts +6 -1
- package/build/util/favicon.js +2 -1
- package/build/util/favicon.js.map +1 -1
- package/build/util/favicon.test.js +27 -37
- package/build/util/favicon.test.js.map +1 -1
- package/build/util/outbound-request.d.ts +33 -0
- package/build/util/outbound-request.integration.test.d.ts +1 -0
- package/build/util/outbound-request.integration.test.js +149 -0
- package/build/util/outbound-request.integration.test.js.map +1 -0
- package/build/util/outbound-request.js +367 -0
- package/build/util/outbound-request.js.map +1 -0
- package/build/util/outbound-request.test.d.ts +1 -0
- package/build/util/outbound-request.test.js +224 -0
- package/build/util/outbound-request.test.js.map +1 -0
- package/build/util/security.js +1 -1
- package/build/util/security.js.map +1 -1
- package/build/util/security.test.js +1 -0
- package/build/util/security.test.js.map +1 -1
- package/package.json +8 -6
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,6 +43,8 @@ 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;
|
|
@@ -95,6 +105,20 @@ export class DocumentStore {
|
|
|
95
105
|
`);
|
|
96
106
|
// Run database migrations
|
|
97
107
|
await this.runMigrations();
|
|
108
|
+
// Reads use a separate connection so they never observe this instance's
|
|
109
|
+
// uncommitted publication transaction.
|
|
110
|
+
this.sqliteReadDb = await open({
|
|
111
|
+
filename: this.dbPath,
|
|
112
|
+
driver: sqlite3.Database,
|
|
113
|
+
});
|
|
114
|
+
await this.sqliteReadDb.exec('PRAGMA busy_timeout = 5000;');
|
|
115
|
+
// Lease renewal uses its own writer so slow Lance staging cannot block
|
|
116
|
+
// heartbeats behind this instance's publication connection.
|
|
117
|
+
this.sqliteLeaseDb = await open({
|
|
118
|
+
filename: this.dbPath,
|
|
119
|
+
driver: sqlite3.Database,
|
|
120
|
+
});
|
|
121
|
+
await this.sqliteLeaseDb.exec('PRAGMA busy_timeout = 5000;');
|
|
98
122
|
// Initialize LanceDB with error handling
|
|
99
123
|
try {
|
|
100
124
|
logger.debug(`[DocumentStore] Connecting to LanceDB at ${this.vectorDbPath}`);
|
|
@@ -108,6 +132,8 @@ export class DocumentStore {
|
|
|
108
132
|
// Define schema using Apache Arrow
|
|
109
133
|
const vectorType = new FixedSizeList(this.embeddings.dimensions, new Field('item', new Float32(), true));
|
|
110
134
|
const schema = new Schema([
|
|
135
|
+
new Field('generation', new Utf8(), false),
|
|
136
|
+
new Field('published', new Bool(), false),
|
|
111
137
|
new Field('url', new Utf8(), false),
|
|
112
138
|
new Field('title', new Utf8(), false),
|
|
113
139
|
new Field('content', new Utf8(), false),
|
|
@@ -127,15 +153,17 @@ export class DocumentStore {
|
|
|
127
153
|
// Create empty table with schema
|
|
128
154
|
this.lanceTable = await this.lanceConn.createEmptyTable('chunks', schema, { mode: 'create' });
|
|
129
155
|
logger.debug(`[DocumentStore] New chunks table created successfully`);
|
|
130
|
-
// Create FTS index for better text search
|
|
131
|
-
await this.createFTSIndex();
|
|
132
156
|
}
|
|
133
157
|
else {
|
|
134
158
|
logger.debug(`[DocumentStore] Using existing chunks table`);
|
|
135
159
|
this.lanceTable = await this.lanceConn.openTable('chunks');
|
|
136
|
-
//
|
|
137
|
-
await this.
|
|
160
|
+
// Tables created before replacement journaling need a generation marker.
|
|
161
|
+
await this.ensureLanceColumn('generation', "'legacy'");
|
|
162
|
+
await this.ensureLanceColumn('published', 'true');
|
|
138
163
|
}
|
|
164
|
+
await this.recoverDocumentReplacements();
|
|
165
|
+
await this.reapOrphanedUnpublishedGenerations();
|
|
166
|
+
await this.createFTSIndex();
|
|
139
167
|
// Verify table is accessible
|
|
140
168
|
const rowCount = await this.lanceTable.countRows();
|
|
141
169
|
logger.debug(`[DocumentStore] Chunks table initialized, contains ${rowCount} rows`);
|
|
@@ -148,9 +176,58 @@ export class DocumentStore {
|
|
|
148
176
|
}
|
|
149
177
|
catch (error) {
|
|
150
178
|
logger.error('[DocumentStore] Error initializing storage:', error);
|
|
179
|
+
try {
|
|
180
|
+
await this.close();
|
|
181
|
+
}
|
|
182
|
+
catch (cleanupError) {
|
|
183
|
+
logger.warn('[DocumentStore] Storage cleanup after initialization failure was incomplete:', cleanupError);
|
|
184
|
+
}
|
|
151
185
|
throw error;
|
|
152
186
|
}
|
|
153
187
|
}
|
|
188
|
+
async close() {
|
|
189
|
+
const errors = [];
|
|
190
|
+
const closeResource = async (name, resource, clear) => {
|
|
191
|
+
if (!resource) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
await resource.close();
|
|
196
|
+
clear();
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
errors.push(error);
|
|
200
|
+
logger.warn(`[DocumentStore] Failed to close ${name}:`, error);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
await closeResource('LanceDB table', this.lanceTable, () => (this.lanceTable = undefined));
|
|
204
|
+
await closeResource('LanceDB connection', this.lanceConn, () => (this.lanceConn = undefined));
|
|
205
|
+
await closeResource('SQLite reader', this.sqliteReadDb, () => (this.sqliteReadDb = undefined));
|
|
206
|
+
await closeResource('SQLite lease writer', this.sqliteLeaseDb, () => (this.sqliteLeaseDb = undefined));
|
|
207
|
+
await closeResource('SQLite writer', this.sqliteDb, () => (this.sqliteDb = undefined));
|
|
208
|
+
if (errors.length > 0) {
|
|
209
|
+
throw new AggregateError(errors, 'Failed to close one or more storage resources');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async ensureLanceColumn(name, valueSql) {
|
|
213
|
+
if (!this.lanceTable) {
|
|
214
|
+
throw new Error('Storage not initialized');
|
|
215
|
+
}
|
|
216
|
+
const schema = await this.lanceTable.schema();
|
|
217
|
+
if (schema.fields.some((field) => field.name === name)) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
await this.lanceTable.addColumns([{ name, valueSql }]);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
await this.lanceTable.checkoutLatest();
|
|
225
|
+
const refreshedSchema = await this.lanceTable.schema();
|
|
226
|
+
if (!refreshedSchema.fields.some((field) => field.name === name)) {
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
154
231
|
/**
|
|
155
232
|
* Database migrations list.
|
|
156
233
|
* Each migration has a unique version number and SQL statements to execute.
|
|
@@ -213,6 +290,20 @@ export class DocumentStore {
|
|
|
213
290
|
CREATE INDEX IF NOT EXISTS idx_collection_documents_url ON collection_documents(url);
|
|
214
291
|
`,
|
|
215
292
|
},
|
|
293
|
+
{
|
|
294
|
+
version: 5,
|
|
295
|
+
description: 'Add document replacement journal',
|
|
296
|
+
sql: `
|
|
297
|
+
CREATE TABLE IF NOT EXISTS document_replacements (
|
|
298
|
+
url TEXT PRIMARY KEY,
|
|
299
|
+
generation TEXT NOT NULL,
|
|
300
|
+
state TEXT NOT NULL CHECK (state IN ('prepared', 'published', 'deleting')),
|
|
301
|
+
owner_id TEXT NOT NULL,
|
|
302
|
+
lease_expires_at INTEGER NOT NULL,
|
|
303
|
+
cleanup_generations TEXT NOT NULL
|
|
304
|
+
);
|
|
305
|
+
`,
|
|
306
|
+
},
|
|
216
307
|
];
|
|
217
308
|
/**
|
|
218
309
|
* Run pending database migrations.
|
|
@@ -264,7 +355,7 @@ export class DocumentStore {
|
|
|
264
355
|
}
|
|
265
356
|
}
|
|
266
357
|
// Record successful migration
|
|
267
|
-
await this.sqliteDb.run('INSERT INTO schema_migrations (version, applied_at, description) VALUES (?, ?, ?)', [
|
|
358
|
+
await this.sqliteDb.run('INSERT OR IGNORE INTO schema_migrations (version, applied_at, description) VALUES (?, ?, ?)', [
|
|
268
359
|
migration.version,
|
|
269
360
|
new Date().toISOString(),
|
|
270
361
|
migration.description,
|
|
@@ -277,87 +368,377 @@ export class DocumentStore {
|
|
|
277
368
|
}
|
|
278
369
|
}
|
|
279
370
|
}
|
|
280
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Stage a complete generation in LanceDB, then publish it through SQLite.
|
|
373
|
+
* Search only reads the published generation, so failed staging never leaks.
|
|
374
|
+
*/
|
|
375
|
+
async addDocument(doc, options = {}) {
|
|
376
|
+
const { signal, tags } = options;
|
|
281
377
|
logger.debug(`[DocumentStore] Starting addDocument for:`, {
|
|
282
378
|
url: doc.metadata.url,
|
|
283
379
|
title: doc.metadata.title,
|
|
284
380
|
chunks: doc.chunks.length,
|
|
285
381
|
});
|
|
286
|
-
// Add diagnostic logging for vector dimensions
|
|
287
382
|
if (doc.chunks.length > 0) {
|
|
288
383
|
logger.debug(`[DocumentStore] Sample vector dimensions: ${doc.chunks[0].vector.length}`);
|
|
289
384
|
logger.debug(`[DocumentStore] Sample vector first 5 values: ${doc.chunks[0].vector.slice(0, 5)}`);
|
|
290
385
|
}
|
|
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');
|
|
386
|
+
const sqliteDb = this.sqliteDb;
|
|
387
|
+
const sqliteReadDb = this.sqliteReadDb;
|
|
388
|
+
if (!sqliteDb || !sqliteReadDb || !this.sqliteLeaseDb || !this.lanceTable) {
|
|
389
|
+
throw new Error('Storage not initialized');
|
|
299
390
|
}
|
|
391
|
+
const url = doc.metadata.url;
|
|
392
|
+
const generation = randomUUID();
|
|
393
|
+
const ownerId = randomUUID();
|
|
394
|
+
const newRows = this.toLanceRows(doc, generation);
|
|
395
|
+
const lease = await this.acquireReplacementLease(url, generation, ownerId, signal);
|
|
396
|
+
let stopHeartbeat = () => Promise.resolve();
|
|
397
|
+
let transactionStarted = false;
|
|
300
398
|
try {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
399
|
+
const preparedJournal = await this.captureCleanupGenerations(lease);
|
|
400
|
+
stopHeartbeat = this.startReplacementHeartbeat(preparedJournal);
|
|
401
|
+
// The committed journal advances reader data_version before staged rows
|
|
402
|
+
// can become queryable.
|
|
403
|
+
signal?.throwIfAborted();
|
|
404
|
+
if (newRows.length > 0) {
|
|
405
|
+
await this.lanceTable.add(newRows);
|
|
305
406
|
}
|
|
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);
|
|
407
|
+
await this.renewReplacementLease(preparedJournal);
|
|
408
|
+
await this.lanceTable.update({
|
|
409
|
+
where: `url = '${escapeFilterValue(url)}' AND generation = '${escapeFilterValue(generation)}'`,
|
|
410
|
+
values: { published: true },
|
|
411
|
+
});
|
|
412
|
+
await stopHeartbeat();
|
|
413
|
+
await this.renewReplacementLease(preparedJournal);
|
|
414
|
+
await sqliteDb.run('BEGIN TRANSACTION');
|
|
415
|
+
transactionStarted = true;
|
|
416
|
+
signal?.throwIfAborted();
|
|
417
|
+
await this.upsertMetadata(doc.metadata);
|
|
418
|
+
if (tags !== undefined) {
|
|
419
|
+
await this.replaceDocumentTags(url, tags);
|
|
420
|
+
}
|
|
421
|
+
signal?.throwIfAborted();
|
|
422
|
+
const publicationLeaseExpiresAt = Date.now() + REPLACEMENT_LEASE_MS;
|
|
423
|
+
const publication = await sqliteDb.run(`UPDATE document_replacements
|
|
424
|
+
SET state = 'published', lease_expires_at = ?
|
|
425
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = 'prepared'`, [publicationLeaseExpiresAt, url, generation, ownerId]);
|
|
426
|
+
if (publication.changes !== 1) {
|
|
427
|
+
throw new Error(`Replacement lease lost for ${url}`);
|
|
428
|
+
}
|
|
429
|
+
signal?.throwIfAborted();
|
|
430
|
+
await sqliteDb.run('COMMIT');
|
|
431
|
+
transactionStarted = false;
|
|
432
|
+
await this.finishDocumentReplacementBestEffort({
|
|
433
|
+
...preparedJournal,
|
|
434
|
+
state: 'published',
|
|
435
|
+
lease_expires_at: publicationLeaseExpiresAt,
|
|
436
|
+
});
|
|
350
437
|
}
|
|
351
438
|
catch (error) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
439
|
+
await stopHeartbeat().catch((heartbeatError) => {
|
|
440
|
+
logger.warn(`[DocumentStore] Replacement heartbeat stopped with an error for ${url}:`, heartbeatError);
|
|
441
|
+
});
|
|
442
|
+
if (transactionStarted) {
|
|
443
|
+
try {
|
|
444
|
+
await sqliteDb.run('ROLLBACK');
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// The transaction may already have been rolled back by SQLite.
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
let durableJournal;
|
|
451
|
+
try {
|
|
452
|
+
durableJournal = await sqliteReadDb.get('SELECT * FROM document_replacements WHERE url = ? AND generation = ? AND owner_id = ?', [url, generation, ownerId]);
|
|
453
|
+
}
|
|
454
|
+
catch (journalError) {
|
|
455
|
+
logger.error(`[DocumentStore] Could not determine durable replacement state for ${url}:`, journalError);
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
if (durableJournal) {
|
|
459
|
+
await this.finishDocumentReplacementBestEffort(durableJournal);
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
await this.deleteUnpublishedGenerationBestEffort(url, generation);
|
|
355
463
|
}
|
|
356
464
|
logger.error('[DocumentStore] Error adding document:', error);
|
|
357
465
|
throw error;
|
|
358
466
|
}
|
|
359
467
|
}
|
|
468
|
+
async acquireReplacementLease(url, generation, ownerId, signal) {
|
|
469
|
+
if (!this.sqliteLeaseDb) {
|
|
470
|
+
throw new Error('Storage not initialized');
|
|
471
|
+
}
|
|
472
|
+
while (true) {
|
|
473
|
+
signal?.throwIfAborted();
|
|
474
|
+
const now = Date.now();
|
|
475
|
+
const leaseExpiresAt = now + REPLACEMENT_LEASE_MS;
|
|
476
|
+
const insertion = await this.sqliteLeaseDb.run(`INSERT OR IGNORE INTO document_replacements
|
|
477
|
+
(url, generation, state, owner_id, lease_expires_at, cleanup_generations)
|
|
478
|
+
VALUES (?, ?, 'prepared', ?, ?, '[]')`, [url, generation, ownerId, leaseExpiresAt]);
|
|
479
|
+
if (insertion.changes === 1) {
|
|
480
|
+
return { url, generation, state: 'prepared', owner_id: ownerId, lease_expires_at: leaseExpiresAt, cleanup_generations: '[]' };
|
|
481
|
+
}
|
|
482
|
+
const existing = await this.sqliteLeaseDb.get('SELECT * FROM document_replacements WHERE url = ?', [url]);
|
|
483
|
+
if (!existing) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (existing.lease_expires_at <= now) {
|
|
487
|
+
const claim = await this.sqliteLeaseDb.run(`UPDATE document_replacements
|
|
488
|
+
SET owner_id = ?, lease_expires_at = ?
|
|
489
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = ? AND lease_expires_at <= ?`, [ownerId, leaseExpiresAt, url, existing.generation, existing.owner_id, existing.state, now]);
|
|
490
|
+
if (claim.changes === 1) {
|
|
491
|
+
await this.finishDocumentReplacement({ ...existing, owner_id: ownerId, lease_expires_at: leaseExpiresAt });
|
|
492
|
+
}
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
await delay(Math.max(1, Math.min(REPLACEMENT_LEASE_POLL_MS, existing.lease_expires_at - now)), undefined, { signal });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async captureCleanupGenerations(journal) {
|
|
499
|
+
if (!this.lanceTable || !this.sqliteLeaseDb) {
|
|
500
|
+
throw new Error('Storage not initialized');
|
|
501
|
+
}
|
|
502
|
+
await this.lanceTable.checkoutLatest();
|
|
503
|
+
const rows = await this.lanceTable
|
|
504
|
+
.query()
|
|
505
|
+
.where(`url = '${escapeFilterValue(journal.url)}'`)
|
|
506
|
+
.select(['generation'])
|
|
507
|
+
.toArray();
|
|
508
|
+
const cleanupGenerations = JSON.stringify([...new Set(rows.map((row) => String(row.generation)))].sort());
|
|
509
|
+
const leaseExpiresAt = Date.now() + REPLACEMENT_LEASE_MS;
|
|
510
|
+
const update = await this.sqliteLeaseDb.run(`UPDATE document_replacements
|
|
511
|
+
SET cleanup_generations = ?, lease_expires_at = ?
|
|
512
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = 'prepared'`, [cleanupGenerations, leaseExpiresAt, journal.url, journal.generation, journal.owner_id]);
|
|
513
|
+
if (update.changes !== 1) {
|
|
514
|
+
throw new Error(`Replacement lease lost for ${journal.url}`);
|
|
515
|
+
}
|
|
516
|
+
return { ...journal, cleanup_generations: cleanupGenerations, lease_expires_at: leaseExpiresAt };
|
|
517
|
+
}
|
|
518
|
+
startReplacementHeartbeat(journal) {
|
|
519
|
+
let stopped = false;
|
|
520
|
+
let timer;
|
|
521
|
+
let renewal = Promise.resolve();
|
|
522
|
+
let renewalError;
|
|
523
|
+
const schedule = () => {
|
|
524
|
+
timer = setTimeout(() => {
|
|
525
|
+
renewal = this.renewReplacementLease(journal)
|
|
526
|
+
.catch((error) => {
|
|
527
|
+
renewalError = error;
|
|
528
|
+
})
|
|
529
|
+
.finally(() => {
|
|
530
|
+
if (!stopped && !renewalError) {
|
|
531
|
+
schedule();
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
}, REPLACEMENT_HEARTBEAT_MS);
|
|
535
|
+
timer.unref();
|
|
536
|
+
};
|
|
537
|
+
schedule();
|
|
538
|
+
return async () => {
|
|
539
|
+
stopped = true;
|
|
540
|
+
if (timer) {
|
|
541
|
+
clearTimeout(timer);
|
|
542
|
+
}
|
|
543
|
+
await renewal;
|
|
544
|
+
if (renewalError) {
|
|
545
|
+
throw renewalError;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
async renewReplacementLease(journal) {
|
|
550
|
+
if (!this.sqliteLeaseDb) {
|
|
551
|
+
throw new Error('Storage not initialized');
|
|
552
|
+
}
|
|
553
|
+
const renewal = await this.sqliteLeaseDb.run(`UPDATE document_replacements
|
|
554
|
+
SET lease_expires_at = ?
|
|
555
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = ?`, [Date.now() + REPLACEMENT_LEASE_MS, journal.url, journal.generation, journal.owner_id, journal.state]);
|
|
556
|
+
if (renewal.changes !== 1) {
|
|
557
|
+
throw new Error(`Replacement lease lost for ${journal.url}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
toLanceRows(doc, generation) {
|
|
561
|
+
const lastUpdated = new Date().toISOString();
|
|
562
|
+
return doc.chunks.map((chunk) => ({
|
|
563
|
+
generation,
|
|
564
|
+
published: false,
|
|
565
|
+
url: doc.metadata.url,
|
|
566
|
+
title: doc.metadata.title,
|
|
567
|
+
content: chunk.content,
|
|
568
|
+
path: chunk.path,
|
|
569
|
+
startLine: chunk.startLine,
|
|
570
|
+
endLine: chunk.endLine,
|
|
571
|
+
vector: chunk.vector,
|
|
572
|
+
type: chunk.metadata.type,
|
|
573
|
+
lastUpdated,
|
|
574
|
+
version: chunk.metadata.version ?? '',
|
|
575
|
+
framework: chunk.metadata.framework ?? '',
|
|
576
|
+
language: chunk.metadata.language ?? '',
|
|
577
|
+
codeBlocks: JSON.stringify(chunk.metadata.codeBlocks || []),
|
|
578
|
+
props: JSON.stringify(chunk.metadata.props || []),
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
async upsertMetadata(metadata) {
|
|
582
|
+
if (!this.sqliteDb) {
|
|
583
|
+
throw new Error('Storage not initialized');
|
|
584
|
+
}
|
|
585
|
+
await this.sqliteDb.run(`INSERT INTO documents (url, title, favicon, last_indexed, requires_auth, auth_domain, version)
|
|
586
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
587
|
+
ON CONFLICT(url) DO UPDATE SET
|
|
588
|
+
title = excluded.title,
|
|
589
|
+
favicon = excluded.favicon,
|
|
590
|
+
last_indexed = excluded.last_indexed,
|
|
591
|
+
requires_auth = excluded.requires_auth,
|
|
592
|
+
auth_domain = excluded.auth_domain,
|
|
593
|
+
version = excluded.version`, [
|
|
594
|
+
metadata.url,
|
|
595
|
+
metadata.title,
|
|
596
|
+
metadata.favicon ?? null,
|
|
597
|
+
metadata.lastIndexed.toISOString(),
|
|
598
|
+
metadata.requiresAuth ? 1 : 0,
|
|
599
|
+
metadata.authDomain ?? null,
|
|
600
|
+
metadata.version ?? null,
|
|
601
|
+
]);
|
|
602
|
+
}
|
|
603
|
+
async recoverDocumentReplacements() {
|
|
604
|
+
if (!this.sqliteReadDb || !this.sqliteLeaseDb || !this.lanceTable) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const now = Date.now();
|
|
608
|
+
const journals = await this.sqliteReadDb.all('SELECT * FROM document_replacements WHERE lease_expires_at <= ?', [now]);
|
|
609
|
+
for (const journal of journals) {
|
|
610
|
+
const ownerId = randomUUID();
|
|
611
|
+
const leaseExpiresAt = Date.now() + REPLACEMENT_LEASE_MS;
|
|
612
|
+
const claim = await this.sqliteLeaseDb.run(`UPDATE document_replacements
|
|
613
|
+
SET owner_id = ?, lease_expires_at = ?
|
|
614
|
+
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()]);
|
|
615
|
+
if (claim.changes === 1) {
|
|
616
|
+
await this.finishDocumentReplacement({ ...journal, owner_id: ownerId, lease_expires_at: leaseExpiresAt });
|
|
617
|
+
logger.info(`[DocumentStore] Recovered ${journal.state} replacement for ${journal.url}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
async reapOrphanedUnpublishedGenerations() {
|
|
622
|
+
if (!this.sqliteReadDb || !this.lanceTable) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
await this.lanceTable.checkoutLatest();
|
|
627
|
+
const rows = await this.lanceTable.query().where('published = false').select(['generation']).toArray();
|
|
628
|
+
const orphaned = new Set(rows.map((row) => String(row.generation)));
|
|
629
|
+
if (orphaned.size === 0) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const journals = await this.sqliteReadDb.all('SELECT generation FROM document_replacements');
|
|
633
|
+
for (const { generation } of journals) {
|
|
634
|
+
orphaned.delete(generation);
|
|
635
|
+
}
|
|
636
|
+
if (orphaned.size > 0) {
|
|
637
|
+
const generations = [...orphaned].map((generation) => `'${escapeFilterValue(generation)}'`).join(', ');
|
|
638
|
+
await this.lanceTable.delete(`published = false AND generation IN (${generations})`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
logger.warn('[DocumentStore] Orphaned unpublished generation cleanup deferred:', error);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
async finishDocumentReplacement(journal) {
|
|
646
|
+
if (!this.sqliteLeaseDb || !this.lanceTable) {
|
|
647
|
+
throw new Error('Storage not initialized');
|
|
648
|
+
}
|
|
649
|
+
await this.renewReplacementLease(journal);
|
|
650
|
+
const url = escapeFilterValue(journal.url);
|
|
651
|
+
const cleanupGenerations = journal.state === 'prepared' ? [journal.generation] : this.parseCleanupGenerations(journal.cleanup_generations);
|
|
652
|
+
for (const generation of cleanupGenerations) {
|
|
653
|
+
await this.lanceTable.delete(`url = '${url}' AND generation = '${escapeFilterValue(generation)}'`);
|
|
654
|
+
}
|
|
655
|
+
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]);
|
|
656
|
+
if (deletion.changes !== 1) {
|
|
657
|
+
throw new Error(`Replacement lease lost for ${journal.url}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
parseCleanupGenerations(value) {
|
|
661
|
+
const parsed = JSON.parse(value);
|
|
662
|
+
if (!Array.isArray(parsed) || !parsed.every((generation) => typeof generation === 'string')) {
|
|
663
|
+
throw new Error('Invalid replacement cleanup generations');
|
|
664
|
+
}
|
|
665
|
+
return [...new Set(parsed)];
|
|
666
|
+
}
|
|
667
|
+
async deleteUnpublishedGenerationBestEffort(url, generation) {
|
|
668
|
+
try {
|
|
669
|
+
await this.lanceTable?.delete(`url = '${escapeFilterValue(url)}' AND generation = '${escapeFilterValue(generation)}' AND published = false`);
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
logger.warn(`[DocumentStore] Stale replacement generation cleanup deferred for ${url}:`, error);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async finishDocumentReplacementBestEffort(journal) {
|
|
676
|
+
try {
|
|
677
|
+
await this.finishDocumentReplacement(journal);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
logger.warn(`[DocumentStore] ${journal.state} replacement cleanup deferred for ${journal.url}:`, error);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async getJournalVisibilityFilter() {
|
|
684
|
+
if (!this.sqliteReadDb) {
|
|
685
|
+
throw new Error('Storage not initialized');
|
|
686
|
+
}
|
|
687
|
+
const journals = await this.sqliteReadDb.all('SELECT * FROM document_replacements');
|
|
688
|
+
const journalFilters = journals
|
|
689
|
+
.map(({ url, generation, state }) => {
|
|
690
|
+
const escapedUrl = escapeFilterValue(url);
|
|
691
|
+
const escapedGeneration = escapeFilterValue(generation);
|
|
692
|
+
if (state === 'prepared') {
|
|
693
|
+
return `(url != '${escapedUrl}' OR generation != '${escapedGeneration}')`;
|
|
694
|
+
}
|
|
695
|
+
if (state === 'published') {
|
|
696
|
+
return `(url != '${escapedUrl}' OR generation = '${escapedGeneration}')`;
|
|
697
|
+
}
|
|
698
|
+
return `url != '${escapedUrl}'`;
|
|
699
|
+
})
|
|
700
|
+
.join(' AND ');
|
|
701
|
+
return journalFilters ? `published = true AND ${journalFilters}` : 'published = true';
|
|
702
|
+
}
|
|
703
|
+
async withStableSearch(operation) {
|
|
704
|
+
let deadline;
|
|
705
|
+
let backoffMs = 1;
|
|
706
|
+
while (true) {
|
|
707
|
+
const dataVersion = await this.getDataVersion();
|
|
708
|
+
if (!this.lanceTable) {
|
|
709
|
+
throw new Error('Storage not initialized');
|
|
710
|
+
}
|
|
711
|
+
// Other DocumentStore instances hold their own Lance table handle; refresh
|
|
712
|
+
// it to the latest committed table version before evaluating this snapshot.
|
|
713
|
+
await this.lanceTable.checkoutLatest();
|
|
714
|
+
const result = await operation(dataVersion);
|
|
715
|
+
if (dataVersion === (await this.getDataVersion())) {
|
|
716
|
+
return { result, dataVersion };
|
|
717
|
+
}
|
|
718
|
+
deadline ??= Date.now() + SEARCH_VISIBILITY_RETRY_WINDOW_MS;
|
|
719
|
+
if (Date.now() >= deadline) {
|
|
720
|
+
throw new Error('Search visibility kept changing; please retry');
|
|
721
|
+
}
|
|
722
|
+
await delay(backoffMs);
|
|
723
|
+
backoffMs = Math.min(backoffMs * 2, SEARCH_VISIBILITY_RETRY_MAX_BACKOFF_MS);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async getDataVersion() {
|
|
727
|
+
if (!this.sqliteReadDb) {
|
|
728
|
+
throw new Error('Storage not initialized');
|
|
729
|
+
}
|
|
730
|
+
// PRAGMA data_version is connection-relative: comparing successive reads
|
|
731
|
+
// on this reader detects commits from this instance's writer and other processes.
|
|
732
|
+
const row = await this.sqliteReadDb.get('PRAGMA data_version');
|
|
733
|
+
if (!row) {
|
|
734
|
+
throw new Error('Could not read SQLite data version');
|
|
735
|
+
}
|
|
736
|
+
return row.data_version;
|
|
737
|
+
}
|
|
360
738
|
async searchDocuments(queryVector, options = {}) {
|
|
739
|
+
return (await this.withStableSearch(() => this.searchDocumentsOnce(queryVector, options))).result;
|
|
740
|
+
}
|
|
741
|
+
async searchDocumentsOnce(queryVector, options) {
|
|
361
742
|
if (!this.lanceTable) {
|
|
362
743
|
throw new Error('Storage not initialized');
|
|
363
744
|
}
|
|
@@ -418,10 +799,9 @@ export class DocumentStore {
|
|
|
418
799
|
}
|
|
419
800
|
logger.debug(`[DocumentStore] Tag filter matched ${tagFilteredUrls.length} documents for vector search`);
|
|
420
801
|
}
|
|
421
|
-
// Create search query
|
|
422
|
-
let query = this.lanceTable.search(queryVector).limit(limit);
|
|
423
802
|
// Build WHERE conditions
|
|
424
|
-
const
|
|
803
|
+
const visibilityFilter = await this.getJournalVisibilityFilter();
|
|
804
|
+
const conditions = [`(${visibilityFilter})`];
|
|
425
805
|
if (filterByType) {
|
|
426
806
|
conditions.push(`type = '${escapeFilterValue(filterByType)}'`);
|
|
427
807
|
}
|
|
@@ -429,9 +809,7 @@ export class DocumentStore {
|
|
|
429
809
|
const urlConditions = tagFilteredUrls.map((u) => `url = '${escapeFilterValue(u)}'`).join(' OR ');
|
|
430
810
|
conditions.push(`(${urlConditions})`);
|
|
431
811
|
}
|
|
432
|
-
|
|
433
|
-
query = query.where(conditions.join(' AND '));
|
|
434
|
-
}
|
|
812
|
+
const query = this.lanceTable.search(queryVector).where(conditions.join(' AND ')).limit(limit);
|
|
435
813
|
const results = await query.toArray();
|
|
436
814
|
logger.debug(`[DocumentStore] Found ${results.length} results`);
|
|
437
815
|
// Log the first result for debugging if available
|
|
@@ -524,14 +902,23 @@ export class DocumentStore {
|
|
|
524
902
|
}
|
|
525
903
|
}
|
|
526
904
|
async searchByText(query, options = {}) {
|
|
905
|
+
const { result, dataVersion } = await this.withStableSearch((version) => this.searchByTextOnce(query, options, version));
|
|
906
|
+
this.searchCache.set(this.textSearchCacheKey(query, options, dataVersion), result);
|
|
907
|
+
return result;
|
|
908
|
+
}
|
|
909
|
+
textSearchCacheKey(query, options, dataVersion) {
|
|
910
|
+
return `text:${dataVersion}:${query}:${JSON.stringify(options)}`;
|
|
911
|
+
}
|
|
912
|
+
async searchByTextOnce(query, options, dataVersion) {
|
|
527
913
|
logger.debug(`[DocumentStore] Searching documents by text:`, { query, options });
|
|
528
|
-
const cacheKey =
|
|
914
|
+
const cacheKey = this.textSearchCacheKey(query, options, dataVersion);
|
|
529
915
|
const cached = this.searchCache.get(cacheKey);
|
|
530
916
|
if (cached) {
|
|
531
917
|
logger.debug(`[DocumentStore] Returning cached results`);
|
|
532
918
|
return cached;
|
|
533
919
|
}
|
|
534
920
|
const { limit = 10, filterByType, filterUrl, filterByTags } = options;
|
|
921
|
+
const visibilityFilter = await this.getJournalVisibilityFilter();
|
|
535
922
|
// If filtering by tags, get the list of URLs that have all those tags
|
|
536
923
|
let tagFilteredUrls;
|
|
537
924
|
if (filterByTags && filterByTags.length > 0) {
|
|
@@ -540,14 +927,13 @@ export class DocumentStore {
|
|
|
540
927
|
// No documents match the tag filter, cache and return empty results
|
|
541
928
|
logger.debug(`[DocumentStore] No documents match tag filter:`, filterByTags);
|
|
542
929
|
const emptyResults = [];
|
|
543
|
-
this.searchCache.set(cacheKey, emptyResults);
|
|
544
930
|
return emptyResults;
|
|
545
931
|
}
|
|
546
932
|
logger.debug(`[DocumentStore] Tag filter matched ${tagFilteredUrls.length} documents`);
|
|
547
933
|
}
|
|
548
934
|
// Build WHERE clause for filtering (using escaped values to prevent injection)
|
|
549
935
|
const buildWhereClause = () => {
|
|
550
|
-
const conditions = [];
|
|
936
|
+
const conditions = [`(${visibilityFilter})`];
|
|
551
937
|
if (filterByType) {
|
|
552
938
|
conditions.push(`type = '${escapeFilterValue(filterByType)}'`);
|
|
553
939
|
}
|
|
@@ -562,7 +948,7 @@ export class DocumentStore {
|
|
|
562
948
|
const urlConditions = tagFilteredUrls.map((u) => `url = '${escapeFilterValue(u)}'`).join(' OR ');
|
|
563
949
|
conditions.push(`(${urlConditions})`);
|
|
564
950
|
}
|
|
565
|
-
return conditions.
|
|
951
|
+
return conditions.join(' AND ');
|
|
566
952
|
};
|
|
567
953
|
const whereClause = buildWhereClause();
|
|
568
954
|
try {
|
|
@@ -589,25 +975,22 @@ export class DocumentStore {
|
|
|
589
975
|
queries.push([Occur.Should, new MatchQuery(processedQuery.cleanedQuery, 'content', { fuzziness: 1 })]);
|
|
590
976
|
}
|
|
591
977
|
const boolQuery = new BooleanQuery(queries);
|
|
592
|
-
|
|
978
|
+
const ftsQuery = this.lanceTable
|
|
593
979
|
.query()
|
|
594
980
|
.fullTextSearch(boolQuery)
|
|
981
|
+
.where(whereClause)
|
|
595
982
|
.limit(limit * 2);
|
|
596
|
-
if (whereClause) {
|
|
597
|
-
ftsQuery = ftsQuery.where(whereClause);
|
|
598
|
-
}
|
|
599
983
|
const ftsResults = await ftsQuery.toArray();
|
|
600
984
|
logger.debug(`[DocumentStore] Phrase-based FTS returned ${ftsResults.length} results`);
|
|
601
985
|
if (ftsResults.length > 0) {
|
|
602
986
|
// Combine with vector search for semantic relevance
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
987
|
+
const vectorQuery = this.lanceTable
|
|
988
|
+
.search(queryVector)
|
|
989
|
+
.where(whereClause)
|
|
990
|
+
.limit(limit * 2);
|
|
607
991
|
const vectorResults = await vectorQuery.toArray();
|
|
608
992
|
const mergedResults = this.mergeAndRankResults(ftsResults, vectorResults, limit);
|
|
609
993
|
const searchResults = this.formatSearchResults(mergedResults);
|
|
610
|
-
this.searchCache.set(cacheKey, searchResults);
|
|
611
994
|
return searchResults;
|
|
612
995
|
}
|
|
613
996
|
}
|
|
@@ -622,27 +1005,24 @@ export class DocumentStore {
|
|
|
622
1005
|
// LanceDB's FTS already handles stop words and stemming
|
|
623
1006
|
// Add fuzziness for typo tolerance
|
|
624
1007
|
const matchQuery = new MatchQuery(processedQuery.cleanedQuery, 'content', { fuzziness: 1 });
|
|
625
|
-
|
|
1008
|
+
const ftsQuery = this.lanceTable
|
|
626
1009
|
.query()
|
|
627
1010
|
.fullTextSearch(matchQuery)
|
|
1011
|
+
.where(whereClause)
|
|
628
1012
|
.limit(limit * 2);
|
|
629
|
-
if (whereClause) {
|
|
630
|
-
ftsQuery = ftsQuery.where(whereClause);
|
|
631
|
-
}
|
|
632
1013
|
const ftsResults = await ftsQuery.toArray();
|
|
633
1014
|
logger.debug(`[DocumentStore] FTS returned ${ftsResults.length} results`);
|
|
634
1015
|
// Always combine with vector search for best results
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1016
|
+
const vectorQuery = this.lanceTable
|
|
1017
|
+
.search(queryVector)
|
|
1018
|
+
.where(whereClause)
|
|
1019
|
+
.limit(limit * 2);
|
|
639
1020
|
const vectorResults = await vectorQuery.toArray();
|
|
640
1021
|
logger.debug(`[DocumentStore] Vector search returned ${vectorResults.length} results`);
|
|
641
1022
|
// Merge using RRF even if one is empty - ensures we get results
|
|
642
1023
|
const mergedResults = this.mergeAndRankResults(ftsResults, vectorResults, limit);
|
|
643
1024
|
if (mergedResults.length > 0) {
|
|
644
1025
|
const searchResults = this.formatSearchResults(mergedResults);
|
|
645
|
-
this.searchCache.set(cacheKey, searchResults);
|
|
646
1026
|
return searchResults;
|
|
647
1027
|
}
|
|
648
1028
|
}
|
|
@@ -653,9 +1033,7 @@ export class DocumentStore {
|
|
|
653
1033
|
}
|
|
654
1034
|
// Strategy 3: Fallback to pure vector search (semantic similarity)
|
|
655
1035
|
logger.debug('[DocumentStore] Falling back to pure vector search');
|
|
656
|
-
|
|
657
|
-
this.searchCache.set(cacheKey, results);
|
|
658
|
-
return results;
|
|
1036
|
+
return this.searchDocumentsOnce(queryVector, options);
|
|
659
1037
|
}
|
|
660
1038
|
catch (error) {
|
|
661
1039
|
logger.error('[DocumentStore] Error searching documents by text:', error);
|
|
@@ -731,15 +1109,16 @@ export class DocumentStore {
|
|
|
731
1109
|
});
|
|
732
1110
|
}
|
|
733
1111
|
async listDocuments() {
|
|
734
|
-
if (!this.
|
|
1112
|
+
if (!this.sqliteReadDb) {
|
|
735
1113
|
throw new Error('Storage not initialized');
|
|
736
1114
|
}
|
|
737
1115
|
logger.debug(`[DocumentStore] Listing documents`);
|
|
738
1116
|
try {
|
|
739
|
-
const rows = await this.
|
|
1117
|
+
const rows = await this.sqliteReadDb.all(`SELECT d.url, d.title, d.favicon, d.last_indexed, d.requires_auth, d.auth_domain, d.version,
|
|
1118
|
+
COALESCE((SELECT json_group_array(dt.tag) FROM document_tags dt WHERE dt.url = d.url), '[]') AS tags_json
|
|
1119
|
+
FROM documents d
|
|
1120
|
+
ORDER BY d.last_indexed DESC`);
|
|
740
1121
|
logger.debug(`[DocumentStore] Found ${rows.length} documents`);
|
|
741
|
-
// Fetch tags for all documents
|
|
742
|
-
const tagsMap = await this.getAllDocumentTags();
|
|
743
1122
|
return rows.map((row) => ({
|
|
744
1123
|
url: row.url,
|
|
745
1124
|
title: row.title,
|
|
@@ -747,7 +1126,7 @@ export class DocumentStore {
|
|
|
747
1126
|
lastIndexed: new Date(row.last_indexed),
|
|
748
1127
|
requiresAuth: row.requires_auth === 1,
|
|
749
1128
|
authDomain: row.auth_domain ?? undefined,
|
|
750
|
-
tags:
|
|
1129
|
+
tags: this.parseDocumentTags(row.tags_json),
|
|
751
1130
|
version: row.version ?? undefined,
|
|
752
1131
|
}));
|
|
753
1132
|
}
|
|
@@ -756,60 +1135,75 @@ export class DocumentStore {
|
|
|
756
1135
|
throw error;
|
|
757
1136
|
}
|
|
758
1137
|
}
|
|
759
|
-
/**
|
|
760
|
-
* Get all tags for all documents as a Map
|
|
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;
|
|
774
|
-
}
|
|
775
1138
|
async deleteDocument(url) {
|
|
776
|
-
|
|
1139
|
+
const sqliteDb = this.sqliteDb;
|
|
1140
|
+
const sqliteReadDb = this.sqliteReadDb;
|
|
1141
|
+
if (!sqliteDb || !sqliteReadDb || !this.sqliteLeaseDb || !this.lanceTable) {
|
|
777
1142
|
throw new Error('Storage not initialized');
|
|
778
1143
|
}
|
|
779
1144
|
logger.debug(`[DocumentStore] Deleting document: ${url}`);
|
|
1145
|
+
const generation = `delete:${randomUUID()}`;
|
|
1146
|
+
const ownerId = randomUUID();
|
|
1147
|
+
const lease = await this.acquireReplacementLease(url, generation, ownerId);
|
|
1148
|
+
let transactionStarted = false;
|
|
780
1149
|
try {
|
|
781
|
-
await this.
|
|
1150
|
+
const preparedJournal = await this.captureCleanupGenerations(lease);
|
|
1151
|
+
await this.renewReplacementLease(preparedJournal);
|
|
1152
|
+
await sqliteDb.run('BEGIN TRANSACTION');
|
|
1153
|
+
transactionStarted = true;
|
|
782
1154
|
// Delete tags first (in case foreign key cascade isn't enabled)
|
|
783
|
-
await
|
|
784
|
-
await
|
|
785
|
-
|
|
786
|
-
await
|
|
787
|
-
|
|
1155
|
+
await sqliteDb.run('DELETE FROM document_tags WHERE url = ?', [url]);
|
|
1156
|
+
await sqliteDb.run('DELETE FROM documents WHERE url = ?', [url]);
|
|
1157
|
+
const leaseExpiresAt = Date.now() + REPLACEMENT_LEASE_MS;
|
|
1158
|
+
const transition = await sqliteDb.run(`UPDATE document_replacements
|
|
1159
|
+
SET state = 'deleting', lease_expires_at = ?
|
|
1160
|
+
WHERE url = ? AND generation = ? AND owner_id = ? AND state = 'prepared'`, [leaseExpiresAt, url, generation, ownerId]);
|
|
1161
|
+
if (transition.changes !== 1) {
|
|
1162
|
+
throw new Error(`Replacement lease lost for ${url}`);
|
|
1163
|
+
}
|
|
1164
|
+
await sqliteDb.run('COMMIT');
|
|
1165
|
+
transactionStarted = false;
|
|
1166
|
+
await this.finishDocumentReplacementBestEffort({
|
|
1167
|
+
...preparedJournal,
|
|
1168
|
+
state: 'deleting',
|
|
1169
|
+
lease_expires_at: leaseExpiresAt,
|
|
1170
|
+
});
|
|
788
1171
|
this.clearCacheForUrl(url);
|
|
789
1172
|
logger.debug(`[DocumentStore] Document deleted successfully`);
|
|
790
1173
|
}
|
|
791
1174
|
catch (error) {
|
|
792
|
-
if (
|
|
793
|
-
|
|
1175
|
+
if (transactionStarted) {
|
|
1176
|
+
try {
|
|
1177
|
+
await sqliteDb.run('ROLLBACK');
|
|
1178
|
+
}
|
|
1179
|
+
catch {
|
|
1180
|
+
// The transaction may already have been rolled back by SQLite.
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
const durableJournal = await sqliteReadDb.get('SELECT * FROM document_replacements WHERE url = ? AND generation = ? AND owner_id = ?', [url, generation, ownerId]);
|
|
1184
|
+
if (durableJournal) {
|
|
1185
|
+
await this.finishDocumentReplacementBestEffort(durableJournal);
|
|
1186
|
+
if (durableJournal.state === 'deleting') {
|
|
1187
|
+
this.clearCacheForUrl(url);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
794
1190
|
}
|
|
795
1191
|
logger.error('[DocumentStore] Error deleting document:', error);
|
|
796
1192
|
throw error;
|
|
797
1193
|
}
|
|
798
1194
|
}
|
|
799
1195
|
async getDocument(url) {
|
|
800
|
-
if (!this.
|
|
1196
|
+
if (!this.sqliteReadDb) {
|
|
801
1197
|
throw new Error('Storage not initialized');
|
|
802
1198
|
}
|
|
803
1199
|
logger.debug(`[DocumentStore] Getting document: ${url}`);
|
|
804
1200
|
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
1201
|
// Log the query being executed
|
|
811
1202
|
logger.debug(`[DocumentStore] Executing SQLite query for URL: ${url}`);
|
|
812
|
-
const row = await this.
|
|
1203
|
+
const row = await this.sqliteReadDb.get(`SELECT d.url, d.title, d.favicon, d.last_indexed, d.requires_auth, d.auth_domain, d.version,
|
|
1204
|
+
COALESCE((SELECT json_group_array(dt.tag) FROM document_tags dt WHERE dt.url = d.url), '[]') AS tags_json
|
|
1205
|
+
FROM documents d
|
|
1206
|
+
WHERE d.url = ?`, [url]);
|
|
813
1207
|
if (!row) {
|
|
814
1208
|
logger.debug(`[DocumentStore] Document not found in SQLite: ${url}`);
|
|
815
1209
|
return null;
|
|
@@ -819,8 +1213,6 @@ export class DocumentStore {
|
|
|
819
1213
|
const chunks = await this.lanceTable.countRows(`url = '${escapeFilterValue(url)}'`);
|
|
820
1214
|
logger.debug(`[DocumentStore] Found ${chunks} chunks in LanceDB for ${url}`);
|
|
821
1215
|
}
|
|
822
|
-
// Fetch tags for this document
|
|
823
|
-
const tags = await this.getDocumentTags(url);
|
|
824
1216
|
logger.debug(`[DocumentStore] Document found in SQLite:`, row);
|
|
825
1217
|
return {
|
|
826
1218
|
url: row.url,
|
|
@@ -829,7 +1221,7 @@ export class DocumentStore {
|
|
|
829
1221
|
lastIndexed: new Date(row.last_indexed),
|
|
830
1222
|
requiresAuth: row.requires_auth === 1,
|
|
831
1223
|
authDomain: row.auth_domain ?? undefined,
|
|
832
|
-
tags,
|
|
1224
|
+
tags: this.parseDocumentTags(row.tags_json),
|
|
833
1225
|
version: row.version ?? undefined,
|
|
834
1226
|
};
|
|
835
1227
|
}
|
|
@@ -838,15 +1230,24 @@ export class DocumentStore {
|
|
|
838
1230
|
throw error;
|
|
839
1231
|
}
|
|
840
1232
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1233
|
+
parseDocumentTags(value) {
|
|
1234
|
+
try {
|
|
1235
|
+
const parsed = JSON.parse(value);
|
|
1236
|
+
return Array.isArray(parsed) && parsed.every((tag) => typeof tag === 'string') ? parsed.sort() : [];
|
|
1237
|
+
}
|
|
1238
|
+
catch {
|
|
846
1239
|
return [];
|
|
847
1240
|
}
|
|
848
|
-
|
|
849
|
-
|
|
1241
|
+
}
|
|
1242
|
+
async replaceDocumentTags(url, tags) {
|
|
1243
|
+
if (!this.sqliteDb) {
|
|
1244
|
+
throw new Error('Storage not initialized');
|
|
1245
|
+
}
|
|
1246
|
+
await this.sqliteDb.run('DELETE FROM document_tags WHERE url = ?', [url]);
|
|
1247
|
+
const normalizedTags = [...new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean))];
|
|
1248
|
+
for (const tag of normalizedTags) {
|
|
1249
|
+
await this.sqliteDb.run('INSERT INTO document_tags (url, tag) VALUES (?, ?)', [url, tag]);
|
|
1250
|
+
}
|
|
850
1251
|
}
|
|
851
1252
|
/**
|
|
852
1253
|
* Set tags for a documentation site. Replaces any existing tags.
|
|
@@ -866,13 +1267,7 @@ export class DocumentStore {
|
|
|
866
1267
|
await this.sqliteDb.run('ROLLBACK');
|
|
867
1268
|
throw new Error('Documentation not found');
|
|
868
1269
|
}
|
|
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
|
-
}
|
|
1270
|
+
await this.replaceDocumentTags(url, tags);
|
|
876
1271
|
await this.sqliteDb.run('COMMIT');
|
|
877
1272
|
// Clear cached search results that may be affected by tag changes
|
|
878
1273
|
this.clearCacheForUrl(url);
|
|
@@ -896,12 +1291,12 @@ export class DocumentStore {
|
|
|
896
1291
|
* @returns Array of tags with counts, sorted by count descending
|
|
897
1292
|
*/
|
|
898
1293
|
async listAllTags() {
|
|
899
|
-
if (!this.
|
|
1294
|
+
if (!this.sqliteReadDb) {
|
|
900
1295
|
throw new Error('Storage not initialized');
|
|
901
1296
|
}
|
|
902
1297
|
logger.debug(`[DocumentStore] Listing all tags`);
|
|
903
1298
|
try {
|
|
904
|
-
const rows = await this.
|
|
1299
|
+
const rows = await this.sqliteReadDb.all('SELECT tag, COUNT(*) as count FROM document_tags GROUP BY tag ORDER BY count DESC, tag ASC');
|
|
905
1300
|
logger.debug(`[DocumentStore] Found ${rows.length} unique tags`);
|
|
906
1301
|
return rows;
|
|
907
1302
|
}
|
|
@@ -916,7 +1311,7 @@ export class DocumentStore {
|
|
|
916
1311
|
* @returns Array of matching document URLs
|
|
917
1312
|
*/
|
|
918
1313
|
async getUrlsByTags(tags) {
|
|
919
|
-
if (!this.
|
|
1314
|
+
if (!this.sqliteReadDb || tags.length === 0) {
|
|
920
1315
|
return [];
|
|
921
1316
|
}
|
|
922
1317
|
// Normalize tags
|
|
@@ -935,7 +1330,7 @@ export class DocumentStore {
|
|
|
935
1330
|
GROUP BY url
|
|
936
1331
|
HAVING COUNT(DISTINCT tag) = ?
|
|
937
1332
|
`;
|
|
938
|
-
const rows = await this.
|
|
1333
|
+
const rows = await this.sqliteReadDb.all(query, [...normalizedTags, normalizedTags.length]);
|
|
939
1334
|
logger.debug(`[DocumentStore] Found ${rows.length} URLs matching all tags`);
|
|
940
1335
|
return rows.map((row) => row.url);
|
|
941
1336
|
}
|
|
@@ -1077,11 +1472,11 @@ export class DocumentStore {
|
|
|
1077
1472
|
* @returns Array of collections sorted by name
|
|
1078
1473
|
*/
|
|
1079
1474
|
async listCollections() {
|
|
1080
|
-
if (!this.
|
|
1475
|
+
if (!this.sqliteReadDb) {
|
|
1081
1476
|
throw new Error('Storage not initialized');
|
|
1082
1477
|
}
|
|
1083
1478
|
logger.debug(`[DocumentStore] Listing collections`);
|
|
1084
|
-
const rows = await this.
|
|
1479
|
+
const rows = await this.sqliteReadDb.all(`
|
|
1085
1480
|
SELECT c.name, c.description, c.created_at, c.updated_at,
|
|
1086
1481
|
COUNT(cd.url) as document_count
|
|
1087
1482
|
FROM collections c
|
|
@@ -1104,27 +1499,26 @@ export class DocumentStore {
|
|
|
1104
1499
|
* @returns Collection with documents, or null if not found
|
|
1105
1500
|
*/
|
|
1106
1501
|
async getCollection(name) {
|
|
1107
|
-
if (!this.
|
|
1502
|
+
if (!this.sqliteReadDb) {
|
|
1108
1503
|
throw new Error('Storage not initialized');
|
|
1109
1504
|
}
|
|
1110
1505
|
const normalizedName = name.trim();
|
|
1111
1506
|
logger.debug(`[DocumentStore] Getting collection: ${normalizedName}`);
|
|
1112
1507
|
// Get collection metadata
|
|
1113
|
-
const row = await this.
|
|
1508
|
+
const row = await this.sqliteReadDb.get('SELECT name, description, created_at, updated_at FROM collections WHERE name = ?', [normalizedName]);
|
|
1114
1509
|
if (!row) {
|
|
1115
1510
|
logger.debug(`[DocumentStore] Collection not found: ${normalizedName}`);
|
|
1116
1511
|
return null;
|
|
1117
1512
|
}
|
|
1118
1513
|
// 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
|
|
1514
|
+
const docRows = await this.sqliteReadDb.all(`
|
|
1515
|
+
SELECT d.url, d.title, d.favicon, d.last_indexed, d.requires_auth, d.auth_domain, d.version,
|
|
1516
|
+
COALESCE((SELECT json_group_array(dt.tag) FROM document_tags dt WHERE dt.url = d.url), '[]') AS tags_json
|
|
1121
1517
|
FROM documents d
|
|
1122
1518
|
INNER JOIN collection_documents cd ON d.url = cd.url
|
|
1123
1519
|
WHERE cd.collection_name = ?
|
|
1124
1520
|
ORDER BY d.title ASC
|
|
1125
1521
|
`, [normalizedName]);
|
|
1126
|
-
// Fetch tags for all documents
|
|
1127
|
-
const tagsMap = await this.getAllDocumentTags();
|
|
1128
1522
|
const documents = docRows.map((doc) => ({
|
|
1129
1523
|
url: doc.url,
|
|
1130
1524
|
title: doc.title,
|
|
@@ -1132,7 +1526,7 @@ export class DocumentStore {
|
|
|
1132
1526
|
lastIndexed: new Date(doc.last_indexed),
|
|
1133
1527
|
requiresAuth: doc.requires_auth === 1,
|
|
1134
1528
|
authDomain: doc.auth_domain ?? undefined,
|
|
1135
|
-
tags:
|
|
1529
|
+
tags: this.parseDocumentTags(doc.tags_json),
|
|
1136
1530
|
version: doc.version ?? undefined,
|
|
1137
1531
|
}));
|
|
1138
1532
|
logger.debug(`[DocumentStore] Collection "${normalizedName}" has ${documents.length} documents`);
|
|
@@ -1270,12 +1664,12 @@ export class DocumentStore {
|
|
|
1270
1664
|
* @returns Array of document URLs
|
|
1271
1665
|
*/
|
|
1272
1666
|
async getCollectionUrls(name) {
|
|
1273
|
-
if (!this.
|
|
1667
|
+
if (!this.sqliteReadDb) {
|
|
1274
1668
|
throw new Error('Storage not initialized');
|
|
1275
1669
|
}
|
|
1276
1670
|
const normalizedName = name.trim();
|
|
1277
1671
|
logger.debug(`[DocumentStore] Getting URLs for collection: ${normalizedName}`);
|
|
1278
|
-
const rows = await this.
|
|
1672
|
+
const rows = await this.sqliteReadDb.all('SELECT url FROM collection_documents WHERE collection_name = ?', [
|
|
1279
1673
|
normalizedName,
|
|
1280
1674
|
]);
|
|
1281
1675
|
return rows.map((row) => row.url);
|
|
@@ -1299,15 +1693,17 @@ export class DocumentStore {
|
|
|
1299
1693
|
throw new Error('Storage not initialized');
|
|
1300
1694
|
}
|
|
1301
1695
|
try {
|
|
1696
|
+
const visibilityFilter = await this.getJournalVisibilityFilter();
|
|
1302
1697
|
// Get total row count
|
|
1303
|
-
const rowCount = await this.lanceTable.countRows();
|
|
1698
|
+
const rowCount = await this.lanceTable.countRows(visibilityFilter);
|
|
1304
1699
|
logger.debug(`[DocumentStore] Vector validation: Table contains ${rowCount} rows`);
|
|
1305
1700
|
if (rowCount === 0) {
|
|
1306
1701
|
logger.debug('[DocumentStore] Vector validation: No rows found in vector table');
|
|
1307
1702
|
return false;
|
|
1308
1703
|
}
|
|
1309
1704
|
// Get a sample row using a query
|
|
1310
|
-
const
|
|
1705
|
+
const sampleQuery = this.lanceTable.query().where(visibilityFilter).limit(1);
|
|
1706
|
+
const sample = await sampleQuery.toArray();
|
|
1311
1707
|
if (sample.length === 0) {
|
|
1312
1708
|
logger.debug('[DocumentStore] Vector validation: No rows returned from query');
|
|
1313
1709
|
return false;
|
|
@@ -1323,7 +1719,8 @@ export class DocumentStore {
|
|
|
1323
1719
|
// Try a simple vector search with a random vector
|
|
1324
1720
|
const testVector = new Array(this.embeddings.dimensions).fill(0).map(() => Math.random());
|
|
1325
1721
|
logger.debug(`[DocumentStore] Testing vector search with random vector of length ${testVector.length}`);
|
|
1326
|
-
const
|
|
1722
|
+
const searchQuery = this.lanceTable.search(testVector).where(visibilityFilter).limit(1);
|
|
1723
|
+
const searchResults = await searchQuery.toArray();
|
|
1327
1724
|
logger.debug(`[DocumentStore] Vector search test returned ${searchResults.length} results`);
|
|
1328
1725
|
if (searchResults.length > 0) {
|
|
1329
1726
|
logger.debug('[DocumentStore] Vector search test result:', {
|