1import * as ivs from "@distilled.cloud/aws/ivs";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { diffTags } from "../../Tags.ts";
5
6/**
7 * IVS wire tags allow `undefined` values in the record type — flatten to a
8 * plain string record, dropping malformed entries.
9 */
10export const toTagRecord = (
11 tags: { [key: string]: string | undefined } | undefined,
12): Record<string, string> =>
13 Object.fromEntries(
14 Object.entries(tags ?? {}).filter(
15 (entry): entry is [string, string] => typeof entry[1] === "string",
16 ),
17 );
18
19/**
20 * Read the observed tags of an IVS resource. Tag reads are best-effort —
21 * a failure (e.g. a race with deletion) reports no tags.
22 */
23export const readIvsTags = Effect.fn(function* (arn: string) {
24 const response = yield* ivs
25 .listTagsForResource({ resourceArn: arn })
26 .pipe(Effect.catch(() => Effect.succeed(undefined)));
27 return toTagRecord(response?.tags);
28});
29
30/**
31 * Sync tags on an IVS resource: diff the OBSERVED cloud tags against the
32 * desired set and apply only the delta.
33 */
34export const syncIvsTags = Effect.fn(function* (
35 arn: string,
36 desiredTags: Record<string, string>,
37) {
38 const observedTags = yield* readIvsTags(arn);
39 const { removed, upsert } = diffTags(observedTags, desiredTags);
40 if (upsert.length > 0) {
41 yield* ivs
42 .tagResource({
43 resourceArn: arn,
44 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
45 })
46 .pipe(retryWhileThrottled);
47 }
48 if (removed.length > 0) {
49 yield* ivs
50 .untagResource({ resourceArn: arn, tagKeys: removed })
51 .pipe(retryWhileThrottled);
52 }
53});
54
55/**
56 * Explicitly-typed pipeable retry helper. Inlining `Effect.retry` in a
57 * provider op lets `Retry.Return`'s conditional type survive into
58 * declaration emit and widens the provider layer to `unknown` for every
59 * `AWS.providers()` consumer — keep the annotation explicit.
60 *
61 * Retries `ConflictException` (e.g. deleting a channel while a stream is
62 * live, or a resource is mid-transition) on a bounded schedule.
63 */
64export const retryWhileConflict = <A, E extends { readonly _tag: string }, R>(
65 self: Effect.Effect<A, E, R>,
66): Effect.Effect<A, E, R> =>
67 Effect.retry(self, {
68 while: (e) => e._tag === "ConflictException",
69 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(8)]),
70 });
71
72/**
73 * Retries `ThrottlingException` on a bounded exponential schedule. IVS
74 * APIs have very low TPS limits (e.g. ListPlaybackKeyPairs is 1 TPS), so
75 * back-to-back reconciles routinely trip 429s.
76 */
77export const retryWhileThrottled = <A, E extends { readonly _tag: string }, R>(
78 self: Effect.Effect<A, E, R>,
79): Effect.Effect<A, E, R> =>
80 Effect.retry(self, {
81 while: (e) => e._tag === "ThrottlingException",
82 schedule: Schedule.max([
83 Schedule.exponential("1 second"),
84 Schedule.recurs(6),
85 ]),
86 });