@fayz-ai/db 0.13.0 → 0.14.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 (187) hide show
  1. package/canonical-schema.json +106 -23
  2. package/dist/index.cjs +4 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/schema/spine.d.ts +42 -37
  9. package/dist/schema/spine.d.ts.map +1 -1
  10. package/migrations/000_baseline.sql +29667 -0
  11. package/migrations/001_the_tenant_has_apps.sql +314 -0
  12. package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
  13. package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
  14. package/migrations/004_the_projections_get_a_successor.sql +312 -0
  15. package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
  16. package/migrations/006_the_helpers_read_the_source.sql +153 -0
  17. package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
  18. package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
  19. package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
  20. package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
  21. package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
  22. package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
  23. package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
  24. package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
  25. package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
  26. package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
  27. package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
  28. package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
  29. package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
  30. package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
  31. package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
  32. package/package.json +1 -1
  33. package/migrations/0000_legacy_quarantine.sql +0 -86
  34. package/migrations/0000b_straggler_sweep.sql +0 -37
  35. package/migrations/000_core_v1_convert.sql +0 -343
  36. package/migrations/001_core.sql +0 -372
  37. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  38. package/migrations/003_invitations.sql +0 -17
  39. package/migrations/004_archetypes.sql +0 -343
  40. package/migrations/005_locations_archetype.sql +0 -18
  41. package/migrations/006_archetype_rls.sql +0 -74
  42. package/migrations/007_archetype_grants.sql +0 -4
  43. package/migrations/008_grants.sql +0 -5
  44. package/migrations/009_anon_hardening.sql +0 -19
  45. package/migrations/010_migration_ledger.sql +0 -46
  46. package/migrations/011_anon_write_revoke.sql +0 -12
  47. package/migrations/012_rename_migration_ledger.sql +0 -61
  48. package/migrations/013_sequences_rls.sql +0 -22
  49. package/migrations/014_plan_entitlements.sql +0 -28
  50. package/migrations/015_agent_guard.sql +0 -108
  51. package/migrations/016_agent_guard_actor.sql +0 -102
  52. package/migrations/017_core_addresses.sql +0 -115
  53. package/migrations/018_global_search.sql +0 -482
  54. package/migrations/019_tenant_members_person_id.sql +0 -20
  55. package/migrations/020_fayz_projects.sql +0 -63
  56. package/migrations/021_tenant_roles.sql +0 -74
  57. package/migrations/022_invite_acceptance.sql +0 -91
  58. package/migrations/023_team_visibility.sql +0 -75
  59. package/migrations/024_analytics_engine.sql +0 -317
  60. package/migrations/025_created_by.sql +0 -75
  61. package/migrations/026_audit_trail.sql +0 -73
  62. package/migrations/027_domain_events.sql +0 -266
  63. package/migrations/028_tenant_scoped_token.sql +0 -127
  64. package/migrations/029_connections.sql +0 -186
  65. package/migrations/030_effect_idempotency.sql +0 -159
  66. package/migrations/031_sync_run_message.sql +0 -39
  67. package/migrations/032_connection_secrets.sql +0 -227
  68. package/migrations/033_sync_schedule.sql +0 -651
  69. package/migrations/034_custom_fields.sql +0 -55
  70. package/migrations/035_field_registry.sql +0 -148
  71. package/migrations/036_analytics_run_batch.sql +0 -84
  72. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  73. package/migrations/038_onboarding_responses.sql +0 -103
  74. package/migrations/039_unit_tree.sql +0 -270
  75. package/migrations/040_resource_grants.sql +0 -474
  76. package/migrations/041_scoped_columns.sql +0 -192
  77. package/migrations/042_unit_scope_policies.sql +0 -145
  78. package/migrations/043_view_invoker.sql +0 -90
  79. package/migrations/044_unit_member_facts.sql +0 -47
  80. package/migrations/045_unit_entry.sql +0 -236
  81. package/migrations/046_membership_visible_to_members.sql +0 -85
  82. package/migrations/047_tasks.sql +0 -266
  83. package/migrations/048_every_login_is_a_person.sql +0 -190
  84. package/migrations/049_bookable_people.sql +0 -126
  85. package/migrations/050_authz_core.sql +0 -230
  86. package/migrations/051_authz_helpers.sql +0 -330
  87. package/migrations/052_authz_fanout.sql +0 -253
  88. package/migrations/053_authz_catalog_templates.sql +0 -402
  89. package/migrations/060_tenancy_seam.sql +0 -132
  90. package/migrations/061_create_tenant.sql +0 -125
  91. package/migrations/062_invitations.sql +0 -329
  92. package/migrations/063_legacy_tenancy_retired.sql +0 -568
  93. package/migrations/064_agent_guard_authz.sql +0 -118
  94. package/migrations/064b_supersede_unit_scope.sql +0 -205
  95. package/migrations/065_scaffold_sweep.sql +0 -139
  96. package/migrations/066_shadow_report.sql +0 -122
  97. package/migrations/070_permission_catalog.sql +0 -205
  98. package/migrations/071_v1_permission_map.sql +0 -266
  99. package/migrations/072_role_rpcs.sql +0 -126
  100. package/migrations/073_legacy_rbac_retire.sql +0 -471
  101. package/migrations/075_company_legal_entities.sql +0 -169
  102. package/migrations/076_tenant_settings.sql +0 -251
  103. package/migrations/080_people_roles.sql +0 -269
  104. package/migrations/081_people_identity.sql +0 -271
  105. package/migrations/082_people_membership_link.sql +0 -142
  106. package/migrations/083_people_data_migration.sql +0 -75
  107. package/migrations/084_people_registrations_tokens.sql +0 -282
  108. package/migrations/085_catalog_products.sql +0 -333
  109. package/migrations/086_catalog_pricing.sql +0 -194
  110. package/migrations/087_catalog_packages.sql +0 -134
  111. package/migrations/090_orders_venda.sql +0 -166
  112. package/migrations/091_orders_scaffold.sql +0 -92
  113. package/migrations/095_config_field_rules.sql +0 -261
  114. package/migrations/096_audit_append_only.sql +0 -256
  115. package/migrations/097_sequences_authorized.sql +0 -111
  116. package/migrations/098_holidays.sql +0 -80
  117. package/migrations/099_access_grants.sql +0 -293
  118. package/migrations/100_migration_schema.sql +0 -217
  119. package/migrations/101_migration_allowlist.sql +0 -202
  120. package/migrations/102_migration_rpcs.sql +0 -933
  121. package/migrations/103_migration_reconcile_ponr.sql +0 -132
  122. package/migrations/105_policies_to_authenticated.sql +0 -71
  123. package/migrations/106_function_search_path.sql +0 -55
  124. package/migrations/107_core_table_prefix.sql +0 -361
  125. package/migrations/108_authz_write_guards.sql +0 -149
  126. package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
  127. package/migrations/110_people_kind_vocabulary.sql +0 -120
  128. package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
  129. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
  130. package/migrations/112_money_precision_standard.sql +0 -132
  131. package/migrations/113_addresses_units_companies.sql +0 -114
  132. package/migrations/114_migrations_ledger_immutable.sql +0 -65
  133. package/migrations/115_registration_honours_field_rules.sql +0 -172
  134. package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
  135. package/migrations/117_authz_reconciliation_schedule.sql +0 -82
  136. package/migrations/118_migration_catalog_writers.sql +0 -450
  137. package/migrations/119_migration_config_writers.sql +0 -193
  138. package/migrations/120_migration_facet_writer.sql +0 -139
  139. package/migrations/121_migration_financial_writers.sql +0 -294
  140. package/migrations/122_migration_inventory_writers.sql +0 -245
  141. package/migrations/123_migration_order_item_writer.sql +0 -144
  142. package/migrations/124_migration_ledger_writers.sql +0 -170
  143. package/migrations/125_migration_authz_people_writers.sql +0 -205
  144. package/migrations/126_owner_scoping_is_configuration.sql +0 -247
  145. package/migrations/127_scaffold_audit_hook.sql +0 -98
  146. package/migrations/128_shadow_tables_enforced.sql +0 -93
  147. package/migrations/129_order_kind_terminal_states.sql +0 -57
  148. package/migrations/130_every_login_is_a_person.sql +0 -196
  149. package/migrations/131_migration_invoice_split.sql +0 -127
  150. package/migrations/132_audit_prune_scheduled.sql +0 -28
  151. package/migrations/133_unit_is_a_session_property.sql +0 -155
  152. package/migrations/134_field_rules_on_read.sql +0 -341
  153. package/migrations/135_distribution_polarity.sql +0 -158
  154. package/migrations/136_unit_id_means_locality.sql +0 -358
  155. package/migrations/137_vendor_named_spine_objects.sql +0 -126
  156. package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
  157. package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
  158. package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
  159. package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
  160. package/migrations/142_a_person_has_addresses.sql +0 -212
  161. package/migrations/143_a_professional_has_a_rate.sql +0 -75
  162. package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
  163. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
  164. package/migrations/146_products_stock_has_one_writer.sql +0 -67
  165. package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
  166. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
  167. package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
  168. package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
  169. package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
  170. package/migrations/152_a_file_is_an_archetype.sql +0 -222
  171. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
  172. package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
  173. package/migrations/155_a_tenant_can_leave.sql +0 -271
  174. package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
  175. package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
  176. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
  177. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
  178. package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
  179. package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
  180. package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
  181. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
  182. package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
  183. package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
  184. package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
  185. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
  186. package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
  187. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
