Skip to content

Commit d2c4475

Browse files
committed
feat(store): unified edges, single-table indexed stores, graph-prefixed schema
- Unified Edge type replaces Relation + CrossLink (one edges table) - Code, Docs, Files: single table per graph with kind field - Cleanup triggers on all entity tables (edges + attachments + vec0) - Tags as entities with edges (kind='tagged'), inline in records - Attachments: metadata only (url optional, no BLOB) - CASCADE on all project_id FKs - Search tests: keyword, vector, hybrid, project isolation - All imports use @/ alias - 31 tests passing
1 parent 5a4110c commit d2c4475

13 files changed

Lines changed: 293 additions & 63 deletions

File tree

src/store/sqlite/migrations/v001.ts

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,22 @@ CREATE TABLE team_members (
3434
);
3535
3636
CREATE TABLE tags (
37-
project_id INTEGER NOT NULL,
38-
graph TEXT NOT NULL,
39-
entity_id INTEGER NOT NULL,
40-
tag TEXT NOT NULL,
41-
PRIMARY KEY (project_id, graph, entity_id, tag)
37+
id INTEGER PRIMARY KEY AUTOINCREMENT,
38+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
39+
name TEXT NOT NULL,
40+
UNIQUE(project_id, name)
4241
);
43-
CREATE INDEX idx_tags_tag ON tags(tag);
44-
CREATE INDEX idx_tags_entity ON tags(graph, entity_id);
42+
CREATE INDEX idx_tags_project ON tags(project_id);
4543
4644
CREATE TABLE attachments (
4745
id INTEGER PRIMARY KEY AUTOINCREMENT,
48-
project_id INTEGER NOT NULL,
46+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
4947
graph TEXT NOT NULL,
5048
entity_id INTEGER NOT NULL,
5149
filename TEXT NOT NULL,
5250
mime_type TEXT NOT NULL,
5351
size INTEGER NOT NULL,
54-
data BLOB NOT NULL,
52+
url TEXT,
5553
added_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
5654
UNIQUE(project_id, graph, entity_id, filename)
5755
);
@@ -107,6 +105,14 @@ END;
107105
108106
CREATE VIRTUAL TABLE knowledge_vec USING vec0(embedding float[${EMBEDDING_DIM}]);
109107
108+
CREATE TRIGGER knowledge_cleanup AFTER DELETE ON knowledge BEGIN
109+
DELETE FROM edges WHERE
110+
(from_graph = 'knowledge' AND from_id = old.id AND project_id = old.project_id) OR
111+
(to_graph = 'knowledge' AND to_id = old.id AND project_id = old.project_id);
112+
DELETE FROM attachments WHERE graph = 'knowledge' AND entity_id = old.id AND project_id = old.project_id;
113+
DELETE FROM knowledge_vec WHERE rowid = old.id;
114+
END;
115+
110116
-- =============================================
111117
-- Tasks
112118
-- =============================================
@@ -150,6 +156,14 @@ END;
150156
151157
CREATE VIRTUAL TABLE tasks_vec USING vec0(embedding float[${EMBEDDING_DIM}]);
152158
159+
CREATE TRIGGER tasks_cleanup AFTER DELETE ON tasks BEGIN
160+
DELETE FROM edges WHERE
161+
(from_graph = 'tasks' AND from_id = old.id AND project_id = old.project_id) OR
162+
(to_graph = 'tasks' AND to_id = old.id AND project_id = old.project_id);
163+
DELETE FROM attachments WHERE graph = 'tasks' AND entity_id = old.id AND project_id = old.project_id;
164+
DELETE FROM tasks_vec WHERE rowid = old.id;
165+
END;
166+
153167
-- =============================================
154168
-- Epics
155169
-- =============================================
@@ -188,6 +202,14 @@ END;
188202
189203
CREATE VIRTUAL TABLE epics_vec USING vec0(embedding float[${EMBEDDING_DIM}]);
190204
205+
CREATE TRIGGER epics_cleanup AFTER DELETE ON epics BEGIN
206+
DELETE FROM edges WHERE
207+
(from_graph = 'epics' AND from_id = old.id AND project_id = old.project_id) OR
208+
(to_graph = 'epics' AND to_id = old.id AND project_id = old.project_id);
209+
DELETE FROM attachments WHERE graph = 'epics' AND entity_id = old.id AND project_id = old.project_id;
210+
DELETE FROM epics_vec WHERE rowid = old.id;
211+
END;
212+
191213
-- =============================================
192214
-- Skills
193215
-- =============================================
@@ -231,6 +253,14 @@ END;
231253
232254
CREATE VIRTUAL TABLE skills_vec USING vec0(embedding float[${EMBEDDING_DIM}]);
233255
256+
CREATE TRIGGER skills_cleanup AFTER DELETE ON skills BEGIN
257+
DELETE FROM edges WHERE
258+
(from_graph = 'skills' AND from_id = old.id AND project_id = old.project_id) OR
259+
(to_graph = 'skills' AND to_id = old.id AND project_id = old.project_id);
260+
DELETE FROM attachments WHERE graph = 'skills' AND entity_id = old.id AND project_id = old.project_id;
261+
DELETE FROM skills_vec WHERE rowid = old.id;
262+
END;
263+
234264
-- =============================================
235265
-- Code (one table — file nodes + symbol nodes, linked via edges)
236266
-- =============================================
@@ -275,6 +305,13 @@ END;
275305
276306
CREATE VIRTUAL TABLE code_vec USING vec0(embedding float[${EMBEDDING_DIM}]);
277307
308+
CREATE TRIGGER code_cleanup AFTER DELETE ON code BEGIN
309+
DELETE FROM edges WHERE
310+
(from_graph = 'code' AND from_id = old.id AND project_id = old.project_id) OR
311+
(to_graph = 'code' AND to_id = old.id AND project_id = old.project_id);
312+
DELETE FROM code_vec WHERE rowid = old.id;
313+
END;
314+
278315
-- =============================================
279316
-- Docs (one table — file nodes + chunk nodes, linked via edges)
280317
-- =============================================
@@ -311,6 +348,13 @@ END;
311348
312349
CREATE VIRTUAL TABLE docs_vec USING vec0(embedding float[${EMBEDDING_DIM}]);
313350
351+
CREATE TRIGGER docs_cleanup AFTER DELETE ON docs BEGIN
352+
DELETE FROM edges WHERE
353+
(from_graph = 'docs' AND from_id = old.id AND project_id = old.project_id) OR
354+
(to_graph = 'docs' AND to_id = old.id AND project_id = old.project_id);
355+
DELETE FROM docs_vec WHERE rowid = old.id;
356+
END;
357+
314358
-- =============================================
315359
-- Files (one table — file nodes + directory nodes, linked via edges)
316360
-- =============================================
@@ -335,5 +379,19 @@ CREATE INDEX idx_files_dir ON files(project_id, directory);
335379
CREATE INDEX idx_files_kind ON files(project_id, kind);
336380
337381
CREATE VIRTUAL TABLE files_vec USING vec0(embedding float[${EMBEDDING_DIM}]);
382+
383+
CREATE TRIGGER files_cleanup AFTER DELETE ON files BEGIN
384+
DELETE FROM edges WHERE
385+
(from_graph = 'files' AND from_id = old.id AND project_id = old.project_id) OR
386+
(to_graph = 'files' AND to_id = old.id AND project_id = old.project_id);
387+
DELETE FROM files_vec WHERE rowid = old.id;
388+
END;
389+
390+
-- Tags cleanup: when a tag is deleted, remove its edges
391+
CREATE TRIGGER tags_cleanup AFTER DELETE ON tags BEGIN
392+
DELETE FROM edges WHERE
393+
(from_graph = 'tags' AND from_id = old.id AND project_id = old.project_id) OR
394+
(to_graph = 'tags' AND to_id = old.id AND project_id = old.project_id);
395+
END;
338396
`,
339397
};

src/store/types/attachments.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,28 @@
11
import type { GraphName } from './common';
22

33
// ---------------------------------------------------------------------------
4-
// Attachments Store (shared across all graphs)
4+
// Attachments Store (metadata registry, files stored externally)
55
// ---------------------------------------------------------------------------
66

77
export interface AttachmentMeta {
88
filename: string;
99
mimeType: string;
1010
size: number;
11+
/** Optional external URL (S3, CDN). If absent — file is in mirror directory */
12+
url?: string;
1113
addedAt: number;
1214
}
1315

14-
export interface AttachmentRecord extends AttachmentMeta {
15-
graph: GraphName;
16-
entityId: number;
17-
}
18-
1916
export interface AttachmentsStore {
20-
/** Add an attachment to an entity */
21-
add(graph: GraphName, entityId: number, filename: string, data: Buffer): AttachmentMeta;
17+
/** Register an attachment */
18+
add(graph: GraphName, entityId: number, meta: AttachmentMeta): void;
2219

23-
/** Remove an attachment from an entity */
20+
/** Remove an attachment record */
2421
remove(graph: GraphName, entityId: number, filename: string): void;
2522

26-
/** Remove all attachments for an entity (e.g. on delete) */
23+
/** Remove all attachment records for an entity */
2724
removeAll(graph: GraphName, entityId: number): void;
2825

2926
/** List attachments for an entity */
3027
list(graph: GraphName, entityId: number): AttachmentMeta[];
31-
32-
/** Get attachment file path (for serving) */
33-
getPath(graph: GraphName, entityId: number, filename: string): string | null;
3428
}

src/store/types/knowledge.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import type { AttachmentMeta } from './attachments';
88
export interface NoteCreate {
99
title: string;
1010
content: string;
11+
tags?: string[];
1112
authorId?: number;
1213
}
1314

1415
export interface NotePatch {
1516
title?: string;
1617
content?: string;
18+
tags?: string[];
1719
}
1820

1921
export interface NoteRecord {

src/store/types/skills.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface SkillCreate {
1414
triggers?: string[];
1515
inputHints?: string[];
1616
filePatterns?: string[];
17+
tags?: string[];
1718
source?: SkillSource;
1819
confidence?: number;
1920
authorId?: number;
@@ -26,6 +27,7 @@ export interface SkillPatch {
2627
triggers?: string[];
2728
inputHints?: string[];
2829
filePatterns?: string[];
30+
tags?: string[];
2931
source?: SkillSource;
3032
confidence?: number;
3133
}

src/store/types/store.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type { FilesStore } from './files';
55
import type { KnowledgeStore } from './knowledge';
66
import type { TasksStore } from './tasks';
77
import type { SkillsStore } from './skills';
8-
import type { TagsStore } from './tags';
98
import type { AttachmentsStore } from './attachments';
109
import type { ProjectsStore } from './projects';
1110
import type { TeamStore } from './team';
@@ -23,7 +22,6 @@ export interface ProjectScopedStore {
2322
readonly knowledge: KnowledgeStore;
2423
readonly tasks: TasksStore;
2524
readonly skills: SkillsStore;
26-
readonly tags: TagsStore;
2725
readonly attachments: AttachmentsStore;
2826

2927
// --- Edges (unified graph edges — same-graph and cross-graph) ---

src/store/types/tags.ts

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,12 @@
1-
import type { GraphName, PaginationOptions } from './common';
2-
31
// ---------------------------------------------------------------------------
4-
// Tags Store (shared across all graphs)
2+
// Tags (per-project entities, linked to other entities via edges)
3+
//
4+
// Tags are managed internally by each store's create/update methods.
5+
// No separate TagsStore — tags flow through Create/Patch/Record interfaces
6+
// and are stored in the `tags` table + `edges` table (kind='tagged').
57
// ---------------------------------------------------------------------------
68

7-
export interface TagEntry {
8-
tag: string;
9-
/** How many entities use this tag (across all graphs) */
10-
count: number;
11-
}
12-
13-
export interface EntityTag {
14-
graph: GraphName;
15-
entityId: number;
16-
tag: string;
17-
}
18-
19-
export interface TagsStore {
20-
/** Set tags for an entity (replaces previous tags) */
21-
set(graph: GraphName, entityId: number, tags: string[]): void;
22-
23-
/** Get all tags for an entity */
24-
get(graph: GraphName, entityId: number): string[];
25-
26-
/** Remove all tags for an entity (e.g. on delete) */
27-
remove(graph: GraphName, entityId: number): void;
28-
29-
/** List all known tags with usage counts, optionally filtered by graph */
30-
list(graph?: GraphName, pagination?: PaginationOptions): { results: TagEntry[]; total: number };
31-
32-
/** Find all entities that have a given tag, optionally filtered by graph */
33-
findByTag(tag: string, graph?: GraphName, pagination?: PaginationOptions): { results: EntityTag[]; total: number };
9+
export interface TagRecord {
10+
id: number;
11+
name: string;
3412
}

src/store/types/tasks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface TaskCreate {
1414
description: string;
1515
status?: TaskStatus;
1616
priority?: TaskPriority;
17+
tags?: string[];
1718
dueDate?: number | null;
1819
estimate?: number | null;
1920
assigneeId?: number | null;
@@ -26,6 +27,7 @@ export interface TaskPatch {
2627
description?: string;
2728
status?: TaskStatus;
2829
priority?: TaskPriority;
30+
tags?: string[];
2931
dueDate?: number | null;
3032
estimate?: number | null;
3133
assigneeId?: number | null;
@@ -75,6 +77,7 @@ export interface EpicCreate {
7577
description: string;
7678
status?: EpicStatus;
7779
priority?: TaskPriority;
80+
tags?: string[];
7881
authorId?: number;
7982
}
8083

@@ -83,6 +86,7 @@ export interface EpicPatch {
8386
description?: string;
8487
status?: EpicStatus;
8588
priority?: TaskPriority;
89+
tags?: string[];
8690
}
8791

8892
export interface EpicRecord {

src/tests/store/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mkdtempSync, rmSync } from 'fs';
22
import { tmpdir } from 'os';
33
import { join } from 'path';
4-
import { SqliteStore } from '../../store';
4+
import { SqliteStore } from '@/store';
55

66
export const TEST_DIM = 384;
77

src/tests/store/sqlite/bigint.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { num, now } from '../../../store/sqlite/lib/bigint';
1+
import { num, now } from '@/store/sqlite/lib/bigint';
22

33
describe('BigInt helpers', () => {
44
it('num converts BigInt to number', () => {

src/tests/store/sqlite/db.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { existsSync } from 'fs';
33
import { join } from 'path';
44
import { mkdtempSync, rmSync } from 'fs';
55
import { tmpdir } from 'os';
6-
import { SqliteStore } from '../../../store';
6+
import { SqliteStore } from '@/store';
77

88
describe('SQLite DB lifecycle', () => {
99
const factory = createSqliteStoreFactory();

0 commit comments

Comments
 (0)