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 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 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’uso | Storage |
|---|---|
| Dati operativi del plugin (log, submissions, cache) | ctx.storage |
| Impostazioni configurabili dall’utente | ctx.kv con prefisso settings: |
| Stato interno del plugin | ctx.kv con prefisso state: |
| Contenuto modificabile nell’UI admin | Collezioni 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.