Storage

本页内容

沙箱插件可以在文档集合中存储自己的数据。您在清单中声明集合和索引,EmDash 会自动创建架构——无需编写迁移。

本页面涵盖沙箱插件。集合 API 对于原生插件是相同的;唯一的区别是原生插件在 definePlugin() 内部而不是在清单中声明 storage

在清单中声明 storage

对于沙箱插件,storage 位于 emdash-plugin.jsonc 中。声明必须在构建时可见,以便沙箱桥接知道插件可以访问哪些集合。

{
	"slug": "forms",
	// ...identity + profile...

	"storage": {
		"submissions": {
			"indexes": [
				"formId",
				"status",
				"createdAt",
				["formId", "createdAt"],
				["status", "createdAt"]
			]
		},
		"forms": {
			"indexes": ["slug"]
		}
	}
}

storage 中的每个键都是一个集合名称。indexes 数组列出可以高效查询的字段——单字段索引用字符串表示,复合索引用字符串数组表示。完整规则请参阅清单参考

在运行时使用 storage

src/plugin.ts 中,通过 ctx.storage 访问集合。形状反映了清单中声明的内容:

import type { SandboxedPlugin } from "emdash/plugin";

export default {
	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 });
			},
		},
	},
} satisfies SandboxedPlugin;

访问未在清单中声明的集合会抛出错误——桥接在运行时级别强制执行此操作。

集合 API

interface StorageCollection<T = unknown> {
	// 基本 CRUD
	get(id: string): Promise<T | null>;
	put(id: string, data: T): Promise<void>;
	delete(id: string): Promise<boolean>;
	exists(id: string): Promise<boolean>;

	// 批量操作
	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<PaginatedResult<{ id: string; data: T }>>;
	count(where?: WhereClause): Promise<number>;
}

查询

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  — 分页游标(如果存在更多结果)
// result.hasMore — boolean

查询选项

interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number;     // 默认 50,最大 1000
	cursor?: string;    // 用于分页
}

Where 子句操作符

使用这些操作符按索引字段过滤:

精确匹配

where: {
	status: "pending",     // 字符串精确匹配
	count: 5,              // 数字精确匹配
	archived: false,       // 布尔值精确匹配
}

范围

where: {
	createdAt: { gte: "2024-01-01" },
	score: { gt: 50, lte: 100 },
}
// 可用:gt, gte, lt, lte

在列表中

where: {
	status: { in: ["pending", "approved"] },
}

开头匹配

where: {
	slug: { startsWith: "blog-" },
}

排序

orderBy: { createdAt: "desc" }   // 最新的在前
orderBy: { score: "asc" }        // 最低的在前

分页

消耗游标以遍历所有匹配的项目:

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",
});

批量操作

const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);
// 返回 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"]);

索引设计

根据实际查询模式选择索引:

查询模式所需索引
formId 过滤"formId"
formId 过滤,按 createdAt 排序["formId", "createdAt"]
仅按 createdAt 排序"createdAt"
statusformId 过滤"status""formId"(分开)

复合索引支持在第一个字段上过滤并可选择按第二个字段排序的查询:

// 使用索引 ["formId", "createdAt"]:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });  // 使用索引
query({ where: { formId: "contact" } });                                  // 使用索引(仅过滤)
query({ where: { createdAt: { gte: "2024-01-01" } } });                   // 不使用此复合索引——过滤从错误的字段开始

类型安全

强制转换集合访问以获得项目形状的 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;
}

export default {
	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(),
				});
			},
		},
	},
} satisfies SandboxedPlugin;

两个导入都是仅类型的,因此沙箱插件对 emdash 没有运行时依赖。

Storage vs Content vs KV

为每种数据类型选择正确的机制:

用例Storage
插件运营数据(日志、提交、缓存)ctx.storage
用户可配置的设置ctx.kvsettings: 前缀
内部插件状态ctx.kvstate: 前缀
在管理 UI 中可编辑的内容站点集合(不是插件 storage)

如果站点编辑者需要通过常规内容编辑器在管理 UI 中查看或编辑数据,请改为创建站点集合。

实现细节

插件 storage 使用单个命名空间表:

CREATE TABLE _plugin_storage (
	plugin_id TEXT NOT NULL,
	collection TEXT NOT NULL,
	id TEXT NOT NULL,
	data JSON NOT NULL,
	created_at TEXT,
	updated_at TEXT,
	PRIMARY KEY (plugin_id, collection, id)
);

EmDash 为声明的字段创建表达式索引:

CREATE INDEX idx_forms_submissions_formId
	ON _plugin_storage(json_extract(data, '$.formId'))
	WHERE plugin_id = 'forms' AND collection = 'submissions';

这种设计为您提供了无迁移、跨 SQLite/libSQL/D1 的可移植性、插件级隔离以及每条路径上的参数化查询。

添加索引

当您在插件更新中添加索引时,EmDash 会在下次启动时自动创建它。添加索引是安全的,不需要数据迁移。当您删除索引时,EmDash 会删除它——对该字段的查询开始失败并显示验证错误,这是预期的信号。