INTEGRITY Dokumentace

Pravidla pro Durable Objects

Durable Objects poskytují výkonný primitivní stavební prvek pro vytváření stavových, koordinovaných aplikací. Každý Durable Object je jednovláknová, celosvětově jedinečná instance s vlastním trvalým úložištěm. Pro vytváření efektivních aplikací je zásadní rozumět tomu, jak s ohledem na tyto vlastnosti navrhovat architekturu.

Toto je průvodce, jak vytvářet efektivnější a správně fungující aplikace s Durable Objects.

Kdy použít Durable Objects

Durable Objects používejte pro stavovou koordinaci, ne pro bezstavové zpracování požadavků

Workers jsou bezstavové funkce: každý požadavek může běžet na jiné instanci, v jiné lokalitě, bez sdílené paměti mezi jednotlivými požadavky. Durable Objects jsou stavový výpočetní prostředek: každá instance má jedinečnou identitu, běží v jediné lokalitě a stav si uchovává napříč požadavky.

Používejte Durable Objects, když potřebujete:

Používejte obyčejné Workers, když potřebujete:

index.js
import { DurableObject } from "cloudflare:workers";

// ✅ Good use of Durable Objects: Seat booking requires coordination
// All booking requests for a venue must be serialized to prevent double-booking
export class SeatBooking extends DurableObject {
	async bookSeat(seatId, userId) {
		// Check if seat is already booked
		const existing = this.ctx.storage.sql
			.exec("SELECT user_id FROM bookings WHERE seat_id = ?", seatId)
			.toArray();

		if (existing.length > 0) {
			return { success: false, message: "Seat already booked" };
		}

		// Book the seat - this is safe because Durable Objects are single-threaded
		this.ctx.storage.sql.exec(
			"INSERT INTO bookings (seat_id, user_id, booked_at) VALUES (?, ?, ?)",
			seatId,
			userId,
			Date.now(),
		);

		return { success: true, message: "Seat booked successfully" };
	}
}

export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const eventId = url.searchParams.get("event") ?? "default";

		// Route to a Durable Object by event ID
		// All bookings for the same event go to the same instance
		const id = env.BOOKING.idFromName(eventId);
		const booking = env.BOOKING.get(id);

		const { seatId, userId } = await request.json();
		const result = await booking.bookSeat(seatId, userId);

		return Response.json(result, {
			status: result.success ? 200 : 409,
		});
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	BOOKING: DurableObjectNamespace<SeatBooking>;
}

// ✅ Good use of Durable Objects: Seat booking requires coordination
// All booking requests for a venue must be serialized to prevent double-booking
export class SeatBooking extends DurableObject<Env> {
async bookSeat(
seatId: string,
userId: string
): Promise<{ success: boolean; message: string }> {
// Check if seat is already booked
const existing = this.ctx.storage.sql
.exec<{ user_id: string }>(
"SELECT user_id FROM bookings WHERE seat_id = ?",
seatId
)
.toArray();

    	if (existing.length > 0) {
    		return { success: false, message: "Seat already booked" };
    	}

    	// Book the seat - this is safe because Durable Objects are single-threaded
    	this.ctx.storage.sql.exec(
    		"INSERT INTO bookings (seat_id, user_id, booked_at) VALUES (?, ?, ?)",
    		seatId,
    		userId,
    		Date.now()
    	);

    	return { success: true, message: "Seat booked successfully" };
    }
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);
		const eventId = url.searchParams.get("event") ?? "default";

    	// Route to a Durable Object by event ID
    	// All bookings for the same event go to the same instance
    	const id = env.BOOKING.idFromName(eventId);
    	const booking = env.BOOKING.get(id);

    	const { seatId, userId } = await request.json<{
    		seatId: string;
    		userId: string;
    	}>();
    	const result = await booking.bookSeat(seatId, userId);

    	return Response.json(result, {
    		status: result.success ? 200 : 409,
    	});
    },
};

Běžným vzorem je použít Workers jako bezstavový vstupní bod, který směruje požadavky na Durable Objects, kdykoli je potřeba koordinace. Worker se stará o autentizaci, validaci a formátování odpovědi, zatímco Durable Object obsluhuje stavovou logiku.

Návrh a sharding

Navrhněte Durable Objects kolem svého „atomu“ koordinace

Nejdůležitějším návrhovým rozhodnutím je určit, co každý Durable Object reprezentuje. Vytvořte jeden Durable Object pro každou logickou jednotku, která vyžaduje koordinaci: chatovou místnost, herní relaci, dokument, data uživatele nebo pracovní prostor tenanta.

V tom spočívá klíčová myšlenka, díky které jsou Durable Objects tak silné. Místo sdílené databáze se zámky získá každý "atom" vaší aplikace vlastní jednovláknové prostředí pro běh s vlastním privátním úložištěm.

index.js
import { DurableObject } from "cloudflare:workers";

// Each chat room is its own Durable Object instance
export class ChatRoom extends DurableObject {
	async sendMessage(userId, message) {
		// All messages to this room are processed sequentially by this single instance.
		// No race conditions, no distributed locks needed.
		this.ctx.storage.sql.exec(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
			userId,
			message,
			Date.now(),
		);
	}
}

export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const roomId = url.searchParams.get("room") ?? "lobby";

		// Each room ID maps to exactly one Durable Object instance globally
		const id = env.CHAT_ROOM.idFromName(roomId);
		const stub = env.CHAT_ROOM.get(id);

		await stub.sendMessage("user-123", "Hello, room!");
		return new Response("Message sent");
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

// Each chat room is its own Durable Object instance
export class ChatRoom extends DurableObject<Env> {
	async sendMessage(userId: string, message: string) {
		// All messages to this room are processed sequentially by this single instance.
		// No race conditions, no distributed locks needed.
		this.ctx.storage.sql.exec(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
			userId,
			message,
			Date.now()
		);
	}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);
		const roomId = url.searchParams.get("room") ?? "lobby";

		// Each room ID maps to exactly one Durable Object instance globally
		const id = env.CHAT_ROOM.idFromName(roomId);
		const stub = env.CHAT_ROOM.get(id);

		await stub.sendMessage("user-123", "Hello, room!");
		return new Response("Message sent");
	},
};

Nevytvářejte jeden „globální“ Durable Object, který zpracovává všechny požadavky:

index.js
import { DurableObject } from "cloudflare:workers";

// 🔴 Bad: A single Durable Object handling ALL chat rooms
export class ChatRoom extends DurableObject {
	async sendMessage(roomId, userId, message) {
		// All messages for ALL rooms go through this single instance.
		// This becomes a bottleneck as traffic grows.
		this.ctx.storage.sql.exec(
			"INSERT INTO messages (room_id, user_id, content) VALUES (?, ?, ?)",
			roomId,
			userId,
			message,
		);
	}
}

export default {
	async fetch(request, env) {
		// 🔴 Bad: Always using the same ID means one global instance
		const id = env.CHAT_ROOM.idFromName("global");
		const stub = env.CHAT_ROOM.get(id);

		await stub.sendMessage("room-123", "user-456", "Hello!");
		return new Response("Sent");
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

// 🔴 Bad: A single Durable Object handling ALL chat rooms
export class ChatRoom extends DurableObject<Env> {
async sendMessage(roomId: string, userId: string, message: string) {
// All messages for ALL rooms go through this single instance.
// This becomes a bottleneck as traffic grows.
this.ctx.storage.sql.exec(
"INSERT INTO messages (room_id, user_id, content) VALUES (?, ?, ?)",
roomId,
userId,
message
);
}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		// 🔴 Bad: Always using the same ID means one global instance
		const id = env.CHAT_ROOM.idFromName("global");
		const stub = env.CHAT_ROOM.get(id);

    	await stub.sendMessage("room-123", "user-456", "Hello!");
    	return new Response("Sent");
    },
};

Limity propustnosti zpráv

Jeden Durable Object dokáže obsloužit přibližně 500-1,000 požadavků za sekundu pro jednoduché operace. Tento limit se liší podle práce provedené na jeden požadavek:

Typ operace Propustnost
Jednoduché předávání (minimální parsování) ~1,000 req/sec
Středně náročné zpracování (parsování JSON, validace) ~500-750 req/sec
Komplexní operace (transformace, zápisy do úložiště) ~200-500 req/sec

Při návrhu svého "atomu" zohledněte očekávanou míru požadavků. Pokud váš případ použití tyto limity překračuje, rozdělte zátěž mezi více Durable Objects.

Představte si například hru v reálném čase s 50,000 souběžnými hráči, z nichž každý odesílá 10 aktualizací za sekundu. To dohromady generuje 500,000 požadavků za sekundu. Potřebovali byste 500-1,000 Durable Objects pro herní relace, nikoli jeden globální koordinátor.

Vypočítejte si požadavky na sharding:


Required DOs = (Total requests/second) / (Requests per DO capacity)

Používejte deterministická ID pro předvídatelné směrování

Použijte getByName() s významovými, deterministickými řetězci pro konzistentní směrování. Stejný vstup vždy vytvoří stejné ID Durable Object, takže požadavky pro stejnou logickou entitu vždy dorazí na stejnou instanci.

index.js
import { DurableObject } from "cloudflare:workers";

export class GameSession extends DurableObject {
	async join(playerId) {
		// Game logic here
	}
}

export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const gameId = url.searchParams.get("game");

		if (!gameId) {
			return new Response("Missing game ID", { status: 400 });
		}

		// ✅ Good: Deterministic ID from a meaningful string
		// All requests for "game-abc123" go to the same Durable Object
		const stub = env.GAME_SESSION.getByName(gameId);

		await stub.join("player-xyz");
		return new Response("Joined game");
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_SESSION: DurableObjectNamespace<GameSession>;
}

