Sandboxed Plugins können ihre eigenen Daten in Dokumentensammlungen speichern. Sie deklarieren Sammlungen und Indizes im Manifest, und EmDash erstellt das Schema automatisch – keine Migrationen zu schreiben.
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() deklarieren, anstatt im Manifest.
Deklaration von Storage im Manifest
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 Sammlungen das Plugin zugreifen darf.
{
"slug": "forms",
// ...identity + profile...
"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 – einzelne Feldindizes als Strings, zusammengesetzte Indizes als String-Arrays. Siehe die Manifest-Referenz für die vollständigen Regeln.
Verwendung von Storage zur Laufzeit
In src/plugin.ts greifen Sie über ctx.storage auf Sammlungen zu. Die Form spiegelt wider, was im Manifest deklariert wurde:
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;
Der Zugriff auf eine Sammlung, die nicht im Manifest deklariert wurde, wirft einen Fehler – die Bridge erzwingt dies auf Laufzeitebene.
Collection API
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>;
// 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>;
}
Abfragen
query() gibt paginierte Ergebnisse zurück, gefiltert nach indizierten Feldern:
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 weitere Ergebnisse existieren)
// 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
Durchlaufen 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 |
|---|---|
Filtern nach formId | "formId" |
Filtern nach formId, sortieren nach createdAt | ["formId", "createdAt"] |
Nur sortieren nach createdAt | "createdAt" |
Filtern nach status und formId | "status" und "formId" (separat) |
Zusammengesetzte Indizes unterstützen Abfragen, die auf 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 auf Item-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;
}
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;
Beide Imports sind nur Typen, sodass ein Sandboxed Plugin keine Laufzeitabhä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, Submissions, Cache) | ctx.storage |
| Benutzer-konfigurierbare Einstellungen | ctx.kv mit settings:-Präfix |
| Interner Plugin-Status | ctx.kv mit state:-Präfix |
| In der Admin-UI editierbarer Inhalt | Site-Sammlungen (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-Sammlung.
Implementierungsdetails
Plugin-Storage verwendet eine einzelne namespaced 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 hinweg, Plugin-Level-Isolation und parametrisierte Abfragen auf jedem Pfad.
Hinzufügen von Indizes
Wenn Sie einen Index in einem Plugin-Update 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 schlagen mit einem Validierungsfehler fehl, was das beabsichtigte Signal ist.