Storage

En esta página

Los plugins sandboxed pueden almacenar sus propios registros en colecciones de documentos. Declare cada colección y sus índices en el manifiesto. EmDash crea y actualiza los índices correspondientes cuando el plugin se carga.

Esta página cubre los 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 del manifiesto.

Declarar storage en el manifiesto

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

{
	"slug": "forms",
	// ...identidad + perfil...
	"capabilities": ["content:read"],

	"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 consultarse eficientemente — índices de un solo campo como strings, índices compuestos como arrays de strings. Consulte la referencia del manifiesto para las reglas completas.

Los nombres de colección comienzan con una letra minúscula y contienen letras minúsculas, dígitos o guiones bajos. Los nombres de campos de índice comienzan con una letra y contienen letras, dígitos o guiones bajos. Coloque un campo único o combinación de campos en uniqueIndexes; un índice único ya es consultable, por lo que no lo repita en indexes.

Usar storage en tiempo de ejecución

En src/plugin.ts, acceda a las colecciones a través de ctx.storage. La estructura refleja lo que se declaró en el manifiesto:

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: "[email protected]",
					status: "pending",
					createdAt: new Date().toISOString(),
				});

				const item = await submissions.get("sub_123");
				ctx.log.info("Stored submission", { id: item?.formId });
			},
		},
	},
};

export default plugin;

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

API de colección

Cada colección declarada proporciona los siguientes métodos de lectura, escritura, lote, consulta y conteo:

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

	// Escrituras condicionales
	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>>;

	// Operaciones en lote
	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>;
}

Escrituras condicionales

Use getVersioned() y compareAndSet() cuando las solicitudes concurrentes pueden actualizar el mismo registro. Estos métodos están disponibles en las colecciones declaradas de ctx.storage y en ctx.kv, tanto para plugins nativos como sandboxed. Cada operación accede a una clave en el namespace del plugin que la invoca.

Los métodos tienen el siguiente comportamiento:

MétodoResultado
getVersioned(key)El valor JSON almacenado y una revisión opaca, o null cuando la fila está ausente. Un JSON null almacenado devuelve { value: null, revision }.
compareAndSet(key, null, value)Crea la fila solo cuando está ausente.
compareAndSet(key, revision, value)Reemplaza el valor completo solo cuando la revisión almacenada coincide.
compareAndDelete(key, revision)Elimina la fila solo cuando la revisión almacenada coincide.

Un compareAndSet() exitoso devuelve { applied: true, revision }. Una precondición fallida devuelve { applied: false }; argumentos inválidos, permisos faltantes y fallos de base de datos rechazan la promesa. compareAndDelete() devuelve { applied: boolean }. Una violación de índice único no relacionada es un error, incluso cuando la clave solicitada está ausente.

Pase las revisiones sin cambios y solo para la clave de la que provienen. Cada escritura cambia la revisión, incluyendo set(), put() y escrituras en lote con valores iguales. Eliminar y recrear una clave invalida su revisión anterior.

El siguiente helper agrega un trabajo completado al contador de un plugin, reintentando hasta tres veces cuando otra solicitud escribe primero.

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 caso de conflicto, lea el valor nuevamente y recalcule el cambio propuesto. Mantenga los reintentos limitados. Una respuesta perdida puede dejar desconocido el resultado de una escritura; estos métodos no hacen que las acciones externas o las ejecuciones de trabajos reintentadas ocurran exactamente una vez.

La atomicidad cubre una sola clave. Leer un elemento de contenido y escribir un registro de plugin, o escribir dos registros de plugin, son operaciones separadas. Coloque los campos que deben cambiar juntos en un solo valor. Aplique reglas de negocio como propiedad del trabajo o límites de cantidad al construir ese valor.

Los métodos condicionales requieren una clave no vacía de como máximo 1.024 caracteres de cadena JavaScript y un valor JSON de como máximo 1 MiB después de la codificación UTF-8. Una revisión debe ser una cadena no vacía de como máximo 128 caracteres. Las revisiones omitidas son inválidas; solo un null explícito solicita la creación. Los métodos incondicionales existentes mantienen su comportamiento.

Despliegue las versiones correspondientes del core y el adaptador sandbox y aplique las migraciones de base de datos del host antes de usar estos métodos. La migración preserva los valores almacenados y hace que las escrituras de procesos host más antiguos invaliden las revisiones durante un despliegue progresivo.

Actualizaciones condicionales

Use updateIf() para cambiar un documento existente solo cuando sus campos almacenados coincidan con una condición. La base de datos verifica la condición y aplica los cambios en una operación atómica. Este método está disponible para plugins nativos y plugins sandboxed en Cloudflare y Workerd.

Importe los tipos NumericDelta, UpdateIfArgs y UpdateIfResult con import type desde emdash o emdash/plugin.

La siguiente llamada aprueba un envío pendiente e incrementa su contador de revisiones en la misma operación:

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

Una llamada exitosa devuelve { applied: true, data } con el documento actualizado completo. Devuelve { applied: false } si el documento falta o la condición no coincide. Nunca inserta un documento.

