actual-mcp-server 0.16.0 → 0.16.2

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.2 | **Tool Count:** 74 (verified LibreChat-compatible)
778
778
 
779
779
  ## Sponsor
780
780
 
@@ -1,4 +1,5 @@
1
1
  import { subtransactionsSum } from './schemas/common.js';
2
+ import { AsyncLocalStorage } from 'async_hooks';
2
3
  import api from '@actual-app/api';
3
4
  // @actual-app/api is a CJS package (no "type" field). In NodeNext/ESM context TypeScript
4
5
  // cannot expose its named exports via static import syntax. At runtime the default import
@@ -537,6 +538,98 @@ let writeConnectionReuseCount = 0;
537
538
  // A fix that drained on every enqueue would close the deadlock and silently turn one
538
539
  // batch into N; nothing else in the suite would notice.
539
540
  let writeQueueBatchCount = 0;
541
+ /**
542
+ * #378: the four entity listings, for a write that touches NONE of them.
543
+ *
544
+ * Only for operations that write to their OWN table and cannot mint or remove an account,
545
+ * category, category group or payee: notes, the budget-amount family, tags, rules and
546
+ * schedules. Each was checked against the installed @actual-app/api source map rather than
547
+ * assumed: `api/schedule-create`, `api/rule-create` and `api/tag-create` all delegate to their
548
+ * own handler with no payee path, and the budget writes land in reflect_budgets/zero_budgets.
549
+ *
550
+ * NOT for anything that mutates an entity, even indirectly. The traps that kept methods off
551
+ * this list: `deleteAccount` and `closeAccount` can remove an account's TRANSFER PAYEE;
552
+ * `updateAccount` renaming an account renames its transfer payee, so the payee listing's
553
+ * CONTENT changes even though its id set does not, and guards read fields as well as ids;
554
+ * `deleteCategoryGroup` takes its categories with it; and every transaction write can mint a
555
+ * payee by two routes (see addTransactions). Those all stay on the invalidate-everything
556
+ * default, which costs a listing and cannot be wrong.
557
+ */
558
+ const PRESERVES_ALL_ENTITY_LISTINGS = ['accounts', 'categories', 'categoryGroups', 'payees'];
559
+ /**
560
+ * The drain's cache lives in an AsyncLocalStorage, NOT in a module-level variable.
561
+ *
562
+ * This is structural on purpose. The first version of this used `let drainListingCache` at
563
+ * module scope, and review could show it was unreachable from outside a drain only by
564
+ * ARGUING it: every call site happens to sit inside a `queueWriteOperation` body, and
565
+ * `isProcessingWrites` happens to admit one drain at a time. Both are true and both are
566
+ * conventions that a future edit can break silently, and the failure they would produce is a
567
+ * cache shared across sessions and budgets, which in a multi-budget deployment means one
568
+ * user's guard reading another user's entity list. That is an isolation bug, not a staleness
569
+ * bug, so it should not rest on a convention.
570
+ *
571
+ * With the store, the cache exists only inside the drain's own `.run()`. Any other caller
572
+ * gets `undefined` and a straight pass-through, and there is nothing to reach even by
573
+ * mistake.
574
+ *
575
+ * BE PRECISE ABOUT WHAT "STRUCTURAL" BUYS, because the trade is real. A module-level binding
576
+ * was context-INDEPENDENT; an ALS is not. Every readDrainListing call site is
577
+ * `withConcurrency(() => retry(() => readDrainListing(...)))`, and `withConcurrency` queues a
578
+ * plain closure on a module-level array when saturated, invoking it from ANOTHER task's
579
+ * `.finally()`, so a queued task runs in the releasing task's async context. The store
580
+ * survives that today by arrangement rather than by construction: the drain holds the api lock
581
+ * for its whole body, so every limiter task inside it is already a drain-context task. If that
582
+ * ever stops being true the failure is SILENT and benign (a pass-through: correct but slow,
583
+ * never a wrong or foreign cache, since readDrainListing has no caller outside a drain op), so
584
+ * case (11) of the drain-cache test squeezes the limiter to 1 and counts the listings. `requestContext` is a separate store carrying the sessionId; this one deliberately
585
+ * does not ride along in it, because their lifetimes differ (a request spans many drains, a
586
+ * drain can span two sessions' operations).
587
+ */
588
+ const drainListingStore = new AsyncLocalStorage();
589
+ /**
590
+ * Read one entity listing, memoised for the life of the current drain.
591
+ *
592
+ * Outside a drain the cache is null and this is a straight pass-through, so non-queued
593
+ * callers (CLI scripts, startup health checks, the read path) are unaffected and can never
594
+ * see a cached value.
595
+ *
596
+ * The PROMISE is cached, not the resolved value. Two guards that start concurrently inside
597
+ * one drain therefore share a single in-flight listing rather than racing to start two.
598
+ */
599
+ async function readDrainListing(kind, fetch) {
600
+ const drainListingCache = drainListingStore.getStore();
601
+ if (!drainListingCache)
602
+ return await fetch();
603
+ const cached = drainListingCache.get(kind);
604
+ if (cached)
605
+ return (await cached);
606
+ const inFlight = fetch();
607
+ drainListingCache.set(kind, inFlight);
608
+ try {
609
+ return await inFlight;
610
+ }
611
+ catch (error) {
612
+ // A failed listing must not be cached: the next guard in this drain would inherit the
613
+ // failure and refuse a write for a reason that has nothing to do with its own target.
614
+ if (drainListingCache.get(kind) === inFlight)
615
+ drainListingCache.delete(kind);
616
+ throw error;
617
+ }
618
+ }
619
+ /**
620
+ * Drop a cached listing after a write that could have changed it.
621
+ *
622
+ * Call this AFTER the write commits, never before: dropping early reopens the window this
623
+ * exists to close. Over-invalidating is safe and merely costs one listing; under-invalidating
624
+ * is a false not-found, so when in doubt, invalidate.
625
+ */
626
+ function invalidateDrainListing(...kinds) {
627
+ const drainListingCache = drainListingStore.getStore();
628
+ if (!drainListingCache)
629
+ return;
630
+ for (const kind of kinds)
631
+ drainListingCache.delete(kind);
632
+ }
540
633
  /**
541
634
  * #278: the ONLY place a write-queue drain is scheduled.
542
635
  *
@@ -586,101 +679,171 @@ async function processWriteQueue() {
586
679
  // the legacy branch, never that we attribute one session's writes to
587
680
  // another's pool entry.
588
681
  const batchSessionId = batch[0]?.sessionId;
682
+ // #378: every distinct session represented in this batch, computed once. See the touch call
683
+ // in the dispatch loop for why batchSessionId alone is not enough there.
684
+ const batchSessionIds = [...new Set(batch.map((b) => b.sessionId).filter((x) => !!x))];
589
685
  const usePoolBranch = !!batchSessionId && _hasPooledConnection(batchSessionId);
590
686
  logger.debug(`[WRITE QUEUE] Processing batch of ${batch.length} operations ` +
591
687
  `(sessionId=${batchSessionId ?? 'none'}, poolBranch=${usePoolBranch})`);
592
688
  try {
593
- await withApiLock(async () => {
594
- try {
595
- if (usePoolBranch) {
596
- // Pool branch: api singleton already live for this session, no need
597
- // to init or shutdown around the batch. Sync at the end to commit
598
- // writes upstream. On infrastructure-level errors, release the pool
599
- // entry so the next call materialises a fresh connection.
600
- writeConnectionReuseCount++;
601
- logger.debug(`[WRITE QUEUE] Reusing pool connection for session ${batchSessionId} ` +
602
- `(writeReuses=${writeConnectionReuseCount})`);
603
- }
604
- else {
605
- // Legacy branch: no pool entry, init+shutdown around the batch as
606
- // before. initActualApiForOperation() still short-circuits if the
607
- // api is somehow already live (e.g. another path init'd it).
608
- await initActualApiForOperation();
609
- }
610
- // Process all queued writes in the same session
611
- // Each operation handles its own success/failure
612
- await Promise.allSettled(batch.map(async ({ operation, resolve, reject }) => {
613
- 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
- const result = await withOpTimeout(operation);
618
- resolve(result);
689
+ // #378: the drain's listing cache exists ONLY for the duration of this run. There is no
690
+ // assignment to clear and therefore no path, fatal or otherwise, that can leak it: when
691
+ // the callback returns the store is gone. This replaced a module-level variable plus a
692
+ // `finally` that nulled it, which worked but rested on that finally being reached.
693
+ await drainListingStore.run(new Map(), async () => {
694
+ await withApiLock(async () => {
695
+ try {
696
+ if (usePoolBranch) {
697
+ // Pool branch: api singleton already live for this session, no need
698
+ // to init or shutdown around the batch. Sync at the end to commit
699
+ // writes upstream. On infrastructure-level errors, release the pool
700
+ // entry so the next call materialises a fresh connection.
701
+ writeConnectionReuseCount++;
702
+ logger.debug(`[WRITE QUEUE] Reusing pool connection for session ${batchSessionId} ` +
703
+ `(writeReuses=${writeConnectionReuseCount})`);
619
704
  }
620
- catch (error) {
621
- logger.error('[WRITE QUEUE] Operation failed:', error);
622
- reject(error);
705
+ else {
706
+ // Legacy branch: no pool entry, init+shutdown around the batch as
707
+ // before. initActualApiForOperation() still short-circuits if the
708
+ // api is somehow already live (e.g. another path init'd it).
709
+ await initActualApiForOperation();
623
710
  }
624
- }));
625
- // Explicitly sync changes to server before shutdown (legacy) or just
626
- // before returning (pool). Persistence guarantee in both branches.
627
- logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
628
- try {
629
- await withOpTimeout(() => api.sync(), 'sync');
630
- logger.debug(`[WRITE QUEUE] Sync completed`);
631
- }
632
- catch (syncError) {
633
- logger.error('[WRITE QUEUE] Sync failed:', syncError);
634
- // Pool branch: drop the connection on infrastructure-level sync
635
- // failure so the next write re-initialises cleanly. Mirrors
636
- // withActualApiWrite's policy.
637
- if (usePoolBranch && _shouldDropPoolOnError(syncError)) {
638
- logger.warn(`[WRITE QUEUE] Releasing pool connection for session ${batchSessionId} after sync failure`);
711
+ // Process all queued writes in the same session
712
+ // Each operation handles its own success/failure
713
+ // #378: SEQUENTIAL, in enqueue order. This was `Promise.allSettled(batch.map(...))`,
714
+ // which dispatched the whole batch concurrently inside the one api lock.
715
+ //
716
+ // The api lock makes a drain atomic against OTHER DRAINS. It never made an operation
717
+ // atomic against its SIBLINGS in the same batch, and every read-then-write guard
718
+ // moved into this adapter by #371 and #376 assumed it did. Reproduced on develop
719
+ // before this change: an `actual_payees_delete` and an `actual_payees_update` of the
720
+ // SAME payee, issued as parallel tool calls, land in one drain; the update's
721
+ // pre-read sees the payee, the guard passes, and rawUpdatePayee then runs against a
722
+ // payee the sibling already removed. Per #360 the CRDT apply path INSERTs when the
723
+ // row is absent, so that is the phantom partial row the guard exists to prevent. It
724
+ // reproduced at every timing tested, including a zero-delay delete.
725
+ //
726
+ // Caching cannot fix that: it is an ORDERING problem, not a staleness problem. A
727
+ // read that happens before the sibling's write is equally wrong whether it came from
728
+ // a cache or the database. Ordering the batch is what makes read-decide-write hold.
729
+ //
730
+ // The concurrency lost is worth very little. Every op in a batch targets the same
731
+ // in-process SQLite through one api singleton, so they contend rather than overlap,
732
+ // and the memoisation above removes the O(n) listing cost that dominated a bulk
733
+ // batch: 200 guarded payee updates now pay ONE listing instead of 200.
734
+ //
735
+ // Each op keeps its own withOpTimeout (#270) and still settles individually, so one
736
+ // stalled or rejected operation cannot hold the api mutex or fail its siblings.
737
+ for (const { operation, resolve, reject, preservesListings } of batch) {
639
738
  try {
640
- await connectionPool.shutdownConnection(batchSessionId);
739
+ const result = await withOpTimeout(operation);
740
+ resolve(result);
641
741
  }
642
- catch (_e) {
643
- /* swallow */
742
+ catch (error) {
743
+ logger.error('[WRITE QUEUE] Operation failed:', error);
744
+ reject(error);
745
+ }
746
+ finally {
747
+ // #378: drop everything this operation did not explicitly promise to leave alone,
748
+ // and do it whether the op resolved OR THREW. A failed op may still have written
749
+ // before failing, so a rejection is not evidence that nothing changed.
750
+ //
751
+ // The default is invalidate-all, so a write method added later without an
752
+ // annotation is merely slower, never wrong. Only the reverse mistake, claiming a
753
+ // listing is preserved when it is not, produces a false not-found, and that
754
+ // requires someone to write the claim down.
755
+ const preserved = new Set(preservesListings ?? []);
756
+ invalidateDrainListing(...['accounts', 'categories', 'categoryGroups', 'payees'].filter((k) => !preserved.has(k)));
757
+ // #378: keep every session in this batch alive for the length of the drain.
758
+ //
759
+ // `connectionPool.touch()` is driven by inbound HTTP requests, not by write ops,
760
+ // so `lastActivity` does not advance while a drain runs. That was harmless while
761
+ // the batch ran concurrently and a drain was bounded by roughly one
762
+ // ACTUAL_OP_TIMEOUT_MS. Sequential dispatch makes a drain's wall clock the SUM of
763
+ // its ops, so a batch of slow ops can now outlive SESSION_IDLE_TIMEOUT_MINUTES
764
+ // (default 5) and be swept mid-drain. The sweep's shutdownConnection calls
765
+ // api.shutdown() WITHOUT holding withApiLock, which is the same hazard this file
766
+ // already documents as the reason stdio is kept off the pool: it tears the
767
+ // singleton down underneath an in-flight operation.
768
+ //
769
+ // THIS MUST STAY INSIDE THE LOOP. A single touch at drain start does nothing for
770
+ // a drain whose wall clock is the SUM of its ops, which is the entire reason the
771
+ // hazard exists. Hoisting it out as a tidy-up reopens the bug in full, and case
772
+ // (8) of tests/unit/adapter_drain_listing_cache.test.js samples the pool clock
773
+ // from inside the raw call specifically so that hoist goes red.
774
+ //
775
+ // AND IT MUST COVER EVERY SESSION IN THE BATCH, not just batchSessionId. That is
776
+ // `batch[0]?.sessionId`, a heuristic that is sound for the pool-branch decision
777
+ // above (a wrong guess merely falls back to the legacy path) and NOT sound here.
778
+ // The write queue is process-global, so two HTTP sessions writing inside the same
779
+ // WRITE_SESSION_DELAY_MS window batch together. Sweeping the untouched one calls
780
+ // api.shutdown() on the PROCESS-GLOBAL singleton, which kills the other session's
781
+ // in-flight operation as well. `touch` is a Map lookup that no-ops on an unknown
782
+ // id and cannot throw, so covering the whole set costs nothing.
783
+ for (const sid of batchSessionIds)
784
+ connectionPool.touch(sid);
644
785
  }
