Skip to content

Commit 5a4110c

Browse files
committed
feat(store): unified edges, single-table indexed stores, graph-prefixed schema
Major refactoring of store types and schema: - Unified Edge type replaces Relation + CrossLink (one edges table) - Code, Docs, Files: single table per graph with kind field (no separate file/symbol tables) - Graph-prefixed table names: knowledge, tasks, epics, skills, code, docs, files - Hierarchy via edges (file→symbol, file→chunk, dir→file) instead of FK parent refs - Detail types simplified: edges: Edge[] instead of separate relation arrays - SearchResult.id changed to number (numeric IDs throughout) - All 22 tests passing
1 parent e4cfbe3 commit 5a4110c

21 files changed

Lines changed: 1068 additions & 118 deletions

File tree

src/store/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { SqliteStore } from './sqlite/store';
2+
export * from './types';

src/store/sqlite/lib/bigint.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** Convert BigInt (from better-sqlite3 safeIntegers) to number */
2+
export function num(v: bigint | number): number {
3+
return typeof v === 'bigint' ? Number(v) : v;
4+
}
5+
6+
/** Current timestamp in ms as BigInt (for SQLite INTEGER columns) */
7+
export function now(): bigint {
8+
return BigInt(Date.now());
9+
}

src/store/sqlite/lib/db.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Database from 'better-sqlite3';
2+
import sqliteVec from 'sqlite-vec';
3+
4+
/**
5+
* Open a SQLite database with all required extensions and pragmas.
6+
*
7+
* - defaultSafeIntegers(true) — required for sqlite-vec (BigInt rowids)
8+
* - sqlite-vec extension loaded
9+
* - WAL journal mode for concurrent reads
10+
* - foreign_keys enforced
11+
*/
12+
export function openDatabase(dbPath: string): Database.Database {
13+
const db = new Database(dbPath);
14+
db.defaultSafeIntegers(true);
15+
sqliteVec.load(db);
16+
db.pragma('journal_mode = WAL');
17+
db.pragma('foreign_keys = ON');
18+
return db;
19+
}

src/store/sqlite/lib/meta.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Database from 'better-sqlite3';
2+
3+
/**
4+
* Reusable MetaMixin implementation.
5+
* Keys are stored with a prefix: `${prefix}:${key}` (or just `${key}` if prefix is empty).
6+
*/
7+
export class MetaHelper {
8+
private stmtGet: Database.Statement;
9+
private stmtSet: Database.Statement;
10+
private stmtDel: Database.Statement;
11+
12+
constructor(db: Database.Database, private prefix: string) {
13+
this.stmtGet = db.prepare('SELECT value FROM meta WHERE key = ?');
14+
this.stmtSet = db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)');
15+
this.stmtDel = db.prepare('DELETE FROM meta WHERE key = ?');
16+
}
17+
18+
private key(k: string): string {
19+
return this.prefix ? `${this.prefix}:${k}` : k;
20+
}
21+
22+
getMeta(key: string): string | null {
23+
const row = this.stmtGet.get(this.key(key)) as { value: string } | undefined;
24+
return row ? row.value : null;
25+
}
26+
27+
setMeta(key: string, value: string): void {
28+
this.stmtSet.run(this.key(key), value);
29+
}
30+
31+
deleteMeta(key: string): void {
32+
this.stmtDel.run(this.key(key));
33+
}
34+
}

src/store/sqlite/lib/migrate.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Database from 'better-sqlite3';
2+
import { num } from './bigint';
3+
4+
export interface Migration {
5+
version: number;
6+
sql: string;
7+
}
8+
9+
/**
10+
* Run pending migrations against the database.
11+
* Uses PRAGMA user_version to track which migrations have been applied.
12+
* Each migration runs in its own transaction.
13+
*
14+
* @returns number of migrations applied
15+
*/
16+
export function runMigrations(db: Database.Database, migrations: Migration[]): number {
17+
const sorted = [...migrations].sort((a, b) => a.version - b.version);
18+
const current = num((db.pragma('user_version') as Array<{ user_version: bigint }>)[0].user_version);
19+
let applied = 0;
20+
21+
for (const m of sorted) {
22+
if (m.version > current) {
23+
db.transaction(() => {
24+
db.exec(m.sql);
25+
db.pragma(`user_version = ${m.version}`);
26+
})();
27+
applied++;
28+
}
29+
}
30+
31+
return applied;
32+
}
33+
34+
/** Get the current schema version */
35+
export function getSchemaVersion(db: Database.Database): number {
36+
return num((db.pragma('user_version') as Array<{ user_version: bigint }>)[0].user_version);
37+
}

