Les plugins sandboxed peuvent stocker leurs propres enregistrements dans des collections de documents. Déclarez chaque collection et ses index dans le manifeste. EmDash crée et met à jour les index correspondants lorsque le plugin se charge.
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 dans definePlugin() plutôt que dans le manifeste.
Déclarer le storage dans le manifeste
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é à utiliser.
{
"slug": "forms",
// ...identité + profil...
"capabilities": ["content:read"],
"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 pouvant être interrogés efficacement — les index à champ unique en tant que chaînes, les index composites en tant que tableaux de chaînes. Voir la référence du manifeste pour les règles complètes.
Les noms de collection commencent par une lettre minuscule et contiennent des lettres minuscules, des chiffres ou des underscores. Les noms de champs d’index commencent par une lettre et contiennent des lettres, des chiffres ou des underscores. Placez un champ unique ou une combinaison de champs dans uniqueIndexes ; un index unique est déjà interrogeable, ne le répétez donc pas dans indexes.
Utiliser le storage en temps d’exécution
Dans src/plugin.ts, accédez aux collections via ctx.storage. La structure reflète ce qui a été déclaré dans le manifeste :
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;
Accéder à une collection qui n’a pas été déclarée dans le manifeste lance une erreur — le pont l’applique au niveau de l’exécution.
API de collection
Chaque collection déclarée fournit les méthodes suivantes de lecture, écriture, lot, requête et comptage :
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>;
}
Écritures conditionnelles
Utilisez getVersioned() et compareAndSet() lorsque des requêtes concurrentes peuvent mettre à jour le même enregistrement. Ces méthodes sont disponibles sur les collections ctx.storage déclarées et sur ctx.kv, pour les plugins natifs et sandboxed. Chaque opération accède à une clé dans le namespace du plugin appelant.
Les méthodes ont le comportement suivant :
| Méthode | Résultat |
|---|---|
getVersioned(key) | La valeur JSON stockée et une révision opaque, ou null lorsque la ligne est absente. Un JSON null stocké renvoie { value: null, revision }. |
compareAndSet(key, null, value) | Crée la ligne uniquement lorsqu’elle est absente. |
compareAndSet(key, revision, value) | Remplace la valeur entière uniquement lorsque la révision stockée correspond. |
compareAndDelete(key, revision) | Supprime la ligne uniquement lorsque la révision stockée correspond. |
Un compareAndSet() réussi renvoie { applied: true, revision }. Une précondition échouée renvoie { applied: false } ; les arguments invalides, les permissions manquantes et les défaillances de base de données rejettent la promesse. compareAndDelete() renvoie { applied: boolean }. Une violation d’index unique non liée est une erreur, même lorsque la clé demandée est absente.
Renvoyez les révisions sans modification et uniquement pour la clé dont elles proviennent. Chaque écriture modifie la révision, y compris les écritures set(), put() et par lot à valeur identique. Supprimer et recréer une clé invalide sa révision précédente.
L’helper suivant ajoute un travail terminé au compteur d’un plugin, en réessayant jusqu’à trois fois lorsqu’une autre requête écrit en premier.
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");
}
En cas de conflit, relisez la valeur et recalculez la modification proposée. Limitez les tentatives. Une réponse perdue peut laisser le résultat d’une écriture inconnu ; ces méthodes ne garantissent pas que les actions externes ou les exécutions de travaux réessayées se produisent exactement une fois.
L’atomicité couvre une seule clé. Lire un élément de contenu et écrire un enregistrement de plugin, ou écrire deux enregistrements de plugin, sont des opérations séparées. Regroupez les champs qui doivent changer ensemble dans une seule valeur. Appliquez les règles métier telles que la propriété du travail ou les limites de quantité lors de la construction de cette valeur.
Les méthodes conditionnelles nécessitent une clé non vide d’au plus 1 024 caractères de chaîne JavaScript et une valeur JSON d’au plus 1 Mio après encodage UTF-8. Une révision doit être une chaîne non vide d’au plus 128 caractères. Les révisions omises sont invalides ; seul un null explicite demande la création. Les méthodes inconditionnelles existantes conservent leur comportement.
Déployez les versions correspondantes du core et de l’adaptateur sandbox et appliquez les migrations de base de données de l’hôte avant d’utiliser ces méthodes. La migration préserve les valeurs stockées et fait en sorte que les écritures des anciens processus hôtes invalident les révisions lors d’un déploiement progressif.
Mises à jour conditionnelles
Utilisez updateIf() pour modifier un document existant uniquement lorsque ses champs stockés correspondent à une condition. La base de données vérifie la condition et applique les modifications en une seule opération atomique. Cette méthode est disponible pour les plugins natifs et les plugins sandboxed sur Cloudflare et Workerd.
Importez les types NumericDelta, UpdateIfArgs et UpdateIfResult avec import type depuis emdash ou emdash/plugin.
L’appel suivant approuve une soumission en attente et incrémente son compteur de révisions dans la même opération :
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 });
}
Un appel réussi renvoie { applied: true, data } avec le document mis à jour complet. Il renvoie { applied: false } si le document est manquant ou si la condition ne correspond pas. Il n’insère jamais de document.
Les arguments ont le comportement suivant :
whereest requis et utilise les mêmes opérateurs que les filtres de requête. Unwhere: {}explicite n’ajoute aucune condition de champ. Les champs de garde n’ont pas besoin d’index de requête déclarés car la mise à jour cible un document par ID.- Un filtre de plage nécessite au moins une borne définie. Les bornes indéfinies sont ignorées lorsqu’une autre borne est définie. Les opérandes numériques utilisés par une garde doivent être finis.
setremplace chaque valeur de champ de niveau supérieur fournie et laisse les autres champs inchangés. Les valeurs doivent être sérialisables en JSON.deltaapplique exactement un{ inc: number }ou{ dec: number }par champ. Chaque opérande doit être un entier sûr ; les opérandes négatifs sont autorisés.- Un champ ne peut pas apparaître à la fois dans
setetdelta. Les entréesundefinedde niveau supérieur dans l’un ou l’autre objet sont ignorées. Au moins un champ défini doit rester.
Les arguments de mise à jour mal formés rejettent la promesse sans modifier le document. L’objet d’arguments, set, delta et chaque opération delta doivent être des objets simples.
Compteurs entiers
Un delta démarre un compteur manquant ou null à 0. Les compteurs existants et leurs résultats doivent être des entiers entre Number.MIN_SAFE_INTEGER et Number.MAX_SAFE_INTEGER. Une chaîne, un booléen, un objet, un tableau, un nombre fractionnaire, un entier non sûr ou un résultat hors plage fait que la mise à jour entière renvoie { applied: false }. Un document stocké qui n’est pas un objet JSON renvoie également { applied: false }. Aucun champ n’est modifié dans les deux cas.
Les deltas peuvent produire des valeurs négatives. Pour garder un compteur non négatif, associez un décrément de n avec une condition where exigeant que le compteur soit au moins n.
Réessayer les échecs de sérialisation
PostgreSQL peut rejeter les écritures concurrentes avec un échec de sérialisation ou un deadlock. Un deadlock peut survenir à n’importe quel niveau d’isolation, y compris READ COMMITTED. Dans les plugins natifs, ces échecs lancent StorageSerializationError avec code: "STORAGE_SERIALIZATION_FAILURE", retryable: true et un sqlState optionnel (40001 ou 40P01). Importez la classe d’erreur depuis emdash.
Utilisez des tentatives limitées avec recul pour un appel autonome. Si l’appel est à l’intérieur d’une transaction explicite, redémarrez l’intégralité de la transaction, y compris ses lectures ; réessayer l’écriture à l’intérieur de la transaction annulée ne peut pas réussir. Traitez { applied: false } comme une mise à jour non appliquée plutôt qu’un échec de sérialisation.
Les transports sandbox préservent le nom de l’erreur et les métadonnées de tentative, mais ne garantissent pas instanceof StorageSerializationError. Vérifiez code et retryable lors du traitement des erreurs à travers une frontière sandbox.
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
Passez ces options à query() pour filtrer, trier et paginer le résultat :
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // default 50, max 100
cursor?: string; // for pagination
}
Opérateurs de clause where
Filtrez par champs indexés en utilisant ces opérateurs :
Correspondance exacte
where: {
status: "pending", // exact string match
count: 5, // exact number match
archived: false, // exact boolean match
} Plage
where: {
createdAt: { gte: "2024-01-01" },
score: { gt: 50, lte: 100 },
}
// Available: gt, gte, lt, lte Dans la liste
where: {
status: { in: ["pending", "approved"] },
} Commence par
where: {
slug: { startsWith: "blog-" },
} Tri
Définissez un ou plusieurs champs indexés en ordre croissant ou décroissant :
orderBy: { createdAt: "desc" } // newest first
orderBy: { score: "asc" } // lowest first
Pagination
Parcourez un curseur pour récupérer 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
Comptez chaque enregistrement dans une collection, ou uniquement les enregistrements correspondant aux champs indexés :
const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({
status: "pending",
});
Opérations par lot
Utilisez les méthodes par lot lorsqu’une opération lit, écrit ou supprime plusieurs ID d’enregistrement connus :
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"]);
Sur l’adaptateur sandbox Cloudflare, putMany() écrit les éléments séquentiellement. Si une écriture échoue, la promesse est rejetée, les écritures antérieures restent validées et les éléments suivants ne sont pas tentés.
Conception des index
Choisissez les index en fonction des motifs de requête réels :
| Motif 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 ensemble | ["status", "formId"] |
Les index composites prennent en charge les requêtes qui filtrent sur le premier champ et trient éventuellement par le second :
// 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
Chaque champ nommé quelque part dans indexes ou uniqueIndexes passe la vérification de champ indexé de l’API de requête. L’ordre d’un index composite détermine toujours quelles formes de requête la base de données peut exécuter efficacement. Ajoutez un index "createdAt" séparé lorsque le plugin filtre ou trie fréquemment par ce champ sans formId.
Sécurité des types
Convertissez l’accès à la collection pour l’IntelliSense sur les formes des é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;
}
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;
Les deux importations sont uniquement des types, donc un plugin sandboxed n’a aucune dépendance d’exécution sur emdash.
Storage vs contenu vs KV
Choisissez le bon mécanisme pour chaque type de données :
| Cas d’utilisation | Storage |
|---|---|
| Données opérationnelles du plugin (logs, soumissions, cache) | ctx.storage |
| Paramètres configurables par l’utilisateur | ctx.settings |
| État interne du plugin | ctx.kv avec le préfixe state: |
| Contenu modifiable dans l’UI d’administration | Collections du site (pas le storage du plugin) |
Si les éditeurs du site doivent voir ou modifier les données dans l’UI d’administration via l’éditeur de contenu standard, créez plutôt une collection du site.
Comment les collections sont isolées
EmDash stocke les documents de plugin avec l’ID du plugin, le nom de la collection, l’ID de l’enregistrement, les données JSON et les horodatages. Ces colonnes de namespace font partie de chaque clé et index. Un plugin ne reçoit des accesseurs que pour les collections de son manifeste, et le pont sandbox rejette l’accès à toute autre collection.
Les champs déclarés deviennent des index d’expression à côté du namespace du plugin et de la collection. EmDash génère le SQL spécifique au dialecte pour SQLite, D1 et PostgreSQL ; le code du plugin utilise la même API de collection sur chaque base de données.
Ajouter des index
Lorsqu’une mise à jour de plugin ajoute un index, EmDash le crée au prochain chargement du plugin. Un index unique ne peut pas être créé tant que les enregistrements existants contiennent des valeurs en double, vérifiez donc et résolvez les doublons avant de publier cette modification.
Lorsqu’une mise à jour supprime un index, EmDash le supprime. Toute requête ou tri qui utilise encore le champ échoue alors à la validation. Mettez à jour le code et le manifeste ensemble.
Les index font partie du contrat de confiance de storage du manifeste. Incrémentez la version du plugin chaque fois que vous en ajoutez, supprimez ou modifiez un, et utilisez une version majeure lorsque le changement casse une requête existante ou une hypothèse d’unicité.