Catalisa.Building Blocks
Catálogo/Dados/Data Store

Data Store

Produção

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

13
Endpoints
3
Entidades
0
Provedores
Tenant
Escopo
3030
Porta
2026-07
Desde

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
O que dá para fazer

15 endpoints em 4 recursos.

Explorar a API →
01

Resumo 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, Redis, IAM

02

O problema

negó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.

As quatro saídas de sempre, e onde cada uma quebra

flowchart TD
  P["Cliente pede um campo que só ele usa"]
  P --> A["Coluna nova na tabela do produto"]
  P --> B["Coluna metadata jsonb genérica"]
  P --> C["Banco de documentos contratado à parte"]
  P --> D["Um banco inteiro por cliente"]

  A --> A1["Migração em produção a cada campo<br/>O schema vira museu de colunas"]
  B --> B1["Depósito sem dono<br/>Ninguém sabe de qual cliente é cada chave"]
  C --> C1["Guarda o documento muito bem<br/>O isolamento volta para o seu código, em toda consulta"]
  D --> D1["A saída mais segura e a mais cara<br/>Custo, backup, migração e onboarding crescem por cliente"]

  A1 --> R["O que sobra: isolamento como disciplina de equipe"]
  B1 --> R
  C1 --> R
  D1 --> R

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.


03

Proposta de valor

negó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.

flowchart LR
  T["JWT assinado<br/>organizationId"] --> M["requireOrganization<br/>403 sem organização"]
  M --> S["DataStoreCtx<br/>tenantId obrigatório"]
  S --> Q["WHERE tenant_id<br/>primeira cláusula, sempre"]
  Q --> R["Política de RLS<br/>avaliada na transação"]
  B["Corpo da requisição"] -.->|"nunca é fonte do tenant"| S

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.


04

Casos de uso reais

negó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.

flowchart LR
  E1["captura"] --> E2["consulta de margem"] --> E3["motor de decisão"] --> E4["precificação"]
  E4 --> E5["assinatura"] --> E6["averbação"] --> E7["pagamento"]
  E7 --> F["proposta paga"]
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. É a trava otimista que faz a diferença quando dois workers pegam a mesma proposta:

sequenceDiagram
  participant A as Worker A
  participant B as Worker B
  participant DS as Data Store
  A->>DS: GET do documento da proposta
  DS-->>A: version 7, status precificacao
  B->>DS: GET do mesmo documento
  DS-->>B: version 7, status precificacao
  A->>DS: PUT expectedVersion 7, status assinatura
  DS-->>A: 200 — version passa a 8
  B->>DS: PUT expectedVersion 7, status averbacao
  DS-->>B: 409 — releia e reaplique sobre a version 8
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.

flowchart LR
  PD["Time de produto"] -->|"ACL de escrita restrita"| F["Coleção formularios<br/>perguntas, tipos, ordem"]
  U["Usuário final"] --> APP["App de cotação"]
  APP -->|"lê a definição da campanha"| F
  APP -->|"grava o preenchimento"| R["Coleção respostas<br/>cpf e telefone com encrypted true"]
  R --> DB[("Postgres JSONB<br/>enc v1 iv tag ciphertext")]
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.

flowchart LR
  W["PUT /kv/config/horario-disparo"] --> KV["Coleção reservada kv_config"]
  R["GET /kv/config/horario-disparo"] --> KV
  KV --> M["Escopo por tenant · RLS · createdBy e updatedBy · exclusão lógica"]
  KV -.->|"quando o valor precisa de forma"| SC["Coleção normal com schema-lite<br/>rejeita 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

No 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).

A solução com o BB

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.

flowchart LR
  T["organizationId — claim do token assinado pelo IAM"]
  T --> B1["1 · Contrato do driver<br/>não existe operação sem tenantId"]
  T --> B2["2 · Consulta SQL<br/>tenant_id é a primeira cláusula do WHERE"]
  T --> B3["3 · Política de RLS<br/>FORCE ROW LEVEL SECURITY nas três tabelas"]
  B1 --> V["Vazar exige errar em camadas independentes,<br/>por motivos diferentes"]
  B2 --> V
  B3 --> V
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.


05

Mercado e diferenciais

negó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.

