actual-mcp-server 0.22.25 → 0.22.28

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 CHANGED
@@ -454,7 +454,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
454
454
 
455
455
  Four tools take many items in one call, for clients that allow only a few tool calls per turn. They share ONE contract: each item is applied independently and **not atomically**. There is no rollback, so items applied before a failure stay applied; a bad item is reported by its index and the rest continue; an infrastructure failure (lost connection, timeout) fails the whole call, and then you must read the data back before retrying anything. A bulk path never retries a create on its own.
456
456
 
457
- - `actual_transactions_update_batch` (counted under Transactions): update up to 50 transactions, `{id, fields}` each. Result: `{ succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }`.
457
+ - `actual_transactions_update_batch` (counted under Transactions): update up to 50 transactions, `{id, fields}` each. Result: `{ succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }`. Items marked "not attempted" were never sent. Re-read with `actual_transactions_get` before retrying any item.
458
458
  - `actual_transactions_import` (counted under Transactions): bulk create with import semantics (deduplication and reconciliation against existing transactions, rules optionally applied). Use it to add many transactions at once.
459
459
  - `actual_budget_updates_batch`: set budget amounts and/or carryover flags for 1 to 100 month/category pairs, and the way to rebalance categories: read the month with `actual_budgets_getMonth`, then send one batch of absolute amounts. Each operation needs at least one of `amount` (integer cents) or `carryover`. Result: `{ succeeded: [{index, month, categoryId}], failed: [{index, month, categoryId, error}], total, successCount, failureCount }`. An unknown category, an out-of-range month or a carryover on an income category fails only that item. After a timeout, read the months back with `actual_budgets_getMonth` before retrying.
460
460
  - `actual_rules_create_batch` (counted under Rules): create 1 to 50 rules, each exactly the input of `actual_rules_create`. Result: `{ succeeded: [{index, id}], failed: [{index, error}], total, successCount, failureCount }`. Items marked "not attempted" were never sent. Re-read with `actual_rules_get` before retrying any item.
@@ -835,7 +835,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
835
835
 
836
836
  ---
837
837
 
838
- **Version:** 0.22.25 | **Tool Count:** 83 (verified LibreChat-compatible)
838
+ **Version:** 0.22.28 | **Tool Count:** 83 (verified LibreChat-compatible)
839
839
 
840
840
  ## Sponsor
841
841
 
@@ -1946,9 +1946,11 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
1946
1946
  }
