Inhaltsmodell

Auf dieser Seite

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:

QuelleBeschreibung
manualÜber die Admin-UI angelegt
template:blogAus Seed-Datei einer Vorlage
import:wordpressAus WordPress importiert
discoveredAutomatisch 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:

  1. Eintrag in _emdash_fields einfü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:

VorgangUnterstützt
Spalte hinzufügenJa
Spalte umbenennenJa
Spalte löschenJa (SQLite 3.35+)
Spaltentyp ändernNein (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:

AspektOrtTabellen
SchemaSystemtabellen_emdash_collections, _emdash_fields
InhaltTabellen pro Collectionec_posts, ec_products, usw.
MedienEigene Tabelle + SpeicherTabelle media + R2/S3
EinstellungenOptionen-Tabelleoptions 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:

  1. Inhaltstypen im Admin öffnen
  2. Collection hinzufügen klicken
  3. Felder im visuellen Builder definieren
  4. 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

AnsatzSchema-OrtÄnderung zur LaufzeitTypsicherheit
EmDashDatenbankJa (vollständig)Aus DB generiert
WordPressPHP-Code + EAVBegrenzt (Metafelder)Keine
StrapiCode-DateienNein (Rebuild nötig)Beim Build generiert
SanityCode-DateienNein (Schema muss deployen)Eingebaut
DirectusDatenbankJa (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