@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,312 @@
1
+ -- ============================================================================
2
+ -- 004_the_projections_get_a_successor — what replaces locations,
3
+ -- location_members and tenant_members (#214 / #352).
4
+ --
5
+ -- #134 moved tenancy into app.* and left three COMPAT PROJECTIONS in public so
6
+ -- the screens kept answering. They are real tables kept in step by triggers,
7
+ -- and their comments have said "Removal: Phase 1" since 2026-08-18.
8
+ --
9
+ -- They could not simply be dropped, for a reason worth writing down: PostgREST
10
+ -- cannot reach the `app` schema, so "read app.* directly" was never available
11
+ -- to a caller. The projection was not laziness — it was the only door.
12
+ --
13
+ -- A security_invoker VIEW is the door that should have been built. RLS on
14
+ -- app.* still decides every row (the policies are already there:
15
+ -- membership_units_write wants members.manage), the shape stays what the 41
16
+ -- callers already expect, and nothing is a second copy of anything.
17
+ --
18
+ -- So the three views below are DROP-IN: same column names, same derivation as
19
+ -- the trigger that maintained the table. app.compat_sync_unit is where the
20
+ -- unit mapping comes from, expression for expression, which is why this reads
21
+ -- like it was copied — it was, deliberately, so the two cannot disagree.
22
+ --
23
+ -- TWO THINGS A VIEW CANNOT DO, and what happens instead:
24
+ --
25
+ -- · app.membership_units has no `id` column; the projection synthesised one.
26
+ -- Here it is derived from (tenant, user, unit), which is the key that was
27
+ -- already unique — so it is stable across reads rather than a fresh uuid
28
+ -- each time, which is what a React key needs it to be.
29
+ -- · PostgREST's upsert needs a unique CONSTRAINT, and a view has none. The
30
+ -- two call sites that upserted a unit membership get an RPC instead, which
31
+ -- is better anyway: assigning somebody to a unit is a verb, and it now
32
+ -- checks members.manage in one place instead of trusting the policy to
33
+ -- catch a malformed write.
34
+ -- ============================================================================
35
+
36
+ CREATE OR REPLACE VIEW public.v_team_members WITH (security_invoker='true') AS
37
+ SELECT m.id, m.tenant_id, m.user_id, r.key AS role, m.created_at, m.person_id
38
+ FROM app.memberships m
39
+ LEFT JOIN app.roles r ON r.id = m.role_id
40
+ WHERE m.active;
41
+
42
+ COMMENT ON VIEW public.v_team_members IS
43
+ 'Active memberships with the role KEY (#214). Drop-in successor to public.tenant_members: same columns, security_invoker, so app.memberships RLS decides the rows.';
44
+
45
+ CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
46
+ SELECT u.id,
47
+ u.tenant_id,
48
+ coalesce(u.metadata ->> 'legacy_kind', u.kind) AS kind,
49
+ u.name,
50
+ u.metadata ->> 'email' AS email,
51
+ u.metadata ->> 'phone' AS phone,
52
+ u.metadata ->> 'address' AS address,
53
+ u.metadata ->> 'city' AS city,
54
+ u.metadata ->> 'state' AS state,
55
+ coalesce(u.metadata ->> 'country', 'BR') AS country,
56
+ u.metadata ->> 'postal_code' AS postal_code,
57
+ (u.kind = 'hq') AS is_headquarters,
58
+ u.active AS is_active,
59
+ coalesce((SELECT array_agg(t) FROM jsonb_array_elements_text(
60
+ CASE WHEN jsonb_typeof(u.metadata -> 'tags') = 'array'
61
+ THEN u.metadata -> 'tags' ELSE '[]'::jsonb END) t), '{}'::text[]) AS tags,
62
+ u.metadata ->> 'notes' AS notes,
63
+ u.metadata,
64
+ CASE WHEN jsonb_typeof(u.metadata -> 'custom_fields') = 'object'
65
+ THEN u.metadata -> 'custom_fields' ELSE '{}'::jsonb END AS custom_fields,
66
+ -- app.compat_uuid inlined: it is a guarded cast, and calling it here
67
+ -- would make every reader of this view need EXECUTE on an app.* function
68
+ -- as well as SELECT on the view. A view should not carry a second grant.
69
+ CASE WHEN u.metadata ->> 'created_by' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
70
+ THEN (u.metadata ->> 'created_by')::uuid END AS created_by,
71
+ u.created_at,
72
+ u.updated_at,
73
+ u.parent_unit_id AS parent_id,
74
+ u.metadata ->> 'code' AS code,
75
+ -- Guarded casts: metadata is merchant-writable, and a view that throws
76
+ -- on one bad string takes the whole units screen down with it.
77
+ CASE WHEN u.metadata ->> 'latitude' ~ '^-?[0-9]+(\.[0-9]+)?$'
78
+ THEN (u.metadata ->> 'latitude')::numeric END AS latitude,
79
+ CASE WHEN u.metadata ->> 'longitude' ~ '^-?[0-9]+(\.[0-9]+)?$'
80
+ THEN (u.metadata ->> 'longitude')::numeric END AS longitude
81
+ FROM app.units u;
82
+
83
+ COMMENT ON VIEW public.v_units IS
84
+ 'Units in the shape the units screen reads (#214). Drop-in successor to public.locations, deriving the same columns from app.units + metadata as app.compat_sync_unit did.';
85
+
86
+ CREATE OR REPLACE VIEW public.v_unit_members WITH (security_invoker='true') AS
87
+ SELECT md5(mu.tenant_id::text || ':' || mu.user_id::text || ':' || mu.unit_id::text)::uuid AS id,
88
+ mu.unit_id AS location_id,
89
+ mu.user_id,
90
+ coalesce(ro.key, r.key) AS role,
91
+ mu.tenant_id,
92
+ mu.created_at
93
+ FROM app.membership_units mu
94
+ LEFT JOIN app.roles ro ON ro.id = mu.role_override_id
95
+ LEFT JOIN app.memberships m ON m.tenant_id = mu.tenant_id AND m.user_id = mu.user_id
96
+ LEFT JOIN app.roles r ON r.id = m.role_id;
97
+
98
+ COMMENT ON VIEW public.v_unit_members IS
99
+ 'Who is bound to which unit, with the EFFECTIVE role key — the per-unit override when there is one, otherwise the membership role (#214). Drop-in successor to public.location_members; `id` is derived from (tenant, user, unit) because app.membership_units has none and that triple was already the key.';
100
+
101
+ REVOKE ALL ON public.v_team_members, public.v_units, public.v_unit_members FROM PUBLIC;
102
+ GRANT SELECT ON public.v_team_members, public.v_units, public.v_unit_members TO authenticated, service_role;
103
+
104
+ -- ── the two verbs a view cannot carry ──────────────────────────────────────
105
+
106
+ CREATE OR REPLACE FUNCTION public.unit_member_set(p_unit uuid, p_user uuid, p_role text DEFAULT NULL)
107
+ RETURNS void
108
+ LANGUAGE plpgsql
109
+ SECURITY DEFINER
110
+ SET search_path TO ''
111
+ AS $function$
112
+ DECLARE
113
+ v_tenant uuid := app.current_tenant_id();
114
+ v_role uuid;
115
+ BEGIN
116
+ IF auth.uid() IS NULL OR v_tenant IS NULL THEN
117
+ RAISE EXCEPTION 'unit_member_set: no active tenant for this session' USING ERRCODE = '42501';
118
+ END IF;
119
+ IF NOT app.has_permission('members.manage') THEN
120
+ RAISE EXCEPTION 'unit_member_set: members.manage is required' USING ERRCODE = '42501';
121
+ END IF;
122
+ IF NOT EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = v_tenant AND u.id = p_unit) THEN
123
+ RAISE EXCEPTION 'unit_member_set: no unit % in the active tenant', p_unit USING ERRCODE = 'P0002';
124
+ END IF;
125
+ IF NOT EXISTS (SELECT 1 FROM app.memberships m WHERE m.tenant_id = v_tenant AND m.user_id = p_user) THEN
126
+ RAISE EXCEPTION 'unit_member_set: % is not a member of this tenant', p_user USING ERRCODE = '23503';
127
+ END IF;
128
+
129
+ -- NULL role = bound to the unit with no override, which is not the same as
130
+ -- "no role": the membership's own role still applies. Only a NAMED role is
131
+ -- an override, and an unknown one is refused rather than silently ignored.
132
+ IF p_role IS NOT NULL THEN
133
+ SELECT r.id INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.key = p_role;
134
+ IF v_role IS NULL THEN
135
+ RAISE EXCEPTION 'unit_member_set: no role % in this tenant', p_role USING ERRCODE = '23503';
136
+ END IF;
137
+ END IF;
138
+
139
+ INSERT INTO app.membership_units (tenant_id, user_id, unit_id, role_override_id)
140
+ VALUES (v_tenant, p_user, p_unit, v_role)
141
+ ON CONFLICT (tenant_id, user_id, unit_id) DO UPDATE SET role_override_id = EXCLUDED.role_override_id;
142
+ END $function$;
143
+
144
+ CREATE OR REPLACE FUNCTION public.unit_member_remove(p_unit uuid, p_user uuid)
145
+ RETURNS void
146
+ LANGUAGE plpgsql
147
+ SECURITY DEFINER
148
+ SET search_path TO ''
149
+ AS $function$
150
+ DECLARE v_tenant uuid := app.current_tenant_id();
151
+ BEGIN
152
+ IF auth.uid() IS NULL OR v_tenant IS NULL THEN
153
+ RAISE EXCEPTION 'unit_member_remove: no active tenant for this session' USING ERRCODE = '42501';
154
+ END IF;
155
+ IF NOT app.has_permission('members.manage') THEN
156
+ RAISE EXCEPTION 'unit_member_remove: members.manage is required' USING ERRCODE = '42501';
157
+ END IF;
158
+ DELETE FROM app.membership_units
159
+ WHERE tenant_id = v_tenant AND user_id = p_user AND unit_id = p_unit;
160
+ END $function$;
161
+
162
+ REVOKE ALL ON FUNCTION public.unit_member_set(uuid, uuid, text) FROM PUBLIC;
163
+ REVOKE ALL ON FUNCTION public.unit_member_remove(uuid, uuid) FROM PUBLIC;
164
+ GRANT EXECUTE ON FUNCTION public.unit_member_set(uuid, uuid, text) TO authenticated, service_role;
165
+ GRANT EXECUTE ON FUNCTION public.unit_member_remove(uuid, uuid) TO authenticated, service_role;
166
+
167
+ COMMENT ON FUNCTION public.unit_member_set(uuid, uuid, text) IS
168
+ 'Bind a member to a unit, optionally with a role override (#214). Replaces the upsert the location_members projection accepted.';
169
+ COMMENT ON FUNCTION public.unit_member_remove(uuid, uuid) IS
170
+ 'Unbind a member from a unit (#214).';
171
+
172
+ -- ── the team-membership verbs the projection used to accept as writes ──────
173
+ -- The Team screen updated a role, removed a member and created a unit by
174
+ -- writing the projection and letting a trigger forward it. A joined view
175
+ -- cannot accept those, and should not: each is a verb with a rule attached.
176
+
177
+ CREATE OR REPLACE FUNCTION public.member_set_role(p_membership uuid, p_role text)
178
+ RETURNS void
179
+ LANGUAGE plpgsql
180
+ SECURITY DEFINER
181
+ SET search_path TO ''
182
+ AS $function$
183
+ DECLARE
184
+ v_tenant uuid := app.current_tenant_id();
185
+ v_role uuid;
186
+ v_owners integer;
187
+ v_was uuid;
188
+ BEGIN
189
+ IF auth.uid() IS NULL OR v_tenant IS NULL THEN
190
+ RAISE EXCEPTION 'member_set_role: no active tenant for this session' USING ERRCODE = '42501';
191
+ END IF;
192
+ IF NOT app.has_permission('members.manage') THEN
193
+ RAISE EXCEPTION 'member_set_role: members.manage is required' USING ERRCODE = '42501';
194
+ END IF;
195
+
196
+ SELECT r.id INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.key = p_role;
197
+ IF v_role IS NULL THEN
198
+ RAISE EXCEPTION 'member_set_role: no role % in this tenant', p_role USING ERRCODE = '23503';
199
+ END IF;
200
+
201
+ SELECT m.role_id INTO v_was FROM app.memberships m WHERE m.tenant_id = v_tenant AND m.id = p_membership;
202
+ IF v_was IS NULL THEN
203
+ RAISE EXCEPTION 'member_set_role: membership not found in the active tenant' USING ERRCODE = 'P0002';
204
+ END IF;
205
+
206
+ -- Demoting the last owner locks everybody out of the tenant, and the screen
207
+ -- that does it has no way to know it was the last one. Refuse here, where
208
+ -- the count is cheap and correct.
209
+ IF EXISTS (SELECT 1 FROM app.roles r WHERE r.id = v_was AND r.key = 'owner')
210
+ AND NOT EXISTS (SELECT 1 FROM app.roles r WHERE r.id = v_role AND r.key = 'owner') THEN
211
+ SELECT count(*) INTO v_owners
212
+ FROM app.memberships m JOIN app.roles r ON r.id = m.role_id
213
+ WHERE m.tenant_id = v_tenant AND m.active AND r.key = 'owner';
214
+ IF v_owners <= 1 THEN
215
+ RAISE EXCEPTION 'member_set_role: this is the last owner — promote somebody else first'
216
+ USING ERRCODE = '23503';
217
+ END IF;
218
+ END IF;
219
+
220
+ UPDATE app.memberships SET role_id = v_role, updated_at = now()
221
+ WHERE tenant_id = v_tenant AND id = p_membership;
222
+ END $function$;
223
+
224
+ CREATE OR REPLACE FUNCTION public.member_remove(p_membership uuid)
225
+ RETURNS void
226
+ LANGUAGE plpgsql
227
+ SECURITY DEFINER
228
+ SET search_path TO ''
229
+ AS $function$
230
+ DECLARE
231
+ v_tenant uuid := app.current_tenant_id();
232
+ v_owner boolean;
233
+ v_owners integer;
234
+ BEGIN
235
+ IF auth.uid() IS NULL OR v_tenant IS NULL THEN
236
+ RAISE EXCEPTION 'member_remove: no active tenant for this session' USING ERRCODE = '42501';
237
+ END IF;
238
+ IF NOT app.has_permission('members.manage') THEN
239
+ RAISE EXCEPTION 'member_remove: members.manage is required' USING ERRCODE = '42501';
240
+ END IF;
241
+
242
+ SELECT EXISTS (SELECT 1 FROM app.roles r WHERE r.id = m.role_id AND r.key = 'owner')
243
+ INTO v_owner FROM app.memberships m WHERE m.tenant_id = v_tenant AND m.id = p_membership;
244
+ IF v_owner IS NULL THEN
245
+ RAISE EXCEPTION 'member_remove: membership not found in the active tenant' USING ERRCODE = 'P0002';
246
+ END IF;
247
+ IF v_owner THEN
248
+ SELECT count(*) INTO v_owners
249
+ FROM app.memberships m JOIN app.roles r ON r.id = m.role_id
250
+ WHERE m.tenant_id = v_tenant AND m.active AND r.key = 'owner';
251
+ IF v_owners <= 1 THEN
252
+ RAISE EXCEPTION 'member_remove: this is the last owner — a tenant nobody owns cannot be administered'
253
+ USING ERRCODE = '23503';
254
+ END IF;
255
+ END IF;
256
+
257
+ -- DEACTIVATE, not delete. The projection's DELETE was forwarded to an UPDATE
258
+ -- that set active = false, and the history is the point: who was on the team
259
+ -- in March is a question an audit asks. v_team_members filters on active, so
260
+ -- the row disappears from every screen either way.
261
+ UPDATE app.memberships SET active = false, updated_at = now()
262
+ WHERE tenant_id = v_tenant AND id = p_membership;
263
+ END $function$;
264
+
265
+ CREATE OR REPLACE FUNCTION public.unit_create(p_name text, p_kind text DEFAULT 'branch',
266
+ p_parent_unit uuid DEFAULT NULL, p_metadata jsonb DEFAULT '{}'::jsonb)
267
+ RETURNS uuid
268
+ LANGUAGE plpgsql
269
+ SECURITY DEFINER
270
+ SET search_path TO ''
271
+ AS $function$
272
+ DECLARE
273
+ v_tenant uuid := app.current_tenant_id();
274
+ v_id uuid;
275
+ BEGIN
276
+ IF auth.uid() IS NULL OR v_tenant IS NULL THEN
277
+ RAISE EXCEPTION 'unit_create: no active tenant for this session' USING ERRCODE = '42501';
278
+ END IF;
279
+ IF NOT app.has_permission('units.manage') THEN
280
+ RAISE EXCEPTION 'unit_create: units.manage is required' USING ERRCODE = '42501';
281
+ END IF;
282
+ IF p_name IS NULL OR btrim(p_name) = '' THEN
283
+ RAISE EXCEPTION 'unit_create: a name is required' USING ERRCODE = '22023';
284
+ END IF;
285
+ IF p_kind NOT IN ('hq', 'branch') THEN
286
+ RAISE EXCEPTION 'unit_create: kind must be hq or branch (got %)', p_kind USING ERRCODE = '22023';
287
+ END IF;
288
+ IF p_parent_unit IS NOT NULL
289
+ AND NOT EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = v_tenant AND u.id = p_parent_unit) THEN
290
+ RAISE EXCEPTION 'unit_create: no parent unit % in this tenant', p_parent_unit USING ERRCODE = 'P0002';
291
+ END IF;
292
+
293
+ INSERT INTO app.units (tenant_id, kind, name, parent_unit_id, active, metadata)
294
+ VALUES (v_tenant, p_kind, btrim(p_name), p_parent_unit, true, coalesce(p_metadata, '{}'::jsonb))
295
+ RETURNING id INTO v_id;
296
+
297
+ RETURN v_id;
298
+ END $function$;
299
+
300
+ REVOKE ALL ON FUNCTION public.member_set_role(uuid, text) FROM PUBLIC;
301
+ REVOKE ALL ON FUNCTION public.member_remove(uuid) FROM PUBLIC;
302
+ REVOKE ALL ON FUNCTION public.unit_create(text, text, uuid, jsonb) FROM PUBLIC;
303
+ GRANT EXECUTE ON FUNCTION public.member_set_role(uuid, text) TO authenticated, service_role;
304
+ GRANT EXECUTE ON FUNCTION public.member_remove(uuid) TO authenticated, service_role;
305
+ GRANT EXECUTE ON FUNCTION public.unit_create(text, text, uuid, jsonb) TO authenticated, service_role;
306
+
307
+ COMMENT ON FUNCTION public.member_set_role(uuid, text) IS
308
+ 'Change a member''s role (#214). Refuses to demote the last owner — the screen doing it cannot know it was the last.';
309
+ COMMENT ON FUNCTION public.member_remove(uuid) IS
310
+ 'Deactivate a membership (#214). Refuses the last owner. Deactivates rather than deletes: the projection it replaces did the same, and who was on the team last month is a question an audit asks.';
311
+ COMMENT ON FUNCTION public.unit_create(text, text, uuid, jsonb) IS
312
+ 'Create a unit (#214). Replaces the INSERT the locations projection accepted; the address lives in metadata, which is where app.units keeps it.';
@@ -0,0 +1,90 @@
1
+ -- ============================================================================
2
+ -- 005_the_archetypes_anchor_to_the_unit — eight foreign keys stop pointing at
3
+ -- a projection (#214 / #352).
4
+ --
5
+ -- Found by applying the chain and then trying to drop public.locations: eight
6
+ -- core tables have a FOREIGN KEY into it.
7
+ --
8
+ -- appointments.location_id orders.location_id schedules.location_id
9
+ -- categories.unit_id people.unit_id products.unit_id
10
+ -- tasks.unit_id user_unit_prefs.unit_id
11
+ --
12
+ -- That is not merely why the projection could not be dropped. It is a defect on
13
+ -- its own terms: public.locations is a COMPAT PROJECTION of app.units, kept in
14
+ -- step by a trigger (app.compat_sync_unit), and referential integrity for the
15
+ -- whole unit-scoping layer was resting on a DERIVED table. If the trigger ever
16
+ -- fell behind, the constraint would be enforcing against a copy.
17
+ --
18
+ -- Repointed to app.units(id), which is the real thing and already has the
19
+ -- primary key to be referenced by. ON DELETE is preserved exactly — SET NULL
20
+ -- everywhere except user_unit_prefs, which CASCADEs, because a preference for
21
+ -- a unit that no longer exists is not a preference.
22
+ --
23
+ -- SAFE BECAUSE THE IDS ARE THE SAME. The projection's own comment says so
24
+ -- ("same ids"), and the guard below proves it per pool before touching
25
+ -- anything: if a single row names a unit that app.units does not have, the
26
+ -- migration says so and changes nothing, rather than dropping a constraint and
27
+ -- failing to re-add it.
28
+ --
29
+ -- locations_parent_same_tenant is deliberately not moved: it is locations' own
30
+ -- self-reference and leaves with the table. Neither is location_members', for
31
+ -- the same reason.
32
+ --
33
+ -- DISCOVERED, NOT NAMED. The first version of this file listed the eight
34
+ -- constraints it had seen. Then a real pool turned out to have NINE — it
35
+ -- predates #351, so public.services still exists and still carries
36
+ -- services_unit_id_fkey. A hardcoded list is a photograph of one database, and
37
+ -- the fleet is not one database. So this reads pg_constraint and moves whatever
38
+ -- it finds, which is also what makes it correct on a pool nobody has looked at.
39
+ -- ============================================================================
40
+
41
+ DO $fk$
42
+ DECLARE
43
+ r record;
44
+ v_orph bigint;
45
+ v_moved integer := 0;
46
+ BEGIN
47
+ IF to_regclass('app.units') IS NULL OR to_regclass('public.locations') IS NULL THEN RETURN; END IF;
48
+
49
+ FOR r IN
50
+ SELECT c.conname,
51
+ src.relname AS src_table,
52
+ a.attname AS src_col,
53
+ CASE c.confdeltype WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL'
54
+ WHEN 'd' THEN 'SET DEFAULT' WHEN 'r' THEN 'RESTRICT'
55
+ ELSE 'NO ACTION' END AS on_delete
56
+ FROM pg_constraint c
57
+ JOIN pg_class src ON src.oid = c.conrelid
58
+ JOIN pg_class tgt ON tgt.oid = c.confrelid
59
+ JOIN pg_namespace tn ON tn.oid = tgt.relnamespace
60
+ JOIN pg_namespace sn ON sn.oid = src.relnamespace
61
+ JOIN pg_attribute a ON a.attrelid = src.oid AND a.attnum = c.conkey[1]
62
+ WHERE c.contype = 'f'
63
+ AND tn.nspname = 'public' AND tgt.relname = 'locations'
64
+ AND sn.nspname = 'public'
65
+ -- Single-column only. The one multi-column FK here is locations' own
66
+ -- (parent_id, tenant_id) self-reference, which leaves with the table.
67
+ AND array_length(c.conkey, 1) = 1
68
+ -- Anything whose own table is going away in the same wave: its FK goes
69
+ -- with it, and repointing it first would be work thrown away.
70
+ AND src.relname NOT IN ('locations', 'location_members')
71
+ LOOP
72
+ EXECUTE format(
73
+ 'SELECT count(*) FROM public.%I x WHERE x.%I IS NOT NULL
74
+ AND NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = x.%I)',
75
+ r.src_table, r.src_col, r.src_col) INTO v_orph;
76
+ IF v_orph > 0 THEN
77
+ RAISE EXCEPTION '005: %.% has % row(s) naming a unit app.units does not have — reconcile before repointing',
78
+ r.src_table, r.src_col, v_orph USING ERRCODE = '23503';
79
+ END IF;
80
+
81
+ EXECUTE format('ALTER TABLE public.%I DROP CONSTRAINT %I', r.src_table, r.conname);
82
+ EXECUTE format('ALTER TABLE public.%I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES app.units(id) ON DELETE %s',
83
+ r.src_table, r.conname, r.src_col, r.on_delete);
84
+ v_moved := v_moved + 1;
85
+ END LOOP;
86
+
87
+ IF v_moved > 0 THEN
88
+ RAISE NOTICE '005: % foreign key(s) repointed from public.locations to app.units', v_moved;
89
+ END IF;
90
+ END $fk$;
@@ -0,0 +1,153 @@
1
+ -- ============================================================================
2
+ -- 006_the_helpers_read_the_source — six SQL functions and one policy stop
3
+ -- reading the compat projections (#214 / #352).
4
+ --
5
+ -- Found by applying the chain and then trying to drop the three projections.
6
+ -- One object BLOCKED the drop — the profiles_select policy. Six others did
7
+ -- not block it and were the real hazard:
8
+ --
9
+ -- is_tenant_member user_admin_tenant_ids user_unit_ids
10
+ -- unit_bootstrap tenant_member_activity unlinked_member_count
11
+ --
12
+ -- All six are LANGUAGE sql, and a classic SQL function body is a string that
13
+ -- Postgres does not resolve until it runs. So the DROP would have succeeded,
14
+ -- and the breakage would have arrived later, at a call — and three of these
15
+ -- are called from inside RLS across the whole database. `is_tenant_member` and
16
+ -- `user_unit_ids` are in the N17 allowlist precisely because policies lean on
17
+ -- them. Dropping a table under them would not have failed loudly; it would
18
+ -- have quietly emptied every policy that asks who you are.
19
+ --
20
+ -- That is the argument for this file existing separately from 005: a foreign
21
+ -- key announces itself, and a function body does not.
22
+ --
23
+ -- Each body is the original with the relation swapped, and nothing else:
24
+ --
25
+ -- public.tenant_members -> app.memberships (active only, which is what
26
+ -- the projection contained)
27
+ -- public.location_members -> app.membership_units
28
+ -- public.locations -> app.units, or the successor views from 004
29
+ -- where the shape is what the caller wanted
30
+ --
31
+ -- `role` is the one place the swap is not one word: the projection carried the
32
+ -- role KEY and app.memberships carries role_id, so the two that filtered on it
33
+ -- join app.roles to get the key back.
34
+ -- ============================================================================
35
+
36
+ CREATE OR REPLACE FUNCTION public.is_tenant_member(p_tenant_id uuid, p_user_id uuid)
37
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public', 'pg_temp'
38
+ AS $function$
39
+ SELECT EXISTS (
40
+ SELECT 1 FROM app.memberships m
41
+ WHERE m.tenant_id = p_tenant_id AND m.user_id = p_user_id AND m.active
42
+ );
43
+ $function$;
44
+
45
+ CREATE OR REPLACE FUNCTION public.user_admin_tenant_ids()
46
+ RETURNS SETOF uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public', 'pg_temp'
47
+ AS $function$
48
+ SELECT m.tenant_id
49
+ FROM app.memberships m
50
+ JOIN app.roles r ON r.id = m.role_id
51
+ WHERE m.user_id = auth.uid() AND m.active
52
+ AND r.key IN ('owner', 'admin');
53
+ $function$;
54
+
55
+ CREATE OR REPLACE FUNCTION public.user_unit_ids()
56
+ RETURNS SETOF uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public', 'pg_temp'
57
+ AS $function$
58
+ WITH RECURSIVE seed AS (
59
+ SELECT mu.unit_id AS id, 0 AS depth
60
+ FROM app.membership_units mu
61
+ WHERE mu.user_id = auth.uid()
62
+ ),
63
+ tree AS (
64
+ SELECT id, depth FROM seed
65
+ UNION -- UNION, not UNION ALL: dedupes, and
66
+ SELECT u.id, t.depth + 1 -- therefore terminates on a cycle
67
+ FROM app.units u
68
+ JOIN tree t ON u.parent_unit_id = t.id
69
+ WHERE t.depth < 8 -- a policy must never be able to hang
70
+ )
71
+ SELECT id FROM tree;
72
+ $function$;
73
+
74
+ CREATE OR REPLACE FUNCTION public.tenant_member_activity(p_tenant_id uuid)
75
+ RETURNS TABLE(user_id uuid, last_sign_in_at timestamp with time zone, signed_up_at timestamp with time zone)
76
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public', 'pg_temp'
77
+ AS $function$
78
+ SELECT u.id, u.last_sign_in_at, u.created_at
79
+ FROM auth.users u
80
+ JOIN app.memberships m ON m.user_id = u.id
81
+ WHERE m.tenant_id = p_tenant_id AND m.active
82
+ -- The gate, and the only one: an admin of THIS tenant, and nobody else.
83
+ -- Without it the function is a directory of every last_sign_in_at in the
84
+ -- pool, reachable by anyone with an anon key.
85
+ AND p_tenant_id IN (SELECT public.user_admin_tenant_ids());
86
+ $function$;
87
+
88
+ CREATE OR REPLACE FUNCTION public.unlinked_member_count()
89
+ RETURNS integer LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public', 'pg_temp'
90
+ AS $function$
91
+ SELECT count(*)::integer FROM app.memberships WHERE person_id IS NULL AND active;
92
+ $function$;
93
+
94
+ CREATE OR REPLACE FUNCTION public.unit_bootstrap(p_tenant_id uuid, p_device_id text DEFAULT NULL::text)
95
+ RETURNS jsonb LANGUAGE sql STABLE SET search_path TO 'public', 'pg_temp'
96
+ AS $function$
97
+ SELECT jsonb_build_object(
98
+ 'mode', COALESCE((
99
+ SELECT t.unit_scoping_enabled FROM public.tenants t WHERE t.id = p_tenant_id
100
+ ), false),
101
+ -- v_units, not app.units: this shape is the one the shell already parses,
102
+ -- and the view exists to keep saying it after the projection is gone.
103
+ 'units', COALESCE((
104
+ SELECT jsonb_agg(to_jsonb(l) ORDER BY l.is_headquarters DESC NULLS LAST, l.name)
105
+ FROM public.v_units l WHERE l.tenant_id = p_tenant_id
106
+ ), '[]'::jsonb),
107
+ 'bindings', COALESCE((
108
+ SELECT jsonb_agg(jsonb_build_object('location_id', lm.location_id, 'role', lm.role))
109
+ FROM public.v_unit_members lm WHERE lm.user_id = (SELECT auth.uid())
110
+ ), '[]'::jsonb),
111
+ 'scoped', COALESCE((
112
+ SELECT jsonb_object_agg(sr.resource_table, jsonb_build_object(
113
+ 'unit_column', sr.unit_column, 'shareable', sr.shareable))
114
+ FROM public.scoped_resources sr
115
+ ), '{}'::jsonb),
116
+ -- O pino desta máquina ganha do pino da conta quando os dois existem: a
117
+ -- máquina fica na loja, a conta viaja com a pessoa.
118
+ 'device_pin', (
119
+ SELECT p.unit_id FROM public.user_unit_prefs p
120
+ WHERE p.user_id = (SELECT auth.uid())
121
+ AND p.tenant_id = p_tenant_id
122
+ AND p.device_id = p_device_id
123
+ ),
124
+ 'device_pin_set', EXISTS (
125
+ SELECT 1 FROM public.user_unit_prefs p
126
+ WHERE p.user_id = (SELECT auth.uid())
127
+ AND p.tenant_id = p_tenant_id
128
+ AND p.device_id = p_device_id
129
+ ),
130
+ 'account_pin', (
131
+ SELECT p.unit_id FROM public.user_unit_prefs p
132
+ WHERE p.user_id = (SELECT auth.uid())
133
+ AND p.tenant_id = p_tenant_id
134
+ ORDER BY p.updated_at DESC
135
+ LIMIT 1
136
+ ),
137
+ 'account_pin_set', EXISTS (
138
+ SELECT 1 FROM public.user_unit_prefs p
139
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id
140
+ )
141
+ );
142
+ $function$;
143
+
144
+ -- The one hard dependency: a policy naming the table blocks the DROP outright.
145
+ DROP POLICY IF EXISTS "profiles_select" ON public.profiles;
146
+ CREATE POLICY "profiles_select" ON public.profiles FOR SELECT TO authenticated
147
+ USING (
148
+ id = (SELECT auth.uid())
149
+ OR id IN (
150
+ SELECT m.user_id FROM app.memberships m
151
+ WHERE m.active AND m.tenant_id IN (SELECT public.user_tenant_ids())
152
+ )
153
+ );
@@ -0,0 +1,77 @@
1
+ -- ============================================================================
2
+ -- 007_the_compat_machinery_leaves_with_the_tables — what MAINTAINED the three
3
+ -- projections goes with them (#214 / #352).
4
+ --
5
+ -- Found by `node packages/db/test/chain/run.mjs --full`, which is the run that
6
+ -- applies the optional plugins too — so it is the only run where shop 0110
7
+ -- actually drops the projections, and therefore the only run that could catch
8
+ -- this. The core-A chain had been green throughout.
9
+ --
10
+ -- The seed failed with `relation "public.locations" does not exist`, and the
11
+ -- cause was not the seed: FOUR TRIGGERS ON app.* still fanned every write out
12
+ -- into the projections.
13
+ --
14
+ -- app.units units_compat -> app.compat_sync_unit
15
+ -- app.memberships memberships_compat -> app.compat_sync_membership
16
+ -- app.membership_units membership_units_compat -> app.compat_sync_membership
17
+ -- app.roles roles_compat -> app.compat_sync_membership
18
+ --
19
+ -- So after 0110, CREATING A UNIT OR A MEMBERSHIP RAISED. A projection is two
20
+ -- things — the copy and the machine that keeps it — and dropping only the copy
21
+ -- leaves the machine writing to something that is not there. The `plpgsql
22
+ -- resolves names at call time` property that made these safe to drop AROUND is
23
+ -- exactly what stopped it being noticed at drop time.
24
+ --
25
+ -- ensure_member_person and backfill_member_persons are NOT dropped: linking a
26
+ -- login to its Person is still a live concern (#137). Only the relation they
27
+ -- read changes, taken from the pool's own body so nothing else moves with it.
28
+ -- ============================================================================
29
+
30
+ -- GUARDED, because where this runs is not where the tables go. The projections
31
+ -- are dropped by shop 0110, and shop is an OPTIONAL plugin: on a core-A pool it
32
+ -- never runs, and the tables are still there and still have to be maintained.
33
+ -- Dropping the machinery here unconditionally would leave a core-A cluster with
34
+ -- three projection tables nothing keeps in step — worse than either end state.
35
+ --
36
+ -- So this only fires once the tables are actually gone, which on a --full chain
37
+ -- is shop 0111, and on a pool that already has them dropped is right here.
38
+ DO $guard$
39
+ BEGIN
40
+ IF to_regclass('public.locations') IS NOT NULL
41
+ OR to_regclass('public.tenant_members') IS NOT NULL
42
+ OR to_regclass('public.location_members') IS NOT NULL THEN
43
+ RAISE NOTICE '007: the projections are still present — the machinery stays with them';
44
+ RETURN;
45
+ END IF;
46
+
47
+ EXECUTE $sql$
48
+ DROP TRIGGER IF EXISTS units_compat ON app.units;
49
+ DROP TRIGGER IF EXISTS memberships_compat ON app.memberships;
50
+ DROP TRIGGER IF EXISTS membership_units_compat ON app.membership_units;
51
+ DROP TRIGGER IF EXISTS roles_compat ON app.roles;
52
+
53
+ DROP FUNCTION IF EXISTS app.trg_units_compat() CASCADE;
54
+ DROP FUNCTION IF EXISTS app.trg_memberships_compat() CASCADE;
55
+ DROP FUNCTION IF EXISTS app.trg_membership_units_compat() CASCADE;
56
+ DROP FUNCTION IF EXISTS app.trg_roles_compat() CASCADE;
57
+ DROP FUNCTION IF EXISTS app.compat_sync_unit(uuid) CASCADE;
58
+ DROP FUNCTION IF EXISTS app.compat_sync_membership(uuid, uuid) CASCADE;
59
+ DROP FUNCTION IF EXISTS app.compat_rebuild() CASCADE;
60
+ DROP FUNCTION IF EXISTS public.handle_location_member_tenant() CASCADE;
61
+ $sql$;
62
+ RAISE NOTICE '007: the compat sync machinery is retired';
63
+ END $guard$;
64
+
65
+ DO $fix$
66
+ DECLARE r record; v_def text; v_new text; v_n int := 0;
67
+ BEGIN
68
+ FOR r IN
69
+ SELECT p.oid FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
70
+ WHERE n.nspname = 'public' AND p.proname IN ('ensure_member_person','backfill_member_persons')
71
+ LOOP
72
+ v_def := pg_get_functiondef(r.oid);
73
+ v_new := replace(v_def, 'public.tenant_members', 'app.memberships');
74
+ IF v_new <> v_def THEN EXECUTE v_new; v_n := v_n + 1; END IF;
75
+ END LOOP;
76
+ RAISE NOTICE '007: % person-link function(s) moved to app.memberships', v_n;
77
+ END $fix$;