EmDash usa un modelo de contenido orientado a la base de datos en el que las definiciones de esquema viven en la base de datos, no en el código. Es una decisión de diseño fundamental que permite modificar el esquema en tiempo de ejecución y un flujo de trabajo accesible sin rol de desarrollador.
Esquema como datos
Los CMS tradicionales como Strapi o Keystatic exigen definir el esquema en código:
// Enfoque tradicional: esquema en código
const posts = collection({
fields: {
title: text({ required: true }),
content: richText(),
},
});
EmDash guarda la misma información en tablas de base de datos:
-- Tabla _emdash_collections
INSERT INTO _emdash_collections (slug, label)
VALUES ('posts', 'Blog Posts');
-- Tabla _emdash_fields
INSERT INTO _emdash_fields (collection_id, slug, type, required)
VALUES
('coll_abc', 'title', 'string', true),
('coll_abc', 'content', 'portableText', false);
Ambos enfoques definen la misma estructura de contenido. La diferencia está en dónde vive esa estructura y cómo puede modificarse.
¿Por qué primero la base de datos?
Modificación en tiempo de ejecución
Crear y editar tipos de contenido sin cambios de código ni reconstrucciones. Sin rol de desarrollador se puede diseñar el modelo de datos desde la interfaz de administración.
Columnas SQL reales
A diferencia del modelo EAV de WordPress, cada campo tiene una columna real. Índices adecuados, claves foráneas y optimización de consultas.
Autodocumentado
Las herramientas de base de datos pueden inspeccionar el esquema directamente. No hace falta analizar código para entender el modelo de datos.
Ruta de migración
Exportar el esquema como JSON para control de versiones. Importar el esquema en nuevos entornos.
Tablas de esquema
Dos tablas de sistema definen la estructura de tu contenido:
Tabla de colecciones
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, -- nombre de icono Lucide
supports JSON, -- ["drafts", "revisions", "preview"]
source TEXT, -- cómo se creó
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
El campo source indica cómo se creó la colección:
| Origen | Descripción |
|---|---|
manual | Creada desde el admin |
template:blog | Creada por el seed de una plantilla |
import:wordpress | Importada desde WordPress |
discovered | Detectada automáticamente a partir de datos existentes |
Tabla de campos
CREATE TABLE _emdash_fields (
id TEXT PRIMARY KEY,
collection_id TEXT REFERENCES _emdash_collections(id),
slug TEXT NOT NULL, -- nombre de columna: "title", "price"
label TEXT NOT NULL, -- etiqueta visible
type TEXT NOT NULL, -- tipo de campo
column_type TEXT NOT NULL, -- tipo SQLite: TEXT, REAL, INTEGER, JSON
required INTEGER DEFAULT 0,
unique_field INTEGER DEFAULT 0,
default_value TEXT, -- valor por defecto codificado en JSON
validation JSON, -- reglas de validación
widget TEXT, -- identificador de widget personalizado
options JSON, -- opciones del widget
sort_order INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, slug)
);
Tablas de contenido
Cada colección tiene su propia tabla con el prefijo ec_. Si creas una colección «products» con campos título y precio:
CREATE TABLE ec_products (
-- Columnas de sistema (siempre presentes)
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, -- borrado lógico
version INTEGER DEFAULT 1, -- bloqueo optimista
-- Columnas de contenido (desde definiciones de campos)
title TEXT NOT NULL,
price REAL
);
Cambios de esquema en tiempo de ejecución
Cuando añades un campo desde el admin, EmDash:
- Inserta un registro en
_emdash_fields2. EjecutaALTER TABLE ec_<colección> ADD COLUMN <nombre> <tipo>3. Regenera el esquema Zod para la validación
SQLite admite estas operaciones ALTER TABLE en tiempo de ejecución:
| Operación | Admitida |
|---|---|
| Añadir columna | Sí |
| Renombrar columna | Sí |
| Eliminar columna | Sí (SQLite 3.35+) |
| Cambiar tipo | No (requiere reconstruir la tabla) |
Para cambios de tipo, EmDash reconstruye la tabla de forma transparente: crear tabla nueva → copiar datos → eliminar la antigua → renombrar la nueva.
Separación esquema y contenido
EmDash mantiene una separación clara:
| Aspecto | Ubicación | Tablas |
|---|---|---|
| Esquema | Tablas de sistema | _emdash_collections, _emdash_fields |
| Contenido | Tablas por colección | ec_posts, ec_products, etc. |
| Medios | Tabla aparte + almacenamiento | tabla media + R2/S3 |
| Ajustes | Tabla de opciones | options con prefijo site: |
Esto implica:
- El esquema se puede exportar sin contenido
- El contenido puede migrarse entre esquemas
- Las tablas de sistema no se mezclan con datos de usuario
Validación en tiempo de ejecución
EmDash construye esquemas Zod a partir de las definiciones de campos en la base de datos al arrancar:
// Ejemplo simplificado
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);
}
El contenido se valida contra estos esquemas en tiempo de ejecución en cada creación y actualización.
Integración con TypeScript
Genera tipos TypeScript desde el esquema de la base de datos:
# Obtener esquema de la base de datos y generar tipos
npx emdash types
Esto genera .emdash/types.ts:
// .emdash/types.ts (generado)
export interface Post {
title: string;
content: PortableTextBlock[];
excerpt?: string;
featuredImage?: string;
}
export interface Product {
title: string;
price: number;
quantity: number;
}
// Sobrecargas para funciones de consulta
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 }>;
}
Flujo desarrollador frente a sin rol de desarrollador
Desarrolladores pueden usar la CLI:
# Obtener esquema y generar tipos
npx emdash types
# Exportar esquema como JSON
npx emdash export-seed > seed.json
Sin rol de desarrollador, solo la interfaz de administración:
- Abrir Tipos de contenido en el admin
- Pulsar Añadir colección
- Definir campos con el constructor visual
- Empezar a crear contenido de inmediato
Ambos flujos modifican las mismas tablas subyacentes.
Archivos seed
Las plantillas y exportaciones usan archivos seed JSON para definiciones de esquema portables:
{
"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" }]
}
Aplicar archivos seed por código:
import { applySeed, validateSeed } from "emdash/seed";
import seedData from "./.emdash/seed.json";
// Validar primero
const { valid, errors } = validateSeed(seedData);
// Aplicar (idempotente: seguro ejecutar varias veces)
await applySeed(db, seedData, {
includeContent: true,
onConflict: "skip", // 'skip' | 'update' | 'error'
});
Comparación con otros enfoques
| Enfoque | Ubicación del esquema | Modificación en runtime | Tipado |
|---|---|---|---|
| EmDash | Base de datos | Sí (completa) | Generado desde la BD |
| WordPress | Código PHP + EAV | Limitada (metacampos) | Ninguno |
| Strapi | Archivos de código | No (requiere rebuild) | Generado en build |
| Sanity | Archivos de código | No (hay que desplegar esquema) | Integrado |
| Directus | Base de datos | Sí (completa) | Generado desde la BD |
EmDash sigue el modelo de Directus: primero la base de datos, con generación de tipos opcional. Máxima flexibilidad y desarrollo con tipos cuando lo necesites.
Próximos pasos
Colecciones
Más sobre tipos de campo y validación.
Panel de administración
Explorar la arquitectura del admin.
Seeding
Configurar sitios con archivos seed.