Hooks Globais de Backend
Visão Geral
Seção intitulada “Visão Geral”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.dataAsAdminno 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) —
beforeQueryem 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, umbeforeQueryglobal se recusará a inicializar em vez de deixar as leituras dessa fonte irrestritas. ConsultebeforeQuery. - 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.
Configuração
Seção intitulada “Configuração”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.
Tipo CollectionCallbacks
Seção intitulada “Tipo CollectionCallbacks”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).
Propriedades dos Callbacks (Callback Props)
Seção intitulada “Propriedades dos Callbacks (Callback Props)”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.
Pipeline de Execução
Seção intitulada “Pipeline de Execução”[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]Semântica de Bloqueio vs. Assíncrona
Seção intitulada “Semântica de Bloqueio vs. Assíncrona”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ódigoCALLBACK_REJECTED, e a escrita no banco de dados nunca acontece. Lance umRebaseApiErrorde@rebasepro/typespara definir o status você mesmo — consulte Entity Callbacks. UmbeforeDeleteque retornafalseresulta 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 oafterSave, obeforeDelete, oafterDeletee 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 400CALLBACK_REJECTED, comdetails.stageindicando 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 umcontextem 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.
Quando o afterSaveError é executado
Seção intitulada “Quando o afterSaveError é executado”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.
afterRead não pode realizar escritas
Seção intitulada “afterRead não pode realizar escritas”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.
Exemplos
Seção intitulada “Exemplos”Mascaramento de PII
Seção intitulada “Mascaramento de PII”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; } }});Registro de Auditoria Global
Seção intitulada “Registro de Auditoria Global”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.
Lógica Específica por Coleção
Seção intitulada “Lógica Específica por Coleção”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.