EmDash nutzt ein datenbankzentriertes Inhaltsmodell, bei dem Schemadefinitionen in der Datenbank liegen, nicht im Code. Das ist eine zentrale Designentscheidung und ermöglicht Schemaänderungen zur Laufzeit sowie ein Setup, das auch ohne Entwicklerrolle funktioniert.
Schema als Daten
Klassische CMS wie Strapi oder Keystatic verlangen, dass du das Schema im Code definierst:
// Herkömmlicher Ansatz – Schema im Code
const posts = collection({
fields: {
title: text({ required: true }),
content: richText(),
},
});
EmDash speichert dieselben Informationen in Datenbanktabellen:
-- Tabelle _emdash_collections
INSERT INTO _emdash_collections (slug, label)
VALUES ('posts', 'Blog Posts');
-- Tabelle _emdash_fields
INSERT INTO _emdash_fields (collection_id, slug, type, required)
VALUES
('coll_abc', 'title', 'string', true),
('coll_abc', 'content', 'portableText', false);
Beide Ansätze beschreiben dieselbe Inhaltsstruktur. Der Unterschied ist, wo diese Struktur liegt und wie sie geändert werden kann.
Warum datenbankzentriert?
Änderung zur Laufzeit
Inhaltstypen ohne Codeänderung oder Neu-Build anlegen und bearbeiten. Ohne Entwicklerrolle kann das Datenmodell über die Admin-Oberfläche gestaltet werden.
Echte SQL-Spalten
Anders als WordPress’ EAV-Modell (Entity-Attribute-Value) bekommt jedes Feld eine echte Spalte. Sinnvolle Indizes, Fremdschlüssel und Abfrageoptimierung.
Selbsterklärend
Datenbank-Tools können das Schema direkt einsehen. Kein Parsen von Code nötig, um das Datenmodell zu verstehen.
Migrationspfad
Schema als JSON exportieren für Versionskontrolle. Schema in neuen Umgebungen importieren.
Schema-Tabellen
Zwei Systemtabellen definieren deine Inhaltsstruktur:
Tabelle Collections
CREATE TABLE _emdash_collections (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL, -- "posts", "products"
label TEXT NOT NULL, -- "Blog Posts"
label_singular TEXT, -- "Post"
description TEXT,
icon TEXT, -- Lucide-Icon-Name
supports JSON, -- ["drafts", "revisions", "preview"]
source TEXT, -- wie angelegt
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
Das Feld source zeigt, wie die Collection erstellt wurde:
| Quelle | Beschreibung |
|---|---|
manual | Über die Admin-UI angelegt |
template:blog | Aus Seed-Datei einer Vorlage |
import:wordpress | Aus WordPress importiert |
discovered | Automatisch aus vorhandenen Daten |
Tabelle Fields
CREATE TABLE _emdash_fields (
id TEXT PRIMARY KEY,
collection_id TEXT REFERENCES _emdash_collections(id),
slug TEXT NOT NULL, -- Spaltenname: "title", "price"
label TEXT NOT NULL, -- Anzeige-Label
type TEXT NOT NULL, -- Feldtyp
column_type TEXT NOT NULL, -- SQLite-Typ: TEXT, REAL, INTEGER, JSON
required INTEGER DEFAULT 0,
unique_field INTEGER DEFAULT 0,
default_value TEXT, -- Standardwert als JSON
validation JSON, -- Validierungsregeln
widget TEXT, -- ID eines Custom-Widgets
options JSON, -- Widget-Optionen
sort_order INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, slug)
);
Inhaltstabellen
Jede Collection erhält eine eigene Tabelle mit dem Präfix ec_. Legst du eine Collection „products“ mit Feldern Titel und Preis an:
CREATE TABLE ec_products (
-- Systemspalten (immer vorhanden)
id TEXT PRIMARY KEY,
slug TEXT UNIQUE,
status TEXT DEFAULT 'draft',
author_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
published_at TEXT,
deleted_at TEXT, -- Soft Delete
version INTEGER DEFAULT 1, -- Optimistic Locking
-- Inhaltsspalten (aus Felddefinitionen)
title TEXT NOT NULL,
price REAL
);
Schemaänderungen zur Laufzeit
Wenn du über die Admin-UI ein Feld hinzufügst, führt EmDash Folgendes aus:
- Eintrag in
_emdash_fieldseinfügen 2.ALTER TABLE ec_<collection> ADD COLUMN <name> <typ>ausführen 3. Zod-Schema für die Validierung neu erzeugen
SQLite unterstützt diese ALTER TABLE-Operationen zur Laufzeit:
| Vorgang | Unterstützt |
|---|---|
| Spalte hinzufügen | Ja |
| Spalte umbenennen | Ja |
| Spalte löschen | Ja (SQLite 3.35+) |
| Spaltentyp ändern | Nein (Tabellen-Neuaufbau nötig) |
Bei Typänderungen baut EmDash die Tabelle transparent neu auf: neue Tabelle anlegen → Daten kopieren → alte Tabelle löschen → neue umbenennen.
Trennung Schema und Inhalt
EmDash hält eine klare Trennung:
| Aspekt | Ort | Tabellen |
|---|---|---|
| Schema | Systemtabellen | _emdash_collections, _emdash_fields |
| Inhalt | Tabellen pro Collection | ec_posts, ec_products, usw. |
| Medien | Eigene Tabelle + Speicher | Tabelle media + R2/S3 |
| Einstellungen | Optionen-Tabelle | options mit Präfix site: |
Das bedeutet:
- Schema lässt sich ohne Inhalt exportieren
- Inhalt kann zwischen Schemas migriert werden
- Systemtabellen bleiben frei von Nutzerdaten
Validierung zur Laufzeit
EmDash erzeugt beim Start Zod-Schemas aus den Felddefinitionen in der Datenbank:
// Vereinfachtes Beispiel
function buildSchema(fields: Field[]): ZodSchema {
const shape: Record<string, ZodType> = {};
for (const field of fields) {
let zodType = fieldTypeToZod(field.type);
if (field.required) {
zodType = zodType.required();
}
if (field.validation?.min !== undefined) {
zodType = zodType.min(field.validation.min);
}
shape[field.slug] = zodType;
}
return z.object(shape);
}
Inhalte werden bei jedem Anlegen und Aktualisieren gegen diese Laufzeit-Schemas validiert.
TypeScript-Anbindung
TypeScript-Typen aus dem Datenbankschema erzeugen:
# Schema aus der Datenbank holen, Typen erzeugen
npx emdash types
Das erzeugt .emdash/types.ts:
// .emdash/types.ts (generiert)
export interface Post {
title: string;
content: PortableTextBlock[];
excerpt?: string;
featuredImage?: string;
}
export interface Product {
title: string;
price: number;
quantity: number;
}
// Überladungen für Abfragefunktionen
declare module "emdash" {
export function getEmDashCollection(
type: "posts",
): Promise<{ entries: ContentEntry<Post>[]; error?: Error }>;
export function getEmDashEntry(
type: "products",
id: string,
): Promise<{ entry: ContentEntry<Product> | null; error?: Error; isPreview: boolean }>;
}
Workflow Entwickler vs. ohne Entwicklerrolle
Entwickler können die CLI nutzen:
# Schema holen, Typen erzeugen
npx emdash types
# Schema als JSON exportieren
npx emdash export-seed > seed.json
Ohne Entwicklerrolle ausschließlich die Admin-UI:
- Inhaltstypen im Admin öffnen
- Collection hinzufügen klicken
- Felder im visuellen Builder definieren
- Sofort mit Inhalten starten
Beide Wege ändern dieselben zugrunde liegenden Datenbanktabellen.
Seed-Dateien
Vorlagen und Exporte nutzen JSON-Seed-Dateien für portable Schemadefinitionen:
{
"version": "1",
"collections": [
{
"slug": "posts",
"label": "Blog Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "preview"],
"fields": [
{ "slug": "title", "type": "string", "required": true },
{ "slug": "content", "type": "portableText" },
{ "slug": "featuredImage", "type": "image" }
]
}
],
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
"menus": [{ "name": "primary", "label": "Primary Navigation" }]
}
Seed-Dateien programmatisch anwenden:
import { applySeed, validateSeed } from "emdash/seed";
import seedData from "./.emdash/seed.json";
// Zuerst validieren
const { valid, errors } = validateSeed(seedData);
// Anwenden (idempotent – sicher mehrfach ausführbar)
await applySeed(db, seedData, {
includeContent: true,
onConflict: "skip", // 'skip' | 'update' | 'error'
});
Vergleich mit anderen Ansätzen
| Ansatz | Schema-Ort | Änderung zur Laufzeit | Typsicherheit |
|---|---|---|---|
| EmDash | Datenbank | Ja (vollständig) | Aus DB generiert |
| WordPress | PHP-Code + EAV | Begrenzt (Metafelder) | Keine |
| Strapi | Code-Dateien | Nein (Rebuild nötig) | Beim Build generiert |
| Sanity | Code-Dateien | Nein (Schema muss deployen) | Eingebaut |
| Directus | Datenbank | Ja (vollständig) | Aus DB generiert |
EmDash folgt dem Directus-Modell: datenbankzuerst mit optionaler Typgenerierung. Das bietet maximale Flexibilität und dennoch typsichere Entwicklung, wenn gewünscht.
Nächste Schritte
Collections
Mehr zu Feldtypen und Validierung.
Admin-Panel
Admin-Architektur erkunden.
Seeding
Sites mit Seed-Dateien einrichten.