1import * as datazone from "@distilled.cloud/aws/datazone";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { unredact } from "./internal.ts";
9
10export interface ProjectProps {
11 /**
12 * The identifier of the {@link Domain} the project lives in. Accepts a
13 * domain's `domainId` output. Changing it triggers a replacement.
14 */
15 domainId: string;
16 /**
17 * Name of the project. If omitted, a deterministic physical name is
18 * generated from the app, stage, and logical ID. The name is mutable — it
19 * converges via `UpdateProject` without replacement.
20 */
21 name?: string;
22 /**
23 * A description of the project.
24 */
25 description?: string;
26 /**
27 * Glossary term identifiers to attach to the project.
28 */
29 glossaryTerms?: string[];
30}
31
32export interface Project extends Resource<
33 "AWS.DataZone.Project",
34 ProjectProps,
35 {
36 /** The unique identifier of the project. */
37 projectId: string;
38 /** The identifier of the domain the project lives in. */
39 domainId: string;
40 /** The name of the project. */
41 name: string;
42 /** The status of the project (`ACTIVE` once settled). */
43 projectStatus: string | undefined;
44 /** The identifier of the domain unit the project belongs to. */
45 domainUnitId: string | undefined;
46 /** The DataZone user who created the project. */
47 createdBy: string;
48 }
49> {}
50
51/**
52 * An Amazon DataZone project — the collaboration space within a domain where
53 * teams catalog, publish, and subscribe to data assets.
54 *
55 * The creating principal is automatically the project owner. DataZone
56 * projects do not support resource tags, so ownership is tracked purely by
57 * identity.
58 *
59 * ### Creating Projects
60 * **Example:** Minimal Project
61 * ```typescript
62 * import * as DataZone from "alchemy/AWS/DataZone";
63 *
64 * const domain = yield* DataZone.Domain("governance", {});
65 *
66 * const project = yield* DataZone.Project("analytics", {
67 * domainId: domain.domainId,
68 * description: "Analytics team project",
69 * });
70 * ```
71 *
72 * **Example:** Project with an Explicit Name
73 * ```typescript
74 * const project = yield* DataZone.Project("analytics", {
75 * domainId: domain.domainId,
76 * name: "analytics-team",
77 * glossaryTerms: [term.id],
78 * });
79 * ```
80 *
81 * @resource
82 */
83export const Project = Resource<Project>("AWS.DataZone.Project");
84
85/** Project status values indicating an in-flight transition to wait out. */
86const PROJECT_TRANSIENT = new Set(["DELETING", "MOVING", "UPDATING"]);
87
88/**
89 * A freshly created domain propagates the creator's user profile eventually;
90 * `createProject` can transiently reject with `AccessDeniedException` right
91 * after domain creation. Wrapped in an explicitly-typed helper so the
92 * `Effect.retry` conditional return type does not leak into declaration emit
93 * (see PATTERNS §7).
94 */
95const retryWhileUserProfilePropagates = <A, E extends { _tag: string }, R>(
96 self: Effect.Effect<A, E, R>,
97): Effect.Effect<A, E, R> =>
98 Effect.retry(self, {
99 while: (e) => e._tag === "AccessDeniedException",
100 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
101 });
102
103export const ProjectProvider = () =>
104 Provider.effect(
105 Project,
106 Effect.gen(function* () {
107 const createName = Effect.fn(function* (id: string, props: ProjectProps) {
108 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
109 });
110
111 // A deleted project — or a project inside a deleted domain — is
112 // reported as AccessDeniedException, NOT ResourceNotFoundException:
113 // DataZone evaluates domain-scoped authorization before existence.
114 // Both mean "absent".
115 const getProjectOrUndefined = Effect.fn(function* (
116 domainId: string,
117 projectId: string,
118 ) {
119 return yield* datazone
120 .getProject({ domainIdentifier: domainId, identifier: projectId })
121 .pipe(
122 Effect.catchTag("ResourceNotFoundException", () =>
123 Effect.succeed(undefined),
124 ),
125 Effect.catchTag("AccessDeniedException", () =>
126 Effect.succeed(undefined),
127 ),
128 );
129 });
130
131 const findByName = Effect.fn(function* (domainId: string, name: string) {
132 const found = yield* datazone
133 .listProjects({ domainIdentifier: domainId, name })
134 .pipe(
135 Effect.catchTag("ResourceNotFoundException", () =>
136 Effect.succeed(undefined),
137 ),
138 Effect.catchTag("AccessDeniedException", () =>
139 Effect.succeed(undefined),
140 ),
141 );
142 const summary = (found?.items ?? []).find(
143 (s) => unredact(s.name) === name && s.projectStatus !== "DELETING",
144 );
145 return summary?.id;
146 });
147
148 // Poll the project to a settled (non-transient) status — project
149 // operations settle within seconds.
150 const waitForSettled = Effect.fn(function* (
151 domainId: string,
152 projectId: string,
153 ) {
154 return yield* getProjectOrUndefined(domainId, projectId).pipe(
155 Effect.repeat({
156 schedule: Schedule.fixed("3 seconds"),
157 until: (project) =>
158 project === undefined ||
159 project.projectStatus === undefined ||
160 !PROJECT_TRANSIENT.has(project.projectStatus),
161 times: 20,
162 }),
163 );
164 });
165
166 // Poll the project until it no longer exists — deletion is async but
167 // settles within seconds for empty projects.
168 const waitForGone = Effect.fn(function* (
169 domainId: string,
170 projectId: string,
171 ) {
172 yield* getProjectOrUndefined(domainId, projectId).pipe(
173 Effect.repeat({
174 schedule: Schedule.fixed("3 seconds"),
175 until: (project) => project === undefined,
176 times: 20,
177 }),
178 );
179 });
180
181 const toAttributes = (
182 project: datazone.GetProjectOutput | datazone.CreateProjectOutput,
183 ) => ({
184 projectId: project.id,
185 domainId: project.domainId,
186 name: unredact(project.name),
187 projectStatus: project.projectStatus,
188 domainUnitId: project.domainUnitId,
189 createdBy: project.createdBy,
190 });
191
192 return Project.Provider.of({
193 stables: ["projectId", "domainId", "createdBy"],
194
195 // Projects are keyed by their parent domain — there is no
196 // account-level enumeration without a domain identifier.
197 list: () => Effect.succeed([]),
198
199 read: Effect.fn(function* ({ id, olds, output }) {
200 const domainId = output?.domainId ?? olds?.domainId;
201 if (domainId === undefined) return undefined;
202 const projectId =
203 output?.projectId ??
204 (yield* findByName(
205 domainId,
206 yield* createName(id, olds ?? { domainId }),
207 ));
208 if (projectId === undefined) return undefined;
209 const project = yield* getProjectOrUndefined(domainId, projectId);
210 if (project === undefined || project.projectStatus === "DELETING") {
211 return undefined;
212 }
213 // Projects have no tags — ownership is tracked purely by identity.
214 return toAttributes(project);
215 }),
216
217 diff: Effect.fn(function* ({ news, olds }) {
218 if (!isResolved(news)) return undefined;
219 if (olds !== undefined && olds.domainId !== news.domainId) {
220 return { action: "replace" } as const;
221 }
222 // name, description, and glossaryTerms converge via updateProject.
223 }),
224
225 reconcile: Effect.fn(function* ({ id, news, output, session }) {
226 const domainId = news.domainId;
227 const name = yield* createName(id, news);
228
229 // 1. OBSERVE — cloud state is authoritative; output is only an id
230 // cache. Fall back to a name lookup after state loss.
231 let project = output?.projectId
232 ? yield* getProjectOrUndefined(domainId, output.projectId)
233 : undefined;
234 if (project === undefined) {
235 const foundId = yield* findByName(domainId, name);
236 if (foundId !== undefined) {
237 project = yield* getProjectOrUndefined(domainId, foundId);
238 }
239 }
240
241 if (project === undefined) {
242 // 2. ENSURE — create (retrying the user-profile propagation
243 // window right after domain creation).
244 project = yield* retryWhileUserProfilePropagates(
245 datazone.createProject({
246 domainIdentifier: domainId,
247 name,
248 description: news.description,
249 glossaryTerms: news.glossaryTerms,
250 }),
251 );
252 project = (yield* waitForSettled(domainId, project.id)) ?? project;
253 } else {
254 // 3. SYNC — wait out any in-flight transition, then converge the
255 // mutable aspects by diffing OBSERVED state against desired.
256 project = (yield* waitForSettled(domainId, project.id)) ?? project;
257 const observedTerms = project.glossaryTerms ?? [];
258 const desiredTerms = news.glossaryTerms ?? [];
259 const drifted =
260 unredact(project.name) !== name ||
261 (project.description === undefined
262 ? undefined
263 : unredact(project.description)) !==
264 (news.description ?? undefined) ||
265 observedTerms.length !== desiredTerms.length ||
266 desiredTerms.some((t) => !observedTerms.includes(t));
267 if (drifted) {
268 yield* datazone.updateProject({
269 domainIdentifier: domainId,
270 identifier: project.id,
271 name,
272 description: news.description,
273 glossaryTerms: news.glossaryTerms,
274 });
275 project =
276 (yield* getProjectOrUndefined(domainId, project.id)) ?? project;
277 }
278 }
279
280 yield* session.note(project.id);
281 return toAttributes(project);
282 }),
283
284 delete: Effect.fn(function* ({ output }) {
285 yield* datazone
286 .deleteProject({
287 domainIdentifier: output.domainId,
288 identifier: output.projectId,
289 skipDeletionCheck: true,
290 })
291 .pipe(
292 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
293 // deleting a project whose domain is already gone surfaces as
294 // AccessDenied — auth is checked before existence.
295 Effect.catchTag("AccessDeniedException", () => Effect.void),
296 );
297 yield* waitForGone(output.domainId, output.projectId);
298 }),
299 });
300 }),
301 );