645
786
  }
646
- // Don't throw - we still want to shutdown cleanly
647
- // Individual operation errors were already reported to callers
648
- }
649
- if (!usePoolBranch) {
650
- // Legacy branch only: actually shut the singleton down.
651
- // shutdownActualApi() itself short-circuits to sync-only if another
652
- // path has active pool sessions, so this is safe under contention.
653
- await shutdownActualApi();
654
- }
655
- logger.debug(`[WRITE QUEUE] Batch completed successfully`);
656
- }
657
- catch (error) {
658
- logger.error('[WRITE QUEUE] Fatal error in write queue:', error);
659
- // Reject any operations that weren't processed
660
- batch.forEach(({ reject }) => {
787
+ // Explicitly sync changes to server before shutdown (legacy) or just
788
+ // before returning (pool). Persistence guarantee in both branches.
789
+ logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
661
790
  try {
662
- reject(error);
791
+ await withOpTimeout(() => api.sync(), 'sync');
792
+ logger.debug(`[WRITE QUEUE] Sync completed`);
793
+ }
794
+ catch (syncError) {
795
+ logger.error('[WRITE QUEUE] Sync failed:', syncError);
796
+ // Pool branch: drop the connection on infrastructure-level sync
797
+ // failure so the next write re-initialises cleanly. Mirrors
798
+ // withActualApiWrite's policy.
799
+ if (usePoolBranch && _shouldDropPoolOnError(syncError)) {
800
+ logger.warn(`[WRITE QUEUE] Releasing pool connection for session ${batchSessionId} after sync failure`);
801
+ try {
802
+ await connectionPool.shutdownConnection(batchSessionId);
803
+ }
804
+ catch (_e) {
805
+ /* swallow */
806
+ }
807
+ }
808
+ // Don't throw - we still want to shutdown cleanly
809
+ // Individual operation errors were already reported to callers
663
810
  }
664
- catch (e) {
665
- logger.error('[WRITE QUEUE] Error rejecting operation:', e);
811
+ if (!usePoolBranch) {
812
+ // Legacy branch only: actually shut the singleton down.
813
+ // shutdownActualApi() itself short-circuits to sync-only if another
814
+ // path has active pool sessions, so this is safe under contention.
815
+ await shutdownActualApi();
666
816
  }
667
- });
668
- // Pool branch: drop the pool entry if the error suggests infrastructure
669
- // corruption. Legacy branch: full shutdown.
670
- if (usePoolBranch) {
671
- if (_shouldDropPoolOnError(error)) {
817
+ logger.debug(`[WRITE QUEUE] Batch completed successfully`);
818
+ }
819
+ catch (error) {
820
+ logger.error('[WRITE QUEUE] Fatal error in write queue:', error);
821
+ // Reject any operations that weren't processed
822
+ batch.forEach(({ reject }) => {
672
823
  try {
673
- await connectionPool.shutdownConnection(batchSessionId);
824
+ reject(error);
674
825
  }
675
- catch (_e) {
676
- /* swallow */
826
+ catch (e) {
827
+ logger.error('[WRITE QUEUE] Error rejecting operation:', e);
828
+ }
829
+ });
830
+ // Pool branch: drop the pool entry if the error suggests infrastructure
831
+ // corruption. Legacy branch: full shutdown.
832
+ if (usePoolBranch) {
833
+ if (_shouldDropPoolOnError(error)) {
834
+ try {
835
+ await connectionPool.shutdownConnection(batchSessionId);
836
+ }
837
+ catch (_e) {
838
+ /* swallow */
839
+ }
677
840
  }
678
841
  }
842
+ else {
843
+ await shutdownActualApi();
844
+ }
679
845
  }
680
- else {
681
- await shutdownActualApi();
682
- }
683
- }
846
+ });
684
847
  });
