Catalisa.
Building blocks/DadosProdução

Data Store

Banco de documentos JSON multi-tenant sem criar tabela para cada cliente

13
Endpoints
3
Entidades
0
Provedores
Tenant
Escopo
3030
Porta

Seu produto precisa guardar dados que mudam de formato a cada cliente. O Data Store guarda esses dados em coleções JSON isoladas por empresa, sem que ninguém precise criar tabela, rodar migração ou lembrar de filtrar por cliente na consulta.

Para quem é
  • Fintechs que guardam estado de esteira de crédito e contexto de workflow por cliente
  • Plataformas B2B que precisam de campos personalizados por empresa cliente sem alterar schema
  • Times que integram vários building blocks e precisam de um lugar comum para estado leve
  • Operações que guardam formulários e cadastros com formato variável entre parceiros
Substitui
  • Cluster MongoDB Atlas contratado só para guardar estado de aplicação
  • Tabela "metadata jsonb" improvisada dentro do banco de outro serviço
  • Redis usado como banco de verdade porque ninguém queria criar migração
  • Coleção Firestore com regra de segurança escrita à mão por cliente
O que não é
  • Banco de dados principal da aplicação do cliente (não há join entre coleções)
  • Data lake ou ferramenta de analytics sobre grandes volumes
  • Armazenamento de arquivos e binários (isso é o building block file-storage)
  • Cache distribuído de baixa latência para caminho quente de requisição

01Resumo executivo

O Data Store guarda dados estruturados dos seus clientes sem que você precise criar uma tabela nova a cada cliente que entra. Você cria uma coleção, joga um documento JSON dentro e consulta depois pelo conteúdo. Não existe migração, não existe provisionamento, não existe "vou pedir para o DBA criar a coluna".

Na prática ele resolve o dado que muda de formato entre clientes. Uma financeira que atende trinta parceiros descobre que cada parceiro quer guardar três campos diferentes na proposta — com o Data Store isso é um documento JSON por parceiro, validado por um schema opcional, e não trinta variações de tabela que alguém precisa manter para sempre.

Está em produção desde julho de 2026, roda como serviço próprio em staging e em produção, e é irmão declarado do file-storage: aquele guarda bytes, este guarda documentos.

AtributoValor
Identificadordata-store
CategoriaDados
EscopoTenant (exige organizationId no token)
Porta (standalone)3030
Path alias@data-store
Prefixo HTTP/data-store
Schema no bancodatastore
StatusProdução desde 2026-07
Depende dePostgreSQL, IAM

02O problemanegócio

O cenário. Uma plataforma B2B vende para dezenas de empresas. Cada empresa quer guardar alguma coisa que as outras não querem: um campo a mais na proposta, um catálogo de códigos internos, o estado intermediário de uma esteira que roda em cinco etapas. Nada disso é o dado central do produto — é o dado da borda, e é ele que multiplica.

O que trava hoje.

  • Cada campo novo vira uma migração. O cliente pede um campo, o time abre PR, roda migração em produção e reza. Multiplique por trinta clientes e o schema vira um museu de colunas que só um cliente usa.
  • A alternativa improvisada é pior. Alguém adiciona uma coluna metadata jsonb numa tabela existente e ela vira o depósito de tudo. Seis meses depois ninguém sabe o que tem lá dentro, nem de qual cliente é cada chave.
  • Contratar um banco de documentos resolve metade. MongoDB Atlas, Firestore ou DynamoDB guardam o documento muito bem. Nenhum deles resolve "este documento é da empresa A e a empresa B não pode vê-lo" — isso volta para o seu código, em toda consulta, para sempre.
  • Um banco por cliente não escala. É a saída mais segura e a mais cara: cresce em custo, em backup, em migração e em tempo de onboarding. Adicionar um cliente deixa de ser uma linha e vira um projeto.
  • O isolamento vira disciplina de equipe. "Todo mundo lembra de filtrar por organization_id" funciona até o dia em que alguém não lembra. Esse é o incidente que não tem pedido de desculpas suficiente.

O custo de não resolver. Falha de controle de acesso lidera o OWASP Top 10 desde 2021 (OWASP Top 10:2021 — Broken Access Control), e vazamento entre clientes é exatamente essa categoria. O whitepaper da AWS sobre isolamento em SaaS é explícito: no modelo de banco compartilhado, o isolamento passa a depender inteiramente da camada de aplicação (AWS SaaS Tenant Isolation Strategies). O custo direto, antes do incidente, é mais mundano: cada campo personalizado custa uma migração, e cada migração custa uma janela.


03Proposta de valornegócio

AntesDepois
Campo novo de cliente exige migração e janela de deployDocumento JSON gravado por API, sem DDL
Isolamento entre clientes depende de lembrar do WHERETenant vem do token e é aplicado no driver e na política do banco
Banco de documentos separado, com contrato e operação própriosReusa o Postgres que já roda, em schema dedicado
Dado sensível fica em claro porque cifrar dá trabalhoCampo marcado encrypted: true no schema da coleção, cifrado sem código do integrador
Cliente abusivo derruba a base de todo mundoQuota de documentos por tenant, verificada antes de gravar

Zero DDL por cliente. Uma tabela documents com coluna JSONB e índice GIN atende todos os tenants. Entrar com uma empresa nova é inserir linha, não provisionar. Isso é o que mantém o custo por cliente plano.

O tenant não é opcional em nenhum ponto do caminho. O organizationId chega como claim assinado, o middleware requireOrganization recusa token sem ele, o contrato do driver obriga toda operação a receber tenantId, e a política de RLS no Postgres avalia o mesmo tenant dentro da transação. Não existe rota que leia o identificador da empresa do corpo da requisição.

Schemaless quando você quer, estruturado quando você precisa. A coleção aceita um schema opcional. Sem schema, grava qualquer JSON. Com schema, valida tipo, campos obrigatórios e propriedades extras na escrita. Você escolhe por coleção, não por produto.

Criptografia de campo sem código de cripto. Marcar "encrypted": true numa propriedade do schema basta: o serviço cifra na escrita com AES-256-GCM e decifra na leitura. O CPF fica ilegível no banco e legível na API, sem o integrador chamar biblioteca nenhuma.

Concorrência otimista de série. Todo documento tem version. Enviar expectedVersion no update transforma uma sobrescrita silenciosa em um 409 explícito.


04Casos de uso reaisnegócio

Caso 1 — Uma esteira de crédito guarda o estado de cada proposta sem inventar tabela Cenário ilustrativo

Contexto. Financeira de crédito consignado com uma esteira de sete etapas: captura, consulta de margem, motor de decisão, precificação, assinatura, averbação e pagamento. Cerca de 4 mil propostas por dia, e cada parceiro comercial acrescenta seus próprios campos ao contexto.

A dor. O estado da esteira vivia em uma tabela relacional com 60 colunas, das quais 22 eram usadas por um único parceiro. Toda vez que um parceiro novo entrava, o time abria migração. A tabela tinha três colunas chamadas obs, obs2 e obs_nova, e ninguém lembrava a diferença.

A solução com o BB. Uma coleção propostas com docKey igual ao número da proposta. Cada transição de etapa faz PUT /collections/propostas/documents/:id com expectedVersion, o que impede que dois workers concorrentes sobrescrevam um ao outro. O contexto acumulado é um objeto JSON livre; os campos que todo parceiro tem são validados por um schema-lite com required. Consultar "propostas paradas em averbação há mais de dois dias" é um POST .../documents/query com where em $updatedAt e status.

O resultado. Campo novo de parceiro deixa de ser migração e passa a ser gravação. A tabela de 60 colunas some, e o estado da esteira passa a ter versão e histórico de quem escreveu por último.

