@flow-as-code/core 0.1.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.
Files changed (164) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +30 -0
  3. package/SPEC.md +92 -0
  4. package/dist/actions.d.ts +87 -0
  5. package/dist/actions.d.ts.map +1 -0
  6. package/dist/actions.js +204 -0
  7. package/dist/actions.js.map +1 -0
  8. package/dist/aws.d.ts +27 -0
  9. package/dist/aws.d.ts.map +1 -0
  10. package/dist/aws.js +32 -0
  11. package/dist/aws.js.map +1 -0
  12. package/dist/blocks.d.ts +287 -0
  13. package/dist/blocks.d.ts.map +1 -0
  14. package/dist/blocks.js +352 -0
  15. package/dist/blocks.js.map +1 -0
  16. package/dist/codegen.d.ts +21 -0
  17. package/dist/codegen.d.ts.map +1 -0
  18. package/dist/codegen.js +1027 -0
  19. package/dist/codegen.js.map +1 -0
  20. package/dist/export.d.ts +256 -0
  21. package/dist/export.d.ts.map +1 -0
  22. package/dist/export.js +799 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/flow.d.ts +41 -0
  25. package/dist/flow.d.ts.map +1 -0
  26. package/dist/flow.js +85 -0
  27. package/dist/flow.js.map +1 -0
  28. package/dist/flowdoc.d.ts +111 -0
  29. package/dist/flowdoc.d.ts.map +1 -0
  30. package/dist/flowdoc.js +139 -0
  31. package/dist/flowdoc.js.map +1 -0
  32. package/dist/index.d.ts +16 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +26 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/layout.d.ts +11 -0
  37. package/dist/layout.d.ts.map +1 -0
  38. package/dist/layout.js +49 -0
  39. package/dist/layout.js.map +1 -0
  40. package/dist/lint/engine.d.ts +16 -0
  41. package/dist/lint/engine.d.ts.map +1 -0
  42. package/dist/lint/engine.js +37 -0
  43. package/dist/lint/engine.js.map +1 -0
  44. package/dist/lint/graph.d.ts +34 -0
  45. package/dist/lint/graph.d.ts.map +1 -0
  46. package/dist/lint/graph.js +79 -0
  47. package/dist/lint/graph.js.map +1 -0
  48. package/dist/lint/index.d.ts +7 -0
  49. package/dist/lint/index.d.ts.map +1 -0
  50. package/dist/lint/index.js +5 -0
  51. package/dist/lint/index.js.map +1 -0
  52. package/dist/lint/reporters.d.ts +6 -0
  53. package/dist/lint/reporters.d.ts.map +1 -0
  54. package/dist/lint/reporters.js +30 -0
  55. package/dist/lint/reporters.js.map +1 -0
  56. package/dist/lint/rules/action-allowed-in-flow-type.d.ts +11 -0
  57. package/dist/lint/rules/action-allowed-in-flow-type.d.ts.map +1 -0
  58. package/dist/lint/rules/action-allowed-in-flow-type.js +32 -0
  59. package/dist/lint/rules/action-allowed-in-flow-type.js.map +1 -0
  60. package/dist/lint/rules/action-count.d.ts +16 -0
  61. package/dist/lint/rules/action-count.d.ts.map +1 -0
  62. package/dist/lint/rules/action-count.js +32 -0
  63. package/dist/lint/rules/action-count.js.map +1 -0
  64. package/dist/lint/rules/error-branches.d.ts +10 -0
  65. package/dist/lint/rules/error-branches.d.ts.map +1 -0
  66. package/dist/lint/rules/error-branches.js +39 -0
  67. package/dist/lint/rules/error-branches.js.map +1 -0
  68. package/dist/lint/rules/index.d.ts +17 -0
  69. package/dist/lint/rules/index.d.ts.map +1 -0
  70. package/dist/lint/rules/index.js +36 -0
  71. package/dist/lint/rules/index.js.map +1 -0
  72. package/dist/lint/rules/module-depth-5.d.ts +14 -0
  73. package/dist/lint/rules/module-depth-5.d.ts.map +1 -0
  74. package/dist/lint/rules/module-depth-5.js +67 -0
  75. package/dist/lint/rules/module-depth-5.js.map +1 -0
  76. package/dist/lint/rules/no-literal-arn.d.ts +21 -0
  77. package/dist/lint/rules/no-literal-arn.d.ts.map +1 -0
  78. package/dist/lint/rules/no-literal-arn.js +58 -0
  79. package/dist/lint/rules/no-literal-arn.js.map +1 -0
  80. package/dist/lint/rules/no-unresolved-token.d.ts +8 -0
  81. package/dist/lint/rules/no-unresolved-token.d.ts.map +1 -0
  82. package/dist/lint/rules/no-unresolved-token.js +71 -0
  83. package/dist/lint/rules/no-unresolved-token.js.map +1 -0
  84. package/dist/lint/rules/prompt-length-3000.d.ts +16 -0
  85. package/dist/lint/rules/prompt-length-3000.d.ts.map +1 -0
  86. package/dist/lint/rules/prompt-length-3000.js +61 -0
  87. package/dist/lint/rules/prompt-length-3000.js.map +1 -0
  88. package/dist/lint/rules/reachable-blocks.d.ts +7 -0
  89. package/dist/lint/rules/reachable-blocks.d.ts.map +1 -0
  90. package/dist/lint/rules/reachable-blocks.js +45 -0
  91. package/dist/lint/rules/reachable-blocks.js.map +1 -0
  92. package/dist/lint/rules/recording-consent-before-record.d.ts +3 -0
  93. package/dist/lint/rules/recording-consent-before-record.d.ts.map +1 -0
  94. package/dist/lint/rules/recording-consent-before-record.js +87 -0
  95. package/dist/lint/rules/recording-consent-before-record.js.map +1 -0
  96. package/dist/lint/rules/terminal-blocks.d.ts +8 -0
  97. package/dist/lint/rules/terminal-blocks.d.ts.map +1 -0
  98. package/dist/lint/rules/terminal-blocks.js +40 -0
  99. package/dist/lint/rules/terminal-blocks.js.map +1 -0
  100. package/dist/lint/rules/unique-names.d.ts +7 -0
  101. package/dist/lint/rules/unique-names.d.ts.map +1 -0
  102. package/dist/lint/rules/unique-names.js +40 -0
  103. package/dist/lint/rules/unique-names.js.map +1 -0
  104. package/dist/lint/types.d.ts +30 -0
  105. package/dist/lint/types.d.ts.map +1 -0
  106. package/dist/lint/types.js +11 -0
  107. package/dist/lint/types.js.map +1 -0
  108. package/dist/materialize.d.ts +31 -0
  109. package/dist/materialize.d.ts.map +1 -0
  110. package/dist/materialize.js +192 -0
  111. package/dist/materialize.js.map +1 -0
  112. package/dist/package-names.d.ts +11 -0
  113. package/dist/package-names.d.ts.map +1 -0
  114. package/dist/package-names.js +25 -0
  115. package/dist/package-names.js.map +1 -0
  116. package/dist/refs.d.ts +35 -0
  117. package/dist/refs.d.ts.map +1 -0
  118. package/dist/refs.js +69 -0
  119. package/dist/refs.js.map +1 -0
  120. package/dist/serialize.d.ts +13 -0
  121. package/dist/serialize.d.ts.map +1 -0
  122. package/dist/serialize.js +86 -0
  123. package/dist/serialize.js.map +1 -0
  124. package/dist/simulate.d.ts +446 -0
  125. package/dist/simulate.d.ts.map +1 -0
  126. package/dist/simulate.js +1115 -0
  127. package/dist/simulate.js.map +1 -0
  128. package/dist/synth.d.ts +25 -0
  129. package/dist/synth.d.ts.map +1 -0
  130. package/dist/synth.js +93 -0
  131. package/dist/synth.js.map +1 -0
  132. package/package.json +73 -0
  133. package/src/actions.ts +221 -0
  134. package/src/aws.ts +55 -0
  135. package/src/blocks.ts +619 -0
  136. package/src/codegen.ts +1144 -0
  137. package/src/export.ts +1190 -0
  138. package/src/flow.ts +108 -0
  139. package/src/flowdoc.ts +261 -0
  140. package/src/index.ts +31 -0
  141. package/src/layout.ts +54 -0
  142. package/src/lint/engine.ts +56 -0
  143. package/src/lint/graph.ts +86 -0
  144. package/src/lint/index.ts +10 -0
  145. package/src/lint/reporters.ts +44 -0
  146. package/src/lint/rules/action-allowed-in-flow-type.ts +32 -0
  147. package/src/lint/rules/action-count.ts +35 -0
  148. package/src/lint/rules/error-branches.ts +45 -0
  149. package/src/lint/rules/index.ts +53 -0
  150. package/src/lint/rules/module-depth-5.ts +70 -0
  151. package/src/lint/rules/no-literal-arn.ts +64 -0
  152. package/src/lint/rules/no-unresolved-token.ts +80 -0
  153. package/src/lint/rules/prompt-length-3000.ts +66 -0
  154. package/src/lint/rules/reachable-blocks.ts +50 -0
  155. package/src/lint/rules/recording-consent-before-record.ts +90 -0
  156. package/src/lint/rules/terminal-blocks.ts +45 -0
  157. package/src/lint/rules/unique-names.ts +44 -0
  158. package/src/lint/types.ts +43 -0
  159. package/src/materialize.ts +230 -0
  160. package/src/package-names.ts +26 -0
  161. package/src/refs.ts +93 -0
  162. package/src/serialize.ts +97 -0
  163. package/src/simulate.ts +1610 -0
  164. package/src/synth.ts +110 -0
