@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.
Files changed (46) hide show
  1. package/build/crawler/auth.js +117 -7
  2. package/build/crawler/auth.js.map +1 -1
  3. package/build/crawler/auth.test.js +333 -83
  4. package/build/crawler/auth.test.js.map +1 -1
  5. package/build/crawler/browser-config.d.ts +1 -1
  6. package/build/crawler/browser-config.js +7 -1
  7. package/build/crawler/browser-config.js.map +1 -1
  8. package/build/crawler/browser-config.test.js +21 -25
  9. package/build/crawler/browser-config.test.js.map +1 -1
  10. package/build/crawler/crawlee-crawler.d.ts +3 -0
  11. package/build/crawler/crawlee-crawler.js +155 -12
  12. package/build/crawler/crawlee-crawler.js.map +1 -1
  13. package/build/crawler/crawlee-crawler.test.js +190 -143
  14. package/build/crawler/crawlee-crawler.test.js.map +1 -1
  15. package/build/crawler/github.js +3 -2
  16. package/build/crawler/github.js.map +1 -1
  17. package/build/crawler/llms-txt.js +2 -1
  18. package/build/crawler/llms-txt.js.map +1 -1
  19. package/build/index.js +21 -24
  20. package/build/index.js.map +1 -1
  21. package/build/setupTests.js +44 -0
  22. package/build/setupTests.js.map +1 -1
  23. package/build/storage/storage.d.ts +30 -10
  24. package/build/storage/storage.js +567 -170
  25. package/build/storage/storage.js.map +1 -1
  26. package/build/storage/storage.test.js +721 -2
  27. package/build/storage/storage.test.js.map +1 -1
  28. package/build/types.d.ts +6 -1
  29. package/build/util/favicon.js +2 -1
  30. package/build/util/favicon.js.map +1 -1
  31. package/build/util/favicon.test.js +27 -37
  32. package/build/util/favicon.test.js.map +1 -1
  33. package/build/util/outbound-request.d.ts +33 -0
  34. package/build/util/outbound-request.integration.test.d.ts +1 -0
  35. package/build/util/outbound-request.integration.test.js +149 -0
  36. package/build/util/outbound-request.integration.test.js.map +1 -0
  37. package/build/util/outbound-request.js +367 -0
  38. package/build/util/outbound-request.js.map +1 -0
  39. package/build/util/outbound-request.test.d.ts +1 -0
  40. package/build/util/outbound-request.test.js +224 -0
  41. package/build/util/outbound-request.test.js.map +1 -0
  42. package/build/util/security.js +1 -1
  43. package/build/util/security.js.map +1 -1
  44. package/build/util/security.test.js +1 -0
  45. package/build/util/security.test.js.map +1 -1
  46. package/package.json +8 -6
