1import * as location from "@distilled.cloud/aws/location";
2import * as Effect from "effect/Effect";
3import * as EffectStream 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 diffTags,
12 hasAlchemyTags,
13 type Tags,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16import { toTagRecord } from "./internal.ts";
17
18export type PositionFiltering = "TimeBased" | "DistanceBased" | "AccuracyBased";
19
20export interface TrackerProps {
21 /**
22 * Name of the tracker. Immutable — changing it replaces the tracker.
23 * @default ${app}-${stage}-${id}
24 */
25 trackerName?: string;
26 /**
27 * KMS key ID used to encrypt the tracker's position data. Immutable —
28 * changing it replaces the tracker.
29 */
30 kmsKeyId?: string;
31 /**
32 * The position filtering method applied to device updates.
33 * @default "TimeBased"
34 */
35 positionFiltering?: PositionFiltering;
36 /**
37 * Whether to publish device position updates to EventBridge.
38 * @default false
39 */
40 eventBridgeEnabled?: boolean;
41 /**
42 * Optional description of the tracker resource.
43 */
44 description?: string;
45 /**
46 * Tags to associate with the tracker.
47 */
48 tags?: Record<string, string>;
49}
50
51export interface Tracker extends Resource<
52 "AWS.Location.Tracker",
53 TrackerProps,
54 {
55 /** Physical name of the tracker. */
56 trackerName: string;
57 /** ARN of the tracker. */
58 trackerArn: string;
59 /** KMS key ID backing the tracker, if configured. */
60 kmsKeyId: string | undefined;
61 /** Position filtering method applied to updates. */
62 positionFiltering: string | undefined;
63 /** Whether device updates are published to EventBridge. */
64 eventBridgeEnabled: boolean | undefined;
65 /** Description of the tracker. */
66 description: string | undefined;
67 /** Tags currently associated with the tracker. */
68 tags: Record<string, string>;
69 },
70 never,
71 Providers
72> {}
73
74/**
75 * An Amazon Location Service tracker. A tracker records device positions and
76 * evaluates them against linked geofence collections. The KMS key is
77 * immutable; position filtering, EventBridge publishing, and the description
78 * can be updated in place.
79 *
80 * ### Creating Trackers
81 * **Example:** Basic Tracker
82 * ```typescript
83 * import * as Location from "alchemy/AWS/Location";
84 *
85 * const tracker = yield* Location.Tracker("Devices", {});
86 * ```
87 *
88 * **Example:** Distance-Filtered Tracker with EventBridge
89 * ```typescript
90 * const tracker = yield* Location.Tracker("Fleet", {
91 * positionFiltering: "DistanceBased",
92 * eventBridgeEnabled: true,
93 * });
94 * ```
95 *
96 * @resource
97 */
98export const Tracker = Resource<Tracker>("AWS.Location.Tracker");
99
100const createTrackerName = (
101 id: string,
102 props: { trackerName?: string | undefined },
103) =>
104 Effect.gen(function* () {
105 if (props.trackerName) return props.trackerName;
106 return yield* createPhysicalName({ id, maxLength: 100 });
107 });
108
109const readTracker = Effect.fn(function* (trackerName: string) {
110 const found = yield* location
111 .describeTracker({ TrackerName: trackerName })
112 .pipe(
113 Effect.catchTag("ResourceNotFoundException", () =>
114 Effect.succeed(undefined),
115 ),
116 );
117 if (!found) return undefined;
118 return {
119 trackerName: found.TrackerName,
120 trackerArn: found.TrackerArn,
121 kmsKeyId: found.KmsKeyId,
122 positionFiltering: found.PositionFiltering,
123 eventBridgeEnabled: found.EventBridgeEnabled,
124 description: found.Description ? found.Description : undefined,
125 tags: toTagRecord(found.Tags),
126 } satisfies Tracker["Attributes"];
127});
128
129export const TrackerProvider = () =>
130 Provider.effect(
131 Tracker,
132 Effect.gen(function* () {
133 return {
134 stables: ["trackerName", "trackerArn"],
135 list: () =>
136 Effect.gen(function* () {
137 const names = yield* location.listTrackers.pages({}).pipe(
138 EffectStream.runCollect,
139 Effect.map((chunk) =>
140 Array.from(chunk).flatMap((page) =>
141 (page.Entries ?? []).map((entry) => entry.TrackerName),
142 ),
143 ),
144 );
145 const hydrated = yield* Effect.forEach(
146 names,
147 (name) => readTracker(name),
148 { concurrency: 10 },
149 );
150 return hydrated.filter(
151 (attrs): attrs is Tracker["Attributes"] => attrs !== undefined,
152 );
153 }),
154 read: Effect.fn(function* ({ id, olds, output }) {
155 const trackerName =
156 output?.trackerName ?? (yield* createTrackerName(id, olds ?? {}));
157 const state = yield* readTracker(trackerName);
158 if (!state) return undefined;
159 return (yield* hasAlchemyTags(id, state.tags as Tags))
160 ? state
161 : Unowned(state);
162 }),
163 diff: Effect.fn(function* ({ id, news = {}, olds = {} }) {
164 if (!isResolved(news)) return;
165 const oldName = yield* createTrackerName(id, olds);
166 const newName = yield* createTrackerName(id, news);
167 if (oldName !== newName || olds.kmsKeyId !== news.kmsKeyId) {
168 return { action: "replace" } as const;
169 }
170 }),
171 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
172 const trackerName =
173 output?.trackerName ?? (yield* createTrackerName(id, news));
174 const internalTags = yield* createInternalTags(id);
175 const desiredTags = { ...internalTags, ...news.tags };
176
177 let state = yield* readTracker(trackerName);
178
179 if (state === undefined) {
180 yield* location
181 .createTracker({
182 TrackerName: trackerName,
183 KmsKeyId: news.kmsKeyId,
184 PositionFiltering: news.positionFiltering,
185 EventBridgeEnabled: news.eventBridgeEnabled,
186 Description: news.description,
187 Tags: desiredTags,
188 })
189 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
190 state = yield* readTracker(trackerName);
191 if (state === undefined) {
192 return yield* Effect.fail(
193 new Error(`failed to read created tracker ${trackerName}`),
194 );
195 }
196 }
197
198 const desiredFiltering = news.positionFiltering ?? "TimeBased";
199 const desiredEventBridge = news.eventBridgeEnabled ?? false;
200 if (
201 state.description !== (news.description ?? undefined) ||
202 state.positionFiltering !== desiredFiltering ||
203 (state.eventBridgeEnabled ?? false) !== desiredEventBridge
204 ) {
205 yield* location.updateTracker({
206 TrackerName: trackerName,
207 Description: news.description,
208 PositionFiltering: news.positionFiltering,
209 EventBridgeEnabled: news.eventBridgeEnabled,
210 });
211 }
212
213 const { removed, upsert } = diffTags(state.tags, desiredTags);
214 if (removed.length > 0) {
215 yield* location.untagResource({
216 ResourceArn: state.trackerArn,
217 TagKeys: removed,
218 });
219 }
220 if (upsert.length > 0) {
221 yield* location.tagResource({
222 ResourceArn: state.trackerArn,
223 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
224 });
225 }
226
227 yield* session.note(state.trackerArn);
228
229 const final = yield* readTracker(trackerName);
230 if (!final) {
231 return yield* Effect.fail(
232 new Error(`failed to read reconciled tracker ${trackerName}`),
233 );
234 }
235 return final;
236 }),
237 delete: Effect.fn(function* ({ output }) {
238 yield* location
239 .deleteTracker({ TrackerName: output.trackerName })
240 .pipe(
241 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
242 );
243 }),
244 };
245 }),
246 );