# F1.3 — Conversão do schema para PostgreSQL

Decisões de conversão das 83 tabelas, a partir do levantamento feito com
`scripts/analisar-schema-mysql.php`.

Escrito em 17/08/2026.

---

## O que o levantamento encontrou

| | |
|---|---|
| Tabelas | 83 |
| Colunas | 885 |
| Chaves estrangeiras | 127 |
| Índices | 137 |
| **Índices únicos** | **0** |

O schema é bem mais limpo que o típico legado MySQL. **Não existe** nada do que
costuma travar uma migração: sem `ENUM`, sem `SET`, sem `UNSIGNED`, sem
`ON UPDATE CURRENT_TIMESTAMP`, sem `LONGTEXT`/`BLOB`, sem palavra reservada do
PostgreSQL usada como identificador, e **todas as 83 tabelas têm chave
primária** — o que é pré-requisito de replicação lógica e de RLS.

Duas surpresas, ambas viraram débito: **nenhuma restrição de unicidade** em todo
o banco (D29) e **dinheiro em ponto flutuante** (D30).

---

## Mapa de tipos

| MySQL | Qtd. | PostgreSQL | Observação |
|---|---:|---|---|
| `int` | 300 | `integer`, **`bigint`** nas tabelas de crescimento | ver abaixo |
| `varchar(n)` | 202 | `varchar(n)` | direto |
| `datetime` | 132 | **`timestamptz`** | ver abaixo |
| `double` | 85 | `double precision` | fiel de propósito — D30 |
| `tinyint(1)` | 78 | `boolean` | conferir uma a uma, ver D17 |
| `time` | 34 | `time` | direto |
| `text` | 32 | `text` | direto |
| `date` | 19 | `date` | direto |
| `tinyint` (≠1) | 3 | `smallint` | direto |

### `int` → `bigint` nas três tabelas que crescem

Projeção de `f1-2-decisao-saas.md` para 4.000 inquilinos:

| Tabela | Hoje | Projetado | Do limite do `int` |
|---|---:|---:|---:|
| `agendas` | 4,0 M | 500 M | 23% |
| `vendas_produtos` | 3,3 M | 408 M | 19% |
| `vendas` | 2,3 M | 284 M | 13% |

O `int` do PostgreSQL vai até 2,1 bilhões. As projeções cabem — mas ficam na
mesma ordem de grandeza, e estourar depois significa reescrever a tabela e todas
as que a referenciam, com o sistema em produção.

**Agora custa nada:** o schema está sendo criado do zero. `bigint` nas chaves
primárias e estrangeiras dessas três. As demais ficam `integer`.

### `datetime` → `timestamptz`

É a única decisão de tipo com risco real de comportamento, e a razão é o SaaS.

Hoje a operação inteira está num fuso só, e `datetime` sem fuso funciona. Com
4.000 inquilinos, não: **o Brasil tem quatro fusos**. Uma barbearia em Manaus
marcando 14:00 grava um instante diferente de uma em Florianópolis, e
`datetime` puro não sabe distinguir.

`timestamptz` guarda o instante absoluto e converte na leitura conforme o fuso da
sessão — que passa a ser definido por inquilino, junto com `app.inquilino_id`.

**Na migração, os valores atuais precisam ser interpretados explicitamente:**

```sql
data_criacao AT TIME ZONE 'America/Sao_Paulo'
```

Sem isso o PostgreSQL assume o fuso do servidor, e uma diferença de três horas
entra em 2,2 milhões de vendas sem ninguém notar. **É o ponto da F1.3 que os 67
testes de caracterização mais precisam validar.**

### `double` fica `double precision`, de propósito

Conversão fiel: os dois são IEEE 754 binário de 64 bits, então o comportamento
não muda em nada. Trocar por `numeric` — que é o tipo correto para dinheiro —
mudaria arredondamento e faria relatórios históricos divergirem.

Isso é o D30. A correção é uma migração dedicada, depois do corte, com
reconciliação própria. **Não durante a mudança de banco**, que é a regra que
vale para D20 e D22 também.

---

## Limpeza obrigatória antes da carga

19 linhas em 4 colunas têm anos que o PostgreSQL **rejeita** — `0202`, `0205`,
`0021`, `0000`. São erros de digitação em datas cujo dia e mês estão corretos. O
MySQL aceita; o PostgreSQL aborta a carga no meio, com a tabela pela metade.

`scripts/pre-migracao-limpeza.sql` corrige, é idempotente, e já foi exercitado na
base anonimizada:

| Coluna | Linhas | Conserto |
|---|---:|---|
| `agendas.data` | 7 | Ano vem de `data_criacao` — todas criadas em 2024-07-19, para 07-19 |
| `agendas_fechamentos.data_fim` | 6 | Ano vem da coluna irmã, que descreve o mesmo evento |
| `agendas_fechamentos.data_inicio` | 1 | idem |
| `usuarios.data_contratacao` | 5 | **NULO** — a coluna aceita, e não há fonte confiável |

