@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,439 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 054_o_arquivo_migrado_ganha_bytes.sql — o porte passa a carregar a imagem, e
3
+ -- não só o caminho dela.
4
+ --
5
+ -- A 152 dividiu o arquivo em duas metades: o REGISTRO é do base
6
+ -- (`public.documents` — de quem é, que espécie é, quem pode ver) e os BYTES são
7
+ -- de um serviço de storage, com a linha apontando para eles. A metade do
8
+ -- registro ficou pronta. A dos bytes, no porte V1 → V2, nunca existiu.
9
+ --
10
+ -- ── O sintoma, medido em 18/09/2026 ─────────────────────────────────────────
11
+ --
12
+ -- No tenant Pertinho do Céu, 1.320 das 1.361 batidas de ponto chegaram ao V2
13
+ -- com `evidence.photo_path` preenchido — `8/2026-07-05/professional-15-…jpg`.
14
+ -- Esse caminho é de um bucket PRIVADO de OUTRO projeto Supabase, o do V1.
15
+ -- Nenhuma dessas 1.320 imagens é alcançável daqui, e todas desaparecem no dia
16
+ -- em que o V1 sair do ar.
17
+ --
18
+ -- O porte transportou a prova por referência e deixou a prova para trás. Uma
19
+ -- batida contestada tem hora, GPS e score facial, e não tem a cara de quem
20
+ -- bateu — que é o único dos quatro que uma pessoa reconhece.
21
+ --
22
+ -- A mesma coisa vale para o cadastro: `people.avatar_url` chegou com a URL
23
+ -- pública do bucket `professional-avatars` do V1 (63 pessoas no mesmo tenant).
24
+ -- Hoje ela até abre, porque o V1 está de pé. É um empréstimo com data marcada.
25
+ --
26
+ -- ── A decisão: o transporte é de fora, o registro e o ponteiro são daqui ────
27
+ --
28
+ -- Mover bytes entre dois projetos é rede, não SQL, e é o fayz-etl que faz
29
+ -- (`fayz_etl/blobs.py`). O que ESTE arquivo dá a ele é a mesma coisa que o resto
30
+ -- do schema `migration` dá ao `upsert_row`:
31
+ --
32
+ -- asset_kinds o de-para dos buckets: que espécie sai de onde e vai para
33
+ -- onde, e quem reescreve o ponteiro depois
34
+ -- asset_backlog() o que falta atravessar, perguntado ao DESTINO — quem sabe
35
+ -- o que está faltando é quem guarda o que já chegou
36
+ -- attach_asset() registra em public.documents, reescreve o ponteiro e
37
+ -- carimba o ledger; idempotente pelo checksum dos bytes
38
+ --
39
+ -- O fayz-etl não escolhe bucket, não monta caminho e não sabe o que é um
40
+ -- ponteiro de avatar. Ele lê o backlog, baixa, sobe e oferece — a mesma divisão
41
+ -- que fez o porte de linhas caber em 900 linhas de Python.
42
+ --
43
+ -- ── Por que o backlog é uma VIEW por espécie, e não uma coluna de SQL ───────
44
+ --
45
+ -- Cada espécie tem uma pergunta diferente para "o que falta": o avatar é a
46
+ -- pessoa cujo `avatar_url` AINDA aponta para o V1; a foto de batida é a linha
47
+ -- cuja `evidence` tem `photo_path` e não tem `photo`. As duas se apagam sozinhas
48
+ -- quando o ponteiro é reescrito — o backlog não precisa de estado próprio, e não
49
+ -- há segunda lista para ficar desatualizada.
50
+ --
51
+ -- Uma view também é verificável: `check-canonical-schema` a enxerga, e um
52
+ -- plugin que registre um nome de view que não existe quebra na aplicação, não
53
+ -- numa rodada às 4h da manhã.
54
+ --
55
+ -- Idempotente e replay-safe.
56
+ -- ---------------------------------------------------------------------------
57
+
58
+ -- ─────────────────────────────────────────────────────────────────────────
59
+ -- §1 O bucket dos avatares
60
+ --
61
+ -- Público, e é o que o app já assume: `packages/admin` chama
62
+ -- `storage.from('avatars').getPublicUrl(...)` em quatro lugares. O bucket nunca
63
+ -- foi criado neste cluster — a troca de foto no cadastro está quebrada desde
64
+ -- sempre, e ninguém viu porque o erro morre num toast.
65
+ -- ─────────────────────────────────────────────────────────────────────────
66
+
67
+ INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
68
+ VALUES ('avatars', 'avatars', true, 5242880,
69
+ ARRAY['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'image/gif'])
70
+ ON CONFLICT (id) DO UPDATE SET
71
+ public = EXCLUDED.public,
72
+ file_size_limit = EXCLUDED.file_size_limit,
73
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
74
+
75
+ DROP POLICY IF EXISTS core_avatars_read ON storage.objects;
76
+ DROP POLICY IF EXISTS core_avatars_insert ON storage.objects;
77
+ DROP POLICY IF EXISTS core_avatars_update ON storage.objects;
78
+ DROP POLICY IF EXISTS core_avatars_delete ON storage.objects;
79
+
80
+ CREATE POLICY core_avatars_read ON storage.objects
81
+ FOR SELECT TO anon, authenticated
82
+ USING (bucket_id = 'avatars');
83
+
84
+ -- Duas formas de caminho convivem no mesmo bucket, e é de propósito:
85
+ --
86
+ -- avatars/<auth-uid>.<ext> a foto que a PESSOA LOGADA troca de si
87
+ -- mesma (packages/admin). Não é de tenant
88
+ -- nenhum: a conta atravessa workspaces.
89
+ -- <tenant_id>/people/<person_id>.<ext> a foto de um CADASTRO, que é do tenant
90
+ -- e segue o escopo de pasta que o resto
91
+ -- da plataforma usa.
92
+ CREATE POLICY core_avatars_insert ON storage.objects
93
+ FOR INSERT TO authenticated
94
+ WITH CHECK (
95
+ bucket_id = 'avatars'
96
+ AND ((storage.foldername(name))[1] = 'avatars'
97
+ OR (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text))
98
+ );
99
+
100
+ CREATE POLICY core_avatars_update ON storage.objects
101
+ FOR UPDATE TO authenticated
102
+ USING (
103
+ bucket_id = 'avatars'
104
+ AND ((storage.foldername(name))[1] = 'avatars'
105
+ OR (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text))
106
+ )
107
+ WITH CHECK (
108
+ bucket_id = 'avatars'
109
+ AND ((storage.foldername(name))[1] = 'avatars'
110
+ OR (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text))
111
+ );
112
+
113
+ CREATE POLICY core_avatars_delete ON storage.objects
114
+ FOR DELETE TO authenticated
115
+ USING (
116
+ bucket_id = 'avatars'
117
+ AND ((storage.foldername(name))[1] = 'avatars'
118
+ OR (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text))
119
+ );
120
+
121
+ -- ─────────────────────────────────────────────────────────────────────────
122
+ -- §2 A espécie "retrato de uma pessoa"
123
+ -- ─────────────────────────────────────────────────────────────────────────
124
+
125
+ -- `active` declarado, e não inventado no INSERT: o registro de espécies existe
126
+ -- para que o vocabulário de estado de cada arquivo esteja escrito em algum lugar.
127
+ -- Um retrato não tem rascunho nem aprovação — ele vale ou foi substituído.
128
+ SELECT public.register_kind('document', 'avatar', 'base', ARRAY['active', 'archived'], NULL,
129
+ 'O retrato de uma pessoa no cadastro — o que a tela mostra ao lado do nome');
130
+
131
+ -- ─────────────────────────────────────────────────────────────────────────
132
+ -- §3 O de-para dos buckets
133
+ -- ─────────────────────────────────────────────────────────────────────────
134
+
135
+ CREATE TABLE IF NOT EXISTS migration.asset_kinds (
136
+ kind text PRIMARY KEY REFERENCES public.document_kinds(kind),
137
+ -- O bucket na ORIGEM. Quando a origem serve o objeto por URL pública, a view
138
+ -- de backlog devolve `source_url` e este campo é só documentação.
139
+ source_bucket text NOT NULL,
140
+ target_bucket text NOT NULL,
141
+ -- View em `migration` com o contrato de colunas do §4. O nome, e não a
142
+ -- definição: cada plugin declara a sua, e a sua pergunta é diferente.
143
+ backlog_view text NOT NULL,
144
+ -- `(p_tenant uuid, p_document uuid, p_row jsonb) RETURNS void`. NULL é uma
145
+ -- resposta legítima: há espécie que só precisa estar registrada em
146
+ -- public.documents, sem ponteiro em outra tabela para reescrever.
147
+ pointer_fn text,
148
+ -- Obrigatório. Espécie sem motivo escrito vira folclore em seis meses, e esta
149
+ -- tabela é lida por quem está depurando uma rodada que não trouxe nada.
150
+ notes text NOT NULL,
151
+ created_at timestamptz NOT NULL DEFAULT now(),
152
+ updated_at timestamptz NOT NULL DEFAULT now()
153
+ );
154
+
155
+ COMMENT ON TABLE migration.asset_kinds IS
156
+ 'O de-para dos BYTES (054): de que bucket da origem sai cada espécie de arquivo, para que bucket do destino vai, onde está a lista do que falta e quem reescreve o ponteiro. É o que a allowlist é para as linhas.';
157
+
158
+ DROP TRIGGER IF EXISTS asset_kinds_updated_at ON migration.asset_kinds;
159
+ CREATE TRIGGER asset_kinds_updated_at BEFORE UPDATE ON migration.asset_kinds
160
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
161
+
162
+ CREATE OR REPLACE FUNCTION migration.register_asset_kind(
163
+ p_kind text, p_source_bucket text, p_target_bucket text,
164
+ p_backlog_view text, p_pointer_fn text, p_notes text)
165
+ RETURNS void
166
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
167
+ AS $$
168
+ BEGIN
169
+ INSERT INTO migration.asset_kinds (kind, source_bucket, target_bucket, backlog_view, pointer_fn, notes)
170
+ VALUES (p_kind, p_source_bucket, p_target_bucket, p_backlog_view, p_pointer_fn, p_notes)
171
+ ON CONFLICT (kind) DO UPDATE SET
172
+ source_bucket = EXCLUDED.source_bucket,
173
+ target_bucket = EXCLUDED.target_bucket,
174
+ backlog_view = EXCLUDED.backlog_view,
175
+ pointer_fn = EXCLUDED.pointer_fn,
176
+ notes = EXCLUDED.notes;
177
+ END $$;
178
+
179
+ COMMENT ON FUNCTION migration.register_asset_kind(text, text, text, text, text, text) IS
180
+ 'Como um plugin declara que uma espécie de arquivo dele tem bytes para transportar (054). Idempotente — reaplicar a migration reescreve a linha.';
181
+
182
+ -- ─────────────────────────────────────────────────────────────────────────
183
+ -- §4 O que falta atravessar
184
+ --
185
+ -- Contrato de colunas que toda `backlog_view` precisa devolver:
186
+ --
187
+ -- tenant_id uuid de quem é
188
+ -- subject_type text 'person', 'time_entry', 'invoice'…
189
+ -- subject_id uuid a linha do destino que aponta para o arquivo
190
+ -- person_id uuid a pessoa, quando há uma (é a pergunta da LGPD)
191
+ -- source_path text o objeto DENTRO do bucket de origem
192
+ -- source_url text URL direta, quando a origem serve o objeto em público;
193
+ -- NULL = baixe pelo bucket com a chave de serviço
194
+ -- target_path text o objeto no bucket de destino. Determinístico, sempre:
195
+ -- rodar duas vezes sobrescreve, não duplica
196
+ -- title text o que uma pessoa lê numa lista de arquivos
197
+ -- ─────────────────────────────────────────────────────────────────────────
198
+
199
+ CREATE OR REPLACE VIEW migration.v_asset_backlog_avatar AS
200
+ SELECT p.tenant_id,
201
+ 'person'::text AS subject_type,
202
+ p.id AS subject_id,
203
+ p.id AS person_id,
204
+ -- Os dois hosts do V1 servem o mesmo bucket: o projeto
205
+ -- (xzihdmcyoyrjndpvlxdj.supabase.co) e o domínio próprio
206
+ -- (api.ymaia.com). O recorte é pelo caminho, que é igual nos dois.
207
+ regexp_replace(p.avatar_url, '^.*/storage/v1/object/public/professional-avatars/', '') AS source_path,
208
+ p.avatar_url AS source_url,
209
+ p.tenant_id::text || '/people/' || p.id::text
210
+ || lower(coalesce(substring(p.avatar_url from '\.[A-Za-z0-9]{2,4}$'), '.jpg')) AS target_path,
211
+ p.name AS title
212
+ FROM public.people p
213
+ -- O próprio ponteiro é o backlog: enquanto a URL apontar para o bucket do V1,
214
+ -- os bytes não atravessaram. Reescrevê-la tira a linha daqui, sem lista à
215
+ -- parte para ficar desatualizada.
216
+ WHERE p.avatar_url ~ '/storage/v1/object/public/professional-avatars/';
217
+
218
+ COMMENT ON VIEW migration.v_asset_backlog_avatar IS
219
+ 'Pessoas cujo retrato ainda mora no bucket do V1 (054). A condição É o ponteiro: reescrevê-lo apaga a linha desta lista.';
220
+
221
+ -- DROP antes do CREATE, e não só REPLACE: `CREATE OR REPLACE` recusa mudar a
222
+ -- FORMA do retorno ("cannot change return type of existing function"), e uma
223
+ -- migration posterior já mudou a desta — a 063, que acrescentou colunas ao
224
+ -- backlog. Na segunda passada do chain este arquivo roda de novo e encontra a
225
+ -- forma nova; sem o drop, ele falha ali e o replay inteiro para.
226
+ --
227
+ -- Replay-safe não é propriedade de um arquivo sozinho: é propriedade dele
228
+ -- DEPOIS de tudo o que veio em seguida.
229
+ DROP FUNCTION IF EXISTS migration.asset_backlog(uuid, text);
230
+ CREATE OR REPLACE FUNCTION migration.asset_backlog(p_tenant uuid, p_kind text DEFAULT NULL)
231
+ RETURNS TABLE (
232
+ kind text, source_bucket text, target_bucket text,
233
+ tenant_id uuid, subject_type text, subject_id uuid, person_id uuid,
234
+ source_path text, source_url text, target_path text, title text)
235
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
236
+ AS $$
237
+ DECLARE k migration.asset_kinds%ROWTYPE;
238
+ BEGIN
239
+ FOR k IN SELECT * FROM migration.asset_kinds a
240
+ WHERE p_kind IS NULL OR a.kind = p_kind
241
+ ORDER BY a.kind
242
+ LOOP
243
+ -- Espécie declarada cuja view ainda não existe é o caso do plugin
244
+ -- desinstalado, não um erro: o cluster monta por plugin, e o porte de um
245
+ -- cliente sem `workforce` não pode quebrar por causa de foto de ponto.
246
+ IF to_regclass('migration.' || quote_ident(k.backlog_view)) IS NULL THEN
247
+ CONTINUE;
248
+ END IF;
249
+ RETURN QUERY EXECUTE format(
250
+ 'SELECT %L::text, %L::text, %L::text, v.tenant_id, v.subject_type, v.subject_id,
251
+ v.person_id, v.source_path, v.source_url, v.target_path, v.title
252
+ FROM migration.%I v
253
+ WHERE v.tenant_id = $1
254
+ AND coalesce(btrim(v.source_path), '''') <> ''''',
255
+ k.kind, k.source_bucket, k.target_bucket, k.backlog_view)
256
+ USING p_tenant;
257
+ END LOOP;
258
+ END $$;
259
+
260
+ COMMENT ON FUNCTION migration.asset_backlog(uuid, text) IS
261
+ 'O que falta de BYTES neste tenant, por espécie (054). Perguntado ao destino de propósito: quem sabe o que está faltando é quem guarda o que já chegou.';
262
+
263
+ GRANT EXECUTE ON FUNCTION migration.asset_backlog(uuid, text) TO service_role;
264
+
265
+ -- ─────────────────────────────────────────────────────────────────────────
266
+ -- §5 O ponteiro do avatar
267
+ -- ─────────────────────────────────────────────────────────────────────────
268
+
269
+ CREATE OR REPLACE FUNCTION migration._point_avatar(p_tenant uuid, p_document uuid, p_row jsonb)
270
+ RETURNS void
271
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
272
+ AS $$
273
+ DECLARE v_person uuid := (p_row ->> 'subject_id')::uuid;
274
+ v_url text := p_row ->> 'public_url';
275
+ BEGIN
276
+ IF v_person IS NULL OR v_url IS NULL THEN
277
+ RAISE EXCEPTION '_point_avatar: preciso de subject_id e public_url' USING ERRCODE = '22023';
278
+ END IF;
279
+ -- A URL do V1 vira histórico em vez de sumir. É o que permite conferir, um
280
+ -- ano depois, que o retrato que está no V2 é o retrato que estava no V1 —
281
+ -- sem isso a única prova da procedência é o log de uma rodada.
282
+ UPDATE public.people
283
+ SET avatar_url = v_url,
284
+ metadata = migration._deep_set(coalesce(metadata, '{}'::jsonb),
285
+ ARRAY['migration', 'v1_avatar_url'], to_jsonb(avatar_url)),
286
+ updated_at = now()
287
+ WHERE id = v_person AND tenant_id = p_tenant;
288
+ END $$;
289
+
290
+ SELECT migration.register_asset_kind(
291
+ 'avatar', 'professional-avatars', 'avatars',
292
+ 'v_asset_backlog_avatar', 'migration._point_avatar',
293
+ 'O retrato do cadastro. A origem serve por URL pública, então o backlog devolve source_url e o transporte nem precisa de chave na origem.');
294
+
295
+ -- ─────────────────────────────────────────────────────────────────────────
296
+ -- §6 attach_asset — registra, aponta, carimba
297
+ --
298
+ -- A forma é a do `upsert_row` de propósito: mesma casca que liga
299
+ -- `migration.writer`, mesma cerca, mesmo ledger, mesmo jsonb de volta. Quem
300
+ -- souber depurar uma rodada de linhas sabe depurar uma de bytes.
301
+ --
302
+ -- O checksum do ledger é dos BYTES, não da linha. É o que faz a segunda rodada
303
+ -- devolver `skipped` para 1.320 fotos sem baixar nenhuma delas de novo.
304
+ -- ─────────────────────────────────────────────────────────────────────────
305
+
306
+ CREATE OR REPLACE FUNCTION migration._attach_asset_impl(
307
+ p_batch uuid, p_kind text, p_source_path text, p_row jsonb)
308
+ RETURNS jsonb
309
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
310
+ AS $$
311
+ DECLARE
312
+ b migration.batches%ROWTYPE;
313
+ k migration.asset_kinds%ROWTYPE;
314
+ l migration.ledger%ROWTYPE;
315
+ v_source_table text;
316
+ v_source_uuid uuid;
317
+ v_checksum text := p_row ->> 'checksum';
318
+ v_doc uuid;
319
+ v_person uuid := nullif(p_row ->> 'person_id', '')::uuid;
320
+ BEGIN
321
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch;
322
+ IF NOT FOUND THEN RAISE EXCEPTION 'attach_asset: lote % desconhecido', p_batch USING ERRCODE = 'P0002'; END IF;
323
+ IF b.status <> 'running' THEN
324
+ RAISE EXCEPTION 'attach_asset: o lote % está % (só um lote em execução aceita arquivo)', p_batch, b.status USING ERRCODE = '55000';
325
+ END IF;
326
+ PERFORM migration._assert_fence(b.tenant_id);
327
+
328
+ SELECT * INTO k FROM migration.asset_kinds WHERE kind = p_kind;
329
+ IF NOT FOUND THEN
330
+ RAISE EXCEPTION 'attach_asset: espécie % não está em migration.asset_kinds', p_kind USING ERRCODE = '22023';
331
+ END IF;
332
+ IF v_checksum IS NULL OR p_row ->> 'target_path' IS NULL THEN
333
+ RAISE EXCEPTION 'attach_asset: preciso de checksum e target_path' USING ERRCODE = '22023';
334
+ END IF;
335
+
336
+ -- `storage:<bucket>` e não o nome de uma tabela: a origem de um byte é um
337
+ -- bucket, e o prefixo deixa isso explícito para quem lê o ledger depois.
338
+ v_source_table := 'storage:' || k.source_bucket;
339
+ v_source_uuid := md5(v_source_table || ':' || p_source_path)::uuid;
340
+
341
+ SELECT * INTO l FROM migration.ledger
342
+ WHERE source_project = b.source_project AND source_table = v_source_table
343
+ AND source_uuid = v_source_uuid AND coalesce(target_table, '') = 'public.documents'
344
+ FOR UPDATE;
345
+ IF NOT FOUND THEN
346
+ INSERT INTO migration.ledger (source_project, source_table, source_uuid, tenant_id,
347
+ target_table, checksum, cursor, batch_id, status)
348
+ VALUES (b.source_project, v_source_table, v_source_uuid, b.tenant_id,
349
+ 'public.documents', v_checksum, p_source_path, p_batch, 'pending')
350
+ RETURNING * INTO l;
351
+ ELSIF l.tenant_id <> b.tenant_id THEN
352
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
353
+ format('o objeto %s já é do tenant %s; este lote migra o %s', p_source_path, l.tenant_id, b.tenant_id), p_row);
354
+ ELSIF l.status = 'migrated' AND l.checksum = v_checksum THEN
355
+ PERFORM migration._count(p_batch, 'skipped');
356
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'bytes iguais',
357
+ 'target_table', 'public.documents', 'target_id', l.target_id, 'ledger_id', l.id);
358
+ END IF;
359
+
360
+ -- O id do registro é derivado do caminho de DESTINO, que é determinístico.
361
+ -- Rerun sobrescreve a mesma linha; não há segunda cópia do mesmo arquivo.
362
+ v_doc := coalesce(l.target_id, md5('document:' || p_kind || ':' || (p_row ->> 'target_path'))::uuid);
363
+
364
+ INSERT INTO public.documents (
365
+ id, tenant_id, kind, person_id, subject_type, subject_id, title, status,
366
+ file_url, file_name, file_size, mime_type,
367
+ storage_provider, storage_bucket, storage_path, checksum, metadata)
368
+ VALUES (
369
+ v_doc, b.tenant_id, p_kind, v_person,
370
+ p_row ->> 'subject_type', nullif(p_row ->> 'subject_id', '')::uuid,
371
+ p_row ->> 'title', 'active',
372
+ p_row ->> 'public_url', p_row ->> 'file_name',
373
+ nullif(p_row ->> 'file_size', '')::integer, p_row ->> 'mime_type',
374
+ 'supabase', k.target_bucket, p_row ->> 'target_path', v_checksum,
375
+ jsonb_build_object('migration', jsonb_build_object(
376
+ 'source_project', b.source_project, 'source_bucket', k.source_bucket,
377
+ 'source_path', p_source_path, 'batch_id', p_batch)))
378
+ ON CONFLICT (id) DO UPDATE SET
379
+ person_id = coalesce(EXCLUDED.person_id, public.documents.person_id),
380
+ subject_type = EXCLUDED.subject_type,
381
+ subject_id = EXCLUDED.subject_id,
382
+ title = coalesce(EXCLUDED.title, public.documents.title),
383
+ file_url = EXCLUDED.file_url,
384
+ file_name = EXCLUDED.file_name,
385
+ file_size = EXCLUDED.file_size,
386
+ mime_type = EXCLUDED.mime_type,
387
+ storage_provider = EXCLUDED.storage_provider,
388
+ storage_bucket = EXCLUDED.storage_bucket,
389
+ storage_path = EXCLUDED.storage_path,
390
+ checksum = EXCLUDED.checksum,
391
+ is_active = true,
392
+ metadata = migration._merge_meta(public.documents.metadata, EXCLUDED.metadata),
393
+ updated_at = now();
394
+
395
+ IF k.pointer_fn IS NOT NULL THEN
396
+ EXECUTE format('SELECT %s($1, $2, $3)', k.pointer_fn)
397
+ USING b.tenant_id, v_doc, p_row || jsonb_build_object('bucket', k.target_bucket);
398
+ END IF;
399
+
400
+ UPDATE migration.ledger
401
+ SET status = 'migrated', target_id = v_doc, checksum = v_checksum,
402
+ cursor = p_source_path, batch_id = p_batch, reason = NULL
403
+ WHERE id = l.id;
404
+ PERFORM migration._count(p_batch, 'migrated');
405
+
406
+ RETURN jsonb_build_object('status', 'migrated', 'target_table', 'public.documents',
407
+ 'target_id', v_doc, 'ledger_id', l.id);
408
+ END $$;
409
+
410
+ CREATE OR REPLACE FUNCTION migration.attach_asset(
411
+ p_batch uuid, p_kind text, p_source_path text, p_row jsonb)
412
+ RETURNS jsonb
413
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
414
+ AS $$
415
+ DECLARE v jsonb;
416
+ BEGIN
417
+ IF p_row IS NULL OR jsonb_typeof(p_row) <> 'object' THEN
418
+ RAISE EXCEPTION 'attach_asset: p_row precisa ser um objeto JSON' USING ERRCODE = '22023';
419
+ END IF;
420
+ -- `migration.writer = on` é o que abre as travas de append-only do destino —
421
+ -- a batida recusa UPDATE por trigger, e apontar a foto dela é um UPDATE.
422
+ PERFORM set_config('migration.writer', 'on', true);
423
+ PERFORM set_config('migration.batch', p_batch::text, true);
424
+ BEGIN
425
+ v := migration._attach_asset_impl(p_batch, p_kind, p_source_path, p_row);
426
+ EXCEPTION WHEN OTHERS THEN
427
+ PERFORM set_config('migration.writer', 'off', true);
428
+ PERFORM set_config('migration.batch', '', true);
429
+ RAISE;
430
+ END;
431
+ PERFORM set_config('migration.writer', 'off', true);
432
+ PERFORM set_config('migration.batch', '', true);
433
+ RETURN v;
434
+ END $$;
435
+
436
+ COMMENT ON FUNCTION migration.attach_asset(uuid, text, text, jsonb) IS
437
+ 'Um arquivo que atravessou: registra em public.documents, reescreve o ponteiro da espécie e carimba o ledger (054). O checksum é dos BYTES — é o que faz a segunda rodada devolver skipped sem baixar nada.';
438
+
439
+ GRANT EXECUTE ON FUNCTION migration.attach_asset(uuid, text, text, jsonb) TO service_role;