1import * as b2bi from "@distilled.cloud/aws/b2bi";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10import { readB2biTags, syncB2biTags, toWireTags } from "./internal.ts";
11
12export interface TransformerProps {
13 /**
14 * The name of the transformer.
15 */
16 name: string;
17 /**
18 * The transformer's processing state. Newly created transformers default
19 * to `inactive`; set to `active` to make the transformer usable by a
20 * capability. Activation is one-way: an active transformer rejects every
21 * update (including deactivation), so changing the configuration, name, or
22 * status of an active transformer replaces it.
23 * @default "inactive"
24 */
25 status?: "active" | "inactive";
26 /**
27 * Describes the format of the source document (e.g. X12 EDI) and any
28 * advanced options for parsing it.
29 */
30 inputConversion?: b2bi.InputConversion;
31 /**
32 * The mapping template (JSONATA or XSLT) that transforms the input
33 * document into the output format.
34 */
35 mapping?: b2bi.Mapping;
36 /**
37 * Describes the format of the output document (e.g. X12 EDI) for outbound
38 * transformers.
39 */
40 outputConversion?: b2bi.OutputConversion;
41 /**
42 * Locations of sample input and output documents used to test the
43 * transformer.
44 */
45 sampleDocuments?: b2bi.SampleDocuments;
46 /**
47 * User-defined tags for the transformer.
48 */
49 tags?: Record<string, string>;
50}
51
52export interface Transformer extends Resource<
53 "AWS.B2BI.Transformer",
54 TransformerProps,
55 {
56 /**
57 * Service-assigned unique ID of the transformer.
58 */
59 transformerId: string;
60 /**
61 * ARN of the transformer.
62 */
63 transformerArn: string;
64 /**
65 * Name of the transformer.
66 */
67 name: string;
68 /**
69 * Current status (`active` or `inactive`).
70 */
71 status: string;
72 },
73 never,
74 Providers
75> {}
76
77/**
78 * An AWS B2B Data Interchange (B2BI) transformer. A transformer describes
79 * how to convert inbound EDI documents to a target format (or the reverse)
80 * using a mapping template. Transformers are credential-free and testable.
81 *
82 * Activation is one-way: B2BI rejects every update to an `active`
83 * transformer, including a status-only deactivation, so changing the
84 * configuration, name, or status of an active transformer replaces it
85 * (delete-first). Deletion works regardless of status.
86 * ### Creating a Transformer
87 * **Example:** Inbound X12 to JSON
88 * ```typescript
89 * const transformer = yield* B2BI.Transformer("X12ToJson", {
90 * name: "x12-to-json",
91 * status: "active",
92 * inputConversion: {
93 * fromFormat: "X12",
94 * formatOptions: { x12: { transactionSet: "X12_850", version: "VERSION_4010" } },
95 * },
96 * mapping: {
97 * templateLanguage: "JSONATA",
98 * template: "{ \"orderId\": transactionSets[0].St.transactionSetControlNumber }",
99 * },
100 * });
101 * ```
102 *
103 * @resource
104 */
105export const Transformer = Resource<Transformer>("AWS.B2BI.Transformer");
106
107const toAttrs = (
108 r:
109 | b2bi.GetTransformerResponse
110 | b2bi.CreateTransformerResponse
111 | b2bi.UpdateTransformerResponse,
112) => ({
113 transformerId: r.transformerId,
114 transformerArn: r.transformerArn,
115 name: r.name,
116 status: r.status,
117});
118
119/** JSON fingerprint of the mutable transformer configuration. */
120const spec = (
121 props: Pick<
122 TransformerProps,
123 "inputConversion" | "mapping" | "outputConversion" | "sampleDocuments"
124 >,
125): string =>
126 JSON.stringify({
127 inputConversion: props.inputConversion ?? null,
128 mapping: props.mapping ?? null,
129 outputConversion: props.outputConversion ?? null,
130 sampleDocuments: props.sampleDocuments ?? null,
131 });
132
133export const TransformerProvider = () =>
134 Provider.effect(
135 Transformer,
136 Effect.gen(function* () {
137 const findByName = (name: string) =>
138 b2bi.listTransformers.items({}).pipe(
139 Stream.filter((s) => s.name === name),
140 Stream.runHead,
141 Effect.flatMap((head) =>
142 head._tag === "Some"
143 ? b2bi
144 .getTransformer({ transformerId: head.value.transformerId })
145 .pipe(
146 Effect.catchTag("ResourceNotFoundException", () =>
147 Effect.succeed(undefined),
148 ),
149 )
150 : Effect.succeed(undefined),
151 ),
152 );
153
154 return {
155 stables: ["transformerId", "transformerArn"],
156
157 diff: Effect.fn(function* ({ olds, news }) {
158 if (!isResolved(news)) return undefined;
159 // An ACTIVE transformer rejects every update — including a
160 // status-only deactivation ("An active Transformer cannot be
161 // updated", verified live) — so any change away from a deployed
162 // active state forces a replacement. Delete-first: transformer
163 // names are how lost state is recovered (findByName), so the old
164 // instance must be gone before the new one is created.
165 if (olds.status === "active") {
166 const changed =
167 spec(olds) !== spec(news) ||
168 olds.name !== news.name ||
169 (news.status ?? "inactive") !== "active";
170 if (changed) {
171 return { action: "replace", deleteFirst: true } as const;
172 }
173 }
174 }),
175
176 read: Effect.fn(function* ({ id, olds, output }) {
177 const found = output?.transformerId
178 ? yield* b2bi
179 .getTransformer({ transformerId: output.transformerId })
180 .pipe(
181 Effect.catchTag("ResourceNotFoundException", () =>
182 Effect.succeed(undefined),
183 ),
184 )
185 : yield* findByName(olds?.name ?? "");
186 if (found === undefined) return undefined;
187 const attrs = toAttrs(found);
188 const tags = yield* readB2biTags(attrs.transformerArn);
189 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
190 }),
191
192 reconcile: Effect.fn(function* ({ id, news, output, session }) {
193 const internalTags = yield* createInternalTags(id);
194 const desiredTags = { ...internalTags, ...news.tags };
195 const desiredStatus = news.status ?? "inactive";
196
197 // 1. Observe.
198 let live = output?.transformerId
199 ? yield* b2bi
200 .getTransformer({ transformerId: output.transformerId })
201 .pipe(
202 Effect.catchTag("ResourceNotFoundException", () =>
203 Effect.succeed(undefined),
204 ),
205 )
206 : yield* findByName(news.name);
207
208 // 2. Ensure.
209 if (live === undefined) {
210 const created = yield* b2bi.createTransformer({
211 name: news.name,
212 inputConversion: news.inputConversion,
213 mapping: news.mapping,
214 outputConversion: news.outputConversion,
215 sampleDocuments: news.sampleDocuments,
216 tags: toWireTags(desiredTags),
217 });
218 live = yield* b2bi.getTransformer({
219 transformerId: created.transformerId,
220 });
221 }
222
223 // 3. Sync — converge name/config while the transformer is still
224 // inactive, then activate last. An ACTIVE transformer rejects every
225 // update (including a status-only deactivation), so drift against
226 // an active transformer cannot be converged in place — `diff`
227 // reports those changes as a delete-first replacement.
228 const configDrift = spec(live) !== spec(news);
229 const nameDrift = live.name !== news.name;
230 const statusDrift = live.status !== desiredStatus;
231 if (live.status === "active" && (configDrift || nameDrift)) {
232 return yield* Effect.fail(
233 new Error(
234 `Transformer '${live.transformerId}' is active and cannot be updated in place — B2BI rejects every update to an active transformer. Replace it instead.`,
235 ),
236 );
237 }
238 if (configDrift || nameDrift) {
239 // Config update carries NO status field (a status change in the
240 // same call as a config change is rejected).
241 const updated = yield* b2bi.updateTransformer({
242 transformerId: live.transformerId,
243 name: news.name,
244 inputConversion: news.inputConversion,
245 mapping: news.mapping,
246 outputConversion: news.outputConversion,
247 sampleDocuments: news.sampleDocuments,
248 });
249 live = { ...live, ...updated };
250 }
251 if (statusDrift && desiredStatus === "active") {
252 const updated = yield* b2bi.updateTransformer({
253 transformerId: live.transformerId,
254 status: "active",
255 });
256 live = { ...live, ...updated };
257 }
258
259 // 3b. Sync tags.
260 yield* syncB2biTags(live.transformerArn, desiredTags);
261
262 yield* session.note(live.transformerId);
263 return toAttrs(live);
264 }),
265
266 delete: Effect.fn(function* ({ output }) {
267 // deleteTransformer works on both active and inactive transformers
268 // (verified live) — no deactivation step is needed (nor possible:
269 // an active transformer rejects all updates).
270 yield* b2bi
271 .deleteTransformer({ transformerId: output.transformerId })
272 .pipe(
273 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
274 );
275 // NOTE: transformer creation auto-creates the shared account-level
276 // /aws/vendedlogs/b2bi/transformers log group. That group is a
277 // service-managed singleton (like a service-linked role) and MUST
278 // NOT be reaped here: B2BI's internal log-delivery bookkeeping
279 // references it, and deleting it opens a ~60-90s window where every
280 // subsequent createTransformer in the account fails with
281 // "Unable to perform CreateLogDelivery. Log destination resource
282 // /aws/vendedlogs/b2bi/transformers was not found" (verified live).
283 }),
284
285 list: () =>
286 b2bi.listTransformers.items({}).pipe(
287 Stream.mapEffect((s) =>
288 b2bi.getTransformer({ transformerId: s.transformerId }).pipe(
289 Effect.map(toAttrs),
290 Effect.catchTag("ResourceNotFoundException", () =>
291 Effect.succeed(undefined),
292 ),
293 ),
294 ),
295 Stream.filter((item) => item !== undefined),
296 Stream.runCollect,
297 Effect.map((chunk) => Array.from(chunk)),
298 ),
299 };
300 }),
301 );