export class GameSession extends DurableObject<Env> {
	async join(playerId: string) {
		// Game logic here
	}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);
		const gameId = url.searchParams.get("game");

		if (!gameId) {
			return new Response("Missing game ID", { status: 400 });
		}

		// ✅ Good: Deterministic ID from a meaningful string
		// All requests for "game-abc123" go to the same Durable Object
		const stub = env.GAME_SESSION.getByName(gameId);

		await stub.join("player-xyz");
		return new Response("Joined game");
	},
};

Vytvoření stubu neinstancuje ani neprobouzí Durable Object. Ten se aktivuje až ve chvíli, kdy na stubu zavoláte nějakou metodu.

Použijte newUniqueId() pouze tehdy, když potřebujete novou náhodnou instanci a mapování si uložíte externě:

index.js
import { DurableObject } from "cloudflare:workers";

export class GameSession extends DurableObject {
	async join(playerId) {
		// Game logic here
	}
}

export default {
	async fetch(request, env) {
		// newUniqueId() creates a random ID - useful when creating new instances
		// You must store this ID somewhere (e.g., D1) to find it again later
		const id = env.GAME_SESSION.newUniqueId();
		const stub = env.GAME_SESSION.get(id);

		// Store the mapping: gameCode -> id.toString()
		// await env.DB.prepare("INSERT INTO games (code, do_id) VALUES (?, ?)").bind(gameCode, id.toString()).run();

		return Response.json({ gameId: id.toString() });
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_SESSION: DurableObjectNamespace<GameSession>;
}

export class GameSession extends DurableObject<Env> {
	async join(playerId: string) {
		// Game logic here
	}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		// newUniqueId() creates a random ID - useful when creating new instances
		// You must store this ID somewhere (e.g., D1) to find it again later
		const id = env.GAME_SESSION.newUniqueId();
		const stub = env.GAME_SESSION.get(id);

    	// Store the mapping: gameCode -> id.toString()
    	// await env.DB.prepare("INSERT INTO games (code, do_id) VALUES (?, ?)").bind(gameCode, id.toString()).run();

    	return Response.json({ gameId: id.toString() });
    },
};

Neukládejte všechna svá data do jediného Durable Object. Pokud máte hierarchická data (workspaces obsahující projekty, herní servery spravující zápasy), vytvořte pro každou entitu samostatný podřízený Durable Object. Nadřazený objekt koordinuje a sleduje podřízené objekty, zatímco ty si svůj stav spravují nezávisle.

Díky tomu je možný paralelismus: operace nad různými potomky mohou probíhat souběžně, přičemž každý potomek si zachovává vlastní jednovláknovou konzistenci (přečtěte si více o tomto vzoru).

index.js
import { DurableObject } from "cloudflare:workers";

// Parent: Coordinates matches, but doesn't store match data
export class GameServer extends DurableObject {
	async createMatch(matchName) {
		const matchId = crypto.randomUUID();

		// Store reference to the child in parent's database
		this.ctx.storage.sql.exec(
			"INSERT INTO matches (id, name, created_at) VALUES (?, ?, ?)",
			matchId,
			matchName,
			Date.now(),
		);

		// Initialize the child Durable Object
		const childId = this.env.GAME_MATCH.idFromName(matchId);
		const childStub = this.env.GAME_MATCH.get(childId);
		await childStub.init(matchId, matchName);

		return matchId;
	}

	async listMatches() {
		// Parent knows about all matches without waking up each child
		const cursor = this.ctx.storage.sql.exec(
			"SELECT id, name FROM matches ORDER BY created_at DESC",
		);
		return cursor.toArray();
	}
}

// Child: Handles its own game state independently
export class GameMatch extends DurableObject {
	async init(matchId, matchName) {
		await this.ctx.storage.put("matchId", matchId);
		await this.ctx.storage.put("matchName", matchName);
		this.ctx.storage.sql.exec(`
			CREATE TABLE IF NOT EXISTS players (
				id TEXT PRIMARY KEY,
				name TEXT NOT NULL,
				score INTEGER DEFAULT 0
			)
		`);
	}

	async addPlayer(playerId, playerName) {
		this.ctx.storage.sql.exec(
			"INSERT INTO players (id, name, score) VALUES (?, ?, 0)",
			playerId,
			playerName,
		);
	}

	async updateScore(playerId, score) {
		this.ctx.storage.sql.exec(
			"UPDATE players SET score = ? WHERE id = ?",
			score,
			playerId,
		);
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_SERVER: DurableObjectNamespace<GameServer>;
	GAME_MATCH: DurableObjectNamespace<GameMatch>;
}

// Parent: Coordinates matches, but doesn't store match data
export class GameServer extends DurableObject<Env> {
	async createMatch(matchName: string): Promise<string> {
		const matchId = crypto.randomUUID();

		// Store reference to the child in parent's database
		this.ctx.storage.sql.exec(
			"INSERT INTO matches (id, name, created_at) VALUES (?, ?, ?)",
			matchId,
			matchName,
			Date.now()
		);

		// Initialize the child Durable Object
		const childId = this.env.GAME_MATCH.idFromName(matchId);
		const childStub = this.env.GAME_MATCH.get(childId);
		await childStub.init(matchId, matchName);

		return matchId;
	}

	async listMatches(): Promise<{ id: string; name: string }[]> {
		// Parent knows about all matches without waking up each child
		const cursor = this.ctx.storage.sql.exec<{ id: string; name: string }>(
			"SELECT id, name FROM matches ORDER BY created_at DESC"
		);
		return cursor.toArray();
	}
}

// Child: Handles its own game state independently
export class GameMatch extends DurableObject<Env> {
	async init(matchId: string, matchName: string) {
		await this.ctx.storage.put("matchId", matchId);
		await this.ctx.storage.put("matchName", matchName);
		this.ctx.storage.sql.exec(`
			CREATE TABLE IF NOT EXISTS players (
				id TEXT PRIMARY KEY,
				name TEXT NOT NULL,
				score INTEGER DEFAULT 0
			)
		`);
	}

	async addPlayer(playerId: string, playerName: string) {
		this.ctx.storage.sql.exec(
			"INSERT INTO players (id, name, score) VALUES (?, ?, 0)",
			playerId,
			playerName
		);
	}

	async updateScore(playerId: string, score: number) {
		this.ctx.storage.sql.exec(
			"UPDATE players SET score = ? WHERE id = ?",
			score,
			playerId
		);
	}
}

S tímto vzorem:

Zvažte použití location hints u aplikací citlivých na latenci

Durable Object se ve výchozím nastavení vytváří v blízkosti místa prvního přijatého požadavku. Většině aplikací to vyhovuje, ale pokud chcete ovlivnit, kde se Durable Object vytvoří, můžete zadat nápovědu umístění (location hint).

index.js
import { DurableObject } from "cloudflare:workers";

export class GameSession extends DurableObject {
	// Game session logic
}

export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const gameId = url.searchParams.get("game") ?? "default";
		const region = url.searchParams.get("region") ?? "wnam"; // Western North America

		// Provide a location hint for where this Durable Object should be created
		const id = env.GAME_SESSION.idFromName(gameId);
		const stub = env.GAME_SESSION.get(id, { locationHint: region });

		return new Response("Connected to game session");
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_SESSION: DurableObjectNamespace<GameSession>;
}

export class GameSession extends DurableObject<Env> {
	// Game session logic
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);
		const gameId = url.searchParams.get("game") ?? "default";
		const region = url.searchParams.get("region") ?? "wnam"; // Western North America

    	// Provide a location hint for where this Durable Object should be created
    	const id = env.GAME_SESSION.idFromName(gameId);
    	const stub = env.GAME_SESSION.get(id, { locationHint: region });

    	return new Response("Connected to game session");
    },
};

