Pular para o conteúdo

Hooks Globais de Backend

O Rebase oferece dois níveis de callbacks de ciclo de vida de entidades — ambos utilizam o mesmo tipo CollectionCallbacks de @rebasepro/types:

  • Callbacks por coleção: Definidos nas configurações de coleções individuais. Eles são executados apenas para aquela coleção.
  • Callbacks globais: Definidos em initializeRebaseBackend({ callbacks }). Eles são disparados em todas as coleções, em todos os caminhos de dados (API REST, WebSocket / realtime, rebase.dataAsAdmin no lado do servidor). O único gravador que eles não veem é o sistema de autenticação: o cadastro, o login via OAuth e a gestão de usuários do admin gravam linhas de usuário diretamente e executam os hooks de autenticação (afterUserCreate, …) em vez disso.

Use callbacks globais para:

  • Escopo de linhas (Row scoping) — beforeQuery em todas as coleções, para que as leituras de um locatário (tenant) sejam restringidas em um único local, em vez de coleção por coleção. Apenas para Postgres: junto a uma fonte de dados MongoDB ou Firestore, um beforeQuery global se recusará a inicializar em vez de deixar as leituras dessa fonte irrestritas. Consulte beforeQuery.
  • Mascaramento de PII — ocultar campos confidenciais para chamadores não administradores em todas as coleções.
  • Registro de auditoria unificado — registrar cada criação, atualização ou exclusão em um único lugar. Novas contas vindas de cadastro e OAuth não estão entre elas; registre-as a partir do afterUserCreate.
  • Validação transversal — aplicar invariantes que abrangem várias coleções.

Passe a chave callbacks para initializeRebaseBackend:

import { initializeRebaseBackend } from "@rebasepro/server";
const instance = await initializeRebaseBackend({
// ... other config
callbacks: {
afterRead({ row, context }) {
// Runs after every entity read, across all collections
return row;
},
beforeSave({ values, context }) {
// Runs before every entity save
return values;
}
}
});

Desde 0.25 callbacks recebe um único conjunto. Para manter várias preocupações separadas, escreva cada uma como seu próprio conjunto e passe composeCallbacks(tenantScope, auditTrail): eles se combinam exatamente como os níveis acima. Veja Combinando conjuntos de callbacks. O mesmo vale para backend.setCollectionCallbacks(slug, callbacks), que substitui os callbacks que uma coleção declarou: passe composeCallbacks(declared, yours) para mantê-los.


type CollectionCallbacks = {
beforeQuery?(props): QueryNarrowing | void; // Conditions to AND into a read before it is compiled
afterRead?(props): Record<string, unknown>; // Transform row before returning to caller
beforeSave?(props): Partial<Values>; // Modify values before writing to DB
afterSave?(props): void; // After the write, still in the transaction
afterSaveError?(props): void; // Side-effects after a failed save
beforeDelete?(props): boolean | void; // Return false (403) or throw to block deletion
afterDelete?(props): void; // After the delete, still in the transaction
};

beforeQuery restringe uma leitura antes que ela seja compilada; consulte beforeQuery.

Todos os callbacks podem retornar uma Promise (assíncrono) ou um valor direto (síncrono).


Cada callback recebe um único objeto de propriedades. Campos comuns:

Campo Tipo Presente em
collection CollectionConfig Todos os callbacks
path string Todos os callbacks
row Record<string, unknown> afterRead, beforeDelete, afterDelete
id string beforeSave (opcional), afterSave, afterSaveError, beforeDelete, afterDelete
values EntityValues beforeSave, afterSave, afterSaveError
previousValues EntityValues (opcional) beforeSave, afterSave, afterSaveError
status "new" | "existing" beforeSave, afterSave, afterSaveError
context RebaseCallContext Todos os callbacks

context.user contém o usuário autenticado (uid, roles, etc.), ou é undefined para requisições públicas.

collection está sempre presente. Um callback global é disparado para todas as coleções, portanto é a única camada registrada independentemente de qualquer uma delas — mas ainda assim nunca recebe uma coleção inexistente. Uma requisição especificando um caminho que o registro de coleções não consegue resolver é recusada com 404 NOT_FOUND antes que qualquer camada seja executada, que é a mesma resposta que os caminhos de leitura e escrita já fornecem a esse caminho. A alternativa — ignorar a camada para esses caminhos — tornaria o afterRead uma etapa de mascaramento com uma exceção silenciosa, portanto essa opção não é oferecida.


