memory-journal-mcp 4.4.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/.github/workflows/codeql.yml +1 -6
  2. package/.github/workflows/docker-publish.yml +15 -49
  3. package/.github/workflows/lint-and-test.yml +1 -1
  4. package/.github/workflows/secrets-scanning.yml +4 -3
  5. package/.github/workflows/security-update.yml +3 -3
  6. package/CHANGELOG.md +213 -0
  7. package/CONTRIBUTING.md +132 -97
  8. package/DOCKER_README.md +184 -235
  9. package/Dockerfile +27 -24
  10. package/README.md +218 -190
  11. package/SECURITY.md +27 -35
  12. package/dist/cli.js +16 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/constants/ServerInstructions.d.ts +5 -1
  15. package/dist/constants/ServerInstructions.d.ts.map +1 -1
  16. package/dist/constants/ServerInstructions.js +133 -73
  17. package/dist/constants/ServerInstructions.js.map +1 -1
  18. package/dist/constants/icons.d.ts +2 -2
  19. package/dist/constants/icons.d.ts.map +1 -1
  20. package/dist/constants/icons.js +7 -6
  21. package/dist/constants/icons.js.map +1 -1
  22. package/dist/database/SqliteAdapter.d.ts +37 -24
  23. package/dist/database/SqliteAdapter.d.ts.map +1 -1
  24. package/dist/database/SqliteAdapter.js +319 -157
  25. package/dist/database/SqliteAdapter.js.map +1 -1
  26. package/dist/database/schema.d.ts +45 -0
  27. package/dist/database/schema.d.ts.map +1 -0
  28. package/dist/database/schema.js +92 -0
  29. package/dist/database/schema.js.map +1 -0
  30. package/dist/filtering/ToolFilter.d.ts +1 -1
  31. package/dist/filtering/ToolFilter.d.ts.map +1 -1
  32. package/dist/filtering/ToolFilter.js +13 -2
  33. package/dist/filtering/ToolFilter.js.map +1 -1
  34. package/dist/github/GitHubIntegration.d.ts.map +1 -1
  35. package/dist/github/GitHubIntegration.js +1 -3
  36. package/dist/github/GitHubIntegration.js.map +1 -1
  37. package/dist/handlers/prompts/github.d.ts +12 -0
  38. package/dist/handlers/prompts/github.d.ts.map +1 -0
  39. package/dist/handlers/prompts/github.js +178 -0
  40. package/dist/handlers/prompts/github.js.map +1 -0
  41. package/dist/handlers/prompts/index.d.ts +23 -2
  42. package/dist/handlers/prompts/index.d.ts.map +1 -1
  43. package/dist/handlers/prompts/index.js +7 -432
  44. package/dist/handlers/prompts/index.js.map +1 -1
  45. package/dist/handlers/prompts/workflow.d.ts +12 -0
  46. package/dist/handlers/prompts/workflow.d.ts.map +1 -0
  47. package/dist/handlers/prompts/workflow.js +277 -0
  48. package/dist/handlers/prompts/workflow.js.map +1 -0
  49. package/dist/handlers/resources/core.d.ts +11 -0
  50. package/dist/handlers/resources/core.d.ts.map +1 -0
  51. package/dist/handlers/resources/core.js +433 -0
  52. package/dist/handlers/resources/core.js.map +1 -0
  53. package/dist/handlers/resources/github.d.ts +11 -0
  54. package/dist/handlers/resources/github.d.ts.map +1 -0
  55. package/dist/handlers/resources/github.js +314 -0
  56. package/dist/handlers/resources/github.js.map +1 -0
  57. package/dist/handlers/resources/graph.d.ts +11 -0
  58. package/dist/handlers/resources/graph.d.ts.map +1 -0
  59. package/dist/handlers/resources/graph.js +204 -0
  60. package/dist/handlers/resources/graph.js.map +1 -0
  61. package/dist/handlers/resources/index.d.ts +5 -20
  62. package/dist/handlers/resources/index.d.ts.map +1 -1
  63. package/dist/handlers/resources/index.js +16 -1278
  64. package/dist/handlers/resources/index.js.map +1 -1
  65. package/dist/handlers/resources/shared.d.ts +60 -0
  66. package/dist/handlers/resources/shared.d.ts.map +1 -0
  67. package/dist/handlers/resources/shared.js +49 -0
  68. package/dist/handlers/resources/shared.js.map +1 -0
  69. package/dist/handlers/resources/team.d.ts +13 -0
  70. package/dist/handlers/resources/team.d.ts.map +1 -0
  71. package/dist/handlers/resources/team.js +119 -0
  72. package/dist/handlers/resources/team.js.map +1 -0
  73. package/dist/handlers/resources/templates.d.ts +13 -0
  74. package/dist/handlers/resources/templates.d.ts.map +1 -0
  75. package/dist/handlers/resources/templates.js +310 -0
  76. package/dist/handlers/resources/templates.js.map +1 -0
  77. package/dist/handlers/tools/admin.d.ts +8 -0
  78. package/dist/handlers/tools/admin.d.ts.map +1 -0
  79. package/dist/handlers/tools/admin.js +270 -0
  80. package/dist/handlers/tools/admin.js.map +1 -0
  81. package/dist/handlers/tools/analytics.d.ts +8 -0
  82. package/dist/handlers/tools/analytics.d.ts.map +1 -0
  83. package/dist/handlers/tools/analytics.js +256 -0
  84. package/dist/handlers/tools/analytics.js.map +1 -0
  85. package/dist/handlers/tools/backup.d.ts +8 -0
  86. package/dist/handlers/tools/backup.d.ts.map +1 -0
  87. package/dist/handlers/tools/backup.js +224 -0
  88. package/dist/handlers/tools/backup.js.map +1 -0
  89. package/dist/handlers/tools/core.d.ts +9 -0
  90. package/dist/handlers/tools/core.d.ts.map +1 -0
  91. package/dist/handlers/tools/core.js +326 -0
  92. package/dist/handlers/tools/core.js.map +1 -0
  93. package/dist/handlers/tools/export.d.ts +8 -0
  94. package/dist/handlers/tools/export.d.ts.map +1 -0
  95. package/dist/handlers/tools/export.js +89 -0
  96. package/dist/handlers/tools/export.js.map +1 -0
  97. package/dist/handlers/tools/github/helpers.d.ts +34 -0
  98. package/dist/handlers/tools/github/helpers.d.ts.map +1 -0
  99. package/dist/handlers/tools/github/helpers.js +52 -0
  100. package/dist/handlers/tools/github/helpers.js.map +1 -0
  101. package/dist/handlers/tools/github/insights-tools.d.ts +8 -0
  102. package/dist/handlers/tools/github/insights-tools.d.ts.map +1 -0
  103. package/dist/handlers/tools/github/insights-tools.js +104 -0
  104. package/dist/handlers/tools/github/insights-tools.js.map +1 -0
  105. package/dist/handlers/tools/github/issue-tools.d.ts +8 -0
  106. package/dist/handlers/tools/github/issue-tools.d.ts.map +1 -0
  107. package/dist/handlers/tools/github/issue-tools.js +359 -0
  108. package/dist/handlers/tools/github/issue-tools.js.map +1 -0
  109. package/dist/handlers/tools/github/kanban-tools.d.ts +8 -0
  110. package/dist/handlers/tools/github/kanban-tools.d.ts.map +1 -0
  111. package/dist/handlers/tools/github/kanban-tools.js +108 -0
  112. package/dist/handlers/tools/github/kanban-tools.js.map +1 -0
  113. package/dist/handlers/tools/github/milestone-tools.d.ts +9 -0
  114. package/dist/handlers/tools/github/milestone-tools.d.ts.map +1 -0
  115. package/dist/handlers/tools/github/milestone-tools.js +302 -0
  116. package/dist/handlers/tools/github/milestone-tools.js.map +1 -0
  117. package/dist/handlers/tools/github/mutation-tools.d.ts +12 -0
  118. package/dist/handlers/tools/github/mutation-tools.d.ts.map +1 -0
  119. package/dist/handlers/tools/github/mutation-tools.js +15 -0
  120. package/dist/handlers/tools/github/mutation-tools.js.map +1 -0
  121. package/dist/handlers/tools/github/read-tools.d.ts +8 -0
  122. package/dist/handlers/tools/github/read-tools.d.ts.map +1 -0
  123. package/dist/handlers/tools/github/read-tools.js +260 -0
  124. package/dist/handlers/tools/github/read-tools.js.map +1 -0
  125. package/dist/handlers/tools/github/schemas.d.ts +467 -0
  126. package/dist/handlers/tools/github/schemas.d.ts.map +1 -0
  127. package/dist/handlers/tools/github/schemas.js +335 -0
  128. package/dist/handlers/tools/github/schemas.js.map +1 -0
  129. package/dist/handlers/tools/github.d.ts +14 -0
  130. package/dist/handlers/tools/github.d.ts.map +1 -0
  131. package/dist/handlers/tools/github.js +28 -0
  132. package/dist/handlers/tools/github.js.map +1 -0
  133. package/dist/handlers/tools/index.d.ts +15 -20
  134. package/dist/handlers/tools/index.d.ts.map +1 -1
  135. package/dist/handlers/tools/index.js +117 -2909
  136. package/dist/handlers/tools/index.js.map +1 -1
  137. package/dist/handlers/tools/relationships.d.ts +8 -0
  138. package/dist/handlers/tools/relationships.d.ts.map +1 -0
  139. package/dist/handlers/tools/relationships.js +308 -0
  140. package/dist/handlers/tools/relationships.js.map +1 -0
  141. package/dist/handlers/tools/schemas.d.ts +108 -0
  142. package/dist/handlers/tools/schemas.d.ts.map +1 -0
  143. package/dist/handlers/tools/schemas.js +122 -0
  144. package/dist/handlers/tools/schemas.js.map +1 -0
  145. package/dist/handlers/tools/search.d.ts +8 -0
  146. package/dist/handlers/tools/search.d.ts.map +1 -0
  147. package/dist/handlers/tools/search.js +282 -0
  148. package/dist/handlers/tools/search.js.map +1 -0
  149. package/dist/handlers/tools/team.d.ts +11 -0
  150. package/dist/handlers/tools/team.d.ts.map +1 -0
  151. package/dist/handlers/tools/team.js +239 -0
  152. package/dist/handlers/tools/team.js.map +1 -0
  153. package/dist/server/McpServer.d.ts +4 -0
  154. package/dist/server/McpServer.d.ts.map +1 -1
  155. package/dist/server/McpServer.js +48 -297
  156. package/dist/server/McpServer.js.map +1 -1
  157. package/dist/server/Scheduler.d.ts +91 -0
  158. package/dist/server/Scheduler.d.ts.map +1 -0
  159. package/dist/server/Scheduler.js +201 -0
  160. package/dist/server/Scheduler.js.map +1 -0
  161. package/dist/transports/http.d.ts +66 -0
  162. package/dist/transports/http.d.ts.map +1 -0
  163. package/dist/transports/http.js +519 -0
  164. package/dist/transports/http.js.map +1 -0
  165. package/dist/types/entities.d.ts +101 -0
  166. package/dist/types/entities.d.ts.map +1 -0
  167. package/dist/types/entities.js +5 -0
  168. package/dist/types/entities.js.map +1 -0
  169. package/dist/types/filtering.d.ts +34 -0
  170. package/dist/types/filtering.d.ts.map +1 -0
  171. package/dist/types/filtering.js +5 -0
  172. package/dist/types/filtering.js.map +1 -0
  173. package/dist/types/github.d.ts +166 -0
  174. package/dist/types/github.d.ts.map +1 -0
  175. package/dist/types/github.js +5 -0
  176. package/dist/types/github.js.map +1 -0
  177. package/dist/types/index.d.ts +35 -292
  178. package/dist/types/index.d.ts.map +1 -1
  179. package/dist/types/index.js +2 -2
  180. package/dist/types/index.js.map +1 -1
  181. package/dist/utils/error-helpers.d.ts +37 -0
  182. package/dist/utils/error-helpers.d.ts.map +1 -0
  183. package/dist/utils/error-helpers.js +47 -0
  184. package/dist/utils/error-helpers.js.map +1 -0
  185. package/dist/utils/logger.d.ts.map +1 -1
  186. package/dist/utils/logger.js +6 -3
  187. package/dist/utils/logger.js.map +1 -1
  188. package/dist/utils/security-utils.d.ts +0 -21
  189. package/dist/utils/security-utils.d.ts.map +1 -1
  190. package/dist/utils/security-utils.js +0 -47
  191. package/dist/utils/security-utils.js.map +1 -1
  192. package/dist/vector/VectorSearchManager.d.ts.map +1 -1
  193. package/dist/vector/VectorSearchManager.js +9 -32
  194. package/dist/vector/VectorSearchManager.js.map +1 -1
  195. package/docker-compose.yml +11 -2
  196. package/hooks/README.md +107 -0
  197. package/hooks/cursor/hooks.json +10 -0
  198. package/hooks/cursor/memory-journal.mdc +22 -0
  199. package/hooks/cursor/session-end.sh +19 -0
  200. package/hooks/kilo-code/session-end-mode.json +11 -0
  201. package/hooks/kiro/session-end.md +13 -0
  202. package/mcp-config-example.json +1 -0
  203. package/package.json +11 -9
  204. package/playwright.config.ts +29 -0
  205. package/releases/v4.5.0.md +116 -0
  206. package/releases/v5.0.0.md +105 -0
  207. package/scripts/generate-server-instructions.ts +176 -0
  208. package/scripts/server-instructions-function-body.ts +77 -0
  209. package/server.json +3 -3
  210. package/src/cli.ts +45 -1
  211. package/src/constants/ServerInstructions.ts +133 -73
  212. package/src/constants/icons.ts +8 -7
  213. package/src/constants/server-instructions.md +268 -0
  214. package/src/database/SqliteAdapter.ts +358 -192
  215. package/src/database/schema.ts +125 -0
  216. package/src/filtering/ToolFilter.ts +13 -2
  217. package/src/github/GitHubIntegration.ts +1 -3
  218. package/src/handlers/prompts/github.ts +209 -0
  219. package/src/handlers/prompts/index.ts +10 -499
  220. package/src/handlers/prompts/workflow.ts +314 -0
  221. package/src/handlers/resources/core.ts +528 -0
  222. package/src/handlers/resources/github.ts +358 -0
  223. package/src/handlers/resources/graph.ts +254 -0
  224. package/src/handlers/resources/index.ts +23 -1570
  225. package/src/handlers/resources/shared.ts +103 -0
  226. package/src/handlers/resources/team.ts +133 -0
  227. package/src/handlers/resources/templates.ts +374 -0
  228. package/src/handlers/tools/admin.ts +285 -0
  229. package/src/handlers/tools/analytics.ts +301 -0
  230. package/src/handlers/tools/backup.ts +242 -0
  231. package/src/handlers/tools/core.ts +350 -0
  232. package/src/handlers/tools/export.ts +115 -0
  233. package/src/handlers/tools/github/helpers.ts +86 -0
  234. package/src/handlers/tools/github/insights-tools.ts +119 -0
  235. package/src/handlers/tools/github/issue-tools.ts +439 -0
  236. package/src/handlers/tools/github/kanban-tools.ts +134 -0
  237. package/src/handlers/tools/github/milestone-tools.ts +392 -0
  238. package/src/handlers/tools/github/mutation-tools.ts +17 -0
  239. package/src/handlers/tools/github/read-tools.ts +328 -0
  240. package/src/handlers/tools/github/schemas.ts +369 -0
  241. package/src/handlers/tools/github.ts +36 -0
  242. package/src/handlers/tools/index.ts +144 -3325
  243. package/src/handlers/tools/relationships.ts +358 -0
  244. package/src/handlers/tools/schemas.ts +132 -0
  245. package/src/handlers/tools/search.ts +343 -0
  246. package/src/handlers/tools/team.ts +273 -0
  247. package/src/server/McpServer.ts +63 -358
  248. package/src/server/Scheduler.ts +278 -0
  249. package/src/transports/http.ts +635 -0
  250. package/src/types/entities.ts +145 -0
  251. package/src/types/filtering.ts +54 -0
  252. package/src/types/github.ts +180 -0
  253. package/src/types/index.ts +67 -375
  254. package/src/utils/error-helpers.ts +52 -0
  255. package/src/utils/logger.ts +6 -3
  256. package/src/utils/security-utils.ts +0 -52
  257. package/src/vector/VectorSearchManager.ts +9 -33
  258. package/tests/constants/icons.test.ts +1 -2
  259. package/tests/constants/server-instructions.test.ts +30 -4
  260. package/tests/database/sqlite-adapter.test.ts +91 -7
  261. package/tests/e2e/auth.spec.ts +154 -0
  262. package/tests/e2e/health.spec.ts +63 -0
  263. package/tests/e2e/protocols.spec.ts +134 -0
  264. package/tests/e2e/resources.spec.ts +103 -0
  265. package/tests/e2e/scheduler.spec.ts +79 -0
  266. package/tests/e2e/security.spec.ts +91 -0
  267. package/tests/e2e/sessions.spec.ts +95 -0
  268. package/tests/e2e/stateless.spec.ts +121 -0
  269. package/tests/e2e/tools.spec.ts +111 -0
  270. package/tests/filtering/tool-filter.test.ts +46 -0
  271. package/tests/handlers/error-path-coverage.test.ts +324 -0
  272. package/tests/handlers/github-resource-handlers.test.ts +453 -0
  273. package/tests/handlers/github-tool-handlers.test.ts +899 -0
  274. package/tests/handlers/prompt-handler-coverage.test.ts +106 -0
  275. package/tests/handlers/prompt-handlers.test.ts +40 -0
  276. package/tests/handlers/resource-handler-coverage.test.ts +181 -0
  277. package/tests/handlers/resource-handlers.test.ts +33 -9
  278. package/tests/handlers/search-tool-handlers.test.ts +272 -0
  279. package/tests/handlers/targeted-gap-closure.test.ts +387 -0
  280. package/tests/handlers/team-resource-handlers.test.ts +156 -0
  281. package/tests/handlers/team-tool-handlers.test.ts +301 -0
  282. package/tests/handlers/tool-handler-coverage.test.ts +469 -0
  283. package/tests/handlers/tool-handlers.test.ts +2 -2
  284. package/tests/security/sql-injection.test.ts +3 -54
  285. package/tests/server/mcp-server.test.ts +503 -8
  286. package/tests/server/scheduler.test.ts +400 -0
  287. package/tests/transports/http-transport.test.ts +620 -0
  288. package/tests/vector/vector-search-manager.test.ts +60 -0
  289. package/vitest.config.ts +4 -1
  290. package/.memory-journal-team.db +0 -0
  291. package/.vscode/settings.json +0 -84