flowchart TD
  N["Preciso guardar documento JSON por cliente"]
  N --> C1["Banco gerenciado de documentos<br/>MongoDB Atlas · DynamoDB"]
  N --> C2["Plataforma de backend<br/>Firestore · Supabase"]
  N --> C3["Um jsonb numa tabela que já existe"]
  N --> C4["Catalisa Data Store"]
  C1 --> R1["Motor maduro.<br/>Você decide como separar clientes"]
  C2 --> R2["API pronta.<br/>Você escreve as regras de segurança"]
  C3 --> R3["Custo zero hoje.<br/>Depósito sem dono em seis meses"]
  C4 --> R4["Recorte estreito.<br/>Já vem isolado por empresa cliente"]

Tabela comparativa

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 seu caso éEscolhaPor quê
O dado que você quer guardar é o banco principal do seu produtoPostgres direto ou MongoDB AtlasO Data Store não faz join entre coleções nem transação multi-documento, e forçar isso aqui é errado
O seu app é móvel e precisa de sincronização em tempo real e modo offlineCloud FirestoreO Firestore resolve isso hoje e nós não resolvemos
O volume é de dezenas de milhares de escritas por segundo com latência estávelAmazon DynamoDBO DynamoDB é o produto certo e a nossa tabela JSONB única não é
O seu time quer um Postgres completo, com API automática e controle total do schemaSupabaseO 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.


06

Modelo de cobrança e ROI

negó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.

flowchart LR
  D1["Volume de documentos armazenados"] --> Q["Quota por tenant<br/>maxDocs e maxBytes"]
  D2["Bytes armazenados"] --> Q
  D3["Chamadas de API"] --> Q
  Q --> C["Gancho onde a cobrança vai se apoiar<br/>precificação em definição"]

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".


07

Arquitetura

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.

flowchart TD
  HTTP["Requisição HTTP<br/>envelope tolerante a JSON API"]

  subgraph ROT["routes/ — Hono"]
    direction TB
    R1["authMiddleware"] --> R2["requirePermission DATASTORE_*"]
    R2 --> R3["requireOrganization"]
    R3 --> R4["parse Zod"]
    R4 --> R5["handleResult"]
    R6["tenantId = user.organizationId<br/>NUNCA vem do corpo da requisição"]
  end

  subgraph SRV["services/DataStoreService — neverthrow"]
    direction TB
    S1["resolve coleção"] --> S2["ACL da coleção"]
    S2 --> S3["validação schema-lite"]
    S3 --> S4["quota do tenant"]
    S4 --> S5["cripto de campo"]
    S5 --> S6["publica eventos data-store.*"]
    S6 --> S7["delega a persistência ao driver<br/>não conhece motor nenhum"]
  end

  subgraph SPI["DataStoreDriver — a SPI, a abstração"]
    direction TB
    D1["insert · get · update · delete · query"]
    D2["upsertByKey · getByKey · ensureByKey · deleteByKey"]
    D3["createCollection · findCollection · listCollections · dropCollection"]
    D4["usage · countDocuments · capabilities"]
    D5["TODA operação recebe tenantId<br/>e é OBRIGADA a escopar por ele"]
    D1 --- D2 --- D3 --- D4 --- D5
  end

  PGD["PostgresJsonbDriver<br/>driver padrão"]
  DYD["DynamoDbDriver<br/>não implementado, ver §15"]
  MGD["MongoDriver<br/>não implementado, ver §15"]

  DB[("Postgres · schema datastore<br/>documents · collections · quotas<br/>uma tabela JSONB com índice GIN<br/>ZERO DDL por tenant")]
  RLS["Row-Level Security sobre app.tenant_id<br/>defesa em profundidade"]

  HTTP --> R1
  R5 -->|"ResultAsync de T ou AppError"| S1
  S7 -->|"DataStoreCtx com tenantId e userId"| D1
  D5 -->|"padrão"| PGD
  D5 -.->|"não implementado"| DYD
  D5 -.->|"não implementado"| MGD
  PGD --> DB
  DB --- RLS

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.


08

