1import * as s3vectors from "@distilled.cloud/aws/s3vectors";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { PolicyStatement } from "../IAM/Policy.ts";
12import type { Providers } from "../Providers.ts";
13
14/**
15 * Server-side encryption configuration for a vector bucket. Immutable —
16 * changing it replaces the bucket.
17 */
18export interface VectorBucketEncryption {
19 /**
20 * The SSE algorithm. `AES256` uses S3-managed keys; `aws:kms` uses a KMS
21 * key (supply `kmsKeyArn`).
22 * @default "AES256"
23 */
24 sseType?: "AES256" | "aws:kms";
25 /**
26 * ARN of the KMS key to use when `sseType` is `aws:kms`.
27 */
28 kmsKeyArn?: string;
29}
30
31export interface VectorBucketProps {
32 /**
33 * Name of the vector bucket (3-63 chars, lowercase). If omitted, a unique
34 * name is generated from the app, stage, and logical id.
35 *
36 * Changing the name replaces the bucket.
37 * @default ${app}-${stage}-${id}
38 */
39 vectorBucketName?: string;
40 /**
41 * Server-side encryption configuration. Immutable — changing it replaces
42 * the bucket.
43 */
44 encryption?: VectorBucketEncryption;
45 /**
46 * Resource policy statements for the vector bucket, granting or denying
47 * cross-account/principal access to it and the indexes inside it
48 * (`arn:…:bucket/<name>/index/*`). Rendered as a standard
49 * `2012-10-17` policy document via `PutVectorBucketPolicy`; omitting the
50 * prop removes any existing policy.
51 */
52 policy?: PolicyStatement[];
53 /**
54 * Tags to apply to the bucket. Merged with internal Alchemy tags.
55 */
56 tags?: Record<string, string>;
57}
58
59export interface VectorBucket extends Resource<
60 "AWS.S3Vectors.VectorBucket",
61 VectorBucketProps,
62 {
63 /**
64 * Name of the vector bucket.
65 */
66 vectorBucketName: string;
67 /**
68 * ARN of the vector bucket.
69 */
70 vectorBucketArn: string;
71 },
72 never,
73 Providers
74> {}
75
76/**
77 * An Amazon S3 Vectors bucket — durable storage for vector embeddings,
78 * queryable by similarity. Create one or more {@link Index}es inside it to
79 * store and query vectors.
80 *
81 * S3 Vectors is in preview; availability varies by region.
82 *
83 * ### Creating a Vector Bucket
84 * **Example:** Basic Vector Bucket
85 * ```typescript
86 * import * as S3Vectors from "alchemy/AWS/S3Vectors";
87 *
88 * const bucket = yield* S3Vectors.VectorBucket("Embeddings", {});
89 * ```
90 *
91 * **Example:** Vector Bucket with KMS Encryption
92 * ```typescript
93 * const bucket = yield* S3Vectors.VectorBucket("Embeddings", {
94 * encryption: { sseType: "aws:kms", kmsKeyArn: key.keyArn },
95 * });
96 * ```
97 *
98 * ### Bucket Policy
99 * **Example:** Grant Another Account Read Access
100 * ```typescript
101 * const bucket = yield* S3Vectors.VectorBucket("Embeddings", {
102 * vectorBucketName: "shared-embeddings",
103 * policy: [
104 * {
105 * Effect: "Allow",
106 * Principal: { AWS: "arn:aws:iam::123456789012:root" },
107 * Action: ["s3vectors:GetVectors", "s3vectors:QueryVectors"],
108 * Resource:
109 * "arn:aws:s3vectors:us-east-1:999999999999:bucket/shared-embeddings/index/*",
110 * },
111 * ],
112 * });
113 * ```
114 *
115 * @resource
116 */
117export const VectorBucket = Resource<VectorBucket>(
118 "AWS.S3Vectors.VectorBucket",
119);
120
121const encryptionKey = (e: VectorBucketEncryption | undefined) =>
122 JSON.stringify({ sseType: e?.sseType, kmsKeyArn: e?.kmsKeyArn });
123
124export const VectorBucketProvider = () =>
125 Provider.effect(
126 VectorBucket,
127 Effect.gen(function* () {
128 const createName = Effect.fn(function* (
129 id: string,
130 props: Pick<VectorBucketProps, "vectorBucketName">,
131 ) {
132 // The service rejects bucket names starting with `aws` as reserved.
133 return (
134 props.vectorBucketName ??
135 (yield* createPhysicalName({
136 id,
137 maxLength: 63,
138 forbiddenPrefixes: ["aws"],
139 })).toLowerCase()
140 );
141 });
142
143 const observe = (vectorBucketName: string) =>
144 s3vectors.getVectorBucket({ vectorBucketName }).pipe(
145 Effect.map((r) => r.vectorBucket),
146 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
147 );
148
149 const observedTags = (resourceArn: string) =>
150 s3vectors.listTagsForResource({ resourceArn }).pipe(
151 Effect.map(
152 (r) =>
153 Object.fromEntries(
154 Object.entries(r.tags ?? {}).filter(([, v]) => v !== undefined),
155 ) as Record<string, string>,
156 ),
157 Effect.catchTag("NotFoundException", () =>
158 Effect.succeed({} as Record<string, string>),
159 ),
160 );
161
162 const observedPolicy = (vectorBucketName: string) =>
163 s3vectors.getVectorBucketPolicy({ vectorBucketName }).pipe(
164 Effect.map((r) => r.policy),
165 Effect.catchTag("NotFoundException", () =>
166 Effect.succeed(undefined as string | undefined),
167 ),
168 );
169
170 const desiredEncryption = (props: VectorBucketProps) =>
171 props.encryption
172 ? {
173 sseType: props.encryption.sseType,
174 kmsKeyArn: props.encryption.kmsKeyArn,
175 }
176 : undefined;
177
178 return VectorBucket.Provider.of({
179 stables: ["vectorBucketName", "vectorBucketArn"],
180 list: () =>
181 Effect.gen(function* () {
182 const buckets = yield* s3vectors.listVectorBuckets
183 .items({})
184 .pipe(Stream.runCollect);
185 return Array.from(buckets).map((b) => ({
186 vectorBucketName: b.vectorBucketName,
187 vectorBucketArn: b.vectorBucketArn,
188 }));
189 }),
190 read: Effect.fn(function* ({ id, olds, output }) {
191 const name =
192 output?.vectorBucketName ?? (yield* createName(id, olds ?? {}));
193 const found = yield* observe(name);
194 if (!found) return undefined;
195 const attrs = {
196 vectorBucketName: found.vectorBucketName,
197 vectorBucketArn: found.vectorBucketArn,
198 };
199 const tags = yield* observedTags(found.vectorBucketArn);
200 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
201 }),
202 diff: Effect.fn(function* ({ id, news, olds }) {
203 if (!isResolved(news)) return undefined;
204 const oldName = yield* createName(id, olds ?? {});
205 const newName = yield* createName(id, news ?? {});
206 if (oldName !== newName) return { action: "replace" } as const;
207 // Encryption is fixed at create time — any change replaces.
208 if (
209 encryptionKey(olds?.encryption) !== encryptionKey(news?.encryption)
210 ) {
211 return { action: "replace" } as const;
212 }
213 // fall through: engine default update (tags only)
214 }),
215 reconcile: Effect.fn(function* ({ id, news, output, session }) {
216 const name =
217 output?.vectorBucketName ?? (yield* createName(id, news));
218 const internalTags = yield* createInternalTags(id);
219 const desiredTags = { ...news.tags, ...internalTags };
220
221 // 1. OBSERVE — cloud state is authoritative.
222 let live = yield* observe(name);
223
224 // 2. ENSURE — create when missing; a concurrent create surfaces as
225 // ConflictException, which we treat as a race and re-observe.
226 if (live === undefined) {
227 yield* s3vectors
228 .createVectorBucket({
229 vectorBucketName: name,
230 encryptionConfiguration: desiredEncryption(news),
231 tags: desiredTags,
232 })
233 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
234 live = yield* observe(name);
235 }
236
237 const vectorBucketArn =
238 live?.vectorBucketArn ?? output?.vectorBucketArn;
239
240 // 3. SYNC TAGS — diff against OBSERVED cloud tags so adoption and
241 // drift converge (create-time tags only apply on first create).
242 if (vectorBucketArn !== undefined) {
243 const currentTags = yield* observedTags(vectorBucketArn);
244 const { upsert, removed } = diffTags(currentTags, desiredTags);
245 if (upsert.length > 0) {
246 yield* s3vectors.tagResource({
247 resourceArn: vectorBucketArn,
248 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
249 });
250 }
251 if (removed.length > 0) {
252 yield* s3vectors.untagResource({
253 resourceArn: vectorBucketArn,
254 tagKeys: removed,
255 });
256 }
257 }
258
259 // 4. SYNC POLICY — diff desired against the OBSERVED bucket policy
260 // so adoption and drift converge (delete when the prop is gone).
261 const desiredPolicy =
262 news.policy !== undefined && news.policy.length > 0
263 ? JSON.stringify({
264 Version: "2012-10-17",
265 Statement: news.policy,
266 })
267 : undefined;
268 const existingPolicy = yield* observedPolicy(name);
269 if (desiredPolicy !== undefined) {
270 if (existingPolicy !== desiredPolicy) {
271 yield* s3vectors.putVectorBucketPolicy({
272 vectorBucketName: name,
273 policy: desiredPolicy,
274 });
275 yield* session.note(`Updated bucket policy: ${name}`);
276 }
277 } else if (existingPolicy !== undefined) {
278 yield* s3vectors
279 .deleteVectorBucketPolicy({ vectorBucketName: name })
280 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
281 yield* session.note(`Removed bucket policy: ${name}`);
282 }
283
284 yield* session.note(name);
285 return { vectorBucketName: name, vectorBucketArn: vectorBucketArn! };
286 }),
287 delete: Effect.fn(function* ({ output, force }) {
288 const vectorBucketName = output.vectorBucketName;
289 if (force !== true) {
290 yield* s3vectors
291 .deleteVectorBucket({ vectorBucketName })
292 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
293 return;
294 }
295
296 const purgeAndDelete = Effect.gen(function* () {
297 // Indexes are scoped to their bucket and invisible to a global
298 // nuke scan. Only the explicit force path may purge them.
299 const indexes = yield* s3vectors.listIndexes
300 .items({ vectorBucketName })
301 .pipe(
302 Stream.runCollect,
303 Effect.map((chunk) => Array.from(chunk)),
304 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
305 );
306 yield* Effect.forEach(
307 indexes,
308 (index) =>
309 s3vectors
310 .deleteIndex({
311 vectorBucketName,
312 indexName: index.indexName,
313 })
314 .pipe(
315 Effect.catchTag("NotFoundException", () => Effect.void),
316 ),
317 { concurrency: 4, discard: true },
318 );
319 yield* s3vectors.deleteVectorBucket({ vectorBucketName });
320 });
321
322 yield* purgeAndDelete.pipe(
323 // Index deletion is eventually consistent and bucket deletion can
324 // report Conflict until all indexes disappear. Re-list on retry.
325 Effect.retry({
326 while: (e) =>
327 e._tag === "ConflictException" ||
328 e._tag === "ServiceUnavailableException",
329 schedule: Schedule.max([
330 Schedule.exponential(500),
331 Schedule.recurs(8),
332 ]),
333 }),
334 Effect.catchTag("NotFoundException", () => Effect.void),
335 );
336 }),
337 });
338 }),
339 );