Caso 2 — Um formulário dinâmico por parceiro, sem uma tabela por parceiro Cenário ilustrativo

Contexto. Corretora de seguros que publica formulários de cotação diferentes para cada seguradora parceira. Dezoito parceiras, cada uma com o próprio conjunto de perguntas, que mudam a cada campanha.

A dor. A primeira versão gravava respostas em colunas. Cada campanha nova exigia deploy. O time chegou a manter uma planilha para saber qual coluna pertencia a qual campanha, o que é o sintoma clássico de schema no lugar errado.

A solução com o BB. Duas coleções: formularios guarda a definição (perguntas, tipos, ordem), respostas guarda o que o usuário preencheu. A coleção respostas marca cpf e telefone como encrypted: true, então esses campos ficam cifrados no banco. A ACL por coleção restringe a escrita em formularios aos identificadores de usuário do time de produto, mesmo entre quem já tem DATASTORE_WRITE.

O resultado. Campanha nova é uma inserção de documento, não um deploy. E os dados pessoais coletados ficam cifrados em repouso sem o time do formulário escrever uma linha de criptografia.

Caso 3 — Configuração por cliente sem cache incoerente Cenário ilustrativo

Contexto. Plataforma que roda 30 building blocks para 80 empresas clientes, cada uma com preferências operacionais: horário de disparo, canal preferido, limite de tentativa, texto padrão.

A dor. A configuração vivia em variável de ambiente e em Redis. Variável exige deploy para mudar; Redis perde tudo em um flush e ninguém sabe quem gravou o quê. Não havia auditoria e não havia validação — um valor errado só aparecia em produção.

A solução com o BB. O fast-path KV: PUT /kv/config/horario-disparo grava por chave natural, GET lê. Por baixo é uma coleção reservada kv_config com todo o resto da mecânica junto — escopo por tenant, RLS, createdBy/updatedBy e soft delete. Para a configuração que precisa de forma, uma coleção normal com schema-lite rejeita valor de tipo errado na escrita.

O resultado. Configuração passa a ser dado, com dono e carimbo de tempo, e não estado volátil. Mudança não pede deploy.

Caso 4 — Isolamento em banco compartilhado é padrão de mercado, e o risco dele é conhecido Referência de mercado

Contexto. O modelo de banco compartilhado com discriminador de tenant é a arquitetura recomendada pela AWS para SaaS B2B pelo custo por tenant (AWS SaaS Tenant Isolation Strategies).

A dor do mercado. O mesmo material é direto no risco: com banco compartilhado, o isolamento depende inteiramente da camada de aplicação. Uma cláusula WHERE esquecida é um vazamento entre clientes. A documentação do PostgreSQL descreve a resposta a isso — políticas de Row-Level Security que restringem as linhas visíveis por sessão (PostgreSQL — Row Security Policies).

Como a Catalisa endereça. O Data Store aplica o discriminador de tenant em três lugares independentes: no contrato do driver, que não tem operação sem tenantId; na consulta SQL, onde tenant_id é a primeira cláusula e não é opcional; e na política de RLS instalada nas três tabelas com FORCE ROW LEVEL SECURITY. As três camadas leem o mesmo valor, que vem do token assinado.

O resultado. O padrão econômico de multi-tenancy com uma barreira a mais do que a implementação usual. As condições para a barreira de RLS valer estão descritas sem rodeio em §14 e §15.


05Mercado e diferenciaisnegócio

Panorama. Quem precisa guardar documento JSON hoje escolhe entre três caminhos. O banco gerenciado de documentos (MongoDB Atlas, DynamoDB) entrega motor maduro e transfere para você toda a decisão de como separar clientes. A plataforma de backend (Firestore, Supabase) entrega API pronta e transfere para você a autoria das regras de segurança — Security Rules no Firestore, políticas de RLS no Supabase. E o terceiro caminho, o mais comum, é não escolher nada e enfiar um jsonb numa tabela que já existe.

O Data Store nasce em um recorte estreito de propósito: ele não tenta ser um banco de documentos completo. Ele é a capacidade de guardar documento JSON já isolada por empresa cliente, para quem já usa o resto do catálogo.

CritérioCatalisa Data StoreMongoDB AtlasCloud FirestoreSupabaseAmazon DynamoDB
Isolamento por tenantPronto: token → driver → política do bancoVocê escolhe e implementa o padrãoVocê escreve as Security RulesVocê escreve as políticas de RLSConvenção na chave de partição
DDL ao entrar um cliente novoNenhumNenhum, se usar discriminadorNenhumDepende do seu schemaNenhum
Consulta por conteúdoOperadores portáveis sobre JSONBLinguagem de consulta completaConsulta indexada, com limitesSQL completoSó por chave ou índice secundário
Agregação e joinNãoSim, pipeline de agregaçãoLimitadaSim, SQLNão
Criptografia de campoMarcada no schema da coleçãoQueryable Encryption nos planos superioresVocê implementaVocê implementaClient-side encryption via SDK
Tempo real para app móvelNãoChange streamsSim, é a força do produtoSim, RealtimeStreams
Operação por sua contaJá vem operado com o catálogoNãoNãoNãoNão
Modelo de preçoEm definição, por documentoPor hora de clusterPor operaçãoPor projeto e por GBPor requisição e GB

Preços dos análogos consultados nas páginas oficiais em 2026-08-16 — ver §6 para os valores e as fontes.

Nossos diferenciais

  1. O isolamento é estrutural, não convencional. O tipo DataStoreCtx não tem operação sem tenantId, e não existe "buscar por id sem tenant" no contrato do driver. Um desenvolvedor não consegue escrever a consulta insegura sem alterar a interface — o compilador reclama antes do revisor.
  2. A segunda barreira está no banco, na mesma transação. Cada operação abre transação, define app.tenant_id com set_config e só então executa. As políticas de RLS avaliam essa configuração. Copiar isso não é difícil tecnicamente; é difícil porque exige disciplina em cada operação nova, e é justamente onde implementações caseiras escorregam.
  3. Vem junto com o resto. Um cliente que já usa IAM, Audit Trail e Webhooks Engine ganha, sem integração adicional, um armazenamento de documentos que fala a mesma língua de permissão, de organização e de evento. Contratar Atlas ou Firestore ao lado significa reconciliar dois modelos de identidade.
  4. A portabilidade de motor está no contrato, não na promessa. O driver publica o que sabe fazer em capabilities, e o serviço recusa uma consulta que o motor ativo não honra em vez de devolver resultado errado em silêncio. É o oposto da abstração que descarta filtro sem avisar.

Quando escolher o concorrente. Se o dado que você quer guardar é o banco principal do seu produto, escolha Postgres direto ou MongoDB Atlas — o Data Store não faz join entre coleções nem transação multi-documento, e forçar isso aqui é errado. Se o seu app é móvel e precisa de sincronização em tempo real e modo offline, o Firestore resolve isso hoje e nós não resolvemos. Se o volume é de dezenas de milhares de escritas por segundo com latência estável, o DynamoDB é o produto certo e a nossa tabela JSONB única não é. E se o seu time quer um Postgres completo com API automática e controle total do schema, o Supabase entrega mais do que nós — nós entregamos menos, de propósito, já isolado por cliente. O Data Store ganha quando o problema é dado de borda, variável por cliente, dentro de uma plataforma multi-tenant — não quando o problema é escala bruta ou modelagem relacional rica.


06Modelo de cobrança e ROInegócio

Unidade de cobrança. Documento armazenado. É a unidade que o cliente entende e que cresce junto com o valor que ele extrai: quem guarda mais registro está usando mais o produto. A precificação está em definição — não há tabela publicada, e este documento não estima uma.

O que dispara custo. Três coisas: volume de documentos armazenados, bytes armazenados e número de chamadas de API. A quota por tenant (maxDocs, maxBytes) já existe no modelo de dados e é o gancho onde a cobrança vai se apoiar.

