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 会:
- 在
_emdash_fields插入记录 - 执行
ALTER TABLE ec_<collection> ADD COLUMN … - 重新生成用于校验的 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
非开发者仅使用后台界面:
- 在后台打开内容类型
- 点击添加集合
- 通过可视化构建器定义字段
- 立即开始创建内容
两者修改的是同一套底层数据库表。
种子文件
模板和导出使用 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 生成 |
| WordPress | PHP 代码 + EAV | 有限(元数据字段) | 无 |
| Strapi | 代码文件 | 否(需重建) | 构建时生成 |
| Sanity | 代码文件 | 否(需部署 schema) | 内置 |
| Directus | 数据库 | 是(完整) | 由 DB 生成 |
EmDash 遵循 Directus 模型:数据库优先加可选的类型生成。在支持类型安全开发的同时,提供最大的灵活性。