| 1 | import * as s3tables from "@distilled.cloud/aws/s3tables"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { AWSEnvironment, type AccountID } from "../Environment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | import type { RegionID } from "../Region.ts"; |
| 12 | |
| 13 | export 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 | */ |
| 20 | export 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 | |
| 32 | export 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 | |
| 47 | export 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 | */ |
| 97 | export const TableBucket = Resource<TableBucket>("AWS.S3Tables.TableBucket"); |
| 98 | |
| 99 | const 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 | |
| 114 | export 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 | }); |