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
@@ -8,82 +8,8 @@ import initSqlJs from 'sql.js';
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
10
  import { logger } from '../utils/logger.js';
11
- import { validateDateFormatPattern } from '../utils/security-utils.js';
12
- // Schema SQL for initialization
13
- const SCHEMA_SQL = `
14
- -- Main journal entries table
15
- CREATE TABLE IF NOT EXISTS memory_journal (
16
- id INTEGER PRIMARY KEY AUTOINCREMENT,
17
- entry_type TEXT NOT NULL,
18
- content TEXT NOT NULL,
19
- timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
20
- is_personal INTEGER DEFAULT 1,
21
- significance_type TEXT,
22
- auto_context TEXT,
23
- deleted_at TEXT,
24
- -- GitHub integration fields
25
- project_number INTEGER,
26
- project_owner TEXT,
27
- issue_number INTEGER,
28
- issue_url TEXT,
29
- pr_number INTEGER,
30
- pr_url TEXT,
31
- pr_status TEXT,
32
- workflow_run_id INTEGER,
33
- workflow_name TEXT,
34
- workflow_status TEXT
35
- );
36
-
37
- -- Tags table
38
- CREATE TABLE IF NOT EXISTS tags (
39
- id INTEGER PRIMARY KEY AUTOINCREMENT,
40
- name TEXT UNIQUE NOT NULL,
41
- usage_count INTEGER DEFAULT 0
42
- );
43
-
44
- -- Junction table for entry-tag relationships
45
- CREATE TABLE IF NOT EXISTS entry_tags (
46
- entry_id INTEGER NOT NULL,
47
- tag_id INTEGER NOT NULL,
48
- PRIMARY KEY (entry_id, tag_id),
49
- FOREIGN KEY (entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE,
50
- FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
51
- );
52
-
53
- -- Relationships between entries
54
- CREATE TABLE IF NOT EXISTS relationships (
55
- id INTEGER PRIMARY KEY AUTOINCREMENT,
56
- from_entry_id INTEGER NOT NULL,
57
- to_entry_id INTEGER NOT NULL,
58
- relationship_type TEXT NOT NULL,
59
- description TEXT,
60
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
61
- FOREIGN KEY (from_entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE,
62
- FOREIGN KEY (to_entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE
63
- );
64
-
65
- -- Embeddings for vector search (stored as JSON for sql.js compatibility)
66
- CREATE TABLE IF NOT EXISTS embeddings (
67
- entry_id INTEGER PRIMARY KEY,
68
- embedding TEXT NOT NULL,
69
- model_name TEXT NOT NULL,
70
- FOREIGN KEY (entry_id) REFERENCES memory_journal(id) ON DELETE CASCADE
71
- );
72
-
73
- -- Indexes for performance
74
- CREATE INDEX IF NOT EXISTS idx_memory_journal_timestamp ON memory_journal(timestamp);
75
- CREATE INDEX IF NOT EXISTS idx_memory_journal_type ON memory_journal(entry_type);
76
- CREATE INDEX IF NOT EXISTS idx_memory_journal_personal ON memory_journal(is_personal);
77
- CREATE INDEX IF NOT EXISTS idx_memory_journal_deleted ON memory_journal(deleted_at);
78
- CREATE INDEX IF NOT EXISTS idx_memory_journal_project ON memory_journal(project_number);
79
- CREATE INDEX IF NOT EXISTS idx_memory_journal_issue ON memory_journal(issue_number);
80
- CREATE INDEX IF NOT EXISTS idx_memory_journal_pr ON memory_journal(pr_number);
81
- CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
82
- CREATE INDEX IF NOT EXISTS idx_entry_tags_entry ON entry_tags(entry_id);
83
- CREATE INDEX IF NOT EXISTS idx_entry_tags_tag ON entry_tags(tag_id);
84
- CREATE INDEX IF NOT EXISTS idx_relationships_from ON relationships(from_entry_id);
85
- CREATE INDEX IF NOT EXISTS idx_relationships_to ON relationships(to_entry_id);
86
- `;
11
+ import { validateDateFormatPattern, sanitizeSearchQuery, assertNoPathTraversal, } from '../utils/security-utils.js';
12
+ import { SCHEMA_SQL, TEAM_SCHEMA_SQL } from './schema.js';
87
13
  /**
88
14
  * SQLite Database Adapter for Memory Journal using sql.js
89
15
  */