[Client Request]
│
▼
[Hono Router]
│
[Database Driver]
┌─────┴───────────────────────────────────────────────────────┐
│ 1. Start PostgreSQL Transaction │
│ 2. Set Config: app.user_id = '<uid>', app.user_roles = ... │
│ │
│ 3. Global Callback: beforeSave ─┐ │
│ 4. Collection Callback: beforeSave ─┘ awaited │
│ 5. Drizzle SQL execution & Postgres RLS evaluation │
│ 6. Global Callback: afterSave ─┐ │
│ 7. Collection Callback: afterSave ─┘ awaited │
│ │
│ 8. Commit ← a throw anywhere in 3–7 rolls the write back │
└─────┬───────────────────────────────────────────────────────┘
│
[Realtime notifications flushed — after the commit, never before]
│
▼
[Client Response]

Cada callback na lista abaixo é aguardado (awaited), e todos eles, exceto afterSaveError, são executados dentro da transação que processa a escrita. Não existe uma camada “dispare e esqueça” (fire and forget): a linha e tudo o que seus callbacks fizeram são confirmados juntos ou nada é confirmado.

  • beforeSave, beforeDelete — se o callback lançar um erro (throw), a operação é rejeitada com um HTTP 400 contendo sua mensagem e o código CALLBACK_REJECTED, e a escrita no banco de dados nunca acontece. Lance um RebaseApiError de @rebasepro/types para definir o status você mesmo — consulte Entity Callbacks. Um beforeDelete que retorna false resulta na mesma recusa sem mensagem, respondendo 403 com esse código.
  • afterRead — a linha retornada (ou a linha transformada) é o que o chamador recebe, e apenas o chamador: em uma escrita, ela molda a resposta, enquanto o afterSave, o beforeDelete, o afterDelete e o histórico recebem a linha como ela foi armazenada — um valor mascarado nunca é registrado nem revertido para a coluna. Sua transação é READ ONLY — consulte abaixo.
  • afterSave, afterDelete — são executados antes do commit, aguardados (awaited). Um erro lançado aqui desfaz a escrita da linha (rollback) e responde com o mesmo 400 CALLBACK_REJECTED, com details.stage indicando o hook. Eles mantêm a transação aberta enquanto são executados, portanto, um callback lento significa um lock mantido por mais tempo.
  • afterSaveError — é executado quando o salvamento falha, durante o fluxo de saída. Em uma requisição, ele é executado depois que a transação da escrita que falhou sofreu rollback, com um context em que cada chamada é uma transação própria, de modo que um job que ele enfileire para relatar a falha é mantido. Um erro lançado por ele é registrado no log; o chamador recebe o erro do próprio salvamento.

Ele reporta um salvamento que falhou no banco de dados ou depois dele — a partir da instrução INSERT ou UPDATE. Um salvamento recusado antes desse ponto nunca chegou ao banco de dados, então o hook não é avisado sobre ele.

Executa Não executa
O banco de dados recusa a instrução: uma violação de chave única ou estrangeira, uma check constraint ou trigger, uma política de row-level security (WRITE_DENIED) Uma recusa do beforeSave — o hook que recusou é o que sabe o motivo
O afterRead ou o afterSave lança um erro (o chamador recebe CALLBACK_REJECTED) Uma requisição recusada antes do driver: validação (VALIDATION_*), um campo que o chamador não pode escrever, uma permissão ausente
A entrada de histórico não pode ser registrada Uma linha que o chamador não pode endereçar (404), um carimbo de tenant que ele não pode definir
O commit é recusado depois que o salvamento retornou: TRANSACTION_ABORTED (uma instrução que falhou e foi capturada por um callback), ou uma constraint diferida verificada no COMMIT
Uma exclusão — não existe uma contraparte para exclusão

Em uma escrita em lote ou _batch, ele é executado para a linha que falhou; as linhas anteriores a ela sofrem rollback junto com o lote e não são reportadas.

Efeitos colaterais que não devem prender a transação

Seção intitulada “Efeitos colaterais que não devem prender a transação”

Qualquer operação lenta ou que não possa ser desfeita caso a transação sofra rollback não deve estar no corpo do callback:

