1import * as glue from "@distilled.cloud/aws/glue";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { diffTags } from "../../Tags.ts";
5
6/** ARN partition — the testing account is on the standard AWS partition. */
7const PARTITION = "aws";
8
9/** `arn:aws:glue:{region}:{account}:catalog` */
10export const catalogArn = (region: string, accountId: string) =>
11 `arn:${PARTITION}:glue:${region}:${accountId}:catalog`;
12
13/** `arn:aws:glue:{region}:{account}:database/{name}` */
14export const databaseArn = (
15 region: string,
16 accountId: string,
17 databaseName: string,
18) => `arn:${PARTITION}:glue:${region}:${accountId}:database/${databaseName}`;
19
20/** `arn:aws:glue:{region}:{account}:table/{database}/{table}` */
21export const tableArn = (
22 region: string,
23 accountId: string,
24 databaseName: string,
25 tableName: string,
26) =>
27 `arn:${PARTITION}:glue:${region}:${accountId}:table/${databaseName}/${tableName}`;
28
29/** `arn:aws:glue:{region}:{account}:crawler/{name}` */
30export const crawlerArn = (
31 region: string,
32 accountId: string,
33 crawlerName: string,
34) => `arn:${PARTITION}:glue:${region}:${accountId}:crawler/${crawlerName}`;
35
36/** `arn:aws:glue:{region}:{account}:job/{name}` */
37export const jobArn = (region: string, accountId: string, jobName: string) =>
38 `arn:${PARTITION}:glue:${region}:${accountId}:job/${jobName}`;
39
40/** `arn:aws:glue:{region}:{account}:connection/{name}` */
41export const connectionArn = (
42 region: string,
43 accountId: string,
44 connectionName: string,
45) =>
46 `arn:${PARTITION}:glue:${region}:${accountId}:connection/${connectionName}`;
47
48/**
49 * Fetch the observed Glue tags for a resource ARN as a plain record. Glue's
50 * `GetTags` returns a `{ Tags: { key: value } }` map (not the array shape most
51 * AWS services use). Tolerate a missing/untaggable resource as `{}`.
52 */
53export const fetchObservedTags = Effect.fn("AWS.Glue.fetchObservedTags")(
54 function* (resourceArn: string) {
55 const response = yield* glue
56 .getTags({ ResourceArn: resourceArn })
57 .pipe(Effect.catch(() => Effect.succeed({ Tags: undefined })));
58 const tags = response.Tags ?? {};
59 return Object.fromEntries(
60 Object.entries(tags).filter(
61 (entry): entry is [string, string] => entry[1] !== undefined,
62 ),
63 );
64 },
65);
66
67/**
68 * Sync a Glue resource's tags: diff OBSERVED cloud tags against desired and
69 * apply the delta via `TagResource` (map of adds/updates) / `UntagResource`
70 * (list of removed keys).
71 */
72export const syncTags = Effect.fn("AWS.Glue.syncTags")(function* (
73 resourceArn: string,
74 observed: Record<string, string>,
75 desired: Record<string, string>,
76) {
77 const { upsert, removed } = diffTags(observed, desired);
78 if (upsert.length > 0) {
79 yield* glue.tagResource({
80 ResourceArn: resourceArn,
81 TagsToAdd: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
82 });
83 }
84 if (removed.length > 0) {
85 yield* glue.untagResource({
86 ResourceArn: resourceArn,
87 TagsToRemove: removed,
88 });
89 }
90});
91
92/**
93 * Bounded retry through `CrawlerRunningException` — a crawler cannot be
94 * updated or deleted while a crawl is in progress. Explicitly typed so the
95 * conditional `Retry.Return` type does not leak into the provider's
96 * declaration emit and widen `AWS.providers()` for downstream consumers.
97 */
98export const retryWhileCrawlerRunning = <A, E extends { _tag: string }, R>(
99 self: Effect.Effect<A, E, R>,
100): Effect.Effect<A, E, R> =>
101 Effect.retry(self, {
102 while: (e) => e._tag === "CrawlerRunningException",
103 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(20)]),
104 });
105
106/**
107 * Bounded retry through `ConcurrentModificationException` — Glue catalog
108 * mutations occasionally race under concurrent reconciles. Explicitly typed
109 * for the same declaration-emit reason as above.
110 */
111export const retryWhileConcurrentModification = <
112 A,
113 E extends { _tag: string },
114 R,
115>(
116 self: Effect.Effect<A, E, R>,
117): Effect.Effect<A, E, R> =>
118 Effect.retry(self, {
119 while: (e) => e._tag === "ConcurrentModificationException",
120 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]),
121 });
122
123/**
124 * Bounded retry through `GlueRoleNotAssumable` — a freshly-created IAM role is
125 * not yet assumable by the Glue service (IAM propagation), which surfaces as a
126 * message-discriminated `InvalidInputException` (patched into a typed tag).
127 * Explicitly typed for the same declaration-emit reason as above.
128 */
129export const retryWhileRoleNotAssumable = <A, E extends { _tag: string }, R>(
130 self: Effect.Effect<A, E, R>,
131): Effect.Effect<A, E, R> =>
132 Effect.retry(self, {
133 while: (e) => e._tag === "GlueRoleNotAssumable",
134 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(12)]),
135 });
136
137/**
138 * Glue validates crawler S3 targets through a service credential that can lag
139 * a newly-created bucket. Retry only the distilled synthetic tag for the
140 * observed InvalidAccessKeyId propagation failure.
141 */
142export const retryWhileCrawlerTargetNotReady = <
143 A,
144 E extends { _tag: string },
145 R,
146>(
147 self: Effect.Effect<A, E, R>,
148): Effect.Effect<A, E, R> =>
149 Effect.retry(self, {
150 while: (e) => e._tag === "GlueS3TargetNotReady",
151 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(10)]),
152 });
153
154/** Bounded retry for transient states that prevent crawler deletion. */
155export const retryCrawlerDelete = <A, E extends { _tag: string }, R>(
156 self: Effect.Effect<A, E, R>,
157): Effect.Effect<A, E, R> =>
158 Effect.retry(self, {
159 while: (e) =>
160 e._tag === "CrawlerRunningException" ||
161 e._tag === "SchedulerTransitioningException" ||
162 e._tag === "OperationTimeoutException",
163 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
164 });
165
166/** Glue free-form parameter/label maps arrive with `undefined` values erased. */
167export const cleanMap = (
168 map: Record<string, string | undefined> | undefined,
169): Record<string, string> =>
170 Object.fromEntries(
171 Object.entries(map ?? {}).filter(
172 (entry): entry is [string, string] => entry[1] !== undefined,
173 ),
174 );