node-autotask 0.8.3 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +13 -10
- package/MCP_TOOL_CATALOG.json +733 -251
- package/MCP_TOOL_MANIFEST.md +162 -133
- package/MCP_TOOL_OVERRIDES.json +2 -2
- package/N8N_MCP_MANIFEST.json +88 -25
- package/N8N_MCP_MANIFEST.md +26 -24
- package/README.md +12 -4
- package/capabilities.json +12503 -9316
- package/dist/capabilities.cjs +2 -3
- package/dist/capabilities.js +1 -2
- package/dist/{chunk-4WJCXIY2.js → chunk-3VB6N44U.js} +1141 -2977
- package/dist/{chunk-32AKZMY4.js → chunk-6KFP5HV3.js} +12502 -9316
- package/dist/chunk-DMMMWO6A.js +0 -0
- package/dist/chunk-FLB2X6O5.cjs +1 -0
- package/dist/{chunk-GYI4ZQID.js → chunk-HWC2WPX2.js} +4781 -89
- package/dist/chunk-IDPB4N2Q.cjs +1 -0
- package/dist/{chunk-F6YSQRZV.js → chunk-NVJ2PTN3.js} +7 -7
- package/dist/{chunk-5UGSQLLO.cjs → chunk-QUM74WLQ.cjs} +136 -136
- package/dist/{chunk-LAKEIYET.cjs → chunk-S7VMY5KN.cjs} +1468 -3304
- package/dist/chunk-UPQIUWOS.js +0 -0
- package/dist/{chunk-KNTUX3RR.cjs → chunk-WTVD2JZN.cjs} +12502 -9316
- package/dist/{chunk-V3XPLTIR.cjs → chunk-WTVLHMZA.cjs} +4784 -92
- package/dist/{index-B6JeThrY.d.ts → index-BOyh7S65.d.cts} +870 -79
- package/dist/{index-B0I2bGtx.d.cts → index-DGgMD8aq.d.ts} +870 -79
- package/dist/{index-DOCRp_83.d.cts → index-cQ6CRexR.d.cts} +158 -90
- package/dist/{index-DOCRp_83.d.ts → index-cQ6CRexR.d.ts} +158 -90
- package/dist/index.cjs +37 -22
- package/dist/index.d.cts +46 -5
- package/dist/index.d.ts +46 -5
- package/dist/index.js +48 -33
- package/dist/operations/index.cjs +480 -359
- package/dist/operations/index.d.cts +195 -16
- package/dist/operations/index.d.ts +195 -16
- package/dist/operations/index.js +370 -249
- package/dist/pool.cjs +36 -12
- package/dist/pool.d.cts +2 -3
- package/dist/pool.d.ts +2 -3
- package/dist/pool.js +33 -9
- package/dist/resources/index.cjs +5 -6
- package/dist/resources/index.d.cts +2 -2
- package/dist/resources/index.d.ts +2 -2
- package/dist/resources/index.js +4 -5
- package/dist/types/index.cjs +1 -2
- package/dist/types/index.d.cts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.js +1 -2
- package/dist/untrusted.cjs +0 -1
- package/dist/untrusted.js +0 -1
- package/docs/API.md +693 -48
- package/docs/N8N-ADAPTER.md +4 -4
- package/examples/basic.ts +1 -1
- package/package.json +5 -3
- package/dist/capabilities.cjs.map +0 -1
- package/dist/capabilities.js.map +0 -1
- package/dist/chunk-32AKZMY4.js.map +0 -1
- package/dist/chunk-4WJCXIY2.js.map +0 -1
- package/dist/chunk-5UGSQLLO.cjs.map +0 -1
- package/dist/chunk-CXM35TEC.cjs +0 -1
- package/dist/chunk-CXM35TEC.cjs.map +0 -1
- package/dist/chunk-F6YSQRZV.js.map +0 -1
- package/dist/chunk-GIVD2CQ3.js +0 -1
- package/dist/chunk-GIVD2CQ3.js.map +0 -1
- package/dist/chunk-GYI4ZQID.js.map +0 -1
- package/dist/chunk-KNTUX3RR.cjs.map +0 -1
- package/dist/chunk-LAKEIYET.cjs.map +0 -1
- package/dist/chunk-MHBMSBTA.js +0 -1
- package/dist/chunk-MHBMSBTA.js.map +0 -1
- package/dist/chunk-NZUFSSIZ.cjs +0 -1
- package/dist/chunk-NZUFSSIZ.cjs.map +0 -1
- package/dist/chunk-V3XPLTIR.cjs.map +0 -1
- package/dist/client-DHmr0Oyx.d.ts +0 -203
- package/dist/client-Q6NoqinP.d.cts +0 -203
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/operations/index.cjs.map +0 -1
- package/dist/operations/index.js.map +0 -1
- package/dist/pool.cjs.map +0 -1
- package/dist/pool.js.map +0 -1
- package/dist/resources/index.cjs.map +0 -1
- package/dist/resources/index.js.map +0 -1
- package/dist/types/index.cjs.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/untrusted.cjs.map +0 -1
- package/dist/untrusted.js.map +0 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
formatVendorDetail,
|
|
39
|
-
isAutotaskError,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
3188
|
-
var
|
|
826
|
+
var DEFAULT_LIMIT2 = 25;
|
|
827
|
+
var MAX_LIMIT2 = 500;
|
|
3189
828
|
function clampLimit(limit) {
|
|
3190
|
-
const n = limit === void 0 ?
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8666
|
-
|
|
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
|
|
9086
|
+
const num2 = (v) => typeof v === "number" ? v : null;
|
|
10866
9087
|
const milestones = [
|
|
10867
|
-
{ name: "firstResponse", met: bool(row.isFirstResponseMet), elapsedHours:
|
|
10868
|
-
{ name: "resolution", met: bool(row.isResolutionMet), elapsedHours:
|
|
10869
|
-
{ name: "resolutionPlan", met: bool(row.isResolutionPlanMet), elapsedHours:
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
11322
|
-
|
|
11323
|
-
|
|
11324
|
-
|
|
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: [
|
|
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
|
|
11724
|
-
var
|
|
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
|
-
|
|
9995
|
+
const { items, pageDetails } = unwrapItems(res);
|
|
9996
|
+
return { items, hasMore: pageDetails.nextPageUrl != null };
|
|
11808
9997
|
}
|
|
11809
|
-
async function
|
|
9998
|
+
async function windowQuery3(_resource, kind, http, opts) {
|
|
11810
9999
|
const now = opts?.now ?? /* @__PURE__ */ new Date();
|
|
11811
|
-
const
|
|
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
|
-
|
|
10006
|
+
const { items } = await queryTimeEntries(_resource, filter, clampWindowLimit(opts?.limit), `time_entries.${kind}`, http);
|
|
10007
|
+
return items;
|
|
11817
10008
|
}
|
|
11818
|
-
async function
|
|
11819
|
-
const
|
|
11820
|
-
|
|
11821
|
-
|
|
11822
|
-
|
|
11823
|
-
|
|
11824
|
-
|
|
11825
|
-
const
|
|
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:
|
|
11828
|
-
{ field: "dateWorked", op: "lte", value:
|
|
10018
|
+
{ field: "dateWorked", op: "gte", value: iso2(fromB.ms) },
|
|
10019
|
+
{ field: "dateWorked", op: "lte", value: iso2(toEndMs) }
|
|
11829
10020
|
];
|
|
11830
|
-
|
|
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
|
-
|
|
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 =
|
|
11842
|
-
const
|
|
11843
|
-
|
|
11844
|
-
|
|
11845
|
-
|
|
11846
|
-
|
|
11847
|
-
|
|
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:
|
|
11864
|
-
{ field: "dateWorked", op: "lte", value:
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
10355
|
+
passthrough[key] = value;
|
|
12170
10356
|
}
|
|
12171
|
-
const body = { resourceID: rid, ...window, ...
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|