@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,138 @@
1
+ /**
2
+ * Low-level LeanIX GraphQL API client.
3
+ * Handles authentication (Bearer token), request throttling, and errors.
4
+ */
5
+
6
+ /** Configuration for LeanixApiClient (apiToken, baseUrl, requestDelayMs). */
7
+ export interface LeanixApiClientConfig {
8
+ /** LeanIX API token (required for auth). */
9
+ apiToken: string
10
+ /**
11
+ * Base URL for the LeanIX API (e.g. https://app.leanix.net or https://<workspace>.leanix.net).
12
+ * Default: https://app.leanix.net
13
+ */
14
+ baseUrl?: string
15
+ /**
16
+ * Delay in ms between consecutive GraphQL requests (rate limiting).
17
+ * Default: 200
18
+ */
19
+ requestDelayMs?: number
20
+ }
21
+
22
+ const DEFAULT_BASE_URL = 'https://app.leanix.net'
23
+ const DEFAULT_DELAY_MS = 200
24
+
25
+ /** Result of a GraphQL request (data or errors). */
26
+ export interface GraphQLResponse<T = unknown> {
27
+ data?: T
28
+ errors?: Array<{ message: string; path?: string[]; extensions?: Record<string, unknown> }>
29
+ }
30
+
31
+ /** Thrown when the LeanIX API returns errors or non-OK HTTP. */
32
+ export class LeanixApiError extends Error {
33
+ constructor(
34
+ message: string,
35
+ public readonly statusCode?: number,
36
+ public readonly graphqlErrors?: GraphQLResponse['errors'],
37
+ ) {
38
+ super(message)
39
+ this.name = 'LeanixApiError'
40
+ }
41
+ }
42
+
43
+ function sleep(ms: number): Promise<void> {
44
+ return new Promise(resolve => setTimeout(resolve, ms))
45
+ }
46
+
47
+ /**
48
+ * LeanIX GraphQL API client with Bearer auth and optional rate limiting.
49
+ * Throttle state is per instance so multiple clients do not share delay.
50
+ */
51
+ export class LeanixApiClient {
52
+ private readonly baseUrl: string
53
+ private readonly apiToken: string
54
+ private readonly requestDelayMs: number
55
+ /** Last request timestamp for throttling (per instance). */
56
+ private lastRequestTime = 0
57
+ /** Serializes rate-limit and request so only one caller runs at a time. */
58
+ private rateLimitLock: Promise<void> = Promise.resolve()
59
+
60
+ constructor(config: LeanixApiClientConfig) {
61
+ this.apiToken = config.apiToken
62
+ this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '')
63
+ this.requestDelayMs = config.requestDelayMs ?? DEFAULT_DELAY_MS
64
+ }
65
+
66
+ /**
67
+ * Execute a GraphQL operation (query or mutation).
68
+ * Throttles requests by requestDelayMs. Throws LeanixApiError on HTTP or GraphQL errors.
69
+ */
70
+ async graphql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
71
+ const run = async (): Promise<T> => {
72
+ const now = Date.now()
73
+ const elapsed = now - this.lastRequestTime
74
+ if (elapsed < this.requestDelayMs) {
75
+ await sleep(this.requestDelayMs - elapsed)
76
+ }
77
+ this.lastRequestTime = Date.now()
78
+
79
+ const url = `${this.baseUrl}/services/pathfinder/v1/graphql`
80
+ let res: Response
81
+ try {
82
+ res = await fetch(url, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ Authorization: `Bearer ${this.apiToken}`,
87
+ },
88
+ body: JSON.stringify({ query, variables }),
89
+ })
90
+ } catch (err) {
91
+ const msg = err instanceof Error ? err.message : String(err)
92
+ throw new LeanixApiError(`GraphQL request failed: ${url} POST - ${msg}`)
93
+ }
94
+
95
+ let body: GraphQLResponse<T>
96
+ try {
97
+ body = (await res.json()) as GraphQLResponse<T>
98
+ } catch (err) {
99
+ const msg = err instanceof Error ? err.message : String(err)
100
+ throw new LeanixApiError(
101
+ `Invalid JSON response: ${url} ${res.status} ${res.statusText} - ${msg}`,
102
+ res.status,
103
+ )
104
+ }
105
+
106
+ if (!res.ok) {
107
+ throw new LeanixApiError(
108
+ body.errors?.[0]?.message ?? `HTTP ${res.status} ${res.statusText}`,
109
+ res.status,
110
+ body.errors,
111
+ )
112
+ }
113
+
114
+ if (body.errors && body.errors.length > 0) {
115
+ const msg = body.errors.map(e => e.message).join('; ')
116
+ throw new LeanixApiError(msg, res.status, body.errors)
117
+ }
118
+
119
+ if (body.data === undefined) {
120
+ throw new LeanixApiError('GraphQL response had no data and no errors')
121
+ }
122
+
123
+ return body.data as T
124
+ }
125
+
126
+ const prev = this.rateLimitLock
127
+ let resolve: () => void
128
+ this.rateLimitLock = new Promise<void>(r => {
129
+ resolve = r
130
+ })
131
+ await prev
132
+ try {
133
+ return await run()
134
+ } finally {
135
+ resolve!()
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * LeanIX GraphQL operations used by sync (find, create, patch fact sheets; create relations).
3
+ * Extracted for SRP; sync-to-leanix orchestrates, this module performs API calls.
4
+ */
5
+
6
+ import type { LeanixApiClient } from './leanix-api-client'
7
+ import type { LeanixFactSheetDryRun } from './to-leanix-inventory-dry-run'
8
+
9
+ /** Search fact sheets by name and type (for idempotency). Returns null when not found; throws on API/network error. */
10
+ export async function findFactSheetByNameAndType(
11
+ client: LeanixApiClient,
12
+ name: string,
13
+ type: string,
14
+ ): Promise<string | null> {
15
+ type AllFactSheetsResult = {
16
+ allFactSheets?: {
17
+ edges?: Array<{
18
+ node?: { id?: string; name?: string; type?: string }
19
+ }>
20
+ }
21
+ }
22
+ const query = `
23
+ query FindFactSheet($name: String!, $type: String!) {
24
+ allFactSheets(filter: { name: $name, factSheetType: $type }) {
25
+ edges { node { id name type } }
26
+ }
27
+ }
28
+ `
29
+ const data = await client.graphql<AllFactSheetsResult>(query, { name, type })
30
+ const edges = data.allFactSheets?.edges ?? []
31
+ if (edges.length > 1) {
32
+ throw new Error(
33
+ `Multiple fact sheets found for name="${name}" type="${type}". Ensure unique name+type in LeanIX or use likec4Id attribute for lookup.`,
34
+ )
35
+ }
36
+ const first = edges[0]?.node
37
+ return first?.id ?? null
38
+ }
39
+
40
+ /**
41
+ * Search fact sheets by custom attribute (e.g. likec4Id) for idempotent lookup.
42
+ * Returns null when not found; throws on API/network error.
43
+ */
44
+ export async function findFactSheetByLikec4IdAttribute(
45
+ client: LeanixApiClient,
46
+ attributeKey: string,
47
+ likec4Id: string,
48
+ ): Promise<string | null> {
49
+ type AllFactSheetsResult = {
50
+ allFactSheets?: {
51
+ edges?: Array<{
52
+ node?: { id?: string }
53
+ }>
54
+ }
55
+ }
56
+ type FilterInput = {
57
+ facetFilters?: Array<{ facetKey: string; operator: string; keys: string[] }>
58
+ }
59
+ const query = `
60
+ query FindFactSheetByAttribute($filter: FilterInput!) {
61
+ allFactSheets(filter: $filter) {
62
+ edges { node { id } }
63
+ }
64
+ }
65
+ `
66
+ const filter: FilterInput = {
67
+ facetFilters: [{ facetKey: attributeKey, operator: 'OR', keys: [likec4Id] }],
68
+ }
69
+ const data = await client.graphql<AllFactSheetsResult>(query, { filter })
70
+ const edges = data.allFactSheets?.edges ?? []
71
+ if (edges.length > 1) {
72
+ throw new Error(
73
+ `Multiple fact sheets found for attribute ${attributeKey}=${likec4Id}. Ensure unique likec4Id in LeanIX.`,
74
+ )
75
+ }
76
+ const first = edges[0]?.node
77
+ return first?.id ?? null
78
+ }
79
+
80
+ /** Patch an existing fact sheet to set a custom attribute (e.g. likec4Id). Throws on API error. */
81
+ export async function patchFactSheetAttribute(
82
+ client: LeanixApiClient,
83
+ factSheetId: string,
84
+ attributeKey: string,
85
+ value: string,
86
+ ): Promise<void> {
87
+ type UpdateResult = { updateFactSheet?: { factSheet?: { id: string } } }
88
+ const patches = [{ op: 'replace', path: `/factSheetAttributes/${attributeKey}`, value }]
89
+ const mutation = `
90
+ mutation UpdateFactSheet($id: ID!, $patches: [Patch]) {
91
+ updateFactSheet(id: $id, patches: $patches) {
92
+ factSheet { id }
93
+ }
94
+ }
95
+ `
96
+ const data = await client.graphql<UpdateResult>(mutation, { id: factSheetId, patches })
97
+ if (!data.updateFactSheet?.factSheet?.id) {
98
+ throw new Error(`updateFactSheet did not return fact sheet (id=${factSheetId}, attribute=${attributeKey})`)
99
+ }
100
+ }
101
+
102
+ /** Create fact sheet (name, type, optional description and likec4Id attribute). Returns new id; throws on API error. */
103
+ export async function createFactSheet(
104
+ client: LeanixApiClient,
105
+ fs: LeanixFactSheetDryRun,
106
+ likec4IdAttribute: string | undefined,
107
+ ): Promise<string> {
108
+ type CreateResult = { createFactSheet?: { factSheet?: { id: string } } }
109
+ const patches: Array<{ op: string; path: string; value: string }> = []
110
+ if (fs.description) {
111
+ patches.push({ op: 'replace', path: '/description', value: fs.description })
112
+ }
113
+ if (likec4IdAttribute && fs.likec4Id) {
114
+ patches.push({ op: 'replace', path: `/factSheetAttributes/${likec4IdAttribute}`, value: fs.likec4Id })
115
+ }
116
+ const mutation = `
117
+ mutation CreateFactSheet($input: CreateFactSheetInput!, $patches: [Patch]) {
118
+ createFactSheet(input: $input, patches: $patches) {
119
+ factSheet { id name type rev }
120
+ }
121
+ }
122
+ `
123
+ const input = { name: fs.name, type: fs.type }
124
+ const variables = { input, patches }
125
+ const data = await client.graphql<CreateResult>(mutation, variables)
126
+ const id = data.createFactSheet?.factSheet?.id
127
+ if (!id) throw new Error(`createFactSheet did not return id for ${String(fs.name)}`)
128
+ return id
129
+ }
130
+
131
+ /** Create a relation between two fact sheets. Returns relation id; throws when mutation returns no id. */
132
+ export async function createRelation(
133
+ client: LeanixApiClient,
134
+ sourceFactSheetId: string,
135
+ targetFactSheetId: string,
136
+ relationType: string,
137
+ _title?: string,
138
+ ): Promise<string> {
139
+ type CreateResult = { createRelation?: { relation?: { id: string } } }
140
+ const mutation = `
141
+ mutation CreateRelation($source: ID!, $target: ID!, $type: String!) {
142
+ createRelation(source: $source, target: $target, type: $type) {
143
+ relation { id }
144
+ }
145
+ }
146
+ `
147
+ const data = await client.graphql<CreateResult>(mutation, {
148
+ source: sourceFactSheetId,
149
+ target: targetFactSheetId,
150
+ type: relationType,
151
+ })
152
+ const id = data.createRelation?.relation?.id
153
+ if (!id) {
154
+ const payload = JSON.stringify(data, null, 2)
155
+ throw new Error(
156
+ `createRelation did not return relation id (sourceFactSheetId=${sourceFactSheetId}, targetFactSheetId=${targetFactSheetId}, relationType=${relationType}). Response: ${payload}`,
157
+ )
158
+ }
159
+ return id
160
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Inbound LeanIX: read-only snapshot of LeanIX inventory (fact sheets + relations).
3
+ * Used for reconciliation with the LikeC4 manifest; no DSL generation.
4
+ */
5
+
6
+ import type { LeanixApiClient } from './leanix-api-client'
7
+
8
+ /** Single fact sheet as returned from LeanIX API (read-only snapshot). */
9
+ export interface LeanixFactSheetSnapshotItem {
10
+ id: string
11
+ name: string
12
+ type: string
13
+ /** Set when fetched with likec4IdAttribute and the fact sheet has that custom attribute. */
14
+ likec4Id?: string
15
+ }
16
+
17
+ /** Single relation as returned from LeanIX API (read-only snapshot). */
18
+ export interface LeanixRelationSnapshotItem {
19
+ id?: string
20
+ sourceFactSheetId: string
21
+ targetFactSheetId: string
22
+ type: string
23
+ }
24
+
25
+ /** Read-only snapshot of LeanIX inventory for reconciliation. */
26
+ export interface LeanixInventorySnapshot {
27
+ generatedAt: string
28
+ /** Workspace or project identifier if available from API. */
29
+ workspaceId?: string
30
+ factSheets: LeanixFactSheetSnapshotItem[]
31
+ relations: LeanixRelationSnapshotItem[]
32
+ }
33
+
34
+ /** Options for fetchLeanixInventorySnapshot (likec4Id attribute, maxFactSheets, generatedAt). */
35
+ export interface FetchLeanixInventorySnapshotOptions {
36
+ /** Custom attribute key to read likec4Id from fact sheets (e.g. "likec4Id"). When set, snapshot items get likec4Id when present. */
37
+ likec4IdAttribute?: string
38
+ /** Max fact sheets to fetch (pagination). Default 1000. */
39
+ maxFactSheets?: number
40
+ /** ISO timestamp for snapshot. Default: new Date().toISOString() */
41
+ generatedAt?: string
42
+ }
43
+
44
+ const DEFAULT_PAGE_SIZE = 100
45
+ const DEFAULT_MAX_FACT_SHEETS = 1000
46
+ const MAX_GRAPHQL_RETRIES = 3
47
+
48
+ async function withGraphQLRetry<T>(fn: () => Promise<T>): Promise<T> {
49
+ let lastErr: unknown
50
+ for (let attempt = 0; attempt < MAX_GRAPHQL_RETRIES; attempt++) {
51
+ try {
52
+ return await fn()
53
+ } catch (err) {
54
+ lastErr = err
55
+ if (attempt < MAX_GRAPHQL_RETRIES - 1) {
56
+ await new Promise(r => setTimeout(r, 500 * (attempt + 1)))
57
+ }
58
+ }
59
+ }
60
+ throw lastErr
61
+ }
62
+
63
+ /**
64
+ * Fetches a read-only snapshot of the LeanIX inventory (fact sheets, then relations).
65
+ * Uses cursor-based pagination. Does not modify LeanIX.
66
+ */
67
+ export async function fetchLeanixInventorySnapshot(
68
+ client: LeanixApiClient,
69
+ options: FetchLeanixInventorySnapshotOptions = {},
70
+ ): Promise<LeanixInventorySnapshot> {
71
+ const generatedAt = options.generatedAt ?? new Date().toISOString()
72
+ const maxFactSheets = options.maxFactSheets ?? DEFAULT_MAX_FACT_SHEETS
73
+ if (!Number.isInteger(maxFactSheets) || maxFactSheets < 0) {
74
+ throw new Error('maxFactSheets must be a non-negative integer')
75
+ }
76
+ const likec4IdAttribute = options.likec4IdAttribute
77
+
78
+ const factSheets = await fetchAllFactSheets(client, {
79
+ ...(likec4IdAttribute != null ? { likec4IdAttribute } : {}),
80
+ maxFactSheets,
81
+ })
82
+ const relations = await fetchAllRelations(client, factSheets.map(f => f.id))
83
+
84
+ return {
85
+ generatedAt,
86
+ factSheets,
87
+ relations,
88
+ }
89
+ }
90
+
91
+ type FactSheetNode = {
92
+ id?: string
93
+ name?: string
94
+ type?: string
95
+ factSheetAttributes?: Array<{ key?: string; value?: string }>
96
+ }
97
+
98
+ /** Maps a GraphQL node to LeanixFactSheetSnapshotItem; returns null when node has no id. */
99
+ function mapNodeToFactSheetItem(
100
+ node: FactSheetNode | undefined,
101
+ likec4IdAttribute: string | undefined,
102
+ ): LeanixFactSheetSnapshotItem | null {
103
+ if (!node?.id) return null
104
+ const likec4Id = likec4IdAttribute != null && Array.isArray(node.factSheetAttributes)
105
+ ? node.factSheetAttributes.find((a: { key?: string; value?: string }) => a.key === likec4IdAttribute)?.value
106
+ : undefined
107
+ return {
108
+ id: node.id,
109
+ name: node.name ?? '',
110
+ type: node.type ?? '',
111
+ ...(likec4Id ? { likec4Id } : {}),
112
+ }
113
+ }
114
+
115
+ async function fetchAllFactSheets(
116
+ client: LeanixApiClient,
117
+ opts: { likec4IdAttribute?: string; maxFactSheets: number },
118
+ ): Promise<LeanixInventorySnapshot['factSheets']> {
119
+ const likec4IdAttribute = opts.likec4IdAttribute
120
+ const pageSize = Math.min(DEFAULT_PAGE_SIZE, opts.maxFactSheets)
121
+ const result: LeanixInventorySnapshot['factSheets'] = []
122
+ let after: string | null = null
123
+ let hasNextPage = true
124
+
125
+ const attributeSelection = likec4IdAttribute != null
126
+ ? `factSheetAttributes { key value }`
127
+ : ''
128
+
129
+ const query = `
130
+ query AllFactSheets($first: Int!, $after: String, $filter: FilterInput) {
131
+ allFactSheets(first: $first, after: $after, filter: $filter) {
132
+ edges {
133
+ node {
134
+ id
135
+ name
136
+ type
137
+ ${attributeSelection}
138
+ }
139
+ cursor
140
+ }
141
+ pageInfo {
142
+ hasNextPage
143
+ endCursor
144
+ }
145
+ }
146
+ }
147
+ `
148
+
149
+ type PageRes = {
150
+ allFactSheets?: {
151
+ edges?: Array<{ node?: FactSheetNode; cursor?: string }>
152
+ pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }
153
+ }
154
+ }
155
+
156
+ const fetchPage = async (cursor: string | null): Promise<PageRes> =>
157
+ withGraphQLRetry(() =>
158
+ client.graphql<PageRes>(query, {
159
+ first: pageSize,
160
+ after: cursor,
161
+ filter: {},
162
+ })
163
+ )
164
+
165
+ while (hasNextPage && result.length < opts.maxFactSheets) {
166
+ const data: PageRes = await fetchPage(after)
167
+ const edges = data.allFactSheets?.edges ?? []
168
+ const pageInfo = data.allFactSheets?.pageInfo
169
+
170
+ for (const edge of edges) {
171
+ if (result.length >= opts.maxFactSheets) break
172
+ const item = mapNodeToFactSheetItem(edge.node, likec4IdAttribute)
173
+ if (item) result.push(item)
174
+ }
175
+
176
+ hasNextPage = pageInfo?.hasNextPage === true && result.length < opts.maxFactSheets
177
+ after = pageInfo?.endCursor ?? null
178
+ }
179
+
180
+ return result
181
+ }
182
+
183
+ const RELATIONS_FETCH_CONCURRENCY = 10
184
+ const RELATIONS_PAGE_SIZE = 100
185
+
186
+ async function fetchAllRelations(
187
+ client: LeanixApiClient,
188
+ factSheetIds: string[],
189
+ ): Promise<LeanixInventorySnapshot['relations']> {
190
+ if (factSheetIds.length === 0) return []
191
+
192
+ const relations: LeanixRelationSnapshotItem[] = []
193
+ const idSet = new Set(factSheetIds)
194
+
195
+ type RelationsResult = {
196
+ factSheet?: {
197
+ id?: string
198
+ relations?: {
199
+ edges?: Array<{
200
+ node?: {
201
+ id?: string
202
+ type?: string
203
+ targetFactSheet?: { id?: string }
204
+ }
205
+ }>
206
+ pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }
207
+ }
208
+ }
209
+ }
210
+
211
+ const query = `
212
+ query FactSheetRelations($id: ID!, $first: Int, $after: String) {
213
+ factSheet(id: $id) {
214
+ id
215
+ relations(first: $first, after: $after) {
216
+ edges {
217
+ node {
218
+ id
219
+ type
220
+ targetFactSheet { id }
221
+ }
222
+ }
223
+ pageInfo { hasNextPage endCursor }
224
+ }
225
+ }
226
+ }
227
+ `
228
+
229
+ async function fetchRelationsForFactSheet(sourceId: string): Promise<LeanixRelationSnapshotItem[]> {
230
+ const out: LeanixRelationSnapshotItem[] = []
231
+ let after: string | null = null
232
+ let hasNextPage = true
233
+ while (hasNextPage) {
234
+ const data = await withGraphQLRetry(() =>
235
+ client.graphql<RelationsResult>(query, { id: sourceId, first: RELATIONS_PAGE_SIZE, after })
236
+ )
237
+ const edges = data?.factSheet?.relations?.edges ?? []
238
+ const pageInfo = data?.factSheet?.relations?.pageInfo
239
+ for (const edge of edges) {
240
+ const node = edge.node
241
+ const targetId = node?.targetFactSheet?.id
242
+ if (!targetId || !idSet.has(targetId)) continue
243
+ out.push({
244
+ ...(node?.id ? { id: node.id } : {}),
245
+ sourceFactSheetId: sourceId,
246
+ targetFactSheetId: targetId,
247
+ type: node?.type ?? 'RELATES_TO',
248
+ })
249
+ }
250
+ hasNextPage = pageInfo?.hasNextPage === true
251
+ after = pageInfo?.endCursor ?? null
252
+ }
253
+ return out
254
+ }
255
+
256
+ for (let i = 0; i < factSheetIds.length; i += RELATIONS_FETCH_CONCURRENCY) {
257
+ const batch = factSheetIds.slice(i, i + RELATIONS_FETCH_CONCURRENCY)
258
+ const results = await Promise.all(batch.map(sourceId => fetchRelationsForFactSheet(sourceId)))
259
+ for (const items of results) relations.push(...items)
260
+ }
261
+
262
+ return relations
263
+ }
package/src/mapping.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Configurable LeanIX mapping: LikeC4 kinds/relations/tags → LeanIX fact sheet types and fields.
3
+ * No universal taxonomy; safe defaults. Actor kind maps to 'Provider' unless overridden.
4
+ */
5
+
6
+ /** Configurable mapping from LikeC4 kinds/relations/tags to LeanIX fact sheet and relation types. */
7
+ export interface LeanixMappingConfig {
8
+ /** LikeC4 element kind → LeanIX fact sheet type */
9
+ factSheetTypes?: Record<string, string>
10
+ /** LikeC4 relationship kind → LeanIX relation type name */
11
+ relationTypes?: Record<string, string>
12
+ /** LikeC4 tags / metadata keys → LeanIX field names (optional) */
13
+ metadataToFields?: Record<string, string>
14
+ }
15
+
16
+ /** Fallback when element kind is unknown (G25: named constant). */
17
+ export const FALLBACK_FACT_SHEET_TYPE = 'Application'
18
+
19
+ /** Fallback when relation kind is unknown (G25: named constant). */
20
+ export const FALLBACK_RELATION_TYPE = 'depends on'
21
+
22
+ /** Default mapping: actor → Provider; override factSheetTypes/relationTypes as needed */
23
+ export const DEFAULT_LEANIX_MAPPING: Required<LeanixMappingConfig> = {
24
+ factSheetTypes: {
25
+ system: 'Application',
26
+ container: 'ITComponent',
27
+ component: 'ITComponent',
28
+ actor: 'Provider',
29
+ },
30
+ relationTypes: {
31
+ default: FALLBACK_RELATION_TYPE,
32
+ },
33
+ metadataToFields: {
34
+ title: 'name',
35
+ description: 'description',
36
+ technology: 'technology',
37
+ },
38
+ }
39
+
40
+ /**
41
+ * Merges partial mapping config with DEFAULT_LEANIX_MAPPING; returns a full Required<LeanixMappingConfig>.
42
+ */
43
+ export function mergeWithDefault(partial?: LeanixMappingConfig | null): Required<LeanixMappingConfig> {
44
+ const base = {
45
+ factSheetTypes: { ...DEFAULT_LEANIX_MAPPING.factSheetTypes },
46
+ relationTypes: { ...DEFAULT_LEANIX_MAPPING.relationTypes },
47
+ metadataToFields: { ...DEFAULT_LEANIX_MAPPING.metadataToFields },
48
+ }
49
+ if (!partial) {
50
+ return base
51
+ }
52
+ return {
53
+ factSheetTypes: { ...base.factSheetTypes, ...partial.factSheetTypes },
54
+ relationTypes: { ...base.relationTypes, ...partial.relationTypes },
55
+ metadataToFields: { ...base.metadataToFields, ...partial.metadataToFields },
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Returns LeanIX fact sheet type for a LikeC4 element kind.
61
+ * Uses mapping.factSheetTypes[kind], then 'default', then FALLBACK_FACT_SHEET_TYPE.
62
+ */
63
+ export function getFactSheetType(
64
+ likec4Kind: string,
65
+ mapping: Required<LeanixMappingConfig>,
66
+ ): string {
67
+ return (
68
+ mapping.factSheetTypes[likec4Kind] ??
69
+ mapping.factSheetTypes['default'] ??
70
+ FALLBACK_FACT_SHEET_TYPE
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Returns LeanIX relation type for a LikeC4 relationship kind.
76
+ * Uses mapping.relationTypes[kind], then 'default', then FALLBACK_RELATION_TYPE.
77
+ */
78
+ export function getRelationType(
79
+ likec4Kind: string | null,
80
+ mapping: Required<LeanixMappingConfig>,
81
+ ): string {
82
+ const kind = likec4Kind ?? 'default'
83
+ return (
84
+ mapping.relationTypes[kind] ??
85
+ mapping.relationTypes['default'] ??
86
+ FALLBACK_RELATION_TYPE
87
+ )
88
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Minimal model shape required by the bridge. Satisfied by LikeC4Model (layouted).
3
+ * Keeps the bridge decoupled from concrete model class.
4
+ */
5
+
6
+ /** Element shape required by the bridge (id, kind, title, tags, getMetadata). */
7
+ export interface BridgeElementLike {
8
+ id: string
9
+ kind: string
10
+ title: string
11
+ tags: readonly string[]
12
+ technology?: string | null
13
+ getMetadata(): Record<string, unknown>
14
+ }
15
+
16
+ /** Relation shape required by the bridge (id, source, target, kind, title). */
17
+ export interface BridgeRelationLike {
18
+ id: string
19
+ source: { id: string }
20
+ target: { id: string }
21
+ kind: string | null
22
+ title?: string | null
23
+ }
24
+
25
+ /** View shape required by the bridge (id only). */
26
+ export interface BridgeViewLike {
27
+ id: string
28
+ }
29
+
30
+ /** Model input for toBridgeManifest / toLeanixInventoryDryRun */
31
+ export interface BridgeModelInput {
32
+ projectId: string
33
+ elements(): Iterable<BridgeElementLike>
34
+ relationships(): Iterable<BridgeRelationLike>
35
+ views(): Iterable<BridgeViewLike>
36
+ }