サンドボックスプラグインは、ドキュメントコレクションに独自のデータを保存できます。プラグインディスクリプタでコレクションとインデックスを宣言すると、EmDash が自動的にスキーマを作成します。マイグレーションスクリプトを書く必要はありません。
このページでは、サンドボックス(標準形式)プラグインについて説明します。コレクション API はネイティブプラグインでも同じです。唯一の違いは、ネイティブプラグインは別のディスクリプタではなく、definePlugin() 内で直接 storage を宣言することです。
ディスクリプタでストレージを宣言する
サンドボックスプラグインの場合、storage はディスクリプタに配置されます。これは、サンドボックスエントリではなく、astro.config.mjs によってインポートされるファイルです。ストレージの宣言はビルド時に可視である必要があり、サンドボックスブリッジはプラグインがアクセスを許可されているコレクションを知る必要があります。
import type { PluginDescriptor } from "emdash";
export function formsPlugin(): PluginDescriptor {
return {
id: "forms",
version: "1.0.0",
format: "standard",
entrypoint: "@my-org/plugin-forms/sandbox",
storage: {
submissions: {
indexes: [
"formId",
"status",
"createdAt",
["formId", "createdAt"],
["status", "createdAt"],
],
},
forms: {
indexes: ["slug"],
},
},
};
}
storage の各キーはコレクション名です。indexes 配列は、効率的にクエリできるフィールドをリストします。単一フィールドのインデックスは文字列、複合インデックスは文字列の配列です。
サンドボックスエントリでストレージを使用する
サンドボックスエントリ内では、ctx.storage を介してコレクションにアクセスします。その構造は、ディスクリプタで宣言されたものと一致します。
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx: PluginContext) => {
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 });
},
},
});
ディスクリプタで宣言されていないコレクションにアクセスすると、例外がスローされます。ブリッジは実行時にこれを強制します。
コレクション 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>;
// バッチ操作
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>;
}
クエリ
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
クエリオプション
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // デフォルト 50、最大 1000
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",
});
バッチ操作
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" |
status と formId でフィルタ | "status" と "formId"(別々) |
複合インデックスは、最初のフィールドでフィルタリングし、オプションで2番目のフィールドで並び替えるクエリをサポートします。
// インデックス ["formId", "createdAt"] を使用:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } }); // インデックスを使用
query({ where: { formId: "contact" } }); // インデックスを使用(フィルタのみ)
query({ where: { createdAt: { gte: "2024-01-01" } } }); // この複合インデックスは使用しない — フィルタが間違ったフィールドから始まる
型安全性
アイテムの形状の IntelliSense を取得するために、コレクションアクセスをキャストします。
import type { StorageCollection, PluginContext } from "emdash";
interface Submission {
formId: string;
email: string;
data: Record<string, unknown>;
status: "pending" | "approved" | "spam";
createdAt: string;
}
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx: PluginContext) => {
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(),
});
},
},
});
Storage vs content vs KV
各種データに適切なメカニズムを選択します。
| ユースケース | ストレージ方法 |
|---|---|
| プラグイン操作データ(ログ、送信、キャッシュ) | ctx.storage |
| ユーザー設定可能な設定 | ctx.kv with settings: プレフィックス |
| 内部プラグイン状態 | ctx.kv with state: プレフィックス |
| 管理 UI で編集可能なコンテンツ | サイトコレクション(プラグインストレージではない) |
サイトエディターが管理 UI の通常のコンテンツエディタを通じてデータを表示または編集する必要がある場合は、代わりにサイトコレクションを作成してください。
実装の詳細
プラグインストレージは、単一の名前空間付きテーブルを使用します。
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';
この設計により、マイグレーション不要、SQLite/libSQL/D1 間の移植性、プラグインレベルの分離、すべてのパスでのパラメーター化されたクエリが提供されます。
インデックスの追加
プラグイン更新でインデックスを追加すると、EmDash は次回起動時に自動的に作成します。インデックスの追加は安全で、データマイグレーションは必要ありません。インデックスを削除すると、EmDash はそれを削除し、そのフィールドへのクエリは検証エラーをスローし始めます。これは意図されたシグナルです。