플러그인 스토리지

이 페이지

플러그인은 데이터베이스 마이그레이션을 작성하지 않고도 문서 컬렉션에 자체 데이터를 저장할 수 있습니다. 플러그인 정의에서 컬렉션과 인덱스를 선언하면 EmDash가 스키마를 자동으로 처리합니다.

스토리지 선언

definePlugin()에서 스토리지 컬렉션을 정의합니다.

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"],
		},
	},

	// ...
});

storage의 각 키는 컬렉션 이름입니다. indexes 배열은 효율적으로 쿼리할 수 있는 필드를 나열합니다.

스토리지 컬렉션 API

훅과 라우트에서 ctx.storage로 컬렉션에 접근합니다.

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

  // CRUD operations
  await submissions.put("sub_123", { formId: "contact", email: "[email protected]" });
  const item = await submissions.get("sub_123");
  const exists = await submissions.exists("sub_123");
  await submissions.delete("sub_123");
}

전체 API 참고

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()를 사용합니다. 쿼리는 페이지가 나뉜 결과를 반환합니다.

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

쿼리 옵션

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

where 절 연산자

인덱스된 필드에 다음 연산자로 필터합니다.

정확히 일치

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

범위

where: {
  createdAt: { gte: "2024-01-01" },     // Greater than or equal
  score: { gt: 50, lte: 100 }           // Between (exclusive/inclusive)
}

// Available: gt, gte, lt, lte

In

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

시작 문자열

where: {
  slug: { startsWith: "blog-" }
}

정렬

인덱스된 필드로 결과를 정렬합니다.

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

페이지네이션

결과는 페이지로 나뉩니다. 추가 페이지를 가져오려면 cursor를 사용합니다.

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

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

문서 개수

기준에 맞는 문서 수를 셉니다.

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

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

배치 작업

대량 작업에는 배치 메서드를 사용합니다.

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

인덱스 설계

쿼리 패턴에 맞게 인덱스를 선택합니다.

쿼리 패턴필요한 인덱스
formId로 필터"formId"
formId로 필터하고 createdAt로 정렬["formId", "createdAt"]
createdAt만으로 정렬"createdAt"
statusformId로 필터"status""formId"(별도)

복합 인덱스는 첫 필드로 필터하고 선택적으로 두 번째 필드로 정렬하는 쿼리를 지원합니다.

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

타입 안전성

IntelliSense를 위해 스토리지 컬렉션에 타입을 지정합니다.

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: "[email protected]",
				data: { message: "Hello" },
				status: "pending",
				createdAt: new Date().toISOString(),
			};

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

Storage vs Content vs KV

용도에 맞는 저장 방식을 사용합니다.

사용 사례스토리지
플러그인 운영 데이터(로그, 제출, 캐시)ctx.storage
사용자가 구성할 수 있는 설정접두사 settings:ctx.kv
플러그인 내부 상태접두사 state:ctx.kv
관리 UI에서 편집하는 콘텐츠사이트 컬렉션(플러그인 스토리지 아님)

구현 세부 사항

내부적으로 플러그인 스토리지는 단일 데이터베이스 테이블을 사용합니다.

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는 선언된 필드에 대해 표현식 인덱스를 만듭니다.

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

이 설계는 다음을 제공합니다.

  • 마이그레이션 없음 — 스키마는 플러그인 코드에 있습니다
  • 이식성 — D1, libSQL, SQLite에서 동작합니다
  • 격리 — 플러그인은 자신의 데이터에만 접근할 수 있습니다
  • 안전성 — SQL 인젝션 없음, 검증된 쿼리

인덱스 추가

플러그인 업데이트에서 인덱스를 추가하면 EmDash가 다음 시작 시 자동으로 생성합니다. 데이터 마이그레이션 없이 인덱스를 추가해도 안전합니다.

인덱스를 제거하면 EmDash가 해당 인덱스를 삭제합니다. 인덱스가 없는 필드에 대한 쿼리는 검증 오류로 실패합니다.