Storage

Nesta página

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

Esta página cobre plugins sandboxed. A API de coleção é idêntica para plugins nativos; a única diferença é que plugins nativos declaram storage dentro de definePlugin() em vez de no manifest.

Declarar storage no manifest

Para plugins sandboxed, storage fica em emdash-plugin.jsonc. A declaração precisa ser visível em tempo de compilação para que a ponte sandbox saiba quais coleções o plugin pode acessar.

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

	"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 campos que podem ser consultados eficientemente — índices de campo único como strings, índices compostos como arrays de strings. Veja a referência do manifest para as regras completas.

Usar storage em tempo de execução

Em src/plugin.ts, acesse coleções via ctx.storage. A forma reflete o que foi declarado no 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;

Acessar uma coleção que não foi declarada no manifest lança um erro — a ponte aplica isso ao 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áximo 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 boolean
}

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 com base 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 de itens:

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 as importações são apenas de tipo, então um plugin sandboxed não tem dependência de tempo de execução em emdash.

Storage vs Content vs KV

Escolha o mecanismo certo para cada tipo de dado:

Caso de usoStorage
Dados operacionais do plugin (logs, submissions, cache)ctx.storage
Configurações configuráveis pelo usuárioctx.kv com prefixo settings:
Estado interno do pluginctx.kv com prefixo state:
Conteúdo editável na UI adminColeções do site (não storage do plugin)

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

Detalhes de implementação

O storage do plugin 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 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 em SQLite/libSQL/D1, isolamento ao nível do plugin e consultas parametrizadas em todos os caminhos.

Adicionar índices

Quando você adiciona um índice em uma atualização do 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 remove — e consultas nesse campo começam a falhar com um erro de validação, que é o sinal pretendido.