Data API Client v2.4: Prisma Support for the Amazon RDS Data API
A new Prisma 7 adapter complements the existing Knex, Postgres, and MySQL compatibility layers for working with the RDS Data API on Provisioned and Serverless Aurora.
When I announced v2 back in October, I ended the post by saying I was already exploring Prisma and TypeORM adapters. With v2.4, the Prisma one is finally here, along with Knex, Drizzle, and Kysely.
Now that we have all the pieces in place, I thought it might be a good time to go through all the ways you can use this library to interact with the Amazon RDS Data API. All of it works with both the Aurora Provisioned and Aurora Serverless v2 editions of Postgres and MySQL.
There are four ways to use Data API Client today, and they stack from "just give me a simple query function" all the way up to "let my ORM drive." Pick the one that fits how you like to work.
Option one: the original simple syntax
The original version of this library existed for one reason: the raw Data API is clunky to write against. You send a SQL string and an array of typed parameter objects, and you get back a nested response you have to dig through by hand. The client wrapped all of that in a query method with named parameters and native JavaScript types, plus a transaction manager that handles the begin/commit/rollback dance for you.
That original approach is still here, and it's still a great way to go if you want to hand-roll your own SQL with a simple syntax and skip the abstraction:
1import dataApiClient from "data-api-client";23const client = dataApiClient({4 resourceArn: "arn:aws:rds:...",5 secretArn: "arn:aws:secretsmanager:...",6 database: "myDatabase",7 engine: "pg",8});910// Named parameters, native types in and out11const result = await client.query("SELECT * FROM users WHERE id = :id", {12 id: 123,13});14console.log(result.records);
Transactions chain together and commit as a unit, with no connection to manage:
1const results = await client2 .transaction()3 .query("INSERT INTO users (name) VALUES(:name)", { name: "Alice" })4 .query("UPDATE users SET age = :age WHERE name = :name", {5 age: 30,6 name: "Alice",7 })8 .commit();
No drivers, no ORM, no schema definitions. If your needs are simple, you can stop reading here. But a lot of people want to bring their existing tools, and that's what the rest of the options are for.
The compatibility layers
The trouble with bringing your existing tools is that the Data API is its own protocol. Nothing else on AWS talks to a database this way. Every ORM, every query builder, every tutorial you've ever read assumes a TCP connection to mysql2 or pg that simply doesn't exist when you're running on the Data API.
So v2 shipped two shims that impersonate those drivers. data-api-client/compat/mysql2 matches the mysql2/promise interface. data-api-client/compat/pg matches pg. Same method signatures, same return shapes, same parameter syntax. Underneath, they translate everything to Data API calls and translate the responses back.
That opens up the next two ways to work.
Option two: use the driver semantics directly
If you like writing SQL and you already know how mysql2 or pg behave, you don't have to learn anything new. The shims behave like the real thing.
Here's the pg client. Numbered placeholders, result.rows, all of it:
1import { createPgClient } from "data-api-client/compat/pg";23const client = createPgClient({4 resourceArn: "arn:aws:rds:...",5 secretArn: "arn:aws:secretsmanager:...",6 database: "myDatabase",7});89const result = await client.query("SELECT * FROM users WHERE id = $1", [123]);10console.log(result.rows);
And here's mysql2. Question-mark placeholders, the [rows, fields] array destructure that mysql2 people have in muscle memory:
1import { createMySQLConnection } from "data-api-client/compat/mysql2";23const connection = createMySQLConnection({4 resourceArn: "arn:aws:rds:...",5 secretArn: "arn:aws:secretsmanager:...",6 database: "myDatabase",7});89const [rows, fields] = await connection.query(10 "SELECT * FROM users WHERE id = ?",11 [123],12);
Prefer named placeholders? mysql2 has an option for that, so the shim does too:
1const connection = createMySQLConnection({2 resourceArn: "arn:aws:rds:...",3 secretArn: "arn:aws:secretsmanager:...",4 database: "myDatabase",5 namedPlaceholders: true,6});78const [users] = await connection.query(9 "SELECT * FROM users WHERE name = :name AND age > :age",10 { name: "Alice", age: 25 },11);
There are pool variants too, createMySQLPool and createPgPool, for the libraries that expect to manage connections themselves. Pooling against the Data API is mostly a fiction (there's no socket to pool), but the interface is there because the things sitting on top of it ask for it.
Which is the real point of the compat layers.
Option three: put a query builder or ORM on top
Because the shims look like mysql2 and pg, anything that's built to talk to mysql2 and pg just works. You hand the shim to the library and it has no idea it's not talking to a real database.
Drizzle with Postgres:
1import { drizzle } from "drizzle-orm/node-postgres";2import { createPgClient } from "data-api-client/compat/pg";34const client = createPgClient({5 resourceArn: "arn:aws:rds:...",6 secretArn: "arn:aws:secretsmanager:...",7 database: "myDatabase",8});910await client.connect();11const db = drizzle(client as any);1213const result = await db.select().from(users).where(eq(users.id, 123));
Kysely with MySQL, fully typed, using its standard dialect:
1import { Kysely, MysqlDialect } from "kysely";2import { createMySQLPool } from "data-api-client/compat/mysql2";34const pool = createMySQLPool({5 resourceArn: "arn:aws:rds:...",6 secretArn: "arn:aws:secretsmanager:...",7 database: "myDatabase",8});910const db = new Kysely<Database>({11 dialect: new MysqlDialect({ pool: pool as any }),12});1314const users = await db15 .selectFrom("users")16 .selectAll()17 .where("id", "=", 123)18 .execute();
Nothing in either of those snippets is Data-API-aware. Drizzle thinks it has a pg client. Kysely thinks it has a mysql2 pool. The scale-to-zero retries, the parameter encoding, the result parsing, all of it happens a layer below where the ORM can see it.
So that's three of them: write SQL against the simple syntax, drop down to driver semantics, or stack a builder on top. The fourth needs a little more than the shims.
Option four: dedicated adapters for Prisma and Knex
Drizzle and Kysely work because they speak mysql2 and pg natively, so the shims are enough. Prisma and Knex don't, and that's why each needed a dedicated module rather than riding the existing compat layers.
Prisma 7 moved to driver adapters, which is its own interface. Knex expects a custom client class instead of a raw connection. So each got a small module that reuses everything the core client already does (queries, retries, result formatting, transactions) and only adds the bits that specific library needs.
The Prisma adapter is what's new in v2.4. The Knex dialect shipped in v2.3.
Prisma
There's a new entry point at data-api-client/compat/prisma with two adapters, one per engine.
Postgres:
1import { PrismaClient } from "@prisma/client";2import { createPrismaPgAdapter } from "data-api-client/compat/prisma";34const adapter = createPrismaPgAdapter({5 secretArn: process.env.SECRET_ARN,6 resourceArn: process.env.RESOURCE_ARN,7 database: "mydb",8});910const prisma = new PrismaClient({ adapter });
MySQL is the same shape with a different factory:
1import { PrismaClient } from "@prisma/client";2import { createPrismaMySQLAdapter } from "data-api-client/compat/prisma";34const adapter = createPrismaMySQLAdapter({5 secretArn: process.env.SECRET_ARN,6 resourceArn: process.env.RESOURCE_ARN,7 database: "mydb",8});910const prisma = new PrismaClient({ adapter });
After that, it's just Prisma. Your schema and generated client behave exactly as they would against any other database:
1const user = await prisma.user.create({2 data: { email: "alice@example.com", name: "Alice" },3});45const recent = await prisma.post.findMany({6 where: { published: true },7 include: { author: true },8 orderBy: { createdAt: "desc" },9 take: 10,10});
If you've ever hated yourself enough to try building a compatibility layer, then you know that drift and nuance are the things ulcers and sleepless nights are made of. So I wasn't about to ship this on a hunch that "the common stuff works." The release went through a 179-test audit against the adapter: CRUD, aggregations, groupBy, the filter operators, cursor pagination, select/include/omit, nested writes, relation filters, atomic updates, JSON columns, and the awkward types like Decimal, BigInt, DateTime, and Postgres scalar arrays. Raw queries too.
Interactive transactions work, and they map to the Data API's native transaction lifecycle:
1await prisma.$transaction(async (tx) => {2 const user = await tx.user.create({ data: { name: "Alice" } });3 await tx.post.create({4 data: { authorId: user.id, title: "Hello" },5 });6});
One caveat worth knowing up front: nested savepoints aren't supported. The Data API gives you a single flat transaction, so a savepoint inside a transaction gets rejected rather than silently doing the wrong thing. That same rule holds across all the adapter layers, so it's worth treating as a general Data API constraint.
Knex
Knex gets its own module too, at data-api-client/compat/knex, with a client factory per engine. You pass the factory as Knex's client and leave connection empty, since there's no real connection to configure:
1import knex from "knex";2import { createKnexPgClient } from "data-api-client/compat/knex";34const db = knex({5 client: createKnexPgClient({6 resourceArn: "arn:aws:rds:...",7 secretArn: "arn:aws:secretsmanager:...",8 database: "myDatabase",9 }),10 connection: {},11});1213const id = await db("users").insert({ name: "Alice" }).returning("id");
MySQL is the mirror image with createKnexMySQLClient:
1import knex from "knex";2import { createKnexMySQLClient } from "data-api-client/compat/knex";34const db = knex({5 client: createKnexMySQLClient({6 resourceArn: "arn:aws:rds:...",7 secretArn: "arn:aws:secretsmanager:...",8 database: "myDatabase",9 }),10 connection: {},11});1213const users = await db("users").where("id", 123).select("*");
Knex transactions work the way you'd expect:
1await db.transaction(async (trx) => {2 const [userId] = await trx("users").insert({ name: "Alice" }).returning("id");3 await trx("posts").insert({ user_id: userId, title: "Hello" });4});
Same transaction rule as everywhere else: it's a real Data API transaction, no nested savepoints.
Two bug fixes that matter
A couple of things were wrong, and they're fixed now.
One was array result formatting. When you ran with hydrateColumnNames: false (raw arrays instead of objects, the path you take when you care about throughput), array-column values were getting flattened incorrectly. If you were reading Postgres array columns in that mode, you'll want this fix.
The other was typeHint handling. Explicit type hints for DATE, TIME, DECIMAL, UUID, and JSON had stopped being applied. They're back. If you were passing those hints to get correct encoding, they actually do something again.
Things to keep in mind
There are two limits with the Amazon RDS Data API worth calling out, because they affect the library.
Nested transactions still aren't a thing. I've mentioned it a few times above, but it's the one place where Data API semantics genuinely differ from a normal driver, so it's worth repeating: you get one flat transaction at a time, with no nesting inside it.
And the Data API caps a single statement's result at roughly 1 MB. If a query would return more than that, it fails at the AWS level before this client ever sees the rows. For most application queries you'll never come near it, but if you're doing a giant SELECT * for an export, paginate it.
Where this leaves things
The shape of the project is finally what I wanted it to be. There's one core that handles the annoying parts of the Data API (the encoding, the retries when Aurora is waking back up, the result parsing), and then a thin compat layer per engine that makes that core look like the driver your tools already expect.
Drizzle and Kysely ride on the mysql2 and pg shims. Prisma and Knex get their own small adapters because their interfaces are different enough to need one. Either way, you write your queries the way you already know how, and the scale-to-zero, no-VPC, IAM-auth reality of the Data API stays out of your way.
Pick your layer. The simple query syntax if you just want to write SQL without ceremony, the driver semantics if you want to stay close to mysql2 or pg, a query builder like Drizzle or Kysely if you want type safety, or a full ORM like Prisma if you want the whole thing managed. They all run on the same engine, against both Aurora Provisioned and Serverless v2, for Postgres and MySQL.
Try it out, and let me know what breaks: github.com/jeremydaly/data-api-client.