EmDash 插件存储指南:Storage 集合、KV 与加密 Settings 的完整实战
2026/9/23 11:35:32 网站建设 项目流程
  • CMS
  • 后端
  • 前端
  • 插件系统

【免费下载链接】emdash

EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress

项目地址:https://gitcode.com/gh_mirrors/emdas/emdash
点击查看免费下载

在 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)仅当当前修订号匹配时删除

语义边界(源码注释与文档一致):

  • 存储的 JSONnull仍然返回版本化信封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复用QueryOptionsWhereClause,在 SQL 内求值,与query()使用同一套数值正确、全序比较语义。
  • set替换传入的顶层字段,未涉及的字段保持不变。
  • delta中每个字段恰好一个安全整数incdec;缺失或null的计数器从零开始(COALESCE(base, 0) ± n)。
  • 同一字段不能同时出现在setdelta中;set/delta至少留一个已定义字段。
  • 需要保持非负时,把dec: ngte: n守卫配对。
  • setdelta是独立参数而非联合类型,因此一个恰好长得像{ inc: 5 }的整体值永远不会被误判为增量(源码注释明确说明这一设计动机)。

错误处理:畸形参数会在不写入的情况下 reject。在原生 PostgreSQL 执行中,序列化失败与死锁会抛出StorageSerializationError(storage-query.ts),其code === "STORAGE_SERIALIZATION_FAILURE"retryable === true,并携带可选的sqlState(Postgres SQLSTATE,如40001/40P01)。沙箱传输会保留coderetryable等安全字段,但不保证instanceof——跨桥判断时请检查coderetryable字段而非类型。重试整个显式事务(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)
  • 范围对象:使用gtgteltlte,至少需要一个有定义的边界

分页约定:

  • query()默认返回 50 条,每页最多 100 条。
  • cursor翻页,直到hasMorefalse
  • 复合索引的字段顺序决定可用的查询形态:['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 支持setdeletelistgetVersionedcompareAndSetcompareAndDelete

settingsSchema的字段类型在 emdash-plugin.schema.json 中定义,包括string(可带multilinedefault)、number(可带min/max)、booleanselectoptions数组)、urlemail,以及加密类型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

项目地址:https://gitcode.com/gh_mirrors/emdas/emdash
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询