actual-mcp-server 0.6.27 → 0.6.29
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 +1 -1
- package/dist/package.json +2 -2
- package/dist/src/lib/actual-adapter/auth-retry.js +80 -0
- package/dist/src/lib/actual-adapter/concurrency.js +56 -0
- package/dist/src/lib/actual-adapter/normalize.js +43 -0
- package/dist/src/lib/actual-adapter/query.js +107 -0
- package/dist/src/lib/actual-adapter.js +49 -309
- package/dist/src/lib/retry.js +40 -0
- package/package.json +2 -2
package/README.md
CHANGED
package/dist/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.29",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Auth-rate-limit retry subsystem for the Actual adapter (#166 split out of
|
|
2
|
+
// actual-adapter.ts). Self-contained: the two observability counters are
|
|
3
|
+
// mutated ONLY here (withAuthRetry / the reset), so the mutable state never
|
|
4
|
+
// crosses a module boundary. actual-adapter reads the counts via
|
|
5
|
+
// getAuthRetryCounts() for getConcurrencyState, and wraps api.init() with
|
|
6
|
+
// withAuthRetry. Linked issue: #127.
|
|
7
|
+
import { DEFAULT_RETRY_ATTEMPTS, MAX_RETRY_DELAY_MS } from '../constants.js';
|
|
8
|
+
import logger from '../../logger.js';
|
|
9
|
+
let authRetryCount = 0; // monotonic, observability
|
|
10
|
+
let authRetryFailureCount = 0; // increments only when retry budget exhausted
|
|
11
|
+
// The auth-rate-limit path uses a deliberately LARGER backoff than the generic
|
|
12
|
+
// retry helper because Actual Budget's auth rate-limiter operates on a multi-
|
|
13
|
+
// second sliding window, not a per-request burst. The generic 200ms base
|
|
14
|
+
// would exhaust within 1.4s, well inside the upstream's window.
|
|
15
|
+
//
|
|
16
|
+
// Empirically (2026-05-06, #127):
|
|
17
|
+
// - 200ms base = 1.4s total: too short, every retry hits the throttle.
|
|
18
|
+
// - 2000ms base = 14s total: insufficient under heavy auth pressure.
|
|
19
|
+
// - 5000ms base = 5s + 10s + 10s = 25s total (each step capped by
|
|
20
|
+
// MAX_RETRY_DELAY_MS): clears the window in light-pressure scenarios.
|
|
21
|
+
//
|
|
22
|
+
// Beyond 25s, blocking the API mutex starts to harm tail latency for unrelated
|
|
23
|
+
// tool calls. The long-term fix for sustained pressure is session reuse.
|
|
24
|
+
const AUTH_RETRY_BASE_BACKOFF_MS = 5000;
|
|
25
|
+
export function isRetryableAuthError(err) {
|
|
26
|
+
if (!(err instanceof Error))
|
|
27
|
+
return false;
|
|
28
|
+
return (err.message.includes('Authentication failed: too-many-requests') ||
|
|
29
|
+
err.message.includes('Authentication failed: network-failure'));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Wrap an operation with retry-on-rate-limit. Used to wrap api.init() so
|
|
33
|
+
* transient too-many-requests errors are absorbed transparently. The retry
|
|
34
|
+
* budget is capped at DEFAULT_RETRY_ATTEMPTS (3) attempts and total wallclock
|
|
35
|
+
* is bounded by MAX_RETRY_DELAY_MS via exponential backoff.
|
|
36
|
+
*
|
|
37
|
+
* Test-friendly: opts.maxRetries / opts.baseBackoffMs override the defaults
|
|
38
|
+
* so unit tests can run fast.
|
|
39
|
+
*
|
|
40
|
+
* Log hygiene: never logs the upstream URL, password, or any config-derived
|
|
41
|
+
* value, only the error class and the Actual error code plus the attempt counter.
|
|
42
|
+
*/
|
|
43
|
+
export async function withAuthRetry(operation, opts) {
|
|
44
|
+
const maxRetries = opts?.maxRetries ?? DEFAULT_RETRY_ATTEMPTS;
|
|
45
|
+
const baseBackoffMs = opts?.baseBackoffMs ?? AUTH_RETRY_BASE_BACKOFF_MS;
|
|
46
|
+
let attempt = 0;
|
|
47
|
+
while (true) {
|
|
48
|
+
try {
|
|
49
|
+
return await operation();
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (!isRetryableAuthError(err))
|
|
53
|
+
throw err;
|
|
54
|
+
attempt++;
|
|
55
|
+
if (attempt > maxRetries) {
|
|
56
|
+
// Budget exhausted: log + bump failure counter, but do NOT bump
|
|
57
|
+
// authRetryCount, which measures successful retry-and-sleep cycles.
|
|
58
|
+
authRetryFailureCount++;
|
|
59
|
+
const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
|
|
60
|
+
logger.error(`[ADAPTER] Auth retry exhausted after ${maxRetries} retries (last code: ${code})`);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
// We're going to retry: count it and sleep with exponential backoff.
|
|
64
|
+
authRetryCount++;
|
|
65
|
+
const delay = Math.min(baseBackoffMs * Math.pow(2, attempt - 1), MAX_RETRY_DELAY_MS);
|
|
66
|
+
const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
|
|
67
|
+
logger.debug(`[ADAPTER] Auth retry ${attempt}/${maxRetries} (code: ${code}) after ${delay}ms`);
|
|
68
|
+
await new Promise(r => setTimeout(r, delay));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Read-only snapshot of the auth-retry counters for getConcurrencyState. */
|
|
73
|
+
export function getAuthRetryCounts() {
|
|
74
|
+
return { authRetries: authRetryCount, authRetryFailures: authRetryFailureCount };
|
|
75
|
+
}
|
|
76
|
+
/** Test-only: reset the auth retry observability counters. */
|
|
77
|
+
export function _resetAuthRetryCountersForTests() {
|
|
78
|
+
authRetryCount = 0;
|
|
79
|
+
authRetryFailureCount = 0;
|
|
80
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// In-memory concurrency limiter for adapter calls (#166 split out of
|
|
2
|
+
// actual-adapter.ts). Self-contained: MAX_CONCURRENCY / running / queue are
|
|
3
|
+
// mutated ONLY by the functions here, so the mutable state never crosses a
|
|
4
|
+
// module boundary. It prevents bursts from overloading the Actual server.
|
|
5
|
+
// Intentionally tiny; replace with Bottleneck or p-queue for production.
|
|
6
|
+
import { DEFAULT_CONCURRENCY_LIMIT } from '../constants.js';
|
|
7
|
+
let MAX_CONCURRENCY = parseInt(process.env.ACTUAL_API_CONCURRENCY || String(DEFAULT_CONCURRENCY_LIMIT), 10);
|
|
8
|
+
let running = 0;
|
|
9
|
+
const queue = [];
|
|
10
|
+
function processQueue() {
|
|
11
|
+
if (running >= MAX_CONCURRENCY)
|
|
12
|
+
return;
|
|
13
|
+
const next = queue.shift();
|
|
14
|
+
if (!next)
|
|
15
|
+
return;
|
|
16
|
+
running++;
|
|
17
|
+
try {
|
|
18
|
+
next();
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
// next() will manage its own promise resolution
|
|
22
|
+
running--;
|
|
23
|
+
processQueue();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function withConcurrency(fn) {
|
|
27
|
+
if (running < MAX_CONCURRENCY) {
|
|
28
|
+
running++;
|
|
29
|
+
return fn().finally(() => {
|
|
30
|
+
running--;
|
|
31
|
+
processQueue();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
queue.push(async () => {
|
|
36
|
+
try {
|
|
37
|
+
const r = await fn();
|
|
38
|
+
resolve(r);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
reject(err);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
running--;
|
|
45
|
+
processQueue();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function setMaxConcurrency(n) {
|
|
51
|
+
MAX_CONCURRENCY = n;
|
|
52
|
+
}
|
|
53
|
+
/** Read-only snapshot of the limiter state for getConcurrencyState. */
|
|
54
|
+
export function getConcurrencySnapshot() {
|
|
55
|
+
return { running, queueLength: queue.length, maxConcurrency: MAX_CONCURRENCY };
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Pure response-normalisation helpers for the Actual adapter (#166 split out of
|
|
2
|
+
// actual-adapter.ts). No module state, no side effects: they only coerce the
|
|
3
|
+
// varied raw shapes the @actual-app/api returns (id string, id array, object,
|
|
4
|
+
// array of objects) into the canonical shapes the tools expect. Re-exported
|
|
5
|
+
// from actual-adapter.ts so the public surface and all importers are unchanged.
|
|
6
|
+
export function normalizeToTransactionArray(raw) {
|
|
7
|
+
if (!raw)
|
|
8
|
+
return [];
|
|
9
|
+
// If already an array of transactions
|
|
10
|
+
if (Array.isArray(raw) && raw.every(r => typeof r === 'object'))
|
|
11
|
+
return raw;
|
|
12
|
+
// If a single transaction object, wrap it
|
|
13
|
+
if (typeof raw === 'object' && raw !== null && 'id' in raw)
|
|
14
|
+
return [raw];
|
|
15
|
+
// If array of ids returned, convert to minimal Transaction objects
|
|
16
|
+
if (Array.isArray(raw) && raw.every(r => typeof r === 'string')) {
|
|
17
|
+
return raw.map(id => ({ id }));
|
|
18
|
+
}
|
|
19
|
+
// Fallback: try to coerce
|
|
20
|
+
return Array.isArray(raw) ? raw : [];
|
|
21
|
+
}
|
|
22
|
+
export function normalizeToId(raw) {
|
|
23
|
+
if (typeof raw === 'string')
|
|
24
|
+
return raw;
|
|
25
|
+
if (raw && typeof raw === 'object' && 'id' in raw) {
|
|
26
|
+
const idVal = raw['id'];
|
|
27
|
+
if (typeof idVal === 'string')
|
|
28
|
+
return idVal;
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string')
|
|
31
|
+
return raw[0];
|
|
32
|
+
return String(raw ?? '');
|
|
33
|
+
}
|
|
34
|
+
export function normalizeImportResult(raw) {
|
|
35
|
+
if (!raw || typeof raw !== 'object')
|
|
36
|
+
return { added: [], updated: [], errors: [] };
|
|
37
|
+
const r = raw;
|
|
38
|
+
return {
|
|
39
|
+
added: Array.isArray(r.added) ? r.added : [],
|
|
40
|
+
updated: Array.isArray(r.updated) ? r.updated : [],
|
|
41
|
+
errors: Array.isArray(r.errors) ? r.errors : [],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SQL-to-ActualQL WHERE translation for actual_query_run (#166 split out of
|
|
2
|
+
// actual-adapter.ts). Pure: it only transforms a query builder via .filter()
|
|
3
|
+
// calls and string parsing, with no module state or side effects. parseWhereClause
|
|
4
|
+
// is re-exported from actual-adapter.ts and is unit-tested directly. The #178
|
|
5
|
+
// operator support (LIKE / NOT LIKE / IS NULL, throw-on-unsupported) lives here.
|
|
6
|
+
// Strip a single pair of surrounding quotes from a SQL value literal.
|
|
7
|
+
function _stripWhereQuotes(s) {
|
|
8
|
+
return s.trim().replace(/^['"]|['"]$/g, '');
|
|
9
|
+
}
|
|
10
|
+
// Coerce a SQL value literal to a number when it looks numeric, else keep the
|
|
11
|
+
// (unquoted) string. Used for IN lists and comparison operands. Empty stays a
|
|
12
|
+
// string so an empty literal is not silently turned into 0.
|
|
13
|
+
function _coerceWhereValue(s) {
|
|
14
|
+
const v = _stripWhereQuotes(s);
|
|
15
|
+
if (v === '')
|
|
16
|
+
return v;
|
|
17
|
+
const n = Number(v);
|
|
18
|
+
return isNaN(n) ? v : n;
|
|
19
|
+
}
|
|
20
|
+
export function parseWhereClause(query, whereClause) {
|
|
21
|
+
// OR is not supported. Detect it up front and fail loudly. Without this guard
|
|
22
|
+
// a clause like `amount = 100 OR amount < 0` is left as a single fragment by
|
|
23
|
+
// the AND-splitter, and the comparison regex's greedy value capture swallows
|
|
24
|
+
// `100 OR amount < 0` into the operand, running a silently-wrong filter rather
|
|
25
|
+
// than erroring. That silent mishandling is exactly what #178 set out to stop.
|
|
26
|
+
// This shares the AND-splitter's quote-naive simplicity: an " OR " inside a
|
|
27
|
+
// quoted value is a known limitation, the same as " AND ".
|
|
28
|
+
if (/\sOR\s/i.test(whereClause)) {
|
|
29
|
+
throw new Error(`Unsupported WHERE condition: OR is not supported. ` +
|
|
30
|
+
`Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
|
|
31
|
+
`Combine conditions with AND only.`);
|
|
32
|
+
}
|
|
33
|
+
// Split by AND. This is a simple parser: it does not handle OR or nested /
|
|
34
|
+
// parenthesised conditions (see the unsupported-operator throw below).
|
|
35
|
+
const conditions = whereClause.split(/\s+AND\s+/i);
|
|
36
|
+
for (const condition of conditions) {
|
|
37
|
+
const trimmedCondition = condition.trim();
|
|
38
|
+
if (!trimmedCondition)
|
|
39
|
+
continue;
|
|
40
|
+
// IS NULL / IS NOT NULL: lets callers find unmerged rows (e.g. imported_payee
|
|
41
|
+
// IS NULL). ActualQL treats `field: null` as IS NULL and `$ne: null` as IS NOT NULL.
|
|
42
|
+
const nullMatch = trimmedCondition.match(/^([\w.]+)\s+IS\s+(NOT\s+)?NULL$/i);
|
|
43
|
+
if (nullMatch) {
|
|
44
|
+
const [, field, not] = nullMatch;
|
|
45
|
+
query = not
|
|
46
|
+
? query.filter({ [field]: { $ne: null } })
|
|
47
|
+
: query.filter({ [field]: null });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// NOT LIKE (checked before LIKE so the longer keyword wins).
|
|
51
|
+
const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
|
|
52
|
+
if (notLikeMatch) {
|
|
53
|
+
const [, field, valueStr] = notLikeMatch;
|
|
54
|
+
query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
|
|
58
|
+
// so it is case-insensitive and accent-insensitive. Use % as the wildcard,
|
|
59
|
+
// e.g. imported_payee LIKE '%amazon%'.
|
|
60
|
+
const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
|
|
61
|
+
if (likeMatch) {
|
|
62
|
+
const [, field, valueStr] = likeMatch;
|
|
63
|
+
query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// IN clause: field IN (value1, value2, ...)
|
|
67
|
+
// [\w.]+ matches both simple fields (amount) and joined fields (category.name)
|
|
68
|
+
const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
|
|
69
|
+
if (inMatch) {
|
|
70
|
+
const [, field, valuesStr] = inMatch;
|
|
71
|
+
const values = valuesStr.split(',').map(_coerceWhereValue);
|
|
72
|
+
query = query.filter({ [field]: { $oneof: values } });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// Comparison operators: field >= value, field = value, etc.
|
|
76
|
+
// [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
|
|
77
|
+
const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
|
|
78
|
+
if (compMatch) {
|
|
79
|
+
const [, field, operator, valueStr] = compMatch;
|
|
80
|
+
const operatorMap = {
|
|
81
|
+
'>=': '$gte',
|
|
82
|
+
'<=': '$lte',
|
|
83
|
+
'>': '$gt',
|
|
84
|
+
'<': '$lt',
|
|
85
|
+
'=': '$eq',
|
|
86
|
+
'!=': '$ne',
|
|
87
|
+
};
|
|
88
|
+
const actualOp = operatorMap[operator];
|
|
89
|
+
const finalValue = _coerceWhereValue(valueStr);
|
|
90
|
+
if (actualOp === '$eq') {
|
|
91
|
+
// Simple equality can use the direct field: value shorthand.
|
|
92
|
+
query = query.filter({ [field]: finalValue });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
query = query.filter({ [field]: { [actualOp]: finalValue } });
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Nothing matched. Refuse to silently drop the condition: dropping it would
|
|
100
|
+
// run the query UNFILTERED and hand back misleading "matches everything"
|
|
101
|
+
// results. Fail loudly with an actionable error instead. See #178.
|
|
102
|
+
throw new Error(`Unsupported WHERE condition: "${trimmedCondition}". ` +
|
|
103
|
+
`Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
|
|
104
|
+
`OR, REGEXP, NOT IN, and parenthesised groups are not yet supported.`);
|
|
105
|
+
}
|
|
106
|
+
return query;
|
|
107
|
+
}
|
|
@@ -11,7 +11,7 @@ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, import
|
|
|
11
11
|
} = api;
|
|
12
12
|
import { EventEmitter } from 'events';
|
|
13
13
|
import observability from '../observability.js';
|
|
14
|
-
import retry from './retry.js';
|
|
14
|
+
import retry, { isRetryableError } from './retry.js';
|
|
15
15
|
import logger from '../logger.js';
|
|
16
16
|
import config from '../config.js';
|
|
17
17
|
import { parseBudgetRegistry } from './budget-registry.js';
|
|
@@ -130,17 +130,13 @@ function _hasPooledConnection(sessionId) {
|
|
|
130
130
|
* actually corrupted but the error pattern doesn't match, the next call's op
|
|
131
131
|
* will surface the same root cause and we'll catch it then.
|
|
132
132
|
*/
|
|
133
|
+
// Whether an error is infrastructure-level (drop the pooled connection so the
|
|
134
|
+
// next call re-inits cleanly). This is the SAME class as "retryable", so it
|
|
135
|
+
// delegates to isRetryableError (#177): the pool-drop decision and the retry
|
|
136
|
+
// decision share one pattern list and cannot drift. A consistency test pins
|
|
137
|
+
// this equivalence.
|
|
133
138
|
function _shouldDropPoolOnError(err) {
|
|
134
|
-
|
|
135
|
-
return false;
|
|
136
|
-
const msg = err.message || '';
|
|
137
|
-
return (msg.includes('Authentication failed') ||
|
|
138
|
-
msg.includes('ECONNRESET') ||
|
|
139
|
-
msg.includes('ECONNREFUSED') ||
|
|
140
|
-
msg.includes('socket hang up') ||
|
|
141
|
-
msg.includes('ETIMEDOUT') ||
|
|
142
|
-
msg.includes('out of memory') ||
|
|
143
|
-
msg.includes('ENOMEM'));
|
|
139
|
+
return isRetryableError(err);
|
|
144
140
|
}
|
|
145
141
|
/**
|
|
146
142
|
* Enforce per-request budget ACL before any pool branching or lock acquisition.
|
|
@@ -377,84 +373,11 @@ export function _setSkipApiInitForTests(value) {
|
|
|
377
373
|
// The retry budget is bounded so a rate-limited init cannot indefinitely
|
|
378
374
|
// hold the API mutex (withApiLock) and starve other operations.
|
|
379
375
|
// ----------------------------------------------------------------------------
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
// would exhaust within 1.4s — well inside the upstream's window.
|
|
386
|
-
//
|
|
387
|
-
// Empirically (2026-05-06, #127):
|
|
388
|
-
// - 200ms base = 1.4s total: too short, every retry hits the throttle.
|
|
389
|
-
// - 2000ms base = 14s total: insufficient under heavy auth pressure
|
|
390
|
-
// (e.g. 10 rapid logins before a tool call still throttle 14s+).
|
|
391
|
-
// - 5000ms base = 5s + 10s + 10s = 25s total (each step capped by
|
|
392
|
-
// MAX_RETRY_DELAY_MS): clears the rate-limit window in light-pressure
|
|
393
|
-
// scenarios (3 rapid logins) without holding the API mutex unreasonably
|
|
394
|
-
// long.
|
|
395
|
-
//
|
|
396
|
-
// Beyond 25s, blocking the API mutex starts to harm tail latency for
|
|
397
|
-
// unrelated tool calls. The proper long-term fix for sustained-pressure
|
|
398
|
-
// scenarios is session reuse (avoid init+shutdown per op) — out of scope for
|
|
399
|
-
// this ticket; tracked as a follow-up.
|
|
400
|
-
const AUTH_RETRY_BASE_BACKOFF_MS = 5000;
|
|
401
|
-
export function isRetryableAuthError(err) {
|
|
402
|
-
if (!(err instanceof Error))
|
|
403
|
-
return false;
|
|
404
|
-
return (err.message.includes('Authentication failed: too-many-requests') ||
|
|
405
|
-
err.message.includes('Authentication failed: network-failure'));
|
|
406
|
-
}
|
|
407
|
-
/**
|
|
408
|
-
* Wrap an operation with retry-on-rate-limit. Used to wrap api.init() so
|
|
409
|
-
* transient too-many-requests errors are absorbed transparently. The retry
|
|
410
|
-
* budget is capped at DEFAULT_RETRY_ATTEMPTS (3) attempts and total wallclock
|
|
411
|
-
* is bounded by MAX_RETRY_DELAY_MS via exponential backoff.
|
|
412
|
-
*
|
|
413
|
-
* Test-friendly: opts.maxRetries / opts.baseBackoffMs override the defaults
|
|
414
|
-
* so unit tests can run fast.
|
|
415
|
-
*
|
|
416
|
-
* Log hygiene: this function never logs the upstream URL, password, or any
|
|
417
|
-
* config-derived value — only the error class and the Actual error code
|
|
418
|
-
* (extracted from the message) plus the attempt counter.
|
|
419
|
-
*/
|
|
420
|
-
export async function withAuthRetry(operation, opts) {
|
|
421
|
-
const maxRetries = opts?.maxRetries ?? DEFAULT_RETRY_ATTEMPTS;
|
|
422
|
-
const baseBackoffMs = opts?.baseBackoffMs ?? AUTH_RETRY_BASE_BACKOFF_MS;
|
|
423
|
-
let attempt = 0;
|
|
424
|
-
while (true) {
|
|
425
|
-
try {
|
|
426
|
-
return await operation();
|
|
427
|
-
}
|
|
428
|
-
catch (err) {
|
|
429
|
-
if (!isRetryableAuthError(err))
|
|
430
|
-
throw err;
|
|
431
|
-
attempt++;
|
|
432
|
-
if (attempt > maxRetries) {
|
|
433
|
-
// Budget exhausted: log + bump failure counter, but do NOT bump
|
|
434
|
-
// authRetryCount — that counter measures successful retry-and-sleep
|
|
435
|
-
// cycles, not failed final attempts.
|
|
436
|
-
authRetryFailureCount++;
|
|
437
|
-
const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
|
|
438
|
-
logger.error(`[ADAPTER] Auth retry exhausted after ${maxRetries} retries (last code: ${code})`);
|
|
439
|
-
throw err;
|
|
440
|
-
}
|
|
441
|
-
// We're going to retry — count it and sleep with exponential backoff.
|
|
442
|
-
authRetryCount++;
|
|
443
|
-
const delay = Math.min(baseBackoffMs * Math.pow(2, attempt - 1), MAX_RETRY_DELAY_MS);
|
|
444
|
-
const code = (err instanceof Error ? err.message.match(/Authentication failed: (\S+)/)?.[1] : null) || 'unknown';
|
|
445
|
-
logger.debug(`[ADAPTER] Auth retry ${attempt}/${maxRetries} (code: ${code}) after ${delay}ms`);
|
|
446
|
-
await new Promise(r => setTimeout(r, delay));
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
/**
|
|
451
|
-
* Test-only: reset the auth retry observability counters. NOT exported via
|
|
452
|
-
* the package public surface — only used by unit tests.
|
|
453
|
-
*/
|
|
454
|
-
export function _resetAuthRetryCountersForTests() {
|
|
455
|
-
authRetryCount = 0;
|
|
456
|
-
authRetryFailureCount = 0;
|
|
457
|
-
}
|
|
376
|
+
// Auth-rate-limit retry subsystem extracted to ./actual-adapter/auth-retry.ts
|
|
377
|
+
// (#166). Imported for internal use (wrapping api.init, getConcurrencyState)
|
|
378
|
+
// and re-exported so the public surface and importers are unchanged.
|
|
379
|
+
import { isRetryableAuthError, withAuthRetry, _resetAuthRetryCountersForTests, getAuthRetryCounts, } from './actual-adapter/auth-retry.js';
|
|
380
|
+
export { isRetryableAuthError, withAuthRetry, _resetAuthRetryCountersForTests };
|
|
458
381
|
/**
|
|
459
382
|
* Initialize Actual API - based on s-stefanov/actual-mcp pattern
|
|
460
383
|
* This calls api.init() and api.downloadBudget() for each operation
|
|
@@ -550,15 +473,12 @@ async function shutdownActualApi() {
|
|
|
550
473
|
setApiInitialized(false);
|
|
551
474
|
}
|
|
552
475
|
}
|
|
553
|
-
import { BANK_SYNC_SETTLE_MS,
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
let MAX_CONCURRENCY = parseInt(process.env.ACTUAL_API_CONCURRENCY || String(DEFAULT_CONCURRENCY_LIMIT), 10);
|
|
560
|
-
let running = 0;
|
|
561
|
-
const queue = [];
|
|
476
|
+
import { BANK_SYNC_SETTLE_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
|
|
477
|
+
// Concurrency limiter extracted to ./actual-adapter/concurrency.ts (#166).
|
|
478
|
+
// Imported for internal use (every method wraps its raw call in withConcurrency)
|
|
479
|
+
// and setMaxConcurrency is re-exported.
|
|
480
|
+
import { withConcurrency, setMaxConcurrency, getConcurrencySnapshot } from './actual-adapter/concurrency.js';
|
|
481
|
+
export { setMaxConcurrency };
|
|
562
482
|
let writeQueue = [];
|
|
563
483
|
let isProcessingWrites = false;
|
|
564
484
|
let writeSessionTimeout = null;
|
|
@@ -728,59 +648,16 @@ function queueWriteOperation(operation) {
|
|
|
728
648
|
export async function withWriteSession(fn) {
|
|
729
649
|
return queueWriteOperation(fn);
|
|
730
650
|
}
|
|
731
|
-
function processQueue() {
|
|
732
|
-
if (running >= MAX_CONCURRENCY)
|
|
733
|
-
return;
|
|
734
|
-
const next = queue.shift();
|
|
735
|
-
if (!next)
|
|
736
|
-
return;
|
|
737
|
-
running++;
|
|
738
|
-
try {
|
|
739
|
-
next();
|
|
740
|
-
}
|
|
741
|
-
catch (e) {
|
|
742
|
-
// next() will manage its own promise resolution
|
|
743
|
-
running--;
|
|
744
|
-
processQueue();
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
function withConcurrency(fn) {
|
|
748
|
-
if (running < MAX_CONCURRENCY) {
|
|
749
|
-
running++;
|
|
750
|
-
return fn().finally(() => {
|
|
751
|
-
running--;
|
|
752
|
-
processQueue();
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
return new Promise((resolve, reject) => {
|
|
756
|
-
queue.push(async () => {
|
|
757
|
-
try {
|
|
758
|
-
const r = await fn();
|
|
759
|
-
resolve(r);
|
|
760
|
-
}
|
|
761
|
-
catch (err) {
|
|
762
|
-
reject(err);
|
|
763
|
-
}
|
|
764
|
-
finally {
|
|
765
|
-
running--;
|
|
766
|
-
processQueue();
|
|
767
|
-
}
|
|
768
|
-
});
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
651
|
// Expose some helpers for testing concurrency
|
|
772
652
|
export function getConcurrencyState() {
|
|
773
653
|
return {
|
|
774
|
-
|
|
775
|
-
queueLength: queue.length,
|
|
776
|
-
maxConcurrency: MAX_CONCURRENCY,
|
|
654
|
+
...getConcurrencySnapshot(),
|
|
777
655
|
// Auth-retry observability — issue #127. authRetries is monotonic over the
|
|
778
656
|
// process lifetime; authRetryFailures only increments when retry budget
|
|
779
657
|
// exhausted. A jump in authRetries without a matching jump in
|
|
780
658
|
// authRetryFailures means the retry-with-backoff is absorbing rate-limit
|
|
781
659
|
// pressure (healthy). Both jumping = upstream genuinely overloaded.
|
|
782
|
-
|
|
783
|
-
authRetryFailures: authRetryFailureCount,
|
|
660
|
+
...getAuthRetryCounts(),
|
|
784
661
|
// Pool-cooperation observability — issue #134. connectionReuses increments
|
|
785
662
|
// every time withActualApi reused an existing per-session pool connection
|
|
786
663
|
// instead of running its own init+shutdown cycle. Pre-#134 this was
|
|
@@ -818,9 +695,6 @@ async function syncToServer() {
|
|
|
818
695
|
console.error('[SYNC] Sync to server failed:', err);
|
|
819
696
|
}
|
|
820
697
|
}
|
|
821
|
-
export function setMaxConcurrency(n) {
|
|
822
|
-
MAX_CONCURRENCY = n;
|
|
823
|
-
}
|
|
824
698
|
/**
|
|
825
699
|
* Wrap a raw function with the standard adapter retry + concurrency behavior.
|
|
826
700
|
* Useful for tests that want to exercise retry behavior without calling the real raw methods.
|
|
@@ -832,44 +706,10 @@ export function callWithRetry(fn, opts) {
|
|
|
832
706
|
}
|
|
833
707
|
export const notifications = new EventEmitter();
|
|
834
708
|
// --- Normalization helpers -------------------------------------------------
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
if (Array.isArray(raw) && raw.every(r => typeof r === 'object'))
|
|
840
|
-
return raw;
|
|
841
|
-
// If a single transaction object, wrap it
|
|
842
|
-
if (typeof raw === 'object' && raw !== null && 'id' in raw)
|
|
843
|
-
return [raw];
|
|
844
|
-
// If array of ids returned, convert to minimal Transaction objects
|
|
845
|
-
if (Array.isArray(raw) && raw.every(r => typeof r === 'string')) {
|
|
846
|
-
return raw.map(id => ({ id }));
|
|
847
|
-
}
|
|
848
|
-
// Fallback: try to coerce
|
|
849
|
-
return Array.isArray(raw) ? raw : [];
|
|
850
|
-
}
|
|
851
|
-
export function normalizeToId(raw) {
|
|
852
|
-
if (typeof raw === 'string')
|
|
853
|
-
return raw;
|
|
854
|
-
if (raw && typeof raw === 'object' && 'id' in raw) {
|
|
855
|
-
const idVal = raw['id'];
|
|
856
|
-
if (typeof idVal === 'string')
|
|
857
|
-
return idVal;
|
|
858
|
-
}
|
|
859
|
-
if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string')
|
|
860
|
-
return raw[0];
|
|
861
|
-
return String(raw ?? '');
|
|
862
|
-
}
|
|
863
|
-
export function normalizeImportResult(raw) {
|
|
864
|
-
if (!raw || typeof raw !== 'object')
|
|
865
|
-
return { added: [], updated: [], errors: [] };
|
|
866
|
-
const r = raw;
|
|
867
|
-
return {
|
|
868
|
-
added: Array.isArray(r.added) ? r.added : [],
|
|
869
|
-
updated: Array.isArray(r.updated) ? r.updated : [],
|
|
870
|
-
errors: Array.isArray(r.errors) ? r.errors : [],
|
|
871
|
-
};
|
|
872
|
-
}
|
|
709
|
+
// Extracted to ./actual-adapter/normalize.ts (#166). Imported for internal use
|
|
710
|
+
// and re-exported so the public surface and external importers are unchanged.
|
|
711
|
+
import { normalizeToTransactionArray, normalizeToId, normalizeImportResult } from './actual-adapter/normalize.js';
|
|
712
|
+
export { normalizeToTransactionArray, normalizeToId, normalizeImportResult };
|
|
873
713
|
// ---------------------------------------------------------------------------
|
|
874
714
|
export async function getAccounts() {
|
|
875
715
|
return withActualApi(async () => {
|
|
@@ -897,7 +737,7 @@ export async function addTransactions(txs, options = {}) {
|
|
|
897
737
|
return rest;
|
|
898
738
|
});
|
|
899
739
|
// API docs say it returns id[], but reality is it can return "ok", array of IDs, or Transaction objects
|
|
900
|
-
const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, options), { retries: 2, backoffMs: 200 }));
|
|
740
|
+
const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, options), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
901
741
|
// Handle various return formats
|
|
902
742
|
if (result === 'ok') {
|
|
903
743
|
// Transaction created successfully but no IDs returned
|
|
@@ -924,7 +764,7 @@ export async function addTransactions(txs, options = {}) {
|
|
|
924
764
|
export async function importTransactions(accountId, txs) {
|
|
925
765
|
observability.incrementToolCall('actual.transactions.import').catch(() => { });
|
|
926
766
|
return queueWriteOperation(async () => {
|
|
927
|
-
const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200 }));
|
|
767
|
+
const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
928
768
|
return raw || { added: [], updated: [], errors: [] };
|
|
929
769
|
});
|
|
930
770
|
}
|
|
@@ -957,7 +797,7 @@ export async function createTransfer(params) {
|
|
|
957
797
|
payee: transferPayee.id,
|
|
958
798
|
...(params.notes !== undefined && { notes: params.notes }),
|
|
959
799
|
};
|
|
960
|
-
await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200 }));
|
|
800
|
+
await withConcurrency(() => retry(() => rawAddTransactions(params.from_account, [sourceTx], { runTransfers: true }), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
961
801
|
return { success: true };
|
|
962
802
|
});
|
|
963
803
|
if (!writeResult.success)
|
|
@@ -998,7 +838,7 @@ export async function createCategory(category) {
|
|
|
998
838
|
observability.incrementToolCall('actual.categories.create').catch(() => { });
|
|
999
839
|
return queueWriteOperation(async () => {
|
|
1000
840
|
try {
|
|
1001
|
-
const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200 }));
|
|
841
|
+
const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1002
842
|
return normalizeToId(raw);
|
|
1003
843
|
}
|
|
1004
844
|
catch (error) {
|
|
@@ -1022,7 +862,7 @@ export async function getPayees() {
|
|
|
1022
862
|
export async function createPayee(payee) {
|
|
1023
863
|
observability.incrementToolCall('actual.payees.create').catch(() => { });
|
|
1024
864
|
return queueWriteOperation(async () => {
|
|
1025
|
-
const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200 }));
|
|
865
|
+
const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1026
866
|
return normalizeToId(raw);
|
|
1027
867
|
});
|
|
1028
868
|
}
|
|
@@ -1047,7 +887,7 @@ export async function setBudgetAmount(month, categoryId, amount) {
|
|
|
1047
887
|
if (!exists) {
|
|
1048
888
|
throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
|
|
1049
889
|
}
|
|
1050
|
-
const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200 }));
|
|
890
|
+
const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1051
891
|
return result;
|
|
1052
892
|
});
|
|
1053
893
|
}
|
|
@@ -1091,7 +931,7 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
|
|
|
1091
931
|
export async function createAccount(account, initialBalance) {
|
|
1092
932
|
observability.incrementToolCall('actual.accounts.create').catch(() => { });
|
|
1093
933
|
return queueWriteOperation(async () => {
|
|
1094
|
-
const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200 }));
|
|
934
|
+
const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1095
935
|
const id = normalizeToId(raw);
|
|
1096
936
|
// NO NEED for syncToServer() - shutdown() will handle persistence
|
|
1097
937
|
return id;
|
|
@@ -1100,7 +940,7 @@ export async function createAccount(account, initialBalance) {
|
|
|
1100
940
|
export async function updateAccount(id, fields) {
|
|
1101
941
|
observability.incrementToolCall('actual.accounts.update').catch(() => { });
|
|
1102
942
|
return queueWriteOperation(async () => {
|
|
1103
|
-
await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200 }));
|
|
943
|
+
await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1104
944
|
return null;
|
|
1105
945
|
});
|
|
1106
946
|
}
|
|
@@ -1143,7 +983,7 @@ export async function updateTransaction(id, fields) {
|
|
|
1143
983
|
observability.incrementToolCall('actual.transactions.update').catch(() => { });
|
|
1144
984
|
// Use write queue to batch concurrent updates in a single budget session
|
|
1145
985
|
return queueWriteOperation(async () => {
|
|
1146
|
-
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
|
|
986
|
+
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1147
987
|
});
|
|
1148
988
|
}
|
|
1149
989
|
export async function updateTransactionBatch(updates) {
|
|
@@ -1156,7 +996,7 @@ export async function updateTransactionBatch(updates) {
|
|
|
1156
996
|
const failed = [];
|
|
1157
997
|
for (const { id, fields } of updates) {
|
|
1158
998
|
try {
|
|
1159
|
-
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
|
|
999
|
+
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1160
1000
|
succeeded.push({ id });
|
|
1161
1001
|
}
|
|
1162
1002
|
catch (err) {
|
|
@@ -1179,7 +1019,7 @@ export async function deleteTransaction(id) {
|
|
|
1179
1019
|
export async function updateCategory(id, fields) {
|
|
1180
1020
|
observability.incrementToolCall('actual.categories.update').catch(() => { });
|
|
1181
1021
|
return queueWriteOperation(async () => {
|
|
1182
|
-
await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200 }));
|
|
1022
|
+
await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1183
1023
|
});
|
|
1184
1024
|
}
|
|
1185
1025
|
export async function deleteCategory(id) {
|
|
@@ -1210,7 +1050,7 @@ export async function updatePayee(id, fields) {
|
|
|
1210
1050
|
}
|
|
1211
1051
|
// Update the payee's direct fields (name, transfer_acct, etc.)
|
|
1212
1052
|
if (Object.keys(directFields).length > 0) {
|
|
1213
|
-
await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200 }));
|
|
1053
|
+
await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1214
1054
|
}
|
|
1215
1055
|
// Handle category via the rules mechanism (same approach Actual Budget uses internally)
|
|
1216
1056
|
if (categoryValue !== undefined) {
|
|
@@ -1230,7 +1070,7 @@ export async function updatePayee(id, fields) {
|
|
|
1230
1070
|
...setCategoryRule,
|
|
1231
1071
|
actions: setCategoryRule.actions.map((a) => a.op === 'set' && a.field === 'category' ? { ...a, value: categoryValue } : a),
|
|
1232
1072
|
};
|
|
1233
|
-
await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200 }));
|
|
1073
|
+
await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1234
1074
|
logger.debug(`[UPDATE PAYEE] Updated default category rule for payee ${id} to category ${categoryValue}`);
|
|
1235
1075
|
}
|
|
1236
1076
|
}
|
|
@@ -1242,7 +1082,7 @@ export async function updatePayee(id, fields) {
|
|
|
1242
1082
|
conditions: [{ op: 'is', field: 'payee', value: id }],
|
|
1243
1083
|
actions: [{ op: 'set', field: 'category', value: categoryValue }],
|
|
1244
1084
|
};
|
|
1245
|
-
await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200 }));
|
|
1085
|
+
await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1246
1086
|
logger.debug(`[UPDATE PAYEE] Created default category rule for payee ${id} with category ${categoryValue}`);
|
|
1247
1087
|
}
|
|
1248
1088
|
// category=null + no existing rule = no-op (already clear)
|
|
@@ -1271,7 +1111,7 @@ export async function getRules() {
|
|
|
1271
1111
|
export async function createRule(rule) {
|
|
1272
1112
|
observability.incrementToolCall('actual.rules.create').catch(() => { });
|
|
1273
1113
|
return queueWriteOperation(async () => {
|
|
1274
|
-
const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200 }));
|
|
1114
|
+
const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1275
1115
|
const id = normalizeToId(raw);
|
|
1276
1116
|
return id;
|
|
1277
1117
|
});
|
|
@@ -1295,7 +1135,7 @@ export async function updateRule(id, fields) {
|
|
|
1295
1135
|
actions: fieldsObj.actions ?? existingRule.actions ?? [],
|
|
1296
1136
|
};
|
|
1297
1137
|
logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
|
|
1298
|
-
await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200 }));
|
|
1138
|
+
await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1299
1139
|
logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
|
|
1300
1140
|
});
|
|
1301
1141
|
}
|
|
@@ -1317,7 +1157,7 @@ export async function createSchedule(schedule) {
|
|
|
1317
1157
|
return queueWriteOperation(async () => {
|
|
1318
1158
|
// Note: rawCreateSchedule(schedule) passes the external schedule object directly.
|
|
1319
1159
|
// Do NOT wrap in { schedule: ... } — that would double-nest and break date parsing.
|
|
1320
|
-
const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200 }));
|
|
1160
|
+
const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1321
1161
|
const id = normalizeToId(raw);
|
|
1322
1162
|
return id;
|
|
1323
1163
|
});
|
|
@@ -1325,7 +1165,7 @@ export async function createSchedule(schedule) {
|
|
|
1325
1165
|
export async function updateSchedule(id, fields, resetNextDate) {
|
|
1326
1166
|
observability.incrementToolCall('actual.schedules.update').catch(() => { });
|
|
1327
1167
|
return queueWriteOperation(async () => {
|
|
1328
|
-
await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200 }));
|
|
1168
|
+
await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1329
1169
|
});
|
|
1330
1170
|
}
|
|
1331
1171
|
export async function deleteSchedule(id) {
|
|
@@ -1337,7 +1177,7 @@ export async function deleteSchedule(id) {
|
|
|
1337
1177
|
export async function setBudgetCarryover(month, categoryId, flag) {
|
|
1338
1178
|
observability.incrementToolCall('actual.budgets.setCarryover').catch(() => { });
|
|
1339
1179
|
return queueWriteOperation(async () => {
|
|
1340
|
-
await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200 }));
|
|
1180
|
+
await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1341
1181
|
});
|
|
1342
1182
|
}
|
|
1343
1183
|
export async function closeAccount(id) {
|
|
@@ -1363,7 +1203,7 @@ export async function getCategoryGroups() {
|
|
|
1363
1203
|
export async function createCategoryGroup(group) {
|
|
1364
1204
|
observability.incrementToolCall('actual.category_groups.create').catch(() => { });
|
|
1365
1205
|
return queueWriteOperation(async () => {
|
|
1366
|
-
const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200 }));
|
|
1206
|
+
const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1367
1207
|
const id = normalizeToId(raw);
|
|
1368
1208
|
return id;
|
|
1369
1209
|
});
|
|
@@ -1371,7 +1211,7 @@ export async function createCategoryGroup(group) {
|
|
|
1371
1211
|
export async function updateCategoryGroup(id, fields) {
|
|
1372
1212
|
observability.incrementToolCall('actual.category_groups.update').catch(() => { });
|
|
1373
1213
|
return queueWriteOperation(async () => {
|
|
1374
|
-
await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200 }));
|
|
1214
|
+
await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1375
1215
|
});
|
|
1376
1216
|
}
|
|
1377
1217
|
export async function deleteCategoryGroup(id) {
|
|
@@ -1587,110 +1427,10 @@ export async function runQuery(queryString) {
|
|
|
1587
1427
|
throw new Error(`Query execution failed: ${errorMsg}`);
|
|
1588
1428
|
}
|
|
1589
1429
|
}
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
return s.trim().replace(/^['"]|['"]$/g, '');
|
|
1595
|
-
}
|
|
1596
|
-
// Coerce a SQL value literal to a number when it looks numeric, else keep the
|
|
1597
|
-
// (unquoted) string. Used for IN lists and comparison operands. Empty stays a
|
|
1598
|
-
// string so an empty literal is not silently turned into 0.
|
|
1599
|
-
function _coerceWhereValue(s) {
|
|
1600
|
-
const v = _stripWhereQuotes(s);
|
|
1601
|
-
if (v === '')
|
|
1602
|
-
return v;
|
|
1603
|
-
const n = Number(v);
|
|
1604
|
-
return isNaN(n) ? v : n;
|
|
1605
|
-
}
|
|
1606
|
-
export function parseWhereClause(query, whereClause) {
|
|
1607
|
-
// OR is not supported. Detect it up front and fail loudly. Without this guard
|
|
1608
|
-
// a clause like `amount = 100 OR amount < 0` is left as a single fragment by
|
|
1609
|
-
// the AND-splitter, and the comparison regex's greedy value capture swallows
|
|
1610
|
-
// `100 OR amount < 0` into the operand, running a silently-wrong filter rather
|
|
1611
|
-
// than erroring. That silent mishandling is exactly what #178 set out to stop.
|
|
1612
|
-
// This shares the AND-splitter's quote-naive simplicity: an " OR " inside a
|
|
1613
|
-
// quoted value is a known limitation, the same as " AND ".
|
|
1614
|
-
if (/\sOR\s/i.test(whereClause)) {
|
|
1615
|
-
throw new Error(`Unsupported WHERE condition: OR is not supported. ` +
|
|
1616
|
-
`Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
|
|
1617
|
-
`Combine conditions with AND only.`);
|
|
1618
|
-
}
|
|
1619
|
-
// Split by AND. This is a simple parser: it does not handle OR or nested /
|
|
1620
|
-
// parenthesised conditions (see the unsupported-operator throw below).
|
|
1621
|
-
const conditions = whereClause.split(/\s+AND\s+/i);
|
|
1622
|
-
for (const condition of conditions) {
|
|
1623
|
-
const trimmedCondition = condition.trim();
|
|
1624
|
-
if (!trimmedCondition)
|
|
1625
|
-
continue;
|
|
1626
|
-
// IS NULL / IS NOT NULL: lets callers find unmerged rows (e.g. imported_payee
|
|
1627
|
-
// IS NULL). ActualQL treats `field: null` as IS NULL and `$ne: null` as IS NOT NULL.
|
|
1628
|
-
const nullMatch = trimmedCondition.match(/^([\w.]+)\s+IS\s+(NOT\s+)?NULL$/i);
|
|
1629
|
-
if (nullMatch) {
|
|
1630
|
-
const [, field, not] = nullMatch;
|
|
1631
|
-
query = not
|
|
1632
|
-
? query.filter({ [field]: { $ne: null } })
|
|
1633
|
-
: query.filter({ [field]: null });
|
|
1634
|
-
continue;
|
|
1635
|
-
}
|
|
1636
|
-
// NOT LIKE (checked before LIKE so the longer keyword wins).
|
|
1637
|
-
const notLikeMatch = trimmedCondition.match(/^([\w.]+)\s+NOT\s+LIKE\s+(.+)$/i);
|
|
1638
|
-
if (notLikeMatch) {
|
|
1639
|
-
const [, field, valueStr] = notLikeMatch;
|
|
1640
|
-
query = query.filter({ [field]: { $notlike: _stripWhereQuotes(valueStr) } });
|
|
1641
|
-
continue;
|
|
1642
|
-
}
|
|
1643
|
-
// LIKE: pattern match. ActualQL's $like runs through NORMALISE + UNICODE_LIKE,
|
|
1644
|
-
// so it is case-insensitive and accent-insensitive. Use % as the wildcard,
|
|
1645
|
-
// e.g. imported_payee LIKE '%amazon%'.
|
|
1646
|
-
const likeMatch = trimmedCondition.match(/^([\w.]+)\s+LIKE\s+(.+)$/i);
|
|
1647
|
-
if (likeMatch) {
|
|
1648
|
-
const [, field, valueStr] = likeMatch;
|
|
1649
|
-
query = query.filter({ [field]: { $like: _stripWhereQuotes(valueStr) } });
|
|
1650
|
-
continue;
|
|
1651
|
-
}
|
|
1652
|
-
// IN clause: field IN (value1, value2, ...)
|
|
1653
|
-
// [\w.]+ matches both simple fields (amount) and joined fields (category.name)
|
|
1654
|
-
const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
|
|
1655
|
-
if (inMatch) {
|
|
1656
|
-
const [, field, valuesStr] = inMatch;
|
|
1657
|
-
const values = valuesStr.split(',').map(_coerceWhereValue);
|
|
1658
|
-
query = query.filter({ [field]: { $oneof: values } });
|
|
1659
|
-
continue;
|
|
1660
|
-
}
|
|
1661
|
-
// Comparison operators: field >= value, field = value, etc.
|
|
1662
|
-
// [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
|
|
1663
|
-
const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
|
|
1664
|
-
if (compMatch) {
|
|
1665
|
-
const [, field, operator, valueStr] = compMatch;
|
|
1666
|
-
const operatorMap = {
|
|
1667
|
-
'>=': '$gte',
|
|
1668
|
-
'<=': '$lte',
|
|
1669
|
-
'>': '$gt',
|
|
1670
|
-
'<': '$lt',
|
|
1671
|
-
'=': '$eq',
|
|
1672
|
-
'!=': '$ne',
|
|
1673
|
-
};
|
|
1674
|
-
const actualOp = operatorMap[operator];
|
|
1675
|
-
const finalValue = _coerceWhereValue(valueStr);
|
|
1676
|
-
if (actualOp === '$eq') {
|
|
1677
|
-
// Simple equality can use the direct field: value shorthand.
|
|
1678
|
-
query = query.filter({ [field]: finalValue });
|
|
1679
|
-
}
|
|
1680
|
-
else {
|
|
1681
|
-
query = query.filter({ [field]: { [actualOp]: finalValue } });
|
|
1682
|
-
}
|
|
1683
|
-
continue;
|
|
1684
|
-
}
|
|
1685
|
-
// Nothing matched. Refuse to silently drop the condition: dropping it would
|
|
1686
|
-
// run the query UNFILTERED and hand back misleading "matches everything"
|
|
1687
|
-
// results. Fail loudly with an actionable error instead. See #178.
|
|
1688
|
-
throw new Error(`Unsupported WHERE condition: "${trimmedCondition}". ` +
|
|
1689
|
-
`Supported operators: =, !=, >, >=, <, <=, IN (...), LIKE, NOT LIKE, IS NULL, IS NOT NULL. ` +
|
|
1690
|
-
`OR, REGEXP, NOT IN, and parenthesised groups are not yet supported.`);
|
|
1691
|
-
}
|
|
1692
|
-
return query;
|
|
1693
|
-
}
|
|
1430
|
+
// WHERE-clause translation extracted to ./actual-adapter/query.ts (#166).
|
|
1431
|
+
// Imported for internal use by runQuery and re-exported (unit-tested directly).
|
|
1432
|
+
import { parseWhereClause } from './actual-adapter/query.js';
|
|
1433
|
+
export { parseWhereClause };
|
|
1694
1434
|
export async function runBankSync(accountId) {
|
|
1695
1435
|
try {
|
|
1696
1436
|
return await withActualApi(async () => {
|
package/dist/src/lib/retry.js
CHANGED
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
import { DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_BACKOFF_MS, MAX_RETRY_DELAY_MS } from './constants.js';
|
|
2
2
|
import { ModuleLoggers } from './loggerFactory.js';
|
|
3
3
|
const log = ModuleLoggers.RETRY;
|
|
4
|
+
/**
|
|
5
|
+
* Error-message fragments that mark a TRANSIENT / infrastructure-level failure:
|
|
6
|
+
* the kind a retry can actually recover from, and the kind worth dropping a
|
|
7
|
+
* pooled connection over. Single source of truth for #177: the adapter's
|
|
8
|
+
* `_shouldDropPoolOnError` delegates to `isRetryableError`, so the retry
|
|
9
|
+
* decision and the pool-drop decision cannot drift apart.
|
|
10
|
+
*
|
|
11
|
+
* Anything NOT matching here (domain/validation errors such as "is required",
|
|
12
|
+
* "not found", "does not exist", Zod failures, and any unknown error) is
|
|
13
|
+
* terminal: it fails the same way on every attempt, so it must NOT be retried.
|
|
14
|
+
*/
|
|
15
|
+
export const TRANSIENT_ERROR_PATTERNS = [
|
|
16
|
+
'Authentication failed',
|
|
17
|
+
'ECONNRESET',
|
|
18
|
+
'ECONNREFUSED',
|
|
19
|
+
'socket hang up',
|
|
20
|
+
'ETIMEDOUT',
|
|
21
|
+
'out of memory',
|
|
22
|
+
'ENOMEM',
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* True only for known transient/infrastructure errors (#177). Non-Error and
|
|
26
|
+
* unknown rejections return false (fail fast), so a deterministic domain error
|
|
27
|
+
* is never retried.
|
|
28
|
+
*/
|
|
29
|
+
export function isRetryableError(err) {
|
|
30
|
+
if (!(err instanceof Error))
|
|
31
|
+
return false;
|
|
32
|
+
const msg = err.message || '';
|
|
33
|
+
return TRANSIENT_ERROR_PATTERNS.some(p => msg.includes(p));
|
|
34
|
+
}
|
|
4
35
|
export async function retry(fn, opts) {
|
|
5
36
|
const retries = opts?.retries ?? DEFAULT_RETRY_ATTEMPTS;
|
|
6
37
|
const backoffMs = opts?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
|
|
38
|
+
const isRetryable = opts?.isRetryable;
|
|
7
39
|
let attempt = 0;
|
|
8
40
|
while (true) {
|
|
9
41
|
try {
|
|
@@ -12,6 +44,14 @@ export async function retry(fn, opts) {
|
|
|
12
44
|
return result;
|
|
13
45
|
}
|
|
14
46
|
catch (err) {
|
|
47
|
+
// Fail fast on a non-retryable (domain/validation) error when a classifier
|
|
48
|
+
// is supplied: retrying cannot help and only wastes work plus log noise
|
|
49
|
+
// (#177). With no classifier, behaviour is unchanged (retry until the
|
|
50
|
+
// attempt budget is exhausted), preserving every existing call site.
|
|
51
|
+
if (isRetryable && !isRetryable(err)) {
|
|
52
|
+
log.debug('Not retrying non-transient error', { error: err?.message });
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
15
55
|
attempt++;
|
|
16
56
|
if (attempt > retries) {
|
|
17
57
|
log.error(`All retry attempts exhausted after ${retries} tries`, err);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.29",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|