@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/src/reconcile.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciliation of LeanIX inventory snapshot with LikeC4 manifest.
|
|
3
|
+
* Produces matched, unmatched (in LikeC4 only / in LeanIX only), and ambiguous pairs.
|
|
4
|
+
* No DSL generation; read-only comparison.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type BridgeManifest, type CanonicalId, type ManifestEntity, LEANIX_PROVIDER } from './contracts'
|
|
8
|
+
import type {
|
|
9
|
+
LeanixFactSheetSnapshotItem,
|
|
10
|
+
LeanixInventorySnapshot,
|
|
11
|
+
} from './leanix-inventory-snapshot'
|
|
12
|
+
import type { LeanixInventoryDryRun } from './to-leanix-inventory-dry-run'
|
|
13
|
+
|
|
14
|
+
/** A manifest entity matched to a single LeanIX fact sheet. */
|
|
15
|
+
export interface MatchedPair {
|
|
16
|
+
canonicalId: CanonicalId
|
|
17
|
+
factSheetId: string
|
|
18
|
+
name: string
|
|
19
|
+
type: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Manifest entity with no matching LeanIX fact sheet. */
|
|
23
|
+
export interface UnmatchedInLikec4 {
|
|
24
|
+
canonicalId: CanonicalId
|
|
25
|
+
name?: string | undefined
|
|
26
|
+
type?: string | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** LeanIX fact sheet with no matching manifest entity. */
|
|
30
|
+
export interface UnmatchedInLeanix {
|
|
31
|
+
factSheetId: string
|
|
32
|
+
name: string
|
|
33
|
+
type: string
|
|
34
|
+
likec4Id?: string | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Manifest entity that could match multiple LeanIX fact sheets (e.g. same name+type). */
|
|
38
|
+
export interface AmbiguousMatch {
|
|
39
|
+
canonicalId: CanonicalId
|
|
40
|
+
name?: string | undefined
|
|
41
|
+
type?: string | undefined
|
|
42
|
+
/** Multiple LeanIX fact sheets could match (e.g. same name+type). */
|
|
43
|
+
candidateFactSheetIds: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Result of reconciling a LeanIX snapshot with the bridge manifest (matched, unmatched, ambiguous). */
|
|
47
|
+
export interface ReconciliationResult {
|
|
48
|
+
generatedAt: string
|
|
49
|
+
manifestProjectId: string
|
|
50
|
+
snapshotGeneratedAt: string
|
|
51
|
+
matched: MatchedPair[]
|
|
52
|
+
unmatchedInLikec4: UnmatchedInLikec4[]
|
|
53
|
+
unmatchedInLeanix: UnmatchedInLeanix[]
|
|
54
|
+
ambiguous: AmbiguousMatch[]
|
|
55
|
+
summary: {
|
|
56
|
+
matched: number
|
|
57
|
+
unmatchedInLikec4: number
|
|
58
|
+
unmatchedInLeanix: number
|
|
59
|
+
ambiguous: number
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Separator for name+type composite key (G25: avoid magic character). */
|
|
64
|
+
const NAME_TYPE_SEP = '\0'
|
|
65
|
+
|
|
66
|
+
/** Tries to match by manifest entity's external.leanix.factSheetId; returns match or null. */
|
|
67
|
+
function tryMatchByManifestFactSheetId(
|
|
68
|
+
entity: ManifestEntity,
|
|
69
|
+
canonicalId: CanonicalId,
|
|
70
|
+
snapshotById: Map<string, LeanixFactSheetSnapshotItem>,
|
|
71
|
+
usedFactSheetIds: Set<string>,
|
|
72
|
+
): MatchedPair | null {
|
|
73
|
+
const leanixExternal = entity.external?.[LEANIX_PROVIDER]
|
|
74
|
+
const manifestFactSheetId = leanixExternal?.factSheetId ?? leanixExternal?.externalId
|
|
75
|
+
if (manifestFactSheetId == null) return null
|
|
76
|
+
const fs = snapshotById.get(manifestFactSheetId)
|
|
77
|
+
if (!fs) return null
|
|
78
|
+
if (usedFactSheetIds.has(fs.id)) return null
|
|
79
|
+
usedFactSheetIds.add(fs.id)
|
|
80
|
+
return { canonicalId, factSheetId: fs.id, name: fs.name, type: fs.type }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Tries to match by snapshot fact sheet's likec4Id === canonicalId; returns match or null. */
|
|
84
|
+
function tryMatchByLikec4Id(
|
|
85
|
+
canonicalId: CanonicalId,
|
|
86
|
+
snapshotByLikec4Id: Map<string, LeanixFactSheetSnapshotItem>,
|
|
87
|
+
usedFactSheetIds: Set<string>,
|
|
88
|
+
): MatchedPair | null {
|
|
89
|
+
const byLikec4 = snapshotByLikec4Id.get(canonicalId)
|
|
90
|
+
if (!byLikec4 || usedFactSheetIds.has(byLikec4.id)) return null
|
|
91
|
+
usedFactSheetIds.add(byLikec4.id)
|
|
92
|
+
return { canonicalId, factSheetId: byLikec4.id, name: byLikec4.name, type: byLikec4.type }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolves match by name+type (or pushes to unmatchedInLikec4 / ambiguous). */
|
|
96
|
+
function resolveByNameAndType(
|
|
97
|
+
canonicalId: CanonicalId,
|
|
98
|
+
entityName: string | undefined,
|
|
99
|
+
entityType: string | undefined,
|
|
100
|
+
snapshotByNameAndType: Map<string, LeanixFactSheetSnapshotItem[]>,
|
|
101
|
+
usedFactSheetIds: Set<string>,
|
|
102
|
+
matched: MatchedPair[],
|
|
103
|
+
unmatchedInLikec4: UnmatchedInLikec4[],
|
|
104
|
+
ambiguous: AmbiguousMatch[],
|
|
105
|
+
): void {
|
|
106
|
+
const nameTypeKey = `${entityName ?? canonicalId}${NAME_TYPE_SEP}${entityType ?? ''}`
|
|
107
|
+
const candidates = snapshotByNameAndType.get(nameTypeKey)?.filter(f => !usedFactSheetIds.has(f.id)) ?? []
|
|
108
|
+
if (candidates.length === 0) {
|
|
109
|
+
unmatchedInLikec4.push({
|
|
110
|
+
canonicalId,
|
|
111
|
+
...(entityName !== undefined ? { name: entityName } : {}),
|
|
112
|
+
...(entityType !== undefined ? { type: entityType } : {}),
|
|
113
|
+
})
|
|
114
|
+
} else if (candidates.length === 1) {
|
|
115
|
+
const candidate = candidates[0]!
|
|
116
|
+
matched.push({
|
|
117
|
+
canonicalId,
|
|
118
|
+
factSheetId: candidate.id,
|
|
119
|
+
name: candidate.name,
|
|
120
|
+
type: candidate.type,
|
|
121
|
+
})
|
|
122
|
+
usedFactSheetIds.add(candidate.id)
|
|
123
|
+
} else {
|
|
124
|
+
ambiguous.push({
|
|
125
|
+
canonicalId,
|
|
126
|
+
...(entityName !== undefined ? { name: entityName } : {}),
|
|
127
|
+
...(entityType !== undefined ? { type: entityType } : {}),
|
|
128
|
+
candidateFactSheetIds: candidates.map(c => c.id),
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Options for reconcileInventoryWithManifest (generatedAt, optional dry-run for name/type resolution). */
|
|
134
|
+
export interface ReconcileOptions {
|
|
135
|
+
generatedAt?: string
|
|
136
|
+
/** When provided, name+type matching and ambiguous detection use dry-run fact sheet names/types. */
|
|
137
|
+
dryRun?: LeanixInventoryDryRun
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Reconciles a LeanIX inventory snapshot with the bridge manifest.
|
|
142
|
+
* 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).
|
|
143
|
+
* Does not modify any data; no DSL generation.
|
|
144
|
+
*/
|
|
145
|
+
export function reconcileInventoryWithManifest(
|
|
146
|
+
snapshot: LeanixInventorySnapshot,
|
|
147
|
+
manifest: BridgeManifest,
|
|
148
|
+
options: ReconcileOptions = {},
|
|
149
|
+
): ReconciliationResult {
|
|
150
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString()
|
|
151
|
+
const dryRun = options.dryRun
|
|
152
|
+
const matched: MatchedPair[] = []
|
|
153
|
+
const unmatchedInLikec4: UnmatchedInLikec4[] = []
|
|
154
|
+
const ambiguous: AmbiguousMatch[] = []
|
|
155
|
+
|
|
156
|
+
const snapshotById = new Map(snapshot.factSheets.map(f => [f.id, f]))
|
|
157
|
+
const snapshotByLikec4Id = new Map<string, LeanixFactSheetSnapshotItem>()
|
|
158
|
+
const snapshotByNameAndType = new Map<string, LeanixFactSheetSnapshotItem[]>()
|
|
159
|
+
for (const fs of snapshot.factSheets) {
|
|
160
|
+
if (fs.likec4Id) snapshotByLikec4Id.set(fs.likec4Id, fs)
|
|
161
|
+
const key = `${fs.name}${NAME_TYPE_SEP}${fs.type}`
|
|
162
|
+
if (!snapshotByNameAndType.has(key)) snapshotByNameAndType.set(key, [])
|
|
163
|
+
snapshotByNameAndType.get(key)!.push(fs)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const dryRunByCanonicalId = dryRun
|
|
167
|
+
? new Map(dryRun.factSheets.map(f => [f.likec4Id, f]))
|
|
168
|
+
: null
|
|
169
|
+
|
|
170
|
+
const usedFactSheetIds = new Set<string>()
|
|
171
|
+
|
|
172
|
+
for (const [canonicalId, entity] of Object.entries(manifest.entities)) {
|
|
173
|
+
const byManifest = tryMatchByManifestFactSheetId(
|
|
174
|
+
entity,
|
|
175
|
+
canonicalId,
|
|
176
|
+
snapshotById,
|
|
177
|
+
usedFactSheetIds,
|
|
178
|
+
)
|
|
179
|
+
if (byManifest) {
|
|
180
|
+
matched.push(byManifest)
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
const byLikec4 = tryMatchByLikec4Id(canonicalId, snapshotByLikec4Id, usedFactSheetIds)
|
|
184
|
+
if (byLikec4) {
|
|
185
|
+
matched.push(byLikec4)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
const dryRunFs = dryRunByCanonicalId?.get(canonicalId)
|
|
189
|
+
const entityName = dryRunFs?.name ?? undefined
|
|
190
|
+
const entityType = dryRunFs?.type ?? undefined
|
|
191
|
+
resolveByNameAndType(
|
|
192
|
+
canonicalId,
|
|
193
|
+
entityName,
|
|
194
|
+
entityType,
|
|
195
|
+
snapshotByNameAndType,
|
|
196
|
+
usedFactSheetIds,
|
|
197
|
+
matched,
|
|
198
|
+
unmatchedInLikec4,
|
|
199
|
+
ambiguous,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const unmatchedInLeanix: UnmatchedInLeanix[] = snapshot.factSheets
|
|
204
|
+
.filter(f => !usedFactSheetIds.has(f.id))
|
|
205
|
+
.map(f => ({
|
|
206
|
+
factSheetId: f.id,
|
|
207
|
+
name: f.name,
|
|
208
|
+
type: f.type,
|
|
209
|
+
...(f.likec4Id ? { likec4Id: f.likec4Id } : {}),
|
|
210
|
+
}))
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
generatedAt,
|
|
214
|
+
manifestProjectId: manifest.projectId,
|
|
215
|
+
snapshotGeneratedAt: snapshot.generatedAt,
|
|
216
|
+
matched,
|
|
217
|
+
unmatchedInLikec4,
|
|
218
|
+
unmatchedInLeanix,
|
|
219
|
+
ambiguous,
|
|
220
|
+
summary: {
|
|
221
|
+
matched: matched.length,
|
|
222
|
+
unmatchedInLikec4: unmatchedInLikec4.length,
|
|
223
|
+
unmatchedInLeanix: unmatchedInLeanix.length,
|
|
224
|
+
ambiguous: ambiguous.length,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { BridgeManifest } from './contracts'
|
|
2
|
+
import type { LeanixInventoryDryRun } from './to-leanix-inventory-dry-run'
|
|
3
|
+
|
|
4
|
+
/** Summary report for the bridge run (no live sync) */
|
|
5
|
+
export interface BridgeReport {
|
|
6
|
+
generatedAt: string
|
|
7
|
+
projectId: string
|
|
8
|
+
manifestVersion: string
|
|
9
|
+
bridgeVersion: string
|
|
10
|
+
mappingProfile: string
|
|
11
|
+
counts: {
|
|
12
|
+
entities: number
|
|
13
|
+
views: number
|
|
14
|
+
relations: number
|
|
15
|
+
factSheets: number
|
|
16
|
+
leanixRelations: number
|
|
17
|
+
}
|
|
18
|
+
artifacts: {
|
|
19
|
+
manifest: string
|
|
20
|
+
leanixDryRun: string
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Builds error message listing which coherence fields mismatched (projectId, mappingProfile). */
|
|
25
|
+
function buildCoherenceErrorMessage(
|
|
26
|
+
manifest: BridgeManifest,
|
|
27
|
+
leanixDryRun: LeanixInventoryDryRun,
|
|
28
|
+
): string {
|
|
29
|
+
const mismatches: string[] = []
|
|
30
|
+
if (manifest.projectId !== leanixDryRun.projectId) {
|
|
31
|
+
mismatches.push(`projectId (manifest: ${manifest.projectId}, leanixDryRun: ${leanixDryRun.projectId})`)
|
|
32
|
+
}
|
|
33
|
+
if (manifest.mappingProfile !== leanixDryRun.mappingProfile) {
|
|
34
|
+
mismatches.push(
|
|
35
|
+
`mappingProfile (manifest: ${manifest.mappingProfile}, leanixDryRun: ${leanixDryRun.mappingProfile})`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return `Manifest and LeanIX dry-run must belong to the same run. Mismatch: ${mismatches.join('; ')}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Builds a summary report (counts, artifact names) from manifest and dry-run. Throws if projectId or mappingProfile differ.
|
|
43
|
+
*/
|
|
44
|
+
export function buildBridgeReport(
|
|
45
|
+
manifest: BridgeManifest,
|
|
46
|
+
leanixDryRun: LeanixInventoryDryRun,
|
|
47
|
+
): BridgeReport {
|
|
48
|
+
if (
|
|
49
|
+
manifest.projectId !== leanixDryRun.projectId ||
|
|
50
|
+
manifest.mappingProfile !== leanixDryRun.mappingProfile
|
|
51
|
+
) {
|
|
52
|
+
throw new Error(buildCoherenceErrorMessage(manifest, leanixDryRun))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
generatedAt: manifest.generatedAt,
|
|
57
|
+
projectId: manifest.projectId,
|
|
58
|
+
manifestVersion: manifest.manifestVersion,
|
|
59
|
+
bridgeVersion: manifest.bridgeVersion,
|
|
60
|
+
mappingProfile: manifest.mappingProfile,
|
|
61
|
+
counts: {
|
|
62
|
+
entities: Object.keys(manifest.entities).length,
|
|
63
|
+
views: Object.keys(manifest.views).length,
|
|
64
|
+
relations: manifest.relations.length,
|
|
65
|
+
factSheets: leanixDryRun.factSheets.length,
|
|
66
|
+
leanixRelations: leanixDryRun.relations.length,
|
|
67
|
+
},
|
|
68
|
+
artifacts: {
|
|
69
|
+
manifest: 'manifest.json',
|
|
70
|
+
leanixDryRun: 'leanix-dry-run.json',
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync bridge manifest + LeanIX dry-run inventory to the LeanIX API.
|
|
3
|
+
* Creates or updates fact sheets and relations; returns manifest with external LeanIX IDs.
|
|
4
|
+
* Supports a read-only "plan" step that queries LeanIX to produce a sync plan artifact (Phase 2).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type BridgeManifest, type CanonicalId, LEANIX_PROVIDER } from './contracts'
|
|
8
|
+
import { LeanixApiClient } from './leanix-api-client'
|
|
9
|
+
import {
|
|
10
|
+
createFactSheet,
|
|
11
|
+
createRelation,
|
|
12
|
+
findFactSheetByLikec4IdAttribute,
|
|
13
|
+
findFactSheetByNameAndType,
|
|
14
|
+
patchFactSheetAttribute,
|
|
15
|
+
} from './leanix-graphql-operations'
|
|
16
|
+
import type { LeanixFactSheetDryRun, LeanixInventoryDryRun } from './to-leanix-inventory-dry-run'
|
|
17
|
+
|
|
18
|
+
/** Options for live sync (idempotent lookup, optional likec4Id custom attribute). */
|
|
19
|
+
export interface SyncToLeanixOptions {
|
|
20
|
+
/** If true, only create fact sheets that do not exist (by name+type). Default: true. */
|
|
21
|
+
idempotent?: boolean
|
|
22
|
+
/** Custom attribute key in LeanIX to store likec4Id for lookup. If not set, lookup by name+type. */
|
|
23
|
+
likec4IdAttribute?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Result of syncToLeanix (updated manifest, counts, errors). */
|
|
27
|
+
export interface SyncToLeanixResult {
|
|
28
|
+
/** Updated manifest with external.leanix.factSheetId and relation IDs filled. */
|
|
29
|
+
manifest: BridgeManifest
|
|
30
|
+
/** Count of fact sheets created. */
|
|
31
|
+
factSheetsCreated: number
|
|
32
|
+
/** Count of fact sheets reused (when idempotent and found; no mutation). */
|
|
33
|
+
factSheetsReused: number
|
|
34
|
+
/** Count of relations created. */
|
|
35
|
+
relationsCreated: number
|
|
36
|
+
/** Errors encountered (e.g. rate limit, validation). Partial sync may still have updated manifest. */
|
|
37
|
+
errors: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Single fact sheet entry in a sync plan: what would happen when syncing. */
|
|
41
|
+
export interface SyncPlanFactSheetEntry {
|
|
42
|
+
likec4Id: string
|
|
43
|
+
name: string
|
|
44
|
+
type: string
|
|
45
|
+
/** 'create' = not found in LeanIX; 'update' = found by name+type (idempotent). */
|
|
46
|
+
action: 'create' | 'update'
|
|
47
|
+
/** Set when action is 'update': existing LeanIX fact sheet id. */
|
|
48
|
+
existingFactSheetId?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Single relation entry in a sync plan (relations are always created when source/target exist). */
|
|
52
|
+
export interface SyncPlanRelationEntry {
|
|
53
|
+
likec4RelationId: string
|
|
54
|
+
sourceLikec4Id: string
|
|
55
|
+
targetLikec4Id: string
|
|
56
|
+
type: string
|
|
57
|
+
action: 'create'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Summary counts for a sync plan. */
|
|
61
|
+
export interface SyncPlanSummary {
|
|
62
|
+
factSheetsToCreate: number
|
|
63
|
+
factSheetsToUpdate: number
|
|
64
|
+
relationsToCreate: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Sync plan artifact: what would be created/updated in LeanIX (no writes; uses API only to read existing). */
|
|
68
|
+
export interface SyncPlan {
|
|
69
|
+
generatedAt: string
|
|
70
|
+
projectId: string
|
|
71
|
+
mappingProfile: string
|
|
72
|
+
summary: SyncPlanSummary
|
|
73
|
+
factSheetPlans: SyncPlanFactSheetEntry[]
|
|
74
|
+
relationPlans: SyncPlanRelationEntry[]
|
|
75
|
+
/** Errors from read-only queries (e.g. auth, rate limit). Plan may be partial. */
|
|
76
|
+
errors: string[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildFactSheetPlanEntry(
|
|
80
|
+
fs: LeanixFactSheetDryRun,
|
|
81
|
+
existingFactSheetId: string | null,
|
|
82
|
+
): SyncPlanFactSheetEntry {
|
|
83
|
+
const action = existingFactSheetId ? 'update' : 'create'
|
|
84
|
+
return {
|
|
85
|
+
likec4Id: fs.likec4Id,
|
|
86
|
+
name: fs.name,
|
|
87
|
+
type: fs.type,
|
|
88
|
+
action,
|
|
89
|
+
...(existingFactSheetId ? { existingFactSheetId } : {}),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildPlanSummary(
|
|
94
|
+
factSheetPlans: SyncPlanFactSheetEntry[],
|
|
95
|
+
relationPlans: SyncPlanRelationEntry[],
|
|
96
|
+
): SyncPlanSummary {
|
|
97
|
+
return {
|
|
98
|
+
factSheetsToCreate: factSheetPlans.filter(p => p.action === 'create').length,
|
|
99
|
+
factSheetsToUpdate: factSheetPlans.filter(p => p.action === 'update').length,
|
|
100
|
+
relationsToCreate: relationPlans.length,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Options for planSyncToLeanix (idempotent, likec4Id attribute, generatedAt). */
|
|
105
|
+
export interface PlanSyncToLeanixOptions {
|
|
106
|
+
/** If true, plan assumes idempotent sync (look up by likec4IdAttribute or name+type to decide create vs update). Default: true. */
|
|
107
|
+
idempotent?: boolean
|
|
108
|
+
/** Custom attribute key for likec4Id lookup; when set, plan uses it for idempotent resolution. */
|
|
109
|
+
likec4IdAttribute?: string
|
|
110
|
+
/** ISO timestamp for the plan. Default: new Date().toISOString() */
|
|
111
|
+
generatedAt?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Normalise caught value to a string for error reporting (Clean Code: context in errors). */
|
|
115
|
+
function toErrorMessage(err: unknown): string {
|
|
116
|
+
if (err instanceof Error) {
|
|
117
|
+
return err.stack ? `${err.message}\n${err.stack}` : err.message
|
|
118
|
+
}
|
|
119
|
+
if (typeof err === 'object' && err !== null) {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.stringify(err, null, 2)
|
|
122
|
+
} catch {
|
|
123
|
+
return Object.prototype.toString.call(err)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return String(err)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Applies LeanIX fact sheet IDs to manifest entities; returns new entities object. */
|
|
130
|
+
function applyLeanixIdsToEntities(
|
|
131
|
+
entities: BridgeManifest['entities'],
|
|
132
|
+
likec4IdToFactSheetId: Map<CanonicalId, string>,
|
|
133
|
+
): BridgeManifest['entities'] {
|
|
134
|
+
const out = { ...entities }
|
|
135
|
+
for (const [canonicalId, entity] of Object.entries(entities)) {
|
|
136
|
+
const leanixId = likec4IdToFactSheetId.get(canonicalId)
|
|
137
|
+
if (leanixId) {
|
|
138
|
+
out[canonicalId] = {
|
|
139
|
+
...entity,
|
|
140
|
+
external: { ...entity.external, [LEANIX_PROVIDER]: { factSheetId: leanixId, externalId: leanixId } },
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolves existing LeanIX fact sheet id by likec4Id attribute or name+type; optionally patches likec4Id. Returns null when not found. */
|
|
148
|
+
async function resolveExistingFactSheetId(
|
|
149
|
+
client: LeanixApiClient,
|
|
150
|
+
fs: LeanixFactSheetDryRun,
|
|
151
|
+
idempotent: boolean,
|
|
152
|
+
likec4IdAttribute: string | undefined,
|
|
153
|
+
): Promise<string | null> {
|
|
154
|
+
if (!idempotent) return null
|
|
155
|
+
if (likec4IdAttribute) {
|
|
156
|
+
const byAttr = await findFactSheetByLikec4IdAttribute(client, likec4IdAttribute, fs.likec4Id)
|
|
157
|
+
if (byAttr) return byAttr
|
|
158
|
+
const byNameType = await findFactSheetByNameAndType(client, fs.name, fs.type)
|
|
159
|
+
if (byNameType && fs.likec4Id) {
|
|
160
|
+
try {
|
|
161
|
+
await patchFactSheetAttribute(client, byNameType, likec4IdAttribute, fs.likec4Id)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.warn(
|
|
164
|
+
`[leanix-bridge] Failed to backfill likec4Id on fact sheet ${byNameType} (${fs.name}/${fs.type}): ${
|
|
165
|
+
err instanceof Error ? err.message : String(err)
|
|
166
|
+
}. Reusing ID.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return byNameType ?? null
|
|
171
|
+
}
|
|
172
|
+
return findFactSheetByNameAndType(client, fs.name, fs.type)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Result of syncing fact sheets: map of likec4Id → factSheetId and counts/errors. */
|
|
176
|
+
interface SyncFactSheetsResult {
|
|
177
|
+
likec4IdToFactSheetId: Map<CanonicalId, string>
|
|
178
|
+
factSheetsCreated: number
|
|
179
|
+
factSheetsReused: number
|
|
180
|
+
errors: string[]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Creates or finds fact sheets in LeanIX; returns map and counts. Single responsibility. */
|
|
184
|
+
async function syncFactSheetsToLeanix(
|
|
185
|
+
client: LeanixApiClient,
|
|
186
|
+
factSheets: LeanixInventoryDryRun['factSheets'],
|
|
187
|
+
idempotent: boolean,
|
|
188
|
+
likec4IdAttribute: string | undefined,
|
|
189
|
+
): Promise<SyncFactSheetsResult> {
|
|
190
|
+
const likec4IdToFactSheetId = new Map<CanonicalId, string>()
|
|
191
|
+
const errors: string[] = []
|
|
192
|
+
let factSheetsCreated = 0
|
|
193
|
+
let factSheetsReused = 0
|
|
194
|
+
|
|
195
|
+
for (const fs of factSheets) {
|
|
196
|
+
try {
|
|
197
|
+
let factSheetId = await resolveExistingFactSheetId(client, fs, idempotent, likec4IdAttribute)
|
|
198
|
+
if (factSheetId) factSheetsReused++
|
|
199
|
+
if (!factSheetId) {
|
|
200
|
+
factSheetId = await createFactSheet(client, fs, likec4IdAttribute)
|
|
201
|
+
factSheetsCreated++
|
|
202
|
+
}
|
|
203
|
+
if (factSheetId) likec4IdToFactSheetId.set(fs.likec4Id, factSheetId)
|
|
204
|
+
} catch (e) {
|
|
205
|
+
errors.push(`Fact sheet ${fs.likec4Id} (${String(fs.name)}): ${toErrorMessage(e)}`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { likec4IdToFactSheetId, factSheetsCreated, factSheetsReused, errors }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Result of syncing relations: updated relations array and count/errors. */
|
|
213
|
+
interface SyncRelationsResult {
|
|
214
|
+
updatedRelations: BridgeManifest['relations']
|
|
215
|
+
relationsCreated: number
|
|
216
|
+
errors: string[]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Creates relations in LeanIX and returns manifest relations with external IDs. Single responsibility. */
|
|
220
|
+
async function syncRelationsToLeanix(
|
|
221
|
+
client: LeanixApiClient,
|
|
222
|
+
manifestRelations: BridgeManifest['relations'],
|
|
223
|
+
leanixRelations: LeanixInventoryDryRun['relations'],
|
|
224
|
+
likec4IdToFactSheetId: Map<CanonicalId, string>,
|
|
225
|
+
): Promise<SyncRelationsResult> {
|
|
226
|
+
const updatedRelations: BridgeManifest['relations'] = []
|
|
227
|
+
const errors: string[] = []
|
|
228
|
+
let relationsCreated = 0
|
|
229
|
+
|
|
230
|
+
for (const rel of manifestRelations) {
|
|
231
|
+
const sourceId = likec4IdToFactSheetId.get(rel.sourceFqn)
|
|
232
|
+
const targetId = likec4IdToFactSheetId.get(rel.targetFqn)
|
|
233
|
+
const existing = rel.external?.[LEANIX_PROVIDER]
|
|
234
|
+
if (sourceId && targetId && !existing?.relationId) {
|
|
235
|
+
const dryRel = leanixRelations.find(
|
|
236
|
+
r =>
|
|
237
|
+
r.sourceLikec4Id === rel.sourceFqn &&
|
|
238
|
+
r.targetLikec4Id === rel.targetFqn &&
|
|
239
|
+
r.likec4RelationId === rel.relationId,
|
|
240
|
+
)
|
|
241
|
+
if (dryRel) {
|
|
242
|
+
try {
|
|
243
|
+
const relationId = await createRelation(client, sourceId, targetId, dryRel.type, dryRel.title)
|
|
244
|
+
relationsCreated++
|
|
245
|
+
updatedRelations.push({
|
|
246
|
+
...rel,
|
|
247
|
+
external: { ...rel.external, [LEANIX_PROVIDER]: { relationId, ...rel.external?.[LEANIX_PROVIDER] } },
|
|
248
|
+
})
|
|
249
|
+
continue
|
|
250
|
+
} catch (e) {
|
|
251
|
+
errors.push(`Relation ${rel.compositeKey}: ${toErrorMessage(e)}`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
updatedRelations.push(rel)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { updatedRelations, relationsCreated, errors }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Produces a sync plan by querying LeanIX for existing fact sheets (read-only; no creates/updates).
|
|
263
|
+
* Use before syncToLeanix to review what would be created vs updated. Phase 2 dry-run sync planning.
|
|
264
|
+
*/
|
|
265
|
+
export async function planSyncToLeanix(
|
|
266
|
+
leanixDryRun: LeanixInventoryDryRun,
|
|
267
|
+
client: LeanixApiClient,
|
|
268
|
+
options: PlanSyncToLeanixOptions = {},
|
|
269
|
+
): Promise<SyncPlan> {
|
|
270
|
+
const idempotent = options.idempotent ?? true
|
|
271
|
+
const likec4IdAttribute = options.likec4IdAttribute
|
|
272
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString()
|
|
273
|
+
const errors: string[] = []
|
|
274
|
+
const factSheetPlans: SyncPlan['factSheetPlans'] = []
|
|
275
|
+
|
|
276
|
+
for (const fs of leanixDryRun.factSheets) {
|
|
277
|
+
try {
|
|
278
|
+
let existingId: string | null = null
|
|
279
|
+
if (idempotent) {
|
|
280
|
+
if (likec4IdAttribute) {
|
|
281
|
+
existingId = await findFactSheetByLikec4IdAttribute(client, likec4IdAttribute, fs.likec4Id)
|
|
282
|
+
if (!existingId) existingId = await findFactSheetByNameAndType(client, fs.name, fs.type)
|
|
283
|
+
} else {
|
|
284
|
+
existingId = await findFactSheetByNameAndType(client, fs.name, fs.type)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
factSheetPlans.push(buildFactSheetPlanEntry(fs, existingId))
|
|
288
|
+
} catch (e) {
|
|
289
|
+
errors.push(`Fact sheet ${fs.likec4Id} (${String(fs.name)}): ${toErrorMessage(e)}`)
|
|
290
|
+
factSheetPlans.push(buildFactSheetPlanEntry(fs, null))
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const relationPlans: SyncPlan['relationPlans'] = leanixDryRun.relations.map(rel => ({
|
|
295
|
+
likec4RelationId: rel.likec4RelationId,
|
|
296
|
+
sourceLikec4Id: rel.sourceLikec4Id,
|
|
297
|
+
targetLikec4Id: rel.targetLikec4Id,
|
|
298
|
+
type: rel.type,
|
|
299
|
+
action: 'create' as const,
|
|
300
|
+
}))
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
generatedAt,
|
|
304
|
+
projectId: leanixDryRun.projectId,
|
|
305
|
+
mappingProfile: leanixDryRun.mappingProfile,
|
|
306
|
+
summary: buildPlanSummary(factSheetPlans, relationPlans),
|
|
307
|
+
factSheetPlans,
|
|
308
|
+
relationPlans,
|
|
309
|
+
errors,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Syncs the dry-run inventory to LeanIX: creates or finds fact sheets, creates relations,
|
|
315
|
+
* and returns an updated manifest with external LeanIX IDs.
|
|
316
|
+
*/
|
|
317
|
+
export async function syncToLeanix(
|
|
318
|
+
manifest: BridgeManifest,
|
|
319
|
+
leanixDryRun: LeanixInventoryDryRun,
|
|
320
|
+
client: LeanixApiClient,
|
|
321
|
+
options: SyncToLeanixOptions = {},
|
|
322
|
+
): Promise<SyncToLeanixResult> {
|
|
323
|
+
const idempotent = options.idempotent ?? true
|
|
324
|
+
const likec4IdAttribute = options.likec4IdAttribute
|
|
325
|
+
|
|
326
|
+
const fsResult = await syncFactSheetsToLeanix(
|
|
327
|
+
client,
|
|
328
|
+
leanixDryRun.factSheets,
|
|
329
|
+
idempotent,
|
|
330
|
+
likec4IdAttribute,
|
|
331
|
+
)
|
|
332
|
+
const updatedEntities = applyLeanixIdsToEntities(manifest.entities, fsResult.likec4IdToFactSheetId)
|
|
333
|
+
|
|
334
|
+
const relResult = await syncRelationsToLeanix(
|
|
335
|
+
client,
|
|
336
|
+
manifest.relations,
|
|
337
|
+
leanixDryRun.relations,
|
|
338
|
+
fsResult.likec4IdToFactSheetId,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
manifest: {
|
|
343
|
+
...manifest,
|
|
344
|
+
entities: updatedEntities,
|
|
345
|
+
relations: relResult.updatedRelations,
|
|
346
|
+
},
|
|
347
|
+
factSheetsCreated: fsResult.factSheetsCreated,
|
|
348
|
+
factSheetsReused: fsResult.factSheetsReused,
|
|
349
|
+
relationsCreated: relResult.relationsCreated,
|
|
350
|
+
errors: [...fsResult.errors, ...relResult.errors],
|
|
351
|
+
}
|
|
352
|
+
}
|