1import * as AWS from "@/AWS";
2import { KeyValueStore, KvEntries } from "@/AWS/CloudFront";
3import { extractValue, withKvsRegion } from "@/AWS/CloudFront/common.ts";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as cloudfront from "@distilled.cloud/aws/cloudfront";
7import * as kvs from "@distilled.cloud/aws/cloudfront-keyvaluestore";
8import { describe, expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14describe(
15 "AWS.CloudFront.KvEntries",
16 { tags: ["provider:aws", "provider:aws:cloudfront", "live"] },
17 () => {
18 test.provider(
19 "list returns [] for the non-listable KvEntries resource",
20 () =>
21 Effect.gen(function* () {
22 // KvEntries is keyed entirely by a parent store ARN + namespace and
23 // represents managed data, so it has no enumeration API → list() is [].
24 const provider = yield* Provider.findProvider(KvEntries);
25 expect(yield* provider.list()).toEqual([]);
26 }),
27 );
28
29 test.provider(
30 "create, update, and delete namespaced entries",
31 (stack) =>
32 Effect.gen(function* () {
33 yield* stack.destroy();
34
35 const initialEntries = {
36 "/": "/index.html",
37 "/about": "/about.html",
38 "/old": "/old.html",
39 };
40
41 const deployed = yield* stack.deploy(
42 Effect.gen(function* () {
43 const store = yield* KeyValueStore("EntriesStore", {
44 comment: "kv-entries lifecycle",
45 });
46 const entries = yield* KvEntries("Routes", {
47 store: store.keyValueStoreArn,
48 namespace: "routes",
49 entries: initialEntries,
50 purge: true,
51 });
52 return { store, entries };
53 }),
54 );
55
56 expect(deployed.entries.store).toBe(deployed.store.keyValueStoreArn);
57 expect(deployed.entries.namespace).toBe("routes");
58 expect(deployed.entries.entries).toEqual(initialEntries);
59
60 const described = yield* withKvsRegion(
61 kvs.describeKeyValueStore({
62 KvsARN: deployed.store.keyValueStoreArn,
63 }),
64 );
65 expect(described.KvsARN).toBe(deployed.store.keyValueStoreArn);
66 expect(described.ItemCount).toBeGreaterThanOrEqual(3);
67
68 yield* assertEntries(deployed.store.keyValueStoreArn, "routes", {
69 "/": "/index.html",
70 "/about": "/about.html",
71 "/old": "/old.html",
72 });
73
74 const updatedEntries = {
75 "/": "/index.html",
76 "/about": "/about-v2.html",
77 "/contact": "/contact.html",
78 };
79
80 const updated = yield* stack.deploy(
81 Effect.gen(function* () {
82 const store = yield* KeyValueStore("EntriesStore", {
83 comment: "kv-entries lifecycle",
84 });
85 const entries = yield* KvEntries("Routes", {
86 store: store.keyValueStoreArn,
87 namespace: "routes",
88 entries: updatedEntries,
89 purge: true,
90 });
91 return { store, entries };
92 }),
93 );
94
95 expect(updated.store.keyValueStoreArn).toBe(
96 deployed.store.keyValueStoreArn,
97 );
98 expect(updated.entries.entries).toEqual(updatedEntries);
99
100 yield* assertEntries(updated.store.keyValueStoreArn, "routes", {
101 "/": "/index.html",
102 "/about": "/about-v2.html",
103 "/contact": "/contact.html",
104 });
105
106 yield* stack.destroy();
107 yield* assertKeyValueStoreDeleted(deployed.store.keyValueStoreName);
108 }),
109 { timeout: 120_000 },
110 );
111 },
112);
113
114const listNamespacedEntries = (store: string, namespace: string) =>
115 withKvsRegion(
116 Effect.gen(function* () {
117 const prefix = `${namespace}:`;
118 const out: Record<string, string> = {};
119 let nextToken: string | undefined;
120 do {
121 const resp = yield* kvs.listKeys({
122 KvsARN: store,
123 NextToken: nextToken,
124 });
125 for (const item of resp.Items ?? []) {
126 if (!item.Key.startsWith(prefix)) continue;
127 out[item.Key.slice(prefix.length)] = extractValue(item.Value);
128 }
129 nextToken = resp.NextToken;
130 } while (nextToken);
131 return out;
132 }),
133 );
134
135const getNamespacedEntry = (store: string, namespace: string, key: string) =>
136 withKvsRegion(kvs.getKey({ KvsARN: store, Key: `${namespace}:${key}` })).pipe(
137 Effect.map((resp) => extractValue(resp.Value)),
138 );
139
140const entriesMatch = (
141 got: Record<string, string>,
142 expected: Record<string, string>,
143) => {
144 const gotKeys = Object.keys(got).sort();
145 const expectedKeys = Object.keys(expected).sort();
146 return (
147 gotKeys.length === expectedKeys.length &&
148 expectedKeys.every((key) => got[key] === expected[key])
149 );
150};
151
152const assertEntries = (
153 store: string,
154 namespace: string,
155 expected: Record<string, string>,
156) =>
157 Effect.gen(function* () {
158 const listed = yield* listNamespacedEntries(store, namespace).pipe(
159 Effect.repeat({
160 schedule: Schedule.spaced("200 millis"),
161 until: (got) => entriesMatch(got, expected),
162 times: 15,
163 }),
164 );
165 expect(listed).toEqual(expected);
166
167 for (const [key, value] of Object.entries(expected)) {
168 expect(yield* getNamespacedEntry(store, namespace, key)).toBe(value);
169 }
170 });
171
172const assertKeyValueStoreDeleted = (name: string) =>
173 cloudfront.describeKeyValueStore({ Name: name }).pipe(
174 Effect.flatMap(() => Effect.fail(new Error("KeyValueStoreStillExists"))),
175 Effect.catchTag("EntityNotFound", () => Effect.void),
176 Effect.retry({
177 while: (error) =>
178 error instanceof Error && error.message === "KeyValueStoreStillExists",
179 schedule: Schedule.max([
180 Schedule.fixed("2 seconds"),
181 Schedule.recurs(20),
182 ]),
183 }),
184 );