Skip to content

Commit a00cd32

Browse files
committed
feat(store): Phase 3 — Team, Projects, Attachments stores + cascade tests
- SqliteTeamStore: CRUD, slug uniqueness, getBySlug, list, MetaMixin - SqliteProjectsStore: CRUD, cascade delete via FK + triggers - SqliteAttachmentsStore: metadata registry (add/remove/list), optional url - Wired team + projects in SqliteStore - Fixed cleanup triggers: code/docs/files now also clean attachments - Cascade delete tests: edges, attachments, vec0, tags all verified - 70 tests passing (31 sqlite-specific + 39 contract)
1 parent d2c4475 commit a00cd32

8 files changed

Lines changed: 682 additions & 2 deletions

File tree

src/store/sqlite/migrations/v001.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ CREATE TRIGGER code_cleanup AFTER DELETE ON code BEGIN
309309
DELETE FROM edges WHERE
310310
(from_graph = 'code' AND from_id = old.id AND project_id = old.project_id) OR
311311
(to_graph = 'code' AND to_id = old.id AND project_id = old.project_id);
312+
DELETE FROM attachments WHERE graph = 'code' AND entity_id = old.id AND project_id = old.project_id;
312313
DELETE FROM code_vec WHERE rowid = old.id;
313314
END;
314315
@@ -352,6 +353,7 @@ CREATE TRIGGER docs_cleanup AFTER DELETE ON docs BEGIN
352353
DELETE FROM edges WHERE
353354
(from_graph = 'docs' AND from_id = old.id AND project_id = old.project_id) OR
354355
(to_graph = 'docs' AND to_id = old.id AND project_id = old.project_id);
356+
DELETE FROM attachments WHERE graph = 'docs' AND entity_id = old.id AND project_id = old.project_id;
355357
DELETE FROM docs_vec WHERE rowid = old.id;
356358
END;
357359
@@ -384,6 +386,7 @@ CREATE TRIGGER files_cleanup AFTER DELETE ON files BEGIN
384386
DELETE FROM edges WHERE
385387
(from_graph = 'files' AND from_id = old.id AND project_id = old.project_id) OR
386388
(to_graph = 'files' AND to_id = old.id AND project_id = old.project_id);
389+
DELETE FROM attachments WHERE graph = 'files' AND entity_id = old.id AND project_id = old.project_id;
387390
DELETE FROM files_vec WHERE rowid = old.id;
388391
END;
389392

src/store/sqlite/store.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,28 @@ import { openDatabase } from './lib/db';
1313
import { runMigrations } from './lib/migrate';
1414
import { MetaHelper } from './lib/meta';
1515
import { v001 } from './migrations/v001';
16+
import { SqliteTeamStore } from './stores/team';
17+
import { SqliteProjectsStore } from './stores/projects';
1618

1719
const ALL_MIGRATIONS = [v001];
1820

