| 1 | import { Region, type RegionName } from "@distilled.cloud/aws/Region"; |
| 2 | import * as iotfleetwise from "@distilled.cloud/aws/iotfleetwise"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { diffTags } from "../../Tags.ts"; |
| 6 | |
| 7 | /** |
| 8 | * AWS IoT FleetWise is offered only in `us-east-1` and `eu-central-1` |
| 9 | * (there is no `iotfleetwise.{region}.amazonaws.com` endpoint anywhere |
| 10 | * else). The providers follow the ambient region when it is one of the |
| 11 | * supported two and pin `us-east-1` otherwise, mirroring how WAFv2 |
| 12 | * CLOUDFRONT-scoped resources pin their home region. |
| 13 | * |
| 14 | * @internal |
| 15 | */ |
| 16 | const FLEETWISE_REGIONS: ReadonlySet<string> = new Set([ |
| 17 | "us-east-1", |
| 18 | "eu-central-1", |
| 19 | ]); |
| 20 | |
| 21 | /** @internal */ |
| 22 | export const FLEETWISE_HOME_REGION: RegionName = "us-east-1"; |
| 23 | |
| 24 | /** |
| 25 | * Run a distilled IoT FleetWise effect in a supported region: the ambient |
| 26 | * region when FleetWise is offered there, otherwise `us-east-1`. |
| 27 | * |
| 28 | * `Region`'s service value is an `Effect<RegionName>` (see |
| 29 | * `@distilled.cloud/aws/Region`), so it is provided as an effect, not a |
| 30 | * bare string. |
| 31 | * |
| 32 | * @internal |
| 33 | */ |
| 34 | export const inFleetWiseRegion = <A, E, R>( |
| 35 | effect: Effect.Effect<A, E, R>, |
| 36 | ): Effect.Effect<A, E, R | Region> => |
| 37 | Effect.gen(function* () { |
| 38 | const ambient = yield* yield* Region; |
| 39 | return FLEETWISE_REGIONS.has(ambient) |
| 40 | ? yield* effect |
| 41 | : yield* effect.pipe( |
| 42 | Effect.provideService(Region, Effect.succeed(FLEETWISE_HOME_REGION)), |
| 43 | ); |
| 44 | }); |
| 45 | |
| 46 | /** |
| 47 | * Read the observed tags of an IoT FleetWise resource as a plain record. |
| 48 | * Tag-read failures degrade to an empty record so tag drift never blocks |
| 49 | * reads of the resource itself. |
| 50 | * |
| 51 | * @internal |
| 52 | */ |
| 53 | export const readFleetWiseTags = Effect.fn(function* (arn: string) { |
| 54 | const response = yield* iotfleetwise |
| 55 | .listTagsForResource({ ResourceARN: arn }) |
| 56 | .pipe( |
| 57 | inFleetWiseRegion, |
| 58 | Effect.catch(() => |
| 59 | Effect.succeed({ |
| 60 | Tags: [], |
| 61 | } as iotfleetwise.ListTagsForResourceResponse), |
| 62 | ), |
| 63 | ); |
| 64 | return Object.fromEntries( |
| 65 | (response.Tags ?? []).map((tag) => [tag.Key, tag.Value]), |
| 66 | ) as Record<string, string>; |
| 67 | }); |
| 68 | |
| 69 | /** |
| 70 | * Convert a tag record to the FleetWise wire shape. |
| 71 | * |
| 72 | * @internal |
| 73 | */ |
| 74 | export const toFleetWiseTagList = ( |
| 75 | tags: Record<string, string>, |
| 76 | ): iotfleetwise.Tag[] => |
| 77 | Object.entries(tags).map(([Key, Value]) => ({ Key, Value })); |
| 78 | |
| 79 | /** |
| 80 | * Converge an IoT FleetWise resource's tags on the desired record, diffing |
| 81 | * against OBSERVED cloud tags (never `olds`/`output`). |
| 82 | * |
| 83 | * @internal |
| 84 | */ |
| 85 | export const syncFleetWiseTags = Effect.fn(function* ( |
| 86 | arn: string, |
| 87 | desired: Record<string, string>, |
| 88 | ) { |
| 89 | const observed = yield* readFleetWiseTags(arn); |
| 90 | const { upsert, removed } = diffTags(observed, desired); |
| 91 | if (upsert.length > 0) { |
| 92 | yield* iotfleetwise |
| 93 | .tagResource({ ResourceARN: arn, Tags: upsert }) |
| 94 | .pipe(inFleetWiseRegion); |
| 95 | } |
| 96 | if (removed.length > 0) { |
| 97 | yield* iotfleetwise |
| 98 | .untagResource({ ResourceARN: arn, TagKeys: removed }) |
| 99 | .pipe(inFleetWiseRegion); |
| 100 | } |
| 101 | }); |
| 102 | |
| 103 | /** |
| 104 | * Retry an effect through FleetWise `ConflictException` windows (e.g. a |
| 105 | * signal catalog whose model manifests are still deleting, or a decoder |
| 106 | * manifest with vehicles detaching). Bounded: ~30s total. |
| 107 | * |
| 108 | * Explicitly typed: inlining `Effect.retry` with options in provider |
| 109 | * lifecycle code widens the provider layer to `unknown` in declaration |
| 110 | * emit. |
| 111 | * |
| 112 | * @internal |
| 113 | */ |
| 114 | export const retryWhileConflict = <A, E extends { readonly _tag: string }, R>( |
| 115 | self: Effect.Effect<A, E, R>, |
| 116 | ): Effect.Effect<A, E, R> => |
| 117 | Effect.retry(self, { |
| 118 | while: (e) => e._tag === "ConflictException", |
| 119 | schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]), |
| 120 | }); |
| 121 | |
| 122 | /** |
| 123 | * Bounded eventual-consistency retry for observe-after-write reads |
| 124 | * (~20s total). |
| 125 | * |
| 126 | * @internal |
| 127 | */ |
| 128 | export const retryObservation = <A, E, R>( |
| 129 | self: Effect.Effect<A, E, R>, |
| 130 | ): Effect.Effect<A, E, R> => |
| 131 | Effect.retry(self, { |
| 132 | schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]), |
| 133 | }); |
| 134 | |
| 135 | /** |
| 136 | * Order-insensitive deep equality over plain JSON-ish values (props and |
| 137 | * observed FleetWise structures). Object keys are sorted; arrays stay |
| 138 | * positional. |
| 139 | * |
| 140 | * @internal |
| 141 | */ |
| 142 | export const stableEquals = (left: unknown, right: unknown): boolean => |
| 143 | stableStringify(left) === stableStringify(right); |
| 144 | |
| 145 | const stableStringify = (value: unknown): string => |
| 146 | JSON.stringify(normalize(value)); |
| 147 | |
| 148 | const normalize = (value: unknown): unknown => { |
| 149 | if (Array.isArray(value)) return value.map(normalize); |
| 150 | if (value !== null && typeof value === "object") { |
| 151 | return Object.fromEntries( |
| 152 | Object.entries(value as Record<string, unknown>) |
| 153 | .filter(([, v]) => v !== undefined) |
| 154 | .sort(([l], [r]) => l.localeCompare(r)) |
| 155 | .map(([k, v]) => [k, normalize(v)]), |
| 156 | ); |
| 157 | } |
| 158 | return value; |
| 159 | }; |