I plugin sandboxed possono memorizzare i propri dati nelle collezioni di documenti. Dichiari le collezioni e gli indici nel descrittore del plugin, ed EmDash crea automaticamente lo schema — nessuna migrazione da scrivere.
Questa pagina copre i plugin sandboxed (formato standard). L’API delle collezioni è identica per i plugin nativi; l’unica differenza è che i plugin nativi dichiarano storage direttamente all’interno di definePlugin() piuttosto che su un descrittore separato.
Dichiarare l’archiviazione nel descrittore
Per i plugin sandboxed, storage si trova nel descrittore — il file importato da astro.config.mjs, non l’entry sandbox. Le dichiarazioni di archiviazione devono essere visibili al momento della build in modo che il bridge sandbox sappia quali collezioni il plugin può toccare.
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"],
},
},
};
}
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 compositi come array di stringhe.
Utilizzare l’archiviazione nell’entry sandbox
All’interno dell’entry sandbox, accedi alle collezioni tramite ctx.storage. La forma rispecchia ciò che è stato dichiarato nel descrittore:
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 });
},
},
});
L’accesso a una collezione che non è stata dichiarata nel descrittore genera un’eccezione — il bridge applica questo 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, max 1000
cursor?: string; // per la paginazione
}
Operatori della clausola Where
Filtra per campi indicizzati usando questi operatori:
Corrispondenza esatta
where: {
status: "pending", // corrispondenza esatta di stringa
count: 5, // corrispondenza esatta di numero
archived: false, // corrispondenza esatta di booleano
} 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 per primi
orderBy: { score: "asc" } // più bassi per primi
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 query | Indice 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 compositi 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 composito — il filtro inizia dal campo sbagliato
Sicurezza dei tipi
Esegui il cast dell’accesso alla collezione per IntelliSense sulle forme degli elementi:
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
Scegli il meccanismo giusto per ogni tipo di dato:
| Caso d’uso | Archiviazione |
|---|---|
| Dati operativi del plugin (log, invii, cache) | ctx.storage |
| Impostazioni configurabili dall’utente | ctx.kv con prefisso settings: |
| Stato interno del plugin | ctx.kv con prefisso state: |
| Contenuto modificabile nell’interfaccia admin | Collezioni del sito (non archiviazione plugin) |
Se gli editor del sito devono visualizzare o modificare i dati nell’interfaccia admin attraverso l’editor di contenuti regolare, crea invece una collezione del sito.
Dettagli di implementazione
L’archiviazione 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 offre 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.