---
title: Plugin Storage
description: Persist plugin data in document collections with indexed queries.
---

import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";

Plugins can store their own data in document collections without writing database migrations. Declare collections and indexes in your plugin definition, and EmDash handles the schema automatically.

## Declaring Storage

Define storage collections in `definePlugin()`:

```typescript
import { definePlugin } from "emdash";

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

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

	// ...
});
```

Each key in `storage` is a collection name. The `indexes` array lists fields that can be queried efficiently.

<Aside type="note">
	Storage is scoped to the plugin. A `submissions` collection in the `forms` plugin is completely
	separate from `submissions` in another plugin.
</Aside>

## Storage Collection API

Access collections via `ctx.storage` in hooks and routes:

```typescript
"content:afterSave": async (event, ctx) => {
  const { submissions } = ctx.storage;

  // CRUD operations
  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");
}
```

### Full API Reference

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

## Querying Data

Use `query()` to retrieve documents matching criteria. Queries return paginated results.

```typescript
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 (if more results)
// result.hasMore - Boolean indicating more pages
```

### Query Options

```typescript
interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number; // Default 50, max 1000
	cursor?: string; // For pagination
}
```

### Where Clause Operators

Filter by indexed fields using these operators:

<Tabs>
  <TabItem label="Exact Match">
```typescript
where: {
  status: "pending",        // Exact string match
  count: 5,                 // Exact number match
  archived: false           // Exact boolean match
}
```
  </TabItem>
  <TabItem label="Range">
```typescript
where: {
  createdAt: { gte: "2024-01-01" },     // Greater than or equal
  score: { gt: 50, lte: 100 }           // Between (exclusive/inclusive)
}

// Available: gt, gte, lt, lte

````
  </TabItem>
  <TabItem label="In">
```typescript
where: {
  status: { in: ["pending", "approved"] }
}
````

  </TabItem>
  <TabItem label="Starts With">
```typescript
where: {
  slug: { startsWith: "blog-" }
}
```
  </TabItem>
</Tabs>

### Ordering

Order results by indexed fields:

```typescript
orderBy: {
	createdAt: "desc";
} // Newest first
orderBy: {
	score: "asc";
} // Lowest first
```

<Aside type="caution">
	You can only query and order by indexed fields. Queries on non-indexed fields throw an error. This
	prevents accidental full-table scans.
</Aside>

## Pagination

Results are paginated. Use `cursor` to fetch additional pages:

```typescript
async function getAllSubmissions(ctx: PluginContext) {
	const allItems = [];
	let cursor: string | undefined;

	do {
		const result = await ctx.storage.submissions!.query({
			orderBy: { createdAt: "desc" },
			limit: 100,
			cursor,
		});

		allItems.push(...result.items);
		cursor = result.cursor;
	} while (cursor);

	return allItems;
}
```

### PaginatedResult

```typescript
interface PaginatedResult<T> {
	items: T[];
	cursor?: string; // Pass to next query for more results
	hasMore: boolean; // True if more pages exist
}
```

## Counting Documents

Count documents matching criteria:

```typescript
// Count all
const total = await ctx.storage.submissions!.count();

// Count with filter
const pending = await ctx.storage.submissions!.count({
	status: "pending",
});
```

## Batch Operations

For bulk operations, use batch methods:

```typescript
// Get multiple by ID
const items = await ctx.storage.submissions!.getMany(["sub_1", "sub_2", "sub_3"]);
// Returns Map<string, T>

// Put multiple
await ctx.storage.submissions!.putMany([
	{ id: "sub_1", data: { formId: "contact", status: "new" } },
	{ id: "sub_2", data: { formId: "contact", status: "new" } },
]);

// Delete multiple
const deletedCount = await ctx.storage.submissions!.deleteMany(["sub_1", "sub_2"]);
```

## Index Design

Choose indexes based on your 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`          | `"status"` and `"formId"` (separate) |

Composite indexes support queries that filter on the first field and optionally order by the second:

```typescript
// With index ["formId", "createdAt"]:

// This works:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });

// This also works (filter only):
query({ where: { formId: "contact" } });

// This does NOT use the composite index (wrong field order):
query({ where: { createdAt: { gte: "2024-01-01" } } });
```

## Type Safety

Type your storage collections for better IntelliSense:

```typescript
interface Submission {
	formId: string;
	email: string;
	data: Record<string, unknown>;
	status: "pending" | "approved" | "spam";
	createdAt: string;
}

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

	storage: {
		submissions: {
			indexes: ["formId", "status", "createdAt"],
		},
	},

	hooks: {
		"content:afterSave": async (event, ctx) => {
			// Cast to typed collection
			const submissions = ctx.storage.submissions as StorageCollection<Submission>;

			const submission: Submission = {
				formId: "contact",
				email: "user@example.com",
				data: { message: "Hello" },
				status: "pending",
				createdAt: new Date().toISOString(),
			};

			await submissions.put(`sub_${Date.now()}`, submission);
		},
	},
});
```

## Storage vs Content vs KV

Use the right storage mechanism for your use case:

| 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 admin UI                       | Site collections (not plugin storage) |

<Aside type="tip">
	Plugin storage is for data the plugin owns and manages internally. If content editors need to view
	or edit the data in the admin UI, create a site collection instead.
</Aside>

## Implementation Details

Under the hood, plugin storage uses a single database table:

```sql
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:

```sql
CREATE INDEX idx_forms_submissions_formId
  ON _plugin_storage(json_extract(data, '$.formId'))
  WHERE plugin_id = 'forms' AND collection = 'submissions';
```

This design provides:

- **No migrations** — Schema lives in plugin code
- **Portability** — Works on D1, libSQL, SQLite
- **Isolation** — Plugins can only access their own data
- **Safety** — No SQL injection, validated queries

## Adding Indexes

When you add indexes in a plugin update, EmDash creates them automatically on next startup. This is safe—indexes can be added without data migration.

When you remove indexes, EmDash drops them. Queries on non-indexed fields will fail with a validation error.
