@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.
- package/LICENSE +202 -0
- package/README.md +30 -0
- package/SPEC.md +92 -0
- package/dist/actions.d.ts +87 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +204 -0
- package/dist/actions.js.map +1 -0
- package/dist/aws.d.ts +27 -0
- package/dist/aws.d.ts.map +1 -0
- package/dist/aws.js +32 -0
- package/dist/aws.js.map +1 -0
- package/dist/blocks.d.ts +287 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +352 -0
- package/dist/blocks.js.map +1 -0
- package/dist/codegen.d.ts +21 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +1027 -0
- package/dist/codegen.js.map +1 -0
- package/dist/export.d.ts +256 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +799 -0
- package/dist/export.js.map +1 -0
- package/dist/flow.d.ts +41 -0
- package/dist/flow.d.ts.map +1 -0
- package/dist/flow.js +85 -0
- package/dist/flow.js.map +1 -0
- package/dist/flowdoc.d.ts +111 -0
- package/dist/flowdoc.d.ts.map +1 -0
- package/dist/flowdoc.js +139 -0
- package/dist/flowdoc.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +11 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +49 -0
- package/dist/layout.js.map +1 -0
- package/dist/lint/engine.d.ts +16 -0
- package/dist/lint/engine.d.ts.map +1 -0
- package/dist/lint/engine.js +37 -0
- package/dist/lint/engine.js.map +1 -0
- package/dist/lint/graph.d.ts +34 -0
- package/dist/lint/graph.d.ts.map +1 -0
- package/dist/lint/graph.js +79 -0
- package/dist/lint/graph.js.map +1 -0
- package/dist/lint/index.d.ts +7 -0
- package/dist/lint/index.d.ts.map +1 -0
- package/dist/lint/index.js +5 -0
- package/dist/lint/index.js.map +1 -0
- package/dist/lint/reporters.d.ts +6 -0
- package/dist/lint/reporters.d.ts.map +1 -0
- package/dist/lint/reporters.js +30 -0
- package/dist/lint/reporters.js.map +1 -0
- package/dist/lint/rules/action-allowed-in-flow-type.d.ts +11 -0
- package/dist/lint/rules/action-allowed-in-flow-type.d.ts.map +1 -0
- package/dist/lint/rules/action-allowed-in-flow-type.js +32 -0
- package/dist/lint/rules/action-allowed-in-flow-type.js.map +1 -0
- package/dist/lint/rules/action-count.d.ts +16 -0
- package/dist/lint/rules/action-count.d.ts.map +1 -0
- package/dist/lint/rules/action-count.js +32 -0
- package/dist/lint/rules/action-count.js.map +1 -0
- package/dist/lint/rules/error-branches.d.ts +10 -0
- package/dist/lint/rules/error-branches.d.ts.map +1 -0
- package/dist/lint/rules/error-branches.js +39 -0
- package/dist/lint/rules/error-branches.js.map +1 -0
- package/dist/lint/rules/index.d.ts +17 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/index.js +36 -0
- package/dist/lint/rules/index.js.map +1 -0
- package/dist/lint/rules/module-depth-5.d.ts +14 -0
- package/dist/lint/rules/module-depth-5.d.ts.map +1 -0
- package/dist/lint/rules/module-depth-5.js +67 -0
- package/dist/lint/rules/module-depth-5.js.map +1 -0
- package/dist/lint/rules/no-literal-arn.d.ts +21 -0
- package/dist/lint/rules/no-literal-arn.d.ts.map +1 -0
- package/dist/lint/rules/no-literal-arn.js +58 -0
- package/dist/lint/rules/no-literal-arn.js.map +1 -0
- package/dist/lint/rules/no-unresolved-token.d.ts +8 -0
- package/dist/lint/rules/no-unresolved-token.d.ts.map +1 -0
- package/dist/lint/rules/no-unresolved-token.js +71 -0
- package/dist/lint/rules/no-unresolved-token.js.map +1 -0
- package/dist/lint/rules/prompt-length-3000.d.ts +16 -0
- package/dist/lint/rules/prompt-length-3000.d.ts.map +1 -0
- package/dist/lint/rules/prompt-length-3000.js +61 -0
- package/dist/lint/rules/prompt-length-3000.js.map +1 -0
- package/dist/lint/rules/reachable-blocks.d.ts +7 -0
- package/dist/lint/rules/reachable-blocks.d.ts.map +1 -0
- package/dist/lint/rules/reachable-blocks.js +45 -0
- package/dist/lint/rules/reachable-blocks.js.map +1 -0
- package/dist/lint/rules/recording-consent-before-record.d.ts +3 -0
- package/dist/lint/rules/recording-consent-before-record.d.ts.map +1 -0
- package/dist/lint/rules/recording-consent-before-record.js +87 -0
- package/dist/lint/rules/recording-consent-before-record.js.map +1 -0
- package/dist/lint/rules/terminal-blocks.d.ts +8 -0
- package/dist/lint/rules/terminal-blocks.d.ts.map +1 -0
- package/dist/lint/rules/terminal-blocks.js +40 -0
- package/dist/lint/rules/terminal-blocks.js.map +1 -0
- package/dist/lint/rules/unique-names.d.ts +7 -0
- package/dist/lint/rules/unique-names.d.ts.map +1 -0
- package/dist/lint/rules/unique-names.js +40 -0
- package/dist/lint/rules/unique-names.js.map +1 -0
- package/dist/lint/types.d.ts +30 -0
- package/dist/lint/types.d.ts.map +1 -0
- package/dist/lint/types.js +11 -0
- package/dist/lint/types.js.map +1 -0
- package/dist/materialize.d.ts +31 -0
- package/dist/materialize.d.ts.map +1 -0
- package/dist/materialize.js +192 -0
- package/dist/materialize.js.map +1 -0
- package/dist/package-names.d.ts +11 -0
- package/dist/package-names.d.ts.map +1 -0
- package/dist/package-names.js +25 -0
- package/dist/package-names.js.map +1 -0
- package/dist/refs.d.ts +35 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +69 -0
- package/dist/refs.js.map +1 -0
- package/dist/serialize.d.ts +13 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +86 -0
- package/dist/serialize.js.map +1 -0
- package/dist/simulate.d.ts +446 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +1115 -0
- package/dist/simulate.js.map +1 -0
- package/dist/synth.d.ts +25 -0
- package/dist/synth.d.ts.map +1 -0
- package/dist/synth.js +93 -0
- package/dist/synth.js.map +1 -0
- package/package.json +73 -0
- package/src/actions.ts +221 -0
- package/src/aws.ts +55 -0
- package/src/blocks.ts +619 -0
- package/src/codegen.ts +1144 -0
- package/src/export.ts +1190 -0
- package/src/flow.ts +108 -0
- package/src/flowdoc.ts +261 -0
- package/src/index.ts +31 -0
- package/src/layout.ts +54 -0
- package/src/lint/engine.ts +56 -0
- package/src/lint/graph.ts +86 -0
- package/src/lint/index.ts +10 -0
- package/src/lint/reporters.ts +44 -0
- package/src/lint/rules/action-allowed-in-flow-type.ts +32 -0
- package/src/lint/rules/action-count.ts +35 -0
- package/src/lint/rules/error-branches.ts +45 -0
- package/src/lint/rules/index.ts +53 -0
- package/src/lint/rules/module-depth-5.ts +70 -0
- package/src/lint/rules/no-literal-arn.ts +64 -0
- package/src/lint/rules/no-unresolved-token.ts +80 -0
- package/src/lint/rules/prompt-length-3000.ts +66 -0
- package/src/lint/rules/reachable-blocks.ts +50 -0
- package/src/lint/rules/recording-consent-before-record.ts +90 -0
- package/src/lint/rules/terminal-blocks.ts +45 -0
- package/src/lint/rules/unique-names.ts +44 -0
- package/src/lint/types.ts +43 -0
- package/src/materialize.ts +230 -0
- package/src/package-names.ts +26 -0
- package/src/refs.ts +93 -0
- package/src/serialize.ts +97 -0
- package/src/simulate.ts +1610 -0
- 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
|