Skip to content

Storage

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.

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.

emdash-plugin.jsonc
{
"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.

In src/plugin.ts, access collections via ctx.storage. The shape mirrors what was declared in the manifest:

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

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

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.