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 +1 -1
- package/dist/src/lib/actual-adapter.js +331 -119
- package/package.json +2 -2
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.
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|
739
|
+
const result = await withOpTimeout(operation);
|
|
740
|
+
resolve(result);
|
|
641
741
|
}
|
|
642
|
-
catch (
|
|
643
|
-
|
|
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
|
-
//
|
|
647
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
824
|
+
reject(error);
|
|
674
825
|
}
|
|
675
|
-
catch (
|
|
676
|
-
|
|
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
|
-
|
|
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 = {
|
|
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).
|
|
711
|
-
//
|
|
712
|
-
//
|
|
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.
|
|
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",
|