1import * as glue from "@distilled.cloud/aws/glue";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 cleanMap,
14 databaseArn,
15 retryWhileConcurrentModification,
16} from "./internal.ts";
17
18export interface DatabaseProps {
19 /**
20 * Name of the database. Glue lowercases database names, so provide a
21 * lowercase name. If omitted, a unique lowercase name is generated.
22 * Changing the name replaces the database.
23 * @default a generated lowercase physical name
24 */
25 databaseName?: string;
26 /**
27 * A description of the database.
28 */
29 description?: string;
30 /**
31 * The location of the database (for example, an S3 path) used as the
32 * default for tables that do not specify their own location.
33 */
34 locationUri?: string;
35 /**
36 * Free-form key/value properties stored on the database. Alchemy adds its
37 * own `alchemy::*` ownership markers to this map (Glue databases are not
38 * ARN-taggable) — user keys are preserved alongside them.
39 */
40 parameters?: Record<string, string>;
41 /**
42 * The AWS account ID of the Data Catalog the database lives in. Changing it
43 * replaces the database.
44 * @default the caller's account (the default Data Catalog)
45 */
46 catalogId?: string;
47}
48
49export interface Database extends Resource<
50 "AWS.Glue.Database",
51 DatabaseProps,
52 {
53 /** The (lowercase) name of the database. */
54 databaseName: string;
55 /** The ARN of the database. */
56 databaseArn: string;
57 /** The AWS account ID of the Data Catalog the database lives in. */
58 catalogId: string;
59 },
60 {},
61 Providers
62> {}
63
64/**
65 * An AWS Glue Data Catalog database — the top-level container for Glue tables
66 * that Athena, EMR, Redshift Spectrum, and Glue jobs query. Databases are free
67 * and instant to create.
68 * ### Creating Databases
69 * **Example:** Basic Database
70 * ```typescript
71 * import * as AWS from "alchemy/AWS";
72 *
73 * const database = yield* AWS.Glue.Database("Analytics", {
74 * databaseName: "analytics",
75 * });
76 * ```
77 *
78 * **Example:** Database with a Default S3 Location
79 * ```typescript
80 * const database = yield* AWS.Glue.Database("Analytics", {
81 * databaseName: "analytics",
82 * description: "Curated analytics tables",
83 * locationUri: "s3://my-data-lake/analytics/",
84 * parameters: { classification: "parquet" },
85 * });
86 * ```
87 *
88 * @resource
89 */
90export const Database = Resource<Database>("AWS.Glue.Database");
91
92export const DatabaseProvider = () =>
93 Provider.effect(
94 Database,
95 Effect.gen(function* () {
96 const createName = Effect.fn(function* (
97 id: string,
98 props: { databaseName?: string | undefined },
99 ) {
100 return (
101 props.databaseName ??
102 (yield* createPhysicalName({ id, maxLength: 255, lowercase: true }))
103 );
104 });
105
106 const observe = Effect.fn(function* (
107 name: string,
108 catalogId: string | undefined,
109 ) {
110 return yield* glue
111 .getDatabase({ Name: name, CatalogId: catalogId })
112 .pipe(
113 Effect.map((r) => r.Database),
114 Effect.catchTag("EntityNotFoundException", () =>
115 Effect.succeed(undefined),
116 ),
117 );
118 });
119
120 return Database.Provider.of({
121 stables: ["databaseName", "databaseArn", "catalogId"],
122
123 list: () =>
124 Effect.gen(function* () {
125 const { accountId, region } = yield* AWSEnvironment.current;
126 const pages = yield* glue.getDatabases
127 .pages({})
128 .pipe(Stream.runCollect);
129 return Array.from(pages)
130 .flatMap((page) => page.DatabaseList ?? [])
131 .map((db) => ({
132 databaseName: db.Name,
133 databaseArn: databaseArn(
134 region,
135 db.CatalogId ?? accountId,
136 db.Name,
137 ),
138 catalogId: db.CatalogId ?? accountId,
139 }));
140 }),
141
142 read: Effect.fn(function* ({ id, olds, output }) {
143 const { accountId, region } = yield* AWSEnvironment.current;
144 const catalogId = output?.catalogId ?? olds?.catalogId ?? accountId;
145 const name =
146 output?.databaseName ?? (yield* createName(id, olds ?? {}));
147 const db = yield* observe(name, catalogId);
148 if (db === undefined) return undefined;
149 const attrs = {
150 databaseName: db.Name,
151 databaseArn: databaseArn(
152 region,
153 db.CatalogId ?? catalogId,
154 db.Name,
155 ),
156 catalogId: db.CatalogId ?? catalogId,
157 };
158 // Glue databases are not ARN-taggable — ownership markers live in
159 // the database Parameters map.
160 return (yield* hasAlchemyTags(id, cleanMap(db.Parameters)))
161 ? attrs
162 : Unowned(attrs);
163 }),
164
165 diff: Effect.fn(function* ({ id, news, olds }) {
166 if (!isResolved(news)) return undefined;
167 const oldName = yield* createName(id, olds);
168 const newName = yield* createName(id, news);
169 if (oldName !== newName) return { action: "replace" } as const;
170 if ((olds.catalogId ?? undefined) !== (news.catalogId ?? undefined)) {
171 return { action: "replace" } as const;
172 }
173 // description / locationUri / parameters → update
174 }),
175
176 reconcile: Effect.fn(function* ({ id, news, output, session }) {
177 const { accountId, region } = yield* AWSEnvironment.current;
178 const catalogId = news.catalogId ?? output?.catalogId ?? accountId;
179 const name = output?.databaseName ?? (yield* createName(id, news));
180 const internalTags = yield* createInternalTags(id);
181 const desiredParameters = { ...news.parameters, ...internalTags };
182
183 const databaseInput = {
184 Name: name,
185 Description: news.description,
186 LocationUri: news.locationUri,
187 Parameters: desiredParameters,
188 };
189
190 // 1. OBSERVE
191 let db = yield* observe(name, catalogId);
192
193 // 2. ENSURE
194 if (db === undefined) {
195 yield* glue
196 .createDatabase({
197 CatalogId: catalogId,
198 DatabaseInput: databaseInput,
199 })
200 .pipe(
201 Effect.catchTag("AlreadyExistsException", () => Effect.void),
202 retryWhileConcurrentModification,
203 );
204 db = yield* observe(name, catalogId);
205 } else {
206 // 3. SYNC — UpdateDatabase replaces the full DatabaseInput.
207 yield* glue
208 .updateDatabase({
209 CatalogId: catalogId,
210 Name: name,
211 DatabaseInput: databaseInput,
212 })
213 .pipe(retryWhileConcurrentModification);
214 db = yield* observe(name, catalogId);
215 }
216
217 yield* session.note(name);
218 return {
219 databaseName: name,
220 databaseArn: databaseArn(region, db?.CatalogId ?? catalogId, name),
221 catalogId: db?.CatalogId ?? catalogId,
222 };
223 }),
224
225 delete: Effect.fn(function* ({ output }) {
226 yield* glue
227 .deleteDatabase({
228 Name: output.databaseName,
229 CatalogId: output.catalogId,
230 })
231 .pipe(
232 retryWhileConcurrentModification,
233 Effect.catchTag("EntityNotFoundException", () => Effect.void),
234 );
235 }),
236 });
237 }),
238 );