内容模型

本页内容

EmDash 采用数据库优先的内容模型:schema 定义存放在数据库而非代码中。这是支持运行时改 schema、并让非开发者也能上手的基础设计。

Schema 即数据

Strapi、Keystatic 等传统 CMS 常在代码里定义 schema:

// 传统方式:schema 写在代码里
const posts = collection({
	fields: {
		title: text({ required: true }),
		content: richText(),
	},
});

EmDash 把同样的信息存在表中:

-- _emdash_collections 表
INSERT INTO _emdash_collections (slug, label)
VALUES ('posts', 'Blog Posts');

-- _emdash_fields 表
INSERT INTO _emdash_fields (collection_id, slug, type, required)
VALUES
  ('coll_abc', 'title', 'string', true),
  ('coll_abc', 'content', 'portableText', false);

两种方式描述的是同一套内容结构,差别在于结构存放在哪、如何修改。

为何数据库优先?

运行时修改

无需改代码或重新构建即可增删内容类型。非开发者也可在后台界面设计数据模型。

真实 SQL 列

不同于 WordPress 的 EAV(实体-属性-值)模型,每个字段对应真实列,便于索引、外键与查询优化。

自描述

可用数据库工具直接查看 schema,不必从代码反推数据模型。

迁移路径

可将 schema 导出为 JSON 做版本管理,并在新环境中导入。

Schema 表

两张系统表描述内容结构:

集合表

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 图标名
  supports JSON,                    -- ["drafts", "revisions", "preview"]
  source TEXT,                      -- 创建来源
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT
);

source 记录集合的来源:

说明
manual通过后台创建
template:blog来自模板种子文件
import:wordpress从 WordPress 导入
discovered从已有数据自动发现

字段表

CREATE TABLE _emdash_fields (
  id TEXT PRIMARY KEY,
  collection_id TEXT REFERENCES _emdash_collections(id),
  slug TEXT NOT NULL,               -- 列名:"title", "price"
  label TEXT NOT NULL,              -- 显示标签
  type TEXT NOT NULL,               -- 字段类型
  column_type TEXT NOT NULL,        -- SQLite 类型:TEXT, REAL, INTEGER, JSON
  required INTEGER DEFAULT 0,
  unique_field INTEGER DEFAULT 0,
  default_value TEXT,               -- JSON 编码的默认值
  validation JSON,                  -- 校验规则
  widget TEXT,                      -- 自定义小组件标识
  options JSON,                     -- 小组件选项
  sort_order INTEGER,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(collection_id, slug)
);

内容表

每个集合有带 ec_ 前缀的表。当你创建带 title 和 price 字段的 “products” 集合时:

CREATE TABLE ec_products (
  -- 系统列(始终存在)
  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,                  -- 软删除
  version INTEGER DEFAULT 1,        -- 乐观锁

  -- 内容列(来自字段定义)
  title TEXT NOT NULL,
  price REAL
);

运行时 schema 变更

在后台添加字段时,EmDash 会:

  1. _emdash_fields 插入记录
  2. 执行 ALTER TABLE ec_<collection> ADD COLUMN …
  3. 重新生成用于校验的 Zod schema

SQLite 在运行时支持的 ALTER TABLE 操作:

操作是否支持
添加列
重命名列
删除列是(SQLite 3.35+)
修改列类型否(需重建表)

类型变更时,EmDash 会透明地完成表重建:创建新表 → 复制数据 → 删除旧表 → 重命名新表。

Schema 与内容分离

EmDash 保持清晰的分离:

方面位置
Schema系统表_emdash_collections, _emdash_fields
内容按集合分表ec_posts, ec_products
媒体独立表 + 存储media 表 + R2/S3
设置选项表site: 前缀的 options

这种分离意味着:

  • Schema 可以在不包含内容的情况下导出
  • 内容可以在不同 schema 之间迁移
  • 系统表不会被用户数据所混杂

运行时校验

启动时 EmDash 根据数据库字段定义构建 Zod schema:

// 简化示例
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);
}

内容在每次创建和更新时都会通过这些运行时 schema 进行校验。

与 TypeScript 集成

根据数据库 schema 生成 TypeScript 类型:

# 从数据库获取 schema,生成类型
npx emdash types

这会生成 .emdash/types.ts

// .emdash/types.ts(生成)
export interface Post {
	title: string;
	content: PortableTextBlock[];
	excerpt?: string;
	featuredImage?: string;
}

export interface Product {
	title: string;
	price: number;
	quantity: number;
}

// 查询函数的类型重载
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 }>;
}

开发者与非开发者工作流

开发者可使用 CLI:

# 获取 schema,生成类型
npx emdash types

# 导出 schema 为 JSON
npx emdash export-seed > seed.json

非开发者仅使用后台界面:

  1. 在后台打开内容类型
  2. 点击添加集合
  3. 通过可视化构建器定义字段
  4. 立即开始创建内容

两者修改的是同一套底层数据库表。

种子文件

模板和导出使用 JSON 种子文件来存储可移植的 schema 定义:

{
	"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" }]
}

通过编程方式应用种子文件:

import { applySeed, validateSeed } from "emdash/seed";
import seedData from "./.emdash/seed.json";

// 先校验
const { valid, errors } = validateSeed(seedData);

// 应用(幂等——可安全重复运行)
await applySeed(db, seedData, {
	includeContent: true,
	onConflict: "skip", // 'skip' | 'update' | 'error'
});

与其他方案对比

方案Schema 位置运行时修改类型安全
EmDash数据库是(完整)由 DB 生成
WordPressPHP 代码 + EAV有限(元数据字段)
Strapi代码文件否(需重建)构建时生成
Sanity代码文件否(需部署 schema)内置
Directus数据库是(完整)由 DB 生成

EmDash 遵循 Directus 模型:数据库优先加可选的类型生成。在支持类型安全开发的同时,提供最大的灵活性。

下一步