package/src/export.ts ADDED
@@ -0,0 +1,1190 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // Export: a live Amazon Connect instance in, FlowDocs out.
6
+ //
7
+ // The client is an interface, not the AWS SDK, so every path in this file is
8
+ // testable offline against recorded fixtures (conformance/export/). The SDK
9
+ // adapter at the bottom is the only code that touches @aws-sdk/client-connect,
10
+ // and it loads it with a dynamic import, so importing @flow-as-code/core never requires
11
+ // the optional peer dependency.
12
+ //
13
+ // Operations used. Verified 2026-08-31 against the current API reference and
14
+ // against the command classes in @aws-sdk/client-connect 3.1122.0:
15
+ // ListContactFlows https://docs.aws.amazon.com/connect/latest/APIReference/API_ListContactFlows.html
16
+ // DescribeContactFlow https://docs.aws.amazon.com/connect/latest/APIReference/API_DescribeContactFlow.html
17
+ // ListContactFlowModules https://docs.aws.amazon.com/connect/latest/APIReference/API_ListContactFlowModules.html
18
+ // DescribeContactFlowModule https://docs.aws.amazon.com/connect/latest/APIReference/API_DescribeContactFlowModule.html
19
+ // ListQueues https://docs.aws.amazon.com/connect/latest/APIReference/API_ListQueues.html
20
+ // ListHoursOfOperations https://docs.aws.amazon.com/connect/latest/APIReference/API_ListHoursOfOperations.html
21
+ // ListPrompts https://docs.aws.amazon.com/connect/latest/APIReference/API_ListPrompts.html
22
+ // ListLambdaFunctions https://docs.aws.amazon.com/connect/latest/APIReference/API_ListLambdaFunctions.html
23
+ // ListBots https://docs.aws.amazon.com/connect/latest/APIReference/API_ListBots.html
24
+ //
25
+ // SPEC.md used to say Lambda and Lex come from "Lambda/Lex associations", which
26
+ // points at ListIntegrationAssociations. That operation cannot discover either:
27
+ // its IntegrationType enum has no LAMBDA_FUNCTION and no LEX_BOT member.
28
+ // ListLambdaFunctions and ListBots are the operations that do.
29
+ // https://docs.aws.amazon.com/connect/latest/APIReference/API_ListIntegrationAssociations.html
30
+
31
+ import { createRateLimiter, type AwsCommandSender, type RateLimiterOptions } from "./aws.js";
32
+ import { codegen, type CodegenOptions } from "./codegen.js";
33
+ import type {
34
+ ConnectType,
35
+ FlowAction,
36
+ FlowContent,
37
+ FlowDoc,
38
+ FlowDocMeta,
39
+ Point,
40
+ RefEntry,
41
+ RefType,
42
+ } from "./flowdoc.js";
43
+ import { FLOW_LANGUAGE_VERSION, FLOWDOC_VERSION, SLUG_PATTERN } from "./flowdoc.js";
44
+ import { autoLayout } from "./layout.js";
45
+ import { collectRefs, parseToken } from "./refs.js";
46
+ import { canonicalize } from "./serialize.js";
47
+
48
+ // --- ARN parsing -------------------------------------------------------------
49
+ // Every Connect resource ARN nests under the instance ARN, so the resource part
50
+ // splits on "/" into [instance, {instanceId}, {typeKeyword}, {resourceId}].
51
+ // Two type keywords do not match their IAM resource-type names, which is the
52
+ // trap this parser exists to avoid: a contact-flow-module is `flow-module` in
53
+ // the ARN, and an hours-of-operation is `operating-hours`.
54
+ // Machine-readable source for both:
55
+ // https://servicereference.us-east-1.amazonaws.com/v1/connect/connect.json
56
+ // (the feed behind
57
+ // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonconnect.html)
58
+
59
+ /** ARN type keyword to FlowDoc ref type, for resources nested under an instance. */
60
+ export const CONNECT_ARN_REF_TYPES: Readonly<Record<string, RefType>> = {
61
+ "contact-flow": "flow",
62
+ "flow-module": "module",
63
+ queue: "queue",
64
+ "operating-hours": "hours",
65
+ prompt: "prompt",
66
+ };
67
+
68
+ /** FlowDoc ref type to ARN type keyword. The inverse of CONNECT_ARN_REF_TYPES. */
69
+ export const REF_TYPE_ARN_KEYWORDS: Readonly<Record<string, string>> = {
70
+ flow: "contact-flow",
71
+ module: "flow-module",
72
+ queue: "queue",
73
+ hours: "operating-hours",
74
+ prompt: "prompt",
75
+ };
76
+
77
+ export interface ConnectArn {
78
+ partition: string;
79
+ region: string;
80
+ account: string;
81
+ instanceId: string;
82
+ /** ARN type keyword, e.g. `contact-flow`, `flow-module`, `operating-hours`. */
83
+ resourceType?: string;
84
+ resourceId?: string;
85
+ /**
86
+ * Trailing colon qualifier on a flow or module ARN: `$SAVED` or a version
87
+ * number. Documented on DescribeContactFlow, which spells the alias form
88
+ * `arn:aws:.../contact-flow/{id}:$SAVED`.
89
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_DescribeContactFlow.html
90
+ */
91
+ qualifier?: string;
92
+ }
93
+
94
+ /** Parses an Amazon Connect resource ARN. Returns undefined for anything else. */
95
+ export function parseConnectArn(arn: string): ConnectArn | undefined {
96
+ const parts = arn.split(":");
97
+ if (parts.length < 6) return undefined;
98
+ if (parts[0] !== "arn" || parts[2] !== "connect") return undefined;
99
+ const segments = (parts[5] ?? "").split("/");
100
+ if (segments[0] !== "instance" || segments.length < 2) return undefined;
101
+ const instanceId = segments[1] ?? "";
102
+ if (instanceId === "") return undefined;
103
+ const parsed: ConnectArn = {
104
+ partition: parts[1] ?? "",
105
+ region: parts[3] ?? "",
106
+ account: parts[4] ?? "",
107
+ instanceId,
108
+ };
109
+ if (segments.length >= 4) {
110
+ parsed.resourceType = segments[2];
111
+ parsed.resourceId = segments.slice(3).join("/");
112
+ }
113
+ if (parts.length > 6) parsed.qualifier = parts.slice(6).join(":");
114
+ return parsed;
115
+ }
116
+
117
+ /**
118
+ * Function name out of a Lambda ARN. Lambda is not a Connect resource type:
119
+ * ListLambdaFunctions returns native Lambda ARNs, so the name is the segment
120
+ * after `function`, with any version or alias qualifier dropped.
121
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_ListLambdaFunctions.html
122
+ */
123
+ export function parseLambdaFunctionArn(arn: string): string | undefined {
124
+ const parts = arn.split(":");
125
+ if (parts.length < 7 || parts[0] !== "arn" || parts[2] !== "lambda") return undefined;
126
+ if (parts[5] !== "function") return undefined;
127
+ const name = parts[6];
128
+ return name === undefined || name === "" ? undefined : name;
129
+ }
130
+
131
+ /**
132
+ * Drops the trailing qualifier a reference may carry, so `:$SAVED` or a version
133
+ * suffix resolves to the same reverse-map entry as the bare resource.
134
+ */
135
+ export function normalizeArn(arn: string): string {
136
+ const parts = arn.split(":");
137
+ if (parts.length < 6 || parts[0] !== "arn") return arn;
138
+ if (parts[2] === "connect") return parts.slice(0, 6).join(":");
139
+ if (parts[2] === "lambda" && parts[5] === "function") return parts.slice(0, 7).join(":");
140
+ return arn;
141
+ }
142
+
143
+ // --- Names -------------------------------------------------------------------
144
+
145
+ /**
146
+ * Connect resource names are free text; FlowDoc names are slugs. Lowercase,
147
+ * runs of anything else collapse to a single hyphen, edges trimmed.
148
+ */
149
+ export function slugifyResourceName(name: string): string {
150
+ return name
151
+ .normalize("NFKD")
152
+ .replace(/[\u0300-\u036f]/g, "")
153
+ .toLowerCase()
154
+ .replace(/[^a-z0-9]+/g, "-")
155
+ .replace(/^-+|-+$/g, "");
156
+ }
157
+
158
+ // --- Inventory ---------------------------------------------------------------
159
+
160
+ /** One named Connect resource, as the List* operations return it. */
161
+ export interface ResourceSummary {
162
+ arn: string;
163
+ id?: string;
164
+ name: string;
165
+ }
166
+
167
+ export interface ContactFlowSummary extends ResourceSummary {
168
+ /** ListContactFlows names this field ContactFlowType, not Type. */
169
+ contactFlowType?: string;
170
+ contactFlowState?: string;
171
+ contactFlowStatus?: string;
172
+ }
173
+
174
+ export interface ContactFlowModuleSummary extends ResourceSummary {
175
+ state?: string;
176
+ }
177
+
178
+ /**
179
+ * ListBots returns V1 and V2 bots in one list and they are not symmetric: a V2
180
+ * bot yields an alias ARN and no name, a V1 bot yields a name and region and no
181
+ * ARN at all. Only an ARN can be reverse-mapped out of flow content.
182
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_ListBots.html
183
+ */
184
+ export interface LexBotSummary {
185
+ lexVersion: "V1" | "V2";
186
+ name?: string;
187
+ lexRegion?: string;
188
+ aliasArn?: string;
189
+ }
190
+
191
+ export interface InstanceInventory {
192
+ contactFlows: ContactFlowSummary[];
193
+ contactFlowModules: ContactFlowModuleSummary[];
194
+ queues: ResourceSummary[];
195
+ hoursOfOperations: ResourceSummary[];
196
+ prompts: ResourceSummary[];
197
+ /** Bare Lambda function ARNs, which is all ListLambdaFunctions returns. */
198
+ lambdaFunctions: string[];
199
+ lexBots: LexBotSummary[];
200
+ }
201
+
202
+ /** A described flow or module: the operation that carries the Flow language. */
203
+ export interface DescribedContactFlow {
204
+ arn: string;
205
+ id: string;
206
+ name: string;
207
+ /** DescribeContactFlow names these Type/State/Status, not ContactFlow*. */
208
+ type?: string;
209
+ state?: string;
210
+ status?: string;
211
+ version?: string;
212
+ /** The Flow language JSON, as a string. */
213
+ content: string;
214
+ contentSha256?: string;
215
+ }
216
+
217
+ export interface DescribedContactFlowModule extends DescribedContactFlow {
218
+ /**
219
+ * Two fields a ContactFlow has no analogue for. An exporter that means to
220
+ * round-trip modules has to carry both or it loses content.
221
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_DescribeContactFlowModule.html
222
+ */
223
+ settings?: string;
224
+ externalInvocationEnabled?: boolean;
225
+ }
226
+
227
+ /**
228
+ * The narrow seam every export path runs through. Implementations page and rate
229
+ * limit; this interface deals in complete lists.
230
+ */
231
+ export interface ConnectInventoryClient {
232
+ listContactFlows(contactFlowTypes?: readonly string[]): Promise<ContactFlowSummary[]>;
233
+ describeContactFlow(contactFlowId: string): Promise<DescribedContactFlow>;
234
+ listContactFlowModules(): Promise<ContactFlowModuleSummary[]>;
235
+ describeContactFlowModule(contactFlowModuleId: string): Promise<DescribedContactFlowModule>;
236
+ listQueues(): Promise<ResourceSummary[]>;
237
+ listHoursOfOperations(): Promise<ResourceSummary[]>;
238
+ listPrompts(): Promise<ResourceSummary[]>;
239
+ listLambdaFunctions(): Promise<string[]>;
240
+ listBots(): Promise<LexBotSummary[]>;
241
+ }
242
+
243
+ export interface CollectInventoryOptions {
244
+ /**
245
+ * ContactFlowTypes filter for ListContactFlows. Omitted means every type.
246
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_ListContactFlows.html
247
+ */
248
+ flowTypes?: readonly string[];
249
+ /** Set false to skip ListContactFlowModules and DescribeContactFlowModule. */
250
+ includeModules?: boolean;
251
+ }
252
+
253
+ /** Runs the nine list operations and assembles one inventory. */
254
+ export async function collectInventory(
255
+ client: ConnectInventoryClient,
256
+ options: CollectInventoryOptions = {},
257
+ ): Promise<InstanceInventory> {
258
+ const includeModules = options.includeModules !== false;
259
+ const [
260
+ contactFlows,
261
+ contactFlowModules,
262
+ queues,
263
+ hoursOfOperations,
264
+ prompts,
265
+ lambdaFunctions,
266
+ lexBots,
267
+ ] = await Promise.all([
268
+ client.listContactFlows(options.flowTypes),
269
+ includeModules ? client.listContactFlowModules() : Promise.resolve([]),
270
+ client.listQueues(),
271
+ client.listHoursOfOperations(),
272
+ client.listPrompts(),
273
+ client.listLambdaFunctions(),
274
+ client.listBots(),
275
+ ]);
276
+ return {
277
+ contactFlows,
278
+ contactFlowModules,
279
+ queues,
280
+ hoursOfOperations,
281
+ prompts,
282
+ lambdaFunctions,
283
+ lexBots,
284
+ };
285
+ }
286
+
287
+ // --- Reverse map -------------------------------------------------------------
288
+
289
+ export interface ReverseMap {
290
+ /** Normalized ARN to the ref entry that replaces it. */
291
+ byArn: ReadonlyMap<string, RefEntry>;
292
+ /** Names that collided, bots with no ARN, and anything else lossy. */
293
+ warnings: readonly string[];
294
+ }
295
+
296
+ interface Candidate {
297
+ arn: string;
298
+ type: RefType;
299
+ name: string;
300
+ alias?: string;
301
+ }
302
+
303
+ function makeRefEntry(type: RefType, name: string, alias?: string): RefEntry | undefined {
304
+ const suffix = alias === undefined ? "" : `@${alias}`;
305
+ return parseToken(`\${cdref:${type}:${name}${suffix}}`);
306
+ }
307
+
308
+ /**
309
+ * Assigns one slug per resource, per ref type, deterministically: candidates
310
+ * are sorted by (name, ARN) and a collision takes a numeric suffix. Renaming
311
+ * beats failing an entire instance export over two queues named "Sales" and
312
+ * "sales", and sorting first keeps the assignment stable across runs.
313
+ */
314
+ function assignNames(candidates: Candidate[], warnings: string[]): Map<string, RefEntry> {
315
+ const byArn = new Map<string, RefEntry>();
316
+ const taken = new Map<string, Set<string>>();
317
+ const sorted = [...candidates].sort((a, b) =>
318
+ a.type !== b.type
319
+ ? a.type < b.type
320
+ ? -1
321
+ : 1
322
+ : a.name !== b.name
323
+ ? a.name < b.name
324
+ ? -1
325
+ : 1
326
+ : a.arn < b.arn
327
+ ? -1
328
+ : a.arn > b.arn
329
+ ? 1
330
+ : 0,
331
+ );
332
+
333
+ for (const candidate of sorted) {
334
+ const arn = normalizeArn(candidate.arn);
335
+ if (byArn.has(arn)) continue;
336
+ const used = taken.get(candidate.type) ?? new Set<string>();
337
+ taken.set(candidate.type, used);
338
+
339
+ let name = candidate.name;
340
+ if (used.has(name)) {
341
+ let n = 2;
342
+ while (used.has(`${name}-${String(n)}`)) n += 1;
343
+ const renamed = `${name}-${String(n)}`;
344
+ warnings.push(
345
+ `Two ${candidate.type} resources slug to "${name}"; ${arn} exported as "${renamed}".`,
346
+ );
347
+ name = renamed;
348
+ }
349
+
350
+ const entry = makeRefEntry(candidate.type, name, candidate.alias);
351
+ if (entry === undefined) {
352
+ warnings.push(`Cannot name ${candidate.type} ${arn}: "${name}" is not a valid slug.`);
353
+ continue;
354
+ }
355
+ used.add(name);
356
+ byArn.set(arn, entry);
357
+ }
358
+ return byArn;
359
+ }
360
+
361
+ function candidateName(
362
+ raw: string,
363
+ arn: string,
364
+ type: RefType,
365
+ warnings: string[],
366
+ ): string | undefined {
367
+ const slug = slugifyResourceName(raw);
368
+ if (SLUG_PATTERN.test(slug)) return slug;
369
+ warnings.push(`Skipping ${type} ${arn}: name "${raw}" has no slug form.`);
370
+ return undefined;
371
+ }
372
+
373
+ /**
374
+ * ARN to ref entry for one instance. Every ARN a flow can hold gets an entry,
375
+ * so anything left over after export is genuinely unknown and is reported.
376
+ */
377
+ export function buildReverseMap(inventory: InstanceInventory): ReverseMap {
378
+ const warnings: string[] = [];
379
+ const candidates: Candidate[] = [];
380
+
381
+ const add = (arn: string | undefined, rawName: string | undefined, type: RefType) => {
382
+ if (arn === undefined || arn === "" || rawName === undefined) return;
383
+ const name = candidateName(rawName, arn, type, warnings);
384
+ if (name === undefined) return;
385
+ candidates.push({ arn, type, name });
386
+ };
387
+
388
+ for (const q of inventory.queues) add(q.arn, q.name, "queue");
389
+ for (const h of inventory.hoursOfOperations) add(h.arn, h.name, "hours");
390
+ for (const p of inventory.prompts) add(p.arn, p.name, "prompt");
391
+ for (const f of inventory.contactFlows) add(f.arn, f.name, "flow");
392
+ for (const m of inventory.contactFlowModules) add(m.arn, m.name, "module");
393
+
394
+ for (const arn of inventory.lambdaFunctions) {
395
+ const fn = parseLambdaFunctionArn(arn);
396
+ if (fn === undefined) {
397
+ warnings.push(`Skipping Lambda ARN with no function segment: ${arn}`);
398
+ continue;
399
+ }
400
+ add(arn, fn, "lambda");
401
+ }
402
+
403
+ for (const bot of inventory.lexBots) {
404
+ if (bot.aliasArn === undefined || bot.aliasArn === "") {
405
+ // A V1 bot has no ARN at all, so nothing in flow content can be matched
406
+ // back to it. Flow content references V1 bots by name and region rather
407
+ // than by ARN, so this costs nothing unless a V1 alias ARN turns up.
408
+ warnings.push(
409
+ `Amazon Lex ${bot.lexVersion} bot "${bot.name ?? "(unnamed)"}" has no ARN and is not reverse-mapped.`,
410
+ );
411
+ continue;
412
+ }
413
+ // ListBots gives a V2 bot an AliasArn and no name, so the slug comes from
414
+ // the ARN's own identifiers unless the entry carries a name.
415
+ const raw = bot.name ?? bot.aliasArn.split(":").slice(5).join("-");
416
+ add(bot.aliasArn, raw, "lex");
417
+ }
418
+
419
+ return { byArn: assignNames(candidates, warnings), warnings };
420
+ }
421
+
422
+ /**
423
+ * Reverse map from a materialization resource map (token to value). The
424
+ * offline half of export: it turns the same file that materializes a FlowDoc
425
+ * into the map that exports the result back, which is what makes the
426
+ * round-trip property testable with no instance.
427
+ */
428
+ export function reverseMapOfResourceMap(resourceMap: Record<string, string>): ReverseMap {
429
+ const warnings: string[] = [];
430
+ const byArn = new Map<string, RefEntry>();
431
+ for (const [token, value] of Object.entries(resourceMap).sort(([a], [b]) => (a < b ? -1 : 1))) {
432
+ const entry = parseToken(token);
433
+ if (entry === undefined) {
434
+ warnings.push(`Skipping map key that is not a reference token: ${token}`);
435
+ continue;
436
+ }
437
+ const arn = normalizeArn(value);
438
+ const existing = byArn.get(arn);
439
+ if (existing !== undefined) {
440
+ warnings.push(`${arn} is mapped by both ${existing.token} and ${token}; keeping the first.`);
441
+ continue;
442
+ }
443
+ byArn.set(arn, entry);
444
+ }
445
+ return { byArn, warnings };
446
+ }
447
+
448
+ /** Reverse-map lookup, tolerant of a `:$SAVED` or version qualifier. */
449
+ export function lookupArn(reverseMap: ReverseMap, arn: string): RefEntry | undefined {
450
+ return reverseMap.byArn.get(arn) ?? reverseMap.byArn.get(normalizeArn(arn));
451
+ }
452
+
453
+ // --- exportFlow --------------------------------------------------------------
454
+
455
+ /**
456
+ * The account segment of an ARN: digits for a customer resource, the literal
457
+ * `aws` for an AWS-managed one. The stock "Sample after contact work flow" on a
458
+ * fresh instance references the view
459
+ * `arn:aws:connect:<region>:aws:view/after-contact-work:1` (observed live
460
+ * 2026-09-01), and ListViews documents AWS_MANAGED views beside
461
+ * CUSTOMER_MANAGED ones. Digits alone let that ARN through as prose, which put
462
+ * a literal ARN in an exported FlowDoc (FlowDoc invariant 4) and past the lint
463
+ * rule that fails on it. There is no view ref type yet, so it is reported as
464
+ * an unknown ARN instead.
465
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_ListViews.html
466
+ */
467
+ const ARN_ACCOUNT = "(?:[0-9]*|aws)";
468
+ /** A whole field value that is an ARN, which is the only replaceable shape. */
469
+ const WHOLE_ARN = new RegExp(
470
+ String.raw`^arn:aws[a-z0-9-]*:[a-z0-9-]*:[a-z0-9-]*:${ARN_ACCOUNT}:\S+$`,
471
+ );
472
+ /** An ARN anywhere inside a longer string, which is never replaceable. */
473
+ const ARN_ANYWHERE = new RegExp(
474
+ String.raw`arn:aws[a-z0-9-]*:[a-z0-9-]*:[a-z0-9-]*:${ARN_ACCOUNT}:[^\s"']+`,
475
+ "g",
476
+ );
477
+
478
+ /**
479
+ * Export failed. Both lists are complete and sorted, so a caller fixes the
480
+ * inventory once rather than one ARN per run. Mirrors MaterializeError.
481
+ */
482
+ export class ExportError extends Error {
483
+ /** ARNs occupying a whole field with no entry in the reverse map. */
484
+ readonly unknownArns: readonly string[];
485
+ /**
486
+ * ARNs embedded in a longer string. A reference occupies an entire field
487
+ * value (FlowDoc invariant 4), so these cannot become tokens at all.
488
+ */
489
+ readonly interpolatedArns: readonly string[];
490
+ /** ARN to the content paths it was found at, for locating each one. */
491
+ readonly locations: Readonly<Record<string, readonly string[]>>;
492
+ /**
493
+ * The FlowDoc name being exported, when the caller knew it. A whole-instance
494
+ * export reads many flows through one call, so without this the message names
495
+ * an ARN and leaves the operator to find which flow holds it.
496
+ */
497
+ readonly resource?: string;
498
+
499
+ constructor(
500
+ unknownArns: readonly string[],
501
+ interpolatedArns: readonly string[],
502
+ locations: Readonly<Record<string, readonly string[]>>,
503
+ resource?: string,
504
+ ) {
505
+ const parts: string[] = [];
506
+ if (unknownArns.length > 0) {
507
+ parts.push(
508
+ `${String(unknownArns.length)} ARN(s) not found in the instance inventory: ` +
509
+ unknownArns.join(", "),
510
+ );
511
+ }
512
+ if (interpolatedArns.length > 0) {
513
+ parts.push(
514
+ `${String(interpolatedArns.length)} ARN(s) embedded in a longer string, which cannot hold a reference: ` +
515
+ interpolatedArns.join(", "),
516
+ );
517
+ }
518
+ super(`Cannot export${resource === undefined ? "" : ` "${resource}"`}: ${parts.join("; ")}`);
519
+ this.name = "ExportError";
520
+ this.unknownArns = unknownArns;
521
+ this.interpolatedArns = interpolatedArns;
522
+ this.locations = locations;
523
+ if (resource !== undefined) this.resource = resource;
524
+ }
525
+ }
526
+
527
+ interface RewriteAccumulator {
528
+ unknown: Map<string, string[]>;
529
+ interpolated: Map<string, string[]>;
530
+ }
531
+
532
+ function record(into: Map<string, string[]>, arn: string, path: string): void {
533
+ const paths = into.get(arn);
534
+ if (paths === undefined) into.set(arn, [path]);
535
+ else paths.push(path);
536
+ }
537
+
538
+ function rewriteArns(
539
+ value: unknown,
540
+ path: string,
541
+ reverseMap: ReverseMap,
542
+ acc: RewriteAccumulator,
543
+ ): unknown {
544
+ if (typeof value === "string") {
545
+ if (WHOLE_ARN.test(value)) {
546
+ const entry = lookupArn(reverseMap, value);
547
+ if (entry !== undefined) return entry.token;
548
+ record(acc.unknown, value, path);
549
+ return value;
550
+ }
551
+ for (const match of value.match(ARN_ANYWHERE) ?? []) record(acc.interpolated, match, path);
552
+ return value;
553
+ }
554
+ if (Array.isArray(value)) {
555
+ return value.map((v, i) => rewriteArns(v, `${path}[${String(i)}]`, reverseMap, acc));
556
+ }
557
+ if (value !== null && typeof value === "object") {
558
+ return Object.fromEntries(
559
+ Object.entries(value as Record<string, unknown>).map(([k, v]) => [
560
+ k,
561
+ rewriteArns(v, path === "" ? k : `${path}.${k}`, reverseMap, acc),
562
+ ]),
563
+ );
564
+ }
565
+ return value;
566
+ }
567
+
568
+ function isRecord(value: unknown): value is Record<string, unknown> {
569
+ return value !== null && typeof value === "object" && !Array.isArray(value);
570
+ }
571
+
572
+ /**
573
+ * Connect omits `Parameters` from an action that takes none, rather than
574
+ * writing an empty map. Observed live: of the 20 stock sample flows on a fresh
575
+ * instance, 12 actions across 7 flows (`TransferContactToQueue` and
576
+ * `DistributeByPercentage`) came back with no `Parameters` key at all, while 28
577
+ * other parameterless actions in the same flows carried `"Parameters": {}`. So
578
+ * both spellings are live output and mean the same thing.
579
+ *
580
+ * FlowDoc requires the key on every action
581
+ * (conformance/schema/flowdoc-0.1.schema.json, `$defs.action.required`), so
582
+ * passing the omission through produced a schema-invalid document, and codegen
583
+ * read `Object.keys(a.Parameters)` straight off it and threw
584
+ * "Cannot convert undefined or null to object". Filling in the empty map
585
+ * Connect means by the absence is not a content change: materializing the
586
+ * result re-emits `"Parameters": {}`, the form the console itself writes.
587
+ *
588
+ * `Transitions` is normalized the same way. Connect writes `{}` for a terminal
589
+ * action rather than omitting the key, so this half is defensive, but
590
+ * canonicalization reads through `Transitions` unconditionally and an absent
591
+ * one would crash serialization instead of degrading.
592
+ */
593
+ function normalizeAction(action: FlowAction): FlowAction {
594
+ if (isRecord(action.Parameters) && isRecord(action.Transitions)) return action;
595
+ return {
596
+ ...action,
597
+ Parameters: isRecord(action.Parameters) ? action.Parameters : {},
598
+ Transitions: isRecord(action.Transitions) ? action.Transitions : {},
599
+ };
600
+ }
601
+
602
+ function isPoint(value: unknown): value is Point {
603
+ return (
604
+ value !== null &&
605
+ typeof value === "object" &&
606
+ typeof (value as Point).x === "number" &&
607
+ typeof (value as Point).y === "number"
608
+ );
609
+ }
610
+
611
+ /**
612
+ * Pulls positions out of content.Metadata and into `layout`, which is FlowDoc's
613
+ * single source of truth for position (docs/01-flowdoc-spec.md). Everything
614
+ * else Metadata carries stays in content: materialization preserves it, and
615
+ * dropping it would lose console state we never modeled.
616
+ *
617
+ * The Flow language example writes `Position`; console exports have also been
618
+ * seen writing `position`, so both are lifted and only `Position` is written
619
+ * back by materialization.
620
+ * https://docs.aws.amazon.com/connect/latest/devguide/flow-language-example.html
621
+ */
622
+ function liftMetadata(
623
+ metadata: unknown,
624
+ actionIds: ReadonlySet<string>,
625
+ ): { layout: Record<string, Point>; rest?: Record<string, unknown> } {
626
+ const layout: Record<string, Point> = {};
627
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
628
+ return { layout };
629
+ }
630
+ const rest: Record<string, unknown> = { ...(metadata as Record<string, unknown>) };
631
+ delete rest.EntryPointPosition;
632
+ delete rest.entryPointPosition;
633
+
634
+ const rawActionMetadata = rest.ActionMetadata;
635
+ if (
636
+ rawActionMetadata !== null &&
637
+ typeof rawActionMetadata === "object" &&
638
+ !Array.isArray(rawActionMetadata)
639
+ ) {
640
+ const remaining: Record<string, unknown> = {};
641
+ for (const [id, raw] of Object.entries(rawActionMetadata as Record<string, unknown>)) {
642
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
643
+ remaining[id] = raw;
644
+ continue;
645
+ }
646
+ const entry: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
647
+ const position = entry.Position ?? entry.position;
648
+ if (actionIds.has(id) && isPoint(position)) {
649
+ layout[id] = { x: position.x, y: position.y };
650
+ delete entry.Position;
651
+ delete entry.position;
652
+ }
653
+ if (Object.keys(entry).length > 0) remaining[id] = entry;
654
+ }
655
+ if (Object.keys(remaining).length > 0) rest.ActionMetadata = remaining;
656
+ else delete rest.ActionMetadata;
657
+ }
658
+
659
+ return Object.keys(rest).length > 0 ? { layout, rest } : { layout };
660
+ }
661
+
662
+ export interface ExportFlowOptions {
663
+ /** FlowDoc name. Must be a slug; use slugifyResourceName on a console name. */
664
+ name: string;
665
+ connectType: ConnectType;
666
+ /** Defaults to "module" when connectType is MODULE, "flow" otherwise. */
667
+ kind?: "flow" | "module";
668
+ /** Recorded in meta.generator. Defaults to the package identity. */
669
+ generator?: string;
670
+ /** Merged into meta, after generator. */
671
+ meta?: FlowDocMeta;
672
+ /** Set false to emit no `meta` block. */
673
+ includeMeta?: boolean;
674
+ }
675
+
676
+ /**
677
+ * Live Flow language in, FlowDoc out. Every ARN occupying a whole field value
678
+ * becomes its `${cdref:type:name}` token; an ARN with no reverse-map entry is a
679
+ * hard error listing every unknown ARN at once, per SPEC.md.
680
+ */
681
+ export function exportFlow(
682
+ content: string | FlowContent | Record<string, unknown>,
683
+ reverseMap: ReverseMap,
684
+ options: ExportFlowOptions,
685
+ ): FlowDoc {
686
+ const parsed: unknown = typeof content === "string" ? JSON.parse(content) : content;
687
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
688
+ throw new Error("Cannot export: flow content is not a JSON object.");
689
+ }
690
+ const raw = parsed as Record<string, unknown>;
691
+ if (raw.Version !== FLOW_LANGUAGE_VERSION) {
692
+ throw new Error(
693
+ `Cannot export: flow content Version is ${JSON.stringify(raw.Version)}, expected "${FLOW_LANGUAGE_VERSION}".`,
694
+ );
695
+ }
696
+ if (typeof raw.StartAction !== "string" || raw.StartAction === "") {
697
+ throw new Error("Cannot export: flow content has no StartAction.");
698
+ }
699
+ if (!Array.isArray(raw.Actions) || raw.Actions.length === 0) {
700
+ throw new Error("Cannot export: flow content has no Actions.");
701
+ }
702
+ if (!SLUG_PATTERN.test(options.name)) {
703
+ throw new Error(
704
+ `Cannot export: "${options.name}" is not a valid FlowDoc name. Names are lowercase words separated by single hyphens.`,
705
+ );
706
+ }
707
+
708
+ const acc: RewriteAccumulator = { unknown: new Map(), interpolated: new Map() };
709
+ const actions = (
710
+ rewriteArns(raw.Actions, "Actions", reverseMap, acc) as FlowContent["Actions"]
711
+ ).map(normalizeAction);
712
+ const metadata = rewriteArns(raw.Metadata, "Metadata", reverseMap, acc);
713
+
714
+ if (acc.unknown.size > 0 || acc.interpolated.size > 0) {
715
+ const sorted = (m: Map<string, string[]>) => [...m.keys()].sort();
716
+ const locations: Record<string, readonly string[]> = {};
717
+ for (const [arn, paths] of [...acc.unknown, ...acc.interpolated]) locations[arn] = paths;
718
+ throw new ExportError(sorted(acc.unknown), sorted(acc.interpolated), locations, options.name);
719
+ }
720
+
721
+ const actionIds = new Set(actions.map((a) => a.Identifier));
722
+ const lifted = liftMetadata(metadata, actionIds);
723
+
724
+ const flowContent: FlowContent = {
725
+ Version: FLOW_LANGUAGE_VERSION,
726
+ StartAction: raw.StartAction,
727
+ Actions: actions,
728
+ };
729
+ if (lifted.rest !== undefined) flowContent.Metadata = lifted.rest;
730
+
731
+ // Actions the instance never gave a position get the same deterministic
732
+ // auto-layout synth would have assigned, so the studio can open the result.
733
+ const auto = Object.values(lifted.layout).length === actions.length ? {} : autoLayout(actions);
734
+ const layout: Record<string, Point> = {};
735
+ for (const action of actions) {
736
+ layout[action.Identifier] = lifted.layout[action.Identifier] ??
737
+ auto[action.Identifier] ?? { x: 0, y: 0 };
738
+ }
739
+
740
+ const doc: FlowDoc = {
741
+ flowdoc: FLOWDOC_VERSION,
742
+ kind: options.kind ?? (options.connectType === "MODULE" ? "module" : "flow"),
743
+ name: options.name,
744
+ connectType: options.connectType,
745
+ content: flowContent,
746
+ layout,
747
+ refs: collectRefs(flowContent),
748
+ };
749
+
750
+ if (options.includeMeta !== false) {
751
+ doc.meta = {
752
+ generator: options.generator ?? `core@${FLOWDOC_VERSION}`,
753
+ ...options.meta,
754
+ };
755
+ }
756
+ return canonicalize(doc);
757
+ }
758
+
759
+ // --- exportInstance ----------------------------------------------------------
760
+
761
+ /** ContactFlowType values FlowDoc models. CAMPAIGN has no FlowDoc connectType. */
762
+ const EXPORTABLE_FLOW_TYPES: ReadonlySet<string> = new Set<ConnectType>([
763
+ "CONTACT_FLOW",
764
+ "CUSTOMER_QUEUE",
765
+ "CUSTOMER_HOLD",
766
+ "CUSTOMER_WHISPER",
767
+ "AGENT_HOLD",
768
+ "AGENT_WHISPER",
769
+ "OUTBOUND_WHISPER",
770
+ "AGENT_TRANSFER",
771
+ "QUEUE_TRANSFER",
772
+ ]);
773
+
774
+ export interface ExportedFlow {
775
+ arn: string;
776
+ id: string;
777
+ /** The name as the instance spells it, before slugging. */
778
+ sourceName: string;
779
+ doc: FlowDoc;
780
+ /** Present when options.codegen is set. */
781
+ code?: string;
782
+ /** Set when the flow was read through the $SAVED alias. */
783
+ saved?: boolean;
784
+ }
785
+
786
+ export interface ExportFailure {
787
+ arn: string;
788
+ name: string;
789
+ reason: string;
790
+ unknownArns?: readonly string[];
791
+ }
792
+
793
+ export interface ExportInstanceResult {
794
+ inventory: InstanceInventory;
795
+ reverseMap: ReverseMap;
796
+ flows: ExportedFlow[];
797
+ warnings: string[];
798
+ failures: ExportFailure[];
799
+ }
800
+
801
+ export interface ExportInstanceOptions extends CollectInventoryOptions {
802
+ /**
803
+ * Read never-published flows through the `$SAVED` alias instead of failing.
804
+ * DescribeContactFlow throws ContactFlowNotPublishedException for a flow that
805
+ * has never been published, and documents `$SAVED` as the way to read the
806
+ * saved content. Default true.
807
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_DescribeContactFlow.html
808
+ */
809
+ savedFallback?: boolean;
810
+ /**
811
+ * "throw" (default, and what SPEC.md specifies) aborts the export on the
812
+ * first flow that cannot be exported. "collect" records it in `failures` and
813
+ * keeps going, which is what a first look at an unfamiliar instance wants.
814
+ */
815
+ onError?: "throw" | "collect";
816
+ /** Emit TypeScript beside each FlowDoc. `true` uses codegen defaults. */
817
+ codegen?: boolean | CodegenOptions;
818
+ /** Recorded in meta.generator on every exported doc. */
819
+ generator?: string;
820
+ /** Pre-supplied inventory, to export twice without listing twice. */
821
+ inventory?: InstanceInventory;
822
+ }
823
+
824
+ function isNotPublished(error: unknown): boolean {
825
+ return (
826
+ error !== null &&
827
+ typeof error === "object" &&
828
+ (error as { name?: unknown }).name === "ContactFlowNotPublishedException"
829
+ );
830
+ }
831
+
832
+ /**
833
+ * Whole-instance export: inventory, reverse map, then every flow and module.
834
+ * Nothing here is Connect-version specific beyond the client interface, so the
835
+ * offline fixtures exercise exactly the code a live run does.
836
+ */
837
+ export async function exportInstance(
838
+ client: ConnectInventoryClient,
839
+ options: ExportInstanceOptions = {},
840
+ ): Promise<ExportInstanceResult> {
841
+ const inventory = options.inventory ?? (await collectInventory(client, options));
842
+ const reverseMap = buildReverseMap(inventory);
843
+ const warnings = [...reverseMap.warnings];
844
+ const failures: ExportFailure[] = [];
845
+ const flows: ExportedFlow[] = [];
846
+ const codegenOptions: CodegenOptions | undefined =
847
+ options.codegen === undefined || options.codegen === false
848
+ ? undefined
849
+ : options.codegen === true
850
+ ? {}
851
+ : options.codegen;
852
+
853
+ const fail = (arn: string, name: string, error: unknown): void => {
854
+ if (options.onError !== "collect") throw error;
855
+ const failure: ExportFailure = {
856
+ arn,
857
+ name,
858
+ reason: error instanceof Error ? error.message : String(error),
859
+ };
860
+ if (error instanceof ExportError) failure.unknownArns = error.unknownArns;
861
+ failures.push(failure);
862
+ };
863
+
864
+ const describe = async (
865
+ id: string,
866
+ read: (id: string) => Promise<DescribedContactFlow>,
867
+ ): Promise<{ described: DescribedContactFlow; saved: boolean }> => {
868
+ try {
869
+ return { described: await read(id), saved: false };
870
+ } catch (error) {
871
+ if (options.savedFallback === false || !isNotPublished(error)) throw error;
872
+ // "Use the $SAVED alias in the request to describe the SAVED content of a
873
+ // Flow." Never-published flows are otherwise a 404 for the exporter.
874
+ return { described: await read(`${id}:$SAVED`), saved: true };
875
+ }
876
+ };
877
+
878
+ const emit = (
879
+ summary: ResourceSummary,
880
+ described: DescribedContactFlow,
881
+ connectType: ConnectType,
882
+ saved: boolean,
883
+ ): void => {
884
+ const entry = lookupArn(reverseMap, summary.arn);
885
+ if (entry === undefined) {
886
+ throw new Error(
887
+ `Cannot export ${summary.arn}: the instance inventory has no entry for it, so it has no name.`,
888
+ );
889
+ }
890
+ const instanceId = parseConnectArn(summary.arn)?.instanceId;
891
+ const doc = exportFlow(described.content, reverseMap, {
892
+ name: entry.name,
893
+ connectType,
894
+ generator: options.generator,
895
+ meta: {
896
+ // Provenance, deliberately without the ARN. An exported FlowDoc is an
897
+ // authored document and gets committed to a repository, so it must not
898
+ // carry a literal ARN anywhere, meta included; that would pin it to one
899
+ // account and leak the account id. The instance id and resource id say
900
+ // where it came from without either.
901
+ source: {
902
+ ...(instanceId === undefined ? {} : { instanceId }),
903
+ id: described.id,
904
+ name: described.name,
905
+ ...(described.version === undefined ? {} : { version: described.version }),
906
+ ...(described.contentSha256 === undefined
907
+ ? {}
908
+ : { contentSha256: described.contentSha256 }),
909
+ ...(saved ? { alias: "$SAVED" } : {}),
910
+ },
911
+ },
912
+ });
913
+ const exported: ExportedFlow = {
914
+ arn: summary.arn,
915
+ id: described.id,
916
+ sourceName: described.name,
917
+ doc,
918
+ };
919
+ if (saved) exported.saved = true;
920
+ if (codegenOptions !== undefined) exported.code = codegen(doc, codegenOptions);
921
+ flows.push(exported);
922
+ };
923
+
924
+ for (const summary of [...inventory.contactFlows].sort((a, b) => (a.arn < b.arn ? -1 : 1))) {
925
+ const type = summary.contactFlowType ?? "CONTACT_FLOW";
926
+ if (!EXPORTABLE_FLOW_TYPES.has(type)) {
927
+ warnings.push(`Skipping ${summary.arn}: ContactFlowType ${type} has no FlowDoc connectType.`);
928
+ continue;
929
+ }
930
+ try {
931
+ const { described, saved } = await describe(summary.id ?? summary.arn, (id) =>
932
+ client.describeContactFlow(id),
933
+ );
934
+ emit(summary, described, type as ConnectType, saved);
935
+ } catch (error) {
936
+ fail(summary.arn, summary.name, error);
937
+ }
938
+ }
939
+
940
+ for (const summary of [...inventory.contactFlowModules].sort((a, b) =>
941
+ a.arn < b.arn ? -1 : 1,
942
+ )) {
943
+ try {
944
+ const { described, saved } = await describe(summary.id ?? summary.arn, (id) =>
945
+ client.describeContactFlowModule(id),
946
+ );
947
+ const module = described as DescribedContactFlowModule;
948
+ if (module.settings !== undefined || module.externalInvocationEnabled !== undefined) {
949
+ // Neither field has a FlowDoc home yet. Warn rather than drop silently.
950
+ warnings.push(
951
+ `Module ${summary.arn} carries Settings or ExternalInvocationConfiguration, which FlowDoc does not model; they are not exported.`,
952
+ );
953
+ }
954
+ emit(summary, described, "MODULE", saved);
955
+ } catch (error) {
956
+ fail(summary.arn, summary.name, error);
957
+ }
958
+ }
959
+
960
+ return { inventory, reverseMap, flows, warnings, failures };
961
+ }
962
+
963
+ // --- AWS SDK adapter ---------------------------------------------------------
964
+ // Everything above is SDK-free. This is the only code that loads
965
+ // @aws-sdk/client-connect, and it does so with a dynamic import inside a
966
+ // function, so the optional peer dependency is required only by a caller that
967
+ // actually connects to an instance.
968
+
969
+ interface ConnectCommands {
970
+ ListContactFlowsCommand: new (input: any) => any;
971
+ DescribeContactFlowCommand: new (input: any) => any;
972
+ ListContactFlowModulesCommand: new (input: any) => any;
973
+ DescribeContactFlowModuleCommand: new (input: any) => any;
974
+ ListQueuesCommand: new (input: any) => any;
975
+ ListHoursOfOperationsCommand: new (input: any) => any;
976
+ ListPromptsCommand: new (input: any) => any;
977
+ ListLambdaFunctionsCommand: new (input: any) => any;
978
+ ListBotsCommand: new (input: any) => any;
979
+ }
980
+
981
+ async function loadConnectCommands(): Promise<ConnectCommands> {
982
+ try {
983
+ return (await import("@aws-sdk/client-connect")) as unknown as ConnectCommands;
984
+ } catch (cause) {
985
+ throw new Error(
986
+ "Connecting to an instance needs the optional peer dependency @aws-sdk/client-connect. Install it, or use the offline paths (exportFlow with reverseMapOfResourceMap).",
987
+ { cause },
988
+ );
989
+ }
990
+ }
991
+
992
+ export interface ConnectClientOptions extends RateLimiterOptions {
993
+ /** An @aws-sdk/client-connect ConnectClient, or anything with `send`. */
994
+ connect: AwsCommandSender;
995
+ /** Instance id or instance ARN; both are accepted by every operation. */
996
+ instanceId: string;
997
+ /** Page size for the Connect resource lists. Their maximum is 1000. */
998
+ maxResults?: number;
999
+ }
1000
+
1001
+ /**
1002
+ * Builds a ConnectInventoryClient over the AWS SDK: pagination, the 2 rps
1003
+ * throttle budget, and the response-shape differences between the list and
1004
+ * describe operations all live here so nothing above has to know them.
1005
+ */
1006
+ export function createConnectInventoryClient(
1007
+ options: ConnectClientOptions,
1008
+ ): ConnectInventoryClient {
1009
+ const { connect, instanceId } = options;
1010
+ const maxResults = options.maxResults ?? 1000;
1011
+ let commands: ConnectCommands | undefined;
1012
+ const throttle = createRateLimiter(options);
1013
+
1014
+ const send = async (make: (c: ConnectCommands) => any): Promise<any> => {
1015
+ commands ??= await loadConnectCommands();
1016
+ await throttle();
1017
+ return connect.send(make(commands));
1018
+ };
1019
+
1020
+ /** Every list operation here pages the same way: opaque NextToken, query string. */
1021
+ const paginate = async <T>(
1022
+ make: (c: ConnectCommands, nextToken: string | undefined) => any,
1023
+ pick: (response: any) => T[] | undefined,
1024
+ ): Promise<T[]> => {
1025
+ const out: T[] = [];
1026
+ let nextToken: string | undefined;
1027
+ do {
1028
+ const response = await send((c) => make(c, nextToken));
1029
+ out.push(...(pick(response) ?? []));
1030
+ nextToken =
1031
+ response.NextToken === "" ? undefined : (response.NextToken as string | undefined);
1032
+ } while (nextToken !== undefined);
1033
+ return out;
1034
+ };
1035
+
1036
+ const summary = (s: { Arn?: string; Id?: string; Name?: string }): ResourceSummary => ({
1037
+ arn: s.Arn ?? "",
1038
+ ...(s.Id === undefined ? {} : { id: s.Id }),
1039
+ name: s.Name ?? "",
1040
+ });
1041
+
1042
+ const described = (f: any): DescribedContactFlow => ({
1043
+ arn: f.Arn ?? "",
1044
+ id: f.Id ?? "",
1045
+ name: f.Name ?? "",
1046
+ type: f.Type,
1047
+ state: f.State,
1048
+ status: f.Status,
1049
+ version: f.Version === undefined ? undefined : String(f.Version),
1050
+ content: f.Content ?? "",
1051
+ contentSha256: f.FlowContentSha256 ?? f.FlowModuleContentSha256,
1052
+ });
1053
+
1054
+ return {
1055
+ listContactFlows: (contactFlowTypes) =>
1056
+ paginate<ContactFlowSummary>(
1057
+ (c, nextToken) =>
1058
+ new c.ListContactFlowsCommand({
1059
+ InstanceId: instanceId,
1060
+ MaxResults: maxResults,
1061
+ NextToken: nextToken,
1062
+ ...(contactFlowTypes === undefined ? {} : { ContactFlowTypes: [...contactFlowTypes] }),
1063
+ }),
1064
+ (r) =>
1065
+ (r.ContactFlowSummaryList ?? []).map((s: any) => ({
1066
+ ...summary(s),
1067
+ contactFlowType: s.ContactFlowType,
1068
+ contactFlowState: s.ContactFlowState,
1069
+ contactFlowStatus: s.ContactFlowStatus,
1070
+ })),
1071
+ ),
1072
+
1073
+ describeContactFlow: async (contactFlowId) => {
1074
+ const response = await send(
1075
+ (c) =>
1076
+ new c.DescribeContactFlowCommand({
1077
+ InstanceId: instanceId,
1078
+ ContactFlowId: contactFlowId,
1079
+ }),
1080
+ );
1081
+ return described(response.ContactFlow ?? {});
1082
+ },
1083
+
1084
+ listContactFlowModules: () =>
1085
+ paginate<ContactFlowModuleSummary>(
1086
+ (c, nextToken) =>
1087
+ new c.ListContactFlowModulesCommand({
1088
+ InstanceId: instanceId,
1089
+ MaxResults: maxResults,
1090
+ NextToken: nextToken,
1091
+ }),
1092
+ // Note the plural: ContactFlowModulesSummaryList, not ...ModuleSummaryList.
1093
+ (r) =>
1094
+ (r.ContactFlowModulesSummaryList ?? []).map((s: any) => ({
1095
+ ...summary(s),
1096
+ state: s.State,
1097
+ })),
1098
+ ),
1099
+
1100
+ describeContactFlowModule: async (contactFlowModuleId) => {
1101
+ const response = await send(
1102
+ (c) =>
1103
+ new c.DescribeContactFlowModuleCommand({
1104
+ InstanceId: instanceId,
1105
+ ContactFlowModuleId: contactFlowModuleId,
1106
+ }),
1107
+ );
1108
+ const module = response.ContactFlowModule ?? {};
1109
+ return {
1110
+ ...described(module),
1111
+ settings: module.Settings,
1112
+ externalInvocationEnabled: module.ExternalInvocationConfiguration?.Enabled,
1113
+ };
1114
+ },
1115
+
1116
+ listQueues: () =>
1117
+ paginate<ResourceSummary>(
1118
+ (c, nextToken) =>
1119
+ // QueueTypes is deliberate: without it agent queues come back too,
1120
+ // which are per-user and can truncate the page past 1000 agents.
1121
+ new c.ListQueuesCommand({
1122
+ InstanceId: instanceId,
1123
+ QueueTypes: ["STANDARD"],
1124
+ MaxResults: maxResults,
1125
+ NextToken: nextToken,
1126
+ }),
1127
+ (r) => (r.QueueSummaryList ?? []).map(summary),
1128
+ ),
1129
+
1130
+ listHoursOfOperations: () =>
1131
+ paginate<ResourceSummary>(
1132
+ (c, nextToken) =>
1133
+ new c.ListHoursOfOperationsCommand({
1134
+ InstanceId: instanceId,
1135
+ MaxResults: maxResults,
1136
+ NextToken: nextToken,
1137
+ }),
1138
+ (r) => (r.HoursOfOperationSummaryList ?? []).map(summary),
1139
+ ),
1140
+
1141
+ listPrompts: () =>
1142
+ paginate<ResourceSummary>(
1143
+ (c, nextToken) =>
1144
+ new c.ListPromptsCommand({
1145
+ InstanceId: instanceId,
1146
+ MaxResults: maxResults,
1147
+ NextToken: nextToken,
1148
+ }),
1149
+ (r) => (r.PromptSummaryList ?? []).map(summary),
1150
+ ),
1151
+
1152
+ // MaxResults maxes out at 25 here, not 1000, and the response is a bare
1153
+ // array of Lambda ARNs with no ids or names.
1154
+ listLambdaFunctions: () =>
1155
+ paginate<string>(
1156
+ (c, nextToken) =>
1157
+ new c.ListLambdaFunctionsCommand({
1158
+ InstanceId: instanceId,
1159
+ MaxResults: 25,
1160
+ NextToken: nextToken,
1161
+ }),
1162
+ (r) => r.LambdaFunctions ?? [],
1163
+ ),
1164
+
1165
+ // lexVersion is required, so a full inventory is two paginated passes.
1166
+ listBots: async () => {
1167
+ const out: LexBotSummary[] = [];
1168
+ for (const lexVersion of ["V1", "V2"] as const) {
1169
+ const configs = await paginate<any>(
1170
+ (c, nextToken) =>
1171
+ new c.ListBotsCommand({
1172
+ InstanceId: instanceId,
1173
+ LexVersion: lexVersion,
1174
+ MaxResults: 25,
1175
+ NextToken: nextToken,
1176
+ }),
1177
+ (r) => r.LexBots ?? [],
1178
+ );
1179
+ for (const config of configs) {
1180
+ const bot: LexBotSummary = { lexVersion };
1181
+ if (config.LexBot?.Name !== undefined) bot.name = config.LexBot.Name;
1182
+ if (config.LexBot?.LexRegion !== undefined) bot.lexRegion = config.LexBot.LexRegion;
1183
+ if (config.LexV2Bot?.AliasArn !== undefined) bot.aliasArn = config.LexV2Bot.AliasArn;
1184
+ out.push(bot);
1185
+ }
1186
+ }
1187
+ return out;
1188
+ },
1189
+ };
1190
+ }