1import * as ivschat from "@distilled.cloud/aws/ivschat";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 retryWhileConflict,
15 retryWhileThrottled,
16 syncIvsChatTags,
17 toTagRecord,
18} from "./internal.ts";
19
20export interface LoggingConfigurationDestination {
21 /**
22 * Deliver chat logs to an S3 bucket. Exactly one destination must be
23 * specified.
24 */
25 s3?: { bucketName: string };
26 /**
27 * Deliver chat logs to a CloudWatch Logs log group. Exactly one
28 * destination must be specified.
29 */
30 cloudWatchLogs?: { logGroupName: string };
31 /**
32 * Deliver chat logs to a Kinesis Data Firehose delivery stream.
33 * Exactly one destination must be specified.
34 */
35 firehose?: { deliveryStreamName: string };
36}
37
38export interface LoggingConfigurationProps {
39 /**
40 * Where chat messages are delivered: exactly one of `s3`,
41 * `cloudWatchLogs`, or `firehose`.
42 */
43 destinationConfiguration: LoggingConfigurationDestination;
44 /**
45 * Name of the logging configuration (not unique). If omitted, a
46 * deterministic physical name is generated. Mutable — changing the
47 * name updates the configuration in place.
48 */
49 loggingConfigurationName?: string;
50 /**
51 * Tags to apply to the logging configuration. Merged with internal
52 * Alchemy tags.
53 */
54 tags?: Record<string, string>;
55}
56
57export interface LoggingConfiguration extends Resource<
58 "AWS.IVSChat.LoggingConfiguration",
59 LoggingConfigurationProps,
60 {
61 /**
62 * The logging configuration's physical name.
63 */
64 loggingConfigurationName: string;
65 /**
66 * ARN of the logging configuration.
67 */
68 loggingConfigurationArn: string;
69 /**
70 * Unique ID of the logging configuration.
71 */
72 loggingConfigurationId: string;
73 /**
74 * Lifecycle state reported by IVS Chat (e.g. `ACTIVE`).
75 */
76 state: string | undefined;
77 },
78 never,
79 Providers
80> {}
81
82/**
83 * An Amazon IVS Chat logging configuration — records the chat messages of
84 * the rooms it is attached to into S3, CloudWatch Logs, or a Kinesis
85 * Data Firehose delivery stream.
86 * ### Creating Logging Configurations
87 * **Example:** CloudWatch Logs Destination
88 * ```typescript
89 * import * as IVSChat from "alchemy/AWS/IVSChat";
90 * import * as Logs from "alchemy/AWS/Logs";
91 *
92 * const logGroup = yield* Logs.LogGroup("ChatLogGroup");
93 * const logging = yield* IVSChat.LoggingConfiguration("ChatLogs", {
94 * destinationConfiguration: {
95 * cloudWatchLogs: { logGroupName: logGroup.logGroupName },
96 * },
97 * });
98 * ```
99 *
100 * **Example:** S3 Destination
101 * ```typescript
102 * const logging = yield* IVSChat.LoggingConfiguration("ChatLogs", {
103 * destinationConfiguration: {
104 * s3: { bucketName: bucket.bucketName },
105 * },
106 * });
107 * ```
108 *
109 * ### Attaching to Rooms
110 * **Example:** Log a Room's Messages
111 * ```typescript
112 * const room = yield* IVSChat.Room("LiveChat", {
113 * loggingConfigurationIdentifiers: [logging.loggingConfigurationArn],
114 * });
115 * ```
116 *
117 * @resource
118 */
119export const LoggingConfiguration = Resource<LoggingConfiguration>(
120 "AWS.IVSChat.LoggingConfiguration",
121);
122
123/**
124 * Raised when a `LoggingConfiguration` does not specify exactly one of
125 * `s3`, `cloudWatchLogs`, or `firehose`, or when the API returns a
126 * configuration missing its identifiers.
127 */
128export class IvsChatLoggingConfigurationInvalid extends Data.TaggedError(
129 "IvsChatLoggingConfigurationInvalid",
130)<{ message: string }> {}
131
132/** Narrow the plain destination prop to the exactly-one wire union. */
133const toWireDestination = (
134 destination: LoggingConfigurationDestination,
135): Effect.Effect<
136 ivschat.DestinationConfiguration,
137 IvsChatLoggingConfigurationInvalid
138> => {
139 const specified = [
140 destination.s3,
141 destination.cloudWatchLogs,
142 destination.firehose,
143 ].filter((d) => d !== undefined);
144 if (specified.length !== 1) {
145 return Effect.fail(
146 new IvsChatLoggingConfigurationInvalid({
147 message:
148 "destinationConfiguration must specify exactly one of s3, cloudWatchLogs, or firehose",
149 }),
150 );
151 }
152 if (destination.s3) return Effect.succeed({ s3: destination.s3 });
153 if (destination.cloudWatchLogs) {
154 return Effect.succeed({ cloudWatchLogs: destination.cloudWatchLogs });
155 }
156 return Effect.succeed({ firehose: destination.firehose! });
157};
158
159/**
160 * Explicitly-typed pipeable poll helper: repeat a read until the
161 * configuration reaches a settled state (bounded — ~60s).
162 */
163const pollUntilSettled = <
164 A extends { state?: string | undefined } | undefined,
165 E,
166 R,
167>(
168 self: Effect.Effect<A, E, R>,
169): Effect.Effect<A, E, R> =>
170 Effect.repeat(self, {
171 until: (config) =>
172 config === undefined ||
173 (config.state !== "CREATING" && config.state !== "UPDATING"),
174 schedule: Schedule.fixed("2 seconds"),
175 times: 30,
176 });
177
178type LoggingConfigurationState = {
179 arn?: string | undefined;
180 id?: string | undefined;
181 name?: string | undefined;
182 destinationConfiguration?: ivschat.DestinationConfiguration | undefined;
183 state?: string | undefined;
184 tags?: { [key: string]: string | undefined } | undefined;
185};
186
187export const LoggingConfigurationProvider = () =>
188 Provider.effect(
189 LoggingConfiguration,
190 Effect.gen(function* () {
191 const toName = (
192 id: string,
193 props: { loggingConfigurationName?: string | undefined },
194 ) =>
195 props.loggingConfigurationName
196 ? Effect.succeed(props.loggingConfigurationName)
197 : createPhysicalName({ id, maxLength: 128 });
198
199 const toAttrs = Effect.fn(function* (config: LoggingConfigurationState) {
200 if (!config.arn || !config.id || !config.name) {
201 return yield* Effect.fail(
202 new IvsChatLoggingConfigurationInvalid({
203 message:
204 "IVS Chat logging configuration is missing its ARN, ID, or name",
205 }),
206 );
207 }
208 return {
209 loggingConfigurationName: config.name,
210 loggingConfigurationArn: config.arn,
211 loggingConfigurationId: config.id,
212 state: config.state,
213 };
214 });
215
216 const getByIdentifier = Effect.fn(function* (identifier: string) {
217 return yield* ivschat.getLoggingConfiguration({ identifier }).pipe(
218 retryWhileThrottled,
219 Effect.catchTag("ResourceNotFoundException", () =>
220 Effect.succeed(undefined),
221 ),
222 );
223 });
224
225 /** No name filter on ListLoggingConfigurations — enumerate + match. */
226 const findByName = Effect.fn(function* (name: string) {
227 const summaries = yield* ivschat.listLoggingConfigurations
228 .pages({})
229 .pipe(
230 Stream.runCollect,
231 Effect.map((chunk) =>
232 Array.from(chunk).flatMap((page) => page.loggingConfigurations),
233 ),
234 retryWhileThrottled,
235 );
236 const match = summaries.find((s) => s.name === name && s.arn);
237 return match?.arn ? yield* getByIdentifier(match.arn) : undefined;
238 });
239
240 return {
241 stables: ["loggingConfigurationArn", "loggingConfigurationId"],
242
243 read: Effect.fn(function* ({ id, olds, output }) {
244 const config = output?.loggingConfigurationArn
245 ? yield* getByIdentifier(output.loggingConfigurationArn)
246 : yield* findByName(yield* toName(id, olds ?? {}));
247 if (config === undefined) return undefined;
248 const attrs = yield* toAttrs(config);
249 return (yield* hasAlchemyTags(id, toTagRecord(config.tags)))
250 ? attrs
251 : Unowned(attrs);
252 }),
253
254 diff: Effect.fn(function* ({ news }) {
255 if (!isResolved(news)) return undefined;
256 // Validate the exactly-one destination shape before any AWS call.
257 yield* toWireDestination(news.destinationConfiguration);
258 }),
259
260 reconcile: Effect.fn(function* ({ id, news, output, session }) {
261 const name = yield* toName(id, news);
262 const internalTags = yield* createInternalTags(id);
263 const desiredTags = { ...internalTags, ...news.tags };
264 const desiredDestination = yield* toWireDestination(
265 news.destinationConfiguration,
266 );
267
268 // 1. Observe — and wait out any in-flight state transition so
269 // the sync step below is allowed to mutate.
270 let observed = output?.loggingConfigurationArn
271 ? yield* pollUntilSettled(
272 getByIdentifier(output.loggingConfigurationArn),
273 )
274 : yield* findByName(name);
275
276 // 2. Ensure — create if missing, then wait for ACTIVE (a
277 // configuration is created in state CREATING).
278 if (observed === undefined) {
279 const created = yield* ivschat
280 .createLoggingConfiguration({
281 name,
282 destinationConfiguration: desiredDestination,
283 tags: desiredTags,
284 })
285 .pipe(retryWhileThrottled);
286 if (created.arn !== undefined) {
287 observed = yield* pollUntilSettled(getByIdentifier(created.arn));
288 }
289 }
290 const arn = observed?.arn;
291 if (observed === undefined || arn === undefined) {
292 return yield* Effect.fail(
293 new IvsChatLoggingConfigurationInvalid({
294 message: "IVS Chat CreateLoggingConfiguration returned no ARN",
295 }),
296 );
297 }
298
299 // 3. Sync — name and destination are mutable via
300 // UpdateLoggingConfiguration (allowed only in a settled state;
301 // Conflict retried through a bounded window).
302 const patch: Partial<ivschat.UpdateLoggingConfigurationRequest> = {};
303 if (observed.name !== name) patch.name = name;
304 if (
305 JSON.stringify(observed.destinationConfiguration) !==
306 JSON.stringify(desiredDestination)
307 ) {
308 patch.destinationConfiguration = desiredDestination;
309 }
310 if (Object.keys(patch).length > 0) {
311 yield* ivschat
312 .updateLoggingConfiguration({ identifier: arn, ...patch })
313 .pipe(retryWhileThrottled, retryWhileConflict);
314 }
315
316 // 3b. Sync tags — diff against OBSERVED cloud tags.
317 yield* syncIvsChatTags(arn, desiredTags);
318
319 // 4. Return fresh, settled attributes.
320 const final = yield* pollUntilSettled(getByIdentifier(arn));
321 if (final === undefined) {
322 return yield* Effect.fail(
323 new IvsChatLoggingConfigurationInvalid({
324 message: `IVS Chat logging configuration '${arn}' vanished during reconcile`,
325 }),
326 );
327 }
328 yield* session.note(arn);
329 return yield* toAttrs(final);
330 }),
331
332 delete: Effect.fn(function* ({ output }) {
333 // Deleting mid-transition raises ConflictException — retry
334 // through a bounded window, then tolerate already-gone.
335 yield* ivschat
336 .deleteLoggingConfiguration({
337 identifier: output.loggingConfigurationArn,
338 })
339 .pipe(
340 retryWhileThrottled,
341 retryWhileConflict,
342 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
343 );
344 }),
345
346 list: () =>
347 ivschat.listLoggingConfigurations.pages({}).pipe(
348 Stream.runCollect,
349 Effect.map((chunk) =>
350 Array.from(chunk).flatMap((page) => page.loggingConfigurations),
351 ),
352 Effect.flatMap(
353 Effect.forEach(
354 (summary) =>
355 toAttrs(summary).pipe(
356 // Tolerate a malformed/deleted summary — drop it.
357 Effect.catchTag("IvsChatLoggingConfigurationInvalid", () =>
358 Effect.succeed(undefined),
359 ),
360 ),
361 { concurrency: 5 },
362 ),
363 ),
364 Effect.map((items) => items.filter((item) => item !== undefined)),
365 ),
366 };
367 }),
368 );