Storage
Sandboxed plugins can store their own data in document collections. You declare collections and indexes on the plugin descriptor, and EmDash creates the schema automatically — no migrations to write.
This page covers sandboxed (standard-format) plugins. The collection API is identical for native plugins; the only difference is that native plugins declare storage directly inside definePlugin() rather than on a separate descriptor.
Declaring storage on the descriptor
Section titled “Declaring storage on the descriptor”For sandboxed plugins, storage lives on the descriptor — the file imported by astro.config.mjs, not the sandbox entry. Storage declarations need to be visible at build time so the sandbox bridge knows which collections the plugin is allowed to touch.
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"], }, }, };}Each key in storage is a collection name. The indexes array lists fields that can be queried efficiently — single-field indexes as strings, composite indexes as arrays of strings.
Using storage in the sandbox entry
Section titled “Using storage in the sandbox entry”Inside the sandbox entry, access collections via ctx.storage. The shape mirrors what was declared on the descriptor:
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: "user@example.com", status: "pending", createdAt: new Date().toISOString(), });
const item = await submissions.get("sub_123"); ctx.log.info("Stored submission", { id: item?.formId }); }, },});Accessing a collection that wasn’t declared on the descriptor throws — the bridge enforces this at the runtime level.
Collection API
Section titled “Collection API”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
Section titled “Querying”query() returns paginated results filtered by indexed fields:
const result = await ctx.storage.submissions.query({ where: { formId: "contact", status: "pending", }, orderBy: { createdAt: "desc" }, limit: 20,});
// result.items — Array<{ id, data }>// result.cursor — pagination cursor (if more results exist)// result.hasMore — booleanQuery 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" }, score: { gt: 50, lte: 100 },}// Available: gt, gte, lt, ltewhere: { status: { in: ["pending", "approved"] },}where: { slug: { startsWith: "blog-" },}Ordering
Section titled “Ordering”orderBy: { createdAt: "desc" } // newest firstorderBy: { score: "asc" } // lowest firstPagination
Section titled “Pagination”Drain a cursor to walk all matching items:
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;}Counting
Section titled “Counting”const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({ status: "pending",});Batch operations
Section titled “Batch operations”const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);// Returns Map<string, T>
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
Section titled “Index design”Choose indexes based on actual 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"]:query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } }); // uses indexquery({ where: { formId: "contact" } }); // uses index (filter only)query({ where: { createdAt: { gte: "2024-01-01" } } }); // does NOT use this composite — filter starts at the wrong fieldType safety
Section titled “Type safety”Cast collection access for IntelliSense on item shapes:
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: "user@example.com", data: { message: "Hello" }, status: "pending", createdAt: new Date().toISOString(), }); }, },});Storage vs content vs KV
Section titled “Storage vs content vs KV”Pick the right mechanism for each kind of data:
| 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 the admin UI | Site collections (not plugin storage) |
If site editors need to view or edit the data in the admin UI through the regular content editor, create a site collection instead.
Implementation details
Section titled “Implementation details”Plugin storage uses a single namespaced 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 gives you no migrations, portability across SQLite/libSQL/D1, plugin-level isolation, and parameterised queries on every path.
Adding indexes
Section titled “Adding indexes”When you add an index in a plugin update, EmDash creates it automatically on next startup. Adding an index is safe and requires no data migration. When you remove an index, EmDash drops it — and queries on that field start failing with a validation error, which is the intended signal.