| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Core from "@/Test/Core"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import { describe, expect } from "alchemy-test"; |
| 5 | import * as Data from "effect/Data"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 10 | import DynamoDBTestFunctionLive, { DynamoDBTestFunction } from "./handler"; |
| 11 | |
| 12 | const testOptions = { providers: AWS.providers() }; |
| 13 | const { test, beforeAll, afterAll } = Test.make(testOptions); |
| 14 | const sharedStack = Core.scratchStack(testOptions, "DynamoDBBindings"); |
| 15 | |
| 16 | // Lambda function URL cold-start (DNS, IAM propagation, init) can take |
| 17 | // well over 60s on a fresh deploy under parallel-suite load — observed |
| 18 | // up to ~90s when S3 throttling delays the Lambda code upload too. |
| 19 | // Budget ~150s of readiness polling so we don't fail the whole suite on |
| 20 | // a slow init. |
| 21 | const readinessPolicy = Schedule.max([ |
| 22 | Schedule.fixed("2 seconds"), |
| 23 | Schedule.recurs(75), |
| 24 | ]); |
| 25 | |
| 26 | let baseUrl: string; |
| 27 | const sourceTableId = "TestTable"; |
| 28 | |
| 29 | class TransientUpstream extends Data.TaggedError("TransientUpstream")<{ |
| 30 | readonly status: number; |
| 31 | readonly body: string; |
| 32 | }> {} |
| 33 | |
| 34 | // The shared Lambda fixture occasionally answers a transient 5xx under |
| 35 | // full-suite parallel load — a cold re-init, or a throttled/eventually- |
| 36 | // consistent DynamoDB call that the handler's `Effect.orDie` surfaces as a 500 |
| 37 | // with a non-JSON ("Internal Server Error") body. Those are not assertion |
| 38 | // failures: retry the request a few times before surfacing it. A genuine |
| 39 | // 4xx/assertion failure is returned immediately (only 5xx is retried). |
| 40 | const send = (request: HttpClientRequest.HttpClientRequest) => |
| 41 | HttpClient.execute(request).pipe( |
| 42 | Effect.flatMap((response) => |
| 43 | response.status >= 500 |
| 44 | ? response.text.pipe( |
| 45 | Effect.flatMap((body) => |
| 46 | Effect.fail( |
| 47 | new TransientUpstream({ status: response.status, body }), |
| 48 | ), |
| 49 | ), |
| 50 | ) |
| 51 | : Effect.succeed(response), |
| 52 | ), |
| 53 | Effect.retry({ |
| 54 | while: (e) => e._tag === "TransientUpstream", |
| 55 | schedule: Schedule.max([ |
| 56 | Schedule.exponential("500 millis"), |
| 57 | Schedule.recurs(6), |
| 58 | ]), |
| 59 | }), |
| 60 | ); |
| 61 | |
| 62 | describe( |
| 63 | "DynamoDB Bindings", |
| 64 | { |
| 65 | tags: [ |
| 66 | "provider:aws", |
| 67 | "provider:aws:batch", |
| 68 | "provider:aws:dynamodb", |
| 69 | "provider:aws:lambda", |
| 70 | "provider:aws:s3", |
| 71 | "live", |
| 72 | ], |
| 73 | }, |
| 74 | () => { |
| 75 | beforeAll( |
| 76 | Effect.gen(function* () { |
| 77 | yield* Effect.logInfo( |
| 78 | "DynamoDB test setup: destroying previous resources", |
| 79 | ); |
| 80 | yield* sharedStack.destroy(); |
| 81 | |
| 82 | yield* Effect.logInfo("DynamoDB test setup: deploying fixture"); |
| 83 | const { functionUrl } = yield* sharedStack.deploy( |
| 84 | Effect.gen(function* () { |
| 85 | return yield* DynamoDBTestFunction; |
| 86 | }).pipe(Effect.provide(DynamoDBTestFunctionLive)), |
| 87 | ); |
| 88 | |
| 89 | expect(functionUrl).toBeTruthy(); |
| 90 | baseUrl = functionUrl!.replace(/\/+$/, ""); |
| 91 | const readinessUrl = `${baseUrl}/scan`; |
| 92 | |
| 93 | yield* Effect.logInfo( |
| 94 | `DynamoDB test setup: function URL ready (${functionUrl})`, |
| 95 | ); |
| 96 | yield* Effect.logInfo( |
| 97 | `DynamoDB test setup: probing readiness at ${readinessUrl} (20s budget)`, |
| 98 | ); |
| 99 | |
| 100 | yield* HttpClient.get(readinessUrl).pipe( |
| 101 | Effect.flatMap((response) => |
| 102 | response.status === 200 |
| 103 | ? Effect.succeed(response) |
| 104 | : Effect.fail( |
| 105 | new Error(`Function not ready: ${response.status}`), |
| 106 | ), |
| 107 | ), |
| 108 | Effect.tap(() => |
| 109 | Effect.logInfo( |
| 110 | "DynamoDB test setup: fixture responded successfully", |
| 111 | ), |
| 112 | ), |
| 113 | Effect.tapError((error) => |
| 114 | Effect.logWarning( |
| 115 | `DynamoDB test setup: fixture not ready yet (${String(error)})`, |
| 116 | ), |
| 117 | ), |
| 118 | Effect.retry({ schedule: readinessPolicy }), |
| 119 | ); |
| 120 | }), |
| 121 | { timeout: 240_000 }, |
| 122 | ); |
| 123 | |
| 124 | // Lambda cleanup watches for late log flushes before the dependent tables |
| 125 | // can be deleted. Allow time for both phases to confirm deletion. |
| 126 | afterAll(sharedStack.destroy(), { timeout: 120_000 }); |
| 127 | |
| 128 | describe("PutItem", () => { |
| 129 | test.provider("puts an item into the table", (_stack) => |
| 130 | Effect.gen(function* () { |
| 131 | const response = yield* send( |
| 132 | HttpClientRequest.bodyJsonUnsafe( |
| 133 | HttpClientRequest.post(`${baseUrl}/put`), |
| 134 | { pk: "put-test#1", sk: "item", data: "test data" }, |
| 135 | ), |
| 136 | ).pipe(Effect.flatMap((r) => r.json)); |
| 137 | |
| 138 | expect(response).toHaveProperty("success", true); |
| 139 | }), |
| 140 | ); |
| 141 | }); |
| 142 | |
| 143 | describe("GetItem", () => { |
| 144 | test.provider("gets an existing item from the table", (_stack) => |
| 145 | Effect.gen(function* () { |
| 146 | yield* send( |
| 147 | HttpClientRequest.bodyJsonUnsafe( |
| 148 | HttpClientRequest.post(`${baseUrl}/put`), |
| 149 | { pk: "get-test#1", sk: "item", data: "get test data" }, |
| 150 | ), |
| 151 | ); |
| 152 | |
| 153 | const response = yield* HttpClient.get( |
| 154 | `${baseUrl}/get?pk=${encodeURIComponent("get-test#1")}&sk=item`, |
| 155 | ).pipe(Effect.flatMap((r) => r.json)); |
| 156 | |
| 157 | expect(response).toHaveProperty("item"); |
| 158 | expect((response as any).item.pk.S).toBe("get-test#1"); |
| 159 | expect((response as any).item.sk.S).toBe("item"); |
| 160 | expect((response as any).item.data.S).toBe("get test data"); |
| 161 | }), |
| 162 | ); |
| 163 | |
| 164 | test.provider("returns undefined for non-existent item", (_stack) => |
| 165 | Effect.gen(function* () { |
| 166 | const response = yield* HttpClient.get( |
| 167 | `${baseUrl}/get?pk=${encodeURIComponent("non-existent")}&sk=item`, |
| 168 | ).pipe(Effect.flatMap((r) => r.json)); |
| 169 | |
| 170 | expect((response as any).item).toBeUndefined(); |
| 171 | }), |
| 172 | ); |
| 173 | }); |
| 174 | |
| 175 | describe("DescribeTable", () => { |
| 176 | test.provider("describes the bound table", (_stack) => |
| 177 | Effect.gen(function* () { |
| 178 | const response = yield* HttpClient.get( |
| 179 | `${baseUrl}/describe-table`, |
| 180 | ).pipe(Effect.flatMap((r) => r.json)); |
| 181 | |
| 182 | expect((response as any).table.TableName).toBeTruthy(); |
| 183 | expect((response as any).table.KeySchema).toEqual([ |
| 184 | { AttributeName: "pk", KeyType: "HASH" }, |
| 185 | { AttributeName: "sk", KeyType: "RANGE" }, |
| 186 | ]); |
| 187 | }), |
| 188 | ); |
| 189 | }); |
| 190 | |
| 191 | describe("DescribeTimeToLive", () => { |
| 192 | test.provider("describes table ttl configuration", (_stack) => |
| 193 | Effect.gen(function* () { |
| 194 | const response = yield* HttpClient.get( |
| 195 | `${baseUrl}/describe-ttl`, |
| 196 | ).pipe(Effect.flatMap((r) => r.json)); |
| 197 | |
| 198 | expect(response).toHaveProperty("timeToLiveDescription"); |
| 199 | }), |
| 200 | ); |
| 201 | }); |
| 202 | |
| 203 | describe("BatchWriteItem", () => { |
| 204 | test.provider("writes multiple items through the bound table", (_stack) => |
| 205 | Effect.gen(function* () { |
| 206 | const response = yield* send( |
| 207 | HttpClientRequest.bodyJsonUnsafe( |
| 208 | HttpClientRequest.post(`${baseUrl}/batch-write`), |
| 209 | { |
| 210 | RequestItems: { |
| 211 | [sourceTableId]: [ |
| 212 | { |
| 213 | PutRequest: { |
| 214 | Item: { |
| 215 | pk: { S: "batch-write#1" }, |
| 216 | sk: { S: "item" }, |
| 217 | data: { S: "first item" }, |
| 218 | }, |
| 219 | }, |
| 220 | }, |
| 221 | { |
| 222 | PutRequest: { |
| 223 | Item: { |
| 224 | pk: { S: "batch-write#2" }, |
| 225 | sk: { S: "item" }, |
| 226 | data: { S: "second item" }, |
| 227 | }, |
| 228 | }, |
| 229 | }, |
| 230 | ], |
| 231 | }, |
| 232 | }, |
| 233 | ), |
| 234 | ).pipe(Effect.flatMap((r) => r.json)); |
| 235 | |
| 236 | expect(Object.keys((response as any).unprocessedItems)).toHaveLength( |
| 237 | 0, |
| 238 | ); |
| 239 | }), |
| 240 | ); |
| 241 | }); |
| 242 | |
| 243 | describe("BatchGetItem", () => { |
| 244 | test.provider("reads multiple items through the bound table", (_stack) => |
| 245 | Effect.gen(function* () { |
| 246 | yield* send( |
| 247 | HttpClientRequest.bodyJsonUnsafe( |
| 248 | HttpClientRequest.post(`${baseUrl}/batch-write`), |
| 249 | { |
| 250 | RequestItems: { |
| 251 | [sourceTableId]: [ |
| 252 | { |
| 253 | PutRequest: { |
| 254 | Item: { |
| 255 | pk: { S: "batch-get#1" }, |
| 256 | sk: { S: "item" }, |
| 257 | data: { S: "first item" }, |
| 258 | }, |
| 259 | }, |
| 260 | }, |
| 261 | { |
| 262 | PutRequest: { |
| 263 | Item: { |
| 264 | pk: { S: "batch-get#2" }, |
| 265 | sk: { S: "item" }, |
| 266 | data: { S: "second item" }, |
| 267 | }, |
| 268 | }, |
| 269 | }, |
| 270 | ], |
| 271 | }, |
| 272 | }, |
| 273 | ), |
| 274 | ); |
| 275 | |
| 276 | const response = yield* send( |
| 277 | HttpClientRequest.bodyJsonUnsafe( |
| 278 | HttpClientRequest.post(`${baseUrl}/batch-get`), |
| 279 | { |
| 280 | RequestItems: { |
| 281 | [sourceTableId]: { |
| 282 | Keys: [ |
| 283 | { pk: { S: "batch-get#1" }, sk: { S: "item" } }, |
| 284 | { pk: { S: "batch-get#2" }, sk: { S: "item" } }, |
| 285 | ], |
| 286 | }, |
| 287 | }, |
| 288 | }, |
| 289 | ), |
| 290 | ).pipe(Effect.flatMap((r) => r.json)); |
| 291 | |
| 292 | const items = Object.values((response as any).responses).flat(); |
| 293 | expect(items).toHaveLength(2); |
| 294 | }), |
| 295 | ); |
| 296 | }); |
| 297 | |
| 298 | describe("UpdateTimeToLive", () => { |
| 299 | test.provider("updates table ttl configuration", (_stack) => |
| 300 | Effect.gen(function* () { |
| 301 | const response = yield* send( |
| 302 | HttpClientRequest.bodyJsonUnsafe( |
| 303 | HttpClientRequest.post(`${baseUrl}/update-ttl`), |
| 304 | { attributeName: "expiresAt", enabled: true }, |
| 305 | ), |
| 306 | ).pipe(Effect.flatMap((r) => r.json)); |
| 307 | |
| 308 | expect((response as any).timeToLiveSpecification).toEqual({ |
| 309 | AttributeName: "expiresAt", |
| 310 | Enabled: true, |
| 311 | }); |
| 312 | }), |
| 313 | ); |
| 314 | }); |
| 315 | |
| 316 | describe("ExecuteStatement", () => { |
| 317 | test.provider( |
| 318 | "executes a PartiQL statement against the bound table", |
| 319 | (_stack) => |
| 320 | Effect.gen(function* () { |
| 321 | yield* send( |
| 322 | HttpClientRequest.bodyJsonUnsafe( |
| 323 | HttpClientRequest.post(`${baseUrl}/put`), |
| 324 | { pk: "statement#1", sk: "item", data: "statement data" }, |
| 325 | ), |
| 326 | ); |
| 327 | |
| 328 | const response = yield* send( |
| 329 | HttpClientRequest.bodyJsonUnsafe( |
| 330 | HttpClientRequest.post(`${baseUrl}/execute-statement`), |
| 331 | { pk: "statement#1", sk: "item" }, |
| 332 | ), |
| 333 | ).pipe(Effect.flatMap((r) => r.json)); |
| 334 | |
| 335 | expect((response as any).items).toHaveLength(1); |
| 336 | expect((response as any).items[0].data.S).toBe("statement data"); |
| 337 | }), |
| 338 | ); |
| 339 | }); |
| 340 | |
| 341 | describe("BatchExecuteStatement", () => { |
| 342 | test.provider( |
| 343 | "executes PartiQL statements against the bound table", |
| 344 | (_stack) => |
| 345 | Effect.gen(function* () { |
| 346 | yield* send( |
| 347 | HttpClientRequest.bodyJsonUnsafe( |
| 348 | HttpClientRequest.post(`${baseUrl}/batch-write`), |
| 349 | { |
| 350 | RequestItems: { |
| 351 | [sourceTableId]: [ |
| 352 | { |
| 353 | PutRequest: { |
| 354 | Item: { |
| 355 | pk: { S: "batch-statement#1" }, |
| 356 | sk: { S: "item" }, |
| 357 | data: { S: "first item" }, |
| 358 | }, |
| 359 | }, |
| 360 | }, |
| 361 | { |
| 362 | PutRequest: { |
| 363 | Item: { |
| 364 | pk: { S: "batch-statement#2" }, |
| 365 | sk: { S: "item" }, |
| 366 | data: { S: "second item" }, |
| 367 | }, |
| 368 | }, |
| 369 | }, |
| 370 | ], |
| 371 | }, |
| 372 | }, |
| 373 | ), |
| 374 | ); |
| 375 | |
| 376 | const response = yield* fetchUntil( |
| 377 | send( |
| 378 | HttpClientRequest.bodyJsonUnsafe( |
| 379 | HttpClientRequest.post(`${baseUrl}/batch-execute-statement`), |
| 380 | { |
| 381 | first: { pk: "batch-statement#1", sk: "item" }, |
| 382 | second: { pk: "batch-statement#2", sk: "item" }, |
| 383 | }, |
| 384 | ), |
| 385 | ).pipe(Effect.flatMap((r) => r.json)), |
| 386 | (body) => |
| 387 | Array.isArray(body?.responses) && body.responses.length === 2, |
| 388 | ); |
| 389 | |
| 390 | expect((response as any).responses).toHaveLength(2); |
| 391 | }), |
| 392 | ); |
| 393 | }); |
| 394 | |
| 395 | describe("ExecuteTransaction", () => { |
| 396 | test.provider( |
| 397 | "executes a PartiQL transaction against the table", |
| 398 | (_stack) => |
| 399 | Effect.gen(function* () { |
| 400 | yield* send( |
| 401 | HttpClientRequest.bodyJsonUnsafe( |
| 402 | HttpClientRequest.post(`${baseUrl}/put`), |
| 403 | { pk: "tx#1", sk: "item1", data: "first" }, |
| 404 | ), |
| 405 | ); |
| 406 | yield* send( |
| 407 | HttpClientRequest.bodyJsonUnsafe( |
| 408 | HttpClientRequest.post(`${baseUrl}/put`), |
| 409 | { pk: "tx#1", sk: "item2", data: "second" }, |
| 410 | ), |
| 411 | ); |
| 412 | |
| 413 | const response = yield* fetchUntil( |
| 414 | send( |
| 415 | HttpClientRequest.post(`${baseUrl}/execute-transaction`), |
| 416 | ).pipe(Effect.flatMap((r) => r.json)), |
| 417 | (body) => |
| 418 | Array.isArray(body?.responses) && body.responses.length === 2, |
| 419 | ); |
| 420 | |
| 421 | expect((response as any).responses).toHaveLength(2); |
| 422 | }), |
| 423 | ); |
| 424 | }); |
| 425 | |
| 426 | describe("TransactWriteItems", () => { |
| 427 | test.provider( |
| 428 | "writes items transactionally through the bound table", |
| 429 | (_stack) => |
| 430 | Effect.gen(function* () { |
| 431 | const response = yield* send( |
| 432 | HttpClientRequest.bodyJsonUnsafe( |
| 433 | HttpClientRequest.post(`${baseUrl}/transact-write`), |
| 434 | { |
| 435 | TransactItems: [ |
| 436 | { |
| 437 | Put: { |
| 438 | Table: sourceTableId, |
| 439 | Item: { |
| 440 | pk: { S: "transact-write#1" }, |
| 441 | sk: { S: "item" }, |
| 442 | data: { S: "first item" }, |
| 443 | }, |
| 444 | }, |
| 445 | }, |
| 446 | { |
| 447 | Put: { |
| 448 | Table: sourceTableId, |
| 449 | Item: { |
| 450 | pk: { S: "transact-write#2" }, |
| 451 | sk: { S: "item" }, |
| 452 | data: { S: "second item" }, |
| 453 | }, |
| 454 | }, |
| 455 | }, |
| 456 | ], |
| 457 | }, |
| 458 | ), |
| 459 | ).pipe(Effect.flatMap((r) => r.json)); |
| 460 | |
| 461 | expect((response as any).success).toBe(true); |
| 462 | }), |
| 463 | ); |
| 464 | }); |
| 465 | |
| 466 | describe("TransactGetItems", () => { |
| 467 | test.provider( |
| 468 | "reads items transactionally through the bound table", |
| 469 | (_stack) => |
| 470 | Effect.gen(function* () { |
| 471 | yield* send( |
| 472 | HttpClientRequest.bodyJsonUnsafe( |
| 473 | HttpClientRequest.post(`${baseUrl}/transact-write`), |
| 474 | { |
| 475 | TransactItems: [ |
| 476 | { |
| 477 | Put: { |
| 478 | Table: sourceTableId, |
| 479 | Item: { |
| 480 | pk: { S: "transact-get#1" }, |
| 481 | sk: { S: "item" }, |
| 482 | data: { S: "first item" }, |
| 483 | }, |
| 484 | }, |
| 485 | }, |
| 486 | { |
| 487 | Put: { |
| 488 | Table: sourceTableId, |
| 489 | Item: { |
| 490 | pk: { S: "transact-get#2" }, |
| 491 | sk: { S: "item" }, |
| 492 | data: { S: "second item" }, |
| 493 | }, |
| 494 | }, |
| 495 | }, |
| 496 | ], |
| 497 | }, |
| 498 | ), |
| 499 | ); |
| 500 | |
| 501 | const response = yield* fetchUntil( |
| 502 | send( |
| 503 | HttpClientRequest.bodyJsonUnsafe( |
| 504 | HttpClientRequest.post(`${baseUrl}/transact-get`), |
| 505 | { |
| 506 | TransactItems: [ |
| 507 | { |
| 508 | Get: { |
| 509 | Table: sourceTableId, |
| 510 | Key: { |
| 511 | pk: { S: "transact-get#1" }, |
| 512 | sk: { S: "item" }, |
| 513 | }, |
| 514 | }, |
| 515 | }, |
| 516 | { |
| 517 | Get: { |
| 518 | Table: sourceTableId, |
| 519 | Key: { |
| 520 | pk: { S: "transact-get#2" }, |
| 521 | sk: { S: "item" }, |
| 522 | }, |
| 523 | }, |
| 524 | }, |
| 525 | ], |
| 526 | }, |
| 527 | ), |
| 528 | ).pipe(Effect.flatMap((r) => r.json)), |
| 529 | (body) => |
| 530 | Array.isArray(body?.responses) && body.responses.length === 2, |
| 531 | ); |
| 532 | |
| 533 | expect((response as any).responses).toHaveLength(2); |
| 534 | }), |
| 535 | ); |
| 536 | }); |
| 537 | |
| 538 | describe("UpdateItem", () => { |
| 539 | test.provider("updates an existing item", (_stack) => |
| 540 | Effect.gen(function* () { |
| 541 | yield* send( |
| 542 | HttpClientRequest.bodyJsonUnsafe( |
| 543 | HttpClientRequest.post(`${baseUrl}/put`), |
| 544 | { pk: "update-test#1", sk: "item", data: "original" }, |
| 545 | ), |
| 546 | ); |
| 547 | |
| 548 | const response = yield* send( |
| 549 | HttpClientRequest.bodyJsonUnsafe( |
| 550 | HttpClientRequest.post(`${baseUrl}/update`), |
| 551 | { pk: "update-test#1", sk: "item", data: "updated" }, |
| 552 | ), |
| 553 | ).pipe(Effect.flatMap((r) => r.json)); |
| 554 | |
| 555 | expect(response).toHaveProperty("success", true); |
| 556 | expect((response as any).attributes.data.S).toBe("updated"); |
| 557 | |
| 558 | const getResponse = yield* HttpClient.get( |
| 559 | `${baseUrl}/get?pk=${encodeURIComponent("update-test#1")}&sk=item`, |
| 560 | ).pipe(Effect.flatMap((r) => r.json)); |
| 561 | |
| 562 | expect((getResponse as any).item.data.S).toBe("updated"); |
| 563 | }), |
| 564 | ); |
| 565 | }); |
| 566 | |
| 567 | describe("DeleteItem", () => { |
| 568 | test.provider("deletes an existing item", (_stack) => |
| 569 | Effect.gen(function* () { |
| 570 | yield* send( |
| 571 | HttpClientRequest.bodyJsonUnsafe( |
| 572 | HttpClientRequest.post(`${baseUrl}/put`), |
| 573 | { pk: "delete-test#1", sk: "item", data: "to delete" }, |
| 574 | ), |
| 575 | ); |
| 576 | |
| 577 | const response = yield* send( |
| 578 | HttpClientRequest.bodyJsonUnsafe( |
| 579 | HttpClientRequest.delete(`${baseUrl}/delete`), |
| 580 | { pk: "delete-test#1", sk: "item" }, |
| 581 | ), |
| 582 | ).pipe(Effect.flatMap((r) => r.json)); |
| 583 | |
| 584 | expect(response).toHaveProperty("success", true); |
| 585 | |
| 586 | const getResponse = yield* HttpClient.get( |
| 587 | `${baseUrl}/get?pk=${encodeURIComponent("delete-test#1")}&sk=item`, |
| 588 | ).pipe(Effect.flatMap((r) => r.json)); |
| 589 | |
| 590 | expect((getResponse as any).item).toBeUndefined(); |
| 591 | }), |
| 592 | ); |
| 593 | }); |
| 594 | |
| 595 | describe("Query", () => { |
| 596 | test.provider("queries items by partition key", (_stack) => |
| 597 | Effect.gen(function* () { |
| 598 | yield* send( |
| 599 | HttpClientRequest.bodyJsonUnsafe( |
| 600 | HttpClientRequest.post(`${baseUrl}/put`), |
| 601 | { pk: "query-test#1", sk: "item1", data: "first" }, |
| 602 | ), |
| 603 | ); |
| 604 | yield* send( |
| 605 | HttpClientRequest.bodyJsonUnsafe( |
| 606 | HttpClientRequest.post(`${baseUrl}/put`), |
| 607 | { pk: "query-test#1", sk: "item2", data: "second" }, |
| 608 | ), |
| 609 | ); |
| 610 | |
| 611 | // DynamoDB Query is eventually consistent by default, so a |
| 612 | // brand-new PutItem may not appear in the immediately-following |
| 613 | // Query. Poll briefly until both items are visible. |
| 614 | const response = yield* Effect.gen(function* () { |
| 615 | const r = yield* HttpClient.get( |
| 616 | `${baseUrl}/query?pk=${encodeURIComponent("query-test#1")}`, |
| 617 | ).pipe(Effect.flatMap((r) => r.json)); |
| 618 | if ((r as any).count !== 2) { |
| 619 | return yield* Effect.fail(new QueryNotConsistent()); |
| 620 | } |
| 621 | return r; |
| 622 | }).pipe( |
| 623 | Effect.retry({ |
| 624 | while: (e) => e._tag === "QueryNotConsistent", |
| 625 | schedule: Schedule.max([ |
| 626 | Schedule.fixed("500 millis"), |
| 627 | Schedule.recurs(20), |
| 628 | ]), |
| 629 | }), |
| 630 | ); |
| 631 | |
| 632 | expect((response as any).count).toBe(2); |
| 633 | expect((response as any).items.length).toBe(2); |
| 634 | }), |
| 635 | ); |
| 636 | |
| 637 | test.provider( |
| 638 | "queries the multi-attribute-key GSI by composite partition key", |
| 639 | (_stack) => |
| 640 | Effect.gen(function* () { |
| 641 | // Items carry natural attributes — the GSI indexes them without |
| 642 | // synthetic concatenated keys. The third item differs only in |
| 643 | // `subcategory`, so the composite partition key must exclude it. |
| 644 | const items = [ |
| 645 | { |
| 646 | pk: "multi-query#1", |
| 647 | sk: "item1", |
| 648 | category: "electronics", |
| 649 | subcategory: "audio", |
| 650 | rank: "featured", |
| 651 | }, |
| 652 | { |
| 653 | pk: "multi-query#2", |
| 654 | sk: "item2", |
| 655 | category: "electronics", |
| 656 | subcategory: "audio", |
| 657 | rank: "standard", |
| 658 | }, |
| 659 | { |
| 660 | pk: "multi-query#3", |
| 661 | sk: "item3", |
| 662 | category: "electronics", |
| 663 | subcategory: "video", |
| 664 | rank: "featured", |
| 665 | }, |
| 666 | ]; |
| 667 | yield* Effect.forEach(items, (item) => |
| 668 | send( |
| 669 | HttpClientRequest.bodyJsonUnsafe( |
| 670 | HttpClientRequest.post(`${baseUrl}/put`), |
| 671 | item, |
| 672 | ), |
| 673 | ), |
| 674 | ); |
| 675 | |
| 676 | // Both HASH attributes must be equality conditions; poll through |
| 677 | // the GSI's asynchronous propagation. |
| 678 | const byPartition = yield* Effect.gen(function* () { |
| 679 | const r = yield* HttpClient.get( |
| 680 | `${baseUrl}/query-multi?category=electronics&subcategory=audio`, |
| 681 | ).pipe(Effect.flatMap((r) => r.json)); |
| 682 | if ((r as any).count !== 2) { |
| 683 | return yield* Effect.fail(new QueryNotConsistent()); |
| 684 | } |
| 685 | return r; |
| 686 | }).pipe( |
| 687 | Effect.retry({ |
| 688 | while: (e) => e._tag === "QueryNotConsistent", |
| 689 | schedule: Schedule.max([ |
| 690 | Schedule.fixed("500 millis"), |
| 691 | Schedule.recurs(30), |
| 692 | ]), |
| 693 | }), |
| 694 | ); |
| 695 | expect((byPartition as any).count).toBe(2); |
| 696 | expect( |
| 697 | ((byPartition as any).items as any[]) |
| 698 | .map((item) => item.pk.S) |
| 699 | .sort(), |
| 700 | ).toEqual(["multi-query#1", "multi-query#2"]); |
| 701 | |
| 702 | // Narrow by the first RANGE attribute (left-to-right). |
| 703 | const byRank = yield* HttpClient.get( |
| 704 | `${baseUrl}/query-multi?category=electronics&subcategory=audio&rank=featured`, |
| 705 | ).pipe(Effect.flatMap((r) => r.json)); |
| 706 | expect((byRank as any).count).toBe(1); |
| 707 | expect((byRank as any).items[0].pk.S).toBe("multi-query#1"); |
| 708 | }), |
| 709 | ); |
| 710 | }); |
| 711 | |
| 712 | describe("ListTables", () => { |
| 713 | test.provider("lists the deployed table", (_stack) => |
| 714 | Effect.gen(function* () { |
| 715 | // DescribeTable can momentarily return an empty body on a freshly |
| 716 | // provisioned table; poll until the table description is populated. |
| 717 | const described = yield* fetchUntil( |
| 718 | HttpClient.get(`${baseUrl}/describe-table`).pipe( |
| 719 | Effect.flatMap((r) => r.json), |
| 720 | ), |
| 721 | (body) => Boolean(body?.table?.TableName), |
| 722 | ); |
| 723 | |
| 724 | // ListTables is eventually consistent and a new table can lag its |
| 725 | // appearance in the account-wide listing by a few seconds. |
| 726 | const response = yield* fetchUntil( |
| 727 | HttpClient.get(`${baseUrl}/list-tables`).pipe( |
| 728 | Effect.flatMap((r) => r.json), |
| 729 | ), |
| 730 | (body) => |
| 731 | Array.isArray(body?.tableNames) && |
| 732 | body.tableNames.includes((described as any).table.TableName), |
| 733 | ); |
| 734 | |
| 735 | expect((response as any).tableNames).toContain( |
| 736 | (described as any).table.TableName, |
| 737 | ); |
| 738 | }), |
| 739 | ); |
| 740 | }); |
| 741 | |
| 742 | describe("ListTagsOfResource", () => { |
| 743 | test.provider("lists alchemy ownership tags for the table", (_stack) => |
| 744 | Effect.gen(function* () { |
| 745 | const response = yield* HttpClient.get(`${baseUrl}/list-tags`).pipe( |
| 746 | Effect.flatMap((r) => r.json), |
| 747 | ); |
| 748 | |
| 749 | const keys = ((response as any).tags ?? []).map( |
| 750 | (tag: any) => tag.Key, |
| 751 | ); |
| 752 | expect(keys).toContain("alchemy::stack"); |
| 753 | expect(keys).toContain("alchemy::stage"); |
| 754 | expect(keys).toContain("alchemy::id"); |
| 755 | }), |
| 756 | ); |
| 757 | }); |
| 758 | |
| 759 | describe("RestoreTableToPointInTime", () => { |
| 760 | test.provider( |
| 761 | "returns a structured error when point-in-time recovery is unavailable", |
| 762 | (_stack) => |
| 763 | Effect.gen(function* () { |
| 764 | // The route always returns a structured `{ ok, ... }` body; a |
| 765 | // missing `ok` means the request hit a not-yet-ready instance, so |
| 766 | // poll until the binding produces its structured result. |
| 767 | const response = yield* fetchUntil( |
| 768 | send( |
| 769 | HttpClientRequest.bodyJsonUnsafe( |
| 770 | HttpClientRequest.post(`${baseUrl}/restore-table`), |
| 771 | {}, |
| 772 | ), |
| 773 | ).pipe(Effect.flatMap((r) => r.json)), |
| 774 | (body) => typeof body?.ok === "boolean", |
| 775 | ); |
| 776 | |
| 777 | expect((response as any).ok).toBe(false); |
| 778 | expect([ |
| 779 | "PointInTimeRecoveryUnavailableException", |
| 780 | "TableAlreadyExistsException", |
| 781 | ]).toContain((response as any).error); |
| 782 | }), |
| 783 | ); |
| 784 | }); |
| 785 | |
| 786 | describe("Scan", () => { |
| 787 | test.provider("scans all items in the table", (_stack) => |
| 788 | Effect.gen(function* () { |
| 789 | yield* send( |
| 790 | HttpClientRequest.bodyJsonUnsafe( |
| 791 | HttpClientRequest.post(`${baseUrl}/put`), |
| 792 | { pk: "scan-test#1", sk: "item", data: "scan data" }, |
| 793 | ), |
| 794 | ); |
| 795 | |
| 796 | const response = yield* HttpClient.get(`${baseUrl}/scan`).pipe( |
| 797 | Effect.flatMap((r) => r.json), |
| 798 | ); |
| 799 | |
| 800 | expect((response as any).count).toBeGreaterThanOrEqual(1); |
| 801 | expect((response as any).items.length).toBeGreaterThanOrEqual(1); |
| 802 | }), |
| 803 | ); |
| 804 | }); |
| 805 | |
| 806 | describe("Backups", () => { |
| 807 | test.provider( |
| 808 | "creates, describes, lists, restore-conflicts, and deletes a backup", |
| 809 | (_stack) => |
| 810 | Effect.gen(function* () { |
| 811 | const created = (yield* send( |
| 812 | HttpClientRequest.bodyJsonUnsafe( |
| 813 | HttpClientRequest.post(`${baseUrl}/create-backup`), |
| 814 | { name: "bindings-test-backup" }, |
| 815 | ), |
| 816 | ).pipe(Effect.flatMap((r) => r.json))) as { |
| 817 | backupArn?: string; |
| 818 | status?: string; |
| 819 | }; |
| 820 | expect(created.backupArn).toBeTruthy(); |
| 821 | const backupArn = created.backupArn!; |
| 822 | |
| 823 | yield* Effect.gen(function* () { |
| 824 | // Wait for the on-demand backup to become AVAILABLE (it is |
| 825 | // CREATING for a few seconds on an empty table). |
| 826 | const described = yield* fetchUntil<{ status?: string }>( |
| 827 | HttpClient.get( |
| 828 | `${baseUrl}/describe-backup?arn=${encodeURIComponent(backupArn)}`, |
| 829 | ).pipe(Effect.flatMap((r) => r.json)), |
| 830 | (body) => body?.status === "AVAILABLE", |
| 831 | ); |
| 832 | expect(described.status).toBe("AVAILABLE"); |
| 833 | |
| 834 | const listed = (yield* HttpClient.get( |
| 835 | `${baseUrl}/list-backups`, |
| 836 | ).pipe(Effect.flatMap((r) => r.json))) as { |
| 837 | backupArns: string[]; |
| 838 | }; |
| 839 | expect(listed.backupArns).toContain(backupArn); |
| 840 | |
| 841 | // Restoring into the already-deployed target table conflicts — |
| 842 | // proves the RestoreTableFromBackup binding round-trips with a |
| 843 | // typed error and never creates an unmanaged table. |
| 844 | const restored = (yield* send( |
| 845 | HttpClientRequest.bodyJsonUnsafe( |
| 846 | HttpClientRequest.post(`${baseUrl}/restore-from-backup`), |
| 847 | { arn: backupArn }, |
| 848 | ), |
| 849 | ).pipe(Effect.flatMap((r) => r.json))) as { |
| 850 | ok: boolean; |
| 851 | error?: string; |
| 852 | }; |
| 853 | expect(restored.ok).toBe(false); |
| 854 | expect(restored.error).toBe("TableAlreadyExistsException"); |
| 855 | }).pipe( |
| 856 | // Always delete the backup, even if an assertion above failed — |
| 857 | // zero orphans. |
| 858 | Effect.ensuring( |
| 859 | send( |
| 860 | HttpClientRequest.bodyJsonUnsafe( |
| 861 | HttpClientRequest.delete(`${baseUrl}/delete-backup`), |
| 862 | { arn: backupArn }, |
| 863 | ), |
| 864 | ).pipe(Effect.ignore), |
| 865 | ), |
| 866 | ); |
| 867 | |
| 868 | // The delete in `ensuring` already ran; verify it took effect (the |
| 869 | // backup drops out of ListBackups, allowing for eventual |
| 870 | // consistency). |
| 871 | const afterDelete = yield* fetchUntil<{ backupArns: string[] }>( |
| 872 | HttpClient.get(`${baseUrl}/list-backups`).pipe( |
| 873 | Effect.flatMap((r) => r.json), |
| 874 | ), |
| 875 | (body) => |
| 876 | Array.isArray(body?.backupArns) && |
| 877 | !body.backupArns.includes(backupArn), |
| 878 | ); |
| 879 | expect(afterDelete.backupArns).not.toContain(backupArn); |
| 880 | }), |
| 881 | { timeout: 120_000 }, |
| 882 | ); |
| 883 | }); |
| 884 | |
| 885 | describe("DescribeContinuousBackups", () => { |
| 886 | test.provider("reads the continuous backups / PITR status", (_stack) => |
| 887 | Effect.gen(function* () { |
| 888 | const response = (yield* HttpClient.get( |
| 889 | `${baseUrl}/describe-continuous-backups`, |
| 890 | ).pipe(Effect.flatMap((r) => r.json))) as { |
| 891 | continuousBackupsStatus?: string; |
| 892 | pitrStatus?: string; |
| 893 | }; |
| 894 | |
| 895 | expect(response.continuousBackupsStatus).toBe("ENABLED"); |
| 896 | // The fixture table does not enable PITR. |
| 897 | expect(response.pitrStatus).toBe("DISABLED"); |
| 898 | }), |
| 899 | ); |
| 900 | }); |
| 901 | |
| 902 | describe("Exports", () => { |
| 903 | test.provider( |
| 904 | "surfaces a typed error when PITR is unavailable and lists exports", |
| 905 | (_stack) => |
| 906 | Effect.gen(function* () { |
| 907 | // PITR is disabled on the fixture table, so starting an export |
| 908 | // fails fast with the typed tag (and never writes to the bucket). |
| 909 | const exported = (yield* send( |
| 910 | HttpClientRequest.bodyJsonUnsafe( |
| 911 | HttpClientRequest.post(`${baseUrl}/export-table`), |
| 912 | {}, |
| 913 | ), |
| 914 | ).pipe(Effect.flatMap((r) => r.json))) as { |
| 915 | ok: boolean; |
| 916 | error?: string; |
| 917 | }; |
| 918 | expect(exported.ok).toBe(false); |
| 919 | expect(exported.error).toBe( |
| 920 | "PointInTimeRecoveryUnavailableException", |
| 921 | ); |
| 922 | |
| 923 | const listed = (yield* HttpClient.get( |
| 924 | `${baseUrl}/list-exports`, |
| 925 | ).pipe(Effect.flatMap((r) => r.json))) as { exportArns: string[] }; |
| 926 | expect(Array.isArray(listed.exportArns)).toBe(true); |
| 927 | expect(listed.exportArns).toHaveLength(0); |
| 928 | }), |
| 929 | { timeout: 120_000 }, |
| 930 | ); |
| 931 | |
| 932 | test.provider( |
| 933 | "DescribeExport returns a typed not-found error", |
| 934 | (_stack) => |
| 935 | Effect.gen(function* () { |
| 936 | const described = (yield* HttpClient.get( |
| 937 | `${baseUrl}/describe-table`, |
| 938 | ).pipe(Effect.flatMap((r) => r.json))) as { |
| 939 | table: { TableArn: string }; |
| 940 | }; |
| 941 | const exportArn = `${described.table.TableArn}/export/01700000000000-00000000`; |
| 942 | |
| 943 | const response = (yield* HttpClient.get( |
| 944 | `${baseUrl}/describe-export?arn=${encodeURIComponent(exportArn)}`, |
| 945 | ).pipe(Effect.flatMap((r) => r.json))) as { |
| 946 | ok: boolean; |
| 947 | error?: string; |
| 948 | }; |
| 949 | expect(response.ok).toBe(false); |
| 950 | expect(response.error).toBe("ExportNotFoundException"); |
| 951 | }), |
| 952 | ); |
| 953 | }); |
| 954 | }, |
| 955 | ); |
| 956 | |
| 957 | class QueryNotConsistent extends Data.TaggedError("QueryNotConsistent") {} |
| 958 | |
| 959 | // A request hit a Lambda instance that hadn't fully converged yet (cold |
| 960 | // secondary instance, IAM propagation lag, control-plane eventual |
| 961 | // consistency), so the bound call returned a not-yet-populated shape. Retry. |
| 962 | class BindingNotConsistent extends Data.TaggedError("BindingNotConsistent") {} |
| 963 | |
| 964 | // Poll an HTTP-driven binding call until its parsed body satisfies `ready`. |
| 965 | // Control-plane reads (ListTables/DescribeTable) and PartiQL transactions can |
| 966 | // briefly observe stale/empty state right after the table is provisioned. |
| 967 | const fetchUntil = <A>( |
| 968 | fetch: Effect.Effect<unknown, any, HttpClient.HttpClient>, |
| 969 | ready: (body: any) => boolean, |
| 970 | ) => |
| 971 | fetch.pipe( |
| 972 | Effect.flatMap((body) => |
| 973 | ready(body) |
| 974 | ? Effect.succeed(body as A) |
| 975 | : Effect.fail(new BindingNotConsistent()), |
| 976 | ), |
| 977 | Effect.retry({ |
| 978 | while: (e) => e._tag === "BindingNotConsistent", |
| 979 | schedule: Schedule.max([ |
| 980 | Schedule.fixed("2 seconds"), |
| 981 | Schedule.recurs(20), |
| 982 | ]), |
| 983 | }), |
| 984 | ); |