@fayz-ai/db 0.17.0 → 0.18.1
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.
- package/migrations/120_o_servico_diz_se_ele_se_agenda.sql +76 -0
- package/migrations/121_a_liberacao_pergunta_uma_vez_e_nao_cento_e_vinte_mil.sql +109 -0
- package/migrations/122_o_email_da_pessoa_e_um_email_nao_uma_lista.sql +80 -0
- package/migrations/123_um_writer_para_a_tabela_que_ja_tem_a_forma_certa.sql +197 -0
- package/migrations/124_o_updated_at_so_pode_ser_atribuido_uma_vez.sql +155 -0
- package/migrations/125_a_data_que_fecha_nao_e_a_data_que_so_lembra.sql +160 -0
- package/migrations/126_o_telefone_procurado_nao_tem_parenteses.sql +62 -0
- package/migrations/127_a_configuracao_tambem_se_pergunta_por_unidade.sql +250 -0
- package/migrations/128_a_biblioteca_de_midia_tambem_precisa_de_porta.sql +97 -0
- package/migrations/129_o_login_diz_de_quem_ele_e.sql +83 -0
- package/migrations/130_o_aviso_leva_a_algum_lugar.sql +31 -0
- package/migrations/131_o_salao_fala_o_vocabulario_do_v1.sql +81 -0
- package/migrations/132_o_cadastro_do_salao_fala_como_o_v1.sql +91 -0
- package/migrations/133_a_agenda_do_salao_perde_o_que_nao_e_dela.sql +69 -0
- package/migrations/134_todo_oficio_agrupa_os_seus_cadastros.sql +123 -0
- package/package.json +1 -1
|
@@ -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$;
|