Data Store
ProduçãoBanco de documentos JSON multi-tenant sem criar tabela para cada cliente
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.
- 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
- 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
- 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
15 endpoints em 4 recursos.
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.
| Atributo | Valor |
|---|---|
| Identificador | data-store |
| Categoria | Dados |
| Escopo | Tenant (exige organizationId no token) |
| Porta (standalone) | 3030 |
| Path alias | @data-store |
| Prefixo HTTP | /data-store |
| Schema no banco | datastore |
| Status | Produção desde 2026-07 |
| Depende de | PostgreSQL, Redis, IAM |
O problema
negócioO 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 jsonbnuma 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.
Proposta de valor
negócio| Antes | Depois |
|---|---|
| Campo novo de cliente exige migração e janela de deploy | Documento JSON gravado por API, sem DDL |
Isolamento entre clientes depende de lembrar do WHERE | Tenant vem do token e é aplicado no driver e na política do banco |
| Banco de documentos separado, com contrato e operação próprios | Reusa o Postgres que já roda, em schema dedicado |
| Dado sensível fica em claro porque cifrar dá trabalho | Campo marcado encrypted: true no schema da coleção, cifrado sem código do integrador |
| Cliente abusivo derruba a base de todo mundo | Quota 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.
Casos de uso reais
negócioCaso 1 — Uma esteira de crédito guarda o estado de cada proposta sem inventar tabela Cenário ilustrativo
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"]
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.
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
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
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 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.
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")]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
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 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.
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"]
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
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).
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).
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 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.
Mercado e diferenciais
negócioPanorama
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ério | Catalisa Data Store | MongoDB Atlas | Cloud Firestore | Supabase | Amazon DynamoDB |
|---|---|---|---|---|---|
| Isolamento por tenant | Pronto: token → driver → política do banco | Você escolhe e implementa o padrão | Você escreve as Security Rules | Você escreve as políticas de RLS | Convenção na chave de partição |
| DDL ao entrar um cliente novo | Nenhum | Nenhum, se usar discriminador | Nenhum | Depende do seu schema | Nenhum |
| Consulta por conteúdo | Operadores portáveis sobre JSONB | Linguagem de consulta completa | Consulta indexada, com limites | SQL completo | Só por chave ou índice secundário |
| Agregação e join | Não | Sim, pipeline de agregação | Limitada | Sim, SQL | Não |
| Criptografia de campo | Marcada no schema da coleção | Queryable Encryption nos planos superiores | Você implementa | Você implementa | Client-side encryption via SDK |
| Tempo real para app móvel | Não | Change streams | Sim, é a força do produto | Sim, Realtime | Streams |
| Operação por sua conta | Já vem operado com o catálogo | Não | Não | Não | Não |
| Modelo de preço | Em definição, por documento | Por hora de cluster | Por operação | Por projeto e por GB | Por 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
- O isolamento é estrutural, não convencional. O tipo
DataStoreCtxnão tem operação semtenantId, 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. - A segunda barreira está no banco, na mesma transação. Cada operação abre transação, define
app.tenant_idcomset_confige 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. - 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.
- 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 é | Escolha | Por quê |
|---|---|---|
| O dado que você quer guardar é o banco principal do seu produto | 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 |
| O seu app é móvel e precisa de sincronização em tempo real e modo offline | Cloud Firestore | O Firestore resolve isso hoje e nós não resolvemos |
| O volume é de dezenas de milhares de escritas por segundo com latência estável | Amazon DynamoDB | O 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 schema | Supabase | 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.
Modelo de cobrança e ROI
negócioUnidade 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.
| Produto | Como cobra | Ordem de grandeza no cenário | Fonte e data |
|---|---|---|---|
| MongoDB Atlas | Cluster 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ência | mongodb.com/pricing, consultado em 2026-08-16 |
| MongoDB Atlas Flex | Tier compartilhado a partir de US$ 0,011/h, teto de US$ 30/mês, 5 GB | US$ 8 a US$ 30/mês, mas o teto de 5 GB não cobre o cenário | mongodb.com/pricing, consultado em 2026-08-16 |
| Amazon DynamoDB | US$ 0,625 por milhão de escritas, US$ 0,125 por milhão de leituras, US$ 0,25 por GB/mês | Armazenamento de 10 GB ≈ US$ 2,50/mês; as 200 mil chamadas custam centavos | aws.amazon.com/dynamodb/pricing/on-demand, consultado em 2026-08-16 |
| Supabase Pro | US$ 25/mês com 8 GB de disco, depois US$ 0,125 por GB | ~US$ 25 + ~US$ 0,25 de disco excedente | supabase.com/pricing, consultado em 2026-08-16 |
| Cloud Firestore | Por 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ê tudo | firebase.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".
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 --- RLSTrocar 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 colunatenant_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 aoset_config('app.tenant_id', …, true), e o terceiro argumentotruesignifica "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 osids 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 com400em 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 detenant_idcontinua 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.
Conceitos e modelo de dados
Glossário
| Termo | Significa |
|---|---|
| Coleção | Agrupamento nomeado de documentos, análogo a uma tabela. Única por (tenant, nome). Nome casa ^[a-z][a-z0-9_-]{0,62}$. |
| Documento | Um objeto JSON gravado numa coleção. Máximo de 512 KiB por documento. |
docKey | Chave natural opcional do documento, única dentro de (tenant, coleção). É o que permite gravar por identificador de negócio em vez de por UUID. |
version | Contador de concorrência otimista. Sobe a cada escrita. Enviar expectedVersion diferente do gravado devolve 409. |
| Schema-lite | Subconjunto de JSON Schema validado na escrita: type, required, properties.type, additionalProperties. Sem ajv, sem schema aninhado. |
| ACL de coleção | Lista 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 cifrado | Propriedade marcada "encrypted": true no schema. Guardada como envelope enc:v1:iv:tag:ciphertext e decifrada na leitura. |
| Fast-path KV | Açúcar sintático sobre uma coleção reservada kv_<namespace>, endereçada por chave natural. |
| Driver | Implementação da SPI que fala com o motor. O padrão é PostgresJsonbDriver. |
| Quota | Limite 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 Prisma | Tabela | Propósito | Campos-chave |
|---|---|---|---|
DataCollection | datastore.collections | Metadado da coleção | name, jsonSchema (JSONB), pii, acl (JSONB). Único (tenantId, name) |
DataDocument | datastore.documents | Os documentos | data (JSONB), version, docKey. Único (tenantId, collection, docKey). Índice GIN com jsonb_path_ops em data |
DataStoreQuota | datastore.quotas | Limites por tenant | maxDocs (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"| PReferê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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /data-store/api/v1/collections | Cria coleção | DATASTORE_ADMIN |
GET | /data-store/api/v1/collections | Lista coleções do tenant | DATASTORE_READ |
GET | /data-store/api/v1/collections/:name | Metadado de uma coleção | DATASTORE_READ |
DELETE | /data-store/api/v1/collections/:name | Exclusão lógica da coleção e dos documentos dela | DATASTORE_ADMIN |
Documentos — /data-store/api/v1/collections/:name/documents
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /data-store/api/v1/collections/:name/documents | Insere documento | DATASTORE_WRITE |
POST | /data-store/api/v1/collections/:name/documents/ensure | Get-or-create por docKey, idempotente | DATASTORE_WRITE |
GET | /data-store/api/v1/collections/:name/documents/:id | Lê documento por UUID | DATASTORE_READ |
PUT | /data-store/api/v1/collections/:name/documents/:id | Substitui documento, com concorrência otimista | DATASTORE_WRITE |
DELETE | /data-store/api/v1/collections/:name/documents/:id | Exclusão lógica | DATASTORE_WRITE |
POST | /data-store/api/v1/collections/:name/documents/query | Filtra, ordena e pagina por conteúdo | DATASTORE_READ |
Fast-path KV — /data-store/api/v1/kv
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
PUT | /data-store/api/v1/kv/:namespace/:key | Grava valor, cria a coleção de apoio se preciso | DATASTORE_WRITE |
GET | /data-store/api/v1/kv/:namespace/:key | Lê valor | DATASTORE_READ |
DELETE | /data-store/api/v1/kv/:namespace/:key | Exclusão lógica do valor | DATASTORE_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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /data-store/api/v1/usage | Documentos e bytes usados contra a quota do tenant | DATASTORE_READ |
GET | /data-store/health | Sonda de saúde do serviço | Pú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"] }
}{
"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"] }
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
name | string | Sim | Casa ^[a-z][a-z0-9_-]{0,62}$. Único por tenant |
jsonSchema | object | Não | Schema-lite. Sem ele, a coleção aceita qualquer JSON |
pii | boolean | Não | Marca a coleção como portadora de dado pessoal. Padrão false |
acl | { read?: string[], write?: string[] } | Não | Até 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"
}
}{
"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
| Status | Quando |
|---|---|
400 | Nome fora do padrão, ou corpo reprovado no Zod |
403 | Token sem organizationId, ou sem DATASTORE_ADMIN |
409 | Já 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
}{
"data": { "parceiro": "banco-exemplo", "status": "captura", "valor": 12000, "cpf": "12345678900" },
"docKey": "PROP-2026-000123",
"createIfNotExists": true
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
data | object | Sim | O documento. Máximo de 512 KiB serializado |
docKey | string (1–255) | Não | Chave natural. Único por (tenant, coleção) |
createIfNotExists | boolean | Não | Cria 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"
}
}{
"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
| Status | Quando |
|---|---|
400 | Documento acima de 512 KiB, ou reprovado no schema-lite da coleção |
403 | ACL de escrita da coleção não inclui o chamador |
404 | Coleção não existe e createIfNotExists não foi enviado |
409 | docKey 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
}{
"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
| Status | Quando |
|---|---|
404 | Documento inexistente ou já excluído |
409 | Version 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
}{
"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
}| Campo | Tipo | Padrão | Limite |
|---|---|---|---|
where | mapa de caminho → { op, value } | vazio | profundidade de caminho até 8 segmentos, cada um casando ^[A-Za-z0-9_]+$ |
sort | lista de { path, dir } | created_at DESC | até 4 cláusulas, caminho até 200 caracteres |
page | inteiro ≥ 1 | 1 | — |
pageSize | inteiro | 50 | máximo 200 |
Operadores — e os detalhes de cada um que mudam o resultado:
| Operador | O que faz | Detalhe que muda o resultado |
|---|---|---|
eq | Igual | Com null, vira IS NULL |
ne | Diferente | Com null, vira IS NOT NULL; caso contrário compara com IS DISTINCT FROM |
gt gte lt lte | Comparações | Valor numérico converte o caminho para ::numeric; valor de texto compara como texto |
in nin | Pertence, ou não pertence, a uma lista | Exige array no value; sem array devolve 400 |
exists | O caminho existe no documento | "value": false inverte e casa o caminho ausente |
prefix | Começa com | Exige string, e escapa %, _ e \ antes de montar o LIKE |
contains | Contençã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.
{
"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
| Status | Quando |
|---|---|
400 | Operador desconhecido, caminho inválido, in/nin sem array, ou filtro/ordenação sobre campo cifrado |
403 | ACL de leitura da coleção não inclui o chamador |
404 | Coleçã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 }{ "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.
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
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:
eyJhbGciOiJIUzI1NiIsInR5...eyJhbGciOiJIUzI1NiIsInR5...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'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:
{ "type": "collections", "id": "…", "name": "propostas", "pii": false }{ "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' # 1DOC=$(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' # 1Resposta esperada — version em 1, porque o documento acabou de nascer:
114. 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'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:
{ "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
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):
{ "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ê
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.
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
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:
112. 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}" | 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:
2
"averbacao"2
"averbacao"Armadilhas.
- O
PUTsubstitui o documento inteiro. Se você mandar só o campo que mudou, os outros somem. Leia, altere o objeto em memória, mande completo. 409nã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
expectedVersionem escrita idempotente de reprocessamento; ali o certo éensurepordocKey.
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
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:
"pessoas"
true"pessoas"
true2. 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"}}' | 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:
{ "nome": "Maria", "cpf": "12345678900" }{ "nome": "Maria", "cpf": "12345678900" }Armadilhas.
- Campo cifrado não é consultável. Filtrar ou ordenar por
cpfdevolve400, 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.cpfaninhado não é. - A chave
DATASTORE_CREDENTIAL_MASTER_KEYprecisa existir no ambiente antes da primeira escrita cifrada. Sem ela, a escrita falha com500; 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"]
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:
"scores""scores"Armadilhas.
- A ACL é aplicada sobre o RBAC, nunca no lugar dele. Quem não tem
DATASTORE_READcontinua barrado antes, com403do middleware. - Lado não preenchido fica aberto a quem tem a permissão base.
{"read":[...]}semwritesignifica 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
userIdsentinela00000000-0000-0000-0000-000000000000. Se a ACL não incluir esse identificador nem"*", o outro building block toma403.
Usar como armazenamento chave-valor
Objetivo. Guardar configuração por cliente sem criar coleção nem schema.
1. Gravar o valor
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:
"horario-disparo""horario-disparo"2. Ler o valor de volta
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:
{ "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
PUTcria a coleçãokv_configna primeira gravação, sem schema e sem ACL. Se você precisa de ACL nesse dado, crie a coleção antes, com o nomekv_<namespace>completo. - O valor é embrulhado como
{ "value": … }no documento. Se você consultar a coleçãokv_configpela 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}' | 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:
{ "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. containscompara 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. pageSizeacima de 200 não é erro, é silenciosamente reduzido a 200 pelo driver.
Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token que carrega organizationId e as permissões DATASTORE_*. É de onde vem o tenant | Sim |
| File Storage | O irmão: guarda os bytes; aqui ficam os metadados e o dado estruturado que aponta para eles | Não |
| Audit Trail | Registra quem leu e quem escreveu, quando a operação exige trilha de compliance | Não |
| Webhooks Engine | Entrega os eventos data-store.* a sistemas externos do cliente | Não |
| API Keys | Credencial de longa duração para integração programática que grava documentos | Não |
| Qualquer BB, via facade | DataStoreFacadeToken expõe insert, get e delete sem dependência estática do módulo | Nã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.
Configuração e operação
Variáveis de ambiente
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
DATABASE_URL | PostgreSQL. O schema datastore vive nele | Sim | — |
REDIS_URL | Redis. Usado só pelos contadores do rate limit global | Sim | — |
JWT_SECRET | Segredo compartilhado para verificar o token do IAM (mínimo 44 caracteres) | Sim | — |
RATE_LIMIT_ENABLED | Liga o rate limit global por IP | Não | true |
RATE_LIMIT_GLOBAL_MAX | Requisições por janela, por IP | Não | 10000 |
RATE_LIMIT_GLOBAL_WINDOW | Tamanho da janela, em milissegundos | Não | 60000 |
DATASTORE_CREDENTIAL_MASTER_KEY | Chave de 64 caracteres hexadecimais (32 bytes) para criptografia de campo. Só é exigida quando há campo marcado encrypted | Não | — |
DATASTORE_RLS_ENABLED | Liga a instalação das políticas de RLS | Não | true |
MODULE_DATA_STORE_URL | URL do serviço, usada pelo facade remoto em modo standalone | Em standalone | — |
PORT | Porta no modo standalone | Não | 3030 |
DEPLOYMENT_MODE | monolith ou standalone | Não | standalone no entrypoint próprio |
DATASTORE_DEFAULT_MAX_DOCSeDATASTORE_DEFAULT_MAX_BYTESexistem 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ência | Para quê |
|---|---|
| PostgreSQL | Schema datastore: coleções, documentos e quotas. Índice GIN em data |
| Redis | Contadores 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 |
| IAM | Verificação do token e resolução de permissões |
Não usa S3 e não chama nenhum provedor externo.
Limites e quotas
| Limite | Valor | Onde é aplicado |
|---|---|---|
| Tamanho do corpo da requisição | 1 MiB | applyCommonMiddleware, antes de qualquer rota |
| Rate limit global | 10.000 requisições por IP a cada 60 segundos | rateLimitMiddleware, 429 acima disso |
| Tamanho de um documento | 512 KiB serializado | Schema Zod da rota, 400 acima disso |
| Documentos por tenant | 100.000 (padrão) | Verificado antes do insert, 409 com campo quota |
| Bytes por tenant | 1 GiB (padrão) | Registrado no modelo, contabilização não implementada (§15) |
| Itens por página na consulta | 200 | Reduzido em silêncio pelo driver |
| Cláusulas de ordenação | 4 | 400 acima disso |
| Profundidade de caminho JSON | 8 segmentos | 400 acima disso |
userId por lado da ACL | 64 | 400 acima disso |
| Nome de coleção | 63 caracteres, ^[a-z][a-z0-9_-]{0,62}$ | 400 fora do padrão |
Catálogo de erros
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | VALIDATION | Corpo reprovado no Zod ou no schema-lite da coleção | Confira tipos e campos obrigatórios contra o schema da coleção |
400 | BAD_REQUEST | Operador desconhecido, caminho JSON inválido, in sem array | Confira a consulta contra §9 |
400 | VALIDATION | Filtro ou ordenação sobre campo cifrado | Guarde um hash em campo separado e consulte por ele |
401 | UNAUTHORIZED | Token ausente, inválido ou expirado | Renove o token no IAM |
403 | — | Token sem organizationId | Autentique informando a organização |
403 | FORBIDDEN | Permissão DATASTORE_* ausente, ou ACL da coleção barrando | Confira o token; depois confira a ACL da coleção |
404 | NOT_FOUND | Coleção, documento ou chave inexistente (ou excluído logicamente) | Confira o nome e o id; lembre que exclusão é lógica |
409 | CONFLICT (version) | expectedVersion diferente do gravado | Releia o documento e reaplique a mudança |
409 | CONFLICT (docKey) | docKey já usado no tenant | Use ensure se a intenção era get-or-create |
409 | CONFLICT (name) | Coleção já existe | Escolha outro nome |
409 | CONFLICT (quota) | Quota de documentos estourada | Libere documentos ou ajuste a quota do tenant |
429 | too_many_requests | Rate limit global por IP estourado | Aplique recuo exponencial; o cabeçalho Retry-After diz quanto esperar |
500 | INTERNAL | Falha de banco, ou chave de criptografia ausente na escrita de campo marcado | Verifique conectividade e DATASTORE_CREDENTIAL_MASTER_KEY |
Observabilidade
GET /data-store/healthdevolve 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
infocom a lista de tabelas; falha sai emwarncom o erro e o serviço continua. Se você não vê a linha deinfono boot, as políticas não foram aplicadas — vale investigar. - Todos os eventos
data-store.*carregamuserIdeorganizationId, o que permite reconstruir quem mexeu em quê pelo Audit Trail.
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:
- Autenticação —
authMiddlewareverifica a assinatura do token. - Autorização RBAC —
requirePermission(DATASTORE_READ | DATASTORE_WRITE | DATASTORE_ADMIN). - Contexto obrigatório —
requireOrganizationdevolve403para token semorganizationId. - ACL por coleção — lista de
userIdautorizados 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:
- Escopo estrutural no driver. O tipo
DataStoreCtxexigetenantId, e não existe operação no contrato sem ele. Na consulta,tenant_idé a primeira cláusula doWHERE, sempre, antes de qualquer filtro que o cliente tenha pedido. - 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:
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:
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": trueno 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.envversionado. - 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
createdByeupdatedBycom ouserIddo 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 LGPD | O que fazer no Data Store | Situação |
|---|---|---|
| Inventário de dado pessoal | Marcar a coleção com pii: true | Disponível |
| Proteção em repouso | Marcar os campos identificadores com "encrypted": true | Disponível, só primeiro nível |
| Trilha de acesso | Ligar o Audit Trail sobre os eventos data-store.* | Disponível |
| Direito de eliminação | Expurgo definitivo da linha | Manual 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.
Limitações conhecidas
| Limitação | Impacto | Situação |
|---|---|---|
| RLS não é a barreira efetiva no ambiente atual | O 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ção | Tarefa 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 existem | A SPI está pronta e o enum de motor os prevê, mas não há implementação. O único motor é o Postgres JSONB | Planejado sob demanda. Não é funcionalidade disponível |
| Contabilização de bytes não implementada | maxBytes e usedBytes existem no modelo e aparecem em /usage, mas usedBytes fica sempre em zero: nada escreve na tabela quotas | Roadmap. Só a quota de documentos é aplicada de fato |
| Quota por tenant só muda no banco | Não há endpoint para ajustar maxDocs de um tenant; a linha em datastore.quotas precisa ser inserida ou alterada manualmente | Roadmap |
DATASTORE_DEFAULT_MAX_DOCS e DATASTORE_DEFAULT_MAX_BYTES não têm efeito | Estão no schema de configuração, mas o driver usa constantes internas com os mesmos valores | Inconsistência conhecida; alterar as variáveis hoje não muda nada |
| Sem atualização de coleção | Mudar schema, ACL ou flag pii de uma coleção exige excluir e recriar | Roadmap |
| Campo cifrado não é consultável | Filtro e ordenação sobre ele devolvem 400 | Por design — é texto cifrado em repouso |
| Criptografia só de primeiro nível | dados.cpf aninhado não é cifrado, só cpf no topo | Roadmap |
| Sem rotação de chave de criptografia | Trocar a chave torna os envelopes antigos ilegíveis; a leitura devolve o envelope em vez do valor, sem erro | Roadmap. Planeje migração antes de rotacionar |
| Sem join entre coleções e sem transação multi-documento | Cada operação toca uma coleção. Consistência entre coleções é problema da sua aplicação | Por design — é uma capability, não o banco primário do cliente |
| Sem agregação | Não há count by, soma nem pipeline. Só filtro, ordenação e paginação | Por design |
| Índice GIN genérico, sem índice por caminho | Consulta muito seletiva em coleção muito grande depende do GIN em data; não há índice por expressão de caminho | Gargalo conhecido em escala; partição de documents por tenant está no roadmap |
| Paginação por offset | Em coleção com escrita constante, páginas podem repetir ou pular itens | Por design. Para varredura completa, ordene por $id |
| Chave JSON com hífen ou espaço não é consultável | Segmento de caminho só aceita [A-Za-z0-9_] | Por design, por segurança na montagem do SQL |
| Sem expurgo automatizado para LGPD | Exclusão é lógica; eliminação definitiva é manual | Roadmap |
prisma db push não remove o schema | Retirar o building block exige limpeza manual do schema datastore | Consequência do modelo de push do projeto |
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