@@ -0,0 +1,543 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 016_the_rule_stops_asking_every_row.sql — a list of 3.545 clientes stops
3
+ -- costing 3.545 answers to the same question.
4
+ --
5
+ -- THE BUG, as seen: `/clients` no Espaço Facial não carrega. Não é lentidão —
6
+ -- é o teto de 8s do papel `authenticated` estourando, e a tela dizendo "não foi
7
+ -- possível carregar" sem nenhuma linha. A 3.545 clientes o tenant é o primeiro
8
+ -- de escala real da rede; nos outros a mesma consulta responde em milissegundos
9
+ -- e nada disto aparece.
10
+ --
11
+ -- O QUE O PLANEJADOR DIZ (EXPLAIN ANALYZE, 50 linhas, ordenado por nome):
12
+ --
13
+ -- Limit actual time=10034
14
+ -- Index Scan using people_tenant_kind rows=3545
15
+ -- SubPlan 1 (available_in_my_units) 2.471ms × 3545 = 8.760s
16
+ -- SubPlan 8 (has_permission) 0.333ms × 3545 = 1.180s
17
+ -- Execution Time: 10035 ms
18
+ --
19
+ -- Duas perguntas feitas UMA VEZ POR LINHA, e as duas com a mesma resposta em
20
+ -- todas elas.
21
+ --
22
+ -- ── A primeira: a regra de distribuição ────────────────────────────────────
23
+ -- `people_available_in` chama `app.available_in_my_units('people.person', id)`
24
+ -- para cada linha sem unidade. A função monta quatro CTEs, resolve a cadeia de
25
+ -- cada unidade do chamador e cruza com `app.resource_grants` — para descobrir,
26
+ -- 3.545 vezes seguidas, que ESTE TENANT NÃO TEM NENHUMA REGRA DE DISTRIBUIÇÃO.
27
+ -- São zero tuplas no banco inteiro fora uma.
28
+ --
29
+ -- E quando não há tupla, a resposta é conhecida sem olhar linha nenhuma. Com
30
+ -- `tup` vazio o corpo da função reduz a:
31
+ --
32
+ -- NOT EXISTS(direct unavailable_in) -- vazio ⇒ true
33
+ -- AND ( EXISTS(direct available_in) -- vazio ⇒ false
34
+ -- OR EXISTS(verdicts allow) -- 'none' ⇒ false
35
+ -- OR ( NOT EXISTS(tup available_in) -- vazio ⇒ true
36
+ -- AND (EXISTS(verdicts 'none') OR NOT EXISTS(mine)) ) )
37
+ --
38
+ -- O último parêntese é verdade nos dois casos possíveis: com unidades, todo
39
+ -- veredito é 'none'; sem unidades, `NOT EXISTS(mine)`. Logo: sem tupla do tipo
40
+ -- no tenant, a função devolve `true` para TODA linha. Isso não é uma
41
+ -- aproximação — é o mesmo valor, e é por isso que dá para perguntar uma vez.
42
+ --
43
+ -- A pergunta barata — "este tenant tem alguma regra deste tipo?" — não depende
44
+ -- da linha, então o planejador a promove a InitPlan e ela roda UMA vez. Só
45
+ -- quando a resposta é sim é que a função cara volta a ser chamada, e aí ela é o
46
+ -- preço correto de um recurso que realmente foi distribuído.
47
+ --
48
+ -- ── A segunda: a permissão ─────────────────────────────────────────────────
49
+ -- `(select app.has_permission('people.read', unit_id))` correlaciona pela
50
+ -- coluna, então roda por linha — inclusive nas linhas em que `unit_id` é NULL,
51
+ -- onde `effective_role_id` ignora a unidade e a resposta é sempre a mesma.
52
+ --
53
+ -- Separar os dois casos num CASE resolve sem mudar o resultado: o ramo do NULL
54
+ -- não menciona coluna nenhuma, vira InitPlan, e roda uma vez para a consulta
55
+ -- inteira. O ramo da unidade continua exatamente como era. Verificado no
56
+ -- planejador: `Filter: CASE WHEN (unit_id IS NULL) THEN (InitPlan 1).col1 ELSE
57
+ -- (SubPlan 2) END`, com o SubPlan `never executed`.
58
+ --
59
+ -- ── E a terceira, que ainda não doeu ───────────────────────────────────────
60
+ -- `app.has_grant(tipo, id, 'shared_with')` no predicado de escopo é a mesma
61
+ -- armadilha, só que adormecida: ela só não custa hoje porque `owner_id IS NULL`
62
+ -- curto-circuita o OR antes dela. Numa tabela com dono preenchido ela roda por
63
+ -- linha, com a mesma resposta constante. Ganha a mesma guarda, pelo mesmo
64
+ -- motivo, antes de virar o próximo incidente.
65
+ --
66
+ -- ── O que muda de verdade ──────────────────────────────────────────────────
67
+ -- Nada no que cada política DECIDE. O que muda é onde a decisão é tomada: o
68
+ -- que não depende da linha sai de dentro do laço. As políticas são geradas —
69
+ -- `scaffold_table`, `scaffold_refresh_policies`, `scaffold_distributable`,
70
+ -- `masked_view_refresh` — então a correção mora nos geradores, e o bloco final
71
+ -- os re-executa para todas as tabelas do registro.
72
+ -- ---------------------------------------------------------------------------
73
+
74
+ -- ---------------------------------------------------------------------------
75
+ -- 1. A pergunta barata: existe ALGUMA tupla deste tipo neste tenant?
76
+ -- Não recebe id de linha de propósito — é o que a deixa sair do laço.
77
+ -- ---------------------------------------------------------------------------
78
+ CREATE OR REPLACE FUNCTION app.grants_exist(p_type text, p_relations text[])
79
+ RETURNS boolean
80
+ LANGUAGE sql STABLE SECURITY DEFINER
81
+ SET search_path TO ''
82
+ AS $$
83
+ SELECT EXISTS (
84
+ SELECT 1 FROM app.resource_grants g
85
+ WHERE g.tenant_id = app.current_tenant_id()
86
+ AND g.resource_type = p_type
87
+ AND g.relation = ANY (p_relations)
88
+ );
89
+ $$;
90
+
91
+ REVOKE ALL ON FUNCTION app.grants_exist(text, text[]) FROM PUBLIC;
92
+ GRANT ALL ON FUNCTION app.grants_exist(text, text[]) TO authenticated;
93
+ GRANT ALL ON FUNCTION app.grants_exist(text, text[]) TO service_role;
94
+
95
+ COMMENT ON FUNCTION app.grants_exist(text, text[]) IS
96
+ 'Este tenant tem alguma tupla destas relações para este tipo de recurso (#016)? Não recebe id de linha porque a pergunta não é sobre a linha — é o que permite ao planejador promovê-la a InitPlan e tirar a regra cara de dentro do laço da RLS.';
97
+
98
+ -- ---------------------------------------------------------------------------
99
+ -- 2. A mesma saída antecipada DENTRO da função, para quem a chama direto
100
+ -- (RPCs, views, `excluded_in_my_units`) e não passa por política nenhuma.
101
+ -- Corpo idêntico ao da 000 depois da guarda.
102
+ -- ---------------------------------------------------------------------------
103
+ CREATE OR REPLACE FUNCTION app.available_in_my_units(p_type text, p_id uuid)
104
+ RETURNS boolean
105
+ LANGUAGE sql STABLE SECURITY DEFINER
106
+ SET search_path TO ''
107
+ AS $$
108
+ -- Sem nenhuma tupla deste tipo no tenant não há o que resolver: a derivação
109
+ -- está no cabeçalho da 016 e termina em `true` para toda linha. Um CASE e não
110
+ -- um OR porque o ramo do CASE é avaliado sob demanda de forma garantida — as
111
+ -- CTEs abaixo nem chegam a ser montadas. A guarda é uma sondagem de índice no
112
+ -- prefixo (tenant_id, resource_type) da PK de resource_grants.
113
+ SELECT CASE
114
+ WHEN NOT app.grants_exist(p_type, ARRAY['available_in', 'unavailable_in']) THEN true
115
+ ELSE (
116
+ WITH me AS (
117
+ SELECT app.current_tenant_id() AS tenant_id,
118
+ auth.uid() AS uid,
119
+ nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid) AS focus
120
+ ),
121
+ tup AS (
122
+ SELECT g.relation, g.subject_type, g.subject_id
123
+ FROM app.resource_grants g, me
124
+ WHERE g.tenant_id = me.tenant_id AND g.resource_type = p_type AND g.resource_id = p_id
125
+ AND g.relation IN ('available_in', 'unavailable_in')
126
+ ),
127
+ -- me, or a role of mine: outside the tree, so it simply decides
128
+ direct AS (
129
+ SELECT t.relation FROM tup t, me
130
+ WHERE (t.subject_type = 'user' AND t.subject_id = me.uid)
131
+ OR (t.subject_type = 'role' AND t.subject_id IN (
132
+ SELECT m.role_id FROM app.memberships m
133
+ WHERE m.tenant_id = me.tenant_id AND m.user_id = me.uid AND m.active
134
+ UNION
135
+ SELECT mu.role_override_id FROM app.membership_units mu
136
+ WHERE mu.tenant_id = me.tenant_id AND mu.user_id = me.uid AND mu.role_override_id IS NOT NULL))
137
+ ),
138
+ mine AS (
139
+ SELECT a.unit_id FROM app.user_unit_access a, me
140
+ WHERE a.tenant_id = me.tenant_id AND a.user_id = me.uid
141
+ AND (me.focus IS NULL OR a.unit_id = me.focus)
142
+ ),
143
+ verdicts AS (
144
+ SELECT app.unit_release_verdict(p_type, p_id, mine.unit_id) AS v FROM mine
145
+ )
146
+ SELECT NOT EXISTS (SELECT 1 FROM direct WHERE relation = 'unavailable_in')
147
+ AND (
148
+ EXISTS (SELECT 1 FROM direct WHERE relation = 'available_in')
149
+ OR EXISTS (SELECT 1 FROM verdicts WHERE v = 'allow')
150
+ OR (NOT EXISTS (SELECT 1 FROM tup WHERE relation = 'available_in')
151
+ AND (EXISTS (SELECT 1 FROM verdicts WHERE v = 'none') OR NOT EXISTS (SELECT 1 FROM mine)))
152
+ )
153
+ )
154
+ END;
155
+ $$;
156
+
157
+ -- ---------------------------------------------------------------------------
158
+ -- 3. Como uma checagem de permissão é RENDERIZADA num predicado de varredura.
159
+ -- Um lugar só, porque quatro geradores a escrevem.
160
+ -- ---------------------------------------------------------------------------
161
+ CREATE OR REPLACE FUNCTION app.scaffold_perm_sql(p_perm text, p_scope text DEFAULT NULL::text)
162
+ RETURNS text
163
+ LANGUAGE sql IMMUTABLE
164
+ SET search_path TO ''
165
+ AS $$
166
+ -- `unit` = a coluna é NOT NULL, então o ramo do NULL é código morto e a forma
167
+ -- curta é a honesta. Fora disso, o CASE separa o caso que não depende da
168
+ -- linha (InitPlan, uma vez) do que depende (SubPlan, como sempre foi).
169
+ SELECT CASE WHEN p_scope = 'unit'
170
+ THEN format('(select app.has_permission(%L, unit_id))', p_perm)
171
+ ELSE format('(CASE WHEN unit_id IS NULL'
172
+ || ' THEN (select app.has_permission(%L, NULL::uuid))'
173
+ || ' ELSE (select app.has_permission(%L, unit_id)) END)', p_perm, p_perm)
174
+ END;
175
+ $$;
176
+
177
+ -- O `p_scope` novo faz desta uma função NOVA — a de um argumento continua
178
+ -- existindo —, e função nova nasce com EXECUTE para PUBLIC, que inclui `anon`.
179
+ -- Nada em `app` é chamável de fora; é o que a asserção N15c da postura grada.
180
+ REVOKE ALL ON FUNCTION app.scaffold_perm_sql(text, text) FROM PUBLIC;
181
+ GRANT ALL ON FUNCTION app.scaffold_perm_sql(text, text) TO service_role;
182
+
183
+ COMMENT ON FUNCTION app.scaffold_perm_sql(text, text) IS
184
+ 'Renderiza a checagem de permissão de um predicado de varredura (#016). O ramo `unit_id IS NULL` não menciona coluna nenhuma e por isso vira InitPlan — é o que impede uma pergunta de resposta constante de ser feita uma vez por linha.';
185
+
186
+ -- ---------------------------------------------------------------------------
187
+ -- 4. O predicado de escopo compartilhado por select/update/delete.
188
+ -- Mesmas decisões da 000; o que muda é onde elas são avaliadas.
189
+ -- ---------------------------------------------------------------------------
190
+ CREATE OR REPLACE FUNCTION app.scaffold_scope_sql(p_resource_type text, p_plugin text, p_scope text DEFAULT NULL::text)
191
+ RETURNS text
192
+ LANGUAGE sql IMMUTABLE
193
+ SET search_path TO ''
194
+ AS $$
195
+ SELECT 'tenant_id = (select app.current_tenant_id())'
196
+ || CASE WHEN p_scope = 'unit'
197
+ THEN ' AND (select app.has_unit(unit_id))'
198
+ ELSE ' AND (unit_id IS NULL OR (select app.has_unit(unit_id)))' END
199
+ || format(
200
+ ' AND (NOT (select app.owner_scoped(%L))'
201
+ || ' OR owner_id IS NULL OR owner_id = (select auth.uid())'
202
+ -- A guarda barata na frente da cara: sem tupla `shared_with` deste
203
+ -- tipo no tenant, `has_grant` só pode responder false, e agora
204
+ -- responde isso uma vez em vez de uma vez por linha.
205
+ || ' OR ((select app.grants_exist(%L, ARRAY[''shared_with'']))'
206
+ || ' AND (select app.has_grant(%L, id, ARRAY[''shared_with''])))'
207
+ || ' OR %s)',
208
+ p_resource_type, p_resource_type, p_resource_type,
209
+ app.scaffold_perm_sql(p_plugin || '.read_all', p_scope));
210
+ $$;
211
+
212
+ -- ---------------------------------------------------------------------------
213
+ -- 5. Os geradores de política. Idênticos aos da 000 fora a renderização.
214
+ -- ---------------------------------------------------------------------------
215
+ CREATE OR REPLACE FUNCTION app.scaffold_refresh_policies(p_table text) RETURNS void
216
+ LANGUAGE plpgsql SECURITY DEFINER
217
+ SET search_path TO ''
218
+ AS $$
219
+ DECLARE
220
+ v_schema text := split_part(replace(p_table, '"', ''), '.', 1);
221
+ v_name text := split_part(replace(p_table, '"', ''), '.', 2);
222
+ v_q text := format('%I.%I', split_part(replace(p_table, '"', ''), '.', 1), split_part(replace(p_table, '"', ''), '.', 2));
223
+ r app.scaffold_registry%ROWTYPE;
224
+ v_scope text;
225
+ v_class text;
226
+ BEGIN
227
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
228
+ IF NOT FOUND THEN
229
+ RAISE EXCEPTION 'scaffold_refresh_policies: %.% is not in the scaffold registry', v_schema, v_name USING ERRCODE = 'P0002';
230
+ END IF;
231
+
232
+ -- The `unit` rendering drops the `unit_id IS NULL OR` branch, which is only
233
+ -- sound when the column really is NOT NULL. Declaring a table `unit` while its
234
+ -- writers still produce NULLs is exactly the state this file leaves most tables
235
+ -- in — so the optimisation follows the CONSTRAINT, never the label. Getting
236
+ -- this wrong hid every unit-less financial movement and took
237
+ -- v_financial_customer_credit to 0.00.
238
+ SELECT CASE WHEN c.is_nullable = 'NO' THEN 'unit' ELSE NULL END INTO v_class
239
+ FROM information_schema.columns c
240
+ WHERE c.table_schema = v_schema AND c.table_name = v_name AND c.column_name = 'unit_id';
241
+ v_scope := app.scaffold_scope_sql(r.resource_type, r.plugin, v_class);
242
+
243
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
244
+ EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND %s)',
245
+ v_name || '_authz_select', v_q, v_scope, app.scaffold_perm_sql(r.plugin || '.read', v_class));
246
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
247
+ EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE TO authenticated USING (%s AND %s) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
248
+ v_name || '_authz_update', v_q, v_scope, app.scaffold_perm_sql(r.plugin || '.edit', v_class), r.plugin || '.edit');
249
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
250
+ EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND %s)',
251
+ v_name || '_authz_delete', v_q, v_scope, app.scaffold_perm_sql(r.plugin || '.delete', v_class));
252
+ END $$;
253
+
254
+ CREATE OR REPLACE FUNCTION app.scaffold_distributable(p_table regclass, p_resource_type text DEFAULT NULL::text, p_key_column text DEFAULT 'id'::text, p_unit_escape boolean DEFAULT true, p_enabled boolean DEFAULT true) RETURNS void
255
+ LANGUAGE plpgsql SECURITY DEFINER
256
+ SET search_path TO ''
257
+ AS $$
258
+ DECLARE
259
+ v_schema text; v_name text; v_q text; v_policy text;
260
+ r app.scaffold_registry%ROWTYPE; v_rt text;
261
+ BEGIN
262
+ SELECT n.nspname, c.relname INTO v_schema, v_name
263
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
264
+ v_q := format('%I.%I', v_schema, v_name);
265
+ v_policy := v_name || '_available_in';
266
+
267
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
268
+ IF NOT FOUND THEN
269
+ RAISE EXCEPTION 'scaffold_distributable: %.% is not scaffolded', v_schema, v_name USING ERRCODE = 'P0002';
270
+ END IF;
271
+ v_rt := coalesce(p_resource_type, r.resource_type);
272
+
273
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_policy, v_q);
274
+
275
+ IF p_enabled THEN
276
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
277
+ WHERE table_schema = v_schema AND table_name = v_name AND column_name = p_key_column) THEN
278
+ RAISE EXCEPTION 'scaffold_distributable: %.% has no column %', v_schema, v_name, p_key_column;
279
+ END IF;
280
+ -- RESTRICTIVE: permissive policies OR together, so a release rule that is
281
+ -- meant to REMOVE rows has to AND with the scaffold's select.
282
+ --
283
+ -- A guarda vem PRIMEIRO e sem coluna nenhuma: é o que a torna InitPlan.
284
+ -- Quando o tenant não distribui este tipo de recurso — o caso de todo
285
+ -- tenant hoje — a regra cara não é chamada nem uma vez.
286
+ EXECUTE format(
287
+ 'CREATE POLICY %I ON %s AS RESTRICTIVE FOR SELECT TO authenticated USING (NOT (select app.grants_exist(%L, ARRAY[''available_in'', ''unavailable_in''])) OR %s(select app.available_in_my_units(%L, %I)))',
288
+ v_policy, v_q, v_rt,
289
+ CASE WHEN p_unit_escape THEN 'unit_id IS NOT NULL OR ' ELSE '' END,
290
+ v_rt, p_key_column);
291
+ END IF;
292
+
293
+ UPDATE app.scaffold_registry
294
+ SET distributable = p_enabled,
295
+ dist_resource_type = CASE WHEN p_enabled THEN v_rt END,
296
+ dist_key_column = CASE WHEN p_enabled THEN p_key_column END,
297
+ dist_unit_escape = CASE WHEN p_enabled THEN p_unit_escape END
298
+ WHERE table_schema = v_schema AND table_name = v_name;
299
+ END $$;
300
+
301
+ -- O gerador de tabela nova: a mesma renderização, para que uma tabela criada
302
+ -- depois desta migration já nasça com a forma rápida.
303
+ CREATE OR REPLACE FUNCTION app.scaffold_table(p_table regclass, p_resource_type text, p_plugin text, p_owner_scoping boolean DEFAULT true, p_mode text DEFAULT 'enforce'::text) RETURNS void
304
+ LANGUAGE plpgsql SECURITY DEFINER
305
+ SET search_path TO ''
306
+ AS $$
307
+ DECLARE
308
+ v_schema text;
309
+ v_name text;
310
+ v_q text; -- quoted qualified name
311
+ v_prev text[] := '{}';
312
+ v_rows bigint;
313
+ r record;
314
+ v_scope text; -- the row-scope predicate shared by every policy
315
+ BEGIN
316
+ IF p_mode NOT IN ('shadow', 'enforce') THEN
317
+ RAISE EXCEPTION 'scaffold_table: mode must be shadow|enforce';
318
+ END IF;
319
+ SELECT n.nspname, c.relname INTO v_schema, v_name
320
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
321
+ v_q := format('%I.%I', v_schema, v_name);
322
+
323
+ -- ── columns
324
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'id') THEN
325
+ RAISE EXCEPTION 'scaffold_table: % has no id column', v_q;
326
+ END IF;
327
+ EXECUTE format('SELECT count(*) FROM %s', v_q) INTO v_rows;
328
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'tenant_id') THEN
329
+ EXECUTE format('ALTER TABLE %s ADD COLUMN tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE', v_q);
330
+ IF v_rows = 0 THEN EXECUTE format('ALTER TABLE %s ALTER COLUMN tenant_id SET NOT NULL', v_q); END IF;
331
+ END IF;
332
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS unit_id uuid', v_q);
333
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS owner_id uuid', v_q);
334
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', v_q);
335
+ EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', v_q);
336
+
337
+ -- ── unique(tenant_id, id) — enables composite FKs from other tables
338
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_tenant_id_id_key') THEN
339
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I UNIQUE (tenant_id, id)', v_q, v_name || '_tenant_id_id_key');
340
+ END IF;
341
+ -- ── composite FKs: unit within tenant, owner a member of the tenant
342
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_fk') THEN
343
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL', v_q, v_name || '_unit_fk');
344
+ END IF;
345
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_owner_fk') THEN
346
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL', v_q, v_name || '_owner_fk');
347
+ END IF;
348
+
349
+ -- ── tenant-first indexes
350
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, unit_id)', v_name || '_tenant_unit_idx', v_q);
351
+ EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, owner_id) WHERE owner_id IS NOT NULL', v_name || '_tenant_owner_idx', v_q);
352
+
353
+ -- ── updated_at
354
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_updated_at', v_q);
355
+ EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', v_name || '_updated_at', v_q);
356
+
357
+ -- ── RLS
358
+ EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', v_q);
359
+ EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', v_q);
360
+ IF p_mode = 'enforce' THEN
361
+ FOR r IN SELECT policyname FROM pg_policies WHERE schemaname = v_schema AND tablename = v_name
362
+ AND policyname NOT IN (v_name || '_authz_select', v_name || '_authz_insert', v_name || '_authz_update', v_name || '_authz_delete') LOOP
363
+ v_prev := v_prev || r.policyname;
364
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', r.policyname, v_q);
365
+ END LOOP;
366
+ END IF;
367
+
368
+ v_scope := app.scaffold_scope_sql(p_resource_type, p_plugin);
369
+
370
+ -- SELECT/UPDATE/DELETE varrem, então rendem pelo `scaffold_perm_sql`. O
371
+ -- WITH CHECK do INSERT não varre — ele julga a linha que está sendo escrita,
372
+ -- uma de cada vez — e por isso fica como estava.
373
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
374
+ EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND %s)',
375
+ v_name || '_authz_select', v_q, v_scope, app.scaffold_perm_sql(p_plugin || '.read'));
376
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_insert', v_q);
377
+ EXECUTE format('CREATE POLICY %I ON %s FOR INSERT TO authenticated WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
378
+ v_name || '_authz_insert', v_q, p_plugin || '.create');
379
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
380
+ EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE TO authenticated USING (%s AND %s) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
381
+ v_name || '_authz_update', v_q, v_scope, app.scaffold_perm_sql(p_plugin || '.edit'), p_plugin || '.edit');
382
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
383
+ EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND %s)',
384
+ v_name || '_authz_delete', v_q, v_scope, app.scaffold_perm_sql(p_plugin || '.delete'));
385
+
386
+ -- scope defaults on REGISTRATION, so a table cannot exist in the registry
387
+ -- unclassified. A plugin's tables are scaffolded after the core chain has run,
388
+ -- so 136's DO block could never have reached them — they arrive here instead,
389
+ -- declared `unit_optional` until the plugin says otherwise.
390
+ INSERT INTO app.scaffold_registry (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, scope)
391
+ VALUES (v_schema, v_name, p_resource_type, p_plugin, p_owner_scoping, p_mode, v_prev, 'unit_optional')
392
+ ON CONFLICT (table_schema, table_name) DO UPDATE
393
+ SET resource_type = EXCLUDED.resource_type, plugin = EXCLUDED.plugin, owner_scoping = EXCLUDED.owner_scoping,
394
+ scope = coalesce(app.scaffold_registry.scope, EXCLUDED.scope),
395
+ mode = EXCLUDED.mode, applied_at = now(),
396
+ previous_policies = CASE WHEN EXCLUDED.mode = 'enforce' AND app.scaffold_registry.mode = 'shadow'
397
+ THEN app.scaffold_registry.previous_policies || EXCLUDED.previous_policies
398
+ ELSE EXCLUDED.previous_policies END;
399
+ END $$;
400
+
401
+ -- A view mascarada renderiza O MESMO predicado, com o mesmo `has_permission`
402
+ -- por linha. Herda a correção pela mesma porta.
403
+ CREATE OR REPLACE FUNCTION app.masked_view_refresh(p_table regclass, p_context text, p_reference text) RETURNS void
404
+ LANGUAGE plpgsql SECURITY DEFINER
405
+ SET search_path TO ''
406
+ AS $$
407
+ DECLARE
408
+ v_schema text;
409
+ v_name text;
410
+ v_view text;
411
+ r app.scaffold_registry%ROWTYPE;
412
+ v_cols text;
413
+ v_class text;
414
+ v_structural constant text[] := ARRAY['id','tenant_id','unit_id','owner_id','created_at','updated_at'];
415
+ BEGIN
416
+ SELECT n.nspname, c.relname INTO v_schema, v_name
417
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
418
+
419
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
420
+ IF NOT FOUND THEN
421
+ RAISE EXCEPTION 'masked_view_refresh: %.% is not scaffolded — the view renders the scaffold''s own predicate', v_schema, v_name
422
+ USING ERRCODE = 'P0002';
423
+ END IF;
424
+ IF NOT EXISTS (SELECT 1 FROM public.field_rule_contexts WHERE key = p_context) THEN
425
+ RAISE EXCEPTION 'masked_view_refresh: unknown field-rule context %', p_context USING ERRCODE = '23503';
426
+ END IF;
427
+
428
+ v_view := 'v_' || v_name;
429
+
430
+ SELECT string_agg(
431
+ CASE WHEN c.column_name = ANY (v_structural)
432
+ THEN format('%I', c.column_name)
433
+ -- `@>` and not `= ANY (…)`: a parenthesised scalar subquery is
434
+ -- parsed as ANY's SUBQUERY form, which compares against rows and
435
+ -- chokes on a text[]. Containment takes the array directly and
436
+ -- still plans as a once-per-statement InitPlan.
437
+ ELSE format('CASE WHEN NOT ((select app.hidden_fields(%L, %L)) @> ARRAY[%L::text]) THEN %I END AS %I',
438
+ p_context, p_reference, c.column_name, c.column_name, c.column_name)
439
+ END, E',\n ' ORDER BY c.ordinal_position)
440
+ INTO v_cols
441
+ FROM information_schema.columns c
442
+ WHERE c.table_schema = v_schema AND c.table_name = v_name;
443
+
444
+ SELECT CASE WHEN c.is_nullable = 'NO' THEN 'unit' ELSE NULL END INTO v_class
445
+ FROM information_schema.columns c
446
+ WHERE c.table_schema = v_schema AND c.table_name = v_name AND c.column_name = 'unit_id';
447
+
448
+ EXECUTE format('DROP VIEW IF EXISTS %I.%I', v_schema, v_view);
449
+ -- Owner rights on purpose (see the header): the predicate below IS the
450
+ -- scaffold's, rendered from the same function the policies render from.
451
+ EXECUTE format(
452
+ 'CREATE VIEW %I.%I AS SELECT %s FROM %I.%I WHERE %s AND %s',
453
+ v_schema, v_view, v_cols, v_schema, v_name,
454
+ app.scaffold_scope_sql(r.resource_type, r.plugin, v_class),
455
+ app.scaffold_perm_sql(r.plugin || '.read', v_class));
456
+
457
+ EXECUTE format('REVOKE ALL ON %I.%I FROM public, anon', v_schema, v_view);
458
+ EXECUTE format('GRANT SELECT ON %I.%I TO authenticated, service_role', v_schema, v_view);
459
+ EXECUTE format(
460
+ 'COMMENT ON VIEW %I.%I IS %L',
461
+ v_schema, v_view,
462
+ format('Masked read of %I.%I (#221): every non-structural column reads NULL when field_rules hides it for the caller''s role and unit. Owner rights with the scaffold''s own row predicate — regenerate with app.masked_view_refresh(''%I.%I'', %L, %L), never edit by hand.',
463
+ v_schema, v_name, v_schema, v_name, p_context, p_reference));
464
+
465
+ INSERT INTO app.masked_views (table_schema, table_name, view_name, context, reference)
466
+ VALUES (v_schema, v_name, v_view, p_context, p_reference)
467
+ ON CONFLICT (table_schema, table_name) DO UPDATE
468
+ SET view_name = EXCLUDED.view_name, context = EXCLUDED.context,
469
+ reference = EXCLUDED.reference, applied_at = now();
470
+ END $$;
471
+
472
+ -- ---------------------------------------------------------------------------
473
+ -- 6. Reescrever o que já está no banco. Um gerador corrigido não corrige nada
474
+ -- sozinho: o texto das políticas existentes foi assado quando elas foram
475
+ -- criadas. Idempotente — cada gerador derruba e recria a sua própria
476
+ -- política a partir do registro.
477
+ -- ---------------------------------------------------------------------------
478
+ DO $$
479
+ DECLARE r record; v_n int := 0;
480
+ BEGIN
481
+ FOR r IN SELECT table_schema, table_name FROM app.scaffold_registry ORDER BY 1, 2 LOOP
482
+ -- `public.items` e `public.item_associations` ficam de fora, e é a única
483
+ -- exceção nominal desta migration.
484
+ --
485
+ -- A permissão delas NÃO é constante por tabela: a linha de uma fatura pede
486
+ -- `financial.read`, a de uma remessa `inventory.read`, a de um pedido
487
+ -- `orders.read`, e quem decide é a `parent_kind` — coluna gerada, uma por
488
+ -- LINHA. O baseline escreveu essas quatro políticas à mão com esse CASE
489
+ -- dentro, e o renderizador genérico só sabe emitir um `plugin.ação` fixo:
490
+ -- regerá-las aqui achataria o despacho e tornaria uma linha de fatura
491
+ -- legível com `orders.read` sozinho. É fronteira de permissão, não estilo,
492
+ -- e a suíte 110_commercial_line existe para não deixar isso passar.
493
+ --
494
+ -- O custo de ficarem fora é a checagem por linha que a 016 tira das outras;
495
+ -- é um custo conhecido e escrito, e não foi delas que veio o teto de 8s.
496
+ -- Expressá-las no registro (uma coluna de despacho por plugin) é o
497
+ -- trabalho que fecha esta exceção.
498
+ CONTINUE WHEN r.table_schema = 'public' AND r.table_name IN ('items', 'item_associations');
499
+ -- Uma linha do registro pode apontar para uma tabela que já não existe
500
+ -- (plugin desinstalado). Pular é a resposta certa: não é papel desta
501
+ -- migration ter opinião sobre isso.
502
+ CONTINUE WHEN to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NULL;
503
+ -- E pode apontar para uma tabela que não tem as colunas do andaime: 16
504
+ -- linhas de plugin entraram no registro por INSERT direto, sem passar pelo
505
+ -- `scaffold_table` que as cria, e ficaram sem `unit_id`, sem `owner_id` ou
506
+ -- (uma) sem `id`. O gerador NUNCA conseguiu rodar nelas — o predicado que
507
+ -- ele escreve nomeia as quatro — e as políticas que elas têm foram escritas
508
+ -- à mão. Pular preserva o que já valia; tentar derrubaria a migration
509
+ -- inteira por causa de uma tabela que ela não governa.
510
+ --
511
+ -- É um desalinhamento real entre o registro e o esquema, e ele continua
512
+ -- depois desta migration. Fica o NOTICE; consertar é assunto do plugin.
513
+ CONTINUE WHEN EXISTS (
514
+ SELECT 1 FROM unnest(ARRAY['id', 'tenant_id', 'unit_id', 'owner_id']) col
515
+ WHERE NOT EXISTS (
516
+ SELECT 1 FROM information_schema.columns c
517
+ WHERE c.table_schema = r.table_schema AND c.table_name = r.table_name
518
+ AND c.column_name = col));
519
+ PERFORM app.scaffold_refresh_policies(r.table_schema || '.' || r.table_name);
520
+ v_n := v_n + 1;
521
+ END LOOP;
522
+ RAISE NOTICE '016: % tabela(s) com o predicado de varredura regerado', v_n;
523
+
524
+ v_n := 0;
525
+ FOR r IN SELECT table_schema, table_name, dist_resource_type, dist_key_column, dist_unit_escape
526
+ FROM app.scaffold_registry WHERE distributable ORDER BY 1, 2 LOOP
527
+ CONTINUE WHEN to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NULL;
528
+ PERFORM app.scaffold_distributable(
529
+ to_regclass(format('%I.%I', r.table_schema, r.table_name)),
530
+ r.dist_resource_type, coalesce(r.dist_key_column, 'id'), coalesce(r.dist_unit_escape, true), true);
531
+ v_n := v_n + 1;
532
+ END LOOP;
533
+ RAISE NOTICE '016: % política(s) de distribuição regerada(s)', v_n;
534
+
535
+ v_n := 0;
536
+ FOR r IN SELECT table_schema, table_name, context, reference FROM app.masked_views ORDER BY 1, 2 LOOP
537
+ CONTINUE WHEN to_regclass(format('%I.%I', r.table_schema, r.table_name)) IS NULL;
538
+ PERFORM app.masked_view_refresh(
539
+ to_regclass(format('%I.%I', r.table_schema, r.table_name)), r.context, r.reference);
540
+ v_n := v_n + 1;
541
+ END LOOP;
542
+ RAISE NOTICE '016: % view(s) mascarada(s) regerada(s)', v_n;
543
+ END $$;