Los argumentos tienen el siguiente comportamiento:

  • where es requerido y usa los mismos operadores que los filtros de consulta. Un where: {} explícito no agrega condiciones de campo. Los campos de guardia no necesitan índices de consulta declarados porque la actualización apunta a un documento por ID.
  • Un filtro de rango necesita al menos un límite definido. Los límites indefinidos se ignoran cuando otro límite está definido. Los operandos numéricos usados por un guard deben ser finitos.
  • set reemplaza cada valor de campo de nivel superior proporcionado y deja los demás campos sin cambios. Los valores deben ser serializables a JSON.
  • delta aplica exactamente un { inc: number } o { dec: number } por campo. Cada operando debe ser un entero seguro; los operandos negativos están permitidos.
  • Un campo no puede aparecer tanto en set como en delta. Las entradas undefined de nivel superior en cualquiera de los objetos se ignoran. Debe quedar al menos un campo definido.

Los argumentos de actualización malformados rechazan la promesa sin cambiar el documento. El objeto de argumentos, set, delta y cada operación delta deben ser objetos simples.

Contadores enteros

Un delta inicia un contador faltante o null en 0. Los contadores existentes y sus resultados deben ser enteros entre Number.MIN_SAFE_INTEGER y Number.MAX_SAFE_INTEGER. Un string, boolean, objeto, array, número fraccionario, entero no seguro o resultado fuera de rango hace que toda la actualización devuelva { applied: false }. Un documento almacenado que no es un objeto JSON también devuelve { applied: false }. No se cambian campos en ninguno de los casos.

Los deltas pueden producir valores negativos. Para mantener un contador no negativo, combine un decremento de n con una condición where que requiera que el contador sea al menos n.

Reintentar fallos de serialización

PostgreSQL puede rechazar escrituras concurrentes con un fallo de serialización o deadlock. Un deadlock puede ocurrir en cualquier nivel de aislamiento, incluyendo READ COMMITTED. En plugins nativos, estos fallos lanzan StorageSerializationError con code: "STORAGE_SERIALIZATION_FAILURE", retryable: true y un sqlState opcional (40001 o 40P01). Importe la clase de error desde emdash.

Use reintentos limitados con retroceso para una llamada independiente. Si la llamada está dentro de una transacción explícita, reinicie toda la transacción, incluyendo sus lecturas; reintentar la escritura dentro de la transacción abortada no puede tener éxito. Maneje { applied: false } como una actualización no aplicada en lugar de un error de serialización.

Los transportes sandbox preservan el nombre del error y los metadatos de reintento, pero no garantizan instanceof StorageSerializationError. Verifique code y retryable al manejar errores a través de un límite sandbox.

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

Pase estas opciones a query() para filtrar, ordenar y paginar el resultado:

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

Operadores de cláusula where

Filtre 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

Establezca uno o más campos indexados en orden ascendente o descendente:

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

Paginación

Agote 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

Cuente cada registro en una colección, o solo registros que coincidan con campos indexados:

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

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

Operaciones en lote

Use los métodos de lote cuando una operación lee, escribe o elimina varios IDs de registro conocidos:

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

Elija í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 juntos["status", "formId"]

Los índices compuestos soportan consultas que filtran por 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

Cada campo nombrado en cualquier parte de indexes o uniqueIndexes pasa la verificación de campo indexado de la API de consulta. El orden de un índice compuesto aún determina qué formas de consulta la base de datos puede ejecutar eficientemente. Agregue un índice separado "createdAt" cuando el plugin frecuentemente filtra u ordena por ese campo sin formId.

Seguridad de tipos

Convierta el acceso a la colección para IntelliSense en las formas de los 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;
}

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: "[email protected]",
					data: { message: "Hello" },
					status: "pending",
					createdAt: new Date().toISOString(),
				});
			},
		},
	},
};

export default plugin;

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

Storage vs contenido vs KV

Elija el mecanismo correcto para cada tipo de datos:

Caso de usoStorage
Datos operativos del plugin (logs, envíos, 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 administraciónColecciones del sitio (no storage del plugin)

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

Cómo se aíslan las colecciones

EmDash almacena documentos de plugin con el ID del plugin, nombre de colección, ID de registro, datos JSON y marcas de tiempo. Esas columnas de namespace son parte de cada clave e índice. Un plugin recibe accesores solo para las colecciones en su manifiesto, y el puente sandbox rechaza el acceso a cualquier otra colección.

Los campos declarados se convierten en índices de expresión junto con el namespace del plugin y la colección. EmDash genera el SQL específico del dialecto para SQLite, D1 y PostgreSQL; el código del plugin usa la misma API de colección en cada base de datos.

Agregar índices

Cuando una actualización de plugin agrega un índice, EmDash lo crea la próxima vez que el plugin se carga. Un índice único no puede crearse mientras los registros existentes contengan valores duplicados, así que verifique y resuelva los duplicados antes de publicar ese cambio.

Cuando una actualización elimina un índice, EmDash lo elimina. Cualquier consulta u ordenamiento que aún use el campo falla en la validación. Actualice el código y el manifiesto juntos.

Los índices son parte del contrato de confianza de storage del manifiesto. Incremente la versión del plugin cada vez que agregue, elimine o cambie uno, y use una versión mayor cuando el cambio rompa una consulta existente o una suposición de unicidad.