Storage

本頁內容

沙盒外掛程式可以將自己的記錄儲存在文件集合中。在清單中宣告每個集合及其索引。EmDash 在外掛程式載入時建立並更新對應的索引。

本頁介紹沙盒外掛程式。集合 API 對原生外掛程式也是相同的;唯一的差異是原生外掛程式在 definePlugin() 內宣告 storage,而不是在清單中。

在清單中宣告 storage

對於沙盒外掛程式,storage 位於 emdash-plugin.jsonc 中。宣告必須在建置時可見,以便沙盒橋接層知道外掛程式可以存取哪些集合。

{
	"slug": "forms",
	// ...身分 + 設定檔...
	"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: "[email protected]",
					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> {
	// 基本 CRUD
	get(id: string): Promise<T | null>;
	put(id: string, data: T): Promise<void>;
	delete(id: string): Promise<boolean>;
	exists(id: string): Promise<boolean>;

	// 條件寫入
	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>>;

	// 批次操作
	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>;
}

條件寫入

當並行請求可能更新同一筆記錄時,使用 getVersioned()compareAndSet()。這些方法在宣告的 ctx.storage 集合和 ctx.kv 上都可用,適用於原生和沙盒外掛程式。每個操作存取呼叫外掛程式命名空間內的一個鍵。

方法的行為如下:

方法結果
getVersioned(key)儲存的 JSON 值和一個不透明的修訂版本,或列不存在時為 null。儲存的 JSON null 回傳 { value: null, revision }
compareAndSet(key, null, value)僅在列不存在時建立。
compareAndSet(key, revision, value)僅在儲存的修訂版本相符時替換整個值。
compareAndDelete(key, revision)僅在儲存的修訂版本相符時刪除列。

成功的 compareAndSet() 回傳 { applied: true, revision }。失敗的前置條件回傳 { applied: false };無效引數、缺少權限和資料庫故障會拒絕 Promise。compareAndDelete() 回傳 { applied: boolean }。不相關的唯一索引違規是一個錯誤,即使請求的鍵不存在。

將修訂版本原樣傳回,並且僅用於它們所來自的鍵。每次寫入都會變更修訂版本,包括等值的 set()put() 和批次寫入。刪除和重新建立一個鍵會使其先前的修訂版本無效。

以下輔助函式向外掛程式的計數器新增一個已完成的任務,當另一個請求先寫入時最多重試三次。

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 字串字元的非空鍵和一個 UTF-8 編碼後最多 1 MiB 的 JSON 值。修訂版本必須是最多 128 個字元的非空字串。省略的修訂版本無效;只有明確的 null 才請求建立。現有的無條件方法保持其行為不變。

在使用這些方法之前,部署相符的核心和沙盒適配器版本並套用主機資料庫遷移。遷移會保留儲存的值,並使滾動部署期間較舊主機程序的寫入使修訂版本無效。

條件更新

使用 updateIf() 僅在儲存欄位與條件相符時變更現有文件。資料庫檢查條件並在一個原子操作中套用變更。此方法適用於原生外掛程式和 Cloudflare 及 Workerd 上的沙盒外掛程式。

使用 import typeemdashemdash/plugin 匯入 NumericDeltaUpdateIfArgsUpdateIfResult 型別。

以下呼叫批准一個待處理的提交並在同一操作中增加其審查計數:

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 },包含完整的更新後文件。如果文件不存在或條件不相符,則回傳 { applied: false }。它永遠不會插入文件。

引數的行為如下:

  • where 是必需的,使用與查詢篩選器相同的運算子。明確的 where: {} 不新增欄位條件。守衛欄位不需要宣告的查詢索引,因為更新透過 ID 定位單一文件。
  • 範圍篩選器至少需要一個已定義的邊界。當另一個邊界已定義時,未定義的邊界將被忽略。守衛使用的數值運算元必須是有限的。
  • set 替換每個提供的頂層欄位值,保持其他欄位不變。值必須是 JSON 可序列化的。
  • delta 對每個欄位恰好套用一個 { inc: number }{ dec: number }。每個運算元必須是安全整數;允許負運算元。
  • 一個欄位不能同時出現在 setdelta 中。任一物件中的頂層 undefined 項目將被忽略。至少必須保留一個已定義的欄位。

