INTEGRITY Документация

Правила Durable Objects

Durable Objects предоставляют мощный примитив для создания stateful, координированных приложений. Каждый Durable Object представляет собой однопоточный, глобально уникальный экземпляр с собственным постоянным хранилищем. Понимание того, как проектировать приложения с учётом этих свойств, необходимо для создания эффективных решений.

Это руководство о том, как создавать более эффективные и корректные приложения на основе Durable Object.

Когда использовать Durable Objects

Используйте Durable Objects для координации с сохранением состояния, а не для обработки запросов без сохранения состояния

Workers представляют собой функции без состояния: каждый запрос может выполняться на другом экземпляре, в другом регионе, без общей памяти между запросами. Durable Objects, напротив, являются вычислениями с состоянием: каждый экземпляр имеет уникальный идентификатор, работает в одном регионе и сохраняет состояние между запросами.

Используйте Durable Objects, если вам нужно:

Используйте обычные Workers, если вам нужно:

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,
    	});
    },
};

Распространённый паттерн заключается в использовании Workers в качестве не хранящей состояние точки входа, которая направляет запросы к Durable Objects, когда требуется координация. Worker отвечает за аутентификацию, валидацию и форматирование ответа, а Durable Object отвечает за логику с состоянием.

Проектирование и шардирование

Проектируйте Durable Object вокруг своего «атома» координации

Важнейшее архитектурное решение состоит в том, чтобы определить, что будет представлять собой каждый Durable Object. Создавайте один Durable Object на каждую логическую единицу, которой требуется координация: чат-комнату, игровую сессию, документ, данные пользователя или рабочее пространство арендатора.

В этом и заключается ключевая идея, которая делает Durable Objects такими мощными. Вместо общей базы данных с блокировками каждый «атом» вашего приложения получает собственную однопоточную среду выполнения с приватным хранилищем.

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");
	},
};

Не создавайте единый «глобальный» Durable Object, который обрабатывает все запросы:

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");
    },
};

Лимиты пропускной способности сообщений

Один Durable Object может обрабатывать приблизительно 500-1,000 запросов в секунду для простых операций. Это ограничение зависит от объёма работы, выполняемой в рамках одного запроса:

Тип операции Пропускная способность
Простая передача данных (минимальный парсинг) ~1,000 req/sec
Умеренная нагрузка на обработку (парсинг JSON, валидация) ~500-750 req/sec
Сложные операции (преобразование данных, запись в хранилище) ~200-500 req/sec

Определяя свой «атом», учитывайте ожидаемую частоту запросов. Если ваш сценарий использования превышает эти лимиты, распределите нагрузку между несколькими Durable Objects.

Например, рассмотрим игру в реальном времени с 50,000 одновременными игроками, каждый из которых отправляет 10 обновлений в секунду. Суммарно это даёт 500,000 запросов в секунду. Потребуется 500-1,000 Durable Objects для игровых сессий, а не один общий координатор.

Рассчитайте требования к шардированию:


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

Используйте детерминированные идентификаторы для предсказуемой маршрутизации

Используйте getByName() с содержательными, детерминированными строками для последовательной маршрутизации. Одинаковые входные данные всегда дают одинаковый ID Durable Object, что гарантирует, что запросы к одной и той же логической сущности всегда попадают в один и тот же экземпляр.

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");
	},
};

Создание заглушки не создаёт экземпляр Durable Object и не активирует его. Durable Object активируется только при вызове метода у заглушки.

Используйте newUniqueId() только если вам нужен новый случайный экземпляр, а сопоставление вы будете хранить отдельно:

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() });
    },
};

Не храните все данные в одном Durable Object. Если данные иерархические (рабочие пространства с проектами, игровые серверы, управляющие матчами), создавайте отдельные дочерние Durable Objects для каждой сущности. Родительский объект координирует дочерние объекты и отслеживает их, а дочерние объекты самостоятельно управляют своим состоянием.

