Database Drivers
Configuration reference for supported database drivers
Database Drivers
ObjectStack supports multiple database backends through a unified driver interface. Drivers can be selected in two ways:
-
Env var only — auto-inferred from the URL scheme (the simplest path):
export OS_DATABASE_URL=mongodb://localhost:27017/myapp # → MongoDB export OS_DATABASE_URL=postgres://user:pass@host/db # → PostgreSQL export OS_DATABASE_URL=mysql://user:pass@host/db # → MySQL export OS_DATABASE_URL=file:./data/app.db # → SQLite pnpm devThe CLI inspects the connection-string scheme and picks the right driver automatically. You only need to set
OS_DATABASE_DRIVERwhen you want to override the inference (e.g. forcesqlitefor an ambiguous path). -
Programmatic — register a driver instance as a kernel plugin via
DriverPlugin. This is the escape hatch for pre-built or auxiliary drivers — the CLI itself no longer boots the primary this way: it declares thedefaultdatasource as a definition and connects it through the shared datasource path (#3826, described further down this page):import { DriverPlugin } from '@objectstack/runtime'; import { SqlDriver } from '@objectstack/driver-sql'; await kernel.use( new DriverPlugin( new SqlDriver({ client: 'pg', connection: process.env.OS_DATABASE_URL, }), ), );
URL → Driver Inference Table
| URL pattern | Inferred driver | npm package |
|---|---|---|
mongodb://…, mongodb+srv://… | MongoDB | @objectstack/driver-mongodb |
postgres://…, postgresql://… | PostgreSQL (Knex pg) | @objectstack/driver-sql + pg |
mysql://…, mysql2://… | MySQL (Knex mysql2) | @objectstack/driver-sql + mysql2 |
libsql://…, http(s)://*.turso.… | Turso / libSQL | @objectstack/driver-turso (optional — install it yourself) |
wasm-sqlite://…, *.wasm.db | SQLite (pure-JS WASM) | @objectstack/driver-sqlite-wasm |
file:…, sqlite:…, :memory:, *.db / *.sqlite | SQLite (Knex better-sqlite3) | @objectstack/driver-sql + better-sqlite3 |
| (unset, dev mode) | SQLite (native, falling back to WASM, then in-memory) | @objectstack/driver-sql / -sqlite-wasm / -memory |
Turso / libSQL needs one extra install. libsql:// and *.turso.io URLs are
inferred — by the CLI (os serve / os start / os dev) and by the standalone
runtime stack the one-shot commands and embedders boot through (os migrate,
createStandaloneStack) alike. But @objectstack/driver-turso is an optional
install — it pulls in @libsql/client plus native bindings, so it is not part of a
default install:
npm install @objectstack/driver-tursoWithout it the boot fails loudly with that exact command; it never degrades to
SQLite, which would start the server against an empty local database while your
libSQL data stayed untouched. Pass the token with --database-auth-token
(OS_DATABASE_AUTH_TOKEN, or the vendor's own TURSO_AUTH_TOKEN).
Supported Drivers
| Driver | npm package | Class | Explicit OS_DATABASE_DRIVER value |
|---|---|---|---|
| PostgreSQL | @objectstack/driver-sql (peer: pg) | SqlDriver | postgres | postgresql | pg |
| MySQL | @objectstack/driver-sql (peer: mysql2) | SqlDriver | mysql | mysql2 |
| SQLite | @objectstack/driver-sql (peer: better-sqlite3) | SqlDriver | sqlite | sql |
| SQLite (WASM) | @objectstack/driver-sqlite-wasm | SqliteWasmDriver | sqlite-wasm | wasm-sqlite | wasm |
| MongoDB | @objectstack/driver-mongodb | MongoDBDriver | mongodb | mongo (single-tenant only — see below) |
| Turso / libSQL | @objectstack/driver-turso (optional install — see the callout above) | TursoDriver | turso | libsql |
| Memory | @objectstack/driver-memory | InMemoryDriver | memory |
All SQL flavours (PostgreSQL / MySQL / SQLite) are served by a single
@objectstack/driver-sqlpackage — there is no separatedriver-postgres,driver-mysql, ordriver-sqlitepackage. Pick the Knex client name (pg/mysql2/better-sqlite3) when you instantiateSqlDriver.
config is validated per driver
A datasource's config is driver-specific — a SQLite filename and a Postgres
host share no shape — so the datasource schema keeps that slot open at the top
level and parses it against the contract for the driver you named. Each built-in
driver ships that contract as a zod schema, exported from @objectstack/spec/data:
driver | Contract | Keys |
|---|---|---|
postgres | postgresql | pg | PostgresConfigSchema | url, host, port, database, username, password, ssl, schema, applicationName, statementTimeout, autoMigrate |
mysql | mysql2 | mariadb | MysqlConfigSchema | url, host, port, database, username, password, ssl, autoMigrate |
sqlite | sqlite3 | SqliteConfigSchema | filename, autoMigrate |
sqlite-wasm | wasm-sqlite | SqliteWasmConfigSchema | filename, persist |
mongo | mongodb | MongoConfigSchema | url, host, port, database, username, password, authSource, options |
memory | in-memory | MemoryConfigSchema | initialData, strictMode, persistence |
An unrecognised key is rejected with its correction, at authoring time and in the Setup → Datasources wizard alike:
Unrecognized key(s) on this postgres datasource's config: `hostname`.
Did you mean `hostname` → `host`?This matters more than a typical typo check, because the failure it replaces was
silent: a misspelled key was dropped, the driver fell back to its own defaults,
and the datasource connected to localhost while every signal — the parse, the
save, the connection probe — reported success.
Two things live outside config, because they are not driver-specific:
-
Pool sizing — the
poolblock on the datasource (min,max,idleTimeoutMillis,connectionTimeoutMillis), honoured by the pooled drivers:postgresandmysqlpass all four to Knex, andmongodbmapsmin/max— and only those two — onto the client'sminPoolSize/maxPoolSize. Everywhere else the block is an authoring error, rejected by the Setup wizard when you save and by the boot when a declared datasource carries one, rather than dropped:Driver Verdict on poolpostgres,mysqlall four keys honoured mongodbmin/maxhonoured;idleTimeoutMillisandconnectionTimeoutMillisrejected by namesqlite,sqlite-wasmwhole block rejected memorywhole block rejected turso/libsqlwhole block rejected Each arm says why in its own terms. A SQLite connection strategy is owned by the driver (one connection per database, because a second connection to
:memory:opens a separate, empty one);memoryopens no connection at all; neither libSQL transport pools — afile:url runs that same local SQLite engine and alibsql://url is a remote request transport capped byconfig.concurrency. Every one of these used to be dropped in silence: anapp-crmdatasource asking formax: 5measurably ran on one connection (#5714), and a mongo datasource asking formax: 20, idleTimeoutMillis: 30000got the first and lost the second without a word (#7243) — the half-honoured case, which is the hardest to notice. The fix is always to delete the rejected keys; they were reaching nothing either way. -
TLS certificates — the
sslblock on the datasource (enabled,rejectUnauthorized,ca,cert,key). Insideconfig,sslis the on/off boolean shorthand. -
schemaMode— the ADR-0015 ownership mode, declared next todriver.
A plugin-contributed driver (com.vendor.snowflake) has no contract in this
repo, so its config is left unvalidated rather than judged against a shape the
platform does not have.
The same schemas are projected to JSON Schema for
DriverDefinitionSchema.configSchema and for GET /api/v1/datasources/drivers,
which the Studio connection form renders — so the form offers exactly the fields
the validator accepts.
Startup: a driver that cannot connect aborts the boot
ObjectQLEngine.init() connects every registered driver during kernel
bootstrap. If any connect() rejects, the boot is refused
(#3741) — the error
names each failed driver and its cause, and objectstack serve exits 1.
Booting without a reachable datasource would produce a server that reports
itself started, answers health checks, and then fails every request with an
error nothing like the database is unreachable — while the schema sync that
follows init() issues DDL against a datasource that isn't there.
The reason is not that the connection can never come back. Client libraries do re-establish connections on their own — the MongoDB driver's topology monitor reconnects, and knex/pg opens a fresh connection per acquire (verified in #3759). What never comes back is the boot sequence that was skipped: nothing re-runs the schema sync, so those objects can be left with no tables even after the database returns.
The same guard covers declared datasources whose objects have no fallback — see When auto-connect fails (#3758).
The standalone default datasource itself is now a declared definition
(#3826): the stack
translates OS_DATABASE_URL into { driver, config } and connects it at boot
through the same datasource connection path — one failure verdict, one escape
hatch, and a real status for the primary DB in Setup → Datasources. The
name default is host-reserved: an app bundle declaring a datasource with that
name is rejected at load.
OS_ALLOW_DRIVER_CONNECT_FAILURE=1 boots anyway, in an explicitly degraded
state announced by a DEGRADED BOOT banner. Queries to a failed driver fail
until the datasource becomes reachable, and its boot-time schema sync is skipped
for good. Do not set it in production.
Writing a driver: connect() is where you refuse to start
Because the rejection now propagates, throwing from connect() is the supported
way for a driver to veto the boot — not only for an unreachable socket, but
for any fatal startup condition: an unsupported server version, a missing
capability, an incompatible deployment mode, a licence check. Validation that
needs a live connection belongs here rather than in the constructor.
async connect(): Promise<void> {
await this.pool.connect();
const { version } = await this.probeServerVersion();
if (major(version) < 14) {
// Aborts bootstrap — the operator sees this message, not a 500 per request.
throw new Error(`PostgreSQL ${version} is unsupported; this driver requires 14+.`);
}
}Checks that need no connection can still run in the constructor, which fails
even earlier — that is where MongoDBDriver puts its
tenancy guard.
PostgreSQL (via @objectstack/driver-sql)
pnpm add @objectstack/driver-sql pgSqlDriver's constructor accepts a Knex Knex.Config
object, passing it through unchanged except for two connect-timeout defaults
described below.
import { SqlDriver } from '@objectstack/driver-sql';
new SqlDriver({
client: 'pg',
connection: {
host: 'db.example.com',
port: 5432,
user: 'admin',
password: process.env.DB_PASSWORD,
database: 'myapp',
ssl: true,
},
pool: { min: 2, max: 10 },
});Or via env var alone (driver inferred from postgres:// scheme):
export OS_DATABASE_URL=postgres://admin:secret@db.example.com:5432/myappConnect timeouts
A database endpoint that accepts the TCP connection but never completes the
handshake — an overloaded instance, a half-open firewall, a load balancer
mid-failover — makes every query wait rather than fail. Left to Knex's own
defaults that wait is 30 seconds per query on the request path, and with a small
pool.max a handful of them saturate the pool. So SqlDriver supplies two
defaults (#3769):
| Setting | Default | Purpose |
|---|---|---|
connection.connectionTimeoutMillis (pg) / connection.connectTimeout (mysql2) | 10_000 | The effective bound. Fails with the driver's own wording — timeout expired / connect ETIMEDOUT — which names the network. |
pool.createTimeoutMillis | 15_000 | Backstop, only reached by a client that has no connect-timeout option or ignores it. Deliberately looser: the two race, and Knex wins a tie, so an equal value would mask the accurate message with Knex's misleading "the pool is probably full". |
Set either explicitly and it is left untouched — do that when a datasource legitimately takes longer to connect (a distant cross-region replica). SQLite gets neither: opening a file has no handshake to time out.
Carrying a connect timeout requires connection to be an object, so a URL string
is moved into the client's URL slot (connectionString for pg, uri for
mysql2) before reaching Knex. This affects only what Knex receives — the config
you passed is preserved as-is on the driver.
MongoDB
Configuration properties for the MongoDB driver.
| Property | Type | Required | Description |
|---|---|---|---|
| url | string | ✅ | Connection URI (e.g., mongodb://host:27017/db) |
| database | string | optional | Database name (overrides the database in the URI) |
| maxPoolSize | number | optional | Max connection pool size (default: 10) |
| minPoolSize | number | optional | Min connection pool size (default: 1) |
| connectTimeoutMS | number | optional | Connection timeout in milliseconds |
| serverSelectionTimeoutMS | number | optional | Server selection timeout in milliseconds |
| options | MongoClientOptions | optional | Additional MongoClient options (e.g. ssl, replicaSet, readPreference, authSource) |
Example (config):
import { MongoDBDriver } from '@objectstack/driver-mongodb';
new MongoDBDriver({
url: 'mongodb+srv://admin:secret@cluster.mongodb.net/myapp',
maxPoolSize: 50,
options: { readPreference: 'nearest' },
});Example (env-var, recommended for objectstack dev / objectstack serve):
export OS_DATABASE_URL=mongodb://localhost:27017/myapp
pnpm devThe CLI infers the MongoDB driver from the mongodb:// (or mongodb+srv://)
URL scheme automatically — no OS_DATABASE_DRIVER needed. Set
OS_DATABASE_DRIVER=mongodb only if you want to be explicit.
Multi-tenancy: not supported
The MongoDB driver is single-tenant only. It implements no row-level tenant
isolation — unlike SqlDriver, it ignores DriverOptions.tenantId, so reads
carry no tenant predicate and writes are not stamped with a tenant column.
Rather than serve a multi-tenant deployment without isolation, the driver refuses to start in one (#3724):
| Signal | Checked in | Result |
|---|---|---|
Tenancy posture is not single — OS_TENANCY_POSTURE=group/isolated, or derived from OS_MULTI_ORG_ENABLED=true | new MongoDBDriver(), re-checked in connect() | throws MongoDBMultiTenantUnsupportedError; objectstack serve exits 1 |
An object declares tenancy.enabled: true | syncSchema() / syncSchemasBatch() | throws, naming every offending object |
The error carries code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'. There is no
override flag — one would restore exactly the silent cross-tenant access the
guard prevents. For multi-tenant deployments use @objectstack/driver-sql
(PostgreSQL / MySQL / SQLite), which enforces tenant scoping at the driver
level.
SQLite (via @objectstack/driver-sql)
SQLite is ideal for local-first development and embedded applications.
It uses the same SqlDriver class with client: 'better-sqlite3'.
pnpm add @objectstack/driver-sql better-sqlite3import { SqlDriver } from '@objectstack/driver-sql';
new SqlDriver({
client: 'better-sqlite3',
connection: { filename: './data/app.db' },
useNullAsDefault: true,
});Env-var path (driver inferred from file: / :memory: / .db paths):
export OS_DATABASE_URL=file:./data/app.db
# or
export OS_DATABASE_URL=:memory:Journal mode: WAL, and cross-process access
A file-backed SQLite database is switched to WAL (write-ahead logging) the
first time ObjectStack connects to it. SQLite's own default is a rollback
journal (journal_mode = delete), which is the wrong trade for the way this
platform is actually used — several processes on one file: a dev server,
os migrate, os meta resync, a test run.
| rollback journal (SQLite default) | WAL (ObjectStack default) | |
|---|---|---|
| Reader while another process writes | allowed until the writer commits | always allowed (last committed snapshot) |
| Writer while another process reads | blocked — committing needs an exclusive lock (SQLITE_BUSY) | allowed |
| Idle connection visible to other processes | no — a lock lasts only as long as its transaction | yes, which is what makes the os migrate occupancy check reliable |
| Files on disk | app.db (plus a transient app.db-journal) | app.db, plus app.db-wal / app.db-shm while a connection is attached |
Concurrent writers still serialize — SQLite allows one at a time in any mode.
Two consequences worth knowing:
- Journal mode is stored in the file. One connection sets it and it stays set, so an existing database is converted in place on the next boot (a header change; no rows are touched) and stays in WAL even when opened by other tools.
app.db-walcan hold committed data. A clean shutdown checkpoints and removes it, but do not copy or restoreapp.dbon its own while a server is attached — usesqlite3 app.db ".backup …", as Backup & restore describes.
Opting out. WAL requires shared memory beside the database and therefore does not work on network filesystems (NFS/SMB). Set the journal mode back per deployment:
export OS_DATABASE_SQLITE_JOURNAL_MODE=delete…or per datasource, which outranks the env var:
new SqlDriver({
client: 'better-sqlite3',
connection: { filename: '/mnt/share/app.db' },
useNullAsDefault: true,
sqliteJournalMode: 'delete',
});Either form applies delete, so it also converts a database that already
adopted WAL back. The driver never assumes the switch worked: it reads the mode
back and, if WAL is accepted but cannot be read through (the usual network-share
symptom), reverts to delete and says so. A database that stays on a rollback
journal is slower under concurrency, never broken.
:memory: databases and the WASM SQLite driver are left on their own journal
mode. Neither is shared between processes: the WASM driver's live database sits
in the WASM heap and what reaches disk is a byte image it exports, so there is no
cross-process concurrency for WAL to buy.
Space Reclamation & Table Rotation (ADR-0057)
The SQL driver connects SQLite with auto_vacuum=INCREMENTAL and exposes
reclaimSpace() (PRAGMA incremental_vacuum), which the platform
LifecycleService calls after every sweep that deleted rows — so the database
file actually shrinks instead of pinning at its high-water mark. Objects
declaring lifecycle.storage.strategy: 'rotation' are physically
time-sharded on SQLite: writes land in the current shard, reads go through a
view under the table's name, and expired shards are reclaimed with a single
DROP TABLE. On PostgreSQL/MySQL both are no-ops — those engines manage
space with their own vacuum machinery, and rotation falls back to an
equivalent age-based reap.
Memory Driver
The in-memory driver keeps records in plain in-process objects (queried via
mingo). Data is lost when the process
exits unless persistence is explicitly requested.
It is the last-resort fallback in dev mode: objectstack dev prefers native
SQLite (better-sqlite3), falls back to the pure-JS WASM SQLite driver if the
native binary is unavailable, and only drops to the in-memory driver if WASM also
fails to load. Set OS_DATABASE_DRIVER=memory to select it explicitly.
Both ways of building the driver are ephemeral by default (#4083, #4065):
| How it is built | Persistence |
|---|---|
A declared datasource — { driver: 'memory' } in a stack/app config | Ephemeral. Nothing is written to disk unless the declaration sets config.persistence — and when it does, the destination is scoped per datasource, so two memory datasources never share one file. |
new InMemoryDriver() constructed directly | Ephemeral. Pass persistence to opt in (see below). Note the scoping above is the factory's job: two directly-constructed 'auto' drivers in one process still share the single default path. |
import { InMemoryDriver } from '@objectstack/driver-memory';
new InMemoryDriver(); // pure memory — the default
new InMemoryDriver({ persistence: 'file' }); // opt in to durabilityPersistence is opt-in
A bare new InMemoryDriver() persists nothing. Durability is requested
explicitly:
new InMemoryDriver({ persistence: 'file' }) // Node.js — .objectstack/data/memory-driver.json
new InMemoryDriver({ persistence: 'local' }) // browser — localStorage
new InMemoryDriver({ persistence: 'auto' }) // pick per environment (file / localStorage / off)'auto' chooses localStorage in a browser, a file under Node.js, and disables
persistence in serverless/edge runtimes (Vercel, Lambda, Netlify, Cloud Run, Deno
Deploy) where a local write would be silently discarded — supply a custom adapter
there instead.
Before v17 the default was 'auto', which on Node.js meant file — so a bare
new InMemoryDriver() silently wrote .objectstack/data/memory-driver.json into
the working directory and reloaded it on the next boot. If you relied on that,
pass persistence: 'auto' (or 'file') explicitly. See
#4065.
For tests, prefer in-memory SQLite — SqlDriver with
connection: { filename: ':memory:' }, or SqliteWasmDriver({ filename: ':memory:' })
when you want no native build. Both give the SQL semantics production runs on;
mingo does not enforce primary keys, uniqueness, NOT NULL or column types, so a
green run against the memory driver is weaker evidence than it looks. The
framework's own dogfood gate boots on WASM SQLite at :memory: for this reason.
The memory driver remains fine where you want no setup at all and the assertions do not depend on storage semantics — it is ephemeral by default, so one run cannot see what an earlier run left behind.
Local Environment Runtime
Local framework commands run one active environment. The business records served
at /api/v1/data/* use the driver selected by OS_DATABASE_URL and
OS_DATABASE_DRIVER.
# Run the showcase with MongoDB as the local environment DB:
OS_ENVIRONMENT_ID=env_local \
OS_DATABASE_URL=mongodb://localhost:27017/objectstack \
pnpm dev
# Pin the driver explicitly when the URL is ambiguous:
OS_DATABASE_URL=file:./.objectstack/data/app.db \
OS_DATABASE_DRIVER=sqlite \
pnpm devCloud control-plane databases and environment provisioning live in the Cloud
distribution outside this framework repo. When this runtime is pointed at Cloud,
the environment id still flows through OS_ENVIRONMENT_ID, scoped URLs, or
X-Environment-Id; mutable deployment config does not live in the compiled
artifact.
Multi-Datasource
ObjectStack supports multiple data sources. Objects can target specific datasources:
Datasources are declared as metadata descriptors ({ driver, config }) — the
map key becomes the datasource name:
import { defineStack } from '@objectstack/spec';
export default defineStack({
datasources: {
analytics: {
driver: 'postgres',
config: { url: process.env.ANALYTICS_URL },
},
cache: {
driver: 'mongodb',
config: { url: process.env.MONGO_URL },
},
},
});Then in your object definition:
export const AuditLog = ObjectSchema.create({
name: 'audit_log',
datasource: 'analytics', // Routes to the analytics database
fields: { /* ... */ },
});Read-only: grant it at the database, not in metadata
A managed datasource has no platform-level read-only gate, and this is
deliberate. Read-only for a database ObjectStack owns is a database account
privilege — GRANT SELECT — not a key on the datasource. There is no
metadata you can write that makes a managed connection read-only.
Point the datasource's config at an account that can only read:
-- PostgreSQL: a login that can read the schema and nothing else.
CREATE USER analytics_ro PASSWORD '…';
GRANT CONNECT ON DATABASE analytics TO analytics_ro;
GRANT USAGE ON SCHEMA public TO analytics_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO analytics_ro;import { defineDatasource } from '@objectstack/spec/data';
export const Analytics = defineDatasource({
name: 'analytics',
label: 'Analytics (read-only account)',
driver: 'postgres',
// The connection itself cannot write. Nothing in the app can talk past it.
config: { url: process.env.ANALYTICS_RO_URL },
active: true,
});(external.credentialsRef is a federation key — the parse rejects an
external block on a managed datasource — so a managed connection carries its
credentials in config, from the environment as above.)
Note that a read-only account also refuses DDL, so such a datasource cannot
run ObjectStack's boot-time schema sync or migrations. That is the honest
consequence of a real boundary: a database you only read is a database you do
not own the schema of. If you want ObjectStack to keep the schema in step, it
needs a writable account — or the datasource belongs on the federation path
(schemaMode: 'external'), where DDL is forbidden by design and the write gate
is enforced (see below).
Why the platform does not offer the flag
The obvious-looking alternative — a readOnly boolean on the datasource — is
the exact shape #4583
removed. datasource.capabilities.readOnly shipped for three releases, read as
a safety property, and gated nothing: no write path consulted it, so a
datasource labelled a read replica accepted inserts exactly like the primary.
The shipped CRM example called one of its datasources a "Read Replica" on the
strength of it.
Rebuilding it as a working ObjectQL check would not fix the underlying
problem, only make it harder to see. Such a gate stops writes that go through
ObjectQLEngine; it cannot stop a direct psql session, a migration, a
syncSchema() DDL statement, a background job holding its own driver handle, or
any other process on the same connection string. A boundary that holds in one
path and not the others is not a boundary — and a flag that looks like one is
worse than no flag at all, because it is trusted. The database account has no
such gap: there is no code path in ObjectStack, or anywhere else, that can write
through a connection the server will not let write
(#4584).
The one enforced write gate is federation-only
external.allowWrites: false is enforced, by
ObjectQLEngine.assertWriteAllowed before every insert/update/delete — but it
answers a question about ownership, not about connections: which side may
write to a database ObjectStack does not own. You cannot reach for it on a local
database: the parse rejects an external block whose schemaMode is managed,
and the engine check itself returns early for managed (and for a definition
that declares no schemaMode) before it ever reads allowWrites. See
Writes (double opt-in).
| What you want | What actually does it |
|---|---|
| A managed datasource that cannot be written | A database account with SELECT only. No metadata key. |
| A federated datasource that cannot be written | external: { allowWrites: false } — the default — enforced by the engine. |
| A federated datasource writable for some objects | external.allowWrites: true on the datasource and external.writable: true on each object. |
Read replicas: the platform does not route
There is no read/write splitting in ObjectStack. No query path distinguishes
a read from a write, so there is nothing to route to a replica. datasource.readReplicas
was removed in 17.0.0 (#4468)
because it described replica connections nothing ever opened.
Put the replicas behind a single endpoint and let the database tier route:
pgpool-II, ProxySQL, or an RDS / Aurora reader endpoint. Point
config at that endpoint.
This is the correct answer, not a stopgap
(#4479). The hard
parts of read/write splitting are not the replica connections — they are
deciding what counts as a read (a find inside a transaction that just issued
an update must go to the primary, or the app cannot read its own writes),
declaring the staleness a query will tolerate, and ejecting a replica that falls
behind. A proxy is a component built to do exactly that, and it does it better
than a field on a datasource could. Should the platform ever need to pick a
consistency level from business semantics, the schema shape will be decided by
that routing path — it will not be bolted on ahead of it.