Custo de referência dos análogos — cenário nomeado: 50 mil documentos, cerca de 10 GB, 200 mil chamadas de API por mês, uma operação B2B de porte médio.

ProdutoComo cobraOrdem de grandeza no cenárioFonte e data
MongoDB AtlasCluster dedicado por hora; M10 (2 vCPU, 2 GB RAM, 10–128 GB) a US$ 0,08/h~US$ 57/mês só de cluster, antes de backup e transferênciamongodb.com/pricing, consultado em 2026-08-16
MongoDB Atlas FlexTier compartilhado a partir de US$ 0,011/h, teto de US$ 30/mês, 5 GBUS$ 8 a US$ 30/mês, mas o teto de 5 GB não cobre o cenáriomongodb.com/pricing, consultado em 2026-08-16
Amazon DynamoDBUS$ 0,625 por milhão de escritas, US$ 0,125 por milhão de leituras, US$ 0,25 por GB/mêsArmazenamento de 10 GB ≈ US$ 2,50/mês; as 200 mil chamadas custam centavosaws.amazon.com/dynamodb/pricing/on-demand, consultado em 2026-08-16
Supabase ProUS$ 25/mês com 8 GB de disco, depois US$ 0,125 por GB~US$ 25 + ~US$ 0,25 de disco excedentesupabase.com/pricing, consultado em 2026-08-16
Cloud FirestorePor operação, com cota gratuita diária (50 mil leituras e 20 mil escritas/dia, 1 GiB armazenado)Depende inteiramente do padrão de leitura; o cenário pode ficar dentro da cota gratuita ou explodir com uma tela que relê tudofirebase.google.com/pricing, consultado em 2026-08-16

Valores consultados nas páginas oficiais em 2026-08-16, em dólares, sem impostos e sem descontos negociados. Servem para ordem de grandeza em conversa comercial, não como proposta. Confira a tabela do fornecedor na data da sua análise.

ROI. A conta relevante não é a linha de licença — o DynamoDB é barato e o Firestore pode ser gratuito no cenário acima. A conta é a que ninguém coloca na planilha:

  • Infraestrutura evitada. No cenário acima, contratar Atlas custa da ordem de US$ 57/mês em cluster. Mas o custo maior é ter mais um sistema com backup, monitoramento, rotação de credencial e plantão.
  • Engenharia de isolamento evitada. Escolher e implementar um padrão de multi-tenancy em Atlas, Firestore ou DynamoDB — e depois auditar que ele foi seguido em todo lugar — é trabalho recorrente, não trabalho de uma vez. Cada consulta nova é uma chance de esquecer o filtro.
  • Migração evitada. O ganho mais concreto e mais fácil de medir: cada campo personalizado de cliente que deixa de virar migração é uma janela de deploy que não acontece.

Um cliente que já usa o catálogo Catalisa está comparando "mais um documento gravado" contra "mais um fornecedor de banco, com contrato, integração de identidade e plantão próprio".


07Arquitetura

O Data Store segue a arquitetura padrão dos building blocks — app.ts, main.ts, routes/, services/, types/ — com uma adição: uma SPI de driver entre o serviço e a persistência, de modo que o building block inteiro é independente do motor de banco por baixo.

                         HTTP (envelope tolerante a JSON:API)
                           │
  ┌────────────────────────┴───────────────────────────────────────┐
  │ routes/  (Hono)                                                 │
  │   authMiddleware → requirePermission(DATASTORE_*)               │
  │   → requireOrganization → parse Zod → handleResult              │
  │   tenantId = user.organizationId!   (NUNCA vem do corpo)        │
  └────────────────────────┬───────────────────────────────────────┘
                           │  ResultAsync<T, AppError>
  ┌────────────────────────┴───────────────────────────────────────┐
  │ services/DataStoreService  (neverthrow)                         │
  │   resolve coleção · ACL · validação schema-lite ·               │
  │   quota · cripto de campo · eventos ·                           │
  │   delega persistência ao DRIVER (não conhece motor nenhum)      │
  └────────────────────────┬───────────────────────────────────────┘
                           │  DataStoreCtx { tenantId, userId }
  ┌────────────────────────┴───────────────────────────────────────┐
  │ DataStoreDriver  (SPI — a abstração)                            │
  │   insert/get/update/delete/query · upsertByKey/getByKey/…       │
  │   createCollection/… · usage · capabilities                     │
  │   ── TODA operação recebe tenantId e é OBRIGADA a escopar ──    │
  └───────┬──────────────────────┬───────────────────────┬─────────┘
          │ padrão               │ não implementado      │ não implementado
  ┌───────┴────────┐   ┌─────────┴────────┐   ┌──────────┴─────────┐
  │ PostgresJsonb  │   │  DynamoDbDriver  │   │   MongoDriver      │
  │ Driver         │   │  (ver §15)       │   │   (ver §15)        │
  └───────┬────────┘   └──────────────────┘   └────────────────────┘
          │
  ┌───────┴───────────────────────────────────────────────────────┐
  │ Postgres, schema "datastore"  (uma tabela JSONB, índice GIN)   │
  │   documents · collections · quotas   → ZERO DDL por tenant     │
  │   + Row-Level Security (app.tenant_id)  [defesa em profund.]   │
  └───────────────────────────────────────────────────────────────┘

Trocar o motor é mudar uma linha no registrador de DI (src/shared/container/modules/data-store.ts), ligando outro driver ao DataStoreDriverToken. services/ e routes/ não são tocados.

Decisões não óbvias.

  • Documento em vez de relacional. Relacional por cliente exigiria DDL em tempo de execução — criar tabela e coluna dinamicamente. Isso é sobrecarga operacional e uma superfície de segurança grande demais para o ganho. JSONB dá a flexibilidade schemaless com zero DDL por tenant, e continua consultável pelo conteúdo. Quem quer estrutura ativa o schema-lite por coleção e ganha os dois.
  • Uma tabela para todos os tenants, com índice GIN em data. Isolamento é a coluna tenant_id, não um objeto de banco por cliente. É o que mantém o custo por tenant plano; o preço é que consulta por conteúdo depende do índice GIN e não de índice por caminho — aceitável no perfil de uso desta capability, e o gargalo conhecido está em §15.
  • Toda operação abre transação, mesmo leitura de um documento só. Parece exagero para um findFirst. Não é: a transação é o que dá escopo ao set_config('app.tenant_id', …, true), e o terceiro argumento true significa "local a esta transação". Sem transação, a configuração vazaria para a próxima requisição que pegasse a mesma conexão do pool — o que, num pool compartilhado, é exatamente o bug que a RLS deveria evitar.
  • CRUD pelo Prisma, filtro por SQL cru. O Prisma não expressa bem consulta por caminho JSONB arbitrário. A query() monta SQL cru para selecionar os ids ordenados e depois hidrata as linhas completas pelo Prisma, o que preserva o tipo do modelo. O SQL é montado com cuidado: cada segmento de caminho é validado contra ^[A-Za-z0-9_]+$ antes de ser embutido no literal de caminho, e todo valor é ligado como parâmetro $N, nunca concatenado.
  • O driver publica o que sabe fazer. capabilities: { arbitrarySort, containment, offsetPagination }. Se um driver não suporta ordenação arbitrária, o serviço recusa a consulta com 400 em vez de devolver resultado na ordem errada. Abstração que descarta filtro em silêncio é pior que abstração nenhuma.
  • A instalação da RLS é idempotente e não fatal. ensureSecurity() roda uma vez por processo e aplica as políticas. Se o papel de banco não tiver direito de DDL — comum em banco gerenciado —, ele registra aviso e segue, porque o filtro explícito de tenant_id continua sendo a barreira primária. Derrubar o serviço nesse caso trocaria uma defesa a menos por indisponibilidade total.