Nápovědy k umístění jsou pouze doporučení, nikoli záruka. Podrobnosti najdete v Umístění dat pro dostupné regiony a další podrobnosti.

Úložiště a stav

Použití Durable Objects podporovaných SQLite

Úložiště SQLite je doporučeným úložištěm pro nové Durable Objects. Nabízí známé SQL API pro relační dotazy, indexy, transakce a lepší výkon než starší Durable Objects s úložištěm klíč-hodnota. Durable Objects SQLite navíc podporují KV API v synchronní i asynchronní verzi.

V konfiguraci Wrangler nastavte, aby vaše třída Durable Object používala úložiště SQLite:

{
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] }
  ]
}
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "ChatRoom" ]

Poté použijte SQL API ve svém Durable Object:

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	constructor(ctx, env) {
		super(ctx, env);

		// Create tables on first instantiation
		this.ctx.storage.sql.exec(`
    		CREATE TABLE IF NOT EXISTS messages (
    			id INTEGER PRIMARY KEY AUTOINCREMENT,
    			user_id TEXT NOT NULL,
    			content TEXT NOT NULL,
    			created_at INTEGER NOT NULL
    		)
    	`);
	}

	async addMessage(userId, content) {
		this.ctx.storage.sql.exec(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
			userId,
			content,
			Date.now(),
		);
	}

	async getRecentMessages(limit = 50) {
		// Use type parameter for typed results
		const cursor = this.ctx.storage.sql.exec(
			"SELECT * FROM messages ORDER BY created_at DESC LIMIT ?",
			limit,
		);
		return cursor.toArray();
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

type Message = {
id: number;
user_id: string;
content: string;
created_at: number;
};

export class ChatRoom extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env);

    	// Create tables on first instantiation
    	this.ctx.storage.sql.exec(`
    		CREATE TABLE IF NOT EXISTS messages (
    			id INTEGER PRIMARY KEY AUTOINCREMENT,
    			user_id TEXT NOT NULL,
    			content TEXT NOT NULL,
    			created_at INTEGER NOT NULL
    		)
    	`);
    }

    async addMessage(userId: string, content: string) {
    	this.ctx.storage.sql.exec(
    		"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
    		userId,
    		content,
    		Date.now()
    	);
    }

    async getRecentMessages(limit: number = 50): Promise<Message[]> {
    	// Use type parameter for typed results
    	const cursor = this.ctx.storage.sql.exec<Message>(
    		"SELECT * FROM messages ORDER BY created_at DESC LIMIT ?",
    		limit
    	);
    	return cursor.toArray();
    }
}

Viz Přístup k úložišti Durable Objects kde najdete další podrobnosti o SQL API.

Inicializace úložiště a spuštění migrací v konstruktoru

Použijte blockConcurrencyWhile() v konstruktoru pro spuštění migrací a inicializaci stavu ještě před zpracováním jakýchkoli požadavků. Tím se zajistí, že schéma je připravené, a zabrání se souběžným konfliktům (race conditions) během inicializace.

Pro produkční aplikace používejte migrační knihovnu, která automaticky řeší sledování verzí a jejich spouštění:

Pokud nechcete používat knihovnu, můžete verze schématu sledovat ručně pomocí _sql_schema_migrations tabulka. Následující příklad tento postup demonstruje:

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	constructor(ctx, env) {
		super(ctx, env);

		// blockConcurrencyWhile() ensures no requests are processed until this completes
		ctx.blockConcurrencyWhile(async () => {
			await this.migrate();
		});
	}

	async migrate() {
		// Create the migrations tracking table if it does not exist
		this.ctx.storage.sql.exec(`
			CREATE TABLE IF NOT EXISTS _sql_schema_migrations (
				id INTEGER PRIMARY KEY,
				applied_at TEXT NOT NULL DEFAULT (datetime('now'))
			);
		`);

		// Determine the current schema version
		const version = this.ctx.storage.sql
			.exec(
				"SELECT COALESCE(MAX(id), 0) as version FROM _sql_schema_migrations",
			)
			.one().version;

		if (version < 1) {
			this.ctx.storage.sql.exec(`
				CREATE TABLE IF NOT EXISTS messages (
					id INTEGER PRIMARY KEY AUTOINCREMENT,
					user_id TEXT NOT NULL,
					content TEXT NOT NULL,
					created_at INTEGER NOT NULL
				);
				CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
				INSERT INTO _sql_schema_migrations (id) VALUES (1);
			`);
		}

		if (version < 2) {
			// Future migration: add a new column
			this.ctx.storage.sql.exec(`
				ALTER TABLE messages ADD COLUMN edited_at INTEGER;
				INSERT INTO _sql_schema_migrations (id) VALUES (2);
			`);
		}
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env);

		// blockConcurrencyWhile() ensures no requests are processed until this completes
		ctx.blockConcurrencyWhile(async () => {
			await this.migrate();
		});
	}

	private async migrate() {
		// Create the migrations tracking table if it does not exist
		this.ctx.storage.sql.exec(`
			CREATE TABLE IF NOT EXISTS _sql_schema_migrations (
				id INTEGER PRIMARY KEY,
				applied_at TEXT NOT NULL DEFAULT (datetime('now'))
			);
		`);

		// Determine the current schema version
		const version =
			this.ctx.storage.sql
				.exec<{ version: number }>(
					"SELECT COALESCE(MAX(id), 0) as version FROM _sql_schema_migrations",
				)
				.one().version;

		if (version < 1) {
			this.ctx.storage.sql.exec(`
				CREATE TABLE IF NOT EXISTS messages (
					id INTEGER PRIMARY KEY AUTOINCREMENT,
					user_id TEXT NOT NULL,
					content TEXT NOT NULL,
					created_at INTEGER NOT NULL
				);
				CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
				INSERT INTO _sql_schema_migrations (id) VALUES (1);
			`);
		}

		if (version < 2) {
			// Future migration: add a new column
			this.ctx.storage.sql.exec(`
				ALTER TABLE messages ADD COLUMN edited_at INTEGER;
				INSERT INTO _sql_schema_migrations (id) VALUES (2);
			`);
		}
	}
}

Pochopte rozdíl mezi stavem v paměti a trvalým úložištěm

Durable Objects poskytují několik vrstev pro správu stavu, každou s jinými vlastnostmi:

Typ Rychlost Perzistence Případ použití
V paměti (vlastnosti třídy) Nejrychlejší Ztraceno při vyřazení z paměti nebo pádu Ukládání do mezipaměti, aktivní připojení
Úložiště SQLite Rychlé Trvalé i po restartech Primární úložiště dat
Externí (R2, D1) Proměnná Trvalé, dostupné napříč DO Velké soubory, sdílená data