685
848
  }
686
849
  finally {
@@ -694,7 +857,7 @@ async function processWriteQueue() {
694
857
  scheduleWriteQueueDrain();
695
858
  }
696
859
  }
697
- function queueWriteOperation(operation) {
860
+ function queueWriteOperation(operation, options) {
698
861
  // ACL enforcement at the write-queue entry (#156). Failing here means the
699
862
  // op is never enqueued and no upstream resource is touched.
700
863
  _enforceBudgetAcl();
@@ -703,13 +866,27 @@ function queueWriteOperation(operation) {
703
866
  // decision in processWriteQueue would always miss. See #158.
704
867
  const sessionId = _resolveSessionId();
705
868
  return new Promise((resolve, reject) => {
706
- const entry = { operation, resolve, reject, sessionId };
869
+ const entry = {
870
+ operation,
871
+ resolve,
872
+ reject,
873
+ sessionId,
874
+ preservesListings: options?.preservesListings,
875
+ };
707
876
  // #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
708
877
  // operation that is RUNNING; it cannot bound one that never starts. Reuse the same
709
878
  // 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).
879
+ // disabled (matching withOpTimeout). Residency is normally under WRITE_SESSION_DELAY_MS
880
+ // (100ms).
881
+ //
882
+ // #378 CHANGED THIS BOUND and it used to read "worst case for a single call is therefore
883
+ // residency + execution = 2 * ACTUAL_OP_TIMEOUT_MS". That was true while the batch ran
884
+ // concurrently. It now runs sequentially, and residency timers are cleared for EVERY
885
+ // entry at dispatch while withOpTimeout bounds only the op currently running, so the
886
+ // honest worst case for the k-th op in a batch is residency + k * ACTUAL_OP_TIMEOUT_MS.
887
+ // A drain's wall clock went from the MAX of its ops to the SUM. In practice each op is
888
+ // milliseconds against in-process SQLite, but see the touch() call in processWriteQueue
889
+ // for the one consequence that had to be closed rather than merely documented.
713
890
  const residencyLimitMs = config.ACTUAL_OP_TIMEOUT_MS;
714
891
  if (Number.isFinite(residencyLimitMs) && residencyLimitMs > 0) {
715
892
  entry.residencyTimer = setTimeout(() => {
@@ -806,6 +983,15 @@ export async function getAccounts() {
806
983
  // addTransactions returns various formats: "ok", array of IDs, or Transaction objects
807
984
  export async function addTransactions(txs, options = {}) {
808
985
  observability.incrementToolCall('actual.transactions.create').catch(() => { });
986
+ // #378: a transaction write cannot change the account, category or category-group
987
+ // listings, so a bulk import pays ONE accounts listing for its guard instead of one per
988
+ // transaction. `payees` is deliberately NOT claimed: upstream's addTransactions runs
989
+ // normalizeTransactions, which returns `payeesToCreate` and creates a payee from a raw
990
+ // payee name (verified in loot-core/src/server/accounts/sync.ts via the source map). There
991
+ // is a SECOND route too, and it is not gated on the payee_name input at all: runRules fires
992
+ // unconditionally, and resolvePayeeNameForRules calls insertPayee when a rule action sets
993
+ // `payee: 'new'`. So a transaction write CAN change the payee listing by two independent
994
+ // paths, and claiming otherwise would produce a false not-found for a payee it just created.
809
995
  return queueWriteOperation(async () => {
810
996
  // The Actual API expects addTransactions(accountId, transactions, options)
811
997
  // Extract accountId from the first transaction and remove it from transaction objects
@@ -830,7 +1016,7 @@ export async function addTransactions(txs, options = {}) {
830
1016
  // deliberately still allowed: importing history into a closed account is legitimate,
831
1017
  // which is why this diverges from createTransfer, where a closed account would be an
832
1018
  // odd transfer destination.
833
- const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1019
+ const accounts = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
834
1020
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
835
1021
  const accountExists = accounts.some((a) => a?.id === accountId);
836
1022
  if (!accountExists) {
@@ -865,7 +1051,7 @@ export async function addTransactions(txs, options = {}) {
865
1051
  return [result.id];
866
1052
  }
867
1053
  return [];
868
- });
1054
+ }, { preservesListings: ['accounts', 'categories', 'categoryGroups'] });
869
1055
  }
870
1056
  export async function importTransactions(accountId, txs) {
871
1057
  observability.incrementToolCall('actual.transactions.import').catch(() => { });
@@ -881,7 +1067,7 @@ export async function createTransfer(params) {
881
1067
  if (params.from_account === params.to_account) {
882
1068
  return { success: false, error: 'from_account and to_account must be different accounts.' };
883
1069
  }
884
- const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1070
+ const accounts = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
885
1071
  const fromAcc = accounts.find((a) => a.id === params.from_account);
886
1072
  const toAcc = accounts.find((a) => a.id === params.to_account);
887
1073
  if (!fromAcc)
@@ -892,7 +1078,7 @@ export async function createTransfer(params) {
892
1078
  return { success: false, error: `Account '${params.to_account}' not found. Use actual_accounts_list to find valid accounts.` };
893
1079
  if (toAcc.closed)
894
1080
  return { success: false, error: `Destination account '${toAcc.name}' is closed.` };
895
- const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1081
+ const payees = await withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 }));
896
1082
  const transferPayee = payees.find((p) => p.transfer_acct === params.to_account && !p.tombstone);
897
1083
  if (!transferPayee) {
898
1084
  return { success: false, error: `No transfer payee found for destination account '${toAcc.name}'. The account may not support transfers.` };
@@ -992,9 +1178,12 @@ export async function getBudgetMonth(month) {
992
1178
  }
993
1179
  export async function setBudgetAmount(month, categoryId, amount) {
994
1180
  observability.incrementToolCall('actual.budgets.setAmount').catch(() => { });
1181
+ // #378: a budget amount is written to the zero/reflect budget tables and cannot change any
1182
+ // of the four entity listings, so a month of category budgets pays ONE categories listing
1183
+ // for its guard rather than one per category.
995
1184
  return queueWriteOperation(async () => {
996
1185
  // 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 }));
1186
+ const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
998
1187
  const exists = categories.some((c) => c.id === categoryId);
999
1188
  if (!exists) {
1000
1189
  // #377: MUST be a typed refusal. `budgets_setAmount` is one of the two tools whose
@@ -1022,7 +1211,7 @@ export async function setBudgetAmount(month, categoryId, amount) {
1022
1211
  }
1023
1212
  const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1024
1213
  return result;
1025
- });
1214
+ }, { preservesListings: ['accounts', 'categories', 'categoryGroups', 'payees'] });
1026
1215
  }
1027
1216
  export async function transferBudgetAmount(month, fromCategoryId, toCategoryId, amount) {
1028
1217
  observability.incrementToolCall('actual.budgets.transfer').catch(() => { });
@@ -1059,7 +1248,7 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
1059
1248
  fromCategory: { id: fromCategoryId, previousAmount: prevFrom, newAmount: prevFrom - amount },
1060
1249
  toCategory: { id: toCategoryId, previousAmount: prevTo, newAmount: prevTo + amount },
1061
1250
  };
1062
- });
1251
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1063
1252
  }
1064
1253
  export async function createAccount(account, initialBalance) {
1065
1254
  observability.incrementToolCall('actual.accounts.create').catch(() => { });
@@ -1073,12 +1262,15 @@ export async function createAccount(account, initialBalance) {
1073
1262
  export async function updateAccount(id, fields) {
1074
1263
  observability.incrementToolCall('actual.accounts.update').catch(() => { });
1075
1264
  return queueWriteOperation(async () => {
1265
+ // #378: this guard's pre-read is safe against a SIBLING operation in the same drain only
1266
+ // because the batch dispatches sequentially. It reads a listing memoised for this drain,
1267
+ // which the previous operation invalidated unless it declared it could not change it.
1076
1268
  // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1077
1269
  // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1078
1270
  // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1079
1271
  // A CLOSED account is still updatable: getAccounts filters `tombstone = 0`, not
1080
1272
  // `closed = 0`, so this refuses only ids that genuinely do not exist.
1081
- const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1273
+ const accounts = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
1082
1274
  if (!(Array.isArray(accounts) && accounts.some((a) => a?.id === id))) {
1083
1275
  throw new NotFoundRefusal('Account', id, 'actual_accounts_list');
1084
1276
  }
@@ -1243,12 +1435,15 @@ export async function deleteTransaction(id) {
1243
1435
  export async function updateCategory(id, fields) {
1244
1436
  observability.incrementToolCall('actual.categories.update').catch(() => { });
1245
1437
  return queueWriteOperation(async () => {
1438
+ // #378: this guard's pre-read is safe against a SIBLING operation in the same drain only
1439
+ // because the batch dispatches sequentially. It reads a listing memoised for this drain,
1440
+ // which the previous operation invalidated unless it declared it could not change it.
1246
1441
  // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1247
1442
  // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1248
1443
  // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1249
1444
  // Called with no argument, upstream returns every category in every group, hidden
1250
1445
  // included, so a hidden category is not misreported as missing.
1251
- const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1446
+ const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
1252
1447
  if (!(Array.isArray(categories) && categories.some((c) => c?.id === id))) {
1253
1448
  throw new NotFoundRefusal('Category', id, 'actual_categories_get');
1254
1449
  }
@@ -1259,7 +1454,7 @@ export async function deleteCategory(id) {
1259
1454
  observability.incrementToolCall('actual.categories.delete').catch(() => { });
1260
1455
  return queueWriteOperation(async () => {
1261
1456
  // Pre-flight: verify category exists to avoid ECONNRESET on missing id (BUG-1)
1262
- const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1457
+ const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
1263
1458
  const exists = categories.some((c) => c.id === id);
1264
1459
  if (!exists) {
1265
1460
  throw new NotFoundRefusal('Category', id, 'actual_categories_get');
@@ -1270,6 +1465,9 @@ export async function deleteCategory(id) {
1270
1465
  export async function updatePayee(id, fields) {
1271
1466
  observability.incrementToolCall('actual.payees.update').catch(() => { });
1272
1467
  return queueWriteOperation(async () => {
1468
+ // #378: this guard's pre-read is safe against a SIBLING operation in the same drain only
1469
+ // because the batch dispatches sequentially. It reads a listing memoised for this drain,
1470
+ // which the previous operation invalidated unless it declared it could not change it.
1273
1471
  // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1274
1472
  // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1275
1473
  // than matching nothing. Refuse first, the way updateTag and updateRule already do.
@@ -1280,7 +1478,7 @@ export async function updatePayee(id, fields) {
1280
1478
  // strictly unreachable. It is accepted because updating an orphaned transfer payee,
1281
1479
  // whose account no longer exists, is not a real workflow, and because the alternative
1282
1480
  // is leaving four tools able to create phantom rows.
1283
- const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1481
+ const payees = await withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 }));
1284
1482
  if (!(Array.isArray(payees) && payees.some((p) => p?.id === id))) {
1285
1483
  throw new NotFoundRefusal('Payee', id, 'actual_payees_get');
1286
1484
  }
@@ -1340,7 +1538,7 @@ export async function deletePayee(id) {
1340
1538
  observability.incrementToolCall('actual.payees.delete').catch(() => { });
1341
1539
  return queueWriteOperation(async () => {
1342
1540
  // Pre-flight: verify payee exists to avoid ECONNRESET on missing id (BUG-2)
1343
- const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1541
+ const payees = await withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 }));
1344
1542
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1345
1543
  const found = payees.find((p) => p.id === id);
1346
1544
  const exists = Boolean(found);
@@ -1377,7 +1575,7 @@ export async function createRule(rule) {
1377
1575
  const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1378
1576
  const id = normalizeToId(raw);
1379
1577
  return id;
1380
- });
1578
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1381
1579
  }
1382
1580
  export async function updateRule(id, fields) {
1383
1581
  observability.incrementToolCall('actual.rules.update').catch(() => { });
@@ -1413,7 +1611,7 @@ export async function updateRule(id, fields) {
1413
1611
  logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
1414
1612
  await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1415
1613
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
1416
- });
1614
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1417
1615
  }
1418
1616
  /**
1419
1617
  * #355: RETURNS the upstream verdict instead of discarding it, for the same reason
@@ -1474,7 +1672,7 @@ export async function upsertRule(input, stageWasSupplied) {
1474
1672
  ruleData.stage = null;
1475
1673
  const rawId = await withConcurrency(() => retry(() => rawCreateRule(ruleData), { retries: 0, backoffMs: 200 }));
1476
1674
  return { id: normalizeToId(rawId), created: true };
1477
- });
1675
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1478
1676
  }
1479
1677
  /**
1480
1678
  * #376: the existence guard and the schedule-owned refusal moved here from
@@ -1519,7 +1717,7 @@ export async function deleteRule(id) {
1519
1717
  'instead with actual_schedules_delete (find it with actual_schedules_get), ' +
1520
1718
  'which removes this rule too.');
1521
1719
  }
1522
- });
1720
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1523
1721
  }
1524
1722
  export async function getSchedules() {
1525
1723
  return withActualApi(async () => {
@@ -1536,13 +1734,13 @@ export async function createSchedule(schedule) {
1536
1734
  const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1537
1735
  const id = normalizeToId(raw);
1538
1736
  return id;
1539
- });
1737
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1540
1738
  }
1541
1739
  export async function updateSchedule(id, fields, resetNextDate) {
1542
1740
  observability.incrementToolCall('actual.schedules.update').catch(() => { });
1543
1741
  return queueWriteOperation(async () => {
1544
1742
  await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1545
- });
1743
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1546
1744
  }
1547
1745
  /**
1548
1746
  * #376: the existence guard and the constraint-error translation moved here from
@@ -1566,13 +1764,13 @@ export async function deleteSchedule(id) {
1566
1764
  }
1567
1765
  throw err;
1568
1766
  }
1569
- });
1767
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1570
1768
  }
1571
1769
  export async function setBudgetCarryover(month, categoryId, flag) {
1572
1770
  observability.incrementToolCall('actual.budgets.setCarryover').catch(() => { });
1573
1771
  return queueWriteOperation(async () => {
1574
1772
  await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1575
- });
1773
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1576
1774
  }
1577
1775
  /**
1578
1776
  * #357, at the adapter layer since #371.
@@ -1601,7 +1799,7 @@ export async function setBudgetCarryover(month, categoryId, flag) {
1601
1799
  export async function closeAccount(id, transferAccountId, transferCategoryId) {
1602
1800
  observability.incrementToolCall('actual.accounts.close').catch(() => { });
1603
1801
  return queueWriteOperation(async () => {
1604
- const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1802
+ const before = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
1605
1803
  const list = Array.isArray(before) ? before : [];
1606
1804
  const target = list.find((a) => a?.id === id);
1607
1805
  if (!target) {
@@ -1630,7 +1828,7 @@ export async function closeAccount(id, transferAccountId, transferCategoryId) {
1630
1828
  // #359's lesson applied to the only WRITE this path can add. Upstream forwards this id
1631
1829
  // straight into `transaction-add` unchecked, so a bogus value writes a "Closing
1632
1830
  // account" transaction carrying a category that does not exist, and syncs it.
1633
- const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1831
+ const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
1634
1832
  const known = Array.isArray(categories) && categories.some((c) => c?.id === transferCategoryId);
1635
1833
  if (!known) {
1636
1834
  throw new Error(`Transfer category "${transferCategoryId}" not found. Use actual_categories_get to ` +
@@ -1668,7 +1866,7 @@ export async function closeAccount(id, transferAccountId, transferCategoryId) {
1668
1866
  export async function reopenAccount(id) {
1669
1867
  observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
1670
1868
  return queueWriteOperation(async () => {
1671
- const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1869
+ const before = await withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 }));
1672
1870
  const target = (Array.isArray(before) ? before : []).find((a) => a?.id === id);
1673
1871
  if (!target) {
1674
1872
  throw new Error(`Account "${id}" not found, so it cannot be reopened. Use actual_accounts_list to see ` +
@@ -1716,13 +1914,16 @@ export async function createCategoryGroup(group) {
1716
1914
  export async function updateCategoryGroup(id, fields) {
1717
1915
  observability.incrementToolCall('actual.category_groups.update').catch(() => { });
1718
1916
  return queueWriteOperation(async () => {
1917
+ // #378: this guard's pre-read is safe against a SIBLING operation in the same drain only
1918
+ // because the batch dispatches sequentially. It reads a listing memoised for this drain,
1919
+ // which the previous operation invalidated unless it declared it could not change it.
1719
1920
  // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1720
1921
  // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1721
1922
  // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1722
1923
  // Called with no argument, upstream returns all groups including hidden ones, so a
1723
1924
  // hidden group is not misreported as missing (the same reason category_groups_delete
1724
1925
  // can rely on this listing).
1725
- const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1926
+ const groups = await withConcurrency(() => retry(() => readDrainListing('categoryGroups', () => rawGetCategoryGroups()), { retries: 2, backoffMs: 200 }));
1726
1927
  if (!(Array.isArray(groups) && groups.some((g) => g?.id === id))) {
1727
1928
  throw new NotFoundRefusal('Category group', id, 'actual_category_groups_get');
1728
1929
  }
@@ -1737,7 +1938,7 @@ export async function updateCategoryGroup(id, fields) {
1737
1938
  export async function deleteCategoryGroup(id) {
1738
1939
  observability.incrementToolCall('actual.category_groups.delete').catch(() => { });
1739
1940
  return queueWriteOperation(async () => {
1740
- const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1941
+ const groups = await withConcurrency(() => retry(() => readDrainListing('categoryGroups', () => rawGetCategoryGroups()), { retries: 2, backoffMs: 200 }));
1741
1942
  if (!Array.isArray(groups) || !groups.some((g) => g?.id === id)) {
1742
1943
  throw new NotFoundRefusal('Category group', id, 'actual_category_groups_get');
1743
1944
  }
@@ -1779,7 +1980,7 @@ function summariseIds(ids, limit = 5) {
1779
1980
  export async function mergePayees(targetId, mergeIds) {
1780
1981
  observability.incrementToolCall('actual.payees.merge').catch(() => { });
1781
1982
  return queueWriteOperation(async () => {
1782
- const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1983
+ const payees = await withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 }));
1783
1984
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1784
1985
  const byId = new Map(payees.map((p) => [p.id, p]));
1785
1986
  const target = byId.get(targetId);
@@ -1855,11 +2056,22 @@ export async function getPayeeRules(payeeId) {
1855
2056
  return filtered;
1856
2057
  });
1857
2058
  }
2059
+ /**
2060
+ * #378 CAVEAT ON THIS METHOD'S preservesListings CLAIM. This forwards an arbitrary callback to
2061
+ * rawBatchBudgetUpdates, which upstream is only a batch-budget-start / await func() /
2062
+ * batch-budget-end transaction bracket: it performs no writes of its own, so the annotation is
2063
+ * really a claim about whatever the CALLBACK does. It holds today because
2064
+ * src/tools/budget_updates_batch.ts is the only caller passing a real callback and it calls
2065
+ * only rawSetBudgetAmount and rawSetBudgetCarryover, both verified listing-safe. Nothing in
2066
+ * the signature or upstream constrains that. RE-AUDIT THIS when a second caller appears: a
2067
+ * callback reaching rawCreateCategory or rawDeleteAccount would violate the claim with no
2068
+ * compiler or runtime signal, and the symptom would be a false not-found elsewhere in the drain.
2069
+ */
1858
2070
  export async function batchBudgetUpdates(fn) {
1859
2071
  observability.incrementToolCall('actual.budgets.batchUpdates').catch(() => { });
1860
2072
  return queueWriteOperation(async () => {
1861
2073
  await withConcurrency(() => retry(() => rawBatchBudgetUpdates(fn), { retries: 2, backoffMs: 200 }));
1862
- });
2074
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1863
2075
  }
1864
2076
  /**
1865
2077
  * #355: RETURNS the upstream verdict instead of discarding it.
@@ -1906,13 +2118,13 @@ export async function holdBudgetForNextMonth(month, amount) {
1906
2118
  await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 0, backoffMs: 200 }));
1907
2119
  const after = await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1908
2120
  return Number(after?.forNextMonth ?? 0) - heldBefore;
1909
- });
2121
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1910
2122
  }
1911
2123
  export async function resetBudgetHold(month) {
1912
2124
  observability.incrementToolCall('actual.budgets.resetHold').catch(() => { });
1913
2125
  return queueWriteOperation(async () => {
1914
2126
  await withConcurrency(() => retry(() => rawResetBudgetHold(month), { retries: 2, backoffMs: 200 }));
1915
- });
2127
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1916
2128
  }
1917
2129
  export async function runQuery(queryString) {
1918
2130
  try {
@@ -2421,7 +2633,7 @@ export async function createTag(tag) {
2421
2633
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2422
2634
  const raw = await withConcurrency(() => retry(() => rawCreateTag(tag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
2423
2635
  return normalizeToId(raw);
2424
- });
2636
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2425
2637
  }
2426
2638
  export async function updateTag(id, fields) {
2427
2639
  observability.incrementToolCall('actual.tags.update').catch(() => { });
@@ -2434,7 +2646,7 @@ export async function updateTag(id, fields) {
2434
2646
  throw new NotFoundRefusal('Tag', id, 'actual_tags_list');
2435
2647
  }
2436
2648
  await withConcurrency(() => retry(() => rawUpdateTag(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2437
- });
2649
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2438
2650
  }
2439
2651
  export async function deleteTag(id) {
2440
2652
  observability.incrementToolCall('actual.tags.delete').catch(() => { });
@@ -2447,7 +2659,7 @@ export async function deleteTag(id) {
2447
2659
  throw new NotFoundRefusal('Tag', id, 'actual_tags_list');
2448
2660
  }
2449
2661
  await withConcurrency(() => retry(() => rawDeleteTag(id), { retries: 0, backoffMs: 200 }));
2450
- });
2662
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2451
2663
  }
2452
2664
  export async function getNote(id) {
2453
2665
  return withActualApi(async () => {
@@ -2479,10 +2691,10 @@ export async function updateNote(id, note) {
2479
2691
  if (!BUDGET_MONTH_NOTE_RE.test(id)) {
2480
2692
  const has = (rows) => Array.isArray(rows) && rows.some((e) => e?.id === id);
2481
2693
  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 })),
2694
+ withConcurrency(() => retry(() => readDrainListing('accounts', () => rawGetAccounts()), { retries: 2, backoffMs: 200 })),
2695
+ withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 })),
2696
+ withConcurrency(() => retry(() => readDrainListing('categoryGroups', () => rawGetCategoryGroups()), { retries: 2, backoffMs: 200 })),
2697
+ withConcurrency(() => retry(() => readDrainListing('payees', () => rawGetPayees()), { retries: 2, backoffMs: 200 })),
2486
2698
  ]);
2487
2699
  if (!has(accounts) && !has(categories) && !has(categoryGroups) && !has(payees)) {
2488
2700
  // Guarding matters here for the same reason as #360: upstream's note write is a
@@ -2492,7 +2704,7 @@ export async function updateNote(id, note) {
2492
2704
  }
2493
2705
  }
2494
2706
  await withConcurrency(() => retry(() => rawUpdateNote(id, note), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2495
- });
2707
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2496
2708
  }
2497
2709
  /**
2498
2710
  * Export the currently-loaded budget as a zip (#332).
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.2",
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",