Ir al contenido

Almacenamiento de complementos

Los plugins pueden almacenar sus propios datos en colecciones de documentos sin escribir migraciones de base de datos. Declara colecciones e índices en la definición de tu plugin, y EmDash maneja el esquema automáticamente.

Define las colecciones de almacenamiento en definePlugin():

import { definePlugin } from "emdash";
export default definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: [
"formId", // Índice de un solo campo
"status",
"createdAt",
["formId", "createdAt"], // Índice compuesto
["status", "createdAt"],
],
},
forms: {
indexes: ["slug"],
},
},
// ...
});

Cada clave en storage es un nombre de colección. El array indexes lista los campos que se pueden consultar de manera eficiente.

Accede a las colecciones a través de ctx.storage en hooks y rutas:

"content:afterSave": async (event, ctx) => {
const { submissions } = ctx.storage;
// Operaciones CRUD
await submissions.put("sub_123", { formId: "contact", email: "user@example.com" });
const item = await submissions.get("sub_123");
const exists = await submissions.exists("sub_123");
await submissions.delete("sub_123");
}
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>;
}

Usa query() para recuperar documentos que coincidan con los criterios. Las consultas devuelven resultados paginados.

const result = await ctx.storage.submissions.query({
where: {
formId: "contact",
status: "pending",
},
orderBy: { createdAt: "desc" },
limit: 20,
});
// result.items - Array de { id, data }
// result.cursor - Cursor de paginación (si hay más resultados)
// result.hasMore - Booleano que indica si hay más páginas
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // Por defecto: 50, máximo: 1000
cursor?: string; // Para la paginación
}

Filtra por campos indexados usando estos operadores:

where: {
status: "pending", // Coincidencia exacta de cadena
count: 5, // Coincidencia exacta numérica
archived: false // Coincidencia exacta booleana
}

Ordena resultados por campos indexados:

orderBy: { createdAt: "desc" } // Primero lo más reciente
orderBy: { score: "asc" } // Primero la puntuación más baja

Los resultados están paginados. Usa cursor para obtener páginas adicionales:

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;
}
interface PaginatedResult<T> {
items: T[];
cursor?: string; // Pásalo a la siguiente consulta para obtener más resultados
hasMore: boolean; // Indica si existen más páginas
}

Cuenta documentos que coincidan con los criterios:

// Contar todo
const total = await ctx.storage.submissions!.count();
// Contar con filtro
const pending = await ctx.storage.submissions!.count({
status: "pending",
});

Para operaciones masivas, usa los métodos por lotes:

// Obtener varios por ID
const items = await ctx.storage.submissions!.getMany(["sub_1", "sub_2", "sub_3"]);
// Devuelve un Map<string, T>
// Insertar múltiples
await ctx.storage.submissions!.putMany([
{ id: "sub_1", data: { formId: "contact", status: "new" } },
{ id: "sub_2", data: { formId: "contact", status: "new" } },
]);
// Eliminar múltiples
const deletedCount = await ctx.storage.submissions!.deleteMany(["sub_1", "sub_2"]);

Elige índices basándote en tus patrones de consulta:

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 admiten consultas que filtran por el primer campo y opcionalmente ordenan por el segundo:

// With index ["formId", "createdAt"]:
// Esto funciona:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });
// Esto también funciona (solo filtro):
query({ where: { formId: "contact" } });
// Esto NO usa el índice compuesto (orden de campos incorrecto):
query({ where: { createdAt: { gte: "2024-01-01" } } });

Tipa tus colecciones de almacenamiento para un mejor 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) => {
// Convertir a colección tipada
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
const submission: Submission = {
formId: "contact",
email: "user@example.com",
data: { message: "Hello" },
status: "pending",
createdAt: new Date().toISOString(),
};
await submissions.put(`sub_${Date.now()}`, submission);
},
},
});

Usa el mecanismo de almacenamiento correcto para tu caso de uso:

Caso de usoAlmacenamiento
Datos operativos del plugin (logs, envíos, caché)ctx.storage
Configuraciones editables 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 almacenamiento del plugin)

Internamente, el almacenamiento del plugin usa una única tabla de base de datos:

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 proporciona:

  • Sin migraciones — El esquema vive en el código del plugin
  • Portabilidad — Funciona en D1, libSQL, SQLite
  • Aislamiento — Los plugins solo pueden acceder a sus propios datos
  • Seguridad — Sin inyección SQL, consultas validadas

Cuando agregas índices en una actualización del plugin, EmDash los crea automáticamente en el próximo inicio. Esto es seguro: los índices se pueden agregar sin migración de datos.

Cuando eliminas índices, EmDash los descarta. Las consultas sobre campos no indexados fallarán con un error de validación.