@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/dist/export.js ADDED
@@ -0,0 +1,799 @@
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
+ import { createRateLimiter } from "./aws.js";
31
+ import { codegen } from "./codegen.js";
32
+ import { FLOW_LANGUAGE_VERSION, FLOWDOC_VERSION, SLUG_PATTERN } from "./flowdoc.js";
33
+ import { autoLayout } from "./layout.js";
34
+ import { collectRefs, parseToken } from "./refs.js";
35
+ import { canonicalize } from "./serialize.js";
36
+ // --- ARN parsing -------------------------------------------------------------
37
+ // Every Connect resource ARN nests under the instance ARN, so the resource part
38
+ // splits on "/" into [instance, {instanceId}, {typeKeyword}, {resourceId}].
39
+ // Two type keywords do not match their IAM resource-type names, which is the
40
+ // trap this parser exists to avoid: a contact-flow-module is `flow-module` in
41
+ // the ARN, and an hours-of-operation is `operating-hours`.
42
+ // Machine-readable source for both:
43
+ // https://servicereference.us-east-1.amazonaws.com/v1/connect/connect.json
44
+ // (the feed behind
45
+ // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonconnect.html)
46
+ /** ARN type keyword to FlowDoc ref type, for resources nested under an instance. */
47
+ export const CONNECT_ARN_REF_TYPES = {
48
+ "contact-flow": "flow",
49
+ "flow-module": "module",
50
+ queue: "queue",
51
+ "operating-hours": "hours",
52
+ prompt: "prompt",
53
+ };
54
+ /** FlowDoc ref type to ARN type keyword. The inverse of CONNECT_ARN_REF_TYPES. */
55
+ export const REF_TYPE_ARN_KEYWORDS = {
56
+ flow: "contact-flow",
57
+ module: "flow-module",
58
+ queue: "queue",
59
+ hours: "operating-hours",
60
+ prompt: "prompt",
61
+ };
62
+ /** Parses an Amazon Connect resource ARN. Returns undefined for anything else. */
63
+ export function parseConnectArn(arn) {
64
+ const parts = arn.split(":");
65
+ if (parts.length < 6)
66
+ return undefined;
67
+ if (parts[0] !== "arn" || parts[2] !== "connect")
68
+ return undefined;
69
+ const segments = (parts[5] ?? "").split("/");
70
+ if (segments[0] !== "instance" || segments.length < 2)
71
+ return undefined;
72
+ const instanceId = segments[1] ?? "";
73
+ if (instanceId === "")
74
+ return undefined;
75
+ const parsed = {
76
+ partition: parts[1] ?? "",
77
+ region: parts[3] ?? "",
78
+ account: parts[4] ?? "",
79
+ instanceId,
80
+ };
81
+ if (segments.length >= 4) {
82
+ parsed.resourceType = segments[2];
83
+ parsed.resourceId = segments.slice(3).join("/");
84
+ }
85
+ if (parts.length > 6)
86
+ parsed.qualifier = parts.slice(6).join(":");
87
+ return parsed;
88
+ }
89
+ /**
90
+ * Function name out of a Lambda ARN. Lambda is not a Connect resource type:
91
+ * ListLambdaFunctions returns native Lambda ARNs, so the name is the segment
92
+ * after `function`, with any version or alias qualifier dropped.
93
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_ListLambdaFunctions.html
94
+ */
95
+ export function parseLambdaFunctionArn(arn) {
96
+ const parts = arn.split(":");
97
+ if (parts.length < 7 || parts[0] !== "arn" || parts[2] !== "lambda")
98
+ return undefined;
99
+ if (parts[5] !== "function")
100
+ return undefined;
101
+ const name = parts[6];
102
+ return name === undefined || name === "" ? undefined : name;
103
+ }
104
+ /**
105
+ * Drops the trailing qualifier a reference may carry, so `:$SAVED` or a version
106
+ * suffix resolves to the same reverse-map entry as the bare resource.
107
+ */
108
+ export function normalizeArn(arn) {
109
+ const parts = arn.split(":");
110
+ if (parts.length < 6 || parts[0] !== "arn")
111
+ return arn;
112
+ if (parts[2] === "connect")
113
+ return parts.slice(0, 6).join(":");
114
+ if (parts[2] === "lambda" && parts[5] === "function")
115
+ return parts.slice(0, 7).join(":");
116
+ return arn;
117
+ }
118
+ // --- Names -------------------------------------------------------------------
119
+ /**
120
+ * Connect resource names are free text; FlowDoc names are slugs. Lowercase,
121
+ * runs of anything else collapse to a single hyphen, edges trimmed.
122
+ */
123
+ export function slugifyResourceName(name) {
124
+ return name
125
+ .normalize("NFKD")
126
+ .replace(/[\u0300-\u036f]/g, "")
127
+ .toLowerCase()
128
+ .replace(/[^a-z0-9]+/g, "-")
129
+ .replace(/^-+|-+$/g, "");
130
+ }
131
+ /** Runs the nine list operations and assembles one inventory. */
132
+ export async function collectInventory(client, options = {}) {
133
+ const includeModules = options.includeModules !== false;
134
+ const [contactFlows, contactFlowModules, queues, hoursOfOperations, prompts, lambdaFunctions, lexBots,] = await Promise.all([
135
+ client.listContactFlows(options.flowTypes),
136
+ includeModules ? client.listContactFlowModules() : Promise.resolve([]),
137
+ client.listQueues(),
138
+ client.listHoursOfOperations(),
139
+ client.listPrompts(),
140
+ client.listLambdaFunctions(),
141
+ client.listBots(),
142
+ ]);
143
+ return {
144
+ contactFlows,
145
+ contactFlowModules,
146
+ queues,
147
+ hoursOfOperations,
148
+ prompts,
149
+ lambdaFunctions,
150
+ lexBots,
151
+ };
152
+ }
153
+ function makeRefEntry(type, name, alias) {
154
+ const suffix = alias === undefined ? "" : `@${alias}`;
155
+ return parseToken(`\${cdref:${type}:${name}${suffix}}`);
156
+ }
157
+ /**
158
+ * Assigns one slug per resource, per ref type, deterministically: candidates
159
+ * are sorted by (name, ARN) and a collision takes a numeric suffix. Renaming
160
+ * beats failing an entire instance export over two queues named "Sales" and
161
+ * "sales", and sorting first keeps the assignment stable across runs.
162
+ */
163
+ function assignNames(candidates, warnings) {
164
+ const byArn = new Map();
165
+ const taken = new Map();
166
+ const sorted = [...candidates].sort((a, b) => a.type !== b.type
167
+ ? a.type < b.type
168
+ ? -1
169
+ : 1
170
+ : a.name !== b.name
171
+ ? a.name < b.name
172
+ ? -1
173
+ : 1
174
+ : a.arn < b.arn
175
+ ? -1
176
+ : a.arn > b.arn
177
+ ? 1
178
+ : 0);
179
+ for (const candidate of sorted) {
180
+ const arn = normalizeArn(candidate.arn);
181
+ if (byArn.has(arn))
182
+ continue;
183
+ const used = taken.get(candidate.type) ?? new Set();
184
+ taken.set(candidate.type, used);
185
+ let name = candidate.name;
186
+ if (used.has(name)) {
187
+ let n = 2;
188
+ while (used.has(`${name}-${String(n)}`))
189
+ n += 1;
190
+ const renamed = `${name}-${String(n)}`;
191
+ warnings.push(`Two ${candidate.type} resources slug to "${name}"; ${arn} exported as "${renamed}".`);
192
+ name = renamed;
193
+ }
194
+ const entry = makeRefEntry(candidate.type, name, candidate.alias);
195
+ if (entry === undefined) {
196
+ warnings.push(`Cannot name ${candidate.type} ${arn}: "${name}" is not a valid slug.`);
197
+ continue;
198
+ }
199
+ used.add(name);
200
+ byArn.set(arn, entry);
201
+ }
202
+ return byArn;
203
+ }
204
+ function candidateName(raw, arn, type, warnings) {
205
+ const slug = slugifyResourceName(raw);
206
+ if (SLUG_PATTERN.test(slug))
207
+ return slug;
208
+ warnings.push(`Skipping ${type} ${arn}: name "${raw}" has no slug form.`);
209
+ return undefined;
210
+ }
211
+ /**
212
+ * ARN to ref entry for one instance. Every ARN a flow can hold gets an entry,
213
+ * so anything left over after export is genuinely unknown and is reported.
214
+ */
215
+ export function buildReverseMap(inventory) {
216
+ const warnings = [];
217
+ const candidates = [];
218
+ const add = (arn, rawName, type) => {
219
+ if (arn === undefined || arn === "" || rawName === undefined)
220
+ return;
221
+ const name = candidateName(rawName, arn, type, warnings);
222
+ if (name === undefined)
223
+ return;
224
+ candidates.push({ arn, type, name });
225
+ };
226
+ for (const q of inventory.queues)
227
+ add(q.arn, q.name, "queue");
228
+ for (const h of inventory.hoursOfOperations)
229
+ add(h.arn, h.name, "hours");
230
+ for (const p of inventory.prompts)
231
+ add(p.arn, p.name, "prompt");
232
+ for (const f of inventory.contactFlows)
233
+ add(f.arn, f.name, "flow");
234
+ for (const m of inventory.contactFlowModules)
235
+ add(m.arn, m.name, "module");
236
+ for (const arn of inventory.lambdaFunctions) {
237
+ const fn = parseLambdaFunctionArn(arn);
238
+ if (fn === undefined) {
239
+ warnings.push(`Skipping Lambda ARN with no function segment: ${arn}`);
240
+ continue;
241
+ }
242
+ add(arn, fn, "lambda");
243
+ }
244
+ for (const bot of inventory.lexBots) {
245
+ if (bot.aliasArn === undefined || bot.aliasArn === "") {
246
+ // A V1 bot has no ARN at all, so nothing in flow content can be matched
247
+ // back to it. Flow content references V1 bots by name and region rather
248
+ // than by ARN, so this costs nothing unless a V1 alias ARN turns up.
249
+ warnings.push(`Amazon Lex ${bot.lexVersion} bot "${bot.name ?? "(unnamed)"}" has no ARN and is not reverse-mapped.`);
250
+ continue;
251
+ }
252
+ // ListBots gives a V2 bot an AliasArn and no name, so the slug comes from
253
+ // the ARN's own identifiers unless the entry carries a name.
254
+ const raw = bot.name ?? bot.aliasArn.split(":").slice(5).join("-");
255
+ add(bot.aliasArn, raw, "lex");
256
+ }
257
+ return { byArn: assignNames(candidates, warnings), warnings };
258
+ }
259
+ /**
260
+ * Reverse map from a materialization resource map (token to value). The
261
+ * offline half of export: it turns the same file that materializes a FlowDoc
262
+ * into the map that exports the result back, which is what makes the
263
+ * round-trip property testable with no instance.
264
+ */
265
+ export function reverseMapOfResourceMap(resourceMap) {
266
+ const warnings = [];
267
+ const byArn = new Map();
268
+ for (const [token, value] of Object.entries(resourceMap).sort(([a], [b]) => (a < b ? -1 : 1))) {
269
+ const entry = parseToken(token);
270
+ if (entry === undefined) {
271
+ warnings.push(`Skipping map key that is not a reference token: ${token}`);
272
+ continue;
273
+ }
274
+ const arn = normalizeArn(value);
275
+ const existing = byArn.get(arn);
276
+ if (existing !== undefined) {
277
+ warnings.push(`${arn} is mapped by both ${existing.token} and ${token}; keeping the first.`);
278
+ continue;
279
+ }
280
+ byArn.set(arn, entry);
281
+ }
282
+ return { byArn, warnings };
283
+ }
284
+ /** Reverse-map lookup, tolerant of a `:$SAVED` or version qualifier. */
285
+ export function lookupArn(reverseMap, arn) {
286
+ return reverseMap.byArn.get(arn) ?? reverseMap.byArn.get(normalizeArn(arn));
287
+ }
288
+ // --- exportFlow --------------------------------------------------------------
289
+ /**
290
+ * The account segment of an ARN: digits for a customer resource, the literal
291
+ * `aws` for an AWS-managed one. The stock "Sample after contact work flow" on a
292
+ * fresh instance references the view
293
+ * `arn:aws:connect:<region>:aws:view/after-contact-work:1` (observed live
294
+ * 2026-09-01), and ListViews documents AWS_MANAGED views beside
295
+ * CUSTOMER_MANAGED ones. Digits alone let that ARN through as prose, which put
296
+ * a literal ARN in an exported FlowDoc (FlowDoc invariant 4) and past the lint
297
+ * rule that fails on it. There is no view ref type yet, so it is reported as
298
+ * an unknown ARN instead.
299
+ * https://docs.aws.amazon.com/connect/latest/APIReference/API_ListViews.html
300
+ */
301
+ const ARN_ACCOUNT = "(?:[0-9]*|aws)";
302
+ /** A whole field value that is an ARN, which is the only replaceable shape. */
303
+ const WHOLE_ARN = new RegExp(String.raw `^arn:aws[a-z0-9-]*:[a-z0-9-]*:[a-z0-9-]*:${ARN_ACCOUNT}:\S+$`);
304
+ /** An ARN anywhere inside a longer string, which is never replaceable. */
305
+ const ARN_ANYWHERE = new RegExp(String.raw `arn:aws[a-z0-9-]*:[a-z0-9-]*:[a-z0-9-]*:${ARN_ACCOUNT}:[^\s"']+`, "g");
306
+ /**
307
+ * Export failed. Both lists are complete and sorted, so a caller fixes the
308
+ * inventory once rather than one ARN per run. Mirrors MaterializeError.
309
+ */
310
+ export class ExportError extends Error {
311
+ /** ARNs occupying a whole field with no entry in the reverse map. */
312
+ unknownArns;
313
+ /**
314
+ * ARNs embedded in a longer string. A reference occupies an entire field
315
+ * value (FlowDoc invariant 4), so these cannot become tokens at all.
316
+ */
317
+ interpolatedArns;
318
+ /** ARN to the content paths it was found at, for locating each one. */
319
+ locations;
320
+ /**
321
+ * The FlowDoc name being exported, when the caller knew it. A whole-instance
322
+ * export reads many flows through one call, so without this the message names
323
+ * an ARN and leaves the operator to find which flow holds it.
324
+ */
325
+ resource;
326
+ constructor(unknownArns, interpolatedArns, locations, resource) {
327
+ const parts = [];
328
+ if (unknownArns.length > 0) {
329
+ parts.push(`${String(unknownArns.length)} ARN(s) not found in the instance inventory: ` +
330
+ unknownArns.join(", "));
331
+ }
332
+ if (interpolatedArns.length > 0) {
333
+ parts.push(`${String(interpolatedArns.length)} ARN(s) embedded in a longer string, which cannot hold a reference: ` +
334
+ interpolatedArns.join(", "));
335
+ }
336
+ super(`Cannot export${resource === undefined ? "" : ` "${resource}"`}: ${parts.join("; ")}`);
337
+ this.name = "ExportError";
338
+ this.unknownArns = unknownArns;
339
+ this.interpolatedArns = interpolatedArns;
340
+ this.locations = locations;
341
+ if (resource !== undefined)
342
+ this.resource = resource;
343
+ }
344
+ }
345
+ function record(into, arn, path) {
346
+ const paths = into.get(arn);
347
+ if (paths === undefined)
348
+ into.set(arn, [path]);
349
+ else
350
+ paths.push(path);
351
+ }
352
+ function rewriteArns(value, path, reverseMap, acc) {
353
+ if (typeof value === "string") {
354
+ if (WHOLE_ARN.test(value)) {
355
+ const entry = lookupArn(reverseMap, value);
356
+ if (entry !== undefined)
357
+ return entry.token;
358
+ record(acc.unknown, value, path);
359
+ return value;
360
+ }
361
+ for (const match of value.match(ARN_ANYWHERE) ?? [])
362
+ record(acc.interpolated, match, path);
363
+ return value;
364
+ }
365
+ if (Array.isArray(value)) {
366
+ return value.map((v, i) => rewriteArns(v, `${path}[${String(i)}]`, reverseMap, acc));
367
+ }
368
+ if (value !== null && typeof value === "object") {
369
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [
370
+ k,
371
+ rewriteArns(v, path === "" ? k : `${path}.${k}`, reverseMap, acc),
372
+ ]));
373
+ }
374
+ return value;
375
+ }
376
+ function isRecord(value) {
377
+ return value !== null && typeof value === "object" && !Array.isArray(value);
378
+ }
379
+ /**
380
+ * Connect omits `Parameters` from an action that takes none, rather than
381
+ * writing an empty map. Observed live: of the 20 stock sample flows on a fresh
382
+ * instance, 12 actions across 7 flows (`TransferContactToQueue` and
383
+ * `DistributeByPercentage`) came back with no `Parameters` key at all, while 28
384
+ * other parameterless actions in the same flows carried `"Parameters": {}`. So
385
+ * both spellings are live output and mean the same thing.
386
+ *
387
+ * FlowDoc requires the key on every action
388
+ * (conformance/schema/flowdoc-0.1.schema.json, `$defs.action.required`), so
389
+ * passing the omission through produced a schema-invalid document, and codegen
390
+ * read `Object.keys(a.Parameters)` straight off it and threw
391
+ * "Cannot convert undefined or null to object". Filling in the empty map
392
+ * Connect means by the absence is not a content change: materializing the
393
+ * result re-emits `"Parameters": {}`, the form the console itself writes.
394
+ *
395
+ * `Transitions` is normalized the same way. Connect writes `{}` for a terminal
396
+ * action rather than omitting the key, so this half is defensive, but
397
+ * canonicalization reads through `Transitions` unconditionally and an absent
398
+ * one would crash serialization instead of degrading.
399
+ */
400
+ function normalizeAction(action) {
401
+ if (isRecord(action.Parameters) && isRecord(action.Transitions))
402
+ return action;
403
+ return {
404
+ ...action,
405
+ Parameters: isRecord(action.Parameters) ? action.Parameters : {},
406
+ Transitions: isRecord(action.Transitions) ? action.Transitions : {},
407
+ };
408
+ }
409
+ function isPoint(value) {
410
+ return (value !== null &&
411
+ typeof value === "object" &&
412
+ typeof value.x === "number" &&
413
+ typeof value.y === "number");
414
+ }
415
+ /**
416
+ * Pulls positions out of content.Metadata and into `layout`, which is FlowDoc's
417
+ * single source of truth for position (docs/01-flowdoc-spec.md). Everything
418
+ * else Metadata carries stays in content: materialization preserves it, and
419
+ * dropping it would lose console state we never modeled.
420
+ *
421
+ * The Flow language example writes `Position`; console exports have also been
422
+ * seen writing `position`, so both are lifted and only `Position` is written
423
+ * back by materialization.
424
+ * https://docs.aws.amazon.com/connect/latest/devguide/flow-language-example.html
425
+ */
426
+ function liftMetadata(metadata, actionIds) {
427
+ const layout = {};
428
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
429
+ return { layout };
430
+ }
431
+ const rest = { ...metadata };
432
+ delete rest.EntryPointPosition;
433
+ delete rest.entryPointPosition;
434
+ const rawActionMetadata = rest.ActionMetadata;
435
+ if (rawActionMetadata !== null &&
436
+ typeof rawActionMetadata === "object" &&
437
+ !Array.isArray(rawActionMetadata)) {
438
+ const remaining = {};
439
+ for (const [id, raw] of Object.entries(rawActionMetadata)) {
440
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
441
+ remaining[id] = raw;
442
+ continue;
443
+ }
444
+ const entry = { ...raw };
445
+ const position = entry.Position ?? entry.position;
446
+ if (actionIds.has(id) && isPoint(position)) {
447
+ layout[id] = { x: position.x, y: position.y };
448
+ delete entry.Position;
449
+ delete entry.position;
450
+ }
451
+ if (Object.keys(entry).length > 0)
452
+ remaining[id] = entry;
453
+ }
454
+ if (Object.keys(remaining).length > 0)
455
+ rest.ActionMetadata = remaining;
456
+ else
457
+ delete rest.ActionMetadata;
458
+ }
459
+ return Object.keys(rest).length > 0 ? { layout, rest } : { layout };
460
+ }
461
+ /**
462
+ * Live Flow language in, FlowDoc out. Every ARN occupying a whole field value
463
+ * becomes its `${cdref:type:name}` token; an ARN with no reverse-map entry is a
464
+ * hard error listing every unknown ARN at once, per SPEC.md.
465
+ */
466
+ export function exportFlow(content, reverseMap, options) {
467
+ const parsed = typeof content === "string" ? JSON.parse(content) : content;
468
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
469
+ throw new Error("Cannot export: flow content is not a JSON object.");
470
+ }
471
+ const raw = parsed;
472
+ if (raw.Version !== FLOW_LANGUAGE_VERSION) {
473
+ throw new Error(`Cannot export: flow content Version is ${JSON.stringify(raw.Version)}, expected "${FLOW_LANGUAGE_VERSION}".`);
474
+ }
475
+ if (typeof raw.StartAction !== "string" || raw.StartAction === "") {
476
+ throw new Error("Cannot export: flow content has no StartAction.");
477
+ }
478
+ if (!Array.isArray(raw.Actions) || raw.Actions.length === 0) {
479
+ throw new Error("Cannot export: flow content has no Actions.");
480
+ }
481
+ if (!SLUG_PATTERN.test(options.name)) {
482
+ throw new Error(`Cannot export: "${options.name}" is not a valid FlowDoc name. Names are lowercase words separated by single hyphens.`);
483
+ }
484
+ const acc = { unknown: new Map(), interpolated: new Map() };
485
+ const actions = rewriteArns(raw.Actions, "Actions", reverseMap, acc).map(normalizeAction);
486
+ const metadata = rewriteArns(raw.Metadata, "Metadata", reverseMap, acc);
487
+ if (acc.unknown.size > 0 || acc.interpolated.size > 0) {
488
+ const sorted = (m) => [...m.keys()].sort();
489
+ const locations = {};
490
+ for (const [arn, paths] of [...acc.unknown, ...acc.interpolated])
491
+ locations[arn] = paths;
492
+ throw new ExportError(sorted(acc.unknown), sorted(acc.interpolated), locations, options.name);
493
+ }
494
+ const actionIds = new Set(actions.map((a) => a.Identifier));
495
+ const lifted = liftMetadata(metadata, actionIds);
496
+ const flowContent = {
497
+ Version: FLOW_LANGUAGE_VERSION,
498
+ StartAction: raw.StartAction,
499
+ Actions: actions,
500
+ };
501
+ if (lifted.rest !== undefined)
502
+ flowContent.Metadata = lifted.rest;
503
+ // Actions the instance never gave a position get the same deterministic
504
+ // auto-layout synth would have assigned, so the studio can open the result.
505
+ const auto = Object.values(lifted.layout).length === actions.length ? {} : autoLayout(actions);
506
+ const layout = {};
507
+ for (const action of actions) {
508
+ layout[action.Identifier] = lifted.layout[action.Identifier] ??
509
+ auto[action.Identifier] ?? { x: 0, y: 0 };
510
+ }
511
+ const doc = {
512
+ flowdoc: FLOWDOC_VERSION,
513
+ kind: options.kind ?? (options.connectType === "MODULE" ? "module" : "flow"),
514
+ name: options.name,
515
+ connectType: options.connectType,
516
+ content: flowContent,
517
+ layout,
518
+ refs: collectRefs(flowContent),
519
+ };
520
+ if (options.includeMeta !== false) {
521
+ doc.meta = {
522
+ generator: options.generator ?? `core@${FLOWDOC_VERSION}`,
523
+ ...options.meta,
524
+ };
525
+ }
526
+ return canonicalize(doc);
527
+ }
528
+ // --- exportInstance ----------------------------------------------------------
529
+ /** ContactFlowType values FlowDoc models. CAMPAIGN has no FlowDoc connectType. */
530
+ const EXPORTABLE_FLOW_TYPES = new Set([
531
+ "CONTACT_FLOW",
532
+ "CUSTOMER_QUEUE",
533
+ "CUSTOMER_HOLD",
534
+ "CUSTOMER_WHISPER",
535
+ "AGENT_HOLD",
536
+ "AGENT_WHISPER",
537
+ "OUTBOUND_WHISPER",
538
+ "AGENT_TRANSFER",
539
+ "QUEUE_TRANSFER",
540
+ ]);
541
+ function isNotPublished(error) {
542
+ return (error !== null &&
543
+ typeof error === "object" &&
544
+ error.name === "ContactFlowNotPublishedException");
545
+ }
546
+ /**
547
+ * Whole-instance export: inventory, reverse map, then every flow and module.
548
+ * Nothing here is Connect-version specific beyond the client interface, so the
549
+ * offline fixtures exercise exactly the code a live run does.
550
+ */
551
+ export async function exportInstance(client, options = {}) {
552
+ const inventory = options.inventory ?? (await collectInventory(client, options));
553
+ const reverseMap = buildReverseMap(inventory);
554
+ const warnings = [...reverseMap.warnings];
555
+ const failures = [];
556
+ const flows = [];
557
+ const codegenOptions = options.codegen === undefined || options.codegen === false
558
+ ? undefined
559
+ : options.codegen === true
560
+ ? {}
561
+ : options.codegen;
562
+ const fail = (arn, name, error) => {
563
+ if (options.onError !== "collect")
564
+ throw error;
565
+ const failure = {
566
+ arn,
567
+ name,
568
+ reason: error instanceof Error ? error.message : String(error),
569
+ };
570
+ if (error instanceof ExportError)
571
+ failure.unknownArns = error.unknownArns;
572
+ failures.push(failure);
573
+ };
574
+ const describe = async (id, read) => {
575
+ try {
576
+ return { described: await read(id), saved: false };
577
+ }
578
+ catch (error) {
579
+ if (options.savedFallback === false || !isNotPublished(error))
580
+ throw error;
581
+ // "Use the $SAVED alias in the request to describe the SAVED content of a
582
+ // Flow." Never-published flows are otherwise a 404 for the exporter.
583
+ return { described: await read(`${id}:$SAVED`), saved: true };
584
+ }
585
+ };
586
+ const emit = (summary, described, connectType, saved) => {
587
+ const entry = lookupArn(reverseMap, summary.arn);
588
+ if (entry === undefined) {
589
+ throw new Error(`Cannot export ${summary.arn}: the instance inventory has no entry for it, so it has no name.`);
590
+ }
591
+ const instanceId = parseConnectArn(summary.arn)?.instanceId;
592
+ const doc = exportFlow(described.content, reverseMap, {
593
+ name: entry.name,
594
+ connectType,
595
+ generator: options.generator,
596
+ meta: {
597
+ // Provenance, deliberately without the ARN. An exported FlowDoc is an
598
+ // authored document and gets committed to a repository, so it must not
599
+ // carry a literal ARN anywhere, meta included; that would pin it to one
600
+ // account and leak the account id. The instance id and resource id say
601
+ // where it came from without either.
602
+ source: {
603
+ ...(instanceId === undefined ? {} : { instanceId }),
604
+ id: described.id,
605
+ name: described.name,
606
+ ...(described.version === undefined ? {} : { version: described.version }),
607
+ ...(described.contentSha256 === undefined
608
+ ? {}
609
+ : { contentSha256: described.contentSha256 }),
610
+ ...(saved ? { alias: "$SAVED" } : {}),
611
+ },
612
+ },
613
+ });
614
+ const exported = {
615
+ arn: summary.arn,
616
+ id: described.id,
617
+ sourceName: described.name,
618
+ doc,
619
+ };
620
+ if (saved)
621
+ exported.saved = true;
622
+ if (codegenOptions !== undefined)
623
+ exported.code = codegen(doc, codegenOptions);
624
+ flows.push(exported);
625
+ };
626
+ for (const summary of [...inventory.contactFlows].sort((a, b) => (a.arn < b.arn ? -1 : 1))) {
627
+ const type = summary.contactFlowType ?? "CONTACT_FLOW";
628
+ if (!EXPORTABLE_FLOW_TYPES.has(type)) {
629
+ warnings.push(`Skipping ${summary.arn}: ContactFlowType ${type} has no FlowDoc connectType.`);
630
+ continue;
631
+ }
632
+ try {
633
+ const { described, saved } = await describe(summary.id ?? summary.arn, (id) => client.describeContactFlow(id));
634
+ emit(summary, described, type, saved);
635
+ }
636
+ catch (error) {
637
+ fail(summary.arn, summary.name, error);
638
+ }
639
+ }
640
+ for (const summary of [...inventory.contactFlowModules].sort((a, b) => a.arn < b.arn ? -1 : 1)) {
641
+ try {
642
+ const { described, saved } = await describe(summary.id ?? summary.arn, (id) => client.describeContactFlowModule(id));
643
+ const module = described;
644
+ if (module.settings !== undefined || module.externalInvocationEnabled !== undefined) {
645
+ // Neither field has a FlowDoc home yet. Warn rather than drop silently.
646
+ warnings.push(`Module ${summary.arn} carries Settings or ExternalInvocationConfiguration, which FlowDoc does not model; they are not exported.`);
647
+ }
648
+ emit(summary, described, "MODULE", saved);
649
+ }
650
+ catch (error) {
651
+ fail(summary.arn, summary.name, error);
652
+ }
653
+ }
654
+ return { inventory, reverseMap, flows, warnings, failures };
655
+ }
656
+ async function loadConnectCommands() {
657
+ try {
658
+ return (await import("@aws-sdk/client-connect"));
659
+ }
660
+ catch (cause) {
661
+ throw new Error("Connecting to an instance needs the optional peer dependency @aws-sdk/client-connect. Install it, or use the offline paths (exportFlow with reverseMapOfResourceMap).", { cause });
662
+ }
663
+ }
664
+ /**
665
+ * Builds a ConnectInventoryClient over the AWS SDK: pagination, the 2 rps
666
+ * throttle budget, and the response-shape differences between the list and
667
+ * describe operations all live here so nothing above has to know them.
668
+ */
669
+ export function createConnectInventoryClient(options) {
670
+ const { connect, instanceId } = options;
671
+ const maxResults = options.maxResults ?? 1000;
672
+ let commands;
673
+ const throttle = createRateLimiter(options);
674
+ const send = async (make) => {
675
+ commands ??= await loadConnectCommands();
676
+ await throttle();
677
+ return connect.send(make(commands));
678
+ };
679
+ /** Every list operation here pages the same way: opaque NextToken, query string. */
680
+ const paginate = async (make, pick) => {
681
+ const out = [];
682
+ let nextToken;
683
+ do {
684
+ const response = await send((c) => make(c, nextToken));
685
+ out.push(...(pick(response) ?? []));
686
+ nextToken =
687
+ response.NextToken === "" ? undefined : response.NextToken;
688
+ } while (nextToken !== undefined);
689
+ return out;
690
+ };
691
+ const summary = (s) => ({
692
+ arn: s.Arn ?? "",
693
+ ...(s.Id === undefined ? {} : { id: s.Id }),
694
+ name: s.Name ?? "",
695
+ });
696
+ const described = (f) => ({
697
+ arn: f.Arn ?? "",
698
+ id: f.Id ?? "",
699
+ name: f.Name ?? "",
700
+ type: f.Type,
701
+ state: f.State,
702
+ status: f.Status,
703
+ version: f.Version === undefined ? undefined : String(f.Version),
704
+ content: f.Content ?? "",
705
+ contentSha256: f.FlowContentSha256 ?? f.FlowModuleContentSha256,
706
+ });
707
+ return {
708
+ listContactFlows: (contactFlowTypes) => paginate((c, nextToken) => new c.ListContactFlowsCommand({
709
+ InstanceId: instanceId,
710
+ MaxResults: maxResults,
711
+ NextToken: nextToken,
712
+ ...(contactFlowTypes === undefined ? {} : { ContactFlowTypes: [...contactFlowTypes] }),
713
+ }), (r) => (r.ContactFlowSummaryList ?? []).map((s) => ({
714
+ ...summary(s),
715
+ contactFlowType: s.ContactFlowType,
716
+ contactFlowState: s.ContactFlowState,
717
+ contactFlowStatus: s.ContactFlowStatus,
718
+ }))),
719
+ describeContactFlow: async (contactFlowId) => {
720
+ const response = await send((c) => new c.DescribeContactFlowCommand({
721
+ InstanceId: instanceId,
722
+ ContactFlowId: contactFlowId,
723
+ }));
724
+ return described(response.ContactFlow ?? {});
725
+ },
726
+ listContactFlowModules: () => paginate((c, nextToken) => new c.ListContactFlowModulesCommand({
727
+ InstanceId: instanceId,
728
+ MaxResults: maxResults,
729
+ NextToken: nextToken,
730
+ }),
731
+ // Note the plural: ContactFlowModulesSummaryList, not ...ModuleSummaryList.
732
+ (r) => (r.ContactFlowModulesSummaryList ?? []).map((s) => ({
733
+ ...summary(s),
734
+ state: s.State,
735
+ }))),
736
+ describeContactFlowModule: async (contactFlowModuleId) => {
737
+ const response = await send((c) => new c.DescribeContactFlowModuleCommand({
738
+ InstanceId: instanceId,
739
+ ContactFlowModuleId: contactFlowModuleId,
740
+ }));
741
+ const module = response.ContactFlowModule ?? {};
742
+ return {
743
+ ...described(module),
744
+ settings: module.Settings,
745
+ externalInvocationEnabled: module.ExternalInvocationConfiguration?.Enabled,
746
+ };
747
+ },
748
+ listQueues: () => paginate((c, nextToken) =>
749
+ // QueueTypes is deliberate: without it agent queues come back too,
750
+ // which are per-user and can truncate the page past 1000 agents.
751
+ new c.ListQueuesCommand({
752
+ InstanceId: instanceId,
753
+ QueueTypes: ["STANDARD"],
754
+ MaxResults: maxResults,
755
+ NextToken: nextToken,
756
+ }), (r) => (r.QueueSummaryList ?? []).map(summary)),
757
+ listHoursOfOperations: () => paginate((c, nextToken) => new c.ListHoursOfOperationsCommand({
758
+ InstanceId: instanceId,
759
+ MaxResults: maxResults,
760
+ NextToken: nextToken,
761
+ }), (r) => (r.HoursOfOperationSummaryList ?? []).map(summary)),
762
+ listPrompts: () => paginate((c, nextToken) => new c.ListPromptsCommand({
763
+ InstanceId: instanceId,
764
+ MaxResults: maxResults,
765
+ NextToken: nextToken,
766
+ }), (r) => (r.PromptSummaryList ?? []).map(summary)),
767
+ // MaxResults maxes out at 25 here, not 1000, and the response is a bare
768
+ // array of Lambda ARNs with no ids or names.
769
+ listLambdaFunctions: () => paginate((c, nextToken) => new c.ListLambdaFunctionsCommand({
770
+ InstanceId: instanceId,
771
+ MaxResults: 25,
772
+ NextToken: nextToken,
773
+ }), (r) => r.LambdaFunctions ?? []),
774
+ // lexVersion is required, so a full inventory is two paginated passes.
775
+ listBots: async () => {
776
+ const out = [];
777
+ for (const lexVersion of ["V1", "V2"]) {
778
+ const configs = await paginate((c, nextToken) => new c.ListBotsCommand({
779
+ InstanceId: instanceId,
780
+ LexVersion: lexVersion,
781
+ MaxResults: 25,
782
+ NextToken: nextToken,
783
+ }), (r) => r.LexBots ?? []);
784
+ for (const config of configs) {
785
+ const bot = { lexVersion };
786
+ if (config.LexBot?.Name !== undefined)
787
+ bot.name = config.LexBot.Name;
788
+ if (config.LexBot?.LexRegion !== undefined)
789
+ bot.lexRegion = config.LexBot.LexRegion;
790
+ if (config.LexV2Bot?.AliasArn !== undefined)
791
+ bot.aliasArn = config.LexV2Bot.AliasArn;
792
+ out.push(bot);
793
+ }
794
+ }
795
+ return out;
796
+ },
797
+ };
798
+ }
799
+ //# sourceMappingURL=export.js.map