Modelo de contenido

En esta página

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:

OrigenDescripción
manualCreada desde el admin
template:blogCreada por el seed de una plantilla
import:wordpressImportada desde WordPress
discoveredDetectada 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:

  1. Inserta un registro en _emdash_fields 2. Ejecuta ALTER 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ónAdmitida
Añadir columna
Renombrar columna
Eliminar columnaSí (SQLite 3.35+)
Cambiar tipoNo (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:

AspectoUbicaciónTablas
EsquemaTablas de sistema_emdash_collections, _emdash_fields
ContenidoTablas por colecciónec_posts, ec_products, etc.
MediosTabla aparte + almacenamientotabla media + R2/S3
AjustesTabla de opcionesoptions 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:

  1. Abrir Tipos de contenido en el admin
  2. Pulsar Añadir colección
  3. Definir campos con el constructor visual
  4. 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

EnfoqueUbicación del esquemaModificación en runtimeTipado
EmDashBase de datosSí (completa)Generado desde la BD
WordPressCódigo PHP + EAVLimitada (metacampos)Ninguno
StrapiArchivos de códigoNo (requiere rebuild)Generado en build
SanityArchivos de códigoNo (hay que desplegar esquema)Integrado
DirectusBase de datosSí (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