1import * as AWS from "@/AWS";
2import { Namespace, Table, TableBucket } from "@/AWS/S3Tables";
3import type { ScopedPlanStatusSession } from "@/Report.ts";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as s3tables from "@distilled.cloud/aws/s3tables";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as Stream from "effect/Stream";
11import * as Result from "effect/Result";
12
13const { test } = Test.make({ providers: AWS.providers() });
14const stubSession = {
15 note: () => Effect.void,
16} as unknown as ScopedPlanStatusSession;
17
18// Auto-generated table-bucket names are `${testNameSlug}-${id}-${stage}-`
19// truncated + a random instance-id suffix. A run killed mid-deploy (before
20// state persists) leaves buckets that `stack.destroy()` cannot see, and the
21// suffix changes on every run so a re-run never reclaims them by name. Sweep
22// by the deterministic prefix at test start so a passing run always implies
23// a clean account. MUST match the lifecycle test's name slug below.
24const LIFECYCLE_BUCKET_PREFIX = "table-bucket-namespace-table-lifecycle-";
25
26const purgeOrphanedTableBuckets = Effect.gen(function* () {
27 const buckets = yield* s3tables.listTableBuckets.pages({}).pipe(
28 Stream.runCollect,
29 Effect.map((chunk) =>
30 Array.from(chunk).flatMap((page) => page.tableBuckets ?? []),
31 ),
32 );
33 yield* Effect.forEach(
34 buckets.filter((b) => b.name.startsWith(LIFECYCLE_BUCKET_PREFIX)),
35 (bucket) =>
36 Effect.gen(function* () {
37 const tables = yield* s3tables.listTables
38 .pages({ tableBucketARN: bucket.arn })
39 .pipe(
40 Stream.runCollect,
41 Effect.map((chunk) =>
42 Array.from(chunk).flatMap((page) => page.tables ?? []),
43 ),
44 );
45 yield* Effect.forEach(tables, (t) =>
46 s3tables
47 .deleteTable({
48 tableBucketARN: bucket.arn,
49 namespace: t.namespace[0]!,
50 name: t.name,
51 })
52 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
53 );
54 const namespaces = yield* s3tables.listNamespaces
55 .pages({ tableBucketARN: bucket.arn })
56 .pipe(
57 Stream.runCollect,
58 Effect.map((chunk) =>
59 Array.from(chunk).flatMap((page) => page.namespaces ?? []),
60 ),
61 );
62 yield* Effect.forEach(namespaces, (ns) =>
63 s3tables
64 .deleteNamespace({
65 tableBucketARN: bucket.arn,
66 namespace: ns.namespace[0]!,
67 })
68 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
69 );
70 yield* s3tables.deleteTableBucket({ tableBucketARN: bucket.arn }).pipe(
71 // Child deletes are eventually consistent; ride out the window.
72 Effect.retry({
73 while: (e) => e._tag === "ConflictException",
74 schedule: Schedule.max([
75 Schedule.exponential(500),
76 Schedule.recurs(8),
77 ]),
78 }),
79 Effect.catchTag("NotFoundException", () => Effect.void),
80 );
81 }),
82 );
83});
84
85// Poll a getter until it reports the resource is gone (typed
86// NotFoundException), bounded so a stuck delete fails fast.
87const waitUntilGone = <A, E extends { _tag: string }, R>(
88 read: Effect.Effect<A, E, R>,
89) =>
90 read.pipe(
91 Effect.flatMap(() =>
92 Effect.fail({ _tag: "StillExists" as const } as const),
93 ),
94 Effect.retry({
95 while: (e: { _tag: string }) => e._tag === "StillExists",
96 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
97 }),
98 Effect.catchIf(
99 (e): e is E & { _tag: "NotFoundException" } =>
100 e._tag === "NotFoundException",
101 () => Effect.void,
102 ),
103 );
104
105test.provider(
106 "table bucket → namespace → table lifecycle",
107 (stack) =>
108 Effect.gen(function* () {
109 yield* stack.destroy();
110 yield* purgeOrphanedTableBuckets;
111
112 const { bucket, namespace, table } = yield* stack.deploy(
113 Effect.gen(function* () {
114 const bucket = yield* TableBucket("Analytics");
115 const namespace = yield* Namespace("Events", {
116 tableBucket: bucket.tableBucketArn,
117 });
118 const table = yield* Table("PageViews", {
119 tableBucket: bucket.tableBucketArn,
120 namespace: namespace.namespace,
121 schema: {
122 fields: [
123 { name: "id", type: "long", required: true },
124 { name: "url", type: "string" },
125 { name: "ts", type: "timestamp" },
126 ],
127 },
128 });
129 return { bucket, namespace, table };
130 }),
131 );
132
133 expect(bucket.tableBucketArn).toContain(":bucket/");
134 expect(bucket.ownerAccountId).toBeDefined();
135 expect(table.tableArn).toContain("/table/");
136 expect(table.format).toBe("ICEBERG");
137 expect(table.warehouseLocation).toBeDefined();
138
139 // Out-of-band verification via distilled.
140 const observedBucket = yield* s3tables.getTableBucket({
141 tableBucketARN: bucket.tableBucketArn,
142 });
143 expect(observedBucket.name).toBe(bucket.name);
144
145 const observedNs = yield* s3tables.getNamespace({
146 tableBucketARN: bucket.tableBucketArn,
147 namespace: namespace.namespace,
148 });
149 expect(observedNs.namespace[0]).toBe(namespace.namespace);
150
151 const observedTable = yield* s3tables.getTable({
152 tableBucketARN: bucket.tableBucketArn,
153 namespace: namespace.namespace,
154 name: table.name,
155 });
156 expect(observedTable.tableARN).toBe(table.tableArn);
157
158 // Idempotent re-deploy: reconciler observes existing cloud state and
159 // converges without error.
160 yield* stack.deploy(
161 Effect.gen(function* () {
162 const bucket = yield* TableBucket("Analytics");
163 const namespace = yield* Namespace("Events", {
164 tableBucket: bucket.tableBucketArn,
165 });
166 yield* Table("PageViews", {
167 tableBucket: bucket.tableBucketArn,
168 namespace: namespace.namespace,
169 schema: {
170 fields: [
171 { name: "id", type: "long", required: true },
172 { name: "url", type: "string" },
173 { name: "ts", type: "timestamp" },
174 ],
175 },
176 });
177 return { bucket };
178 }),
179 );
180
181 yield* stack.destroy();
182
183 // The bucket (and everything under it) must be gone.
184 yield* waitUntilGone(
185 s3tables.getTableBucket({ tableBucketARN: bucket.tableBucketArn }),
186 );
187 }),
188 { tags: ["provider:aws", "provider:aws:s3tables", "live"], timeout: 180_000 },
189);
190
191test.provider(
192 "table bucket replaces on name change",
193 (stack) =>
194 Effect.gen(function* () {
195 yield* stack.destroy();
196
197 const first = yield* stack.deploy(
198 Effect.gen(function* () {
199 return yield* TableBucket("Renamed");
200 }),
201 );
202 expect(first.name).toBeDefined();
203 yield* s3tables.getTableBucket({ tableBucketARN: first.tableBucketArn });
204
205 // Derive the replacement name from the engine-generated physical name.
206 // Reusing fixed bucket names across test runs races AWS's deletion
207 // tombstone and can return Conflict for minutes after Get reports 404.
208 const replacementName = `${first.name.slice(0, 55)}-renamed`;
209 const second = yield* stack.deploy(
210 Effect.gen(function* () {
211 return yield* TableBucket("Renamed", {
212 name: replacementName,
213 });
214 }),
215 );
216 expect(second.name).toBe(replacementName);
217 expect(second.tableBucketArn).not.toBe(first.tableBucketArn);
218
219 // The old bucket must have been deleted as part of the replacement.
220 yield* waitUntilGone(
221 s3tables.getTableBucket({ tableBucketARN: first.tableBucketArn }),
222 );
223
224 yield* stack.destroy();
225 yield* waitUntilGone(
226 s3tables.getTableBucket({ tableBucketARN: second.tableBucketArn }),
227 );
228 }),
229 { tags: ["provider:aws", "provider:aws:s3tables", "live"], timeout: 180_000 },
230);
231
232test.provider(
233 "ordinary bucket delete protects untracked children; force purges them",
234 (stack) =>
235 Effect.gen(function* () {
236 const provider = yield* Provider.findProvider(TableBucket);
237 const initialDestroy = yield* Effect.result(stack.destroy());
238 if (Result.isFailure(initialDestroy)) {
239 // Recover only this test's deterministic leftovers from an interrupted
240 // protection assertion. Ordinary destroy intentionally cannot remove
241 // them, so use the same explicit force path nuke uses.
242 const stale = (yield* provider.list()).filter((bucket) =>
243 bucket.name.startsWith(
244 "ordinary-bucket-delete-protects-untracked-child",
245 ),
246 );
247 yield* Effect.forEach(
248 stale,
249 (output) =>
250 provider.delete({
251 id: "CascadeDeleteRecovery",
252 fqn: "CascadeDeleteRecovery",
253 instanceId: "force-delete-recovery",
254 olds: {},
255 output,
256 session: stubSession,
257 bindings: [],
258 force: true,
259 }),
260 { discard: true },
261 );
262 yield* stack.destroy();
263 }
264
265 const bucket = yield* stack.deploy(
266 Effect.gen(function* () {
267 return yield* TableBucket("CascadeDelete");
268 }),
269 );
270 // Simulate a run interrupted before child state was persisted. Global
271 // nuke can see the bucket but cannot enumerate Namespace providers.
272 yield* s3tables.createNamespace({
273 tableBucketARN: bucket.tableBucketArn,
274 namespace: ["untracked_child"],
275 });
276
277 const deleteInput = {
278 id: "CascadeDelete",
279 fqn: "CascadeDelete",
280 instanceId: "force-delete-test",
281 olds: {},
282 output: bucket,
283 session: stubSession,
284 bindings: [],
285 };
286 const protectedDelete = yield* Effect.result(
287 provider.delete(deleteInput),
288 );
289 expect(Result.isFailure(protectedDelete)).toBe(true);
290 if (Result.isFailure(protectedDelete)) {
291 expect(protectedDelete.failure._tag).toBe("BadRequestException");
292 }
293 yield* s3tables.getNamespace({
294 tableBucketARN: bucket.tableBucketArn,
295 namespace: "untracked_child",
296 });
297
298 yield* provider.delete({ ...deleteInput, force: true });
299 yield* waitUntilGone(
300 s3tables.getTableBucket({ tableBucketARN: bucket.tableBucketArn }),
301 );
302 // Clear the now-stale stack state through the normal idempotent path.
303 yield* stack.destroy();
304 }),
305 { tags: ["provider:aws", "provider:aws:s3tables", "live"], timeout: 120_000 },
306);