Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Use the [SQL documentation](https://alchemy.run/sql) to choose a client or

| Runtime | Database | Client | Runnable project |
| --- | --- | --- | --- |
| AWS Lambda | Aurora PostgreSQL | Drizzle | [aws-aurora-drizzle](./aws-aurora-drizzle) |
| AWS Lambda | Aurora PostgreSQL | `pg` | [aws-rds](./aws-rds) |
| Cloudflare Workers | Neon Postgres through Hyperdrive | Drizzle | [cloudflare-neon-drizzle](./cloudflare-neon-drizzle) |
| Cloudflare Workers | PlanetScale Postgres through Hyperdrive | Drizzle | [cloudflare-planetscale-postgres-drizzle](./cloudflare-planetscale-postgres-drizzle) |
Expand All @@ -20,10 +21,11 @@ Use the [SQL documentation](https://alchemy.run/sql) to choose a client or

### Looking for Drizzle + Aurora?

The current `aws-rds` application uses **plain `pg`**, not Drizzle.
An [Aurora PostgreSQL + Drizzle IAM test fixture](../packages/alchemy/test/AWS/RDSData/drizzle-iam-handler.ts)
exists, but depends on the surrounding test infrastructure and database-user
bootstrap. It is not a standalone example.
Start with [aws-aurora-drizzle](./aws-aurora-drizzle): a standalone Lambda
application with private Aurora PostgreSQL, IAM authentication, verified TLS,
and deployment-time schema setup. Its [integration guide](https://alchemy.run/aws/data/drizzle-aurora)
explains the connection and migration placement. The separate `aws-rds`
application uses **plain `pg`**, not Drizzle.

The [AWS full-stack Drizzle guide](https://alchemy.run/aws/frontend/full-stack-tanstack-rpc-drizzle)
uses **Aurora DSQL**, a different database and deployment from Aurora
Expand Down
71 changes: 71 additions & 0 deletions examples/aws-aurora-drizzle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Drizzle + Aurora PostgreSQL + AWS Lambda

A standalone example using `alchemy/Drizzle/Postgres` with Aurora PostgreSQL 17, IAM database authentication, verified TLS, and a Lambda HTTP handler. This is **Aurora PostgreSQL**, not Aurora DSQL or Drizzle over the RDS Data API.

[Integration guide](https://alchemy.run/aws/data/drizzle-aurora/) · [SQL on AWS](https://alchemy.run/sql/providers/aws/)

## Prerequisites

- Install the repository dependencies with `pnpm install` from the repository root.
- Configure an [Alchemy AWS profile](https://alchemy.run/aws/setup/) with deployment permissions for VPC, RDS, Secrets Manager, IAM, Lambda, CloudWatch Logs, and the RDS Data API.
- Use **us-west-2**: the two private subnets are in `us-west-2a` and `us-west-2b`. Changing region also requires changing those availability zones and checking Aurora version/Data API availability.
- Allow several minutes for Aurora creation and deletion. The cluster incurs charges until destroyed (minimum 0.5 Aurora capacity units). This example is not configured for auto-pause or production retention.

## Deploy

From this directory:

```sh
pnpm deploy --profile testing --stage aurora-example
```

`src/database.ts` creates an isolated VPC, two subnets, security groups, a generated admin secret, and an Aurora writer. Only the Lambda security group may connect to TCP 5432. There is no public database endpoint, NAT gateway, or internet gateway.

`src/bootstrap.ts` runs a deployment Action using the Data API and the deployment identity. Its atomic, replay-safe initial migration creates the table and `app_iam` login, grants `rds_iam`, and grants only table CRUD and schema usage. The Action waits for the writer and its output orders Lambda deployment after setup. It runs again when its input changes or deployment is forced; it is not a general Drizzle migration runner. For schema evolution, add reviewed, versioned migrations with a history table rather than editing an already-applied initial migration.

The deployment machine needs access to AWS HTTPS APIs, **not** a route into the VPC. Runtime queries use a private PostgreSQL socket, not the Data API. The application role has `rds-db:connect` for `app_iam`; it does not receive the admin secret or Data API grants.

## Call the HTTP API

The output `url` uses AWS IAM authentication. Unsigned HTTP requests are rejected. Use a SigV4-capable HTTP client authorized for both `lambda:InvokeFunctionUrl` and `lambda:InvokeFunction` on this function. Alternatively, invoke the same HTTP handler using the AWS CLI and the output `functionName`:

```sh
aws lambda invoke --region us-west-2 \
--function-name '<functionName>' \
--cli-binary-format raw-in-base64-out \
--payload file://health-event.json response.json
```

The AWS CLI uses its own configured credentials, not Alchemy's `--profile` setting. `response.json` contains an HTTP response whose JSON body reports `app_iam`, TLS enabled, INSERT allowed, and schema CREATE denied.

| Method | Path | Body |
| --- | --- | --- |
| GET | `/health` | — |
| GET | `/todos` | — |
| POST | `/todos` | `{"id":"aaaaaaaa-0000-4000-8000-000000000001","title":"Ship it"}` |
| PATCH | `/todos/<uuid>` | `{"done":true}` |
| DELETE | `/todos/<uuid>` | — |

For direct invocation, change `rawPath` and `requestContext.http.path` in the event, set `requestContext.http.method`, and encode the request JSON as the event's `body` string. There is no `/setup` or `/migrate` route.

## TLS and connection lifetime

The function sets `NODE_EXTRA_CA_CERTS=/var/runtime/ca-cert.pem`, loading the Amazon CAs supplied by the managed Node.js Lambda runtime. It changes the connection URL's current `sslmode=no-verify` default to `verify-full`. Both hostname and certificate-chain verification remain enabled. This certificate path is specific to the managed Lambda runtime, not a portable container path.

The connection Effect generates a fresh IAM token when Drizzle first opens a pool in each invocation. Queries in that invocation share the pool; the invocation scope closes it afterward. No password or token is logged or returned by the API.

## Live integration test

```sh
AWS_TEST_SLOW=1 ALCHEMY_PROFILE=testing bun test test/integ.test.ts
```

This opt-in test destroys any previous test-stage deployment, deploys the whole example including schema setup, redeploys without schema changes, rejects unsigned requests, calls the IAM-authenticated Function URL with SigV4-signed HTTP, and checks CRUD/validation/repeated invocations and SQL privileges, then destroys the stack. Without `AWS_TEST_SLOW=1`, the suite skips without provisioning. Provisioning and cleanup have finite 25-minute budgets; query assertions have a two-minute budget.

## Destroy

```sh
pnpm destroy --profile testing --stage aurora-example
```

This deletes the example database and its data. Do not use the example's disposable retention settings for production. Wait for cleanup to finish; if interrupted, rerun the same destroy command with the same profile and stage.
21 changes: 21 additions & 0 deletions examples/aws-aurora-drizzle/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as Alchemy from "alchemy";
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import Api from "./src/Api.ts";
import { database } from "./src/database.ts";

export default Alchemy.Stack(
"aws-aurora-drizzle",
{ providers: AWS.providers(), state: Alchemy.localState() },
Effect.gen(function* () {
const api = yield* Api;
const db = yield* database;
return {
url: api.functionUrl,
functionName: api.functionName,
clusterId: db.cluster.dbClusterIdentifier,
writerId: db.writer.dbInstanceIdentifier,
vpcId: db.vpc.vpcId,
};
}),
);
17 changes: 17 additions & 0 deletions examples/aws-aurora-drizzle/health-event.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"version": "2.0",
"routeKey": "$default",
"rawPath": "/health",
"rawQueryString": "",
"headers": { "host": "example.invalid" },
"requestContext": {
"http": {
"method": "GET",
"path": "/health",
"protocol": "HTTP/1.1",
"sourceIp": "127.0.0.1",
"userAgent": "aws-cli"
}
},
"isBase64Encoded": false
}
22 changes: 22 additions & 0 deletions examples/aws-aurora-drizzle/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "aws-aurora-drizzle-example",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"scripts": {
"build": "tsc -b",
"deploy": "alchemy deploy",
"destroy": "alchemy destroy",
"test": "bun test"
},
"dependencies": {
"@distilled.cloud/aws": "workspace:*",
"@effect/platform-node": "catalog:",
"@effect/sql-pg": "catalog:",
"@types/pg": "catalog:",
"alchemy": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"pg": "catalog:"
}
}
115 changes: 115 additions & 0 deletions examples/aws-aurora-drizzle/src/Api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as AWS from "alchemy/AWS";
import * as Drizzle from "alchemy/Drizzle/Postgres";
import * as Duration from "effect/Duration";
import { eq, sql } from "drizzle-orm";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";
import * as Schema from "effect/Schema";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { bootstrap } from "./bootstrap.ts";
import { database } from "./database.ts";
import { todos } from "./schema.ts";

export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
Effect.gen(function* () {
const schemaVersion = yield* bootstrap;
return {
main: import.meta.url,
runtime: "nodejs22.x" as const,
functionUrl: { authType: "AWS_IAM" as const },
timeout: Duration.seconds(30),
build: { install: ["pg"] },
env: {
SCHEMA_VERSION: schemaVersion,
NODE_EXTRA_CA_CERTS: "/var/runtime/ca-cert.pem",
},
};
}),
Effect.gen(function* () {
const { cluster, subnetIds, lambdaSecurityGroup } = yield* database;
const connect = yield* AWS.RDS.Connect(cluster, {
auth: "iam",
username: "app_iam",
database: "app",
subnetIds,
securityGroupIds: [lambdaSecurityGroup.groupId],
});
const db = yield* Drizzle.Postgres(
connect.pipe(
Effect.map((connection) => {
const url = new URL(Redacted.value(connection.url));
// Connect currently emits no-verify; require full certificate verification.
url.searchParams.set("sslmode", "verify-full");
return Redacted.make(url.toString());
}),
),
);
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const url = new URL(request.originalUrl);
if (request.method === "GET" && url.pathname === "/health") {
const rows = yield* db.execute(sql`SELECT current_user AS username,
(SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()) AS tls,
has_table_privilege(current_user, 'todos', 'INSERT') AS can_insert,
has_schema_privilege(current_user, 'public', 'CREATE') AS can_create`);
return yield* HttpServerResponse.json(rows);
}
if (url.pathname === "/todos" && request.method === "GET") {
return yield* HttpServerResponse.json(yield* db.select().from(todos));
}
if (url.pathname === "/todos" && request.method === "POST") {
const input = yield* request.json.pipe(
Effect.flatMap(
Schema.decodeUnknownEffect(
Schema.Struct({
id: Schema.String.check(Schema.isUUID()),
title: Schema.NonEmptyString,
}),
),
),
);
return yield* HttpServerResponse.json(
yield* db.insert(todos).values(input).returning(),
{ status: 201 },
);
}
if (url.pathname.startsWith("/todos/")) {
const id = yield* Schema.decodeUnknownEffect(
Schema.String.check(Schema.isUUID()),
)(url.pathname.slice(7));
if (request.method === "PATCH") {
const input = yield* request.json.pipe(
Effect.flatMap(
Schema.decodeUnknownEffect(
Schema.Struct({ done: Schema.Boolean }),
),
),
);
return yield* HttpServerResponse.json(
yield* db
.update(todos)
.set(input)
.where(eq(todos.id, id))
.returning(),
);
}
if (request.method === "DELETE") {
yield* db.delete(todos).where(eq(todos.id, id));
return HttpServerResponse.empty({ status: 204 });
}
}
return HttpServerResponse.text("Not found", { status: 404 });
}).pipe(
Effect.catchTag("SchemaError", () =>
Effect.succeed(
HttpServerResponse.text("Invalid request", { status: 400 }),
),
),
Effect.orDie,
),
};
}).pipe(Effect.provide(AWS.RDS.ConnectHttp)),
) {}
52 changes: 52 additions & 0 deletions examples/aws-aurora-drizzle/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as Alchemy from "alchemy";
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import { database } from "./database.ts";

