@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,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;
|