Tabellen komponieren
wie CSS-Klassen.

Drizzle und Prisma haben TypeScript-Schemas praxistauglich gemacht. Voltro geht den nächsten Schritt: Jeder Querschnittsbelang, den deine Tabellen teilen — Audit-Spalten, Tenant-Isolation, Soft Delete, Row-History, AI-Auto-Embedding — ist ein komponierbarer Mixin, den du mit .with() anhängst. Eine Zeile pro Belang, kein Codegen-Schritt, vollständig typisiertes Ergebnis.

posts.entity.ts
TypeScript
// 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)

Sechs Features, die mit @voltro/database mitkommen.

Komponierbare Mixins

.with(audit(), tenant(), softDelete()) — drei Zeilen Deklaration, fünfzehn Spalten + Constraints ergänzt. Mixins kennen ihre Abhängigkeiten; softDelete() erfordert transitiv audit(), und der Resolver dedupliziert, wenn beide es erreichen.

Branded TypeIDs standardmäßig

Jedes id() liefert einen brand-typisierten Wert (PostId, UserId, OrgId). Sortierbar nach Erstellzeit, URL-sicher, Präfix aus dem Tabellennamen abgeleitet. Tabellenübergreifende Zuweisung schlägt beim Kompilieren fehl.

Partielle + Expression- + FTS-Indizes

index([cols]) für einfach, index('name', cols, where) für partiell, expressionIndex('name', [expr]) für berechnet, fullTextIndex für FTS. Der Boot-Time-Audit meldet Indizes mit redundantem Präfix, die Schreibkosten verursachen.

Fünf Dialekte, ein Schema

Postgres / MariaDB / MySQL / MSSQL / SQLite — beim Boot über die DB_DIALECT-Env umgeschaltet. Die DSL emittiert das dialektspezifische DDL automatisch; du schreibst das Schema einmal und lässt es überall laufen.

Relationen + Eager Loading

Deklariere 1:1-, 1:N- und N:M-Relationen in einer *.relations.ts-Datei. Ein .with(...)-Aufruf lädt den Baum in EINEM Roundtrip per dialekt-nativer JSON-Aggregation. Pro-Eltern-Limits über korrelierte Subqueries.

Plugin-definierte Mixins

defineMixin(...) liefert Drittanbieter-Mixins (vectorEmbedding, billing.versioned usw.). Sie komponieren neben den eingebauten über dieselbe .with()-Kette.

Die Querschnittsbelange sind die Bug-Fläche.

Die Spalten, die jede Tabelle hat — createdAt, updatedAt, createdBy, deletedAt, tenantId — sind die Spalten, die zu den Bugs deines zweiten Jahres werden, sobald du sie vergisst oder inkonsistent implementierst. Voltro standardisiert sie als Mixins, damit die Implementierung überall korrekt ist oder nirgends — nie an drei Stellen halb richtig.

Die dialektübergreifende Wette:

Voltros DSL zielt auf fünf SQL-Dialekte — Postgres, MariaDB, MySQL, MSSQL, SQLite. Ein Schema, ein Query-Builder, ein Satz Mixins. Ein Wechsel von DB_DIALECT wechselt den DDL-Emitter, den JSON-agg-Compiler, die FTS-Index-Syntax. Postgres bleibt das empfohlene Ziel für native, LISTEN/NOTIFY-getriebene Reaktivität; die anderen sind für Enterprise-Beschaffung und Embedded-Workloads da.

Die Entscheidung lautet "du schreibst Voltro" — nicht "du schreibst Postgres-spezifisches Voltro". Die DSL verbirgt jede Dialekt-Abweichung; EXPLAIN auf deiner heißesten Query bleibt portabel.

Eine Deklaration, vier Dinge daraus abgeleitet.

database/schema.ts
ts
// 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())

