Storage
Sandboxed plugins can store their own records in document collections. Declare each collection and its indexes in the manifest. EmDash creates and updates the matching indexes when the plugin loads.
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
Section titled “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... "capabilities": ["content:read"],
"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.
Collection names start with a lowercase letter and contain lowercase letters, digits, or underscores. Index field names start with a letter and contain letters, digits, or underscores. Put a unique field or field combination in uniqueIndexes; a unique index is already queryable, so do not repeat it in indexes.
Using storage at runtime
Section titled “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";
const plugin: SandboxedPlugin = { 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 }); }, }, },};
export default plugin;Accessing a collection that wasn’t declared in the manifest throws — the bridge enforces this at the runtime level.
Collection API
Section titled “Collection API”Each declared collection provides the following read, write, batch, query, and count methods:
interface StorageCollection<T = unknown> { // Basic CRUD get(id: string): Promise<T | null>; put(id: string, data: T): Promise<void>; updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>>; 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>;}Conditional updates
Section titled “Conditional updates”Use updateIf() to change an existing document only when its stored fields match a condition. The database checks the condition and applies the changes in one atomic operation. This method is available to native plugins and sandboxed plugins on Cloudflare and Workerd.
Import its NumericDelta, UpdateIfArgs, and UpdateIfResult types with import type from emdash or emdash/plugin.
The following call approves a pending submission and increments its review count in the same operation:
const result = await ctx.storage.submissions.updateIf("sub_123", { where: { status: "pending" }, set: { status: "approved" }, delta: { reviewCount: { inc: 1 } },});
if (result.applied) { ctx.log.info("Submission approved", { submission: result.data });}A successful call returns { applied: true, data } with the complete updated document. It returns { applied: false } if the document is missing or the condition does not match. It never inserts a document.
The arguments have the following behavior:
whereis required and uses the same operators as query filters. An explicitwhere: {}adds no field conditions. Guard fields do not need declared query indexes because the update targets one document by ID.- A range filter needs at least one defined bound. Undefined bounds are ignored when another bound is defined. Numeric operands used by a guard must be finite.
setreplaces each supplied top-level field value and leaves other fields unchanged. Values must be JSON-serializable.deltaapplies exactly one{ inc: number }or{ dec: number }per field. Each operand must be a safe integer; negative operands are allowed.- A field cannot appear in both
setanddelta. Top-levelundefinedentries in either object are ignored. At least one defined field must remain.
Malformed update arguments reject the promise without changing the document. The arguments object, set, delta, and each delta operation must be plain objects.
Integer counters
Section titled “Integer counters”A delta starts a missing or null counter at 0. Existing counters and their results must be integers between Number.MIN_SAFE_INTEGER and Number.MAX_SAFE_INTEGER. A string, boolean, object, array, fractional number, unsafe integer, or out-of-range result causes the entire update to return { applied: false }. A stored document that is not a JSON object also returns { applied: false }. No fields change in either case.
Deltas can produce negative values. To keep a counter nonnegative, pair a decrement of n with a where condition requiring that counter to be at least n.
Retry serialization failures
Section titled “Retry serialization failures”PostgreSQL can reject concurrent writes with a serialization failure or deadlock. A deadlock can occur at any isolation level, including READ COMMITTED. In native plugins, these failures throw StorageSerializationError with code: "STORAGE_SERIALIZATION_FAILURE", retryable: true, and an optional sqlState (40001 or 40P01). Import the error class from emdash.
Use bounded retries with backoff for a standalone call. If the call is inside an explicit transaction, restart the entire transaction, including its reads; retrying the write inside the aborted transaction cannot succeed. Handle { applied: false } as an unapplied update rather than a serialization error.
Sandbox transports preserve the error name and retry metadata, but do not guarantee instanceof StorageSerializationError. Check code and retryable when handling errors across a sandbox boundary.
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”Pass these options to query() to filter, order, and page the result:
interface QueryOptions { where?: WhereClause; orderBy?: Record<string, "asc" | "desc">; limit?: number; // default 50, max 100 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”Set one or more indexed fields to ascending or descending order:
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”Count every record in a collection, or only records matching indexed fields:
const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({ status: "pending",});Batch operations
Section titled “Batch operations”Use the batch methods when one operation reads, writes, or deletes several known record IDs:
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 together | ["status", "formId"] |
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 fieldEvery field named anywhere in indexes or uniqueIndexes passes the query API’s indexed-field check. The order of a composite index still determines which query shapes the database can execute efficiently. Add a separate "createdAt" index when the plugin frequently filters or orders by that field without formId.
Type safety
Section titled “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;}
const plugin: SandboxedPlugin = { 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(), }); }, }, },};
export default plugin;Both imports are type-only, so a sandboxed plugin has no runtime dependency on emdash.
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.
How collections are isolated
Section titled “How collections are isolated”EmDash stores plugin documents with the plugin ID, collection name, record ID, JSON data, and timestamps. Those namespacing columns are part of every key and index. A plugin receives accessors only for the collections in its manifest, and the sandbox bridge rejects access to any other collection.
Declared fields become expression indexes alongside the plugin and collection namespace. EmDash generates the dialect-specific SQL for SQLite, D1, and PostgreSQL; plugin code uses the same collection API on each database.
Adding indexes
Section titled “Adding indexes”When a plugin update adds an index, EmDash creates it the next time the plugin loads. A unique index cannot be created while existing records contain duplicate values, so check and resolve duplicates before releasing that change.
When an update removes an index, EmDash drops it. Any query or ordering that still uses the field then fails validation. Update the code and manifest together.
Indexes are part of the manifest’s storage trust contract. Bump the plugin version whenever you add, remove, or change one, and use a major version when the change breaks an existing query or uniqueness assumption.