@dx-do/client 6.2.3 → 6.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs.js CHANGED
@@ -45995,6 +45995,226 @@ exports.OIAlarm = void 0;
45995
45995
  };
45996
45996
  })(exports.OIAlarm || (exports.OIAlarm = {}));
45997
45997
 
45998
+ /**
45999
+ * The `service export` / `service export-hierarchy` file format and the
46000
+ * `service import` boundary validator.
46001
+ *
46002
+ * The envelope wraps a re-postable store body ({@link CreateServices.CreateServiceBody})
46003
+ * with provenance metadata. `import` accepts either the envelope or a bare body.
46004
+ */
46005
+ exports.ServiceExport = void 0;
46006
+ (function (ServiceExport) {
46007
+ ServiceExport.SCHEMA_VERSION = 1;
46008
+ // Structural guard for a store body — does not re-validate every field
46009
+ // (the server is the authority); just enough to reject obviously-wrong files.
46010
+ const BodyShape = v4.z.looseObject({
46011
+ vertices: v4.z.array(v4.z.looseObject({ attributes: v4.z.looseObject({ name: v4.z.string() }) })),
46012
+ edges: v4.z.array(v4.z.looseObject({
46013
+ sourceExternalId: v4.z.string(),
46014
+ targetExternalId: v4.z.string(),
46015
+ })),
46016
+ });
46017
+ const EnvelopeShape = v4.z.looseObject({
46018
+ schemaVersion: v4.z.literal(1),
46019
+ kind: v4.z.enum(['service', 'service-hierarchy']),
46020
+ sourceTenant: v4.z.string().optional(),
46021
+ exportedAt: v4.z.string(),
46022
+ body: BodyShape,
46023
+ });
46024
+ /** Wrap a store body in an export envelope. */
46025
+ function envelope(kind, body, sourceTenant, exportedAt) {
46026
+ return { schemaVersion: ServiceExport.SCHEMA_VERSION, kind, sourceTenant, exportedAt, body };
46027
+ }
46028
+ ServiceExport.envelope = envelope;
46029
+ /**
46030
+ * Validate an import file (already JSON-parsed) and return the store body.
46031
+ * Accepts a full export envelope or a bare `{ vertices, edges }` body.
46032
+ * @throws Error when the input is neither shape.
46033
+ */
46034
+ function parseImport(raw) {
46035
+ const asEnvelope = EnvelopeShape.safeParse(raw);
46036
+ if (asEnvelope.success) {
46037
+ return asEnvelope.data.body;
46038
+ }
46039
+ const asBody = BodyShape.safeParse(raw);
46040
+ if (asBody.success) {
46041
+ return raw;
46042
+ }
46043
+ throw new Error('Import file is neither a service export envelope nor a bare {vertices, edges} body. ' +
46044
+ `Envelope error: ${asEnvelope.error.message}`);
46045
+ }
46046
+ ServiceExport.parseImport = parseImport;
46047
+ })(exports.ServiceExport || (exports.ServiceExport = {}));
46048
+
46049
+ /**
46050
+ * Pure (HTTP-free) transforms between the Service read shapes and the store
46051
+ * (`/oi/v2/sa/save`) body. Kept separate from {@link ServiceService} so the
46052
+ * edge/weight logic — the part with real correctness risk — is unit-testable.
46053
+ *
46054
+ * Empirical constraints these helpers encode (see plan Phase 0):
46055
+ * - A parent's direct-child health/risk weights must each total ≤ 1.0 (server rejects otherwise).
46056
+ * - `replace=true` makes each saved vertex's outgoing-edge set authoritative, so a
46057
+ * faithful re-save must carry the whole subtree's vertices + edges.
46058
+ */
46059
+ exports.ServiceTransform = void 0;
46060
+ (function (ServiceTransform) {
46061
+ /**
46062
+ * Convert a retrieved (read) service vertex into a re-postable store vertex.
46063
+ *
46064
+ * @remarks externalId is keyed by the service **name**, not the server
46065
+ * `SA:<uuid>` id. The store API treats `externalId` as a payload-local handle
46066
+ * and resolves/creates services by `attributes.name`; keying edges by `SA:uuid`
46067
+ * makes the server silently drop them (so a `replace` save wipes the existing
46068
+ * edges and re-adds nothing). Names are tenant-unique, so this is unambiguous
46069
+ * and also makes exports portable across tenants.
46070
+ */
46071
+ function retrievedToServiceVertex(v) {
46072
+ const a = v.attributes;
46073
+ return {
46074
+ externalId: a.name,
46075
+ attributes: {
46076
+ type: 'saService',
46077
+ name: a.name,
46078
+ state: 'ACTIVE',
46079
+ maintenance: a.maintenance ?? false,
46080
+ situationsIncludeChildServices: a.situationsIncludeChildServices ?? false,
46081
+ serviceContent: a.serviceContent ?? [],
46082
+ root_service: null,
46083
+ tags: a.tags ?? [],
46084
+ location: a.location ?? '',
46085
+ description: a.description ?? '',
46086
+ customProperties: a.customProperties ?? [],
46087
+ customMetrics: a.customMetrics ?? [],
46088
+ metrics: a.metrics ?? [],
46089
+ },
46090
+ };
46091
+ }
46092
+ ServiceTransform.retrievedToServiceVertex = retrievedToServiceVertex;
46093
+ /**
46094
+ * Build NAME-keyed store edges from a topology graph, preserving weights +
46095
+ * semantic. Endpoints are mapped from `SA:<uuid>` to service name via the
46096
+ * graph's vertices; edges whose endpoints aren't in `topo.vertices` are
46097
+ * dropped — this is exactly the incoming edge(s) from the root's parent, which
46098
+ * `saTopologyGraph` returns without the parent vertex (and which must not be
46099
+ * re-posted: the parent isn't re-saved, so its edge survives anyway).
46100
+ */
46101
+ function edgesFromTopology(topo) {
46102
+ const idToName = new Map(topo.vertices.map((v) => [v.externalId, v.attributes.name]));
46103
+ return topo.edges
46104
+ .map((e) => ({
46105
+ s: idToName.get(e.sourceExternalId),
46106
+ t: idToName.get(e.targetExternalId),
46107
+ e,
46108
+ }))
46109
+ .filter((x) => x.s !== undefined && x.t !== undefined)
46110
+ .map((x) => ({
46111
+ sourceExternalId: x.s,
46112
+ targetExternalId: x.t,
46113
+ attributes: {
46114
+ health_weight: x.e.attributes?.health_weight ?? 1,
46115
+ risk_weight: x.e.attributes?.risk_weight ?? 1,
46116
+ semantic: 'AggregateOf',
46117
+ },
46118
+ }));
46119
+ }
46120
+ ServiceTransform.edgesFromTopology = edgesFromTopology;
46121
+ /** Merge two store bodies, de-duplicating vertices by externalId and edges by endpoints. */
46122
+ function mergeBodies(a, b) {
46123
+ const vertices = [];
46124
+ const seenV = new Set();
46125
+ for (const v of [...a.vertices, ...b.vertices]) {
46126
+ const key = v.externalId ?? v.attributes.name;
46127
+ if (!seenV.has(key)) {
46128
+ seenV.add(key);
46129
+ vertices.push(v);
46130
+ }
46131
+ }
46132
+ const edges = [];
46133
+ const seenE = new Set();
46134
+ for (const e of [...a.edges, ...b.edges]) {
46135
+ const key = `${e.sourceExternalId}|${e.targetExternalId}`;
46136
+ if (!seenE.has(key)) {
46137
+ seenE.add(key);
46138
+ edges.push(e);
46139
+ }
46140
+ }
46141
+ return { vertices, edges };
46142
+ }
46143
+ ServiceTransform.mergeBodies = mergeBodies;
46144
+ /** Insert or update the parent→child edge with the given weights. */
46145
+ function upsertEdge(edges, source, target, healthWeight, riskWeight) {
46146
+ const next = edges.filter((e) => !(e.sourceExternalId === source && e.targetExternalId === target));
46147
+ next.push({
46148
+ sourceExternalId: source,
46149
+ targetExternalId: target,
46150
+ attributes: {
46151
+ health_weight: healthWeight,
46152
+ risk_weight: riskWeight,
46153
+ semantic: 'AggregateOf',
46154
+ },
46155
+ });
46156
+ return next;
46157
+ }
46158
+ ServiceTransform.upsertEdge = upsertEdge;
46159
+ /** Remove the parent→child edge. */
46160
+ function removeEdge(edges, source, target) {
46161
+ return edges.filter((e) => !(e.sourceExternalId === source && e.targetExternalId === target));
46162
+ }
46163
+ ServiceTransform.removeEdge = removeEdge;
46164
+ /** Edges whose source is the given parent (its direct-child associations). */
46165
+ function childEdges(edges, parent) {
46166
+ return edges.filter((e) => e.sourceExternalId === parent);
46167
+ }
46168
+ ServiceTransform.childEdges = childEdges;
46169
+ /**
46170
+ * Keep only edges whose BOTH endpoints are in the given vertex-id set.
46171
+ *
46172
+ * `saTopologyGraph(root)` returns the root's subtree plus the *incoming* edge(s)
46173
+ * from the root's parent(s) — but not the parent vertices. Re-posting such a
46174
+ * dangling edge is rejected ("vertex referenced by edge is not present in the
46175
+ * input graph"). Dropping it is also correct: the parent is not re-saved, so its
46176
+ * edge to the root is preserved server-side (replace is destructive only per
46177
+ * saved vertex).
46178
+ */
46179
+ function filterEdgesToVertices(edges, vertexIds) {
46180
+ return edges.filter((e) => vertexIds.has(e.sourceExternalId) && vertexIds.has(e.targetExternalId));
46181
+ }
46182
+ ServiceTransform.filterEdgesToVertices = filterEdgesToVertices;
46183
+ /**
46184
+ * Evenly distribute a parent's direct-child weights so each total is ≤ 1.0.
46185
+ * Uses floored thirds (e.g. 3 children → 0.333 each, sum 0.999) to avoid
46186
+ * float drift pushing the total over 100%.
46187
+ */
46188
+ function rebalanceChildren(edges, parent) {
46189
+ const kids = childEdges(edges, parent);
46190
+ if (kids.length === 0)
46191
+ return edges;
46192
+ const w = Math.floor((1 / kids.length) * 1000) / 1000;
46193
+ return edges.map((e) => e.sourceExternalId === parent
46194
+ ? {
46195
+ ...e,
46196
+ attributes: {
46197
+ health_weight: w,
46198
+ risk_weight: w,
46199
+ semantic: 'AggregateOf',
46200
+ },
46201
+ }
46202
+ : e);
46203
+ }
46204
+ ServiceTransform.rebalanceChildren = rebalanceChildren;
46205
+ /** Throw if a parent's direct-child weights exceed 100% (the server's hard limit). */
46206
+ function assertWeightsWithinLimit(edges, parent) {
46207
+ const kids = childEdges(edges, parent);
46208
+ const health = kids.reduce((s, e) => s + (e.attributes?.health_weight ?? 0), 0);
46209
+ const risk = kids.reduce((s, e) => s + (e.attributes?.risk_weight ?? 0), 0);
46210
+ const eps = 1e-6;
46211
+ if (health > 1 + eps || risk > 1 + eps) {
46212
+ throw new Error(`Weight total for parent exceeds 100% (health=${health.toFixed(3)}, risk=${risk.toFixed(3)}). Lower the weights or omit them to auto-distribute evenly.`);
46213
+ }
46214
+ }
46215
+ ServiceTransform.assertWeightsWithinLimit = assertWeightsWithinLimit;
46216
+ })(exports.ServiceTransform || (exports.ServiceTransform = {}));
46217
+
45998
46218
  exports.OIService = void 0;
