Storage

이 페이지

샌드박스 플러그인은 자체 레코드를 문서 컬렉션에 저장할 수 있습니다. 각 컬렉션과 인덱스를 매니페스트에 선언합니다. EmDash는 플러그인이 로드될 때 해당 인덱스를 생성하고 업데이트합니다.

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

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

샌드박스 플러그인의 경우, storage는 emdash-plugin.jsonc에 있습니다. 선언은 빌드 시점에 보여야 하며, 이를 통해 샌드박스 브리지가 플러그인이 접근할 수 있는 컬렉션을 파악합니다.

{
	"slug": "forms",
	// ...identity + profile...
	"capabilities": ["content:read"],

	"storage": {
		"submissions": {
			"indexes": [
				"formId",
				"status",
				"createdAt",
				["formId", "createdAt"],
				["status", "createdAt"]
			]
		},
		"forms": {
			"indexes": ["slug"]
		}
	}
}

storage 내의 각 키는 컬렉션 이름입니다. indexes 배열은 효율적으로 쿼리할 수 있는 필드를 나열합니다 — 단일 필드 인덱스는 문자열로, 복합 인덱스는 문자열 배열로 표시합니다. 전체 규칙은 매니페스트 레퍼런스를 참조하세요.

컬렉션 이름은 소문자로 시작하며 소문자, 숫자 또는 언더스코어를 포함합니다. 인덱스 필드 이름은 문자로 시작하며 문자, 숫자 또는 언더스코어를 포함합니다. 고유한 필드 또는 필드 조합은 uniqueIndexes에 넣으세요. 고유 인덱스는 이미 쿼리 가능하므로 indexes에서 반복하지 마세요.

런타임에서 storage 사용하기

src/plugin.ts에서 ctx.storage를 통해 컬렉션에 접근합니다. 구조는 매니페스트에서 선언한 것을 반영합니다:

import type { SandboxedPlugin } from "emdash/plugin";

const plugin: SandboxedPlugin = {
	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 });
			},
		},
	},
};

export default plugin;

매니페스트에 선언되지 않은 컬렉션에 접근하면 오류가 발생합니다 — 브리지가 런타임 레벨에서 이를 강제합니다.

컬렉션 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>;

	// 조건부 쓰기
	getVersioned(id: string): Promise<{ value: T; revision: string } | null>;
	compareAndSet(id: string, expectedRevision: string | null, data: T):
		Promise<{ applied: true; revision: string } | { applied: false }>;
	compareAndDelete(id: string, expectedRevision: string): Promise<{ applied: boolean }>;
	updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>>;

	// 배치 작업
	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>;
}

조건부 쓰기

동시 요청이 같은 레코드를 업데이트할 수 있는 경우 getVersioned()와 compareAndSet()을 사용합니다. 이 메서드는 선언된 ctx.storage 컬렉션과 ctx.kv 모두에서, 네이티브 및 샌드박스 플러그인 모두에서 사용할 수 있습니다. 각 작업은 호출하는 플러그인의 네임스페이스 내 하나의 키에 접근합니다.

메서드의 동작은 다음과 같습니다:

메서드결과
getVersioned(key)저장된 JSON 값과 불투명한 리비전, 또는 행이 없을 때 null. 저장된 JSON null은 { value: null, revision }을 반환합니다.
compareAndSet(key, null, value)행이 없을 때만 생성합니다.
compareAndSet(key, revision, value)저장된 리비전이 일치할 때만 전체 값을 교체합니다.
compareAndDelete(key, revision)저장된 리비전이 일치할 때만 행을 삭제합니다.

성공적인 compareAndSet()은 { applied: true, revision }을 반환합니다. 실패한 전제조건은 { applied: false }를 반환합니다. 잘못된 인수, 누락된 권한 및 데이터베이스 장애는 프로미스를 거부합니다. compareAndDelete()는 { applied: boolean }을 반환합니다. 관련 없는 유니크 인덱스 위반은 요청된 키가 없더라도 오류입니다.

리비전은 변경하지 않고 해당 키에 대해서만 반환하세요. 모든 쓰기는 동일한 값의 set(), put() 및 배치 쓰기를 포함하여 리비전을 변경합니다. 키를 삭제하고 재생성하면 이전 리비전이 무효화됩니다.

다음 헬퍼는 다른 요청이 먼저 쓸 때 최대 3회 재시도하면서 플러그인의 카운터에 완료된 작업을 추가합니다.

import type { PluginContext } from "emdash/plugin";

export async function recordCompletedJob(ctx: PluginContext): Promise<number> {
	const key = "state:completedJobs";
	for (let attempt = 0; attempt < 3; attempt++) {
		const current = await ctx.kv.getVersioned<number>(key);
		const count = (current?.value ?? 0) + 1;
		const result = await ctx.kv.compareAndSet(key, current?.revision ?? null, count);
		if (result.applied) return count;
	}
	throw new Error("Job counter changed repeatedly; try again later");
}

