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