샌드박스 플러그인은 자체 데이터를 문서 컬렉션에 저장할 수 있습니다. 매니페스트에서 컬렉션과 인덱스를 선언하면 EmDash가 자동으로 스키마를 생성합니다. 마이그레이션을 작성할 필요가 없습니다.
이 페이지는 샌드박스 플러그인을 다룹니다. 컬렉션 API는 네이티브 플러그인에서도 동일합니다. 유일한 차이점은 네이티브 플러그인이 매니페스트 대신 definePlugin() 내부에서 storage를 선언한다는 것입니다.
매니페스트에서 storage 선언하기
샌드박스 플러그인의 경우 storage는 emdash-plugin.jsonc에 있습니다. 샌드박스 브리지가 플러그인이 접근할 수 있는 컬렉션을 알 수 있도록 빌드 시점에 선언이 표시되어야 합니다.
{
"slug": "forms",
// ...identity + profile...
"storage": {
"submissions": {
"indexes": [
"formId",
"status",
"createdAt",
["formId", "createdAt"],
["status", "createdAt"]
]
},
"forms": {
"indexes": ["slug"]
}
}
}
storage의 각 키는 컬렉션 이름입니다. indexes 배열은 효율적으로 쿼리할 수 있는 필드를 나열합니다. 단일 필드 인덱스는 문자열로, 복합 인덱스는 문자열 배열로 표시됩니다. 전체 규칙은 매니페스트 참조를 참조하세요.
런타임에서 storage 사용하기
src/plugin.ts에서 ctx.storage를 통해 컬렉션에 액세스합니다. 형태는 매니페스트에서 선언된 것을 반영합니다:
import type { SandboxedPlugin } from "emdash/plugin";
export default {
hooks: {
"content:afterSave": {
handler: async (event, ctx) => {
const { submissions } = ctx.storage;
await submissions.put("sub_123", {
formId: "contact",
email: "user@example.com",
status: "pending",
createdAt: new Date().toISOString(),
});
const item = await submissions.get("sub_123");
ctx.log.info("Stored submission", { id: item?.formId });
},
},
},
} satisfies SandboxedPlugin;
매니페스트에 선언되지 않은 컬렉션에 액세스하면 오류가 발생합니다. 브리지가 런타임 수준에서 이를 강제합니다.
컬렉션 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 { SandboxedPlugin } from "emdash/plugin";
import type { StorageCollection } from "emdash";
interface Submission {
formId: string;
email: string;
data: Record<string, unknown>;
status: "pending" | "approved" | "spam";
createdAt: string;
}
export default {
hooks: {
"content:afterSave": {
handler: async (event, ctx) => {
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
await submissions.put(`sub_${Date.now()}`, {
formId: "contact",
email: "user@example.com",
data: { message: "Hello" },
status: "pending",
createdAt: new Date().toISOString(),
});
},
},
},
} satisfies SandboxedPlugin;
두 import 모두 타입 전용이므로 샌드박스 플러그인은 emdash에 대한 런타임 종속성이 없습니다.
Storage vs Content vs KV
각 데이터 유형에 적합한 메커니즘을 선택하세요:
| 사용 사례 | Storage |
|---|---|
| 플러그인 운영 데이터 (로그, 제출, 캐시) | ctx.storage |
| 사용자 구성 가능한 설정 | ctx.kv (settings: 접두사) |
| 내부 플러그인 상태 | ctx.kv (state: 접두사) |
| 관리 UI에서 편집 가능한 콘텐츠 | 사이트 컬렉션 (플러그인 storage 아님) |
사이트 편집자가 일반 콘텐츠 편집기를 통해 관리 UI에서 데이터를 보거나 편집해야 하는 경우 대신 사이트 컬렉션을 생성하세요.
구현 세부사항
플러그인 storage는 단일 네임스페이스 테이블을 사용합니다:
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가 삭제하고 해당 필드에 대한 쿼리가 유효성 검사 오류로 실패하기 시작합니다. 이것이 의도된 신호입니다.