Stav v paměti je není zachováno pokud je Durable Object kvůli neaktivitě odstraněn z paměti nebo pokud dojde k jeho pádu kvůli nezachycené výjimce. Důležitý stav proto vždy ukládejte trvale do úložiště SQLite.

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	// In-memory cache - fast but NOT preserved across evictions or crashes
	messageCache = null;

	async getRecentMessages() {
		// Return from cache if available (only valid while DO is in memory)
		if (this.messageCache !== null) {
			return this.messageCache;
		}

		// Otherwise, load from durable storage
		const cursor = this.ctx.storage.sql.exec(
			"SELECT * FROM messages ORDER BY created_at DESC LIMIT 100",
		);
		this.messageCache = cursor.toArray();
		return this.messageCache;
	}

	async addMessage(userId, content) {
		// ✅ Always persist to durable storage first
		this.ctx.storage.sql.exec(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
			userId,
			content,
			Date.now(),
		);

		// Then update the cache (if it exists)
		// If the DO crashes here, the message is still saved in SQLite
		this.messageCache = null; // Invalidate cache
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

type Message = {
id: number;
user_id: string;
content: string;
created_at: number;
};

export class ChatRoom extends DurableObject<Env> {
	// In-memory cache - fast but NOT preserved across evictions or crashes
	private messageCache: Message[] | null = null;

    async getRecentMessages(): Promise<Message[]> {
    	// Return from cache if available (only valid while DO is in memory)
    	if (this.messageCache !== null) {
    		return this.messageCache;
    	}

    	// Otherwise, load from durable storage
    	const cursor = this.ctx.storage.sql.exec<Message>(
    		"SELECT * FROM messages ORDER BY created_at DESC LIMIT 100"
    	);
    	this.messageCache = cursor.toArray();
    	return this.messageCache;
    }

    async addMessage(userId: string, content: string) {
    	// ✅ Always persist to durable storage first
    	this.ctx.storage.sql.exec(
    		"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
    		userId,
    		content,
    		Date.now()
    	);

    	// Then update the cache (if it exists)
    	// If the DO crashes here, the message is still saved in SQLite
    	this.messageCache = null; // Invalidate cache
    }
}

Vytvářejte indexy pro často dotazované sloupce

Stejně jako u jakékoli databáze indexy výrazně zrychlují čtení u sloupců, podle kterých se často filtruje. Daní za to jsou o něco vyšší nároky na úložiště a mírně pomalejší zápisy.

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	constructor(ctx, env) {
		super(ctx, env);

		ctx.blockConcurrencyWhile(async () => {
			this.ctx.storage.sql.exec(`
				CREATE TABLE IF NOT EXISTS messages (
					id INTEGER PRIMARY KEY AUTOINCREMENT,
					user_id TEXT NOT NULL,
					content TEXT NOT NULL,
					created_at INTEGER NOT NULL
				);

				-- Index for queries filtering by user
				CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages(user_id);

				-- Index for time-based queries (recent messages)
				CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);

				-- Composite index for user + time queries
				CREATE INDEX IF NOT EXISTS idx_messages_user_time ON messages(user_id, created_at);
			`);
		});
	}

	// This query benefits from idx_messages_user_time
	async getUserMessages(userId, since) {
		return this.ctx.storage.sql
			.exec(
				"SELECT * FROM messages WHERE user_id = ? AND created_at > ? ORDER BY created_at",
				userId,
				since,
			)
			.toArray();
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env);

		ctx.blockConcurrencyWhile(async () => {
			this.ctx.storage.sql.exec(`
				CREATE TABLE IF NOT EXISTS messages (
					id INTEGER PRIMARY KEY AUTOINCREMENT,
					user_id TEXT NOT NULL,
					content TEXT NOT NULL,
					created_at INTEGER NOT NULL
				);

				-- Index for queries filtering by user
				CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages(user_id);

				-- Index for time-based queries (recent messages)
				CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);

				-- Composite index for user + time queries
				CREATE INDEX IF NOT EXISTS idx_messages_user_time ON messages(user_id, created_at);
			`);
		});
	}

	// This query benefits from idx_messages_user_time
	async getUserMessages(userId: string, since: number) {
		return this.ctx.storage.sql
			.exec(
				"SELECT * FROM messages WHERE user_id = ? AND created_at > ? ORDER BY created_at",
				userId,
				since
			)
			.toArray();
	}
}

Pochopte, jak fungují vstupní a výstupní brány

Ačkoli jsou Durable Objects jednovláknové, JavaScriptový async/await může umožnit prokládání provádění více požadavků, zatímco požadavek čeká na výsledek asynchronní operace. Runtime Cloudflare používá vstupní brány a výstupní brány aby ve výchozím nastavení zabránil datovým závodům a zajistil správnost.

Vstupní brány blokovat nové události (příchozí požadavky, odpovědi fetch), dokud probíhá synchronní provádění JavaScriptu. Čekání na asynchronní operace jako fetch() nebo metody úložiště KV otevře vstupní bránu a umožní prolnutí dalších požadavků. Operace s úložištěm ale poskytují zvláštní ochranu:

index.js
import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
	// This code is safe due to input gates
	async increment() {
		// While these storage operations execute, no other requests
		// can interleave - input gate blocks new events
		const value = (await this.ctx.storage.get("count")) ?? 0;
		await this.ctx.storage.put("count", value + 1);
		return value + 1;
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	COUNTER: DurableObjectNamespace<Counter>;
}

export class Counter extends DurableObject<Env> {
	// This code is safe due to input gates
	async increment(): Promise<number> {
		// While these storage operations execute, no other requests
		// can interleave - input gate blocks new events
		const value = (await this.ctx.storage.get<number>("count")) ?? 0;
		await this.ctx.storage.put("count", value + 1);
		return value + 1;
	}
}

Výstupní brány podrží odchozí síťové zprávy (odpovědi, požadavky fetch), dokud se nedokončí čekající zápisy do úložiště. Tím se zajistí, že klienti nikdy neuvidí potvrzení dat, která ještě nebyla trvale uložena:

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	async sendMessage(userId, content) {
		// Write to storage - don't need to await for correctness
		this.ctx.storage.sql.exec(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
			userId,
			content,
			Date.now(),
		);

		// This response is held by the output gate until the write completes.
		// The client only receives "Message sent" after data is safely persisted.
		return "Message sent";
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
	async sendMessage(userId: string, content: string): Promise<string> {
		// Write to storage - don't need to await for correctness
		this.ctx.storage.sql.exec(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
			userId,
			content,
			Date.now()
		);

    	// This response is held by the output gate until the write completes.
    	// The client only receives "Message sent" after data is safely persisted.
    	return "Message sent";
    }
}

Slučování zápisů: Více zápisů do úložiště bez toho, aby mezi nimi proběhlo await volání jsou automaticky seskupena do jediné atomické implicitní transakce:

index.js
import { DurableObject } from "cloudflare:workers";

export class Account extends DurableObject {
	async transfer(fromId, toId, amount) {
		// ✅ Good: These writes are coalesced into one atomic transaction
		this.ctx.storage.sql.exec(
			"UPDATE accounts SET balance = balance - ? WHERE id = ?",
			amount,
			fromId,
		);
		this.ctx.storage.sql.exec(
			"UPDATE accounts SET balance = balance + ? WHERE id = ?",
			amount,
			toId,
		);
		this.ctx.storage.sql.exec(
			"INSERT INTO transfers (from_id, to_id, amount, created_at) VALUES (?, ?, ?, ?)",
			fromId,
			toId,
			amount,
			Date.now(),
		);
		// All three writes commit together atomically
	}

	// 🔴 Bad: await on KV operations breaks coalescing
	async transferBrokenKV(fromId, toId, amount) {
		const fromBalance = (await this.ctx.storage.get(`balance:${fromId}`)) ?? 0;
		await this.ctx.storage.put(`balance:${fromId}`, fromBalance - amount);
		// If the next write fails, the debit already committed!
		const toBalance = (await this.ctx.storage.get(`balance:${toId}`)) ?? 0;
		await this.ctx.storage.put(`balance:${toId}`, toBalance + amount);
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	ACCOUNT: DurableObjectNamespace<Account>;
}

export class Account extends DurableObject<Env> {
	async transfer(fromId: string, toId: string, amount: number) {
		// ✅ Good: These writes are coalesced into one atomic transaction
		this.ctx.storage.sql.exec(
			"UPDATE accounts SET balance = balance - ? WHERE id = ?",
			amount,
			fromId
		);
		this.ctx.storage.sql.exec(
			"UPDATE accounts SET balance = balance + ? WHERE id = ?",
			amount,
			toId
		);
		this.ctx.storage.sql.exec(
			"INSERT INTO transfers (from_id, to_id, amount, created_at) VALUES (?, ?, ?, ?)",
			fromId,
			toId,
			amount,
			Date.now()
		);
		// All three writes commit together atomically
	}

	// 🔴 Bad: await on KV operations breaks coalescing
	async transferBrokenKV(fromId: string, toId: string, amount: number) {
		const fromBalance = (await this.ctx.storage.get<number>(`balance:${fromId}`)) ?? 0;
		await this.ctx.storage.put(`balance:${fromId}`, fromBalance - amount);
		// If the next write fails, the debit already committed!
		const toBalance = (await this.ctx.storage.get<number>(`balance:${toId}`)) ?? 0;
		await this.ctx.storage.put(`balance:${toId}`, toBalance + amount);
	}
}

Další podrobnosti najdete v Durable Objects: jednoduché, rychlé, spolehlivé. Vyberte si všechny tři ↗ a slovníček pojmů.

Jak se vyhnout souběhu (race conditions) u I/O mimo úložiště

Vstupní brány chrání pouze během operací s úložištěm. Vstupně výstupní operace mimo úložiště, jako je fetch() nebo zápis do R2 umožňuje prolnutí dalších požadavků, což může vést k souběhu (race conditions):

index.js
import { DurableObject } from "cloudflare:workers";

export class Processor extends DurableObject {
	// ⚠️ Potential race condition: fetch() allows interleaving
	async processItem(id) {
		const item = await this.ctx.storage.get(`item:${id}`);

		if (item?.status === "pending") {
			// During this fetch, other requests CAN execute and modify storage
			const result = await fetch("https://api.example.com/process");

			// Another request may have already processed this item!
			await this.ctx.storage.put(`item:${id}`, { status: "completed" });
		}
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	PROCESSOR: DurableObjectNamespace<Processor>;
}

export class Processor extends DurableObject<Env> {
	// ⚠️ Potential race condition: fetch() allows interleaving
	async processItem(id: string) {
		const item = await this.ctx.storage.get<{ status: string }>(`item:${id}`);

    	if (item?.status === "pending") {
    		// During this fetch, other requests CAN execute and modify storage
    		const result = await fetch("https://api.example.com/process");

    		// Another request may have already processed this item!
    		await this.ctx.storage.put(`item:${id}`, { status: "completed" });
    	}
    }
}

Řešením je použít vzor optimistického zamykání (check-and-set): před externím voláním si přečtěte číslo verze a před zápisem ověřte, že se nezměnilo.

Použijte blockConcurrencyWhile() střídmě

blockConcurrencyWhile() metoda zaručuje, že se až do dokončení zadaného callbacku nezpracují žádné další události, a to i v případě, že callback provádí asynchronní I/O. Hodí se pro operace, které musí být atomické, například pro inicializaci stavu z úložiště v konstruktoru:

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	constructor(ctx, env) {
		super(ctx, env);

		// ✅ Good: Use blockConcurrencyWhile for one-time initialization
		ctx.blockConcurrencyWhile(async () => {
			this.ctx.storage.sql.exec(`
				CREATE TABLE IF NOT EXISTS messages (
					id INTEGER PRIMARY KEY,
					content TEXT
				)
			`);
		});
	}

