- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
在 EmDash 中,沙箱插件(Sandboxed Plugin)并非直接访问宿主数据库,而是通过三个插件作用域(plugin-scoped)的数据 API 读写数据:可查询的ctx.storage.<collection>记录集合、面向用户的ctx.settings配置(支持加密密钥)、以及用于游标与缓存的ctx.kv。本文以仓库中的官方插件开发参考文档 storage.md 为骨架,结合 EmDash 核心源码(types.ts、storage-query.ts)与插件清单 Schema(emdash-plugin.schema.json),系统讲解集合声明、CRUD 与批量写入、基于修订号的乐观并发控制(CAS)、谓词守卫原子更新(updateIf)、索引查询分页,以及 KV 与加密设置的使用边界,帮助你写出既安全又可在三种运行时(native、Cloudflare 沙箱、Node/workerd 沙箱)间可移植的存储代码。
三大插件级数据 API 总览
沙箱插件可以使用的数据 API 只有三个,全部走宿主数据库,并按运行时插件 ID(runtime plugin ID)做隔离,且不需要声明任何 capability(参见 SKILL.md 中的 capability 表格说明:"Settings, KV, declared storage, logging, and cron scheduling are plugin-scoped and need no capability"):
| API | 用途 |
|---|---|
ctx.storage.<collection> | 在emdash-plugin.jsonc中声明的、可查询的记录集合 |
ctx.settings | 用户可配置的设置项,支持加密密钥(secret) |
ctx.kv | 游标(cursors)、缓存值及其他键值状态 |
需要特别强调的是"插件作用域 + 运行时隔离":每个插件只能看到自己名下的集合与键,无法越界访问其他插件或宿主的数据。三个存储都落在宿主数据库中,因此不需要为它们申请capabilities,也正因为如此,存储 API 是所有沙箱插件默认就有的能力。
在清单中声明存储集合
任何集合与查询索引都必须先在插件清单emdash-plugin.jsonc中声明。清单的storage字段在 emdash-plugin.schema.json 中有完整的 JSON Schema 约束:
{ "storage": { "submissions": { "indexes": ["formId", "status", "createdAt", ["formId", "createdAt"]], "uniqueIndexes": ["externalId"], }, }, }声明规则与 Schema 约束:
- 集合名必须匹配
^[a-z][a-z0-9_]*$(小写字母开头,可含小写字母、数字、下划线),运行时按插件命名空间隔离。 indexes是必填字段,Schema 中集合对象required: ["indexes"];每个索引要么是单个字段名字符串,要么是复合索引(字段名数组)。uniqueIndexes声明唯一索引;其中的字段本身已经可查询,不要再重复写进indexes。- 未声明的集合会被沙箱桥(sandbox bridge)直接拒绝("An undeclared collection is rejected by the sandbox bridge"),因此清单是存储可用性的硬约束,而不是可选优化。
从源码实现看,这些声明会被编译为实际的查询约束:getIndexedFields()(storage-query.ts)把声明拍平成"可索引字段集合",validateWhereClause()随后校验where里的每个字段是否都在该集合内——这正是"只能过滤/排序已声明索引字段"这一规则在底层的强制实施。
集合操作:一套可移植的 API
每个已声明集合都暴露统一的StorageCollection<T>接口,在 native、Cloudflare 沙箱和 Node/workerd 沙箱三种执行方式下保持一致的语义(完整定义见 types.ts):
interface StorageCollection<T = unknown> { get(id: string): Promise<T | null>; put(id: string, data: T): Promise<void>; delete(id: string): Promise<boolean>; exists(id: string): Promise<boolean>; getVersioned(id: string): Promise<{ value: T; revision: string } | null>; compareAndSet( id: string, expectedRevision: string | null, data: T, ): Promise<{ applied: true; revision: string } | { applied: false }>; compareAndDelete(id: string, expectedRevision: string): Promise<{ applied: boolean }>; updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>>; getMany(ids: string[]): Promise<Map<string, T>>; putMany(items: Array<{ id: string; data: T }>): Promise<void>; deleteMany(ids: string[]): Promise<number>; query(options?: QueryOptions): Promise<{ items: Array<{ id: string; data: T }>; cursor?: string; hasMore: boolean; }>; count(where?: WhereClause): Promise<number>; }几点实现细节值得注意:
- 不依赖异步迭代器:源码注释明确 "No async iterators - all operations return promises with pagination",即查询一律走分页结果而非流式迭代。
- 批量方法可跨桥:
getMany()返回Map<string, T>,即使跨过沙箱桥(Cloudflare 或 Node/workerd 的 bridge)也保持Map类型。 - 内容批量方法不在
StorageCollection中:Node/workerd wrapper 还额外含有 content batch 方法,但它们不属于本接口;上面这组 storage 批量方法才是两种 runner 之间可移植的公共子集。
基础与批量写入
最基本的 CRUD 与批量操作如下(以表单插件收集的"提交记录"为例,仓库中 forms 插件 即使用这类集合处理提交数据):
const submissions = ctx.storage.submissions as StorageCollection<Submission>; await submissions.put("sub_123", { formId: "contact", status: "pending", createdAt: new Date().toISOString(), }); const item = await submissions.get("sub_123"); const exists = await submissions.exists("sub_123"); const items = await submissions.getMany(["sub_123", "sub_456"]); await submissions.putMany([ { id: "sub_456", data: { formId: "contact", status: "pending" } }, { id: "sub_789", data: { formId: "sales", status: "pending" } }, ]); const deleted = await submissions.deleteMany(["sub_456", "sub_789"]);使用要点:
put(id, data)全量替换该 id 下的 JSON 文档;delete(id)返回布尔值表示是否确实删除了记录。getMany对不存在的 id 会自然缺项(返回的Map中无该键),适合做批量预取。deleteMany返回实际删除的数量。- 批量操作与单条操作共享同一套可移植语义,跨沙箱桥行为一致。
基于修订号的乐观并发(CAS)
当多个并发请求可能替换同一个完整值时,不要用"读-改-写"裸奔,而应使用getVersioned()、compareAndSet()、compareAndDelete()三个基于修订号(revision)的操作:
const current = await submissions.getVersioned("sub_123"); if (!current) throw new Error("Submission not found"); const result = await submissions.compareAndSet("sub_123", current.revision, { ...current.value, status: "processing", }); if (!result.applied) { // 另一个请求已经修改或删除了该值。重新读取后再重试。 }各操作的前置条件(preconditions)总结:
| 操作 | 行为 |
|---|---|
getVersioned(key) | 返回{ value, revision };只有记录不存在时才返回null |
compareAndSet(key, null, value) | 仅当记录不存在时创建(create-if-absent) |
compareAndSet(key, revision, value) | 仅当当前修订号匹配时替换 |
compareAndDelete(key, revision) | 仅当当前修订号匹配时删除 |
语义边界(源码注释与文档一致):
- 存储的 JSON
null仍然返回版本化信封:getVersioned对"存在但值为 null"返回{ value: null, revision },只有整条记录缺失才返回null。 - 每次成功写入都会改变修订号,包括写入相同值的
put()/set();修订号是不透明的、按 key 隔离的值,必须原样回传,不能解析或构造。 - 冲突返回
applied: false,而非法输入、权限失败、唯一索引冲突、数据库错误等会直接 reject。 - CAS 不是外部副作用的 exactly-once 机制:冲突后要重新读取、重新计算,并把重试次数控制在有界范围内;丢失响应可能让写入结果未知,因此不要把 CAS 当作外部副作用(如发邮件、扣款)的幂等保证。
版本化方法在ctx.kv上同样可用,典型用途是"无锁计数器":
const current = await ctx.kv.getVersioned<number>("state:completed"); const next = (current?.value ?? 0) + 1; const result = await ctx.kv.compareAndSet("state:completed", current?.revision ?? null, next);谓词守卫原子更新:updateIf
updateIf()在存储数据匹配守卫(guard)时修改已存在文档的字段。守卫、字段替换和整数增量在单条记录上原子执行——这正是源码注释中强调的 no-oversell 原语:守卫与算术位于同一条UPDATE … RETURNING语句中,N 个并发守卫递减会正确串行化(见 types.ts 的updateIf文档)。
const result = await submissions.updateIf("sub_123", { where: { status: "pending", attempts: { lt: 3 } }, set: { status: "processing", lastAttemptAt: new Date().toISOString() }, delta: { attempts: { inc: 1 } }, }); if (result.applied) { ctx.log.info("Claimed submission", { submission: result.data }); }返回{ applied: false }的四种情形:行不存在、守卫不匹配、存储文档不是对象、整数算术不安全。updateIf是纯更新操作,永远不会插入缺失行(源码注释:"applied: falseintentionally conflates 'row absent' and 'guard failed'",两者有意不区分)。
UpdateIfArgs的真实定义(types.ts)与使用规则:
where必填;显式{}表示"匹配任意存在的行"(等同 update-if-row-exists,源码提醒这在防超卖场景下是个 footgun,应写真实谓词如{ stock: { gte: 1 } })。where复用QueryOptions的WhereClause,在 SQL 内求值,与query()使用同一套数值正确、全序比较语义。set替换传入的顶层字段,未涉及的字段保持不变。delta中每个字段恰好一个安全整数inc或dec;缺失或null的计数器从零开始(COALESCE(base, 0) ± n)。- 同一字段不能同时出现在
set和delta中;set/delta至少留一个已定义字段。 - 需要保持非负时,把
dec: n与gte: n守卫配对。 set与delta是独立参数而非联合类型,因此一个恰好长得像{ inc: 5 }的整体值永远不会被误判为增量(源码注释明确说明这一设计动机)。
错误处理:畸形参数会在不写入的情况下 reject。在原生 PostgreSQL 执行中,序列化失败与死锁会抛出StorageSerializationError(storage-query.ts),其code === "STORAGE_SERIALIZATION_FAILURE"、retryable === true,并携带可选的sqlState(Postgres SQLSTATE,如40001/40P01)。沙箱传输会保留code与retryable等安全字段,但不保证instanceof——跨桥判断时请检查code和retryable字段而非类型。重试整个显式事务(explicit transaction)前要先整体重启该事务。
索引查询与分页
查询只能过滤或排序已声明索引的字段。query()返回分页结果,count(where)接受同样的索引过滤条件:
const result = await submissions.query({ where: { formId: "contact", status: { in: ["pending", "processing"] }, createdAt: { gte: "2026-01-01" }, }, orderBy: { createdAt: "desc" }, limit: 100, cursor, });支持的过滤形式:
- 精确值:
{ field: "contact" } - 枚举:
{ in: [...] } - 前缀:
{ startsWith: "..." }(底层用 LIKE 实现,escapeLikePattern()会转义%、_、\等通配符,见 storage-query.ts) - 范围对象:使用
gt、gte、lt、lte,至少需要一个有定义的边界
分页约定:
query()默认返回 50 条,每页最多 100 条。- 用
cursor翻页,直到hasMore为false。 - 复合索引的字段顺序决定可用的查询形态:
['formId', 'createdAt']支持"按formId过滤 + 按createdAt排序",但它不能替代独立的createdAt索引——省略formId的查询用不到该复合索引。
KV 操作
KV 支持无条件读写、版本化读写、删除与前缀列举:
interface KVAccess { get<T>(key: string): Promise<T | null>; set(key: string, value: unknown): Promise<void>; delete(key: string): Promise<boolean>; list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>; getVersioned<T>(key: string): Promise<{ value: T; revision: string } | null>; compareAndSet( key: string, expectedRevision: string | null, value: unknown, ): Promise<{ applied: true; revision: string } | { applied: false }>; compareAndDelete(key: string, expectedRevision: string): Promise<{ applied: boolean }>; }源码注释(types.ts)给出了官方键命名约定:
state:*:插件内部状态(不对用户展示),如游标、计数器;cache:*:可复用的计算结果或远程数据;settings:*:EmDash 0.x 期间的兼容别名(见下文)。
用稳定的前缀保持内部 KV 键可发现、可管理:
await ctx.settings.set("webhookUrl", url); await ctx.kv.set("state:lastRun", new Date().toISOString()); await ctx.kv.set("cache:summary", summary); const settings = await ctx.settings.list();设置(Settings)与密钥加密
ctx.settings是面向用户的配置入口,与后台管理界面(admin)的生成表单直接打通:插件 CLI 会序列化admin.settingsSchema,两个沙箱桥把ctx.settings路由到与后台表单相同的 options 记录上——用户在管理表单里保存的值,通过ctx.settings.get("<key>")即可读到。完整设置 API 支持set、delete、list、getVersioned、compareAndSet、compareAndDelete。
settingsSchema的字段类型在 emdash-plugin.schema.json 中定义,包括string(可带multiline、default)、number(可带min/max)、boolean、select(options数组)、url、email,以及加密类型secret。
secret 字段的加密机制:
- 声明为
secret的字段使用带版本的 AES-GCM 信封,插件 ID 与设置键作为认证数据(authenticated data)参与加密,防止密文被替换到其他插件/其他键上。 EMDASH_ENCRYPTION_KEY环境变量可包含逗号分隔的密钥轮换列表:第一个密钥用于加密新值,信封中的kid选择用于读取的密钥。- 缺失、错误或被篡改的密钥会安全失败(fail closed),不会暴露明文。
- 已存在的明文 secret 仍可读取,并在再次保存时转为加密存储。
运维注意:必须把完整的密钥列表与运营备份放在一起;如果恢复数据库时缺少其加密设置引用的任一密钥,这些设置值将无法读取。另外,ctx.kv.get("settings:<key>")在整个 EmDash 0.x 中仍是兼容别名,但新插件应统一使用ctx.settings。
结语
EmDash 的插件存储体系用"清单声明 + 统一接口 + 沙箱桥隔离"三条原则,把插件数据访问收敛成可控、可移植、可审计的形态:集合索引必须显式声明并由桥强制校验,并发安全交给修订号 CAS 与谓词守卫updateIf(单语句原子更新),用户配置与密钥安全由ctx.settings和 AES-GCM 信封统一承担。编写插件时,优先对照 storage.md 与 SKILL.md 的 capability 说明,以 types.ts 的导出类型为准,即可写出在 native、Cloudflare 与 Node/workerd 三种运行时之间行为一致、无需额外 capability 的持久化代码。
- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
相关推荐
EmDash 插件存储指南:Storage、Settings 与 KV 的声明、读写与并发控制
EmDash 插件存储指南:Storage、Settings 与 KV 的声明、读写与并发控制 EmDash 为沙箱化插件提供了三套插件级数据 API:可查询的
CMS后端前端插件系统EmDash 插件存储指南:基于 Sandboxed 插件的 Collection、Settings 与 KV 数据 API 实战
EmDash 插件存储指南:基于 Sandboxed 插件的 Collection、Settings 与 KV 数据 API 实战 导读 本文是 EmDash
CMS后端前端插件系统EmDash 插件存储与 KV 完全指南:ctx.storage、ctx.settings 与 ctx.kv 的声明、并发控制与加密实践
EmDash 插件存储与 KV 完全指南:ctx.storage、ctx.settings 与 ctx.kv 的声明、并发控制与加密实践 沙盒化插件(Sandb
CMS后端前端插件系统
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考