node-autotask 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/MCP_TOOL_CATALOG.json +147 -149
  3. package/MCP_TOOL_MANIFEST.md +9 -9
  4. package/N8N_MCP_MANIFEST.json +4 -43
  5. package/N8N_MCP_MANIFEST.md +6 -7
  6. package/README.md +3 -4
  7. package/capabilities.json +3945 -3405
  8. package/dist/capabilities.cjs +2 -2
  9. package/dist/capabilities.js +1 -1
  10. package/dist/{chunk-VDVBXXJW.js → chunk-AETFO6BO.js} +1765 -1880
  11. package/dist/{chunk-EUGSF3U4.cjs → chunk-HD5BRA3Z.cjs} +285 -285
  12. package/dist/{chunk-E5W24BZB.cjs → chunk-MYL34QU5.cjs} +143 -143
  13. package/dist/{chunk-FEYVLIHD.cjs → chunk-SEFJJY4J.cjs} +2801 -2916
  14. package/dist/{chunk-O32L6GS5.js → chunk-TTPHVFPJ.js} +2 -2
  15. package/dist/{chunk-EVDVESQM.cjs → chunk-VEOSOIU4.cjs} +3944 -3404
  16. package/dist/{chunk-Y6YJYKBZ.js → chunk-WP33PQVM.js} +3944 -3404
  17. package/dist/{chunk-MTFRSD7P.js → chunk-ZR24JGW2.js} +2 -2
  18. package/dist/index-C3KFMnRT.d.cts +6860 -0
  19. package/dist/index-C3KFMnRT.d.ts +6860 -0
  20. package/dist/{index-BKp8_P2L.d.cts → index-C7rMJ4IV.d.ts} +139 -7236
  21. package/dist/{index-BKp8_P2L.d.ts → index-Cm_Xpt7X.d.cts} +139 -7236
  22. package/dist/index.cjs +22 -24
  23. package/dist/index.d.cts +58 -34
  24. package/dist/index.d.ts +58 -34
  25. package/dist/index.js +8 -10
  26. package/dist/operations/index.cjs +363 -242
  27. package/dist/operations/index.d.cts +281 -25
  28. package/dist/operations/index.d.ts +281 -25
  29. package/dist/operations/index.js +178 -57
  30. package/dist/pool.cjs +8 -8
  31. package/dist/pool.d.cts +2 -1
  32. package/dist/pool.d.ts +2 -1
  33. package/dist/pool.js +4 -4
  34. package/dist/resources/index.cjs +4 -4
  35. package/dist/resources/index.d.cts +2 -1
  36. package/dist/resources/index.d.ts +2 -1
  37. package/dist/resources/index.js +3 -3
  38. package/dist/types/index.d.cts +1 -1
  39. package/dist/types/index.d.ts +1 -1
  40. package/docs/API.md +101 -139
  41. package/docs/LIVE-TESTING.md +9 -5
  42. package/docs/N8N-ADAPTER.md +1 -1
  43. package/docs/RELEASING.md +121 -0
  44. package/package.json +3 -2
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  CAPABILITIES
3
- } from "./chunk-Y6YJYKBZ.js";
3
+ } from "./chunk-WP33PQVM.js";
4
4
  import {
5
5
  wrapUntrusted
6
6
  } from "./chunk-5VHSLJMR.js";
@@ -486,7 +486,7 @@ function suggestedActionForStatus(status) {
486
486
  case 429:
487
487
  return "Back off and retry; honour Retry-After.";
488
488
  default:
489
- return status >= 500 ? "Retry with backoff; the server error is transient." : void 0;
489
+ return status >= 500 ? "The SDK already retried per its policy. A repeated 500 on one entity while others work usually means the API user lacks permission on that entity \u2014 ask the Autotask admin to grant it; otherwise retry once more at most." : void 0;
490
490
  }
491
491
  }
492
492
  function errorFromStatus(status, body, url, operation, redactPersonalData = true, credential) {
@@ -586,6 +586,40 @@ var MoveRefusedError = class extends AutotaskError {
586
586
  }
587
587
  };
588
588
 
589
+ // src/errors/scopeRequired.ts
590
+ var UNSCOPED_WALK_THRESHOLD = 500;
591
+ var ScopeRequiredError = class extends AutotaskConfigError {
592
+ /** True when the call was refused locally: the walk was never issued. */
593
+ notSent = true;
594
+ /** The refusal is a caller-input problem (fix the filter; do not retry). */
595
+ callerInput = true;
596
+ /**
597
+ * @param operation The operation the refusal is about (e.g.
598
+ * `billing_items.query` — the op the caller's filter must scope).
599
+ * @param details `{ threshold }` + the pre-flight `count` when the
600
+ * guard spent one (the aggregate's pre-request refusal has no count).
601
+ * @param reason The per-surface refusal clause (default
602
+ * `'no-id-scope'` — the query guard; the aggregate surface passes
603
+ * `'empty-filter'`, its fold refusing on an EMPTY filter).
604
+ */
605
+ constructor(operation, details, reason = "no-id-scope") {
606
+ const countClause = details.count !== void 0 ? `${details.count} rows match ` : "The unscoped walk ";
607
+ const reasonClause = reason === "empty-filter" ? `has no filter (over ${details.threshold}) \u2014 the fold would walk the whole tenant table \u2014 ` : `with no id scope in the filter (over ${details.threshold}) \u2014 `;
608
+ super(
609
+ `${countClause}${reasonClause}"${operation}" was not issued`,
610
+ {
611
+ code: "SCOPE_REQUIRED",
612
+ category: "validation",
613
+ operation,
614
+ retryable: false,
615
+ suggestedAction: "Filter on an id field with op eq or in (companyID, contractID, ticketID, resourceID, or the entity id \u2014 time entries: ticketID), or use a count / group / sum for totals, or pass acknowledgeUnscoped: true to walk it anyway.",
616
+ details: { threshold: details.threshold, ...details.count !== void 0 ? { count: details.count } : {} }
617
+ }
618
+ );
619
+ this.name = "ScopeRequiredError";
620
+ }
621
+ };
622
+
589
623
  // src/cache.ts
590
624
  var READ_CACHE_MAX_ENTRIES = 4096;
591
625
  var REFERENCE_TTL_MS = 36e5;
