Skip to content

Pelo SDK TypeScript ​

O SDK implementa a própria interface do plugin (IJornadaHub, IOcorrenciaHub) do outro lado da rede, e a mesma suíte de contrato que verifica o roda contra ele. Sem caminho, sem status, sem cabeçalho: "funciona" deixa de ser opinião.

Instalar e começar ​

O SDK é o pacote @geocontrol/geohub-sdk, com uma entrada por plugin: @geocontrol/geohub-sdk/jornada e @geocontrol/geohub-sdk/ocorrencia. Cada uma exporta uma função, criarClienteJornada e criarClienteOcorrencia, que recebe a raiz do Geohub (https://geohub.exemplo.gov.br), o token do usuário de serviço e a autoria (em nome de quem o CAD age), e devolve o cliente com os mesmos verbos do hub — comandos, leituras e eventos no mesmo nível. Onde os plugins estão montados é assunto do SDK, não seu. O vocabulário tipado (ids, matricula(), autoriaDeIntegrador()) sai da mesma entrada; e quem já tem um hub em mãos o embrulha com clienteDeUmAtor, de @geocontrol/geohub-sdk/cliente.

Hoje o SDK não está publicado no npm

O pacote vive no repositório do e exporta essas entradas como fonte TypeScript. Quem integra de fora consome a REST gerada, que tem o mesmo contrato (REST e WebSocket); a publicação no npm está decidida e vem a seguir. Esta página continua valendo para quem tem o repositório, e é a forma mais curta de ler o contrato.

O que todo passo precisa, e só isso: a raiz do Geohub, o token do usuário de serviço e em nome de quem o CAD age (a autoria, feita do id do integrador). Os três entram uma vez, na conexão; o cliente que sai dela tem os comandos, as leituras e os eventos no mesmo nível. Os passos da documentação leem isso do ambiente, e o teste do pacote exemplo-integrador-despacho roda cada um deles contra o Geohub em processo.

ts
export const GEOHUB =
  process.env['GEOHUB_URL'] ?? 'https://geohub.exemplo.gov.br';
export const TOKEN =
  process.env['GEOHUB_TOKEN'] ?? 'TOKEN_DO_USUARIO_DE_SERVICO';
export const INTEGRADOR = integradorId(
  process.env['GEOHUB_INTEGRADOR'] ?? 'ID_DO_USUARIO_DE_SERVICO',
);

/** A conexão que todo cliente do SDK recebe: onde, com que token, em nome de quem. */
export const CONEXAO = {
  autoria: autoriaDeIntegrador(INTEGRADOR),
  geohub: GEOHUB,
  token: TOKEN,
};
js
export const GEOHUB =
  process.env['GEOHUB_URL'] ?? 'https://geohub.exemplo.gov.br';
export const TOKEN =
  process.env['GEOHUB_TOKEN'] ?? 'TOKEN_DO_USUARIO_DE_SERVICO';
export const INTEGRADOR = integradorId(
  process.env['GEOHUB_INTEGRADOR'] ?? 'ID_DO_USUARIO_DE_SERVICO',
);
/** A conexão que todo cliente do SDK recebe: onde, com que token, em nome de quem. */
export const CONEXAO = {
  autoria: autoriaDeIntegrador(INTEGRADOR),
  geohub: GEOHUB,
  token: TOKEN,
};
sh
export GEOHUB=https://geohub.exemplo.gov.br
export TOKEN=…          # o token estático do usuário de serviço, ou o de um login
export INTEGRADOR=…     # o id do usuário de serviço, o mesmo de GET /users/me

O primeiro passo de um CAD: registrar uma e complementá-la.

ts
import { criarClienteOcorrencia } from '@geocontrol/geohub-sdk/ocorrencia';
import { CONEXAO } from './conexao.js';

const ocorrencia = criarClienteOcorrencia(CONEXAO);

// O cliente cunha um UUID v7 por comando: é a chave de idempotência.
const registro = await ocorrencia.registrarOcorrencia({
  classificacao: { natureza: 'roubo a transeunte', prioridade: 1 },
  local: {
    endereco: {
      logradouro: 'Av. Dante Michelini',
      municipio: 'Vitória',
      uf: 'ES',
    },
  },
  // O protocolo no CAD: identifica uma só ocorrência por integrador.
  referenciaExterna: 'CAD-2026-004512',
  solicitante: { canal: 'telefone' },
});
if (!registro.success) throw new Error(registro.error.codigo);

export const ocorrenciaId = registro.data;

// Complementar depois: só dados de negócio, nunca o status.
export const atualizacao = await ocorrencia.atualizarOcorrencia({
  alteracoes: { descricao: 'Dois suspeitos em motocicleta.' },
  id: ocorrenciaId,
});
js
import { criarClienteOcorrencia } from '@geocontrol/geohub-sdk/ocorrencia';
import { CONEXAO } from './conexao.js';
const ocorrencia = criarClienteOcorrencia(CONEXAO);
// O cliente cunha um UUID v7 por comando: é a chave de idempotência.
const registro = await ocorrencia.registrarOcorrencia({
  classificacao: { natureza: 'roubo a transeunte', prioridade: 1 },
  local: {
    endereco: {
      logradouro: 'Av. Dante Michelini',
      municipio: 'Vitória',
      uf: 'ES',
    },
  },
  // O protocolo no CAD: identifica uma só ocorrência por integrador.
  referenciaExterna: 'CAD-2026-004512',
  solicitante: { canal: 'telefone' },
});
if (!registro.success) throw new Error(registro.error.codigo);
export const ocorrenciaId = registro.data;
// Complementar depois: só dados de negócio, nunca o status.
export const atualizacao = await ocorrencia.atualizarOcorrencia({
  alteracoes: { descricao: 'Dois suspeitos em motocicleta.' },
  id: ocorrenciaId,
});
sh
curl -X POST $GEOHUB/plugins/ocorrencias -H "Authorization: Bearer $TOKEN" \
  -H "x-solicitacao-id: $(uuidv7)" -H "Content-Type: application/json" \
  -d '{"classificacao":{"natureza":"roubo a transeunte","prioridade":1},"solicitante":{"canal":"telefone"},"referenciaExterna":"CAD-2026-004512"}'
# {"dados":"<ocorrenciaId>"}

Os outros passos estão nos casos de uso: pôr em serviço e constituir a guarnição e despachar a viatura. Três coisas que eles já fazem certo e que o resto da página explica: um UUID v7 por comando, cunhado antes de sair; a falha de negócio devolvida como valor, não lançada; e o recurso do montado com a que o Geohub conhece, não com a composição que o CAD imagina.

Dois clientes, um contrato ​

Há dois jeitos de obter um cliente, e a diferença é só o transporte:

clientetransportepara quem
criarClienteJornada / criarClienteOcorrencia (@geocontrol/geohub-sdk/jornada, …/ocorrencia)a REST gerada, com x-solicitacao-id por comandoo
jornadaRemota / ocorrenciaRemota (@geocontrol/jornadahub/remoto, …/ocorrenciahub/remoto)a invocação (/invocar) com polling de eventos por inscriçãoo , que precisa de .on('ocorrencia:despachada', …) na tela

Os dois recebem a raiz do Geohub (geohub). O primeiro leva a autoria na conexão e uma credencial só, e o tipo não deixa faltar nem sobrar: o token do usuário de serviço (com um buscar opcional, que é só o transporte), ou a buscaAutenticada de uma sessão que renova sozinha. O segundo é multiator (como(autoria) por chamada) até ser embrulhado por clienteDeUmAtor, e leva a mesma credencial. Para o policial, sempre a busca autenticada: o token dele muda a cada renovação, e um token fixo falharia com 401 na primeira.

Tudo o que o cliente recebe ou devolve sai da mesma entrada com nome — os agregados (Jornada, Guarnicao, Ocorrencia, Despacho) e seus estados, os status, os códigos e as falhas (discriminadas por codigo, com contexto só onde existe), os payloads e o envelope dos eventos. A recusa de protocolo é ErroDoClienteRest, e ehErroDoClienteRest (de @geocontrol/geohub-sdk/cliente) a reconhece pelo nome.

O vocabulário vem com o SDK

Os ids do domínio são tipos marcados: um JornadaId não é um GuarnicaoId, e nenhum dos dois é string. O SDK exporta os tipos e os construtores (jornadaId(texto), viaturaId('RP-1207'), matricula(texto)), então o integrador tipa o próprio código com eles e o compilador impede passar o id errado ao verbo errado. O texto do mundo de fora vira valor do domínio uma vez, na fronteira: a matrícula que o CAD tem vira Matricula ao entrar, e o corpo de uma resposta REST vira OcorrenciaId ao sair do fetch. É assim que os dois exemplos são escritos: nenhum as fabrica um id, e o que resta de as é a leitura do JSON na fronteira.

O app do policial ​

Três peças: a entrada de autenticação do SDK para a sessão (o

do outro lado), a busca autenticada que renova sozinha, e os

dois hubs remotos. É o caminho do pacote exemplo-app-do-policial, que roda contra o Geohub em processo.

O hub remoto é multiator; o app tem um usuário só. O serviço do policial o embrulha num cliente de um ator (@geocontrol/geohub-sdk/cliente), e a autoria do usuário entra uma vez, no construtor:

ts
private readonly jornada: ClienteDeUmAtor<IJornadaHub, IComandosDeJornada>;

constructor(
  hub: IJornadaHub,
  private readonly usuario: UsuarioId,
) {
  this.jornada = clienteDeUmAtor(hub, autoriaDeUsuario(usuario));
}
js
jornada;
constructor(hub, usuario) {
  this.usuario = usuario;
  this.jornada = clienteDeUmAtor(hub, autoriaDeUsuario(usuario));
}

Entrar ​

ts
/**
 * Entra no Geohub com matrícula e senha e devolve a sessão.
 *
 * Toda rejeição chega tipada (`credenciais_invalidas`, `usuario_bloqueado`,
 * `central_indisponivel`…), embora o Geohub entregue todas como `401`: é o
 * código do erro que as separa, traduzido dentro do SDK.
 */
export async function entrarNoGeohub(
  conexao: ConexaoComOGeohub,
  credenciais: CredenciaisDoPolicial,
): Promise<SessaoDoPolicial> {
  const buscar = conexao.buscar ?? globalThis.fetch;
  const autoriza = new ClienteDeAutenticacao({
    buscar,
    geohub: conexao.geohub,
    // Matrícula → e-mail é convenção do deployment, nunca da tela.
    resolucao: resolucaoPorSufixo(conexao.sufixoDaMatricula ?? '@pm.br'),
  });

  const resposta = await autoriza.entrar({
    // A matrícula é o identificador de login deste deployment.
    identificador: identificador(credenciais.matricula),
    senha: credenciais.senha,
  });
  if (resposta.status !== 'autenticado') {
    throw new EntradaRecusadaError(resposta.motivo);
  }

  return {
    // Põe o Bearer em toda requisição e, num 401, renova uma vez e repete.
    buscar: criarBuscaAutenticada(autoriza, buscar),
    sair: () => autoriza.sair(),
    usuario: resposta.sessao.usuario,
  };
}
js
/**
 * Entra no Geohub com matrícula e senha e devolve a sessão.
 *
 * Toda rejeição chega tipada (`credenciais_invalidas`, `usuario_bloqueado`,
 * `central_indisponivel`…), embora o Geohub entregue todas como `401`: é o
 * código do erro que as separa, traduzido dentro do SDK.
 */
export async function entrarNoGeohub(conexao, credenciais) {
  const buscar = conexao.buscar ?? globalThis.fetch;
  const autoriza = new ClienteDeAutenticacao({
    buscar,
    geohub: conexao.geohub,
    // Matrícula → e-mail é convenção do deployment, nunca da tela.
    resolucao: resolucaoPorSufixo(conexao.sufixoDaMatricula ?? '@pm.br'),
  });
  const resposta = await autoriza.entrar({
    // A matrícula é o identificador de login deste deployment.
    identificador: identificador(credenciais.matricula),
    senha: credenciais.senha,
  });
  if (resposta.status !== 'autenticado') {
    throw new EntradaRecusadaError(resposta.motivo);
  }
  return {
    // Põe o Bearer em toda requisição e, num 401, renova uma vez e repete.
    buscar: criarBuscaAutenticada(autoriza, buscar),
    sair: () => autoriza.sair(),
    usuario: resposta.sessao.usuario,
  };
}
sh
curl -X POST https://geohub.exemplo.gov.br/auth/login -H "Content-Type: application/json" \
  -d '{"email":"123456@pm.br","password":"…","mode":"json"}'
# {"data":{"access_token":"…","refresh_token":"…","expires":900000}}   (expires em ms)
curl "https://geohub.exemplo.gov.br/users/me?fields=id,role" -H "Authorization: Bearer $TOKEN"
# {"data":{"id":"<UsuarioId>","role":"<PapelId>"}}
curl -X POST https://geohub.exemplo.gov.br/auth/refresh -H "Content-Type: application/json" -d '{"refresh_token":"…","mode":"json"}'   # o refresh token é de uso único
curl -X POST https://geohub.exemplo.gov.br/auth/logout  -H "Content-Type: application/json" -d '{"refresh_token":"…","mode":"json"}'

O cliente faz POST /auth/login com o e-mail resolvido da matrícula, depois GET /users/me?fields=id,role, e guarda o par de tokens, o UsuarioId e o papel. Toda rejeição chega tipada: credenciais_invalidas, usuario_bloqueado, usuario_desconhecido, token_expirado, permissao_negada (usuário sem papel) e central_indisponivel (erro 5xx ou sem rede). As mensagens de tela vêm de mensagemDeRejeicao(motivo).

A busca autenticada renova o token uma vez num 401 e repete a chamada; um 403 não renova, porque ali o token vale e o papel é que não permite. Renovações concorrentes dividem a mesma chamada, porque o refresh token do Geohub é de uso único.

Ligar os hubs ​

ts
/** Liga os dois hubs remotos com a sessão do policial. */
export async function ligarHubs(conexao: ConexaoDosHubs): Promise<HubsDoApp> {
  const polling = conexao.polling ?? { aCada: milissegundos(1500) };
  const intervaloDeEventos = polling === 'desligado' ? 0 : polling.aCada;
  const jornada = jornadaRemota({
    geohub: conexao.geohub,
    buscaAutenticada: conexao.buscaAutenticada,
    intervaloDeEventos,
  });
  const ocorrencia = ocorrenciaRemota({
    geohub: conexao.geohub,
    buscaAutenticada: conexao.buscaAutenticada,
    intervaloDeEventos,
  });
  // `iniciar()` só liga o polling DESTE cliente; o plugin é do servidor.
  await Promise.all([jornada.iniciar(), ocorrencia.iniciar()]);
  return {
    desligar: async () => {
      await Promise.all([jornada.parar(), ocorrencia.parar()]);
    },
    jornada,
    ocorrencia,
  };
}
js
/** Liga os dois hubs remotos com a sessão do policial. */
export async function ligarHubs(conexao) {
  const polling = conexao.polling ?? { aCada: milissegundos(1500) };
  const intervaloDeEventos = polling === 'desligado' ? 0 : polling.aCada;
  const jornada = jornadaRemota({
    geohub: conexao.geohub,
    buscaAutenticada: conexao.buscaAutenticada,
    intervaloDeEventos,
  });
  const ocorrencia = ocorrenciaRemota({
    geohub: conexao.geohub,
    buscaAutenticada: conexao.buscaAutenticada,
    intervaloDeEventos,
  });
  // `iniciar()` só liga o polling DESTE cliente; o plugin é do servidor.
  await Promise.all([jornada.iniciar(), ocorrencia.iniciar()]);
  return {
    desligar: async () => {
      await Promise.all([jornada.parar(), ocorrencia.parar()]);
    },
    jornada,
    ocorrencia,
  };
}
sh
curl https://geohub.exemplo.gov.br/plugins/jornada/manifesto
curl https://geohub.exemplo.gov.br/plugins/ocorrencia/manifesto
curl -X POST https://geohub.exemplo.gov.br/plugins/ocorrencia/invocar -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"verbo":"aceitarDespacho","args":["…"],"solicitacaoId":"…"}'
curl "https://geohub.exemplo.gov.br/plugins/ocorrencia/eventos?inscricao=$INSCRICAO" -H "Authorization: Bearer $TOKEN"
curl -X POST https://geohub.exemplo.gov.br/plugins/ocorrencia/eventos/encerrar -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"inscricao":"'$INSCRICAO'"}'

Com os hubs ligados, cada é o que as páginas dos casos mostram: entrar em serviço, atender e registrar em campo. Todo comando devolve um Result: { success: true, data } ou { success: false, error: { codigo, detalhe?, contexto? } }. Falha de negócio é valor; recusa de protocolo (401, 403 SEM_CAPACIDADE) é exceção do cliente remoto.

Ao sair, pare o polling antes do logout: um timer vivo pediria eventos com uma sessão que não existe mais.

O integrador (CAD) ​

Três coisas valem para todo comando do integrador: o token do usuário de serviço, o solicitacaoId cunhado antes de sair (UUIDv7, um por comando) e o retry que repete a mesma chave.

ts
/** Troca as credenciais do usuário de serviço por um token. */
static async autenticar(
  credenciais: CredenciaisDoIntegrador,
): Promise<SessaoDoIntegrador> {
  const buscar = credenciais.buscar ?? globalThis.fetch;
  const resposta = await buscar(`${credenciais.geohub}/auth/login`, {
    body: JSON.stringify({
      email: credenciais.email,
      password: credenciais.senha,
    }),
    headers: { 'Content-Type': 'application/json' },
    method: 'POST',
  });
  if (!resposta.ok) throw new ErroDeAutenticacao(resposta.status);
  const corpo = (await resposta.json()) as RespostaDeLogin;
  const token = corpo.data?.access_token;
  if (token === undefined) throw new ErroDeAutenticacao(resposta.status);
  return new SessaoDoIntegrador(
    credenciais.geohub,
    token,
    credenciais.integradorId,
    credenciais.buscar,
    credenciais.esperar,
  );
}

/** Quando o token já veio de outro lugar (um cofre, uma variável). */
static comToken(opcoes: TokenDoIntegrador): SessaoDoIntegrador {
  return new SessaoDoIntegrador(
    opcoes.geohub,
    opcoes.token,
    opcoes.integradorId,
    opcoes.buscar,
    opcoes.esperar,
  );
}

/** A autoria que atravessa a rede para ser **conferida** contra a sessão. */
readonly autoria: Autoria;

/** O `fetch` desta sessão. É ele que o SDK recebe. */
readonly buscar: typeof fetch;

/**
 * O token da sessão de serviço.
 *
 * Público porque é ele que o SDK recebe (`criarClienteOcorrencia({ token
 * })`) — e porque esconder um segredo atrás de um método privado não o
 * torna menos segredo: o que protege é de onde ele veio.
 */
readonly token: string;
js
/** Troca as credenciais do usuário de serviço por um token. */
static async autenticar(credenciais) {
  const buscar = credenciais.buscar ?? globalThis.fetch;
  const resposta = await buscar(`${credenciais.geohub}/auth/login`, {
    body: JSON.stringify({
      email: credenciais.email,
      password: credenciais.senha,
    }),
    headers: { 'Content-Type': 'application/json' },
    method: 'POST',
  });
  if (!resposta.ok) throw new ErroDeAutenticacao(resposta.status);
  const corpo = await resposta.json();
  const token = corpo.data?.access_token;
  if (token === undefined) throw new ErroDeAutenticacao(resposta.status);
  return new SessaoDoIntegrador(
    credenciais.geohub,
    token,
    credenciais.integradorId,
    credenciais.buscar,
    credenciais.esperar,
  );
}
/** Quando o token já veio de outro lugar (um cofre, uma variável). */
static comToken(opcoes) {
  return new SessaoDoIntegrador(
    opcoes.geohub,
    opcoes.token,
    opcoes.integradorId,
    opcoes.buscar,
    opcoes.esperar,
  );
}
/** A autoria que atravessa a rede para ser **conferida** contra a sessão. */
autoria;
/** O `fetch` desta sessão. É ele que o SDK recebe. */
buscar;
/**
 * O token da sessão de serviço.
 *
 * Público porque é ele que o SDK recebe (`criarClienteOcorrencia({ token
 * })`) — e porque esconder um segredo atrás de um método privado não o
 * torna menos segredo: o que protege é de onde ele veio.
 */
token;
sh
curl -X POST https://geohub.exemplo.gov.br/auth/login -H "Content-Type: application/json" \
  -d '{"email":"<usuário de serviço>","password":"…","mode":"json"}'
# {"data":{"access_token":"…","refresh_token":"…","expires":900000}}
# (ou o token estático do usuário de serviço, direto como Bearer)
ts
/**
 * UUIDv7: 48 bits de milissegundos no começo, aleatório no resto.
 *
 * Versão 7 e não 4 porque o contrato publicado a exige, e porque ela é
 * **ordenável no tempo** — a chave de idempotência vira, no banco do Geohub,
 * um índice que se lê em ordem de chegada.
 */
export function uuidV7(): string {
  const bytes = new Uint8Array(16);
  globalThis.crypto.getRandomValues(bytes);
  const agora = Date.now();
  for (let i = 0; i < 6; i += 1) {
    bytes[i] = Math.floor(agora / 256 ** (5 - i)) % 256;
  }
  bytes[6] = ((bytes[6] as number) & 0x0f) | 0x70;
  bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80;
  const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
  return [
    hex.slice(0, 8),
    hex.slice(8, 12),
    hex.slice(12, 16),
    hex.slice(16, 20),
    hex.slice(20),
  ].join('-');
}
js
/**
 * UUIDv7: 48 bits de milissegundos no começo, aleatório no resto.
 *
 * Versão 7 e não 4 porque o contrato publicado a exige, e porque ela é
 * **ordenável no tempo** — a chave de idempotência vira, no banco do Geohub,
 * um índice que se lê em ordem de chegada.
 */
export function uuidV7() {
  const bytes = new Uint8Array(16);
  globalThis.crypto.getRandomValues(bytes);
  const agora = Date.now();
  for (let i = 0; i < 6; i += 1) {
    bytes[i] = Math.floor(agora / 256 ** (5 - i)) % 256;
  }
  bytes[6] = (bytes[6] & 0x0f) | 0x70;
  bytes[8] = (bytes[8] & 0x3f) | 0x80;
  const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
  return [
    hex.slice(0, 8),
    hex.slice(8, 12),
    hex.slice(12, 16),
    hex.slice(16, 20),
    hex.slice(20),
  ].join('-');
}
sh
# Um UUID v7 por comando, cunhado antes da primeira tentativa:
curl -X POST … -H "x-solicitacao-id: 0199c0a1-7b2e-7c3d-8e4f-5a6b7c8d9e0f"
ts
/**
 * Executa um comando com `solicitacaoId` próprio, repetindo **a mesma
 * chave** enquanto o comando não tem desfecho.
 *
 * Dois casos, e só eles: a falha de rede (o pedido pode ter chegado) e o
 * `409 SOLICITACAO_EM_ANDAMENTO` (chegou, e o primeiro ainda roda — espere
 * o `Retry-After`). Qualquer outra resposta do servidor é desfecho, e
 * repeti-la seria martelar quem já respondeu.
 */
async comando<T>(
  executar: (solicitacaoId: string) => Promise<T>,
  tentativas = 3,
): Promise<T> {
  const solicitacaoId = uuidV7();
  let ultima: unknown;
  for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
    try {
      return await executar(solicitacaoId);
    } catch (erro) {
      const segundos = segundosSeEmAndamento(erro);
      if (segundos !== undefined) {
        await this.esperar(segundos * 1000);
      } else if (!(erro instanceof TypeError)) {
        throw erro;
      }
      ultima = erro;
    }
  }
  throw ultima;
}
js
/**
 * Executa um comando com `solicitacaoId` próprio, repetindo **a mesma
 * chave** enquanto o comando não tem desfecho.
 *
 * Dois casos, e só eles: a falha de rede (o pedido pode ter chegado) e o
 * `409 SOLICITACAO_EM_ANDAMENTO` (chegou, e o primeiro ainda roda — espere
 * o `Retry-After`). Qualquer outra resposta do servidor é desfecho, e
 * repeti-la seria martelar quem já respondeu.
 */
