콘텐츠로 이동

플러그인 스토리지

플러그인은 데이터베이스 마이그레이션을 작성하지 않고도 문서 컬렉션에 자체 데이터를 저장할 수 있습니다. 플러그인 정의에서 컬렉션과 인덱스를 선언하면 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 배열은 효율적으로 쿼리할 수 있는 필드를 나열합니다.

훅과 라우트에서 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");
}
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: {
status: "pending", // Exact string match
count: 5, // Exact number match
archived: false // Exact boolean match
}

인덱싱된 필드로 결과를 정렬하세요:

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;
}
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();
// 필터와 함께 개수 세기
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>
// 여러 개 저장
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);
},
},
});

사용 사례에 맞는 적절한 저장 메커니즘을 사용하세요:

사용 사례저장소
플러그인 운영 데이터 (로그, 제출물, 캐시)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가 삭제합니다. 인덱싱되지 않은 필드에 대한 쿼리는 검증 오류와 함께 실패합니다.