1import * as iotw from "@distilled.cloud/aws/iot-wireless";
2import * as Effect from "effect/Effect";
3import { diffTags, tagRecord } from "../../Tags.ts";
4
5/**
6 * Read the observed tags for any IoT Wireless resource ARN. Tag reads are
7 * best-effort — a race with deletion (or a missing-tags edge) degrades to an
8 * empty record rather than failing the lifecycle operation.
9 */
10export const readIotWirelessTags = (arn: string) =>
11 iotw.listTagsForResource({ ResourceArn: arn }).pipe(
12 Effect.map((r) => tagRecord(r.Tags ?? [])),
13 Effect.catch(() => Effect.succeed<Record<string, string>>({})),
14 );
15
16/**
17 * Converge the tags on an IoT Wireless resource to `desired`, diffing against
18 * the OBSERVED cloud tags so adoption converges.
19 */
20export const syncIotWirelessTags = Effect.fn(function* (
21 arn: string,
22 desired: Record<string, string>,
23) {
24 const current = yield* readIotWirelessTags(arn);
25 const { upsert, removed } = diffTags(current, desired);
26 if (upsert.length > 0) {
27 yield* iotw.tagResource({ ResourceArn: arn, Tags: upsert });
28 }
29 if (removed.length > 0) {
30 yield* iotw.untagResource({ ResourceArn: arn, TagKeys: removed });
31 }
32});
33
34/**
35 * Deep structural equality on JSON-ish values with key order and
36 * `undefined`-valued members normalized away. Used to compare desired
37 * LoRaWAN config structs against observed cloud state.
38 */
39const normalize = (value: unknown): unknown => {
40 if (Array.isArray(value)) return value.map(normalize);
41 if (value !== null && typeof value === "object") {
42 return Object.fromEntries(
43 Object.entries(value as Record<string, unknown>)
44 .filter(([, v]) => v !== undefined)
45 .sort(([l], [r]) => l.localeCompare(r))
46 .map(([k, v]) => [k, normalize(v)]),
47 );
48 }
49 return value;
50};
51
52export const sameShape = (l: unknown, r: unknown) =>
53 JSON.stringify(normalize(l)) === JSON.stringify(normalize(r));