// One atomic, replay-safe initial migration. Application roles never own DDL.
const initialMigration = `DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_iam') THEN
CREATE ROLE app_iam LOGIN;
END IF;
GRANT rds_iam TO app_iam;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
CREATE TABLE IF NOT EXISTS public.todos (
id uuid PRIMARY KEY,
title text NOT NULL,
done boolean NOT NULL DEFAULT false
);
GRANT CONNECT ON DATABASE app TO app_iam;
GRANT USAGE ON SCHEMA public TO app_iam;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.todos TO app_iam;
END $$`;

export const bootstrap = Effect.gen(function* () {
const db = yield* database;
const Bootstrap = Alchemy.Action(
"BootstrapDatabase",
Effect.gen(function* () {
const execute = yield* AWS.RDSData.ExecuteStatement(db.cluster, {
secret: db.secret,
database: "app",
});
return Effect.fn(function* (input: { writer: string; sql: string }) {
yield* execute({ sql: input.sql }).pipe(
Effect.retry({
while: (error) =>
error._tag === "DatabaseUnavailableException" ||
error._tag === "DatabaseResumingException",
schedule: Schedule.spaced("5 seconds"),
times: 8,
}),
);
return "1";
});
}).pipe(Effect.provide(AWS.RDSData.ExecuteStatementHttp)),
);
return yield* Bootstrap({
writer: db.writer.dbInstanceArn,
sql: initialMigration,
});
});
54 changes: 54 additions & 0 deletions examples/aws-aurora-drizzle/src/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";

