1import * as notifications from "@distilled.cloud/aws/notifications";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 pinNotificationsRegion,
14 readNotificationsTags,
15 syncNotificationsTags,
16} from "./internal.ts";
17
18/**
19 * How long User Notifications aggregates events of the same kind into a
20 * single notification before delivering it.
21 */
22export type AggregationDuration = "LONG" | "SHORT" | "NONE";
23
24export interface NotificationConfigurationProps {
25 /**
26 * Name of the notification configuration. Must be unique in the account
27 * (letters, numbers, underscores and hyphens). If omitted, a unique name
28 * is generated from the app, stage and logical ID. The name can be
29 * changed in place (no replacement).
30 */
31 name?: string;
32
33 /**
34 * Human-readable description of the configuration.
35 * @default "Managed by Alchemy"
36 */
37 description?: string;
38
39 /**
40 * How long to aggregate matching events into a single notification:
41 * `LONG` (12 hours), `SHORT` (5 minutes) or `NONE` (no aggregation).
42 * @default "NONE"
43 */
44 aggregationDuration?: AggregationDuration;
45
46 /**
47 * User tags to attach to the configuration. Merged with internal
48 * Alchemy tags.
49 */
50 tags?: Record<string, string>;
51}
52
53export interface NotificationConfiguration extends Resource<
54 "AWS.Notifications.NotificationConfiguration",
55 NotificationConfigurationProps,
56 {
57 /** The ARN of the notification configuration (regionless). */
58 notificationConfigurationArn: string;
59 /** The unique name of the configuration. */
60 name: string;
61 /** The description of the configuration. */
62 description: string;
63 /**
64 * Current status: `ACTIVE`, `PARTIALLY_ACTIVE`, `INACTIVE` (no event
65 * rules / channels associated yet) or `DELETING`.
66 */
67 status: string;
68 },
69 never,
70 Providers
71> {}
72
73/**
74 * An AWS User Notifications **notification configuration** — the container
75 * that groups {@link EventRule}s (which events to notify on) and delivery
76 * channels (where notifications go: Console bell, email contacts, chat).
77 *
78 * User Notifications is a global service managed from `us-east-1`; the
79 * provider pins the control-plane region automatically, so the resource
80 * works from a stack deployed in any region.
81 *
82 * ### Creating a Notification Configuration
83 * **Example:** Basic configuration
84 * ```typescript
85 * import * as Notifications from "alchemy/AWS/Notifications";
86 *
87 * const config = yield* Notifications.NotificationConfiguration("Alerts", {
88 * description: "Deployment alerts",
89 * });
90 * ```
91 *
92 * **Example:** Aggregate duplicate events for 5 minutes
93 * ```typescript
94 * const config = yield* Notifications.NotificationConfiguration("Alerts", {
95 * description: "Deployment alerts",
96 * aggregationDuration: "SHORT",
97 * tags: { team: "platform" },
98 * });
99 * ```
100 *
101 * ### Adding Event Rules
102 * **Example:** Notify on CloudWatch alarm state changes
103 * ```typescript
104 * const rule = yield* Notifications.EventRule("AlarmRule", {
105 * notificationConfigurationArn: config.notificationConfigurationArn,
106 * source: "aws.cloudwatch",
107 * eventType: "CloudWatch Alarm State Change",
108 * regions: ["us-west-2"],
109 * });
110 * ```
111 *
112 * @resource
113 */
114export const NotificationConfiguration = Resource<NotificationConfiguration>(
115 "AWS.Notifications.NotificationConfiguration",
116);
117
118const DEFAULT_DESCRIPTION = "Managed by Alchemy";
119
120/**
121 * Retry a mutation while the service reports a transitional-lock
122 * `ConflictException` (e.g. an event rule of this configuration is still
123 * `DELETING`). Bounded (~40s). Explicitly typed so `Effect.retry`'s
124 * conditional return type doesn't widen the provider layer in declaration
125 * emit.
126 */
127const retryWhileConflict = <A, E extends { _tag: string }, R>(
128 self: Effect.Effect<A, E, R>,
129): Effect.Effect<A, E, R> =>
130 Effect.retry(self, {
131 while: (e) => e._tag === "ConflictException",
132 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(20)]),
133 });
134
135export const NotificationConfigurationProvider = () =>
136 Provider.effect(
137 NotificationConfiguration,
138 Effect.gen(function* () {
139 const createName = Effect.fn(function* (
140 id: string,
141 props: NotificationConfigurationProps,
142 ) {
143 return props.name ?? (yield* createPhysicalName({ id }));
144 });
145
146 // Names are unique account-wide (CreateNotificationConfiguration
147 // rejects duplicates with ConflictException), so a name scan is a
148 // reliable identity fallback when the ARN cache is missing.
149 const findByName = Effect.fn(function* (name: string) {
150 return yield* pinNotificationsRegion(
151 notifications.listNotificationConfigurations.items({}).pipe(
152 Stream.filter((item) => item.name === name),
153 Stream.runHead,
154 Effect.map(Option.getOrUndefined),
155 ),
156 );
157 });
158
159 const getByArn = Effect.fn(function* (arn: string) {
160 return yield* pinNotificationsRegion(
161 notifications
162 .getNotificationConfiguration({ arn })
163 .pipe(
164 Effect.catchTag("ResourceNotFoundException", () =>
165 Effect.succeed(undefined),
166 ),
167 ),
168 );
169 });
170
171 const toAttrs = (live: {
172 arn: string;
173 name: string;
174 description: string;
175 status: string;
176 }) => ({
177 notificationConfigurationArn: live.arn,
178 name: live.name,
179 description: live.description,
180 status: live.status,
181 });
182
183 return NotificationConfiguration.Provider.of({
184 stables: ["notificationConfigurationArn"],
185
186 list: () =>
187 pinNotificationsRegion(
188 notifications.listNotificationConfigurations.items({}).pipe(
189 Stream.runCollect,
190 Effect.map((chunk) => Array.from(chunk).map(toAttrs)),
191 ),
192 ),
193
194 read: Effect.fn(function* ({ id, olds, output }) {
195 const name = output?.name ?? (yield* createName(id, olds ?? {}));
196 const found = output?.notificationConfigurationArn
197 ? yield* getByArn(output.notificationConfigurationArn)
198 : yield* findByName(name);
199 if (!found) return undefined;
200 const attrs = toAttrs(found);
201 const tags = yield* readNotificationsTags(found.arn);
202 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
203 }),
204
205 // Everything (including the name) is mutable in place — no diff
206 // needed; the engine's default update path always reconciles.
207
208 reconcile: Effect.fn(function* ({ id, news, output, session }) {
209 const desiredName = yield* createName(id, news);
210 const desiredDescription = news.description ?? DEFAULT_DESCRIPTION;
211
212 // OBSERVE — cloud state is authoritative; the cached ARN falls
213 // through to a name scan when it no longer resolves.
214 let live = output?.notificationConfigurationArn
215 ? yield* getByArn(output.notificationConfigurationArn)
216 : yield* findByName(desiredName);
217
218 // ENSURE — create when missing; ConflictException means a peer
219 // created the same-named configuration concurrently → re-observe.
220 if (live === undefined) {
221 const created = yield* pinNotificationsRegion(
222 notifications
223 .createNotificationConfiguration({
224 name: desiredName,
225 description: desiredDescription,
226 aggregationDuration: news.aggregationDuration,
227 tags: news.tags,
228 })
229 .pipe(
230 Effect.catchTag("ConflictException", () =>
231 Effect.succeed(undefined),
232 ),
233 ),
234 );
235 live = created
236 ? yield* getByArn(created.arn)
237 : yield* findByName(desiredName);
238 }
239 const arn = live!.arn;
240
241 // SYNC settings — diff observed name/description/aggregation
242 // against desired and update only on change. An absent
243 // aggregationDuration prop leaves the observed value untouched.
244 const wantsAggregation = news.aggregationDuration;
245 const aggregationDrifted =
246 wantsAggregation !== undefined &&
247 (live!.aggregationDuration ?? "NONE") !== wantsAggregation;
248 if (
249 live!.name !== desiredName ||
250 live!.description !== desiredDescription ||
251 aggregationDrifted
252 ) {
253 yield* pinNotificationsRegion(
254 notifications.updateNotificationConfiguration({
255 arn,
256 name: desiredName,
257 description: desiredDescription,
258 ...(wantsAggregation !== undefined
259 ? { aggregationDuration: wantsAggregation }
260 : {}),
261 }),
262 );
263 }
264
265 // SYNC tags — against observed cloud tags (adoption-safe).
266 yield* syncNotificationsTags(arn, id, news.tags);
267
268 const fresh = yield* getByArn(arn);
269 yield* session.note(arn);
270 return toAttrs(fresh ?? live!);
271 }),
272
273 delete: Effect.fn(function* ({ output }) {
274 // Idempotent — deleting also cascades any remaining event rules.
275 // Retry the transitional lock (a rule still DELETING blocks the
276 // configuration delete with a ConflictException).
277 yield* retryWhileConflict(
278 pinNotificationsRegion(
279 notifications.deleteNotificationConfiguration({
280 arn: output.notificationConfigurationArn,
281 }),
282 ),
283 ).pipe(
284 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
285 );
286 }),
287 });
288 }),
289 );