1import * as Lambda from "@/AWS/Lambda";
2import * as AOSS from "@/AWS/OpenSearchServerless";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schedule from "effect/Schedule";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "index-bindings-handler.ts");
11
12// Deterministic names, distinct from Collection.test.ts so the suites never
13// collide. ≤32 chars, lowercase.
14export const COLLECTION_NAME = "alchemy-aossb";
15export const ENC_POLICY = "alchemy-aossb-enc";
16export const NET_POLICY = "alchemy-aossb-net";
17
18const INDEX_NAME = "alchemy-roundtrip";
19
20export class AossIndexFunction extends Lambda.Function<Lambda.Function>()(
21 "AossIndexFunction",
22) {}
23
24export default AossIndexFunction.make(
25 {
26 main,
27 functionUrl: true,
28 },
29 Effect.gen(function* () {
30 // The collection (and its prerequisite encryption/network policies) is
31 // deployed as part of the fixture; the data access policy granting THIS
32 // function's role lives in the test (it needs the function's roleArn).
33 const enc = yield* AOSS.SecurityPolicy("Enc", {
34 policyName: ENC_POLICY,
35 type: "encryption",
36 policy: {
37 Rules: [
38 {
39 ResourceType: "collection",
40 Resource: [`collection/${COLLECTION_NAME}`],
41 },
42 ],
43 AWSOwnedKey: true,
44 },
45 });
46 const net = yield* AOSS.SecurityPolicy("Net", {
47 policyName: NET_POLICY,
48 type: "network",
49 policy: [
50 {
51 Rules: [
52 {
53 ResourceType: "collection",
54 Resource: [`collection/${COLLECTION_NAME}`],
55 },
56 ],
57 AllowFromPublic: true,
58 },
59 ],
60 });
61 const collection = yield* AOSS.Collection("Collection", {
62 collectionName: COLLECTION_NAME,
63 type: "SEARCH",
64 standbyReplicas: "DISABLED",
65 // Reference the policies so the engine orders them before the
66 // collection (the encryption policy is a hard create prerequisite).
67 tags: { encPolicy: enc.policyName, netPolicy: net.policyName },
68 });
69
70 const getCollection = yield* AOSS.GetCollection(collection);
71 const createIndex = yield* AOSS.CreateIndex(collection);
72 const getIndex = yield* AOSS.GetIndex(collection);
73 const updateIndex = yield* AOSS.UpdateIndex(collection);
74 const deleteIndex = yield* AOSS.DeleteIndex(collection);
75
76 const bound = {
77 getCollection,
78 createIndex,
79 getIndex,
80 updateIndex,
81 deleteIndex,
82 };
83
84 return {
85 fetch: Effect.gen(function* () {
86 const request = yield* HttpServerRequest;
87 const url = new URL(request.originalUrl);
88 const pathname = url.pathname;
89
90 if (request.method === "GET" && pathname === "/bindings") {
91 return yield* HttpServerResponse.json({
92 bound: Object.keys(bound),
93 });
94 }
95
96 if (request.method === "GET" && pathname === "/collection") {
97 const detail = yield* getCollection();
98 return yield* HttpServerResponse.json({
99 id: detail?.id,
100 status: detail?.status,
101 endpoint: detail?.collectionEndpoint,
102 });
103 }
104
105 if (request.method === "POST" && pathname === "/index/roundtrip") {
106 // Create → read → update → delete: exercises the runtime
107 // multi-tenant index pattern end-to-end and leaves no orphan. A
108 // crashed previous invocation surfaces the typed ConflictException
109 // on create — treat the existing index as the winner.
110 yield* createIndex({
111 indexName: INDEX_NAME,
112 indexSchema: {
113 mappings: { properties: { title: { type: "text" } } },
114 },
115 }).pipe(Effect.catchTag("ConflictException", () => Effect.void));
116
117 // Index visibility is eventually consistent — retry the read
118 // through the typed not-found window (bounded, ~30s).
119 const read = yield* Effect.retry(
120 getIndex({ indexName: INDEX_NAME }),
121 {
122 while: (e): boolean => e._tag === "ResourceNotFoundException",
123 schedule: Schedule.max([
124 Schedule.fixed("3 seconds"),
125 Schedule.recurs(10),
126 ]),
127 },
128 );
129
130 yield* updateIndex({
131 indexName: INDEX_NAME,
132 indexSchema: {
133 mappings: {
134 properties: { body: { type: "text" } },
135 },
136 },
137 });
138
139 yield* deleteIndex({ indexName: INDEX_NAME }).pipe(
140 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
141 );
142
143 return yield* HttpServerResponse.json({
144 created: true,
145 hadSchema: read.indexSchema !== undefined,
146 deleted: true,
147 });
148 }
149
150 return yield* HttpServerResponse.json(
151 { error: "Not found", method: request.method, pathname },
152 { status: 404 },
153 );
154 }).pipe(Effect.orDie),
155 };
156 }).pipe(
157 Effect.provide(
158 Layer.mergeAll(
159 AOSS.GetCollectionHttp,
160 AOSS.CreateIndexHttp,
161 AOSS.GetIndexHttp,
162 AOSS.UpdateIndexHttp,
163 AOSS.DeleteIndexHttp,
164 ),
165 ),
166 ),
167);