1947
1947
  catch (error) {
1948
1948
  const msg = error instanceof Error ? error.message : String(error);
1949
- // An infrastructure error says nothing about whether the write landed, so only a
1950
- // deterministic (domain) rejection may claim the target is unchanged.
1951
- const target = _shouldDropPoolOnError(error)
1949
+ // A transient error (infrastructure or a rate limit) says nothing about whether the write
1950
+ // landed, so only a deterministic (domain) rejection may claim the target is unchanged.
1951
+ // #544: this is isRetryableError, not _shouldDropPoolOnError: a rate limit keeps the pool
1952
+ // but its outcome is just as unknown. The pool-drop decision itself is untouched.
1953
+ const target = isRetryableError(error)
1952
1954
  ? `its outcome is unknown (it was ${prevTo})`
1953
1955
  : `it is unchanged at ${prevTo}`;
1954
1956
  // The original text stays INSIDE the message: isRetryableError classifies by message only
@@ -2030,6 +2032,9 @@ export async function updateTransaction(id, fields) {
2030
2032
  observability.incrementToolCall('actual.transactions.update').catch(() => { });
2031
2033
  // Use write queue to batch concurrent updates in a single budget session
2032
2034
  return queueWriteOperation(async () => {
2035
+ // #494: the drain's op timeout starts when this callback starts, so the split poll's cap
2036
+ // below is measured from here, not from the end of the write (same origin as #538's startedAt).
2037
+ const opStartedAt = Date.now();
2033
2038
  // Pre-flight existence check (#212): the raw API silently no-ops on a missing id,
2034
2039
  // so an update that changed nothing would otherwise be reported as success. A
2035
2040
  // targeted ActualQL query by id keeps this cheap (indexed lookup). #305 extends
@@ -2100,8 +2105,16 @@ export async function updateTransaction(id, fields) {
2100
2105
  // with every requested child. Polling here, inside the drain, also guarantees the change is
2101
2106
  // local before the drain's api.sync(). A retry after a timeout is safe: the row is then a
2102
2107
  // split, so a second call EDITS its children rather than creating anything new.
2108
+ // #494: the poll must end before the op timeout does, or the caller sees "timed out" (which
2109
+ // invites a retry) while this callback keeps reading in the background. Cap it at HALF the op
2110
+ // timeout, counted from op start; 0 disables the op timeout, so the poll keeps its own bound.
2111
+ // Do not cap at opStartedAt + boundMs: that term would also shorten the poll at the default.
2103
2112
  const expected = subs.length;
2104
- const deadline = Date.now() + SPLIT_VERIFY_TIMEOUT_MS;
2113
+ const T = config.ACTUAL_OP_TIMEOUT_MS;
2114
+ const deadline = T > 0
2115
+ ? Math.min(Date.now() + SPLIT_VERIFY_TIMEOUT_MS, opStartedAt + Math.floor(T / 2))
2116
+ : Date.now() + SPLIT_VERIFY_TIMEOUT_MS;
2117
+ const boundMs = T > 0 ? Math.min(SPLIT_VERIFY_TIMEOUT_MS, Math.floor(T / 2)) : SPLIT_VERIFY_TIMEOUT_MS;
2105
2118
  for (;;) {
2106
2119
  const res = (await rawRunQuery(q('transactions').options({ splits: 'all' })
2107
2120
  .filter({ $or: [{ id }, { parent_id: id }] })
@@ -2113,7 +2126,7 @@ export async function updateTransaction(id, fields) {
2113
2126
  return { split: { parent: id, children } };
2114
2127
  }
2115
2128
  if (Date.now() >= deadline) {
2116
- throw new Error(`Transaction "${id}" was sent to be split into ${expected} parts, but the split was not visible after ${SPLIT_VERIFY_TIMEOUT_MS}ms (found ${children.length} children), so the outcome is unknown. ` +
2129
+ throw new Error(`Transaction "${id}" was sent to be split into ${expected} parts, but the split was not visible after ${boundMs}ms (found ${children.length} children), so the outcome is unknown. ` +
2117
2130
  'Read it back with actual_transactions_get before retrying: if it is already a split, a retry edits its children rather than duplicating them.');
2118
2131
  }
2119
2132
  await new Promise((resolve) => setTimeout(resolve, SPLIT_VERIFY_INTERVAL_MS));
@@ -55,6 +55,13 @@ export const WRITE_SESSION_DELAY_MS = 100;
55
55
  * therefore reads the split back, polling every SPLIT_VERIFY_INTERVAL_MS, and refuses to report
56
56
  * success if it has not appeared within this bound. A local SQLite write, so milliseconds in
57
57
  * practice; the bound only decides how long a genuinely lost write takes to be reported.
58
+ *
59
+ * #494: the poll runs inside the drain under ACTUAL_OP_TIMEOUT_MS, which races the callback rather
60
+ * than cancelling it. So the effective bound is min(this, ACTUAL_OP_TIMEOUT_MS / 2), counted from
61
+ * the start of the operation; an op timeout of 0 (disabled) leaves this bound alone. The cap is
62
+ * cooperative: one read already in flight at the deadline can still outlive the op timeout if that
63
+ * single read takes longer than the remaining half. Separately, the pre-flight read's retry backoff
64
+ * (200ms then 400ms) can by itself exceed an op timeout under 600ms; that is outside the poll.
58
65
  */
59
66
  export const SPLIT_VERIFY_TIMEOUT_MS = 2000;
60
67
  export const SPLIT_VERIFY_INTERVAL_MS = 25;
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.22.25",
4
+ "version": "0.22.28",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"