충돌 시 값을 다시 읽고 제안된 변경을 다시 계산하세요. 재시도를 제한하세요. 손실된 응답은 쓰기 결과를 알 수 없게 만들 수 있습니다. 이 메서드는 외부 작업이나 재시도된 작업 실행이 정확히 한 번 발생하는 것을 보장하지 않습니다.

원자성은 단일 키를 대상으로 합니다. 콘텐츠 항목을 읽고 플러그인 레코드를 쓰거나, 두 개의 플러그인 레코드를 쓰는 것은 별도의 작업입니다. 함께 변경해야 하는 필드는 하나의 값에 넣으세요. 작업 소유권이나 수량 제한과 같은 비즈니스 규칙은 해당 값을 구성할 때 적용하세요.

조건부 메서드는 최대 1,024개의 JavaScript 문자열 문자로 된 비어 있지 않은 키와 UTF-8 인코딩 후 최대 1 MiB의 JSON 값이 필요합니다. 리비전은 최대 128자의 비어 있지 않은 문자열이어야 합니다. 생략된 리비전은 유효하지 않습니다. 명시적 null만 생성을 요청합니다. 기존의 무조건적 메서드는 동작을 유지합니다.

이 메서드를 사용하기 전에 해당 코어 및 샌드박스 어댑터 버전을 배포하고 호스트 데이터베이스 마이그레이션을 적용하세요. 마이그레이션은 저장된 값을 보존하며, 롤링 배포 중에 이전 호스트 프로세스의 쓰기가 리비전을 무효화하도록 합니다.

조건부 업데이트

저장된 필드가 조건과 일치할 때만 기존 문서를 변경하려면 updateIf()를 사용합니다. 데이터베이스가 조건을 확인하고 하나의 원자적 작업으로 변경을 적용합니다. 이 메서드는 네이티브 플러그인과 Cloudflare 및 Workerd의 샌드박스 플러그인에서 사용할 수 있습니다.

emdash 또는 emdash/plugin에서 import type으로 NumericDelta, UpdateIfArgs, UpdateIfResult 타입을 가져옵니다.

다음 호출은 보류 중인 제출을 승인하고 같은 작업에서 리뷰 카운트를 증가시킵니다:

const result = await ctx.storage.submissions.updateIf("sub_123", {
	where: { status: "pending" },
	set: { status: "approved" },
	delta: { reviewCount: { inc: 1 } },
});

if (result.applied) {
	ctx.log.info("Submission approved", { submission: result.data });
}

성공적인 호출은 완전히 업데이트된 문서와 함께 { applied: true, data }를 반환합니다. 문서가 없거나 조건이 일치하지 않으면 { applied: false }를 반환합니다. 문서를 삽입하지 않습니다.

인수의 동작은 다음과 같습니다:

  • where는 필수이며 쿼리 필터와 동일한 연산자를 사용합니다. 명시적 where: {}는 필드 조건을 추가하지 않습니다. 가드 필드는 선언된 쿼리 인덱스가 필요하지 않습니다. 업데이트가 ID로 하나의 문서를 대상으로 하기 때문입니다.
  • 범위 필터는 정의된 경계가 하나 이상 필요합니다. 다른 경계가 정의된 경우 정의되지 않은 경계는 무시됩니다. 가드가 사용하는 숫자 피연산자는 유한해야 합니다.
  • set은 제공된 각 최상위 필드 값을 교체하고 다른 필드는 변경하지 않습니다. 값은 JSON 직렬화 가능해야 합니다.
  • delta는 필드당 정확히 하나의 { inc: number } 또는 { dec: number }를 적용합니다. 각 피연산자는 안전한 정수여야 합니다. 음수 피연산자가 허용됩니다.
  • 필드는 set과 delta 모두에 나타날 수 없습니다. 두 객체의 최상위 undefined 항목은 무시됩니다. 정의된 필드가 하나 이상 남아야 합니다.

잘못된 업데이트 인수는 문서를 변경하지 않고 프로미스를 거부합니다. 인수 객체, set, delta 및 각 델타 작업은 일반 객체여야 합니다.

정수 카운터

델타는 누락되거나 null인 카운터를 0에서 시작합니다. 기존 카운터와 결과는 Number.MIN_SAFE_INTEGER와 Number.MAX_SAFE_INTEGER 사이의 정수여야 합니다. 문자열, 불리언, 객체, 배열, 소수, 안전하지 않은 정수 또는 범위 밖의 결과는 전체 업데이트가 { applied: false }를 반환하게 합니다. JSON 객체가 아닌 저장된 문서도 { applied: false }를 반환합니다. 두 경우 모두 필드가 변경되지 않습니다.

델타는 음수 값을 생성할 수 있습니다. 카운터를 비음수로 유지하려면 n의 감소를 카운터가 최소 n이어야 한다는 where 조건과 결합하세요.

직렬화 실패 재시도

PostgreSQL은 직렬화 실패 또는 데드락으로 동시 쓰기를 거부할 수 있습니다. 데드락은 READ COMMITTED를 포함한 모든 격리 수준에서 발생할 수 있습니다. 네이티브 플러그인에서 이러한 실패는 code: "STORAGE_SERIALIZATION_FAILURE", retryable: true 및 선택적 sqlState(40001 또는 40P01)를 가진 StorageSerializationError를 던집니다. emdash에서 오류 클래스를 가져옵니다.

