Storage

이 페이지

샌드박스 플러그인은 자체 데이터를 문서 컬렉션에 저장할 수 있습니다. 매니페스트에서 컬렉션과 인덱스를 선언하면 EmDash가 자동으로 스키마를 생성합니다. 마이그레이션을 작성할 필요가 없습니다.

이 페이지는 샌드박스 플러그인을 다룹니다. 컬렉션 API는 네이티브 플러그인에서도 동일합니다. 유일한 차이점은 네이티브 플러그인이 매니페스트 대신 definePlugin() 내부에서 storage를 선언한다는 것입니다.

매니페스트에서 storage 선언하기

샌드박스 플러그인의 경우 storageemdash-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"
statusformId로 필터링"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가 삭제하고 해당 필드에 대한 쿼리가 유효성 검사 오류로 실패하기 시작합니다. 이것이 의도된 신호입니다.