Skip to content
Source
Sign inSign up

handler.ts

532 lines19.9 KB

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