샌드박스 플러그인은 문서 컬렉션에 자체 데이터를 저장할 수 있습니다. 플러그인 디스크립터에서 컬렉션과 인덱스를 선언하면 EmDash가 자동으로 스키마를 생성합니다. 마이그레이션 스크립트를 작성할 필요가 없습니다.
이 페이지는 샌드박스(표준 형식) 플러그인을 다룹니다. 컬렉션 API는 네이티브 플러그인에서도 동일합니다. 유일한 차이점은 네이티브 플러그인은 별도의 디스크립터가 아닌 definePlugin() 내부에서 직접 storage를 선언한다는 것입니다.
디스크립터에서 스토리지 선언하기
샌드박스 플러그인의 경우 storage는 디스크립터에 위치합니다. 이는 샌드박스 진입점이 아닌 astro.config.mjs에서 가져온 파일입니다. 스토리지 선언은 빌드 시 표시되어야 샌드박스 브리지가 플러그인이 액세스할 수 있는 컬렉션을 알 수 있습니다.
import type { PluginDescriptor } from "emdash";
export function formsPlugin(): PluginDescriptor {
return {
id: "forms",
version: "1.0.0",
format: "standard",
entrypoint: "@my-org/plugin-forms/sandbox",
storage: {
submissions: {
indexes: [
"formId",
"status",
"createdAt",
["formId", "createdAt"],
["status", "createdAt"],
],
},
forms: {
indexes: ["slug"],
},
},
};
}
storage의 각 키는 컬렉션 이름입니다. indexes 배열은 효율적으로 쿼리할 수 있는 필드를 나열합니다. 단일 필드 인덱스는 문자열로, 복합 인덱스는 문자열 배열로 표시됩니다.
샌드박스 진입점에서 스토리지 사용하기
샌드박스 진입점 내부에서는 ctx.storage를 통해 컬렉션에 액세스합니다. 구조는 디스크립터에서 선언한 내용과 일치합니다.
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx: PluginContext) => {
const { submissions } = ctx.storage;
await submissions.put("sub_123", {
formId: "contact",
email: "[email protected]",
status: "pending",
createdAt: new Date().toISOString(),
});
const item = await submissions.get("sub_123");
ctx.log.info("Stored submission", { id: item?.formId });
},
},
});
디스크립터에서 선언되지 않은 컬렉션에 액세스하면 예외가 발생합니다. 브리지는 런타임 수준에서 이를 강제합니다.
컬렉션 API
interface StorageCollection<T = unknown> {
// 기본 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 — Array<{ id, data }>
// result.cursor — 페이지네이션 커서(더 많은 결과가 있는 경우)
// result.hasMore — boolean
쿼리 옵션
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // 기본값 50, 최대 1000
cursor?: string; // 페이지네이션용
}
Where 절 연산자
다음 연산자를 사용하여 인덱스 필드로 필터링합니다.
정확한 일치
where: {
status: "pending", // 문자열 정확한 일치
count: 5, // 숫자 정확한 일치
archived: false, // 불린 정확한 일치
} 범위
where: {
createdAt: { gte: "2024-01-01" },
score: { gt: 50, lte: 100 },
}
// 사용 가능: gt, gte, lt, lte 목록 일치
where: {
status: { in: ["pending", "approved"] },
} 접두사 일치
where: {
slug: { startsWith: "blog-" },
} 정렬
orderBy: { createdAt: "desc" } // 최신 순
orderBy: { score: "asc" } // 최소 순
페이지네이션
커서를 소비하여 일치하는 모든 항목을 순회합니다.
async function getAllSubmissions(ctx: PluginContext) {
const all: Array<{ id: string; data: unknown }> = [];
let cursor: string | undefined;
do {
const result = await ctx.storage.submissions.query({
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
all.push(...result.items);
cursor = result.cursor;
} while (cursor);
return all;
}
카운트
const total = await ctx.storage.submissions.count();
const pending = await ctx.storage.submissions.count({
status: "pending",
});
배치 작업
const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);
// 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"(별도) |
복합 인덱스는 첫 번째 필드로 필터링하고 선택적으로 두 번째 필드로 정렬하는 쿼리를 지원합니다.
// 인덱스 ["formId", "createdAt"] 사용:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } }); // 인덱스 사용
query({ where: { formId: "contact" } }); // 인덱스 사용(필터만)
query({ where: { createdAt: { gte: "2024-01-01" } } }); // 이 복합 인덱스를 사용하지 않음 — 필터가 잘못된 필드에서 시작
타입 안전성
항목 형태의 IntelliSense를 얻기 위해 컬렉션 액세스를 캐스트합니다.
import type { StorageCollection, PluginContext } from "emdash";
interface Submission {
formId: string;
email: string;
data: Record<string, unknown>;
status: "pending" | "approved" | "spam";
createdAt: string;
}
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx: PluginContext) => {
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
await submissions.put(`sub_${Date.now()}`, {
formId: "contact",
email: "[email protected]",
data: { message: "Hello" },
status: "pending",
createdAt: new Date().toISOString(),
});
},
},
});
Storage vs content vs KV
각 데이터 유형에 적합한 메커니즘을 선택합니다.
| 사용 사례 | 스토리지 |
|---|---|
| 플러그인 운영 데이터(로그, 제출, 캐시) | ctx.storage |
| 사용자 구성 가능한 설정 | ctx.kv with settings: 접두사 |
| 내부 플러그인 상태 | ctx.kv with state: 접두사 |
| 관리 UI에서 편집 가능한 콘텐츠 | 사이트 컬렉션(플러그인 스토리지 아님) |
사이트 편집자가 관리 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';
이 설계는 마이그레이션 불필요, SQLite/libSQL/D1 간 이식성, 플러그인 수준 격리 및 모든 경로에서 매개변수화된 쿼리를 제공합니다.
인덱스 추가
플러그인 업데이트에서 인덱스를 추가하면 EmDash는 다음 시작 시 자동으로 생성합니다. 인덱스 추가는 안전하며 데이터 마이그레이션이 필요하지 않습니다. 인덱스를 제거하면 EmDash는 이를 삭제하고 해당 필드에 대한 쿼리는 유효성 검사 오류를 발생시키기 시작합니다. 이것이 의도된 신호입니다.