Sobre a última: são cinco colaboradores inativos, quatro com `0000-11-30`, que é
artefato de conversão do MySQL e não carrega informação nenhuma. Nulo é a
resposta honesta; "contratado em 2021" seria invenção. Se alguém souber a data
real, corrija à mão **antes** de rodar o script.

**Rodar em produção antes do dump que alimenta a migração.**

---

## O que NÃO dá para decidir com a base anonimizada

Registrado porque quase virou conclusão errada.

Testei duplicatas em `usuarios.login`, `usuarios.email`, `clientes.cpf` e
`unidades.hash` para saber se dá para criar restrições de unicidade. Deu zero em
todas — e o resultado **não vale nada**, porque a anonimização deriva essas
colunas do `id`:

```sql
`login` = CONCAT('user', `id`)
`email` = CONCAT('colaborador', `id`, '@exemplo.invalid')
`cpf`   = LPAD(MOD(CONV(SUBSTRING(MD5(CONCAT('ucpf', `id`)), 1, 10), 16, 10), ...))
`hash`  = MD5(CONCAT('hash-unidade-', `id`))
```

São únicas por construção. Qualquer duplicata real de produção foi apagada pela
anonimização.

**Esta consulta precisa rodar na réplica de produção, com dados reais:**

```sql
SELECT 'usuarios.login' onde, COUNT(*) valores, SUM(n) linhas
  FROM (SELECT login, COUNT(*) n FROM usuarios
         WHERE login IS NOT NULL AND login <> '' GROUP BY login HAVING n > 1) x
UNION ALL SELECT 'usuarios.email', COUNT(*), SUM(n)
  FROM (SELECT email, COUNT(*) n FROM usuarios
         WHERE email IS NOT NULL AND email <> '' GROUP BY email HAVING n > 1) y
UNION ALL SELECT 'clientes.cpf', COUNT(*), SUM(n)
  FROM (SELECT cpf, COUNT(*) n FROM clientes
         WHERE cpf IS NOT NULL AND cpf <> '' GROUP BY cpf HAVING n > 1) z
UNION ALL SELECT 'unidades.hash', COUNT(*), SUM(n)
  FROM (SELECT hash, COUNT(*) n FROM unidades
         WHERE hash IS NOT NULL AND hash <> '' GROUP BY hash HAVING n > 1) w;
```

Se houver duplicata, criar a restrição **falha** — e falha durante a migração, no
pior momento possível. O resultado decide se as restrições do D29 entram nesta
fase ou depois de uma limpeza.

Vale a mesma desconfiança para qualquer conclusão tirada da base anonimizada
sobre unicidade, formato ou distribuição de dado pessoal.

---

## O DDL está gerado e carrega

`scripts/gerar-ddl-postgres.php` lê o `mysql.sql` e emite `postgres/schema.sql`
aplicando todas as decisões acima. **Não editar o SQL à mão** — ajuste vai no
gerador e regenera.

Verificado carregando num PostgreSQL 16 de verdade, do zero:

| | MySQL | PostgreSQL | |
|---|---:|---:|---|
| Tabelas | 83 | 84 | +`inquilinos` |
| Colunas | 885 | 964 | −1 órfã (D7), +75 `inquilino_id`, +5 de `inquilinos` |
| Índices | 137 | 137 | reconciliado |
| Chaves estrangeiras | 127 | 202 | +75 para `inquilinos` |
| Tabelas com RLS | — | 75 | as 8 de referência ficam de fora |

### Dois defeitos que o gerador teve, e o que ensinam

**Identificador acima de 63 bytes.** O PostgreSQL trunca em silêncio, só com um
`NOTICE` — e duas chaves estrangeiras de `vipclass_checkpoints_usuarios` ficaram
idênticas depois do corte, quebrando a carga com "already exists". O gerador
agora encurta de forma determinística, com sufixo derivado do nome completo.

**Dezenove índices sumindo sem aviso.** O padrão de leitura exigia que a linha
terminasse em `)`, e 19 índices deste dump terminam em `USING BTREE`. O gerador
descartava e informava "avisos: 0". Índice faltando numa tabela de centenas de
milhões de linhas não dá erro: dá lentidão inexplicável, meses depois.

A correção do padrão importa menos que a outra: **o gerador agora avisa sobre
qualquer linha que não souber ler**, em vez de ignorar. Silêncio num gerador de
DDL é pior que erro.

---

## O achado mais sério: superusuário ignora RLS

`postgres/teste-rls.sql` prova o isolamento com seis asserções, e a primeira
delas existe para registrar um risco:

**Superusuário do PostgreSQL ignora RLS sempre.** Nem `ENABLE` nem `FORCE` valem
alguma coisa para ele. Se a aplicação conectar com um usuário superusuário,
**o isolamento entre inquilinos simplesmente não existe** — e não existe em
silêncio, sem erro nenhum, até um cliente enxergar dado de outro.

Não é hipótese: o usuário `franquiabv` deste ambiente **é superusuário**, e é
com ele que a aplicação conecta hoje.

