wp-devdocs-mcp 1.1.1

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.
@@ -0,0 +1,1012 @@
1
+ import Database from 'better-sqlite3';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { DB_PATH } from '../constants.js';
5
+
6
+ let db;
7
+
8
+ /**
9
+ * Get or create the SQLite database connection.
10
+ * Creates the database directory and initializes the schema on first call.
11
+ * @returns {import('better-sqlite3').Database}
12
+ */
13
+ export function getDb() {
14
+ if (!db) {
15
+ mkdirSync(dirname(DB_PATH), { recursive: true });
16
+ db = new Database(DB_PATH);
17
+ db.pragma('journal_mode = WAL');
18
+ db.pragma('foreign_keys = ON');
19
+ initDb(db);
20
+ }
21
+ return db;
22
+ }
23
+
24
+ /**
25
+ * Close the database connection and clear the statement cache.
26
+ */
27
+ export function closeDb() {
28
+ if (db) {
29
+ stmtCache.clear();
30
+ db.close();
31
+ db = null;
32
+ }
33
+ }
34
+
35
+ function initDb(db) {
36
+ db.exec(`
37
+ CREATE TABLE IF NOT EXISTS sources (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ name TEXT UNIQUE NOT NULL,
40
+ type TEXT NOT NULL,
41
+ repo_url TEXT,
42
+ subfolder TEXT,
43
+ local_path TEXT,
44
+ token_env_var TEXT,
45
+ branch TEXT DEFAULT 'main',
46
+ enabled INTEGER DEFAULT 1,
47
+ content_type TEXT DEFAULT 'source'
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS hooks (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
53
+ file_path TEXT NOT NULL,
54
+ line_number INTEGER NOT NULL,
55
+ name TEXT NOT NULL,
56
+ type TEXT NOT NULL,
57
+ php_function TEXT,
58
+ params TEXT,
59
+ param_count INTEGER DEFAULT 0,
60
+ docblock TEXT,
61
+ inferred_description TEXT,
62
+ function_context TEXT,
63
+ class_name TEXT,
64
+ code_before TEXT,
65
+ code_after TEXT,
66
+ hook_line TEXT,
67
+ is_dynamic INTEGER DEFAULT 0,
68
+ content_hash TEXT,
69
+ status TEXT DEFAULT 'active',
70
+ removed_at TEXT,
71
+ first_seen_at TEXT DEFAULT (datetime('now')),
72
+ last_seen_at TEXT DEFAULT (datetime('now')),
73
+ UNIQUE(source_id, file_path, line_number, name)
74
+ );
75
+
76
+ CREATE INDEX IF NOT EXISTS idx_hooks_source_id ON hooks(source_id);
77
+ CREATE INDEX IF NOT EXISTS idx_hooks_name ON hooks(name);
78
+ CREATE INDEX IF NOT EXISTS idx_hooks_type ON hooks(type);
79
+ CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status);
80
+ CREATE INDEX IF NOT EXISTS idx_hooks_source_status ON hooks(source_id, status);
81
+
82
+ CREATE VIRTUAL TABLE IF NOT EXISTS hooks_fts USING fts5(
83
+ name,
84
+ type,
85
+ docblock,
86
+ inferred_description,
87
+ function_context,
88
+ class_name,
89
+ params,
90
+ content='hooks',
91
+ content_rowid='id'
92
+ );
93
+
94
+ CREATE TABLE IF NOT EXISTS indexed_files (
95
+ source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
96
+ file_path TEXT NOT NULL,
97
+ mtime_ms REAL,
98
+ content_hash TEXT,
99
+ UNIQUE(source_id, file_path)
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS block_registrations (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
105
+ file_path TEXT NOT NULL,
106
+ line_number INTEGER NOT NULL,
107
+ block_name TEXT,
108
+ block_title TEXT,
109
+ block_category TEXT,
110
+ block_attributes TEXT,
111
+ supports TEXT,
112
+ code_context TEXT,
113
+ content_hash TEXT,
114
+ first_seen_at TEXT DEFAULT (datetime('now')),
115
+ last_seen_at TEXT DEFAULT (datetime('now'))
116
+ );
117
+
118
+ CREATE VIRTUAL TABLE IF NOT EXISTS block_registrations_fts USING fts5(
119
+ block_name,
120
+ block_title,
121
+ block_category,
122
+ block_attributes,
123
+ supports,
124
+ code_context,
125
+ content='block_registrations',
126
+ content_rowid='id'
127
+ );
128
+
129
+ CREATE TABLE IF NOT EXISTS api_usages (
130
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
131
+ source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
132
+ file_path TEXT NOT NULL,
133
+ line_number INTEGER NOT NULL,
134
+ api_call TEXT,
135
+ namespace TEXT,
136
+ method TEXT,
137
+ code_context TEXT,
138
+ content_hash TEXT,
139
+ first_seen_at TEXT DEFAULT (datetime('now')),
140
+ last_seen_at TEXT DEFAULT (datetime('now'))
141
+ );
142
+
143
+ CREATE VIRTUAL TABLE IF NOT EXISTS api_usages_fts USING fts5(
144
+ api_call,
145
+ namespace,
146
+ method,
147
+ code_context,
148
+ content='api_usages',
149
+ content_rowid='id'
150
+ );
151
+
152
+ CREATE TABLE IF NOT EXISTS docs (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
155
+ file_path TEXT NOT NULL,
156
+ slug TEXT NOT NULL,
157
+ title TEXT NOT NULL,
158
+ doc_type TEXT NOT NULL,
159
+ category TEXT,
160
+ subcategory TEXT,
161
+ description TEXT,
162
+ content TEXT NOT NULL,
163
+ code_examples TEXT,
164
+ metadata TEXT,
165
+ content_hash TEXT,
166
+ status TEXT DEFAULT 'active',
167
+ first_seen_at TEXT DEFAULT (datetime('now')),
168
+ last_seen_at TEXT DEFAULT (datetime('now')),
169
+ UNIQUE(source_id, file_path)
170
+ );
171
+
172
+ CREATE INDEX IF NOT EXISTS idx_docs_source_id ON docs(source_id);
173
+ CREATE INDEX IF NOT EXISTS idx_docs_slug ON docs(slug);
174
+ CREATE INDEX IF NOT EXISTS idx_docs_doc_type ON docs(doc_type);
175
+ CREATE INDEX IF NOT EXISTS idx_docs_category ON docs(category);
176
+ CREATE INDEX IF NOT EXISTS idx_docs_status ON docs(status);
177
+
178
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
179
+ title,
180
+ slug,
181
+ doc_type,
182
+ category,
183
+ description,
184
+ content,
185
+ content='docs',
186
+ content_rowid='id',
187
+ tokenize='porter unicode61'
188
+ );
189
+ `);
190
+
191
+ // Migration: add content_type to sources if missing (for existing databases)
192
+ try {
193
+ db.exec(`ALTER TABLE sources ADD COLUMN content_type TEXT DEFAULT 'source'`);
194
+ } catch {
195
+ // Column already exists — ignore
196
+ }
197
+
198
+ // Migration: add last_indexed_at to sources if missing
199
+ try {
200
+ db.exec(`ALTER TABLE sources ADD COLUMN last_indexed_at TEXT`);
201
+ } catch {
202
+ // Column already exists — ignore
203
+ }
204
+ }
205
+
206
+ // --- Prepared statement cache ---
207
+ const stmtCache = new Map();
208
+
209
+ /**
210
+ * Get or create a cached prepared statement for the given SQL.
211
+ * @param {import('better-sqlite3').Database} db
212
+ * @param {string} sql
213
+ * @returns {import('better-sqlite3').Statement}
214
+ */
215
+ function stmt(db, sql) {
216
+ if (!stmtCache.has(sql)) {
217
+ stmtCache.set(sql, db.prepare(sql));
218
+ }
219
+ return stmtCache.get(sql);
220
+ }
221
+
222
+ // --- Sources ---
223
+
224
+ /**
225
+ * Register a new source in the database.
226
+ * @param {object} data - Source configuration (name, type, repo_url, subfolder, local_path, token_env_var, branch, enabled)
227
+ * @returns {import('better-sqlite3').RunResult}
228
+ */
229
+ export function addSource(data) {
230
+ const db = getDb();
231
+ return stmt(db, `
232
+ INSERT INTO sources (name, type, repo_url, subfolder, local_path, token_env_var, branch, enabled, content_type)
233
+ VALUES (@name, @type, @repo_url, @subfolder, @local_path, @token_env_var, @branch, @enabled, @content_type)
234
+ `).run({
235
+ name: data.name,
236
+ type: data.type,
237
+ repo_url: data.repo_url || null,
238
+ subfolder: data.subfolder || null,
239
+ local_path: data.local_path || null,
240
+ token_env_var: data.token_env_var || null,
241
+ branch: data.branch || 'main',
242
+ enabled: data.enabled !== undefined ? (data.enabled ? 1 : 0) : 1,
243
+ content_type: data.content_type || 'source',
244
+ });
245
+ }
246
+
247
+ /**
248
+ * List all registered sources.
249
+ * @returns {Array<object>}
250
+ */
251
+ export function listSources() {
252
+ const db = getDb();
253
+ return stmt(db, 'SELECT * FROM sources ORDER BY name').all();
254
+ }
255
+
256
+ /**
257
+ * Get a source by its unique name.
258
+ * @param {string} name
259
+ * @returns {object|undefined}
260
+ */
261
+ export function getSource(name) {
262
+ const db = getDb();
263
+ return stmt(db, 'SELECT * FROM sources WHERE name = ?').get(name);
264
+ }
265
+
266
+ /**
267
+ * Get a source by its numeric ID.
268
+ * @param {number} id
269
+ * @returns {object|undefined}
270
+ */
271
+ export function getSourceById(id) {
272
+ const db = getDb();
273
+ return stmt(db, 'SELECT * FROM sources WHERE id = ?').get(id);
274
+ }
275
+
276
+ /**
277
+ * Remove a source and all its associated data (hooks, blocks, APIs, FTS entries).
278
+ * Runs within a transaction for consistency.
279
+ * @param {string} name - Source name
280
+ * @returns {object|null} The removed source object, or null if not found
281
+ */
282
+ export function removeSource(name) {
283
+ const db = getDb();
284
+ const source = getSource(name);
285
+ if (!source) return null;
286
+ const tx = db.transaction(() => {
287
+ stmt(db, 'DELETE FROM hooks_fts WHERE rowid IN (SELECT id FROM hooks WHERE source_id = ?)').run(source.id);
288
+ stmt(db, 'DELETE FROM block_registrations_fts WHERE rowid IN (SELECT id FROM block_registrations WHERE source_id = ?)').run(source.id);
289
+ stmt(db, 'DELETE FROM api_usages_fts WHERE rowid IN (SELECT id FROM api_usages WHERE source_id = ?)').run(source.id);
290
+ stmt(db, 'DELETE FROM docs_fts WHERE rowid IN (SELECT id FROM docs WHERE source_id = ?)').run(source.id);
291
+ stmt(db, 'DELETE FROM sources WHERE name = ?').run(name);
292
+ });
293
+ tx();
294
+ return source;
295
+ }
296
+
297
+ export function getActiveDocId(sourceId, filePath) {
298
+ const db = getDb();
299
+ const row = stmt(db, "SELECT id FROM docs WHERE source_id = ? AND file_path = ? AND status = 'active'").get(sourceId, filePath);
300
+ return row ? row.id : null;
301
+ }
302
+
303
+ /**
304
+ * Check whether a source has any indexed files.
305
+ * @param {number} sourceId
306
+ * @returns {boolean}
307
+ */
308
+ export function isSourceIndexed(sourceId) {
309
+ const db = getDb();
310
+ const row = stmt(db, 'SELECT COUNT(*) as count FROM indexed_files WHERE source_id = ?').get(sourceId);
311
+ return row.count > 0;
312
+ }
313
+
314
+ export function updateSourceLastIndexed(sourceId) {
315
+ const db = getDb();
316
+ stmt(db, "UPDATE sources SET last_indexed_at = datetime('now') WHERE id = ?").run(sourceId);
317
+ }
318
+
319
+ export function getStaleSources(maxAgeMs) {
320
+ const db = getDb();
321
+ const cutoffSeconds = Math.floor(maxAgeMs / 1000);
322
+ return stmt(db, `
323
+ SELECT * FROM sources
324
+ WHERE enabled = 1
325
+ AND (last_indexed_at IS NULL OR last_indexed_at < datetime('now', '-' || ? || ' seconds'))
326
+ ORDER BY name
327
+ `).all(cutoffSeconds);
328
+ }
329
+
330
+ // --- Hooks ---
331
+
332
+ /**
333
+ * Insert or update a hook. Uses content_hash to detect changes.
334
+ * Returns { id, action } where action is 'inserted', 'updated', or 'skipped'.
335
+ * @param {object} data - Hook data including source_id, file_path, line_number, name, type, etc.
336
+ * @returns {{ id: number, action: string }}
337
+ */
338
+ export function upsertHook(data) {
339
+ const db = getDb();
340
+ const upsertTx = db.transaction((d) => {
341
+ const existing = stmt(db, `
342
+ SELECT id, content_hash FROM hooks
343
+ WHERE source_id = @source_id AND file_path = @file_path AND line_number = @line_number AND name = @name
344
+ `).get(d);
345
+
346
+ if (existing) {
347
+ if (existing.content_hash === d.content_hash) {
348
+ // No change — just bump last_seen_at
349
+ stmt(db, 'UPDATE hooks SET last_seen_at = datetime(\'now\'), status = \'active\' WHERE id = ?').run(existing.id);
350
+ return { id: existing.id, action: 'skipped' };
351
+ }
352
+ // Update
353
+ stmt(db, `
354
+ UPDATE hooks SET
355
+ type = @type, php_function = @php_function, params = @params, param_count = @param_count,
356
+ docblock = @docblock, inferred_description = @inferred_description,
357
+ function_context = @function_context, class_name = @class_name,
358
+ code_before = @code_before, code_after = @code_after, hook_line = @hook_line,
359
+ is_dynamic = @is_dynamic, content_hash = @content_hash,
360
+ status = 'active', removed_at = NULL, last_seen_at = datetime('now')
361
+ WHERE id = @id
362
+ `).run({ ...d, id: existing.id });
363
+ // Update FTS — delete old, insert new
364
+ stmt(db, 'DELETE FROM hooks_fts WHERE rowid = ?').run(existing.id);
365
+ stmt(db, `
366
+ INSERT INTO hooks_fts(rowid, name, type, docblock, inferred_description, function_context, class_name, params)
367
+ VALUES (@id, @name, @type, @docblock, @inferred_description, @function_context, @class_name, @params)
368
+ `).run({ ...d, id: existing.id });
369
+ return { id: existing.id, action: 'updated' };
370
+ }
371
+
372
+ // Insert
373
+ const result = stmt(db, `
374
+ INSERT INTO hooks (
375
+ source_id, file_path, line_number, name, type, php_function, params, param_count,
376
+ docblock, inferred_description, function_context, class_name,
377
+ code_before, code_after, hook_line, is_dynamic, content_hash, status
378
+ ) VALUES (
379
+ @source_id, @file_path, @line_number, @name, @type, @php_function, @params, @param_count,
380
+ @docblock, @inferred_description, @function_context, @class_name,
381
+ @code_before, @code_after, @hook_line, @is_dynamic, @content_hash, 'active'
382
+ )
383
+ `).run(d);
384
+
385
+ stmt(db, `
386
+ INSERT INTO hooks_fts(rowid, name, type, docblock, inferred_description, function_context, class_name, params)
387
+ VALUES (@id, @name, @type, @docblock, @inferred_description, @function_context, @class_name, @params)
388
+ `).run({ ...d, id: result.lastInsertRowid });
389
+
390
+ return { id: result.lastInsertRowid, action: 'inserted' };
391
+ });
392
+
393
+ return upsertTx(data);
394
+ }
395
+
396
+ /**
397
+ * Soft-delete hooks that are no longer present in a file.
398
+ * Marks hooks as 'removed' if their ID is not in the activeIds list.
399
+ * @param {number} sourceId
400
+ * @param {string} filePath
401
+ * @param {Array<number>} activeIds - IDs of hooks still found in the file
402
+ * @returns {number} Count of hooks marked as removed
403
+ */
404
+ export function markHooksRemoved(sourceId, filePath, activeIds) {
405
+ const db = getDb();
406
+ const tx = db.transaction(() => {
407
+ const allHooks = stmt(db, `
408
+ SELECT id FROM hooks WHERE source_id = ? AND file_path = ? AND status = 'active'
409
+ `).all(sourceId, filePath);
410
+
411
+ const activeSet = new Set(activeIds.map(Number));
412
+ const toRemove = allHooks.filter(h => !activeSet.has(h.id));
413
+
414
+ const removeStmt = stmt(db, `
415
+ UPDATE hooks SET status = 'removed', removed_at = datetime('now') WHERE id = ?
416
+ `);
417
+
418
+ for (const h of toRemove) {
419
+ removeStmt.run(h.id);
420
+ }
421
+
422
+ return toRemove.length;
423
+ });
424
+
425
+ return tx();
426
+ }
427
+
428
+ /**
429
+ * Full-text search hooks using FTS5 with BM25 ranking.
430
+ * @param {string} query - Search keywords
431
+ * @param {object} [opts] - { type, source, isDynamic, includeRemoved, limit }
432
+ * @returns {Array<object>} Ranked search results with source_name joined
433
+ */
434
+ export function searchHooks(query, opts = {}) {
435
+ const db = getDb();
436
+ const { type, source, isDynamic, includeRemoved, limit = 20 } = opts;
437
+
438
+ // Build FTS query — escape special chars
439
+ const ftsQuery = query.replace(/['"(){}[\]*:^~!]/g, ' ').trim();
440
+ if (!ftsQuery) return [];
441
+
442
+ // Tokenize and add wildcards for prefix matching
443
+ const terms = ftsQuery.split(/\s+/).filter(Boolean).map(t => `"${t}"*`).join(' ');
444
+
445
+ // Boost exact hook name matches so core hooks like "init" surface above
446
+ // longer-named hooks like "woocommerce_shipping_init" that have richer BM25 context.
447
+ // Exact matches get rank bonus of -100 (lower = better in BM25).
448
+ let sql = `
449
+ SELECT h.*, s.name AS source_name,
450
+ bm25(hooks_fts, 10, 5, 2, 3, 1, 1, 1)
451
+ + CASE WHEN h.name = @rawQuery THEN -100 ELSE 0 END
452
+ AS rank
453
+ FROM hooks_fts
454
+ JOIN hooks h ON h.id = hooks_fts.rowid
455
+ JOIN sources s ON s.id = h.source_id
456
+ WHERE hooks_fts MATCH @terms
457
+ `;
458
+
459
+ const params = { terms, rawQuery: ftsQuery };
460
+
461
+ if (!includeRemoved) {
462
+ sql += ` AND h.status = 'active'`;
463
+ }
464
+ if (type) {
465
+ sql += ` AND h.type = @type`;
466
+ params.type = type;
467
+ }
468
+ if (source) {
469
+ sql += ` AND s.name = @source`;
470
+ params.source = source;
471
+ }
472
+ if (isDynamic !== undefined) {
473
+ sql += ` AND h.is_dynamic = @isDynamic`;
474
+ params.isDynamic = isDynamic ? 1 : 0;
475
+ }
476
+
477
+ sql += ` ORDER BY rank LIMIT @limit`;
478
+ params.limit = limit;
479
+
480
+ return db.prepare(sql).all(params);
481
+ }
482
+
483
+ /**
484
+ * Validate whether a hook name exists in the index.
485
+ * Returns VALID (with locations), REMOVED, or NOT_FOUND (with FTS-based suggestions).
486
+ * @param {string} hookName - Exact hook name to check
487
+ * @returns {{ status: string, hooks?: Array, similar?: Array }}
488
+ */
489
+ export function validateHook(hookName) {
490
+ const db = getDb();
491
+
492
+ const exact = stmt(db, `
493
+ SELECT h.*, s.name AS source_name FROM hooks h
494
+ JOIN sources s ON s.id = h.source_id
495
+ WHERE h.name = @name AND h.status = 'active'
496
+ `).all({ name: hookName });
497
+
498
+ if (exact.length > 0) {
499
+ return { status: 'VALID', hooks: exact };
500
+ }
501
+
502
+ const removed = stmt(db, `
503
+ SELECT h.*, s.name AS source_name FROM hooks h
504
+ JOIN sources s ON s.id = h.source_id
505
+ WHERE h.name = @name AND h.status = 'removed'
506
+ `).all({ name: hookName });
507
+
508
+ if (removed.length > 0) {
509
+ return { status: 'REMOVED', hooks: removed };
510
+ }
511
+
512
+ // Try FTS for similar suggestions
513
+ const ftsQuery = hookName.replace(/['"(){}[\]*:^~!]/g, ' ').replace(/_/g, ' ').trim();
514
+ const terms = ftsQuery.split(/\s+/).filter(Boolean).map(t => `"${t}"*`).join(' ');
515
+
516
+ let similar = [];
517
+ if (terms) {
518
+ try {
519
+ similar = db.prepare(`
520
+ SELECT h.name, h.type, s.name AS source_name,
521
+ bm25(hooks_fts, 10, 5, 2, 3, 1, 1, 1) AS rank
522
+ FROM hooks_fts
523
+ JOIN hooks h ON h.id = hooks_fts.rowid
524
+ JOIN sources s ON s.id = h.source_id
525
+ WHERE hooks_fts MATCH @terms AND h.status = 'active'
526
+ ORDER BY rank LIMIT 5
527
+ `).all({ terms });
528
+ } catch {
529
+ // FTS query may fail on edge cases — return empty suggestions
530
+ }
531
+ }
532
+
533
+ return { status: 'NOT_FOUND', similar };
534
+ }
535
+
536
+ /**
537
+ * Get full hook details including code context. Looks up by numeric ID first, then by name.
538
+ * @param {string|number} idOrName - Hook ID or exact hook name
539
+ * @returns {object|undefined}
540
+ */
541
+ export function getHookContext(idOrName) {
542
+ const db = getDb();
543
+
544
+ // Try by ID first
545
+ if (typeof idOrName === 'number' || /^\d+$/.test(idOrName)) {
546
+ const hook = stmt(db, `
547
+ SELECT h.*, s.name AS source_name FROM hooks h
548
+ JOIN sources s ON s.id = h.source_id
549
+ WHERE h.id = ?
550
+ `).get(Number(idOrName));
551
+ if (hook) return hook;
552
+ }
553
+
554
+ // Try by name
555
+ return stmt(db, `
556
+ SELECT h.*, s.name AS source_name FROM hooks h
557
+ JOIN sources s ON s.id = h.source_id
558
+ WHERE h.name = ? AND h.status = 'active'
559
+ ORDER BY h.last_seen_at DESC LIMIT 1
560
+ `).get(idOrName);
561
+ }
562
+
563
+ // --- Block Registrations ---
564
+
565
+ /**
566
+ * Insert or update a block registration. Uses content_hash for change detection.
567
+ * @param {object} data - Block data including source_id, file_path, line_number, block_name, etc.
568
+ * @returns {{ id: number, action: string }}
569
+ */
570
+ export function upsertBlockRegistration(data) {
571
+ const db = getDb();
572
+ const tx = db.transaction((d) => {
573
+ const existing = stmt(db, `
574
+ SELECT id, content_hash FROM block_registrations
575
+ WHERE source_id = @source_id AND file_path = @file_path AND line_number = @line_number AND block_name = @block_name
576
+ `).get(d);
577
+
578
+ if (existing) {
579
+ if (existing.content_hash === d.content_hash) {
580
+ stmt(db, 'UPDATE block_registrations SET last_seen_at = datetime(\'now\') WHERE id = ?').run(existing.id);
581
+ return { id: existing.id, action: 'skipped' };
582
+ }
583
+ stmt(db, `
584
+ UPDATE block_registrations SET
585
+ block_title = @block_title, block_category = @block_category,
586
+ block_attributes = @block_attributes, supports = @supports,
587
+ code_context = @code_context, content_hash = @content_hash,
588
+ last_seen_at = datetime('now')
589
+ WHERE id = @id
590
+ `).run({ ...d, id: existing.id });
591
+ stmt(db, 'DELETE FROM block_registrations_fts WHERE rowid = ?').run(existing.id);
592
+ stmt(db, `
593
+ INSERT INTO block_registrations_fts(rowid, block_name, block_title, block_category, block_attributes, supports, code_context)
594
+ VALUES (@id, @block_name, @block_title, @block_category, @block_attributes, @supports, @code_context)
595
+ `).run({ ...d, id: existing.id });
596
+ return { id: existing.id, action: 'updated' };
597
+ }
598
+
599
+ const result = stmt(db, `
600
+ INSERT INTO block_registrations (source_id, file_path, line_number, block_name, block_title, block_category, block_attributes, supports, code_context, content_hash)
601
+ VALUES (@source_id, @file_path, @line_number, @block_name, @block_title, @block_category, @block_attributes, @supports, @code_context, @content_hash)
602
+ `).run(d);
603
+
604
+ stmt(db, `
605
+ INSERT INTO block_registrations_fts(rowid, block_name, block_title, block_category, block_attributes, supports, code_context)
606
+ VALUES (@id, @block_name, @block_title, @block_category, @block_attributes, @supports, @code_context)
607
+ `).run({ ...d, id: result.lastInsertRowid });
608
+
609
+ return { id: result.lastInsertRowid, action: 'inserted' };
610
+ });
611
+
612
+ return tx(data);
613
+ }
614
+
615
+ // --- API Usages ---
616
+
617
+ /**
618
+ * Insert or update a JS API usage record. Uses content_hash for change detection.
619
+ * @param {object} data - API data including source_id, file_path, line_number, api_call, namespace, method, etc.
620
+ * @returns {{ id: number, action: string }}
621
+ */
622
+ export function upsertApiUsage(data) {
623
+ const db = getDb();
624
+ const tx = db.transaction((d) => {
625
+ const existing = stmt(db, `
626
+ SELECT id, content_hash FROM api_usages
627
+ WHERE source_id = @source_id AND file_path = @file_path AND line_number = @line_number AND api_call = @api_call
628
+ `).get(d);
629
+
630
+ if (existing) {
631
+ if (existing.content_hash === d.content_hash) {
632
+ stmt(db, 'UPDATE api_usages SET last_seen_at = datetime(\'now\') WHERE id = ?').run(existing.id);
633
+ return { id: existing.id, action: 'skipped' };
634
+ }
635
+ stmt(db, `
636
+ UPDATE api_usages SET
637
+ namespace = @namespace, method = @method,
638
+ code_context = @code_context, content_hash = @content_hash,
639
+ last_seen_at = datetime('now')
640
+ WHERE id = @id
641
+ `).run({ ...d, id: existing.id });
642
+ stmt(db, 'DELETE FROM api_usages_fts WHERE rowid = ?').run(existing.id);
643
+ stmt(db, `
644
+ INSERT INTO api_usages_fts(rowid, api_call, namespace, method, code_context)
645
+ VALUES (@id, @api_call, @namespace, @method, @code_context)
646
+ `).run({ ...d, id: existing.id });
647
+ return { id: existing.id, action: 'updated' };
648
+ }
649
+
650
+ const result = stmt(db, `
651
+ INSERT INTO api_usages (source_id, file_path, line_number, api_call, namespace, method, code_context, content_hash)
652
+ VALUES (@source_id, @file_path, @line_number, @api_call, @namespace, @method, @code_context, @content_hash)
653
+ `).run(d);
654
+
655
+ stmt(db, `
656
+ INSERT INTO api_usages_fts(rowid, api_call, namespace, method, code_context)
657
+ VALUES (@id, @api_call, @namespace, @method, @code_context)
658
+ `).run({ ...d, id: result.lastInsertRowid });
659
+
660
+ return { id: result.lastInsertRowid, action: 'inserted' };
661
+ });
662
+
663
+ return tx(data);
664
+ }
665
+
666
+ // --- Indexed Files ---
667
+
668
+ /**
669
+ * Get the indexed file record for mtime/hash comparison.
670
+ * @param {number} sourceId
671
+ * @param {string} filePath
672
+ * @returns {object|undefined}
673
+ */
674
+ export function getIndexedFile(sourceId, filePath) {
675
+ const db = getDb();
676
+ return stmt(db, 'SELECT * FROM indexed_files WHERE source_id = ? AND file_path = ?').get(sourceId, filePath);
677
+ }
678
+
679
+ /**
680
+ * Track a file as indexed with its mtime and content hash.
681
+ * @param {number} sourceId
682
+ * @param {string} filePath
683
+ * @param {number} mtimeMs
684
+ * @param {string} contentHash
685
+ */
686
+ export function upsertIndexedFile(sourceId, filePath, mtimeMs, contentHash) {
687
+ const db = getDb();
688
+ stmt(db, `
689
+ INSERT INTO indexed_files (source_id, file_path, mtime_ms, content_hash)
690
+ VALUES (@source_id, @file_path, @mtime_ms, @content_hash)
691
+ ON CONFLICT(source_id, file_path)
692
+ DO UPDATE SET mtime_ms = @mtime_ms, content_hash = @content_hash
693
+ `).run({ source_id: sourceId, file_path: filePath, mtime_ms: mtimeMs, content_hash: contentHash });
694
+ }
695
+
696
+ // --- FTS Rebuild ---
697
+
698
+ /**
699
+ * Rebuild all FTS5 indexes from the content tables.
700
+ * Useful for recovery when FTS gets out of sync.
701
+ */
702
+ export function rebuildFtsIndex() {
703
+ const db = getDb();
704
+ const tx = db.transaction(() => {
705
+ // Rebuild hooks FTS
706
+ db.exec('DELETE FROM hooks_fts');
707
+ db.exec(`
708
+ INSERT INTO hooks_fts(rowid, name, type, docblock, inferred_description, function_context, class_name, params)
709
+ SELECT id, name, type, docblock, inferred_description, function_context, class_name, params FROM hooks
710
+ `);
711
+
712
+ // Rebuild block_registrations FTS
713
+ db.exec('DELETE FROM block_registrations_fts');
714
+ db.exec(`
715
+ INSERT INTO block_registrations_fts(rowid, block_name, block_title, block_category, block_attributes, supports, code_context)
716
+ SELECT id, block_name, block_title, block_category, block_attributes, supports, code_context FROM block_registrations
717
+ `);
718
+
719
+ // Rebuild api_usages FTS
720
+ db.exec('DELETE FROM api_usages_fts');
721
+ db.exec(`
722
+ INSERT INTO api_usages_fts(rowid, api_call, namespace, method, code_context)
723
+ SELECT id, api_call, namespace, method, code_context FROM api_usages
724
+ `);
725
+
726
+ // Rebuild docs FTS
727
+ db.exec('DELETE FROM docs_fts');
728
+ db.exec(`
729
+ INSERT INTO docs_fts(rowid, title, slug, doc_type, category, description, content)
730
+ SELECT id, title, slug, doc_type, category, description, content FROM docs
731
+ `);
732
+ });
733
+ tx();
734
+ }
735
+
736
+ // --- Stats ---
737
+
738
+ /**
739
+ * Get overall and per-source indexing statistics.
740
+ * @returns {{ totals: object, per_source: Array<object> }}
741
+ */
742
+ export function getStats() {
743
+ const db = getDb();
744
+ const sources = stmt(db, 'SELECT COUNT(*) as count FROM sources').get();
745
+ const hooks = stmt(db, "SELECT COUNT(*) as count FROM hooks WHERE status = 'active'").get();
746
+ const removedHooks = stmt(db, "SELECT COUNT(*) as count FROM hooks WHERE status = 'removed'").get();
747
+ const blocks = stmt(db, 'SELECT COUNT(*) as count FROM block_registrations').get();
748
+ const apis = stmt(db, 'SELECT COUNT(*) as count FROM api_usages').get();
749
+ const docs = stmt(db, "SELECT COUNT(*) as count FROM docs WHERE status = 'active'").get();
750
+
751
+ const perSource = db.prepare(`
752
+ SELECT s.name, s.content_type,
753
+ (SELECT COUNT(*) FROM hooks WHERE source_id = s.id AND status = 'active') AS hooks,
754
+ (SELECT COUNT(*) FROM hooks WHERE source_id = s.id AND status = 'removed') AS removed_hooks,
755
+ (SELECT COUNT(*) FROM block_registrations WHERE source_id = s.id) AS blocks,
756
+ (SELECT COUNT(*) FROM api_usages WHERE source_id = s.id) AS apis,
757
+ (SELECT COUNT(*) FROM docs WHERE source_id = s.id AND status = 'active') AS docs,
758
+ (SELECT COUNT(*) FROM indexed_files WHERE source_id = s.id) AS files
759
+ FROM sources s ORDER BY s.name
760
+ `).all();
761
+
762
+ return {
763
+ totals: {
764
+ sources: sources.count,
765
+ active_hooks: hooks.count,
766
+ removed_hooks: removedHooks.count,
767
+ block_registrations: blocks.count,
768
+ api_usages: apis.count,
769
+ docs: docs.count,
770
+ },
771
+ per_source: perSource,
772
+ };
773
+ }
774
+
775
+ // --- Search block APIs ---
776
+
777
+ /**
778
+ * Full-text search for block registrations and API usages.
779
+ * Uses FTS5 column filters to match only structured columns (not code_context).
780
+ * @param {string} query - Search keywords
781
+ * @param {object} [opts] - { limit }
782
+ * @returns {{ blocks: Array<object>, apis: Array<object> }}
783
+ */
784
+ export function searchBlockApis(query, opts = {}) {
785
+ const db = getDb();
786
+ const { limit = 20 } = opts;
787
+ const ftsQuery = query.replace(/['"(){}[\]*:^~!]/g, ' ').trim();
788
+ if (!ftsQuery) return { blocks: [], apis: [] };
789
+
790
+ const terms = ftsQuery.split(/\s+/).filter(Boolean).map(t => `"${t}"*`).join(' ');
791
+
792
+ // Use FTS5 column filters to restrict matching to structured columns only.
793
+ // code_context is still returned in results but won't cause false positives.
794
+ const blockTerms = `{block_name block_title block_category} : ${terms}`;
795
+ const apiTerms = `{api_call namespace method} : ${terms}`;
796
+
797
+ let blocks = [];
798
+ let apis = [];
799
+
800
+ try {
801
+ blocks = db.prepare(`
802
+ SELECT br.*, s.name AS source_name,
803
+ bm25(block_registrations_fts, 10, 5, 3, 1, 1, 0) AS rank
804
+ FROM block_registrations_fts
805
+ JOIN block_registrations br ON br.id = block_registrations_fts.rowid
806
+ JOIN sources s ON s.id = br.source_id
807
+ WHERE block_registrations_fts MATCH @terms
808
+ ORDER BY rank LIMIT @limit
809
+ `).all({ terms: blockTerms, limit });
810
+ } catch {
811
+ // FTS query may fail
812
+ }
813
+
814
+ try {
815
+ apis = db.prepare(`
816
+ SELECT au.*, s.name AS source_name,
817
+ bm25(api_usages_fts, 10, 3, 5, 0) AS rank
818
+ FROM api_usages_fts
819
+ JOIN api_usages au ON au.id = api_usages_fts.rowid
820
+ JOIN sources s ON s.id = au.source_id
821
+ WHERE api_usages_fts MATCH @terms
822
+ ORDER BY rank LIMIT @limit
823
+ `).all({ terms: apiTerms, limit });
824
+ } catch {
825
+ // FTS query may fail
826
+ }
827
+
828
+ return { blocks, apis };
829
+ }
830
+
831
+ // --- Docs ---
832
+
833
+ export function upsertDoc(data) {
834
+ const db = getDb();
835
+ const upsertTx = db.transaction((d) => {
836
+ const existing = stmt(db, `
837
+ SELECT id, content_hash FROM docs
838
+ WHERE source_id = @source_id AND file_path = @file_path
839
+ `).get(d);
840
+
841
+ if (existing) {
842
+ if (existing.content_hash === d.content_hash) {
843
+ stmt(db, "UPDATE docs SET last_seen_at = datetime('now'), status = 'active' WHERE id = ?").run(existing.id);
844
+ return { id: existing.id, action: 'skipped' };
845
+ }
846
+ stmt(db, `
847
+ UPDATE docs SET
848
+ slug = @slug, title = @title, doc_type = @doc_type, category = @category,
849
+ subcategory = @subcategory, description = @description, content = @content,
850
+ code_examples = @code_examples, metadata = @metadata, content_hash = @content_hash,
851
+ status = 'active', last_seen_at = datetime('now')
852
+ WHERE id = @id
853
+ `).run({ ...d, id: existing.id });
854
+ stmt(db, 'DELETE FROM docs_fts WHERE rowid = ?').run(existing.id);
855
+ stmt(db, `
856
+ INSERT INTO docs_fts(rowid, title, slug, doc_type, category, description, content)
857
+ VALUES (@id, @title, @slug, @doc_type, @category, @description, @content)
858
+ `).run({ ...d, id: existing.id });
859
+ return { id: existing.id, action: 'updated' };
860
+ }
861
+
862
+ const result = stmt(db, `
863
+ INSERT INTO docs (
864
+ source_id, file_path, slug, title, doc_type, category, subcategory,
865
+ description, content, code_examples, metadata, content_hash, status
866
+ ) VALUES (
867
+ @source_id, @file_path, @slug, @title, @doc_type, @category, @subcategory,
868
+ @description, @content, @code_examples, @metadata, @content_hash, 'active'
869
+ )
870
+ `).run(d);
871
+
872
+ stmt(db, `
873
+ INSERT INTO docs_fts(rowid, title, slug, doc_type, category, description, content)
874
+ VALUES (@id, @title, @slug, @doc_type, @category, @description, @content)
875
+ `).run({ ...d, id: result.lastInsertRowid });
876
+
877
+ return { id: result.lastInsertRowid, action: 'inserted' };
878
+ });
879
+
880
+ return upsertTx(data);
881
+ }
882
+
883
+ export function markDocsRemoved(sourceId, activeDocIds) {
884
+ const db = getDb();
885
+ const tx = db.transaction(() => {
886
+ const allDocs = stmt(db, `
887
+ SELECT id FROM docs WHERE source_id = ? AND status = 'active'
888
+ `).all(sourceId);
889
+
890
+ const activeSet = new Set(activeDocIds.map(Number));
891
+ const toRemove = allDocs.filter(d => !activeSet.has(d.id));
892
+
893
+ const removeStmt = stmt(db, `
894
+ UPDATE docs SET status = 'removed' WHERE id = ?
895
+ `);
896
+
897
+ for (const d of toRemove) {
898
+ removeStmt.run(d.id);
899
+ }
900
+
901
+ return toRemove.length;
902
+ });
903
+
904
+ return tx();
905
+ }
906
+
907
+ export function searchDocs(query, opts = {}) {
908
+ const db = getDb();
909
+ const { doc_type, category, source, limit = 20 } = opts;
910
+
911
+ const ftsQuery = query.replace(/['"(){}[\]*:^~!]/g, ' ').trim();
912
+ if (!ftsQuery) return [];
913
+
914
+ const terms = ftsQuery.split(/\s+/).filter(Boolean).map(t => `"${t}"*`).join(' ');
915
+
916
+ let sql = `
917
+ SELECT d.id, d.source_id, d.file_path, d.slug, d.title, d.doc_type, d.category,
918
+ d.subcategory, d.description, d.status, s.name AS source_name,
919
+ bm25(docs_fts, 10, 5, 3, 2, 5, 1) AS rank
920
+ FROM docs_fts
921
+ JOIN docs d ON d.id = docs_fts.rowid
922
+ JOIN sources s ON s.id = d.source_id
923
+ WHERE docs_fts MATCH @terms
924
+ AND d.status = 'active'
925
+ `;
926
+
927
+ const params = { terms };
928
+
929
+ if (doc_type) {
930
+ sql += ` AND d.doc_type = @doc_type`;
931
+ params.doc_type = doc_type;
932
+ }
933
+ if (category) {
934
+ sql += ` AND d.category = @category`;
935
+ params.category = category;
936
+ }
937
+ if (source) {
938
+ sql += ` AND s.name = @source`;
939
+ params.source = source;
940
+ }
941
+
942
+ sql += ` ORDER BY rank LIMIT @limit`;
943
+ params.limit = limit;
944
+
945
+ try {
946
+ return db.prepare(sql).all(params);
947
+ } catch (err) {
948
+ console.error(`searchDocs FTS error: ${err.message}`);
949
+ return [];
950
+ }
951
+ }
952
+
953
+ export function getDoc(idOrSlug) {
954
+ const db = getDb();
955
+
956
+ if (typeof idOrSlug === 'number' || /^\d+$/.test(idOrSlug)) {
957
+ return stmt(db, `
958
+ SELECT d.*, s.name AS source_name FROM docs d
959
+ JOIN sources s ON s.id = d.source_id
960
+ WHERE d.id = ?
961
+ `).get(Number(idOrSlug));
962
+ }
963
+
964
+ return stmt(db, `
965
+ SELECT d.*, s.name AS source_name FROM docs d
966
+ JOIN sources s ON s.id = d.source_id
967
+ WHERE d.slug = ? AND d.status = 'active'
968
+ ORDER BY d.last_seen_at DESC LIMIT 1
969
+ `).get(idOrSlug);
970
+ }
971
+
972
+ export function getDocCategoryCounts() {
973
+ const db = getDb();
974
+ return db.prepare(`
975
+ SELECT category, COUNT(*) as count
976
+ FROM docs WHERE status = 'active'
977
+ GROUP BY category ORDER BY category
978
+ `).all();
979
+ }
980
+
981
+ export function listDocs(opts = {}) {
982
+ const db = getDb();
983
+ const { doc_type, category, source, limit = 50 } = opts;
984
+
985
+ let sql = `
986
+ SELECT d.id, d.slug, d.title, d.doc_type, d.category, d.subcategory,
987
+ d.description, s.name AS source_name
988
+ FROM docs d
989
+ JOIN sources s ON s.id = d.source_id
990
+ WHERE d.status = 'active'
991
+ `;
992
+
993
+ const params = {};
994
+
995
+ if (doc_type) {
996
+ sql += ` AND d.doc_type = @doc_type`;
997
+ params.doc_type = doc_type;
998
+ }
999
+ if (category) {
1000
+ sql += ` AND d.category = @category`;
1001
+ params.category = category;
1002
+ }
1003
+ if (source) {
1004
+ sql += ` AND s.name = @source`;
1005
+ params.source = source;
1006
+ }
1007
+
1008
+ sql += ` ORDER BY d.category, d.title LIMIT @limit`;
1009
+ params.limit = limit;
1010
+
1011
+ return db.prepare(sql).all(params);
1012
+ }