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 interface PlaceIndexProps {
19 /**
20 * Name of the place index. Immutable — changing it replaces the index.
21 * @default ${app}-${stage}-${id}
22 */
23 indexName?: string;
24 /**
25 * Data provider for geocoding/search. Immutable — changing it replaces the
26 * index. One of `Esri`, `Grab`, or `Here`.
27 */
28 dataSource: string;
29 /**
30 * How results of the operations that use this index will be stored.
31 * `SingleUse` results can't be stored; `Storage` results can be cached.
32 * @default "SingleUse"
33 */
34 intendedUse?: string;
35 /**
36 * Optional description of the place index resource.
37 */
38 description?: string;
39 /**
40 * Tags to associate with the place index.
41 */
42 tags?: Record<string, string>;
43}
44
45export interface PlaceIndex extends Resource<
46 "AWS.Location.PlaceIndex",
47 PlaceIndexProps,
48 {
49 /** Physical name of the place index. */
50 indexName: string;
51 /** ARN of the place index. */
52 indexArn: string;
53 /** Data provider backing the index. */
54 dataSource: string;
55 /** Intended use of the index results. */
56 intendedUse: string | undefined;
57 /** Description of the place index. */
58 description: string | undefined;
59 /** Tags currently associated with the index. */
60 tags: Record<string, string>;
61 },
62 never,
63 Providers
64> {}
65
66/**
67 * An Amazon Location Service place index. A place index geocodes text and
68 * positions against a chosen data provider. The data source is immutable;
69 * the intended use and description can be updated in place.
70 *
71 * ### Creating Place Indexes
72 * **Example:** Basic Place Index
73 * ```typescript
74 * import * as Location from "alchemy/AWS/Location";
75 *
76 * const index = yield* Location.PlaceIndex("Places", {
77 * dataSource: "Esri",
78 * });
79 * ```
80 *
81 * **Example:** Storage-Intent Place Index
82 * ```typescript
83 * const index = yield* Location.PlaceIndex("Geocoder", {
84 * dataSource: "Here",
85 * intendedUse: "Storage",
86 * description: "Cacheable geocoding index",
87 * });
88 * ```
89 *
90 * @resource
91 */
92export const PlaceIndex = Resource<PlaceIndex>("AWS.Location.PlaceIndex");
93
94const createIndexName = (
95 id: string,
96 props: { indexName?: string | undefined },
97) =>
98 Effect.gen(function* () {
99 if (props.indexName) return props.indexName;
100 return yield* createPhysicalName({ id, maxLength: 100 });
101 });
102
103const readIndex = Effect.fn(function* (indexName: string) {
104 const found = yield* location
105 .describePlaceIndex({ IndexName: indexName })
106 .pipe(
107 Effect.catchTag("ResourceNotFoundException", () =>
108 Effect.succeed(undefined),
109 ),
110 );
111 if (!found) return undefined;
112 return {
113 indexName: found.IndexName,
114 indexArn: found.IndexArn,
115 dataSource: found.DataSource,
116 intendedUse: found.DataSourceConfiguration?.IntendedUse,
117 description: found.Description ? found.Description : undefined,
118 tags: toTagRecord(found.Tags),
119 } satisfies PlaceIndex["Attributes"];
120});
121
122export const PlaceIndexProvider = () =>
123 Provider.effect(
124 PlaceIndex,
125 Effect.gen(function* () {
126 return {
127 stables: ["indexName", "indexArn"],
128 list: () =>
129 Effect.gen(function* () {
130 const names = yield* location.listPlaceIndexes.pages({}).pipe(
131 EffectStream.runCollect,
132 Effect.map((chunk) =>
133 Array.from(chunk).flatMap((page) =>
134 (page.Entries ?? []).map((entry) => entry.IndexName),
135 ),
136 ),
137 );
138 const hydrated = yield* Effect.forEach(
139 names,
140 (name) => readIndex(name),
141 { concurrency: 10 },
142 );
143 return hydrated.filter(
144 (attrs): attrs is PlaceIndex["Attributes"] => attrs !== undefined,
145 );
146 }),
147 read: Effect.fn(function* ({ id, olds, output }) {
148 const indexName =
149 output?.indexName ?? (yield* createIndexName(id, olds ?? {}));
150 const state = yield* readIndex(indexName);
151 if (!state) return undefined;
152 return (yield* hasAlchemyTags(id, state.tags as Tags))
153 ? state
154 : Unowned(state);
155 }),
156 diff: Effect.fn(function* ({ id, news, olds }) {
157 if (!isResolved(news)) return;
158 const oldName = yield* createIndexName(id, olds);
159 const newName = yield* createIndexName(id, news);
160 // Name and data source are immutable — either change forces a replace.
161 if (oldName !== newName || olds.dataSource !== news.dataSource) {
162 return { action: "replace" } as const;
163 }
164 }),
165 reconcile: Effect.fn(function* ({ id, news, output, session }) {
166 const indexName =
167 output?.indexName ?? (yield* createIndexName(id, news));
168 const internalTags = yield* createInternalTags(id);
169 const desiredTags = { ...internalTags, ...news.tags };
170
171 let state = yield* readIndex(indexName);
172
173 if (state === undefined) {
174 yield* location
175 .createPlaceIndex({
176 IndexName: indexName,
177 DataSource: news.dataSource,
178 Description: news.description,
179 DataSourceConfiguration: news.intendedUse
180 ? { IntendedUse: news.intendedUse }
181 : undefined,
182 Tags: desiredTags,
183 })
184 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
185 state = yield* readIndex(indexName);
186 if (state === undefined) {
187 return yield* Effect.fail(
188 new Error(`failed to read created place index ${indexName}`),
189 );
190 }
191 }
192
193 if (
194 state.description !== (news.description ?? undefined) ||
195 (news.intendedUse !== undefined &&
196 state.intendedUse !== news.intendedUse)
197 ) {
198 yield* location.updatePlaceIndex({
199 IndexName: indexName,
200 Description: news.description,
201 DataSourceConfiguration: news.intendedUse
202 ? { IntendedUse: news.intendedUse }
203 : undefined,
204 });
205 }
206
207 const { removed, upsert } = diffTags(state.tags, desiredTags);
208 if (removed.length > 0) {
209 yield* location.untagResource({
210 ResourceArn: state.indexArn,
211 TagKeys: removed,
212 });
213 }
214 if (upsert.length > 0) {
215 yield* location.tagResource({
216 ResourceArn: state.indexArn,
217 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
218 });
219 }
220
221 yield* session.note(state.indexArn);
222
223 const final = yield* readIndex(indexName);
224 if (!final) {
225 return yield* Effect.fail(
226 new Error(`failed to read reconciled place index ${indexName}`),
227 );
228 }
229 return final;
230 }),
231 delete: Effect.fn(function* ({ output }) {
232 yield* location
233 .deletePlaceIndex({ IndexName: output.indexName })
234 .pipe(
235 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
236 );
237 }),
238 };
239 }),
240 );