@fayz-ai/db 0.14.0 → 0.16.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.
- package/migrations/011_leadcontrol_joins_the_family.sql +5 -1
- package/migrations/029_o_administrador_enxerga_o_que_existe.sql +167 -0
- package/migrations/031_ticketcontrol_joins_the_family.sql +109 -0
- package/migrations/032_a_marca_e_um_conjunto_de_tokens.sql +291 -0
- package/migrations/041_a_entrega_tem_onde_guardar_a_politica.sql +53 -0
- package/migrations/042_o_token_de_marca_fecha_a_porta_anon.sql +17 -0
- package/migrations/043_fiscalcontrol_joins_the_family.sql +75 -0
- package/migrations/044_o_teste_gratis_dura_trinta_dias.sql +15 -0
- package/migrations/045_o_espelho_manda_enquanto_o_v2_nao_escreveu.sql +519 -0
- package/migrations/046_the_tick_advances_the_flows.sql +77 -0
- package/migrations/047_the_tick_sweeps_the_offers.sql +74 -0
- package/migrations/052_fullcontrol_joins_the_family.sql +213 -0
- package/migrations/053_o_erp_para_de_exigir_o_que_nao_desenha.sql +37 -0
- package/migrations/054_o_arquivo_migrado_ganha_bytes.sql +439 -0
- package/migrations/061_a_linha_sem_data_pode_ser_estrutura.sql +253 -0
- package/migrations/063_o_anexo_ja_registrado_recebe_os_bytes.sql +303 -0
- package/migrations/064_um_arquivo_anexado_a_dezenove_contas.sql +178 -0
- package/migrations/065_apagar_o_sobrevivente_nao_apaga_o_tenant.sql +59 -0
- package/migrations/066_the_port_machinery_closes_its_doors.sql +51 -0
- package/migrations/067_the_erp_keeps_the_front_door.sql +56 -0
- package/migrations/068_o_ledger_encontra_a_linha_pelo_caminho_que_o_writer_usa.sql +55 -0
- package/migrations/069_a_sondagem_de_irma_usa_indice.sql +293 -0
- package/migrations/071_o_sdr_trabalha_o_funil_e_mais_nada.sql +110 -0
- package/migrations/072_o_porte_nao_avisa_a_plataforma_de_uma_venda_antiga.sql +91 -0
- package/migrations/073_a_exclusao_e_contrato_do_cliente_nao_do_cluster.sql +300 -0
- package/migrations/074_a_classificacao_quebrada_nao_leva_o_dinheiro_junto.sql +332 -0
- package/migrations/075_o_apelido_da_referencia_nao_disputa_com_a_variavel.sql +276 -0
- package/migrations/076_o_desligado_tambem_precisa_atravessar.sql +110 -0
- package/migrations/077_a_unidade_escolhida_chega_ao_banco.sql +128 -0
- package/migrations/078_onde_o_profissional_atende_atravessa.sql +71 -0
- package/migrations/079_quem_aparece_na_agenda_e_quem_atende.sql +210 -0
- package/migrations/080_o_contexto_da_requisicao_se_calcula_uma_vez.sql +240 -0
- package/migrations/081_a_permissao_por_unidade_se_resolve_uma_vez_por_unidade.sql +88 -0
- package/migrations/082_a_cerca_tambem_se_lembra.sql +110 -0
- package/migrations/083_quem_e_profissional_continua_agendavel.sql +32 -0
- package/migrations/084_quem_atende_em_varias_nao_cabe_numa_coluna.sql +60 -0
- package/migrations/085_a_unidade_do_profissional_vem_de_onde_ele_atende.sql +47 -0
- package/migrations/086_a_ponte_de_clientes_volta_a_existir.sql +89 -0
- package/migrations/087_a_liberacao_pergunta_ao_indice_antes_da_funcao.sql +79 -0
- package/migrations/088_quem_pode_executar_o_que_o_porte_criou.sql +50 -0
- package/migrations/089_a_memoria_de_transacao_nao_serve_para_isto.sql +86 -0
- package/migrations/090_a_marca_tambem_conta_como_mudanca_de_configuracao.sql +75 -0
- package/migrations/091_o_shell_tambem_e_configuracao_do_tenant.sql +71 -0
- package/migrations/092_a_marca_tem_uma_grafia_so_e_uma_porta_so.sql +159 -0
- package/migrations/093_a_marca_carrega_o_tema_inteiro.sql +179 -0
- package/migrations/094_a_porta_do_dominio_aprende_a_grafia_nova.sql +55 -0
- package/migrations/095_o_aplicativo_tem_um_preset_que_a_conta_estende.sql +98 -0
- package/migrations/096_o_sublinhado_tambem_e_nome_de_modulo.sql +100 -0
- package/migrations/097_o_oficio_diz_o_que_oferece_e_a_conta_o_que_usa.sql +137 -0
- package/migrations/098_a_arvore_do_oficio_nomeia_quem_ainda_desenha.sql +22 -0
- package/migrations/099_o_estoque_mora_dentro_de_produtos.sql +48 -0
- package/migrations/100_o_chefcontrol_desenha_abas_de_modulo.sql +24 -0
- package/migrations/101_o_oficio_agrupa_o_plugin_nomeia.sql +93 -0
- package/migrations/102_tres_marcas_ganham_fundo_e_contraste.sql +73 -0
- package/migrations/103_great_djs_e_iam_club_entram_na_frota.sql +75 -0
- package/migrations/104_o_curso_e_a_bilheteria_ganham_preset.sql +29 -0
- package/migrations/105_o_que_o_oficio_exige_ele_tambem_oferece.sql +78 -0
- package/migrations/106_a_comunidade_se_gere_como_escola.sql +84 -0
- package/migrations/107_o_logo_da_coluna_e_projecao_do_documento.sql +53 -0
- package/migrations/108_o_modulo_do_oficio_vizinho_tem_onde_ser_visto.sql +78 -0
- package/migrations/109_a_conta_diz_o_que_nao_usa_e_a_marca_pinta_a_casa.sql +67 -0
- package/migrations/110_a_tabela_de_ramos_aprende_o_vocabulario_fechado.sql +60 -0
- package/migrations/111_o_erp_generico_nao_vende_por_pedido.sql +50 -0
- package/migrations/112_o_cache_do_contexto_sabe_de_quem_ele_e.sql +327 -0
- package/migrations/113_o_ramo_volta_a_saber_que_papeis_a_conta_nasce_tendo.sql +124 -0
- package/migrations/114_a_resposta_inicial_volta_a_dizer_o_que_a_pessoa_pode.sql +93 -0
- package/migrations/115_o_rail_e_de_quem_tem_um.sql +108 -0
- package/package.json +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
-- A sondagem de irmã usa índice.
|
|
2
|
+
--
|
|
3
|
+
-- Com o índice da 065 no lugar, a carga do Espaço Facial passou a andar a
|
|
4
|
+
-- **21 linhas por segundo** — 18 horas para 1,4 milhão. O índice novo estava
|
|
5
|
+
-- sendo usado; o problema era outra consulta, dentro do mesmo
|
|
6
|
+
-- `migration._upsert_row_impl`, e ela é a que roda no caminho MAIS COMUM de
|
|
7
|
+
-- todos: o da linha que já migrou e não mudou.
|
|
8
|
+
--
|
|
9
|
+
-- ── A consulta ──────────────────────────────────────────────────────────────
|
|
10
|
+
--
|
|
11
|
+
-- Toda vez que a origem reoferece uma linha com `status = 'migrated'`, o motor
|
|
12
|
+
-- pergunta se existe uma IRMÃ — a mesma linha da origem gravada com outro
|
|
13
|
+
-- destino, ainda `pending` ou `skipped`. É o que faz a fatura de uma Venda
|
|
14
|
+
-- nascer numa segunda passada, quando o writer dela não existia na primeira.
|
|
15
|
+
-- A pergunta é necessária. A forma dela não era:
|
|
16
|
+
--
|
|
17
|
+
-- AND s.source_numeric_id IS NOT DISTINCT FROM l.source_numeric_id
|
|
18
|
+
-- AND s.source_uuid IS NOT DISTINCT FROM l.source_uuid
|
|
19
|
+
--
|
|
20
|
+
-- `IS NOT DISTINCT FROM` não é um operador indexável. O planejador não tem como
|
|
21
|
+
-- transformá-lo em condição de índice, então ele desce os dois como FILTRO
|
|
22
|
+
-- sobre tudo o que a tabela e o projeto delimitam. Medido em 19/09/2026, com
|
|
23
|
+
-- 298 mil linhas na ledger:
|
|
24
|
+
--
|
|
25
|
+
-- Index Scan using ledger_tenant_source_idx
|
|
26
|
+
-- Index Cond: (source_project = … AND source_table = 'clients')
|
|
27
|
+
-- Filter: ((NOT (source_numeric_id IS DISTINCT FROM 12345)) AND …)
|
|
28
|
+
-- Rows Removed by Filter: 123535 ← a tabela inteira
|
|
29
|
+
-- Buffers: shared hit=8467
|
|
30
|
+
-- Execution Time: 83.592 ms
|
|
31
|
+
--
|
|
32
|
+
-- **83 ms e 66 MB por linha oferecida.** Em `clients` isso é 123.535 linhas
|
|
33
|
+
-- lidas para responder uma pergunta cuja resposta é quase sempre "não".
|
|
34
|
+
--
|
|
35
|
+
-- ── O conserto ──────────────────────────────────────────────────────────────
|
|
36
|
+
--
|
|
37
|
+
-- Duas formas da mesma pergunta, escolhidas por qual chave a linha tem. Cada
|
|
38
|
+
-- ramo é um qual simples, e cada um cai num índice que já existia:
|
|
39
|
+
--
|
|
40
|
+
-- numérica → ledger_source_numeric_key (source_project, source_table, source_numeric_id, …)
|
|
41
|
+
-- uuid → ledger_source_uuid_idx (source_project, source_table, source_uuid)
|
|
42
|
+
--
|
|
43
|
+
-- O ramo numérico deixa de repetir o casamento de uuid, e isso NÃO muda o
|
|
44
|
+
-- conjunto: dentro de (projeto, tabela) o id numérico já identifica a linha da
|
|
45
|
+
-- origem — as irmãs no ledger são essa mesma linha com outro `target_table`.
|
|
46
|
+
--
|
|
47
|
+
-- A mesma correção vale para a segunda consulta com o mesmo operador, a que
|
|
48
|
+
-- procura a linha de ledger de cada destino extra depois que o writer devolve
|
|
49
|
+
-- `extra` (o caminho da fatura da Venda).
|
|
50
|
+
--
|
|
51
|
+
-- Nada muda no que o motor decide. O que muda é o preço de decidir.
|
|
52
|
+
--
|
|
53
|
+
-- Idempotente: substitui a função inteira.
|
|
54
|
+
|
|
55
|
+
CREATE OR REPLACE FUNCTION migration._upsert_row_impl(p_batch uuid, p_source_table text, p_source_numeric_id bigint, p_source_uuid uuid, p_cursor text, p_row jsonb)
|
|
56
|
+
RETURNS jsonb
|
|
57
|
+
LANGUAGE plpgsql
|
|
58
|
+
SECURITY DEFINER
|
|
59
|
+
SET search_path TO ''
|
|
60
|
+
AS $function$
|
|
61
|
+
DECLARE
|
|
62
|
+
b migration.batches%ROWTYPE;
|
|
63
|
+
a migration.allowlist%ROWTYPE;
|
|
64
|
+
l migration.ledger%ROWTYPE;
|
|
65
|
+
v_checksum text := md5(p_row::text);
|
|
66
|
+
v_target_table text;
|
|
67
|
+
v_target_id uuid;
|
|
68
|
+
v_mapped jsonb; v_row jsonb; v_meta jsonb; v_unres jsonb;
|
|
69
|
+
v_dest jsonb; v_fin jsonb;
|
|
70
|
+
v_res jsonb; v_status text; v_col text; v_excl text;
|
|
71
|
+
v_retry_split boolean := false;
|
|
72
|
+
-- O V1 manda: ver migration.is_mirroring() e o cabeçalho deste arquivo.
|
|
73
|
+
v_mirror boolean := false;
|
|
74
|
+
x jsonb; v_xid bigint;
|
|
75
|
+
BEGIN
|
|
76
|
+
SELECT * INTO b FROM migration.batches WHERE id = p_batch;
|
|
77
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'upsert_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
|
|
78
|
+
IF b.status <> 'running' THEN
|
|
79
|
+
RAISE EXCEPTION 'upsert_row: batch % is % (only a running batch accepts rows)', p_batch, b.status USING ERRCODE = '55000';
|
|
80
|
+
END IF;
|
|
81
|
+
PERFORM migration._assert_fence(b.tenant_id);
|
|
82
|
+
|
|
83
|
+
v_mirror := coalesce(migration.is_mirroring(b.tenant_id), false);
|
|
84
|
+
|
|
85
|
+
SELECT * INTO a FROM migration.allowlist WHERE source_table = p_source_table;
|
|
86
|
+
v_target_table := a.target_table;
|
|
87
|
+
|
|
88
|
+
SELECT * INTO l FROM migration.ledger
|
|
89
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
90
|
+
AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
|
|
91
|
+
OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
|
|
92
|
+
AND coalesce(target_table, '') = coalesce(v_target_table, '')
|
|
93
|
+
FOR UPDATE;
|
|
94
|
+
IF NOT FOUND THEN
|
|
95
|
+
INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, checksum, cursor, batch_id, status)
|
|
96
|
+
VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, v_target_table, v_checksum, p_cursor, p_batch, 'pending')
|
|
97
|
+
RETURNING * INTO l;
|
|
98
|
+
ELSIF l.tenant_id <> b.tenant_id THEN
|
|
99
|
+
RETURN migration._quarantine(l.id, p_batch, 'tenant',
|
|
100
|
+
format('source row already belongs to tenant %s; this batch migrates tenant %s', l.tenant_id, b.tenant_id), p_row);
|
|
101
|
+
END IF;
|
|
102
|
+
|
|
103
|
+
-- allowlist
|
|
104
|
+
IF p_source_table LIKE 'import\_ef\_%' OR EXISTS (SELECT 1 FROM migration.excluded_tables e WHERE e.source_table = p_source_table) THEN
|
|
105
|
+
SELECT reason INTO v_excl FROM migration.excluded_tables e WHERE e.source_table = CASE WHEN p_source_table LIKE 'import\_ef\_%' THEN 'import_ef_*' ELSE p_source_table END;
|
|
106
|
+
RETURN migration._quarantine(l.id, p_batch, 'allowlist', 'source table is explicitly excluded: ' || coalesce(v_excl, 'no reason recorded'), p_row,
|
|
107
|
+
NULL, 'recorded decision: the table is in migration.excluded_tables');
|
|
108
|
+
END IF;
|
|
109
|
+
IF a.source_table IS NULL THEN
|
|
110
|
+
RETURN migration._quarantine(l.id, p_batch, 'allowlist', format('source table %s is not in the core-A allowlist', p_source_table), p_row);
|
|
111
|
+
END IF;
|
|
112
|
+
-- tenant identity of the source row vs the batch
|
|
113
|
+
IF b.source_tenant_id IS NOT NULL AND (p_row ->> 'tenant_id') ~ '^\d+$' AND (p_row ->> 'tenant_id')::bigint <> b.source_tenant_id THEN
|
|
114
|
+
RETURN migration._quarantine(l.id, p_batch, 'tenant',
|
|
115
|
+
format('source row tenant_id %s differs from the batch source_tenant_id %s', p_row ->> 'tenant_id', b.source_tenant_id), p_row);
|
|
116
|
+
END IF;
|
|
117
|
+
|
|
118
|
+
-- idempotency + delta
|
|
119
|
+
IF EXISTS (SELECT 1 FROM migration.quarantine q WHERE q.ledger_id = l.id AND q.resolved_at IS NULL AND md5(q.payload::text) = v_checksum) THEN
|
|
120
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
121
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', 'quarantined and unresolved: ' || coalesce(l.reason, ''),
|
|
122
|
+
'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
123
|
+
END IF;
|
|
124
|
+
IF l.status = 'migrated' THEN
|
|
125
|
+
-- a split destination that did not exist on the first offer (invoice) and exists now: run the writer again
|
|
126
|
+
-- Duas formas da MESMA pergunta, e não uma com IS NOT DISTINCT FROM: esse
|
|
127
|
+
-- operador não vira condição de índice, então a sondagem virava varredura
|
|
128
|
+
-- da ledger inteira daquela tabela — 83 ms e 66 MB POR LINHA oferecida,
|
|
129
|
+
-- medido com 123.535 linhas de `clients`. Aqui cada ramo é um qual simples
|
|
130
|
+
-- sobre a chave que existe, e os dois têm índice:
|
|
131
|
+
-- numérica → ledger_source_numeric_key (source_project, source_table, source_numeric_id, …)
|
|
132
|
+
-- uuid → ledger_source_uuid_idx (source_project, source_table, source_uuid)
|
|
133
|
+
-- O ramo numérico não repete o casamento de uuid porque não precisa: dentro
|
|
134
|
+
-- de (projeto, tabela) o id numérico JÁ identifica a linha da origem, e as
|
|
135
|
+
-- irmãs no ledger são a mesma linha com outro destino.
|
|
136
|
+
IF l.source_numeric_id IS NOT NULL THEN
|
|
137
|
+
SELECT EXISTS (SELECT 1 FROM migration.ledger s
|
|
138
|
+
WHERE s.source_project = l.source_project AND s.source_table = l.source_table
|
|
139
|
+
AND s.source_numeric_id = l.source_numeric_id
|
|
140
|
+
AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
|
|
141
|
+
ELSE
|
|
142
|
+
SELECT EXISTS (SELECT 1 FROM migration.ledger s
|
|
143
|
+
WHERE s.source_project = l.source_project AND s.source_table = l.source_table
|
|
144
|
+
AND s.source_uuid = l.source_uuid
|
|
145
|
+
AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
|
|
146
|
+
END IF;
|
|
147
|
+
IF l.checksum = v_checksum AND NOT v_retry_split THEN
|
|
148
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
149
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', 'unchanged', 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
150
|
+
END IF;
|
|
151
|
+
IF l.checksum <> v_checksum AND migration.cursor_cmp(p_cursor, l.cursor) < 0 THEN
|
|
152
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
153
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', format('stale cursor: %s < %s already migrated', p_cursor, l.cursor),
|
|
154
|
+
'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
155
|
+
END IF;
|
|
156
|
+
END IF;
|
|
157
|
+
|
|
158
|
+
-- no writer yet: recorded, re-offered later
|
|
159
|
+
IF a.writer IS NULL THEN
|
|
160
|
+
UPDATE migration.ledger SET status = 'pending', reason = format('no destination writer for %s yet', v_target_table),
|
|
161
|
+
checksum = v_checksum, cursor = p_cursor, batch_id = p_batch WHERE id = l.id;
|
|
162
|
+
PERFORM migration._count(p_batch, 'pending');
|
|
163
|
+
RETURN jsonb_build_object('status', 'pending', 'reason', format('no destination writer for %s yet', v_target_table),
|
|
164
|
+
'target_table', v_target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
165
|
+
END IF;
|
|
166
|
+
|
|
167
|
+
-- map + references
|
|
168
|
+
v_mapped := migration.map_row(b.source_project, p_source_table, p_row);
|
|
169
|
+
v_row := v_mapped -> 'row'; v_meta := v_mapped -> 'metadata'; v_unres := v_mapped -> 'unresolved';
|
|
170
|
+
IF jsonb_array_length(v_unres) > 0 THEN
|
|
171
|
+
RETURN migration._quarantine(l.id, p_batch, 'fk', 'unresolved reference(s): ' || v_unres::text, p_row);
|
|
172
|
+
END IF;
|
|
173
|
+
|
|
174
|
+
-- destination id (ADR 0007): the source uuid, else the id minted on the first offer
|
|
175
|
+
v_target_id := coalesce(l.target_id, p_source_uuid, gen_random_uuid());
|
|
176
|
+
v_meta := jsonb_set(v_meta, '{migration}', coalesce(v_meta -> 'migration', '{}'::jsonb) || jsonb_build_object(
|
|
177
|
+
'source_project', b.source_project, 'source_table', p_source_table, 'source_numeric_id', p_source_numeric_id,
|
|
178
|
+
'source_uuid', p_source_uuid, 'batch_id', p_batch), true);
|
|
179
|
+
v_row := v_row || jsonb_build_object('metadata', v_meta);
|
|
180
|
+
SELECT coalesce(jsonb_object_agg(c, v_row -> c), '{}'::jsonb) INTO v_fin FROM unnest(a.financial_columns) c WHERE v_row ? c;
|
|
181
|
+
|
|
182
|
+
-- As duas travas do corte. Em regime de espelho elas ficam de fora, e é a única
|
|
183
|
+
-- diferença de comportamento deste arquivo:
|
|
184
|
+
--
|
|
185
|
+
-- valor financeiro o V1 é a fonte da verdade enquanto o V2 não escreveu;
|
|
186
|
+
-- recusar a atualização deixaria o V2 com o preço velho de
|
|
187
|
+
-- uma venda que foi corrigida ontem, em silêncio.
|
|
188
|
+
-- linha apagada quem apaga no V2 durante o espelho é o retire_row, e ele
|
|
189
|
+
-- marca o ledger como `retired`. Uma linha ainda
|
|
190
|
+
-- `migrated` cujo destino sumiu foi apagada por fora — e
|
|
191
|
+
-- recriá-la é justamente refletir o V1, que ainda a tem.
|
|
192
|
+
IF l.status = 'migrated' AND NOT v_mirror THEN
|
|
193
|
+
IF cardinality(a.financial_columns) > 0 THEN
|
|
194
|
+
v_dest := migration._destination_values(v_target_table, l.target_id, a.financial_columns);
|
|
195
|
+
IF v_dest IS NULL THEN
|
|
196
|
+
RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
|
|
197
|
+
END IF;
|
|
198
|
+
FOREACH v_col IN ARRAY a.financial_columns LOOP
|
|
199
|
+
IF (v_row ? v_col) AND round(coalesce((v_row ->> v_col)::numeric, 0), 2) IS DISTINCT FROM round(coalesce((v_dest ->> v_col)::numeric, 0), 2) THEN
|
|
200
|
+
RETURN migration._quarantine(l.id, p_batch, 'value',
|
|
201
|
+
format('financial column %s: destination %s, incoming %s — an existing financial value is never overwritten', v_col, coalesce(v_dest ->> v_col, 'null'), coalesce(v_row ->> v_col, 'null')), p_row);
|
|
202
|
+
END IF;
|
|
203
|
+
END LOOP;
|
|
204
|
+
ELSIF migration._destination_values(v_target_table, l.target_id, ARRAY['id']) IS NULL THEN
|
|
205
|
+
RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
|
|
206
|
+
END IF;
|
|
207
|
+
END IF;
|
|
208
|
+
|
|
209
|
+
-- delegate; any error is a state quarantine, never a partial write
|
|
210
|
+
BEGIN
|
|
211
|
+
-- Dispatch by name rather than by a CASE arm per writer. The CASE meant every
|
|
212
|
+
-- new destination writer had to re-emit this whole function, and a row naming
|
|
213
|
+
-- a writer the CASE did not list fell through to NULL — silently, which is
|
|
214
|
+
-- the failure mode this schema exists to prevent. The allowlist's CHECK is
|
|
215
|
+
-- the vocabulary, `migration` is definer-only, and the name is verified to
|
|
216
|
+
-- exist before it is called, so an allowlist row naming a writer nobody built
|
|
217
|
+
-- quarantines with that sentence instead of vanishing.
|
|
218
|
+
IF to_regprocedure(format('migration.upsert_%s(uuid,uuid,jsonb,jsonb)', a.writer)) IS NULL THEN
|
|
219
|
+
RETURN migration._quarantine(l.id, p_batch, 'state',
|
|
220
|
+
format('the allowlist names writer %L for %s, and migration.upsert_%s does not exist', a.writer, p_source_table, a.writer),
|
|
221
|
+
p_row, v_target_table);
|
|
222
|
+
END IF;
|
|
223
|
+
-- %I on the whole identifier, not on the suffix: format('upsert_%I', 'order')
|
|
224
|
+
-- yields upsert_"order", which is not the function's name.
|
|
225
|
+
EXECUTE format('SELECT migration.%I($1, $2, $3, $4)', 'upsert_' || a.writer)
|
|
226
|
+
INTO v_res USING b.tenant_id, v_target_id, v_row, a.writer_args;
|
|
227
|
+
EXCEPTION WHEN OTHERS THEN
|
|
228
|
+
v_res := jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
|
|
229
|
+
END;
|
|
230
|
+
v_status := coalesce(v_res ->> 'status', 'quarantined');
|
|
231
|
+
|
|
232
|
+
IF v_status = 'quarantined' THEN
|
|
233
|
+
RETURN migration._quarantine(l.id, p_batch, coalesce(v_res ->> 'kind', 'state'), coalesce(v_res ->> 'reason', 'writer refused'), p_row);
|
|
234
|
+
END IF;
|
|
235
|
+
IF v_status = 'skipped' THEN
|
|
236
|
+
UPDATE migration.ledger SET status = 'skipped', reason = v_res ->> 'reason', checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
|
|
237
|
+
target_id = coalesce((v_res ->> 'target_id')::uuid, target_id) WHERE id = l.id;
|
|
238
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
239
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_res ->> 'target_id', 'ledger_id', l.id);
|
|
240
|
+
END IF;
|
|
241
|
+
|
|
242
|
+
v_target_id := coalesce((v_res ->> 'target_id')::uuid, v_target_id);
|
|
243
|
+
UPDATE migration.ledger
|
|
244
|
+
SET status = 'migrated', target_table = v_target_table, target_id = v_target_id, checksum = v_checksum, cursor = p_cursor,
|
|
245
|
+
batch_id = p_batch, reason = v_res ->> 'reason', financial = nullif(v_fin, '{}'::jsonb)
|
|
246
|
+
WHERE id = l.id;
|
|
247
|
+
UPDATE migration.quarantine SET resolved_at = now(), resolution = 'row migrated on a later offer' WHERE ledger_id = l.id AND resolved_at IS NULL;
|
|
248
|
+
|
|
249
|
+
-- split rows (a Venda's invoice): one more ledger row per extra destination
|
|
250
|
+
FOR x IN SELECT value FROM jsonb_array_elements(coalesce(v_res -> 'extra', '[]'::jsonb)) LOOP
|
|
251
|
+
-- Mesma correção da sondagem de irmã, pelo mesmo motivo: com
|
|
252
|
+
-- IS NOT DISTINCT FROM nenhum dos dois índices da ledger é usado, e esta
|
|
253
|
+
-- busca acontece uma vez por destino extra de cada Venda.
|
|
254
|
+
IF p_source_numeric_id IS NOT NULL THEN
|
|
255
|
+
SELECT id INTO v_xid FROM migration.ledger
|
|
256
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
257
|
+
AND source_numeric_id = p_source_numeric_id
|
|
258
|
+
AND target_table = x ->> 'target_table';
|
|
259
|
+
ELSE
|
|
260
|
+
SELECT id INTO v_xid FROM migration.ledger
|
|
261
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
262
|
+
AND source_numeric_id IS NULL AND source_uuid = p_source_uuid
|
|
263
|
+
AND target_table = x ->> 'target_table';
|
|
264
|
+
END IF;
|
|
265
|
+
IF v_xid IS NULL THEN
|
|
266
|
+
INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, target_id, checksum, cursor, batch_id, status, reason, financial)
|
|
267
|
+
VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, x ->> 'target_table', (x ->> 'target_id')::uuid,
|
|
268
|
+
v_checksum, p_cursor, p_batch, x ->> 'status', x ->> 'reason', nullif(x -> 'financial', 'null'::jsonb));
|
|
269
|
+
ELSE
|
|
270
|
+
UPDATE migration.ledger SET target_id = (x ->> 'target_id')::uuid, checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
|
|
271
|
+
status = x ->> 'status', reason = x ->> 'reason', financial = coalesce(nullif(x -> 'financial', 'null'::jsonb), financial) WHERE id = v_xid;
|
|
272
|
+
END IF;
|
|
273
|
+
END LOOP;
|
|
274
|
+
|
|
275
|
+
INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
276
|
+
VALUES (b.tenant_id, NULL, 'migration.upsert', v_target_table, v_target_id::text,
|
|
277
|
+
jsonb_build_object('batch_id', p_batch, 'source_project', b.source_project, 'source_table', p_source_table,
|
|
278
|
+
'source_numeric_id', p_source_numeric_id, 'source_uuid', p_source_uuid, 'reason', v_res ->> 'reason'));
|
|
279
|
+
PERFORM migration._count(p_batch, 'migrated');
|
|
280
|
+
RETURN jsonb_build_object('status', 'migrated', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_target_id,
|
|
281
|
+
'ledger_id', l.id, 'merged', coalesce((v_res ->> 'merged')::boolean, false), 'extra', coalesce(v_res -> 'extra', '[]'::jsonb));
|
|
282
|
+
END $function$
|
|
283
|
+
|
|
284
|
+
;
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
-- ── quem pode executar ────────────────────────────────────────────────────
|
|
288
|
+
--
|
|
289
|
+
-- Função nova nasce executável por PUBLIC, e `anon`/`authenticated` herdam
|
|
290
|
+
-- dali. Estas são de PORTE: SECURITY DEFINER, escrevem dados de qualquer
|
|
291
|
+
-- inquilino. Só o service_role, que é quem roda a onda.
|
|
292
|
+
REVOKE ALL ON FUNCTION migration._upsert_row_impl(uuid, text, bigint, uuid, text, jsonb) FROM PUBLIC, anon, authenticated;
|
|
293
|
+
GRANT EXECUTE ON FUNCTION migration._upsert_row_impl(uuid, text, bigint, uuid, text, jsonb) TO service_role;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 071_o_sdr_trabalha_o_funil_e_mais_nada.sql — o papel que faltava entre o
|
|
3
|
+
-- gerente e o operador de loja.
|
|
4
|
+
--
|
|
5
|
+
-- Quem trabalha uma lista de leads não é nenhum dos quatro que o template
|
|
6
|
+
-- `generic` oferece hoje:
|
|
7
|
+
--
|
|
8
|
+
-- Administrator lê o catálogo inteiro menos o que é do dono (029)
|
|
9
|
+
-- Manager 44 permissões, incluindo financeiro, estoque e catálogo
|
|
10
|
+
-- Staff chão de operação — agenda, pedidos, catálogo. ZERO de CRM
|
|
11
|
+
-- Viewer só leitura, e de tudo
|
|
12
|
+
--
|
|
13
|
+
-- O sintoma que trouxe esta migration: um vendedor entrou como `Staff` e a
|
|
14
|
+
-- tela de CRM respondeu "Acesso restrito". A saída à mão foi promovê-lo a
|
|
15
|
+
-- Administrator — 211 permissões para quem precisa de dezenove, incluindo
|
|
16
|
+
-- apagar conta a pagar e mexer em configuração de unidade.
|
|
17
|
+
--
|
|
18
|
+
-- A alternativa errada seria dar CRM ao `Staff`. `Staff` é o papel do balcão em
|
|
19
|
+
-- TODA casa do cluster; acrescentar `crm.leads.read` ali entrega a lista de
|
|
20
|
+
-- leads a todo operador de loja de todo cliente.
|
|
21
|
+
--
|
|
22
|
+
-- ── por que uma lista, e não o catálogo ─────────────────────────────────
|
|
23
|
+
--
|
|
24
|
+
-- A 029 dividiu os papéis em dois regimes e a divisão continua valendo: Owner e
|
|
25
|
+
-- Administrator leem o CATÁLOGO, e crescem sozinhos quando um plugin novo
|
|
26
|
+
-- registra permissões; manager, staff e viewer são RECORTES, escritos à mão,
|
|
27
|
+
-- que precisam de decisão a cada plugin. O SDR é recorte pela mesma razão que
|
|
28
|
+
-- o viewer é: um SDR que enxerga tudo o que existe deixou de ser SDR.
|
|
29
|
+
--
|
|
30
|
+
-- O preço é o que a 029 já nomeou — quando o CRM ganhar uma tela nova, alguém
|
|
31
|
+
-- precisa lembrar deste papel. É o preço de um recorte ser deliberado.
|
|
32
|
+
--
|
|
33
|
+
-- ── o que ele pode, e o porquê de cada grupo ────────────────────────────
|
|
34
|
+
--
|
|
35
|
+
-- sales.read/edit a rota `/sales` pede `sales.read`; sem ela a
|
|
36
|
+
-- tela inteira responde "Acesso restrito"
|
|
37
|
+
-- crm.leads.* ler, criar e editar. NÃO apagar, NÃO `manage`:
|
|
38
|
+
-- arquivar em massa é decisão de quem responde
|
|
39
|
+
-- pela base, não de quem trabalha ela
|
|
40
|
+
-- crm.pipeline.read/edit mover o card entre etapas. Criar e apagar ETAPA
|
|
41
|
+
-- é desenhar o processo — vocabulário do gerente
|
|
42
|
+
-- crm.quotes.read/create o SDR levanta o orçamento; aprovar e apagar não
|
|
43
|
+
-- people.read/create/edit um lead É uma linha de `people`. Sem isto a
|
|
44
|
+
-- lista não carrega. Sem `read_all` de propósito:
|
|
45
|
+
-- é a chave que abre a base inteira de pessoas
|
|
46
|
+
-- conversations.read/create falar com o lead é o trabalho, não um extra
|
|
47
|
+
-- dashboard.read o painel de onde ele vê o próprio dia
|
|
48
|
+
-- config/tenancy/tenant/units .read a casca: sem elas o shell não desenha
|
|
49
|
+
--
|
|
50
|
+
-- E o que ele NÃO pode, dito por extenso porque é o ponto do papel: financeiro,
|
|
51
|
+
-- estoque, catálogo, pedidos, agenda, marketing, relatórios, membros, papéis,
|
|
52
|
+
-- configurações. Nada fora do funil.
|
|
53
|
+
--
|
|
54
|
+
-- ── só no template `generic` ────────────────────────────────────────────
|
|
55
|
+
--
|
|
56
|
+
-- `restaurant`, `salon` e `clinic` têm vocabulários próprios — Garçom, Recepção,
|
|
57
|
+
-- Profissional — e um SDR não é papel de nenhuma dessas casas. Se um dia for,
|
|
58
|
+
-- é outra linha, tomada por quem conhece a operação delas.
|
|
59
|
+
-- ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
-- ── o recorte ──────────────────────────────────────────────────────────────
|
|
62
|
+
INSERT INTO app.role_templates (template, role_key, role_name, sort_order, permission)
|
|
63
|
+
SELECT 'generic', 'sdr', 'SDR', 3, p
|
|
64
|
+
FROM unnest(ARRAY[
|
|
65
|
+
'sales.read', 'sales.edit',
|
|
66
|
+
'crm.leads.read', 'crm.leads.create', 'crm.leads.edit',
|
|
67
|
+
'crm.pipeline.read', 'crm.pipeline.edit',
|
|
68
|
+
'crm.quotes.read', 'crm.quotes.create',
|
|
69
|
+
'people.read', 'people.create', 'people.edit',
|
|
70
|
+
'conversations.read', 'conversations.create',
|
|
71
|
+
'dashboard.read',
|
|
72
|
+
'config.read', 'tenancy.read', 'tenant.read', 'units.read'
|
|
73
|
+
]) AS p
|
|
74
|
+
-- Só o que o catálogo realmente tem. Uma chave inventada aqui vira uma
|
|
75
|
+
-- permissão que nenhuma tela consulta: silenciosa, e por isso pior que um erro.
|
|
76
|
+
WHERE EXISTS (SELECT 1 FROM app.permissions perm WHERE perm.key = p)
|
|
77
|
+
ON CONFLICT DO NOTHING;
|
|
78
|
+
|
|
79
|
+
-- ── as casas que já existem ────────────────────────────────────────────────
|
|
80
|
+
--
|
|
81
|
+
-- A tabela acima serve quem nascer daqui para frente: `seed_role_template` lê
|
|
82
|
+
-- dela. Quem já nasceu precisa da mesma conta agora, e pelo mesmo caminho que a
|
|
83
|
+
-- 029 usou — criar o papel, depois conceder.
|
|
84
|
+
--
|
|
85
|
+
-- O alvo é quem TEM o recorte `generic`, reconhecido pela presença do papel
|
|
86
|
+
-- `staff`. Uma clínica não ganha um SDR por causa desta migration.
|
|
87
|
+
DO $$
|
|
88
|
+
DECLARE v_roles integer; v_perms integer;
|
|
89
|
+
BEGIN
|
|
90
|
+
INSERT INTO app.roles (tenant_id, key, name, is_system)
|
|
91
|
+
SELECT DISTINCT r.tenant_id, 'sdr', 'SDR', true
|
|
92
|
+
FROM app.roles r
|
|
93
|
+
WHERE r.key = 'staff'
|
|
94
|
+
AND EXISTS (SELECT 1 FROM app.roles a WHERE a.tenant_id = r.tenant_id AND a.key = 'admin')
|
|
95
|
+
ON CONFLICT (tenant_id, key) DO NOTHING;
|
|
96
|
+
GET DIAGNOSTICS v_roles = ROW_COUNT;
|
|
97
|
+
|
|
98
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission)
|
|
99
|
+
SELECT r.tenant_id, r.id, t.permission
|
|
100
|
+
FROM app.roles r
|
|
101
|
+
JOIN app.role_templates t ON t.template = 'generic' AND t.role_key = 'sdr'
|
|
102
|
+
WHERE r.key = 'sdr'
|
|
103
|
+
ON CONFLICT DO NOTHING;
|
|
104
|
+
GET DIAGNOSTICS v_perms = ROW_COUNT;
|
|
105
|
+
|
|
106
|
+
RAISE NOTICE '071: % papel(is) SDR criado(s), % permissão(ões) concedida(s)', v_roles, v_perms;
|
|
107
|
+
END $$;
|
|
108
|
+
|
|
109
|
+
COMMENT ON TABLE app.role_templates IS
|
|
110
|
+
'Os recortes deliberados de cada template. Owner e admin leem o catálogo (029); manager, sdr, staff e viewer são listas, e cada plugin novo pede uma decisão sobre elas (071).';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
-- O porte não avisa a plataforma de uma venda de dois anos atrás.
|
|
2
|
+
--
|
|
3
|
+
-- `trg_orders_emit_completed` emite `order.completed` toda vez que um pedido
|
|
4
|
+
-- fecha. Está certo: é assim que a automação, a notificação e a integração
|
|
5
|
+
-- ficam sabendo que houve uma venda.
|
|
6
|
+
--
|
|
7
|
+
-- Só que o porte também fecha pedidos — 166.301 deles no Espaço Facial, todos
|
|
8
|
+
-- de vendas que aconteceram entre 2025 e ontem. O gatilho não distinguia, e a
|
|
9
|
+
-- plataforma foi informada de que **166 mil vendas acabaram de acontecer**.
|
|
10
|
+
--
|
|
11
|
+
-- ── Duas consequências, e a segunda é pior ──────────────────────────────────
|
|
12
|
+
--
|
|
13
|
+
-- A visível é o custo. Medido em 20/09/2026:
|
|
14
|
+
--
|
|
15
|
+
-- event_log 11.981 linhas → 157.642 linhas (113 MB)
|
|
16
|
+
-- pendentes (`recorded`) 101.225
|
|
17
|
+
-- vazão do runner (`event_runner_tick`) 200/minuto
|
|
18
|
+
--
|
|
19
|
+
-- Oito horas de escrita contínua num disco que já estava sem crédito de
|
|
20
|
+
-- rajada — e foi essa fila, somada à carga, que manteve a instância
|
|
21
|
+
-- inoperante mesmo depois de o porte ser interrompido.
|
|
22
|
+
--
|
|
23
|
+
-- A invisível é o significado. Um evento não é um registro: é um AVISO. Quem
|
|
24
|
+
-- assina `order.completed` pode mandar mensagem para o cliente, lançar
|
|
25
|
+
-- comissão, chamar um webhook. Um porte que dispara esse aviso 166 mil vezes
|
|
26
|
+
-- não está sendo lento — está mentindo sobre o presente.
|
|
27
|
+
--
|
|
28
|
+
-- ── A regra ─────────────────────────────────────────────────────────────────
|
|
29
|
+
--
|
|
30
|
+
-- O motor já marca as escritas dele: `migration.writer = 'on'`, um GUC local
|
|
31
|
+
-- que todo `upsert_*` liga antes de escrever e que morre com a transação. O
|
|
32
|
+
-- gatilho passa a olhar para ele.
|
|
33
|
+
--
|
|
34
|
+
-- Isso não silencia o evento para sempre nem "desliga trigger na carga": o
|
|
35
|
+
-- fato continua gravado em `public.orders`, e qualquer fechamento feito pelo
|
|
36
|
+
-- APP — que nunca liga esse GUC — emite como antes. O que sai é só o aviso
|
|
37
|
+
-- sobre um fato que já era passado quando chegou aqui.
|
|
38
|
+
--
|
|
39
|
+
-- ── E a fila que já se formou ───────────────────────────────────────────────
|
|
40
|
+
--
|
|
41
|
+
-- Os eventos pendentes que vieram do porte são descartados, e só eles: a
|
|
42
|
+
-- condição exige que o pedido tenha linha no `migration.ledger`. Um evento de
|
|
43
|
+
-- venda feita no app não tem, e fica onde está. Marcados como `skipped` com o
|
|
44
|
+
-- motivo escrito — não apagados, porque a fila é um registro do que a
|
|
45
|
+
-- plataforma viu, e apagar esconderia esta própria decisão.
|
|
46
|
+
--
|
|
47
|
+
-- Idempotente.
|
|
48
|
+
|
|
49
|
+
CREATE OR REPLACE FUNCTION public.trg_orders_emit_completed()
|
|
50
|
+
RETURNS trigger
|
|
51
|
+
LANGUAGE plpgsql
|
|
52
|
+
SECURITY DEFINER
|
|
53
|
+
SET search_path TO ''
|
|
54
|
+
AS $function$
|
|
55
|
+
BEGIN
|
|
56
|
+
-- O porte escreve com `migration.writer = 'on'`. Um pedido que ele fecha é
|
|
57
|
+
-- um fato ANTIGO chegando agora; avisar a plataforma seria descrever o
|
|
58
|
+
-- passado como se fosse o presente.
|
|
59
|
+
IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN
|
|
60
|
+
RETURN NULL;
|
|
61
|
+
END IF;
|
|
62
|
+
|
|
63
|
+
IF NEW.status IS DISTINCT FROM 'completed' THEN
|
|
64
|
+
RETURN NULL;
|
|
65
|
+
END IF;
|
|
66
|
+
IF TG_OP = 'UPDATE' AND OLD.status IS NOT DISTINCT FROM 'completed' THEN
|
|
67
|
+
RETURN NULL;
|
|
68
|
+
END IF;
|
|
69
|
+
|
|
70
|
+
PERFORM public.plg_emit_event(
|
|
71
|
+
'order.completed',
|
|
72
|
+
jsonb_build_object(
|
|
73
|
+
'order_id', NEW.id,
|
|
74
|
+
'kind', NEW.kind,
|
|
75
|
+
'unit_id', NEW.unit_id,
|
|
76
|
+
'party_id', NEW.party_id,
|
|
77
|
+
'total', NEW.total
|
|
78
|
+
),
|
|
79
|
+
'order',
|
|
80
|
+
NEW.id::text,
|
|
81
|
+
NEW.tenant_id,
|
|
82
|
+
NULL
|
|
83
|
+
);
|
|
84
|
+
RETURN NULL;
|
|
85
|
+
END $function$;
|
|
86
|
+
|
|
87
|
+
COMMENT ON FUNCTION public.trg_orders_emit_completed() IS
|
|
88
|
+
'Avisa a plataforma que uma venda fechou. Silencioso quando quem fecha é o '
|
|
89
|
+
'porte (migration.writer = on): a venda migrada é um fato antigo, e emitir '
|
|
90
|
+
'o aviso faria a automação tratar 166 mil vendas de 2025 como se tivessem '
|
|
91
|
+
'acabado de acontecer.';
|