1import * as AWS from "@/AWS";
2import { Index, VectorBucket } from "@/AWS/S3Vectors";
3import type { ScopedPlanStatusSession } from "@/Report.ts";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as s3vectors from "@distilled.cloud/aws/s3vectors";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Result from "effect/Result";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: AWS.providers() });
14const stubSession = {
15 note: () => Effect.void,
16} as unknown as ScopedPlanStatusSession;
17
18const findBucket = (vectorBucketName: string) =>
19 s3vectors.getVectorBucket({ vectorBucketName }).pipe(
20 Effect.map((r) => r.vectorBucket),
21 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
22 );
23
24const findIndex = (vectorBucketName: string, indexName: string) =>
25 s3vectors.getIndex({ vectorBucketName, indexName }).pipe(
26 Effect.map((r) => r.index),
27 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
28 );
29
30const findPolicy = (vectorBucketName: string) =>
31 s3vectors.getVectorBucketPolicy({ vectorBucketName }).pipe(
32 Effect.map((r) => r.policy),
33 Effect.catchTag("NotFoundException", () =>
34 Effect.succeed(undefined as string | undefined),
35 ),
36 );
37
38class BucketStillExists extends Data.TaggedError("BucketStillExists")<{
39 readonly vectorBucketName: string;
40}> {}
41
42const assertBucketDeleted = (vectorBucketName: string) =>
43 findBucket(vectorBucketName).pipe(
44 Effect.flatMap((bucket) =>
45 bucket === undefined
46 ? Effect.void
47 : Effect.fail(new BucketStillExists({ vectorBucketName })),
48 ),
49 Effect.retry({
50 while: (e) => e._tag === "BucketStillExists",
51 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
52 }),
53 );
54
55test.provider(
56 "create bucket + index, verify out-of-band, delete",
57 (stack) =>
58 Effect.gen(function* () {
59 yield* stack.destroy();
60
61 const { bucket, index } = yield* stack.deploy(
62 Effect.gen(function* () {
63 const bucket = yield* VectorBucket("TestVectors", {});
64 const index = yield* Index("TestIndex", {
65 vectorBucketName: bucket.vectorBucketName,
66 dimension: 8,
67 distanceMetric: "cosine",
68 });
69 return { bucket, index };
70 }),
71 );
72
73 expect(bucket.vectorBucketName).toBeDefined();
74 expect(bucket.vectorBucketArn).toContain(":bucket/");
75 expect(index.indexName).toBeDefined();
76 expect(index.indexArn).toContain("/index/");
77
78 // out-of-band verification via distilled
79 const observedBucket = yield* findBucket(bucket.vectorBucketName);
80 expect(observedBucket?.vectorBucketArn).toBe(bucket.vectorBucketArn);
81
82 const observedIndex = yield* findIndex(
83 bucket.vectorBucketName,
84 index.indexName,
85 );
86 expect(observedIndex?.dimension).toBe(8);
87 expect(observedIndex?.distanceMetric).toBe("cosine");
88
89 // internal ownership tags applied
90 const tags = yield* s3vectors
91 .listTagsForResource({ resourceArn: bucket.vectorBucketArn })
92 .pipe(Effect.map((r) => r.tags ?? {}));
93 expect(tags["alchemy::id"]).toBe("TestVectors");
94
95 yield* stack.destroy();
96 yield* assertBucketDeleted(bucket.vectorBucketName);
97 }),
98 {
99 tags: ["provider:aws", "provider:aws:s3vectors", "live"],
100 timeout: 180_000,
101 },
102);
103
104test.provider(
105 "custom names + index shape replacement on dimension change",
106 (stack) =>
107 Effect.gen(function* () {
108 yield* stack.destroy();
109
110 const first = yield* stack.deploy(
111 Effect.gen(function* () {
112 const bucket = yield* VectorBucket("NamedVectors", {
113 vectorBucketName: "alchemy-test-vectors-a",
114 });
115 // index name generated (engine-default) so a shape change replaces
116 // it under a fresh physical name — a pinned name would collide with
117 // itself during create-before-delete replacement.
118 const index = yield* Index("NamedIndex", {
119 vectorBucketName: bucket.vectorBucketName,
120 dimension: 4,
121 distanceMetric: "euclidean",
122 });
123 return { bucket, index };
124 }),
125 );
126 expect(first.bucket.vectorBucketName).toBe("alchemy-test-vectors-a");
127 expect(first.index.indexName).toBeDefined();
128
129 // changing dimension replaces the index (shape is immutable)
130 const second = yield* stack.deploy(
131 Effect.gen(function* () {
132 const bucket = yield* VectorBucket("NamedVectors", {
133 vectorBucketName: "alchemy-test-vectors-a",
134 });
135 const index = yield* Index("NamedIndex", {
136 vectorBucketName: bucket.vectorBucketName,
137 dimension: 16,
138 distanceMetric: "euclidean",
139 });
140 return { bucket, index };
141 }),
142 );
143 // replacement produced a new physical index
144 expect(second.index.indexName).not.toBe(first.index.indexName);
145
146 const observed = yield* findIndex(
147 "alchemy-test-vectors-a",
148 second.index.indexName,
149 );
150 expect(observed?.dimension).toBe(16);
151
152 yield* stack.destroy();
153 yield* assertBucketDeleted("alchemy-test-vectors-a");
154 }),
155 {
156 tags: ["provider:aws", "provider:aws:s3vectors", "live"],
157 timeout: 180_000,
158 },
159);
160
161test.provider(
162 "bucket policy lifecycle: attach, verify, remove",
163 (stack) =>
164 Effect.gen(function* () {
165 yield* stack.destroy();
166 const bucketName = "alchemy-test-vectors-policy";
167
168 // 1. deploy without a policy
169 const first = yield* stack.deploy(
170 Effect.gen(function* () {
171 const bucket = yield* VectorBucket("PolicyVectors", {
172 vectorBucketName: bucketName,
173 });
174 return { bucket };
175 }),
176 );
177 expect(yield* findPolicy(bucketName)).toBeUndefined();
178
179 // 2. attach a policy — principal derived from the bucket ARN's account
180 const accountId = first.bucket.vectorBucketArn.split(":")[4];
181 const deployWithPolicy = stack.deploy(
182 Effect.gen(function* () {
183 const bucket = yield* VectorBucket("PolicyVectors", {
184 vectorBucketName: bucketName,
185 policy: [
186 {
187 Effect: "Allow",
188 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
189 Action: ["s3vectors:GetVectors", "s3vectors:QueryVectors"],
190 Resource: `${first.bucket.vectorBucketArn}/index/*`,
191 },
192 ],
193 });
194 return { bucket };
195 }),
196 );
197 yield* deployWithPolicy;
198
199 const attached = yield* findPolicy(bucketName);
200 expect(attached).toBeDefined();
201 expect(attached).toContain("s3vectors:GetVectors");
202
203 // re-deploying the same policy is a no-op (idempotent sync)
204 yield* deployWithPolicy;
205 expect(yield* findPolicy(bucketName)).toContain("s3vectors:GetVectors");
206
207 // 3. remove the policy prop — reconcile deletes the bucket policy
208 yield* stack.deploy(
209 Effect.gen(function* () {
210 const bucket = yield* VectorBucket("PolicyVectors", {
211 vectorBucketName: bucketName,
212 });
213 return { bucket };
214 }),
215 );
216 expect(yield* findPolicy(bucketName)).toBeUndefined();
217
218 yield* stack.destroy();
219 yield* assertBucketDeleted(bucketName);
220 }),
221 {
222 tags: ["provider:aws", "provider:aws:s3vectors", "live"],
223 timeout: 240_000,
224 },
225);
226
227test.provider(
228 "ordinary bucket delete protects an untracked index; force purges it",
229 (stack) =>
230 Effect.gen(function* () {
231 yield* stack.destroy();
232
233 const bucket = yield* stack.deploy(
234 Effect.gen(function* () {
235 return yield* VectorBucket("CascadeDelete", {});
236 }),
237 );
238 // Simulate an index created before state persistence. Its child provider
239 // cannot enumerate it globally, but deleting the bucket must still work.
240 yield* s3vectors.createIndex({
241 vectorBucketName: bucket.vectorBucketName,
242 indexName: "untracked-child",
243 dataType: "float32",
244 dimension: 2,
245 distanceMetric: "cosine",
246 });
247
248 const provider = yield* Provider.findProvider(VectorBucket);
249 const deleteInput = {
250 id: "CascadeDelete",
251 fqn: "CascadeDelete",
252 instanceId: "force-delete-test",
253 olds: {},
254 output: bucket,
255 session: stubSession,
256 bindings: [],
257 };
258 const protectedDelete = yield* Effect.result(
259 provider.delete(deleteInput),
260 );
261 expect(Result.isFailure(protectedDelete)).toBe(true);
262 if (Result.isFailure(protectedDelete)) {
263 expect(protectedDelete.failure._tag).toBe("ConflictException");
264 }
265 expect(
266 yield* findIndex(bucket.vectorBucketName, "untracked-child"),
267 ).toBeDefined();
268
269 yield* provider.delete({ ...deleteInput, force: true });
270 yield* assertBucketDeleted(bucket.vectorBucketName);
271 yield* stack.destroy();
272 }),
273 {
274 tags: ["provider:aws", "provider:aws:s3vectors", "live"],
275 timeout: 120_000,
276 },
277);