存储

本页内容

沙箱插件可以将自己的数据存储在文档集合中。你在插件描述符上声明集合和索引,EmDash 会自动创建模式——无需编写迁移脚本。

本页面涵盖沙箱(标准格式)插件。集合 API 对原生插件是相同的;唯一的区别是原生插件直接在 definePlugin() 内部声明 storage,而不是在单独的描述符上。

在描述符上声明存储

对于沙箱插件,storage 位于描述符上——即 astro.config.mjs 导入的文件,而不是沙箱入口。存储声明需要在构建时可见,以便沙箱桥接知道插件允许访问哪些集合。

import type { PluginDescriptor } from "emdash";

export function formsPlugin(): PluginDescriptor {
	return {
		id: "forms",
		version: "1.0.0",
		format: "standard",
		entrypoint: "@my-org/plugin-forms/sandbox",

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

storage 中的每个键都是一个集合名称。indexes 数组列出可以高效查询的字段——单字段索引为字符串,复合索引为字符串数组。

在沙箱入口中使用存储

在沙箱入口内部,通过 ctx.storage 访问集合。其结构与描述符上声明的内容一致:

import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";

export default definePlugin({
	hooks: {
		"content:afterSave": async (event, ctx: PluginContext) => {
			const { submissions } = ctx.storage;

			await submissions.put("sub_123", {
				formId: "contact",
				email: "[email protected]",
				status: "pending",
				createdAt: new Date().toISOString(),
			});

			const item = await submissions.get("sub_123");
			ctx.log.info("Stored submission", { id: item?.formId });
		},
	},
});

访问未在描述符上声明的集合会抛出异常——桥接在运行时强制执行此规则。

集合 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 { StorageCollection, PluginContext } from "emdash";

interface Submission {
	formId: string;
	email: string;
	data: Record<string, unknown>;
	status: "pending" | "approved" | "spam";
	createdAt: string;
}

export default definePlugin({
	hooks: {
		"content:afterSave": async (event, ctx: PluginContext) => {
			const submissions = ctx.storage.submissions as StorageCollection<Submission>;

			await submissions.put(`sub_${Date.now()}`, {
				formId: "contact",
				email: "[email protected]",
				data: { message: "Hello" },
				status: "pending",
				createdAt: new Date().toISOString(),
			});
		},
	},
});

Storage vs content vs KV

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

使用场景存储方式
插件操作数据(日志、提交、缓存)ctx.storage
用户可配置的设置ctx.kvsettings: 前缀
内部插件状态ctx.kvstate: 前缀
在管理界面中可编辑的内容站点集合(非插件存储)

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

实现细节

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

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 会删除它——对该字段的查询将开始抛出验证错误,这正是预期的信号。