Les plugins sandboxés peuvent stocker leurs propres données dans des collections de documents. Vous déclarez les collections et les index sur le descripteur du plugin, et EmDash crée le schéma automatiquement — pas de migrations à écrire.
Cette page couvre les plugins sandboxés (format standard). L’API de collection est identique pour les plugins natifs ; la seule différence est que les plugins natifs déclarent storage directement à l’intérieur de definePlugin() plutôt que sur un descripteur séparé.
Déclarer le stockage sur le descripteur
Pour les plugins sandboxés, storage réside sur le descripteur — le fichier importé par astro.config.mjs, pas l’entrée du sandbox. Les déclarations de stockage doivent être visibles au moment de la compilation pour que le pont sandbox sache quelles collections le plugin est autorisé à toucher.
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"],
},
},
};
}
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 chaînes, les index composites en tant que tableaux de chaînes.
Utiliser le stockage dans l’entrée du sandbox
À l’intérieur de l’entrée du sandbox, accédez aux collections via ctx.storage. La forme reflète ce qui a été déclaré sur le descripteur :
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 });
},
},
});
L’accès à une collection qui n’a pas été déclarée sur le descripteur lève une exception — le pont applique cela 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 lot
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ête
query() retourne 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 chaîne
count: 5, // correspondance exacte de nombre
archived: false, // correspondance exacte de booléen
} 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" } // le plus récent en premier
orderBy: { score: "asc" } // le 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 lot
const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);
// Retourne 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ête | Index nécessaire |
|---|---|
Filtrer par formId | "formId" |
Filtrer par formId, trier par createdAt | ["formId", "createdAt"] |
Trier par createdAt uniquement | "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 ce 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 { 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
Choisissez le bon mécanisme pour chaque type de données :
| Cas d’usage | Stockage |
|---|---|
| Données opérationnelles du plugin (logs, soumissions, cache) | ctx.storage |
| Paramètres configurables par l’utilisateur | ctx.kv avec préfixe settings: |
| État interne du plugin | ctx.kv avec préfixe state: |
| Contenu éditable dans l’interface d’administration | Collections de site (pas de stockage de plugin) |
Si les éditeurs de site doivent visualiser ou modifier les données dans l’interface d’administration via l’éditeur de contenu standard, créez plutôt une collection de site.
Détails d’implémentation
Le stockage de plugin utilise une seule table avec espace de noms :
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 pas de migrations, portabilité entre SQLite/libSQL/D1, isolation au niveau du plugin et 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, ce qui est le signal prévu.