Monolito vs. standalone. Em monolito, o app é montado sob /data-store junto com os demais. Em standalone — o modo usado em produção — sobe na porta 3030 (3000 dentro do cluster) e outros building blocks o alcançam por HTTP através do DataStoreFacadeToken, autenticados como o usuário sentinela INTERNAL_DATA_STORE_USER_ID. A lógica de negócio é a mesma nos dois modos.


08Conceitos e modelo de dados

Glossário

TermoSignifica
ColeçãoAgrupamento nomeado de documentos, análogo a uma tabela. Única por (tenant, nome). Nome casa ^[a-z][a-z0-9_-]{0,62}$.
DocumentoUm objeto JSON gravado numa coleção. Máximo de 512 KiB por documento.
docKeyChave natural opcional do documento, única dentro de (tenant, coleção). É o que permite gravar por identificador de negócio em vez de por UUID.
versionContador de concorrência otimista. Sobe a cada escrita. Enviar expectedVersion diferente do gravado devolve 409.
Schema-liteSubconjunto de JSON Schema validado na escrita: type, required, properties.type, additionalProperties. Sem ajv, sem schema aninhado.
ACL de coleçãoLista de userId autorizados a ler ou escrever aquela coleção, aplicada sobre a permissão RBAC, nunca no lugar dela. "*" libera para quem já tem a permissão.
Campo cifradoPropriedade marcada "encrypted": true no schema. Guardada como envelope enc:v1:iv:tag:ciphertext e decifrada na leitura.
Fast-path KVAçúcar sintático sobre uma coleção reservada kv_<namespace>, endereçada por chave natural.
DriverImplementação da SPI que fala com o motor. O padrão é PostgresJsonbDriver.
QuotaLimite por tenant de documentos e bytes. Verificada antes de inserir.

Modelo de dados — schema datastore no PostgreSQL. Três tabelas, todas com tenant_id, exclusão lógica em deleted_at e política de RLS.

Modelo PrismaTabelaPropósitoCampos-chave
DataCollectiondatastore.collectionsMetadado da coleçãoname, jsonSchema (JSONB), pii, acl (JSONB). Único (tenantId, name)
DataDocumentdatastore.documentsOs documentosdata (JSONB), version, docKey. Único (tenantId, collection, docKey). Índice GIN com jsonb_path_ops em data
DataStoreQuotadatastore.quotasLimites por tenantmaxDocs (padrão 100000), maxBytes (padrão 1 GiB), usedDocs, usedBytes

O schema é aplicado por prisma db push — este projeto é gerenciado por push, não por migrate.

Ciclo de vida de um documento

   POST /documents
        │
        ▼
  ┌───────────┐   PUT com expectedVersion correto   ┌───────────┐
  │ version 1 │ ─────────────────────────────────▶  │ version 2 │ ─▶ …
  └─────┬─────┘                                     └─────┬─────┘
        │                                                 │
        │ PUT com expectedVersion desatualizado           │ DELETE
        ▼                                                 ▼
   409 CONFLICT                                    deleted_at preenchido
   (nada é gravado)                                (linha preservada)

  Exclusão é lógica. O slot do único (tenant, coleção, docKey) continua
  ocupado depois do DELETE — por isso existe o `ensure`, que ressuscita
  o documento soft-deleted sem sobrescrever o conteúdo dele.

Como o tenant atravessa as camadas

  JWT assinado          middleware              serviço            driver
  ────────────          ──────────              ───────            ──────
  organizationId  ──▶  requireOrganization ──▶  DataStoreCtx  ──▶  tenant_id
       │                (403 se ausente)         { tenantId }        │
       │                                                            ▼
       │                                          set_config('app.tenant_id', …, true)
       │                                                            │
       └──────────── mesmo valor em todas as camadas ───────────────┤
                                                                    ▼
                                            política RLS: tenant_id = current_setting(…)

09Referência da API

Prefixo: /data-store. Em staging, a base é https://data-store.bb.stg.catalisa.app.

Todas as rotas de negócio exigem, nesta ordem: authMiddleware (JWT válido), requirePermission(...) e requireOrganization (token com organizationId; devolve 403 sem ele). O corpo aceita tanto o formato direto quanto o envelope JSON:API ({"data":{"attributes":{...}}}).

São 13 endpoints declarados em routes/, mais GET /api/v1/usage e GET /health, declarados diretamente em app.ts.

Coleções — /data-store/api/v1/collections

MétodoRotaDescriçãoPermissão
POST/data-store/api/v1/collectionsCria coleçãoDATASTORE_ADMIN
GET/data-store/api/v1/collectionsLista coleções do tenantDATASTORE_READ
GET/data-store/api/v1/collections/:nameMetadado de uma coleçãoDATASTORE_READ
DELETE/data-store/api/v1/collections/:nameExclusão lógica da coleção e dos documentos delaDATASTORE_ADMIN

Documentos — /data-store/api/v1/collections/:name/documents

MétodoRotaDescriçãoPermissão
POST/data-store/api/v1/collections/:name/documentsInsere documentoDATASTORE_WRITE
POST/data-store/api/v1/collections/:name/documents/ensureGet-or-create por docKey, idempotenteDATASTORE_WRITE
GET/data-store/api/v1/collections/:name/documents/:idLê documento por UUIDDATASTORE_READ
PUT/data-store/api/v1/collections/:name/documents/:idSubstitui documento, com concorrência otimistaDATASTORE_WRITE
DELETE/data-store/api/v1/collections/:name/documents/:idExclusão lógicaDATASTORE_WRITE
POST/data-store/api/v1/collections/:name/documents/queryFiltra, ordena e pagina por conteúdoDATASTORE_READ

Fast-path KV — /data-store/api/v1/kv

MétodoRotaDescriçãoPermissão
PUT/data-store/api/v1/kv/:namespace/:keyGrava valor, cria a coleção de apoio se precisoDATASTORE_WRITE
GET/data-store/api/v1/kv/:namespace/:keyLê valorDATASTORE_READ
DELETE/data-store/api/v1/kv/:namespace/:keyExclusão lógica do valorDATASTORE_WRITE

namespace casa ^[a-z][a-z0-9_-]{0,40}$ e key casa ^[A-Za-z0-9._:-]{1,255}$. Fora disso, 400.

Uso e saúde

MétodoRotaDescriçãoPermissão
GET/data-store/api/v1/usageDocumentos e bytes usados contra a quota do tenantDATASTORE_READ
GET/data-store/healthSonda de saúde do serviçoPública

Quem tem as permissões. O papel ADMIN tem as três (DATASTORE_READ, DATASTORE_WRITE, DATASTORE_ADMIN). O papel VIEWER tem apenas DATASTORE_READ. Como em todo building block, a organização é o teto: papel não concede o que a organização não contratou.


POST /data-store/api/v1/collections

Cria uma coleção. Exige DATASTORE_ADMIN.

Request

{
  "name": "propostas",
  "pii": true,
  "jsonSchema": {
    "type": "object",
    "required": ["parceiro", "status"],
    "properties": {
      "parceiro": { "type": "string" },
      "status":   { "type": "string" },
      "valor":    { "type": "number" },
      "cpf":      { "type": "string", "encrypted": true }
    }
  },
  "acl": { "write": ["b1000000-0000-0000-0000-000000000001"] }
}
CampoTipoObrigatórioDescrição
namestringSimCasa ^[a-z][a-z0-9_-]{0,62}$. Único por tenant
jsonSchemaobjectNãoSchema-lite. Sem ele, a coleção aceita qualquer JSON
piibooleanNãoMarca a coleção como portadora de dado pessoal. Padrão false
acl{ read?: string[], write?: string[] }NãoAté 64 userId por lado. Lado ausente fica aberto à permissão RBAC

