1import * as iot from "@distilled.cloud/aws/iot";
2import * as Effect from "effect/Effect";
3import { createInternalTags, diffTags } from "../../Tags.ts";
4
5/**
6 * IoT rule names accept only `[a-zA-Z0-9_]`. Coerce any other character
7 * (hyphens from generated physical names, colons, etc.) to an underscore so
8 * a name derived from the stack/id/stage is always valid.
9 */
10export const sanitizeRuleName = (name: string): string =>
11 name.replace(/[^a-zA-Z0-9_]/g, "_");
12
13/**
14 * Reads the observed tag map for an IoT resource ARN. A missing resource
15 * yields `{}` so callers treat it as "no tags observed".
16 */
17export const readIotTags = Effect.fn(function* (resourceArn: string) {
18 const { tags } = yield* iot
19 .listTagsForResource({ resourceArn })
20 .pipe(
21 Effect.catchTag("ResourceNotFoundException", () =>
22 Effect.succeed({ tags: [] as iot.Tag[] }),
23 ),
24 );
25 const record: Record<string, string> = {};
26 for (const tag of tags ?? []) {
27 if (tag.Value !== undefined) record[tag.Key] = tag.Value;
28 }
29 return record;
30});
31
32/**
33 * Reconciles the tags on an IoT resource to `{ ...user, ...internal }`,
34 * diffing against the OBSERVED cloud tags (so adoption converges). Applies
35 * only the delta: `tagResource` for upserts, `untagResource` for removals.
36 */
37export const syncIotTags = Effect.fn(function* (
38 resourceArn: string,
39 id: string,
40 userTags: Record<string, string> | undefined,
41) {
42 const internalTags = yield* createInternalTags(id);
43 const desired = { ...userTags, ...internalTags };
44 const observed = yield* readIotTags(resourceArn);
45 const { upsert, removed } = diffTags(observed, desired);
46 if (upsert.length > 0) {
47 yield* iot.tagResource({
48 resourceArn,
49 tags: upsert.map((t) => ({ Key: t.Key, Value: t.Value })),
50 });
51 }
52 if (removed.length > 0) {
53 yield* iot.untagResource({ resourceArn, tagKeys: removed });
54 }
55});