Stockage des plugins

Sur cette page

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ê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 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’usageStockage
Données opérationnelles du plugin (logs, soumissions, cache)ctx.storage
Paramètres configurables par l’utilisateurctx.kv avec le préfixe settings:
État interne du pluginctx.kv avec le préfixe state:
Contenu éditable dans l’adminCollections 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.