Conceitos 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
erDiagram
  DataStoreQuota ||--o{ DataCollection : "mesmo tenant_id"
  DataCollection ||--o{ DataDocument : "agrupa por nome"

  DataStoreQuota {
    uuid tenantId PK
    int maxDocs "padrão 100000"
    bigint maxBytes "padrão 1 GiB"
    int usedDocs
    bigint usedBytes "não contabilizado hoje"
  }
  DataCollection {
    uuid id PK
    uuid tenantId "único com name"
    string name
    jsonb jsonSchema "schema-lite opcional"
    bool pii
    jsonb acl "read e write por userId"
    timestamp deletedAt "exclusão lógica"
  }
  DataDocument {
    uuid id PK
    uuid tenantId "único com collection e docKey"
    string collection
    string docKey "chave natural opcional"
    jsonb data "índice GIN jsonb_path_ops"
    int version "concorrência otimista"
    uuid createdBy
    uuid updatedBy
    timestamp deletedAt "exclusão lógica"
  }

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

Ciclo de vida de um documento

stateDiagram-v2
  direction LR
  [*] --> V1
  V1: version 1
  V2: version 2
  VN: version N
  DEL: excluído logicamente

  V1 --> V2: PUT com expectedVersion correto
  V2 --> VN: PUT com expectedVersion correto
  V1 --> V1: PUT com expectedVersion desatualizado, 409 CONFLICT e nada é gravado
  V2 --> V2: PUT com expectedVersion desatualizado, 409 CONFLICT e nada é gravado
  VN --> DEL: DELETE preenche deleted_at e preserva a linha
  DEL --> VN: ensure ressuscita sem sobrescrever o conteúdo

Atenção. 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

flowchart TD
  J["JWT assinado<br/>claim organizationId"]
  M["middleware<br/>requireOrganization<br/>403 se ausente"]
  S["serviço<br/>DataStoreCtx com tenantId"]
  D["driver<br/>coluna tenant_id em toda consulta"]
  G["set_config('app.tenant_id', …, true)<br/>local à transação"]
  P["política de RLS<br/>tenant_id = current_setting(…)"]

  J --> M --> S --> D --> G --> P
  J -.->|"mesmo valor em todas as camadas"| P

09

Referê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

json
{
  "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"] }
}
{
  "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

json
{
  "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"
  }
}
{
  "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

json
{
  "data": { "parceiro": "banco-exemplo", "status": "captura", "valor": 12000, "cpf": "12345678900" },
  "docKey": "PROP-2026-000123",
  "createIfNotExists": true
}
{
  "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

json
{
  "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"
  }
}
{
  "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.

json
{
  "data": { "parceiro": "banco-exemplo", "status": "averbacao", "valor": 12000 },
  "expectedVersion": 1
}
{
  "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.

jsonc
{
  "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
}
{
  "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 — e os detalhes de cada um que mudam o resultado:

OperadorO que fazDetalhe que muda o resultado
eqIgualCom null, vira IS NULL
neDiferenteCom null, vira IS NOT NULL; caso contrário compara com IS DISTINCT FROM
gt gte lt lteComparaçõesValor numérico converte o caminho para ::numeric; valor de texto compara como texto
in ninPertence, ou não pertence, a uma listaExige array no value; sem array devolve 400
existsO caminho existe no documento"value": false inverte e casa o caminho ausente
prefixComeça comExige string, e escapa %, _ e \ antes de montar o LIKE
containsContenção JSONB (@>)Para array, o valor precisa ser um array, não um elemento solto

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

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

json
{
  "data": [ { "type": "documents", "id": "…", "version": 3, "attributes": { "…": {} } } ],
  "meta": { "totalItems": 1, "totalPages": 1, "currentPage": 1, "itemsPerPage": 50,
            "hasNextPage": false, "hasPreviousPage": false },
  "links": { "self": "/api/v1/collections/propostas/documents/query?page[number]=1&page[size]=50",
             "first": "…", "last": "…" }
}
{
  "data": [ { "type": "documents", "id": "…", "version": 3, "attributes": { "…": {} } } ],
  "meta": { "totalItems": 1, "totalPages": 1, "currentPage": 1, "itemsPerPage": 50,
            "hasNextPage": false, "hasPreviousPage": false },
  "links": { "self": "/api/v1/collections/propostas/documents/query?page[number]=1&page[size]=50",
             "first": "…", "last": "…" }
}

Atenção. links.prev e links.next só aparecem quando existe página anterior ou seguinte — na primeira e única página eles vêm ausentes, não null.

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".

json
{ "docKey": "config-parceiro-42", "defaultData": { "canal": "whatsapp", "tentativas": 3 }, "createIfNotExists": true }
{ "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.


10

Iní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.

flowchart LR
  P1["1 · Autenticar no IAM"] --> P2["2 · Criar a coleção"]
  P2 --> P3["3 · Gravar um documento"]
  P3 --> P4["4 · Consultar pelo conteúdo"]
  P4 --> P5["5 · Ver o consumo contra a quota"]
  P5 --> P6["6 · Confirmar que o isolamento não depende de você"]

1. Autenticar no IAM

bash
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}..."
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}..."

Resposta esperada — o começo de um JWT, prova de que o token saiu:

text
eyJhbGciOiJIUzI1NiIsInR5...
eyJhbGciOiJIUzI1NiIsInR5...

2. Criar a coleção

bash
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'
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'

Resposta esperada:

json
{ "type": "collections", "id": "…", "name": "propostas", "pii": false }
{ "type": "collections", "id": "…", "name": "propostas", "pii": false }

3. Gravar um documento

bash
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
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

Resposta esperada — version em 1, porque o documento acabou de nascer:

text
1
1

4. Consultar pelo conteúdo

bash
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'
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'

Resposta esperada — a paginação e o primeiro documento que casou com o filtro:

json
{ "totalItems": 1, "totalPages": 1, "currentPage": 1, "itemsPerPage": 10, "hasNextPage": false, "hasPreviousPage": false }
{ "parceiro": "banco-exemplo", "status": "captura", "valor": 12000 }
{ "totalItems": 1, "totalPages": 1, "currentPage": 1, "itemsPerPage": 10, "hasNextPage": false, "hasPreviousPage": false }
{ "parceiro": "banco-exemplo", "status": "captura", "valor": 12000 }

5. Ver o consumo contra a quota

bash
curl -s https://data-store.bb.stg.catalisa.app/data-store/api/v1/usage \
  -H "Authorization: Bearer $TOKEN" | jq '.data'
curl -s https://data-store.bb.stg.catalisa.app/data-store/api/v1/usage \
  -H "Authorization: Bearer $TOKEN" | jq '.data'

Resposta esperada — usedBytes fica em zero porque a contabilização de bytes ainda não está implementada (§15):

json
{ "usedDocs": 1, "maxDocs": 100000, "usedBytes": 0, "maxBytes": 1073741824 }
{ "usedDocs": 1, "maxDocs": 100000, "usedBytes": 0, "maxBytes": 1073741824 }

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

bash
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"
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.


11

Receitas

Guardar estado de workflow com trava otimista

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

flowchart LR
  L["1 · GET do documento<br/>guarda a version"] --> E["2 · PUT com expectedVersion"]
  E -->|"versão bate"| OK["200 — version sobe"]
  E -->|"versão não bate"| KO["409 — outro worker escreveu antes"]
  KO -.->|"releia e reaplique"| L

1. Ler o estado atual e guardar a versão

bash
CUR=$(curl -s "$BASE/data-store/api/v1/collections/propostas/documents/$DOC_ID" \
  -H "Authorization: Bearer $TOKEN")
VER=$(echo "$CUR" | jq -r '.data.version')
echo "$VER"
CUR=$(curl -s "$BASE/data-store/api/v1/collections/propostas/documents/$DOC_ID" \
  -H "Authorization: Bearer $TOKEN")
VER=$(echo "$CUR" | jq -r '.data.version')
echo "$VER"

Resposta esperada — a versão gravada hoje:

text
1
1

2. Escrever a próxima etapa exigindo essa versão

bash
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}" | jq '.data.version, .data.attributes.status'
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}" | jq '.data.version, .data.attributes.status'

Resposta esperada — a versão incrementada e o novo estado:

text
2
"averbacao"
2
"averbacao"

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.

sequenceDiagram
  participant App as Sua aplicação
  participant DS as Data Store
  participant PG as Postgres
  App->>DS: POST documento com cpf em claro
  DS->>DS: campo marcado encrypted no schema da coleção
  DS->>PG: grava enc v1 iv tag ciphertext, AES-256-GCM
  App->>DS: GET do documento
  DS->>PG: lê o envelope cifrado
  DS-->>App: cpf em claro na resposta

1. A coleção declara quais campos são cifrados

bash
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}}}}' | jq '.data.name, .data.pii'
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}}}}' | jq '.data.name, .data.pii'