독립 호출에 대해서는 백오프가 있는 제한된 재시도를 사용하세요. 호출이 명시적 트랜잭션 내에 있다면 읽기를 포함한 전체 트랜잭션을 다시 시작하세요. 중단된 트랜잭션 내에서 쓰기를 재시도하면 성공할 수 없습니다. { applied: false }를 직렬화 오류가 아닌 적용되지 않은 업데이트로 처리하세요.

샌드박스 전송은 오류 이름과 재시도 메타데이터를 보존하지만 instanceof StorageSerializationError를 보장하지 않습니다. 샌드박스 경계를 넘어 오류를 처리할 때 code와 retryable을 확인하세요.

쿼리

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

쿼리 옵션

결과를 필터링, 정렬 및 페이지네이션하기 위해 이 옵션을 query()에 전달합니다:

interface QueryOptions {
	where?: WhereClause;
	orderBy?: Record<string, "asc" | "desc">;
	limit?: number;     // 기본값 50, 최대 100
	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",
});

배치 작업

하나의 작업이 여러 알려진 레코드 ID를 읽고, 쓰고 또는 삭제할 때 배치 메서드를 사용합니다:

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"]);

Cloudflare 샌드박스 어댑터에서는 putMany()가 항목을 순차적으로 씁니다. 쓰기가 실패하면 프로미스가 거부되고, 이전에 성공한 쓰기는 커밋된 상태로 남으며, 이후 항목은 시도되지 않습니다.

인덱스 설계

실제 쿼리 패턴에 기반하여 인덱스를 선택합니다:

쿼리 패턴필요한 인덱스
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" } } });                   // 이 복합 인덱스를 사용하지 않음 — 필터가 잘못된 필드에서 시작

indexes 또는 uniqueIndexes 어디에서든 이름이 지정된 모든 필드는 쿼리 API의 인덱싱된 필드 검사를 통과합니다. 복합 인덱스의 순서는 데이터베이스가 어떤 쿼리 형태를 효율적으로 실행할 수 있는지 결정합니다. 플러그인이 formId 없이 해당 필드로 자주 필터링하거나 정렬하는 경우 별도의 "createdAt" 인덱스를 추가하세요.

타입 안전성

항목 형태에 대한 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;
}

const plugin: SandboxedPlugin = {
	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(),
				});
			},
		},
	},
};

export default plugin;

두 임포트 모두 타입 전용이므로 샌드박스 플러그인은 emdash에 대한 런타임 종속성이 없습니다.

Storage vs 콘텐츠 vs KV

각 종류의 데이터에 적합한 메커니즘을 선택합니다:

사용 사례Storage
플러그인 운영 데이터 (로그, 제출, 캐시)ctx.storage
사용자 구성 가능한 설정ctx.settings
플러그인 내부 상태state: 프리픽스가 있는 ctx.kv
관리 UI에서 편집 가능한 콘텐츠사이트 컬렉션 (플러그인 storage 아님)

사이트 편집자가 관리 UI에서 일반 콘텐츠 에디터를 통해 데이터를 보거나 편집해야 하는 경우, 대신 사이트 컬렉션을 생성하세요.

컬렉션이 격리되는 방법

EmDash는 플러그인 문서를 플러그인 ID, 컬렉션 이름, 레코드 ID, JSON 데이터 및 타임스탬프와 함께 저장합니다. 이러한 네임스페이스 열은 모든 키와 인덱스의 일부입니다. 플러그인은 매니페스트에 있는 컬렉션의 접근자만 받으며, 샌드박스 브리지는 다른 컬렉션에 대한 접근을 거부합니다.

선언된 필드는 플러그인 및 컬렉션 네임스페이스와 함께 표현식 인덱스가 됩니다. EmDash는 SQLite, D1 및 PostgreSQL에 대한 방언별 SQL을 생성합니다. 플러그인 코드는 각 데이터베이스에서 동일한 컬렉션 API를 사용합니다.

인덱스 추가

플러그인 업데이트가 인덱스를 추가하면 EmDash는 다음에 플러그인이 로드될 때 이를 생성합니다. 기존 레코드에 중복 값이 포함되어 있으면 고유 인덱스를 생성할 수 없으므로, 해당 변경을 릴리스하기 전에 중복을 확인하고 해결하세요.

업데이트가 인덱스를 제거하면 EmDash는 이를 삭제합니다. 해당 필드를 여전히 사용하는 쿼리나 정렬은 유효성 검사에서 실패합니다. 코드와 매니페스트를 함께 업데이트하세요.

인덱스는 매니페스트의 storage 신뢰 계약의 일부입니다. 추가, 제거 또는 변경할 때마다 플러그인 버전을 올리고, 변경이 기존 쿼리나 고유성 가정을 깨는 경우 메이저 버전을 사용하세요.