1import * as scheduler from "@distilled.cloud/aws/scheduler";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 diffTags,
13 hasTags,
14} from "../../Tags.ts";
15
16export interface ScheduleGroupProps {
17 /**
18 * Schedule group name. If omitted, Alchemy generates a deterministic name.
19 */
20 name?: string;
21 /**
22 * User-defined tags for the schedule group.
23 */
24 tags?: Record<string, string>;
25}
26
27/**
28 * An EventBridge Scheduler schedule group.
29 *
30 * Schedule groups provide a namespace for schedules so higher-level helpers can
31 * organize recurring jobs separately from one-shot or operational schedules.
32 * ### Creating Schedule Groups
33 * **Example:** Basic Group
34 * ```typescript
35 * const group = yield* ScheduleGroup("Operations", {
36 * tags: {
37 * domain: "ops",
38 * },
39 * });
40 * ```
41 *
42 * @resource
43 */
44export interface ScheduleGroup extends Resource<
45 "AWS.Scheduler.ScheduleGroup",
46 ScheduleGroupProps,
47 {
48 /**
49 * ARN of the schedule group.
50 */
51 scheduleGroupArn: string;
52 /**
53 * Name of the schedule group.
54 */
55 scheduleGroupName: string;
56 /**
57 * Current state of the schedule group (`ACTIVE` or `DELETING`).
58 */
59 state: string | undefined;
60 },
61 never,
62 Providers
63> {}
64
65export const ScheduleGroup = Resource<ScheduleGroup>(
66 "AWS.Scheduler.ScheduleGroup",
67);
68
69export const ScheduleGroupProvider = () =>
70 Provider.effect(
71 ScheduleGroup,
72 Effect.gen(function* () {
73 const toName = (id: string, props: ScheduleGroupProps) =>
74 props.name
75 ? Effect.succeed(props.name)
76 : createPhysicalName({ id, maxLength: 64 });
77
78 return {
79 stables: ["scheduleGroupArn", "scheduleGroupName"],
80 diff: Effect.fn(function* ({ id, olds, news }) {
81 if (!isResolved(news)) return undefined;
82 if (
83 (yield* toName(id, olds)) !==
84 (yield* toName(id, news as ScheduleGroupProps))
85 ) {
86 return { action: "replace" } as const;
87 }
88 }),
89 read: Effect.fn(function* ({ id, olds, output }) {
90 const scheduleGroupName =
91 output?.scheduleGroupName ?? (yield* toName(id, olds));
92 const described = yield* scheduler
93 .getScheduleGroup({
94 Name: scheduleGroupName,
95 })
96 .pipe(
97 Effect.catchTag("ResourceNotFoundException", () =>
98 Effect.succeed(undefined),
99 ),
100 );
101
102 if (!described?.Arn || !described.Name) {
103 return undefined;
104 }
105
106 return {
107 scheduleGroupArn: described.Arn,
108 scheduleGroupName: described.Name,
109 state: described.State,
110 };
111 }),
112 reconcile: Effect.fn(function* ({ id, news, output, session }) {
113 const scheduleGroupName =
114 output?.scheduleGroupName ?? (yield* toName(id, news));
115 const internalTags = yield* createInternalTags(id);
116 const desiredTags = { ...internalTags, ...news.tags };
117
118 // Observe — fetch live group; gracefully handle missing.
119 let observed = yield* scheduler
120 .getScheduleGroup({ Name: scheduleGroupName })
121 .pipe(
122 Effect.catchTag("ResourceNotFoundException", () =>
123 Effect.succeed(undefined),
124 ),
125 );
126
127 // Ensure — create if missing. Tolerate `ConflictException` as a
128 // race or adoption case: verify ownership via tags before
129 // continuing.
130 if (!observed?.Arn) {
131 yield* scheduler
132 .createScheduleGroup({
133 Name: scheduleGroupName,
134 Tags: createTagsList(desiredTags),
135 })
136 .pipe(
137 Effect.catchTag("ConflictException", () =>
138 scheduler.getScheduleGroup({ Name: scheduleGroupName }).pipe(
139 Effect.flatMap((existing) =>
140 existing.Arn
141 ? scheduler
142 .listTagsForResource({ ResourceArn: existing.Arn })
143 .pipe(
144 Effect.filterOrFail(
145 ({ Tags }) => hasTags(internalTags, Tags),
146 () =>
147 new Error(
148 `ScheduleGroup '${scheduleGroupName}' already exists and is not managed by alchemy`,
149 ),
150 ),
151 Effect.asVoid,
152 )
153 : Effect.fail(
154 new Error(
155 `ScheduleGroup '${scheduleGroupName}' already exists but could not be described`,
156 ),
157 ),
158 ),
159 ),
160 ),
161 );
162 observed = yield* scheduler
163 .getScheduleGroup({ Name: scheduleGroupName })
164 .pipe(
165 Effect.catchTag("ResourceNotFoundException", () =>
166 Effect.succeed(undefined),
167 ),
168 );
169 }
170
171 if (!observed?.Arn) {
172 return yield* Effect.fail(
173 new Error(
174 `Failed to read created ScheduleGroup '${scheduleGroupName}'`,
175 ),
176 );
177 }
178
179 const groupArn = observed.Arn;
180
181 // Sync tags — diff observed cloud tags against desired so
182 // adoption rewrites ownership tags.
183 const observedTagsResp = yield* scheduler
184 .listTagsForResource({ ResourceArn: groupArn })
185 .pipe(
186 Effect.catchTag("ResourceNotFoundException", () =>
187 Effect.succeed({ Tags: [] as scheduler.Tag[] }),
188 ),
189 );
190 const observedTags = Object.fromEntries(
191 (observedTagsResp.Tags ?? []).map((t) => [t.Key, t.Value]),
192 );
193 const { removed, upsert } = diffTags(observedTags, desiredTags);
194
195 if (removed.length > 0) {
196 yield* scheduler.untagResource({
197 ResourceArn: groupArn,
198 TagKeys: removed,
199 });
200 }
201 if (upsert.length > 0) {
202 yield* scheduler.tagResource({
203 ResourceArn: groupArn,
204 Tags: upsert,
205 });
206 }
207
208 yield* session.note(groupArn);
209
210 return {
211 scheduleGroupArn: groupArn,
212 scheduleGroupName,
213 state: observed?.State,
214 };
215 }),
216 list: () =>
217 Effect.gen(function* () {
218 // Enumerate every schedule group in the account/region,
219 // paginated.
220 const summaries = yield* scheduler.listScheduleGroups
221 .pages({})
222 .pipe(
223 Stream.runCollect,
224 Effect.map((chunk) =>
225 Array.from(chunk).flatMap((page) =>
226 (page.ScheduleGroups ?? []).filter(
227 (
228 g,
229 ): g is scheduler.ScheduleGroupSummary & {
230 Name: string;
231 } => g.Name != null,
232 ),
233 ),
234 ),
235 );
236
237 // Hydrate each summary via GetScheduleGroup into the exact
238 // `read` shape. Skip groups deleted between list and get.
239 const rows = yield* Effect.forEach(
240 summaries,
241 (summary) =>
242 scheduler.getScheduleGroup({ Name: summary.Name }).pipe(
243 Effect.map((described) =>
244 described.Arn && described.Name
245 ? {
246 scheduleGroupArn: described.Arn,
247 scheduleGroupName: described.Name,
248 state: described.State,
249 }
250 : undefined,
251 ),
252 Effect.catchTag("ResourceNotFoundException", () =>
253 Effect.succeed(undefined),
254 ),
255 ),
256 { concurrency: 10 },
257 );
258
259 return rows.filter(
260 (row): row is ScheduleGroup["Attributes"] => row !== undefined,
261 );
262 }),
263 delete: Effect.fn(function* ({ output }) {
264 yield* scheduler
265 .deleteScheduleGroup({
266 Name: output.scheduleGroupName,
267 })
268 .pipe(
269 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
270 );
271 }),
272 };
273 }),
274 );