ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

EmDash 插件存储指南:Storage 集合、KV 与加密 Settings 的完整实战

EmDash 插件存储指南:Storage 集合、KV 与加密 Settings 的完整实战 CMS后端前端插件系统【免费下载链接】emdashEmDash 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与插件清单 Schemaemdash-plugin.schema.json系统讲解集合声明、CRUD 与批量写入、基于修订号的乐观并发控制CAS、谓词守卫原子更新updateIf、索引查询分页以及 KV 与加密设置的使用边界帮助你写出既安全又可在三种运行时native、Cloudflare 沙箱、Node/workerd 沙箱间可移植的存储代码。三大插件级数据 API 总览沙箱插件可以使用的数据 API 只有三个全部走宿主数据库并按运行时插件 IDruntime plugin ID做隔离且不需要声明任何 capability参见 SKILL.md 中的 capability 表格说明Settings, KV, declared storage, logging, and cron scheduling are plugin-scoped and need no capabilityAPI用途ctx.storage.collection在emdash-plugin.jsonc中声明的、可查询的记录集合ctx.settings用户可配置的设置项支持加密密钥secretctx.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每个已声明集合都暴露统一的StorageCollectionT接口在 native、Cloudflare 沙箱和 Node/workerd 沙箱三种执行方式下保持一致的语义完整定义见 types.tsinterface StorageCollectionT unknown { get(id: string): PromiseT | null; put(id: string, data: T): Promisevoid; delete(id: string): Promiseboolean; exists(id: string): Promiseboolean; 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: UpdateIfArgsT): PromiseUpdateIfResultT; getMany(ids: string[]): PromiseMapstring, T; putMany(items: Array{ id: string; data: T }): Promisevoid; deleteMany(ids: string[]): Promisenumber; query(options?: QueryOptions): Promise{ items: Array{ id: string; data: T }; cursor?: string; hasMore: boolean; }; count(where?: WhereClause): Promisenumber; }几点实现细节值得注意不依赖异步迭代器源码注释明确 No async iterators - all operations return promises with pagination即查询一律走分页结果而非流式迭代。批量方法可跨桥getMany()返回Mapstring, T即使跨过沙箱桥Cloudflare 或 Node/workerd 的 bridge也保持Map类型。内容批量方法不在StorageCollection中Node/workerd wrapper 还额外含有 content batch 方法但它们不属于本接口上面这组 storage 批量方法才是两种 runner 之间可移植的公共子集。基础与批量写入最基本的 CRUD 与批量操作如下以表单插件收集的提交记录为例仓库中 forms 插件 即使用这类集合处理提交数据const submissions ctx.storage.submissions as StorageCollectionSubmission; 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 }只有记录不存在时才返回nullcompareAndSet(key, null, value)仅当记录不存在时创建create-if-absentcompareAndSet(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.getVersionednumber(state:completed); const next (current?.value ?? 0) 1; const result await ctx.kv.compareAndSet(state:completed, current?.revision ?? null, next);谓词守卫原子更新updateIfupdateIf()在存储数据匹配守卫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 执行中序列化失败与死锁会抛出StorageSerializationErrorstorage-query.ts其code STORAGE_SERIALIZATION_FAILURE、retryable true并携带可选的sqlStatePostgres 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 { getT(key: string): PromiseT | null; set(key: string, value: unknown): Promisevoid; delete(key: string): Promiseboolean; list(prefix?: string): PromiseArray{ key: string; value: unknown }; getVersionedT(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、selectoptions数组、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后端前端插件系统【免费下载链接】emdashEmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress项目地址https://gitcode.com/gh_mirrors/emdas/emdash点击查看免费下载相关推荐EmDash 插件存储指南Storage、Settings 与 KV 的声明、读写与并发控制EmDash 插件存储指南Storage、Settings 与 KV 的声明、读写与并发控制 EmDash 为沙箱化插件提供了三套插件级数据 API可查询的CMS后端前端插件系统EmDash 插件存储指南基于 Sandboxed 插件的 Collection、Settings 与 KV 数据 API 实战EmDash 插件存储指南基于 Sandboxed 插件的 Collection、Settings 与 KV 数据 API 实战 导读 本文是 EmDashCMS后端前端插件系统EmDash 插件存储与 KV 完全指南ctx.storage、ctx.settings 与 ctx.kv 的声明、并发控制与加密实践EmDash 插件存储与 KV 完全指南ctx.storage、ctx.settings 与 ctx.kv 的声明、并发控制与加密实践 沙盒化插件SandbCMS后端前端插件系统创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表