1import * as rds from "@distilled.cloud/aws/rds";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { deepEqual, 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";
11import { createInternalTags, diffTags } from "../../Tags.ts";
12
13export interface DBParameterGroupProps {
14 /**
15 * Name of the parameter group. If omitted, Alchemy generates one.
16 */
17 dbParameterGroupName?: string;
18 /**
19 * Parameter group family, for example `aurora-postgresql16`.
20 */
21 family: string;
22 /**
23 * Human-readable description.
24 */
25 description?: string;
26 /**
27 * Instance parameter overrides, e.g. `{ time_zone: "Australia/Sydney" }`.
28 *
29 * This map is the group's entire user-owned surface: entries are written,
30 * and undeclared user overrides are reset to engine defaults. Omitting the
31 * map is equivalent to `{}`, including on adoption and unchanged-input deploys.
32 *
33 * Values must be in the form RDS reports back (it canonicalises some — a
34 * boolean set as `ON` reads back as `1`). Reconciliation waits for those
35 * reported values and fails if they do not converge within the observation budget.
36 *
37 * Static parameters are applied with `pending-reboot`, dynamic parameters
38 * with `immediate`.
39 */
40 parameters?: Record<string, string>;
41 /**
42 * User-defined tags.
43 */
44 tags?: Record<string, string>;
45}
46
47export interface DBParameterGroup extends Resource<
48 "AWS.RDS.DBParameterGroup",
49 DBParameterGroupProps,
50 {
51 /**
52 * Name of the parameter group.
53 */
54 dbParameterGroupName: string;
55 /**
56 * ARN of the parameter group.
57 */
58 dbParameterGroupArn: string | undefined;
59 /**
60 * Parameter group family (e.g. `aurora-postgresql16`).
61 */
62 family: string;
63 /**
64 * Description of the parameter group.
65 */
66 description: string | undefined;
67 /**
68 * Observed user overrides and explicitly managed engine defaults.
69 * Omitting `parameters` removes all user overrides during reconciliation.
70 * These are group settings; static values still require an instance reboot.
71 */
72 parameters: Record<string, string>;
73 /**
74 * Tags on the parameter group.
75 */
76 tags: Record<string, string>;
77 },
78 never,
79 Providers
80> {}
81
82/**
83 * An RDS DB parameter group — instance-level engine settings, applied to a
84 * `DBInstance` (as opposed to the cluster-wide `DBClusterParameterGroup`).
85 *
86 * Name, family, and description changes force a replacement (RDS has no
87 * modify API for these); parameters and tags update in place.
88 * ### Creating a Parameter Group
89 * **Example:** Parameter Group for Aurora Postgres 16 Instances
90 * ```typescript
91 * const instanceParams = yield* DBParameterGroup("InstanceParams", {
92 * family: "aurora-postgresql16",
93 * description: "Instance-level settings for the app database",
94 * });
95 * ```
96 *
97 * **Example:** Set Engine Parameters
98 * ```typescript
99 * const params = yield* DBParameterGroup("MysqlParams", {
100 * family: "mysql8.4",
101 * parameters: {
102 * time_zone: "Australia/Sydney",
103 * max_connections: "200",
104 * },
105 * });
106 * ```
107 * Dynamic parameters apply immediately, static ones on the next reboot.
108 * Cluster-wide settings belong on `DBClusterParameterGroup` instead — Postgres
109 * `timezone`, for example, is a cluster parameter on Aurora.
110 *
111 * **Example:** Attach to an Instance
112 * ```typescript
113 * const writer = yield* DBInstance("Writer", {
114 * dbClusterIdentifier: cluster.dbClusterIdentifier,
115 * dbInstanceClass: "db.serverless",
116 * engine: "aurora-postgresql",
117 * dbParameterGroupName: instanceParams.dbParameterGroupName,
118 * });
119 * ```
120 *
121 * @resource
122 */
123export const DBParameterGroup = Resource<DBParameterGroup>(
124 "AWS.RDS.DBParameterGroup",
125);
126
127/**
128 * A parameter modify/reset issued immediately after another change fails with
129 * `InvalidDBParameterGroupStateFault` ("has pending changes") until the prior
130 * change settles — retry it on a short bounded schedule.
131 */
132const retryWhileParameterGroupBusy = <A, E extends { _tag: string }, R>(
133 self: Effect.Effect<A, E, R>,
134): Effect.Effect<A, E, R> =>
135 Effect.retry(self, {
136 while: (e) => e._tag === "InvalidDBParameterGroupStateFault",
137 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(10)]),
138 });
139
140class DBParameterGroupNotSettled extends Data.TaggedError(
141 "DBParameterGroupNotSettled",
142)<{
143 name: string;
144}> {}
145
146export const DBParameterGroupProvider = () =>
147 Provider.effect(
148 DBParameterGroup,
149 Effect.gen(function* () {
150 const toName = (id: string, props: DBParameterGroupProps) =>
151 props.dbParameterGroupName
152 ? Effect.succeed(props.dbParameterGroupName)
153 : createPhysicalName({ id, maxLength: 255 });
154
155 const readGroup = Effect.fn(function* (name: string) {
156 const response = yield* rds
157 .describeDBParameterGroups({
158 DBParameterGroupName: name,
159 })
160 .pipe(
161 Effect.catchTag("DBParameterGroupNotFoundFault", () =>
162 Effect.succeed(undefined),
163 ),
164 );
165 return response?.DBParameterGroups?.[0];
166 });
167
168 const readTags = Effect.fn(function* (arn: string | undefined) {
169 if (!arn) return {};
170 const response = yield* rds.listTagsForResource({ ResourceName: arn });
171 return Object.fromEntries(
172 (response.TagList ?? []).flatMap(({ Key, Value }) =>
173 Key !== undefined && Value !== undefined ? [[Key, Value]] : [],
174 ),
175 );
176 });
177
178 // All parameters (defaults + overrides) with their current values and
179 // apply types — the observed baseline for the parameter sync.
180 const readParameters = Effect.fn(function* (name: string) {
181 return yield* rds.describeDBParameters
182 .pages({ DBParameterGroupName: name })
183 .pipe(
184 Stream.runCollect,
185 Effect.map((chunk) =>
186 Array.from(chunk).flatMap((page) => page.Parameters ?? []),
187 ),
188 );
189 });
190
191 // The subset the user has overridden (Source `user`) — used to compute
192 // resets when a prop entry is removed.
193 const readUserParameters = Effect.fn(function* (name: string) {
194 return yield* rds.describeDBParameters
195 .pages({ DBParameterGroupName: name, Source: "user" })
196 .pipe(
197 Stream.runCollect,
198 Effect.map((chunk) =>
199 Array.from(chunk).flatMap((page) => page.Parameters ?? []),
200 ),
201 );
202 });
203
204 const toManagedParameterRecord = (
205 parameters: rds.Parameter[],
206 desired: Record<string, string> = {},
207 ): Record<string, string> =>
208 Object.fromEntries(
209 parameters.flatMap((p) =>
210 p.ParameterName !== undefined &&
211 p.ParameterValue !== undefined &&
212 (p.Source === "user" || Object.hasOwn(desired, p.ParameterName))
213 ? [[p.ParameterName, p.ParameterValue]]
214 : [],
215 ),
216 );
217
218 return {
219 stables: ["dbParameterGroupArn", "dbParameterGroupName"],
220 diff: Effect.fn(function* ({ id, olds, news, output }) {
221 if (!isResolved(news)) return undefined;
222 if (
223 (yield* toName(id, olds ?? ({} as DBParameterGroupProps))) !==
224 (yield* toName(id, news))
225 ) {
226 return { action: "replace" } as const;
227 }
228 if (
229 olds?.family !== news.family ||
230 olds?.description !== news.description
231 ) {
232 return { action: "replace" } as const;
233 }
234 if (output !== undefined) {
235 const group = yield* readGroup(output.dbParameterGroupName);
236 if (!group) return { action: "update", stables: [] } as const;
237 const desired = news.parameters ?? {};
238 const parameters = yield* readParameters(
239 output.dbParameterGroupName,
240 ).pipe(
241 Effect.catchTag("DBParameterGroupNotFoundFault", () =>
242 Effect.succeed(undefined),
243 ),
244 );
245 if (parameters === undefined) {
246 return { action: "update", stables: [] } as const;
247 }
248 const observed = toManagedParameterRecord(parameters, desired);
249 const desiredTags = {
250 ...news.tags,
251 ...(yield* createInternalTags(id)),
252 };
253 if (
254 !deepEqual(desired, observed) ||
255 !deepEqual(
256 desiredTags,
257 yield* readTags(group.DBParameterGroupArn),
258 )
259 ) {
260 return { action: "update" } as const;
261 }
262 }
263 }),
264 list: () =>
265 // Collection reads omit per-group tags and parameters; read hydrates them.
266 rds.describeDBParameterGroups.pages({}).pipe(
267 Stream.runCollect,
268 Effect.map((chunk) =>
269 Array.from(chunk).flatMap((page) =>
270 (page.DBParameterGroups ?? [])
271 .filter(
272 (
273 g,
274 ): g is rds.DBParameterGroup & {
275 DBParameterGroupName: string;
276 } =>
277 g.DBParameterGroupName != null &&
278 // AWS-managed `default.*` groups cannot be deleted
279 // (InvalidDBParameterGroupStateFault) — don't enumerate.
280 !g.DBParameterGroupName.startsWith("default."),
281 )
282 .map((g) => ({
283 dbParameterGroupName: g.DBParameterGroupName,
284 dbParameterGroupArn: g.DBParameterGroupArn,
285 family: g.DBParameterGroupFamily ?? "",
286 description: g.Description,
287 // Like tags: a describe per group would be O(groups) calls.
288 parameters: {} as Record<string, string>,
289 tags: {} as Record<string, string>,
290 })),
291 ),
292 ),
293 ),
294 read: Effect.fn(function* ({ id, olds, output }) {
295 const name =
296 output?.dbParameterGroupName ??
297 (yield* toName(
298 id,
299 olds ?? ({ family: "" } as DBParameterGroupProps),
300 ));
301 const group = yield* readGroup(name);
302 if (!group?.DBParameterGroupName) {
303 return undefined;
304 }
305 const parameters = toManagedParameterRecord(
306 yield* readParameters(group.DBParameterGroupName),
307 olds?.parameters,
308 );
309 return {
310 dbParameterGroupName: group.DBParameterGroupName,
311 dbParameterGroupArn: group.DBParameterGroupArn,
312 family: group.DBParameterGroupFamily ?? olds?.family ?? "",
313 description: group.Description,
314 parameters,
315 tags: yield* readTags(group.DBParameterGroupArn),
316 };
317 }),
318 reconcile: Effect.fn(function* ({ id, news, output, session }) {
319 const name =
320 output?.dbParameterGroupName ?? (yield* toName(id, news));
321 const internalTags = yield* createInternalTags(id);
322 const desiredTags = { ...news.tags, ...internalTags };
323
324 // Observe — fetch live parameter-group state.
325 let observed = yield* readGroup(name);
326
327 // Ensure — create if missing. Tolerate
328 // `DBParameterGroupAlreadyExistsFault` as a race with a peer
329 // reconciler by re-reading.
330 if (!observed?.DBParameterGroupName) {
331 yield* rds
332 .createDBParameterGroup({
333 DBParameterGroupName: name,
334 DBParameterGroupFamily: news.family,
335 Description:
336 news.description ?? `Alchemy parameter group ${name}`,
337 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
338 Key,
339 Value,
340 })),
341 })
342 .pipe(
343 Effect.catchTag(
344 "DBParameterGroupAlreadyExistsFault",
345 () => Effect.void,
346 ),
347 );
348 observed = yield* readGroup(name);
349 if (!observed?.DBParameterGroupName) {
350 return yield* Effect.fail(
351 new Error(`Failed to create DB parameter group '${name}'`),
352 );
353 }
354 }
355
356 // Omission owns an empty override map, including on adoption.
357 const desiredParameters = news.parameters ?? {};
358 {
359 const byName = new Map(
360 (yield* readParameters(name)).flatMap((p) =>
361 p.ParameterName !== undefined
362 ? [[p.ParameterName, p] as const]
363 : [],
364 ),
365 );
366
367 const toModify: rds.Parameter[] = Object.entries(
368 desiredParameters,
369 ).flatMap(([ParameterName, ParameterValue]) => {
370 const current = byName.get(ParameterName);
371 if (current?.ParameterValue === ParameterValue) return [];
372 return [
373 {
374 ParameterName,
375 ParameterValue,
376 ApplyMethod:
377 current?.ApplyType === "static"
378 ? "pending-reboot"
379 : "immediate",
380 },
381 ];
382 });
383 if (toModify.length > 0) {
384 // The API caps a single call at 20 parameters.
385 for (let i = 0; i < toModify.length; i += 20) {
386 yield* retryWhileParameterGroupBusy(
387 rds.modifyDBParameterGroup({
388 DBParameterGroupName: name,
389 Parameters: toModify.slice(i, i + 20),
390 }),
391 );
392 }
393 }
394
395 // Reset user-overridden parameters that were removed from props.
396 const toReset = (yield* readUserParameters(name)).flatMap((p) =>
397 p.ParameterName !== undefined &&
398 !(p.ParameterName in desiredParameters)
399 ? [
400 {
401 ParameterName: p.ParameterName,
402 ApplyMethod:
403 p.ApplyType === "static"
404 ? ("pending-reboot" as const)
405 : ("immediate" as const),
406 },
407 ]
408 : [],
409 );
410 if (toReset.length > 0) {
411 for (let i = 0; i < toReset.length; i += 20) {
412 yield* retryWhileParameterGroupBusy(
413 rds.resetDBParameterGroup({
414 DBParameterGroupName: name,
415 ResetAllParameters: false,
416 Parameters: toReset.slice(i, i + 20),
417 }),
418 );
419 }
420 }
421 }
422
423 const dbParameterGroupArn = observed.DBParameterGroupArn;
424
425 const observedTags = yield* readTags(dbParameterGroupArn);
426 const { removed, upsert } = diffTags(observedTags, desiredTags);
427 if (upsert.length > 0 && dbParameterGroupArn) {
428 yield* rds.addTagsToResource({
429 ResourceName: dbParameterGroupArn,
430 Tags: upsert,
431 });
432 }
433 if (removed.length > 0 && dbParameterGroupArn) {
434 yield* rds.removeTagsFromResource({
435 ResourceName: dbParameterGroupArn,
436 TagKeys: removed,
437 });
438 }
439
440 // A successful modify/reset only acknowledges the request. Read back
441 // the managed group values, including absence of undeclared overrides.
442 // Reuse the existing parameter-group consistency budget; retry reads
443 // for a pending observation, never repeat accepted mutations here.
444 const parameters = yield* readParameters(name).pipe(
445 Effect.flatMap((parameters) => {
446 const observed = toManagedParameterRecord(
447 parameters,
448 desiredParameters,
449 );
450 return deepEqual(desiredParameters, observed)
451 ? Effect.succeed(observed)
452 : Effect.fail(new DBParameterGroupNotSettled({ name }));
453 }),
454 Effect.retry({
455 while: (error) => error._tag === "DBParameterGroupNotSettled",
456 schedule: Schedule.spaced("5 seconds"),
457 times: 8,
458 }),
459 );
460 yield* session.note(dbParameterGroupArn ?? name);
461 return {
462 dbParameterGroupName: observed.DBParameterGroupName,
463 dbParameterGroupArn,
464 family: observed.DBParameterGroupFamily ?? news.family,
465 description: observed.Description,
466 parameters,
467 tags: desiredTags,
468 };
469 }),
470 delete: Effect.fn(function* ({ output }) {
471 yield* rds
472 .deleteDBParameterGroup({
473 DBParameterGroupName: output.dbParameterGroupName,
474 })
475 .pipe(
476 Effect.catchTag(
477 "DBParameterGroupNotFoundFault",
478 () => Effect.void,
479 ),
480 );
481 }),
482 };
483 }),
484 );