1import * as opensearch from "@distilled.cloud/aws/opensearch";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4
5/**
6 * Convert OpenSearch's `[{ Key, Value }]` tag list into a plain record,
7 * dropping any entry missing a key or value.
8 */
9export const toTagRecord = (
10 tags: ReadonlyArray<{ Key?: string; Value?: string }> | undefined,
11): Record<string, string> =>
12 Object.fromEntries(
13 (tags ?? [])
14 .filter(
15 (tag): tag is { Key: string; Value: string } =>
16 typeof tag.Key === "string" && typeof tag.Value === "string",
17 )
18 .map((tag) => [tag.Key, tag.Value]),
19 );
20
21/**
22 * Read the observed tags for an OpenSearch domain by ARN. A domain that has
23 * just been created (or is mid-transition) can transiently reject `listTags`;
24 * treat any failure as "no observed tags" so tag reconciliation still runs.
25 */
26export const readDomainTags = Effect.fn(function* (arn: string) {
27 const response = yield* opensearch
28 .listTags({ ARN: arn })
29 .pipe(Effect.catch(() => Effect.succeed(undefined)));
30 return toTagRecord(response?.TagList);
31});
32
33/**
34 * True when any DEFINED leaf of `desired` differs from the corresponding
35 * value in `observed`. Keys the caller left undefined are ignored, so the
36 * comparison only covers configuration the user actually expressed.
37 */
38export const subsetDiffers = (desired: unknown, observed: unknown): boolean => {
39 if (desired === undefined) return false;
40 if (Array.isArray(desired)) {
41 if (!Array.isArray(observed) || observed.length !== desired.length) {
42 return true;
43 }
44 return desired.some((value, i) => subsetDiffers(value, observed[i]));
45 }
46 if (typeof desired === "object" && desired !== null) {
47 if (typeof observed !== "object" || observed === null) return true;
48 return Object.entries(desired).some(
49 ([key, value]) =>
50 value !== undefined &&
51 subsetDiffers(value, (observed as Record<string, unknown>)[key]),
52 );
53 }
54 return desired !== observed;
55};
56
57/**
58 * Poll an OpenSearch domain until `done` observes a settled state (bounded:
59 * 15s x 120 = ~30 minutes; domain provisioning and blue/green config changes
60 * typically take 15-25 minutes). If the schedule exhausts first, the last
61 * observation is returned as-is so the caller sees the real (still
62 * converging) domain rather than a spurious failure.
63 *
64 * The explicit `Effect.Effect<A, E, R>` return annotation is load-bearing:
65 * inlining repeat/retry combinators in provider lifecycle code lets their
66 * conditional return types survive into declaration emit and widen the
67 * provider layer to `unknown` R, poisoning `AWS.providers()` downstream.
68 */
69export const repeatUntilDomainState = <E extends { readonly _tag: string }, R>(
70 read: Effect.Effect<opensearch.DomainStatus | undefined, E, R>,
71 done: (domain: opensearch.DomainStatus | undefined) => boolean,
72): Effect.Effect<opensearch.DomainStatus | undefined, E, R> =>
73 Effect.repeat(read, {
74 schedule: Schedule.max([
75 Schedule.fixed("15 seconds"),
76 Schedule.recurs(120),
77 ]),
78 until: done,
79 });
80
81/** A domain is active once created and no config change is being applied. */
82export const isDomainActive = (
83 domain: opensearch.DomainStatus | undefined,
84): boolean =>
85 domain === undefined ||
86 (domain.Created === true &&
87 domain.Processing !== true &&
88 domain.Deleted !== true);
89
90/** A domain can be deleted once it is no longer applying a config change. */
91export const isDomainDeletable = (
92 domain: opensearch.DomainStatus | undefined,
93): boolean =>
94 domain === undefined || domain.Deleted === true || domain.Processing !== true;