Это обеспечивает параллелизм: операции с разными дочерними объектами могут выполняться одновременно, при этом каждый дочерний объект сохраняет собственную однопоточную согласованность (подробнее об этом подходе).

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
		);
	}
}

При таком подходе:

Учитывайте Location Hints для приложений, чувствительных к задержкам

По умолчанию Durable Object создаётся рядом с местом, откуда поступил первый запрос к нему. Для большинства приложений этого достаточно, но вы можете задать подсказку о местоположении, чтобы повлиять на то, где будет создан Durable Object.

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");
    },
};

Подсказки о местоположении носят рекомендательный характер и не являются гарантией. См. Расположение данных со списком доступных регионов и подробностями.

Хранилище и состояние

Используйте Durable Objects с поддержкой SQLite

Хранилище SQLite является рекомендуемым бэкендом хранилища для новых Durable Objects. Он предоставляет привычный SQL API для реляционных запросов, индексов и транзакций, а также обеспечивает более высокую производительность по сравнению с устаревшими Durable Objects на основе key-value-хранилища. SQLite Durable Objects также поддерживают KV API в синхронной и асинхронной версиях.

Настройте класс Durable Object для использования хранилища SQLite в конфигурации Wrangler:

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

Затем используйте SQL API в своём 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();
    }
}

См. Доступ к хранилищу Durable Objects с подробностями о SQL API.

Инициализация хранилища и выполнение миграций в конструкторе

Используйте blockConcurrencyWhile() в конструкторе, чтобы выполнить миграции и инициализировать состояние до обработки каких-либо запросов. Это гарантирует готовность схемы и предотвращает гонки при инициализации.

Для промышленных приложений используйте библиотеку миграций, которая автоматически отслеживает версии и выполняет миграции:

