ynab-mcp-deluxe 0.1.9

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.
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Drift detection for LocalBudget.
3
+ *
4
+ * This module compares a merged LocalBudget (base + deltas) against a fresh
5
+ * full budget fetch to detect any discrepancies in our merge logic.
6
+ *
7
+ * When drift is detected:
8
+ * 1. Log detailed warnings showing what differs
9
+ * 2. Self-heal by replacing local budget with the full fetch result
10
+ */
11
+ import deepDiff from 'deep-diff';
12
+ // ============================================================================
13
+ // Type Guards for deep-diff types
14
+ // ============================================================================
15
+ /**
16
+ * Type guard for DiffNew (kind: "N") - entity exists in truth but not in merged.
17
+ * Indicates something was added in the truth that the merge didn't produce.
18
+ */
19
+ function isDiffNew(d) {
20
+ return d.kind === 'N';
21
+ }
22
+ /**
23
+ * Type guard for DiffDeleted (kind: "D") - entity exists in merged but not in truth.
24
+ * Indicates something extra in merged that shouldn't be there.
25
+ */
26
+ function isDiffDeleted(d) {
27
+ return d.kind === 'D';
28
+ }
29
+ /**
30
+ * Type guard for DiffEdit (kind: "E") - entity exists in both but values differ.
31
+ * Indicates a field value mismatch between merged and truth.
32
+ */
33
+ function isDiffEdit(d) {
34
+ return d.kind === 'E';
35
+ }
36
+ /**
37
+ * Type guard for DiffArray (kind: "A") - array-specific change at an index.
38
+ * Indicates an element-level change within an array.
39
+ */
40
+ function isDiffArray(d) {
41
+ return d.kind === 'A';
42
+ }
43
+ /**
44
+ * Per-budget drift check state.
45
+ * Using a Map keyed by budgetId ensures each budget tracks its own
46
+ * drift check frequency independently, preventing race conditions
47
+ * when multiple budgets are synced concurrently.
48
+ */
49
+ const driftCheckStateByBudget = new Map();
50
+ /**
51
+ * Get or create drift check state for a specific budget.
52
+ */
53
+ function getDriftCheckState(budgetId) {
54
+ let state = driftCheckStateByBudget.get(budgetId);
55
+ if (state === undefined) {
56
+ state = {
57
+ checkEvaluationCount: 0,
58
+ lastDriftCheckAt: null,
59
+ };
60
+ driftCheckStateByBudget.set(budgetId, state);
61
+ }
62
+ return state;
63
+ }
64
+ // ============================================================================
65
+ // Environment Variable Helpers
66
+ // ============================================================================
67
+ /**
68
+ * Check if drift detection is enabled.
69
+ *
70
+ * Default: true (enabled)
71
+ * Set YNAB_DRIFT_DETECTION=false to disable
72
+ */
73
+ export function isDriftDetectionEnabled() {
74
+ const value = process.env['YNAB_DRIFT_DETECTION'];
75
+ // Default to true if not set
76
+ if (value === undefined || value === '') {
77
+ return true;
78
+ }
79
+ // Explicitly disabled
80
+ return value !== 'false' && value !== '0';
81
+ }
82
+ /**
83
+ * Check if "always full sync" mode is enabled.
84
+ * When enabled, skip delta queries entirely and always fetch the full budget.
85
+ *
86
+ * Default: false (use delta sync for performance)
87
+ * Set YNAB_ALWAYS_FULL_SYNC=true to enable
88
+ */
89
+ export function isAlwaysFullSyncEnabled() {
90
+ const value = process.env['YNAB_ALWAYS_FULL_SYNC'];
91
+ return value === 'true' || value === '1';
92
+ }
93
+ /**
94
+ * Get the drift check interval in number of syncs.
95
+ * In production, we don't need to check every sync - periodic checks are sufficient.
96
+ *
97
+ * Default: 1 (check every sync - good for development/alpha)
98
+ * Set YNAB_DRIFT_CHECK_INTERVAL_SYNCS to change
99
+ */
100
+ export function getDriftCheckIntervalSyncs() {
101
+ const value = process.env['YNAB_DRIFT_CHECK_INTERVAL_SYNCS'];
102
+ if (value === undefined || value === '') {
103
+ return 1; // Default: check every sync
104
+ }
105
+ const parsed = parseInt(value, 10);
106
+ if (isNaN(parsed) || parsed < 1) {
107
+ return 1;
108
+ }
109
+ return parsed;
110
+ }
111
+ /**
112
+ * Get the drift check interval in minutes.
113
+ * Alternative to sync-count-based checking.
114
+ *
115
+ * Default: 0 (disabled - use sync count instead)
116
+ * Set YNAB_DRIFT_CHECK_INTERVAL_MINUTES to enable time-based checking
117
+ */
118
+ export function getDriftCheckIntervalMinutes() {
119
+ const value = process.env['YNAB_DRIFT_CHECK_INTERVAL_MINUTES'];
120
+ if (value === undefined || value === '') {
121
+ return 0; // Disabled by default
122
+ }
123
+ const parsed = parseInt(value, 10);
124
+ if (isNaN(parsed) || parsed < 0) {
125
+ return 0;
126
+ }
127
+ return parsed;
128
+ }
129
+ // ============================================================================
130
+ // Drift Check Logic
131
+ // ============================================================================
132
+ /**
133
+ * Determine if a drift check should be performed based on frequency settings.
134
+ * Call this before performing a drift check to respect rate limiting.
135
+ *
136
+ * @param budgetId - The budget ID to check drift state for
137
+ * @returns true if a drift check should be performed
138
+ */
139
+ export function shouldPerformDriftCheck(budgetId) {
140
+ if (!isDriftDetectionEnabled()) {
141
+ return false;
142
+ }
143
+ // Always full sync mode means we don't need drift checks
144
+ // (we're always getting the full budget anyway)
145
+ if (isAlwaysFullSyncEnabled()) {
146
+ return false;
147
+ }
148
+ // Get per-budget state
149
+ const state = getDriftCheckState(budgetId);
150
+ // Increment the evaluation count for this budget.
151
+ // This tracks how many times we've checked whether to perform a drift check,
152
+ // NOT how many actual syncs have occurred. A drift check is triggered when
153
+ // this count reaches a multiple of the configured interval.
154
+ state.checkEvaluationCount++;
155
+ // Check if we've reached the interval threshold
156
+ const checkInterval = getDriftCheckIntervalSyncs();
157
+ if (state.checkEvaluationCount % checkInterval === 0) {
158
+ return true;
159
+ }
160
+ // Check time interval (if configured)
161
+ const minuteInterval = getDriftCheckIntervalMinutes();
162
+ if (minuteInterval > 0 && state.lastDriftCheckAt !== null) {
163
+ const elapsed = Date.now() - state.lastDriftCheckAt.getTime();
164
+ const elapsedMinutes = elapsed / (1000 * 60);
165
+ if (elapsedMinutes >= minuteInterval) {
166
+ return true;
167
+ }
168
+ }
169
+ return false;
170
+ }
171
+ /**
172
+ * Record that a drift check was performed for a specific budget.
173
+ * Call this after completing a drift check.
174
+ *
175
+ * @param budgetId - The budget ID to record the drift check for
176
+ */
177
+ export function recordDriftCheck(budgetId) {
178
+ const state = getDriftCheckState(budgetId);
179
+ state.lastDriftCheckAt = new Date();
180
+ }
181
+ /**
182
+ * Reset drift check state (useful for testing).
183
+ *
184
+ * @param budgetId - Optional budget ID to reset. If not provided, resets all budgets.
185
+ */
186
+ export function resetDriftCheckState(budgetId) {
187
+ if (budgetId !== undefined) {
188
+ // Reset specific budget
189
+ driftCheckStateByBudget.delete(budgetId);
190
+ }
191
+ else {
192
+ // Reset all budgets
193
+ driftCheckStateByBudget.clear();
194
+ }
195
+ }
196
+ /**
197
+ * Sort an array of entities by their `id` field for consistent comparison.
198
+ * This ensures that arrays are compared by content, not by position.
199
+ */
200
+ function sortById(arr) {
201
+ return [...arr].sort((a, b) => a.id.localeCompare(b.id));
202
+ }
203
+ /**
204
+ * Prepare months for comparison by sorting by `month` key and sorting
205
+ * nested `categories` arrays by `id`.
206
+ */
207
+ function prepareMonths(months) {
208
+ return [...months]
209
+ .map((m) => ({
210
+ ...m,
211
+ categories: sortById(m.categories),
212
+ }))
213
+ .sort((a, b) => a.month.localeCompare(b.month));
214
+ }
215
+ /**
216
+ * Prepare a LocalBudget for comparison by converting Maps to plain objects,
217
+ * removing non-comparable fields, and normalizing array order.
218
+ *
219
+ * Arrays are sorted by ID to ensure comparison is by content, not position.
220
+ * This is necessary because delta sync may return entities in a different
221
+ * order than a full fetch.
222
+ *
223
+ * @param budget - The LocalBudget to prepare
224
+ * @returns A plain object suitable for deep comparison
225
+ */
226
+ function prepareForComparison(budget) {
227
+ // Only compare the data arrays, not the lookup maps or metadata
228
+ // Sort all arrays by ID to compare by content, not position
229
+ return {
230
+ accounts: sortById(budget.accounts),
231
+ budgetId: budget.budgetId,
232
+ budgetName: budget.budgetName,
233
+ categories: sortById(budget.categories),
234
+ categoryGroups: sortById(budget.categoryGroups),
235
+ currencyFormat: budget.currencyFormat,
236
+ months: prepareMonths(budget.months),
237
+ payeeLocations: sortById(budget.payeeLocations),
238
+ payees: sortById(budget.payees),
239
+ scheduledSubtransactions: sortById(budget.scheduledSubtransactions),
240
+ scheduledTransactions: sortById(budget.scheduledTransactions),
241
+ // Skip serverKnowledge as it will differ
242
+ subtransactions: sortById(budget.subtransactions),
243
+ transactions: sortById(budget.transactions),
244
+ };
245
+ }
246
+ /**
247
+ * Get a human-readable path from a diff result.
248
+ */
249
+ function formatDiffPath(diffResult) {
250
+ if (diffResult.path === undefined) {
251
+ return '(root)';
252
+ }
253
+ return diffResult.path.map((p) => String(p)).join('.');
254
+ }
255
+ /**
256
+ * Summarize differences by top-level category.
257
+ */
258
+ function summarizeDifferences(differences) {
259
+ const summary = {};
260
+ for (const d of differences) {
261
+ // d.path is typed as any[] | undefined in deep-diff, so we handle it safely
262
+ const pathArray = d.path;
263
+ const firstPath = pathArray?.[0];
264
+ const category = firstPath !== undefined ? String(firstPath) : 'unknown';
265
+ summary[category] = (summary[category] ?? 0) + 1;
266
+ }
267
+ return summary;
268
+ }
269
+ /**
270
+ * Compare a merged LocalBudget against a "truth" budget from a full fetch.
271
+ *
272
+ * @param mergedBudget - The budget built from base + delta merges
273
+ * @param truthBudget - The budget from a fresh full fetch (source of truth)
274
+ * @returns Detailed comparison result
275
+ */
276
+ export function checkForDrift(mergedBudget, truthBudget) {
277
+ const serverKnowledgeMismatch = mergedBudget.serverKnowledge !== truthBudget.serverKnowledge;
278
+ // Prepare budgets for comparison (strip Maps and metadata)
279
+ const mergedData = prepareForComparison(mergedBudget);
280
+ const truthData = prepareForComparison(truthBudget);
281
+ // Perform deep comparison
282
+ const differences = deepDiff(mergedData, truthData) ?? [];
283
+ return {
284
+ differenceCount: differences.length,
285
+ differenceSummary: summarizeDifferences(differences),
286
+ differences,
287
+ hasDrift: differences.length > 0,
288
+ mergedServerKnowledge: mergedBudget.serverKnowledge,
289
+ serverKnowledgeMismatch,
290
+ truthServerKnowledge: truthBudget.serverKnowledge,
291
+ };
292
+ }
293
+ /**
294
+ * Log drift check results appropriately based on outcome.
295
+ *
296
+ * @param result - The drift check result
297
+ * @param budgetId - The budget ID (for logging context)
298
+ * @param log - The logger to use
299
+ */
300
+ export function logDriftCheckResult(result, budgetId, log) {
301
+ // Log server knowledge mismatch warning
302
+ if (result.serverKnowledgeMismatch) {
303
+ log.warn('⚠️ Server knowledge mismatch during drift check', {
304
+ budgetId,
305
+ mergedServerKnowledge: result.mergedServerKnowledge,
306
+ note: 'External changes likely occurred between queries. Differences may be expected.',
307
+ truthServerKnowledge: result.truthServerKnowledge,
308
+ });
309
+ }
310
+ if (!result.hasDrift) {
311
+ // Success! No drift detected
312
+ log.info('✅ Drift check passed - merge logic validated', {
313
+ budgetId,
314
+ serverKnowledge: {
315
+ merged: result.mergedServerKnowledge,
316
+ truth: result.truthServerKnowledge,
317
+ },
318
+ });
319
+ return;
320
+ }
321
+ // Drift detected - log detailed warning
322
+ log.error('🚨 DRIFT DETECTED - merge logic produced different result', {
323
+ budgetId,
324
+ differenceCount: result.differenceCount,
325
+ differenceSummary: result.differenceSummary,
326
+ });
327
+ // Log first few differences in detail (limit to avoid log spam)
328
+ const maxDetailsToLog = 5;
329
+ const differencesToLog = result.differences.slice(0, maxDetailsToLog);
330
+ for (let i = 0; i < differencesToLog.length; i++) {
331
+ const d = differencesToLog[i];
332
+ if (d === undefined)
333
+ continue;
334
+ const path = formatDiffPath(d);
335
+ // Use type guards for proper type narrowing
336
+ if (isDiffNew(d)) {
337
+ // New in truth (missing in merged)
338
+ log.error(` [${i + 1}] MISSING: ${path}`, {
339
+ truthValue: d.rhs,
340
+ });
341
+ }
342
+ else if (isDiffDeleted(d)) {
343
+ // Deleted in truth (extra in merged)
344
+ log.error(` [${i + 1}] EXTRA: ${path}`, {
345
+ mergedValue: d.lhs,
346
+ });
347
+ }
348
+ else if (isDiffEdit(d)) {
349
+ // Edited (value differs)
350
+ log.error(` [${i + 1}] DIFFERS: ${path}`, {
351
+ merged: d.lhs,
352
+ truth: d.rhs,
353
+ });
354
+ }
355
+ else if (isDiffArray(d)) {
356
+ // Array change
357
+ log.error(` [${i + 1}] ARRAY CHANGE: ${path}[${d.index}]`, {
358
+ item: d.item,
359
+ });
360
+ }
361
+ }
362
+ if (result.differenceCount > maxDetailsToLog) {
363
+ log.error(` ... and ${result.differenceCount - maxDetailsToLog} more differences`);
364
+ }
365
+ log.info('🔧 Self-healing: Replacing local budget with full fetch result');
366
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Drift Snapshot Collection
3
+ *
4
+ * Saves drift detection artifacts for later analysis when drift is detected.
5
+ * This allows passive collection of real-world drift cases while development
6
+ * continues with full sync (guaranteed correct).
7
+ */
8
+ import type { DriftCheckResult } from './drift-detection.js';
9
+ import type { LocalBudget } from './types.js';
10
+ import type { BudgetDetail } from 'ynab';
11
+ /**
12
+ * Logger interface matching FastMCP's context log
13
+ */
14
+ interface ContextLog {
15
+ debug: (message: string, data?: unknown) => void;
16
+ error: (message: string, data?: unknown) => void;
17
+ info: (message: string, data?: unknown) => void;
18
+ warn: (message: string, data?: unknown) => void;
19
+ }
20
+ /**
21
+ * Get the drift sample rate (1 in N drift occurrences are saved).
22
+ * Default: 1 (save all drift occurrences)
23
+ * Set YNAB_DRIFT_SAMPLE_RATE to change.
24
+ */
25
+ export declare function getDriftSampleRate(): number;
26
+ /**
27
+ * Check if this drift occurrence should be sampled (saved).
28
+ * Increments internal counter and checks against sample rate.
29
+ */
30
+ export declare function shouldSampleDrift(): boolean;
31
+ /**
32
+ * Reset drift occurrence counter (useful for testing).
33
+ */
34
+ export declare function resetDriftOccurrenceCount(): void;
35
+ /**
36
+ * Get the drift snapshots directory.
37
+ * ~/.config/ynab-mcp-deluxe/drift-snapshots/
38
+ */
39
+ export declare function getDriftSnapshotsDir(): string;
40
+ /**
41
+ * Artifacts to save when drift is detected.
42
+ */
43
+ export interface DriftSnapshotArtifacts {
44
+ /** The budget ID */
45
+ budgetId: string;
46
+ /** The delta API response */
47
+ deltaResponse: BudgetDetail;
48
+ /** The drift check result with differences */
49
+ driftResult: DriftCheckResult;
50
+ /** The full API response (truth) */
51
+ fullResponse: BudgetDetail;
52
+ /** The merged budget (from applying delta to previous) */
53
+ mergedBudget: LocalBudget;
54
+ /** The previous full budget (base for merge) */
55
+ previousFullResponse: BudgetDetail;
56
+ /** Server knowledge values */
57
+ serverKnowledge: {
58
+ afterDelta: number;
59
+ afterFull: number;
60
+ previous: number;
61
+ };
62
+ }
63
+ /**
64
+ * Save drift snapshot artifacts to disk.
65
+ *
66
+ * Creates a timestamped directory with all artifacts for later analysis.
67
+ *
68
+ * @param artifacts - The drift snapshot artifacts to save
69
+ * @param log - Logger for debug output
70
+ * @returns The path to the saved snapshot directory
71
+ */
72
+ export declare function saveDriftSnapshot(artifacts: DriftSnapshotArtifacts, log: ContextLog): Promise<string>;
73
+ export {};
74
+ //# sourceMappingURL=drift-snapshot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drift-snapshot.d.ts","sourceRoot":"","sources":["../src/drift-snapshot.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,sBAAsB,CAAC;AAC3D,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,MAAM,CAAC;AAMvC;;GAEG;AACH,UAAU,UAAU;IAClB,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAChD,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CACjD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAU3C;AAKD;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAI3C;AAED;;GAEG;AACH,wBAAgB,yBAAyB,IAAI,IAAI,CAEhD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAE7C;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,oBAAoB;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,6BAA6B;IAC7B,aAAa,EAAE,YAAY,CAAC;IAC5B,8CAA8C;IAC9C,WAAW,EAAE,gBAAgB,CAAC;IAC9B,oCAAoC;IACpC,YAAY,EAAE,YAAY,CAAC;IAC3B,0DAA0D;IAC1D,YAAY,EAAE,WAAW,CAAC;IAC1B,gDAAgD;IAChD,oBAAoB,EAAE,YAAY,CAAC;IACnC,8BAA8B;IAC9B,eAAe,EAAE;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC;CACH;AA0BD;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,SAAS,EAAE,sBAAsB,EACjC,GAAG,EAAE,UAAU,GACd,OAAO,CAAC,MAAM,CAAC,CA8DjB"}
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Drift Snapshot Collection
3
+ *
4
+ * Saves drift detection artifacts for later analysis when drift is detected.
5
+ * This allows passive collection of real-world drift cases while development
6
+ * continues with full sync (guaranteed correct).
7
+ */
8
+ import * as fs from 'node:fs/promises';
9
+ import { homedir } from 'node:os';
10
+ import * as path from 'node:path';
11
+ /**
12
+ * Get the drift sample rate (1 in N drift occurrences are saved).
13
+ * Default: 1 (save all drift occurrences)
14
+ * Set YNAB_DRIFT_SAMPLE_RATE to change.
15
+ */
16
+ export function getDriftSampleRate() {
17
+ const value = process.env['YNAB_DRIFT_SAMPLE_RATE'];
18
+ if (value === undefined || value === '') {
19
+ return 1; // Default: save all
20
+ }
21
+ const parsed = parseInt(value, 10);
22
+ if (isNaN(parsed) || parsed < 1) {
23
+ return 1;
24
+ }
25
+ return parsed;
26
+ }
27
+ // Module-level counter for sample rate
28
+ let driftOccurrenceCount = 0;
29
+ /**
30
+ * Check if this drift occurrence should be sampled (saved).
31
+ * Increments internal counter and checks against sample rate.
32
+ */
33
+ export function shouldSampleDrift() {
34
+ driftOccurrenceCount++;
35
+ const sampleRate = getDriftSampleRate();
36
+ return driftOccurrenceCount % sampleRate === 0;
37
+ }
38
+ /**
39
+ * Reset drift occurrence counter (useful for testing).
40
+ */
41
+ export function resetDriftOccurrenceCount() {
42
+ driftOccurrenceCount = 0;
43
+ }
44
+ /**
45
+ * Get the drift snapshots directory.
46
+ * ~/.config/ynab-mcp-deluxe/drift-snapshots/
47
+ */
48
+ export function getDriftSnapshotsDir() {
49
+ return path.join(homedir(), '.config', 'ynab-mcp-deluxe', 'drift-snapshots');
50
+ }
51
+ /**
52
+ * Serialize a LocalBudget for saving (convert Maps to objects).
53
+ */
54
+ function serializeLocalBudget(budget) {
55
+ return {
56
+ accounts: budget.accounts,
57
+ budgetId: budget.budgetId,
58
+ budgetName: budget.budgetName,
59
+ categories: budget.categories,
60
+ categoryGroups: budget.categoryGroups,
61
+ currencyFormat: budget.currencyFormat,
62
+ lastSyncedAt: budget.lastSyncedAt.toISOString(),
63
+ months: budget.months,
64
+ needsSync: budget.needsSync,
65
+ payeeLocations: budget.payeeLocations,
66
+ payees: budget.payees,
67
+ scheduledSubtransactions: budget.scheduledSubtransactions,
68
+ scheduledTransactions: budget.scheduledTransactions,
69
+ serverKnowledge: budget.serverKnowledge,
70
+ subtransactions: budget.subtransactions,
71
+ transactions: budget.transactions,
72
+ };
73
+ }
74
+ /**
75
+ * Save drift snapshot artifacts to disk.
76
+ *
77
+ * Creates a timestamped directory with all artifacts for later analysis.
78
+ *
79
+ * @param artifacts - The drift snapshot artifacts to save
80
+ * @param log - Logger for debug output
81
+ * @returns The path to the saved snapshot directory
82
+ */
83
+ export async function saveDriftSnapshot(artifacts, log) {
84
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
85
+ const snapshotDir = path.join(getDriftSnapshotsDir(), `${timestamp}_${artifacts.budgetId}`);
86
+ try {
87
+ // Ensure directory exists
88
+ await fs.mkdir(snapshotDir, { recursive: true });
89
+ // Prepare summary
90
+ const summary = {
91
+ budgetId: artifacts.budgetId,
92
+ differenceCount: artifacts.driftResult.differenceCount,
93
+ differenceSummary: artifacts.driftResult.differenceSummary,
94
+ savedAt: new Date().toISOString(),
95
+ serverKnowledge: artifacts.serverKnowledge,
96
+ serverKnowledgeMismatch: artifacts.driftResult.serverKnowledgeMismatch,
97
+ };
98
+ // Save all artifacts in parallel
99
+ await Promise.all([
100
+ fs.writeFile(path.join(snapshotDir, 'summary.json'), JSON.stringify(summary, null, 2)),
101
+ fs.writeFile(path.join(snapshotDir, 'previous-full.json'), JSON.stringify(artifacts.previousFullResponse, null, 2)),
102
+ fs.writeFile(path.join(snapshotDir, 'delta-response.json'), JSON.stringify(artifacts.deltaResponse, null, 2)),
103
+ fs.writeFile(path.join(snapshotDir, 'merged-budget.json'), JSON.stringify(serializeLocalBudget(artifacts.mergedBudget), null, 2)),
104
+ fs.writeFile(path.join(snapshotDir, 'full-response.json'), JSON.stringify(artifacts.fullResponse, null, 2)),
105
+ fs.writeFile(path.join(snapshotDir, 'differences.json'), JSON.stringify(artifacts.driftResult.differences, null, 2)),
106
+ ]);
107
+ log.info('💾 Drift snapshot saved for later analysis', {
108
+ differenceCount: artifacts.driftResult.differenceCount,
109
+ path: snapshotDir,
110
+ });
111
+ return snapshotDir;
112
+ }
113
+ catch (error) {
114
+ log.error('Failed to save drift snapshot', {
115
+ error: error instanceof Error ? error.message : String(error),
116
+ snapshotDir,
117
+ });
118
+ throw error;
119
+ }
120
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Helper functions for the YNAB MCP server
3
+ */
4
+ import type { CategoryDistribution, EnrichedTransaction, TransactionSortBy } from './types.js';
5
+ /**
6
+ * Apply a JMESPath query to data
7
+ * @throws Error with helpful message if query is invalid
8
+ */
9
+ export declare function applyJMESPath<T>(data: T, query: string): unknown;
10
+ /**
11
+ * Sort transactions by the specified criteria
12
+ */
13
+ export declare function sortTransactions(transactions: EnrichedTransaction[], sortBy: TransactionSortBy): EnrichedTransaction[];
14
+ /**
15
+ * Filter transactions by payee name (case-insensitive partial match)
16
+ */
17
+ export declare function filterByPayee(transactions: EnrichedTransaction[], payeeContains: string): EnrichedTransaction[];
18
+ /**
19
+ * Filter transactions by date range
20
+ */
21
+ export declare function filterByDateRange(transactions: EnrichedTransaction[], sinceDate?: string, untilDate?: string): EnrichedTransaction[];
22
+ /**
23
+ * Filter transactions by account ID
24
+ */
25
+ export declare function filterByAccount(transactions: EnrichedTransaction[], accountId: string): EnrichedTransaction[];
26
+ /**
27
+ * Calculate category distribution for a set of transactions
28
+ */
29
+ export declare function calculateCategoryDistribution(transactions: EnrichedTransaction[]): CategoryDistribution[];
30
+ /**
31
+ * Create an MCP error response
32
+ */
33
+ export declare function createErrorResponse(message: string): {
34
+ content: {
35
+ text: string;
36
+ type: 'text';
37
+ }[];
38
+ isError: true;
39
+ };
40
+ /**
41
+ * Validate a selector has exactly one of name or id
42
+ */
43
+ export declare function validateSelector(selector: {
44
+ id?: string;
45
+ name?: string;
46
+ } | undefined, entityType: string): void;
47
+ /**
48
+ * Check if a value looks like it might be a JMESPath query result
49
+ * (i.e., it's been transformed by a projection)
50
+ */
51
+ export declare function isTransformed(value: unknown): boolean;
52
+ /**
53
+ * Create an enhanced MCP error response with context from YNAB errors
54
+ *
55
+ * Extracts HTTP status codes and error details from YNAB SDK errors
56
+ * to provide actionable error messages for the LLM.
57
+ */
58
+ export declare function createEnhancedErrorResponse(error: unknown, operation: string): Promise<{
59
+ content: {
60
+ text: string;
61
+ type: 'text';
62
+ }[];
63
+ isError: true;
64
+ }>;
65
+ //# sourceMappingURL=helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../src/helpers.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EACV,oBAAoB,EACpB,mBAAmB,EACnB,iBAAiB,EAClB,MAAM,YAAY,CAAC;AAIpB;;;GAGG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAchE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,mBAAmB,EAAE,EACnC,MAAM,EAAE,iBAAiB,GACxB,mBAAmB,EAAE,CAqBvB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAC3B,YAAY,EAAE,mBAAmB,EAAE,EACnC,aAAa,EAAE,MAAM,GACpB,mBAAmB,EAAE,CAavB;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,mBAAmB,EAAE,EACnC,SAAS,CAAC,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,GACjB,mBAAmB,EAAE,CAQvB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,YAAY,EAAE,mBAAmB,EAAE,EACnC,SAAS,EAAE,MAAM,GAChB,mBAAmB,EAAE,CAEvB;AAED;;GAEG;AACH,wBAAgB,6BAA6B,CAC3C,YAAY,EAAE,mBAAmB,EAAE,GAClC,oBAAoB,EAAE,CAoCxB;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG;IACpD,OAAO,EAAE;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAC,EAAE,CAAC;IACxC,OAAO,EAAE,IAAI,CAAC;CACf,CAKA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAC,GAAG,SAAS,EAClD,UAAU,EAAE,MAAM,GACjB,IAAI,CAWN;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAOrD;AAiDD;;;;;GAKG;AACH,wBAAsB,2BAA2B,CAC/C,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC;IAAC,OAAO,EAAE;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAC,EAAE,CAAC;IAAC,OAAO,EAAE,IAAI,CAAA;CAAC,CAAC,CAoDnE"}