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 DBInstanceProps {
13 /**
14 * Instance identifier. If omitted, Alchemy generates one.
15 */
16 dbInstanceIdentifier?: string;
17 /**
18 * Neptune cluster the instance belongs to. Required — every Neptune
19 * instance is a cluster member. Immutable — forces replacement.
20 */
21 dbClusterIdentifier: string;
22 /**
23 * Instance class such as `db.r6g.large`, `db.t4g.medium`, or
24 * `db.serverless` (requires the cluster to have a
25 * `serverlessV2ScalingConfiguration`). In-place modify.
26 */
27 dbInstanceClass: string;
28 /**
29 * Database engine. Neptune only supports `neptune`.
30 * Changing the engine forces replacement.
31 * @default "neptune"
32 */
33 engine?: string;
34 /**
35 * Availability zone. Immutable — forces replacement.
36 */
37 availabilityZone?: string;
38 /**
39 * Weekly maintenance window, e.g. `Mon:00:00-Mon:03:00`. In-place modify.
40 */
41 preferredMaintenanceWindow?: string;
42 /**
43 * Auto minor version upgrades. In-place modify.
44 */
45 autoMinorVersionUpgrade?: boolean;
46 /**
47 * Promotion tier inside the cluster (0-15). In-place modify.
48 */
49 promotionTier?: number;
50 /**
51 * DB (instance-level) parameter group name. In-place modify.
52 */
53 dbParameterGroupName?: string;
54 /**
55 * User-defined tags.
56 */
57 tags?: Record<string, string>;
58}
59
60export interface DBInstance extends Resource<
61 "AWS.Neptune.DBInstance",
62 DBInstanceProps,
63 {
64 /** Identifier of the instance. */
65 dbInstanceIdentifier: string;
66 /** ARN of the instance. */
67 dbInstanceArn: string;
68 /** Identifier of the cluster the instance belongs to. */
69 dbClusterIdentifier: string | undefined;
70 /** Instance endpoint host name. */
71 endpointAddress: string | undefined;
72 /** Port the instance listens on. */
73 endpointPort: number | undefined;
74 /** Compute class of the instance (e.g. `db.r5.large`, `db.serverless`). */
75 dbInstanceClass: string | undefined;
76 /** Database engine (`neptune`). */
77 engine: string | undefined;
78 /** Running engine version. */
79 engineVersion: string | undefined;
80 /** Current lifecycle status (e.g. `creating`, `available`). */
81 status: string | undefined;
82 /** Failover promotion priority of the instance. */
83 promotionTier: number | undefined;
84 /** Name of the subnet group the instance is placed in. */
85 dbSubnetGroupName: string | undefined;
86 /** Availability Zone the instance runs in. */
87 availabilityZone: string | undefined;
88 /** Weekly window during which maintenance may occur. */
89 preferredMaintenanceWindow: string | undefined;
90 /** Whether storage is encrypted at rest. */
91 storageEncrypted: boolean | undefined;
92 /** KMS key encrypting the instance's storage. */
93 kmsKeyId: string | undefined;
94 /** Immutable, region-unique identifier of the instance. */
95 dbiResourceId: string | undefined;
96 /** Whether minor engine upgrades are applied automatically. */
97 autoMinorVersionUpgrade: boolean | undefined;
98 /** Tags on the instance (user + internal Alchemy tags). */
99 tags: Record<string, string>;
100 },
101 never,
102 Providers
103> {}
104
105/**
106 * An Amazon Neptune instance — a compute member of a Neptune
107 * {@link DBCluster}. Storage, backup, and endpoints are managed at the
108 * cluster level; the instance contributes CPU/RAM and can serve as a writer
109 * or reader. Provisioning takes several minutes.
110 *
111 * Mutable fields (`dbInstanceClass`, `promotionTier`, maintenance window)
112 * are reconciled in place; immutable fields (`engine`,
113 * `dbClusterIdentifier`, `availabilityZone`) force a replacement.
114 * ### Adding an Instance
115 * **Example:** A Neptune writer instance
116 * ```typescript
117 * const writer = yield* DBInstance("Writer", {
118 * dbClusterIdentifier: cluster.dbClusterIdentifier,
119 * dbInstanceClass: "db.t4g.medium",
120 * });
121 * ```
122 *
123 * **Example:** A serverless instance
124 * ```typescript
125 * const writer = yield* DBInstance("Writer", {
126 * dbClusterIdentifier: cluster.dbClusterIdentifier,
127 * dbInstanceClass: "db.serverless",
128 * });
129 * ```
130 *
131 * @resource
132 */
133export const DBInstance = Resource<DBInstance>("AWS.Neptune.DBInstance");
134
135const toTagRecord = (
136 tags: Array<{ Key?: string; Value?: string }> | undefined,
137): Record<string, string> =>
138 Object.fromEntries(
139 (tags ?? [])
140 .filter(
141 (tag): tag is { Key: string; Value: string } =>
142 typeof tag.Key === "string" && typeof tag.Value === "string",
143 )
144 .map((tag) => [tag.Key, tag.Value]),
145 );
146
147const toAttrs = ({
148 instance,
149 tags,
150}: {
151 instance: neptune.DBInstance;
152 tags: Record<string, string>;
153}): DBInstance["Attributes"] => ({
154 dbInstanceIdentifier: instance.DBInstanceIdentifier ?? "",
155 dbInstanceArn: instance.DBInstanceArn ?? "",
156 dbClusterIdentifier: instance.DBClusterIdentifier,
157 endpointAddress: instance.Endpoint?.Address,
158 endpointPort: instance.Endpoint?.Port,
159 dbInstanceClass: instance.DBInstanceClass,
160 engine: instance.Engine,
161 engineVersion: instance.EngineVersion,
162 status: instance.DBInstanceStatus,
163 promotionTier: instance.PromotionTier,
164 dbSubnetGroupName: instance.DBSubnetGroup?.DBSubnetGroupName,
165 availabilityZone: instance.AvailabilityZone,
166 preferredMaintenanceWindow: instance.PreferredMaintenanceWindow,
167 storageEncrypted: instance.StorageEncrypted,
168 kmsKeyId: instance.KmsKeyId,
169 dbiResourceId: instance.DbiResourceId,
170 autoMinorVersionUpgrade: instance.AutoMinorVersionUpgrade,
171 tags,
172});
173
174export const DBInstanceProvider = () =>
175 Provider.effect(
176 DBInstance,
177 Effect.gen(function* () {
178 const toIdentifier = (id: string, props: DBInstanceProps) =>
179 props.dbInstanceIdentifier
180 ? Effect.succeed(props.dbInstanceIdentifier)
181 : createPhysicalName({ id, maxLength: 63 });
182
183 const readInstance = Effect.fn(function* (instanceId: string) {
184 const response = yield* neptune
185 .describeDBInstances({
186 DBInstanceIdentifier: instanceId,
187 })
188 .pipe(
189 Effect.catchTag("DBInstanceNotFoundFault", () =>
190 Effect.succeed(undefined),
191 ),
192 );
193 return response?.DBInstances?.[0];
194 });
195
196 const readTags = Effect.fn(function* (arn: string | undefined) {
197 if (!arn) return {} as Record<string, string>;
198 const response = yield* neptune
199 .listTagsForResource({ ResourceName: arn })
200 .pipe(
201 Effect.catchTag("DBInstanceNotFoundFault", () =>
202 Effect.succeed(undefined),
203 ),
204 );
205 return toTagRecord(response?.TagList);
206 });
207
208 // Bounded readiness wait. Gate on `DBInstanceStatus === "available"` so a
209 // follow-on `modifyDBInstance` doesn't hit `InvalidDBInstanceStateFault`.
210 // Budgets ~10 min (60 * 10s) for slow provisioning.
211 const waitForInstance = Effect.fn(function* (instanceId: string) {
212 const readinessPolicy = Schedule.max([
213 Schedule.fixed("10 seconds"),
214 Schedule.recurs(60),
215 ]);
216 return yield* readInstance(instanceId).pipe(
217 Effect.flatMap((instance) => {
218 if (!instance?.DBInstanceArn) {
219 return Effect.fail(
220 new Error(`DB instance '${instanceId}' not found`),
221 );
222 }
223 const status = instance.DBInstanceStatus;
224 if (
225 status !== "available" &&
226 status !== "incompatible-parameters" &&
227 status !== "incompatible-restore"
228 ) {
229 return Effect.fail(
230 new Error(
231 `DB instance '${instanceId}' not available (status: ${status})`,
232 ),
233 );
234 }
235 return Effect.succeed(instance);
236 }),
237 Effect.retry({ schedule: readinessPolicy }),
238 );
239 });
240
241 return {
242 stables: ["dbInstanceArn", "dbInstanceIdentifier"],
243 // AWS account/region collection: the RDS-family control plane serves
244 // instances for every engine, so filter to `engine = neptune`. Tags
245 // are not surfaced inline — emit an empty tag map rather than a
246 // per-item `listTagsForResource` fan-out.
247 list: () =>
248 neptune.describeDBInstances
249 .pages({ Filters: [{ Name: "engine", Values: ["neptune"] }] })
250 .pipe(
251 Stream.runCollect,
252 Effect.map((chunk) =>
253 Array.from(chunk).flatMap((page) =>
254 (page.DBInstances ?? [])
255 .filter(
256 (
257 instance,
258 ): instance is typeof instance & {
259 DBInstanceArn: string;
260 } => instance.DBInstanceArn != null,
261 )
262 .map((instance) => toAttrs({ instance, tags: {} })),
263 ),
264 ),
265 Effect.catchTag("DBInstanceNotFoundFault", () =>
266 Effect.succeed([] as DBInstance["Attributes"][]),
267 ),
268 ),
269 diff: Effect.fn(function* ({ id, olds, news }) {
270 if (!isResolved(news)) return undefined;
271 if (
272 (yield* toIdentifier(id, olds ?? ({} as DBInstanceProps))) !==
273 (yield* toIdentifier(id, news))
274 ) {
275 return { action: "replace" } as const;
276 }
277 // Immutable props — any change forces a fresh instance.
278 if (
279 olds !== undefined &&
280 ((olds.engine ?? "neptune") !== (news.engine ?? "neptune") ||
281 olds.dbClusterIdentifier !== news.dbClusterIdentifier ||
282 olds.availabilityZone !== news.availabilityZone)
283 ) {
284 return { action: "replace" } as const;
285 }
286 }),
287 read: Effect.fn(function* ({ id, olds, output }) {
288 const identifier =
289 output?.dbInstanceIdentifier ??
290 (yield* toIdentifier(
291 id,
292 olds ??
293 ({
294 dbClusterIdentifier: "",
295 dbInstanceClass: "",
296 } as DBInstanceProps),
297 ));
298 const instance = yield* readInstance(identifier);
299 if (!instance?.DBInstanceArn) {
300 return undefined;
301 }
302 const tags = yield* readTags(instance.DBInstanceArn);
303 return toAttrs({ instance, tags });
304 }),
305 reconcile: Effect.fn(function* ({ id, news, output, session }) {
306 const identifier =
307 output?.dbInstanceIdentifier ?? (yield* toIdentifier(id, news));
308 const internalTags = yield* createInternalTags(id);
309 const desiredTags = { ...internalTags, ...news.tags };
310
311 // Observe — fetch live instance state.
312 let observed = yield* readInstance(identifier);
313
314 // Ensure — create if missing. Tolerate
315 // `DBInstanceAlreadyExistsFault` as a race with a peer reconciler.
316 if (!observed?.DBInstanceArn) {
317 yield* neptune
318 .createDBInstance({
319 DBInstanceIdentifier: identifier,
320 DBClusterIdentifier: news.dbClusterIdentifier,
321 DBInstanceClass: news.dbInstanceClass,
322 Engine: news.engine ?? "neptune",
323 AvailabilityZone: news.availabilityZone,
324 PreferredMaintenanceWindow: news.preferredMaintenanceWindow,
325 AutoMinorVersionUpgrade: news.autoMinorVersionUpgrade,
326 PromotionTier: news.promotionTier,
327 DBParameterGroupName: news.dbParameterGroupName,
328 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
329 Key,
330 Value,
331 })),
332 })
333 .pipe(
334 Effect.catchTag(
335 "DBInstanceAlreadyExistsFault",
336 () => Effect.void,
337 ),
338 );
339
340 observed = yield* waitForInstance(identifier);
341 } else {
342 // Wait for the instance to settle before any modify so the call
343 // doesn't hit `InvalidDBInstanceStateFault`.
344 observed = yield* waitForInstance(identifier);
345
346 // syncCoreSettings — single `modifyDBInstance` carrying scalar
347 // in-place fields. Only emit a field when the desired value differs
348 // from the observed cloud state.
349 const core: neptune.ModifyDBInstanceMessage = {
350 DBInstanceIdentifier: identifier,
351 ApplyImmediately: true,
352 };
353 let coreDirty = false;
354 const setIf = <K extends keyof neptune.ModifyDBInstanceMessage>(
355 key: K,
356 desired: neptune.ModifyDBInstanceMessage[K] | undefined,
357 observedValue: unknown,
358 ) => {
359 if (desired !== undefined && desired !== observedValue) {
360 core[key] = desired;
361 coreDirty = true;
362 }
363 };
364 setIf("DBInstanceClass", news.dbInstanceClass, observed.DBInstanceClass); // prettier-ignore
365 setIf("PreferredMaintenanceWindow", news.preferredMaintenanceWindow, observed.PreferredMaintenanceWindow); // prettier-ignore
366 setIf("AutoMinorVersionUpgrade", news.autoMinorVersionUpgrade, observed.AutoMinorVersionUpgrade); // prettier-ignore
367 setIf("PromotionTier", news.promotionTier, observed.PromotionTier);
368 setIf("DBParameterGroupName", news.dbParameterGroupName, observed.DBParameterGroups?.[0]?.DBParameterGroupName); // prettier-ignore
369 if (coreDirty) {
370 yield* neptune.modifyDBInstance(core);
371 observed = yield* waitForInstance(identifier);
372 }
373 }
374
375 const dbInstanceArn = observed.DBInstanceArn ?? "";
376
377 // Sync tags — diff observed cloud tags against desired.
378 const observedTags = yield* readTags(dbInstanceArn);
379 const { removed, upsert } = diffTags(observedTags, desiredTags);
380 if (upsert.length > 0 && dbInstanceArn) {
381 yield* neptune.addTagsToResource({
382 ResourceName: dbInstanceArn,
383 Tags: upsert,
384 });
385 }
386 if (removed.length > 0 && dbInstanceArn) {
387 yield* neptune.removeTagsFromResource({
388 ResourceName: dbInstanceArn,
389 TagKeys: removed,
390 });
391 }
392
393 yield* session.note(dbInstanceArn || identifier);
394 return toAttrs({ instance: observed, tags: desiredTags });
395 }),
396 delete: Effect.fn(function* ({ output }) {
397 yield* neptune
398 .deleteDBInstance({
399 DBInstanceIdentifier: output.dbInstanceIdentifier,
400 SkipFinalSnapshot: true,
401 })
402 .pipe(
403 Effect.catchTag("DBInstanceNotFoundFault", () => Effect.void),
404 );
405 // Block until the instance is fully gone so a dependent cluster or
406 // subnet group is not torn down while Neptune still references it.
407 yield* Effect.repeat(
408 neptune
409 .describeDBInstances({
410 DBInstanceIdentifier: output.dbInstanceIdentifier,
411 })
412 .pipe(
413 Effect.as(true),
414 Effect.catchTag("DBInstanceNotFoundFault", () =>
415 Effect.succeed(false),
416 ),
417 ),
418 {
419 schedule: Schedule.max([
420 Schedule.fixed("15 seconds"),
421 Schedule.recurs(40),
422 ]),
423 until: (exists) => exists === false,
424 },
425 ).pipe(Effect.catch(() => Effect.void));
426 }),
427 };
428 }),
429 );