1import * as mediapackagev2 from "@distilled.cloud/aws/mediapackagev2";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 deleteChannelWithEndpoints,
13 listGroupChannels,
14 retryWhileMpConflict,
15 syncMpTags,
16 toMpTagRecord,
17} from "./internal.ts";
18
19export interface ChannelGroupProps {
20 /**
21 * Name of the channel group. Must be unique within the account/region and
22 * match `^[a-zA-Z0-9_-]+$`. If omitted, a unique name is generated.
23 * Changing the name replaces the channel group.
24 */
25 channelGroupName?: string;
26 /**
27 * Optional description of the channel group (up to 1024 characters).
28 */
29 description?: string;
30 /**
31 * User-defined tags for the channel group. Merged with internal Alchemy
32 * tags.
33 */
34 tags?: Record<string, string>;
35}
36
37export interface ChannelGroup extends Resource<
38 "AWS.MediaPackageV2.ChannelGroup",
39 ChannelGroupProps,
40 {
41 /** Name of the channel group. */
42 channelGroupName: string;
43 /** ARN of the channel group. */
44 channelGroupArn: string;
45 /** Shared egress domain that serves all origin endpoints in the group. */
46 egressDomain: string;
47 },
48 never,
49 Providers
50> {}
51
52/**
53 * An AWS Elemental MediaPackage v2 channel group — the top-level container
54 * for channels and origin endpoints. All channels and origin endpoints in a
55 * group share one egress domain, giving downstream players and CDNs
56 * predictable URLs for stream delivery.
57 *
58 * ### Creating a Channel Group
59 * **Example:** Basic Channel Group
60 * ```typescript
61 * import * as MediaPackageV2 from "alchemy/AWS/MediaPackageV2";
62 *
63 * const group = yield* MediaPackageV2.ChannelGroup("Live");
64 * ```
65 *
66 * **Example:** Channel Group with Description and Tags
67 * ```typescript
68 * const group = yield* MediaPackageV2.ChannelGroup("Live", {
69 * description: "Live sports streams",
70 * tags: { team: "media" },
71 * });
72 * ```
73 *
74 * ### Egress Domain
75 * **Example:** Use the shared egress domain
76 * ```typescript
77 * const group = yield* MediaPackageV2.ChannelGroup("Live");
78 * // e.g. abcde.egress.xyz.mediapackagev2.us-east-1.amazonaws.com
79 * const domain = group.egressDomain;
80 * ```
81 *
82 * @resource
83 */
84export const ChannelGroup = Resource<ChannelGroup>(
85 "AWS.MediaPackageV2.ChannelGroup",
86);
87
88export const ChannelGroupProvider = () =>
89 Provider.effect(
90 ChannelGroup,
91 Effect.gen(function* () {
92 const createName = Effect.fn(function* (
93 id: string,
94 props: ChannelGroupProps,
95 ) {
96 return (
97 props.channelGroupName ??
98 (yield* createPhysicalName({ id, maxLength: 256 }))
99 );
100 });
101
102 const toAttrs = (group: {
103 ChannelGroupName: string;
104 Arn: string;
105 EgressDomain: string;
106 }) => ({
107 channelGroupName: group.ChannelGroupName,
108 channelGroupArn: group.Arn,
109 egressDomain: group.EgressDomain,
110 });
111
112 /** Get a channel group by name; typed not-found → undefined. */
113 const getGroup = Effect.fn(function* (channelGroupName: string) {
114 return yield* mediapackagev2
115 .getChannelGroup({ ChannelGroupName: channelGroupName })
116 .pipe(
117 Effect.catchTag("ResourceNotFoundException", () =>
118 Effect.succeed(undefined),
119 ),
120 );
121 });
122
123 return {
124 stables: ["channelGroupName", "channelGroupArn", "egressDomain"],
125
126 diff: Effect.fn(function* ({ id, olds, news }) {
127 if (!isResolved(news)) return undefined;
128 const oldName = yield* createName(id, olds);
129 const newName = yield* createName(id, news);
130 // The name is the group's identity; a change means a new group.
131 if (oldName !== newName) return { action: "replace" } as const;
132 }),
133
134 read: Effect.fn(function* ({ id, olds, output }) {
135 const name =
136 output?.channelGroupName ?? (yield* createName(id, olds ?? {}));
137 const group = yield* getGroup(name);
138 if (group === undefined) return undefined;
139 const attrs = toAttrs(group);
140 return (yield* hasAlchemyTags(id, toMpTagRecord(group.Tags)))
141 ? attrs
142 : Unowned(attrs);
143 }),
144
145 reconcile: Effect.fn(function* ({ id, news, output, session }) {
146 const internalTags = yield* createInternalTags(id);
147 const desiredTags = { ...internalTags, ...news.tags };
148 const name =
149 output?.channelGroupName ?? (yield* createName(id, news));
150
151 // 1. Observe — cloud state is authoritative; output is an id cache.
152 let group = yield* getGroup(name);
153
154 // 2. Ensure — create if missing; a Conflict means a peer created it
155 // concurrently, so fall through to observing the winner.
156 if (group === undefined) {
157 group = yield* mediapackagev2
158 .createChannelGroup({
159 ChannelGroupName: name,
160 Description: news.description,
161 Tags: desiredTags,
162 })
163 .pipe(
164 Effect.catchTag("ConflictException", () =>
165 mediapackagev2.getChannelGroup({ ChannelGroupName: name }),
166 ),
167 );
168 } else if ((news.description ?? "") !== (group.Description ?? "")) {
169 // 3. Sync — the description is the only mutable field.
170 group = yield* mediapackagev2.updateChannelGroup({
171 ChannelGroupName: name,
172 Description: news.description,
173 });
174 }
175
176 // 3b. Sync tags — diff against OBSERVED cloud tags.
177 yield* syncMpTags(group.Arn, toMpTagRecord(group.Tags), desiredTags);
178
179 yield* session.note(name);
180 return toAttrs(group);
181 }),
182
183 delete: Effect.fn(function* ({ output }) {
184 const channelGroupName = output.channelGroupName;
185 // Reap child channels (and their origin endpoints) first. A normal
186 // stack destroy deletes children before the group, so this observes
187 // nothing — but an orphan sweep can target a group whose children
188 // it never enumerated, and deleting the group would then Conflict
189 // until the retry budget ran out and leak the group.
190 const channels = yield* listGroupChannels(channelGroupName);
191 yield* Effect.forEach(
192 channels,
193 (channel) =>
194 deleteChannelWithEndpoints(channelGroupName, channel.ChannelName),
195 { concurrency: 5, discard: true },
196 );
197 // MediaPackage v2 deletes are idempotent (deleting a missing group
198 // succeeds), but the group transiently rejects deletion with a
199 // Conflict while its just-deleted channels are cleaned up.
200 yield* mediapackagev2
201 .deleteChannelGroup({ ChannelGroupName: channelGroupName })
202 .pipe(retryWhileMpConflict);
203 }),
204
205 list: () =>
206 Effect.gen(function* () {
207 const items = yield* mediapackagev2.listChannelGroups
208 .pages({})
209 .pipe(
210 Stream.runCollect,
211 Effect.map((chunk) =>
212 Array.from(chunk).flatMap((page) => page.Items ?? []),
213 ),
214 );
215 // The list shape omits the egress domain, so hydrate each item
216 // via get; a group can vanish between enumeration and hydration.
217 const groups = yield* Effect.forEach(
218 items,
219 (item) =>
220 mediapackagev2
221 .getChannelGroup({ ChannelGroupName: item.ChannelGroupName })
222 .pipe(
223 Effect.map(toAttrs),
224 Effect.catchTag("ResourceNotFoundException", () =>
225 Effect.succeed(undefined),
226 ),
227 ),
228 { concurrency: 5 },
229 );
230 return groups.filter(
231 (g): g is ChannelGroup["Attributes"] => g !== undefined,
232 );
233 }),
234 };
235 }),
236 );