Storage

Nesta página

Plugins sandboxed podem armazenar seus próprios registros em coleções de documentos. Declare cada coleção e seus índices no manifesto. O EmDash cria e atualiza os índices correspondentes quando o plugin é carregado.

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

Declarar storage no manifesto

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

{
	"slug": "forms",
	// ...identity + profile...
	"capabilities": ["content:read"],

	"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 com eficiência — índices de campo único como strings, índices compostos como arrays de strings. Consulte a referência do manifesto para as regras completas.

Nomes de coleção começam com uma letra minúscula e contêm letras minúsculas, dígitos ou underscores. Nomes de campos de índice começam com uma letra e contêm letras, dígitos ou underscores. Coloque um campo único ou combinação de campos em uniqueIndexes; um índice único já é consultável, portanto não o repita em indexes.

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

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;

Acessar uma coleção que não foi declarada no manifesto lança um erro — a ponte impõe isso no nível de execução.

API da coleção

Cada coleção declarada oferece os seguintes métodos de leitura, gravação, lote, consulta e contagem:

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

Gravações condicionais

Use getVersioned() e compareAndSet() quando requisições concorrentes podem atualizar o mesmo registro. Esses métodos estão disponíveis em coleções declaradas de ctx.storage e em ctx.kv, para plugins nativos e sandboxed. Cada operação acessa uma chave no namespace do plugin que faz a chamada.

Os métodos têm o seguinte comportamento:

MétodoResultado
getVersioned(key)O valor JSON armazenado e uma revisão opaca, ou null quando a linha está ausente. Um JSON null armazenado retorna { value: null, revision }.
compareAndSet(key, null, value)Cria a linha somente quando ela está ausente.
compareAndSet(key, revision, value)Substitui o valor inteiro somente quando a revisão armazenada corresponde.
compareAndDelete(key, revision)Exclui a linha somente quando a revisão armazenada corresponde.

Um compareAndSet() bem-sucedido retorna { applied: true, revision }. Uma precondição falha retorna { applied: false }; argumentos inválidos, permissões ausentes e falhas de banco de dados rejeitam a promessa. compareAndDelete() retorna { applied: boolean }. Uma violação de índice único não relacionada é um erro, mesmo quando a chave solicitada está ausente.

Passe revisões de volta sem alteração e apenas para a chave de onde vieram. Cada gravação altera a revisão, incluindo set(), put() e gravações em lote de valores iguais. Excluir e recriar uma chave invalida sua revisão anterior.

O helper a seguir adiciona um job concluído ao contador de um plugin, tentando até três vezes quando outra requisição grava primeiro.

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

Em caso de conflito, leia o valor novamente e recalcule a alteração proposta. Mantenha as tentativas limitadas. Uma resposta perdida pode deixar o resultado de uma gravação desconhecido; esses métodos não fazem com que ações externas ou execuções repetidas de jobs ocorram exatamente uma vez.

A atomicidade cobre uma única chave. Ler um item de conteúdo e gravar um registro de plugin, ou gravar dois registros de plugin, são operações separadas. Coloque campos que devem mudar juntos em um único valor. Aplique regras de negócio como propriedade de jobs ou limites de quantidade ao construir esse valor.

Métodos condicionais exigem uma chave não vazia com no máximo 1.024 caracteres de string JavaScript e um valor JSON de no máximo 1 MiB após codificação UTF-8. Uma revisão deve ser uma string não vazia com no máximo 128 caracteres. Revisões omitidas são inválidas; apenas um null explícito solicita criação. Métodos incondicionais existentes mantêm seu comportamento.

Implante as versões correspondentes do core e do adaptador sandbox e aplique as migrações de banco de dados do host antes de usar esses métodos. A migração preserva valores armazenados e faz com que gravações de processos host mais antigos invalidem revisões durante uma implantação contínua.

Atualizações condicionais

Use updateIf() para alterar um documento existente somente quando seus campos armazenados correspondem a uma condição. O banco de dados verifica a condição e aplica as alterações em uma operação atômica. Este método está disponível para plugins nativos e plugins sandboxed no Cloudflare e Workerd.

Importe os tipos NumericDelta, UpdateIfArgs e UpdateIfResult com import type de emdash ou emdash/plugin.

A chamada a seguir aprova um envio pendente e incrementa sua contagem de revisões na mesma operação:

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

Uma chamada bem-sucedida retorna { applied: true, data } com o documento atualizado por completo. Retorna { applied: false } se o documento estiver ausente ou a condição não corresponder. Nunca insere um documento.

Os argumentos têm o seguinte comportamento:

  • where é obrigatório e usa os mesmos operadores dos filtros de consulta. Um where: {} explícito não adiciona condições de campo. Campos de guarda não precisam de índices de consulta declarados porque a atualização tem como alvo um documento por ID.
  • Um filtro de intervalo precisa de pelo menos um limite definido. Limites indefinidos são ignorados quando outro limite está definido. Operandos numéricos usados por um guard devem ser finitos.
  • set substitui cada valor de campo de nível superior fornecido e deixa os demais campos inalterados. Os valores devem ser serializáveis em JSON.
  • delta aplica exatamente um { inc: number } ou { dec: number } por campo. Cada operando deve ser um inteiro seguro; operandos negativos são permitidos.
  • Um campo não pode aparecer em set e em delta. Entradas undefined de nível superior em qualquer um dos objetos são ignoradas. Pelo menos um campo definido deve permanecer.

Argumentos de atualização malformados rejeitam a promessa sem alterar o documento. O objeto de argumentos, set, delta e cada operação delta devem ser objetos simples.

Contadores inteiros

Um delta inicia um contador ausente ou null em 0. Contadores existentes e seus resultados devem ser inteiros entre Number.MIN_SAFE_INTEGER e Number.MAX_SAFE_INTEGER. Uma string, booleano, objeto, array, número fracionário, inteiro inseguro ou resultado fora do intervalo faz com que toda a atualização retorne { applied: false }. Um documento armazenado que não é um objeto JSON também retorna { applied: false }. Nenhum campo muda em nenhum dos casos.

Deltas podem produzir valores negativos. Para manter um contador não negativo, combine um decremento de n com uma condição where que exija que o contador seja pelo menos n.

Tentar novamente falhas de serialização

O PostgreSQL pode rejeitar gravações concorrentes com falha de serialização ou deadlock. Um deadlock pode ocorrer em qualquer nível de isolamento, incluindo READ COMMITTED. Em plugins nativos, essas falhas lançam StorageSerializationError com code: "STORAGE_SERIALIZATION_FAILURE", retryable: true e um sqlState opcional (40001 ou 40P01). Importe a classe de erro de emdash.

Use tentativas limitadas com backoff para uma chamada isolada. Se a chamada estiver dentro de uma transação explícita, reinicie a transação inteira, incluindo suas leituras; tentar novamente a gravação dentro da transação abortada não pode ter sucesso. Trate { applied: false } como uma atualização não aplicada, e não como erro de serialização.

Transportes sandbox preservam o nome do erro e metadados de tentativa, mas não garantem instanceof StorageSerializationError. Verifique code e retryable ao tratar erros através de uma fronteira sandbox.

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  — pagination cursor (if more results exist)
// result.hasMore — boolean

Opções de consulta

Passe estas opções para query() para filtrar, ordenar e paginar o resultado:

interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number;     // default 50, max 100
	cursor?: string;    // for pagination
}