Resposta esperada:

text
"pessoas"
true
"pessoas"
true

2. Gravar normalmente — o serviço cifra sozinho

bash
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"}}' | jq '.data.attributes'
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"}}' | jq '.data.attributes'

Resposta esperada — o CPF volta em claro na API, e é o envelope enc:v1:… que está no banco:

json
{ "nome": "Maria", "cpf": "12345678900" }
{ "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.

A ACL é a última porta, não a primeira — ela só é consultada depois que o RBAC já deixou passar:

flowchart TD
  T["Requisição com token"] --> A["authMiddleware<br/>token válido?"]
  A -->|"não"| E401["401"]
  A -->|"sim"| P["requirePermission DATASTORE_READ"]
  P -->|"não tem"| E403a["403 do middleware"]
  P -->|"tem"| O["requireOrganization"]
  O -->|"sem organizationId"| E403b["403"]
  O -->|"com organizationId"| ACL["ACL de leitura da coleção<br/>inclui este userId ou '*'?"]
  ACL -->|"não"| E403c["403 da ACL"]
  ACL -->|"sim"| OK["documento entregue"]
bash
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>"]}}' | jq '.data.name'
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>"]}}' | jq '.data.name'

Resposta esperada — repare que o acl não volta na resposta, por ser metadado de controle:

text
"scores"
"scores"

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.

1. Gravar o valor

bash
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"}}' | jq '.data.key'
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"}}' | jq '.data.key'

Resposta esperada — a coleção de apoio kv_config é criada nesta primeira gravação:

text
"horario-disparo"
"horario-disparo"

2. Ler o valor de volta

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

Resposta esperada:

json
{ "inicio": "09:00", "fim": "18:00", "fuso": "America/Sao_Paulo" }
{ "inicio": "09:00", "fim": "18:00", "fuso": "America/Sao_Paulo" }

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.

bash
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}' | jq '.meta'
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}' | jq '.meta'

