Files
emdash-patch-imageupload/templates/blank/.agents/skills/creating-plugins/references/storage.md
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

7.1 KiB

Storage, KV & Settings

Plugins have three data mechanisms:

Mechanism Purpose Access
Storage Document collections with indexed queries ctx.storage
KV Key-value pairs for state and settings ctx.kv
Settings Schema Auto-generated admin UI for configuration admin.settingsSchema

Storage Collections

Declare in definePlugin({ storage }). EmDash creates the schema automatically — no migrations.

definePlugin({
	id: "forms",
	version: "1.0.0",

	storage: {
		submissions: {
			indexes: [
				"formId", // Single-field index
				"status",
				"createdAt",
				["formId", "createdAt"], // Composite index
			],
		},
		forms: {
			indexes: ["slug"],
		},
	},
});

Storage is scoped to the plugin — submissions in plugin forms is separate from submissions in another plugin.

CRUD

const { submissions } = ctx.storage;

await submissions.put("sub_123", { formId: "contact", email: "user@example.com" });
const item = await submissions.get("sub_123");
const exists = await submissions.exists("sub_123");
await submissions.delete("sub_123");

Batch Operations

const items = await submissions.getMany(["sub_1", "sub_2"]); // Map<string, T>

await submissions.putMany([
	{ id: "sub_1", data: { formId: "contact", status: "new" } },
	{ id: "sub_2", data: { formId: "contact", status: "new" } },
]);

const deletedCount = await submissions.deleteMany(["sub_1", "sub_2"]);

Querying

Only indexed fields can be queried. Non-indexed queries throw.

const result = await ctx.storage.submissions.query({
	where: {
		formId: "contact",
		status: "pending",
	},
	orderBy: { createdAt: "desc" },
	limit: 20,
});

// result.items - Array of { id, data }
// result.cursor - Pagination cursor
// result.hasMore - Boolean

Where Operators

// Exact match
where: { status: "pending" }

// Range
where: { createdAt: { gte: "2024-01-01" } }
where: { score: { gt: 50, lte: 100 } }

// In
where: { status: { in: ["pending", "approved"] } }

// Starts with
where: { slug: { startsWith: "blog-" } }

Pagination

let cursor: string | undefined;
do {
	const result = await ctx.storage.submissions!.query({
		orderBy: { createdAt: "desc" },
		limit: 100,
		cursor,
	});
	// process result.items
	cursor = result.cursor;
} while (cursor);

Counting

const total = await ctx.storage.submissions!.count();
const pending = await ctx.storage.submissions!.count({ status: "pending" });

Index Design

Query Pattern Index Needed
Filter by formId "formId"
Filter by formId, order by createdAt ["formId", "createdAt"]
Order by createdAt only "createdAt"

Composite indexes support filtering on the first field + ordering by the second.

Type Safety

interface Submission {
	formId: string;
	status: "pending" | "approved" | "spam";
	createdAt: string;
}

// Cast in hook/route handlers
const submissions = ctx.storage.submissions as StorageCollection<Submission>;

Full API

interface StorageCollection<T = unknown> {
	get(id: string): Promise<T | null>;
	put(id: string, data: T): Promise<void>;
	delete(id: string): Promise<boolean>;
	exists(id: string): Promise<boolean>;
	getMany(ids: string[]): Promise<Map<string, T>>;
	putMany(items: Array<{ id: string; data: T }>): Promise<void>;
	deleteMany(ids: string[]): Promise<number>;
	query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
	count(where?: WhereClause): Promise<number>;
}

KV Store

General-purpose key-value store. Use for internal state, cached computations, or programmatic access to settings.

interface KVAccess {
	get<T>(key: string): Promise<T | null>;
	set(key: string, value: unknown): Promise<void>;
	delete(key: string): Promise<boolean>;
	list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
}

Key Naming Conventions

Prefix Purpose Example
settings: User-configurable preferences settings:apiKey
state: Internal plugin state state:lastSync
cache: Cached data cache:results
await ctx.kv.set("settings:webhookUrl", url);
await ctx.kv.set("state:lastRun", new Date().toISOString());
const allSettings = await ctx.kv.list("settings:");

Settings Schema

Declare admin.settingsSchema to auto-generate a settings form in the admin UI:

admin: {
	settingsSchema: {
		siteTitle: {
			type: "string",
			label: "Site Title",
			description: "Used in title tags",
			default: "",
		},
		maxItems: {
			type: "number",
			label: "Max Items",
			default: 100,
			min: 1,
			max: 1000,
		},
		enabled: {
			type: "boolean",
			label: "Enabled",
			default: true,
		},
		theme: {
			type: "select",
			label: "Theme",
			options: [
				{ value: "light", label: "Light" },
				{ value: "dark", label: "Dark" },
			],
			default: "light",
		},
		apiKey: {
			type: "secret",
			label: "API Key",
			description: "Encrypted at rest",
		},
	},
}

Setting Types

Type UI Notes
string Text input Optional multiline: true for textarea
number Number input Optional min, max
boolean Toggle
select Dropdown Requires options: [{ value, label }]
secret Masked input Encrypted at rest, never shown after save

Reading Settings

Settings are accessed via KV with settings: prefix:

const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
const apiKey = await ctx.kv.get<string>("settings:apiKey");

Schema defaults are UI defaults only — not auto-persisted. Handle missing values with ?? or persist defaults in plugin:install:

"plugin:install": async (_event, ctx) => {
	await ctx.kv.set("settings:enabled", true);
	await ctx.kv.set("settings:maxItems", 100);
}

When to Use What

Use Case Mechanism
Admin-editable preferences settingsSchema + KV settings:
Internal state (timestamps, cursors) KV state:
Collections of documents (logs, submissions) Storage
Cached computations KV cache: