1import * as scheduler from "@distilled.cloud/aws/scheduler";
2import * as Effect from "effect/Effect";
3import * as Schedule_ from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11
12/**
13 * EventBridge Scheduler validates that the target's execution role is
14 * assumable by `scheduler.amazonaws.com`. A freshly-created IAM role can take
15 * well over a minute to propagate, surfacing as the typed
16 * `ExecutionRoleNotAssumable` error ("The execution role you provide must
17 * allow AWS EventBridge Scheduler to assume the role"). Retry (bounded) until
18 * propagation completes.
19 */
20const retryUntilRoleAssumable = <A, E extends { _tag: string }, R>(
21 effect: Effect.Effect<A, E, R>,
22): Effect.Effect<A, E, R> =>
23 Effect.retry(effect, {
24 while: (e) => e._tag === "ExecutionRoleNotAssumable",
25 schedule: Schedule_.spaced("5 seconds"),
26 times: 24,
27 });
28
29export interface ScheduleProps {
30 /**
31 * Schedule name. If omitted, Alchemy generates a deterministic name.
32 */
33 name?: string;
34 /**
35 * Optional schedule group. Defaults to the AWS default group.
36 */
37 groupName?: Input<string>;
38 /**
39 * Required schedule expression, such as `rate(5 minutes)` or `cron(...)`.
40 */
41 scheduleExpression: string;
42 /**
43 * Optional start date.
44 */
45 startDate?: Date;
46 /**
47 * Optional end date.
48 */
49 endDate?: Date;
50 /**
51 * Optional description.
52 */
53 description?: string;
54 /**
55 * Optional timezone for cron or at expressions.
56 */
57 scheduleExpressionTimezone?: string;
58 /**
59 * Desired schedule state.
60 */
61 state?: string;
62 /**
63 * Optional KMS key ARN.
64 */
65 kmsKeyArn?: Input<string>;
66 /**
67 * Scheduler target configuration.
68 */
69 target: Input<scheduler.Target>;
70 /**
71 * Flexible time window configuration.
72 */
73 flexibleTimeWindow?: Input<scheduler.FlexibleTimeWindow>;
74 /**
75 * Action after a one-time schedule completes.
76 */
77 actionAfterCompletion?: string;
78}
79
80/**
81 * An EventBridge Scheduler schedule.
82 *
83 * `Schedule` is the canonical time-based delivery primitive. High-level helpers
84 * like `every`, `cron`, and `at` can synthesize the target role and scheduler
85 * target configuration on top of this resource.
86 * ### Creating Schedules
87 * **Example:** Hourly Schedule
88 * ```typescript
89 * const schedule = yield* Schedule("HourlyJob", {
90 * scheduleExpression: "rate(1 hour)",
91 * target: {
92 * Arn: fn.functionArn,
93 * RoleArn: role.roleArn,
94 * },
95 * flexibleTimeWindow: {
96 * Mode: "OFF",
97 * },
98 * });
99 * ```
100 *
101 * @resource
102 */
103export interface Schedule extends Resource<
104 "AWS.Scheduler.Schedule",
105 ScheduleProps,
106 {
107 /**
108 * ARN of the schedule.
109 */
110 scheduleArn: string;
111 /**
112 * Name of the schedule.
113 */
114 scheduleName: string;
115 /**
116 * Name of the schedule group containing the schedule.
117 */
118 groupName: string;
119 /**
120 * Current state of the schedule (`ENABLED` or `DISABLED`).
121 */
122 state: string | undefined;
123 },
124 never,
125 Providers
126> {}
127
128export const Schedule = Resource<Schedule>("AWS.Scheduler.Schedule");
129
130export const ScheduleProvider = () =>
131 Provider.effect(
132 Schedule,
133 Effect.gen(function* () {
134 const toName = (id: string, props: ScheduleProps) =>
135 props.name
136 ? Effect.succeed(props.name)
137 : createPhysicalName({ id, maxLength: 64 });
138
139 return {
140 stables: ["scheduleArn", "scheduleName", "groupName"],
141 diff: Effect.fn(function* ({ id, olds, news }) {
142 if (!isResolved(news)) return undefined;
143 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
144 return { action: "replace" } as const;
145 }
146
147 if ((olds.groupName ?? "default") !== (news.groupName ?? "default")) {
148 return { action: "replace" } as const;
149 }
150 }),
151 read: Effect.fn(function* ({ id, olds, output }) {
152 const scheduleName =
153 output?.scheduleName ?? (yield* toName(id, olds));
154 const groupName =
155 output?.groupName ??
156 (olds.groupName as string | undefined) ??
157 "default";
158 const described = yield* scheduler
159 .getSchedule({
160 Name: scheduleName,
161 GroupName: groupName !== "default" ? groupName : undefined,
162 })
163 .pipe(
164 Effect.catchTag("ResourceNotFoundException", () =>
165 Effect.succeed(undefined),
166 ),
167 );
168
169 if (!described?.Arn || !described.Name) {
170 return undefined;
171 }
172
173 return {
174 scheduleArn: described.Arn,
175 scheduleName: described.Name,
176 groupName: described.GroupName ?? groupName,
177 state: described.State,
178 };
179 }),
180 reconcile: Effect.fn(function* ({ id, news, output, session }) {
181 const scheduleName =
182 output?.scheduleName ?? (yield* toName(id, news));
183 const groupName =
184 output?.groupName ??
185 (news.groupName as string | undefined) ??
186 "default";
187 const groupNameParam =
188 groupName !== "default" ? groupName : undefined;
189
190 const desiredConfig = {
191 ScheduleExpression: news.scheduleExpression,
192 StartDate: news.startDate,
193 EndDate: news.endDate,
194 Description: news.description,
195 ScheduleExpressionTimezone: news.scheduleExpressionTimezone,
196 State: news.state,
197 KmsKeyArn: news.kmsKeyArn as string | undefined,
198 Target: news.target as scheduler.Target,
199 FlexibleTimeWindow: (news.flexibleTimeWindow as
200 | scheduler.FlexibleTimeWindow
201 | undefined) ?? {
202 Mode: "OFF" as const,
203 },
204 ActionAfterCompletion: news.actionAfterCompletion,
205 };
206
207 // Observe — fetch live schedule.
208 let observed = yield* scheduler
209 .getSchedule({ Name: scheduleName, GroupName: groupNameParam })
210 .pipe(
211 Effect.catchTag("ResourceNotFoundException", () =>
212 Effect.succeed(undefined),
213 ),
214 );
215
216 // Ensure — create if missing. EventBridge Scheduler does not support
217 // tagging individual schedules (only schedule groups), so ownership
218 // can't be branded/verified via tags. A `ConflictException` means a
219 // schedule with this deterministic name already exists; fall through
220 // to the sync (`updateSchedule`) path to converge it.
221 if (!observed?.Arn) {
222 yield* scheduler
223 .createSchedule({
224 Name: scheduleName,
225 GroupName: groupNameParam,
226 ...desiredConfig,
227 })
228 .pipe(
229 retryUntilRoleAssumable,
230 Effect.catchTag("ConflictException", () => Effect.void),
231 );
232 observed = yield* scheduler
233 .getSchedule({ Name: scheduleName, GroupName: groupNameParam })
234 .pipe(
235 Effect.catchTag("ResourceNotFoundException", () =>
236 Effect.succeed(undefined),
237 ),
238 );
239 }
240
241 if (!observed?.Arn) {
242 return yield* Effect.fail(
243 new Error(`Failed to read created Schedule '${scheduleName}'`),
244 );
245 }
246
247 const scheduleArn = observed.Arn;
248
249 // Sync schedule configuration. Scheduler doesn't support a partial
250 // update — `updateSchedule` is a full PUT — so we always send the
251 // full desired config. Fields like `state` are reflected in
252 // `observed.State`; sending a no-op update is cheap.
253 yield* scheduler
254 .updateSchedule({
255 Name: scheduleName,
256 GroupName: groupNameParam,
257 ...desiredConfig,
258 })
259 .pipe(retryUntilRoleAssumable);
260
261 yield* session.note(scheduleArn);
262
263 return {
264 scheduleArn,
265 scheduleName,
266 groupName,
267 state: news.state ?? observed.State,
268 };
269 }),
270 list: () =>
271 Effect.gen(function* () {
272 // Enumerate every schedule in the account/region. Omitting
273 // GroupName makes ListSchedules return summaries across all
274 // schedule groups, paginated.
275 const summaries = yield* scheduler.listSchedules.pages({}).pipe(
276 Stream.runCollect,
277 Effect.map((chunk) =>
278 Array.from(chunk).flatMap((page) =>
279 (page.Schedules ?? []).filter(
280 (s): s is scheduler.ScheduleSummary & { Name: string } =>
281 s.Name != null,
282 ),
283 ),
284 ),
285 );
286
287 // Hydrate each summary via GetSchedule into the exact `read`
288 // shape. Skip schedules deleted between list and get.
289 const rows = yield* Effect.forEach(
290 summaries,
291 (summary) => {
292 const groupName = summary.GroupName ?? "default";
293 return scheduler
294 .getSchedule({
295 Name: summary.Name,
296 GroupName: groupName !== "default" ? groupName : undefined,
297 })
298 .pipe(
299 Effect.map((described) =>
300 described.Arn && described.Name
301 ? {
302 scheduleArn: described.Arn,
303 scheduleName: described.Name,
304 groupName: described.GroupName ?? groupName,
305 state: described.State,
306 }
307 : undefined,
308 ),
309 Effect.catchTag("ResourceNotFoundException", () =>
310 Effect.succeed(undefined),
311 ),
312 );
313 },
314 { concurrency: 10 },
315 );
316
317 return rows.filter(
318 (row): row is Schedule["Attributes"] => row !== undefined,
319 );
320 }),
321 delete: Effect.fn(function* ({ output }) {
322 yield* scheduler
323 .deleteSchedule({
324 Name: output.scheduleName,
325 GroupName:
326 output.groupName !== "default" ? output.groupName : undefined,
327 })
328 .pipe(
329 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
330 );
331 }),
332 };
333 }),
334 );