@dizzlkheinz/ynab-mcpb 0.18.4 → 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 (343) hide show
  1. package/CLAUDE.md +87 -8
  2. package/bin/ynab-mcp-server.cjs +2 -2
  3. package/bin/ynab-mcp-server.js +3 -3
  4. package/biome.json +39 -0
  5. package/dist/bundle/index.cjs +67 -67
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +27 -27
  8. package/dist/server/YNABMCPServer.d.ts +3 -4
  9. package/dist/server/YNABMCPServer.js +111 -116
  10. package/dist/server/budgetResolver.d.ts +6 -5
  11. package/dist/server/budgetResolver.js +46 -36
  12. package/dist/server/cacheKeys.js +6 -6
  13. package/dist/server/cacheManager.js +14 -11
  14. package/dist/server/completions.d.ts +2 -2
  15. package/dist/server/completions.js +20 -15
  16. package/dist/server/config.d.ts +10 -5
  17. package/dist/server/config.js +24 -7
  18. package/dist/server/deltaCache.d.ts +2 -2
  19. package/dist/server/deltaCache.js +22 -16
  20. package/dist/server/deltaCache.merge.d.ts +2 -2
  21. package/dist/server/diagnostics.d.ts +4 -4
  22. package/dist/server/diagnostics.js +38 -32
  23. package/dist/server/errorHandler.d.ts +5 -12
  24. package/dist/server/errorHandler.js +219 -217
  25. package/dist/server/prompts.d.ts +2 -2
  26. package/dist/server/prompts.js +45 -45
  27. package/dist/server/rateLimiter.js +4 -4
  28. package/dist/server/requestLogger.d.ts +1 -1
  29. package/dist/server/requestLogger.js +40 -35
  30. package/dist/server/resources.d.ts +3 -3
  31. package/dist/server/resources.js +55 -52
  32. package/dist/server/responseFormatter.js +6 -6
  33. package/dist/server/securityMiddleware.d.ts +2 -2
  34. package/dist/server/securityMiddleware.js +22 -20
  35. package/dist/server/serverKnowledgeStore.js +1 -1
  36. package/dist/server/toolRegistry.d.ts +3 -3
  37. package/dist/server/toolRegistry.js +47 -40
  38. package/dist/tools/__tests__/deltaTestUtils.d.ts +3 -3
  39. package/dist/tools/__tests__/deltaTestUtils.js +2 -2
  40. package/dist/tools/accountTools.d.ts +9 -8
  41. package/dist/tools/accountTools.js +47 -47
  42. package/dist/tools/adapters.d.ts +13 -8
  43. package/dist/tools/adapters.js +21 -11
  44. package/dist/tools/budgetTools.d.ts +8 -7
  45. package/dist/tools/budgetTools.js +22 -22
  46. package/dist/tools/categoryTools.d.ts +9 -8
  47. package/dist/tools/categoryTools.js +68 -59
  48. package/dist/tools/compareTransactions/formatter.d.ts +3 -3
  49. package/dist/tools/compareTransactions/formatter.js +9 -9
  50. package/dist/tools/compareTransactions/index.d.ts +6 -6
  51. package/dist/tools/compareTransactions/index.js +58 -43
  52. package/dist/tools/compareTransactions/matcher.d.ts +1 -1
  53. package/dist/tools/compareTransactions/matcher.js +28 -15
  54. package/dist/tools/compareTransactions/parser.d.ts +2 -2
  55. package/dist/tools/compareTransactions/parser.js +144 -138
  56. package/dist/tools/compareTransactions/types.d.ts +4 -4
  57. package/dist/tools/compareTransactions.d.ts +1 -1
  58. package/dist/tools/compareTransactions.js +1 -1
  59. package/dist/tools/deltaFetcher.d.ts +2 -2
  60. package/dist/tools/deltaFetcher.js +16 -15
  61. package/dist/tools/deltaSupport.d.ts +4 -4
  62. package/dist/tools/deltaSupport.js +35 -41
  63. package/dist/tools/exportTransactions.d.ts +5 -4
  64. package/dist/tools/exportTransactions.js +61 -59
  65. package/dist/tools/monthTools.d.ts +7 -6
  66. package/dist/tools/monthTools.js +31 -29
  67. package/dist/tools/payeeTools.d.ts +7 -6
  68. package/dist/tools/payeeTools.js +28 -28
  69. package/dist/tools/reconcileAdapter.d.ts +2 -2
  70. package/dist/tools/reconcileAdapter.js +19 -12
  71. package/dist/tools/reconciliation/analyzer.d.ts +4 -4
  72. package/dist/tools/reconciliation/analyzer.js +73 -59
  73. package/dist/tools/reconciliation/csvParser.d.ts +3 -3
  74. package/dist/tools/reconciliation/csvParser.js +128 -104
  75. package/dist/tools/reconciliation/executor.d.ts +4 -4
  76. package/dist/tools/reconciliation/executor.js +148 -109
  77. package/dist/tools/reconciliation/index.d.ts +10 -10
  78. package/dist/tools/reconciliation/index.js +96 -83
  79. package/dist/tools/reconciliation/matcher.d.ts +3 -3
  80. package/dist/tools/reconciliation/matcher.js +17 -16
  81. package/dist/tools/reconciliation/payeeNormalizer.js +19 -8
  82. package/dist/tools/reconciliation/recommendationEngine.d.ts +1 -1
  83. package/dist/tools/reconciliation/recommendationEngine.js +40 -40
  84. package/dist/tools/reconciliation/reportFormatter.d.ts +2 -2
  85. package/dist/tools/reconciliation/reportFormatter.js +59 -58
  86. package/dist/tools/reconciliation/signDetector.d.ts +1 -1
  87. package/dist/tools/reconciliation/types.d.ts +16 -16
  88. package/dist/tools/reconciliation/ynabAdapter.d.ts +2 -2
  89. package/dist/tools/schemas/common.d.ts +1 -1
  90. package/dist/tools/schemas/common.js +1 -1
  91. package/dist/tools/schemas/outputs/accountOutputs.d.ts +1 -1
  92. package/dist/tools/schemas/outputs/accountOutputs.js +24 -18
  93. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +1 -1
  94. package/dist/tools/schemas/outputs/budgetOutputs.js +14 -11
  95. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +1 -1
  96. package/dist/tools/schemas/outputs/categoryOutputs.js +49 -29
  97. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  98. package/dist/tools/schemas/outputs/comparisonOutputs.js +12 -12
  99. package/dist/tools/schemas/outputs/index.d.ts +14 -14
  100. package/dist/tools/schemas/outputs/index.js +14 -14
  101. package/dist/tools/schemas/outputs/monthOutputs.d.ts +1 -1
  102. package/dist/tools/schemas/outputs/monthOutputs.js +56 -41
  103. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +1 -1
  104. package/dist/tools/schemas/outputs/payeeOutputs.js +10 -10
  105. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +2 -2
  106. package/dist/tools/schemas/outputs/reconciliationOutputs.js +45 -45
  107. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +1 -1
  108. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +28 -22
  109. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +1 -1
  110. package/dist/tools/schemas/outputs/transactionOutputs.js +43 -35
  111. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +1 -1
  112. package/dist/tools/schemas/outputs/utilityOutputs.js +5 -3
  113. package/dist/tools/schemas/shared/commonOutputs.d.ts +1 -1
  114. package/dist/tools/schemas/shared/commonOutputs.js +15 -9
  115. package/dist/tools/transactionReadTools.d.ts +11 -0
  116. package/dist/tools/transactionReadTools.js +202 -0
  117. package/dist/tools/transactionSchemas.d.ts +7 -7
  118. package/dist/tools/transactionSchemas.js +77 -57
  119. package/dist/tools/transactionTools.d.ts +6 -24
  120. package/dist/tools/transactionTools.js +7 -1499
  121. package/dist/tools/transactionUtils.d.ts +6 -6
  122. package/dist/tools/transactionUtils.js +78 -63
  123. package/dist/tools/transactionWriteTools.d.ts +20 -0
  124. package/dist/tools/transactionWriteTools.js +1342 -0
  125. package/dist/tools/utilityTools.d.ts +5 -4
  126. package/dist/tools/utilityTools.js +11 -11
  127. package/dist/types/index.d.ts +7 -7
  128. package/dist/types/index.js +6 -6
  129. package/dist/types/reconciliation.d.ts +1 -1
  130. package/dist/types/toolRegistration.d.ts +14 -12
  131. package/dist/utils/amountUtils.js +1 -1
  132. package/dist/utils/dateUtils.js +4 -4
  133. package/dist/utils/errors.d.ts +3 -3
  134. package/dist/utils/errors.js +4 -4
  135. package/dist/utils/money.d.ts +2 -2
  136. package/dist/utils/money.js +8 -8
  137. package/dist/utils/validationError.d.ts +1 -1
  138. package/dist/utils/validationError.js +1 -1
  139. package/docs/assets/examples/reconciliation-with-recommendations.json +66 -66
  140. package/docs/assets/schemas/reconciliation-v2.json +360 -336
  141. package/esbuild.config.mjs +53 -50
  142. package/meta.json +12548 -12548
  143. package/package.json +98 -111
  144. package/scripts/analyze-bundle.mjs +33 -30
  145. package/scripts/create-pr-description.js +169 -120
  146. package/scripts/run-all-tests.js +178 -169
  147. package/scripts/run-domain-integration-tests.js +28 -18
  148. package/scripts/run-generate-mcpb.js +19 -17
  149. package/scripts/run-throttled-integration-tests.js +92 -83
  150. package/scripts/test-delta-params.mjs +149 -120
  151. package/scripts/test-recommendations.ts +36 -32
  152. package/scripts/tmpTransaction.ts +80 -43
  153. package/scripts/validate-env.js +98 -91
  154. package/scripts/verify-build.js +78 -76
  155. package/src/__tests__/comprehensive.integration.test.ts +1281 -1154
  156. package/src/__tests__/performance.test.ts +723 -671
  157. package/src/__tests__/setup.ts +442 -395
  158. package/src/__tests__/smoke.e2e.test.ts +41 -39
  159. package/src/__tests__/testRunner.ts +314 -295
  160. package/src/__tests__/testUtils.ts +456 -364
  161. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +109 -107
  162. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +41 -41
  163. package/src/index.ts +68 -59
  164. package/src/server/CLAUDE.md +480 -0
  165. package/src/server/YNABMCPServer.ts +821 -794
  166. package/src/server/__tests__/YNABMCPServer.integration.test.ts +929 -893
  167. package/src/server/__tests__/YNABMCPServer.test.ts +903 -899
  168. package/src/server/__tests__/budgetResolver.test.ts +466 -423
  169. package/src/server/__tests__/cacheManager.test.ts +891 -874
  170. package/src/server/__tests__/completions.integration.test.ts +115 -106
  171. package/src/server/__tests__/completions.test.ts +334 -313
  172. package/src/server/__tests__/config.test.ts +98 -86
  173. package/src/server/__tests__/deltaCache.merge.test.ts +774 -703
  174. package/src/server/__tests__/deltaCache.swr.test.ts +198 -153
  175. package/src/server/__tests__/deltaCache.test.ts +946 -759
  176. package/src/server/__tests__/diagnostics.test.ts +825 -792
  177. package/src/server/__tests__/errorHandler.integration.test.ts +512 -462
  178. package/src/server/__tests__/errorHandler.test.ts +402 -397
  179. package/src/server/__tests__/prompts.test.ts +424 -347
  180. package/src/server/__tests__/rateLimiter.test.ts +313 -309
  181. package/src/server/__tests__/requestLogger.test.ts +443 -403
  182. package/src/server/__tests__/resources.template.test.ts +196 -185
  183. package/src/server/__tests__/resources.test.ts +294 -288
  184. package/src/server/__tests__/security.integration.test.ts +487 -421
  185. package/src/server/__tests__/securityMiddleware.test.ts +519 -444
  186. package/src/server/__tests__/server-startup.integration.test.ts +509 -490
  187. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -173
  188. package/src/server/__tests__/toolRegistration.test.ts +239 -210
  189. package/src/server/__tests__/toolRegistry.test.ts +907 -845
  190. package/src/server/budgetResolver.ts +221 -181
  191. package/src/server/cacheKeys.ts +6 -6
  192. package/src/server/cacheManager.ts +498 -484
  193. package/src/server/completions.ts +267 -243
  194. package/src/server/config.ts +35 -14
  195. package/src/server/deltaCache.merge.ts +146 -128
  196. package/src/server/deltaCache.ts +352 -309
  197. package/src/server/diagnostics.ts +257 -242
  198. package/src/server/errorHandler.ts +747 -744
  199. package/src/server/prompts.ts +181 -176
  200. package/src/server/rateLimiter.ts +131 -129
  201. package/src/server/requestLogger.ts +350 -322
  202. package/src/server/resources.ts +442 -374
  203. package/src/server/responseFormatter.ts +41 -37
  204. package/src/server/securityMiddleware.ts +223 -205
  205. package/src/server/serverKnowledgeStore.ts +67 -67
  206. package/src/server/toolRegistry.ts +508 -474
  207. package/src/tools/CLAUDE.md +604 -0
  208. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -111
  209. package/src/tools/__tests__/accountTools.integration.test.ts +129 -111
  210. package/src/tools/__tests__/accountTools.test.ts +685 -638
  211. package/src/tools/__tests__/adapters.test.ts +142 -108
  212. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +73 -73
  213. package/src/tools/__tests__/budgetTools.integration.test.ts +132 -124
  214. package/src/tools/__tests__/budgetTools.test.ts +442 -413
  215. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +76 -68
  216. package/src/tools/__tests__/categoryTools.integration.test.ts +314 -288
  217. package/src/tools/__tests__/categoryTools.test.ts +656 -625
  218. package/src/tools/__tests__/compareTransactions/formatter.test.ts +535 -462
  219. package/src/tools/__tests__/compareTransactions/index.test.ts +378 -358
  220. package/src/tools/__tests__/compareTransactions/matcher.test.ts +497 -398
  221. package/src/tools/__tests__/compareTransactions/parser.test.ts +765 -747
  222. package/src/tools/__tests__/compareTransactions.test.ts +352 -332
  223. package/src/tools/__tests__/compareTransactions.window.test.ts +150 -146
  224. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +69 -65
  225. package/src/tools/__tests__/deltaFetcher.test.ts +325 -265
  226. package/src/tools/__tests__/deltaSupport.test.ts +211 -184
  227. package/src/tools/__tests__/deltaTestUtils.ts +37 -33
  228. package/src/tools/__tests__/exportTransactions.test.ts +205 -200
  229. package/src/tools/__tests__/monthTools.delta.integration.test.ts +68 -68
  230. package/src/tools/__tests__/monthTools.integration.test.ts +178 -166
  231. package/src/tools/__tests__/monthTools.test.ts +561 -512
  232. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +68 -68
  233. package/src/tools/__tests__/payeeTools.integration.test.ts +158 -142
  234. package/src/tools/__tests__/payeeTools.test.ts +486 -434
  235. package/src/tools/__tests__/transactionSchemas.test.ts +1202 -1186
  236. package/src/tools/__tests__/transactionTools.integration.test.ts +875 -825
  237. package/src/tools/__tests__/transactionTools.test.ts +4923 -4366
  238. package/src/tools/__tests__/transactionUtils.test.ts +1004 -977
  239. package/src/tools/__tests__/utilityTools.integration.test.ts +32 -32
  240. package/src/tools/__tests__/utilityTools.test.ts +68 -58
  241. package/src/tools/accountTools.ts +293 -271
  242. package/src/tools/adapters.ts +120 -63
  243. package/src/tools/budgetTools.ts +121 -116
  244. package/src/tools/categoryTools.ts +379 -339
  245. package/src/tools/compareTransactions/formatter.ts +131 -119
  246. package/src/tools/compareTransactions/index.ts +249 -214
  247. package/src/tools/compareTransactions/matcher.ts +259 -209
  248. package/src/tools/compareTransactions/parser.ts +517 -487
  249. package/src/tools/compareTransactions/types.ts +38 -38
  250. package/src/tools/compareTransactions.ts +1 -1
  251. package/src/tools/deltaFetcher.ts +281 -260
  252. package/src/tools/deltaSupport.ts +264 -259
  253. package/src/tools/exportTransactions.ts +230 -218
  254. package/src/tools/monthTools.ts +180 -165
  255. package/src/tools/payeeTools.ts +152 -140
  256. package/src/tools/reconcileAdapter.ts +297 -252
  257. package/src/tools/reconciliation/CLAUDE.md +506 -0
  258. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +133 -124
  259. package/src/tools/reconciliation/__tests__/adapter.test.ts +249 -230
  260. package/src/tools/reconciliation/__tests__/analyzer.test.ts +408 -400
  261. package/src/tools/reconciliation/__tests__/csvParser.test.ts +71 -69
  262. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +348 -323
  263. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +503 -457
  264. package/src/tools/reconciliation/__tests__/executor.test.ts +898 -831
  265. package/src/tools/reconciliation/__tests__/matcher.test.ts +667 -663
  266. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +296 -276
  267. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +692 -624
  268. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1008 -989
  269. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +187 -146
  270. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +583 -533
  271. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +75 -74
  272. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +70 -62
  273. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +102 -88
  274. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +56 -55
  275. package/src/tools/reconciliation/__tests__/signDetector.test.ts +209 -206
  276. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +66 -60
  277. package/src/tools/reconciliation/analyzer.ts +564 -504
  278. package/src/tools/reconciliation/csvParser.ts +656 -609
  279. package/src/tools/reconciliation/executor.ts +1290 -1128
  280. package/src/tools/reconciliation/index.ts +580 -528
  281. package/src/tools/reconciliation/matcher.ts +256 -240
  282. package/src/tools/reconciliation/payeeNormalizer.ts +92 -78
  283. package/src/tools/reconciliation/recommendationEngine.ts +357 -345
  284. package/src/tools/reconciliation/reportFormatter.ts +343 -307
  285. package/src/tools/reconciliation/signDetector.ts +89 -83
  286. package/src/tools/reconciliation/types.ts +164 -159
  287. package/src/tools/reconciliation/ynabAdapter.ts +17 -15
  288. package/src/tools/schemas/CLAUDE.md +546 -0
  289. package/src/tools/schemas/common.ts +1 -1
  290. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +410 -409
  291. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +305 -299
  292. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +431 -430
  293. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +510 -495
  294. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +179 -153
  295. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +293 -254
  296. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +457 -457
  297. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +362 -356
  298. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +402 -399
  299. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +225 -211
  300. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +457 -454
  301. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +316 -315
  302. package/src/tools/schemas/outputs/accountOutputs.ts +40 -34
  303. package/src/tools/schemas/outputs/budgetOutputs.ts +24 -19
  304. package/src/tools/schemas/outputs/categoryOutputs.ts +76 -56
  305. package/src/tools/schemas/outputs/comparisonOutputs.ts +192 -169
  306. package/src/tools/schemas/outputs/index.ts +163 -163
  307. package/src/tools/schemas/outputs/monthOutputs.ts +95 -80
  308. package/src/tools/schemas/outputs/payeeOutputs.ts +18 -18
  309. package/src/tools/schemas/outputs/reconciliationOutputs.ts +386 -373
  310. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +259 -231
  311. package/src/tools/schemas/outputs/transactionOutputs.ts +81 -71
  312. package/src/tools/schemas/outputs/utilityOutputs.ts +90 -84
  313. package/src/tools/schemas/shared/commonOutputs.ts +27 -19
  314. package/src/tools/toolCategories.ts +114 -114
  315. package/src/tools/transactionReadTools.ts +327 -0
  316. package/src/tools/transactionSchemas.ts +322 -291
  317. package/src/tools/transactionTools.ts +84 -2246
  318. package/src/tools/transactionUtils.ts +507 -422
  319. package/src/tools/transactionWriteTools.ts +2110 -0
  320. package/src/tools/utilityTools.ts +46 -41
  321. package/src/types/CLAUDE.md +477 -0
  322. package/src/types/__tests__/index.test.ts +51 -51
  323. package/src/types/index.ts +43 -39
  324. package/src/types/integration-tests.d.ts +26 -26
  325. package/src/types/reconciliation.ts +29 -29
  326. package/src/types/toolAnnotations.ts +30 -30
  327. package/src/types/toolRegistration.ts +43 -32
  328. package/src/utils/CLAUDE.md +508 -0
  329. package/src/utils/__tests__/dateUtils.test.ts +174 -168
  330. package/src/utils/__tests__/money.test.ts +193 -187
  331. package/src/utils/amountUtils.ts +5 -5
  332. package/src/utils/baseError.ts +5 -5
  333. package/src/utils/dateUtils.ts +29 -26
  334. package/src/utils/errors.ts +14 -14
  335. package/src/utils/money.ts +66 -52
  336. package/src/utils/validationError.ts +1 -1
  337. package/tsconfig.json +29 -29
  338. package/tsconfig.prod.json +16 -16
  339. package/vitest-reporters/split-json-reporter.ts +247 -204
  340. package/vitest.config.ts +99 -95
  341. package/.prettierignore +0 -10
  342. package/.prettierrc.json +0 -10
  343. 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