ストレージ

このページ

サンドボックスプラグインは、独自のレコードをドキュメントコレクションに保存できます。各コレクションとそのインデックスをマニフェストで宣言します。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 の両方で、ネイティブ・サンドボックスのどちらのプラグインからも利用できます。各操作は、呼び出し元プラグインのネームスペース内の 1 キーだけを対象にします。

メソッドの挙動は次のとおりです。

メソッド結果
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 を reject します。compareAndDelete() は { applied: boolean } を返します。要求キーが無くても、無関係な一意インデックス違反はエラーになります。

revision は変更せず、取得したキーに対してだけ渡してください。等値の set()、put()、バッチ書き込みを含め、すべての書き込みで revision は変わります。キーを削除して再作成すると、以前の revision は無効になります。

次のヘルパーは、別リクエストが先に書き込んだ場合に最大 3 回リトライしながら、プラグインのカウンターに完了ジョブを 1 件加算します。

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 回」にすることは保証しません。

原子性が及ぶのは単一キーだけです。コンテンツ項目の読み取りとプラグインレコードの書き込み、または 2 つのプラグインレコードの書き込みは、それぞれ別操作です。一緒に変える必要があるフィールドは 1 つの値にまとめてください。ジョブの所有権や数量上限などのビジネスルールは、その値を組み立てる段階で適用します。

条件付きメソッドでは、空でないキー(JavaScript 文字列で最大 1,024 文字)と、UTF-8 エンコード後最大 1 MiB の JSON 値が必要です。revision は空でない文字列で最大 128 文字です。省略された revision は無効で、作成を要求するのは明示的な null だけです。既存の無条件メソッドの挙動は変わりません。

これらのメソッドを使う前に、対応するコアとサンドボックスアダプターのバージョンをデプロイし、ホスト側のデータベースマイグレーションを適用してください。マイグレーションは保存値を保持し、ローリングデプロイ中に古いホストプロセスからの書き込みが revision を無効化できるようにします。

条件付き更新

保存されているフィールドが条件を満たすときだけ既存ドキュメントを変更するには updateIf() を使います。データベースが条件を評価し、変更を 1 回のアトミック操作で適用します。このメソッドは、Cloudflare および Workerd 上のネイティブプラグインとサンドボックスプラグインで利用できます。

NumericDelta、UpdateIfArgs、UpdateIfResult 型は emdash または emdash/plugin から import type で読み込みます。

次の呼び出しは、保留中の submission を承認し、同じ操作で review 回数を 1 増やします。

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 で 1 ドキュメントを対象にするためです。
  • 範囲フィルターには、定義済みの境界が少なくとも 1 つ必要です。別の境界が定義されていれば、未定義の境界は無視されます。ガードで使う数値オペランドは有限である必要があります。
  • set は指定した各トップレベルフィールドの値を置き換え、他フィールドはそのままにします。値は JSON シリアライズ可能である必要があります。
  • delta はフィールドごとに { inc: number } または { dec: number } のどちらか 1 つだけを適用します。各オペランドは safe integer である必要があり、負数も可です。
  • 同一フィールドを set と delta の両方に書けません。どちらのオブジェクトでも、トップレベルの undefined エントリは無視されます。定義済みフィールドが少なくとも 1 つ残る必要があります。

不正な更新引数は、ドキュメントを変更せず Promise を reject します。引数オブジェクト、set、delta、各 delta 操作は plain object である必要があります。

整数カウンター

delta は、存在しないまたは null のカウンターを 0 から始めます。既存カウンターと結果は、Number.MIN_SAFE_INTEGER から Number.MAX_SAFE_INTEGER までの整数である必要があります。文字列、真偽値、オブジェクト、配列、小数、unsafe integer、範囲外の結果になると、更新全体が { applied: false } になります。JSON オブジェクトでない保存ドキュメントも { applied: false } です。いずれの場合もフィールドは変わりません。

delta で負の値になることはあります。カウンターを非負に保つには、n の decrement と、そのカウンターが少なくとも n であることを要求する where 条件を組み合わせてください。

シリアライゼーション障害のリトライ

PostgreSQL は、シリアライゼーション障害やデッドロックで並行書き込みを拒否することがあります。デッドロックは READ COMMITTED を含む任意の分離レベルで起こり得ます。ネイティブプラグインでは、これらの障害は StorageSerializationError を投げ、code: "STORAGE_SERIALIZATION_FAILURE"、retryable: true、任意の sqlState(40001 または 40P01)が付きます。エラークラスは emdash から import します。

単独の呼び出しでは、バックオフ付きの上限付きリトライを使います。明示的なトランザクション内の呼び出しでは、読み取りを含めトランザクション全体をやり直します。中断されたトランザクション内で書き込みだけリトライしても成功しません。{ 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-" },
}

並べ替え

インデックス付きフィールドを 1 つ以上、昇順または降順に指定します。

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 を 1 操作で読み書き・削除するときはバッチメソッドを使います。

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 は reject され、それより前の書き込みはコミットされたまま、以降の項目は試行されません。

インデックス設計

実際のクエリパターンに合わせてインデックスを選びます。

クエリパターン必要なインデックス
formId でフィルタ"formId"
formId でフィルタ、createdAt で並べ替え["formId", "createdAt"]
createdAt のみで並べ替え"createdAt"
status と formId を同時にフィルタ["status", "formId"]

複合インデックスは、先頭フィールドでのフィルタと、任意で 2 番目フィールドでの並べ替えに対応します。

// 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 だけをよくフィルタ・並べ替えする場合は、別途 "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;

どちらのインポートも型のみであるため、サンドボックスプラグインは実行時に emdash への依存関係を持ちません。

Storage とコンテンツと KV

データの種類に応じて適切な仕組みを選びます。

用途Storage
プラグインの運用データ(ログ、submission、キャッシュ)ctx.storage
ユーザーが設定可能な項目ctx.settings
プラグイン内部の状態ctx.kv の state: プレフィックス
管理 UI で編集するコンテンツサイトコレクション(プラグイン storage ではない)

サイト編集者が管理 UI の通常のコンテンツエディターでデータを見たり編集したりする必要がある場合は、サイトコレクションを作成してください。

コレクションの分離

EmDash はプラグインドキュメントを、プラグイン ID、コレクション名、レコード ID、JSON データ、タイムスタンプとともに保存します。これらのネームスペース用カラムはすべてのキーとインデックスに含まれます。プラグインが受け取るのはマニフェストに載ったコレクション用アクセサだけで、サンドボックスブリッジはそれ以外へのアクセスを拒否します。

宣言したフィールドは、プラグインとコレクションのネームスペースに加えた式インデックスになります。EmDash は SQLite、D1、PostgreSQL 向けに方言別 SQL を生成し、プラグインコードは各データベースで同じコレクション API を使います。

インデックスの追加

プラグイン更新でインデックスを追加すると、EmDash は次回プラグインロード時に作成します。既存レコードに重複がある間は一意インデックスを作れないため、リリース前に重複を確認して解消してください。

更新でインデックスを削除すると、EmDash はそれを drop します。そのフィールドを使うクエリや並べ替えはバリデーションで失敗します。コードとマニフェストはセットで更新してください。

インデックスはマニフェストの storage 信頼契約の一部です。追加・削除・変更のたびにプラグインバージョンを上げ、既存のクエリや一意性の前提を壊す変更ではメジャーバージョンにしてください。