Storage

Sur cette page

Les plugins sandboxed peuvent stocker leurs propres données dans des collections de documents. Vous déclarez les collections et les index dans le manifest, et EmDash crée le schéma automatiquement — aucune migration à écrire.

Cette page couvre les plugins sandboxed. L’API de collection est identique pour les plugins natifs ; la seule différence est que les plugins natifs déclarent storage à l’intérieur de definePlugin() plutôt que dans le manifest.

Déclarer le storage dans le manifest

Pour les plugins sandboxed, storage se trouve dans emdash-plugin.jsonc. La déclaration doit être visible au moment de la compilation pour que le pont sandbox sache quelles collections le plugin est autorisé à toucher.

{
	"slug": "forms",
	// ...identity + profile...

	"storage": {
		"submissions": {
			"indexes": [
				"formId",
				"status",
				"createdAt",
				["formId", "createdAt"],
				["status", "createdAt"]
			]
		},
		"forms": {
			"indexes": ["slug"]
		}
	}
}

Chaque clé dans storage est un nom de collection. Le tableau indexes liste les champs qui peuvent être interrogés efficacement — les index à un seul champ en tant que strings, les index composites en tant que tableaux de strings. Voir la référence du manifest pour les règles complètes.

Utilisation du storage à l’exécution

Dans src/plugin.ts, accédez aux collections via ctx.storage. La forme reflète ce qui a été déclaré dans le manifest :

import type { SandboxedPlugin } from "emdash/plugin";

export default {
	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 });
			},
		},
	},
} satisfies SandboxedPlugin;

L’accès à une collection qui n’a pas été déclarée dans le manifest lance une erreur — le pont l’applique au niveau de l’exécution.

API de Collection

interface StorageCollection<T = unknown> {
	// CRUD de base
	get(id: string): Promise<T | null>;
	put(id: string, data: T): Promise<void>;
	delete(id: string): Promise<boolean>;
	exists(id: string): Promise<boolean>;

	// Opérations par lots
	getMany(ids: string[]): Promise<Map<string, T>>;
	putMany(items: Array<{ id: string; data: T }>): Promise<void>;
	deleteMany(ids: string[]): Promise<number>;

	// Requête (champs indexés uniquement)
	query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
	count(where?: WhereClause): Promise<number>;
}

Requêtes

query() renvoie des résultats paginés filtrés par champs indexés :

const result = await ctx.storage.submissions.query({
	where: {
		formId: "contact",
		status: "pending",
	},
	orderBy: { createdAt: "desc" },
	limit: 20,
});

// result.items   — Array<{ id, data }>
// result.cursor  — curseur de pagination (si plus de résultats existent)
// result.hasMore — boolean

Options de requête

interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number;     // par défaut 50, max 1000
	cursor?: string;    // pour la pagination
}

Opérateurs de clause where

Filtrez par champs indexés en utilisant ces opérateurs :

Correspondance exacte

where: {
	status: "pending",     // correspondance exacte de string
	count: 5,              // correspondance exacte de nombre
	archived: false,       // correspondance exacte de boolean
}

Plage

where: {
	createdAt: { gte: "2024-01-01" },
	score: { gt: 50, lte: 100 },
}
// Disponibles : gt, gte, lt, lte

Dans la liste

where: {
	status: { in: ["pending", "approved"] },
}

Commence par

where: {
	slug: { startsWith: "blog-" },
}

Tri

orderBy: { createdAt: "desc" }   // plus récents en premier
orderBy: { score: "asc" }        // plus bas en premier

Pagination

Épuisez un curseur pour parcourir tous les éléments correspondants :

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;
}

Comptage

const total = await ctx.storage.submissions.count();

const pending = await ctx.storage.submissions.count({
	status: "pending",
});

Opérations par lots

const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);
// Renvoie 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"]);

Conception d’index

Choisissez les index en fonction des modèles de requête réels :

Modèle de requêteIndex nécessaire
Filtrer par formId"formId"
Filtrer par formId, trier par createdAt["formId", "createdAt"]
Trier uniquement par createdAt"createdAt"
Filtrer par status et formId"status" et "formId" (séparés)

Les index composites prennent en charge les requêtes qui filtrent sur le premier champ et trient éventuellement par le second :

// Avec l'index ["formId", "createdAt"] :
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });  // utilise l'index
query({ where: { formId: "contact" } });                                  // utilise l'index (filtre uniquement)
query({ where: { createdAt: { gte: "2024-01-01" } } });                   // N'utilise PAS cet index composite — le filtre commence au mauvais champ

Sécurité des types

Castez l’accès à la collection pour IntelliSense sur les formes d’éléments :

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;
}

export default {
	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(),
				});
			},
		},
	},
} satisfies SandboxedPlugin;

Les deux imports sont uniquement de types, donc un plugin sandboxed n’a aucune dépendance d’exécution sur emdash.

Storage vs content vs KV

Choisissez le bon mécanisme pour chaque type de données :

Cas d’usageStorage
Données opérationnelles du plugin (logs, submissions, cache)ctx.storage
Paramètres configurables par l’utilisateurctx.kv avec préfixe settings:
État interne du pluginctx.kv avec préfixe state:
Contenu modifiable dans l’UI adminCollections du site (pas le storage du plugin)

Si les éditeurs du site doivent visualiser ou modifier les données dans l’UI admin via l’éditeur de contenu régulier, créez une collection de site à la place.

Détails d’implémentation

Le storage du plugin utilise une seule table avec namespace :

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 crée des index d’expression pour les champs déclarés :

CREATE INDEX idx_forms_submissions_formId
	ON _plugin_storage(json_extract(data, '$.formId'))
	WHERE plugin_id = 'forms' AND collection = 'submissions';

Cette conception vous donne aucune migration, la portabilité sur SQLite/libSQL/D1, l’isolation au niveau du plugin et des requêtes paramétrées sur chaque chemin.

Ajout d’index

Lorsque vous ajoutez un index dans une mise à jour de plugin, EmDash le crée automatiquement au prochain démarrage. L’ajout d’un index est sûr et ne nécessite aucune migration de données. Lorsque vous supprimez un index, EmDash le supprime — et les requêtes sur ce champ commencent à échouer avec une erreur de validation, qui est le signal prévu.