async comando(executar, tentativas = 3) {
  const solicitacaoId = uuidV7();
  let ultima;
  for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
    try {
      return await executar(solicitacaoId);
    } catch (erro) {
      const segundos = segundosSeEmAndamento(erro);
      if (segundos !== undefined) {
        await this.esperar(segundos * 1000);
      } else if (!(erro instanceof TypeError)) {
        throw erro;
      }
      ultima = erro;
    }
  }
  throw ultima;
}
sh
curl -X POST https://geohub.exemplo.gov.br/plugins/ocorrencias/$OCORRENCIA/despachos -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "x-solicitacao-id: 0199c0a1-7b2e-7c3d-8e4f-5a6b7c8d9e0f" \
  -H 'x-autoria: {"tipo":"integrador","id":"'$INTEGRADOR'"}' \
  -d '{"recurso":{"tipo":"guarnicao","guarnicaoId":"…","viatura":"RP-1207","composicao":[…],"identificador":"RP-1207"}}'
# {"dados":"<despachoId>"}                       (repetir a mesma chave devolve o mesmo, com "repetido":true)
# 409 {"erro":{"codigo":"SOLICITACAO_EM_ANDAMENTO"}}  Retry-After: 2

O exemplo declara portas no vocabulário do integrador e as implementa duas vezes, uma pelo SDK e outra por fetch; é por isso que o mesmo roteiro roda nos dois transportes sem um caso editado.