Resposta esperada — os três filtros são combinados com AND, e o meta diz quantos documentos casaram:

json
{ "totalItems": 12, "totalPages": 1, "currentPage": 1, "itemsPerPage": 100, "hasNextPage": false, "hasPreviousPage": false }
{ "totalItems": 12, "totalPages": 1, "currentPage": 1, "itemsPerPage": 100, "hasNextPage": false, "hasPreviousPage": false }

Como cada parte da consulta é resolvida:

flowchart LR
  W["where"] --> N["cliente.endereco.uf<br/>caminho aninhado, operador in"]
  W --> C["tags<br/>operador contains, contenção JSONB"]
  W --> U["$updatedAt<br/>caminho meta, vira coluna updated_at"]
  N --> AND["combinados com AND"]
  C --> AND
  U --> AND
  AND --> SQL["SELECT dos ids ordenados<br/>valores ligados como parâmetro"]
  SQL --> H["hidratação das linhas pelo Prisma"]

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.

12

Integraçã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.

O desenho da cadeia

flowchart TD
  IAM["IAM<br/>token com organizationId"]
  APP["App do cliente"]
  DS["Data Store<br/>JSONB"]
  FS["File Storage<br/>S3"]
  AT["Audit Trail<br/>trilha de uso"]
  WH["Webhooks Engine<br/>entrega externa"]

  IAM -->|"Bearer JWT"| APP
  IAM -->|"Bearer JWT"| DS
  IAM -->|"Bearer JWT"| FS
  APP -->|"documento JSON"| DS
  APP -->|"bytes do arquivo"| FS
  FS -->|"id do arquivo guardado no documento"| DS
  DS -->|"eventos data-store.*"| AT
  DS -->|"eventos data-store.*"| WH

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.


13

Configuração e operação

Variáveis de ambiente