Por isso o DDL cria um papel próprio:

```sql
CREATE ROLE bvip_app NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE LOGIN;
```

E o teste confere o papel antes de testar o isolamento: se alguém der
`SUPERUSER` ou `BYPASSRLS` a ele, o teste falha na hora, em vez de passar dando
a falsa impressão de que há isolamento.

**Vale conferir também o privilégio do usuário MySQL de produção.** Não sei qual
é, e um usuário de aplicação com privilégio administrativo é problema hoje,
antes de qualquer RLS.

### As seis asserções

| | |
|---|---|
| 1 | Superusuário enxerga tudo — registra que a escolha do usuário é parte da segurança |
| 2 | Inquilino 1 enxerga só o dele |
| 3 | Inquilino 2 enxerga só o dele |
| 4 | `WITH CHECK` bloqueia gravar no inquilino alheio, mesmo informando o id |
| 5 | `UPDATE` não alcança linha de outro inquilino |
| 6 | Sem contexto declarado, o acesso **falha fechado** |

A sexta é a que protege contra a armadilha do PgBouncer: conexão reaproveitada
sem contexto não enxerga tudo, não enxerga nada.

Roda com código de saída, então serve direto no CI:

```bash
docker compose exec -T postgres psql -U franquiabv -d franquiabv_dev   -v ON_ERROR_STOP=1 -q < postgres/teste-rls.sql
```

---

## Nota sobre a chave primária

O DDL mantém `PRIMARY KEY (id)`, sem `inquilino_id`. É deliberado: a aplicação
busca por id o tempo todo (`find($id)`), e esse é o índice ideal para isso. A
regra de liderar por `inquilino_id` vale para os índices **secundários**, que são
os usados em varredura e filtro — e é lá que o gerador a aplica.

Ids continuam únicos entre inquilinos porque a sequência é uma só.

---

## O portão da F1.3 foi atingido

`scripts/verificar-schema-postgres.php` compara o mapeamento Doctrine com o
schema PostgreSQL **de pé** — não com o arquivo. É o banco que a aplicação vai
usar, e é ele que carrega os efeitos do gerador, inclusive os errados.

```
Tabelas no banco ............... 84
Entidades Doctrine lidas ....... 82
Tabelas mapeadas e ausentes .... 0
Colunas esperadas e ausentes ... 0
Tipos que BLOQUEIAM ............ 0
Tipos tolerados ................ 54
```

E os 67 testes de caracterização continuam passando.

### O que a verificação encontrou, e a regra que saiu daí

A primeira execução acusou **70 tipos divergentes**. Investigando, eles se
dividiam em duas naturezas bem diferentes.

**Dezesseis eram regressão minha.** Eu convertia pelo tipo do MySQL, e o MySQL é
frouxo: `int`, `tinyint` e `tinyint(1)` são todos inteiros por baixo, então um
mapeamento `type="boolean"` sobre uma coluna `int` funciona sem ninguém notar. O
PostgreSQL não perdoa — o Doctrine grava `true` numa coluna `integer` e a
inserção falha.

Errava nos dois sentidos: `agendas.status` é `int` no MySQL e `boolean` no
Doctrine; `pesquisas.status` é `tinyint(1)` no MySQL e `integer` no Doctrine.

**A regra que corrigiu isso: quem decide é a anotação da entidade, não a coluna
do MySQL.** É o mapeamento que vale em execução. O gerador agora lê as entidades
antes de converter.

**As outras 54 são D17 pré-existente**, e não quebram. Testei uma a uma no
PostgreSQL em vez de deduzir:

| Divergência | O que acontece |
|---|---|
| `datetime` → coluna `date` | grava, truncando a hora |
| `datetime` → coluna `time` | grava, pegando a parte de hora |
| `string` → `double precision` | grava |
| `integer`/`float` → `varchar` | grava |
| `float` → `integer` | grava, **arredondando** (12,7 vira 13) |
| **qualquer coisa ↔ `boolean`** | **erro de execução** |

Por isso o verificador separa **bloqueante** de **tolerado**, e só o primeiro
falha o build. Tratar os 54 como erro tornaria o portão inútil — ninguém
consegue passar por um portão que nunca abre.

Listar os tolerados:

```bash
MOSTRAR_TOLERADOS=1 php scripts/verificar-schema-postgres.php
```

---

## Ordem de execução

1. Rodar a consulta de duplicatas **na réplica de produção**.
2. `pre-migracao-limpeza.sql` em produção, antes do dump.
3. Gerar o DDL do PostgreSQL com o mapa de tipos acima.
4. Acrescentar `inquilino_id`, políticas RLS e a regra de índice (ver
   `f1-2-decisao-saas.md`).
5. Acrescentar as restrições de unicidade que o passo 1 permitir — sempre
   **compostas com `inquilino_id`**, senão dois inquilinos não podem ter um
   colaborador com o mesmo login.
6. Adaptar `verificar-schema.php` ao PostgreSQL e zerar as divergências.
