플러그인 스토리지
플러그인은 데이터베이스 마이그레이션을 작성하지 않고도 문서 컬렉션에 자체 데이터를 저장할 수 있습니다. 플러그인 정의에서 컬렉션과 인덱스를 선언하면 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
섹션 제목: “저장소 컬렉션 API”훅과 라우트에서 ctx.storage를 통해 컬렉션에 접근하세요:
"content:afterSave": async (event, ctx) => { const { submissions } = ctx.storage;
// CRUD 작업 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");}전체 API 참조
섹션 제목: “전체 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>;
// 배치 작업 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>;}데이터 쿼리
섹션 제목: “데이터 쿼리”query()를 사용하여 기준과 일치하는 문서를 검색하세요. 쿼리는 페이지네이션된 결과를 반환합니다.
const result = await ctx.storage.submissions.query({ where: { formId: "contact", status: "pending", }, orderBy: { createdAt: "desc" }, limit: 20,});
// result.items - { id, data } 배열// result.cursor - 페이지네이션 커서 (더 많은 결과가 있는 경우)// result.hasMore - 추가 페이지 존재 여부를 나타내는 불리언쿼리 옵션
섹션 제목: “쿼리 옵션”interface QueryOptions { where?: WhereClause; orderBy?: Record<string, "asc" | "desc">; limit?: number; // Default 50, max 1000 cursor?: string; // For pagination}Where 절 연산자
섹션 제목: “Where 절 연산자”다음 연산자를 사용하여 인덱싱된 필드로 필터링하세요:
where: { status: "pending", // Exact string match count: 5, // Exact number match archived: false // Exact boolean match}where: { createdAt: { gte: "2024-01-01" }, // 크거나 같음 score: { gt: 50, lte: 100 } // 사이 (배타적/포괄적)}
// 사용 가능: gt, gte, lt, ltewhere: { status: { in: ["pending", "approved"] }}where: { slug: { startsWith: "blog-" }}인덱싱된 필드로 결과를 정렬하세요:
orderBy: { createdAt: "desc";} // Newest firstorderBy: { 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
섹션 제목: “PaginatedResult”interface PaginatedResult<T> { items: T[]; cursor?: string; // Pass to next query for more results hasMore: boolean; // True if more pages exist}문서 개수 세기
섹션 제목: “문서 개수 세기”기준과 일치하는 문서의 개수를 세세요:
// Count allconst total = await ctx.storage.submissions!.count();
// 필터와 함께 개수 세기const pending = await ctx.storage.submissions!.count({ status: "pending",});배치 작업
섹션 제목: “배치 작업”대량 작업의 경우 배치 메서드를 사용하세요:
// Get multiple by IDconst 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"]);인덱스 설계
섹션 제목: “인덱스 설계”쿼리 패턴에 따라 인덱스를 선택하세요:
| 쿼리 패턴 | 필요한 인덱스 |
|---|---|
formId로 필터링 | "formId" |
formId로 필터링, createdAt로 정렬 | ["formId", "createdAt"] |
createdAt로만 정렬 | "createdAt" |
status와 formId로 필터링 | "status" 및 "formId" (별도) |
복합 인덱스는 첫 번째 필드로 필터링하고 선택적으로 두 번째 필드로 정렬하는 쿼리를 지원합니다:
// With index ["formId", "createdAt"]:
// 이렇게 작동합니다:query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });
// 이렇게도 작동합니다 (필터링만):query({ where: { formId: "contact" } });
// 이렇게는 복합 인덱스를 사용하지 않습니다 (잘못된 필드 순서):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) => { // 타입이 지정된 컬렉션으로 캐스팅 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); }, },});저장소 vs 콘텐츠 vs KV
섹션 제목: “저장소 vs 콘텐츠 vs KV”사용 사례에 맞는 적절한 저장 메커니즘을 사용하세요:
| 사용 사례 | 저장소 |
|---|---|
| 플러그인 운영 데이터 (로그, 제출물, 캐시) | ctx.storage |
| 사용자 구성 가능한 설정 | ctx.kv 및 settings: 접두사 |
| 내부 플러그인 상태 | ctx.kv 및 state: 접두사 |
| 관리자 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가 삭제합니다. 인덱싱되지 않은 필드에 대한 쿼리는 검증 오류와 함께 실패합니다.