Persistir um registro de atividades de uma sala

CloudflareBeginner
Pratique Agora

Introdução

Um objeto JavaScript em execução pode manter valores em propriedades de classe, mas esses valores desaparecem quando o runtime é reiniciado, falha ou remove da memória um objeto inativo. Um registro de atividades não pode aceitar esse risco: quem participa de uma sala espera que o evento de ontem continue visível depois que o código da aplicação for implantado novamente.

Neste laboratório, cada nome de sala validado seleciona um Durable Object. Esse objeto possui um banco de dados SQLite privado que contém seus eventos de atividade. O Worker de entrada chama o objeto por RPC, para que os clientes não acessem o armazenamento diretamente. Você interromperá e reiniciará o runtime local, depois implantará novamente o Worker na nuvem e abrirá uma nova conexão. Nos dois casos, as linhas gravadas anteriormente deverão continuar disponíveis. Uma segunda sala comprovará que o armazenamento pertence à identidade de um objeto, e não ao namespace inteiro.

Você também comparará dois tipos de estado:

  • O estado em memória fica nas propriedades JavaScript e serve apenas como cache temporário.
  • O estado durável é gravado no armazenamento do objeto antes que a requisição seja concluída e sobrevive à substituição do runtime.

Antes de entrar diretamente neste curso, conclua Conectar o LabEx à sua conta da Cloudflare. Cada VM nova precisa de sua própria autorização do Wrangler. Você já deve entender os manipuladores de requisições de Workers, os nomes de Durable Objects, os bindings e RPC, conforme explicado no laboratório anterior. As noções básicas sobre chaves SQL e consultas ordenadas são explicadas nos pontos em que aparecem.

Atualmente, a Cloudflare oferece suporte a Durable Objects baseados em SQLite no Workers Free. Este laboratório cria um namespace de classe descartável, alguns objetos nomeados pequenos e apenas requisições limitadas. A configuração instala o Node.js 22.22.0 e o Wrangler 4.132.0 local ao projeto em /home/labex/project/room-activity-log; ela não autoriza a Cloudflare, não cria um namespace, não implanta um Worker nem grava registros de atividade do aluno.

Autorizar a VM e configurar o namespace das salas

Nesta etapa, você autorizará esta VM nova, selecionará sua conta de aprendizagem e descreverá uma classe de Durable Object baseada em SQLite. O login no Dashboard e a autorização da VM são operações separadas porque a VM não tem acesso à sessão do seu navegador.

Entre no projeto preparado e confirme a versão fixada do Wrangler:

cd /home/labex/project/room-activity-log
npx wrangler --version

O resultado esperado é 4.132.0. Inicie a autorização pelo dispositivo:

npx wrangler login --device --browser=false

Abra no navegador a URL exibida, informe o código curto, verifique a conta selecionada e as permissões e, em seguida, autorize. Só volte ao terminal depois que o navegador e o Wrangler informarem que a operação foi concluída. Nunca cole uma senha ou um token no laboratório.

Leia as informações estruturadas de identidade e selecione em privado o ID da conta desejada:

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"

A primeira expressão jq mostra apenas campos seguros de identidade. A segunda mantém o ID da conta em uma variável do shell, sem imprimi-lo. Se sua conta de aprendizagem dedicada tiver outro nome de exibição, substitua pelo nome confirmado.

Gere um nome exclusivo para o Worker:

RUN="labex-c10-o02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

Crie a configuração. O delimitador não delimitado JSON expande $RUN e $ACCOUNT_ID; \$schema mantém a chave 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 é o identificador que o Worker usa para acessar o namespace. A entrada exports informa à Cloudflare que cada objeto RoomActivity usa seu próprio banco de dados SQLite. Esse arquivo ainda não cria nenhum recurso na nuvem; a implantação acontecerá mais tarde.

Armazenar eventos da sala no SQLite

Nesta etapa, você implementará a tabela pertencente à sala e dois métodos RPC: um adiciona um evento e o outro retorna o histórico ordenado.

Um evento de atividade tem uma chave de texto estável, um tipo curto, um detalhe legível e um timestamp do servidor. A restrição PRIMARY KEY impede que duas linhas usem o mesmo ID de evento dentro de uma sala. AUTOINCREMENT atribui um sequence crescente monotonicamente, permitindo que a consulta de leitura preserve a ordem de inserção sem depender de timestamps que podem empatar.

