Storage

En esta página

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

Esta página cubre plugins sandboxed. La API de colecciones es idéntica para plugins nativos; la única diferencia es que los plugins nativos declaran storage dentro de definePlugin() en lugar de en el manifest.

Declarar storage en el manifest

Para plugins sandboxed, storage está en emdash-plugin.jsonc. La declaración debe ser visible en tiempo de compilación para que el puente de sandbox sepa qué colecciones puede tocar el plugin.

{
	"slug": "forms",
	// ...identity + profile...

	"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 campos que pueden ser consultados eficientemente — índices de un solo campo como strings, índices compuestos como arrays de strings. Consulta la referencia del manifest para las reglas completas.

Usar storage en tiempo de ejecución

En src/plugin.ts, accede a las colecciones a través de ctx.storage. La forma refleja lo que fue declarado en el 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;

Acceder a una colección que no fue declarada en el manifest lanza un error — el puente lo hace cumplir a nivel de tiempo de ejecución.

API de Colecciones

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;     // por defecto 50, máximo 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 string
	count: 5,              // coincidencia exacta de número
	archived: false,       // coincidencia exacta de boolean
}

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 equivocado

Seguridad de tipos

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

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;

Ambas importaciones son solo de tipos, por lo que un plugin sandboxed no tiene dependencia de tiempo de ejecución en emdash.

Storage vs content vs KV

Elige el mecanismo correcto para cada tipo de datos:

Caso de usoStorage
Datos operacionales del plugin (logs, submissions, caché)ctx.storage
Configuraciones configurables por el usuarioctx.kv con prefijo settings:
Estado interno del pluginctx.kv con prefijo state:
Contenido editable en la UI de adminColecciones del sitio (no storage del plugin)

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

Detalles de implementación

El storage del plugin usa una sola tabla con 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 crea índices de expresión para 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 a través de SQLite/libSQL/D1, aislamiento a nivel de plugin, y consultas parametrizadas en cada ruta.

Agregar índices

Cuando agregas un índice en una actualización del 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.