Sandboxed plugins can store their own data in document collections. You declare collections and indexes in the manifest, and EmDash creates the schema automatically — no migrations to write.
This page covers sandboxed plugins. The collection API is identical for native plugins; the only difference is that native plugins declare storage inside definePlugin() rather than in the manifest.
Declaring storage in the manifest
For sandboxed plugins, storage lives in emdash-plugin.jsonc. The declaration has to be visible at build time so the sandbox bridge knows which collections the plugin is allowed to touch.
{
"slug": "forms",
// ...identity + profile...
"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. See the manifest reference for the full rules.
Using storage at runtime
In src/plugin.ts, access collections via ctx.storage. The shape mirrors what was declared in the manifest:
import type { SandboxedPlugin } from "emdash/plugin";
export default {
hooks: {
"content:afterSave": {
handler: async (event, ctx) => {
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 });
},
},
},
} satisfies SandboxedPlugin;
Accessing a collection that wasn’t declared in the manifest throws — the bridge enforces this at the runtime level.
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
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 — boolean
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" },
score: { gt: 50, lte: 100 },
}
// Available: gt, gte, lt, lte In list
where: {
status: { in: ["pending", "approved"] },
} Starts with
where: {
slug: { startsWith: "blog-" },
} Ordering
orderBy: { createdAt: "desc" } // newest first
orderBy: { score: "asc" } // lowest first
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
const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({
status: "pending",
});
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
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 index
query({ where: { formId: "contact" } }); // uses index (filter only)
query({ where: { createdAt: { gte: "2024-01-01" } } }); // does NOT use this composite — filter starts at the wrong field
Type safety
Cast collection access for IntelliSense on item shapes:
import type { SandboxedPlugin } from "emdash/plugin";
import type { StorageCollection } from "emdash";
interface Submission {
formId: string;
email: string;
data: Record<string, unknown>;
status: "pending" | "approved" | "spam";
createdAt: string;
}
export default {
hooks: {
"content:afterSave": {
handler: async (event, ctx) => {
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(),
});
},
},
},
} satisfies SandboxedPlugin;
Both imports are type-only, so a sandboxed plugin has no runtime dependency on emdash.
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
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
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.