Storage

In questa pagina

I plugin sandboxed possono memorizzare i propri dati in collezioni di documenti. Dichiari collezioni e indici nel manifest, ed EmDash crea lo schema automaticamente — nessuna migrazione da scrivere.

Questa pagina copre i plugin sandboxed. L’API della collezione è identica per i plugin nativi; l’unica differenza è che i plugin nativi dichiarano storage all’interno di definePlugin() invece che nel manifest.

Dichiarare lo storage nel manifest

Per i plugin sandboxed, storage si trova in emdash-plugin.jsonc. La dichiarazione deve essere visibile al momento della compilazione in modo che il bridge della sandbox sappia quali collezioni il plugin può toccare.

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

	"storage": {
		"submissions": {
			"indexes": [
				"formId",
				"status",
				"createdAt",
				["formId", "createdAt"],
				["status", "createdAt"]
			]
		},
		"forms": {
			"indexes": ["slug"]
		}
	}
}

Ogni chiave in storage è un nome di collezione. L’array indexes elenca i campi che possono essere interrogati in modo efficiente — indici a campo singolo come stringhe, indici composti come array di stringhe. Vedi il riferimento del manifest per le regole complete.

Usare lo storage a runtime

In src/plugin.ts, accedi alle collezioni tramite ctx.storage. La forma rispecchia ciò che è stato dichiarato nel 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;

L’accesso a una collezione che non è stata dichiarata nel manifest genera un errore — il bridge lo applica a livello di runtime.

API della Collezione

interface StorageCollection<T = unknown> {
	// CRUD di base
	get(id: string): Promise<T | null>;
	put(id: string, data: T): Promise<void>;
	delete(id: string): Promise<boolean>;
	exists(id: string): Promise<boolean>;

	// Operazioni batch
	getMany(ids: string[]): Promise<Map<string, T>>;
	putMany(items: Array<{ id: string; data: T }>): Promise<void>;
	deleteMany(ids: string[]): Promise<number>;

	// Query (solo campi indicizzati)
	query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
	count(where?: WhereClause): Promise<number>;
}

Query

query() restituisce risultati paginati filtrati per campi indicizzati:

const result = await ctx.storage.submissions.query({
	where: {
		formId: "contact",
		status: "pending",
	},
	orderBy: { createdAt: "desc" },
	limit: 20,
});

// result.items   — Array<{ id, data }>
// result.cursor  — cursore di paginazione (se esistono più risultati)
// result.hasMore — boolean

Opzioni di query

interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number;     // predefinito 50, massimo 1000
	cursor?: string;    // per la paginazione
}

Operatori della clausola where

Filtra per campi indicizzati utilizzando questi operatori:

Corrispondenza esatta

where: {
	status: "pending",     // corrispondenza esatta di stringa
	count: 5,              // corrispondenza esatta di numero
	archived: false,       // corrispondenza esatta di boolean
}

Intervallo

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

In lista

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

Inizia con

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

Ordinamento

orderBy: { createdAt: "desc" }   // più recenti prima
orderBy: { score: "asc" }        // più bassi prima

Paginazione

Esaurisci un cursore per attraversare tutti gli elementi corrispondenti:

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

Conteggio

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

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

Operazioni batch

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

Scegli gli indici in base ai pattern di query effettivi:

Pattern di queryIndice necessario
Filtra per formId"formId"
Filtra per formId, ordina per createdAt["formId", "createdAt"]
Ordina solo per createdAt"createdAt"
Filtra per status e formId"status" e "formId" (separati)

Gli indici composti supportano query che filtrano sul primo campo e opzionalmente ordinano per il secondo:

// Con indice ["formId", "createdAt"]:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });  // usa l'indice
query({ where: { formId: "contact" } });                                  // usa l'indice (solo filtro)
query({ where: { createdAt: { gte: "2024-01-01" } } });                   // NON usa questo composto — il filtro inizia dal campo sbagliato

Sicurezza dei tipi

Esegui il cast dell’accesso alla collezione per IntelliSense sulle forme degli elementi:

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;

Entrambe le importazioni sono solo di tipo, quindi un plugin sandboxed non ha dipendenze runtime su emdash.

Storage vs content vs KV

Scegli il meccanismo giusto per ogni tipo di dati:

Caso d’usoStorage
Dati operativi del plugin (log, submissions, cache)ctx.storage
Impostazioni configurabili dall’utentectx.kv con prefisso settings:
Stato interno del pluginctx.kv con prefisso state:
Contenuto modificabile nell’UI adminCollezioni del sito (non storage del plugin)

Se gli editori del sito devono visualizzare o modificare i dati nell’UI admin tramite l’editor di contenuti regolare, crea invece una collezione del sito.

Dettagli di implementazione

Lo storage del plugin utilizza una singola tabella con 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)
);

EmDash crea indici di espressione per i campi dichiarati:

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

Questo design ti dà nessuna migrazione, portabilità tra SQLite/libSQL/D1, isolamento a livello di plugin e query parametrizzate su ogni percorso.

Aggiunta di indici

Quando aggiungi un indice in un aggiornamento del plugin, EmDash lo crea automaticamente al prossimo avvio. L’aggiunta di un indice è sicura e non richiede migrazione dei dati. Quando rimuovi un indice, EmDash lo elimina — e le query su quel campo iniziano a fallire con un errore di validazione, che è il segnale previsto.