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
⭐
estadoetipo_signoestão tipados comCHECK, não com chave estrangeira (migração…126, fase 37ESQ-01), einstancia_clase.clase_numerocomCHECK (BETWEEN 1 AND 45). O vocabulário vai literal na restrição, não derivado da tabela: assim continua verdadeiro quandoESQ-16retirar os catálogos. ✅ As etiquetas amigáveis ES/EN/PT vivem empackages/core/src/i18n/com negociação porAccept-Language; os 14estadoe os 14tipo_signoestã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 empackages/core, não na base.⚠
descripcion_etiquetajá não leva URLs (ESQ-03): o mapeador do legacy usavaetiquetacomo 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 naturalnro_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)
⭐
secciontem chave estrangeira paraseccion_catalogo.codigodesde 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. UmnombreNULL 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, ounullpara um código não catalogado. Sem FK desde a migração…118(o catálogotipo_anotacionfoi retirado); a garantia vive agora nos mapeadores. ⛔ Está DENTRO da chave natural: preenchê-lo onde hoje énulldividiria os grupos da chave | |fecha,fecha_vencimiento|date| | |observacion,seccion,seccion_nombre|text| ⛔observacionestá DENTRO da chave (pelo seumd5): 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ãopersona: o boletim traz um nome nu e a identidade de uma pessoa é(pais, identificador)| |partes|text| O par de litigantes da famíliaC(…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_mongopassou paravia_importacion(era uma VIA, não uma origem) edesconocidofoi retirado por não ter emissor possível. ⛔ Fora da chave natural, do guard e docontentHash| |fuente_origen|text| Que sinal produziufuente, 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 desync_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 numWHERE| |evento_id|bigint| QUE LINHAS DESCREVEM O MESMO FACTO (…122). É omin(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: osidsão monótonos, logo uma linha nova nunca baixa o mínimo do grupo a que chega. ⛔NULLsignifica «esta linha é o seu próprio evento» e é assim em 89,8 % do corpus: toda leitura agrupa porcoalesce(evento_id, id), nunca porevento_idsozinho. ⛔ Fora da chave natural, do guard e docontentHash; nenhuma das duas cópias deupsertAnotaciono escreve | |evento_metodo|text| QUE SINAL agrupou as linhas:equivalencia_seccion(mesma marca, mesmo dia, e as duas secções formam um parconfirmadaemseccion_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 quefuente_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_id → raw_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_id → consumer; 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_vectorepersona.search_vector(espanhol, insensível a acentos viaunaccent). - Fuzzy:
pg_trgmGIN/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/.