	// 🔴 Bad: Don't use blockConcurrencyWhile on every request
	async sendMessageSlow(content) {
		await this.ctx.blockConcurrencyWhile(async () => {
			this.ctx.storage.sql.exec(
				"INSERT INTO messages (content) VALUES (?)",
				content,
			);
		});
		// If this takes ~5ms, you're limited to ~200 requests/second
	}

	// ✅ Good: Let output gates handle consistency
	async sendMessageFast(content) {
		this.ctx.storage.sql.exec(
			"INSERT INTO messages (content) VALUES (?)",
			content,
		);
		// Output gate ensures write completes before response is sent
		// Other requests can be processed concurrently
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env);

		// ✅ Good: Use blockConcurrencyWhile for one-time initialization
		ctx.blockConcurrencyWhile(async () => {
			this.ctx.storage.sql.exec(`
				CREATE TABLE IF NOT EXISTS messages (
					id INTEGER PRIMARY KEY,
					content TEXT
				)
			`);
		});
	}

	// 🔴 Bad: Don't use blockConcurrencyWhile on every request
	async sendMessageSlow(content: string) {
		await this.ctx.blockConcurrencyWhile(async () => {
			this.ctx.storage.sql.exec(
				"INSERT INTO messages (content) VALUES (?)",
				content
			);
		});
		// If this takes ~5ms, you're limited to ~200 requests/second
	}

	// ✅ Good: Let output gates handle consistency
	async sendMessageFast(content: string) {
		this.ctx.storage.sql.exec(
			"INSERT INTO messages (content) VALUES (?)",
			content
		);
		// Output gate ensures write completes before response is sent
		// Other requests can be processed concurrently
	}
}

Protože blockConcurrencyWhile() bloky all souběžnost bezpodmínečně, výrazně to snižuje propustnost. Pokud každé volání trvá ~5ms, je daný Durable Object omezen na zhruba 200 požadavků za sekundu. Vyhraďte si to pro inicializaci a migrace, ne pro běžné zpracování požadavků. Pro běžný provoz se místo toho spolehněte na vstupně výstupní brány a slučování zápisů.

Pro atomické operace typu přečíst-změnit-zapsat během zpracování požadavku dávejte přednost transaction() přes blockConcurrencyWhile(). Transakce zajišťují atomicitu operací úložiště, aniž by blokovaly nesouvisející souběžné požadavky.

Komunikace a návrh API

Použijte metody RPC namísto fetch() handler

Projekty s compatibility date z 2024-04-03 nebo novější by měly používat metody RPC. RPC je pohodlnější, poskytuje lepší typovou bezpečnost a odstraňuje nutnost ručního parsování požadavků a odpovědí.

Definujte veřejné metody ve své třídě Durable Object a volejte je přímo ze stubů s plnou podporou TypeScriptu:

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	// Public methods are automatically exposed as RPC endpoints
	async sendMessage(userId, content) {
		const createdAt = Date.now();
		const result = this.ctx.storage.sql.exec(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
			userId,
			content,
			createdAt,
		);
		const { id } = result.one();
		return { id, userId, content, createdAt };
	}

	async getMessages(limit = 50) {
		const cursor = this.ctx.storage.sql.exec(
			"SELECT * FROM messages ORDER BY created_at DESC LIMIT ?",
			limit,
		);

		return cursor.toArray().map((row) => ({
			id: row.id,
			userId: row.user_id,
			content: row.content,
			createdAt: row.created_at,
		}));
	}
}

export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const roomId = url.searchParams.get("room") ?? "lobby";

		const id = env.CHAT_ROOM.idFromName(roomId);
		// stub is typed as DurableObjectStub<ChatRoom>
		const stub = env.CHAT_ROOM.get(id);

		if (request.method === "POST") {
			const { userId, content } = await request.json();
			// Direct method call with full type checking
			const message = await stub.sendMessage(userId, content);
			return Response.json(message);
		}

		// TypeScript knows getMessages() returns Promise<Message[]>
		const messages = await stub.getMessages(100);
		return Response.json(messages);
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	// Type parameter provides typed method calls on the stub
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

type Message = {
id: number;
userId: string;
content: string;
createdAt: number;
};

export class ChatRoom extends DurableObject<Env> {
	// Public methods are automatically exposed as RPC endpoints
	async sendMessage(userId: string, content: string): Promise<Message> {
		const createdAt = Date.now();
		const result = this.ctx.storage.sql.exec<{ id: number }>(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
			userId,
			content,
			createdAt
		);
		const { id } = result.one();
		return { id, userId, content, createdAt };
	}

    async getMessages(limit: number = 50): Promise<Message[]> {
    	const cursor = this.ctx.storage.sql.exec<{
    		id: number;
    		user_id: string;
    		content: string;
    		created_at: number;
    	}>("SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", limit);

    	return cursor.toArray().map((row) => ({
    		id: row.id,
    		userId: row.user_id,
    		content: row.content,
    		createdAt: row.created_at,
    	}));
    }
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);
		const roomId = url.searchParams.get("room") ?? "lobby";

    	const id = env.CHAT_ROOM.idFromName(roomId);
    	// stub is typed as DurableObjectStub<ChatRoom>
    	const stub = env.CHAT_ROOM.get(id);

    	if (request.method === "POST") {
    		const { userId, content } = await request.json<{
    			userId: string;
    			content: string;
    		}>();
    		// Direct method call with full type checking
    		const message = await stub.sendMessage(userId, content);
    		return Response.json(message);
    	}

    	// TypeScript knows getMessages() returns Promise<Message[]>
    	const messages = await stub.getMessages(100);
    	return Response.json(messages);
    },
};

Viz Volání metod kde najdete další podrobnosti o RPC a starším fetch() handler.

Durable Objects inicializujte explicitně pomocí init() metoda

Durable Objects zevnitř neznají svůj vlastní název ani ID. Pokud váš Durable Object potřebuje znát svou identitu (například aby si uložil odkaz sám na sebe nebo komunikoval se souvisejícími objekty), musíte ji explicitně inicializovat.

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	roomId = null;

	// Call this after creating the Durable Object for the first time
	async init(roomId, createdBy) {
		// Check if already initialized
		const existing = await this.ctx.storage.get("roomId");
		if (existing) {
			return; // Already initialized
		}

		// Store the identity
		await this.ctx.storage.put("roomId", roomId);
		await this.ctx.storage.put("createdBy", createdBy);
		await this.ctx.storage.put("createdAt", Date.now());

		// Cache in memory for this session
		this.roomId = roomId;
	}

	async getRoomId() {
		if (this.roomId) {
			return this.roomId;
		}

		const stored = await this.ctx.storage.get("roomId");
		if (!stored) {
			throw new Error("ChatRoom not initialized. Call init() first.");
		}

		this.roomId = stored;
		return stored;
	}
}