Crie o ponto de entrada do 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() é usado apenas para criar o schema. Ele atrasa as requisições até a tabela existir, mas não envolve o tráfego comum nem operações de I/O externas. O estado importante da aplicação nunca é armazenado somente em uma propriedade de classe: appendEvent() grava a linha no SQLite antes de retorná-la.

Execute os testes determinísticos de roteamento HTTP fornecidos e uma verificação real do bundle pelo Wrangler:

NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run

Espere dois testes aprovados e uma simulação concluída com sucesso. Essas verificações não fazem nenhuma implantação remota.

Comprovar a persistência local após uma reinicialização

Nesta etapa, você gravará dois eventos na sala planning, interromperá completamente o runtime local do Workers, iniciará um novo runtime usando o mesmo diretório de armazenamento local e lerá as linhas novamente.

Normalmente, o Wrangler coloca os dados locais dos bindings em .wrangler/state. Este laboratório usa explicitamente o diretório .labex/local-state para tornar visível o limite de persistência. Esse diretório representa apenas dados do desenvolvimento local; ele é separado do armazenamento da Cloudflare.

Inicie o primeiro runtime 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

Adicione dois eventos a planning. --data envia o corpo JSON, e o cabeçalho de content type informa ao Worker como interpretá-lo.

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

Leia a sala e observe as sequências 1 e 2:

curl --silent http://127.0.0.1:8787/rooms/planning/events | jq

Agora encerre esse runtime e aguarde o término do processo:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true

Inicie um novo processo de runtime usando o mesmo diretório de persistência:

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

Leia planning novamente e depois leia uma sala diferente, que nunca recebeu um 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

O novo runtime retorna os dois eventos de planning na ordem correta, enquanto support retorna um array events vazio. A reinicialização removeu todas as instâncias de classe JavaScript, mas não removeu as linhas SQLite. A segunda sala vazia demonstra que cada objeto nomeado possui armazenamento privado.

Implantar e gravar atividade na nuvem

Nesta etapa, você interromperá o processo local, implantará o namespace da classe e gravará um pequeno histórico de atividades na nuvem.

Interrompa o runtime local reiniciado para que as próximas requisições não sejam confundidas com respostas da nuvem:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true

Implante o Worker enquanto salva a saída normal do terminal. tee /dev/tty mantém essa saída visível, enquanto $(...) a captura em uma variável do shell:

DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"

A primeira implantação sincroniza a exportação RoomActivity e cria o namespace baseado em SQLite. Extraia a URL workers.dev exibida sem presumir que outro aluno tenha o mesmo subdomínio:

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 somente o texto correspondente à URL, e tail -1 seleciona o endereço final caso outras linhas informativas contenham links.

Uma implantação bem-sucedida pode levar alguns segundos para tornar o código do Worker e seu novo namespace de Durable Object acessíveis em todos os pontos de presença. Aguarde até que uma leitura do objeto support, ainda vazio, retorne o JSON esperado antes de enviar gravações:

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

A leitura final torna a prontidão explícita: o laboratório para nesse ponto se a rota do Durable Object ainda não retornar JSON válido, em vez de enviar uma página de erro da edge para os comandos seguintes. O breve intervalo adicional também evita criar um segundo objeto nomeado enquanto um namespace recém-sincronizado ainda se propaga pela edge.

Grave os mesmos dois eventos lógicos de planning no armazenamento da nuvem. Os bancos de dados dos Durable Objects local e remoto são ambientes deliberadamente separados, portanto a sala na nuvem começa vazia.

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

Leia as salas planning e support, que ainda não recebeu eventos:

curl --silent "$APP_URL/rooms/planning/events" | jq
curl --silent "$APP_URL/rooms/support/events" | jq

O objeto planning na nuvem contém duas linhas, e support continua vazio. Isso comprova a identidade e o isolamento na nuvem antes de testar a substituição por uma nova implantação.

Implantar novamente e inspecionar o estado durável

Nesta etapa, você implantará novamente o mesmo nome de Worker e a mesma declaração de classe. Depois, lerá as linhas existentes por meio de uma nova conexão HTTP e relacionará essa evidência do runtime ao Dashboard.

Implante novamente a aplicação sem alterações:

npx wrangler deploy

Uma implantação de código pode substituir a instância em execução do Durable Object e, portanto, limpar as propriedades da classe. Ela não substitui o namespace quando a mesma exportação ativa RoomActivity continua declarada. Abra uma nova requisição e leia o histórico de planning:

