外掛 Storage

本頁內容

外掛可以將自有資料存入文件集合,而無需撰寫資料庫 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"
同時依 statusformId 篩選"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 會刪除它們。對未索引欄位的查詢將因驗證錯誤而失敗。