Almacenamiento

En esta página

Los plugins en sandbox pueden almacenar sus propios datos en colecciones de documentos. Declaras las colecciones e índices en el descriptor del plugin, y EmDash crea el esquema automáticamente — sin migraciones que escribir.

Esta página cubre plugins en sandbox (formato estándar). La API de colección es idéntica para plugins nativos; la única diferencia es que los plugins nativos declaran storage directamente dentro de definePlugin() en lugar de en un descriptor separado.

Declarar almacenamiento en el descriptor

Para plugins en sandbox, storage reside en el descriptor — el archivo importado por astro.config.mjs, no la entrada del sandbox. Las declaraciones de almacenamiento deben ser visibles en tiempo de compilación para que el puente del sandbox sepa qué colecciones el plugin tiene permitido tocar.

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"],
			},
		},
	};
}

Cada clave en storage es un nombre de colección. El array indexes lista los campos que pueden consultarse eficientemente — índices de un solo campo como cadenas, índices compuestos como arrays de cadenas.

Usar almacenamiento en la entrada del sandbox

Dentro de la entrada del sandbox, accede a las colecciones a través de ctx.storage. La forma refleja lo que fue declarado en el descriptor:

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 });
		},
	},
});

Acceder a una colección que no fue declarada en el descriptor lanza una excepción — el puente hace cumplir esto a nivel de tiempo de ejecución.

API de colección

interface StorageCollection<T = unknown> {
	// CRUD básico
	get(id: string): Promise<T | null>;
	put(id: string, data: T): Promise<void>;
	delete(id: string): Promise<boolean>;
	exists(id: string): Promise<boolean>;

	// Operaciones por lotes
	getMany(ids: string[]): Promise<Map<string, T>>;
	putMany(items: Array<{ id: string; data: T }>): Promise<void>;
	deleteMany(ids: string[]): Promise<number>;

	// Consulta (solo campos indexados)
	query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
	count(where?: WhereClause): Promise<number>;
}

Consultas

query() devuelve resultados paginados filtrados por campos indexados:

const result = await ctx.storage.submissions.query({
	where: {
		formId: "contact",
		status: "pending",
	},
	orderBy: { createdAt: "desc" },
	limit: 20,
});

// result.items   — Array<{ id, data }>
// result.cursor  — cursor de paginación (si existen más resultados)
// result.hasMore — boolean

Opciones de consulta

interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number;     // predeterminado 50, máx 1000
	cursor?: string;    // para paginación
}

Operadores de cláusula Where

Filtra por campos indexados usando estos operadores:

Coincidencia exacta

where: {
	status: "pending",     // coincidencia exacta de cadena
	count: 5,              // coincidencia exacta de número
	archived: false,       // coincidencia exacta de booleano
}

Rango

where: {
	createdAt: { gte: "2024-01-01" },
	score: { gt: 50, lte: 100 },
}
// Disponibles: gt, gte, lt, lte

En lista

where: {
	status: { in: ["pending", "approved"] },
}

Comienza con

where: {
	slug: { startsWith: "blog-" },
}

Ordenamiento

orderBy: { createdAt: "desc" }   // más recientes primero
orderBy: { score: "asc" }        // más bajos primero

Paginación

Agota un cursor para recorrer todos los elementos coincidentes:

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;
}

Conteo

const total = await ctx.storage.submissions.count();

const pending = await ctx.storage.submissions.count({
	status: "pending",
});

Operaciones por lotes

const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);
// Devuelve 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"]);

Diseño de índices

Elige índices basados en patrones de consulta reales:

Patrón de consultaÍndice necesario
Filtrar por formId"formId"
Filtrar por formId, ordenar por createdAt["formId", "createdAt"]
Ordenar solo por createdAt"createdAt"
Filtrar por status y formId"status" y "formId" (separados)

Los índices compuestos soportan consultas que filtran en el primer campo y opcionalmente ordenan por el segundo:

// Con índice ["formId", "createdAt"]:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });  // usa índice
query({ where: { formId: "contact" } });                                  // usa índice (solo filtro)
query({ where: { createdAt: { gte: "2024-01-01" } } });                   // NO usa este compuesto — el filtro comienza en el campo incorrecto

Seguridad de tipos

Convierte el acceso a la colección para IntelliSense en formas de elementos:

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

Elige el mecanismo correcto para cada tipo de dato:

Caso de usoAlmacenamiento
Datos operativos del plugin (logs, envíos, caché)ctx.storage
Configuraciones ajustables por el usuarioctx.kv con prefijo settings:
Estado interno del pluginctx.kv con prefijo state:
Contenido editable en la interfaz de administraciónColecciones del sitio (no almacenamiento de plugin)

Si los editores del sitio necesitan ver o editar los datos en la interfaz de administración a través del editor de contenido regular, crea una colección del sitio en su lugar.

Detalles de implementación

El almacenamiento de plugins usa una sola tabla con espacio de nombres:

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 crea índices de expresión para los campos declarados:

CREATE INDEX idx_forms_submissions_formId
	ON _plugin_storage(json_extract(data, '$.formId'))
	WHERE plugin_id = 'forms' AND collection = 'submissions';

Este diseño te da sin migraciones, portabilidad entre SQLite/libSQL/D1, aislamiento a nivel de plugin y consultas parametrizadas en cada ruta.

Agregar índices

Cuando agregas un índice en una actualización de plugin, EmDash lo crea automáticamente en el próximo inicio. Agregar un índice es seguro y no requiere migración de datos. Cuando eliminas un índice, EmDash lo elimina — y las consultas en ese campo comienzan a fallar con un error de validación, que es la señal prevista.