ts
/** Os verbos de `jornada` que o papel `Integração - Sistema de despacho` usa. */
export interface PortaDeJornada {
  assumirComando(dados: {
    guarnicaoId: GuarnicaoId;
    jornadaId: JornadaId;
  }): Promise<Desfecho<void, FalhaDeJornada>>;
  constituirGuarnicao(
    dados: DadosDaGuarnicao,
  ): Promise<Desfecho<GuarnicaoId, FalhaDeJornada>>;
  declararProntidao(
    guarnicaoId: GuarnicaoId,
  ): Promise<Desfecho<void, FalhaDeJornada>>;
  /** Leitura: `undefined` quando a viatura não tem guarnição `disponivel`. */
  guarnicaoDisponivelNaViatura(
    viatura: ViaturaId,
  ): Promise<GuarnicaoElegivel | undefined>;
  iniciarJornada(dados: {
    id: JornadaId;
    posto: PostoOcupado;
  }): Promise<Desfecho<void, FalhaDeJornada>>;
  registrarApresentacao(
    usuario: UsuarioId,
  ): Promise<Desfecho<JornadaId, FalhaDeJornada>>;
}

/** Os verbos de `ocorrencia` que o mesmo papel usa. */
export interface PortaDeOcorrencia {
  atualizarOcorrencia(dados: {
    alteracoes: AlteracoesDeOcorrencia;
    id: OcorrenciaId;
  }): Promise<Desfecho<void, FalhaDeOcorrencia>>;
  despachar(dados: {
    instrucoes?: string;
    ocorrenciaId: OcorrenciaId;
    recurso: RecursoGuarnicao;
  }): Promise<Desfecho<DespachoId, FalhaDeOcorrencia>>;
  registrarOcorrencia(
    dados: DadosDeRegistroDeOcorrencia,
  ): Promise<Desfecho<OcorrenciaId, FalhaDeOcorrencia>>;
}
js
// #endregion desfecho
// #endregion portas
export {};
ts
/**
 * O desfecho de um comando.
 *
 * Falha de **negócio** é valor, não exceção: "a viatura não tem guarnição
 * disponível" é uma resposta que o despachante trata na tela, não um
 * incidente. Recusa de **protocolo** (token inválido, papel que não alcança
 * o verbo, corpo fora do contrato) continua sendo exceção — ela é defeito
 * do integrador, e engoli-la num `if` faria um bug de integração parecer
 * uma regra de negócio.
 *
 * A falha é a do plugin, inteira: discriminada por `codigo`, com `contexto`
 * só onde existe. Quem lê `codigo === 'referencia-externa-ja-registrada'`
 * tem `contexto.ocorrenciaId` tipado, sem voltar ao `typeof`.
 */
