Sandboxed Plugins können ihre eigenen Daten in Dokumentensammlungen speichern. Sie deklarieren Sammlungen und Indizes im Plugin-Deskriptor, und EmDash erstellt das Schema automatisch — keine Migrationen zu schreiben.
Diese Seite behandelt Sandboxed (Standardformat) Plugins. Die Sammlungs-API ist identisch für native Plugins; der einzige Unterschied ist, dass native Plugins storage direkt innerhalb von definePlugin() deklarieren, anstatt auf einem separaten Deskriptor.
Speicherung im Deskriptor deklarieren
Für Sandboxed Plugins befindet sich storage im Deskriptor — der Datei, die von astro.config.mjs importiert wird, nicht im Sandbox-Einstiegspunkt. Speicherdeklarationen müssen zur Buildzeit sichtbar sein, damit die Sandbox-Bridge weiß, welche Sammlungen das Plugin berühren darf.
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"],
},
},
};
}
Jeder Schlüssel in storage ist ein Sammlungsname. Das indexes-Array listet Felder auf, die effizient abgefragt werden können — Einzelfeld-Indizes als Strings, zusammengesetzte Indizes als String-Arrays.
Speicherung im Sandbox-Einstiegspunkt verwenden
Innerhalb des Sandbox-Einstiegspunkts greifen Sie auf Sammlungen über ctx.storage zu. Die Form spiegelt wider, was im Deskriptor deklariert wurde:
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 });
},
},
});
Der Zugriff auf eine Sammlung, die nicht im Deskriptor deklariert wurde, wirft eine Ausnahme — die Bridge setzt dies auf Laufzeitebene durch.
Sammlungs-API
interface StorageCollection<T = unknown> {
// Basis-CRUD
get(id: string): Promise<T | null>;
put(id: string, data: T): Promise<void>;
delete(id: string): Promise<boolean>;
exists(id: string): Promise<boolean>;
// Batch-Operationen
getMany(ids: string[]): Promise<Map<string, T>>;
putMany(items: Array<{ id: string; data: T }>): Promise<void>;
deleteMany(ids: string[]): Promise<number>;
// Abfrage (nur indizierte Felder)
query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
count(where?: WhereClause): Promise<number>;
}
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 — Paginierungs-Cursor (falls mehr Ergebnisse vorhanden sind)
// result.hasMore — boolean
Abfrageoptionen
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // Standard 50, max 1000
cursor?: string; // für Paginierung
}
Where-Klausel-Operatoren
Filtern Sie nach indizierten Feldern mit diesen Operatoren:
Exakte Übereinstimmung
where: {
status: "pending", // exakte String-Übereinstimmung
count: 5, // exakte Zahlen-Übereinstimmung
archived: false, // exakte Boolean-Übereinstimmung
} Bereich
where: {
createdAt: { gte: "2024-01-01" },
score: { gt: 50, lte: 100 },
}
// Verfügbar: gt, gte, lt, lte In Liste
where: {
status: { in: ["pending", "approved"] },
} Beginnt mit
where: {
slug: { startsWith: "blog-" },
} Sortierung
orderBy: { createdAt: "desc" } // neueste zuerst
orderBy: { score: "asc" } // niedrigste zuerst
Paginierung
Erschöpfen Sie einen Cursor, um alle übereinstimmenden Elemente zu durchlaufen:
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
const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({
status: "pending",
});
Batch-Operationen
const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);
// Gibt Map<string, T> zurück
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"]);
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 filtern | "status" und "formId" (getrennt) |
Zusammengesetzte Indizes unterstützen Abfragen, die nach dem ersten Feld filtern und optional nach dem zweiten sortieren:
// Mit Index ["formId", "createdAt"]:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } }); // verwendet Index
query({ where: { formId: "contact" } }); // verwendet Index (nur Filter)
query({ where: { createdAt: { gte: "2024-01-01" } } }); // verwendet NICHT diesen zusammengesetzten Index — Filter beginnt beim falschen Feld
Typsicherheit
Casten Sie den Sammlungszugriff für IntelliSense zu Element-Formen:
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
Wählen Sie den richtigen Mechanismus für jede Art von Daten:
| Anwendungsfall | Speicherung |
|---|---|
| Plugin-Betriebsdaten (Logs, Übermittlungen, Cache) | ctx.storage |
| Benutzerkonfigurierbare Einstellungen | ctx.kv mit settings:-Präfix |
| Interner Plugin-Status | ctx.kv mit state:-Präfix |
| Im Admin-UI editierbarer Inhalt | Site-Sammlungen (nicht Plugin-Speicher) |
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-Sammlung.
Implementierungsdetails
Plugin-Speicher verwendet eine einzelne Namespace-Tabelle:
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 erstellt Ausdrucks-Indizes für deklarierte Felder:
CREATE INDEX idx_forms_submissions_formId
ON _plugin_storage(json_extract(data, '$.formId'))
WHERE plugin_id = 'forms' AND collection = 'submissions';
Dieses Design gibt Ihnen keine Migrationen, Portabilität über SQLite/libSQL/D1, Plugin-Level-Isolation und parametrisierte Abfragen auf jedem Pfad.
Indizes hinzufügen
Wenn Sie in einem Plugin-Update einen Index hinzufügen, erstellt EmDash ihn automatisch beim nächsten Start. Das Hinzufügen eines Index ist sicher und erfordert keine Datenmigration. Wenn Sie einen Index entfernen, löscht EmDash ihn — und Abfragen auf diesem Feld beginnen mit einem Validierungsfehler zu scheitern, was das beabsichtigte Signal ist.