1import { Region, type RegionName } from "@distilled.cloud/aws/Region";
2import * as iotfleetwise from "@distilled.cloud/aws/iotfleetwise";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { 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 */
16const FLEETWISE_REGIONS: ReadonlySet<string> = new Set([
17 "us-east-1",
18 "eu-central-1",
19]);
20
21/** @internal */
22export 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 */
34export 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 */
53export 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 */
74export 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 */
85export 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 */
114export 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 */
128export 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 */
142export const stableEquals = (left: unknown, right: unknown): boolean =>
143 stableStringify(left) === stableStringify(right);
144
145const stableStringify = (value: unknown): string =>
146 JSON.stringify(normalize(value));
147
148const 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};