node-autotask 0.12.1 → 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 +4 -3
  3. package/MCP_TOOL_MANIFEST.md +5 -5
  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 +3537 -4144
  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-PPTAMCFA.js → chunk-2ZZOTJ2C.js} +2 -2
  13. package/dist/{chunk-36PG2IU2.cjs → chunk-5QMXECLN.cjs} +594 -574
  14. package/dist/{chunk-CSRCMBZ2.cjs → chunk-EWMG3HHU.cjs} +3536 -4143
  15. package/dist/{chunk-PGQIGE63.cjs → chunk-FCJUM42E.cjs} +135 -135
  16. package/dist/{chunk-P35GFG2H.js → chunk-IYN2BHVW.js} +172 -152
  17. package/dist/{chunk-NACI2NDF.js → chunk-JIDR3NPV.js} +3536 -4143
  18. package/dist/{chunk-ZOA5HUFX.js → chunk-KA6UU26H.js} +35 -23
  19. package/dist/{chunk-IHRXVIUU.cjs → chunk-XSIY3WRR.cjs} +40 -28
  20. package/dist/{index-DZfd9Gt7.d.cts → index-oiwPuJOG.d.cts} +100 -36
  21. package/dist/{index-UilX3PFL.d.ts → index-zt1KXThi.d.ts} +100 -36
  22. package/dist/index.cjs +62 -20
  23. package/dist/index.d.cts +83 -3
  24. package/dist/index.d.ts +83 -3
  25. package/dist/index.js +47 -5
  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 +146 -28
  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
  |--------------|-----------|------|------|--------|----------------------|-------------|---------|
@@ -2608,7 +2696,9 @@ the plan change — a new registry op — is a minor at publish). One call
2608
2696
  returns the contract's reconciled P&L over a date window:
2609
2697
 
2610
2698
  - **`contracts.profitability(contractId, opts)` — the closed opts bag.**
2611
- `dateFrom`/`dateTo` are required (`YYYY-MM-DD`, `dateTo >= dateFrom`) and
2699
+ `opts` itself is required (0.13.0 — the signature and the registry arg map
2700
+ agree; a call without it is a pre-request `CONFIG_ERROR` naming the bag and
2701
+ its `dateFrom`/`dateTo`). `dateFrom`/`dateTo` are required (`YYYY-MM-DD`, `dateTo >= dateFrom`) and
2612
2702
  the window is capped at 36 calendar months (the per-month windowing budget
2613
2703
  stays bounded; OQ1). `labourCostMethod` (default
2614
2704
  `billing_item_our_cost`; the closed 4-set
@@ -2627,7 +2717,8 @@ returns the contract's reconciled P&L over a date window:
2627
2717
  1..500) caps the ticket join set fed to the expenses + miscoding probe
2628
2718
  (sorted, first N, the `N of M` note) and the time-referenced ticket ids;
2629
2719
  `limit` (default 50, clamped 500) is
2630
- every part's `MaxRecords` + the capped lists. Validation collects the
2720
+ every part's `MaxRecords` + the capped lists; `includeRows` (default
2721
+ false, boolean) selects the output shape (below). Validation collects the
2631
2722
  COMPLETE problem list and throws ONE typed `CONFIG_ERROR` pre-request —
2632
2723
  zero wire on any malformed bag (the refusal table is house style: every
2633
2724
  message names the key + the valid values).
@@ -2745,13 +2836,17 @@ returns the contract's reconciled P&L over a date window:
2745
2836
  lands in its start month in one lump) ·