1921
export class SqliteStore implements Store {
2022
private db: Database.Database | null = null;
2123
private metaHelper: MetaHelper | null = null;
2224
private scopedCache = new Map<number, ProjectScopedStore>();
25+
private _projects: SqliteProjectsStore | null = null;
26+
private _team: SqliteTeamStore | null = null;
2327

2428
// --- Sub-stores (workspace-level) ---
29+
2530
get projects(): ProjectsStore {
26-
throw new Error('Not implemented yet (Phase 3)');
31+
this.requireDb();
32+
return this._projects!;
2733
}
2834

2935
get team(): TeamStore {
30-
throw new Error('Not implemented yet (Phase 3)');
36+
this.requireDb();
37+
return this._team!;
3138
}
3239

3340
// --- Lifecycle ---
@@ -37,11 +44,15 @@ export class SqliteStore implements Store {
3744
this.db = openDatabase(opts.dbPath);
3845
runMigrations(this.db, ALL_MIGRATIONS);
3946
this.metaHelper = new MetaHelper(this.db, '');
47+
this._projects = new SqliteProjectsStore(this.db);
48+
this._team = new SqliteTeamStore(this.db);
4049
}
4150

4251
close(): void {
4352
if (!this.db) return;
4453
this.scopedCache.clear();
54+
this._projects = null;
55+
this._team = null;
4556
this.db.pragma('wal_checkpoint(TRUNCATE)');
4657
this.db.close();
4758
this.db = null;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Database from 'better-sqlite3';
2+
import type {
3+
AttachmentsStore,
4+
AttachmentMeta,
5+
GraphName,
6+
} from '../../types';
7+
import { num, now } from '../lib/bigint';
8+
9+
export class SqliteAttachmentsStore implements AttachmentsStore {
10+
private stmts: ReturnType<SqliteAttachmentsStore['prepareStatements']>;
11+
12+
constructor(private db: Database.Database, private projectId: number) {
13+
this.stmts = this.prepareStatements();
14+
}
15+
16+
private prepareStatements() {
17+
return {
18+
insert: this.db.prepare(`
19+
INSERT INTO attachments (project_id, graph, entity_id, filename, mime_type, size, url, added_at)
20+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
21+
`),
22+
delete: this.db.prepare(
23+
'DELETE FROM attachments WHERE project_id = ? AND graph = ? AND entity_id = ? AND filename = ?'
24+
),
25+
deleteAll: this.db.prepare(
26+
'DELETE FROM attachments WHERE project_id = ? AND graph = ? AND entity_id = ?'
27+
),
28+
list: this.db.prepare(
29+
'SELECT filename, mime_type, size, url, added_at FROM attachments WHERE project_id = ? AND graph = ? AND entity_id = ? ORDER BY added_at'
30+
),
31+
};
32+
}
33+
34+
private toMeta(row: Record<string, unknown>): AttachmentMeta {
35+
return {
36+
filename: row.filename as string,
37+
mimeType: row.mime_type as string,
38+
size: num(row.size as bigint),
39+
url: (row.url as string) ?? undefined,
40+
addedAt: num(row.added_at as bigint),
41+
};
42+
}
43+
44+
add(graph: GraphName, entityId: number, meta: AttachmentMeta): void {
45+
this.stmts.insert.run(
46+
this.projectId, graph, entityId,
47+
meta.filename, meta.mimeType, meta.size, meta.url ?? null, meta.addedAt ?? now(),
48+
);
49+
}
50+
51+
remove(graph: GraphName, entityId: number, filename: string): void {
52+
this.stmts.delete.run(this.projectId, graph, entityId, filename);
53+
}
54+
55+
removeAll(graph: GraphName, entityId: number): void {
56+
this.stmts.deleteAll.run(this.projectId, graph, entityId);
57+
}
58+
59+
list(graph: GraphName, entityId: number): AttachmentMeta[] {
60+
const rows = this.stmts.list.all(this.projectId, graph, entityId) as Array<Record<string, unknown>>;
61+
return rows.map(r => this.toMeta(r));
62+
}
63+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Database from 'better-sqlite3';
2+
import type {
3+
ProjectsStore,
4+
ProjectCreate,
5+
ProjectPatch,
6+
ProjectRecord,
7+
PaginationOptions,
8+
} from '../../types';
9+
import { MetaHelper } from '../lib/meta';
10+
import { num, now } from '../lib/bigint';
11+
12+
export class SqliteProjectsStore implements ProjectsStore {
13+
private meta: MetaHelper;
14+
private stmts: ReturnType<SqliteProjectsStore['prepareStatements']>;
15+
16+
constructor(private db: Database.Database) {
17+
this.meta = new MetaHelper(db, 'projects');
18+
this.stmts = this.prepareStatements();
19+
}
20+
21+
private prepareStatements() {
22+
return {
23+
insert: this.db.prepare(`
24+
INSERT INTO projects (slug, name, directory, created_at, updated_at)
25+
VALUES (?, ?, ?, ?, ?)
26+
`),
27+
update: this.db.prepare(`
28+
UPDATE projects SET name = ?, directory = ?, updated_at = ?
29+
WHERE id = ?
30+
`),
31+
delete: this.db.prepare('DELETE FROM projects WHERE id = ?'),
32+
getById: this.db.prepare('SELECT * FROM projects WHERE id = ?'),
33+
getBySlug: this.db.prepare('SELECT * FROM projects WHERE slug = ?'),
34+
list: this.db.prepare('SELECT * FROM projects ORDER BY name LIMIT ? OFFSET ?'),
35+
count: this.db.prepare('SELECT COUNT(*) AS c FROM projects'),
36+
};
37+
}
38+
39+
private toRecord(row: Record<string, unknown>): ProjectRecord {
40+
return {
41+
id: num(row.id as bigint),
42+
slug: row.slug as string,
43+
name: row.name as string,
44+
directory: row.directory as string,
45+
createdAt: num(row.created_at as bigint),
46+
updatedAt: num(row.updated_at as bigint),
47+
};
48+
}
49+
50+
create(data: ProjectCreate): ProjectRecord {
51+
const ts = now();
52+
const result = this.stmts.insert.run(data.slug, data.name, data.directory, ts, ts);
53+
return this.get(num(result.lastInsertRowid))!;
54+
}
55+
56+
update(projectId: number, patch: ProjectPatch): ProjectRecord {
57+
const existing = this.get(projectId);
58+
if (!existing) throw new Error(`Project ${projectId} not found`);
59+
60+
this.stmts.update.run(
61+
patch.name ?? existing.name,
62+
patch.directory ?? existing.directory,
63+
now(),
64+
projectId,
65+
);
66+
return this.get(projectId)!;
67+
}
68+
69+
delete(projectId: number): void {
70+
// CASCADE on FK handles: knowledge, tasks, epics, skills, code, docs, files, edges, tags
71+
// Cleanup triggers on each entity table handle: vec0, edges, attachments
72+
// But we need to clean vec0 for entities BEFORE cascade deletes them,
73+
// because triggers fire per-row — which is fine, SQLite does this automatically.
74+
this.stmts.delete.run(projectId);
75+
}
76+
77+
get(projectId: number): ProjectRecord | null {
78+
const row = this.stmts.getById.get(projectId) as Record<string, unknown> | undefined;
79+
return row ? this.toRecord(row) : null;
80+
}
81+
82+
getBySlug(slug: string): ProjectRecord | null {
83+
const row = this.stmts.getBySlug.get(slug) as Record<string, unknown> | undefined;
84+
return row ? this.toRecord(row) : null;
85+
}
86+
87+
list(pagination?: PaginationOptions): { results: ProjectRecord[]; total: number } {
88+
const limit = pagination?.limit ?? 50;
89+
const offset = pagination?.offset ?? 0;
90+
const rows = this.stmts.list.all(limit, offset) as Array<Record<string, unknown>>;
91+
const total = num((this.stmts.count.get() as { c: bigint }).c);
92+
return { results: rows.map(r => this.toRecord(r)), total };
93+
}
94+
95+
getMeta(key: string): string | null { return this.meta.getMeta(key); }
96+
setMeta(key: string, value: string): void { this.meta.setMeta(key, value); }
97+
deleteMeta(key: string): void { this.meta.deleteMeta(key); }
98+
}

src/store/sqlite/stores/team.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Database from 'better-sqlite3';
2+
import type {
3+
TeamStore,
4+
TeamMemberCreate,
5+
TeamMemberPatch,
6+
TeamMemberRecord,
7+
PaginationOptions,
8+
} from '../../types';
9+
import { MetaHelper } from '../lib/meta';
10+
import { num, now } from '../lib/bigint';
11+
12+
export class SqliteTeamStore implements TeamStore {
13+
private meta: MetaHelper;
14+
private stmts: ReturnType<SqliteTeamStore['prepareStatements']>;
15+
16+
constructor(private db: Database.Database) {
17+
this.meta = new MetaHelper(db, 'team');
18+
this.stmts = this.prepareStatements();
19+
}
20+
21+
private prepareStatements() {
22+
return {
23+
insert: this.db.prepare(`
24+
INSERT INTO team_members (slug, name, email, role, created_at, updated_at)
25+
VALUES (?, ?, ?, ?, ?, ?)
26+
`),
27+
update: this.db.prepare(`
28+
UPDATE team_members SET name = ?, email = ?, role = ?, updated_at = ?
29+
WHERE id = ?
30+
`),
31+
delete: this.db.prepare('DELETE FROM team_members WHERE id = ?'),
32+
getById: this.db.prepare('SELECT * FROM team_members WHERE id = ?'),
33+
getBySlug: this.db.prepare('SELECT * FROM team_members WHERE slug = ?'),
34+
list: this.db.prepare('SELECT * FROM team_members ORDER BY name LIMIT ? OFFSET ?'),
35+
count: this.db.prepare('SELECT COUNT(*) AS c FROM team_members'),
36+
};
37+
}
38+
39+
private toRecord(row: Record<string, unknown>): TeamMemberRecord {
40+
return {
41+
id: num(row.id as bigint),
42+
slug: row.slug as string,
43+
name: row.name as string,
44+
email: (row.email as string) ?? null,
45+
role: (row.role as string) ?? null,
46+
createdAt: num(row.created_at as bigint),
47+
updatedAt: num(row.updated_at as bigint),
48+
};
49+
}
50+
51+
create(data: TeamMemberCreate): TeamMemberRecord {
52+
const ts = now();
53+
const result = this.stmts.insert.run(
54+
data.slug, data.name, data.email ?? null, data.role ?? null, ts, ts,
55+
);
56+
return this.get(num(result.lastInsertRowid))!;
57+
}
58+
59+
update(memberId: number, patch: TeamMemberPatch): TeamMemberRecord {
60+
const existing = this.get(memberId);
61+
if (!existing) throw new Error(`Team member ${memberId} not found`);
62+
63+
this.stmts.update.run(
64+
patch.name ?? existing.name,
65+
patch.email !== undefined ? patch.email : existing.email,
66+
patch.role !== undefined ? patch.role : existing.role,
67+
now(),
68+
memberId,
69+
);
70+
return this.get(memberId)!;
71+
}
72+
73+
delete(memberId: number): void {
74+
this.stmts.delete.run(memberId);
75+
}
76+
77+
get(memberId: number): TeamMemberRecord | null {
78+
const row = this.stmts.getById.get(memberId) as Record<string, unknown> | undefined;
79+
return row ? this.toRecord(row) : null;
80+
}
81+
82+
getBySlug(slug: string): TeamMemberRecord | null {
83+
const row = this.stmts.getBySlug.get(slug) as Record<string, unknown> | undefined;
84+
return row ? this.toRecord(row) : null;
85+
}
86+
87+
list(pagination?: PaginationOptions): { results: TeamMemberRecord[]; total: number } {
88+
const limit = pagination?.limit ?? 50;
89+
const offset = pagination?.offset ?? 0;
90+
const rows = this.stmts.list.all(limit, offset) as Array<Record<string, unknown>>;
91+
const total = num((this.stmts.count.get() as { c: bigint }).c);
92+
return { results: rows.map(r => this.toRecord(r)), total };
93+
}
94+
95+
getMeta(key: string): string | null { return this.meta.getMeta(key); }
96+
setMeta(key: string, value: string): void { this.meta.setMeta(key, value); }
97+
deleteMeta(key: string): void { this.meta.deleteMeta(key); }
98+
}

0 commit comments

Comments
 (0)