Operadores da cláusula where

Filtre por campos indexados usando estes operadores:

Correspondência exata

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

Intervalo

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

Na lista

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

Começa com

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

Ordenação

Defina um ou mais campos indexados em ordem crescente ou decrescente:

orderBy: { createdAt: "desc" }   // newest first
orderBy: { score: "asc" }        // lowest first

Paginação

Consuma 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

Conte todos os registros em uma coleção, ou apenas registros que correspondem a campos indexados:

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

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

Operações em lote

Use os métodos em lote quando uma operação lê, grava ou exclui vários IDs de registro conhecidos:

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"]);

No adaptador sandbox do Cloudflare, putMany() grava os itens sequencialmente. Se uma gravação falhar, a promessa é rejeitada, as gravações anteriores permanecem confirmadas e os itens posteriores não são tentados.

Design de índices

Escolha índices com base em padrões reais de consulta:

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 juntos["status", "formId"]

Índices compostos suportam consultas que filtram no primeiro campo e, opcionalmente, ordenam pelo 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 nomeado em qualquer lugar de indexes ou uniqueIndexes passa na verificação de campo indexado da API de consulta. A ordem de um índice composto ainda determina quais formas de consulta o banco de dados pode executar com eficiência. Adicione um índice "createdAt" separado quando o plugin filtra ou ordena com frequência por esse campo sem formId.

Segurança de tipos

Converta o acesso à coleção para obter IntelliSense nas formas dos 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;
}

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

Storage vs conteúdo vs KV

Escolha o mecanismo certo para cada tipo de dado:

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

Se editores do site precisam ver ou editar os dados na UI de administração pelo editor de conteúdo regular, crie uma coleção do site.

Como as coleções são isoladas

O EmDash armazena documentos de plugin com o ID do plugin, nome da coleção, ID do registro, dados JSON e timestamps. Essas colunas de namespace fazem parte de cada chave e índice. Um plugin recebe acessores apenas para as coleções em seu manifesto, e a ponte sandbox rejeita acesso a qualquer outra coleção.

Campos declarados tornam-se índices de expressão junto ao namespace do plugin e da coleção. O EmDash gera o SQL específico do dialeto para SQLite, D1 e PostgreSQL; o código do plugin usa a mesma API de coleção em cada banco de dados.

Adicionar índices

Quando uma atualização de plugin adiciona um índice, o EmDash o cria na próxima vez que o plugin é carregado. Um índice único não pode ser criado enquanto registros existentes contêm valores duplicados; verifique e resolva duplicatas antes de publicar essa alteração.

Quando uma atualização remove um índice, o EmDash o descarta. Qualquer consulta ou ordenação que ainda use o campo falha na validação. Atualize o código e o manifesto juntos.

Índices fazem parte do contrato de confiança de storage do manifesto. Incremente a versão do plugin sempre que adicionar, remover ou alterar um, e use uma versão major quando a alteração quebrar uma consulta existente ou uma suposição de unicidade.