@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.
Files changed (68) hide show
  1. package/migrations/011_leadcontrol_joins_the_family.sql +5 -1
  2. package/migrations/029_o_administrador_enxerga_o_que_existe.sql +167 -0
  3. package/migrations/031_ticketcontrol_joins_the_family.sql +109 -0
  4. package/migrations/032_a_marca_e_um_conjunto_de_tokens.sql +291 -0
  5. package/migrations/041_a_entrega_tem_onde_guardar_a_politica.sql +53 -0
  6. package/migrations/042_o_token_de_marca_fecha_a_porta_anon.sql +17 -0
  7. package/migrations/043_fiscalcontrol_joins_the_family.sql +75 -0
  8. package/migrations/044_o_teste_gratis_dura_trinta_dias.sql +15 -0
  9. package/migrations/045_o_espelho_manda_enquanto_o_v2_nao_escreveu.sql +519 -0
  10. package/migrations/046_the_tick_advances_the_flows.sql +77 -0
  11. package/migrations/047_the_tick_sweeps_the_offers.sql +74 -0
  12. package/migrations/052_fullcontrol_joins_the_family.sql +213 -0
  13. package/migrations/053_o_erp_para_de_exigir_o_que_nao_desenha.sql +37 -0
  14. package/migrations/054_o_arquivo_migrado_ganha_bytes.sql +439 -0
  15. package/migrations/061_a_linha_sem_data_pode_ser_estrutura.sql +253 -0
  16. package/migrations/063_o_anexo_ja_registrado_recebe_os_bytes.sql +303 -0
  17. package/migrations/064_um_arquivo_anexado_a_dezenove_contas.sql +178 -0
  18. package/migrations/065_apagar_o_sobrevivente_nao_apaga_o_tenant.sql +59 -0
  19. package/migrations/066_the_port_machinery_closes_its_doors.sql +51 -0
  20. package/migrations/067_the_erp_keeps_the_front_door.sql +56 -0
  21. package/migrations/068_o_ledger_encontra_a_linha_pelo_caminho_que_o_writer_usa.sql +55 -0
  22. package/migrations/069_a_sondagem_de_irma_usa_indice.sql +293 -0
  23. package/migrations/071_o_sdr_trabalha_o_funil_e_mais_nada.sql +110 -0
  24. package/migrations/072_o_porte_nao_avisa_a_plataforma_de_uma_venda_antiga.sql +91 -0
  25. package/migrations/073_a_exclusao_e_contrato_do_cliente_nao_do_cluster.sql +300 -0
  26. package/migrations/074_a_classificacao_quebrada_nao_leva_o_dinheiro_junto.sql +332 -0
  27. package/migrations/075_o_apelido_da_referencia_nao_disputa_com_a_variavel.sql +276 -0
  28. package/migrations/076_o_desligado_tambem_precisa_atravessar.sql +110 -0
  29. package/migrations/077_a_unidade_escolhida_chega_ao_banco.sql +128 -0
  30. package/migrations/078_onde_o_profissional_atende_atravessa.sql +71 -0
  31. package/migrations/079_quem_aparece_na_agenda_e_quem_atende.sql +210 -0
  32. package/migrations/080_o_contexto_da_requisicao_se_calcula_uma_vez.sql +240 -0
  33. package/migrations/081_a_permissao_por_unidade_se_resolve_uma_vez_por_unidade.sql +88 -0
  34. package/migrations/082_a_cerca_tambem_se_lembra.sql +110 -0
  35. package/migrations/083_quem_e_profissional_continua_agendavel.sql +32 -0
  36. package/migrations/084_quem_atende_em_varias_nao_cabe_numa_coluna.sql +60 -0
  37. package/migrations/085_a_unidade_do_profissional_vem_de_onde_ele_atende.sql +47 -0
  38. package/migrations/086_a_ponte_de_clientes_volta_a_existir.sql +89 -0
  39. package/migrations/087_a_liberacao_pergunta_ao_indice_antes_da_funcao.sql +79 -0
  40. package/migrations/088_quem_pode_executar_o_que_o_porte_criou.sql +50 -0
  41. package/migrations/089_a_memoria_de_transacao_nao_serve_para_isto.sql +86 -0
  42. package/migrations/090_a_marca_tambem_conta_como_mudanca_de_configuracao.sql +75 -0
  43. package/migrations/091_o_shell_tambem_e_configuracao_do_tenant.sql +71 -0
  44. package/migrations/092_a_marca_tem_uma_grafia_so_e_uma_porta_so.sql +159 -0
  45. package/migrations/093_a_marca_carrega_o_tema_inteiro.sql +179 -0
  46. package/migrations/094_a_porta_do_dominio_aprende_a_grafia_nova.sql +55 -0
  47. package/migrations/095_o_aplicativo_tem_um_preset_que_a_conta_estende.sql +98 -0
  48. package/migrations/096_o_sublinhado_tambem_e_nome_de_modulo.sql +100 -0
  49. package/migrations/097_o_oficio_diz_o_que_oferece_e_a_conta_o_que_usa.sql +137 -0
  50. package/migrations/098_a_arvore_do_oficio_nomeia_quem_ainda_desenha.sql +22 -0
  51. package/migrations/099_o_estoque_mora_dentro_de_produtos.sql +48 -0
  52. package/migrations/100_o_chefcontrol_desenha_abas_de_modulo.sql +24 -0
  53. package/migrations/101_o_oficio_agrupa_o_plugin_nomeia.sql +93 -0
  54. package/migrations/102_tres_marcas_ganham_fundo_e_contraste.sql +73 -0
  55. package/migrations/103_great_djs_e_iam_club_entram_na_frota.sql +75 -0
  56. package/migrations/104_o_curso_e_a_bilheteria_ganham_preset.sql +29 -0
  57. package/migrations/105_o_que_o_oficio_exige_ele_tambem_oferece.sql +78 -0
  58. package/migrations/106_a_comunidade_se_gere_como_escola.sql +84 -0
  59. package/migrations/107_o_logo_da_coluna_e_projecao_do_documento.sql +53 -0
  60. package/migrations/108_o_modulo_do_oficio_vizinho_tem_onde_ser_visto.sql +78 -0
  61. package/migrations/109_a_conta_diz_o_que_nao_usa_e_a_marca_pinta_a_casa.sql +67 -0
  62. package/migrations/110_a_tabela_de_ramos_aprende_o_vocabulario_fechado.sql +60 -0
  63. package/migrations/111_o_erp_generico_nao_vende_por_pedido.sql +50 -0
  64. package/migrations/112_o_cache_do_contexto_sabe_de_quem_ele_e.sql +327 -0
  65. package/migrations/113_o_ramo_volta_a_saber_que_papeis_a_conta_nasce_tendo.sql +124 -0
  66. package/migrations/114_a_resposta_inicial_volta_a_dizer_o_que_a_pessoa_pode.sql +93 -0
  67. package/migrations/115_o_rail_e_de_quem_tem_um.sql +108 -0
  68. 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.';