2746
2837
  `revenue_expected_unbilled = max(0, expected − the month's invoiced
2747
2838
  SERVICE lines)` (labour and charges never mask a missed recurring
2748
- invoice) · `unposted_time { entries, hours, value, absent? }` (billable
2749
- time with no billing item yet — derived from the billing join, never
2750
- getPosted/getUnposted; `value` = Σ `hoursToBill ×
2839
+ invoice) · `unposted_time { entries, hours, hoursAbsent?, value, absent? }`
2840
+ (billable time with no billing item yet — derived from the billing join,
2841
+ never getPosted/getUnposted; `hours` = Σ `max(0, hoursToBill)`, or `null`
2842
+ + `hoursAbsent: 'HOURS_TO_BILL_MISSING'` when any of the month's unposted
2843
+ entries carries no numeric `hoursToBill` — unknown, never a summed 0; a
2844
+ deliberate `hoursToBill: 0` stays 0; `value` = Σ `hoursToBill ×
2751
2845
  internalCurrencyContractHourlyRate` (fallback `contractHourlyRate`) on
2752
2846
  Time & Materials contracts only, else `null` + `absent:
2753
2847
  'NOT_TIME_AND_MATERIALS_CONTRACT'`, `'CONTRACT_TYPE_UNKNOWN'`, or
2754
- `'NO_CONTRACT_RATE'` when a role has no contract rate) · `labour_hours`
2848
+ `'NO_CONTRACT_RATE'` when a role has no contract rate, or
2849
+ `'HOURS_TO_BILL_MISSING'` on a T&M contract when the hours are unknown) · `labour_hours`
2755
2850
  (Σ `hoursWorked` over positive entries — the same rule as the cost) ·
2756
2851
  `labour_cost { total, unresolvedHours, rateSources }` (the per-month
2757
2852
  mirror of the ladder audit) · `tooling_cost { vendor, caller_supplied }`
@@ -2854,6 +2949,25 @@ returns the contract's reconciled P&L over a date window:
2854
2949
  rows with the divisions refused (`margin: null`, `break_even_hours:
2855
2950
  null`, `tooling_cost.vendor: null`, `revenue_expected: null`), all 11
2856
2951
  warnings at `count: 0`, zero tool failure.
2952
+ - **Summary-first output (0.13.0).** By default each of the 16 per-part
2953
+ containers (`contractServices` … `subscriptions`) is a
2954
+ `ProfitabilityPartDigest`: every stamp (`status`, `capped`, `hasMore`,
2955
+ `note`, `blindSurface`, `emptyReason`, `absent`) plus `rows_fetched` (the
2956
+ row count), WITHOUT the raw `items` array. Every computed figure is
2957
+ returned either way — `monthly` (incl. `coverage_gaps`), `contract_type`,
2958
+ `included_hours`, `currency`, the activity/costliest-ticket/miscoding/
2959
+ renewal folds, `data_quality_warnings`, `costRateResolution`, `parts`,
2960
+ `wires` — as are the `contract` and `customer` records. `includeRows: true`
2961
+ returns the full `ProfitabilityPart<T>` containers (the pre-0.13.0 shape,
2962
+ `items` included, no `rows_fetched`); the wire is identical in both modes.
2963
+ The part fields are typed `ProfitabilityPart<T> | ProfitabilityPartDigest`
2964
+ (both exported from the package root with the other option/result types):
2965
+ narrow with `'items' in part` before reading rows, e.g.
2966
+ `const rows = 'items' in res.billingItems ? res.billingItems.items : [];`.
2967
+ On the 2-month test fixture the serialised result is 8,657 bytes without
2968
+ rows vs 15,060 with them (−43%); the fixture rows are trimmed to a few
2969
+ fields, so real vendor rows over a 12-month window make the rows the bulk
2970
+ of the payload and the saving much larger.
2857
2971
  - **0.12.1 corrections.** The 0.12.0 folds counted the charge
2858
2972
  `billableAmount` (sell price) as recurring cost, read service `units` as
2859
2973
  included hours, joined every company CI's subscriptions and added a
@@ -2875,14 +2989,18 @@ returns the contract's reconciled P&L over a date window:
2875
2989
  subset is ticket/contact/company/resource/time_entry only).
2876
2990
  - **Test pins.** `test/resources/contracts.profitability.test.ts`: the
2877
2991
  hand-computed 2-month synthetic fixture (every figure derived from the
2878
- fixture rows; `wires.issued = 18` with the exact queue; every wire pinned
2992
+ fixture rows; `wires.issued = 20` with the exact queue; every wire pinned
2879
2993
  in issue order; the structural exclusions + zero-mutating recorded paths;
2880
2994
  the ladder audit incl. the per-month `rateSources`; the 11 checks with
2881
2995
  exact counts/samples; the activity/highest-cost/miscoding/renewal folds;
2882
2996
  the per-month windowing (exact extra month queries, id-dedup, the
2883
2997
  still-full month note; `wires.issued` counts the expansion); the
2884
2998
  join-set/`limit` caps; the malformed-bag refusal table (one complete
2885
- problem list, zero wire); the contract NOT_FOUND; the 0.12.1 pins: the
2999
+ problem list, zero wire); the contract NOT_FOUND; the summary-first pins
3000
+ (the default digest = the full container minus `items` plus the
3001
+ hand-counted `rows_fetched` per part, every computed figure equal across
3002
+ modes, a non-boolean `includeRows` and a missing opts bag refused with
3003
+ zero wire); the 0.12.1 pins: the
2886
3004
  charge cost/period-type/line-type classifiers, the Block Hours
2887
3005
  `contract_blocks` path, T&M unposted pricing + the time-referenced old
2888
3006
  ticket, the exclusive window ends, the month-level `coverage_gaps`).
@@ -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.12.1",
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",