Skip to content

Commit 06f10a4

Browse files
committed
refactor(store): split edge project_id into from/to, rename orchestrator to StoreManager
Edges table now stores from_project_id and to_project_id separately, enabling cross-project edges for workspace mode. Cleanup triggers simplified since entity IDs are globally unique. Renamed GraphOrchestrator → StoreManager for clarity.
1 parent 4a7cb63 commit 06f10a4

19 files changed

Lines changed: 173 additions & 156 deletions
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
2-
* GraphOrchestrator — thin layer between API (MCP/REST) and Store (SQLite).
2+
* StoreManager — thin layer between API (MCP/REST) and Store (SQLite).
33
*
44
* Handles: embedding generation, file mirror sync, event emission.
55
* Does NOT own the store — receives it as dependency.
6-
* One orchestrator per project.
6+
* One StoreManager per project.
77
*/
88
import { EventEmitter } from 'events';
99
import type {
@@ -54,15 +54,15 @@ import {
5454
} from './file-mirror';
5555
import { createLogger } from './logger';
5656

57-
const log = createLogger('orchestrator');
57+
const log = createLogger('store-manager');
5858

5959
// ---------------------------------------------------------------------------
6060
// Types
6161
// ---------------------------------------------------------------------------
6262

6363
export type EmbedFn = (text: string) => Promise<number[]>;
6464

65-
export interface OrchestratorConfig {
65+
export interface StoreManagerConfig {
6666
store: Store;
6767
projectId: number;
6868
projectDir: string;
@@ -72,18 +72,18 @@ export interface OrchestratorConfig {
7272
}
7373

7474
// ---------------------------------------------------------------------------
75-
// GraphOrchestrator
75+
// StoreManager
7676
// ---------------------------------------------------------------------------
7777

78-
export class GraphOrchestrator {
78+
export class StoreManager {
7979
readonly store: Store;
8080
readonly scoped: ProjectScopedStore;
8181
readonly projectId: number;
8282
readonly projectDir: string;
8383
private embedFn: EmbedFn;
8484
private emitter: EventEmitter;
8585

86-
constructor(config: OrchestratorConfig) {
86+
constructor(config: StoreManagerConfig) {
8787
this.store = config.store;
8888
this.projectId = config.projectId;
8989
this.projectDir = config.projectDir;

src/store/sqlite/lib/edge-helper.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,28 @@ import { num } from './bigint';
99
export class EdgeHelper {
1010
constructor(private db: Database.Database) {}
1111

12-
createEdge(projectId: number, edge: Edge): void {
12+
createEdge(fromProjectId: number, toProjectId: number, edge: Edge): void {
1313
this.db.prepare(`
14-
INSERT OR IGNORE INTO edges (project_id, from_graph, from_id, to_graph, to_id, kind)
15-
VALUES (?, ?, ?, ?, ?, ?)
16-
`).run(projectId, edge.fromGraph, edge.fromId, edge.toGraph, edge.toId, edge.kind);
14+
INSERT OR IGNORE INTO edges (from_project_id, from_graph, from_id, to_project_id, to_graph, to_id, kind)
15+
VALUES (?, ?, ?, ?, ?, ?, ?)
16+
`).run(fromProjectId, edge.fromGraph, edge.fromId, toProjectId, edge.toGraph, edge.toId, edge.kind);
1717
}
1818

19-
deleteEdge(projectId: number, edge: Edge): void {
19+
deleteEdge(edge: Edge): void {
2020
this.db.prepare(`
2121
DELETE FROM edges
22-
WHERE project_id = ? AND from_graph = ? AND from_id = ? AND to_graph = ? AND to_id = ? AND kind = ?
23-
`).run(projectId, edge.fromGraph, edge.fromId, edge.toGraph, edge.toId, edge.kind);
22+
WHERE from_graph = ? AND from_id = ? AND to_graph = ? AND to_id = ? AND kind = ?
23+
`).run(edge.fromGraph, edge.fromId, edge.toGraph, edge.toId, edge.kind);
2424
}
2525

26-
listEdges(filter: EdgeFilter & { projectId?: number }): Edge[] {
26+
listEdges(filter: EdgeFilter): Edge[] {
2727
const conditions: string[] = [];
2828
const params: unknown[] = [];
2929

30-
if (filter.projectId !== undefined) { conditions.push('project_id = ?'); params.push(filter.projectId); }
30+
if (filter.fromProjectId !== undefined) { conditions.push('from_project_id = ?'); params.push(filter.fromProjectId); }
3131
if (filter.fromGraph) { conditions.push('from_graph = ?'); params.push(filter.fromGraph); }
3232
if (filter.fromId !== undefined) { conditions.push('from_id = ?'); params.push(filter.fromId); }
33+
if (filter.toProjectId !== undefined) { conditions.push('to_project_id = ?'); params.push(filter.toProjectId); }
3334
if (filter.toGraph) { conditions.push('to_graph = ?'); params.push(filter.toGraph); }
3435
if (filter.toId !== undefined) { conditions.push('to_id = ?'); params.push(filter.toId); }
3536
if (filter.kind) { conditions.push('kind = ?'); params.push(filter.kind); }
@@ -49,10 +50,14 @@ export class EdgeHelper {
4950
}
5051

5152
findIncomingEdges(targetGraph: GraphName, targetId: number, projectId?: number): Edge[] {
52-
return this.listEdges({ toGraph: targetGraph, toId: targetId, projectId });
53+
const filter: EdgeFilter = { toGraph: targetGraph, toId: targetId };
54+
if (projectId !== undefined) filter.toProjectId = projectId;
55+
return this.listEdges(filter);
5356
}
5457

5558
findOutgoingEdges(fromGraph: GraphName, fromId: number, projectId?: number): Edge[] {
56-
return this.listEdges({ fromGraph, fromId, projectId });
59+
const filter: EdgeFilter = { fromGraph, fromId };
60+
if (projectId !== undefined) filter.fromProjectId = projectId;
61+
return this.listEdges(filter);
5762
}
5863
}

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

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ export class EntityHelpers {
1414
setTags(graph: string, entityId: number, tags: string[]): void {
1515
// Collect old tag ids before deleting edges
1616
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 }>;
17+
`SELECT from_id FROM edges WHERE to_graph = ? AND to_id = ? AND from_graph = 'tags' AND kind = 'tagged'`
18+
).all(graph, entityId) as Array<{ from_id: bigint }>;
1919

20-
this.db.prepare(`DELETE FROM edges WHERE project_id = ? AND to_graph = ? AND to_id = ? AND from_graph = 'tags' AND kind = 'tagged'`)
21-
.run(this.projectId, graph, entityId);
20+
this.db.prepare(`DELETE FROM edges WHERE to_graph = ? AND to_id = ? AND from_graph = 'tags' AND kind = 'tagged'`)
21+
.run(graph, entityId);
2222

2323
// Clean up orphaned tags in one query
2424
if (oldTagIds.length > 0) {
@@ -27,7 +27,7 @@ export class EntityHelpers {
2727
this.db.prepare(`
2828
DELETE FROM tags WHERE project_id = ? AND id IN (${ph})
2929
AND NOT EXISTS (
30-
SELECT 1 FROM edges WHERE project_id = tags.project_id AND from_graph = 'tags' AND from_id = tags.id AND kind = 'tagged'
30+
SELECT 1 FROM edges WHERE from_graph = 'tags' AND from_id = tags.id AND kind = 'tagged'
3131
)
3232
`).run(this.projectId, ...ids);
3333
}
@@ -36,22 +36,22 @@ export class EntityHelpers {
3636
const uniqueTags = [...new Set(tags)];
3737
const insertTag = this.db.prepare('INSERT OR IGNORE INTO tags (project_id, name) VALUES (?, ?)');
3838
const selectTag = this.db.prepare('SELECT id FROM tags WHERE project_id = ? AND name = ?');
39-
const insertEdge = this.db.prepare(`INSERT OR IGNORE INTO edges (project_id, from_graph, from_id, to_graph, to_id, kind) VALUES (?, 'tags', ?, ?, ?, 'tagged')`);
39+
const insertEdge = this.db.prepare(`INSERT OR IGNORE INTO edges (from_project_id, from_graph, from_id, to_project_id, to_graph, to_id, kind) VALUES (?, 'tags', ?, ?, ?, ?, 'tagged')`);
4040
for (const tag of uniqueTags) {
4141
insertTag.run(this.projectId, tag);
4242
const row = selectTag.get(this.projectId, tag) as { id: bigint } | undefined;
4343
if (!row) throw new Error(`Failed to resolve tag: ${tag}`);
44-
insertEdge.run(this.projectId, num(row.id), graph, entityId);
44+
insertEdge.run(this.projectId, num(row.id), this.projectId, graph, entityId);
4545
}
4646
}
4747

4848
fetchTags(graph: string, entityId: number): string[] {
4949
const rows = this.db.prepare(`
5050
SELECT t.name FROM tags t
51-
JOIN edges e ON e.from_graph = 'tags' AND e.from_id = t.id AND e.project_id = ?
51+
JOIN edges e ON e.from_graph = 'tags' AND e.from_id = t.id
5252
WHERE e.to_graph = ? AND e.to_id = ? AND e.kind = 'tagged'
5353
ORDER BY t.name
54-
`).all(this.projectId, graph, entityId) as Array<{ name: string }>;
54+
`).all(graph, entityId) as Array<{ name: string }>;
5555
return rows.map(r => r.name);
5656
}
5757

@@ -66,11 +66,11 @@ export class EntityHelpers {
6666
const rows = this.db.prepare(`
6767
SELECT e.to_id AS entity_id, t.name
6868
FROM edges e
69-
JOIN tags t ON t.id = e.from_id AND t.project_id = e.project_id
70-
WHERE e.project_id = ? AND e.from_graph = 'tags' AND e.to_graph = ? AND e.kind = 'tagged'
69+
JOIN tags t ON t.id = e.from_id
70+
WHERE e.from_graph = 'tags' AND e.to_graph = ? AND e.kind = 'tagged'
7171
AND e.to_id IN (${ph})
7272
ORDER BY t.name
73-
`).all(this.projectId, graph, ...batch) as Array<{ entity_id: bigint; name: string }>;
73+
`).all(graph, ...batch) as Array<{ entity_id: bigint; name: string }>;
7474

7575
for (const r of rows) {
7676
const id = num(r.entity_id);
@@ -129,10 +129,10 @@ export class EntityHelpers {
129129
fetchEdges(graph: string, entityId: number): Edge[] {
130130
const rows = this.db.prepare(`
131131
SELECT from_graph, from_id, to_graph, to_id, kind FROM edges
132-
WHERE project_id = ? AND (
132+
WHERE (
133133
(from_graph = ? AND from_id = ?) OR (to_graph = ? AND to_id = ?)
134134
) AND from_graph != 'tags'
135-
`).all(this.projectId, graph, entityId, graph, entityId) as Array<Record<string, unknown>>;
135+
`).all(graph, entityId, graph, entityId) as Array<Record<string, unknown>>;
136136
return rows.map(r => ({
137137
fromGraph: r.from_graph as GraphName,
138138
fromId: num(r.from_id as bigint),

src/store/sqlite/migrations/v001.ts

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,19 @@ CREATE INDEX idx_attachments_entity ON attachments(project_id, graph, entity_id)
6464
-- =============================================
6565
6666
CREATE TABLE edges (
67-
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
68-
from_graph TEXT NOT NULL,
69-
from_id INTEGER NOT NULL,
70-
to_graph TEXT NOT NULL,
71-
to_id INTEGER NOT NULL,
72-
kind TEXT NOT NULL,
73-
PRIMARY KEY (project_id, from_graph, from_id, to_graph, to_id, kind)
67+
from_project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
68+
from_graph TEXT NOT NULL,
69+
from_id INTEGER NOT NULL,
70+
to_project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
71+
to_graph TEXT NOT NULL,
72+
to_id INTEGER NOT NULL,
73+
kind TEXT NOT NULL,
74+
PRIMARY KEY (from_graph, from_id, to_graph, to_id, kind)
7475
);
75-
CREATE INDEX idx_edges_target ON edges(project_id, to_graph, to_id);
76-
CREATE INDEX idx_edges_source ON edges(project_id, from_graph, from_id);
76+
CREATE INDEX idx_edges_target ON edges(to_graph, to_id);
77+
CREATE INDEX idx_edges_source ON edges(from_graph, from_id);
78+
CREATE INDEX idx_edges_from_project ON edges(from_project_id);
79+
CREATE INDEX idx_edges_to_project ON edges(to_project_id);
7780
7881
-- =============================================
7982
-- Knowledge (notes)
@@ -113,8 +116,8 @@ CREATE VIRTUAL TABLE knowledge_vec USING vec0(embedding float[${d('knowledge')}]
113116
114117
CREATE TRIGGER knowledge_cleanup AFTER DELETE ON knowledge BEGIN
115118
DELETE FROM edges WHERE
116-
(from_graph = 'knowledge' AND from_id = old.id AND project_id = old.project_id) OR
117-
(to_graph = 'knowledge' AND to_id = old.id AND project_id = old.project_id);
119+
(from_graph = 'knowledge' AND from_id = old.id) OR
120+
(to_graph = 'knowledge' AND to_id = old.id);
118121
DELETE FROM attachments WHERE graph = 'knowledge' AND entity_id = old.id AND project_id = old.project_id;
119122
DELETE FROM knowledge_vec WHERE rowid = old.id;
120123
END;
@@ -166,8 +169,8 @@ CREATE VIRTUAL TABLE tasks_vec USING vec0(embedding float[${d('tasks')}]);
166169
167170
CREATE TRIGGER tasks_cleanup AFTER DELETE ON tasks BEGIN
168171
DELETE FROM edges WHERE
169-
(from_graph = 'tasks' AND from_id = old.id AND project_id = old.project_id) OR
170-
(to_graph = 'tasks' AND to_id = old.id AND project_id = old.project_id);
172+
(from_graph = 'tasks' AND from_id = old.id) OR
173+
(to_graph = 'tasks' AND to_id = old.id);
171174
DELETE FROM attachments WHERE graph = 'tasks' AND entity_id = old.id AND project_id = old.project_id;
172175
DELETE FROM tasks_vec WHERE rowid = old.id;
173176
END;
@@ -213,8 +216,8 @@ CREATE VIRTUAL TABLE epics_vec USING vec0(embedding float[${d('epics')}]);
213216
214217
CREATE TRIGGER epics_cleanup AFTER DELETE ON epics BEGIN
215218
DELETE FROM edges WHERE
216-
(from_graph = 'epics' AND from_id = old.id AND project_id = old.project_id) OR
217-
(to_graph = 'epics' AND to_id = old.id AND project_id = old.project_id);
219+
(from_graph = 'epics' AND from_id = old.id) OR
220+
(to_graph = 'epics' AND to_id = old.id);
218221
DELETE FROM attachments WHERE graph = 'epics' AND entity_id = old.id AND project_id = old.project_id;
219222
DELETE FROM epics_vec WHERE rowid = old.id;
220223
END;
@@ -265,8 +268,8 @@ CREATE VIRTUAL TABLE skills_vec USING vec0(embedding float[${d('skills')}]);
265268
266269
CREATE TRIGGER skills_cleanup AFTER DELETE ON skills BEGIN
267270
DELETE FROM edges WHERE
268-
(from_graph = 'skills' AND from_id = old.id AND project_id = old.project_id) OR
269-
(to_graph = 'skills' AND to_id = old.id AND project_id = old.project_id);
271+
(from_graph = 'skills' AND from_id = old.id) OR
272+
(to_graph = 'skills' AND to_id = old.id);
270273
DELETE FROM attachments WHERE graph = 'skills' AND entity_id = old.id AND project_id = old.project_id;
271274
DELETE FROM skills_vec WHERE rowid = old.id;
272275
END;
@@ -317,8 +320,8 @@ CREATE VIRTUAL TABLE code_vec USING vec0(embedding float[${d('code')}]);
317320
318321
CREATE TRIGGER code_cleanup AFTER DELETE ON code BEGIN
319322
DELETE FROM edges WHERE
320-
(from_graph = 'code' AND from_id = old.id AND project_id = old.project_id) OR
321-
(to_graph = 'code' AND to_id = old.id AND project_id = old.project_id);
323+
(from_graph = 'code' AND from_id = old.id) OR
324+
(to_graph = 'code' AND to_id = old.id);
322325
DELETE FROM attachments WHERE graph = 'code' AND entity_id = old.id AND project_id = old.project_id;
323326
DELETE FROM code_vec WHERE rowid = old.id;
324327
END;
@@ -361,8 +364,8 @@ CREATE VIRTUAL TABLE docs_vec USING vec0(embedding float[${d('docs')}]);
361364
362365
CREATE TRIGGER docs_cleanup AFTER DELETE ON docs BEGIN
363366
DELETE FROM edges WHERE
364-
(from_graph = 'docs' AND from_id = old.id AND project_id = old.project_id) OR
365-
(to_graph = 'docs' AND to_id = old.id AND project_id = old.project_id);
367+
(from_graph = 'docs' AND from_id = old.id) OR
368+
(to_graph = 'docs' AND to_id = old.id);
366369
DELETE FROM attachments WHERE graph = 'docs' AND entity_id = old.id AND project_id = old.project_id;
367370
DELETE FROM docs_vec WHERE rowid = old.id;
368371
END;
@@ -393,17 +396,17 @@ CREATE VIRTUAL TABLE files_vec USING vec0(embedding float[${d('files')}]);
393396
394397
CREATE TRIGGER files_cleanup AFTER DELETE ON files BEGIN
395398
DELETE FROM edges WHERE
396-
(from_graph = 'files' AND from_id = old.id AND project_id = old.project_id) OR
397-
(to_graph = 'files' AND to_id = old.id AND project_id = old.project_id);
399+
(from_graph = 'files' AND from_id = old.id) OR
400+
(to_graph = 'files' AND to_id = old.id);
398401
DELETE FROM attachments WHERE graph = 'files' AND entity_id = old.id AND project_id = old.project_id;
399402
DELETE FROM files_vec WHERE rowid = old.id;
400403
END;
401404
402405
-- Tags cleanup: when a tag is deleted, remove its edges
403406
CREATE TRIGGER tags_cleanup AFTER DELETE ON tags BEGIN
404407
DELETE FROM edges WHERE
405-
(from_graph = 'tags' AND from_id = old.id AND project_id = old.project_id) OR
406-
(to_graph = 'tags' AND to_id = old.id AND project_id = old.project_id);
408+
(from_graph = 'tags' AND from_id = old.id) OR
409+
(to_graph = 'tags' AND to_id = old.id);
407410
END;
408411
`,
409412
};

src/store/sqlite/store.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,17 @@ export class SqliteStore implements Store {
8484

8585
// --- Edges ---
8686

87-
createEdge(projectId: number, edge: Edge): void {
87+
createEdge(fromProjectId: number, toProjectId: number, edge: Edge): void {
8888
this.requireDb();
89-
this.edgeHelper!.createEdge(projectId, edge);
89+
this.edgeHelper!.createEdge(fromProjectId, toProjectId, edge);
9090
}
9191

92-
deleteEdge(projectId: number, edge: Edge): void {
92+
deleteEdge(edge: Edge): void {
9393
this.requireDb();
94-
this.edgeHelper!.deleteEdge(projectId, edge);
94+
this.edgeHelper!.deleteEdge(edge);
9595
}
9696

97-
listEdges(filter: EdgeFilter & { projectId?: number }): Edge[] {
97+
listEdges(filter: EdgeFilter): Edge[] {
9898
this.requireDb();
9999
return this.edgeHelper!.listEdges(filter);
100100
}

src/store/sqlite/stores/code.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ export class SqliteCodeStore implements CodeStore {
9595
`);
9696
const insertVec = this.db.prepare('INSERT INTO code_vec (rowid, embedding) VALUES (?, ?)');
9797
const insertEdge = this.db.prepare(`
98-
INSERT OR IGNORE INTO edges (project_id, from_graph, from_id, to_graph, to_id, kind)
99-
VALUES (?, 'code', ?, 'code', ?, ?)
98+
INSERT OR IGNORE INTO edges (from_project_id, from_graph, from_id, to_project_id, to_graph, to_id, kind)
99+
VALUES (?, 'code', ?, ?, 'code', ?, ?)
100100
`);
101101

102102
const nameToId = new Map<string, number>();
@@ -119,15 +119,15 @@ export class SqliteCodeStore implements CodeStore {
119119
}
120120

121121
// Edge: file → symbol (contains)
122-
insertEdge.run(this.projectId, fileNodeId, nodeId, 'contains');
122+
insertEdge.run(this.projectId, fileNodeId, this.projectId, nodeId, 'contains');
123123
}
124124

125125
// 4. Insert intra-file edges
126126
for (const edge of edges) {
127127
const fromId = nameToId.get(edge.fromName);
128128
const toId = nameToId.get(edge.toName);
129129
if (fromId !== undefined && toId !== undefined) {
130-
insertEdge.run(this.projectId, fromId, toId, edge.kind);
130+
insertEdge.run(this.projectId, fromId, this.projectId, toId, edge.kind);
131131
}
132132
}
133133
}
@@ -149,16 +149,16 @@ export class SqliteCodeStore implements CodeStore {
149149
resolveEdges(edges: Array<{ fromName: string; toName: string; kind: string }>): void {
150150
const findByName = this.db.prepare("SELECT id FROM code WHERE project_id = ? AND name = ? AND kind != 'file'");
151151
const insertEdge = this.db.prepare(`
152-
INSERT OR IGNORE INTO edges (project_id, from_graph, from_id, to_graph, to_id, kind)
153-
VALUES (?, 'code', ?, 'code', ?, ?)
152+
INSERT OR IGNORE INTO edges (from_project_id, from_graph, from_id, to_project_id, to_graph, to_id, kind)
153+
VALUES (?, 'code', ?, ?, 'code', ?, ?)
154154
`);
155155

156156
for (const edge of edges) {
157157
const fromRows = findByName.all(this.projectId, edge.fromName) as Array<{ id: bigint }>;
158158
const toRows = findByName.all(this.projectId, edge.toName) as Array<{ id: bigint }>;
159159
for (const fromRow of fromRows) {
160160
for (const toRow of toRows) {
161-
insertEdge.run(this.projectId, num(fromRow.id), num(toRow.id), edge.kind);
161+
insertEdge.run(this.projectId, num(fromRow.id), this.projectId, num(toRow.id), edge.kind);
162162
}
163163
}
164164
}

0 commit comments

Comments
 (0)