export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const roomId = url.searchParams.get("room") ?? "lobby";

		const id = env.CHAT_ROOM.idFromName(roomId);
		const stub = env.CHAT_ROOM.get(id);

		// Initialize on first access
		await stub.init(roomId, "system");

		return new Response(`Room ${await stub.getRoomId()} ready`);
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
	private roomId: string | null = null;

	// Call this after creating the Durable Object for the first time
	async init(roomId: string, createdBy: string) {
		// Check if already initialized
		const existing = await this.ctx.storage.get("roomId");
		if (existing) {
			return; // Already initialized
		}

		// Store the identity
		await this.ctx.storage.put("roomId", roomId);
		await this.ctx.storage.put("createdBy", createdBy);
		await this.ctx.storage.put("createdAt", Date.now());

		// Cache in memory for this session
		this.roomId = roomId;
	}

	async getRoomId(): Promise<string> {
		if (this.roomId) {
			return this.roomId;
		}

		const stored = await this.ctx.storage.get<string>("roomId");
		if (!stored) {
			throw new Error("ChatRoom not initialized. Call init() first.");
		}

		this.roomId = stored;
		return stored;
	}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);
		const roomId = url.searchParams.get("room") ?? "lobby";

		const id = env.CHAT_ROOM.idFromName(roomId);
		const stub = env.CHAT_ROOM.get(id);

		// Initialize on first access
		await stub.init(roomId, "system");

		return new Response(`Room ${await stub.getRoomId()} ready`);
	},
};

Vždy await volání RPC

Při volání metod na stubu Durable Object vždy použijte await. Nevyčkaná volání vytvářejí visící promisy, což způsobuje, že se chyby ztratí a návratové hodnoty se nezpracují.

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	async sendMessage(userId, content) {
		const result = this.ctx.storage.sql.exec(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
			userId,
			content,
			Date.now(),
		);
		return result.one().id;
	}
}

export default {
	async fetch(request, env) {
		const id = env.CHAT_ROOM.idFromName("lobby");
		const stub = env.CHAT_ROOM.get(id);

		// 🔴 Bad: Not awaiting the call
		// The message ID is lost, and any errors are swallowed
		stub.sendMessage("user-123", "Hello");

		// ✅ Good: Properly awaited
		const messageId = await stub.sendMessage("user-123", "Hello");

		return Response.json({ messageId });
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
	async sendMessage(userId: string, content: string): Promise<number> {
		const result = this.ctx.storage.sql.exec<{ id: number }>(
			"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
			userId,
			content,
			Date.now()
		);
		return result.one().id;
	}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const id = env.CHAT_ROOM.idFromName("lobby");
		const stub = env.CHAT_ROOM.get(id);

    	// 🔴 Bad: Not awaiting the call
    	// The message ID is lost, and any errors are swallowed
    	stub.sendMessage("user-123", "Hello");

    	// ✅ Good: Properly awaited
    	const messageId = await stub.sendMessage("user-123", "Hello");

    	return Response.json({ messageId });
    },
};

Zpracování chyb

Zpracování chyb a využití hranic výjimek

Nezachycená výjimka v Durable Object ho může uvést do neznámého stavu a runtime kvůli tomu může instanci ukončit. Rizikové operace obalte do try...catch bloky a odpovídajícím způsobem ošetřete chyby.

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	async processMessage(userId, content) {
		// ✅ Good: Wrap risky operations in try...catch
		try {
			// Validate input before processing
			if (!content || content.length > 10000) {
				throw new Error("Invalid message content");
			}

			this.ctx.storage.sql.exec(
				"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
				userId,
				content,
				Date.now(),
			);

			// External call that might fail
			await this.notifySubscribers(content);
		} catch (error) {
			// Log the error for debugging
			console.error("Failed to process message:", error);

			// Re-throw if it's a validation error (don't retry)
			if (error instanceof Error && error.message.includes("Invalid")) {
				throw error;
			}

			// For transient errors, you might want to handle differently
			throw error;
		}
	}

	async notifySubscribers(content) {
		// External notification logic
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
	async processMessage(userId: string, content: string) {
		// ✅ Good: Wrap risky operations in try...catch
		try {
			// Validate input before processing
			if (!content || content.length > 10000) {
				throw new Error("Invalid message content");
			}

			this.ctx.storage.sql.exec(
				"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
				userId,
				content,
				Date.now()
			);

			// External call that might fail
			await this.notifySubscribers(content);
		} catch (error) {
			// Log the error for debugging
			console.error("Failed to process message:", error);

			// Re-throw if it's a validation error (don't retry)
			if (error instanceof Error && error.message.includes("Invalid")) {
				throw error;
			}

			// For transient errors, you might want to handle differently
			throw error;
		}
	}

	private async notifySubscribers(content: string) {
		// External notification logic
	}
}

Při volání Durable Objects z Workeru mohou chyby zahrnovat .retryable a .overloaded vlastnosti udávající, zda lze operaci zopakovat. U přechodných chyb implementujte exponenciální prodlevu, abyste systém nezahltili.

Viz Zpracování chyb pro podrobnosti o vlastnostech chyb, strategiích opakování a exponenciálních vzorech čekání (backoff).

WebSockets a komunikace v reálném čase

Používejte Hibernatable WebSockets API pro nákladovou efektivitu

Hibernatable WebSockets API umožňuje Durable Objects přejít do klidového režimu při zachování spojení WebSocket. Díky tomu výrazně klesají náklady u aplikací s velkým počtem neaktivních spojení.

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	async fetch(request) {
		const url = new URL(request.url);

		if (url.pathname === "/websocket") {
			// Check for WebSocket upgrade
			if (request.headers.get("Upgrade") !== "websocket") {
				return new Response("Expected WebSocket", { status: 400 });
			}

			const pair = new WebSocketPair();
			const [client, server] = Object.values(pair);

			// Accept the WebSocket with Hibernation API
			this.ctx.acceptWebSocket(server);

			return new Response(null, { status: 101, webSocket: client });
		}

		return new Response("Not found", { status: 404 });
	}

	// Called when a message is received (even after hibernation)
	async webSocketMessage(ws, message) {
		const data = typeof message === "string" ? message : "binary data";

		// Broadcast to all connected clients
		for (const client of this.ctx.getWebSockets()) {
			if (client !== ws && client.readyState === WebSocket.OPEN) {
				client.send(data);
			}
		}
	}

	// Called when a WebSocket is closed
	async webSocketClose(ws, code, reason, wasClean) {
		// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
		// auto-replies to Close frames. Calling close() is safe but no longer required.
		ws.close(code, reason);
		console.log(`WebSocket closed: ${code} ${reason}`);
	}

	// Called when a WebSocket error occurs
	async webSocketError(ws, error) {
		console.error("WebSocket error:", error);
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
	async fetch(request: Request): Promise<Response> {
		const url = new URL(request.url);

    	if (url.pathname === "/websocket") {
    		// Check for WebSocket upgrade
    		if (request.headers.get("Upgrade") !== "websocket") {
    			return new Response("Expected WebSocket", { status: 400 });
    		}

    		const pair = new WebSocketPair();
    		const [client, server] = Object.values(pair);

    		// Accept the WebSocket with Hibernation API
    		this.ctx.acceptWebSocket(server);

    		return new Response(null, { status: 101, webSocket: client });
    	}

    	return new Response("Not found", { status: 404 });
    }

    // Called when a message is received (even after hibernation)
    async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    	const data = typeof message === "string" ? message : "binary data";

    	// Broadcast to all connected clients
    	for (const client of this.ctx.getWebSockets()) {
    		if (client !== ws && client.readyState === WebSocket.OPEN) {
    			client.send(data);
    		}
    	}
    }

    // Called when a WebSocket is closed
    async webSocketClose(
    	ws: WebSocket,
    	code: number,
    	reason: string,
    	wasClean: boolean
    ) {
    	// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
    	// auto-replies to Close frames. Calling close() is safe but no longer required.
    	ws.close(code, reason);
    	console.log(`WebSocket closed: ${code} ${reason}`);
    }

    // Called when a WebSocket error occurs
    async webSocketError(ws: WebSocket, error: unknown) {
    	console.error("WebSocket error:", error);
    }
}

S Hibernation API může Durable Object přejít do klidového stavu, pokud neprobíhá žádné aktivní provádění JavaScriptu, avšak WebSocket připojení zůstávají otevřená. Jakmile dorazí zpráva, Durable Object se automaticky probudí.

Osvědčené postupy:

Viz WebSockets pro další podrobnosti.

Použijte serializeAttachment() pro trvalé uložení stavu jednotlivého připojení

