Storage

En esta página

Los plugins en sandbox 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 se carga el plugin.

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

Declarar storage en el manifiesto

En los plugins en sandbox, storage está en emdash-plugin.jsonc. La declaración debe ser visible en tiempo de compilación para que el puente del sandbox sepa a qué colecciones puede acceder el plugin.

{
	"slug": "forms",
	// ...identity + profile...
	"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 enumera los campos que pueden consultarse de forma eficiente: índices de un solo campo como cadenas, índices compuestos como arrays de cadenas. Consulte la referencia del manifiesto para conocer todas las reglas.

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 una combinación de campos en uniqueIndexes; un índice único ya es consultable, así que no lo repita en indexes.

Usar storage en tiempo de ejecución

En src/plugin.ts, acceda a las colecciones mediante ctx.storage. La forma refleja lo declarado 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: "user@example.com",
					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 se declaró en el manifiesto lanza una excepción; el puente lo exige a nivel de tiempo de ejecución.

API de colección

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

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

	// Conditional writes
	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>>;

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

Escrituras condicionales

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

Los métodos se comportan de la siguiente manera:

MétodoResultado
getVersioned(key)El valor JSON almacenado y una revisión opaca, o null cuando la fila no existe. Un JSON null almacenado devuelve { value: null, revision }.
compareAndSet(key, null, value)Crea la fila solo cuando no existe.
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 no válidos, permisos insuficientes 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 no existe.

Pase las revisiones sin modificar y solo para la clave de la que provienen. Cada escritura cambia la revisión, incluidos set(), put() y escrituras por lotes con el mismo valor. Eliminar y volver a crear 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, vuelva a leer el valor y recalcule el cambio propuesto. Limite los reintentos. Una respuesta perdida puede dejar desconocido el resultado de una escritura; estos métodos no garantizan que acciones externas o ejecuciones de trabajos reintentadas ocurran exactamente una vez.

La atomicidad abarca una sola clave. Leer un elemento de contenido y escribir un registro del plugin, o escribir dos registros del plugin, son operaciones separadas. Agrupe en un solo valor los campos que deben cambiar juntos. Aplique reglas de negocio como la 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 tras la codificación UTF-8. Una revisión debe ser una cadena no vacía de como máximo 128 caracteres. Las revisiones omitidas no son válidas; solo un null explícito solicita la creación. Los métodos incondicionales existentes conservan su comportamiento.

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

Actualizaciones condicionales

Use updateIf() para modificar un documento existente solo cuando sus campos almacenados cumplan una condición. La base de datos comprueba la condición y aplica los cambios en una operación atómica. Este método está disponible para plugins nativos y plugins en sandbox 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 no existe o la condición no coincide. Nunca inserta un documento.

Los argumentos se comportan de la siguiente manera:

  • where es obligatorio y usa los mismos operadores que los filtros de consulta. Un where: {} explícito no añade 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 indicado y deja el resto 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; se permiten operandos negativos.
  • 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 mal formados rechazan la promesa sin modificar el documento. El objeto de argumentos, set, delta y cada operación delta deben ser objetos simples.

Contadores enteros

Un delta inicia un contador ausente o null en 0. Los contadores existentes y sus resultados deben ser enteros entre Number.MIN_SAFE_INTEGER y Number.MAX_SAFE_INTEGER. Una cadena, booleano, 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 }. En ninguno de los casos cambian campos.

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

Reintentar fallos de serialización

PostgreSQL puede rechazar escrituras concurrentes con un fallo de serialización o un interbloqueo. Un interbloqueo puede ocurrir en cualquier nivel de aislamiento, incluido 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 acotados con retroceso para una llamada independiente. Si la llamada está dentro de una transacción explícita, reinicie toda la transacción, incluidas sus lecturas; reintentar la escritura dentro de la transacción abortada no puede tener éxito. Trate { applied: false } como una actualización no aplicada, no como un error de serialización.

Los transportes del sandbox conservan el nombre del error y los metadatos de reintento, pero no garantizan instanceof StorageSerializationError. Compruebe code y retryable al manejar errores a través del límite del 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  — pagination cursor (if more results exist)
// 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;     // default 50, max 100
	cursor?: string;    // for pagination
}

Operadores de cláusula where

Filtre por campos indexados con estos operadores:

Coincidencia exacta

where: {
	status: "pending",     // exact string match
	count: 5,              // exact number match
	archived: false,       // exact boolean match
}

Rango

where: {
	createdAt: { gte: "2024-01-01" },
	score: { gt: 50, lte: 100 },
}
// Available: 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" }   // newest first
orderBy: { score: "asc" }        // lowest first

Paginación

Recorra un cursor para obtener todos los elementos que coinciden:

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 todos los registros de una colección, o solo los que coinciden con campos indexados:

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

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

Operaciones por lotes

Use los métodos por lotes cuando una operación lea, escriba o elimine varios IDs de registro conocidos:

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

En el adaptador de sandbox de Cloudflare, putMany() escribe los elementos de forma secuencial. Si una escritura falla, la promesa se rechaza, las escrituras anteriores permanecen confirmadas y no se intentan los elementos posteriores.

Diseño de índices

Elija índices según los 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 admiten consultas que filtran por el primer campo y, opcionalmente, ordenan por el segundo:

// With index ["formId", "createdAt"]:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });  // uses index
query({ where: { formId: "contact" } });                                  // uses index (filter only)
query({ where: { createdAt: { gte: "2024-01-01" } } });                   // does NOT use this composite — filter starts at the wrong field

Todo campo nombrado en cualquier parte de indexes o uniqueIndexes supera la comprobación de campo indexado de la API de consulta. El orden de un índice compuesto sigue determinando qué formas de consulta puede ejecutar la base de datos de forma eficiente. Añada un índice "createdAt" aparte cuando el plugin filtre u ordene con frecuencia por ese campo sin formId.

Seguridad de tipos

Convierta el acceso a la colección para obtener IntelliSense sobre la forma 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: "user@example.com",
					data: { message: "Hello" },
					status: "pending",
					createdAt: new Date().toISOString(),
				});
			},
		},
	},
};

export default plugin;

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

Storage frente a contenido y KV

Elija el mecanismo adecuado para cada tipo de datos:

Caso de usoStorage
Datos operativos del plugin (logs, envíos, caché)ctx.storage
Ajustes configurables por el usuarioctx.settings
Estado interno del pluginctx.kv with state: prefix
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 mediante el editor de contenido habitual, 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, el nombre de la colección, el ID del registro, datos JSON y marcas de tiempo. Esas columnas de espacio de nombres forman parte de cada clave e índice. Un plugin recibe accesores solo para las colecciones de su manifiesto, y el puente del sandbox rechaza el acceso a cualquier otra colección.

Los campos declarados se convierten en índices de expresión junto al espacio de nombres del plugin y de 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.

Añadir índices

Cuando una actualización del plugin añade un índice, EmDash lo crea la próxima vez que se carga el plugin. No se puede crear un índice único mientras los registros existentes contengan valores duplicados; compruebe y resuelva los duplicados antes de publicar ese cambio.

Cuando una actualización elimina un índice, EmDash lo suprime. Cualquier consulta u ordenación que siga usando el campo fallará en la validación. Actualice el código y el manifiesto a la vez.

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