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