SQL dialects
Six SQL backends, one schema DSL. Decision matrix, configuration, boot-log shape, and the cross-dialect feature parity table the framework hides for you.
Voltro runs on six SQL backends. The application code — schema, queries, workflows, subscriptions, the reactive engine — is written ONCE and compiles down to the dialect-native idiom at runtime. Selecting a dialect is a single environment variable.
postgres · mysql 8+ · mariadb 10.6+ · mssql 2019+ · sqlite 3.38+ · turso (beta)This index covers the cross-cutting bits: decision matrix, configuration, boot log, and the parity table. Each dialect has its own page below for the runtime-specific surface (driver quirks, framework workarounds, performance notes).
Hosting providers — running on a managed database (Supabase, Neon, Vercel Postgres, Railway, Render, Fly.io, AWS RDS, DigitalOcean, Timescale, CockroachDB, PlanetScale, Azure SQL)? See Database hosting for per-provider connection strings, pooling, and — above all — how to enable CDC on each one.
Multi-replica reactivity — behind a load balancer, how does a write on one replica reach clients on another? Postgres / MariaDB fan out natively; for every other dialect,
@voltro/plugin-broadcast(Redis / NATS) closes the gap. See Multi-replica reactivity.
- Postgres — the reference. LISTEN/NOTIFY CDC, advisory locks, streaming replication.
- MySQL 8+ — INSERT-then-SELECT instead of RETURNING,
LAST_INSERT_ID()recovery for AUTO_INCREMENT ids, binlog CDC (cross-instance reactivity, at parity with mariadb), mysql2 driver quirks. - MariaDB 10.6+ — same wire driver as MySQL but UPDATE-RETURNING gap + ROW_NUMBER eager-load + binlog CDC (cross-instance reactivity, one shared reader with mysql).
- MSSQL 2019+ — TOP / OFFSET-FETCH instead of LIMIT, single-statement OUTPUT INSERTED/DELETED, native MERGE upsert, Change Tracking CDC, four upstream cluster patches.
- SQLite 3.38+ — single-process, in-memory workflow runner, in-process CDC bus.
- Turso (beta) — the Rust rewrite of SQLite with MVCC concurrent writes (
BEGIN CONCURRENTover a connection pool). SQLite-compatible; single-node; no generated columns / FTS; no Alpine/musl prebuilts.
Decision matrix
Pick a dialect by intersection of need + constraint.
| Need | Pick | Why |
|---|---|---|
| Sub-second reactive UIs at scale | postgres / mariadb / mssql | Native LISTEN/NOTIFY (postgres), ROW-format binlog CDC (mariadb), or Change Tracking (mssql, polled) deliver cross-instance change events with zero extra infra. mysql / sqlite have no native fan-out — add @voltro/plugin-broadcast (Redis / NATS) for cross-instance reactivity, or they degrade to single-instance. |
| Multi-instance horizontal scaling | postgres / mysql 8+ / mariadb 10.6+ / mssql | Cluster workflow runners need a coordinatable lock primitive (advisory locks, GET_LOCK, sp_getapplock). SQLite is single-process by design. |
| MySQL-shop procurement | mysql 8.0.18+ | Mysql 8.0.0–8.0.17 rejects parameterized LIMIT ? — framework auto-inlines integers but other tooling may break. |
| MariaDB-shop procurement | mariadb 10.6+ | Same wire driver as mysql. The framework's dialect dispatcher branches on a few quirks (UPDATE … RETURNING, JSON-agg LATERAL gap). |
| Enterprise SQL Server | mssql 2019+ | 2016/2017 work but lose query-optimizer shortcuts for OFFSET … FETCH NEXT. |
| Single-binary distribution (dev tool, embedded device, CLI) | sqlite | No cluster scale-out: workflows run with runnerStorage: 'memory'. Durable replay within ONE process. |
| Single node that needs CONCURRENT writes (beta) | turso | The Rust SQLite rewrite: a connection pool + MVCC BEGIN CONCURRENT runs concurrent writers instead of sqlite's single-writer lock. Beta; no generated columns / FTS; glibc-only binaries. |
| Zero infrastructure for smoke tests | store: 'memory' |
In-process map. Resets on restart. |
Configuration
Two sources, in priority order: env > app.config.ts.
DB_DIALECT=postgres # default. Maps to @effect/sql-pg.
DB_DIALECT=mysql # @effect/sql-mysql2, variant='mysql'.
DB_DIALECT=mariadb # @effect/sql-mysql2, variant='mariadb'.
DB_DIALECT=mssql # @effect/sql-mssql.
DB_DIALECT=sqlite # @effect/sql-sqlite-node. DB_URL=file:./db.sqlite or :memory:
DB_DIALECT=turso # @voltro/sql-turso (Rust rewrite, MVCC). DB_URL=file:./db.turso or :memory:; DB_MAX_CONNECTIONS = pool size. BETA.
DB_DIALECT=memory # in-process DataStore, no SQL.Connection details come from the same DB_* vars for every dialect — there are no per-dialect MYSQL_* / MARIADB_* / MSSQL_* vars:
# One URL…
DB_URL=mysql://app:app@db.internal:3306/app
# …or discrete fields (any dialect)
DB_HOST=… DB_PORT=… DB_USER=… DB_PASSWORD=… DB_DATABASE=…
# Pool size (any dialect)
DB_MAX_CONNECTIONS=10
# SQLite / Turso take a file URL
DB_URL=file:./db.sqlite # or `:memory:` for ephemeralPG_HOST / PG_PORT / PG_USER / PG_PASSWORD / PG_DATABASE / PG_MAX_CONNECTIONS are accepted as postgres-flavoured aliases for the same fields; DB_SCHEMA (postgres search_path) and PG_SSL are postgres-only knobs.
The app.config.ts store: field stays as the dev-friendly shortcut (store: 'postgres', store: 'memory') — env always wins.
Local development — bring up all five
The framework ships a docker-compose at voltro/test/docker-compose.yml that brings up postgres + mysql + mariadb + mssql on distinct ports so per-dialect tests can run side-by-side and the dev fixture never clashes with your starter postgres on :5432:
cd voltro
docker compose -f test/docker-compose.yml up -d --wait| Service | Host port |
|---|---|
| postgres-test | :55432 |
| mysql-test | :33060 |
| mariadb-test | :33061 |
| mssql-test | :11433 (database voltro_test) |
SQLite needs no container — point DB_URL=:memory: for an ephemeral in-process DB.
Boot log
voltro dev and voltro start print a one-line summary of the resolved dialect + its enabled capabilities so voltro logs --tail 50 answers "what's running" without grepping source:
[voltro:dev] sql dialect resolved: postgres — CDC: LISTEN/NOTIFY, RETURNING: native
[voltro:dev] read replicas: 0 configured (DB_REPLICA_URLS empty) — all queries → primary
[voltro:dev] workflow engine: cluster-sql, dialect=postgresFor sqlite:
[voltro:dev] sql dialect resolved: sqlite — CDC: in-process bus, RETURNING: native
[voltro:dev] workflow engine: in-process durable (storage=memory) — no horizontal scale-outFeature parity matrix
What the framework hides for you vs what's worth knowing. Per-dialect pages drill into each row.
| Capability | postgres | mysql 8+ | mariadb 10.6+ | mssql 2019+ | sqlite 3.38+ | turso (beta) |
|---|---|---|---|---|---|---|
| CDC (change feed) | LISTEN/NOTIFY | binlog CDC (ROW) | binlog CDC (ROW) | Change Tracking (polled) | in-process bus | in-process bus |
| Concurrent writers (one node) | yes (MVCC) | yes (InnoDB) | yes (InnoDB) | yes | no — single-writer lock | yes — MVCC BEGIN CONCURRENT |
| Workflow cluster runners | advisory_lock | GET_LOCK | GET_LOCK | sp_getapplock | n/a — single process | n/a — single process |
| Read-replica routing | streaming repl | GTID repl | GTID repl | Always-On AG | n/a — single writer | n/a — single node |
RETURNING * on INSERT |
yes | no (INSERT then SELECT) | yes (10.5+) | OUTPUT INSERTED.* | yes | yes |
RETURNING * on UPDATE |
yes | no | NO (any version) | OUTPUT INSERTED.* | yes | yes |
RETURNING * on DELETE |
yes | no | yes (10.0+) | OUTPUT DELETED.* | yes | yes |
Parameterized LIMIT ? |
yes | no — integer-literal inlined | yes | no — integer-literal inlined | yes | yes |
LIMIT N OFFSET N syntax |
yes | yes | yes | no — OFFSET … ROWS FETCH NEXT … ROWS ONLY |
yes | yes |
| DEFAULT on TEXT columns | yes | NO — auto-uses VARCHAR(255) | yes | yes (NVARCHAR(MAX)) | yes | yes |
| Native JSON column type | JSONB | JSON | JSON | NVARCHAR(MAX) | TEXT | TEXT |
| JSON columns returned as objects | yes | yes | yes | no — strings — framework auto-parses | no — strings — framework auto-parses | no — strings — framework auto-parses |
| Booleans | proper booleans | 0/1 (TINYINT) | 0/1 | BIT (proper bool) | 0/1 (INTEGER) | 0/1 (INTEGER) |
| Auto-incrementing numeric id | BIGSERIAL | AUTO_INCREMENT | AUTO_INCREMENT | IDENTITY | INTEGER PK AUTOINCREMENT | INTEGER PK — no AUTOINCREMENT |
| Identifier quoting | "name" |
`name` |
`name` |
[name] |
"name" |
"name" |
Dialect-aware code
You almost never need to write dialect-branching SQL — the schema DSL is portable. When you reach for sql.unsafe() or hand-written queries, branch via sql.onDialectOrElse:
import { SqlClient } from '@effect/sql'
const fetchTopN = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
return yield* sql.onDialectOrElse({
pg: () => sql<{ id: string }>`SELECT id FROM users LIMIT 10`,
mysql: () => sql<{ id: string }>`SELECT id FROM users LIMIT 10`, // mariadb rides this branch
mssql: () => sql<{ id: string }>`SELECT TOP 10 id FROM users`,
orElse: () => sql<{ id: string }>`SELECT id FROM users LIMIT 10`,
})
})The orElse branch catches sqlite + future dialects.
Migration between dialects
Switching DB_DIALECT is a deploy-time decision, not a runtime one. Migrating data from postgres to mysql (or vice versa) is a separate problem the framework doesn't solve — use pg_dump / mysqldump + a transform script. The schema you wrote against the framework's DSL re-applies cleanly on the new dialect (voltro migrate re-emits in the new idiom); production data does not.
Where the dialect lives in the codebase
voltro/packages/database/src/dialect.ts—SqlDialectinterfacevoltro/packages/database/src/jsonEagerCompiler.ts— per-dialect JSON-agg emissionvoltro/packages/database/src/sqlCompiler.ts— dialect-aware SELECT compile (TOP / LIMIT / FETCH NEXT)voltro/packages/database/src/migrate.ts— DDL emitter, identifier quoting, column-type mappingvoltro/packages/database/src/rowDecoder.ts— read-path coercion for booleans / JSON / datesvoltro/packages/sql-postgres/sql-mysql/sql-mssql/sql-sqlite— per-dialect store implementations + retry filters + CDC sourcesvoltro/packages/cli/src/dev.tsbuildStore()— dialect resolution from env
Each per-dialect package exports a SqlDialect value (postgresDialect, mysqlDialect, mariadbDialect, mssqlDialect, sqliteDialect). The CLI dynamically imports just the one the deployment needs — postgres-only deploys don't pull mysql / mssql into the bundle.