@dizzlkheinz/ynab-mcpb 0.18.3 → 0.19.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 (346) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/CLAUDE.md +87 -8
  3. package/bin/ynab-mcp-server.cjs +2 -2
  4. package/bin/ynab-mcp-server.js +3 -3
  5. package/biome.json +39 -0
  6. package/dist/bundle/index.cjs +67 -67
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +27 -27
  9. package/dist/server/YNABMCPServer.d.ts +3 -4
  10. package/dist/server/YNABMCPServer.js +111 -116
  11. package/dist/server/budgetResolver.d.ts +6 -5
  12. package/dist/server/budgetResolver.js +46 -36
  13. package/dist/server/cacheKeys.js +6 -6
  14. package/dist/server/cacheManager.js +14 -11
  15. package/dist/server/completions.d.ts +2 -2
  16. package/dist/server/completions.js +20 -15
  17. package/dist/server/config.d.ts +10 -5
  18. package/dist/server/config.js +24 -7
  19. package/dist/server/deltaCache.d.ts +2 -2
  20. package/dist/server/deltaCache.js +22 -16
  21. package/dist/server/deltaCache.merge.d.ts +2 -2
  22. package/dist/server/diagnostics.d.ts +4 -4
  23. package/dist/server/diagnostics.js +38 -32
  24. package/dist/server/errorHandler.d.ts +5 -12
  25. package/dist/server/errorHandler.js +219 -217
  26. package/dist/server/prompts.d.ts +2 -2
  27. package/dist/server/prompts.js +45 -45
  28. package/dist/server/rateLimiter.js +4 -4
  29. package/dist/server/requestLogger.d.ts +1 -1
  30. package/dist/server/requestLogger.js +40 -35
  31. package/dist/server/resources.d.ts +3 -3
  32. package/dist/server/resources.js +55 -52
  33. package/dist/server/responseFormatter.js +6 -6
  34. package/dist/server/securityMiddleware.d.ts +2 -2
  35. package/dist/server/securityMiddleware.js +22 -20
  36. package/dist/server/serverKnowledgeStore.js +1 -1
  37. package/dist/server/toolRegistry.d.ts +3 -3
  38. package/dist/server/toolRegistry.js +47 -40
  39. package/dist/tools/__tests__/deltaTestUtils.d.ts +3 -3
  40. package/dist/tools/__tests__/deltaTestUtils.js +2 -2
  41. package/dist/tools/accountTools.d.ts +9 -8
  42. package/dist/tools/accountTools.js +47 -47
  43. package/dist/tools/adapters.d.ts +13 -8
  44. package/dist/tools/adapters.js +21 -11
  45. package/dist/tools/budgetTools.d.ts +8 -7
  46. package/dist/tools/budgetTools.js +22 -22
  47. package/dist/tools/categoryTools.d.ts +9 -8
  48. package/dist/tools/categoryTools.js +68 -59
  49. package/dist/tools/compareTransactions/formatter.d.ts +3 -3
  50. package/dist/tools/compareTransactions/formatter.js +9 -9
  51. package/dist/tools/compareTransactions/index.d.ts +6 -6
  52. package/dist/tools/compareTransactions/index.js +58 -43
  53. package/dist/tools/compareTransactions/matcher.d.ts +1 -1
  54. package/dist/tools/compareTransactions/matcher.js +28 -15
  55. package/dist/tools/compareTransactions/parser.d.ts +2 -2
  56. package/dist/tools/compareTransactions/parser.js +144 -138
  57. package/dist/tools/compareTransactions/types.d.ts +4 -4
  58. package/dist/tools/compareTransactions.d.ts +1 -1
  59. package/dist/tools/compareTransactions.js +1 -1
  60. package/dist/tools/deltaFetcher.d.ts +2 -2
  61. package/dist/tools/deltaFetcher.js +16 -15
  62. package/dist/tools/deltaSupport.d.ts +4 -4
  63. package/dist/tools/deltaSupport.js +35 -41
  64. package/dist/tools/exportTransactions.d.ts +5 -4
  65. package/dist/tools/exportTransactions.js +61 -59
  66. package/dist/tools/monthTools.d.ts +7 -6
  67. package/dist/tools/monthTools.js +31 -29
  68. package/dist/tools/payeeTools.d.ts +7 -6
  69. package/dist/tools/payeeTools.js +28 -28
  70. package/dist/tools/reconcileAdapter.d.ts +2 -2
  71. package/dist/tools/reconcileAdapter.js +21 -11
  72. package/dist/tools/reconciliation/analyzer.d.ts +4 -4
  73. package/dist/tools/reconciliation/analyzer.js +136 -57
  74. package/dist/tools/reconciliation/csvParser.d.ts +3 -3
  75. package/dist/tools/reconciliation/csvParser.js +128 -104
  76. package/dist/tools/reconciliation/executor.d.ts +4 -4
  77. package/dist/tools/reconciliation/executor.js +148 -109
  78. package/dist/tools/reconciliation/index.d.ts +10 -10
  79. package/dist/tools/reconciliation/index.js +96 -83
  80. package/dist/tools/reconciliation/matcher.d.ts +3 -3
  81. package/dist/tools/reconciliation/matcher.js +17 -16
  82. package/dist/tools/reconciliation/payeeNormalizer.js +19 -8
  83. package/dist/tools/reconciliation/recommendationEngine.d.ts +1 -1
  84. package/dist/tools/reconciliation/recommendationEngine.js +40 -40
  85. package/dist/tools/reconciliation/reportFormatter.d.ts +2 -2
  86. package/dist/tools/reconciliation/reportFormatter.js +79 -54
  87. package/dist/tools/reconciliation/signDetector.d.ts +1 -1
  88. package/dist/tools/reconciliation/types.d.ts +19 -16
  89. package/dist/tools/reconciliation/ynabAdapter.d.ts +2 -2
  90. package/dist/tools/schemas/common.d.ts +1 -1
  91. package/dist/tools/schemas/common.js +1 -1
  92. package/dist/tools/schemas/outputs/accountOutputs.d.ts +1 -1
  93. package/dist/tools/schemas/outputs/accountOutputs.js +24 -18
  94. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +1 -1
  95. package/dist/tools/schemas/outputs/budgetOutputs.js +14 -11
  96. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +1 -1
  97. package/dist/tools/schemas/outputs/categoryOutputs.js +49 -29
  98. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  99. package/dist/tools/schemas/outputs/comparisonOutputs.js +12 -12
  100. package/dist/tools/schemas/outputs/index.d.ts +14 -14
  101. package/dist/tools/schemas/outputs/index.js +14 -14
  102. package/dist/tools/schemas/outputs/monthOutputs.d.ts +1 -1
  103. package/dist/tools/schemas/outputs/monthOutputs.js +56 -41
  104. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +1 -1
  105. package/dist/tools/schemas/outputs/payeeOutputs.js +10 -10
  106. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +2 -2
  107. package/dist/tools/schemas/outputs/reconciliationOutputs.js +45 -45
  108. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +1 -1
  109. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +28 -22
  110. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +1 -1
  111. package/dist/tools/schemas/outputs/transactionOutputs.js +43 -35
  112. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +1 -1
  113. package/dist/tools/schemas/outputs/utilityOutputs.js +5 -3
  114. package/dist/tools/schemas/shared/commonOutputs.d.ts +1 -1
  115. package/dist/tools/schemas/shared/commonOutputs.js +15 -9
  116. package/dist/tools/transactionReadTools.d.ts +11 -0
  117. package/dist/tools/transactionReadTools.js +202 -0
  118. package/dist/tools/transactionSchemas.d.ts +309 -0
  119. package/dist/tools/transactionSchemas.js +235 -0
  120. package/dist/tools/transactionTools.d.ts +6 -302
  121. package/dist/tools/transactionTools.js +7 -2054
  122. package/dist/tools/transactionUtils.d.ts +31 -0
  123. package/dist/tools/transactionUtils.js +364 -0
  124. package/dist/tools/transactionWriteTools.d.ts +20 -0
  125. package/dist/tools/transactionWriteTools.js +1342 -0
  126. package/dist/tools/utilityTools.d.ts +5 -4
  127. package/dist/tools/utilityTools.js +11 -11
  128. package/dist/types/index.d.ts +7 -7
  129. package/dist/types/index.js +6 -6
  130. package/dist/types/reconciliation.d.ts +1 -1
  131. package/dist/types/toolRegistration.d.ts +14 -12
  132. package/dist/utils/amountUtils.js +1 -1
  133. package/dist/utils/dateUtils.js +4 -4
  134. package/dist/utils/errors.d.ts +3 -3
  135. package/dist/utils/errors.js +4 -4
  136. package/dist/utils/money.d.ts +2 -2
  137. package/dist/utils/money.js +8 -8
  138. package/dist/utils/validationError.d.ts +1 -1
  139. package/dist/utils/validationError.js +1 -1
  140. package/docs/assets/examples/reconciliation-with-recommendations.json +66 -66
  141. package/docs/assets/schemas/reconciliation-v2.json +360 -336
  142. package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
  143. package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
  144. package/esbuild.config.mjs +53 -50
  145. package/meta.json +12548 -12548
  146. package/package.json +98 -109
  147. package/scripts/analyze-bundle.mjs +33 -30
  148. package/scripts/create-pr-description.js +169 -120
  149. package/scripts/run-all-tests.js +205 -0
  150. package/scripts/run-domain-integration-tests.js +28 -18
  151. package/scripts/run-generate-mcpb.js +19 -17
  152. package/scripts/run-throttled-integration-tests.js +92 -83
  153. package/scripts/test-delta-params.mjs +149 -120
  154. package/scripts/test-recommendations.ts +36 -32
  155. package/scripts/tmpTransaction.ts +80 -43
  156. package/scripts/validate-env.js +98 -91
  157. package/scripts/verify-build.js +78 -76
  158. package/src/__tests__/comprehensive.integration.test.ts +1281 -1154
  159. package/src/__tests__/performance.test.ts +723 -671
  160. package/src/__tests__/setup.ts +442 -395
  161. package/src/__tests__/smoke.e2e.test.ts +41 -39
  162. package/src/__tests__/testRunner.ts +314 -295
  163. package/src/__tests__/testUtils.ts +456 -364
  164. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +109 -107
  165. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +41 -41
  166. package/src/index.ts +68 -59
  167. package/src/server/CLAUDE.md +480 -0
  168. package/src/server/YNABMCPServer.ts +821 -794
  169. package/src/server/__tests__/YNABMCPServer.integration.test.ts +929 -893
  170. package/src/server/__tests__/YNABMCPServer.test.ts +903 -899
  171. package/src/server/__tests__/budgetResolver.test.ts +466 -423
  172. package/src/server/__tests__/cacheManager.test.ts +891 -874
  173. package/src/server/__tests__/completions.integration.test.ts +115 -106
  174. package/src/server/__tests__/completions.test.ts +334 -313
  175. package/src/server/__tests__/config.test.ts +98 -86
  176. package/src/server/__tests__/deltaCache.merge.test.ts +774 -703
  177. package/src/server/__tests__/deltaCache.swr.test.ts +198 -153
  178. package/src/server/__tests__/deltaCache.test.ts +946 -759
  179. package/src/server/__tests__/diagnostics.test.ts +825 -792
  180. package/src/server/__tests__/errorHandler.integration.test.ts +512 -462
  181. package/src/server/__tests__/errorHandler.test.ts +402 -397
  182. package/src/server/__tests__/prompts.test.ts +424 -347
  183. package/src/server/__tests__/rateLimiter.test.ts +313 -309
  184. package/src/server/__tests__/requestLogger.test.ts +443 -403
  185. package/src/server/__tests__/resources.template.test.ts +196 -185
  186. package/src/server/__tests__/resources.test.ts +294 -288
  187. package/src/server/__tests__/security.integration.test.ts +487 -421
  188. package/src/server/__tests__/securityMiddleware.test.ts +519 -444
  189. package/src/server/__tests__/server-startup.integration.test.ts +509 -490
  190. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -173
  191. package/src/server/__tests__/toolRegistration.test.ts +239 -210
  192. package/src/server/__tests__/toolRegistry.test.ts +907 -845
  193. package/src/server/budgetResolver.ts +221 -181
  194. package/src/server/cacheKeys.ts +6 -6
  195. package/src/server/cacheManager.ts +498 -484
  196. package/src/server/completions.ts +267 -243
  197. package/src/server/config.ts +35 -14
  198. package/src/server/deltaCache.merge.ts +146 -128
  199. package/src/server/deltaCache.ts +352 -309
  200. package/src/server/diagnostics.ts +257 -242
  201. package/src/server/errorHandler.ts +747 -744
  202. package/src/server/prompts.ts +181 -176
  203. package/src/server/rateLimiter.ts +131 -129
  204. package/src/server/requestLogger.ts +350 -322
  205. package/src/server/resources.ts +442 -374
  206. package/src/server/responseFormatter.ts +41 -37
  207. package/src/server/securityMiddleware.ts +223 -205
  208. package/src/server/serverKnowledgeStore.ts +67 -67
  209. package/src/server/toolRegistry.ts +508 -474
  210. package/src/tools/CLAUDE.md +604 -0
  211. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -111
  212. package/src/tools/__tests__/accountTools.integration.test.ts +129 -111
  213. package/src/tools/__tests__/accountTools.test.ts +685 -638
  214. package/src/tools/__tests__/adapters.test.ts +142 -108
  215. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +73 -73
  216. package/src/tools/__tests__/budgetTools.integration.test.ts +132 -124
  217. package/src/tools/__tests__/budgetTools.test.ts +442 -413
  218. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +76 -68
  219. package/src/tools/__tests__/categoryTools.integration.test.ts +314 -288
  220. package/src/tools/__tests__/categoryTools.test.ts +656 -625
  221. package/src/tools/__tests__/compareTransactions/formatter.test.ts +535 -462
  222. package/src/tools/__tests__/compareTransactions/index.test.ts +378 -358
  223. package/src/tools/__tests__/compareTransactions/matcher.test.ts +497 -398
  224. package/src/tools/__tests__/compareTransactions/parser.test.ts +765 -747
  225. package/src/tools/__tests__/compareTransactions.test.ts +352 -332
  226. package/src/tools/__tests__/compareTransactions.window.test.ts +150 -146
  227. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +69 -65
  228. package/src/tools/__tests__/deltaFetcher.test.ts +325 -265
  229. package/src/tools/__tests__/deltaSupport.test.ts +211 -184
  230. package/src/tools/__tests__/deltaTestUtils.ts +37 -33
  231. package/src/tools/__tests__/exportTransactions.test.ts +205 -200
  232. package/src/tools/__tests__/monthTools.delta.integration.test.ts +68 -68
  233. package/src/tools/__tests__/monthTools.integration.test.ts +178 -166
  234. package/src/tools/__tests__/monthTools.test.ts +561 -512
  235. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +68 -68
  236. package/src/tools/__tests__/payeeTools.integration.test.ts +158 -142
  237. package/src/tools/__tests__/payeeTools.test.ts +486 -434
  238. package/src/tools/__tests__/transactionSchemas.test.ts +1204 -0
  239. package/src/tools/__tests__/transactionTools.integration.test.ts +875 -825
  240. package/src/tools/__tests__/transactionTools.test.ts +4923 -4366
  241. package/src/tools/__tests__/transactionUtils.test.ts +1016 -0
  242. package/src/tools/__tests__/utilityTools.integration.test.ts +32 -32
  243. package/src/tools/__tests__/utilityTools.test.ts +68 -58
  244. package/src/tools/accountTools.ts +293 -271
  245. package/src/tools/adapters.ts +120 -63
  246. package/src/tools/budgetTools.ts +121 -116
  247. package/src/tools/categoryTools.ts +379 -339
  248. package/src/tools/compareTransactions/formatter.ts +131 -119
  249. package/src/tools/compareTransactions/index.ts +249 -214
  250. package/src/tools/compareTransactions/matcher.ts +259 -209
  251. package/src/tools/compareTransactions/parser.ts +517 -487
  252. package/src/tools/compareTransactions/types.ts +38 -38
  253. package/src/tools/compareTransactions.ts +1 -1
  254. package/src/tools/deltaFetcher.ts +281 -260
  255. package/src/tools/deltaSupport.ts +264 -259
  256. package/src/tools/exportTransactions.ts +230 -218
  257. package/src/tools/monthTools.ts +180 -165
  258. package/src/tools/payeeTools.ts +152 -140
  259. package/src/tools/reconcileAdapter.ts +297 -246
  260. package/src/tools/reconciliation/CLAUDE.md +506 -0
  261. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +135 -112
  262. package/src/tools/reconciliation/__tests__/adapter.test.ts +249 -227
  263. package/src/tools/reconciliation/__tests__/analyzer.test.ts +408 -335
  264. package/src/tools/reconciliation/__tests__/csvParser.test.ts +71 -69
  265. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +348 -323
  266. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +503 -457
  267. package/src/tools/reconciliation/__tests__/executor.test.ts +898 -831
  268. package/src/tools/reconciliation/__tests__/matcher.test.ts +667 -663
  269. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +296 -276
  270. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +692 -624
  271. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1008 -986
  272. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +187 -146
  273. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +583 -530
  274. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +75 -71
  275. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +70 -58
  276. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +102 -88
  277. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +58 -43
  278. package/src/tools/reconciliation/__tests__/signDetector.test.ts +209 -206
  279. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +66 -60
  280. package/src/tools/reconciliation/analyzer.ts +582 -406
  281. package/src/tools/reconciliation/csvParser.ts +656 -609
  282. package/src/tools/reconciliation/executor.ts +1290 -1128
  283. package/src/tools/reconciliation/index.ts +580 -528
  284. package/src/tools/reconciliation/matcher.ts +256 -240
  285. package/src/tools/reconciliation/payeeNormalizer.ts +92 -78
  286. package/src/tools/reconciliation/recommendationEngine.ts +357 -345
  287. package/src/tools/reconciliation/reportFormatter.ts +349 -276
  288. package/src/tools/reconciliation/signDetector.ts +89 -83
  289. package/src/tools/reconciliation/types.ts +164 -153
  290. package/src/tools/reconciliation/ynabAdapter.ts +17 -15
  291. package/src/tools/schemas/CLAUDE.md +546 -0
  292. package/src/tools/schemas/common.ts +1 -1
  293. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +410 -409
  294. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +305 -299
  295. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +431 -430
  296. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +510 -495
  297. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +179 -153
  298. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +293 -254
  299. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +457 -457
  300. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +362 -356
  301. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +402 -399
  302. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +225 -211
  303. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +457 -454
  304. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +316 -315
  305. package/src/tools/schemas/outputs/accountOutputs.ts +40 -34
  306. package/src/tools/schemas/outputs/budgetOutputs.ts +24 -19
  307. package/src/tools/schemas/outputs/categoryOutputs.ts +76 -56
  308. package/src/tools/schemas/outputs/comparisonOutputs.ts +192 -169
  309. package/src/tools/schemas/outputs/index.ts +163 -163
  310. package/src/tools/schemas/outputs/monthOutputs.ts +95 -80
  311. package/src/tools/schemas/outputs/payeeOutputs.ts +18 -18
  312. package/src/tools/schemas/outputs/reconciliationOutputs.ts +386 -373
  313. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +259 -231
  314. package/src/tools/schemas/outputs/transactionOutputs.ts +81 -71
  315. package/src/tools/schemas/outputs/utilityOutputs.ts +90 -84
  316. package/src/tools/schemas/shared/commonOutputs.ts +27 -19
  317. package/src/tools/toolCategories.ts +114 -114
  318. package/src/tools/transactionReadTools.ts +327 -0
  319. package/src/tools/transactionSchemas.ts +484 -0
  320. package/src/tools/transactionTools.ts +107 -2990
  321. package/src/tools/transactionUtils.ts +621 -0
  322. package/src/tools/transactionWriteTools.ts +2110 -0
  323. package/src/tools/utilityTools.ts +46 -41
  324. package/src/types/CLAUDE.md +477 -0
  325. package/src/types/__tests__/index.test.ts +51 -51
  326. package/src/types/index.ts +43 -39
  327. package/src/types/integration-tests.d.ts +26 -26
  328. package/src/types/reconciliation.ts +29 -29
  329. package/src/types/toolAnnotations.ts +30 -30
  330. package/src/types/toolRegistration.ts +43 -32
  331. package/src/utils/CLAUDE.md +508 -0
  332. package/src/utils/__tests__/dateUtils.test.ts +174 -168
  333. package/src/utils/__tests__/money.test.ts +193 -187
  334. package/src/utils/amountUtils.ts +5 -5
  335. package/src/utils/baseError.ts +5 -5
  336. package/src/utils/dateUtils.ts +29 -26
  337. package/src/utils/errors.ts +14 -14
  338. package/src/utils/money.ts +66 -52
  339. package/src/utils/validationError.ts +1 -1
  340. package/tsconfig.json +29 -29
  341. package/tsconfig.prod.json +16 -16
  342. package/vitest-reporters/split-json-reporter.ts +247 -204
  343. package/vitest.config.ts +99 -95
  344. package/.prettierignore +0 -10
  345. package/.prettierrc.json +0 -10
  346. package/eslint.config.js +0 -49
