From 477397c031800d0f57b05cf5c0fed6bd14ffc2d5 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 17 Jun 2026 16:34:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(aws):=20RDS=20Aurora=20Drizzle=20support?= =?UTF-8?q?=20=E2=80=94=20Data=20API=20migrations=20+=20runtime=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AWS.RDS.Schema: deploy-time migration applier over the RDS Data API (HTTPS+IAM, no VPC reachability needed), with a writer-readiness wait - AWS.RDSData.drizzle: runtime Drizzle client backed by the Data API - AWS.RDS.connectionString: postgres:// binding for Drizzle.postgres - proxyChainPromise: promise-aware chain for the aws-data-api driver - fixes surfaced by live e2e testing: - Aurora: don't set vpcSecurityGroupIds on cluster-member instances - Lambda runtime: provide per-invocation ExecutionContext - EC2.Network: resolve AWSEnvironment lazily so it survives runtime re-exec - example: aws-lambda-rds-aurora-drizzle (live-tested end to end) Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 22 + .../alchemy.run.ts | 20 + .../20260617220312_migration/migration.sql | 18 + .../20260617220312_migration/snapshot.json | 176 ++++++++ .../package.json | 29 ++ .../aws-lambda-rds-aurora-drizzle/src/Api.ts | 109 +++++ .../aws-lambda-rds-aurora-drizzle/src/Db.ts | 68 ++++ .../src/schema.ts | 37 ++ .../test/integ.test.ts | 108 +++++ .../tsconfig.json | 17 + packages/alchemy/package.json | 5 + packages/alchemy/src/AWS/EC2/Network.ts | 34 +- packages/alchemy/src/AWS/Lambda/Function.ts | 14 + packages/alchemy/src/AWS/Providers.ts | 2 + packages/alchemy/src/AWS/RDS/Aurora.ts | 7 +- .../alchemy/src/AWS/RDS/ConnectionString.ts | 62 +++ packages/alchemy/src/AWS/RDS/Schema.ts | 385 ++++++++++++++++++ packages/alchemy/src/AWS/RDS/index.ts | 2 + packages/alchemy/src/AWS/RDSData/Drizzle.ts | 111 +++++ packages/alchemy/src/AWS/RDSData/index.ts | 1 + packages/alchemy/src/Util/proxy-chain.ts | 48 ++- .../alchemy/test/AWS/RDS/Schema.unit.test.ts | 26 ++ .../alchemy/test/Util/proxy-chain.test.ts | 67 +++ tsconfig.json | 3 + 24 files changed, 1347 insertions(+), 24 deletions(-) create mode 100644 examples/aws-lambda-rds-aurora-drizzle/alchemy.run.ts create mode 100644 examples/aws-lambda-rds-aurora-drizzle/migrations/20260617220312_migration/migration.sql create mode 100644 examples/aws-lambda-rds-aurora-drizzle/migrations/20260617220312_migration/snapshot.json create mode 100644 examples/aws-lambda-rds-aurora-drizzle/package.json create mode 100644 examples/aws-lambda-rds-aurora-drizzle/src/Api.ts create mode 100644 examples/aws-lambda-rds-aurora-drizzle/src/Db.ts create mode 100644 examples/aws-lambda-rds-aurora-drizzle/src/schema.ts create mode 100644 examples/aws-lambda-rds-aurora-drizzle/test/integ.test.ts create mode 100644 examples/aws-lambda-rds-aurora-drizzle/tsconfig.json create mode 100644 packages/alchemy/src/AWS/RDS/ConnectionString.ts create mode 100644 packages/alchemy/src/AWS/RDS/Schema.ts create mode 100644 packages/alchemy/src/AWS/RDSData/Drizzle.ts create mode 100644 packages/alchemy/test/AWS/RDS/Schema.unit.test.ts create mode 100644 packages/alchemy/test/Util/proxy-chain.test.ts diff --git a/bun.lock b/bun.lock index 008535d89..d905b7f95 100644 --- a/bun.lock +++ b/bun.lock @@ -420,6 +420,21 @@ "effect": "catalog:", }, }, + "examples/aws-lambda-rds-aurora-drizzle": { + "name": "aws-lambda-rds-aurora-drizzle", + "version": "0.0.0", + "dependencies": { + "@aws-sdk/client-rds-data": "^3.0.0", + "@distilled.cloud/aws": "catalog:", + "@effect/platform-node": "catalog:", + "alchemy": "workspace:*", + "drizzle-orm": "1.0.0-rc.1", + "effect": "catalog:", + }, + "devDependencies": { + "drizzle-kit": "1.0.0-rc.1", + }, + }, "examples/aws-lambda-rpc": { "name": "aws-lambda-rpc", "version": "0.0.0", @@ -806,6 +821,7 @@ "yaml": "catalog:", }, "devDependencies": { + "@aws-sdk/client-rds-data": "^3.0.0", "@clack/prompts": "^0.11.0", "@cloudflare/puppeteer": "^1.1.0", "@cloudflare/workers-types": "catalog:", @@ -833,6 +849,7 @@ "ws": "catalog:", }, "peerDependencies": { + "@aws-sdk/client-rds-data": "^3", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-pg": "catalog:", @@ -843,6 +860,7 @@ "ws": "catalog:", }, "optionalPeers": [ + "@aws-sdk/client-rds-data", "@effect/platform-bun", "@effect/platform-node", "@effect/sql-pg", @@ -1036,6 +1054,8 @@ "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1068.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/credential-provider-node": "^3.972.55", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-by2Qj3f9BI9X4cY0n160R3uzkMpI6k9PmGA8QLAuzr8HzkiNrYFygMEPhIEdqBFHQPD/8AXNUs45HYjEDsQ33g=="], + "@aws-sdk/client-rds-data": ["@aws-sdk/client-rds-data@3.1068.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/credential-provider-node": "^3.972.55", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-LtJOPkUZfZls9ML/MRImMvZeMY0EJ5GxpyD/jyguACxUUvCcGmAgDrdIzjKGsURYvoFRQeE1VQhBlKto/gEIcg=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.20", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g=="], "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.45", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-4L/REssieLON1hVsTzZucP1p2u5jmPNejyh/9BCGZXr93IalBbhRPCrtKIKwMTu9yRGr/bcKzhrQocByKLSzLQ=="], @@ -2338,6 +2358,8 @@ "aws-lambda-httpapi": ["aws-lambda-httpapi@workspace:examples/aws-lambda-httpapi"], + "aws-lambda-rds-aurora-drizzle": ["aws-lambda-rds-aurora-drizzle@workspace:examples/aws-lambda-rds-aurora-drizzle"], + "aws-lambda-rpc": ["aws-lambda-rpc@workspace:examples/aws-lambda-rpc"], "aws-rds-example": ["aws-rds-example@workspace:examples/aws-rds"], diff --git a/examples/aws-lambda-rds-aurora-drizzle/alchemy.run.ts b/examples/aws-lambda-rds-aurora-drizzle/alchemy.run.ts new file mode 100644 index 000000000..6916858d1 --- /dev/null +++ b/examples/aws-lambda-rds-aurora-drizzle/alchemy.run.ts @@ -0,0 +1,20 @@ +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import Api from "./src/Api.ts"; + +export default Alchemy.Stack( + "AwsLambdaRdsAuroraDrizzleExample", + { + providers: Layer.mergeAll(AWS.providers(), Drizzle.providers()), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* Api; + return { + url: api.functionUrl, + }; + }), +); diff --git a/examples/aws-lambda-rds-aurora-drizzle/migrations/20260617220312_migration/migration.sql b/examples/aws-lambda-rds-aurora-drizzle/migrations/20260617220312_migration/migration.sql new file mode 100644 index 000000000..733880035 --- /dev/null +++ b/examples/aws-lambda-rds-aurora-drizzle/migrations/20260617220312_migration/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "posts" ( + "id" serial PRIMARY KEY, + "user_id" integer NOT NULL, + "title" text NOT NULL, + "body" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +--> statement-breakpoint +CREATE TABLE "users" ( + "id" serial PRIMARY KEY, + "email" text NOT NULL UNIQUE, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE; diff --git a/examples/aws-lambda-rds-aurora-drizzle/migrations/20260617220312_migration/snapshot.json b/examples/aws-lambda-rds-aurora-drizzle/migrations/20260617220312_migration/snapshot.json new file mode 100644 index 000000000..a40838bbb --- /dev/null +++ b/examples/aws-lambda-rds-aurora-drizzle/migrations/20260617220312_migration/snapshot.json @@ -0,0 +1,176 @@ +{ + "dialect": "postgres", + "id": "a5dd7b5d-0c42-408d-97a5-9c0345a6825a", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "version": "8", + "ddl": [ + { + "isRlsEnabled": false, + "name": "posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": ["user_id"], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "posts_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "posts_pkey", + "schema": "public", + "table": "posts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + } + ], + "renames": [] +} diff --git a/examples/aws-lambda-rds-aurora-drizzle/package.json b/examples/aws-lambda-rds-aurora-drizzle/package.json new file mode 100644 index 000000000..8c7c015fc --- /dev/null +++ b/examples/aws-lambda-rds-aurora-drizzle/package.json @@ -0,0 +1,29 @@ +{ + "name": "aws-lambda-rds-aurora-drizzle", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/alchemy-run/alchemy-effect.git", + "directory": "examples/aws-lambda-rds-aurora-drizzle" + }, + "type": "module", + "scripts": { + "deploy": "alchemy deploy", + "dev": "alchemy dev", + "destroy": "alchemy destroy", + "test": "bun test" + }, + "dependencies": { + "@aws-sdk/client-rds-data": "^3.0.0", + "@distilled.cloud/aws": "catalog:", + "@effect/platform-node": "catalog:", + "alchemy": "workspace:*", + "drizzle-orm": "1.0.0-rc.1", + "effect": "catalog:" + }, + "devDependencies": { + "drizzle-kit": "1.0.0-rc.1" + } +} diff --git a/examples/aws-lambda-rds-aurora-drizzle/src/Api.ts b/examples/aws-lambda-rds-aurora-drizzle/src/Api.ts new file mode 100644 index 000000000..7689489d0 --- /dev/null +++ b/examples/aws-lambda-rds-aurora-drizzle/src/Api.ts @@ -0,0 +1,109 @@ +import * as AWS from "alchemy/AWS"; +import { eq } from "drizzle-orm"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { Database, DatabaseLive } from "./Db.ts"; +import { relations, Users } from "./schema.ts"; + +/** + * Lambda function exposing user CRUD over the Aurora cluster via Drizzle, + * talking to the database through the **RDS Data API** (`AWS.RDSData.drizzle`). + * No VPC attachment is required — the Data API is reached over HTTPS+IAM. + * + * For an in-VPC alternative that uses a pooled `postgres://` connection instead, + * attach the function to the VPC and swap the binding for: + * + * ```typescript + * const connStr = yield* AWS.RDS.connectionString(cluster, { secret, database: "app" }); + * const db = yield* Drizzle.postgres(connStr, { relations }); + * ``` + */ +export default class Api extends AWS.Lambda.Function()( + "Api", + { + main: import.meta.filename, + runtime: "nodejs24.x", + url: true, + }, + Effect.gen(function* () { + const { cluster, secret } = yield* Database; + const db = yield* AWS.RDSData.drizzle(cluster, { + secret, + database: "app", + relations, + }); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const pathname = new URL(request.originalUrl).pathname; + + switch (request.method) { + case "GET": { + if (pathname === "/") { + const users = yield* db.select().from(Users); + return yield* HttpServerResponse.json({ users }); + } + const id = Number(pathname.split("/").pop()); + if (Number.isNaN(id)) { + return yield* HttpServerResponse.json( + { error: "Invalid user ID" }, + { status: 400 }, + ); + } + const user = yield* db.query.Users.findFirst({ + where: { id }, + with: { posts: true }, + }); + return yield* HttpServerResponse.json({ user }); + } + case "POST": { + const user = yield* db + .insert(Users) + .values({ + name: crypto.randomUUID(), + email: crypto.randomUUID(), + }) + .returning(); + return yield* HttpServerResponse.json({ user }); + } + case "DELETE": { + const id = Number(pathname.split("/").pop()); + if (Number.isNaN(id)) { + return yield* HttpServerResponse.json( + { error: "Invalid user ID" }, + { status: 400 }, + ); + } + const [user] = yield* db + .delete(Users) + .where(eq(Users.id, id)) + .returning(); + return yield* HttpServerResponse.json({ user }); + } + default: { + return yield* HttpServerResponse.json( + { error: "Method not allowed" }, + { status: 405 }, + ); + } + } + }).pipe( + Effect.catch((cause: unknown) => { + const peel = (e: any): any => (e?.cause ? peel(e.cause) : e); + const root = peel(cause); + return HttpServerResponse.json( + { + ok: false, + error: String(cause), + rootError: root?.message ?? String(root), + rootCode: root?.code, + }, + { status: 500 }, + ); + }), + ), + }; + }).pipe(Effect.provide(DatabaseLive)), +) {} diff --git a/examples/aws-lambda-rds-aurora-drizzle/src/Db.ts b/examples/aws-lambda-rds-aurora-drizzle/src/Db.ts new file mode 100644 index 000000000..24dd2f292 --- /dev/null +++ b/examples/aws-lambda-rds-aurora-drizzle/src/Db.ts @@ -0,0 +1,68 @@ +import * as AWS from "alchemy/AWS"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +/** + * Aurora cluster + deploy-time migrations, exposed as a `Database` service. + * + * Provider order on deploy: + * 1. `Drizzle.Schema` regenerates pending migration SQL under `./migrations`. + * 2. `AWS.RDS.Aurora` provisions a serverless v2 cluster (Data API enabled). + * 3. `AWS.RDS.Schema` applies the migrations through the **Data API** — no VPC + * reachability required from the deploy machine. + * + * The cluster lives in private subnets, but the Lambda reaches it over the + * public Data API endpoint, so the function never needs to join the VPC. + */ +export class Database extends Context.Service< + Database, + { + cluster: AWS.RDS.DBCluster; + secret: AWS.SecretsManager.Secret; + } +>()("Database") {} + +export const DatabaseLive = Layer.effect( + Database, + Effect.gen(function* () { + const network = yield* AWS.EC2.Network("Network", { + cidrBlock: "10.0.0.0/16", + // Pin explicit AZs rather than a count. `DatabaseLive` is provided to the + // Lambda, so it re-runs at function init — and resolving a *count* would + // call `ec2:DescribeAvailabilityZones`, which the Lambda role can't do. + // Naming the AZs skips that lookup; the cluster/subnet resources + // themselves resolve from injected outputs at runtime. + availabilityZones: ["us-west-2a", "us-west-2b"], + }); + + const databaseSecurityGroup = yield* AWS.EC2.SecurityGroup( + "DatabaseSecurityGroup", + { + vpcId: network.vpcId, + description: "Security group for the Aurora cluster", + }, + ); + + const schema = yield* Drizzle.Schema("AppSchema", { + schema: "./src/schema.ts", + out: "./migrations", + }); + + const db = yield* AWS.RDS.Aurora("AppDb", { + databaseName: "app", + subnetIds: network.privateSubnetIds, + securityGroupIds: [databaseSecurityGroup.groupId], + }); + + yield* AWS.RDS.Schema("AppDbSchema", { + resourceArn: db.cluster.dbClusterArn, + secretArn: db.secret.secretArn, + database: "app", + migrationsDir: schema.out, + }); + + return Database.of({ cluster: db.cluster, secret: db.secret }); + }), +); diff --git a/examples/aws-lambda-rds-aurora-drizzle/src/schema.ts b/examples/aws-lambda-rds-aurora-drizzle/src/schema.ts new file mode 100644 index 000000000..7c3633eed --- /dev/null +++ b/examples/aws-lambda-rds-aurora-drizzle/src/schema.ts @@ -0,0 +1,37 @@ +import { defineRelations } from "drizzle-orm"; +import { integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; + +export const Users = pgTable("users", { + id: serial("id").primaryKey(), + email: text("email").notNull().unique(), + name: text("name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); +export type User = typeof Users.$inferSelect; + +export const Posts = pgTable("posts", { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => Users.id, { onDelete: "cascade" }), + title: text("title").notNull(), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); +export type Post = typeof Posts.$inferSelect; + +export const relations = defineRelations({ Users, Posts }, (t) => ({ + Users: { + posts: t.many.Posts(), + }, + Posts: { + user: t.one.Users({ + from: t.Posts.userId, + to: t.Users.id, + }), + }, +})); diff --git a/examples/aws-lambda-rds-aurora-drizzle/test/integ.test.ts b/examples/aws-lambda-rds-aurora-drizzle/test/integ.test.ts new file mode 100644 index 000000000..c1ccf8f11 --- /dev/null +++ b/examples/aws-lambda-rds-aurora-drizzle/test/integ.test.ts @@ -0,0 +1,108 @@ +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Test from "alchemy/Test/Bun"; +import { expect } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import Stack from "../alchemy.run.ts"; +import type { Post, User } from "../src/schema.ts"; + +// Aurora serverless v2 bring-up dominates the wall clock — allow plenty of +// headroom for the deploy (cluster + writer instance) and the teardown. +const DEPLOY_TIMEOUT = 1_200_000; + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: Layer.mergeAll(AWS.providers(), Drizzle.providers()), + state: Alchemy.localState(), +}); + +const stack = beforeAll(deploy(Stack), { timeout: DEPLOY_TIMEOUT }); + +afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), { + timeout: DEPLOY_TIMEOUT, +}); + +test( + "stack exposes a function URL", + Effect.gen(function* () { + const { url } = yield* stack; + expect(url).toBeString(); + }), +); + +const requireUrl = Effect.gen(function* () { + const { url } = yield* stack; + if (!url) return yield* Effect.fail(new Error("stack did not expose a URL")); + return url.replace(/\/+$/, ""); +}); + +// Function URLs take a few seconds to start serving 200s after creation. +const getOnce = (url: string) => + HttpClient.get(url).pipe( + Effect.filterOrFail( + (response) => response.status === 200, + () => new Error("function URL not yet serving"), + ), + Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 30 }), + ); + +test( + "user CRUD through Drizzle over the RDS Data API", + Effect.gen(function* () { + const baseUrl = yield* requireUrl; + + const initialResponse = yield* getOnce(baseUrl); + expect(initialResponse.status).toBe(200); + const initialBody = (yield* initialResponse.json) as unknown as { + users: User[]; + }; + expect(Array.isArray(initialBody.users)).toBe(true); + + const createResponse = yield* HttpClient.execute( + HttpClientRequest.post(baseUrl), + ); + expect(createResponse.status).toBe(200); + const createBody = (yield* createResponse.json) as unknown as { + user: User[]; + }; + expect(createBody.user).toHaveLength(1); + const [createdUser] = createBody.user; + expect(createdUser.id).toBeNumber(); + expect(createdUser.email).toBeString(); + + const readResponse = yield* HttpClient.get(`${baseUrl}/${createdUser.id}`); + expect(readResponse.status).toBe(200); + const readBody = (yield* readResponse.json) as unknown as { + user: User & { posts: Post[] }; + }; + expect(readBody.user).toMatchObject({ + id: createdUser.id, + email: createdUser.email, + posts: [], + }); + + const invalidRead = yield* HttpClient.get(`${baseUrl}/not-a-user`); + expect(invalidRead.status).toBe(400); + + const methodResponse = yield* HttpClient.execute( + HttpClientRequest.patch(baseUrl), + ); + expect(methodResponse.status).toBe(405); + + const deleteResponse = yield* HttpClient.execute( + HttpClientRequest.delete(`${baseUrl}/${createdUser.id}`), + ); + expect(deleteResponse.status).toBe(200); + + const finalResponse = yield* HttpClient.get(baseUrl); + const finalBody = (yield* finalResponse.json) as unknown as { + users: User[]; + }; + expect(finalBody.users.some((u) => u.id === createdUser.id)).toBe(false); + }), + { timeout: 60_000 }, +); diff --git a/examples/aws-lambda-rds-aurora-drizzle/tsconfig.json b/examples/aws-lambda-rds-aurora-drizzle/tsconfig.json new file mode 100644 index 000000000..4e69e516b --- /dev/null +++ b/examples/aws-lambda-rds-aurora-drizzle/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["alchemy.run.ts", "src/**/*.ts", "test/**/*.ts"], + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "module": "Preserve", + "moduleResolution": "Bundler", + "customConditions": ["bun"], + "target": "ESNext" + }, + "references": [ + { + "path": "../../packages/alchemy/tsconfig.json" + } + ] +} diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index 13fb95e54..ceaf744e6 100644 --- a/packages/alchemy/package.json +++ b/packages/alchemy/package.json @@ -327,6 +327,7 @@ "yaml": "catalog:" }, "devDependencies": { + "@aws-sdk/client-rds-data": "^3.0.0", "@clack/prompts": "^0.11.0", "@cloudflare/puppeteer": "^1.1.0", "@cloudflare/workers-types": "catalog:", @@ -354,6 +355,7 @@ "ws": "catalog:" }, "peerDependencies": { + "@aws-sdk/client-rds-data": "^3", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-pg": "catalog:", @@ -364,6 +366,9 @@ "ws": "catalog:" }, "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, "@effect/platform-node": { "optional": true }, diff --git a/packages/alchemy/src/AWS/EC2/Network.ts b/packages/alchemy/src/AWS/EC2/Network.ts index cad80b34b..93cb3ea92 100644 --- a/packages/alchemy/src/AWS/EC2/Network.ts +++ b/packages/alchemy/src/AWS/EC2/Network.ts @@ -306,20 +306,28 @@ export const Network = (id: string, props: NetworkProps) => } } - const { region } = yield* AWSEnvironment.current; const gatewayEndpoints: VpcEndpointResource[] = []; - for (const service of uniqueGatewayEndpoints(props.gatewayEndpoints)) { - gatewayEndpoints.push( - yield* VpcEndpoint(`${toEndpointId(service)}Endpoint`, { - vpcId: vpc.vpcId, - serviceName: `com.amazonaws.${region}.${service}`, - vpcEndpointType: "Gateway", - routeTableIds: privateRouteTables.map( - (table) => table.routeTableId, - ), - tags, - }), - ); + const endpointServices = uniqueGatewayEndpoints(props.gatewayEndpoints); + if (endpointServices.length > 0) { + // Resolve the region lazily — only when gateway endpoints are actually + // requested. `Network` is composed inside Function/Worker init, which + // re-runs at runtime where `AWSEnvironment` isn't provided; an + // unconditional `AWSEnvironment.current` here would crash a runtime + // that simply binds to a VPC-backed resource. + const { region } = yield* AWSEnvironment.current; + for (const service of endpointServices) { + gatewayEndpoints.push( + yield* VpcEndpoint(`${toEndpointId(service)}Endpoint`, { + vpcId: vpc.vpcId, + serviceName: `com.amazonaws.${region}.${service}`, + vpcEndpointType: "Gateway", + routeTableIds: privateRouteTables.map( + (table) => table.routeTableId, + ), + tags, + }), + ); + } } return { diff --git a/packages/alchemy/src/AWS/Lambda/Function.ts b/packages/alchemy/src/AWS/Lambda/Function.ts index 1941cb8c2..59bd66d99 100644 --- a/packages/alchemy/src/AWS/Lambda/Function.ts +++ b/packages/alchemy/src/AWS/Lambda/Function.ts @@ -18,6 +18,7 @@ import { Unowned } from "../../AdoptPolicy.ts"; import * as Bundle from "../../Bundle/Bundle.ts"; import * as TempRoot from "../../Bundle/TempRoot.ts"; import { deepEqual, isResolved } from "../../Diff.ts"; +import { ExecutionContext } from "../../ExecutionContext.ts"; import type { HttpEffect } from "../../Http.ts"; import * as Output from "../../Output.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; @@ -537,6 +538,19 @@ export const Function: Platform< if (Effect.isEffect(eff)) { return await eff.pipe( Effect.provideService(HandlerContext, context), + // Provide a per-invocation `ExecutionContext` (scope + + // cache) so runtime bindings that memoize per request — + // `Drizzle.postgres`, `AWS.RDSData.drizzle` — work inside a + // Lambda the same way they do inside a Worker. The scope is + // closed when the invocation settles. + Effect.provideServiceEffect( + ExecutionContext, + Effect.map(Effect.scope, (scope) => ({ + scope, + cache: {}, + })), + ), + Effect.scoped, Effect.tap(Effect.logDebug), Effect.runPromise, ); diff --git a/packages/alchemy/src/AWS/Providers.ts b/packages/alchemy/src/AWS/Providers.ts index fa8cc332e..fc61e3328 100644 --- a/packages/alchemy/src/AWS/Providers.ts +++ b/packages/alchemy/src/AWS/Providers.ts @@ -264,6 +264,7 @@ export const providers = () => RDS.DBProxyEndpoint, RDS.DBProxyTargetGroup, RDS.DBSubnetGroup, + RDS.Schema, RDSData.BatchExecuteStatementPolicy, RDSData.BeginTransactionPolicy, RDSData.CommitTransactionPolicy, @@ -522,6 +523,7 @@ export const providers = () => RDS.DBProxyProvider(), RDS.DBProxyTargetGroupProvider(), RDS.DBSubnetGroupProvider(), + RDS.SchemaProvider(), RDSData.BatchExecuteStatementPolicyLive, RDSData.BeginTransactionPolicyLive, RDSData.CommitTransactionPolicyLive, diff --git a/packages/alchemy/src/AWS/RDS/Aurora.ts b/packages/alchemy/src/AWS/RDS/Aurora.ts index 81b69e785..d2964941d 100644 --- a/packages/alchemy/src/AWS/RDS/Aurora.ts +++ b/packages/alchemy/src/AWS/RDS/Aurora.ts @@ -499,7 +499,9 @@ export const Aurora = (id: string, props: AuroraProps) => engineVersion, dbSubnetGroupName: subnetGroup.dbSubnetGroupName, dbParameterGroupName: parameterGroup?.dbParameterGroupName, - vpcSecurityGroupIds: securityGroupIds, + // VPC security groups are set on the cluster, not on cluster-member + // instances — AWS rejects `VpcSecurityGroupIds` on an instance that + // belongs to a DB cluster (`InvalidParameterCombination`). publiclyAccessible: props.instance?.publiclyAccessible ?? false, promotionTier: props.instance?.promotionTier ?? 0, autoMinorVersionUpgrade: @@ -524,7 +526,8 @@ export const Aurora = (id: string, props: AuroraProps) => engineVersion, dbSubnetGroupName: subnetGroup.dbSubnetGroupName, dbParameterGroupName: parameterGroup?.dbParameterGroupName, - vpcSecurityGroupIds: securityGroupIds, + // VPC security groups are set on the cluster, not on cluster-member + // instances (see Writer above). publiclyAccessible: props.instance?.publiclyAccessible ?? false, promotionTier: index + 1, autoMinorVersionUpgrade: diff --git a/packages/alchemy/src/AWS/RDS/ConnectionString.ts b/packages/alchemy/src/AWS/RDS/ConnectionString.ts new file mode 100644 index 000000000..c6b1c6774 --- /dev/null +++ b/packages/alchemy/src/AWS/RDS/ConnectionString.ts @@ -0,0 +1,62 @@ +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { + Connect, + type ConnectionInfo, + type ConnectOptions, +} from "./Connect.ts"; +import type { DBCluster } from "./DBCluster.ts"; +import type { DBProxy } from "./DBProxy.ts"; +import type { DBProxyEndpoint } from "./DBProxyEndpoint.ts"; + +type ConnectResource = DBCluster | DBProxy | DBProxyEndpoint; + +const formatConnectionString = (c: ConnectionInfo): string => { + const user = c.username ? encodeURIComponent(c.username) : ""; + const pass = c.password ? `:${encodeURIComponent(c.password)}` : ""; + const auth = user ? `${user}${pass}@` : ""; + const database = c.database ? `/${encodeURIComponent(c.database)}` : ""; + const query = c.ssl ? "?sslmode=require" : ""; + return `postgres://${auth}${c.host}:${c.port}${database}${query}`; +}; + +/** + * Runtime binding that resolves a `postgres://` connection string for an Aurora + * cluster, RDS Proxy, or proxy endpoint. Like {@link Connect} but formats the + * resolved {@link ConnectionInfo} into a single redacted URL — ready to hand + * straight to `Drizzle.postgres(...)`. + * + * @example In-VPC drizzle over a pooled connection + * ```typescript + * const connStr = yield* AWS.RDS.connectionString(db.cluster, { + * secret: db.secret, + * database: "app", + * }); + * const pg = yield* Drizzle.postgres(connStr, { relations }); + * const rows = yield* pg.select().from(Users); + * ``` + * + * @example Fronting Aurora with Cloudflare Hyperdrive + * ```typescript + * // Hyperdrive accepts the cluster's endpoint Outputs directly. Supply a known + * // master password (e.g. via alchemy.secret) when creating the cluster so it + * // can be passed to the origin — Hyperdrive needs the password at deploy time. + * const hd = yield* Cloudflare.Hyperdrive("AppHyperdrive", { + * origin: { + * scheme: "postgres", + * host: db.cluster.endpoint, + * port: db.cluster.port, + * database: "app", + * user: "app", + * password: alchemy.secret.env.DB_PASSWORD, + * }, + * }); + * ``` + */ +export const connectionString = ( + resource: ConnectResource, + options: ConnectOptions, +) => + Effect.map(Connect.bind(resource, options), (info) => + Effect.map(info, (c) => Redacted.make(formatConnectionString(c))), + ); diff --git a/packages/alchemy/src/AWS/RDS/Schema.ts b/packages/alchemy/src/AWS/RDS/Schema.ts new file mode 100644 index 000000000..f9c90b4dd --- /dev/null +++ b/packages/alchemy/src/AWS/RDS/Schema.ts @@ -0,0 +1,385 @@ +import * as rdsdata from "@distilled.cloud/aws/rds-data"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { + hashImports, + hashMigrations, + listSqlFiles, + readSqlFile, +} from "../../Sql/SqlFile.ts"; +import { recordsEqual } from "../../Util/equal.ts"; +import type { Providers } from "../Providers.ts"; + +const DEFAULT_MIGRATIONS_TABLE = "__drizzle_migrations"; +const DEFAULT_DATABASE = "app"; + +const rootDir = Effect.sync(() => process.cwd()); + +/** + * Split a `.sql` file into individual statements. The RDS Data API runs exactly + * one statement per `ExecuteStatement` call (unlike node-postgres, which runs a + * whole multi-statement file), so migration files must be split. + * + * Files generated by `Drizzle.Schema` separate statements with drizzle's + * `--> statement-breakpoint` marker — one statement per chunk, so the split is + * exact. Hand-authored `importFiles` without that marker fall back to splitting + * on `;`, which is unsafe for dollar-quoted bodies (`DO $$ ... $$`, + * `CREATE FUNCTION ... $$ ... $$`) — author those with `--> statement-breakpoint`. + */ +export const splitStatements = (sql: string): string[] => { + const parts = sql.includes("statement-breakpoint") + ? sql.split(/^\s*-->\s*statement-breakpoint\s*$/m) + : sql.split(/;\s*$/m); + return parts + .map((s) => s.trim().replace(/;\s*$/, "").trim()) + .filter((s) => s.length > 0); +}; + +export interface SchemaProps { + /** + * The Aurora cluster ARN to apply migrations to — pass `cluster.dbClusterArn` + * from an `AWS.RDS.Aurora` / `AWS.RDS.DBCluster`. The cluster must have the + * Data API (HTTP endpoint) enabled, which `AWS.RDS.Aurora` does by default. + */ + resourceArn: string; + /** + * The Secrets Manager secret ARN holding the cluster's master credentials — + * pass `secret.secretArn` from `AWS.RDS.Aurora`. The Data API reads it + * server-side; no `secretsmanager:GetSecretValue` grant is needed on the + * deployer beyond the ambient `rds-data:*` permissions. + */ + secretArn: string; + /** + * Database name to apply migrations against. + * @default "app" + */ + database?: string; + /** + * Directory containing `.sql` migration files. Files are sorted by their + * numeric prefix (e.g. `0001_init.sql`) and applied in order. Wire this to + * `Drizzle.Schema`'s `out` so pending migrations regenerate and apply in a + * single deploy. + */ + migrationsDir?: string; + /** + * Name of the table tracking applied migrations. + * @default "__drizzle_migrations" + */ + migrationsTable?: string; + /** + * Paths to additional `.sql` files applied (once each, hash-gated) after the + * directory migrations — e.g. seed data. + */ + importFiles?: string[]; +} + +export interface Schema extends Resource< + "AWS.RDS.Schema", + SchemaProps, + { + resourceArn: string; + secretArn: string; + database: string; + migrationsDir: string | undefined; + migrationsTable: string; + migrationsHashes: Record; + importHashes: Record; + }, + never, + Providers +> {} + +/** + * Applies Drizzle (or hand-authored) SQL migrations to an Aurora cluster + * through the **RDS Data API** at deploy time. + * + * Unlike Neon/Planetscale — where migrations run over a node-postgres + * connection — `AWS.RDS.Schema` speaks the Data API over HTTPS+IAM, so the + * deploy machine never needs network reachability to a VPC-private cluster. + * Each migration file runs inside a Data API transaction (one statement per + * call) and is recorded in a tracking table so re-deploys are idempotent. + * + * Pair it with `Drizzle.Schema` for the same deploy-driven flow as the + * Neon/Planetscale examples: `Drizzle.Schema` regenerates pending migration + * SQL, then `AWS.RDS.Schema` applies it. + * + * @section Applying migrations + * @example Drizzle migrations via the Data API + * ```typescript + * const schema = yield* Drizzle.Schema("AppSchema", { + * schema: "./src/schema.ts", + * }); + * const db = yield* AWS.RDS.Aurora("AppDb", { subnetIds, securityGroupIds }); + * yield* AWS.RDS.Schema("AppDbSchema", { + * resourceArn: db.cluster.dbClusterArn, + * secretArn: db.secret.secretArn, + * migrationsDir: schema.out, + * }); + * ``` + * + * @example With seed data + * ```typescript + * yield* AWS.RDS.Schema("AppDbSchema", { + * resourceArn: db.cluster.dbClusterArn, + * secretArn: db.secret.secretArn, + * migrationsDir: schema.out, + * importFiles: ["./seed/users.sql"], + * }); + * ``` + */ +export const Schema = Resource("AWS.RDS.Schema"); + +export const SchemaProvider = () => + Provider.effect( + Schema, + Effect.gen(function* () { + return { + // Local artifact driven by `migrationsDir` props — nothing to + // enumerate out-of-band. + list: () => Effect.succeed([]), + + diff: Effect.fn(function* ({ news, output }) { + if (!isResolved(news)) return undefined; + // Target identity changed (e.g. the cluster was replaced) — the new + // database needs the full migration set. + if ( + output && + (news.resourceArn !== output.resourceArn || + (news.database ?? DEFAULT_DATABASE) !== output.database) + ) { + return { action: "update" } as const; + } + if (news.migrationsDir) { + const newHashes = yield* hashMigrations(news.migrationsDir); + if (!recordsEqual(newHashes, output?.migrationsHashes ?? {})) { + return { action: "update" } as const; + } + if ( + (news.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE) !== + (output?.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE) + ) { + return { action: "update" } as const; + } + } + if (news.importFiles?.length) { + const newHashes = yield* hashImports( + news.importFiles, + yield* rootDir, + ); + if (!recordsEqual(newHashes, output?.importHashes ?? {})) { + return { action: "update" } as const; + } + } + return undefined; + }), + + // The "remote" state is the migrations table; there is no cheap + // out-of-band drift source, so refresh is a no-op. + read: Effect.fn(function* ({ output }) { + return output; + }), + + reconcile: Effect.fn(function* ({ news, output, session }) { + const resourceArn = news.resourceArn; + const secretArn = news.secretArn; + const database = news.database ?? DEFAULT_DATABASE; + const migrationsTable = + news.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE; + + const exec = ( + sql: string, + opts?: { + transactionId?: string; + parameters?: rdsdata.SqlParameter[]; + }, + ) => + rdsdata.executeStatement({ + resourceArn, + secretArn, + // `database` is bound by the transaction; passing it alongside a + // `transactionId` is rejected by the Data API. + database: opts?.transactionId ? undefined : database, + sql, + transactionId: opts?.transactionId, + parameters: opts?.parameters, + }); + + const runInTransaction = ( + statements: ReadonlyArray< + string | { sql: string; parameters?: rdsdata.SqlParameter[] } + >, + ) => + Effect.gen(function* () { + const begun = yield* rdsdata.beginTransaction({ + resourceArn, + secretArn, + database, + }); + const transactionId = begun.transactionId; + if (!transactionId) { + return yield* Effect.die( + "RDS Data API beginTransaction returned no transactionId", + ); + } + yield* Effect.gen(function* () { + for (const stmt of statements) { + if (typeof stmt === "string") { + yield* exec(stmt, { transactionId }); + } else { + yield* exec(stmt.sql, { + transactionId, + parameters: stmt.parameters, + }); + } + } + yield* rdsdata.commitTransaction({ + resourceArn, + secretArn, + transactionId, + }); + }).pipe( + Effect.tapError(() => + rdsdata + .rollbackTransaction({ + resourceArn, + secretArn, + transactionId, + }) + .pipe(Effect.ignore), + ), + ); + }); + + // ── Wait for the Data API to be query-ready ──────────────────── + // On a fresh `AWS.RDS.Aurora` deploy this resource depends only on + // the cluster ARN, so it can run while the writer instance is still + // provisioning — the Data API then rejects with + // `DatabaseNotFoundException: Cannot find DBInstance in DBCluster` + // (or a resuming/unavailable error for a paused serverless cluster). + // Probe `SELECT 1` and wait out those transient states before + // touching the schema; a genuinely wrong database name surfaces as a + // `DatabaseNotFoundException` with a different message and fails fast. + const isWarmingUp = (e: rdsdata.ExecuteStatementError) => + e._tag === "DatabaseResumingException" || + e._tag === "DatabaseUnavailableException" || + ((e._tag === "DatabaseNotFoundException" || + e._tag === "BadRequestException") && + /cannot find dbinstance|not currently available|resuming|not available/i.test( + e.message ?? "", + )); + + const hasWork = + !!news.migrationsDir || (news.importFiles?.length ?? 0) > 0; + if (hasWork) { + yield* session.note( + "Waiting for the Aurora Data API to become available", + ); + yield* exec("SELECT 1;").pipe( + Effect.retry({ + while: isWarmingUp, + // Aurora writer-instance provisioning can take well over five + // minutes; wait up to ~15m (deploy-time, one-shot) so a slow + // instance launch doesn't abort the migration. + schedule: Schedule.spaced("10 seconds"), + times: 90, + }), + ); + } + + // ── Directory migrations ─────────────────────────────────────── + let migrationsHashes = output?.migrationsHashes ?? {}; + if (news.migrationsDir) { + const files = yield* listSqlFiles(news.migrationsDir); + if (files.length > 0) { + yield* session.note( + `Applying ${files.length} RDS Data API migration(s) to "${database}"`, + ); + yield* exec( + `CREATE TABLE IF NOT EXISTS "${migrationsTable}" (` + + `id TEXT PRIMARY KEY, name TEXT NOT NULL, ` + + `applied_at TIMESTAMPTZ NOT NULL DEFAULT now());`, + ); + const appliedRes = yield* exec( + `SELECT name FROM "${migrationsTable}";`, + ); + const applied = new Set( + (appliedRes.records ?? []) + .map((row) => row[0]?.stringValue) + .filter((v): v is string => v !== undefined), + ); + let nextSeq = 1; + const idRes = yield* exec(`SELECT id FROM "${migrationsTable}";`); + for (const row of idRes.records ?? []) { + const id = row[0]?.stringValue; + if (id && /^\d+$/.test(id)) { + nextSeq = Math.max(nextSeq, Number.parseInt(id, 10) + 1); + } + } + + const hashes: Record = {}; + for (const file of files) { + if (!applied.has(file.id)) { + const id = nextSeq.toString().padStart(5, "0"); + nextSeq += 1; + yield* runInTransaction([ + ...splitStatements(file.sql), + { + sql: + `INSERT INTO "${migrationsTable}" (id, name) ` + + `VALUES (:id, :name);`, + parameters: [ + { name: "id", value: { stringValue: id } }, + { name: "name", value: { stringValue: file.id } }, + ], + }, + ]); + } + hashes[file.id] = file.hash; + } + migrationsHashes = hashes; + } else { + migrationsHashes = {}; + } + } + + // ── Import files (seed data) ─────────────────────────────────── + let importHashes = output?.importHashes ?? {}; + if (news.importFiles?.length) { + const root = yield* rootDir; + const previous = output?.importHashes ?? {}; + const next: Record = { ...previous }; + for (const filePath of news.importFiles) { + const file = yield* readSqlFile(root, filePath); + if (previous[filePath] !== file.hash) { + yield* runInTransaction(splitStatements(file.sql)); + } + next[filePath] = file.hash; + } + const tracked = new Set(news.importFiles); + for (const key of Object.keys(next)) { + if (!tracked.has(key)) delete next[key]; + } + importHashes = next; + } + + return { + resourceArn, + secretArn, + database, + migrationsDir: news.migrationsDir, + migrationsTable, + migrationsHashes, + importHashes, + }; + }), + + delete: Effect.fn(function* () { + // Migrations are checked in and shared across environments; tearing + // down this resource does not drop tables. + }), + }; + }), + ); diff --git a/packages/alchemy/src/AWS/RDS/index.ts b/packages/alchemy/src/AWS/RDS/index.ts index eddad3394..9e80cade5 100644 --- a/packages/alchemy/src/AWS/RDS/index.ts +++ b/packages/alchemy/src/AWS/RDS/index.ts @@ -1,5 +1,6 @@ export * from "./Aurora.ts"; export * from "./Connect.ts"; +export * from "./ConnectionString.ts"; export * from "./DBCluster.ts"; export * from "./DBClusterEndpoint.ts"; export * from "./DBClusterParameterGroup.ts"; @@ -9,3 +10,4 @@ export * from "./DBProxy.ts"; export * from "./DBProxyEndpoint.ts"; export * from "./DBProxyTargetGroup.ts"; export * from "./DBSubnetGroup.ts"; +export * from "./Schema.ts"; diff --git a/packages/alchemy/src/AWS/RDSData/Drizzle.ts b/packages/alchemy/src/AWS/RDSData/Drizzle.ts new file mode 100644 index 000000000..e71fbf3ec --- /dev/null +++ b/packages/alchemy/src/AWS/RDSData/Drizzle.ts @@ -0,0 +1,111 @@ +import { RDSDataClient } from "@aws-sdk/client-rds-data"; +import type { AnyRelations, EmptyRelations } from "drizzle-orm"; +import { drizzle as drizzleDataApi } from "drizzle-orm/aws-data-api/pg"; +import type { EffectPgDatabase } from "drizzle-orm/effect-postgres"; +import * as Effect from "effect/Effect"; +import { ExecutionContext } from "../../ExecutionContext.ts"; +import { proxyChainPromise } from "../../Util/proxy-chain.ts"; +import type { DBCluster } from "../RDS/DBCluster.ts"; +import type { Secret } from "../SecretsManager/Secret.ts"; +import { BatchExecuteStatementPolicy } from "./BatchExecuteStatement.ts"; +import { BeginTransactionPolicy } from "./BeginTransaction.ts"; +import { CommitTransactionPolicy } from "./CommitTransaction.ts"; +import { ExecuteStatementPolicy } from "./ExecuteStatement.ts"; +import { RollbackTransactionPolicy } from "./RollbackTransaction.ts"; + +export interface DataApiOptions { + /** Secrets Manager secret holding the cluster's credentials. */ + secret: Secret; + /** + * Database name to connect to. + * @default "app" + */ + database?: string; + /** Optional drizzle relations (for the relational query API). */ + relations?: TRelations; +} + +/** + * Runtime Drizzle client backed by the **RDS Data API** — the AWS analog of + * {@link import("../../Drizzle/Postgres.ts").postgres}, but speaking the Data + * API over HTTPS+IAM instead of a `postgres://` connection. Ideal for Lambda + * functions that stay out of the VPC. + * + * Like `Drizzle.postgres`, it returns a chainable proxy over the drizzle + * database — query builders can be `yield*`-ed directly. The underlying + * `RDSDataClient` + drizzle instance are built lazily and memoized on the + * current `ExecutionContext`, so they're created at most once per invocation. + * IAM for every Data API operation drizzle may issue (statements + + * transactions) is attached at deploy time via the RDSData policies, which come + * from `AWS.providers()` on the Stack — nothing extra to provide on the Function. + * + * @binding + * @example + * ```typescript + * const db = yield* AWS.RDSData.drizzle(cluster, { secret, relations }); + * + * fetch: Effect.gen(function* () { + * const users = yield* db.select().from(Users); + * return yield* HttpServerResponse.json({ users }); + * }); + * ``` + */ +export const drizzle = ( + cluster: DBCluster, + options: DataApiOptions, +) => + Effect.gen(function* () { + const database = options.database ?? "app"; + // Attach IAM (deploy-time) for every Data API operation drizzle may issue. + // We yield the *policies* directly rather than the binding Services — the + // Service `Live` layers resolve the distilled SDK ops, which would require + // the AWS environment (Region/Credentials) at runtime; this binding talks + // to the Data API through its own `RDSDataClient`, so it needs none of + // that. Each policy is a no-op at runtime (its layer isn't provided). + const policyOptions = { secret: options.secret, database }; + const execPolicy = yield* ExecuteStatementPolicy; + const batchPolicy = yield* BatchExecuteStatementPolicy; + const beginPolicy = yield* BeginTransactionPolicy; + const commitPolicy = yield* CommitTransactionPolicy; + const rollbackPolicy = yield* RollbackTransactionPolicy; + yield* execPolicy(cluster, policyOptions); + yield* batchPolicy(cluster, policyOptions); + yield* beginPolicy(cluster, policyOptions); + yield* commitPolicy(cluster, policyOptions); + yield* rollbackPolicy(cluster, policyOptions); + + const resourceArn = yield* cluster.dbClusterArn; + const secretArn = yield* options.secret.secretArn; + + const symbol = Symbol(); + // Typed as drizzle's effect-aware `EffectPgDatabase` — its query-builder + // surface is structurally identical to the aws-data-api db, but every + // terminal resolves to an `Effect`, which is exactly what `proxyChainPromise` + // produces at runtime (it wraps the driver's `QueryPromise` thenables). + return proxyChainPromise< + EffectPgDatabase & { $client: RDSDataClient } + >( + Effect.gen(function* () { + const ctx = yield* ExecutionContext; + return yield* (ctx.cache[symbol] ??= yield* Effect.gen(function* () { + const arn = yield* resourceArn; + const sec = yield* secretArn; + // Region + credentials resolve from the Lambda execution environment + // via the AWS SDK default provider chain. + const client = new RDSDataClient({}); + return drizzleDataApi({ + client, + database, + resourceArn: arn, + secretArn: sec, + relations: options.relations, + }); + }).pipe(Effect.cached)); + }) as Effect.Effect< + EffectPgDatabase & { $client: RDSDataClient } + >, + ); + }); + +/** Friendly alias for {@link drizzle}. */ +export const dataApi = drizzle; diff --git a/packages/alchemy/src/AWS/RDSData/index.ts b/packages/alchemy/src/AWS/RDSData/index.ts index f3a4a0305..dfb3f52ae 100644 --- a/packages/alchemy/src/AWS/RDSData/index.ts +++ b/packages/alchemy/src/AWS/RDSData/index.ts @@ -1,6 +1,7 @@ export * from "./BatchExecuteStatement.ts"; export * from "./BeginTransaction.ts"; export * from "./CommitTransaction.ts"; +export * from "./Drizzle.ts"; export * from "./ExecuteSql.ts"; export * from "./ExecuteStatement.ts"; export * from "./RollbackTransaction.ts"; diff --git a/packages/alchemy/src/Util/proxy-chain.ts b/packages/alchemy/src/Util/proxy-chain.ts index 0727590eb..119ce51b6 100644 --- a/packages/alchemy/src/Util/proxy-chain.ts +++ b/packages/alchemy/src/Util/proxy-chain.ts @@ -10,7 +10,7 @@ type Op = * to the object the method was read from (drizzle's `select()` etc. * read `this._.session`, so dropping `this` would throw). */ -const replay = (root: unknown, ops: ReadonlyArray): unknown => { +export const replay = (root: unknown, ops: ReadonlyArray): unknown => { let cur: any = root; let receiver: any = root; for (const op of ops) { @@ -53,28 +53,58 @@ const replay = (root: unknown, ops: ReadonlyArray): unknown => { * builder, etc). Anything before that is recorded as ops. */ export const proxyChain = (cached: Effect.Effect): T => - chain(cached) as T; + chain( + cached, + [], + (value) => value as Effect.Effect, + ) as T; + +/** + * Like {@link proxyChain}, but for targets whose query builders resolve to a + * `Promise`/thenable rather than an `Effect` — e.g. drizzle's + * `aws-data-api/pg` driver (its query builders extend `QueryPromise`, which is + * a real `Promise`). At the terminal `yield*`, the replayed value is wrapped in + * `Effect.tryPromise` when it is a thenable; non-thenable values (e.g. reading + * `db.$client`) pass through unchanged so they can still be yielded. + * + * `onError` maps a rejected promise's cause into the Effect's error channel. + */ +export const proxyChainPromise = ( + cached: Effect.Effect, + onError: (cause: unknown) => E = (cause) => cause as E, +): T => + chain(cached, [], (value) => { + if ( + value != null && + typeof (value as { then?: unknown }).then === "function" + ) { + return Effect.tryPromise({ + try: () => value as Promise, + catch: onError, + }); + } + return value as Effect.Effect; + }) as T; const chain = ( cached: Effect.Effect, - ops: ReadonlyArray = [], + ops: ReadonlyArray, + toEffect: (value: unknown) => Effect.Effect, ): unknown => new Proxy(function () {} as any, { get(_, prop) { if (prop === Symbol.iterator) { // `yield* proxy` — produce the resolved Effect's iterator. return function () { - const eff = Effect.flatMap( - cached, - (root) => - replay(root, ops) as Effect.Effect, + const eff = Effect.flatMap(cached, (root) => + toEffect(replay(root, ops)), ); return (eff as any)[Symbol.iterator](); }; } - return chain(cached, [...ops, { kind: "get", prop }]); + return chain(cached, [...ops, { kind: "get", prop }], toEffect); }, apply(_, __, args) { - return chain(cached, [...ops, { kind: "call", args }]); + return chain(cached, [...ops, { kind: "call", args }], toEffect); }, }); diff --git a/packages/alchemy/test/AWS/RDS/Schema.unit.test.ts b/packages/alchemy/test/AWS/RDS/Schema.unit.test.ts new file mode 100644 index 000000000..2e860af6c --- /dev/null +++ b/packages/alchemy/test/AWS/RDS/Schema.unit.test.ts @@ -0,0 +1,26 @@ +import { splitStatements } from "@/AWS/RDS/Schema"; +import { describe, expect, test } from "vitest"; + +describe("splitStatements", () => { + test("splits drizzle output on statement-breakpoint and strips trailing ;", () => { + const sql = + "CREATE TABLE a (id int);\n--> statement-breakpoint\nCREATE TABLE b (id int);\n"; + expect(splitStatements(sql)).toEqual([ + "CREATE TABLE a (id int)", + "CREATE TABLE b (id int)", + ]); + }); + + test("falls back to semicolon split when there is no breakpoint", () => { + const sql = "INSERT INTO a VALUES (1);\nINSERT INTO a VALUES (2);\n"; + expect(splitStatements(sql)).toEqual([ + "INSERT INTO a VALUES (1)", + "INSERT INTO a VALUES (2)", + ]); + }); + + test("drops empty trailing chunks", () => { + const sql = "CREATE TABLE a (id int);\n--> statement-breakpoint\n\n"; + expect(splitStatements(sql)).toEqual(["CREATE TABLE a (id int)"]); + }); +}); diff --git a/packages/alchemy/test/Util/proxy-chain.test.ts b/packages/alchemy/test/Util/proxy-chain.test.ts new file mode 100644 index 000000000..28ea71aa6 --- /dev/null +++ b/packages/alchemy/test/Util/proxy-chain.test.ts @@ -0,0 +1,67 @@ +import { proxyChain, proxyChainPromise } from "@/Util/proxy-chain"; +import * as Effect from "effect/Effect"; +import { describe, expect, test } from "vitest"; + +describe("proxyChain", () => { + test("replays a get+call chain against an Effect-returning target", async () => { + const target = { + select() { + return { from: (t: string) => Effect.succeed(`rows:${t}`) }; + }, + }; + const db = proxyChain(Effect.succeed(target)); + const result = await Effect.runPromise( + Effect.gen(function* () { + return yield* db + .select() + .from("users") as unknown as Effect.Effect; + }), + ); + expect(result).toBe("rows:users"); + }); +}); + +describe("proxyChainPromise", () => { + test("wraps a thenable (Promise) result in an Effect", async () => { + const target = { + select() { + return { from: (t: string) => Promise.resolve(`rows:${t}`) }; + }, + }; + const db = proxyChainPromise(Effect.succeed(target)); + const result = await Effect.runPromise( + Effect.gen(function* () { + return yield* db + .select() + .from("users") as unknown as Effect.Effect; + }), + ); + expect(result).toBe("rows:users"); + }); + + test("maps a rejected promise into the Effect error channel", async () => { + const boom = new Error("boom"); + const target = { run: () => Promise.reject(boom) }; + const db = proxyChainPromise( + Effect.succeed(target), + (cause) => cause as Error, + ); + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + return yield* db.run() as unknown as Effect.Effect; + }), + ); + expect(exit._tag).toBe("Failure"); + }); + + test("passes a non-thenable (Effect) result through unchanged", async () => { + const target = { value: Effect.succeed(42) }; + const db = proxyChainPromise(Effect.succeed(target)); + const result = await Effect.runPromise( + Effect.gen(function* () { + return yield* db.value as unknown as Effect.Effect; + }), + ); + expect(result).toBe(42); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 3120b2c07..9a12de463 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -69,6 +69,9 @@ { "path": "./examples/aws-rds/tsconfig.json" }, + { + "path": "./examples/aws-lambda-rds-aurora-drizzle/tsconfig.json" + }, { "path": "./examples/aws-static-site/tsconfig.json" },