Skip to content

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.

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.

src/index.ts
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.

Inside the sandbox entry, access collections via ctx.storage. The shape mirrors what was declared on the descriptor:

src/sandbox-entry.ts
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.

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

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
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // default 50, max 1000
cursor?: string; // for pagination
}

Filter by indexed fields using these operators:

where: {
status: "pending", // exact string match
count: 5, // exact number match
archived: false, // exact boolean match
}
orderBy: { createdAt: "desc" } // newest first
orderBy: { score: "asc" } // lowest first

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;
}
const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({
status: "pending",
});
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"]);

Choose indexes based on actual 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"]:
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

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(),
});
},
},
});

Pick the right mechanism for each kind of data:

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 the admin UISite 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.

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.

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.