1import type * as scheduler from "@distilled.cloud/aws/scheduler";
2import * as Effect from "effect/Effect";
3import type { Input, InputProps } from "../../Input.ts";
4import type { Cluster } from "../ECS/Cluster.ts";
5import * as IAM from "../IAM/index.ts";
6import type { Function } from "../Lambda/Function.ts";
7import type { Queue } from "../SQS/Queue.ts";
8import { Schedule } from "./Schedule.ts";
9import type { ScheduleGroup } from "./ScheduleGroup.ts";
10
11/**
12 * Accumulated state of a fluent schedule builder (`every`/`cron`/`at` plus
13 * any `.named(...)` and target options applied so far).
14 */
15export interface ScheduleBuilderState {
16 /** Schedule expression: `rate(...)`, `cron(...)`, or `at(...)`. */
17 expression: string;
18 /** Explicit schedule name set via `.named(...)`. */
19 name?: string;
20 /** Schedule group the schedule is created in. */
21 group?: ScheduleGroup;
22 /** Description on the backing schedule. */
23 description?: string;
24 /** Timezone for cron or at expressions. */
25 timezone?: string;
26 /** Date before which the schedule does not fire. */
27 startDate?: Date;
28 /** Date after which the schedule stops firing. */
29 endDate?: Date;
30 /** Desired schedule state (`ENABLED` / `DISABLED`). */
31 state?: string;
32 /** KMS key ARN used to encrypt the schedule's target input. */
33 kmsKeyArn?: string;
34 /**
35 * Flexible time window configuration.
36 * @default { Mode: "OFF" }
37 */
38 flexibleTimeWindow?: scheduler.FlexibleTimeWindow;
39 /** Action after a one-time schedule completes (e.g. `DELETE`). */
40 actionAfterCompletion?: string;
41}
42
43/**
44 * Options accepted by the `every`/`cron`/`at` schedule builders.
45 */
46export interface ScheduleOptions {
47 /** Schedule group the schedule is created in. */
48 group?: ScheduleGroup;
49 /** Description on the backing schedule. */
50 description?: string;
51 /** Timezone for cron or at expressions. */
52 timezone?: string;
53 /** Date before which the schedule does not fire. */
54 startDate?: Date;
55 /** Date after which the schedule stops firing. */
56 endDate?: Date;
57 /** Desired schedule state (`ENABLED` / `DISABLED`). */
58 state?: string;
59 /** KMS key ARN used to encrypt the schedule's target input. */
60 kmsKeyArn?: string;
61 /**
62 * Flexible time window configuration.
63 * @default { Mode: "OFF" }
64 */
65 flexibleTimeWindow?: scheduler.FlexibleTimeWindow;
66 /** Action after a one-time schedule completes (e.g. `DELETE`). */
67 actionAfterCompletion?: string;
68}
69
70/**
71 * Target options for `.toLambda(fn, props)`.
72 */
73export interface LambdaTargetProps {
74 /** JSON-serializable payload passed to the function as the event. */
75 input?: unknown;
76 /** Retry policy for failed invocations. */
77 retryPolicy?: scheduler.RetryPolicy;
78 /** Dead-letter queue for undeliverable invocations. */
79 deadLetterConfig?: scheduler.DeadLetterConfig;
80}
81
82/**
83 * Target options for `.toQueue(queue, payload, props)` — note the message
84 * payload is passed as `.toQueue`'s second argument, not via `input`.
85 */
86export interface QueueTargetProps {
87 /** JSON-serializable payload used as the message body. */
88 input?: unknown;
89 /** Retry policy for failed deliveries. */
90 retryPolicy?: scheduler.RetryPolicy;
91 /** Dead-letter queue for undeliverable deliveries. */
92 deadLetterConfig?: scheduler.DeadLetterConfig;
93 /** SQS-specific parameters (e.g. `MessageGroupId` for FIFO queues). */
94 sqs?: scheduler.SqsParameters;
95}
96
97/**
98 * Target options for `.toEcsTask(props)`.
99 */
100export interface EcsTaskTargetProps {
101 /** ECS cluster the task runs on. */
102 cluster: Cluster;
103 /**
104 * Task definition and the roles Scheduler passes to it.
105 *
106 * This is a builder argument (not resource Props), so the fields are
107 * `Input`-typed explicitly: an `AWS.ECS.Task` resource — whose attributes
108 * are `Output`s — is directly assignable here.
109 */
110 task: {
111 /** ARN of the task definition to run. */
112 taskDefinitionArn: Input<string>;
113 /** ARN of the task role (`iam:PassRole` is granted automatically). */
114 taskRoleArn: Input<string>;
115 /** ARN of the execution role (`iam:PassRole` is granted automatically). */
116 executionRoleArn: Input<string>;
117 };
118 /** Subnets for the task's awsvpc network configuration. */
119 subnets: Input<string[]>;
120 /** Security groups for the task's awsvpc network configuration. */
121 securityGroups?: Input<string[]>;
122 /**
123 * Whether the task gets a public IP.
124 * @default false
125 */
126 assignPublicIp?: boolean;
127 /**
128 * Number of tasks to launch per invocation.
129 * @default 1
130 */
131 taskCount?: number;
132 /** JSON-serializable payload passed to the task as the event. */
133 input?: unknown;
134 /** Retry policy for failed task launches. */
135 retryPolicy?: scheduler.RetryPolicy;
136 /** Dead-letter queue for undeliverable invocations. */
137 deadLetterConfig?: scheduler.DeadLetterConfig;
138}
139
140export const every = (value: string, options: ScheduleOptions = {}) =>
141 makeBuilder({
142 expression:
143 value.startsWith("rate(") || value.startsWith("cron(")
144 ? value
145 : `rate(${value})`,
146 ...options,
147 });
148
149export const cron = (expression: string, options: ScheduleOptions = {}) =>
150 makeBuilder({
151 expression,
152 ...options,
153 });
154
155export const at = (date: Date, options: ScheduleOptions = {}) =>
156 makeBuilder({
157 expression: `at(${date.toISOString().replace(/\.\d{3}Z$/, "Z")})`,
158 ...options,
159 });
160
161/**
162 * The fluent schedule builder returned by `every`/`cron`/`at`. Route it to a
163 * target with `.toLambda`/`.toQueue`/`.toEcsTask`, or consume it on the host
164 * Function with `consumeSchedule(builder, handler)`.
165 */
166export type ScheduleBuilder = ReturnType<typeof makeBuilder>;
167
168const makeBuilder = (state: ScheduleBuilderState) => ({
169 /**
170 * The accumulated builder state (expression, name, group, ...). Read by
171 * `consumeSchedule` to materialize the schedule against the host Function.
172 */
173 state,
174
175 named: (name: string) =>
176 makeBuilder({
177 ...state,
178 name,
179 }),
180
181 toLambda: (fn: Function, props: LambdaTargetProps = {}) =>
182 materializeSchedule(
183 state,
184 fn.LogicalId,
185 [
186 {
187 Effect: "Allow",
188 Action: ["lambda:InvokeFunction"],
189 Resource: [fn.functionArn],
190 },
191 ],
192 {
193 Arn: fn.functionArn as any,
194 Input: toInput(props.input),
195 RetryPolicy: props.retryPolicy,
196 DeadLetterConfig: props.deadLetterConfig,
197 },
198 ),
199
200 toQueue: (
201 queue: Queue,
202 payload?: unknown,
203 props: Omit<QueueTargetProps, "input"> = {},
204 ) =>
205 materializeSchedule(
206 state,
207 queue.LogicalId,
208 [
209 {
210 Effect: "Allow",
211 Action: ["sqs:SendMessage"],
212 Resource: [queue.queueArn],
213 },
214 ],
215 {
216 Arn: queue.queueArn as any,
217 Input: toInput(payload),
218 RetryPolicy: props.retryPolicy,
219 DeadLetterConfig: props.deadLetterConfig,
220 SqsParameters: props.sqs,
221 },
222 ),
223
224 toEcsTask: (props: EcsTaskTargetProps) =>
225 materializeSchedule(
226 state,
227 props.cluster.LogicalId,
228 [
229 {
230 Effect: "Allow",
231 Action: ["ecs:RunTask"],
232 Resource: [props.task.taskDefinitionArn],
233 },
234 {
235 Effect: "Allow",
236 Action: ["iam:PassRole"],
237 Resource: [props.task.taskRoleArn, props.task.executionRoleArn],
238 },
239 ],
240 {
241 Arn: props.cluster.clusterArn as any,
242 Input: toInput(props.input),
243 RetryPolicy: props.retryPolicy,
244 DeadLetterConfig: props.deadLetterConfig,
245 EcsParameters: {
246 TaskDefinitionArn: props.task.taskDefinitionArn,
247 TaskCount: props.taskCount ?? 1,
248 LaunchType: "FARGATE",
249 NetworkConfiguration: {
250 awsvpcConfiguration: {
251 Subnets: props.subnets,
252 SecurityGroups: props.securityGroups,
253 AssignPublicIp: props.assignPublicIp ? "ENABLED" : "DISABLED",
254 },
255 },
256 },
257 },
258 ),
259});
260
261const materializeSchedule = (
262 state: ScheduleBuilderState,
263 targetId: string,
264 statements: any[],
265 // `InputProps` so target fields may carry unresolved `Output`s (e.g. a
266 // Task's `taskDefinitionArn`); they flow into the `Schedule` resource's
267 // Input-typed `target` prop and resolve at deploy time.
268 target: InputProps<Omit<scheduler.Target, "RoleArn">>,
269) =>
270 Effect.gen(function* () {
271 const scheduleId = state.name ?? `${targetId}Schedule`;
272 const role = yield* IAM.Role(`${scheduleId}Role`, {
273 assumeRolePolicyDocument: {
274 Version: "2012-10-17",
275 Statement: [
276 {
277 Effect: "Allow",
278 Principal: {
279 Service: "scheduler.amazonaws.com",
280 },
281 Action: ["sts:AssumeRole"],
282 },
283 ],
284 },
285 inlinePolicies: {
286 ScheduleTarget: {
287 Version: "2012-10-17",
288 Statement: statements,
289 },
290 },
291 });
292
293 return yield* Schedule(scheduleId, {
294 name: state.name,
295 groupName: state.group?.scheduleGroupName,
296 scheduleExpression: state.expression,
297 startDate: state.startDate,
298 endDate: state.endDate,
299 description: state.description,
300 scheduleExpressionTimezone: state.timezone,
301 state: state.state,
302 kmsKeyArn: state.kmsKeyArn,
303 flexibleTimeWindow: state.flexibleTimeWindow ?? {
304 Mode: "OFF",
305 },
306 actionAfterCompletion: state.actionAfterCompletion,
307 target: {
308 ...target,
309 RoleArn: role.roleArn,
310 } as any,
311 });
312 });
313
314const toInput = (value: unknown) =>
315 value === undefined
316 ? undefined
317 : typeof value === "string"
318 ? value
319 : JSON.stringify(value);