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 retryWhileConflict,
16 syncFleetWiseTags,
17 toFleetWiseTagList,
18} from "./internal.ts";
19
20export interface ModelManifestProps {
21 /**
22 * Name of the model manifest. 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 manifest.
25 */
26 modelManifestName?: string;
27 /**
28 * ARN of the {@link SignalCatalog} the manifest's nodes come from.
29 * Changing the signal catalog replaces the manifest.
30 */
31 signalCatalogArn: string;
32 /**
33 * Fully qualified names of the signal-catalog nodes included in the
34 * vehicle model, e.g. `["Vehicle.Speed"]`. Updated in place via
35 * `nodesToAdd`/`nodesToRemove` deltas (only while the manifest is in
36 * `DRAFT` status).
37 */
38 nodes: string[];
39 /**
40 * Human-readable description of the model manifest.
41 */
42 description?: string;
43 /**
44 * Status of the manifest. Vehicles can only be created from an `ACTIVE`
45 * manifest; node changes require `DRAFT`.
46 * @default "DRAFT"
47 */
48 status?: "ACTIVE" | "DRAFT";
49 /**
50 * User-defined tags for the model manifest.
51 */
52 tags?: Record<string, string>;
53}
54
55export interface ModelManifest extends Resource<
56 "AWS.IoTFleetWise.ModelManifest",
57 ModelManifestProps,
58 {
59 /** The name of the model manifest. */
60 modelManifestName: string;
61 /** The ARN of the model manifest. */
62 modelManifestArn: string;
63 /** The current status of the manifest (`ACTIVE`, `DRAFT`, ...). */
64 status: string;
65 /** The signal catalog the manifest's nodes come from. */
66 signalCatalogArn: string | undefined;
67 },
68 never,
69 Providers
70> {}
71
72/**
73 * An AWS IoT FleetWise model manifest (vehicle model) — the subset of
74 * signal-catalog nodes that describes one vehicle type.
75 *
76 * A manifest is created in `DRAFT` status; set `status: "ACTIVE"` to make
77 * it usable by decoder manifests and vehicles. AWS IoT FleetWise is
78 * allowlist-gated and offered in `us-east-1`/`eu-central-1` only.
79 * ### Creating a Model Manifest
80 * **Example:** Active Vehicle Model
81 * ```typescript
82 * const model = yield* ModelManifest("SedanModel", {
83 * signalCatalogArn: catalog.signalCatalogArn,
84 * nodes: ["Vehicle.Speed"],
85 * status: "ACTIVE",
86 * });
87 * ```
88 *
89 * **Example:** Draft Model with Description
90 * ```typescript
91 * const model = yield* ModelManifest("SedanModel", {
92 * signalCatalogArn: catalog.signalCatalogArn,
93 * nodes: ["Vehicle.Speed", "Vehicle.VIN"],
94 * description: "2026 sedan line",
95 * });
96 * ```
97 *
98 * @resource
99 */
100export const ModelManifest = Resource<ModelManifest>(
101 "AWS.IoTFleetWise.ModelManifest",
102);
103
104const nodeFqn = (node: iotfleetwise.Node): string =>
105 node.branch?.fullyQualifiedName ??
106 node.sensor?.fullyQualifiedName ??
107 node.actuator?.fullyQualifiedName ??
108 node.attribute?.fullyQualifiedName ??
109 node.struct?.fullyQualifiedName ??
110 node.property?.fullyQualifiedName ??
111 "";
112
113export const ModelManifestProvider = () =>
114 Provider.effect(
115 ModelManifest,
116 Effect.gen(function* () {
117 const toName = (id: string, props: { modelManifestName?: string }) =>
118 props.modelManifestName
119 ? Effect.succeed(props.modelManifestName)
120 : createPhysicalName({ id, maxLength: 100 });
121
122 const readManifest = Effect.fn(function* (name: string) {
123 return yield* iotfleetwise.getModelManifest({ name }).pipe(
124 inFleetWiseRegion,
125 Effect.catchTag("ResourceNotFoundException", () =>
126 Effect.succeed(undefined),
127 ),
128 );
129 });
130
131 const readNodeFqns = Effect.fn(function* (name: string) {
132 const nodes = yield* iotfleetwise.listModelManifestNodes
133 .items({ name })
134 .pipe(
135 Stream.runCollect,
136 Effect.map((chunk) => Array.from(chunk)),
137 inFleetWiseRegion,
138 );
139 return nodes.map(nodeFqn);
140 });
141
142 const toAttrs = (manifest: iotfleetwise.GetModelManifestResponse) => ({
143 modelManifestName: manifest.name,
144 modelManifestArn: manifest.arn,
145 status: manifest.status ?? "DRAFT",
146 signalCatalogArn: manifest.signalCatalogArn,
147 });
148
149 return {
150 stables: ["modelManifestName", "modelManifestArn"],
151
152 diff: Effect.fn(function* ({ id, olds, news }) {
153 if (!isResolved(news)) return undefined;
154 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
155 return { action: "replace" } as const;
156 }
157 // The update API cannot change the signal catalog.
158 if (news.signalCatalogArn !== olds.signalCatalogArn) {
159 return { action: "replace" } as const;
160 }
161 }),
162
163 read: Effect.fn(function* ({ id, olds, output }) {
164 const name =
165 output?.modelManifestName ?? (yield* toName(id, olds ?? {}));
166 const found = yield* readManifest(name);
167 if (found === undefined) return undefined;
168 const attrs = toAttrs(found);
169 const tags = yield* readFleetWiseTags(found.arn);
170 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
171 }),
172
173 reconcile: Effect.fn(function* ({ id, news, output, session }) {
174 const name = output?.modelManifestName ?? (yield* toName(id, news));
175 const internalTags = yield* createInternalTags(id);
176 const desiredTags = { ...internalTags, ...news.tags };
177 const desiredStatus = news.status ?? "DRAFT";
178
179 // 1. Observe — cloud state is authoritative.
180 let observed = yield* readManifest(name);
181
182 // 2. Ensure — create if missing; tolerate the AlreadyExists race.
183 if (observed === undefined) {
184 yield* iotfleetwise
185 .createModelManifest({
186 name,
187 nodes: news.nodes,
188 signalCatalogArn: news.signalCatalogArn,
189 description: news.description,
190 tags: toFleetWiseTagList(desiredTags),
191 })
192 .pipe(
193 inFleetWiseRegion,
194 Effect.catchTag("ConflictException", () => Effect.void),
195 );
196 observed = yield* readManifest(name).pipe(
197 Effect.flatMap((manifest) =>
198 manifest === undefined
199 ? Effect.fail(new Error(`Model manifest '${name}' not found`))
200 : Effect.succeed(manifest),
201 ),
202 retryObservation,
203 );
204 }
205
206 // 3. Sync nodes + description — diff OBSERVED node FQNs against
207 // the desired list. Node changes require DRAFT status, so they
208 // are applied before any DRAFT -> ACTIVE transition.
209 const observedFqns = yield* readNodeFqns(name);
210 const observedSet = new Set(observedFqns);
211 const desiredSet = new Set(news.nodes);
212 const nodesToAdd = news.nodes.filter((n) => !observedSet.has(n));
213 const nodesToRemove = observedFqns.filter((n) => !desiredSet.has(n));
214 const descriptionChanged =
215 news.description !== undefined &&
216 news.description !== observed.description;
217 if (
218 nodesToAdd.length > 0 ||
219 nodesToRemove.length > 0 ||
220 descriptionChanged
221 ) {
222 yield* iotfleetwise
223 .updateModelManifest({
224 name,
225 description: descriptionChanged ? news.description : undefined,
226 nodesToAdd: nodesToAdd.length > 0 ? nodesToAdd : undefined,
227 nodesToRemove:
228 nodesToRemove.length > 0 ? nodesToRemove : undefined,
229 })
230 .pipe(inFleetWiseRegion);
231 observed = yield* readManifest(name).pipe(
232 Effect.flatMap((manifest) =>
233 manifest === undefined
234 ? Effect.fail(new Error(`Model manifest '${name}' not found`))
235 : Effect.succeed(manifest),
236 ),
237 retryObservation,
238 );
239 }
240
241 // 3b. Sync status (e.g. activate a draft).
242 if ((observed.status ?? "DRAFT") !== desiredStatus) {
243 yield* iotfleetwise
244 .updateModelManifest({ name, status: desiredStatus })
245 .pipe(inFleetWiseRegion);
246 observed = yield* readManifest(name).pipe(
247 Effect.flatMap((manifest) =>
248 manifest === undefined
249 ? Effect.fail(new Error(`Model manifest '${name}' not found`))
250 : Effect.succeed(manifest),
251 ),
252 retryObservation,
253 );
254 }
255
256 // 3c. Sync tags against OBSERVED cloud tags.
257 yield* syncFleetWiseTags(observed.arn, desiredTags);
258
259 yield* session.note(name);
260 return toAttrs(observed);
261 }),
262
263 delete: Effect.fn(function* ({ output }) {
264 // Idempotent: deleting a missing manifest succeeds. Decoder
265 // manifests still detaching surface as ConflictException — retry
266 // through the window (bounded).
267 yield* iotfleetwise
268 .deleteModelManifest({ name: output.modelManifestName })
269 .pipe(
270 inFleetWiseRegion,
271 retryWhileConflict,
272 Effect.catchTag("ConflictException", () => Effect.void),
273 );
274 }),
275
276 list: () =>
277 iotfleetwise.listModelManifests.items({}).pipe(
278 Stream.runCollect,
279 Effect.map((chunk) =>
280 Array.from(chunk).flatMap((summary) =>
281 summary.name !== undefined && summary.arn !== undefined
282 ? [
283 {
284 modelManifestName: summary.name,
285 modelManifestArn: summary.arn,
286 status: summary.status ?? "DRAFT",
287 signalCatalogArn: summary.signalCatalogArn,
288 },
289 ]
290 : [],
291 ),
292 ),
293 inFleetWiseRegion,
294 ),
295 };
296 }),
297 );