格式錯誤的更新引數會拒絕 Promise 而不變更文件。引數物件、setdelta 和每個 delta 操作必須是普通物件。

整數計數器

Delta 將缺失或 null 的計數器從 0 開始。現有計數器及其結果必須是 Number.MIN_SAFE_INTEGERNumber.MAX_SAFE_INTEGER 之間的整數。字串、布林值、物件、陣列、小數、不安全整數或超出範圍的結果會導致整個更新回傳 { applied: false }。不是 JSON 物件的儲存文件也回傳 { applied: false }。兩種情況下都不會變更任何欄位。

Delta 可以產生負值。要保持計數器非負,將 n 的遞減與要求計數器至少為 nwhere 條件配對。

重試序列化失敗

PostgreSQL 可能因序列化失敗或死結而拒絕並行寫入。死結可以在任何隔離層級發生,包括 READ COMMITTED。在原生外掛程式中,這些失敗拋出 StorageSerializationError,帶有 code: "STORAGE_SERIALIZATION_FAILURE"retryable: true 和可選的 sqlState4000140P01)。從 emdash 匯入錯誤類別。

對獨立呼叫使用帶有退避的有限重試。如果呼叫在明確交易內,請重新啟動整個交易,包括其讀取;在中止的交易內重試寫入無法成功。將 { applied: false } 作為未套用的更新而不是序列化錯誤處理。

沙盒傳輸保留錯誤名稱和重試中繼資料,但不保證 instanceof StorageSerializationError。在跨沙盒邊界處理錯誤時檢查 coderetryable

查詢

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

查詢選項

將這些選項傳遞給 query() 以篩選、排序和分頁結果:

interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number;     // 預設 50,最大 100
	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",
});

批次操作

當一個操作讀取、寫入或刪除多個已知的記錄 ID 時,使用批次方法:

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" } } });                   // 不使用此複合索引 — 篩選從錯誤的欄位開始

indexesuniqueIndexes 中任何地方命名的每個欄位都通過查詢 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: "[email protected]",
					data: { message: "Hello" },
					status: "pending",
					createdAt: new Date().toISOString(),
				});
			},
		},
	},
};

export default plugin;

兩個匯入都是僅型別的,因此沙盒外掛程式沒有對 emdash 的執行時依賴。

Storage vs 內容 vs KV

為每種資料選擇正確的機制:

使用案例Storage
外掛程式營運資料(日誌、提交、快取)ctx.storage
使用者可設定的設定ctx.kvsettings: 前綴
外掛程式內部狀態ctx.kvstate: 前綴
管理 UI 中可編輯的內容網站集合(不是外掛程式 storage)

如果網站編輯人員需要透過管理 UI 中的常規內容編輯器檢視或編輯資料,請改為建立網站集合。

集合如何隔離

EmDash 使用外掛程式 ID、集合名稱、記錄 ID、JSON 資料和時間戳記儲存外掛程式文件。這些命名空間欄是每個鍵和索引的一部分。外掛程式僅接收其清單中集合的存取器,沙盒橋接層拒絕對任何其他集合的存取。

宣告的欄位與外掛程式和集合命名空間一起成為運算式索引。EmDash 為 SQLite、D1 和 PostgreSQL 產生方言特定的 SQL;外掛程式程式碼在每個資料庫上使用相同的集合 API。

新增索引

當外掛程式更新新增索引時,EmDash 在下次載入外掛程式時建立它。當現有記錄包含重複值時,無法建立唯一索引,因此在發佈該變更之前檢查並解決重複項。

當更新移除索引時,EmDash 會刪除它。任何仍使用該欄位的查詢或排序將在驗證時失敗。同時更新程式碼和清單。

索引是清單的 storage 信任合約的一部分。每當新增、移除或變更索引時,請增加外掛程式版本號,當變更破壞現有查詢或唯一性假設時使用主要版本號。