Если вы не хотите использовать библиотеку, можно отслеживать версии схемы вручную с помощью _sql_schema_migrations таблице. Следующий пример демонстрирует такой подход:

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);
			`);
		}
	}
}

Узнайте разницу между состоянием в памяти и постоянным хранилищем

Durable Objects предоставляют несколько уровней управления состоянием, каждый со своими особенностями:

Тип Speed Персистентность Вариант использования
В памяти (свойства класса) Самая быстрая Теряется при вытеснении или сбое Кеширование, активные соединения
Хранилище SQLite Быстро Сохраняется при перезапусках Основное хранилище данных
Внешнее (R2, D1) Переменная Сохраняется, доступно из разных Durable Objects Большие файлы, общие данные

Состояние в памяти является не сохраняется, если Durable Object вытесняется из памяти из-за неактивности или аварийно завершается из-за неперехваченного исключения. Всегда сохраняйте важное состояние в хранилище 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
    }
}

Создавайте индексы для часто запрашиваемых столбцов

Как и в любой базе данных, индексы значительно повышают производительность чтения для часто фильтруемых столбцов. Цена за это: немного больше места в хранилище и незначительное замедление записи.

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();
	}
}

Узнайте, как работают шлюзы ввода и вывода

Хотя Durable Objects однопоточны, async/await может позволять нескольким запросам чередовать выполнение, пока один из запросов ожидает результата асинхронной операции. В среде выполнения Cloudflare используется входные шлюзы и выходные шлюзы чтобы по умолчанию предотвращать гонки данных и обеспечивать корректность.

Входные шлюзы блокируют новые события (входящие запросы, ответы fetch) на время выполнения синхронного кода JavaScript. Ожидание асинхронных операций, таких как fetch() или методов KV-хранилища открывает входной шлюз (input gate), позволяя другим запросам выполняться параллельно. Однако операции с хранилищем обеспечивают особую защиту:

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;
	}
}

Шлюзы вывода задерживает исходящие сетевые сообщения (ответы, запросы fetch) до завершения ожидающих записей в хранилище. Это гарантирует, что клиенты никогда не увидят подтверждение данных, которые ещё не были сохранены:

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";
    }
}

Объединение операций записи: Несколько операций записи в хранилище без промежуточных await вызовы автоматически объединяются в единую атомарную неявную транзакцию:

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);
	}
}

Подробнее см. Durable Objects: просто, быстро, правильно. Выбирайте все три. ↗ и глоссарий.

Избегайте гонок при вводе-выводе, не связанном с хранилищем

Входные шлюзы защищают только во время операций с хранилищем. Операции ввода-вывода, не связанные с хранилищем, например fetch() или запись в R2 позволяет другим запросам выполняться параллельно, что может привести к состояниям гонки:

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

Чтобы справиться с этим, используйте шаблон оптимистичной блокировки (check-and-set): считайте номер версии перед внешним вызовом, а перед записью проверьте, что он не изменился.

Используйте blockConcurrencyWhile() умеренно

blockConcurrencyWhile() гарантирует, что никакие другие события не обрабатываются, пока не завершится переданный колбэк, даже если этот колбэк выполняет асинхронные операции ввода-вывода. Это полезно для операций, которые должны быть атомарными, например для инициализации состояния из хранилища в конструкторе:

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
	}
}

Поскольку blockConcurrencyWhile() блоки all параллелизм безусловно, это заметно снижает пропускную способность. Если каждый вызов занимает около 5 мс, конкретный Durable Object ограничен примерно 200 запросами в секунду. Используйте этот подход только для инициализации и миграций, а не для обычной обработки запросов. В штатном режиме полагайтесь на input/output gates и объединение операций записи.

Для атомарных операций чтения, изменения и записи при обработке запросов отдавайте предпочтение transaction() через blockConcurrencyWhile(). Транзакции обеспечивают атомарность операций хранилища, не блокируя не связанные с ними параллельные запросы.

Коммуникация и проектирование API

Используйте методы RPC вместо fetch() обработчик

Проекты с дата совместимости 2024-04-03 или более поздней версии следует использовать методы RPC. RPC удобнее, обеспечивает более строгую типизацию и избавляет от необходимости вручную разбирать запросы и ответы.

Определите публичные методы в классе Durable Object и вызывайте их напрямую из заглушек с полной поддержкой TypeScript:

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);
    },
};

См. Вызов методов с подробностями об RPC и устаревшем fetch() обработчик.

Явно инициализируйте Durable Objects с помощью init() метод

Durable Objects не знают собственное имя или ID изнутри. Если вашему Durable Object нужно знать свою идентичность (например, чтобы сохранить ссылку на себя или взаимодействовать со связанными объектами), необходимо явно инициализировать его.

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`);
	},
};

Всегда await вызовы RPC

При вызове методов у stub Durable Object всегда используйте await. Вызовы без await создают «зависшие» промисы, из-за чего ошибки проглатываются, а возвращаемые значения теряются.

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 });
    },
};

Обработка ошибок

Обработка ошибок и использование границ исключений

Необработанные исключения в Durable Object могут перевести его в неизвестное состояние и привести к завершению работы экземпляра средой выполнения. Оборачивайте рискованные операции в try...catch блоки и соответствующим образом обрабатывайте ошибки.

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
	}
}

При вызове Durable Objects из Worker ошибки могут включать .retryable и .overloaded свойства, указывающие, можно ли повторить операцию. Для временных сбоев используйте экспоненциальную задержку между повторными попытками, чтобы не перегружать систему.

См. Обработка ошибок с подробностями о свойствах ошибок, стратегиях повторных попыток и экспоненциальной задержке.

WebSocket и реальное время

Используйте Hibernatable WebSockets API для экономии затрат

Hibernatable WebSockets API позволяет Durable Objects переходить в спящий режим, сохраняя при этом соединения WebSocket. Это значительно снижает расходы для приложений с большим числом неактивных соединений.

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);
    }
}

С Hibernation API ваш Durable Object может переходить в спящий режим при отсутствии активного выполнения JavaScript, при этом WebSocket-соединения остаются открытыми. Когда приходит сообщение, Durable Object автоматически просыпается.

Рекомендации:

См. WebSockets для дополнительных сведений.

Используйте serializeAttachment() чтобы сохранять состояние для каждого соединения

