@cosmocoder/mcp-web-docs 2.0.9 → 2.0.11

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