@likec4/leanix-bridge 1.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/index.d.mts +559 -0
- package/dist/index.mjs +1110 -0
- package/package.json +62 -0
- package/src/adr-generation.ts +105 -0
- package/src/contracts.ts +96 -0
- package/src/drawio-leanix-roundtrip.ts +51 -0
- package/src/drift-report.ts +61 -0
- package/src/fixture-model.ts +52 -0
- package/src/governance-checks.ts +103 -0
- package/src/impact-report.ts +38 -0
- package/src/index.ts +109 -0
- package/src/leanix-api-client.ts +138 -0
- package/src/leanix-graphql-operations.ts +160 -0
- package/src/leanix-inventory-snapshot.ts +263 -0
- package/src/mapping.ts +88 -0
- package/src/model-input.ts +36 -0
- package/src/reconcile.ts +227 -0
- package/src/report.ts +73 -0
- package/src/sync-to-leanix.ts +352 -0
- package/src/to-bridge-manifest.ts +85 -0
- package/src/to-leanix-inventory-dry-run.ts +105 -0
- package/src/validate.ts +106 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Canonical bridge contracts for LikeC4 ↔ LeanIX interoperability.
|
|
6
|
+
* LikeC4 remains the semantic source of truth; external IDs are provider-scoped.
|
|
7
|
+
* Uses readFileSync (not require) so when bundled into likec4 CLI the bundler
|
|
8
|
+
* does not emit require('../package.json') which fails in the tarball layout.
|
|
9
|
+
*/
|
|
10
|
+
const _dir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
function readVersion() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(join(_dir, "..", "package.json"), "utf8")).version ?? "0.1.0";
|
|
14
|
+
} catch {
|
|
15
|
+
return JSON.parse(readFileSync(join(_dir, "..", "..", "package.json"), "utf8")).version ?? "0.1.0";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Single source of truth: must match package.json version. */
|
|
19
|
+
const BRIDGE_VERSION = readVersion();
|
|
20
|
+
/** Manifest schema version (semantic; must match parser). */
|
|
21
|
+
const BRIDGE_MANIFEST_VERSION = "1.0";
|
|
22
|
+
/** Provider identifier for LeanIX (single source of truth for external.leanix). */
|
|
23
|
+
const LEANIX_PROVIDER = "leanix";
|
|
24
|
+
/** Fallback when element kind is unknown (G25: named constant). */
|
|
25
|
+
const FALLBACK_FACT_SHEET_TYPE = "Application";
|
|
26
|
+
/** Fallback when relation kind is unknown (G25: named constant). */
|
|
27
|
+
const FALLBACK_RELATION_TYPE = "depends on";
|
|
28
|
+
/** Default mapping: actor → Provider; override factSheetTypes/relationTypes as needed */
|
|
29
|
+
const DEFAULT_LEANIX_MAPPING = {
|
|
30
|
+
factSheetTypes: {
|
|
31
|
+
system: "Application",
|
|
32
|
+
container: "ITComponent",
|
|
33
|
+
component: "ITComponent",
|
|
34
|
+
actor: "Provider"
|
|
35
|
+
},
|
|
36
|
+
relationTypes: { default: FALLBACK_RELATION_TYPE },
|
|
37
|
+
metadataToFields: {
|
|
38
|
+
title: "name",
|
|
39
|
+
description: "description",
|
|
40
|
+
technology: "technology"
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Merges partial mapping config with DEFAULT_LEANIX_MAPPING; returns a full Required<LeanixMappingConfig>.
|
|
45
|
+
*/
|
|
46
|
+
function mergeWithDefault(partial) {
|
|
47
|
+
const base = {
|
|
48
|
+
factSheetTypes: { ...DEFAULT_LEANIX_MAPPING.factSheetTypes },
|
|
49
|
+
relationTypes: { ...DEFAULT_LEANIX_MAPPING.relationTypes },
|
|
50
|
+
metadataToFields: { ...DEFAULT_LEANIX_MAPPING.metadataToFields }
|
|
51
|
+
};
|
|
52
|
+
if (!partial) return base;
|
|
53
|
+
return {
|
|
54
|
+
factSheetTypes: {
|
|
55
|
+
...base.factSheetTypes,
|
|
56
|
+
...partial.factSheetTypes
|
|
57
|
+
},
|
|
58
|
+
relationTypes: {
|
|
59
|
+
...base.relationTypes,
|
|
60
|
+
...partial.relationTypes
|
|
61
|
+
},
|
|
62
|
+
metadataToFields: {
|
|
63
|
+
...base.metadataToFields,
|
|
64
|
+
...partial.metadataToFields
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Returns LeanIX fact sheet type for a LikeC4 element kind.
|
|
70
|
+
* Uses mapping.factSheetTypes[kind], then 'default', then FALLBACK_FACT_SHEET_TYPE.
|
|
71
|
+
*/
|
|
72
|
+
function getFactSheetType(likec4Kind, mapping) {
|
|
73
|
+
return mapping.factSheetTypes[likec4Kind] ?? mapping.factSheetTypes["default"] ?? FALLBACK_FACT_SHEET_TYPE;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Returns LeanIX relation type for a LikeC4 relationship kind.
|
|
77
|
+
* Uses mapping.relationTypes[kind], then 'default', then FALLBACK_RELATION_TYPE.
|
|
78
|
+
*/
|
|
79
|
+
function getRelationType(likec4Kind, mapping) {
|
|
80
|
+
const kind = likec4Kind ?? "default";
|
|
81
|
+
return mapping.relationTypes[kind] ?? mapping.relationTypes["default"] ?? FALLBACK_RELATION_TYPE;
|
|
82
|
+
}
|
|
83
|
+
const defaultOptions = {
|
|
84
|
+
manifestVersion: BRIDGE_MANIFEST_VERSION,
|
|
85
|
+
bridgeVersion: BRIDGE_VERSION,
|
|
86
|
+
mappingProfile: "default"
|
|
87
|
+
};
|
|
88
|
+
/** Builds manifest entities map from model elements (canonicalId + empty external). */
|
|
89
|
+
function buildManifestEntities(model) {
|
|
90
|
+
const entities = {};
|
|
91
|
+
for (const el of model.elements()) entities[el.id] = {
|
|
92
|
+
canonicalId: el.id,
|
|
93
|
+
external: {}
|
|
94
|
+
};
|
|
95
|
+
return entities;
|
|
96
|
+
}
|
|
97
|
+
/** Builds manifest views map from model views (viewId + empty external). */
|
|
98
|
+
function buildManifestViews(model) {
|
|
99
|
+
const views = {};
|
|
100
|
+
for (const v of model.views()) views[v.id] = {
|
|
101
|
+
viewId: v.id,
|
|
102
|
+
external: {}
|
|
103
|
+
};
|
|
104
|
+
return views;
|
|
105
|
+
}
|
|
106
|
+
/** Builds manifest relations array from model relationships (compositeKey + empty external). */
|
|
107
|
+
function buildManifestRelations(model) {
|
|
108
|
+
const relations = [];
|
|
109
|
+
for (const rel of model.relationships()) relations.push({
|
|
110
|
+
relationId: rel.id,
|
|
111
|
+
sourceFqn: rel.source.id,
|
|
112
|
+
targetFqn: rel.target.id,
|
|
113
|
+
compositeKey: `${rel.source.id}|${rel.target.id}|${rel.id}`,
|
|
114
|
+
external: {}
|
|
115
|
+
});
|
|
116
|
+
return relations;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Produces the identity manifest from a LikeC4 model (canonical IDs + placeholders for external IDs).
|
|
120
|
+
* Pure function; no live API calls.
|
|
121
|
+
*/
|
|
122
|
+
function toBridgeManifest(model, options = {}) {
|
|
123
|
+
const opts = {
|
|
124
|
+
...defaultOptions,
|
|
125
|
+
...options,
|
|
126
|
+
generatedAt: options.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
manifestVersion: opts.manifestVersion,
|
|
130
|
+
generatedAt: opts.generatedAt,
|
|
131
|
+
bridgeVersion: opts.bridgeVersion,
|
|
132
|
+
mappingProfile: opts.mappingProfile,
|
|
133
|
+
projectId: model.projectId,
|
|
134
|
+
entities: buildManifestEntities(model),
|
|
135
|
+
views: buildManifestViews(model),
|
|
136
|
+
relations: buildManifestRelations(model)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Builds LeanIX fact sheet dry-run list from model elements and mapping. */
|
|
140
|
+
function buildFactSheetsFromModel(model, mapping) {
|
|
141
|
+
const factSheets = [];
|
|
142
|
+
for (const el of model.elements()) {
|
|
143
|
+
const fsType = getFactSheetType(el.kind, mapping);
|
|
144
|
+
const meta = el.getMetadata();
|
|
145
|
+
const desc = typeof meta["description"] === "string" ? meta["description"] : void 0;
|
|
146
|
+
const tech = el.technology ?? (typeof meta["technology"] === "string" ? meta["technology"] : void 0);
|
|
147
|
+
factSheets.push({
|
|
148
|
+
type: fsType,
|
|
149
|
+
likec4Id: el.id,
|
|
150
|
+
name: el.title,
|
|
151
|
+
...desc !== void 0 && { description: desc },
|
|
152
|
+
...tech !== void 0 && { technology: tech },
|
|
153
|
+
...el.tags.length > 0 && { tags: [...el.tags] },
|
|
154
|
+
...Object.keys(meta).length > 0 && { metadata: { ...meta } }
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
factSheets.sort((a, b) => a.likec4Id.localeCompare(b.likec4Id));
|
|
158
|
+
return factSheets;
|
|
159
|
+
}
|
|
160
|
+
/** Builds LeanIX relation dry-run list from model relationships and mapping. */
|
|
161
|
+
function buildRelationsFromModel(model, mapping) {
|
|
162
|
+
const relations = [];
|
|
163
|
+
for (const rel of model.relationships()) {
|
|
164
|
+
const titleVal = rel.title ?? rel.kind;
|
|
165
|
+
relations.push({
|
|
166
|
+
type: getRelationType(rel.kind, mapping),
|
|
167
|
+
likec4RelationId: rel.id,
|
|
168
|
+
sourceLikec4Id: rel.source.id,
|
|
169
|
+
targetLikec4Id: rel.target.id,
|
|
170
|
+
...titleVal != null && titleVal !== "" && { title: String(titleVal) }
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
relations.sort((a, b) => a.likec4RelationId.localeCompare(b.likec4RelationId));
|
|
174
|
+
return relations;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Produces LeanIX-shaped inventory artifacts (fact sheets + relations) from a LikeC4 model.
|
|
178
|
+
* Pure function; no live API. Use for dry-run and planning.
|
|
179
|
+
*/
|
|
180
|
+
function toLeanixInventoryDryRun(model, options = {}) {
|
|
181
|
+
const mapping = mergeWithDefault(options.mapping);
|
|
182
|
+
const generatedAt = options.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
183
|
+
const mappingProfile = options.mappingProfile ?? (options.mapping ? "custom" : "default");
|
|
184
|
+
return {
|
|
185
|
+
generatedAt,
|
|
186
|
+
projectId: model.projectId,
|
|
187
|
+
mappingProfile,
|
|
188
|
+
factSheets: buildFactSheetsFromModel(model, mapping),
|
|
189
|
+
relations: buildRelationsFromModel(model, mapping)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Builds error message listing which coherence fields mismatched (projectId, mappingProfile). */
|
|
193
|
+
function buildCoherenceErrorMessage(manifest, leanixDryRun) {
|
|
194
|
+
const mismatches = [];
|
|
195
|
+
if (manifest.projectId !== leanixDryRun.projectId) mismatches.push(`projectId (manifest: ${manifest.projectId}, leanixDryRun: ${leanixDryRun.projectId})`);
|
|
196
|
+
if (manifest.mappingProfile !== leanixDryRun.mappingProfile) mismatches.push(`mappingProfile (manifest: ${manifest.mappingProfile}, leanixDryRun: ${leanixDryRun.mappingProfile})`);
|
|
197
|
+
return `Manifest and LeanIX dry-run must belong to the same run. Mismatch: ${mismatches.join("; ")}`;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Builds a summary report (counts, artifact names) from manifest and dry-run. Throws if projectId or mappingProfile differ.
|
|
201
|
+
*/
|
|
202
|
+
function buildBridgeReport(manifest, leanixDryRun) {
|
|
203
|
+
if (manifest.projectId !== leanixDryRun.projectId || manifest.mappingProfile !== leanixDryRun.mappingProfile) throw new Error(buildCoherenceErrorMessage(manifest, leanixDryRun));
|
|
204
|
+
return {
|
|
205
|
+
generatedAt: manifest.generatedAt,
|
|
206
|
+
projectId: manifest.projectId,
|
|
207
|
+
manifestVersion: manifest.manifestVersion,
|
|
208
|
+
bridgeVersion: manifest.bridgeVersion,
|
|
209
|
+
mappingProfile: manifest.mappingProfile,
|
|
210
|
+
counts: {
|
|
211
|
+
entities: Object.keys(manifest.entities).length,
|
|
212
|
+
views: Object.keys(manifest.views).length,
|
|
213
|
+
relations: manifest.relations.length,
|
|
214
|
+
factSheets: leanixDryRun.factSheets.length,
|
|
215
|
+
leanixRelations: leanixDryRun.relations.length
|
|
216
|
+
},
|
|
217
|
+
artifacts: {
|
|
218
|
+
manifest: "manifest.json",
|
|
219
|
+
leanixDryRun: "leanix-dry-run.json"
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const DEFAULT_BASE_URL = "https://app.leanix.net";
|
|
224
|
+
const DEFAULT_DELAY_MS = 200;
|
|
225
|
+
/** Thrown when the LeanIX API returns errors or non-OK HTTP. */
|
|
226
|
+
var LeanixApiError = class extends Error {
|
|
227
|
+
constructor(message, statusCode, graphqlErrors) {
|
|
228
|
+
super(message);
|
|
229
|
+
this.statusCode = statusCode;
|
|
230
|
+
this.graphqlErrors = graphqlErrors;
|
|
231
|
+
this.name = "LeanixApiError";
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
function sleep(ms) {
|
|
235
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* LeanIX GraphQL API client with Bearer auth and optional rate limiting.
|
|
239
|
+
* Throttle state is per instance so multiple clients do not share delay.
|
|
240
|
+
*/
|
|
241
|
+
var LeanixApiClient = class {
|
|
242
|
+
baseUrl;
|
|
243
|
+
apiToken;
|
|
244
|
+
requestDelayMs;
|
|
245
|
+
/** Last request timestamp for throttling (per instance). */
|
|
246
|
+
lastRequestTime = 0;
|
|
247
|
+
/** Serializes rate-limit and request so only one caller runs at a time. */
|
|
248
|
+
rateLimitLock = Promise.resolve();
|
|
249
|
+
constructor(config) {
|
|
250
|
+
this.apiToken = config.apiToken;
|
|
251
|
+
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
252
|
+
this.requestDelayMs = config.requestDelayMs ?? DEFAULT_DELAY_MS;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Execute a GraphQL operation (query or mutation).
|
|
256
|
+
* Throttles requests by requestDelayMs. Throws LeanixApiError on HTTP or GraphQL errors.
|
|
257
|
+
*/
|
|
258
|
+
async graphql(query, variables) {
|
|
259
|
+
const run = async () => {
|
|
260
|
+
const elapsed = Date.now() - this.lastRequestTime;
|
|
261
|
+
if (elapsed < this.requestDelayMs) await sleep(this.requestDelayMs - elapsed);
|
|
262
|
+
this.lastRequestTime = Date.now();
|
|
263
|
+
const url = `${this.baseUrl}/services/pathfinder/v1/graphql`;
|
|
264
|
+
let res;
|
|
265
|
+
try {
|
|
266
|
+
res = await fetch(url, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: {
|
|
269
|
+
"Content-Type": "application/json",
|
|
270
|
+
Authorization: `Bearer ${this.apiToken}`
|
|
271
|
+
},
|
|
272
|
+
body: JSON.stringify({
|
|
273
|
+
query,
|
|
274
|
+
variables
|
|
275
|
+
})
|
|
276
|
+
});
|
|
277
|
+
} catch (err) {
|
|
278
|
+
throw new LeanixApiError(`GraphQL request failed: ${url} POST - ${err instanceof Error ? err.message : String(err)}`);
|
|
279
|
+
}
|
|
280
|
+
let body;
|
|
281
|
+
try {
|
|
282
|
+
body = await res.json();
|
|
283
|
+
} catch (err) {
|
|
284
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
285
|
+
throw new LeanixApiError(`Invalid JSON response: ${url} ${res.status} ${res.statusText} - ${msg}`, res.status);
|
|
286
|
+
}
|
|
287
|
+
if (!res.ok) throw new LeanixApiError(body.errors?.[0]?.message ?? `HTTP ${res.status} ${res.statusText}`, res.status, body.errors);
|
|
288
|
+
if (body.errors && body.errors.length > 0) throw new LeanixApiError(body.errors.map((e) => e.message).join("; "), res.status, body.errors);
|
|
289
|
+
if (body.data === void 0) throw new LeanixApiError("GraphQL response had no data and no errors");
|
|
290
|
+
return body.data;
|
|
291
|
+
};
|
|
292
|
+
const prev = this.rateLimitLock;
|
|
293
|
+
let resolve;
|
|
294
|
+
this.rateLimitLock = new Promise((r) => {
|
|
295
|
+
resolve = r;
|
|
296
|
+
});
|
|
297
|
+
await prev;
|
|
298
|
+
try {
|
|
299
|
+
return await run();
|
|
300
|
+
} finally {
|
|
301
|
+
resolve();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
/** Search fact sheets by name and type (for idempotency). Returns null when not found; throws on API/network error. */
|
|
306
|
+
async function findFactSheetByNameAndType(client, name, type) {
|
|
307
|
+
const edges = (await client.graphql(`
|
|
308
|
+
query FindFactSheet($name: String!, $type: String!) {
|
|
309
|
+
allFactSheets(filter: { name: $name, factSheetType: $type }) {
|
|
310
|
+
edges { node { id name type } }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
`, {
|
|
314
|
+
name,
|
|
315
|
+
type
|
|
316
|
+
})).allFactSheets?.edges ?? [];
|
|
317
|
+
if (edges.length > 1) throw new Error(`Multiple fact sheets found for name="${name}" type="${type}". Ensure unique name+type in LeanIX or use likec4Id attribute for lookup.`);
|
|
318
|
+
return (edges[0]?.node)?.id ?? null;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Search fact sheets by custom attribute (e.g. likec4Id) for idempotent lookup.
|
|
322
|
+
* Returns null when not found; throws on API/network error.
|
|
323
|
+
*/
|
|
324
|
+
async function findFactSheetByLikec4IdAttribute(client, attributeKey, likec4Id) {
|
|
325
|
+
const query = `
|
|
326
|
+
query FindFactSheetByAttribute($filter: FilterInput!) {
|
|
327
|
+
allFactSheets(filter: $filter) {
|
|
328
|
+
edges { node { id } }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
`;
|
|
332
|
+
const filter = { facetFilters: [{
|
|
333
|
+
facetKey: attributeKey,
|
|
334
|
+
operator: "OR",
|
|
335
|
+
keys: [likec4Id]
|
|
336
|
+
}] };
|
|
337
|
+
const edges = (await client.graphql(query, { filter })).allFactSheets?.edges ?? [];
|
|
338
|
+
if (edges.length > 1) throw new Error(`Multiple fact sheets found for attribute ${attributeKey}=${likec4Id}. Ensure unique likec4Id in LeanIX.`);
|
|
339
|
+
return (edges[0]?.node)?.id ?? null;
|
|
340
|
+
}
|
|
341
|
+
/** Patch an existing fact sheet to set a custom attribute (e.g. likec4Id). Throws on API error. */
|
|
342
|
+
async function patchFactSheetAttribute(client, factSheetId, attributeKey, value) {
|
|
343
|
+
const patches = [{
|
|
344
|
+
op: "replace",
|
|
345
|
+
path: `/factSheetAttributes/${attributeKey}`,
|
|
346
|
+
value
|
|
347
|
+
}];
|
|
348
|
+
if (!(await client.graphql(`
|
|
349
|
+
mutation UpdateFactSheet($id: ID!, $patches: [Patch]) {
|
|
350
|
+
updateFactSheet(id: $id, patches: $patches) {
|
|
351
|
+
factSheet { id }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
`, {
|
|
355
|
+
id: factSheetId,
|
|
356
|
+
patches
|
|
357
|
+
})).updateFactSheet?.factSheet?.id) throw new Error(`updateFactSheet did not return fact sheet (id=${factSheetId}, attribute=${attributeKey})`);
|
|
358
|
+
}
|
|
359
|
+
/** Create fact sheet (name, type, optional description and likec4Id attribute). Returns new id; throws on API error. */
|
|
360
|
+
async function createFactSheet(client, fs, likec4IdAttribute) {
|
|
361
|
+
const patches = [];
|
|
362
|
+
if (fs.description) patches.push({
|
|
363
|
+
op: "replace",
|
|
364
|
+
path: "/description",
|
|
365
|
+
value: fs.description
|
|
366
|
+
});
|
|
367
|
+
if (likec4IdAttribute && fs.likec4Id) patches.push({
|
|
368
|
+
op: "replace",
|
|
369
|
+
path: `/factSheetAttributes/${likec4IdAttribute}`,
|
|
370
|
+
value: fs.likec4Id
|
|
371
|
+
});
|
|
372
|
+
const mutation = `
|
|
373
|
+
mutation CreateFactSheet($input: CreateFactSheetInput!, $patches: [Patch]) {
|
|
374
|
+
createFactSheet(input: $input, patches: $patches) {
|
|
375
|
+
factSheet { id name type rev }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
`;
|
|
379
|
+
const variables = {
|
|
380
|
+
input: {
|
|
381
|
+
name: fs.name,
|
|
382
|
+
type: fs.type
|
|
383
|
+
},
|
|
384
|
+
patches
|
|
385
|
+
};
|
|
386
|
+
const id = (await client.graphql(mutation, variables)).createFactSheet?.factSheet?.id;
|
|
387
|
+
if (!id) throw new Error(`createFactSheet did not return id for ${String(fs.name)}`);
|
|
388
|
+
return id;
|
|
389
|
+
}
|
|
390
|
+
/** Create a relation between two fact sheets. Returns relation id; throws when mutation returns no id. */
|
|
391
|
+
async function createRelation(client, sourceFactSheetId, targetFactSheetId, relationType, _title) {
|
|
392
|
+
const data = await client.graphql(`
|
|
393
|
+
mutation CreateRelation($source: ID!, $target: ID!, $type: String!) {
|
|
394
|
+
createRelation(source: $source, target: $target, type: $type) {
|
|
395
|
+
relation { id }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
`, {
|
|
399
|
+
source: sourceFactSheetId,
|
|
400
|
+
target: targetFactSheetId,
|
|
401
|
+
type: relationType
|
|
402
|
+
});
|
|
403
|
+
const id = data.createRelation?.relation?.id;
|
|
404
|
+
if (!id) {
|
|
405
|
+
const payload = JSON.stringify(data, null, 2);
|
|
406
|
+
throw new Error(`createRelation did not return relation id (sourceFactSheetId=${sourceFactSheetId}, targetFactSheetId=${targetFactSheetId}, relationType=${relationType}). Response: ${payload}`);
|
|
407
|
+
}
|
|
408
|
+
return id;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Sync bridge manifest + LeanIX dry-run inventory to the LeanIX API.
|
|
412
|
+
* Creates or updates fact sheets and relations; returns manifest with external LeanIX IDs.
|
|
413
|
+
* Supports a read-only "plan" step that queries LeanIX to produce a sync plan artifact (Phase 2).
|
|
414
|
+
*/
|
|
415
|
+
function buildFactSheetPlanEntry(fs, existingFactSheetId) {
|
|
416
|
+
const action = existingFactSheetId ? "update" : "create";
|
|
417
|
+
return {
|
|
418
|
+
likec4Id: fs.likec4Id,
|
|
419
|
+
name: fs.name,
|
|
420
|
+
type: fs.type,
|
|
421
|
+
action,
|
|
422
|
+
...existingFactSheetId ? { existingFactSheetId } : {}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function buildPlanSummary(factSheetPlans, relationPlans) {
|
|
426
|
+
return {
|
|
427
|
+
factSheetsToCreate: factSheetPlans.filter((p) => p.action === "create").length,
|
|
428
|
+
factSheetsToUpdate: factSheetPlans.filter((p) => p.action === "update").length,
|
|
429
|
+
relationsToCreate: relationPlans.length
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/** Normalise caught value to a string for error reporting (Clean Code: context in errors). */
|
|
433
|
+
function toErrorMessage(err) {
|
|
434
|
+
if (err instanceof Error) return err.stack ? `${err.message}\n${err.stack}` : err.message;
|
|
435
|
+
if (typeof err === "object" && err !== null) try {
|
|
436
|
+
return JSON.stringify(err, null, 2);
|
|
437
|
+
} catch {
|
|
438
|
+
return Object.prototype.toString.call(err);
|
|
439
|
+
}
|
|
440
|
+
return String(err);
|
|
441
|
+
}
|
|
442
|
+
/** Applies LeanIX fact sheet IDs to manifest entities; returns new entities object. */
|
|
443
|
+
function applyLeanixIdsToEntities(entities, likec4IdToFactSheetId) {
|
|
444
|
+
const out = { ...entities };
|
|
445
|
+
for (const [canonicalId, entity] of Object.entries(entities)) {
|
|
446
|
+
const leanixId = likec4IdToFactSheetId.get(canonicalId);
|
|
447
|
+
if (leanixId) out[canonicalId] = {
|
|
448
|
+
...entity,
|
|
449
|
+
external: {
|
|
450
|
+
...entity.external,
|
|
451
|
+
[LEANIX_PROVIDER]: {
|
|
452
|
+
factSheetId: leanixId,
|
|
453
|
+
externalId: leanixId
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
/** Resolves existing LeanIX fact sheet id by likec4Id attribute or name+type; optionally patches likec4Id. Returns null when not found. */
|
|
461
|
+
async function resolveExistingFactSheetId(client, fs, idempotent, likec4IdAttribute) {
|
|
462
|
+
if (!idempotent) return null;
|
|
463
|
+
if (likec4IdAttribute) {
|
|
464
|
+
const byAttr = await findFactSheetByLikec4IdAttribute(client, likec4IdAttribute, fs.likec4Id);
|
|
465
|
+
if (byAttr) return byAttr;
|
|
466
|
+
const byNameType = await findFactSheetByNameAndType(client, fs.name, fs.type);
|
|
467
|
+
if (byNameType && fs.likec4Id) try {
|
|
468
|
+
await patchFactSheetAttribute(client, byNameType, likec4IdAttribute, fs.likec4Id);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.warn(`[leanix-bridge] Failed to backfill likec4Id on fact sheet ${byNameType} (${fs.name}/${fs.type}): ${err instanceof Error ? err.message : String(err)}. Reusing ID.`);
|
|
471
|
+
}
|
|
472
|
+
return byNameType ?? null;
|
|
473
|
+
}
|
|
474
|
+
return findFactSheetByNameAndType(client, fs.name, fs.type);
|
|
475
|
+
}
|
|
476
|
+
/** Creates or finds fact sheets in LeanIX; returns map and counts. Single responsibility. */
|
|
477
|
+
async function syncFactSheetsToLeanix(client, factSheets, idempotent, likec4IdAttribute) {
|
|
478
|
+
const likec4IdToFactSheetId = /* @__PURE__ */ new Map();
|
|
479
|
+
const errors = [];
|
|
480
|
+
let factSheetsCreated = 0;
|
|
481
|
+
let factSheetsReused = 0;
|
|
482
|
+
for (const fs of factSheets) try {
|
|
483
|
+
let factSheetId = await resolveExistingFactSheetId(client, fs, idempotent, likec4IdAttribute);
|
|
484
|
+
if (factSheetId) factSheetsReused++;
|
|
485
|
+
if (!factSheetId) {
|
|
486
|
+
factSheetId = await createFactSheet(client, fs, likec4IdAttribute);
|
|
487
|
+
factSheetsCreated++;
|
|
488
|
+
}
|
|
489
|
+
if (factSheetId) likec4IdToFactSheetId.set(fs.likec4Id, factSheetId);
|
|
490
|
+
} catch (e) {
|
|
491
|
+
errors.push(`Fact sheet ${fs.likec4Id} (${String(fs.name)}): ${toErrorMessage(e)}`);
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
likec4IdToFactSheetId,
|
|
495
|
+
factSheetsCreated,
|
|
496
|
+
factSheetsReused,
|
|
497
|
+
errors
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/** Creates relations in LeanIX and returns manifest relations with external IDs. Single responsibility. */
|
|
501
|
+
async function syncRelationsToLeanix(client, manifestRelations, leanixRelations, likec4IdToFactSheetId) {
|
|
502
|
+
const updatedRelations = [];
|
|
503
|
+
const errors = [];
|
|
504
|
+
let relationsCreated = 0;
|
|
505
|
+
for (const rel of manifestRelations) {
|
|
506
|
+
const sourceId = likec4IdToFactSheetId.get(rel.sourceFqn);
|
|
507
|
+
const targetId = likec4IdToFactSheetId.get(rel.targetFqn);
|
|
508
|
+
const existing = rel.external?.[LEANIX_PROVIDER];
|
|
509
|
+
if (sourceId && targetId && !existing?.relationId) {
|
|
510
|
+
const dryRel = leanixRelations.find((r) => r.sourceLikec4Id === rel.sourceFqn && r.targetLikec4Id === rel.targetFqn && r.likec4RelationId === rel.relationId);
|
|
511
|
+
if (dryRel) try {
|
|
512
|
+
const relationId = await createRelation(client, sourceId, targetId, dryRel.type, dryRel.title);
|
|
513
|
+
relationsCreated++;
|
|
514
|
+
updatedRelations.push({
|
|
515
|
+
...rel,
|
|
516
|
+
external: {
|
|
517
|
+
...rel.external,
|
|
518
|
+
[LEANIX_PROVIDER]: {
|
|
519
|
+
relationId,
|
|
520
|
+
...rel.external?.[LEANIX_PROVIDER]
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
continue;
|
|
525
|
+
} catch (e) {
|
|
526
|
+
errors.push(`Relation ${rel.compositeKey}: ${toErrorMessage(e)}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
updatedRelations.push(rel);
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
updatedRelations,
|
|
533
|
+
relationsCreated,
|
|
534
|
+
errors
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Produces a sync plan by querying LeanIX for existing fact sheets (read-only; no creates/updates).
|
|
539
|
+
* Use before syncToLeanix to review what would be created vs updated. Phase 2 dry-run sync planning.
|
|
540
|
+
*/
|
|
541
|
+
async function planSyncToLeanix(leanixDryRun, client, options = {}) {
|
|
542
|
+
const idempotent = options.idempotent ?? true;
|
|
543
|
+
const likec4IdAttribute = options.likec4IdAttribute;
|
|
544
|
+
const generatedAt = options.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
545
|
+
const errors = [];
|
|
546
|
+
const factSheetPlans = [];
|
|
547
|
+
for (const fs of leanixDryRun.factSheets) try {
|
|
548
|
+
let existingId = null;
|
|
549
|
+
if (idempotent) if (likec4IdAttribute) {
|
|
550
|
+
existingId = await findFactSheetByLikec4IdAttribute(client, likec4IdAttribute, fs.likec4Id);
|
|
551
|
+
if (!existingId) existingId = await findFactSheetByNameAndType(client, fs.name, fs.type);
|
|
552
|
+
} else existingId = await findFactSheetByNameAndType(client, fs.name, fs.type);
|
|
553
|
+
factSheetPlans.push(buildFactSheetPlanEntry(fs, existingId));
|
|
554
|
+
} catch (e) {
|
|
555
|
+
errors.push(`Fact sheet ${fs.likec4Id} (${String(fs.name)}): ${toErrorMessage(e)}`);
|
|
556
|
+
factSheetPlans.push(buildFactSheetPlanEntry(fs, null));
|
|
557
|
+
}
|
|
558
|
+
const relationPlans = leanixDryRun.relations.map((rel) => ({
|
|
559
|
+
likec4RelationId: rel.likec4RelationId,
|
|
560
|
+
sourceLikec4Id: rel.sourceLikec4Id,
|
|
561
|
+
targetLikec4Id: rel.targetLikec4Id,
|
|
562
|
+
type: rel.type,
|
|
563
|
+
action: "create"
|
|
564
|
+
}));
|
|
565
|
+
return {
|
|
566
|
+
generatedAt,
|
|
567
|
+
projectId: leanixDryRun.projectId,
|
|
568
|
+
mappingProfile: leanixDryRun.mappingProfile,
|
|
569
|
+
summary: buildPlanSummary(factSheetPlans, relationPlans),
|
|
570
|
+
factSheetPlans,
|
|
571
|
+
relationPlans,
|
|
572
|
+
errors
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Syncs the dry-run inventory to LeanIX: creates or finds fact sheets, creates relations,
|
|
577
|
+
* and returns an updated manifest with external LeanIX IDs.
|
|
578
|
+
*/
|
|
579
|
+
async function syncToLeanix(manifest, leanixDryRun, client, options = {}) {
|
|
580
|
+
const idempotent = options.idempotent ?? true;
|
|
581
|
+
const likec4IdAttribute = options.likec4IdAttribute;
|
|
582
|
+
const fsResult = await syncFactSheetsToLeanix(client, leanixDryRun.factSheets, idempotent, likec4IdAttribute);
|
|
583
|
+
const updatedEntities = applyLeanixIdsToEntities(manifest.entities, fsResult.likec4IdToFactSheetId);
|
|
584
|
+
const relResult = await syncRelationsToLeanix(client, manifest.relations, leanixDryRun.relations, fsResult.likec4IdToFactSheetId);
|
|
585
|
+
return {
|
|
586
|
+
manifest: {
|
|
587
|
+
...manifest,
|
|
588
|
+
entities: updatedEntities,
|
|
589
|
+
relations: relResult.updatedRelations
|
|
590
|
+
},
|
|
591
|
+
factSheetsCreated: fsResult.factSheetsCreated,
|
|
592
|
+
factSheetsReused: fsResult.factSheetsReused,
|
|
593
|
+
relationsCreated: relResult.relationsCreated,
|
|
594
|
+
errors: [...fsResult.errors, ...relResult.errors]
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Draw.io ↔ LeanIX round-trip: mapping between bridge manifest (with LeanIX external IDs)
|
|
599
|
+
* and diagram identity (likec4Id, likec4RelationId).
|
|
600
|
+
* Use after syncToLeanix to get a mapping for re-export or for annotating diagrams.
|
|
601
|
+
*/
|
|
602
|
+
/**
|
|
603
|
+
* Collects likec4Id → LeanIX identity (factSheetId or externalId) from manifest entities that have LeanIX external.
|
|
604
|
+
* Single responsibility: one level of abstraction for entity extraction.
|
|
605
|
+
*/
|
|
606
|
+
function collectLikec4IdToLeanixId(manifest) {
|
|
607
|
+
const out = {};
|
|
608
|
+
for (const [canonicalId, entity] of Object.entries(manifest.entities)) {
|
|
609
|
+
const leanixId = entity.external?.[LEANIX_PROVIDER]?.factSheetId ?? entity.external?.[LEANIX_PROVIDER]?.externalId;
|
|
610
|
+
if (leanixId) out[canonicalId] = leanixId;
|
|
611
|
+
}
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Collects compositeKey → LeanIX relationId from manifest relations that have LeanIX external.
|
|
616
|
+
* Single responsibility: one level of abstraction for relation extraction.
|
|
617
|
+
*/
|
|
618
|
+
function collectRelationKeyToLeanixRelationId(manifest) {
|
|
619
|
+
const out = {};
|
|
620
|
+
for (const rel of manifest.relations) {
|
|
621
|
+
const leanixRelId = rel.external?.[LEANIX_PROVIDER]?.relationId;
|
|
622
|
+
if (leanixRelId) out[rel.compositeKey] = leanixRelId;
|
|
623
|
+
}
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Builds a mapping from manifest (after sync) for use in Draw.io bridge-managed export
|
|
628
|
+
* or when re-importing from LeanIX. Elements can store leanixFactSheetId in style for round-trip.
|
|
629
|
+
*/
|
|
630
|
+
function manifestToDrawioLeanixMapping(manifest) {
|
|
631
|
+
return {
|
|
632
|
+
likec4IdToLeanixId: collectLikec4IdToLeanixId(manifest),
|
|
633
|
+
relationKeyToLeanixRelationId: collectRelationKeyToLeanixRelationId(manifest)
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
637
|
+
const DEFAULT_MAX_FACT_SHEETS = 1e3;
|
|
638
|
+
const MAX_GRAPHQL_RETRIES = 3;
|
|
639
|
+
async function withGraphQLRetry(fn) {
|
|
640
|
+
let lastErr;
|
|
641
|
+
for (let attempt = 0; attempt < MAX_GRAPHQL_RETRIES; attempt++) try {
|
|
642
|
+
return await fn();
|
|
643
|
+
} catch (err) {
|
|
644
|
+
lastErr = err;
|
|
645
|
+
if (attempt < MAX_GRAPHQL_RETRIES - 1) await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
|
646
|
+
}
|
|
647
|
+
throw lastErr;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Fetches a read-only snapshot of the LeanIX inventory (fact sheets, then relations).
|
|
651
|
+
* Uses cursor-based pagination. Does not modify LeanIX.
|
|
652
|
+
*/
|
|
653
|
+
async function fetchLeanixInventorySnapshot(client, options = {}) {
|
|
654
|
+
const generatedAt = options.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
655
|
+
const maxFactSheets = options.maxFactSheets ?? DEFAULT_MAX_FACT_SHEETS;
|
|
656
|
+
if (!Number.isInteger(maxFactSheets) || maxFactSheets < 0) throw new Error("maxFactSheets must be a non-negative integer");
|
|
657
|
+
const likec4IdAttribute = options.likec4IdAttribute;
|
|
658
|
+
const factSheets = await fetchAllFactSheets(client, {
|
|
659
|
+
...likec4IdAttribute != null ? { likec4IdAttribute } : {},
|
|
660
|
+
maxFactSheets
|
|
661
|
+
});
|
|
662
|
+
return {
|
|
663
|
+
generatedAt,
|
|
664
|
+
factSheets,
|
|
665
|
+
relations: await fetchAllRelations(client, factSheets.map((f) => f.id))
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/** Maps a GraphQL node to LeanixFactSheetSnapshotItem; returns null when node has no id. */
|
|
669
|
+
function mapNodeToFactSheetItem(node, likec4IdAttribute) {
|
|
670
|
+
if (!node?.id) return null;
|
|
671
|
+
const likec4Id = likec4IdAttribute != null && Array.isArray(node.factSheetAttributes) ? node.factSheetAttributes.find((a) => a.key === likec4IdAttribute)?.value : void 0;
|
|
672
|
+
return {
|
|
673
|
+
id: node.id,
|
|
674
|
+
name: node.name ?? "",
|
|
675
|
+
type: node.type ?? "",
|
|
676
|
+
...likec4Id ? { likec4Id } : {}
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
async function fetchAllFactSheets(client, opts) {
|
|
680
|
+
const likec4IdAttribute = opts.likec4IdAttribute;
|
|
681
|
+
const pageSize = Math.min(DEFAULT_PAGE_SIZE, opts.maxFactSheets);
|
|
682
|
+
const result = [];
|
|
683
|
+
let after = null;
|
|
684
|
+
let hasNextPage = true;
|
|
685
|
+
const query = `
|
|
686
|
+
query AllFactSheets($first: Int!, $after: String, $filter: FilterInput) {
|
|
687
|
+
allFactSheets(first: $first, after: $after, filter: $filter) {
|
|
688
|
+
edges {
|
|
689
|
+
node {
|
|
690
|
+
id
|
|
691
|
+
name
|
|
692
|
+
type
|
|
693
|
+
${likec4IdAttribute != null ? `factSheetAttributes { key value }` : ""}
|
|
694
|
+
}
|
|
695
|
+
cursor
|
|
696
|
+
}
|
|
697
|
+
pageInfo {
|
|
698
|
+
hasNextPage
|
|
699
|
+
endCursor
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
`;
|
|
704
|
+
const fetchPage = async (cursor) => withGraphQLRetry(() => client.graphql(query, {
|
|
705
|
+
first: pageSize,
|
|
706
|
+
after: cursor,
|
|
707
|
+
filter: {}
|
|
708
|
+
}));
|
|
709
|
+
while (hasNextPage && result.length < opts.maxFactSheets) {
|
|
710
|
+
const data = await fetchPage(after);
|
|
711
|
+
const edges = data.allFactSheets?.edges ?? [];
|
|
712
|
+
const pageInfo = data.allFactSheets?.pageInfo;
|
|
713
|
+
for (const edge of edges) {
|
|
714
|
+
if (result.length >= opts.maxFactSheets) break;
|
|
715
|
+
const item = mapNodeToFactSheetItem(edge.node, likec4IdAttribute);
|
|
716
|
+
if (item) result.push(item);
|
|
717
|
+
}
|
|
718
|
+
hasNextPage = pageInfo?.hasNextPage === true && result.length < opts.maxFactSheets;
|
|
719
|
+
after = pageInfo?.endCursor ?? null;
|
|
720
|
+
}
|
|
721
|
+
return result;
|
|
722
|
+
}
|
|
723
|
+
const RELATIONS_FETCH_CONCURRENCY = 10;
|
|
724
|
+
const RELATIONS_PAGE_SIZE = 100;
|
|
725
|
+
async function fetchAllRelations(client, factSheetIds) {
|
|
726
|
+
if (factSheetIds.length === 0) return [];
|
|
727
|
+
const relations = [];
|
|
728
|
+
const idSet = new Set(factSheetIds);
|
|
729
|
+
const query = `
|
|
730
|
+
query FactSheetRelations($id: ID!, $first: Int, $after: String) {
|
|
731
|
+
factSheet(id: $id) {
|
|
732
|
+
id
|
|
733
|
+
relations(first: $first, after: $after) {
|
|
734
|
+
edges {
|
|
735
|
+
node {
|
|
736
|
+
id
|
|
737
|
+
type
|
|
738
|
+
targetFactSheet { id }
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
pageInfo { hasNextPage endCursor }
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
`;
|
|
746
|
+
async function fetchRelationsForFactSheet(sourceId) {
|
|
747
|
+
const out = [];
|
|
748
|
+
let after = null;
|
|
749
|
+
let hasNextPage = true;
|
|
750
|
+
while (hasNextPage) {
|
|
751
|
+
const data = await withGraphQLRetry(() => client.graphql(query, {
|
|
752
|
+
id: sourceId,
|
|
753
|
+
first: RELATIONS_PAGE_SIZE,
|
|
754
|
+
after
|
|
755
|
+
}));
|
|
756
|
+
const edges = data?.factSheet?.relations?.edges ?? [];
|
|
757
|
+
const pageInfo = data?.factSheet?.relations?.pageInfo;
|
|
758
|
+
for (const edge of edges) {
|
|
759
|
+
const node = edge.node;
|
|
760
|
+
const targetId = node?.targetFactSheet?.id;
|
|
761
|
+
if (!targetId || !idSet.has(targetId)) continue;
|
|
762
|
+
out.push({
|
|
763
|
+
...node?.id ? { id: node.id } : {},
|
|
764
|
+
sourceFactSheetId: sourceId,
|
|
765
|
+
targetFactSheetId: targetId,
|
|
766
|
+
type: node?.type ?? "RELATES_TO"
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
hasNextPage = pageInfo?.hasNextPage === true;
|
|
770
|
+
after = pageInfo?.endCursor ?? null;
|
|
771
|
+
}
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
for (let i = 0; i < factSheetIds.length; i += RELATIONS_FETCH_CONCURRENCY) {
|
|
775
|
+
const batch = factSheetIds.slice(i, i + RELATIONS_FETCH_CONCURRENCY);
|
|
776
|
+
const results = await Promise.all(batch.map((sourceId) => fetchRelationsForFactSheet(sourceId)));
|
|
777
|
+
for (const items of results) relations.push(...items);
|
|
778
|
+
}
|
|
779
|
+
return relations;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Reconciliation of LeanIX inventory snapshot with LikeC4 manifest.
|
|
783
|
+
* Produces matched, unmatched (in LikeC4 only / in LeanIX only), and ambiguous pairs.
|
|
784
|
+
* No DSL generation; read-only comparison.
|
|
785
|
+
*/
|
|
786
|
+
/** Separator for name+type composite key (G25: avoid magic character). */
|
|
787
|
+
const NAME_TYPE_SEP = "\0";
|
|
788
|
+
/** Tries to match by manifest entity's external.leanix.factSheetId; returns match or null. */
|
|
789
|
+
function tryMatchByManifestFactSheetId(entity, canonicalId, snapshotById, usedFactSheetIds) {
|
|
790
|
+
const leanixExternal = entity.external?.[LEANIX_PROVIDER];
|
|
791
|
+
const manifestFactSheetId = leanixExternal?.factSheetId ?? leanixExternal?.externalId;
|
|
792
|
+
if (manifestFactSheetId == null) return null;
|
|
793
|
+
const fs = snapshotById.get(manifestFactSheetId);
|
|
794
|
+
if (!fs) return null;
|
|
795
|
+
if (usedFactSheetIds.has(fs.id)) return null;
|
|
796
|
+
usedFactSheetIds.add(fs.id);
|
|
797
|
+
return {
|
|
798
|
+
canonicalId,
|
|
799
|
+
factSheetId: fs.id,
|
|
800
|
+
name: fs.name,
|
|
801
|
+
type: fs.type
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
/** Tries to match by snapshot fact sheet's likec4Id === canonicalId; returns match or null. */
|
|
805
|
+
function tryMatchByLikec4Id(canonicalId, snapshotByLikec4Id, usedFactSheetIds) {
|
|
806
|
+
const byLikec4 = snapshotByLikec4Id.get(canonicalId);
|
|
807
|
+
if (!byLikec4 || usedFactSheetIds.has(byLikec4.id)) return null;
|
|
808
|
+
usedFactSheetIds.add(byLikec4.id);
|
|
809
|
+
return {
|
|
810
|
+
canonicalId,
|
|
811
|
+
factSheetId: byLikec4.id,
|
|
812
|
+
name: byLikec4.name,
|
|
813
|
+
type: byLikec4.type
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/** Resolves match by name+type (or pushes to unmatchedInLikec4 / ambiguous). */
|
|
817
|
+
function resolveByNameAndType(canonicalId, entityName, entityType, snapshotByNameAndType, usedFactSheetIds, matched, unmatchedInLikec4, ambiguous) {
|
|
818
|
+
const nameTypeKey = `${entityName ?? canonicalId}${NAME_TYPE_SEP}${entityType ?? ""}`;
|
|
819
|
+
const candidates = snapshotByNameAndType.get(nameTypeKey)?.filter((f) => !usedFactSheetIds.has(f.id)) ?? [];
|
|
820
|
+
if (candidates.length === 0) unmatchedInLikec4.push({
|
|
821
|
+
canonicalId,
|
|
822
|
+
...entityName !== void 0 ? { name: entityName } : {},
|
|
823
|
+
...entityType !== void 0 ? { type: entityType } : {}
|
|
824
|
+
});
|
|
825
|
+
else if (candidates.length === 1) {
|
|
826
|
+
const candidate = candidates[0];
|
|
827
|
+
matched.push({
|
|
828
|
+
canonicalId,
|
|
829
|
+
factSheetId: candidate.id,
|
|
830
|
+
name: candidate.name,
|
|
831
|
+
type: candidate.type
|
|
832
|
+
});
|
|
833
|
+
usedFactSheetIds.add(candidate.id);
|
|
834
|
+
} else ambiguous.push({
|
|
835
|
+
canonicalId,
|
|
836
|
+
...entityName !== void 0 ? { name: entityName } : {},
|
|
837
|
+
...entityType !== void 0 ? { type: entityType } : {},
|
|
838
|
+
candidateFactSheetIds: candidates.map((c) => c.id)
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Reconciles a LeanIX inventory snapshot with the bridge manifest.
|
|
843
|
+
* Matching order: 1) manifest entity has external.leanix.factSheetId; 2) snapshot fact sheet has likec4Id === canonicalId; 3) if dryRun provided, name+type (can be ambiguous).
|
|
844
|
+
* Does not modify any data; no DSL generation.
|
|
845
|
+
*/
|
|
846
|
+
function reconcileInventoryWithManifest(snapshot, manifest, options = {}) {
|
|
847
|
+
const generatedAt = options.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
848
|
+
const dryRun = options.dryRun;
|
|
849
|
+
const matched = [];
|
|
850
|
+
const unmatchedInLikec4 = [];
|
|
851
|
+
const ambiguous = [];
|
|
852
|
+
const snapshotById = new Map(snapshot.factSheets.map((f) => [f.id, f]));
|
|
853
|
+
const snapshotByLikec4Id = /* @__PURE__ */ new Map();
|
|
854
|
+
const snapshotByNameAndType = /* @__PURE__ */ new Map();
|
|
855
|
+
for (const fs of snapshot.factSheets) {
|
|
856
|
+
if (fs.likec4Id) snapshotByLikec4Id.set(fs.likec4Id, fs);
|
|
857
|
+
const key = `${fs.name}${NAME_TYPE_SEP}${fs.type}`;
|
|
858
|
+
if (!snapshotByNameAndType.has(key)) snapshotByNameAndType.set(key, []);
|
|
859
|
+
snapshotByNameAndType.get(key).push(fs);
|
|
860
|
+
}
|
|
861
|
+
const dryRunByCanonicalId = dryRun ? new Map(dryRun.factSheets.map((f) => [f.likec4Id, f])) : null;
|
|
862
|
+
const usedFactSheetIds = /* @__PURE__ */ new Set();
|
|
863
|
+
for (const [canonicalId, entity] of Object.entries(manifest.entities)) {
|
|
864
|
+
const byManifest = tryMatchByManifestFactSheetId(entity, canonicalId, snapshotById, usedFactSheetIds);
|
|
865
|
+
if (byManifest) {
|
|
866
|
+
matched.push(byManifest);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
const byLikec4 = tryMatchByLikec4Id(canonicalId, snapshotByLikec4Id, usedFactSheetIds);
|
|
870
|
+
if (byLikec4) {
|
|
871
|
+
matched.push(byLikec4);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
const dryRunFs = dryRunByCanonicalId?.get(canonicalId);
|
|
875
|
+
resolveByNameAndType(canonicalId, dryRunFs?.name ?? void 0, dryRunFs?.type ?? void 0, snapshotByNameAndType, usedFactSheetIds, matched, unmatchedInLikec4, ambiguous);
|
|
876
|
+
}
|
|
877
|
+
const unmatchedInLeanix = snapshot.factSheets.filter((f) => !usedFactSheetIds.has(f.id)).map((f) => ({
|
|
878
|
+
factSheetId: f.id,
|
|
879
|
+
name: f.name,
|
|
880
|
+
type: f.type,
|
|
881
|
+
...f.likec4Id ? { likec4Id: f.likec4Id } : {}
|
|
882
|
+
}));
|
|
883
|
+
return {
|
|
884
|
+
generatedAt,
|
|
885
|
+
manifestProjectId: manifest.projectId,
|
|
886
|
+
snapshotGeneratedAt: snapshot.generatedAt,
|
|
887
|
+
matched,
|
|
888
|
+
unmatchedInLikec4,
|
|
889
|
+
unmatchedInLeanix,
|
|
890
|
+
ambiguous,
|
|
891
|
+
summary: {
|
|
892
|
+
matched: matched.length,
|
|
893
|
+
unmatchedInLikec4: unmatchedInLikec4.length,
|
|
894
|
+
unmatchedInLeanix: unmatchedInLeanix.length,
|
|
895
|
+
ambiguous: ambiguous.length
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Builds an impact report from a sync plan (read-only).
|
|
901
|
+
* Use before applying sync to understand what would be created/updated.
|
|
902
|
+
*/
|
|
903
|
+
function impactReportFromSyncPlan(plan) {
|
|
904
|
+
const { summary, errors } = plan;
|
|
905
|
+
const parts = [];
|
|
906
|
+
if (summary.factSheetsToCreate > 0) parts.push(`${summary.factSheetsToCreate} fact sheet(s) to create`);
|
|
907
|
+
if (summary.factSheetsToUpdate > 0) parts.push(`${summary.factSheetsToUpdate} fact sheet(s) to update`);
|
|
908
|
+
if (summary.relationsToCreate > 0) parts.push(`${summary.relationsToCreate} relation(s) to create`);
|
|
909
|
+
const impactSummary = parts.length > 0 ? parts.join("; ") : "No changes";
|
|
910
|
+
return {
|
|
911
|
+
generatedAt: plan.generatedAt,
|
|
912
|
+
projectId: plan.projectId,
|
|
913
|
+
mappingProfile: plan.mappingProfile,
|
|
914
|
+
summary,
|
|
915
|
+
impactSummary: errors.length > 0 ? `${impactSummary} (${errors.length} plan error(s))` : impactSummary,
|
|
916
|
+
hasErrors: errors.length > 0
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Builds a drift report from a reconciliation result.
|
|
921
|
+
* In_sync: all matched, no unmatched, no ambiguous. Likec4_ahead: only unmatched in LikeC4. Leanix_ahead: only unmatched in LeanIX. Diverged: mixed or ambiguous.
|
|
922
|
+
*/
|
|
923
|
+
function buildDriftReport(reconciliation) {
|
|
924
|
+
const { summary } = reconciliation;
|
|
925
|
+
const hasUnmatchedLikec4 = summary.unmatchedInLikec4 > 0;
|
|
926
|
+
const hasUnmatchedLeanix = summary.unmatchedInLeanix > 0;
|
|
927
|
+
const hasAmbiguous = summary.ambiguous > 0;
|
|
928
|
+
let status;
|
|
929
|
+
let description;
|
|
930
|
+
switch (true) {
|
|
931
|
+
case hasAmbiguous || hasUnmatchedLikec4 && hasUnmatchedLeanix:
|
|
932
|
+
status = "diverged";
|
|
933
|
+
description = `${summary.ambiguous} ambiguous; ${summary.unmatchedInLikec4} only in LikeC4, ${summary.unmatchedInLeanix} only in LeanIX`;
|
|
934
|
+
break;
|
|
935
|
+
case hasUnmatchedLikec4 && !hasUnmatchedLeanix:
|
|
936
|
+
status = "likec4_ahead";
|
|
937
|
+
description = `${summary.unmatchedInLikec4} element(s) in LikeC4 not yet in LeanIX`;
|
|
938
|
+
break;
|
|
939
|
+
case hasUnmatchedLeanix && !hasUnmatchedLikec4:
|
|
940
|
+
status = "leanix_ahead";
|
|
941
|
+
description = `${summary.unmatchedInLeanix} fact sheet(s) in LeanIX not in LikeC4`;
|
|
942
|
+
break;
|
|
943
|
+
default:
|
|
944
|
+
status = "in_sync";
|
|
945
|
+
description = `${summary.matched} matched; no drift`;
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
generatedAt: reconciliation.generatedAt,
|
|
949
|
+
manifestProjectId: reconciliation.manifestProjectId,
|
|
950
|
+
snapshotGeneratedAt: reconciliation.snapshotGeneratedAt,
|
|
951
|
+
status,
|
|
952
|
+
summary: reconciliation.summary,
|
|
953
|
+
description
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
/** Returns date part YYYY-MM-DD from ISO timestamp (G25, G5). Handles short strings defensively. */
|
|
957
|
+
function formatIsoDateString(iso) {
|
|
958
|
+
if (typeof iso !== "string" || iso.length < 10) return iso;
|
|
959
|
+
return iso.slice(0, 10);
|
|
960
|
+
}
|
|
961
|
+
/** Shared ADR header lines (title, status, date). Reduces duplication between ADR generators. */
|
|
962
|
+
function buildAdrHeader(title, status, dateIso) {
|
|
963
|
+
return [
|
|
964
|
+
`# ${title}`,
|
|
965
|
+
"",
|
|
966
|
+
`- Status: ${status}`,
|
|
967
|
+
`- Date: ${formatIsoDateString(dateIso)}`,
|
|
968
|
+
""
|
|
969
|
+
];
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Generates a short ADR-style markdown document from a reconciliation result (and optional impact).
|
|
973
|
+
* Use for governance or audit trail; does not modify any data.
|
|
974
|
+
*/
|
|
975
|
+
function generateAdrFromReconciliation(reconciliation, options = {}) {
|
|
976
|
+
const title = options.title ?? "LeanIX inventory reconciliation";
|
|
977
|
+
const status = options.status ?? "Proposed";
|
|
978
|
+
const impact = options.impact;
|
|
979
|
+
const lines = [
|
|
980
|
+
...buildAdrHeader(title, status, reconciliation.generatedAt),
|
|
981
|
+
"## Context",
|
|
982
|
+
"",
|
|
983
|
+
`Reconciliation of LikeC4 manifest (project: ${reconciliation.manifestProjectId}) with LeanIX inventory snapshot (${formatIsoDateString(reconciliation.snapshotGeneratedAt)}).`,
|
|
984
|
+
"",
|
|
985
|
+
"## Result",
|
|
986
|
+
"",
|
|
987
|
+
`- Matched: ${reconciliation.summary.matched}`,
|
|
988
|
+
`- Unmatched in LikeC4: ${reconciliation.summary.unmatchedInLikec4}`,
|
|
989
|
+
`- Unmatched in LeanIX: ${reconciliation.summary.unmatchedInLeanix}`,
|
|
990
|
+
`- Ambiguous: ${reconciliation.summary.ambiguous}`,
|
|
991
|
+
""
|
|
992
|
+
];
|
|
993
|
+
if (impact) lines.push("## Impact (if sync applied)", "", impact.impactSummary, "", "");
|
|
994
|
+
lines.push("## Decision", "", "LikeC4 remains the single source of truth; LeanIX is an adapter. No DSL is auto-generated from LeanIX.", "");
|
|
995
|
+
return lines.join("\n");
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Generates a short ADR-style markdown from a drift report.
|
|
999
|
+
*/
|
|
1000
|
+
function generateAdrFromDriftReport(drift, options = {}) {
|
|
1001
|
+
return [
|
|
1002
|
+
...buildAdrHeader(options.title ?? "LeanIX drift report", options.status ?? "Proposed", drift.generatedAt),
|
|
1003
|
+
"## Drift status",
|
|
1004
|
+
"",
|
|
1005
|
+
drift.description,
|
|
1006
|
+
"",
|
|
1007
|
+
"## Summary",
|
|
1008
|
+
"",
|
|
1009
|
+
`- Matched: ${drift.summary.matched}`,
|
|
1010
|
+
`- Unmatched in LikeC4: ${drift.summary.unmatchedInLikec4}`,
|
|
1011
|
+
`- Unmatched in LeanIX: ${drift.summary.unmatchedInLeanix}`,
|
|
1012
|
+
`- Ambiguous: ${drift.summary.ambiguous}`,
|
|
1013
|
+
"",
|
|
1014
|
+
"## Decision",
|
|
1015
|
+
"",
|
|
1016
|
+
"Review drift findings and determine whether synchronization or manual remediation is required.",
|
|
1017
|
+
""
|
|
1018
|
+
].join("\n");
|
|
1019
|
+
}
|
|
1020
|
+
const DEFAULT_OPTIONS = {
|
|
1021
|
+
noAmbiguous: true,
|
|
1022
|
+
allLikec4Matched: false,
|
|
1023
|
+
noOrphanInLeanix: false
|
|
1024
|
+
};
|
|
1025
|
+
/** Builds a single check result (G5: reduce duplication). */
|
|
1026
|
+
function buildCheck(id, name, passed, failedMessage) {
|
|
1027
|
+
const result = {
|
|
1028
|
+
id,
|
|
1029
|
+
name,
|
|
1030
|
+
passed
|
|
1031
|
+
};
|
|
1032
|
+
if (!passed && failedMessage !== void 0) result.message = failedMessage;
|
|
1033
|
+
return result;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Runs governance checks on a reconciliation result.
|
|
1037
|
+
* Returns a report with one entry per check; overall passed iff all checks pass.
|
|
1038
|
+
*/
|
|
1039
|
+
function runGovernanceChecks(reconciliation, options = {}) {
|
|
1040
|
+
const opts = {
|
|
1041
|
+
...DEFAULT_OPTIONS,
|
|
1042
|
+
...options
|
|
1043
|
+
};
|
|
1044
|
+
const checks = [];
|
|
1045
|
+
const { summary } = reconciliation;
|
|
1046
|
+
if (opts.noAmbiguous) checks.push(buildCheck("noAmbiguous", "No ambiguous matches", summary.ambiguous === 0, `${summary.ambiguous} ambiguous match(es)`));
|
|
1047
|
+
if (opts.allLikec4Matched) checks.push(buildCheck("allLikec4Matched", "All LikeC4 elements matched in LeanIX", summary.unmatchedInLikec4 === 0, `${summary.unmatchedInLikec4} unmatched in LikeC4`));
|
|
1048
|
+
if (opts.noOrphanInLeanix) checks.push(buildCheck("noOrphanInLeanix", "No LeanIX fact sheets without LikeC4 match", summary.unmatchedInLeanix === 0, `${summary.unmatchedInLeanix} unmatched in LeanIX`));
|
|
1049
|
+
return {
|
|
1050
|
+
passed: checks.every((c) => c.passed),
|
|
1051
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1052
|
+
checks
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
function isRecord(value) {
|
|
1056
|
+
return typeof value === "object" && value !== null;
|
|
1057
|
+
}
|
|
1058
|
+
function isManifestEntity(value) {
|
|
1059
|
+
if (!isRecord(value)) return false;
|
|
1060
|
+
return typeof value["canonicalId"] === "string";
|
|
1061
|
+
}
|
|
1062
|
+
function isManifestView(value) {
|
|
1063
|
+
if (!isRecord(value)) return false;
|
|
1064
|
+
return typeof value["viewId"] === "string";
|
|
1065
|
+
}
|
|
1066
|
+
function isManifestRelation(value) {
|
|
1067
|
+
if (!isRecord(value)) return false;
|
|
1068
|
+
return typeof value["relationId"] === "string" && typeof value["sourceFqn"] === "string" && typeof value["targetFqn"] === "string" && typeof value["compositeKey"] === "string";
|
|
1069
|
+
}
|
|
1070
|
+
function isLeanixFactSheetSnapshotItem(value) {
|
|
1071
|
+
if (!isRecord(value)) return false;
|
|
1072
|
+
return typeof value["id"] === "string" && typeof value["name"] === "string" && typeof value["type"] === "string";
|
|
1073
|
+
}
|
|
1074
|
+
function isLeanixRelationSnapshotItem(value) {
|
|
1075
|
+
if (!isRecord(value)) return false;
|
|
1076
|
+
return typeof value["sourceFactSheetId"] === "string" && typeof value["targetFactSheetId"] === "string" && typeof value["type"] === "string";
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Returns true if the value is a valid BridgeManifest shape (manifestVersion, generatedAt, bridgeVersion, mappingProfile, projectId, entities, relations, views)
|
|
1080
|
+
* and nested entities/views/relations match expected shapes.
|
|
1081
|
+
*/
|
|
1082
|
+
function isBridgeManifest(obj) {
|
|
1083
|
+
if (!isRecord(obj)) return false;
|
|
1084
|
+
if (typeof obj["manifestVersion"] !== "string" || typeof obj["generatedAt"] !== "string" || typeof obj["bridgeVersion"] !== "string" || typeof obj["mappingProfile"] !== "string" || typeof obj["projectId"] !== "string") return false;
|
|
1085
|
+
const entities = obj["entities"];
|
|
1086
|
+
if (typeof entities !== "object" || entities === null || Array.isArray(entities)) return false;
|
|
1087
|
+
for (const v of Object.values(entities)) if (!isManifestEntity(v)) return false;
|
|
1088
|
+
const views = obj["views"];
|
|
1089
|
+
if (typeof views !== "object" || views === null || Array.isArray(views)) return false;
|
|
1090
|
+
for (const v of Object.values(views)) if (!isManifestView(v)) return false;
|
|
1091
|
+
const relations = obj["relations"];
|
|
1092
|
+
if (!Array.isArray(relations)) return false;
|
|
1093
|
+
for (const r of relations) if (!isManifestRelation(r)) return false;
|
|
1094
|
+
return true;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Returns true if the value is a valid LeanixInventorySnapshot shape (generatedAt, factSheets and relations arrays)
|
|
1098
|
+
* and each fact sheet/relation item has required fields.
|
|
1099
|
+
*/
|
|
1100
|
+
function isLeanixInventorySnapshot(obj) {
|
|
1101
|
+
if (!isRecord(obj)) return false;
|
|
1102
|
+
if (typeof obj["generatedAt"] !== "string") return false;
|
|
1103
|
+
if (obj["workspaceId"] !== void 0 && typeof obj["workspaceId"] !== "string") return false;
|
|
1104
|
+
if (!Array.isArray(obj["factSheets"])) return false;
|
|
1105
|
+
for (const fs of obj["factSheets"]) if (!isLeanixFactSheetSnapshotItem(fs)) return false;
|
|
1106
|
+
if (!Array.isArray(obj["relations"])) return false;
|
|
1107
|
+
for (const rel of obj["relations"]) if (!isLeanixRelationSnapshotItem(rel)) return false;
|
|
1108
|
+
return true;
|
|
1109
|
+
}
|
|
1110
|
+
export { BRIDGE_MANIFEST_VERSION, BRIDGE_VERSION, DEFAULT_LEANIX_MAPPING, FALLBACK_FACT_SHEET_TYPE, FALLBACK_RELATION_TYPE, LEANIX_PROVIDER, LeanixApiClient, LeanixApiError, buildBridgeReport, buildDriftReport, fetchLeanixInventorySnapshot, generateAdrFromDriftReport, generateAdrFromReconciliation, getFactSheetType, getRelationType, impactReportFromSyncPlan, isBridgeManifest, isLeanixInventorySnapshot, manifestToDrawioLeanixMapping, mergeWithDefault, planSyncToLeanix, reconcileInventoryWithManifest, runGovernanceChecks, syncToLeanix, toBridgeManifest, toLeanixInventoryDryRun };
|