| 1 | import { adopt } from "@/AdoptPolicy"; |
| 2 | import * as AWS from "@/AWS"; |
| 3 | import { Table } from "@/AWS/DynamoDB"; |
| 4 | import { Stream as KinesisStream } from "@/AWS/Kinesis"; |
| 5 | import * as Provider from "@/Provider"; |
| 6 | import { isResourceState, State, type ResourceState } from "@/State"; |
| 7 | import * as Test from "@/Test/Alchemy"; |
| 8 | import * as CloudWatch from "@distilled.cloud/aws/cloudwatch"; |
| 9 | import * as DynamoDB from "@distilled.cloud/aws/dynamodb"; |
| 10 | import { describe, expect } from "alchemy-test"; |
| 11 | import * as Data from "effect/Data"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | |
| 15 | const { test } = Test.make({ providers: AWS.providers() }); |
| 16 | |
| 17 | describe.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 | ); |