export type Desfecho<T, F extends { readonly codigo: string }> =
  | ({ readonly aceito: false } & F)
  | { readonly aceito: true; readonly valor: T };
js
// #endregion desfecho
// #endregion portas
export {};

Pelo SDK, não há caminho nenhum escrito: o cliente tem os mesmos verbos do IJornadaHub e do IOcorrenciaHub. A vai na conexão (a exposição a confere contra a sessão) e a chave de é cunhada a cada comando; comSolicitacao(solicitacaoId) devolve os comandos presos a uma chave, que é como o retry repete a mesma.

ts
/** O plugin `jornada` pelo SDK. */
export function jornadaPorSdk(sessao: SessaoDoIntegrador): PortaDeJornada {
  const cliente = criarClienteJornada({
    autoria: sessao.autoria,
    buscar: sessao.buscar,
    geohub: sessao.geohub,
    token: sessao.token,
  });

  /** Os comandos presos à chave de idempotência do comando: o retry a repete. */
  const comando = (solicitacaoId: string) =>
    cliente.comSolicitacao(solicitacaoId);

  return {
    assumirComando: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).assumirComando(dados)),
      ),
    constituirGuarnicao: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).constituirGuarnicao(dados)),
      ),
    declararProntidao: (guarnicaoId) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).declararProntidao(guarnicaoId)),
      ),
    guarnicaoDisponivelNaViatura: (viatura) =>
      cliente.guarnicaoDisponivelNaViatura(viatura),
    iniciarJornada: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).iniciarJornada(dados)),
      ),
    registrarApresentacao: (usuario) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).registrarApresentacao(usuario)),
      ),
  };
}
js
/** O plugin `jornada` pelo SDK. */
export function jornadaPorSdk(sessao) {
  const cliente = criarClienteJornada({
    autoria: sessao.autoria,
    buscar: sessao.buscar,
    geohub: sessao.geohub,
    token: sessao.token,
  });
  /** Os comandos presos à chave de idempotência do comando: o retry a repete. */
  const comando = (solicitacaoId) => cliente.comSolicitacao(solicitacaoId);
  return {
    assumirComando: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).assumirComando(dados)),
      ),
    constituirGuarnicao: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).constituirGuarnicao(dados)),
      ),
    declararProntidao: (guarnicaoId) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).declararProntidao(guarnicaoId)),
      ),
    guarnicaoDisponivelNaViatura: (viatura) =>
      cliente.guarnicaoDisponivelNaViatura(viatura),
    iniciarJornada: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).iniciarJornada(dados)),
      ),
    registrarApresentacao: (usuario) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).registrarApresentacao(usuario)),
      ),
  };
}
ts
/** O plugin `ocorrencia` pelo SDK. */
export function ocorrenciaPorSdk(
  sessao: SessaoDoIntegrador,
): PortaDeOcorrencia {
  const cliente = criarClienteOcorrencia({
    autoria: sessao.autoria,
    buscar: sessao.buscar,
    geohub: sessao.geohub,
    token: sessao.token,
  });

  const comando = (solicitacaoId: string) =>
    cliente.comSolicitacao(solicitacaoId);

  return {
    atualizarOcorrencia: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).atualizarOcorrencia(dados)),
      ),
    despachar: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).despachar(dados)),
      ),
    registrarOcorrencia: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).registrarOcorrencia(dados)),
      ),
  };
}
js
/** O plugin `ocorrencia` pelo SDK. */
export function ocorrenciaPorSdk(sessao) {
  const cliente = criarClienteOcorrencia({
    autoria: sessao.autoria,
    buscar: sessao.buscar,
    geohub: sessao.geohub,
    token: sessao.token,
  });
  const comando = (solicitacaoId) => cliente.comSolicitacao(solicitacaoId);
  return {
    atualizarOcorrencia: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).atualizarOcorrencia(dados)),
      ),
    despachar: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).despachar(dados)),
      ),
    registrarOcorrencia: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).registrarOcorrencia(dados)),
      ),
  };
}

