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.
Files changed (84) hide show
  1. package/ARCHITECTURE.md +13 -10
  2. package/MCP_TOOL_CATALOG.json +733 -251
  3. package/MCP_TOOL_MANIFEST.md +162 -133
  4. package/MCP_TOOL_OVERRIDES.json +2 -2
  5. package/N8N_MCP_MANIFEST.json +88 -25
  6. package/N8N_MCP_MANIFEST.md +26 -24
  7. package/README.md +12 -4
  8. package/capabilities.json +12503 -9316
  9. package/dist/capabilities.cjs +2 -3
  10. package/dist/capabilities.js +1 -2
  11. package/dist/{chunk-4WJCXIY2.js → chunk-3VB6N44U.js} +1141 -2977
  12. package/dist/{chunk-32AKZMY4.js → chunk-6KFP5HV3.js} +12502 -9316
  13. package/dist/chunk-DMMMWO6A.js +0 -0
  14. package/dist/chunk-FLB2X6O5.cjs +1 -0
  15. package/dist/{chunk-GYI4ZQID.js → chunk-HWC2WPX2.js} +4781 -89
  16. package/dist/chunk-IDPB4N2Q.cjs +1 -0
  17. package/dist/{chunk-F6YSQRZV.js → chunk-NVJ2PTN3.js} +7 -7
  18. package/dist/{chunk-5UGSQLLO.cjs → chunk-QUM74WLQ.cjs} +136 -136
  19. package/dist/{chunk-LAKEIYET.cjs → chunk-S7VMY5KN.cjs} +1468 -3304
  20. package/dist/chunk-UPQIUWOS.js +0 -0
  21. package/dist/{chunk-KNTUX3RR.cjs → chunk-WTVD2JZN.cjs} +12502 -9316
  22. package/dist/{chunk-V3XPLTIR.cjs → chunk-WTVLHMZA.cjs} +4784 -92
  23. package/dist/{index-B6JeThrY.d.ts → index-BOyh7S65.d.cts} +870 -79
  24. package/dist/{index-B0I2bGtx.d.cts → index-DGgMD8aq.d.ts} +870 -79
  25. package/dist/{index-DOCRp_83.d.cts → index-cQ6CRexR.d.cts} +158 -90
  26. package/dist/{index-DOCRp_83.d.ts → index-cQ6CRexR.d.ts} +158 -90
  27. package/dist/index.cjs +37 -22
  28. package/dist/index.d.cts +46 -5
  29. package/dist/index.d.ts +46 -5
  30. package/dist/index.js +48 -33
  31. package/dist/operations/index.cjs +480 -359
  32. package/dist/operations/index.d.cts +195 -16
  33. package/dist/operations/index.d.ts +195 -16
  34. package/dist/operations/index.js +370 -249
  35. package/dist/pool.cjs +36 -12
  36. package/dist/pool.d.cts +2 -3
  37. package/dist/pool.d.ts +2 -3
  38. package/dist/pool.js +33 -9
  39. package/dist/resources/index.cjs +5 -6
  40. package/dist/resources/index.d.cts +2 -2
  41. package/dist/resources/index.d.ts +2 -2
  42. package/dist/resources/index.js +4 -5
  43. package/dist/types/index.cjs +1 -2
  44. package/dist/types/index.d.cts +1 -1
  45. package/dist/types/index.d.ts +1 -1
  46. package/dist/types/index.js +1 -2
  47. package/dist/untrusted.cjs +0 -1
  48. package/dist/untrusted.js +0 -1
  49. package/docs/API.md +693 -48
  50. package/docs/N8N-ADAPTER.md +4 -4
  51. package/examples/basic.ts +1 -1
  52. package/package.json +5 -3
  53. package/dist/capabilities.cjs.map +0 -1
  54. package/dist/capabilities.js.map +0 -1
  55. package/dist/chunk-32AKZMY4.js.map +0 -1
  56. package/dist/chunk-4WJCXIY2.js.map +0 -1
  57. package/dist/chunk-5UGSQLLO.cjs.map +0 -1
  58. package/dist/chunk-CXM35TEC.cjs +0 -1
  59. package/dist/chunk-CXM35TEC.cjs.map +0 -1
  60. package/dist/chunk-F6YSQRZV.js.map +0 -1
  61. package/dist/chunk-GIVD2CQ3.js +0 -1
  62. package/dist/chunk-GIVD2CQ3.js.map +0 -1
  63. package/dist/chunk-GYI4ZQID.js.map +0 -1
  64. package/dist/chunk-KNTUX3RR.cjs.map +0 -1
  65. package/dist/chunk-LAKEIYET.cjs.map +0 -1
  66. package/dist/chunk-MHBMSBTA.js +0 -1
  67. package/dist/chunk-MHBMSBTA.js.map +0 -1
  68. package/dist/chunk-NZUFSSIZ.cjs +0 -1
  69. package/dist/chunk-NZUFSSIZ.cjs.map +0 -1
  70. package/dist/chunk-V3XPLTIR.cjs.map +0 -1
  71. package/dist/client-DHmr0Oyx.d.ts +0 -203
  72. package/dist/client-Q6NoqinP.d.cts +0 -203
  73. package/dist/index.cjs.map +0 -1
  74. package/dist/index.js.map +0 -1
  75. package/dist/operations/index.cjs.map +0 -1
  76. package/dist/operations/index.js.map +0 -1
  77. package/dist/pool.cjs.map +0 -1
  78. package/dist/pool.js.map +0 -1
  79. package/dist/resources/index.cjs.map +0 -1
  80. package/dist/resources/index.js.map +0 -1
  81. package/dist/types/index.cjs.map +0 -1
  82. package/dist/types/index.js.map +0 -1
  83. package/dist/untrusted.cjs.map +0 -1
  84. package/dist/untrusted.js.map +0 -1