Keines davon ist ein Build-Artefakt, das du synchron hältst. Die Typen kommen aus der Deklaration selbst, die Migration ist ein Diff gegen die Live-Datenbank, und die Admin-Oberfläche liest dieselben Spalten — ein umbenanntes Feld bewegt also alle vier gleichzeitig, und was sich nicht mitbewegen kann, bricht den Build statt zu driften.

Die Schema-DSL im Detail.

Warum ein Schema in TypeScript statt in SQL deklarieren?

Weil das Schema die eine Beschreibung ist, die alles andere braucht. Aus einer TypeScript-Deklaration leitet das Framework die Zeilentypen deiner Handler ab, die Wire-Typen, die der Client dekodiert, den Migrationsplan, die Validatoren, die Admin-UI und die Seed-Form — alles aus einer Quelle, die sich nicht selbst widersprechen kann.

Als SQL geschrieben steht dieselbe Information nur der Datenbank zur Verfügung. Alles darüber — Typen, Validierung, Formulare, Migrationen — wird zur handgepflegten zweiten Kopie, und die Kopien driften beim ersten Mal, wenn es jemand eilig hat.

Es heißt außerdem, dass eine Spalte mehr tragen kann als ihren Speichertyp. Sie als verschlüsselt, server-only oder CRDT-verwaltet zu markieren ist eine Eigenschaft der Deklaration — das Verhalten folgt der Spalte überall hin, statt davon abzuhängen, dass jede Aufrufstelle daran denkt.

Wie funktionieren Relationen ohne ORM?

Eine `reference()`-Spalte deklariert einen echten Fremdschlüssel mit seinem On-Delete-Verhalten und vernünftigen Index-Defaults — und du zeigst auf den Tabellen-WERT statt auf einen Namens-String. Genau das lässt eine Umbenennung mitgehen: die Constraint wandert mit der Tabelle, eine umbenannte Tabelle lässt also keine baumelnden Text-Ids zurück.

Lesevorgänge sind explizit. Du fragst die verwandten Zeilen an, die du willst, statt einen lazy geladenen Objektgraphen zu bekommen, der Queries feuert, sobald eine Property berührt wird — das Muster, das ein N+1-Problem unsichtbar macht, bis es ein Produktionsvorfall ist.

Relationen überqueren auch Plugin-Grenzen. Ein Plugin exportiert seine Table-Handles, deine Spalte kann eine Plugin-Zeile also mit demselben echten Schlüssel referenzieren — oder, wenn du die Schemas bewusst entkoppeln willst, eine referenzfreie typisierte Id mit deklarierter Orphan-Policy nutzen, die das Framework auf dem Change-Kanal durchsetzt.

Was sind Mixins, und warum keine Vererbung?

Ein Mixin ergänzt einen querschnittlichen Satz Spalten und das zugehörige Verhalten: Mandantentrennung ergänzt eine Tenant-Spalte plus Lese-Scoping und Schreib-Befüllung; Soft-Delete ergänzt Löschspalten und macht aus einem Delete ein Update; Audit ergänzt Zuschreibung. Du komponierst sie auf eine Tabelle, statt von einer Basis zu erben.

Komposition zählt, weil diese Belange unabhängig sind. Eine Tabelle kann mandantengescoped und soft-gelöscht und auditiert sein, in jeder Kombination — ohne Klassenhierarchie, die eine Reihenfolge erzwingt, und ohne Basistabelle, die jede jemals gebrauchte Spalte ansammelt.

Das Verhalten ist nicht beratend. Tenant-Scoping wird von der Runtime ins Query-Prädikat gemerged, unterhalb jedes Plugin-Interceptors — Anwendungscode, der es vergisst, kann es also nicht umgehen. Das ist der Unterschied zwischen einer Konvention und einer Grenze.

Wie wird aus dem Schema eine Migration?

Durch Diffen. Dein Schema ist der Sollzustand; der Planer liest den Live-Katalog, berechnet die Operationen, die die Lücke schließen, klassifiziert jede und verweigert destruktive ohne Bestätigung. Du prüfst einen Plan, statt Migrationsdateien zu schreiben und zu ordnen.

