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ête | Index 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’usage | Storage |
|---|---|
| Données opérationnelles du plugin (logs, submissions, 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 modifiable dans l’UI admin | Collections 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.