1import * as sitewise from "@distilled.cloud/aws/iotsitewise";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as EffectStream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, hasAlchemyTags, type Tags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 fetchSiteWiseTags,
15 matchesDesired,
16 syncSiteWiseTags,
17} from "./internal.ts";
18
19export type AssetModelType = sitewise.AssetModelType;
20export type AssetModelState = sitewise.AssetModelState;
21export type AssetModelPropertyDefinition =
22 sitewise.AssetModelPropertyDefinition;
23export type AssetModelHierarchyDefinition =
24 sitewise.AssetModelHierarchyDefinition;
25export type AssetModelCompositeModelDefinition =
26 sitewise.AssetModelCompositeModelDefinition;
27
28export interface AssetModelProps {
29 /**
30 * A unique name for the asset model.
31 * @default ${app}-${stage}-${id}
32 */
33 assetModelName?: string;
34 /**
35 * The type of asset model: `ASSET_MODEL` (default, creates assets),
36 * `COMPONENT_MODEL` (reusable component), or `INTERFACE`.
37 * Changing the type replaces the asset model.
38 * @default "ASSET_MODEL"
39 */
40 assetModelType?: AssetModelType;
41 /**
42 * A description for the asset model.
43 */
44 assetModelDescription?: string;
45 /**
46 * The property definitions of the asset model (attributes, measurements,
47 * transforms, and metrics). Assets created from the model inherit these.
48 */
49 assetModelProperties?: AssetModelPropertyDefinition[];
50 /**
51 * The hierarchy definitions of the asset model. Each hierarchy specifies
52 * an asset model whose assets can be children of assets created from
53 * this model.
54 */
55 assetModelHierarchies?: AssetModelHierarchyDefinition[];
56 /**
57 * The composite models that are part of this asset model (e.g. alarms
58 * or component-model references).
59 */
60 assetModelCompositeModels?: AssetModelCompositeModelDefinition[];
61 /**
62 * Tags to associate with the asset model.
63 */
64 tags?: Record<string, string>;
65}
66
67export interface AssetModel extends Resource<
68 "AWS.IoTSiteWise.AssetModel",
69 AssetModelProps,
70 {
71 /**
72 * Service-assigned UUID of the asset model.
73 */
74 assetModelId: string;
75 /**
76 * ARN of the asset model.
77 */
78 assetModelArn: string;
79 /**
80 * The asset model's name.
81 */
82 assetModelName: string;
83 /**
84 * The asset model's type.
85 */
86 assetModelType: AssetModelType | undefined;
87 /**
88 * Current lifecycle state of the asset model.
89 */
90 state: AssetModelState;
91 /**
92 * Current tags reported for the asset model.
93 */
94 tags: Record<string, string>;
95 },
96 never,
97 Providers
98> {}
99
100/**
101 * An AWS IoT SiteWise asset model — a standardized definition of
102 * properties (attributes, measurements, transforms, metrics) and
103 * hierarchies from which industrial assets are created.
104 *
105 * Asset model creation and updates are asynchronous
106 * (`CREATING`/`UPDATING` → `ACTIVE`); the provider waits for the model to
107 * converge to `ACTIVE` before returning.
108 *
109 * ### Creating Asset Models
110 * **Example:** Asset Model with a Measurement and an Attribute
111 * ```typescript
112 * import * as AWS from "alchemy/AWS";
113 *
114 * const model = yield* AWS.IoTSiteWise.AssetModel("PumpModel", {
115 * assetModelDescription: "A pump on the factory floor",
116 * assetModelProperties: [
117 * {
118 * name: "SerialNumber",
119 * dataType: "STRING",
120 * type: { attribute: { defaultValue: "unknown" } },
121 * },
122 * {
123 * name: "Temperature",
124 * dataType: "DOUBLE",
125 * unit: "Celsius",
126 * type: { measurement: {} },
127 * },
128 * ],
129 * });
130 * ```
131 *
132 * ### Hierarchies
133 * **Example:** Parent Model with a Child Hierarchy
134 * ```typescript
135 * const pump = yield* AWS.IoTSiteWise.AssetModel("PumpModel", {
136 * assetModelProperties: [
137 * { name: "Temperature", dataType: "DOUBLE", type: { measurement: {} } },
138 * ],
139 * });
140 *
141 * const site = yield* AWS.IoTSiteWise.AssetModel("SiteModel", {
142 * assetModelHierarchies: [
143 * { name: "Pumps", childAssetModelId: pump.assetModelId },
144 * ],
145 * });
146 * ```
147 *
148 * @resource
149 */
150export const AssetModel = Resource<AssetModel>("AWS.IoTSiteWise.AssetModel");
151
152const createAssetModelName = (
153 id: string,
154 props: { assetModelName?: string | undefined },
155) =>
156 props.assetModelName
157 ? Effect.succeed(props.assetModelName)
158 : createPhysicalName({ id, maxLength: 256 });
159
160interface AssetModelState_ {
161 attrs: AssetModel["Attributes"];
162 described: sitewise.DescribeAssetModelResponse;
163}
164
165const readAssetModelById = Effect.fn(function* (assetModelId: string) {
166 const described = yield* sitewise
167 .describeAssetModel({ assetModelId, excludeProperties: false })
168 .pipe(
169 Effect.catchTag("ResourceNotFoundException", () =>
170 Effect.succeed(undefined),
171 ),
172 );
173 if (!described || described.assetModelStatus.state === "DELETING") {
174 return undefined;
175 }
176 const state: AssetModelState_ = {
177 described,
178 attrs: {
179 assetModelId: described.assetModelId,
180 assetModelArn: described.assetModelArn,
181 assetModelName: described.assetModelName,
182 assetModelType: described.assetModelType,
183 state: described.assetModelStatus.state,
184 tags: yield* fetchSiteWiseTags(described.assetModelArn),
185 },
186 };
187 return state;
188});
189
190const findAssetModelByName = Effect.fn(function* (name: string) {
191 const summaries = yield* sitewise.listAssetModels.pages({}).pipe(
192 EffectStream.runCollect,
193 Effect.map((chunk) =>
194 Array.from(chunk).flatMap((page) => page.assetModelSummaries),
195 ),
196 );
197 const match = summaries.find(
198 (summary) => summary.name === name && summary.status.state !== "DELETING",
199 );
200 if (!match) return undefined;
201 return yield* readAssetModelById(match.id);
202});
203
204/**
205 * An asset model still transitioning toward the awaited state — retried
206 * by {@link waitForAssetModelState}'s bounded schedule.
207 */
208class AssetModelNotReady extends Data.TaggedError("AssetModelNotReady")<{
209 readonly assetModelId: string;
210 readonly state: string | undefined;
211}> {}
212
213/**
214 * An asset model whose asynchronous provisioning converged to the
215 * terminal `FAILED` state.
216 */
217export class AssetModelProvisioningFailed extends Data.TaggedError(
218 "AssetModelProvisioningFailed",
219)<{
220 readonly assetModelId: string;
221 readonly message: string | undefined;
222}> {}
223
224// Explicitly-typed retry wrapper — an inline `Effect.retry` in provider
225// lifecycle code leaks `Retry.Return`'s conditional type into declaration
226// emit and widens the provider layer to `unknown` for every consumer of
227// `AWS.providers()`.
228const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
229 self: Effect.Effect<A, E, R>,
230): Effect.Effect<A, E, R> =>
231 Effect.retry(self, {
232 while: (e) => e._tag === "AssetModelNotReady",
233 // Asset model provisioning usually converges in seconds; poll every
234 // 3s up to ~90s total.
235 schedule: Schedule.max([Schedule.spaced("3 seconds"), Schedule.recurs(30)]),
236 });
237
238// A delete/update racing an in-flight CREATING/UPDATING transition
239// surfaces as ConflictingOperationException — bounded retry through it.
240const retryThroughConflictingOperation = <
241 A,
242 E extends { readonly _tag: string },
243 R,
244>(
245 self: Effect.Effect<A, E, R>,
246): Effect.Effect<A, E, R> =>
247 Effect.retry(self, {
248 while: (e) => e._tag === "ConflictingOperationException",
249 schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(10)]),
250 });
251
252const waitForAssetModelState = (
253 assetModelId: string,
254 target: "ACTIVE" | "DELETED",
255) =>
256 retryWhileNotReady(
257 Effect.gen(function* () {
258 const described = yield* sitewise
259 .describeAssetModel({ assetModelId, excludeProperties: true })
260 .pipe(
261 Effect.catchTag("ResourceNotFoundException", () =>
262 Effect.succeed(undefined),
263 ),
264 );
265 if (target === "DELETED") {
266 if (described === undefined) return;
267 return yield* Effect.fail(
268 new AssetModelNotReady({
269 assetModelId,
270 state: described.assetModelStatus.state,
271 }),
272 );
273 }
274 if (described?.assetModelStatus.state === "ACTIVE") return;
275 if (described?.assetModelStatus.state === "FAILED") {
276 return yield* Effect.fail(
277 new AssetModelProvisioningFailed({
278 assetModelId,
279 message: described.assetModelStatus.error?.message,
280 }),
281 );
282 }
283 return yield* Effect.fail(
284 new AssetModelNotReady({
285 assetModelId,
286 state: described?.assetModelStatus.state,
287 }),
288 );
289 }),
290 );
291
292/**
293 * Map desired property/hierarchy definitions onto the observed model so
294 * an update preserves the service-assigned ids of entries that already
295 * exist (matched by name). Entries without a match are sent id-less and
296 * get new ids.
297 */
298const withObservedIds = <T extends { id?: string; name: string }>(
299 desired: readonly T[],
300 observed: readonly { id?: string; name: string }[],
301): T[] =>
302 desired.map((item) => {
303 if (item.id !== undefined) return item;
304 const match = observed.find((o) => o.name === item.name);
305 return match?.id !== undefined ? { ...item, id: match.id } : item;
306 });
307
308export const AssetModelProvider = () =>
309 Provider.effect(
310 AssetModel,
311 Effect.gen(function* () {
312 return {
313 stables: ["assetModelId", "assetModelArn"],
314 list: () =>
315 Effect.gen(function* () {
316 const summaries = yield* sitewise.listAssetModels.pages({}).pipe(
317 EffectStream.runCollect,
318 Effect.map((chunk) =>
319 Array.from(chunk).flatMap((page) => page.assetModelSummaries),
320 ),
321 );
322 const hydrated = yield* Effect.forEach(
323 summaries.map((s) => s.id),
324 (assetModelId) => readAssetModelById(assetModelId),
325 { concurrency: 5 },
326 );
327 return hydrated.flatMap((state) =>
328 state === undefined ? [] : [state.attrs],
329 );
330 }),
331 read: Effect.fn(function* ({ id, olds, output }) {
332 const state = output?.assetModelId
333 ? yield* readAssetModelById(output.assetModelId)
334 : yield* findAssetModelByName(
335 yield* createAssetModelName(id, olds ?? {}),
336 );
337 if (!state) return undefined;
338 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
339 ? state.attrs
340 : Unowned(state.attrs);
341 }),
342 diff: Effect.fn(function* ({ news, olds }) {
343 if (!isResolved(news)) return;
344 if (olds === undefined) return;
345 // The model type is fixed at creation.
346 if (
347 (olds.assetModelType ?? "ASSET_MODEL") !==
348 (news.assetModelType ?? "ASSET_MODEL")
349 ) {
350 return { action: "replace" } as const;
351 }
352 }),
353 reconcile: Effect.fn(function* ({ id, news, output, session }) {
354 if (!news) {
355 return yield* Effect.fail(
356 new Error("IoT SiteWise AssetModel requires props"),
357 );
358 }
359 const name = yield* createAssetModelName(id, news);
360 const internalTags = yield* createInternalTags(id);
361 const desiredTags = { ...internalTags, ...news.tags };
362
363 // Observe — prefer the cached id; fall back to a name lookup so
364 // a create whose state failed to persist is adopted, not
365 // duplicated.
366 let state = output?.assetModelId
367 ? yield* readAssetModelById(output.assetModelId)
368 : yield* findAssetModelByName(name);
369
370 // Ensure — create if missing, then wait for ACTIVE.
371 if (state === undefined) {
372 const created = yield* sitewise.createAssetModel({
373 assetModelName: name,
374 assetModelType: news.assetModelType,
375 assetModelDescription: news.assetModelDescription,
376 assetModelProperties: news.assetModelProperties,
377 assetModelHierarchies: news.assetModelHierarchies,
378 assetModelCompositeModels: news.assetModelCompositeModels,
379 tags: desiredTags,
380 });
381 yield* session.note(
382 `Creating asset model ${name} (${created.assetModelId})...`,
383 );
384 yield* waitForAssetModelState(created.assetModelId, "ACTIVE");
385 state = yield* readAssetModelById(created.assetModelId);
386 if (state === undefined) {
387 return yield* Effect.fail(
388 new Error(`failed to read created asset model ${name}`),
389 );
390 }
391 }
392
393 // Sync — diff observed definition against desired; UpdateAssetModel
394 // replaces the full definition, so send the complete desired set
395 // with observed ids preserved by name.
396 const described = state.described;
397 const desiredProperties = withObservedIds(
398 news.assetModelProperties ?? [],
399 described.assetModelProperties,
400 );
401 const desiredHierarchies = withObservedIds(
402 news.assetModelHierarchies ?? [],
403 described.assetModelHierarchies,
404 );
405 const observedDefinition = {
406 name: described.assetModelName,
407 description: described.assetModelDescription,
408 properties: described.assetModelProperties,
409 hierarchies: described.assetModelHierarchies,
410 };
411 const desiredDefinition = {
412 name,
413 description: news.assetModelDescription ?? "",
414 properties: desiredProperties,
415 hierarchies: desiredHierarchies,
416 };
417 const definitionDrifted =
418 name !== described.assetModelName ||
419 (news.assetModelDescription ?? "") !==
420 described.assetModelDescription ||
421 desiredProperties.length !==
422 described.assetModelProperties.length ||
423 desiredHierarchies.length !==
424 described.assetModelHierarchies.length ||
425 !matchesDesired(desiredDefinition, observedDefinition);
426 if (definitionDrifted) {
427 yield* retryThroughConflictingOperation(
428 sitewise.updateAssetModel({
429 assetModelId: state.attrs.assetModelId,
430 assetModelName: name,
431 assetModelDescription: news.assetModelDescription,
432 assetModelProperties: desiredProperties,
433 assetModelHierarchies: desiredHierarchies,
434 }),
435 );
436 yield* waitForAssetModelState(state.attrs.assetModelId, "ACTIVE");
437 yield* session.note(`Updated asset model ${name}`);
438 }
439
440 // Sync tags — diff against observed cloud tags.
441 yield* syncSiteWiseTags(
442 state.attrs.assetModelArn,
443 state.attrs.tags,
444 desiredTags,
445 );
446
447 yield* session.note(state.attrs.assetModelArn);
448
449 const final = yield* readAssetModelById(state.attrs.assetModelId);
450 if (!final) {
451 return yield* Effect.fail(
452 new Error(`failed to read reconciled asset model ${name}`),
453 );
454 }
455 return final.attrs;
456 }),
457 delete: Effect.fn(function* ({ output }) {
458 // Deleting while assets still reference the model (or while a
459 // prior transition is settling) raises
460 // ConflictingOperationException — retry through the window.
461 yield* retryThroughConflictingOperation(
462 sitewise
463 .deleteAssetModel({ assetModelId: output.assetModelId })
464 .pipe(
465 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
466 ),
467 );
468 yield* waitForAssetModelState(output.assetModelId, "DELETED");
469 }),
470 };
471 }),
472 );