Aurora RDS Data API Dialect (`rdsdata`)
Overview#
The RDS Data API
runs SQL against an Aurora PostgreSQL or Aurora MySQL cluster over
HTTPS, through the AWS SDK: no socket, no pool, no VPC route to the database,
and no database password in the application. Every call names the cluster
(resourceArn) and a Secrets Manager secret holding the database credentials
(secretArn), and is signed with the caller's IAM credentials.| Property | Value |
|---|
| Dialect name | rdsdata |
| Class | RdsDataDialect |
| SQL grammar | PostgresDialect (engine: 'postgres', default) or MySQLDialect (engine: 'mysql') |
| Driver | @aws-sdk/client-rds-data (optional peer, loaded on connect()) |
| Port | none of its own — HTTPS, 443 (RDS_DATA_DEFAULT_PORT) |
| Extends | nothing — implements Dialect directly |
| Cloud SDK | @aws-sdk/client-rds-data |
| Default port | none — HTTPS |
| Local runtime | none — hosted service only |
Quick start#
Install#
$ npm install ts-prorm-orm @aws-sdk/client-rds-data
$ pnpm add ts-prorm-orm @aws-sdk/client-rds-data
$ yarn add ts-prorm-orm @aws-sdk/client-rds-data
$ bun add ts-prorm-orm @aws-sdk/client-rds-data
Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'rdsdata',
database: 'app',
dialectOptions: {
engine: 'postgres', // or 'mysql'
resourceArn: 'arn:aws:rds:us-east-1:123456789012:cluster:app-cluster',
secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:app-AbCdEf',
region: 'us-east-1',
},
});
Define a model#
import { DataTypes } from 'ts-prorm-orm';
const User = db.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
name: { type: DataTypes.STRING(100) },
meta: { type: DataTypes.JSONB() },
});
await db.sync(); // CREATE TABLE — see Generated SQL below
meta is JSONB, which the containment query below needs.Query#
import { Op } from 'ts-prorm-orm';
await User.create({ email: 'ada@example.com', name: 'Ada' });
const page = await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Generated SQL#
Each block is the statement the model call above it hands the dialect's query(), captured with the driver stubbed out. The model layer inlines the values.Create table#
db.sync() for the model in the Quick start:CREATE TABLE
Insert, update and delete#
await User.create({ email: 'ada@example.com', name: 'Ada' });
await User.update({ name: 'Grace' }, { where: { email: 'ada@example.com' } });
await User.destroy({ where: { id: 1 } });
Upsert#
await User.upsert({ id: 1, email: 'ada@example.com', name: 'Ada Lovelace' });
upsert() also reads the row by primary key before and after the write, to return the instance.await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
JSON#
Containment on the meta column:import { Op } from 'ts-prorm-orm';
await User.findAll({ where: { meta: { [Op.contains]: { plan: 'pro' } } } });
Transaction#
await db.transaction(async (t) => {
await User.create({ email: 'grace@example.com', name: 'Grace' }, { transaction: t });
await User.update({ name: 'Grace Hopper' }, { where: { email: 'grace@example.com' }, transaction: t });
});
[!WARNING] No savepoints
capabilitiesOf() reports no savepoints for this dialect, so a transaction() nested inside another one throws.
Table sampling#
sample on the select builder reads a random fraction of the table, for estimates over large tables:const sample = db.getDialectInstance()!.buildSelectQuery({
tableName: 'users',
sample: { method: 'BERNOULLI', percent: 10 },
});
await db.query(sample.sql);
Type mapping for the portable descriptors: STRING → VARCHAR(255), auto-increment INTEGER → SERIAL, BOOLEAN → BOOLEAN, DATE → TIMESTAMP, UUID → UUID, JSON → JSON. The type comparison lines the major engines up side by side.Connection#
import { Prorm } from 'ts-prorm-orm';
const db = new Prorm({
dialect: 'rdsdata',
database: 'app',
dialectOptions: {
engine: 'postgres', // or 'mysql'
resourceArn: 'arn:aws:rds:us-east-1:123456789012:cluster:app-cluster',
secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:app-AbCdEf',
region: 'us-east-1',
// credentials, endpoint (VPC/FIPS endpoint), resultSetOptions, continueAfterTimeout
},
});
| Option | Meaning |
|---|
engine | 'postgres' (default) or 'mysql' — picks the SQL grammar |
resourceArn | The Aurora cluster ARN (…:cluster:<id>); 11–100 characters |
secretArn | Secret with the database user and password; 11–100 characters |
database | Database for every call (≤ 64 characters) |
region, endpoint, credentials | Passed to RDSDataClient; default: the SDK's chains |
client | A ready client with the five methods (e.g. new RDSData({...})); the SDK is then not loaded here |
continueAfterTimeout | 'ddl' (default), true or false — see Timeouts |
resultSetOptions | Passed through, e.g. { longReturnType: 'STRING' } for exact 64-bit values |
resumeRetries, resumeRetryDelayMs | Retries after DatabaseResumingException (default 3 × 5 s) |
connect() validates the options and builds the client; it makes no call (a
probe statement would wake a paused Serverless v2 instance every time).
Construction loads nothing.schema — Aurora clusters reject Data API calls that include the schema
parameter. Qualify names (quoteTable(table, schema)); on MySQL set
database.
password — credentials come from the secret; a password would be silently
ignored.
A non-cluster resourceArn — the Data API serves Aurora clusters only.
The cluster needs the Data API enabled, and a version that supports it
(Aurora PostgreSQL 13.11+, 14.8+, 15.3+, 16.1+, 17.4+; Aurora MySQL 3.07+;
provisioned or Serverless v2; not T instance classes). On Aurora PostgreSQL 14+
the database user's password must use scram-sha-256.One class, two grammars#
RdsDataDialect holds an unconnected PostgresDialect or MySQLDialect
(dialect.grammar) rather than extending one: a class cannot choose its
superclass per instance, and the transport is identical for both engines.
Every grammar method the facade does not define is forwarded to it, and the
grammar's own I/O entry points (query, transactions, queryStream, the
version probe, the PostgreSQL session features) are pointed back at the
facade, so inherited helpers such as createTable() or bulkInsert() go out
over the Data API.Parameters#
The Data API binds named parameters (:name) with typed values. The
grammars' positional placeholders — $1 (PostgreSQL) and ? (MySQL) — are
rewritten to :p1, :p2, …, skipping string literals, quoted identifiers,
comments and PostgreSQL dollar-quoted bodies.| JavaScript value | Sent as |
|---|
null / undefined | isNull |
| boolean | booleanValue |
| safe integer / bigint | longValue |
| bigint beyond ±2^53 | stringValue, typeHint DECIMAL |
| other number | doubleValue (NaN/Infinity refused) |
| string | stringValue |
Date | stringValue YYYY-MM-DD HH:MM:SS.FFF (UTC), typeHint TIMESTAMP |
Buffer / Uint8Array | blobValue |
| plain object | JSON stringValue, typeHint JSON |
RdsDataTypes.decimal/uuid/json/date/time/timestamp(v) | stringValue with that typeHint (formats checked) |
array, raw Literal | inlined by the grammar's escape() — the Data API has no array parameters |
A plain object as replacements is sent as named parameters for the :names
the SQL uses; a :name without a value is refused.await db.query('SELECT * FROM orders WHERE id = :id AND total > :min', {
replacements: { id: RdsDataTypes.uuid(orderId), min: RdsDataTypes.decimal('10.00') },
});
Results#
Rows come from records + columnMetadata (includeResultMetadata: true),
not from formatRecordsAs: 'JSON': typed fields keep BLOBs as Buffers and
64-bit integers out of JSON.parse. Dates and times come back as strings
(the Data API returns them as stringValue; Aurora PostgreSQL returns
TIMESTAMPTZ in UTC). On MySQL, generatedFields becomes lastInsertRowid;
on PostgreSQL (which does not support generatedFields) an INSERT gets
RETURNING *, as the postgres dialect does.Limits#
| Limit (AWS-documented) | What the dialect does |
|---|
| Response ≤ 1 MiB; ≤ 64 KB per row | Error annotated; queryStream() pages with LIMIT/OFFSET |
| SQL text ≤ 65,536 characters | Refused before sending |
| HTTP request ≤ 4 MiB (headers + JSON) | Refused before sending; an oversize batch() in a transaction is split |
| Call cancelled after 45 s | continueAfterTimeout (see below) |
| One statement per call | Several statements refused before sending |
| Transaction: 3 min idle, 24 h total | Checked before each call; server error annotated |
| One request at a time per transaction | Calls on a transaction are queued |
| No array parameters | Arrays inlined as literals |
schema parameter rejected | Refused at connect() |
All values are exported as RDS_DATA_LIMITS.Timeouts#
A Data API call is cancelled after 45 seconds. With continueAfterTimeout
the call still returns a timeout error, but the statement keeps running. AWS
recommends it for DDL, because a DDL statement cut off mid-way can leave
errors or damaged structures — so the default 'ddl' sends it for
CREATE/ALTER/DROP/RENAME/TRUNCATE/COMMENT/GRANT/REVOKE outside a
transaction. Override per call: query(sql, { continueAfterTimeout: true }).
Keep any SDK/HTTP timeout above 45 s (AWS suggests at least a minute) so the
client does not give up — and retry — while the statement is still running.Transactions#
const tx = await db.getDialect().startTransaction(); // BeginTransaction
await tx.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', {
replacements: [10, 1],
});
await tx.commit(); // CommitTransaction
transactionId is sent with every call made through tx.query() or
query(sql, { transaction: tx }). Refused:An isolation level — BeginTransaction has none. Set the cluster default
in its DB parameter group (default_transaction_isolation on PostgreSQL,
transaction_isolation on MySQL).
DDL inside an Aurora MySQL transaction — MySQL commits the transaction
implicitly before DDL. Run it on its own.
Batches#
const { sql } = dialect.buildInsertQuery('events', { kind: '', at: new Date() });
await dialect.batch(
sql,
rows.map((r) => [r.kind, r.at]),
{ transaction: tx },
);
batch() is BatchExecuteStatement: one DML statement over many parameter
sets, no result set (a SELECT is refused), trailing ; removed. Outside a
transaction it is one call and commits as a unit; a batch over 4 MiB is
refused. Inside a transaction an oversize batch is split into several calls,
committed together.Refused: features that need a held connection#
Each call is a separate HTTPS request, so nothing that needs a connection kept
open between calls works:| Method | Alternative |
|---|
listen / unlisten / notify | The postgres dialect on the cluster endpoint, or SNS/SQS/EventBridge |
copyFrom (COPY FROM STDIN) | batch(), or aws_s3.table_import_from_s3 / LOAD DATA FROM S3 |
copyTo (COPY TO STDOUT) | queryStream(), or aws_s3.query_export_to_s3 / SELECT … INTO OUTFILE S3 |
pgAdvisoryLock / pgTryAdvisoryLock | pg_advisory_xact_lock inside a transaction |
queryStream() uses LIMIT/OFFSET pages instead of a server-side cursor.References#
Capabilities#
Computed from the code — capabilitiesOf() (src/transaction-support.ts),
whereSupportOf() (src/query-builders/expression-render.ts),
sqlObjectCapabilitiesOf() (src/sql-objects/capabilities.ts) and the
builders — by the scratch test described in the
capability matrix.| Capability | rdsdata |
|---|
| Transactions atomic | yes |
| Savepoints | yes |
| Isolation | up to SERIALIZABLE |
| Transaction modes | read-only no; deferrable no; constraint deferral no |
| RETURNING | RETURNING on INSERT |
| Upsert | INSERT … ON CONFLICT (…) DO UPDATE |
| JSON column | JSON (JSONB → JSONB) |
| Vector column | VECTOR(3) |
| Array column | INTEGER[] |
| WHERE builder | 62 operator keys; $and/$or/$not; $exists: yes; $in subquery: yes; expression operands: yes |
| SQL objects | family unknown: triggers no; procedures no; functions no; materialized views no; sequences no; enums check; RLS no |
| Streaming | queryStream() present; LISTEN present; createExtension() present |
Testing#
tests/dialects/rdsdata.test.ts exercises the dialect with a mocked driver, so nothing touches a server.See also#
Modified at 2026-10-04 00:40:16