Skip to content

Commit 998caf1

Browse files
committed
fix(store): BigInt consistency, indexes, CHECK constraints, safe JSON, tests
- Wrap vec0 insert rowid in BigInt() consistently across all stores - Add compound indexes on edges (project_id, to/from_graph, to/from_id) - Add updated_at indexes on knowledge, tasks, epics, skills - Add assignee_id index on tasks - Add CHECK constraints for status, priority, source, confidence in v001 - Add safeJson() helper for corrupted JSON fallback in skills/docs - Replace correlated subqueries with LEFT JOIN in code/docs listFiles - Add getUpdatedAt to EpicsStore interface + implementation - Add hybrid search, pagination edge case, null/empty input tests
1 parent 27adc84 commit 998caf1

13 files changed

Lines changed: 171 additions & 23 deletions

File tree

src/store/sqlite/lib/bigint.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,12 @@ export function num(v: bigint | number): number {
77
export function now(): bigint {
88
return BigInt(Date.now());
99
}
10+
11+
/** Safely parse JSON with a fallback for corrupted data */
12+
export function safeJson<T>(raw: string, fallback: T): T {
13+
try {
14+
return JSON.parse(raw) as T;
15+
} catch {
16+
return fallback;
17+
}
18+
}

src/store/sqlite/migrations/v001.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ CREATE TABLE edges (
6767
kind TEXT NOT NULL,
6868
PRIMARY KEY (project_id, from_graph, from_id, to_graph, to_id, kind)
6969
);
70-
CREATE INDEX idx_edges_target ON edges(to_graph, to_id);
71-
CREATE INDEX idx_edges_source ON edges(from_graph, from_id);
70+
CREATE INDEX idx_edges_target ON edges(project_id, to_graph, to_id);
71+
CREATE INDEX idx_edges_source ON edges(project_id, from_graph, from_id);
7272
7373
-- =============================================
7474
-- Knowledge (notes)
@@ -88,6 +88,7 @@ CREATE TABLE knowledge (
8888
UNIQUE(project_id, slug)
8989
);
9090
CREATE INDEX idx_knowledge_project ON knowledge(project_id);
91+
CREATE INDEX idx_knowledge_updated ON knowledge(project_id, updated_at);
9192
9293
CREATE VIRTUAL TABLE knowledge_fts USING fts5(
9394
title, content, content=knowledge, content_rowid=id
@@ -123,8 +124,8 @@ CREATE TABLE tasks (
123124
slug TEXT NOT NULL,
124125
title TEXT NOT NULL,
125126
description TEXT NOT NULL DEFAULT '',
126-
status TEXT NOT NULL DEFAULT 'backlog',
127-
priority TEXT NOT NULL DEFAULT 'medium',
127+
status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','todo','in_progress','review','done','cancelled')),
128+
priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('critical','high','medium','low')),
128129
"order" REAL NOT NULL DEFAULT 0,
129130
due_date INTEGER,
130131
estimate INTEGER,
@@ -139,6 +140,8 @@ CREATE TABLE tasks (
139140
);
140141
CREATE INDEX idx_tasks_project ON tasks(project_id);
141142
CREATE INDEX idx_tasks_status ON tasks(project_id, status, "order");
143+
CREATE INDEX idx_tasks_assignee ON tasks(assignee_id);
144+
CREATE INDEX idx_tasks_updated ON tasks(project_id, updated_at);
142145
143146
CREATE VIRTUAL TABLE tasks_fts USING fts5(
144147
title, description, content=tasks, content_rowid=id
@@ -174,8 +177,8 @@ CREATE TABLE epics (
174177
slug TEXT NOT NULL,
175178
title TEXT NOT NULL,
176179
description TEXT NOT NULL DEFAULT '',
177-
status TEXT NOT NULL DEFAULT 'open',
178-
priority TEXT NOT NULL DEFAULT 'medium',
180+
status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','in_progress','done','cancelled')),
181+
priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('critical','high','medium','low')),
179182
"order" REAL NOT NULL DEFAULT 0,
180183
version INTEGER NOT NULL DEFAULT 1,
181184
created_by_id INTEGER REFERENCES team_members(id) ON DELETE SET NULL,
@@ -185,6 +188,7 @@ CREATE TABLE epics (
185188
UNIQUE(project_id, slug)
186189
);
187190
CREATE INDEX idx_epics_project ON epics(project_id);
191+
CREATE INDEX idx_epics_updated ON epics(project_id, updated_at);
188192
189193
CREATE VIRTUAL TABLE epics_fts USING fts5(
190194
title, description, content=epics, content_rowid=id
@@ -224,8 +228,8 @@ CREATE TABLE skills (
224228
triggers_json TEXT NOT NULL DEFAULT '[]',
225229
input_hints_json TEXT NOT NULL DEFAULT '[]',
226230
file_patterns_json TEXT NOT NULL DEFAULT '[]',
227-
source TEXT NOT NULL DEFAULT 'user',
228-
confidence REAL NOT NULL DEFAULT 1.0,
231+
source TEXT NOT NULL DEFAULT 'user' CHECK(source IN ('user','learned')),
232+
confidence REAL NOT NULL DEFAULT 1.0 CHECK(confidence >= 0.0 AND confidence <= 1.0),
229233
usage_count INTEGER NOT NULL DEFAULT 0,
230234
last_used_at INTEGER,
231235
version INTEGER NOT NULL DEFAULT 1,
@@ -236,6 +240,7 @@ CREATE TABLE skills (
236240
UNIQUE(project_id, slug)
237241
);
238242
CREATE INDEX idx_skills_project ON skills(project_id);
243+
CREATE INDEX idx_skills_updated ON skills(project_id, updated_at);
239244
240245
CREATE VIRTUAL TABLE skills_fts USING fts5(
241246
title, description, content=skills, content_rowid=id

src/store/sqlite/stores/code.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,12 @@ export class SqliteCodeStore implements CodeStore {
192192
const where = conditions.join(' AND ');
193193
const rows = this.db.prepare(`
194194
SELECT f.id, f.file_id, f.language, f.mtime,
195-
(SELECT COUNT(*) FROM code c WHERE c.project_id = f.project_id AND c.file_id = f.file_id AND c.kind != 'file') AS symbol_count
196-
FROM code f WHERE ${where}
195+
COALESCE(s.cnt, 0) AS symbol_count
196+
FROM code f
197+
LEFT JOIN (
198+
SELECT project_id, file_id, COUNT(*) AS cnt FROM code WHERE kind != 'file' GROUP BY project_id, file_id
199+
) s ON s.project_id = f.project_id AND s.file_id = f.file_id
200+
WHERE ${where}
197201
ORDER BY f.file_id ASC LIMIT ? OFFSET ?
198202
`).all(...params, limit, offset) as Array<Record<string, unknown>>;
199203

src/store/sqlite/stores/docs.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
PaginationOptions,
99
} from '../../types';
1010
import { MetaHelper } from '../lib/meta';
11-
import { num } from '../lib/bigint';
11+
import { num, safeJson } from '../lib/bigint';
1212
import { hybridSearch, SearchConfig } from '../lib/search';
1313

1414
const GRAPH = 'docs';
@@ -37,7 +37,7 @@ export class SqliteDocsStore implements DocsStore {
3737
content: row.content as string,
3838
level: num(row.level as bigint),
3939
language: (row.language as string | null) ?? undefined,
40-
symbols: JSON.parse(row.symbols_json as string),
40+
symbols: safeJson<string[]>(row.symbols_json as string, []),
4141
mtime: num(row.mtime as bigint),
4242
};
4343
}
@@ -179,8 +179,12 @@ export class SqliteDocsStore implements DocsStore {
179179
const where = conditions.join(' AND ');
180180
const rows = this.db.prepare(`
181181
SELECT d.id, d.file_id, d.title, d.mtime,
182-
(SELECT COUNT(*) FROM docs c WHERE c.project_id = d.project_id AND c.file_id = d.file_id AND c.kind = 'chunk') AS chunk_count
183-
FROM docs d WHERE ${where}
182+
COALESCE(ch.cnt, 0) AS chunk_count
183+
FROM docs d
184+
LEFT JOIN (
185+
SELECT project_id, file_id, COUNT(*) AS cnt FROM docs WHERE kind = 'chunk' GROUP BY project_id, file_id
186+
) ch ON ch.project_id = d.project_id AND ch.file_id = d.file_id
187+
WHERE ${where}
184188
ORDER BY d.file_id ASC LIMIT ? OFFSET ?
185189
`).all(...params, limit, offset) as Array<Record<string, unknown>>;
186190

src/store/sqlite/stores/epics.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export class SqliteEpicsStore implements EpicsStore {
112112
`).run(this.projectId, slug, data.title, data.description ?? '', data.status ?? 'open', data.priority ?? 'medium', ORDER_GAP, authorId, authorId, ts, ts);
113113
const id = result.lastInsertRowid;
114114

115-
this.db.prepare('INSERT INTO epics_vec (rowid, embedding) VALUES (?, ?)').run(id, Buffer.from(new Float32Array(embedding).buffer));
115+
this.db.prepare('INSERT INTO epics_vec (rowid, embedding) VALUES (?, ?)').run(BigInt(id as number | bigint), Buffer.from(new Float32Array(embedding).buffer));
116116

117117
if (data.tags && data.tags.length > 0) this.helpers.setTags(GRAPH, num(id), data.tags);
118118

@@ -235,6 +235,15 @@ export class SqliteEpicsStore implements EpicsStore {
235235
`).run(this.projectId, epicId, taskId);
236236
}
237237

238+
// =========================================================================
239+
// Timestamps
240+
// =========================================================================
241+
242+
getUpdatedAt(epicId: number): number | null {
243+
const row = this.db.prepare('SELECT updated_at FROM epics WHERE id = ? AND project_id = ?').get(epicId, this.projectId) as { updated_at: bigint } | undefined;
244+
return row ? num(row.updated_at) : null;
245+
}
246+
238247
// =========================================================================
239248
// Meta
240249
// =========================================================================

src/store/sqlite/stores/knowledge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export class SqliteKnowledgeStore implements KnowledgeStore {
8181
const result = this.stmts.insert.run(this.projectId, slug, data.title, data.content, authorId, authorId, ts, ts);
8282
const id = result.lastInsertRowid;
8383

84-
this.stmts.insertVec.run(id, Buffer.from(new Float32Array(embedding).buffer));
84+
this.stmts.insertVec.run(BigInt(id as number | bigint), Buffer.from(new Float32Array(embedding).buffer));
8585

8686
if (data.tags && data.tags.length > 0) {
8787
this.helpers.setTags(GRAPH, num(id), data.tags);

src/store/sqlite/stores/skills.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
import { VersionConflictError } from '../../types';
1414
import { MetaHelper } from '../lib/meta';
1515
import { EntityHelpers } from '../lib/entity-helpers';
16-
import { num, now } from '../lib/bigint';
16+
import { num, now, safeJson } from '../lib/bigint';
1717
import { hybridSearch, SearchConfig } from '../lib/search';
1818

1919
const GRAPH = 'skills';
@@ -42,10 +42,10 @@ export class SqliteSkillsStore implements SkillsStore {
4242
slug: row.slug as string,
4343
title: row.title as string,
4444
description: row.description as string,
45-
steps: JSON.parse(row.steps_json as string),
46-
triggers: JSON.parse(row.triggers_json as string),
47-
inputHints: JSON.parse(row.input_hints_json as string),
48-
filePatterns: JSON.parse(row.file_patterns_json as string),
45+
steps: safeJson<string[]>(row.steps_json as string, []),
46+
triggers: safeJson<string[]>(row.triggers_json as string, []),
47+
inputHints: safeJson<string[]>(row.input_hints_json as string, []),
48+
filePatterns: safeJson<string[]>(row.file_patterns_json as string, []),
4949
tags: tags ?? this.helpers.fetchTags(GRAPH, id),
5050
source: row.source as SkillRecord['source'],
5151
confidence: num(row.confidence as number),
@@ -84,7 +84,7 @@ export class SqliteSkillsStore implements SkillsStore {
8484
);
8585
const id = result.lastInsertRowid;
8686

87-
this.db.prepare('INSERT INTO skills_vec (rowid, embedding) VALUES (?, ?)').run(id, Buffer.from(new Float32Array(embedding).buffer));
87+
this.db.prepare('INSERT INTO skills_vec (rowid, embedding) VALUES (?, ?)').run(BigInt(id as number | bigint), Buffer.from(new Float32Array(embedding).buffer));
8888

8989
if (data.tags && data.tags.length > 0) this.helpers.setTags(GRAPH, num(id), data.tags);
9090

src/store/sqlite/stores/tasks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export class SqliteTasksStore implements TasksStore {
8686
);
8787
const id = result.lastInsertRowid;
8888

89-
this.db.prepare('INSERT INTO tasks_vec (rowid, embedding) VALUES (?, ?)').run(id, Buffer.from(new Float32Array(embedding).buffer));
89+
this.db.prepare('INSERT INTO tasks_vec (rowid, embedding) VALUES (?, ?)').run(BigInt(id as number | bigint), Buffer.from(new Float32Array(embedding).buffer));
9090

9191
if (data.tags && data.tags.length > 0) this.helpers.setTags(GRAPH_TASKS, num(id), data.tags);
9292

src/store/types/epics.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,7 @@ export interface EpicsStore extends MetaMixin {
6464
search(query: SearchQuery): SearchResult[];
6565
linkTask(epicId: number, taskId: number): void;
6666
unlinkTask(epicId: number, taskId: number): void;
67+
68+
// --- Timestamps ---
69+
getUpdatedAt(epicId: number): number | null;
6770
}

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,40 @@ describe('EpicsStore contract', () => {
144144
expect(epics.get(epic.id)!.progress.total).toBe(1);
145145
});
146146

147+
// --- Hybrid search ---
148+
149+
it('hybrid search combines keyword and vector', () => {
150+
epics.create({ title: 'MVP Release', description: 'Ship it' }, seedEmbedding(1));
151+
epics.create({ title: 'Tech Debt', description: 'Cleanup' }, seedEmbedding(2));
152+
153+
const results = epics.search({ text: 'MVP', embedding: seedEmbedding(1), searchMode: 'hybrid' });
154+
expect(results.length).toBeGreaterThan(0);
155+
});
156+
157+
// --- Pagination edge cases ---
158+
159+
it('list with offset beyond total returns empty', () => {
160+
epics.create({ title: 'Only', description: '' }, seedEmbedding(1));
161+
const result = epics.list({ offset: 100 });
162+
expect(result.results).toEqual([]);
163+
expect(result.total).toBe(1);
164+
});
165+
166+
// --- Null/empty input ---
167+
168+
it('creates epic with empty description', () => {
169+
const epic = epics.create({ title: 'Minimal', description: '' }, seedEmbedding(1));
170+
expect(epic.description).toBe('');
171+
});
172+
173+
// --- Timestamps ---
174+
175+
it('getUpdatedAt works', () => {
176+
const epic = epics.create({ title: 'T', description: '' }, seedEmbedding(1));
177+
expect(epics.getUpdatedAt(epic.id)).toBe(epic.updatedAt);
178+
expect(epics.getUpdatedAt(999)).toBeNull();
179+
});
180+
147181
// --- Meta ---
148182

149183
it('meta is scoped', () => {

0 commit comments

Comments
 (0)