Speicherung

Auf dieser Seite

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:

AbfragemusterBenö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:

AnwendungsfallSpeicherung
Plugin-Betriebsdaten (Logs, Übermittlungen, Cache)ctx.storage
Benutzerkonfigurierbare Einstellungenctx.kv mit settings:-Präfix
Interner Plugin-Statusctx.kv mit state:-Präfix
Im Admin-UI editierbarer InhaltSite-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.