沙箱外掛可以將自己的資料儲存在文件集合中。你在外掛描述符上宣告集合和索引,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" |
按 status 和 formId 過濾 | "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.kv 帶 settings: 前綴 |
| 內部外掛狀態 | ctx.kv 帶 state: 前綴 |
| 在管理介面中可編輯的內容 | 網站集合(非外掛儲存) |
如果網站編輯者需要透過常規內容編輯器在管理介面中檢視或編輯資料,請改為建立網站集合。
實作細節
外掛儲存使用單個命名空間表:
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 會刪除它——對該欄位的查詢將開始拋出驗證錯誤,這正是預期的訊號。