kotadb 2.0.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 (52) hide show
  1. package/README.md +79 -0
  2. package/package.json +75 -0
  3. package/src/api/auto-reindex.ts +55 -0
  4. package/src/api/openapi/builder.ts +209 -0
  5. package/src/api/openapi/paths.ts +354 -0
  6. package/src/api/openapi/schemas.ts +608 -0
  7. package/src/api/queries.ts +1168 -0
  8. package/src/api/routes.ts +339 -0
  9. package/src/auth/middleware.ts +83 -0
  10. package/src/cli.ts +221 -0
  11. package/src/config/constants.ts +96 -0
  12. package/src/config/environment.ts +96 -0
  13. package/src/config/gitignore.ts +68 -0
  14. package/src/config/index.ts +20 -0
  15. package/src/config/project-root.ts +52 -0
  16. package/src/db/client.ts +72 -0
  17. package/src/db/sqlite/index.ts +35 -0
  18. package/src/db/sqlite/jsonl-exporter.ts +416 -0
  19. package/src/db/sqlite/jsonl-importer.ts +361 -0
  20. package/src/db/sqlite/sqlite-client.ts +536 -0
  21. package/src/index.ts +66 -0
  22. package/src/indexer/ast-parser.ts +146 -0
  23. package/src/indexer/ast-types.ts +54 -0
  24. package/src/indexer/circular-detector.ts +262 -0
  25. package/src/indexer/dependency-extractor.ts +352 -0
  26. package/src/indexer/extractors.ts +54 -0
  27. package/src/indexer/import-resolver.ts +167 -0
  28. package/src/indexer/parsers.ts +177 -0
  29. package/src/indexer/reference-extractor.ts +488 -0
  30. package/src/indexer/repos.ts +245 -0
  31. package/src/indexer/storage.ts +277 -0
  32. package/src/indexer/symbol-extractor.ts +660 -0
  33. package/src/instrument.ts +88 -0
  34. package/src/logging/context.ts +46 -0
  35. package/src/logging/logger.ts +193 -0
  36. package/src/logging/middleware.ts +107 -0
  37. package/src/mcp/github-integration.ts +293 -0
  38. package/src/mcp/headers.ts +101 -0
  39. package/src/mcp/impact-analysis.ts +495 -0
  40. package/src/mcp/jsonrpc.ts +141 -0
  41. package/src/mcp/lifecycle.ts +73 -0
  42. package/src/mcp/server.ts +202 -0
  43. package/src/mcp/session.ts +44 -0
  44. package/src/mcp/spec-validation.ts +491 -0
  45. package/src/mcp/tools.ts +889 -0
  46. package/src/sync/deletion-manifest.ts +210 -0
  47. package/src/sync/index.ts +16 -0
  48. package/src/sync/merge-driver.ts +172 -0
  49. package/src/sync/watcher.ts +221 -0
  50. package/src/types/rate-limit.ts +88 -0
  51. package/src/validation/common-schemas.ts +96 -0
  52. package/src/validation/schemas.ts +187 -0
