1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import DynamoDBTestFunctionLive, { DynamoDBTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const 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.
21const readinessPolicy = Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(75),
24]);
25
26let baseUrl: string;
27const sourceTableId = "TestTable";
28
29class 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).
40const 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
62describe(
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
957class 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.
962class 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.
967const 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 );