1import * as eventbridge from "@distilled.cloud/aws/eventbridge";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { toWireDays } from "../../Util/Duration.ts";
10import { AWSEnvironment, type AccountID } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import type { RegionID } from "../Region.ts";
13
14export type { ArchiveState } from "@distilled.cloud/aws/eventbridge";
15
16export type ArchiveName = string;
17export type ArchiveArn =
18 `arn:aws:events:${RegionID}:${AccountID}:archive/${ArchiveName}`;
19
20export interface ArchiveProps {
21 /**
22 * Name of the archive. Must match [\.\-_A-Za-z0-9]+, 1-48 characters.
23 * If omitted, a unique name will be generated.
24 */
25 name?: ArchiveName;
26
27 /**
28 * ARN of the event bus whose events are archived. Immutable — changing it
29 * replaces the archive.
30 */
31 eventSourceArn: string;
32
33 /**
34 * Description of the archive. Max 512 characters.
35 */
36 description?: string;
37
38 /**
39 * Event pattern filtering which events are archived, as a JSON-compatible
40 * object. If omitted, all events on the source bus are archived (except
41 * replayed events).
42 */
43 eventPattern?: Record<string, any>;
44
45 /**
46 * How long events are retained in the archive, as any `Duration.Input`
47 * (e.g. `"30 days"`, `Duration.days(90)`); converted to whole days on the
48 * wire (`RetentionDays`).
49 * @default indefinite retention
50 */
51 retention?: Duration.Input;
52
53 /**
54 * The identifier of the KMS customer managed key to encrypt events in this
55 * archive. Strongly recommended when the source event bus uses a customer
56 * managed key.
57 */
58 kmsKeyIdentifier?: string;
59}
60
61/**
62 * An Amazon EventBridge archive that retains events from an event bus so
63 * they can later be replayed (see the replay bindings: `StartReplay`,
64 * `DescribeReplay`, `CancelReplay`, `ListReplays`).
65 *
66 * Archives do not support tags, so ownership is tracked by the
67 * deterministic physical name.
68 * ### Archiving Events
69 * **Example:** Archive All Events on a Bus
70 * ```typescript
71 * const bus = yield* AWS.EventBridge.EventBus("AppEvents", {});
72 *
73 * const archive = yield* AWS.EventBridge.Archive("AppArchive", {
74 * eventSourceArn: bus.eventBusArn,
75 * retention: "30 days",
76 * });
77 * ```
78 *
79 * **Example:** Archive a Filtered Subset of Events
80 * ```typescript
81 * const archive = yield* AWS.EventBridge.Archive("OrderArchive", {
82 * eventSourceArn: bus.eventBusArn,
83 * description: "Order events only",
84 * eventPattern: { source: ["my.app"], "detail-type": ["OrderCreated"] },
85 * retention: "90 days",
86 * });
87 * ```
88 *
89 * @resource
90 */
91export interface Archive extends Resource<
92 "AWS.EventBridge.Archive",
93 ArchiveProps,
94 {
95 /** The name of the archive. */
96 archiveName: ArchiveName;
97 /** The ARN of the archive. */
98 archiveArn: ArchiveArn;
99 /** The ARN of the event bus the archive sources events from. */
100 eventSourceArn: string;
101 },
102 never,
103 Providers
104> {}
105export const Archive = Resource<Archive>("AWS.EventBridge.Archive");
106
107export const ArchiveProvider = () =>
108 Provider.effect(
109 Archive,
110 Effect.gen(function* () {
111 const createArchiveName = (id: string, props: { name?: string } = {}) =>
112 props.name
113 ? Effect.succeed(props.name)
114 : createPhysicalName({
115 id,
116 maxLength: 48,
117 });
118
119 /**
120 * Poll until the archive leaves its transitional state. Archive
121 * creation/updates settle in seconds; the wait is bounded so a stuck
122 * archive surfaces the last observed state instead of hanging.
123 */
124 const awaitSettled = (archiveName: string) =>
125 eventbridge.describeArchive({ ArchiveName: archiveName }).pipe(
126 // A describe fired immediately after create can be eventually
127 // consistent — absorb NotFound briefly before polling the state.
128 Effect.retry({
129 while: (e): boolean => e._tag === "ResourceNotFoundException",
130 schedule: Schedule.spaced("1 second"),
131 times: 5,
132 }),
133 Effect.repeat({
134 schedule: Schedule.spaced("2 seconds"),
135 until: (r): boolean =>
136 r.State !== "CREATING" && r.State !== "UPDATING",
137 times: 10,
138 }),
139 );
140
141 return {
142 stables: ["archiveName", "archiveArn", "eventSourceArn"],
143 diff: Effect.fn(function* ({ id, news, olds }) {
144 if (!isResolved(news)) return;
145 const oldName = yield* createArchiveName(id, olds);
146 const newName = yield* createArchiveName(id, news);
147 if (oldName !== newName) {
148 return { action: "replace" } as const;
149 }
150 if (olds.eventSourceArn !== news.eventSourceArn) {
151 return { action: "replace" } as const;
152 }
153 }),
154 read: Effect.fn(function* ({ id, olds, output }) {
155 // Archives don't support tags; the deterministic physical name is
156 // the ownership signal (it embeds app/stage/logical id).
157 const archiveName =
158 output?.archiveName ?? (yield* createArchiveName(id, olds ?? {}));
159 const described = yield* eventbridge
160 .describeArchive({ ArchiveName: archiveName })
161 .pipe(
162 Effect.catchTag("ResourceNotFoundException", () =>
163 Effect.succeed(undefined),
164 ),
165 );
166 if (!described?.ArchiveName || !described.ArchiveArn) {
167 return undefined;
168 }
169 return {
170 archiveName: described.ArchiveName,
171 archiveArn: described.ArchiveArn as ArchiveArn,
172 eventSourceArn: described.EventSourceArn ?? "",
173 };
174 }),
175 list: () =>
176 Effect.gen(function* () {
177 const { accountId, region } = yield* AWSEnvironment.current;
178 const attrs: {
179 archiveName: ArchiveName;
180 archiveArn: ArchiveArn;
181 eventSourceArn: string;
182 }[] = [];
183 let nextToken: string | undefined;
184 do {
185 const page = yield* eventbridge.listArchives({
186 NextToken: nextToken,
187 });
188 for (const archive of page.Archives ?? []) {
189 if (!archive.ArchiveName) {
190 continue;
191 }
192 attrs.push({
193 archiveName: archive.ArchiveName,
194 archiveArn:
195 `arn:aws:events:${region}:${accountId}:archive/${archive.ArchiveName}` as ArchiveArn,
196 eventSourceArn: archive.EventSourceArn ?? "",
197 });
198 }
199 nextToken = page.NextToken;
200 } while (nextToken);
201 return attrs;
202 }),
203 reconcile: Effect.fn(function* ({ id, news, output, session }) {
204 const archiveName =
205 output?.archiveName ?? (yield* createArchiveName(id, news));
206 const eventPattern = news.eventPattern
207 ? JSON.stringify(news.eventPattern)
208 : undefined;
209 const retentionDays = toWireDays(news.retention);
210
211 // Observe — live cloud state is authoritative; a vanished archive
212 // falls through to create.
213 const observed = yield* eventbridge
214 .describeArchive({ ArchiveName: archiveName })
215 .pipe(
216 Effect.catchTag("ResourceNotFoundException", () =>
217 Effect.succeed(undefined),
218 ),
219 );
220
221 if (!observed?.ArchiveArn) {
222 // Ensure — create the archive; tolerate an AlreadyExists race
223 // with a peer reconciler and converge via the update below.
224 yield* eventbridge
225 .createArchive({
226 ArchiveName: archiveName,
227 EventSourceArn: news.eventSourceArn,
228 Description: news.description,
229 EventPattern: eventPattern,
230 RetentionDays: retentionDays,
231 KmsKeyIdentifier: news.kmsKeyIdentifier,
232 })
233 .pipe(
234 Effect.catchTag(
235 "ResourceAlreadyExistsException",
236 () => Effect.void,
237 ),
238 );
239 } else {
240 // Sync — updateArchive overwrites description, pattern,
241 // retention, and KMS key in one shot (idempotent on matching
242 // values).
243 yield* eventbridge.updateArchive({
244 ArchiveName: archiveName,
245 Description: news.description,
246 EventPattern: eventPattern,
247 RetentionDays: retentionDays,
248 KmsKeyIdentifier: news.kmsKeyIdentifier,
249 });
250 }
251
252 const settled = yield* awaitSettled(archiveName);
253 const archiveArn = (settled.ArchiveArn ??
254 observed?.ArchiveArn) as ArchiveArn;
255
256 yield* session.note(archiveArn);
257 return {
258 archiveName,
259 archiveArn,
260 eventSourceArn: settled.EventSourceArn ?? news.eventSourceArn,
261 };
262 }),
263 delete: Effect.fn(function* ({ output }) {
264 yield* eventbridge
265 .deleteArchive({ ArchiveName: output.archiveName })
266 .pipe(
267 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
268 );
269 }),
270 };
271 }),
272 );