Objetivo Faça isto em vez disso
Chamar serviços de terceiros, enviar e-mails, gerar arquivos Enfileire um job. Um job enfileirado em uma transação que sofre rollback nunca foi enfileirado — o que é exatamente o comportamento desejado.
Notificar outros processos de que algo aconteceu Publique em um canal realtime após o retorno da escrita, não dentro do hook.
Processar tarefas em uma função personalizada pelas quais o chamador não precisa esperar waitUntil(c, promise) de @rebasepro/server/functions — é executado após a resposta, e o host aguarda sua conclusão antes de desligar.

A regra geral: se o trabalho ainda deve acontecer mesmo quando a escrita for desfeita, ele não faz parte da escrita, portanto não deve estar no hook.

Uma leitura com escopo de requisição abre sua transação como READ ONLY, assim como o refetch de uma assinatura realtime. O afterRead é executado dentro dela, portanto nenhuma escrita originada desse callback pode ter sucesso — nem uma criação com context.data, nem uma atualização, nem mesmo uma oculta dentro de uma função auxiliar que ele chame. O Postgres recusa a instrução com o SQLSTATE 25006, e o chamador recebe a resposta:

{ "error": { "message": "An `afterRead` callback tried to write. …",
"code": "READ_ONLY_TRANSACTION",
"details": { "dbCode": "25006" } } }

Isso é um 409, não um 500: é o seu código sendo recusado, e não uma falha do servidor. O modo somente leitura é intencional — uma leitura que realiza escritas silenciosamente é uma leitura cujo custo, locks e superfície de RLS ninguém planejou.

Portanto, a auditoria de leitura não deve ser feita em afterRead. Registre a leitura fora da requisição — a partir de um job em segundo plano alimentado pelo que você já emite, ou a partir de uma função personalizada que realize a leitura e a escrita com duas chamadas separadas:

// ✗ Fails with READ_ONLY_TRANSACTION on every read.
callbacks: {
afterRead: async ({ path, row, context }) => {
await context.data.read_log.create({ path, uid: context.user?.uid });
return row;
}
}
// backend/functions/read-article.ts — served at /api/functions/read-article/:id
// ✓ The read and the audit row are two operations, and only the second writes.
import { defineFunction, getUserId, requireAuth, requireDriver } from "@rebasepro/server/functions";
export default defineFunction((app, { rebase }) => {
app.get("/:id", requireAuth, async (c) => {
const article = await requireDriver(c).fetchOne({ path: "articles", id: c.req.param("id") });
await rebase.dataAsAdmin.read_log.create({ path: "articles", uid: getUserId(c) });
return c.json(article);
});
});

A auditoria no lado da escrita não tem esse problema: afterSave e afterDelete são executados em uma transação de leitura e escrita, e a linha de auditoria é commitada junto com a alteração que registra.


Oculte endereços de e-mail para chamadores que não sejam administradores em todas as coleções:

import { initializeRebaseBackend } from "@rebasepro/server";
const instance = await initializeRebaseBackend({
// ... other config
callbacks: {
afterRead({ row, context }) {
const isAdmin = context.user?.roles?.includes("admin");
if (!isAdmin && row.email) {
return { ...row, email: "********" };
}
return row;
}
}
});

Registre cada exclusão, em todas as coleções, em uma tabela audit_log. Como o afterDelete é executado na própria transação da exclusão, a linha de auditoria e a exclusão são confirmadas juntas — não há brecha na qual uma exista sem a outra:

import { initializeRebaseBackend } from "@rebasepro/server";
const instance = await initializeRebaseBackend({
// ... other config
callbacks: {
async afterDelete({ collection, id, row, context }) {
if (collection.slug === "audit_log") return; // don't audit the audit
await context.data.audit_log.create({
action: "delete",
collection: collection.slug,
entity_id: String(id),
actor: context.user?.uid ?? "anonymous",
snapshot: row
});
}
}
});

Observe o que isso garante e o que custa: se a linha de auditoria não puder ser gravada, a exclusão também não acontece. Para uma trilha de auditoria, isso geralmente é o que se deseja. Se não for esse o caso, capture o erro no callback e documente isso em um comentário.

Callbacks globais são acionados para todas as coleções. Para restringir a lógica a uma única coleção, verifique collection.slug ou path:

callbacks: {
beforeSave({ collection, values, context }) {
if (collection.slug === "orders") {
if (!values.total || values.total <= 0) {
throw new Error("Order total must be positive");
}
}
return values;
}
}

Para callbacks que se aplicam apenas a uma única coleção, prefira utilizar callbacks por coleção.