1import * as databrew from "@distilled.cloud/aws/databrew";
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 databrewArn,
15 fetchObservedTags,
16 retryWhileConflict,
17 retryWhileRoleNotAssumable,
18 syncTags,
19} from "./internal.ts";
20
21/** How DataBrew samples the dataset in the interactive project view. */
22export interface ProjectSample {
23 /** Number of rows in the sample (default 500). */
24 size?: number;
25 /** Sampling strategy. */
26 type: "FIRST_N" | "LAST_N" | "RANDOM" | (string & {});
27}
28
29export interface ProjectProps {
30 /**
31 * Name of the project. If omitted, a unique name is generated. Changing
32 * the name replaces the project.
33 * @default a generated physical name
34 */
35 projectName?: string;
36 /**
37 * The dataset the project explores. Changing it replaces the project.
38 */
39 datasetName: string;
40 /**
41 * The recipe the project edits (its `LATEST_WORKING` version). Changing
42 * it replaces the project.
43 */
44 recipeName: string;
45 /**
46 * The sample shown in the interactive session.
47 * @default 500 rows, FIRST_N
48 */
49 sample?: ProjectSample;
50 /**
51 * The IAM role ARN DataBrew assumes for the project's interactive
52 * sessions (needs read access to the dataset's source).
53 */
54 role: string;
55 /**
56 * Tags to apply to the project. Merged with internal Alchemy tags.
57 */
58 tags?: Record<string, string>;
59}
60
61export interface Project extends Resource<
62 "AWS.DataBrew.Project",
63 ProjectProps,
64 {
65 /** Name of the project. */
66 projectName: string;
67 /** ARN of the project. */
68 projectArn: string;
69 },
70 {},
71 Providers
72> {}
73
74/**
75 * An AWS Glue DataBrew project — the interactive workspace binding a dataset
76 * to a recipe's working version. The project definition is free; costs only
77 * accrue when an interactive session is started in the console.
78 * ### Creating Projects
79 * **Example:** Dataset + Recipe Project
80 * ```typescript
81 * import * as AWS from "alchemy/AWS";
82 *
83 * const project = yield* AWS.DataBrew.Project("Explore", {
84 * datasetName: dataset.datasetName,
85 * recipeName: recipe.recipeName,
86 * role: role.roleArn,
87 * });
88 * ```
89 *
90 * **Example:** Custom Sample
91 * ```typescript
92 * const project = yield* AWS.DataBrew.Project("Explore", {
93 * datasetName: dataset.datasetName,
94 * recipeName: recipe.recipeName,
95 * sample: { type: "RANDOM", size: 250 },
96 * role: role.roleArn,
97 * });
98 * ```
99 *
100 * @resource
101 */
102export const Project = Resource<Project>("AWS.DataBrew.Project");
103
104const buildSample = (sample: ProjectSample | undefined) =>
105 sample ? { Size: sample.size, Type: sample.type } : undefined;
106
107export const ProjectProvider = () =>
108 Provider.effect(
109 Project,
110 Effect.gen(function* () {
111 const createName = Effect.fn(function* (
112 id: string,
113 props: { projectName?: string | undefined },
114 ) {
115 return (
116 props.projectName ??
117 (yield* createPhysicalName({ id, maxLength: 255 }))
118 );
119 });
120
121 const observe = Effect.fn(function* (name: string) {
122 return yield* databrew
123 .describeProject({ Name: name })
124 .pipe(
125 Effect.catchTag("ResourceNotFoundException", () =>
126 Effect.succeed(undefined),
127 ),
128 );
129 });
130
131 return Project.Provider.of({
132 stables: ["projectName", "projectArn"],
133
134 list: () =>
135 Effect.gen(function* () {
136 const { accountId, region } = yield* AWSEnvironment.current;
137 const pages = yield* databrew.listProjects
138 .pages({})
139 .pipe(Stream.runCollect);
140 return Array.from(pages)
141 .flatMap((page) => page.Projects ?? [])
142 .map((p) => ({
143 projectName: p.Name,
144 projectArn:
145 p.ResourceArn ??
146 databrewArn(region, accountId, "project", p.Name),
147 }));
148 }),
149
150 read: Effect.fn(function* ({ id, olds, output }) {
151 const { accountId, region } = yield* AWSEnvironment.current;
152 const name =
153 output?.projectName ?? (yield* createName(id, olds ?? {}));
154 const project = yield* observe(name);
155 if (project === undefined) return undefined;
156 const arn =
157 project.ResourceArn ??
158 databrewArn(region, accountId, "project", name);
159 const attrs = { projectName: name, projectArn: arn };
160 const tags = cleanMap(project.Tags);
161 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
162 }),
163
164 diff: Effect.fn(function* ({ id, news, olds }) {
165 if (!isResolved(news)) return undefined;
166 const oldName = yield* createName(id, olds);
167 const newName = yield* createName(id, news);
168 if (oldName !== newName) return { action: "replace" } as const;
169 // UpdateProject only accepts Sample + RoleArn — the dataset and
170 // recipe associations are immutable.
171 if (olds.datasetName !== news.datasetName) {
172 return { action: "replace" } as const;
173 }
174 if (olds.recipeName !== news.recipeName) {
175 return { action: "replace" } as const;
176 }
177 }),
178
179 reconcile: Effect.fn(function* ({ id, news, output, session }) {
180 const { accountId, region } = yield* AWSEnvironment.current;
181 const name = output?.projectName ?? (yield* createName(id, news));
182 const internalTags = yield* createInternalTags(id);
183 const desiredTags = { ...news.tags, ...internalTags };
184
185 // 1. OBSERVE
186 const project = yield* observe(name);
187
188 // 2. ENSURE / 3. SYNC (Sample + RoleArn are the mutable aspects)
189 if (project === undefined) {
190 yield* retryWhileRoleNotAssumable(
191 databrew.createProject({
192 Name: name,
193 DatasetName: news.datasetName,
194 RecipeName: news.recipeName,
195 Sample: buildSample(news.sample),
196 RoleArn: news.role,
197 Tags: desiredTags,
198 }),
199 ).pipe(Effect.catchTag("ConflictException", () => Effect.void));
200 } else {
201 yield* retryWhileRoleNotAssumable(
202 databrew.updateProject({
203 Name: name,
204 Sample: buildSample(news.sample),
205 RoleArn: news.role,
206 }),
207 );
208 }
209
210 const arn =
211 project?.ResourceArn ??
212 databrewArn(region, accountId, "project", name);
213
214 // 3b. SYNC TAGS against observed cloud tags
215 const observedTags = yield* fetchObservedTags(arn);
216 yield* syncTags(arn, observedTags, desiredTags);
217
218 yield* session.note(name);
219 return { projectName: name, projectArn: arn };
220 }),
221
222 delete: Effect.fn(function* ({ output }) {
223 // ConflictException while an interactive session is winding down.
224 yield* retryWhileConflict(
225 databrew.deleteProject({ Name: output.projectName }),
226 ).pipe(
227 Effect.catchTag("ResourceNotFoundException", () =>
228 Effect.succeed(undefined),
229 ),
230 );
231 }),
232 });
233 }),
234 );