src/store/sqlite/lib/search.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import Database from 'better-sqlite3';
2+
import type { SearchQuery, SearchResult } from '../../types';
3+
import { num } from './bigint';
4+
5+
const RRF_K = 60;
6+
7+
/**
8+
* Configuration for hybrid search on a specific entity table.
9+
* Each store provides its own config pointing to its FTS5 and vec0 tables.
10+
*/
11+
export interface SearchConfig {
12+
/** FTS5 virtual table name (e.g. 'notes_fts') */
13+
ftsTable: string;
14+
/** vec0 virtual table name (e.g. 'notes_vec') */
15+
vecTable: string;
16+
/** Parent table name (e.g. 'notes') for project_id filtering */
17+
parentTable: string;
18+
/** Column to join parent table to FTS rowid (usually 'id') */
19+
parentIdColumn: string;
20+
}
21+
22+
/**
23+
* Perform hybrid search combining FTS5 keyword search and vec0 vector search.
24+
*
25+
* - mode 'keyword': FTS5 only, embedding ignored
26+
* - mode 'vector': vec0 only, text ignored
27+
* - mode 'hybrid' (default): both, fused via Reciprocal Rank Fusion (RRF)
28+
*
29+
* vec0 cannot filter by project_id directly, so we overfetch and post-filter via JOIN.
30+
*/
31+
export function hybridSearch(
32+
db: Database.Database,
33+
config: SearchConfig,
34+
query: SearchQuery,
35+
projectId: number,
36+
): SearchResult[] {
37+
const mode = query.searchMode ?? 'hybrid';
38+
const topK = query.topK ?? 50;
39+
const maxResults = query.maxResults ?? 20;
40+
const minScore = query.minScore ?? 0;
41+
42+
let ftsRanked: Array<{ id: number; rn: number }> = [];
43+
let vecRanked: Array<{ id: number; rn: number }> = [];
44+
45+
// FTS5 keyword search
46+
if (mode !== 'vector' && query.text) {
47+
const rows = db.prepare(`
48+
SELECT p.${config.parentIdColumn} AS id, ROW_NUMBER() OVER (ORDER BY rank) AS rn
49+
FROM ${config.ftsTable} fts
50+
JOIN ${config.parentTable} p ON p.${config.parentIdColumn} = fts.rowid AND p.project_id = ?
51+
WHERE ${config.ftsTable} MATCH ?
52+
LIMIT ?
53+
`).all(projectId, query.text, topK) as Array<{ id: bigint; rn: bigint }>;
54+
55+
ftsRanked = rows.map(r => ({ id: num(r.id), rn: num(r.rn) }));
56+
}
57+
58+
// vec0 vector search
59+
if (mode !== 'keyword' && query.embedding && query.embedding.length > 0) {
60+
const embeddingBuf = Buffer.from(new Float32Array(query.embedding).buffer);
61+
// Overfetch x3 to compensate for cross-project rows filtered out by JOIN
62+
const vecK = topK * 3;
63+
64+
const rows = db.prepare(`
65+
SELECT v.rowid AS id, v.distance, ROW_NUMBER() OVER (ORDER BY v.distance) AS rn
66+
FROM ${config.vecTable} v
67+
JOIN ${config.parentTable} p ON p.${config.parentIdColumn} = v.rowid AND p.project_id = ?
68+
WHERE v.embedding MATCH ? AND v.k = ?
69+
`).all(projectId, embeddingBuf, vecK) as Array<{ id: bigint; distance: number; rn: bigint }>;
70+
71+
vecRanked = rows.slice(0, topK).map((r, i) => ({ id: num(r.id), rn: i + 1 }));
72+
}
73+
74+
// Single-mode: return directly with normalized scores
75+
if (mode === 'keyword') {
76+
return ftsRanked
77+
.map(r => ({ id: r.id, score: 1 / (RRF_K + r.rn) }))
78+
.filter(r => r.score >= minScore)
79+
.slice(0, maxResults);
80+
}
81+
82+
if (mode === 'vector') {
83+
return vecRanked
84+
.map(r => ({ id: r.id, score: 1 / (RRF_K + r.rn) }))
85+
.filter(r => r.score >= minScore)
86+
.slice(0, maxResults);
87+
}
88+
89+
// Hybrid: RRF fusion
90+
const scores = new Map<number, number>();
91+
for (const r of ftsRanked) {
92+
scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (RRF_K + r.rn));
93+
}
94+
for (const r of vecRanked) {
95+
scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (RRF_K + r.rn));
96+
}
97+
98+
return [...scores.entries()]
99+
.map(([id, score]) => ({ id, score }))
100+
.filter(r => r.score >= minScore)
101+
.sort((a, b) => b.score - a.score)
102+
.slice(0, maxResults);
103+
}

0 commit comments

Comments
 (0)