1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface KeyValueStoreProps {
13 /**
14 * KeyValueStore name. If omitted, a deterministic name is generated.
15 */
16 name?: string;
17 /**
18 * Optional store comment.
19 */
20 comment?: string;
21}
22
23export interface KeyValueStore extends Resource<
24 "AWS.CloudFront.KeyValueStore",
25 KeyValueStoreProps,
26 {
27 /**
28 * KeyValueStore ID.
29 */
30 keyValueStoreId: string;
31 /**
32 * Store ARN.
33 */
34 keyValueStoreArn: string;
35 /**
36 * Store name.
37 */
38 keyValueStoreName: string;
39 /**
40 * Current comment.
41 */
42 comment: string;
43 /**
44 * Current status.
45 */
46 status: string;
47 /**
48 * Last modified time.
49 */
50 lastModifiedTime: Date | undefined;
51 /**
52 * Latest entity tag for update/delete operations.
53 */
54 etag: string | undefined;
55 },
56 never,
57 Providers
58> {}
59
60/**
61 * A CloudFront KeyValueStore for edge metadata.
62 *
63 * KeyValueStores can be associated with CloudFront Functions and are useful for
64 * routing metadata or other small edge-time lookup tables.
65 * ### Creating KeyValueStores
66 * **Example:** Basic Store
67 * ```typescript
68 * const store = yield* KeyValueStore("RouterStore", {
69 * comment: "Route metadata",
70 * });
71 * ```
72 *
73 * @resource
74 */
75export const KeyValueStore = Resource<KeyValueStore>(
76 "AWS.CloudFront.KeyValueStore",
77);
78
79export const KeyValueStoreProvider = () =>
80 Provider.effect(
81 KeyValueStore,
82 Effect.gen(function* () {
83 const getByName = Effect.fn(function* (name: string) {
84 const store = yield* cloudfront.listKeyValueStores.pages({}).pipe(
85 Stream.map((page) => page.KeyValueStoreList?.Items ?? []),
86 Stream.flattenIterable,
87 Stream.filter((item) => item.Name === name),
88 Stream.runHead,
89 Effect.map(Option.getOrUndefined),
90 );
91 if (!store?.Name) {
92 return undefined;
93 }
94 return yield* cloudfront
95 .describeKeyValueStore({
96 Name: store.Name,
97 })
98 .pipe(
99 Effect.catchTag("EntityNotFound", () => Effect.succeed(undefined)),
100 );
101 });
102
103 return {
104 stables: ["keyValueStoreId", "keyValueStoreArn", "keyValueStoreName"],
105 diff: Effect.fn(function* ({ id, olds, news: _news }) {
106 if (!isResolved(_news)) return undefined;
107 const news = _news as typeof olds;
108 if (
109 (yield* createName(id, olds ?? {})) !==
110 (yield* createName(id, news))
111 ) {
112 return { action: "replace" } as const;
113 }
114 }),
115 read: Effect.fn(function* ({ id, olds, output }) {
116 const name =
117 output?.keyValueStoreName ?? (yield* createName(id, olds ?? {}));
118 const current = yield* cloudfront
119 .describeKeyValueStore({
120 Name: name,
121 })
122 .pipe(Effect.catchTag("EntityNotFound", () => getByName(name)));
123 if (!current?.KeyValueStore) {
124 return undefined;
125 }
126 return toAttrs(current.KeyValueStore, current.ETag, name);
127 }),
128 // CloudFront is global; `listKeyValueStores` enumerates every store in
129 // the account. The list summary carries no ETag, so it's undefined here
130 // (matches the `etag: string | undefined` attribute).
131 list: () =>
132 cloudfront.listKeyValueStores.pages({}).pipe(
133 Stream.runCollect,
134 Effect.map((chunk) =>
135 Array.from(chunk).flatMap((page) =>
136 (page.KeyValueStoreList?.Items ?? []).map((store) =>
137 toAttrs(store, undefined, store.Name),
138 ),
139 ),
140 ),
141 ),
142 reconcile: Effect.fn(function* ({ id, news, output, session }) {
143 const name =
144 output?.keyValueStoreName ?? (yield* createName(id, news));
145
146 // Observe — describe the store, falling back to a list lookup
147 // by name. Trust live state, not stale `olds`.
148 const observed = yield* cloudfront
149 .describeKeyValueStore({ Name: name })
150 .pipe(Effect.catchTag("EntityNotFound", () => getByName(name)));
151
152 // Ensure — create the store if it's missing. Tolerate
153 // `EntityAlreadyExists` (race with a peer reconciler).
154 if (!observed?.KeyValueStore) {
155 const created = yield* cloudfront
156 .createKeyValueStore({
157 Name: name,
158 Comment: news.comment,
159 })
160 .pipe(
161 Effect.catchTag("EntityAlreadyExists", () =>
162 getByName(name).pipe(
163 Effect.flatMap((existing) =>
164 existing
165 ? Effect.succeed(existing)
166 : Effect.die(
167 `CloudFront KeyValueStore '${name}' already exists but could not be recovered`,
168 ),
169 ),
170 ),
171 ),
172 );
173 if (!created.KeyValueStore) {
174 return yield* Effect.die(
175 "createKeyValueStore returned no key value store",
176 );
177 }
178 yield* session.note(created.KeyValueStore.Id);
179 return toAttrs(created.KeyValueStore, created.ETag, name);
180 }
181
182 // Sync — patch the observed comment via `updateKeyValueStore`
183 // using the freshly observed ETag.
184 const updated = yield* cloudfront.updateKeyValueStore({
185 Name: observed.KeyValueStore.Name,
186 Comment: news.comment ?? "",
187 IfMatch: observed.ETag!,
188 });
189 if (!updated.KeyValueStore) {
190 return yield* Effect.die(
191 "updateKeyValueStore returned no key value store",
192 );
193 }
194 yield* session.note(updated.KeyValueStore.Id);
195 return toAttrs(
196 updated.KeyValueStore,
197 updated.ETag,
198 observed.KeyValueStore.Name,
199 );
200 }),
201 delete: Effect.fn(
202 function* ({ output }) {
203 const current = yield* cloudfront
204 .describeKeyValueStore({
205 Name: output.keyValueStoreName,
206 })
207 .pipe(
208 Effect.catchTag("EntityNotFound", () =>
209 Effect.succeed(undefined),
210 ),
211 );
212
213 const etag = current?.ETag;
214 if (!etag) {
215 yield* Effect.logInfo(
216 `CloudFront KeyValueStore delete: ${output.keyValueStoreName} already absent`,
217 );
218 return;
219 }
220
221 yield* Effect.logInfo(
222 `CloudFront KeyValueStore delete: deleting ${output.keyValueStoreName} with etag=${etag}`,
223 );
224 yield* cloudfront
225 .deleteKeyValueStore({
226 Name: output.keyValueStoreName,
227 IfMatch: etag,
228 })
229 .pipe(Effect.catchTag("EntityNotFound", () => Effect.void));
230 },
231 Effect.retry({
232 // KV writes can invalidate the control-plane ETag before Describe
233 // catches up. Retry the entire read/delete pair with a fresh ETag.
234 while: (error) => error._tag === "PreconditionFailed",
235 schedule: Schedule.spaced("500 millis"),
236 times: 8,
237 }),
238 ),
239 };
240 }),
241 );
242
243const createName = (id: string, props: KeyValueStoreProps) =>
244 props.name
245 ? Effect.succeed(props.name)
246 : createPhysicalName({
247 id,
248 maxLength: 64,
249 lowercase: true,
250 });
251
252const toAttrs = (
253 store: cloudfront.KeyValueStore,
254 etag: string | undefined,
255 fallbackName: string,
256): KeyValueStore["Attributes"] => ({
257 keyValueStoreId: store.Id,
258 keyValueStoreArn: store.ARN,
259 keyValueStoreName: store.Name || fallbackName,
260 comment: store.Comment ?? "",
261 status: store.Status ?? "UNKNOWN",
262 lastModifiedTime: store.LastModifiedTime,
263 etag,
264});