Plugin Storage
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
Section titled “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
Section titled “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: "user@example.com" }); const item = await submissions.get("sub_123"); const exists = await submissions.exists("sub_123"); await submissions.delete("sub_123");}Full API Reference
Section titled “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
Section titled “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 pagesQuery Options
Section titled “Query Options”interface QueryOptions { where?: WhereClause; orderBy?: Record<string, "asc" | "desc">; limit?: number; // Default 50, max 1000 cursor?: string; // For pagination}Where Clause Operators
Section titled “Where Clause Operators”Filter by indexed fields using these operators:
where: { status: "pending", // Exact string match count: 5, // Exact number match archived: false // Exact boolean match}where: { createdAt: { gte: "2024-01-01" }, // Greater than or equal score: { gt: 50, lte: 100 } // Between (exclusive/inclusive)}
// Available: gt, gte, lt, ltewhere: { status: { in: ["pending", "approved"] }}where: { slug: { startsWith: "blog-" }}Ordering
Section titled “Ordering”Order results by indexed fields:
orderBy: { createdAt: "desc";} // Newest firstorderBy: { score: "asc";} // Lowest firstPagination
Section titled “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
Section titled “PaginatedResult”interface PaginatedResult<T> { items: T[]; cursor?: string; // Pass to next query for more results hasMore: boolean; // True if more pages exist}Counting Documents
Section titled “Counting Documents”Count documents matching criteria:
// Count allconst total = await ctx.storage.submissions!.count();
// Count with filterconst pending = await ctx.storage.submissions!.count({ status: "pending",});Batch Operations
Section titled “Batch Operations”For bulk operations, use batch methods:
// Get multiple by IDconst items = await ctx.storage.submissions!.getMany(["sub_1", "sub_2", "sub_3"]);// Returns Map<string, T>
// Put multipleawait ctx.storage.submissions!.putMany([ { id: "sub_1", data: { formId: "contact", status: "new" } }, { id: "sub_2", data: { formId: "contact", status: "new" } },]);
// Delete multipleconst deletedCount = await ctx.storage.submissions!.deleteMany(["sub_1", "sub_2"]);Index Design
Section titled “Index Design”Choose indexes based on your query patterns:
| Query Pattern | Index 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
Section titled “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: "user@example.com", data: { message: "Hello" }, status: "pending", createdAt: new Date().toISOString(), };
await submissions.put(`sub_${Date.now()}`, submission); }, },});Storage vs Content vs KV
Section titled “Storage vs Content vs KV”Use the right storage mechanism for your use case:
| Use Case | Storage |
|---|---|
| Plugin operational data (logs, submissions, cache) | ctx.storage |
| User-configurable settings | ctx.kv with settings: prefix |
| Internal plugin state | ctx.kv with state: prefix |
| Content editable in admin UI | Site collections (not plugin storage) |
Implementation Details
Section titled “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
Section titled “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.