@@ -4,500 +4,514 @@
4
4
  */
5
5
 
6
6
  interface CacheEntry<T> {
7
- data: T;
8
- timestamp: number;
9
- ttl: number;
10
- staleWhileRevalidate?: number;
7
+ data: T;
8
+ timestamp: number;
9
+ ttl: number;
10
+ staleWhileRevalidate?: number;
11
11
  }
12
12
 
13
13
  interface CacheSetOptions {
14
- ttl?: number;
15
- /**
16
- * Stale-while-revalidate window in milliseconds.
17
- * When explicitly set to undefined, uses the default stale window.
18
- * When omitted entirely, no stale-while-revalidate is applied.
19
- */
20
- staleWhileRevalidate?: number;
14
+ ttl?: number;
15
+ /**
16
+ * Stale-while-revalidate window in milliseconds.
17
+ * When explicitly set to undefined, uses the default stale window.
18
+ * When omitted entirely, no stale-while-revalidate is applied.
19
+ */
20
+ staleWhileRevalidate?: number;
21
21
  }
22
22
 
23
23
  export class CacheManager {
24
- private cache = new Map<string, CacheEntry<unknown>>();
25
- private readonly defaultTTL: number;
26
- private hits = 0;
27
- private misses = 0;
28
- private evictions = 0;
29
- private lastCleanup: number | null = null;
30
- private maxEntries: number;
31
- private defaultStaleWindow: number;
32
- private pendingFetches = new Map<string, Promise<unknown>>();
33
- private pendingRefresh = new Set<string>();
34
-
35
- constructor() {
36
- this.maxEntries = this.parseEnvInt('YNAB_MCP_CACHE_MAX_ENTRIES', 1000);
37
- this.defaultStaleWindow = this.parseEnvInt('YNAB_MCP_CACHE_STALE_MS', 2 * 60 * 1000);
38
- this.defaultTTL = this.parseEnvInt('YNAB_MCP_CACHE_DEFAULT_TTL_MS', 300000);
39
- }
40
-
41
- /**
42
- * Get cached data if valid, null if expired or not found
43
- */
44
- get<T>(key: string): T | null {
45
- const entry = this.cache.get(key);
46
-
47
- if (!entry) {
48
- this.misses++;
49
- return null;
50
- }
51
-
52
- const now = Date.now();
53
- const age = now - entry.timestamp;
54
-
55
- // Check if entry is expired
56
- if (age > entry.ttl) {
57
- // Check if we're within stale-while-revalidate window
58
- const staleWindow = entry.staleWhileRevalidate || 0;
59
- if (staleWindow > 0 && age <= entry.ttl + staleWindow) {
60
- this.hits++;
61
- // Update access order for LRU
62
- this.cache.delete(key);
63
- this.cache.set(key, entry);
64
- // Mark for background refresh
65
- this.pendingRefresh.add(key);
66
- return entry.data as T;
67
- }
68
-
69
- this.cache.delete(key);
70
- this.pendingFetches.delete(key);
71
- this.pendingRefresh.delete(key);
72
- this.misses++;
73
- return null;
74
- }
75
-
76
- this.hits++;
77
- // Update access order for LRU
78
- this.cache.delete(key);
79
- this.cache.set(key, entry);
80
- return entry.data as T;
81
- }
82
-
83
- /**
84
- * Check if a valid cache entry exists without updating hit/miss counters
85
- */
86
- has(key: string): boolean {
87
- const entry = this.cache.get(key);
88
- if (!entry) {
89
- return false;
90
- }
91
-
92
- const now = Date.now();
93
- const age = now - entry.timestamp;
94
- if (age > entry.ttl) {
95
- const staleWindow = entry.staleWhileRevalidate || 0;
96
- if (staleWindow > 0 && age <= entry.ttl + staleWindow) {
97
- return true;
98
- }
99
-
100
- this.cache.delete(key);
101
- this.pendingFetches.delete(key);
102
- this.pendingRefresh.delete(key);
103
- return false;
104
- }
105
-
106
- return true;
107
- }
108
-
109
- /**
110
- * Set cache entry with optional TTL or options
111
- *
112
- * @param key - Cache key
113
- * @param data - Data to cache
114
- * @param ttlOrOptions - TTL in milliseconds (number) or options object
115
- *
116
- * Note: Default stale-while-revalidate window is applied only when:
117
- * - An options object is provided AND
118
- * - The staleWhileRevalidate property is explicitly present (even if undefined)
119
- *
120
- * When using the simple number interface or when staleWhileRevalidate property
121
- * is not present in the options object, no default stale window is applied.
122
- */
123
- set<T>(key: string, data: T, ttlOrOptions?: number | CacheSetOptions): void {
124
- // Don't cache anything if maxEntries is 0
125
- if (this.maxEntries <= 0) {
126
- return;
127
- }
128
-
129
- const isUpdate = this.cache.has(key);
130
- if (!isUpdate) {
131
- this.evictIfNeeded();
132
- }
133
-
134
- let ttl: number;
135
- let staleWhileRevalidate: number | undefined;
136
-
137
- if (typeof ttlOrOptions === 'number') {
138
- ttl = Number.isFinite(ttlOrOptions) ? ttlOrOptions : this.defaultTTL;
139
- // When using simple number interface, no stale window is applied
140
- staleWhileRevalidate = undefined;
141
- } else if (ttlOrOptions === undefined) {
142
- // When called without any options (simple set), use defaults but NO stale window
143
- ttl = this.defaultTTL;
144
- staleWhileRevalidate = undefined;
145
- } else {
146
- const providedTtl = ttlOrOptions?.ttl;
147
- ttl = providedTtl !== undefined ? providedTtl : this.defaultTTL;
148
- const hasStaleWhileRevalidate =
149
- ttlOrOptions !== undefined && 'staleWhileRevalidate' in ttlOrOptions;
150
- if (hasStaleWhileRevalidate) {
151
- staleWhileRevalidate = ttlOrOptions.staleWhileRevalidate;
152
- if (staleWhileRevalidate === undefined && this.defaultStaleWindow > 0) {
153
- staleWhileRevalidate = this.defaultStaleWindow;
154
- }
155
- } else {
156
- staleWhileRevalidate = undefined;
157
- }
158
- }
159
- const entry: CacheEntry<T> = {
160
- data,
161
- timestamp: Date.now(),
162
- ttl,
163
- };
164
- if (staleWhileRevalidate !== undefined) {
165
- entry.staleWhileRevalidate = staleWhileRevalidate;
166
- }
167
-
168
- if (isUpdate) {
169
- // When updating, delete then set to preserve MRU ordering
170
- this.cache.delete(key);
171
- }
172
- this.cache.set(key, entry);
173
- // Clear any pending operations since we have fresh data
174
- this.pendingFetches.delete(key);
175
- this.pendingRefresh.delete(key);
176
- }
177
-
178
- /**
179
- * Clear specific cache entry
180
- */
181
- delete(key: string): boolean {
182
- const deleted = this.cache.delete(key);
183
- if (deleted) {
184
- this.pendingFetches.delete(key);
185
- this.pendingRefresh.delete(key);
186
- }
187
- return deleted;
188
- }
189
-
190
- /**
191
- * Delete multiple cache entries in a single operation
192
- */
193
- deleteMany(keys: Iterable<string>): void {
194
- for (const key of keys) {
195
- this.cache.delete(key);
196
- this.pendingFetches.delete(key);
197
- this.pendingRefresh.delete(key);
198
- }
199
- }
200
-
201
- /**
202
- * Delete cache entries whose keys begin with the provided prefix.
203
- * Useful for invalidating a specific resource type across budgets.
204
- *
205
- * @param prefix - Cache key prefix (e.g., 'transactions:' or 'accounts:list:')
206
- * @returns The number of entries removed
207
- */
208
- deleteByPrefix(prefix: string): number {
209
- if (!prefix) {
210
- return 0;
211
- }
212
-
213
- const normalizedPrefix = prefix.endsWith(':') ? prefix.slice(0, -1) : prefix;
214
- const prefixWithColon = `${normalizedPrefix}:`;
215
-
216
- let removed = 0;
217
- for (const key of this.cache.keys()) {
218
- if (key === normalizedPrefix || key.startsWith(prefixWithColon)) {
219
- this.cache.delete(key);
220
- this.pendingFetches.delete(key);
221
- this.pendingRefresh.delete(key);
222
- removed++;
223
- }
224
- }
225
- return removed;
226
- }
227
-
228
- /**
229
- * Delete cache entries that belong to a specific budget.
230
- * Matches keys containing the budget ID (e.g., '...:budget-123:...').
231
- *
232
- * @param budgetId - Budget identifier to match
233
- */
234
- deleteByBudgetId(budgetId: string): number {
235
- if (!budgetId) {
236
- return 0;
237
- }
238
-
239
- let removed = 0;
240
- for (const key of this.cache.keys()) {
241
- const segments = key.split(':');
242
- if (segments.some((segment) => segment === budgetId)) {
243
- this.cache.delete(key);
244
- this.pendingFetches.delete(key);
245
- this.pendingRefresh.delete(key);
246
- removed++;
247
- }
248
- }
249
- return removed;
250
- }
251
-
252
- /**
253
- * Return all cache keys for debugging and diagnostics.
254
- *
255
- * @returns Snapshot of cache keys in insertion order
256
- */
257
- getKeys(): string[] {
258
- return Array.from(this.cache.keys());
259
- }
260
-
261
- /**
262
- * Clear all cache entries
263
- */
264
- clear(): void {
265
- this.cache.clear();
266
- this.hits = 0;
267
- this.misses = 0;
268
- this.evictions = 0;
269
- this.lastCleanup = null;
270
- this.pendingFetches.clear();
271
- this.pendingRefresh.clear();
272
- }
273
-
274
- /**
275
- * Get cache statistics
276
- */
277
- getStats(): {
278
- size: number;
279
- keys: string[];
280
- hits: number;
281
- misses: number;
282
- evictions: number;
283
- lastCleanup: number | null;
284
- maxEntries: number;
285
- hitRate: number;
286
- } {
287
- const totalRequests = this.hits + this.misses;
288
- return {
289
- size: this.cache.size,
290
- keys: Array.from(this.cache.keys()),
291
- hits: this.hits,
292
- misses: this.misses,
293
- evictions: this.evictions,
294
- lastCleanup: this.lastCleanup,
295
- maxEntries: this.maxEntries,
296
- hitRate: totalRequests > 0 ? this.hits / totalRequests : 0,
297
- };
298
- }
299
-
300
- /**
301
- * Provide a filtered snapshot for cache size estimation without exposing expired entries.
302
- */
303
- getEntriesForSizeEstimation(): [string, CacheEntry<unknown>][] {
304
- const now = Date.now();
305
- return Array.from(this.cache.entries()).filter(
306
- ([, entry]) => now - entry.timestamp <= entry.ttl,
307
- );
308
- }
309
-
310
- /**
311
- * Get lightweight cache metadata for size estimation without full entry data.
312
- * Returns summaries with keys, timestamps, and TTLs for estimating memory usage.
313
- */
314
- getCacheMetadata(): {
315
- key: string;
316
- timestamp: number;
317
- ttl: number;
318
- staleWhileRevalidate?: number;
319
- dataType: string;
320
- isExpired: boolean;
321
- }[] {
322
- const now = Date.now();
323
- return Array.from(this.cache.entries()).map(([key, entry]) => {
324
- const metadata: {
325
- key: string;
326
- timestamp: number;
327
- ttl: number;
328
- staleWhileRevalidate?: number;
329
- dataType: string;
330
- isExpired: boolean;
331
- } = {
332
- key,
333
- timestamp: entry.timestamp,
334
- ttl: entry.ttl,
335
- dataType: typeof entry.data,
336
- isExpired: now - entry.timestamp > entry.ttl,
337
- };
338
- if (entry.staleWhileRevalidate !== undefined) {
339
- metadata.staleWhileRevalidate = entry.staleWhileRevalidate;
340
- }
341
- return metadata;
342
- });
343
- }
344
-
345
- /**
346
- * Clean up expired entries
347
- */
348
- cleanup(): number {
349
- const result = this.cleanupDetailed();
350
- return result.cleaned;
351
- }
352
-
353
- /**
354
- * Clean up expired entries with detailed information
355
- */
356
- cleanupDetailed(): { cleaned: number; evictions: number } {
357
- const now = Date.now();
358
- let cleaned = 0;
359
- const initialEvictions = this.evictions;
360
-
361
- for (const [key, entry] of this.cache.entries()) {
362
- if (now - entry.timestamp > entry.ttl) {
363
- this.cache.delete(key);
364
- this.pendingFetches.delete(key);
365
- this.pendingRefresh.delete(key);
366
- cleaned++;
367
- this.evictions++;
368
- }
369
- }
370
-
371
- this.lastCleanup = now;
372
- return { cleaned, evictions: this.evictions - initialEvictions };
373
- }
374
-
375
- /**
376
- * Wrap a loader function with caching and concurrent deduplication
377
- */
378
- async wrap<T>(key: string, options: CacheSetOptions & { loader: () => Promise<T> }): Promise<T> {
379
- // Check cache first and preserve existing entry for background refresh
380
- const existingEntry = this.cache.get(key);
381
- const cached = this.get<T>(key);
382
- if (cached !== null) {
383
- // Check if this key was marked for background refresh (stale-while-revalidate)
384
- if (this.pendingRefresh.has(key) && !this.pendingFetches.has(key)) {
385
- // Start background refresh
386
- const refreshPromise = options.loader().then(
387
- (result) => {
388
- // Preserve existing TTL/SWR if not specified in options
389
- const refreshOptions: CacheSetOptions = {};
390
- const ttl = options.ttl ?? existingEntry?.ttl;
391
- if (ttl !== undefined) {
392
- refreshOptions.ttl = ttl;
393
- }
394
- const staleWhileRevalidate =
395
- options.staleWhileRevalidate ?? existingEntry?.staleWhileRevalidate;
396
- if (staleWhileRevalidate !== undefined) {
397
- refreshOptions.staleWhileRevalidate = staleWhileRevalidate;
398
- }
399
- // Cache the successful result
400
- this.set(key, result, refreshOptions);
401
- // Clean up
402
- this.pendingFetches.delete(key);
403
- this.pendingRefresh.delete(key);
404
- return result;
405
- },
406
- (error) => {
407
- // Clean up on error
408
- this.pendingFetches.delete(key);
409
- this.pendingRefresh.delete(key);
410
- throw error;
411
- },
412
- );
413
- this.pendingFetches.set(key, refreshPromise);
414
- }
415
- return cached;
416
- }
417
-
418
- // Check if there's already a pending fetch for this key
419
- const existingFetch = this.pendingFetches.get(key) as Promise<T> | undefined;
420
- if (existingFetch) {
421
- return existingFetch;
422
- }
423
-
424
- // Execute the loader
425
- const fetchPromise = options.loader().then(
426
- (result) => {
427
- // Cache the successful result using provided options (no existing entry to preserve)
428
- this.set(key, result, options);
429
- // Clean up pending fetch
430
- this.pendingFetches.delete(key);
431
- this.pendingRefresh.delete(key);
432
- return result;
433
- },
434
- (error) => {
435
- // Clean up on error, don't cache failures
436
- this.pendingFetches.delete(key);
437
- this.pendingRefresh.delete(key);
438
- throw error;
439
- },
440
- );
441
-
442
- // Store the pending fetch
443
- this.pendingFetches.set(key, fetchPromise);
444
- return fetchPromise;
445
- }
446
-
447
- /**
448
- * Evict least recently used entries if cache is at capacity
449
- */
450
- private evictIfNeeded(): void {
451
- if (this.maxEntries <= 0) return;
452
-
453
- while (this.cache.size >= this.maxEntries) {
454
- // Get the first (oldest) entry
455
- const firstKey = this.cache.keys().next().value;
456
- if (firstKey) {
457
- this.cache.delete(firstKey);
458
- this.pendingFetches.delete(firstKey);
459
- this.pendingRefresh.delete(firstKey);
460
- this.evictions++;
461
- } else {
462
- break;
463
- }
464
- }
465
- }
466
-
467
- /**
468
- * Parse environment variable as integer with fallback
469
- */
470
- private parseEnvInt(key: string, defaultValue: number): number {
471
- const value = process.env[key];
472
- if (!value) return defaultValue;
473
-
474
- const parsed = parseInt(value, 10);
475
- return isNaN(parsed) ? defaultValue : parsed;
476
- }
477
-
478
- /**
479
- * Generate cache key from parameters
480
- */
481
- static generateKey(prefix: string, ...params: (string | number | boolean | undefined)[]): string {
482
- const cleanParams = params
483
- .filter((p) => p !== undefined)
484
- .map((p) => String(p))
485
- .join(':');
486
-
487
- return `${prefix}:${cleanParams}`;
488
- }
24
+ private cache = new Map<string, CacheEntry<unknown>>();
25
+ private readonly defaultTTL: number;
26
+ private hits = 0;
27
+ private misses = 0;
28
+ private evictions = 0;
29
+ private lastCleanup: number | null = null;
30
+ private maxEntries: number;
31
+ private defaultStaleWindow: number;
32
+ private pendingFetches = new Map<string, Promise<unknown>>();
33
+ private pendingRefresh = new Set<string>();
34
+
35
+ constructor() {
36
+ this.maxEntries = this.parseEnvInt("YNAB_MCP_CACHE_MAX_ENTRIES", 1000);
37
+ this.defaultStaleWindow = this.parseEnvInt(
38
+ "YNAB_MCP_CACHE_STALE_MS",
39
+ 2 * 60 * 1000,
40
+ );
41
+ this.defaultTTL = this.parseEnvInt("YNAB_MCP_CACHE_DEFAULT_TTL_MS", 300000);
42
+ }
43
+
44
+ /**
45
+ * Get cached data if valid, null if expired or not found
46
+ */
47
+ get<T>(key: string): T | null {
48
+ const entry = this.cache.get(key);
49
+
50
+ if (!entry) {
51
+ this.misses++;
52
+ return null;
53
+ }
54
+
55
+ const now = Date.now();
56
+ const age = now - entry.timestamp;
57
+
58
+ // Check if entry is expired
59
+ if (age > entry.ttl) {
60
+ // Check if we're within stale-while-revalidate window
61
+ const staleWindow = entry.staleWhileRevalidate || 0;
62
+ if (staleWindow > 0 && age <= entry.ttl + staleWindow) {
63
+ this.hits++;
64
+ // Update access order for LRU
65
+ this.cache.delete(key);
66
+ this.cache.set(key, entry);
67
+ // Mark for background refresh
68
+ this.pendingRefresh.add(key);
69
+ return entry.data as T;
70
+ }
71
+
72
+ this.cache.delete(key);
73
+ this.pendingFetches.delete(key);
74
+ this.pendingRefresh.delete(key);
75
+ this.misses++;
76
+ return null;
77
+ }
78
+
79
+ this.hits++;
80
+ // Update access order for LRU
81
+ this.cache.delete(key);
82
+ this.cache.set(key, entry);
83
+ return entry.data as T;
84
+ }
85
+
86
+ /**
87
+ * Check if a valid cache entry exists without updating hit/miss counters
88
+ */
89
+ has(key: string): boolean {
90
+ const entry = this.cache.get(key);
91
+ if (!entry) {
92
+ return false;
93
+ }
94
+
95
+ const now = Date.now();
96
+ const age = now - entry.timestamp;
97
+ if (age > entry.ttl) {
98
+ const staleWindow = entry.staleWhileRevalidate || 0;
99
+ if (staleWindow > 0 && age <= entry.ttl + staleWindow) {
100
+ return true;
101
+ }
102
+
103
+ this.cache.delete(key);
104
+ this.pendingFetches.delete(key);
105
+ this.pendingRefresh.delete(key);
106
+ return false;
107
+ }
108
+
109
+ return true;
110
+ }
111
+
112
+ /**
113
+ * Set cache entry with optional TTL or options
114
+ *
115
+ * @param key - Cache key
116
+ * @param data - Data to cache
117
+ * @param ttlOrOptions - TTL in milliseconds (number) or options object
118
+ *
119
+ * Note: Default stale-while-revalidate window is applied only when:
120
+ * - An options object is provided AND
121
+ * - The staleWhileRevalidate property is explicitly present (even if undefined)
122
+ *
123
+ * When using the simple number interface or when staleWhileRevalidate property
124
+ * is not present in the options object, no default stale window is applied.
125
+ */
126
+ set<T>(key: string, data: T, ttlOrOptions?: number | CacheSetOptions): void {
127
+ // Don't cache anything if maxEntries is 0
128
+ if (this.maxEntries <= 0) {
129
+ return;
130
+ }
131
+
132
+ const isUpdate = this.cache.has(key);
133
+ if (!isUpdate) {
134
+ this.evictIfNeeded();
135
+ }
136
+
137
+ let ttl: number;
138
+ let staleWhileRevalidate: number | undefined;
139
+
140
+ if (typeof ttlOrOptions === "number") {
141
+ ttl = Number.isFinite(ttlOrOptions) ? ttlOrOptions : this.defaultTTL;
142
+ // When using simple number interface, no stale window is applied
143
+ staleWhileRevalidate = undefined;
144
+ } else if (ttlOrOptions === undefined) {
145
+ // When called without any options (simple set), use defaults but NO stale window
146
+ ttl = this.defaultTTL;
147
+ staleWhileRevalidate = undefined;
148
+ } else {
149
+ const providedTtl = ttlOrOptions?.ttl;
150
+ ttl = providedTtl !== undefined ? providedTtl : this.defaultTTL;
151
+ const hasStaleWhileRevalidate =
152
+ ttlOrOptions !== undefined && "staleWhileRevalidate" in ttlOrOptions;
153
+ if (hasStaleWhileRevalidate) {
154
+ staleWhileRevalidate = ttlOrOptions.staleWhileRevalidate;
155
+ if (staleWhileRevalidate === undefined && this.defaultStaleWindow > 0) {
156
+ staleWhileRevalidate = this.defaultStaleWindow;
157
+ }
158
+ } else {
159
+ staleWhileRevalidate = undefined;
160
+ }
161
+ }
162
+ const entry: CacheEntry<T> = {
163
+ data,
164
+ timestamp: Date.now(),
165
+ ttl,
166
+ };
167
+ if (staleWhileRevalidate !== undefined) {
168
+ entry.staleWhileRevalidate = staleWhileRevalidate;
169
+ }
170
+
171
+ if (isUpdate) {
172
+ // When updating, delete then set to preserve MRU ordering
173
+ this.cache.delete(key);
174
+ }
175
+ this.cache.set(key, entry);
176
+ // Clear any pending operations since we have fresh data
177
+ this.pendingFetches.delete(key);
178
+ this.pendingRefresh.delete(key);
179
+ }
180
+
181
+ /**
182
+ * Clear specific cache entry
183
+ */
184
+ delete(key: string): boolean {
185
+ const deleted = this.cache.delete(key);
186
+ if (deleted) {
187
+ this.pendingFetches.delete(key);
188
+ this.pendingRefresh.delete(key);
189
+ }
190
+ return deleted;
191
+ }
192
+
193
+ /**
194
+ * Delete multiple cache entries in a single operation
195
+ */
196
+ deleteMany(keys: Iterable<string>): void {
197
+ for (const key of keys) {
198
+ this.cache.delete(key);
199
+ this.pendingFetches.delete(key);
200
+ this.pendingRefresh.delete(key);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Delete cache entries whose keys begin with the provided prefix.
206
+ * Useful for invalidating a specific resource type across budgets.
207
+ *
208
+ * @param prefix - Cache key prefix (e.g., 'transactions:' or 'accounts:list:')
209
+ * @returns The number of entries removed
210
+ */
211
+ deleteByPrefix(prefix: string): number {
212
+ if (!prefix) {
213
+ return 0;
214
+ }
215
+
216
+ const normalizedPrefix = prefix.endsWith(":")
217
+ ? prefix.slice(0, -1)
218
+ : prefix;
219
+ const prefixWithColon = `${normalizedPrefix}:`;
220
+
221
+ let removed = 0;
222
+ for (const key of this.cache.keys()) {
223
+ if (key === normalizedPrefix || key.startsWith(prefixWithColon)) {
224
+ this.cache.delete(key);
225
+ this.pendingFetches.delete(key);
226
+ this.pendingRefresh.delete(key);
227
+ removed++;
228
+ }
229
+ }
230
+ return removed;
231
+ }
232
+
233
+ /**
234
+ * Delete cache entries that belong to a specific budget.
235
+ * Matches keys containing the budget ID (e.g., '...:budget-123:...').
236
+ *
237
+ * @param budgetId - Budget identifier to match
238
+ */
239
+ deleteByBudgetId(budgetId: string): number {
240
+ if (!budgetId) {
241
+ return 0;
242
+ }
243
+
244
+ let removed = 0;
245
+ for (const key of this.cache.keys()) {
246
+ const segments = key.split(":");
247
+ if (segments.some((segment) => segment === budgetId)) {
248
+ this.cache.delete(key);
249
+ this.pendingFetches.delete(key);
250
+ this.pendingRefresh.delete(key);
251
+ removed++;
252
+ }
253
+ }
254
+ return removed;
255
+ }
256
+
257
+ /**
258
+ * Return all cache keys for debugging and diagnostics.
259
+ *
260
+ * @returns Snapshot of cache keys in insertion order
261
+ */
262
+ getKeys(): string[] {
263
+ return Array.from(this.cache.keys());
264
+ }
265
+
266
+ /**
267
+ * Clear all cache entries
268
+ */
269
+ clear(): void {
270
+ this.cache.clear();
271
+ this.hits = 0;
272
+ this.misses = 0;
273
+ this.evictions = 0;
274
+ this.lastCleanup = null;
275
+ this.pendingFetches.clear();
276
+ this.pendingRefresh.clear();
277
+ }
278
+
279
+ /**
280
+ * Get cache statistics
281
+ */
282
+ getStats(): {
283
+ size: number;
284
+ keys: string[];
285
+ hits: number;
286
+ misses: number;
287
+ evictions: number;
288
+ lastCleanup: number | null;
289
+ maxEntries: number;
290
+ hitRate: number;
291
+ } {
292
+ const totalRequests = this.hits + this.misses;
293
+ return {
294
+ size: this.cache.size,
295
+ keys: Array.from(this.cache.keys()),
296
+ hits: this.hits,
297
+ misses: this.misses,
298
+ evictions: this.evictions,
299
+ lastCleanup: this.lastCleanup,
300
+ maxEntries: this.maxEntries,
301
+ hitRate: totalRequests > 0 ? this.hits / totalRequests : 0,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Provide a filtered snapshot for cache size estimation without exposing expired entries.
307
+ */
308
+ getEntriesForSizeEstimation(): [string, CacheEntry<unknown>][] {
309
+ const now = Date.now();
310
+ return Array.from(this.cache.entries()).filter(
311
+ ([, entry]) => now - entry.timestamp <= entry.ttl,
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Get lightweight cache metadata for size estimation without full entry data.
317
+ * Returns summaries with keys, timestamps, and TTLs for estimating memory usage.
318
+ */
319
+ getCacheMetadata(): {
320
+ key: string;
321
+ timestamp: number;
322
+ ttl: number;
323
+ staleWhileRevalidate?: number;
324
+ dataType: string;
325
+ isExpired: boolean;
326
+ }[] {
327
+ const now = Date.now();
328
+ return Array.from(this.cache.entries()).map(([key, entry]) => {
329
+ const metadata: {
330
+ key: string;
331
+ timestamp: number;
332
+ ttl: number;
333
+ staleWhileRevalidate?: number;
334
+ dataType: string;
335
+ isExpired: boolean;
336
+ } = {
337
+ key,
338
+ timestamp: entry.timestamp,
339
+ ttl: entry.ttl,
340
+ dataType: typeof entry.data,
341
+ isExpired: now - entry.timestamp > entry.ttl,
342
+ };
343
+ if (entry.staleWhileRevalidate !== undefined) {
344
+ metadata.staleWhileRevalidate = entry.staleWhileRevalidate;
345
+ }
346
+ return metadata;
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Clean up expired entries
352
+ */
353
+ cleanup(): number {
354
+ const result = this.cleanupDetailed();
355
+ return result.cleaned;
356
+ }
357
+
358
+ /**
359
+ * Clean up expired entries with detailed information
360
+ */
361
+ cleanupDetailed(): { cleaned: number; evictions: number } {
362
+ const now = Date.now();
363
+ let cleaned = 0;
364
+ const initialEvictions = this.evictions;
365
+
366
+ for (const [key, entry] of this.cache.entries()) {
367
+ if (now - entry.timestamp > entry.ttl) {
368
+ this.cache.delete(key);
369
+ this.pendingFetches.delete(key);
370
+ this.pendingRefresh.delete(key);
371
+ cleaned++;
372
+ this.evictions++;
373
+ }
374
+ }
375
+
376
+ this.lastCleanup = now;
377
+ return { cleaned, evictions: this.evictions - initialEvictions };
378
+ }
379
+
380
+ /**
381
+ * Wrap a loader function with caching and concurrent deduplication
382
+ */
383
+ async wrap<T>(
384
+ key: string,
385
+ options: CacheSetOptions & { loader: () => Promise<T> },
386
+ ): Promise<T> {
387
+ // Check cache first and preserve existing entry for background refresh
388
+ const existingEntry = this.cache.get(key);
389
+ const cached = this.get<T>(key);
390
+ if (cached !== null) {
391
+ // Check if this key was marked for background refresh (stale-while-revalidate)
392
+ if (this.pendingRefresh.has(key) && !this.pendingFetches.has(key)) {
393
+ // Start background refresh
394
+ const refreshPromise = options.loader().then(
395
+ (result) => {
396
+ // Preserve existing TTL/SWR if not specified in options
397
+ const refreshOptions: CacheSetOptions = {};
398
+ const ttl = options.ttl ?? existingEntry?.ttl;
399
+ if (ttl !== undefined) {
400
+ refreshOptions.ttl = ttl;
401
+ }
402
+ const staleWhileRevalidate =
403
+ options.staleWhileRevalidate ??
404
+ existingEntry?.staleWhileRevalidate;
405
+ if (staleWhileRevalidate !== undefined) {
406
+ refreshOptions.staleWhileRevalidate = staleWhileRevalidate;
407
+ }
408
+ // Cache the successful result
409
+ this.set(key, result, refreshOptions);
410
+ // Clean up
411
+ this.pendingFetches.delete(key);
412
+ this.pendingRefresh.delete(key);
413
+ return result;
414
+ },
415
+ (error) => {
416
+ // Clean up on error
417
+ this.pendingFetches.delete(key);
418
+ this.pendingRefresh.delete(key);
419
+ throw error;
420
+ },
421
+ );
422
+ this.pendingFetches.set(key, refreshPromise);
423
+ }
424
+ return cached;
425
+ }
426
+
427
+ // Check if there's already a pending fetch for this key
428
+ const existingFetch = this.pendingFetches.get(key) as
429
+ | Promise<T>
430
+ | undefined;
431
+ if (existingFetch) {
432
+ return existingFetch;
433
+ }
434
+
435
+ // Execute the loader
436
+ const fetchPromise = options.loader().then(
437
+ (result) => {
438
+ // Cache the successful result using provided options (no existing entry to preserve)
439
+ this.set(key, result, options);
440
+ // Clean up pending fetch
441
+ this.pendingFetches.delete(key);
442
+ this.pendingRefresh.delete(key);
443
+ return result;
444
+ },
445
+ (error) => {
446
+ // Clean up on error, don't cache failures
447
+ this.pendingFetches.delete(key);
448
+ this.pendingRefresh.delete(key);
449
+ throw error;
450
+ },
451
+ );
452
+
453
+ // Store the pending fetch
454
+ this.pendingFetches.set(key, fetchPromise);
455
+ return fetchPromise;
456
+ }
457
+
458
+ /**
459
+ * Evict least recently used entries if cache is at capacity
460
+ */
461
+ private evictIfNeeded(): void {
462
+ if (this.maxEntries <= 0) return;
463
+
464
+ while (this.cache.size >= this.maxEntries) {
465
+ // Get the first (oldest) entry
466
+ const firstKey = this.cache.keys().next().value;
467
+ if (firstKey) {
468
+ this.cache.delete(firstKey);
469
+ this.pendingFetches.delete(firstKey);
470
+ this.pendingRefresh.delete(firstKey);
471
+ this.evictions++;
472
+ } else {
473
+ break;
474
+ }
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Parse environment variable as integer with fallback
480
+ */
481
+ private parseEnvInt(key: string, defaultValue: number): number {
482
+ const value = process.env[key];
483
+ if (!value) return defaultValue;
484
+
485
+ const parsed = Number.parseInt(value, 10);
486
+ return Number.isNaN(parsed) ? defaultValue : parsed;
487
+ }
488
+
489
+ /**
490
+ * Generate cache key from parameters
491
+ */
492
+ static generateKey(
493
+ prefix: string,
494
+ ...params: (string | number | boolean | undefined)[]
495
+ ): string {
496
+ const cleanParams = params
497
+ .filter((p) => p !== undefined)
498
+ .map((p) => String(p))
499
+ .join(":");
500
+
501
+ return `${prefix}:${cleanParams}`;
502
+ }
489
503
  }
490
504
 
491
505
  // Cache TTL configurations for different data types
492
506
  export const CACHE_TTLS = {
493
- BUDGETS: 10 * 60 * 1000, // 10 minutes - budgets don't change often
494
- ACCOUNTS: 5 * 60 * 1000, // 5 minutes - account info is fairly static
495
- CATEGORIES: 5 * 60 * 1000, // 5 minutes - categories change infrequently
496
- PAYEES: 10 * 60 * 1000, // 10 minutes - payees are relatively stable
497
- TRANSACTIONS: 2 * 60 * 1000, // 2 minutes - transactions change more frequently
498
- SCHEDULED_TRANSACTIONS: 5 * 60 * 1000, // 5 minutes - scheduled transactions rarely change rapidly
499
- USER_INFO: 30 * 60 * 1000, // 30 minutes - user info rarely changes
500
- MONTHS: 5 * 60 * 1000, // 5 minutes - month data changes with new transactions
507
+ BUDGETS: 10 * 60 * 1000, // 10 minutes - budgets don't change often
508
+ ACCOUNTS: 5 * 60 * 1000, // 5 minutes - account info is fairly static
509
+ CATEGORIES: 5 * 60 * 1000, // 5 minutes - categories change infrequently
510
+ PAYEES: 10 * 60 * 1000, // 10 minutes - payees are relatively stable
511
+ TRANSACTIONS: 2 * 60 * 1000, // 2 minutes - transactions change more frequently
512
+ SCHEDULED_TRANSACTIONS: 5 * 60 * 1000, // 5 minutes - scheduled transactions rarely change rapidly
513
+ USER_INFO: 30 * 60 * 1000, // 30 minutes - user info rarely changes
514
+ MONTHS: 5 * 60 * 1000, // 5 minutes - month data changes with new transactions
501
515
  } as const;
502
516
 
503
517
  // Singleton cache manager instance