@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.
- package/README.md +74 -7
- package/package.json +30 -1
- package/src/cli/commands/ask.ts +12 -187
- package/src/cli/commands/embed.ts +10 -4
- package/src/cli/commands/models/pull.ts +9 -4
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/commands/vsearch.ts +5 -2
- package/src/cli/program.ts +28 -0
- package/src/config/types.ts +11 -6
- package/src/llm/registry.ts +3 -1
- package/src/mcp/tools/vsearch.ts +5 -2
- package/src/pipeline/answer.ts +224 -0
- package/src/pipeline/contextual.ts +57 -0
- package/src/pipeline/expansion.ts +49 -31
- package/src/pipeline/explain.ts +11 -3
- package/src/pipeline/fusion.ts +20 -9
- package/src/pipeline/hybrid.ts +57 -40
- package/src/pipeline/index.ts +7 -0
- package/src/pipeline/rerank.ts +55 -27
- package/src/pipeline/types.ts +0 -3
- package/src/pipeline/vsearch.ts +3 -2
- package/src/serve/CLAUDE.md +91 -0
- package/src/serve/bunfig.toml +2 -0
- package/src/serve/context.ts +181 -0
- package/src/serve/index.ts +7 -0
- package/src/serve/public/app.tsx +56 -0
- package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
- package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
- package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
- package/src/serve/public/components/ai-elements/loader.tsx +96 -0
- package/src/serve/public/components/ai-elements/message.tsx +443 -0
- package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
- package/src/serve/public/components/ai-elements/sources.tsx +75 -0
- package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
- package/src/serve/public/components/preset-selector.tsx +403 -0
- package/src/serve/public/components/ui/badge.tsx +46 -0
- package/src/serve/public/components/ui/button-group.tsx +82 -0
- package/src/serve/public/components/ui/button.tsx +62 -0
- package/src/serve/public/components/ui/card.tsx +92 -0
- package/src/serve/public/components/ui/carousel.tsx +244 -0
- package/src/serve/public/components/ui/collapsible.tsx +31 -0
- package/src/serve/public/components/ui/command.tsx +181 -0
- package/src/serve/public/components/ui/dialog.tsx +141 -0
- package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
- package/src/serve/public/components/ui/hover-card.tsx +42 -0
- package/src/serve/public/components/ui/input-group.tsx +167 -0
- package/src/serve/public/components/ui/input.tsx +21 -0
- package/src/serve/public/components/ui/progress.tsx +28 -0
- package/src/serve/public/components/ui/scroll-area.tsx +56 -0
- package/src/serve/public/components/ui/select.tsx +188 -0
- package/src/serve/public/components/ui/separator.tsx +26 -0
- package/src/serve/public/components/ui/table.tsx +114 -0
- package/src/serve/public/components/ui/textarea.tsx +18 -0
- package/src/serve/public/components/ui/tooltip.tsx +59 -0
- package/src/serve/public/globals.css +226 -0
- package/src/serve/public/hooks/use-api.ts +112 -0
- package/src/serve/public/index.html +13 -0
- package/src/serve/public/pages/Ask.tsx +442 -0
- package/src/serve/public/pages/Browse.tsx +270 -0
- package/src/serve/public/pages/Dashboard.tsx +202 -0
- package/src/serve/public/pages/DocView.tsx +302 -0
- package/src/serve/public/pages/Search.tsx +335 -0
- package/src/serve/routes/api.ts +763 -0
- package/src/serve/server.ts +249 -0
- package/src/store/migrations/002-documents-fts.ts +40 -0
- package/src/store/migrations/index.ts +2 -1
- package/src/store/sqlite/adapter.ts +216 -33
- package/src/store/sqlite/fts5-snowball.ts +144 -0
- package/src/store/types.ts +33 -3
- package/src/store/vector/stats.ts +3 -0
- 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
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
bm25(
|
|
709
|
-
${options.snippet ? "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
|
|
721
|
-
JOIN
|
|
722
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
.
|
|
874
|
+
.get(collection, relPath);
|
|
802
875
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
|
935
|
+
'INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)'
|
|
811
936
|
);
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
//
|
|
1255
|
+
// Clean orphaned FTS entries (documents that no longer exist or are inactive)
|
|
1073
1256
|
db.run(`
|
|
1074
|
-
DELETE FROM
|
|
1075
|
-
SELECT
|
|
1257
|
+
DELETE FROM documents_fts WHERE rowid NOT IN (
|
|
1258
|
+
SELECT id FROM documents WHERE active = 1
|
|
1076
1259
|
)
|
|
1077
1260
|
`);
|
|
1078
1261
|
});
|