@@ -1,2766 +1,39 @@
1
1
  import {
2
- AutotaskAbortError,
3
2
  AutotaskConfigError,
4
3
  AutotaskError,
5
- AutotaskNetworkError,
6
- AutotaskTimeoutError,
7
4
  BLIND_SURFACE_STAMPS,
8
- CREDENTIAL_ENV_LOAD_ACTION,
9
- CircuitBreakerError,
5
+ BaseResource,
10
6
  ConflictError,
11
- DEFAULT_PAGE_SIZE,
12
- DELETE_BOUNDARY_NOTES,
13
- EMPTY_REASON_STAMPS,
14
- EmptyResponseError,
15
- MAX_PAGES,
16
- MAX_PAGE_SIZE,
17
7
  NotFoundError,
18
- POINTER_NOTES,
19
- QueryTooLongError,
20
- REDACTED,
21
8
  RELATIONSHIP_MAP,
22
- RESPONSE_DRIFT_MARKERS,
23
- RateLimitError,
24
9
  ResolutionError,
25
- ResponseRejectedError,
26
10
  StaleObjectError,
27
- UnexpectedContentTypeError,
28
- buildBreakerKey,
29
- buildTenantKey,
30
- clampPageSize,
31
- collectAll,
32
- cooldownDurationMs,
33
- credentialShapeProblem,
34
- decideHostPolicy,
35
- defaultCircuitBreakerStore,
36
- defaultCooldownStore,
37
- errorFromStatus,
38
- formatVendorDetail,
39
- isAutotaskError,
40
- isBreakerFailure,
41
- isDeterministicVendor500,
42
- isIdentifierCandidate,
43
- isRecord,
44
- isRedactedFieldName,
45
- newCorrelationId,
46
- paginate,
47
- paginateItems,
48
- parseIdentifier,
49
- parseVendorErrors,
50
- redactAuditBody,
51
- redactResponseBody,
52
- totalPagesFor
53
- } from "./chunk-GYI4ZQID.js";
54
- import {
55
- CAPABILITIES
56
- } from "./chunk-32AKZMY4.js";
57
-
58
- // src/auth.ts
59
- var AUTH_HEADERS = {
60
- integrationCode: "ApiIntegrationCode",
61
- userName: "UserName",
62
- secret: "Secret",
63
- impersonation: "ImpersonationResourceId"
64
- };
65
- function buildAuthHeaders(cfg) {
66
- const headers = {
67
- [AUTH_HEADERS.integrationCode]: cfg.apiIntegrationCode,
68
- [AUTH_HEADERS.userName]: cfg.userName,
69
- [AUTH_HEADERS.secret]: cfg.secret
70
- };
71
- if (cfg.impersonationResourceId !== void 0 && cfg.impersonationResourceId.length > 0) {
72
- headers[AUTH_HEADERS.impersonation] = cfg.impersonationResourceId;
73
- }
74
- return headers;
75
- }
76
- function withAuthHeaders(headers, cfg) {
77
- const merged = { ...headers ?? {} };
78
- const auth = buildAuthHeaders(cfg);
79
- const owned = new Set(Object.keys(auth));
80
- for (const key of Object.keys(merged)) {
81
- if (owned.has(key)) delete merged[key];
82
- }
83
- return { ...merged, ...auth };
84
- }
85
-
86
- // src/query.ts
87
- var FILTER_OPERATORS = [
88
- "eq",
89
- "noteq",
90
- "gt",
91
- "gte",
92
- "lt",
93
- "lte",
94
- "beginsWith",
95
- "endsWith",
96
- "contains",
97
- "exist",
98
- "notExist",
99
- "in",
100
- "notIn",
101
- "and",
102
- "or"
103
- ];
104
- var GROUPING_OPERATORS = ["and", "or"];
105
- var MAX_OR_CONDITIONS = 500;
106
- var MAX_UDF_PER_QUERY = 1;
107
- var MAX_QUERY_LENGTH = 8e3;
108
- var MAX_GET_LENGTH = 2048;
109
- var MAX_FIELD_LENGTH = 128;
110
- function isFilterOperator(op) {
111
- return typeof op === "string" && FILTER_OPERATORS.includes(op);
112
- }
113
- function validateCondition(cond, path) {
114
- if (!isRecord(cond)) {
115
- throw new AutotaskConfigError(`Filter condition at ${path} must be an object`);
116
- }
117
- const op = cond.op;
118
- if (!isFilterOperator(op)) {
119
- throw new AutotaskConfigError(
120
- `Unknown filter operator "${String(op)}" at ${path}; expected one of: ${FILTER_OPERATORS.join(", ")}`
121
- );
122
- }
123
- if (op === "and" || op === "or") {
124
- if (!Array.isArray(cond.items) || cond.items.length === 0) {
125
- throw new AutotaskConfigError(`Grouping operator "${op}" requires a non-empty "items" array at ${path}`);
126
- }
127
- cond.items.forEach((item, i) => validateCondition(item, `${path}.items[${i}]`));
128
- return;
129
- }
130
- if (typeof cond.field !== "string" || cond.field.trim().length === 0) {
131
- throw new AutotaskConfigError(`Filter condition at ${path} requires a non-empty "field"`);
132
- }
133
- if (cond.field.length > MAX_FIELD_LENGTH) {
134
- throw new AutotaskConfigError(
135
- `Filter field name at ${path} is ${cond.field.length} chars; the cap is ${MAX_FIELD_LENGTH}`
136
- );
137
- }
138
- if (op === "in" || op === "notIn") {
139
- if (!Array.isArray(cond.value)) {
140
- throw new AutotaskConfigError(`Operator "${op}" requires an array "value" at ${path}`);
141
- }
142
- }
143
- if (cond.value !== void 0) {
144
- const values = Array.isArray(cond.value) ? cond.value : [cond.value];
145
- for (const value of values) {
146
- if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
147
- throw new AutotaskConfigError(`Filter value at ${path} must be a string, number or boolean`);
148
- }
149
- }
150
- }
151
- }
152
- function countOrConditions(conditions) {
153
- let n = 0;
154
- for (const cond of conditions) {
155
- if (!isRecord(cond)) continue;
156
- if (cond.op === "or" && Array.isArray(cond.items)) n += cond.items.length;
157
- if (Array.isArray(cond.items)) n += countOrConditions(cond.items);
158
- }
159
- return n;
160
- }
161
- function countUdfConditions(conditions) {
162
- let n = 0;
163
- for (const cond of conditions) {
164
- if (!isRecord(cond)) continue;
165
- if (cond.udf === true) n += 1;
166
- if (Array.isArray(cond.items)) n += countUdfConditions(cond.items);
167
- }
168
- return n;
169
- }
170
- function serializeFilter(filter) {
171
- return JSON.stringify(filter);
172
- }
173
- function validateFilter(filter) {
174
- if (!isRecord(filter) || !Array.isArray(filter.filter)) {
175
- throw new AutotaskConfigError("Filter must be the shape { filter: FilterCondition[] }");
176
- }
177
- filter.filter.forEach((condition, i) => validateCondition(condition, `filter[${i}]`));
178
- const orCount = countOrConditions(filter.filter);
179
- if (orCount > MAX_OR_CONDITIONS) {
180
- throw new AutotaskConfigError(
181
- `Query has ${orCount} OR-conditions; the vendor cap is ${MAX_OR_CONDITIONS} \u2014 split the query`
182
- );
183
- }
184
- const udfCount = countUdfConditions(filter.filter);
185
- if (udfCount > MAX_UDF_PER_QUERY) {
186
- throw new AutotaskConfigError(
187
- `Query has ${udfCount} UDF conditions (udf: true); the vendor allows at most ${MAX_UDF_PER_QUERY} per query`
188
- );
189
- }
190
- const length = serializeFilter(filter).length;
191
- if (length > MAX_QUERY_LENGTH) {
192
- throw new AutotaskConfigError(
193
- `Serialised filter is ${length} chars; the vendor cap is ${MAX_QUERY_LENGTH} \u2014 narrow the query`
194
- );
195
- }
196
- }
197
- function buildFilter(conditions) {
198
- const list = Array.isArray(conditions) ? conditions : [conditions];
199
- if (list.length === 0) {
200
- throw new AutotaskConfigError("buildFilter requires at least one condition");
201
- }
202
- const root = list.length === 1 ? list[0] : { op: "and", items: list };
203
- const filter = { filter: [root] };
204
- validateFilter(filter);
205
- return filter;
206
- }
207
-
208
- // src/http.ts
209
- import { AsyncLocalStorage } from "async_hooks";
210
- var IDEMPOTENT_METHODS = ["GET", "PUT", "DELETE"];
211
- var BACKOFF_BASE_MS = 200;
212
- var BACKOFF_CAP_MS = 3e4;
213
- var MAX_RETRY_DELAY_MS = 6e4;
214
- var HOUR_MS = 36e5;
215
- function assertSafePath(path) {
216
- const pathPart = path.split("?", 1)[0];
217
- const bad = !path.startsWith("/") || /[#\\\u0000-\u001f\u007f]/.test(pathPart) || pathPart.split("/").some((segment) => {
218
- let decoded;
219
- try {
220
- decoded = decodeURIComponent(segment);
221
- } catch {
222
- return true;
223
- }
224
- return decoded === "." || decoded === ".." || /[/\\]/.test(decoded);
225
- });
226
- if (bad) {
227
- throw new AutotaskConfigError(`Refused request path ${JSON.stringify(path.slice(0, 200))}: not a plain absolute API path (dot segment, '#', '\\', control character or missing leading '/')`);
228
- }
229
- }
230
- function buildUrl(baseUrl, path, query) {
231
- assertSafePath(path);
232
- let url = baseUrl + path;
233
- if (new URL(url).origin !== new URL(baseUrl).origin) {
234
- throw new AutotaskConfigError(`Refused request path ${JSON.stringify(path.slice(0, 200))}: it leaves the configured origin`);
235
- }
236
- if (query !== void 0) {
237
- const params = new URLSearchParams();
238
- for (const [key, value] of Object.entries(query)) {
239
- if (value === null || value === void 0 || value === "") continue;
240
- params.set(key, String(value));
241
- }
242
- const qs = params.toString();
243
- if (qs.length > 0) url += `?${qs}`;
244
- }
245
- return url;
246
- }
247
- function cursorToPath(baseUrl, cursor) {
248
- const base = new URL(baseUrl);
249
- let url;
250
- try {
251
- url = new URL(cursor, base);
252
- } catch {
253
- throw new ResponseRejectedError("Refused the pagination cursor: not a parseable URL");
254
- }
255
- if (url.origin !== base.origin) {
256
- throw new ResponseRejectedError(`Refused the pagination cursor: it points off the configured origin (${url.origin})`);
257
- }
258
- const basePath = base.pathname.replace(/\/+$/, "");
259
- const path = basePath !== "" && url.pathname.startsWith(`${basePath}/`) ? url.pathname.slice(basePath.length) : url.pathname;
260
- return path + url.search;
261
- }
262
- var ZONE_DISCOVERY_URL = "https://webservices.autotask.net/atservicesrest/v1.0/zoneInformation";
263
- function normalizeZoneBase(raw) {
264
- let url;
265
- try {
266
- url = new URL(raw);
267
- } catch {
268
- return void 0;
269
- }
270
- if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
271
- return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
272
- }
273
- function buildQueryRequest(baseUrl, resourcePath, params) {
274
- const queryPath = `${resourcePath}/query`;
275
- const raw = params.filter;
276
- const conditions = raw === void 0 ? void 0 : Array.isArray(raw) ? raw : raw.filter;
277
- const filter = conditions === void 0 ? void 0 : buildFilter(conditions);
278
- if (params.via === "get") {
279
- const search = {};
280
- if (filter !== void 0) search.filter = filter.filter;
281
- if (params.maxRecords !== void 0) search.maxRecords = params.maxRecords;
282
- if (params.includeFields !== void 0) search.includeFields = params.includeFields;
283
- const req = {
284
- method: "GET",
285
- path: queryPath,
286
- query: { search: JSON.stringify(search) }
287
- };
288
- const url = buildUrl(baseUrl, queryPath, req.query);
289
- if (url.length > MAX_GET_LENGTH) {
290
- throw new QueryTooLongError(
291
- `GET query URL is ${url.length} chars; the vendor GET cap is ${MAX_GET_LENGTH}`
292
- );
293
- }
294
- return req;
295
- }
296
- const body = {};
297
- if (params.maxRecords !== void 0) body.MaxRecords = params.maxRecords;
298
- if (params.includeFields !== void 0) body.IncludeFields = params.includeFields;
299
- body.filter = filter === void 0 ? [] : filter.filter;
300
- return { method: "POST", path: queryPath, body, idempotent: true };
301
- }
302
- var ambientSignal = new AsyncLocalStorage();
303
- function runWithSignal(signal, fn) {
304
- return ambientSignal.run(signal, fn);
305
- }
306
- function sleep(ms, signal) {
307
- return new Promise((resolve, reject2) => {
308
- if (signal.aborted) return reject2(signal.reason);
309
- const onAbort = () => {
310
- clearTimeout(timer);
311
- reject2(signal.reason);
312
- };
313
- const timer = setTimeout(() => {
314
- signal.removeEventListener("abort", onAbort);
315
- resolve();
316
- }, ms);
317
- signal.addEventListener("abort", onAbort, { once: true });
318
- });
319
- }
320
- async function readBodyText(res, max, what, ctx) {
321
- const tooLarge = () => new ResponseRejectedError(`Response for ${what} exceeds maxResponseBytes (${max})`, {
322
- ...ctx,
323
- code: "RESPONSE_TOO_LARGE",
324
- suggestedAction: "Narrow the query (filter/includeFields) or raise maxResponseBytes."
325
- });
326
- const declared = Number(res.headers.get("Content-Length"));
327
- if (Number.isFinite(declared) && declared > max) {
328
- await res.body?.cancel().catch(() => void 0);
329
- throw tooLarge();
330
- }
331
- if (res.body === null) return "";
332
- const reader = res.body.getReader();
333
- const decoder = new TextDecoder();
334
- let total = 0;
335
- let text = "";
336
- for (; ; ) {
337
- const { done, value } = await reader.read();
338
- if (done) break;
339
- total += value.byteLength;
340
- if (total > max) {
341
- await reader.cancel().catch(() => void 0);
342
- throw tooLarge();
343
- }
344
- text += decoder.decode(value, { stream: true });
345
- }
346
- return text + decoder.decode();
347
- }
348
- function backoffDelay(attempt) {
349
- const exp = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
350
- return Math.floor(exp / 2 + Math.random() * (exp / 2));
351
- }
352
- function parseRetryAfter(value) {
353
- if (value === null || value.length === 0) return void 0;
354
- const trimmed = value.trim();
355
- if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
356
- if (/^[+-]?\d+$/.test(trimmed)) return void 0;
357
- const asDate = Date.parse(trimmed);
358
- if (Number.isFinite(asDate)) {
359
- const secs = Math.ceil((asDate - Date.now()) / 1e3);
360
- return Math.max(secs, 0);
361
- }
362
- return void 0;
363
- }
364
- function isAbortLike(err) {
365
- return typeof err === "object" && err !== null && "name" in err && err.name === "AbortError";
366
- }
367
- function isJsonMediaType(header) {
368
- const mediaType = header.split(";")[0]?.trim().toLowerCase() ?? "";
369
- return mediaType.startsWith("application/json") || mediaType.endsWith("+json");
370
- }
371
- function bodySnippet(text, redactPersonalData) {
372
- const bytes = Buffer.from(text, "utf8").subarray(0, 256).toString("utf8");
373
- return redactResponseBody(bytes, { redactPersonalData });
374
- }
375
- var CREDENTIAL_HEADER_NAMES = /* @__PURE__ */ new Set([
376
- "ApiIntegrationCode",
377
- "UserName",
378
- "Secret",
379
- "ImpersonationResourceId"
380
- ]);
381
- function assertCredentialShape(headers, auth) {
382
- const owned = new Set(CREDENTIAL_HEADER_NAMES);
383
- for (const name of Object.keys(buildAuthHeaders(auth))) owned.add(name);
384
- for (const name of Object.keys(headers)) {
385
- if (!owned.has(name)) continue;
386
- const value = headers[name];
387
- const shape = credentialShapeProblem(value.trim(), { skipEdgeWhitespace: true });
388
- if (shape === void 0) continue;
389
- throw new AutotaskConfigError(
390
- `${name} ${CREDENTIAL_ENV_LOAD_ACTION} (${shape})`,
391
- {
392
- code: "INVALID_CREDENTIAL_SHAPE",
393
- suggestedAction: "Load the credential with the shell (`set -a; . ./.env; set +a`) or with loadAutotaskCredentialsFromEnv() from node-autotask; single-quoted values must be shell-sourced, not parsed by a quote-preserving loader. No request was sent."
394
- }
395
- );
396
- }
397
- }
398
- function credentialShapeExpectation(headers, auth) {
399
- const owned = new Set(CREDENTIAL_HEADER_NAMES);
400
- for (const name of Object.keys(buildAuthHeaders(auth))) owned.add(name);
401
- const names = Object.keys(headers).filter((name) => owned.has(name));
402
- if (names.length === 0) return void 0;
403
- let problem;
404
- for (const name of names) {
405
- const value = headers[name];
406
- problem = credentialShapeProblem(value.trim(), { skipEdgeWhitespace: true });
407
- if (problem !== void 0) break;
408
- }
409
- return {
410
- headerNames: names,
411
- allValuesClean: problem === void 0,
412
- ...problem !== void 0 ? { problem } : {}
413
- };
414
- }
415
- var HttpClient = class {
416
- /**
417
- * The LIVE base URL (issue #43): the construction-time value until a zone
418
- * re-point happens, then the tenant's newly resolved zone base. Reads are
419
- * source-compatible with the pre-#43 readonly field; the ONLY write site
420
- * is the 401 recovery's `repointZoneBase` (process-lifetime, never
421
- * persisted — like a cache). The configured base (`config.baseUrl`)
422
- * stays the construction-time value; `baseUrl` is where the client
423
- * TALKS.
424
- */
425
- get baseUrl() {
426
- return this.liveBaseUrl;
427
- }
428
- maxConcurrent;
429
- timeoutMs;
430
- /** Bounded client-scan caps for resolution (section 19.2; per-client). */
431
- resolution;
432
- /** Opt-in single-record read cache (undefined = off, the default). */
433
- readCache;
434
- /** Opt-in field-metadata cache for the entityInformation trio (undefined = off, the default). */
435
- fieldCache;
436
- /** Resolved update fallback gate (issue #34; OFF by default). */
437
- update;
438
- auth;
439
- maxRetries;
440
- maxResponseBytes;
441
- perHour;
442
- /** The resolved profile (issue #4) — the agent profile changes the
443
- * exhaustion `suggestedAction` (stop/narrow, never "raise the limit"). */
444
- profile;
445
- /** Request timestamps inside the trailing hour (only used when `rateLimit.perHour` is set). */
446
- hourStamps = [];
447
- /** Tenant 429 cooldown gate store (undefined when the gate is disabled). */
448
- cooldownStore;
449
- cooldownMaxMs;
450
- cooldownDefaultMs;
451
- /** The live base URL (issue #43 — the `baseUrl` getter's backing field;
452
- * re-pointed by the 401 recovery's `repointZoneBase`). */
453
- liveBaseUrl;
454
- /** The resolved named zone (issue #43): the CATALOG-base marker for the
455
- * re-point gate — `undefined` for a bare explicit baseUrl, `'other'` for
456
- * an explicit baseUrl named "other"; neither class is re-pointable.
457
- * (The construction-time value — a re-point changes the live base, not
458
- * the configured zone.) */
459
- zone;
460
- /** The resolved zone re-resolution gate (issue #43; OFF by default). */
461
- reResolveZoneOn401;
462
- /** The resolved opt-in host bounds (skill 3.5.0 / L2, finding A1; absent
463
- * when the caller supplied no `hostPolicy` — the byte-identical default).
464
- * Gates the issue-#43 re-point target: a refused host is a discovery
465
- * fault (the original 401 propagates byte-identically, zero wire). */
466
- hostPolicy;
467
- /** The gate key for this client's tenant (lowercased userName + host).
468
- * Re-derived on a zone re-point (issue #43 — the re-pointed host starts
469
- * with fresh per-host state). */
470
- tenantKey;
471
- /** The host circuit breaker settings (issue #26; undefined when the
472
- * breaker is off — the default, in which case the gate/recording sites
473
- * are skipped and no store entry is ever written). */
474
- breaker;
475
- /** The breaker key for this client's host (lowercased host of the base
476
- * URL — host-only: vendor health is host-scoped, issue #26). Re-derived
477
- * on a zone re-point (issue #43). */
478
- breakerKey;
479
- logger;
480
- /** Response redaction policy (the INV-A1 hotfix; PII/CONFIDENTIAL default on, SECRET always).
481
- * Public read so the single dry-run envelope builder (base.ts `buildDryRunResult`)
482
- * threads the SAME resolved policy (INV-E M3 — the in-process input echo). */
483
- redactPersonalData;
484
- /** The configured UserName (the authenticated user). Public read so the
485
- * ZoneInformation resource can send the swagger-required `user` query
486
- * param (the same public-read precedent as redactPersonalData — the value
487
- * already travels in every auth header; nothing new crosses the boundary). */
488
- userName;
489
- onAudit;
490
- inFlight = 0;
491
- waitQueue = [];
492
- constructor(config) {
493
- this.liveBaseUrl = config.baseUrl;
494
- this.resolution = config.resolution;
495
- this.readCache = config.readCache;
496
- this.fieldCache = config.fieldCache;
497
- this.update = config.update;
498
- this.zone = config.zone;
499
- this.reResolveZoneOn401 = config.reResolveZoneOn401;
500
- this.hostPolicy = config.hostPolicy;
501
- this.onAudit = config.onAudit;
502
- this.auth = {
503
- apiIntegrationCode: config.apiIntegrationCode,
504
- userName: config.userName,
505
- secret: config.secret,
506
- impersonationResourceId: config.impersonationResourceId
507
- };
508
- this.maxRetries = config.maxRetries;
509
- this.maxResponseBytes = config.maxResponseBytes;
510
- this.perHour = config.rateLimit?.perHour;
511
- this.tenantKey = buildTenantKey(config.userName, config.baseUrl);
512
- this.cooldownStore = config.cooldown.enabled ? config.cooldown.store ?? defaultCooldownStore() : void 0;
513
- this.cooldownMaxMs = config.cooldown.maxMs;
514
- this.cooldownDefaultMs = config.cooldown.defaultMs;
515
- this.breakerKey = buildBreakerKey(config.baseUrl);
516
- this.breaker = config.circuitBreaker.enabled ? {
517
- failureWindowMs: config.circuitBreaker.failureWindowMs,
518
- failureThreshold: config.circuitBreaker.failureThreshold,
519
- openTimeoutMs: config.circuitBreaker.openTimeoutMs,
520
- successesToClose: config.circuitBreaker.successesToClose,
521
- halfOpenProbes: config.circuitBreaker.halfOpenProbes,
522
- store: config.circuitBreaker.store ?? defaultCircuitBreakerStore(),
523
- isFailure: config.circuitBreaker.isFailure ?? isBreakerFailure
524
- } : void 0;
525
- this.profile = config.profile;
526
- this.maxConcurrent = config.maxConcurrent;
527
- this.timeoutMs = config.timeoutMs;
528
- this.logger = config.logger;
529
- this.redactPersonalData = config.redactPersonalData;
530
- this.userName = config.userName;
531
- }
532
- /**
533
- * The LOCAL hourly-budget state (the sliding window this client counts):
534
- * `{ perHour, used, remaining }` — `undefined` when `rateLimit.perHour`
535
- * is off. The limiter is PER PROCESS (issue #4): it cannot see other
536
- * instances or integrations on the tenant — the tenant-wide count is
537
- * `thresholdInformation.get()`.
538
- */
539
- hourlyBudget() {
540
- if (this.perHour === void 0) return void 0;
541
- const now = Date.now();
542
- while (this.hourStamps.length > 0 && this.hourStamps[0] <= now - HOUR_MS) this.hourStamps.shift();
543
- return { perHour: this.perHour, used: this.hourStamps.length, remaining: Math.max(0, this.perHour - this.hourStamps.length) };
544
- }
545
- /**
546
- * Breaker success recording (issue #26): a 2xx that got through counts
547
- * toward `successesToClose` whenever the host has an armed state (open or
548
- * half-open — a half-open probe, or a threshold-bypass probe of a downed
549
- * host; decision Q10). A probe completion decrements the admitted-probe
550
- * budget. On reaching `successesToClose` the circuit CLOSES: counters
551
- * reset and the entry is DELETED (a healthy host leaves no trace — the
552
- * #6 delete-on-2xx precedent). A success while closed (or with no entry)
553
- * is a no-op: the closed failure window is a rate window that prunes
554
- * itself lazily, not a gate a success clears early.
555
- */
556
- recordBreakerSuccess(probe, probeKey) {
557
- const breaker = this.breaker;
558
- if (breaker === void 0) return;
559
- if (probe && (probeKey === void 0 || probeKey !== this.breakerKey)) return;
560
- const key = probe && probeKey !== void 0 ? probeKey : this.breakerKey;
561
- const st = breaker.store.get(key);
562
- if (st === void 0 || st.openedAt === null) return;
563
- const next = {
564
- failures: st.failures,
565
- openedAt: st.openedAt,
566
- successes: st.successes + 1,
567
- probes: probe ? Math.max(0, st.probes - 1) : st.probes
568
- };
569
- if (next.successes >= breaker.successesToClose) {
570
- breaker.store.delete(key);
571
- this.logger.warn?.(
572
- `[autotask] circuit breaker CLOSED for ${key} (${next.successes} probe successes)`,
573
- void 0
574
- );
575
- return;
576
- }
577
- breaker.store.set(key, next);
578
- }
579
- /**
580
- * Breaker failure recording (issue #26): called from the outer catch (the
581
- * single choke point every surfaced failure passes through) for errors
582
- * the failure predicate counts. While closed, a counted failure appends
583
- * to the window-pruned `failures` (keeping only timestamps strictly inside
584
- * `failureWindowMs`) and OPENS the circuit at `failureThreshold`. While
585
- * open / half-open (a probe failure, a racy late probe failure, or a
586
- * threshold-bypass failure) it RE-ARMS the open period from now and
587
- * resets the success count (the #6 "a 429 re-arms" precedent — a bypass
588
- * failure never fails silently).
589
- */
590
- recordBreakerFailure(probe, probeKey, correlationId) {
591
- const breaker = this.breaker;
592
- if (breaker === void 0) return;
593
- if (probe && (probeKey === void 0 || probeKey !== this.breakerKey)) return;
594
- const key = probe && probeKey !== void 0 ? probeKey : this.breakerKey;
595
- const now = Date.now();
596
- const st = breaker.store.get(key) ?? { failures: [], openedAt: null, successes: 0, probes: 0 };
597
- if (st.openedAt === null) {
598
- const failures = [...st.failures.filter((t) => t > now - breaker.failureWindowMs), now];
599
- if (failures.length >= breaker.failureThreshold) {
600
- breaker.store.set(key, { failures, openedAt: now, successes: 0, probes: 0 });
601
- this.logger.warn?.(
602
- `[autotask] circuit breaker OPEN for ${key} (${failures.length} counted failures in ${Math.round(breaker.failureWindowMs / 1e3)}s) \u2014 refusing locally for ${Math.round(breaker.openTimeoutMs / 1e3)}s`,
603
- correlationId
604
- );
605
- return;
606
- }
607
- breaker.store.set(key, { ...st, failures });
608
- return;
609
- }
610
- breaker.store.set(key, {
611
- failures: st.failures,
612
- openedAt: now,
613
- successes: 0,
614
- probes: probe ? Math.max(0, st.probes - 1) : st.probes
615
- });
616
- }
617
- acquire() {
618
- if (this.inFlight < this.maxConcurrent) {
619
- this.inFlight += 1;
620
- return Promise.resolve();
621
- }
622
- return new Promise((resolve) => {
623
- this.waitQueue.push(() => {
624
- this.inFlight += 1;
625
- resolve();
626
- });
627
- });
628
- }
629
- release() {
630
- this.inFlight -= 1;
631
- const next = this.waitQueue.shift();
632
- if (next !== void 0) next();
633
- }
634
- /**
635
- * One request: build URL/headers, apply the whole-call deadline, retry
636
- * idempotent methods on 429/5xx, map failures to the typed errors.
637
- * Returns the RAW parsed body (2xx); `undefined` for an empty 2xx body.
638
- */
639
- /**
640
- * One audit event per settled request (section 19.3): the correlation id,
641
- * the redacted request body, the outcome. An audit hook that throws never
642
- * breaks the request.
643
- */
644
- emitAudit(event) {
645
- if (this.onAudit === void 0) return;
646
- try {
647
- this.onAudit(event);
648
- } catch {
649
- }
650
- }
651
- /** Sliding-window `rateLimit.perHour` limiter: waits for a slot within the call budget, else throws. */
652
- async takeHourlySlot(deadlineAt, signal, ctx) {
653
- if (this.perHour === void 0) return;
654
- for (; ; ) {
655
- const now = Date.now();
656
- while (this.hourStamps.length > 0 && this.hourStamps[0] <= now - HOUR_MS) this.hourStamps.shift();
657
- if (this.hourStamps.length < this.perHour) {
658
- this.hourStamps.push(now);
659
- return;
660
- }
661
- const waitMs = this.hourStamps[0] + HOUR_MS - now;
662
- if (waitMs >= deadlineAt - now) {
663
- const err = new RateLimitError(`Client-side rateLimit.perHour (${this.perHour}) exhausted; next slot in ${Math.ceil(waitMs / 1e3)}s`, {
664
- ...ctx,
665
- suggestedAction: this.profile === "agent" ? "Stop or narrow the query \u2014 the LOCAL hourly budget is exhausted (per-process: it cannot see other instances or integrations on the tenant). Do NOT raise rateLimit.perHour; the window frees in about an hour." : "Back off until the hourly window frees a slot, or raise rateLimit.perHour."
666
- });
667
- err.retryAfter = Math.ceil(waitMs / 1e3);
668
- throw err;
669
- }
670
- await sleep(waitMs, signal);
671
- }
672
- }
673
- /**
674
- * Issue #43: the re-point gate — the zone re-resolution is available for
675
- * this request. ALL gates must hold (the WYRE prior art, adopted
676
- * verbatim): the flag is ON (strict `true`, off by default), the live
677
- * base is CATALOG-derived (an explicit baseUrl — a bare one, or
678
- * `zone: 'other'` + baseUrl — is NEVER re-pointed: the operator said
679
- * where to go), and the path is RELATIVE (an absolute path, e.g. a
680
- * cursor `nextPageUrl`, is already baked to a host we do not own and
681
- * cannot be rewritten). The fourth gate — "this is not already the
682
- * recovery retry" (exactly ONE retry, never a loop) — is enforced at the
683
- * call site by the request-local `zoneRetry` flag.
684
- */
685
- canReResolveZone(opts) {
686
- if (!this.reResolveZoneOn401) return false;
687
- if (this.zone === void 0 || this.zone === "other") return false;
688
- if (!opts.path.startsWith("/") || /^https?:\/\//i.test(opts.path)) return false;
689
- return true;
690
- }
691
- /**
692
- * Issue #43: one UNAUTHENTICATED vendor zone-discovery call
693
- * (`GET https://webservices.autotask.net/atservicesrest/v1.0/zoneInformation?user=<userName>`
694
- * — NO auth headers: the recovery signal must not leak credentials to a
695
- * host that is not the configured API base). Returns the tenant's
696
- * resolved zone base URL, or undefined on ANY fault (network, non-2xx,
697
- * redirect, malformed body, missing/invalid `url`) — the caller then
698
- * propagates the original 401 untouched (zero retry). Bounded by the
699
- * SAME whole-call deadline as the request it recovers.
700
- */
701
- async discoverZoneBase(signal, ctx) {
702
- const discoveryUrl = `${ZONE_DISCOVERY_URL}?user=${encodeURIComponent(this.userName)}`;
703
- let res;
704
- try {
705
- res = await fetch(discoveryUrl, {
706
- method: "GET",
707
- headers: { Accept: "application/json" },
708
- redirect: "manual",
709
- // a redirect off the discovery host is a fault, not a target
710
- signal
711
- });
712
- } catch {
713
- return void 0;
714
- }
715
- if (res.status < 200 || res.status >= 300) {
716
- await res.body?.cancel().catch(() => void 0);
717
- return void 0;
718
- }
719
- let text;
720
- try {
721
- text = await readBodyText(res, this.maxResponseBytes, "zone discovery", { ...ctx, httpStatus: res.status });
722
- } catch {
723
- return void 0;
724
- }
725
- let body;
726
- try {
727
- body = JSON.parse(text);
728
- } catch {
729
- return void 0;
730
- }
731
- if (!isRecord(body) || typeof body.url !== "string" || body.url.trim().length === 0) {
732
- return void 0;
733
- }
734
- return normalizeZoneBase(body.url);
735
- }
736
- /**
737
- * Issue #43: re-point the client to the tenant's newly resolved zone
738
- * base. The transport's PER-HOST state is RE-DERIVED, so the re-pointed
739
- * host starts FRESH: no cooldown/breaker/tenant-rate inheritance from
740
- * the stale host (conservative — the stale host's armed entries stay in
741
- * the shared stores for the other clients that still talk to it; nothing
742
- * is copied across). The configured base (config.baseUrl) is untouched;
743
- * the LIVE base is `baseUrl` (the getter over liveBaseUrl), and the
744
- * re-point is logged (a transport fact — the success path is unchanged,
745
- * no envelope change).
746
- */
747
- repointZoneBase(to) {
748
- const from = this.liveBaseUrl;
749
- this.liveBaseUrl = to;
750
- this.tenantKey = buildTenantKey(this.auth.userName, to);
751
- this.breakerKey = buildBreakerKey(to);
752
- this.logger.warn?.(
753
- `[autotask] zone re-point ${from} -> ${to} (a 401 on the stale zone base; the vendor's unauthenticated zone discovery named the tenant's current zone) \u2014 retrying the request once against the new base`,
754
- void 0
755
- );
756
- }
757
- async request(opts) {
758
- const method = opts.method;
759
- let url = buildUrl(this.baseUrl, opts.path, opts.query);
760
- let zoneRetry;
761
- const headers = withAuthHeaders(opts.headers, this.auth);
762
- assertCredentialShape(headers, this.auth);
763
- if (opts.body !== void 0) headers["Content-Type"] = "application/json";
764
- const bodyText = opts.body !== void 0 ? JSON.stringify(opts.body) : void 0;
765
- const correlationId = newCorrelationId();
766
- const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
767
- const maxRetries = opts.maxRetries ?? this.maxRetries;
768
- const started = Date.now();
769
- const deadlineAt = started + timeoutMs;
770
- const callerSignal = opts.signal ?? ambientSignal.getStore();
771
- const signal = AbortSignal.any(
772
- callerSignal === void 0 ? [AbortSignal.timeout(timeoutMs)] : [AbortSignal.timeout(timeoutMs), callerSignal]
773
- );
774
- const auditBody = opts.body !== void 0 ? redactAuditBody(opts.body) : void 0;
775
- const retriable = opts.idempotent ?? IDEMPOTENT_METHODS.includes(method);
776
- const expectBody = opts.expectBody ?? method !== "DELETE";
777
- const ctx = { operation: opts.operation, url, correlationId };
778
- let lastStatus;
779
- const interrupted = () => callerSignal?.aborted ? new AutotaskAbortError(`Request aborted: ${method} ${opts.path}`, { ...ctx, suggestedAction: "The caller aborted; re-issue the call if still needed." }) : new AutotaskTimeoutError(`Request timed out after ${timeoutMs}ms: ${method} ${opts.path}`, ctx);
780
- const canWait = (delay) => delay <= MAX_RETRY_DELAY_MS && delay < deadlineAt - Date.now();
781
- const pause = async (delay) => {
782
- try {
783
- await sleep(delay, signal);
784
- } catch {
785
- throw interrupted();
786
- }
787
- };
788
- if (this.cooldownStore !== void 0 && !opts.bypassCooldown) {
789
- const deadline = this.cooldownStore.get(this.tenantKey);
790
- if (deadline !== void 0) {
791
- const remainingMs = deadline - Date.now();
792
- if (remainingMs > 0) {
793
- if (callerSignal?.aborted) throw interrupted();
794
- const seconds = Math.ceil(remainingMs / 1e3);
795
- const err = new RateLimitError(
796
- `Tenant is in a rate-limit cooldown; the call was NOT sent. ${seconds}s remain \u2014 do not retry before then, and narrow the query (filter/includeFields) before retrying.`,
797
- {
798
- ...ctx,
799
- code: "RATE_LIMITED_COOLDOWN",
800
- retryable: false,
801
- suggestedAction: `Wait ${seconds}s (per-tenant 429 cooldown) and retry with a narrower query; the call was not sent and consumed no vendor budget.`
802
- }
803
- );
804
- err.retryAfter = seconds;
805
- err.notSent = true;
806
- this.emitAudit({
807
- correlationId,
808
- operation: opts.operation,
809
- method,
810
- path: opts.path,
811
- durationMs: Date.now() - started,
812
- body: auditBody,
813
- notSent: true,
814
- errorCode: "RATE_LIMITED_COOLDOWN"
815
- });
816
- throw err;
817
- }
818
- this.cooldownStore.delete(this.tenantKey);
819
- }
820
- }
821
- let breakerProbe = false;
822
- let breakerProbeKey;
823
- if (this.breaker !== void 0 && !opts.bypassCircuitBreaker) {
824
- if (callerSignal?.aborted) throw interrupted();
825
- const now = Date.now();
826
- const st = this.breaker.store.get(this.breakerKey);
827
- if (st !== void 0 && st.openedAt !== null) {
828
- const openUntil = st.openedAt + this.breaker.openTimeoutMs;
829
- if (now < openUntil) {
830
- const remainingMs = openUntil - now;
831
- const seconds = Math.ceil(remainingMs / 1e3);
832
- const err = new CircuitBreakerError(
833
- `The circuit breaker for ${this.breakerKey} is open; the call was NOT sent. ${seconds}s remain \u2014 retry the same call after the wait (a host-scoped vendor fault, not the request).`,
834
- {
835
- ...ctx,
836
- details: { state: "open", retryAfterMs: remainingMs, failureCount: st.failures.length },
837
- suggestedAction: `Wait ${seconds}s (the circuit is open for ${this.breakerKey}) and retry the SAME call; the call was not sent and consumed no vendor budget.`
838
- }
839
- );
840
- err.retryAfter = seconds;
841
- err.notSent = true;
842
- this.emitAudit({
843
- correlationId,
844
- operation: opts.operation,
845
- method,
846
- path: opts.path,
847
- durationMs: Date.now() - started,
848
- body: auditBody,
849
- notSent: true,
850
- errorCode: "CIRCUIT_BREAKER_OPEN"
851
- });
852
- throw err;
853
- }
854
- if (st.probes >= this.breaker.halfOpenProbes) {
855
- const err = new CircuitBreakerError(
856
- `The circuit breaker for ${this.breakerKey} is half-open and its probes are in flight; the call was NOT sent \u2014 retry once a probe settles.`,
857
- {
858
- ...ctx,
859
- details: { state: "half-open", retryAfterMs: 0, failureCount: st.failures.length },
860
- suggestedAction: "A half-open probe is settling; retry shortly \u2014 the call was not sent and consumed no vendor budget."
861
- }
862
- );
863
- err.notSent = true;
864
- this.emitAudit({
865
- correlationId,
866
- operation: opts.operation,
867
- method,
868
- path: opts.path,
869
- durationMs: Date.now() - started,
870
- body: auditBody,
871
- notSent: true,
872
- errorCode: "CIRCUIT_BREAKER_OPEN"
873
- });
874
- throw err;
875
- }
876
- const admitted = { ...st, probes: st.probes + 1 };
877
- this.breaker.store.set(this.breakerKey, admitted);
878
- breakerProbe = true;
879
- breakerProbeKey = this.breakerKey;
880
- }
881
- }
882
- this.logger.debug?.(`[autotask] ${method} ${opts.path}`, correlationId);
883
- try {
884
- let attempt = 0;
885
- let outcome = null;
886
- for (; ; ) {
887
- try {
888
- await this.takeHourlySlot(deadlineAt, signal, ctx);
889
- } catch (err2) {
890
- if (signal.aborted) throw interrupted();
891
- throw err2;
892
- }
893
- await this.acquire();
894
- let res;
895
- try {
896
- res = await fetch(url, { method, headers, body: bodyText, signal, redirect: "manual" });
897
- } catch (err2) {
898
- this.release();
899
- if (signal.aborted || isAbortLike(err2)) throw interrupted();
900
- const delay = backoffDelay(attempt);
901
- if (attempt < maxRetries && retriable && canWait(delay)) {
902
- this.logger.warn?.(
903
- `[autotask] network error on ${method} ${opts.path}; retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,
904
- correlationId
905
- );
906
- attempt += 1;
907
- await pause(delay);
908
- continue;
909
- }
910
- throw new AutotaskNetworkError(
911
- `Network error during ${method} ${opts.path}: ${err2 instanceof Error ? err2.message : String(err2)}`,
912
- ctx
913
- );
914
- }
915
- let status;
916
- let text;
917
- try {
918
- status = res.status;
919
- if (status >= 300 && status < 400) {
920
- await res.body?.cancel().catch(() => void 0);
921
- throw new ResponseRejectedError(
922
- `Refused to follow a ${status} redirect for ${method} ${opts.path}`,
923
- { ...ctx, code: "REDIRECT_BLOCKED", httpStatus: status, suggestedAction: "Check the configured zone/baseUrl; the API does not redirect." }
924
- );
925
- }
926
- text = await readBodyText(res, this.maxResponseBytes, `${method} ${opts.path}`, { ...ctx, httpStatus: status });
927
- } catch (err2) {
928
- this.release();
929
- if (err2 instanceof AutotaskError) throw err2;
930
- if (signal.aborted || isAbortLike(err2)) throw interrupted();
931
- throw new AutotaskNetworkError(
932
- `Network error reading the response of ${method} ${opts.path}: ${err2 instanceof Error ? err2.message : String(err2)}`,
933
- ctx
934
- );
935
- }
936
- this.release();
937
- lastStatus = status;
938
- if (status >= 200 && status < 300) {
939
- if (this.cooldownStore !== void 0) this.cooldownStore.delete(this.tenantKey);
940
- if (text.length === 0) {
941
- if (expectBody) {
942
- const err2 = new EmptyResponseError(`Empty ${status} response for ${method} ${opts.path}`, {
943
- ...ctx,
944
- httpStatus: status,
945
- retryable: method !== "DELETE",
946
- details: { httpStatus: status, method }
947
- });
948
- attachEmptyReasonDetails(err2);
949
- throw err2;
950
- }
951
- outcome = { value: void 0, status };
952
- break;
953
- }
954
- let parsed;
955
- try {
956
- parsed = JSON.parse(text);
957
- } catch {
958
- const contentType = res.headers.get("Content-Type");
959
- const err2 = contentType !== null && contentType !== "" && !isJsonMediaType(contentType) ? new UnexpectedContentTypeError(
960
- `Non-JSON response body for ${method} ${opts.path} (HTTP ${status})`,
961
- {
962
- ...ctx,
963
- httpStatus: status,
964
- details: {
965
- httpStatus: status,
966
- contentType,
967
- bodySnippet: bodySnippet(text, this.redactPersonalData)
968
- },
969
- // The content-type is host-sourced (a host-supplied
970
- // baseUrl can serve arbitrary HTML) — capped, never prose;
971
- // the raw snippet rides details only.
972
- suggestedAction: retriable ? `The zone answered with a non-JSON body (content-type: ${contentType.slice(0, 100)}); the zone is likely in a maintenance window \u2014 retry after the window, or verify baseUrl points at the REST API.` : `The zone answered a write with a non-JSON body (content-type: ${contentType.slice(0, 100)}); the request may have taken effect \u2014 verify the record before re-issuing (writes are never auto-retried).`
973
- }
974
- ) : new AutotaskError(`Invalid JSON response body for ${method} ${opts.path}`, {
975
- ...ctx,
976
- code: "INVALID_RESPONSE",
977
- category: "server",
978
- httpStatus: status,
979
- suggestedAction: "The zone returned a non-JSON 2xx body; verify baseUrl points at the REST API."
980
- });
981
- if (err2.code === "UNEXPECTED_CONTENT_TYPE" && retriable && attempt < maxRetries) {
982
- const retryAfter = parseRetryAfter(res.headers.get("Retry-After"));
983
- const delay = retryAfter !== void 0 ? retryAfter * 1e3 : backoffDelay(attempt);
984
- if (canWait(delay)) {
985
- this.logger.warn?.(
986
- `[autotask] non-JSON 2xx on ${method} ${opts.path}; retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,
987
- correlationId
988
- );
989
- attempt += 1;
990
- await pause(delay);
991
- continue;
992
- }
993
- }
994
- attachEmptyReasonDetails(err2);
995
- throw err2;
996
- }
997
- outcome = {
998
- value: opts.skipResponseRedaction ? parsed : redactResponseBody(parsed, { redactPersonalData: this.redactPersonalData }),
999
- status
1000
- };
1001
- break;
1002
- }
1003
- let body;
1004
- if (text.length > 0) {
1005
- try {
1006
- body = JSON.parse(text);
1007
- } catch {
1008
- body = text;
1009
- }
1010
- }
1011
- const deterministic500 = status >= 500 && isDeterministicVendor500(parseVendorErrors(body));
1012
- const retryableStatus = status === 429 || status >= 500;
1013
- if (retryableStatus && !deterministic500 && attempt < maxRetries && retriable) {
1014
- const retryAfter = parseRetryAfter(res.headers.get("Retry-After"));
1015
- const delay = retryAfter !== void 0 ? retryAfter * 1e3 : backoffDelay(attempt);
1016
- if (canWait(delay)) {
1017
- this.logger.warn?.(
1018
- `[autotask] ${status} on ${method} ${opts.path}; retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,
1019
- correlationId
1020
- );
1021
- attempt += 1;
1022
- await pause(delay);
1023
- continue;
1024
- }
1025
- }
1026
- if (status === 401 && zoneRetry === void 0 && this.canReResolveZone(opts)) {
1027
- const target = await this.discoverZoneBase(signal, ctx);
1028
- const targetViolation = target === void 0 || this.hostPolicy === void 0 ? null : decideHostPolicy(target, this.hostPolicy);
1029
- if (targetViolation !== null) {
1030
- this.logger.warn?.(
1031
- `[autotask] zone re-point refused by hostPolicy (${targetViolation.bound}): ${targetViolation.origin} \u2014 the original 401 propagates, zero wire to the refused host`,
1032
- void 0
1033
- );
1034
- }
1035
- if (target !== void 0 && targetViolation === null && target !== this.baseUrl) {
1036
- zoneRetry = { from: this.baseUrl, to: target };
1037
- this.repointZoneBase(target);
1038
- url = buildUrl(this.baseUrl, opts.path, opts.query);
1039
- continue;
1040
- }
1041
- }
1042
- const retryAfterSecs = parseRetryAfter(res.headers.get("Retry-After"));
1043
- const err = errorFromStatus(
1044
- status,
1045
- body,
1046
- url,
1047
- opts.operation,
1048
- this.redactPersonalData,
1049
- credentialShapeExpectation(headers, this.auth)
1050
- );
1051
- err.correlationId = correlationId;
1052
- if (status === 401 && zoneRetry !== void 0) {
1053
- err.details = {
1054
- ...err.details ?? {},
1055
- zoneRecovery: { from: zoneRetry.from, to: zoneRetry.to, retried: true }
1056
- };
1057
- }
1058
- attachEmptyReasonDetails(err);
1059
- if (err instanceof RateLimitError && retryAfterSecs !== void 0) err.retryAfter = retryAfterSecs;
1060
- if (status === 429 && this.cooldownStore !== void 0) {
1061
- const threadThreshold = err.code.toLowerCase() === "itgenatr005";
1062
- const durationMs = cooldownDurationMs(retryAfterSecs, {
1063
- maxMs: this.cooldownMaxMs,
1064
- defaultMs: this.cooldownDefaultMs,
1065
- threadThreshold
1066
- });
1067
- this.cooldownStore.set(this.tenantKey, Date.now() + durationMs);
1068
- this.logger.warn?.(
1069
- `[autotask] 429 on ${method} ${opts.path} surfaces after ${attempt + 1} attempt(s); tenant cooldown armed for ${Math.round(durationMs / 1e3)}s`,
1070
- correlationId
1071
- );
1072
- }
1073
- throw err;
1074
- }
1075
- if (outcome !== null) {
1076
- this.emitAudit({
1077
- correlationId,
1078
- operation: opts.operation,
1079
- method,
1080
- path: opts.path,
1081
- status: outcome.status,
1082
- durationMs: Date.now() - started,
1083
- body: auditBody
1084
- });
1085
- if (this.breaker !== void 0) {
1086
- try {
1087
- this.recordBreakerSuccess(breakerProbe, breakerProbeKey);
1088
- } catch {
1089
- }
1090
- }
1091
- return outcome.value;
1092
- }
1093
- } catch (err) {
1094
- if (this.breaker !== void 0 && err instanceof AutotaskError && err.notSent !== true) {
1095
- try {
1096
- if (this.breaker.isFailure(err)) this.recordBreakerFailure(breakerProbe, breakerProbeKey, correlationId);
1097
- } catch {
1098
- }
1099
- }
1100
- this.emitAudit({
1101
- correlationId,
1102
- operation: opts.operation,
1103
- method,
1104
- path: opts.path,
1105
- status: lastStatus,
1106
- durationMs: Date.now() - started,
1107
- body: auditBody,
1108
- errorCode: err instanceof AutotaskError ? err.code : "NETWORK_ERROR"
1109
- });
1110
- if (this.fieldCache !== void 0 && err instanceof AutotaskError) {
1111
- const op = typeof opts.operation === "string" ? opts.operation : void 0;
1112
- const verb = op !== void 0 ? op.slice(op.lastIndexOf(".") + 1) : "";
1113
- const drift = err.details?.deterministic === true || err.code === "INVALID_RESPONSE" && META_TRIO_VERBS.has(verb);
1114
- if (drift && op !== void 0) {
1115
- const dot = op.indexOf(".");
1116
- if (dot > 0) this.fieldCache.invalidate(op.slice(0, dot));
1117
- }
1118
- }
1119
- throw err;
1120
- } finally {
1121
- if (!(method === "GET" || opts.idempotent === true)) this.readCache?.clear();
1122
- }
1123
- throw new AutotaskError("unreachable: the request loop ended without an outcome");
1124
- }
1125
- };
1126
- var INVALID_SHAPE_ACTION = "Report the unexpected response shape; verify baseUrl points at the REST API.";
1127
- var META_TRIO_VERBS = /* @__PURE__ */ new Set(["getEntityInfo", "getFieldInfo", "getUdfInfo"]);
1128
- function attachEmptyReasonDetails(err) {
1129
- if (err.operation === void 0) return;
1130
- const dot = err.operation.indexOf(".");
1131
- if (dot <= 0) return;
1132
- const resource = err.operation.slice(0, dot);
1133
- const op = err.operation.slice(dot + 1);
1134
- const surface = err.operation;
1135
- const stamp = EMPTY_REASON_STAMPS[resource]?.[op];
1136
- const pointer = POINTER_NOTES[surface];
1137
- const isGetMiss = err instanceof NotFoundError && op === "get";
1138
- if (stamp === void 0 && pointer === void 0 && !isGetMiss) return;
1139
- err.details = { ...err.details ?? {} };
1140
- if (stamp !== void 0) {
1141
- err.details.emptyReason = stamp;
1142
- } else if (isGetMiss) {
1143
- const blindOps = BLIND_SURFACE_STAMPS[resource];
1144
- const blind = blindOps !== void 0 ? Object.values(blindOps).find((v) => v !== void 0) : void 0;
1145
- err.details.emptyReason = blind !== void 0 ? {
1146
- surface,
1147
- kind: "blind-surface",
1148
- note: blind.note,
1149
- authority: blind.authority,
1150
- remedy: "the note is the remedy \u2014 the authority op answers for a record"
1151
- } : {
1152
- surface,
1153
- kind: "not-found",
1154
- note: `no ${resource} record exists at this id (a miss is the data, not an error condition)`,
1155
- authority: `${resource}.query`,
1156
- remedy: `query the ${resource} collection (a bounded queryBounded walk) or verify the id from the authority surface \u2014 do not retry the same get`
1157
- };
1158
- }
1159
- if (pointer !== void 0) err.details.pointerNote = pointer;
1160
- }
1161
- function unwrapItems(body, operation) {
1162
- if (!isRecord(body) || !Array.isArray(body.items) || !isRecord(body.pageDetails)) {
1163
- const err = new AutotaskError("Expected the query envelope { items, pageDetails }; got a different shape", {
1164
- code: "INVALID_RESPONSE",
1165
- category: "server",
1166
- operation,
1167
- suggestedAction: INVALID_SHAPE_ACTION
1168
- });
1169
- attachEmptyReasonDetails(err);
1170
- throw err;
1171
- }
1172
- return { items: body.items, pageDetails: body.pageDetails };
1173
- }
1174
- function unwrapItem(body, id, operation) {
1175
- if (!isRecord(body) || !("item" in body)) {
1176
- throw new AutotaskError("Expected the item envelope { item }; got a different shape", {
1177
- code: "INVALID_RESPONSE",
1178
- category: "server",
1179
- operation,
1180
- suggestedAction: INVALID_SHAPE_ACTION
1181
- });
1182
- }
1183
- const item = body.item;
1184
- if (item === null) {
1185
- const err = new NotFoundError(
1186
- id !== void 0 ? `The record with ID ${id} was not found` : "The record was not found",
1187
- {
1188
- httpStatus: 200,
1189
- resourceIds: id !== void 0 ? [id] : void 0,
1190
- operation,
1191
- suggestedAction: "Verify the id, or resolve the record by name first."
1192
- }
1193
- );
1194
- attachEmptyReasonDetails(err);
1195
- throw err;
1196
- }
1197
- return item;
1198
- }
1199
- function unwrapQueryCount(body, operation) {
1200
- if (!isRecord(body) || typeof body.queryCount !== "number") {
1201
- const err = new AutotaskError("Expected the count envelope { queryCount: number }; got a different shape", {
1202
- code: "INVALID_RESPONSE",
1203
- category: "server",
1204
- operation,
1205
- suggestedAction: INVALID_SHAPE_ACTION
1206
- });
1207
- attachEmptyReasonDetails(err);
1208
- throw err;
1209
- }
1210
- return body.queryCount;
1211
- }
1212
- function unwrapOperation(body, operation) {
1213
- const value = isRecord(body) ? body.itemId !== void 0 ? body.itemId : body.id : void 0;
1214
- const idValue = toIdentifierId(value);
1215
- if (idValue === void 0) {
1216
- const err = new AutotaskError(
1217
- "Expected OperationResultModel { itemId } (or { id }); got a different shape",
1218
- { code: "INVALID_RESPONSE", category: "server", operation, suggestedAction: INVALID_SHAPE_ACTION }
1219
- );
1220
- attachEmptyReasonDetails(err);
1221
- throw err;
1222
- }
1223
- return { id: idValue };
1224
- }
1225
- function toIdentifierId(value) {
1226
- if (typeof value === "number" && Number.isInteger(value)) return value;
1227
- if (typeof value === "string" && /^\d+$/.test(value.trim())) {
1228
- const trimmed = value.trim();
1229
- const parsed = Number.parseInt(trimmed, 10);
1230
- return Number.isSafeInteger(parsed) ? parsed : trimmed;
1231
- }
1232
- return void 0;
1233
- }
1234
- function passthrough(body) {
1235
- return body;
1236
- }
1237
-
1238
- // src/cache-ttl-tables.ts
1239
- var RESOURCE_KEY_BY_PATH = {
1240
- "/V1.0/Appointments": "appointments",
1241
- "/V1.0/BillingCodes": "billing_codes",
1242
- "/V1.0/BillingItems": "billing_items",
1243
- "/V1.0/ChangeRequestLinks": "change_request_links",
1244
- "/V1.0/ChecklistLibraries": "checklist_libraries",
1245
- "/V1.0/ChecklistLibraryChecklistItems": "checklist_library_checklist_items",
1246
- "/V1.0/ClassificationIcons": "classification_icons",
1247
- "/V1.0/Companies": "companies",
1248
- "/V1.0/CompanyAlerts": "company_alerts",
1249
- "/V1.0/CompanyLocations": "company_locations",
1250
- "/V1.0/CompanyNotes": "company_notes",
1251
- "/V1.0/CompanySiteConfigurations": "company_site_configurations",
1252
- "/V1.0/CompanyWebhooks": "company_webhooks",
1253
- "/V1.0/ConfigurationItemBillingProductAssociations": "configuration_item_billing_product_associations",
1254
- "/V1.0/ConfigurationItemCategories": "configuration_item_categories",
1255
- "/V1.0/ConfigurationItemCategoryUdfAssociations": "configuration_item_category_udf_associations",
1256
- "/V1.0/ConfigurationItemDnsRecords": "configuration_item_dns_records",
1257
- "/V1.0/ConfigurationItemNotes": "configuration_item_notes",
1258
- "/V1.0/ConfigurationItemRelatedItems": "configuration_item_related_items",
1259
- "/V1.0/ConfigurationItemSslSubjectAlternativeNames": "configuration_item_ssl_subject_alternative_names",
1260
- "/V1.0/ConfigurationItemTypes": "configuration_item_types",
1261
- "/V1.0/ConfigurationItemWebhooks": "configuration_item_webhooks",
1262
- "/V1.0/ConfigurationItems": "configuration_items",
1263
- "/V1.0/ContactGroupContacts": "contact_group_contacts",
1264
- "/V1.0/ContactGroups": "contact_groups",
1265
- "/V1.0/ContactWebhooks": "contact_webhooks",
1266
- "/V1.0/Contacts": "contacts",
1267
- "/V1.0/ContractBillingRules": "contract_billing_rules",
1268
- "/V1.0/ContractBlockHourFactors": "contract_block_hour_factors",
1269
- "/V1.0/ContractBlocks": "contract_blocks",
1270
- "/V1.0/ContractCharges": "contract_charges",
1271
- "/V1.0/ContractExclusionBillingCodes": "contract_exclusion_billing_codes",
1272
- "/V1.0/ContractExclusionRoles": "contract_exclusion_roles",
1273
- "/V1.0/ContractExclusionSetExcludedRoles": "contract_exclusion_set_excluded_roles",
1274
- "/V1.0/ContractExclusionSetExcludedWorkTypes": "contract_exclusion_set_excluded_work_types",
1275
- "/V1.0/ContractExclusionSets": "contract_exclusion_sets",
1276
- "/V1.0/ContractMilestones": "contract_milestones",
1277
- "/V1.0/ContractNotes": "contract_notes",
1278
- "/V1.0/ContractRates": "contract_rates",
1279
- "/V1.0/ContractRetainers": "contract_retainers",
1280
- "/V1.0/ContractRoleCosts": "contract_role_costs",
1281
- "/V1.0/ContractServiceAdjustments": "contract_service_adjustments",
1282
- "/V1.0/ContractServiceBundleAdjustments": "contract_service_bundle_adjustments",
1283
- "/V1.0/ContractServiceBundleUnits": "contract_service_bundle_units",
1284
- "/V1.0/ContractServiceBundles": "contract_service_bundles",
1285
- "/V1.0/ContractServiceUnits": "contract_service_units",
1286
- "/V1.0/ContractServices": "contract_services",
1287
- "/V1.0/ContractTicketPurchases": "contract_ticket_purchases",
1288
- "/V1.0/Contracts": "contracts",
1289
- "/V1.0/Countries": "countries",
1290
- "/V1.0/Departments": "departments",
1291
- "/V1.0/DomainRegistrars": "domain_registrars",
1292
- "/V1.0/ExpenseItemAttachments": "expense_item_attachments",
1293
- "/V1.0/ExpenseItems": "expense_items",
1294
- "/V1.0/ExpenseReports": "expense_reports",
1295
- "/V1.0/HolidaySets": "holiday_sets",
1296
- "/V1.0/Holidays": "holidays",
1297
- "/V1.0/InventoryProducts": "inventory_products",
1298
- "/V1.0/Invoices": "invoices",
1299
- "/V1.0/NotificationHistory": "notification_history",
1300
- "/V1.0/Opportunities": "opportunities",
1301
- "/V1.0/OpportunityAttachments": "opportunity_attachments",
1302
- "/V1.0/OpportunityCategories": "opportunity_categories",
1303
- "/V1.0/Phases": "phases",
1304
- "/V1.0/ProductVendors": "product_vendors",
1305
- "/V1.0/Products": "products",
1306
- "/V1.0/ProjectCharges": "project_charges",
1307
- "/V1.0/ProjectNotes": "project_notes",
1308
- "/V1.0/Projects": "projects",
1309
- "/V1.0/PurchaseApprovals": "purchase_approvals",
1310
- "/V1.0/PurchaseOrderItemReceiving": "purchase_order_item_receiving",
1311
- "/V1.0/PurchaseOrderItems": "purchase_order_items",
1312
- "/V1.0/PurchaseOrders": "purchase_orders",
1313
- "/V1.0/QuoteItems": "quote_items",
1314
- "/V1.0/QuoteLocations": "quote_locations",
1315
- "/V1.0/QuoteTemplates": "quote_templates",
1316
- "/V1.0/Quotes": "quotes",
1317
- "/V1.0/ResourceRoleQueues": "resource_role_queues",
1318
- "/V1.0/ResourceRoles": "resource_roles",
1319
- "/V1.0/ResourceSkills": "resource_skills",
1320
- "/V1.0/ResourceTimeOffAdditional": "resource_time_off_additional",
1321
- "/V1.0/ResourceTimeOffApprovers": "resource_time_off_approvers",
1322
- "/V1.0/ResourceTimeOffBalances": "resource_time_off_balances",
1323
- "/V1.0/Resources": "resources",
1324
- "/V1.0/Roles": "roles",
1325
- "/V1.0/ServiceCallTaskResources": "service_call_task_resources",
1326
- "/V1.0/ServiceCallTasks": "service_call_tasks",
1327
- "/V1.0/ServiceCallTicketResources": "service_call_ticket_resources",
1328
- "/V1.0/ServiceCallTickets": "service_call_tickets",
1329
- "/V1.0/ServiceCalls": "service_calls",
1330
- "/V1.0/ServiceLevelAgreementResults": "service_level_agreement_results",
1331
- "/V1.0/Services": "services",
1332
- "/V1.0/Skills": "skills",
1333
- "/V1.0/SubscriptionPeriods": "subscription_periods",
1334
- "/V1.0/Subscriptions": "subscriptions",
1335
- "/V1.0/SurveyResults": "survey_results",
1336
- "/V1.0/Surveys": "surveys",
1337
- "/V1.0/TagAliases": "tag_aliases",
1338
- "/V1.0/TagGroups": "tag_groups",
1339
- "/V1.0/Tags": "tags",
1340
- "/V1.0/TaskSecondaryResources": "task_secondary_resources",
1341
- "/V1.0/Tasks": "tasks",
1342
- "/V1.0/TicketAdditionalConfigurationItems": "ticket_additional_configuration_items",
1343
- "/V1.0/TicketAdditionalContacts": "ticket_additional_contacts",
1344
- "/V1.0/TicketAttachments": "ticket_attachments",
1345
- "/V1.0/TicketCategories": "ticket_categories",
1346
- "/V1.0/TicketCategoryFieldDefaults": "ticket_category_field_defaults",
1347
- "/V1.0/TicketChangeRequestApprovals": "ticket_change_request_approvals",
1348
- "/V1.0/TicketCharges": "ticket_charges",
1349
- "/V1.0/TicketChecklistItems": "ticket_checklist_items",
1350
- "/V1.0/TicketChecklistLibraries": "ticket_checklist_libraries",
1351
- "/V1.0/TicketHistory": "ticket_history",
1352
- "/V1.0/TicketNoteAttachments": "ticket_note_attachments",
1353
- "/V1.0/TicketNoteWebhooks": "ticket_note_webhooks",
1354
- "/V1.0/TicketNotes": "ticket_notes",
1355
- "/V1.0/TicketSecondaryResources": "ticket_secondary_resources",
1356
- "/V1.0/TicketWebhooks": "ticket_webhooks",
1357
- "/V1.0/Tickets": "tickets",
1358
- "/V1.0/TimeEntries": "time_entries",
1359
- "/V1.0/TimeEntryAttachments": "time_entry_attachments",
1360
- "/V1.0/TimeOffRequests": "time_off_requests"
1361
- };
1362
-
1363
- // src/resources/base.ts
1364
- var BOUNDED_QUERY_DEFAULT_LIMIT = 500;
1365
- var BOUNDED_QUERY_MAX_LIMIT = 2500;
1366
- var MARKER_RECOVERY_LIMIT = 5;
1367
- function describeResponseShape(body) {
1368
- if (body === null) return "null";
1369
- if (Array.isArray(body)) return `array(len=${body.length})`;
1370
- if (typeof body !== "object") return typeof body;
1371
- const rec = body;
1372
- const keys = Object.keys(rec).sort();
1373
- const typeOf = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
1374
- const parts = keys.slice(0, 16).map((k) => `${k}:${typeOf(rec[k])}`);
1375
- const more = keys.length > 16 ? `, +${keys.length - 16} more` : "";
1376
- return `{ ${parts.join(", ")}${more} }`;
1377
- }
1378
- function defaultMarkerToken(data) {
1379
- for (const field of ["name", "title"]) {
1380
- const v = data[field];
1381
- if (typeof v === "string" && v.trim() !== "") return v.trim();
1382
- }
1383
- return null;
1384
- }
1385
- var CONTEXT_ABSENT_MARKER = "#context-absent";
1386
- function contextAbsentKey(path) {
1387
- return path + "\0context-absent";
1388
- }
1389
- function blindEmptyReason(stamp) {
1390
- return {
1391
- surface: stamp.surface,
1392
- kind: "blind-surface",
1393
- note: stamp.note,
1394
- authority: stamp.authority,
1395
- remedy: "the note is the remedy \u2014 the authority op answers for a record"
1396
- };
1397
- }
1398
- function attachUpdateTargetMissReason(err, key) {
1399
- const pinned = EMPTY_REASON_STAMPS[key]?.update;
1400
- if (pinned === void 0) return;
1401
- err.details = { ...err.details ?? {}, emptyReason: pinned };
1402
- }
1403
- function buildDryRunResult(spec, policy = { redactPersonalData: true }) {
1404
- if (spec.operation === "") {
1405
- throw new AutotaskConfigError("dry-run: the operation name is required");
1406
- }
1407
- const wouldApply = spec.checks.every((check) => check.ok);
1408
- const resource = spec.operation.split(".")[0] ?? spec.operation;
1409
- const diffPart = spec.diff !== void 0 ? {
1410
- diff: spec.diff.map((entry) => {
1411
- if (entry.to === void 0) return entry;
1412
- const to = isRedactedFieldName(entry.field, policy) ? REDACTED : redactResponseBody(entry.to, policy);
1413
- return { ...entry, to };
1414
- })
1415
- } : {};
1416
- const dataPart = spec.data !== void 0 ? { data: redactResponseBody(spec.data, policy) } : {};
1417
- return {
1418
- operation: spec.operation,
1419
- wouldApply,
1420
- target: { resource, ids: spec.ids },
1421
- request: { method: spec.method, path: spec.path },
1422
- ...diffPart,
1423
- checks: spec.checks,
1424
- impact: spec.impact,
1425
- simulated: true,
1426
- warnings: spec.warnings ?? [],
1427
- ...dataPart,
1428
- ...spec.emptyReason !== void 0 ? { emptyReason: spec.emptyReason } : {}
1429
- };
1430
- }
1431
- function computeFieldDiff(current, data) {
1432
- const out = [];
1433
- for (const [field, to] of Object.entries(data)) {
1434
- if (field === "id" || to === void 0) continue;
1435
- const from = current[field];
1436
- if (from !== void 0 && JSON.stringify(from) === JSON.stringify(to)) continue;
1437
- out.push({ field, from: from === void 0 ? null : from, to });
1438
- }
1439
- return out;
1440
- }
1441
- function mergePartialBody(current, data) {
1442
- const merged = { ...current };
1443
- for (const [key, value] of Object.entries(data)) {
1444
- if (value === void 0) continue;
1445
- merged[key] = value;
1446
- }
1447
- return merged;
1448
- }
1449
- function normalizeUdfInput(value) {
1450
- if (Array.isArray(value)) {
1451
- return value.map((entry) => {
1452
- if (!isRecord(entry) || typeof entry.name !== "string" || entry.name === "") {
1453
- throw new AutotaskConfigError("userDefinedFields array entries must be { name, value } objects");
1454
- }
1455
- return { name: entry.name, value: entry.value };
1456
- });
1457
- }
1458
- if (isRecord(value)) {
1459
- return Object.entries(value).map(([name, entryValue]) => ({ name, value: entryValue }));
1460
- }
1461
- throw new AutotaskConfigError("userDefinedFields must be an array of { name, value } entries or a name-to-value map");
1462
- }
1463
- function normalizedConditions(filter) {
1464
- if (filter === void 0) return void 0;
1465
- return Array.isArray(filter) ? filter : filter.filter;
1466
- }
1467
- function blindStampFor(stamp, filter) {
1468
- if (stamp === void 0) return void 0;
1469
- if (stamp.kind !== "unfiltered-blind") return stamp;
1470
- const conditions = normalizedConditions(filter);
1471
- return conditions === void 0 || conditions.length === 0 ? stamp : void 0;
1472
- }
1473
- function decodeFileData(body) {
1474
- if (isRecord(body)) {
1475
- const data = body.data;
1476
- if (typeof data === "string") return Buffer.from(data, "base64");
1477
- if (Array.isArray(data) && data.every((b) => typeof b === "number")) {
1478
- return Buffer.from(data);
1479
- }
1480
- }
1481
- if (typeof body === "string") return Buffer.from(body, "base64");
1482
- throw new AutotaskError(
1483
- "Expected the file envelope FileQueryResultModel { data } (or a base64 string); got a different shape",
1484
- { code: "INVALID_RESPONSE", category: "server" }
1485
- );
1486
- }
1487
- var BaseResource = class {
1488
- http;
1489
- config;
1490
- /** Default page size for query walks (the client's resolved `pageSize`). */
1491
- defaultPageSize;
1492
- /** Versioned global URL segment (from the config), e.g. '/V1.0/Companies'. */
1493
- get resourcePath() {
1494
- return this.config.resourcePath;
1495
- }
1496
- /** Parent-scoped template (from the config); undefined for top-level resources. */
1497
- get parentScopedPath() {
1498
- return this.config.parentScopedPath;
1499
- }
1500
- /** Parent field used to narrow the global query (from the config). */
1501
- get parentField() {
1502
- return this.config.parentField;
1503
- }
1504
- /** The bounded client-scan caps (section 19.2; resolved per client). */
1505
- get resolutionCaps() {
1506
- return this.http.resolution;
1507
- }
1508
- constructor(http, config, pageSize) {
1509
- if (config.key === "" || config.resourcePath === "") {
1510
- throw new AutotaskConfigError("BaseResource requires a non-empty key and resourcePath");
1511
- }
1512
- this.http = http;
1513
- this.config = config;
1514
- this.defaultPageSize = clampPageSize(pageSize ?? DEFAULT_PAGE_SIZE);
1515
- }
1516
- // ------------------------------------------------------------------
1517
- // Query walk (wired to the G3 walker; cursor-follow per the pinned wire)
1518
- // ------------------------------------------------------------------
1519
- /** One page fetch: page 1 POSTs the QueryModel body; later pages POST the filter to the absolute cursor. */
1520
- async fetchPage(params, page, pageSize, state) {
1521
- const operation = `${this.config.key}.query`;
1522
- if (page === 1) {
1523
- const req = buildQueryRequest(this.http.baseUrl, this.resourcePath, { ...params, maxRecords: pageSize });
1524
- const body2 = await this.http.request({ ...req, operation });
1525
- return this.readPage(body2, page, pageSize, state);
1526
- }
1527
- const cursor = state.cursor;
1528
- if (cursor === null) {
1529
- throw new AutotaskError(`No pagination cursor available for page ${page} of ${operation}`, {
1530
- code: "INVALID_RESPONSE",
1531
- category: "server",
1532
- operation
1533
- });
1534
- }
1535
- const conditions = normalizedConditions(params.filter);
1536
- const body = { filter: conditions === void 0 ? [] : buildFilter(conditions).filter };
1537
- const res = await this.http.request({
1538
- method: "POST",
1539
- path: cursorToPath(this.http.baseUrl, cursor),
1540
- body,
1541
- operation,
1542
- idempotent: true
1543
- });
1544
- return this.readPage(res, page, pageSize, state);
1545
- }
1546
- readPage(body, page, pageSize, state) {
1547
- const { items, pageDetails } = unwrapItems(body, `${this.config.key}.query`);
1548
- state.cursor = pageDetails.nextPageUrl;
1549
- const totalPages = totalPagesFor(pageDetails.requestCount, pageSize);
1550
- return {
1551
- items,
1552
- page,
1553
- pageSize,
1554
- requestCount: pageDetails.requestCount,
1555
- totalPages,
1556
- // The vendor's nextPageUrl is the continuation signal (node-proven: the walk
1557
- // follows the cursor until it is null; the 1000-page guard caps pathological loops).
1558
- hasMore: state.cursor !== null
1559
- };
1560
- }
1561
- /** Build the per-walk fetcher (closure keeps cursor state private to this walk). */
1562
- pageFetcher(params) {
1563
- const state = { cursor: null };
1564
- const p = params ?? {};
1565
- return (page, pageSize) => this.fetchPage(p, page, pageSize, state);
1566
- }
1567
- walkPageSize(params) {
1568
- return clampPageSize(params?.maxRecords ?? this.defaultPageSize);
1569
- }
1570
- /** Stream items across pages (the resource `query` surface). */
1571
- queryItems(params) {
1572
- return paginateItems(this.pageFetcher(params), { pageSize: this.walkPageSize(params) });
1573
- }
1574
- /** Stream page objects (the resource `queryPages` surface). */
1575
- queryPageIter(params) {
1576
- return paginate(this.pageFetcher(params), { pageSize: this.walkPageSize(params) });
1577
- }
1578
- /** Collect every item across all pages (the resource `queryAll` surface; 1000-page guarded). */
1579
- async queryAllItems(params) {
1580
- return collectAll(this.pageFetcher(params), { pageSize: this.walkPageSize(params), maxRecords: params?.maxTotalRecords });
1581
- }
1582
- /**
1583
- * `queryBounded` — server-side pagination (the MCP query surface): ONE call
1584
- * walks the vendor cursor internally until `limit` TOTAL records are
1585
- * collected or the cursor is exhausted, and returns a bounded,
1586
- * complete-enough result. The client never owns the walk:
1587
- * - `limit` = total records (default 500, hard max 2500 — above it throws
1588
- * AutotaskConfigError; it is NOT a page size). The page-1 request uses
1589
- * the internal page size min(500, limit); follow-up pages POST the
1590
- * preserved filter to the absolute cursor verbatim (the pinned wire:
1591
- * MaxRecords/IncludeFields are baked into the cursor — never changed
1592
- * mid-walk), so the walk reuses `fetchPage` (the shared cursor machinery)
1593
- * with a per-walk closure state (walks never share state).
1594
- * - The 1000-page guard still applies as a SOFT stop: a walk that would
1595
- * fetch a 1001st page stops with `hasMore: true` (never a throw — this
1596
- * is a bounded read, not the `paginate` generator's error path).
1597
- * - `hasMore` = (truncated by limit OR guard) AND (a non-null next cursor
1598
- * was seen at the stop point OR the final page was sliced to meet the
1599
- * limit). Cursor exhausted, or a short/empty page with no truncation →
1600
- * false (never claims more than the cursor + the slice say exist).
1601
- * - `count` is always omitted: `pageDetails.count` is the current page's
1602
- * record count (vendor-documented), never a total — `<resource>.count`
1603
- * is the total.
1604
- * Pure read; no mutation paths involved.
1605
- */
1606
- async queryBounded(params) {
1607
- if (Array.isArray(params)) {
1608
- throw new AutotaskConfigError(
1609
- "queryBounded: params must be an object { filter?, limit?, includeFields? } \u2014 a bare conditions array is not accepted (wrap it: { filter: [...] })"
1610
- );
1611
- }
1612
- const limit = params?.limit ?? BOUNDED_QUERY_DEFAULT_LIMIT;
1613
- if (!Number.isInteger(limit) || limit < 1 || limit > BOUNDED_QUERY_MAX_LIMIT) {
1614
- throw new AutotaskConfigError(
1615
- `queryBounded: limit must be an integer 1..${BOUNDED_QUERY_MAX_LIMIT} (got ${String(limit)}) \u2014 it is the TOTAL record cap, not a page size`
1616
- );
1617
- }
1618
- const state = { cursor: null };
1619
- const p = params ?? {};
1620
- const items = [];
1621
- let pages = 0;
1622
- let sliced = 0;
1623
- let guard = false;
1624
- const pageSize = Math.min(MAX_PAGE_SIZE, limit);
1625
- for (; ; ) {
1626
- if (pages >= MAX_PAGES) {
1627
- guard = true;
1628
- break;
1629
- }
1630
- const page = await this.fetchPage(p, pages + 1, pageSize, state);
1631
- pages += 1;
1632
- const remaining = limit - items.length;
1633
- const take = page.items.slice(0, remaining);
1634
- sliced += Math.max(0, page.items.length - remaining);
1635
- items.push(...take);
1636
- if (items.length >= limit) break;
1637
- if (!page.hasMore || page.items.length === 0) break;
1638
- }
1639
- const hasMore = (items.length >= limit || guard) && (sliced > 0 || state.cursor !== null);
1640
- const result = { items, fetched: items.length, hasMore, pagesWalked: pages };
1641
- if (items.length === 0) {
1642
- result.blindSurface = blindStampFor(BLIND_SURFACE_STAMPS[this.config.key]?.query, params?.filter);
1643
- const pinnedReason = normalizedConditions(params?.filter) === void 0 ? EMPTY_REASON_STAMPS[this.config.key]?.query : void 0;
1644
- if (result.blindSurface !== void 0 || pinnedReason !== void 0) {
1645
- result.emptyReason = result.blindSurface !== void 0 ? blindEmptyReason(result.blindSurface) : pinnedReason;
1646
- }
1647
- }
1648
- return result;
1649
- }
1650
- // ------------------------------------------------------------------
1651
- // Count
1652
- // ------------------------------------------------------------------
1653
- /** `POST /V1.0/<E>/query/count` → `queryCount` unwrapped to a plain number —
1654
- * or, on a plan-pinned blind surface, the `{ count: 0, blindSurface }`
1655
- * typed sentinel (S12-C T10; see `BlindCountResult`). */
1656
- async countGlobal(params) {
1657
- const conditions = normalizedConditions(params?.filter);
1658
- const body = {};
1659
- body.filter = conditions === void 0 ? [] : buildFilter(conditions).filter;
1660
- const res = await this.http.request({
1661
- method: "POST",
1662
- path: `${this.resourcePath}/query/count`,
1663
- body,
1664
- operation: `${this.config.key}.count`,
1665
- idempotent: true
1666
- });
1667
- const count = unwrapQueryCount(res, `${this.config.key}.count`);
1668
- const stamp = blindStampFor(BLIND_SURFACE_STAMPS[this.config.key]?.count, params?.filter);
1669
- if (count === 0) {
1670
- const pinnedReason = conditions === void 0 ? EMPTY_REASON_STAMPS[this.config.key]?.count : void 0;
1671
- if (stamp !== void 0 || pinnedReason !== void 0) {
1672
- return {
1673
- count,
1674
- ...stamp !== void 0 ? { blindSurface: stamp } : {},
1675
- emptyReason: stamp !== void 0 ? blindEmptyReason(stamp) : pinnedReason
1676
- };
1677
- }
1678
- }
1679
- return count;
1680
- }
1681
- // ------------------------------------------------------------------
1682
- // Single-record reads
1683
- // ------------------------------------------------------------------
1684
- /** Single-record GET through the opt-in read cache (off by default: with
1685
- * no cache this is exactly one `http.request`). Only the successful
1686
- * (already redacted) body is stored — errors throw before `set`.
1687
- * `fresh` skips the lookup (the read still refreshes the entry for the
1688
- * other callers): mutation-path reads (stale-object guard, dry-run diff)
1689
- * and the composite ROOT read of a hot resource (issue #36 — the
1690
- * ticket-composite root) must see the live record, never a snapshot.
1691
- * `resource` (issue #32) is the plan key of the entity this path
1692
- * points to: the stored entry's TTL is the per-resource override for
1693
- * that key when `cache.perResourceTtlMs` names it, else `ttlMs`. */
1694
- async cachedGet(path, operation, fresh = false, resource) {
1695
- const cache = this.http.readCache;
1696
- if (cache !== void 0 && !fresh) {
1697
- const hit = cache.get(path);
1698
- if (hit !== void 0) return hit;
1699
- }
1700
- const res = await this.http.request({ method: "GET", path, operation });
1701
- cache?.set(path, res, resource);
1702
- return res;
1703
- }
1704
- /**
1705
- * Entity-metadata read through the opt-in field cache (issue #31; off by
1706
- * default: with no fieldCache this is exactly one `http.request` — the
1707
- * bit-for-bit uncached trio path). One private helper beside `cachedGet`,
1708
- * for the same three reasons the read cache exists: a vendor drift
1709
- * (deterministic 500, INVALID_RESPONSE) must not be served stale, repeat
1710
- * reads must not pay the wire, and concurrent callers must not issue
1711
- * duplicate requests. The UNWRAPPED value is stored (never the raw
1712
- * envelope); an EMPTY or FAILED result is never cached (a failure
1713
- * rejects before any `set`; an empty is refused by the cache itself);
1714
- * concurrent callers share ONE in-flight wire request; the drift signal
1715
- * (the INVALID_RESPONSE shape drift, the deterministic validation 500)
1716
- * invalidates THIS entity's entries so the next call refetches
1717
- * (transport-level signals are also invalidated in the transport's own
1718
- * catch — the two sites cover the 2xx and non-2xx halves). `fresh`
1719
- * skips the lookup: fetch live and refresh the entries.
1720
- */
1721
- async cachedMeta(kind, path, operation, unwrap, fresh = false) {
1722
- const cache = this.http.fieldCache;
1723
- if (cache === void 0) {
1724
- const res = await this.http.request({ method: "GET", path, operation });
1725
- return unwrap(res);
1726
- }
1727
- const key = this.config.key;
1728
- if (!fresh) {
1729
- const shared = cache.inflight(key, kind);
1730
- if (shared !== void 0) return shared;
1731
- const hit = cache.get(key, kind);
1732
- if (hit !== void 0) return hit;
1733
- }
1734
- const work = this.http.request({ method: "GET", path, operation }).then((body) => {
1735
- const value = unwrap(body);
1736
- cache.set(key, kind, value);
1737
- return value;
1738
- });
1739
- work.catch((err) => {
1740
- if (err instanceof AutotaskError && (err.code === "INVALID_RESPONSE" || err.details?.deterministic === true)) {
1741
- cache.invalidate(key);
1742
- }
1743
- });
1744
- return cache.track(key, kind, work);
1745
- }
1746
- /** `GET /V1.0/<E>/{id}` → `{ item }` unwrapped (`item: null` → NotFoundError). */
1747
- async getGlobal(id, fresh = false) {
1748
- const numeric = parseIdentifier(id, "id");
1749
- const res = await this.cachedGet(`${this.resourcePath}/${numeric}`, `${this.config.key}.get`, fresh, this.config.key);
1750
- return unwrapItem(res, numeric, `${this.config.key}.get`);
1751
- }
1752
- /**
1753
- * V2 merge read: a FRESH single-record GET that skips response redaction
1754
- * (the `skipResponseRedaction` transport flag). The merge round-trips the
1755
- * record's own current values back to the same vendor endpoint in the
1756
- * confirmed PUT — redacting this read would write literal '[REDACTED]'
1757
- * markers into the merged body (corruption on live records; the caller
1758
- * never sees the values: the PUT answers `{ id }`, and the request-body
1759
- * audit keeps logging redacted). Never cached: mutation-path reads must
1760
- * see the live record, and an unredacted body must not enter the
1761
- * (redacted-view) read cache.
1762
- */
1763
- async getGlobalUnredacted(id) {
1764
- const numeric = parseIdentifier(id, "id");
1765
- const res = await this.http.request({
1766
- method: "GET",
1767
- path: `${this.resourcePath}/${numeric}`,
1768
- operation: `${this.config.key}.get`,
1769
- skipResponseRedaction: true
1770
- });
1771
- return unwrapItem(res, numeric, `${this.config.key}.get`);
1772
- }
1773
- /**
1774
- * Issue #34 fallback merge read: a FRESH parent-scoped single-record GET
1775
- * that skips response redaction and is never cached (the same invariants
1776
- * as `getGlobalUnredacted`, on the scoped item path — the spec's readable
1777
- * route for parent-scoped children). The merged retry body round-trips the
1778
- * record's own current values: a redacted read would write literal
1779
- * '[REDACTED]' markers into the fallback PUT (corruption on live records).
1780
- */
1781
- async getScopedUnredacted(parentId, id) {
1782
- const numeric = parseIdentifier(id, "id");
1783
- const res = await this.http.request({
1784
- method: "GET",
1785
- path: this.scopedItemPath(parentId, numeric),
1786
- operation: `${this.config.key}.get`,
1787
- skipResponseRedaction: true
1788
- });
1789
- return unwrapItem(res, numeric, `${this.config.key}.get`);
1790
- }
1791
- /** Parent-scoped `GET .../{parentId}/<Child>/{id}` → `{ item }` unwrapped. */
1792
- async getScopedRaw(parentId, id, fresh = false) {
1793
- const numeric = parseIdentifier(id, "id");
1794
- const res = await this.cachedGet(this.scopedItemPath(parentId, id), `${this.config.key}.get`, fresh, this.config.key);
1795
- return unwrapItem(res, numeric, `${this.config.key}.get`);
1796
- }
1797
- /**
1798
- * Attachment single-read: the vendor answers the model DIRECTLY (swagger),
1799
- * while the node observed `{ items: [model] }` for some attachment GETs —
1800
- * accept all three shapes; empty/null → NotFoundError.
1801
- */
1802
- async getScopedModel(parentId, id, fresh = false) {
1803
- const numeric = parseIdentifier(id, "id");
1804
- const res = await this.cachedGet(this.scopedItemPath(parentId, id), `${this.config.key}.get`, fresh, this.config.key);
1805
- return this.unwrapModelLike(res, numeric);
1806
- }
1807
- unwrapModelLike(body, id) {
1808
- if (!isRecord(body)) {
1809
- throw new AutotaskError(
1810
- `Expected an attachment model (or { item } / { items }) envelope for ID ${id}; got a different shape`,
1811
- { code: "INVALID_RESPONSE", category: "server", resourceIds: [id] }
1812
- );
1813
- }
1814
- if (Object.keys(body).length === 0) {
1815
- throw new NotFoundError(`The record with ID ${id} was not found`, { httpStatus: 200, resourceIds: [id] });
1816
- }
1817
- if ("item" in body) return unwrapItem(body, id);
1818
- if (Array.isArray(body.items)) {
1819
- const first = body.items[0];
1820
- if (first === null || first === void 0) {
1821
- throw new NotFoundError(`The record with ID ${id} was not found`, { httpStatus: 200, resourceIds: [id] });
1822
- }
1823
- return first;
1824
- }
1825
- return body;
1826
- }
1827
- // ------------------------------------------------------------------
1828
- // Mutations
1829
- // ------------------------------------------------------------------
1830
- /** Normalise a mutation body: `id` OR `itemId` → `id`; UDF map → UDF array (UDF-capable only). */
1831
- normalizeMutationBody(data) {
1832
- const body = { ...data };
1833
- if (body.itemId !== void 0 && body.id === void 0) {
1834
- body.id = body.itemId;
1835
- delete body.itemId;
1836
- }
1837
- if (this.config.udfCapable && body.userDefinedFields !== void 0) {
1838
- body.userDefinedFields = normalizeUdfInput(body.userDefinedFields);
1839
- }
1840
- return body;
1841
- }
1842
- /** `POST /V1.0/<E>` → `{ id }` (issue #13: a drifted 2xx runs the bounded marker recovery where the plan pins a marker field). */
1843
- async createGlobal(data, opts = {}) {
1844
- const body = this.normalizeMutationBody(data);
1845
- const res = await this.http.request({
1846
- method: "POST",
1847
- path: this.resourcePath,
1848
- body,
1849
- operation: `${this.config.key}.create`
1850
- });
1851
- return this.unwrapCreateOperation(res, body, opts);
1852
- }
1853
- /**
1854
- * Issue #13: the create-response envelope unwrap with the bounded marker
1855
- * recovery. The fast path is the plain `unwrapOperation` (a well-formed
1856
- * envelope costs ZERO extra requests and returns the byte-identical
1857
- * `{ id }` result). Only a typed `INVALID_RESPONSE` (a 2xx body that is
1858
- * not the specced envelope / `{ id }` / `{ itemId }` alternative) with a
1859
- * plan-pinned marker field AND `recoverIdFromMarker !== false` runs the
1860
- * fallback — and it can only run on the APPLY path: a dry run returns the
1861
- * pinned envelope BEFORE the mutating request, so the fallback never sees
1862
- * a dry-run response (skipped entirely by construction).
1863
- */
1864
- async unwrapCreateOperation(body, wireBody, opts) {
1865
- const operation = `${this.config.key}.create`;
1866
- try {
1867
- return unwrapOperation(body, operation);
1868
- } catch (err) {
1869
- if (!isAutotaskError(err) || err.code !== "INVALID_RESPONSE") throw err;
1870
- const pin = RESPONSE_DRIFT_MARKERS[this.config.key];
1871
- if (pin === void 0 || opts.recoverIdFromMarker === false) throw err;
1872
- return this.recoverCreatedIdByMarker(err, body, wireBody, opts, pin);
1873
- }
1874
- }
1875
- /**
1876
- * Issue #13: the ONE bounded marker read on the resource's own query
1877
- * surface (`POST <collection>/query`, MaxRecords 5 — a read: idempotent,
1878
- * zero mutating wire; the fallback never issues anything else). Outcomes:
1879
- * exactly ONE numeric match → the normal success result stamped
1880
- * `idFrom: 'marker-recovery'` + the `shapeDrift` note; zero matches, more
1881
- * than one, or a cap-tripped search (a full 5 + a next cursor) → the
1882
- * existing `INVALID_RESPONSE` with the drift detail + the failed search
1883
- * (candidate ids listed — never a guessed id).
1884
- */
1885
- async recoverCreatedIdByMarker(err, body, wireBody, opts, pin) {
1886
- const operation = `${this.config.key}.create`;
1887
- const markerField = typeof opts.markerField === "string" && opts.markerField.trim() !== "" ? opts.markerField.trim() : pin.markerField;
1888
- const markerToken = typeof opts.markerToken === "string" && opts.markerToken.trim() !== "" ? opts.markerToken.trim() : defaultMarkerToken(wireBody);
1889
- if (markerToken === null) throw err;
1890
- const observedShape = describeResponseShape(body);
1891
- const res = await this.http.request({
1892
- method: "POST",
1893
- idempotent: true,
1894
- path: `${this.resourcePath}/query`,
1895
- body: { MaxRecords: MARKER_RECOVERY_LIMIT, filter: [{ field: markerField, op: "contains", value: markerToken }] },
1896
- operation: `${operation}.marker-recovery`
1897
- });
1898
- const { items, pageDetails } = unwrapItems(res, operation);
1899
- const truncated = items.length >= MARKER_RECOVERY_LIMIT && pageDetails.nextPageUrl !== null;
1900
- const drift = {
1901
- observedShape,
1902
- speccedEnvelope: "OperationResultModel",
1903
- markerField,
1904
- markerToken,
1905
- matches: items.length,
1906
- truncated,
1907
- note: pin.note
1908
- };
1909
- const numericIds = items.map((row) => {
1910
- const id = row.id;
1911
- if (typeof id === "number" && Number.isInteger(id) && id >= 0) return id;
1912
- if (typeof id === "string" && /^\d+$/.test(id.trim())) {
1913
- const parsed = Number.parseInt(id.trim(), 10);
1914
- return Number.isSafeInteger(parsed) ? parsed : id.trim();
1915
- }
1916
- return null;
1917
- });
1918
- if (items.length === 1 && !truncated && numericIds[0] !== null) {
1919
- const id = numericIds[0];
1920
- const shapeDrift = {
1921
- observedShape,
1922
- speccedEnvelope: "OperationResultModel",
1923
- note: `The create answered 2xx with a body that is not the specced OperationResultModel (observed: ${observedShape}); the id was recovered by ONE bounded marker read (${markerField} contains the caller's marker, limit ${MARKER_RECOVERY_LIMIT}) \u2014 exactly one numeric match.`
1924
- };
1925
- return { id, idFrom: "marker-recovery", shapeDrift };
1926
- }
1927
- const candidateIds = numericIds.map((n, i) => n ?? String(items[i].id ?? "unknown"));
1928
- const outcome = items.length === 0 ? "found 0 matches" : truncated ? `found ${items.length} matches at the limit ${MARKER_RECOVERY_LIMIT} cap (truncated \u2014 more may exist)` : `found ${items.length} matches`;
1929
- err.details = { ...err.details, responseDrift: { ...drift, candidateIds } };
1930
- err.suggestedAction = `The create answered 2xx with a body that is not the specced OperationResultModel (observed shape: ${observedShape}); the bounded marker recovery (${markerField} contains the caller's marker, limit ${MARKER_RECOVERY_LIMIT}) ${outcome} \u2014 ` + (items.length === 0 ? "the record may exist with an unfindable marker; report the vendor response-shape drift and locate the record in the UI." : "refusing to guess an id \u2014 disambiguate with an exact-field query (or the UI) and recover the id before retrying.");
1931
- throw err;
1932
- }
1933
- /** `PUT /V1.0/<E>/{id}` (the vendor tolerates PATCH at the same path) → `{ id }`.
1934
- * For collection-only-writable resources (`collectionUpdate`, D1) the
1935
- * vendor spec defines no put/patch on the item path (it is GET-only; the
1936
- * item-path PUT 405s), so the confirmed update hits the COLLECTION
1937
- * `PUT /V1.0/<E>` with the record id merged into the mutation body — the
1938
- * vendor keys the record by the id in the body (the same convention the
1939
- * parent-scoped `updateScopedRaw` PATCH uses).
1940
- *
1941
- * V2 partial-update merge: the vendor's collection PUT validates the FULL
1942
- * model (a partial body 500s 'Missing Required Field'), so a
1943
- * collectionUpdate update MERGES the caller's partial body into the
1944
- * record's current full model before the PUT: ONE fresh GET serves both
1945
- * the merge source and the stale-object guard's read (a `lastModifiedDate`
1946
- * mismatch throws STALE_OBJECT before the PUT; the guard semantics are
1947
- * unchanged — it is a no-op where the model lacks the field or the caller
1948
- * supplied no token). `opts.raw === true` sends the caller body as-is
1949
- * instead (the pre-merge wire; the guard keeps its own verification read
1950
- * when a token is supplied).
1951
- *
1952
- * Issue #34: with `update.putFallback: true` (OFF by default), a TYPED
1953
- * 404 on the pinned route retries ONCE as `PUT /{Entity}/{id}` where the
1954
- * gate holds — never a collectionUpdate route (the spec defines no
1955
- * item-path update there), never a `raw: true` update, never a non-404
1956
- * fault; the retry body runs the same fresh-read merge (see
1957
- * `putFallbackMergedBody`), so the fallback is never a null-clearing
1958
- * partial. A declined gate (a collectionUpdate route 404s, or the retry
1959
- * itself 404s) throws the self-explaining typed error (the route tried,
1960
- * the status, the opt-in; `details.putFallback`). */
1961
- /**
1962
- * Issue #34 gate: the fallback may fire only while `update.putFallback`
1963
- * is ON and the pinned update route answered a TYPED 404 (a transport
1964
- * `NotFoundError` with `httpStatus: 404`) — never a 400/405/422/5xx, and
1965
- * never a 2xx shape fault (those keep today's typed error byte-identical).
1966
- */
1967
- putFallbackEligible(err) {
1968
- return this.http.update.putFallback === true && err instanceof NotFoundError && err.httpStatus === 404;
1969
- }
1970
- /**
1971
- * Issue #34: the fallback retry body — the caller's (normalised) fields
1972
- * merged onto a FRESH unredacted read of the current record (the same
1973
- * merge the collection-update path performs; the read is the live record,
1974
- * never cached). A target miss on the merge read surfaces with the pinned
1975
- * update-target reason (the issue-#14 convention) — the recovery has
1976
- * nothing to merge onto, and no retry is issued.
1977
- */
1978
- async putFallbackMergedBody(read, callerBody, id) {
1979
- let current;
1980
- try {
1981
- current = await read();
1982
- } catch (err) {
1983
- if (err instanceof NotFoundError) attachUpdateTargetMissReason(err, this.config.key);
1984
- throw err;
1985
- }
1986
- const merged = mergePartialBody(current, callerBody);
1987
- merged.id = id;
1988
- return merged;
1989
- }
1990
- /**
1991
- * Issue #34: the self-explaining typed error a DECLINED gate throws. The
1992
- * original transport error keeps its identity (class / code / httpStatus);
1993
- * the message names the route tried, the status and the opt-in, and the
1994
- * structured detail rides under `details.putFallback`
1995
- * (`{ route, status, retried, optIn }`).
1996
- */
1997
- putFallbackDeclined(err, route, retried) {
1998
- err.message = `${route} answered 404 on the pinned update route; ` + (retried ? `the update.putFallback retry ${route} also answered 404 \u2014 the zone drops the item route as well, so no further recovery is possible` : `the opt-in update.putFallback declines to retry this route (collectionUpdate resources have no spec-defined item-path update; raw updates send exactly what they were told) \u2014 no fallback was issued`);
1999
- err.details = { ...err.details ?? {}, putFallback: { route, status: 404, retried, optIn: "update.putFallback" } };
2000
- err.suggestedAction = retried ? "The zone answers 404 for both the pinned update route and the item route \u2014 verify the zone route registration for this entity before retrying." : "The zone drops the pinned update route and the spec defines no item-path update for this resource \u2014 verify the zone route registration, or issue the update against the collection route raw.";
2001
- return err;
2002
- }
2003
- async updateGlobal(id, data, opts = {}) {
2004
- const numeric = parseIdentifier(id, "id");
2005
- const operation = `${this.config.key}.update`;
2006
- let body = this.normalizeMutationBody(data);
2007
- const collectionUpdate = this.config.collectionUpdate === true;
2008
- if (collectionUpdate) {
2009
- if (opts.raw !== true) {
2010
- let current;
2011
- try {
2012
- current = await this.getGlobalUnredacted(numeric);
2013
- } catch (err) {
2014
- if (err instanceof NotFoundError) attachUpdateTargetMissReason(err, this.config.key);
2015
- throw err;
2016
- }
2017
- this.staleObjectCheck(current, opts, operation);
2018
- body = mergePartialBody(current, body);
2019
- } else {
2020
- await this.guardStaleBeforeMutation(numeric, opts, operation);
2021
- }
2022
- body.id = numeric;
2023
- }
2024
- const route = collectionUpdate ? `PUT ${this.resourcePath}` : `PUT ${this.resourcePath}/${numeric}`;
2025
- let res;
2026
- try {
2027
- res = await this.http.request({
2028
- method: "PUT",
2029
- path: collectionUpdate ? this.resourcePath : `${this.resourcePath}/${numeric}`,
2030
- body,
2031
- operation
2032
- });
2033
- } catch (err) {
2034
- if (!this.putFallbackEligible(err)) throw err;
2035
- if (collectionUpdate) {
2036
- throw this.putFallbackDeclined(err, route, false);
2037
- }
2038
- const merged = await this.putFallbackMergedBody(
2039
- () => this.getGlobalUnredacted(numeric),
2040
- body,
2041
- numeric
2042
- );
2043
- let retry;
2044
- try {
2045
- retry = await this.http.request({
2046
- method: "PUT",
2047
- path: `${this.resourcePath}/${numeric}`,
2048
- body: merged,
2049
- operation
2050
- });
2051
- } catch (rerr) {
2052
- if (this.putFallbackEligible(rerr)) throw this.putFallbackDeclined(rerr, route, true);
2053
- throw rerr;
2054
- }
2055
- return unwrapOperation(retry, operation);
2056
- }
2057
- return unwrapOperation(res, operation);
2058
- }
2059
- /** `DELETE /V1.0/<E>/{id}` → `{ id }` (OperationResultModel). A blank 2xx
2060
- * (204 / empty body) is a valid vendor delete answer — synthesize the
2061
- * result from the path id (issue #25: the transport returns undefined
2062
- * for it; a non-blank wrong shape still throws via unwrapOperation). */
2063
- async deleteGlobal(id) {
2064
- const numeric = parseIdentifier(id, "id");
2065
- const res = await this.http.request({
2066
- method: "DELETE",
2067
- path: `${this.resourcePath}/${numeric}`,
2068
- operation: `${this.config.key}.delete`
2069
- });
2070
- return res === void 0 ? { id: numeric } : unwrapOperation(res, `${this.config.key}.delete`);
2071
- }
2072
- scopedCollectionPath(parentId) {
2073
- if (this.config.parentScopedPath === void 0) {
2074
- throw new AutotaskConfigError(`Resource ${this.config.key} is not parent-scoped; there is no parent-scoped path`);
2075
- }
2076
- const p = parseIdentifier(parentId, "parentId");
2077
- return this.config.parentScopedPath.replace("{parentId}", String(p));
2078
- }
2079
- scopedItemPath(parentId, id) {
2080
- return `${this.scopedCollectionPath(parentId)}/${parseIdentifier(id, "id")}`;
2081
- }
2082
- /** Parent-scoped create: `POST .../{parentId}/<Child>` (issue #13: a drifted 2xx runs the bounded marker recovery where the plan pins a marker field). */
2083
- async createScopedRaw(parentId, data, opts = {}) {
2084
- const body = this.normalizeMutationBody(data);
2085
- const res = await this.http.request({
2086
- method: "POST",
2087
- path: this.scopedCollectionPath(parentId),
2088
- body,
2089
- operation: `${this.config.key}.create`
2090
- });
2091
- return this.unwrapCreateOperation(res, body, opts);
2092
- }
2093
- /** Parent-scoped update: `PATCH .../{parentId}/<Child>` — the COLLECTION.
2094
- * Swagger documents put+patch on the scoped collection and has NO put/patch on
2095
- * the item path anywhere in the spec; the node strips the id from the path and
2096
- * PATCHes the collection, and the vendor keys the record by the id in the body. */
2097
- async updateScopedRaw(parentId, id, data) {
2098
- const numeric = parseIdentifier(id, "id");
2099
- const body = this.normalizeMutationBody(data);
2100
- body.id = numeric;
2101
- const path = this.scopedCollectionPath(parentId);
2102
- const operation = `${this.config.key}.update`;
2103
- let res;
2104
- try {
2105
- res = await this.http.request({
2106
- method: "PATCH",
2107
- path,
2108
- body,
2109
- operation
2110
- });
2111
- } catch (err) {
2112
- if (!this.putFallbackEligible(err)) throw err;
2113
- const merged = await this.putFallbackMergedBody(
2114
- () => this.getScopedUnredacted(parentId, numeric),
2115
- body,
2116
- numeric
2117
- );
2118
- let retry;
2119
- try {
2120
- retry = await this.http.request({
2121
- method: "PUT",
2122
- path: `${path}/${numeric}`,
2123
- body: merged,
2124
- operation
2125
- });
2126
- } catch (rerr) {
2127
- if (this.putFallbackEligible(rerr)) throw this.putFallbackDeclined(rerr, `PUT ${path}/${numeric}`, true);
2128
- throw rerr;
2129
- }
2130
- return unwrapOperation(retry, operation);
2131
- }
2132
- return unwrapOperation(res, operation);
2133
- }
2134
- /** Parent-scoped delete: `DELETE .../{parentId}/<Child>/{id}` → `{ id }`
2135
- * (a blank 2xx synthesizes `{ id }` from the path id — issue #25). */
2136
- async deleteScopedRaw(parentId, id) {
2137
- const numeric = parseIdentifier(id, "id");
2138
- const res = await this.http.request({
2139
- method: "DELETE",
2140
- path: this.scopedItemPath(parentId, id),
2141
- operation: `${this.config.key}.delete`
2142
- });
2143
- return res === void 0 ? { id: numeric } : unwrapOperation(res, `${this.config.key}.delete`);
2144
- }
2145
- /**
2146
- * Attachment delete: the vendor returns the deleted attachment MODEL, not
2147
- * OperationResultModel (G1 envelope.md) — pass it through typed.
2148
- */
2149
- async deleteScopedModel(parentId, id) {
2150
- const numeric = parseIdentifier(id, "id");
2151
- const res = await this.http.request({
2152
- method: "DELETE",
2153
- path: this.scopedItemPath(parentId, id),
2154
- operation: `${this.config.key}.delete`
2155
- });
2156
- if (res === void 0) return {};
2157
- if (!isRecord(res)) {
2158
- throw new AutotaskError(
2159
- `Expected the deleted attachment model for ID ${numeric}; got a different shape`,
2160
- { code: "INVALID_RESPONSE", category: "server", resourceIds: [numeric] }
2161
- );
2162
- }
2163
- return res;
2164
- }
2165
- // ------------------------------------------------------------------
2166
- // Entity metadata trio (global paths; the vendor defines them for children too)
2167
- // ------------------------------------------------------------------
2168
- /** `GET /V1.0/<E>/entityInformation` → `{ info }` unwrapped.
2169
- * Goes through the opt-in field cache when `fieldCache` is configured
2170
- * (off by default: the bit-for-bit uncached wire path). A fresh
2171
- * (bypass + refresh) metadata read is the caller's act: drop the
2172
- * entity's entries via the fieldCache's `invalidate` first — the trio
2173
- * keeps its no-arg surface (the registry/MCP arity pin). */
2174
- async getEntityInfo() {
2175
- return await this.cachedMeta(
2176
- "info",
2177
- `${this.resourcePath}/entityInformation`,
2178
- `${this.config.key}.getEntityInfo`,
2179
- (res) => {
2180
- const body = isRecord(res) ? res : void 0;
2181
- if (body === void 0 || !isRecord(body.info)) {
2182
- throw new AutotaskError("Expected the entityInformation envelope { info }; got a different shape", {
2183
- code: "INVALID_RESPONSE",
2184
- category: "server"
2185
- });
2186
- }
2187
- return body.info;
2188
- }
2189
- );
2190
- }
2191
- /** `GET /V1.0/<E>/entityInformation/fields` → `{ fields }` unwrapped.
2192
- * Goes through the opt-in field cache when `fieldCache` is configured
2193
- * (off by default: the bit-for-bit uncached wire path). A fresh
2194
- * (bypass + refresh) metadata read is the caller's act: drop the
2195
- * entity's entries via the fieldCache's `invalidate` first — the trio
2196
- * keeps its no-arg surface (the registry/MCP arity pin). */
2197
- async getFieldInfo() {
2198
- return await this.cachedMeta(
2199
- "fields",
2200
- `${this.resourcePath}/entityInformation/fields`,
2201
- `${this.config.key}.getFieldInfo`,
2202
- (res) => this.unwrapFields(res, "FieldInformationResultModel")
2203
- );
2204
- }
2205
- /** `GET /V1.0/<E>/entityInformation/userDefinedFields` → `{ fields }` unwrapped.
2206
- * Goes through the opt-in field cache when `fieldCache` is configured
2207
- * (off by default: the bit-for-bit uncached wire path). A fresh
2208
- * (bypass + refresh) metadata read is the caller's act: drop the
2209
- * entity's entries via the fieldCache's `invalidate` first — the trio
2210
- * keeps its no-arg surface (the registry/MCP arity pin). */
2211
- async getUdfInfo() {
2212
- return await this.cachedMeta(
2213
- "udf",
2214
- `${this.resourcePath}/entityInformation/userDefinedFields`,
2215
- `${this.config.key}.getUdfInfo`,
2216
- (res) => this.unwrapFields(res, "UserDefinedFieldInformationResultModel")
2217
- );
2218
- }
2219
- unwrapFields(body, envelopeName) {
2220
- if (!isRecord(body) || !Array.isArray(body.fields)) {
2221
- throw new AutotaskError(`Expected the ${envelopeName} envelope { fields: [] }; got a different shape`, {
2222
- code: "INVALID_RESPONSE",
2223
- category: "server"
2224
- });
2225
- }
2226
- return body.fields;
2227
- }
2228
- // ------------------------------------------------------------------
2229
- // Compositions used by special ops (node-parity flows)
2230
- // ------------------------------------------------------------------
2231
- /**
2232
- * Bounded read against another entity's global `query` endpoint (special-op
2233
- * compositions only; the primary surface stays the resource's own ops).
2234
- */
2235
- async queryEntity(entityPath, conditions, opts = {}) {
2236
- const body = { filter: buildFilter(conditions).filter };
2237
- if (opts.maxRecords !== void 0) body.MaxRecords = clampPageSize(opts.maxRecords);
2238
- if (opts.includeFields !== void 0) body.IncludeFields = opts.includeFields;
2239
- const res = await this.http.request({
2240
- method: "POST",
2241
- idempotent: true,
2242
- path: entityPath,
2243
- body,
2244
- operation: `${this.config.key}.cross-query`
2245
- });
2246
- return unwrapItems(res, `${this.config.key}.cross-query`).items;
2247
- }
2248
- async createIfNotExists(dataOrParentId, dataOrOpts, opts = {}) {
2249
- const isObject = (v) => isRecord(v) && !Array.isArray(v);
2250
- const scoped = this.config.parentScopedPath !== void 0;
2251
- let parentId;
2252
- let data;
2253
- let cinOpts;
2254
- if (scoped) {
2255
- const dataIsOptsBag = isObject(dataOrOpts) && ("dedupFields" in dataOrOpts || "dryRun" in dataOrOpts);
2256
- if (!isIdentifierCandidate(dataOrParentId) || !isObject(dataOrOpts) || dataIsOptsBag) {
2257
- throw new AutotaskConfigError(
2258
- `createIfNotExists for parent-scoped resource ${this.config.key} requires (parentId, data[, opts]) \u2014 the global collection has no create; a missing parentId would be a wrong-path request`
2259
- );
2260
- }
2261
- parentId = parseIdentifier(dataOrParentId, "parentId");
2262
- data = dataOrOpts;
2263
- cinOpts = opts;
2264
- } else {
2265
- if (!isObject(dataOrParentId)) {
2266
- throw new AutotaskConfigError(
2267
- `createIfNotExists for top-level resource ${this.config.key} takes (data[, opts]) \u2014 got a non-record first argument`
2268
- );
2269
- }
2270
- data = dataOrParentId;
2271
- cinOpts = isObject(dataOrOpts) ? dataOrOpts : {};
2272
- }
2273
- const record = isRecord(data) ? data : {};
2274
- const dedupFields = (cinOpts.dedupFields ?? []).filter((field) => field in record);
2275
- let existing;
2276
- let existingId;
2277
- if (dedupFields.length > 0) {
2278
- const conditions = dedupFields.map((field) => ({
2279
- field,
2280
- op: "eq",
2281
- value: record[field]
2282
- }));
2283
- if (parentId !== void 0 && this.config.parentField !== void 0) {
2284
- conditions.unshift({ field: this.config.parentField, op: "eq", value: parentId });
2285
- }
2286
- const matches = await this.queryEntity(`${this.resourcePath}/query`, conditions, { maxRecords: 10 });
2287
- const first = matches[0];
2288
- if (first !== void 0) {
2289
- const firstId = first.id;
2290
- if (typeof firstId === "number") {
2291
- existing = first;
2292
- existingId = firstId;
2293
- }
2294
- }
2295
- }
2296
- if (cinOpts.dryRun === true) {
2297
- const checks = [
2298
- { name: "input-validation", ok: isRecord(data) && Object.keys(data).length > 0 }
2299
- ];
2300
- if (dedupFields.length > 0) {
2301
- checks.push({ name: "duplicate-detection", ok: existingId === void 0 });
2302
- }
2303
- const warnings = existingId !== void 0 ? [`duplicate match on ${dedupFields.join(", ")} (id ${existingId}); the create would be skipped`] : [];
2304
- return buildDryRunResult({
2305
- operation: `${this.config.key}.createIfNotExists`,
2306
- method: "POST",
2307
- path: parentId !== void 0 ? this.scopedCollectionPath(parentId) : this.resourcePath,
2308
- ids: existingId !== void 0 ? [existingId] : [],
2309
- checks,
2310
- warnings,
2311
- impact: { affected: 1, scope: "single-record", reversible: true },
2312
- data
2313
- }, { redactPersonalData: this.http.redactPersonalData });
2314
- }
2315
- if (existing !== void 0 && existingId !== void 0) {
2316
- return { id: existingId, created: false, existing };
2317
- }
2318
- const op = parentId !== void 0 ? await this.createScopedRaw(parentId, data) : await this.createGlobal(data);
2319
- return { id: op.id, created: true };
2320
- }
2321
- // ------------------------------------------------------------------
2322
- // Dry-run scaffolding + stale-object guard (mutation safety, section 19.3)
2323
- // ------------------------------------------------------------------
2324
- /**
2325
- * Stale-object guard (section 19.3): compare the plan's `staleCheck` field
2326
- * (vendor `lastModifiedDate`) against the caller-observed value. Mismatch
2327
- * throws `STALE_OBJECT`. Returns true when a comparison was actually made
2328
- * (the resource model carries the field AND the vendor answered it AND the
2329
- * caller supplied an expected value); false when the check is unavailable.
2330
- */
2331
- staleObjectCheck(current, opts, operation) {
2332
- const field = this.config.staleField;
2333
- const expected = opts.lastModifiedDate;
2334
- if (field === void 0 || expected === void 0) return false;
2335
- const actual = current[field];
2336
- if (typeof actual !== "string") return false;
2337
- if (actual !== expected) {
2338
- throw new StaleObjectError(
2339
- `stale object: ${this.config.key} ${field} changed since the caller read it (expected ${expected}, current ${actual})`,
2340
- {
2341
- code: "STALE_OBJECT",
2342
- operation,
2343
- suggestedAction: "Re-read the record and retry with the fresh lastModifiedDate."
2344
- }
2345
- );
2346
- }
2347
- return true;
2348
- }
2349
- /**
2350
- * Stale-object guard for a REAL mutation: the caller supplied an expected
2351
- * `lastModifiedDate` and the model carries the field → fetch the current
2352
- * record and compare before the mutating request (mismatch throws
2353
- * STALE_OBJECT; a vanished record propagates the vendor's NotFoundError).
2354
- */
2355
- async guardStaleBeforeMutation(id, opts, operation, parentId) {
2356
- if (opts.lastModifiedDate === void 0 || this.config.staleField === void 0) return;
2357
- let current;
2358
- try {
2359
- current = parentId === void 0 ? await this.getGlobal(id, true) : this.config.attachmentGet ? await this.getScopedModel(parentId, id, true) : await this.getScopedRaw(parentId, id, true);
2360
- } catch (err) {
2361
- if (err instanceof NotFoundError) attachUpdateTargetMissReason(err, this.config.key);
2362
- throw err;
2363
- }
2364
- this.staleObjectCheck(current, opts, operation);
2365
- }
2366
- /**
2367
- * `create` dry-run: input validation + parent verification (scoped
2368
- * resources) + the pinned envelope. NO request is issued (scoped parent
2369
- * verification is a read). Duplicate detection has no reliable lookup for
2370
- * a plain create (that is `createIfNotExists`), so it is not claimed.
2371
- */
2372
- async runCreateDryRun(data, parentId) {
2373
- const checks = [];
2374
- const warnings = [];
2375
- checks.push({ name: "input-validation", ok: isRecord(data) && Object.keys(data).length > 0 });
2376
- const deleteBoundaryNote = DELETE_BOUNDARY_NOTES[this.config.key];
2377
- if (deleteBoundaryNote !== void 0) {
2378
- checks.push({ name: "delete-boundary", ok: true });
2379
- warnings.push(deleteBoundaryNote);
2380
- }
2381
- let path = this.resourcePath;
2382
- if (parentId !== void 0) {
2383
- path = this.scopedCollectionPath(parentId);
2384
- const parentPath = this.config.parentPath;
2385
- if (parentPath === void 0) {
2386
- throw new AutotaskConfigError(
2387
- `dry-run: ${this.config.key} is parent-scoped but has no parentPath configured`
2388
- );
2389
- }
2390
- const parent = await this.contextFetch(parentPath, parentId);
2391
- checks.push({ name: "parent-exists", ok: parent !== null });
2392
- if (parent === null) {
2393
- warnings.push(`parent ${parentId} not found; the vendor would reject the create`);
2394
- }
2395
- }
2396
- return buildDryRunResult({
2397
- operation: `${this.config.key}.create`,
2398
- method: "POST",
2399
- path,
2400
- ids: [],
2401
- checks,
2402
- impact: { affected: 1, scope: "single-record", reversible: true },
2403
- warnings,
2404
- data
2405
- }, { redactPersonalData: this.http.redactPersonalData });
2406
- }
2407
- /**
2408
- * `update` dry-run: input validation + current-record fetch (a missing
2409
- * record carries `warnings` and no `diff` — pinned), the stale-object
2410
- * check (mismatch throws STALE_OBJECT) and the best-effort `diff`. NO
2411
- * mutating request is issued.
2412
- */
2413
- async runUpdateDryRun(id, data, opts = {}, parentId) {
2414
- const operation = `${this.config.key}.update`;
2415
- const checks = [];
2416
- const warnings = [];
2417
- checks.push({ name: "input-validation", ok: isRecord(data) && Object.keys(data).length > 0 });
2418
- const scopedUpdate = parentId !== void 0 && this.config.parentScopedPath !== void 0;
2419
- const collectionUpdate = scopedUpdate || this.config.collectionUpdate === true;
2420
- const path = scopedUpdate ? this.scopedCollectionPath(parentId) : collectionUpdate ? this.resourcePath : `${this.resourcePath}/${id}`;
2421
- const method = scopedUpdate ? "PATCH" : "PUT";
2422
- let current = null;
2423
- try {
2424
- current = parentId === void 0 ? await this.getGlobal(id, true) : this.config.attachmentGet ? await this.getScopedModel(parentId, id, true) : await this.getScopedRaw(parentId, id, true);
2425
- } catch (err) {
2426
- if (err instanceof NotFoundError) {
2427
- const updateReason = EMPTY_REASON_STAMPS[this.config.key]?.update;
2428
- checks.push({
2429
- name: "current-record-exists",
2430
- ok: false,
2431
- ...updateReason !== void 0 ? { note: updateReason.note } : {}
2432
- });
2433
- warnings.push(`current record ${id} not fetchable; no diff`);
2434
- return buildDryRunResult({
2435
- operation,
2436
- method,
2437
- path,
2438
- ids: [id],
2439
- checks,
2440
- impact: { affected: 1, scope: "single-record", reversible: true },
2441
- warnings,
2442
- data,
2443
- ...updateReason !== void 0 ? { emptyReason: updateReason } : {}
2444
- }, { redactPersonalData: this.http.redactPersonalData });
2445
- }
2446
- throw err;
2447
- }
2448
- checks.push({ name: "current-record-exists", ok: true });
2449
- if (this.staleObjectCheck(current, opts, operation)) {
2450
- checks.push({ name: "stale-check", ok: true });
2451
- }
2452
- let envelopeData = data;
2453
- if (parentId === void 0 && this.config.collectionUpdate === true && opts.raw !== true) {
2454
- envelopeData = mergePartialBody(
2455
- current,
2456
- this.normalizeMutationBody(data)
2457
- );
2458
- }
2459
- return buildDryRunResult({
2460
- operation,
2461
- method,
2462
- path,
2463
- ids: [id],
2464
- checks,
2465
- diff: computeFieldDiff(current, data),
2466
- impact: { affected: 1, scope: "single-record", reversible: true },
2467
- warnings,
2468
- data: envelopeData
2469
- }, { redactPersonalData: this.http.redactPersonalData });
2470
- }
2471
- /**
2472
- * `delete` dry-run: current-record verification + the stale-object check +
2473
- * the pinned envelope (no diff — a delete has no before/after fields). NO
2474
- * mutating request is issued.
2475
- */
2476
- async runDeleteDryRun(id, opts = {}, parentId) {
2477
- const operation = `${this.config.key}.delete`;
2478
- const checks = [];
2479
- const warnings = [];
2480
- const path = parentId === void 0 ? `${this.resourcePath}/${id}` : this.scopedItemPath(parentId, id);
2481
- let current = null;
2482
- try {
2483
- current = parentId === void 0 ? await this.getGlobal(id, true) : this.config.attachmentGet ? await this.getScopedModel(parentId, id, true) : await this.getScopedRaw(parentId, id, true);
2484
- } catch (err) {
2485
- if (err instanceof NotFoundError) {
2486
- checks.push({ name: "current-record-exists", ok: false });
2487
- warnings.push(`record ${id} not fetchable; the vendor would reject the delete`);
2488
- return buildDryRunResult({
2489
- operation,
2490
- method: "DELETE",
2491
- path,
2492
- ids: [id],
2493
- checks,
2494
- impact: { affected: 1, scope: "single-record", reversible: false },
2495
- warnings
2496
- }, { redactPersonalData: this.http.redactPersonalData });
2497
- }
2498
- throw err;
2499
- }
2500
- checks.push({ name: "current-record-exists", ok: true });
2501
- if (this.staleObjectCheck(current, opts, operation)) {
2502
- checks.push({ name: "stale-check", ok: true });
2503
- }
2504
- return buildDryRunResult({
2505
- operation,
2506
- method: "DELETE",
2507
- path,
2508
- ids: [id],
2509
- checks,
2510
- impact: { affected: 1, scope: "single-record", reversible: false },
2511
- warnings
2512
- }, { redactPersonalData: this.http.redactPersonalData });
2513
- }
2514
- /**
2515
- * Attachment `download`: `GET .../{parentId}/<Child>/{id}/data` → Buffer
2516
- * (node-proven `/data` path, quirk #9; the metadata GET is `get`).
2517
- */
2518
- async downloadScoped(parentId, id) {
2519
- const res = await this.http.request({
2520
- method: "GET",
2521
- path: `${this.scopedItemPath(parentId, id)}/data`,
2522
- operation: `${this.config.key}.download`
2523
- });
2524
- return decodeFileData(res);
2525
- }
2526
- // ------------------------------------------------------------------
2527
- // Cross-entity reads + resolution helpers (helper-floor ops, G4)
2528
- // ------------------------------------------------------------------
2529
- /** Raw single-record read on ANY entity path: `GET <path>/{id}` → `{ item }`
2530
- * unwrapped (`item: null` → NotFoundError). Used by `resolve` (id branch)
2531
- * and `getContext` (the cross-entity parts); the operation tag is scoped to
2532
- * the resource + call site so errors stay attributable. `fresh` skips the
2533
- * cache lookup (the read still refreshes the entry for the other callers):
2534
- * the composite ROOT of a hot resource (issue #36) must see the live
2535
- * record, never a snapshot. */
2536
- async fetchRecordRaw(entityPath, id, fresh = false) {
2537
- const numeric = parseIdentifier(id, "id");
2538
- const resource = RESOURCE_KEY_BY_PATH[entityPath];
2539
- const res = await this.cachedGet(`${entityPath}/${numeric}`, `${this.config.key}.fetchRecord`, fresh, resource);
2540
- if (this.config.attachmentGet) {
2541
- return this.unwrapModelLike(res, numeric);
2542
- }
2543
- return unwrapItem(res, numeric, `${this.config.key}.fetchRecord`);
2544
- }
2545
- /**
2546
- * `getContext` part fetch: a single-record read that degrades to `null`
2547
- * when the reference id is absent (or the vendor answers not-found).
2548
- * Other errors propagate — a part that fails on a real fault is not a
2549
- * "missing reference".
2550
- *
2551
- * Issue #36 (negative caching, the pinned hazard): the vendor's true
2552
- * not-found (the HTTP 404 or the 200 `item: null` signal — both surface
2553
- * as `NotFoundError`) is stored as an ABSENT marker in the SAME ReadCache
2554
- * under a namespaced key the positive get path never constructs, so a
2555
- * repeat composite call serves 'absent' with zero wire for that part.
2556
- * The marker expires on the READ entity's TTL (the #32 per-resource
2557
- * table / `perResourceTtlMs` override, via `set`'s resource key) and is
2558
- * cleared by any real write through the client (the ReadCache clear).
2559
- * A transient fault (5xx, shape drift, network) is NEVER stored as an
2560
- * ABSENT marker — it propagates and the next call re-wires (no
2561
- * poisoning; a malformed 200 body may still land under the POSITIVE
2562
- * key — pre-existing ReadCache behaviour, out of this mechanism's
2563
- * scope). The marker is consulted BEFORE the positive lookup, but a
2564
- * POSITIVE entry that exists alongside a live marker means a newer
2565
- * positive outcome (this client read/created the record after the
2566
- * not-found) — the marker is dropped and the positive entry serves
2567
- * (the newest outcome wins in both directions). A record created
2568
- * ELSEWHERE may serve 'absent' until the marker TTL expires (or a write
2569
- * through this client lands) — the opt-in staleness contract, same
2570
- * trade as the positive entries.
2571
- */
2572
- async contextFetch(entityPath, referenceId) {
2573
- if (typeof referenceId !== "number" || !Number.isInteger(referenceId) || referenceId < 0) {
2574
- return null;
2575
- }
2576
- const cache = this.http.readCache;
2577
- const absentKey = contextAbsentKey(`${entityPath}/${referenceId}`);
2578
- if (cache !== void 0 && cache.get(absentKey) !== void 0) {
2579
- if (cache.get(`${entityPath}/${referenceId}`) !== void 0) {
2580
- cache.delete(absentKey);
2581
- } else {
2582
- return null;
2583
- }
2584
- }
2585
- try {
2586
- return await this.fetchRecordRaw(entityPath, referenceId);
2587
- } catch (err) {
2588
- if (err instanceof NotFoundError) {
2589
- cache?.set(absentKey, CONTEXT_ABSENT_MARKER, RESOURCE_KEY_BY_PATH[entityPath]);
2590
- return null;
2591
- }
2592
- throw err;
2593
- }
2594
- }
2595
- /**
2596
- * `resolve` — the helper-floor resolution (section 19.2), with cost and
2597
- * truncation tracking (`resolveDetailed`); the plain `resolveBase` is its
2598
- * value projection. Pinned semantics:
2599
- * - numeric id (number or all-digit string) → single record read; a miss
2600
- * throws NotFoundError (the vendor's own not-found, cost `direct`);
2601
- * - bare value → SERVER FILTER FIRST: exact (`eq`) on the `searchFields`
2602
- * (OR-grouped when the entity covers several), bounded to 2 records —
2603
- * 1 → the record (cost `server-filter`), ≥2 → RESOLUTION_AMBIGUOUS with
2604
- * the candidate ids;
2605
- * - zero items + zero `requestCount` is a COMPLETE non-match (the vendor
2606
- * filters every model field; the server answer is authoritative) → null;
2607
- * - zero items + positive `requestCount` is an INCONCLUSIVE server answer
2608
- * (an index gap the server could not reconcile) → the bounded CLIENT
2609
- * SCAN fallback: an unfiltered walk, exact match on the `searchFields`,
2610
- * capped by the client caps (`maxScanRecords` / `maxScanPages`). A hit
2611
- * is cost `client-scan`; a completed scan with no match → null; a
2612
- * cap-tripped scan THROWS RESOLUTION_TRUNCATED — never a silent null.
2613
- * - bare value on an id-only resource → AutotaskConfigError (CONFIG_ERROR).
2614
- */
2615
- async resolveDetailed(identifier) {
2616
- let numeric = null;
2617
- if (isIdentifierCandidate(identifier)) {
2618
- numeric = parseIdentifier(identifier, "id");
2619
- }
2620
- if (numeric !== null) {
2621
- const record = await this.fetchRecordRaw(this.resourcePath, numeric);
2622
- return { value: record, resolutionCost: "direct", scanned: 0, scanTruncated: false };
2623
- }
2624
- if (typeof identifier !== "string" || identifier.trim() === "") {
2625
- throw new AutotaskConfigError("resolve: identifier must be a numeric id or a non-empty value");
2626
- }
2627
- const value = identifier.trim();
2628
- const fields = this.config.searchFields ?? [];
2629
- if (fields.length === 0) {
2630
- throw new AutotaskConfigError(
2631
- `resolve: ${this.config.key} has no primary text field (ids only)`
2632
- );
2633
- }
2634
- const leaf = fields.map((field) => ({ field, op: "eq", value }));
2635
- const root = leaf.length === 1 ? leaf[0] : { op: "or", items: leaf };
2636
- const { items, requestCount } = await this.queryPage1([root], 2);
2637
- if (items.length === 1) {
2638
- return { value: items[0], resolutionCost: "server-filter", scanned: 0, scanTruncated: false };
2639
- }
2640
- if (items.length >= 2) {
2641
- const ids = items.map((m) => m.id).filter((id) => typeof id === "number");
2642
- throw new ResolutionError(
2643
- `resolve: "${value}" is ambiguous on ${this.config.key} (matched ${items.length} records)`,
2644
- { code: "RESOLUTION_AMBIGUOUS", resourceIds: ids }
2645
- );
2646
- }
2647
- if (requestCount === 0) {
2648
- return { value: null, resolutionCost: "server-filter", scanned: 0, scanTruncated: false, triedFields: fields };
2649
- }
2650
- const scan = await this.scanForExactMatch(value, fields);
2651
- if (scan.found !== null) {
2652
- return {
2653
- value: scan.found,
2654
- resolutionCost: "client-scan",
2655
- scanned: scan.scanned,
2656
- scanTruncated: false
2657
- };
2658
- }
2659
- if (scan.truncated) {
2660
- throw new ResolutionError(
2661
- `resolve: "${value}" is unverified on ${this.config.key} \u2014 the bounded client scan read ${scan.scanned} records (cap) without completing; a complete non-match cannot be proven`,
2662
- {
2663
- code: "RESOLUTION_TRUNCATED",
2664
- suggestedAction: "Retry with a more specific identifier, or narrow the search to fewer records."
2665
- }
2666
- );
2667
- }
2668
- return { value: null, resolutionCost: "client-scan", scanned: scan.scanned, scanTruncated: false, triedFields: fields };
2669
- }
2670
- /** The plain value projection of `resolveDetailed` (the `resolve` floor). */
2671
- async resolveBase(identifier) {
2672
- return (await this.resolveDetailed(identifier)).value;
2673
- }
2674
- /** One page of the resource's own `/query` with items + total match count. */
2675
- async queryPage1(conditions, maxRecords) {
2676
- const body = {
2677
- filter: buildFilter(conditions).filter,
2678
- MaxRecords: maxRecords
2679
- };
2680
- const res = await this.http.request({
2681
- method: "POST",
2682
- idempotent: true,
2683
- path: `${this.resourcePath}/query`,
2684
- body,
2685
- operation: `${this.config.key}.resolve`
2686
- });
2687
- const { items, pageDetails } = unwrapItems(res, `${this.config.key}.resolve`);
2688
- return { items, requestCount: pageDetails.requestCount };
2689
- }
2690
- /**
2691
- * The bounded client-scan fallback (section 19.2): an unfiltered walk of
2692
- * the resource's own query endpoint, exact match on `fields`, bounded by
2693
- * BOTH client caps (records AND pages). `truncated` is true when the caps
2694
- * were hit before the walk completed (a complete non-match is unproven).
2695
- */
2696
- async scanForExactMatch(value, fields) {
2697
- const caps = this.resolutionCaps;
2698
- const pageSize = Math.max(1, Math.floor(caps.maxScanRecords / caps.maxScanPages));
2699
- const state = { cursor: null };
2700
- let scanned = 0;
2701
- let pages = 0;
2702
- let found = null;
2703
- let exhausted = false;
2704
- for (; ; ) {
2705
- const page = await this.fetchPage({ maxRecords: pageSize }, pages + 1, pageSize, state);
2706
- pages += 1;
2707
- for (const item of page.items) {
2708
- scanned += 1;
2709
- const rec = item;
2710
- if (fields.some((field) => rec[field] === value)) {
2711
- found = item;
2712
- break;
2713
- }
2714
- }
2715
- if (found !== null) break;
2716
- if (!page.hasMore) {
2717
- exhausted = true;
2718
- break;
2719
- }
2720
- if (pages >= caps.maxScanPages || scanned >= caps.maxScanRecords) break;
2721
- }
2722
- return { found, scanned, truncated: found === null && !exhausted };
2723
- }
2724
- /**
2725
- * `findBy<field>` helpers: exact (`eq`) filter on one vendor field, bounded
2726
- * to 2 records — 0 → null, 1 → the record, ≥2 → ResolutionError with the
2727
- * candidate ids.
2728
- */
2729
- async findByField(field, value) {
2730
- if (value.trim() === "") throw new AutotaskConfigError(`findBy${field}: empty value`);
2731
- const matches = await this.queryEntity(
2732
- `${this.resourcePath}/query`,
2733
- [{ field, op: "eq", value: value.trim() }],
2734
- { maxRecords: 2 }
2735
- );
2736
- if (matches.length === 0) return null;
2737
- if (matches.length > 1) {
2738
- const ids = matches.map((m) => m.id).filter((id) => typeof id === "number");
2739
- throw new ResolutionError(
2740
- `findBy${field}: "${value}" is ambiguous on ${this.config.key} (matched ${matches.length} records)`,
2741
- { code: "RESOLUTION_AMBIGUOUS", resourceIds: ids }
2742
- );
2743
- }
2744
- return matches[0];
2745
- }
2746
- /**
2747
- * `search` — the bounded contains surface (no vendor free-text search):
2748
- * `contains` on every `searchFields` field (OR-grouped), `MaxRecords` =
2749
- * `limit` (default 25, clamped to 1..500). Empty query → AutotaskConfigError.
2750
- */
2751
- async searchBase(query, opts = {}) {
2752
- const text = query.trim();
2753
- if (text === "") throw new AutotaskConfigError("search: empty query");
2754
- const fields = this.config.searchFields ?? [];
2755
- if (fields.length === 0) {
2756
- throw new AutotaskConfigError(`search: ${this.config.key} has no searchable field`);
2757
- }
2758
- const limit = opts.limit === void 0 ? 25 : clampPageSize(opts.limit);
2759
- const leaf = fields.map((field) => ({ field, op: "contains", value: text }));
2760
- const root = leaf.length === 1 ? leaf[0] : { op: "or", items: leaf };
2761
- return this.queryEntity(`${this.resourcePath}/query`, [root], { maxRecords: limit });
2762
- }
2763
- };
11
+ TICKET_LABEL_FIELDS,
12
+ UNTRUSTED_FIELDS_BY_RESOURCE,
13
+ addLabelSibling,
14
+ addNameSibling,
15
+ blindStampFor,
16
+ buildDryRunResult,
17
+ buildFilter,
18
+ clampPageSize,
19
+ createNamePlan,
20
+ cursorToPath,
21
+ decodeFileData,
22
+ formatVendorDetail,
23
+ isAutotaskError,
24
+ mergePartialBody,
25
+ parseIdentifier,
26
+ parseVendorErrors,
27
+ resolveCompositeNames,
28
+ resolveFieldsPreset,
29
+ truncateNoteChars,
30
+ unwrapItem,
31
+ unwrapItems,
32
+ unwrapOperation
33
+ } from "./chunk-HWC2WPX2.js";
34
+ import {
35
+ CAPABILITIES
36
+ } from "./chunk-6KFP5HV3.js";
2764
37
 
2765
38
  // src/resources/appointments.ts
2766
39
  var AppointmentsResource = class extends BaseResource {
@@ -2891,6 +164,324 @@ var BillingCodesResource = class extends BaseResource {
2891
164
  }
2892
165
  };
2893
166
 
167
+ // src/resources/special/dateWindow.ts
168
+ var DAY_MS = 864e5;
169
+ var DEFAULT_LIMIT = 25;
170
+ var MAX_LIMIT = 500;
171
+ function clampWindowLimit(limit) {
172
+ const n = limit === void 0 ? DEFAULT_LIMIT : Math.trunc(limit);
173
+ if (!Number.isInteger(n) || n < 1) {
174
+ throw new AutotaskConfigError(`limit must be an integer >= 1 (got ${String(limit)})`);
175
+ }
176
+ return Math.min(n, MAX_LIMIT);
177
+ }
178
+ var iso = (ms) => new Date(ms).toISOString().slice(0, 19) + "Z";
179
+ var WEEKDAYS = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
180
+ function wallParts(tz, ms) {
181
+ const parts = new Intl.DateTimeFormat("en-US", {
182
+ timeZone: tz,
183
+ year: "numeric",
184
+ month: "2-digit",
185
+ day: "2-digit",
186
+ hour: "2-digit",
187
+ minute: "2-digit",
188
+ second: "2-digit",
189
+ hour12: false,
190
+ weekday: "short"
191
+ }).formatToParts(new Date(ms));
192
+ const w = {};
193
+ let weekday = -1;
194
+ for (const p of parts) {
195
+ switch (p.type) {
196
+ case "year":
197
+ w.y = Number(p.value);
198
+ break;
199
+ case "month":
200
+ w.m = Number(p.value);
201
+ break;
202
+ case "day":
203
+ w.d = Number(p.value);
204
+ break;
205
+ case "hour":
206
+ w.h = Number(p.value) % 24;
207
+ break;
208
+ case "minute":
209
+ w.mi = Number(p.value);
210
+ break;
211
+ case "second":
212
+ w.s = Number(p.value);
213
+ break;
214
+ case "weekday":
215
+ weekday = WEEKDAYS[p.value] ?? -1;
216
+ break;
217
+ }
218
+ }
219
+ if (weekday < 0 || [w.y, w.m, w.d, w.h, w.mi, w.s].some((v) => v === void 0 || Number.isNaN(v))) {
220
+ throw new AutotaskConfigError(`dateWindow: could not read the wall-clock parts for timezone "${tz}" (the Intl format returned an incomplete or unmapped part)`);
221
+ }
222
+ return { y: w.y, m: w.m, d: w.d, h: w.h, mi: w.mi, s: w.s, weekday };
223
+ }
224
+ function tzOffsetMs(tz, ms) {
225
+ const p = wallParts(tz, ms);
226
+ return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s) - ms;
227
+ }
228
+ function localDayStartMs(y, m, d, tz) {
229
+ if (tz === void 0) return Date.UTC(y, m - 1, d);
230
+ const candidate = Date.UTC(y, m - 1, d);
231
+ const offset = tzOffsetMs(tz, candidate);
232
+ let start = candidate - offset;
233
+ if (tzOffsetMs(tz, start) !== offset) {
234
+ start = candidate - tzOffsetMs(tz, start);
235
+ }
236
+ const p = wallParts(tz, start);
237
+ if (p.d !== d) start += 864e5 - ((p.h * 60 + p.mi) * 60 + p.s) * 1e3;
238
+ return start;
239
+ }
240
+ function localDayEndMs(y, m, d, tz) {
241
+ if (tz === void 0) return Date.UTC(y, m - 1, d) + DAY_MS - 1;
242
+ const next = new Date(Date.UTC(y, m - 1, d + 1));
243
+ return localDayStartMs(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate(), tz) - 1;
244
+ }
245
+ function validateZone(tz, opName = "dateWindow") {
246
+ if (typeof tz !== "string" || tz.length === 0) {
247
+ throw new AutotaskConfigError(
248
+ `${opName}: tz must be a non-empty IANA timezone string (got ${JSON.stringify(tz)}; e.g. 'Australia/Sydney' \u2014 omitted tz = UTC)`,
249
+ { operation: opName }
250
+ );
251
+ }
252
+ try {
253
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
254
+ } catch {
255
+ throw new AutotaskConfigError(
256
+ `${opName}: tz is not a valid IANA timezone (${JSON.stringify(tz)}; e.g. 'Australia/Sydney' \u2014 omitted tz = UTC)`,
257
+ { operation: opName }
258
+ );
259
+ }
260
+ return tz;
261
+ }
262
+ var utcDayStart = (ms) => {
263
+ const d = new Date(ms);
264
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
265
+ };
266
+ var utcDayEnd = (ms) => utcDayStart(ms) + DAY_MS - 1;
267
+ function utcDateWindowBounds(kind, now) {
268
+ const t = now.getTime();
269
+ let from;
270
+ let to;
271
+ switch (kind) {
272
+ case "today":
273
+ from = utcDayStart(t);
274
+ to = utcDayEnd(t);
275
+ break;
276
+ case "yesterday":
277
+ from = utcDayStart(t - DAY_MS);
278
+ to = utcDayEnd(t - DAY_MS);
279
+ break;
280
+ case "last24h":
281
+ from = t - DAY_MS;
282
+ to = t;
283
+ break;
284
+ case "last7":
285
+ case "last14":
286
+ case "last30":
287
+ case "last90": {
288
+ const days = Number(kind.slice(4));
289
+ from = utcDayStart(t - days * DAY_MS);
290
+ to = utcDayEnd(t);
291
+ break;
292
+ }
293
+ case "lastFullWeek":
294
+ case "lastFullFortnight": {
295
+ const d = new Date(t);
296
+ const daysSinceMonday = (d.getUTCDay() + 6) % 7;
297
+ const thisMonday = utcDayStart(t) - daysSinceMonday * DAY_MS;
298
+ const daysBack = kind === "lastFullWeek" ? 7 : 14;
299
+ from = thisMonday - daysBack * DAY_MS;
300
+ to = thisMonday - 1;
301
+ break;
302
+ }
303
+ case "lastFullMonth": {
304
+ const d = new Date(t);
305
+ const y = d.getUTCFullYear();
306
+ const m = d.getUTCMonth() - 1;
307
+ from = Date.UTC(y, m, 1);
308
+ to = Date.UTC(y, m + 1, 1) - 1;
309
+ break;
310
+ }
311
+ case "lastFullQuarter": {
312
+ const d = new Date(t);
313
+ let q = Math.floor(d.getUTCMonth() / 3) - 1;
314
+ let y = d.getUTCFullYear();
315
+ if (q < 0) {
316
+ q = 3;
317
+ y -= 1;
318
+ }
319
+ const first = q * 3;
320
+ from = Date.UTC(y, first, 1);
321
+ to = Date.UTC(y, first + 3, 1) - 1;
322
+ break;
323
+ }
324
+ }
325
+ return { from: iso(from), to: iso(to) };
326
+ }
327
+ function dateWindowBounds(kind, now, tz) {
328
+ if (tz === void 0) return utcDateWindowBounds(kind, now);
329
+ const zone = validateZone(tz);
330
+ const t = now.getTime();
331
+ if (kind === "last24h") {
332
+ return { from: iso(t - DAY_MS), to: iso(t) };
333
+ }
334
+ const { y, m, d, weekday } = wallParts(zone, t);
335
+ const shifted = (dy) => new Date(Date.UTC(y, m - 1, d + dy));
336
+ const dayStart = (dy) => {
337
+ const dt = shifted(dy);
338
+ return localDayStartMs(dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate(), zone);
339
+ };
340
+ const dayStartAt = (instant) => {
341
+ const p = wallParts(zone, instant);
342
+ return localDayStartMs(p.y, p.m, p.d, zone);
343
+ };
344
+ const dayEndAt = (instant) => {
345
+ const p = wallParts(zone, instant);
346
+ return localDayEndMs(p.y, p.m, p.d, zone);
347
+ };
348
+ let from;
349
+ let to;
350
+ switch (kind) {
351
+ case "today":
352
+ from = dayStartAt(t);
353
+ to = dayEndAt(t);
354
+ break;
355
+ case "yesterday":
356
+ from = dayStart(-1);
357
+ to = dayStart(0) - 1;
358
+ break;
359
+ case "last7":
360
+ case "last14":
361
+ case "last30":
362
+ case "last90": {
363
+ const days = Number(kind.slice(4));
364
+ from = dayStart(-days);
365
+ to = dayEndAt(t);
366
+ break;
367
+ }
368
+ case "lastFullWeek":
369
+ case "lastFullFortnight": {
370
+ const daysSinceMonday = (weekday + 6) % 7;
371
+ const daysBack = kind === "lastFullWeek" ? 7 : 14;
372
+ from = dayStart(-(daysSinceMonday + daysBack));
373
+ to = dayStart(-daysSinceMonday) - 1;
374
+ break;
375
+ }
376
+ case "lastFullMonth": {
377
+ const prev = new Date(Date.UTC(y, m - 2, 1));
378
+ from = localDayStartMs(prev.getUTCFullYear(), prev.getUTCMonth() + 1, 1, zone);
379
+ to = localDayStartMs(y, m, 1, zone) - 1;
380
+ break;
381
+ }
382
+ case "lastFullQuarter": {
383
+ const q = Math.floor((m - 1) / 3);
384
+ const prevQ = q - 1 < 0 ? 3 : q - 1;
385
+ const prevY = q - 1 < 0 ? y - 1 : y;
386
+ from = localDayStartMs(prevY, prevQ * 3 + 1, 1, zone);
387
+ to = localDayStartMs(y, q * 3 + 1, 1, zone) - 1;
388
+ break;
389
+ }
390
+ }
391
+ return { from: iso(from), to: iso(to) };
392
+ }
393
+ async function dateWindowQuery(http, path, field, kind, limit, operation, now, tz) {
394
+ const zone = tz === void 0 ? void 0 : validateZone(tz, operation);
395
+ const { from, to } = dateWindowBounds(kind, now, zone);
396
+ const filter = [
397
+ { field, op: "gte", value: from },
398
+ { field, op: "lte", value: to }
399
+ ];
400
+ const res = await http.request({
401
+ method: "POST",
402
+ idempotent: true,
403
+ path,
404
+ body: { MaxRecords: limit, filter },
405
+ operation
406
+ });
407
+ return unwrapItems(res).items;
408
+ }
409
+ var isoUtc = (ms) => new Date(ms).toISOString().slice(0, 19) + "Z";
410
+ function customRangeDay(value, label, zone, opName) {
411
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
412
+ if (!m) {
413
+ throw new AutotaskConfigError(
414
+ zone === void 0 ? `${opName}: ${label} must be a UTC 'YYYY-MM-DD' date (got ${JSON.stringify(value)})` : `${opName}: ${label} must be a 'YYYY-MM-DD' local calendar day in ${zone} (got ${JSON.stringify(value)})`,
415
+ { operation: opName }
416
+ );
417
+ }
418
+ const y = Number(m[1]);
419
+ const mo = Number(m[2]);
420
+ const d = Number(m[3]);
421
+ const probe = new Date(Date.UTC(y, mo - 1, d));
422
+ const real = probe.getUTCFullYear() === y && probe.getUTCMonth() === mo - 1 && probe.getUTCDate() === d;
423
+ const ms = !real ? NaN : zone === void 0 ? Date.UTC(y, mo - 1, d) : localDayStartMs(y, mo, d, zone);
424
+ if (!Number.isFinite(ms)) {
425
+ throw new AutotaskConfigError(
426
+ zone === void 0 ? `${opName}: ${label} is not a valid UTC date (${JSON.stringify(value)})` : `${opName}: ${label} is not a valid calendar day in ${zone} (${JSON.stringify(value)})`,
427
+ { operation: opName }
428
+ );
429
+ }
430
+ return { y, mo, d, ms };
431
+ }
432
+ async function customRangeQuery(http, path, field, from, to, limit, operation, zone) {
433
+ const fromB = customRangeDay(from, "from", zone, operation);
434
+ const toB = customRangeDay(to, "to", zone, operation);
435
+ if (fromB.ms > toB.ms) {
436
+ throw new AutotaskConfigError(
437
+ `${operation}: from (${isoUtc(fromB.ms)}) must not be after to (${isoUtc(toB.ms)})`,
438
+ { operation }
439
+ );
440
+ }
441
+ const toEndMs = zone === void 0 ? toB.ms + DAY_MS - 1 : localDayEndMs(toB.y, toB.mo, toB.d, zone);
442
+ const filter = [
443
+ { field, op: "gte", value: isoUtc(fromB.ms) },
444
+ { field, op: "lte", value: isoUtc(toEndMs) }
445
+ ];
446
+ const res = await http.request({
447
+ method: "POST",
448
+ idempotent: true,
449
+ path,
450
+ body: { MaxRecords: limit, filter },
451
+ operation
452
+ });
453
+ return unwrapItems(res).items;
454
+ }
455
+
456
+ // src/resources/special/billing_items.ts
457
+ async function windowQuery(_resource, kind, http, opts) {
458
+ const now = opts?.now ?? /* @__PURE__ */ new Date();
459
+ const tz = opts?.tz === void 0 ? void 0 : validateZone(opts.tz, `billing_items.${kind}`);
460
+ return dateWindowQuery(
461
+ http,
462
+ "/V1.0/BillingItems/query",
463
+ "itemDate",
464
+ kind,
465
+ clampWindowLimit(opts?.limit),
466
+ `billing_items.${kind}`,
467
+ now,
468
+ tz
469
+ );
470
+ }
471
+ async function customRange(_resource, from, to, http, opts) {
472
+ const zone = opts?.tz === void 0 ? void 0 : validateZone(opts.tz, "billing_items.customRange");
473
+ return customRangeQuery(
474
+ http,
475
+ "/V1.0/BillingItems/query",
476
+ "itemDate",
477
+ from,
478
+ to,
479
+ clampWindowLimit(opts?.limit),
480
+ "billing_items.customRange",
481
+ zone
482
+ );
483
+ }
484
+
2894
485
  // src/resources/billing_items.ts
2895
486
  var BillingItemsResource = class extends BaseResource {
2896
487
  constructor(http, pageSize) {
@@ -2943,6 +534,54 @@ var BillingItemsResource = class extends BaseResource {
2943
534
  void opts;
2944
535
  return this.resolveBase(identifier);
2945
536
  }
537
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
538
+ customRange(from, to, opts) {
539
+ return customRange(this, from, to, this.http, opts);
540
+ }
541
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
542
+ last14(opts) {
543
+ return windowQuery(this, "last14", this.http, opts);
544
+ }
545
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
546
+ last24h(opts) {
547
+ return windowQuery(this, "last24h", this.http, opts);
548
+ }
549
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
550
+ last30(opts) {
551
+ return windowQuery(this, "last30", this.http, opts);
552
+ }
553
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
554
+ last7(opts) {
555
+ return windowQuery(this, "last7", this.http, opts);
556
+ }
557
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
558
+ last90(opts) {
559
+ return windowQuery(this, "last90", this.http, opts);
560
+ }
561
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
562
+ lastFullFortnight(opts) {
563
+ return windowQuery(this, "lastFullFortnight", this.http, opts);
564
+ }
565
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
566
+ lastFullMonth(opts) {
567
+ return windowQuery(this, "lastFullMonth", this.http, opts);
568
+ }
569
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
570
+ lastFullQuarter(opts) {
571
+ return windowQuery(this, "lastFullQuarter", this.http, opts);
572
+ }
573
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
574
+ lastFullWeek(opts) {
575
+ return windowQuery(this, "lastFullWeek", this.http, opts);
576
+ }
577
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
578
+ today(opts) {
579
+ return windowQuery(this, "today", this.http, opts);
580
+ }
581
+ /** Node-parity special op (hand-written in src/resources/special/billing_items.ts). */
582
+ yesterday(opts) {
583
+ return windowQuery(this, "yesterday", this.http, opts);
584
+ }
2946
585
  };
2947
586
 
2948
587
  // src/resources/change_request_links.ts
@@ -3184,12 +823,12 @@ var ClassificationIconsResource = class extends BaseResource {
3184
823
  };
3185
824
 
3186
825
  // src/resources/special/companies.ts
3187
- var DEFAULT_LIMIT = 25;
3188
- var MAX_LIMIT = 500;
826
+ var DEFAULT_LIMIT2 = 25;
827
+ var MAX_LIMIT2 = 500;
3189
828
  function clampLimit(limit) {
3190
- const n = limit === void 0 ? DEFAULT_LIMIT : Math.trunc(limit);
829
+ const n = limit === void 0 ? DEFAULT_LIMIT2 : Math.trunc(limit);
3191
830
  if (!Number.isInteger(n) || n < 1) throw new AutotaskConfigError(`limit must be an integer >= 1 (got ${String(limit)})`);
3192
- return Math.min(n, MAX_LIMIT);
831
+ return Math.min(n, MAX_LIMIT2);
3193
832
  }
3194
833
  async function queryCompanies(http, filter, limit) {
3195
834
  const res = await http.request({
@@ -3261,7 +900,85 @@ async function searchByIdentity(_resource, identity, opts = {}, http) {
3261
900
  const ids = [...new Set(contacts.map((c) => c.companyID).filter((id) => typeof id === "number" && id >= 0))];
3262
901
  add(await fetchCompanies(http, ids, limit));
3263
902
  }
3264
- return [...found.values()].slice(0, limit);
903
+ return [...found.values()].slice(0, limit);
904
+ }
905
+ var COMPANIES_FAMILY_CHILD_CAP = 50;
906
+ var COMPANIES_FAMILY_BLIND_NOTE = "the family is reported as SEEN BY THIS API USER, never as complete: childIDs (and the search hits themselves) are scoped to the caller-visible enumeration (companies.query is the plan-pinned unowned-blind surface \u2014 get is the authority)";
907
+ var COMPANY_SEARCH_OPT_KEYS = ["limit", "includeFamily"];
908
+ async function familyChildPage(http, parentID) {
909
+ const res = await http.request({
910
+ method: "POST",
911
+ idempotent: true,
912
+ path: "/V1.0/Companies/query",
913
+ body: {
914
+ MaxRecords: COMPANIES_FAMILY_CHILD_CAP,
915
+ filter: buildFilter([{ field: "parentCompanyID", op: "eq", value: parentID }]).filter
916
+ },
917
+ operation: "companies.search"
918
+ });
919
+ const { items, pageDetails } = unwrapItems(res, "companies.search");
920
+ return { rows: items, hasNext: pageDetails.nextPageUrl !== null };
921
+ }
922
+ function assertSearchWithDetailOpts(opts) {
923
+ if (opts === void 0) return;
924
+ if (typeof opts !== "object" || opts === null || Array.isArray(opts)) {
925
+ throw new AutotaskConfigError("companies.searchWithDetail: opts must be an object { limit?: number }", {
926
+ operation: "companies.searchWithDetail"
927
+ });
928
+ }
929
+ for (const key of Object.keys(opts)) {
930
+ if (key !== "limit") {
931
+ throw new AutotaskConfigError(
932
+ `companies.searchWithDetail: unknown opts key "${key}" \u2014 the bag is closed: limit (includeFamily is a companies.search option)`,
933
+ { operation: "companies.searchWithDetail" }
934
+ );
935
+ }
936
+ }
937
+ }
938
+ async function searchWithFamily(_resource, query, opts = {}, http) {
939
+ if (opts !== void 0 && (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
940
+ throw new AutotaskConfigError("companies.search: opts must be an object { limit?: number, includeFamily?: boolean }");
941
+ }
942
+ for (const key of Object.keys(opts ?? {})) {
943
+ if (!COMPANY_SEARCH_OPT_KEYS.includes(key)) {
944
+ throw new AutotaskConfigError(
945
+ `companies.search: unknown opts key "${key}" \u2014 the bag is closed: ${COMPANY_SEARCH_OPT_KEYS.join(", ")}`,
946
+ { operation: "companies.search" }
947
+ );
948
+ }
949
+ }
950
+ if (opts.includeFamily !== void 0 && typeof opts.includeFamily !== "boolean") {
951
+ throw new AutotaskConfigError(`companies.search: opts.includeFamily must be a boolean (got ${String(opts.includeFamily)})`, {
952
+ operation: "companies.search"
953
+ });
954
+ }
955
+ const text = query.trim();
956
+ if (text === "") throw new AutotaskConfigError("search: empty query", { operation: "companies.search" });
957
+ const limit = opts.limit === void 0 ? 25 : clampPageSize(opts.limit);
958
+ const res = await http.request({
959
+ method: "POST",
960
+ idempotent: true,
961
+ path: "/V1.0/Companies/query",
962
+ body: { MaxRecords: limit, filter: buildFilter([{ field: "companyName", op: "contains", value: text }]).filter },
963
+ operation: "companies.search"
964
+ });
965
+ const hits = unwrapItems(res, "companies.search").items;
966
+ if (opts.includeFamily !== true) return hits;
967
+ const rows = [];
968
+ for (const hit of hits) {
969
+ const family = {
970
+ parentID: hit.parentCompanyID ?? null,
971
+ childIDs: [],
972
+ blindNote: COMPANIES_FAMILY_BLIND_NOTE
973
+ };
974
+ if (typeof hit.id === "number") {
975
+ const { rows: children, hasNext } = await familyChildPage(http, hit.id);
976
+ family.childIDs = children.map((c) => c.id).filter((id) => typeof id === "number");
977
+ if (children.length >= COMPANIES_FAMILY_CHILD_CAP && hasNext) family.childIDsTruncated = true;
978
+ }
979
+ rows.push({ ...hit, family });
980
+ }
981
+ return rows;
3265
982
  }
3266
983
  var OP_UPSERT_NAME = "companies.upsertByName";
3267
984
  var UPSERT_NAME_MAX_DISCOVERY_MERGES = 3;
@@ -3495,7 +1212,7 @@ async function fullProfile(resource, id, opts = {}, http) {
3495
1212
  };
3496
1213
  }
3497
1214
  }
3498
- return {
1215
+ const result = {
3499
1216
  company,
3500
1217
  locations: { items: locations, capped: locations.length >= PROFILE_PART_LIMIT },
3501
1218
  contacts: { items: contacts, capped: contacts.length >= contactLimit },
@@ -3511,6 +1228,384 @@ async function fullProfile(resource, id, opts = {}, http) {
3511
1228
  projects: "ok"
3512
1229
  }
3513
1230
  };
1231
+ if (opts.names === true) {
1232
+ const builder = createNamePlan(TICKET_LABEL_FIELDS);
1233
+ const c = company;
1234
+ builder.addResource(c["ownerResourceID"]);
1235
+ builder.addResource(c["createdByResourceID"]);
1236
+ for (const row of openTickets.items) {
1237
+ const r = row;
1238
+ builder.addResource(r["creatorResourceID"]);
1239
+ builder.addFk("contract", r["contractID"]);
1240
+ builder.addFk("billingCode", r["billingCodeID"]);
1241
+ builder.addFk("role", r["assignedResourceRoleID"]);
1242
+ }
1243
+ for (const row of projects) {
1244
+ const r = row;
1245
+ builder.addResource(r["projectLeadResourceID"]);
1246
+ builder.addResource(r["creatorResourceID"]);
1247
+ builder.addFk("contract", r["contractID"]);
1248
+ }
1249
+ const names = await resolveCompositeNames(http, builder.plan(), { maxNameReads: opts.maxNameReads });
1250
+ const companyRow = { ...company };
1251
+ addNameSibling(companyRow, "ownerResourceID", (v) => names.resourceName(v));
1252
+ addNameSibling(companyRow, "createdByResourceID", (v) => names.resourceName(v));
1253
+ result.company = companyRow;
1254
+ result.openTickets = {
1255
+ ...openTickets,
1256
+ items: openTickets.items.map((row) => {
1257
+ const r = { ...row };
1258
+ addNameSibling(r, "creatorResourceID", (v) => names.resourceName(v));
1259
+ addNameSibling(r, "contractID", (v) => names.recordName("contract", v));
1260
+ addNameSibling(r, "billingCodeID", (v) => names.recordName("billingCode", v));
1261
+ addNameSibling(r, "assignedResourceRoleID", (v) => names.recordName("role", v));
1262
+ for (const f of TICKET_LABEL_FIELDS) addLabelSibling(r, f, names);
1263
+ return r;
1264
+ })
1265
+ };
1266
+ result.projects = {
1267
+ ...result.projects,
1268
+ items: result.projects.items.map((row) => {
1269
+ const r = { ...row };
1270
+ addNameSibling(r, "projectLeadResourceID", (v) => names.resourceName(v));
1271
+ addNameSibling(r, "creatorResourceID", (v) => names.resourceName(v));
1272
+ addNameSibling(r, "contractID", (v) => names.recordName("contract", v));
1273
+ return r;
1274
+ })
1275
+ };
1276
+ result.names = names.section;
1277
+ }
1278
+ return result;
1279
+ }
1280
+ var UNBILLED_WORK_NOTE_CHARS_DEFAULT = 400;
1281
+ var UNBILLED_WORK_JOIN_SET_CAP = 300;
1282
+ var UNBILLED_WORK_OPT_KEYS = [
1283
+ "source",
1284
+ "includeParent",
1285
+ "dateFrom",
1286
+ "dateTo",
1287
+ "invoiceId",
1288
+ "ticketIds",
1289
+ "includeNotes",
1290
+ "noteChars",
1291
+ "rollup",
1292
+ "limit"
1293
+ ];
1294
+ var UNBILLED_WORK_SOURCE_SET = ["posted_uninvoiced", "invoice", "date_range", "tickets"];
1295
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1296
+ function num(v) {
1297
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
1298
+ }
1299
+ function isDateYMD(v) {
1300
+ return typeof v === "string" && DATE_RE.test(v) && !Number.isNaN(Date.parse(v));
1301
+ }
1302
+ function validateUnbilledWorkOptions(companyId, opts) {
1303
+ if (opts !== void 0 && (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
1304
+ throw new AutotaskConfigError(
1305
+ `unbilledWork: opts must be the options object (got ${Array.isArray(opts) ? "an array" : typeof opts})`,
1306
+ { operation: "companies.unbilledWork" }
1307
+ );
1308
+ }
1309
+ const problems = [];
1310
+ for (const key of Object.keys(opts ?? {})) {
1311
+ if (!UNBILLED_WORK_OPT_KEYS.includes(key)) {
1312
+ problems.push(`unknown opts key "${key}" \u2014 the bag is closed: ${UNBILLED_WORK_OPT_KEYS.join(", ")}`);
1313
+ }
1314
+ }
1315
+ const source = opts.source ?? "posted_uninvoiced";
1316
+ if (!UNBILLED_WORK_SOURCE_SET.includes(source)) {
1317
+ problems.push(`source must be one of: ${UNBILLED_WORK_SOURCE_SET.join(", ")} (got ${String(source)})`);
1318
+ }
1319
+ if (opts.includeParent !== void 0 && typeof opts.includeParent !== "boolean") {
1320
+ problems.push(`includeParent must be a boolean (got ${String(opts.includeParent)})`);
1321
+ }
1322
+ if (opts.includeNotes !== void 0 && typeof opts.includeNotes !== "boolean") {
1323
+ problems.push(`includeNotes must be a boolean (got ${String(opts.includeNotes)})`);
1324
+ }
1325
+ const noteChars = opts.noteChars ?? UNBILLED_WORK_NOTE_CHARS_DEFAULT;
1326
+ if (!Number.isInteger(noteChars) || noteChars < 1) problems.push(`noteChars must be an integer >= 1 (got ${String(opts.noteChars)})`);
1327
+ const rollup = opts.rollup ?? "ticket";
1328
+ if (rollup !== "ticket" && rollup !== "entry") problems.push(`rollup must be 'ticket' or 'entry' (got ${String(opts.rollup)})`);
1329
+ let limit;
1330
+ try {
1331
+ limit = clampLimit(opts.limit);
1332
+ } catch (err) {
1333
+ problems.push(err instanceof AutotaskError ? err.message : `limit must be an integer >= 1 (got ${String(opts.limit)})`);
1334
+ limit = 50;
1335
+ }
1336
+ if (source === "date_range") {
1337
+ if (!isDateYMD(opts.dateFrom)) problems.push(`dateFrom is required (YYYY-MM-DD) when source is "date_range" (got ${String(opts.dateFrom)})`);
1338
+ if (!isDateYMD(opts.dateTo)) problems.push(`dateTo is required (YYYY-MM-DD) when source is "date_range" (got ${String(opts.dateTo)})`);
1339
+ } else {
1340
+ if (opts.dateFrom !== void 0 && !isDateYMD(opts.dateFrom)) problems.push(`dateFrom must be YYYY-MM-DD (got ${String(opts.dateFrom)})`);
1341
+ if (opts.dateTo !== void 0 && !isDateYMD(opts.dateTo)) problems.push(`dateTo must be YYYY-MM-DD (got ${String(opts.dateTo)})`);
1342
+ }
1343
+ if (source === "invoice") {
1344
+ if (opts.invoiceId === void 0) {
1345
+ problems.push('invoiceId is required (the vendor invoice id) when source is "invoice"');
1346
+ } else {
1347
+ try {
1348
+ parseIdentifier(opts.invoiceId, "opts.invoiceId");
1349
+ } catch (err) {
1350
+ problems.push(err instanceof AutotaskError ? err.message : "invoiceId must be a vendor numeric id");
1351
+ }
1352
+ }
1353
+ }
1354
+ if (source === "tickets") {
1355
+ if (!Array.isArray(opts.ticketIds) || opts.ticketIds.length === 0) {
1356
+ problems.push('ticketIds is required (a non-empty array of vendor ticket ids) when source is "tickets"');
1357
+ } else {
1358
+ opts.ticketIds.forEach((tid, i) => {
1359
+ try {
1360
+ parseIdentifier(tid, `opts.ticketIds[${i}]`);
1361
+ } catch (err) {
1362
+ problems.push(err instanceof AutotaskError ? err.message : `ticketIds[${i}] must be a vendor numeric id`);
1363
+ }
1364
+ });
1365
+ }
1366
+ }
1367
+ try {
1368
+ parseIdentifier(companyId, "companyId");
1369
+ } catch (err) {
1370
+ problems.push(err instanceof AutotaskError ? err.message : "companyId must be a vendor numeric id");
1371
+ }
1372
+ if (problems.length > 0) {
1373
+ throw new AutotaskConfigError(
1374
+ `unbilledWork: ${problems.length} problem(s) found pre-request (zero wire was issued)
1375
+ - ` + problems.join("\n- "),
1376
+ { operation: "companies.unbilledWork" }
1377
+ );
1378
+ }
1379
+ return {
1380
+ source,
1381
+ includeParent: opts.includeParent ?? true,
1382
+ dateFrom: isDateYMD(opts.dateFrom) ? opts.dateFrom : void 0,
1383
+ dateTo: isDateYMD(opts.dateTo) ? opts.dateTo : void 0,
1384
+ invoiceId: source === "invoice" ? opts.invoiceId : void 0,
1385
+ ticketIds: source === "tickets" ? opts.ticketIds : void 0,
1386
+ includeNotes: opts.includeNotes ?? false,
1387
+ noteChars,
1388
+ rollup,
1389
+ limit
1390
+ };
1391
+ }
1392
+ async function boundedPart(http, path, conditions, limit, includeFields) {
1393
+ const body = { MaxRecords: limit, filter: conditions };
1394
+ if (includeFields !== void 0) body.IncludeFields = includeFields;
1395
+ const res = await http.request({
1396
+ method: "POST",
1397
+ idempotent: true,
1398
+ path,
1399
+ body,
1400
+ operation: "companies.unbilledWork"
1401
+ });
1402
+ const { items, pageDetails } = unwrapItems(res, "companies.unbilledWork");
1403
+ const capped = items.length >= limit;
1404
+ return { items, capped, hasMore: capped && pageDetails.nextPageUrl != null };
1405
+ }
1406
+ function capJoinSet(ids, cap) {
1407
+ const sorted = [...new Set(ids)].sort((a, b) => a - b);
1408
+ if (sorted.length <= cap) return { ids: sorted, truncated: false };
1409
+ return { ids: sorted.slice(0, cap), truncated: true };
1410
+ }
1411
+ function unbilledWorkPartStatus(key, conditions, items) {
1412
+ if (items.length !== 0) return { status: "ok" };
1413
+ const stamp = blindStampFor(BLIND_SURFACE_STAMPS[key]?.query, conditions);
1414
+ if (stamp !== void 0) return { status: "blind", blindSurface: stamp, note: stamp.note };
1415
+ return {
1416
+ status: "ok",
1417
+ note: `0 rows on ${key}.query for this filter (a filtered empty is a TRUE zero \u2014 the pinned blind surfaces stamp only their blind shape, and this call is always filtered)`
1418
+ };
1419
+ }
1420
+ async function unbilledWork(resource, companyId, opts = {}, http) {
1421
+ const v = validateUnbilledWorkOptions(companyId, opts);
1422
+ const id = parseIdentifier(companyId, "companyId");
1423
+ const company = await resource.get(id);
1424
+ const companyIds = [num(company.id)];
1425
+ let parent = null;
1426
+ let parentStatus = "skipped";
1427
+ if (v.includeParent) {
1428
+ const parentID = company.parentCompanyID;
1429
+ if (typeof parentID === "number" && parentID > 0) {
1430
+ try {
1431
+ parent = await resource.get(parentID);
1432
+ parentStatus = "ok";
1433
+ companyIds.push(num(parent.id));
1434
+ } catch (err) {
1435
+ if (err instanceof NotFoundError) parent = null;
1436
+ else throw err;
1437
+ }
1438
+ }
1439
+ }
1440
+ const ticketsCond = [{ field: "companyID", op: "in", value: companyIds }];
1441
+ const ticketsRaw = await boundedPart(http, "/V1.0/Tickets/query", ticketsCond, v.limit);
1442
+ const projectsCond = [{ field: "companyID", op: "in", value: companyIds }];
1443
+ const projectsRaw = await boundedPart(http, "/V1.0/Projects/query", projectsCond, v.limit);
1444
+ const projectIds = projectsRaw.items.map((p) => num(p.id)).filter((n) => n > 0);
1445
+ let taskIds = [];
1446
+ let tasksHasMore = false;
1447
+ if (projectIds.length > 0) {
1448
+ const tasksRaw = await boundedPart(http, "/V1.0/Tasks/query", [{ field: "projectID", op: "in", value: projectIds }], v.limit);
1449
+ taskIds = tasksRaw.items.map((t) => num(t.id)).filter((n) => n > 0);
1450
+ tasksHasMore = tasksRaw.hasMore;
1451
+ }
1452
+ const ticketJoinIds = ticketsRaw.items.map((t) => num(t.id)).filter((n) => n > 0);
1453
+ if (v.source === "tickets" && v.ticketIds !== void 0) {
1454
+ for (const tid of v.ticketIds) ticketJoinIds.push(typeof tid === "number" ? tid : parseInt(tid, 10));
1455
+ }
1456
+ const billingConditions = [{ field: "companyID", op: "in", value: companyIds }];
1457
+ if (v.source === "posted_uninvoiced") billingConditions.push({ field: "invoiceID", op: "notExist" });
1458
+ if (v.source === "invoice" && v.invoiceId !== void 0) billingConditions.push({ field: "invoiceID", op: "eq", value: v.invoiceId });
1459
+ if (v.source === "date_range") {
1460
+ billingConditions.push({ field: "itemDate", op: "gte", value: v.dateFrom }, { field: "itemDate", op: "lte", value: v.dateTo });
1461
+ }
1462
+ if (v.source === "tickets" && v.ticketIds !== void 0) billingConditions.push({ field: "ticketID", op: "in", value: v.ticketIds });
1463
+ if (v.source !== "date_range") {
1464
+ if (v.dateFrom !== void 0) billingConditions.push({ field: "itemDate", op: "gte", value: v.dateFrom });
1465
+ if (v.dateTo !== void 0) billingConditions.push({ field: "itemDate", op: "lte", value: v.dateTo });
1466
+ }
1467
+ const billingFields = resolveFieldsPreset("billing_join", "billing_items");
1468
+ const billingIncludeFields = [...billingFields, "id", ...v.includeNotes ? ["description"] : []];
1469
+ const billingRaw = await boundedPart(http, "/V1.0/BillingItems/query", billingConditions, v.limit, billingIncludeFields);
1470
+ const taskCap = capJoinSet(taskIds, UNBILLED_WORK_JOIN_SET_CAP);
1471
+ const ticketCap = capJoinSet(ticketJoinIds, UNBILLED_WORK_JOIN_SET_CAP);
1472
+ const joinNotes = [];
1473
+ for (const [what, more] of [["ticket", ticketsRaw.hasMore], ["project", projectsRaw.hasMore], ["task", tasksHasMore]]) {
1474
+ if (more) joinNotes.push(`${what} join read hit the limit of ${v.limit} \u2014 time entries are joined against the first ${v.limit} ${what}s only (raise limit to widen it)`);
1475
+ }
1476
+ if (taskCap.truncated) joinNotes.push(`task join set truncated to ${UNBILLED_WORK_JOIN_SET_CAP} of ${taskIds.length} ids (the 8000-char filter cap \u2014 the results cover the returned join set only)`);
1477
+ if (ticketCap.truncated) joinNotes.push(`ticket join set truncated to ${UNBILLED_WORK_JOIN_SET_CAP} of ${ticketJoinIds.length} ids (the 8000-char filter cap \u2014 the results cover the returned join set only)`);
1478
+ let timePart;
1479
+ let entries = [];
1480
+ if (taskCap.ids.length === 0 && ticketCap.ids.length === 0) {
1481
+ timePart = {
1482
+ items: [],
1483
+ capped: false,
1484
+ hasMore: false,
1485
+ status: "skipped",
1486
+ // joinNotes ride the skipped part too: an empty join set from a PARTIAL read (e.g. a full projects page) is not a true zero
1487
+ note: ["skipped: the company (set) has no tickets or tasks in the returned join set \u2014 no tasks/tickets to join against is NOT evidence of no time entries (zero wire)", ...joinNotes].join(" | ")
1488
+ };
1489
+ } else {
1490
+ const timeConditions = taskCap.ids.length > 0 && ticketCap.ids.length > 0 ? [{ op: "or", items: [{ field: "taskID", op: "in", value: taskCap.ids }, { field: "ticketID", op: "in", value: ticketCap.ids }] }] : taskCap.ids.length > 0 ? [{ field: "taskID", op: "in", value: taskCap.ids }] : [{ field: "ticketID", op: "in", value: ticketCap.ids }];
1491
+ if (v.dateFrom !== void 0) timeConditions.push({ field: "dateWorked", op: "gte", value: v.dateFrom });
1492
+ if (v.dateTo !== void 0) timeConditions.push({ field: "dateWorked", op: "lte", value: v.dateTo });
1493
+ const timeFields = ["taskID", "ticketID", "hoursWorked", "dateWorked", "id", ...v.includeNotes ? ["summaryNotes"] : []];
1494
+ const raw = await boundedPart(http, "/V1.0/TimeEntries/query", timeConditions, v.limit, timeFields);
1495
+ entries = raw.items;
1496
+ let cappedItems = entries;
1497
+ let noteCharsTruncated = 0;
1498
+ if (v.includeNotes) {
1499
+ [cappedItems, noteCharsTruncated] = truncateNoteChars(entries, v.noteChars, ["summaryNotes"]);
1500
+ }
1501
+ const st = unbilledWorkPartStatus("time_entries", timeConditions, entries);
1502
+ timePart = {
1503
+ items: cappedItems,
1504
+ capped: raw.capped,
1505
+ hasMore: raw.hasMore,
1506
+ status: st.status,
1507
+ blindSurface: st.blindSurface,
1508
+ note: [...joinNotes, ...st.note !== void 0 ? [st.note] : [], ...noteCharsTruncated > 0 ? [`noteChars: ${noteCharsTruncated} note field value(s) truncated at ${v.noteChars} chars`] : []].filter(Boolean).join(" | ") || void 0
1509
+ };
1510
+ }
1511
+ let billingItems = billingRaw.items;
1512
+ let billingNoteCharsTruncated = 0;
1513
+ if (v.includeNotes) {
1514
+ const noteFields = (UNTRUSTED_FIELDS_BY_RESOURCE["billing_items"] ?? []).filter((f) => billingIncludeFields.includes(f));
1515
+ [billingItems, billingNoteCharsTruncated] = truncateNoteChars(billingRaw.items, v.noteChars, noteFields);
1516
+ }
1517
+ const billingSt = unbilledWorkPartStatus("billing_items", billingConditions, billingRaw.items);
1518
+ const billingItemsPart = {
1519
+ items: billingItems,
1520
+ capped: billingRaw.capped,
1521
+ hasMore: billingRaw.hasMore,
1522
+ status: billingSt.status,
1523
+ blindSurface: billingSt.blindSurface,
1524
+ note: [...billingSt.note !== void 0 ? [billingSt.note] : [], ...billingNoteCharsTruncated > 0 ? [`noteChars: ${billingNoteCharsTruncated} note field value(s) truncated at ${v.noteChars} chars`] : []].filter(Boolean).join(" | ") || void 0
1525
+ };
1526
+ const entryByTimeId = /* @__PURE__ */ new Map();
1527
+ for (const e of entries) entryByTimeId.set(num(e.id), e);
1528
+ const fold = /* @__PURE__ */ new Map();
1529
+ const rowFor = (key) => {
1530
+ let r = fold.get(key);
1531
+ if (r === void 0) {
1532
+ r = { ticketId: key === "unassigned" ? null : key, itemCount: 0, hours: 0, preTax: 0, entryCount: 0 };
1533
+ fold.set(key, r);
1534
+ }
1535
+ return r;
1536
+ };
1537
+ for (const item of billingItems) {
1538
+ const t = num(item.ticketID);
1539
+ const r = rowFor(t > 0 ? t : "unassigned");
1540
+ r.itemCount += 1;
1541
+ r.preTax += num(item.extendedPrice);
1542
+ }
1543
+ for (const e of entries) {
1544
+ const t = num(e.ticketID);
1545
+ const r = rowFor(t > 0 ? t : "unassigned");
1546
+ r.entryCount += 1;
1547
+ r.hours += num(e.hoursWorked);
1548
+ }
1549
+ const totals = { preTax: 0, hours: 0, entryCount: 0, totalAmount: 0, taxDollars: 0 };
1550
+ for (const row of fold.values()) {
1551
+ totals.preTax += row.preTax;
1552
+ totals.hours += row.hours;
1553
+ totals.entryCount += row.entryCount;
1554
+ }
1555
+ for (const item of billingItems) {
1556
+ totals.totalAmount += num(item.totalAmount);
1557
+ totals.taxDollars += num(item.taxDollars);
1558
+ }
1559
+ const rollupRows = [...fold.values()].sort((a, b) => {
1560
+ if (a.ticketId === null) return 1;
1561
+ if (b.ticketId === null) return -1;
1562
+ return a.ticketId - b.ticketId;
1563
+ });
1564
+ const rollup = v.rollup === "entry" ? { mode: "entry", items: timePart.items, capped: timePart.capped, hasMore: timePart.hasMore } : { mode: "ticket", rows: rollupRows };
1565
+ const billedEntryIds = new Set(billingItems.map((i) => num(i.timeEntryID)).filter((n) => n > 0));
1566
+ const unpostedAll = entries.filter((e) => num(e.id) > 0 && !billedEntryIds.has(num(e.id)));
1567
+ const unpostedCapped = unpostedAll.length > v.limit;
1568
+ const unposted = {
1569
+ items: unpostedAll.slice(0, v.limit),
1570
+ capped: unpostedCapped,
1571
+ hasMore: unpostedCapped,
1572
+ status: "ok",
1573
+ note: unpostedCapped ? `unposted list capped at ${v.limit} of ${unpostedAll.length} (the full anti-join set is larger)` : void 0
1574
+ };
1575
+ const noTimeEntryAll = billingItems.filter((i) => !num(i.timeEntryID));
1576
+ const hourDeltaAll = [];
1577
+ for (const item of billingItems) {
1578
+ const teId = num(item.timeEntryID);
1579
+ if (teId <= 0) continue;
1580
+ const entry = entryByTimeId.get(teId);
1581
+ if (entry === void 0) continue;
1582
+ const billed = num(item.quantity);
1583
+ const worked = num(entry.hoursWorked);
1584
+ if (Math.abs(billed - worked) > 0) {
1585
+ hourDeltaAll.push({ billingItemId: num(item.id), timeEntryId: teId, billedQuantity: billed, entryHours: worked });
1586
+ }
1587
+ }
1588
+ const warnings = {
1589
+ noTimeEntry: { items: noTimeEntryAll.slice(0, v.limit).map((i) => ({ billingItemId: num(i.id) })), count: noTimeEntryAll.length },
1590
+ hourDelta: { items: hourDeltaAll.slice(0, v.limit), count: hourDeltaAll.length }
1591
+ };
1592
+ return {
1593
+ company,
1594
+ parent,
1595
+ billingItems: billingItemsPart,
1596
+ timeEntries: timePart,
1597
+ rollup,
1598
+ unposted,
1599
+ totals,
1600
+ warnings,
1601
+ parts: {
1602
+ company: "ok",
1603
+ parent: parentStatus,
1604
+ billingItems: billingSt.status,
1605
+ timeEntries: timePart.status,
1606
+ unposted: "ok"
1607
+ }
1608
+ };
3514
1609
  }
3515
1610
 
3516
1611
  // src/resources/companies.ts
@@ -3574,9 +1669,20 @@ var CompaniesResource = class extends BaseResource {
3574
1669
  return this.resolveBase(identifier);
3575
1670
  }
3576
1671
  /** Search Company records: bounded contains on companyName (no vendor free-text search exists);
3577
- * limit defaults to 25, max 500. */
1672
+ * limit defaults to 25, max 500. The closed opts bag { limit?, includeFamily? }:
1673
+ * includeFamily: true adds the per-match family block (parentID — zero extra wire —
1674
+ * the record's own parentCompanyID; childIDs — one bounded single-page query per match,
1675
+ * capped at 50 with the childIDsTruncated flag; the blind-surface blindNote — the
1676
+ * family is reported as SEEN BY THIS API USER, never as complete). */
3578
1677
  async search(query, opts) {
3579
- return this.searchBase(query, opts);
1678
+ return searchWithFamily(this, query, opts, this.http);
1679
+ }
1680
+ /** `search` with an empty result explained. Same closed opts discipline as `search`: only `limit` is
1681
+ * accepted here (an unknown key is refused pre-request, zero wire — never silently dropped); the
1682
+ * family block is a `search`-only option. */
1683
+ async searchWithDetail(query, opts = {}) {
1684
+ assertSearchWithDetailOpts(opts);
1685
+ return super.searchWithDetail(query, opts);
3580
1686
  }
3581
1687
  /** Get the Company with its primary context records in one call (each part a single-record
3582
1688
  * fetch; no sub-lists; absent/dangling references come back null). */
@@ -3603,6 +1709,10 @@ var CompaniesResource = class extends BaseResource {
3603
1709
  fullProfile(id, opts) {
3604
1710
  return fullProfile(this, id, opts, this.http);
3605
1711
  }
1712
+ /** Node-parity special op (hand-written in src/resources/special/companies.ts). */
1713
+ unbilledWork(id, opts) {
1714
+ return unbilledWork(this, id, opts, this.http);
1715
+ }
3606
1716
  };
3607
1717
 
3608
1718
  // src/resources/company_alerts.ts
@@ -4431,7 +2541,7 @@ async function profile(resource, id, opts = {}, http) {
4431
2541
  blind: wallQuirk !== void 0
4432
2542
  };
4433
2543
  if (wallQuirk !== void 0) productAssociations.note = wallQuirk;
4434
- return {
2544
+ const result = {
4435
2545
  ci,
4436
2546
  type,
4437
2547
  category,
@@ -4447,6 +2557,16 @@ async function profile(resource, id, opts = {}, http) {
4447
2557
  productAssociations: wallQuirk !== void 0 ? "blind" : "ok"
4448
2558
  }
4449
2559
  };
2560
+ if (opts.names === true) {
2561
+ const builder = createNamePlan();
2562
+ builder.addFk("contract", root["contractID"]);
2563
+ const names = await resolveCompositeNames(http, builder.plan(), { maxNameReads: opts.maxNameReads });
2564
+ const ciRow = { ...ci };
2565
+ addNameSibling(ciRow, "contractID", (v) => names.recordName("contract", v));
2566
+ result.ci = ciRow;
2567
+ result.names = names.section;
2568
+ }
2569
+ return result;
4450
2570
  }
4451
2571
 
4452
2572
  // src/resources/configuration_items.ts
@@ -7209,6 +5329,33 @@ async function pdf(_resource, id, http) {
7209
5329
  });
7210
5330
  return decodeFileData(res);
7211
5331
  }
5332
+ async function windowQuery2(_resource, kind, http, opts) {
5333
+ const now = opts?.now ?? /* @__PURE__ */ new Date();
5334
+ const tz = opts?.tz === void 0 ? void 0 : validateZone(opts.tz, `invoices.${kind}`);
5335
+ return dateWindowQuery(
5336
+ http,
5337
+ "/V1.0/Invoices/query",
5338
+ "invoiceDateTime",
5339
+ kind,
5340
+ clampWindowLimit(opts?.limit),
5341
+ `invoices.${kind}`,
5342
+ now,
5343
+ tz
5344
+ );
5345
+ }
5346
+ async function customRange2(_resource, from, to, http, opts) {
5347
+ const zone = opts?.tz === void 0 ? void 0 : validateZone(opts.tz, "invoices.customRange");
5348
+ return customRangeQuery(
5349
+ http,
5350
+ "/V1.0/Invoices/query",
5351
+ "invoiceDateTime",
5352
+ from,
5353
+ to,
5354
+ clampWindowLimit(opts?.limit),
5355
+ "invoices.customRange",
5356
+ zone
5357
+ );
5358
+ }
7212
5359
 
7213
5360
  // src/resources/invoices.ts
7214
5361
  var InvoicesResource = class extends BaseResource {
@@ -7263,6 +5410,46 @@ var InvoicesResource = class extends BaseResource {
7263
5410
  return this.resolveBase(identifier);
7264
5411
  }
7265
5412
  /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5413
+ customRange(from, to, opts) {
5414
+ return customRange2(this, from, to, this.http, opts);
5415
+ }
5416
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5417
+ last14(opts) {
5418
+ return windowQuery2(this, "last14", this.http, opts);
5419
+ }
5420
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5421
+ last24h(opts) {
5422
+ return windowQuery2(this, "last24h", this.http, opts);
5423
+ }
5424
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5425
+ last30(opts) {
5426
+ return windowQuery2(this, "last30", this.http, opts);
5427
+ }
5428
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5429
+ last7(opts) {
5430
+ return windowQuery2(this, "last7", this.http, opts);
5431
+ }
5432
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5433
+ last90(opts) {
5434
+ return windowQuery2(this, "last90", this.http, opts);
5435
+ }
5436
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5437
+ lastFullFortnight(opts) {
5438
+ return windowQuery2(this, "lastFullFortnight", this.http, opts);
5439
+ }
5440
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5441
+ lastFullMonth(opts) {
5442
+ return windowQuery2(this, "lastFullMonth", this.http, opts);
5443
+ }
5444
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5445
+ lastFullQuarter(opts) {
5446
+ return windowQuery2(this, "lastFullQuarter", this.http, opts);
5447
+ }
5448
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5449
+ lastFullWeek(opts) {
5450
+ return windowQuery2(this, "lastFullWeek", this.http, opts);
5451
+ }
5452
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
7266
5453
  markupHtml(id) {
7267
5454
  return markup(this, id, "html", this.http);
7268
5455
  }
@@ -7274,6 +5461,14 @@ var InvoicesResource = class extends BaseResource {
7274
5461
  pdf(id) {
7275
5462
  return pdf(this, id, this.http);
7276
5463
  }
5464
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5465
+ today(opts) {
5466
+ return windowQuery2(this, "today", this.http, opts);
5467
+ }
5468
+ /** Node-parity special op (hand-written in src/resources/special/invoices.ts). */
5469
+ yesterday(opts) {
5470
+ return windowQuery2(this, "yesterday", this.http, opts);
5471
+ }
7277
5472
  };
7278
5473
 
7279
5474
  // src/resources/modules.ts
@@ -7895,14 +6090,29 @@ var ProjectsResource = class extends BaseResource {
7895
6090
  return this.searchBase(query, opts);
7896
6091
  }
7897
6092
  /** Get the Project with its primary context records in one call (each part a single-record
7898
- * fetch; no sub-lists; absent/dangling references come back null). */
7899
- async getContext(id) {
6093
+ * fetch; no sub-lists; absent/dangling references come back null). `names: true` (issue #77)
6094
+ * adds the opt-in name siblings on the project row (creator name, contract name — the
6095
+ * closed scope; project picklists stay label-less) + the top-level `names` section;
6096
+ * `maxNameReads` may lower the name-read budget (default 20), never raise it. */
6097
+ async getContext(id, opts = {}) {
7900
6098
  const project = await this.fetchRecordRaw("/V1.0/Projects", id);
7901
6099
  const root = project;
7902
6100
  const company = await this.contextFetch("/V1.0/Companies", root["companyID"]);
7903
6101
  const opportunity = await this.contextFetch("/V1.0/Opportunities", root["opportunityID"]);
7904
6102
  const leadResource = await this.contextFetch("/V1.0/Resources", root["projectLeadResourceID"]);
7905
- return { project, company, opportunity, leadResource };
6103
+ const result = { project, company, opportunity, leadResource };
6104
+ if (opts.names === true) {
6105
+ const builder = createNamePlan();
6106
+ builder.addResource(root["creatorResourceID"]);
6107
+ builder.addFk("contract", root["contractID"]);
6108
+ const names = await resolveCompositeNames(this.http, builder.plan(), { maxNameReads: opts.maxNameReads });
6109
+ const row = { ...project };
6110
+ addNameSibling(row, "creatorResourceID", (v) => names.resourceName(v));
6111
+ addNameSibling(row, "contractID", (v) => names.recordName("contract", v));
6112
+ result.project = row;
6113
+ result.names = names.section;
6114
+ }
6115
+ return result;
7906
6116
  }
7907
6117
  };
7908
6118
 
@@ -8661,9 +6871,12 @@ var ResourcesResource = class extends BaseResource {
8661
6871
  async count(params) {
8662
6872
  return this.countGlobal(params);
8663
6873
  }
8664
- /** Get one record by id (item envelope; item: null → NotFoundError). */
8665
- async get(id) {
8666
- return this.getGlobal(id);
6874
+ /** Get one record by id (item envelope; item: null → NotFoundError).
6875
+ * `fresh: true` in the opts bag bypasses AND refreshes the ReadCache entry for this id
6876
+ * (the per-id authority read of operations.expandResourceNames with fresh: true — issue #77);
6877
+ * a plain get is the bit-for-bit cached read. */
6878
+ async get(id, opts = {}) {
6879
+ return this.getGlobal(id, opts.fresh === true);
8667
6880
  }
8668
6881
  /** Update a record (PUT on the COLLECTION — the vendored spec's item path is GET-only; the vendor
8669
6882
  * keys the record by the id in the body). The vendor validates the FULL model on this route (V2), so a
@@ -8711,6 +6924,14 @@ async function getByResource(_resource, resourceId, http) {
8711
6924
  path: `/V1.0/Resources/${numericResource}/TimeOffAdditional`,
8712
6925
  operation: "resource_time_off_additional.getByResource"
8713
6926
  });
6927
+ if (typeof res === "object" && res !== null && !Array.isArray(res)) {
6928
+ const keys = Object.keys(res);
6929
+ if (keys.length === 1 && keys[0] === "item") {
6930
+ const item = res.item;
6931
+ if (item === null) return [];
6932
+ if (typeof item === "object" && !Array.isArray(item)) return [item];
6933
+ }
6934
+ }
8714
6935
  return unwrapItems(res, "resource_time_off_additional.getByResource").items;
8715
6936
  }
8716
6937
 
@@ -10862,11 +9083,11 @@ async function slaHealthCheck(_resource, id, opts = {}, http) {
10862
9083
  );
10863
9084
  const slas = rows.map((row) => {
10864
9085
  const bool = (v) => typeof v === "boolean" ? v : null;
10865
- const num = (v) => typeof v === "number" ? v : null;
9086
+ const num2 = (v) => typeof v === "number" ? v : null;
10866
9087
  const milestones = [
10867
- { name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours: num(row.firstResponseElapsedHours) },
10868
- { name: "resolution", met: bool(row.isResolutionMet), elapsedHours: num(row.resolutionElapsedHours) },
10869
- { name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours: num(row.resolutionPlanElapsedHours) }
9088
+ { name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours: num2(row.firstResponseElapsedHours) },
9089
+ { name: "resolution", met: bool(row.isResolutionMet), elapsedHours: num2(row.resolutionElapsedHours) },
9090
+ { name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours: num2(row.resolutionPlanElapsedHours) }
10870
9091
  ];
10871
9092
  return { id: Number(row.id), name: row.serviceLevelAgreementName ?? null, milestones };
10872
9093
  });
@@ -11248,55 +9469,21 @@ async function openWithNoteAndAssign(resource, params, note, assign, opts, http)
11248
9469
  ...assign !== void 0 ? { blind: { resources: true, note: "the resources table is blind to this API user (count/query return 0) \u2014 the assignee was verified via resources.get (the authority)" } } : {}
11249
9470
  };
11250
9471
  }
11251
- var STATUS_PICKLIST = [
11252
- { id: 1, label: "New" },
11253
- { id: 5, label: "Complete" },
11254
- { id: 7, label: "Waiting Customer" },
11255
- { id: 8, label: "In Progress" },
11256
- { id: 11, label: "Escalated" },
11257
- { id: 12, label: "Waiting 3rd Party" },
11258
- { id: 13, label: "Waiting Approval" },
11259
- { id: 14, label: "Ticket Updated" },
11260
- { id: 15, label: "N-central Update" },
11261
- { id: 17, label: "Follow up time" },
11262
- { id: 18, label: "Allocated" },
11263
- { id: 20, label: "Idle" },
11264
- { id: 21, label: "Sales & Ordering" },
11265
- { id: 24, label: "RTN" },
11266
- { id: 25, label: "Waiting Internal" },
11267
- { id: 27, label: "Ready to Allocate" },
11268
- { id: 28, label: "Customer to Test" },
11269
- { id: 29, label: "Call Scheduled" },
11270
- { id: 30, label: "Call Requested" },
11271
- { id: 31, label: "Waiting Order" },
11272
- { id: 33, label: "Waiting to Invoice" },
11273
- { id: 34, label: "Stakeholder Update" },
11274
- { id: 35, label: "Stage 1 - OPP Identified" },
11275
- { id: 36, label: "Stage 2 - Qualification" },
11276
- { id: 37, label: "Stage 3 - Presales Scope" },
11277
- { id: 38, label: "Stage 4 - Quote Creation" },
11278
- { id: 39, label: "Stage 5 - Quote Prepared" },
11279
- { id: 40, label: "Stage 6 - Quote Presented" },
11280
- { id: 41, label: "Stage 7 - Quote Followed" },
11281
- { id: 42, label: "Stage 8 - Negotiation" },
11282
- { id: 43, label: "Stage 9 - Verbal Approval" }
11283
- ];
11284
- var STATUS_PICKLIST_SET = new Set(STATUS_PICKLIST.map((v) => v.id));
11285
9472
  var STATUS_REQUIRED_FIELDS = {
11286
9473
  // <discovery output lands here — e.g. 8: { requiredFields: ['dueDateTime'], safeDefaults: { dueDateTime: '<now>' } }>
11287
9474
  };
11288
9475
  var StatusUnknownError = class extends AutotaskError {
11289
- /** The pinned picklist size (the message names it; the labels are via autotask_describe(tickets.describeFields)). */
9476
+ /** The live picklist size (the message names it; the values + labels are via operations.fieldCatalog with labels: true). */
11290
9477
  picklistCount;
11291
9478
  constructor(message, options = {}) {
11292
9479
  super(message, {
11293
9480
  code: "STATUS_UNKNOWN",
11294
9481
  category: "validation",
11295
- suggestedAction: "Pass one of the pinned picklist values (autotask_describe(tickets.describeFields) lists the full labels).",
9482
+ suggestedAction: `Pass one of the tenant's live status values (discover the current values + labels with operations.fieldCatalog({ resources: ["tickets"], fields: ["status"], labels: true })).`,
11296
9483
  ...options
11297
9484
  });
11298
9485
  this.name = "StatusUnknownError";
11299
- this.picklistCount = STATUS_PICKLIST.length;
9486
+ this.picklistCount = options.picklistCount ?? 0;
11300
9487
  }
11301
9488
  };
11302
9489
  var StatusFieldsRequiredError = class extends AutotaskError {
@@ -11313,17 +9500,56 @@ var StatusFieldsRequiredError = class extends AutotaskError {
11313
9500
  this.currentValues = details.currentValues;
11314
9501
  }
11315
9502
  };
11316
- async function changeStatus(_resource, id, targetStatus, fields, opts, http) {
9503
+ async function readLiveStatusSet(resource) {
9504
+ const raw = await resource.getFieldInfo();
9505
+ const row = raw.find((f) => f.name === "status");
9506
+ const values = row !== void 0 && row.isPickList === true ? row.picklistValues : void 0;
9507
+ const ids = /* @__PURE__ */ new Set();
9508
+ if (Array.isArray(values)) {
9509
+ for (const pv of values) {
9510
+ const n = Number(pv.value);
9511
+ if (Number.isFinite(n)) ids.add(n);
9512
+ }
9513
+ }
9514
+ return ids;
9515
+ }
9516
+ var GUARD_DEGRADED_WARNING = "the tenant's live status picklist metadata was unavailable (the local guard is degraded for this call \u2014 the W-A4 wall or a metadata failure); the vendor's own validation is the final authority on the status value";
9517
+ async function changeStatus(resource, id, targetStatus, fields, opts, http) {
11317
9518
  const ticketId = parseIdentifier(id, "id");
11318
9519
  if (!isPositiveInt(targetStatus)) {
11319
9520
  throw new AutotaskConfigError(`changeStatus: targetStatus must be a positive integer (got ${String(targetStatus)})`, { operation: OP_STATUS });
11320
9521
  }
11321
- if (!STATUS_PICKLIST_SET.has(targetStatus)) {
11322
- throw new StatusUnknownError(
11323
- `changeStatus: targetStatus ${targetStatus} is not in the ww29 status picklist (pinned: ${STATUS_PICKLIST.length} values, max id 43 \u2014 tenant-specific; the live describeFields probe is the oracle)`,
11324
- { operation: OP_STATUS }
11325
- );
9522
+ let liveStatus = null;
9523
+ try {
9524
+ const ids = await readLiveStatusSet(resource);
9525
+ if (ids.size > 0) liveStatus = ids;
9526
+ } catch (err) {
9527
+ if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
9528
+ }
9529
+ if (liveStatus !== null && !liveStatus.has(targetStatus)) {
9530
+ http.fieldCache?.invalidate("tickets");
9531
+ let freshStatus = null;
9532
+ let reReadFailed = false;
9533
+ try {
9534
+ const freshIds = await readLiveStatusSet(resource);
9535
+ if (freshIds.size > 0) freshStatus = freshIds;
9536
+ } catch (err) {
9537
+ if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
9538
+ freshStatus = null;
9539
+ reReadFailed = true;
9540
+ }
9541
+ if (freshStatus !== null && freshStatus.has(targetStatus)) {
9542
+ liveStatus = freshStatus;
9543
+ } else {
9544
+ const verdict = freshStatus ?? liveStatus;
9545
+ const staleNote = freshStatus === null ? `; the live re-read ${reReadFailed ? "failed" : "returned no values"}, so this picklist count is from the initial read (up to one field-cache TTL old)` : "";
9546
+ throw new StatusUnknownError(
9547
+ `changeStatus: targetStatus ${targetStatus} is not in the tenant's live status picklist (live: ${verdict.size} values \u2014 the entity metadata is the oracle; the values + labels are discoverable with operations.fieldCatalog({ resources: ["tickets"], fields: ["status"], labels: true })${staleNote})`,
9548
+ { operation: OP_STATUS, picklistCount: verdict.size }
9549
+ );
9550
+ }
11326
9551
  }
9552
+ const guardDegraded = liveStatus === null;
11327
9553
  if (fields !== void 0 && (typeof fields !== "object" || fields === null || Array.isArray(fields))) {
11328
9554
  throw new AutotaskConfigError("changeStatus: fields must be a plain object of ticket fields (or omitted)", { operation: OP_STATUS });
11329
9555
  }
@@ -11346,7 +9572,10 @@ async function changeStatus(_resource, id, targetStatus, fields, opts, http) {
11346
9572
  { name: "no-op", ok: true }
11347
9573
  ],
11348
9574
  impact: { affected: 0, scope: "single-record", reversible: true },
11349
- warnings: [`NO_CHANGE: the ticket is already in the target status ${targetStatus} \u2014 the apply would be a zero-wire no-op.`],
9575
+ warnings: [
9576
+ ...guardDegraded ? [GUARD_DEGRADED_WARNING] : [],
9577
+ `NO_CHANGE: the ticket is already in the target status ${targetStatus} \u2014 the apply would be a zero-wire no-op.`
9578
+ ],
11350
9579
  data: { reason: "NO_CHANGE", status: targetStatus }
11351
9580
  },
11352
9581
  { redactPersonalData: http.redactPersonalData }
@@ -11378,6 +9607,7 @@ async function changeStatus(_resource, id, targetStatus, fields, opts, http) {
11378
9607
  }
11379
9608
  if (opts.dryRun === true) {
11380
9609
  const warnings = [];
9610
+ if (guardDegraded) warnings.push(GUARD_DEGRADED_WARNING);
11381
9611
  if (filled.length > 0) {
11382
9612
  warnings.push(`the node will fill the map-required field(s) ${filled.join(", ")} with the proven-safe defaults (the live-discovered map); the caller value always wins.`);
11383
9613
  }
@@ -11534,6 +9764,38 @@ async function fullContext(resource, id, opts = {}, http) {
11534
9764
  result.parts.histories = historiesRead.status;
11535
9765
  result.truncated = result.truncated || part.capped || historyRows.some((h) => h.detailTruncated === true);
11536
9766
  }
9767
+ if (opts.names === true) {
9768
+ const builder = createNamePlan(TICKET_LABEL_FIELDS);
9769
+ const t = ctx.ticket;
9770
+ builder.addResource(t["creatorResourceID"]);
9771
+ builder.addFk("contract", t["contractID"]);
9772
+ builder.addFk("billingCode", t["billingCodeID"]);
9773
+ builder.addFk("role", t["assignedResourceRoleID"]);
9774
+ for (const e of entriesPart.items) {
9775
+ builder.addResource(e["resourceID"]);
9776
+ builder.addFk("role", e["roleID"]);
9777
+ builder.addFk("billingCode", e["billingCodeID"]);
9778
+ builder.addFk("contract", e["contractID"]);
9779
+ }
9780
+ const names = await resolveCompositeNames(http, builder.plan(), { maxNameReads: opts.maxNameReads });
9781
+ const ticketRow = { ...ctx.ticket };
9782
+ addNameSibling(ticketRow, "creatorResourceID", (v) => names.resourceName(v));
9783
+ addNameSibling(ticketRow, "contractID", (v) => names.recordName("contract", v));
9784
+ addNameSibling(ticketRow, "billingCodeID", (v) => names.recordName("billingCode", v));
9785
+ addNameSibling(ticketRow, "assignedResourceRoleID", (v) => names.recordName("role", v));
9786
+ for (const f of TICKET_LABEL_FIELDS) addLabelSibling(ticketRow, f, names);
9787
+ result.ticket = ticketRow;
9788
+ result.timeEntries.items = entriesPart.items.map((e) => {
9789
+ const row = { ...e };
9790
+ addNameSibling(row, "resourceID", (v) => names.resourceName(v));
9791
+ addNameSibling(row, "roleID", (v) => names.recordName("role", v));
9792
+ addNameSibling(row, "billingCodeID", (v) => names.recordName("billingCode", v));
9793
+ addNameSibling(row, "contractID", (v) => names.recordName("contract", v));
9794
+ return row;
9795
+ });
9796
+ result.names = names.section;
9797
+ result.truncated = result.truncated || names.section.truncated;
9798
+ }
11537
9799
  return result;
11538
9800
  }
11539
9801
 
@@ -11720,82 +9982,8 @@ var TicketWebhooksResource = class extends BaseResource {
11720
9982
  };
11721
9983
 
11722
9984
  // src/resources/special/time_entries.ts
11723
- var DAY_MS = 864e5;
11724
- var DEFAULT_LIMIT2 = 25;
11725
- var MAX_LIMIT2 = 500;
11726
- function clampLimit2(limit) {
11727
- const n = limit === void 0 ? DEFAULT_LIMIT2 : Math.trunc(limit);
11728
- if (!Number.isInteger(n) || n < 1) {
11729
- throw new AutotaskConfigError(`limit must be an integer >= 1 (got ${String(limit)})`);
11730
- }
11731
- return Math.min(n, MAX_LIMIT2);
11732
- }
11733
- var utcDayStart = (ms) => {
11734
- const d = new Date(ms);
11735
- return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
11736
- };
11737
- var utcDayEnd = (ms) => utcDayStart(ms) + DAY_MS - 1;
11738
- var iso = (ms) => new Date(ms).toISOString().slice(0, 19) + "Z";
11739
- function timeEntriesWindowBounds(kind, now) {
11740
- const t = now.getTime();
11741
- let from;
11742
- let to;
11743
- switch (kind) {
11744
- case "today":
11745
- from = utcDayStart(t);
11746
- to = utcDayEnd(t);
11747
- break;
11748
- case "yesterday":
11749
- from = utcDayStart(t - DAY_MS);
11750
- to = utcDayEnd(t - DAY_MS);
11751
- break;
11752
- case "last24h":
11753
- from = t - DAY_MS;
11754
- to = t;
11755
- break;
11756
- case "last7":
11757
- case "last14":
11758
- case "last30":
11759
- case "last90": {
11760
- const days = Number(kind.slice(4));
11761
- from = utcDayStart(t - days * DAY_MS);
11762
- to = utcDayEnd(t);
11763
- break;
11764
- }
11765
- case "lastFullWeek":
11766
- case "lastFullFortnight": {
11767
- const d = new Date(t);
11768
- const daysSinceMonday = (d.getUTCDay() + 6) % 7;
11769
- const thisMonday = utcDayStart(t) - daysSinceMonday * DAY_MS;
11770
- const daysBack = kind === "lastFullWeek" ? 7 : 14;
11771
- from = thisMonday - daysBack * DAY_MS;
11772
- to = thisMonday - 1;
11773
- break;
11774
- }
11775
- case "lastFullMonth": {
11776
- const d = new Date(t);
11777
- const y = d.getUTCFullYear();
11778
- const m = d.getUTCMonth() - 1;
11779
- from = Date.UTC(y, m, 1);
11780
- to = Date.UTC(y, m + 1, 1) - 1;
11781
- break;
11782
- }
11783
- case "lastFullQuarter": {
11784
- const d = new Date(t);
11785
- let q = Math.floor(d.getUTCMonth() / 3) - 1;
11786
- let y = d.getUTCFullYear();
11787
- if (q < 0) {
11788
- q = 3;
11789
- y -= 1;
11790
- }
11791
- const first = q * 3;
11792
- from = Date.UTC(y, first, 1);
11793
- to = Date.UTC(y, first + 3, 1) - 1;
11794
- break;
11795
- }
11796
- }
11797
- return { from: iso(from), to: iso(to) };
11798
- }
9985
+ var DAY_MS2 = 864e5;
9986
+ var iso2 = (ms) => new Date(ms).toISOString().slice(0, 19) + "Z";
11799
9987
  async function queryTimeEntries(_resource, filter, limit, operation, http) {
11800
9988
  const res = await http.request({
11801
9989
  method: "POST",
@@ -11804,30 +9992,34 @@ async function queryTimeEntries(_resource, filter, limit, operation, http) {
11804
9992
  body: { MaxRecords: limit, filter },
11805
9993
  operation
11806
9994
  });
11807
- return unwrapItems(res).items;
9995
+ const { items, pageDetails } = unwrapItems(res);
9996
+ return { items, hasMore: pageDetails.nextPageUrl != null };
11808
9997
  }
11809
- async function windowQuery(_resource, kind, http, opts) {
9998
+ async function windowQuery3(_resource, kind, http, opts) {
11810
9999
  const now = opts?.now ?? /* @__PURE__ */ new Date();
11811
- const { from, to } = timeEntriesWindowBounds(kind, now);
10000
+ const tz = opts?.tz === void 0 ? void 0 : validateZone(opts.tz, `time_entries.${kind}`);
10001
+ const { from, to } = dateWindowBounds(kind, now, tz);
11812
10002
  const filter = [
11813
10003
  { field: "dateWorked", op: "gte", value: from },
11814
10004
  { field: "dateWorked", op: "lte", value: to }
11815
10005
  ];
11816
- return queryTimeEntries(_resource, filter, clampLimit2(opts?.limit), `time_entries.${kind}`, http);
10006
+ const { items } = await queryTimeEntries(_resource, filter, clampWindowLimit(opts?.limit), `time_entries.${kind}`, http);
10007
+ return items;
11817
10008
  }
11818
- async function customRange(_resource, from, to, http, opts) {
11819
- const parseDay = (value, label) => {
11820
- const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
11821
- if (!m) throw new AutotaskConfigError(`customRange: ${label} must be a UTC 'YYYY-MM-DD' date`);
11822
- return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
11823
- };
11824
- const fromMs = parseDay(from, "from");
11825
- const toMs = parseDay(to, "to");
10009
+ async function customRange3(_resource, from, to, http, opts) {
10010
+ const zone = opts?.tz === void 0 ? void 0 : validateZone(opts.tz, "time_entries.customRange");
10011
+ const fromB = customRangeDay(from, "from", zone, "customRange");
10012
+ const toB = customRangeDay(to, "to", zone, "customRange");
10013
+ if (fromB.ms > toB.ms) {
10014
+ throw new AutotaskConfigError("customRange: from must not be after to");
10015
+ }
10016
+ const toEndMs = zone === void 0 ? toB.ms + DAY_MS2 - 1 : localDayEndMs(toB.y, toB.mo, toB.d, zone);
11826
10017
  const filter = [
11827
- { field: "dateWorked", op: "gte", value: iso(fromMs) },
11828
- { field: "dateWorked", op: "lte", value: iso(toMs + DAY_MS - 1) }
10018
+ { field: "dateWorked", op: "gte", value: iso2(fromB.ms) },
10019
+ { field: "dateWorked", op: "lte", value: iso2(toEndMs) }
11829
10020
  ];
11830
- return queryTimeEntries(_resource, filter, clampLimit2(opts?.limit), "time_entries.customRange", http);
10021
+ const { items } = await queryTimeEntries(_resource, filter, clampWindowLimit(opts?.limit), "time_entries.customRange", http);
10022
+ return items;
11831
10023
  }
11832
10024
  async function postedDateStatusQuery(_resource, mode, http, opts) {
11833
10025
  const filter = [
@@ -11835,39 +10027,27 @@ async function postedDateStatusQuery(_resource, mode, http, opts) {
11835
10027
  ];
11836
10028
  if (opts?.ticketID !== void 0) filter.push({ field: "ticketID", op: "eq", value: opts.ticketID });
11837
10029
  const operation = mode === "posted" ? "time_entries.getPosted" : "time_entries.getUnposted";
11838
- return queryTimeEntries(_resource, filter, clampLimit2(opts?.limit), operation, http);
10030
+ const { items } = await queryTimeEntries(_resource, filter, clampWindowLimit(opts?.limit), operation, http);
10031
+ return items;
11839
10032
  }
11840
- async function postedWithin(_resource, from, to, http, opts) {
11841
- const OP2 = "time_entries.postedWithin";
11842
- const parseBound = (value, label) => {
11843
- const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
11844
- if (!m) {
11845
- throw new AutotaskConfigError(
11846
- `postedWithin: ${label} must be a UTC 'YYYY-MM-DD' date string (got ${JSON.stringify(value)})`,
11847
- { operation: OP2 }
11848
- );
11849
- }
11850
- const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
11851
- if (!Number.isFinite(ms)) {
11852
- throw new AutotaskConfigError(`postedWithin: ${label} is not a valid UTC date (${JSON.stringify(value)})`, { operation: OP2 });
11853
- }
11854
- return ms;
11855
- };
11856
- const fromMs = parseBound(from, "from");
11857
- const toMs = parseBound(to, "to");
11858
- if (fromMs > toMs) {
11859
- throw new AutotaskConfigError(`postedWithin: from (${iso(fromMs)}) must not be after to (${iso(toMs)})`, { operation: OP2 });
11860
- }
11861
- const windowToMs = toMs + DAY_MS - 1;
10033
+ async function postedWithin(_resource, from, to, http, opts, opLabel = "time_entries.postedWithin") {
10034
+ const OP2 = opLabel;
10035
+ const zone = opts?.tz === void 0 ? void 0 : validateZone(opts.tz, OP2);
10036
+ const fromB = customRangeDay(from, "from", zone, OP2);
10037
+ const toB = customRangeDay(to, "to", zone, OP2);
10038
+ if (fromB.ms > toB.ms) {
10039
+ throw new AutotaskConfigError(`${OP2}: from (${iso2(fromB.ms)}) must not be after to (${iso2(toB.ms)})`, { operation: OP2 });
10040
+ }
10041
+ const windowToMs = zone === void 0 ? toB.ms + DAY_MS2 - 1 : localDayEndMs(toB.y, toB.mo, toB.d, zone);
11862
10042
  const filter = [
11863
- { field: "dateWorked", op: "gte", value: iso(fromMs) },
11864
- { field: "dateWorked", op: "lte", value: iso(windowToMs) }
10043
+ { field: "dateWorked", op: "gte", value: iso2(fromB.ms) },
10044
+ { field: "dateWorked", op: "lte", value: iso2(windowToMs) }
11865
10045
  ];
11866
10046
  if (opts?.posted === true) filter.push({ field: "postedDate", op: "exist" });
11867
10047
  else if (opts?.posted === false) filter.push({ field: "postedDate", op: "notExist" });
11868
10048
  if (opts?.ticketID !== void 0) filter.push({ field: "ticketID", op: "eq", value: opts.ticketID });
11869
10049
  if (opts?.resourceID !== void 0) filter.push({ field: "resourceID", op: "eq", value: opts.resourceID });
11870
- const items = await queryTimeEntries(_resource, filter, clampLimit2(opts?.limit), OP2, http);
10050
+ const { items, hasMore } = await queryTimeEntries(_resource, filter, clampWindowLimit(opts?.limit), OP2, http);
11871
10051
  let postedCount = 0;
11872
10052
  for (const item of items) {
11873
10053
  const postedDate = item.postedDate;
@@ -11879,9 +10059,15 @@ async function postedWithin(_resource, from, to, http, opts) {
11879
10059
  items,
11880
10060
  postedCount,
11881
10061
  unpostedCount: items.length - postedCount,
11882
- window: { from: iso(fromMs), to: iso(windowToMs) }
10062
+ window: { from: iso2(fromB.ms), to: iso2(windowToMs) },
10063
+ // A live next-page cursor past the bounded read: the counts above cover
10064
+ // the returned rows ONLY when this is true (issue #66 AC1 — the 500-row cap case is the general case).
10065
+ truncated: hasMore
11883
10066
  };
11884
10067
  }
10068
+ async function dateWorkedWindow(resource, from, to, http, opts) {
10069
+ return postedWithin(resource, from, to, http, opts, "time_entries.dateWorkedWindow");
10070
+ }
11885
10071
  var USE_TYPE_GENERAL = 1;
11886
10072
  var USE_TYPE_INTERNAL = 3;
11887
10073
  var BODY_INTERNAL_CODE_KEY = "internalBillingCodeID";
@@ -12162,13 +10348,13 @@ async function createForResource(resource, resourceID, params, opts = {}, http)
12162
10348
  const callerValue = params[field];
12163
10349
  window[field] = callerValue !== void 0 ? callerValue : defaults[field];
12164
10350
  }
12165
- const passthrough2 = {};
10351
+ const passthrough = {};
12166
10352
  for (const [key, value] of Object.entries(params)) {
12167
10353
  if (value === void 0 || PARAM_KEYS.has(key)) continue;
12168
10354
  if (READ_ONLY_TIME_ENTRY_FIELDS.has(key) || LINKAGE_OWNED_FIELDS.has(key)) continue;
12169
- passthrough2[key] = value;
10355
+ passthrough[key] = value;
12170
10356
  }
12171
- const body = { resourceID: rid, ...window, ...passthrough2 };
10357
+ const body = { resourceID: rid, ...window, ...passthrough };
12172
10358
  if (usedLinkage === "internal") {
12173
10359
  body[BODY_INTERNAL_CODE_KEY] = resolvedCode.id;
12174
10360
  } else {
@@ -12245,7 +10431,7 @@ async function createForResource(resource, resourceID, params, opts = {}, http)
12245
10431
  }
12246
10432
  }
12247
10433
  if (isAutotaskError(lastError)) {
12248
- lastError.suggestedAction = (lastError.suggestedAction ? `${lastError.suggestedAction} ` : "") + `The createForResource attempt budget (${MAX_CREATE_ATTEMPTS} attempts: the role ladder + ${MAX_DISCOVERY_MERGES2} discovery merges) was exhausted \u2014 the vendor oracle rejected every candidate. Re-read the record/fields (time_entries.getFieldInfo, billing_codes.query with a filter) and retry with an explicit pin.`;
10434
+ lastError.suggestedAction = (lastError.suggestedAction ? `${lastError.suggestedAction} ` : "") + `The createForResource attempt budget (${MAX_CREATE_ATTEMPTS} attempts: the role ladder + ${MAX_DISCOVERY_MERGES2} discovery merges) was exhausted \u2014 the vendor oracle rejected every candidate. Re-read the record/fields (operations.fieldCatalog, billing_codes.query with a filter) and retry with an explicit pin.`;
12249
10435
  }
12250
10436
  throw lastError;
12251
10437
  }
@@ -12336,7 +10522,11 @@ var TimeEntriesResource = class extends BaseResource {
12336
10522
  }
12337
10523
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12338
10524
  customRange(from, to, opts) {
12339
- return customRange(this, from, to, this.http, opts);
10525
+ return customRange3(this, from, to, this.http, opts);
10526
+ }
10527
+ /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
10528
+ dateWorkedWindow(from, to, opts) {
10529
+ return dateWorkedWindow(this, from, to, this.http, opts);
12340
10530
  }
12341
10531
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12342
10532
  getPosted(opts) {
@@ -12348,39 +10538,39 @@ var TimeEntriesResource = class extends BaseResource {
12348
10538
  }
12349
10539
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12350
10540
  last14(opts) {
12351
- return windowQuery(this, "last14", this.http, opts);
10541
+ return windowQuery3(this, "last14", this.http, opts);
12352
10542
  }
12353
10543
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12354
10544
  last24h(opts) {
12355
- return windowQuery(this, "last24h", this.http, opts);
10545
+ return windowQuery3(this, "last24h", this.http, opts);
12356
10546
  }
12357
10547
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12358
10548
  last30(opts) {
12359
- return windowQuery(this, "last30", this.http, opts);
10549
+ return windowQuery3(this, "last30", this.http, opts);
12360
10550
  }
12361
10551
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12362
10552
  last7(opts) {
12363
- return windowQuery(this, "last7", this.http, opts);
10553
+ return windowQuery3(this, "last7", this.http, opts);
12364
10554
  }
12365
10555
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12366
10556
  last90(opts) {
12367
- return windowQuery(this, "last90", this.http, opts);
10557
+ return windowQuery3(this, "last90", this.http, opts);
12368
10558
  }
12369
10559
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12370
10560
  lastFullFortnight(opts) {
12371
- return windowQuery(this, "lastFullFortnight", this.http, opts);
10561
+ return windowQuery3(this, "lastFullFortnight", this.http, opts);
12372
10562
  }
12373
10563
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12374
10564
  lastFullMonth(opts) {
12375
- return windowQuery(this, "lastFullMonth", this.http, opts);
10565
+ return windowQuery3(this, "lastFullMonth", this.http, opts);
12376
10566
  }
12377
10567
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12378
10568
  lastFullQuarter(opts) {
12379
- return windowQuery(this, "lastFullQuarter", this.http, opts);
10569
+ return windowQuery3(this, "lastFullQuarter", this.http, opts);
12380
10570
  }
12381
10571
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12382
10572
  lastFullWeek(opts) {
12383
- return windowQuery(this, "lastFullWeek", this.http, opts);
10573
+ return windowQuery3(this, "lastFullWeek", this.http, opts);
12384
10574
  }
12385
10575
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12386
10576
  postedWithin(from, to, opts) {
@@ -12388,11 +10578,11 @@ var TimeEntriesResource = class extends BaseResource {
12388
10578
  }
12389
10579
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12390
10580
  today(opts) {
12391
- return windowQuery(this, "today", this.http, opts);
10581
+ return windowQuery3(this, "today", this.http, opts);
12392
10582
  }
12393
10583
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
12394
10584
  yesterday(opts) {
12395
- return windowQuery(this, "yesterday", this.http, opts);
10585
+ return windowQuery3(this, "yesterday", this.http, opts);
12396
10586
  }
12397
10587
  };
12398
10588
 
@@ -12614,31 +10804,6 @@ var ZoneInformationResource = class {
12614
10804
  };
12615
10805
 
12616
10806
  export {
12617
- AUTH_HEADERS,
12618
- buildAuthHeaders,
12619
- withAuthHeaders,
12620
- FILTER_OPERATORS,
12621
- GROUPING_OPERATORS,
12622
- MAX_OR_CONDITIONS,
12623
- MAX_UDF_PER_QUERY,
12624
- MAX_QUERY_LENGTH,
12625
- MAX_GET_LENGTH,
12626
- MAX_FIELD_LENGTH,
12627
- isFilterOperator,
12628
- serializeFilter,
12629
- validateFilter,
12630
- buildFilter,
12631
- IDEMPOTENT_METHODS,
12632
- buildUrl,
12633
- cursorToPath,
12634
- buildQueryRequest,
12635
- runWithSignal,
12636
- HttpClient,
12637
- unwrapItems,
12638
- unwrapItem,
12639
- unwrapQueryCount,
12640
- unwrapOperation,
12641
- passthrough,
12642
10807
  AppointmentsResource,
12643
10808
  AuthenticateResource,
12644
10809
  BillingCodesResource,
@@ -12768,4 +10933,3 @@ export {
12768
10933
  VersionResource,
12769
10934
  ZoneInformationResource
12770
10935
  };
12771
- //# sourceMappingURL=chunk-4WJCXIY2.js.map