存储

本页内容

沙盒插件可以将自己的记录存储在文档集合中。在清单中声明每个集合及其索引。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 信任合约的一部分。添加、删除或变更索引时请递增插件版本;若变更会破坏现有查询或唯一性假设,应使用主版本号。