actual-mcp-server 0.16.0 → 0.16.1

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
@@ -774,7 +774,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
774
774
 
775
775
  ---
776
776
 
777
- **Version:** 0.16.0 | **Tool Count:** 74 (verified LibreChat-compatible)
777
+ **Version:** 0.16.1 | **Tool Count:** 74 (verified LibreChat-compatible)
778
778
 
779
779
  ## Sponsor
780
780
 
@@ -537,6 +537,49 @@ let writeConnectionReuseCount = 0;
537
537
  // A fix that drained on every enqueue would close the deadlock and silently turn one
538
538
  // batch into N; nothing else in the suite would notice.
539
539
  let writeQueueBatchCount = 0;
540
+ let drainListingCache = null;
541
+ /**
542
+ * Read one entity listing, memoised for the life of the current drain.
543
+ *
544
+ * Outside a drain the cache is null and this is a straight pass-through, so non-queued
545
+ * callers (CLI scripts, startup health checks, the read path) are unaffected and can never
546
+ * see a cached value.
547
+ *
548
+ * The PROMISE is cached, not the resolved value. Two guards that start concurrently inside
549
+ * one drain therefore share a single in-flight listing rather than racing to start two.
550
+ */
551
+ async function readDrainListing(kind, fetch) {
552
+ if (!drainListingCache)
553
+ return await fetch();
554
+ const cached = drainListingCache.get(kind);
555
+ if (cached)
556
+ return (await cached);
557
+ const inFlight = fetch();
558
+ drainListingCache.set(kind, inFlight);
559
+ try {
560
+ return await inFlight;
561
+ }
562
+ catch (error) {
563
+ // A failed listing must not be cached: the next guard in this drain would inherit the
564
+ // failure and refuse a write for a reason that has nothing to do with its own target.
565
+ if (drainListingCache?.get(kind) === inFlight)
566
+ drainListingCache.delete(kind);
567
+ throw error;
568
+ }
569
+ }
570
+ /**
571
+ * Drop a cached listing after a write that could have changed it.
572
+ *
573
+ * Call this AFTER the write commits, never before: dropping early reopens the window this
574
+ * exists to close. Over-invalidating is safe and merely costs one listing; under-invalidating
575
+ * is a false not-found, so when in doubt, invalidate.
576
+ */
577
+ function invalidateDrainListing(...kinds) {
578
+ if (!drainListingCache)
579
+ return;
580
+ for (const kind of kinds)
581
+ drainListingCache.delete(kind);
582
+ }
540
583
  /**
541
584
  * #278: the ONLY place a write-queue drain is scheduled.
542
585
  *
@@ -586,10 +629,18 @@ async function processWriteQueue() {
586
629
  // the legacy branch, never that we attribute one session's writes to
587
630
  // another's pool entry.
588
631
  const batchSessionId = batch[0]?.sessionId;
632
+ // #378: every distinct session represented in this batch, computed once. See the touch call
633
+ // in the dispatch loop for why batchSessionId alone is not enough there.
634
+ const batchSessionIds = [...new Set(batch.map((b) => b.sessionId).filter((x) => !!x))];
589
635
  const usePoolBranch = !!batchSessionId && _hasPooledConnection(batchSessionId);
590
636
  logger.debug(`[WRITE QUEUE] Processing batch of ${batch.length} operations ` +
591
637
  `(sessionId=${batchSessionId ?? 'none'}, poolBranch=${usePoolBranch})`);
592
638
  try {
639
+ // #378 (L7): opened INSIDE the try whose `finally` nulls it. Created earlier it would
640
+ // survive a throw between creation and the try, which is benign today only because
641
+ // isProcessingWrites would wedge the queue first and make the stuck cache unreachable.
642
+ // Not worth depending on that accident.
643
+ drainListingCache = new Map();
593
644
  await withApiLock(async () => {
594
645
  try {
595
646
  if (usePoolBranch) {
@@ -609,11 +660,32 @@ async function processWriteQueue() {
609
660
  }
610
661
  // Process all queued writes in the same session
611
662
  // Each operation handles its own success/failure
612
- await Promise.allSettled(batch.map(async ({ operation, resolve, reject }) => {
663
+ // #378: SEQUENTIAL, in enqueue order. This was `Promise.allSettled(batch.map(...))`,
664
+ // which dispatched the whole batch concurrently inside the one api lock.
665
+ //
666
+ // The api lock makes a drain atomic against OTHER DRAINS. It never made an operation
667
+ // atomic against its SIBLINGS in the same batch, and every read-then-write guard
668
+ // moved into this adapter by #371 and #376 assumed it did. Reproduced on develop
669
+ // before this change: an `actual_payees_delete` and an `actual_payees_update` of the
670
+ // SAME payee, issued as parallel tool calls, land in one drain; the update's
671
+ // pre-read sees the payee, the guard passes, and rawUpdatePayee then runs against a
672
+ // payee the sibling already removed. Per #360 the CRDT apply path INSERTs when the
673
+ // row is absent, so that is the phantom partial row the guard exists to prevent. It
674
+ // reproduced at every timing tested, including a zero-delay delete.
675
+ //
676
+ // Caching cannot fix that: it is an ORDERING problem, not a staleness problem. A
677
+ // read that happens before the sibling's write is equally wrong whether it came from
678
+ // a cache or the database. Ordering the batch is what makes read-decide-write hold.
679
+ //
680
+ // The concurrency lost is worth very little. Every op in a batch targets the same
681
+ // in-process SQLite through one api singleton, so they contend rather than overlap,
682
+ // and the memoisation above removes the O(n) listing cost that dominated a bulk
683
+ // batch: 200 guarded payee updates now pay ONE listing instead of 200.
684
+ //
685
+ // Each op keeps its own withOpTimeout (#270) and still settles individually, so one
686
+ // stalled or rejected operation cannot hold the api mutex or fail its siblings.
687
+ for (const { operation, resolve, reject, preservesListings } of batch) {
613
688
  try {
614
- // Bound each queued write (#270): a stalled op must reject its own
615
- // promise (so withWriteSession callers don't hang) and let the
616
- // batch settle so the api mutex releases.
617
689
  const result = await withOpTimeout(operation);
618
690
  resolve(result);
619
691
  }
@@ -621,7 +693,47 @@ async function processWriteQueue() {
621
693
  logger.error('[WRITE QUEUE] Operation failed:', error);
622
694
  reject(error);
623
695
  }
624
- }));
696
+ finally {
697
+ // #378: drop everything this operation did not explicitly promise to leave alone,
698
+ // and do it whether the op resolved OR THREW. A failed op may still have written
699
+ // before failing, so a rejection is not evidence that nothing changed.
700
+ //
701
+ // The default is invalidate-all, so a write method added later without an
702
+ // annotation is merely slower, never wrong. Only the reverse mistake, claiming a
703
+ // listing is preserved when it is not, produces a false not-found, and that
704
+ // requires someone to write the claim down.
705
+ const preserved = new Set(preservesListings ?? []);
706
+ invalidateDrainListing(...['accounts', 'categories', 'categoryGroups', 'payees'].filter((k) => !preserved.has(k)));
707
+ // #378: keep every session in this batch alive for the length of the drain.
708
+ //
709
+ // `connectionPool.touch()` is driven by inbound HTTP requests, not by write ops,
710
+ // so `lastActivity` does not advance while a drain runs. That was harmless while
711
+ // the batch ran concurrently and a drain was bounded by roughly one
712
+ // ACTUAL_OP_TIMEOUT_MS. Sequential dispatch makes a drain's wall clock the SUM of
713
+ // its ops, so a batch of slow ops can now outlive SESSION_IDLE_TIMEOUT_MINUTES
714
+ // (default 5) and be swept mid-drain. The sweep's shutdownConnection calls
715
+ // api.shutdown() WITHOUT holding withApiLock, which is the same hazard this file
716
+ // already documents as the reason stdio is kept off the pool: it tears the
717
+ // singleton down underneath an in-flight operation.
718
+ //
719
+ // THIS MUST STAY INSIDE THE LOOP. A single touch at drain start does nothing for
720
+ // a drain whose wall clock is the SUM of its ops, which is the entire reason the
721
+ // hazard exists. Hoisting it out as a tidy-up reopens the bug in full, and case
722
+ // (8) of tests/unit/adapter_drain_listing_cache.test.js samples the pool clock
723
+ // from inside the raw call specifically so that hoist goes red.
724
+ //
725
+ // AND IT MUST COVER EVERY SESSION IN THE BATCH, not just batchSessionId. That is
726
+ // `batch[0]?.sessionId`, a heuristic that is sound for the pool-branch decision
727
+ // above (a wrong guess merely falls back to the legacy path) and NOT sound here.
728
+ // The write queue is process-global, so two HTTP sessions writing inside the same
729
+ // WRITE_SESSION_DELAY_MS window batch together. Sweeping the untouched one calls
730
+ // api.shutdown() on the PROCESS-GLOBAL singleton, which kills the other session's
731
+ // in-flight operation as well. `touch` is a Map lookup that no-ops on an unknown
732
+ // id and cannot throw, so covering the whole set costs nothing.
733
+ for (const sid of batchSessionIds)
734
+ connectionPool.touch(sid);
735
+ }
736
+ }
625
737
  // Explicitly sync changes to server before shutdown (legacy) or just
626
738
  // before returning (pool). Persistence guarantee in both branches.
627
739
  logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
@@ -684,6 +796,9 @@ async function processWriteQueue() {
684
796
  });
685
797
  }
686
798
  finally {
799
+ // #378: the cache dies with the drain, on every path including the fatal one. A drain
800
+ // that threw partway must not hand a partially-invalidated view to the next drain.
801
+ drainListingCache = null;
687
802
  isProcessingWrites = false;
688
803
  // #278: ALWAYS re-drain a non-empty queue. The old `&& writeSessionTimeout === null`
689
804
  // guard was the lost wakeup: a timer that fired mid-drain left a dead handle here,
@@ -694,7 +809,7 @@ async function processWriteQueue() {
694
809
  scheduleWriteQueueDrain();
695
810
  }
696
811
  }
697
- function queueWriteOperation(operation) {
812
+ function queueWriteOperation(operation, options) {
698
813
  // ACL enforcement at the write-queue entry (#156). Failing here means the
699
814
  // op is never enqueued and no upstream resource is touched.
700
815
  _enforceBudgetAcl();
@@ -703,13 +818,27 @@ function queueWriteOperation(operation) {
703
818
  // decision in processWriteQueue would always miss. See #158.
704
819
  const sessionId = _resolveSessionId();
705
820
  return new Promise((resolve, reject) => {
706
- const entry = { operation, resolve, reject, sessionId };
821
+ const entry = {
822
+ operation,
823
+ resolve,
824
+ reject,
825
+ sessionId,
826
+ preservesListings: options?.preservesListings,
827
+ };
707
828
  // #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
708
829
  // operation that is RUNNING; it cannot bound one that never starts. Reuse the same
709
830
  // knob so there is one timeout concept: ACTUAL_OP_TIMEOUT_MS, with <= 0 meaning
710
- // disabled (matching withOpTimeout). Worst case for a single call is therefore
711
- // residency + execution = 2 * ACTUAL_OP_TIMEOUT_MS; residency is normally under
712
- // WRITE_SESSION_DELAY_MS (100ms).
831
+ // disabled (matching withOpTimeout). Residency is normally under WRITE_SESSION_DELAY_MS
832
+ // (100ms).
833
+ //
834
+ // #378 CHANGED THIS BOUND and it used to read "worst case for a single call is therefore
835
+ // residency + execution = 2 * ACTUAL_OP_TIMEOUT_MS". That was true while the batch ran
836
+ // concurrently. It now runs sequentially, and residency timers are cleared for EVERY
837
+ // entry at dispatch while withOpTimeout bounds only the op currently running, so the
838
+ // honest worst case for the k-th op in a batch is residency + k * ACTUAL_OP_TIMEOUT_MS.
839
+ // A drain's wall clock went from the MAX of its ops to the SUM. In practice each op is
840
+ // milliseconds against in-process SQLite, but see the touch() call in processWriteQueue
841
+ // for the one consequence that had to be closed rather than merely documented.
713
842
  const residencyLimitMs = config.ACTUAL_OP_TIMEOUT_MS;
714
843
  if (Number.isFinite(residencyLimitMs) && residencyLimitMs > 0) {
715
844
  entry.residencyTimer = setTimeout(() => {
@@ -806,6 +935,15 @@ export async function getAccounts() {
806
935
  // addTransactions returns various formats: "ok", array of IDs, or Transaction objects
807
936
  export async function addTransactions(txs, options = {}) {
808
937
  observability.incrementToolCall('actual.transactions.create').catch(() => { });
938
+ // #378: a transaction write cannot change the account, category or category-group
939
+ // listings, so a bulk import pays ONE accounts listing for its guard instead of one per
940
+ // transaction. `payees` is deliberately NOT claimed: upstream's addTransactions runs
941
+ // normalizeTransactions, which returns `payeesToCreate` and creates a payee from a raw
942
+ // payee name (verified in loot-core/src/server/accounts/sync.ts via the source map). There
943
+ // is a SECOND route too, and it is not gated on the payee_name input at all: runRules fires
944
+ // unconditionally, and resolvePayeeNameForRules calls insertPayee when a rule action sets
945
+ // `payee: 'new'`. So a transaction write CAN change the payee listing by two independent
946
+ // paths, and claiming otherwise would produce a false not-found for a payee it just created.
809
947
  return queueWriteOperation(async () => {
810
948
  // The Actual API expects addTransactions(accountId, transactions, options)
811
949
  // Extract accountId from the first transaction and remove it from transaction objects
@@ -830,7 +968,7 @@ export async function addTransactions(txs, options = {}) {
830
968
  // deliberately still allowed: importing history into a closed account is legitimate,
831
969
  // which is why this diverges from createTransfer, where a closed account would be an
832
970
  // odd transfer destination.
833
- const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
971
+ const accounts = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
834
972
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
835
973
  const accountExists = accounts.some((a) => a?.id === accountId);
836
974
  if (!accountExists) {
@@ -865,7 +1003,7 @@ export async function addTransactions(txs, options = {}) {
865
1003
  return [result.id];
866
1004
  }
867
1005
  return [];
868
- });
1006
+ }, { preservesListings: ['accounts', 'categories', 'categoryGroups'] });
869
1007
  }
870
1008
  export async function importTransactions(accountId, txs) {
871
1009
  observability.incrementToolCall('actual.transactions.import').catch(() => { });
@@ -881,7 +1019,7 @@ export async function createTransfer(params) {
881
1019
  if (params.from_account === params.to_account) {
882
1020
  return { success: false, error: 'from_account and to_account must be different accounts.' };
883
1021
  }
884
- const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1022
+ const accounts = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
885
1023
  const fromAcc = accounts.find((a) => a.id === params.from_account);
886
1024
  const toAcc = accounts.find((a) => a.id === params.to_account);
887
1025
  if (!fromAcc)
@@ -892,7 +1030,7 @@ export async function createTransfer(params) {
892
1030
  return { success: false, error: `Account '${params.to_account}' not found. Use actual_accounts_list to find valid accounts.` };
893
1031
  if (toAcc.closed)
894
1032
  return { success: false, error: `Destination account '${toAcc.name}' is closed.` };
895
- const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1033
+ const payees = await withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 }));
896
1034
  const transferPayee = payees.find((p) => p.transfer_acct === params.to_account && !p.tombstone);
897
1035
  if (!transferPayee) {
898
1036
  return { success: false, error: `No transfer payee found for destination account '${toAcc.name}'. The account may not support transfers.` };
@@ -992,9 +1130,12 @@ export async function getBudgetMonth(month) {
992
1130
  }
993
1131
  export async function setBudgetAmount(month, categoryId, amount) {
994
1132
  observability.incrementToolCall('actual.budgets.setAmount').catch(() => { });
1133
+ // #378: a budget amount is written to the zero/reflect budget tables and cannot change any
1134
+ // of the four entity listings, so a month of category budgets pays ONE categories listing
1135
+ // for its guard rather than one per category.
995
1136
  return queueWriteOperation(async () => {
996
1137
  // Pre-flight: verify category exists — nil/unknown UUIDs silently no-op in Actual Budget
997
- const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1138
+ const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
998
1139
  const exists = categories.some((c) => c.id === categoryId);
999
1140
  if (!exists) {
1000
1141
  // #377: MUST be a typed refusal. `budgets_setAmount` is one of the two tools whose
@@ -1022,7 +1163,7 @@ export async function setBudgetAmount(month, categoryId, amount) {
1022
1163
  }
1023
1164
  const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1024
1165
  return result;
1025
- });
1166
+ }, { preservesListings: ['accounts', 'categories', 'categoryGroups', 'payees'] });
1026
1167
  }
1027
1168
  export async function transferBudgetAmount(month, fromCategoryId, toCategoryId, amount) {
1028
1169
  observability.incrementToolCall('actual.budgets.transfer').catch(() => { });
@@ -1073,12 +1214,15 @@ export async function createAccount(account, initialBalance) {
1073
1214
  export async function updateAccount(id, fields) {
1074
1215
  observability.incrementToolCall('actual.accounts.update').catch(() => { });
1075
1216
  return queueWriteOperation(async () => {
1217
+ // #378: this guard's pre-read is safe against a SIBLING operation in the same drain only
1218
+ // because the batch dispatches sequentially. It reads a listing memoised for this drain,
1219
+ // which the previous operation invalidated unless it declared it could not change it.
1076
1220
  // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1077
1221
  // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1078
1222
  // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1079
1223
  // A CLOSED account is still updatable: getAccounts filters `tombstone = 0`, not
1080
1224
  // `closed = 0`, so this refuses only ids that genuinely do not exist.
1081
- const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1225
+ const accounts = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
1082
1226
  if (!(Array.isArray(accounts) && accounts.some((a) => a?.id === id))) {
1083
1227
  throw new NotFoundRefusal('Account', id, 'actual_accounts_list');
1084
1228
  }
@@ -1243,12 +1387,15 @@ export async function deleteTransaction(id) {
1243
1387
  export async function updateCategory(id, fields) {
1244
1388
  observability.incrementToolCall('actual.categories.update').catch(() => { });
1245
1389
  return queueWriteOperation(async () => {
1390
+ // #378: this guard's pre-read is safe against a SIBLING operation in the same drain only
1391
+ // because the batch dispatches sequentially. It reads a listing memoised for this drain,
1392
+ // which the previous operation invalidated unless it declared it could not change it.
1246
1393
  // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1247
1394
  // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1248
1395
  // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1249
1396
  // Called with no argument, upstream returns every category in every group, hidden
1250
1397
  // included, so a hidden category is not misreported as missing.
1251
- const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1398
+ const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
1252
1399
  if (!(Array.isArray(categories) && categories.some((c) => c?.id === id))) {
1253
1400
  throw new NotFoundRefusal('Category', id, 'actual_categories_get');
1254
1401
  }
@@ -1259,7 +1406,7 @@ export async function deleteCategory(id) {
1259
1406
  observability.incrementToolCall('actual.categories.delete').catch(() => { });
1260
1407
  return queueWriteOperation(async () => {
1261
1408
  // Pre-flight: verify category exists to avoid ECONNRESET on missing id (BUG-1)
1262
- const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1409
+ const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
1263
1410
  const exists = categories.some((c) => c.id === id);
1264
1411
  if (!exists) {
1265
1412
  throw new NotFoundRefusal('Category', id, 'actual_categories_get');
@@ -1270,6 +1417,9 @@ export async function deleteCategory(id) {
1270
1417
  export async function updatePayee(id, fields) {
1271
1418
  observability.incrementToolCall('actual.payees.update').catch(() => { });
1272
1419
  return queueWriteOperation(async () => {
1420
+ // #378: this guard's pre-read is safe against a SIBLING operation in the same drain only
1421
+ // because the batch dispatches sequentially. It reads a listing memoised for this drain,
1422
+ // which the previous operation invalidated unless it declared it could not change it.
1273
1423
  // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1274
1424
  // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1275
1425
  // than matching nothing. Refuse first, the way updateTag and updateRule already do.
@@ -1280,7 +1430,7 @@ export async function updatePayee(id, fields) {
1280
1430
  // strictly unreachable. It is accepted because updating an orphaned transfer payee,
1281
1431
  // whose account no longer exists, is not a real workflow, and because the alternative
1282
1432
  // is leaving four tools able to create phantom rows.
1283
- const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1433
+ const payees = await withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 }));
1284
1434
  if (!(Array.isArray(payees) && payees.some((p) => p?.id === id))) {
1285
1435
  throw new NotFoundRefusal('Payee', id, 'actual_payees_get');
1286
1436
  }
@@ -1340,7 +1490,7 @@ export async function deletePayee(id) {
1340
1490
  observability.incrementToolCall('actual.payees.delete').catch(() => { });
1341
1491
  return queueWriteOperation(async () => {
1342
1492
  // Pre-flight: verify payee exists to avoid ECONNRESET on missing id (BUG-2)
1343
- const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1493
+ const payees = await withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 }));
1344
1494
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1345
1495
  const found = payees.find((p) => p.id === id);
1346
1496
  const exists = Boolean(found);
@@ -1601,7 +1751,7 @@ export async function setBudgetCarryover(month, categoryId, flag) {
1601
1751
  export async function closeAccount(id, transferAccountId, transferCategoryId) {
1602
1752
  observability.incrementToolCall('actual.accounts.close').catch(() => { });
1603
1753
  return queueWriteOperation(async () => {
1604
- const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1754
+ const before = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
1605
1755
  const list = Array.isArray(before) ? before : [];
1606
1756
  const target = list.find((a) => a?.id === id);
1607
1757
  if (!target) {
@@ -1630,7 +1780,7 @@ export async function closeAccount(id, transferAccountId, transferCategoryId) {
1630
1780
  // #359's lesson applied to the only WRITE this path can add. Upstream forwards this id
1631
1781
  // straight into `transaction-add` unchecked, so a bogus value writes a "Closing
1632
1782
  // account" transaction carrying a category that does not exist, and syncs it.
1633
- const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1783
+ const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
1634
1784
  const known = Array.isArray(categories) && categories.some((c) => c?.id === transferCategoryId);
1635
1785
  if (!known) {
1636
1786
  throw new Error(`Transfer category "${transferCategoryId}" not found. Use actual_categories_get to ` +
@@ -1668,7 +1818,7 @@ export async function closeAccount(id, transferAccountId, transferCategoryId) {
1668
1818
  export async function reopenAccount(id) {
1669
1819
  observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
1670
1820
  return queueWriteOperation(async () => {
1671
- const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1821
+ const before = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
1672
1822
  const target = (Array.isArray(before) ? before : []).find((a) => a?.id === id);
1673
1823
  if (!target) {
1674
1824
  throw new Error(`Account "${id}" not found, so it cannot be reopened. Use actual_accounts_list to see ` +
@@ -1716,13 +1866,16 @@ export async function createCategoryGroup(group) {
1716
1866
  export async function updateCategoryGroup(id, fields) {
1717
1867
  observability.incrementToolCall('actual.category_groups.update').catch(() => { });
1718
1868
  return queueWriteOperation(async () => {
1869
+ // #378: this guard's pre-read is safe against a SIBLING operation in the same drain only
1870
+ // because the batch dispatches sequentially. It reads a listing memoised for this drain,
1871
+ // which the previous operation invalidated unless it declared it could not change it.
1719
1872
  // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1720
1873
  // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1721
1874
  // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1722
1875
  // Called with no argument, upstream returns all groups including hidden ones, so a
1723
1876
  // hidden group is not misreported as missing (the same reason category_groups_delete
1724
1877
  // can rely on this listing).
1725
- const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1878
+ const groups = await withConcurrency(() => retry(() => readDrainListing('categoryGroups', () => rawGetCategoryGroups()), { retries: 2, backoffMs: 200 }));
1726
1879
  if (!(Array.isArray(groups) && groups.some((g) => g?.id === id))) {
1727
1880
  throw new NotFoundRefusal('Category group', id, 'actual_category_groups_get');
1728
1881
  }
@@ -1737,7 +1890,7 @@ export async function updateCategoryGroup(id, fields) {
1737
1890
  export async function deleteCategoryGroup(id) {
1738
1891
  observability.incrementToolCall('actual.category_groups.delete').catch(() => { });
1739
1892
  return queueWriteOperation(async () => {
1740
- const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1893
+ const groups = await withConcurrency(() => retry(() => readDrainListing('categoryGroups', () => rawGetCategoryGroups()), { retries: 2, backoffMs: 200 }));
1741
1894
  if (!Array.isArray(groups) || !groups.some((g) => g?.id === id)) {
1742
1895
  throw new NotFoundRefusal('Category group', id, 'actual_category_groups_get');
1743
1896
  }
@@ -1779,7 +1932,7 @@ function summariseIds(ids, limit = 5) {
1779
1932
  export async function mergePayees(targetId, mergeIds) {
1780
1933
  observability.incrementToolCall('actual.payees.merge').catch(() => { });
1781
1934
  return queueWriteOperation(async () => {
1782
- const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1935
+ const payees = await withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 }));
1783
1936
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1784
1937
  const byId = new Map(payees.map((p) => [p.id, p]));
1785
1938
  const target = byId.get(targetId);
@@ -2479,10 +2632,10 @@ export async function updateNote(id, note) {
2479
2632
  if (!BUDGET_MONTH_NOTE_RE.test(id)) {
2480
2633
  const has = (rows) => Array.isArray(rows) && rows.some((e) => e?.id === id);
2481
2634
  const [accounts, categories, categoryGroups, payees] = await Promise.all([
2482
- withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 })),
2483
- withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 })),
2484
- withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 })),
2485
- withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 })),
2635
+ withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 })),
2636
+ withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 })),
2637
+ withConcurrency(() => retry(() => readDrainListing('categoryGroups', () => rawGetCategoryGroups()), { retries: 2, backoffMs: 200 })),
2638
+ withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 })),
2486
2639
  ]);
2487
2640
  if (!has(accounts) && !has(categories) && !has(categoryGroups) && !has(payees)) {
2488
2641
  // Guarding matters here for the same reason as #360: upstream's note write is a
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.16.0",
4
+ "version": "0.16.1",
5
5
  "engines": {
6
6
  "node": ">=22.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/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.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_hold_for_next_month.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/httpServer_session_not_found.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.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/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.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_hold_for_next_month.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/httpServer_session_not_found.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.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",