@@ -1300,6 +1334,15 @@ function resolveConfig(config) {
1300
1334
  const caches = resolveCaches(config);
1301
1335
  const update = { putFallback: config.update?.putFallback === true };
1302
1336
  const reResolveZoneOn401 = config.reResolveZoneOn401 === true;
1337
+ let labels = false;
1338
+ if (config.labels !== void 0) {
1339
+ if (typeof config.labels !== "boolean") {
1340
+ throw new AutotaskConfigError(
1341
+ "labels must be a boolean (true adds the *Name/*Label row siblings to the read surfaces, false returns raw ids)"
1342
+ );
1343
+ }
1344
+ labels = config.labels;
1345
+ }
1303
1346
  const logger = config.logger ?? NoopLogger;
1304
1347
  if (credentials.baseUrl.startsWith("http:")) {
1305
1348
  const host = new URL(credentials.baseUrl).host;
@@ -1315,6 +1358,7 @@ function resolveConfig(config) {
1315
1358
  userName: credentials.userName,
1316
1359
  secret: credentials.secret,
1317
1360
  impersonationResourceId: credentials.impersonationResourceId,
1361
+ labels,
1318
1362
  ...limits,
1319
1363
  logger,
1320
1364
  rateLimit,
@@ -1557,7 +1601,7 @@ function normalizeInCondition(cond, path) {
1557
1601
  `Operator "${cond.op}" at ${path} has an empty value list \u2014 pass an array or a comma-separated string with at least one value (e.g. "1,2,3")`
1558
1602
  );
1559
1603
  }
1560
- const value = cond.udf === true ? tokens : tokens.map((t) => String(Number(t)) === t ? Number(t) : t);
1604
+ const value = cond.udf === true ? tokens : tokens.map((t) => /^(0|[1-9]\d*)$/.test(t) && Number.isSafeInteger(Number(t)) ? Number(t) : t);
1561
1605
  return { ...cond, value };
1562
1606
  }
1563
1607
  function refusedFilterShape(filter) {
@@ -16808,469 +16852,129 @@ async function toArray(iter) {
16808
16852
  return out;
16809
16853
  }
16810
16854
 
16811
- // src/field-presets.ts
16812
- var FIELD_PRESETS = {
16813
- /**
16814
- * The billing-item join read (the issue's example): the FK + measure
16815
- * columns that join a billing item to its company / ticket / project /
16816
- * time entry / contract service / contract / billing code / expense item,
16817
- * plus the date and the amount fields. All 14 names verified against the
16818
- * vendored-spec billing_items model (the non-authoritative guard); LIVE
16819
- * existence is asserted by the live-smoke preset check (the AC test).
16820
- */
16821
- billing_join: {
16822
- resource: "billing_items",
16823
- fields: [
16824
- "companyID",
16825
- "ticketID",
16826
- "projectID",
16827
- "timeEntryID",
16828
- "contractServiceID",
16829
- "contractID",
16830
- "billingCodeID",
16831
- "expenseItemID",
16832
- "itemDate",
16833
- "quantity",
16834
- "rate",
16835
- "extendedPrice",
16836
- "totalAmount",
16837
- "nonBillable"
16838
- ]
16839
- }
16840
- };
16841
- var FIELD_PRESET_NAMES = Object.keys(FIELD_PRESETS);
16842
- function resolveFieldsPreset(name, resourceKey, explicitIncludeFields) {
16843
- if (explicitIncludeFields !== void 0 && explicitIncludeFields !== null && explicitIncludeFields.length > 0) {
16844
- throw new AutotaskConfigError(
16845
- `fieldsPreset: "${name}" cannot be combined with an explicit includeFields \u2014 the bag is closed (pass ONE of the two: the preset name OR the field list)`
16846
- );
16847
- }
16848
- const preset = Object.hasOwn(FIELD_PRESETS, name) ? FIELD_PRESETS[name] : void 0;
16849
- if (preset === void 0) {
16850
- throw new AutotaskConfigError(
16851
- `fieldsPreset: unknown preset "${name}" \u2014 the preset list is closed (valid names: ${FIELD_PRESET_NAMES.join(", ")})`
16852
- );
16853
- }
16854
- if (preset.resource !== resourceKey) {
16855
- throw new AutotaskConfigError(
16856
- `fieldsPreset: "${name}" is a ${preset.resource} preset \u2014 it cannot be resolved on ${resourceKey} (the preset names its resource; the valid names are: ${FIELD_PRESET_NAMES.join(", ")})`
16857
- );
16858
- }
16859
- return [...preset.fields];
16860
- }
16855
+ // src/operations/types.ts
16856
+ var WORKFLOW_RESOURCES = [
16857
+ "companies",
16858
+ "tickets",
16859
+ "contacts",
16860
+ "configuration_items",
16861
+ "projects"
16862
+ ];
16861
16863
 
16862
- // src/resources/base/helpers.ts
16863
- function describeResponseShape(body) {
16864
- if (body === null) return "null";
16865
- if (Array.isArray(body)) return `array(len=${body.length})`;
16866
- if (typeof body !== "object") return typeof body;
16867
- const rec = body;
16868
- const keys = Object.keys(rec).sort();
16869
- const typeOf = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
16870
- const parts = keys.slice(0, 16).map((k) => `${k}:${typeOf(rec[k])}`);
16871
- const more = keys.length > 16 ? `, +${keys.length - 16} more` : "";
16872
- return `{ ${parts.join(", ")}${more} }`;
16873
- }
16874
- function defaultMarkerToken(data) {
16875
- for (const field of ["name", "title"]) {
16876
- const v = data[field];
16877
- if (typeof v === "string" && v.trim() !== "") return v.trim();
16878
- }
16879
- return null;
16880
- }
16881
- var CONTEXT_ABSENT_MARKER = "#context-absent";
16882
- function contextAbsentKey(path) {
16883
- return path + "\0context-absent";
16884
- }
16885
- function noteCharsTruncationMarker(charsRemoved) {
16886
- return `\u2026 [+${charsRemoved} chars truncated]`;
16887
- }
16888
- function truncateNoteChars(items, cap, fields) {
16889
- let truncated = 0;
16890
- const out = items.map((item) => {
16891
- if (!isRecord2(item)) return item;
16892
- let changed = false;
16893
- const copy = { ...item };
16894
- for (const field of fields) {
16895
- const v = copy[field];
16896
- if (typeof v === "string" && v.length > cap) {
16897
- const end = cap > 0 && (v.charCodeAt(cap - 1) & 64512) === 55296 ? cap - 1 : cap;
16898
- copy[field] = v.slice(0, end) + noteCharsTruncationMarker(v.length - end);
16899
- truncated += 1;
16900
- changed = true;
16901
- }
16902
- }
16903
- return changed ? copy : item;
16904
- });
16905
- return [out, truncated];
16906
- }
16907
- function blindEmptyReason(stamp) {
16864
+ // src/operations/fieldCatalog.ts
16865
+ var MAX_FIELD_CATALOG_WIRE = 60;
16866
+ var FIELD_CATALOG_HONESTY_NOTE = "Live field metadata reports the SHAPE of a field (existence, requiredness, type, maxLength, read-only, queryable, picklist-ness) as FLAGS and NAMES: picklist-ness as a flag (isPickList) and picklist field names (picklistFields). With the opt-in `labels: true`, picklist rows carry the vendor's exposed picklist VALUES (labels next to each value); without it the op serves no VALUES. It cannot resolve a picklist VALUE the vendor does not expose to this API user (the W-A4 wall \u2014 e.g. the attachments Type picklist): retrying the catalog will not surface a withheld value; that gap is vendor-side, not a client defect.";
16867
+ var MIN_FIELD_CATALOG_MAX_BYTES = 4096;
16868
+ var FIELD_CATALOG_OPT_KEYS = ["resources", "include", "fresh", "fields", "labels", "compact", "maxBytes"];
16869
+ function projectFieldRow(raw, labels = false, udf = false) {
16870
+ const r = typeof raw === "object" && raw !== null ? raw : {};
16871
+ const name = typeof r.name === "string" ? r.name : "";
16872
+ const type = typeof r.dataType === "string" ? r.dataType : typeof r.type === "string" ? r.type : "";
16873
+ const values = labels && r.isPickList === true && Array.isArray(r.picklistValues) && r.picklistValues.length > 0 ? r.picklistValues : void 0;
16874
+ const referenceEntityType = typeof r.referenceEntityType === "string" ? r.referenceEntityType : "";
16908
16875
  return {
16909
- surface: stamp.surface,
16910
- kind: "blind-surface",
16911
- note: stamp.note,
16912
- authority: stamp.authority,
16913
- remedy: "the note is the remedy \u2014 the authority op answers for a record"
16876
+ name,
16877
+ ...typeof r.label === "string" && r.label !== "" ? { label: r.label } : {},
16878
+ type,
16879
+ isRequired: r.isRequired === true,
16880
+ isReadOnly: r.isReadOnly === true,
16881
+ // Issue #219 N7: a UDF row never serves the vendor's isQueryable (it is
16882
+ // true for UDFs while a plain filter on a UDF name is refused); it serves
16883
+ // the marker the filter actually needs.
16884
+ ...udf ? { filterable: "udf-marker" } : { isQueryable: r.isQueryable === true },
16885
+ isPickList: r.isPickList === true,
16886
+ ...isObjectOrArrayType(type) ? { writeShape: "replace-all" } : {},
16887
+ maxLength: typeof r.length === "number" ? r.length : null,
16888
+ referenceEntityType,
16889
+ ...values !== void 0 ? { picklistValues: values } : {}
16914
16890
  };
16915
16891
  }
16916
- function attachUpdateTargetMissReason(err, key) {
16917
- const pinned = EMPTY_REASON_STAMPS[key]?.update;
16918
- if (pinned === void 0) return;
16919
- err.details = { ...err.details ?? {}, emptyReason: pinned };
16920
- }
16921
- function buildDryRunResult(spec, policy = { redactPersonalData: true }) {
16922
- if (spec.operation === "") {
16923
- throw new AutotaskConfigError("dry-run: the operation name is required");
16924
- }
16925
- const wouldApply = spec.checks.every((check) => check.ok);
16926
- const resource = spec.operation.split(".")[0] ?? spec.operation;
16927
- const diffPart = spec.diff !== void 0 ? {
16928
- diff: spec.diff.map((entry) => {
16929
- if (entry.to === void 0) return entry;
16930
- const to = isRedactedFieldName(entry.field, policy) ? REDACTED : redactResponseBody(entry.to, policy);
16931
- return { ...entry, to };
16932
- })
16933
- } : {};
16934
- const dataPart = spec.data !== void 0 ? { data: redactResponseBody(spec.data, policy) } : {};
16892
+ function compactFieldRow(f, udf, keepValues) {
16935
16893
  return {
16936
- operation: spec.operation,
16937
- wouldApply,
16938
- target: { resource, ids: spec.ids },
16939
- request: { method: spec.method, path: spec.path },
16940
- ...diffPart,
16941
- checks: spec.checks,
16942
- impact: spec.impact,
16943
- simulated: true,
16944
- warnings: spec.warnings ?? [],
16945
- ...dataPart,
16946
- ...spec.emptyReason !== void 0 ? { emptyReason: spec.emptyReason } : {}
16894
+ name: f.name,
16895
+ type: f.type,
16896
+ isPickList: f.isPickList,
16897
+ isReference: f.referenceEntityType !== "",
16898
+ udf,
16899
+ ...keepValues && f.picklistValues !== void 0 ? { picklistValues: f.picklistValues } : {}
16947
16900
  };
16948
16901
  }
16949
- function computeFieldDiff(current, data) {
16950
- const out = [];
16951
- for (const [field, to] of Object.entries(data)) {
16952
- if (field === "id" || to === void 0) continue;
16953
- const from = current[field];
16954
- if (from !== void 0 && JSON.stringify(from) === JSON.stringify(to)) continue;
16955
- out.push({ field, from: from === void 0 ? null : from, to });
16902
+ function isObjectOrArrayType(type) {
16903
+ const t = type.trim().toLowerCase();
16904
+ return t === "object" || t === "array" || t === "collection" || t.endsWith("[]");
16905
+ }
16906
+ function exposesPicklistValues(raw) {
16907
+ const r = typeof raw === "object" && raw !== null ? raw : {};
16908
+ return Array.isArray(r.picklistValues) && r.picklistValues.length > 0;
16909
+ }
16910
+ function applyFieldsFilter(projected, filter, row) {
16911
+ const kept = filter === void 0 ? projected : projected.filter((f) => filter.includes(f.name));
16912
+ if (filter !== void 0) {
16913
+ const present = new Set(projected.map((f) => f.name));
16914
+ row.unmatchedFields = filter.filter((n) => !present.has(n));
16915
+ row.projected = true;
16956
16916
  }
16957
- return out;
16917
+ row.fields = kept;
16918
+ row.required = kept.filter((f) => f.isRequired).map((f) => f.name);
16919
+ row.readOnly = kept.filter((f) => f.isReadOnly).map((f) => f.name);
16920
+ row.queryable = kept.filter((f) => f.isQueryable).map((f) => f.name);
16921
+ row.picklistFields = kept.filter((f) => f.isPickList).map((f) => f.name);
16958
16922
  }
16959
- function mergePartialBody(current, data) {
16960
- const merged = { ...current };
16961
- for (const [key, value] of Object.entries(data)) {
16962
- if (value === void 0) continue;
16963
- merged[key] = value;
16923
+ function capabilitySets() {
16924
+ const fieldOps = /* @__PURE__ */ new Map();
16925
+ const udfCapable = /* @__PURE__ */ new Set();
16926
+ const resources = /* @__PURE__ */ new Set();
16927
+ for (const rec of CAPABILITIES) {
16928
+ if (rec.resource === "operations") continue;
16929
+ resources.add(rec.resource);
16930
+ if (rec.operation === "getFieldInfo") fieldOps.set(rec.resource, rec);
16931
+ if (rec.udfCapable) udfCapable.add(rec.resource);
16964
16932
  }
16965
- return merged;
16933
+ return { fieldOps, udfCapable, resources };
16966
16934
  }
16967
- function normalizeUdfInput(value) {
16968
- if (Array.isArray(value)) {
16969
- return value.map((entry) => {
16970
- if (!isRecord2(entry) || typeof entry.name !== "string" || entry.name === "") {
16971
- throw new AutotaskConfigError("userDefinedFields array entries must be { name, value } objects");
16935
+ var derivedIndex = null;
16936
+ function derivedFieldsFor(resource) {
16937
+ if (derivedIndex === null) {
16938
+ derivedIndex = /* @__PURE__ */ new Map();
16939
+ for (const rec of CAPABILITIES) {
16940
+ const declared = rec.metadata?.derivedFields;
16941
+ if (!Array.isArray(declared) || derivedIndex.has(rec.resource)) continue;
16942
+ const rows = [];
16943
+ for (const row of declared) {
16944
+ if (typeof row?.field !== "string" || typeof row.rule !== "string") continue;
16945
+ rows.push({
16946
+ field: row.field,
16947
+ rule: row.rule,
16948
+ ...typeof row.writableRoute === "string" ? { writableRoute: row.writableRoute } : {}
16949
+ });
16972
16950
  }
16973
- return { name: entry.name, value: entry.value };
16974
- });
16975
- }
16976
- if (isRecord2(value)) {
16977
- return Object.entries(value).map(([name, entryValue]) => ({ name, value: entryValue }));
16951
+ derivedIndex.set(rec.resource, rows);
16952
+ }
16978
16953
  }
16979
- throw new AutotaskConfigError("userDefinedFields must be an array of { name, value } entries or a name-to-value map");
16954
+ return derivedIndex.get(resource) ?? [];
16980
16955
  }
16981
- function blindStampFor(stamp, filter) {
16982
- if (stamp === void 0) return void 0;
16983
- if (stamp.kind !== "unfiltered-blind") return stamp;
16984
- const conditions = normalizedConditions(filter);
16985
- return conditions === void 0 || conditions.length === 0 ? stamp : void 0;
16956
+ function apiEntityFor(rec) {
16957
+ const m = /^GET \/V1\.0\/([^/]+)\/entityInformation\/fields$/.exec(rec.endpoint ?? "");
16958
+ if (m === null) {
16959
+ throw new AutotaskConfigError(
16960
+ `fieldCatalog: the plan's getFieldInfo endpoint for "${rec.resource}" has no /V1.0/<Entity>/entityInformation/fields shape (${rec.endpoint}) \u2014 plan defect`,
16961
+ { operation: "operations.fieldCatalog" }
16962
+ );
16963
+ }
16964
+ return m[1];
16986
16965
  }
16987
- function decodeFileData(body) {
16988
- if (isRecord2(body)) {
16989
- const data = body.data;
16990
- if (typeof data === "string") return Buffer.from(data, "base64");
16991
- if (Array.isArray(data) && data.every((b) => typeof b === "number")) {
16992
- return Buffer.from(data);
16993
- }
16994
- }
16995
- if (typeof body === "string") return Buffer.from(body, "base64");
16996
- throw new AutotaskError(
16997
- "Expected the file envelope FileQueryResultModel { data } (or a base64 string); got a different shape",
16998
- { code: "INVALID_RESPONSE", category: "server" }
16999
- );
17000
- }
17001
- var DEPLURAL_EXCEPTIONS = {
17002
- tag_aliase: "tag_alias"
17003
- };
17004
- function entityNameFromPath(resourcePath) {
17005
- const segment = resourcePath.slice(resourcePath.lastIndexOf("/") + 1);
17006
- const snake = segment.replace(/([A-Z])/g, (_m, c, i) => (i > 0 ? `_` : "") + c.toLowerCase()).replace(/_{2,}/g, "_");
17007
- if (snake.endsWith("ies")) return `${snake.slice(0, -3)}y`;
17008
- if (snake.endsWith("s")) {
17009
- const stripped = snake.slice(0, -1);
17010
- return DEPLURAL_EXCEPTIONS[stripped] ?? stripped;
17011
- }
17012
- return snake;
17013
- }
17014
-
17015
- // src/operations/expandResourceNames.ts
17016
- var EXPAND_RESOURCE_NAMES_MAX_IDS = 500;
17017
- function composeName(record) {
17018
- const parts = [];
17019
- for (const field of ["firstName", "middleName", "lastName"]) {
17020
- const value = record[field];
17021
- if (typeof value === "string" && value.trim() !== "") parts.push(value.trim());
17022
- }
17023
- return parts.length > 0 ? parts.join(" ") : null;
17024
- }
17025
- async function expandResourceNames(client, ids2, opts = {}) {
17026
- if (opts.maxIds !== void 0 && (!Number.isInteger(opts.maxIds) || opts.maxIds < 1)) {
17027
- throw new AutotaskConfigError(`expandResourceNames: maxIds must be an integer >= 1 (got ${String(opts.maxIds)})`);
17028
- }
17029
- const cap = Math.min(EXPAND_RESOURCE_NAMES_MAX_IDS, opts.maxIds ?? EXPAND_RESOURCE_NAMES_MAX_IDS);
17030
- if (!Array.isArray(ids2) || ids2.length === 0) {
17031
- throw new AutotaskConfigError(
17032
- "expandResourceNames: ids must be a non-empty array of vendor numeric resource ids (a zero-id call is a shape error, not a no-op)",
17033
- { suggestedAction: "Pass at least one vendor numeric resource id \u2014 safe integer, or the canonical integer string for ids above 2^53." }
17034
- );
17035
- }
17036
- if (ids2.length > cap) {
17037
- throw new AutotaskConfigError(
17038
- `expandResourceNames: ${ids2.length} ids over the ${cap}-id cap (EXPAND_RESOURCE_NAMES_MAX_IDS = ${EXPAND_RESOURCE_NAMES_MAX_IDS}; maxIds may lower, never raise) \u2014 zero ids were fetched (pre-request)`,
17039
- { suggestedAction: `Split the call into groups of <= ${cap} ids.` }
17040
- );
17041
- }
17042
- const problems = [];
17043
- const canonical = [];
17044
- for (let i = 0; i < ids2.length; i += 1) {
17045
- const at = `ids[${i}]`;
17046
- const raw = ids2[i];
17047
- try {
17048
- canonical[i] = parseIdentifier(raw, at);
17049
- } catch {
17050
- if (typeof raw === "string") {
17051
- problems.push(`${at}: "${raw}" is not a vendor numeric resource id \u2014 non-numeric string identifiers are operations.resolveAny territory (the resource's findBy* helpers)`);
17052
- } else if (typeof raw === "number") {
17053
- problems.push(`${at}: ${raw} is not a non-negative safe integer (a JS number this large is already corrupted \u2014 pass the canonical integer string form)`);
17054
- } else {
17055
- problems.push(`${at}: required \u2014 the vendor numeric resource id (safe integer, or the canonical integer string for ids above 2^53)`);
17056
- }
17057
- }
17058
- }
17059
- if (problems.length > 0) {
17060
- throw new AutotaskConfigError(`expandResourceNames: ${problems.length} problem(s) found pre-request (zero wire was issued)
17061
- - ` + problems.join("\n- "), {
17062
- suggestedAction: "Fix every id above and call again \u2014 the ids are vendor NUMERIC resource ids only (name-to-id resolution is operations.resolveAny territory)."
17063
- });
17064
- }
17065
- const unique = [];
17066
- const seen = /* @__PURE__ */ new Set();
17067
- for (const id of canonical) {
17068
- const k = String(id);
17069
- if (!seen.has(k)) {
17070
- seen.add(k);
17071
- unique.push(id);
17072
- }
17073
- }
17074
- const holder = client.resources;
17075
- const cache = client.http.readCache;
17076
- const fresh = opts.fresh === true;
17077
- const byId = /* @__PURE__ */ new Map();
17078
- for (const id of unique) {
17079
- const k = String(id);
17080
- const absentKey = contextAbsentKey(`/V1.0/Resources/${k}`);
17081
- if (cache !== void 0 && !fresh && cache.get(absentKey) !== void 0) {
17082
- byId.set(k, { name: null, blind: true });
17083
- continue;
17084
- }
17085
- const epoch = cache?.epoch ?? 0;
17086
- try {
17087
- const record = await holder.get(id, { fresh });
17088
- cache?.delete(absentKey);
17089
- byId.set(k, { name: composeName(record), blind: false });
17090
- } catch (err) {
17091
- if (err instanceof NotFoundError) {
17092
- cache?.set(absentKey, CONTEXT_ABSENT_MARKER, epoch);
17093
- byId.set(k, { name: null, blind: true });
17094
- continue;
17095
- }
17096
- if (isAutotaskError(err) && err.code !== "ABORTED") {
17097
- const e = { code: err.code, message: err.message };
17098
- if (err.httpStatus !== void 0) e.httpStatus = err.httpStatus;
17099
- byId.set(k, { name: null, blind: false, error: e });
17100
- continue;
17101
- }
17102
- throw err;
17103
- }
17104
- }
17105
- const results = [];
17106
- for (const id of canonical) {
17107
- const out = byId.get(String(id));
17108
- results.push({ id, name: out.name, blind: out.blind, ...out.error !== void 0 ? { error: out.error } : {} });
17109
- }
17110
- const resolved = results.filter((r) => r.name !== null).length;
17111
- const blind = results.filter((r) => r.blind === true).length;
17112
- const failed = results.filter((r) => r.error !== void 0).length;
17113
- return { results, resolved, blind, failed };
17114
- }
17115
-
17116
- // src/operations/entityType.ts
17117
- function buildEntityTypeMap() {
17118
- const exact = {};
17119
- for (const [resource, entry] of Object.entries(RELATIONSHIP_MAP)) {
17120
- const apiEntity = entry.apiEntity;
17121
- if (typeof apiEntity !== "string" || apiEntity === "") continue;
17122
- if (exact[apiEntity] === void 0) exact[apiEntity] = resource;
17123
- }
17124
- const aliases = {};
17125
- for (const [apiEntity, resource] of Object.entries(exact)) {
17126
- if (!apiEntity.endsWith("s") || apiEntity.endsWith("ss")) continue;
17127
- const singular = apiEntity.slice(0, -1);
17128
- if (exact[singular] !== void 0 || aliases[singular] !== void 0) continue;
17129
- aliases[singular] = resource;
17130
- }
17131
- return { ...exact, ...aliases };
17132
- }
17133
- var ENTITY_TYPE_TO_RESOURCE = buildEntityTypeMap();
17134
- var ENUMERABLE_RESOURCES = new Set(
17135
- CAPABILITIES.filter((rec) => rec.resource !== "operations" && (rec.operation === "query" || rec.operation === "get")).map(
17136
- (rec) => rec.resource
17137
- )
17138
- );
17139
- function resourceForEntityType(entityType) {
17140
- if (typeof entityType !== "string" || entityType === "") return null;
17141
- return ENTITY_TYPE_TO_RESOURCE[entityType] ?? null;
17142
- }
17143
- function isEnumerableResource(resource) {
17144
- if (typeof resource !== "string" || resource === "") return false;
17145
- return ENUMERABLE_RESOURCES.has(resource);
17146
- }
17147
-
17148
- // src/operations/types.ts
17149
- var WORKFLOW_RESOURCES = [
17150
- "companies",
17151
- "tickets",
17152
- "contacts",
17153
- "configuration_items",
17154
- "projects"
17155
- ];
17156
-
17157
- // src/operations/fieldCatalog.ts
17158
- var MAX_FIELD_CATALOG_WIRE = 60;
17159
- var FIELD_CATALOG_HONESTY_NOTE = "Live field metadata reports the SHAPE of a field (existence, requiredness, type, maxLength, read-only, queryable, picklist-ness) as FLAGS and NAMES: picklist-ness as a flag (isPickList) and picklist field names (picklistFields). With the opt-in `labels: true`, picklist rows carry the vendor's exposed picklist VALUES (labels next to each value); without it the op serves no VALUES. It cannot resolve a picklist VALUE the vendor does not expose to this API user (the W-A4 wall \u2014 e.g. the attachments Type picklist): retrying the catalog will not surface a withheld value; that gap is vendor-side, not a client defect.";
17160
- var MIN_FIELD_CATALOG_MAX_BYTES = 4096;
17161
- var FIELD_CATALOG_OPT_KEYS = ["resources", "include", "fresh", "fields", "labels", "compact", "maxBytes"];
17162
- function projectFieldRow(raw, labels = false, udf = false) {
17163
- const r = typeof raw === "object" && raw !== null ? raw : {};
17164
- const name = typeof r.name === "string" ? r.name : "";
17165
- const type = typeof r.dataType === "string" ? r.dataType : typeof r.type === "string" ? r.type : "";
17166
- const values = labels && r.isPickList === true && Array.isArray(r.picklistValues) && r.picklistValues.length > 0 ? r.picklistValues : void 0;
17167
- const referenceEntityType = typeof r.referenceEntityType === "string" ? r.referenceEntityType : "";
17168
- const targetResource = resourceForEntityType(referenceEntityType);
17169
- return {
17170
- name,
17171
- ...typeof r.label === "string" && r.label !== "" ? { label: r.label } : {},
17172
- type,
17173
- isRequired: r.isRequired === true,
17174
- isReadOnly: r.isReadOnly === true,
17175
- // Issue #219 N7: a UDF row never serves the vendor's isQueryable (it is
17176
- // true for UDFs while a plain filter on a UDF name is refused); it serves
17177
- // the marker the filter actually needs.
17178
- ...udf ? { filterable: "udf-marker" } : { isQueryable: r.isQueryable === true },
17179
- isPickList: r.isPickList === true,
17180
- ...isObjectOrArrayType(type) ? { writeShape: "replace-all" } : {},
17181
- maxLength: typeof r.length === "number" ? r.length : null,
17182
- referenceEntityType,
17183
- targetResource,
17184
- targetEnumerable: isEnumerableResource(targetResource),
17185
- ...values !== void 0 ? { picklistValues: values } : {}
17186
- };
17187
- }
17188
- function compactFieldRow(f, udf, keepValues) {
17189
- return {
17190
- name: f.name,
17191
- type: f.type,
17192
- isPickList: f.isPickList,
17193
- isReference: f.referenceEntityType !== "",
17194
- udf,
17195
- ...keepValues && f.picklistValues !== void 0 ? { picklistValues: f.picklistValues } : {}
17196
- };
17197
- }
17198
- function isObjectOrArrayType(type) {
17199
- const t = type.trim().toLowerCase();
17200
- return t === "object" || t === "array" || t === "collection" || t.endsWith("[]");
17201
- }
17202
- function exposesPicklistValues(raw) {
17203
- const r = typeof raw === "object" && raw !== null ? raw : {};
17204
- return Array.isArray(r.picklistValues) && r.picklistValues.length > 0;
17205
- }
17206
- function applyFieldsFilter(projected, filter, row) {
17207
- const kept = filter === void 0 ? projected : projected.filter((f) => filter.includes(f.name));
17208
- if (filter !== void 0) {
17209
- const present = new Set(projected.map((f) => f.name));
17210
- row.unmatchedFields = filter.filter((n) => !present.has(n));
17211
- row.projected = true;
17212
- }
17213
- row.fields = kept;
17214
- row.required = kept.filter((f) => f.isRequired).map((f) => f.name);
17215
- row.readOnly = kept.filter((f) => f.isReadOnly).map((f) => f.name);
17216
- row.queryable = kept.filter((f) => f.isQueryable).map((f) => f.name);
17217
- row.picklistFields = kept.filter((f) => f.isPickList).map((f) => f.name);
17218
- }
17219
- function capabilitySets() {
17220
- const fieldOps = /* @__PURE__ */ new Map();
17221
- const udfCapable = /* @__PURE__ */ new Set();
17222
- const resources = /* @__PURE__ */ new Set();
17223
- for (const rec of CAPABILITIES) {
17224
- if (rec.resource === "operations") continue;
17225
- resources.add(rec.resource);
17226
- if (rec.operation === "getFieldInfo") fieldOps.set(rec.resource, rec);
17227
- if (rec.udfCapable) udfCapable.add(rec.resource);
17228
- }
17229
- return { fieldOps, udfCapable, resources };
17230
- }
17231
- var derivedIndex = null;
17232
- function derivedFieldsFor(resource) {
17233
- if (derivedIndex === null) {
17234
- derivedIndex = /* @__PURE__ */ new Map();
17235
- for (const rec of CAPABILITIES) {
17236
- const declared = rec.metadata?.derivedFields;
17237
- if (!Array.isArray(declared) || derivedIndex.has(rec.resource)) continue;
17238
- const rows = [];
17239
- for (const row of declared) {
17240
- if (typeof row?.field !== "string" || typeof row.rule !== "string") continue;
17241
- rows.push({
17242
- field: row.field,
17243
- rule: row.rule,
17244
- ...typeof row.writableRoute === "string" ? { writableRoute: row.writableRoute } : {}
17245
- });
17246
- }
17247
- derivedIndex.set(rec.resource, rows);
17248
- }
17249
- }
17250
- return derivedIndex.get(resource) ?? [];
17251
- }
17252
- function apiEntityFor(rec) {
17253
- const m = /^GET \/V1\.0\/([^/]+)\/entityInformation\/fields$/.exec(rec.endpoint ?? "");
17254
- if (m === null) {
17255
- throw new AutotaskConfigError(
17256
- `fieldCatalog: the plan's getFieldInfo endpoint for "${rec.resource}" has no /V1.0/<Entity>/entityInformation/fields shape (${rec.endpoint}) \u2014 plan defect`,
17257
- { operation: "operations.fieldCatalog" }
17258
- );
17259
- }
17260
- return m[1];
17261
- }
17262
- function validateCatalogOptsShape(opts) {
17263
- if (opts !== void 0 && (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
17264
- throw new AutotaskConfigError(
17265
- "fieldCatalog: opts must be an object { resources?: string[], include?: (info|fields|udf)[], fresh?: boolean, fields?: string[], labels?: boolean, compact?: boolean, maxBytes?: number }"
17266
- );
17267
- }
17268
- for (const key of Object.keys(opts ?? {})) {
17269
- if (!FIELD_CATALOG_OPT_KEYS.includes(key)) {
17270
- throw new AutotaskConfigError(
17271
- `fieldCatalog: unknown opts key "${key}" \u2014 the bag is closed: ${FIELD_CATALOG_OPT_KEYS.join(", ")}`,
17272
- { suggestedAction: "Drop the unknown key (autotask_describe(operations.fieldCatalog) shows the exact bag)." }
17273
- );
16966
+ function validateCatalogOptsShape(opts) {
16967
+ if (opts !== void 0 && (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
16968
+ throw new AutotaskConfigError(
16969
+ "fieldCatalog: opts must be an object { resources?: string[], include?: (info|fields|udf)[], fresh?: boolean, fields?: string[], labels?: boolean, compact?: boolean, maxBytes?: number }"
16970
+ );
16971
+ }
16972
+ for (const key of Object.keys(opts ?? {})) {
16973
+ if (!FIELD_CATALOG_OPT_KEYS.includes(key)) {
16974
+ throw new AutotaskConfigError(
16975
+ `fieldCatalog: unknown opts key "${key}" \u2014 the bag is closed: ${FIELD_CATALOG_OPT_KEYS.join(", ")}`,
16976
+ { suggestedAction: "Drop the unknown key (autotask_describe(operations.fieldCatalog) shows the exact bag)." }
16977
+ );
17274
16978
  }
17275
16979
  }
17276
16980
  }
@@ -17361,7 +17065,8 @@ function resolveSizeOpts(opts) {
17361
17065
  `fieldCatalog: opts.maxBytes must be an integer >= ${MIN_FIELD_CATALOG_MAX_BYTES} (got ${String(maxBytes)})`
17362
17066
  );
17363
17067
  }
17364
- return { compact: opts?.compact === true, maxBytes };
17068
+ const compact = opts?.compact ?? (opts?.fields === void 0 && opts?.labels !== true);
17069
+ return { compact, maxBytes };
17365
17070
  }
17366
17071
  var jsonBytes = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");
17367
17072
  var TRIM_KINDS = ["udf", "fields", "picklistFields", "queryable", "readOnly", "required"];
@@ -17552,15 +17257,592 @@ async function fieldCatalog(client, opts) {
17552
17257
  return result;
17553
17258
  }
17554
17259
 
17555
- // src/field-sets.ts
17556
- var MODEL_FIELD_SETS = {
17557
- "appointments": ["id", "createDateTime", "creatorResourceID", "description", "endDateTime", "resourceID", "startDateTime", "title", "updateDateTime"],
17558
- "billing_codes": ["id", "afterHoursWorkType", "billingCodeType", "department", "description", "externalNumber", "generalLedgerAccount", "isActive", "isExcludedFromNewContracts", "markupRate", "name", "taxCategoryID", "unitCost", "unitPrice", "useType"],
17559
- "billing_item_approval_levels": ["id", "approvalDateTime", "approvalLevel", "approvalResourceID", "timeEntryID"],
17560
- "billing_items": ["id", "accountManagerWhenApprovedID", "billingCodeID", "billingItemType", "companyID", "configurationItemID", "contractBlockID", "contractChargeID", "contractID", "contractServiceAdjustmentID", "contractServiceBundleAdjustmentID", "contractServiceBundleID", "contractServiceBundlePeriodID", "contractServiceID", "contractServicePeriodID", "description", "expenseItemID", "extendedPrice", "internalCurrencyExtendedPrice", "internalCurrencyRate", "internalCurrencyTaxDollars", "internalCurrencyTotalAmount", "invoiceID", "itemApproverID", "itemDate", "itemName", "lineItemFullDescription", "lineItemGroupDescription", "milestoneID", "nonBillable", "organizationalLevelAssociationID", "ourCost", "postedDate", "postedOnTime", "projectChargeID", "projectID", "purchaseOrderNumber", "quantity", "rate", "roleID", "serviceBundleID", "serviceID", "sortOrderID", "subType", "taskID", "taxDollars", "ticketChargeID", "ticketID", "timeEntryID", "totalAmount", "vendorID", "webServiceDate"],
17561
- "change_request_links": ["id", "changeRequestTicketID", "problemOrIncidentTicketID"],
17562
- "checklist_libraries": ["id", "description", "entityType", "isActive", "name"],
17563
- "checklist_library_checklist_items": ["id", "checklistLibraryID", "isImportant", "itemName", "knowledgebaseArticleID", "position", "soapParentPropertyId"],
17260
+ // src/resources/base/helpers.ts
17261
+ function describeResponseShape(body) {
17262
+ if (body === null) return "null";
17263
+ if (Array.isArray(body)) return `array(len=${body.length})`;
17264
+ if (typeof body !== "object") return typeof body;
17265
+ const rec = body;
17266
+ const keys = Object.keys(rec).sort();
17267
+ const typeOf = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
17268
+ const parts = keys.slice(0, 16).map((k) => `${k}:${typeOf(rec[k])}`);
17269
+ const more = keys.length > 16 ? `, +${keys.length - 16} more` : "";
17270
+ return `{ ${parts.join(", ")}${more} }`;
17271
+ }
17272
+ function defaultMarkerToken(data) {
17273
+ for (const field of ["name", "title"]) {
17274
+ const v = data[field];
17275
+ if (typeof v === "string" && v.trim() !== "") return v.trim();
17276
+ }
17277
+ return null;
17278
+ }
17279
+ var CONTEXT_ABSENT_MARKER = "#context-absent";
17280
+ function contextAbsentKey(path) {
17281
+ return path + "\0context-absent";
17282
+ }
17283
+ function noteCharsTruncationMarker(charsRemoved) {
17284
+ return `\u2026 [+${charsRemoved} chars truncated]`;
17285
+ }
17286
+ function truncateNoteChars(items, cap, fields) {
17287
+ let truncated = 0;
17288
+ const out = items.map((item) => {
17289
+ if (!isRecord2(item)) return item;
17290
+ let changed = false;
17291
+ const copy = { ...item };
17292
+ for (const field of fields) {
17293
+ const v = copy[field];
17294
+ if (typeof v === "string" && v.length > cap) {
17295
+ const end = cap > 0 && (v.charCodeAt(cap - 1) & 64512) === 55296 ? cap - 1 : cap;
17296
+ copy[field] = v.slice(0, end) + noteCharsTruncationMarker(v.length - end);
17297
+ truncated += 1;
17298
+ changed = true;
17299
+ }
17300
+ }
17301
+ return changed ? copy : item;
17302
+ });
17303
+ return [out, truncated];
17304
+ }
17305
+ function blindEmptyReason(stamp) {
17306
+ return {
17307
+ surface: stamp.surface,
17308
+ kind: "blind-surface",
17309
+ note: stamp.note,
17310
+ authority: stamp.authority,
17311
+ remedy: "the note is the remedy \u2014 the authority op answers for a record"
17312
+ };
17313
+ }
17314
+ function attachUpdateTargetMissReason(err, key) {
17315
+ const pinned = EMPTY_REASON_STAMPS[key]?.update;
17316
+ if (pinned === void 0) return;
17317
+ err.details = { ...err.details ?? {}, emptyReason: pinned };
17318
+ }
17319
+ function buildDryRunResult(spec, policy = { redactPersonalData: true }) {
17320
+ if (spec.operation === "") {
17321
+ throw new AutotaskConfigError("dry-run: the operation name is required");
17322
+ }
17323
+ const wouldApply = spec.checks.every((check) => check.ok);
17324
+ const resource = spec.operation.split(".")[0] ?? spec.operation;
17325
+ const diffPart = spec.diff !== void 0 ? {
17326
+ diff: spec.diff.map((entry) => {
17327
+ if (entry.to === void 0) return entry;
17328
+ const to = isRedactedFieldName(entry.field, policy) ? REDACTED : redactResponseBody(entry.to, policy);
17329
+ return { ...entry, to };
17330
+ })
17331
+ } : {};
17332
+ const dataPart = spec.data !== void 0 ? { data: redactResponseBody(spec.data, policy) } : {};
17333
+ return {
17334
+ operation: spec.operation,
17335
+ wouldApply,
17336
+ target: { resource, ids: spec.ids },
17337
+ request: { method: spec.method, path: spec.path },
17338
+ ...diffPart,
17339
+ checks: spec.checks,
17340
+ impact: spec.impact,
17341
+ simulated: true,
17342
+ warnings: spec.warnings ?? [],
17343
+ ...dataPart,
17344
+ ...spec.emptyReason !== void 0 ? { emptyReason: spec.emptyReason } : {}
17345
+ };
17346
+ }
17347
+ function computeFieldDiff(current, data) {
17348
+ const out = [];
17349
+ for (const [field, to] of Object.entries(data)) {
17350
+ if (field === "id" || to === void 0) continue;
17351
+ const from = current[field];
17352
+ if (from !== void 0 && JSON.stringify(from) === JSON.stringify(to)) continue;
17353
+ out.push({ field, from: from === void 0 ? null : from, to });
17354
+ }
17355
+ return out;
17356
+ }
17357
+ function mergePartialBody(current, data) {
17358
+ const merged = { ...current };
17359
+ for (const [key, value] of Object.entries(data)) {
17360
+ if (value === void 0) continue;
17361
+ merged[key] = value;
17362
+ }
17363
+ return merged;
17364
+ }
17365
+ function normalizeUdfInput(value) {
17366
+ if (Array.isArray(value)) {
17367
+ return value.map((entry) => {
17368
+ if (!isRecord2(entry) || typeof entry.name !== "string" || entry.name === "") {
17369
+ throw new AutotaskConfigError("userDefinedFields array entries must be { name, value } objects");
17370
+ }
17371
+ return { name: entry.name, value: entry.value };
17372
+ });
17373
+ }
17374
+ if (isRecord2(value)) {
17375
+ return Object.entries(value).map(([name, entryValue]) => ({ name, value: entryValue }));
17376
+ }
17377
+ throw new AutotaskConfigError("userDefinedFields must be an array of { name, value } entries or a name-to-value map");
17378
+ }
17379
+ function blindStampFor(stamp, filter) {
17380
+ if (stamp === void 0) return void 0;
17381
+ if (stamp.kind !== "unfiltered-blind") return stamp;
17382
+ const conditions = normalizedConditions(filter);
17383
+ return conditions === void 0 || conditions.length === 0 ? stamp : void 0;
17384
+ }
17385
+ function decodeFileData(body) {
17386
+ if (isRecord2(body)) {
17387
+ const data = body.data;
17388
+ if (typeof data === "string") return Buffer.from(data, "base64");
17389
+ if (Array.isArray(data) && data.every((b) => typeof b === "number")) {
17390
+ return Buffer.from(data);
17391
+ }
17392
+ }
17393
+ if (typeof body === "string") return Buffer.from(body, "base64");
17394
+ throw new AutotaskError(
17395
+ "Expected the file envelope FileQueryResultModel { data } (or a base64 string); got a different shape",
17396
+ { code: "INVALID_RESPONSE", category: "server" }
17397
+ );
17398
+ }
17399
+ var DEPLURAL_EXCEPTIONS = {
17400
+ tag_aliase: "tag_alias"
17401
+ };
17402
+ function entityNameFromPath(resourcePath) {
17403
+ const segment = resourcePath.slice(resourcePath.lastIndexOf("/") + 1);
17404
+ const snake = segment.replace(/([A-Z])/g, (_m, c, i) => (i > 0 ? `_` : "") + c.toLowerCase()).replace(/_{2,}/g, "_");
17405
+ if (snake.endsWith("ies")) return `${snake.slice(0, -3)}y`;
17406
+ if (snake.endsWith("s")) {
17407
+ const stripped = snake.slice(0, -1);
17408
+ return DEPLURAL_EXCEPTIONS[stripped] ?? stripped;
17409
+ }
17410
+ return snake;
17411
+ }
17412
+
17413
+ // src/operations/expandResourceNames.ts
17414
+ var EXPAND_RESOURCE_NAMES_MAX_IDS = 500;
17415
+ function composeName(record) {
17416
+ const parts = [];
17417
+ for (const field of ["firstName", "middleName", "lastName"]) {
17418
+ const value = record[field];
17419
+ if (typeof value === "string" && value.trim() !== "") parts.push(value.trim());
17420
+ }
17421
+ return parts.length > 0 ? parts.join(" ") : null;
17422
+ }
17423
+ async function expandResourceNames(client, ids2, opts = {}) {
17424
+ if (opts.maxIds !== void 0 && (!Number.isInteger(opts.maxIds) || opts.maxIds < 1)) {
17425
+ throw new AutotaskConfigError(`expandResourceNames: maxIds must be an integer >= 1 (got ${String(opts.maxIds)})`);
17426
+ }
17427
+ const cap = Math.min(EXPAND_RESOURCE_NAMES_MAX_IDS, opts.maxIds ?? EXPAND_RESOURCE_NAMES_MAX_IDS);
17428
+ if (!Array.isArray(ids2) || ids2.length === 0) {
17429
+ throw new AutotaskConfigError(
17430
+ "expandResourceNames: ids must be a non-empty array of vendor numeric resource ids (a zero-id call is a shape error, not a no-op)",
17431
+ { suggestedAction: "Pass at least one vendor numeric resource id \u2014 safe integer, or the canonical integer string for ids above 2^53." }
17432
+ );
17433
+ }
17434
+ if (ids2.length > cap) {
17435
+ throw new AutotaskConfigError(
17436
+ `expandResourceNames: ${ids2.length} ids over the ${cap}-id cap (EXPAND_RESOURCE_NAMES_MAX_IDS = ${EXPAND_RESOURCE_NAMES_MAX_IDS}; maxIds may lower, never raise) \u2014 zero ids were fetched (pre-request)`,
17437
+ { suggestedAction: `Split the call into groups of <= ${cap} ids.` }
17438
+ );
17439
+ }
17440
+ const problems = [];
17441
+ const canonical = [];
17442
+ for (let i = 0; i < ids2.length; i += 1) {
17443
+ const at = `ids[${i}]`;
17444
+ const raw = ids2[i];
17445
+ try {
17446
+ canonical[i] = parseIdentifier(raw, at);
17447
+ } catch {
17448
+ if (typeof raw === "string") {
17449
+ problems.push(`${at}: "${raw}" is not a vendor numeric resource id \u2014 non-numeric string identifiers are operations.resolveAny territory (the resource's findBy* helpers)`);
17450
+ } else if (typeof raw === "number") {
17451
+ problems.push(`${at}: ${raw} is not a non-negative safe integer (a JS number this large is already corrupted \u2014 pass the canonical integer string form)`);
17452
+ } else {
17453
+ problems.push(`${at}: required \u2014 the vendor numeric resource id (safe integer, or the canonical integer string for ids above 2^53)`);
17454
+ }
17455
+ }
17456
+ }
17457
+ if (problems.length > 0) {
17458
+ throw new AutotaskConfigError(`expandResourceNames: ${problems.length} problem(s) found pre-request (zero wire was issued)
17459
+ - ` + problems.join("\n- "), {
17460
+ suggestedAction: "Fix every id above and call again \u2014 the ids are vendor NUMERIC resource ids only (name-to-id resolution is operations.resolveAny territory)."
17461
+ });
17462
+ }
17463
+ const unique = [];
17464
+ const seen = /* @__PURE__ */ new Set();
17465
+ for (const id of canonical) {
17466
+ const k = String(id);
17467
+ if (!seen.has(k)) {
17468
+ seen.add(k);
17469
+ unique.push(id);
17470
+ }
17471
+ }
17472
+ const holder = client.resources;
17473
+ const cache = client.http.readCache;
17474
+ const fresh = opts.fresh === true;
17475
+ const byId = /* @__PURE__ */ new Map();
17476
+ for (const id of unique) {
17477
+ const k = String(id);
17478
+ const absentKey = contextAbsentKey(`/V1.0/Resources/${k}`);
17479
+ if (cache !== void 0 && !fresh && cache.get(absentKey) !== void 0) {
17480
+ byId.set(k, { name: null, blind: true });
17481
+ continue;
17482
+ }
17483
+ const epoch = cache?.epoch ?? 0;
17484
+ try {
17485
+ const record = await holder.get(id, { fresh });
17486
+ cache?.delete(absentKey);
17487
+ byId.set(k, { name: composeName(record), blind: false });
17488
+ } catch (err) {
17489
+ if (err instanceof NotFoundError) {
17490
+ cache?.set(absentKey, CONTEXT_ABSENT_MARKER, epoch);
17491
+ byId.set(k, { name: null, blind: true });
17492
+ continue;
17493
+ }
17494
+ if (isAutotaskError(err) && err.code !== "ABORTED") {
17495
+ const e = { code: err.code, message: err.message };
17496
+ if (err.httpStatus !== void 0) e.httpStatus = err.httpStatus;
17497
+ byId.set(k, { name: null, blind: false, error: e });
17498
+ continue;
17499
+ }
17500
+ throw err;
17501
+ }
17502
+ }
17503
+ const results = [];
17504
+ for (const id of canonical) {
17505
+ const out = byId.get(String(id));
17506
+ results.push({ id, name: out.name, blind: out.blind, ...out.error !== void 0 ? { error: out.error } : {} });
17507
+ }
17508
+ const resolved = results.filter((r) => r.name !== null).length;
17509
+ const blind = results.filter((r) => r.blind === true).length;
17510
+ const failed = results.filter((r) => r.error !== void 0).length;
17511
+ return { results, resolved, blind, failed };
17512
+ }
17513
+
17514
+ // src/operations/lineOfBusinessChain.ts
17515
+ function vendorId(value) {
17516
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 1 ? value : void 0;
17517
+ if (typeof value === "string" && /^[0-9]+$/.test(value.trim())) {
17518
+ const n = Number(value.trim());
17519
+ return Number.isSafeInteger(n) && n >= 1 ? n : void 0;
17520
+ }
17521
+ return void 0;
17522
+ }
17523
+ async function resolveLineOfBusinessChain(http, associationId, options) {
17524
+ const operation = options.operation ?? "compositeNames";
17525
+ const degrade = options.onError === "degrade";
17526
+ const get = (path) => {
17527
+ const res = http.request({ method: "GET", path, operation }).then((body) => {
17528
+ const b = body;
17529
+ if (b === null || !("item" in b) || b.item === null || b.item === void 0) return null;
17530
+ return b.item;
17531
+ });
17532
+ if (!degrade) {
17533
+ return res.catch((e) => {
17534
+ if (e instanceof NotFoundError) return null;
17535
+ throw e;
17536
+ });
17537
+ }
17538
+ return res.catch((e) => {
17539
+ if (e instanceof NotFoundError || isAutotaskError(e) && e.code !== "ABORTED") return null;
17540
+ throw e;
17541
+ });
17542
+ };
17543
+ let remaining = options.budget;
17544
+ if (remaining < 1) {
17545
+ return {
17546
+ lineOfBusiness: { association: null, organizationalLevel2: null, organizationalLevel1: null, markers: { association: "capped", organizationalLevel2: "capped", organizationalLevel1: "capped" } },
17547
+ used: 0,
17548
+ truncated: true
17549
+ };
17550
+ }
17551
+ remaining -= 1;
17552
+ const association = await get(`/V1.0/OrganizationalLevelAssociations/${associationId}`);
17553
+ const associationMarker = association === null ? "unresolved" : "ok";
17554
+ if (association === null) {
17555
+ return {
17556
+ lineOfBusiness: { association: null, organizationalLevel2: null, organizationalLevel1: null, markers: { association: associationMarker, organizationalLevel2: "missing", organizationalLevel1: "missing" } },
17557
+ used: 1,
17558
+ truncated: false
17559
+ };
17560
+ }
17561
+ const l2Id = vendorId(association.organizationalLevel2ID);
17562
+ const l1Id = vendorId(association.organizationalLevel1ID);
17563
+ let l2Marker = l2Id !== void 0 ? "unresolved" : "missing";
17564
+ let l1Marker = l1Id !== void 0 ? "unresolved" : "missing";
17565
+ const need = (l2Id !== void 0 ? 1 : 0) + (l1Id !== void 0 ? 1 : 0);
17566
+ let l2 = null;
17567
+ let l1 = null;
17568
+ let used = 1;
17569
+ let truncated = false;
17570
+ if (need > 0 && remaining < need) {
17571
+ truncated = true;
17572
+ if (l2Id !== void 0) l2Marker = "capped";
17573
+ if (l1Id !== void 0) l1Marker = "capped";
17574
+ } else {
17575
+ used += need;
17576
+ const [r2, r1] = await Promise.all([
17577
+ l2Id !== void 0 ? get(`/V1.0/OrganizationalLevel2s/${l2Id}`) : Promise.resolve(null),
17578
+ l1Id !== void 0 ? get(`/V1.0/OrganizationalLevel1s/${l1Id}`) : Promise.resolve(null)
17579
+ ]);
17580
+ l2 = r2;
17581
+ l1 = r1;
17582
+ if (l2Id !== void 0) l2Marker = l2 === null ? "unresolved" : "ok";
17583
+ if (l1Id !== void 0) l1Marker = l1 === null ? "unresolved" : "ok";
17584
+ }
17585
+ return {
17586
+ lineOfBusiness: { association, organizationalLevel2: l2, organizationalLevel1: l1, markers: { association: associationMarker, organizationalLevel2: l2Marker, organizationalLevel1: l1Marker } },
17587
+ used,
17588
+ truncated
17589
+ };
17590
+ }
17591
+
17592
+ // src/operations/compositeNames.ts
17593
+ var PICKLIST_VALUE_NOT_SERVED_NOTE = "The picklist is served to this API user, but this record's value is not in it (an inactive or unexposed value) \u2014 the label cannot be resolved and is null; refreshing the field catalog will not surface it. (A picklist the vendor does not expose at all is reported with the field-catalog honesty note instead.)";
17594
+ var DEFAULT_MAX_NAME_READS = 20;
17595
+ var TICKET_LABEL_FIELDS = ["status", "priority", "ticketType", "issueType", "subIssueType", "source", "queueID"];
17596
+ function isVendorId(value) {
17597
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0;
17598
+ if (typeof value === "string") return /^\d+$/.test(value);
17599
+ return false;
17600
+ }
17601
+ var zeroIsRecord = (table) => table === "company";
17602
+ function createNamePlan(labelFields = [], labelResource) {
17603
+ const resourceIds = [];
17604
+ const resSeen = /* @__PURE__ */ new Set();
17605
+ const recordFks = [];
17606
+ const fkSeen = /* @__PURE__ */ new Set();
17607
+ let lineOfBusinessId;
17608
+ let lineOfBusinessSource;
17609
+ let lineOfBusinessContractId;
17610
+ return {
17611
+ plan: () => ({
17612
+ resourceIds,
17613
+ recordFks,
17614
+ labelFields,
17615
+ ...labelResource !== void 0 ? { labelResource } : {},
17616
+ ...lineOfBusinessId !== void 0 ? { lineOfBusinessId } : {},
17617
+ ...lineOfBusinessSource !== void 0 ? { lineOfBusinessSource } : {},
17618
+ ...lineOfBusinessContractId !== void 0 ? { lineOfBusinessContractId } : {}
17619
+ }),
17620
+ addResource: (value) => {
17621
+ if (!isVendorId(value) || Number(value) === 0) return;
17622
+ const k = String(value);
17623
+ if (!resSeen.has(k)) {
17624
+ resSeen.add(k);
17625
+ resourceIds.push(value);
17626
+ }
17627
+ },
17628
+ addFk: (table, value) => {
17629
+ if (!isVendorId(value) || Number(value) === 0 && !zeroIsRecord(table)) return;
17630
+ const k = `${table}:${String(value)}`;
17631
+ if (!fkSeen.has(k)) {
17632
+ fkSeen.add(k);
17633
+ recordFks.push({ table, id: value });
17634
+ }
17635
+ },
17636
+ addLineOfBusinessFromContract: (value) => {
17637
+ if (!isVendorId(value)) return;
17638
+ lineOfBusinessContractId = value;
17639
+ },
17640
+ addLineOfBusiness: (value, origin) => {
17641
+ if (!isVendorId(value)) return;
17642
+ lineOfBusinessId = value;
17643
+ if (origin !== void 0) {
17644
+ lineOfBusinessSource = origin.source;
17645
+ if (origin.contractId !== void 0) lineOfBusinessContractId = origin.contractId;
17646
+ }
17647
+ }
17648
+ };
17649
+ }
17650
+ function addNameSibling(row, field, resolve) {
17651
+ const value = row[field];
17652
+ if (value === void 0) return;
17653
+ row[`${field}Name`] = isVendorId(value) ? resolve(value) : null;
17654
+ }
17655
+ function addLabelSibling(row, field, names) {
17656
+ const value = row[field];
17657
+ if (value === void 0) return;
17658
+ row[`${field}Label`] = names.labelFor(field, value);
17659
+ }
17660
+ var FK_TABLE_PATH = {
17661
+ role: "/V1.0/Roles",
17662
+ billingCode: "/V1.0/BillingCodes",
17663
+ contract: "/V1.0/Contracts",
17664
+ company: "/V1.0/Companies",
17665
+ ticket: "/V1.0/Tickets"
17666
+ };
17667
+ var FK_NAME_FIELD = {
17668
+ role: "name",
17669
+ billingCode: "name",
17670
+ contract: "contractName",
17671
+ company: "companyName",
17672
+ ticket: "ticketNumber"
17673
+ };
17674
+ function fkName(record, table) {
17675
+ if (table === "ticket") {
17676
+ const parts = [record["ticketNumber"], record["title"]].filter((v) => typeof v === "string" && v.trim() !== "").map((v) => v.trim());
17677
+ return parts.length > 0 ? parts.join(" ") : null;
17678
+ }
17679
+ const value = record[FK_NAME_FIELD[table]];
17680
+ return typeof value === "string" && value.trim() !== "" ? value : null;
17681
+ }
17682
+ function faultRow(err) {
17683
+ const e = err;
17684
+ return e.httpStatus !== void 0 ? { code: e.code, message: e.message, httpStatus: e.httpStatus } : { code: e.code, message: e.message };
17685
+ }
17686
+ async function resolveCompositeNames(http, plan, opts = {}) {
17687
+ if (opts.maxNameReads !== void 0 && (!Number.isInteger(opts.maxNameReads) || opts.maxNameReads < 1)) {
17688
+ throw new AutotaskConfigError(`composite names: maxNameReads must be an integer >= 1 (got ${String(opts.maxNameReads)})`);
17689
+ }
17690
+ const budget = Math.min(DEFAULT_MAX_NAME_READS, opts.maxNameReads ?? DEFAULT_MAX_NAME_READS);
17691
+ const client = http.client;
17692
+ if (client === void 0) {
17693
+ throw new AutotaskConfigError("composite names: the transport is not wired to an AutotaskClient (the composite must run through the client)");
17694
+ }
17695
+ const readCache = http.readCache;
17696
+ const section = { truncated: false, rows: {}, reasons: {} };
17697
+ let remaining = budget;
17698
+ let contractLobPointer;
17699
+ let contractLobUnread;
17700
+ const coldResource = [];
17701
+ for (const id of plan.resourceIds) {
17702
+ const path = `/V1.0/Resources/${id}`;
17703
+ if (readCache !== void 0 && (readCache.peek(path).hit || readCache.peek(contextAbsentKey(path)).hit)) continue;
17704
+ coldResource.push(id);
17705
+ }
17706
+ const withinBudget = coldResource.slice(0, remaining);
17707
+ const overBudgetResource = coldResource.slice(remaining);
17708
+ remaining -= withinBudget.length;
17709
+ for (const id of overBudgetResource) {
17710
+ section.rows[`resource:${id}`] = { name: null, blind: false };
17711
+ section.truncated = true;
17712
+ }
17713
+ const overSet = new Set(overBudgetResource.map((id) => String(id)));
17714
+ const opIds = plan.resourceIds.filter((id) => !overSet.has(String(id)));
17715
+ if (opIds.length > 0) {
17716
+ let pass = opIds;
17717
+ if (pass.length > 500) {
17718
+ for (const id of pass.slice(500)) {
17719
+ section.rows[`resource:${id}`] = { name: null, blind: false };
17720
+ section.truncated = true;
17721
+ }
17722
+ pass = pass.slice(0, 500);
17723
+ }
17724
+ const res = await expandResourceNames(client, pass);
17725
+ for (const r of res.results) {
17726
+ const row = { name: r.name, blind: r.blind };
17727
+ if (r.error !== void 0) row.error = faultRow(r.error);
17728
+ section.rows[`resource:${r.id}`] = row;
17729
+ }
17730
+ }
17731
+ const holders = {
17732
+ role: client.roles,
17733
+ billingCode: client.billingCodes,
17734
+ contract: client.contracts,
17735
+ company: client.companies,
17736
+ ticket: client.tickets
17737
+ };
17738
+ for (const fk of plan.recordFks) {
17739
+ const key = `${fk.table}:${fk.id}`;
17740
+ const cold = readCache === void 0 || readCache.get(`${FK_TABLE_PATH[fk.table]}/${fk.id}`) === void 0;
17741
+ if (cold) {
17742
+ if (remaining < 1) {
17743
+ section.rows[key] = { name: null, blind: false };
17744
+ section.truncated = true;
17745
+ if (fk.table === "contract" && plan.lineOfBusinessContractId !== void 0 && String(fk.id) === String(plan.lineOfBusinessContractId)) {
17746
+ contractLobUnread = "capped";
17747
+ }
17748
+ continue;
17749
+ }
17750
+ remaining -= 1;
17751
+ }
17752
+ try {
17753
+ const record = await holders[fk.table].get(fk.id);
17754
+ if (fk.table === "contract" && plan.lineOfBusinessId === void 0 && plan.lineOfBusinessContractId !== void 0 && String(fk.id) === String(plan.lineOfBusinessContractId)) {
17755
+ contractLobPointer = vendorId(record["organizationalLevelAssociationID"]);
17756
+ }
17757
+ section.rows[key] = { name: fkName(record, fk.table), blind: false };
17758
+ } catch (err) {
17759
+ if (err instanceof NotFoundError) {
17760
+ section.rows[key] = { name: null, blind: true };
17761
+ } else if (isAutotaskError(err) && err.code !== "ABORTED") {
17762
+ section.rows[key] = { name: null, blind: false, error: faultRow(err) };
17763
+ if (fk.table === "contract" && plan.lineOfBusinessContractId !== void 0 && String(fk.id) === String(plan.lineOfBusinessContractId)) {
17764
+ contractLobUnread = "unresolved";
17765
+ }
17766
+ } else {
17767
+ throw err;
17768
+ }
17769
+ }
17770
+ }
17771
+ const picklists = {};
17772
+ if (plan.labelFields.length > 0) {
17773
+ try {
17774
+ const labelResource = plan.labelResource ?? "tickets";
17775
+ const catalog = await fieldCatalog(client, { resources: [labelResource], labels: true, fields: [...plan.labelFields] });
17776
+ const row = catalog.rows.find((r) => r.resource === labelResource);
17777
+ if (row !== void 0 && row.error === null && row.fields !== void 0) {
17778
+ for (const f of plan.labelFields) {
17779
+ const frow = row.fields.find((x) => x.name === f);
17780
+ picklists[f] = frow?.picklistValues;
17781
+ }
17782
+ }
17783
+ } catch (err) {
17784
+ if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
17785
+ }
17786
+ }
17787
+ let lineOfBusiness2 = null;
17788
+ const lobId = plan.lineOfBusinessId ?? contractLobPointer;
17789
+ if (lobId !== void 0) {
17790
+ const chain = await resolveLineOfBusinessChain(http, lobId, { budget: remaining, onError: "degrade" });
17791
+ remaining -= chain.used;
17792
+ if (chain.truncated) section.truncated = true;
17793
+ const fromContract = plan.lineOfBusinessId === void 0;
17794
+ lineOfBusiness2 = {
17795
+ ...chain.lineOfBusiness,
17796
+ ...fromContract ? { source: "contract", contractId: plan.lineOfBusinessContractId } : plan.lineOfBusinessSource !== void 0 ? { source: plan.lineOfBusinessSource } : {},
17797
+ ...!fromContract && plan.lineOfBusinessContractId !== void 0 ? { contractId: plan.lineOfBusinessContractId } : {}
17798
+ };
17799
+ } else if (contractLobUnread !== void 0 && plan.lineOfBusinessContractId !== void 0) {
17800
+ lineOfBusiness2 = {
17801
+ source: "contract",
17802
+ contractId: plan.lineOfBusinessContractId,
17803
+ association: null,
17804
+ organizationalLevel2: null,
17805
+ organizationalLevel1: null,
17806
+ markers: { association: contractLobUnread, organizationalLevel2: "missing", organizationalLevel1: "missing" }
17807
+ };
17808
+ }
17809
+ return {
17810
+ section,
17811
+ lineOfBusiness: lineOfBusiness2,
17812
+ resourceName: (id) => section.rows[`resource:${id}`]?.name ?? null,
17813
+ recordName: (table, id) => section.rows[`${table}:${id}`]?.name ?? null,
17814
+ labelFor: (field, value) => {
17815
+ if (value === null || value === void 0) return null;
17816
+ const values = picklists[field];
17817
+ if (values === void 0) {
17818
+ section.reasons[field] = FIELD_CATALOG_HONESTY_NOTE;
17819
+ return null;
17820
+ }
17821
+ const hit = values.find((v) => String(v.value) === String(value));
17822
+ if (hit === void 0) {
17823
+ if (String(value) !== "0") section.reasons[field] = PICKLIST_VALUE_NOT_SERVED_NOTE;
17824
+ return null;
17825
+ }
17826
+ return typeof hit.label === "string" ? hit.label : null;
17827
+ }
17828
+ };
17829
+ }
17830
+
17831
+ // src/resources/base/types.ts
17832
+ var BOUNDED_QUERY_DEFAULT_LIMIT = 500;
17833
+ var BOUNDED_QUERY_MAX_LIMIT = 2500;
17834
+ var LATEST_MAX_COUNT_PROBES = 64;
17835
+ var MARKER_RECOVERY_LIMIT = 5;
17836
+
17837
+ // src/field-sets.ts
17838
+ var MODEL_FIELD_SETS = {
17839
+ "appointments": ["id", "createDateTime", "creatorResourceID", "description", "endDateTime", "resourceID", "startDateTime", "title", "updateDateTime"],
17840
+ "billing_codes": ["id", "afterHoursWorkType", "billingCodeType", "department", "description", "externalNumber", "generalLedgerAccount", "isActive", "isExcludedFromNewContracts", "markupRate", "name", "taxCategoryID", "unitCost", "unitPrice", "useType"],
17841
+ "billing_item_approval_levels": ["id", "approvalDateTime", "approvalLevel", "approvalResourceID", "timeEntryID"],
17842
+ "billing_items": ["id", "accountManagerWhenApprovedID", "billingCodeID", "billingItemType", "companyID", "configurationItemID", "contractBlockID", "contractChargeID", "contractID", "contractServiceAdjustmentID", "contractServiceBundleAdjustmentID", "contractServiceBundleID", "contractServiceBundlePeriodID", "contractServiceID", "contractServicePeriodID", "description", "expenseItemID", "extendedPrice", "internalCurrencyExtendedPrice", "internalCurrencyRate", "internalCurrencyTaxDollars", "internalCurrencyTotalAmount", "invoiceID", "itemApproverID", "itemDate", "itemName", "lineItemFullDescription", "lineItemGroupDescription", "milestoneID", "nonBillable", "organizationalLevelAssociationID", "ourCost", "postedDate", "postedOnTime", "projectChargeID", "projectID", "purchaseOrderNumber", "quantity", "rate", "roleID", "serviceBundleID", "serviceID", "sortOrderID", "subType", "taskID", "taxDollars", "ticketChargeID", "ticketID", "timeEntryID", "totalAmount", "vendorID", "webServiceDate"],
17843
+ "change_request_links": ["id", "changeRequestTicketID", "problemOrIncidentTicketID"],
17844
+ "checklist_libraries": ["id", "description", "entityType", "isActive", "name"],
17845
+ "checklist_library_checklist_items": ["id", "checklistLibraryID", "isImportant", "itemName", "knowledgebaseArticleID", "position", "soapParentPropertyId"],
17564
17846
  "classification_icons": ["id", "description", "isActive", "isSystem", "name", "relativeUrl"],
17565
17847
  "companies": ["id", "additionalAddressInformation", "address1", "address2", "alternatePhone1", "alternatePhone2", "apiVendorID", "assetValue", "billToCompanyLocationID", "billToAdditionalAddressInformation", "billingAddress1", "billingAddress2", "billToAddressToUse", "billToAttention", "billToCity", "billToCountryID", "billToState", "billToZipCode", "city", "classification", "companyCategoryID", "companyName", "companyNumber", "companyType", "competitorID", "countryID", "createDate", "createdByResourceID", "currencyID", "fax", "impersonatorCreatorResourceID", "invoiceEmailMessageID", "invoiceMethod", "invoiceNonContractItemsToParentCompany", "invoiceTemplateID", "isActive", "isClientPortalActive", "isEnabledForComanaged", "isSample", "isTaskFireActive", "isTaxExempt", "lastActivityDate", "lastTrackedModifiedDateTime", "marketSegmentID", "ownerResourceID", "parentCompanyID", "phone", "postalCode", "purchaseOrderTemplateID", "quoteEmailMessageID", "quoteTemplateID", "sicCode", "state", "stockMarket", "stockSymbol", "surveyCompanyRating", "taxID", "taxRegionID", "territoryID", "webAddress", "userDefinedFields"],
17566
17848
  "company_alerts": ["id", "alertText", "alertTypeID", "companyID", "soapParentPropertyId"],
@@ -17743,12 +18025,6 @@ var MODEL_DATE_FIELDS = {
17743
18025
  "time_off_requests": ["approvedDateTime", "createDateTime", "endTime", "lastModifiedDateTime", "requestDate", "startTime"]
17744
18026
  };
17745
18027
 
17746
- // src/resources/base/types.ts
17747
- var BOUNDED_QUERY_DEFAULT_LIMIT = 500;
17748
- var BOUNDED_QUERY_MAX_LIMIT = 2500;
17749
- var LATEST_MAX_COUNT_PROBES = 64;
17750
- var MARKER_RECOVERY_LIMIT = 5;
17751
-
17752
18028
  // src/resources/special/_shared/bounded-part.ts
17753
18029
  async function boundedPart(http, path, conditions, limit, operation, includeFields) {
17754
18030
  const body = { MaxRecords: limit, filter: conditions };
@@ -17781,6 +18057,10 @@ function relationsFor(resourceKey) {
17781
18057
  const fields = MODEL_FIELD_SETS[resourceKey] ?? [];
17782
18058
  return Object.keys(RELATION_TARGETS).filter((rel) => fields.includes(`${rel}ID`));
17783
18059
  }
18060
+ function selfPrefixes(resourceKey) {
18061
+ const rels = Object.keys(RELATION_TARGETS).filter((rel) => RELATION_TARGETS[rel].resource === resourceKey);
18062
+ return [resourceKey, ...rels];
18063
+ }
17784
18064
  var isRelationLeaf = (c) => isRecord2(c) && c.op !== "and" && c.op !== "or" && typeof c.field === "string" && c.field.includes(".");
17785
18065
  function hasRelationConditions(conditions) {
17786
18066
  if (conditions === void 0) return false;
@@ -17800,6 +18080,10 @@ function validateRelationLeaves(resourceKey, conditions, op) {
17800
18080
  suggestedAction: valid.length > 0 ? `Use ${valid.map((r) => `${r}.<${RELATION_TARGETS[r].resource} field>`).join(" or ")} (one hop; field names from operations.fieldCatalog).` : `${resourceKey} has no relation filters; filter on its own fields.`
17801
18081
  });
17802
18082
  };
18083
+ if (selfPrefixes(resourceKey).includes(rel)) {
18084
+ if (rest.length > 0 || !sub) fail("is not <resource>.<field> (multi-hop relations are not supported)");
18085
+ continue;
18086
+ }
17803
18087
  if (c.udf === true) fail("cannot be a UDF condition");
17804
18088
  if (rest.length > 0 || !sub) fail("is not <relation>.<field> (multi-hop relations are not supported)");
17805
18089
  if (!valid.includes(rel)) fail(`names an unknown relation for ${resourceKey} (valid: ${valid.join(", ") || "none"})`);
@@ -17833,7 +18117,12 @@ async function rewriteRelationFilters(http, resourceKey, conditions, op) {
17833
18117
  const byRel = /* @__PURE__ */ new Map();
17834
18118
  for (const c of list) {
17835
18119
  if (isRelationLeaf(c)) {
17836
- const rel = c.field.split(".")[0];
18120
+ const parts = c.field.split(".");
18121
+ if (parts.length === 2 && selfPrefixes(resourceKey).includes(parts[0])) {
18122
+ out.push({ ...c, field: parts[1] });
18123
+ continue;
18124
+ }
18125
+ const rel = parts[0];
17837
18126
  if (mode === "and") {
17838
18127
  byRel.set(rel, [...byRel.get(rel) ?? [], c]);
17839
18128
  continue;
@@ -17851,20 +18140,128 @@ async function rewriteRelationFilters(http, resourceKey, conditions, op) {
17851
18140
  out.push({ ...c, items });
17852
18141
  continue;
17853
18142
  }
17854
- out.push(c);
18143
+ out.push(c);
18144
+ }
18145
+ for (const [rel, leaves] of byRel) {
18146
+ const ids2 = await parentIds(rel, leaves);
18147
+ if (ids2.length === 0) return NEVER;
18148
+ out.push({ field: `${rel}ID`, op: "in", value: ids2 });
18149
+ }
18150
+ return out.length === 0 && mode === "or" ? NEVER : out;
18151
+ };
18152
+ const rewritten = await rewrite(conds, "and");
18153
+ if (rewritten === NEVER) {
18154
+ return { never: true, note: `no parent record matched the relation conditions \u2014 0 ${resourceKey} rows (the ${resourceKey} query was not issued)` };
18155
+ }
18156
+ return { never: false, conditions: rewritten };
18157
+ }
18158
+
18159
+ // src/operations/rowLabels.ts
18160
+ var ROW_FK_TABLES = {
18161
+ companyID: "company",
18162
+ contractID: "contract",
18163
+ ticketID: "ticket",
18164
+ roleID: "role",
18165
+ billingCodeID: "billingCode"
18166
+ };
18167
+ var isResourceFkField = (field) => field === "resourceID" || field.endsWith("ResourceID");
18168
+ var LABEL_NAME_MAX_CHARS = 200;
18169
+ function untrustedName(name) {
18170
+ if (name === null) return null;
18171
+ const [[capped]] = truncateNoteChars([{ v: name }], LABEL_NAME_MAX_CHARS, ["v"]);
18172
+ return wrapUntrusted(capped.v) ?? capped.v;
18173
+ }
18174
+ function readNamesOpt(client, value, where) {
18175
+ if (value === void 0) return client?.config?.labels ?? false;
18176
+ if (typeof value !== "boolean") {
18177
+ throw new AutotaskConfigError(`${where}: names must be a boolean (got ${typeof value}) \u2014 true adds label siblings, false returns raw ids only, absent uses the client's labels option (off by default)`);
18178
+ }
18179
+ return value;
18180
+ }
18181
+ async function labelRows(client, resourceKey, rows) {
18182
+ const keys = /* @__PURE__ */ new Set();
18183
+ for (const row of rows) for (const k of Object.keys(row)) keys.add(k);
18184
+ if (keys.size === 0) return { rows: [...rows] };
18185
+ let picklistFields = [];
18186
+ const modelFields = MODEL_FIELD_SETS[resourceKey] ?? [];
18187
+ const candidates = [...keys].filter((f) => f !== "id" && modelFields.includes(f) && ROW_FK_TABLES[f] === void 0 && !isResourceFkField(f));
18188
+ if (candidates.length > 0) {
18189
+ try {
18190
+ const catalog = await fieldCatalog(client, { resources: [resourceKey], labels: true });
18191
+ const row = catalog.rows.find((r) => r.resource === resourceKey);
18192
+ if (row !== void 0 && row.error === null && row.fields !== void 0) {
18193
+ picklistFields = row.fields.filter((f) => f.isPickList).map((f) => f.name).filter((f) => candidates.includes(f));
18194
+ }
18195
+ } catch (err) {
18196
+ if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
17855
18197
  }
17856
- for (const [rel, leaves] of byRel) {
17857
- const ids2 = await parentIds(rel, leaves);
17858
- if (ids2.length === 0) return NEVER;
17859
- out.push({ field: `${rel}ID`, op: "in", value: ids2 });
18198
+ }
18199
+ const labelled = (field) => picklistFields.includes(field) || ROW_FK_TABLES[field] !== void 0 || isResourceFkField(field);
18200
+ if (![...keys].some(labelled)) return { rows: [...rows] };
18201
+ const builder = createNamePlan(picklistFields, resourceKey);
18202
+ for (const row of rows) {
18203
+ for (const [field, value] of Object.entries(row)) {
18204
+ const table = ROW_FK_TABLES[field];
18205
+ if ((value === 0 || value === "0") && table !== "company") continue;
18206
+ if (table !== void 0) builder.addFk(table, value);
18207
+ else if (isResourceFkField(field)) builder.addResource(value);
17860
18208
  }
17861
- return out.length === 0 && mode === "or" ? NEVER : out;
17862
- };
17863
- const rewritten = await rewrite(conds, "and");
17864
- if (rewritten === NEVER) {
17865
- return { never: true, note: `no parent record matched the relation conditions \u2014 0 ${resourceKey} rows (the ${resourceKey} query was not issued)` };
17866
18209
  }
17867
- return { never: false, conditions: rewritten };
18210
+ const plan = builder.plan();
18211
+ const names = plan.resourceIds.length === 0 && plan.recordFks.length === 0 && picklistFields.length === 0 ? void 0 : await resolveCompositeNames(client.http, plan);
18212
+ const out = rows.map((row) => {
18213
+ const r = { ...row };
18214
+ for (const [field, value] of Object.entries(row)) {
18215
+ const has2 = value !== null && value !== void 0 && names !== void 0;
18216
+ if (picklistFields.includes(field)) r[`${field}Label`] = has2 ? names.labelFor(field, value) : null;
18217
+ const table = ROW_FK_TABLES[field];
18218
+ const set = has2 && (table === "company" || value !== 0 && value !== "0");
18219
+ if (table !== void 0) r[`${field}Name`] = set && isVendorId(value) ? untrustedName(names.recordName(table, value)) : null;
18220
+ else if (isResourceFkField(field)) r[`${field}Name`] = set && isVendorId(value) ? untrustedName(names.resourceName(value)) : null;
18221
+ }
18222
+ return r;
18223
+ });
18224
+ const capHit = names?.section.truncated === true;
18225
+ const unresolved = Object.keys(names?.section.reasons ?? {});
18226
+ if (capHit === false && unresolved.length === 0) return { rows: out };
18227
+ const parts = [];
18228
+ if (capHit) parts.push(`ids left unlabelled (their *Name is null): the per-call cap of ${DEFAULT_MAX_NAME_READS} uncached name reads was hit \u2014 names already read are cached, so repeating or narrowing the call labels more`);
18229
+ if (unresolved.length > 0) parts.push(`picklist label(s) unavailable for ${unresolved.join(", ")} (see reasons)`);
18230
+ return { rows: out, names: { note: parts.join("; "), ...Object.keys(names.section.reasons).length > 0 ? { reasons: names.section.reasons } : {} } };
18231
+ }
18232
+ async function labelFetchResults(client, result) {
18233
+ const byResource = /* @__PURE__ */ new Map();
18234
+ result.results.forEach((r, i) => {
18235
+ if (r.found && r.value !== null) byResource.set(r.resource, [...byResource.get(r.resource) ?? [], i]);
18236
+ });
18237
+ const results = [...result.results];
18238
+ const notes = [];
18239
+ const reasons = {};
18240
+ for (const [resource, indices] of byResource) {
18241
+ const labelled = await labelRows(client, resource, indices.map((i) => results[i].value));
18242
+ indices.forEach((idx, j) => {
18243
+ results[idx] = { ...results[idx], value: labelled.rows[j] };
18244
+ });
18245
+ if (labelled.names !== void 0) {
18246
+ notes.push(labelled.names.note);
18247
+ for (const [field, why] of Object.entries(labelled.names.reasons ?? {})) reasons[field] = why;
18248
+ }
18249
+ }
18250
+ if (notes.length === 0) return { ...result, results };
18251
+ return { ...result, results, names: { note: notes.join("; "), ...Object.keys(reasons).length > 0 ? { reasons } : {} } };
18252
+ }
18253
+
18254
+ // src/operations/walkBag.ts
18255
+ function validateWalkBag(walk, prefix, details) {
18256
+ if (walk === void 0) return void 0;
18257
+ const wr = typeof walk === "object" && walk !== null && !Array.isArray(walk) ? walk.maxRows : void 0;
18258
+ if (!Number.isInteger(wr) || wr < 1) {
18259
+ throw new AutotaskConfigError(
18260
+ `${prefix} walk.maxRows must be a positive integer (got ${String(wr)}) \u2014 it is the walk's TOTAL row cap (the bounding cap = min(limit, walk.maxRows))`,
18261
+ details
18262
+ );
18263
+ }
18264
+ return wr;
17868
18265
  }
17869
18266
 
17870
18267
  // src/resources/base/query.ts
@@ -17968,19 +18365,19 @@ var QueryBase = class {
17968
18365
  /**
17969
18366
  * Stream items across pages (the resource `query` surface — the MCP
17970
18367
  * server's `output_path` walk). Issue #281: relation filters resolve first
17971
- * (as queryBounded), and each page's rows carry the default label siblings
17972
- * (issue #276, the same `labelRows`) unless `names: false`. Labelling runs
17973
- * per page, so the cold name-read cap (20) applies per page; a capped id
17974
- * gets a null `*Name` (an iterator has no result envelope for the `names`
17975
- * summary). A resource built without a client (bare `HttpClient`) yields
17976
- * raw rows.
18368
+ * (as queryBounded). Label siblings are opt-in (issue #287): the per-call
18369
+ * `names` key is the explicit override, absent = the client's `labels`
18370
+ * option (off by default). When on, labelling runs per page, so the cold
18371
+ * name-read cap (20) applies per page; a capped id gets a null `*Name`
18372
+ * (an iterator has no result envelope for the `names` summary). A
18373
+ * resource built without a client (bare `HttpClient`) yields raw rows.
17977
18374
  */
17978
18375
  async *queryItems(params) {
17979
18376
  const { names, ...rest } = params ?? {};
17980
- const label = readNamesOpt(names, `${this.config.key}.query`);
18377
+ const client = this.http.client;
18378
+ const label = readNamesOpt(client, names, `${this.config.key}.query`);
17981
18379
  const walk = await this.relationWalkParams(rest, `${this.config.key}.query`);
17982
18380
  if (walk === void 0) return;
17983
- const client = this.http.client;
17984
18381
  if (!label || client === void 0) {
17985
18382
  yield* paginateItems(this.pageFetcher(walk), { pageSize: this.walkPageSize(walk) });
17986
18383
  return;
@@ -18049,8 +18446,7 @@ var QueryBase = class {
18049
18446
  `queryBounded: noteChars must be an integer >= 1 (got ${String(noteChars)}) \u2014 it is the per-field char cap on the untrusted-class free-text fields (absent = the raw, untruncated result)`
18050
18447
  );
18051
18448
  }
18052
- let walkParams = params?.fieldsPreset !== void 0 ? { ...params, includeFields: resolveFieldsPreset(params.fieldsPreset, this.config.key, params.includeFields) } : params ?? {};
18053
- const latest = walkParams.latest;
18449
+ const latest = params?.latest;
18054
18450
  if (latest !== void 0) {
18055
18451
  if (!Number.isInteger(latest) || latest < 1 || latest > BOUNDED_QUERY_MAX_LIMIT) {
18056
18452
  throw new AutotaskConfigError(
@@ -18063,15 +18459,27 @@ var QueryBase = class {
18063
18459
  );
18064
18460
  }
18065
18461
  }
18462
+ const walk = params?.walk;
18463
+ const maxRows = validateWalkBag(walk, "queryBounded:");
18464
+ if (walk !== void 0 && latest !== void 0) {
18465
+ throw new AutotaskConfigError(
18466
+ "queryBounded: walk.maxRows is not combinable with latest \u2014 the count-anchored mechanism has its own walk window"
18467
+ );
18468
+ }
18469
+ const cap = maxRows !== void 0 ? Math.min(limit, maxRows) : limit;
18470
+ let walkParams = params ?? {};
18066
18471
  const relation = await rewriteRelationFilters(this.http, this.config.key, normalizedConditions(walkParams.filter), `${this.config.key}.query`);
18067
18472
  if (relation?.never === true) return { items: [], fetched: 0, hasMore: false, pagesWalked: 0, relationNote: relation.note };
18068
18473
  if (relation !== void 0) walkParams = { ...walkParams, filter: relation.conditions };
18069
18474
  if (latest !== void 0) {
18070
18475
  return this.applyNoteChars(await this.latestWalk(walkParams, Math.max(limit, latest), latest), noteChars);
18071
18476
  }
18072
- const { items, pages, guard, sliced, cursor } = await this.walkBounded(walkParams, limit);
18073
- const hasMore = (items.length >= limit || guard) && (sliced > 0 || cursor !== null);
18477
+ const { items, pages, guard, sliced, cursor } = await this.walkBounded(walkParams, cap, limit);
18478
+ const hasMore = (items.length >= cap || guard) && (sliced > 0 || cursor !== null);
18074
18479
  const result = { items, fetched: items.length, hasMore, pagesWalked: pages };
18480
+ if (maxRows !== void 0) {
18481
+ result.truncated = items.length >= maxRows && (sliced > 0 || cursor !== null || guard);
18482
+ }
18075
18483
  this.stampEmptyQueryResult(result, params);
18076
18484
  return this.applyNoteChars(result, noteChars);
18077
18485
  }
@@ -18102,13 +18510,13 @@ var QueryBase = class {
18102
18510
  * SOFT stop (never a throw). ONE per-walk closure (walks never share
18103
18511
  * state).
18104
18512
  * @internal */
18105
- async walkBounded(p, limit) {
18513
+ async walkBounded(p, limit, pageSizeBase) {
18106
18514
  const state = { cursor: null };
18107
18515
  const items = [];
18108
18516
  let pages = 0;
18109
18517
  let sliced = 0;
18110
18518
  let guard = false;
18111
- const pageSize = Math.min(MAX_PAGE_SIZE, limit);
18519
+ const pageSize = Math.min(MAX_PAGE_SIZE, pageSizeBase ?? limit);
18112
18520
  for (; ; ) {
18113
18521
  if (pages >= MAX_PAGES) {
18114
18522
  guard = true;
@@ -18724,1333 +19132,884 @@ var MetaBase = class extends QueryBase {
18724
19132
  * `searchFields`, capped by the client caps (`maxScanRecords` /
18725
19133
  * `maxScanPages`). A hit is cost `client-scan`; a completed scan with no
18726
19134
  * match → null; a cap-tripped scan THROWS RESOLUTION_TRUNCATED — never a
18727
- * silent null.
18728
- * - bare value on an id-only resource → AutotaskConfigError (CONFIG_ERROR).
18729
- */
18730
- async resolveDetailed(identifier) {
18731
- let numeric = null;
18732
- if (isIdentifierCandidate(identifier)) {
18733
- numeric = parseIdentifier(identifier, "id");
18734
- }
18735
- if (numeric !== null) {
18736
- const record = await this.fetchRecordRaw(this.resourcePath, numeric);
18737
- return { value: record, resolutionCost: "direct", scanned: 0, scanTruncated: false };
18738
- }
18739
- if (typeof identifier !== "string" || identifier.trim() === "") {
18740
- throw new AutotaskConfigError("resolve: identifier must be a numeric id or a non-empty value");
18741
- }
18742
- const value = identifier.trim();
18743
- const fields = this.config.searchFields ?? [];
18744
- if (fields.length === 0) {
18745
- throw new AutotaskConfigError(
18746
- `resolve: ${this.config.key} has no primary text field (ids only)`
18747
- );
18748
- }
18749
- const leaf = fields.map((field) => ({ field, op: "eq", value }));
18750
- const root = leaf.length === 1 ? leaf[0] : { op: "or", items: leaf };
18751
- const items = await this.queryPage1([root], 2);
18752
- if (items.length === 1) {
18753
- return { value: items[0], resolutionCost: "server-filter", scanned: 0, scanTruncated: false };
18754
- }
18755
- if (items.length >= 2) {
18756
- const ids2 = items.map((m) => m.id).filter((id) => typeof id === "number");
18757
- throw new ResolutionError(
18758
- `resolve: "${value}" is ambiguous on ${this.config.key} (matched ${items.length} records)`,
18759
- {
18760
- code: "RESOLUTION_AMBIGUOUS",
18761
- resourceIds: ids2,
18762
- details: { idRefs: ids2.map((cid) => ({ entity: this.entityName, id: cid })) },
18763
- // issue #59 PR-B: the miss-channel pointer (the generic floor names
18764
- // the resource's own search op — reachable only where searchFields
18765
- // is non-empty, so the named op always exists here).
18766
- suggestedAction: `Narrow the identifier to the full exact value, or run ${this.config.key}.search (contains match) for candidate records to disambiguate.`
18767
- }
18768
- );
18769
- }
18770
- if (!this.resolutionCaps.scanOnMiss) {
18771
- return { value: null, resolutionCost: "server-filter", scanned: 0, scanTruncated: false, triedFields: fields };
18772
- }
18773
- const scan = await this.scanForExactMatch(value, fields);
18774
- if (scan.found !== null) {
18775
- return {
18776
- value: scan.found,
18777
- resolutionCost: "client-scan",
18778
- scanned: scan.scanned,
18779
- scanTruncated: false
18780
- };
18781
- }
18782
- if (scan.truncated) {
18783
- throw new ResolutionError(
18784
- `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`,
18785
- {
18786
- code: "RESOLUTION_TRUNCATED",
18787
- // issue #59 PR-B: the pointer channel on the unverified-miss outcome
18788
- // (the contains-candidates channel is the resource's search op).
18789
- suggestedAction: `Retry with a more specific identifier, or narrow the search to fewer records (contains candidates: ${this.config.key}.search).`
18790
- }
18791
- );
18792
- }
18793
- return { value: null, resolutionCost: "client-scan", scanned: scan.scanned, scanTruncated: false, triedFields: fields };
18794
- }
18795
- /**
18796
- * Issue #78: `resolve` with the miss explained. A hit is `{ record, resolutionCost }`
18797
- * (unchanged). A miss is never a bare `null`: `miss` carries the fields and operator
18798
- * tried, the blind-surface stamp when the surface is blind, and the next steps.
18799
- * Thrown outcomes (not found by id, ambiguous, truncated) are unchanged.
18800
- */
18801
- async resolveWithDetail(identifier) {
18802
- const r = await this.resolveDetailed(identifier);
18803
- if (r.value !== null) return { record: r.value, resolutionCost: r.resolutionCost };
18804
- return { record: null, resolutionCost: r.resolutionCost, miss: this.missDetail("eq", r.triedFields ?? this.config.searchFields ?? []) };
18805
- }
18806
- /**
18807
- * Issue #78: `search` with an empty result explained (a bare `[]` reads as complete on a
18808
- * blind surface). Hits are `{ items }` (unchanged); an empty result adds `miss`.
18809
- */
18810
- async searchWithDetail(query, opts = {}) {
18811
- const items = await this.searchBase(query, opts);
18812
- if (items.length > 0) return { items };
18813
- return { items, miss: this.missDetail("contains", this.config.searchFields ?? []) };
18814
- }
18815
- /** The shared miss explanation for `resolveWithDetail` / `searchWithDetail`. @internal */
18816
- missDetail(operator, fields) {
18817
- const key = this.config.key;
18818
- const probe = fields.map((field) => ({ field, op: operator, value: "" }));
18819
- const blind = blindStampFor(BLIND_SURFACE_STAMPS[key]?.query, probe);
18820
- const next = [];
18821
- if (blind !== void 0) {
18822
- next.push(`This surface is blind to records the API user does not own, so a miss is not proof of absence: ${blind.authority} returns the record when the id is known.`);
18823
- }
18824
- next.push(
18825
- operator === "eq" ? `Matching is exact on ${fields.join(", ")} (other identifier forms were not tried); ${key}.search finds contains candidates.` : `Matching is a contains match on ${fields.join(", ")}; try a shorter or different fragment, or ${key}.resolve with the full exact value.`
18826
- );
18827
- return { tried: { fields: [...fields], operator }, ...blind !== void 0 ? { blindSurface: blind } : {}, next };
18828
- }
18829
- /** The plain value projection of `resolveDetailed` (the `resolve` floor). */
18830
- async resolveBase(identifier) {
18831
- return (await this.resolveDetailed(identifier)).value;
18832
- }
18833
- /** The items of one page of the resource's own `/query`. */
18834
- async queryPage1(conditions, maxRecords) {
18835
- const body = {
18836
- filter: buildFilter(conditions).filter,
18837
- MaxRecords: maxRecords
18838
- };
18839
- const res = await this.http.request({
18840
- method: "POST",
18841
- idempotent: true,
18842
- path: `${this.resourcePath}/query`,
18843
- body,
18844
- operation: `${this.config.key}.resolve`
18845
- });
18846
- return unwrapItems(res, `${this.config.key}.resolve`).items;
18847
- }
18848
- /**
18849
- * The bounded client-scan fallback (section 19.2): an unfiltered walk of
18850
- * the resource's own query endpoint, exact match on `fields`, bounded by
18851
- * BOTH client caps (records AND pages). `truncated` is true when the caps
18852
- * were hit before the walk completed (a complete non-match is unproven).
18853
- */
18854
- async scanForExactMatch(value, fields) {
18855
- const caps = this.resolutionCaps;
18856
- const pageSize = Math.max(1, Math.floor(caps.maxScanRecords / caps.maxScanPages));
18857
- const state = { cursor: null };
18858
- let scanned = 0;
18859
- let pages = 0;
18860
- let found = null;
18861
- let exhausted = false;
18862
- for (; ; ) {
18863
- const page = await this.fetchPage({ maxRecords: pageSize }, pages + 1, pageSize, state);
18864
- pages += 1;
18865
- for (const item of page.items) {
18866
- scanned += 1;
18867
- const rec = item;
18868
- if (fields.some((field) => rec[field] === value)) {
18869
- found = item;
18870
- break;
18871
- }
18872
- }
18873
- if (found !== null) break;
18874
- if (!page.hasMore) {
18875
- exhausted = true;
18876
- break;
18877
- }
18878
- if (pages >= caps.maxScanPages || scanned >= caps.maxScanRecords) break;
18879
- }
18880
- return { found, scanned, truncated: found === null && !exhausted };
18881
- }
18882
- /**
18883
- * `findBy<field>` helpers: exact (`eq`) filter on one vendor field, bounded
18884
- * to 2 records — 0 → null, 1 → the record, ≥2 → ResolutionError with the
18885
- * candidate ids.
18886
- */
18887
- async findByField(field, value) {
18888
- if (value.trim() === "") throw new AutotaskConfigError(`findBy${field}: empty value`);
18889
- const matches = await this.queryEntity(
18890
- `${this.resourcePath}/query`,
18891
- [{ field, op: "eq", value: value.trim() }],
18892
- { maxRecords: 2 }
18893
- );
18894
- if (matches.length === 0) return null;
18895
- if (matches.length > 1) {
18896
- const ids2 = matches.map((m) => m.id).filter((id) => typeof id === "number");
18897
- throw new ResolutionError(
18898
- `findBy${field}: "${value}" is ambiguous on ${this.config.key} (matched ${matches.length} records)`,
18899
- { code: "RESOLUTION_AMBIGUOUS", resourceIds: ids2, details: { idRefs: ids2.map((cid) => ({ entity: this.entityName, id: cid })) } }
18900
- );
18901
- }
18902
- return matches[0];
18903
- }
18904
- /**
18905
- * `search` — the bounded contains surface (no vendor free-text search):
18906
- * `contains` on every `searchFields` field (OR-grouped), `MaxRecords` =
18907
- * `limit` (default 25, clamped to 1..500). Empty query → AutotaskConfigError.
19135
+ * silent null.
19136
+ * - bare value on an id-only resource → AutotaskConfigError (CONFIG_ERROR).
18908
19137
  */
18909
- async searchBase(query, opts = {}) {
18910
- const text = query.trim();
18911
- if (text === "") throw new AutotaskConfigError("search: empty query");
19138
+ async resolveDetailed(identifier) {
19139
+ let numeric = null;
19140
+ if (isIdentifierCandidate(identifier)) {
19141
+ numeric = parseIdentifier(identifier, "id");
19142
+ }
19143
+ if (numeric !== null) {
19144
+ const record = await this.fetchRecordRaw(this.resourcePath, numeric);
19145
+ return { value: record, resolutionCost: "direct", scanned: 0, scanTruncated: false };
19146
+ }
19147
+ if (typeof identifier !== "string" || identifier.trim() === "") {
19148
+ throw new AutotaskConfigError("resolve: identifier must be a numeric id or a non-empty value");
19149
+ }
19150
+ const value = identifier.trim();
18912
19151
  const fields = this.config.searchFields ?? [];
18913
19152
  if (fields.length === 0) {
18914
- throw new AutotaskConfigError(`search: ${this.config.key} has no searchable field`);
19153
+ throw new AutotaskConfigError(
19154
+ `resolve: ${this.config.key} has no primary text field (ids only)`
19155
+ );
18915
19156
  }
18916
- const limit = opts.limit === void 0 ? 25 : clampPageSize(opts.limit);
18917
- const leaf = fields.map((field) => ({ field, op: "contains", value: text }));
19157
+ const leaf = fields.map((field) => ({ field, op: "eq", value }));
18918
19158
  const root = leaf.length === 1 ? leaf[0] : { op: "or", items: leaf };
18919
- for (const key of Object.keys(opts)) {
18920
- if (key !== "limit" && key !== "companyID" && key !== "includeFields") {
18921
- throw new AutotaskConfigError(`search: unknown opts key "${key}" \u2014 the bag is closed: limit, companyID, includeFields`);
18922
- }
19159
+ const items = await this.queryPage1([root], 2);
19160
+ if (items.length === 1) {
19161
+ return { value: items[0], resolutionCost: "server-filter", scanned: 0, scanTruncated: false };
18923
19162
  }
18924
- if (opts.companyID !== void 0 && !isIdentifierCandidate(opts.companyID)) {
18925
- throw new AutotaskConfigError(`search: companyID must be a positive integer id (got ${String(opts.companyID)})`);
19163
+ if (items.length >= 2) {
19164
+ const ids2 = items.map((m) => m.id).filter((id) => typeof id === "number");
19165
+ throw new ResolutionError(
19166
+ `resolve: "${value}" is ambiguous on ${this.config.key} (matched ${items.length} records)`,
19167
+ {
19168
+ code: "RESOLUTION_AMBIGUOUS",
19169
+ resourceIds: ids2,
19170
+ details: { idRefs: ids2.map((cid) => ({ entity: this.entityName, id: cid })) },
19171
+ // issue #59 PR-B: the miss-channel pointer (the generic floor names
19172
+ // the resource's own search op — reachable only where searchFields
19173
+ // is non-empty, so the named op always exists here).
19174
+ suggestedAction: `Narrow the identifier to the full exact value, or run ${this.config.key}.search (contains match) for candidate records to disambiguate.`
19175
+ }
19176
+ );
18926
19177
  }
18927
- if (opts.includeFields !== void 0 && (!Array.isArray(opts.includeFields) || opts.includeFields.length === 0 || opts.includeFields.some((f) => typeof f !== "string" || f === ""))) {
18928
- throw new AutotaskConfigError("search: includeFields must be a non-empty array of field names");
19178
+ if (!this.resolutionCaps.scanOnMiss) {
19179
+ return { value: null, resolutionCost: "server-filter", scanned: 0, scanTruncated: false, triedFields: fields };
18929
19180
  }
18930
- const companyID = opts.companyID === void 0 ? void 0 : Number(opts.companyID);
18931
- const includeFields = opts.includeFields === void 0 ? void 0 : opts.includeFields.includes("id") ? opts.includeFields : ["id", ...opts.includeFields];
18932
- const conditions = companyID === void 0 ? [root] : [{ field: "companyID", op: "eq", value: companyID }, root];
18933
- const rows = await this.queryEntity(`${this.resourcePath}/query`, conditions, {
18934
- maxRecords: limit,
18935
- ...includeFields !== void 0 ? { includeFields } : {}
18936
- });
18937
- if (includeFields === void 0 || includeFields.includes("userDefinedFields")) return rows;
18938
- return rows.map((row) => {
18939
- const r = row;
18940
- if (!Array.isArray(r.userDefinedFields) || r.userDefinedFields.length > 0) return row;
18941
- const rest = { ...r };
18942
- delete rest.userDefinedFields;
18943
- return rest;
18944
- });
18945
- }
18946
- };
18947
- function isEmpty(value) {
18948
- if (value === null || value === void 0) return true;
18949
- if (Array.isArray(value)) return value.length === 0;
18950
- return typeof value === "object" && Object.keys(value).length === 0;
18951
- }
18952
-
18953
- // src/resources/base/mutations.ts
18954
- var MutationBase = class extends MetaBase {
18955
- // ------------------------------------------------------------------
18956
- // Mutations
18957
- // ------------------------------------------------------------------
18958
- /** Normalise a mutation body: `id` OR `itemId` → `id`; UDF map → UDF array (UDF-capable only). */
18959
- normalizeMutationBody(data) {
18960
- const body = { ...data };
18961
- if (body.itemId !== void 0 && body.id === void 0) {
18962
- body.id = body.itemId;
18963
- delete body.itemId;
19181
+ const scan = await this.scanForExactMatch(value, fields);
19182
+ if (scan.found !== null) {
19183
+ return {
19184
+ value: scan.found,
19185
+ resolutionCost: "client-scan",
19186
+ scanned: scan.scanned,
19187
+ scanTruncated: false
19188
+ };
18964
19189
  }
18965
- if (this.config.udfCapable && body.userDefinedFields !== void 0) {
18966
- body.userDefinedFields = normalizeUdfInput(body.userDefinedFields);
19190
+ if (scan.truncated) {
19191
+ throw new ResolutionError(
19192
+ `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`,
19193
+ {
19194
+ code: "RESOLUTION_TRUNCATED",
19195
+ // issue #59 PR-B: the pointer channel on the unverified-miss outcome
19196
+ // (the contains-candidates channel is the resource's search op).
19197
+ suggestedAction: `Retry with a more specific identifier, or narrow the search to fewer records (contains candidates: ${this.config.key}.search).`
19198
+ }
19199
+ );
18967
19200
  }
18968
- return body;
18969
- }
18970
- /** `POST /V1.0/<E>` → `{ id }` (issue #13: a drifted 2xx runs the bounded marker recovery where the plan pins a marker field). */
18971
- async createGlobal(data, opts = {}) {
18972
- const body = this.normalizeMutationBody(data);
18973
- const res = await this.http.request({
18974
- method: "POST",
18975
- path: this.resourcePath,
18976
- body,
18977
- operation: `${this.config.key}.create`,
18978
- maxRetries: opts.maxRetries
18979
- });
18980
- return this.unwrapCreateOperation(res, body, opts);
19201
+ return { value: null, resolutionCost: "client-scan", scanned: scan.scanned, scanTruncated: false, triedFields: fields };
18981
19202
  }
18982
19203
  /**
18983
- * Issue #13: the create-response envelope unwrap with the bounded marker
18984
- * recovery. The fast path is the plain `unwrapOperation` (a well-formed
18985
- * envelope costs ZERO extra requests and returns the byte-identical
18986
- * `{ id }` result). Only a typed `INVALID_RESPONSE` (a 2xx body that is
18987
- * not the specced envelope / `{ id }` / `{ itemId }` alternative) with a
18988
- * plan-pinned marker field AND `recoverIdFromMarker !== false` runs the
18989
- * fallback — and it can only run on the APPLY path: a dry run returns the
18990
- * pinned envelope BEFORE the mutating request, so the fallback never sees
18991
- * a dry-run response (skipped entirely by construction).
18992
- * @internal
19204
+ * Issue #78: `resolve` with the miss explained. A hit is `{ record, resolutionCost }`
19205
+ * (unchanged). A miss is never a bare `null`: `miss` carries the fields and operator
19206
+ * tried, the blind-surface stamp when the surface is blind, and the next steps.
19207
+ * Thrown outcomes (not found by id, ambiguous, truncated) are unchanged.
18993
19208
  */
18994
- async unwrapCreateOperation(body, wireBody, opts) {
18995
- const operation = `${this.config.key}.create`;
18996
- try {
18997
- return unwrapOperation(body, operation);
18998
- } catch (err) {
18999
- if (!isAutotaskError(err) || err.code !== "INVALID_RESPONSE") throw err;
19000
- const pin = RESPONSE_DRIFT_MARKERS[this.config.key];
19001
- if (pin === void 0 || opts.recoverIdFromMarker === false) throw err;
19002
- return this.recoverCreatedIdByMarker(err, body, wireBody, opts, pin);
19003
- }
19209
+ async resolveWithDetail(identifier) {
19210
+ const r = await this.resolveDetailed(identifier);
19211
+ if (r.value !== null) return { record: r.value, resolutionCost: r.resolutionCost };
19212
+ return { record: null, resolutionCost: r.resolutionCost, miss: this.missDetail("eq", r.triedFields ?? this.config.searchFields ?? []) };
19004
19213
  }
19005
19214
  /**
19006
- * Issue #13: the ONE bounded marker read on the resource's own query
19007
- * surface (`POST <collection>/query`, MaxRecords 5 — a read: idempotent,
19008
- * zero mutating wire; the fallback never issues anything else). Outcomes:
19009
- * exactly ONE numeric match → the normal success result stamped
19010
- * `idFrom: 'marker-recovery'` + the `shapeDrift` note; zero matches, more
19011
- * than one, or a cap-tripped search (a full 5 + a next cursor) → the
19012
- * existing `INVALID_RESPONSE` with the drift detail + the failed search
19013
- * (candidate ids listed — never a guessed id).
19014
- * @internal
19215
+ * Issue #78: `search` with an empty result explained (a bare `[]` reads as complete on a
19216
+ * blind surface). Hits are `{ items }` (unchanged); an empty result adds `miss`.
19015
19217
  */
19016
- async recoverCreatedIdByMarker(err, body, wireBody, opts, pin) {
19017
- const operation = `${this.config.key}.create`;
19018
- const markerField = typeof opts.markerField === "string" && opts.markerField.trim() !== "" ? opts.markerField.trim() : pin.markerField;
19019
- const markerToken = typeof opts.markerToken === "string" && opts.markerToken.trim() !== "" ? opts.markerToken.trim() : defaultMarkerToken(wireBody);
19020
- if (markerToken === null) throw err;
19021
- const observedShape = describeResponseShape(body);
19022
- const res = await this.http.request({
19023
- method: "POST",
19024
- idempotent: true,
19025
- path: `${this.resourcePath}/query`,
19026
- body: { MaxRecords: MARKER_RECOVERY_LIMIT, filter: [{ field: markerField, op: "contains", value: markerToken }] },
19027
- operation: `${operation}.marker-recovery`
19028
- });
19029
- const { items, pageDetails } = unwrapItems(res, operation);
19030
- const truncated = items.length >= MARKER_RECOVERY_LIMIT && pageDetails.nextPageUrl != null;
19031
- const drift = {
19032
- observedShape,
19033
- speccedEnvelope: "OperationResultModel",
19034
- markerField,
19035
- markerToken,
19036
- matches: items.length,
19037
- truncated,
19038
- note: pin.note
19039
- };
19040
- const numericIds = items.map((row) => {
19041
- const id = row.id;
19042
- if (typeof id === "number" && Number.isInteger(id) && id >= 0) return id;
19043
- if (typeof id === "string" && /^\d+$/.test(id.trim())) {
19044
- const parsed = Number.parseInt(id.trim(), 10);
19045
- return Number.isSafeInteger(parsed) ? parsed : id.trim();
19046
- }
19047
- return null;
19048
- });
19049
- if (items.length === 1 && !truncated && numericIds[0] !== null) {
19050
- const id = numericIds[0];
19051
- const shapeDrift = {
19052
- observedShape,
19053
- speccedEnvelope: "OperationResultModel",
19054
- 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.`
19055
- };
19056
- return { id, idFrom: "marker-recovery", shapeDrift };
19218
+ async searchWithDetail(query, opts = {}) {
19219
+ const items = await this.searchBase(query, opts);
19220
+ if (items.length > 0) return { items };
19221
+ return { items, miss: this.missDetail("contains", this.config.searchFields ?? []) };
19222
+ }
19223
+ /** The shared miss explanation for `resolveWithDetail` / `searchWithDetail`. @internal */
19224
+ missDetail(operator, fields) {
19225
+ const key = this.config.key;
19226
+ const probe = fields.map((field) => ({ field, op: operator, value: "" }));
19227
+ const blind = blindStampFor(BLIND_SURFACE_STAMPS[key]?.query, probe);
19228
+ const next = [];
19229
+ if (blind !== void 0) {
19230
+ next.push(`This surface is blind to records the API user does not own, so a miss is not proof of absence: ${blind.authority} returns the record when the id is known.`);
19057
19231
  }
19058
- const candidateIds = numericIds.map((n, i) => n ?? String(items[i].id ?? "unknown"));
19059
- 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`;
19060
- err.details = { ...err.details, responseDrift: { ...drift, candidateIds } };
19061
- 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.");
19062
- throw err;
19232
+ next.push(
19233
+ operator === "eq" ? `Matching is exact on ${fields.join(", ")} (other identifier forms were not tried); ${key}.search finds contains candidates.` : `Matching is a contains match on ${fields.join(", ")}; try a shorter or different fragment, or ${key}.resolve with the full exact value.`
19234
+ );
19235
+ return { tried: { fields: [...fields], operator }, ...blind !== void 0 ? { blindSurface: blind } : {}, next };
19236
+ }
19237
+ /** The plain value projection of `resolveDetailed` (the `resolve` floor). */
19238
+ async resolveBase(identifier) {
19239
+ return (await this.resolveDetailed(identifier)).value;
19240
+ }
19241
+ /** The items of one page of the resource's own `/query`. */
19242
+ async queryPage1(conditions, maxRecords) {
19243
+ const body = {
19244
+ filter: buildFilter(conditions).filter,
19245
+ MaxRecords: maxRecords
19246
+ };
19247
+ const res = await this.http.request({
19248
+ method: "POST",
19249
+ idempotent: true,
19250
+ path: `${this.resourcePath}/query`,
19251
+ body,
19252
+ operation: `${this.config.key}.resolve`
19253
+ });
19254
+ return unwrapItems(res, `${this.config.key}.resolve`).items;
19063
19255
  }
19064
- /** `PUT /V1.0/<E>/{id}` (the vendor tolerates PATCH at the same path) → `{ id }`.
19065
- * For collection-only-writable resources (`collectionUpdate`, D1) the
19066
- * vendor spec defines no put/patch on the item path (it is GET-only; the
19067
- * item-path PUT 405s), so the confirmed update hits the COLLECTION
19068
- * `PUT /V1.0/<E>` with the record id merged into the mutation body — the
19069
- * vendor keys the record by the id in the body (the same convention the
19070
- * parent-scoped `updateScopedRaw` PATCH uses).
19071
- *
19072
- * V2 partial-update merge: the vendor's collection PUT validates the FULL
19073
- * model (a partial body 500s 'Missing Required Field'), so a
19074
- * collectionUpdate update MERGES the caller's partial body into the
19075
- * record's current full model before the PUT: ONE fresh GET serves both
19076
- * the merge source and the stale-object guard's read (a `lastModifiedDate`
19077
- * mismatch throws STALE_OBJECT before the PUT; the guard semantics are
19078
- * unchanged — it is a no-op where the model lacks the field or the caller
19079
- * supplied no token). `opts.raw === true` sends the caller body as-is
19080
- * instead (the pre-merge wire; the guard keeps its own verification read
19081
- * when a token is supplied).
19082
- *
19083
- * Issue #34: with `update.putFallback: true` (OFF by default), a TYPED
19084
- * 404 on the pinned route retries ONCE as `PUT /{Entity}/{id}` where the
19085
- * gate holds — never a collectionUpdate route (the spec defines no
19086
- * item-path update there), never a `raw: true` update, never a non-404
19087
- * fault; the retry body runs the same fresh-read merge (see
19088
- * `putFallbackMergedBody`), so the fallback is never a null-clearing
19089
- * partial. A declined gate (a collectionUpdate route 404s, or the retry
19090
- * itself 404s) throws the self-explaining typed error (the route tried,
19091
- * the status, the opt-in; `details.putFallback`). */
19092
19256
  /**
19093
- * Issue #34 gate: the fallback may fire only while `update.putFallback`
19094
- * is ON and the pinned update route answered a TYPED 404 (a transport
19095
- * `NotFoundError` with `httpStatus: 404`) — never a 400/405/422/5xx, and
19096
- * never a 2xx shape fault (those keep today's typed error byte-identical).
19097
- * @internal
19257
+ * The bounded client-scan fallback (section 19.2): an unfiltered walk of
19258
+ * the resource's own query endpoint, exact match on `fields`, bounded by
19259
+ * BOTH client caps (records AND pages). `truncated` is true when the caps
19260
+ * were hit before the walk completed (a complete non-match is unproven).
19098
19261
  */
19099
- putFallbackEligible(err) {
19100
- return this.http.update.putFallback === true && err instanceof NotFoundError && err.httpStatus === 404;
19262
+ async scanForExactMatch(value, fields) {
19263
+ const caps = this.resolutionCaps;
19264
+ const pageSize = Math.max(1, Math.floor(caps.maxScanRecords / caps.maxScanPages));
19265
+ const state = { cursor: null };
19266
+ let scanned = 0;
19267
+ let pages = 0;
19268
+ let found = null;
19269
+ let exhausted = false;
19270
+ for (; ; ) {
19271
+ const page = await this.fetchPage({ maxRecords: pageSize }, pages + 1, pageSize, state);
19272
+ pages += 1;
19273
+ for (const item of page.items) {
19274
+ scanned += 1;
19275
+ const rec = item;
19276
+ if (fields.some((field) => rec[field] === value)) {
19277
+ found = item;
19278
+ break;
19279
+ }
19280
+ }
19281
+ if (found !== null) break;
19282
+ if (!page.hasMore) {
19283
+ exhausted = true;
19284
+ break;
19285
+ }
19286
+ if (pages >= caps.maxScanPages || scanned >= caps.maxScanRecords) break;
19287
+ }
19288
+ return { found, scanned, truncated: found === null && !exhausted };
19101
19289
  }
19102
19290
  /**
19103
- * Issue #34: the fallback retry body — the caller's (normalised) fields
19104
- * merged onto a FRESH unredacted read of the current record (the same
19105
- * merge the collection-update path performs; the read is the live record,
19106
- * never cached). A target miss on the merge read surfaces with the pinned
19107
- * update-target reason (the issue-#14 convention) — the recovery has
19108
- * nothing to merge onto, and no retry is issued.
19109
- * @internal
19291
+ * `findBy<field>` helpers: exact (`eq`) filter on one vendor field, bounded
19292
+ * to 2 records — 0 → null, 1 → the record, ≥2 → ResolutionError with the
19293
+ * candidate ids.
19110
19294
  */
19111
- async putFallbackMergedBody(read, callerBody, id) {
19112
- let current;
19113
- try {
19114
- current = await read();
19115
- } catch (err) {
19116
- if (err instanceof NotFoundError) attachUpdateTargetMissReason(err, this.config.key);
19117
- throw err;
19295
+ async findByField(field, value) {
19296
+ if (value.trim() === "") throw new AutotaskConfigError(`findBy${field}: empty value`);
19297
+ const matches = await this.queryEntity(
19298
+ `${this.resourcePath}/query`,
19299
+ [{ field, op: "eq", value: value.trim() }],
19300
+ { maxRecords: 2 }
19301
+ );
19302
+ if (matches.length === 0) return null;
19303
+ if (matches.length > 1) {
19304
+ const ids2 = matches.map((m) => m.id).filter((id) => typeof id === "number");
19305
+ throw new ResolutionError(
19306
+ `findBy${field}: "${value}" is ambiguous on ${this.config.key} (matched ${matches.length} records)`,
19307
+ { code: "RESOLUTION_AMBIGUOUS", resourceIds: ids2, details: { idRefs: ids2.map((cid) => ({ entity: this.entityName, id: cid })) } }
19308
+ );
19118
19309
  }
19119
- const merged = mergePartialBody(current, callerBody);
19120
- merged.id = id;
19121
- return merged;
19310
+ return matches[0];
19122
19311
  }
19123
19312
  /**
19124
- * Issue #34: the self-explaining typed error a DECLINED gate throws. The
19125
- * original transport error keeps its identity (class / code / httpStatus);
19126
- * the message names the route tried, the status and the opt-in, and the
19127
- * structured detail rides under `details.putFallback`
19128
- * (`{ route, status, retried, optIn }`).
19129
- * @internal
19313
+ * `search` — the bounded contains surface (no vendor free-text search):
19314
+ * `contains` on every `searchFields` field (OR-grouped), `MaxRecords` =
19315
+ * `limit` (default 25, clamped to 1..500). Empty query → AutotaskConfigError.
19130
19316
  */
19131
- putFallbackDeclined(err, route, retried) {
19132
- 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`);
19133
- err.details = { ...err.details ?? {}, putFallback: { route, status: 404, retried, optIn: "update.putFallback" } };
19134
- 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.";
19135
- return err;
19136
- }
19137
- async updateGlobal(id, data, opts = {}) {
19138
- const numeric = parseIdentifier(id, "id");
19139
- const operation = `${this.config.key}.update`;
19140
- let body = this.normalizeMutationBody(data);
19141
- const collectionUpdate = this.config.collectionUpdate === true;
19142
- if (collectionUpdate) {
19143
- if (opts.raw !== true) {
19144
- let current;
19145
- try {
19146
- current = await this.getGlobalUnredacted(numeric, { maxRetries: opts.maxRetries });
19147
- } catch (err) {
19148
- if (err instanceof NotFoundError) attachUpdateTargetMissReason(err, this.config.key);
19149
- throw err;
19150
- }
19151
- this.staleObjectCheck(current, opts, operation);
19152
- body = mergePartialBody(current, body);
19153
- } else {
19154
- await this.guardStaleBeforeMutation(numeric, opts, operation);
19155
- }
19156
- body.id = numeric;
19317
+ async searchBase(query, opts = {}) {
19318
+ const text = query.trim();
19319
+ if (text === "") throw new AutotaskConfigError("search: empty query");
19320
+ const fields = this.config.searchFields ?? [];
19321
+ if (fields.length === 0) {
19322
+ throw new AutotaskConfigError(`search: ${this.config.key} has no searchable field`);
19157
19323
  }
19158
- const route = collectionUpdate ? `PUT ${this.resourcePath}` : `PUT ${this.resourcePath}/${numeric}`;
19159
- let res;
19160
- try {
19161
- res = await this.http.request({
19162
- method: "PUT",
19163
- path: collectionUpdate ? this.resourcePath : `${this.resourcePath}/${numeric}`,
19164
- body,
19165
- operation,
19166
- maxRetries: opts.maxRetries
19167
- });
19168
- } catch (err) {
19169
- if (!this.putFallbackEligible(err)) throw err;
19170
- if (collectionUpdate) {
19171
- throw this.putFallbackDeclined(err, route, false);
19172
- }
19173
- const merged = await this.putFallbackMergedBody(
19174
- () => this.getGlobalUnredacted(numeric, { maxRetries: opts.maxRetries }),
19175
- body,
19176
- numeric
19177
- );
19178
- let retry;
19179
- try {
19180
- retry = await this.http.request({
19181
- method: "PUT",
19182
- path: `${this.resourcePath}/${numeric}`,
19183
- body: merged,
19184
- operation,
19185
- maxRetries: opts.maxRetries
19186
- });
19187
- } catch (rerr) {
19188
- if (this.putFallbackEligible(rerr)) throw this.putFallbackDeclined(rerr, route, true);
19189
- throw rerr;
19324
+ const limit = opts.limit === void 0 ? 25 : clampPageSize(opts.limit);
19325
+ const leaf = fields.map((field) => ({ field, op: "contains", value: text }));
19326
+ const root = leaf.length === 1 ? leaf[0] : { op: "or", items: leaf };
19327
+ for (const key of Object.keys(opts)) {
19328
+ if (key !== "limit" && key !== "companyID" && key !== "includeFields") {
19329
+ throw new AutotaskConfigError(`search: unknown opts key "${key}" \u2014 the bag is closed: limit, companyID, includeFields`);
19190
19330
  }
19191
- return unwrapOperation(retry, operation);
19192
19331
  }
19193
- return unwrapOperation(res, operation);
19194
- }
19195
- /** `DELETE /V1.0/<E>/{id}` → `{ id }` (OperationResultModel). A blank 2xx
19196
- * (204 / empty body) is a valid vendor delete answer — synthesize the
19197
- * result from the path id (issue #25: the transport returns undefined
19198
- * for it; a non-blank wrong shape still throws via unwrapOperation). */
19199
- async deleteGlobal(id, opts = {}) {
19200
- const numeric = parseIdentifier(id, "id");
19201
- const res = await this.http.request({
19202
- method: "DELETE",
19203
- path: `${this.resourcePath}/${numeric}`,
19204
- operation: `${this.config.key}.delete`,
19205
- maxRetries: opts.maxRetries
19332
+ if (opts.companyID !== void 0 && !isIdentifierCandidate(opts.companyID)) {
19333
+ throw new AutotaskConfigError(`search: companyID must be a positive integer id (got ${String(opts.companyID)})`);
19334
+ }
19335
+ if (opts.includeFields !== void 0 && (!Array.isArray(opts.includeFields) || opts.includeFields.length === 0 || opts.includeFields.some((f) => typeof f !== "string" || f === ""))) {
19336
+ throw new AutotaskConfigError("search: includeFields must be a non-empty array of field names");
19337
+ }
19338
+ const companyID = opts.companyID === void 0 ? void 0 : Number(opts.companyID);
19339
+ const includeFields = opts.includeFields === void 0 ? void 0 : opts.includeFields.includes("id") ? opts.includeFields : ["id", ...opts.includeFields];
19340
+ const conditions = companyID === void 0 ? [root] : [{ field: "companyID", op: "eq", value: companyID }, root];
19341
+ const rows = await this.queryEntity(`${this.resourcePath}/query`, conditions, {
19342
+ maxRecords: limit,
19343
+ ...includeFields !== void 0 ? { includeFields } : {}
19206
19344
  });
19207
- return res === void 0 ? { id: numeric } : unwrapOperation(res, `${this.config.key}.delete`);
19208
- }
19209
- /** Parent-scoped create: `POST .../{parentId}/<Child>` (issue #13: a drifted 2xx runs the bounded marker recovery where the plan pins a marker field). */
19210
- async createScopedRaw(parentId, data, opts = {}) {
19211
- const body = this.normalizeMutationBody(data);
19212
- const res = await this.http.request({
19213
- method: "POST",
19214
- path: this.scopedCollectionPath(parentId),
19215
- body,
19216
- operation: `${this.config.key}.create`,
19217
- maxRetries: opts.maxRetries
19345
+ if (includeFields === void 0 || includeFields.includes("userDefinedFields")) return rows;
19346
+ return rows.map((row) => {
19347
+ const r = row;
19348
+ if (!Array.isArray(r.userDefinedFields) || r.userDefinedFields.length > 0) return row;
19349
+ const rest = { ...r };
19350
+ delete rest.userDefinedFields;
19351
+ return rest;
19218
19352
  });
19219
- return this.unwrapCreateOperation(res, body, opts);
19220
19353
  }
19221
- /** Parent-scoped update: `PATCH .../{parentId}/<Child>` — the COLLECTION.
19222
- * Swagger documents put+patch on the scoped collection and has NO put/patch on
19223
- * the item path anywhere in the spec; the node strips the id from the path and
19224
- * PATCHes the collection, and the vendor keys the record by the id in the body. */
19225
- async updateScopedRaw(parentId, id, data, opts = {}) {
19226
- const numeric = parseIdentifier(id, "id");
19227
- const body = this.normalizeMutationBody(data);
19228
- body.id = numeric;
19229
- const path = this.scopedCollectionPath(parentId);
19230
- const operation = `${this.config.key}.update`;
19231
- let res;
19232
- try {
19233
- res = await this.http.request({
19234
- method: "PATCH",
19235
- path,
19236
- body,
19237
- operation,
19238
- maxRetries: opts.maxRetries
19239
- });
19240
- } catch (err) {
19241
- if (!this.putFallbackEligible(err)) throw err;
19242
- const merged = await this.putFallbackMergedBody(
19243
- () => this.getScopedUnredacted(parentId, numeric, { maxRetries: opts.maxRetries }),
19244
- body,
19245
- numeric
19246
- );
19247
- let retry;
19248
- try {
19249
- retry = await this.http.request({
19250
- method: "PUT",
19251
- path: `${path}/${numeric}`,
19252
- body: merged,
19253
- operation,
19254
- maxRetries: opts.maxRetries
19255
- });
19256
- } catch (rerr) {
19257
- if (this.putFallbackEligible(rerr)) throw this.putFallbackDeclined(rerr, `PUT ${path}/${numeric}`, true);
19258
- throw rerr;
19259
- }
19260
- return unwrapOperation(retry, operation);
19354
+ /**
19355
+ * Issue #287 (D7): the bounded CONTAINS fallback of `resolveAny`'s
19356
+ * `match: 'auto'` — the resource's own contains surface (`searchBase`),
19357
+ * bounded by the resource's scan RECORD cap (the same cap that bounds the
19358
+ * `scanOnMiss` client scan — default 500 records; the walk's page clamp
19359
+ * keeps the fetch within the bounded-query page budget). Returns the
19360
+ * matching items (0..cap): the caller folds 1-hit / many / none. Zero wire
19361
+ * on an id-only resource (no primary text field → the empty set, so the
19362
+ * caller reports the miss unchanged).
19363
+ */
19364
+ async resolveContainsMatches(value) {
19365
+ const fields = this.config.searchFields ?? [];
19366
+ if (fields.length === 0) return [];
19367
+ const limit = Math.min(this.resolutionCaps.maxScanRecords, 500);
19368
+ return this.searchBase(value, { limit });
19369
+ }
19370
+ };
19371
+ function isEmpty(value) {
19372
+ if (value === null || value === void 0) return true;
19373
+ if (Array.isArray(value)) return value.length === 0;
19374
+ return typeof value === "object" && Object.keys(value).length === 0;
19375
+ }
19376
+
19377
+ // src/resources/base/mutations.ts
19378
+ var MutationBase = class extends MetaBase {
19379
+ // ------------------------------------------------------------------
19380
+ // Mutations
19381
+ // ------------------------------------------------------------------
19382
+ /** Normalise a mutation body: `id` OR `itemId` → `id`; UDF map → UDF array (UDF-capable only). */
19383
+ normalizeMutationBody(data) {
19384
+ const body = { ...data };
19385
+ if (body.itemId !== void 0 && body.id === void 0) {
19386
+ body.id = body.itemId;
19387
+ delete body.itemId;
19261
19388
  }
19262
- return unwrapOperation(res, operation);
19389
+ if (this.config.udfCapable && body.userDefinedFields !== void 0) {
19390
+ body.userDefinedFields = normalizeUdfInput(body.userDefinedFields);
19391
+ }
19392
+ return body;
19263
19393
  }
19264
- /** Parent-scoped delete: `DELETE .../{parentId}/<Child>/{id}` → `{ id }`
19265
- * (a blank 2xx synthesizes `{ id }` from the path id — issue #25). */
19266
- async deleteScopedRaw(parentId, id, opts = {}) {
19267
- const numeric = parseIdentifier(id, "id");
19394
+ /** `POST /V1.0/<E>` → `{ id }` (issue #13: a drifted 2xx runs the bounded marker recovery where the plan pins a marker field). */
19395
+ async createGlobal(data, opts = {}) {
19396
+ const body = this.normalizeMutationBody(data);
19268
19397
  const res = await this.http.request({
19269
- method: "DELETE",
19270
- path: this.scopedItemPath(parentId, id),
19271
- operation: `${this.config.key}.delete`,
19398
+ method: "POST",
19399
+ path: this.resourcePath,
19400
+ body,
19401
+ operation: `${this.config.key}.create`,
19272
19402
  maxRetries: opts.maxRetries
19273
19403
  });
19274
- return res === void 0 ? { id: numeric } : unwrapOperation(res, `${this.config.key}.delete`);
19404
+ return this.unwrapCreateOperation(res, body, opts);
19275
19405
  }
19276
19406
  /**
19277
- * Attachment delete: the vendor returns the deleted attachment MODEL, not
19278
- * OperationResultModel (G1 envelope.md) — pass it through typed.
19407
+ * Issue #13: the create-response envelope unwrap with the bounded marker
19408
+ * recovery. The fast path is the plain `unwrapOperation` (a well-formed
19409
+ * envelope costs ZERO extra requests and returns the byte-identical
19410
+ * `{ id }` result). Only a typed `INVALID_RESPONSE` (a 2xx body that is
19411
+ * not the specced envelope / `{ id }` / `{ itemId }` alternative) with a
19412
+ * plan-pinned marker field AND `recoverIdFromMarker !== false` runs the
19413
+ * fallback — and it can only run on the APPLY path: a dry run returns the
19414
+ * pinned envelope BEFORE the mutating request, so the fallback never sees
19415
+ * a dry-run response (skipped entirely by construction).
19416
+ * @internal
19279
19417
  */
19280
- async deleteScopedModel(parentId, id, opts = {}) {
19281
- const numeric = parseIdentifier(id, "id");
19282
- const res = await this.http.request({
19283
- method: "DELETE",
19284
- path: this.scopedItemPath(parentId, id),
19285
- operation: `${this.config.key}.delete`,
19286
- maxRetries: opts.maxRetries
19287
- });
19288
- if (res === void 0) return {};
19289
- if (!isRecord2(res)) {
19290
- throw new AutotaskError(
19291
- `Expected the deleted attachment model for ID ${numeric}; got a different shape`,
19292
- { code: "INVALID_RESPONSE", category: "server", resourceIds: [numeric], details: { idRefs: [{ entity: this.entityName, id: numeric }] } }
19293
- );
19418
+ async unwrapCreateOperation(body, wireBody, opts) {
19419
+ const operation = `${this.config.key}.create`;
19420
+ try {
19421
+ return unwrapOperation(body, operation);
19422
+ } catch (err) {
19423
+ if (!isAutotaskError(err) || err.code !== "INVALID_RESPONSE") throw err;
19424
+ const pin = RESPONSE_DRIFT_MARKERS[this.config.key];
19425
+ if (pin === void 0 || opts.recoverIdFromMarker === false) throw err;
19426
+ return this.recoverCreatedIdByMarker(err, body, wireBody, opts, pin);
19294
19427
  }
19295
- return res;
19296
19428
  }
19297
- async createIfNotExists(dataOrParentId, dataOrOpts, opts = {}) {
19298
- const isObject = (v) => isRecord2(v) && !Array.isArray(v);
19299
- const scoped = this.config.parentScopedPath !== void 0;
19300
- let parentId;
19301
- let data;
19302
- let cinOpts;
19303
- if (scoped) {
19304
- const dataIsOptsBag = isObject(dataOrOpts) && ("dedupFields" in dataOrOpts || "dryRun" in dataOrOpts);
19305
- if (!isIdentifierCandidate(dataOrParentId) || !isObject(dataOrOpts) || dataIsOptsBag) {
19306
- throw new AutotaskConfigError(
19307
- `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`
19308
- );
19309
- }
19310
- parentId = parseIdentifier(dataOrParentId, "parentId");
19311
- data = dataOrOpts;
19312
- cinOpts = opts;
19313
- } else {
19314
- if (!isObject(dataOrParentId)) {
19315
- throw new AutotaskConfigError(
19316
- `createIfNotExists for top-level resource ${this.config.key} takes (data[, opts]) \u2014 got a non-record first argument`
19317
- );
19318
- }
19319
- data = dataOrParentId;
19320
- cinOpts = isObject(dataOrOpts) ? dataOrOpts : {};
19321
- }
19322
- const record = isRecord2(data) ? data : {};
19323
- const dedupFields = (cinOpts.dedupFields ?? []).filter((field) => field in record);
19324
- let existing;
19325
- let existingId;
19326
- if (dedupFields.length > 0) {
19327
- const conditions = dedupFields.map((field) => ({
19328
- field,
19329
- op: "eq",
19330
- value: record[field]
19331
- }));
19332
- if (parentId !== void 0 && this.config.parentField !== void 0) {
19333
- conditions.unshift({ field: this.config.parentField, op: "eq", value: parentId });
19334
- }
19335
- const matches = await this.queryEntity(`${this.resourcePath}/query`, conditions, { maxRecords: 10 });
19336
- const first = matches[0];
19337
- if (first !== void 0) {
19338
- const firstId = first.id;
19339
- if (isIdentifierCandidate(firstId)) {
19340
- existing = first;
19341
- existingId = parseIdentifier(firstId, "id");
19342
- }
19343
- }
19344
- }
19345
- if (cinOpts.dryRun === true) {
19346
- const checks = [
19347
- { name: "input-validation", ok: isRecord2(data) && Object.keys(data).length > 0 }
19348
- ];
19349
- if (dedupFields.length > 0) {
19350
- checks.push({ name: "duplicate-detection", ok: existingId === void 0 });
19429
+ /**
19430
+ * Issue #13: the ONE bounded marker read on the resource's own query
19431
+ * surface (`POST <collection>/query`, MaxRecords 5 — a read: idempotent,
19432
+ * zero mutating wire; the fallback never issues anything else). Outcomes:
19433
+ * exactly ONE numeric match → the normal success result stamped
19434
+ * `idFrom: 'marker-recovery'` + the `shapeDrift` note; zero matches, more
19435
+ * than one, or a cap-tripped search (a full 5 + a next cursor) → the
19436
+ * existing `INVALID_RESPONSE` with the drift detail + the failed search
19437
+ * (candidate ids listed — never a guessed id).
19438
+ * @internal
19439
+ */
19440
+ async recoverCreatedIdByMarker(err, body, wireBody, opts, pin) {
19441
+ const operation = `${this.config.key}.create`;
19442
+ const markerField = typeof opts.markerField === "string" && opts.markerField.trim() !== "" ? opts.markerField.trim() : pin.markerField;
19443
+ const markerToken = typeof opts.markerToken === "string" && opts.markerToken.trim() !== "" ? opts.markerToken.trim() : defaultMarkerToken(wireBody);
19444
+ if (markerToken === null) throw err;
19445
+ const observedShape = describeResponseShape(body);
19446
+ const res = await this.http.request({
19447
+ method: "POST",
19448
+ idempotent: true,
19449
+ path: `${this.resourcePath}/query`,
19450
+ body: { MaxRecords: MARKER_RECOVERY_LIMIT, filter: [{ field: markerField, op: "contains", value: markerToken }] },
19451
+ operation: `${operation}.marker-recovery`
19452
+ });
19453
+ const { items, pageDetails } = unwrapItems(res, operation);
19454
+ const truncated = items.length >= MARKER_RECOVERY_LIMIT && pageDetails.nextPageUrl != null;
19455
+ const drift = {
19456
+ observedShape,
19457
+ speccedEnvelope: "OperationResultModel",
19458
+ markerField,
19459
+ markerToken,
19460
+ matches: items.length,
19461
+ truncated,
19462
+ note: pin.note
19463
+ };
19464
+ const numericIds = items.map((row) => {
19465
+ const id = row.id;
19466
+ if (typeof id === "number" && Number.isInteger(id) && id >= 0) return id;
19467
+ if (typeof id === "string" && /^\d+$/.test(id.trim())) {
19468
+ const parsed = Number.parseInt(id.trim(), 10);
19469
+ return Number.isSafeInteger(parsed) ? parsed : id.trim();
19351
19470
  }
19352
- const warnings = existingId !== void 0 ? [`duplicate match on ${dedupFields.join(", ")} (id ${existingId}); the create would be skipped`] : [];
19353
- return buildDryRunResult({
19354
- operation: `${this.config.key}.createIfNotExists`,
19355
- method: "POST",
19356
- path: parentId !== void 0 ? this.scopedCollectionPath(parentId) : this.resourcePath,
19357
- ids: existingId !== void 0 ? [existingId] : [],
19358
- checks,
19359
- warnings,
19360
- impact: { affected: 1, scope: "single-record", reversible: true },
19361
- data
19362
- }, { redactPersonalData: this.http.redactPersonalData });
19363
- }
19364
- if (existing !== void 0 && existingId !== void 0) {
19365
- return { id: existingId, created: false, existing };
19471
+ return null;
19472
+ });
19473
+ if (items.length === 1 && !truncated && numericIds[0] !== null) {
19474
+ const id = numericIds[0];
19475
+ const shapeDrift = {
19476
+ observedShape,
19477
+ speccedEnvelope: "OperationResultModel",
19478
+ 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.`
19479
+ };
19480
+ return { id, idFrom: "marker-recovery", shapeDrift };
19366
19481
  }
19367
- const op = parentId !== void 0 ? await this.createScopedRaw(parentId, data) : await this.createGlobal(data);
19368
- return { id: op.id, created: true };
19482
+ const candidateIds = numericIds.map((n, i) => n ?? String(items[i].id ?? "unknown"));
19483
+ 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`;
19484
+ err.details = { ...err.details, responseDrift: { ...drift, candidateIds } };
19485
+ 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.");
19486
+ throw err;
19369
19487
  }
19370
- // ------------------------------------------------------------------
19371
- // Dry-run scaffolding + stale-object guard (mutation safety, section 19.3)
19372
- // ------------------------------------------------------------------
19488
+ /** `PUT /V1.0/<E>/{id}` (the vendor tolerates PATCH at the same path) → `{ id }`.
19489
+ * For collection-only-writable resources (`collectionUpdate`, D1) the
19490
+ * vendor spec defines no put/patch on the item path (it is GET-only; the
19491
+ * item-path PUT 405s), so the confirmed update hits the COLLECTION
19492
+ * `PUT /V1.0/<E>` with the record id merged into the mutation body — the
19493
+ * vendor keys the record by the id in the body (the same convention the
19494
+ * parent-scoped `updateScopedRaw` PATCH uses).
19495
+ *
19496
+ * V2 partial-update merge: the vendor's collection PUT validates the FULL
19497
+ * model (a partial body 500s 'Missing Required Field'), so a
19498
+ * collectionUpdate update MERGES the caller's partial body into the
19499
+ * record's current full model before the PUT: ONE fresh GET serves both
19500
+ * the merge source and the stale-object guard's read (a `lastModifiedDate`
19501
+ * mismatch throws STALE_OBJECT before the PUT; the guard semantics are
19502
+ * unchanged — it is a no-op where the model lacks the field or the caller
19503
+ * supplied no token). `opts.raw === true` sends the caller body as-is
19504
+ * instead (the pre-merge wire; the guard keeps its own verification read
19505
+ * when a token is supplied).
19506
+ *
19507
+ * Issue #34: with `update.putFallback: true` (OFF by default), a TYPED
19508
+ * 404 on the pinned route retries ONCE as `PUT /{Entity}/{id}` where the
19509
+ * gate holds — never a collectionUpdate route (the spec defines no
19510
+ * item-path update there), never a `raw: true` update, never a non-404
19511
+ * fault; the retry body runs the same fresh-read merge (see
19512
+ * `putFallbackMergedBody`), so the fallback is never a null-clearing
19513
+ * partial. A declined gate (a collectionUpdate route 404s, or the retry
19514
+ * itself 404s) throws the self-explaining typed error (the route tried,
19515
+ * the status, the opt-in; `details.putFallback`). */
19373
19516
  /**
19374
- * Stale-object guard (section 19.3): compare the plan's `staleCheck` field
19375
- * (vendor `lastModifiedDate`) against the caller-observed value. Mismatch
19376
- * throws `STALE_OBJECT`. Returns true when a comparison was actually made
19377
- * (the resource model carries the field AND the vendor answered it AND the
19378
- * caller supplied an expected value); false when the check is unavailable.
19379
- */
19380
- staleObjectCheck(current, opts, operation) {
19381
- const field = this.config.staleField;
19382
- const expected = opts.lastModifiedDate;
19383
- if (field === void 0 || expected === void 0) return false;
19384
- const actual = current[field];
19385
- if (typeof actual !== "string") return false;
19386
- if (actual !== expected) {
19387
- throw new StaleObjectError(
19388
- `stale object: ${this.config.key} ${field} changed since the caller read it (expected ${expected}, current ${actual})`,
19389
- {
19390
- code: "STALE_OBJECT",
19391
- operation,
19392
- suggestedAction: "Re-read the record and retry with the fresh lastModifiedDate."
19393
- }
19394
- );
19395
- }
19396
- return true;
19517
+ * Issue #34 gate: the fallback may fire only while `update.putFallback`
19518
+ * is ON and the pinned update route answered a TYPED 404 (a transport
19519
+ * `NotFoundError` with `httpStatus: 404`) — never a 400/405/422/5xx, and
19520
+ * never a 2xx shape fault (those keep today's typed error byte-identical).
19521
+ * @internal
19522
+ */
19523
+ putFallbackEligible(err) {
19524
+ return this.http.update.putFallback === true && err instanceof NotFoundError && err.httpStatus === 404;
19397
19525
  }
19398
19526
  /**
19399
- * Stale-object guard for a REAL mutation: the caller supplied an expected
19400
- * `lastModifiedDate` and the model carries the field → fetch the current
19401
- * record and compare before the mutating request (mismatch throws
19402
- * STALE_OBJECT; a vanished record propagates the vendor's NotFoundError).
19527
+ * Issue #34: the fallback retry body — the caller's (normalised) fields
19528
+ * merged onto a FRESH unredacted read of the current record (the same
19529
+ * merge the collection-update path performs; the read is the live record,
19530
+ * never cached). A target miss on the merge read surfaces with the pinned
19531
+ * update-target reason (the issue-#14 convention) — the recovery has
19532
+ * nothing to merge onto, and no retry is issued.
19533
+ * @internal
19403
19534
  */
19404
- async guardStaleBeforeMutation(id, opts, operation, parentId) {
19405
- if (opts.lastModifiedDate === void 0 || this.config.staleField === void 0) return;
19535
+ async putFallbackMergedBody(read, callerBody, id) {
19406
19536
  let current;
19407
19537
  try {
19408
- current = parentId === void 0 ? await this.getGlobal(id, true) : this.config.attachmentGet ? await this.getScopedModel(parentId, id, true) : await this.getScopedRaw(parentId, id, true);
19538
+ current = await read();
19409
19539
  } catch (err) {
19410
19540
  if (err instanceof NotFoundError) attachUpdateTargetMissReason(err, this.config.key);
19411
19541
  throw err;
19412
19542
  }
19413
- this.staleObjectCheck(current, opts, operation);
19543
+ const merged = mergePartialBody(current, callerBody);
19544
+ merged.id = id;
19545
+ return merged;
19414
19546
  }
19415
19547
  /**
19416
- * `create` dry-run: input validation + parent verification (scoped
19417
- * resources) + the pinned envelope. NO request is issued (scoped parent
19418
- * verification is a read). Duplicate detection has no reliable lookup for
19419
- * a plain create (that is `createIfNotExists`), so it is not claimed.
19548
+ * Issue #34: the self-explaining typed error a DECLINED gate throws. The
19549
+ * original transport error keeps its identity (class / code / httpStatus);
19550
+ * the message names the route tried, the status and the opt-in, and the
19551
+ * structured detail rides under `details.putFallback`
19552
+ * (`{ route, status, retried, optIn }`).
19553
+ * @internal
19420
19554
  */
19421
- async runCreateDryRun(data, parentId) {
19422
- const checks = [];
19423
- const warnings = [];
19424
- checks.push({ name: "input-validation", ok: isRecord2(data) && Object.keys(data).length > 0 });
19425
- const deleteBoundaryNote = DELETE_BOUNDARY_NOTES[this.config.key];
19426
- if (deleteBoundaryNote !== void 0) {
19427
- checks.push({ name: "delete-boundary", ok: true });
19428
- warnings.push(deleteBoundaryNote);
19429
- }
19430
- let path = this.resourcePath;
19431
- if (parentId !== void 0) {
19432
- path = this.scopedCollectionPath(parentId);
19433
- const parentPath = this.config.parentPath;
19434
- if (parentPath === void 0) {
19435
- throw new AutotaskConfigError(
19436
- `dry-run: ${this.config.key} is parent-scoped but has no parentPath configured`
19437
- );
19438
- }
19439
- let parent = null;
19440
- if (isIdentifierCandidate(parentId)) {
19555
+ putFallbackDeclined(err, route, retried) {
19556
+ 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`);
19557
+ err.details = { ...err.details ?? {}, putFallback: { route, status: 404, retried, optIn: "update.putFallback" } };
19558
+ 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.";
19559
+ return err;
19560
+ }
19561
+ async updateGlobal(id, data, opts = {}) {
19562
+ const numeric = parseIdentifier(id, "id");
19563
+ const operation = `${this.config.key}.update`;
19564
+ let body = this.normalizeMutationBody(data);
19565
+ const collectionUpdate = this.config.collectionUpdate === true;
19566
+ if (collectionUpdate) {
19567
+ if (opts.raw !== true) {
19568
+ let current;
19441
19569
  try {
19442
- parent = await this.fetchRecordRaw(
19443
- parentPath,
19444
- parseIdentifier(parentId, "parentId"),
19445
- true
19446
- );
19570
+ current = await this.getGlobalUnredacted(numeric, { maxRetries: opts.maxRetries });
19447
19571
  } catch (err) {
19448
- if (!(err instanceof NotFoundError)) throw err;
19572
+ if (err instanceof NotFoundError) attachUpdateTargetMissReason(err, this.config.key);
19573
+ throw err;
19449
19574
  }
19575
+ this.staleObjectCheck(current, opts, operation);
19576
+ body = mergePartialBody(current, body);
19577
+ } else {
19578
+ await this.guardStaleBeforeMutation(numeric, opts, operation);
19450
19579
  }
19451
- checks.push({ name: "parent-exists", ok: parent !== null });
19452
- if (parent === null) {
19453
- warnings.push(`parent ${parentId} not found; the vendor would reject the create`);
19454
- }
19455
- }
19456
- if (isRecord2(data)) {
19457
- const eff = effectiveRequiredFields(void 0, RELATIONSHIP_MAP[this.config.key]?.zoneFacts ?? null);
19458
- const missing = eff.fields.filter((f) => f !== this.config.parentField && data[f] === void 0);
19459
- if (missing.length > 0) {
19460
- warnings.push(`required field(s) not in the data: ${missing.join(", ")} (${eff.from}); the vendor would likely reject the create`);
19461
- }
19462
- }
19463
- if (this.config.key === "companies") {
19464
- warnings.push('the vendor auto-creates a "Primary Address" location for a new company (rename it with company_locations.update; it cannot be deleted via the API)');
19465
- }
19466
- return buildDryRunResult({
19467
- operation: `${this.config.key}.create`,
19468
- method: "POST",
19469
- path,
19470
- ids: [],
19471
- checks,
19472
- impact: { affected: 1, scope: "single-record", reversible: true },
19473
- warnings,
19474
- data
19475
- }, { redactPersonalData: this.http.redactPersonalData });
19476
- }
19477
- /**
19478
- * `update` dry-run: input validation + current-record fetch (a missing
19479
- * record carries `warnings` and no `diff` — pinned), the stale-object
19480
- * check (mismatch throws STALE_OBJECT) and the best-effort `diff`. NO
19481
- * mutating request is issued.
19482
- */
19483
- async runUpdateDryRun(id, data, opts = {}, parentId) {
19484
- const operation = `${this.config.key}.update`;
19485
- const checks = [];
19486
- const warnings = [];
19487
- checks.push({ name: "input-validation", ok: isRecord2(data) && Object.keys(data).length > 0 });
19488
- const scopedUpdate = parentId !== void 0 && this.config.parentScopedPath !== void 0;
19489
- const collectionUpdate = scopedUpdate || this.config.collectionUpdate === true;
19490
- const path = scopedUpdate ? this.scopedCollectionPath(parentId) : collectionUpdate ? this.resourcePath : `${this.resourcePath}/${id}`;
19491
- const method = scopedUpdate ? "PATCH" : "PUT";
19492
- let current = null;
19493
- try {
19494
- current = parentId === void 0 ? await this.getGlobal(id, true) : this.config.attachmentGet ? await this.getScopedModel(parentId, id, true) : await this.getScopedRaw(parentId, id, true);
19495
- } catch (err) {
19496
- if (err instanceof NotFoundError) {
19497
- const updateReason = EMPTY_REASON_STAMPS[this.config.key]?.update;
19498
- checks.push({
19499
- name: "current-record-exists",
19500
- ok: false,
19501
- ...updateReason !== void 0 ? { note: updateReason.note } : {}
19502
- });
19503
- warnings.push(`current record ${id} not fetchable; no diff`);
19504
- return buildDryRunResult({
19505
- operation,
19506
- method,
19507
- path,
19508
- ids: [id],
19509
- checks,
19510
- impact: { affected: 1, scope: "single-record", reversible: true },
19511
- warnings,
19512
- data,
19513
- ...updateReason !== void 0 ? { emptyReason: updateReason } : {}
19514
- }, { redactPersonalData: this.http.redactPersonalData });
19515
- }
19516
- throw err;
19517
- }
19518
- checks.push({ name: "current-record-exists", ok: true });
19519
- if (this.staleObjectCheck(current, opts, operation)) {
19520
- checks.push({ name: "stale-check", ok: true });
19521
- }
19522
- let envelopeData = data;
19523
- if (parentId === void 0 && this.config.collectionUpdate === true && opts.raw !== true) {
19524
- envelopeData = mergePartialBody(
19525
- current,
19526
- this.normalizeMutationBody(data)
19527
- );
19528
- }
19529
- return buildDryRunResult({
19530
- operation,
19531
- method,
19532
- path,
19533
- ids: [id],
19534
- checks,
19535
- diff: computeFieldDiff(current, data),
19536
- impact: { affected: 1, scope: "single-record", reversible: true },
19537
- warnings,
19538
- data: envelopeData
19539
- }, { redactPersonalData: this.http.redactPersonalData });
19540
- }
19541
- /**
19542
- * `delete` dry-run: current-record verification + the stale-object check +
19543
- * the pinned envelope (no diff — a delete has no before/after fields). NO
19544
- * mutating request is issued.
19545
- */
19546
- async runDeleteDryRun(id, opts = {}, parentId) {
19547
- const operation = `${this.config.key}.delete`;
19548
- const checks = [];
19549
- const warnings = [];
19550
- const path = parentId === void 0 ? `${this.resourcePath}/${id}` : this.scopedItemPath(parentId, id);
19551
- let current = null;
19552
- try {
19553
- current = parentId === void 0 ? await this.getGlobal(id, true) : this.config.attachmentGet ? await this.getScopedModel(parentId, id, true) : await this.getScopedRaw(parentId, id, true);
19554
- } catch (err) {
19555
- if (err instanceof NotFoundError) {
19556
- checks.push({ name: "current-record-exists", ok: false });
19557
- warnings.push(`record ${id} not fetchable; the vendor would reject the delete`);
19558
- return buildDryRunResult({
19559
- operation,
19560
- method: "DELETE",
19561
- path,
19562
- ids: [id],
19563
- checks,
19564
- impact: { affected: 1, scope: "single-record", reversible: false },
19565
- warnings
19566
- }, { redactPersonalData: this.http.redactPersonalData });
19567
- }
19568
- throw err;
19569
- }
19570
- checks.push({ name: "current-record-exists", ok: true });
19571
- if (this.staleObjectCheck(current, opts, operation)) {
19572
- checks.push({ name: "stale-check", ok: true });
19573
- }
19574
- return buildDryRunResult({
19575
- operation,
19576
- method: "DELETE",
19577
- path,
19578
- ids: [id],
19579
- checks,
19580
- impact: { affected: 1, scope: "single-record", reversible: false },
19581
- warnings
19582
- }, { redactPersonalData: this.http.redactPersonalData });
19583
- }
19584
- };
19585
-
19586
- // src/resources/base/BaseResource.ts
19587
- var BaseResource = class extends MutationBase {
19588
- };
19589
-
19590
- // src/operations/compositeNames.ts
19591
- var PICKLIST_VALUE_NOT_SERVED_NOTE = "The picklist is served to this API user, but this record's value is not in it (an inactive or unexposed value) \u2014 the label cannot be resolved and is null; refreshing the field catalog will not surface it. (A picklist the vendor does not expose at all is reported with the field-catalog honesty note instead.)";
19592
- var PICKLIST_CATALOG_UNAVAILABLE_NOTE = "The field-catalog read that serves picklist labels failed for this call, so this label is unavailable (null), not absent: the value may well have one. Retry the call once the catalog read succeeds (see names.catalogError).";
19593
- var DEFAULT_MAX_NAME_READS = 20;
19594
- var TICKET_LABEL_FIELDS = ["status", "priority", "ticketType", "issueType", "subIssueType", "source", "queueID"];
19595
- function isVendorId(value) {
19596
- if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0;
19597
- if (typeof value === "string") return /^\d+$/.test(value);
19598
- return false;
19599
- }
19600
- var zeroIsRecord = (table) => table === "company";
19601
- function createNamePlan(labelFields = [], labelResource) {
19602
- const resourceIds = [];
19603
- const resSeen = /* @__PURE__ */ new Set();
19604
- const recordFks = [];
19605
- const fkSeen = /* @__PURE__ */ new Set();
19606
- let lineOfBusinessId;
19607
- let lineOfBusinessSource;
19608
- let lineOfBusinessContractId;
19609
- return {
19610
- plan: () => ({
19611
- resourceIds,
19612
- recordFks,
19613
- labelFields,
19614
- ...labelResource !== void 0 ? { labelResource } : {},
19615
- ...lineOfBusinessId !== void 0 ? { lineOfBusinessId } : {},
19616
- ...lineOfBusinessSource !== void 0 ? { lineOfBusinessSource } : {},
19617
- ...lineOfBusinessContractId !== void 0 ? { lineOfBusinessContractId } : {}
19618
- }),
19619
- addResource: (value) => {
19620
- if (!isVendorId(value) || Number(value) === 0) return;
19621
- const k = String(value);
19622
- if (!resSeen.has(k)) {
19623
- resSeen.add(k);
19624
- resourceIds.push(value);
19625
- }
19626
- },
19627
- addFk: (table, value) => {
19628
- if (!isVendorId(value) || Number(value) === 0 && !zeroIsRecord(table)) return;
19629
- const k = `${table}:${String(value)}`;
19630
- if (!fkSeen.has(k)) {
19631
- fkSeen.add(k);
19632
- recordFks.push({ table, id: value });
19580
+ body.id = numeric;
19581
+ }
19582
+ const route = collectionUpdate ? `PUT ${this.resourcePath}` : `PUT ${this.resourcePath}/${numeric}`;
19583
+ let res;
19584
+ try {
19585
+ res = await this.http.request({
19586
+ method: "PUT",
19587
+ path: collectionUpdate ? this.resourcePath : `${this.resourcePath}/${numeric}`,
19588
+ body,
19589
+ operation,
19590
+ maxRetries: opts.maxRetries
19591
+ });
19592
+ } catch (err) {
19593
+ if (!this.putFallbackEligible(err)) throw err;
19594
+ if (collectionUpdate) {
19595
+ throw this.putFallbackDeclined(err, route, false);
19633
19596
  }
19634
- },
19635
- addLineOfBusinessFromContract: (value) => {
19636
- if (!isVendorId(value)) return;
19637
- lineOfBusinessContractId = value;
19638
- },
19639
- addLineOfBusiness: (value, origin) => {
19640
- if (!isVendorId(value)) return;
19641
- lineOfBusinessId = value;
19642
- if (origin !== void 0) {
19643
- lineOfBusinessSource = origin.source;
19644
- if (origin.contractId !== void 0) lineOfBusinessContractId = origin.contractId;
19597
+ const merged = await this.putFallbackMergedBody(
19598
+ () => this.getGlobalUnredacted(numeric, { maxRetries: opts.maxRetries }),
19599
+ body,
19600
+ numeric
19601
+ );
19602
+ let retry;
19603
+ try {
19604
+ retry = await this.http.request({
19605
+ method: "PUT",
19606
+ path: `${this.resourcePath}/${numeric}`,
19607
+ body: merged,
19608
+ operation,
19609
+ maxRetries: opts.maxRetries
19610
+ });
19611
+ } catch (rerr) {
19612
+ if (this.putFallbackEligible(rerr)) throw this.putFallbackDeclined(rerr, route, true);
19613
+ throw rerr;
19645
19614
  }
19615
+ return unwrapOperation(retry, operation);
19646
19616
  }
19647
- };
19648
- }
19649
- function addNameSibling(row, field, resolve) {
19650
- const value = row[field];
19651
- if (value === void 0) return;
19652
- row[`${field}Name`] = isVendorId(value) ? resolve(value) : null;
19653
- }
19654
- function addLabelSibling(row, field, names) {
19655
- const value = row[field];
19656
- if (value === void 0) return;
19657
- row[`${field}Label`] = names.labelFor(field, value);
19658
- }
19659
- var FK_TABLE_PATH = {
19660
- role: "/V1.0/Roles",
19661
- billingCode: "/V1.0/BillingCodes",
19662
- contract: "/V1.0/Contracts",
19663
- company: "/V1.0/Companies",
19664
- ticket: "/V1.0/Tickets"
19665
- };
19666
- var FK_NAME_FIELD = {
19667
- role: "name",
19668
- billingCode: "name",
19669
- contract: "contractName",
19670
- company: "companyName",
19671
- ticket: "ticketNumber"
19672
- };
19673
- function fkName(record, table) {
19674
- if (table === "ticket") {
19675
- const parts = [record["ticketNumber"], record["title"]].filter((v) => typeof v === "string" && v.trim() !== "").map((v) => v.trim());
19676
- return parts.length > 0 ? parts.join(" ") : null;
19677
- }
19678
- const value = record[FK_NAME_FIELD[table]];
19679
- return typeof value === "string" && value.trim() !== "" ? value : null;
19680
- }
19681
- function faultRow(err) {
19682
- const e = err;
19683
- return e.httpStatus !== void 0 ? { code: e.code, message: e.message, httpStatus: e.httpStatus } : { code: e.code, message: e.message };
19684
- }
19685
- function vendorId(value) {
19686
- if (typeof value === "number") return Number.isSafeInteger(value) && value >= 1 ? value : void 0;
19687
- if (typeof value === "string" && /^[0-9]+$/.test(value.trim())) {
19688
- const n = Number(value.trim());
19689
- return Number.isSafeInteger(n) && n >= 1 ? n : void 0;
19617
+ return unwrapOperation(res, operation);
19690
19618
  }
19691
- return void 0;
19692
- }
19693
- async function resolveLineOfBusinessChain(http, associationId, options) {
19694
- const operation = options.operation ?? "compositeNames";
19695
- const degrade = options.onError === "degrade";
19696
- const get = (path) => {
19697
- const res = http.request({ method: "GET", path, operation }).then((body) => {
19698
- const b = body;
19699
- if (b === null || !("item" in b) || b.item === null || b.item === void 0) return null;
19700
- return b.item;
19701
- });
19702
- if (!degrade) {
19703
- return res.catch((e) => {
19704
- if (e instanceof NotFoundError) return null;
19705
- throw e;
19706
- });
19707
- }
19708
- return res.catch((e) => {
19709
- if (e instanceof NotFoundError || isAutotaskError(e) && e.code !== "ABORTED") return null;
19710
- throw e;
19619
+ /** `DELETE /V1.0/<E>/{id}` → `{ id }` (OperationResultModel). A blank 2xx
19620
+ * (204 / empty body) is a valid vendor delete answer — synthesize the
19621
+ * result from the path id (issue #25: the transport returns undefined
19622
+ * for it; a non-blank wrong shape still throws via unwrapOperation). */
19623
+ async deleteGlobal(id, opts = {}) {
19624
+ const numeric = parseIdentifier(id, "id");
19625
+ const res = await this.http.request({
19626
+ method: "DELETE",
19627
+ path: `${this.resourcePath}/${numeric}`,
19628
+ operation: `${this.config.key}.delete`,
19629
+ maxRetries: opts.maxRetries
19711
19630
  });
19712
- };
19713
- let remaining = options.budget;
19714
- if (remaining < 1) {
19715
- return {
19716
- lineOfBusiness: { association: null, organizationalLevel2: null, organizationalLevel1: null, markers: { association: "capped", organizationalLevel2: "capped", organizationalLevel1: "capped" } },
19717
- used: 0,
19718
- truncated: true
19719
- };
19720
- }
19721
- remaining -= 1;
19722
- const association = await get(`/V1.0/OrganizationalLevelAssociations/${associationId}`);
19723
- const associationMarker = association === null ? "unresolved" : "ok";
19724
- if (association === null) {
19725
- return {
19726
- lineOfBusiness: { association: null, organizationalLevel2: null, organizationalLevel1: null, markers: { association: associationMarker, organizationalLevel2: "missing", organizationalLevel1: "missing" } },
19727
- used: 1,
19728
- truncated: false
19729
- };
19730
- }
19731
- const l2Id = vendorId(association.organizationalLevel2ID);
19732
- const l1Id = vendorId(association.organizationalLevel1ID);
19733
- let l2Marker = l2Id !== void 0 ? "unresolved" : "missing";
19734
- let l1Marker = l1Id !== void 0 ? "unresolved" : "missing";
19735
- const need = (l2Id !== void 0 ? 1 : 0) + (l1Id !== void 0 ? 1 : 0);
19736
- let l2 = null;
19737
- let l1 = null;
19738
- let used = 1;
19739
- let truncated = false;
19740
- if (need > 0 && remaining < need) {
19741
- truncated = true;
19742
- if (l2Id !== void 0) l2Marker = "capped";
19743
- if (l1Id !== void 0) l1Marker = "capped";
19744
- } else {
19745
- used += need;
19746
- const [r2, r1] = await Promise.all([
19747
- l2Id !== void 0 ? get(`/V1.0/OrganizationalLevel2s/${l2Id}`) : Promise.resolve(null),
19748
- l1Id !== void 0 ? get(`/V1.0/OrganizationalLevel1s/${l1Id}`) : Promise.resolve(null)
19749
- ]);
19750
- l2 = r2;
19751
- l1 = r1;
19752
- if (l2Id !== void 0) l2Marker = l2 === null ? "unresolved" : "ok";
19753
- if (l1Id !== void 0) l1Marker = l1 === null ? "unresolved" : "ok";
19754
- }
19755
- return {
19756
- lineOfBusiness: { association, organizationalLevel2: l2, organizationalLevel1: l1, markers: { association: associationMarker, organizationalLevel2: l2Marker, organizationalLevel1: l1Marker } },
19757
- used,
19758
- truncated
19759
- };
19760
- }
19761
- async function resolveCompositeNames(http, plan, opts = {}) {
19762
- if (opts.maxNameReads !== void 0 && (!Number.isInteger(opts.maxNameReads) || opts.maxNameReads < 1)) {
19763
- throw new AutotaskConfigError(`composite names: maxNameReads must be an integer >= 1 (got ${String(opts.maxNameReads)})`);
19764
- }
19765
- const budget = Math.min(DEFAULT_MAX_NAME_READS, opts.maxNameReads ?? DEFAULT_MAX_NAME_READS);
19766
- const client = http.client;
19767
- if (client === void 0) {
19768
- throw new AutotaskConfigError("composite names: the transport is not wired to an AutotaskClient (the composite must run through the client)");
19769
- }
19770
- const readCache = http.readCache;
19771
- const section = { truncated: false, rows: {}, reasons: {} };
19772
- let remaining = budget;
19773
- let contractLobPointer;
19774
- let contractLobUnread;
19775
- const coldResource = [];
19776
- for (const id of plan.resourceIds) {
19777
- const path = `/V1.0/Resources/${id}`;
19778
- if (readCache !== void 0 && (readCache.peek(path).hit || readCache.peek(contextAbsentKey(path)).hit)) continue;
19779
- coldResource.push(id);
19631
+ return res === void 0 ? { id: numeric } : unwrapOperation(res, `${this.config.key}.delete`);
19780
19632
  }
19781
- const withinBudget = coldResource.slice(0, remaining);
19782
- const overBudgetResource = coldResource.slice(remaining);
19783
- remaining -= withinBudget.length;
19784
- for (const id of overBudgetResource) {
19785
- section.rows[`resource:${id}`] = { name: null, blind: false, capped: true };
19786
- section.truncated = true;
19633
+ /** Parent-scoped create: `POST .../{parentId}/<Child>` (issue #13: a drifted 2xx runs the bounded marker recovery where the plan pins a marker field). */
19634
+ async createScopedRaw(parentId, data, opts = {}) {
19635
+ const body = this.normalizeMutationBody(data);
19636
+ const res = await this.http.request({
19637
+ method: "POST",
19638
+ path: this.scopedCollectionPath(parentId),
19639
+ body,
19640
+ operation: `${this.config.key}.create`,
19641
+ maxRetries: opts.maxRetries
19642
+ });
19643
+ return this.unwrapCreateOperation(res, body, opts);
19787
19644
  }
19788
- const overSet = new Set(overBudgetResource.map((id) => String(id)));
19789
- const opIds = plan.resourceIds.filter((id) => !overSet.has(String(id)));
19790
- if (opIds.length > 0) {
19791
- let pass = opIds;
19792
- if (pass.length > 500) {
19793
- for (const id of pass.slice(500)) {
19794
- section.rows[`resource:${id}`] = { name: null, blind: false, capped: true };
19795
- section.truncated = true;
19645
+ /** Parent-scoped update: `PATCH .../{parentId}/<Child>` — the COLLECTION.
19646
+ * Swagger documents put+patch on the scoped collection and has NO put/patch on
19647
+ * the item path anywhere in the spec; the node strips the id from the path and
19648
+ * PATCHes the collection, and the vendor keys the record by the id in the body. */
19649
+ async updateScopedRaw(parentId, id, data, opts = {}) {
19650
+ const numeric = parseIdentifier(id, "id");
19651
+ const body = this.normalizeMutationBody(data);
19652
+ body.id = numeric;
19653
+ const path = this.scopedCollectionPath(parentId);
19654
+ const operation = `${this.config.key}.update`;
19655
+ let res;
19656
+ try {
19657
+ res = await this.http.request({
19658
+ method: "PATCH",
19659
+ path,
19660
+ body,
19661
+ operation,
19662
+ maxRetries: opts.maxRetries
19663
+ });
19664
+ } catch (err) {
19665
+ if (!this.putFallbackEligible(err)) throw err;
19666
+ const merged = await this.putFallbackMergedBody(
19667
+ () => this.getScopedUnredacted(parentId, numeric, { maxRetries: opts.maxRetries }),
19668
+ body,
19669
+ numeric
19670
+ );
19671
+ let retry;
19672
+ try {
19673
+ retry = await this.http.request({
19674
+ method: "PUT",
19675
+ path: `${path}/${numeric}`,
19676
+ body: merged,
19677
+ operation,
19678
+ maxRetries: opts.maxRetries
19679
+ });
19680
+ } catch (rerr) {
19681
+ if (this.putFallbackEligible(rerr)) throw this.putFallbackDeclined(rerr, `PUT ${path}/${numeric}`, true);
19682
+ throw rerr;
19796
19683
  }
19797
- pass = pass.slice(0, 500);
19684
+ return unwrapOperation(retry, operation);
19798
19685
  }
19799
- const res = await expandResourceNames(client, pass);
19800
- for (const r of res.results) {
19801
- const row = { name: r.name, blind: r.blind, capped: false };
19802
- if (r.error !== void 0) row.error = faultRow(r.error);
19803
- section.rows[`resource:${r.id}`] = row;
19686
+ return unwrapOperation(res, operation);
19687
+ }
19688
+ /** Parent-scoped delete: `DELETE .../{parentId}/<Child>/{id}` → `{ id }`
19689
+ * (a blank 2xx synthesizes `{ id }` from the path id — issue #25). */
19690
+ async deleteScopedRaw(parentId, id, opts = {}) {
19691
+ const numeric = parseIdentifier(id, "id");
19692
+ const res = await this.http.request({
19693
+ method: "DELETE",
19694
+ path: this.scopedItemPath(parentId, id),
19695
+ operation: `${this.config.key}.delete`,
19696
+ maxRetries: opts.maxRetries
19697
+ });
19698
+ return res === void 0 ? { id: numeric } : unwrapOperation(res, `${this.config.key}.delete`);
19699
+ }
19700
+ /**
19701
+ * Attachment delete: the vendor returns the deleted attachment MODEL, not
19702
+ * OperationResultModel (G1 envelope.md) — pass it through typed.
19703
+ */
19704
+ async deleteScopedModel(parentId, id, opts = {}) {
19705
+ const numeric = parseIdentifier(id, "id");
19706
+ const res = await this.http.request({
19707
+ method: "DELETE",
19708
+ path: this.scopedItemPath(parentId, id),
19709
+ operation: `${this.config.key}.delete`,
19710
+ maxRetries: opts.maxRetries
19711
+ });
19712
+ if (res === void 0) return {};
19713
+ if (!isRecord2(res)) {
19714
+ throw new AutotaskError(
19715
+ `Expected the deleted attachment model for ID ${numeric}; got a different shape`,
19716
+ { code: "INVALID_RESPONSE", category: "server", resourceIds: [numeric], details: { idRefs: [{ entity: this.entityName, id: numeric }] } }
19717
+ );
19804
19718
  }
19719
+ return res;
19805
19720
  }
19806
- const holders = {
19807
- role: client.roles,
19808
- billingCode: client.billingCodes,
19809
- contract: client.contracts,
19810
- company: client.companies,
19811
- ticket: client.tickets
19812
- };
19813
- for (const fk of plan.recordFks) {
19814
- const key = `${fk.table}:${fk.id}`;
19815
- const cold = readCache === void 0 || readCache.get(`${FK_TABLE_PATH[fk.table]}/${fk.id}`) === void 0;
19816
- if (cold) {
19817
- if (remaining < 1) {
19818
- section.rows[key] = { name: null, blind: false, capped: true };
19819
- section.truncated = true;
19820
- if (fk.table === "contract" && plan.lineOfBusinessContractId !== void 0 && String(fk.id) === String(plan.lineOfBusinessContractId)) {
19821
- contractLobUnread = "capped";
19822
- }
19823
- continue;
19721
+ async createIfNotExists(dataOrParentId, dataOrOpts, opts = {}) {
19722
+ const isObject = (v) => isRecord2(v) && !Array.isArray(v);
19723
+ const scoped = this.config.parentScopedPath !== void 0;
19724
+ let parentId;
19725
+ let data;
19726
+ let cinOpts;
19727
+ if (scoped) {
19728
+ const dataIsOptsBag = isObject(dataOrOpts) && ("dedupFields" in dataOrOpts || "dryRun" in dataOrOpts);
19729
+ if (!isIdentifierCandidate(dataOrParentId) || !isObject(dataOrOpts) || dataIsOptsBag) {
19730
+ throw new AutotaskConfigError(
19731
+ `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`
19732
+ );
19824
19733
  }
19825
- remaining -= 1;
19734
+ parentId = parseIdentifier(dataOrParentId, "parentId");
19735
+ data = dataOrOpts;
19736
+ cinOpts = opts;
19737
+ } else {
19738
+ if (!isObject(dataOrParentId)) {
19739
+ throw new AutotaskConfigError(
19740
+ `createIfNotExists for top-level resource ${this.config.key} takes (data[, opts]) \u2014 got a non-record first argument`
19741
+ );
19742
+ }
19743
+ data = dataOrParentId;
19744
+ cinOpts = isObject(dataOrOpts) ? dataOrOpts : {};
19826
19745
  }
19827
- try {
19828
- const record = await holders[fk.table].get(fk.id);
19829
- if (fk.table === "contract" && plan.lineOfBusinessId === void 0 && plan.lineOfBusinessContractId !== void 0 && String(fk.id) === String(plan.lineOfBusinessContractId)) {
19830
- contractLobPointer = vendorId(record["organizationalLevelAssociationID"]);
19746
+ const record = isRecord2(data) ? data : {};
19747
+ const dedupFields = (cinOpts.dedupFields ?? []).filter((field) => field in record);
19748
+ let existing;
19749
+ let existingId;
19750
+ if (dedupFields.length > 0) {
19751
+ const conditions = dedupFields.map((field) => ({
19752
+ field,
19753
+ op: "eq",
19754
+ value: record[field]
19755
+ }));
19756
+ if (parentId !== void 0 && this.config.parentField !== void 0) {
19757
+ conditions.unshift({ field: this.config.parentField, op: "eq", value: parentId });
19831
19758
  }
19832
- section.rows[key] = { name: fkName(record, fk.table), blind: false, capped: false };
19833
- } catch (err) {
19834
- if (err instanceof NotFoundError) {
19835
- section.rows[key] = { name: null, blind: true, capped: false };
19836
- } else if (isAutotaskError(err) && err.code !== "ABORTED") {
19837
- section.rows[key] = { name: null, blind: false, capped: false, error: faultRow(err) };
19838
- if (fk.table === "contract" && plan.lineOfBusinessContractId !== void 0 && String(fk.id) === String(plan.lineOfBusinessContractId)) {
19839
- contractLobUnread = "unresolved";
19759
+ const matches = await this.queryEntity(`${this.resourcePath}/query`, conditions, { maxRecords: 10 });
19760
+ const first = matches[0];
19761
+ if (first !== void 0) {
19762
+ const firstId = first.id;
19763
+ if (isIdentifierCandidate(firstId)) {
19764
+ existing = first;
19765
+ existingId = parseIdentifier(firstId, "id");
19840
19766
  }
19841
- } else {
19842
- throw err;
19843
19767
  }
19844
19768
  }
19845
- }
19846
- const picklists = {};
19847
- if (plan.labelFields.length > 0) {
19848
- let usable = true;
19849
- let failure;
19850
- try {
19851
- const labelResource = plan.labelResource ?? "tickets";
19852
- const catalog = await fieldCatalog(client, { resources: [labelResource], labels: true, fields: [...plan.labelFields] });
19853
- const row = catalog.rows.find((r) => r.resource === labelResource);
19854
- if (row === void 0 || row.error !== null || row.fields === void 0) {
19855
- usable = false;
19856
- failure = row?.error ?? { code: "INVALID_RESPONSE", message: `fieldCatalog: no ${labelResource} row` };
19857
- } else {
19858
- for (const f of plan.labelFields) {
19859
- const frow = row.fields.find((x) => x.name === f);
19860
- picklists[f] = frow?.picklistValues;
19861
- }
19769
+ if (cinOpts.dryRun === true) {
19770
+ const checks = [
19771
+ { name: "input-validation", ok: isRecord2(data) && Object.keys(data).length > 0 }
19772
+ ];
19773
+ if (dedupFields.length > 0) {
19774
+ checks.push({ name: "duplicate-detection", ok: existingId === void 0 });
19862
19775
  }
19863
- } catch (err) {
19864
- if (isAutotaskError(err) && err.code !== "ABORTED") {
19865
- usable = false;
19866
- failure = { code: err.code, message: err.message };
19867
- } else throw err;
19776
+ const warnings = existingId !== void 0 ? [`duplicate match on ${dedupFields.join(", ")} (id ${existingId}); the create would be skipped`] : [];
19777
+ return buildDryRunResult({
19778
+ operation: `${this.config.key}.createIfNotExists`,
19779
+ method: "POST",
19780
+ path: parentId !== void 0 ? this.scopedCollectionPath(parentId) : this.resourcePath,
19781
+ ids: existingId !== void 0 ? [existingId] : [],
19782
+ checks,
19783
+ warnings,
19784
+ impact: { affected: 1, scope: "single-record", reversible: true },
19785
+ data
19786
+ }, { redactPersonalData: this.http.redactPersonalData });
19868
19787
  }
19869
- if (!usable) {
19870
- section.catalogError = failure;
19871
- for (const f of plan.labelFields) section.reasons[f] = PICKLIST_CATALOG_UNAVAILABLE_NOTE;
19788
+ if (existing !== void 0 && existingId !== void 0) {
19789
+ return { id: existingId, created: false, existing };
19872
19790
  }
19791
+ const op = parentId !== void 0 ? await this.createScopedRaw(parentId, data) : await this.createGlobal(data);
19792
+ return { id: op.id, created: true };
19873
19793
  }
19874
- let lineOfBusiness2 = null;
19875
- const lobId = plan.lineOfBusinessId ?? contractLobPointer;
19876
- if (lobId !== void 0) {
19877
- const chain = await resolveLineOfBusinessChain(http, lobId, { budget: remaining, onError: "degrade" });
19878
- remaining -= chain.used;
19879
- if (chain.truncated) section.truncated = true;
19880
- const fromContract = plan.lineOfBusinessId === void 0;
19881
- lineOfBusiness2 = {
19882
- ...chain.lineOfBusiness,
19883
- ...fromContract ? { source: "contract", contractId: plan.lineOfBusinessContractId } : plan.lineOfBusinessSource !== void 0 ? { source: plan.lineOfBusinessSource } : {},
19884
- ...!fromContract && plan.lineOfBusinessContractId !== void 0 ? { contractId: plan.lineOfBusinessContractId } : {}
19885
- };
19886
- } else if (contractLobUnread !== void 0 && plan.lineOfBusinessContractId !== void 0) {
19887
- lineOfBusiness2 = {
19888
- source: "contract",
19889
- contractId: plan.lineOfBusinessContractId,
19890
- association: null,
19891
- organizationalLevel2: null,
19892
- organizationalLevel1: null,
19893
- markers: { association: contractLobUnread, organizationalLevel2: "missing", organizationalLevel1: "missing" }
19894
- };
19794
+ // ------------------------------------------------------------------
19795
+ // Dry-run scaffolding + stale-object guard (mutation safety, section 19.3)
19796
+ // ------------------------------------------------------------------
19797
+ /**
19798
+ * Stale-object guard (section 19.3): compare the plan's `staleCheck` field
19799
+ * (vendor `lastModifiedDate`) against the caller-observed value. Mismatch
19800
+ * throws `STALE_OBJECT`. Returns true when a comparison was actually made
19801
+ * (the resource model carries the field AND the vendor answered it AND the
19802
+ * caller supplied an expected value); false when the check is unavailable.
19803
+ */
19804
+ staleObjectCheck(current, opts, operation) {
19805
+ const field = this.config.staleField;
19806
+ const expected = opts.lastModifiedDate;
19807
+ if (field === void 0 || expected === void 0) return false;
19808
+ const actual = current[field];
19809
+ if (typeof actual !== "string") return false;
19810
+ if (actual !== expected) {
19811
+ throw new StaleObjectError(
19812
+ `stale object: ${this.config.key} ${field} changed since the caller read it (expected ${expected}, current ${actual})`,
19813
+ {
19814
+ code: "STALE_OBJECT",
19815
+ operation,
19816
+ suggestedAction: "Re-read the record and retry with the fresh lastModifiedDate."
19817
+ }
19818
+ );
19819
+ }
19820
+ return true;
19895
19821
  }
19896
- return {
19897
- section,
19898
- lineOfBusiness: lineOfBusiness2,
19899
- resourceName: (id) => section.rows[`resource:${id}`]?.name ?? null,
19900
- recordName: (table, id) => section.rows[`${table}:${id}`]?.name ?? null,
19901
- labelFor: (field, value) => {
19902
- if (value === null || value === void 0) return null;
19903
- const values = picklists[field];
19904
- if (values === void 0) {
19905
- section.reasons[field] = section.catalogError !== void 0 ? PICKLIST_CATALOG_UNAVAILABLE_NOTE : FIELD_CATALOG_HONESTY_NOTE;
19906
- return null;
19822
+ /**
19823
+ * Stale-object guard for a REAL mutation: the caller supplied an expected
19824
+ * `lastModifiedDate` and the model carries the field → fetch the current
19825
+ * record and compare before the mutating request (mismatch throws
19826
+ * STALE_OBJECT; a vanished record propagates the vendor's NotFoundError).
19827
+ */
19828
+ async guardStaleBeforeMutation(id, opts, operation, parentId) {
19829
+ if (opts.lastModifiedDate === void 0 || this.config.staleField === void 0) return;
19830
+ let current;
19831
+ try {
19832
+ current = parentId === void 0 ? await this.getGlobal(id, true) : this.config.attachmentGet ? await this.getScopedModel(parentId, id, true) : await this.getScopedRaw(parentId, id, true);
19833
+ } catch (err) {
19834
+ if (err instanceof NotFoundError) attachUpdateTargetMissReason(err, this.config.key);
19835
+ throw err;
19836
+ }
19837
+ this.staleObjectCheck(current, opts, operation);
19838
+ }
19839
+ /**
19840
+ * `create` dry-run: input validation + parent verification (scoped
19841
+ * resources) + the pinned envelope. NO request is issued (scoped parent
19842
+ * verification is a read). Duplicate detection has no reliable lookup for
19843
+ * a plain create (that is `createIfNotExists`), so it is not claimed.
19844
+ */
19845
+ async runCreateDryRun(data, parentId) {
19846
+ const checks = [];
19847
+ const warnings = [];
19848
+ checks.push({ name: "input-validation", ok: isRecord2(data) && Object.keys(data).length > 0 });
19849
+ const deleteBoundaryNote = DELETE_BOUNDARY_NOTES[this.config.key];
19850
+ if (deleteBoundaryNote !== void 0) {
19851
+ checks.push({ name: "delete-boundary", ok: true });
19852
+ warnings.push(deleteBoundaryNote);
19853
+ }
19854
+ let path = this.resourcePath;
19855
+ if (parentId !== void 0) {
19856
+ path = this.scopedCollectionPath(parentId);
19857
+ const parentPath = this.config.parentPath;
19858
+ if (parentPath === void 0) {
19859
+ throw new AutotaskConfigError(
19860
+ `dry-run: ${this.config.key} is parent-scoped but has no parentPath configured`
19861
+ );
19862
+ }
19863
+ let parent = null;
19864
+ if (isIdentifierCandidate(parentId)) {
19865
+ try {
19866
+ parent = await this.fetchRecordRaw(
19867
+ parentPath,
19868
+ parseIdentifier(parentId, "parentId"),
19869
+ true
19870
+ );
19871
+ } catch (err) {
19872
+ if (!(err instanceof NotFoundError)) throw err;
19873
+ }
19907
19874
  }
19908
- const hit = values.find((v) => String(v.value) === String(value));
19909
- if (hit === void 0) {
19910
- if (String(value) !== "0") section.reasons[field] = PICKLIST_VALUE_NOT_SERVED_NOTE;
19911
- return null;
19875
+ checks.push({ name: "parent-exists", ok: parent !== null });
19876
+ if (parent === null) {
19877
+ warnings.push(`parent ${parentId} not found; the vendor would reject the create`);
19912
19878
  }
19913
- return typeof hit.label === "string" ? hit.label : null;
19914
19879
  }
19915
- };
19916
- }
19917
- var ROW_FK_TABLES = {
19918
- companyID: "company",
19919
- contractID: "contract",
19920
- ticketID: "ticket",
19921
- roleID: "role",
19922
- billingCodeID: "billingCode"
19923
- };
19924
- var isResourceFkField = (field) => field === "resourceID" || field.endsWith("ResourceID");
19925
- var LABEL_NAME_MAX_CHARS = 200;
19926
- function untrustedName(name) {
19927
- if (name === null) return null;
19928
- const [[capped]] = truncateNoteChars([{ v: name }], LABEL_NAME_MAX_CHARS, ["v"]);
19929
- return wrapUntrusted(capped.v) ?? capped.v;
19930
- }
19931
- var lastPicklists = /* @__PURE__ */ new WeakMap();
19932
- function readNamesOpt(value, where) {
19933
- if (value === void 0) return true;
19934
- if (typeof value !== "boolean") {
19935
- throw new AutotaskConfigError(`${where}: names must be a boolean (got ${typeof value}) \u2014 true (default) adds label siblings, false returns raw ids only`);
19936
- }
19937
- return value;
19938
- }
19939
- async function labelRows(client, resourceKey, rows, resourceKeyedReasons = false) {
19940
- const keys = /* @__PURE__ */ new Set();
19941
- for (const row of rows) for (const k of Object.keys(row)) keys.add(k);
19942
- if (keys.size === 0) return { rows: [...rows] };
19943
- let picklistFields = [];
19944
- let unavailable = [];
19945
- let catalogError;
19946
- const modelFields = MODEL_FIELD_SETS[resourceKey] ?? [];
19947
- const candidates = [...keys].filter((f) => f !== "id" && modelFields.includes(f) && ROW_FK_TABLES[f] === void 0 && !isResourceFkField(f));
19948
- if (candidates.length > 0) try {
19949
- const catalog = await fieldCatalog(client, { resources: [resourceKey], labels: true });
19950
- const row = catalog.rows.find((r) => r.resource === resourceKey);
19951
- const known = lastPicklists.get(client) ?? /* @__PURE__ */ new Map();
19952
- lastPicklists.set(client, known);
19953
- if (row?.fields === void 0) {
19954
- const last = known.get(resourceKey);
19955
- unavailable = (last ?? []).filter((f) => candidates.includes(f));
19956
- if (last === void 0 || unavailable.length > 0) {
19957
- catalogError = row?.error ?? { code: "INVALID_RESPONSE", message: `fieldCatalog: no ${resourceKey} row` };
19880
+ if (isRecord2(data)) {
19881
+ const eff = effectiveRequiredFields(void 0, RELATIONSHIP_MAP[this.config.key]?.zoneFacts ?? null);
19882
+ const missing = eff.fields.filter((f) => f !== this.config.parentField && data[f] === void 0);
19883
+ if (missing.length > 0) {
19884
+ warnings.push(`required field(s) not in the data: ${missing.join(", ")} (${eff.from}); the vendor would likely reject the create`);
19958
19885
  }
19959
- } else {
19960
- const all = row.fields.filter((f) => f.isPickList).map((f) => f.name);
19961
- known.set(resourceKey, all);
19962
- picklistFields = all.filter((f) => candidates.includes(f));
19963
19886
  }
19964
- } catch (err) {
19965
- if (!isAutotaskError(err) || err.code === "ABORTED") throw err;
19966
- }
19967
- const labelled = (field) => picklistFields.includes(field) || unavailable.includes(field) || ROW_FK_TABLES[field] !== void 0 || isResourceFkField(field);
19968
- if (catalogError === void 0 && ![...keys].some(labelled)) return { rows: [...rows] };
19969
- const builder = createNamePlan(picklistFields, resourceKey);
19970
- for (const row of rows) {
19971
- for (const [field, value] of Object.entries(row)) {
19972
- const table = ROW_FK_TABLES[field];
19973
- if ((value === 0 || value === "0") && !zeroIsRecord(table)) continue;
19974
- if (table !== void 0) builder.addFk(table, value);
19975
- else if (isResourceFkField(field)) builder.addResource(value);
19887
+ if (this.config.key === "companies") {
19888
+ warnings.push('the vendor auto-creates a "Primary Address" location for a new company (rename it with company_locations.update; it cannot be deleted via the API)');
19976
19889
  }
19890
+ return buildDryRunResult({
19891
+ operation: `${this.config.key}.create`,
19892
+ method: "POST",
19893
+ path,
19894
+ ids: [],
19895
+ checks,
19896
+ impact: { affected: 1, scope: "single-record", reversible: true },
19897
+ warnings,
19898
+ data
19899
+ }, { redactPersonalData: this.http.redactPersonalData });
19977
19900
  }
19978
- const plan = builder.plan();
19979
- const names = plan.resourceIds.length === 0 && plan.recordFks.length === 0 && picklistFields.length === 0 ? void 0 : await resolveCompositeNames(client.http, plan);
19980
- const secondReadFailed = names?.section.catalogError;
19981
- if (secondReadFailed !== void 0) {
19982
- catalogError ??= secondReadFailed;
19983
- unavailable = [.../* @__PURE__ */ new Set([...unavailable, ...picklistFields])];
19984
- }
19985
- const out = rows.map((row) => {
19986
- const r = { ...row };
19987
- for (const [field, value] of Object.entries(row)) {
19988
- const has2 = value !== null && value !== void 0 && names !== void 0;
19989
- if (picklistFields.includes(field)) r[`${field}Label`] = has2 ? names.labelFor(field, value) : null;
19990
- else if (unavailable.includes(field)) r[`${field}Label`] = null;
19991
- const table = ROW_FK_TABLES[field];
19992
- const set = has2 && (zeroIsRecord(table) || value !== 0 && value !== "0");
19993
- if (table !== void 0) r[`${field}Name`] = set && isVendorId(value) ? untrustedName(names.recordName(table, value)) : null;
19994
- else if (isResourceFkField(field)) r[`${field}Name`] = set && isVendorId(value) ? untrustedName(names.resourceName(value)) : null;
19901
+ /**
19902
+ * `update` dry-run: input validation + current-record fetch (a missing
19903
+ * record carries `warnings` and no `diff` — pinned), the stale-object
19904
+ * check (mismatch throws STALE_OBJECT) and the best-effort `diff`. NO
19905
+ * mutating request is issued.
19906
+ */
19907
+ async runUpdateDryRun(id, data, opts = {}, parentId) {
19908
+ const operation = `${this.config.key}.update`;
19909
+ const checks = [];
19910
+ const warnings = [];
19911
+ checks.push({ name: "input-validation", ok: isRecord2(data) && Object.keys(data).length > 0 });
19912
+ const scopedUpdate = parentId !== void 0 && this.config.parentScopedPath !== void 0;
19913
+ const collectionUpdate = scopedUpdate || this.config.collectionUpdate === true;
19914
+ const path = scopedUpdate ? this.scopedCollectionPath(parentId) : collectionUpdate ? this.resourcePath : `${this.resourcePath}/${id}`;
19915
+ const method = scopedUpdate ? "PATCH" : "PUT";
19916
+ let current = null;
19917
+ try {
19918
+ current = parentId === void 0 ? await this.getGlobal(id, true) : this.config.attachmentGet ? await this.getScopedModel(parentId, id, true) : await this.getScopedRaw(parentId, id, true);
19919
+ } catch (err) {
19920
+ if (err instanceof NotFoundError) {
19921
+ const updateReason = EMPTY_REASON_STAMPS[this.config.key]?.update;
19922
+ checks.push({
19923
+ name: "current-record-exists",
19924
+ ok: false,
19925
+ ...updateReason !== void 0 ? { note: updateReason.note } : {}
19926
+ });
19927
+ warnings.push(`current record ${id} not fetchable; no diff`);
19928
+ return buildDryRunResult({
19929
+ operation,
19930
+ method,
19931
+ path,
19932
+ ids: [id],
19933
+ checks,
19934
+ impact: { affected: 1, scope: "single-record", reversible: true },
19935
+ warnings,
19936
+ data,
19937
+ ...updateReason !== void 0 ? { emptyReason: updateReason } : {}
19938
+ }, { redactPersonalData: this.http.redactPersonalData });
19939
+ }
19940
+ throw err;
19995
19941
  }
19996
- if (r["assignedResourceIDName"] !== void 0) r["assignedResourceName"] = r["assignedResourceIDName"];
19997
- return r;
19998
- });
19999
- const capped = names === void 0 ? 0 : Object.values(names.section.rows).filter((row) => row.capped).length;
20000
- const unresolved = Object.keys(names?.section.reasons ?? {});
20001
- const reasons = { ...names?.section.reasons };
20002
- for (const f of unavailable) reasons[f] = PICKLIST_CATALOG_UNAVAILABLE_NOTE;
20003
- if (capped === 0 && unresolved.length === 0 && catalogError === void 0) return { rows: out };
20004
- const parts = [];
20005
- if (capped > 0) parts.push(`${capped} id(s) left unlabelled (their *Name is null): the per-call cap of ${DEFAULT_MAX_NAME_READS} uncached name reads was hit \u2014 names already read are cached, so repeating or narrowing the call labels more`);
20006
- if (unresolved.length > 0) parts.push(`picklist label(s) unavailable for ${unresolved.map((f) => resourceKeyedReasons ? `${resourceKey}.${f}` : f).join(", ")} (see reasons)`);
20007
- if (catalogError !== void 0) {
20008
- parts.push(
20009
- `the ${resourceKey} field catalog read failed (${catalogError.code}), so picklist labels are unavailable for this call, not absent: ` + (unavailable.length > 0 ? `the *Label is null for ${unavailable.join(", ")}` : "no *Label sibling was added (the picklist fields are unknown until a catalog read succeeds)") + " \u2014 retry the call to label them"
20010
- );
19942
+ checks.push({ name: "current-record-exists", ok: true });
19943
+ if (this.staleObjectCheck(current, opts, operation)) {
19944
+ checks.push({ name: "stale-check", ok: true });
19945
+ }
19946
+ let envelopeData = data;
19947
+ if (parentId === void 0 && this.config.collectionUpdate === true && opts.raw !== true) {
19948
+ envelopeData = mergePartialBody(
19949
+ current,
19950
+ this.normalizeMutationBody(data)
19951
+ );
19952
+ }
19953
+ return buildDryRunResult({
19954
+ operation,
19955
+ method,
19956
+ path,
19957
+ ids: [id],
19958
+ checks,
19959
+ diff: computeFieldDiff(current, data),
19960
+ impact: { affected: 1, scope: "single-record", reversible: true },
19961
+ warnings,
19962
+ data: envelopeData
19963
+ }, { redactPersonalData: this.http.redactPersonalData });
20011
19964
  }
20012
- return {
20013
- rows: out,
20014
- names: {
20015
- truncated: capped > 0,
20016
- capped,
20017
- note: parts.join("; "),
20018
- ...Object.keys(reasons).length > 0 ? { reasons } : {},
20019
- ...catalogError !== void 0 ? { catalogError } : {}
19965
+ /**
19966
+ * `delete` dry-run: current-record verification + the stale-object check +
19967
+ * the pinned envelope (no diff — a delete has no before/after fields). NO
19968
+ * mutating request is issued.
19969
+ */
19970
+ async runDeleteDryRun(id, opts = {}, parentId) {
19971
+ const operation = `${this.config.key}.delete`;
19972
+ const checks = [];
19973
+ const warnings = [];
19974
+ const path = parentId === void 0 ? `${this.resourcePath}/${id}` : this.scopedItemPath(parentId, id);
19975
+ let current = null;
19976
+ try {
19977
+ current = parentId === void 0 ? await this.getGlobal(id, true) : this.config.attachmentGet ? await this.getScopedModel(parentId, id, true) : await this.getScopedRaw(parentId, id, true);
19978
+ } catch (err) {
19979
+ if (err instanceof NotFoundError) {
19980
+ checks.push({ name: "current-record-exists", ok: false });
19981
+ warnings.push(`record ${id} not fetchable; the vendor would reject the delete`);
19982
+ return buildDryRunResult({
19983
+ operation,
19984
+ method: "DELETE",
19985
+ path,
19986
+ ids: [id],
19987
+ checks,
19988
+ impact: { affected: 1, scope: "single-record", reversible: false },
19989
+ warnings
19990
+ }, { redactPersonalData: this.http.redactPersonalData });
19991
+ }
19992
+ throw err;
20020
19993
  }
20021
- };
20022
- }
20023
- async function labelFetchResults(client, result) {
20024
- const byResource = /* @__PURE__ */ new Map();
20025
- result.results.forEach((r, i) => {
20026
- if (r.found && r.value !== null) byResource.set(r.resource, [...byResource.get(r.resource) ?? [], i]);
20027
- });
20028
- const results = [...result.results];
20029
- const summaries = [];
20030
- const reasons = {};
20031
- const catalogErrors = {};
20032
- for (const [resource, indices] of byResource) {
20033
- const labelled = await labelRows(client, resource, indices.map((i) => results[i].value), true);
20034
- indices.forEach((idx, j) => {
20035
- results[idx] = { ...results[idx], value: labelled.rows[j] };
20036
- });
20037
- if (labelled.names !== void 0) {
20038
- summaries.push(labelled.names);
20039
- for (const [field, why] of Object.entries(labelled.names.reasons ?? {})) reasons[`${resource}.${field}`] = why;
20040
- if (labelled.names.catalogError !== void 0) catalogErrors[resource] = labelled.names.catalogError;
20041
- }
20042
- }
20043
- if (summaries.length === 0) return { ...result, results };
20044
- const catalogError = Object.values(catalogErrors)[0];
20045
- const names = {
20046
- truncated: summaries.some((s) => s.truncated),
20047
- capped: summaries.reduce((n, s) => n + s.capped, 0),
20048
- note: summaries.map((s) => s.note).join("; "),
20049
- ...Object.keys(reasons).length > 0 ? { reasons } : {},
20050
- ...catalogError !== void 0 ? { catalogError, catalogErrors } : {}
20051
- };
20052
- return { ...result, results, names };
20053
- }
19994
+ checks.push({ name: "current-record-exists", ok: true });
19995
+ if (this.staleObjectCheck(current, opts, operation)) {
19996
+ checks.push({ name: "stale-check", ok: true });
19997
+ }
19998
+ return buildDryRunResult({
19999
+ operation,
20000
+ method: "DELETE",
20001
+ path,
20002
+ ids: [id],
20003
+ checks,
20004
+ impact: { affected: 1, scope: "single-record", reversible: false },
20005
+ warnings
20006
+ }, { redactPersonalData: this.http.redactPersonalData });
20007
+ }
20008
+ };
20009
+
20010
+ // src/resources/base/BaseResource.ts
20011
+ var BaseResource = class extends MutationBase {
20012
+ };
20054
20013
 
20055
20014
  // src/resources/billing_codes.ts
20056
20015
  var BillingCodesResource = class extends BaseResource {
@@ -20954,7 +20913,7 @@ function formatUnknownNameList(names, cap = UNKNOWN_NAME_LIST_CAP) {
20954
20913
  const more = names.length > cap ? ` (and ${names.length - cap} more)` : "";
20955
20914
  return `${listed}${more}`;
20956
20915
  }
20957
- function collectFilterFieldNames(conditions) {
20916
+ function collectFilterFieldNames(conditions, resourceKey) {
20958
20917
  const out = [];
20959
20918
  const walk = (conds) => {
20960
20919
  for (const cond of conds) {
@@ -20964,7 +20923,16 @@ function collectFilterFieldNames(conditions) {
20964
20923
  continue;
20965
20924
  }
20966
20925
  if (cond.udf === true) continue;
20967
- if (typeof cond.field === "string" && cond.field.length > 0 && !cond.field.includes(".")) out.push(cond.field);
20926
+ if (typeof cond.field === "string" && cond.field.length > 0) {
20927
+ if (!cond.field.includes(".")) {
20928
+ out.push(cond.field);
20929
+ continue;
20930
+ }
20931
+ if (resourceKey !== void 0) {
20932
+ const parts = cond.field.split(".");
20933
+ if (parts.length === 2 && selfPrefixes(resourceKey).includes(parts[0])) out.push(parts[1]);
20934
+ }
20935
+ }
20968
20936
  }
20969
20937
  };
20970
20938
  walk(conditions);
@@ -21027,10 +20995,17 @@ function unknownNamesRefusal(record, authority, known, unknownInclude, unknownFi
21027
20995
  message,
21028
20996
  {
21029
20997
  operation: record.id,
21030
- suggestedAction: `Use exact vendor field names (operations.fieldCatalog with resources: ["${record.resource}"] lists the live set for the entity); pass fieldCheck: false to disable this name check. The authority here was ${authority}.`
20998
+ suggestedAction: unknownNamesSuggestedAction(record, authority, unknownFilter.length > 0)
21031
20999
  }
21032
21000
  );
21033
21001
  }
21002
+ function unknownNamesSuggestedAction(record, authority, filterUnknown) {
21003
+ const vendor = `Use exact vendor field names (operations.fieldCatalog resources: ["${record.resource}"] lists the live set).`;
21004
+ if (filterUnknown && record.udfCapable) {
21005
+ return `${vendor} UDF names: filter with udf: true (max 1 per query); names + picklist ids via fieldCatalog resources: ["${record.resource}"], include: ["udf"], labels: true; fieldCheck: false = vendor names only (never UDF names). Authority: ${authority}.`;
21006
+ }
21007
+ return `${vendor} Pass fieldCheck: false to disable this name check. The authority here was ${authority}.`;
21008
+ }
21034
21009
  async function validateQueryFieldNames(record, holder, args) {
21035
21010
  const bag = args[0];
21036
21011
  if (!isRecord2(bag)) return;
@@ -21039,7 +21014,7 @@ async function validateQueryFieldNames(record, holder, args) {
21039
21014
  let filterNames;
21040
21015
  if (bag["filter"] !== void 0) {
21041
21016
  const conditions = normalizedConditions(bag["filter"]);
21042
- filterNames = collectFilterFieldNames(conditions ?? []);
21017
+ filterNames = collectFilterFieldNames(conditions ?? [], record.resource);
21043
21018
  }
21044
21019
  if (fieldCheck === false) return;
21045
21020
  const found = await findUnknownFieldNames(record, holder, { includeFields: includeNames ?? [], filter: filterNames ?? [] });
@@ -21653,7 +21628,22 @@ async function readBillingPart(http, v, companyIds) {
21653
21628
  if (v.dateFrom !== void 0) billingConditions.push({ field: "itemDate", op: "gte", value: v.dateFrom });
21654
21629
  if (v.dateTo !== void 0) billingConditions.push({ field: "itemDate", op: "lte", value: v.dateTo });
21655
21630
  }
21656
- const billingFields = resolveFieldsPreset("billing_join", "billing_items");
21631
+ const billingFields = [
21632
+ "companyID",
21633
+ "ticketID",
21634
+ "projectID",
21635
+ "timeEntryID",
21636
+ "contractServiceID",
21637
+ "contractID",
21638
+ "billingCodeID",
21639
+ "expenseItemID",
21640
+ "itemDate",
21641
+ "quantity",
21642
+ "rate",
21643
+ "extendedPrice",
21644
+ "totalAmount",
21645
+ "nonBillable"
21646
+ ];
21657
21647
  const billingIncludeFields = [...billingFields, "id", ...v.includeNotes ? ["description"] : []];
21658
21648
  const billingRaw = await boundedPart(http, "/V1.0/BillingItems/query", billingConditions, v.limit, OP3, billingIncludeFields);
21659
21649
  let billingItems = billingRaw.items;
@@ -22038,7 +22028,7 @@ async function fetchMany(client, items, opts = {}) {
22038
22028
  if (opts.liveFields !== void 0 && typeof opts.liveFields !== "boolean") {
22039
22029
  throw new AutotaskConfigError(`fetchMany: liveFields must be a boolean (got ${typeof opts.liveFields})`);
22040
22030
  }
22041
- const labels = readNamesOpt(opts.names, "fetchMany");
22031
+ const labels = readNamesOpt(client, opts.names, "fetchMany");
22042
22032
  const cap = Math.min(FETCH_MANY_MAX_ITEMS, opts.maxItems ?? FETCH_MANY_MAX_ITEMS);
22043
22033
  if (opts.liveFields === true) {
22044
22034
  validateFetchManyItems(items, cap, void 0, void 0, { fieldCheck: false });
@@ -22123,7 +22113,7 @@ async function fetchByIds(client, items, opts = {}) {
22123
22113
  throw new AutotaskConfigError(`fetchByIds: maxItems must be an integer >= 1 (got ${String(opts.maxItems)})`);
22124
22114
  }
22125
22115
  }
22126
- const labels = readNamesOpt(opts.names, "fetchByIds");
22116
+ const labels = readNamesOpt(client, opts.names, "fetchByIds");
22127
22117
  const cap = Math.min(FETCH_BY_IDS_MAX_ITEMS, opts.maxItems ?? FETCH_BY_IDS_MAX_ITEMS);
22128
22118
  validateFetchManyItems(items, cap, "fetchByIds", `FETCH_BY_IDS_MAX_ITEMS = ${FETCH_BY_IDS_MAX_ITEMS}`);
22129
22119
  const groups = /* @__PURE__ */ new Map();
@@ -22729,7 +22719,7 @@ function appendWiresNote(wires, note) {
22729
22719
  wires.note = (wires.note ? wires.note + " | " : "") + note;
22730
22720
  }
22731
22721
  function makeFetchCtx(rawHttp, v, contractId, budget) {
22732
- const OP9 = PROFITABILITY_OP;
22722
+ const OP8 = PROFITABILITY_OP;
22733
22723
  const id = parseIdentifier(contractId, "contractId");
22734
22724
  const idNum = typeof id === "number" ? id : Number(id);
22735
22725
  const wires = { issued: 0, requests: 0, budget };
@@ -22741,19 +22731,19 @@ function makeFetchCtx(rawHttp, v, contractId, budget) {
22741
22731
  } };
22742
22732
  const readPart = (path, conditions, fields) => {
22743
22733
  wires.issued += 1;
22744
- return degradable(() => boundedPart(http, path, conditions, v.limit, OP9, fields));
22734
+ return degradable(() => boundedPart(http, path, conditions, v.limit, OP8, fields));
22745
22735
  };
22746
22736
  const truncatedMonths = {};
22747
22737
  const windowedPart = async (name, key, path, cond, fields, dateField) => {
22748
22738
  const first = await readPart(path, cond, fields);
22749
22739
  if (first.absent !== void 0 || !first.hasMore) return { part: stampPart(key, first, cond), rows: first.items };
22750
- const wp = await degradable(() => expandByMonth(http, path, cond, v.limit, OP9, fields, dateField, v.dateFrom, v.dateTo, wires));
22740
+ const wp = await degradable(() => expandByMonth(http, path, cond, v.limit, OP8, fields, dateField, v.dateFrom, v.dateTo, wires));
22751
22741
  if (!("truncatedMonths" in wp)) return { part: stampPart(key, wp, cond), rows: [] };
22752
22742
  const { truncatedMonths: tm, ...part } = wp;
22753
22743
  truncatedMonths[name] = tm;
22754
22744
  return { part, rows: wp.items };
22755
22745
  };
22756
- return { v, OP: OP9, id, idNum, rawHttp, http, wires, windowOn, readPart, windowedPart, truncatedMonths };
22746
+ return { v, OP: OP8, id, idNum, rawHttp, http, wires, windowOn, readPart, windowedPart, truncatedMonths };
22757
22747
  }
22758
22748
 
22759
22749
  // src/resources/special/contracts/profitability.stages.ts
@@ -24385,50 +24375,50 @@ async function queryTimeEntries(_resource, filter, limit, operation, http, curso
24385
24375
  const { items, pageDetails } = unwrapItems(res);
24386
24376
  return { items, hasMore: pageDetails.nextPageUrl != null, nextCursor: pageDetails.nextPageUrl ?? null };
24387
24377
  }
24388
- function pagingOpts(opts, OP9) {
24378
+ function pagingOpts(opts, OP8) {
24389
24379
  if (opts?.countOnly !== void 0 && typeof opts.countOnly !== "boolean") {
24390
- throw new AutotaskConfigError(`${OP9}: countOnly must be a boolean`, { operation: OP9 });
24380
+ throw new AutotaskConfigError(`${OP8}: countOnly must be a boolean`, { operation: OP8 });
24391
24381
  }
24392
24382
  const countOnly = opts?.countOnly === true;
24393
24383
  const cursor = opts?.cursor;
24394
24384
  if (cursor !== void 0 && (typeof cursor !== "string" || cursor.trim() === "")) {
24395
- throw new AutotaskConfigError(`${OP9}: cursor must be the non-empty nextCursor of a previous call`, { operation: OP9 });
24385
+ throw new AutotaskConfigError(`${OP8}: cursor must be the non-empty nextCursor of a previous call`, { operation: OP8 });
24396
24386
  }
24397
24387
  if (cursor !== void 0 && cursor.length > CURSOR_MAX) {
24398
- throw new ResponseRejectedError(`${OP9}: refused the pagination cursor: ${cursor.length} chars is over the ${CURSOR_MAX}-char cap (a vendor nextPageUrl is far shorter)`);
24388
+ throw new ResponseRejectedError(`${OP8}: refused the pagination cursor: ${cursor.length} chars is over the ${CURSOR_MAX}-char cap (a vendor nextPageUrl is far shorter)`);
24399
24389
  }
24400
24390
  if (cursor !== void 0 && countOnly) {
24401
- throw new AutotaskConfigError(`${OP9}: cursor does not apply with countOnly (countOnly reads every page itself)`, { operation: OP9 });
24391
+ throw new AutotaskConfigError(`${OP8}: cursor does not apply with countOnly (countOnly reads every page itself)`, { operation: OP8 });
24402
24392
  }
24403
24393
  const budget = opts?.maxVendorCalls ?? COUNT_BUDGET_DEFAULT;
24404
24394
  if (!Number.isInteger(budget) || budget < 1 || budget > COUNT_BUDGET_MAX) {
24405
- throw new AutotaskConfigError(`${OP9}: maxVendorCalls must be an integer 1-${COUNT_BUDGET_MAX} (got ${String(opts?.maxVendorCalls)})`, { operation: OP9 });
24395
+ throw new AutotaskConfigError(`${OP8}: maxVendorCalls must be an integer 1-${COUNT_BUDGET_MAX} (got ${String(opts?.maxVendorCalls)})`, { operation: OP8 });
24406
24396
  }
24407
24397
  return { ...cursor === void 0 ? {} : { cursor }, countOnly, budget };
24408
24398
  }
24409
- function budgetExceeded(OP9, details, midWalk) {
24399
+ function budgetExceeded(OP8, details, midWalk) {
24410
24400
  const { estimate, budget, total } = details;
24411
24401
  const raise = midWalk ? budget < COUNT_BUDGET_MAX ? `, or pass a maxVendorCalls above ${budget} for headroom past the estimate` : "" : estimate <= COUNT_BUDGET_MAX ? ` or pass maxVendorCalls >= ${estimate}` : "";
24412
24402
  return new AutotaskError(
24413
- midWalk ? `${OP9}: countOnly stopped at the ${budget}-call budget mid-walk: the walk needed more calls than the ${estimate} estimated from the ${total}-entry count (more entries than counted, or an extra page such as an empty continuation page), so the next call, which may be the closing re-count, did not fit; no partial total is returned` : `${OP9}: countOnly needs an estimated ${estimate} vendor calls for ${total} entries (1 count + 1 per ${PAGE} entries + 1 join per 250 + 1 re-count), over the ${budget}-call budget; nothing was read past the count`,
24414
- { code: "BUDGET_EXCEEDED", category: "policy", operation: OP9, details, suggestedAction: `Narrow the dateWorked window (dateFrom/dateTo)${raise}.` }
24403
+ midWalk ? `${OP8}: countOnly stopped at the ${budget}-call budget mid-walk: the walk needed more calls than the ${estimate} estimated from the ${total}-entry count (more entries than counted, or an extra page such as an empty continuation page), so the next call, which may be the closing re-count, did not fit; no partial total is returned` : `${OP8}: countOnly needs an estimated ${estimate} vendor calls for ${total} entries (1 count + 1 per ${PAGE} entries + 1 join per 250 + 1 re-count), over the ${budget}-call budget; nothing was read past the count`,
24404
+ { code: "BUDGET_EXCEEDED", category: "policy", operation: OP8, details, suggestedAction: `Narrow the dateWorked window (dateFrom/dateTo)${raise}.` }
24415
24405
  );
24416
24406
  }
24417
- async function countPostedState(http, filter, OP9, budget, window) {
24407
+ async function countPostedState(http, filter, OP8, budget, window) {
24418
24408
  let issued = 0;
24419
24409
  let estimate = 0;
24420
24410
  let counted = 0;
24421
24411
  const spend = () => {
24422
- if (issued >= budget) throw budgetExceeded(OP9, { estimate, budget, total: counted, issued }, true);
24412
+ if (issued >= budget) throw budgetExceeded(OP8, { estimate, budget, total: counted, issued }, true);
24423
24413
  issued += 1;
24424
24414
  };
24425
24415
  spend();
24426
24416
  counted = unwrapQueryCount(
24427
- await http.request({ method: "POST", idempotent: true, path: "/V1.0/TimeEntries/query/count", body: { filter }, operation: OP9 }),
24428
- OP9
24417
+ await http.request({ method: "POST", idempotent: true, path: "/V1.0/TimeEntries/query/count", body: { filter }, operation: OP8 }),
24418
+ OP8
24429
24419
  );
24430
24420
  estimate = 1 + Math.max(1, Math.ceil(counted / PAGE)) + Math.ceil(counted / 250) + 1;
24431
- if (estimate > budget) throw budgetExceeded(OP9, { estimate, budget, total: counted, issued }, false);
24421
+ if (estimate > budget) throw budgetExceeded(OP8, { estimate, budget, total: counted, issued }, false);
24432
24422
  const states = /* @__PURE__ */ new Map();
24433
24423
  let cursor = null;
24434
24424
  do {
@@ -24438,15 +24428,15 @@ async function countPostedState(http, filter, OP9, budget, window) {
24438
24428
  idempotent: true,
24439
24429
  path: cursor === null ? "/V1.0/TimeEntries/query" : entryCursorPath(http.baseUrl, cursor),
24440
24430
  body: { MaxRecords: PAGE, IncludeFields: ["id"], filter },
24441
- operation: OP9
24431
+ operation: OP8
24442
24432
  });
24443
24433
  const page = unwrapItems(res);
24444
- const joined = await postedStateOf(http, page.items, OP9, spend);
24434
+ const joined = await postedStateOf(http, page.items, OP8, spend);
24445
24435
  if (joined.incomplete) {
24446
- throw new AutotaskError(`${OP9}: countOnly refused: a billing-item join page was truncated (more than 500 billing items for 250 entries), so the posted split cannot be confirmed`, {
24436
+ throw new AutotaskError(`${OP8}: countOnly refused: a billing-item join page was truncated (more than 500 billing items for 250 entries), so the posted split cannot be confirmed`, {
24447
24437
  code: "RESOLUTION_TRUNCATED",
24448
24438
  category: "resolution",
24449
- operation: OP9,
24439
+ operation: OP8,
24450
24440
  details: { issued, budget },
24451
24441
  suggestedAction: "Narrow the dateWorked window, or page it with cursor (rows mode reports unconfirmed entries via hasMore)."
24452
24442
  });
@@ -24456,14 +24446,14 @@ async function countPostedState(http, filter, OP9, budget, window) {
24456
24446
  } while (cursor !== null);
24457
24447
  spend();
24458
24448
  const recounted = unwrapQueryCount(
24459
- await http.request({ method: "POST", idempotent: true, path: "/V1.0/TimeEntries/query/count", body: { filter }, operation: OP9 }),
24460
- OP9
24449
+ await http.request({ method: "POST", idempotent: true, path: "/V1.0/TimeEntries/query/count", body: { filter }, operation: OP8 }),
24450
+ OP8
24461
24451
  );
24462
24452
  if (states.size !== counted && states.size !== recounted) {
24463
- throw new AutotaskError(`${OP9}: countOnly read ${states.size} distinct entries but the window counted ${counted} before the walk and ${recounted} after it (a page repeated or dropped entries, or the window changed mid-walk); no total is returned. Retry the call`, {
24453
+ throw new AutotaskError(`${OP8}: countOnly read ${states.size} distinct entries but the window counted ${counted} before the walk and ${recounted} after it (a page repeated or dropped entries, or the window changed mid-walk); no total is returned. Retry the call`, {
24464
24454
  code: "INVALID_RESPONSE",
24465
24455
  category: "server",
24466
- operation: OP9,
24456
+ operation: OP8,
24467
24457
  details: { reason: "count_mismatch", streamed: states.size, counted, recounted, issued, budget },
24468
24458
  suggestedAction: "Retry the call; a window that changes during the walk can fail or pass depending on timing."
24469
24459
  });
@@ -24520,13 +24510,13 @@ async function postedStateQuery(_resource, mode, http, opts) {
24520
24510
  return { items, hasMore: read.hasMore || incomplete, window, nextCursor: read.nextCursor, unconfirmed, approvedCount, unapprovedCount: items.length - approvedCount };
24521
24511
  }
24522
24512
  async function postedWithin(_resource, from, to, http, opts, opLabel = "time_entries.postedWithin") {
24523
- const OP9 = opLabel;
24524
- const paging = pagingOpts(opts, OP9);
24525
- if (opts?.tz !== void 0) validateZone(opts.tz, OP9);
24526
- const fromB = customRangeDay(from, "from", void 0, OP9);
24527
- const toB = customRangeDay(to, "to", void 0, OP9);
24513
+ const OP8 = opLabel;
24514
+ const paging = pagingOpts(opts, OP8);
24515
+ if (opts?.tz !== void 0) validateZone(opts.tz, OP8);
24516
+ const fromB = customRangeDay(from, "from", void 0, OP8);
24517
+ const toB = customRangeDay(to, "to", void 0, OP8);
24528
24518
  if (fromB.ms > toB.ms) {
24529
- throw new AutotaskConfigError(`${OP9}: from (${iso2(fromB.ms)}) must not be after to (${iso2(toB.ms)})`, { operation: OP9 });
24519
+ throw new AutotaskConfigError(`${OP8}: from (${iso2(fromB.ms)}) must not be after to (${iso2(toB.ms)})`, { operation: OP8 });
24530
24520
  }
24531
24521
  const windowToMs = toB.ms + DAY_MS2 - 1;
24532
24522
  const filter = [
@@ -24536,9 +24526,9 @@ async function postedWithin(_resource, from, to, http, opts, opLabel = "time_ent
24536
24526
  if (opts?.ticketID !== void 0) filter.push({ field: "ticketID", op: "eq", value: opts.ticketID });
24537
24527
  if (opts?.resourceID !== void 0) filter.push({ field: "resourceID", op: "eq", value: opts.resourceID });
24538
24528
  const window = { from: iso2(fromB.ms), to: iso2(windowToMs) };
24539
- if (paging.countOnly) return countPostedState(http, filter, OP9, paging.budget, window);
24540
- const { items, hasMore, nextCursor } = await queryTimeEntries(_resource, filter, clampWindowLimit(opts?.limit), OP9, http, paging.cursor);
24541
- const { states, incomplete } = await postedStateOf(http, items, OP9);
24529
+ if (paging.countOnly) return countPostedState(http, filter, OP8, paging.budget, window);
24530
+ const { items, hasMore, nextCursor } = await queryTimeEntries(_resource, filter, clampWindowLimit(opts?.limit), OP8, http, paging.cursor);
24531
+ const { states, incomplete } = await postedStateOf(http, items, OP8);
24542
24532
  let postedCount = 0;
24543
24533
  let unpostedCount = 0;
24544
24534
  for (const state of states.values()) {
@@ -25308,103 +25298,6 @@ async function approve(resource, id, opts = {}, http) {
25308
25298
  };
25309
25299
  }
25310
25300
 
25311
- // src/resources/special/time_entries/recentTickets.ts
25312
- var OP8 = "time_entries.recentTickets";
25313
- var DAY_MS3 = 864e5;
25314
- var TICKET_CHUNK = 100;
25315
- var TICKET_FIELDS2 = ["id", "ticketNumber", "title", "status", "assignedResourceID", "companyID"];
25316
- function positiveId2(value, name) {
25317
- const n = typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value) : value;
25318
- if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) {
25319
- throw new AutotaskConfigError(`${OP8}: ${name} must be a positive integer id (got ${String(value)})`, { operation: OP8 });
25320
- }
25321
- return n;
25322
- }
25323
- async function recentTickets(_resource, from, to, http, opts) {
25324
- if (opts === void 0 || opts === null || typeof opts !== "object") {
25325
- throw new AutotaskConfigError(`${OP8}: opts { resourceID, companyID?, tz?, limit? } is required`, { operation: OP8 });
25326
- }
25327
- const resourceID = positiveId2(opts.resourceID, "resourceID");
25328
- const companyID = opts.companyID === void 0 ? void 0 : positiveId2(opts.companyID, "companyID");
25329
- if (opts.tz !== void 0) validateZone(opts.tz, OP8);
25330
- const fromB = customRangeDay(from, "from", void 0, OP8);
25331
- const toB = customRangeDay(to, "to", void 0, OP8);
25332
- if (fromB.ms > toB.ms) {
25333
- throw new AutotaskConfigError(`${OP8}: from (${isoUtc(fromB.ms)}) must not be after to (${isoUtc(toB.ms)})`, { operation: OP8 });
25334
- }
25335
- const windowToMs = toB.ms + DAY_MS3 - 1;
25336
- const { items: entries, hasMore } = await queryTimeEntries(
25337
- _resource,
25338
- [
25339
- { field: "dateWorked", op: "gte", value: isoUtc(fromB.ms) },
25340
- { field: "dateWorked", op: "lte", value: isoUtc(windowToMs) },
25341
- { field: "resourceID", op: "eq", value: resourceID }
25342
- ],
25343
- clampWindowLimit(opts.limit),
25344
- OP8,
25345
- http
25346
- );
25347
- const groups = /* @__PURE__ */ new Map();
25348
- let entriesWithoutTicket = 0;
25349
- for (const e of entries) {
25350
- if (typeof e.ticketID !== "number") {
25351
- entriesWithoutTicket += 1;
25352
- continue;
25353
- }
25354
- const g = groups.get(e.ticketID) ?? { entryCount: 0, totalHours: 0, lastWorked: "" };
25355
- g.entryCount += 1;
25356
- g.totalHours += e.hoursWorked ?? 0;
25357
- if ((e.dateWorked ?? "") > g.lastWorked) g.lastWorked = e.dateWorked ?? "";
25358
- groups.set(e.ticketID, g);
25359
- }
25360
- const ids2 = [...groups.keys()];
25361
- const tickets = /* @__PURE__ */ new Map();
25362
- for (let c = 0; c < ids2.length; c += TICKET_CHUNK) {
25363
- const chunk = ids2.slice(c, c + TICKET_CHUNK);
25364
- const filter = [{ field: "id", op: "in", value: chunk }];
25365
- const res = await http.request({
25366
- method: "POST",
25367
- idempotent: true,
25368
- path: "/V1.0/Tickets/query",
25369
- body: { MaxRecords: TICKET_CHUNK, filter, IncludeFields: TICKET_FIELDS2 },
25370
- operation: OP8
25371
- });
25372
- for (const t of unwrapItems(res, OP8).items) if (typeof t.id === "number") tickets.set(t.id, t);
25373
- }
25374
- const items = [];
25375
- const unresolvedTicketIDs = [];
25376
- let excludedTickets = 0;
25377
- for (const [ticketID, g] of groups) {
25378
- const t = tickets.get(ticketID);
25379
- if (t === void 0) unresolvedTicketIDs.push(ticketID);
25380
- if (companyID !== void 0 && t?.companyID !== companyID) {
25381
- if (t !== void 0) excludedTickets += 1;
25382
- continue;
25383
- }
25384
- items.push({
25385
- ticketID,
25386
- ...t?.ticketNumber !== void 0 ? { ticketNumber: t.ticketNumber } : {},
25387
- ...t?.title !== void 0 ? { title: t.title } : {},
25388
- ...t?.status !== void 0 ? { status: t.status } : {},
25389
- ...t?.assignedResourceID !== void 0 ? { assignedResourceID: t.assignedResourceID } : {},
25390
- ...t?.companyID !== void 0 ? { companyID: t.companyID } : {},
25391
- entryCount: g.entryCount,
25392
- totalHours: Math.round(g.totalHours * 100) / 100,
25393
- lastWorked: g.lastWorked
25394
- });
25395
- }
25396
- items.sort((a, b) => a.lastWorked < b.lastWorked ? 1 : a.lastWorked > b.lastWorked ? -1 : a.ticketID - b.ticketID);
25397
- return {
25398
- items,
25399
- entriesRead: entries.length,
25400
- entriesWithoutTicket,
25401
- unresolvedTicketIDs,
25402
- ...companyID !== void 0 ? { excludedTickets } : {},
25403
- window: { from: isoUtc(fromB.ms), to: isoUtc(windowToMs) },
25404
- truncated: hasMore
25405
- };
25406
- }
25407
-
25408
25301
  // src/resources/time_entries.ts
25409
25302
  var TimeEntriesResource = class extends BaseResource {
25410
25303
  constructor(http, pageSize) {
@@ -25536,11 +25429,6 @@ var TimeEntriesResource = class extends BaseResource {
25536
25429
  postedWithin(from, to, opts) {
25537
25430
  return postedWithin(this, from, to, this.http, opts);
25538
25431
  }
25539
- /** Node-parity special op (hand-written in src/resources/special/time_entries.ts).
25540
- * @deprecated Since 0.38.0, removed in the next minor. Use operations.aggregate(client, 'time_entries.query', [{ field: 'resourceID', op: 'eq', value: R }, { field: 'dateWorked', op: 'gte', value: FROM }, { field: 'dateWorked', op: 'lte', value: TO }], { groupBy: ['ticketID'], sum: ['hoursWorked'] }) — group keys carry ticketIDName; add { field: 'ticket.companyID', op: 'eq', value: C } to scope by company. */
25541
- recentTickets(from, to, opts) {
25542
- return recentTickets(this, from, to, this.http, opts);
25543
- }
25544
25432
  /** Node-parity special op (hand-written in src/resources/special/time_entries.ts). */
25545
25433
  today(opts) {
25546
25434
  return windowQuery3(this, "today", this.http, opts);
@@ -25599,6 +25487,8 @@ export {
25599
25487
  MarkerUnsafeIdError,
25600
25488
  RecoveryTruncatedError,
25601
25489
  MoveRefusedError,
25490
+ UNSCOPED_WALK_THRESHOLD,
25491
+ ScopeRequiredError,
25602
25492
  READ_CACHE_MAX_ENTRIES,
25603
25493
  REFERENCE_TTL_MS,
25604
25494
  REFERENCE_ENTITIES,
@@ -25675,6 +25565,7 @@ export {
25675
25565
  unwrapOperation,
25676
25566
  passthrough,
25677
25567
  HttpClient,
25568
+ BOUNDED_QUERY_DEFAULT_LIMIT,
25678
25569
  BOUNDED_QUERY_MAX_LIMIT,
25679
25570
  buildDryRunResult,
25680
25571
  mergePartialBody,
@@ -25686,31 +25577,25 @@ export {
25686
25577
  paginateItems,
25687
25578
  collectAll,
25688
25579
  toArray,
25689
- FIELD_PRESETS,
25690
- FIELD_PRESET_NAMES,
25691
- resolveFieldsPreset,
25692
25580
  MODEL_DATE_FIELDS,
25693
- EXPAND_RESOURCE_NAMES_MAX_IDS,
25694
- expandResourceNames,
25695
- ENTITY_TYPE_TO_RESOURCE,
25696
- resourceForEntityType,
25697
- isEnumerableResource,
25698
25581
  WORKFLOW_RESOURCES,
25699
25582
  MAX_FIELD_CATALOG_WIRE,
25700
25583
  FIELD_CATALOG_HONESTY_NOTE,
25701
25584
  MIN_FIELD_CATALOG_MAX_BYTES,
25702
25585
  fieldCatalog,
25586
+ EXPAND_RESOURCE_NAMES_MAX_IDS,
25587
+ expandResourceNames,
25588
+ vendorId,
25589
+ resolveLineOfBusinessChain,
25703
25590
  PICKLIST_VALUE_NOT_SERVED_NOTE,
25704
- PICKLIST_CATALOG_UNAVAILABLE_NOTE,
25705
25591
  TICKET_LABEL_FIELDS,
25706
25592
  createNamePlan,
25707
25593
  addNameSibling,
25708
25594
  addLabelSibling,
25709
- vendorId,
25710
- resolveLineOfBusinessChain,
25711
25595
  resolveCompositeNames,
25712
25596
  readNamesOpt,
25713
25597
  labelRows,
25598
+ validateWalkBag,
25714
25599
  BaseResource,
25715
25600
  BillingCodesResource,
25716
25601
  BillingItemsResource,