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 +1 -1
- package/dist/src/lib/actual-adapter.js +223 -164
- package/package.json +1 -1
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,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
|
-
|
|
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
|
|
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
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
739
|
+
const result = await withOpTimeout(operation);
|
|
740
|
+
resolve(result);
|
|
753
741
|
}
|
|
754
|
-
catch (
|
|
755
|
-
|
|
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
|
-
//
|
|
759
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
777
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
824
|
+
reject(error);
|
|
786
825
|
}
|
|
787
|
-
catch (
|
|
788
|
-
|
|
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
|
-
|
|
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).
|