Přílohy WebSocketu vám umožňují ukládat metadata jednotlivých připojení, která přežijí hibernaci. Použijte je například pro ID uživatelů, tokeny relace nebo jiná data vázaná na konkrétní připojení.

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	async fetch(request) {
		const url = new URL(request.url);

		if (url.pathname === "/websocket") {
			if (request.headers.get("Upgrade") !== "websocket") {
				return new Response("Expected WebSocket", { status: 400 });
			}

			const userId = url.searchParams.get("userId") ?? "anonymous";
			const username = url.searchParams.get("username") ?? "Anonymous";

			const pair = new WebSocketPair();
			const [client, server] = Object.values(pair);

			this.ctx.acceptWebSocket(server);

			// Store per-connection state that survives hibernation
			const state = {
				userId,
				username,
				joinedAt: Date.now(),
			};
			server.serializeAttachment(state);

			// Broadcast join message
			this.broadcast(`${username} joined the chat`);

			return new Response(null, { status: 101, webSocket: client });
		}

		return new Response("Not found", { status: 404 });
	}

	async webSocketMessage(ws, message) {
		// Retrieve the connection state (works even after hibernation)
		const state = ws.deserializeAttachment();

		const chatMessage = JSON.stringify({
			userId: state.userId,
			username: state.username,
			content: message,
			timestamp: Date.now(),
		});

		this.broadcast(chatMessage);
	}

	async webSocketClose(ws, code, reason) {
		// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
		// auto-replies to Close frames. Calling close() is safe but no longer required.
		ws.close(code, reason);
		const state = ws.deserializeAttachment();
		this.broadcast(`${state.username} left the chat`);
	}

	broadcast(message) {
		for (const client of this.ctx.getWebSockets()) {
			if (client.readyState === WebSocket.OPEN) {
				client.send(message);
			}
		}
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

type ConnectionState = {
	userId: string;
	username: string;
	joinedAt: number;
};

export class ChatRoom extends DurableObject<Env> {
	async fetch(request: Request): Promise<Response> {
		const url = new URL(request.url);

		if (url.pathname === "/websocket") {
			if (request.headers.get("Upgrade") !== "websocket") {
				return new Response("Expected WebSocket", { status: 400 });
			}

			const userId = url.searchParams.get("userId") ?? "anonymous";
			const username = url.searchParams.get("username") ?? "Anonymous";

			const pair = new WebSocketPair();
			const [client, server] = Object.values(pair);

			this.ctx.acceptWebSocket(server);

			// Store per-connection state that survives hibernation
			const state: ConnectionState = {
				userId,
				username,
				joinedAt: Date.now(),
			};
			server.serializeAttachment(state);

			// Broadcast join message
			this.broadcast(`${username} joined the chat`);

			return new Response(null, { status: 101, webSocket: client });
		}

		return new Response("Not found", { status: 404 });
	}

	async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
		// Retrieve the connection state (works even after hibernation)
		const state = ws.deserializeAttachment() as ConnectionState;

		const chatMessage = JSON.stringify({
			userId: state.userId,
			username: state.username,
			content: message,
			timestamp: Date.now(),
		});

		this.broadcast(chatMessage);
	}

	async webSocketClose(ws: WebSocket, code: number, reason: string) {
		// With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
		// auto-replies to Close frames. Calling close() is safe but no longer required.
		ws.close(code, reason);
		const state = ws.deserializeAttachment() as ConnectionState;
		this.broadcast(`${state.username} left the chat`);
	}

	private broadcast(message: string) {
		for (const client of this.ctx.getWebSockets()) {
			if (client.readyState === WebSocket.OPEN) {
				client.send(message);
			}
		}
	}
}

Plánování a životní cyklus

Používejte alarmy pro plánované úlohy jednotlivých entit

Každý Durable Object může naplánovat svou budoucí práci pomocí Alarms API, což Durable Objectu umožňuje spouštět úlohy na pozadí v libovolném intervalu bez příchozího požadavku, RPC volání nebo zprávy WebSocket.

Klíčové informace o alarmech:

index.js
import { DurableObject } from "cloudflare:workers";

export class GameMatch extends DurableObject {
	async startGame(durationMs = 60000) {
		await this.ctx.storage.put("gameStarted", Date.now());
		await this.ctx.storage.put("gameActive", true);

		// Schedule the game to end after the duration
		await this.ctx.storage.setAlarm(Date.now() + durationMs);
	}

	// Called when the alarm fires
	async alarm(alarmInfo) {
		const isActive = await this.ctx.storage.get("gameActive");

		if (!isActive) {
			return; // Game was already ended
		}

		// End the game
		await this.ctx.storage.put("gameActive", false);
		await this.ctx.storage.put("gameEnded", Date.now());

		// Calculate final scores, notify players, etc.
		try {
			await this.calculateFinalScores();
		} catch (err) {
			// If we're almost out of retries but still have work to do, schedule a new alarm
			// rather than letting our retries run out to ensure we keep getting invoked.
			if (alarmInfo && alarmInfo.retryCount >= 5) {
				await this.ctx.storage.setAlarm(Date.now() + 30 * 1000);
				return;
			}
			throw err;
		}

		// Schedule the next alarm only if there's more work to do
		// In this case, schedule cleanup in 24 hours
		await this.ctx.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
	}

	async calculateFinalScores() {
		// Game ending logic
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_MATCH: DurableObjectNamespace<GameMatch>;
}

export class GameMatch extends DurableObject<Env> {
	async startGame(durationMs: number = 60000) {
		await this.ctx.storage.put("gameStarted", Date.now());
		await this.ctx.storage.put("gameActive", true);

    	// Schedule the game to end after the duration
    	await this.ctx.storage.setAlarm(Date.now() + durationMs);
    }

    // Called when the alarm fires
    async alarm(alarmInfo?: AlarmInvocationInfo) {
    	const isActive = await this.ctx.storage.get<boolean>("gameActive");

    	if (!isActive) {
    		return; // Game was already ended
    	}

    	// End the game
    	await this.ctx.storage.put("gameActive", false);
    	await this.ctx.storage.put("gameEnded", Date.now());

    	// Calculate final scores, notify players, etc.
    	try {
    		await this.calculateFinalScores();
    	} catch (err) {
    		// If we're almost out of retries but still have work to do, schedule a new alarm
    		// rather than letting our retries run out to ensure we keep getting invoked.
    		if (alarmInfo && alarmInfo.retryCount >= 5) {
    			await this.ctx.storage.setAlarm(Date.now() + 30 * 1000);
    			return;
    		}
    		throw err;
    	}

    	// Schedule the next alarm only if there's more work to do
    	// In this case, schedule cleanup in 24 hours
    	await this.ctx.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
    }

    private async calculateFinalScores() {
    	// Game ending logic
    }
}

Zajistěte, aby obslužné rutiny alarmů byly idempotentní

Ve vzácných případech se může alarm spustit i vícekrát. Váš alarm() handler by měl bezpečně fungovat i při opakovaném spuštění, aniž by to způsobilo problémy.

index.js
import { DurableObject } from "cloudflare:workers";

export class Subscription extends DurableObject {
	async alarm() {
		// ✅ Good: Check state before performing the action
		const lastRenewal = await this.ctx.storage.get("lastRenewal");
		const renewalPeriod = 30 * 24 * 60 * 60 * 1000; // 30 days

		// If we already renewed recently, don't do it again
		if (lastRenewal && Date.now() - lastRenewal < renewalPeriod - 60000) {
			console.log("Already renewed recently, skipping");
			return;
		}

		// Perform the renewal
		const success = await this.processRenewal();

		if (success) {
			// Record the renewal time
			await this.ctx.storage.put("lastRenewal", Date.now());

			// Schedule the next renewal
			await this.ctx.storage.setAlarm(Date.now() + renewalPeriod);
		} else {
			// Retry in 1 hour
			await this.ctx.storage.setAlarm(Date.now() + 60 * 60 * 1000);
		}
	}

	async processRenewal() {
		// Payment processing logic
		return true;
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	SUBSCRIPTION: DurableObjectNamespace<Subscription>;
}

export class Subscription extends DurableObject<Env> {
	async alarm() {
		// ✅ Good: Check state before performing the action
		const lastRenewal = await this.ctx.storage.get<number>("lastRenewal");
		const renewalPeriod = 30 * 24 * 60 * 60 * 1000; // 30 days

		// If we already renewed recently, don't do it again
		if (lastRenewal && Date.now() - lastRenewal < renewalPeriod - 60000) {
			console.log("Already renewed recently, skipping");
			return;
		}

		// Perform the renewal
		const success = await this.processRenewal();

		if (success) {
			// Record the renewal time
			await this.ctx.storage.put("lastRenewal", Date.now());

			// Schedule the next renewal
			await this.ctx.storage.setAlarm(Date.now() + renewalPeriod);
		} else {
			// Retry in 1 hour
			await this.ctx.storage.setAlarm(Date.now() + 60 * 60 * 1000);
		}
	}

