Introducción
Un objeto JavaScript en ejecución puede conservar valores en las propiedades de una clase, pero esos valores desaparecen cuando el entorno de ejecución se reinicia, se bloquea o elimina de la memoria un objeto inactivo. Un registro de actividad no puede asumir ese riesgo: un miembro de una sala espera que el evento de ayer siga visible después de volver a implementar el código de la aplicación.
En este laboratorio, cada nombre de sala validado selecciona un Durable Object. Ese objeto posee una base de datos SQLite privada que contiene sus eventos de actividad. El Worker de entrada se comunica con el objeto mediante RPC, por lo que los clientes no acceden directamente al almacenamiento. Detendrá y reiniciará el entorno de ejecución local, después volverá a implementar el Worker en la nube y abrirá una conexión nueva. En ambos casos, las filas escritas anteriormente deben seguir disponibles. Una segunda sala demostrará que el almacenamiento pertenece a una identidad de objeto y no a todo el espacio de nombres.
También comparará dos tipos de estado:
- El estado en memoria vive en las propiedades de JavaScript y solo sirve como caché temporal.
- El estado duradero se escribe en el almacenamiento del objeto antes de que termine la solicitud y sobrevive al reemplazo del entorno de ejecución.
Antes de acceder directamente a este curso, complete Conectar LabEx a su cuenta de Cloudflare. Cada VM nueva necesita su propia autorización de Wrangler. Ya debe comprender los controladores de solicitudes de Workers, los nombres de Durable Objects, los bindings y RPC del laboratorio anterior. Las claves SQL básicas y las consultas ordenadas se explican cuando aparecen.
Actualmente, Cloudflare admite Durable Objects respaldados por SQLite en Workers Free. Este laboratorio crea un espacio de nombres de clase desechable, unos pocos objetos con nombre y únicamente solicitudes acotadas. La configuración instala Node.js 22.22.0 y Wrangler 4.132.0 local al proyecto en /home/labex/project/room-activity-log; no autoriza Cloudflare, no crea un espacio de nombres, no implementa un Worker ni escribe registros de actividad del estudiante.
Autorizar la VM y configurar el espacio de nombres de la sala
En este paso, autorizará esta VM nueva, seleccionará su cuenta de aprendizaje y describirá una clase de Durable Object respaldada por SQLite. El inicio de sesión en el Dashboard y la autorización de la VM son procesos independientes porque la VM no tiene acceso a la sesión de su navegador.
Acceda al proyecto preparado y confirme la versión fijada de Wrangler:
cd /home/labex/project/room-activity-log
npx wrangler --version
Debe aparecer 4.132.0. Inicie la autorización mediante dispositivo:
npx wrangler login --device --browser=false
Abra en el navegador la URL que se muestra, introduzca el código corto, revise la cuenta seleccionada y los permisos, y autorice el acceso. Regrese a la terminal solo después de que el navegador y Wrangler indiquen que la operación se realizó correctamente. Nunca pegue una contraseña ni un token en el laboratorio.
Lea la información estructurada de identidad y seleccione de forma privada el ID de la cuenta prevista:
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"
La primera expresión jq muestra únicamente campos de identidad seguros. La segunda conserva el ID de la cuenta en una variable de shell en lugar de imprimirlo. Si su cuenta de aprendizaje específica tiene otro nombre visible, sustituya el nombre confirmado.
Genere un nombre único para el Worker:
RUN="labex-c10-o02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
Cree la configuración. El delimitador JSON sin comillas expande $RUN y $ACCOUNT_ID; \$schema conserva la clave JSON literal.
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 es el identificador que usa el Worker para acceder al espacio de nombres. La entrada exports indica a Cloudflare que cada objeto RoomActivity utiliza su propia base de datos SQLite. Este archivo todavía no crea ningún recurso en la nube; la implementación se realizará más adelante.
Almacenar eventos de la sala en SQLite
En este paso, implementará la tabla propiedad de la sala y dos métodos RPC: uno añade un evento y el otro devuelve el historial ordenado.
Un evento de actividad tiene una clave de texto estable, un tipo corto, un detalle legible y una marca de tiempo del servidor. La restricción PRIMARY KEY impide que dos filas utilicen el mismo ID de evento dentro de una sala. AUTOINCREMENT asigna una sequence monótonamente creciente, lo que permite que la consulta de lectura conserve el orden de inserción sin depender de marcas de tiempo que podrían coincidir.
Cree el punto de entrada del 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() se limita a crear el esquema. Retrasa las solicitudes hasta que exista la tabla, pero no envuelve el tráfico normal ni las operaciones de E/S externas. El estado importante de la aplicación nunca se almacena únicamente en una propiedad de la clase: appendEvent() escribe la fila en SQLite antes de devolverla.
Ejecute las pruebas deterministas de enrutamiento HTTP proporcionadas y una comprobación real del paquete de Wrangler:
NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run
Debe obtener dos pruebas aprobadas y una simulación de implementación correcta. Estas comprobaciones no realizan ninguna implementación remota.
Demostrar la persistencia local después de un reinicio
En este paso, escribirá dos eventos en la sala planning, detendrá por completo el entorno de ejecución local, iniciará un entorno nuevo con el mismo directorio de almacenamiento local y volverá a leer las filas.
Normalmente, Wrangler coloca los datos de los bindings locales en .wrangler/state. Este laboratorio utiliza explícitamente el directorio .labex/local-state para que el límite de persistencia sea visible. El directorio representa únicamente datos de desarrollo local y está separado del almacenamiento de Cloudflare.
Inicie el primer entorno de ejecución local:
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
Añada dos eventos a planning. --data envía el cuerpo JSON y la cabecera de tipo de contenido indica al Worker cómo interpretarlo.
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
Lea la sala y observe las secuencias 1 y 2:
curl --silent http://127.0.0.1:8787/rooms/planning/events | jq
Ahora finalice ese entorno de ejecución y espere a que termine su proceso:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
Inicie un nuevo proceso de ejecución con el mismo directorio de persistencia:
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
Lea de nuevo planning y después lea otra sala que nunca haya recibido un evento:
curl --silent http://127.0.0.1:8787/rooms/planning/events | jq
curl --silent http://127.0.0.1:8787/rooms/support/events | jq
El nuevo entorno devuelve los dos eventos de planning en orden, mientras que support devuelve una matriz events vacía. El reinicio eliminó todas las instancias de clases JavaScript, pero no eliminó las filas de SQLite. La segunda sala vacía demuestra que cada objeto con nombre posee un almacenamiento privado.
Implementar y escribir actividad en la nube
En este paso, detendrá el proceso local, implementará el espacio de nombres de la clase y escribirá un pequeño historial de actividad en la nube.
Detenga el entorno de ejecución local reiniciado para que las solicitudes posteriores no se confundan con respuestas de la nube:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
Implemente el Worker mientras guarda la salida normal de la terminal. tee /dev/tty mantiene visible esa salida mientras $(...) la captura en una variable de shell:
DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"
La primera implementación reconcilia la exportación RoomActivity y crea su espacio de nombres respaldado por SQLite. Extraiga la URL de workers.dev mostrada sin suponer que otro estudiante tiene el mismo subdominio:
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 imprime únicamente el texto que coincide con la URL y tail -1 selecciona la dirección final si otras líneas informativas contienen enlaces.
Una implementación correcta puede tardar unos segundos en hacer que tanto el código del Worker como su nuevo espacio de nombres de Durable Object estén disponibles en todos los puntos de presencia. Espere hasta que una lectura del objeto support, que todavía está vacío, devuelva el JSON esperado antes de enviar escrituras:
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
La lectura final hace explícita la disponibilidad: el laboratorio se detiene si la ruta del Durable Object todavía no devuelve JSON válido, en lugar de enviar una página de error del punto de presencia a los comandos posteriores. La breve espera adicional también evita crear un segundo objeto con nombre mientras un espacio de nombres recién reconciliado todavía se propaga por los puntos de presencia.
Escriba los mismos dos eventos lógicos de planning en el almacenamiento de la nube. Las bases de datos locales y remotas de Durable Objects son entornos separados deliberadamente, por lo que la sala de la nube comienza vacía.
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
Lea las salas planning y support, que no se ha modificado:
curl --silent "$APP_URL/rooms/planning/events" | jq
curl --silent "$APP_URL/rooms/support/events" | jq
El objeto planning de la nube contiene dos filas y support sigue vacío. Esto demuestra la identidad y el aislamiento en la nube antes de probar un reemplazo de la implementación.
Volver a implementar e inspeccionar el estado duradero
En este paso, volverá a implementar el mismo nombre de Worker y la misma declaración de clase. Después leerá las filas existentes mediante una conexión HTTP nueva y relacionará las pruebas del entorno de ejecución con el Dashboard.
Vuelva a implementar la aplicación sin cambios:
npx wrangler deploy
Una implementación del código puede reemplazar la instancia en ejecución del Durable Object y, por tanto, borrar las propiedades de la clase. No reemplaza el espacio de nombres mientras la misma exportación activa RoomActivity siga declarada. Abra una solicitud nueva y lea el historial de planning:
curl --silent "$APP_URL/rooms/planning/events" | jq
Las filas evt-opening y evt-notes deben seguir apareciendo en orden de secuencia. Esta es la diferencia importante entre una matriz temporal en memoria y un estado duradero respaldado por SQLite.
Abra el Cloudflare Dashboard y seleccione la misma cuenta. Vaya a Workers & Pages, busque el Worker exacto labex-c10-o02-... y confirme que su binding de Durable Object se llama ROOMS y apunta a RoomActivity. Después abra Durable Objects, seleccione ese espacio de nombres y revise Overview. El nombre del espacio de nombres identifica el Worker y la clase implementados, mientras que Storage: SQL confirma el backend seleccionado por wrangler.jsonc.

