沙盒插件可以将自己的记录存储在文档集合中。在清单中声明每个集合及其索引。EmDash 在插件加载时创建并更新匹配的索引。
本页介绍沙盒插件。集合 API 对原生插件同样适用;唯一区别是原生插件在 definePlugin() 内声明 storage,而不是在清单中。
在清单中声明 storage
对于沙盒插件,storage 位于 emdash-plugin.jsonc 中。声明必须在构建时可见,以便沙盒桥接层知道插件允许访问哪些集合。
{
"slug": "forms",
// ...identity + profile...
"capabilities": ["content:read"],
"storage": {
"submissions": {
"indexes": [
"formId",
"status",
"createdAt",
["formId", "createdAt"],
["status", "createdAt"]
]
},
"forms": {
"indexes": ["slug"]
}
}
}
storage 中的每个键是一个集合名称。indexes 数组列出可高效查询的字段 — 单字段索引为字符串,复合索引为字符串数组。完整规则请参阅清单参考。
集合名称以小写字母开头,只能包含小写字母、数字或下划线。索引字段名以字母开头,只能包含字母、数字或下划线。将唯一字段或字段组合放在 uniqueIndexes 中;唯一索引本身即可查询,因此不要在 indexes 中重复声明。
在运行时使用 storage
在 src/plugin.ts 中,通过 ctx.storage 访问集合。其结构与清单中的声明一致:
import type { SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
hooks: {
"content:afterSave": {
handler: async (event, ctx) => {
const { submissions } = ctx.storage;
await submissions.put("sub_123", {
formId: "contact",
email: "user@example.com",
status: "pending",
createdAt: new Date().toISOString(),
});
const item = await submissions.get("sub_123");
ctx.log.info("Stored submission", { id: item?.formId });
},
},
},
};
export default plugin;
访问未在清单中声明的集合会抛出错误 — 桥接层在运行时强制执行此限制。
集合 API
每个已声明的集合提供以下读取、写入、批量、查询和计数方法:
interface StorageCollection<T = unknown> {
// Basic CRUD
get(id: string): Promise<T | null>;
put(id: string, data: T): Promise<void>;
delete(id: string): Promise<boolean>;
exists(id: string): Promise<boolean>;
// Conditional writes
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>>;
// Batch operations
getMany(ids: string[]): Promise<Map<string, T>>;
putMany(items: Array<{ id: string; data: T }>): Promise<void>;
deleteMany(ids: string[]): Promise<number>;
// Query (indexed fields only)
query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
count(where?: WhereClause): Promise<number>;
}
条件写入
当并发请求可能更新同一记录时,使用 getVersioned() 和 compareAndSet()。这些方法在已声明的 ctx.storage 集合以及 ctx.kv 上均可用,适用于原生插件与沙盒插件。每次操作只访问调用方插件命名空间内的一个键。
各方法行为如下:
| 方法 | 结果 |
|---|---|
getVersioned(key) | 返回已存储的 JSON 值与不透明的 revision;行不存在时为 null。若存储的 JSON 为 null,则返回 { value: null, revision }。 |
compareAndSet(key, null, value) | 仅在该行不存在时创建。 |
compareAndSet(key, revision, value) | 仅当已存储的 revision 匹配时替换整个值。 |
compareAndDelete(key, revision) | 仅当已存储的 revision 匹配时删除该行。 |
成功的 compareAndSet() 返回 { applied: true, revision }。前置条件不满足时返回 { applied: false };无效参数、缺少权限或数据库故障会使 Promise 被拒绝。compareAndDelete() 返回 { applied: boolean }。与目标键无关的唯一索引冲突仍会报错,即使请求的键不存在。
revision 须原样传回,且只能用于其来源键。每次写入都会变更 revision,包括等值的 set()、put() 以及批量写入。删除后重新创建同一键会使其先前的 revision 失效。
以下辅助函数向插件计数器增加一条已完成任务;若其他请求先完成写入,最多重试三次。
import type { PluginContext } from "emdash/plugin";
export async function recordCompletedJob(ctx: PluginContext): Promise<number> {
const key = "state:completedJobs";
for (let attempt = 0; attempt < 3; attempt++) {
const current = await ctx.kv.getVersioned<number>(key);
const count = (current?.value ?? 0) + 1;
const result = await ctx.kv.compareAndSet(key, current?.revision ?? null, count);
if (result.applied) return count;
}
throw new Error("Job counter changed repeatedly; try again later");
}
发生冲突时,应重新读取值并重新计算拟写入的变更。重试次数应有限制。响应丢失可能导致写入结果未知;这些方法无法保证外部操作或重试的任务执行恰好发生一次。
原子性仅针对单个键。读取内容项并写入插件记录,或写入两条插件记录,均为独立操作。必须一起变更的字段应放在同一值中;在构造该值时执行业务规则(如任务归属或数量上限)。
条件写入方法要求:键为非空字符串且最多 1,024 个 JavaScript 字符;JSON 值经 UTF-8 编码后最多 1 MiB。revision 须为非空字符串且最多 128 个字符。省略 revision 无效;只有显式传入 null 才表示创建新行。现有无条件方法的行为不变。
使用这些方法前,请部署匹配的核心与沙盒适配器版本,并应用主机数据库迁移。迁移会保留已存储的值,并在滚动部署期间使旧主机进程写入的 revision 失效。
条件更新
使用 updateIf() 仅在已存储字段满足条件时修改现有文档。数据库在同一原子操作中校验条件并应用变更。原生插件以及运行在 Cloudflare、Workerd 上的沙盒插件均可使用此方法。
请使用 import type 从 emdash 或 emdash/plugin 导入 NumericDelta、UpdateIfArgs 和 UpdateIfResult 类型。
以下调用在同一操作中批准一条待处理提交,并将其 review 计数加一:
const result = await ctx.storage.submissions.updateIf("sub_123", {
where: { status: "pending" },
set: { status: "approved" },
delta: { reviewCount: { inc: 1 } },
});
if (result.applied) {
ctx.log.info("Submission approved", { submission: result.data });
}
成功时返回 { applied: true, data },其中 data 为完整更新后的文档。文档不存在或条件不匹配时返回 { applied: false }。此方法不会插入新文档。
参数行为如下:
where为必填,运算符与查询过滤器相同。显式where: {}不添加任何字段条件。守卫字段无需声明查询索引,因为更新通过 ID 定位单条文档。- 范围过滤至少需要一个已定义的边界;当另一边界已定义时,未定义的边界会被忽略。守卫条件中使用的数值操作数须为有限数。
set替换所提供各顶层字段的值,其余字段不变。值须可 JSON 序列化。delta每个字段恰好应用一个{ inc: number }或{ dec: number }。各操作数须为安全整数;允许为负数。- 同一字段不能同时出现在
set与delta中。任一对象顶层的undefined项会被忽略。至少须保留一个已定义字段。
格式错误的更新参数会使 Promise 被拒绝且不修改文档。参数对象、set、delta 以及各 delta 操作均须为普通对象。
整数计数器
对缺失或为 null 的计数器,delta 从 0 起算。已有计数器及其结果须为 Number.MIN_SAFE_INTEGER 与 Number.MAX_SAFE_INTEGER 之间的整数。若字段为字符串、布尔值、对象、数组、小数、非安全整数,或结果超出范围,整次更新返回 { applied: false }。已存储文档若不是 JSON 对象,同样返回 { applied: false }。上述两种情况下均不会修改任何字段。
delta 可产生负值。若需保持计数器非负,可将减 n 与要求该计数器至少为 n 的 where 条件组合使用。
重试序列化失败
PostgreSQL 可能因序列化失败或死锁拒绝并发写入。死锁可在任意隔离级别发生,包括 READ COMMITTED。在原生插件中,此类失败会抛出 StorageSerializationError,包含 code: "STORAGE_SERIALIZATION_FAILURE"、retryable: true 以及可选的 sqlState(40001 或 40P01)。请从 emdash 导入该错误类。
对独立调用应使用带退避的有限重试。若调用位于显式事务内,须重启整个事务(含其中的读操作);在中止的事务内仅重试写入无法成功。应将 { applied: false } 视为未应用的更新,而非序列化错误。
沙盒传输会保留错误名称与重试元数据,但不保证 instanceof StorageSerializationError。跨沙盒边界处理错误时请检查 code 与 retryable。
查询
query() 返回按索引字段过滤的分页结果:
const result = await ctx.storage.submissions.query({
where: {
formId: "contact",
status: "pending",
},
orderBy: { createdAt: "desc" },
limit: 20,
});
// result.items — Array<{ id, data }>
// result.cursor — pagination cursor (if more results exist)
// result.hasMore — boolean
查询选项
将以下选项传给 query() 以过滤、排序并分页:
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // default 50, max 100
cursor?: string; // for pagination
}
Where 子句运算符
使用下列运算符按索引字段过滤:
精确匹配
where: {
status: "pending", // exact string match
count: 5, // exact number match
archived: false, // exact boolean match
} 范围
where: {
createdAt: { gte: "2024-01-01" },
score: { gt: 50, lte: 100 },
}
// Available: gt, gte, lt, lte 列表包含
where: {
status: { in: ["pending", "approved"] },
} 前缀匹配
where: {
slug: { startsWith: "blog-" },
} 排序
将一个或多个索引字段设为升序或降序:
orderBy: { createdAt: "desc" } // newest first
orderBy: { score: "asc" } // lowest first
分页
沿游标逐页遍历所有匹配项:
async function getAllSubmissions(ctx: PluginContext) {
const all: Array<{ id: string; data: unknown }> = [];
let cursor: string | undefined;
do {
const result = await ctx.storage.submissions.query({
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
all.push(...result.items);
cursor = result.cursor;
} while (cursor);
return all;
}
计数
统计集合中的全部记录,或仅统计匹配索引字段的记录:
const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({
status: "pending",
});
批量操作
当一次操作需要读取、写入或删除多个已知记录 ID 时,使用批量方法:
const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);
// Returns Map<string, T>
await ctx.storage.submissions.putMany([
{ id: "sub_1", data: { formId: "contact", status: "new" } },
{ id: "sub_2", data: { formId: "contact", status: "new" } },
]);
const deletedCount = await ctx.storage.submissions.deleteMany(["sub_1", "sub_2"]);
在 Cloudflare 沙盒适配器上,putMany() 按顺序写入各项。若某次写入失败,Promise 会被拒绝,先前已提交的写入仍保留,且不会尝试后续项。
索引设计
根据实际查询模式选择索引:
| 查询模式 | 所需索引 |
|---|---|
按 formId 过滤 | "formId" |
按 formId 过滤,按 createdAt 排序 | ["formId", "createdAt"] |
仅按 createdAt 排序 | "createdAt" |
同时按 status 与 formId 过滤 | ["status", "formId"] |
复合索引支持按第一个字段过滤,并可选择按第二个字段排序:
// With index ["formId", "createdAt"]:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } }); // uses index
query({ where: { formId: "contact" } }); // uses index (filter only)
query({ where: { createdAt: { gte: "2024-01-01" } } }); // does NOT use this composite — filter starts at the wrong field
在 indexes 或 uniqueIndexes 中声明的每个字段均会通过查询 API 的索引字段检查。复合索引的字段顺序仍决定数据库能高效执行哪些查询形态。若插件经常在不带 formId 的情况下按某字段过滤或排序,应单独添加 "createdAt" 等索引。
类型安全
对集合访问进行类型断言,以便 IntelliSense 识别条目结构:
import type { SandboxedPlugin } from "emdash/plugin";
import type { StorageCollection } from "emdash";
interface Submission {
formId: string;
email: string;
data: Record<string, unknown>;
status: "pending" | "approved" | "spam";
createdAt: string;
}
const plugin: SandboxedPlugin = {
hooks: {
"content:afterSave": {
handler: async (event, ctx) => {
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
await submissions.put(`sub_${Date.now()}`, {
formId: "contact",
email: "user@example.com",
data: { message: "Hello" },
status: "pending",
createdAt: new Date().toISOString(),
});
},
},
},
};
export default plugin;
两处 import 均为仅类型导入,因此沙盒插件在运行时不会依赖 emdash。
Storage、内容与 KV 的对比
按数据类型选择合适的机制:
| 用例 | Storage |
|---|---|
| 插件运行数据(日志、提交、缓存) | ctx.storage |
| 用户可配置设置 | ctx.settings |
| 插件内部状态 | ctx.kv,键前缀为 state: |
| 可在管理 UI 中编辑的内容 | 站点集合(非插件 storage) |
若站点编辑者需要通过管理 UI 的常规内容编辑器查看或编辑数据,应创建站点集合。
集合隔离方式
EmDash 存储插件文档时使用插件 ID、集合名称、记录 ID、JSON 数据与时间戳。这些命名空间列属于每个键与索引的一部分。插件只能获得其清单中集合的访问器,沙盒桥接层会拒绝访问其他任何集合。
已声明字段会与插件及集合命名空间一起成为表达式索引。EmDash 为 SQLite、D1 与 PostgreSQL 生成各方言的 SQL;插件代码在各数据库上使用相同的集合 API。
添加索引
插件更新新增索引时,EmDash 会在下次加载该插件时创建。若现有记录存在重复值,则无法创建唯一索引,因此在发布该变更前应检查并消除重复。
更新移除索引时,EmDash 会删除该索引。仍使用该字段的查询或排序将在校验阶段失败。请同时更新代码与清单。
索引属于清单 storage 信任合约的一部分。添加、删除或变更索引时请递增插件版本;若变更会破坏现有查询或唯一性假设,应使用主版本号。