actual-mcp-server 0.16.1 → 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.1 | **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,7 +538,54 @@ 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;
540
- let drainListingCache = null;
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();
541
589
  /**
542
590
  * Read one entity listing, memoised for the life of the current drain.
543
591
  *
@@ -549,6 +597,7 @@ let drainListingCache = null;
549
597
  * one drain therefore share a single in-flight listing rather than racing to start two.
550
598
  */
551
599
  async function readDrainListing(kind, fetch) {
600
+ const drainListingCache = drainListingStore.getStore();
552
601
  if (!drainListingCache)
553
602
  return await fetch();
554
603
  const cached = drainListingCache.get(kind);
@@ -562,7 +611,7 @@ async function readDrainListing(kind, fetch) {
562
611
  catch (error) {
563
612
  // A failed listing must not be cached: the next guard in this drain would inherit the
564
613
  // failure and refuse a write for a reason that has nothing to do with its own target.
565
- if (drainListingCache?.get(kind) === inFlight)
614
+ if (drainListingCache.get(kind) === inFlight)
566
615
  drainListingCache.delete(kind);
567
616
  throw error;
568
617
  }
@@ -575,6 +624,7 @@ async function readDrainListing(kind, fetch) {
575
624
  * is a false not-found, so when in doubt, invalidate.
576
625
  */
577
626
  function invalidateDrainListing(...kinds) {
627
+ const drainListingCache = drainListingStore.getStore();
578
628
  if (!drainListingCache)
579
629
  return;
580
630
  for (const kind of kinds)
@@ -636,169 +686,167 @@ async function processWriteQueue() {
636
686
  logger.debug(`[WRITE QUEUE] Processing batch of ${batch.length} operations ` +
637
687
  `(sessionId=${batchSessionId ?? 'none'}, poolBranch=${usePoolBranch})`);
638
688
  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();
644
- await withApiLock(async () => {
645
- try {
646
- if (usePoolBranch) {
647
- // Pool branch: api singleton already live for this session, no need
648
- // to init or shutdown around the batch. Sync at the end to commit
649
- // writes upstream. On infrastructure-level errors, release the pool
650
- // entry so the next call materialises a fresh connection.
651
- writeConnectionReuseCount++;
652
- logger.debug(`[WRITE QUEUE] Reusing pool connection for session ${batchSessionId} ` +
653
- `(writeReuses=${writeConnectionReuseCount})`);
654
- }
655
- else {
656
- // Legacy branch: no pool entry, init+shutdown around the batch as
657
- // before. initActualApiForOperation() still short-circuits if the
658
- // api is somehow already live (e.g. another path init'd it).
659
- await initActualApiForOperation();
660
- }
661
- // Process all queued writes in the same session
662
- // Each operation handles its own success/failure
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) {
688
- try {
689
- const result = await withOpTimeout(operation);
690
- resolve(result);
691
- }
692
- catch (error) {
693
- logger.error('[WRITE QUEUE] Operation failed:', error);
694
- reject(error);
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})`);
695
704
  }
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);
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();
735
710
  }
736
- }
737
- // Explicitly sync changes to server before shutdown (legacy) or just
738
- // before returning (pool). Persistence guarantee in both branches.
739
- logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
740
- try {
741
- await withOpTimeout(() => api.sync(), 'sync');
742
- logger.debug(`[WRITE QUEUE] Sync completed`);
743
- }
744
- catch (syncError) {
745
- logger.error('[WRITE QUEUE] Sync failed:', syncError);
746
- // Pool branch: drop the connection on infrastructure-level sync
747
- // failure so the next write re-initialises cleanly. Mirrors
748
- // withActualApiWrite's policy.
749
- if (usePoolBranch && _shouldDropPoolOnError(syncError)) {
750
- 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) {
751
738
  try {
752
- await connectionPool.shutdownConnection(batchSessionId);
739
+ const result = await withOpTimeout(operation);
740
+ resolve(result);
753
741
  }
754
- catch (_e) {
755
- /* 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);
756
785
  }
757
786
  }
758
- // Don't throw - we still want to shutdown cleanly
759
- // Individual operation errors were already reported to callers
760
- }
761
- if (!usePoolBranch) {
762
- // Legacy branch only: actually shut the singleton down.
763
- // shutdownActualApi() itself short-circuits to sync-only if another
764
- // path has active pool sessions, so this is safe under contention.
765
- await shutdownActualApi();
766
- }
767
- logger.debug(`[WRITE QUEUE] Batch completed successfully`);
768
- }
769
- catch (error) {
770
- logger.error('[WRITE QUEUE] Fatal error in write queue:', error);
771
- // Reject any operations that weren't processed
772
- 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`);
773
790
  try {
774
- 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
775
810
  }
776
- catch (e) {
777
- 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();
778
816
  }
779
- });
780
- // Pool branch: drop the pool entry if the error suggests infrastructure
781
- // corruption. Legacy branch: full shutdown.
782
- if (usePoolBranch) {
783
- 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 }) => {
784
823
  try {
785
- await connectionPool.shutdownConnection(batchSessionId);
824
+ reject(error);
786
825
  }
787
- catch (_e) {
788
- /* 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
+ }
789
840
  }
790
841
  }
842
+ else {
843
+ await shutdownActualApi();
844
+ }
791
845
  }
792
- else {
793
- await shutdownActualApi();
794
- }
795
- }
846
+ });
796
847
  });
797
848
  }
798
849
  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;
802
850
  isProcessingWrites = false;
803
851
  // #278: ALWAYS re-drain a non-empty queue. The old `&& writeSessionTimeout === null`
804
852
  // guard was the lost wakeup: a timer that fired mid-drain left a dead handle here,
@@ -1200,7 +1248,7 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
1200
1248
  fromCategory: { id: fromCategoryId, previousAmount: prevFrom, newAmount: prevFrom - amount },
1201
1249
  toCategory: { id: toCategoryId, previousAmount: prevTo, newAmount: prevTo + amount },
1202
1250
  };
1203
- });
1251
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1204
1252
  }
1205
1253
  export async function createAccount(account, initialBalance) {
1206
1254
  observability.incrementToolCall('actual.accounts.create').catch(() => { });
@@ -1527,7 +1575,7 @@ export async function createRule(rule) {
1527
1575
  const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1528
1576
  const id = normalizeToId(raw);
1529
1577
  return id;
1530
- });
1578
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1531
1579
  }
1532
1580
  export async function updateRule(id, fields) {
1533
1581
  observability.incrementToolCall('actual.rules.update').catch(() => { });
@@ -1563,7 +1611,7 @@ export async function updateRule(id, fields) {
1563
1611
  logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
1564
1612
  await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1565
1613
  logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
1566
- });
1614
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1567
1615
  }
1568
1616
  /**
1569
1617
  * #355: RETURNS the upstream verdict instead of discarding it, for the same reason
@@ -1624,7 +1672,7 @@ export async function upsertRule(input, stageWasSupplied) {
1624
1672
  ruleData.stage = null;
1625
1673
  const rawId = await withConcurrency(() => retry(() => rawCreateRule(ruleData), { retries: 0, backoffMs: 200 }));
1626
1674
  return { id: normalizeToId(rawId), created: true };
1627
- });
1675
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1628
1676
  }
1629
1677
  /**
1630
1678
  * #376: the existence guard and the schedule-owned refusal moved here from
@@ -1669,7 +1717,7 @@ export async function deleteRule(id) {
1669
1717
  'instead with actual_schedules_delete (find it with actual_schedules_get), ' +
1670
1718
  'which removes this rule too.');
1671
1719
  }
1672
- });
1720
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1673
1721
  }
1674
1722
  export async function getSchedules() {
1675
1723
  return withActualApi(async () => {
@@ -1686,13 +1734,13 @@ export async function createSchedule(schedule) {
1686
1734
  const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1687
1735
  const id = normalizeToId(raw);
1688
1736
  return id;
1689
- });
1737
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1690
1738
  }
1691
1739
  export async function updateSchedule(id, fields, resetNextDate) {
1692
1740
  observability.incrementToolCall('actual.schedules.update').catch(() => { });
1693
1741
  return queueWriteOperation(async () => {
1694
1742
  await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1695
- });
1743
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1696
1744
  }
1697
1745
  /**
1698
1746
  * #376: the existence guard and the constraint-error translation moved here from
@@ -1716,13 +1764,13 @@ export async function deleteSchedule(id) {
1716
1764
  }
1717
1765
  throw err;
1718
1766
  }
1719
- });
1767
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1720
1768
  }
1721
1769
  export async function setBudgetCarryover(month, categoryId, flag) {
1722
1770
  observability.incrementToolCall('actual.budgets.setCarryover').catch(() => { });
1723
1771
  return queueWriteOperation(async () => {
1724
1772
  await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1725
- });
1773
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
1726
1774
  }
1727
1775
  /**
1728
1776
  * #357, at the adapter layer since #371.
@@ -2008,11 +2056,22 @@ export async function getPayeeRules(payeeId) {
2008
2056
  return filtered;
2009
2057
  });
2010
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
+ */
2011
2070
  export async function batchBudgetUpdates(fn) {
2012
2071
  observability.incrementToolCall('actual.budgets.batchUpdates').catch(() => { });
2013
2072
  return queueWriteOperation(async () => {
2014
2073
  await withConcurrency(() => retry(() => rawBatchBudgetUpdates(fn), { retries: 2, backoffMs: 200 }));
2015
- });
2074
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2016
2075
  }
2017
2076
  /**
2018
2077
  * #355: RETURNS the upstream verdict instead of discarding it.
@@ -2059,13 +2118,13 @@ export async function holdBudgetForNextMonth(month, amount) {
2059
2118
  await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 0, backoffMs: 200 }));
2060
2119
  const after = await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
2061
2120
  return Number(after?.forNextMonth ?? 0) - heldBefore;
2062
- });
2121
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2063
2122
  }
2064
2123
  export async function resetBudgetHold(month) {
2065
2124
  observability.incrementToolCall('actual.budgets.resetHold').catch(() => { });
2066
2125
  return queueWriteOperation(async () => {
2067
2126
  await withConcurrency(() => retry(() => rawResetBudgetHold(month), { retries: 2, backoffMs: 200 }));
2068
- });
2127
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2069
2128
  }
2070
2129
  export async function runQuery(queryString) {
2071
2130
  try {
@@ -2574,7 +2633,7 @@ export async function createTag(tag) {
2574
2633
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2575
2634
  const raw = await withConcurrency(() => retry(() => rawCreateTag(tag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
2576
2635
  return normalizeToId(raw);
2577
- });
2636
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2578
2637
  }
2579
2638
  export async function updateTag(id, fields) {
2580
2639
  observability.incrementToolCall('actual.tags.update').catch(() => { });
@@ -2587,7 +2646,7 @@ export async function updateTag(id, fields) {
2587
2646
  throw new NotFoundRefusal('Tag', id, 'actual_tags_list');
2588
2647
  }
2589
2648
  await withConcurrency(() => retry(() => rawUpdateTag(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2590
- });
2649
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2591
2650
  }
2592
2651
  export async function deleteTag(id) {
2593
2652
  observability.incrementToolCall('actual.tags.delete').catch(() => { });
@@ -2600,7 +2659,7 @@ export async function deleteTag(id) {
2600
2659
  throw new NotFoundRefusal('Tag', id, 'actual_tags_list');
2601
2660
  }
2602
2661
  await withConcurrency(() => retry(() => rawDeleteTag(id), { retries: 0, backoffMs: 200 }));
2603
- });
2662
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2604
2663
  }
2605
2664
  export async function getNote(id) {
2606
2665
  return withActualApi(async () => {
@@ -2645,7 +2704,7 @@ export async function updateNote(id, note) {
2645
2704
  }
2646
2705
  }
2647
2706
  await withConcurrency(() => retry(() => rawUpdateNote(id, note), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2648
- });
2707
+ }, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
2649
2708
  }
2650
2709
  /**
2651
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.1",
4
+ "version": "0.16.2",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"