@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.
@@ -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
+ }