La captura muestra la ejecución probada. El sufijo generado será diferente, pero el tipo, el nombre y la clase de destino del binding deben coincidir con su configuración.

Es posible que el Dashboard agregue las métricas del espacio de nombres después de un retraso, por lo que la respuesta HTTP sigue siendo la prueba autorizada de que las dos filas sobrevivieron. La vista Overview sirve como punto de orientación, no sustituye la lectura del entorno de ejecución.
Abra la vista Logs del espacio de nombres. Las filas correctas RoomActivity.jsrpc confirman que Cloudflare invocó la clase mediante RPC. Los mismos ID de objeto repetidos identifican llamadas sucesivas al mismo objeto, mientras que otros ID proceden de la otra sala y de la sala única de la ejecución del verificador. Estos ID son ejemplos generados por Cloudflare; no son nombres de sala que deba copiar. Los registros demuestran las invocaciones; la respuesta HTTP ordenada demuestra el contenido de actividad almacenado.

Ejecute una vez más la comprobación independiente de la implementación. Verifica el binding y el espacio de nombres propiedad del Worker, lee las filas conservadas de planning, confirma que support está vacía y crea una sala de verificación independiente con un nombre único:
python3 .labex/verify.py deployed
Eliminar el espacio de nombres y revocar el acceso de la VM
En este paso, eliminará el espacio de nombres de Durable Object y todas sus bases de datos de salas antes de borrar el Worker restante y cerrar la sesión.
Borrar únicamente un Worker no retira explícitamente una clase de Durable Object. El ciclo de vida declarativo utiliza un deleted tombstone. Esto elimina permanentemente el espacio de nombres de esta clase y no existe una papelera de reciclaje, así que confirme que $RUN comienza por labex-c10-o02- antes de continuar.
Cree un punto de entrada de limpieza sin estado:
cat > src/cleanup.js <<'JS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
JS
Cree la configuración de limpieza para el mismo Worker y la misma cuenta exactos. Elimina el binding y marca únicamente RoomActivity como eliminada:
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
Implemente el marcador de eliminación y revise la salida de reconciliación:
npx wrangler deploy --config wrangler.cleanup.jsonc
Debe indicar que RoomActivity se eliminó. Esto elimina planning, support, la sala temporal del verificador y todas las filas SQLite del espacio de nombres propiedad de este laboratorio. Borre el Worker sin estado restante:
npx wrangler delete --config wrangler.cleanup.jsonc
Confirme que solo se trata del Worker generado exacto. Ejecute la comprobación autenticada de ausencia mientras la autorización siga disponible:
python3 .labex/verify.py deleted
Solo después de que muestre PASS: deleted, cierre la sesión y revise el estado estructurado de cierre:
npx wrangler logout
npx wrangler whoami --json
La salida final debe indicar loggedIn: false. Un error de red no demuestra que se haya eliminado un recurso ni que se haya cerrado la sesión.
Resumen
Creó un servicio de actividad de salas en el que cada nombre de sala estable selecciona un Durable Object y una base de datos SQLite privada. Creó una tabla de eventos con claves y orden, expuso las operaciones de adición y listado mediante RPC, validó las solicitudes antes de seleccionar el objeto y demostró que una segunda sala no hereda el historial de otra.
También distinguió la memoria temporal de JavaScript del almacenamiento duradero al leer las mismas filas después de reiniciar el entorno local y volver a implementar en la nube. Por último, inspeccionó el binding, el espacio de nombres, las filas almacenadas y los registros en el Dashboard, y después eliminó el espacio de nombres y el Worker exactos antes de revocar la autorización de la VM.



