1import * as DynamoDB from "@/AWS/DynamoDB";
2import * as Lambda from "@/AWS/Lambda";
3import * as S3 from "@/AWS/S3";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12export class DynamoDBTestFunction extends Lambda.Function<Lambda.Function>()(
13 "DynamoDBTestFunction",
14) {}
15
16export default DynamoDBTestFunction.make(
17 {
18 main,
19 functionUrl: true,
20 },
21 Effect.gen(function* () {
22 const sourceTable = yield* DynamoDB.Table("TestTable", {
23 partitionKey: "pk",
24 sortKey: "sk",
25 attributes: {
26 pk: "S",
27 sk: "S",
28 category: "S",
29 subcategory: "S",
30 rank: "S",
31 },
32 globalSecondaryIndexes: [
33 {
34 // Multi-attribute keys: composite partition key (category +
35 // subcategory) and composite sort key (rank + sk). Sparse — only
36 // items written with all four attributes appear in the index.
37 indexName: "MultiAttrIndex",
38 partitionKey: ["category", "subcategory"],
39 sortKey: ["rank", "sk"],
40 projection: { ProjectionType: "ALL" },
41 },
42 ],
43 });
44 const restoreTargetTable = yield* DynamoDB.Table("RestoreTargetTable", {
45 partitionKey: "pk",
46 sortKey: "sk",
47 attributes: { pk: "S", sk: "S" },
48 });
49 const exportBucket = yield* S3.Bucket("ExportBucket", {
50 forceDestroy: true,
51 });
52
53 const getItem = yield* DynamoDB.GetItem(sourceTable);
54 const batchGetItem = yield* DynamoDB.BatchGetItem(sourceTable);
55 const batchWriteItem = yield* DynamoDB.BatchWriteItem(sourceTable);
56 const batchExecuteStatement =
57 yield* DynamoDB.BatchExecuteStatement(sourceTable);
58 const describeTable = yield* DynamoDB.DescribeTable(sourceTable);
59 const describeTimeToLive = yield* DynamoDB.DescribeTimeToLive(sourceTable);
60 const executeStatement = yield* DynamoDB.ExecuteStatement(sourceTable);
61 const executeTransaction = yield* DynamoDB.ExecuteTransaction(sourceTable);
62 const putItem = yield* DynamoDB.PutItem(sourceTable);
63 const deleteItem = yield* DynamoDB.DeleteItem(sourceTable);
64 const listTables = yield* DynamoDB.ListTables();
65 const listTagsOfResource = yield* DynamoDB.ListTagsOfResource(sourceTable);
66 const updateItem = yield* DynamoDB.UpdateItem(sourceTable);
67 const updateTimeToLive = yield* DynamoDB.UpdateTimeToLive(sourceTable);
68 const query = yield* DynamoDB.Query(sourceTable);
69 const scan = yield* DynamoDB.Scan(sourceTable);
70 const TableName = yield* sourceTable.tableName;
71 const restoreTableToPointInTime = yield* DynamoDB.RestoreTableToPointInTime(
72 sourceTable,
73 restoreTargetTable,
74 );
75 const transactGetItems = yield* DynamoDB.TransactGetItems(sourceTable);
76 const transactWriteItems = yield* DynamoDB.TransactWriteItems(sourceTable);
77 const createBackup = yield* DynamoDB.CreateBackup(sourceTable);
78 const describeBackup = yield* DynamoDB.DescribeBackup(sourceTable);
79 const listBackups = yield* DynamoDB.ListBackups(sourceTable);
80 const deleteBackup = yield* DynamoDB.DeleteBackup(sourceTable);
81 const describeContinuousBackups =
82 yield* DynamoDB.DescribeContinuousBackups(sourceTable);
83 const restoreTableFromBackup = yield* DynamoDB.RestoreTableFromBackup(
84 sourceTable,
85 restoreTargetTable,
86 );
87 const exportTableToPointInTime = yield* DynamoDB.ExportTableToPointInTime(
88 sourceTable,
89 exportBucket,
90 );
91 const describeExport = yield* DynamoDB.DescribeExport(sourceTable);
92 const listExports = yield* DynamoDB.ListExports(sourceTable);
93
94 return {
95 fetch: Effect.gen(function* () {
96 const request = yield* HttpServerRequest;
97 const url = new URL(request.originalUrl);
98 const pathname = url.pathname;
99
100 yield* Effect.logInfo(
101 `Request: ${request.method} ${pathname} (originalUrl: ${request.originalUrl}, url: ${request.url})`,
102 );
103
104 if (request.method === "POST" && pathname === "/put") {
105 const body = (yield* request.json) as unknown as {
106 pk: string;
107 sk: string;
108 data?: string;
109 category?: string;
110 subcategory?: string;
111 rank?: string;
112 };
113 const result = yield* putItem({
114 Item: {
115 pk: { S: body.pk },
116 sk: { S: body.sk },
117 ...(body.data ? { data: { S: body.data } } : {}),
118 // Natural attributes indexed by the multi-attribute-key GSI —
119 // no synthetic concatenated keys.
120 ...(body.category ? { category: { S: body.category } } : {}),
121 ...(body.subcategory
122 ? { subcategory: { S: body.subcategory } }
123 : {}),
124 ...(body.rank ? { rank: { S: body.rank } } : {}),
125 },
126 });
127 return yield* HttpServerResponse.json({ success: true, result });
128 }
129
130 if (request.method === "GET" && pathname === "/get") {
131 const pk = url.searchParams.get("pk");
132 const sk = url.searchParams.get("sk");
133 if (!pk || !sk) {
134 return HttpServerResponse.text("Missing pk or sk", { status: 400 });
135 }
136 const result = yield* getItem({
137 Key: {
138 pk: { S: pk },
139 sk: { S: sk },
140 },
141 });
142 return yield* HttpServerResponse.json({ item: result.Item });
143 }
144
145 if (request.method === "GET" && pathname === "/describe-table") {
146 const result = yield* describeTable();
147 return yield* HttpServerResponse.json({
148 table: result.Table,
149 });
150 }
151
152 if (request.method === "GET" && pathname === "/describe-ttl") {
153 const result = yield* describeTimeToLive();
154 return yield* HttpServerResponse.json({
155 timeToLiveDescription: result.TimeToLiveDescription,
156 });
157 }
158
159 if (request.method === "DELETE" && pathname === "/delete") {
160 const body = (yield* request.json) as unknown as {
161 pk: string;
162 sk: string;
163 };
164 const result = yield* deleteItem({
165 Key: {
166 pk: { S: body.pk },
167 sk: { S: body.sk },
168 },
169 });
170 return yield* HttpServerResponse.json({ success: true, result });
171 }
172
173 if (request.method === "POST" && pathname === "/update") {
174 const body = (yield* request.json) as unknown as {
175 pk: string;
176 sk: string;
177 data: string;
178 };
179 const result = yield* updateItem({
180 Key: {
181 pk: { S: body.pk },
182 sk: { S: body.sk },
183 },
184 UpdateExpression: "SET #data = :data",
185 ExpressionAttributeNames: { "#data": "data" },
186 ExpressionAttributeValues: { ":data": { S: body.data } },
187 ReturnValues: "ALL_NEW",
188 });
189 return yield* HttpServerResponse.json({
190 success: true,
191 attributes: result.Attributes,
192 });
193 }
194
195 if (request.method === "POST" && pathname === "/update-ttl") {
196 const body = (yield* request.json) as unknown as {
197 attributeName: string;
198 enabled: boolean;
199 };
200 const result = yield* updateTimeToLive({
201 TimeToLiveSpecification: {
202 AttributeName: body.attributeName,
203 Enabled: body.enabled,
204 },
205 });
206 return yield* HttpServerResponse.json({
207 timeToLiveSpecification: result.TimeToLiveSpecification,
208 });
209 }
210
211 if (request.method === "POST" && pathname === "/batch-write") {
212 const body =
213 (yield* request.json) as unknown as DynamoDB.BatchWriteItemRequest;
214 const result = yield* batchWriteItem(body);
215 return yield* HttpServerResponse.json({
216 unprocessedItems: result.UnprocessedItems ?? {},
217 });
218 }
219
220 if (request.method === "POST" && pathname === "/batch-get") {
221 const body =
222 (yield* request.json) as unknown as DynamoDB.BatchGetItemRequest;
223 const result = yield* batchGetItem(body);
224 return yield* HttpServerResponse.json({
225 responses: result.Responses ?? {},
226 unprocessedKeys: result.UnprocessedKeys ?? {},
227 });
228 }
229
230 if (request.method === "POST" && pathname === "/transact-write") {
231 const body =
232 (yield* request.json) as unknown as DynamoDB.TransactWriteItemsRequest;
233 const result = yield* transactWriteItems(body);
234 return yield* HttpServerResponse.json({
235 success: true,
236 result,
237 });
238 }
239
240 if (request.method === "POST" && pathname === "/transact-get") {
241 const body =
242 (yield* request.json) as unknown as DynamoDB.TransactGetItemsRequest;
243 const result = yield* transactGetItems(body);
244 return yield* HttpServerResponse.json({
245 responses: result.Responses ?? [],
246 });
247 }
248
249 if (request.method === "POST" && pathname === "/execute-transaction") {
250 const tableName = yield* TableName;
251 const result = yield* executeTransaction({
252 TransactStatements: [
253 {
254 Statement: `SELECT * FROM "${tableName}" WHERE pk=? AND sk=?`,
255 Parameters: [{ S: "tx#1" }, { S: "item1" }],
256 },
257 {
258 Statement: `SELECT * FROM "${tableName}" WHERE pk=? AND sk=?`,
259 Parameters: [{ S: "tx#1" }, { S: "item2" }],
260 },
261 ],
262 });
263 return yield* HttpServerResponse.json({
264 responses: result.Responses,
265 });
266 }
267
268 if (request.method === "POST" && pathname === "/execute-statement") {
269 const body = (yield* request.json) as unknown as {
270 pk: string;
271 sk: string;
272 };
273 const tableName = yield* TableName;
274 const result = yield* executeStatement({
275 Statement: `SELECT * FROM "${tableName}" WHERE pk=? AND sk=?`,
276 Parameters: [{ S: body.pk }, { S: body.sk }],
277 });
278 return yield* HttpServerResponse.json({
279 items: result.Items ?? [],
280 });
281 }
282
283 if (
284 request.method === "POST" &&
285 pathname === "/batch-execute-statement"
286 ) {
287 const body = (yield* request.json) as unknown as {
288 first: { pk: string; sk: string };
289 second: { pk: string; sk: string };
290 };
291 const sourceTableName = yield* TableName;
292 const result = yield* batchExecuteStatement({
293 Statements: [
294 {
295 Statement: `SELECT * FROM "${sourceTableName}" WHERE pk=? AND sk=?`,
296 Parameters: [{ S: body.first.pk }, { S: body.first.sk }],
297 },
298 {
299 Statement: `SELECT * FROM "${sourceTableName}" WHERE pk=? AND sk=?`,
300 Parameters: [{ S: body.second.pk }, { S: body.second.sk }],
301 },
302 ],
303 });
304 return yield* HttpServerResponse.json({
305 responses: result.Responses ?? [],
306 });
307 }
308
309 if (request.method === "POST" && pathname === "/restore-table") {
310 const result = yield* restoreTableToPointInTime({
311 UseLatestRestorableTime: true,
312 }).pipe(
313 Effect.map((result) => ({
314 ok: true as const,
315 result,
316 })),
317 Effect.catch((error) =>
318 Effect.succeed({
319 ok: false as const,
320 error:
321 typeof error === "object" && error !== null && "_tag" in error
322 ? (error as { _tag: string })._tag
323 : `${error}`,
324 }),
325 ),
326 );
327 return yield* HttpServerResponse.json(result);
328 }
329
330 if (request.method === "GET" && pathname === "/query") {
331 const pk = url.searchParams.get("pk");
332 if (!pk) {
333 return HttpServerResponse.text("Missing pk", { status: 400 });
334 }
335 const result = yield* query({
336 KeyConditionExpression: "pk = :pk",
337 ExpressionAttributeValues: { ":pk": { S: pk } },
338 });
339 return yield* HttpServerResponse.json({
340 items: result.Items,
341 count: result.Count,
342 });
343 }
344
345 if (request.method === "GET" && pathname === "/query-multi") {
346 const category = url.searchParams.get("category");
347 const subcategory = url.searchParams.get("subcategory");
348 const rank = url.searchParams.get("rank");
349 if (!category || !subcategory) {
350 return HttpServerResponse.text("Missing category or subcategory", {
351 status: 400,
352 });
353 }
354 // Multi-attribute-key GSI query: every partition attribute must be
355 // an equality condition; sort attributes narrow left-to-right.
356 const result = yield* query({
357 IndexName: "MultiAttrIndex",
358 KeyConditionExpression: rank
359 ? "#c = :c AND #s = :s AND #r = :r"
360 : "#c = :c AND #s = :s",
361 ExpressionAttributeNames: {
362 "#c": "category",
363 "#s": "subcategory",
364 ...(rank ? { "#r": "rank" } : {}),
365 },
366 ExpressionAttributeValues: {
367 ":c": { S: category },
368 ":s": { S: subcategory },
369 ...(rank ? { ":r": { S: rank } } : {}),
370 },
371 });
372 return yield* HttpServerResponse.json({
373 items: result.Items,
374 count: result.Count,
375 });
376 }
377
378 if (request.method === "GET" && pathname === "/list-tables") {
379 const result = yield* listTables();
380 return yield* HttpServerResponse.json({
381 tableNames: result.TableNames,
382 });
383 }
384
385 if (request.method === "GET" && pathname === "/list-tags") {
386 const result = yield* listTagsOfResource();
387 return yield* HttpServerResponse.json({
388 tags: result.Tags,
389 });
390 }
391
392 if (request.method === "POST" && pathname === "/create-backup") {
393 const body = (yield* request.json) as unknown as { name: string };
394 const result = yield* createBackup({ BackupName: body.name });
395 return yield* HttpServerResponse.json({
396 backupArn: result.BackupDetails?.BackupArn,
397 status: result.BackupDetails?.BackupStatus,
398 });
399 }
400
401 if (request.method === "GET" && pathname === "/describe-backup") {
402 const arn = url.searchParams.get("arn");
403 if (!arn) {
404 return HttpServerResponse.text("Missing arn", { status: 400 });
405 }
406 const result = yield* describeBackup({ BackupArn: arn });
407 return yield* HttpServerResponse.json({
408 status: result.BackupDescription?.BackupDetails?.BackupStatus,
409 });
410 }
411
412 if (request.method === "GET" && pathname === "/list-backups") {
413 const result = yield* listBackups();
414 return yield* HttpServerResponse.json({
415 backupArns: (result.BackupSummaries ?? []).map(
416 (summary) => summary.BackupArn,
417 ),
418 });
419 }
420
421 if (request.method === "DELETE" && pathname === "/delete-backup") {
422 const body = (yield* request.json) as unknown as { arn: string };
423 const result = yield* deleteBackup({ BackupArn: body.arn }).pipe(
424 Effect.map((output) => ({
425 ok: true as const,
426 status: output.BackupDescription?.BackupDetails?.BackupStatus,
427 })),
428 Effect.catch((error) =>
429 Effect.succeed({ ok: false as const, error: error._tag }),
430 ),
431 );
432 return yield* HttpServerResponse.json(result);
433 }
434
435 if (
436 request.method === "GET" &&
437 pathname === "/describe-continuous-backups"
438 ) {
439 const result = yield* describeContinuousBackups();
440 return yield* HttpServerResponse.json({
441 continuousBackupsStatus:
442 result.ContinuousBackupsDescription?.ContinuousBackupsStatus,
443 pitrStatus:
444 result.ContinuousBackupsDescription
445 ?.PointInTimeRecoveryDescription?.PointInTimeRecoveryStatus,
446 });
447 }
448
449 if (request.method === "POST" && pathname === "/restore-from-backup") {
450 const body = (yield* request.json) as unknown as { arn: string };
451 const result = yield* restoreTableFromBackup({
452 BackupArn: body.arn,
453 }).pipe(
454 Effect.map((output) => ({
455 ok: true as const,
456 status: output.TableDescription?.TableStatus,
457 })),
458 Effect.catch((error) =>
459 Effect.succeed({ ok: false as const, error: error._tag }),
460 ),
461 );
462 return yield* HttpServerResponse.json(result);
463 }
464
465 if (request.method === "POST" && pathname === "/export-table") {
466 const result = yield* exportTableToPointInTime({
467 ExportFormat: "DYNAMODB_JSON",
468 }).pipe(
469 Effect.map((output) => ({
470 ok: true as const,
471 exportArn: output.ExportDescription?.ExportArn,
472 })),
473 Effect.catch((error) =>
474 Effect.succeed({ ok: false as const, error: error._tag }),
475 ),
476 );
477 return yield* HttpServerResponse.json(result);
478 }
479
480 if (request.method === "GET" && pathname === "/list-exports") {
481 const result = yield* listExports();
482 return yield* HttpServerResponse.json({
483 exportArns: (result.ExportSummaries ?? []).map(
484 (summary) => summary.ExportArn,
485 ),
486 });
487 }
488
489 if (request.method === "GET" && pathname === "/describe-export") {
490 const arn = url.searchParams.get("arn");
491 if (!arn) {
492 return HttpServerResponse.text("Missing arn", { status: 400 });
493 }
494 const result = yield* describeExport({ ExportArn: arn }).pipe(
495 Effect.map((output) => ({
496 ok: true as const,
497 status: output.ExportDescription?.ExportStatus,
498 })),
499 Effect.catch((error) =>
500 Effect.succeed({ ok: false as const, error: error._tag }),
501 ),
502 );
503 return yield* HttpServerResponse.json(result);
504 }
505
506 if (request.method === "GET" && pathname === "/scan") {
507 const result = yield* scan({});
508 return yield* HttpServerResponse.json({
509 items: result.Items,
510 count: result.Count,
511 });
512 }
513
514 return yield* HttpServerResponse.json(
515 {
516 error: "Not found",
517 method: request.method,
518 pathname,
519 url: request.url,
520 },
521 { status: 404 },
522 );
523 }).pipe(Effect.orDie),
524 };
525 }).pipe(
526 Effect.provide(
527 Layer.mergeAll(
528 DynamoDB.BatchExecuteStatementHttp,
529 DynamoDB.BatchGetItemHttp,
530 DynamoDB.BatchWriteItemHttp,
531 DynamoDB.CreateBackupHttp,
532 DynamoDB.DeleteBackupHttp,
533 DynamoDB.DescribeBackupHttp,
534 DynamoDB.DescribeContinuousBackupsHttp,
535 DynamoDB.DescribeExportHttp,
536 DynamoDB.ExportTableToPointInTimeHttp,
537 DynamoDB.ListBackupsHttp,
538 DynamoDB.ListExportsHttp,
539 DynamoDB.RestoreTableFromBackupHttp,
540 DynamoDB.DescribeTableHttp,
541 DynamoDB.DescribeTimeToLiveHttp,
542 DynamoDB.ExecuteStatementHttp,
543 DynamoDB.ExecuteTransactionHttp,
544 DynamoDB.GetItemHttp,
545 DynamoDB.ListTablesHttp,
546 DynamoDB.ListTagsOfResourceHttp,
547 DynamoDB.PutItemHttp,
548 DynamoDB.DeleteItemHttp,
549 DynamoDB.UpdateItemHttp,
550 DynamoDB.UpdateTimeToLiveHttp,
551 DynamoDB.QueryHttp,
552 DynamoDB.RestoreTableToPointInTimeHttp,
553 DynamoDB.ScanHttp,
554 DynamoDB.TransactGetItemsHttp,
555 DynamoDB.TransactWriteItemsHttp,
556 ),
557 ),
558 ),
559);