1import * as s3tables from "@distilled.cloud/aws/s3tables";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { AWSEnvironment, type AccountID } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import type { RegionID } from "../Region.ts";
12
13export type TableBucketArn =
14 `arn:aws:s3tables:${RegionID}:${AccountID}:bucket/${string}`;
15
16/**
17 * Server-side encryption configuration for an S3 table bucket. Applied at
18 * create time; changing it replaces the bucket.
19 */
20export interface TableBucketEncryption {
21 /**
22 * Encryption algorithm. `AES256` uses S3-managed keys (SSE-S3); `aws:kms`
23 * uses AWS KMS (SSE-KMS) and requires `kmsKeyArn`.
24 */
25 sseAlgorithm: "AES256" | "aws:kms";
26 /**
27 * ARN of the KMS key to use when `sseAlgorithm` is `aws:kms`.
28 */
29 kmsKeyArn?: string;
30}
31
32export interface TableBucketProps {
33 /**
34 * Name of the table bucket. Must be 3-63 characters, lowercase letters,
35 * numbers, and hyphens, beginning and ending with a letter or number.
36 * Changing the name replaces the bucket.
37 * @default a deterministic name derived from `${app}-${id}-${stage}`
38 */
39 name?: string;
40 /**
41 * Default server-side encryption for tables in this bucket. Applied at
42 * create time; changing it replaces the bucket.
43 */
44 encryptionConfiguration?: TableBucketEncryption;
45}
46
47export interface TableBucket extends Resource<
48 "AWS.S3Tables.TableBucket",
49 TableBucketProps,
50 {
51 tableBucketArn: TableBucketArn;
52 name: string;
53 ownerAccountId: string;
54 createdAt: Date;
55 tableBucketId: string | undefined;
56 type: string | undefined;
57 },
58 never,
59 Providers
60> {}
61
62/**
63 * An Amazon S3 Tables table bucket — a purpose-built bucket for storing
64 * fully-managed Apache Iceberg tables.
65 *
66 * A table bucket is regional and holds {@link Namespace}s, which in turn hold
67 * {@link Table}s. The bucket name is auto-generated from the app, stage, and
68 * logical ID unless you provide one explicitly.
69 * ### Creating Table Buckets
70 * **Example:** Basic Table Bucket
71 * ```typescript
72 * import * as S3Tables from "alchemy/AWS/S3Tables";
73 *
74 * const bucket = yield* S3Tables.TableBucket("Analytics");
75 * ```
76 *
77 * **Example:** Named Table Bucket
78 * ```typescript
79 * const bucket = yield* S3Tables.TableBucket("Analytics", {
80 * name: "my-analytics-tables",
81 * });
82 * ```
83 *
84 * ### Encryption
85 * **Example:** KMS-encrypted Table Bucket
86 * ```typescript
87 * const bucket = yield* S3Tables.TableBucket("Secure", {
88 * encryptionConfiguration: {
89 * sseAlgorithm: "aws:kms",
90 * kmsKeyArn: key.keyArn,
91 * },
92 * });
93 * ```
94 *
95 * @resource
96 */
97export const TableBucket = Resource<TableBucket>("AWS.S3Tables.TableBucket");
98
99const createBucketName = (id: string, props: { name?: string | undefined }) =>
100 Effect.gen(function* () {
101 if (props.name) {
102 return props.name;
103 }
104 // Table-bucket names follow S3-bucket DNS rules: lowercase, 3-63 chars,
105 // and must not start with the service-reserved prefixes.
106 return yield* createPhysicalName({
107 id,
108 maxLength: 63,
109 lowercase: true,
110 forbiddenPrefixes: ["xn--", "sthree-", "amzn-s3-demo-", "aws"],
111 });
112 });
113
114export const TableBucketProvider = () =>
115 Provider.succeed(TableBucket, {
116 stables: ["tableBucketArn", "name", "ownerAccountId"],
117 // Top-level resource — enumerate every table bucket in the ambient
118 // account/region so the engine can detect drift/adoption.
119 list: Effect.fn(function* () {
120 const buckets = yield* s3tables.listTableBuckets.pages({}).pipe(
121 Stream.runCollect,
122 Effect.map((chunk) =>
123 Array.from(chunk).flatMap((page) => page.tableBuckets ?? []),
124 ),
125 );
126 return buckets.map((b): TableBucket["Attributes"] => ({
127 tableBucketArn: b.arn as TableBucketArn,
128 name: b.name,
129 ownerAccountId: b.ownerAccountId,
130 createdAt: b.createdAt,
131 tableBucketId: b.tableBucketId,
132 type: b.type,
133 }));
134 }),
135 read: Effect.fn(function* ({ id, olds, output }) {
136 const { accountId, region } = yield* AWSEnvironment.current;
137 const name = output?.name ?? (yield* createBucketName(id, olds ?? {}));
138 const arn =
139 output?.tableBucketArn ??
140 (`arn:aws:s3tables:${region}:${accountId}:bucket/${name}` as TableBucketArn);
141 return yield* s3tables.getTableBucket({ tableBucketARN: arn }).pipe(
142 Effect.map((b): TableBucket["Attributes"] => ({
143 tableBucketArn: b.arn as TableBucketArn,
144 name: b.name,
145 ownerAccountId: b.ownerAccountId,
146 createdAt: b.createdAt,
147 tableBucketId: b.tableBucketId,
148 type: b.type,
149 })),
150 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
151 );
152 }),
153 diff: Effect.fn(function* ({ id, news = {}, olds = {} }) {
154 if (!isResolved(news)) return;
155 const oldName = yield* createBucketName(id, olds);
156 const newName = yield* createBucketName(id, news);
157 if (oldName !== newName) {
158 return { action: "replace" } as const;
159 }
160 // Encryption is set at create time and cannot be changed in place.
161 const oldEnc = JSON.stringify(olds.encryptionConfiguration ?? null);
162 const newEnc = JSON.stringify(news.encryptionConfiguration ?? null);
163 if (oldEnc !== newEnc) {
164 return { action: "replace" } as const;
165 }
166 }),
167 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
168 const { accountId, region } = yield* AWSEnvironment.current;
169 const name = output?.name ?? (yield* createBucketName(id, news));
170 const arn =
171 output?.tableBucketArn ??
172 (`arn:aws:s3tables:${region}:${accountId}:bucket/${name}` as TableBucketArn);
173
174 // Observe — cloud state is authoritative. `output` is only a cache for
175 // the deterministic ARN; the bucket may have been deleted out-of-band.
176 let bucket = yield* s3tables.getTableBucket({ tableBucketARN: arn }).pipe(
177 Effect.map((b) => b),
178 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
179 );
180
181 // Ensure — create if missing. Tolerate a concurrent create
182 // (`ConflictException`) as a race and fall through to re-read.
183 if (bucket === undefined) {
184 yield* s3tables
185 .createTableBucket({
186 name,
187 encryptionConfiguration: news.encryptionConfiguration,
188 })
189 .pipe(
190 Effect.asVoid,
191 Effect.catchTag("ConflictException", () => Effect.void),
192 );
193 // The control plane is eventually consistent: `getTableBucket` can
194 // briefly 404 a bucket that `createTableBucket` just returned. Ride
195 // out that propagation window on a bounded schedule.
196 bucket = yield* s3tables.getTableBucket({ tableBucketARN: arn }).pipe(
197 Effect.retry({
198 while: (e) => e._tag === "NotFoundException",
199 schedule: Schedule.max([
200 Schedule.exponential(500),
201 Schedule.recurs(8),
202 ]),
203 }),
204 );
205 }
206
207 yield* session.note(bucket.arn);
208 return {
209 tableBucketArn: bucket.arn as TableBucketArn,
210 name: bucket.name,
211 ownerAccountId: bucket.ownerAccountId,
212 createdAt: bucket.createdAt,
213 tableBucketId: bucket.tableBucketId,
214 type: bucket.type,
215 };
216 }),
217 delete: Effect.fn(function* ({ output, force }) {
218 const tableBucketARN = output.tableBucketArn;
219 if (force !== true) {
220 yield* s3tables.deleteTableBucket({ tableBucketARN }).pipe(
221 // Tracked children are normally deleted first, but their deletion
222 // can take a few seconds to propagate. Do not purge untracked data
223 // during ordinary destroy: a persistent Conflict is protective.
224 Effect.retry({
225 while: (e) => e._tag === "ConflictException",
226 schedule: Schedule.max([Schedule.fixed(500), Schedule.recurs(8)]),
227 }),
228 Effect.catchTag("NotFoundException", () => Effect.void),
229 );
230 return;
231 }
232
233 const purgeAndDelete = Effect.gen(function* () {
234 // Nuke explicitly sets force after operator confirmation. It can
235 // discover a top-level bucket even when child state was never saved,
236 // so only this path may purge globally-invisible children.
237 const tables = yield* s3tables.listTables
238 .items({ tableBucketARN })
239 .pipe(
240 Stream.runCollect,
241 Effect.map((chunk) => Array.from(chunk)),
242 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
243 );
244 yield* Effect.forEach(
245 tables,
246 (table) =>
247 s3tables
248 .deleteTable({
249 tableBucketARN,
250 namespace: table.namespace[0]!,
251 name: table.name,
252 })
253 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
254 { concurrency: 4, discard: true },
255 );
256
257 const namespaces = yield* s3tables.listNamespaces
258 .items({ tableBucketARN })
259 .pipe(
260 Stream.runCollect,
261 Effect.map((chunk) => Array.from(chunk)),
262 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
263 );
264 yield* Effect.forEach(
265 namespaces,
266 (namespace) =>
267 s3tables
268 .deleteNamespace({
269 tableBucketARN,
270 namespace: namespace.namespace[0]!,
271 })
272 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
273 { concurrency: 4, discard: true },
274 );
275
276 yield* s3tables.deleteTableBucket({ tableBucketARN });
277 });
278
279 yield* purgeAndDelete.pipe(
280 // Deletes propagate asynchronously. Re-listing on every bounded retry
281 // also catches children that were briefly absent from a prior list.
282 Effect.retry({
283 while: (e) =>
284 e._tag === "ConflictException" ||
285 e._tag === "TooManyRequestsException" ||
286 e._tag === "InternalServerErrorException",
287 schedule: Schedule.max([
288 Schedule.exponential(500),
289 Schedule.recurs(8),
290 ]),
291 }),
292 Effect.catchTag("NotFoundException", () => Effect.void),
293 );
294 }),
295 });