1import * as neptune from "@distilled.cloud/aws/neptune";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface DBParameterGroupProps {
13 /**
14 * Name of the DB (instance-level) parameter group. If omitted, a
15 * deterministic name is generated. Changing it forces replacement.
16 */
17 dbParameterGroupName?: string;
18 /**
19 * DB parameter group family, e.g. `neptune1.4`, `neptune1.3`.
20 * Immutable — forces replacement.
21 */
22 family: string;
23 /**
24 * Description for the parameter group.
25 * @default "Managed by Alchemy"
26 */
27 description?: string;
28 /**
29 * Instance parameter overrides, e.g.
30 * `{ neptune_query_timeout: "120000" }`. Parameters removed from this map
31 * are reset to their engine defaults. Static parameters are applied with
32 * `pending-reboot`, dynamic parameters with `immediate`.
33 */
34 parameters?: Record<string, string>;
35 /**
36 * User-defined tags for the parameter group.
37 */
38 tags?: Record<string, string>;
39}
40
41export interface DBParameterGroup extends Resource<
42 "AWS.Neptune.DBParameterGroup",
43 DBParameterGroupProps,
44 {
45 /** Name of the parameter group. */
46 dbParameterGroupName: string;
47 /** ARN of the parameter group. */
48 dbParameterGroupArn: string | undefined;
49 /** Parameter group family (e.g. `neptune1.4`). */
50 family: string;
51 /** Description of the parameter group. */
52 description: string | undefined;
53 /** Non-default parameter values applied to the group. */
54 parameters: Record<string, string>;
55 /** Tags on the parameter group (user + internal Alchemy tags). */
56 tags: Record<string, string>;
57 },
58 never,
59 Providers
60> {}
61
62/**
63 * An Amazon Neptune DB (instance-level) parameter group — a named set of
64 * engine configuration parameters applied to individual Neptune
65 * {@link DBInstance}s via `dbParameterGroupName` (cluster-wide settings live
66 * in a {@link DBClusterParameterGroup} instead).
67 * ### Creating a Parameter Group
68 * **Example:** Parameter group with a custom query timeout
69 * ```typescript
70 * const params = yield* DBParameterGroup("InstanceParams", {
71 * family: "neptune1.4",
72 * parameters: {
73 * neptune_query_timeout: "120000",
74 * },
75 * });
76 * ```
77 *
78 * ### Attaching to an Instance
79 * **Example:** Instance using the parameter group
80 * ```typescript
81 * const writer = yield* DBInstance("Writer", {
82 * dbClusterIdentifier: cluster.dbClusterIdentifier,
83 * dbInstanceClass: "db.serverless",
84 * dbParameterGroupName: params.dbParameterGroupName,
85 * });
86 * ```
87 *
88 * @resource
89 */
90export const DBParameterGroup = Resource<DBParameterGroup>(
91 "AWS.Neptune.DBParameterGroup",
92);
93
94const toTagRecord = (
95 tags: Array<{ Key?: string; Value?: string }> | undefined,
96): Record<string, string> =>
97 Object.fromEntries(
98 (tags ?? [])
99 .filter(
100 (tag): tag is { Key: string; Value: string } =>
101 typeof tag.Key === "string" && typeof tag.Value === "string",
102 )
103 .map((tag) => [tag.Key, tag.Value]),
104 );
105
106/**
107 * A parameter modify/reset issued immediately after another change fails with
108 * `InvalidDBParameterGroupStateFault` ("has pending changes") until the prior
109 * change settles — retry it on a short bounded schedule.
110 */
111const retryWhileParameterGroupBusy = <A, E extends { _tag: string }, R>(
112 self: Effect.Effect<A, E, R>,
113): Effect.Effect<A, E, R> =>
114 Effect.retry(self, {
115 while: (e) => e._tag === "InvalidDBParameterGroupStateFault",
116 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(10)]),
117 });
118
119export const DBParameterGroupProvider = () =>
120 Provider.effect(
121 DBParameterGroup,
122 Effect.gen(function* () {
123 const toName = (id: string, props: DBParameterGroupProps) =>
124 props.dbParameterGroupName
125 ? Effect.succeed(props.dbParameterGroupName)
126 : createPhysicalName({ id, maxLength: 255 });
127
128 const readGroup = Effect.fn(function* (groupName: string) {
129 const response = yield* neptune
130 .describeDBParameterGroups({
131 DBParameterGroupName: groupName,
132 })
133 .pipe(
134 Effect.catchTag("DBParameterGroupNotFoundFault", () =>
135 Effect.succeed(undefined),
136 ),
137 );
138 return response?.DBParameterGroups?.[0];
139 });
140
141 // All parameters (defaults + overrides) with their current values and
142 // apply types — the observed baseline for the parameter sync.
143 const readParameters = Effect.fn(function* (groupName: string) {
144 return yield* neptune.describeDBParameters
145 .pages({ DBParameterGroupName: groupName })
146 .pipe(
147 Stream.runCollect,
148 Effect.map((chunk) =>
149 Array.from(chunk).flatMap((page) => page.Parameters ?? []),
150 ),
151 );
152 });
153
154 // The subset of parameters the user has overridden (Source `user`) —
155 // used to compute resets when a prop entry is removed.
156 const readUserParameters = Effect.fn(function* (groupName: string) {
157 return yield* neptune.describeDBParameters
158 .pages({ DBParameterGroupName: groupName, Source: "user" })
159 .pipe(
160 Stream.runCollect,
161 Effect.map((chunk) =>
162 Array.from(chunk).flatMap((page) => page.Parameters ?? []),
163 ),
164 );
165 });
166
167 const readTags = Effect.fn(function* (arn: string | undefined) {
168 if (!arn) return {} as Record<string, string>;
169 const response = yield* neptune.listTagsForResource({
170 ResourceName: arn,
171 });
172 return toTagRecord(response?.TagList);
173 });
174
175 const toUserParameterRecord = (
176 parameters: neptune.Parameter[],
177 ): Record<string, string> =>
178 Object.fromEntries(
179 parameters.flatMap((p) =>
180 p.ParameterName !== undefined && p.ParameterValue !== undefined
181 ? [[p.ParameterName, p.ParameterValue]]
182 : [],
183 ),
184 );
185
186 return {
187 stables: ["dbParameterGroupArn", "dbParameterGroupName", "family"],
188 diff: Effect.fn(function* ({ id, olds, news }) {
189 if (!isResolved(news)) return undefined;
190 if (
191 (yield* toName(id, olds ?? { family: "" })) !==
192 (yield* toName(id, news))
193 ) {
194 return { action: "replace" } as const;
195 }
196 // Family is immutable — any change forces a fresh group.
197 if (olds !== undefined && olds.family !== news.family) {
198 return { action: "replace" } as const;
199 }
200 }),
201 read: Effect.fn(function* ({ id, olds, output }) {
202 const name =
203 output?.dbParameterGroupName ??
204 (yield* toName(
205 id,
206 olds ?? ({ family: "" } as DBParameterGroupProps),
207 ));
208 const group = yield* readGroup(name);
209 if (!group?.DBParameterGroupName) {
210 return undefined;
211 }
212 const userParameters = yield* readUserParameters(name);
213 const tags = yield* readTags(group.DBParameterGroupArn);
214 return {
215 dbParameterGroupName: group.DBParameterGroupName,
216 dbParameterGroupArn: group.DBParameterGroupArn,
217 family: group.DBParameterGroupFamily ?? "",
218 description: group.Description,
219 parameters: toUserParameterRecord(userParameters),
220 tags,
221 };
222 }),
223 reconcile: Effect.fn(function* ({ id, news, output, session }) {
224 const name =
225 output?.dbParameterGroupName ?? (yield* toName(id, news));
226 const internalTags = yield* createInternalTags(id);
227 const desiredTags = { ...internalTags, ...news.tags };
228
229 // Observe — fetch live parameter-group state.
230 let observed = yield* readGroup(name);
231
232 // Ensure — create if missing. Tolerate
233 // `DBParameterGroupAlreadyExistsFault` as a race with a peer
234 // reconciler by re-reading.
235 if (!observed?.DBParameterGroupName) {
236 yield* neptune
237 .createDBParameterGroup({
238 DBParameterGroupName: name,
239 DBParameterGroupFamily: news.family,
240 Description: news.description ?? "Managed by Alchemy",
241 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
242 Key,
243 Value,
244 })),
245 })
246 .pipe(
247 Effect.catchTag(
248 "DBParameterGroupAlreadyExistsFault",
249 () => Effect.void,
250 ),
251 );
252 observed = yield* readGroup(name);
253 if (!observed?.DBParameterGroupName) {
254 return yield* Effect.fail(
255 new Error(`Failed to create DB parameter group '${name}'`),
256 );
257 }
258 }
259
260 // Sync parameters — diff observed cloud values against desired.
261 const desiredParameters = news.parameters ?? {};
262 const allParameters = yield* readParameters(name);
263 const byName = new Map(
264 allParameters.map((p) => [p.ParameterName, p]),
265 );
266
267 const toModify: neptune.Parameter[] = Object.entries(
268 desiredParameters,
269 ).flatMap(([ParameterName, ParameterValue]) => {
270 const current = byName.get(ParameterName);
271 if (current?.ParameterValue === ParameterValue) return [];
272 return [
273 {
274 ParameterName,
275 ParameterValue,
276 ApplyMethod:
277 current?.ApplyType === "static"
278 ? "pending-reboot"
279 : "immediate",
280 },
281 ];
282 });
283 if (toModify.length > 0) {
284 // The API caps a single call at 20 parameters.
285 for (let i = 0; i < toModify.length; i += 20) {
286 yield* retryWhileParameterGroupBusy(
287 neptune.modifyDBParameterGroup({
288 DBParameterGroupName: name,
289 Parameters: toModify.slice(i, i + 20),
290 }),
291 );
292 }
293 }
294
295 // Reset user-overridden parameters that were removed from props.
296 const userParameters = yield* readUserParameters(name);
297 const toReset = userParameters.flatMap((p) =>
298 p.ParameterName !== undefined &&
299 !(p.ParameterName in desiredParameters)
300 ? [
301 {
302 ParameterName: p.ParameterName,
303 ApplyMethod:
304 p.ApplyType === "static"
305 ? ("pending-reboot" as const)
306 : ("immediate" as const),
307 },
308 ]
309 : [],
310 );
311 if (toReset.length > 0) {
312 for (let i = 0; i < toReset.length; i += 20) {
313 yield* retryWhileParameterGroupBusy(
314 neptune.resetDBParameterGroup({
315 DBParameterGroupName: name,
316 ResetAllParameters: false,
317 Parameters: toReset.slice(i, i + 20),
318 }),
319 );
320 }
321 }
322
323 const arn = observed.DBParameterGroupArn;
324
325 // Sync tags — diff observed cloud tags against desired.
326 const observedTags = yield* readTags(arn);
327 const { removed, upsert } = diffTags(observedTags, desiredTags);
328 if (upsert.length > 0 && arn) {
329 yield* neptune.addTagsToResource({
330 ResourceName: arn,
331 Tags: upsert,
332 });
333 }
334 if (removed.length > 0 && arn) {
335 yield* neptune.removeTagsFromResource({
336 ResourceName: arn,
337 TagKeys: removed,
338 });
339 }
340
341 yield* session.note(arn ?? name);
342 return {
343 dbParameterGroupName: observed.DBParameterGroupName,
344 dbParameterGroupArn: arn,
345 family: observed.DBParameterGroupFamily ?? news.family,
346 description: observed.Description,
347 parameters: desiredParameters,
348 tags: desiredTags,
349 };
350 }),
351 list: () =>
352 // AWS account/region collection: the RDS-family control plane
353 // serves parameter groups for every engine, so keep only families
354 // beginning with `neptune`. Hydrating per-group parameters/tags
355 // would fan out one call per item — emit empty maps instead.
356 neptune.describeDBParameterGroups.pages({}).pipe(
357 Stream.runCollect,
358 Effect.map((chunk) =>
359 Array.from(chunk).flatMap((page) =>
360 (page.DBParameterGroups ?? []).flatMap((group) =>
361 group.DBParameterGroupName &&
362 group.DBParameterGroupFamily?.startsWith("neptune")
363 ? [
364 {
365 dbParameterGroupName: group.DBParameterGroupName,
366 dbParameterGroupArn: group.DBParameterGroupArn,
367 family: group.DBParameterGroupFamily,
368 description: group.Description,
369 parameters: {} as Record<string, string>,
370 tags: {} as Record<string, string>,
371 },
372 ]
373 : [],
374 ),
375 ),
376 ),
377 ),
378 delete: Effect.fn(function* ({ output }) {
379 yield* neptune
380 .deleteDBParameterGroup({
381 DBParameterGroupName: output.dbParameterGroupName,
382 })
383 .pipe(
384 Effect.catchTag(
385 "DBParameterGroupNotFoundFault",
386 () => Effect.void,
387 ),
388 );
389 }),
390 };
391 }),
392 );