1import * as entityresolution from "@distilled.cloud/aws/entityresolution";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { deepEqual, isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 retryRolePropagation,
14 syncEntityResolutionTags,
15 toTagRecord,
16} from "./internal.ts";
17
18export interface IdNamespaceProps {
19 /**
20 * Name of the ID namespace. Must be unique per account/region and match
21 * `[a-zA-Z_0-9-]*`. If omitted, a unique name is generated from the app,
22 * stage, and logical ID. Changing the name replaces the ID namespace.
23 */
24 idNamespaceName?: string;
25 /**
26 * A description of the ID namespace.
27 */
28 description?: string;
29 /**
30 * The kind of data this namespace holds: `SOURCE` namespaces contain the
31 * records to be mapped, `TARGET` namespaces contain the dataset (and, for
32 * rule-based mapping, the matching rules) the sources are mapped onto.
33 * Changing the type replaces the ID namespace.
34 */
35 type: entityresolution.IdNamespaceType;
36 /**
37 * The input records of the namespace: each entry names a Glue table
38 * (`inputSourceARN`) and optionally the schema mapping (`schemaName`) that
39 * describes its columns.
40 */
41 inputSourceConfig?: entityresolution.IdNamespaceInputSource[];
42 /**
43 * How this namespace participates in ID mapping workflows: `RULE_BASED`
44 * with `ruleBasedProperties` (rules live on the `TARGET` namespace) or a
45 * `PROVIDER` service.
46 */
47 idMappingWorkflowProperties?: entityresolution.IdNamespaceIdMappingWorkflowProperties[];
48 /**
49 * The ARN of the IAM role Entity Resolution assumes to read the namespace's
50 * input Glue tables on your behalf. Required when `inputSourceConfig`
51 * references Glue tables.
52 */
53 roleArn?: string;
54 /**
55 * Tags to apply to the ID namespace. Merged with internal Alchemy tags.
56 */
57 tags?: Record<string, string>;
58}
59
60export interface IdNamespace extends Resource<
61 "AWS.EntityResolution.IdNamespace",
62 IdNamespaceProps,
63 {
64 /**
65 * The name of the ID namespace.
66 */
67 idNamespaceName: string;
68 /**
69 * The ARN of the ID namespace.
70 */
71 idNamespaceArn: string;
72 },
73 never,
74 Providers
75> {}
76
77/**
78 * An AWS Entity Resolution ID namespace — a wrapper around an input dataset
79 * used by ID mapping workflows. A `SOURCE` namespace holds the records to be
80 * translated; a `TARGET` namespace holds the dataset (and the rule-based
81 * matching configuration) they are mapped onto.
82 *
83 * ### Creating ID Namespaces
84 * **Example:** Source namespace over a Glue table
85 * ```typescript
86 * import * as AWS from "alchemy/AWS";
87 *
88 * const source = yield* AWS.EntityResolution.IdNamespace("Source", {
89 * type: "SOURCE",
90 * inputSourceConfig: [
91 * { inputSourceARN: table.tableArn, schemaName: schema.schemaName },
92 * ],
93 * idMappingWorkflowProperties: [{ idMappingType: "RULE_BASED" }],
94 * roleArn: role.roleArn,
95 * });
96 * ```
97 *
98 * **Example:** Target namespace with matching rules
99 * ```typescript
100 * const target = yield* AWS.EntityResolution.IdNamespace("Target", {
101 * type: "TARGET",
102 * inputSourceConfig: [
103 * { inputSourceARN: table.tableArn, schemaName: schema.schemaName },
104 * ],
105 * idMappingWorkflowProperties: [
106 * {
107 * idMappingType: "RULE_BASED",
108 * ruleBasedProperties: {
109 * rules: [{ ruleName: "ByEmail", matchingKeys: ["email"] }],
110 * ruleDefinitionTypes: ["TARGET"],
111 * attributeMatchingModel: "ONE_TO_ONE",
112 * recordMatchingModels: ["ONE_SOURCE_TO_ONE_TARGET"],
113 * },
114 * },
115 * ],
116 * roleArn: role.roleArn,
117 * });
118 * ```
119 *
120 * @resource
121 */
122export const IdNamespace = Resource<IdNamespace>(
123 "AWS.EntityResolution.IdNamespace",
124);
125
126export const IdNamespaceProvider = () =>
127 Provider.effect(
128 IdNamespace,
129 Effect.gen(function* () {
130 const createName = Effect.fn(function* (
131 id: string,
132 props: { idNamespaceName?: string | undefined },
133 ) {
134 return (
135 props.idNamespaceName ??
136 (yield* createPhysicalName({ id, maxLength: 255 }))
137 );
138 });
139
140 /** Get an ID namespace by name; typed not-found → undefined. */
141 const getByName = Effect.fn(function* (idNamespaceName: string) {
142 return yield* entityresolution
143 .getIdNamespace({ idNamespaceName })
144 .pipe(
145 Effect.catchTag("ResourceNotFoundException", () =>
146 Effect.succeed(undefined),
147 ),
148 );
149 });
150
151 return {
152 stables: ["idNamespaceName", "idNamespaceArn"],
153
154 read: Effect.fn(function* ({ id, olds, output }) {
155 const name =
156 output?.idNamespaceName ?? (yield* createName(id, olds ?? {}));
157 const namespace = yield* getByName(name);
158 if (namespace === undefined) return undefined;
159 const attrs = {
160 idNamespaceName: namespace.idNamespaceName,
161 idNamespaceArn: namespace.idNamespaceArn,
162 };
163 // getIdNamespace returns tags — use the observed tags for the
164 // ownership check.
165 const tags = toTagRecord(namespace.tags);
166 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
167 }),
168
169 diff: Effect.fn(function* ({ id, news, olds }) {
170 if (!isResolved(news)) return undefined;
171 const oldName = yield* createName(id, olds);
172 const newName = yield* createName(id, news);
173 // The name is the identity, and `type` is create-only —
174 // updateIdNamespace does not accept it.
175 if (oldName !== newName || olds.type !== news.type) {
176 return { action: "replace" } as const;
177 }
178 }),
179
180 reconcile: Effect.fn(function* ({ id, news, output, session }) {
181 const name = output?.idNamespaceName ?? (yield* createName(id, news));
182 const internalTags = yield* createInternalTags(id);
183 const desiredTags = { ...news.tags, ...internalTags };
184
185 // 1. Observe — names are unique, so the name IS the identity.
186 let namespace = yield* getByName(name);
187
188 // 2. Ensure — create when missing. A freshly-created IAM role is
189 // transiently rejected while IAM propagates; tolerate the
190 // concurrent-create race, then re-observe.
191 if (namespace === undefined) {
192 yield* entityresolution
193 .createIdNamespace({
194 idNamespaceName: name,
195 description: news.description,
196 type: news.type,
197 inputSourceConfig: news.inputSourceConfig,
198 idMappingWorkflowProperties: news.idMappingWorkflowProperties,
199 roleArn: news.roleArn,
200 tags: desiredTags,
201 })
202 .pipe(
203 retryRolePropagation,
204 Effect.catchTag("ConflictException", () => Effect.void),
205 );
206 namespace = yield* entityresolution.getIdNamespace({
207 idNamespaceName: name,
208 });
209 }
210
211 // 3. Sync — everything except name/type updates in place via a
212 // full-PUT update. Only call the API on an actual delta between
213 // OBSERVED and desired configuration.
214 const desired = {
215 description: news.description ?? undefined,
216 inputSourceConfig: news.inputSourceConfig ?? undefined,
217 idMappingWorkflowProperties:
218 news.idMappingWorkflowProperties ?? undefined,
219 roleArn: news.roleArn ?? undefined,
220 };
221 const observed = {
222 description: namespace.description ?? undefined,
223 inputSourceConfig: namespace.inputSourceConfig ?? undefined,
224 idMappingWorkflowProperties:
225 namespace.idMappingWorkflowProperties ?? undefined,
226 roleArn: namespace.roleArn ?? undefined,
227 };
228 if (!deepEqual(observed, desired)) {
229 yield* entityresolution
230 .updateIdNamespace({
231 idNamespaceName: name,
232 description: news.description,
233 inputSourceConfig: news.inputSourceConfig,
234 idMappingWorkflowProperties: news.idMappingWorkflowProperties,
235 roleArn: news.roleArn,
236 })
237 .pipe(retryRolePropagation);
238 }
239
240 const idNamespaceArn = namespace.idNamespaceArn;
241
242 // 3b. Sync tags against OBSERVED cloud tags (adoption-safe).
243 yield* syncEntityResolutionTags(idNamespaceArn, desiredTags);
244
245 yield* session.note(idNamespaceArn);
246 return { idNamespaceName: name, idNamespaceArn };
247 }),
248
249 delete: Effect.fn(function* ({ output }) {
250 // deleteIdNamespace succeeds even when the namespace is already
251 // gone; it conflicts while an ID mapping workflow still references
252 // it (the engine deletes dependents first — retry the
253 // eventual-consistency window).
254 yield* entityresolution
255 .deleteIdNamespace({ idNamespaceName: output.idNamespaceName })
256 .pipe(
257 Effect.retry({
258 while: (e) => e._tag === "ConflictException",
259 schedule: Schedule.max([
260 Schedule.fixed("2 seconds"),
261 Schedule.recurs(10),
262 ]),
263 }),
264 );
265 }),
266
267 list: () =>
268 entityresolution.listIdNamespaces.items({}).pipe(
269 Stream.map((summary) => ({
270 idNamespaceName: summary.idNamespaceName,
271 idNamespaceArn: summary.idNamespaceArn,
272 })),
273 Stream.runCollect,
274 Effect.map((chunk) => Array.from(chunk)),
275 ),
276 };
277 }),
278 );