@@ -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
- // Try to create FTS index if it doesn't exist
137
- await this.createFTSIndex();
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
- async addDocument(doc) {
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
- // Validate storage initialization
292
- if (!this.sqliteDb) {
293
- logger.debug('[DocumentStore] SQLite not initialized during addDocument');
294
- throw new Error('SQLite storage not initialized');
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
- // Check if document already exists
302
- const existing = await this.getDocument(doc.metadata.url);
303
- if (existing) {
304
- logger.debug(`[DocumentStore] Existing document found, will update:`, existing);
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
- logger.debug(`[DocumentStore] Starting SQLite transaction`);
307
- await this.sqliteDb.run('BEGIN TRANSACTION');
308
- // Add metadata to SQLite
309
- await this.sqliteDb.run('INSERT OR REPLACE INTO documents (url, title, favicon, last_indexed, requires_auth, auth_domain, version) VALUES (?, ?, ?, ?, ?, ?, ?)', [
310
- doc.metadata.url,
311
- doc.metadata.title,
312
- doc.metadata.favicon,
313
- doc.metadata.lastIndexed.toISOString(),
314
- doc.metadata.requiresAuth ? 1 : 0,
315
- doc.metadata.authDomain || null,
316
- doc.metadata.version || null,
317
- ]);
318
- logger.debug(`[DocumentStore] Added metadata to SQLite (requiresAuth: ${doc.metadata.requiresAuth}, authDomain: ${doc.metadata.authDomain}, version: ${doc.metadata.version})`);
319
- // Delete existing chunks for this document (using escaped value to prevent injection)
320
- await this.lanceTable.delete(`url = '${escapeFilterValue(doc.metadata.url)}'`);
321
- logger.debug(`[DocumentStore] Deleted existing chunks`);
322
- // Add new chunks to LanceDB
323
- const rows = doc.chunks.map((chunk) => ({
324
- url: doc.metadata.url,
325
- title: doc.metadata.title,
326
- content: chunk.content,
327
- path: chunk.path,
328
- startLine: chunk.startLine,
329
- endLine: chunk.endLine,
330
- vector: chunk.vector,
331
- type: chunk.metadata.type,
332
- lastUpdated: new Date().toISOString(),
333
- version: '',
334
- framework: '',
335
- language: '',
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
- // Rollback on error
353
- if (this.sqliteDb) {
354
- await this.sqliteDb.run('ROLLBACK');
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 conditions = [];
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
- if (conditions.length > 0) {
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 = `text:${query}:${JSON.stringify(options)}`;
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.length > 0 ? conditions.join(' AND ') : undefined;
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
- let ftsQuery = this.lanceTable
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
- let vectorQuery = this.lanceTable.search(queryVector).limit(limit * 2);
604
- if (whereClause) {
605
- vectorQuery = vectorQuery.where(whereClause);
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
- let ftsQuery = this.lanceTable
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
- let vectorQuery = this.lanceTable.search(queryVector).limit(limit * 2);
636
- if (whereClause) {
637
- vectorQuery = vectorQuery.where(whereClause);
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
- const results = await this.searchDocuments(queryVector, options);
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.sqliteDb) {
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.sqliteDb.all('SELECT url, title, favicon, last_indexed, requires_auth, auth_domain, version FROM documents ORDER BY last_indexed DESC');
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: tagsMap.get(row.url) || [],
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
- if (!this.sqliteDb || !this.lanceTable) {
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.sqliteDb.run('BEGIN TRANSACTION');
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 this.sqliteDb.run('DELETE FROM document_tags WHERE url = ?', [url]);
784
- await this.sqliteDb.run('DELETE FROM documents WHERE url = ?', [url]);
785
- await this.lanceTable.delete(`url = '${escapeFilterValue(url)}'`);
786
- await this.sqliteDb.run('COMMIT');
787
- // Clear cache for this URL
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 (this.sqliteDb) {
793
- await this.sqliteDb.run('ROLLBACK');
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.sqliteDb) {
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.sqliteDb.get('SELECT url, title, favicon, last_indexed, requires_auth, auth_domain, version FROM documents WHERE url = ?', [url]);
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
- * Get tags for a specific document
843
- */
844
- async getDocumentTags(url) {
845
- if (!this.sqliteDb) {
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
- const rows = await this.sqliteDb.all('SELECT tag FROM document_tags WHERE url = ? ORDER BY tag', [url]);
849
- return rows.map((row) => row.tag);
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
- // Delete existing tags
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.sqliteDb) {
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.sqliteDb.all('SELECT tag, COUNT(*) as count FROM document_tags GROUP BY tag ORDER BY count DESC, tag ASC');
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.sqliteDb || tags.length === 0) {
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.sqliteDb.all(query, [...normalizedTags, normalizedTags.length]);
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.sqliteDb) {
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.sqliteDb.all(`
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.sqliteDb) {
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.sqliteDb.get('SELECT name, description, created_at, updated_at FROM collections WHERE name = ?', [normalizedName]);
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.sqliteDb.all(`
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: tagsMap.get(doc.url) || [],
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.sqliteDb) {
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.sqliteDb.all('SELECT url FROM collection_documents WHERE collection_name = ?', [
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 sample = await this.lanceTable.query().limit(1).toArray();
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 searchResults = await this.lanceTable.search(testVector).limit(1).toArray();
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:', {