@@ -0,0 +1,1168 @@
1
+ /**
2
+ * Database query layer for indexed data
3
+ *
4
+ * Local-only implementation using SQLite for all operations.
5
+ *
6
+ * @module @api/queries
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import type { Reference } from "@indexer/reference-extractor";
11
+ import type { Symbol as ExtractedSymbol, SymbolKind } from "@indexer/symbol-extractor";
12
+ import { createLogger } from "@logging/logger.js";
13
+ import type { IndexRequest, IndexedFile } from "@shared/types";
14
+ import { detectLanguage } from "@shared/language-utils";
15
+ import { getGlobalDatabase, type KotaDatabase } from "@db/sqlite/index.js";
16
+
17
+ const logger = createLogger({ module: "api-queries" });
18
+
19
+ export interface SearchOptions {
20
+ repositoryId?: string;
21
+ projectId?: string;
22
+ limit?: number;
23
+ }
24
+
25
+ // ============================================================================
26
+ // Internal implementations that accept a database parameter
27
+ // These are used by both the new API and backward-compatible aliases
28
+ // ============================================================================
29
+
30
+ function saveIndexedFilesInternal(
31
+ db: KotaDatabase,
32
+ files: IndexedFile[],
33
+ repositoryId: string,
34
+ ): number {
35
+ if (files.length === 0) {
36
+ return 0;
37
+ }
38
+
39
+ let count = 0;
40
+
41
+ db.transaction(() => {
42
+ const stmt = db.prepare(`
43
+ INSERT OR REPLACE INTO indexed_files (
44
+ id, repository_id, path, content, language,
45
+ size_bytes, content_hash, indexed_at, metadata
46
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
47
+ `);
48
+
49
+ for (const file of files) {
50
+ const id = randomUUID();
51
+ const language = detectLanguage(file.path);
52
+ const sizeBytes = new TextEncoder().encode(file.content).length;
53
+ const indexedAt = file.indexedAt ? file.indexedAt.toISOString() : new Date().toISOString();
54
+ const metadata = JSON.stringify({ dependencies: file.dependencies || [] });
55
+
56
+ stmt.run([
57
+ id,
58
+ repositoryId,
59
+ file.path,
60
+ file.content,
61
+ language,
62
+ sizeBytes,
63
+ null, // content_hash
64
+ indexedAt,
65
+ metadata
66
+ ]);
67
+ count++;
68
+ }
69
+ });
70
+
71
+ logger.info("Saved indexed files to SQLite", { count, repositoryId });
72
+ return count;
73
+ }
74
+
75
+ function storeSymbolsInternal(
76
+ db: KotaDatabase,
77
+ symbols: ExtractedSymbol[],
78
+ fileId: string,
79
+ ): number {
80
+ if (symbols.length === 0) {
81
+ return 0;
82
+ }
83
+
84
+ // Get repository_id from the file
85
+ const fileResult = db.queryOne<{ repository_id: string }>(
86
+ "SELECT repository_id FROM indexed_files WHERE id = ?",
87
+ [fileId]
88
+ );
89
+
90
+ if (!fileResult) {
91
+ throw new Error(`File not found: ${fileId}`);
92
+ }
93
+
94
+ const repositoryId = fileResult.repository_id;
95
+ let count = 0;
96
+
97
+ db.transaction(() => {
98
+ const stmt = db.prepare(`
99
+ INSERT OR REPLACE INTO indexed_symbols (
100
+ id, file_id, repository_id, name, kind,
101
+ line_start, line_end, signature, documentation, metadata
102
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
103
+ `);
104
+
105
+ for (const symbol of symbols) {
106
+ const id = randomUUID();
107
+ const metadata = JSON.stringify({
108
+ column_start: symbol.columnStart,
109
+ column_end: symbol.columnEnd,
110
+ is_exported: symbol.isExported,
111
+ is_async: symbol.isAsync,
112
+ access_modifier: symbol.accessModifier,
113
+ });
114
+
115
+ stmt.run([
116
+ id,
117
+ fileId,
118
+ repositoryId,
119
+ symbol.name,
120
+ symbol.kind,
121
+ symbol.lineStart,
122
+ symbol.lineEnd,
123
+ symbol.signature || null,
124
+ symbol.documentation || null,
125
+ metadata
126
+ ]);
127
+ count++;
128
+ }
129
+ });
130
+
131
+ logger.info("Stored symbols to SQLite", { count, fileId });
132
+ return count;
133
+ }
134
+
135
+ function storeReferencesInternal(
136
+ db: KotaDatabase,
137
+ references: Reference[],
138
+ fileId: string,
139
+ ): number {
140
+ if (references.length === 0) {
141
+ return 0;
142
+ }
143
+
144
+ // Get repository_id from the file
145
+ const fileResult = db.queryOne<{ repository_id: string }>(
146
+ "SELECT repository_id FROM indexed_files WHERE id = ?",
147
+ [fileId]
148
+ );
149
+
150
+ if (!fileResult) {
151
+ throw new Error(`File not found: ${fileId}`);
152
+ }
153
+
154
+ const repositoryId = fileResult.repository_id;
155
+ let count = 0;
156
+
157
+ db.transaction(() => {
158
+ // First, delete existing references for this file
159
+ db.run("DELETE FROM indexed_references WHERE file_id = ?", [fileId]);
160
+
161
+ const stmt = db.prepare(`
162
+ INSERT INTO indexed_references (
163
+ id, file_id, repository_id, symbol_name, target_symbol_id,
164
+ target_file_path, line_number, column_number, reference_type, metadata
165
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
166
+ `);
167
+
168
+ for (const ref of references) {
169
+ const id = randomUUID();
170
+ const metadata = JSON.stringify({
171
+ target_name: ref.targetName,
172
+ column_number: ref.columnNumber,
173
+ ...ref.metadata,
174
+ });
175
+
176
+ stmt.run([
177
+ id,
178
+ fileId,
179
+ repositoryId,
180
+ ref.targetName || "unknown",
181
+ null, // target_symbol_id - deferred
182
+ null, // target_file_path - deferred
183
+ ref.lineNumber,
184
+ ref.columnNumber || 0,
185
+ ref.referenceType,
186
+ metadata
187
+ ]);
188
+ count++;
189
+ }
190
+ });
191
+
192
+ logger.info("Stored references to SQLite", { count, fileId });
193
+ return count;
194
+ }
195
+
196
+ function storeDependenciesInternal(
197
+ db: KotaDatabase,
198
+ dependencies: Array<{
199
+ repositoryId: string;
200
+ fromFileId: string | null;
201
+ toFileId: string | null;
202
+ fromSymbolId: string | null;
203
+ toSymbolId: string | null;
204
+ dependencyType: "file_import" | "symbol_usage";
205
+ metadata: Record<string, unknown>;
206
+ }>,
207
+ ): number {
208
+ if (dependencies.length === 0) {
209
+ return 0;
210
+ }
211
+
212
+ let count = 0;
213
+
214
+ db.transaction(() => {
215
+ const stmt = db.prepare(`
216
+ INSERT INTO dependency_graph (
217
+ id, repository_id, from_file_id, to_file_id,
218
+ from_symbol_id, to_symbol_id, dependency_type, metadata
219
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
220
+ `);
221
+
222
+ for (const dep of dependencies) {
223
+ const id = randomUUID();
224
+ const metadata = JSON.stringify(dep.metadata);
225
+
226
+ stmt.run([
227
+ id,
228
+ dep.repositoryId,
229
+ dep.fromFileId,
230
+ dep.toFileId,
231
+ dep.fromSymbolId,
232
+ dep.toSymbolId,
233
+ dep.dependencyType,
234
+ metadata
235
+ ]);
236
+ count++;
237
+ }
238
+ });
239
+
240
+ logger.info("Stored dependencies to SQLite", { count });
241
+ return count;
242
+ }
243
+
244
+ /**
245
+ * Escape a search term for use in SQLite FTS5 MATCH clause.
246
+ * Wraps the entire term in double quotes for exact phrase matching.
247
+ * Escapes internal double quotes by doubling them.
248
+ *
249
+ * This ensures that:
250
+ * - Multi-word searches match adjacent words in order ("hello world")
251
+ * - Hyphenated terms don't trigger FTS5 operator parsing ("mom-and-pop")
252
+ * - FTS5 keywords (AND, OR, NOT) are treated as literals, not operators
253
+ *
254
+ * @param term - Raw search term from user input
255
+ * @returns Escaped term safe for FTS5 MATCH clause
256
+ */
257
+ function escapeFts5Term(term: string): string {
258
+ // Escape internal double quotes by doubling them
259
+ const escaped = term.replace(/"/g, '""');
260
+ // Wrap in double quotes for exact phrase matching
261
+ return `"${escaped}"`;
262
+ }
263
+
264
+ function searchFilesInternal(
265
+ db: KotaDatabase,
266
+ term: string,
267
+ repositoryId: string | undefined,
268
+ limit: number,
269
+ ): IndexedFile[] {
270
+ const hasRepoFilter = repositoryId !== undefined;
271
+ const sql = hasRepoFilter
272
+ ? `
273
+ SELECT
274
+ f.id,
275
+ f.repository_id,
276
+ f.path,
277
+ f.content,
278
+ f.metadata,
279
+ f.indexed_at,
280
+ snippet(indexed_files_fts, 1, '<mark>', '</mark>', '...', 32) AS snippet
281
+ FROM indexed_files_fts fts
282
+ JOIN indexed_files f ON fts.rowid = f.rowid
283
+ WHERE indexed_files_fts MATCH ?
284
+ AND f.repository_id = ?
285
+ ORDER BY bm25(indexed_files_fts)
286
+ LIMIT ?
287
+ `
288
+ : `
289
+ SELECT
290
+ f.id,
291
+ f.repository_id,
292
+ f.path,
293
+ f.content,
294
+ f.metadata,
295
+ f.indexed_at,
296
+ snippet(indexed_files_fts, 1, '<mark>', '</mark>', '...', 32) AS snippet
297
+ FROM indexed_files_fts fts
298
+ JOIN indexed_files f ON fts.rowid = f.rowid
299
+ WHERE indexed_files_fts MATCH ?
300
+ ORDER BY bm25(indexed_files_fts)
301
+ LIMIT ?
302
+ `;
303
+
304
+ const escapedTerm = escapeFts5Term(term);
305
+ const params = hasRepoFilter ? [escapedTerm, repositoryId, limit] : [escapedTerm, limit];
306
+ const rows = db.query<{
307
+ id: string;
308
+ repository_id: string;
309
+ path: string;
310
+ content: string;
311
+ metadata: string;
312
+ indexed_at: string;
313
+ }>(sql, params);
314
+
315
+ return rows.map((row) => {
316
+ const metadata = JSON.parse(row.metadata || '{}');
317
+ return {
318
+ id: row.id,
319
+ projectRoot: row.repository_id,
320
+ path: row.path,
321
+ content: row.content,
322
+ dependencies: metadata.dependencies || [],
323
+ indexedAt: new Date(row.indexed_at),
324
+ };
325
+ });
326
+ }
327
+
328
+ function listRecentFilesInternal(
329
+ db: KotaDatabase,
330
+ limit: number,
331
+ repositoryId?: string,
332
+ ): IndexedFile[] {
333
+ const hasRepoFilter = repositoryId !== undefined;
334
+ const sql = hasRepoFilter
335
+ ? `
336
+ SELECT
337
+ id, repository_id, path, content, metadata, indexed_at
338
+ FROM indexed_files
339
+ WHERE repository_id = ?
340
+ ORDER BY indexed_at DESC
341
+ LIMIT ?
342
+ `
343
+ : `
344
+ SELECT
345
+ id, repository_id, path, content, metadata, indexed_at
346
+ FROM indexed_files
347
+ ORDER BY indexed_at DESC
348
+ LIMIT ?
349
+ `;
350
+
351
+ const params = hasRepoFilter ? [repositoryId, limit] : [limit];
352
+ const rows = db.query<{
353
+ id: string;
354
+ repository_id: string;
355
+ path: string;
356
+ content: string;
357
+ metadata: string;
358
+ indexed_at: string;
359
+ }>(sql, params);
360
+
361
+ return rows.map((row) => {
362
+ const metadata = JSON.parse(row.metadata || '{}');
363
+ return {
364
+ id: row.id,
365
+ projectRoot: row.repository_id,
366
+ path: row.path,
367
+ content: row.content,
368
+ dependencies: metadata.dependencies || [],
369
+ indexedAt: new Date(row.indexed_at),
370
+ };
371
+ });
372
+ }
373
+
374
+ function resolveFilePathInternal(
375
+ db: KotaDatabase,
376
+ filePath: string,
377
+ repositoryId: string,
378
+ ): string | null {
379
+ const sql = `
380
+ SELECT id
381
+ FROM indexed_files
382
+ WHERE repository_id = ? AND path = ?
383
+ LIMIT 1
384
+ `;
385
+
386
+ const result = db.queryOne<{ id: string }>(sql, [repositoryId, filePath]);
387
+ return result?.id || null;
388
+ }
389
+
390
+ function ensureRepositoryInternal(
391
+ db: KotaDatabase,
392
+ fullName: string,
393
+ gitUrl?: string,
394
+ defaultBranch?: string,
395
+ ): string {
396
+ // Check if repository already exists
397
+ const existing = db.queryOne<{ id: string }>(
398
+ "SELECT id FROM repositories WHERE full_name = ?",
399
+ [fullName]
400
+ );
401
+
402
+ if (existing) {
403
+ logger.debug("Repository already exists in SQLite", { fullName, id: existing.id });
404
+ return existing.id;
405
+ }
406
+
407
+ // Create new repository
408
+ const id = randomUUID();
409
+ const name = fullName.split("/").pop() || fullName;
410
+ const now = new Date().toISOString();
411
+
412
+ db.run(`
413
+ INSERT INTO repositories (id, name, full_name, git_url, default_branch, created_at, updated_at)
414
+ VALUES (?, ?, ?, ?, ?, ?, ?)
415
+ `, [
416
+ id,
417
+ name,
418
+ fullName,
419
+ gitUrl || null,
420
+ defaultBranch || "main",
421
+ now,
422
+ now
423
+ ]);
424
+
425
+ logger.info("Created repository in SQLite", { fullName, id });
426
+ return id;
427
+ }
428
+
429
+ function updateRepositoryLastIndexedInternal(
430
+ db: KotaDatabase,
431
+ repositoryId: string,
432
+ ): void {
433
+ const now = new Date().toISOString();
434
+ db.run(
435
+ "UPDATE repositories SET last_indexed_at = ?, updated_at = ? WHERE id = ?",
436
+ [now, now, repositoryId]
437
+ );
438
+ logger.debug("Updated repository last_indexed_at", { repositoryId });
439
+ }
440
+
441
+ // ============================================================================
442
+ // Public API - uses global database
443
+ // ============================================================================
444
+
445
+ /**
446
+ * Save indexed files to SQLite database
447
+ *
448
+ * @param files - Array of indexed files
449
+ * @param repositoryId - Repository UUID
450
+ * @returns Number of files saved
451
+ */
452
+ export function saveIndexedFiles(
453
+ files: IndexedFile[],
454
+ repositoryId: string,
455
+ ): number {
456
+ return saveIndexedFilesInternal(getGlobalDatabase(), files, repositoryId);
457
+ }
458
+
459
+ /**
460
+ * Store symbols extracted from AST into SQLite database.
461
+ *
462
+ * @param symbols - Array of extracted symbols
463
+ * @param fileId - UUID of the indexed file
464
+ * @returns Number of symbols stored
465
+ */
466
+ export function storeSymbols(
467
+ symbols: ExtractedSymbol[],
468
+ fileId: string,
469
+ ): number {
470
+ return storeSymbolsInternal(getGlobalDatabase(), symbols, fileId);
471
+ }
472
+
473
+ /**
474
+ * Store references extracted from AST into SQLite database.
475
+ *
476
+ * @param references - Array of extracted references
477
+ * @param fileId - UUID of the source file
478
+ * @returns Number of references stored
479
+ */
480
+ export function storeReferences(
481
+ references: Reference[],
482
+ fileId: string,
483
+ ): number {
484
+ return storeReferencesInternal(getGlobalDatabase(), references, fileId);
485
+ }
486
+
487
+ /**
488
+ * Store dependency graph edges into SQLite database.
489
+ *
490
+ * @param dependencies - Array of dependency edges
491
+ * @returns Number of dependencies stored
492
+ */
493
+ export function storeDependencies(
494
+ dependencies: Array<{
495
+ repositoryId: string;
496
+ fromFileId: string | null;
497
+ toFileId: string | null;
498
+ fromSymbolId: string | null;
499
+ toSymbolId: string | null;
500
+ dependencyType: "file_import" | "symbol_usage";
501
+ metadata: Record<string, unknown>;
502
+ }>,
503
+ ): number {
504
+ return storeDependenciesInternal(getGlobalDatabase(), dependencies);
505
+ }
506
+
507
+ /**
508
+ * Search indexed files by content term using FTS5.
509
+ *
510
+ * @param term - Search term to match in file content
511
+ * @param options - Search options (repositoryId filter, limit)
512
+ * @returns Array of matching indexed files
513
+ */
514
+ export function searchFiles(
515
+ term: string,
516
+ options: SearchOptions = {},
517
+ ): IndexedFile[] {
518
+ const limit = Math.min(Math.max(options.limit ?? 20, 1), 100);
519
+ return searchFilesInternal(getGlobalDatabase(), term, options.repositoryId, limit);
520
+ }
521
+
522
+ /**
523
+ * List recently indexed files.
524
+ *
525
+ * @param limit - Maximum number of files to return
526
+ * @returns Array of recently indexed files
527
+ */
528
+ export function listRecentFiles(
529
+ limit: number,
530
+ repositoryId?: string,
531
+ ): IndexedFile[] {
532
+ return listRecentFilesInternal(getGlobalDatabase(), limit, repositoryId);
533
+ }
534
+
535
+ /**
536
+ * Resolve file path to file UUID.
537
+ *
538
+ * @param filePath - Relative file path to resolve
539
+ * @param repositoryId - Repository UUID
540
+ * @returns File UUID or null if not found
541
+ */
542
+ export function resolveFilePath(
543
+ filePath: string,
544
+ repositoryId: string,
545
+ ): string | null {
546
+ return resolveFilePathInternal(getGlobalDatabase(), filePath, repositoryId);
547
+ }
548
+
549
+ export interface DependencyResult {
550
+ direct: string[];
551
+ indirect: Record<string, string[]>;
552
+ cycles: string[][];
553
+ }
554
+
555
+ /**
556
+ * Query files that depend on the given file (reverse lookup).
557
+ *
558
+ * @param fileId - Target file UUID
559
+ * @param depth - Recursion depth (1-5)
560
+ * @param includeTests - Whether to include test files
561
+ * @returns Dependency result with direct/indirect relationships and cycles
562
+ */
563
+ export function queryDependents(
564
+ fileId: string,
565
+ depth: number,
566
+ includeTests: boolean,
567
+ ): DependencyResult {
568
+ const db = getGlobalDatabase();
569
+ const fileRecord = db.queryOne<{ repository_id: string }>(
570
+ "SELECT repository_id FROM indexed_files WHERE id = ?",
571
+ [fileId]
572
+ );
573
+
574
+ if (!fileRecord) {
575
+ throw new Error(`File not found: ${fileId}`);
576
+ }
577
+
578
+ const results = queryDependentsRaw(db, fileRecord.repository_id, fileId, null, depth);
579
+ return processDepthResults(results, includeTests);
580
+ }
581
+
582
+ /**
583
+ * Query files that the given file depends on (forward lookup).
584
+ *
585
+ * @param fileId - Source file UUID
586
+ * @param depth - Recursion depth (1-5)
587
+ * @returns Dependency result with direct/indirect relationships and cycles
588
+ */
589
+ export function queryDependencies(
590
+ fileId: string,
591
+ depth: number,
592
+ ): DependencyResult {
593
+ const db = getGlobalDatabase();
594
+ const fileRecord = db.queryOne<{ repository_id: string }>(
595
+ "SELECT repository_id FROM indexed_files WHERE id = ?",
596
+ [fileId]
597
+ );
598
+
599
+ if (!fileRecord) {
600
+ throw new Error(`File not found: ${fileId}`);
601
+ }
602
+
603
+ const results = queryDependenciesRaw(db, fileRecord.repository_id, fileId, null, depth);
604
+ return processDepthResults(results, true);
605
+ }
606
+
607
+ function processDepthResults(
608
+ results: Array<{
609
+ file_path: string | null;
610
+ depth: number;
611
+ }>,
612
+ includeTests: boolean
613
+ ): DependencyResult {
614
+ const direct: string[] = [];
615
+ const indirect: Record<string, string[]> = {};
616
+ const cycles: string[][] = [];
617
+
618
+ for (const result of results) {
619
+ if (!result.file_path) continue;
620
+
621
+ if (!includeTests && (result.file_path.includes("test") || result.file_path.includes("spec"))) {
622
+ continue;
623
+ }
624
+
625
+ if (result.depth === 1) {
626
+ if (!direct.includes(result.file_path)) {
627
+ direct.push(result.file_path);
628
+ }
629
+ } else {
630
+ const key = `depth_${result.depth}`;
631
+ if (!indirect[key]) {
632
+ indirect[key] = [];
633
+ }
634
+ if (!indirect[key].includes(result.file_path)) {
635
+ indirect[key].push(result.file_path);
636
+ }
637
+ }
638
+ }
639
+
640
+ return { direct, indirect, cycles };
641
+ }
642
+
643
+ /**
644
+ * Query dependents (reverse lookup): files/symbols that depend on the target
645
+ *
646
+ * @internal - Use queryDependents() for the wrapped version
647
+ */
648
+ export function queryDependentsRaw(
649
+ db: KotaDatabase,
650
+ repositoryId: string,
651
+ fileId: string | null,
652
+ symbolId: string | null = null,
653
+ depth: number = 5
654
+ ): Array<{
655
+ file_id: string | null;
656
+ file_path: string | null;
657
+ symbol_id: string | null;
658
+ symbol_name: string | null;
659
+ dependency_type: string;
660
+ depth: number;
661
+ }> {
662
+ const targetCondition = symbolId
663
+ ? 'AND dg.to_symbol_id = ?'
664
+ : 'AND dg.to_file_id = ?';
665
+
666
+ const recursiveJoinCondition = symbolId
667
+ ? 'dg.to_symbol_id = d.from_symbol_id'
668
+ : 'dg.to_file_id = d.from_file_id';
669
+
670
+ const sql = `
671
+ WITH RECURSIVE dependents AS (
672
+ SELECT
673
+ dg.id,
674
+ dg.from_file_id,
675
+ dg.from_symbol_id,
676
+ dg.dependency_type,
677
+ 1 AS depth,
678
+ '/' || dg.id || '/' AS path
679
+ FROM dependency_graph dg
680
+ WHERE dg.repository_id = ?
681
+ ${targetCondition}
682
+
683
+ UNION ALL
684
+
685
+ SELECT
686
+ dg.id,
687
+ dg.from_file_id,
688
+ dg.from_symbol_id,
689
+ dg.dependency_type,
690
+ d.depth + 1,
691
+ d.path || dg.id || '/'
692
+ FROM dependency_graph dg
693
+ JOIN dependents d ON ${recursiveJoinCondition}
694
+ WHERE dg.repository_id = ?
695
+ AND d.depth < ?
696
+ AND INSTR(d.path, '/' || dg.id || '/') = 0
697
+ )
698
+ SELECT DISTINCT
699
+ d.from_file_id AS file_id,
700
+ f.path AS file_path,
701
+ d.from_symbol_id AS symbol_id,
702
+ s.name AS symbol_name,
703
+ d.dependency_type,
704
+ d.depth
705
+ FROM dependents d
706
+ LEFT JOIN indexed_files f ON d.from_file_id = f.id
707
+ LEFT JOIN indexed_symbols s ON d.from_symbol_id = s.id
708
+ ORDER BY d.depth ASC
709
+ `;
710
+
711
+ const targetParam = symbolId || fileId;
712
+ return db.query<{
713
+ file_id: string | null;
714
+ file_path: string | null;
715
+ symbol_id: string | null;
716
+ symbol_name: string | null;
717
+ dependency_type: string;
718
+ depth: number;
719
+ }>(sql, [repositoryId, targetParam, repositoryId, depth]);
720
+ }
721
+
722
+ /**
723
+ * Query dependencies (forward lookup): files/symbols that the source depends on
724
+ *
725
+ * @internal - Use queryDependencies() for the wrapped version
726
+ */
727
+ export function queryDependenciesRaw(
728
+ db: KotaDatabase,
729
+ repositoryId: string,
730
+ fileId: string | null,
731
+ symbolId: string | null = null,
732
+ depth: number = 5
733
+ ): Array<{
734
+ file_id: string | null;
735
+ file_path: string | null;
736
+ symbol_id: string | null;
737
+ symbol_name: string | null;
738
+ dependency_type: string;
739
+ depth: number;
740
+ }> {
741
+ const sourceCondition = symbolId
742
+ ? 'AND dg.from_symbol_id = ?'
743
+ : 'AND dg.from_file_id = ?';
744
+
745
+ const recursiveJoinCondition = symbolId
746
+ ? 'dg.from_symbol_id = d.to_symbol_id'
747
+ : 'dg.from_file_id = d.to_file_id';
748
+
749
+ const sql = `
750
+ WITH RECURSIVE dependencies AS (
751
+ SELECT
752
+ dg.id,
753
+ dg.to_file_id,
754
+ dg.to_symbol_id,
755
+ dg.dependency_type,
756
+ 1 AS depth,
757
+ '/' || dg.id || '/' AS path
758
+ FROM dependency_graph dg
759
+ WHERE dg.repository_id = ?
760
+ ${sourceCondition}
761
+
762
+ UNION ALL
763
+
764
+ SELECT
765
+ dg.id,
766
+ dg.to_file_id,
767
+ dg.to_symbol_id,
768
+ dg.dependency_type,
769
+ d.depth + 1,
770
+ d.path || dg.id || '/'
771
+ FROM dependency_graph dg
772
+ JOIN dependencies d ON ${recursiveJoinCondition}
773
+ WHERE dg.repository_id = ?
774
+ AND d.depth < ?
775
+ AND INSTR(d.path, '/' || dg.id || '/') = 0
776
+ )
777
+ SELECT DISTINCT
778
+ d.to_file_id AS file_id,
779
+ f.path AS file_path,
780
+ d.to_symbol_id AS symbol_id,
781
+ s.name AS symbol_name,
782
+ d.dependency_type,
783
+ d.depth
784
+ FROM dependencies d
785
+ LEFT JOIN indexed_files f ON d.to_file_id = f.id
786
+ LEFT JOIN indexed_symbols s ON d.to_symbol_id = s.id
787
+ ORDER BY d.depth ASC
788
+ `;
789
+
790
+ const sourceParam = symbolId || fileId;
791
+ return db.query<{
792
+ file_id: string | null;
793
+ file_path: string | null;
794
+ symbol_id: string | null;
795
+ symbol_name: string | null;
796
+ dependency_type: string;
797
+ depth: number;
798
+ }>(sql, [repositoryId, sourceParam, repositoryId, depth]);
799
+ }
800
+
801
+ /**
802
+ * Ensure repository exists in SQLite, create if not.
803
+ *
804
+ * @param fullName - Repository full name (owner/repo format)
805
+ * @param gitUrl - Git URL for the repository (optional)
806
+ * @param defaultBranch - Default branch name (optional, defaults to 'main')
807
+ * @returns Repository UUID
808
+ */
809
+ export function ensureRepository(
810
+ fullName: string,
811
+ gitUrl?: string,
812
+ defaultBranch?: string,
813
+ ): string {
814
+ return ensureRepositoryInternal(getGlobalDatabase(), fullName, gitUrl, defaultBranch);
815
+ }
816
+
817
+ /**
818
+ * Update repository last_indexed_at timestamp.
819
+ *
820
+ * @param repositoryId - Repository UUID
821
+ */
822
+ export function updateRepositoryLastIndexed(
823
+ repositoryId: string,
824
+ ): void {
825
+ updateRepositoryLastIndexedInternal(getGlobalDatabase(), repositoryId);
826
+ }
827
+
828
+ /**
829
+ * Run indexing workflow for local mode (synchronous, no queue).
830
+ *
831
+ * @param request - Index request with repository details
832
+ * @returns Indexing result with stats
833
+ */
834
+ export async function runIndexingWorkflow(
835
+ request: IndexRequest,
836
+ ): Promise<{
837
+ repositoryId: string;
838
+ filesIndexed: number;
839
+ symbolsExtracted: number;
840
+ referencesExtracted: number;
841
+ dependenciesExtracted: number;
842
+ }> {
843
+ const { existsSync } = await import("node:fs");
844
+ const { resolve } = await import("node:path");
845
+ const { prepareRepository } = await import("@indexer/repos");
846
+ const { discoverSources, parseSourceFile } = await import("@indexer/parsers");
847
+ const { parseFile, isSupportedForAST } = await import("@indexer/ast-parser");
848
+ const { extractSymbols } = await import("@indexer/symbol-extractor");
849
+ const { extractReferences } = await import("@indexer/reference-extractor");
850
+ const { extractDependencies } = await import("@indexer/dependency-extractor");
851
+ const { detectCircularDependencies } = await import("@indexer/circular-detector");
852
+
853
+ const db = getGlobalDatabase();
854
+
855
+ let localPath: string;
856
+ let fullName = request.repository;
857
+
858
+ if (request.localPath) {
859
+ localPath = resolve(request.localPath);
860
+
861
+ const workspaceRoot = resolve(process.cwd());
862
+ if (!localPath.startsWith(workspaceRoot)) {
863
+ throw new Error(`localPath must be within workspace: ${workspaceRoot}`);
864
+ }
865
+ if (!fullName.includes("/")) {
866
+ fullName = `local/${fullName}`;
867
+ }
868
+ } else {
869
+ const repo = await prepareRepository(request);
870
+ localPath = repo.localPath;
871
+ }
872
+
873
+ if (!existsSync(localPath)) {
874
+ throw new Error(`Repository path does not exist: ${localPath}`);
875
+ }
876
+
877
+ const gitUrl = request.localPath ? localPath : `https://github.com/${fullName}.git`;
878
+ const repositoryId = ensureRepository(fullName, gitUrl, request.ref);
879
+
880
+ logger.info("Starting local indexing workflow", {
881
+ repositoryId,
882
+ fullName,
883
+ localPath,
884
+ });
885
+
886
+ const sources = await discoverSources(localPath);
887
+ const records = (
888
+ await Promise.all(sources.map((source) => parseSourceFile(source, localPath)))
889
+ ).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
890
+
891
+ const filesIndexed = saveIndexedFiles(records, repositoryId);
892
+
893
+ const allSymbolsWithFileId: Array<{
894
+ id: string;
895
+ file_id: string;
896
+ name: string;
897
+ kind: SymbolKind;
898
+ lineStart: number;
899
+ lineEnd: number;
900
+ columnStart: number;
901
+ columnEnd: number;
902
+ signature: string | null;
903
+ documentation: string | null;
904
+ isExported: boolean;
905
+ }> = [];
906
+ const allReferencesWithFileId: Array<Reference & { file_id: string }> = [];
907
+ const filesWithId: IndexedFile[] = [];
908
+
909
+ let totalSymbols = 0;
910
+ let totalReferences = 0;
911
+
912
+ for (const file of records) {
913
+ if (!isSupportedForAST(file.path)) continue;
914
+
915
+ const ast = parseFile(file.path, file.content);
916
+ if (!ast) continue;
917
+
918
+ const symbols = extractSymbols(ast, file.path);
919
+ const references = extractReferences(ast, file.path);
920
+
921
+ const fileRecord = db.queryOne<{ id: string }>(
922
+ "SELECT id FROM indexed_files WHERE repository_id = ? AND path = ?",
923
+ [repositoryId, file.path]
924
+ );
925
+
926
+ if (!fileRecord) {
927
+ logger.warn("Could not find file record after indexing", {
928
+ filePath: file.path,
929
+ repositoryId,
930
+ });
931
+ continue;
932
+ }
933
+
934
+ filesWithId.push({ ...file, id: fileRecord.id, repository_id: repositoryId });
935
+
936
+ const symbolCount = storeSymbols(symbols, fileRecord.id);
937
+ const referenceCount = storeReferences(references, fileRecord.id);
938
+
939
+ totalSymbols += symbolCount;
940
+ totalReferences += referenceCount;
941
+
942
+ const storedSymbols = db.query<{
943
+ id: string;
944
+ file_id: string;
945
+ name: string;
946
+ kind: SymbolKind;
947
+ line_start: number;
948
+ line_end: number;
949
+ signature: string | null;
950
+ documentation: string | null;
951
+ metadata: string;
952
+ }>(
953
+ "SELECT id, file_id, name, kind, line_start, line_end, signature, documentation, metadata FROM indexed_symbols WHERE file_id = ?",
954
+ [fileRecord.id]
955
+ );
956
+
957
+ for (const s of storedSymbols) {
958
+ const metadata = JSON.parse(s.metadata || "{}");
959
+ allSymbolsWithFileId.push({
960
+ id: s.id,
961
+ file_id: s.file_id,
962
+ name: s.name,
963
+ kind: s.kind as SymbolKind,
964
+ lineStart: s.line_start,
965
+ lineEnd: s.line_end,
966
+ columnStart: metadata.column_start || 0,
967
+ columnEnd: metadata.column_end || 0,
968
+ signature: s.signature || null,
969
+ documentation: s.documentation || null,
970
+ isExported: metadata.is_exported || false,
971
+ });
972
+ }
973
+
974
+ for (const ref of references) {
975
+ allReferencesWithFileId.push({ ...ref, file_id: fileRecord.id });
976
+ }
977
+ }
978
+
979
+ logger.info("Extracting dependency graph", {
980
+ fileCount: filesWithId.length,
981
+ repositoryId,
982
+ });
983
+
984
+ const dependencies = extractDependencies(
985
+ filesWithId,
986
+ allSymbolsWithFileId,
987
+ allReferencesWithFileId,
988
+ repositoryId,
989
+ );
990
+
991
+ db.run("DELETE FROM dependency_graph WHERE repository_id = ?", [repositoryId]);
992
+ const dependencyCount = storeDependencies(dependencies);
993
+
994
+ const filePathById = new Map(filesWithId.map((f) => [f.id!, f.path]));
995
+ const symbolNameById = new Map(allSymbolsWithFileId.map((s) => [s.id, s.name]));
996
+
997
+ const circularChains = detectCircularDependencies(dependencies, filePathById, symbolNameById);
998
+
999
+ if (circularChains.length > 0) {
1000
+ logger.warn("Circular dependency chains detected", {
1001
+ chainCount: circularChains.length,
1002
+ repositoryId,
1003
+ chains: circularChains.map((c) => ({
1004
+ type: c.type,
1005
+ description: c.description,
1006
+ })),
1007
+ });
1008
+ }
1009
+
1010
+ updateRepositoryLastIndexed(repositoryId);
1011
+
1012
+ logger.info("Local indexing workflow completed", {
1013
+ repositoryId,
1014
+ filesIndexed,
1015
+ symbolsExtracted: totalSymbols,
1016
+ referencesExtracted: totalReferences,
1017
+ dependenciesExtracted: dependencyCount,
1018
+ circularDependencies: circularChains.length,
1019
+ });
1020
+
1021
+ return {
1022
+ repositoryId,
1023
+ filesIndexed,
1024
+ symbolsExtracted: totalSymbols,
1025
+ referencesExtracted: totalReferences,
1026
+ dependenciesExtracted: dependencyCount,
1027
+ };
1028
+ }
1029
+
1030
+ // ============================================================================
1031
+ // Backward-compatible aliases that accept db parameter
1032
+ // These use the passed database (for tests) rather than the global one
1033
+ // ============================================================================
1034
+
1035
+ /**
1036
+ * @deprecated Use saveIndexedFiles() directly
1037
+ */
1038
+ export function saveIndexedFilesLocal(
1039
+ db: KotaDatabase,
1040
+ files: IndexedFile[],
1041
+ repositoryId: string
1042
+ ): number {
1043
+ return saveIndexedFilesInternal(db, files, repositoryId);
1044
+ }
1045
+
1046
+ /**
1047
+ * @deprecated Use storeSymbols() directly
1048
+ */
1049
+ export function storeSymbolsLocal(
1050
+ db: KotaDatabase,
1051
+ symbols: ExtractedSymbol[],
1052
+ fileId: string
1053
+ ): number {
1054
+ return storeSymbolsInternal(db, symbols, fileId);
1055
+ }
1056
+
1057
+ /**
1058
+ * @deprecated Use storeReferences() directly
1059
+ */
1060
+ export function storeReferencesLocal(
1061
+ db: KotaDatabase,
1062
+ references: Reference[],
1063
+ fileId: string
1064
+ ): number {
1065
+ return storeReferencesInternal(db, references, fileId);
1066
+ }
1067
+
1068
+ /**
1069
+ * @deprecated Use searchFiles() directly
1070
+ */
1071
+ export function searchFilesLocal(
1072
+ db: KotaDatabase,
1073
+ term: string,
1074
+ repositoryId: string | undefined,
1075
+ limit: number
1076
+ ): IndexedFile[] {
1077
+ return searchFilesInternal(db, term, repositoryId, limit);
1078
+ }
1079
+
1080
+ /**
1081
+ * @deprecated Use listRecentFiles() directly
1082
+ */
1083
+ export function listRecentFilesLocal(
1084
+ db: KotaDatabase,
1085
+ limit: number,
1086
+ repositoryId?: string,
1087
+ ): IndexedFile[] {
1088
+ return listRecentFilesInternal(db, limit, repositoryId);
1089
+ }
1090
+
1091
+ /**
1092
+ * @deprecated Use resolveFilePath() directly
1093
+ */
1094
+ export function resolveFilePathLocal(
1095
+ db: KotaDatabase,
1096
+ filePath: string,
1097
+ repositoryId: string
1098
+ ): string | null {
1099
+ return resolveFilePathInternal(db, filePath, repositoryId);
1100
+ }
1101
+
1102
+ /**
1103
+ * @deprecated Use storeDependencies() directly
1104
+ */
1105
+ export function storeDependenciesLocal(
1106
+ db: KotaDatabase,
1107
+ dependencies: Array<{
1108
+ repositoryId: string;
1109
+ fromFileId: string | null;
1110
+ toFileId: string | null;
1111
+ fromSymbolId: string | null;
1112
+ toSymbolId: string | null;
1113
+ dependencyType: "file_import" | "symbol_usage";
1114
+ metadata: Record<string, unknown>;
1115
+ }>
1116
+ ): number {
1117
+ return storeDependenciesInternal(db, dependencies);
1118
+ }
1119
+
1120
+ /**
1121
+ * @deprecated Use queryDependentsRaw() directly
1122
+ */
1123
+ export const queryDependentsLocal = queryDependentsRaw;
1124
+
1125
+ /**
1126
+ * @deprecated Use queryDependenciesRaw() directly
1127
+ */
1128
+ export const queryDependenciesLocal = queryDependenciesRaw;
1129
+
1130
+ /**
1131
+ * @deprecated Use ensureRepository() directly
1132
+ */
1133
+ export function ensureRepositoryLocal(
1134
+ db: KotaDatabase,
1135
+ fullName: string,
1136
+ gitUrl?: string,
1137
+ defaultBranch?: string
1138
+ ): string {
1139
+ return ensureRepositoryInternal(db, fullName, gitUrl, defaultBranch);
1140
+ }
1141
+
1142
+ /**
1143
+ * @deprecated Use updateRepositoryLastIndexed() directly
1144
+ */
1145
+ export function updateRepositoryLastIndexedLocal(
1146
+ db: KotaDatabase,
1147
+ repositoryId: string
1148
+ ): void {
1149
+ return updateRepositoryLastIndexedInternal(db, repositoryId);
1150
+ }
1151
+
1152
+ // Add alias for runIndexingWorkflowLocal
1153
+ export const runIndexingWorkflowLocal = runIndexingWorkflow;
1154
+
1155
+
1156
+ /**
1157
+ * Create default organization for a new user.
1158
+ *
1159
+ * @deprecated This function is not available in local-only mode.
1160
+ * Organizations are a cloud-only feature.
1161
+ */
1162
+ export async function createDefaultOrganization(
1163
+ _client: unknown,
1164
+ _userId: string,
1165
+ _userEmail?: string,
1166
+ ): Promise<string> {
1167
+ throw new Error('createDefaultOrganization() is not available in local-only mode - organizations are a cloud-only feature');
1168
+ }