Введение
Работающий объект JavaScript может хранить значения в свойствах класса, но эти значения исчезают после перезапуска среды выполнения, сбоя или удаления неактивного объекта из памяти. Журнал активности не может допускать такую потерю: участник комнаты ожидает, что вчерашнее событие останется доступным после повторного развёртывания кода приложения.
В этой лабораторной работе каждое проверенное имя комнаты выбирает один Durable Object. Этот объект владеет собственной закрытой базой данных SQLite, содержащей события активности. Внешний Worker обращается к объекту через RPC, поэтому клиенты не получают прямой доступ к хранилищу. Вы остановите и перезапустите локальную среду выполнения, затем повторно развернёте облачный Worker и откроете новое соединение. В обоих случаях ранее записанные строки должны остаться доступными. Вторая комната докажет, что хранилище принадлежит идентичности одного объекта, а не всему пространству имён.
Вы также сравните два вида состояния:
- Состояние в памяти хранится в свойствах JavaScript и подходит только как временный кэш.
- Постоянное состояние записывается в хранилище объекта до завершения запроса и сохраняется после замены среды выполнения.
Прежде чем напрямую переходить к этому курсу, выполните Connect LabEx to Your Cloudflare Account. Каждой новой виртуальной машине требуется собственная авторизация Wrangler. Предполагается, что вы уже знакомы с обработчиками запросов Worker, именами Durable Object, привязками и RPC из предыдущей лабораторной работы. Основы SQL-ключей и упорядоченных запросов объясняются по мере их появления.
Cloudflare в настоящее время поддерживает Durable Objects на базе SQLite в Workers Free. В этой лабораторной работе создаются одно временное пространство имён класса, несколько небольших именованных объектов и только ограниченные запросы. Установка добавляет Node.js 22.22.0 и локальный Wrangler 4.132.0 в /home/labex/project/room-activity-log; она не авторизует Cloudflare, не создаёт пространство имён, не развёртывает Worker и не записывает данные активности учащегося.
Авторизация виртуальной машины и настройка пространства имён комнат
На этом шаге вы авторизуете новую виртуальную машину, выберете свою учебную учётную запись и опишете один Durable Object на базе SQLite. Вход в Dashboard и авторизация виртуальной машины выполняются отдельно, потому что виртуальная машина не имеет доступа к сеансу браузера.
Перейдите в подготовленный проект и проверьте зафиксированную версию Wrangler:
cd /home/labex/project/room-activity-log
npx wrangler --version
Ожидается 4.132.0. Запустите авторизацию устройства:
npx wrangler login --device --browser=false
Откройте показанный URL в браузере, введите короткий код, проверьте выбранную учётную запись и разрешения, затем подтвердите авторизацию. Возвращайтесь в терминал только после того, как браузер и Wrangler сообщат об успешном завершении. Никогда не вставляйте пароль или токен в лабораторную работу.
Прочитайте структурированную информацию об идентичности и безопасно выберите нужный идентификатор учётной записи:
WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
Первое выражение jq выводит только безопасные поля идентичности. Второе сохраняет идентификатор учётной записи в переменной оболочки, не выводя его на экран. Если ваша выделенная учебная учётная запись имеет другое отображаемое имя, подставьте подтверждённое имя.
Сгенерируйте уникальное имя Worker:
RUN="labex-c10-o02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
Создайте конфигурацию. Кавычки вокруг разделителя JSON не используются, поэтому $RUN и $ACCOUNT_ID будут подставлены; \$schema сохраняет буквальный ключ JSON.
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/index.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"observability": {
"enabled": true,
"head_sampling_rate": 1
},
"durable_objects": {
"bindings": [
{ "name": "ROOMS", "class_name": "RoomActivity" }
]
},
"exports": {
"RoomActivity": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
ROOMS — это дескриптор пространства имён, используемый Worker. Запись exports сообщает Cloudflare, что каждый объект RoomActivity использует собственную базу данных SQLite. Этот файл пока не создаёт облачный ресурс; развёртывание произойдёт позже.
Сохранение событий комнат в SQLite
На этом шаге вы создадите таблицу, принадлежащую комнате, и два метода RPC: один добавляет событие, другой возвращает историю в заданном порядке.
Событие активности содержит стабильный текстовый ключ, короткий тип, понятное пользователю описание и временную метку сервера. Ограничение PRIMARY KEY не позволяет двум строкам использовать один и тот же идентификатор события внутри одной комнаты. AUTOINCREMENT назначает монотонно возрастающее значение sequence, благодаря чему запрос чтения сохраняет порядок вставки и не зависит от временных меток, которые могут совпасть.
Создайте точку входа Worker:
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
export class RoomActivity extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS activity_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
detail TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
});
}
appendEvent(event) {
const createdAt = Date.now();
return this.ctx.storage.sql.exec(
`INSERT INTO activity_events (event_id, event_type, detail, created_at)
VALUES (?, ?, ?, ?)
RETURNING sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt`,
event.eventId,
event.type,
event.detail,
createdAt
).one();
}
listEvents() {
return this.ctx.storage.sql.exec(
`SELECT sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt
FROM activity_events
ORDER BY sequence`
).toArray();
}
}
function json(data, status = 200) {
return Response.json(data, { status });
}
function roomRoute(pathname) {
const match = pathname.match(/^\/rooms\/([^/]+)\/events$/);
if (!match) return { error: "not_found", status: 404 };
let room;
try {
room = decodeURIComponent(match[1]);
} catch {
return { error: "invalid_room_name", status: 400 };
}
if (!/^[a-z][a-z0-9-]{0,31}$/.test(room)) {
return { error: "invalid_room_name", status: 400 };
}
return { room };
}
function validEvent(value) {
return value &&
/^[a-z][a-z0-9-]{2,31}$/.test(value.eventId) &&
/^[a-z][a-z0-9_]{2,31}$/.test(value.type) &&
typeof value.detail === "string" &&
value.detail.length >= 1 && value.detail.length <= 160;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") {
return json({ status: "ok" });
}
const parsed = roomRoute(url.pathname);
if (parsed.error) return json({ error: parsed.error }, parsed.status);
if (request.method !== "GET" && request.method !== "POST") {
return json({ error: "method_not_allowed" }, 405);
}
const room = parsed.room;
let body;
if (request.method === "POST") {
try {
body = await request.json();
} catch {
return json({ error: "invalid_json" }, 400);
}
if (!validEvent(body)) return json({ error: "invalid_event" }, 400);
}
const stub = env.ROOMS.getByName(room);
try {
if (request.method === "POST") {
const event = await stub.appendEvent(body);
console.log(JSON.stringify({ event: "room_activity_appended", room, eventId: event.eventId, sequence: event.sequence }));
return json({ room, event }, 201);
}
const events = await stub.listEvents();
console.log(JSON.stringify({ event: "room_activity_listed", room, count: events.length }));
return json({ room, events });
} catch (error) {
if (String(error).includes("UNIQUE constraint failed")) {
return json({ error: "duplicate_event_id" }, 409);
}
throw error;
}
}
};
JS
blockConcurrencyWhile() используется только для создания схемы. Он задерживает запросы до появления таблицы, но не оборачивает обычный трафик или внешние операции ввода-вывода. Важное состояние приложения никогда не хранится только в свойстве класса: appendEvent() записывает строку в SQLite до её возврата.
Запустите предоставленные детерминированные тесты HTTP-маршрутизации и настоящую проверку сборки Wrangler:
NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run
Ожидаются два успешно пройденных теста и успешный пробный запуск. Эти проверки не выполняют удалённое развёртывание.
Проверка сохранности данных после локального перезапуска
На этом шаге вы запишете два события комнаты planning, полностью остановите локальную среду Workers, запустите новую среду с тем же каталогом локального хранилища и снова прочитаете строки.
Обычно Wrangler размещает данные локальных привязок в .wrangler/state. В этой лабораторной работе используется явный каталог .labex/local-state, чтобы граница сохранности была видна. Этот каталог содержит только данные локальной разработки и отделён от хранилища Cloudflare.
Запустите первую локальную среду:
npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
curl --silent --fail http://127.0.0.1:8787/health && break
sleep 1
done
Добавьте два события в planning. Параметр --data отправляет тело JSON, а заголовок типа содержимого сообщает Worker, как его интерпретировать.
curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
--header 'content-type: application/json' \
--data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
--header 'content-type: application/json' \
--data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq
Прочитайте комнату и убедитесь, что последовательности равны 1 и 2:
curl --silent http://127.0.0.1:8787/rooms/planning/events | jq
Теперь завершите работу этой среды и дождитесь окончания её процесса:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
Запустите новый процесс среды с тем же каталогом постоянного хранения:
npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev-restarted.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
curl --silent --fail http://127.0.0.1:8787/health && break
sleep 1
done
Снова прочитайте planning, затем прочитайте другую комнату, в которую ещё не добавлялось ни одного события:
curl --silent http://127.0.0.1:8787/rooms/planning/events | jq
curl --silent http://127.0.0.1:8787/rooms/support/events | jq
Новая среда возвращает оба события planning в правильном порядке, а support возвращает пустой массив events. Перезапуск удалил все экземпляры классов JavaScript, но не удалил строки SQLite. Пустая вторая комната показывает, что каждый именованный объект владеет отдельным хранилищем.
Развёртывание и запись облачной активности
На этом шаге вы остановите локальный процесс, развернёте пространство имён класса и запишете небольшую историю активности в облачное хранилище.
Остановите перезапущенную локальную среду, чтобы последующие запросы нельзя было спутать с ответами из облака:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
Разверните Worker, одновременно сохраняя обычный вывод терминала. tee /dev/tty оставляет этот вывод видимым, а $(...) сохраняет его в переменной оболочки:
DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"
Первое развёртывание синхронизирует экспорт RoomActivity и создаёт пространство имён на базе SQLite. Извлеките напечатанный URL workers.dev, не предполагая, что у другого учащегося будет такой же поддомен:
APP_URL="$(printf '%s\n' "$DEPLOY_OUTPUT" | grep -Eo 'https://[a-z0-9.-]+\.workers\.dev' | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL"
grep -Eo выводит только совпадающий текст URL, а tail -1 выбирает последний адрес, если другие информационные строки содержат ссылки.
После успешного развёртывания может потребоваться несколько секунд, чтобы код Worker и новое пространство имён Durable Object стали доступны на всех пограничных узлах. Дождитесь, пока чтение ещё пустого объекта support не вернёт ожидаемый JSON, и только затем отправляйте записи:
for attempt in $(seq 1 30); do
if curl --silent --fail "$APP_URL/rooms/support/events" |
jq -e '.room == "support" and .events == []' >/dev/null; then
break
fi
sleep 1
done
curl --silent --fail "$APP_URL/rooms/support/events" |
jq -e '.room == "support" and .events == []'
sleep 5
Последнее чтение явно проверяет готовность: лабораторная работа остановится, если маршрут Durable Object всё ещё не возвращает корректный JSON, вместо того чтобы передать страницу ошибки пограничного узла следующим командам. Короткая пауза также не позволяет создать второй именованный объект, пока недавно синхронизированное пространство имён распространяется по периферийной сети.
Запишите те же два логических события комнаты planning в облачное хранилище. Локальная и удалённая базы данных Durable Object намеренно являются разными средами, поэтому облачная комната изначально пуста.
curl --silent --request POST "$APP_URL/rooms/planning/events" \
--header 'content-type: application/json' \
--data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST "$APP_URL/rooms/planning/events" \
--header 'content-type: application/json' \
--data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq
Прочитайте комнаты planning и support, в которую ничего не добавлялось:
curl --silent "$APP_URL/rooms/planning/events" | jq
curl --silent "$APP_URL/rooms/support/events" | jq
Облачный объект planning содержит две строки, а support остаётся пустой. Это подтверждает облачную идентичность и изоляцию перед проверкой замены развёртывания.
Повторное развёртывание и проверка постоянного состояния
На этом шаге вы повторно развернёте тот же Worker с тем же именем и объявлением класса, затем прочитаете существующие строки через новое HTTP-соединение и сопоставите полученные данные со сведениями в Dashboard.
Снова разверните приложение без изменений:
npx wrangler deploy
Развёртывание кода может заменить работающий экземпляр Durable Object и поэтому очистить свойства класса. Но пространство имён не заменяется, если тот же активный экспорт RoomActivity остаётся объявленным. Откройте новый запрос и прочитайте историю planning:
curl --silent "$APP_URL/rooms/planning/events" | jq
Строки evt-opening и evt-notes должны по-прежнему отображаться в порядке последовательности. В этом заключается важное различие между временным массивом в памяти и постоянным состоянием на базе SQLite.
Откройте Cloudflare Dashboard и выберите ту же учётную запись. Перейдите в раздел Workers & Pages, найдите Worker с точным именем labex-c10-o02-... и убедитесь, что его привязка Durable Object называется ROOMS и указывает на RoomActivity. Затем откройте раздел Durable Objects, выберите это пространство имён и просмотрите вкладку Overview. Имя пространства имён идентифицирует развёрнутый Worker и класс, а Storage: SQL подтверждает тип хранилища, выбранный в wrangler.jsonc.

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

