@gmickel/gno 0.3.5 → 0.5.0

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 (71) hide show
  1. package/README.md +74 -7
  2. package/package.json +30 -1
  3. package/src/cli/commands/ask.ts +12 -187
  4. package/src/cli/commands/embed.ts +10 -4
  5. package/src/cli/commands/models/pull.ts +9 -4
  6. package/src/cli/commands/serve.ts +19 -0
  7. package/src/cli/commands/vsearch.ts +5 -2
  8. package/src/cli/program.ts +28 -0
  9. package/src/config/types.ts +11 -6
  10. package/src/llm/registry.ts +3 -1
  11. package/src/mcp/tools/vsearch.ts +5 -2
  12. package/src/pipeline/answer.ts +224 -0
  13. package/src/pipeline/contextual.ts +57 -0
  14. package/src/pipeline/expansion.ts +49 -31
  15. package/src/pipeline/explain.ts +11 -3
  16. package/src/pipeline/fusion.ts +20 -9
  17. package/src/pipeline/hybrid.ts +57 -40
  18. package/src/pipeline/index.ts +7 -0
  19. package/src/pipeline/rerank.ts +55 -27
  20. package/src/pipeline/types.ts +0 -3
  21. package/src/pipeline/vsearch.ts +3 -2
  22. package/src/serve/CLAUDE.md +91 -0
  23. package/src/serve/bunfig.toml +2 -0
  24. package/src/serve/context.ts +181 -0
  25. package/src/serve/index.ts +7 -0
  26. package/src/serve/public/app.tsx +56 -0
  27. package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
  28. package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
  29. package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
  30. package/src/serve/public/components/ai-elements/loader.tsx +96 -0
  31. package/src/serve/public/components/ai-elements/message.tsx +443 -0
  32. package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
  33. package/src/serve/public/components/ai-elements/sources.tsx +75 -0
  34. package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
  35. package/src/serve/public/components/preset-selector.tsx +403 -0
  36. package/src/serve/public/components/ui/badge.tsx +46 -0
  37. package/src/serve/public/components/ui/button-group.tsx +82 -0
  38. package/src/serve/public/components/ui/button.tsx +62 -0
  39. package/src/serve/public/components/ui/card.tsx +92 -0
  40. package/src/serve/public/components/ui/carousel.tsx +244 -0
  41. package/src/serve/public/components/ui/collapsible.tsx +31 -0
  42. package/src/serve/public/components/ui/command.tsx +181 -0
  43. package/src/serve/public/components/ui/dialog.tsx +141 -0
  44. package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
  45. package/src/serve/public/components/ui/hover-card.tsx +42 -0
  46. package/src/serve/public/components/ui/input-group.tsx +167 -0
  47. package/src/serve/public/components/ui/input.tsx +21 -0
  48. package/src/serve/public/components/ui/progress.tsx +28 -0
  49. package/src/serve/public/components/ui/scroll-area.tsx +56 -0
  50. package/src/serve/public/components/ui/select.tsx +188 -0
  51. package/src/serve/public/components/ui/separator.tsx +26 -0
  52. package/src/serve/public/components/ui/table.tsx +114 -0
  53. package/src/serve/public/components/ui/textarea.tsx +18 -0
  54. package/src/serve/public/components/ui/tooltip.tsx +59 -0
  55. package/src/serve/public/globals.css +226 -0
  56. package/src/serve/public/hooks/use-api.ts +112 -0
  57. package/src/serve/public/index.html +13 -0
  58. package/src/serve/public/pages/Ask.tsx +442 -0
  59. package/src/serve/public/pages/Browse.tsx +270 -0
  60. package/src/serve/public/pages/Dashboard.tsx +202 -0
  61. package/src/serve/public/pages/DocView.tsx +302 -0
  62. package/src/serve/public/pages/Search.tsx +335 -0
  63. package/src/serve/routes/api.ts +763 -0
  64. package/src/serve/server.ts +249 -0
  65. package/src/store/migrations/002-documents-fts.ts +40 -0
  66. package/src/store/migrations/index.ts +2 -1
  67. package/src/store/sqlite/adapter.ts +216 -33
  68. package/src/store/sqlite/fts5-snowball.ts +144 -0
  69. package/src/store/types.ts +33 -3
  70. package/src/store/vector/stats.ts +3 -0
  71. package/src/store/vector/types.ts +1 -0
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Bun.serve() web server for GNO web UI.
3
+ * Uses Bun's fullstack dev server with HTML imports.
4
+ * Opens DB once at startup, closes on shutdown.
5
+ *
6
+ * @module src/serve/server
7
+ */
8
+
9
+ import { getIndexDbPath } from '../app/constants';
10
+ import { getConfigPaths, isInitialized, loadConfig } from '../config';
11
+ import { SqliteAdapter } from '../store/sqlite/adapter';
12
+ import { createServerContext, disposeServerContext } from './context';
13
+ // HTML import - Bun handles bundling TSX/CSS automatically via routes
14
+ import homepage from './public/index.html';
15
+ import {
16
+ handleAsk,
17
+ handleCapabilities,
18
+ handleCollections,
19
+ handleDoc,
20
+ handleDocs,
21
+ handleHealth,
22
+ handleModelPull,
23
+ handleModelStatus,
24
+ handlePresets,
25
+ handleQuery,
26
+ handleSearch,
27
+ handleSetPreset,
28
+ handleStatus,
29
+ } from './routes/api';
30
+
31
+ export interface ServeOptions {
32
+ /** Port to listen on (default: 3000) */
33
+ port?: number;
34
+ /** Config path override */
35
+ configPath?: string;
36
+ /** Index name (from --index flag) */
37
+ index?: string;
38
+ }
39
+
40
+ export interface ServeResult {
41
+ success: boolean;
42
+ error?: string;
43
+ }
44
+
45
+ // Hostname parsing helpers - preserved for future fetch handler use
46
+ // function parseHostname(host: string): string { ... }
47
+ // function isLoopback(hostname: string): boolean { ... }
48
+
49
+ /**
50
+ * Get CSP based on environment.
51
+ * Dev mode allows WebSocket connections for HMR.
52
+ */
53
+ function getCspHeader(isDev: boolean): string {
54
+ // Local fonts only - no Google Fonts for true offline-first
55
+ const base = [
56
+ "default-src 'self'",
57
+ "script-src 'self'",
58
+ "style-src 'self' 'unsafe-inline'",
59
+ "font-src 'self'",
60
+ "img-src 'self' data: blob:",
61
+ "frame-ancestors 'none'",
62
+ "base-uri 'none'", // Prevent base tag injection
63
+ "object-src 'none'", // Prevent plugin execution
64
+ ];
65
+
66
+ // Dev mode: allow WebSocket for HMR
67
+ if (isDev) {
68
+ base.push("connect-src 'self' ws:");
69
+ } else {
70
+ base.push("connect-src 'self'");
71
+ }
72
+
73
+ return base.join('; ');
74
+ }
75
+
76
+ /**
77
+ * Apply security headers to a Response.
78
+ */
79
+ function withSecurityHeaders(response: Response, isDev: boolean): Response {
80
+ const headers = new Headers(response.headers);
81
+ headers.set('Content-Security-Policy', getCspHeader(isDev));
82
+ headers.set('X-Content-Type-Options', 'nosniff');
83
+ headers.set('X-Frame-Options', 'DENY');
84
+ headers.set('Referrer-Policy', 'no-referrer');
85
+ headers.set('Cross-Origin-Resource-Policy', 'same-origin');
86
+
87
+ return new Response(response.body, {
88
+ status: response.status,
89
+ statusText: response.statusText,
90
+ headers,
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Start the web server.
96
+ * Opens DB once, closes on SIGINT/SIGTERM.
97
+ */
98
+ export async function startServer(
99
+ options: ServeOptions = {}
100
+ ): Promise<ServeResult> {
101
+ const port = options.port ?? 3000;
102
+ const isDev = process.env.NODE_ENV !== 'production';
103
+
104
+ // Check initialization
105
+ const initialized = await isInitialized(options.configPath);
106
+ if (!initialized) {
107
+ return { success: false, error: 'GNO not initialized. Run: gno init' };
108
+ }
109
+
110
+ // Load config
111
+ const configResult = await loadConfig(options.configPath);
112
+ if (!configResult.ok) {
113
+ return { success: false, error: configResult.error.message };
114
+ }
115
+ const config = configResult.value;
116
+
117
+ // Open database once for server lifetime
118
+ const store = new SqliteAdapter();
119
+ const dbPath = getIndexDbPath(options.index);
120
+ // Use actual config path (from options or default) for consistency
121
+ const paths = getConfigPaths();
122
+ const actualConfigPath = options.configPath ?? paths.configFile;
123
+ store.setConfigPath(actualConfigPath);
124
+
125
+ const openResult = await store.open(dbPath, config.ftsTokenizer);
126
+ if (!openResult.ok) {
127
+ return { success: false, error: openResult.error.message };
128
+ }
129
+
130
+ // Create server context with LLM ports for hybrid search and AI answers
131
+ // Use holder pattern to allow hot-reloading presets
132
+ const ctxHolder = {
133
+ current: await createServerContext(store, config),
134
+ config, // Keep original config for reloading
135
+ };
136
+
137
+ // Shutdown controller for clean lifecycle
138
+ const shutdownController = new AbortController();
139
+
140
+ // Graceful shutdown handler
141
+ const shutdown = async () => {
142
+ console.log('\nShutting down...');
143
+ await disposeServerContext(ctxHolder.current);
144
+ await store.close();
145
+ shutdownController.abort();
146
+ };
147
+
148
+ process.once('SIGINT', shutdown);
149
+ process.once('SIGTERM', shutdown);
150
+
151
+ // Start server with try/catch for port-in-use etc.
152
+ let server: ReturnType<typeof Bun.serve>;
153
+ try {
154
+ server = Bun.serve({
155
+ port,
156
+ hostname: '127.0.0.1', // Loopback only - no LAN exposure
157
+
158
+ // Enable development mode for HMR and console logging
159
+ development: isDev,
160
+
161
+ // Routes object - Bun handles HTML bundling and /_bun/* assets automatically
162
+ routes: {
163
+ // SPA routes - all serve the same React app
164
+ '/': homepage,
165
+ '/search': homepage,
166
+ '/browse': homepage,
167
+ '/doc': homepage,
168
+ '/ask': homepage,
169
+
170
+ // API routes
171
+ '/api/health': {
172
+ GET: () => withSecurityHeaders(handleHealth(), isDev),
173
+ },
174
+ '/api/status': {
175
+ GET: async () =>
176
+ withSecurityHeaders(await handleStatus(store), isDev),
177
+ },
178
+ '/api/collections': {
179
+ GET: async () =>
180
+ withSecurityHeaders(await handleCollections(store), isDev),
181
+ },
182
+ '/api/docs': {
183
+ GET: async (req: Request) => {
184
+ const url = new URL(req.url);
185
+ return withSecurityHeaders(await handleDocs(store, url), isDev);
186
+ },
187
+ },
188
+ '/api/doc': {
189
+ GET: async (req: Request) => {
190
+ const url = new URL(req.url);
191
+ return withSecurityHeaders(await handleDoc(store, url), isDev);
192
+ },
193
+ },
194
+ '/api/search': {
195
+ POST: async (req: Request) =>
196
+ withSecurityHeaders(await handleSearch(store, req), isDev),
197
+ },
198
+ '/api/query': {
199
+ POST: async (req: Request) =>
200
+ withSecurityHeaders(
201
+ await handleQuery(ctxHolder.current, req),
202
+ isDev
203
+ ),
204
+ },
205
+ '/api/ask': {
206
+ POST: async (req: Request) =>
207
+ withSecurityHeaders(await handleAsk(ctxHolder.current, req), isDev),
208
+ },
209
+ '/api/capabilities': {
210
+ GET: () =>
211
+ withSecurityHeaders(handleCapabilities(ctxHolder.current), isDev),
212
+ },
213
+ '/api/presets': {
214
+ GET: () =>
215
+ withSecurityHeaders(handlePresets(ctxHolder.current), isDev),
216
+ POST: async (req: Request) =>
217
+ withSecurityHeaders(await handleSetPreset(ctxHolder, req), isDev),
218
+ },
219
+ '/api/models/status': {
220
+ GET: () => withSecurityHeaders(handleModelStatus(), isDev),
221
+ },
222
+ '/api/models/pull': {
223
+ POST: () => withSecurityHeaders(handleModelPull(ctxHolder), isDev),
224
+ },
225
+ },
226
+
227
+ // No fetch fallback - let Bun handle /_bun/* assets and return 404 for others
228
+ });
229
+ } catch (e) {
230
+ await store.close();
231
+ return {
232
+ success: false,
233
+ error: e instanceof Error ? e.message : String(e),
234
+ };
235
+ }
236
+
237
+ console.log(`GNO server running at http://localhost:${server.port}`);
238
+ console.log('Press Ctrl+C to stop');
239
+
240
+ // Block until shutdown signal
241
+ await new Promise<void>((resolve) => {
242
+ shutdownController.signal.addEventListener('abort', () => resolve(), {
243
+ once: true,
244
+ });
245
+ });
246
+
247
+ server.stop(true);
248
+ return { success: true };
249
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Migration: Document-level FTS with Snowball stemmer.
3
+ *
4
+ * Replaces chunk-level content_fts with document-level documents_fts.
5
+ * Uses snowball tokenizer for multilingual stemming support.
6
+ *
7
+ * @module src/store/migrations/002-documents-fts
8
+ */
9
+
10
+ import type { Database } from 'bun:sqlite';
11
+ import type { FtsTokenizer } from '../../config/types';
12
+ import type { Migration } from './runner';
13
+
14
+ export const migration: Migration = {
15
+ version: 2,
16
+ name: 'documents_fts',
17
+
18
+ up(db: Database, ftsTokenizer: FtsTokenizer): void {
19
+ // Drop old chunk-level FTS (no backwards compat needed per epic)
20
+ db.exec('DROP TABLE IF EXISTS content_fts');
21
+
22
+ // Create document-level FTS with snowball stemmer
23
+ // Indexes: filepath (for path searches), title, body (full content)
24
+ // Note: NOT using content='' because contentless tables don't support DELETE
25
+ // The storage overhead is acceptable for simpler update semantics
26
+ db.exec(`
27
+ CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
28
+ filepath,
29
+ title,
30
+ body,
31
+ tokenize='${ftsTokenizer}'
32
+ )
33
+ `);
34
+ },
35
+
36
+ down(db: Database): void {
37
+ db.exec('DROP TABLE IF EXISTS documents_fts');
38
+ // Note: Cannot restore content_fts - would need full reindex
39
+ },
40
+ };
@@ -15,6 +15,7 @@ export {
15
15
 
16
16
  // Import all migrations
17
17
  import { migration as m001 } from './001-initial';
18
+ import { migration as m002 } from './002-documents-fts';
18
19
 
19
20
  /** All migrations in order */
20
- export const migrations = [m001];
21
+ export const migrations = [m001, m002];
@@ -31,6 +31,7 @@ import type {
31
31
  StoreResult,
32
32
  } from '../types';
33
33
  import { err, ok } from '../types';
34
+ import { loadFts5Snowball } from './fts5-snowball';
34
35
  import type { SqliteDbProvider } from './types';
35
36
 
36
37
  // ─────────────────────────────────────────────────────────────────────────────
@@ -103,6 +104,19 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
103
104
  this.db.exec('PRAGMA journal_mode = WAL');
104
105
  }
105
106
 
107
+ // Load fts5-snowball extension if using snowball tokenizer
108
+ if (ftsTokenizer.startsWith('snowball')) {
109
+ const snowballResult = loadFts5Snowball(this.db);
110
+ if (!snowballResult.loaded) {
111
+ this.db.close();
112
+ this.db = null;
113
+ return err(
114
+ 'EXTENSION_LOAD_FAILED',
115
+ `Failed to load fts5-snowball: ${snowballResult.error}`
116
+ );
117
+ }
118
+ }
119
+
106
120
  // Run migrations
107
121
  const result = runMigrations(this.db, migrations, ftsTokenizer);
108
122
  if (!result.ok) {
@@ -487,6 +501,53 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
487
501
  }
488
502
  }
489
503
 
504
+ async listDocumentsPaginated(options: {
505
+ collection?: string;
506
+ limit: number;
507
+ offset: number;
508
+ }): Promise<StoreResult<{ documents: DocumentRow[]; total: number }>> {
509
+ try {
510
+ const db = this.ensureOpen();
511
+ const { collection, limit, offset } = options;
512
+
513
+ // Get total count
514
+ const countRow = collection
515
+ ? db
516
+ .query<{ count: number }, [string]>(
517
+ 'SELECT COUNT(*) as count FROM documents WHERE collection = ?'
518
+ )
519
+ .get(collection)
520
+ : db
521
+ .query<{ count: number }, []>(
522
+ 'SELECT COUNT(*) as count FROM documents'
523
+ )
524
+ .get();
525
+
526
+ const total = countRow?.count ?? 0;
527
+
528
+ // Get paginated documents
529
+ const rows = collection
530
+ ? db
531
+ .query<DbDocumentRow, [string, number, number]>(
532
+ 'SELECT * FROM documents WHERE collection = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
533
+ )
534
+ .all(collection, limit, offset)
535
+ : db
536
+ .query<DbDocumentRow, [number, number]>(
537
+ 'SELECT * FROM documents ORDER BY updated_at DESC LIMIT ? OFFSET ?'
538
+ )
539
+ .all(limit, offset);
540
+
541
+ return ok({ documents: rows.map(mapDocumentRow), total });
542
+ } catch (cause) {
543
+ return err(
544
+ 'QUERY_FAILED',
545
+ cause instanceof Error ? cause.message : 'Failed to list documents',
546
+ cause
547
+ );
548
+ }
549
+ }
550
+
490
551
  async markInactive(
491
552
  collection: string,
492
553
  relPaths: string[]
@@ -697,16 +758,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
697
758
  const db = this.ensureOpen();
698
759
  const limit = options.limit ?? 20;
699
760
 
700
- // Join FTS results with chunks and documents
701
- // Use bm25() function explicitly - fts.rank doesn't work with JOINs
702
- // Note: Multiple docs can share mirror_hash (content-addressed storage)
703
- // Deduplication by uri+seq is done in search.ts to avoid FTS function context issues
761
+ // Document-level FTS search using documents_fts
762
+ // Uses bm25() for relevance ranking (more negative = better match)
763
+ // Snippet from body column (index 2) with highlight markers
704
764
  const sql = `
705
765
  SELECT
706
- c.mirror_hash,
707
- c.seq,
708
- bm25(content_fts) as score,
709
- ${options.snippet ? "snippet(content_fts, 0, '<mark>', '</mark>', '...', 32) as snippet," : ''}
766
+ d.mirror_hash,
767
+ 0 as seq,
768
+ bm25(documents_fts) as score,
769
+ ${options.snippet ? "snippet(documents_fts, 2, '<mark>', '</mark>', '...', 32) as snippet," : ''}
710
770
  d.docid,
711
771
  d.uri,
712
772
  d.title,
@@ -717,13 +777,11 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
717
777
  d.source_mtime,
718
778
  d.source_size,
719
779
  d.source_hash
720
- FROM content_fts fts
721
- JOIN content_chunks c ON c.rowid = fts.rowid
722
- JOIN documents d ON d.mirror_hash = c.mirror_hash AND d.active = 1
723
- WHERE content_fts MATCH ?
780
+ FROM documents_fts fts
781
+ JOIN documents d ON d.id = fts.rowid AND d.active = 1
782
+ WHERE documents_fts MATCH ?
724
783
  ${options.collection ? 'AND d.collection = ?' : ''}
725
- ${options.language ? 'AND c.language = ?' : ''}
726
- ORDER BY bm25(content_fts)
784
+ ORDER BY bm25(documents_fts)
727
785
  LIMIT ?
728
786
  `;
729
787
 
@@ -731,9 +789,6 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
731
789
  if (options.collection) {
732
790
  params.push(options.collection);
733
791
  }
734
- if (options.language) {
735
- params.push(options.language);
736
- }
737
792
  params.push(limit);
738
793
 
739
794
  interface FtsRow {
@@ -788,29 +843,157 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
788
843
  }
789
844
  }
790
845
 
791
- async rebuildFtsForHash(mirrorHash: string): Promise<StoreResult<void>> {
846
+ /**
847
+ * Sync a document to documents_fts for full-text search.
848
+ * Must be called after document and content are both upserted.
849
+ * The FTS rowid matches documents.id for efficient JOINs.
850
+ */
851
+ async syncDocumentFts(
852
+ collection: string,
853
+ relPath: string
854
+ ): Promise<StoreResult<void>> {
792
855
  try {
793
856
  const db = this.ensureOpen();
794
857
 
795
858
  const transaction = db.transaction(() => {
796
- // Get chunks for this hash
797
- const chunks = db
798
- .query<{ rowid: number; text: string }, [string]>(
799
- 'SELECT rowid, text FROM content_chunks WHERE mirror_hash = ?'
859
+ // Get document with its content
860
+ interface DocWithContent {
861
+ id: number;
862
+ rel_path: string;
863
+ title: string | null;
864
+ markdown: string | null;
865
+ }
866
+
867
+ const doc = db
868
+ .query<DocWithContent, [string, string]>(
869
+ `SELECT d.id, d.rel_path, d.title, c.markdown
870
+ FROM documents d
871
+ LEFT JOIN content c ON c.mirror_hash = d.mirror_hash
872
+ WHERE d.collection = ? AND d.rel_path = ? AND d.active = 1`
800
873
  )
801
- .all(mirrorHash);
874
+ .get(collection, relPath);
802
875
 
803
- // Delete old FTS entries for these rowids
804
- for (const chunk of chunks) {
805
- db.run('DELETE FROM content_fts WHERE rowid = ?', [chunk.rowid]);
876
+ if (!doc) {
877
+ return; // Document not found or inactive
878
+ }
879
+
880
+ // Delete existing FTS entry for this doc
881
+ db.run('DELETE FROM documents_fts WHERE rowid = ?', [doc.id]);
882
+
883
+ // Insert new FTS entry if we have content
884
+ if (doc.markdown) {
885
+ db.run(
886
+ 'INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)',
887
+ [doc.id, doc.rel_path, doc.title ?? '', doc.markdown]
888
+ );
806
889
  }
890
+ });
807
891
 
808
- // Insert new FTS entries
892
+ transaction();
893
+ return ok(undefined);
894
+ } catch (cause) {
895
+ return err(
896
+ 'QUERY_FAILED',
897
+ cause instanceof Error ? cause.message : 'Failed to sync document FTS',
898
+ cause
899
+ );
900
+ }
901
+ }
902
+
903
+ /**
904
+ * Rebuild entire documents_fts index from scratch.
905
+ * Use after migration or for recovery.
906
+ */
907
+ async rebuildAllDocumentsFts(): Promise<StoreResult<number>> {
908
+ try {
909
+ const db = this.ensureOpen();
910
+ let count = 0;
911
+
912
+ const transaction = db.transaction(() => {
913
+ // Clear FTS table
914
+ db.run('DELETE FROM documents_fts');
915
+
916
+ // Get all active documents with content
917
+ interface DocWithContent {
918
+ id: number;
919
+ rel_path: string;
920
+ title: string | null;
921
+ markdown: string;
922
+ }
923
+
924
+ const docs = db
925
+ .query<DocWithContent, []>(
926
+ `SELECT d.id, d.rel_path, d.title, c.markdown
927
+ FROM documents d
928
+ JOIN content c ON c.mirror_hash = d.mirror_hash
929
+ WHERE d.active = 1 AND d.mirror_hash IS NOT NULL`
930
+ )
931
+ .all();
932
+
933
+ // Insert FTS entries
809
934
  const stmt = db.prepare(
810
- 'INSERT INTO content_fts (rowid, text) VALUES (?, ?)'
935
+ 'INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)'
811
936
  );
812
- for (const chunk of chunks) {
813
- stmt.run(chunk.rowid, chunk.text);
937
+
938
+ for (const doc of docs) {
939
+ stmt.run(doc.id, doc.rel_path, doc.title ?? '', doc.markdown);
940
+ count++;
941
+ }
942
+ });
943
+
944
+ transaction();
945
+ return ok(count);
946
+ } catch (cause) {
947
+ return err(
948
+ 'QUERY_FAILED',
949
+ cause instanceof Error
950
+ ? cause.message
951
+ : 'Failed to rebuild documents FTS',
952
+ cause
953
+ );
954
+ }
955
+ }
956
+
957
+ /**
958
+ * @deprecated Use syncDocumentFts for document-level FTS.
959
+ * Kept for backwards compat during migration.
960
+ */
961
+ async rebuildFtsForHash(mirrorHash: string): Promise<StoreResult<void>> {
962
+ try {
963
+ const db = this.ensureOpen();
964
+
965
+ const transaction = db.transaction(() => {
966
+ // Get documents using this hash and sync their FTS
967
+ interface DocInfo {
968
+ id: number;
969
+ rel_path: string;
970
+ title: string | null;
971
+ }
972
+
973
+ const docs = db
974
+ .query<DocInfo, [string]>(
975
+ 'SELECT id, rel_path, title FROM documents WHERE mirror_hash = ? AND active = 1'
976
+ )
977
+ .all(mirrorHash);
978
+
979
+ // Get content
980
+ const content = db
981
+ .query<{ markdown: string }, [string]>(
982
+ 'SELECT markdown FROM content WHERE mirror_hash = ?'
983
+ )
984
+ .get(mirrorHash);
985
+
986
+ if (!content) {
987
+ return;
988
+ }
989
+
990
+ // Update FTS for each document using this hash
991
+ for (const doc of docs) {
992
+ db.run('DELETE FROM documents_fts WHERE rowid = ?', [doc.id]);
993
+ db.run(
994
+ 'INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)',
995
+ [doc.id, doc.rel_path, doc.title ?? '', content.markdown]
996
+ );
814
997
  }
815
998
  });
816
999
 
@@ -1069,10 +1252,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1069
1252
  `);
1070
1253
  expiredCache = cacheResult.changes;
1071
1254
 
1072
- // Rebuild FTS index (remove orphaned entries)
1255
+ // Clean orphaned FTS entries (documents that no longer exist or are inactive)
1073
1256
  db.run(`
1074
- DELETE FROM content_fts WHERE rowid NOT IN (
1075
- SELECT rowid FROM content_chunks
1257
+ DELETE FROM documents_fts WHERE rowid NOT IN (
1258
+ SELECT id FROM documents WHERE active = 1
1076
1259
  )
1077
1260
  `);
1078
1261
  });