actual-mcp-server 0.22.22 → 0.22.25
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/index.js +19 -49
- package/dist/src/lib/actual-adapter.js +18 -0
- package/dist/src/lib/mcp-version.js +63 -0
- package/dist/src/lib/transportErrorLogger.js +5 -3
- package/dist/src/server/httpErrorHandler.js +6 -2
- package/dist/src/tools/rules_create_or_update.js +2 -1
- package/dist/src/tools/server_info.js +7 -24
- package/dist/src/tools/transactions_update_batch.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -835,7 +835,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
835
835
|
|
|
836
836
|
---
|
|
837
837
|
|
|
838
|
-
**Version:** 0.22.
|
|
838
|
+
**Version:** 0.22.25 | **Tool Count:** 83 (verified LibreChat-compatible)
|
|
839
839
|
|
|
840
840
|
## Sponsor
|
|
841
841
|
|
package/dist/src/index.js
CHANGED
|
@@ -2,38 +2,25 @@
|
|
|
2
2
|
// source order, so this arms the Node floor check before zod, before the rejection
|
|
3
3
|
// allowlist, and before the handlers below. `npm start` and the documented
|
|
4
4
|
// `node dist/src/index.js --stdio` reach this file without going through bin/, so the
|
|
5
|
-
// guard cannot live in bin/ alone.
|
|
6
|
-
//
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
5
|
+
// guard cannot live in bin/ alone. It is a side-effect import: the module's top-level
|
|
6
|
+
// `enforceNodeVersion()` fires here, exactly once.
|
|
7
|
+
import './lib/node-version-guard.js';
|
|
8
|
+
import { getMcpServerVersion } from './lib/mcp-version.js';
|
|
9
|
+
import path from 'node:path';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
|
|
12
12
|
import { format } from 'node:util';
|
|
13
|
-
|
|
14
|
-
* #277:
|
|
13
|
+
/*
|
|
14
|
+
* #277 / #541: this module's version comes from `getMcpServerVersion()` (src/lib/mcp-version.ts),
|
|
15
|
+
* which `--version`, `--help`, the startup banner, the MCP initialize `serverInfo.version` and
|
|
16
|
+
* `actual_server_info` all share. It reads the ROOT package.json through
|
|
17
|
+
* `findRootPackageJson`, never the stale `dist/package.json` mirror that a dynamic JSON import
|
|
18
|
+
* once produced (#277), and it honours a build-time `VERSION`.
|
|
15
19
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* went stale between a version bump and the next build. `src/tools/server_info.ts` reads
|
|
20
|
-
* the ROOT manifest, so the two disagreed. Removing the import removes the mirror too.
|
|
21
|
-
*
|
|
22
|
-
* `findRootPackageJson` walks up to the outermost package.json named actual-mcp-server,
|
|
23
|
-
* skipping that mirror. It never throws (it swallows unreadable and malformed files), so
|
|
24
|
-
* the try/catch is belt-and-braces around a future change rather than a live path.
|
|
25
|
-
* Removing the import attributes also removes the last instance of the construct that
|
|
26
|
-
* crashed on Node 18 in #275.
|
|
20
|
+
* #541 reversed the gate's "leave `--version` unchanged": the old `readRootVersion()` here
|
|
21
|
+
* ignored `VERSION`, so a Docker image built as `X-dev-abc` printed plain `X` from
|
|
22
|
+
* `--version` while clients saw `X-dev-abc`. One reader means one string.
|
|
27
23
|
*/
|
|
28
|
-
function readRootVersion() {
|
|
29
|
-
try {
|
|
30
|
-
const manifest = findRootPackageJson(dirname(fileURLToPath(import.meta.url)));
|
|
31
|
-
return manifest?.version ?? process.env.VERSION ?? 'unknown';
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return process.env.VERSION ?? 'unknown';
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
24
|
// Add global error handlers
|
|
38
25
|
process.on('unhandledRejection', (reason, promise) => {
|
|
39
26
|
console.error('=== UNHANDLED REJECTION ===');
|
|
@@ -140,7 +127,7 @@ if (!argsEarly.includes('--http') &&
|
|
|
140
127
|
}
|
|
141
128
|
if (argsEarly.includes('--help') || argsEarly.includes('-h') ||
|
|
142
129
|
argsEarly.includes('--version') || argsEarly.includes('-v')) {
|
|
143
|
-
const version =
|
|
130
|
+
const version = getMcpServerVersion();
|
|
144
131
|
if (argsEarly.includes('--version') || argsEarly.includes('-v')) {
|
|
145
132
|
console.log(version);
|
|
146
133
|
}
|
|
@@ -212,26 +199,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
212
199
|
const logger = loggerModule.default;
|
|
213
200
|
const { getLocalIp } = utilsModule;
|
|
214
201
|
const actualToolsManager = actualToolsManagerModule.default;
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
VERSION = readRootVersion();
|
|
219
|
-
// Append git commit hash for development builds
|
|
220
|
-
try {
|
|
221
|
-
const { execSync } = await import('child_process');
|
|
222
|
-
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
|
223
|
-
const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
|
224
|
-
if (branch === 'develop' || branch !== 'main') {
|
|
225
|
-
VERSION = `${VERSION}-dev-${commitHash}`;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
catch (err) {
|
|
229
|
-
// Git not available or not in a git repo, use version as-is
|
|
230
|
-
logger.debug('Could not determine git commit hash:', err);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
// Ensure VERSION is always a string (fallback to 0.1.0 if somehow still undefined)
|
|
234
|
-
const version = VERSION || '0.1.0';
|
|
202
|
+
// #541: the build-time VERSION, or the root package version plus a dev suffix on a
|
|
203
|
+
// checkout. Shared with actual_server_info so both report the identical string.
|
|
204
|
+
const version = getMcpServerVersion();
|
|
235
205
|
// now continue with the original logic (args, flags, usage, etc.)
|
|
236
206
|
const PORT = process.env.MCP_BRIDGE_PORT ? Number(process.env.MCP_BRIDGE_PORT) : 3600;
|
|
237
207
|
const HTTP_PATH = process.env.MCP_HTTP_PATH || '/http';
|
|
@@ -249,7 +219,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
249
219
|
logger.error('❌ --http and --stdio are mutually exclusive. Pick one.');
|
|
250
220
|
process.exit(1);
|
|
251
221
|
}
|
|
252
|
-
logger.info(`🚀 Starting Actual MCP Server v${
|
|
222
|
+
logger.info(`🚀 Starting Actual MCP Server v${version}`);
|
|
253
223
|
// NOTE: Persistent connection disabled - using init/shutdown per operation pattern
|
|
254
224
|
// This ensures tombstone=0 for all created entities (they appear in UI)
|
|
255
225
|
// await connectToActual();
|
|
@@ -2120,12 +2120,25 @@ export async function updateTransaction(id, fields) {
|
|
|
2120
2120
|
}
|
|
2121
2121
|
});
|
|
2122
2122
|
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Update many transactions in ONE write cycle, with a verdict per item. NOT atomic.
|
|
2125
|
+
*
|
|
2126
|
+
* COOPERATIVE DEADLINE (#538): same 0.75 fraction of ACTUAL_OP_TIMEOUT_MS as `createRulesBatch`
|
|
2127
|
+
* and `setBudgetBatch`, because `withOpTimeout` cannot cancel this callback: without it a slow
|
|
2128
|
+
* batch kept writing after its caller was told the operation timed out. The clock starts before
|
|
2129
|
+
* the existence query, so a slow read counts. Items not reached go to `failed` with
|
|
2130
|
+
* BUDGET_BATCH_DEADLINE_ERROR and were never sent upstream. A missing id keeps its not-found
|
|
2131
|
+
* error even past the deadline: it was never going to be written, and "not attempted" would
|
|
2132
|
+
* tell the client a retry could succeed. A raw update already in flight when the deadline
|
|
2133
|
+
* passes can still overrun; the check runs between items.
|
|
2134
|
+
*/
|
|
2123
2135
|
export async function updateTransactionBatch(updates) {
|
|
2124
2136
|
observability.incrementToolCall('actual.transactions.updateBatch').catch(() => { });
|
|
2125
2137
|
// All updates share one queueWriteOperation → one init/sync/shutdown cycle (issue #79).
|
|
2126
2138
|
// Sequential loop (not Promise.all) is intentional: concurrent rawUpdateTransaction calls
|
|
2127
2139
|
// within one session can interleave withMutation CRDT messages unpredictably.
|
|
2128
2140
|
return queueWriteOperation(async () => {
|
|
2141
|
+
const startedAt = Date.now();
|
|
2129
2142
|
// Nothing to do for an empty batch. The tool enforces min 1, but the adapter is a
|
|
2130
2143
|
// public export, so guard here and avoid an empty-$oneof existence query.
|
|
2131
2144
|
if (updates.length === 0)
|
|
@@ -2148,11 +2161,16 @@ export async function updateTransactionBatch(updates) {
|
|
|
2148
2161
|
}, { retries: 2, backoffMs: 200 }));
|
|
2149
2162
|
const succeeded = [];
|
|
2150
2163
|
const failed = [];
|
|
2164
|
+
const deadlineMs = config.ACTUAL_OP_TIMEOUT_MS > 0 ? config.ACTUAL_OP_TIMEOUT_MS * BUDGET_BATCH_DEADLINE_FRACTION : 0;
|
|
2151
2165
|
for (const [position, { id, fields }] of updates.entries()) {
|
|
2152
2166
|
if (!existing.has(id)) {
|
|
2153
2167
|
failed.push({ id, error: `Transaction "${id}" not found. Use actual_transactions_get to list transactions.` });
|
|
2154
2168
|
continue;
|
|
2155
2169
|
}
|
|
2170
|
+
if (deadlineMs > 0 && Date.now() - startedAt > deadlineMs) {
|
|
2171
|
+
failed.push({ id, error: BUDGET_BATCH_DEADLINE_ERROR });
|
|
2172
|
+
continue;
|
|
2173
|
+
}
|
|
2156
2174
|
try {
|
|
2157
2175
|
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2158
2176
|
succeeded.push({ id });
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { findRootPackageJson } from './node-version-guard.js';
|
|
6
|
+
/**
|
|
7
|
+
* #541: the single source of this server's own version, shared by the MCP initialize
|
|
8
|
+
* `serverInfo.version` (src/index.ts) and the `actual_server_info` tool.
|
|
9
|
+
*
|
|
10
|
+
* A build-time `VERSION` (set by the Docker build arg and by CI) wins. Otherwise a
|
|
11
|
+
* developer checkout gets `<package version>-dev-<short hash>` when it is not on `main`.
|
|
12
|
+
*
|
|
13
|
+
* Git runs only when `.git` exists AT the package root, and always with that root as its
|
|
14
|
+
* cwd. The published npm package has no `.git`, and without that check `git rev-parse`
|
|
15
|
+
* would walk up from an install under some other repository (a consumer's node_modules)
|
|
16
|
+
* and report THAT repository's branch and hash. `existsSync` also accepts the `.git` FILE
|
|
17
|
+
* of a worktree or submodule.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveMcpServerVersion(baseVersion, env, root, runGit) {
|
|
20
|
+
if (env.VERSION && env.VERSION !== 'unknown')
|
|
21
|
+
return env.VERSION;
|
|
22
|
+
// #546: no manifest was found, so there is no version to qualify; 'unknown-dev-<hash>'
|
|
23
|
+
// would read as a real one.
|
|
24
|
+
if (baseVersion === 'unknown')
|
|
25
|
+
return baseVersion;
|
|
26
|
+
if (!existsSync(join(root, '.git')))
|
|
27
|
+
return baseVersion;
|
|
28
|
+
try {
|
|
29
|
+
const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
30
|
+
const hash = runGit(['rev-parse', '--short', 'HEAD']);
|
|
31
|
+
if (!hash)
|
|
32
|
+
return baseVersion;
|
|
33
|
+
// A detached HEAD reports 'HEAD', which is not main, so it gets the suffix too.
|
|
34
|
+
return branch !== 'main' ? `${baseVersion}-dev-${hash}` : baseVersion;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Git missing, not a repository, or the timeout fired: report the plain version.
|
|
38
|
+
return baseVersion;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let cached;
|
|
42
|
+
/**
|
|
43
|
+
* This package's (the MCP server's) version, computed once per process. Unrelated to the
|
|
44
|
+
* adapter's `getServerVersion()`, which returns the ACTUAL BUDGET server version.
|
|
45
|
+
*/
|
|
46
|
+
export function getMcpServerVersion() {
|
|
47
|
+
if (cached !== undefined)
|
|
48
|
+
return cached;
|
|
49
|
+
// dist/src/lib/ to the package root. This one root is both the `.git` check directory
|
|
50
|
+
// and git's cwd; never process.cwd(), which is wherever the server was started from.
|
|
51
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
52
|
+
const base = findRootPackageJson(root)?.version ?? 'unknown';
|
|
53
|
+
// stderr is discarded so a "not a git repository" line never reaches the stdio channel
|
|
54
|
+
// or the log, and the timeout keeps a hung git from blocking startup.
|
|
55
|
+
const runGit = (args) => execFileSync('git', args, {
|
|
56
|
+
cwd: root,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
59
|
+
timeout: 2000,
|
|
60
|
+
}).trim();
|
|
61
|
+
cached = resolveMcpServerVersion(base, process.env, root, runGit);
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
import { createModuleLogger } from './loggerFactory.js';
|
|
15
15
|
const MAX_DETAIL_CODE_POINTS = 200;
|
|
16
16
|
const CUT_CHARS = /[{["'`]/;
|
|
17
|
-
// Control (C0, C1), line/paragraph separators
|
|
18
|
-
//
|
|
17
|
+
// Control (C0, C1), line/paragraph separators, bidi format characters, and (#535) the
|
|
18
|
+
// invisible marks that can hide or reorder text in a log line: the Arabic letter mark,
|
|
19
|
+
// zero-width space/joiners, LRM/RLM and the BOM. Written with escapes, as src/logger.ts
|
|
20
|
+
// does: a raw U+2028 in source would terminate the literal.
|
|
19
21
|
// eslint-disable-next-line no-control-regex
|
|
20
|
-
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
22
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
|
|
21
23
|
const OMITTED = '[payload omitted]';
|
|
22
24
|
/** Pure: turn an arbitrary thrown value into a bounded, payload-free, single-line string. */
|
|
23
25
|
export function sanitizeTransportErrorDetail(value) {
|
|
@@ -28,11 +28,15 @@ const JSONRPC_INTERNAL_ERROR = -32603;
|
|
|
28
28
|
* True when the request targets the MCP endpoint: the pathname of `originalUrl` (the query
|
|
29
29
|
* string removed) equals `mcpPath` or starts with `mcpPath + '/'`. Never a bare prefix match:
|
|
30
30
|
* with an MCP path of `/mcp`, `/mcp-info` is NOT the MCP endpoint and gets the plain shape.
|
|
31
|
+
*
|
|
32
|
+
* #545: compared case-insensitively, because this server never enables Express's
|
|
33
|
+
* `case sensitive routing`, so `/MCP` reaches the MCP route and must get its envelope too.
|
|
31
34
|
*/
|
|
32
35
|
function isMcpRequestPath(originalUrl, mcpPath) {
|
|
33
36
|
const q = originalUrl.indexOf('?');
|
|
34
|
-
const pathname = q === -1 ? originalUrl : originalUrl.slice(0, q);
|
|
35
|
-
const
|
|
37
|
+
const pathname = (q === -1 ? originalUrl : originalUrl.slice(0, q)).toLowerCase();
|
|
38
|
+
const lowered = mcpPath.toLowerCase();
|
|
39
|
+
const base = lowered.length > 1 && lowered.endsWith('/') ? lowered.slice(0, -1) : lowered;
|
|
36
40
|
// A root MCP path ('/') makes every request an MCP request.
|
|
37
41
|
if (base === '/' || base === '')
|
|
38
42
|
return true;
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Adapted for this project's conventions:
|
|
17
17
|
* - No wrapToolCall: uses the direct call() pattern
|
|
18
|
-
* -
|
|
18
|
+
* - Shares ConditionSchema with rules_create.ts (src/lib/schemas/rules.ts); ActionSchema and the
|
|
19
|
+
* FIELD_OPERATORS map are private copies of the same shape (#540 found them identical)
|
|
19
20
|
*
|
|
20
21
|
* #376: the read-match-write cycle lives in `adapter.upsertRule` (identity rules in
|
|
21
22
|
* `src/lib/rule-matching.ts`). This tool owns the schema, the operator/UUID validation and
|
|
@@ -4,40 +4,23 @@ import { fileURLToPath } from 'url';
|
|
|
4
4
|
import { dirname, join } from 'path';
|
|
5
5
|
import actualToolsManager from '../actualToolsManager.js';
|
|
6
6
|
import { INSTALLED_API_VERSION, resolveInstalledVersion } from '../lib/installed-api-version.js';
|
|
7
|
+
import { getMcpServerVersion } from '../lib/mcp-version.js';
|
|
7
8
|
/** Resolved once at module load, not per call: the walk does blocking file reads
|
|
8
9
|
* and this tool can be called in a loop. The SDK exports no root path, so the
|
|
9
10
|
* resolvable subpath is passed explicitly (#445). */
|
|
10
11
|
const MCP_SDK_RESOLVED = resolveInstalledVersion('@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/server/index.js');
|
|
11
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
13
|
const __dirname = dirname(__filename);
|
|
13
|
-
//
|
|
14
|
+
// Name, description and declared dependencies come from package.json. The version does
|
|
15
|
+
// NOT: it comes from getMcpServerVersion() at call time (#541), so it matches the MCP
|
|
16
|
+
// initialize version and importing the tool registry never spawns git.
|
|
14
17
|
let packageInfo;
|
|
15
18
|
try {
|
|
16
19
|
const packagePath = join(__dirname, '../../../package.json');
|
|
17
|
-
|
|
18
|
-
packageInfo = JSON.parse(packageJson);
|
|
19
|
-
// Use VERSION environment variable if available (set by Docker build or CI/CD)
|
|
20
|
-
// This includes development metadata like: 0.2.4-dev-abc1234
|
|
21
|
-
if (process.env.VERSION && process.env.VERSION !== 'unknown') {
|
|
22
|
-
packageInfo.version = process.env.VERSION;
|
|
23
|
-
}
|
|
24
|
-
else {
|
|
25
|
-
// Fallback: Try to append git commit hash for local development builds
|
|
26
|
-
try {
|
|
27
|
-
const { execSync } = require('child_process');
|
|
28
|
-
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', cwd: join(__dirname, '../../..') }).trim();
|
|
29
|
-
const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8', cwd: join(__dirname, '../../..') }).trim();
|
|
30
|
-
if (branch === 'develop' || branch === 'development' || branch !== 'main') {
|
|
31
|
-
packageInfo.version = `${packageInfo.version}-dev-${commitHash}`;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
catch (gitErr) {
|
|
35
|
-
// Git not available or not in a git repo, use package.json version as-is
|
|
36
|
-
}
|
|
37
|
-
}
|
|
20
|
+
packageInfo = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
|
38
21
|
}
|
|
39
22
|
catch (error) {
|
|
40
|
-
packageInfo = {
|
|
23
|
+
packageInfo = { name: 'actual-mcp-server', description: 'MCP server for Actual Budget' };
|
|
41
24
|
}
|
|
42
25
|
const InputSchema = z.object({}).strict();
|
|
43
26
|
const tool = {
|
|
@@ -61,7 +44,7 @@ Use this to check server status, verify version compatibility, or debug issues.`
|
|
|
61
44
|
return {
|
|
62
45
|
server: {
|
|
63
46
|
name: packageInfo.name,
|
|
64
|
-
version:
|
|
47
|
+
version: getMcpServerVersion(),
|
|
65
48
|
description: packageInfo.description,
|
|
66
49
|
transport: process.env.MCP_STDIO_MODE === 'true' ? 'stdio' : 'http',
|
|
67
50
|
},
|
|
@@ -37,7 +37,7 @@ const InputSchema = z.object({
|
|
|
37
37
|
});
|
|
38
38
|
const tool = {
|
|
39
39
|
name: 'actual_transactions_update_batch',
|
|
40
|
-
description: `Update multiple transactions in a single call. Accepts up to 50 {id, fields} pairs. Each update is applied independently and NOT atomically: items applied before a failure stay applied. A missing id or an update Actual rejects is reported per item in failed, and the rest continue. If the call itself fails (lost connection, out of memory, timeout), the error names the ids already applied; read the transactions back with actual_transactions_get BEFORE retrying. Splits are not supported here: a subtransactions field is ignored in batch. Use actual_transactions_update to edit an existing split's children.
|
|
40
|
+
description: `Update multiple transactions in a single call. Accepts up to 50 {id, fields} pairs. Each update is applied independently and NOT atomically: items applied before a failure stay applied. A missing id or an update Actual rejects is reported per item in failed, and the rest continue. If the call itself fails (lost connection, out of memory, timeout), the error names the ids already applied; read the transactions back with actual_transactions_get BEFORE retrying. An item whose error says "not attempted" was never sent, because the batch ran out of time; read back before retrying it too, since others may have been applied. Splits are not supported here: a subtransactions field is ignored in batch. Use actual_transactions_update to edit an existing split's children.
|
|
41
41
|
|
|
42
42
|
Returns: { succeeded: [{id}], failed: [{id, error}], total, successCount, failureCount }
|
|
43
43
|
|