1import * as mm from "@distilled.cloud/aws/mailmanager";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { diffTags, tagRecord } from "../../Tags.ts";
5
6/**
7 * Read the observed tags for any Mail Manager resource ARN. Tag reads are
8 * best-effort — a race with deletion (or a missing-tags edge) degrades to an
9 * empty record rather than failing the lifecycle operation.
10 */
11export const readMailManagerTags = (arn: string) =>
12 mm.listTagsForResource({ ResourceArn: arn }).pipe(
13 Effect.map((r) => tagRecord(r.Tags ?? [])),
14 Effect.catch(() => Effect.succeed<Record<string, string>>({})),
15 );
16
17/**
18 * Converge the tags on a Mail Manager resource to `desired`, diffing against
19 * the OBSERVED cloud tags so adoption converges.
20 */
21export const syncMailManagerTags = Effect.fn(function* (
22 arn: string,
23 desired: Record<string, string>,
24) {
25 const current = yield* readMailManagerTags(arn);
26 const { upsert, removed } = diffTags(current, desired);
27 if (upsert.length > 0) {
28 yield* mm.tagResource({ ResourceArn: arn, Tags: upsert });
29 }
30 if (removed.length > 0) {
31 yield* mm.untagResource({ ResourceArn: arn, TagKeys: removed });
32 }
33});
34
35/**
36 * Retry an operation while Mail Manager reports a ConflictException — e.g.
37 * deleting an ingress point that is still PROVISIONING. Explicitly typed so
38 * `Retry.Return`'s conditional type never leaks into declaration emit.
39 */
40export const retryWhileMailManagerConflict = <
41 A,
42 E extends { readonly _tag: string },
43 R,
44>(
45 self: Effect.Effect<A, E, R>,
46): Effect.Effect<A, E, R> =>
47 Effect.retry(self, {
48 while: (e) => e._tag === "ConflictException",
49 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(10)]),
50 });
51
52/**
53 * Repeat a poll until `done` holds (bounded). Explicitly typed for the same
54 * declaration-emit reason as above.
55 */
56export const repeatUntilMailManagerStable = <A, E, R>(
57 self: Effect.Effect<A, E, R>,
58 done: (a: A) => boolean,
59 times = 24,
60): Effect.Effect<A, E, R> =>
61 Effect.repeat(self, {
62 schedule: Schedule.spaced("5 seconds"),
63 until: done,
64 times,
65 });
66
67/**
68 * Deep structural equality on JSON-ish values with key order and
69 * `undefined`-valued members normalized away. Used to compare desired config
70 * structs (rules, policy statements, authentication) against observed cloud
71 * state.
72 */
73const normalize = (value: unknown): unknown => {
74 if (Array.isArray(value)) return value.map(normalize);
75 if (value !== null && typeof value === "object") {
76 return Object.fromEntries(
77 Object.entries(value as Record<string, unknown>)
78 .filter(([, v]) => v !== undefined)
79 .sort(([l], [r]) => l.localeCompare(r))
80 .map(([k, v]) => [k, normalize(v)]),
81 );
82 }
83 return value;
84};
85
86export const sameShape = (l: unknown, r: unknown) =>
87 JSON.stringify(normalize(l)) === JSON.stringify(normalize(r));