actual-mcp-server 0.20.0 → 0.22.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.
- package/README.md +14 -10
- package/dist/src/actualToolsManager.js +4 -0
- package/dist/src/lib/actual-adapter.js +88 -7
- package/dist/src/lib/actual-schema.js +13 -0
- package/dist/src/lib/budgetLoader.js +54 -2
- package/dist/src/lib/init-failure.js +240 -0
- package/dist/src/lib/query-validator.js +107 -6
- package/dist/src/lib/schemas/common.js +10 -1
- package/dist/src/lib/server-version-guard.js +32 -7
- package/dist/src/lib/tool-annotations.js +4 -0
- package/dist/src/server/httpServer.js +5 -91
- package/dist/src/server/stdioServer.js +78 -39
- package/dist/src/tools/account_groups_create.js +26 -0
- package/dist/src/tools/account_groups_delete.js +23 -0
- package/dist/src/tools/account_groups_list.js +23 -0
- package/dist/src/tools/account_groups_update.js +25 -0
- package/dist/src/tools/index.js +4 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -24,13 +24,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
24
24
|
┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
|
|
25
25
|
│ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
26
26
|
│ LobeChat │ │ Server │ │ Budget │
|
|
27
|
-
│ (remote) │ │ (
|
|
27
|
+
│ (remote) │ │ (81 tools) │ │ Server │
|
|
28
28
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
29
29
|
|
|
30
30
|
┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
|
|
31
31
|
│ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
32
32
|
│ Desktop │ │ Server │ │ Budget │
|
|
33
|
-
│ (local) │ │ (
|
|
33
|
+
│ (local) │ │ (81 tools) │ │ Server │
|
|
34
34
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
35
35
|
```
|
|
36
36
|
|
|
@@ -38,14 +38,14 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
38
38
|
|
|
39
39
|
Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
|
|
40
40
|
|
|
41
|
-
- **
|
|
41
|
+
- **81 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
|
|
42
42
|
- **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
|
|
43
43
|
- **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
|
|
44
44
|
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
|
|
45
45
|
- **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
|
|
46
46
|
- **Production-grade reliability on both transports.** HTTP connection pooling (up to 15 concurrent sessions), and a long-lived stdio process that logs in ONCE and reuses that connection for every tool call instead of re-authenticating per call, so Claude Desktop and Claude Code stay fast and a burst of calls no longer risks a per-call login storm against the upstream limiter. Automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
|
|
47
47
|
|
|
48
|
-
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All
|
|
48
|
+
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 81 tools tested end-to-end. Any MCP-compatible client should work.
|
|
49
49
|
|
|
50
50
|
---
|
|
51
51
|
|
|
@@ -182,7 +182,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
|
|
|
182
182
|
}
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All
|
|
185
|
+
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 81 tools are available.
|
|
186
186
|
>
|
|
187
187
|
> **`MCP_BRIDGE_DATA_DIR` should be an absolute path.** Without one, the data directory resolves relative to wherever the client spawns the process, which can be unpredictable. The directory is created automatically on first run.
|
|
188
188
|
|
|
@@ -308,9 +308,9 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
308
308
|
|
|
309
309
|
## Available Tools
|
|
310
310
|
|
|
311
|
-
**
|
|
311
|
+
**81 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
312
312
|
|
|
313
|
-
### Accounts (
|
|
313
|
+
### Accounts (12)
|
|
314
314
|
|
|
315
315
|
| Tool | Description |
|
|
316
316
|
|------|-------------|
|
|
@@ -322,6 +322,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
322
322
|
| `actual_accounts_reopen` | Reopen closed account. An id that is not an account is refused rather than creating one |
|
|
323
323
|
| `actual_accounts_get_balance` | Get account balance at a date |
|
|
324
324
|
| `actual_account_flow_summary` | Explain the exact balance change across a set of accounts over a date range: external income, expense outflow, credits, uncategorized inflows, and transfers into, out of, or within the selection, with an exact reconciliation |
|
|
325
|
+
| `actual_account_groups_list` | List the custom account groups used to organise accounts in the sidebar (Actual 26.9.0+) |
|
|
326
|
+
| `actual_account_groups_create` | Create an account group. Assigning accounts to it is a separate `actual_accounts_update` call |
|
|
327
|
+
| `actual_account_groups_update` | Rename an account group or change its sidebar position. A group that does not exist is refused, writing nothing |
|
|
328
|
+
| `actual_account_groups_delete` | Delete an account group. Its accounts are NOT deleted, they become ungrouped |
|
|
325
329
|
|
|
326
330
|
### Transactions (16)
|
|
327
331
|
|
|
@@ -594,7 +598,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
|
|
|
594
598
|
- No auth token. Process ownership is the security boundary.
|
|
595
599
|
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
|
|
596
600
|
- The process exits when stdin closes (Claude Desktop shutting down)
|
|
597
|
-
- All
|
|
601
|
+
- All 81 tools are available, identical to HTTP mode
|
|
598
602
|
|
|
599
603
|
**Start manually to verify:**
|
|
600
604
|
|
|
@@ -693,7 +697,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
|
|
|
693
697
|
| Command | What It Tests | Requires Live Server |
|
|
694
698
|
|---------|---------------|---------------------|
|
|
695
699
|
| `npm run build` | TypeScript compilation | No |
|
|
696
|
-
| `npm run test:unit-js` |
|
|
700
|
+
| `npm run test:unit-js` | 81-tool smoke, schema validation, auth ACL | No |
|
|
697
701
|
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
|
|
698
702
|
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
|
|
699
703
|
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
|
|
@@ -777,7 +781,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
777
781
|
|
|
778
782
|
---
|
|
779
783
|
|
|
780
|
-
**Version:** 0.
|
|
784
|
+
**Version:** 0.22.0 | **Tool Count:** 81 (verified LibreChat-compatible)
|
|
781
785
|
|
|
782
786
|
## Sponsor
|
|
783
787
|
|
|
@@ -10,6 +10,10 @@ const IMPLEMENTED_TOOLS = [
|
|
|
10
10
|
'actual_accounts_create',
|
|
11
11
|
'actual_accounts_delete',
|
|
12
12
|
'actual_accounts_get_balance',
|
|
13
|
+
'actual_account_groups_list',
|
|
14
|
+
'actual_account_groups_create',
|
|
15
|
+
'actual_account_groups_update',
|
|
16
|
+
'actual_account_groups_delete',
|
|
13
17
|
'actual_accounts_list',
|
|
14
18
|
'actual_accounts_reopen',
|
|
15
19
|
'actual_accounts_update',
|
|
@@ -5,13 +5,14 @@ import api from '@actual-app/api';
|
|
|
5
5
|
// cannot expose its named exports via static import syntax. At runtime the default import
|
|
6
6
|
// IS module.exports, so all methods are accessible as properties.
|
|
7
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
-
const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences,
|
|
8
|
+
const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
|
|
9
9
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
10
|
} = api;
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
12
12
|
import observability from '../observability.js';
|
|
13
13
|
import { retry, isRetryableError, isRateLimitError } from './retry.js';
|
|
14
14
|
import { withOpTimeout } from './opTimeout.js';
|
|
15
|
+
import { markInitFailure } from './init-failure.js';
|
|
15
16
|
import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
|
|
16
17
|
import { findMatchingRule } from './rule-matching.js';
|
|
17
18
|
import logger from '../logger.js';
|
|
@@ -628,7 +629,15 @@ async function initActualApiForOperation() {
|
|
|
628
629
|
//
|
|
629
630
|
// Checking HERE covers every legacy caller in one place: both withActualApi branches,
|
|
630
631
|
// withActualApiWrite, and the drain's legacy branch.
|
|
631
|
-
|
|
632
|
+
// #452: branded on the same terms as the full-init path below. This branch skips api.init()
|
|
633
|
+
// but ensureLoadedBudgetMatchesSession can still DOWNLOAD a budget (after a budgets_switch,
|
|
634
|
+
// or when another session moved the singleton), so it raises the same load failures.
|
|
635
|
+
try {
|
|
636
|
+
await ensureLoadedBudgetMatchesSession();
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
throw markInitFailure(err);
|
|
640
|
+
}
|
|
632
641
|
return;
|
|
633
642
|
}
|
|
634
643
|
try {
|
|
@@ -657,7 +666,12 @@ async function initActualApiForOperation() {
|
|
|
657
666
|
}
|
|
658
667
|
catch (err) {
|
|
659
668
|
logger.error('[ADAPTER] Error initializing Actual API:', err);
|
|
660
|
-
|
|
669
|
+
// #452: mark it as an INITIALISATION failure before it leaves this function. stdio has one
|
|
670
|
+
// catch for every tool error and cannot otherwise tell "the connection could not be
|
|
671
|
+
// established" from "the tool failed", and guessing from the message misfires: a #270 op
|
|
672
|
+
// timeout reads as `timeout` and a #422 rate-limit reads as `auth_failed`. The mark is
|
|
673
|
+
// additive and non-enumerable, so nothing about the error's own propagation changes.
|
|
674
|
+
throw markInitFailure(err);
|
|
661
675
|
}
|
|
662
676
|
}
|
|
663
677
|
async function shutdownActualApi(opts) {
|
|
@@ -2683,7 +2697,11 @@ export async function runQuery(queryString) {
|
|
|
2683
2697
|
});
|
|
2684
2698
|
}
|
|
2685
2699
|
catch (error) {
|
|
2686
|
-
|
|
2700
|
+
// #452 review: `cause` preserves the ORIGINAL error, so an init-failure BRAND survives
|
|
2701
|
+
// the rewrap. Without it, actual_query_run reported a failed connection as
|
|
2702
|
+
// "Query execution failed: <raw upstream text>" and stdio could not recognise it,
|
|
2703
|
+
// silently exempting this tool from the fix.
|
|
2704
|
+
throw new Error(`Query execution failed: ${error.message}`, { cause: error });
|
|
2687
2705
|
}
|
|
2688
2706
|
}
|
|
2689
2707
|
const trimmed = queryString.trim();
|
|
@@ -2818,7 +2836,7 @@ export async function runQuery(queryString) {
|
|
|
2818
2836
|
if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
|
|
2819
2837
|
throw error; // Re-throw the well-formatted validation error without wrapping
|
|
2820
2838
|
}
|
|
2821
|
-
throw new Error(`Query execution failed: ${errorMsg}
|
|
2839
|
+
throw new Error(`Query execution failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
|
|
2822
2840
|
}
|
|
2823
2841
|
}
|
|
2824
2842
|
/**
|
|
@@ -2990,9 +3008,9 @@ export async function runBankSync(accountId) {
|
|
|
2990
3008
|
errorMsg.includes('BankSyncError') ||
|
|
2991
3009
|
errorMsg.includes('NORDIGEN_ERROR') ||
|
|
2992
3010
|
errorMsg.includes('Failed syncing account')) {
|
|
2993
|
-
throw new Error(`Bank sync failed: Provider error — ${errorMsg}
|
|
3011
|
+
throw new Error(`Bank sync failed: Provider error — ${errorMsg}`, { cause: error });
|
|
2994
3012
|
}
|
|
2995
|
-
throw new Error(`Bank sync failed: ${errorMsg}
|
|
3013
|
+
throw new Error(`Bank sync failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
|
|
2996
3014
|
}
|
|
2997
3015
|
}
|
|
2998
3016
|
export async function getBudgets() {
|
|
@@ -3200,6 +3218,65 @@ export async function getServerVersion() {
|
|
|
3200
3218
|
});
|
|
3201
3219
|
}
|
|
3202
3220
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3221
|
+
/**
|
|
3222
|
+
* Account groups (#429). Upstream added these in 26.9.0; the schema arrives through a
|
|
3223
|
+
* migration SHIPPED IN `@actual-app/api` and applied to the LOCAL budget file, so these
|
|
3224
|
+
* work against an older sync server too: the server relays messages and does not execute
|
|
3225
|
+
* the queries.
|
|
3226
|
+
*
|
|
3227
|
+
* One semantic worth carrying into the tool descriptions, taken from upstream's own
|
|
3228
|
+
* comment on the delete path: clearing member refs is best effort under CRDT sync, so a
|
|
3229
|
+
* concurrent assignment on another device can win against those nulls. Consumers must
|
|
3230
|
+
* treat an account whose `account_group_id` points at a missing or tombstoned group as
|
|
3231
|
+
* UNGROUPED rather than as an error.
|
|
3232
|
+
*/
|
|
3233
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3234
|
+
export async function getAccountGroups() {
|
|
3235
|
+
return withActualApi(async () => {
|
|
3236
|
+
observability.incrementToolCall('actual.account_groups.list').catch(() => { });
|
|
3237
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3238
|
+
return await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
export async function createAccountGroup(group) {
|
|
3242
|
+
observability.incrementToolCall('actual.account_groups.create').catch(() => { });
|
|
3243
|
+
return queueWriteOperation(async () => {
|
|
3244
|
+
const raw = await withConcurrency(() => retry(() => rawCreateAccountGroup(group), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
3245
|
+
return normalizeToId(raw);
|
|
3246
|
+
// Lands in account_groups only, so the four entity listings are untouched.
|
|
3247
|
+
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
3248
|
+
}
|
|
3249
|
+
export async function updateAccountGroup(id, fields) {
|
|
3250
|
+
observability.incrementToolCall('actual.account_groups.update').catch(() => { });
|
|
3251
|
+
return queueWriteOperation(async () => {
|
|
3252
|
+
// Read, decide and write inside ONE queued operation, so the existence check and the
|
|
3253
|
+
// write are a single api-lock cycle and cannot be interleaved by a sibling (#371/#378).
|
|
3254
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3255
|
+
const groups = await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
|
|
3256
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3257
|
+
if (!groups.some((g) => g.id === id)) {
|
|
3258
|
+
throw new NotFoundRefusal('Account group', id, 'actual_account_groups_list');
|
|
3259
|
+
}
|
|
3260
|
+
await withConcurrency(() => retry(() => rawUpdateAccountGroup(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
3261
|
+
// Renames the group row itself; no account row changes, so listings are preserved.
|
|
3262
|
+
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
3263
|
+
}
|
|
3264
|
+
export async function deleteAccountGroup(id) {
|
|
3265
|
+
observability.incrementToolCall('actual.account_groups.delete').catch(() => { });
|
|
3266
|
+
return queueWriteOperation(async () => {
|
|
3267
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3268
|
+
const groups = await withConcurrency(() => retry(() => rawGetAccountGroups(), { retries: 2, backoffMs: 200 }));
|
|
3269
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3270
|
+
if (!groups.some((g) => g.id === id)) {
|
|
3271
|
+
throw new NotFoundRefusal('Account group', id, 'actual_account_groups_list');
|
|
3272
|
+
}
|
|
3273
|
+
await withConcurrency(() => retry(() => rawDeleteAccountGroup(id), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
3274
|
+
// NO preservesListings claim, deliberately: upstream's delete UPDATES every member
|
|
3275
|
+
// account to null its account_group_id before removing the group, so the accounts
|
|
3276
|
+
// listing CONTENT changes even though its id set does not. Claiming preservation here
|
|
3277
|
+
// would serve a stale accounts listing to the next guard in the same drain.
|
|
3278
|
+
});
|
|
3279
|
+
}
|
|
3203
3280
|
export async function getTags() {
|
|
3204
3281
|
return withActualApi(async () => {
|
|
3205
3282
|
observability.incrementToolCall('actual.tags.get').catch(() => { });
|
|
@@ -3421,6 +3498,10 @@ export default {
|
|
|
3421
3498
|
getPayees,
|
|
3422
3499
|
getCommonPayees,
|
|
3423
3500
|
createPayee,
|
|
3501
|
+
getAccountGroups,
|
|
3502
|
+
createAccountGroup,
|
|
3503
|
+
updateAccountGroup,
|
|
3504
|
+
deleteAccountGroup,
|
|
3424
3505
|
getTags,
|
|
3425
3506
|
createTag,
|
|
3426
3507
|
updateTag,
|
|
@@ -52,6 +52,19 @@ export const ACTUAL_SCHEMA = {
|
|
|
52
52
|
mask: { type: 'string' },
|
|
53
53
|
bank: { type: 'id', ref: 'banks' },
|
|
54
54
|
account_sync_source: { type: 'string' },
|
|
55
|
+
// #429: these four exist in upstream's ActualQL schema but were missing here, so
|
|
56
|
+
// actual_query_run rejected valid queries against them. account_group_id arrived with
|
|
57
|
+
// account groups in 26.9.0; the other three predate it and were simply never added.
|
|
58
|
+
account_group_id: { type: 'id', ref: 'account_groups' },
|
|
59
|
+
last_reconciled: { type: 'string' },
|
|
60
|
+
last_sync: { type: 'string' },
|
|
61
|
+
bank_sync_status: { type: 'string' },
|
|
62
|
+
},
|
|
63
|
+
account_groups: {
|
|
64
|
+
id: { type: 'id' },
|
|
65
|
+
name: { type: 'string', required: true },
|
|
66
|
+
sort_order: { type: 'float' },
|
|
67
|
+
tombstone: { type: 'boolean' },
|
|
55
68
|
},
|
|
56
69
|
categories: {
|
|
57
70
|
id: { type: 'id' },
|
|
@@ -3,6 +3,8 @@ import { withOpTimeout } from './opTimeout.js';
|
|
|
3
3
|
import config from '../config.js';
|
|
4
4
|
import { setApiInitialized, setLoadedBudgetSyncId, registerBudgetLoad } from './apiState.js';
|
|
5
5
|
import { createModuleLogger } from './loggerFactory.js';
|
|
6
|
+
import { checkServerVersionOnce } from './server-version-guard.js';
|
|
7
|
+
import { markInitFailure } from './init-failure.js';
|
|
6
8
|
const log = createModuleLogger('BUDGET-LOADER');
|
|
7
9
|
/**
|
|
8
10
|
* #390 round 3: the ONE way this codebase loads a budget.
|
|
@@ -409,6 +411,13 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
|
|
|
409
411
|
throw err;
|
|
410
412
|
}
|
|
411
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* How long the #453 pre-download version probe may take before it is abandoned. Deliberately a
|
|
416
|
+
* CONSTANT and not an env var: it is an advisory diagnostic, nobody should have to tune it, and
|
|
417
|
+
* a new config key would need to be threaded through config.ts, .env.example and the README
|
|
418
|
+
* table (the config-drift guard enforces all three) to buy nothing.
|
|
419
|
+
*/
|
|
420
|
+
const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
412
421
|
/**
|
|
413
422
|
* #394: the same discipline for an IMPORT.
|
|
414
423
|
*
|
|
@@ -447,6 +456,43 @@ export async function importBudgetTracked(start, label = 'importBudget') {
|
|
|
447
456
|
return trackBudgetMutation(start, (result) => `imported:${result.id}`, label, undefined, undefined, config.ACTUAL_IMPORT_TIMEOUT_MS);
|
|
448
457
|
}
|
|
449
458
|
export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
|
|
459
|
+
// #453: warn BEFORE the download, not after a successful operation.
|
|
460
|
+
//
|
|
461
|
+
// #276's guard fires from `withActualApi` AFTER `rawOperation()` resolves, so it cannot reach
|
|
462
|
+
// the one case it would help most: a download that FAILS because the server's schema is newer
|
|
463
|
+
// than the bundled `@actual-app/api` (the #427 shape, which recurs on every Actual release that
|
|
464
|
+
// adds a column, for anyone who upgrades inside the dependency soak window). Nothing warned at
|
|
465
|
+
// all when the operation died.
|
|
466
|
+
//
|
|
467
|
+
// HERE rather than in `initActualApiForOperation`, which is the legacy path only: a pooled HTTP
|
|
468
|
+
// session downloads without passing through it, so a probe there would be silent for exactly the
|
|
469
|
+
// multi-user deployments most likely to run a separately-upgraded server. This function is the
|
|
470
|
+
// single funnel every load must pass through, enforced by budget_selection_precondition.test.js.
|
|
471
|
+
//
|
|
472
|
+
// Three properties hold it in place:
|
|
473
|
+
// - OUTSIDE the tracked chain (before the try), because nothing in that chain may take the
|
|
474
|
+
// api lock or extend the window an abandoned load holds open.
|
|
475
|
+
// - BOUNDED by withOpTimeout: this runs inside the process-global api mutex, so an
|
|
476
|
+
// unbounded advisory GET would stall every session. If /info is stalled the download was
|
|
477
|
+
// going to fail anyway, so the bound costs nothing real.
|
|
478
|
+
// - ONCE per process, via the guard's own synchronous flag, which is shared with the post-op
|
|
479
|
+
// call site in actual-adapter.ts. Whichever fires first wins; the other becomes a no-op, so
|
|
480
|
+
// there is no double warning. This one normally fires first, being earlier.
|
|
481
|
+
//
|
|
482
|
+
// `api.getServerVersion` is read at call time (not destructured) so it needs only the server URL
|
|
483
|
+
// that api.init() already established: NO budget has to be loaded, which is what makes a probe
|
|
484
|
+
// before the download possible at all.
|
|
485
|
+
await checkServerVersionOnce(() => withOpTimeout(() => api
|
|
486
|
+
.getServerVersion(), 'server-version-probe',
|
|
487
|
+
// A SHORT dedicated bound, not the general ACTUAL_OP_TIMEOUT_MS (30s by default).
|
|
488
|
+
//
|
|
489
|
+
// Review of the first version caught the reason: `/info` is an unauthenticated GET on a
|
|
490
|
+
// different route from the sync endpoints, so it can stall while the login and download
|
|
491
|
+
// are perfectly healthy. At the general bound that stall costs 30s of the process-global
|
|
492
|
+
// api mutex, and since a failed probe leaves the guard armed it would be paid again on
|
|
493
|
+
// each of the next two loads. An ADVISORY diagnostic must never be able to do that. If
|
|
494
|
+
// the probe cannot answer in 5 seconds it has nothing useful to say.
|
|
495
|
+
SERVER_VERSION_PROBE_TIMEOUT_MS, 'the server-version probe bound (fixed, not configurable)'), log);
|
|
450
496
|
try {
|
|
451
497
|
await trackBudgetMutation(
|
|
452
498
|
// #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
|
|
@@ -467,10 +513,16 @@ export async function loadBudgetTracked(syncId, encryptionPassword, label = 'dow
|
|
|
467
513
|
// all registrations under ONE bound and fails closed on timeout, so two network calls inside
|
|
468
514
|
// the tracked chain would extend the window in which unrelated sessions' lock acquisitions
|
|
469
515
|
// throw, to build a message an abandoned load has no caller to read.
|
|
516
|
+
// #452 review: brand EVERY failed budget load, not just the ones that happen to pass through
|
|
517
|
+
// initActualApiForOperation. The write drain calls ensureLoadedBudgetMatchesSession directly
|
|
518
|
+
// per operation, and the pooled precondition sites do too, so a load failure there escaped
|
|
519
|
+
// unbranded and stdio handed the user the raw upstream sentence the fix exists to suppress.
|
|
520
|
+
// This function is the funnel every load passes through, so branding here needs no list of
|
|
521
|
+
// call sites, which is the same argument that put the version probe above.
|
|
470
522
|
if (isBudgetNotOpen(err)) {
|
|
471
523
|
log.error('download resolved but no budget is open', undefined, { syncId });
|
|
472
|
-
throw postConditionError(syncId, await diagnose(syncId));
|
|
524
|
+
throw markInitFailure(postConditionError(syncId, await diagnose(syncId)));
|
|
473
525
|
}
|
|
474
|
-
throw err;
|
|
526
|
+
throw markInitFailure(err);
|
|
475
527
|
}
|
|
476
528
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialisation-failure classification, shared by BOTH transports.
|
|
3
|
+
*
|
|
4
|
+
* #438 built this for HTTP and left it inside `httpServer.ts`. #452 moved it here
|
|
5
|
+
* unchanged, because stdio needs the same answers and a transport importing another
|
|
6
|
+
* transport for a domain concept is the wrong dependency. `httpServer.ts` re-exports
|
|
7
|
+
* `classifyInitFailure` and `InitFailureCause`, so every existing importer is unaffected.
|
|
8
|
+
*
|
|
9
|
+
* The BRAND at the bottom of this file is the #452 addition. Read its comment before
|
|
10
|
+
* calling the classifier from any new site: the classifier is only safe where the caller
|
|
11
|
+
* already knows it is holding an initialisation failure.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* #438: classify a session-init failure into a CLOSED enum plus our own sentence.
|
|
15
|
+
*
|
|
16
|
+
* PURE and TOTAL: it never throws, never reads I/O, and never returns anything
|
|
17
|
+
* derived from the caught value. That last property is the wire contract: the
|
|
18
|
+
* response carries the enum member and the fixed sentence below and nothing
|
|
19
|
+
* else, so no upstream string reaches a remote client and no scrubber has to be
|
|
20
|
+
* correct. Upstream strings can carry a stack, raw SQL (`SyncError.meta.query`),
|
|
21
|
+
* an `EACCES ... mkdir '/home/<user>/.actual'` path, and a configured server URL.
|
|
22
|
+
*
|
|
23
|
+
* Exported for tests: there is NO fault-injection seam in actualConnection.ts or
|
|
24
|
+
* the pool, and booting against a closed port reaches only `network-failure`, so
|
|
25
|
+
* every other class is covered by calling this directly with synthetic values.
|
|
26
|
+
*/
|
|
27
|
+
// The rate-limit decision is IMPORTED, never copied. #177 collapsed the retry decision and the
|
|
28
|
+
// pool-drop decision onto one authored pattern source precisely so a second list could not drift
|
|
29
|
+
// from the first, and a classifier holding its own near-miss copy would reintroduce that.
|
|
30
|
+
import { isRateLimitError } from './retry.js';
|
|
31
|
+
const INIT_FAILURE_SENTENCES = {
|
|
32
|
+
schema_too_new: "The Actual server's database schema is newer than the @actual-app/api this build bundles. Upgrade actual-mcp-server, or hold the server upgrade until its dependency update ships.",
|
|
33
|
+
auth_failed: 'Authentication against the Actual server failed. Check ACTUAL_PASSWORD and the budget password.',
|
|
34
|
+
// Deliberately not asserting permanence: ECONNRESET and ETIMEDOUT are classed
|
|
35
|
+
// TRANSIENT by isRetryableError, so this can be a blip rather than misconfiguration.
|
|
36
|
+
network_unreachable: 'The Actual server could not be reached, which may be transient. If it persists, check ACTUAL_SERVER_URL and that the server is running.',
|
|
37
|
+
// Deliberately does NOT name a single env var. `loadBudgetTracked` brands every failed load,
|
|
38
|
+
// and actual_budgets_switch loads a budget the CALLER named, so on a multi-budget deployment
|
|
39
|
+
// "check ACTUAL_BUDGET_SYNC_ID" sent a user who asked to switch to their household budget to
|
|
40
|
+
// the wrong variable entirely. The sync id is what is wrong either way; which setting holds
|
|
41
|
+
// it depends on how the budget was selected.
|
|
42
|
+
budget_not_found: 'The requested budget was not found on the Actual server. Check the sync id it was selected by: ACTUAL_BUDGET_SYNC_ID for the default budget, or the matching BUDGET_n_SYNC_ID in a multi-budget setup.',
|
|
43
|
+
out_of_sync: 'The local budget copy is out of sync with the Actual server and could not be reconciled.',
|
|
44
|
+
encryption_error: "The budget's end-to-end encryption password is wrong or missing.",
|
|
45
|
+
clock_drift: "The host clock differs too far from the Actual server's. Fix the system time.",
|
|
46
|
+
// A filesystem permission error, WITHOUT claiming which path: the classifier
|
|
47
|
+
// sees only the errno, and EACCES can come from the data directory, TLS
|
|
48
|
+
// material, or a socket during api.init.
|
|
49
|
+
permission_denied: 'The server was denied filesystem access while initialising. Check the data directory mount and its ownership first, then any TLS material.',
|
|
50
|
+
timeout: 'Initialising the Actual connection ran out of time, which may be transient.',
|
|
51
|
+
// Deliberately NOT phrased as a credential problem: the credentials are fine, the login
|
|
52
|
+
// window is full. Actual's limiter is 500 requests per minute and is not configurable.
|
|
53
|
+
rate_limited: 'The Actual server rejected the login because too many requests were made in a short window. Nothing is misconfigured: wait a minute and retry.',
|
|
54
|
+
unknown: 'The Actual connection for this session could not be initialised. See the server log for the cause.',
|
|
55
|
+
};
|
|
56
|
+
/** Actual's own reason strings, from `withErrorCode` and SyncError, to our enum.
|
|
57
|
+
* `out-of-sync-migrations` maps to schema_too_new, NOT out_of_sync: `invalid-schema`
|
|
58
|
+
* is absent from budgetLoader's KNOWN_LOAD_REASONS, so migrations is the only route
|
|
59
|
+
* by which a too-new schema surfaces on the post-condition path, and upstream's own
|
|
60
|
+
* sentence for it is "This budget cannot be loaded with this version of the app." */
|
|
61
|
+
const REASON_TO_CAUSE = {
|
|
62
|
+
'invalid-schema': 'schema_too_new',
|
|
63
|
+
'out-of-sync-migrations': 'schema_too_new',
|
|
64
|
+
'out-of-sync': 'out_of_sync',
|
|
65
|
+
'out-of-sync-data': 'out_of_sync',
|
|
66
|
+
'budget-not-found': 'budget_not_found',
|
|
67
|
+
'clock-drift': 'clock_drift',
|
|
68
|
+
'encrypt-failure': 'encryption_error',
|
|
69
|
+
'decrypt-failure': 'encryption_error',
|
|
70
|
+
'missing-key': 'encryption_error',
|
|
71
|
+
};
|
|
72
|
+
/** Node fs/net codes. A DIFFERENT namespace from Actual's reasons, deliberately
|
|
73
|
+
* kept in its own table so the two can never be conflated. */
|
|
74
|
+
const SYSTEM_CODE_TO_CAUSE = {
|
|
75
|
+
EACCES: 'permission_denied',
|
|
76
|
+
EPERM: 'permission_denied',
|
|
77
|
+
EROFS: 'permission_denied',
|
|
78
|
+
ECONNREFUSED: 'network_unreachable',
|
|
79
|
+
ENOTFOUND: 'network_unreachable',
|
|
80
|
+
ECONNRESET: 'network_unreachable',
|
|
81
|
+
EHOSTUNREACH: 'network_unreachable',
|
|
82
|
+
ETIMEDOUT: 'timeout',
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Walks the `cause` chain, for the same reason `isInitFailure` does and discovered the same way:
|
|
86
|
+
* `runQuery` and `bankSync` rewrap into a friendlier message, and the rewrapped error carries
|
|
87
|
+
* neither the upstream `.code` nor the reason in its own text. Recognising it as an init failure
|
|
88
|
+
* while classifying it `unknown` would leave those tools propagating the raw upstream sentence,
|
|
89
|
+
* which is the exact defect #452 exists to remove. The FIRST named answer in the chain wins;
|
|
90
|
+
* `unknown` means keep looking rather than give up.
|
|
91
|
+
*/
|
|
92
|
+
export function classifyInitFailure(err) {
|
|
93
|
+
let current = err;
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
96
|
+
const verdict = classifyOne(current);
|
|
97
|
+
if (verdict.cause !== 'unknown')
|
|
98
|
+
return verdict;
|
|
99
|
+
if (!current || (typeof current !== 'object' && typeof current !== 'function'))
|
|
100
|
+
break;
|
|
101
|
+
if (seen.has(current))
|
|
102
|
+
break;
|
|
103
|
+
seen.add(current);
|
|
104
|
+
current = current.cause;
|
|
105
|
+
if (current === undefined || current === null)
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
return { cause: 'unknown', sentence: INIT_FAILURE_SENTENCES.unknown };
|
|
109
|
+
}
|
|
110
|
+
function classifyOne(err) {
|
|
111
|
+
const done = (cause) => ({ cause, sentence: INIT_FAILURE_SENTENCES[cause] });
|
|
112
|
+
try {
|
|
113
|
+
const e = err;
|
|
114
|
+
// `.code` FIRST: upstream's api/download-budget and api/load-budget never let a
|
|
115
|
+
// SyncError escape, they throw a plain Error carrying .code via withErrorCode.
|
|
116
|
+
const code = typeof e?.code === 'string' ? e.code : undefined;
|
|
117
|
+
if (code && REASON_TO_CAUSE[code])
|
|
118
|
+
return done(REASON_TO_CAUSE[code]);
|
|
119
|
+
if (code && SYSTEM_CODE_TO_CAUSE[code])
|
|
120
|
+
return done(SYSTEM_CODE_TO_CAUSE[code]);
|
|
121
|
+
// `.reason` only as a defensive fallback, for a SyncError that reaches us by
|
|
122
|
+
// some path that does not go through those two handlers.
|
|
123
|
+
const reason = typeof e?.reason === 'string' ? e.reason : undefined;
|
|
124
|
+
if (reason && REASON_TO_CAUSE[reason])
|
|
125
|
+
return done(REASON_TO_CAUSE[reason]);
|
|
126
|
+
const message = typeof e?.message === 'string' ? e.message : '';
|
|
127
|
+
// Our OWN synthesized post-condition error (#396) embeds the upstream reason
|
|
128
|
+
// in prose. On a resync of an existing local copy this is the shape that
|
|
129
|
+
// actually arrives for the schema and migration classes, so a classifier that
|
|
130
|
+
// handled only the upstream shapes would answer `unknown` for exactly the
|
|
131
|
+
// failures this ticket is about.
|
|
132
|
+
const embedded = /Upstream reason: \[([a-z-]+)\]/.exec(message)?.[1];
|
|
133
|
+
if (embedded && REASON_TO_CAUSE[embedded])
|
|
134
|
+
return done(REASON_TO_CAUSE[embedded]);
|
|
135
|
+
// Last resort, message shapes. `network-failure` is tested BEFORE the auth
|
|
136
|
+
// wording because upstream reports an unreachable server as
|
|
137
|
+
// "Authentication failed: network-failure", where the actionable half is the
|
|
138
|
+
// network, not the credentials.
|
|
139
|
+
if (/network-failure|ECONNREFUSED|ENOTFOUND/i.test(message))
|
|
140
|
+
return done('network_unreachable');
|
|
141
|
+
if (/timed out|ETIMEDOUT/i.test(message))
|
|
142
|
+
return done('timeout');
|
|
143
|
+
// #452 review: BEFORE the auth branch. A throttled login surfaces as
|
|
144
|
+
// "Authentication failed: Too many requests" (retry.ts documents that exact shape), and
|
|
145
|
+
// withAuthRetry rethrows it once its budget is exhausted, so it reaches here as a genuine
|
|
146
|
+
// INITIALISATION failure and is branded. Classifying it `auth_failed` told a rate-limited
|
|
147
|
+
// user to check their password, which is a wrong answer in the same family this whole
|
|
148
|
+
// classifier exists to remove. It affects HTTP identically; that was live before #452.
|
|
149
|
+
if (isRateLimitError(err))
|
|
150
|
+
return done('rate_limited');
|
|
151
|
+
if (/Authentication failed|invalid-password|Invalid password/i.test(message))
|
|
152
|
+
return done('auth_failed');
|
|
153
|
+
if (/invalid-schema/i.test(message))
|
|
154
|
+
return done('schema_too_new');
|
|
155
|
+
return done('unknown');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// TOTAL by construction: a classifier that throws would land on the outer POST
|
|
159
|
+
// catch and egress as raw String(err), defeating this contract through the one
|
|
160
|
+
// line deliberately left to #446.
|
|
161
|
+
return done('unknown');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* #452: the brand that says "this error came from establishing the Actual connection".
|
|
166
|
+
*
|
|
167
|
+
* WHY A BRAND AND NOT THE MESSAGE. `classifyInitFailure` falls back to matching message
|
|
168
|
+
* prose, which is safe at the HTTP call site because that site is reached ONLY from the
|
|
169
|
+
* catch around session init, so anything it sees IS an init failure. The stdio call site
|
|
170
|
+
* is every tool error, and there the prose branches misfire on errors that are not init
|
|
171
|
+
* failures at all. Measured on v0.21.0:
|
|
172
|
+
*
|
|
173
|
+
* `Actual API operation timed out after 30000ms (ACTUAL_OP_TIMEOUT_MS)` -> `timeout`
|
|
174
|
+
* `Authentication failed: Too many requests` (the #422 rate-limit tail) -> `auth_failed`
|
|
175
|
+
*
|
|
176
|
+
* The first is the #270 nesting-bug signal, which CLAUDE.md instructs a reader to
|
|
177
|
+
* interpret as a probable deadlock rather than a slow server. The second would tell a
|
|
178
|
+
* rate-limited user their password is wrong. Both replace a true message with a false one.
|
|
179
|
+
*
|
|
180
|
+
* So classification is gated on ORIGIN, not wording, which is the same rule #377 applies
|
|
181
|
+
* to refusals: decide by TYPE, never by matching prose.
|
|
182
|
+
*
|
|
183
|
+
* The mark is applied at the CHOKEPOINTS, not at call sites: the api-init try/catch in
|
|
184
|
+
* `initActualApiForOperation`, and both branches of `loadBudgetTracked`'s catch. The second was
|
|
185
|
+
* added in review, and the reason generalises: the write drain calls
|
|
186
|
+
* `ensureLoadedBudgetMatchesSession` directly per operation, and the pooled precondition does
|
|
187
|
+
* too, so a budget-load failure on those paths reached stdio UNBRANDED and handed the user the
|
|
188
|
+
* raw upstream sentence this exists to suppress. Branding the funnel every load passes through
|
|
189
|
+
* means the set of call sites stops mattering, which is the same argument #393 used for the
|
|
190
|
+
* abandoned-load wait.
|
|
191
|
+
*
|
|
192
|
+
* `Symbol.for` rather than a class or `instanceof`: the error object is raised by
|
|
193
|
+
* `@actual-app/api` and merely passes through us, so it cannot be OUR type, and a symbol
|
|
194
|
+
* survives being rethrown by `toolFactory` and `actualToolsManager` untouched.
|
|
195
|
+
*/
|
|
196
|
+
const INIT_FAILURE_BRAND = Symbol.for('actual-mcp.init-failure');
|
|
197
|
+
/** Tag an error as having come from connection initialisation. Returns the same object. */
|
|
198
|
+
export function markInitFailure(err) {
|
|
199
|
+
try {
|
|
200
|
+
if (err && (typeof err === 'object' || typeof err === 'function')) {
|
|
201
|
+
Object.defineProperty(err, INIT_FAILURE_BRAND, {
|
|
202
|
+
value: true,
|
|
203
|
+
enumerable: false, // never serialised into a response or a log line
|
|
204
|
+
configurable: true,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// A frozen or exotic error stays unbranded, which degrades to today's behaviour
|
|
210
|
+
// (the raw error propagates) rather than to a wrong sentence. Never let the mark
|
|
211
|
+
// itself become a failure path.
|
|
212
|
+
}
|
|
213
|
+
return err;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* True only for an error raised while establishing the Actual connection.
|
|
217
|
+
*
|
|
218
|
+
* WALKS `cause`, because several adapter paths REWRAP an error into a new one with a friendlier
|
|
219
|
+
* message (`runQuery` and `bankSync` both do), and a rewrap that dropped the object would drop
|
|
220
|
+
* the brand with it, silently disabling #452 for exactly those tools. Those sites now pass the
|
|
221
|
+
* original as `cause`, so the mark survives one hop or many.
|
|
222
|
+
*
|
|
223
|
+
* Depth bounded, and cycle safe: an error whose `cause` points back at itself (or a long chain
|
|
224
|
+
* built by a retry wrapper) must not spin here. A diagnostic helper is never worth a hang.
|
|
225
|
+
*/
|
|
226
|
+
export function isInitFailure(err) {
|
|
227
|
+
let current = err;
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
230
|
+
if (!current || (typeof current !== 'object' && typeof current !== 'function'))
|
|
231
|
+
return false;
|
|
232
|
+
if (seen.has(current))
|
|
233
|
+
return false;
|
|
234
|
+
seen.add(current);
|
|
235
|
+
if (current[INIT_FAILURE_BRAND] === true)
|
|
236
|
+
return true;
|
|
237
|
+
current = current.cause;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|