@@ -128,11 +54,174 @@ export class SqliteAdapter {
128
54
  }
129
55
  // Initialize schema
130
56
  this.db.run(SCHEMA_SQL);
57
+ // Migrate existing databases that may lack newer columns
58
+ this.migrateSchema();
59
+ // Enable foreign key enforcement (SQLite disables by default)
60
+ // Required for ON DELETE CASCADE in entry_tags, relationships, embeddings
61
+ this.db.run('PRAGMA foreign_keys = ON');
131
62
  this.initialized = true;
132
63
  logger.info('Database opened', { module: 'SqliteAdapter', dbPath: this.dbPath });
133
64
  // Immediate flush after initialization to persist schema
134
65
  this.flushSave();
135
66
  }
67
+ /**
68
+ * Migrate existing databases that may lack newer columns.
69
+ * Required because CREATE TABLE IF NOT EXISTS is a no-op on
70
+ * existing tables — columns added after initial creation are
71
+ * never added. This method checks for each expected column and
72
+ * runs ALTER TABLE as needed.
73
+ * Idempotent — safe to call on databases that already have all columns.
74
+ */
75
+ migrateSchema() {
76
+ const db = this.ensureDb();
77
+ const tableInfo = db.exec('PRAGMA table_info(memory_journal)');
78
+ const columns = new Set((tableInfo[0]?.values ?? []).map((row) => String(row[1])));
79
+ const requiredColumns = [
80
+ {
81
+ name: 'significance_type',
82
+ sql: 'ALTER TABLE memory_journal ADD COLUMN significance_type TEXT',
83
+ },
84
+ {
85
+ name: 'auto_context',
86
+ sql: 'ALTER TABLE memory_journal ADD COLUMN auto_context TEXT',
87
+ },
88
+ { name: 'deleted_at', sql: 'ALTER TABLE memory_journal ADD COLUMN deleted_at TEXT' },
89
+ {
90
+ name: 'project_number',
91
+ sql: 'ALTER TABLE memory_journal ADD COLUMN project_number INTEGER',
92
+ },
93
+ {
94
+ name: 'project_owner',
95
+ sql: 'ALTER TABLE memory_journal ADD COLUMN project_owner TEXT',
96
+ },
97
+ {
98
+ name: 'issue_number',
99
+ sql: 'ALTER TABLE memory_journal ADD COLUMN issue_number INTEGER',
100
+ },
101
+ { name: 'issue_url', sql: 'ALTER TABLE memory_journal ADD COLUMN issue_url TEXT' },
102
+ { name: 'pr_number', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_number INTEGER' },
103
+ { name: 'pr_url', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_url TEXT' },
104
+ { name: 'pr_status', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_status TEXT' },
105
+ {
106
+ name: 'workflow_run_id',
107
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_run_id INTEGER',
108
+ },
109
+ {
110
+ name: 'workflow_name',
111
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_name TEXT',
112
+ },
113
+ {
114
+ name: 'workflow_status',
115
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_status TEXT',
116
+ },
117
+ ];
118
+ const added = [];
119
+ for (const col of requiredColumns) {
120
+ if (!columns.has(col.name)) {
121
+ db.run(col.sql);
122
+ added.push(col.name);
123
+ }
124
+ }
125
+ // Fix any tags with NULL usage_count (data repair from legacy DBs)
126
+ db.run('UPDATE tags SET usage_count = 0 WHERE usage_count IS NULL');
127
+ // Drop legacy FTS5 triggers from Python-era databases.
128
+ // sql.js WASM does not include FTS5; these triggers cause "no such module: fts5"
129
+ // on INSERT/UPDATE/DELETE operations. The TypeScript codebase uses LIKE queries.
130
+ // NOTE: We only drop triggers (regular objects). Dropping FTS5 virtual tables
131
+ // would also require the fts5 module, so we leave the inert shadow tables in place.
132
+ const dropped = [];
133
+ const triggers = db.exec("SELECT name FROM sqlite_master WHERE type = 'trigger' AND sql LIKE '%fts%'");
134
+ // Validate trigger names before interpolating into DDL (defense-in-depth)
135
+ const SAFE_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
136
+ for (const row of triggers[0]?.values ?? []) {
137
+ const name = String(row[0]);
138
+ if (!SAFE_IDENTIFIER_RE.test(name)) {
139
+ logger.warning('Skipping trigger with unsafe name during migration', {
140
+ module: 'SqliteAdapter',
141
+ triggerName: name,
142
+ });
143
+ continue;
144
+ }
145
+ db.run(`DROP TRIGGER IF EXISTS "${name}"`);
146
+ dropped.push(`trigger:${name}`);
147
+ }
148
+ const changes = [...added.map((c) => `column:${c}`), ...dropped];
149
+ if (changes.length > 0) {
150
+ this.flushSave();
151
+ logger.info('Schema migrated', {
152
+ module: 'SqliteAdapter',
153
+ dbPath: this.dbPath,
154
+ changes,
155
+ });
156
+ }
157
+ }
158
+ /**
159
+ * Apply additional schema for team databases (adds author column).
160
+ * Also migrates legacy team DBs that may be missing columns from the
161
+ * current main schema (e.g. issue_number, pr_number added after v2).
162
+ * Idempotent — safe to call on databases that already have all columns.
163
+ */
164
+ applyTeamSchema() {
165
+ const db = this.ensureDb();
166
+ const tableInfo = db.exec('PRAGMA table_info(memory_journal)');
167
+ const columns = new Set((tableInfo[0]?.values ?? []).map((row) => String(row[1])));
168
+ // Columns required by the current schema that legacy team DBs may lack
169
+ const requiredColumns = [
170
+ {
171
+ name: 'issue_number',
172
+ sql: 'ALTER TABLE memory_journal ADD COLUMN issue_number INTEGER',
173
+ },
174
+ { name: 'issue_url', sql: 'ALTER TABLE memory_journal ADD COLUMN issue_url TEXT' },
175
+ { name: 'pr_number', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_number INTEGER' },
176
+ { name: 'pr_url', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_url TEXT' },
177
+ { name: 'pr_status', sql: 'ALTER TABLE memory_journal ADD COLUMN pr_status TEXT' },
178
+ {
179
+ name: 'workflow_run_id',
180
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_run_id INTEGER',
181
+ },
182
+ {
183
+ name: 'workflow_name',
184
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_name TEXT',
185
+ },
186
+ {
187
+ name: 'workflow_status',
188
+ sql: 'ALTER TABLE memory_journal ADD COLUMN workflow_status TEXT',
189
+ },
190
+ {
191
+ name: 'project_number',
192
+ sql: 'ALTER TABLE memory_journal ADD COLUMN project_number INTEGER',
193
+ },
194
+ {
195
+ name: 'project_owner',
196
+ sql: 'ALTER TABLE memory_journal ADD COLUMN project_owner TEXT',
197
+ },
198
+ {
199
+ name: 'significance_type',
200
+ sql: 'ALTER TABLE memory_journal ADD COLUMN significance_type TEXT',
201
+ },
202
+ {
203
+ name: 'auto_context',
204
+ sql: 'ALTER TABLE memory_journal ADD COLUMN auto_context TEXT',
205
+ },
206
+ { name: 'deleted_at', sql: 'ALTER TABLE memory_journal ADD COLUMN deleted_at TEXT' },
207
+ { name: 'author', sql: TEAM_SCHEMA_SQL.trim() },
208
+ ];
209
+ const added = [];
210
+ for (const col of requiredColumns) {
211
+ if (!columns.has(col.name)) {
212
+ db.run(col.sql);
213
+ added.push(col.name);
214
+ }
215
+ }
216
+ if (added.length > 0) {
217
+ this.flushSave();
218
+ logger.info('Team schema migrated', {
219
+ module: 'SqliteAdapter',
220
+ dbPath: this.dbPath,
221
+ columnsAdded: added,
222
+ });
223
+ }
224
+ }
136
225
  /**
137
226
  * Schedule a debounced save to disk.
138
227
  * Batches rapid mutations into a single write after SAVE_DEBOUNCE_MS.
@@ -344,8 +433,7 @@ export class SqliteAdapter {
344
433
  const result = db.exec(sql, params);
345
434
  if (result.length === 0)
346
435
  return [];
347
- const columns = result[0]?.columns ?? [];
348
- return (result[0]?.values ?? []).map((values) => this.rowToEntry(this.rowToObject(columns, values)));
436
+ return this.rowsToEntries(result[0]?.columns ?? [], result[0]?.values ?? []);
349
437
  }
350
438
  /**
351
439
  * Get a page of active entries for batch processing (e.g., vector index rebuild).
@@ -356,8 +444,7 @@ export class SqliteAdapter {
356
444
  const result = db.exec(`SELECT * FROM memory_journal WHERE deleted_at IS NULL ORDER BY id ASC LIMIT ? OFFSET ?`, [limit, offset]);
357
445
  if (result.length === 0)
358
446
  return [];
359
- const columns = result[0]?.columns ?? [];
360
- return (result[0]?.values ?? []).map((values) => this.rowToEntry(this.rowToObject(columns, values)));
447
+ return this.rowsToEntries(result[0]?.columns ?? [], result[0]?.values ?? []);
361
448
  }
362
449
  /**
363
450
  * Get total count of active (non-deleted) entries.
@@ -438,9 +525,9 @@ export class SqliteAdapter {
438
525
  const { limit = 10, isPersonal, projectNumber, issueNumber, prNumber } = options;
439
526
  let sql = `
440
527
  SELECT * FROM memory_journal
441
- WHERE deleted_at IS NULL AND content LIKE ?
528
+ WHERE deleted_at IS NULL AND content LIKE ? ESCAPE '\\'
442
529
  `;
443
- const params = [`%${query}%`];
530
+ const params = [`%${sanitizeSearchQuery(query)}%`];
444
531
  if (isPersonal !== undefined) {
445
532
  sql += ` AND is_personal = ?`;
446
533
  params.push(isPersonal ? 1 : 0);
@@ -462,8 +549,7 @@ export class SqliteAdapter {
462
549
  const result = db.exec(sql, params);
463
550
  if (result.length === 0)
464
551
  return [];
465
- const columns = result[0]?.columns ?? [];
466
- return (result[0]?.values ?? []).map((values) => this.rowToEntry(this.rowToObject(columns, values)));
552
+ return this.rowsToEntries(result[0]?.columns ?? [], result[0]?.values ?? []);
467
553
  }
468
554
  /**
469
555
  * Search by date range
@@ -471,14 +557,28 @@ export class SqliteAdapter {
471
557
  searchByDateRange(startDate, endDate, options = {}) {
472
558
  const db = this.ensureDb();
473
559
  const { entryType, tags, isPersonal, projectNumber } = options;
474
- let sql = `
475
- SELECT DISTINCT m.* FROM memory_journal m
476
- LEFT JOIN entry_tags et ON m.id = et.entry_id
477
- LEFT JOIN tags t ON et.tag_id = t.id
478
- WHERE m.deleted_at IS NULL
479
- AND m.timestamp >= ? AND m.timestamp <= ?
480
- `;
560
+ let sql;
481
561
  const params = [startDate, endDate + ' 23:59:59'];
562
+ // Only JOIN tag tables when filtering by tags (avoids DISTINCT overhead)
563
+ if (tags && tags.length > 0) {
564
+ sql = `
565
+ SELECT DISTINCT m.* FROM memory_journal m
566
+ JOIN entry_tags et ON m.id = et.entry_id
567
+ JOIN tags t ON et.tag_id = t.id
568
+ WHERE m.deleted_at IS NULL
569
+ AND m.timestamp >= ? AND m.timestamp <= ?
570
+ `;
571
+ const placeholders = tags.map(() => '?').join(',');
572
+ sql += ` AND t.name IN (${placeholders})`;
573
+ params.push(...tags);
574
+ }
575
+ else {
576
+ sql = `
577
+ SELECT * FROM memory_journal m
578
+ WHERE m.deleted_at IS NULL
579
+ AND m.timestamp >= ? AND m.timestamp <= ?
580
+ `;
581
+ }
482
582
  if (entryType) {
483
583
  sql += ` AND m.entry_type = ?`;
484
584
  params.push(entryType);
@@ -491,17 +591,12 @@ export class SqliteAdapter {
491
591
  sql += ` AND m.project_number = ?`;
492
592
  params.push(projectNumber);
493
593
  }
494
- if (tags && tags.length > 0) {
495
- const placeholders = tags.map(() => '?').join(',');
496
- sql += ` AND t.name IN (${placeholders})`;
497
- params.push(...tags);
498
- }
499
- sql += ` ORDER BY m.timestamp DESC`;
594
+ sql += ` ORDER BY m.timestamp DESC LIMIT ?`;
595
+ params.push(options.limit ?? 500);
500
596
  const result = db.exec(sql, params);
501
597
  if (result.length === 0)
502
598
  return [];
503
- const columns = result[0]?.columns ?? [];
504
- return (result[0]?.values ?? []).map((values) => this.rowToEntry(this.rowToObject(columns, values)));
599
+ return this.rowsToEntries(result[0]?.columns ?? [], result[0]?.values ?? []);
505
600
  }
506
601
  // =========================================================================
507
602
  // Tag Operations
@@ -510,22 +605,26 @@ export class SqliteAdapter {
510
605
  * Get or create tags and link to entry
511
606
  */
512
607
  linkTagsToEntry(entryId, tagNames) {
608
+ if (tagNames.length === 0)
609
+ return;
513
610
  const db = this.ensureDb();
514
- for (const tagName of tagNames) {
515
- // Insert or ignore tag
516
- db.run('INSERT OR IGNORE INTO tags (name, usage_count) VALUES (?, 0)', [tagName]);
517
- // Get tag ID
518
- const result = db.exec('SELECT id FROM tags WHERE name = ?', [tagName]);
519
- const tagId = result[0]?.values[0]?.[0];
520
- if (tagId !== undefined) {
521
- // Link tag to entry
522
- db.run('INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?, ?)', [
523
- entryId,
524
- tagId,
525
- ]);
526
- // Increment usage
527
- db.run('UPDATE tags SET usage_count = usage_count + 1 WHERE id = ?', [tagId]);
528
- }
611
+ // Batch: insert all tags in one statement
612
+ const insertPlaceholders = tagNames.map(() => '(?, 0)').join(', ');
613
+ db.run(`INSERT OR IGNORE INTO tags (name, usage_count) VALUES ${insertPlaceholders}`, tagNames);
614
+ // Batch: fetch all tag IDs in one query
615
+ const selectPlaceholders = tagNames.map(() => '?').join(', ');
616
+ const result = db.exec(`SELECT id, name FROM tags WHERE name IN (${selectPlaceholders})`, tagNames);
617
+ const tagIds = [];
618
+ for (const row of result[0]?.values ?? []) {
619
+ const tagId = row[0];
620
+ tagIds.push(tagId);
621
+ // Link tag to entry
622
+ db.run('INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?, ?)', [
623
+ entryId,
624
+ tagId,
625
+ ]);
626
+ // Increment usage
627
+ db.run('UPDATE tags SET usage_count = usage_count + 1 WHERE id = ?', [tagId]);
529
628
  }
530
629
  }
531
630
  /**
@@ -547,7 +646,7 @@ export class SqliteAdapter {
547
646
  */
548
647
  listTags() {
549
648
  const db = this.ensureDb();
550
- const result = db.exec('SELECT * FROM tags WHERE usage_count > 0 ORDER BY usage_count DESC');
649
+ const result = db.exec('SELECT id, name, COALESCE(usage_count, 0) as usage_count FROM tags WHERE COALESCE(usage_count, 0) > 0 ORDER BY usage_count DESC');
551
650
  if (result.length === 0)
552
651
  return [];
553
652
  return (result[0]?.values ?? []).map((v) => ({
@@ -682,55 +781,51 @@ export class SqliteAdapter {
682
781
  */
683
782
  getStatistics(groupBy = 'week') {
684
783
  const db = this.ensureDb();
685
- // Total entries
686
- const totalResult = db.exec(`
687
- SELECT COUNT(*) as count FROM memory_journal WHERE deleted_at IS NULL
688
- `);
689
- const totalEntries = totalResult[0]?.values[0]?.[0] ?? 0;
690
- // By type
691
- const byTypeResult = db.exec(`
784
+ // Combined query 1: total entries + breakdown by type
785
+ const combinedResult = db.exec(`
786
+ SELECT COUNT(*) as count FROM memory_journal WHERE deleted_at IS NULL;
692
787
  SELECT entry_type, COUNT(*) as count
693
788
  FROM memory_journal
694
789
  WHERE deleted_at IS NULL
695
790
  GROUP BY entry_type
696
791
  `);
792
+ const totalEntries = combinedResult[0]?.values[0]?.[0] ?? 0;
697
793
  const entriesByType = {};
698
- for (const row of byTypeResult[0]?.values ?? []) {
794
+ for (const row of combinedResult[1]?.values ?? []) {
699
795
  entriesByType[row[0]] = row[1];
700
796
  }
701
- // By period - use validated date format pattern (defense-in-depth)
797
+ // Combined query 2: period breakdown + decision density (using CASE)
702
798
  const dateFormat = validateDateFormatPattern(groupBy);
703
- const byPeriodResult = db.exec(`
704
- SELECT strftime('${dateFormat}', timestamp) as period, COUNT(*) as count
799
+ const periodResult = db.exec(`
800
+ SELECT
801
+ strftime('${dateFormat}', timestamp) as period,
802
+ COUNT(*) as total_count,
803
+ SUM(CASE WHEN significance_type IS NOT NULL THEN 1 ELSE 0 END) as significant_count
705
804
  FROM memory_journal
706
805
  WHERE deleted_at IS NULL
707
806
  GROUP BY period
708
807
  ORDER BY period DESC
709
808
  LIMIT 52
710
809
  `);
711
- const entriesByPeriod = (byPeriodResult[0]?.values ?? []).map((v) => ({
810
+ const entriesByPeriod = (periodResult[0]?.values ?? []).map((v) => ({
712
811
  period: v[0],
713
812
  count: v[1],
714
813
  }));
715
- // =========================================================================
716
- // Enhanced Analytics Metrics (v4.3.0)
717
- // =========================================================================
718
- // Decision Density: significant entries per period
719
- const decisionDensityResult = db.exec(`
720
- SELECT strftime('${dateFormat}', timestamp) as period, COUNT(*) as count
721
- FROM memory_journal
722
- WHERE deleted_at IS NULL AND significance_type IS NOT NULL
723
- GROUP BY period
724
- ORDER BY period DESC
725
- LIMIT 52
726
- `);
727
- const decisionDensity = (decisionDensityResult[0]?.values ?? []).map((v) => ({
814
+ const decisionDensity = (periodResult[0]?.values ?? [])
815
+ .filter((v) => v[2] > 0)
816
+ .map((v) => ({
728
817
  period: v[0],
729
- significantCount: v[1],
818
+ significantCount: v[2],
730
819
  }));
731
- // Relationship Complexity: total relationships and avg per entry
732
- const relCountResult = db.exec(`SELECT COUNT(*) FROM relationships`);
733
- const totalRelationships = relCountResult[0]?.values[0]?.[0] ?? 0;
820
+ // Combined query 3: relationship counts + causal breakdown
821
+ const relResult = db.exec(`
822
+ SELECT COUNT(*) FROM relationships;
823
+ SELECT relationship_type, COUNT(*) as count
824
+ FROM relationships
825
+ WHERE relationship_type IN ('blocked_by', 'resolved', 'caused')
826
+ GROUP BY relationship_type
827
+ `);
828
+ const totalRelationships = relResult[0]?.values[0]?.[0] ?? 0;
734
829
  const avgPerEntry = totalEntries > 0 ? totalRelationships / totalEntries : 0;
735
830
  // Activity Trend: week-over-week growth
736
831
  const currentPeriod = entriesByPeriod[0]?.period ?? '';
@@ -741,14 +836,8 @@ export class SqliteAdapter {
741
836
  ? Math.round(((currentCount - previousCount) / previousCount) * 100)
742
837
  : null;
743
838
  // Causal Metrics: counts for causal relationship types
744
- const causalResult = db.exec(`
745
- SELECT relationship_type, COUNT(*) as count
746
- FROM relationships
747
- WHERE relationship_type IN ('blocked_by', 'resolved', 'caused')
748
- GROUP BY relationship_type
749
- `);
750
839
  const causalMetrics = { blocked_by: 0, resolved: 0, caused: 0 };
751
- for (const row of causalResult[0]?.values ?? []) {
840
+ for (const row of relResult[1]?.values ?? []) {
752
841
  const relType = row[0];
753
842
  causalMetrics[relType] = row[1];
754
843
  }
@@ -786,6 +875,10 @@ export class SqliteAdapter {
786
875
  exportToFile(backupName) {
787
876
  const db = this.ensureDb();
788
877
  const backupsDir = this.getBackupsDir();
878
+ // Validate backup name against path traversal before sanitization
879
+ if (backupName) {
880
+ assertNoPathTraversal(backupName);
881
+ }
789
882
  // Ensure backups directory exists
790
883
  if (!fs.existsSync(backupsDir)) {
791
884
  fs.mkdirSync(backupsDir, { recursive: true });
@@ -854,7 +947,7 @@ export class SqliteAdapter {
854
947
  */
855
948
  deleteOldBackups(keepCount) {
856
949
  const backups = this.listBackups(); // Already sorted newest-first
857
- if (keepCount < 1) {
950
+ if (keepCount < 1 || Number.isNaN(keepCount)) {
858
951
  throw new Error('keepCount must be at least 1');
859
952
  }
860
953
  const toKeep = backups.slice(0, keepCount);
@@ -883,9 +976,7 @@ export class SqliteAdapter {
883
976
  */
884
977
  async restoreFromFile(filename) {
885
978
  // Validate filename (prevent path traversal)
886
- if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
887
- throw new Error('Invalid backup filename: path separators not allowed');
888
- }
979
+ assertNoPathTraversal(filename);
889
980
  const backupsDir = this.getBackupsDir();
890
981
  const backupPath = path.join(backupsDir, filename);
891
982
  if (!fs.existsSync(backupPath)) {
@@ -906,6 +997,7 @@ export class SqliteAdapter {
906
997
  // Initialize new database from backup
907
998
  const SQL = await import('sql.js').then((m) => m.default());
908
999
  this.db = new SQL.Database(backupBuffer);
1000
+ this.db.run('PRAGMA foreign_keys = ON');
909
1001
  this.initialized = true;
910
1002
  // Get new entry count
911
1003
  const newCountResult = this.db.exec('SELECT COUNT(*) FROM memory_journal WHERE deleted_at IS NULL');
@@ -988,7 +1080,9 @@ export class SqliteAdapter {
988
1080
  return obj;
989
1081
  }
990
1082
  /**
991
- * Convert database row to JournalEntry
1083
+ * Convert database row to JournalEntry.
1084
+ * Used for single-entry methods (getEntryById, getEntryByIdIncludeDeleted)
1085
+ * where the N+1 overhead of one tag query is negligible.
992
1086
  */
993
1087
  rowToEntry(row) {
994
1088
  const id = row['id'];
@@ -1016,7 +1110,75 @@ export class SqliteAdapter {
1016
1110
  };
1017
1111
  }
1018
1112
  /**
1019
- * Get raw database for advanced operations
1113
+ * Convert multiple database rows to JournalEntry[] with batch tag fetching.
1114
+ * Uses a single IN (...) query to fetch all tags for all entries at once,
1115
+ * eliminating the N+1 query pattern of per-row getTagsForEntry calls.
1116
+ */
1117
+ rowsToEntries(columns, values) {
1118
+ if (values.length === 0)
1119
+ return [];
1120
+ // Step 1: Convert all rows to objects
1121
+ const rows = values.map((v) => this.rowToObject(columns, v));
1122
+ const ids = rows.map((r) => r['id']);
1123
+ // Step 2: Batch-fetch all tags in one query
1124
+ const tagMap = this.batchGetTagsForEntries(ids);
1125
+ // Step 3: Assemble entries using the pre-fetched tag map
1126
+ return rows.map((row) => {
1127
+ const id = row['id'];
1128
+ return {
1129
+ id,
1130
+ entryType: row['entry_type'],
1131
+ content: row['content'],
1132
+ timestamp: row['timestamp'],
1133
+ isPersonal: row['is_personal'] === 1,
1134
+ significanceType: row['significance_type'],
1135
+ autoContext: row['auto_context'],
1136
+ deletedAt: row['deleted_at'],
1137
+ tags: tagMap.get(id) ?? [],
1138
+ projectNumber: row['project_number'] ?? null,
1139
+ projectOwner: row['project_owner'] ?? null,
1140
+ issueNumber: row['issue_number'] ?? null,
1141
+ issueUrl: row['issue_url'] ?? null,
1142
+ prNumber: row['pr_number'] ?? null,
1143
+ prUrl: row['pr_url'] ?? null,
1144
+ prStatus: row['pr_status'] ?? null,
1145
+ workflowRunId: row['workflow_run_id'] ?? null,
1146
+ workflowName: row['workflow_name'] ?? null,
1147
+ workflowStatus: row['workflow_status'] ?? null,
1148
+ };
1149
+ });
1150
+ }
1151
+ /**
1152
+ * Batch-fetch tags for multiple entry IDs in a single query.
1153
+ * Returns a Map<entryId, tagNames[]>.
1154
+ * Eliminates the N+1 query problem for multi-row result sets.
1155
+ */
1156
+ batchGetTagsForEntries(ids) {
1157
+ const tagMap = new Map();
1158
+ if (ids.length === 0)
1159
+ return tagMap;
1160
+ const db = this.ensureDb();
1161
+ const placeholders = ids.map(() => '?').join(', ');
1162
+ const result = db.exec(`SELECT et.entry_id, t.name
1163
+ FROM entry_tags et
1164
+ JOIN tags t ON et.tag_id = t.id
1165
+ WHERE et.entry_id IN (${placeholders})`, ids);
1166
+ for (const row of result[0]?.values ?? []) {
1167
+ const entryId = row[0];
1168
+ const tagName = row[1];
1169
+ const existing = tagMap.get(entryId);
1170
+ if (existing) {
1171
+ existing.push(tagName);
1172
+ }
1173
+ else {
1174
+ tagMap.set(entryId, [tagName]);
1175
+ }
1176
+ }
1177
+ return tagMap;
1178
+ }
1179
+ /**
1180
+ * Get raw sql.js Database handle for advanced queries.
1181
+ * @internal Callers MUST use parameterized queries — never concatenate user input into SQL.
1020
1182
  */
1021
1183
  getRawDb() {
1022
1184
  return this.ensureDb();