Tables compose
like CSS classes.
Drizzle and Prisma made TypeScript schemas viable. Voltro takes the next step: every cross-cutting concern your tables share — audit columns, tenant isolation, soft delete, row history, AI auto-embedding — is a composable mixin you append with .with(). One line per concern, no codegen step, fully typed result.
// database/posts.entity.ts
import { boolean, id, table, text, timestamp } from '@voltro/database'
import { audit } from '@voltro/plugin-audit'
import { tenant } from '@voltro/plugin-multitenancy'
import { softDelete } from '@voltro/plugin-soft-delete'
export const posts = table('posts', {
id: id(), // typed PostId (TypeID-branded)
title: text(),
body: text(),
status: text().oneOf(['draft', 'published']),
pinned: boolean().default(false),
publishedAt: timestamp().nullable(),
})
.with(audit(), tenant(), softDelete()) // ← composable mixins
.index('byTenantStatus', ['tenantId', 'status'])
.index('byPublished', ['publishedAt'], { where: `"status" = 'published'` })
.reactive()
// You get for free:
// - createdAt, updatedAt, createdBy, updatedBy → actors (audit)
// - tenantId reference + auto-scope reads + auto-fill (tenant)
// - deletedAt, deletedBy + delete()-turns-into-UPDATE (softDelete)
// - Reactive matcher hookup (.reactive)
// - Partial index on published posts only (.index)
// - Branded PostId (id)Six features that ship with @voltro/database.
Composable mixins
.with(audit(), tenant(), softDelete()) — three lines of declaration, fifteen columns + constraints added. Mixins know their dependencies; softDelete() transitively requires audit(), and the resolver dedupes if both reach it.
Branded TypeIDs by default
Every id() returns a brand-typed value (PostId, UserId, OrgId). Sortable by creation time, URL-safe, prefix-derived from the table name. Cross-table assignment fails compilation.
Partial + expression + FTS indexes
index([cols]) for simple, index('name', cols, where) for partial, expressionIndex('name', [expr]) for computed, fullTextIndex for FTS. Boot-time audit flags redundant-prefix indexes that add write cost.
Five dialects, one schema
Postgres / MariaDB / MySQL / MSSQL / SQLite — switched at boot via DB_DIALECT env. The DSL emits per-dialect DDL automatically; you write the schema once and run it anywhere.
Relations + eager loading
Declare 1:1, 1:N, and N:M relations in a *.relations.ts file. A .with(...) call loads the tree in ONE roundtrip via dialect-native JSON aggregation. Per-parent limits via correlated subqueries.
Plugin-authored mixins
defineMixin(...) ships third-party mixins (vectorEmbedding, billing.versioned, etc.). They compose alongside the built-ins through the same .with() chain.
The cross-cutting concerns are the bug surface.
The columns every table has — createdAt, updatedAt, createdBy, deletedAt, tenantId — are the columns that, when you forget them or implement them inconsistently, become the bugs that show up in your second year. Voltro standardises them as mixins so the implementation is correct everywhere or nowhere — never half right in three places.
The cross-dialect bet:
Voltro's DSL targets five SQL dialects — Postgres, MariaDB, MySQL, MSSQL, SQLite. One schema, one query builder, one set of mixins. Switching DB_DIALECT switches the DDL emitter, the JSON-agg compiler, the FTS index syntax. Postgres remains the recommended target for native LISTEN/NOTIFY-driven reactivity; the others are there for enterprise procurement and embedded workloads.
The decision is "you write Voltro" — not "you write Postgres-specific Voltro". The DSL hides every dialect divergence; EXPLAIN on your hottest query stays portable.
One declaration, four things derived from it.
// database/schema.ts
export const invoices = table('invoices', {
number: text().unique(),
amount: integer(),
currency: text().default('EUR'),
paidAt: timestamp().nullable(),
notes: text().nullable().sensitive(), // masked in exports
}).with(tenant(), audit())None of these is a build artefact you keep in sync. The types come from the declaration itself, the migration is a diff against the live database, and the admin surface reads the same columns — so renaming a field moves all four at once, and the ones that cannot move fail the build instead of drifting.
The schema DSL, in depth.
Why declare a schema in TypeScript instead of SQL?
Because the schema is the one description everything else needs. From a TypeScript declaration the framework derives the row types your handlers see, the wire types the client decodes, the migration plan, the validators, the admin UI and the seed shape — all from one source that cannot disagree with itself.
Written as SQL, that same information is available only to the database. Everything above it — types, validation, forms, migrations — becomes a hand-maintained second copy, and the copies drift the first time somebody is in a hurry.
It also means a column can carry more than its storage type. Marking a column as encrypted, server-only or CRDT-managed is a property of the declaration, so the behaviour follows the column everywhere it is used rather than depending on each call site remembering.
How do relations work without an ORM?
A `reference()` column declares a real foreign key with its on-delete behaviour and sensible index defaults, and you point it at the table VALUE rather than at a name string. That is what lets a rename be followed: the constraint travels with the table, so a renamed table does not leave dangling text ids behind.
Reads are explicit. You ask for the related rows you want rather than getting a lazily-loaded object graph that fires queries when a property is touched — the pattern that makes an N+1 problem invisible until it is a production incident.
Relations also cross plugin boundaries. A plugin exports its table handles, so your column can reference a plugin's row with the same real key, or — when you deliberately want the schemas decoupled — use a reference-free typed id with a declared orphan policy the framework enforces on the change channel.
What are mixins, and why not inheritance?
A mixin adds a cross-cutting set of columns and the behaviour that goes with them: tenancy adds a tenant column plus read scoping and write filling; soft-delete adds deletion columns and turns a delete into an update; audit adds attribution. You compose them onto a table rather than inheriting from a base.
Composition matters because these concerns are independent. A table can be tenant-scoped and soft-deleted and audited, in any combination, without a class hierarchy that forces an order or a base table that accumulates every column anybody ever needed.
The behaviour is not advisory. Tenant scoping is merged into the query predicate by the runtime, beneath any plugin interceptor, so it cannot be bypassed by application code that forgets — which is the difference between a convention and a boundary.
How does the schema become a migration?
By diffing. Your schema is the desired state; the planner reads the live catalog and computes the operations that close the gap, classifies each one, and refuses destructive ones unless confirmed. You review a plan rather than write and order migration files.
Renames are the case a differ cannot infer — a rename and a drop-plus-create look identical in the catalog, and for a table the difference is the data. So you declare them: `.renamedFrom()` on the table or column tells the planner this is a move.
The same schema also produces the framework's own tables. Plugin tables and framework tables ride the identical differ on every dialect, which is why enabling a plugin does not require a hand-written migration and why a framework upgrade applies its own table changes the same way yours are applied.
What a column can declare.
| Declaration | What it does |
|---|---|
| reference() | A real foreign key with on-delete behaviour, pointed at the table value so renames are followed. |
| .encrypted() | Encryption at rest — the store encrypts on write and decrypts on read, including through a plugin's store. |
| .serverOnly() | Stripped from every wire response structurally, so exposure is a separate decision from encryption. |
| crdtText() / crdtDoc() | A conflict-free text field merged server-authoritatively on write, for collaborative editing. |
| Mixins | Tenancy, soft-delete, audit, deactivation — columns plus enforced behaviour, composed not inherited. |
| .renamedFrom() | Declares a rename so the differ moves the table or column instead of dropping and recreating it. |
Frequently asked questions
Is this an ORM?
No. There is no lazy object graph and no identity map — you declare tables and query them through a builder that compiles to SQL. Related rows are fetched because you asked for them, which is what keeps an N+1 problem from hiding behind property access.
Can I use raw SQL when I need it?
Yes, and the framework runs hand-written SQL against every supported dialect through a parity harness so a statement that only works on one engine fails in your test suite rather than in production on another.
Can I adopt an existing database?
Yes. Existing tables can be brought into the schema and managed from there, and the planner works from what the live catalog actually contains rather than from an assumed history.
What stops a sensitive column reaching the browser?
Marking it server-only, which strips it from every wire response structurally rather than per query. Encryption at rest is a separate marker on purpose — conflating the two is how a field ends up encrypted in the database and still sent to the client.
Do schema changes require downtime?
Above a row threshold, index and column changes are planned as online, non-blocking operations rather than table-locking ones, and the threshold is configurable. The plan tells you which class each operation falls into before you apply it.
The schema feeds the rest of the runtime.
Multi-tenancy
tenant() is one of the core mixins. Adds the column, scopes reads, auto-fills writes, blocks cross-tenant access.
Reactive queries
.reactive() opts a table into the matcher engine. Without it, the table's changes don't wake subscriptions.
Type safety
Branded TypeIDs, typed JSON columns, oneOf() literal-union narrowing — all flow through the query builder into your React props.
Open the framework. See it for yourself.
Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.