A falha de negócio já chega como Result, e a recusa de protocolo como exceção; só resta adaptar ao seu vocabulário.

ts
/**
 * O `Result` do plugin vira `Desfecho` sem perder nada: o sucesso leva o
 * dado, a falha leva a falha inteira — o `codigo` discriminado e o
 * `contexto` de quem o tem.
 */
const comoDesfecho = <T, F extends { readonly codigo: string }>(
  resultado: Result<T, F>,
): Desfecho<T, F> =>
  resultado.success
    ? { aceito: true, valor: resultado.data }
    : { aceito: false, ...resultado.error };
js
/**
 * O `Result` do plugin vira `Desfecho` sem perder nada: o sucesso leva o
 * dado, a falha leva a falha inteira — o `codigo` discriminado e o
 * `contexto` de quem o tem.
 */
const comoDesfecho = (resultado) =>
  resultado.success
    ? { aceito: true, valor: resultado.data }
    : { aceito: false, ...resultado.error };

Os casos de uso do , com esse cliente, estão em registrar e despachar e entrar em serviço; o receptor de eventos, em o CAD recebe o que aconteceu.

Rodar os exemplos ​

sh
pnpm --filter @geocontrol/exemplo-app-do-policial test
pnpm --filter @geocontrol/exemplo-integrador-despacho test

As duas suítes sobem o Geohub em processo: os dois plugins com a REST e a invocação geradas, os hubs em memória e, no integrador, o outbox e um emissor de de verdade. Não precisam de Docker nem de ambiente.