@lon-ask/dockit 0.1.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/LICENSE +674 -0
- package/README.md +496 -0
- package/SKILL.md +154 -0
- package/apps/client/dist/assets/index-CqOXxsEZ.js +240 -0
- package/apps/client/dist/assets/index-DwvaANnI.css +1 -0
- package/apps/client/dist/index.html +13 -0
- package/apps/server/src/core/domain/entry.ts +22 -0
- package/apps/server/src/core/domain/errors.ts +27 -0
- package/apps/server/src/core/domain/knowledge-graph.ts +51 -0
- package/apps/server/src/core/domain/types.ts +168 -0
- package/apps/server/src/core/ports/IBuildRepository.ts +7 -0
- package/apps/server/src/core/ports/IDocumentNormalizer.ts +6 -0
- package/apps/server/src/core/ports/IDocumentStore.ts +4 -0
- package/apps/server/src/core/ports/IEntryReadModel.ts +9 -0
- package/apps/server/src/core/ports/IEntryRepository.ts +11 -0
- package/apps/server/src/core/ports/IKnowledgeGraph.ts +10 -0
- package/apps/server/src/core/ports/IPathResolver.ts +3 -0
- package/apps/server/src/core/ports/ISearchEngine.ts +9 -0
- package/apps/server/src/core/ports/ISourceProcessor.ts +7 -0
- package/apps/server/src/core/ports/ISourceRepository.ts +11 -0
- package/apps/server/src/core/usecases/BuildUseCase.ts +98 -0
- package/apps/server/src/core/usecases/ConfigUseCase.ts +64 -0
- package/apps/server/src/core/usecases/SearchUseCase.ts +16 -0
- package/apps/server/src/index.ts +98 -0
- package/apps/server/src/infrastructure/filesystem/FileSystemDocumentStore.ts +27 -0
- package/apps/server/src/infrastructure/graph/GraphSearchDecorator.ts +53 -0
- package/apps/server/src/infrastructure/graph/GraphifyKnowledgeGraph.ts +172 -0
- package/apps/server/src/infrastructure/graph/index.ts +2 -0
- package/apps/server/src/infrastructure/persistence/sqlite/SqliteBuildRepository.ts +34 -0
- package/apps/server/src/infrastructure/persistence/sqlite/SqliteEntryReadModel.ts +17 -0
- package/apps/server/src/infrastructure/persistence/sqlite/SqliteEntryRepository.ts +81 -0
- package/apps/server/src/infrastructure/persistence/sqlite/SqliteSourceRepository.ts +65 -0
- package/apps/server/src/infrastructure/persistence/sqlite/connection.ts +52 -0
- package/apps/server/src/infrastructure/search/SearchEngineFactory.ts +43 -0
- package/apps/server/src/infrastructure/search/json/JsonSearchEngine.ts +164 -0
- package/apps/server/src/infrastructure/search/vector/EmbeddingService.ts +23 -0
- package/apps/server/src/infrastructure/search/vector/VectorSearchEngine.ts +480 -0
- package/apps/server/src/infrastructure/source-processors/AntoraSourceProcessor.ts +14 -0
- package/apps/server/src/infrastructure/source-processors/AsciidocSourceProcessor.ts +12 -0
- package/apps/server/src/infrastructure/source-processors/DocumentNormalizer.ts +16 -0
- package/apps/server/src/infrastructure/source-processors/GithubMarkdownSourceProcessor.ts +12 -0
- package/apps/server/src/infrastructure/source-processors/MavenSourceProcessor.ts +12 -0
- package/apps/server/src/infrastructure/source-processors/PathResolver.ts +6 -0
- package/apps/server/src/infrastructure/source-processors/SourceCodeSourceProcessor.ts +260 -0
- package/apps/server/src/infrastructure/source-processors/ZipSourceProcessor.ts +12 -0
- package/apps/server/src/mcp-http.ts +102 -0
- package/apps/server/src/mcp.ts +432 -0
- package/apps/server/src/routes/build.ts +105 -0
- package/apps/server/src/routes/entries.ts +62 -0
- package/apps/server/src/routes/graph.ts +57 -0
- package/apps/server/src/routes/search.ts +28 -0
- package/apps/server/src/routes/sources.ts +105 -0
- package/apps/server/src/routes/viewer.ts +28 -0
- package/apps/server/src/services/antora.ts +238 -0
- package/apps/server/src/services/asciidoc.ts +221 -0
- package/apps/server/src/services/configLoader.ts +207 -0
- package/apps/server/src/services/githubMarkdown.ts +236 -0
- package/apps/server/src/services/maven.ts +178 -0
- package/apps/server/src/services/normalizer.ts +63 -0
- package/apps/server/src/services/paths.ts +5 -0
- package/apps/server/src/services/textExtractor.ts +49 -0
- package/apps/server/src/services/zip.ts +84 -0
- package/bin/commands/build.ts +85 -0
- package/bin/commands/dev.ts +36 -0
- package/bin/commands/get.ts +36 -0
- package/bin/commands/graph.ts +153 -0
- package/bin/commands/init.ts +170 -0
- package/bin/commands/list.ts +47 -0
- package/bin/commands/mcp.ts +32 -0
- package/bin/commands/search.ts +185 -0
- package/bin/commands/serve.ts +23 -0
- package/bin/commands/status.ts +46 -0
- package/bin/dockit-cli.ts +92 -0
- package/bin/dockit.js +17 -0
- package/bin/utils.ts +85 -0
- package/dockit.yaml +154 -0
- package/package.json +60 -0
- package/scripts/mcp-wrapper.sh +44 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getDb, initDb } from './infrastructure/persistence/sqlite/connection.js';
|
|
6
|
+
import { SqliteEntryRepository } from './infrastructure/persistence/sqlite/SqliteEntryRepository.js';
|
|
7
|
+
import { SqliteSourceRepository } from './infrastructure/persistence/sqlite/SqliteSourceRepository.js';
|
|
8
|
+
import { SqliteBuildRepository } from './infrastructure/persistence/sqlite/SqliteBuildRepository.js';
|
|
9
|
+
import { createSearchEngine } from './infrastructure/search/SearchEngineFactory.js';
|
|
10
|
+
import { SearchUseCase } from './core/usecases/SearchUseCase.js';
|
|
11
|
+
import { BuildUseCase } from './core/usecases/BuildUseCase.js';
|
|
12
|
+
import { ConfigUseCase } from './core/usecases/ConfigUseCase.js';
|
|
13
|
+
import { NotFoundError, ValidationError } from './core/domain/errors.js';
|
|
14
|
+
import { ZipSourceProcessor } from './infrastructure/source-processors/ZipSourceProcessor.js';
|
|
15
|
+
import { AntoraSourceProcessor } from './infrastructure/source-processors/AntoraSourceProcessor.js';
|
|
16
|
+
import { AsciidocSourceProcessor } from './infrastructure/source-processors/AsciidocSourceProcessor.js';
|
|
17
|
+
import { MavenSourceProcessor } from './infrastructure/source-processors/MavenSourceProcessor.js';
|
|
18
|
+
import { GithubMarkdownSourceProcessor } from './infrastructure/source-processors/GithubMarkdownSourceProcessor.js';
|
|
19
|
+
import { SourceCodeSourceProcessor } from './infrastructure/source-processors/SourceCodeSourceProcessor.js';
|
|
20
|
+
import { DocumentNormalizer } from './infrastructure/source-processors/DocumentNormalizer.js';
|
|
21
|
+
import { PathResolver } from './infrastructure/source-processors/PathResolver.js';
|
|
22
|
+
import { GraphifyKnowledgeGraph } from './infrastructure/graph/GraphifyKnowledgeGraph.js';
|
|
23
|
+
import { SqliteEntryReadModel } from './infrastructure/persistence/sqlite/SqliteEntryReadModel.js';
|
|
24
|
+
import { createEntryRoutes } from './routes/entries.js';
|
|
25
|
+
import { createSearchRoutes } from './routes/search.js';
|
|
26
|
+
import { createBuildRoutes } from './routes/build.js';
|
|
27
|
+
import { createSourceRoutes, createSourceFlatRoutes } from './routes/sources.js';
|
|
28
|
+
import viewerRoutes from './routes/viewer.js';
|
|
29
|
+
import { createGraphRoutes } from './routes/graph.js';
|
|
30
|
+
|
|
31
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const PORT = process.env.PORT || 3001;
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
const app = express();
|
|
36
|
+
app.use(cors());
|
|
37
|
+
app.use(express.json());
|
|
38
|
+
|
|
39
|
+
const db = getDb();
|
|
40
|
+
const entryRepo = new SqliteEntryRepository(db);
|
|
41
|
+
const sourceRepo = new SqliteSourceRepository(db);
|
|
42
|
+
const buildRepo = new SqliteBuildRepository(db);
|
|
43
|
+
const entryReadModel = new SqliteEntryReadModel(db);
|
|
44
|
+
const searchEngine = await createSearchEngine(entryReadModel);
|
|
45
|
+
|
|
46
|
+
const configUseCase = new ConfigUseCase(entryRepo, sourceRepo);
|
|
47
|
+
const searchUseCase = new SearchUseCase(searchEngine);
|
|
48
|
+
|
|
49
|
+
const processors = [
|
|
50
|
+
new ZipSourceProcessor(),
|
|
51
|
+
new AntoraSourceProcessor(),
|
|
52
|
+
new AsciidocSourceProcessor(),
|
|
53
|
+
new MavenSourceProcessor(),
|
|
54
|
+
new GithubMarkdownSourceProcessor(),
|
|
55
|
+
new SourceCodeSourceProcessor(),
|
|
56
|
+
];
|
|
57
|
+
const documentNormalizer = new DocumentNormalizer();
|
|
58
|
+
const pathResolver = new PathResolver();
|
|
59
|
+
|
|
60
|
+
const buildUseCase = new BuildUseCase(
|
|
61
|
+
buildRepo, sourceRepo, entryRepo, searchEngine,
|
|
62
|
+
processors, documentNormalizer, pathResolver,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
app.use('/api/entries', createEntryRoutes(configUseCase));
|
|
66
|
+
app.use('/api/entries/:entryId/sources', createSourceRoutes(configUseCase));
|
|
67
|
+
app.use('/api/sources', createSourceFlatRoutes(configUseCase));
|
|
68
|
+
app.use('/api', createBuildRoutes(buildUseCase, configUseCase, buildRepo));
|
|
69
|
+
app.use('/api', createSearchRoutes(searchUseCase));
|
|
70
|
+
app.use('/api', createGraphRoutes(buildRepo, configUseCase));
|
|
71
|
+
app.use('/api', viewerRoutes);
|
|
72
|
+
|
|
73
|
+
// Global error handler — catches domain errors thrown by use cases
|
|
74
|
+
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
75
|
+
if (err instanceof NotFoundError) {
|
|
76
|
+
res.status(404).json({ error: err.message, code: err.code });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (err instanceof ValidationError) {
|
|
80
|
+
const body: Record<string, unknown> = { error: err.message, code: err.code };
|
|
81
|
+
if (err.field) body.field = err.field;
|
|
82
|
+
res.status(400).json(body);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.error('Unhandled error:', err);
|
|
86
|
+
const message = err instanceof Error ? err.message : 'Internal server error';
|
|
87
|
+
res.status(500).json({ error: message });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
app.listen(PORT, () => {
|
|
91
|
+
console.log(`Dockit server running on http://localhost:${PORT}`);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main().catch((err) => {
|
|
96
|
+
console.error('Failed to start server:', err);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import type { IDocumentStore } from '../../core/ports/IDocumentStore.js';
|
|
4
|
+
import { DATA_ROOT } from '../../services/paths.js';
|
|
5
|
+
|
|
6
|
+
export class FileSystemDocumentStore implements IDocumentStore {
|
|
7
|
+
async getDocument(entryId: string, docPath: string): Promise<string> {
|
|
8
|
+
const resolved = path.resolve(DATA_ROOT, entryId, 'bundle', docPath);
|
|
9
|
+
const dataRoot = path.resolve(DATA_ROOT);
|
|
10
|
+
if (!resolved.startsWith(dataRoot)) {
|
|
11
|
+
throw new Error('Invalid document path');
|
|
12
|
+
}
|
|
13
|
+
if (!fs.existsSync(resolved)) {
|
|
14
|
+
throw new Error(`Document not found: ${docPath} for entry ${entryId}`);
|
|
15
|
+
}
|
|
16
|
+
return fs.readFileSync(resolved, 'utf-8');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async documentExists(entryId: string, docPath: string): Promise<boolean> {
|
|
20
|
+
const resolved = path.resolve(DATA_ROOT, entryId, 'bundle', docPath);
|
|
21
|
+
const dataRoot = path.resolve(DATA_ROOT);
|
|
22
|
+
if (!resolved.startsWith(dataRoot)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return fs.existsSync(resolved);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ISearchEngine } from '../../core/ports/ISearchEngine.js';
|
|
2
|
+
import type { IKnowledgeGraph } from '../../core/ports/IKnowledgeGraph.js';
|
|
3
|
+
import type { SearchResult, GlobalSearchResult, HtmlFile, SearchEngineType } from '../../core/domain/types.js';
|
|
4
|
+
|
|
5
|
+
export class GraphSearchDecorator implements ISearchEngine {
|
|
6
|
+
readonly capability: SearchEngineType;
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly engine: ISearchEngine,
|
|
10
|
+
private readonly knowledgeGraph: IKnowledgeGraph,
|
|
11
|
+
) {
|
|
12
|
+
this.capability = engine.capability;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async buildIndex(entryId: string, htmlFiles: HtmlFile[], log: (msg: string) => void): Promise<void> {
|
|
16
|
+
return this.engine.buildIndex(entryId, htmlFiles, log);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async search(entryId: string, query: string, limit = 10): Promise<SearchResult[]> {
|
|
20
|
+
const results = await this.engine.search(entryId, query, limit);
|
|
21
|
+
if (!this.knowledgeGraph.exists() || results.length === 0) return results;
|
|
22
|
+
|
|
23
|
+
const graphResult = this.knowledgeGraph.query(query);
|
|
24
|
+
if (graphResult.totalNodes === 0) return results;
|
|
25
|
+
|
|
26
|
+
const graphNames = new Set(graphResult.nodes.map((n) => n.name.toLowerCase()));
|
|
27
|
+
const scored = results.map((r) => {
|
|
28
|
+
let boost = 0;
|
|
29
|
+
const titleWords = r.title.toLowerCase().split(/\s+/);
|
|
30
|
+
const snippetWords = r.snippet.toLowerCase().split(/\s+/);
|
|
31
|
+
for (const word of titleWords) {
|
|
32
|
+
if (graphNames.has(word)) boost += 0.3;
|
|
33
|
+
}
|
|
34
|
+
for (const word of snippetWords) {
|
|
35
|
+
if (graphNames.has(word)) boost += 0.1;
|
|
36
|
+
}
|
|
37
|
+
return { ...r, _score: boost };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
scored.sort((a, b) => (b._score || 0) - (a._score || 0));
|
|
41
|
+
return scored.slice(0, limit);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async globalSearch(query: string, limit = 20): Promise<GlobalSearchResult[]> {
|
|
45
|
+
return this.engine.globalSearch(query, limit);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
declare module '../../core/domain/types.js' {
|
|
50
|
+
interface SearchResult {
|
|
51
|
+
_score?: number;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { IKnowledgeGraph } from '../../core/ports/IKnowledgeGraph.js';
|
|
4
|
+
import type {
|
|
5
|
+
GraphNode,
|
|
6
|
+
GraphEdge,
|
|
7
|
+
GraphMetadata,
|
|
8
|
+
GraphQueryResult,
|
|
9
|
+
GraphPathResult,
|
|
10
|
+
KnowledgeGraphData,
|
|
11
|
+
} from '../../core/domain/knowledge-graph.js';
|
|
12
|
+
|
|
13
|
+
export class GraphifyKnowledgeGraph implements IKnowledgeGraph {
|
|
14
|
+
private data: KnowledgeGraphData | null = null;
|
|
15
|
+
private adjacency: Map<string, Map<string, GraphEdge[]>> = new Map();
|
|
16
|
+
|
|
17
|
+
constructor(entryDir: string) {
|
|
18
|
+
const graphPath = path.join(entryDir, 'graph.json');
|
|
19
|
+
if (fs.existsSync(graphPath)) {
|
|
20
|
+
try {
|
|
21
|
+
const raw = JSON.parse(fs.readFileSync(graphPath, 'utf-8'));
|
|
22
|
+
this.data = this.normalizeGraphData(raw);
|
|
23
|
+
this.buildAdjacency();
|
|
24
|
+
} catch {
|
|
25
|
+
this.data = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private normalizeGraphData(raw: Record<string, unknown>): KnowledgeGraphData {
|
|
31
|
+
const rawNodes = (raw.nodes as Record<string, unknown>[]) || [];
|
|
32
|
+
const rawEdges = (raw.edges as Record<string, unknown>[]) || (raw.links as Record<string, unknown>[]) || [];
|
|
33
|
+
const meta = (raw.metadata as Record<string, unknown>) || {};
|
|
34
|
+
|
|
35
|
+
const nodes: GraphNode[] = rawNodes.map((n) => ({
|
|
36
|
+
id: n.id as string,
|
|
37
|
+
name: (n.name ?? n.label ?? n.norm_label ?? n.id) as string,
|
|
38
|
+
file: (n.file ?? n.source_file ?? '') as string,
|
|
39
|
+
type: (n.type ?? n.file_type ?? '') as string,
|
|
40
|
+
line: (n.line ?? 0) as number,
|
|
41
|
+
community: (n.community as number) ?? 0,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const edges: GraphEdge[] = rawEdges.map((e) => ({
|
|
45
|
+
source: (e.source as string) ?? (e.source as any)?.id ?? '',
|
|
46
|
+
target: (e.target as string) ?? (e.target as any)?.id ?? '',
|
|
47
|
+
type: (e.type as string) ?? 'depends',
|
|
48
|
+
id: (e.id as string) ?? `${e.source}-${e.target}`,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
nodes,
|
|
53
|
+
edges,
|
|
54
|
+
metadata: {
|
|
55
|
+
nodeCount: (meta.nodeCount as number) || nodes.length,
|
|
56
|
+
edgeCount: (meta.edgeCount as number) || edges.length,
|
|
57
|
+
communityCount: (meta.communityCount as number) || 0,
|
|
58
|
+
godNodes: (meta.godNodes as number) || 0,
|
|
59
|
+
languages: (meta.languages as string[]) || [],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private buildAdjacency(): void {
|
|
65
|
+
if (!this.data) return;
|
|
66
|
+
for (const node of this.data.nodes) {
|
|
67
|
+
this.adjacency.set(node.id, new Map());
|
|
68
|
+
}
|
|
69
|
+
for (const edge of this.data.edges) {
|
|
70
|
+
const srcMap = this.adjacency.get(edge.source);
|
|
71
|
+
if (srcMap) {
|
|
72
|
+
const existing = srcMap.get(edge.target) || [];
|
|
73
|
+
existing.push(edge);
|
|
74
|
+
srcMap.set(edge.target, existing);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
exists(): boolean {
|
|
80
|
+
return this.data !== null && this.data.nodes.length > 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
query(query: string, limit = 20): GraphQueryResult {
|
|
84
|
+
if (!this.data) return { nodes: [], edges: [], totalNodes: 0, totalEdges: 0 };
|
|
85
|
+
|
|
86
|
+
const q = query.toLowerCase();
|
|
87
|
+
const matchedNodes = this.data.nodes.filter(
|
|
88
|
+
(n) =>
|
|
89
|
+
(n.name || '').toLowerCase().includes(q) ||
|
|
90
|
+
(n.file || '').toLowerCase().includes(q) ||
|
|
91
|
+
(n.type || '').toLowerCase().includes(q),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const nodeIds = new Set(matchedNodes.map((n) => n.id));
|
|
95
|
+
const matchedEdges = this.data.edges.filter(
|
|
96
|
+
(e) => nodeIds.has(e.source) || nodeIds.has(e.target),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
nodes: matchedNodes.slice(0, limit),
|
|
101
|
+
edges: matchedEdges.slice(0, limit * 2),
|
|
102
|
+
totalNodes: matchedNodes.length,
|
|
103
|
+
totalEdges: matchedEdges.length,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
findPath(from: string, to: string): GraphPathResult {
|
|
108
|
+
if (!this.data) return { found: false, nodes: [], edges: [], length: 0 };
|
|
109
|
+
|
|
110
|
+
const startNode = this.findNodeByName(from);
|
|
111
|
+
const endNode = this.findNodeByName(to);
|
|
112
|
+
if (!startNode || !endNode) return { found: false, nodes: [], edges: [], length: 0 };
|
|
113
|
+
|
|
114
|
+
const visited = new Set<string>();
|
|
115
|
+
const queue: Array<{ nodeId: string; path: string[] }> = [{ nodeId: startNode.id, path: [startNode.id] }];
|
|
116
|
+
visited.add(startNode.id);
|
|
117
|
+
|
|
118
|
+
while (queue.length > 0) {
|
|
119
|
+
const { nodeId, path } = queue.shift()!;
|
|
120
|
+
if (nodeId === endNode.id) {
|
|
121
|
+
const pathNodes = path.map((id) => this.data!.nodes.find((n) => n.id === id)!).filter(Boolean);
|
|
122
|
+
const pathEdges: GraphEdge[] = [];
|
|
123
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
124
|
+
const edges = this.adjacency.get(path[i])?.get(path[i + 1]);
|
|
125
|
+
if (edges) pathEdges.push(edges[0]);
|
|
126
|
+
}
|
|
127
|
+
return { found: true, nodes: pathNodes, edges: pathEdges, length: path.length - 1 };
|
|
128
|
+
}
|
|
129
|
+
const neighbors = this.adjacency.get(nodeId);
|
|
130
|
+
if (neighbors) {
|
|
131
|
+
for (const [neighborId] of neighbors) {
|
|
132
|
+
if (!visited.has(neighborId)) {
|
|
133
|
+
visited.add(neighborId);
|
|
134
|
+
queue.push({ nodeId: neighborId, path: [...path, neighborId] });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { found: false, nodes: [], edges: [], length: 0 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
findGodNodes(limit = 10): GraphNode[] {
|
|
144
|
+
if (!this.data) return [];
|
|
145
|
+
const degreeMap = new Map<string, number>();
|
|
146
|
+
for (const edge of this.data.edges) {
|
|
147
|
+
degreeMap.set(edge.source, (degreeMap.get(edge.source) || 0) + 1);
|
|
148
|
+
degreeMap.set(edge.target, (degreeMap.get(edge.target) || 0) + 1);
|
|
149
|
+
}
|
|
150
|
+
return this.data.nodes
|
|
151
|
+
.map((n) => ({ ...n, degree: degreeMap.get(n.id) || 0 }))
|
|
152
|
+
.sort((a, b) => (b.degree || 0) - (a.degree || 0))
|
|
153
|
+
.slice(0, limit);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getMetadata(): GraphMetadata {
|
|
157
|
+
if (!this.data) return { nodeCount: 0, edgeCount: 0, communityCount: 0, godNodes: 0, languages: [] };
|
|
158
|
+
return { ...this.data.metadata };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getNode(id: string): GraphNode | undefined {
|
|
162
|
+
return this.data?.nodes.find((n) => n.id === id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private findNodeByName(name: string): GraphNode | undefined {
|
|
166
|
+
if (!this.data) return undefined;
|
|
167
|
+
return (
|
|
168
|
+
this.data.nodes.find((n) => n.id === name || n.name === name) ||
|
|
169
|
+
this.data.nodes.find((n) => n.name.toLowerCase().includes(name.toLowerCase()))
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { Build } from '../../../core/domain/types.js';
|
|
3
|
+
import type { IBuildRepository } from '../../../core/ports/IBuildRepository.js';
|
|
4
|
+
import { getDb } from './connection.js';
|
|
5
|
+
|
|
6
|
+
export class SqliteBuildRepository implements IBuildRepository {
|
|
7
|
+
private db: Database.Database;
|
|
8
|
+
|
|
9
|
+
constructor(db?: Database.Database) {
|
|
10
|
+
this.db = db ?? getDb();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async create(entryId: string): Promise<Build> {
|
|
14
|
+
const id = crypto.randomUUID();
|
|
15
|
+
const now = new Date().toISOString();
|
|
16
|
+
this.db.prepare(
|
|
17
|
+
'INSERT INTO builds (id, entry_id, status, started_at) VALUES (?, ?, ?, ?)'
|
|
18
|
+
).run(id, entryId, 'building', now);
|
|
19
|
+
return this.findLatest(entryId) as Promise<Build>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async update(buildId: string, status: Build['status'], log: string): Promise<void> {
|
|
23
|
+
const now = new Date().toISOString();
|
|
24
|
+
this.db.prepare(
|
|
25
|
+
'UPDATE builds SET status = ?, log = ?, finished_at = ? WHERE id = ?'
|
|
26
|
+
).run(status, log, now, buildId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async findLatest(entryId: string): Promise<Build | undefined> {
|
|
30
|
+
return this.db.prepare(
|
|
31
|
+
'SELECT * FROM builds WHERE entry_id = ? ORDER BY started_at DESC LIMIT 1'
|
|
32
|
+
).get(entryId) as Build | undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { IEntryReadModel, EntryReadModelItem } from '../../../core/ports/IEntryReadModel.js';
|
|
3
|
+
import { getDb } from './connection.js';
|
|
4
|
+
|
|
5
|
+
export class SqliteEntryReadModel implements IEntryReadModel {
|
|
6
|
+
private db: Database.Database;
|
|
7
|
+
|
|
8
|
+
constructor(db?: Database.Database) {
|
|
9
|
+
this.db = db ?? getDb();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async listReadyEntries(): Promise<EntryReadModelItem[]> {
|
|
13
|
+
return this.db.prepare(
|
|
14
|
+
"SELECT id, name, version FROM entries WHERE status = 'ready' ORDER BY name"
|
|
15
|
+
).all() as EntryReadModelItem[];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { Entry, CreateEntryInput, UpdateEntryInput } from '../../../core/domain/types.js';
|
|
3
|
+
import type { IEntryRepository } from '../../../core/ports/IEntryRepository.js';
|
|
4
|
+
import { DomainError } from '../../../core/domain/errors.js';
|
|
5
|
+
import { getDb } from './connection.js';
|
|
6
|
+
|
|
7
|
+
export class SqliteEntryRepository implements IEntryRepository {
|
|
8
|
+
private db: Database.Database;
|
|
9
|
+
|
|
10
|
+
constructor(db?: Database.Database) {
|
|
11
|
+
this.db = db ?? getDb();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async findAll(): Promise<(Entry & { source_count: number })[]> {
|
|
15
|
+
return this.db.prepare(`
|
|
16
|
+
SELECT e.*, COUNT(s.id) as source_count
|
|
17
|
+
FROM entries e
|
|
18
|
+
LEFT JOIN sources s ON s.entry_id = e.id
|
|
19
|
+
GROUP BY e.id
|
|
20
|
+
ORDER BY e.created_at DESC
|
|
21
|
+
`).all() as (Entry & { source_count: number })[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async findById(id: string): Promise<Entry | undefined> {
|
|
25
|
+
return this.db.prepare('SELECT * FROM entries WHERE id = ?').get(id) as Entry | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async save(entry: Entry): Promise<void> {
|
|
29
|
+
this.db.prepare(
|
|
30
|
+
'INSERT OR REPLACE INTO entries (id, name, version, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
31
|
+
).run(entry.id, entry.name, entry.version, entry.description, entry.status, entry.created_at, entry.updated_at);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async update(id: string, input: UpdateEntryInput): Promise<void> {
|
|
35
|
+
const existing = await this.findById(id);
|
|
36
|
+
if (!existing) return;
|
|
37
|
+
const now = new Date().toISOString();
|
|
38
|
+
this.db.prepare(
|
|
39
|
+
'UPDATE entries SET name = ?, version = ?, description = ?, updated_at = ? WHERE id = ?'
|
|
40
|
+
).run(
|
|
41
|
+
input.name ?? existing.name,
|
|
42
|
+
input.version ?? existing.version,
|
|
43
|
+
input.description ?? existing.description,
|
|
44
|
+
now,
|
|
45
|
+
id,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async delete(id: string): Promise<void> {
|
|
50
|
+
this.db.prepare('DELETE FROM entries WHERE id = ?').run(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async updateStatus(id: string, status: Entry['status']): Promise<void> {
|
|
54
|
+
this.db.prepare('UPDATE entries SET status = ? WHERE id = ?').run(status, id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async create(input: CreateEntryInput): Promise<Entry> {
|
|
58
|
+
// Resolve unique ID:
|
|
59
|
+
// - Use input.id if provided and available
|
|
60
|
+
// - Fall back to random UUID if no id given
|
|
61
|
+
// - If the provided id already exists, append -2, -3, etc.
|
|
62
|
+
let id = input.id ?? crypto.randomUUID();
|
|
63
|
+
if (input.id) {
|
|
64
|
+
let suffix = 2;
|
|
65
|
+
let candidateId = id;
|
|
66
|
+
while (this.db.prepare('SELECT 1 FROM entries WHERE id = ?').get(candidateId)) {
|
|
67
|
+
candidateId = `${id}-${suffix}`;
|
|
68
|
+
suffix++;
|
|
69
|
+
}
|
|
70
|
+
id = candidateId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
this.db.prepare(
|
|
75
|
+
'INSERT INTO entries (id, name, version, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
|
76
|
+
).run(id, input.name, input.version, input.description ?? '', now, now);
|
|
77
|
+
const entry = await this.findById(id);
|
|
78
|
+
if (!entry) throw new DomainError('Failed to create entry', 'PERSISTENCE_ERROR');
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { Source, CreateSourceInput, UpdateSourceInput } from '../../../core/domain/types.js';
|
|
3
|
+
import type { ISourceRepository } from '../../../core/ports/ISourceRepository.js';
|
|
4
|
+
import { getDb } from './connection.js';
|
|
5
|
+
|
|
6
|
+
function parseSource(row: Record<string, unknown>): Source {
|
|
7
|
+
return {
|
|
8
|
+
...row,
|
|
9
|
+
config: JSON.parse(row.config as string),
|
|
10
|
+
} as Source;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SqliteSourceRepository implements ISourceRepository {
|
|
14
|
+
private db: Database.Database;
|
|
15
|
+
|
|
16
|
+
constructor(db?: Database.Database) {
|
|
17
|
+
this.db = db ?? getDb();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async findByEntryId(entryId: string): Promise<Source[]> {
|
|
21
|
+
const rows = this.db.prepare(
|
|
22
|
+
'SELECT * FROM sources WHERE entry_id = ? ORDER BY created_at DESC'
|
|
23
|
+
).all(entryId) as (Omit<Source, 'config'> & { config: string })[];
|
|
24
|
+
return rows.map(parseSource);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async findById(id: string): Promise<Source | undefined> {
|
|
28
|
+
const row = this.db.prepare('SELECT * FROM sources WHERE id = ?').get(id) as (Omit<Source, 'config'> & { config: string }) | undefined;
|
|
29
|
+
return row ? parseSource(row) : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async save(source: Source): Promise<void> {
|
|
33
|
+
this.db.prepare(
|
|
34
|
+
'INSERT OR REPLACE INTO sources (id, entry_id, type, label, config, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
35
|
+
).run(source.id, source.entry_id, source.type, source.label, JSON.stringify(source.config), source.status, source.created_at);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async create(entryId: string, input: CreateSourceInput): Promise<Source> {
|
|
39
|
+
const id = crypto.randomUUID();
|
|
40
|
+
const now = new Date().toISOString();
|
|
41
|
+
this.db.prepare(
|
|
42
|
+
'INSERT INTO sources (id, entry_id, type, label, config, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
|
43
|
+
).run(id, entryId, input.type, input.label, JSON.stringify(input.config), now);
|
|
44
|
+
const source = await this.findById(id);
|
|
45
|
+
if (!source) throw new Error('Failed to create source');
|
|
46
|
+
return source;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async update(id: string, input: UpdateSourceInput): Promise<void> {
|
|
50
|
+
const existing = await this.findById(id);
|
|
51
|
+
if (!existing) return;
|
|
52
|
+
const newConfig = input.config ?? existing.config;
|
|
53
|
+
this.db.prepare(
|
|
54
|
+
'UPDATE sources SET label = ?, config = ? WHERE id = ?'
|
|
55
|
+
).run(input.label ?? existing.label, JSON.stringify(newConfig), id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async delete(id: string): Promise<void> {
|
|
59
|
+
this.db.prepare('DELETE FROM sources WHERE id = ?').run(id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async updateStatus(id: string, status: Source['status']): Promise<void> {
|
|
63
|
+
this.db.prepare('UPDATE sources SET status = ? WHERE id = ?').run(status, id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { DATA_ROOT } from '../../../services/paths.js';
|
|
5
|
+
|
|
6
|
+
const DB_PATH = path.join(DATA_ROOT, 'dockit.db');
|
|
7
|
+
let db: Database.Database | null = null;
|
|
8
|
+
|
|
9
|
+
export function getDb(): Database.Database {
|
|
10
|
+
if (!db) {
|
|
11
|
+
const dir = path.dirname(DB_PATH);
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
db = new Database(DB_PATH);
|
|
14
|
+
db.pragma('journal_mode = WAL');
|
|
15
|
+
db.pragma('foreign_keys = ON');
|
|
16
|
+
initDb(db);
|
|
17
|
+
}
|
|
18
|
+
return db;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function initDb(database: Database.Database): void {
|
|
22
|
+
database.exec(`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
24
|
+
id TEXT PRIMARY KEY,
|
|
25
|
+
name TEXT NOT NULL,
|
|
26
|
+
version TEXT NOT NULL,
|
|
27
|
+
description TEXT DEFAULT '',
|
|
28
|
+
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'building', 'ready', 'error')),
|
|
29
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
30
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
36
|
+
type TEXT NOT NULL CHECK(type IN ('zip', 'antora', 'maven', 'asciidoc', 'github-markdown', 'source-code')),
|
|
37
|
+
label TEXT NOT NULL,
|
|
38
|
+
config TEXT NOT NULL DEFAULT '{}',
|
|
39
|
+
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'building', 'ready', 'error')),
|
|
40
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS builds (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
46
|
+
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'building', 'ready', 'error')),
|
|
47
|
+
log TEXT DEFAULT '',
|
|
48
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
49
|
+
finished_at TEXT
|
|
50
|
+
);
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ISearchEngine } from '../../core/ports/ISearchEngine.js';
|
|
2
|
+
import type { IEntryReadModel } from '../../core/ports/IEntryReadModel.js';
|
|
3
|
+
import type { IKnowledgeGraph } from '../../core/ports/IKnowledgeGraph.js';
|
|
4
|
+
import type { SearchEngineType } from '../../core/domain/types.js';
|
|
5
|
+
import { JsonSearchEngine } from './json/JsonSearchEngine.js';
|
|
6
|
+
import { GraphSearchDecorator } from '../graph/GraphSearchDecorator.js';
|
|
7
|
+
|
|
8
|
+
export async function createSearchEngine(
|
|
9
|
+
entryReadModel: IEntryReadModel,
|
|
10
|
+
engine: SearchEngineType = 'vector',
|
|
11
|
+
knowledgeGraph?: IKnowledgeGraph,
|
|
12
|
+
): Promise<ISearchEngine> {
|
|
13
|
+
let engine_ = engine;
|
|
14
|
+
switch (engine_) {
|
|
15
|
+
case 'vector':
|
|
16
|
+
return wrapWithGraph(await createVectorSearchEngine(entryReadModel), knowledgeGraph);
|
|
17
|
+
case 'json':
|
|
18
|
+
default:
|
|
19
|
+
return wrapWithGraph(new JsonSearchEngine(entryReadModel), knowledgeGraph);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function wrapWithGraph(engine: ISearchEngine, knowledgeGraph?: IKnowledgeGraph): ISearchEngine {
|
|
24
|
+
if (knowledgeGraph && knowledgeGraph.exists()) {
|
|
25
|
+
return new GraphSearchDecorator(engine, knowledgeGraph);
|
|
26
|
+
}
|
|
27
|
+
return engine;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function createVectorSearchEngine(entryReadModel: IEntryReadModel): Promise<ISearchEngine> {
|
|
31
|
+
try {
|
|
32
|
+
await import('@lancedb/lancedb');
|
|
33
|
+
const { VectorSearchEngine } = await import('./vector/VectorSearchEngine.js');
|
|
34
|
+
return new VectorSearchEngine(entryReadModel);
|
|
35
|
+
} catch {
|
|
36
|
+
console.error(
|
|
37
|
+
'[dockit] Vector search engine is not available. ' +
|
|
38
|
+
'Install @lancedb/lancedb and @dockit/embeddings, then set search.engine to "vector". ' +
|
|
39
|
+
'Falling back to JSON search.'
|
|
40
|
+
);
|
|
41
|
+
return new JsonSearchEngine(entryReadModel);
|
|
42
|
+
}
|
|
43
|
+
}
|