1import * as quicksight from "@distilled.cloud/aws/quicksight";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { diffTags } from "../../Tags.ts";
6
7/**
8 * A QuickSight resource whose asynchronous create/update has not converged
9 * to a terminal status yet.
10 */
11export class QuickSightNotSettled extends Data.TaggedError(
12 "QuickSightNotSettled",
13)<{
14 readonly resourceId: string;
15 readonly status: string | undefined;
16}> {}
17
18/**
19 * A QuickSight resource whose asynchronous create/update converged to a
20 * `*_FAILED` status.
21 */
22export class QuickSightOperationFailed extends Data.TaggedError(
23 "QuickSightOperationFailed",
24)<{
25 readonly resourceId: string;
26 readonly status: string;
27 readonly reason: string | undefined;
28}> {}
29
30const isInProgress = (status: string | undefined): boolean =>
31 status === "CREATION_IN_PROGRESS" || status === "UPDATE_IN_PROGRESS";
32
33const isFailed = (status: string | undefined): boolean =>
34 status === "CREATION_FAILED" || status === "UPDATE_FAILED";
35
36/**
37 * Poll a QuickSight resource until its `ResourceStatus` reaches a terminal
38 * value. `*_IN_PROGRESS` repeats (bounded); `*_FAILED` fails immediately.
39 * Creation/update of most QuickSight resources settles within a few seconds
40 * (data-source connectivity validation can take longer) — budget ~2.5 min.
41 *
42 * The explicit `Effect.Effect<A, E, R>` return annotation is load-bearing:
43 * inlining retry/repeat combinators in provider lifecycle code lets their
44 * conditional return types survive into declaration emit and widen the
45 * provider layer to `unknown` (see `../VpcLattice/internal.ts`).
46 *
47 * If the bounded schedule is exhausted while the resource is still
48 * `*_IN_PROGRESS`, the last observation is returned as-is so the caller sees
49 * the real (still-converging) resource rather than a spurious failure.
50 */
51export const waitForSettled = <
52 A extends { status?: string },
53 E extends { readonly _tag: string },
54 R,
55>(
56 resourceId: string,
57 read: Effect.Effect<A | undefined, E, R>,
58): Effect.Effect<A | undefined, E | QuickSightOperationFailed, R> =>
59 Effect.flatMap(
60 Effect.repeat(read, {
61 schedule: Schedule.max([
62 Schedule.fixed("5 seconds"),
63 Schedule.recurs(30),
64 ]),
65 until: (observed) =>
66 observed === undefined || !isInProgress(observed.status),
67 }),
68 (observed): Effect.Effect<A | undefined, QuickSightOperationFailed> =>
69 observed !== undefined && isFailed(observed.status)
70 ? Effect.fail(
71 new QuickSightOperationFailed({
72 resourceId,
73 status: observed.status!,
74 reason: undefined,
75 }),
76 )
77 : Effect.succeed(observed),
78 );
79
80/**
81 * Convert a QuickSight wire tag list into a plain record.
82 */
83export const toTagRecord = (
84 tags: ReadonlyArray<{ Key?: string; Value?: string }> | undefined,
85): Record<string, string> =>
86 Object.fromEntries(
87 (tags ?? [])
88 .filter(
89 (tag): tag is { Key: string; Value: string } =>
90 typeof tag.Key === "string" && typeof tag.Value === "string",
91 )
92 .map((tag) => [tag.Key, tag.Value]),
93 );
94
95/**
96 * Read the observed tags of a QuickSight resource by ARN. Best-effort — a
97 * failure (e.g. a race with deletion) reports no tags.
98 */
99export const readQuickSightTags = Effect.fn(function* (arn: string) {
100 const response = yield* quicksight
101 .listTagsForResource({ ResourceArn: arn })
102 .pipe(Effect.catch(() => Effect.succeed(undefined)));
103 return toTagRecord(response?.Tags);
104});
105
106/**
107 * Sync tags on a QuickSight resource: diff OBSERVED cloud tags against the
108 * desired set and apply only the delta.
109 */
110export const syncQuickSightTags = Effect.fn(function* (
111 arn: string,
112 desiredTags: Record<string, string>,
113) {
114 const observedTags = yield* readQuickSightTags(arn);
115 const { removed, upsert } = diffTags(observedTags, desiredTags);
116 if (upsert.length > 0) {
117 yield* quicksight.tagResource({ ResourceArn: arn, Tags: upsert });
118 }
119 if (removed.length > 0) {
120 yield* quicksight.untagResource({ ResourceArn: arn, TagKeys: removed });
121 }
122});
123
124/**
125 * Convert a desired tag record into the wire tag list for create calls.
126 * QuickSight requires a non-empty `Tags` list when supplied, so an empty
127 * record maps to `undefined`.
128 */
129export const toWireTags = (
130 tags: Record<string, string>,
131): quicksight.Tag[] | undefined => {
132 const entries = Object.entries(tags);
133 return entries.length > 0
134 ? entries.map(([Key, Value]) => ({ Key, Value }))
135 : undefined;
136};