沙盒外掛程式可以將自己的記錄儲存在文件集合中。在清單中宣告每個集合及其索引。EmDash 在外掛程式載入時建立並更新對應的索引。
本頁介紹沙盒外掛程式。集合 API 對原生外掛程式相同;唯一差異是原生外掛程式在 definePlugin() 內宣告 storage,而非在清單中。
在清單中宣告 storage
對於沙盒外掛程式,storage 位於 emdash-plugin.jsonc。宣告必須在建置時可見,沙盒橋接層才能知道外掛程式允許存取哪些集合。
{
"slug": "forms",
// ...identity + profile...
"capabilities": ["content:read"],
"storage": {
"submissions": {
"indexes": [
"formId",
"status",
"createdAt",
["formId", "createdAt"],
["status", "createdAt"]
]
},
"forms": {
"indexes": ["slug"]
}
}
}
storage 的每個鍵都是集合名稱。indexes 陣列列出可高效查詢的欄位 — 單欄位索引為字串,複合索引為字串陣列。完整規則請見清單參考。
集合名稱以小寫字母開頭,可包含小寫字母、數字或底線。索引欄位名稱以字母開頭,可包含字母、數字或底線。將唯一欄位或欄位組合放在 uniqueIndexes;唯一索引本身即可查詢,請勿在 indexes 中重複宣告。
在執行時使用 storage
在 src/plugin.ts 中,透過 ctx.storage 存取集合。其結構與清單中的宣告一致:
import type { SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
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 });
},
},
},
};
export default plugin;
存取未在清單中宣告的集合會拋出錯誤 — 橋接層會在執行時強制執行此限制。
集合 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>;
// Conditional writes
getVersioned(id: string): Promise<{ value: T; revision: string } | null>;
compareAndSet(id: string, expectedRevision: string | null, data: T):
Promise<{ applied: true; revision: string } | { applied: false }>;
compareAndDelete(id: string, expectedRevision: string): Promise<{ applied: boolean }>;
updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>>;
// 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>;
}
條件寫入
當並行請求可能更新同一筆記錄時,請使用 getVersioned() 與 compareAndSet()。這些方法適用於已宣告的 ctx.storage 集合以及 ctx.kv,原生與沙盒外掛程式皆可使用。每次操作只會存取呼叫外掛程式命名空間內的一個鍵。
各方法的行為如下:
| 方法 | 結果 |
|---|---|
getVersioned(key) | 回傳儲存的 JSON 值與不透明的 revision,若列不存在則為 null。若儲存的是 JSON null,則回傳 { value: null, revision }。 |
compareAndSet(key, null, value) | 僅在列不存在時建立。 |
compareAndSet(key, revision, value) | 僅在儲存的 revision 相符時替換整個值。 |
compareAndDelete(key, revision) | 僅在儲存的 revision 相符時刪除列。 |
成功的 compareAndSet() 回傳 { applied: true, revision }。前置條件不符時回傳 { applied: false };無效引數、缺少權限或資料庫錯誤會使 Promise 被拒絕。compareAndDelete() 回傳 { applied: boolean }。即使請求的鍵不存在,不相關的唯一索引違反仍視為錯誤。
請原樣傳回 revision,且只用於取得該 revision 的同一個鍵。每次寫入都會變更 revision,包含等值的 set()、put() 與批次寫入。刪除後再以相同鍵重新建立會使先前的 revision 失效。
以下輔助函式會向外掛程式的計數器新增一筆已完成工作;若另一個請求先寫入,最多重試三次。
import type { PluginContext } from "emdash/plugin";
export async function recordCompletedJob(ctx: PluginContext): Promise<number> {
const key = "state:completedJobs";
for (let attempt = 0; attempt < 3; attempt++) {
const current = await ctx.kv.getVersioned<number>(key);
const count = (current?.value ?? 0) + 1;
const result = await ctx.kv.compareAndSet(key, current?.revision ?? null, count);
if (result.applied) return count;
}
throw new Error("Job counter changed repeatedly; try again later");
}
發生衝突時,請重新讀取值並重新計算欲寫入的變更。重試次數應有上限。若回應遺失,寫入結果可能無從得知;這些方法無法保證外部動作或重試的工作執行恰好只發生一次。
原子性僅涵蓋單一鍵。讀取內容項目後寫入外掛記錄,或寫入兩筆外掛記錄,皆為彼此獨立的操作。必須一併變更的欄位應放在同一個值內;建構該值時再落實工作歸屬、數量上限等業務規則。
條件式方法要求鍵為非空字串,且最多 1,024 個 JavaScript 字元;JSON 值在 UTF-8 編碼後最多 1 MiB。revision 必須為非空字串,且最多 128 個字元。省略 revision 無效;只有明確傳入 null 才表示要求建立新列。既有的無條件方法行為不變。
使用這些方法前,請部署相符的核心與沙盒適配器版本,並套用主機資料庫遷移。遷移會保留已儲存的值,並在滾動部署期間使舊版主機程序的寫入導致 revision 失效。
條件更新
使用 updateIf() 僅在儲存欄位符合條件時變更現有文件。資料庫會在一個原子操作中檢查條件並套用變更。此方法適用於原生外掛程式,以及 Cloudflare 與 Workerd 上的沙盒外掛程式。
請以 import type 從 emdash 或 emdash/plugin 匯入 NumericDelta、UpdateIfArgs 與 UpdateIfResult 型別。
以下呼叫會核准一筆待處理提交,並在同一操作中遞增其審核次數:
const result = await ctx.storage.submissions.updateIf("sub_123", {
where: { status: "pending" },
set: { status: "approved" },
delta: { reviewCount: { inc: 1 } },
});
if (result.applied) {
ctx.log.info("Submission approved", { submission: result.data });
}
成功時回傳 { applied: true, data },其中 data 為完整更新後的文件。若文件不存在或條件不符,則回傳 { applied: false }。此方法永遠不會插入新文件。
引數行為如下:
where為必填,運算子與查詢篩選器相同。明確的where: {}不新增任何欄位條件。守衛欄位無需在清單中宣告查詢索引,因為更新是依 ID 鎖定單一文件。- 範圍篩選至少需有一個已定義的界線;若另一界線已定義,未定義的界線會被忽略。守衛所用的數值運算元必須為有限值。
set會替換每個提供的頂層欄位值,其餘欄位不變。值必須可 JSON 序列化。delta每個欄位恰好套用一個{ inc: number }或{ dec: number }。各運算元須為安全整數;可為負數。- 同一欄位不可同時出現在
set與delta中。任一物件頂層的undefined項目會被忽略。至少須保留一個已定義的欄位。
格式錯誤的更新引數會使 Promise 被拒絕,且不會變更文件。引數物件、set、delta 以及每個 delta 操作都必須為 plain object。
整數計數器
對缺失或為 null 的計數器,delta 會從 0 起算。既有計數器與運算結果須為 Number.MIN_SAFE_INTEGER 與 Number.MAX_SAFE_INTEGER 之間的整數。若欄位為字串、布林、物件、陣列、小數、非安全整數,或結果超出範圍,整次更新會回傳 { applied: false }。若儲存文件不是 JSON 物件,同樣回傳 { applied: false }。兩種情況下皆不會變更任何欄位。
delta 可能產生負值。若要維持計數器非負,可將遞減 n 與要求該計數器至少為 n 的 where 條件一併使用。
重試序列化失敗
PostgreSQL 可能因序列化失敗或死結而拒絕並行寫入。死結可能在任何隔離層級發生,包含 READ COMMITTED。在原生外掛程式中,這類失敗會拋出 StorageSerializationError,含 code: "STORAGE_SERIALIZATION_FAILURE"、retryable: true,以及可選的 sqlState(40001 或 40P01)。請從 emdash 匯入該錯誤類別。
對獨立呼叫請使用有上限的重試與退避。若呼叫位於明確交易內,應重新開始整個交易(包含其中的讀取);在已中止的交易內重試寫入無法成功。請將 { applied: false } 視為未套用的更新,而非序列化錯誤。
沙盒傳輸會保留錯誤名稱與重試中繼資料,但不保證 instanceof StorageSerializationError。跨沙盒邊界處理錯誤時,請檢查 code 與 retryable。
查詢
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 — pagination cursor (if more results exist)
// result.hasMore — boolean
查詢選項
將下列選項傳入 query() 以篩選、排序與分頁:
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // default 50, max 100
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" },
score: { gt: 50, lte: 100 },
}
// Available: gt, gte, lt, lte 清單
where: {
status: { in: ["pending", "approved"] },
} 前綴
where: {
slug: { startsWith: "blog-" },
} 排序
將一個或多個索引欄位設為遞增或遞減:
orderBy: { createdAt: "desc" } // newest first
orderBy: { score: "asc" } // lowest first
分頁
依游標逐頁取得所有相符項目:
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",
});
批次操作
當單一操作需讀取、寫入或刪除多個已知記錄 ID 時,請使用批次方法:
const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);
// Returns 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"]);
在 Cloudflare 沙盒適配器上,putMany() 會依序寫入各項目。若某次寫入失敗,Promise 會被拒絕,先前已寫入的項目仍會保留,後續項目不會再嘗試。
索引設計
依實際查詢模式選擇索引:
| 查詢模式 | 所需索引 |
|---|---|
依 formId 篩選 | "formId" |
依 formId 篩選並依 createdAt 排序 | ["formId", "createdAt"] |
僅依 createdAt 排序 | "createdAt" |
同時依 status 與 formId 篩選 | ["status", "formId"] |
複合索引支援以第一個欄位篩選,並可選擇以第二個欄位排序:
// With index ["formId", "createdAt"]:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } }); // uses index
query({ where: { formId: "contact" } }); // uses index (filter only)
query({ where: { createdAt: { gte: "2024-01-01" } } }); // does NOT use this composite — filter starts at the wrong field
在 indexes 或 uniqueIndexes 中出現的每個欄位名稱,都會通過查詢 API 的索引欄位檢查。複合索引的欄位順序仍決定資料庫能高效執行的查詢形狀。若外掛常在不帶 formId 的情況下依某欄位篩選或排序,請另外新增 "createdAt" 索引。
型別安全
轉型集合存取以在項目形狀上取得 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;
}
const plugin: SandboxedPlugin = {
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(),
});
},
},
},
};
export default plugin;
兩個 import 皆為僅型別,因此沙盒外掛程式在執行時無需依賴 emdash。
Storage、內容與 KV
依資料類型選擇合適機制:
| 使用情境 | Storage |
|---|---|
| 外掛營運資料(日誌、提交、快取) | ctx.storage |
| 使用者可設定的設定 | ctx.settings |
| 外掛內部狀態 | ctx.kv(state: 前綴) |
| 可在管理 UI 編輯的內容 | 網站集合(非外掛 storage) |
若網站編輯者需透過管理 UI 的一般內容編輯器檢視或編輯資料,請改建立網站集合。
集合如何隔離
EmDash 以外掛 ID、集合名稱、記錄 ID、JSON 資料與時間戳記儲存外掛文件。這些命名空間欄位是每個鍵與索引的一部分。外掛僅能取得清單中已宣告集合的存取器,沙盒橋接層會拒絕存取其他集合。
已宣告的欄位會與外掛、集合命名空間一併建立運算式索引。EmDash 會為 SQLite、D1 與 PostgreSQL 產生對應方言的 SQL;外掛程式碼在各資料庫上使用相同的集合 API。
新增索引
外掛更新新增索引時,EmDash 會在下次載入外掛時建立該索引。若既有記錄含重複值,無法建立唯一索引,因此發佈變更前請先檢查並處理重複資料。
更新移除索引時,EmDash 會刪除該索引。仍依該欄位查詢或排序的程式將無法通過驗證。請同時更新程式碼與清單。
索引屬於清單中 storage 信任合約的一部分。新增、移除或變更索引時請遞增外掛版本;若變更會破壞既有查詢或唯一性假設,請使用主要版本號。