@@ -9,7 +9,11 @@ import initSqlJs, { type Database } from 'sql.js'
9
9
  import * as fs from 'node:fs'
10
10
  import * as path from 'node:path'
11
11
  import { logger } from '../utils/logger.js'
12
- import { validateDateFormatPattern } from '../utils/security-utils.js'
12
+ import {
13
+ validateDateFormatPattern,
14
+ sanitizeSearchQuery,
15
+ assertNoPathTraversal,
16
+ } from '../utils/security-utils.js'
13
17
  import type {
14
18
  JournalEntry,
15
19
  Tag,
@@ -20,104 +24,9 @@ import type {
20
24
  ImportanceBreakdown,
21
25
  ImportanceResult,
22
26
  } from '../types/index.js'
23
-
24
- // Schema SQL for initialization
25
- const SCHEMA_SQL = `
26
- -- Main journal entries table
27
- CREATE TABLE IF NOT EXISTS memory_journal (
28
- id INTEGER PRIMARY KEY AUTOINCREMENT,
29
- entry_type TEXT NOT NULL,
30
- content TEXT NOT NULL,
31
- timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
32
- is_personal INTEGER DEFAULT 1,
33
- significance_type TEXT,
34
- auto_context TEXT,
35
- deleted_at TEXT,
36
- -- GitHub integration fields
37
- project_number INTEGER,
38
- project_owner TEXT,
39
- issue_number INTEGER,
40
- issue_url TEXT,
41
- pr_number INTEGER,
42
- pr_url TEXT,
43
- pr_status TEXT,
44
- workflow_run_id INTEGER,
45
- workflow_name TEXT,
46
- workflow_status TEXT
47
- );
48
-
49
- -- Tags table
50
- CREATE TABLE IF NOT EXISTS tags (
51
- id INTEGER PRIMARY KEY AUTOINCREMENT,
52
- name TEXT UNIQUE NOT NULL,
53
- usage_count INTEGER DEFAULT 0
54
- );
55
-
56
- -- Junction table for entry-tag relationships
57
- CREATE TABLE IF NOT EXISTS entry_tags (
58
- entry_id INTEGER NOT NULL,
59
- tag_id INTEGER NOT NULL,
60
- PRIMARY KEY (entry_id, tag_id),
61
- FOREIGN KEY (entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE,
62
- FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
63
- );
64
-
65
- -- Relationships between entries
66
- CREATE TABLE IF NOT EXISTS relationships (
67
- id INTEGER PRIMARY KEY AUTOINCREMENT,
68
- from_entry_id INTEGER NOT NULL,
69
- to_entry_id INTEGER NOT NULL,
70
- relationship_type TEXT NOT NULL,
71
- description TEXT,
72
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
73
- FOREIGN KEY (from_entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE,
74
- FOREIGN KEY (to_entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE
75
- );
76
-
77
- -- Embeddings for vector search (stored as JSON for sql.js compatibility)
78
- CREATE TABLE IF NOT EXISTS embeddings (
79
- entry_id INTEGER PRIMARY KEY,
80
- embedding TEXT NOT NULL,
81
- model_name TEXT NOT NULL,
82
- FOREIGN KEY (entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE
83
- );
84
-
85
- -- Indexes for performance
86
- CREATE INDEX IF NOT EXISTS idx_memory_journal_timestamp ON memory_journal(timestamp);
87
- CREATE INDEX IF NOT EXISTS idx_memory_journal_type ON memory_journal(entry_type);
88
- CREATE INDEX IF NOT EXISTS idx_memory_journal_personal ON memory_journal(is_personal);
89
- CREATE INDEX IF NOT EXISTS idx_memory_journal_deleted ON memory_journal(deleted_at);
90
- CREATE INDEX IF NOT EXISTS idx_memory_journal_project ON memory_journal(project_number);
91
- CREATE INDEX IF NOT EXISTS idx_memory_journal_issue ON memory_journal(issue_number);
92
- CREATE INDEX IF NOT EXISTS idx_memory_journal_pr ON memory_journal(pr_number);
93
- CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
94
- CREATE INDEX IF NOT EXISTS idx_entry_tags_entry ON entry_tags(entry_id);
95
- CREATE INDEX IF NOT EXISTS idx_entry_tags_tag ON entry_tags(tag_id);
96
- CREATE INDEX IF NOT EXISTS idx_relationships_from ON relationships(from_entry_id);
97
- CREATE INDEX IF NOT EXISTS idx_relationships_to ON relationships(to_entry_id);
98
- `
99
-
100
- /**
101
- * Input for creating a new entry
102
- */
103
- export interface CreateEntryInput {
104
- content: string
105
- entryType?: EntryType
106
- tags?: string[]
107
- isPersonal?: boolean
108
- significanceType?: SignificanceType
109
- autoContext?: string
110
- projectNumber?: number
111
- projectOwner?: string
112
- issueNumber?: number
113
- issueUrl?: string
114
- prNumber?: number
115
- prUrl?: string
116
- prStatus?: 'draft' | 'open' | 'merged' | 'closed'
117
- workflowRunId?: number
118
- workflowName?: string
119
- workflowStatus?: 'queued' | 'in_progress' | 'completed'
120
- }
27
+ import { SCHEMA_SQL, TEAM_SCHEMA_SQL } from './schema.js'
28
+ export type { CreateEntryInput } from './schema.js'
29
+ import type { CreateEntryInput } from './schema.js'
121
30
 
122
31
  /**
123
32
  * SQLite Database Adapter for Memory Journal using sql.js
@@ -168,6 +77,14 @@ export class SqliteAdapter {
168
77
 
169
78
  // Initialize schema
170
79
  this.db.run(SCHEMA_SQL)
80
+
81
+ // Migrate existing databases that may lack newer columns
82
+ this.migrateSchema()
83
+
84
+ // Enable foreign key enforcement (SQLite disables by default)
85
+ // Required for ON DELETE CASCADE in entry_tags, relationships, embeddings
86
+ this.db.run('PRAGMA foreign_keys = ON')
87
+
171
88
  this.initialized = true
172
89
 
173
90
  logger.info('Database opened', { module: 'SqliteAdapter', dbPath: this.dbPath })
@@ -176,6 +93,176 @@ export class SqliteAdapter {
176
93
  this.flushSave()
177
94
  }
178
95
 
96
+ /**
97
+ * Migrate existing databases that may lack newer columns.
98
+ * Required because CREATE TABLE IF NOT EXISTS is a no-op on
99
+ * existing tables — columns added after initial creation are
100
+ * never added. This method checks for each expected column and
101
+ * runs ALTER TABLE as needed.
102
+ * Idempotent — safe to call on databases that already have all columns.
103
+ */
104
+ private migrateSchema(): void {
105
+ const db = this.ensureDb()
106
+ const tableInfo = db.exec('PRAGMA table_info(memory_journal)')
107
+ const columns = new Set((tableInfo[0]?.values ?? []).map((row) => String(row[1])))
108
+
109
+ const requiredColumns: { name: string; sql: string }[] = [
110
+ {
111
+ name: 'significance_type',
112
+ sql: 'ALTER TABLE memory_journal ADD COLUMN significance_type TEXT',
113
+ },
114
+ {
115
+ name: 'auto_context',
116
+ sql: 'ALTER TABLE memory_journal ADD COLUMN auto_context TEXT',
117
+ },
118
+ { name: 'deleted_at', sql: 'ALTER TABLE memory_journal ADD COLUMN deleted_at TEXT' },
119
+ {
120
+ name: 'project_number',
121
+ sql: 'ALTER TABLE memory_journal ADD COLUMN project_number INTEGER',
122
+ },
123
+ {
124
+ name: 'project_owner',
125
+ sql: 'ALTER TABLE memory_journal ADD COLUMN project_owner TEXT',
126
+ },
127
+ {
128
+ name: 'issue_number',
129
+ sql: 'ALTER TABLE memory_journal ADD COLUMN issue_number INTEGER',
130
+ },
131
+ { name: 'issue_url', sql: 'ALTER TABLE memory_journal ADD COLUMN issue_url TEXT' },
132
+ { name: 'pr_number', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_number INTEGER' },
133
+ { name: 'pr_url', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_url TEXT' },
134
+ { name: 'pr_status', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_status TEXT' },
135
+ {
136
+ name: 'workflow_run_id',
137
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_run_id INTEGER',
138
+ },
139
+ {
140
+ name: 'workflow_name',
141
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_name TEXT',
142
+ },
143
+ {
144
+ name: 'workflow_status',
145
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_status TEXT',
146
+ },
147
+ ]
148
+
149
+ const added: string[] = []
150
+ for (const col of requiredColumns) {
151
+ if (!columns.has(col.name)) {
152
+ db.run(col.sql)
153
+ added.push(col.name)
154
+ }
155
+ }
156
+
157
+ // Fix any tags with NULL usage_count (data repair from legacy DBs)
158
+ db.run('UPDATE tags SET usage_count = 0 WHERE usage_count IS NULL')
159
+
160
+ // Drop legacy FTS5 triggers from Python-era databases.
161
+ // sql.js WASM does not include FTS5; these triggers cause "no such module: fts5"
162
+ // on INSERT/UPDATE/DELETE operations. The TypeScript codebase uses LIKE queries.
163
+ // NOTE: We only drop triggers (regular objects). Dropping FTS5 virtual tables
164
+ // would also require the fts5 module, so we leave the inert shadow tables in place.
165
+ const dropped: string[] = []
166
+ const triggers = db.exec(
167
+ "SELECT name FROM sqlite_master WHERE type = 'trigger' AND sql LIKE '%fts%'"
168
+ )
169
+ // Validate trigger names before interpolating into DDL (defense-in-depth)
170
+ const SAFE_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/
171
+ for (const row of triggers[0]?.values ?? []) {
172
+ const name = String(row[0])
173
+ if (!SAFE_IDENTIFIER_RE.test(name)) {
174
+ logger.warning('Skipping trigger with unsafe name during migration', {
175
+ module: 'SqliteAdapter',
176
+ triggerName: name,
177
+ })
178
+ continue
179
+ }
180
+ db.run(`DROP TRIGGER IF EXISTS "${name}"`)
181
+ dropped.push(`trigger:${name}`)
182
+ }
183
+
184
+ const changes = [...added.map((c) => `column:${c}`), ...dropped]
185
+ if (changes.length > 0) {
186
+ this.flushSave()
187
+ logger.info('Schema migrated', {
188
+ module: 'SqliteAdapter',
189
+ dbPath: this.dbPath,
190
+ changes,
191
+ })
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Apply additional schema for team databases (adds author column).
197
+ * Also migrates legacy team DBs that may be missing columns from the
198
+ * current main schema (e.g. issue_number, pr_number added after v2).
199
+ * Idempotent — safe to call on databases that already have all columns.
200
+ */
201
+ applyTeamSchema(): void {
202
+ const db = this.ensureDb()
203
+ const tableInfo = db.exec('PRAGMA table_info(memory_journal)')
204
+ const columns = new Set((tableInfo[0]?.values ?? []).map((row) => String(row[1])))
205
+
206
+ // Columns required by the current schema that legacy team DBs may lack
207
+ const requiredColumns: { name: string; sql: string }[] = [
208
+ {
209
+ name: 'issue_number',
210
+ sql: 'ALTER TABLE memory_journal ADD COLUMN issue_number INTEGER',
211
+ },
212
+ { name: 'issue_url', sql: 'ALTER TABLE memory_journal ADD COLUMN issue_url TEXT' },
213
+ { name: 'pr_number', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_number INTEGER' },
214
+ { name: 'pr_url', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_url TEXT' },
215
+ { name: 'pr_status', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_status TEXT' },
216
+ {
217
+ name: 'workflow_run_id',
218
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_run_id INTEGER',
219
+ },
220
+ {
221
+ name: 'workflow_name',
222
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_name TEXT',
223
+ },
224
+ {
225
+ name: 'workflow_status',
226
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_status TEXT',
227
+ },
228
+ {
229
+ name: 'project_number',
230
+ sql: 'ALTER TABLE memory_journal ADD COLUMN project_number INTEGER',
231
+ },
232
+ {
233
+ name: 'project_owner',
234
+ sql: 'ALTER TABLE memory_journal ADD COLUMN project_owner TEXT',
235
+ },
236
+ {
237
+ name: 'significance_type',
238
+ sql: 'ALTER TABLE memory_journal ADD COLUMN significance_type TEXT',
239
+ },
240
+ {
241
+ name: 'auto_context',
242
+ sql: 'ALTER TABLE memory_journal ADD COLUMN auto_context TEXT',
243
+ },
244
+ { name: 'deleted_at', sql: 'ALTER TABLE memory_journal ADD COLUMN deleted_at TEXT' },
245
+ { name: 'author', sql: TEAM_SCHEMA_SQL.trim() },
246
+ ]
247
+
248
+ const added: string[] = []
249
+ for (const col of requiredColumns) {
250
+ if (!columns.has(col.name)) {
251
+ db.run(col.sql)
252
+ added.push(col.name)
253
+ }
254
+ }
255
+
256
+ if (added.length > 0) {
257
+ this.flushSave()
258
+ logger.info('Team schema migrated', {
259
+ module: 'SqliteAdapter',
260
+ dbPath: this.dbPath,
261
+ columnsAdded: added,
262
+ })
263
+ }
264
+ }
265
+
179
266
  /**
180
267
  * Schedule a debounced save to disk.
181
268
  * Batches rapid mutations into a single write after SAVE_DEBOUNCE_MS.
@@ -450,10 +537,7 @@ export class SqliteAdapter {
450
537
  const result = db.exec(sql, params)
451
538
  if (result.length === 0) return []
452
539
 
453
- const columns = result[0]?.columns ?? []
454
- return (result[0]?.values ?? []).map((values) =>
455
- this.rowToEntry(this.rowToObject(columns, values))
456
- )
540
+ return this.rowsToEntries(result[0]?.columns ?? [], result[0]?.values ?? [])
457
541
  }
458
542
 
459
543
  /**
@@ -468,10 +552,7 @@ export class SqliteAdapter {
468
552
  )
469
553
  if (result.length === 0) return []
470
554
 
471
- const columns = result[0]?.columns ?? []
472
- return (result[0]?.values ?? []).map((values) =>
473
- this.rowToEntry(this.rowToObject(columns, values))
474
- )
555
+ return this.rowsToEntries(result[0]?.columns ?? [], result[0]?.values ?? [])
475
556
  }
476
557
 
477
558
  /**
@@ -582,9 +663,9 @@ export class SqliteAdapter {
582
663
 
583
664
  let sql = `
584
665
  SELECT * FROM memory_journal
585
- WHERE deleted_at IS NULL AND content LIKE ?
666
+ WHERE deleted_at IS NULL AND content LIKE ? ESCAPE '\\'
586
667
  `
587
- const params: unknown[] = [`%${query}%`]
668
+ const params: unknown[] = [`%${sanitizeSearchQuery(query)}%`]
588
669
 
589
670
  if (isPersonal !== undefined) {
590
671
  sql += ` AND is_personal = ?`
@@ -609,10 +690,7 @@ export class SqliteAdapter {
609
690
  const result = db.exec(sql, params)
610
691
  if (result.length === 0) return []
611
692
 
612
- const columns = result[0]?.columns ?? []
613
- return (result[0]?.values ?? []).map((values) =>
614
- this.rowToEntry(this.rowToObject(columns, values))
615
- )
693
+ return this.rowsToEntries(result[0]?.columns ?? [], result[0]?.values ?? [])
616
694
  }
617
695
 
618
696
  /**
@@ -626,20 +704,35 @@ export class SqliteAdapter {
626
704
  tags?: string[]
627
705
  isPersonal?: boolean
628
706
  projectNumber?: number
707
+ limit?: number
629
708
  } = {}
630
709
  ): JournalEntry[] {
631
710
  const db = this.ensureDb()
632
711
  const { entryType, tags, isPersonal, projectNumber } = options
633
712
 
634
- let sql = `
635
- SELECT DISTINCT m.* FROM memory_journal m
636
- LEFT JOIN entry_tags et ON m.id = et.entry_id
637
- LEFT JOIN tags t ON et.tag_id = t.id
638
- WHERE m.deleted_at IS NULL
639
- AND m.timestamp >= ? AND m.timestamp <= ?
640
- `
713
+ let sql: string
641
714
  const params: unknown[] = [startDate, endDate + ' 23:59:59']
642
715
 
716
+ // Only JOIN tag tables when filtering by tags (avoids DISTINCT overhead)
717
+ if (tags && tags.length > 0) {
718
+ sql = `
719
+ SELECT DISTINCT m.* FROM memory_journal m
720
+ JOIN entry_tags et ON m.id = et.entry_id
721
+ JOIN tags t ON et.tag_id = t.id
722
+ WHERE m.deleted_at IS NULL
723
+ AND m.timestamp >= ? AND m.timestamp <= ?
724
+ `
725
+ const placeholders = tags.map(() => '?').join(',')
726
+ sql += ` AND t.name IN (${placeholders})`
727
+ params.push(...tags)
728
+ } else {
729
+ sql = `
730
+ SELECT * FROM memory_journal m
731
+ WHERE m.deleted_at IS NULL
732
+ AND m.timestamp >= ? AND m.timestamp <= ?
733
+ `
734
+ }
735
+
643
736
  if (entryType) {
644
737
  sql += ` AND m.entry_type = ?`
645
738
  params.push(entryType)
@@ -652,21 +745,14 @@ export class SqliteAdapter {
652
745
  sql += ` AND m.project_number = ?`
653
746
  params.push(projectNumber)
654
747
  }
655
- if (tags && tags.length > 0) {
656
- const placeholders = tags.map(() => '?').join(',')
657
- sql += ` AND t.name IN (${placeholders})`
658
- params.push(...tags)
659
- }
660
748
 
661
- sql += ` ORDER BY m.timestamp DESC`
749
+ sql += ` ORDER BY m.timestamp DESC LIMIT ?`
750
+ params.push(options.limit ?? 500)
662
751
 
663
752
  const result = db.exec(sql, params)
664
753
  if (result.length === 0) return []
665
754
 
666
- const columns = result[0]?.columns ?? []
667
- return (result[0]?.values ?? []).map((values) =>
668
- this.rowToEntry(this.rowToObject(columns, values))
669
- )
755
+ return this.rowsToEntries(result[0]?.columns ?? [], result[0]?.values ?? [])
670
756
  }
671
757
 
672
758
  // =========================================================================
@@ -677,25 +763,34 @@ export class SqliteAdapter {
677
763
  * Get or create tags and link to entry
678
764
  */
679
765
  private linkTagsToEntry(entryId: number, tagNames: string[]): void {
766
+ if (tagNames.length === 0) return
680
767
  const db = this.ensureDb()
681
768
 
682
- for (const tagName of tagNames) {
683
- // Insert or ignore tag
684
- db.run('INSERT OR IGNORE INTO tags (name, usage_count) VALUES (?, 0)', [tagName])
769
+ // Batch: insert all tags in one statement
770
+ const insertPlaceholders = tagNames.map(() => '(?, 0)').join(', ')
771
+ db.run(
772
+ `INSERT OR IGNORE INTO tags (name, usage_count) VALUES ${insertPlaceholders}`,
773
+ tagNames
774
+ )
685
775
 
686
- // Get tag ID
687
- const result = db.exec('SELECT id FROM tags WHERE name = ?', [tagName])
688
- const tagId = result[0]?.values[0]?.[0] as number | undefined
776
+ // Batch: fetch all tag IDs in one query
777
+ const selectPlaceholders = tagNames.map(() => '?').join(', ')
778
+ const result = db.exec(
779
+ `SELECT id, name FROM tags WHERE name IN (${selectPlaceholders})`,
780
+ tagNames
781
+ )
689
782
 
690
- if (tagId !== undefined) {
691
- // Link tag to entry
692
- db.run('INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?, ?)', [
693
- entryId,
694
- tagId,
695
- ])
696
- // Increment usage
697
- db.run('UPDATE tags SET usage_count = usage_count + 1 WHERE id = ?', [tagId])
698
- }
783
+ const tagIds: number[] = []
784
+ for (const row of result[0]?.values ?? []) {
785
+ const tagId = row[0] as number
786
+ tagIds.push(tagId)
787
+ // Link tag to entry
788
+ db.run('INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?, ?)', [
789
+ entryId,
790
+ tagId,
791
+ ])
792
+ // Increment usage
793
+ db.run('UPDATE tags SET usage_count = usage_count + 1 WHERE id = ?', [tagId])
699
794
  }
700
795
  }
701
796
 
@@ -722,7 +817,9 @@ export class SqliteAdapter {
722
817
  */
723
818
  listTags(): Tag[] {
724
819
  const db = this.ensureDb()
725
- const result = db.exec('SELECT * FROM tags WHERE usage_count > 0 ORDER BY usage_count DESC')
820
+ const result = db.exec(
821
+ 'SELECT id, name, COALESCE(usage_count, 0) as usage_count FROM tags WHERE COALESCE(usage_count, 0) > 0 ORDER BY usage_count DESC'
822
+ )
726
823
 
727
824
  if (result.length === 0) return []
728
825
 
@@ -918,29 +1015,27 @@ export class SqliteAdapter {
918
1015
  } {
919
1016
  const db = this.ensureDb()
920
1017
 
921
- // Total entries
922
- const totalResult = db.exec(`
923
- SELECT COUNT(*) as count FROM memory_journal WHERE deleted_at IS NULL
924
- `)
925
- const totalEntries = (totalResult[0]?.values[0]?.[0] as number) ?? 0
926
-
927
- // By type
928
- const byTypeResult = db.exec(`
1018
+ // Combined query 1: total entries + breakdown by type
1019
+ const combinedResult = db.exec(`
1020
+ SELECT COUNT(*) as count FROM memory_journal WHERE deleted_at IS NULL;
929
1021
  SELECT entry_type, COUNT(*) as count
930
1022
  FROM memory_journal
931
1023
  WHERE deleted_at IS NULL
932
1024
  GROUP BY entry_type
933
1025
  `)
1026
+ const totalEntries = (combinedResult[0]?.values[0]?.[0] as number) ?? 0
934
1027
  const entriesByType: Record<string, number> = {}
935
- for (const row of byTypeResult[0]?.values ?? []) {
1028
+ for (const row of combinedResult[1]?.values ?? []) {
936
1029
  entriesByType[row[0] as string] = row[1] as number
937
1030
  }
938
1031
 
939
- // By period - use validated date format pattern (defense-in-depth)
1032
+ // Combined query 2: period breakdown + decision density (using CASE)
940
1033
  const dateFormat = validateDateFormatPattern(groupBy)
941
-
942
- const byPeriodResult = db.exec(`
943
- SELECT strftime('${dateFormat}', timestamp) as period, COUNT(*) as count
1034
+ const periodResult = db.exec(`
1035
+ SELECT
1036
+ strftime('${dateFormat}', timestamp) as period,
1037
+ COUNT(*) as total_count,
1038
+ SUM(CASE WHEN significance_type IS NOT NULL THEN 1 ELSE 0 END) as significant_count
944
1039
  FROM memory_journal
945
1040
  WHERE deleted_at IS NULL
946
1041
  GROUP BY period
@@ -948,32 +1043,27 @@ export class SqliteAdapter {
948
1043
  LIMIT 52
949
1044
  `)
950
1045
 
951
- const entriesByPeriod = (byPeriodResult[0]?.values ?? []).map((v: unknown[]) => ({
1046
+ const entriesByPeriod = (periodResult[0]?.values ?? []).map((v: unknown[]) => ({
952
1047
  period: v[0] as string,
953
1048
  count: v[1] as number,
954
1049
  }))
955
1050
 
956
- // =========================================================================
957
- // Enhanced Analytics Metrics (v4.3.0)
958
- // =========================================================================
1051
+ const decisionDensity = (periodResult[0]?.values ?? [])
1052
+ .filter((v: unknown[]) => (v[2] as number) > 0)
1053
+ .map((v: unknown[]) => ({
1054
+ period: v[0] as string,
1055
+ significantCount: v[2] as number,
1056
+ }))
959
1057
 
960
- // Decision Density: significant entries per period
961
- const decisionDensityResult = db.exec(`
962
- SELECT strftime('${dateFormat}', timestamp) as period, COUNT(*) as count
963
- FROM memory_journal
964
- WHERE deleted_at IS NULL AND significance_type IS NOT NULL
965
- GROUP BY period
966
- ORDER BY period DESC
967
- LIMIT 52
1058
+ // Combined query 3: relationship counts + causal breakdown
1059
+ const relResult = db.exec(`
1060
+ SELECT COUNT(*) FROM relationships;
1061
+ SELECT relationship_type, COUNT(*) as count
1062
+ FROM relationships
1063
+ WHERE relationship_type IN ('blocked_by', 'resolved', 'caused')
1064
+ GROUP BY relationship_type
968
1065
  `)
969
- const decisionDensity = (decisionDensityResult[0]?.values ?? []).map((v: unknown[]) => ({
970
- period: v[0] as string,
971
- significantCount: v[1] as number,
972
- }))
973
-
974
- // Relationship Complexity: total relationships and avg per entry
975
- const relCountResult = db.exec(`SELECT COUNT(*) FROM relationships`)
976
- const totalRelationships = (relCountResult[0]?.values[0]?.[0] as number) ?? 0
1066
+ const totalRelationships = (relResult[0]?.values[0]?.[0] as number) ?? 0
977
1067
  const avgPerEntry = totalEntries > 0 ? totalRelationships / totalEntries : 0
978
1068
 
979
1069
  // Activity Trend: week-over-week growth
@@ -987,14 +1077,8 @@ export class SqliteAdapter {
987
1077
  : null
988
1078
 
989
1079
  // Causal Metrics: counts for causal relationship types
990
- const causalResult = db.exec(`
991
- SELECT relationship_type, COUNT(*) as count
992
- FROM relationships
993
- WHERE relationship_type IN ('blocked_by', 'resolved', 'caused')
994
- GROUP BY relationship_type
995
- `)
996
1080
  const causalMetrics = { blocked_by: 0, resolved: 0, caused: 0 }
997
- for (const row of causalResult[0]?.values ?? []) {
1081
+ for (const row of relResult[1]?.values ?? []) {
998
1082
  const relType = row[0] as 'blocked_by' | 'resolved' | 'caused'
999
1083
  causalMetrics[relType] = row[1] as number
1000
1084
  }
@@ -1037,6 +1121,11 @@ export class SqliteAdapter {
1037
1121
  const db = this.ensureDb()
1038
1122
  const backupsDir = this.getBackupsDir()
1039
1123
 
1124
+ // Validate backup name against path traversal before sanitization
1125
+ if (backupName) {
1126
+ assertNoPathTraversal(backupName)
1127
+ }
1128
+
1040
1129
  // Ensure backups directory exists
1041
1130
  if (!fs.existsSync(backupsDir)) {
1042
1131
  fs.mkdirSync(backupsDir, { recursive: true })
@@ -1118,7 +1207,7 @@ export class SqliteAdapter {
1118
1207
  deleteOldBackups(keepCount: number): { deleted: string[]; kept: number } {
1119
1208
  const backups = this.listBackups() // Already sorted newest-first
1120
1209
 
1121
- if (keepCount < 1) {
1210
+ if (keepCount < 1 || Number.isNaN(keepCount)) {
1122
1211
  throw new Error('keepCount must be at least 1')
1123
1212
  }
1124
1213
 
@@ -1155,9 +1244,7 @@ export class SqliteAdapter {
1155
1244
  newEntryCount: number
1156
1245
  }> {
1157
1246
  // Validate filename (prevent path traversal)
1158
- if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
1159
- throw new Error('Invalid backup filename: path separators not allowed')
1160
- }
1247
+ assertNoPathTraversal(filename)
1161
1248
 
1162
1249
  const backupsDir = this.getBackupsDir()
1163
1250
  const backupPath = path.join(backupsDir, filename)
@@ -1187,6 +1274,7 @@ export class SqliteAdapter {
1187
1274
  // Initialize new database from backup
1188
1275
  const SQL = await import('sql.js').then((m) => m.default())
1189
1276
  this.db = new SQL.Database(backupBuffer)
1277
+ this.db.run('PRAGMA foreign_keys = ON')
1190
1278
  this.initialized = true
1191
1279
 
1192
1280
  // Get new entry count
@@ -1300,7 +1388,9 @@ export class SqliteAdapter {
1300
1388
  }
1301
1389
 
1302
1390
  /**
1303
- * Convert database row to JournalEntry
1391
+ * Convert database row to JournalEntry.
1392
+ * Used for single-entry methods (getEntryById, getEntryByIdIncludeDeleted)
1393
+ * where the N+1 overhead of one tag query is negligible.
1304
1394
  */
1305
1395
  private rowToEntry(row: Record<string, unknown>): JournalEntry {
1306
1396
  const id = row['id'] as number
@@ -1329,7 +1419,83 @@ export class SqliteAdapter {
1329
1419
  }
1330
1420
 
1331
1421
  /**
1332
- * Get raw database for advanced operations
1422
+ * Convert multiple database rows to JournalEntry[] with batch tag fetching.
1423
+ * Uses a single IN (...) query to fetch all tags for all entries at once,
1424
+ * eliminating the N+1 query pattern of per-row getTagsForEntry calls.
1425
+ */
1426
+ private rowsToEntries(columns: string[], values: unknown[][]): JournalEntry[] {
1427
+ if (values.length === 0) return []
1428
+
1429
+ // Step 1: Convert all rows to objects
1430
+ const rows = values.map((v) => this.rowToObject(columns, v))
1431
+ const ids = rows.map((r) => r['id'] as number)
1432
+
1433
+ // Step 2: Batch-fetch all tags in one query
1434
+ const tagMap = this.batchGetTagsForEntries(ids)
1435
+
1436
+ // Step 3: Assemble entries using the pre-fetched tag map
1437
+ return rows.map((row) => {
1438
+ const id = row['id'] as number
1439
+ return {
1440
+ id,
1441
+ entryType: row['entry_type'] as EntryType,
1442
+ content: row['content'] as string,
1443
+ timestamp: row['timestamp'] as string,
1444
+ isPersonal: row['is_personal'] === 1,
1445
+ significanceType: row['significance_type'] as SignificanceType,
1446
+ autoContext: row['auto_context'] as string | null,
1447
+ deletedAt: row['deleted_at'] as string | null,
1448
+ tags: tagMap.get(id) ?? [],
1449
+ projectNumber: (row['project_number'] as number | null) ?? null,
1450
+ projectOwner: (row['project_owner'] as string | null) ?? null,
1451
+ issueNumber: (row['issue_number'] as number | null) ?? null,
1452
+ issueUrl: (row['issue_url'] as string | null) ?? null,
1453
+ prNumber: (row['pr_number'] as number | null) ?? null,
1454
+ prUrl: (row['pr_url'] as string | null) ?? null,
1455
+ prStatus: (row['pr_status'] as string | null) ?? null,
1456
+ workflowRunId: (row['workflow_run_id'] as number | null) ?? null,
1457
+ workflowName: (row['workflow_name'] as string | null) ?? null,
1458
+ workflowStatus: (row['workflow_status'] as string | null) ?? null,
1459
+ }
1460
+ })
1461
+ }
1462
+
1463
+ /**
1464
+ * Batch-fetch tags for multiple entry IDs in a single query.
1465
+ * Returns a Map<entryId, tagNames[]>.
1466
+ * Eliminates the N+1 query problem for multi-row result sets.
1467
+ */
1468
+ private batchGetTagsForEntries(ids: number[]): Map<number, string[]> {
1469
+ const tagMap = new Map<number, string[]>()
1470
+ if (ids.length === 0) return tagMap
1471
+
1472
+ const db = this.ensureDb()
1473
+ const placeholders = ids.map(() => '?').join(', ')
1474
+ const result = db.exec(
1475
+ `SELECT et.entry_id, t.name
1476
+ FROM entry_tags et
1477
+ JOIN tags t ON et.tag_id = t.id
1478
+ WHERE et.entry_id IN (${placeholders})`,
1479
+ ids
1480
+ )
1481
+
1482
+ for (const row of result[0]?.values ?? []) {
1483
+ const entryId = row[0] as number
1484
+ const tagName = row[1] as string
1485
+ const existing = tagMap.get(entryId)
1486
+ if (existing) {
1487
+ existing.push(tagName)
1488
+ } else {
1489
+ tagMap.set(entryId, [tagName])
1490
+ }
1491
+ }
1492
+
1493
+ return tagMap
1494
+ }
1495
+
1496
+ /**
1497
+ * Get raw sql.js Database handle for advanced queries.
1498
+ * @internal Callers MUST use parameterized queries — never concatenate user input into SQL.
1333
1499
  */
1334
1500
  getRawDb(): Database {
1335
1501
  return this.ensureDb()