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.
- package/LICENSE +25 -0
- package/README.md +99 -0
- package/dist/backup.d.ts +28 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +78 -0
- package/dist/drift-detection.d.ts +110 -0
- package/dist/drift-detection.d.ts.map +1 -0
- package/dist/drift-detection.js +366 -0
- package/dist/drift-snapshot.d.ts +74 -0
- package/dist/drift-snapshot.d.ts.map +1 -0
- package/dist/drift-snapshot.js +120 -0
- package/dist/helpers.d.ts +65 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +226 -0
- package/dist/local-budget.d.ts +86 -0
- package/dist/local-budget.d.ts.map +1 -0
- package/dist/local-budget.js +277 -0
- package/dist/logger.d.ts +50 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +136 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1626 -0
- package/dist/sync-history.d.ts +95 -0
- package/dist/sync-history.d.ts.map +1 -0
- package/dist/sync-history.js +205 -0
- package/dist/sync-providers.d.ts +71 -0
- package/dist/sync-providers.d.ts.map +1 -0
- package/dist/sync-providers.js +105 -0
- package/dist/types.d.ts +535 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/ynab-client.d.ts +257 -0
- package/dist/ynab-client.d.ts.map +1 -0
- package/dist/ynab-client.js +1248 -0
- package/package.json +102 -0
|
@@ -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"}
|