@fayz-ai/db 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 120_o_servico_diz_se_ele_se_agenda.sql — nem todo serviço se marca na agenda.
3
+ -- ---------------------------------------------------------------------------
4
+ -- O formulário de agendamento do V2 oferece o catálogo inteiro. No BeautySoft
5
+ -- ele oferece TRÊS — Avaliação, Revisão e Compra Antecipada —, e a regra está
6
+ -- escrita na própria tela dele:
7
+ --
8
+ -- "Somente Avaliação, Revisão, Compra Antecipada e serviços com contrato
9
+ -- assinado + avaliação vinculada podem ser agendados."
10
+ --
11
+ -- Não é detalhe de estética. É o desenho do negócio: o tratamento é VENDIDO
12
+ -- (um pacote, um contrato), e o que se marca na agenda é o passo do processo —
13
+ -- a consulta que avalia, o retorno que revisa, a entrega do que foi comprado
14
+ -- antes. Oferecer os 122 no agendamento faz a recepção escolher entre "Ácido
15
+ -- Hialurônico" e "Avaliação" como se fossem a mesma coisa, e marcar o
16
+ -- tratamento em vez do passo é como o histórico do cliente deixa de fechar com
17
+ -- o que ele pagou.
18
+ --
19
+ -- ── A forma no V2 ──────────────────────────────────────────────────────────
20
+ -- Uma coluna, não um enum copiado do V1. `service_kind` de lá tem quatro
21
+ -- valores dos quais três respondem à MESMA pergunta ("isto se marca?"), e o
22
+ -- quarto — o serviço com contrato assinado + avaliação vinculada — depende de
23
+ -- um contrato que o V2 ainda não modela. Um enum importado inteiro carregaria
24
+ -- uma classificação que ninguém aqui sabe ler.
25
+ --
26
+ -- `is_bookable` é a pergunta, e é a MESMA palavra que `people.is_bookable` já
27
+ -- usa para separar quem atende de quem trabalha na casa. Duas tabelas, uma
28
+ -- pergunta, um nome.
29
+ --
30
+ -- ── Nasce TRUE, e isso é deliberado ────────────────────────────────────────
31
+ -- Uma conta que nunca pensou no assunto continua marcando o que sempre marcou.
32
+ -- A regra do ByDesign: pergunta não respondida cai no padrão, não em "não" —
33
+ -- e o contrário esvaziaria o agendamento de toda conta no dia do deploy.
34
+ --
35
+ -- Quem vem do V1 responde na importação (`services.service_kind`) ou na tela
36
+ -- de Serviços, no interruptor "Aparece na agenda".
37
+ --
38
+ -- Idempotente.
39
+ -- ---------------------------------------------------------------------------
40
+
41
+ -- ── Onde a coluna mora ─────────────────────────────────────────────────────
42
+ -- Em `products`. A 191 aposentou o espelho `public.services` e um serviço
43
+ -- passou a ser produto de `kind='service'` — é de lá que o SDK lê, e medido no
44
+ -- cluster do salão os dois lados batem (122 e 122).
45
+ --
46
+ -- Nos pools que ainda têm a tabela antiga viva, a coluna vai nos DOIS: a tela
47
+ -- de Serviços lê o espelho, e uma coluna que só existe de um lado seria um
48
+ -- interruptor que se desliga e volta sozinho.
49
+ ALTER TABLE public.products
50
+ ADD COLUMN IF NOT EXISTS is_bookable boolean NOT NULL DEFAULT true;
51
+
52
+ COMMENT ON COLUMN public.products.is_bookable IS
53
+ 'Este serviço pode ser MARCADO na agenda. Falso = vendido, não agendado (o '
54
+ 'tratamento que se compra em pacote; o que se marca é a avaliação, a revisão '
55
+ 'ou a entrega). Espelha o `service_kind` do V1. Só significa algo para '
56
+ 'kind=service. Padrão true.';
57
+
58
+ CREATE INDEX IF NOT EXISTS products_bookable_idx
59
+ ON public.products (tenant_id, is_bookable)
60
+ WHERE is_bookable;
61
+
62
+ DO $$
63
+ DECLARE
64
+ -- O oid guardado, e não `'public.services'::regclass` dentro do IF: o cast
65
+ -- LANÇA quando a relação não existe, e o `AND` não o protege — o planejador
66
+ -- avalia a subconsulta antes de decidir. Num banco do zero (sem o espelho
67
+ -- legado) isto derrubava a migration inteira com "relation does not exist",
68
+ -- que é exatamente o caso que o guarda existia para cobrir.
69
+ v_oid oid := to_regclass('public.services');
70
+ BEGIN
71
+ IF v_oid IS NOT NULL
72
+ AND (SELECT relkind FROM pg_class WHERE oid = v_oid) = 'r' THEN
73
+ EXECUTE 'ALTER TABLE public.services ADD COLUMN IF NOT EXISTS is_bookable boolean NOT NULL DEFAULT true';
74
+ EXECUTE 'CREATE INDEX IF NOT EXISTS services_bookable_idx ON public.services (tenant_id, is_bookable) WHERE is_bookable';
75
+ END IF;
76
+ END $$;
@@ -0,0 +1,109 @@
1
+ -- A liberação pergunta uma vez, e não cento e vinte mil.
2
+ --
3
+ -- Qualquer lista de Pessoas do Espaço Facial levava ~6,4 s e às vezes estourava
4
+ -- o `statement_timeout` — a tela voltava vazia, que é como o PostgREST entrega
5
+ -- o 57014. Medido no cluster, como `authenticated`, tenant Espaço Facial:
6
+ --
7
+ -- select count(*) from people where kind='customer' 6.206 ms 331.301 buffers
8
+ -- lista com name ilike '%test%', limit 20 6.459 ms 336.521 buffers
9
+ --
10
+ -- ── Não era a busca ─────────────────────────────────────────────────────────
11
+ --
12
+ -- O primeiro suspeito é o `ilike '%test%'`: curinga à esquerda, e a suspeita é
13
+ -- razoável. Mas o `count(*)` acima não tem busca nenhuma e custa o MESMO. O que
14
+ -- os dois têm em comum é a política.
15
+ --
16
+ -- (Sobre o ILIKE, de passagem, porque ele volta: com RLS ligada ele NUNCA vai
17
+ -- virar condição de índice. `texticlike` não é `leakproof`, e um qual que não é
18
+ -- leakproof não desce abaixo da barreira de segurança — então o índice trigrama
19
+ -- `people_name_trgm_idx`, que sozinho responde em 13 ms, fica de fora por
20
+ -- construção. Isso não é um defeito a corrigir aqui; é o preço da barreira.)
21
+ --
22
+ -- ── Era esta linha ──────────────────────────────────────────────────────────
23
+ --
24
+ -- A política `people_available_in` (migration 087) tem quatro cláusulas em OU, e
25
+ -- a terceira é:
26
+ --
27
+ -- NOT app.has_release_tuple('people.person', id)
28
+ --
29
+ -- `has_release_tuple` é barata: uma função SQL simples, inlinável, que faz um
30
+ -- EXISTS num índice. O 087 a escreveu assim de propósito, porque a versão
31
+ -- anterior era SECURITY DEFINER e o Postgres não inlina essas — cada linha
32
+ -- pagava a montagem de um executor inteiro.
33
+ --
34
+ -- O que o 087 não viu é que barata **vezes cento e vinte mil** não é barata. A
35
+ -- cláusula só é alcançada quando as duas anteriores falham, e no Espaço Facial
36
+ -- elas falham SEMPRE:
37
+ --
38
+ -- pessoas no tenant 120.808
39
+ -- com unit_id NULL 120.455 (99,7%)
40
+ -- grants de people.person 1.446 (grants_exist → verdadeiro)
41
+ --
42
+ -- Cliente de salão não mora numa filial — ela é da rede, e o V1 nem tem coluna
43
+ -- de unidade em `clients`. Então `unit_id IS NOT NULL` é falso, e a sonda ao
44
+ -- índice roda uma vez por linha: 2,8 buffers × 120 mil = os 331.301 do plano.
45
+ --
46
+ -- ── A correção ──────────────────────────────────────────────────────────────
47
+ --
48
+ -- A mesma pergunta, feita UMA vez. `NOT IN` sobre uma subconsulta NÃO
49
+ -- correlacionada é resolvida como *hashed SubPlan*: o Postgres lê as 1.446
50
+ -- linhas de grant, monta uma tabela hash e depois cada pessoa custa uma sondagem
51
+ -- de hash em vez de uma descida de índice.
52
+ --
53
+ -- É o mesmo movimento que o funil do CRM já tinha feito por outro motivo: o que
54
+ -- é correlacionado não desce para dentro da barreira, e o que não é, desce uma
55
+ -- vez só.
56
+ --
57
+ -- select count(*) ... 6.206 ms → 115 ms (54×)
58
+ -- lista com ilike, limit 20 6.459 ms → 467 ms (14×)
59
+ --
60
+ -- O resultado é o MESMO — as duas formas devolvem 119.518 linhas. A quarta
61
+ -- cláusula (`available_in_my_units`) continua depois do NOT IN e é ela que
62
+ -- deixa passar quem tem tupla de liberação para a unidade de quem lê.
63
+ --
64
+ -- `resource_id IS NOT NULL` não é decoração: `NOT IN` com um NULO na lista
65
+ -- devolve NULO para todo mundo, e NULO numa política esconde a linha. Sem essa
66
+ -- cláusula, um grant órfão apagaria a agenda inteira de todo mundo.
67
+ --
68
+ -- ── E o índice da lista ─────────────────────────────────────────────────────
69
+ --
70
+ -- Com a política corrigida, o que sobra é a varredura ordenada por nome, e ela
71
+ -- volta ao heap só para ler `is_active`, `unit_id`, `owner_id` e `id`. Pendurar
72
+ -- essas quatro no índice que já existe transforma o `count` num Index Only Scan
73
+ -- (255 ms → 115 ms). A chave não muda — é o mesmo índice, com carga.
74
+ --
75
+ -- Idempotente.
76
+
77
+ ALTER POLICY people_available_in ON public.people USING (
78
+ (NOT (SELECT app.grants_exist('people.person', ARRAY['available_in', 'unavailable_in'])))
79
+ OR (unit_id IS NOT NULL)
80
+ -- A pergunta de sempre, resolvida uma vez: "esta pessoa tem tupla de
81
+ -- liberação?". Não correlacionada de propósito — é isso que a torna um hash.
82
+ OR (people.id NOT IN (
83
+ SELECT g.resource_id
84
+ FROM app.resource_grants g
85
+ WHERE g.tenant_id = (SELECT app.current_tenant_id())
86
+ AND g.resource_type = 'people.person'
87
+ AND g.relation IN ('available_in', 'unavailable_in')
88
+ AND g.resource_id IS NOT NULL))
89
+ OR (SELECT app.available_in_my_units('people.person', people.id)));
90
+
91
+ -- Um índice novo em `resource_grants` foi tentado aqui e NÃO entrou. A ideia era
92
+ -- ler por recurso em vez de por sujeito, porque o plano gasta 921 buffers para
93
+ -- devolver 1.446 linhas. Medido: o planejador continuou escolhendo
94
+ -- `resource_grants_subject_idx` — cuja chave já começa por
95
+ -- (tenant_id, …, relation, resource_type) — e a subconsulta custa 0,95 ms de um
96
+ -- total de 130. Índice que o planejador não escolhe é peso de escrita sem
97
+ -- leitura, e fica registrado aqui em vez de no catálogo.
98
+
99
+ -- A lista de pessoas: mesma chave do índice que já existia, com as quatro
100
+ -- colunas que a política lê penduradas. O antigo sai depois do novo entrar,
101
+ -- para nenhum plano ficar sem índice no meio.
102
+ CREATE INDEX IF NOT EXISTS people_tenant_kind_name_cover_idx
103
+ ON public.people USING btree (tenant_id, kind, name)
104
+ INCLUDE (id, is_active, unit_id, owner_id);
105
+
106
+ DROP INDEX IF EXISTS public.people_tenant_kind_name_idx;
107
+
108
+ COMMENT ON INDEX public.people_tenant_kind_name_cover_idx IS
109
+ 'A lista de Pessoas por tipo, em ordem de nome. As colunas penduradas são as que people_available_in e people_authz_select leem — sem elas o count volta ao heap linha a linha (121).';
@@ -0,0 +1,80 @@
1
+ -- O e-mail da pessoa é um e-mail, não uma lista.
2
+ --
3
+ -- A ficha do profissional mostrava, no campo E-mail:
4
+ --
5
+ -- ["marataizes@espacofacial.com.br"]
6
+ --
7
+ -- e, no Telefone, `[]`. Não é defeito de tela: `people.email` e `people.phone`
8
+ -- são `text`, e o porte do V1 gravou ali o JSON do array que o V1 mantém —
9
+ -- lá uma pessoa pode ter vários e-mails, aqui a coluna guarda um.
10
+ --
11
+ -- Medido em 21/09/2026 (klvfxzreepavcpyjiwla, 123.484 pessoas, 25 contas):
12
+ --
13
+ -- e-mail em forma de array 456 (451 com um valor, 4 vazios, 1 com dois)
14
+ -- telefone em forma de array 277 (233 com um valor, 43 vazios, 1 com dois)
15
+ --
16
+ -- ── Por que reparar o dado e não formatar na tela ──────────────────────────
17
+ --
18
+ -- Um `JSON.parse` na hora de desenhar esconderia o problema nos lugares que
19
+ -- ainda não existem: a busca por e-mail não acha `["x@y"]` procurando `x@y`, o
20
+ -- disparo de mensagem manda para um endereço com colchete, e o `unique` que um
21
+ -- dia entrar nessa coluna vai tratar `x@y` e `["x@y"]` como pessoas diferentes.
22
+ -- Campo estranho na tela é sintoma; a cura é onde o dado foi escrito.
23
+ --
24
+ -- ── O que sobra não se perde ───────────────────────────────────────────────
25
+ --
26
+ -- O primeiro valor sobe para a coluna; os demais descem para
27
+ -- `metadata.contact.extra_emails` / `extra_phones`. São dois registros no
28
+ -- cluster inteiro, mas apagar o segundo e-mail de alguém para deixar a coluna
29
+ -- bonita é o tipo de perda que só aparece quando a pessoa não recebe o aviso.
30
+ --
31
+ -- Array vazio vira NULL: `[]` na tela diz que existe um telefone e não diz
32
+ -- qual; vazio diz a verdade.
33
+
34
+ DO $$
35
+ DECLARE
36
+ v_emails integer := 0;
37
+ v_phones integer := 0;
38
+ BEGIN
39
+ -- Guarda o que exceder ANTES de sobrescrever a coluna.
40
+ UPDATE public.people p
41
+ SET metadata = jsonb_set(
42
+ coalesce(p.metadata, '{}'::jsonb),
43
+ '{contact,extra_emails}',
44
+ (SELECT jsonb_agg(v) FROM jsonb_array_elements(p.email::jsonb) WITH ORDINALITY t(v, i) WHERE i > 1),
45
+ true)
46
+ WHERE p.email ~ '^\[.*\]$'
47
+ AND jsonb_typeof(p.email::jsonb) = 'array'
48
+ AND jsonb_array_length(p.email::jsonb) > 1;
49
+
50
+ UPDATE public.people p
51
+ SET metadata = jsonb_set(
52
+ coalesce(p.metadata, '{}'::jsonb),
53
+ '{contact,extra_phones}',
54
+ (SELECT jsonb_agg(v) FROM jsonb_array_elements(p.phone::jsonb) WITH ORDINALITY t(v, i) WHERE i > 1),
55
+ true)
56
+ WHERE p.phone ~ '^\[.*\]$'
57
+ AND jsonb_typeof(p.phone::jsonb) = 'array'
58
+ AND jsonb_array_length(p.phone::jsonb) > 1;
59
+
60
+ -- O primeiro valor vira o valor. `#>>'{0}'` devolve NULL no array vazio, que
61
+ -- é exatamente o que se quer guardar ali.
62
+ UPDATE public.people p
63
+ SET email = nullif(btrim(p.email::jsonb #>> '{0}'), '')
64
+ WHERE p.email ~ '^\[.*\]$'
65
+ AND jsonb_typeof(p.email::jsonb) = 'array';
66
+ GET DIAGNOSTICS v_emails = ROW_COUNT;
67
+
68
+ UPDATE public.people p
69
+ SET phone = nullif(btrim(p.phone::jsonb #>> '{0}'), '')
70
+ WHERE p.phone ~ '^\[.*\]$'
71
+ AND jsonb_typeof(p.phone::jsonb) = 'array';
72
+ GET DIAGNOSTICS v_phones = ROW_COUNT;
73
+
74
+ RAISE NOTICE 'people: % e-mail(s) e % telefone(s) saíram da forma de lista', v_emails, v_phones;
75
+ END $$;
76
+
77
+ COMMENT ON COLUMN public.people.email IS
78
+ 'UM e-mail. O V1 guarda lista; o porte tem de escolher o principal e mandar o resto para metadata.contact.extra_emails (ver migration 122).';
79
+ COMMENT ON COLUMN public.people.phone IS
80
+ 'UM telefone. Mesma regra do e-mail — o excedente vai para metadata.contact.extra_phones (ver migration 122).';
@@ -0,0 +1,197 @@
1
+ -- Um writer para a tabela que já tem a forma certa.
2
+ --
3
+ -- O porte tem 19 tabelas novas para transportar — caixa de entrada de NF-e,
4
+ -- remessa bancária, comissão a pagar, ordem de produção, chamada de garçom — e
5
+ -- todas foram DESENHADAS para este transporte: as colunas do destino têm o nome
6
+ -- que o de-para produz, porque foram escritas olhando a origem.
7
+ --
8
+ -- Escrever dezenove writers de upsert quase idênticos custaria duas mil linhas
9
+ -- e criaria dezenove lugares onde a mesma regra pode divergir. O motor já tem o
10
+ -- precedente do contrário: `upsert_facet` serve dezesseis tabelas de origem com
11
+ -- uma função só, lendo o alvo do `writer_args`.
12
+ --
13
+ -- Este é o mesmo movimento para o caso do upsert inteiro.
14
+ --
15
+ -- ── O que o torna seguro ────────────────────────────────────────────────────
16
+ --
17
+ -- SQL dinâmico a partir de jsonb é superfície de injeção, e a resposta não é
18
+ -- "os dados vêm de lugar confiável" — isso é uma propriedade que se perde numa
19
+ -- refatoração. As travas são estruturais:
20
+ --
21
+ -- 1. O alvo tem de existir (`to_regclass`) E ser `target_table` de alguma
22
+ -- linha da `migration.allowlist`. A allowlist é congelada por gatilho e só
23
+ -- muda por arquivo de migration, então o conjunto de tabelas que este
24
+ -- writer alcança é o conjunto que alguém escreveu num arquivo revisado.
25
+ --
26
+ -- 2. Toda chave da linha é confrontada com `information_schema.columns` do
27
+ -- alvo. O que casa vira coluna; o que não casa vai para `metadata` — não é
28
+ -- descartado, e não vira identificador.
29
+ --
30
+ -- 3. A coerção de tipo é do Postgres, não minha: `jsonb_populate_record`
31
+ -- converte pelo ROWTYPE do destino. Texto que não é data falha ali, com o
32
+ -- nome da coluna, em vez de virar nulo em silêncio.
33
+ --
34
+ -- 4. Só as colunas que a linha traz entram no INSERT. As outras recebem o
35
+ -- DEFAULT da tabela — que é o contrário de `jsonb_populate_record` sozinho,
36
+ -- que preencheria tudo com nulo e apagaria os defaults.
37
+ --
38
+ -- ── O que ele NÃO faz ───────────────────────────────────────────────────────
39
+ --
40
+ -- Não traduz vocabulário, não deriva coluna, não recusa por regra de negócio.
41
+ -- Tabela que precisa de qualquer uma dessas três coisas ganha writer próprio —
42
+ -- é o caso de `upsert_appointment` (mapa de status), `upsert_clinical_session`
43
+ -- (sete palavras fechadas) e `upsert_agenda_schedule` (dia inteiro vira faixa).
44
+ -- Este writer é para a tabela que só precisa atravessar.
45
+ --
46
+ -- Idempotente.
47
+
48
+ DO $$
49
+ DECLARE v_def text;
50
+ BEGIN
51
+ SELECT pg_get_constraintdef(oid) INTO v_def
52
+ FROM pg_constraint WHERE conname = 'allowlist_writer_check';
53
+ IF v_def IS NULL OR strpos(v_def, 'by_shape') > 0 THEN RETURN; END IF;
54
+ ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;
55
+ EXECUTE replace(
56
+ 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,
57
+ '''person''::text', '''person''::text, ''by_shape''::text');
58
+ END $$;
59
+
60
+ CREATE OR REPLACE FUNCTION migration.upsert_by_shape(
61
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
62
+ ) RETURNS jsonb
63
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
64
+ AS $function$
65
+ DECLARE
66
+ v_target text := p_args ->> 'target';
67
+ v_reg regclass;
68
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
69
+ v_cols text[];
70
+ v_usadas text[] := ARRAY[]::text[];
71
+ v_extra jsonb := '{}'::jsonb;
72
+ v_payload jsonb := '{}'::jsonb;
73
+ v_tem_meta boolean;
74
+ v_tem_unit boolean;
75
+ v_tem_upd boolean;
76
+ v_unit uuid;
77
+ v_lista text;
78
+ v_select text;
79
+ v_set text;
80
+ v_existing uuid;
81
+ v_created boolean := false;
82
+ v_falta text;
83
+ k text;
84
+ v_standalone boolean := migration._standalone();
85
+ BEGIN
86
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
87
+
88
+ IF p_id IS NULL THEN
89
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a row needs an id');
90
+ END IF;
91
+
92
+ -- Trava 1: o alvo existe e está declarado na allowlist.
93
+ v_reg := to_regclass(v_target);
94
+ IF v_reg IS NULL THEN
95
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
96
+ 'reason', format('the by_shape writer needs a target that exists; got %L', coalesce(v_target, '<null>')));
97
+ END IF;
98
+ IF NOT EXISTS (SELECT 1 FROM migration.allowlist a WHERE a.target_table = v_target) THEN
99
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
100
+ 'reason', format('%s is not a target_table of any allowlist row — this writer only reaches what the de-para declares', v_target));
101
+ END IF;
102
+
103
+ -- Trava 2: as colunas reais do destino.
104
+ SELECT array_agg(a.attname ORDER BY a.attnum) INTO v_cols
105
+ FROM pg_attribute a
106
+ WHERE a.attrelid = v_reg AND a.attnum > 0 AND NOT a.attisdropped;
107
+
108
+ v_tem_meta := 'metadata' = ANY (v_cols);
109
+ v_tem_unit := 'unit_id' = ANY (v_cols);
110
+ v_tem_upd := 'updated_at' = ANY (v_cols);
111
+
112
+ FOR k IN SELECT jsonb_object_keys(p_row) LOOP
113
+ IF k IN ('id', 'tenant_id', 'metadata') THEN CONTINUE; END IF;
114
+ IF k = ANY (v_cols) THEN
115
+ v_usadas := v_usadas || k;
116
+ v_payload := v_payload || jsonb_build_object(k, p_row -> k);
117
+ ELSE
118
+ -- Nem coluna nem descarte: o que não casa vira metadata, e continua
119
+ -- legível para quem for conferir o porte depois.
120
+ v_extra := v_extra || jsonb_build_object(k, p_row -> k);
121
+ END IF;
122
+ END LOOP;
123
+
124
+ IF v_tem_meta THEN
125
+ v_meta := v_meta || CASE WHEN v_extra = '{}'::jsonb THEN '{}'::jsonb
126
+ ELSE jsonb_build_object('migration', jsonb_build_object('unmapped', v_extra)) END;
127
+ END IF;
128
+
129
+ -- Coluna obrigatória sem valor e sem default é recusa de VALOR, com o nome
130
+ -- dela — e não um erro de constraint no meio do lote.
131
+ SELECT string_agg(a.attname, ', ') INTO v_falta
132
+ FROM pg_attribute a
133
+ WHERE a.attrelid = v_reg AND a.attnum > 0 AND NOT a.attisdropped
134
+ AND a.attnotnull
135
+ AND NOT EXISTS (SELECT 1 FROM pg_attrdef d WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum)
136
+ AND a.attname <> ALL (v_usadas)
137
+ AND a.attname NOT IN ('id', 'tenant_id');
138
+ IF v_falta IS NOT NULL THEN
139
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
140
+ 'reason', format('%s requires %s, and the de-para did not map it', v_target, v_falta));
141
+ END IF;
142
+
143
+ EXECUTE format('SELECT tenant_id FROM %s WHERE id = $1', v_reg) INTO v_existing USING p_id;
144
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
145
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
146
+ 'reason', format('%s %s already exists in another tenant', v_target, p_id));
147
+ END IF;
148
+
149
+ v_payload := v_payload || jsonb_build_object('id', p_id, 'tenant_id', p_tenant);
150
+ IF v_tem_meta THEN v_payload := v_payload || jsonb_build_object('metadata', v_meta); END IF;
151
+
152
+ v_lista := (SELECT string_agg(quote_ident(x), ', ') FROM unnest(
153
+ v_usadas || ARRAY['id', 'tenant_id'] ||
154
+ CASE WHEN v_tem_meta THEN ARRAY['metadata'] ELSE ARRAY[]::text[] END) x);
155
+ v_select := (SELECT string_agg('r.' || quote_ident(x), ', ') FROM unnest(
156
+ v_usadas || ARRAY['id', 'tenant_id'] ||
157
+ CASE WHEN v_tem_meta THEN ARRAY['metadata'] ELSE ARRAY[]::text[] END) x);
158
+ v_set := (SELECT string_agg(format('%I = EXCLUDED.%I', x, x), ', ')
159
+ FROM unnest(v_usadas) x WHERE x <> 'created_at');
160
+ IF v_tem_meta THEN
161
+ v_set := coalesce(v_set || ', ', '') ||
162
+ format('metadata = migration._merge_meta(%s.metadata, EXCLUDED.metadata)', v_reg::text);
163
+ END IF;
164
+ IF v_tem_upd THEN
165
+ v_set := coalesce(v_set || ', ', '') || 'updated_at = now()';
166
+ END IF;
167
+
168
+ -- `jsonb_populate_record` contra o ROWTYPE do destino: a coerção é do
169
+ -- Postgres. Só as colunas que a linha trouxe entram, para as outras ficarem
170
+ -- com o DEFAULT da tabela.
171
+ IF v_set IS NULL OR v_set = '' THEN
172
+ EXECUTE format('INSERT INTO %s (%s) SELECT %s FROM jsonb_populate_record(null::%s, $1) r ON CONFLICT (id) DO NOTHING',
173
+ v_reg, v_lista, v_select, v_reg) USING v_payload;
174
+ ELSE
175
+ EXECUTE format('INSERT INTO %s (%s) SELECT %s FROM jsonb_populate_record(null::%s, $1) r ON CONFLICT (id) DO UPDATE SET %s',
176
+ v_reg, v_lista, v_select, v_reg, v_set) USING v_payload;
177
+ END IF;
178
+ v_created := v_existing IS NULL;
179
+
180
+ IF v_tem_unit THEN
181
+ BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;
182
+ PERFORM migration._set_unit(v_target, p_id, v_unit);
183
+ END IF;
184
+
185
+ IF v_standalone THEN
186
+ PERFORM migration._audit(p_tenant, v_target, p_id,
187
+ jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
188
+ END IF;
189
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
190
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
191
+ END $function$;
192
+
193
+ COMMENT ON FUNCTION migration.upsert_by_shape(uuid, uuid, jsonb, jsonb) IS
194
+ 'Upsert genérico para a tabela de destino cujas colunas já têm o nome que o de-para produz. O alvo vem de writer_args.target e tem de ser target_table de alguma linha da allowlist.';
195
+
196
+ REVOKE ALL ON FUNCTION migration.upsert_by_shape(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;
197
+ GRANT EXECUTE ON FUNCTION migration.upsert_by_shape(uuid, uuid, jsonb, jsonb) TO service_role;
@@ -0,0 +1,155 @@
1
+ -- O `updated_at` só pode ser atribuído uma vez.
2
+ --
3
+ -- `upsert_by_shape` (123) montava o SET do ON CONFLICT com duas atribuições da
4
+ -- mesma coluna quando a linha de origem trazia `updated_at`:
5
+ --
6
+ -- updated_at = EXCLUDED.updated_at (porque a coluna veio mapeada)
7
+ -- updated_at = now() (porque a tabela tem a coluna)
8
+ --
9
+ -- O Postgres recusa: `42601: multiple assignments to same column "updated_at"`.
10
+ -- A primeira onda com o writer genérico devolveu 170 notas fiscais em
11
+ -- quarentena por isso — e, em cascata, os 915 itens, pagamentos e duplicatas
12
+ -- que dependiam delas.
13
+ --
14
+ -- ── E a correção não é escolher `now()` ─────────────────────────────────────
15
+ --
16
+ -- Quando a origem TRAZ `updated_at`, é ele que vale: o porte está copiando uma
17
+ -- linha que mudou lá em 2025, e carimbar `now()` faria toda a história chegar
18
+ -- com a data da migração. O espelho D-1 depende disso — é por `updated_at` que
19
+ -- o cursor decide o que releu.
20
+ --
21
+ -- `now()` fica para quando a origem não diz: aí a única verdade disponível é
22
+ -- "chegou agora".
23
+ --
24
+ -- Idempotente.
25
+
26
+ CREATE OR REPLACE FUNCTION migration.upsert_by_shape(
27
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
28
+ ) RETURNS jsonb
29
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
30
+ AS $function$
31
+ DECLARE
32
+ v_target text := p_args ->> 'target';
33
+ v_reg regclass;
34
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
35
+ v_cols text[];
36
+ v_usadas text[] := ARRAY[]::text[];
37
+ v_extra jsonb := '{}'::jsonb;
38
+ v_payload jsonb := '{}'::jsonb;
39
+ v_tem_meta boolean;
40
+ v_tem_unit boolean;
41
+ v_tem_upd boolean;
42
+ v_unit uuid;
43
+ v_lista text;
44
+ v_select text;
45
+ v_set text;
46
+ v_existing uuid;
47
+ v_created boolean := false;
48
+ v_falta text;
49
+ k text;
50
+ v_standalone boolean := migration._standalone();
51
+ BEGIN
52
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
53
+
54
+ IF p_id IS NULL THEN
55
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a row needs an id');
56
+ END IF;
57
+
58
+ v_reg := to_regclass(v_target);
59
+ IF v_reg IS NULL THEN
60
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
61
+ 'reason', format('the by_shape writer needs a target that exists; got %L', coalesce(v_target, '<null>')));
62
+ END IF;
63
+ IF NOT EXISTS (SELECT 1 FROM migration.allowlist a WHERE a.target_table = v_target) THEN
64
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
65
+ 'reason', format('%s is not a target_table of any allowlist row — this writer only reaches what the de-para declares', v_target));
66
+ END IF;
67
+
68
+ SELECT array_agg(a.attname ORDER BY a.attnum) INTO v_cols
69
+ FROM pg_attribute a
70
+ WHERE a.attrelid = v_reg AND a.attnum > 0 AND NOT a.attisdropped;
71
+
72
+ v_tem_meta := 'metadata' = ANY (v_cols);
73
+ v_tem_unit := 'unit_id' = ANY (v_cols);
74
+ v_tem_upd := 'updated_at' = ANY (v_cols);
75
+
76
+ FOR k IN SELECT jsonb_object_keys(p_row) LOOP
77
+ IF k IN ('id', 'tenant_id', 'metadata') THEN CONTINUE; END IF;
78
+ IF k = ANY (v_cols) THEN
79
+ v_usadas := v_usadas || k;
80
+ v_payload := v_payload || jsonb_build_object(k, p_row -> k);
81
+ ELSE
82
+ v_extra := v_extra || jsonb_build_object(k, p_row -> k);
83
+ END IF;
84
+ END LOOP;
85
+
86
+ IF v_tem_meta THEN
87
+ v_meta := v_meta || CASE WHEN v_extra = '{}'::jsonb THEN '{}'::jsonb
88
+ ELSE jsonb_build_object('migration', jsonb_build_object('unmapped', v_extra)) END;
89
+ END IF;
90
+
91
+ SELECT string_agg(a.attname, ', ') INTO v_falta
92
+ FROM pg_attribute a
93
+ WHERE a.attrelid = v_reg AND a.attnum > 0 AND NOT a.attisdropped
94
+ AND a.attnotnull
95
+ AND NOT EXISTS (SELECT 1 FROM pg_attrdef d WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum)
96
+ AND a.attname <> ALL (v_usadas)
97
+ AND a.attname NOT IN ('id', 'tenant_id');
98
+ IF v_falta IS NOT NULL THEN
99
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
100
+ 'reason', format('%s requires %s, and the de-para did not map it', v_target, v_falta));
101
+ END IF;
102
+
103
+ EXECUTE format('SELECT tenant_id FROM %s WHERE id = $1', v_reg) INTO v_existing USING p_id;
104
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
105
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
106
+ 'reason', format('%s %s already exists in another tenant', v_target, p_id));
107
+ END IF;
108
+
109
+ v_payload := v_payload || jsonb_build_object('id', p_id, 'tenant_id', p_tenant);
110
+ IF v_tem_meta THEN v_payload := v_payload || jsonb_build_object('metadata', v_meta); END IF;
111
+
112
+ v_lista := (SELECT string_agg(quote_ident(x), ', ') FROM unnest(
113
+ v_usadas || ARRAY['id', 'tenant_id'] ||
114
+ CASE WHEN v_tem_meta THEN ARRAY['metadata'] ELSE ARRAY[]::text[] END) x);
115
+ v_select := (SELECT string_agg('r.' || quote_ident(x), ', ') FROM unnest(
116
+ v_usadas || ARRAY['id', 'tenant_id'] ||
117
+ CASE WHEN v_tem_meta THEN ARRAY['metadata'] ELSE ARRAY[]::text[] END) x);
118
+ -- `created_at` fica de fora porque criação não se reescreve. `updated_at` sai
119
+ -- daqui e volta logo abaixo — ou com o valor da ORIGEM, ou com `now()` quando
120
+ -- a origem não diz. Atribuir as duas coisas é o erro 42601 que esta migration
121
+ -- existe para corrigir.
122
+ v_set := (SELECT string_agg(format('%I = EXCLUDED.%I', x, x), ', ')
123
+ FROM unnest(v_usadas) x WHERE x NOT IN ('created_at', 'updated_at'));
124
+ IF v_tem_meta THEN
125
+ v_set := coalesce(v_set || ', ', '') ||
126
+ format('metadata = migration._merge_meta(%s.metadata, EXCLUDED.metadata)', v_reg::text);
127
+ END IF;
128
+ IF v_tem_upd THEN
129
+ v_set := coalesce(v_set || ', ', '') ||
130
+ CASE WHEN 'updated_at' = ANY (v_usadas)
131
+ THEN 'updated_at = EXCLUDED.updated_at'
132
+ ELSE 'updated_at = now()' END;
133
+ END IF;
134
+
135
+ IF v_set IS NULL OR v_set = '' THEN
136
+ EXECUTE format('INSERT INTO %s (%s) SELECT %s FROM jsonb_populate_record(null::%s, $1) r ON CONFLICT (id) DO NOTHING',
137
+ v_reg, v_lista, v_select, v_reg) USING v_payload;
138
+ ELSE
139
+ EXECUTE format('INSERT INTO %s (%s) SELECT %s FROM jsonb_populate_record(null::%s, $1) r ON CONFLICT (id) DO UPDATE SET %s',
140
+ v_reg, v_lista, v_select, v_reg, v_set) USING v_payload;
141
+ END IF;
142
+ v_created := v_existing IS NULL;
143
+
144
+ IF v_tem_unit THEN
145
+ BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;
146
+ PERFORM migration._set_unit(v_target, p_id, v_unit);
147
+ END IF;
148
+
149
+ IF v_standalone THEN
150
+ PERFORM migration._audit(p_tenant, v_target, p_id,
151
+ jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
152
+ END IF;
153
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
154
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
155
+ END $function$;