Resposta 201

{
  "data": {
    "type": "collections",
    "id": "3f6b1c2a-8e4d-4b1f-9a2c-77e0d1b4c5aa",
    "name": "propostas",
    "pii": true,
    "jsonSchema": { "type": "object", "required": ["parceiro", "status"], "properties": { "…": {} } },
    "createdAt": "2026-08-16T12:00:00.000Z",
    "updatedAt": "2026-08-16T12:00:00.000Z"
  }
}

O acl não é devolvido na resposta — é metadado de controle, não de conteúdo.

Erros

StatusQuando
400Nome fora do padrão, ou corpo reprovado no Zod
403Token sem organizationId, ou sem DATASTORE_ADMIN
409Já existe coleção com esse nome no tenant (campo name)

POST /data-store/api/v1/collections/:name/documents

Insere um documento. Exige DATASTORE_WRITE.

Request

{
  "data": { "parceiro": "banco-exemplo", "status": "captura", "valor": 12000, "cpf": "12345678900" },
  "docKey": "PROP-2026-000123",
  "createIfNotExists": true
}
CampoTipoObrigatórioDescrição
dataobjectSimO documento. Máximo de 512 KiB serializado
docKeystring (1–255)NãoChave natural. Único por (tenant, coleção)
createIfNotExistsbooleanNãoCria a coleção schemaless se ela não existir. Padrão false

Resposta 201

{
  "data": {
    "type": "documents",
    "id": "9a1f0e33-2b7c-4a55-8f10-6d2c9b3e7a41",
    "collection": "propostas",
    "docKey": "PROP-2026-000123",
    "version": 1,
    "attributes": { "parceiro": "banco-exemplo", "status": "captura", "valor": 12000, "cpf": "12345678900" },
    "createdAt": "2026-08-16T12:01:00.000Z",
    "updatedAt": "2026-08-16T12:01:00.000Z",
    "createdBy": "b1000000-0000-0000-0000-000000000001"
  }
}

O cpf volta em claro na resposta porque o serviço decifra na leitura. No banco ele está gravado como enc:v1:….

Erros

StatusQuando
400Documento acima de 512 KiB, ou reprovado no schema-lite da coleção
403ACL de escrita da coleção não inclui o chamador
404Coleção não existe e createIfNotExists não foi enviado
409docKey já usado no tenant (campo docKey), ou quota de documentos estourada (campo quota)

PUT /data-store/api/v1/collections/:name/documents/:id

Substitui o documento inteiro. Não faz merge parcial. Exige DATASTORE_WRITE.

{
  "data": { "parceiro": "banco-exemplo", "status": "averbacao", "valor": 12000 },
  "expectedVersion": 1
}

Com expectedVersion, a escrita só acontece se a versão gravada for exatamente essa. Sem ele, a escrita sobrescreve o que estiver lá.

Resposta 200 — mesmo formato do insert, com version incrementado.

Erros

StatusQuando
404Documento inexistente ou já excluído
409Version conflict: expected N, found M — releia o documento e reaplique a mudança

POST /data-store/api/v1/collections/:name/documents/query

Filtra, ordena e pagina por conteúdo. Exige DATASTORE_READ.

{
  "where": {
    "status":      { "op": "eq",  "value": "averbacao" },
    "valor":       { "op": "gte", "value": 10000 },
    "perfil.idade":{ "op": "gt",  "value": 18 },       // caminho aninhado com ponto
    "$updatedAt":  { "op": "lt",  "value": "2026-08-14T00:00:00Z" }
  },
  "sort": [{ "path": "valor", "dir": "desc" }],
  "page": 1,
  "pageSize": 50
}
CampoTipoPadrãoLimite
wheremapa de caminho → { op, value }vazioprofundidade de caminho até 8 segmentos, cada um casando ^[A-Za-z0-9_]+$
sortlista de { path, dir }created_at DESCaté 4 cláusulas, caminho até 200 caracteres
pageinteiro ≥ 11
pageSizeinteiro50máximo 200

Operadores: eq ne gt gte lt lte in nin exists prefix contains.

Caminhos meta — filtram e ordenam por coluna, não pelo corpo JSON: $id, $version, $docKey, $createdAt, $updatedAt.

Detalhes que mudam o resultado: contains usa contenção JSONB (@>); comparações com valor numérico convertem o caminho para ::numeric; prefix escapa %, _ e \ antes de montar o LIKE; eq com null vira IS NULL.

Resposta 200data com os documentos, mais meta de paginação e links.

Erros

StatusQuando
400Operador desconhecido, caminho inválido, in/nin sem array, ou filtro/ordenação sobre campo cifrado
403ACL de leitura da coleção não inclui o chamador
404Coleção inexistente

POST /data-store/api/v1/collections/:name/documents/ensure

Get-or-create por chave natural. Idempotente e seguro contra corrida — é a operação certa para "garanta que existe um registro de configuração para este cliente".

{ "docKey": "config-parceiro-42", "defaultData": { "canal": "whatsapp", "tentativas": 3 }, "createIfNotExists": true }

O caminho quente é uma leitura só: se o documento existe, ele volta como está, sem escrever, sem incrementar version e sem mexer em updatedAt. Se não existe, delega ao upsert atômico do driver, que também ressuscita documento excluído logicamente sem sobrescrever o conteúdo. Responde 200, não 201, nos dois casos.


10Início rápido

Do zero a um documento consultável. Os comandos abaixo usam staging; substitua pelo seu ambiente. Não executados nesta revisão da documentação — confira a resposta no seu ambiente.

1. Autenticar no IAM

TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@catalisa.app",
    "password": "root123456",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)

echo "${TOKEN:0:24}..."

2. Criar a coleção

curl -s -X POST https://data-store.bb.stg.catalisa.app/data-store/api/v1/collections \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"propostas"}' | jq '.data'
{ "type": "collections", "id": "…", "name": "propostas", "pii": false }

3. Gravar um documento

DOC=$(curl -s -X POST \
  https://data-store.bb.stg.catalisa.app/data-store/api/v1/collections/propostas/documents \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"data":{"parceiro":"banco-exemplo","status":"captura","valor":12000},
       "docKey":"PROP-2026-000123"}')

DOC_ID=$(echo "$DOC" | jq -r '.data.id')
echo "$DOC" | jq '.data.version'   # 1

4. Consultar pelo conteúdo

curl -s -X POST \
  https://data-store.bb.stg.catalisa.app/data-store/api/v1/collections/propostas/documents/query \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"where":{"status":{"op":"eq","value":"captura"},
                "valor":{"op":"gte","value":10000}},
       "sort":[{"path":"valor","dir":"desc"}],"pageSize":10}' | jq '.meta, .data[0].attributes'

5. Ver o consumo contra a quota

curl -s https://data-store.bb.stg.catalisa.app/data-store/api/v1/usage \
  -H "Authorization: Bearer $TOKEN" | jq '.data'
{ "usedDocs": 1, "maxDocs": 100000, "usedBytes": 0, "maxBytes": 1073741824 }

6. Confirmar que o isolamento não depende de você

curl -s -o /dev/null -w "%{http_code}\n" \
  https://data-store.bb.stg.catalisa.app/data-store/api/v1/collections \
  -H "Authorization: Bearer $TOKEN_SEM_ORGANIZACAO"

Devolve 403. Não existe caminho em que um token sem organização leia documento — e não existe campo no corpo onde você possa informar a organização.

Credenciais de staging, publicadas em AMBIENTES.md. Nunca use credencial de produção em documentação ou script de exemplo.


11Receitas

Guardar estado de workflow com trava otimista

