I plugin sandboxed possono memorizzare i propri record in collezioni di documenti. Dichiara ogni collezione e i suoi indici nel manifesto. EmDash crea e aggiorna gli indici corrispondenti quando il plugin viene caricato.
Questa pagina tratta i plugin sandboxed. L’API delle collezioni è identica per i plugin nativi; l’unica differenza è che i plugin nativi dichiarano storage all’interno di definePlugin() anziché nel manifesto.
Dichiarare lo storage nel manifesto
Per i plugin sandboxed, storage si trova in emdash-plugin.jsonc. La dichiarazione deve essere visibile al momento della compilazione in modo che il ponte sandbox sappia a quali collezioni il plugin è autorizzato ad accedere.
{
"slug": "forms",
// ...identità + profilo...
"capabilities": ["content:read"],
"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. Consulta il riferimento del manifesto per le regole complete.
I nomi delle collezioni iniziano con una lettera minuscola e contengono lettere minuscole, cifre o underscore. I nomi dei campi degli indici iniziano con una lettera e contengono lettere, cifre o underscore. Inserisci un campo unico o una combinazione di campi in uniqueIndexes; un indice unico è già interrogabile, quindi non ripeterlo in indexes.
Usare lo storage in fase di esecuzione
In src/plugin.ts, accedi alle collezioni tramite ctx.storage. La struttura rispecchia quanto dichiarato nel 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;
L’accesso a una collezione che non è stata dichiarata nel manifesto genera un errore — il ponte lo applica a livello di esecuzione.
API della collezione
Ogni collezione dichiarata fornisce i seguenti metodi di lettura, scrittura, batch, query e conteggio:
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>;
}
Scritture condizionali
Usa getVersioned() e compareAndSet() quando richieste concorrenti possono aggiornare lo stesso record. Questi metodi sono disponibili sulle collezioni ctx.storage dichiarate e su ctx.kv, sia per plugin nativi che sandboxed. Ogni operazione accede a una chiave nel namespace del plugin chiamante.
I metodi hanno il seguente comportamento:
| Metodo | Risultato |
|---|---|
getVersioned(key) | Il valore JSON memorizzato e una revisione opaca, oppure null quando la riga è assente. Un JSON null memorizzato restituisce { value: null, revision }. |
compareAndSet(key, null, value) | Crea la riga solo quando è assente. |
compareAndSet(key, revision, value) | Sostituisce l’intero valore solo quando la revisione memorizzata corrisponde. |
compareAndDelete(key, revision) | Elimina la riga solo quando la revisione memorizzata corrisponde. |
Un compareAndSet() riuscito restituisce { applied: true, revision }. Una precondizione fallita restituisce { applied: false }; argomenti non validi, permessi mancanti e guasti del database rifiutano la promessa. compareAndDelete() restituisce { applied: boolean }. Una violazione di indice unico non correlata è un errore, anche quando la chiave richiesta è assente.
Restituisci le revisioni invariate e solo per la chiave da cui provengono. Ogni scrittura modifica la revisione, incluse le scritture con valori uguali tramite set(), put() e scritture batch. Eliminare e ricreare una chiave invalida la sua revisione precedente.
Il seguente helper aggiunge un lavoro completato al contatore di un plugin, riprovando fino a tre volte quando un’altra richiesta scrive per prima.
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");
}
In caso di conflitto, rileggi il valore e ricalcola la modifica proposta. Mantieni i tentativi limitati. Una risposta persa può lasciare sconosciuto l’esito di una scrittura; questi metodi non garantiscono che le azioni esterne o le esecuzioni di lavori ripetute avvengano esattamente una volta.
L’atomicità copre una singola chiave. Leggere un elemento di contenuto e scrivere un record di plugin, o scrivere due record di plugin, sono operazioni separate. Inserisci i campi che devono cambiare insieme in un unico valore. Applica le regole di business come la proprietà del lavoro o i limiti di quantità durante la costruzione di quel valore.
I metodi condizionali richiedono una chiave non vuota di al massimo 1.024 caratteri stringa JavaScript e un valore JSON di al massimo 1 MiB dopo la codifica UTF-8. Una revisione deve essere una stringa non vuota di al massimo 128 caratteri. Le revisioni omesse non sono valide; solo un null esplicito richiede la creazione. I metodi incondizionali esistenti mantengono il loro comportamento.
Distribuisci le versioni corrispondenti del core e dell’adattatore sandbox e applica le migrazioni del database host prima di usare questi metodi. La migrazione preserva i valori memorizzati e fa sì che le scritture dei processi host più vecchi invalidino le revisioni durante un deployment progressivo.
Aggiornamenti condizionali
Usa updateIf() per modificare un documento esistente solo quando i suoi campi memorizzati corrispondono a una condizione. Il database verifica la condizione e applica le modifiche in un’unica operazione atomica. Questo metodo è disponibile per i plugin nativi e i plugin sandboxed su Cloudflare e Workerd.
Importa i tipi NumericDelta, UpdateIfArgs e UpdateIfResult con import type da emdash o emdash/plugin.
La seguente chiamata approva un invio in attesa e incrementa il suo contatore di revisioni nella stessa operazione:
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 });
}
Una chiamata riuscita restituisce { applied: true, data } con il documento aggiornato completo. Restituisce { applied: false } se il documento è mancante o la condizione non corrisponde. Non inserisce mai un documento.
Gli argomenti hanno il seguente comportamento:
whereè obbligatorio e usa gli stessi operatori dei filtri di query. Unwhere: {}esplicito non aggiunge condizioni di campo. I campi di guardia non necessitano di indici di query dichiarati perché l’aggiornamento punta a un documento per ID.- Un filtro di intervallo necessita di almeno un limite definito. I limiti indefiniti vengono ignorati quando un altro limite è definito. Gli operandi numerici usati da una guardia devono essere finiti.
setsostituisce ogni valore di campo di livello superiore fornito e lascia gli altri campi invariati. I valori devono essere serializzabili in JSON.deltaapplica esattamente un{ inc: number }o{ dec: number }per campo. Ogni operando deve essere un intero sicuro; gli operandi negativi sono consentiti.- Un campo non può apparire sia in
setche indelta. Le vociundefineddi livello superiore in entrambi gli oggetti vengono ignorate. Deve rimanere almeno un campo definito.
Gli argomenti di aggiornamento malformati rifiutano la promessa senza modificare il documento. L’oggetto degli argomenti, set, delta e ogni operazione delta devono essere oggetti semplici.
Contatori interi
Un delta inizia un contatore mancante o null a 0. I contatori esistenti e i loro risultati devono essere interi tra Number.MIN_SAFE_INTEGER e Number.MAX_SAFE_INTEGER. Una stringa, un booleano, un oggetto, un array, un numero frazionario, un intero non sicuro o un risultato fuori intervallo fa sì che l’intero aggiornamento restituisca { applied: false }. Un documento memorizzato che non è un oggetto JSON restituisce anch’esso { applied: false }. In nessuno dei due casi vengono modificati campi.
I delta possono produrre valori negativi. Per mantenere un contatore non negativo, abbina un decremento di n con una condizione where che richieda che il contatore sia almeno n.
Riprovare i fallimenti di serializzazione
PostgreSQL può rifiutare le scritture concorrenti con un fallimento di serializzazione o deadlock. Un deadlock può verificarsi a qualsiasi livello di isolamento, incluso READ COMMITTED. Nei plugin nativi, questi fallimenti lanciano StorageSerializationError con code: "STORAGE_SERIALIZATION_FAILURE", retryable: true e un sqlState opzionale (40001 o 40P01). Importa la classe di errore da emdash.
Usa tentativi limitati con backoff per una chiamata autonoma. Se la chiamata è all’interno di una transazione esplicita, riavvia l’intera transazione, incluse le sue letture; riprovare la scrittura all’interno della transazione interrotta non può avere successo. Gestisci { applied: false } come un aggiornamento non applicato piuttosto che un errore di serializzazione.
I trasporti sandbox preservano il nome dell’errore e i metadati di tentativo, ma non garantiscono instanceof StorageSerializationError. Verifica code e retryable quando gestisci errori attraverso un confine sandbox.
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
Passa queste opzioni a query() per filtrare, ordinare e paginare il risultato:
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // default 50, max 100
cursor?: string; // for pagination
}
Operatori della clausola where
Filtra per campi indicizzati usando questi operatori:
Corrispondenza esatta
where: {
status: "pending", // exact string match
count: 5, // exact number match
archived: false, // exact boolean match
} Intervallo
where: {
createdAt: { gte: "2024-01-01" },
score: { gt: 50, lte: 100 },
}
// Available: gt, gte, lt, lte Nella lista
where: {
status: { in: ["pending", "approved"] },
} Inizia con
where: {
slug: { startsWith: "blog-" },
} Ordinamento
Imposta uno o più campi indicizzati in ordine crescente o decrescente:
orderBy: { createdAt: "desc" } // newest first
orderBy: { score: "asc" } // lowest first
Paginazione
Esaurisci un cursore per scorrere 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
Conta ogni record in una collezione, o solo i record che corrispondono ai campi indicizzati:
const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({
status: "pending",
});
Operazioni batch
Usa i metodi batch quando un’operazione legge, scrive o elimina diversi ID di record noti:
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"]);
Sull’adattatore sandbox Cloudflare, putMany() scrive gli elementi in sequenza. Se una scrittura fallisce, la promessa viene rifiutata, le scritture precedenti restano confermate e gli elementi successivi non vengono tentati.
Progettazione degli indici
Scegli gli indici basandoti sui pattern di query effettivi:
| Pattern di query | Indice necessario |
|---|---|
Filtrare per formId | "formId" |
Filtrare per formId, ordinare per createdAt | ["formId", "createdAt"] |
Ordinare solo per createdAt | "createdAt" |
Filtrare per status e formId insieme | ["status", "formId"] |
Gli indici composti supportano query che filtrano sul primo campo e opzionalmente ordinano per il secondo:
// 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
Ogni campo nominato in qualsiasi parte di indexes o uniqueIndexes supera il controllo dei campi indicizzati dell’API di query. L’ordine di un indice composto determina comunque quali forme di query il database può eseguire in modo efficiente. Aggiungi un indice "createdAt" separato quando il plugin filtra o ordina frequentemente per quel campo senza formId.
Sicurezza dei tipi
Converti l’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;
}
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;
Entrambe le importazioni sono solo di tipo, quindi un plugin sandboxed non ha dipendenze runtime da emdash.
Storage vs contenuto vs KV
Scegli il meccanismo giusto per ogni tipo di dati:
| Caso d’uso | Storage |
|---|---|
| Dati operativi del plugin (log, invii, cache) | ctx.storage |
| Impostazioni configurabili dall’utente | ctx.settings |
| Stato interno del plugin | ctx.kv con prefisso state: |
| Contenuto modificabile nell’UI di amministrazione | Collezioni del sito (non storage del plugin) |
Se gli editor del sito devono visualizzare o modificare i dati nell’UI di amministrazione tramite l’editor di contenuti standard, crea invece una collezione del sito.
Come le collezioni sono isolate
EmDash memorizza i documenti dei plugin con l’ID del plugin, il nome della collezione, l’ID del record, i dati JSON e i timestamp. Quelle colonne di namespace fanno parte di ogni chiave e indice. Un plugin riceve accessori solo per le collezioni nel suo manifesto, e il ponte sandbox rifiuta l’accesso a qualsiasi altra collezione.
I campi dichiarati diventano indici di espressione accanto al namespace del plugin e della collezione. EmDash genera il SQL specifico del dialetto per SQLite, D1 e PostgreSQL; il codice del plugin usa la stessa API di collezione su ogni database.
Aggiungere indici
Quando un aggiornamento del plugin aggiunge un indice, EmDash lo crea al prossimo caricamento del plugin. Un indice unico non può essere creato finché i record esistenti contengono valori duplicati, quindi verifica e risolvi i duplicati prima di rilasciare quella modifica.
Quando un aggiornamento rimuove un indice, EmDash lo elimina. Qualsiasi query o ordinamento che ancora utilizza il campo fallisce nella validazione. Aggiorna il codice e il manifesto insieme.
Gli indici fanno parte del contratto di fiducia dello storage nel manifesto. Incrementa la versione del plugin ogni volta che ne aggiungi, rimuovi o modifichi uno, e usa una versione major quando la modifica rompe una query esistente o un’assunzione di unicità.