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 uso | Armazenamento |
|---|---|
| Dados operacionais do plugin (logs, envios, cache) | ctx.storage |
| Configurações ajustáveis pelo usuário | ctx.kv com prefixo settings: |
| Estado interno do plugin | ctx.kv com prefixo state: |
| Conteúdo editável na interface de administração | Coleçõ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.