플러그인은 데이터베이스 마이그레이션을 작성하지 않고도 문서 컬렉션에 자체 데이터를 저장할 수 있습니다. 플러그인 정의에서 컬렉션과 인덱스를 선언하면 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" |
status와 formId로 필터 | "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가 해당 인덱스를 삭제합니다. 인덱스가 없는 필드에 대한 쿼리는 검증 오류로 실패합니다.