curl --silent "$APP_URL/rooms/planning/events" | jq

As linhas evt-opening e evt-notes ainda devem aparecer na ordem das sequências. Essa é a diferença importante entre um array temporário em memória e um estado durável baseado em SQLite.

Abra o Cloudflare Dashboard e selecione a mesma conta. Acesse Workers & Pages, encontre o Worker exato labex-c10-o02-... e confirme que o binding do Durable Object se chama ROOMS e aponta para RoomActivity. Depois, abra Durable Objects, selecione esse namespace e inspecione sua Overview. O nome do namespace identifica o Worker e a classe implantados, enquanto Storage: SQL confirma o backend selecionado por wrangler.jsonc.

O binding do Worker implantado conecta ROOMS ao namespace do Durable Object RoomActivity

A captura de tela mostra a execução testada. O sufixo gerado será diferente, mas o tipo, o nome e a classe de destino do binding devem corresponder à sua configuração.

A visão geral do namespace RoomActivity identifica seu backend de armazenamento SQL

O Dashboard pode agregar as métricas do namespace após um atraso; por isso, a resposta HTTP continua sendo a prova principal de que as duas linhas sobreviveram. A Overview é um ponto de verificação para orientação, não substitui a leitura do runtime.

Abra a visualização Logs do namespace. Linhas RoomActivity.jsrpc bem-sucedidas confirmam que a Cloudflare chamou a classe por RPC. IDs de objeto repetidos identificam chamadas repetidas ao mesmo objeto, enquanto outros IDs vêm da outra sala e da sala exclusiva da execução do verificador. Esses IDs são exemplos gerados pela Cloudflare, não nomes de sala que você deve copiar. Os logs comprovam as chamadas; a resposta HTTP ordenada comprova o conteúdo da atividade armazenada.

As chamadas RPC bem-sucedidas de RoomActivity aparecem com IDs de Durable Objects nos logs do namespace

Execute mais uma vez a verificação independente da implantação. Ela verifica o binding e o namespace pertencente ao Worker, lê as linhas preservadas de planning, confirma que support está vazia e cria uma sala de verificação separada com nome exclusivo:

python3 .labex/verify.py deployed

Excluir o namespace e revogar o acesso da VM

Nesta etapa, você removerá o namespace do Durable Object e todos os bancos de dados das salas antes de excluir o Worker restante e sair da conta.

Excluir apenas um Worker não encerra explicitamente uma classe de Durable Object. O ciclo de vida declarativo usa um deleted tombstone. Ele exclui permanentemente este namespace de classe e não possui lixeira; portanto, confirme que $RUN começa com labex-c10-o02- antes de continuar.

Crie um ponto de entrada de limpeza sem estado:

cat > src/cleanup.js <<'JS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
JS

Crie a configuração de limpeza para exatamente o mesmo Worker e a mesma conta. Ela remove o binding e marca somente RoomActivity como excluída:

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

Implante o tombstone e inspecione a saída de sincronização:

npx wrangler deploy --config wrangler.cleanup.jsonc

O resultado deve informar que RoomActivity foi excluída. Isso remove planning, support, a sala temporária do verificador e todas as linhas SQLite desse namespace pertencente ao laboratório. Exclua o Worker sem estado restante:

npx wrangler delete --config wrangler.cleanup.jsonc

Confirme apenas o Worker gerado exato. Execute a verificação autenticada de ausência enquanto a autorização ainda estiver disponível:

python3 .labex/verify.py deleted

Somente depois que for exibido PASS: deleted, saia da conta e inspecione o estado estruturado do logout:

npx wrangler logout
npx wrangler whoami --json

O resultado final deve informar loggedIn: false. Uma falha de rede não comprova a exclusão de nenhum recurso nem o logout.

Resumo

Você criou um serviço de atividades de salas no qual cada nome de sala estável seleciona um Durable Object e um banco de dados SQLite privado. Você criou uma tabela de eventos com chave e ordem, expôs operações de adição e listagem por RPC, validou as requisições antes de selecionar o objeto e comprovou que uma segunda sala não herda o histórico de outra.

Você também diferenciou a memória temporária do JavaScript do armazenamento durável ao ler as mesmas linhas depois de uma reinicialização do runtime local e de uma nova implantação na nuvem. Por fim, inspecionou o binding, o namespace, as linhas armazenadas e os logs no Dashboard e depois excluiu exatamente o namespace e o Worker antes de revogar a autorização da VM.