| 1 | import * as neptune from "@distilled.cloud/aws/neptune"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { createInternalTags, diffTags } from "../../Tags.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | export 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 | |
| 60 | export 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 | */ |
| 133 | export const DBInstance = Resource<DBInstance>("AWS.Neptune.DBInstance"); |
| 134 | |
| 135 | const 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 | |
| 147 | const 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 | |
| 174 | export 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 | ); |