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
01Resumo executivo
O Data Store guarda dados estruturados dos seus clientes sem que você precise criar uma tabela nova a cada cliente que entra. Você cria uma coleção, joga um documento JSON dentro e consulta depois pelo conteúdo. Não existe migração, não existe provisionamento, não existe "vou pedir para o DBA criar a coluna".
Na prática ele resolve o dado que muda de formato entre clientes. Uma financeira que atende trinta parceiros descobre que cada parceiro quer guardar três campos diferentes na proposta — com o Data Store isso é um documento JSON por parceiro, validado por um schema opcional, e não trinta variações de tabela que alguém precisa manter para sempre.
Está em produção desde julho de 2026, roda como serviço próprio em staging e em produção, e é irmão declarado do file-storage: aquele guarda bytes, este guarda documentos.
| 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, IAM |
02O problemanegócio
O cenário. Uma plataforma B2B vende para dezenas de empresas. Cada empresa quer guardar alguma coisa que as outras não querem: um campo a mais na proposta, um catálogo de códigos internos, o estado intermediário de uma esteira que roda em cinco etapas. Nada disso é o dado central do produto — é o dado da borda, e é ele que multiplica.
O que trava hoje.
- Cada campo novo vira uma migração. O cliente pede um campo, o time abre PR, roda migração em produção e reza. Multiplique por trinta clientes e o schema vira um museu de colunas que só um cliente usa.
- A alternativa improvisada é pior. Alguém adiciona uma coluna
metadata 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.
O custo de não resolver. Falha de controle de acesso lidera o OWASP Top 10 desde 2021 (OWASP Top 10:2021 — Broken Access Control), e vazamento entre clientes é exatamente essa categoria. O whitepaper da AWS sobre isolamento em SaaS é explícito: no modelo de banco compartilhado, o isolamento passa a depender inteiramente da camada de aplicação (AWS SaaS Tenant Isolation Strategies). O custo direto, antes do incidente, é mais mundano: cada campo personalizado custa uma migração, e cada migração custa uma janela.
03Proposta de valornegócio
| 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.
Schemaless quando você quer, estruturado quando você precisa. A coleção aceita um schema opcional. Sem schema, grava qualquer JSON. Com schema, valida tipo, campos obrigatórios e propriedades extras na escrita. Você escolhe por coleção, não por produto.
Criptografia de campo sem código de cripto. Marcar "encrypted": true numa propriedade do schema basta: o serviço cifra na escrita com AES-256-GCM e decifra na leitura. O CPF fica ilegível no banco e legível na API, sem o integrador chamar biblioteca nenhuma.
Concorrência otimista de série. Todo documento tem version. Enviar expectedVersion no update transforma uma sobrescrita silenciosa em um 409 explícito.
04Casos de uso reaisnegócio
Caso 1 — Uma esteira de crédito guarda o estado de cada proposta sem inventar tabela Cenário ilustrativo
Contexto. Financeira de crédito consignado com uma esteira de sete etapas: captura, consulta de margem, motor de decisão, precificação, assinatura, averbação e pagamento. Cerca de 4 mil propostas por dia, e cada parceiro comercial acrescenta seus próprios campos ao contexto.
A dor. O estado da esteira vivia em uma tabela relacional com 60 colunas, das quais 22 eram usadas por um único parceiro. Toda vez que um parceiro novo entrava, o time abria migração. A tabela tinha três colunas chamadas obs, obs2 e obs_nova, e ninguém lembrava a diferença.
A solução com o BB. Uma coleção propostas com docKey igual ao número da proposta. Cada transição de etapa faz PUT /collections/propostas/documents/:id com expectedVersion, o que impede que dois workers concorrentes sobrescrevam um ao outro. O contexto acumulado é um objeto JSON livre; os campos que todo parceiro tem são validados por um schema-lite com required. Consultar "propostas paradas em averbação há mais de dois dias" é um POST .../documents/query com where em $updatedAt e status.
O resultado. Campo novo de parceiro deixa de ser migração e passa a ser gravação. A tabela de 60 colunas some, e o estado da esteira passa a ter versão e histórico de quem escreveu por último.
Caso 2 — Um formulário dinâmico por parceiro, sem uma tabela por parceiro Cenário ilustrativo
Contexto. Corretora de seguros que publica formulários de cotação diferentes para cada seguradora parceira. Dezoito parceiras, cada uma com o próprio conjunto de perguntas, que mudam a cada campanha.
A dor. A primeira versão gravava respostas em colunas. Cada campanha nova exigia deploy. O time chegou a manter uma planilha para saber qual coluna pertencia a qual campanha, o que é o sintoma clássico de schema no lugar errado.
A solução com o BB. Duas coleções: formularios guarda a definição (perguntas, tipos, ordem), respostas guarda o que o usuário preencheu. A coleção respostas marca cpf e telefone como encrypted: true, então esses campos ficam cifrados no banco. A ACL por coleção restringe a escrita em formularios aos identificadores de usuário do time de produto, mesmo entre quem já tem DATASTORE_WRITE.
O resultado. Campanha nova é uma inserção de documento, não um deploy. E os dados pessoais coletados ficam cifrados em repouso sem o time do formulário escrever uma linha de criptografia.
Caso 3 — Configuração por cliente sem cache incoerente Cenário ilustrativo
Contexto. Plataforma que roda 30 building blocks para 80 empresas clientes, cada uma com preferências operacionais: horário de disparo, canal preferido, limite de tentativa, texto padrão.
A dor. A configuração vivia em variável de ambiente e em Redis. Variável exige deploy para mudar; Redis perde tudo em um flush e ninguém sabe quem gravou o quê. Não havia auditoria e não havia validação — um valor errado só aparecia em produção.
A solução com o BB. O fast-path KV: PUT /kv/config/horario-disparo grava por chave natural, GET lê. Por baixo é uma coleção reservada kv_config com todo o resto da mecânica junto — escopo por tenant, RLS, createdBy/updatedBy e soft delete. Para a configuração que precisa de forma, uma coleção normal com schema-lite rejeita valor de tipo errado na escrita.
O resultado. Configuração passa a ser dado, com dono e carimbo de tempo, e não estado volátil. Mudança não pede deploy.
Caso 4 — Isolamento em banco compartilhado é padrão de mercado, e o risco dele é conhecido Referência de mercado
Contexto. O modelo de banco compartilhado com discriminador de tenant é a arquitetura recomendada pela AWS para SaaS B2B pelo custo por tenant (AWS SaaS Tenant Isolation Strategies).
A dor do mercado. O mesmo material é direto no risco: com banco compartilhado, o isolamento depende inteiramente da camada de aplicação. Uma cláusula WHERE esquecida é um vazamento entre clientes. A documentação do PostgreSQL descreve a resposta a isso — políticas de Row-Level Security que restringem as linhas visíveis por sessão (PostgreSQL — Row Security Policies).
Como a Catalisa endereça. O Data Store aplica o discriminador de tenant em três lugares independentes: no contrato do driver, que não tem operação sem tenantId; na consulta SQL, onde tenant_id é a primeira cláusula e não é opcional; e na política de RLS instalada nas três tabelas com FORCE ROW LEVEL SECURITY. As três camadas leem o mesmo valor, que vem do token assinado.
O resultado. O padrão econômico de multi-tenancy com uma barreira a mais do que a implementação usual. As condições para a barreira de RLS valer estão descritas sem rodeio em §14 e §15.
05Mercado e diferenciaisnegócio
Panorama. Quem precisa guardar documento JSON hoje escolhe entre três caminhos. O banco gerenciado de documentos (MongoDB Atlas, DynamoDB) entrega motor maduro e transfere para você toda a decisão de como separar clientes. A plataforma de backend (Firestore, Supabase) entrega API pronta e transfere para você a autoria das regras de segurança — Security Rules no Firestore, políticas de RLS no Supabase. E o terceiro caminho, o mais comum, é não escolher nada e enfiar um jsonb numa tabela que já existe.
O Data Store nasce em um recorte estreito de propósito: ele não tenta ser um banco de documentos completo. Ele é a capacidade de guardar documento JSON já isolada por empresa cliente, para quem já usa o resto do catálogo.
| Crité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 dado que você quer guardar é o banco principal do seu produto, escolha Postgres direto ou MongoDB Atlas — o Data Store não faz join entre coleções nem transação multi-documento, e forçar isso aqui é errado. Se o seu app é móvel e precisa de sincronização em tempo real e modo offline, o Firestore resolve isso hoje e nós não resolvemos. Se o volume é de dezenas de milhares de escritas por segundo com latência estável, o DynamoDB é o produto certo e a nossa tabela JSONB única não é. E se o seu time quer um Postgres completo com API automática e controle total do schema, o Supabase entrega mais do que nós — nós entregamos menos, de propósito, já isolado por cliente. O Data Store ganha quando o problema é dado de borda, variável por cliente, dentro de uma plataforma multi-tenant — não quando o problema é escala bruta ou modelagem relacional rica.
06Modelo de cobrança e ROInegócio
Unidade de cobrança. Documento armazenado. É a unidade que o cliente entende e que cresce junto com o valor que ele extrai: quem guarda mais registro está usando mais o produto. A precificação está em definição — não há tabela publicada, e este documento não estima uma.
O que dispara custo. Três coisas: volume de documentos armazenados, bytes armazenados e número de chamadas de API. A quota por tenant (maxDocs, maxBytes) já existe no modelo de dados e é o gancho onde a cobrança vai se apoiar.
Custo de referência dos análogos — cenário nomeado: 50 mil documentos, cerca de 10 GB, 200 mil chamadas de API por mês, uma operação B2B de porte médio.
| 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".
07Arquitetura
O Data Store segue a arquitetura padrão dos building blocks — app.ts, main.ts, routes/, services/, types/ — com uma adição: uma SPI de driver entre o serviço e a persistência, de modo que o building block inteiro é independente do motor de banco por baixo.
HTTP (envelope tolerante a JSON:API)
│
┌────────────────────────┴───────────────────────────────────────┐
│ routes/ (Hono) │
│ authMiddleware → requirePermission(DATASTORE_*) │
│ → requireOrganization → parse Zod → handleResult │
│ tenantId = user.organizationId! (NUNCA vem do corpo) │
└────────────────────────┬───────────────────────────────────────┘
│ ResultAsync<T, AppError>
┌────────────────────────┴───────────────────────────────────────┐
│ services/DataStoreService (neverthrow) │
│ resolve coleção · ACL · validação schema-lite · │
│ quota · cripto de campo · eventos · │
│ delega persistência ao DRIVER (não conhece motor nenhum) │
└────────────────────────┬───────────────────────────────────────┘
│ DataStoreCtx { tenantId, userId }
┌────────────────────────┴───────────────────────────────────────┐
│ DataStoreDriver (SPI — a abstração) │
│ insert/get/update/delete/query · upsertByKey/getByKey/… │
│ createCollection/… · usage · capabilities │
│ ── TODA operação recebe tenantId e é OBRIGADA a escopar ── │
└───────┬──────────────────────┬───────────────────────┬─────────┘
│ padrão │ não implementado │ não implementado
┌───────┴────────┐ ┌─────────┴────────┐ ┌──────────┴─────────┐
│ PostgresJsonb │ │ DynamoDbDriver │ │ MongoDriver │
│ Driver │ │ (ver §15) │ │ (ver §15) │
└───────┬────────┘ └──────────────────┘ └────────────────────┘
│
┌───────┴───────────────────────────────────────────────────────┐
│ Postgres, schema "datastore" (uma tabela JSONB, índice GIN) │
│ documents · collections · quotas → ZERO DDL por tenant │
│ + Row-Level Security (app.tenant_id) [defesa em profund.] │
└───────────────────────────────────────────────────────────────┘
Trocar o motor é mudar uma linha no registrador de DI (src/shared/container/modules/data-store.ts), ligando outro driver ao DataStoreDriverToken. services/ e routes/ não são tocados.
Decisões não óbvias.
- Documento em vez de relacional. Relacional por cliente exigiria DDL em tempo de execução — criar tabela e coluna dinamicamente. Isso é sobrecarga operacional e uma superfície de segurança grande demais para o ganho. JSONB dá a flexibilidade schemaless com zero DDL por tenant, e continua consultável pelo conteúdo. Quem quer estrutura ativa o schema-lite por coleção e ganha os dois.
- Uma tabela para todos os tenants, com índice GIN em
data. Isolamento é a 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.
08Conceitos 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 |
O schema é aplicado por prisma db push — este projeto é gerenciado por push, não por migrate.
Ciclo de vida de um documento
POST /documents
│
▼
┌───────────┐ PUT com expectedVersion correto ┌───────────┐
│ version 1 │ ─────────────────────────────────▶ │ version 2 │ ─▶ …
└─────┬─────┘ └─────┬─────┘
│ │
│ PUT com expectedVersion desatualizado │ DELETE
▼ ▼
409 CONFLICT deleted_at preenchido
(nada é gravado) (linha preservada)
Exclusão é lógica. O slot do único (tenant, coleção, docKey) continua
ocupado depois do DELETE — por isso existe o `ensure`, que ressuscita
o documento soft-deleted sem sobrescrever o conteúdo dele.
Como o tenant atravessa as camadas
JWT assinado middleware serviço driver
──────────── ────────── ─────── ──────
organizationId ──▶ requireOrganization ──▶ DataStoreCtx ──▶ tenant_id
│ (403 se ausente) { tenantId } │
│ ▼
│ set_config('app.tenant_id', …, true)
│ │
└──────────── mesmo valor em todas as camadas ───────────────┤
▼
política RLS: tenant_id = current_setting(…)
09Referência da API
Prefixo: /data-store. Em staging, a base é https://data-store.bb.stg.catalisa.app.
Todas as rotas de negócio exigem, nesta ordem: authMiddleware (JWT válido), requirePermission(...) e requireOrganization (token com organizationId; devolve 403 sem ele). O corpo aceita tanto o formato direto quanto o envelope JSON:API ({"data":{"attributes":{...}}}).
São 13 endpoints declarados em routes/, mais GET /api/v1/usage e GET /health, declarados diretamente em app.ts.
Coleções — /data-store/api/v1/collections
| Mé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"] }
}
| 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"
}
}
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
}
| 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"
}
}
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
}
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
}
| 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: eq ne gt gte lt lte in nin exists prefix contains.
Caminhos meta — filtram e ordenam por coluna, não pelo corpo JSON: $id, $version, $docKey, $createdAt, $updatedAt.
Detalhes que mudam o resultado: contains usa contenção JSONB (@>); comparações com valor numérico convertem o caminho para ::numeric; prefix escapa %, _ e \ antes de montar o LIKE; eq com null vira IS NULL.
Resposta 200 — data com os documentos, mais meta de paginação e links.
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 }
O caminho quente é uma leitura só: se o documento existe, ele volta como está, sem escrever, sem incrementar version e sem mexer em updatedAt. Se não existe, delega ao upsert atômico do driver, que também ressuscita documento excluído logicamente sem sobrescrever o conteúdo. Responde 200, não 201, nos dois casos.
10Início rápido
Do zero a um documento consultável. Os comandos abaixo usam staging; substitua pelo seu ambiente. Não executados nesta revisão da documentação — confira a resposta no seu ambiente.
1. Autenticar no IAM
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@catalisa.app",
"password": "root123456",
"organizationId": "b0000000-0000-0000-0000-000000000001"
}' | jq -r .accessToken)
echo "${TOKEN:0:24}..."
2. Criar a coleção
curl -s -X POST https://data-store.bb.stg.catalisa.app/data-store/api/v1/collections \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"propostas"}' | jq '.data'
{ "type": "collections", "id": "…", "name": "propostas", "pii": false }
3. Gravar um documento
DOC=$(curl -s -X POST \
https://data-store.bb.stg.catalisa.app/data-store/api/v1/collections/propostas/documents \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"parceiro":"banco-exemplo","status":"captura","valor":12000},
"docKey":"PROP-2026-000123"}')
DOC_ID=$(echo "$DOC" | jq -r '.data.id')
echo "$DOC" | jq '.data.version' # 1
4. Consultar pelo conteúdo
curl -s -X POST \
https://data-store.bb.stg.catalisa.app/data-store/api/v1/collections/propostas/documents/query \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"where":{"status":{"op":"eq","value":"captura"},
"valor":{"op":"gte","value":10000}},
"sort":[{"path":"valor","dir":"desc"}],"pageSize":10}' | jq '.meta, .data[0].attributes'
5. Ver o consumo contra a quota
curl -s https://data-store.bb.stg.catalisa.app/data-store/api/v1/usage \
-H "Authorization: Bearer $TOKEN" | jq '.data'
{ "usedDocs": 1, "maxDocs": 100000, "usedBytes": 0, "maxBytes": 1073741824 }
6. Confirmar que o isolamento não depende de você
curl -s -o /dev/null -w "%{http_code}\n" \
https://data-store.bb.stg.catalisa.app/data-store/api/v1/collections \
-H "Authorization: Bearer $TOKEN_SEM_ORGANIZACAO"
Devolve 403. Não existe caminho em que um token sem organização leia documento — e não existe campo no corpo onde você possa informar a organização.
Credenciais de staging, publicadas em AMBIENTES.md. Nunca use credencial de produção em documentação ou script de exemplo.
11Receitas
Guardar estado de workflow com trava otimista
Objetivo. Fazer vários workers avançarem a mesma esteira sem um sobrescrever o trabalho do outro.
# 1. Ler o estado atual e guardar a versão
CUR=$(curl -s "$BASE/data-store/api/v1/collections/propostas/documents/$DOC_ID" \
-H "Authorization: Bearer $TOKEN")
VER=$(echo "$CUR" | jq -r '.data.version')
# 2. Escrever a próxima etapa exigindo essa versão
curl -s -X PUT "$BASE/data-store/api/v1/collections/propostas/documents/$DOC_ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"data\":{\"parceiro\":\"banco-exemplo\",\"status\":\"averbacao\",\"valor\":12000},
\"expectedVersion\":$VER}"
Armadilhas.
- O
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.
# 1. A coleção declara quais campos são cifrados
curl -s -X POST "$BASE/data-store/api/v1/collections" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"pessoas","pii":true,
"jsonSchema":{"type":"object",
"properties":{"nome":{"type":"string"},
"cpf":{"type":"string","encrypted":true}}}}'
# 2. Gravar normalmente — o serviço cifra sozinho
curl -s -X POST "$BASE/data-store/api/v1/collections/pessoas/documents" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"nome":"Maria","cpf":"12345678900"}}'
Armadilhas.
- Campo cifrado não é consultável. Filtrar ou ordenar por
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.
curl -s -X POST "$BASE/data-store/api/v1/collections" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"scores","acl":{"read":["<userId-1>","<userId-2>"],"write":["<userId-1>"]}}'
Armadilhas.
- A ACL é aplicada sobre o RBAC, nunca no lugar dele. Quem não tem
DATASTORE_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.
curl -s -X PUT "$BASE/data-store/api/v1/kv/config/horario-disparo" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"value":{"inicio":"09:00","fim":"18:00","fuso":"America/Sao_Paulo"}}'
curl -s "$BASE/data-store/api/v1/kv/config/horario-disparo" \
-H "Authorization: Bearer $TOKEN" | jq '.data.value'
Armadilhas.
- Isto não é cache. Cada operação é uma transação no Postgres. Para caminho quente de requisição com latência de milissegundos, use Redis.
- O
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}'
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.
12Integraçã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.
┌──────────────────────────────┐
│ IAM │
│ token com organizationId │
└──────────────┬───────────────┘
│ Bearer JWT
┌────────────────────────────────┼────────────────────────────────┐
▼ ▼ ▼
┌───────────┐ documento JSON ┌─────────────┐ bytes do arquivo ┌──────────────┐
│ App do │ ─────────────────▶ │ Data Store │ │ File Storage │
│ cliente │ │ (JSONB) │◀── id do arquivo ──│ (S3) │
└───────────┘ └──────┬──────┘ guardado no doc └──────────────┘
│ eventos data-store.*
┌───────────────┴───────────────┐
▼ ▼
┌───────────────┐ ┌─────────────────┐
│ Audit Trail │ │ Webhooks Engine │
│ trilha de uso │ │ entrega externa │
└───────────────┘ └─────────────────┘
O par com o file-storage é o desenho recomendado: o binário vai para o S3 pelo file-storage, e o documento aqui guarda o fileId junto com os campos de negócio que descrevem aquele arquivo. Assim a consulta por conteúdo funciona sobre metadado estruturado, e o blob não passa pelo banco.
13Configuração e operação
Variáveis de ambiente
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
DATABASE_URL | PostgreSQL. O schema datastore vive nele | Sim | — |
JWT_SECRET | Segredo compartilhado para verificar o token do IAM (mínimo 44 caracteres) | Sim | — |
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 |
| IAM | Verificação do token e resolução de permissões |
Não usa Redis, não usa S3 e não chama nenhum provedor externo.
Limites e quotas
| Limite | Valor | Onde é aplicado |
|---|---|---|
| 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 |
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.
14Segurança e compliance
Isolamento entre tenants — como funciona, especificamente.
O tenantId é sempre user.organizationId, extraído do JWT assinado pelo IAM. Nenhuma rota lê o identificador da organização do corpo da requisição. Essa é a invariante que sustenta tudo o resto. Sobre ela, quatro camadas:
- 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.
Como a Row-Level Security funciona aqui.
Toda operação do driver — inclusive a leitura de um único documento — roda dentro de uma transação que começa assim:
SELECT set_config('app.tenant_id', '<organizationId do token>', true);
O terceiro argumento true faz a configuração ser local à transação: ela vale para as consultas seguintes e desaparece quando a transação termina. Isso importa porque o Prisma usa pool de conexões — sem o escopo transacional, o valor definido por uma requisição continuaria valendo para a próxima requisição que pegasse a mesma conexão, que é precisamente o vazamento que a RLS deveria impedir.
Nas três tabelas do schema (documents, collections, quotas), o driver instala de forma idempotente:
ALTER TABLE datastore.documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE datastore.documents FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON datastore.documents
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
USING filtra o que a sessão enxerga; WITH CHECK impede que ela grave linha de outro tenant. FORCE ROW LEVEL SECURITY existe porque, na configuração normal do PostgreSQL, "table owners normally bypass row security as well, though a table owner can choose to be subject to row security with ALTER TABLE ... FORCE ROW LEVEL SECURITY" (PostgreSQL — Row Security Policies).
Por que isso é defesa em profundidade e não teatro: as duas barreiras falham por motivos diferentes. O filtro do driver falha se alguém escrever uma consulta nova esquecendo o tenant_id — erro humano, no código da aplicação. A política de RLS falha por configuração de banco. Um bug de aplicação não derruba a política, e uma política ausente não derruba o filtro. É preciso errar duas vezes, em camadas distintas, para vazar.
A condição para a RLS valer, dita sem rodeio. A documentação do PostgreSQL é explícita: "Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table". Hoje o serviço se conecta ao banco com o papel postgres, que é superusuário — então, no ambiente atual, as políticas estão instaladas mas não são a barreira efetiva, e o isolamento em vigor é o filtro de tenant_id do driver, que roda em toda operação. Rodar sob um papel de banco não-superusuário é tarefa de infraestrutura, está registrada em §15, e é a única coisa que separa a segunda barreira de estar ativa. Não descreva a RLS como "aplicada em produção" antes disso.
Dados sensíveis.
- Criptografia de campo. Propriedades marcadas
"encrypted": 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).
Autenticação e permissões. Toda rota de negócio exige token válido, uma das três permissões DATASTORE_* e organização no token. ADMIN tem as três; VIEWER tem só leitura. Chamadas entre building blocks passam pelo facade e chegam autenticadas como o usuário sentinela 00000000-0000-0000-0000-000000000000, o que as torna distinguíveis de tráfego de cliente no log.
15Limitações conhecidas
| Limitaçã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 |
16Perguntas frequentes
Posso usar o Data Store como banco principal do meu aplicativo?
Não é para isso. Não há join entre coleções, transação multi-documento nem agregação. Ele foi desenhado para o dado de borda — estado de workflow, tabela de apoio, configuração, registro gerado pelo app. Se o seu modelo precisa de relacionamento e integridade referencial, use um banco relacional; se precisa de agregação pesada, use uma ferramenta analítica.
Qual a diferença entre o Data Store e o File Storage?
O File Storage guarda bytes: PDF, imagem, áudio, planilha. O Data Store guarda documentos JSON estruturados e consultáveis pelo conteúdo. O desenho recomendado usa os dois juntos — o binário vai para o S3 pelo file-storage, e aqui fica o documento com os campos de negócio e o fileId que aponta para ele.
Como o Data Store garante que um cliente não vê o dado de outro?
O identificador da organização vem do token assinado e nunca do corpo da requisição. O contrato do driver não tem operação sem esse identificador, e ele é a primeira cláusula de toda consulta. Sobre isso, políticas de Row-Level Security no Postgres avaliam o mesmo valor dentro da transação que grava. §14 explica a mecânica e diz, sem rodeio, qual condição de infraestrutura ainda falta para a segunda barreira estar ativa.
Preciso definir um schema para começar?
Não. Sem jsonSchema, a coleção aceita qualquer JSON. O schema é opcional e por coleção — dá para começar schemaless e apertar depois, quando o formato estabilizar. O que ele valida é o subconjunto que resolve a maioria dos casos: tipo, campos obrigatórios e propriedades extras.
Dá para trocar o Postgres por outro banco?
A arquitetura está pronta para isso: a SPI de driver isola o building block do motor, e trocar é ligar outra implementação ao mesmo token de DI. Mas hoje só existe o driver Postgres JSONB. DynamoDB e Mongo estão previstos no contrato e não implementados (§15) — não os apresente como disponíveis.
O que acontece se dois processos escreverem no mesmo documento ao mesmo tempo?
Se ambos mandarem expectedVersion, o segundo recebe 409 e não sobrescreve nada. Se nenhum mandar, o último a escrever vence, em silêncio. Para escrita concorrente de verdade, sempre envie expectedVersion; para "garanta que existe", use ensure, que é idempotente por docKey.
Posso guardar CPF e outros dados pessoais aqui?
Pode, com cuidado. Marque o campo como "encrypted": true no schema da coleção e ele fica cifrado em repouso com AES-256-GCM, de forma transparente. Duas consequências: campo cifrado não é consultável, e a chave precisa estar no ambiente antes da primeira escrita. Combine com o Audit Trail para a trilha de acesso que a LGPD pede.
Qual o tamanho máximo de um documento?
512 KiB depois de serializado. Documento maior que isso normalmente é sinal de que o conteúdo deveria estar no File Storage, com o ponteiro guardado aqui.
Por que a consulta por um campo específico não usa índice dedicado?
Porque a tabela é uma só para todos os tenants, e criar índice por caminho de cada cliente traria de volta o DDL por tenant que o desenho evita. O índice GIN sobre data cobre consulta por conteúdo de forma genérica. Em coleção muito grande com filtro muito seletivo isso é um gargalo conhecido, e a saída planejada é particionar documents por tenant (§15).
O fast-path KV substitui o Redis?
Não. Cada operação KV é uma transação no Postgres, com durabilidade e rastro de autoria — o que é ótimo para configuração e péssimo para cache de caminho quente. Use o KV para dado que precisa sobreviver e ser auditável; use Redis para o que precisa ser rápido e pode sumir.
Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md · Design original: 2026-07-10-data-store-bb-design.md