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 會刪除它——對該欄位的查詢開始失敗並顯示驗證錯誤,這是預期的訊號。