Sandboxed Plugins können eigene Datensätze in Dokumenten-Collections speichern. Deklarieren Sie jede Collection und ihre Indizes im Manifest. EmDash erstellt und aktualisiert die passenden Indizes, wenn das Plugin geladen wird.
Diese Seite behandelt Sandboxed Plugins. Die Collection-API ist für native Plugins identisch; der einzige Unterschied besteht darin, dass native Plugins storage innerhalb von definePlugin() statt im Manifest deklarieren.
Storage im Manifest deklarieren
Für Sandboxed Plugins befindet sich storage in emdash-plugin.jsonc. Die Deklaration muss zur Build-Zeit sichtbar sein, damit die Sandbox-Bridge weiß, auf welche Collections das Plugin zugreifen darf.
{
"slug": "forms",
// ...identity + profile...
"capabilities": ["content:read"],
"storage": {
"submissions": {
"indexes": [
"formId",
"status",
"createdAt",
["formId", "createdAt"],
["status", "createdAt"]
]
},
"forms": {
"indexes": ["slug"]
}
}
}
Jeder Schlüssel in storage ist ein Collection-Name. Das indexes-Array listet Felder auf, die effizient abgefragt werden können — Einzelfeld-Indizes als Strings, zusammengesetzte Indizes als Arrays von Strings. Siehe die Manifest-Referenz für die vollständigen Regeln.
Collection-Namen beginnen mit einem Kleinbuchstaben und enthalten Kleinbuchstaben, Ziffern oder Unterstriche. Index-Feldnamen beginnen mit einem Buchstaben und enthalten Buchstaben, Ziffern oder Unterstriche. Setzen Sie ein eindeutiges Feld oder eine Feldkombination in uniqueIndexes; ein eindeutiger Index ist bereits abfragbar, wiederholen Sie ihn daher nicht in indexes.
Storage zur Laufzeit verwenden
In src/plugin.ts greifen Sie über ctx.storage auf Collections zu. Die Struktur spiegelt wider, was im Manifest deklariert wurde:
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;
Der Zugriff auf eine Collection, die nicht im Manifest deklariert wurde, löst einen Fehler aus — die Bridge erzwingt dies auf Laufzeitebene.
Collection-API
Jede deklarierte Collection bietet die folgenden Lese-, Schreib-, Batch-, Abfrage- und Zählmethoden:
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>;
}
Bedingte Schreibvorgänge
Verwenden Sie getVersioned() und compareAndSet(), wenn gleichzeitige Anfragen denselben Datensatz aktualisieren können. Diese Methoden sind auf deklarierten ctx.storage-Collections und auf ctx.kv verfügbar, sowohl für native als auch für Sandboxed Plugins. Jede Operation greift auf einen Schlüssel im Namespace des aufrufenden Plugins zu.
Die Methoden haben folgendes Verhalten:
| Methode | Ergebnis |
|---|---|
getVersioned(key) | Der gespeicherte JSON-Wert und eine opake Revision, oder null, wenn die Zeile nicht vorhanden ist. Ein gespeichertes JSON-null gibt { value: null, revision } zurück. |
compareAndSet(key, null, value) | Erstellt die Zeile nur, wenn sie nicht vorhanden ist. |
compareAndSet(key, revision, value) | Ersetzt den gesamten Wert nur, wenn die gespeicherte Revision übereinstimmt. |
compareAndDelete(key, revision) | Löscht die Zeile nur, wenn die gespeicherte Revision übereinstimmt. |
Ein erfolgreicher compareAndSet() gibt { applied: true, revision } zurück. Eine fehlgeschlagene Vorbedingung gibt { applied: false } zurück; ungültige Argumente, fehlende Berechtigungen und Datenbankfehler lehnen das Promise ab. compareAndDelete() gibt { applied: boolean } zurück. Eine nicht verwandte Unique-Index-Verletzung ist ein Fehler, auch wenn der angeforderte Schlüssel nicht vorhanden ist.
Geben Sie Revisionen unverändert und nur für den Schlüssel zurück, von dem sie stammen. Jeder Schreibvorgang ändert die Revision, einschließlich wertgleicher set()-, put()- und Batch-Schreibvorgänge. Das Löschen und Neuerstellen eines Schlüssels invalidiert seine vorherige Revision.
Der folgende Helfer fügt einen abgeschlossenen Job zum Zähler eines Plugins hinzu und wiederholt bis zu dreimal, wenn eine andere Anfrage zuerst schreibt.
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");
}
Bei einem Konflikt lesen Sie den Wert erneut und berechnen die vorgeschlagene Änderung neu. Begrenzen Sie die Wiederholungsversuche. Eine verlorene Antwort kann das Ergebnis eines Schreibvorgangs unbekannt lassen; diese Methoden stellen nicht sicher, dass externe Aktionen oder wiederholte Job-Ausführungen genau einmal stattfinden.
Die Atomizität umfasst einen einzelnen Schlüssel. Das Lesen eines Content-Elements und das Schreiben eines Plugin-Datensatzes oder das Schreiben zweier Plugin-Datensätze sind separate Operationen. Fassen Sie Felder, die sich gemeinsam ändern müssen, in einem Wert zusammen. Erzwingen Sie Geschäftsregeln wie Job-Besitz oder Mengenbeschränkungen bei der Konstruktion dieses Wertes.
Bedingte Methoden erfordern einen nicht leeren Schlüssel mit maximal 1.024 JavaScript-String-Zeichen und einen JSON-Wert von maximal 1 MiB nach UTF-8-Kodierung. Eine Revision muss ein nicht leerer String mit maximal 128 Zeichen sein. Ausgelassene Revisionen sind ungültig; nur ein explizites null fordert die Erstellung an. Bestehende unbedingte Methoden behalten ihr Verhalten bei.
Stellen Sie die passenden Core- und Sandbox-Adapter-Versionen bereit und wenden Sie die Host-Datenbank-Migrationen an, bevor Sie diese Methoden verwenden. Die Migration bewahrt gespeicherte Werte und bewirkt, dass Schreibvorgänge von älteren Host-Prozessen während eines Rolling Deployments Revisionen invalidieren.
Bedingte Aktualisierungen
Verwenden Sie updateIf(), um ein bestehendes Dokument nur zu ändern, wenn seine gespeicherten Felder einer Bedingung entsprechen. Die Datenbank überprüft die Bedingung und wendet die Änderungen in einer atomaren Operation an. Diese Methode ist für native Plugins und Sandboxed Plugins auf Cloudflare und Workerd verfügbar.
Importieren Sie die Typen NumericDelta, UpdateIfArgs und UpdateIfResult mit import type aus emdash oder emdash/plugin.
Der folgende Aufruf genehmigt eine ausstehende Einreichung und erhöht deren Review-Zähler in derselben Operation:
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 });
}
Ein erfolgreicher Aufruf gibt { applied: true, data } mit dem vollständig aktualisierten Dokument zurück. Er gibt { applied: false } zurück, wenn das Dokument fehlt oder die Bedingung nicht übereinstimmt. Er fügt niemals ein Dokument ein.
Die Argumente haben folgendes Verhalten:
whereist erforderlich und verwendet dieselben Operatoren wie Abfragefilter. Ein expliziteswhere: {}fügt keine Feldbedingungen hinzu. Guard-Felder benötigen keine deklarierten Abfrageindizes, da das Update ein Dokument per ID anspricht.- Ein Bereichsfilter benötigt mindestens eine definierte Grenze. Undefinierte Grenzen werden ignoriert, wenn eine andere Grenze definiert ist. Numerische Operanden, die von einem Guard verwendet werden, müssen endlich sein.
setersetzt jeden angegebenen Top-Level-Feldwert und lässt andere Felder unverändert. Werte müssen JSON-serialisierbar sein.deltawendet genau ein{ inc: number }oder{ dec: number }pro Feld an. Jeder Operand muss ein sicherer Integer sein; negative Operanden sind erlaubt.- Ein Feld kann nicht sowohl in
setals auch indeltaerscheinen. Top-Level-undefined-Einträge in beiden Objekten werden ignoriert. Es muss mindestens ein definiertes Feld verbleiben.
Fehlerhafte Update-Argumente lehnen das Promise ab, ohne das Dokument zu ändern. Das Argumente-Objekt, set, delta und jede Delta-Operation müssen einfache Objekte sein.
Integer-Zähler
Ein Delta startet einen fehlenden oder null-Zähler bei 0. Bestehende Zähler und ihre Ergebnisse müssen Integers zwischen Number.MIN_SAFE_INTEGER und Number.MAX_SAFE_INTEGER sein. Ein String, Boolean, Objekt, Array, eine Bruchzahl, ein unsicherer Integer oder ein Ergebnis außerhalb des Bereichs führt dazu, dass das gesamte Update { applied: false } zurückgibt. Ein gespeichertes Dokument, das kein JSON-Objekt ist, gibt ebenfalls { applied: false } zurück. In keinem Fall werden Felder geändert.
Deltas können negative Werte erzeugen. Um einen Zähler nicht negativ zu halten, kombinieren Sie ein Dekrement von n mit einer where-Bedingung, die verlangt, dass der Zähler mindestens n beträgt.
Serialisierungsfehler wiederholen
PostgreSQL kann gleichzeitige Schreibvorgänge mit einem Serialisierungsfehler oder Deadlock ablehnen. Ein Deadlock kann auf jeder Isolationsebene auftreten, einschließlich READ COMMITTED. In nativen Plugins werfen diese Fehler StorageSerializationError mit code: "STORAGE_SERIALIZATION_FAILURE", retryable: true und einem optionalen sqlState (40001 oder 40P01). Importieren Sie die Fehlerklasse aus emdash.
Verwenden Sie begrenzte Wiederholungen mit Backoff für einen eigenständigen Aufruf. Wenn der Aufruf innerhalb einer expliziten Transaktion stattfindet, starten Sie die gesamte Transaktion neu, einschließlich ihrer Lesevorgänge; das Wiederholen des Schreibvorgangs innerhalb der abgebrochenen Transaktion kann nicht erfolgreich sein. Behandeln Sie { applied: false } als nicht angewendetes Update und nicht als Serialisierungsfehler.
Sandbox-Transporte bewahren den Fehlernamen und die Retry-Metadaten, garantieren aber nicht instanceof StorageSerializationError. Prüfen Sie code und retryable bei der Fehlerbehandlung über eine Sandbox-Grenze hinweg.
Abfragen
query() gibt paginierte Ergebnisse zurück, die nach indizierten Feldern gefiltert sind:
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
Abfrageoptionen
Übergeben Sie diese Optionen an query(), um das Ergebnis zu filtern, zu sortieren und zu paginieren:
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // default 50, max 100
cursor?: string; // for pagination
}
Where-Klausel-Operatoren
Filtern Sie nach indizierten Feldern mit diesen Operatoren:
Exakte Übereinstimmung
where: {
status: "pending", // exact string match
count: 5, // exact number match
archived: false, // exact boolean match
} Bereich
where: {
createdAt: { gte: "2024-01-01" },
score: { gt: 50, lte: 100 },
}
// Available: gt, gte, lt, lte In Liste
where: {
status: { in: ["pending", "approved"] },
} Beginnt mit
where: {
slug: { startsWith: "blog-" },
} Sortierung
Setzen Sie ein oder mehrere indizierte Felder auf aufsteigende oder absteigende Reihenfolge:
orderBy: { createdAt: "desc" } // newest first
orderBy: { score: "asc" } // lowest first
Paginierung
Durchlaufen Sie einen Cursor, um alle übereinstimmenden Elemente abzurufen:
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;
}
Zählen
Zählen Sie jeden Datensatz in einer Collection oder nur Datensätze, die indizierten Feldern entsprechen:
const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({
status: "pending",
});
Batch-Operationen
Verwenden Sie die Batch-Methoden, wenn eine Operation mehrere bekannte Datensatz-IDs liest, schreibt oder löscht:
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"]);
Beim Cloudflare-Sandbox-Adapter schreibt putMany() Elemente nacheinander. Schlägt ein Schreibvorgang fehl, lehnt das Promise ab, frühere Schreibvorgänge bleiben committed, und spätere Elemente werden nicht mehr versucht.
Index-Design
Wählen Sie Indizes basierend auf tatsächlichen Abfragemustern:
| Abfragemuster | Benötigter Index |
|---|---|
Nach formId filtern | "formId" |
Nach formId filtern, nach createdAt sortieren | ["formId", "createdAt"] |
Nur nach createdAt sortieren | "createdAt" |
Nach status und formId zusammen filtern | ["status", "formId"] |
Zusammengesetzte Indizes unterstützen Abfragen, die nach dem ersten Feld filtern und optional nach dem zweiten sortieren:
// 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
Jedes Feld, das irgendwo in indexes oder uniqueIndexes genannt wird, besteht die Prüfung der Abfrage-API auf indizierte Felder. Die Reihenfolge eines zusammengesetzten Index bestimmt weiterhin, welche Abfrageformen die Datenbank effizient ausführen kann. Fügen Sie einen separaten "createdAt"-Index hinzu, wenn das Plugin häufig nach diesem Feld ohne formId filtert oder sortiert.
Typsicherheit
Casten Sie den Collection-Zugriff für IntelliSense auf Element-Formen:
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;
Beide Importe sind nur Typen, sodass ein Sandboxed Plugin keine Laufzeit-Abhängigkeit von emdash hat.
Storage vs. Content vs. KV
Wählen Sie den richtigen Mechanismus für jede Art von Daten:
| Anwendungsfall | Storage |
|---|---|
| Plugin-Betriebsdaten (Logs, Einreichungen, Cache) | ctx.storage |
| Benutzerkonfigurierbare Einstellungen | ctx.settings |
| Interner Plugin-Zustand | ctx.kv with state: prefix |
| In der Admin-UI bearbeitbare Inhalte | Site-Collections (nicht Plugin-Storage) |
Wenn Site-Editoren die Daten in der Admin-UI über den regulären Content-Editor anzeigen oder bearbeiten müssen, erstellen Sie stattdessen eine Site-Collection.
Wie Collections isoliert sind
EmDash speichert Plugin-Dokumente mit der Plugin-ID, dem Collection-Namen, der Datensatz-ID, JSON-Daten und Zeitstempeln. Diese Namensraum-Spalten sind Teil jedes Schlüssels und Index. Ein Plugin erhält nur Zugriffsmethoden für die in seinem Manifest deklarierten Collections, und die Sandbox-Bridge lehnt den Zugriff auf jede andere Collection ab.
Deklarierte Felder werden zu Expression-Indizes neben dem Plugin- und Collection-Namensraum. EmDash generiert das dialektspezifische SQL für SQLite, D1 und PostgreSQL; Plugin-Code verwendet dieselbe Collection-API auf jeder Datenbank.
Indizes hinzufügen
Wenn ein Plugin-Update einen Index hinzufügt, erstellt EmDash ihn beim nächsten Laden des Plugins. Ein eindeutiger Index kann nicht erstellt werden, solange bestehende Datensätze doppelte Werte enthalten — prüfen und bereinigen Sie Duplikate, bevor Sie diese Änderung veröffentlichen.
Wenn ein Update einen Index entfernt, löscht EmDash ihn. Jede Abfrage oder Sortierung, die das Feld noch verwendet, schlägt dann bei der Validierung fehl. Aktualisieren Sie Code und Manifest gemeinsam.
Indizes sind Teil des Storage-Trust-Vertrags im Manifest. Erhöhen Sie die Plugin-Version, wann immer Sie einen hinzufügen, entfernen oder ändern, und verwenden Sie eine Major-Version, wenn die Änderung eine bestehende Abfrage oder Eindeutigkeitsannahme bricht.