Ir para o conteúdo

Schema do banco de dados

O corpus do Tarno é SQL plano (sem ORM): as migrações em ../db/migrations/ são a única fonte da verdade. Esta página é uma referência legível por pessoas gerada a partir dessas migrações. O corpus é neutro — zero campos RMC ou referências cruzadas (imposto por pnpm neutrality).

  • Engine: PostgreSQL 16 + pgvector (halfvec + HNSW), pg_trgm, fuzzystrmatch, unaccent, busca full-text em espanhol.
  • Ferramenta de migração: dbmate (cada arquivo tem -- migrate:up / -- migrate:down).

Diagrama entidade–relacionamento

erDiagram
    instancia ||--o{ instancia_clase : "classes"
    instancia ||--o{ instancia_persona : "owners/agents"
    instancia ||--o{ anotacion : "events"
    instancia |o--o| instancia : "renewal chain"
    persona ||--o{ instancia_persona : "role"
    clase ||--o{ instancia_clase : ""
    estado ||--o{ instancia : "status"
    tipo_signo ||--o{ instancia : "sign type"
    pais ||--o{ persona : "country"
    raw_source ||--o{ dead_letter : "re-derive handle"
    consumer ||--o{ api_key : "owns"

Corpus (entidades neutras)

instancia — a solicitação de marca

estado e tipo_signo estão tipados com CHECK, não com chave estrangeira (migração …126, fase 37 ESQ-01), e instancia_clase.clase_numero com CHECK (BETWEEN 1 AND 45). O vocabulário vai literal na restrição, não derivado da tabela: assim continua verdadeiro quando ESQ-16 retirar os catálogos. ✅ As etiquetas amigáveis ES/EN/PT vivem em packages/core/src/i18n/ com negociação por Accept-Language; os 14 estado e os 14 tipo_signo estão completos. O espanhol é a grafia LITERAL do INAPI, não uma tradução. ✅ Os 45 enunciados de Nice estão presentes EM ESPANHOL, transcritos literalmente do classificador on-line do INAPI (lido em 2026-09-14) — nunca escritos de memória. EN e PT vão a null: o INAPI serve apenas espanhol e o português não é língua oficial de Nice. Os 45 enunciados de Nice — vivem em packages/core, não na base.

descripcion_etiqueta já não leva URLs (ESQ-03): o mapeador do legacy usava etiqueta como recurso, e no Mongo legacy esse campo é o URL da imagem, não uma descrição. Foram anuladas 34.262 linhas e retirado o recurso, nessa ordem. A entidade central. Chave natural nro_solicitud (D-01); denominacion é um atributo, nunca uma chave (D-02). Enriquecida de forma autoritativa pelo Buscador; semeada (somente nulls) pelo Sheets.

Coluna Tipo Notas
nro_solicitud text PK — chave natural
nro_registro text aparece somente na concessão, nullable
denominacion text NOT NULL — o texto da marca
tipo_signo text CHECK ck_instancia_tipo_signo (14 valores, literales) — ya no es FK (…126)
estado text CHECK ck_instancia_estado (14 valores, literales) — ya no es FK (…126)
tipo_nombre, subtipo_nombre text tipo/subtipo do INAPI (D-14)
fecha_presentacion, fecha_publicacion, fecha_registro, fecha_vencimiento date as quatro datas explícitas (D-14)
traduccion, descripcion_etiqueta, protection_description, imagen_url text atributos de detalhe (D-14)
renovada_de, renovada_por text auto-FK → instancia(nro_solicitud) — cadeia de renovação (D-17)
embedding halfvec(1024) vetor semântico, índice HNSW
search_vector tsvector gerada — FTS em espanhol sobre unaccent(denominacion)
content_hash text idempotência (INGEST-05)
created_at, updated_at timestamptz

persona — titulares e agentes

Coluna Tipo Notas
id bigint PK (identity)
pais text pais(codigo)
identificador text RUT (Chile) ou id estrangeiro, nullable
nombre, apellido text
region, comuna text opcional (D-16) — códigos CUT sem zero à esquerda ('13' / '13114'); 99/99999 é a sentinela de desconhecido/estrangeiro. Não é o endereço
search_vector tsvector gerada, FTS
created_at timestamptz

Chave única uq_persona_pais_identificador (pais, identificador) onde identificador IS NOT NULL; personas sem id são reconciliadas por melhor esforço via (pais, lower(nombre)).

instancia_persona — ponte de papéis

Coluna Tipo Notas
instancia_nro_solicitud text PK, → instancia
persona_id bigint PK, → persona
rol text titular | representante | ambas

instancia_clase — classes de Nice

Coluna Tipo Notas
instancia_nro_solicitud text PK, → instancia
clase_numero int PK, → clase (1..45)
descripcion, estado text descrição/status por classe

anotacion — eventos do Estado-Diário (append-only)

seccion tem chave estrangeira para seccion_catalogo.codigo desde a migração …125 (fase 37, ESQ-10). Antes não havia nenhuma: 166 dos 668 códigos em uso não existiam no catálogo e 36.101 linhas (0,32 %) apontavam para o nada. ⚠ seccion_catalogo.nombre é ANULÁVEL de propósito: não existe listagem oficial de códigos — o INAPI, questionado em 2026-08-26, pediu «mais antecedentes» — e não se inventam legendas. Um nombre NULL significa «o código existe e não conhecemos a sua legenda», não «falta preencher». Dos 166 semeados, 8 traziam o título que o INAPI imprime, colhido do boletim; os outros 158 ficam sem legenda, e isso é o dado. | Coluna | Tipo | Notas | |---|---|---| | id | bigint | PK (identity) | | instancia_nro_solicitud | text | → instancia (um pai ausente é convertido em stub/colocado em quarentena) | | tipo | text | M1..M14, ou null para um código não catalogado. Sem FK desde a migração …118 (o catálogo tipo_anotacion foi retirado); a garantia vive agora nos mapeadores. ⛔ Está DENTRO da chave natural: preenchê-lo onde hoje é null dividiria os grupos da chave | | fecha, fecha_vencimiento | date | | | observacion, seccion, seccion_nombre | text | ⛔ observacion está DENTRO da chave (pelo seu md5): mudar-lhe um byte não colide, duplica | | representante, solicitante | text | Os dois nomes que o boletim imprime na sua própria coluna (migração …081). TEXTO, não persona: o boletim traz um nome nu e a identidade de uma pessoa é (pais, identificador) | | partes | text | O par de litigantes da família C (…081). ⭐ É o único dado que nenhuma outra fonte publica — nem o Sheets nem o Buscador dão o par | | tipo_resolucion | text | A etiqueta «Tipo de resolución» do boletim, verbatim (…082) | | seccion_nombre_especifico | text | O título que o boletim imprime para essa secção concreta | | fuente | text | Que fonte produziu a linha: estado_diario | buscador. Dois valores, não quatro desde a migração …120: legacy_mongo passou para via_importacion (era uma VIA, não uma origem) e desconocido foi retirado por não ter emissor possível. ⛔ Fora da chave natural, do guard e do contentHash | | fuente_origen | text | Que sinal produziu fuente, para que uma inferência não se leia como um facto: ingesta (o emissor afirmou-o) | evidencia (uma coluna que só o boletim escreve) | ventana (a passagem de importação ou a janela de sync_run, …120) | codigo (o único que pode errar). Prioridade: evidencia > ventana > codigo | | via_importacion | text | POR QUE VIA a linha entrou, pergunta distinta de quem a escreveu (…120): legacy_mongo = veio no dump do Mongo legacy; null = ingestão direta. ⭐ Existe porque o dump legacy era uma via para duas fontes, e confundi-las custava 369.171 linhas de boletim num WHERE | | evento_id | bigint | QUE LINHAS DESCREVEM O MESMO FACTO (…122). É o min(id) do grupo, NÃO o id da linha canónica: isso separa «que linhas são o mesmo facto» (um backfill reversível por lote) de «qual se mostra» (uma regra de leitura), e a segunda já mudou uma vez. ⭐ Por ser o mínimo é estável sob crescimento: os id são monótonos, logo uma linha nova nunca baixa o mínimo do grupo a que chega. ⛔ NULL significa «esta linha é o seu próprio evento» e é assim em 89,8 % do corpus: toda leitura agrupa por coalesce(evento_id, id), nunca por evento_id sozinho. ⛔ Fora da chave natural, do guard e do contentHash; nenhuma das duas cópias de upsertAnotacion o escreve | | evento_metodo | text | QUE SINAL agrupou as linhas: equivalencia_seccion (mesma marca, mesmo dia, e as duas secções formam um par confirmada em seccion_equivalencia — 297.805 eventos / 595.853 linhas) | a_favor_de (mesma (marca, fecha, seccion), exatamente duas linhas, uma leva o prefixo «A Favor de:» e as duas prosas coincidem ao retirá-lo — 279.008 eventos / 558.016 linhas) | manual. Mesma doutrina que fuente_origen: uma inferência não se pode ler como um facto. ⭐ É o que permite reverter uma família sem a outra dentro de um mesmo lote | | created_at | timestamptz | ⚠ É a data de CRIAÇÃO da linha, não a da sua última escrita |

!!! danger "⛔ Esta página dizia o contrário até 2026-09-10, e era o defeito JÁ CORRIGIDO" Aqui figurava a chave sem seccion, com um aviso que dizia «seccion NÃO está na chave, e isso descarta linhas»: que para um código não-M sem observação a chave se reduzia a (solicitud, fecha) e a segunda linha do dia se perdia sem deixar rasto.

**Era verdade, e a migração `…072` corrigiu-o.** A página continuou a descrever o problema em vez
da correção, pelo que durante semanas disse a quem a lesse que o corpus perde linhas em silêncio.
**Hoje `seccion` está DENTRO da chave** e esse caso não existe.

📌 A lição: uma página que documenta um defeito tem de ser revista **no dia em que o defeito é
corrigido**, não só quando o esquema muda.

Chave de dedup uq_anotacion_event_seccion (instancia_nro_solicitud, tipo, fecha, seccion, md5(coalesce(observacion,''))), com NULLS NOT DISTINCT — um evento repetido é um DO NOTHING limpo (os eventos nunca mutam).


Catálogos

Tabela PK Outros
estado codigo nombre — conjunto controlado de status
tipo_signo codigo nombre
pais codigo nombre — conjunto ISO completo + códigos descontinuados
clase numero nombre — classes de Nice, CHECK (numero BETWEEN 1 AND 45)

Um valor de catálogo desconhecido é aceito como null + registrado em log (D-09) — um novo valor do INAPI nunca interrompe a ingestão.


Ingestão / operações

Tabela Chave Finalidade
sync_state source (PK) cursor de retomada por fonte (cursor jsonb) mais estatísticas de movimento (migração …124): last_movement_at (MONÓTONO — escrito com greatest, porque um backfill de um boletim antigo carimba occurred_at no passado e não deve fazer a frescura retroceder) e movement_count (ACUMULATIVO: quantas mudanças a fonte produziu desde sempre, não count(movement_log) — a retenção apaga linhas e nunca toca neste contador). Mantidas por recordMovement na MESMA transação que escreve o movimento
movement_log id feed de mudanças append-only (entity_type, entity_id, change_type, source, occurred_at, content_hash) — alimenta a lista de trabalho do Buscador
raw_source id bytes brutos retidos (storage_path sob RAW_STORAGE_DIR, content_type, size) — re-derivabilidade (D-10)
dead_letter id quarentena (source, reason, raw_source_idraw_source, payload jsonb) — não abortante (D-07)
sync_run id histórico de execução (source, started_at, finished_at, status ok\|failed\|running, processed, changed, quarantined, error) — OBS-03

Controle de acesso

Tabela Chave Finalidade
consumer id org/tenant (name UNIQUE)
api_key id consumer_idconsumer; key_hash (sha256, UNIQUE — nunca em texto puro), prefix, scopes text[], monthly_quota int (>= 0), expires_at, rotated_at, revoked_at

Escopos: brands:read (/v1/brands, MCP search_brands/get_brand_detail), insights:read (/v1/freshness, /v1/sync-runs). Escopos vazios = irrestrito. Os contadores de cota vivem no Redis (compartilhado REST ↔ MCP), não no Postgres, para que o caminho de leitura permaneça SELECT-only.


Índices principais

  • FTS: GIN em instancia.search_vector e persona.search_vector (espanhol, insensível a acentos via unaccent).
  • Fuzzy: pg_trgm GIN/GiST sobre a denominação para busca tolerante a erros de digitação.
  • Vetor: HNSW em instancia.embedding (halfvec, cosseno) para similaridade semântica.
  • Reconciliação: parcial idx_persona_noident_reconcile (pais, lower(nombre)) WHERE identificador IS NULL.
  • Chaves naturais / dedup: uq_anotacion_event_seccion, uq_persona_pais_identificador, além das PKs de tabela acima.

As migrações são aplicadas via dbmate up; o DDL completo (índices, colunas geradas, ordenação de FK, config de FTS em espanhol) vive em ../db/migrations/.