	private async processRenewal(): Promise<boolean> {
		// Payment processing logic
		return true;
	}
}

Vyčistěte úložiště pomocí deleteAll()

Chcete-li úplně vymazat úložiště Durable Object, zavolejte deleteAll(). Samotné smazání jednotlivých klíčů nebo zahození tabulek nestačí, protože část interních metadat může zůstat zachována. Workery s datem kompatibility starším než 2026-02-24 a nastavený alarm by měl nejprve odstranit alarm pomocí deleteAlarm().

index.js
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	async clearStorage() {
		// Delete all storage, including any set alarm
		await this.ctx.storage.deleteAll();

		// The Durable Object instance still exists, but with empty storage
		// A subsequent request will find no data
	}
}
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
	async clearStorage() {

    	// Delete all storage, including any set alarm
    	await this.ctx.storage.deleteAll();

    	// The Durable Object instance still exists, but with empty storage
    	// A subsequent request will find no data
    }
}

Návrh s ohledem na neočekávaná vypnutí

Durable Objects se mohou kdykoli vypnout kvůli nasazením, nečinnosti nebo rozhodnutím runtime. Namísto spoléhání na hooky při vypnutí (které nejsou k dispozici) navrhněte aplikaci tak, aby stav zapisovala postupně.

Shutdown hooky ani callbacky životního cyklu spouštěné před vypnutím nejsou k dispozici, protože Cloudflare nemůže zaručit jejich spuštění ve všech případech a externí software by se na tyto (nespolehlivé) mechanismy mohl příliš spoléhat.

Místo spoléhání na shutdown hooks můžete pravidelně zapisovat do úložiště a po výpadku se tak spolehlivě zotavit.

Pokud například zpracováváte datový proud a potřebujete si ukládat průběžný postup, zapisujte svou pozici do úložiště průběžně, místo abyste čekali s uložením až na konec:

// Good: Write progress as you go
async processData(data) {
  data.forEach(async (item, index) => {
    await this.processItem(item);
    // Save progress frequently
    await this.ctx.storage.put("lastProcessedIndex", index);
  });
}

I když to může působit neintuitivně, zápisy do úložiště Durable Object jsou rychlé a synchronní, takže stav můžete ukládat s minimálním dopadem na výkon.

Díky tomuto přístupu může váš Durable Object bezpečně pokračovat z libovolného bodu, i když se neočekávaně ukončí.

Antivzory, kterým se vyhnout

Nepoužívejte jeden Durable Object jako globální singleton

Jediný Durable Object obsluhující veškerý provoz se stává úzkým hrdlem. Asynchronní operace sice umožňují prokládání požadavků, ale veškeré synchronní vykonávání JavaScriptu běží v jediném vlákně a operace s úložištěm zajišťují serializaci, která propustnost omezuje.

Častou chybou je použití Durable Object pro globální omezování rychlosti nebo globální čítače, protože to nasměruje veškerý provoz přes jedinou instanci:

index.js
import { DurableObject } from "cloudflare:workers";

// 🔴 Bad: Global rate limiter - ALL requests go through one instance
export class RateLimiter extends DurableObject {
	async checkLimit(ip) {
		const key = `rate:${ip}`;
		const count = (await this.ctx.storage.get(key)) ?? 0;
		await this.ctx.storage.put(key, count + 1);
		return count < 100;
	}
}

// 🔴 Bad: Always using the same ID creates a global bottleneck
export default {
	async fetch(request, env) {
		// Every single request to your application goes through this one DO
		const limiter = env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName("global"));

		const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
		const allowed = await limiter.checkLimit(ip);

		if (!allowed) {
			return new Response("Rate limited", { status: 429 });
		}

		return new Response("OK");
	},
};
index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	RATE_LIMITER: DurableObjectNamespace<RateLimiter>;
}

// 🔴 Bad: Global rate limiter - ALL requests go through one instance
export class RateLimiter extends DurableObject<Env> {
	async checkLimit(ip: string): Promise<boolean> {
		const key = `rate:${ip}`;
		const count = (await this.ctx.storage.get<number>(key)) ?? 0;
		await this.ctx.storage.put(key, count + 1);
		return count < 100;
	}
}

// 🔴 Bad: Always using the same ID creates a global bottleneck
export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		// Every single request to your application goes through this one DO
		const limiter = env.RATE_LIMITER.get(
			env.RATE_LIMITER.idFromName("global")
		);

		const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
		const allowed = await limiter.checkLimit(ip);

		if (!allowed) {
			return new Response("Rate limited", { status: 429 });
		}

		return new Response("OK");
	},
};

Tento vzor neškáluje. S rostoucím provozem se jediný Durable Object stává úzkým hrdlem. Místo toho ve své aplikaci určete přirozené hranice koordinace (na uživatele, na místnost, na dokument) a pro každou z nich vytvořte samostatný Durable Object.

Testování a životní cyklus třídy

Testujte pomocí Vitest a počítejte se změnami v životním cyklu třídy

Použijte @cloudflare/vitest-plugin pro testování Durable Objects. Integrace poskytuje nástroje pro přímý přístup k instanci.

test/chat-room.test.js
import { env } from "cloudflare:workers";
import { runInDurableObject, runDurableObjectAlarm } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("ChatRoom", () => {
	it("should send and retrieve messages", async () => {
		const id = env.CHAT_ROOM.idFromName("test-room");
		const stub = env.CHAT_ROOM.get(id);

		// Call RPC methods directly on the stub
		await stub.sendMessage("user-1", "Hello!");
		await stub.sendMessage("user-2", "Hi there!");

		const messages = await stub.getMessages(10);
		expect(messages).toHaveLength(2);
	});

	it("can access instance internals and trigger alarms", async () => {
		const id = env.CHAT_ROOM.idFromName("test-room");
		const stub = env.CHAT_ROOM.get(id);

		// Access storage directly for verification
		await runInDurableObject(stub, async (instance, state) => {
			const count = state.storage.sql
				.exec("SELECT COUNT(*) as count FROM messages")
				.one();
			expect(count.count).toBe(2);
		});

		// Trigger alarms immediately without waiting
		const alarmRan = await runDurableObjectAlarm(stub);
		expect(alarmRan).toBe(false); // No alarm was scheduled
	});
});
test/chat-room.test.ts
import { env } from "cloudflare:workers";
import {
	runInDurableObject,
	runDurableObjectAlarm,
} from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("ChatRoom", () => {

it("should send and retrieve messages", async () => {
const id = env.CHAT_ROOM.idFromName("test-room");
const stub = env.CHAT_ROOM.get(id);

    	// Call RPC methods directly on the stub
    	await stub.sendMessage("user-1", "Hello!");
    	await stub.sendMessage("user-2", "Hi there!");

    	const messages = await stub.getMessages(10);
    	expect(messages).toHaveLength(2);
    });

    it("can access instance internals and trigger alarms", async () => {
    	const id = env.CHAT_ROOM.idFromName("test-room");
    	const stub = env.CHAT_ROOM.get(id);

    	// Access storage directly for verification
    	await runInDurableObject(stub, async (instance, state) => {
    		const count = state.storage.sql
    			.exec<{ count: number }>("SELECT COUNT(*) as count FROM messages")
    			.one();
    		expect(count.count).toBe(2);
    	});

    	// Trigger alarms immediately without waiting
    	const alarmRan = await runDurableObjectAlarm(stub);
    	expect(alarmRan).toBe(false); // No alarm was scheduled
    });
});

Nakonfigurujte Vitest ve svém vitest.config.ts:

import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config";

export default defineConfig({
	plugins: [
		cloudflareTest({
			wrangler: { configPath: "./wrangler.jsonc" },
		}),
	],
});

Pro změny datového schématu spouštějte migrace schématu v konstruktoru pomocí blockConcurrencyWhile(). Při přejmenování nebo odstranění třídy upravte příslušnou položku třídy v exports pole konfiguračního souboru Wrangler:

{
  "exports": {
    // Rename a class — also add a live entry for the new name
    "OldChatRoom": { "type": "durable-object", "state": "renamed", "renamed_to": "ChatRoom" },
    "ChatRoom": { "type": "durable-object", "storage": "sqlite" },
    // Delete a class (removes all data!)
    "DeprecatedRoom": { "type": "durable-object", "state": "deleted" }
  }
}
[exports.OldChatRoom]
type = "durable-object"
state = "renamed"
renamed_to = "ChatRoom"

[exports.ChatRoom]
type = "durable-object"
storage = "sqlite"

[exports.DeprecatedRoom]
type = "durable-object"
state = "deleted"

Viz Exporty tříd Durable Object kde najdete další podrobnosti o změnách životního cyklu třídy a Testování s Durable Objects pro komplexní vzory testování včetně dotazů SQLite a testování alarmů.