Dashboard может отобразить агрегированные метрики пространства имён с задержкой, поэтому HTTP-ответ остаётся главным доказательством сохранности двух строк. Вкладка Overview служит ориентиром, но не заменяет чтение данных во время выполнения.
Откройте представление Logs пространства имён. Успешные строки RoomActivity.jsrpc подтверждают, что Cloudflare вызвал класс через RPC. Повторяющиеся идентификаторы объектов обозначают повторные вызовы одного и того же объекта, а другие идентификаторы относятся к другой комнате и к уникальной для запуска комнате, созданной средством проверки. Эти идентификаторы генерирует Cloudflare; их не следует копировать как имена комнат. Журналы доказывают факт вызовов, а упорядоченный HTTP-ответ — содержание сохранённой активности.

Ещё раз запустите независимую проверку развёрнутого приложения. Она проверяет привязку и принадлежащее ей пространство имён, читает сохранённые строки planning, подтверждает пустоту комнаты support и создаёт отдельную комнату с уникальным именем для проверки:
python3 .labex/verify.py deployed
Удаление пространства имён и отзыв доступа виртуальной машины
На этом шаге вы удалите пространство имён Durable Object и все базы данных его комнат, затем удалите оставшийся Worker и выйдете из учётной записи.
Одного удаления Worker недостаточно для явного вывода класса Durable Object из эксплуатации. Декларативный жизненный цикл использует deleted tombstone. Он безвозвратно удаляет это пространство имён класса, корзины Trash нет, поэтому перед продолжением убедитесь, что $RUN начинается с labex-c10-o02-.
Создайте точку входа для очистки, не использующую состояние:
cat > src/cleanup.js <<'JS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
JS
Создайте конфигурацию очистки для того же Worker и той же учётной записи. Она удаляет привязку и помечает удалённым только RoomActivity:
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/cleanup.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"exports": {
"RoomActivity": { "type": "durable-object", "state": "deleted" }
}
}
JSON
Разверните маркер удаления и проверьте результат синхронизации:
npx wrangler deploy --config wrangler.cleanup.jsonc
В выводе должно быть указано, что RoomActivity удалён. Это удалит planning, support, временную комнату средства проверки и все строки SQLite в пространстве имён, принадлежащем этой лабораторной работе. Удалите оставшийся Worker без состояния:
npx wrangler delete --config wrangler.cleanup.jsonc
Убедитесь, что удаляется только созданный вами Worker с точным именем. Пока авторизация ещё доступна, запустите проверку отсутствия ресурса:
python3 .labex/verify.py deleted
Только после вывода PASS: deleted выйдите из системы и проверьте структурированное состояние выхода:
npx wrangler logout
npx wrangler whoami --json
В итоговом выводе должно быть указано loggedIn: false. Сетевая ошибка не подтверждает ни удаление ресурса, ни выход из системы.
Итоги
Вы создали сервис активности комнат, в котором каждое стабильное имя комнаты выбирает один Durable Object и одну отдельную базу данных SQLite. Вы создали таблицу событий с ключами и упорядочиванием, открыли операции добавления и чтения через RPC, проверили запросы до выбора объекта и доказали, что вторая комната не получает историю другой комнаты.
Вы также отличили временную память JavaScript от постоянного хранилища, прочитав те же строки после перезапуска локальной среды и повторного развёртывания в облаке. В конце вы проверили привязку, пространство имён, сохранённые строки и журналы в Dashboard, а затем удалили точные пространство имён и Worker и отозвали авторизацию виртуальной машины.



