Skip to content
Source
Sign inSign up

Table.test.ts

1,487 lines60.1 KB

1import * as AWS from "@alchemy.run/aws";
2import { Table } from "@alchemy.run/aws/DynamoDB";
3import { Stream as KinesisStream } from "@alchemy.run/aws/Kinesis";
4import { describe, expect } from "@alchemy.run/test";
5import * as Test from "@alchemy.run/test/Alchemy";
6import * as CloudWatch from "@distilled.cloud/aws/cloudwatch";
7import * as DynamoDB from "@distilled.cloud/aws/dynamodb";
8import { adopt } from "alchemy/AdoptPolicy";
9import * as Drift from "alchemy/Drift";
10import * as Provider from "alchemy/Provider";
11import { isResourceState, State, type ResourceState } from "alchemy/State";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as Schedule from "effect/Schedule";
15​
16const { test } = Test.make({ providers: AWS.providers() });
17​
18describe.skipIf(!!process.env.FAST)(
19 "AWS.DynamoDB.Table",
20 { tags: ["provider:aws", "provider:aws:dynamodb", "live"] },
21 () => {
22 const longGlobalSecondaryIndexStabilization = Schedule.max([
23 Schedule.fixed("10 seconds"),
24 Schedule.recurs(180),
25 ]);
26​
27 test.provider("create, update, delete table", (stack) =>
28 Effect.gen(function* () {
29 yield* logTestStep("starting create/update/delete table");
30 yield* stack.destroy();
31​
32 yield* logTestStep("deploying base table");
33 const table = yield* stack.deploy(
34 Effect.gen(function* () {
35 return yield* Table("TestTable", { partitionKey: "id", attributes: { id: "S" } });
36 }),
37 );
38​
39 const actualTable = yield* DynamoDB.describeTable({ TableName: table.tableName });
40 expect(actualTable.Table?.TableArn).toEqual(table.tableArn);
41​
42 yield* logTestStep("destroying base table");
43 yield* stack.destroy();
44​
45 yield* assertTableIsDeleted(table.tableName);
46 }),
47 );
48​
49 test.provider(
50 "list enumerates the deployed table",
51 (stack) =>
52 Effect.gen(function* () {
53 yield* stack.destroy();
54​
55 const table = yield* stack.deploy(
56 Effect.gen(function* () {
57 return yield* Table("ListTable", { partitionKey: "id", attributes: { id: "S" } });
58 }),
59 );
60​
61 const provider = yield* Provider.findProvider(Table);
62 const all = yield* provider.list();
63​
64 const found = all.find((t) => t.tableName === table.tableName);
65 expect(found).toBeDefined();
66 expect(found?.tableArn).toEqual(table.tableArn);
67​
68 yield* stack.destroy();
69 yield* assertTableIsDeleted(table.tableName);
70 }),
71 // The testing account accumulates many tables; hydrating each to full
72 // Attributes (describe + tags + PITR + TTL) under control-plane throttle
73 // limits needs more than the default per-test budget.
74 { timeout: 240_000 },
75 );
76​
77 test.provider(
78 "create, update, and disable table stream configuration through bindings",
79 (stack) =>
80 Effect.gen(function* () {
81 yield* logTestStep("starting stream configuration test");
82 yield* stack.destroy();
83​
84 yield* logTestStep("deploying table with binding-owned stream");
85 const table = yield* stack.deploy(
86 Effect.gen(function* () {
87 const table = yield* Table("StreamTable", {
88 partitionKey: "id",
89 attributes: { id: "S" },
90 });
91 yield* table.bind("TestTableStreamBinding", {
92 streamSpecification: { StreamEnabled: true, StreamViewType: "NEW_AND_OLD_IMAGES" },
93 });
94 return table;
95 }),
96 );
97​
98 const created = yield* waitForTableStreamSpecification(table.tableName, {
99 StreamEnabled: true,
100 StreamViewType: "NEW_AND_OLD_IMAGES",
101 });
102 expect(created.Table?.StreamSpecification).toEqual({
103 StreamEnabled: true,
104 StreamViewType: "NEW_AND_OLD_IMAGES",
105 });
106 expect(created.Table?.LatestStreamArn).toBeDefined();
107​
108 yield* logTestStep("updating stream view type to KEYS_ONLY");
109 yield* stack.deploy(
110 Effect.gen(function* () {
111 const table = yield* Table("StreamTable", {
112 partitionKey: "id",
113 attributes: { id: "S" },
114 });
115 yield* table.bind("TestTableStreamBinding", {
116 streamSpecification: { StreamEnabled: true, StreamViewType: "KEYS_ONLY" },
117 });
118 return table;
119 }),
120 );
121​
122 const updated = yield* waitForTableStreamSpecification(table.tableName, {
123 StreamEnabled: true,
124 StreamViewType: "KEYS_ONLY",
125 });
126 expect(updated.Table?.StreamSpecification).toEqual({
127 StreamEnabled: true,
128 StreamViewType: "KEYS_ONLY",
129 });
130​
131 yield* logTestStep("removing stream binding");
132 yield* stack.deploy(
133 Effect.gen(function* () {
134 return yield* Table("StreamTable", { partitionKey: "id", attributes: { id: "S" } });
135 }),
136 );
137​
138 const disabled = yield* waitForTableStreamSpecification(table.tableName, undefined);
139 expect(disabled.Table?.StreamSpecification).toBeUndefined();
140​
141 yield* logTestStep("destroying stream table");
142 yield* stack.destroy();
143​
144 yield* assertTableIsDeleted(table.tableName);
145 }),
146 );
147​
148 test.provider(
149 "create and update table tags and point-in-time recovery",
150 (stack) =>
151 Effect.gen(function* () {
152 yield* logTestStep("starting tags and point-in-time recovery test");
153 yield* stack.destroy();
154​
155 yield* logTestStep("deploying tagged table with PITR enabled");
156 const table = yield* stack.deploy(
157 Effect.gen(function* () {
158 return yield* Table("TaggedTable", {
159 partitionKey: "id",
160 attributes: { id: "S" },
161 tags: { Environment: "test" },
162 pointInTimeRecoverySpecification: {
163 pointInTimeRecoveryEnabled: true,
164 recoveryPeriod: "7 days",
165 },
166 });
167 }),
168 );
169​
170 const createdTags = yield* waitForTableTags(table.tableArn, { Environment: "test" });
171 expect(createdTags["Environment"]).toEqual("test");
172 expect(table.tags?.["Environment"]).toEqual("test");
173​
174 const createdBackups = yield* waitForPointInTimeRecovery(table.tableName, true);
175 expect(createdBackups.PointInTimeRecoveryDescription?.PointInTimeRecoveryStatus).toEqual(
176 "ENABLED",
177 );
178 expect(createdBackups.PointInTimeRecoveryDescription?.RecoveryPeriodInDays).toEqual(7);
179​
180 yield* logTestStep("updating tags and disabling PITR");
181 const updated = yield* stack.deploy(
182 Effect.gen(function* () {
183 return yield* Table("TaggedTable", {
184 partitionKey: "id",
185 attributes: { id: "S" },
186 tags: { Environment: "prod", Team: "platform" },
187 pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: false },
188 });
189 }),
190 );
191​
192 const updatedTags = yield* waitForTableTags(updated.tableArn, {
193 Environment: "prod",
194 Team: "platform",
195 });
196 expect(updatedTags["Environment"]).toEqual("prod");
197 expect(updatedTags["Team"]).toEqual("platform");
198 expect(updated.tags?.["Environment"]).toEqual("prod");
199 expect(updated.tags?.["Team"]).toEqual("platform");
200​
201 const updatedBackups = yield* waitForPointInTimeRecovery(updated.tableName, false);
202 expect(updatedBackups.PointInTimeRecoveryDescription?.PointInTimeRecoveryStatus).toEqual(
203 "DISABLED",
204 );
205​
206 yield* logTestStep("destroying tagged table");
207 yield* stack.destroy();
208​
209 yield* assertTableIsDeleted(table.tableName);
210 }),
211 { timeout: 240_000 },
212 );
213​
214 test.provider(
215 "create, update, and remove the table resource policy",
216 (stack) =>
217 Effect.gen(function* () {
218 yield* logTestStep("starting resource policy test");
219 yield* stack.destroy();
220​
221 // The policy document references the table ARN, so deploy the table
222 // first and add the policy in a second deploy.
223 yield* logTestStep("deploying table without a resource policy");
224 const table = yield* stack.deploy(
225 Effect.gen(function* () {
226 return yield* Table("PolicyTable", { partitionKey: "id", attributes: { id: "S" } });
227 }),
228 );
229​
230 const accountId = table.tableArn.split(":")[4];
231 const policyFor = (sid: string, action: string) =>
232 JSON.stringify({
233 Version: "2012-10-17",
234 Statement: [
235 {
236 Sid: sid,
237 Effect: "Allow",
238 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
239 Action: [action],
240 Resource: table.tableArn,
241 },
242 ],
243 });
244​
245 yield* logTestStep("adding a resource policy");
246 yield* stack.deploy(
247 Effect.gen(function* () {
248 return yield* Table("PolicyTable", {
249 partitionKey: "id",
250 attributes: { id: "S" },
251 resourcePolicy: policyFor("AllowSameAccountDescribe", "dynamodb:DescribeTable"),
252 });
253 }),
254 );
255 const created = yield* waitForResourcePolicy(table.tableArn, "AllowSameAccountDescribe");
256 expect(created).toContain("AllowSameAccountDescribe");
257​
258 yield* logTestStep("updating the resource policy");
259 yield* stack.deploy(
260 Effect.gen(function* () {
261 return yield* Table("PolicyTable", {
262 partitionKey: "id",
263 attributes: { id: "S" },
264 resourcePolicy: policyFor("AllowSameAccountGet", "dynamodb:GetItem"),
265 });
266 }),
267 );
268 const updated = yield* waitForResourcePolicy(table.tableArn, "AllowSameAccountGet");
269 expect(updated).toContain("AllowSameAccountGet");
270 expect(updated).not.toContain("AllowSameAccountDescribe");
271​
272 yield* logTestStep("removing the resource policy");
273 yield* stack.deploy(
274 Effect.gen(function* () {
275 return yield* Table("PolicyTable", { partitionKey: "id", attributes: { id: "S" } });
276 }),
277 );
278 const removed = yield* waitForResourcePolicy(table.tableArn, undefined);
279 expect(removed).toBeUndefined();
280​
281 yield* logTestStep("destroying resource policy test table");
282 yield* stack.destroy();
283 yield* assertTableIsDeleted(table.tableName);
284 }),
285 { timeout: 240_000 },
286 );
287​
288 // NOTE: a live precision UPDATE on an ACTIVE destination
289 // (updateKinesisStreamingDestination) is implemented in the provider but
290 // not exercised here: the UPDATING→ACTIVE transition takes multiple
291 // minutes (vs seconds for enable/disable), which blows the test budget.
292 // The enable path below sets an explicit precision, verifying the
293 // configuration plumbing out-of-band.
294 test.provider(
295 "enable and disable the Kinesis streaming destination",
296 (stack) =>
297 Effect.gen(function* () {
298 yield* logTestStep("starting Kinesis streaming destination test");
299 yield* stack.destroy();
300​
301 const program = (enabled: boolean) =>
302 Effect.gen(function* () {
303 const stream = yield* KinesisStream("CdcStream", {
304 streamMode: "PROVISIONED",
305 shardCount: 1,
306 });
307 const table = yield* Table("CdcTable", {
308 partitionKey: "id",
309 attributes: { id: "S" },
310 kinesisStreamingDestination: enabled
311 ? {
312 streamArn: stream.streamArn,
313 approximateCreationDateTimePrecision: "MICROSECOND",
314 }
315 : undefined,
316 });
317 return { table, stream };
318 });
319​
320 yield* logTestStep("deploying stream + table with streaming destination");
321 const { table, stream } = yield* stack.deploy(program(true));
322​
323 const enabled = yield* waitForKinesisDestination(
324 table.tableName,
325 stream.streamArn,
326 "ACTIVE",
327 );
328 expect(enabled?.DestinationStatus).toEqual("ACTIVE");
329 expect(enabled?.ApproximateCreationDateTimePrecision).toEqual("MICROSECOND");
330​
331 yield* logTestStep("removing the streaming destination (stream stays deployed)");
332 yield* stack.deploy(program(false));
333 const disabled = yield* waitForKinesisDestination(
334 table.tableName,
335 stream.streamArn,
336 "DISABLED",
337 );
338 expect(disabled?.DestinationStatus).toEqual("DISABLED");
339​
340 yield* logTestStep("destroying Kinesis streaming destination stack");
341 yield* stack.destroy();
342 yield* assertTableIsDeleted(table.tableName);
343 }),
344 { tags: ["provider:aws:kinesis"], timeout: 360_000 },
345 );
346​
347 test.provider(
348 "enable and disable Contributor Insights",
349 (stack) =>
350 Effect.gen(function* () {
351 yield* logTestStep("starting Contributor Insights test");
352 yield* stack.destroy();
353​
354 yield* logTestStep("deploying table with Contributor Insights");
355 const table = yield* stack.deploy(
356 Effect.gen(function* () {
357 return yield* Table("InsightsTable", {
358 partitionKey: "id",
359 attributes: { id: "S" },
360 contributorInsightsEnabled: true,
361 });
362 }),
363 );
364​
365 const enabled = yield* waitForContributorInsights(table.tableName, true);
366 expect(["ENABLING", "ENABLED"]).toContain(enabled);
367​
368 yield* logTestStep("disabling Contributor Insights");
369 yield* stack.deploy(
370 Effect.gen(function* () {
371 return yield* Table("InsightsTable", { partitionKey: "id", attributes: { id: "S" } });
372 }),
373 );
374​
375 const disabled = yield* waitForContributorInsights(table.tableName, false);
376 expect(["DISABLING", "DISABLED"]).toContain(disabled);
377​
378 yield* logTestStep("destroying Contributor Insights test table");
379 yield* stack.destroy();
380 yield* assertTableIsDeleted(table.tableName);
381 yield* assertNoContributorInsightsRules(table.tableName);
382 }),
383 { tags: ["provider:aws:cloudwatch"], timeout: 240_000 },
384 );
385​
386 test.provider(
387 "destroying a table with Contributor Insights still enabled leaves no CloudWatch rules",
388 (stack) =>
389 Effect.gen(function* () {
390 yield* logTestStep("starting Contributor Insights destroy-while-enabled test");
391 yield* stack.destroy();
392​
393 yield* logTestStep("deploying table with Contributor Insights");
394 const table = yield* stack.deploy(
395 Effect.gen(function* () {
396 return yield* Table("InsightsDestroyTable", {
397 partitionKey: "id",
398 attributes: { id: "S" },
399 contributorInsightsEnabled: true,
400 });
401 }),
402 );
403​
404 const enabled = yield* waitForContributorInsights(table.tableName, true);
405 expect(["ENABLING", "ENABLED"]).toContain(enabled);
406​
407 // Destroy WITHOUT disabling insights first. DynamoDB's CloudWatch
408 // insight rules can only be removed by DynamoDB's own DISABLE
409 // cleanup while the table exists, so the provider's delete must
410 // tear insights down before deleteTable — otherwise the rules are
411 // stranded forever (CloudWatch rejects direct deletion).
412 yield* logTestStep("destroying table while insights are enabled");
413 yield* stack.destroy();
414 yield* assertTableIsDeleted(table.tableName);
415 yield* assertNoContributorInsightsRules(table.tableName);
416 }),
417 { tags: ["provider:aws:cloudwatch"], timeout: 240_000 },
418 );
419​
420 test.provider(
421 "create table with folded local and global secondary indexes",
422 (stack) =>
423 Effect.gen(function* () {
424 yield* logTestStep("starting folded index create test");
425 yield* stack.destroy();
426​
427 yield* logTestStep("deploying table with LSI and GSI");
428 const table = yield* stack.deploy(
429 Effect.gen(function* () {
430 return yield* Table("IndexedTable", {
431 partitionKey: "pk",
432 sortKey: "sk",
433 attributes: { pk: "S", sk: "S", gsi1pk: "S" },
434 localSecondaryIndexes: [
435 { indexName: "lsi-by-sk", sortKey: "sk", projection: { ProjectionType: "ALL" } },
436 ],
437 globalSecondaryIndexes: [
438 {
439 indexName: "gsi-by-lookup",
440 partitionKey: "gsi1pk",
441 projection: { ProjectionType: "ALL" },
442 },
443 ],
444 });
445 }),
446 );
447​
448 const actualTable = yield* Effect.gen(function* () {
449 const current = yield* DynamoDB.describeTable({ TableName: table.tableName });
450 if (
451 current.Table?.GlobalSecondaryIndexes?.some((index) => index.IndexStatus !== "ACTIVE")
452 ) {
453 return yield* Effect.fail(new GlobalSecondaryIndexNotActive());
454 }
455 return current.Table;
456 }).pipe(
457 Effect.retry({
458 while: (error) => error._tag === "GlobalSecondaryIndexNotActive",
459 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(30)]),
460 }),
461 );
462​
463 expect(actualTable?.LocalSecondaryIndexes?.map((index) => index.IndexName)).toContain(
464 "lsi-by-sk",
465 );
466 expect(actualTable?.GlobalSecondaryIndexes?.map((index) => index.IndexName)).toContain(
467 "gsi-by-lookup",
468 );
469 expect(table.localSecondaryIndexes?.map((index) => index.IndexName)).toContain(
470 "lsi-by-sk",
471 );
472 expect(table.globalSecondaryIndexes?.map((index) => index.IndexName)).toContain(
473 "gsi-by-lookup",
474 );
475​
476 yield* logTestStep("destroying indexed table");
477 yield* stack.destroy();
478​
479 yield* assertTableIsDeleted(table.tableName);
480 }),
481 { timeout: 180_000 },
482 );
483​
484 test.provider(
485 "create table with multi-attribute GSI keys and query them",
486 (stack) =>
487 Effect.gen(function* () {
488 yield* logTestStep("starting multi-attribute GSI key test");
489 yield* stack.destroy();
490​
491 const multiAttrTable = Effect.gen(function* () {
492 return yield* Table("MultiAttrKeyTable", {
493 partitionKey: "matchId",
494 attributes: { matchId: "S", tournamentId: "S", region: "S", round: "S" },
495 globalSecondaryIndexes: [
496 {
497 indexName: "TournamentRegionIndex",
498 partitionKey: ["tournamentId", "region"],
499 sortKey: ["round", "matchId"],
500 projection: { ProjectionType: "ALL" },
501 },
502 ],
503 });
504 });
505​
506 yield* logTestStep("deploying table with multi-attribute GSI keys");
507 const table = yield* stack.deploy(multiAttrTable);
508​
509 const actualTable = yield* expectTableIndexes(table.tableName, {
510 local: [],
511 global: ["TournamentRegionIndex"],
512 });
513​
514 // The composite key's element order is significant — DescribeTable
515 // must report the HASH attributes then the RANGE attributes in
516 // declaration order.
517 expect(
518 actualTable?.GlobalSecondaryIndexes?.[0]?.KeySchema?.map((element) => [
519 element.AttributeName,
520 element.KeyType,
521 ]),
522 ).toEqual([
523 ["tournamentId", "HASH"],
524 ["region", "HASH"],
525 ["round", "RANGE"],
526 ["matchId", "RANGE"],
527 ]);
528​
529 yield* logTestStep("writing items with natural attributes");
530 const matches = [
531 { matchId: "match-001", region: "NA-EAST", round: "FINALS" },
532 { matchId: "match-002", region: "NA-EAST", round: "SEMIFINALS" },
533 { matchId: "match-003", region: "NA-WEST", round: "FINALS" },
534 ];
535 yield* Effect.forEach(matches, (match) =>
536 DynamoDB.putItem({
537 TableName: table.tableName,
538 Item: {
539 matchId: { S: match.matchId },
540 tournamentId: { S: "WINTER2024" },
541 region: { S: match.region },
542 round: { S: match.round },
543 },
544 }),
545 );
546​
547 // Query with every partition attribute (equality on both HASH
548 // elements), retried through GSI propagation.
549 yield* logTestStep("querying GSI by composite partition key");
550 const byRegion = yield* DynamoDB.query({
551 TableName: table.tableName,
552 IndexName: "TournamentRegionIndex",
553 KeyConditionExpression: "tournamentId = :t AND #r = :r",
554 ExpressionAttributeNames: { "#r": "region" },
555 ExpressionAttributeValues: { ":t": { S: "WINTER2024" }, ":r": { S: "NA-EAST" } },
556 }).pipe(
557 Effect.repeat({
558 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(30)]),
559 until: (response) => (response.Count ?? 0) === 2,
560 }),
561 );
562 expect(byRegion.Count).toEqual(2);
563​
564 // Narrow by the first sort key attribute (left-to-right).
565 const byRound = yield* DynamoDB.query({
566 TableName: table.tableName,
567 IndexName: "TournamentRegionIndex",
568 KeyConditionExpression: "tournamentId = :t AND #r = :r AND round = :round",
569 ExpressionAttributeNames: { "#r": "region" },
570 ExpressionAttributeValues: {
571 ":t": { S: "WINTER2024" },
572 ":r": { S: "NA-EAST" },
573 ":round": { S: "SEMIFINALS" },
574 },
575 });
576 expect(byRound.Count).toEqual(1);
577 expect(byRound.Items?.[0]?.matchId).toEqual({ S: "match-002" });
578​
579 // An identical redeploy must be a no-op — the order-sensitive GSI
580 // diff must not see a phantom change in the multi-attribute schema.
581 yield* logTestStep("redeploying unchanged table (expect no-op)");
582 const unchanged = yield* stack.deploy(multiAttrTable);
583 expect(unchanged.tableName).toEqual(table.tableName);
584 expect(unchanged.tableId).toEqual(table.tableId);
585​
586 yield* logTestStep("destroying multi-attribute GSI table");
587 yield* stack.destroy();
588 yield* assertTableIsDeleted(table.tableName);
589 }),
590 { timeout: 240_000 },
591 );
592​
593 test.provider(
594 "reordering multi-attribute GSI key attributes replaces the table",
595 (stack) =>
596 Effect.gen(function* () {
597 yield* logTestStep("starting multi-attribute GSI reorder test");
598 yield* stack.destroy();
599​
600 const makeTable = (sortKey: string[]) =>
601 Effect.gen(function* () {
602 return yield* Table("ReorderedMultiAttrKeyTable", {
603 partitionKey: "id",
604 attributes: { id: "S", category: "S", subcategory: "S" },
605 globalSecondaryIndexes: [
606 {
607 indexName: "CategoryIndex",
608 partitionKey: "category",
609 sortKey,
610 projection: { ProjectionType: "KEYS_ONLY" },
611 },
612 ],
613 });
614 });
615​
616 yield* logTestStep("deploying table with ordered sort attributes");
617 const original = yield* stack.deploy(makeTable(["subcategory", "id"]));
618​
619 // Swapping the two sort attributes defines a different index
620 // (sort attributes are queried left-to-right in declaration order),
621 // so the deploy must replace the table, not no-op.
622 yield* logTestStep("reordering sort attributes (expect replacement)");
623 const replaced = yield* stack.deploy(makeTable(["id", "subcategory"]));
624 expect(replaced.tableName).not.toEqual(original.tableName);
625​
626 const replacedTable = yield* expectTableIndexes(replaced.tableName, {
627 local: [],
628 global: ["CategoryIndex"],
629 });
630 expect(
631 replacedTable?.GlobalSecondaryIndexes?.[0]?.KeySchema?.map(
632 (element) => element.AttributeName,
633 ),
634 ).toEqual(["category", "id", "subcategory"]);
635​
636 yield* assertTableIsDeleted(original.tableName);
637​
638 yield* logTestStep("destroying reordered multi-attribute GSI table");
639 yield* stack.destroy();
640 yield* assertTableIsDeleted(replaced.tableName);
641 }),
642 { timeout: 300_000 },
643 );
644​
645 // it's super slow because GSIs are awfully slow to create
646 test.provider.skip(
647 "update global secondary indexes across multiple deploys",
648 (stack) =>
649 Effect.gen(function* () {
650 yield* logTestStep("starting multi-stage global secondary index update test");
651 yield* stack.destroy();
652​
653 yield* logTestStep("deploying table without GSIs");
654 const table = yield* stack.deploy(
655 Effect.gen(function* () {
656 return yield* Table("UpdatingIndexedTable", {
657 partitionKey: "pk",
658 sortKey: "sk",
659 attributes: { pk: "S", sk: "S" },
660 });
661 }),
662 );
663​
664 yield* expectTableIndexes(table.tableName, { local: [], global: [] });
665​
666 yield* logTestStep("adding first GSI");
667 const oneAdded = yield* stack.deploy(
668 Effect.gen(function* () {
669 return yield* Table("UpdatingIndexedTable", {
670 partitionKey: "pk",
671 sortKey: "sk",
672 attributes: { pk: "S", sk: "S", gsi1pk: "S" },
673 globalSecondaryIndexes: [
674 {
675 indexName: "gsi-by-lookup-1",
676 partitionKey: "gsi1pk",
677 projection: { ProjectionType: "ALL" },
678 },
679 ],
680 });
681 }),
682 );
683 expect(oneAdded.tableName).toEqual(table.tableName);
684 yield* expectTableIndexes(oneAdded.tableName, { local: [], global: ["gsi-by-lookup-1"] });
685​
686 yield* logTestStep("removing first GSI");
687 const oneRemoved = yield* stack.deploy(
688 Effect.gen(function* () {
689 return yield* Table("UpdatingIndexedTable", {
690 partitionKey: "pk",
691 sortKey: "sk",
692 attributes: { pk: "S", sk: "S", gsi1pk: "S", gsi2pk: "S" },
693 });
694 }),
695 );
696 expect(oneRemoved.tableName).toEqual(table.tableName);
697 yield* expectTableIndexes(oneRemoved.tableName, { local: [], global: [] });
698​
699 yield* logTestStep("adding two GSIs sequentially through one deploy");
700 const twoAdded = yield* stack.deploy(
701 Effect.gen(function* () {
702 return yield* Table("UpdatingIndexedTable", {
703 partitionKey: "pk",
704 sortKey: "sk",
705 attributes: { pk: "S", sk: "S", gsi1pk: "S", gsi2pk: "S" },
706 globalSecondaryIndexes: [
707 {
708 indexName: "gsi-by-lookup-1",
709 partitionKey: "gsi1pk",
710 projection: { ProjectionType: "ALL" },
711 },
712 {
713 indexName: "gsi-by-lookup-2",
714 partitionKey: "gsi2pk",
715 projection: { ProjectionType: "ALL" },
716 },
717 ],
718 });
719 }),
720 );
721 expect(twoAdded.tableName).toEqual(table.tableName);
722 yield* expectTableIndexes(twoAdded.tableName, {
723 local: [],
724 global: ["gsi-by-lookup-1", "gsi-by-lookup-2"],
725 });
726​
727 yield* logTestStep("removing one of two GSIs");
728 const oneOfTwoRemoved = yield* stack.deploy(
729 Effect.gen(function* () {
730 return yield* Table("UpdatingIndexedTable", {
731 partitionKey: "pk",
732 sortKey: "sk",
733 attributes: { pk: "S", sk: "S", gsi1pk: "S", gsi2pk: "S" },
734 globalSecondaryIndexes: [
735 {
736 indexName: "gsi-by-lookup-2",
737 partitionKey: "gsi2pk",
738 projection: { ProjectionType: "ALL" },
739 },
740 ],
741 });
742 }),
743 );
744 expect(oneOfTwoRemoved.tableName).toEqual(table.tableName);
745 yield* expectTableIndexes(oneOfTwoRemoved.tableName, {
746 local: [],
747 global: ["gsi-by-lookup-2"],
748 });
749​
750 yield* logTestStep("removing remaining GSI");
751 const twoRemoved = yield* stack.deploy(
752 Effect.gen(function* () {
753 return yield* Table("UpdatingIndexedTable", {
754 partitionKey: "pk",
755 sortKey: "sk",
756 attributes: { pk: "S", sk: "S", gsi1pk: "S", gsi2pk: "S" },
757 });
758 }),
759 );
760 expect(twoRemoved.tableName).toEqual(table.tableName);
761 yield* expectTableIndexes(twoRemoved.tableName, { local: [], global: [] });
762​
763 yield* logTestStep("destroying GSI update test table");
764 yield* stack.destroy();
765​
766 yield* assertTableIsDeleted(table.tableName);
767 }),
768 { timeout: 2_400_000 },
769 );
770​
771 test.provider(
772 "changing local secondary indexes replaces the table",
773 (stack) =>
774 Effect.gen(function* () {
775 yield* logTestStep("starting local secondary index replacement test");
776 yield* stack.destroy();
777​
778 yield* logTestStep("deploying baseline table without LSIs");
779 const original = yield* stack.deploy(
780 Effect.gen(function* () {
781 return yield* Table("ReplacingIndexedTable", {
782 partitionKey: "pk",
783 sortKey: "sk",
784 attributes: { pk: "S", sk: "S" },
785 });
786 }),
787 );
788​
789 yield* expectTableIndexes(original.tableName, { local: [], global: [] });
790​
791 yield* logTestStep("deploying replacement with LSI");
792 const withLsi = yield* stack.deploy(
793 Effect.gen(function* () {
794 return yield* Table("ReplacingIndexedTable", {
795 partitionKey: "pk",
796 sortKey: "sk",
797 attributes: { pk: "S", sk: "S" },
798 localSecondaryIndexes: [
799 { indexName: "lsi-by-sk", sortKey: "sk", projection: { ProjectionType: "ALL" } },
800 ],
801 });
802 }),
803 );
804​
805 expect(withLsi.tableName).not.toEqual(original.tableName);
806 yield* assertTableIsDeleted(original.tableName);
807 yield* expectTableIndexes(withLsi.tableName, { local: ["lsi-by-sk"], global: [] });
808​
809 yield* logTestStep("deploying replacement without LSI again");
810 const withoutLsiAgain = yield* stack.deploy(
811 Effect.gen(function* () {
812 return yield* Table("ReplacingIndexedTable", {
813 partitionKey: "pk",
814 sortKey: "sk",
815 attributes: { pk: "S", sk: "S" },
816 });
817 }),
818 );
819​
820 expect(withoutLsiAgain.tableName).not.toEqual(withLsi.tableName);
821 yield* assertTableIsDeleted(withLsi.tableName);
822 yield* expectTableIndexes(withoutLsiAgain.tableName, { local: [], global: [] });
823​
824 yield* logTestStep("destroying replacement test table");
825 yield* stack.destroy();
826​
827 yield* assertTableIsDeleted(withoutLsiAgain.tableName);
828 }),
829 { timeout: 240_000 },
830 );
831​
832 test.provider(
833 "changing the key schema replaces the table under a new physical name",
834 (stack) =>
835 Effect.gen(function* () {
836 yield* logTestStep("starting key schema replacement test");
837 yield* stack.destroy();
838​
839 yield* logTestStep("deploying baseline table (partitionKey=id)");
840 const original = yield* stack.deploy(
841 Effect.gen(function* () {
842 return yield* Table("KeySchemaTable", {
843 partitionKey: "id",
844 attributes: { id: "S" },
845 });
846 }),
847 );
848​
849 yield* logTestStep("deploying replacement (partitionKey=pk)");
850 const replaced = yield* stack.deploy(
851 Effect.gen(function* () {
852 return yield* Table("KeySchemaTable", {
853 partitionKey: "pk",
854 attributes: { pk: "S" },
855 });
856 }),
857 );
858​
859 // Replacement mints a new instance id, so the engine-generated
860 // physical name changes: the successor is created under a fresh name
861 // (create-before-delete) and the old table is deleted afterwards.
862 expect(replaced.tableName).not.toEqual(original.tableName);
863 expect(replaced.partitionKey).toEqual("pk");
864 yield* assertTableIsDeleted(original.tableName);
865​
866 const successor = yield* DynamoDB.describeTable({ TableName: replaced.tableName });
867 expect(successor.Table?.KeySchema).toEqual([{ AttributeName: "pk", KeyType: "HASH" }]);
868​
869 yield* logTestStep("destroying key schema replacement test table");
870 yield* stack.destroy();
871​
872 yield* assertTableIsDeleted(replaced.tableName);
873 }),
874 { timeout: 240_000 },
875 );
876​
877 test.provider(
878 "hard-coded table name is replaced in place (delete before create)",
879 (stack) =>
880 Effect.gen(function* () {
881 yield* logTestStep("starting pinned-name replacement test");
882 yield* stack.destroy();
883​
884 // Deterministic physical name — identical on every run of this case.
885 const tableName = "alchemy-test-ddb-pinned-replace";
886​
887 yield* logTestStep("deploying pinned-name table without sort key");
888 const original = yield* stack.deploy(
889 Effect.gen(function* () {
890 return yield* Table("PinnedNameTable", {
891 tableName,
892 partitionKey: "id",
893 attributes: { id: "S" },
894 });
895 }),
896 );
897 expect(original.tableName).toEqual(tableName);
898 expect(original.sortKey).toBeUndefined();
899​
900 yield* logTestStep("deploying replacement with a sort key");
901 const replaced = yield* stack.deploy(
902 Effect.gen(function* () {
903 return yield* Table("PinnedNameTable", {
904 tableName,
905 partitionKey: "id",
906 sortKey: "sk",
907 attributes: { id: "S", sk: "S" },
908 });
909 }),
910 );
911​
912 // The pinned name cannot change, so the engine must delete the old
913 // generation first and recreate under the same name: same physical
914 // name, new physical table (fresh TableId), new key schema.
915 expect(replaced.tableName).toEqual(tableName);
916 expect(replaced.tableId).not.toEqual(original.tableId);
917 expect(replaced.sortKey).toEqual("sk");
918​
919 const successor = yield* DynamoDB.describeTable({ TableName: tableName });
920 expect(successor.Table?.TableId).toEqual(replaced.tableId);
921 expect(successor.Table?.KeySchema).toEqual([
922 { AttributeName: "id", KeyType: "HASH" },
923 { AttributeName: "sk", KeyType: "RANGE" },
924 ]);
925​
926 yield* logTestStep("destroying pinned-name replacement test table");
927 yield* stack.destroy();
928​
929 yield* assertTableIsDeleted(tableName);
930 }),
931 { timeout: 240_000 },
932 );
933​
934 // Engine-level adoption: a table tagged with this stack/stage/id is
935 // silently adopted on a fresh state store; a table tagged with a
936 // different logical id is rejected unless `adopt(true)` is supplied.
937 // Longer timeouts because DynamoDB table create/delete each take ~30s.
938 test.provider(
939 "owned table (matching alchemy tags) is silently adopted without --adopt",
940 (stack) =>
941 Effect.gen(function* () {
942 yield* stack.destroy();
943​
944 // Deterministic name: a re-run reclaims any orphan left by a
945 // previously interrupted run instead of stranding a new random table.
946 const tableName = "alchemy-test-ddb-adopt";
947​
948 // Pre-clean: the state wipe below hides this table from the scratch
949 // stack's automatic teardown, so reclaim any leftover up front.
950 yield* reclaimOutOfBandTable(tableName);
951​
952 yield* Effect.gen(function* () {
953 // Phase 1: deploy normally; alchemy stamps internal tags.
954 const initial = yield* stack.deploy(
955 Effect.gen(function* () {
956 return yield* Table("AdoptableTable", {
957 tableName,
958 partitionKey: "id",
959 attributes: { id: "S" },
960 });
961 }),
962 );
963 expect(initial.tableName).toEqual(tableName);
964​
965 // Phase 2: wipe local state — the table stays in DynamoDB.
966 yield* Effect.gen(function* () {
967 const state = yield* yield* State;
968 yield* state.delete({ stack: stack.name, stage: stack.stage, fqn: "AdoptableTable" });
969 }).pipe(Effect.provide(stack.state));
970​
971 // Phase 3: redeploy without `adopt(true)`. Read sees alchemy tags
972 // matching this stack/stage/id and returns plain attrs — silent
973 // adoption.
974 const adopted = yield* stack.deploy(
975 Effect.gen(function* () {
976 return yield* Table("AdoptableTable", {
977 tableName,
978 partitionKey: "id",
979 attributes: { id: "S" },
980 });
981 }),
982 );
983​
984 expect(adopted.tableArn).toEqual(initial.tableArn);
985​
986 yield* stack.destroy();
987 yield* assertTableIsDeleted(tableName);
988 }).pipe(
989 // After the state wipe the table is out-of-band: guarantee the
990 // idempotent delete on success, failure, and interruption.
991 Effect.ensuring(reclaimOutOfBandTable(tableName).pipe(Effect.orDie)),
992 );
993 }),
994 { timeout: 360_000 },
995 );
996​
997 test.provider(
998 "foreign-tagged table requires adopt(true) to take over",
999 (stack) =>
1000 Effect.gen(function* () {
1001 yield* stack.destroy();
1002​
1003 // Deterministic name: a re-run reclaims any orphan left by a
1004 // previously interrupted run instead of stranding a new random table.
1005 const tableName = "alchemy-test-ddb-takeover";
1006​
1007 // Pre-clean: the state wipe below hides this table from the scratch
1008 // stack's automatic teardown, so reclaim any leftover up front.
1009 yield* reclaimOutOfBandTable(tableName);
1010​
1011 yield* Effect.gen(function* () {
1012 // Phase 1: deploy under "Original" — table tagged
1013 // alchemy::id=Original.
1014 const original = yield* stack.deploy(
1015 Effect.gen(function* () {
1016 return yield* Table("Original", {
1017 tableName,
1018 partitionKey: "id",
1019 attributes: { id: "S" },
1020 });
1021 }),
1022 );
1023 expect(original.tableName).toEqual(tableName);
1024​
1025 // Wipe state for "Original"; table stays in DynamoDB.
1026 yield* Effect.gen(function* () {
1027 const state = yield* yield* State;
1028 yield* state.delete({ stack: stack.name, stage: stack.stage, fqn: "Original" });
1029 }).pipe(Effect.provide(stack.state));
1030​
1031 // Phase 2: redeploy under "Different" with `adopt(true)`. Read
1032 // returns Unowned(attrs) because the table's tags identify a
1033 // different logical id; with adopt enabled the engine takes over
1034 // and the update rewrites tags.
1035 const takenOver = yield* stack
1036 .deploy(
1037 Effect.gen(function* () {
1038 return yield* Table("Different", {
1039 tableName,
1040 partitionKey: "id",
1041 attributes: { id: "S" },
1042 });
1043 }),
1045 .pipe(adopt(true));
1046​
1047 expect(takenOver.tableName).toEqual(tableName);
1048​
1049 // After the update the tags should now identify this stack/stage/id.
1050 // The takeover update reads tags back from the cloud after writing,
1051 // so `takenOver.tags` already reflects the rewritten tag set.
1052 expect(takenOver.tags?.["alchemy::id"]).toEqual("Different");
1053​
1054 yield* stack.destroy();
1055 yield* assertTableIsDeleted(tableName);
1056 }).pipe(
1057 // After the state wipe the table is out-of-band: guarantee the
1058 // idempotent delete on success, failure, and interruption.
1059 Effect.ensuring(reclaimOutOfBandTable(tableName).pipe(Effect.orDie)),
1060 );
1061 }),
1062 { timeout: 360_000 },
1063 );
1064​
1065 // Regression test for https://github.com/alchemy-run/alchemy/issues/736.
1066 //
1067 // An interrupted first deploy persists the table as `status: "creating"`
1068 // with no attributes — and Output-valued props do not survive the state
1069 // round-trip: they deserialize as `undefined`. Plan's recovery branch first
1070 // calls `provider.read` with those junk olds; if read misses, it then calls
1071 // `provider.diff` against the same junk olds, which crashed in
1072 // `Object.entries(olds.attributes)` when `attributes` was lost.
1073 //
1074 // Simulate exactly that state row after a real deploy, then delete the
1075 // table out-of-band so `read` cannot recover it by name (a live table would
1076 // be recovered and `diff` would never run against the junk olds), and
1077 // assert the next deploy falls through diff and recreates the table.
1078 test.provider(
1079 "recovers a creating-state row that lost its attributes prop (#736)",
1080 (stack) =>
1081 Effect.gen(function* () {
1082 yield* stack.destroy();
1083​
1084 const deployTable = () =>
1085 stack.deploy(
1086 Effect.gen(function* () {
1087 return yield* Table("WedgedTable", { partitionKey: "id", attributes: { id: "S" } });
1088 }),
1089 );
1090​
1091 const created = yield* deployTable();
1092​
1093 // Rewrite the table's persisted row into the wedged shape an
1094 // interrupted deploy leaves behind: `creating`, no attributes, and
1095 // the `attributes` prop lost in the state round-trip.
1096 const state = yield* yield* State;
1097 const stage = stack.stage;
1098 const fqns = yield* state.list({ stack: stack.name, stage });
1099 const rows = yield* Effect.forEach(fqns, (fqn) =>
1100 state.get({ stack: stack.name, stage, fqn }).pipe(Effect.map((row) => ({ fqn, row }))),
1101 );
1102 const wedged = rows.find(
1103 (r): r is { fqn: string; row: ResourceState } =>
1104 isResourceState(r.row) && r.row.resourceType === "AWS.DynamoDB.Table",
1105 );
1106 if (!wedged) {
1107 return yield* Effect.die(
1108 new Error("no AWS.DynamoDB.Table state row found after deploy"),
1109 );
1111 yield* state.set({
1112 stack: stack.name,
1113 stage,
1114 fqn: wedged.fqn,
1115 value: {
1116 ...wedged.row,
1117 status: "creating",
1118 attr: undefined,
1119 props: { ...wedged.row.props, attributes: undefined },
1120 },
1121 });
1122​
1123 // Delete the table out-of-band so the recovery `read` misses.
1124 yield* logTestStep(`deleting ${created.tableName} out-of-band to force a read miss`);
1125 yield* DynamoDB.deleteTable({ TableName: created.tableName }).pipe(
1126 Effect.retry({
1127 while: (e) => e._tag === "ResourceInUseException",
1128 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]),
1129 }),
1130 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
1131 );
1132 yield* assertTableIsDeleted(created.tableName);
1133​
1134 // Before the fix this crashed in plan's diff with
1135 // `TypeError: undefined is not an object (evaluating 'olds.attributes')`.
1136 const recovered = yield* deployTable();
1137 expect(recovered.tableName).toEqual(created.tableName);
1138​
1139 const actual = yield* DynamoDB.describeTable({ TableName: recovered.tableName });
1140 expect(actual.Table?.TableArn).toEqual(recovered.tableArn);
1141​
1142 yield* stack.destroy();
1143 yield* assertTableIsDeleted(recovered.tableName);
1144 }),
1145 { timeout: 240_000 },
1146 );
1147​
1148 // Idempotent out-of-band cleanup for the adoption tests, whose state wipe
1149 // hides the table from the scratch stack's automatic teardown. Safe when
1150 // the table never existed (not-found is swallowed), is mid create/update
1151 // (bounded retry on ResourceInUseException), or was already deleted by
1152 // `stack.destroy()`.
1153 const reclaimOutOfBandTable = Effect.fn(function* (tableName: string) {
1154 yield* logTestStep(`reclaiming out-of-band table ${tableName}`);
1155 yield* DynamoDB.deleteTable({ TableName: tableName }).pipe(
1156 Effect.retry({
1157 while: (e) => e._tag === "ResourceInUseException",
1158 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(8)]),
1159 }),
1160 Effect.flatMap(() => assertTableIsDeleted(tableName)),
1161 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
1162 );
1163 });
1164​
1165 // #2659: `read` must observe every managed setting, and must not report
1166 // values that move on their own (index item counts, the PITR restore
1167 // window), or drift is either blind or never quiet.
1168 test.provider(
1169 "drift detects and repairs out-of-band configuration changes",
1170 (stack) =>
1171 Effect.gen(function* () {
1172 yield* stack.destroy();
1173​
1174 const table = yield* stack.deploy(
1175 Table("DriftConfigTable", {
1176 partitionKey: "id",
1177 attributes: { id: "S", gsiKey: "S" },
1178 globalSecondaryIndexes: [
1180 indexName: "ByGsiKey",
1181 partitionKey: "gsiKey",
1182 projection: { ProjectionType: "KEYS_ONLY" },
1183 },
1184 ],
1185 pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1186 timeToLiveSpecification: { Enabled: true, AttributeName: "expiresAt" },
1187 }),
1188 );
1189 const target = { name: stack.name, stage: stack.stage };
1190 const detect = Drift.detect(target).pipe(
1191 Effect.map((result) => result.resources.DriftConfigTable),
1192 Effect.provide(stack.state),
1193 );
1194 expect((yield* detect)?.action).toBe("unchanged");
1195​
1196 yield* DynamoDB.updateTable({
1197 TableName: table.tableName,
1198 DeletionProtectionEnabled: true,
1199 });
1200 yield* DynamoDB.updateContinuousBackups({
1201 TableName: table.tableName,
1202 PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: false },
1203 });
1204​
1205 const drifted = yield* detect.pipe(
1206 Effect.repeat({
1207 until: (result) =>
1208 result?.attr?.deletionProtectionEnabled === true &&
1209 result.attr.pointInTimeRecoveryDescription?.PointInTimeRecoveryStatus ===
1210 "DISABLED",
1211 schedule: Schedule.spaced("2 seconds"),
1212 times: 15,
1213 }),
1214 );
1215 expect(drifted?.action).toBe("drifted");
1216​
1217 const repaired = yield* Drift.repair(target).pipe(Effect.provide(stack.state));
1218 expect(repaired.resources.DriftConfigTable?.action).toBe("repaired");
1219 expect((yield* detect)?.action).toBe("unchanged");
1220​
1221 const described = yield* DynamoDB.describeTable({ TableName: table.tableName });
1222 expect(described.Table?.DeletionProtectionEnabled).toBe(false);
1223 const backups = yield* DynamoDB.describeContinuousBackups({
1224 TableName: table.tableName,
1225 });
1226 expect(
1227 backups.ContinuousBackupsDescription?.PointInTimeRecoveryDescription
1228 ?.PointInTimeRecoveryStatus,
1229 ).toBe("ENABLED");
1230​
1231 yield* stack.destroy();
1232 yield* assertTableIsDeleted(table.tableName);
1233 }).pipe(Effect.onError(() => stack.destroy().pipe(Effect.ignore))),
1234 { timeout: 240_000 },
1235 );
1236​
1237 const assertTableIsDeleted = Effect.fn(function* (tableName: string) {
1238 yield* Effect.logInfo(`DynamoDB Table test: waiting for deletion of ${tableName}`);
1239 yield* DynamoDB.describeTable({ TableName: tableName }).pipe(
1240 Effect.flatMap(() => Effect.fail(new TableStillExists())),
1241 Effect.retry({
1242 while: (e) => e._tag === "TableStillExists",
1243 schedule: Schedule.max([Schedule.fixed("1 second"), Schedule.recurs(30)]),
1244 }),
1245 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
1246 );
1247 yield* Effect.logInfo(`DynamoDB Table test: confirmed deleted ${tableName}`);
1248 });
1249​
1250 const waitForTableTags = Effect.fn(function* (
1251 tableArn: string,
1252 expected: Record<string, string>,
1253 ) {
1254 yield* Effect.logInfo(
1255 `DynamoDB Table test: waiting for tags on ${tableArn} -> ${JSON.stringify(expected)}`,
1256 );
1257 return yield* DynamoDB.listTagsOfResource({ ResourceArn: tableArn }).pipe(
1258 Effect.flatMap((result) => {
1259 const tags = Object.fromEntries(
1260 (result.Tags ?? []).map((tag) => [tag.Key!, tag.Value!]),
1261 ) as Record<string, string>;
1262 const matches = Object.entries(expected).every(([key, value]) => tags[key] === value);
1263 if (!matches) {
1264 return Effect.logInfo(
1265 `DynamoDB Table test: tags not ready on ${tableArn}. expected=${JSON.stringify(expected)} actual=${JSON.stringify(tags)}`,
1266 ).pipe(Effect.andThen(Effect.fail(new TableTagsNotUpdated())));
1268 return Effect.logInfo(
1269 `DynamoDB Table test: tags ready on ${tableArn} -> ${JSON.stringify(tags)}`,
1270 ).pipe(Effect.andThen(Effect.succeed(tags)));
1271 }),
1272 Effect.retry({
1273 while: (error) => error._tag === "TableTagsNotUpdated",
1274 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(15)]),
1275 }),
1276 );
1277 });
1278​
1279 const waitForPointInTimeRecovery = Effect.fn(function* (tableName: string, enabled: boolean) {
1280 yield* Effect.logInfo(
1281 `DynamoDB Table test: waiting for PITR on ${tableName} -> ${enabled ? "ENABLED" : "DISABLED"}`,
1282 );
1283 return yield* DynamoDB.describeContinuousBackups({ TableName: tableName }).pipe(
1284 Effect.flatMap((result) => {
1285 const status =
1286 result.ContinuousBackupsDescription?.PointInTimeRecoveryDescription
1287 ?.PointInTimeRecoveryStatus;
1288 if ((enabled && status !== "ENABLED") || (!enabled && status !== "DISABLED")) {
1289 return Effect.logInfo(
1290 `DynamoDB Table test: PITR not ready on ${tableName}. current=${status ?? "undefined"}`,
1291 ).pipe(Effect.andThen(Effect.fail(new PointInTimeRecoveryNotUpdated())));
1293 if (!result.ContinuousBackupsDescription) {
1294 return Effect.fail(new PointInTimeRecoveryNotUpdated());
1296 return Effect.logInfo(
1297 `DynamoDB Table test: PITR ready on ${tableName} -> ${status}`,
1298 ).pipe(Effect.andThen(Effect.succeed(result.ContinuousBackupsDescription)));
1299 }),
1300 Effect.retry({
1301 while: (error) => error._tag === "PointInTimeRecoveryNotUpdated",
1302 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(15)]),
1303 }),
1304 );
1305 });
1306​
1307 const waitForTableStreamSpecification = Effect.fn(function* (
1308 tableName: string,
1309 expected: DynamoDB.StreamSpecification | undefined,
1310 ) {
1311 yield* Effect.logInfo(
1312 `DynamoDB Table test: waiting for stream configuration on ${tableName} -> ${JSON.stringify(expected)}`,
1313 );
1314 return yield* DynamoDB.describeTable({ TableName: tableName }).pipe(
1315 Effect.flatMap((result) => {
1316 const actual = result.Table?.StreamSpecification;
1317 const matches = JSON.stringify(actual ?? undefined) === JSON.stringify(expected);
1318 if (!matches) {
1319 return Effect.logInfo(
1320 `DynamoDB Table test: stream configuration not ready on ${tableName}. actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)}`,
1321 ).pipe(Effect.andThen(Effect.fail(new StreamSpecNotUpdated())));
1323 return Effect.logInfo(
1324 `DynamoDB Table test: stream configuration ready on ${tableName} -> ${JSON.stringify(actual)}`,
1325 ).pipe(Effect.andThen(Effect.succeed(result)));
1326 }),
1327 Effect.retry({
1328 while: (error) => error._tag === "StreamSpecNotUpdated",
1329 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(20)]),
1330 }),
1331 );
1332 });
1333​
1334 const expectTableIndexes = Effect.fn(function* (
1335 tableName: string,
1336 expected: { local: string[]; global: string[] },
1337 ) {
1338 const expectedLocal = [...expected.local].sort();
1339 const expectedGlobal = [...expected.global].sort();
1340 yield* Effect.logInfo(
1341 `DynamoDB Table test: waiting for indexes on ${tableName}. expectedLocal=${JSON.stringify(expectedLocal)} expectedGlobal=${JSON.stringify(expectedGlobal)}`,
1342 );
1343​
1344 return yield* DynamoDB.describeTable({ TableName: tableName }).pipe(
1345 Effect.flatMap((result) => {
1346 const table = result.Table;
1347 const actualLocal = [...(table?.LocalSecondaryIndexes ?? [])]
1348 .map((index) => index.IndexName!)
1349 .sort();
1350 const actualGlobal = [...(table?.GlobalSecondaryIndexes ?? [])]
1351 .map((index) => index.IndexName!)
1352 .sort();
1353 const allGlobalActive = (table?.GlobalSecondaryIndexes ?? []).every(
1354 (index) => index.IndexStatus === "ACTIVE",
1355 );
1356​
1357 if (
1358 JSON.stringify(actualLocal) !== JSON.stringify(expectedLocal) ||
1359 JSON.stringify(actualGlobal) !== JSON.stringify(expectedGlobal) ||
1360 !allGlobalActive
1361 ) {
1362 return Effect.logInfo(
1363 `DynamoDB Table test: indexes not ready on ${tableName}. tableStatus=${table?.TableStatus ?? "undefined"} actualLocal=${JSON.stringify(actualLocal)} actualGlobal=${JSON.stringify(actualGlobal)} globalStatuses=${JSON.stringify((table?.GlobalSecondaryIndexes ?? []).map((index) => ({ name: index.IndexName, status: index.IndexStatus })))}`,
1364 ).pipe(Effect.andThen(Effect.fail(new TableIndexesNotUpdated())));
1366​
1367 return Effect.logInfo(
1368 `DynamoDB Table test: indexes ready on ${tableName}. actualLocal=${JSON.stringify(actualLocal)} actualGlobal=${JSON.stringify(actualGlobal)}`,
1369 ).pipe(Effect.andThen(Effect.succeed(table)));
1370 }),
1371 Effect.retry({
1372 while: (error) => error._tag === "TableIndexesNotUpdated",
1373 schedule: longGlobalSecondaryIndexStabilization,
1374 }),
1375 );
1376 });
1377​
1378 const waitForResourcePolicy = Effect.fn(function* (
1379 tableArn: string,
1380 expectedSid: string | undefined,
1381 ) {
1382 yield* Effect.logInfo(
1383 `DynamoDB Table test: waiting for resource policy on ${tableArn} -> ${expectedSid ?? "absent"}`,
1384 );
1385 return yield* DynamoDB.getResourcePolicy({ ResourceArn: tableArn }).pipe(
1386 Effect.map((result) => result.Policy),
1387 Effect.catchTag("PolicyNotFoundException", () => Effect.succeed(undefined)),
1388 Effect.flatMap((policy) => {
1389 const matches =
1390 expectedSid === undefined
1391 ? policy === undefined
1392 : policy?.includes(expectedSid) === true;
1393 if (!matches) {
1394 return Effect.logInfo(
1395 `DynamoDB Table test: resource policy not ready on ${tableArn}. actual=${policy ?? "absent"}`,
1396 ).pipe(Effect.andThen(Effect.fail(new ResourcePolicyNotUpdated())));
1398 return Effect.succeed(policy);
1399 }),
1400 Effect.retry({
1401 while: (error) => error._tag === "ResourcePolicyNotUpdated",
1402 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(15)]),
1403 }),
1404 );
1405 });
1406​
1407 const waitForKinesisDestination = Effect.fn(function* (
1408 tableName: string,
1409 streamArn: string,
1410 expectedStatus: "ACTIVE" | "DISABLED",
1411 ) {
1412 yield* Effect.logInfo(
1413 `DynamoDB Table test: waiting for Kinesis destination ${streamArn} on ${tableName} -> ${expectedStatus}`,
1414 );
1415 return yield* DynamoDB.describeKinesisStreamingDestination({ TableName: tableName }).pipe(
1416 Effect.flatMap((result) => {
1417 const destination = (result.KinesisDataStreamDestinations ?? []).find(
1418 (candidate) => candidate.StreamArn === streamArn,
1419 );
1420 if (destination?.DestinationStatus !== expectedStatus) {
1421 return Effect.logInfo(
1422 `DynamoDB Table test: Kinesis destination not ready on ${tableName}. actual=${destination?.DestinationStatus ?? "absent"} expected=${expectedStatus}`,
1423 ).pipe(Effect.andThen(Effect.fail(new KinesisDestinationNotUpdated())));
1425 return Effect.succeed(destination);
1426 }),
1427 Effect.retry({
1428 while: (error) => error._tag === "KinesisDestinationNotUpdated",
1429 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(18)]),
1430 }),
1431 );
1432 });
1433​
1434 const waitForContributorInsights = Effect.fn(function* (tableName: string, enabled: boolean) {
1435 yield* Effect.logInfo(
1436 `DynamoDB Table test: waiting for Contributor Insights on ${tableName} -> ${enabled ? "enabled" : "disabled"}`,
1437 );
1438 return yield* DynamoDB.describeContributorInsights({ TableName: tableName }).pipe(
1439 Effect.flatMap((result) => {
1440 const status = result.ContributorInsightsStatus;
1441 const isEnabled = status === "ENABLED" || status === "ENABLING";
1442 if (isEnabled !== enabled) {
1443 return Effect.logInfo(
1444 `DynamoDB Table test: Contributor Insights not ready on ${tableName}. actual=${status ?? "undefined"}`,
1445 ).pipe(Effect.andThen(Effect.fail(new ContributorInsightsNotUpdated())));
1447 return Effect.succeed(status);
1448 }),
1449 Effect.retry({
1450 while: (error) => error._tag === "ContributorInsightsNotUpdated",
1451 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(15)]),
1452 }),
1453 );
1454 });
1455​
1456 // Out-of-band proof that DynamoDB's Contributor Insights cleanup ran:
1457 // no `DynamoDBContributorInsights-*-<tableName>-*` CloudWatch rules may
1458 // survive the destroy — stranded rules can only be removed by AWS support.
1459 const assertNoContributorInsightsRules = Effect.fn(function* (tableName: string) {
1460 const rules = yield* CloudWatch.describeInsightRules({});
1461 const leftover = (rules.InsightRules ?? [])
1462 .map((rule) => rule.Name)
1463 .filter((name): name is string => name !== undefined && name.includes(`-${tableName}-`));
1464 expect(leftover).toEqual([]);
1465 });
1466​
1467 const logTestStep = (message: string) => Effect.logInfo(`DynamoDB Table test: ${message}`);
1468​
1469 class TableStillExists extends Data.TaggedError("TableStillExists") {}
1470​
1471 class StreamSpecNotUpdated extends Data.TaggedError("StreamSpecNotUpdated") {}
1472​
1473 class TableTagsNotUpdated extends Data.TaggedError("TableTagsNotUpdated") {}
1474​
1475 class PointInTimeRecoveryNotUpdated extends Data.TaggedError("PointInTimeRecoveryNotUpdated") {}
1476​
1477 class GlobalSecondaryIndexNotActive extends Data.TaggedError("GlobalSecondaryIndexNotActive") {}
1478​
1479 class TableIndexesNotUpdated extends Data.TaggedError("TableIndexesNotUpdated") {}
1480​
1481 class ResourcePolicyNotUpdated extends Data.TaggedError("ResourcePolicyNotUpdated") {}
1482​
1483 class KinesisDestinationNotUpdated extends Data.TaggedError("KinesisDestinationNotUpdated") {}
1484​
1485 class ContributorInsightsNotUpdated extends Data.TaggedError("ContributorInsightsNotUpdated") {}
1486 },