Skip to content

Commit 8bf4629

Browse files
committed
feat(store): Phase 6 — ProjectScopedStore, edges, integration + audit fixes
ProjectScopedStore wraps all sub-stores with fixed projectId, cached via store.project(id). Full edge CRUD at both scoped and workspace levels. GraphName extended with epics/tags. Audit fixes: recursive ensureDirectory chain, minScore in all FilesStore search modes, orphaned tags cleanup in setTags, removed template literal SQL interpolation, simplified isExported check.
1 parent 7a47193 commit 8bf4629

12 files changed

Lines changed: 719 additions & 24 deletions

File tree

src/store/sqlite/lib/entity-helpers.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,25 @@ export class EntityHelpers {
1212
// --- Tags ---
1313

1414
setTags(graph: string, entityId: number, tags: string[]): void {
15-
this.db.prepare(`DELETE FROM edges WHERE project_id = ? AND to_graph = ? AND to_id = ? AND from_graph = 'tags'`)
15+
// Collect old tag ids before deleting edges
16+
const oldTagIds = this.db.prepare(
17+
`SELECT from_id FROM edges WHERE project_id = ? AND to_graph = ? AND to_id = ? AND from_graph = 'tags' AND kind = 'tagged'`
18+
).all(this.projectId, graph, entityId) as Array<{ from_id: bigint }>;
19+
20+
this.db.prepare(`DELETE FROM edges WHERE project_id = ? AND to_graph = ? AND to_id = ? AND from_graph = 'tags' AND kind = 'tagged'`)
1621
.run(this.projectId, graph, entityId);
22+
23+
// Clean up orphaned tags (tags with no remaining edges)
24+
for (const old of oldTagIds) {
25+
const edgeCount = num((this.db.prepare(
26+
`SELECT COUNT(*) AS c FROM edges WHERE project_id = ? AND from_graph = 'tags' AND from_id = ? AND kind = 'tagged'`
27+
).get(this.projectId, num(old.from_id)) as { c: bigint }).c);
28+
if (edgeCount === 0) {
29+
this.db.prepare('DELETE FROM tags WHERE id = ? AND project_id = ?').run(num(old.from_id), this.projectId);
30+
}
31+
}
32+
33+
// Insert new tags
1734
for (const tag of tags) {
1835
this.db.prepare('INSERT OR IGNORE INTO tags (project_id, name) VALUES (?, ?)').run(this.projectId, tag);
1936
const row = this.db.prepare('SELECT id FROM tags WHERE project_id = ? AND name = ?').get(this.projectId, tag) as { id: bigint };

src/store/sqlite/store.ts

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import type {
1212
import { openDatabase } from './lib/db';
1313
import { runMigrations } from './lib/migrate';
1414
import { MetaHelper } from './lib/meta';
15+
import { num } from './lib/bigint';
1516
import { v001 } from './migrations/v001';
1617
import { SqliteTeamStore } from './stores/team';
1718
import { SqliteProjectsStore } from './stores/projects';
19+
import { SqliteProjectScopedStore } from './stores/project-scoped';
1820

1921
const ALL_MIGRATIONS = [v001];
2022

@@ -61,30 +63,66 @@ export class SqliteStore implements Store {
6163

6264
// --- Project scoping ---
6365

64-
project(_projectId: number): ProjectScopedStore {
65-
throw new Error('Not implemented yet (Phase 6)');
66+
project(projectId: number): ProjectScopedStore {
67+
this.requireDb();
68+
let scoped = this.scopedCache.get(projectId);
69+
if (!scoped) {
70+
scoped = new SqliteProjectScopedStore(this.db!, projectId);
71+
this.scopedCache.set(projectId, scoped);
72+
}
73+
return scoped;
6674
}
6775

6876
// --- Edges ---
6977

70-
createEdge(_projectId: number, _edge: Edge): void {
71-
throw new Error('Not implemented yet (Phase 6)');
78+
createEdge(projectId: number, edge: Edge): void {
79+
this.requireDb();
80+
this.db!.prepare(`
81+
INSERT OR IGNORE INTO edges (project_id, from_graph, from_id, to_graph, to_id, kind)
82+
VALUES (?, ?, ?, ?, ?, ?)
83+
`).run(projectId, edge.fromGraph, edge.fromId, edge.toGraph, edge.toId, edge.kind);
7284
}
7385

74-
deleteEdge(_projectId: number, _edge: Edge): void {
75-
throw new Error('Not implemented yet (Phase 6)');
86+
deleteEdge(projectId: number, edge: Edge): void {
87+
this.requireDb();
88+
this.db!.prepare(`
89+
DELETE FROM edges
90+
WHERE project_id = ? AND from_graph = ? AND from_id = ? AND to_graph = ? AND to_id = ? AND kind = ?
91+
`).run(projectId, edge.fromGraph, edge.fromId, edge.toGraph, edge.toId, edge.kind);
7692
}
7793

78-
listEdges(_filter: EdgeFilter & { projectId?: number }): Edge[] {
79-
throw new Error('Not implemented yet (Phase 6)');
94+
listEdges(filter: EdgeFilter & { projectId?: number }): Edge[] {
95+
this.requireDb();
96+
const conditions: string[] = [];
97+
const params: unknown[] = [];
98+
99+
if (filter.projectId !== undefined) { conditions.push('project_id = ?'); params.push(filter.projectId); }
100+
if (filter.fromGraph) { conditions.push('from_graph = ?'); params.push(filter.fromGraph); }
101+
if (filter.fromId !== undefined) { conditions.push('from_id = ?'); params.push(filter.fromId); }
102+
if (filter.toGraph) { conditions.push('to_graph = ?'); params.push(filter.toGraph); }
103+
if (filter.toId !== undefined) { conditions.push('to_id = ?'); params.push(filter.toId); }
104+
if (filter.kind) { conditions.push('kind = ?'); params.push(filter.kind); }
105+
106+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
107+
const rows = this.db!.prepare(
108+
`SELECT from_graph, from_id, to_graph, to_id, kind FROM edges ${where}`
109+
).all(...params) as Array<Record<string, unknown>>;
110+
111+
return rows.map(r => ({
112+
fromGraph: r.from_graph as GraphName,
113+
fromId: num(r.from_id as bigint),
114+
toGraph: r.to_graph as GraphName,
115+
toId: num(r.to_id as bigint),
116+
kind: r.kind as string,
117+
}));
80118
}
81119

82-
findIncomingEdges(_targetGraph: GraphName, _targetId: number, _projectId?: number): Edge[] {
83-
throw new Error('Not implemented yet (Phase 6)');
120+
findIncomingEdges(targetGraph: GraphName, targetId: number, projectId?: number): Edge[] {
121+
return this.listEdges({ toGraph: targetGraph, toId: targetId, projectId });
84122
}
85123

86-
findOutgoingEdges(_fromGraph: GraphName, _fromId: number, _projectId?: number): Edge[] {
87-
throw new Error('Not implemented yet (Phase 6)');
124+
findOutgoingEdges(fromGraph: GraphName, fromId: number, projectId?: number): Edge[] {
125+
return this.listEdges({ fromGraph, fromId, projectId });
88126
}
89127

90128
// --- Transaction ---
@@ -113,7 +151,7 @@ export class SqliteStore implements Store {
113151

114152
// --- Internal ---
115153

116-
/** Get the raw database handle (for sub-stores) */
154+
/** Get the raw database handle (for sub-stores and tests) */
117155
getDb(): Database.Database {
118156
this.requireDb();
119157
return this.db!;

src/store/sqlite/stores/code.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class SqliteCodeStore implements CodeStore {
4141
body: row.body as string,
4242
startLine: num(row.start_line as bigint),
4343
endLine: num(row.end_line as bigint),
44-
isExported: (row.is_exported as number | bigint) === 1 || (row.is_exported as number | bigint) === BigInt(1),
44+
isExported: row.is_exported === BigInt(1),
4545
mtime: num(row.mtime as bigint),
4646
};
4747
}

src/store/sqlite/stores/files.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,22 @@ export class SqliteFilesStore implements FilesStore {
4040
}
4141

4242
private ensureDirectory(dirPath: string): number {
43+
if (!dirPath || dirPath === '.' || dirPath === '') return -1;
44+
4345
// Check if directory already exists
4446
const existing = this.db.prepare(
4547
"SELECT id FROM files WHERE project_id = ? AND file_path = ? AND kind = 'directory'"
4648
).get(this.projectId, dirPath) as { id: bigint } | undefined;
4749

4850
if (existing) return num(existing.id);
4951

50-
const dirName = path.basename(dirPath) || dirPath;
52+
// Recursively ensure parent exists first
5153
const parentDir = path.dirname(dirPath);
54+
if (parentDir !== dirPath && parentDir !== '.' && parentDir !== '') {
55+
this.ensureDirectory(parentDir);
56+
}
57+
58+
const dirName = path.basename(dirPath) || dirPath;
5259

5360
const result = this.db.prepare(`
5461
INSERT INTO files (project_id, kind, file_path, file_name, directory, extension, size, file_count, mtime)
@@ -229,14 +236,18 @@ export class SqliteFilesStore implements FilesStore {
229236
const mode = query.searchMode ?? 'hybrid';
230237
const maxResults = query.maxResults ?? 20;
231238

239+
const minScore = query.minScore ?? 0;
240+
232241
if (mode === 'keyword' && query.text) {
233242
// Fallback: LIKE-based search on file_path
234243
const rows = this.db.prepare(`
235244
SELECT id FROM files WHERE project_id = ? AND file_path LIKE ? AND kind = 'file'
236245
ORDER BY file_path ASC LIMIT ?
237246
`).all(this.projectId, `%${query.text}%`, maxResults) as Array<{ id: bigint }>;
238247

239-
return rows.map((r, i) => ({ id: num(r.id), score: 1 / (60 + i + 1) }));
248+
return rows
249+
.map((r, i) => ({ id: num(r.id), score: 1 / (60 + i + 1) }))
250+
.filter(r => r.score >= minScore);
240251
}
241252

242253
if (mode === 'vector' && query.embedding) {
@@ -251,10 +262,9 @@ export class SqliteFilesStore implements FilesStore {
251262
WHERE v.embedding MATCH ? AND v.k = ?
252263
`).all(this.projectId, embeddingBuf, topK * 3) as Array<{ id: bigint; distance: number }>;
253264

254-
return rows.slice(0, maxResults).map((r, i) => ({
255-
id: num(r.id),
256-
score: 1 / (60 + i + 1),
257-
}));
265+
return rows.slice(0, maxResults)
266+
.map((r, i) => ({ id: num(r.id), score: 1 / (60 + i + 1) }))
267+
.filter(r => r.score >= minScore);
258268
}
259269

260270
// Hybrid: combine LIKE + vector
@@ -288,7 +298,6 @@ export class SqliteFilesStore implements FilesStore {
288298
for (const r of likeResults) scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + r.rn));
289299
for (const r of vecResults) scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + r.rn));
290300

291-
const minScore = query.minScore ?? 0;
292301
return [...scores.entries()]
293302
.map(([id, score]) => ({ id, score }))
294303
.filter(r => r.score >= minScore)

src/store/sqlite/stores/knowledge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export class SqliteKnowledgeStore implements KnowledgeStore {
148148
if (tag) {
149149
conditions.push(`EXISTS (
150150
SELECT 1 FROM edges e JOIN tags t ON t.id = e.from_id AND t.project_id = e.project_id
151-
WHERE e.project_id = k.project_id AND e.to_graph = '${GRAPH}' AND e.to_id = k.id
151+
WHERE e.project_id = k.project_id AND e.to_graph = 'knowledge' AND e.to_id = k.id
152152
AND e.from_graph = 'tags' AND e.kind = 'tagged' AND t.name = ?
153153
)`);
154154
params.push(tag);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import Database from 'better-sqlite3';
2+
import type {
3+
ProjectScopedStore,
4+
CodeStore,
5+
DocsStore,
6+
FilesStore,
7+
KnowledgeStore,
8+
TasksStore,
9+
SkillsStore,
10+
AttachmentsStore,
11+
Edge,
12+
EdgeFilter,
13+
GraphName,
14+
} from '../../types';
15+
import { num } from '../lib/bigint';
16+
import { SqliteCodeStore } from './code';
17+
import { SqliteDocsStore } from './docs';
18+
import { SqliteFilesStore } from './files';
19+
import { SqliteKnowledgeStore } from './knowledge';
20+
import { SqliteTasksStore } from './tasks';
21+
import { SqliteSkillsStore } from './skills';
22+
import { SqliteAttachmentsStore } from './attachments';
23+
24+
export class SqliteProjectScopedStore implements ProjectScopedStore {
25+
readonly code: CodeStore;
26+
readonly docs: DocsStore;
27+
readonly files: FilesStore;
28+
readonly knowledge: KnowledgeStore;
29+
readonly tasks: TasksStore;
30+
readonly skills: SkillsStore;
31+
readonly attachments: AttachmentsStore;
32+
33+
constructor(private db: Database.Database, readonly projectId: number) {
34+
this.code = new SqliteCodeStore(db, projectId);
35+
this.docs = new SqliteDocsStore(db, projectId);
36+
this.files = new SqliteFilesStore(db, projectId);
37+
this.knowledge = new SqliteKnowledgeStore(db, projectId);
38+
this.tasks = new SqliteTasksStore(db, projectId);
39+
this.skills = new SqliteSkillsStore(db, projectId);
40+
this.attachments = new SqliteAttachmentsStore(db, projectId);
41+
}
42+
43+
// =========================================================================
44+
// Edges
45+
// =========================================================================
46+
47+
createEdge(edge: Edge): void {
48+
this.db.prepare(`
49+
INSERT OR IGNORE INTO edges (project_id, from_graph, from_id, to_graph, to_id, kind)
50+
VALUES (?, ?, ?, ?, ?, ?)
51+
`).run(this.projectId, edge.fromGraph, edge.fromId, edge.toGraph, edge.toId, edge.kind);
52+
}
53+
54+
deleteEdge(edge: Edge): void {
55+
this.db.prepare(`
56+
DELETE FROM edges
57+
WHERE project_id = ? AND from_graph = ? AND from_id = ? AND to_graph = ? AND to_id = ? AND kind = ?
58+
`).run(this.projectId, edge.fromGraph, edge.fromId, edge.toGraph, edge.toId, edge.kind);
59+
}
60+
61+
listEdges(filter: EdgeFilter): Edge[] {
62+
const conditions: string[] = ['project_id = ?'];
63+
const params: unknown[] = [this.projectId];
64+
65+
if (filter.fromGraph) { conditions.push('from_graph = ?'); params.push(filter.fromGraph); }
66+
if (filter.fromId !== undefined) { conditions.push('from_id = ?'); params.push(filter.fromId); }
67+
if (filter.toGraph) { conditions.push('to_graph = ?'); params.push(filter.toGraph); }
68+
if (filter.toId !== undefined) { conditions.push('to_id = ?'); params.push(filter.toId); }
69+
if (filter.kind) { conditions.push('kind = ?'); params.push(filter.kind); }
70+
71+
const rows = this.db.prepare(
72+
`SELECT from_graph, from_id, to_graph, to_id, kind FROM edges WHERE ${conditions.join(' AND ')}`
73+
).all(...params) as Array<Record<string, unknown>>;
74+
75+
return rows.map(r => ({
76+
fromGraph: r.from_graph as GraphName,
77+
fromId: num(r.from_id as bigint),
78+
toGraph: r.to_graph as GraphName,
79+
toId: num(r.to_id as bigint),
80+
kind: r.kind as string,
81+
}));
82+
}
83+
84+
findIncomingEdges(targetGraph: GraphName, targetId: number): Edge[] {
85+
return this.listEdges({ toGraph: targetGraph, toId: targetId });
86+
}
87+
88+
findOutgoingEdges(fromGraph: GraphName, fromId: number): Edge[] {
89+
return this.listEdges({ fromGraph, fromId });
90+
}
91+
}

src/store/types/common.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** Shared types used across all store modules. */
22

3-
export type GraphName = 'code' | 'docs' | 'files' | 'knowledge' | 'tasks' | 'skills';
3+
export type GraphName = 'code' | 'docs' | 'files' | 'knowledge' | 'tasks' | 'skills' | 'epics' | 'tags';
44

55
// ---------------------------------------------------------------------------
66
// Search

src/tests/store/contract/docs.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,20 @@ describe('DocsStore contract', () => {
232232
}
233233
});
234234

235+
it('searchSnippets filters by language', () => {
236+
docs.updateFile('docs/guide.md', makeChunks(), 1000, makeEmbeddings());
237+
238+
const yaml = docs.searchSnippets({ text: 'configure', searchMode: 'keyword' }, 'yaml');
239+
const python = docs.searchSnippets({ text: 'configure', searchMode: 'keyword' }, 'python');
240+
241+
// yaml snippet matches, python does not
242+
for (const r of yaml) {
243+
const node = docs.getNode(r.id);
244+
expect(node!.language).toBe('yaml');
245+
}
246+
expect(python.length).toBe(0);
247+
});
248+
235249
// --- Meta ---
236250

237251
it('meta is prefixed', () => {

src/tests/store/contract/files.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ describe('FilesStore contract', () => {
4242
expect(dir!.fileName).toBe('utils');
4343
});
4444

45+
it('creates full directory chain for deeply nested files', () => {
46+
files.updateFile('a/b/c/file.ts', 100, 1000, seedEmbedding(1));
47+
48+
expect(files.getFileInfo('a')).not.toBeNull();
49+
expect(files.getFileInfo('a')!.kind).toBe('directory');
50+
expect(files.getFileInfo('a/b')).not.toBeNull();
51+
expect(files.getFileInfo('a/b')!.kind).toBe('directory');
52+
expect(files.getFileInfo('a/b/c')).not.toBeNull();
53+
expect(files.getFileInfo('a/b/c')!.kind).toBe('directory');
54+
});
55+
4556
it('updates existing file', () => {
4657
files.updateFile('src/index.ts', 1024, 1000, seedEmbedding(1));
4758
files.updateFile('src/index.ts', 2048, 2000, seedEmbedding(2));

0 commit comments

Comments
 (0)