UNPKG

@search-docs/server

Version:
383 lines 15 kB
import { promises as fs } from 'fs'; import { createHash } from 'crypto'; import * as path from 'path'; import { MarkdownSplitter } from '../splitter/markdown-splitter.js'; import { FileDiscovery } from '../discovery/file-discovery.js'; import { FileWatcher } from '../discovery/file-watcher.js'; import { IndexWorker } from '../worker/index.js'; import { StartupSyncWorker } from '../worker/startup-sync-worker.js'; /** * SearchDocsサーバのメインクラス */ export class SearchDocsServer { config; storage; dbEngine; splitter; discovery; watcher = null; indexWorker = null; startupSyncWorker = null; startTime = 0; requestStats = { total: 0, search: 0, getDocument: 0, indexDocument: 0, rebuildIndex: 0, }; constructor(config, storage, dbEngine) { this.config = config; this.storage = storage; this.dbEngine = dbEngine; this.splitter = new MarkdownSplitter(config.indexing); this.discovery = new FileDiscovery({ rootDir: config.project.root, config: config.files, }); // FileWatcher初期化(enabledがtrueの場合のみ) if (config.watcher.enabled) { this.watcher = new FileWatcher({ rootDir: config.project.root, filesConfig: config.files, watcherConfig: config.watcher, }); // イベントハンドラ登録 this.watcher.on('change', (event) => { this.handleFileChange(event).catch((error) => { console.error('File change handling error:', error); }); }); this.watcher.on('error', (error) => { console.error('File watcher error:', error); }); } // IndexWorker初期化(worker.enabledがtrueの場合のみ) if (config.worker.enabled) { this.indexWorker = new IndexWorker({ dbEngine: this.dbEngine, storage: this.storage, splitter: this.splitter, interval: config.worker.interval, maxConcurrent: config.worker.maxConcurrent, }); } // StartupSyncWorker初期化(常に有効) this.startupSyncWorker = new StartupSyncWorker(); } /** * サーバ起動 */ async start() { this.startTime = Date.now(); // DB接続をバックグラウンドで開始(サーバ起動をブロックしない) this.dbEngine.connect().catch((error) => { console.error('[SearchDocsServer] DB connection failed:', error); }); // DB接続完了後にDB依存のワーカーを起動 this.dbEngine.waitForConnection().then(() => { // パフォーマンスログを開始(環境変数で制御) console.log('[SearchDocsServer] ENABLE_PERFORMANCE_LOG:', process.env.ENABLE_PERFORMANCE_LOG); console.log('[SearchDocsServer] PERFORMANCE_LOG_PATH:', process.env.PERFORMANCE_LOG_PATH); if (process.env.ENABLE_PERFORMANCE_LOG === '1') { const logPath = process.env.PERFORMANCE_LOG_PATH; this.dbEngine.startPerformanceLogging(logPath); console.log('[SearchDocsServer] Performance logging enabled'); if (logPath) { console.log('[SearchDocsServer] Performance log path (specified):', logPath); } else { console.log('[SearchDocsServer] Performance log path will be auto-generated in .search-docs/'); } } else { console.log('[SearchDocsServer] Performance logging disabled'); } // 起動時にインデックスを同期(バックグラウンドで非同期実行) if (this.startupSyncWorker) { this.startupSyncWorker.startSync(() => this.rebuildIndex({ force: false })); } // IndexWorker開始 if (this.indexWorker) { this.indexWorker.start(); } }).catch((error) => { console.error('[SearchDocsServer] Failed to start DB-dependent workers:', error); }); // FileWatcher開始(DB非依存) if (this.watcher) { await this.watcher.start(); } } /** * サーバ停止 */ async stop() { // IndexWorker停止 if (this.indexWorker) { this.indexWorker.stop(); } // FileWatcher停止 if (this.watcher) { await this.watcher.stop(); } this.dbEngine.disconnect(); } /** * ファイル変更イベント処理 */ async handleFileChange(event) { console.log(`File ${event.type}: ${event.path}`); switch (event.type) { case 'add': case 'change': { // 1. ファイルを読み込み(event.pathは相対パスなので絶対パスに変換) const absolutePath = path.join(this.config.project.root, event.path); const content = await fs.readFile(absolutePath, 'utf-8'); // 2. ハッシュ計算 const hash = createHash('sha256').update(content).digest('hex'); // 3. ストレージに保存 const existingDoc = await this.storage.get(event.path); const document = { path: event.path, content, metadata: { createdAt: existingDoc?.metadata.createdAt || new Date(), updatedAt: new Date(), fileHash: hash, }, }; await this.storage.save(event.path, document); // 4. IndexRequestを作成 await this.dbEngine.createIndexRequest({ documentPath: event.path, documentHash: hash, }); console.log(`Created IndexRequest for ${event.path} (${hash.slice(0, 8)})`); break; } case 'unlink': // セクションを削除 await this.dbEngine.deleteSectionsByPath(event.path); // ストレージからも削除 await this.storage.delete(event.path); break; } } /** * 検索API */ async search(request) { this.requestStats.total++; this.requestStats.search++; const startTime = Date.now(); // indexStatusによるフィルタ処理 let autoExcludePaths; if (request.options?.indexStatus === 'latest_only' || request.options?.indexStatus === 'completed_only') { // pending/processingのリクエストがあるdocument_pathを除外 autoExcludePaths = await this.dbEngine.getPathsWithStatus(['pending', 'processing']); } // ユーザー指定のexcludePathsと自動除外パスをマージ const mergedExcludePaths = [ ...(request.options?.excludePaths || []), ...(autoExcludePaths || []), ]; const response = await this.dbEngine.search({ query: request.query, ...request.options, excludePaths: mergedExcludePaths.length > 0 ? mergedExcludePaths : undefined, }); // 各結果にindex状態情報を付与 const resultsWithStatus = await Promise.all(response.results.map(async (section) => { const status = await this.computeIndexStatus(section.documentPath, section.documentHash); return { ...section, indexStatus: status.status, isLatest: status.isLatest, hasPendingUpdate: status.hasPendingUpdate, }; })); return { results: resultsWithStatus, total: response.total, took: Date.now() - startTime, }; } /** * 文書取得API */ async getDocument(request) { this.requestStats.total++; this.requestStats.getDocument++; // pathとsectionIdのどちらか一方は必須 if (!request.path && !request.sectionId) { throw new Error('pathまたはsectionIdのどちらか一方を指定してください'); } // sectionIdが指定されている場合はセクションを取得 if (request.sectionId) { const result = await this.dbEngine.getSectionById(request.sectionId); return { document: null, section: result.section }; } // 文書全体を取得 const document = await this.storage.get(request.path); if (!document) { throw new Error(`Document not found: ${request.path}`); } return { document }; } /** * 文書インデックスAPI */ async indexDocument(request) { this.requestStats.total++; this.requestStats.indexDocument++; const { path, force = false } = request; // 1. ファイル読み込み const content = await fs.readFile(path, 'utf-8'); // 2. ハッシュ計算 const hash = createHash('sha256').update(content).digest('hex'); // 3. 既存文書をチェック const existingDoc = await this.storage.get(path); if (existingDoc && existingDoc.metadata.fileHash === hash && !force) { // 文書ハッシュが同じ場合、インデックスが存在するか確認 const { sections: existingSections } = await this.dbEngine.getSectionsByPath(path); if (existingSections.length > 0) { // インデックスも存在するので、変更なし return { success: true, sectionsCreated: 0 }; } // インデックスが存在しない場合は、IndexRequestを作成する必要がある console.log(`Document ${path} exists but has no index sections - creating IndexRequest`); } // 4. 文書をストレージに保存 const document = { path, content, metadata: { createdAt: existingDoc?.metadata.createdAt || new Date(), updatedAt: new Date(), fileHash: hash, }, }; await this.storage.save(path, document); // 5. IndexRequestを作成(IndexWorkerが処理する) await this.dbEngine.createIndexRequest({ documentPath: path, documentHash: hash, }); console.log(`Created IndexRequest for ${path} (${hash.slice(0, 8)})`); // Note: IndexWorkerがバックグラウンドで処理するため、 // sectionsCreatedは0を返す(実際のセクション数は不明) return { success: true, sectionsCreated: 0, }; } /** * インデックス再構築API */ async rebuildIndex(request = {}) { this.requestStats.total++; this.requestStats.rebuildIndex++; const { paths, force = false } = request; let filesToIndex; if (paths && paths.length > 0) { // 指定されたパスのみ filesToIndex = paths; } else { // 全ファイル検索 filesToIndex = await this.discovery.findFiles(); } let documentsProcessed = 0; let sectionsCreated = 0; for (const filePath of filesToIndex) { try { const result = await this.indexDocument({ path: filePath, force, }); documentsProcessed++; sectionsCreated += result.sectionsCreated; } catch (error) { console.error(`Failed to index ${filePath}:`, error); } } return { success: true, documentsProcessed, sectionsCreated, }; } /** * ステータス取得API */ async getStatus() { // DBから統計情報を取得(.select()で最適化済み) const stats = await this.dbEngine.getStats(); // IndexWorkerの状態を取得 const workerStatus = this.indexWorker?.getStatus() ?? { running: false, processing: false }; // pendingリクエストの数を取得(count専用メソッドで高速化) const queueCount = await this.dbEngine.countIndexRequests({ status: 'pending' }); return { server: { version: '0.1.0', uptime: Date.now() - this.startTime, pid: process.pid, syncing: this.startupSyncWorker?.isSyncInProgress() ?? false, requests: { total: this.requestStats.total, search: this.requestStats.search, getDocument: this.requestStats.getDocument, indexDocument: this.requestStats.indexDocument, rebuildIndex: this.requestStats.rebuildIndex, }, }, index: { totalDocuments: stats.totalDocuments, totalSections: stats.totalSections, dirtyCount: stats.dirtyCount, }, worker: { running: workerStatus.running, processing: workerStatus.processing ? 1 : 0, queue: queueCount, }, }; } /** * インデックス状態を計算 */ async computeIndexStatus(documentPath, sectionHash) { // 1. storageから最新のdocument_hashを取得 const doc = await this.storage.get(documentPath); if (!doc) { return { status: 'outdated', isLatest: false, hasPendingUpdate: false, }; } const isLatest = sectionHash === doc.metadata.fileHash; // 2. pending/processingのリクエストがあるか確認 const pendingRequests = await this.dbEngine.findIndexRequests({ documentPath, status: ['pending', 'processing'], }); const hasPendingUpdate = pendingRequests.length > 0; // 3. ステータスを判定 let status; if (hasPendingUpdate) { status = 'updating'; } else if (isLatest) { status = 'latest'; } else { status = 'outdated'; // 通常ありえない(古いindexは削除されるため) } return { status, isLatest, hasPendingUpdate }; } } //# sourceMappingURL=search-docs-server.js.map