1import * as location from "@distilled.cloud/aws/location";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as EffectStream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import {
11 createInternalTags,
12 diffTags,
13 hasAlchemyTags,
14 type Tags,
15} from "../../Tags.ts";
16import type { Providers } from "../Providers.ts";
17import { toTagRecord } from "./internal.ts";
18
19/**
20 * Rendering configuration for a map. `style` selects the base map style and is
21 * immutable — changing it replaces the map.
22 */
23export interface MapConfiguration {
24 /**
25 * The map style. Selecting a style determines the map's data provider
26 * (Esri, HERE, or Grab). Immutable — changing the style replaces the map.
27 *
28 * Common values: `VectorEsriNavigation`, `VectorEsriStreets`,
29 * `VectorHereExplore`, `RasterHereExploreSatellite`,
30 * `VectorGrabStandardLight`.
31 */
32 style: string;
33 /**
34 * Political view (an ISO 3166-1 alpha-3 country code) applied to the map's
35 * disputed borders and labels.
36 */
37 politicalView?: string;
38}
39
40export interface MapProps {
41 /**
42 * Name of the map resource. Immutable — changing it replaces the map.
43 * @default ${app}-${stage}-${id}
44 */
45 mapName?: string;
46 /**
47 * Rendering configuration for the map (base style + political view).
48 */
49 configuration: MapConfiguration;
50 /**
51 * Optional description of the map resource.
52 */
53 description?: string;
54 /**
55 * Tags to associate with the map.
56 */
57 tags?: Record<string, string>;
58}
59
60export interface Map extends Resource<
61 "AWS.Location.Map",
62 MapProps,
63 {
64 /** Physical name of the map resource. */
65 mapName: string;
66 /** ARN of the map resource. */
67 mapArn: string;
68 /** The map style selected at creation. */
69 style: string;
70 /** Political view currently applied to the map, if any. */
71 politicalView: string | undefined;
72 /** Data provider backing the map, derived from the style. */
73 dataSource: string;
74 /** Description of the map resource. */
75 description: string | undefined;
76 /** Tags currently associated with the map. */
77 tags: Record<string, string>;
78 },
79 never,
80 Providers
81> {}
82
83/**
84 * An Amazon Location Service map resource. A map exposes vector/raster tiles,
85 * glyphs, and sprites for a chosen base style. The map style is immutable;
86 * the political view and description can be updated in place.
87 *
88 * ### Creating Maps
89 * **Example:** Basic Map
90 * ```typescript
91 * import * as Location from "alchemy/AWS/Location";
92 *
93 * const map = yield* Location.Map("AppMap", {
94 * configuration: { style: "VectorEsriNavigation" },
95 * });
96 * ```
97 *
98 * **Example:** Map with Political View
99 * ```typescript
100 * const map = yield* Location.Map("RegionMap", {
101 * configuration: { style: "VectorHereExplore", politicalView: "IND" },
102 * description: "Map with India political view",
103 * });
104 * ```
105 *
106 * @resource
107 */
108export const Map = Resource<Map>("AWS.Location.Map");
109
110const createMapName = (id: string, props: { mapName?: string | undefined }) =>
111 Effect.gen(function* () {
112 if (props.mapName) return props.mapName;
113 return yield* createPhysicalName({ id, maxLength: 100 });
114 });
115
116const readMap = Effect.fn(function* (mapName: string) {
117 const found = yield* location
118 .describeMap({ MapName: mapName })
119 .pipe(
120 Effect.catchTag("ResourceNotFoundException", () =>
121 Effect.succeed(undefined),
122 ),
123 );
124 if (!found) return undefined;
125 return {
126 mapName: found.MapName,
127 mapArn: found.MapArn,
128 style: found.Configuration.Style,
129 politicalView: Redacted.isRedacted(found.Configuration.PoliticalView)
130 ? Redacted.value(found.Configuration.PoliticalView)
131 : found.Configuration.PoliticalView,
132 dataSource: found.DataSource,
133 description: found.Description ? found.Description : undefined,
134 tags: toTagRecord(found.Tags),
135 } satisfies Map["Attributes"];
136});
137
138export const MapProvider = () =>
139 Provider.effect(
140 Map,
141 Effect.gen(function* () {
142 return {
143 stables: ["mapName", "mapArn"],
144 list: () =>
145 Effect.gen(function* () {
146 const names = yield* location.listMaps.pages({}).pipe(
147 EffectStream.runCollect,
148 Effect.map((chunk) =>
149 Array.from(chunk).flatMap((page) =>
150 (page.Entries ?? []).map((entry) => entry.MapName),
151 ),
152 ),
153 );
154 const hydrated = yield* Effect.forEach(
155 names,
156 (name) => readMap(name),
157 {
158 concurrency: 10,
159 },
160 );
161 return hydrated.filter(
162 (attrs): attrs is Map["Attributes"] => attrs !== undefined,
163 );
164 }),
165 read: Effect.fn(function* ({ id, olds, output }) {
166 const mapName =
167 output?.mapName ?? (yield* createMapName(id, olds ?? {}));
168 const state = yield* readMap(mapName);
169 if (!state) return undefined;
170 return (yield* hasAlchemyTags(id, state.tags as Tags))
171 ? state
172 : Unowned(state);
173 }),
174 diff: Effect.fn(function* ({ id, news, olds }) {
175 if (!isResolved(news)) return;
176 const oldName = yield* createMapName(id, olds);
177 const newName = yield* createMapName(id, news);
178 // Name and base style are immutable — either change forces a replace.
179 if (
180 oldName !== newName ||
181 olds.configuration?.style !== news.configuration?.style
182 ) {
183 return { action: "replace" } as const;
184 }
185 }),
186 reconcile: Effect.fn(function* ({ id, news, output, session }) {
187 const mapName = output?.mapName ?? (yield* createMapName(id, news));
188 const internalTags = yield* createInternalTags(id);
189 const desiredTags = { ...internalTags, ...news.tags };
190
191 // Observe — cloud state is authoritative; output is only a name cache.
192 let state = yield* readMap(mapName);
193
194 // Ensure — create if missing; tolerate a concurrent create.
195 if (state === undefined) {
196 yield* location
197 .createMap({
198 MapName: mapName,
199 Configuration: {
200 Style: news.configuration.style,
201 PoliticalView: news.configuration.politicalView,
202 },
203 Description: news.description,
204 Tags: desiredTags,
205 })
206 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
207 state = yield* readMap(mapName);
208 if (state === undefined) {
209 return yield* Effect.fail(
210 new Error(`failed to read created map ${mapName}`),
211 );
212 }
213 }
214
215 // Sync description + political view.
216 if (
217 state.description !== (news.description ?? undefined) ||
218 state.politicalView !==
219 (news.configuration.politicalView ?? undefined)
220 ) {
221 yield* location.updateMap({
222 MapName: mapName,
223 Description: news.description,
224 ConfigurationUpdate: {
225 PoliticalView: news.configuration.politicalView,
226 },
227 });
228 }
229
230 // Sync tags — diff against observed cloud tags.
231 const { removed, upsert } = diffTags(state.tags, desiredTags);
232 if (removed.length > 0) {
233 yield* location.untagResource({
234 ResourceArn: state.mapArn,
235 TagKeys: removed,
236 });
237 }
238 if (upsert.length > 0) {
239 yield* location.tagResource({
240 ResourceArn: state.mapArn,
241 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
242 });
243 }
244
245 yield* session.note(state.mapArn);
246
247 const final = yield* readMap(mapName);
248 if (!final) {
249 return yield* Effect.fail(
250 new Error(`failed to read reconciled map ${mapName}`),
251 );
252 }
253 return final;
254 }),
255 delete: Effect.fn(function* ({ output }) {
256 yield* location
257 .deleteMap({ MapName: output.mapName })
258 .pipe(
259 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
260 );
261 }),
262 };
263 }),
264 );