Armazenamento

Nesta página

Plugins em sandbox podem armazenar seus próprios dados em coleções de documentos. Você declara coleções e índices no descritor do plugin, e o EmDash cria o schema automaticamente — sem migrações para escrever.

Esta página cobre plugins em sandbox (formato padrão). A API de coleção é idêntica para plugins nativos; a única diferença é que plugins nativos declaram storage diretamente dentro de definePlugin() em vez de em um descritor separado.

Declarando armazenamento no descritor

Para plugins em sandbox, storage fica no descritor — o arquivo importado por astro.config.mjs, não a entrada do sandbox. As declarações de armazenamento precisam estar visíveis no momento da compilação para que a ponte do sandbox saiba quais coleções o plugin tem permissão para acessar.

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 chave em storage é um nome de coleção. O array indexes lista os campos que podem ser consultados eficientemente — índices de campo único como strings, índices compostos como arrays de strings.

Usando armazenamento na entrada do sandbox

Dentro da entrada do sandbox, acesse coleções via ctx.storage. A forma espelha o que foi declarado no descritor:

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

Acessar uma coleção que não foi declarada no descritor lança uma exceção — a ponte aplica isso no nível de tempo de execução.

API de coleção

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

	// Operações em lote
	getMany(ids: string[]): Promise<Map<string, T>>;
	putMany(items: Array<{ id: string; data: T }>): Promise<void>;
	deleteMany(ids: string[]): Promise<number>;

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

Consultas

query() retorna 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 paginação (se mais resultados existirem)
// result.hasMore — boolean

Opções de consulta

interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number;     // padrão 50, máx 1000
	cursor?: string;    // para paginação
}

Operadores de cláusula Where

Filtre por campos indexados usando estes operadores:

Correspondência exata

where: {
	status: "pending",     // correspondência exata de string
	count: 5,              // correspondência exata de número
	archived: false,       // correspondência exata de booleano
}

Intervalo

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

Na lista

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

Começa com

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

Ordenação

orderBy: { createdAt: "desc" }   // mais recentes primeiro
orderBy: { score: "asc" }        // mais baixos primeiro

Paginação

Esgote um cursor para percorrer todos os itens correspondentes:

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

Contagem

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

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

Operações em lote

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

Design de índices

Escolha índices baseados em padrões de consulta reais:

Padrão de consultaÍndice necessário
Filtrar por formId"formId"
Filtrar por formId, ordenar por createdAt["formId", "createdAt"]
Ordenar apenas por createdAt"createdAt"
Filtrar por status e formId"status" e "formId" (separados)

Índices compostos suportam consultas que filtram no primeiro campo e opcionalmente ordenam pelo segundo:

// Com índice ["formId", "createdAt"]:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });  // usa índice
query({ where: { formId: "contact" } });                                  // usa índice (apenas filtro)
query({ where: { createdAt: { gte: "2024-01-01" } } });                   // NÃO usa este composto — filtro começa no campo errado

Segurança de tipos

Converta o acesso à coleção para IntelliSense nas formas dos itens:

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

Escolha o mecanismo certo para cada tipo de dado:

Caso de usoArmazenamento
Dados operacionais do plugin (logs, envios, cache)ctx.storage
Configurações ajustáveis pelo usuárioctx.kv com prefixo settings:
Estado interno do pluginctx.kv com prefixo state:
Conteúdo editável na interface de administraçãoColeções do site (não armazenamento de plugin)

Se editores do site precisam visualizar ou editar os dados na interface de administração através do editor de conteúdo regular, crie uma coleção do site em vez disso.

Detalhes de implementação

O armazenamento de plugins usa uma única tabela com 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)
);

O EmDash cria índices de expressão para os campos declarados:

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

Este design oferece sem migrações, portabilidade entre SQLite/libSQL/D1, isolamento no nível do plugin e consultas parametrizadas em cada caminho.

Adicionando índices

Quando você adiciona um índice em uma atualização de plugin, o EmDash o cria automaticamente na próxima inicialização. Adicionar um índice é seguro e não requer migração de dados. Quando você remove um índice, o EmDash o elimina — e consultas nesse campo começam a falhar com um erro de validação, que é o sinal pretendido.