1import * as location from "@distilled.cloud/aws/location";
2import * as Effect from "effect/Effect";
3import * as EffectStream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface TrackerConsumerProps {
10 /**
11 * Name of the tracker whose device position updates are consumed.
12 * Changing it replaces the association.
13 */
14 trackerName: string;
15 /**
16 * ARN of the geofence collection that consumes the tracker's position
17 * updates (evaluating them against its geofences). Changing it replaces
18 * the association.
19 */
20 consumerArn: string;
21}
22
23export interface TrackerConsumer extends Resource<
24 "AWS.Location.TrackerConsumer",
25 TrackerConsumerProps,
26 {
27 /** Name of the tracker. */
28 trackerName: string;
29 /** ARN of the consuming geofence collection. */
30 consumerArn: string;
31 },
32 never,
33 Providers
34> {}
35
36/**
37 * Links an Amazon Location {@link Tracker} to a {@link GeofenceCollection}
38 * so every device position uploaded to the tracker is automatically
39 * evaluated against the collection's geofences, emitting ENTER/EXIT events
40 * (delivered to EventBridge — see `consumeTrackerEvents`).
41 *
42 * The association is existence-only: both properties are immutable and any
43 * change replaces it.
44 *
45 * ### Linking Trackers to Geofence Collections
46 * **Example:** Evaluate Tracker Positions Against a Collection
47 * ```typescript
48 * import * as Location from "alchemy/AWS/Location";
49 *
50 * const tracker = yield* Location.Tracker("Fleet", {
51 * eventBridgeEnabled: true,
52 * });
53 * const fences = yield* Location.GeofenceCollection("Zones", {});
54 *
55 * const link = yield* Location.TrackerConsumer("FleetZones", {
56 * trackerName: tracker.trackerName,
57 * consumerArn: fences.collectionArn,
58 * });
59 * ```
60 *
61 * @resource
62 */
63export const TrackerConsumer = Resource<TrackerConsumer>(
64 "AWS.Location.TrackerConsumer",
65);
66
67export const TrackerConsumerProvider = () =>
68 Provider.effect(
69 TrackerConsumer,
70 Effect.gen(function* () {
71 /** Whether `consumerArn` is currently linked to `trackerName`. */
72 const observeAssociation = Effect.fn(function* (
73 trackerName: string,
74 consumerArn: string,
75 ) {
76 const consumerArns = yield* location.listTrackerConsumers
77 .pages({ TrackerName: trackerName })
78 .pipe(
79 EffectStream.runCollect,
80 Effect.map((chunk) =>
81 Array.from(chunk).flatMap((page) => page.ConsumerArns),
82 ),
83 Effect.catchTag("ResourceNotFoundException", () =>
84 Effect.succeed([] as string[]),
85 ),
86 );
87 return consumerArns.includes(consumerArn);
88 });
89
90 return {
91 stables: ["trackerName", "consumerArn"],
92 list: () =>
93 Effect.gen(function* () {
94 const trackerNames = yield* location.listTrackers.pages({}).pipe(
95 EffectStream.runCollect,
96 Effect.map((chunk) =>
97 Array.from(chunk).flatMap((page) =>
98 (page.Entries ?? []).map((entry) => entry.TrackerName),
99 ),
100 ),
101 );
102 const associations = yield* Effect.forEach(
103 trackerNames,
104 (trackerName) =>
105 location.listTrackerConsumers
106 .pages({ TrackerName: trackerName })
107 .pipe(
108 EffectStream.runCollect,
109 Effect.map((chunk) =>
110 Array.from(chunk).flatMap((page) =>
111 page.ConsumerArns.map((consumerArn) => ({
112 trackerName,
113 consumerArn,
114 })),
115 ),
116 ),
117 // Tracker deleted between the two list calls.
118 Effect.catchTag("ResourceNotFoundException", () =>
119 Effect.succeed([]),
120 ),
121 ),
122 { concurrency: 5 },
123 );
124 return associations.flat();
125 }),
126 read: Effect.fn(function* ({ olds, output }) {
127 const trackerName = output?.trackerName ?? olds?.trackerName;
128 const consumerArn = output?.consumerArn ?? olds?.consumerArn;
129 if (!trackerName || !consumerArn) return undefined;
130 const exists = yield* observeAssociation(trackerName, consumerArn);
131 return exists ? { trackerName, consumerArn } : undefined;
132 }),
133 diff: Effect.fn(function* ({ news, olds }) {
134 if (!isResolved(news)) return;
135 if (
136 olds.trackerName !== news.trackerName ||
137 olds.consumerArn !== news.consumerArn
138 ) {
139 return { action: "replace" } as const;
140 }
141 }),
142 reconcile: Effect.fn(function* ({ news, session }) {
143 const exists = yield* observeAssociation(
144 news.trackerName,
145 news.consumerArn,
146 );
147 if (!exists) {
148 yield* location
149 .associateTrackerConsumer({
150 TrackerName: news.trackerName,
151 ConsumerArn: news.consumerArn,
152 })
153 .pipe(
154 // Racing reconciles: the association already exists.
155 Effect.catchTag("ConflictException", () => Effect.void),
156 );
157 }
158 yield* session.note(`${news.trackerName} -> ${news.consumerArn}`);
159 return {
160 trackerName: news.trackerName,
161 consumerArn: news.consumerArn,
162 };
163 }),
164 delete: Effect.fn(function* ({ output }) {
165 yield* location
166 .disassociateTrackerConsumer({
167 TrackerName: output.trackerName,
168 ConsumerArn: output.consumerArn,
169 })
170 .pipe(
171 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
172 );
173 }),
174 };
175 }),
176 );