import * as appsync from "@distilled.cloud/aws/appsync"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import { pipe } from "effect/Function"; import * as Schedule from "effect/Schedule"; import { diffTags, normalizeTags } from "../../Tags.ts"; /** * AppSync serializes control-plane mutations per API; concurrent resolver / * data-source / schema operations surface as `ConcurrentModificationException`. * Ride out the contention window with a bounded retry (~40s). * * The helper carries an EXPLICIT return annotation so the conditional type * of `Effect.retry` never leaks into declaration emit (which would widen the * provider layer to `unknown` for every consumer of `AWS.providers()`). */ export const retryConcurrentModification = ( effect: Effect.Effect, ): Effect.Effect => Effect.retry(effect, { while: (error: E) => error._tag === "ConcurrentModificationException", schedule: Schedule.max([ pipe( Schedule.exponential(Duration.seconds(1), 2), Schedule.modifyDelay(({ duration }) => Effect.succeed( Duration.isGreaterThan(duration, Duration.seconds(8)) ? Duration.seconds(8) : duration, ), ), ), Schedule.recurs(8), ]), }) as Effect.Effect; /** * IAM changes (fresh service roles, updated trust policies) propagate to * AppSync eventually; a `createDataSource` right after `createRole` can * transiently fail with a `BadRequestException` complaining that AppSync * is not authorized to assume the role. Bounded retry (~20s), scoped to * role-assumption messages so genuine validation errors fail fast. */ export const retryWhileRolePropagates = < A, E extends { _tag: string; message?: string }, R, >( effect: Effect.Effect, ): Effect.Effect => Effect.retry(effect, { while: (error: E) => error._tag === "BadRequestException" && /assume|role|authorized/i.test(error.message ?? ""), schedule: Schedule.max([ Schedule.fixed(Duration.seconds(2)), Schedule.recurs(10), ]), }) as Effect.Effect; /** * AppSync data-source and pipeline-function names must match * `[_A-Za-z][_0-9A-Za-z]*` (no dashes). Deterministic physical names are * generated with dashes, so sanitize into the allowed alphabet. */ export const sanitizeAppSyncName = (name: string): string => { const sanitized = name.replaceAll(/[^_0-9A-Za-z]/g, "_"); return /^[_A-Za-z]/.test(sanitized) ? sanitized : `_${sanitized}`; }; /** * Normalize the wire tag map (values may be `undefined`) to a plain record. */ export const tagRecord = ( tags: { [key: string]: string | undefined } | undefined, ): Record => Object.fromEntries( Object.entries(tags ?? {}).filter( (entry): entry is [string, string] => entry[1] !== undefined, ), ); /** * Diff observed tags against desired tags and apply only the delta via the * AppSync `tagResource`/`untagResource` operations. */ export const syncAppSyncTags = Effect.fn(function* ({ resourceArn, oldTags, newTags, }: { resourceArn: string; oldTags: Record; newTags: Record; }) { const { removed, upsert } = diffTags(oldTags, newTags); if (removed.length > 0) { yield* appsync .untagResource({ resourceArn, tagKeys: removed }) .pipe(Effect.catchTag("NotFoundException", () => Effect.void)); } if (upsert.length > 0) { yield* appsync.tagResource({ resourceArn, tags: normalizeTags(upsert), }); } });