45999
46219
  (function (OIService) {
46000
46220
  /** @internal */
@@ -46466,6 +46686,94 @@ exports.OIService = void 0;
46466
46686
  };
46467
46687
  })(exports.OIService || (exports.OIService = {}));
46468
46688
 
46689
+ /**
46690
+ * The `ess/users/v2/list` user-account listing.
46691
+ *
46692
+ * Hand-modelled from observed responses (no published schema): every field is
46693
+ * inferred from live data across multiple tenants. `looseObject` is used
46694
+ * throughout so undocumented server fields are tolerated rather than dropped or
46695
+ * rejected, and single-observation fields (e.g. {@link UserSchema.shape.authMethod})
46696
+ * are kept as plain strings rather than locked enums.
46697
+ */
46698
+ exports.OIUsersV2 = void 0;
46699
+ (function (OIUsersV2) {
46700
+ /** One email address attached to a user. */
46701
+ OIUsersV2.EmailIdSchema = v4.z.looseObject({
46702
+ emailAddr: v4.z.string().describe('the email address'),
46703
+ qualifier: v4.z
46704
+ .string()
46705
+ .describe('email category; only "EMAILID" observed in live data'),
46706
+ });
46707
+ /** The user's assigned role. */
46708
+ OIUsersV2.RoleSchema = v4.z.looseObject({
46709
+ name: v4.z
46710
+ .string()
46711
+ .describe('role identifier, e.g. "VIEW105" / "TA" / "PU" / "UZ"'),
46712
+ privileges: v4.z
46713
+ .array(v4.z.unknown())
46714
+ .describe('granted privilege entries; empty in all observed data'),
46715
+ level: v4.z.number().describe('role level; 0 in all observed data'),
46716
+ });
46717
+ /** A single user account. */
46718
+ OIUsersV2.UserSchema = v4.z.looseObject({
46719
+ userRefID: v4.z.number().describe('numeric internal user reference id'),
46720
+ userId: v4.z
46721
+ .string()
46722
+ .describe('login principal; usually the email address upper-cased'),
46723
+ cohort: v4.z
46724
+ .string()
46725
+ .describe('per-user cohort id in dashed, upper-cased GUID form'),
46726
+ firstName: v4.z.string().describe('given name'),
46727
+ lastName: v4.z
46728
+ .string()
46729
+ .optional()
46730
+ .describe('family name; occasionally absent'),
46731
+ statusEnum: v4.z
46732
+ .enum(['ACTIVE', 'INACTIVE'])
46733
+ .describe('account status; only ACTIVE/INACTIVE observed'),
46734
+ emailIds: v4.z
46735
+ .array(OIUsersV2.EmailIdSchema)
46736
+ .describe('email addresses associated with the user'),
46737
+ hasImage: v4.z.boolean().describe('whether a profile image is set'),
46738
+ authMethod: v4.z
46739
+ .string()
46740
+ .describe('authentication method; only "BASIC_AUTH" observed'),
46741
+ dateCreated: v4.z
46742
+ .string()
46743
+ .describe('creation timestamp, ISO-8601 with +0000 offset (not "Z")'),
46744
+ dateModified: v4.z
46745
+ .string()
46746
+ .describe('last-modified timestamp, ISO-8601 with +0000 offset'),
46747
+ startLockTime: v4.z
46748
+ .string()
46749
+ .optional()
46750
+ .describe('lock-start timestamp; present only when the account is locked'),
46751
+ internalAttrs: v4.z
46752
+ .array(v4.z.string().nullable())
46753
+ .describe('internal attribute slots; observed as a length-3 array of nullable strings, semantics undocumented'),
46754
+ tenant: v4.z.string().describe('tenant id the user belongs to'),
46755
+ lockStatus: v4.z
46756
+ .boolean()
46757
+ .describe('whether the account is currently locked'),
46758
+ role: OIUsersV2.RoleSchema.describe('the assigned role'),
46759
+ hasAllDataAccess: v4.z
46760
+ .boolean()
46761
+ .describe('whether the user has unrestricted data access'),
46762
+ userType: v4.z
46763
+ .string()
46764
+ .optional()
46765
+ .describe('account type; the list request filters to USER_ACCOUNT, but the field is not always echoed back'),
46766
+ deleted: v4.z.boolean().describe('soft-delete flag'),
46767
+ });
46768
+ /** The list envelope: a page of users plus the server-side total. */
46769
+ OIUsersV2.UserListResponseSchema = v4.z.looseObject({
46770
+ countTotal: v4.z
46771
+ .number()
46772
+ .describe('total users matching the query on the server, for pagination'),
46773
+ users: v4.z.array(OIUsersV2.UserSchema).describe('the returned page of users'),
46774
+ });
46775
+ })(exports.OIUsersV2 || (exports.OIUsersV2 = {}));
46776
+
46469
46777
  /** @internal */
