外掛可以將自有資料存入文件集合,而無需撰寫資料庫 migration。在外掛定義中宣告集合與索引,EmDash 會自動處理 schema。
宣告 Storage
在 definePlugin() 中定義 storage 集合:
import { definePlugin } from "emdash";
export default definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: [
"formId", // Single-field index
"status",
"createdAt",
["formId", "createdAt"], // Composite index
["status", "createdAt"],
],
},
forms: {
indexes: ["slug"],
},
},
// ...
});
storage 中的每個鍵都是集合名稱。indexes 陣列列出可高效查詢的欄位。
Storage 集合 API
在 hooks 與路由中透過 ctx.storage 存取集合:
"content:afterSave": async (event, ctx) => {
const { submissions } = ctx.storage;
// CRUD operations
await submissions.put("sub_123", { formId: "contact", email: "[email protected]" });
const item = await submissions.get("sub_123");
const exists = await submissions.exists("sub_123");
await submissions.delete("sub_123");
}
完整 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>;
// 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>;
}
查詢資料
使用 query() 取得符合條件的文件。查詢回傳分頁結果。
const result = await ctx.storage.submissions.query({
where: {
formId: "contact",
status: "pending",
},
orderBy: { createdAt: "desc" },
limit: 20,
});
// result.items - Array of { id, data }
// result.cursor - Pagination cursor (if more results)
// result.hasMore - Boolean indicating more pages
查詢選項
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // Default 50, max 1000
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" }, // Greater than or equal
score: { gt: 50, lte: 100 } // Between (exclusive/inclusive)
}
// Available: gt, gte, lt, lte
In(清單)
where: {
status: { in: ["pending", "approved"] }
} 開頭符合
where: {
slug: { startsWith: "blog-" }
} 排序
依已索引欄位排序:
orderBy: {
createdAt: "desc";
} // Newest first
orderBy: {
score: "asc";
} // Lowest first
分頁
結果為分頁回傳。使用 cursor 取得後續頁:
async function getAllSubmissions(ctx: PluginContext) {
const allItems = [];
let cursor: string | undefined;
do {
const result = await ctx.storage.submissions!.query({
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
allItems.push(...result.items);
cursor = result.cursor;
} while (cursor);
return allItems;
}
PaginatedResult
interface PaginatedResult<T> {
items: T[];
cursor?: string; // Pass to next query for more results
hasMore: boolean; // True if more pages exist
}
統計文件
統計符合條件的文件數量:
// Count all
const total = await ctx.storage.submissions!.count();
// Count with filter
const pending = await ctx.storage.submissions!.count({
status: "pending",
});
批次操作
批次情境請使用批次方法:
// Get multiple by ID
const items = await ctx.storage.submissions!.getMany(["sub_1", "sub_2", "sub_3"]);
// Returns Map<string, T>
// Put multiple
await ctx.storage.submissions!.putMany([
{ id: "sub_1", data: { formId: "contact", status: "new" } },
{ id: "sub_2", data: { formId: "contact", status: "new" } },
]);
// Delete multiple
const deletedCount = await ctx.storage.submissions!.deleteMany(["sub_1", "sub_2"]);
索引設計
依查詢模式選擇索引:
| 查詢模式 | 所需索引 |
|---|---|
依 formId 篩選 | "formId" |
依 formId 篩選並依 createdAt 排序 | ["formId", "createdAt"] |
僅依 createdAt 排序 | "createdAt" |
同時依 status 與 formId 篩選 | "status" 與 "formId"(分開) |
複合索引支援先依第一個欄位篩選、再可選依第二個欄位排序的查詢:
// With index ["formId", "createdAt"]:
// This works:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });
// This also works (filter only):
query({ where: { formId: "contact" } });
// This does NOT use the composite index (wrong field order):
query({ where: { createdAt: { gte: "2024-01-01" } } });
型別安全
為 storage 集合加上型別以獲得更好的 IntelliSense:
interface Submission {
formId: string;
email: string;
data: Record<string, unknown>;
status: "pending" | "approved" | "spam";
createdAt: string;
}
definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: ["formId", "status", "createdAt"],
},
},
hooks: {
"content:afterSave": async (event, ctx) => {
// Cast to typed collection
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
const submission: Submission = {
formId: "contact",
email: "[email protected]",
data: { message: "Hello" },
status: "pending",
createdAt: new Date().toISOString(),
};
await submissions.put(`sub_${Date.now()}`, submission);
},
},
});
Storage 與內容與 KV
依情境選擇合適的儲存機制:
| 使用情境 | Storage |
|---|---|
| 外掛運作資料(日誌、提交、快取) | ctx.storage |
| 使用者可設定項目 | 帶 settings: 前綴的 ctx.kv |
| 外掛內部狀態 | 帶 state: 前綴的 ctx.kv |
| 需在管理後台編輯的內容 | 網站集合(非 plugin storage) |
實作細節
底層實作中,plugin 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';
此設計提供:
- 無需 migration — Schema 位於外掛程式碼中
- 可攜性 — 適用於 D1、libSQL、SQLite
- 隔離性 — 外掛只能存取自身資料
- 安全性 — 無 SQL 注入,查詢經過驗證
新增索引
在外掛更新中新增索引時,EmDash 會在下次啟動時自動建立。這樣做是安全的——可在不做資料遷移的情況下新增索引。
移除索引時,EmDash 會刪除它們。對未索引欄位的查詢將因驗證錯誤而失敗。