Objetivo. Fazer vários workers avançarem a mesma esteira sem um sobrescrever o trabalho do outro.

# 1. Ler o estado atual e guardar a versão
CUR=$(curl -s "$BASE/data-store/api/v1/collections/propostas/documents/$DOC_ID" \
  -H "Authorization: Bearer $TOKEN")
VER=$(echo "$CUR" | jq -r '.data.version')

# 2. Escrever a próxima etapa exigindo essa versão
curl -s -X PUT "$BASE/data-store/api/v1/collections/propostas/documents/$DOC_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"data\":{\"parceiro\":\"banco-exemplo\",\"status\":\"averbacao\",\"valor\":12000},
       \"expectedVersion\":$VER}"

Armadilhas.

  • O PUT substitui o documento inteiro. Se você mandar só o campo que mudou, os outros somem. Leia, altere o objeto em memória, mande completo.
  • 409 não é erro de infraestrutura, é a trava funcionando: outro worker escreveu antes. Releia e reaplique — não faça retry cego com a mesma versão, que vai falhar de novo.
  • Não use expectedVersion em escrita idempotente de reprocessamento; ali o certo é ensure por docKey.

Guardar dado pessoal cifrado em repouso

Objetivo. Que o CPF fique ilegível no banco sem o integrador escrever código de criptografia.

# 1. A coleção declara quais campos são cifrados
curl -s -X POST "$BASE/data-store/api/v1/collections" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"pessoas","pii":true,
       "jsonSchema":{"type":"object",
         "properties":{"nome":{"type":"string"},
                       "cpf":{"type":"string","encrypted":true}}}}'

# 2. Gravar normalmente — o serviço cifra sozinho
curl -s -X POST "$BASE/data-store/api/v1/collections/pessoas/documents" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"data":{"nome":"Maria","cpf":"12345678900"}}'

Armadilhas.

  • Campo cifrado não é consultável. Filtrar ou ordenar por cpf devolve 400, de propósito: no banco há texto cifrado, e uma consulta ali retornaria vazio em silêncio. Se você precisa buscar por CPF, guarde um hash em campo separado e busque por ele.
  • Só campos de primeiro nível são cifrados. dados.cpf aninhado não é.
  • A chave DATASTORE_CREDENTIAL_MASTER_KEY precisa existir no ambiente antes da primeira escrita cifrada. Sem ela, a escrita falha com 500; o serviço sobe normalmente, porque a chave só é lida quando há campo marcado.
  • Se a chave for trocada sem migração, a leitura devolve o envelope enc:v1:… em vez do valor — o serviço não derruba a requisição, ele entrega o texto cifrado. Não há rotação de chave automatizada (§15).

Restringir uma coleção a um subconjunto de usuários

Objetivo. Todo mundo do tenant tem DATASTORE_READ, mas só o time de risco pode ler a coleção scores.

curl -s -X POST "$BASE/data-store/api/v1/collections" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"scores","acl":{"read":["<userId-1>","<userId-2>"],"write":["<userId-1>"]}}'

Armadilhas.

  • A ACL é aplicada sobre o RBAC, nunca no lugar dele. Quem não tem DATASTORE_READ continua barrado antes, com 403 do middleware.
  • Lado não preenchido fica aberto a quem tem a permissão base. {"read":[...]} sem write significa leitura restrita e escrita liberada.
  • A lista é de userId, não de papel. Trocar de pessoa no time exige atualizar a ACL, e hoje isso é recriar a coleção — não há endpoint de atualização de coleção (§15).
  • Chamada máquina a máquina pelo facade chega com o userId sentinela 00000000-0000-0000-0000-000000000000. Se a ACL não incluir esse identificador nem "*", o outro building block toma 403.

Usar como armazenamento chave-valor

Objetivo. Guardar configuração por cliente sem criar coleção nem schema.

curl -s -X PUT "$BASE/data-store/api/v1/kv/config/horario-disparo" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"value":{"inicio":"09:00","fim":"18:00","fuso":"America/Sao_Paulo"}}'

curl -s "$BASE/data-store/api/v1/kv/config/horario-disparo" \
  -H "Authorization: Bearer $TOKEN" | jq '.data.value'

Armadilhas.

  • Isto não é cache. Cada operação é uma transação no Postgres. Para caminho quente de requisição com latência de milissegundos, use Redis.
  • O PUT cria a coleção kv_config na primeira gravação, sem schema e sem ACL. Se você precisa de ACL nesse dado, crie a coleção antes, com o nome kv_<namespace> completo.
  • O valor é embrulhado como { "value": … } no documento. Se você consultar a coleção kv_config pela API de documentos, é assim que vai encontrar.

Consultar por caminho aninhado e por campo meta

Objetivo. Achar documentos por conteúdo profundo e por data de atualização.

curl -s -X POST "$BASE/data-store/api/v1/collections/propostas/documents/query" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"where":{
        "cliente.endereco.uf": {"op":"in","value":["SP","RJ"]},
        "tags":                {"op":"contains","value":["prioritaria"]},
        "$updatedAt":          {"op":"lt","value":"2026-08-14T00:00:00Z"}
      },"pageSize":100}'

Armadilhas.

  • Segmento de caminho só aceita letra, número e sublinhado. Chave JSON com hífen ou espaço não é consultável e devolve 400 — pense nisso ao desenhar o documento.
  • contains compara contenção JSONB. Para array, o valor precisa ser um array, não um elemento solto.
  • A ordenação padrão é created_at DESC. Paginação por offset sobre coleção que recebe escrita constante pode repetir ou pular item entre páginas — para varredura completa, ordene por $id.
  • pageSize acima de 200 não é erro, é silenciosamente reduzido a 200 pelo driver.

12Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token que carrega organizationId e as permissões DATASTORE_*. É de onde vem o tenantSim
File StorageO irmão: guarda os bytes; aqui ficam os metadados e o dado estruturado que aponta para elesNão
Audit TrailRegistra quem leu e quem escreveu, quando a operação exige trilha de complianceNão
Webhooks EngineEntrega os eventos data-store.* a sistemas externos do clienteNão
API KeysCredencial de longa duração para integração programática que grava documentosNão
Qualquer BB, via facadeDataStoreFacadeToken expõe insert, get e delete sem dependência estática do móduloNão

Eventos publicados. data-store.collection.created, data-store.collection.deleted, data-store.document.created, data-store.document.updated, data-store.document.deleted. Todos carregam organizationId e userId nos metadados.

                        ┌──────────────────────────────┐
                        │            IAM               │
                        │  token com organizationId    │
                        └──────────────┬───────────────┘
                                       │ Bearer JWT
      ┌────────────────────────────────┼────────────────────────────────┐
      ▼                                ▼                                ▼
┌───────────┐   documento JSON   ┌─────────────┐   bytes do arquivo ┌──────────────┐
│  App do   │ ─────────────────▶ │ Data Store  │                    │ File Storage │
│  cliente  │                    │  (JSONB)    │◀── id do arquivo ──│    (S3)      │
└───────────┘                    └──────┬──────┘   guardado no doc  └──────────────┘
                                        │ eventos data-store.*
                        ┌───────────────┴───────────────┐
                        ▼                               ▼
                ┌───────────────┐              ┌─────────────────┐
                │  Audit Trail  │              │ Webhooks Engine │
                │ trilha de uso │              │ entrega externa │
                └───────────────┘              └─────────────────┘

O par com o file-storage é o desenho recomendado: o binário vai para o S3 pelo file-storage, e o documento aqui guarda o fileId junto com os campos de negócio que descrevem aquele arquivo. Assim a consulta por conteúdo funciona sobre metadado estruturado, e o blob não passa pelo banco.


13Configuração e operação

Variáveis de ambiente

