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,226 @@
1
+ /**
2
+ * Helper functions for the YNAB MCP server
3
+ */
4
+ import jmespath from '@metrichor/jmespath';
5
+ /**
6
+ * Apply a JMESPath query to data
7
+ * @throws Error with helpful message if query is invalid
8
+ */
9
+ export function applyJMESPath(data, query) {
10
+ try {
11
+ // Cast through unknown for type safety with jmespath
12
+ const jsonData = data;
13
+ return jmespath.search(jsonData, query);
14
+ }
15
+ catch (error) {
16
+ const message = error instanceof Error ? error.message : 'Unknown error';
17
+ throw new Error(`Invalid JMESPath expression: ${message}. Expression: '${query}'.`);
18
+ }
19
+ }
20
+ /**
21
+ * Sort transactions by the specified criteria
22
+ */
23
+ export function sortTransactions(transactions, sortBy) {
24
+ const sorted = [...transactions];
25
+ switch (sortBy) {
26
+ case 'newest':
27
+ sorted.sort((a, b) => b.date.localeCompare(a.date));
28
+ break;
29
+ case 'oldest':
30
+ sorted.sort((a, b) => a.date.localeCompare(b.date));
31
+ break;
32
+ case 'amount_desc':
33
+ // Largest outflows first (most negative first)
34
+ sorted.sort((a, b) => a.amount - b.amount);
35
+ break;
36
+ case 'amount_asc':
37
+ // Largest inflows first (most positive first)
38
+ sorted.sort((a, b) => b.amount - a.amount);
39
+ break;
40
+ }
41
+ return sorted;
42
+ }
43
+ /**
44
+ * Filter transactions by payee name (case-insensitive partial match)
45
+ */
46
+ export function filterByPayee(transactions, payeeContains) {
47
+ const searchLower = payeeContains.toLowerCase();
48
+ return transactions.filter((tx) => {
49
+ const payeeName = tx.payee_name?.toLowerCase() ?? '';
50
+ const importPayeeName = tx.import_payee_name?.toLowerCase() ?? '';
51
+ const importPayeeNameOriginal = tx.import_payee_name_original?.toLowerCase() ?? '';
52
+ return (payeeName.includes(searchLower) ||
53
+ importPayeeName.includes(searchLower) ||
54
+ importPayeeNameOriginal.includes(searchLower));
55
+ });
56
+ }
57
+ /**
58
+ * Filter transactions by date range
59
+ */
60
+ export function filterByDateRange(transactions, sinceDate, untilDate) {
61
+ return transactions.filter((tx) => {
62
+ if (sinceDate !== undefined && sinceDate !== '' && tx.date < sinceDate)
63
+ return false;
64
+ if (untilDate !== undefined && untilDate !== '' && tx.date > untilDate)
65
+ return false;
66
+ return true;
67
+ });
68
+ }
69
+ /**
70
+ * Filter transactions by account ID
71
+ */
72
+ export function filterByAccount(transactions, accountId) {
73
+ return transactions.filter((tx) => tx.account_id === accountId);
74
+ }
75
+ /**
76
+ * Calculate category distribution for a set of transactions
77
+ */
78
+ export function calculateCategoryDistribution(transactions) {
79
+ const counts = new Map();
80
+ for (const tx of transactions) {
81
+ const key = tx.category_id ?? 'uncategorized';
82
+ const existing = counts.get(key);
83
+ if (existing !== undefined) {
84
+ existing.count++;
85
+ }
86
+ else {
87
+ counts.set(key, {
88
+ count: 1,
89
+ groupName: tx.category_group_name,
90
+ name: tx.category_name,
91
+ });
92
+ }
93
+ }
94
+ const total = transactions.length;
95
+ const distribution = [];
96
+ for (const { name, groupName, count } of counts.values()) {
97
+ distribution.push({
98
+ category_group_name: groupName,
99
+ category_name: name,
100
+ count,
101
+ percentage: Math.round((count / total) * 1000) / 10, // One decimal place
102
+ });
103
+ }
104
+ // Sort by count descending
105
+ distribution.sort((a, b) => b.count - a.count);
106
+ return distribution;
107
+ }
108
+ /**
109
+ * Create an MCP error response
110
+ */
111
+ export function createErrorResponse(message) {
112
+ return {
113
+ content: [{ text: message, type: 'text' }],
114
+ isError: true,
115
+ };
116
+ }
117
+ /**
118
+ * Validate a selector has exactly one of name or id
119
+ */
120
+ export function validateSelector(selector, entityType) {
121
+ if (selector === undefined || selector === null)
122
+ return;
123
+ const hasName = selector.name !== undefined && selector.name !== '';
124
+ const hasId = selector.id !== undefined && selector.id !== '';
125
+ if (hasName && hasId) {
126
+ throw new Error(`${entityType} selector must specify exactly one of: 'name' or 'id'.`);
127
+ }
128
+ }
129
+ /**
130
+ * Check if a value looks like it might be a JMESPath query result
131
+ * (i.e., it's been transformed by a projection)
132
+ */
133
+ export function isTransformed(value) {
134
+ if (!Array.isArray(value))
135
+ return true;
136
+ if (value.length === 0)
137
+ return false;
138
+ // If the first item doesn't have standard transaction fields, it's been transformed
139
+ const first = value[0];
140
+ if (typeof first !== 'object' || first === null)
141
+ return true;
142
+ return !('id' in first && 'date' in first && 'amount' in first);
143
+ }
144
+ // ============================================================================
145
+ // YNAB Error Handling
146
+ // ============================================================================
147
+ /**
148
+ * Type guard for YNAB SDK ResponseError (HTTP errors from API)
149
+ */
150
+ function isYnabResponseError(error) {
151
+ return (error !== null &&
152
+ typeof error === 'object' &&
153
+ 'response' in error &&
154
+ 'name' in error &&
155
+ error.name === 'ResponseError');
156
+ }
157
+ /**
158
+ * Type guard for YNAB SDK FetchError (network errors)
159
+ */
160
+ function isYnabFetchError(error) {
161
+ return (error !== null &&
162
+ typeof error === 'object' &&
163
+ 'cause' in error &&
164
+ 'name' in error &&
165
+ error.name === 'FetchError');
166
+ }
167
+ /**
168
+ * Create an enhanced MCP error response with context from YNAB errors
169
+ *
170
+ * Extracts HTTP status codes and error details from YNAB SDK errors
171
+ * to provide actionable error messages for the LLM.
172
+ */
173
+ export async function createEnhancedErrorResponse(error, operation) {
174
+ let message;
175
+ if (isYnabResponseError(error)) {
176
+ const statusCode = error.response.status;
177
+ // Try to extract YNAB error detail from response body
178
+ let detail = null;
179
+ try {
180
+ const body = (await error.response.json());
181
+ if (body?.error?.detail !== undefined &&
182
+ body.error.detail !== null &&
183
+ body.error.detail !== '') {
184
+ detail = body.error.detail;
185
+ }
186
+ }
187
+ catch {
188
+ // Response body not available or not JSON
189
+ }
190
+ if (detail !== null) {
191
+ message = `${operation} failed: ${detail}`;
192
+ }
193
+ else {
194
+ message = `${operation} failed: HTTP ${statusCode}`;
195
+ }
196
+ // Add specific guidance for common HTTP errors
197
+ if (statusCode === 401) {
198
+ message += ' (Check your YNAB API token)';
199
+ }
200
+ else if (statusCode === 404) {
201
+ message += ' (Resource not found - verify the ID exists)';
202
+ }
203
+ else if (statusCode === 429) {
204
+ message += ' (Rate limited - wait before retrying)';
205
+ }
206
+ else if (statusCode >= 500) {
207
+ message += ' (YNAB server error - try again later)';
208
+ }
209
+ }
210
+ else if (isYnabFetchError(error)) {
211
+ const causeMessage = error.cause?.message ?? error.message;
212
+ message = `${operation} failed: Network error - ${causeMessage}`;
213
+ }
214
+ else if (error instanceof Error) {
215
+ // Application-level errors (validation, resolution failures, etc.)
216
+ // These already have good messages from ynab-client.ts
217
+ message = error.message;
218
+ }
219
+ else {
220
+ message = `${operation} failed: Unknown error`;
221
+ }
222
+ return {
223
+ content: [{ text: message, type: 'text' }],
224
+ isError: true,
225
+ };
226
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * LocalBudget building and merging utilities.
3
+ *
4
+ * This module handles:
5
+ * - Building a LocalBudget from a full YNAB API response
6
+ * - Merging delta sync responses into an existing LocalBudget
7
+ * - Rebuilding O(1) lookup maps after modifications
8
+ */
9
+ import type { LocalBudget } from './types.js';
10
+ import type { BudgetDetail, MonthDetail } from 'ynab';
11
+ /**
12
+ * Entity with an ID and optional deleted flag (for delta sync)
13
+ */
14
+ interface EntityWithId {
15
+ deleted?: boolean;
16
+ id: string;
17
+ }
18
+ /**
19
+ * Rebuild all O(1) lookup maps from the arrays in a LocalBudget.
20
+ * Call this after any modification to the arrays.
21
+ *
22
+ * @param localBudget - The LocalBudget to rebuild maps for (mutated in place)
23
+ */
24
+ export declare function rebuildLookupMaps(localBudget: LocalBudget): void;
25
+ /**
26
+ * Build a LocalBudget from a full YNAB API budget response.
27
+ *
28
+ * @param budgetId - The budget ID
29
+ * @param budget - The BudgetDetail from YNAB API
30
+ * @param serverKnowledge - The server_knowledge from the API response
31
+ * @returns A fully populated LocalBudget with lookup maps
32
+ */
33
+ export declare function buildLocalBudget(budgetId: string, budget: BudgetDetail, serverKnowledge: number): LocalBudget;
34
+ /**
35
+ * Merge an array of entities from a delta response into an existing array.
36
+ * Handles additions, updates, and deletions (entities with deleted: true).
37
+ *
38
+ * @param existing - The current array of entities
39
+ * @param delta - The delta array from the API (may include deleted entities)
40
+ * @returns A new merged array with deletions removed
41
+ */
42
+ export declare function mergeEntityArray<T extends EntityWithId>(existing: T[], delta: T[]): T[];
43
+ /**
44
+ * Merge an array of MonthDetail entities (keyed by 'month' instead of 'id').
45
+ * MonthDetail doesn't have an 'id' field - it uses 'month' as its unique key.
46
+ *
47
+ * IMPORTANT: MonthDetail contains a nested `categories` array that must be
48
+ * merged separately. Delta responses may only include CHANGED categories,
49
+ * so we must merge them with existing categories rather than replacing.
50
+ *
51
+ * @param existing - The current array of months
52
+ * @param delta - The delta array from the API
53
+ * @returns A new merged array
54
+ */
55
+ export declare function mergeMonthArray(existing: MonthDetail[], delta: MonthDetail[]): MonthDetail[];
56
+ /**
57
+ * Merge a delta sync response into an existing LocalBudget.
58
+ * Returns a new LocalBudget with the merged data.
59
+ *
60
+ * @param existing - The current LocalBudget
61
+ * @param deltaBudget - The delta BudgetDetail from YNAB API
62
+ * @param newServerKnowledge - The new server_knowledge from the delta response
63
+ * @returns A new LocalBudget with merged data and change counts
64
+ */
65
+ export declare function mergeDelta(existing: LocalBudget, deltaBudget: BudgetDetail, newServerKnowledge: number): {
66
+ changesReceived: {
67
+ accounts: number;
68
+ categories: number;
69
+ months: number;
70
+ payees: number;
71
+ scheduledTransactions: number;
72
+ transactions: number;
73
+ };
74
+ localBudget: LocalBudget;
75
+ };
76
+ /**
77
+ * Compare two LocalBudgets and report any discrepancies.
78
+ * Useful for sanity checking after a full re-fetch.
79
+ *
80
+ * @param local - The local budget (before full re-fetch)
81
+ * @param remote - The remote budget (from full re-fetch)
82
+ * @returns Array of discrepancy descriptions, empty if no drift
83
+ */
84
+ export declare function detectDrift(local: LocalBudget, remote: LocalBudget): string[];
85
+ export {};
86
+ //# sourceMappingURL=local-budget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-budget.d.ts","sourceRoot":"","sources":["../src/local-budget.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAEV,YAAY,EAGZ,WAAW,EAOZ,MAAM,MAAM,CAAC;AAEd;;GAEG;AACH,UAAU,YAAY;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAsEhE;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,YAAY,EACpB,eAAe,EAAE,MAAM,GACtB,WAAW,CAwCb;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,YAAY,EACrD,QAAQ,EAAE,CAAC,EAAE,EACb,KAAK,EAAE,CAAC,EAAE,GACT,CAAC,EAAE,CAmBL;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,WAAW,EAAE,EACvB,KAAK,EAAE,WAAW,EAAE,GACnB,WAAW,EAAE,CA+Bf;AAYD;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CACxB,QAAQ,EAAE,WAAW,EACrB,WAAW,EAAE,YAAY,EACzB,kBAAkB,EAAE,MAAM,GACzB;IACD,eAAe,EAAE;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;QACf,qBAAqB,EAAE,MAAM,CAAC;QAC9B,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,WAAW,EAAE,WAAW,CAAC;CAC1B,CA2EA;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,MAAM,EAAE,CA0B7E"}
@@ -0,0 +1,277 @@
1
+ /**
2
+ * LocalBudget building and merging utilities.
3
+ *
4
+ * This module handles:
5
+ * - Building a LocalBudget from a full YNAB API response
6
+ * - Merging delta sync responses into an existing LocalBudget
7
+ * - Rebuilding O(1) lookup maps after modifications
8
+ */
9
+ /**
10
+ * Rebuild all O(1) lookup maps from the arrays in a LocalBudget.
11
+ * Call this after any modification to the arrays.
12
+ *
13
+ * @param localBudget - The LocalBudget to rebuild maps for (mutated in place)
14
+ */
15
+ export function rebuildLookupMaps(localBudget) {
16
+ // Account maps
17
+ localBudget.accountById.clear();
18
+ localBudget.accountByName.clear();
19
+ for (const account of localBudget.accounts) {
20
+ localBudget.accountById.set(account.id, account);
21
+ localBudget.accountByName.set(account.name.toLowerCase(), account);
22
+ }
23
+ // Category maps
24
+ localBudget.categoryById.clear();
25
+ localBudget.categoryByName.clear();
26
+ for (const category of localBudget.categories) {
27
+ localBudget.categoryById.set(category.id, category);
28
+ localBudget.categoryByName.set(category.name.toLowerCase(), category);
29
+ }
30
+ // Category group name map
31
+ localBudget.categoryGroupNameById.clear();
32
+ for (const group of localBudget.categoryGroups) {
33
+ localBudget.categoryGroupNameById.set(group.id, group.name);
34
+ }
35
+ // Payee map
36
+ localBudget.payeeById.clear();
37
+ for (const payee of localBudget.payees) {
38
+ localBudget.payeeById.set(payee.id, payee);
39
+ }
40
+ // Subtransaction maps (for O(1) joins during enrichment)
41
+ // Guard against null/undefined transaction_id (defensive against malformed API responses)
42
+ localBudget.subtransactionsByTransactionId.clear();
43
+ for (const sub of localBudget.subtransactions) {
44
+ // Skip orphaned subtransactions with missing transaction_id
45
+ if (sub.transaction_id === null || sub.transaction_id === undefined) {
46
+ continue;
47
+ }
48
+ const existing = localBudget.subtransactionsByTransactionId.get(sub.transaction_id);
49
+ if (existing !== undefined) {
50
+ existing.push(sub);
51
+ }
52
+ else {
53
+ localBudget.subtransactionsByTransactionId.set(sub.transaction_id, [sub]);
54
+ }
55
+ }
56
+ // Guard against null/undefined scheduled_transaction_id (defensive against malformed API responses)
57
+ localBudget.scheduledSubtransactionsByScheduledTransactionId.clear();
58
+ for (const sub of localBudget.scheduledSubtransactions) {
59
+ // Skip orphaned scheduled subtransactions with missing scheduled_transaction_id
60
+ if (sub.scheduled_transaction_id === null ||
61
+ sub.scheduled_transaction_id === undefined) {
62
+ continue;
63
+ }
64
+ const existing = localBudget.scheduledSubtransactionsByScheduledTransactionId.get(sub.scheduled_transaction_id);
65
+ if (existing !== undefined) {
66
+ existing.push(sub);
67
+ }
68
+ else {
69
+ localBudget.scheduledSubtransactionsByScheduledTransactionId.set(sub.scheduled_transaction_id, [sub]);
70
+ }
71
+ }
72
+ }
73
+ /**
74
+ * Build a LocalBudget from a full YNAB API budget response.
75
+ *
76
+ * @param budgetId - The budget ID
77
+ * @param budget - The BudgetDetail from YNAB API
78
+ * @param serverKnowledge - The server_knowledge from the API response
79
+ * @returns A fully populated LocalBudget with lookup maps
80
+ */
81
+ export function buildLocalBudget(budgetId, budget, serverKnowledge) {
82
+ const localBudget = {
83
+ // Lookup maps (will be populated by rebuildLookupMaps)
84
+ accountById: new Map(),
85
+ accountByName: new Map(),
86
+ // Budget data (arrays from the API response)
87
+ accounts: budget.accounts ?? [],
88
+ // Budget identity
89
+ budgetId,
90
+ budgetName: budget.name,
91
+ categories: budget.categories ?? [],
92
+ categoryById: new Map(),
93
+ categoryByName: new Map(),
94
+ categoryGroupNameById: new Map(),
95
+ categoryGroups: budget.category_groups ?? [],
96
+ // Budget settings
97
+ currencyFormat: budget.currency_format ?? null,
98
+ // Sync metadata
99
+ lastSyncedAt: new Date(),
100
+ months: budget.months ?? [],
101
+ needsSync: false,
102
+ payeeById: new Map(),
103
+ payeeLocations: budget.payee_locations ?? [],
104
+ payees: budget.payees ?? [],
105
+ scheduledSubtransactions: budget.scheduled_subtransactions ?? [],
106
+ scheduledSubtransactionsByScheduledTransactionId: new Map(),
107
+ scheduledTransactions: budget.scheduled_transactions ?? [],
108
+ serverKnowledge,
109
+ subtransactions: budget.subtransactions ?? [],
110
+ subtransactionsByTransactionId: new Map(),
111
+ transactions: budget.transactions ?? [],
112
+ };
113
+ // Build the lookup maps
114
+ rebuildLookupMaps(localBudget);
115
+ return localBudget;
116
+ }
117
+ /**
118
+ * Merge an array of entities from a delta response into an existing array.
119
+ * Handles additions, updates, and deletions (entities with deleted: true).
120
+ *
121
+ * @param existing - The current array of entities
122
+ * @param delta - The delta array from the API (may include deleted entities)
123
+ * @returns A new merged array with deletions removed
124
+ */
125
+ export function mergeEntityArray(existing, delta) {
126
+ // Build a map from existing entities
127
+ const byId = new Map();
128
+ for (const entity of existing) {
129
+ byId.set(entity.id, entity);
130
+ }
131
+ // Apply delta changes
132
+ for (const entity of delta) {
133
+ if (entity.deleted === true) {
134
+ // Remove deleted entities
135
+ byId.delete(entity.id);
136
+ }
137
+ else {
138
+ // Add or update entity
139
+ byId.set(entity.id, entity);
140
+ }
141
+ }
142
+ return Array.from(byId.values());
143
+ }
144
+ /**
145
+ * Merge an array of MonthDetail entities (keyed by 'month' instead of 'id').
146
+ * MonthDetail doesn't have an 'id' field - it uses 'month' as its unique key.
147
+ *
148
+ * IMPORTANT: MonthDetail contains a nested `categories` array that must be
149
+ * merged separately. Delta responses may only include CHANGED categories,
150
+ * so we must merge them with existing categories rather than replacing.
151
+ *
152
+ * @param existing - The current array of months
153
+ * @param delta - The delta array from the API
154
+ * @returns A new merged array
155
+ */
156
+ export function mergeMonthArray(existing, delta) {
157
+ // Build a map from existing months using 'month' as key
158
+ const byMonth = new Map();
159
+ for (const month of existing) {
160
+ byMonth.set(month.month, month);
161
+ }
162
+ // Apply delta changes
163
+ for (const deltaMonth of delta) {
164
+ if (deltaMonth.deleted === true) {
165
+ byMonth.delete(deltaMonth.month);
166
+ }
167
+ else {
168
+ const existingMonth = byMonth.get(deltaMonth.month);
169
+ if (existingMonth !== undefined) {
170
+ // Month exists - merge the nested categories array
171
+ const mergedCategories = mergeEntityArray(existingMonth.categories, deltaMonth.categories);
172
+ byMonth.set(deltaMonth.month, {
173
+ ...deltaMonth,
174
+ categories: mergedCategories,
175
+ });
176
+ }
177
+ else {
178
+ // New month - use as-is
179
+ byMonth.set(deltaMonth.month, deltaMonth);
180
+ }
181
+ }
182
+ }
183
+ return Array.from(byMonth.values());
184
+ }
185
+ /**
186
+ * Count changes in a delta array.
187
+ *
188
+ * @param delta - The delta array from the API
189
+ * @returns The number of entities in the delta
190
+ */
191
+ function countChanges(delta) {
192
+ return delta?.length ?? 0;
193
+ }
194
+ /**
195
+ * Merge a delta sync response into an existing LocalBudget.
196
+ * Returns a new LocalBudget with the merged data.
197
+ *
198
+ * @param existing - The current LocalBudget
199
+ * @param deltaBudget - The delta BudgetDetail from YNAB API
200
+ * @param newServerKnowledge - The new server_knowledge from the delta response
201
+ * @returns A new LocalBudget with merged data and change counts
202
+ */
203
+ export function mergeDelta(existing, deltaBudget, newServerKnowledge) {
204
+ // Count changes before merging
205
+ const changesReceived = {
206
+ accounts: countChanges(deltaBudget.accounts),
207
+ categories: countChanges(deltaBudget.categories),
208
+ months: countChanges(deltaBudget.months),
209
+ payees: countChanges(deltaBudget.payees),
210
+ scheduledTransactions: countChanges(deltaBudget.scheduled_transactions),
211
+ transactions: countChanges(deltaBudget.transactions),
212
+ };
213
+ // Create new LocalBudget with merged arrays
214
+ const localBudget = {
215
+ // Lookup maps (will be rebuilt)
216
+ accountById: new Map(),
217
+ accountByName: new Map(),
218
+ // Merge all entity arrays
219
+ accounts: mergeEntityArray(existing.accounts, deltaBudget.accounts ?? []),
220
+ // Budget identity (unchanged)
221
+ budgetId: existing.budgetId,
222
+ budgetName: deltaBudget.name ?? existing.budgetName,
223
+ categories: mergeEntityArray(existing.categories, deltaBudget.categories ?? []),
224
+ categoryById: new Map(),
225
+ categoryByName: new Map(),
226
+ categoryGroupNameById: new Map(),
227
+ categoryGroups: mergeEntityArray(existing.categoryGroups, deltaBudget.category_groups ?? []),
228
+ // Budget settings (may be updated)
229
+ currencyFormat: deltaBudget.currency_format ?? existing.currencyFormat,
230
+ // Update sync metadata
231
+ lastSyncedAt: new Date(),
232
+ months: mergeMonthArray(existing.months, deltaBudget.months ?? []),
233
+ needsSync: false,
234
+ payeeById: new Map(),
235
+ payeeLocations: mergeEntityArray(existing.payeeLocations, deltaBudget.payee_locations ?? []),
236
+ payees: mergeEntityArray(existing.payees, deltaBudget.payees ?? []),
237
+ scheduledSubtransactions: mergeEntityArray(existing.scheduledSubtransactions, deltaBudget.scheduled_subtransactions ?? []),
238
+ scheduledSubtransactionsByScheduledTransactionId: new Map(),
239
+ scheduledTransactions: mergeEntityArray(existing.scheduledTransactions, deltaBudget.scheduled_transactions ?? []),
240
+ serverKnowledge: newServerKnowledge,
241
+ subtransactions: mergeEntityArray(existing.subtransactions, deltaBudget.subtransactions ?? []),
242
+ subtransactionsByTransactionId: new Map(),
243
+ transactions: mergeEntityArray(existing.transactions, deltaBudget.transactions ?? []),
244
+ };
245
+ // Rebuild lookup maps
246
+ rebuildLookupMaps(localBudget);
247
+ return { changesReceived, localBudget };
248
+ }
249
+ /**
250
+ * Compare two LocalBudgets and report any discrepancies.
251
+ * Useful for sanity checking after a full re-fetch.
252
+ *
253
+ * @param local - The local budget (before full re-fetch)
254
+ * @param remote - The remote budget (from full re-fetch)
255
+ * @returns Array of discrepancy descriptions, empty if no drift
256
+ */
257
+ export function detectDrift(local, remote) {
258
+ const discrepancies = [];
259
+ // Check array lengths
260
+ const checks = [
261
+ { field: 'accounts', name: 'accounts' },
262
+ { field: 'categories', name: 'categories' },
263
+ { field: 'categoryGroups', name: 'categoryGroups' },
264
+ { field: 'months', name: 'months' },
265
+ { field: 'payees', name: 'payees' },
266
+ { field: 'transactions', name: 'transactions' },
267
+ { field: 'scheduledTransactions', name: 'scheduledTransactions' },
268
+ ];
269
+ for (const { field, name } of checks) {
270
+ const localArray = local[field];
271
+ const remoteArray = remote[field];
272
+ if (localArray.length !== remoteArray.length) {
273
+ discrepancies.push(`${name}: local=${localArray.length}, remote=${remoteArray.length}, drift=${remoteArray.length - localArray.length}`);
274
+ }
275
+ }
276
+ return discrepancies;
277
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pino-based logger for the MCP server.
3
+ *
4
+ * Writes JSON logs to ~/.config/ynab-mcp-deluxe/logs/ with date-based rotation.
5
+ * Use `bun run logs` to tail the log file, optionally piping through pino-pretty.
6
+ */
7
+ /**
8
+ * Logger that implements FastMCP's Logger interface.
9
+ * Wraps pino to write structured JSON logs to a rotating file.
10
+ */
11
+ export declare const logger: {
12
+ debug(...args: unknown[]): void;
13
+ error(...args: unknown[]): void;
14
+ info(...args: unknown[]): void;
15
+ log(...args: unknown[]): void;
16
+ warn(...args: unknown[]): void;
17
+ };
18
+ /**
19
+ * Context logger interface (matches FastMCP's context.log)
20
+ */
21
+ interface ContextLog {
22
+ debug: (message: string, data?: unknown) => void;
23
+ error: (message: string, data?: unknown) => void;
24
+ info: (message: string, data?: unknown) => void;
25
+ warn: (message: string, data?: unknown) => void;
26
+ }
27
+ /**
28
+ * File-only logger for use when there's no context logger available.
29
+ * Writes directly to the pino log file.
30
+ */
31
+ export declare const fileLogger: ContextLog;
32
+ /**
33
+ * Create a combined logger that writes to both the file logger and a context logger.
34
+ * This ensures logs are visible both in `bun run logs` and in MCP clients that surface context logs.
35
+ *
36
+ * @param contextLog - The FastMCP context logger (from tool execute context)
37
+ * @returns A logger that writes to both destinations
38
+ */
39
+ export declare function createCombinedLogger(contextLog: ContextLog): ContextLog;
40
+ /**
41
+ * Get the glob pattern for log files.
42
+ * pino-roll names files as server.N.log where N is the rotation counter.
43
+ */
44
+ export declare function getLogFilePattern(): string;
45
+ /**
46
+ * Get the log directory path.
47
+ */
48
+ export declare function getLogDir(): string;
49
+ export {};
50
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAoDH;;;GAGG;AACH,eAAO,MAAM,MAAM;mBACF,OAAO,EAAE,GAAG,IAAI;mBAGhB,OAAO,EAAE,GAAG,IAAI;kBAGjB,OAAO,EAAE,GAAG,IAAI;iBAGjB,OAAO,EAAE,GAAG,IAAI;kBAIf,OAAO,EAAE,GAAG,IAAI;CAG/B,CAAC;AAEF;;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;;;GAGG;AACH,eAAO,MAAM,UAAU,EAAE,UAaxB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,UAAU,GAAG,UAAU,CAwBvE;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED;;GAEG;AACH,wBAAgB,SAAS,IAAI,MAAM,CAElC"}