1import * as memorydb from "@distilled.cloud/aws/memorydb";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream 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 { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import { readMemoryDbTags } from "./internal.ts";
13
14export interface ParameterGroupProps {
15 /**
16 * Name of the parameter group. Must be 1-40 characters. If omitted, a
17 * deterministic physical name is generated. Changing the name replaces the
18 * parameter group.
19 */
20 parameterGroupName?: string;
21 /**
22 * Parameter group family the group belongs to, e.g. `"memorydb_valkey7"`,
23 * `"memorydb_valkey8"`, `"memorydb_redis7"`. Changing the family replaces
24 * the parameter group.
25 */
26 family: string;
27 /**
28 * Human-readable description of the parameter group. The MemoryDB API has
29 * no update for descriptions, so changing it replaces the parameter group.
30 */
31 description?: string;
32 /**
33 * Engine parameter overrides (name → value), e.g.
34 * `{ "maxmemory-policy": "allkeys-lru" }`. Parameters removed from this map
35 * are reset to the engine default.
36 * @default {} (engine defaults)
37 */
38 parameters?: Record<string, string>;
39 /**
40 * User-defined tags for the parameter group.
41 */
42 tags?: Record<string, string>;
43}
44
45export interface ParameterGroup extends Resource<
46 "AWS.MemoryDB.ParameterGroup",
47 ParameterGroupProps,
48 {
49 /** Name of the parameter group. */
50 parameterGroupName: string;
51 /** ARN of the parameter group. */
52 parameterGroupArn: string;
53 /** Parameter group family (e.g. `memorydb_valkey7`). */
54 family: string | undefined;
55 /** Description of the parameter group. */
56 description: string | undefined;
57 /** Tags on the parameter group (user + internal Alchemy tags). */
58 tags: Record<string, string>;
59 },
60 never,
61 Providers
62> {}
63
64/**
65 * A MemoryDB parameter group — a named collection of engine parameter
66 * overrides applied to every node of any {@link Cluster} that references it
67 * via `parameterGroupName`.
68 *
69 * Parameter groups are free and provision instantly. Parameters not listed
70 * keep their engine defaults; removing a parameter from `parameters` resets
71 * it to the default.
72 * ### Creating a Parameter Group
73 * **Example:** Parameter Group with an Eviction Policy
74 * ```typescript
75 * const params = yield* ParameterGroup("CacheParams", {
76 * family: "memorydb_valkey7",
77 * description: "LRU eviction for the session cache",
78 * parameters: { "maxmemory-policy": "allkeys-lru" },
79 * });
80 * const cluster = yield* Cluster("Cache", {
81 * aclName: acl.aclName,
82 * parameterGroupName: params.parameterGroupName,
83 * });
84 * ```
85 *
86 * @resource
87 */
88export const ParameterGroup = Resource<ParameterGroup>(
89 "AWS.MemoryDB.ParameterGroup",
90);
91
92export const ParameterGroupProvider = () =>
93 Provider.effect(
94 ParameterGroup,
95 Effect.gen(function* () {
96 const toName = (id: string, props: Partial<ParameterGroupProps>) =>
97 props.parameterGroupName
98 ? Effect.succeed(props.parameterGroupName)
99 : createPhysicalName({ id, maxLength: 40, lowercase: true });
100
101 const readGroup = Effect.fn(function* (name: string) {
102 const response = yield* memorydb
103 .describeParameterGroups({ ParameterGroupName: name })
104 .pipe(
105 Effect.catchTag("ParameterGroupNotFoundFault", () =>
106 Effect.succeed(undefined),
107 ),
108 );
109 return response?.ParameterGroups?.[0];
110 });
111
112 // Observed engine parameters (name → value), paginated.
113 const readParameters = Effect.fn(function* (name: string) {
114 const parameters = new Map<string, string>();
115 yield* memorydb.describeParameters
116 .pages({ ParameterGroupName: name })
117 .pipe(
118 Stream.runForEach((page) =>
119 Effect.sync(() => {
120 for (const parameter of page.Parameters ?? []) {
121 if (parameter.Name !== undefined) {
122 parameters.set(parameter.Name, parameter.Value ?? "");
123 }
124 }
125 }),
126 ),
127 );
128 return parameters;
129 });
130
131 const toAttrs = Effect.fn(function* (group: memorydb.ParameterGroup) {
132 if (!group.Name || !group.ARN) {
133 return yield* Effect.fail(
134 new Error(`Parameter group '${group.Name}' is missing its ARN`),
135 );
136 }
137 return {
138 parameterGroupName: group.Name,
139 parameterGroupArn: group.ARN,
140 family: group.Family,
141 description: group.Description,
142 tags: yield* readMemoryDbTags(group.ARN),
143 };
144 });
145
146 return {
147 stables: ["parameterGroupName", "parameterGroupArn"],
148
149 diff: Effect.fn(function* ({ id, olds, news }) {
150 if (!isResolved(news)) return undefined;
151 const n = news ?? { family: "" };
152 const o = olds ?? { family: "" };
153 if ((yield* toName(id, o)) !== (yield* toName(id, n))) {
154 return { action: "replace" } as const;
155 }
156 if (n.family !== o.family) {
157 return { action: "replace" } as const;
158 }
159 // Descriptions have no update API.
160 if ((n.description ?? undefined) !== (o.description ?? undefined)) {
161 return { action: "replace" } as const;
162 }
163 }),
164
165 read: Effect.fn(function* ({ id, olds, output }) {
166 const name =
167 output?.parameterGroupName ??
168 (yield* toName(id, olds ?? { family: "" }));
169 const group = yield* readGroup(name);
170 if (!group?.ARN) return undefined;
171 const attrs = yield* toAttrs(group);
172 return (yield* hasAlchemyTags(id, attrs.tags))
173 ? attrs
174 : Unowned(attrs);
175 }),
176
177 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
178 const props = news!;
179 const name = output?.parameterGroupName ?? (yield* toName(id, props));
180 const internalTags = yield* createInternalTags(id);
181 const desiredTags = { ...internalTags, ...props.tags };
182 const desired = props.parameters ?? {};
183
184 // 1. Observe — cloud state is authoritative.
185 let observed = yield* readGroup(name);
186
187 // 2. Ensure — create if missing; tolerate AlreadyExists as a race.
188 if (observed === undefined) {
189 yield* memorydb
190 .createParameterGroup({
191 ParameterGroupName: name,
192 Family: props.family,
193 Description: props.description,
194 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
195 Key,
196 Value,
197 })),
198 })
199 .pipe(
200 Effect.catchTag(
201 "ParameterGroupAlreadyExistsFault",
202 () => Effect.void,
203 ),
204 );
205 observed = yield* readGroup(name);
206 }
207 if (observed === undefined) {
208 return yield* Effect.fail(
209 new Error(`Parameter group '${name}' not found after create`),
210 );
211 }
212
213 // 3. Sync parameters — diff desired values against OBSERVED cloud
214 // values and apply only the delta. Parameters dropped from the
215 // desired map (present in olds) are reset to the engine default.
216 const observedParams = yield* readParameters(name);
217 const changed = Object.entries(desired).filter(
218 ([key, value]) => observedParams.get(key) !== value,
219 );
220 if (changed.length > 0) {
221 yield* memorydb.updateParameterGroup({
222 ParameterGroupName: name,
223 ParameterNameValues: changed.map(
224 ([ParameterName, ParameterValue]) => ({
225 ParameterName,
226 ParameterValue,
227 }),
228 ),
229 });
230 }
231 const removed = Object.keys(olds?.parameters ?? {}).filter(
232 (key) => !(key in desired),
233 );
234 if (removed.length > 0) {
235 yield* memorydb
236 .resetParameterGroup({
237 ParameterGroupName: name,
238 ParameterNames: removed,
239 })
240 .pipe(
241 // A parameter already at its default resets to a no-op; a
242 // group busy applying the previous update settles quickly.
243 Effect.retry({
244 while: (e): boolean =>
245 e._tag === "InvalidParameterGroupStateFault",
246 schedule: Schedule.max([
247 Schedule.fixed("5 seconds"),
248 Schedule.recurs(8),
249 ]),
250 }),
251 );
252 }
253
254 // 3b. Sync tags — diff against OBSERVED cloud tags.
255 const arn = observed.ARN;
256 if (arn) {
257 const observedTags = yield* readMemoryDbTags(arn);
258 const { removed: removedTags, upsert } = diffTags(
259 observedTags,
260 desiredTags,
261 );
262 if (upsert.length > 0) {
263 yield* memorydb.tagResource({ ResourceArn: arn, Tags: upsert });
264 }
265 if (removedTags.length > 0) {
266 yield* memorydb.untagResource({
267 ResourceArn: arn,
268 TagKeys: removedTags,
269 });
270 }
271 }
272
273 yield* session.note(name);
274 return yield* toAttrs(observed);
275 }),
276
277 delete: Effect.fn(function* ({ output }) {
278 // A parameter group still referenced by a cluster (or mid-update)
279 // rejects deletion with InvalidParameterGroupStateFault — retry
280 // bounded. NotFound is success (idempotent delete).
281 yield* memorydb
282 .deleteParameterGroup({
283 ParameterGroupName: output.parameterGroupName,
284 })
285 .pipe(
286 Effect.catchTag("ParameterGroupNotFoundFault", () => Effect.void),
287 Effect.retry({
288 while: (e): boolean =>
289 e._tag === "InvalidParameterGroupStateFault",
290 schedule: Schedule.max([
291 Schedule.fixed("5 seconds"),
292 Schedule.recurs(12),
293 ]),
294 }),
295 );
296 }),
297
298 list: () =>
299 memorydb.describeParameterGroups.pages({}).pipe(
300 Stream.runCollect,
301 Effect.map((chunk) =>
302 Array.from(chunk).flatMap((page) =>
303 (page.ParameterGroups ?? []).filter(
304 (group) =>
305 group.Name !== undefined &&
306 group.ARN !== undefined &&
307 // The engine-default groups (default.memorydb-*) are
308 // AWS-owned and cannot be deleted.
309 !group.Name.startsWith("default."),
310 ),
311 ),
312 ),
313 Effect.flatMap(
314 Effect.forEach((group) => toAttrs(group), { concurrency: 4 }),
315 ),
316 ),
317 };
318 }),
319 );