1import * as iotfleetwise from "@distilled.cloud/aws/iotfleetwise";
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 { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 inFleetWiseRegion,
13 readFleetWiseTags,
14 retryObservation,
15 stableEquals,
16 syncFleetWiseTags,
17 toFleetWiseTagList,
18} from "./internal.ts";
19
20export interface StateTemplateProps {
21 /**
22 * Name of the state template. Must be 1-100 characters of
23 * `[a-zA-Z0-9:_-]`. If omitted, a deterministic physical name is
24 * generated. Changing the name replaces the state template.
25 */
26 stateTemplateName?: string;
27 /**
28 * Human-readable description of the state template. Updated in place.
29 */
30 description?: string;
31 /**
32 * ARN of the {@link SignalCatalog} the state template's signals come
33 * from. Changing it replaces the state template.
34 */
35 signalCatalogArn: string;
36 /**
37 * Fully qualified names of the signals whose last-known state the
38 * template tracks, e.g. `["Vehicle.Speed"]`. Synced in place via
39 * add/remove deltas.
40 */
41 stateTemplateProperties: string[];
42 /**
43 * Vehicle attribute node paths added as extra dimensions on the state
44 * data, e.g. `["Vehicle.VIN"]`. Updated in place.
45 */
46 dataExtraDimensions?: string[];
47 /**
48 * Vehicle attribute node paths added as extra dimensions on the MQTT
49 * message metadata, e.g. `["Vehicle.VIN"]`. Updated in place.
50 */
51 metadataExtraDimensions?: string[];
52 /**
53 * User-defined tags for the state template.
54 */
55 tags?: Record<string, string>;
56}
57
58export interface StateTemplate extends Resource<
59 "AWS.IoTFleetWise.StateTemplate",
60 StateTemplateProps,
61 {
62 /** The name of the state template. */
63 stateTemplateName: string;
64 /** The ARN of the state template. */
65 stateTemplateArn: string;
66 /** The unique ID of the state template. */
67 stateTemplateId: string | undefined;
68 /** The signal catalog the state template's signals come from. */
69 signalCatalogArn: string | undefined;
70 /** The signals whose last-known state the template tracks. */
71 stateTemplateProperties: string[];
72 },
73 never,
74 Providers
75> {}
76
77/**
78 * An AWS IoT FleetWise state template — a definition of which signals'
79 * last-known state the Edge Agent reports for a {@link Vehicle}
80 * (associated via the vehicle's `stateTemplates` prop).
81 *
82 * Only the description, tracked properties, and extra dimensions are
83 * mutable — changing the name or signal catalog replaces the template.
84 * AWS IoT FleetWise is allowlist-gated and offered in
85 * `us-east-1`/`eu-central-1` only.
86 * ### Creating a State Template
87 * **Example:** Track Last-Known Speed
88 * ```typescript
89 * const template = yield* StateTemplate("SpeedState", {
90 * signalCatalogArn: catalog.signalCatalogArn,
91 * stateTemplateProperties: ["Vehicle.Speed"],
92 * });
93 * ```
94 *
95 * **Example:** Associate with a Vehicle
96 * ```typescript
97 * const vehicle = yield* Vehicle("TestVehicle", {
98 * modelManifestArn: model.modelManifestArn,
99 * decoderManifestArn: decoder.decoderManifestArn,
100 * stateTemplates: [
101 * {
102 * identifier: template.stateTemplateName,
103 * stateTemplateUpdateStrategy: { onChange: {} },
104 * },
105 * ],
106 * });
107 * ```
108 *
109 * @resource
110 */
111export const StateTemplate = Resource<StateTemplate>(
112 "AWS.IoTFleetWise.StateTemplate",
113);
114
115export const StateTemplateProvider = () =>
116 Provider.effect(
117 StateTemplate,
118 Effect.gen(function* () {
119 const toName = (id: string, props: { stateTemplateName?: string }) =>
120 props.stateTemplateName
121 ? Effect.succeed(props.stateTemplateName)
122 : createPhysicalName({ id, maxLength: 100 });
123
124 const readTemplate = Effect.fn(function* (identifier: string) {
125 return yield* iotfleetwise.getStateTemplate({ identifier }).pipe(
126 inFleetWiseRegion,
127 Effect.catchTag("ResourceNotFoundException", () =>
128 Effect.succeed(undefined),
129 ),
130 );
131 });
132
133 const toAttrs = Effect.fn(function* (
134 template: iotfleetwise.GetStateTemplateResponse,
135 ) {
136 if (template.name === undefined || template.arn === undefined) {
137 return yield* Effect.fail(
138 new Error(`StateTemplate '${template.name}' is missing its ARN`),
139 );
140 }
141 return {
142 stateTemplateName: template.name,
143 stateTemplateArn: template.arn,
144 stateTemplateId: template.id,
145 signalCatalogArn: template.signalCatalogArn,
146 stateTemplateProperties: [
147 ...(template.stateTemplateProperties ?? []),
148 ],
149 };
150 });
151
152 return {
153 stables: ["stateTemplateName", "stateTemplateArn", "stateTemplateId"],
154
155 diff: Effect.fn(function* ({ id, olds, news }) {
156 if (!isResolved(news)) return undefined;
157 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
158 return { action: "replace" } as const;
159 }
160 // The backing signal catalog is fixed at creation.
161 if (olds.signalCatalogArn !== news.signalCatalogArn) {
162 return { action: "replace" } as const;
163 }
164 }),
165
166 read: Effect.fn(function* ({ id, olds, output }) {
167 const name =
168 output?.stateTemplateName ?? (yield* toName(id, olds ?? {}));
169 const found = yield* readTemplate(name);
170 if (found?.arn === undefined) return undefined;
171 const attrs = yield* toAttrs(found);
172 const tags = yield* readFleetWiseTags(found.arn);
173 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
174 }),
175
176 reconcile: Effect.fn(function* ({ id, news, output, session }) {
177 const name = output?.stateTemplateName ?? (yield* toName(id, news));
178 const internalTags = yield* createInternalTags(id);
179 const desiredTags = { ...internalTags, ...news.tags };
180
181 // 1. Observe — cloud state is authoritative.
182 let observed = yield* readTemplate(name);
183
184 // 2. Ensure — create if missing; tolerate the AlreadyExists race.
185 if (observed === undefined) {
186 yield* iotfleetwise
187 .createStateTemplate({
188 name,
189 description: news.description,
190 signalCatalogArn: news.signalCatalogArn,
191 stateTemplateProperties: news.stateTemplateProperties,
192 dataExtraDimensions: news.dataExtraDimensions,
193 metadataExtraDimensions: news.metadataExtraDimensions,
194 tags: toFleetWiseTagList(desiredTags),
195 })
196 .pipe(
197 inFleetWiseRegion,
198 Effect.catchTag("ConflictException", () => Effect.void),
199 );
200 observed = yield* readTemplate(name).pipe(
201 Effect.flatMap((template) =>
202 template === undefined
203 ? Effect.fail(new Error(`StateTemplate '${name}' not found`))
204 : Effect.succeed(template),
205 ),
206 retryObservation,
207 );
208 }
209
210 // 3. Sync mutable aspects against OBSERVED cloud state.
211 const observedProperties = observed.stateTemplateProperties ?? [];
212 const toAdd = news.stateTemplateProperties.filter(
213 (fqn) => !observedProperties.includes(fqn),
214 );
215 const toRemove = observedProperties.filter(
216 (fqn) => !news.stateTemplateProperties.includes(fqn),
217 );
218 const descriptionChanged =
219 news.description !== undefined &&
220 news.description !== observed.description;
221 const dataDimensionsChanged =
222 news.dataExtraDimensions !== undefined &&
223 !stableEquals(
224 observed.dataExtraDimensions ?? [],
225 news.dataExtraDimensions,
226 );
227 const metadataDimensionsChanged =
228 news.metadataExtraDimensions !== undefined &&
229 !stableEquals(
230 observed.metadataExtraDimensions ?? [],
231 news.metadataExtraDimensions,
232 );
233 if (
234 toAdd.length > 0 ||
235 toRemove.length > 0 ||
236 descriptionChanged ||
237 dataDimensionsChanged ||
238 metadataDimensionsChanged
239 ) {
240 yield* iotfleetwise
241 .updateStateTemplate({
242 identifier: name,
243 description: descriptionChanged ? news.description : undefined,
244 stateTemplatePropertiesToAdd:
245 toAdd.length > 0 ? toAdd : undefined,
246 stateTemplatePropertiesToRemove:
247 toRemove.length > 0 ? toRemove : undefined,
248 dataExtraDimensions: dataDimensionsChanged
249 ? news.dataExtraDimensions
250 : undefined,
251 metadataExtraDimensions: metadataDimensionsChanged
252 ? news.metadataExtraDimensions
253 : undefined,
254 })
255 .pipe(inFleetWiseRegion);
256 observed = yield* readTemplate(name).pipe(
257 Effect.flatMap((template) =>
258 template === undefined
259 ? Effect.fail(new Error(`StateTemplate '${name}' not found`))
260 : Effect.succeed(template),
261 ),
262 retryObservation,
263 );
264 }
265
266 // 3b. Sync tags against OBSERVED cloud tags.
267 const arn = observed.arn;
268 if (arn !== undefined) {
269 yield* syncFleetWiseTags(arn, desiredTags);
270 }
271
272 yield* session.note(name);
273 return yield* toAttrs(observed);
274 }),
275
276 delete: Effect.fn(function* ({ output }) {
277 // DeleteStateTemplate is idempotent — deleting a missing template
278 // succeeds (its error union carries no not-found tag).
279 yield* iotfleetwise
280 .deleteStateTemplate({ identifier: output.stateTemplateName })
281 .pipe(inFleetWiseRegion);
282 }),
283
284 list: () =>
285 iotfleetwise.listStateTemplates.items({}).pipe(
286 Stream.runCollect,
287 Effect.map((chunk) =>
288 Array.from(chunk).flatMap((summary) =>
289 summary.name !== undefined && summary.arn !== undefined
290 ? [
291 {
292 stateTemplateName: summary.name,
293 stateTemplateArn: summary.arn,
294 stateTemplateId: summary.id,
295 signalCatalogArn: summary.signalCatalogArn,
296 stateTemplateProperties: [] as string[],
297 },
298 ]
299 : [],
300 ),
301 ),
302 inFleetWiseRegion,
303 ),
304 };
305 }),
306 );