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