Skip to content

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.

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

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";
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.

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

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:

  • where is required and uses the same operators as query filters. An explicit where: {} 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.
  • set replaces each supplied top-level field value and leaves other fields unchanged. Values must be JSON-serializable.
  • delta applies 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 set and delta. Top-level undefined entries 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.

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.

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.

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

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
}

Filter by indexed fields using these operators:

where: {
status: "pending", // exact string match
count: 5, // exact number match
archived: false, // exact boolean match
}

Set one or more indexed fields to ascending or descending order:

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

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

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"]);

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

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

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.

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.

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.

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.