node-autotask 0.13.0 → 0.14.0

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.
Files changed (44) hide show
  1. package/ARCHITECTURE.md +36 -7
  2. package/MCP_TOOL_CATALOG.json +1 -1
  3. package/MCP_TOOL_MANIFEST.md +2 -2
  4. package/N8N_MCP_MANIFEST.json +2 -2
  5. package/N8N_MCP_MANIFEST.md +3 -3
  6. package/README.md +6 -5
  7. package/capabilities.json +2132 -2740
  8. package/dist/capabilities.cjs +2 -2
  9. package/dist/capabilities.d.cts +8 -4
  10. package/dist/capabilities.d.ts +8 -4
  11. package/dist/capabilities.js +1 -1
  12. package/dist/{chunk-DDZXN6LN.js → chunk-2ZZOTJ2C.js} +2 -2
  13. package/dist/{chunk-MTDCLVQQ.cjs → chunk-5QMXECLN.cjs} +558 -548
  14. package/dist/{chunk-CXXNONRP.cjs → chunk-EWMG3HHU.cjs} +2131 -2739
  15. package/dist/{chunk-CJA6L6NR.cjs → chunk-FCJUM42E.cjs} +135 -135
  16. package/dist/{chunk-Y6O5VVQ4.js → chunk-IYN2BHVW.js} +136 -126
  17. package/dist/{chunk-OZD5FQM4.js → chunk-JIDR3NPV.js} +2131 -2739
  18. package/dist/{chunk-IKJLIDK3.js → chunk-KA6UU26H.js} +35 -23
  19. package/dist/{chunk-KB2ANVVR.cjs → chunk-XSIY3WRR.cjs} +40 -28
  20. package/dist/{index-DbMKWnEX.d.cts → index-oiwPuJOG.d.cts} +67 -13
  21. package/dist/{index-TuqNq3Ro.d.ts → index-zt1KXThi.d.ts} +67 -13
  22. package/dist/index.cjs +61 -19
  23. package/dist/index.d.cts +83 -3
  24. package/dist/index.d.ts +83 -3
  25. package/dist/index.js +46 -4
  26. package/dist/operations/index.cjs +110 -107
  27. package/dist/operations/index.d.cts +9 -2
  28. package/dist/operations/index.d.ts +9 -2
  29. package/dist/operations/index.js +9 -6
  30. package/dist/pool.cjs +8 -8
  31. package/dist/pool.d.cts +1 -1
  32. package/dist/pool.d.ts +1 -1
  33. package/dist/pool.js +4 -4
  34. package/dist/resources/index.cjs +4 -4
  35. package/dist/resources/index.d.cts +1 -1
  36. package/dist/resources/index.d.ts +1 -1
  37. package/dist/resources/index.js +3 -3
  38. package/dist/untrusted.cjs +3 -1
  39. package/dist/untrusted.d.cts +9 -4
  40. package/dist/untrusted.d.ts +9 -4
  41. package/dist/untrusted.js +3 -1
  42. package/docs/API.md +108 -20
  43. package/docs/LIVE-TESTING.md +1 -1
  44. package/package.json +2 -2
package/docs/API.md CHANGED
@@ -81,12 +81,19 @@ flow.
81
81
  Native global `fetch` (Node >= 24) — zero runtime dependencies across the
82
82
  whole package. `HttpClient.request<T>(opts): Promise<T>` is the single
83
83
  transport primitive (`RequestOptions { method, path, query?, body?, headers? }`):
