node-autotask 0.8.3 → 0.10.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.
- package/ARCHITECTURE.md +13 -10
- package/MCP_TOOL_CATALOG.json +733 -251
- package/MCP_TOOL_MANIFEST.md +162 -133
- package/MCP_TOOL_OVERRIDES.json +2 -2
- package/N8N_MCP_MANIFEST.json +88 -25
- package/N8N_MCP_MANIFEST.md +26 -24
- package/README.md +12 -4
- package/capabilities.json +12503 -9316
- package/dist/capabilities.cjs +2 -3
- package/dist/capabilities.js +1 -2
- package/dist/{chunk-4WJCXIY2.js → chunk-3VB6N44U.js} +1141 -2977
- package/dist/{chunk-32AKZMY4.js → chunk-6KFP5HV3.js} +12502 -9316
- package/dist/chunk-DMMMWO6A.js +0 -0
- package/dist/chunk-FLB2X6O5.cjs +1 -0
- package/dist/{chunk-GYI4ZQID.js → chunk-HWC2WPX2.js} +4781 -89
- package/dist/chunk-IDPB4N2Q.cjs +1 -0
- package/dist/{chunk-F6YSQRZV.js → chunk-NVJ2PTN3.js} +7 -7
- package/dist/{chunk-5UGSQLLO.cjs → chunk-QUM74WLQ.cjs} +136 -136
- package/dist/{chunk-LAKEIYET.cjs → chunk-S7VMY5KN.cjs} +1468 -3304
- package/dist/chunk-UPQIUWOS.js +0 -0
- package/dist/{chunk-KNTUX3RR.cjs → chunk-WTVD2JZN.cjs} +12502 -9316
- package/dist/{chunk-V3XPLTIR.cjs → chunk-WTVLHMZA.cjs} +4784 -92
- package/dist/{index-B6JeThrY.d.ts → index-BOyh7S65.d.cts} +870 -79
- package/dist/{index-B0I2bGtx.d.cts → index-DGgMD8aq.d.ts} +870 -79
- package/dist/{index-DOCRp_83.d.cts → index-cQ6CRexR.d.cts} +158 -90
- package/dist/{index-DOCRp_83.d.ts → index-cQ6CRexR.d.ts} +158 -90
- package/dist/index.cjs +37 -22
- package/dist/index.d.cts +46 -5
- package/dist/index.d.ts +46 -5
- package/dist/index.js +48 -33
- package/dist/operations/index.cjs +480 -359
- package/dist/operations/index.d.cts +195 -16
- package/dist/operations/index.d.ts +195 -16
- package/dist/operations/index.js +370 -249
- package/dist/pool.cjs +36 -12
- package/dist/pool.d.cts +2 -3
- package/dist/pool.d.ts +2 -3
- package/dist/pool.js +33 -9
- package/dist/resources/index.cjs +5 -6
- package/dist/resources/index.d.cts +2 -2
- package/dist/resources/index.d.ts +2 -2
- package/dist/resources/index.js +4 -5
- package/dist/types/index.cjs +1 -2
- package/dist/types/index.d.cts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.js +1 -2
- package/dist/untrusted.cjs +0 -1
- package/dist/untrusted.js +0 -1
- package/docs/API.md +693 -48
- package/docs/N8N-ADAPTER.md +4 -4
- package/examples/basic.ts +1 -1
- package/package.json +5 -3
- package/dist/capabilities.cjs.map +0 -1
- package/dist/capabilities.js.map +0 -1
- package/dist/chunk-32AKZMY4.js.map +0 -1
- package/dist/chunk-4WJCXIY2.js.map +0 -1
- package/dist/chunk-5UGSQLLO.cjs.map +0 -1
- package/dist/chunk-CXM35TEC.cjs +0 -1
- package/dist/chunk-CXM35TEC.cjs.map +0 -1
- package/dist/chunk-F6YSQRZV.js.map +0 -1
- package/dist/chunk-GIVD2CQ3.js +0 -1
- package/dist/chunk-GIVD2CQ3.js.map +0 -1
- package/dist/chunk-GYI4ZQID.js.map +0 -1
- package/dist/chunk-KNTUX3RR.cjs.map +0 -1
- package/dist/chunk-LAKEIYET.cjs.map +0 -1
- package/dist/chunk-MHBMSBTA.js +0 -1
- package/dist/chunk-MHBMSBTA.js.map +0 -1
- package/dist/chunk-NZUFSSIZ.cjs +0 -1
- package/dist/chunk-NZUFSSIZ.cjs.map +0 -1
- package/dist/chunk-V3XPLTIR.cjs.map +0 -1
- package/dist/client-DHmr0Oyx.d.ts +0 -203
- package/dist/client-Q6NoqinP.d.cts +0 -203
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/operations/index.cjs.map +0 -1
- package/dist/operations/index.js.map +0 -1
- package/dist/pool.cjs.map +0 -1
- package/dist/pool.js.map +0 -1
- package/dist/resources/index.cjs.map +0 -1
- package/dist/resources/index.js.map +0 -1
- package/dist/types/index.cjs.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/untrusted.cjs.map +0 -1
- package/dist/untrusted.js.map +0 -1
package/docs/API.md
CHANGED
|
@@ -37,7 +37,7 @@ const autotask = new AutotaskClient({
|
|
|
37
37
|
// maxRetries: 3,
|
|
38
38
|
// maxConcurrent: 10,
|
|
39
39
|
// rateLimit: { perHour: 10_000 }, // perHour: null = explicitly disabled
|
|
40
|
-
// resolution: { maxScanRecords: 500, maxScanPages: 4 },
|
|
40
|
+
// resolution: { maxScanRecords: 500, maxScanPages: 4, scanOnMiss: false },
|
|
41
41
|
// logger: console,
|
|
42
42
|
// onAudit: (event) => { /* one event per completed request */ },
|
|
43
43
|
});
|
|
@@ -117,8 +117,9 @@ server; a blank 2xx on a call that expects a result; `details = { httpStatus,
|
|
|
117
117
|
method }`) — both classified and documented in section 2.1.
|
|
118
118
|
`isDeterministicVendor500(entries)` (over the `parseVendorErrors` output) is
|
|
119
119
|
the documented HEURISTIC phrase matcher for deterministic vendor
|
|
120
|
-
field-validation 500s (`exceeds maximum length`
|
|
121
|
-
wording seen in real responses
|
|
120
|
+
field-validation 500s (`exceeds maximum length`, `Missing Required Field` and `Value does not
|
|
121
|
+
exist for the required field` — it holds only wording seen in real responses; the last two were
|
|
122
|
+
live-verified on `company_notes.create`): it drives the transport's no-retry decision
|
|
122
123
|
and the `errorFromStatus` override that marks a matching 500 permanent
|
|
123
124
|
(`retryable: false`, `details.deterministic: true`, a field-catalog
|
|
124
125
|
`suggestedAction`) while keeping the `ServerError` class + `SERVER_ERROR`
|
|
@@ -144,7 +145,13 @@ served payload verbatim).
|
|
|
144
145
|
`buildFilter(conditions)` wraps caller conditions (multiple top-level
|
|
145
146
|
conditions are `and`-wrapped); `validateFilter` enforces the vendor caps
|
|
146
147
|
(section 5 below). `buildQueryRequest` produces the `QueryModel` wire body
|
|
147
|
-
(`{ MaxRecords, IncludeFields?, filter }`).
|
|
148
|
+
(`{ MaxRecords, IncludeFields?, filter }`). `normalizedConditions` is the
|
|
149
|
+
shared filter-shape gate of the query family (every query-family entry
|
|
150
|
+
point routes through it pre-request): the `filter` param accepts a
|
|
151
|
+
conditions array or `{ filter: [...] }`, and `undefined`/`null` are the
|
|
152
|
+
unfiltered call; any other defined shape is REFUSED pre-request with a
|
|
153
|
+
typed `AutotaskConfigError` (zero wire) — never silently dropped.
|
|
154
|
+
|
|
148
155
|
|
|
149
156
|
### 1.8 `logger.ts` + `utils/`
|
|
150
157
|
|
|
@@ -196,6 +203,8 @@ status → what the vendor does that triggers it.
|
|
|
196
203
|
|-------|------|----------|--------|----------------|
|
|
197
204
|
| `AutotaskError` | `AUTOTASK_ERROR` / `HTTP_<status>` | server | unmapped | any non-mapped HTTP status |
|
|
198
205
|
| `AutotaskConfigError` | `CONFIG_ERROR` | validation | — (client-side) | fail-closed constructor validation; unknown `invoke` operation; cross-mode `invoke` refusal (the mode tools); bad zone/baseUrl; missing client accessor |
|
|
206
|
+
| `AutotaskConfigError` | `LATEST_THRESHOLD_UNRESOLVED` | policy | — (no call beyond the 64 count probes) | `queryBounded latest`: the id-threshold count probes exhausted the 64-call budget without resolving a window — `suggestedAction` names the date-window narrowing (issue #59) |
|
|
207
|
+
| `AutotaskConfigError` | `LATEST_WINDOW_INCOMPLETE` | policy | — (the walk refuses to continue) | `queryBounded latest`: the walk truncated (the limit with a live next cursor, or the 1000-page guard) — a truncated enumeration's top-N-by-id is unprovable, so the op refuses instead of a possibly-wrong answer — `suggestedAction` names the date-window narrowing (issue #59) |
|
|
199
208
|
| `AutotaskNetworkError` | `NETWORK_ERROR` | network | — | `fetch` failure (DNS, connection reset) — retryable |
|
|
200
209
|
| `AutotaskTimeoutError` | `TIMEOUT` | timeout | — | whole-call budget (`timeoutMs`) exceeded — retryable |
|
|
201
210
|
| `BadRequestError` | `BAD_REQUEST` | validation | 400 | malformed request/filter; the vendor body's field errors are parsed into `vendorError` |
|
|
@@ -210,7 +219,7 @@ status → what the vendor does that triggers it.
|
|
|
210
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) |
|
|
211
220
|
| `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) |
|
|
212
221
|
| `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) |
|
|
213
|
-
| `ResolutionError` | `RESOLUTION_AMBIGUOUS` / `RESOLUTION_TRUNCATED` / `NOT_FOUND` | resolution | — | `resolve`: multiple exact matches (candidates in `resourceIds`); a cap-tripped bounded client scan (never a silent `null`); a numeric-id miss |
|
|
222
|
+
| `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 |
|
|
214
223
|
| `PaginationLimitError` | `PAGINATION_LIMIT` | policy | — | a page walk that would exceed the 1000-page guard (`suggestedAction` names the tighter filter) |
|
|
215
224
|
| `QueryTooLongError` | `QUERY_TOO_LONG` | policy | — | caller-requested `via: 'get'` whose serialized URL would exceed the 2048-char GET cap — the SDK never silently degrades to POST |
|
|
216
225
|
| `StaleObjectError` | `STALE_OBJECT` | conflict | — | the caller-supplied `lastModifiedDate` no longer matches the vendor's current value (no `If-Match`/ETag exists) |
|
|
@@ -319,8 +328,8 @@ upstream call (it breaks out before the retry decision — the parsed body is
|
|
|
319
328
|
already in hand at the retry site), and the mapped error keeps the
|
|
320
329
|
`ServerError` class + `SERVER_ERROR` code + status 500 but carries
|
|
321
330
|
`retryable: false`, `details.deterministic: true`, and a deterministic
|
|
322
|
-
`suggestedAction`
|
|
323
|
-
with `maxLength`). Any other 5xx body is byte-identical to before (retried
|
|
331
|
+
`suggestedAction` pointing at `operations.fieldCatalog` (the field catalog
|
|
332
|
+
with types, required and `maxLength`). Any other 5xx body is byte-identical to before (retried
|
|
324
333
|
per policy, `retryable: true`) — the phrase list is a heuristic over wording
|
|
325
334
|
seen on real responses; widen it only with new live evidence.
|
|
326
335
|
|
|
@@ -364,10 +373,12 @@ Host form: `https://<name>.autotask.net/atservicesrest` (lowercase
|
|
|
364
373
|
| `circuitBreaker` | off | — | opt-in per-host circuit breaker (issue #26): `failureThreshold` (5) counted failures inside `failureWindowMs` (10 s) open the circuit for `openTimeoutMs` (30 s); `successesToClose` (2) probe successes close it; `halfOpenProbes` (default `= successesToClose`; validated `>= successesToClose`) bounds the half-open probes. Key = the host of the base URL (host-only — on a shared host, one tenant's outage opens the circuit for that host's other tenants; section 3.2.2). `thresholdInformation.get()` bypasses the refusal (the outcome is still recorded); `store` / `isFailure` inject the store / the failure predicate |
|
|
365
374
|
| `resolution.maxScanRecords` | 500 | — | bounded client-scan cap (section 7, `resolve`) |
|
|
366
375
|
| `resolution.maxScanPages` | 4 | — | bounded client-scan cap |
|
|
376
|
+
| `resolution.scanOnMiss` | `false` | — | re-check a bare-value `resolve` miss with the bounded client scan (exact match on the search fields). Off: the server filter's zero items is a complete non-match (`null`, one call) |
|
|
367
377
|
| `logger` | `NoopLogger` | — | request log sink |
|
|
368
378
|
| `redactPersonalData` | `true` | — | response redaction for PII + confidential fields (section 10); `false` = raw personal data, secrets still redacted |
|
|
369
|
-
| `cache` |
|
|
370
|
-
| `fieldCache` |
|
|
379
|
+
| `cache` | on | — | `{ ttlMs?, perResourceTtlMs? }` read-through cache for get-by-id and `resolve` (issue #80: default-on — absent = the default instance, 5 min + the default per-resource table; `false` = off, the uncached path bit-for-bit). Snapshot at fetch time; a real write through the same client clears the cache and mutation-path reads bypass it. `perResourceTtlMs` (issue #32): a resource plan key -> ms map — a named resource expires on its own TTL, every other resource falls back to `ttlMs`; operator entries OVERRIDE the plan-derived default table `DEFAULT_PER_RESOURCE_TTL_MS` (`src/cache-ttl-tables.ts` — reference class 1h vs default class 5min), which IS the default map (applied by default since #80; section 11.15) |
|
|
380
|
+
| `fieldCache` | on | `maxEntities` 256 | `{ ttlMs?, maxEntities? }` live field-metadata channel for the entityInformation trio (`getEntityInfo` / `getFieldInfo` / `getUdfInfo`) (issue #80: default-on — absent = the default instance, 5 min; `false` = the uncached wire path, bit-for-bit) — see section 11.14. With it `false` the trio is the uncached wire path, bit-for-bit. Live reads are snapshots at fetch time; a vendor drift (a deterministic validation 500, or an INVALID_RESPONSE on a metadata read) invalidates the named entity's entries; a record write never invalidates (it says nothing about the field catalog). Empty results are never cached; concurrent callers share one in-flight request |
|
|
381
|
+
| `resourceNameCache` | on | `maxEntries` 1024 | `{ ttlMs?, maxEntries? }` resource-name channel for `operations.expandResourceNames` (issue #65; issue #80: default-on — absent = the default instance, 1 h; `false` = the uncached per-id path) — see section 11.21. With it `false` the op is the uncached per-id path (per-call de-duplication still applies). Live names are snapshots at fetch time; a `resources` write through the same client clears the channel, writes elsewhere show up once `ttlMs` expires. Failures are never cached; a vendor true not-found IS cached as a blind marker (a repeat miss costs no wire) |
|
|
371
382
|
| `update` | off | — | `{ putFallback?: boolean }` opt-in PUT item-path fallback for a 404'd non-collectionUpdate update route (issue #34) — see section 11.16. A TYPED 404 retries ONCE as `PUT /{Entity}/{id}` with the caller's fields merged onto a fresh unredacted read (never a null-clearing partial); collectionUpdate routes, `raw: true` updates and every non-404 fault are never retried — a declined gate throws the self-explaining error naming the route, the status and the opt-in |
|
|
372
383
|
| `reResolveZoneOn401` | off | — | opt-in zone re-resolution on 401 (issue #43) — see section 11.19. A 401 on a relative path against a CATALOG base makes ONE unauthenticated vendor zone-discovery call; if the tenant's zone base has moved, the client re-points its live base URL (process-lifetime, observable via `http.baseUrl`) and retries the SAME request ONCE. An explicit `baseUrl` and an absolute path are never re-pointed; a resolved base identical to the live one, or a discovery fault, propagates today's typed 401 untouched (zero retry); a retry that 401s again carries `details.zoneRecovery = { from, to, retried: true }` |
|
|
373
384
|
| `hostPolicy` | off | — | opt-in config-time HOST BOUNDS (skill 3.5.0 / L2) — see section 11.20. `{ blockPrivateHosts?: boolean; allowedOrigins?: readonly string[] }`: the RESOLVED base URL (explicit `baseUrl` or zone-derived) must pass every ACTIVE bound at construction time, pre-wire; a violation throws the typed `HostPolicyDeniedError` (`HOST_POLICY_DENIED`, category `policy`, `retryable: false`) naming the offending origin. `blockPrivateHosts: true` refuses a LITERAL private / loopback / link-local host (IPv4 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, 0/8; IPv6 `::1`, `fc00::/7`, `fe80::/10`, IPv4-mapped `::ffff:a.b.c.d`); `allowedOrigins` is an EXACT normalised-origin allow-list. Checks run on the canonical `new URL(origin).host` — HOSTNAMES are never blocked (no DNS lookups); the private bound is checked FIRST and WINS; a present block with no active bound is refused fail-closed; with `reResolveZoneOn401: true` the same bounds gate the discovery-provided re-point target (a refused host is a discovery fault) |
|
|
@@ -526,11 +537,16 @@ List reads are `GET|POST /V1.0/<E>/query` returning the pinned envelope:
|
|
|
526
537
|
|
|
527
538
|
```json
|
|
528
539
|
{ "items": [ "<Model>", ... ],
|
|
529
|
-
"pageDetails": { "count": 10, "requestCount":
|
|
530
|
-
"prevPageUrl": null, "nextPageUrl":
|
|
540
|
+
"pageDetails": { "count": 10, "requestCount": 10,
|
|
541
|
+
"prevPageUrl": null, "nextPageUrl": "<cursor>" } }
|
|
531
542
|
```
|
|
532
543
|
|
|
533
|
-
`
|
|
544
|
+
`count` = records on this page. `requestCount` is NOT a match count: the
|
|
545
|
+
vendor echoes the `MaxRecords` the request asked for (500 when omitted) —
|
|
546
|
+
live-verified: `MaxRecords: 5` over 13,220 matches → `requestCount: 5`; a
|
|
547
|
+
zero-match page with `MaxRecords: 10` → `requestCount: 10`. The vendor
|
|
548
|
+
publishes no total on a page; the only authoritative total is `count()`
|
|
549
|
+
(`POST /V1.0/<E>/query/count`). Whether more rows exist is `nextPageUrl`.
|
|
534
550
|
Single-record reads (`GET /V1.0/<E>/{id}`) and count/entityInfo reads never
|
|
535
551
|
carry paging parameters.
|
|
536
552
|
|
|
@@ -539,8 +555,12 @@ carry paging parameters.
|
|
|
539
555
|
later pages POST the preserved filter to the ABSOLUTE `nextPageUrl`, rebased
|
|
540
556
|
to the client base URL (same-origin cursors verbatim; cross-origin re-based —
|
|
541
557
|
the node's custom-zone behaviour).
|
|
542
|
-
- `Page<T>`: `{ items, page (1-based), pageSize, requestCount
|
|
543
|
-
|
|
558
|
+
- `Page<T>`: `{ items, page (1-based), pageSize, requestCount (the vendor
|
|
559
|
+
value verbatim — the MaxRecords echo, not a total), hasMore (nextPageUrl
|
|
560
|
+
!== null), totalPages }`. `totalPages` is the pages KNOWN so far —
|
|
561
|
+
`page + 1` while `hasMore`, else `page` (`page - 1` for an empty page after a full one; 0 for an empty result): a lower
|
|
562
|
+
bound mid-walk, exact on the last page. For a real page count use
|
|
563
|
+
`totalPagesFor(await r.count(filter), pageSize)`.
|
|
544
564
|
- **Auto-stop** on a short/empty page (or a null cursor, which is `hasMore:
|
|
545
565
|
false`).
|
|
546
566
|
- **1000-page guard:** a walk that would fetch a 1001st page throws
|
|
@@ -563,6 +583,72 @@ carry paging parameters.
|
|
|
563
583
|
when the limit or the guard truncated the walk and more records exist past
|
|
564
584
|
the stop point; `pageDetails.count` is never surfaced (it is the current
|
|
565
585
|
page's record count — `<resource>.count` is the total).
|
|
586
|
+
- **`latest: N` (issue #59) — the N highest-`id` rows without caller paging.**
|
|
587
|
+
The vendor has NO sort parameter (the vendored spec's `QueryModel` is
|
|
588
|
+
exactly `maxRecords` / `includeFields` / `filter`), so `latest` is a
|
|
589
|
+
COUNT-ANCHORED mechanism — the `id` primary key is the only order the SDK
|
|
590
|
+
can prove, and `id` rides every row (the `includeFields` id-append,
|
|
591
|
+
section 5 — `latest` DEPENDS on it). ONE rowless count probe decides the
|
|
592
|
+
mode: `C <= 2500` → the walk of the matching set is COMPLETE (1 count +
|
|
593
|
+
≤5 walk pages, wire `1 + ceil(C/500) <= 6`, independent of table size) —
|
|
594
|
+
all `C` rows are collected, sorted id-descending, and the top `N`
|
|
595
|
+
returned (a set of `C < N` returns itself); `C > 2500` → a full walk
|
|
596
|
+
would be truncated and could not prove "highest id", so the SDK finds the
|
|
597
|
+
smallest id threshold `T` with `count(F ∧ id gt T) <= N` by exponential
|
|
598
|
+
bracketing + binary search (ONE rowless count call per probe; the total
|
|
599
|
+
budget is 64 probes — exhaustion throws the typed
|
|
600
|
+
`LATEST_THRESHOLD_UNRESOLVED`) and walks the window `F ∧ id gte T`
|
|
601
|
+
(≤6 pages) — the window holds exactly `N+1` rows (ids are unique primary
|
|
602
|
+
keys), so its top `N` ARE the `N` highest overall. `hasMore` is `false`
|
|
603
|
+
on a complete `latest` answer (complete FOR THE REQUEST — the table may
|
|
604
|
+
hold more rows; `<resource>.count` is the total). On a plan-pinned BLIND
|
|
605
|
+
surface the count cannot be trusted (a blind 0 proves nothing): the walk
|
|
606
|
+
runs up to `limit` (default 500) instead — a completing walk returns the
|
|
607
|
+
sorted top-N WITH the blind-surface sentinel stamp (the "highest id"
|
|
608
|
+
claim is scoped to the caller-visible enumeration), and a TRUNCATED walk
|
|
609
|
+
(the limit reached with a live next cursor, or the 1000-page guard)
|
|
610
|
+
throws the typed `LATEST_WINDOW_INCOMPLETE` — a truncated enumeration's
|
|
611
|
+
top-N-by-id is unprovable, so the op refuses instead of returning a
|
|
612
|
+
possibly-wrong answer. Both refusals carry a `suggestedAction` naming the
|
|
613
|
+
escape hatch: narrow with a **date window in `filter`** (or a smaller
|
|
614
|
+
`latest`) so the matching set fits the `limit` / the 2500-row full-set
|
|
615
|
+
mode — a date window is never REQUIRED, it is the natural narrowing that
|
|
616
|
+
makes everything cheap. `limit` and `latest` may coexist only when
|
|
617
|
+
`limit >= latest` (above that the returned count is governed by
|
|
618
|
+
`latest`; `limit` is the blind-branch walk window). `latest` is
|
|
619
|
+
query-verb-ONLY (`queryAll` / `count` bags are unchanged).
|
|
620
|
+
- **`noteChars: N` (issue #61) — the opt-in per-field free-text cap.**
|
|
621
|
+
`N` (integer >= 1, pre-request validated, zero wire) caps EVERY
|
|
622
|
+
string value in the resource's untrusted-class free-text fields
|
|
623
|
+
(notes/descriptions/UDF prose — the generated
|
|
624
|
+
`UNTRUSTED_FIELDS_BY_RESOURCE` set, the SAME plan column the
|
|
625
|
+
registry's `metadata.untrustedFields` channel stamps and the server
|
|
626
|
+
wraps). Each capped value becomes the first `N` chars + the explicit
|
|
627
|
+
marker `… [+N chars truncated]` (N = chars removed; deterministic —
|
|
628
|
+
no timestamps). The truncation runs at result-build — BEFORE the
|
|
629
|
+
server-side untrusted wrapper (the src/untrusted.ts contract: the
|
|
630
|
+
caller truncates first, then wraps; the marker rides INSIDE the value,
|
|
631
|
+
so the wrapper wraps the truncated value + marker) — and is
|
|
632
|
+
response-side only (zero wire). ABSENT = the raw, untruncated result
|
|
633
|
+
bit-for-bit; the additive `noteCharsTruncated` result field counts the
|
|
634
|
+
truncated fields and is present ONLY when > 0. The cap is
|
|
635
|
+
query-verb-ONLY (`queryAll` / `count` bags are unchanged; the raw
|
|
636
|
+
`.query()` walker is uncapped — section 8's `queryBounded` is the MCP
|
|
637
|
+
query surface the issue targets).
|
|
638
|
+
- **`fieldsPreset: name` (issue #61) — the closed includeFields
|
|
639
|
+
presets.** The named preset is resolved PRE-WALK to its `includeFields`
|
|
640
|
+
list (the vendor's own column selection rides the existing `IncludeFields`
|
|
641
|
+
wire path; `id` is always appended by the id-append — zero extra wire).
|
|
642
|
+
v1 ships the one preset the issue names: `billing_join` on
|
|
643
|
+
`billing_items` (the 14 join/measure columns — section 11.23). The
|
|
644
|
+
closed-bag refusals are pre-request (zero wire): unknown preset name
|
|
645
|
+
(the valid names are named), preset/resource mismatch (the preset and
|
|
646
|
+
its resource are named), and `fieldsPreset` + an explicit
|
|
647
|
+
`includeFields` in the same bag (both are named — no silent
|
|
648
|
+
precedence). The preset FIELD EXISTENCE is verified against the LIVE
|
|
649
|
+
field catalogue (the live-smoke check — the review correction's
|
|
650
|
+
mandate; the vendored spec is a non-authoritative guard only), not
|
|
651
|
+
against `MODEL_FIELD_SETS`.
|
|
566
652
|
- Raw SDK reads are unchanged: `queryAll` (deterministic plain `T[]`,
|
|
567
653
|
unbounded, 1000-page guarded) and `query` (stream, `for await ...`). The
|
|
568
654
|
MCP surface steers the caller to the bounded `query` op; `queryAll` is for
|
|
@@ -585,8 +671,23 @@ Vendor filter structure (swagger `Filter`, recursive):
|
|
|
585
671
|
contains, exist, notExist, in, notIn` + grouping `and`, `or`. **There is no
|
|
586
672
|
`not` grouping operator** (G1 pinned): negation is expressed with
|
|
587
673
|
`noteq`/`notExist`/`notIn`.
|
|
674
|
+
- `gt` / `gte` / `in` on `id` are the SDK's own paging/threshold mechanism:
|
|
675
|
+
the `queryBounded` `latest` opt-in (section 4) probes and walks `id gt` /
|
|
676
|
+
`id gte` count windows, and the vendored spec's own `QueryModel` example is
|
|
677
|
+
exactly an `id gt` filter — the vendor-side acceptance across zone versions
|
|
678
|
+
is a maintainer live-smoke check (the SDK never assumes page order).
|
|
588
679
|
- `buildFilter(conditions)` wraps the caller's conditions; multiple
|
|
589
680
|
top-level conditions are `and`-wrapped (the node's behaviour).
|
|
681
|
+
- **Refusal, not silent drop (issue #76):** the query-family `filter`
|
|
682
|
+
param accepts a conditions array (`FilterCondition[]`) or
|
|
683
|
+
`{ filter: [...] }`; `undefined`/`null` are the unfiltered call
|
|
684
|
+
(`[]` is the vendor-accepted no-filter wire shape). Every other defined
|
|
685
|
+
shape (a bare condition object, a bare group object, an object missing
|
|
686
|
+
the `filter` field, a string, a number) is REFUSED pre-request with a
|
|
687
|
+
typed `AutotaskConfigError` — zero wire — at every entry point (query,
|
|
688
|
+
queryBounded, cursor pages, count): a non-conformant filter is never
|
|
689
|
+
silently dropped into an unfiltered query. The gate is the shared
|
|
690
|
+
`normalizedConditions` (query.ts), so no entry point can drift.
|
|
590
691
|
- **Vendor caps (`validateFilter`):** <= 500 OR-conditions; <= 1 UDF filter
|
|
591
692
|
per query (`udf: true`); field length <= 128; description length <= 2000.
|
|
592
693
|
- **GET vs POST:** the API caps the GET `search` JSON string at **2048 URL
|
|
@@ -595,7 +696,10 @@ Vendor filter structure (swagger `Filter`, recursive):
|
|
|
595
696
|
serialized GET URL — over 2048 it throws `QueryTooLongError` (never a
|
|
596
697
|
silent degradation).
|
|
597
698
|
- `includeFields` passes through vendor column selection; the SDK never
|
|
598
|
-
invents column selection
|
|
699
|
+
invents column selection — one exception: when `includeFields` is set the
|
|
700
|
+
SDK always appends `id` (the vendor omits `id` from a row unless it is
|
|
701
|
+
named in the selection, and every caller needs the id to act on the row —
|
|
702
|
+
issue #59).
|
|
599
703
|
|
|
600
704
|
## 6. Mutation safety
|
|
601
705
|
|
|
@@ -667,8 +771,8 @@ resource's `resolve` with the SAME identifier. Unlike single-resource
|
|
|
667
771
|
`resolve`, **a miss does NOT throw**: the result lists per-resource
|
|
668
772
|
candidates and the error code when a resource could not resolve (empty
|
|
669
773
|
`candidates` = no match). Bare-value order per resource: numeric id, then
|
|
670
|
-
the primary text field, exact match only;
|
|
671
|
-
(and its truncation cap)
|
|
774
|
+
the primary text field, exact match only; with `resolution.scanOnMiss` the
|
|
775
|
+
bounded client-scan re-check (and its truncation cap) applies per resource. Returns
|
|
672
776
|
`{ identifier, results: [{ resource, value, resolutionCost, scanned,
|
|
673
777
|
scanTruncated, candidates, error? }], matches }`.
|
|
674
778
|
|
|
@@ -743,7 +847,7 @@ be cross-mode-refused.
|
|
|
743
847
|
whose MCP annotation is `readOnlyHint !== true` — today exactly **254
|
|
744
848
|
tools**: the 252 dedicated write/delete tools (198 `effect=write` + 54
|
|
745
849
|
`effect=destructive`, every one of them has a dedicated tool) +
|
|
746
|
-
`autotask_write` + `autotask_delete`. The remaining **
|
|
850
|
+
`autotask_write` + `autotask_delete`. The remaining **600 tools** (the 587
|
|
747
851
|
dedicated read tools + the 10 core read tools + `autotask_read` +
|
|
748
852
|
`autotask_catalog` + `autotask_describe`) all carry `readOnlyHint: true`
|
|
749
853
|
and are safe ungated: every op they can route is `effect=read` (zero
|
|
@@ -817,6 +921,175 @@ const applied = await recovery(client, { resource: 'configuration_item_dns_recor
|
|
|
817
921
|
Pinned by `test/operations/recovery.test.ts` (11 tests) + the full-surface
|
|
818
922
|
governor suite (`test/operations/invoke-modes.test.ts`).
|
|
819
923
|
|
|
924
|
+
### 7.6 `fetchMany(client, items, opts?)` / `fetchByIds(client, items, opts?)` — bulk fetch by ids
|
|
925
|
+
|
|
926
|
+
Two cross-resource bulk reads with per-item typed results — one entry per
|
|
927
|
+
input occurrence: `{ resource, id, found, value, error?: { code, httpStatus?,
|
|
928
|
+
message } }`; a NOT_FOUND or fault on one item never aborts the rest; the
|
|
929
|
+
result is `{ results, ok, failed }`. Both refuse pre-request (zero wire):
|
|
930
|
+
non-numeric string ids (pointing at `autotask_resolve_any` — canonical
|
|
931
|
+
integer strings above 2^53 pass with their exact digits), parent-scoped
|
|
932
|
+
children (their `get` needs `parentId`), unknown `fields` keys (the complete
|
|
933
|
+
problem list in one message), unsafe number ids (the canonical-string
|
|
934
|
+
pointer). The op never fabricates rows: values come only from a vendor 2xx
|
|
935
|
+
(query row or GET record); a miss is a typed outcome, never a null-padded
|
|
936
|
+
row.
|
|
937
|
+
|
|
938
|
+
- **`fetchMany`** — up to 20 items (mixed resources; the pinned
|
|
939
|
+
`FETCH_MANY_MAX_ITEMS`; over → `CONFIG_ERROR` pre-request, zero wire),
|
|
940
|
+
one sequential GET per item. Per-item `fields?: string[]` is a client-side
|
|
941
|
+
projection (the GET returns the full model; projection is post-fetch, zero
|
|
942
|
+
extra wire).
|
|
943
|
+
**Live field validation (opt-in `opts.liveFields`, issue #66)**: when
|
|
944
|
+
`true`, each item's `fields` validate against the tenant's **live** entity
|
|
945
|
+
field metadata (the existing cached metadata channel — with
|
|
946
|
+
`fieldCache` configured a warm cache costs zero wire; a cold run is one
|
|
947
|
+
`entityInformation/fields` GET per DISTINCT resource in the batch) instead
|
|
948
|
+
of the vendored-spec field set alone. A resource whose metadata is
|
|
949
|
+
unavailable (network fault, vendor 5xx) falls back to the vendored-spec
|
|
950
|
+
set for THAT resource only — unavailability never throws and never blocks
|
|
951
|
+
the batch. When available the live set is authoritative: a field it lacks
|
|
952
|
+
is refused even if the spec lists it, and a live field the spec lacks is
|
|
953
|
+
accepted — a tenant whose live TimeEntry entity exposes `postedDate` (the
|
|
954
|
+
live record carries it even when the vendored model omits it) may project
|
|
955
|
+
it once `liveFields: true`; the default spec-only path still refuses it.
|
|
956
|
+
The default (no `liveFields`) is today's vendored-spec-only validation,
|
|
957
|
+
bit-for-bit, zero metadata wire. Field discovery in both modes:
|
|
958
|
+
`operations.fieldCatalog`.
|
|
959
|
+
- **`fetchByIds`** — up to 500 items (mixed resources; the pinned
|
|
960
|
+
`FETCH_BY_IDS_MAX_ITEMS`; `opts.maxItems` may lower the cap, never raise
|
|
961
|
+
it; over → `CONFIG_ERROR` pre-request, zero wire). **Query-first**: per
|
|
962
|
+
resource, the unique ids are chunked at 250 (the pinned
|
|
963
|
+
`FETCH_BY_IDS_CHUNK_SIZE` — the 250-id `in` condition serializes well under
|
|
964
|
+
the 8000-char vendor cap, and 250 is a deliberate chunk size well under the
|
|
965
|
+
500 vendor page max, so each chunk is a single page, no cursor walk) and fetched with one `id in`
|
|
966
|
+
query per chunk. Every id the chunk query does NOT return is re-tried with
|
|
967
|
+
a per-item `get` fallback before it is reported — the GET is the authority
|
|
968
|
+
on every surface, so a blind-surface query-miss is re-verified by the
|
|
969
|
+
authority read before a NOT_FOUND is served (the fallback is universal, not
|
|
970
|
+
limited to the plan-pinned blind surfaces). Wire cost: per resource
|
|
971
|
+
`ceil(uniqueIds/250)` query calls + one `get` per query-missed id — a
|
|
972
|
+
clean 500-id single-resource batch is **2 wire** (down from 500 sequential
|
|
973
|
+
GETs); the blind-surface worst case is 2 + up to 500 gets (fetchMany
|
|
974
|
+
parity, never worse). A chunk-level vendor fault faults every item of that
|
|
975
|
+
chunk (the chunk's `code`/`httpStatus`) and the remaining chunks still run
|
|
976
|
+
(input order is preserved across chunks and groups; a duplicate id gets its
|
|
977
|
+
own entry with the identical outcome over one wire fetch).
|
|
978
|
+
|
|
979
|
+
### 7.7 `expandResourceNames(client, ids, opts?)` — resource name expansion (issue #65)
|
|
980
|
+
|
|
981
|
+
The opt-in cross-resource helper that resolves vendor numeric resource
|
|
982
|
+
ids (the `resourceID` values other tables expose WITHOUT names — the
|
|
983
|
+
`resourceID` of time_entries / billing_items / tickets) to display names
|
|
984
|
+
in one call. Wire shape: **one `GET /V1.0/Resources/{id}` per unique id,
|
|
985
|
+
sequentially — no POST, no `/query`, no `id in`** (AC4 by construction:
|
|
986
|
+
the module imports no query builder) — the resources table's count/query
|
|
987
|
+
can be blind to the API user while per-id reads work, so an `id in`
|
|
988
|
+
lookup on a blind table would silently return nothing; the per-id GET is
|
|
989
|
+
the authority read (section 11.2).
|
|
990
|
+
|
|
991
|
+
- **Input**: `ids: (number | string)[]` — vendor numeric resource ids
|
|
992
|
+
(safe integers, or the canonical integer string for ids above 2^53 —
|
|
993
|
+
the exact digits ride the wire URL verbatim). Non-numeric string ids
|
|
994
|
+
are refused pre-request pointing at `autotask_resolve_any` (the
|
|
995
|
+
complete problem list in one message, zero wire). Cap 500 per call
|
|
996
|
+
(the pinned `EXPAND_RESOURCE_NAMES_MAX_IDS`; `opts.maxIds` may lower
|
|
997
|
+
the cap, never raise it); over → `CONFIG_ERROR` pre-request, zero wire.
|
|
998
|
+
- **Per-call de-duplication** is always on: repeated ids ride one wire
|
|
999
|
+
fetch; each occurrence gets its own row with the identical outcome.
|
|
1000
|
+
- **Per-row** `{ id, name, blind, error? }`:
|
|
1001
|
+
- `name` = composed `firstName [middleName] lastName` (single spaces;
|
|
1002
|
+
the vendor `Resource` model has NO single name field —
|
|
1003
|
+
`suffix`/`initials` excluded; the raw parts via `operations.fetchByIds`
|
|
1004
|
+
with `fields`);
|
|
1005
|
+
- a record FOUND with no name part → `{ name: null, blind: false }`
|
|
1006
|
+
(a vendor data gap — distinct from blind);
|
|
1007
|
+
- a per-id miss (HTTP 404 or `200 item: null` — the vendor's TRUE
|
|
1008
|
+
not-found) → `{ name: null, blind: true }`, the id kept, NO error, NO
|
|
1009
|
+
throw (AC3 — the invisible-table case: at the per-id level the vendor
|
|
1010
|
+
does not distinguish "table invisible to this user" from "record
|
|
1011
|
+
absent", so both are blind);
|
|
1012
|
+
- a transient typed fault (5xx class) → per-row
|
|
1013
|
+
`error: { code, httpStatus?, message }` — never cached, one fault
|
|
1014
|
+
never aborts the remaining ids.
|
|
1015
|
+
- **Result**: `{ results (input order, duplicates get identical rows),
|
|
1016
|
+
resolved, blind, failed }`.
|
|
1017
|
+
- **Wire cost**: `N` unique ids cold = `N` GETs; warm (inside the TTL of
|
|
1018
|
+
the channel below) = 0.
|
|
1019
|
+
- **Cache**: the `resourceNameCache` channel (section 11.21) —
|
|
1020
|
+
each distinct id is fetched at most once per cache lifetime (the blind
|
|
1021
|
+
marker is cached too: a repeat miss costs no wire; a failure is never
|
|
1022
|
+
cached). Default-on (issue #80) — with `false` the op is the uncached per-id
|
|
1023
|
+
path (per-call de-duplication still applies). A `resources` write
|
|
1024
|
+
through the same client clears the channel; a rename made elsewhere
|
|
1025
|
+
shows up once `ttlMs` expires (the documented staleness contract). A
|
|
1026
|
+
`fresh: true` call bypasses BOTH the name cache AND the ReadCache (the
|
|
1027
|
+
per-id `resources.get` fetches live and refreshes the ReadCache entry as
|
|
1028
|
+
well) — a rename made elsewhere is served on the very next fresh call,
|
|
1029
|
+
never a stale snapshot (issue #77).
|
|
1030
|
+
|
|
1031
|
+
```ts
|
|
1032
|
+
import { expandResourceNames } from 'node-autotask/operations';
|
|
1033
|
+
const { results, resolved, blind, failed } = await expandResourceNames(
|
|
1034
|
+
client,
|
|
1035
|
+
[101, 102, '103'], // vendor numeric resource ids (exact digits ride the wire verbatim)
|
|
1036
|
+
{ fresh: false },
|
|
1037
|
+
);
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
### 7.8 `aggregate(client, operation, filter, opts?)` — generic aggregate/count (issue #62)
|
|
1042
|
+
|
|
1043
|
+
One op, two mode families (selected by the `opts` shape — the "one tool plus
|
|
1044
|
+
a cheap count" of issue #62). The `operation` is EXACTLY
|
|
1045
|
+
`<planResourceKey>.query` — a plan resource WITH a `query` row (the
|
|
1046
|
+
capability registry is the closed set; a special op, a `.count` row, or an
|
|
1047
|
+
unknown resource is refused pre-request, naming `autotask_catalog` — zero
|
|
1048
|
+
wire):
|
|
1049
|
+
|
|
1050
|
+
| mode | when | wire |
|
|
1051
|
+
|-------|---------------------------------------------|-----------------------------------------------------------------------|
|
|
1052
|
+
| `count` | NEITHER `groupBy` NOR `sum` | ONE `POST /V1.0/<Entity>/query/count` (the vendor count endpoint — no walk) |
|
|
1053
|
+
| `group` | `groupBy` given (with or without `sum`) | the house server-side-paginated `queryBounded` walk (page size `min(500, limit)`; the 1000-page guard) + the client-side fold |
|
|
1054
|
+
| `sum` | `sum` only | the same walk + fold (the single empty-key group carries the sums) |
|
|
1055
|
+
|
|
1056
|
+
- **`filter`** = the query-bag filter grammar (section 4: 13 leaf operators +
|
|
1057
|
+
and/or grouping; caps 500 OR-conditions / 1 UDF / 8000 chars; field names in
|
|
1058
|
+
vendor casing via `operations.fieldCatalog`). An empty array / absent = the
|
|
1059
|
+
unfiltered call (the vendor-accepted no-filter shape rides the wire as
|
|
1060
|
+
`filter: []`; that is also the blind-predicate's stamp case).
|
|
1061
|
+
- **`groupBy` / `sum`** = exact vendor field names (validated against the
|
|
1062
|
+
resource's vendored field set pre-request — a hallucinated field is a shape
|
|
1063
|
+
error, not a silent fold-miss; the complete problem list in one typed error,
|
|
1064
|
+
zero wire). Non-numeric/absent `sum` values contribute 0 (documented).
|
|
1065
|
+
- **`top`** (default 25, clamped 1..100) keeps the top-N groups (descending
|
|
1066
|
+
count; the deterministic group-key order breaks ties). **`limit`** (default
|
|
1067
|
+
2500 = the house `BOUNDED_QUERY_MAX_LIMIT`, clamped down to it) is the
|
|
1068
|
+
total-record cap for the walk modes.
|
|
1069
|
+
- **count mode — the blind-count marking:** the S12-C blind-surface sentinel
|
|
1070
|
+
rides through the base `count` wrap (filter-aware: a plan-pinned
|
|
1071
|
+
`unfiltered-blind` surface stamps a 0 ONLY when the call is unfiltered — a
|
|
1072
|
+
FILTERED 0 is a true zero, never a sentinel; a non-zero count is the plain
|
|
1073
|
+
number). Result: `{ mode: 'count', count, blind, blindSurface? }` — a 0 on a
|
|
1074
|
+
plan-pinned blind shape is marked `blind: true` with the typed sentinel,
|
|
1075
|
+
never a silent absence.
|
|
1076
|
+
- **group/sum mode — the truncation honesty:** the walk's stamps ride through
|
|
1077
|
+
(`blindSurface` / `emptyReason` on a 0-row walk); `hasMore` = the walk's
|
|
1078
|
+
truncation flag — when true, `groups`/`sums` cover the RETURNED rows ONLY
|
|
1079
|
+
(no silent partial sums); `totalScanned` = the walk's fetched record count;
|
|
1080
|
+
`totalGroups` = the distinct groups found and `groupsTruncated` = `top` dropped some (an absent
|
|
1081
|
+
and an explicit-null group field fold into one group keyed `null`).
|
|
1082
|
+
A mid-walk vendor `429` propagates as the typed `RATE_LIMITED` error (the
|
|
1083
|
+
partial fold is discarded — the error IS the answer). Zero mutating.
|
|
1084
|
+
|
|
1085
|
+
```ts
|
|
1086
|
+
import { aggregate } from 'node-autotask/operations';
|
|
1087
|
+
// the cheap count — one vendor /query/count call (no walk):
|
|
1088
|
+
const n = await aggregate(client, 'billing_items.query', [{ field: 'companyID', op: 'eq', value: 30132785 }]);
|
|
1089
|
+
// the fold — the group-by the vendor has no endpoint for (bounded walk + client-side fold):
|
|
1090
|
+
const fold = await aggregate(client, 'time_entries.query', [{ field: 'dateWorked', op: 'gte', value: '2026-09-01' }], { groupBy: ['ticketID'], sum: ['hoursWorked'], top: 10 });
|
|
1091
|
+
```
|
|
1092
|
+
|
|
820
1093
|
## 8. Resource surface (128 resources)
|
|
821
1094
|
|
|
822
1095
|
<!-- BEGIN GENERATED:api-per-entity -->
|
|
@@ -826,12 +1099,12 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
|
|
|
826
1099
|
|--------------|-----------|------|------|--------|----------------------|-------------|---------|
|
|
827
1100
|
| `appointments` | Appointment | entity | extended | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_appointment_get |
|
|
828
1101
|
| `billing_codes` | BillingCode | entity | extended | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_billingCode_get |
|
|
829
|
-
| `billing_items` | BillingItems | entity | extended | no | query, count, get, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_billingItems_get |
|
|
1102
|
+
| `billing_items` | BillingItems | entity | extended | no | query, count, get, update, resolve | customRange, getEntityInfo, getFieldInfo, getUdfInfo, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, today, yesterday | autotask_billingItems_get |
|
|
830
1103
|
| `change_request_links` | ChangeRequestLink | entity | extended | no | query, count, get, create, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_changeRequestLinks_get |
|
|
831
1104
|
| `checklist_libraries` | ChecklistLibrary | entity | extended | no | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_checklistLibrary_get |
|
|
832
1105
|
| `checklist_library_checklist_items` | ChecklistLibraryChecklistItem | entity | extended | yes (checklist_libraries) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_checklistLibraryChecklistItem_get |
|
|
833
1106
|
| `classification_icons` | ClassificationIcon | entity | advanced | no | query, count, get, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_classificationIcon_get |
|
|
834
|
-
| `companies` | Company | entity | core | no | query, count, get, create, update, resolve, search, getContext | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, searchByDomain, searchByIdentity, upsertByName | autotask_company_get |
|
|
1107
|
+
| `companies` | Company | entity | core | no | query, count, get, create, update, resolve, search, getContext | fullProfile, getEntityInfo, getFieldInfo, getUdfInfo, searchByDomain, searchByIdentity, unbilledWork, upsertByName | autotask_company_get |
|
|
835
1108
|
| `company_alerts` | CompanyAlert | entity | extended | yes (companies) | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyAlert_get |
|
|
836
1109
|
| `company_locations` | CompanyLocation | entity | extended | yes (companies) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyLocation_get |
|
|
837
1110
|
| `company_notes` | CompanyNote | entity | extended | yes (companies) | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_companyNote_get |
|
|
@@ -881,7 +1154,7 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
|
|
|
881
1154
|
| `expense_reports` | ExpenseReport | entity | extended | no | query, count, get, create, update, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_expenseReport_get |
|
|
882
1155
|
| `holiday_sets` | HolidaySet | entity | advanced | no | query, count, get, create, update, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_holidaySet_get |
|
|
883
1156
|
| `holidays` | Holiday | entity | advanced | yes (holiday_sets) | query, count, get, create, update, delete, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_holiday_get |
|
|
884
|
-
| `invoices` | Invoice | entity | extended | no | query, count, get, update, resolve | getEntityInfo, getFieldInfo, getUdfInfo, markupHtml, markupXml, pdf | autotask_invoice_get |
|
|
1157
|
+
| `invoices` | Invoice | entity | extended | no | query, count, get, update, resolve | customRange, getEntityInfo, getFieldInfo, getUdfInfo, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, markupHtml, markupXml, pdf, today, yesterday | autotask_invoice_get |
|
|
885
1158
|
| `notification_history` | NotificationHistory | entity | advanced | no | query, count, get, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_notificationHistory_get |
|
|
886
1159
|
| `opportunities` | Opportunity | entity | extended | no | query, count, get, create, update, resolve | createIfNotExists, getEntityInfo, getFieldInfo, getUdfInfo | autotask_opportunity_get |
|
|
887
1160
|
| `opportunity_attachments` | OpportunityAttachment | entity | extended | yes (opportunities) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_opportunityAttachment_get |
|
|
@@ -941,7 +1214,7 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
|
|
|
941
1214
|
| `ticket_secondary_resources` | TicketSecondaryResource | entity | extended | yes (tickets) | query, count, get, create, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketSecondaryResources_get |
|
|
942
1215
|
| `ticket_webhooks` | TicketWebhook | entity | extended | yes (tickets) | query, count, get, create, update, delete, resolve, search | getEntityInfo, getFieldInfo, getUdfInfo | autotask_ticketWebhook_get |
|
|
943
1216
|
| `tickets` | Ticket | entity | core | no | query, count, get, create, update, resolve, search, getContext | changeStatus, findByTicketNumber, fullContext, getEntityInfo, getFieldInfo, getUdfInfo, openWithNoteAndAssign, slaHealthCheck, summary, timeline | autotask_ticket_get |
|
|
944
|
-
| `time_entries` | TimeEntry | entity | extended | no | query, count, get, create, update, delete, resolve | createForResource, createIfNotExists, customRange, getEntityInfo, getFieldInfo, getPosted, getUdfInfo, getUnposted, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, postedWithin, today, yesterday | autotask_timeEntry_get |
|
|
1217
|
+
| `time_entries` | TimeEntry | entity | extended | no | query, count, get, create, update, delete, resolve | createForResource, createIfNotExists, customRange, dateWorkedWindow, getEntityInfo, getFieldInfo, getPosted, getUdfInfo, getUnposted, last14, last24h, last30, last7, last90, lastFullFortnight, lastFullMonth, lastFullQuarter, lastFullWeek, postedWithin, today, yesterday | autotask_timeEntry_get |
|
|
945
1218
|
| `time_entry_attachments` | TimeEntryAttachment | entity | extended | yes (time_entries) | query, count, get, create, delete, resolve | download, getEntityInfo, getFieldInfo, getUdfInfo | autotask_timeEntryAttachment_get |
|
|
946
1219
|
| `time_off_requests` | TimeOffRequest | entity | extended | no | query, count, get, resolve | approve, getEntityInfo, getFieldInfo, getUdfInfo, reject | autotask_timeOffRequest_get |
|
|
947
1220
|
| `inventory_products` | InventoryProduct | entity | extended | no | query, count, get, create, update, delete, resolve | getEntityInfo, getFieldInfo, getUdfInfo, stockedItemsAdd, stockedItemsRemove, stockedItemsTransfer | autotask_inventoryProduct_get |
|
|
@@ -956,6 +1229,37 @@ Standard methods (every resource, `BaseResource`): `query` (stream, 1000-page gu
|
|
|
956
1229
|
128 resources (121 entity + 7 integration). Rows are generated from the registry — do not hand-type.
|
|
957
1230
|
<!-- END GENERATED:api-per-entity -->
|
|
958
1231
|
|
|
1232
|
+
**`companies.search` include_family (issue #59).** `companies.search` is
|
|
1233
|
+
the contains channel for fuzzy names, abbreviations and family lookups
|
|
1234
|
+
(exact match is the resource's `resolve`). The closed opts bag is
|
|
1235
|
+
`{ limit?: number (1-500, default 25), includeFamily?: boolean }` — an
|
|
1236
|
+
unknown key is refused pre-request (zero wire, the valid keys named). With
|
|
1237
|
+
`includeFamily: true` each match gains a `family` block: `parentID` (the
|
|
1238
|
+
match's own `parentCompanyID` — zero extra wire: the field is on the
|
|
1239
|
+
returned row; `null` when absent), `childIDs` (ONE bounded single-page query
|
|
1240
|
+
per match — `parentCompanyID` eq, capped at 50 with the `childIDsTruncated`
|
|
1241
|
+
flag when that page came back full with a live next cursor — a short page,
|
|
1242
|
+
or a full page with a null cursor, is complete) and `blindNote` (the
|
|
1243
|
+
plan-pinned blind-surface caveat: `companies.query` is `unowned-blind` — the
|
|
1244
|
+
family, and the search hits themselves, are scoped to the caller-visible
|
|
1245
|
+
enumeration — a family is reported as SEEN BY THIS API USER, never as
|
|
1246
|
+
complete). Wire cost: 1 (the search) + matches × 1 (the child queries) —
|
|
1247
|
+
with the default limit 25 that is ≤ 26 wire; lower `limit` for large
|
|
1248
|
+
candidate sets. Default callers (no `includeFamily`) see bit-for-bit the
|
|
1249
|
+
existing behaviour (no `family` key on the rows).
|
|
1250
|
+
|
|
1251
|
+
**The resolve miss channel (issue #59).** `resolve` is exact match only — a
|
|
1252
|
+
complete non-match returns `null` (resolve never widens to contains; the
|
|
1253
|
+
resource's search op is the contains-candidates channel). The two resolve
|
|
1254
|
+
ERROR outcomes carry a `suggestedAction` naming the channel (generic on the
|
|
1255
|
+
floor — for companies it reads `companies.search`): `RESOLUTION_AMBIGUOUS`
|
|
1256
|
+
(narrow the identifier to the full exact value, or run the resource's
|
|
1257
|
+
`search` — contains match — for candidate records to disambiguate) and
|
|
1258
|
+
`RESOLUTION_TRUNCATED` (the unverified miss — the opted-in
|
|
1259
|
+
`resolution.scanOnMiss` client scan tripped the caps without completing; retry with a more specific identifier, or
|
|
1260
|
+
narrow the search to fewer records — contains candidates: the resource's
|
|
1261
|
+
`search`).
|
|
1262
|
+
|
|
959
1263
|
### 8.1 Tasks — scoped mirrors and the out-of-scope task-adjacent entities (QoL note)
|
|
960
1264
|
|
|
961
1265
|
`Projects/{id}/Tasks` and `ServiceCalls/{id}/Tasks` are read-only scoped
|
|
@@ -1207,8 +1511,8 @@ retrying with the same body fails identically; fix the field instead
|
|
|
1207
1511
|
(`src/errors.ts` sets `retryable: true` for `SERVER_ERROR` and for every
|
|
1208
1512
|
httpStatus >= 500). **Resolution (issue #23):** the *deterministic*
|
|
1209
1513
|
validation 500 is no longer labelled retryable — a 500 whose `errors[]` text
|
|
1210
|
-
matches the documented heuristic phrases (`isDeterministicVendor500
|
|
1211
|
-
`exceeds maximum length`
|
|
1514
|
+
matches the documented heuristic phrases (`isDeterministicVendor500`:
|
|
1515
|
+
`exceeds maximum length`, `Missing Required Field`, `Value does not exist for the required field`) makes exactly ONE upstream call (the
|
|
1212
1516
|
transport skips the retry), keeps the `ServerError` class + `SERVER_ERROR`
|
|
1213
1517
|
code + status 500, but carries `retryable: false`, `details.deterministic:
|
|
1214
1518
|
true`, and a `suggestedAction` pointing at the field catalog
|
|
@@ -1511,6 +1815,23 @@ into one predicted-failure-free attempt.
|
|
|
1511
1815
|
re-verified 2026-10-06: 12 unposted rows in the 2026-10-01..06 window —
|
|
1512
1816
|
also the live evidence for the visibility class: unfiltered query 0 rows,
|
|
1513
1817
|
windowed read has data).
|
|
1818
|
+
- **`truncated` + the `dateWorkedWindow` alias + `tz` (issue #66)** — the
|
|
1819
|
+
result is now `{ items, postedCount, unpostedCount, window, truncated }`:
|
|
1820
|
+
`truncated` is true when the bounded read came back with a live
|
|
1821
|
+
`pageDetails.nextPageUrl` (more matching rows exist; `requestCount` is the
|
|
1822
|
+
MaxRecords echo, not a total, so it cannot carry this signal) —
|
|
1823
|
+
the posted/unposted counts then cover the returned rows ONLY (at most
|
|
1824
|
+
`limit`); re-query with a wider window or a narrowing filter to reconcile
|
|
1825
|
+
the totals. `time_entries.dateWorkedWindow(from, to, opts?)` is the
|
|
1826
|
+
clearer-named alias: the same wire, the same result (incl. `truncated`),
|
|
1827
|
+
its own op identity (plan row / MCP tool
|
|
1828
|
+
`autotask_timeEntry_date_worked_window` / operation label) — the name
|
|
1829
|
+
names the `dateWorked` field the window is on; `postedWithin` keeps its
|
|
1830
|
+
name and default semantics. Both accept `opts.tz` (an IANA zone): the
|
|
1831
|
+
`from`/`to` bounds become the LOCAL calendar days in that zone (from-day
|
|
1832
|
+
start / to-day end, converted to UTC for the wire); omitted `tz` = UTC
|
|
1833
|
+
days, the current behaviour bit-for-bit. The same `tz` opt is on every
|
|
1834
|
+
date-window op — see §11.22.
|
|
1514
1835
|
- **Drifted-create marker recovery (issue #13)** — where the plan pins a
|
|
1515
1836
|
`responseDrift` row (the pinned resource: `ticket_checklist_libraries`,
|
|
1516
1837
|
the W-A7 drift: the vendor answers 200 with a body that is not
|
|
@@ -1545,12 +1866,13 @@ reality*), so the point-in-time pins (`operations.createGuidance`, the
|
|
|
1545
1866
|
`zoneFacts` lists) are only as fresh as their `verifiedAt`. The live
|
|
1546
1867
|
channel is the refresh side of that pair:
|
|
1547
1868
|
|
|
1548
|
-
**Config (
|
|
1869
|
+
**Config (on by default; `false` opts out):** `fieldCache: { ttlMs?, maxEntities? }` — a
|
|
1549
1870
|
separate block from `cache` (different key — entity vs resource+id —,
|
|
1550
1871
|
different lifetime, different invalidation signal: drift vs write). With
|
|
1551
|
-
it
|
|
1872
|
+
it `false` there is no `FieldCache` instance at all and the
|
|
1552
1873
|
`getEntityInfo` / `getFieldInfo` / `getUdfInfo` trio is the uncached wire
|
|
1553
|
-
path, bit-for-bit
|
|
1874
|
+
path, bit-for-bit; absent, the default instance is built and the trio
|
|
1875
|
+
goes through one private helper
|
|
1554
1876
|
(`BaseResource.cachedMeta`, beside `cachedGet`) that stores the
|
|
1555
1877
|
UNWRAPPED value, never caches an EMPTY result (an empty is not an answer
|
|
1556
1878
|
— a transient vendor hiccup must not become a permanent lie), de-dupes
|
|
@@ -1568,7 +1890,9 @@ record write says nothing about the field catalog and never invalidates;
|
|
|
1568
1890
|
the TTL is the other expiry path.
|
|
1569
1891
|
|
|
1570
1892
|
**The on-demand op:** `operations.fieldCatalog(client, opts?)`
|
|
1571
|
-
(`{ resources?, include?, fresh? }` —
|
|
1893
|
+
(`{ resources?, include?, fields?, labels?, fresh? }` — `fields` filters the
|
|
1894
|
+
fields-kind rows to the named fields and reports `unmatchedFields`; `labels`
|
|
1895
|
+
adds the picklist labels; default: the five workflow
|
|
1572
1896
|
resources, `include: ['fields']`; the closed opts bag is documented in
|
|
1573
1897
|
the registry record). One bounded entityInformation GET per resource ×
|
|
1574
1898
|
kind; the wire cost `resources × include` is REFUSED pre-request above
|
|
@@ -1585,12 +1909,18 @@ a known key without a catalog → `CONFIG_ERROR`, an unknown key → typed
|
|
|
1585
1909
|
**Honest scope (stated in the served text AND the result's `honesty`
|
|
1586
1910
|
block):** the live catalog reports a field's SHAPE — existence,
|
|
1587
1911
|
requiredness, type, `maxLength`, read-only, queryable, picklist-ness —
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
(the
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
carries
|
|
1912
|
+
as FLAGS and NAMES; without the opt-in `labels: true` it serves no
|
|
1913
|
+
picklist VALUES at all. With `labels: true` the projected picklist rows
|
|
1914
|
+
(the `fields`-kind and `udf`-kind rows) carry the vendor's exposed
|
|
1915
|
+
picklist VALUES — the raw vendor entries `{ value, label, parentValue?, isActive?, ... }` (`value` a string)
|
|
1916
|
+
as-is, zero transform; the `labels` result flag names what the payload
|
|
1917
|
+
carries; the values ride the same cached channel, zero extra wire. It
|
|
1918
|
+
CANNOT resolve a picklist VALUE the vendor does not expose to this API
|
|
1919
|
+
user (the W-A4 wall — e.g. the attachments `Type` picklist): retrying
|
|
1920
|
+
the catalog will not surface a withheld value; that gap is vendor-side.
|
|
1921
|
+
The `honesty.picklistValues` field reports the vendor's WIRE exposure
|
|
1922
|
+
(`partial` when any raw row carried exposed values, `unexposed`
|
|
1923
|
+
otherwise) — informational, orthogonal to the `labels` opt.
|
|
1594
1924
|
|
|
1595
1925
|
Served as `autotask_field_catalog` (extended tier — the 15-tool core
|
|
1596
1926
|
surface is untouched; the tool is reachable through `autotask_read` /
|
|
@@ -1608,13 +1938,16 @@ human timescale (countries, departments, roles, billing codes, services)
|
|
|
1608
1938
|
and records that change minute to minute (tickets, time entries). The
|
|
1609
1939
|
override makes the choice per resource:
|
|
1610
1940
|
|
|
1611
|
-
**Config (
|
|
1612
|
-
perResourceTtlMs?: Record<string, number> }` — every value is a
|
|
1613
|
-
integer, validated fail-closed in `resolveConfig` exactly like
|
|
1614
|
-
(wrong type / non-integer / non-positive → `AutotaskConfigError`
|
|
1615
|
-
construction).
|
|
1616
|
-
|
|
1617
|
-
|
|
1941
|
+
**Config (the default map; `false` opts out):** `cache: { ttlMs?,
|
|
1942
|
+
perResourceTtlMs?: Record<string, number> } | false` — every value is a
|
|
1943
|
+
positive integer, validated fail-closed in `resolveConfig` exactly like
|
|
1944
|
+
`ttlMs` (wrong type / non-integer / non-positive → `AutotaskConfigError`
|
|
1945
|
+
at construction). Issue #80: the plan-derived table
|
|
1946
|
+
`DEFAULT_PER_RESOURCE_TTL_MS` IS the default per-resource map — with the
|
|
1947
|
+
operator map absent, every plan resource rides its table row (the 1h
|
|
1948
|
+
reference class / the 5min default class) and a plan-unnamed path expires
|
|
1949
|
+
on `ttlMs`; operator entries OVERRIDE table entries per resource (the
|
|
1950
|
+
effective map is operator entry > table entry > `ttlMs`).
|
|
1618
1951
|
|
|
1619
1952
|
**The key contract (pinned):** the entry's TTL is the override for the
|
|
1620
1953
|
**plan key of the entity the read path points to** — the cache key is
|
|
@@ -1790,10 +2123,11 @@ config surface, no new ops) and pins the two hazards the issue names:
|
|
|
1790
2123
|
the record after the not-found) drops the stale marker and serves the
|
|
1791
2124
|
record — a record that exists is never reported absent while a live
|
|
1792
2125
|
positive read of it sits in the cache (pinned). The dry-run parent-
|
|
1793
|
-
exists check
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
2126
|
+
exists check does NOT share the marker (issue #80 — with the cache
|
|
2127
|
+
default-on, a stale marker or a stale positive entry would corrupt the
|
|
2128
|
+
verdict): it reads the parent FRESH (bypass + wire), so a vendor 404
|
|
2129
|
+
re-derives the same verdict the base wire call would and a stale marker
|
|
2130
|
+
cannot suppress the read. The
|
|
1797
2131
|
documented trade: a record created elsewhere may serve 'absent' until the
|
|
1798
2132
|
marker TTL (or a write through this client lands) — the opt-in staleness
|
|
1799
2133
|
contract, same trade as the positive entries.
|
|
@@ -1801,8 +2135,16 @@ config surface, no new ops) and pins the two hazards the issue names:
|
|
|
1801
2135
|
client clears the whole read cache (the existing transport behaviour),
|
|
1802
2136
|
covering the FK parts and the negative marker together — the "someone
|
|
1803
2137
|
changed a company name" case.
|
|
1804
|
-
- **
|
|
1805
|
-
bit-for-bit the pre-#36 wire and result
|
|
2138
|
+
- **Default-on since #80.** With `cache: false` every composite call is
|
|
2139
|
+
bit-for-bit the pre-#36 wire and result; absent, composites ride the
|
|
2140
|
+
default-on read cache (positive entries AND the absent markers).
|
|
2141
|
+
|
|
2142
|
+
- **Opt-in names/labels ride these same channels (issue #77).** The
|
|
2143
|
+
composites' opt-in `names` siblings resolve over the existing
|
|
2144
|
+
default-on channels (the name cache / the ReadCache / the fieldCache —
|
|
2145
|
+
no new cache) with a separate name-read budget (`maxNameReads`,
|
|
2146
|
+
default 20, lowerable never raised) — see section 11.21 for the
|
|
2147
|
+
per-field-class wiring.
|
|
1806
2148
|
|
|
1807
2149
|
Pins: `test/resources/context-fk-cache.test.ts` (10 pins, mock wire only).
|
|
1808
2150
|
|
|
@@ -1961,6 +2303,304 @@ Pins: `test/config-host-policy.test.ts` (10 pins — the exported helper is
|
|
|
1961
2303
|
CONSUMED by the tests, never re-implemented; mock wire only, no vendor
|
|
1962
2304
|
wire).
|
|
1963
2305
|
|
|
2306
|
+
### 11.21 Resource name expansion and the name cache (issue #65, default-on since #80)
|
|
2307
|
+
|
|
2308
|
+
`operations.expandResourceNames` (section 7.7) resolves vendor numeric
|
|
2309
|
+
resource ids to display names with PER-ID `resources.get` reads ONLY.
|
|
2310
|
+
Its channel is `resourceNameCache: { ttlMs?, maxEntries? }` — ON by
|
|
2311
|
+
default (issue #80: absent builds the default instance, 1 h / 1024
|
|
2312
|
+
entries); `resourceNameCache: false` is the uncached per-id path,
|
|
2313
|
+
bit-for-bit (per-call de-duplication is unconditional).
|
|
2314
|
+
|
|
2315
|
+
**The channel** (`src/resourceNameCache.ts` — a THIRD cache channel
|
|
2316
|
+
beside `cache` and `fieldCache`; a separate channel, NOT shared with
|
|
2317
|
+
either):
|
|
2318
|
+
|
|
2319
|
+
- **Key space**: `resources:<id>` (the canonical id). Disjoint from the
|
|
2320
|
+
ReadCache (wire-PATH keys) and the FieldCache (`resource:kind` keys —
|
|
2321
|
+
the closed three-kind metadata set). The namespace leaves a documented
|
|
2322
|
+
path to future FK tables — v1 serves `resources` only.
|
|
2323
|
+
- **What is stored**: exactly the two deterministic vendor answers — the
|
|
2324
|
+
resolved name `{ name, blind: false }` and the vendor's TRUE not-found
|
|
2325
|
+
blind marker `{ name: null, blind: true }` (the issue-#36
|
|
2326
|
+
absent-marker precedent: a repeat miss costs no wire). A 200 WITH a
|
|
2327
|
+
record but NO name part is NOT stored (a transient partial record
|
|
2328
|
+
must not stick for the whole TTL). A FAILURE is never cached
|
|
2329
|
+
(a fault rejects before any `set` can run).
|
|
2330
|
+
- **In-flight de-duplication**: concurrent callers for the same id share
|
|
2331
|
+
ONE wire request (the promise is dropped on settle, success OR
|
|
2332
|
+
failure — the fieldCache two-armed pattern).
|
|
2333
|
+
- **Cap / TTL**: `ttlMs` an optional positive integer, default 1 h (fail-closed
|
|
2334
|
+
`AutotaskConfigError`); `maxEntries` default 1024
|
|
2335
|
+
(`RESOURCE_NAME_CACHE_MAX_ENTRIES`); past the cap → purge expired
|
|
2336
|
+
first, then evict the oldest-inserted (lazy purge on read).
|
|
2337
|
+
- **Scoped invalidation**: a real write to the `resources` resource
|
|
2338
|
+
through the same client (a rename via this client) clears the channel;
|
|
2339
|
+
a write to ANY OTHER resource never invalidates (it says nothing about
|
|
2340
|
+
names — the fieldCache "an ordinary record write never invalidates"
|
|
2341
|
+
rule, scoped variant). The existing ReadCache all-write clear is
|
|
2342
|
+
UNCHANGED.
|
|
2343
|
+
- **Coexistence with the ReadCache** (when BOTH are configured): the
|
|
2344
|
+
op's per-id `get` rides the ReadCache transparently (a ReadCache hit
|
|
2345
|
+
inside its TTL costs zero wire and the name is then stored in the name
|
|
2346
|
+
cache — double layering, disjoint key spaces). A `resources` write
|
|
2347
|
+
clears BOTH; a write to another resource clears only the ReadCache. A
|
|
2348
|
+
`fresh: true` call bypasses BOTH channels and refreshes both (the
|
|
2349
|
+
per-id GET fetches live — issue #77: a rename made elsewhere is served
|
|
2350
|
+
on the very next fresh call, never a stale snapshot).
|
|
2351
|
+
- **Zero interaction with the #64 field-cache surface**: `FIELD_CACHE_KINDS`
|
|
2352
|
+
stays the closed `['info','fields','udf']`; the fieldCatalog bag, its
|
|
2353
|
+
honesty wording and the `changeStatus` live guard and its
|
|
2354
|
+
`getFieldInfo` path are bit-for-bit untouched. The only shared code is
|
|
2355
|
+
the `http.request` completion hook, which gains ONE
|
|
2356
|
+
scoped-invalidation line (the fieldCache drift block and the readCache
|
|
2357
|
+
all-write clear are unmodified).
|
|
2358
|
+
- **NO new row in `src/cache-ttl-tables.ts`** (stated so the absence is
|
|
2359
|
+
not hunted): the artifact's row set is pinned at exactly one row per
|
|
2360
|
+
PLAN RESOURCE, and issue #65 adds a plan OPERATION, not a plan
|
|
2361
|
+
resource. The rows — including `resources: 300000`, the ReadCache
|
|
2362
|
+
per-resource TTL REFERENCE for `resources.get` record reads — are
|
|
2363
|
+
unchanged; the name channel's TTL is the operator's
|
|
2364
|
+
`resourceNameCache.ttlMs`, not the table's row. Only the artifact's
|
|
2365
|
+
`planHash` header line re-stamps (the plan bytes move).
|
|
2366
|
+
|
|
2367
|
+
**Staleness contract**: a resource renamed OUTSIDE this client (the UI,
|
|
2368
|
+
another integration) serves its old name until `ttlMs` expires (or a
|
|
2369
|
+
local `resources` write clears the channel) — the exact staleness trade
|
|
2370
|
+
every house cache documents ("a cached read is a snapshot AT FETCH
|
|
2371
|
+
TIME"). A `fresh: true` call closes the gap immediately: it bypasses
|
|
2372
|
+
BOTH the name cache and the ReadCache and refreshes both, so the rename
|
|
2373
|
+
is served on the very next fresh call (issue #77).
|
|
2374
|
+
|
|
2375
|
+
**Composite wiring (issue #77)**: the full-context composites
|
|
2376
|
+
(`tickets.fullContext`, `companies.fullProfile`,
|
|
2377
|
+
`configuration_items.profile`, `projects.getContext`) accept an
|
|
2378
|
+
additive `names?: boolean` opt (default OFF — the sibling keys are
|
|
2379
|
+
ABSENT from every served row and no name/label resolution runs, so the
|
|
2380
|
+
output is byte-identical) + `maxNameReads?: number` (default 20,
|
|
2381
|
+
lowerable, never raised). With `names: true`, the siblings ride the
|
|
2382
|
+
EXISTING default-on channels — no new cache: resource ids (creator /
|
|
2383
|
+
owner / lead / the time-entry resource) resolve through
|
|
2384
|
+
`operations.expandResourceNames` (name cache → ReadCache → one wire read
|
|
2385
|
+
per distinct uncached id); record-FK names (role / billing code /
|
|
2386
|
+
contract) ride the per-resource single-record gets (ReadCache, the
|
|
2387
|
+
per-resource TTLs, the #36 absent-marker contract untouched); the CLOSED
|
|
2388
|
+
picklist scope (owner-approved list: the ticket fields
|
|
2389
|
+
`status` / `priority` / `ticketType` / `issueType` / `subIssueType` /
|
|
2390
|
+
`source` / `queue` ONLY — project / company picklists stay label-less)
|
|
2391
|
+
rides ONE `operations.fieldCatalog` `labels: true` call (zero extra wire
|
|
2392
|
+
on a warm fieldCache). The separate name-read budget counts COLD wire
|
|
2393
|
+
name reads only (cache hits are free of it); over-budget ids serve
|
|
2394
|
+
`name: null, capped: true` on the composite's name row + the top-level
|
|
2395
|
+
`truncated` marker — never an error. An unresolvable id serves the blind
|
|
2396
|
+
marker (`name: null, blind: true`); a picklist whose values the vendor
|
|
2397
|
+
does not expose to this API user serves `null` + the fieldCatalog
|
|
2398
|
+
reason row (the #64 W-A4 wall) — never an error. A picklist that IS served but does not
|
|
2399
|
+
contain the record's value serves `null` + a different reason row
|
|
2400
|
+
(`PICKLIST_VALUE_NOT_SERVED_NOTE`: an inactive or unexposed value) so the two conditions can be
|
|
2401
|
+
told apart. The `names` section
|
|
2402
|
+
(present on the result EXACTLY when `names: true`) carries the per-id
|
|
2403
|
+
rows and the reason rows.
|
|
2404
|
+
|
|
2405
|
+
**AC mapping**: AC1 (opt-in; the explicit-`false` output unchanged) —
|
|
2406
|
+
the op is invoked only on request and the channel is default-on (issue
|
|
2407
|
+
#80; `resourceNameCache: false` keeps the uncached per-id path
|
|
2408
|
+
bit-for-bit — the explicit-`false` pins are that proof). AC2 (each
|
|
2409
|
+
distinct id fetched at most once per cache lifetime) —
|
|
2410
|
+
per-call de-duplication is ALWAYS on (zero-config); with the channel
|
|
2411
|
+
configured, a repeat id (resolved OR blind) inside the TTL costs no
|
|
2412
|
+
wire; a failure is never cached (the AC governs the cache's stored
|
|
2413
|
+
answers, not faults). AC3 (the invisible-table case) — a per-id TRUE
|
|
2414
|
+
not-found → `{ name: null, blind: true }`, the id kept, no error, no
|
|
2415
|
+
throw, the op RESOLVES with the summary counts. AC4 (no `id in`) — the
|
|
2416
|
+
only wire verb is the per-id GET; the module imports no query builder
|
|
2417
|
+
(static guarantee) and the tests assert METHOD + PATH on every wire
|
|
2418
|
+
call.
|
|
2419
|
+
|
|
2420
|
+
Pins: `test/operations/expandResourceNames.test.ts` (11 pins — the plan
|
|
2421
|
+
row's `tests[]`) + `test/resource-name-cache.test.ts` (7 pins — the
|
|
2422
|
+
class unit + the client-option wiring + the scoped invalidation; mock
|
|
2423
|
+
wire only, synthetic data, no vendor wire).
|
|
2424
|
+
|
|
2425
|
+
### 11.22 Timezone-anchored date windows for billing items and invoices (issue #66)
|
|
2426
|
+
|
|
2427
|
+
The time-entry window helpers pinned their bounds to UTC (the API is UTC)
|
|
2428
|
+
while the node computes them in the configured timezone, and billing items /
|
|
2429
|
+
invoices had no window helpers at all. Issue #66 makes the zone EXPLICIT and
|
|
2430
|
+
additive, and mirrors the family:
|
|
2431
|
+
|
|
2432
|
+
- **Two new window families** — the full mirror of the time-entry helpers
|
|
2433
|
+
(the same 12 ops each: `today`, `yesterday`, `last24h`, `last7`, `last14`,
|
|
2434
|
+
`last30`, `last90`, `lastFullWeek`, `lastFullFortnight`, `lastFullMonth`,
|
|
2435
|
+
`lastFullQuarter`, `customRange`):
|
|
2436
|
+
- `billing_items.<kind>(opts?)` — ONE bounded server-filtered query on
|
|
2437
|
+
the billing item's date field **`itemDate`**
|
|
2438
|
+
(`POST /V1.0/BillingItems/query`) → `Promise<BillingItems[]>`;
|
|
2439
|
+
- `invoices.<kind>(opts?)` — ONE bounded server-filtered query on the
|
|
2440
|
+
invoice's date-time field **`invoiceDateTime`**
|
|
2441
|
+
(`POST /V1.0/Invoices/query`) → `Promise<Invoice[]>` (the invoices
|
|
2442
|
+
model has no plain `invoiceDate` — `fromDate`/`toDate` are range
|
|
2443
|
+
fields, not the invoice date).
|
|
2444
|
+
- **The zone contract (shared)** — `opts.tz` takes an explicit IANA zone
|
|
2445
|
+
(e.g. `'Australia/Sydney'`); OMITTED `tz` = UTC, the current behaviour
|
|
2446
|
+
bit-for-bit (the regression pin). With a zone, the window's calendar
|
|
2447
|
+
day/period is computed in that zone's LOCAL calendar and converted to UTC
|
|
2448
|
+
for the wire (vendor filter values stay second-precision UTC ISO). DST is
|
|
2449
|
+
handled by inverting local midnights to UTC instants (a local day that
|
|
2450
|
+
gains or loses an hour still pins both boundaries to its TRUE midnights —
|
|
2451
|
+
never clock-hour arithmetic); `last24h` is rolling (`now − 24h → now`)
|
|
2452
|
+
and TZ-invariant by construction. A zone `Intl.DateTimeFormat` rejects is
|
|
2453
|
+
refused pre-request as a typed `CONFIG_ERROR` (zero wire). There is no
|
|
2454
|
+
config-level timezone in v1 (the zone is per-call; a future
|
|
2455
|
+
`config.timeZone` default would be an additive follow-up).
|
|
2456
|
+
- **The existing family gains the same opt** — the 12 `time_entries`
|
|
2457
|
+
window ops (incl. `customRange`) plus `postedWithin` and its
|
|
2458
|
+
`dateWorkedWindow` alias accept `opts.tz`
|
|
2459
|
+
with the identical contract, so the whole window family is uniformly
|
|
2460
|
+
timezone-anchored (default UTC unchanged).
|
|
2461
|
+
- **Shape and wire** — each op is exactly ONE bounded query (`MaxRecords`
|
|
2462
|
+
1-500, default 25; the `itemDate` / `invoiceDateTime` `gte`/`lte` filter
|
|
2463
|
+
rides the wire), and the result is the plain `items[]` array — no counts
|
|
2464
|
+
(the time-entry family shape).
|
|
2465
|
+
|
|
2466
|
+
### 11.23 Free-text caps and includeFields presets (issue #61)
|
|
2467
|
+
|
|
2468
|
+
Issue #61 (the library remainder after the server-side split — TSV /
|
|
2469
|
+
`output_path` / the untrusted-wrapper opt-out live in the server repo)
|
|
2470
|
+
adds two opt-in keys to the bounded-query bag (section 4), both
|
|
2471
|
+
query-verb-ONLY and zero-wire or vendor-column-selection-only:
|
|
2472
|
+
|
|
2473
|
+
- **`noteChars: N` — the free-text cap.** One integer (>= 1) caps EVERY
|
|
2474
|
+
string value in the resource's untrusted-class free-text fields (the
|
|
2475
|
+
generated `UNTRUSTED_FIELDS_BY_RESOURCE` set — the SAME plan column the
|
|
2476
|
+
projection wraps) to `N` chars + the explicit marker
|
|
2477
|
+
`… [+N chars truncated]` (N = chars removed). Truncation is at
|
|
2478
|
+
result-build, copy-on-truncate (input records are never mutated), and
|
|
2479
|
+
runs BEFORE the server adds its untrusted wrapper (section 12's
|
|
2480
|
+
ordering note). Additive result field: `noteCharsTruncated` (the
|
|
2481
|
+
truncated-field count; present only when > 0). Absent = the raw result
|
|
2482
|
+
bit-for-bit.
|
|
2483
|
+
- **`fieldsPreset: name` — the closed preset list.** A preset is
|
|
2484
|
+
`{ resource, fields }` — hand-curated (NOT plan-derived: a curation
|
|
2485
|
+
choice, same class as n8n-mcp.curation.json), resolved pre-walk to the
|
|
2486
|
+
walk's `includeFields` (the vendor's column selection; `id` always
|
|
2487
|
+
appended). The v1 list is CLOSED at the one preset the issue names:
|
|
2488
|
+
|
|
2489
|
+
| preset | resource | fields (14) |
|
|
2490
|
+
|---|---|---|
|
|
2491
|
+
| `billing_join` | `billing_items` | `companyID, ticketID, projectID, timeEntryID, contractServiceID, contractID, billingCodeID, expenseItemID, itemDate, quantity, rate, extendedPrice, totalAmount, nonBillable` |
|
|
2492
|
+
|
|
2493
|
+
`billing_join` is the billing-item join read: the FK columns that join a
|
|
2494
|
+
billing item to its company / ticket / project / time entry / contract
|
|
2495
|
+
service / contract / billing code / expense item, plus the date and the
|
|
2496
|
+
amount fields — the "common narrow read" the issue's example targets.
|
|
2497
|
+
- **The live check (the AC test for the presets).** Each preset's fields
|
|
2498
|
+
are asserted to EXIST on the live resource against the LIVE field
|
|
2499
|
+
catalogue (`operations.fieldCatalog` — the entityInformation/fields
|
|
2500
|
+
channel; the #64 fieldCache idiom), NOT against `MODEL_FIELD_SETS`
|
|
2501
|
+
(the vendored-spec projection — a cheap NON-AUTHORITATIVE subset guard
|
|
2502
|
+
only; the spec can lag the live API). The check runs in the live round
|
|
2503
|
+
(`npm run live-smoke` — read-only, one field-catalogue read per preset;
|
|
2504
|
+
a missing field FAILS the run), not in `npm test`. The v1 field list's
|
|
2505
|
+
LIVE existence is verified there — the offline proof is the spec-subset
|
|
2506
|
+
guard only (the review correction's mandate, the `fetchMany.liveFields`
|
|
2507
|
+
live-first idiom).
|
|
2508
|
+
- **Refusals (pre-request, zero wire, typed):** unknown preset name (the
|
|
2509
|
+
valid names are named — the closed-set discipline), preset/resource
|
|
2510
|
+
mismatch (the preset and its resource are named), `fieldsPreset` + an
|
|
2511
|
+
explicit `includeFields` (both are named — no silent precedence), and
|
|
2512
|
+
`noteChars` `0` / negative / non-integer.
|
|
2513
|
+
- **Backwards compatibility:** both keys are OPT-IN on a closed bag —
|
|
2514
|
+
absent = the pre-#61 path bit-for-bit (the default-path zero-move is
|
|
2515
|
+
pinned by the note-chars sidecar tests). New public exports:
|
|
2516
|
+
`FIELD_PRESETS`, `FIELD_PRESET_NAMES`, `resolveFieldsPreset` and the
|
|
2517
|
+
`FieldsPreset` type (additive; the public-surface gate passes as
|
|
2518
|
+
additive-only). `UNTRUSTED_FIELDS_BY_RESOURCE` and
|
|
2519
|
+
`noteCharsTruncationMarker` are internal. `src/untrusted.ts` is byte-identical
|
|
2520
|
+
(the projection contract is untouched — the cap is a query-path
|
|
2521
|
+
mechanism, not a projection change).
|
|
2522
|
+
|
|
2523
|
+
|
|
2524
|
+
### 11.24 The unbilled-work composite and the generic aggregate (issue #62)
|
|
2525
|
+
|
|
2526
|
+
Issue #62 adds the two tools the issue names, both additive reads (the 0.9.0
|
|
2527
|
+
release carries them):
|
|
2528
|
+
|
|
2529
|
+
- **`companies.unbilledWork(id, opts?)` — the unbilled-work composite.** One
|
|
2530
|
+
call for a company's posted-but-uninvoiced work: its billing items (per
|
|
2531
|
+
`source`), the time behind them, the per-ticket rollup, the unposted
|
|
2532
|
+
anti-join section, ex-tax totals + hours, and the billing-vs-time warnings.
|
|
2533
|
+
The join path is the correction's core: **the vendored time_entries model
|
|
2534
|
+
carries NO company field** — the join goes through the company's tickets
|
|
2535
|
+
(`ticketID`) AND the company's projects' tasks (`taskID`; the vendored tasks
|
|
2536
|
+
model carries no companyID/ticketID, so tasks are reached via the company's
|
|
2537
|
+
projects). `source` is the closed 4-set: `posted_uninvoiced` (default —
|
|
2538
|
+
no `invoiceID` — `notExist`, the uninvoiced mark; live-verified that `eq 0` matches nothing), `invoice` (`invoiceID = invoiceId`;
|
|
2539
|
+
the invoice record itself is NOT fetched in v1 — the items already carry
|
|
2540
|
+
invoiceID/itemDate/amounts), `date_range` (`itemDate` window, both dates
|
|
2541
|
+
required pre-request), `tickets` (`ticketID in ticketIds`; the time part
|
|
2542
|
+
additionally joins those tickets directly). `dateFrom`/`dateTo` narrow the
|
|
2543
|
+
`itemDate` window in EVERY source (and `dateWorked` on the time part).
|
|
2544
|
+
`includeParent` (default true) pulls ONE level via `company.parentCompanyID`
|
|
2545
|
+
(dangling → `parent: null`, parent parts empty — NOT an error). Per-part
|
|
2546
|
+
`limit` (default 50, clamped 1..500) = the part's `MaxRecords` + the capped
|
|
2547
|
+
lists (`warnings`, the `unposted` anti-join); each part flags `capped` /
|
|
2548
|
+
`hasMore` + the typed `status` (`ok` / `blind` / `skipped` — the empty
|
|
2549
|
+
join set SKIPS the time query with the honest note; a filtered empty part is
|
|
2550
|
+
a TRUE zero). The unposted section = the fetched time entries whose id is in
|
|
2551
|
+
no fetched billing item's `timeEntryID` (client-side anti-join, zero wire).
|
|
2552
|
+
Warnings: `noTimeEntry` (billing items whose `timeEntryID` is empty/0) and
|
|
2553
|
+
`hourDelta` (billed `quantity` ≠ entry `hoursWorked` — strict: the vendor
|
|
2554
|
+
data is exact decimal hours, any mismatch is real; the epsilon alternative
|
|
2555
|
+
is a one-line change). Totals: `preTax` = Σ `extendedPrice`, `hours` = Σ
|
|
2556
|
+
time-entry `hoursWorked`, `entryCount`, with `totalAmount` / `taxDollars`
|
|
2557
|
+
surfaced alongside (Q2 field choice); the per-ticket rollup and the totals
|
|
2558
|
+
derive from the SAME folded rows (one source of truth). `includeNotes`
|
|
2559
|
+
(default false) + `noteChars` (default 400) cap the note fields
|
|
2560
|
+
(`summaryNotes` / `description`) with the #75 marker BEFORE the server-side
|
|
2561
|
+
untrusted wrapper (section 12's ordering note; section 11.23). **Wire
|
|
2562
|
+
budget: 6 bounded reads without a parent (company get + tickets + projects +
|
|
2563
|
+
tasks + billing + time queries) and 7 with a parent; zero mutating;
|
|
2564
|
+
`includeNotes`/`noteChars`/`rollup` add zero wire.** The join sets are
|
|
2565
|
+
capped at 300 ids per `in` list (sorted, first N) so the serialised filter
|
|
2566
|
+
stays under the 8000-char vendor cap — a company with a huge ticket/task set
|
|
2567
|
+
truncates the JOIN SET (not the results) with a documented part note (Q3:
|
|
2568
|
+
the wire caps refuse the honest alternative; the per-query `limit` is the
|
|
2569
|
+
softer first bound).
|
|
2570
|
+
- **`operations.aggregate(operation, filter, opts?)` — the generic
|
|
2571
|
+
aggregate/count** (section 7.8): the count-vs-walk split — the
|
|
2572
|
+
groupBy/sum-absent mode is ONE vendor `POST /query/count` (the S12-C
|
|
2573
|
+
blind-sentinel marking; a 0 on a plan-pinned blind surface is marked blind,
|
|
2574
|
+
never a silent absence — AC5), while the group/sum modes walk the named
|
|
2575
|
+
`<resource>.query` surface (page size `min(500, limit)`; `hasMore` /
|
|
2576
|
+
`totalScanned` carry the truncation honesty — AC4).
|
|
2577
|
+
|
|
2578
|
+
**Acceptance mapping (the body's 6 ACs, post-correction):** AC1 the wire
|
|
2579
|
+
budget (the 6(+1) bounded reads, pinned by the wire-call test; the per-part
|
|
2580
|
+
limits + `hasMore`); AC2 the warnings contract (the `noTimeEntry` +
|
|
2581
|
+
`hourDelta` folds); AC3 the notes default-off + the `noteChars` cap (the #75
|
|
2582
|
+
marker, BEFORE the wrapper); AC4 the truncation honesty (`hasMore` /
|
|
2583
|
+
`totalScanned`; no silent partial sums); AC5 the blind-count marking (the
|
|
2584
|
+
S12-C sentinel in count mode); AC6 the zero-mutating read surface (the
|
|
2585
|
+
`effect: read` plan rows; the governor appends nothing).
|
|
2586
|
+
|
|
2587
|
+
### 11.25 Explained resolve and search misses (issue #78)
|
|
2588
|
+
|
|
2589
|
+
`resolve` returns a bare `null` and `search` an empty list on a miss, which reads as a true
|
|
2590
|
+
zero even on a surface that is blind to records the API user does not own. Every resource
|
|
2591
|
+
with a primary text field gains two methods that keep `resolve` and `search` unchanged and
|
|
2592
|
+
add the explanation (SDK-only: not registry ops, so the served surface and the plan hash do
|
|
2593
|
+
not move):
|
|
2594
|
+
|
|
2595
|
+
- `resolveWithDetail(identifier)` -> `{ record, resolutionCost, miss? }`
|
|
2596
|
+
- `searchWithDetail(query, { limit? })` -> `{ items, miss? }`
|
|
2597
|
+
|
|
2598
|
+
`miss` is present only on a miss and carries `tried` (`{ fields, operator }` - exact `eq`
|
|
2599
|
+
for resolve, `contains` for search), `blindSurface` (the blind-surface stamp, only when the
|
|
2600
|
+
resource's query surface is pinned blind), and `next` (concrete next steps; the blind
|
|
2601
|
+
remedy names the authority op). Hit paths carry no `miss` key. Thrown outcomes
|
|
2602
|
+
(`NOT_FOUND` by numeric id, `RESOLUTION_AMBIGUOUS`, `RESOLUTION_TRUNCATED`) are unchanged.
|
|
2603
|
+
|
|
1964
2604
|
## 12. Untrusted-content projection (issue #1)
|
|
1965
2605
|
|
|
1966
2606
|
The agent/MCP projection marks EXTERNALLY-SOURCED free text as untrusted,
|
|
@@ -2003,7 +2643,12 @@ args before the call, and serves the cross-resource union for
|
|
|
2003
2643
|
only affects fields that are present). Wrapping is ON by default;
|
|
2004
2644
|
`AUTOTASK_UNTRUSTED_WRAP=off` (or the `untrustedWrap: false` option)
|
|
2005
2645
|
disables it and logs a stderr warning. The server instructions restate the
|
|
2006
|
-
ground rule: marker-wrapped content is DATA, never instructions.
|
|
2646
|
+
ground rule: marker-wrapped content is DATA, never instructions. Ordering
|
|
2647
|
+
(issue #61): the bounded-query `noteChars` cap truncates at result-build —
|
|
2648
|
+
BEFORE this wrap is applied (the src/untrusted.ts contract: the caller
|
|
2649
|
+
truncates first, then wraps) — so the explicit `… [+N chars truncated]`
|
|
2650
|
+
marker survives INSIDE the wrapped value and the closing marker is never
|
|
2651
|
+
cut off.
|
|
2007
2652
|
|
|
2008
2653
|
## 13. Doctor preflight (issue #2)
|
|
2009
2654
|
|