Umbenennungen sind der Fall, den ein Differ nicht erraten kann — eine Umbenennung und ein Drop-plus-Create sehen im Katalog identisch aus, und bei einer Tabelle ist der Unterschied die Daten. Also deklarierst du sie: `.renamedFrom()` an Tabelle oder Spalte sagt dem Planer, dass es ein Verschieben ist.

Dasselbe Schema erzeugt auch die Tabellen des Frameworks. Plugin- und Framework-Tabellen reiten auf demselben Differ, auf jedem Dialekt — deshalb braucht das Aktivieren eines Plugins keine handgeschriebene Migration, und deshalb wendet ein Framework-Upgrade seine eigenen Tabellenänderungen genauso an wie deine.

Was eine Spalte deklarieren kann.

Spalten- und Tabellendeklarationen jenseits des Speichertyps
DeklarationWas sie bewirkt
reference()Ein echter Fremdschlüssel mit On-Delete-Verhalten, auf den Tabellenwert gezeigt — Umbenennungen gehen mit.
.encrypted()Verschlüsselung at rest — der Store verschlüsselt beim Schreiben und entschlüsselt beim Lesen, auch über den Store eines Plugins.
.serverOnly()Strukturell aus jeder Wire-Antwort entfernt — Exposition ist damit eine andere Entscheidung als Verschlüsselung.
crdtText() / crdtDoc()Ein konfliktfreies Textfeld, beim Schreiben server-autoritativ gemerged — für kollaboratives Editieren.
MixinsMandant, Soft-Delete, Audit, Deaktivierung — Spalten plus erzwungenes Verhalten, komponiert statt geerbt.
.renamedFrom()Deklariert eine Umbenennung, damit der Differ verschiebt statt zu droppen und neu anzulegen.

Häufige Fragen

Ist das ein ORM?

Nein. Es gibt keinen lazy Objektgraphen und keine Identity-Map — du deklarierst Tabellen und fragst sie über einen Builder ab, der zu SQL kompiliert. Verwandte Zeilen werden geholt, weil du sie angefragt hast; genau das verhindert, dass ein N+1-Problem sich hinter Property-Zugriffen versteckt.

Kann ich rohes SQL nutzen, wenn ich es brauche?

Ja, und das Framework fährt handgeschriebenes SQL über ein Paritäts-Harness gegen jeden unterstützten Dialekt — ein Statement, das nur auf einer Engine funktioniert, scheitert also in deiner Testsuite statt in Produktion auf einer anderen.

Kann ich eine bestehende Datenbank übernehmen?

Ja. Bestehende Tabellen lassen sich ins Schema holen und von dort verwalten, und der Planer arbeitet mit dem, was der Live-Katalog tatsächlich enthält, statt mit einer angenommenen Historie.

Was hindert eine sensible Spalte daran, in den Browser zu gelangen?

Sie als server-only zu markieren — das entfernt sie strukturell aus jeder Wire-Antwort statt pro Query. Verschlüsselung at rest ist bewusst ein eigener Marker: die beiden zu vermengen ist der Weg, auf dem ein Feld in der Datenbank verschlüsselt und trotzdem an den Client gesendet wird.

Brauchen Schema-Änderungen Downtime?

Ab einer Zeilenschwelle werden Index- und Spaltenänderungen als Online-Operationen geplant statt als tabellensperrende, und die Schwelle ist konfigurierbar. Der Plan sagt dir vor dem Anwenden, in welche Klasse jede Operation fällt.

Das Schema speist den Rest der Runtime.

Öffne das Framework. Schau es dir selbst an.

Jede Primitive auf dieser Seite ist heute im Framework. Klone den Starter, lass `voltro dev` laufen, in zwei Minuten ist es auf dem Bildschirm.