1import * as iotw from "@distilled.cloud/aws/iot-wireless";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 hasAlchemyTags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 readIotWirelessTags,
17 sameShape,
18 syncIotWirelessTags,
19} from "./internal.ts";
20
21export interface ServiceProfileProps {
22 /**
23 * Name of the service profile. If omitted, a deterministic physical name
24 * is generated from the app, stage, and logical ID. Service profiles are
25 * immutable — changing the name replaces the profile.
26 */
27 name?: string;
28 /**
29 * LoRaWAN service profile configuration (data-rate bounds, gateway
30 * metadata, roaming flags, transmission counts). Immutable — changing any
31 * field replaces the profile.
32 */
33 loRaWAN?: iotw.LoRaWANServiceProfile;
34 /**
35 * Tags applied to the service profile. Alchemy ownership tags are merged
36 * in automatically.
37 */
38 tags?: Record<string, string>;
39}
40
41export interface ServiceProfile extends Resource<
42 "AWS.IoTWireless.ServiceProfile",
43 ServiceProfileProps,
44 {
45 /** Server-assigned ID of the service profile. */
46 serviceProfileId: string;
47 /** ARN of the service profile. */
48 serviceProfileArn: string;
49 /** Name of the service profile. */
50 serviceProfileName: string;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * An AWS IoT Core for LoRaWAN service profile — the network-level parameters
58 * (data-rate bounds, gateway metadata reporting, roaming permissions) shared
59 * by a fleet of wireless devices.
60 *
61 * Service profiles are immutable after creation: any change to `name` or
62 * `loRaWAN` replaces the profile. Only tags update in place.
63 * ### Creating Service Profiles
64 * **Example:** Default Service Profile
65 * ```typescript
66 * import * as IoTWireless from "alchemy/AWS/IoTWireless";
67 *
68 * const profile = yield* IoTWireless.ServiceProfile("Fleet");
69 * ```
70 *
71 * **Example:** Service Profile with Gateway Metadata
72 * ```typescript
73 * const profile = yield* IoTWireless.ServiceProfile("Fleet", {
74 * loRaWAN: { AddGwMetadata: true, DrMin: 0, DrMax: 10 },
75 * tags: { team: "iot" },
76 * });
77 * ```
78 *
79 * ### Referencing from Devices
80 * **Example:** Wire a device to the profile
81 * ```typescript
82 * const device = yield* IoTWireless.WirelessDevice("Sensor", {
83 * type: "LoRaWAN",
84 * destinationName: destination.destinationName,
85 * loRaWAN: {
86 * DevEui: "1122334455667788",
87 * ServiceProfileId: profile.serviceProfileId,
88 * DeviceProfileId: deviceProfile.deviceProfileId,
89 * OtaaV1_0_x: { AppKey: "...", AppEui: "..." },
90 * },
91 * });
92 * ```
93 *
94 * @resource
95 */
96export const ServiceProfile = Resource<ServiceProfile>(
97 "AWS.IoTWireless.ServiceProfile",
98);
99
100export const ServiceProfileProvider = () =>
101 Provider.effect(
102 ServiceProfile,
103 Effect.gen(function* () {
104 const createName = Effect.fn(function* (
105 id: string,
106 props: { name?: string },
107 ) {
108 return (
109 props.name ?? (yield* createPhysicalName({ id, maxLength: 256 }))
110 );
111 });
112
113 const getById = (serviceProfileId: string) =>
114 iotw
115 .getServiceProfile({ Id: serviceProfileId })
116 .pipe(
117 Effect.catchTag("ResourceNotFoundException", () =>
118 Effect.succeed(undefined),
119 ),
120 );
121
122 // Service profiles have no name-keyed Get — enumerate and match. The
123 // physical name is deterministic, so this recovers identity after a
124 // lost state write.
125 const findByName = (name: string) =>
126 iotw.listServiceProfiles.pages({}).pipe(
127 Stream.runCollect,
128 Effect.map((chunk) =>
129 Array.from(chunk)
130 .flatMap((page) => page.ServiceProfileList ?? [])
131 .find((p) => p.Name === name),
132 ),
133 );
134
135 const observe = Effect.fn(function* (
136 output: ServiceProfile["Attributes"] | undefined,
137 name: string,
138 ) {
139 if (output?.serviceProfileId !== undefined) {
140 const found = yield* getById(output.serviceProfileId);
141 if (found !== undefined) return found;
142 }
143 const summary = yield* findByName(name);
144 if (summary?.Id === undefined) return undefined;
145 return yield* getById(summary.Id);
146 });
147
148 const toAttrs = Effect.fn(function* (
149 profile: iotw.GetServiceProfileResponse,
150 name: string,
151 ) {
152 if (profile.Id === undefined || profile.Arn === undefined) {
153 return yield* Effect.fail(
154 new Error(
155 `IoT Wireless service profile '${name}' returned without Id/Arn`,
156 ),
157 );
158 }
159 return {
160 serviceProfileId: profile.Id,
161 serviceProfileArn: profile.Arn,
162 serviceProfileName: profile.Name ?? name,
163 };
164 });
165
166 return ServiceProfile.Provider.of({
167 stables: [
168 "serviceProfileId",
169 "serviceProfileArn",
170 "serviceProfileName",
171 ],
172
173 list: () =>
174 iotw.listServiceProfiles.pages({}).pipe(
175 Stream.runCollect,
176 Effect.map((chunk) =>
177 Array.from(chunk)
178 .flatMap((page) => page.ServiceProfileList ?? [])
179 .flatMap((p) =>
180 p.Id !== undefined && p.Arn !== undefined
181 ? [
182 {
183 serviceProfileId: p.Id,
184 serviceProfileArn: p.Arn,
185 serviceProfileName: p.Name ?? p.Id,
186 },
187 ]
188 : [],
189 ),
190 ),
191 ),
192
193 read: Effect.fn(function* ({ id, olds, output }) {
194 const name =
195 output?.serviceProfileName ?? (yield* createName(id, olds ?? {}));
196 const profile = yield* observe(output, name);
197 if (profile === undefined) return undefined;
198 const attrs = yield* toAttrs(profile, name);
199 const tags = yield* readIotWirelessTags(attrs.serviceProfileArn);
200 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
201 }),
202
203 // There is no UpdateServiceProfile API — name and LoRaWAN config are
204 // create-only, so any change to either replaces the profile. Tags
205 // stay update-in-place.
206 diff: Effect.fn(function* ({ id, news, olds }) {
207 if (!isResolved(news)) return undefined;
208 const oldName = yield* createName(id, olds ?? {});
209 const newName = yield* createName(id, news);
210 if (oldName !== newName) return { action: "replace" } as const;
211 if (!sameShape(olds?.loRaWAN, news.loRaWAN)) {
212 return { action: "replace" } as const;
213 }
214 }),
215
216 reconcile: Effect.fn(function* ({ id, news, output, session }) {
217 const name =
218 output?.serviceProfileName ?? (yield* createName(id, news));
219 const internalTags = yield* createInternalTags(id);
220 const desiredTags = { ...news.tags, ...internalTags };
221
222 // 1. OBSERVE — cloud state is authoritative; output is an id cache.
223 let profile = yield* observe(output, name);
224
225 // 2. ENSURE — create if missing. CreateServiceProfile always mints
226 // a new profile (names are not unique keys), so re-observing by
227 // name after a Conflict race keeps this idempotent.
228 if (profile === undefined) {
229 yield* session.note(`creating service profile ${name}`);
230 const created = yield* iotw
231 .createServiceProfile({
232 Name: name,
233 LoRaWAN: news.loRaWAN,
234 Tags: createTagsList(desiredTags),
235 })
236 .pipe(
237 Effect.catchTag("ConflictException", () =>
238 Effect.succeed(undefined),
239 ),
240 );
241 profile =
242 created?.Id !== undefined
243 ? yield* getById(created.Id)
244 : yield* observe(undefined, name);
245 }
246 if (profile === undefined) {
247 return yield* Effect.fail(
248 new Error(
249 `IoT Wireless service profile '${name}' not found after create`,
250 ),
251 );
252 }
253
254 // 3. SYNC — only tags are mutable; diff against OBSERVED tags.
255 const attrs = yield* toAttrs(profile, name);
256 yield* syncIotWirelessTags(attrs.serviceProfileArn, desiredTags);
257
258 yield* session.note(attrs.serviceProfileId);
259 return attrs;
260 }),
261
262 delete: Effect.fn(function* ({ output }) {
263 yield* iotw
264 .deleteServiceProfile({ Id: output.serviceProfileId })
265 .pipe(
266 Effect.asVoid,
267 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
268 );
269 }),
270 });
271 }),
272 );