export const database = Effect.gen(function* () {
const vpc = yield* AWS.EC2.Vpc("Vpc", { cidrBlock: "10.62.0.0/16" });
const subnetA = yield* AWS.EC2.Subnet("SubnetA", {
vpcId: vpc.vpcId,
cidrBlock: "10.62.0.0/24",
availabilityZone: "us-west-2a",
});
const subnetB = yield* AWS.EC2.Subnet("SubnetB", {
vpcId: vpc.vpcId,
cidrBlock: "10.62.1.0/24",
availabilityZone: "us-west-2b",
});
const lambdaSecurityGroup = yield* AWS.EC2.SecurityGroup(
"LambdaSecurityGroup",
{
vpcId: vpc.vpcId,
description: "Aurora example application; no inbound connections",
},
);
const dbSecurityGroup = yield* AWS.EC2.SecurityGroup(
"DatabaseSecurityGroup",
{
vpcId: vpc.vpcId,
description: "Postgres from the application security group only",
ingress: [
{
ipProtocol: "tcp",
fromPort: 5432,
toPort: 5432,
referencedGroupId: lambdaSecurityGroup.groupId,
},
],
},
);
const subnetIds = [subnetA.subnetId, subnetB.subnetId];
const aurora = yield* AWS.RDS.Aurora("Database", {
databaseName: "app",
engine: "aurora-postgresql",
engineVersion: "17.5",
subnetIds,
securityGroupIds: [dbSecurityGroup.groupId],
secret: { username: "dbadmin" },
dataApi: true,
cluster: {
enableIAMDatabaseAuthentication: true,
serverlessV2ScalingConfiguration: { MinCapacity: 0.5, MaxCapacity: 1 },
},
instance: { dbInstanceClass: "db.serverless", publiclyAccessible: false },
});
return { ...aurora, vpc, subnetIds, lambdaSecurityGroup };
});
7 changes: 7 additions & 0 deletions examples/aws-aurora-drizzle/src/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { boolean, pgTable, text, uuid } from "drizzle-orm/pg-core";

export const todos = pgTable("todos", {
id: uuid("id").primaryKey(),
title: text("title").notNull(),
done: boolean("done").notNull().default(false),
});
Loading
Loading