1import * as EntityResolution from "@/AWS/EntityResolution";
2import * as Glue from "@/AWS/Glue";
3import * as IAM from "@/AWS/IAM";
4import * as Lambda from "@/AWS/Lambda";
5import * as S3 from "@/AWS/S3";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15// Deterministic fixture names (bucket names are account-global; this suite
16// owns them in the testing account).
17export const FIXTURE_BUCKET_NAME = "alchemy-test-entityres-bindings";
18
19// Well-formed-but-nonexistent job id used to drive the typed
20// ResourceNotFoundException paths. An IAM gap would surface
21// AccessDeniedException (a 500 through the handler's orDie), so a typed
22// not-found tag proves the grant end-to-end.
23const BOGUS_JOB_ID = "00000000000000000000000000000000";
24
25export class EntityResolutionTestFunction extends Lambda.Function<Lambda.Function>()(
26 "EntityResolutionTestFunction",
27) {}
28
29export default EntityResolutionTestFunction.make(
30 {
31 main,
32 functionUrl: true,
33 timeout: Duration.seconds(60),
34 },
35 Effect.gen(function* () {
36 const bucket = yield* S3.Bucket("ErBindingsBucket", {
37 bucketName: FIXTURE_BUCKET_NAME,
38 forceDestroy: true,
39 });
40 // The role Entity Resolution assumes to read the Glue input tables (and
41 // their underlying S3 data) and write matched output to S3.
42 const role = yield* IAM.Role("ErBindingsRole", {
43 assumeRolePolicyDocument: {
44 Version: "2012-10-17",
45 Statement: [
46 {
47 Effect: "Allow",
48 Principal: { Service: "entityresolution.amazonaws.com" },
49 Action: ["sts:AssumeRole"],
50 },
51 ],
52 },
53 inlinePolicies: {
54 workflow: {
55 Version: "2012-10-17",
56 Statement: [
57 {
58 Effect: "Allow",
59 Action: [
60 "glue:GetDatabase",
61 "glue:GetTable",
62 "glue:GetPartition",
63 "glue:GetPartitions",
64 "glue:GetSchema",
65 "glue:GetSchemaVersion",
66 ],
67 Resource: ["*"],
68 },
69 {
70 Effect: "Allow",
71 Action: ["s3:GetObject", "s3:ListBucket", "s3:PutObject"],
72 Resource: [
73 `arn:aws:s3:::${FIXTURE_BUCKET_NAME}`,
74 `arn:aws:s3:::${FIXTURE_BUCKET_NAME}/*`,
75 ],
76 },
77 ],
78 },
79 },
80 });
81 const database = yield* Glue.Database("ErBindingsDb", {});
82 const table = yield* Glue.Table("Customers", {
83 databaseName: database.databaseName,
84 storageDescriptor: {
85 location: `s3://${FIXTURE_BUCKET_NAME}/input/`,
86 inputFormat: "org.apache.hadoop.mapred.TextInputFormat",
87 outputFormat:
88 "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat",
89 serdeInfo: {
90 serializationLibrary:
91 "org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe",
92 parameters: { "field.delim": ",", "skip.header.line.count": "1" },
93 },
94 columns: [
95 { name: "id", type: "string" },
96 { name: "email", type: "string" },
97 { name: "name", type: "string" },
98 ],
99 },
100 parameters: { classification: "csv" },
101 });
102 const schema = yield* EntityResolution.SchemaMapping("ErBindingsSchema", {
103 mappedInputFields: [
104 { fieldName: "id", type: "UNIQUE_ID" },
105 { fieldName: "email", type: "EMAIL_ADDRESS", matchKey: "email" },
106 { fieldName: "name", type: "NAME", matchKey: "name" },
107 ],
108 });
109 const matchingWorkflow = yield* EntityResolution.MatchingWorkflow(
110 "ErBindingsMatching",
111 {
112 inputSourceConfig: [
113 { inputSourceARN: table.tableArn, schemaName: schema.schemaName },
114 ],
115 outputSourceConfig: [
116 {
117 outputS3Path: `s3://${FIXTURE_BUCKET_NAME}/matches/`,
118 output: [{ name: "id" }, { name: "email" }, { name: "name" }],
119 },
120 ],
121 resolutionTechniques: {
122 resolutionType: "RULE_MATCHING",
123 ruleBasedProperties: {
124 rules: [{ ruleName: "ByEmail", matchingKeys: ["email"] }],
125 attributeMatchingModel: "ONE_TO_ONE",
126 },
127 },
128 roleArn: role.roleArn,
129 },
130 );
131
132 // Rule-based ID mapping: SOURCE and TARGET namespaces over the same Glue
133 // table, with the matching rules declared on the TARGET namespace.
134 const sourceNamespace = yield* EntityResolution.IdNamespace(
135 "ErBindingsSourceNs",
136 {
137 type: "SOURCE",
138 inputSourceConfig: [
139 { inputSourceARN: table.tableArn, schemaName: schema.schemaName },
140 ],
141 // The workflow-create validation requires the SOURCE namespace to
142 // declare its rule-based capabilities (rules themselves live on the
143 // TARGET namespace).
144 idMappingWorkflowProperties: [
145 {
146 idMappingType: "RULE_BASED",
147 ruleBasedProperties: {
148 ruleDefinitionTypes: ["TARGET"],
149 attributeMatchingModel: "ONE_TO_ONE",
150 recordMatchingModels: ["ONE_SOURCE_TO_ONE_TARGET"],
151 },
152 },
153 ],
154 roleArn: role.roleArn,
155 },
156 );
157 // The service requires TARGET namespace input sources to be matching
158 // workflow ARNs (the target ids are the workflow's match IDs):
159 // "Check that it follows the pattern:
160 // arn:(aws|...):entityresolution:...:matchingworkflow/{resource_name}".
161 const targetNamespace = yield* EntityResolution.IdNamespace(
162 "ErBindingsTargetNs",
163 {
164 type: "TARGET",
165 inputSourceConfig: [{ inputSourceARN: matchingWorkflow.workflowArn }],
166 idMappingWorkflowProperties: [
167 {
168 idMappingType: "RULE_BASED",
169 ruleBasedProperties: {
170 rules: [{ ruleName: "ByEmail", matchingKeys: ["email"] }],
171 ruleDefinitionTypes: ["TARGET"],
172 attributeMatchingModel: "ONE_TO_ONE",
173 recordMatchingModels: ["ONE_SOURCE_TO_ONE_TARGET"],
174 },
175 },
176 ],
177 roleArn: role.roleArn,
178 },
179 );
180 const idMappingWorkflow = yield* EntityResolution.IdMappingWorkflow(
181 "ErBindingsIdMapping",
182 {
183 inputSourceConfig: [
184 {
185 inputSourceARN: sourceNamespace.idNamespaceArn,
186 type: "SOURCE",
187 },
188 {
189 inputSourceARN: targetNamespace.idNamespaceArn,
190 type: "TARGET",
191 },
192 ],
193 idMappingTechniques: {
194 idMappingType: "RULE_BASED",
195 ruleBasedProperties: {
196 ruleDefinitionType: "TARGET",
197 attributeMatchingModel: "ONE_TO_ONE",
198 recordMatchingModel: "ONE_SOURCE_TO_ONE_TARGET",
199 },
200 },
201 outputSourceConfig: [
202 { outputS3Path: `s3://${FIXTURE_BUCKET_NAME}/idmapping/` },
203 ],
204 roleArn: role.roleArn,
205 },
206 );
207
208 // Used by the real-time routes: records reference the Glue table ARN.
209 const tableArn = yield* table.tableArn;
210
211 // --- matching workflow bindings ---
212 const startMatchingJob =
213 yield* EntityResolution.StartMatchingJob(matchingWorkflow);
214 const getMatchingJob =
215 yield* EntityResolution.GetMatchingJob(matchingWorkflow);
216 const listMatchingJobs =
217 yield* EntityResolution.ListMatchingJobs(matchingWorkflow);
218 const generateMatchId =
219 yield* EntityResolution.GenerateMatchId(matchingWorkflow);
220 const getMatchId = yield* EntityResolution.GetMatchId(matchingWorkflow);
221 const batchDeleteUniqueId =
222 yield* EntityResolution.BatchDeleteUniqueId(matchingWorkflow);
223
224 // --- id mapping workflow bindings ---
225 const startIdMappingJob =
226 yield* EntityResolution.StartIdMappingJob(idMappingWorkflow);
227 const getIdMappingJob =
228 yield* EntityResolution.GetIdMappingJob(idMappingWorkflow);
229 const listIdMappingJobs =
230 yield* EntityResolution.ListIdMappingJobs(idMappingWorkflow);
231
232 const bound = {
233 startMatchingJob,
234 getMatchingJob,
235 listMatchingJobs,
236 generateMatchId,
237 getMatchId,
238 batchDeleteUniqueId,
239 startIdMappingJob,
240 getIdMappingJob,
241 listIdMappingJobs,
242 };
243
244 return {
245 fetch: Effect.gen(function* () {
246 const request = yield* HttpServerRequest;
247 const url = new URL(request.originalUrl);
248 const pathname = url.pathname;
249
250 // Cheap readiness route — no Entity Resolution call.
251 if (request.method === "GET" && pathname === "/bindings") {
252 return yield* HttpServerResponse.json({
253 bound: Object.keys(bound),
254 });
255 }
256
257 // Workflow-scoped job listings (both empty on a fresh deploy).
258 if (request.method === "GET" && pathname === "/jobs") {
259 const matching = yield* listMatchingJobs({});
260 const idMapping = yield* listIdMappingJobs({});
261 return yield* HttpServerResponse.json({
262 matchingJobs: (matching.jobs ?? []).length,
263 idMappingJobs: (idMapping.jobs ?? []).length,
264 });
265 }
266
267 // Drives GetMatchingJob + GetIdMappingJob through their typed
268 // ResourceNotFoundException path (an IAM gap would be a 500).
269 if (request.method === "GET" && pathname === "/jobs/not-found") {
270 const matching = yield* getMatchingJob({ jobId: BOGUS_JOB_ID }).pipe(
271 Effect.map(() => "Found"),
272 Effect.catchTag("ResourceNotFoundException", (e) =>
273 Effect.succeed(e._tag),
274 ),
275 );
276 const idMapping = yield* getIdMappingJob({
277 jobId: BOGUS_JOB_ID,
278 }).pipe(
279 Effect.map(() => "Found"),
280 Effect.catchTag("ResourceNotFoundException", (e) =>
281 Effect.succeed(e._tag),
282 ),
283 );
284 return yield* HttpServerResponse.json({ matching, idMapping });
285 }
286
287 // Real-time lookup of a record that has never been processed — a
288 // successful empty response proves the grant + request plumbing.
289 if (request.method === "POST" && pathname === "/match-id") {
290 const response = yield* getMatchId({
291 record: { id: "1", email: "jane@example.com", name: "Jane Doe" },
292 }).pipe(
293 Effect.map((r) => ({
294 result: "ok" as const,
295 matchId: r.matchId ?? null,
296 matchRule: r.matchRule ?? null,
297 message: null as string | null,
298 })),
299 // A workflow that has never run a job may reject real-time
300 // lookups — surface the typed rejection so the test can assert
301 // on it (an IAM gap would still be a 500).
302 Effect.catchTag(
303 ["ValidationException", "ResourceNotFoundException"],
304 (e) =>
305 Effect.succeed({
306 result: e._tag as string,
307 matchId: null,
308 matchRule: null,
309 message: e.message ?? null,
310 }),
311 ),
312 );
313 return yield* HttpServerResponse.json(response);
314 }
315
316 // Real-time match-id generation for two records that match by email.
317 if (request.method === "POST" && pathname === "/generate-match-id") {
318 const result = yield* generateMatchId({
319 records: [
320 {
321 inputSourceARN: yield* tableArn,
322 uniqueId: "1",
323 recordAttributeMap: {
324 id: "1",
325 email: "jane@example.com",
326 name: "Jane Doe",
327 },
328 },
329 {
330 inputSourceARN: yield* tableArn,
331 uniqueId: "2",
332 recordAttributeMap: {
333 id: "2",
334 email: "jane@example.com",
335 name: "Jane D",
336 },
337 },
338 ],
339 }).pipe(
340 Effect.map((r) => ({
341 result: "ok" as const,
342 matchGroups: r.matchGroups.length,
343 failedRecords: r.failedRecords.length,
344 })),
345 // Real-time generation requires the workflow family to support
346 // it — surface the typed rejection instead of a 500 so the test
347 // can assert on it.
348 Effect.catchTag(
349 ["ValidationException", "ResourceNotFoundException"],
350 (e) =>
351 Effect.succeed({
352 result: e._tag as string,
353 message: e.message ?? null,
354 }),
355 ),
356 );
357 return yield* HttpServerResponse.json(result);
358 }
359
360 // Deletes unknown unique ids from the match store — per-id errors
361 // (or a typed ValidationException when the workflow family doesn't
362 // support deletes) prove the grant end-to-end.
363 if (request.method === "POST" && pathname === "/delete-unique-ids") {
364 // Fully total: report ANY typed failure as its tag so the test can
365 // assert on the observed behavior (an IAM gap would surface here as
366 // AccessDeniedException rather than an opaque 500).
367 const outcome = yield* batchDeleteUniqueId({
368 uniqueIds: ["ghost-1", "ghost-2"],
369 }).pipe(Effect.result);
370 const result =
371 outcome._tag === "Success"
372 ? {
373 result: "ok" as const,
374 status: outcome.success.status,
375 deleted: (outcome.success.deleted ?? []).length,
376 errors: (outcome.success.errors ?? []).length,
377 disconnected: (outcome.success.disconnectedUniqueIds ?? [])
378 .length,
379 }
380 : {
381 result: outcome.failure._tag as string,
382 message: String(outcome.failure),
383 };
384 return yield* HttpServerResponse.json(result);
385 }
386
387 // Gated by the test: really starts batch jobs (matching runs take
388 // many minutes and cannot be cancelled — only exercised when
389 // AWS_TEST_ENTITYRESOLUTION_RUN is set).
390 if (request.method === "POST" && pathname === "/start-matching-job") {
391 const { jobId } = yield* startMatchingJob({});
392 const job = yield* getMatchingJob({ jobId });
393 return yield* HttpServerResponse.json({
394 jobId,
395 status: job.status,
396 });
397 }
398 if (request.method === "POST" && pathname === "/start-id-mapping-job") {
399 const { jobId } = yield* startIdMappingJob({});
400 const job = yield* getIdMappingJob({ jobId });
401 return yield* HttpServerResponse.json({
402 jobId,
403 status: job.status,
404 });
405 }
406
407 return yield* HttpServerResponse.json(
408 { error: "Not found", method: request.method, pathname },
409 { status: 404 },
410 );
411 }).pipe(Effect.orDie),
412 };
413 }).pipe(
414 Effect.provide(
415 Layer.mergeAll(
416 EntityResolution.StartMatchingJobHttp,
417 EntityResolution.GetMatchingJobHttp,
418 EntityResolution.ListMatchingJobsHttp,
419 EntityResolution.GenerateMatchIdHttp,
420 EntityResolution.GetMatchIdHttp,
421 EntityResolution.BatchDeleteUniqueIdHttp,
422 EntityResolution.StartIdMappingJobHttp,
423 EntityResolution.GetIdMappingJobHttp,
424 EntityResolution.ListIdMappingJobsHttp,
425 ),
426 ),
427 ),
428);