Skip to content

Commit 6c187c7

Browse files
committed
fix(store): N+1 attachments, FTS5 escaping, epic progress, orphan tags, dead columns
- Fix N+1 attachment queries in list() for knowledge, tasks, epics, skills - Add nextOrder() to epics (was hardcoded 1000) - Scope LEFT JOIN subqueries by project_id in docs/code listFiles - Escape FTS5 query text (preserve AND/OR/NOT/NEAR operators) - Epic progress: exclude cancelled from total, only count done - Add kind='file' filter to files vector/hybrid search - Fix resolveEdges to handle duplicate symbol names (remove LIMIT 1) - Replace N+1 orphan tag cleanup with single DELETE...NOT EXISTS - Remove dead file_count column from schema and FileNode type
1 parent 998caf1 commit 6c187c7

11 files changed

Lines changed: 68 additions & 52 deletions

File tree

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@ export class EntityHelpers {
2020
this.db.prepare(`DELETE FROM edges WHERE project_id = ? AND to_graph = ? AND to_id = ? AND from_graph = 'tags' AND kind = 'tagged'`)
2121
.run(this.projectId, graph, entityId);
2222

23-
// Clean up orphaned tags (tags with no remaining edges)
24-
for (const old of oldTagIds) {
25-
const edgeCount = num((this.db.prepare(
26-
`SELECT COUNT(*) AS c FROM edges WHERE project_id = ? AND from_graph = 'tags' AND from_id = ? AND kind = 'tagged'`
27-
).get(this.projectId, num(old.from_id)) as { c: bigint }).c);
28-
if (edgeCount === 0) {
29-
this.db.prepare('DELETE FROM tags WHERE id = ? AND project_id = ?').run(num(old.from_id), this.projectId);
30-
}
23+
// Clean up orphaned tags in one query
24+
if (oldTagIds.length > 0) {
25+
const ids = oldTagIds.map(o => num(o.from_id));
26+
const ph = ids.map(() => '?').join(',');
27+
this.db.prepare(`
28+
DELETE FROM tags WHERE project_id = ? AND id IN (${ph})
29+
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'
31+
)
32+
`).run(this.projectId, ...ids);
3133
}
3234

3335
// Insert new tags

src/store/sqlite/lib/search.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ import { num } from './bigint';
44

55
const RRF_K = 60;
66

7+
/**
8+
* Escape user text for FTS5 MATCH — wrap each token in double quotes.
9+
* Preserves FTS5 operators (AND, OR, NOT, NEAR) when used explicitly.
10+
*/
11+
function ftsEscape(text: string): string {
12+
const FTS5_OPS = new Set(['AND', 'OR', 'NOT', 'NEAR']);
13+
return text
14+
.split(/\s+/)
15+
.filter(t => t.length > 0)
16+
.map(t => FTS5_OPS.has(t) ? t : `"${t.replace(/"/g, '""')}"`)
17+
.join(' ');
18+
}
19+
720
/**
821
* Configuration for hybrid search on a specific entity table.
922
* Each store provides its own config pointing to its FTS5 and vec0 tables.
@@ -54,7 +67,7 @@ export function hybridSearch(
5467
JOIN ${config.parentTable} p ON p.${config.parentIdColumn} = fts.rowid AND p.project_id = ? ${extraJoin}
5568
WHERE ${config.ftsTable} MATCH ?
5669
LIMIT ?
57-
`).all(projectId, query.text, topK) as Array<{ id: bigint; rn: bigint }>;
70+
`).all(projectId, ftsEscape(query.text), topK) as Array<{ id: bigint; rn: bigint }>;
5871

5972
ftsRanked = rows.map(r => ({ id: num(r.id), rn: num(r.rn) }));
6073
}

src/store/sqlite/migrations/v001.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,6 @@ CREATE TABLE files (
377377
language TEXT,
378378
mime_type TEXT,
379379
size INTEGER NOT NULL DEFAULT 0,
380-
file_count INTEGER NOT NULL DEFAULT 0,
381380
mtime INTEGER NOT NULL DEFAULT 0,
382381
UNIQUE(project_id, file_path)
383382
);

src/store/sqlite/stores/code.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,18 +149,20 @@ export class SqliteCodeStore implements CodeStore {
149149
// =========================================================================
150150

151151
resolveEdges(edges: Array<{ fromName: string; toName: string; kind: string }>): void {
152-
const findByName = this.db.prepare('SELECT id FROM code WHERE project_id = ? AND name = ? AND kind != \'file\' LIMIT 1');
152+
const findByName = this.db.prepare("SELECT id FROM code WHERE project_id = ? AND name = ? AND kind != 'file'");
153153
const insertEdge = this.db.prepare(`
154154
INSERT OR IGNORE INTO edges (project_id, from_graph, from_id, to_graph, to_id, kind)
155155
VALUES (?, 'code', ?, 'code', ?, ?)
156156
`);
157157

158158
const run = this.db.transaction(() => {
159159
for (const edge of edges) {
160-
const fromRow = findByName.get(this.projectId, edge.fromName) as { id: bigint } | undefined;
161-
const toRow = findByName.get(this.projectId, edge.toName) as { id: bigint } | undefined;
162-
if (fromRow && toRow) {
163-
insertEdge.run(this.projectId, num(fromRow.id), num(toRow.id), edge.kind);
160+
const fromRows = findByName.all(this.projectId, edge.fromName) as Array<{ id: bigint }>;
161+
const toRows = findByName.all(this.projectId, edge.toName) as Array<{ id: bigint }>;
162+
for (const fromRow of fromRows) {
163+
for (const toRow of toRows) {
164+
insertEdge.run(this.projectId, num(fromRow.id), num(toRow.id), edge.kind);
165+
}
164166
}
165167
}
166168
});
@@ -195,11 +197,11 @@ export class SqliteCodeStore implements CodeStore {
195197
COALESCE(s.cnt, 0) AS symbol_count
196198
FROM code f
197199
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+
SELECT file_id, COUNT(*) AS cnt FROM code WHERE project_id = ? AND kind != 'file' GROUP BY file_id
201+
) s ON s.file_id = f.file_id
200202
WHERE ${where}
201203
ORDER BY f.file_id ASC LIMIT ? OFFSET ?
202-
`).all(...params, limit, offset) as Array<Record<string, unknown>>;
204+
`).all(this.projectId, ...params, limit, offset) as Array<Record<string, unknown>>;
203205

204206
const total = num((this.db.prepare(`SELECT COUNT(*) AS c FROM code f WHERE ${where}`).get(...params) as { c: bigint }).c);
205207

src/store/sqlite/stores/docs.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,11 @@ export class SqliteDocsStore implements DocsStore {
182182
COALESCE(ch.cnt, 0) AS chunk_count
183183
FROM docs d
184184
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
185+
SELECT file_id, COUNT(*) AS cnt FROM docs WHERE project_id = ? AND kind = 'chunk' GROUP BY file_id
186+
) ch ON ch.file_id = d.file_id
187187
WHERE ${where}
188188
ORDER BY d.file_id ASC LIMIT ? OFFSET ?
189-
`).all(...params, limit, offset) as Array<Record<string, unknown>>;
189+
`).all(this.projectId, ...params, limit, offset) as Array<Record<string, unknown>>;
190190

191191
const total = num((this.db.prepare(`SELECT COUNT(*) AS c FROM docs d WHERE ${where}`).get(...params) as { c: bigint }).c);
192192

src/store/sqlite/stores/epics.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
EpicListOptions,
1010
EpicStatus,
1111
TaskPriority,
12+
AttachmentMeta,
1213
SearchQuery,
1314
SearchResult,
1415
} from '../../types';
@@ -38,7 +39,7 @@ export class SqliteEpicsStore implements EpicsStore {
3839
// Helpers
3940
// =========================================================================
4041

41-
private toRecord(row: Record<string, unknown>, tags?: string[], progress?: { total: number; done: number }): EpicRecord {
42+
private toRecord(row: Record<string, unknown>, tags?: string[], progress?: { total: number; done: number }, attachments?: AttachmentMeta[]): EpicRecord {
4243
const id = num(row.id as bigint);
4344
return {
4445
id,
@@ -50,7 +51,7 @@ export class SqliteEpicsStore implements EpicsStore {
5051
tags: tags ?? this.helpers.fetchTags(GRAPH, id),
5152
order: num(row.order as bigint | number),
5253
progress: progress ?? this.computeProgress(id),
53-
attachments: this.helpers.fetchAttachments(GRAPH, id),
54+
attachments: attachments ?? this.helpers.fetchAttachments(GRAPH, id),
5455
createdAt: num(row.created_at as bigint),
5556
updatedAt: num(row.updated_at as bigint),
5657
version: num(row.version as bigint),
@@ -66,8 +67,8 @@ export class SqliteEpicsStore implements EpicsStore {
6667

6768
private computeProgress(epicId: number): { total: number; done: number } {
6869
const row = this.db.prepare(`
69-
SELECT COUNT(*) AS total,
70-
COALESCE(SUM(CASE WHEN t.status IN ('done', 'cancelled') THEN 1 ELSE 0 END), 0) AS done
70+
SELECT COALESCE(SUM(CASE WHEN t.status != 'cancelled' THEN 1 ELSE 0 END), 0) AS total,
71+
COALESCE(SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END), 0) AS done
7172
FROM edges e
7273
JOIN tasks t ON t.id = e.to_id AND t.project_id = e.project_id
7374
WHERE e.project_id = ? AND e.from_graph = 'epics' AND e.from_id = ? AND e.to_graph = 'tasks' AND e.kind = 'belongs_to'
@@ -82,8 +83,9 @@ export class SqliteEpicsStore implements EpicsStore {
8283

8384
const ph = epicIds.map(() => '?').join(',');
8485
const rows = this.db.prepare(`
85-
SELECT e.from_id AS epic_id, COUNT(*) AS total,
86-
COALESCE(SUM(CASE WHEN t.status IN ('done', 'cancelled') THEN 1 ELSE 0 END), 0) AS done
86+
SELECT e.from_id AS epic_id,
87+
COALESCE(SUM(CASE WHEN t.status != 'cancelled' THEN 1 ELSE 0 END), 0) AS total,
88+
COALESCE(SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END), 0) AS done
8789
FROM edges e
8890
JOIN tasks t ON t.id = e.to_id AND t.project_id = e.project_id
8991
WHERE e.project_id = ? AND e.from_graph = 'epics' AND e.to_graph = 'tasks' AND e.kind = 'belongs_to'
@@ -109,7 +111,7 @@ export class SqliteEpicsStore implements EpicsStore {
109111
const result = this.db.prepare(`
110112
INSERT INTO epics (project_id, slug, title, description, status, priority, "order", version, created_by_id, updated_by_id, created_at, updated_at)
111113
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
112-
`).run(this.projectId, slug, data.title, data.description ?? '', data.status ?? 'open', data.priority ?? 'medium', ORDER_GAP, authorId, authorId, ts, ts);
114+
`).run(this.projectId, slug, data.title, data.description ?? '', data.status ?? 'open', data.priority ?? 'medium', this.nextOrder(), authorId, authorId, ts, ts);
113115
const id = result.lastInsertRowid;
114116

115117
this.db.prepare('INSERT INTO epics_vec (rowid, embedding) VALUES (?, ?)').run(BigInt(id as number | bigint), Buffer.from(new Float32Array(embedding).buffer));
@@ -198,9 +200,7 @@ export class SqliteEpicsStore implements EpicsStore {
198200

199201
const results = rows.map(r => {
200202
const id = num(r.id as bigint);
201-
const record = this.toRecord(r, tagsMap.get(id), progressMap.get(id));
202-
record.attachments = attachMap.get(id) ?? [];
203-
return record;
203+
return this.toRecord(r, tagsMap.get(id), progressMap.get(id), attachMap.get(id) ?? []);
204204
});
205205

206206
return { results, total };
@@ -210,6 +210,11 @@ export class SqliteEpicsStore implements EpicsStore {
210210
return hybridSearch(this.db, SEARCH_CONFIG, query, this.projectId);
211211
}
212212

213+
private nextOrder(): number {
214+
const row = this.db.prepare(`SELECT MAX("order") AS m FROM epics WHERE project_id = ?`).get(this.projectId) as { m: bigint | null };
215+
return row.m ? num(row.m) + ORDER_GAP : ORDER_GAP;
216+
}
217+
213218
// =========================================================================
214219
// Link / Unlink tasks
215220
// =========================================================================

src/store/sqlite/stores/files.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ export class SqliteFilesStore implements FilesStore {
3434
language: (row.language as string | null),
3535
mimeType: (row.mime_type as string | null),
3636
size: num(row.size as bigint),
37-
fileCount: num(row.file_count as bigint),
3837
mtime: num(row.mtime as bigint),
3938
};
4039
}
@@ -58,8 +57,8 @@ export class SqliteFilesStore implements FilesStore {
5857
const dirName = path.basename(dirPath) || dirPath;
5958

6059
const result = this.db.prepare(`
61-
INSERT INTO files (project_id, kind, file_path, file_name, directory, extension, size, file_count, mtime)
62-
VALUES (?, 'directory', ?, ?, ?, '', 0, 0, 0)
60+
INSERT INTO files (project_id, kind, file_path, file_name, directory, extension, size, mtime)
61+
VALUES (?, 'directory', ?, ?, ?, '', 0, 0)
6362
`).run(this.projectId, dirPath, dirName, parentDir === dirPath ? '' : parentDir);
6463

6564
return num(result.lastInsertRowid as bigint);
@@ -233,7 +232,7 @@ export class SqliteFilesStore implements FilesStore {
233232
const rows = this.db.prepare(`
234233
SELECT v.rowid AS id, v.distance
235234
FROM files_vec v
236-
JOIN files p ON p.id = v.rowid AND p.project_id = ?
235+
JOIN files p ON p.id = v.rowid AND p.project_id = ? AND p.kind = 'file'
237236
WHERE v.embedding MATCH ? AND v.k = ?
238237
`).all(this.projectId, embeddingBuf, topK * 3) as Array<{ id: bigint; distance: number }>;
239238

@@ -261,7 +260,7 @@ export class SqliteFilesStore implements FilesStore {
261260
const rows = this.db.prepare(`
262261
SELECT v.rowid AS id, v.distance
263262
FROM files_vec v
264-
JOIN files p ON p.id = v.rowid AND p.project_id = ?
263+
JOIN files p ON p.id = v.rowid AND p.project_id = ? AND p.kind = 'file'
265264
WHERE v.embedding MATCH ? AND v.k = ?
266265
`).all(this.projectId, embeddingBuf, topK * 3) as Array<{ id: bigint; distance: number }>;
267266
rows.slice(0, topK).forEach((r, i) => vecResults.push({ id: num(r.id), rn: i + 1 }));

src/store/sqlite/stores/knowledge.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
NotePatch,
77
NoteRecord,
88
NoteDetail,
9+
AttachmentMeta,
910
PaginationOptions,
1011
SearchQuery,
1112
SearchResult,
@@ -51,15 +52,15 @@ export class SqliteKnowledgeStore implements KnowledgeStore {
5152
};
5253
}
5354

54-
private toRecord(row: Record<string, unknown>, tags?: string[]): NoteRecord {
55+
private toRecord(row: Record<string, unknown>, tags?: string[], attachments?: AttachmentMeta[]): NoteRecord {
5556
const id = num(row.id as bigint);
5657
return {
5758
id,
5859
slug: row.slug as string,
5960
title: row.title as string,
6061
content: row.content as string,
6162
tags: tags ?? this.helpers.fetchTags(GRAPH, id),
62-
attachments: this.helpers.fetchAttachments(GRAPH, id),
63+
attachments: attachments ?? this.helpers.fetchAttachments(GRAPH, id),
6364
createdAt: num(row.created_at as bigint),
6465
updatedAt: num(row.updated_at as bigint),
6566
version: num(row.version as bigint),
@@ -166,9 +167,7 @@ export class SqliteKnowledgeStore implements KnowledgeStore {
166167

167168
const results = rows.map(r => {
168169
const id = num(r.id as bigint);
169-
const record = this.toRecord(r, tagsMap.get(id));
170-
record.attachments = attachMap.get(id) ?? [];
171-
return record;
170+
return this.toRecord(r, tagsMap.get(id), attachMap.get(id) ?? []);
172171
});
173172

174173
return { results, total };

src/store/sqlite/stores/skills.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
SkillRecord,
88
SkillDetail,
99
SkillListOptions,
10+
AttachmentMeta,
1011
SearchQuery,
1112
SearchResult,
1213
} from '../../types';
@@ -35,7 +36,7 @@ export class SqliteSkillsStore implements SkillsStore {
3536
// CRUD
3637
// =========================================================================
3738

38-
private toRecord(row: Record<string, unknown>, tags?: string[]): SkillRecord {
39+
private toRecord(row: Record<string, unknown>, tags?: string[], attachments?: AttachmentMeta[]): SkillRecord {
3940
const id = num(row.id as bigint);
4041
return {
4142
id,
@@ -51,7 +52,7 @@ export class SqliteSkillsStore implements SkillsStore {
5152
confidence: num(row.confidence as number),
5253
usageCount: num(row.usage_count as bigint),
5354
lastUsedAt: row.last_used_at ? num(row.last_used_at as bigint) : null,
54-
attachments: this.helpers.fetchAttachments(GRAPH, id),
55+
attachments: attachments ?? this.helpers.fetchAttachments(GRAPH, id),
5556
createdAt: num(row.created_at as bigint),
5657
updatedAt: num(row.updated_at as bigint),
5758
version: num(row.version as bigint),
@@ -172,9 +173,7 @@ export class SqliteSkillsStore implements SkillsStore {
172173

173174
const results = rows.map(r => {
174175
const id = num(r.id as bigint);
175-
const record = this.toRecord(r, tagsMap.get(id));
176-
record.attachments = attachMap.get(id) ?? [];
177-
return record;
176+
return this.toRecord(r, tagsMap.get(id), attachMap.get(id) ?? []);
178177
});
179178

180179
return { results, total };

src/store/sqlite/stores/tasks.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
TaskListOptions,
1010
TaskStatus,
1111
TaskPriority,
12+
AttachmentMeta,
1213
SearchQuery,
1314
SearchResult,
1415
} from '../../types';
@@ -40,7 +41,7 @@ export class SqliteTasksStore implements TasksStore {
4041
// Task CRUD
4142
// =========================================================================
4243

43-
private toTaskRecord(row: Record<string, unknown>, tags?: string[]): TaskRecord {
44+
private toTaskRecord(row: Record<string, unknown>, tags?: string[], attachments?: AttachmentMeta[]): TaskRecord {
4445
const id = num(row.id as bigint);
4546
return {
4647
id,
@@ -55,7 +56,7 @@ export class SqliteTasksStore implements TasksStore {
5556
estimate: row.estimate ? num(row.estimate as bigint) : null,
5657
completedAt: row.completed_at ? num(row.completed_at as bigint) : null,
5758
assigneeId: row.assignee_id ? num(row.assignee_id as bigint) : null,
58-
attachments: this.helpers.fetchAttachments(GRAPH_TASKS, id),
59+
attachments: attachments ?? this.helpers.fetchAttachments(GRAPH_TASKS, id),
5960
createdAt: num(row.created_at as bigint),
6061
updatedAt: num(row.updated_at as bigint),
6162
version: num(row.version as bigint),
@@ -178,9 +179,7 @@ export class SqliteTasksStore implements TasksStore {
178179

179180
const results = rows.map(r => {
180181
const id = num(r.id as bigint);
181-
const record = this.toTaskRecord(r, tagsMap.get(id));
182-
record.attachments = attachMap.get(id) ?? [];
183-
return record;
182+
return this.toTaskRecord(r, tagsMap.get(id), attachMap.get(id) ?? []);
184183
});
185184

186185
return { results, total };

0 commit comments

Comments
 (0)