Storage

本頁內容

沙盒外掛程式可以將自己的記錄儲存在文件集合中。在清單中宣告每個集合及其索引。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 信任合約的一部分。新增、移除或變更索引時請遞增外掛版本;若變更會破壞既有查詢或唯一性假設,請使用主要版本號。