Вложения WebSocket позволяют хранить метаданные для каждого подключения, которые сохраняются после hibernation. Используйте их для идентификаторов пользователей, токенов сессии или других данных, привязанных к подключению.

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);
			}
		}
	}
}

Планирование и жизненный цикл

Используйте будильники для запланированных задач на уровне каждой сущности

Каждый Durable Object может планировать собственные будущие задачи с помощью Alarms API, что позволяет Durable Object выполнять фоновые задачи с любым интервалом без входящего запроса, вызова RPC или сообщения WebSocket.

Основные сведения об alarms:

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
    }
}

Делайте обработчики alarm идемпотентными

В редких случаях alarm может сработать более одного раза. Ваш alarm()-обработчик должен безопасно выполняться многократно без побочных эффектов.

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;
	}
}

Очистите хранилище с помощью deleteAll()

Чтобы полностью очистить хранилище Durable Object, вызовите deleteAll(). Простого удаления отдельных ключей или таблиц недостаточно, поскольку часть внутренних метаданных может сохраниться. Для Workers с датой совместимости раньше 2026-02-24 а если будильник уже установлен, необходимо сначала удалить его с помощью 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
    }
}

Проектирование с учётом непредвиденных остановок

Durable Objects могут завершать работу в любой момент из-за развёртываний, бездействия или решений среды выполнения. Вместо того чтобы полагаться на хуки завершения работы (которые не предоставляются), проектируйте приложение так, чтобы состояние записывалось постепенно.

Cloudflare не предоставляет хуки завершения работы или обратные вызовы жизненного цикла, выполняемые перед остановкой, поскольку не может гарантировать их выполнение во всех случаях, а внешнее программное обеспечение может чрезмерно полагаться на эти (ненадёжные) хуки.

Вместо того чтобы полагаться на хуки завершения работы, регулярно записывайте данные в хранилище, чтобы корректно восстанавливаться после завершения работы.

Например, если вы обрабатываете поток данных и вам нужно сохранять прогресс, записывайте текущую позицию в хранилище по ходу выполнения, а не дожидайтесь завершения, чтобы сохранить её:

// 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);
  });
}

Хотя это может показаться неочевидным, запись в хранилище Durable Object выполняется быстро и синхронно, поэтому вы можете сохранять состояние практически без потерь производительности.

Такой подход гарантирует, что ваш Durable Object сможет безопасно возобновить работу с любой точки, даже если он неожиданно завершит работу.

Антипаттерны, которых стоит избегать

Не используйте единственный Durable Object в качестве глобального синглтона

Один Durable Object, обрабатывающий весь трафик, становится узким местом. Хотя асинхронные операции допускают чередование запросов, всё синхронное выполнение JavaScript однопоточно, а операции с хранилищем обеспечивают гарантии сериализации, которые ограничивают пропускную способность.

Частой ошибкой является использование Durable Object для глобального ограничения частоты запросов или глобальных счётчиков. Это направляет весь трафик через один экземпляр:

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");
	},
};

Такой подход плохо масштабируется. С ростом трафика единственный Durable Object превращается в узкое место. Вместо этого определите естественные границы координации в вашем приложении (на пользователя, на комнату, на документ) и создайте для каждой из них отдельный Durable Object.

Тестирование и жизненный цикл класса

Тестируйте с помощью Vitest и учитывайте изменения жизненного цикла класса

Используйте @cloudflare/vitest-plugin для тестирования Durable Object. Эта интеграция предоставляет утилиты для прямого доступа к экземплярам.

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
    });
});

Настройте Vitest в vitest.config.ts:

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

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

При изменении схемы данных выполняйте миграции схемы в конструкторе с помощью blockConcurrencyWhile(). Чтобы переименовать или удалить класс, измените соответствующую запись в exports поле файла конфигурации 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"

См. Экспорт классов Durable Object с подробностями об изменениях жизненного цикла класса, а также Тестирование с использованием Durable Objects с подробными шаблонами тестирования, включая запросы SQLite и тестирование будильников.