Les plugins peuvent stocker leurs propres données dans des collections de documents sans écrire de migrations de base. Déclarez collections et index dans la définition du plugin : EmDash gère le schéma automatiquement.
Déclarer le stockage
Définissez les collections de stockage dans definePlugin() :
import { definePlugin } from "emdash";
export default definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: [
"formId", // Single-field index
"status",
"createdAt",
["formId", "createdAt"], // Composite index
["status", "createdAt"],
],
},
forms: {
indexes: ["slug"],
},
},
// ...
});
Chaque clé dans storage est un nom de collection. Le tableau indexes liste les champs interrogeables efficacement.
API de collection de stockage
Accédez aux collections via ctx.storage dans les hooks et les routes :
"content:afterSave": async (event, ctx) => {
const { submissions } = ctx.storage;
// CRUD operations
await submissions.put("sub_123", { formId: "contact", email: "[email protected]" });
const item = await submissions.get("sub_123");
const exists = await submissions.exists("sub_123");
await submissions.delete("sub_123");
}
Référence complète de l’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>;
// 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>;
}
Interroger les données
Utilisez query() pour récupérer les documents correspondant aux critères. Les requêtes renvoient des résultats paginés.
const result = await ctx.storage.submissions.query({
where: {
formId: "contact",
status: "pending",
},
orderBy: { createdAt: "desc" },
limit: 20,
});
// result.items - Array of { id, data }
// result.cursor - Pagination cursor (if more results)
// result.hasMore - Boolean indicating more pages
Options de requête
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // Default 50, max 1000
cursor?: string; // For pagination
}
Opérateurs de clause where
Filtrez sur les champs indexés avec 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" }, // Greater than or equal
score: { gt: 50, lte: 100 } // Between (exclusive/inclusive)
}
// Available: gt, gte, lt, lte
In
where: {
status: { in: ["pending", "approved"] }
} Commence par
where: {
slug: { startsWith: "blog-" }
} Tri
Triez sur les champs indexés :
orderBy: {
createdAt: "desc";
} // Newest first
orderBy: {
score: "asc";
} // Lowest first
Pagination
Les résultats sont paginés. Utilisez cursor pour les pages suivantes :
async function getAllSubmissions(ctx: PluginContext) {
const allItems = [];
let cursor: string | undefined;
do {
const result = await ctx.storage.submissions!.query({
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
allItems.push(...result.items);
cursor = result.cursor;
} while (cursor);
return allItems;
}
PaginatedResult
interface PaginatedResult<T> {
items: T[];
cursor?: string; // Pass to next query for more results
hasMore: boolean; // True if more pages exist
}
Compter les documents
Comptez les documents correspondant aux critères :
// Count all
const total = await ctx.storage.submissions!.count();
// Count with filter
const pending = await ctx.storage.submissions!.count({
status: "pending",
});
Opérations par lots
Pour les opérations en masse, utilisez les méthodes par lots :
// Get multiple by ID
const items = await ctx.storage.submissions!.getMany(["sub_1", "sub_2", "sub_3"]);
// Returns Map<string, T>
// Put multiple
await ctx.storage.submissions!.putMany([
{ id: "sub_1", data: { formId: "contact", status: "new" } },
{ id: "sub_2", data: { formId: "contact", status: "new" } },
]);
// Delete multiple
const deletedCount = await ctx.storage.submissions!.deleteMany(["sub_1", "sub_2"]);
Conception des index
Choisissez les index selon vos motifs de requête :
| 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 | "status" et "formId" (séparés) |
Les index composites prennent en charge les requêtes qui filtrent sur le premier champ et trient éventuellement sur le second :
// With index ["formId", "createdAt"]:
// This works:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });
// This also works (filter only):
query({ where: { formId: "contact" } });
// This does NOT use the composite index (wrong field order):
query({ where: { createdAt: { gte: "2024-01-01" } } });
Sécurité des types
Typagez vos collections de stockage pour un meilleur IntelliSense :
interface Submission {
formId: string;
email: string;
data: Record<string, unknown>;
status: "pending" | "approved" | "spam";
createdAt: string;
}
definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: ["formId", "status", "createdAt"],
},
},
hooks: {
"content:afterSave": async (event, ctx) => {
// Cast to typed collection
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
const submission: Submission = {
formId: "contact",
email: "[email protected]",
data: { message: "Hello" },
status: "pending",
createdAt: new Date().toISOString(),
};
await submissions.put(`sub_${Date.now()}`, submission);
},
},
});
Stockage vs contenu vs KV
Utilisez le bon mécanisme selon le cas :
| Cas d’usage | Stockage |
|---|---|
| Données opérationnelles du plugin (logs, soumissions, cache) | ctx.storage |
| Paramètres configurables par l’utilisateur | ctx.kv avec le préfixe settings: |
| État interne du plugin | ctx.kv avec le préfixe state: |
| Contenu éditable dans l’admin | Collections du site (pas le stockage plugin) |
Détails d’implémentation
En coulisses, le stockage plugin utilise une seule table :
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';
Ce modèle apporte :
- Pas de migrations — Le schéma vit dans le code du plugin
- Portabilité — Fonctionne sur D1, libSQL, SQLite
- Isolation — Les plugins n’accèdent qu’à leurs propres données
- Sécurité — Pas d’injection SQL, requêtes validées
Ajouter des index
Lorsque vous ajoutez des index dans une mise à jour du plugin, EmDash les crée automatiquement au prochain démarrage. C’est sans risque : on peut ajouter des index sans migration de données.
Lorsque vous supprimez des index, EmDash les retire. Les requêtes sur des champs non indexés échoueront avec une erreur de validation.