| 1 | import * as b2bi from "@distilled.cloud/aws/b2bi"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { createInternalTags, hasAlchemyTags } from "../../Tags.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | import { readB2biTags, syncB2biTags, toWireTags } from "./internal.ts"; |
| 11 | |
| 12 | export 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 | |
| 52 | export 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 | */ |
| 105 | export const Transformer = Resource<Transformer>("AWS.B2BI.Transformer"); |
| 106 | |
| 107 | const 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. */ |
| 120 | const 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 | |
| 133 | export 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 | ); |