Plugin Storage

On this page

Plugins can store their own data in document collections without writing database migrations. Declare collections and indexes in your plugin definition, and EmDash handles the schema automatically.

Declaring Storage

Define storage collections 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"],
		},
	},

	// ...
});

Each key in storage is a collection name. The indexes array lists fields that can be queried efficiently.

Storage Collection API

Access collections via ctx.storage in hooks and routes:

"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");
}

Full API Reference

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>;
}

Querying Data

Use query() to retrieve documents matching criteria. Queries return paginated results.

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

Query Options

interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number; // Default 50, max 1000
	cursor?: string; // For pagination
}

Where Clause Operators

Filter by indexed fields using these operators:

Exact Match

where: {
  status: "pending",        // Exact string match
  count: 5,                 // Exact number match
  archived: false           // Exact boolean match
}

Range

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"] }
}

Starts With

where: {
  slug: { startsWith: "blog-" }
}

Ordering

Order results by indexed fields:

orderBy: {
	createdAt: "desc";
} // Newest first
orderBy: {
	score: "asc";
} // Lowest first

Pagination

Results are paginated. Use cursor to fetch additional pages:

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
}

Counting Documents

Count documents matching criteria:

// Count all
const total = await ctx.storage.submissions!.count();

// Count with filter
const pending = await ctx.storage.submissions!.count({
	status: "pending",
});

Batch Operations

For bulk operations, use batch methods:

// 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

Choose indexes based on your query patterns:

Query PatternIndex Needed
Filter by formId"formId"
Filter by formId, order by createdAt["formId", "createdAt"]
Order by createdAt only"createdAt"
Filter by status and formId"status" and "formId" (separate)

Composite indexes support queries that filter on the first field and optionally order by the second:

// 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" } } });

Type Safety

Type your storage collections for better IntelliSense:

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

Use the right storage mechanism for your use case:

Use CaseStorage
Plugin operational data (logs, submissions, cache)ctx.storage
User-configurable settingsctx.kv with settings: prefix
Internal plugin statectx.kv with state: prefix
Content editable in admin UISite collections (not plugin storage)

Implementation Details

Under the hood, plugin storage uses a single database table:

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 creates expression indexes for declared fields:

CREATE INDEX idx_forms_submissions_formId
  ON _plugin_storage(json_extract(data, '$.formId'))
  WHERE plugin_id = 'forms' AND collection = 'submissions';

This design provides:

  • No migrations — Schema lives in plugin code
  • Portability — Works on D1, libSQL, SQLite
  • Isolation — Plugins can only access their own data
  • Safety — No SQL injection, validated queries

Adding Indexes

When you add indexes in a plugin update, EmDash creates them automatically on next startup. This is safe—indexes can be added without data migration.

When you remove indexes, EmDash drops them. Queries on non-indexed fields will fail with a validation error.