84
- JSON bodies, retry with backoff on 429/5xx for idempotent requests (budget
85
- `maxRetries`), a concurrency guard (`maxConcurrent`, default 10 — the API
84
+ JSON bodies, retries with backoff (budget `maxRetries`) in two legs (issue
85
+ #105): a 429 (a pre-processing refusal — the vendor rejected the call before
86
+ executing it) is retried per `Retry-After` on the pre-#105 retriable set
87
+ (GET/PUT/DELETE + idempotent-flagged calls — an unflagged POST/PATCH surfaces
88
+ a 429 at once, as before #105), while 5xx/network errors are auto-retried only
89
+ for GET and idempotent-flagged calls: a mutation makes exactly one send on a
90
+ 5xx or network error (the write-outcome is unknown once the body was written);
91
+ an explicit `idempotent: true` on a mutation opts back into the pre-#105
92
+ auto-retry on both legs — plus a concurrency guard (`maxConcurrent`, default 10 — the API
86
93
  allows <= 10 concurrent), one correlation id per request (surfaced on every
87
94
  error and to `onAudit`), and the opt-in client-side hourly budget tracker
88
95
  (`rateLimit.perHour`, default off — a sliding one-hour window that waits for a slot within the call deadline, else throws `RateLimitError` with `retryAfter`; the API allows 10,000 req/hour). The tracker is PER PROCESS: it counts only this client's attempts and cannot see other instances or integrations on the tenant (`thresholdInformation.get()` is the only cross-integration signal; the local count is `client.hourlyBudget()`, section 3.2.1). Under the `agent` config profile the exhaustion `suggestedAction` says stop or narrow the query — never raise the limit.
89
- Redirects are never followed (a 3xx throws `REDIRECT_BLOCKED`, so auth headers cannot leave the configured origin). Retry waits are capped at 60 s and never outlast the call deadline: a larger `Retry-After` (or one that exceeds the remaining budget) throws the `RateLimitError` immediately. `RequestOptions` adds `signal` (caller `AbortSignal`, combined with the deadline; aborting throws `AutotaskAbortError`, code `ABORTED`, and cancels retry sleeps) and `idempotent` (retry a non-GET/PUT/DELETE; set on the `/query` and `/query/count` POST reads, never on real writes). `client.withSignal(signal, () => ...)` applies a caller `AbortSignal` to every request (and retry wait) made inside the callback — the way to propagate MCP request cancellation through `invoke` without a signal parameter on each method. A real write clears the read cache. A non-JSON 2xx body is typed by the response `Content-Type` (section 2.1): a non-JSON-ish header throws the retryable `UNEXPECTED_CONTENT_TYPE` (the edge/maintenance page — retried per policy on idempotent methods); a JSON-ish or ABSENT header keeps `INVALID_RESPONSE` (the #13/W-A7 shape-drift key); JSON under any other header still decodes (the decode attempt is unconditional). A blank 2xx on a call that expects a result (every method but `DELETE` by default — `RequestOptions.expectBody`) throws the typed `EMPTY_RESPONSE` (`DELETE` and 204 keep returning `undefined`). A response above `maxResponseBytes` throws `RESPONSE_TOO_LARGE`; HTTP 412 maps to `ConflictError` (category `conflict`).
96
+ Redirects are never followed (a 3xx throws `REDIRECT_BLOCKED`, so auth headers cannot leave the configured origin). Retry waits are capped at 60 s and never outlast the call deadline: a larger `Retry-After` (or one that exceeds the remaining budget) throws the `RateLimitError` immediately. `RequestOptions` adds `signal` (caller `AbortSignal`, combined with the deadline; aborting throws `AutotaskAbortError`, code `ABORTED`, and cancels retry sleeps) and `idempotent` (retry a non-GET method on 5xx/network errors; set on the `/query` and `/query/count` POST reads; on a mutation it is the issue #105 opt-back into the pre-#105 auto-retry — never set on real writes by the SDK). `client.withSignal(signal, () => ...)` applies a caller `AbortSignal` to every request (and retry wait) made inside the callback — the way to propagate MCP request cancellation through `invoke` without a signal parameter on each method. A real write clears the read cache. A non-JSON 2xx body is typed by the response `Content-Type` (section 2.1): a non-JSON-ish header throws the retryable `UNEXPECTED_CONTENT_TYPE` (the edge/maintenance page — retried per policy on the pre-#105 retriable set: GET/PUT/DELETE + idempotent-flagged calls); a JSON-ish or ABSENT header keeps `INVALID_RESPONSE` (the #13/W-A7 shape-drift key); JSON under any other header still decodes (the decode attempt is unconditional). A blank 2xx on a call that expects a result (every method but `DELETE` by default — `RequestOptions.expectBody`) throws the typed `EMPTY_RESPONSE` (`DELETE` and 204 keep returning `undefined`). A response above `maxResponseBytes` throws `RESPONSE_TOO_LARGE`; HTTP 412 maps to `ConflictError` (category `conflict`).
90
97
  Per-tenant 429 cooldown gate (`cooldown`, ON by default): once a 429 surfaces from the transport (retries spent, a `Retry-After` too long to wait out, or a non-retriable method), the client records a cooldown deadline for that tenant (lowercased `UserName` + lowercased host — impersonation does not change the key), and every further call for that tenant fails LOCALLY — before the semaphore, the hourly limiter and fetch, with zero upstream calls — with a `RateLimitError` (`code: 'RATE_LIMITED_COOLDOWN'`, `notSent: true`, `retryAfter` = remaining seconds; no credentials appear in the message) until the deadline passes. Duration is `Retry-After` clamped to `cooldown.maxMs` (default 15 minutes), or `cooldown.defaultMs` (default 60 s) when the header is missing or unparseable; the thread-threshold 429 (vendor code `Itgenatr005`, which clears in seconds) arms only a short window: min(`Retry-After`, 10 s), default 5 s. A 2xx from a call that got through clears the gate early; expiry is lazy (the first call after the deadline deletes the entry and proceeds); a hostile `Retry-After` (huge, zero, negative, non-numeric) is clamped to the cap or falls back to the default, so a lock-out is always bounded. `thresholdInformation.get()` bypasses the gate so a throttled tenant can still read its own headroom (a 2xx from it clears the gate; a 429 re-arms it). The default store is the in-process in-memory map shared by every client instance (capped at 1024 entries, evicting the earliest deadline first — failing open); `cooldown: { enabled: false }` disables the gate and `cooldown.store` injects a custom `CooldownStore` (the interface leaves room for a shared/Redis-backed store). Gated calls emit an audit event marked `notSent` and do not count toward `rateLimit.perHour`. Limits worth knowing: the gate is per process — other processes and integrations on the same tenant are invisible to it (`thresholdInformation` is the only cross-integration signal); it can mask early recovery if the vendor window clears before `Retry-After`; a process that loads both the ESM and the CJS build gets two default stores (the default store is deliberately not anchored on `globalThis`) — inject one shared `CooldownStore` if you need cross-build sharing.
91
98
 
92
99
  Credential-shaped fields are redacted in audit payloads by default.
@@ -165,9 +172,13 @@ The issue-#1 projection helper as a standalone subpath
165
172
  (`import { markUntrusted } from 'node-autotask/untrusted'`) — ZERO runtime
166
173
  imports, so an MCP server (or any other consumer) can wrap vendor free text
167
174
  without pulling the SDK in. `wrapUntrusted(value)` returns
168
- `<untrusted_content>value</untrusted_content>` (idempotent — an already
169
- wrapped string is returned as-is; empty / non-string / null are untouched;
170
- the redaction marker `[REDACTED]` is never wrapped). `markUntrusted(record,
175
+ `<untrusted_content>value</untrusted_content>` (idempotent — an exactly
176
+ wrapped string, outer tags with a tag-free interior, is returned as-is;
177
+ empty / non-string / null are untouched; the redaction marker `[REDACTED]`
178
+ is never wrapped). Since issue #103 a pre-wrapped-LOOKING string whose
179
+ interior contains wrapper tags is neutralised and wrapped exactly once —
180
+ the old shape-based pass-through (which let vendor text pre-wrap itself and
181
+ skip neutralisation) is gone. `markUntrusted(record,
171
182
  fields)` deep-copies one record and wraps ONLY the named top-level fields
172
183
  (`UNTRUSTED_OPEN` / `UNTRUSTED_CLOSE` are exported for exactness).
173
184
  `deepMarkUntrusted(value, fields)` walks an arbitrary tree (objects, arrays,
@@ -194,6 +205,21 @@ NEVER the values). The report is typed + JSON-serialisable; its messages
194
205
  are fixed templates (status codes, machine codes, zone names/hosts, counts)
195
206
  — never a vendor body, never a credential.
196
207
 
208
+ ### 1.11 `write-outcome.ts` — write-outcome classifier (issue #104)
209
+
210
+ `classifyWriteOutcome(error: unknown): WriteOutcome` is the single documented,
211
+ tested primitive that answers the question every unattended writer must answer
212
+ after a failed write: did the write `fail` (definitely not applied — `notSent`
213
+ local refusals, pre-send SDK-side refusals with no wire answer, 4xx, 429, a 500
214
+ with a parseable non-empty vendor `errors[]`), is its outcome `unknown` (it may
215
+ have been applied — timeouts,
216
+ network errors, caller aborts, blocked edge redirects, 5xx without a parseable
217
+ `errors[]`, unreadable 2xx bodies), and is it safe to retry (`unknown` is
218
+ NEVER retryable). The Autotask vendor API has no idempotency-key support —
219
+ every mutating operation is non-idempotent on the vendor side, so an
220
+ `unknown` outcome is resolved by a read-back, never a blind resend. Full
221
+ contract + row table: section 2.2.
222
+
197
223
  ## 2. Error hierarchy
198
224
 
199
225
  Every class throws with its stable machine code; the table maps class →
@@ -216,7 +242,7 @@ status → what the vendor does that triggers it.
216
242
  | `RateLimitError` | `RATE_LIMITED` | rate_limit | 429 | hourly/secondly budget exceeded — `retryAfter` from `Retry-After` — retryable |
217
243
  | `RateLimitError` | `RATE_LIMITED_COOLDOWN` | rate_limit | — (call not sent) | per-tenant cooldown after a surfaced 429 (issue #6) — `notSent: true`, `retryAfter` = remaining seconds — not retryable before the deadline |
218
244
  | `ServerError` | `SERVER_ERROR` | server | 5xx | vendor 5xx — retryable — EXCEPT a deterministic field-validation 500 (a known phrase, `isDeterministicVendor500`): same class/code/status, `retryable: false` + `details.deterministic: true` (issue #23) |
219
- | `UnexpectedContentTypeError` | `UNEXPECTED_CONTENT_TYPE` | server | 2xx (non-JSON body + a non-JSON-ish `Content-Type`) | the edge serves a non-JSON page (maintenance) — the vendor never processed the request; `details = { httpStatus, contentType, bodySnippet (256 B) }` — retryable (advisory; idempotent methods retry per policy, a write makes ONE call — section 2.1) |
245
+ | `UnexpectedContentTypeError` | `UNEXPECTED_CONTENT_TYPE` | server | 2xx (non-JSON body + a non-JSON-ish `Content-Type`) | the edge serves a non-JSON page (maintenance) — the vendor never processed the request; `details = { httpStatus, contentType, bodySnippet (256 B) }` — retryable (advisory; the `#24` leg keeps the pre-#105 retriable set — an unflagged `PUT`/`DELETE` write CAN be auto-retried here, `POST`/`PATCH` make ONE call — section 2.1) |
220
246
  | `EmptyResponseError` | `EMPTY_RESPONSE` | server | 2xx (empty body, `expectBody`) | a blank 2xx on a call that expects a result (non-DELETE by default — section 2.1); `details = { httpStatus, method }` — retryable for non-DELETE (advisory: the loop never retries 2xx) |
221
247
  | `CircuitBreakerError` | `CIRCUIT_BREAKER_OPEN` | policy | — (call not sent) | the host circuit breaker (issue #26, opt-in) is open — refused LOCALLY before the request (zero upstream calls, no hourly stamp) — `notSent: true`, `retryAfter` = remaining open seconds — **retryable: true** (the deliberate divergence from #6's not-retryable cooldown refusal: the open period is bounded and advertised; section 3.2.2) |
222
248
  | `ResolutionError` | `RESOLUTION_AMBIGUOUS` / `RESOLUTION_TRUNCATED` / `NOT_FOUND` | resolution | — | `resolve`: multiple exact matches (candidates in `resourceIds`); a cap-tripped bounded client scan (`resolution.scanOnMiss` only; never a silent `null`); a numeric-id miss |
@@ -284,14 +310,17 @@ transport reads the header:
284
310
  | JSON-ish (`application/json*`, a plus-json suffix) | `INVALID_RESPONSE` (status quo — the #13/W-A7 shape-drift key; a wrong-shape 2xx is a shape-level failure, not a transport one) |
285
311
  | ABSENT | `INVALID_RESPONSE` (status quo — conservative; the maintenance-page premise carries a header) |
286
312
 
287
- Retry leg: a non-JSON 2xx on an IDEMPOTENT method (GET/PUT/DELETE + the
313
+ Retry leg: a non-JSON 2xx on a pre-#105 retriable call (GET/PUT/DELETE + the
288
314
  idempotent-flagged `/query` POSTs) retries per the existing policy (same
289
315
  `Retry-After` / backoff / wait-budget rules as the 5xx leg) — the maintenance
290
- page is served by the EDGE, so the vendor never processed the request. A
291
- non-idempotent call (a create) makes ONE call and throws at once; its
292
- `suggestedAction` states the write may have taken effect (the #13 residue
293
- concern — writes are never auto-retried). An `INVALID_RESPONSE` 2xx (JSON-ish
294
- or header-less) is never retried — a real contract break is not transient.
316
+ page is served by the EDGE, so the vendor never processed the request. This
317
+ leg DELIBERATELY keeps the legacy set — issue #105 flips only the 5xx and
318
+ network legs, so an unflagged `PUT`/`DELETE` write CAN be auto-resent here
319
+ (its `suggestedAction` still notes the write may have taken effect — the #13
320
+ residue concern; the residual tension with the #104 classifier, which pins
321
+ `UNEXPECTED_CONTENT_TYPE` as unknown, is tracked in issue #109). An unflagged `POST`/`PATCH` call (a create) makes ONE call
322
+ and throws at once. An `INVALID_RESPONSE` 2xx (JSON-ish or header-less) is
323
+ never retried — a real contract break is not transient.
295
324
  Zone discovery rides the same transport, so a startup failure during
296
325
  maintenance is recognisable by the code. `details.bodySnippet` is the first
297
326
  256 BYTES of the raw body — TRUNCATION ONLY (the name-based redaction is a
@@ -333,6 +362,64 @@ with types, required and `maxLength`). Any other 5xx body is byte-identical to b
333
362
  per policy, `retryable: true`) — the phrase list is a heuristic over wording
334
363
  seen on real responses; widen it only with new live evidence.
335
364
 
365
+ ### 2.2 The write-outcome classifier (issue #104)
366
+
367
+ `classifyWriteOutcome(error: unknown): WriteOutcome` (`src/write-outcome.ts`,
368
+ exported from the package root — `import { classifyWriteOutcome } from
369
+ 'node-autotask'`) answers the question every unattended writer must answer
370
+ after a failed write: did the write **fail** (definitely not applied), is its
371
+ outcome **unknown** (it may have been applied), and is it safe to **retry**?
372
+ Each consumer re-deriving the mapping from `notSent` / `httpStatus` /
373
+ `retryable` risks getting it wrong — duplicate or lost writes. This is the one
374
+ reviewed implementation, and the one place to update when vendor behaviour
375
+ changes.
376
+
377
+ ```ts
378
+ type WriteOutcome = {
379
+ state: 'failed' | 'unknown';
380
+ retryable: boolean;
381
+ code: string;
382
+ httpStatus?: number;
383
+ };
384
+ ```
385
+
386
+ Rows — evaluated in this order (`notSent` first, so a local 429 refusal that
387
+ carries `notSent: true` is row 1, not row 3):
388
+
389
+ | # | condition | state | retryable | why |
390
+ |---|-----------|-------|-----------|-----|
391
+ | 1 | `notSent === true` (the cooldown gate's / circuit breaker's local refusal) | `failed` | the error's own flag | the request never reached the vendor; the breaker's refusal retries the same call after the wait, the cooldown's is `retryable: false` (wait, then narrow the query first) |
392
+ | 2 | `httpStatus` in 400..499 except 429 | `failed` | `false` | the vendor answered; the write is definitely not applied |
393
+ | 3 | `httpStatus === 429` | `failed` | `true` | a pre-processing refusal — retry once the cooldown / Retry-After allows |
394
+ | 4 | `httpStatus === 500` and `parseVendorErrors(vendorError)` returns a non-empty array | `failed` | `false` | a deterministic business-rule rejection — the same body fails identically on retry |
395
+ | 5 | `code === 'TIMEOUT'` | `unknown` | `false` | the whole-call budget expired mid-flight |
396
+ | 6 | `code === 'NETWORK_ERROR'` | `unknown` | `false` | conservative — the SDK cannot prove whether the body reached the vendor |
397
+ | 7 | `code === 'ABORTED'` | `unknown` | `false` | the wire state at caller abort is unproven (the request may have been mid-flight) |
398
+ | 8 | `code === 'REDIRECT_BLOCKED'` | `unknown` | `false` | the request left the client; an edge redirected before any vendor answer |
399
+ | 9 | `httpStatus` 500..599 without a parseable non-empty `errors[]` | `unknown` | `false` | the vendor may have processed the write |
400
+ | 10 | `code` in { `INVALID_RESPONSE`, `UNEXPECTED_CONTENT_TYPE`, `EMPTY_RESPONSE` } (an unreadable 2xx body) | `unknown` | `false` | the vendor answered but the result is unreadable — the outcome is unprovable |
401
+ | 11 | a typed error with NO `httpStatus` that matches no row above (READ AS a pre-send LOCAL refusal: SDK-side validation / policy / confirmation / stale-object / boundary refusals, the client-side limiter) | `failed` | the error's own flag | the request never left the client — the write is DEFINITELY not applied; a limiter-style refusal frees later, a shape/policy refusal cannot be retried as-is. Note: a compound op can throw a status-less typed error AFTER an earlier send (e.g. `openWithNoteAndAssign`'s merge-read `NotFoundError`) — for a compound op resolve the outcome with a read-back instead of trusting `failed` |
402
+ | 12 | not an `AutotaskError` (or a typed error no row above recognised) | `unknown` | `false` | conservative; `code` from the typed error's machine `code`, else the `Error.name`, else `UNKNOWN` |
403
+
404
+ Invariants (pinned by `test/write-outcome.test.ts`): `unknown` implies
405
+ `retryable === false`; the classifier never throws on arbitrary input
406
+ (`null`, `undefined`, plain objects, plain `Error`, strings); it never
407
+ mutates the error.
408
+
409
+ **Non-idempotency (read before building a retry loop).** The Autotask vendor
410
+ API has NO idempotency-key support and no conditional-write header. ALL
411
+ mutating operations (every resource's create/update/delete, including the
412
+ composite write ops) are NON-IDEMPOTENT on the vendor side. An `unknown`
413
+ outcome MUST be resolved by a READ-BACK (GET the record, or query by a unique
414
+ field), never by a blind resend — a blind resend of an applied write
415
+ duplicates it.
416
+
417
+ **Stricter than the error's own flag.** The classifier's `retryable` is the
418
+ WRITER's decision surface and is intentionally STRICTER than the error's own
419
+ `retryable` (which also serves read-path transport policy — e.g. a plain 5xx
420
+ `ServerError` keeps `retryable: true` for the transport even though the
421
+ classifier says unknown / never-retry for a write).
422
+
336
423
  ## 3. Configuration reference
337
424
 
338
425
  ### 3.1 Zone catalog (23 zones)
@@ -364,7 +451,7 @@ Host form: `https://<name>.autotask.net/atservicesrest` (lowercase
364
451
  |---------|---------|-----|-------|
365
452
  | `pageSize` | 10 | 500 (vendor max) | page size for query walks; `maxRecords` on the wire |
366
453
  | `timeoutMs` | 30 000 | — | whole-call budget, incl. retries |
367
- | `maxRetries` | 3 | — | 429/5xx, idempotent requests only; 0 disables |
454
+ | `maxRetries` | 3 | — | the client's retry budget, overridable per call (`InvokeOptions.maxRetries` / `MutationOpts.maxRetries`; `0` = exactly one send per request the call performs). The budget caps two legs (issue #105): the 429 leg (retried on GET/PUT/DELETE + flagged calls — unchanged) and the 5xx/network leg (GET + flagged only) |
368
455
  | `maxConcurrent` | 10 | 10 (vendor) | concurrency guard |
369
456
  | `profile` | `library` | — | `library` = today's defaults (changes nothing); `agent` = the conservative `AGENT_PROFILE` limits for LLM-driven workloads (below). Resolution: explicit config > profile > library default |
370
457
  | `maxResponseBytes` | 67 108 864 (64 MiB) | — | largest response body read; above it the call throws `RESPONSE_TOO_LARGE` (non-retryable) |
@@ -814,8 +901,9 @@ the mode pinned (§7.4). The governor rules (pinned):
814
901
 
815
902
  Dispatch calls the SAME typed method the SDK publishes, with the caller's
816
903
  `args` in positional order; for writes the governed `{ dryRun,
817
- lastModifiedDate? }` bag is appended as the method's final `MutationOpts`
818
- argument. Every guard that path has (bounded scans, the stale check, the
904
+ lastModifiedDate?, maxRetries? }` bag is appended as the method's final
905
+ `MutationOpts` argument (`maxRetries` = the per-call retry budget — see the
906
+ §3.2 `maxRetries` row and the §2.2 classifier). Every guard that path has (bounded scans, the stale check, the
819
907
  dry-run envelope) is that path's — not a second implementation.
820
908
  `needsConfirmation(record)` exposes the refusal predicate.
821
909
 
@@ -824,8 +912,8 @@ dry-run envelope) is that path's — not a second implementation.
824
912
  The three MCP mode tools (`autotask_read` / `autotask_write` /
825
913
  `autotask_delete`) dispatch through the SAME router as `invoke` with the mode
826
914
  pinned (`InvokeMode = 'read' | 'write' | 'delete'`; the mode predicate is the
827
- registry `effect`, verified one-to-one: 926 read / 198 write / 54
828
- destructive of the 1178 ops). `invoke()` itself is UNCHANGED — the
915
+ registry `effect`, verified one-to-one: 961 read / 198 write / 54
916
+ destructive of the 1213 ops). `invoke()` itself is UNCHANGED — the
829
917
  programmatic escape hatch, its mode derived from the record, so it can never
830
918
  be cross-mode-refused.
831
919
 
@@ -841,7 +929,7 @@ be cross-mode-refused.
841
929
  and the `confirm === <op key>` gate (all 54 deletes; the 3 approval-gated
842
930
  writes — inside `autotask_write` too) apply exactly as in `invoke`.
843
931
  Pinned by the full-surface suite (`test/operations/invoke-modes.test.ts`:
844
- all 1168 ops × 3 modes, zero wire).
932
+ all 1213 ops × 3 modes, zero wire).
845
933
 
846
934
  **Harness approval policy (recommended).** Require approval on every tool
847
935
  whose MCP annotation is `readOnlyHint !== true` — today exactly **254
@@ -1093,7 +1181,7 @@ const fold = await aggregate(client, 'time_entries.query', [{ field: 'dateWorked
1093
1181
  ## 8. Resource surface (128 resources)
1094
1182
 
1095
1183
  <!-- BEGIN GENERATED:api-per-entity -->
1096
- Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page guarded), `queryBounded` (server-side pagination — one call walks the cursor until `limit` TOTAL records, default 500 / max 2500, with a `hasMore` flag; the MCP `query` surface, routed by `invoke`), `queryAll` (collect → `T[]` — the unbounded raw walk), `count`, `get` (id → record; `item: null` → `NotFoundError`), `create` / `update` / `delete` (where the vendor defines them — mutations take a `MutationOpts` bag: `{ dryRun, lastModifiedDate? }`), plus the helper floor where the plan declares it: `resolve` (all 121), `search` (40 — bounded contains on the resource text field), `getContext` (5 workflow resources), `findBy<Field>` (3), `createIfNotExists` (14). Entity metadata on every resource: `getEntityInfo`, `getFieldInfo`, `getUdfInfo` (UDF-capable 15). Parent-scoped children take `(parentId, id)` on the CRUD methods and require `parentId` on `createIfNotExists` (the scoped create POSTs the scoped collection; the dedup query stays global); `query`/`count`/`resolve`/`search` stay global (narrow with a `parentField` filter). Scoped `update` PATCHes the collection (the vendor keys the record by the id in the body).
1184
+ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page guarded), `queryBounded` (server-side pagination — one call walks the cursor until `limit` TOTAL records, default 500 / max 2500, with a `hasMore` flag; the MCP `query` surface, routed by `invoke`), `queryAll` (collect → `T[]` — the unbounded raw walk), `count`, `get` (id → record; `item: null` → `NotFoundError`), `create` / `update` / `delete` (where the vendor defines them — mutations take a `MutationOpts` bag: `{ dryRun, lastModifiedDate?, maxRetries? }` (issue #105: `maxRetries` overrides the configured retry budget for every request the mutation issues; `0` = exactly one send per request)), plus the helper floor where the plan declares it: `resolve` (all 121), `search` (40 — bounded contains on the resource text field), `getContext` (5 workflow resources), `findBy<Field>` (3), `createIfNotExists` (14). Entity metadata on every resource: `getEntityInfo`, `getFieldInfo`, `getUdfInfo` (UDF-capable 15). Parent-scoped children take `(parentId, id)` on the CRUD methods and require `parentId` on `createIfNotExists` (the scoped create POSTs the scoped collection; the dedup query stays global); `query`/`count`/`resolve`/`search` stay global (narrow with a `parentField` filter). Scoped `update` PATCHes the collection (the vendor keys the record by the id in the body).
1097
1185
 
1098
1186
  | resource key | apiEntity | kind | tier | parent | standard ops present | special ops | mcpTool |
1099
1187
  |--------------|-----------|------|------|--------|----------------------|-------------|---------|
@@ -218,7 +218,7 @@ Shutdown: SIGTERM (or Ctrl-C) — verified clean exit 0.
218
218
  | UNAUTHORIZED (401) | bad credentials, or AUTOTASK_ZONE not your tenant's zone | re-check the three exports verbatim against Admin > API User Security (in .env, SINGLE-QUOTE values containing $ — a shell-sourced double-quoted value expands $VAR and silently mangles the credential); confirm the zone with one `GET /V1.0/ZoneInformation?user=<username>` call (SDK: `client.zoneInformation.get()`) — every zone host forwards to the user's real zone |
219
219
  | FORBIDDEN (403) | the integration lacks the permission / impersonation not allowed | check Autotask admin settings; drop or fix AUTOTASK_IMPERSONATION_ID |
220
220
  | RATE_LIMITED (429) | vendor hourly/secondly budget (10k req/hour) | the SDK retries idempotent calls honoring Retry-After; if it persists, stop and wait |
221
- | SERVER_ERROR (5xx) / TIMEOUT / NETWORK_ERROR | vendor or flaky network | SDK retries GET/PUT/DELETE 3x (backoff + jitter); POST/PATCH never retried; re-run later |
221
+ | SERVER_ERROR (5xx) / TIMEOUT / NETWORK_ERROR | vendor or flaky network | post-#105 the SDK auto-retries only GET + idempotent-flagged calls 3x on 5xx/network (backoff + jitter); mutations (POST/PATCH/PUT/DELETE) make one send — read back / verify before resending; the 429 leg is unchanged (GET/PUT/DELETE + flagged retry per `Retry-After`) |
222
222
  | NOT_FOUND | id absent, or wrong tenant/zone | verify the id exists in this tenant; verify the zone |
223
223
  | CONFIG_ERROR | zone typo, bad param shape, a shape the SDK refuses | read the message — it names the fix (unknown zone lists the 23; a bare conditions array says: wrap as { filter: [...] }) |
224
224
  | PAGINATION_LIMIT | a raw queryAll-style walk exceeded 1000 pages | the live scripts never issue raw walks (all reads are queryBounded); if you see it, it is your own code — use queryBounded with a small limit |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-autotask",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Fully-typed TypeScript SDK for the Autotask (Datto) PSA REST API. Zero runtime dependencies. Built for building MCP servers and integrations.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -114,7 +114,7 @@
114
114
  "lint:fix": "eslint src test --fix",
115
115
  "verify": "npm run typecheck && npm run lint && npm run generate:check && npm run build && node scripts/check-capabilities.mjs --ship && npm run mcp:project:check && npm run n8n:project:check && npm run catalog:check && npm run mcp:check-surface && npm run public-surface && npm run verify:pack && npm test",
116
116
  "plan:derive": "node scripts/derive-plan.mjs",
117
- "generate:check": "node scripts/generate-batch.mjs --check",
117
+ "generate:check": "node scripts/generate-batch.mjs --check && node scripts/generate-batch.mjs --check --group B",
118
118
  "capabilities:build": "node scripts/generate-capabilities.mjs",
119
119
  "capabilities:check": "node scripts/check-capabilities.mjs",
120
120
  "clean": "rimraf dist coverage",