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 stableEquals,
17 syncFleetWiseTags,
18 toFleetWiseTagList,
19} from "./internal.ts";
20
21export interface SignalCatalogProps {
22 /**
23 * Name of the signal catalog. Must be 1-100 characters of
24 * `[a-zA-Z0-9:_-]`. If omitted, a deterministic physical name is
25 * generated. Changing the name replaces the catalog.
26 */
27 signalCatalogName?: string;
28 /**
29 * Human-readable description of the signal catalog.
30 */
31 description?: string;
32 /**
33 * Signal nodes forming the catalog's tree — branches, sensors,
34 * actuators, attributes, structs and properties. Each node is one of the
35 * union variants (`{ branch }`, `{ sensor }`, ...) keyed by
36 * `fullyQualifiedName`. Updated in place via
37 * `nodesToAdd`/`nodesToUpdate`/`nodesToRemove` deltas.
38 */
39 nodes?: iotfleetwise.Node[];
40 /**
41 * User-defined tags for the signal catalog.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface SignalCatalog extends Resource<
47 "AWS.IoTFleetWise.SignalCatalog",
48 SignalCatalogProps,
49 {
50 /** The name of the signal catalog. */
51 signalCatalogName: string;
52 /** The ARN of the signal catalog. */
53 signalCatalogArn: string;
54 },
55 never,
56 Providers
57> {}
58
59/**
60 * An AWS IoT FleetWise signal catalog — the account-wide collection of
61 * standardized vehicle signals (branches, sensors, actuators, attributes)
62 * that model manifests draw from.
63 *
64 * AWS IoT FleetWise is offered in `us-east-1` and `eu-central-1` only; the
65 * provider follows the ambient region when supported and pins `us-east-1`
66 * otherwise. Access to the service is allowlist-gated by AWS — accounts
67 * without access receive `AccessDeniedException` on every operation.
68 * ### Creating a Signal Catalog
69 * **Example:** Catalog with a Branch and a Sensor
70 * ```typescript
71 * const catalog = yield* SignalCatalog("Signals", {
72 * nodes: [
73 * { branch: { fullyQualifiedName: "Vehicle" } },
74 * {
75 * sensor: {
76 * fullyQualifiedName: "Vehicle.Speed",
77 * dataType: "DOUBLE",
78 * unit: "km/h",
79 * },
80 * },
81 * ],
82 * });
83 * ```
84 *
85 * **Example:** Catalog with Attributes
86 * ```typescript
87 * const catalog = yield* SignalCatalog("Signals", {
88 * description: "vehicle signals",
89 * nodes: [
90 * { branch: { fullyQualifiedName: "Vehicle" } },
91 * {
92 * attribute: {
93 * fullyQualifiedName: "Vehicle.VIN",
94 * dataType: "STRING",
95 * },
96 * },
97 * ],
98 * tags: { team: "telemetry" },
99 * });
100 * ```
101 *
102 * @resource
103 */
104export const SignalCatalog = Resource<SignalCatalog>(
105 "AWS.IoTFleetWise.SignalCatalog",
106);
107
108const nodeFqn = (node: iotfleetwise.Node): string =>
109 node.branch?.fullyQualifiedName ??
110 node.sensor?.fullyQualifiedName ??
111 node.actuator?.fullyQualifiedName ??
112 node.attribute?.fullyQualifiedName ??
113 node.struct?.fullyQualifiedName ??
114 node.property?.fullyQualifiedName ??
115 "";
116
117export const SignalCatalogProvider = () =>
118 Provider.effect(
119 SignalCatalog,
120 Effect.gen(function* () {
121 const toName = (id: string, props: { signalCatalogName?: string }) =>
122 props.signalCatalogName
123 ? Effect.succeed(props.signalCatalogName)
124 : createPhysicalName({ id, maxLength: 100 });
125
126 const readCatalog = Effect.fn(function* (name: string) {
127 return yield* iotfleetwise.getSignalCatalog({ name }).pipe(
128 inFleetWiseRegion,
129 Effect.catchTag("ResourceNotFoundException", () =>
130 Effect.succeed(undefined),
131 ),
132 );
133 });
134
135 const readNodes = Effect.fn(function* (name: string) {
136 return yield* iotfleetwise.listSignalCatalogNodes.items({ name }).pipe(
137 Stream.runCollect,
138 Effect.map((chunk) => Array.from(chunk)),
139 inFleetWiseRegion,
140 );
141 });
142
143 return {
144 stables: ["signalCatalogName", "signalCatalogArn"],
145
146 diff: Effect.fn(function* ({ id, olds, news }) {
147 if (!isResolved(news)) return undefined;
148 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
149 return { action: "replace" } as const;
150 }
151 }),
152
153 read: Effect.fn(function* ({ id, olds, output }) {
154 const name =
155 output?.signalCatalogName ?? (yield* toName(id, olds ?? {}));
156 const found = yield* readCatalog(name);
157 if (found === undefined) return undefined;
158 const attrs = {
159 signalCatalogName: found.name,
160 signalCatalogArn: found.arn,
161 };
162 const tags = yield* readFleetWiseTags(found.arn);
163 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
164 }),
165
166 reconcile: Effect.fn(function* ({ id, news, output, session }) {
167 const name = output?.signalCatalogName ?? (yield* toName(id, news));
168 const internalTags = yield* createInternalTags(id);
169 const desiredTags = { ...internalTags, ...news.tags };
170 const desiredNodes = news.nodes ?? [];
171
172 // 1. Observe — cloud state is authoritative.
173 let observed = yield* readCatalog(name);
174
175 // 2. Ensure — create if missing; tolerate the AlreadyExists race.
176 if (observed === undefined) {
177 yield* iotfleetwise
178 .createSignalCatalog({
179 name,
180 description: news.description,
181 nodes: desiredNodes,
182 tags: toFleetWiseTagList(desiredTags),
183 })
184 .pipe(
185 inFleetWiseRegion,
186 Effect.catchTag("ConflictException", () => Effect.void),
187 );
188 observed = yield* readCatalog(name).pipe(
189 Effect.flatMap((catalog) =>
190 catalog === undefined
191 ? Effect.fail(new Error(`Signal catalog '${name}' not found`))
192 : Effect.succeed(catalog),
193 ),
194 retryObservation,
195 );
196 }
197
198 // 3. Sync nodes + description — diff OBSERVED nodes against the
199 // desired tree keyed by fullyQualifiedName.
200 const observedNodes = yield* readNodes(name);
201 const observedByFqn = new Map(
202 observedNodes.map((node) => [nodeFqn(node), node]),
203 );
204 const desiredByFqn = new Map(
205 desiredNodes.map((node) => [nodeFqn(node), node]),
206 );
207 const nodesToAdd = desiredNodes.filter(
208 (node) => !observedByFqn.has(nodeFqn(node)),
209 );
210 const nodesToUpdate = desiredNodes.filter((node) => {
211 const current = observedByFqn.get(nodeFqn(node));
212 return current !== undefined && !stableEquals(current, node);
213 });
214 const nodesToRemove = observedNodes
215 .map(nodeFqn)
216 .filter((fqn) => !desiredByFqn.has(fqn));
217 const descriptionChanged =
218 news.description !== undefined &&
219 news.description !== observed.description;
220 if (
221 nodesToAdd.length > 0 ||
222 nodesToUpdate.length > 0 ||
223 nodesToRemove.length > 0 ||
224 descriptionChanged
225 ) {
226 yield* iotfleetwise
227 .updateSignalCatalog({
228 name,
229 description: descriptionChanged ? news.description : undefined,
230 nodesToAdd: nodesToAdd.length > 0 ? nodesToAdd : undefined,
231 nodesToUpdate:
232 nodesToUpdate.length > 0 ? nodesToUpdate : undefined,
233 nodesToRemove:
234 nodesToRemove.length > 0 ? nodesToRemove : undefined,
235 })
236 .pipe(inFleetWiseRegion);
237 }
238
239 // 3b. Sync tags against OBSERVED cloud tags.
240 yield* syncFleetWiseTags(observed.arn, desiredTags);
241
242 yield* session.note(name);
243 return {
244 signalCatalogName: observed.name,
245 signalCatalogArn: observed.arn,
246 };
247 }),
248
249 delete: Effect.fn(function* ({ output }) {
250 // Idempotent: FleetWise deletes return success for missing
251 // resources. Model manifests still detaching surface as
252 // ConflictException — retry through the window (bounded).
253 yield* iotfleetwise
254 .deleteSignalCatalog({ name: output.signalCatalogName })
255 .pipe(
256 inFleetWiseRegion,
257 retryWhileConflict,
258 Effect.catchTag("ConflictException", () => Effect.void),
259 );
260 }),
261
262 list: () =>
263 iotfleetwise.listSignalCatalogs.items({}).pipe(
264 Stream.runCollect,
265 Effect.map((chunk) =>
266 Array.from(chunk).flatMap((summary) =>
267 summary.name !== undefined && summary.arn !== undefined
268 ? [
269 {
270 signalCatalogName: summary.name,
271 signalCatalogArn: summary.arn,
272 },
273 ]
274 : [],
275 ),
276 ),
277 inFleetWiseRegion,
278 ),
279 };
280 }),
281 );