VariávelDescriçãoObrigatóriaPadrão
DATABASE_URLPostgreSQL. O schema datastore vive neleSim
JWT_SECRETSegredo compartilhado para verificar o token do IAM (mínimo 44 caracteres)Sim
DATASTORE_CREDENTIAL_MASTER_KEYChave de 64 caracteres hexadecimais (32 bytes) para criptografia de campo. Só é exigida quando há campo marcado encryptedNão
DATASTORE_RLS_ENABLEDLiga a instalação das políticas de RLSNãotrue
MODULE_DATA_STORE_URLURL do serviço, usada pelo facade remoto em modo standaloneEm standalone
PORTPorta no modo standaloneNão3030
DEPLOYMENT_MODEmonolith ou standaloneNãostandalone no entrypoint próprio

DATASTORE_DEFAULT_MAX_DOCS e DATASTORE_DEFAULT_MAX_BYTES existem no schema de configuração, mas o driver usa constantes internas com os mesmos valores e não lê essas variáveis. Alterá-las hoje não muda comportamento — ver §15.

Dependências de infraestrutura

DependênciaPara quê
PostgreSQLSchema datastore: coleções, documentos e quotas. Índice GIN em data
IAMVerificação do token e resolução de permissões

Não usa Redis, não usa S3 e não chama nenhum provedor externo.

Limites e quotas

LimiteValorOnde é aplicado
Tamanho de um documento512 KiB serializadoSchema Zod da rota, 400 acima disso
Documentos por tenant100.000 (padrão)Verificado antes do insert, 409 com campo quota
Bytes por tenant1 GiB (padrão)Registrado no modelo, contabilização não implementada (§15)
Itens por página na consulta200Reduzido em silêncio pelo driver
Cláusulas de ordenação4400 acima disso
Profundidade de caminho JSON8 segmentos400 acima disso
userId por lado da ACL64400 acima disso
Nome de coleção63 caracteres, ^[a-z][a-z0-9_-]{0,62}$400 fora do padrão

Catálogo de erros

StatusCódigoSignificaO que fazer
400VALIDATIONCorpo reprovado no Zod ou no schema-lite da coleçãoConfira tipos e campos obrigatórios contra o schema da coleção
400BAD_REQUESTOperador desconhecido, caminho JSON inválido, in sem arrayConfira a consulta contra §9
400VALIDATIONFiltro ou ordenação sobre campo cifradoGuarde um hash em campo separado e consulte por ele
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove o token no IAM
403Token sem organizationIdAutentique informando a organização
403FORBIDDENPermissão DATASTORE_* ausente, ou ACL da coleção barrandoConfira o token; depois confira a ACL da coleção
404NOT_FOUNDColeção, documento ou chave inexistente (ou excluído logicamente)Confira o nome e o id; lembre que exclusão é lógica
409CONFLICT (version)expectedVersion diferente do gravadoReleia o documento e reaplique a mudança
409CONFLICT (docKey)docKey já usado no tenantUse ensure se a intenção era get-or-create
409CONFLICT (name)Coleção já existeEscolha outro nome
409CONFLICT (quota)Quota de documentos estouradaLibere documentos ou ajuste a quota do tenant
500INTERNALFalha de banco, ou chave de criptografia ausente na escrita de campo marcadoVerifique conectividade e DATASTORE_CREDENTIAL_MASTER_KEY

Observabilidade.

  • GET /data-store/health devolve a versão do build e o nome do módulo. É a sonda usada pelo healthcheck do container.
  • GET /data-store/api/v1/usage é a métrica de negócio: quantos documentos o tenant tem contra a quota dele.
  • A aplicação das políticas de RLS emite log. Sucesso sai em info com a lista de tabelas; falha sai em warn com o erro e o serviço continua. Se você não vê a linha de info no boot, as políticas não foram aplicadas — vale investigar.
  • Todos os eventos data-store.* carregam userId e organizationId, o que permite reconstruir quem mexeu em quê pelo Audit Trail.

14Segurança e compliance

Isolamento entre tenants — como funciona, especificamente.

O tenantId é sempre user.organizationId, extraído do JWT assinado pelo IAM. Nenhuma rota lê o identificador da organização do corpo da requisição. Essa é a invariante que sustenta tudo o resto. Sobre ela, quatro camadas:

  1. AutenticaçãoauthMiddleware verifica a assinatura do token.
  2. Autorização RBACrequirePermission(DATASTORE_READ | DATASTORE_WRITE | DATASTORE_ADMIN).
  3. Contexto obrigatóriorequireOrganization devolve 403 para token sem organizationId.
  4. ACL por coleção — lista de userId autorizados a ler ou escrever aquela coleção, aplicada sobre o RBAC. Lado não preenchido fica aberto à permissão base.

E, na persistência, duas barreiras independentes:

  1. Escopo estrutural no driver. O tipo DataStoreCtx exige tenantId, e não existe operação no contrato sem ele. Na consulta, tenant_id é a primeira cláusula do WHERE, sempre, antes de qualquer filtro que o cliente tenha pedido.
  2. Row-Level Security no Postgres. É a defesa em profundidade de verdade, e vale explicar a mecânica.

Como a Row-Level Security funciona aqui.

Toda operação do driver — inclusive a leitura de um único documento — roda dentro de uma transação que começa assim:

SELECT set_config('app.tenant_id', '<organizationId do token>', true);

O terceiro argumento true faz a configuração ser local à transação: ela vale para as consultas seguintes e desaparece quando a transação termina. Isso importa porque o Prisma usa pool de conexões — sem o escopo transacional, o valor definido por uma requisição continuaria valendo para a próxima requisição que pegasse a mesma conexão, que é precisamente o vazamento que a RLS deveria impedir.

Nas três tabelas do schema (documents, collections, quotas), o driver instala de forma idempotente:

