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