46470
46778
  function createPostmanRequest(request) {
46471
46779
  function createPostmanHeaders(request) {
@@ -63867,6 +64175,50 @@ class AuthorizationService {
63867
64175
  getUsers() {
63868
64176
  return this.dxSaaSService.tenantGet('oi/v2/api/users');
63869
64177
  }
64178
+ /**
64179
+ * Lists users via the ESS users v2 endpoint.
64180
+ *
64181
+ * The path's trailing segment is the cohort id in upper-cased, dashed GUID
64182
+ * form (e.g. `6E82C5F4-8F16-4CD9-8BF0-6263F83054D5`). `oiGet` already injects
64183
+ * the lowercase, dash-less cohort id as the base path segment, so we rebuild
64184
+ * the GUID form here independently of how the config stores it.
64185
+ *
64186
+ * The response type is hand-modelled from observed data ({@link OIUsersV2});
64187
+ * validation is defensive (`safeParse` + `log.warn`), so a server-side shape
64188
+ * change logs a warning but still returns the raw payload to the caller.
64189
+ *
64190
+ * @remarks Response is validated, not thrown — see {@link OIUsersV2}.
64191
+ */
64192
+ async getUsersV2(startIndex = 1, endIndex = 500) {
64193
+ const cohortGuid = this.toGuidCohortId(this.dxSaaSService.getCohortIDForURL());
64194
+ const raw = await this.dxSaaSService.oiGet(`ess/users/v2/list/${cohortGuid}`, {
64195
+ cachebuster: Math.random(),
64196
+ preventCache: Math.random(),
64197
+ start_index: startIndex,
64198
+ end_index: endIndex,
64199
+ sort_by_field: 'DATEMODIFIED',
64200
+ sort_order: 'desc',
64201
+ user_type: 'USER_ACCOUNT',
64202
+ });
64203
+ const result = exports.OIUsersV2.UserListResponseSchema.safeParse(raw);
64204
+ if (!result.success) {
64205
+ this.log.warn('OIUsersV2 response validation warning: ' + result.error.message);
64206
+ }
64207
+ return raw;
64208
+ }
64209
+ /** Reformats a cohort id to upper-cased, dashed GUID form (`8-4-4-4-12`). */
64210
+ toGuidCohortId(cohortId) {
64211
+ const hex = cohortId.replace(/-/g, '').toLowerCase();
64212
+ return [
64213
+ hex.slice(0, 8),
64214
+ hex.slice(8, 12),
64215
+ hex.slice(12, 16),
64216
+ hex.slice(16, 20),
64217
+ hex.slice(20, 32),
64218
+ ]
64219
+ .join('-')
64220
+ .toUpperCase();
64221
+ }
63870
64222
  }
63871
64223
 
63872
64224
  /**
@@ -75442,19 +75794,90 @@ class ServiceService {
75442
75794
  };
75443
75795
  return this.dxSaaSService.oiPost('oi/v2/sa/serviceDetails', serviceDetailRequest);
75444
75796
  }
75797
+ /**
75798
+ * Retrieve a Service — the full read-endpoint envelope (vertices + edges +
75799
+ * paging metadata). Backs the `service detail` and `service export` commands.
75800
+ *
75801
+ * @param serviceName - Service name (URL path segment).
75802
+ * @param subservices - When true, include the service's subservices in the response.
75803
+ */
75804
+ async getService(serviceName, subservices = false) {
75805
+ return this.dxSaaSService.oiGet(`oi/v2/sa/services/${serviceName}`, { subservices });
75806
+ }
75807
+ /**
75808
+ * Returns the full reachable subtree (all descendants + association edges with
75809
+ * weights) for a service via `oi/v2/sa/saTopologyGraph`. Backs the hierarchy
75810
+ * read used by `export-hierarchy`/`associate`/`dissociate`.
75811
+ *
75812
+ * @remarks The documented `oi/v2/sa/service/details/hierarchy` path is not
75813
+ * routable on the tenant gateway; this topology endpoint is the working one.
75814
+ * Its vertex attributes are a subset — use {@link getService} per vertex when
75815
+ * faithful re-save attributes (tags, customProperties, metrics) are needed.
75816
+ */
75817
+ async getServiceTopology(serviceName) {
75818
+ return this.dxSaaSService.oiPost('oi/v2/sa/saTopologyGraph', { serviceName, startTime: Date.now(), endTime: Date.now() });
75819
+ }
75820
+ /**
75821
+ * Builds a faithful, re-postable store body for a service's entire subtree:
75822
+ * topology for structure + a per-vertex {@link getService} for full attributes
75823
+ * (serviceContent, tags, customProperties, metrics). Shared by
75824
+ * `export-hierarchy`, `associate`, and `dissociate`.
75825
+ *
75826
+ * @remarks One GET per vertex — fine for typical (small) hierarchies; reads
75827
+ * are not throttled like saves.
75828
+ */
75829
+ async buildHierarchyBody(rootName) {
75830
+ const topo = await this.getServiceTopology(rootName);
75831
+ const names = new Set([rootName]);
75832
+ topo.vertices.forEach((v) => names.add(v.attributes.name));
75833
+ const vertices = [];
75834
+ const seen = new Set();
75835
+ for (const name of names) {
75836
+ const full = await this.getService(name, false);
75837
+ const fv = full.vertices[0];
75838
+ if (fv && !seen.has(fv.externalId)) {
75839
+ seen.add(fv.externalId);
75840
+ vertices.push(exports.ServiceTransform.retrievedToServiceVertex(fv));
75841
+ }
75842
+ }
75843
+ // Drop edges that reference vertices outside this subtree (e.g. the incoming
75844
+ // edge from the root's parent, which saTopologyGraph returns without the
75845
+ // parent vertex). Re-posting such a dangling edge is rejected by the server.
75846
+ const presentIds = new Set(vertices
75847
+ .map((v) => v.externalId)
75848
+ .filter((id) => id !== undefined));
75849
+ const edges = exports.ServiceTransform.filterEdgesToVertices(exports.ServiceTransform.edgesFromTopology(topo), presentIds);
75850
+ return { vertices, edges };
75851
+ }
75852
+ /** Builds a single-service store body (no edges) for `service export`. */
75853
+ async exportServiceBody(serviceName) {
75854
+ const r = await this.getService(serviceName, false);
75855
+ if (!r.vertices.length) {
75856
+ throw new Error(`No service with name '${serviceName}' found`);
75857
+ }
75858
+ return {
75859
+ vertices: [exports.ServiceTransform.retrievedToServiceVertex(r.vertices[0])],
75860
+ edges: [],
75861
+ };
75862
+ }
75863
+ /**
75864
+ * Resolves a service's server externalId (`SA:<cohort>:<uuid>`).
75865
+ * @throws Error if the service does not exist.
75866
+ */
75867
+ async getServiceExternalId(serviceName) {
75868
+ const r = await this.getService(serviceName, false);
75869
+ const id = r.vertices[0]?.externalId;
75870
+ if (!id)
75871
+ throw new Error(`No service with name '${serviceName}' found`);
75872
+ return id;
75873
+ }
75445
75874
  /** Returns the overview vertex for a named service; throws if not found. */
75446
75875
  async getServiceOverview(serviceName) {
75447
- return this.dxSaaSService
75448
- .oiGet(`oi/v2/sa/services/${serviceName}`, {
75449
- subservices: false,
75450
- })
75451
- .then((graph) => {
75452
- if (graph.vertices.length == 1) {
75453
- return graph.vertices[0];
75454
- }
75455
- else
75456
- throw new Error(`No Service with name '${serviceName}' Found`);
75457
- });
75876
+ const response = await this.getService(serviceName, false);
75877
+ if (response.vertices.length === 1) {
75878
+ return response.vertices[0];
75879
+ }
75880
+ throw new Error(`No Service with name '${serviceName}' Found`);
75458
75881
  }
75459
75882
  /** Returns paginated top-level (grouped) services up to `maxRequests` pages. */
75460
75883
  async getTopLevelServices(pageSize = 100, maxRequests = 20) {
@@ -75544,12 +75967,109 @@ class ServiceService {
75544
75967
  sirb.customFilter.and.expressions[0].or.expressions[0].value = serviceName;
75545
75968
  return sirb;
75546
75969
  }
75547
- /** Creates a new service from the given body; replaces any existing service with the same name. */
75548
- async createService(createServiceBody) {
75549
- return this.dxSaaSService.tenantPost('/oi/v2/sa/save', createServiceBody, {
75970
+ /**
75971
+ * Creates/updates one or more services via the store API (`/oi/v2/sa/save`).
75972
+ *
75973
+ * @remarks Edges not present in the payload are removed for the services it
75974
+ * covers — the store API treats the supplied edge set as authoritative.
75975
+ * @param createServiceBody - vertices + edges to store.
75976
+ * @param opts.replace - When true (default), replaces an existing service of the
75977
+ * same name rather than erroring.
75978
+ * @param opts.autoWeight - When true, the server computes edge health/risk weights,
75979
+ * overriding any weights supplied in the payload. Omitted by default.
75980
+ */
75981
+ async createService(createServiceBody, opts = {}) {
75982
+ const params = { replace: opts.replace ?? true };
75983
+ if (opts.autoWeight !== undefined) {
75984
+ params['auto_weight'] = opts.autoWeight;
75985
+ }
75986
+ return this.dxSaaSService.tenantPost('/oi/v2/sa/save', createServiceBody, params);
75987
+ }
75988
+ /**
75989
+ * Builds the store body for associating `childName` under `parentName` as a
75990
+ * new `AggregateOf` child, WITHOUT saving (so callers can dry-run it).
75991
+ *
75992
+ * Because `replace=true` makes each saved vertex's edge set authoritative, the
75993
+ * body carries both services' full subtrees. Weights: explicit
75994
+ * `healthWeight`/`riskWeight` are applied to the new edge and validated against
75995
+ * the ≤100% limit; otherwise the parent's direct children are evenly rebalanced.
75996
+ */
75997
+ async buildAssociateBody(parentName, childName, opts = {}) {
75998
+ if (parentName === childName) {
75999
+ throw new Error(`A service cannot be associated with itself ('${parentName}')`);
76000
+ }
76001
+ // Validate both services exist (throws "No service with name … found").
76002
+ await this.getServiceExternalId(parentName);
76003
+ await this.getServiceExternalId(childName);
76004
+ // Bodies are keyed by service name (see ServiceTransform.retrievedToServiceVertex).
76005
+ const body = exports.ServiceTransform.mergeBodies(await this.buildHierarchyBody(parentName), await this.buildHierarchyBody(childName));
76006
+ const explicit = opts.healthWeight !== undefined || opts.riskWeight !== undefined;
76007
+ body.edges = exports.ServiceTransform.upsertEdge(body.edges, parentName, childName, opts.healthWeight ?? 1, opts.riskWeight ?? 1);
76008
+ if (explicit) {
76009
+ exports.ServiceTransform.assertWeightsWithinLimit(body.edges, parentName);
76010
+ }
76011
+ else {
76012
+ body.edges = exports.ServiceTransform.rebalanceChildren(body.edges, parentName);
76013
+ }
76014
+ return body;
76015
+ }
76016
+ /** Associates `childName` under `parentName` and saves it. See {@link buildAssociateBody}. */
76017
+ async associateServices(parentName, childName, opts = {}) {
76018
+ const body = await this.buildAssociateBody(parentName, childName, opts);
76019
+ return this.createService(body, {
75550
76020
  replace: true,
76021
+ autoWeight: opts.autoWeight,
75551
76022
  });
75552
76023
  }
76024
+ /**
76025
+ * Builds the store body for removing the `parentName`→`childName` association,
76026
+ * WITHOUT saving. The child (and its own subtree) is preserved as a now-detached
76027
+ * service.
76028
+ * @throws Error if `childName` is not a direct child of `parentName`.
76029
+ */
76030
+ async buildDissociateBody(parentName, childName) {
76031
+ // Validate both services exist (throws "No service with name … found").
76032
+ await this.getServiceExternalId(parentName);
76033
+ await this.getServiceExternalId(childName);
76034
+ const body = await this.buildHierarchyBody(parentName);
76035
+ const before = body.edges.length;
76036
+ body.edges = exports.ServiceTransform.removeEdge(body.edges, parentName, childName);
76037
+ if (body.edges.length === before) {
76038
+ throw new Error(`'${childName}' is not a direct child of '${parentName}' — nothing to dissociate`);
76039
+ }
76040
+ return body;
76041
+ }
76042
+ /** Removes the `parentName`→`childName` association and saves it. See {@link buildDissociateBody}. */
76043
+ async dissociateServices(parentName, childName) {
76044
+ const body = await this.buildDissociateBody(parentName, childName);
76045
+ return this.createService(body, { replace: true });
76046
+ }
76047
+ /**
76048
+ * Builds the store body for updating a service's content queries, WITHOUT
76049
+ * saving. Re-saves the service's full subtree (via {@link buildHierarchyBody})
76050
+ * so its children edges are preserved — only the target vertex's
76051
+ * `serviceContent` changes.
76052
+ *
76053
+ * @param mode - 'set' replaces the content; 'add' appends to the existing content.
76054
+ * @returns the store body plus the service's previous content (for diffing).
76055
+ * @throws Error if the service does not exist.
76056
+ */
76057
+ async buildServiceContentBody(serviceName, newContent, mode) {
76058
+ const body = await this.buildHierarchyBody(serviceName);
76059
+ const target = body.vertices.find((v) => v.attributes.name === serviceName);
76060
+ if (!target) {
76061
+ throw new Error(`No service with name '${serviceName}' found`);
76062
+ }
76063
+ const previousContent = target.attributes.serviceContent;
76064
+ target.attributes.serviceContent =
76065
+ mode === 'set' ? newContent : [...previousContent, ...newContent];
76066
+ return { body, previousContent };
76067
+ }
76068
+ /** Updates a service's content queries and saves it. See {@link buildServiceContentBody}. */
76069
+ async updateServiceContent(serviceName, newContent, mode) {
76070
+ const { body } = await this.buildServiceContentBody(serviceName, newContent, mode);
76071
+ return this.createService(body, { replace: true });
76072
+ }
75553
76073
  /** Deletes a service by name; optionally deletes child services recursively. */
75554
76074
  async deleteService(serviceName, recursive = false) {
75555
76075
  return this.dxSaaSService.tenantPost('/oi/v2/sa/service/status', { name: serviceName, action: 'delete' }, { recursive });