ALTER TABLE datastore.documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE datastore.documents FORCE  ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON datastore.documents
  USING      (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

USING filtra o que a sessão enxerga; WITH CHECK impede que ela grave linha de outro tenant. FORCE ROW LEVEL SECURITY existe porque, na configuração normal do PostgreSQL, "table owners normally bypass row security as well, though a table owner can choose to be subject to row security with ALTER TABLE ... FORCE ROW LEVEL SECURITY" (PostgreSQL — Row Security Policies).

Por que isso é defesa em profundidade e não teatro: as duas barreiras falham por motivos diferentes. O filtro do driver falha se alguém escrever uma consulta nova esquecendo o tenant_id — erro humano, no código da aplicação. A política de RLS falha por configuração de banco. Um bug de aplicação não derruba a política, e uma política ausente não derruba o filtro. É preciso errar duas vezes, em camadas distintas, para vazar.

A condição para a RLS valer, dita sem rodeio. A documentação do PostgreSQL é explícita: "Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table". Hoje o serviço se conecta ao banco com o papel postgres, que é superusuário — então, no ambiente atual, as políticas estão instaladas mas não são a barreira efetiva, e o isolamento em vigor é o filtro de tenant_id do driver, que roda em toda operação. Rodar sob um papel de banco não-superusuário é tarefa de infraestrutura, está registrada em §15, e é a única coisa que separa a segunda barreira de estar ativa. Não descreva a RLS como "aplicada em produção" antes disso.

Dados sensíveis.

  • Criptografia de campo. Propriedades marcadas "encrypted": true no schema da coleção são gravadas como envelope AES-256-GCM (enc:v1:iv:tag:ciphertext, em hexadecimal) e decifradas na leitura. GCM é autenticado: adulterar o texto cifrado no banco faz a decifragem falhar em vez de devolver lixo. Só campos de primeiro nível.
  • Chave. DATASTORE_CREDENTIAL_MASTER_KEY, 32 bytes em hexadecimal, lida do ambiente no momento do uso. Segue a convenção de chave por building block da plataforma e deve ser guardada com SOPS, nunca em .env versionado.
  • Marcação de PII. A coleção carrega a flag pii, que declara a intenção e serve de gancho para inventário de dados pessoais.
  • Exclusão lógica. Documentos e coleções usam deletedAt. A linha permanece para auditoria; atender a pedido de eliminação definitiva exige expurgo explícito, que hoje não é automatizado.
  • Rastro de autoria. Todo documento guarda createdBy e updatedBy com o userId do token.

Enquadramento regulatório. Para LGPD, a combinação prática é: marcar a coleção como pii, marcar os campos identificadores como encrypted, e ligar o Audit Trail para a trilha de acesso. Exclusão continua sendo lógica por padrão — o direito de eliminação exige processo de expurgo, e ele é manual hoje (§15).

Autenticação e permissões. Toda rota de negócio exige token válido, uma das três permissões DATASTORE_* e organização no token. ADMIN tem as três; VIEWER tem só leitura. Chamadas entre building blocks passam pelo facade e chegam autenticadas como o usuário sentinela 00000000-0000-0000-0000-000000000000, o que as torna distinguíveis de tráfego de cliente no log.


15Limitações conhecidas

LimitaçãoImpactoSituação
RLS não é a barreira efetiva no ambiente atualO serviço conecta como postgres (superusuário), que ignora políticas de RLS. O isolamento em vigor é o filtro de tenant_id do driver, presente em toda operaçãoTarefa de infraestrutura: rodar sob papel de banco não-superusuário. Não anuncie RLS como aplicada até lá
Drivers DynamoDB e Mongo não existemA SPI está pronta e o enum de motor os prevê, mas não há implementação. O único motor é o Postgres JSONBPlanejado sob demanda. Não é funcionalidade disponível
Contabilização de bytes não implementadamaxBytes e usedBytes existem no modelo e aparecem em /usage, mas usedBytes fica sempre em zero: nada escreve na tabela quotasRoadmap. Só a quota de documentos é aplicada de fato
Quota por tenant só muda no bancoNão há endpoint para ajustar maxDocs de um tenant; a linha em datastore.quotas precisa ser inserida ou alterada manualmenteRoadmap
DATASTORE_DEFAULT_MAX_DOCS e DATASTORE_DEFAULT_MAX_BYTES não têm efeitoEstão no schema de configuração, mas o driver usa constantes internas com os mesmos valoresInconsistência conhecida; alterar as variáveis hoje não muda nada
Sem atualização de coleçãoMudar schema, ACL ou flag pii de uma coleção exige excluir e recriarRoadmap
Campo cifrado não é consultávelFiltro e ordenação sobre ele devolvem 400Por design — é texto cifrado em repouso
Criptografia só de primeiro níveldados.cpf aninhado não é cifrado, só cpf no topoRoadmap
Sem rotação de chave de criptografiaTrocar a chave torna os envelopes antigos ilegíveis; a leitura devolve o envelope em vez do valor, sem erroRoadmap. Planeje migração antes de rotacionar
Sem join entre coleções e sem transação multi-documentoCada operação toca uma coleção. Consistência entre coleções é problema da sua aplicaçãoPor design — é uma capability, não o banco primário do cliente
Sem agregaçãoNão há count by, soma nem pipeline. Só filtro, ordenação e paginaçãoPor design
Índice GIN genérico, sem índice por caminhoConsulta muito seletiva em coleção muito grande depende do GIN em data; não há índice por expressão de caminhoGargalo conhecido em escala; partição de documents por tenant está no roadmap
Paginação por offsetEm coleção com escrita constante, páginas podem repetir ou pular itensPor design. Para varredura completa, ordene por $id
Chave JSON com hífen ou espaço não é consultávelSegmento de caminho só aceita [A-Za-z0-9_]Por design, por segurança na montagem do SQL
Sem expurgo automatizado para LGPDExclusão é lógica; eliminação definitiva é manualRoadmap
prisma db push não remove o schemaRetirar o building block exige limpeza manual do schema datastoreConsequência do modelo de push do projeto

16Perguntas frequentes

Posso usar o Data Store como banco principal do meu aplicativo?

Não é para isso. Não há join entre coleções, transação multi-documento nem agregação. Ele foi desenhado para o dado de borda — estado de workflow, tabela de apoio, configuração, registro gerado pelo app. Se o seu modelo precisa de relacionamento e integridade referencial, use um banco relacional; se precisa de agregação pesada, use uma ferramenta analítica.

Qual a diferença entre o Data Store e o File Storage?

O File Storage guarda bytes: PDF, imagem, áudio, planilha. O Data Store guarda documentos JSON estruturados e consultáveis pelo conteúdo. O desenho recomendado usa os dois juntos — o binário vai para o S3 pelo file-storage, e aqui fica o documento com os campos de negócio e o fileId que aponta para ele.

Como o Data Store garante que um cliente não vê o dado de outro?

O identificador da organização vem do token assinado e nunca do corpo da requisição. O contrato do driver não tem operação sem esse identificador, e ele é a primeira cláusula de toda consulta. Sobre isso, políticas de Row-Level Security no Postgres avaliam o mesmo valor dentro da transação que grava. §14 explica a mecânica e diz, sem rodeio, qual condição de infraestrutura ainda falta para a segunda barreira estar ativa.

Preciso definir um schema para começar?

Não. Sem jsonSchema, a coleção aceita qualquer JSON. O schema é opcional e por coleção — dá para começar schemaless e apertar depois, quando o formato estabilizar. O que ele valida é o subconjunto que resolve a maioria dos casos: tipo, campos obrigatórios e propriedades extras.

Dá para trocar o Postgres por outro banco?

A arquitetura está pronta para isso: a SPI de driver isola o building block do motor, e trocar é ligar outra implementação ao mesmo token de DI. Mas hoje só existe o driver Postgres JSONB. DynamoDB e Mongo estão previstos no contrato e não implementados (§15) — não os apresente como disponíveis.

O que acontece se dois processos escreverem no mesmo documento ao mesmo tempo?

Se ambos mandarem expectedVersion, o segundo recebe 409 e não sobrescreve nada. Se nenhum mandar, o último a escrever vence, em silêncio. Para escrita concorrente de verdade, sempre envie expectedVersion; para "garanta que existe", use ensure, que é idempotente por docKey.

Posso guardar CPF e outros dados pessoais aqui?

Pode, com cuidado. Marque o campo como "encrypted": true no schema da coleção e ele fica cifrado em repouso com AES-256-GCM, de forma transparente. Duas consequências: campo cifrado não é consultável, e a chave precisa estar no ambiente antes da primeira escrita. Combine com o Audit Trail para a trilha de acesso que a LGPD pede.

Qual o tamanho máximo de um documento?

512 KiB depois de serializado. Documento maior que isso normalmente é sinal de que o conteúdo deveria estar no File Storage, com o ponteiro guardado aqui.

Por que a consulta por um campo específico não usa índice dedicado?

Porque a tabela é uma só para todos os tenants, e criar índice por caminho de cada cliente traria de volta o DDL por tenant que o desenho evita. O índice GIN sobre data cobre consulta por conteúdo de forma genérica. Em coleção muito grande com filtro muito seletivo isso é um gargalo conhecido, e a saída planejada é particionar documents por tenant (§15).

O fast-path KV substitui o Redis?

Não. Cada operação KV é uma transação no Postgres, com durabilidade e rastro de autoria — o que é ótimo para configuração e péssimo para cache de caminho quente. Use o KV para dado que precisa sobreviver e ser auditável; use Redis para o que precisa ser rápido e pode sumir.


Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md · Design original: 2026-07-10-data-store-bb-design.md

Building blocks relacionados