VariávelDescriçãoObrigatóriaPadrão
DATABASE_URLPostgreSQL. O schema datastore vive neleSim—
REDIS_URLRedis. Usado só pelos contadores do rate limit globalSim—
JWT_SECRETSegredo compartilhado para verificar o token do IAM (mínimo 44 caracteres)Sim—
RATE_LIMIT_ENABLEDLiga o rate limit global por IPNãotrue
RATE_LIMIT_GLOBAL_MAXRequisições por janela, por IPNão10000
RATE_LIMIT_GLOBAL_WINDOWTamanho da janela, em milissegundosNão60000
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
RedisContadores do rate limit global aplicado a todas as rotas. Falha aberta: se o Redis estiver fora, a requisição passa e um aviso vai para o log
IAMVerificação do token e resolução de permissões

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

Limites e quotas

LimiteValorOnde é aplicado
Tamanho do corpo da requisição1 MiBapplyCommonMiddleware, antes de qualquer rota
Rate limit global10.000 requisições por IP a cada 60 segundosrateLimitMiddleware, 429 acima disso
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
403—Token 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
429too_many_requestsRate limit global por IP estouradoAplique recuo exponencial; o cabeçalho Retry-After diz quanto esperar
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.

14

Seguranç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ção — authMiddleware verifica a assinatura do token.
  2. Autorização RBAC — requirePermission(DATASTORE_READ | DATASTORE_WRITE | DATASTORE_ADMIN).
  3. Contexto obrigatório — requireOrganization 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.

As seis barreiras, na ordem em que a requisição as atravessa:

flowchart TD
  REQ["Requisição"] --> B1["1 · authMiddleware<br/>assinatura do token"]
  B1 --> B2["2 · requirePermission<br/>DATASTORE_READ, WRITE ou ADMIN"]
  B2 --> B3["3 · requireOrganization<br/>403 sem organizationId"]
  B3 --> B4["4 · ACL da coleção<br/>sobre o RBAC, nunca no lugar dele"]
  B4 --> B5["5 · Escopo estrutural no driver<br/>tenant_id é a primeira cláusula do WHERE"]
  B5 --> B6["6 · Row-Level Security no Postgres<br/>avaliada dentro da transação"]
  B6 --> OK["Linha entregue ou gravada"]

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:

sql
SELECT set_config('app.tenant_id', '<organizationId do token>', true);
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.

O caminho completo de uma operação, do token ao commit:

sequenceDiagram
  participant R as routes
  participant S as DataStoreService
  participant D as PostgresJsonbDriver
  participant PG as Postgres

  R->>S: operação com tenantId vindo do claim organizationId
  S->>D: DataStoreCtx com tenantId e userId
  D->>PG: BEGIN
  D->>PG: SELECT set_config app.tenant_id, valor do token, local à transação
  D->>PG: SELECT ou INSERT com tenant_id como primeira cláusula
  PG->>PG: política tenant_isolation avalia current_setting app.tenant_id
  Note over PG: USING filtra o que a sessão enxerga<br/>WITH CHECK impede gravar linha de outro tenant
  PG-->>D: apenas as linhas do tenant
  D->>PG: COMMIT
  Note over PG: o app.tenant_id desaparece com a transação,<br/>não vaza para a próxima requisição do pool
  D-->>S: ResultAsync
  S-->>R: resposta

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

sql
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);
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.

flowchart LR
  A["Papel de banco atual: postgres, superusuário"] -->|"ignora as políticas"| B["RLS instalada, porém inerte"]
  B --> C["Barreira efetiva hoje:<br/>filtro de tenant_id do driver, em toda operação"]
  D["Papel de banco não-superusuário<br/>tarefa de infraestrutura, §15"] -.->|"única mudança que falta"| E["RLS ativa como segunda barreira"]

Atençã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).

Exigência da LGPDO que fazer no Data StoreSituação
Inventário de dado pessoalMarcar a coleção com pii: trueDisponível
Proteção em repousoMarcar os campos identificadores com "encrypted": trueDisponível, só primeiro nível
Trilha de acessoLigar o Audit Trail sobre os eventos data-store.*Disponível
Direito de eliminaçãoExpurgo definitivo da linhaManual hoje, ver §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.


15

Limitaçõ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

16

Perguntas 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