Plugins können eigene Daten in Dokumentkollektionen ablegen, ohne Datenbank-Migrationen zu schreiben. Deklarieren Sie Kollektionen und Indizes in der Plugin-Definition — EmDash übernimmt das Schema automatisch.
Storage deklarieren
Definieren Sie Storage-Kollektionen in definePlugin():
import { definePlugin } from "emdash";
export default definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: [
"formId", // Single-field index
"status",
"createdAt",
["formId", "createdAt"], // Composite index
["status", "createdAt"],
],
},
forms: {
indexes: ["slug"],
},
},
// ...
});
Jeder Schlüssel in storage ist ein Kollektionsname. Das Array indexes listet Felder auf, die effizient abgefragt werden können.
Storage Collection API
Zugriff auf Kollektionen über ctx.storage in Hooks und Routen:
"content:afterSave": async (event, ctx) => {
const { submissions } = ctx.storage;
// CRUD operations
await submissions.put("sub_123", { formId: "contact", email: "[email protected]" });
const item = await submissions.get("sub_123");
const exists = await submissions.exists("sub_123");
await submissions.delete("sub_123");
}
Vollständige API-Referenz
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>;
}
Daten abfragen
Verwenden Sie query(), um passende Dokumente zu laden. Abfragen liefern paginierte Ergebnisse.
const result = await ctx.storage.submissions.query({
where: {
formId: "contact",
status: "pending",
},
orderBy: { createdAt: "desc" },
limit: 20,
});
// result.items - Array of { id, data }
// result.cursor - Pagination cursor (if more results)
// result.hasMore - Boolean indicating more pages
Abfrageoptionen
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // Default 50, max 1000
cursor?: string; // For pagination
}
Where-Operatoren
Filtern Sie über indexierte Felder 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" }, // Greater than or equal
score: { gt: 50, lte: 100 } // Between (exclusive/inclusive)
}
// Available: gt, gte, lt, lte
In
where: {
status: { in: ["pending", "approved"] }
} Beginnt mit
where: {
slug: { startsWith: "blog-" }
} Sortierung
Sortieren Sie nach indexierten Feldern:
orderBy: {
createdAt: "desc";
} // Newest first
orderBy: {
score: "asc";
} // Lowest first
Paginierung
Ergebnisse sind paginiert. Verwenden Sie cursor für weitere Seiten:
async function getAllSubmissions(ctx: PluginContext) {
const allItems = [];
let cursor: string | undefined;
do {
const result = await ctx.storage.submissions!.query({
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
allItems.push(...result.items);
cursor = result.cursor;
} while (cursor);
return allItems;
}
PaginatedResult
interface PaginatedResult<T> {
items: T[];
cursor?: string; // Pass to next query for more results
hasMore: boolean; // True if more pages exist
}
Dokumente zählen
Zählen Sie Dokumente, die den Kriterien entsprechen:
// Count all
const total = await ctx.storage.submissions!.count();
// Count with filter
const pending = await ctx.storage.submissions!.count({
status: "pending",
});
Batch-Operationen
Für Massenoperationen nutzen Sie Batch-Methoden:
// Get multiple by ID
const items = await ctx.storage.submissions!.getMany(["sub_1", "sub_2", "sub_3"]);
// Returns Map<string, T>
// Put multiple
await ctx.storage.submissions!.putMany([
{ id: "sub_1", data: { formId: "contact", status: "new" } },
{ id: "sub_2", data: { formId: "contact", status: "new" } },
]);
// Delete multiple
const deletedCount = await ctx.storage.submissions!.deleteMany(["sub_1", "sub_2"]);
Index-Design
Wählen Sie Indizes nach Ihren Abfragemustern:
| Abfragemuster | Benötigter Index |
|---|---|
Filter nach formId | "formId" |
Filter nach formId, Sortierung nach createdAt | ["formId", "createdAt"] |
Nur Sortierung nach createdAt | "createdAt" |
Filter nach status und formId | "status" und "formId" (getrennt) |
Zusammengesetzte Indizes unterstützen Abfragen, die auf dem ersten Feld filtern und optional nach dem zweiten sortieren:
// With index ["formId", "createdAt"]:
// This works:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });
// This also works (filter only):
query({ where: { formId: "contact" } });
// This does NOT use the composite index (wrong field order):
query({ where: { createdAt: { gte: "2024-01-01" } } });
Typsicherheit
Typisieren Sie Storage-Kollektionen für bessere IntelliSense-Unterstützung:
interface Submission {
formId: string;
email: string;
data: Record<string, unknown>;
status: "pending" | "approved" | "spam";
createdAt: string;
}
definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: ["formId", "status", "createdAt"],
},
},
hooks: {
"content:afterSave": async (event, ctx) => {
// Cast to typed collection
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
const submission: Submission = {
formId: "contact",
email: "[email protected]",
data: { message: "Hello" },
status: "pending",
createdAt: new Date().toISOString(),
};
await submissions.put(`sub_${Date.now()}`, submission);
},
},
});
Storage vs. Content vs. KV
Wählen Sie den passenden Speicher für Ihren Anwendungsfall:
| Anwendungsfall | Speicher |
|---|---|
| Betriebsdaten des Plugins (Logs, Submissions, Cache) | ctx.storage |
| Vom Nutzer konfigurierbare Einstellungen | ctx.kv mit Präfix settings: |
| Interner Plugin-Zustand | ctx.kv mit Präfix state: |
| Im Admin bearbeitbare Inhalte | Site-Kollektionen (nicht Plugin-Storage) |
Implementierungsdetails
Intern nutzt Plugin-Storage eine einzige Datenbanktabelle:
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 legt Ausdrucks-Indizes für deklarierte Felder an:
CREATE INDEX idx_forms_submissions_formId
ON _plugin_storage(json_extract(data, '$.formId'))
WHERE plugin_id = 'forms' AND collection = 'submissions';
Dieses Design bietet:
- Keine Migrationen — Das Schema lebt im Plugin-Code
- Portabilität — Läuft auf D1, libSQL, SQLite
- Isolation — Plugins greifen nur auf eigene Daten zu
- Sicherheit — Kein SQL-Injection, validierte Abfragen
Indizes hinzufügen
Wenn Sie in einem Plugin-Update Indizes hinzufügen, erstellt EmDash sie beim nächsten Start automatisch. Das ist unkritisch — Indizes lassen sich ohne Datenmigration ergänzen.
Wenn Sie Indizes entfernen, droppt EmDash sie. Abfragen auf nicht mehr indexierte Felder schlagen mit einem Validierungsfehler fehl.