@fayz-ai/db 0.11.0 → 0.13.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 (119) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  4. package/migrations/038_onboarding_responses.sql +103 -0
  5. package/migrations/039_unit_tree.sql +270 -0
  6. package/migrations/040_resource_grants.sql +474 -0
  7. package/migrations/041_scoped_columns.sql +192 -0
  8. package/migrations/042_unit_scope_policies.sql +145 -0
  9. package/migrations/043_view_invoker.sql +90 -0
  10. package/migrations/044_unit_member_facts.sql +47 -0
  11. package/migrations/045_unit_entry.sql +236 -0
  12. package/migrations/046_membership_visible_to_members.sql +85 -0
  13. package/migrations/047_tasks.sql +266 -0
  14. package/migrations/048_every_login_is_a_person.sql +190 -0
  15. package/migrations/049_bookable_people.sql +126 -0
  16. package/migrations/050_authz_core.sql +230 -0
  17. package/migrations/051_authz_helpers.sql +330 -0
  18. package/migrations/052_authz_fanout.sql +253 -0
  19. package/migrations/053_authz_catalog_templates.sql +402 -0
  20. package/migrations/060_tenancy_seam.sql +132 -0
  21. package/migrations/061_create_tenant.sql +125 -0
  22. package/migrations/062_invitations.sql +329 -0
  23. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  24. package/migrations/064_agent_guard_authz.sql +118 -0
  25. package/migrations/064b_supersede_unit_scope.sql +205 -0
  26. package/migrations/065_scaffold_sweep.sql +139 -0
  27. package/migrations/066_shadow_report.sql +122 -0
  28. package/migrations/070_permission_catalog.sql +205 -0
  29. package/migrations/071_v1_permission_map.sql +266 -0
  30. package/migrations/072_role_rpcs.sql +126 -0
  31. package/migrations/073_legacy_rbac_retire.sql +471 -0
  32. package/migrations/075_company_legal_entities.sql +169 -0
  33. package/migrations/076_tenant_settings.sql +251 -0
  34. package/migrations/080_people_roles.sql +269 -0
  35. package/migrations/081_people_identity.sql +271 -0
  36. package/migrations/082_people_membership_link.sql +142 -0
  37. package/migrations/083_people_data_migration.sql +75 -0
  38. package/migrations/084_people_registrations_tokens.sql +282 -0
  39. package/migrations/085_catalog_products.sql +333 -0
  40. package/migrations/086_catalog_pricing.sql +194 -0
  41. package/migrations/087_catalog_packages.sql +134 -0
  42. package/migrations/090_orders_venda.sql +166 -0
  43. package/migrations/091_orders_scaffold.sql +92 -0
  44. package/migrations/095_config_field_rules.sql +261 -0
  45. package/migrations/096_audit_append_only.sql +256 -0
  46. package/migrations/097_sequences_authorized.sql +111 -0
  47. package/migrations/098_holidays.sql +80 -0
  48. package/migrations/099_access_grants.sql +293 -0
  49. package/migrations/100_migration_schema.sql +217 -0
  50. package/migrations/101_migration_allowlist.sql +202 -0
  51. package/migrations/102_migration_rpcs.sql +933 -0
  52. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  53. package/migrations/105_policies_to_authenticated.sql +71 -0
  54. package/migrations/106_function_search_path.sql +55 -0
  55. package/migrations/107_core_table_prefix.sql +361 -0
  56. package/migrations/108_authz_write_guards.sql +149 -0
  57. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  58. package/migrations/110_people_kind_vocabulary.sql +120 -0
  59. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  60. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  61. package/migrations/112_money_precision_standard.sql +132 -0
  62. package/migrations/113_addresses_units_companies.sql +114 -0
  63. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  64. package/migrations/115_registration_honours_field_rules.sql +172 -0
  65. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  66. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  67. package/migrations/118_migration_catalog_writers.sql +450 -0
  68. package/migrations/119_migration_config_writers.sql +193 -0
  69. package/migrations/120_migration_facet_writer.sql +139 -0
  70. package/migrations/121_migration_financial_writers.sql +294 -0
  71. package/migrations/122_migration_inventory_writers.sql +245 -0
  72. package/migrations/123_migration_order_item_writer.sql +144 -0
  73. package/migrations/124_migration_ledger_writers.sql +170 -0
  74. package/migrations/125_migration_authz_people_writers.sql +205 -0
  75. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  76. package/migrations/127_scaffold_audit_hook.sql +98 -0
  77. package/migrations/128_shadow_tables_enforced.sql +93 -0
  78. package/migrations/129_order_kind_terminal_states.sql +57 -0
  79. package/migrations/130_every_login_is_a_person.sql +196 -0
  80. package/migrations/131_migration_invoice_split.sql +127 -0
  81. package/migrations/132_audit_prune_scheduled.sql +28 -0
  82. package/migrations/133_unit_is_a_session_property.sql +155 -0
  83. package/migrations/134_field_rules_on_read.sql +341 -0
  84. package/migrations/135_distribution_polarity.sql +158 -0
  85. package/migrations/136_unit_id_means_locality.sql +358 -0
  86. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  87. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  88. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  89. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  90. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  91. package/migrations/142_a_person_has_addresses.sql +212 -0
  92. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  93. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  94. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  95. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  96. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  97. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  98. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  99. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  100. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  101. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  102. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  103. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  104. package/migrations/155_a_tenant_can_leave.sql +271 -0
  105. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  106. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  107. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  108. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  109. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  110. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  111. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  112. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  113. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  114. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  115. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  116. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  117. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  118. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  119. package/package.json +6 -5
@@ -0,0 +1,450 @@
1
+ -- 118_migration_catalog_writers.sql
2
+ --
3
+ -- The catalog half of the extractor stops landing `pending`. #139 / #148.
4
+ --
5
+ -- 091 allowlisted the V1 catalog tables and pointed them at a `catalog.*` schema
6
+ -- that never shipped -- the tables live in `public` -- and no writer existed for
7
+ -- any of them, so every price table, package and package item landed `pending`
8
+ -- with no data and `reconcile()` could never reach `pending = 0`.
9
+ --
10
+ -- Two of the mappings also named destination columns that do not exist, which is
11
+ -- the quiet half of the problem: `map_row` builds whatever the mapping says and
12
+ -- the writer reads the keys it knows, so a mapped column with no home is dropped
13
+ -- in silence. That is the shape of #198 (`duration_minutes` mapped to JSONB while
14
+ -- the column was real) and of the `counterparty_id` drop in `upsert_invoice`.
15
+ -- They are corrected here rather than left for a writer to ignore:
16
+ --
17
+ -- price_tables.type -> metadata (public.price_tables has no `kind`)
18
+ -- package_items.item_type,
19
+ -- unit_price, package_price -> metadata (public.service_package_items carries
20
+ -- quantity and sort_order; the money lives on the
21
+ -- package and on the order, not on the line)
22
+ --
23
+ -- ONE TABLE IS DEFERRED RATHER THAN MAPPED. V1 `price_variations` is not a
24
+ -- product variant -- it is a price RULE (`variation_type`, `value_type`, `value`,
25
+ -- `first_appointment_only`). V2 resolves price through price_tables +
26
+ -- price_table_items and has no rule engine, so `public.product_variants`
27
+ -- (product_id NOT NULL, name, sku, attributes) is not the same concept and
28
+ -- writing one into the other would invent data. It goes to excluded_tables with
29
+ -- the reason, like recurring_bills (#188): a decision recorded, not a silent gap.
30
+ -- Same for `package_settings` and `package_unit_shares`, which are tenant
31
+ -- configuration and a revenue-share facet with no destination contract.
32
+ --
33
+ -- Replay-safe: `_allow` upserts under the freeze's documented unlock, the
34
+ -- exclusions are an upsert, the writers are CREATE OR REPLACE.
35
+
36
+ -- ── the writer vocabulary, widened (never narrowed) ──────────────────────
37
+ -- 090 constrains allowlist.writer to the six names that existed then. Each new
38
+ -- destination writer widens it; the CHECK is the registry, which is what makes
39
+ -- the dynamic dispatch below safe.
40
+ ALTER TABLE migration.allowlist DROP CONSTRAINT IF EXISTS allowlist_writer_check;
41
+ ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check
42
+ CHECK (writer IS NULL OR writer IN ('person', 'unit', 'category', 'product', 'order', 'invoice',
43
+ 'price_table', 'price_table_item', 'package', 'package_item'));
44
+
45
+ DO $unlock$
46
+ BEGIN
47
+ PERFORM set_config('migration.allowlist_unlock', 'on', true);
48
+
49
+ PERFORM migration._allow('price_tables', 'public.price_tables', 'catalog', 'price_table', '{}', '{}',
50
+ '{"uuid":"id","description":"name","type":"jsonb:catalog.v1_type","valid_from":"valid_from","valid_until":"valid_to","unit_ids":"jsonb:scope.v1_unit_refs","category_ids":"jsonb:scope.v1_category_refs","professional_ids":"jsonb:scope.v1_professional_refs","partnership_ids":"jsonb:scope.v1_partnership_refs","active":"is_active"}',
51
+ 'PRD 04 R3. `type` has no column on public.price_tables — it rides in metadata rather than being dropped by a writer that does not know it (#108).');
52
+
53
+ PERFORM migration._allow('price_table_items', 'public.price_table_items', 'catalog', 'price_table_item', '{}', '{}',
54
+ '{"uuid":"id","price_table_id":"price_table_id@price_tables","service_id":"product_id@services","table_price":"price"}',
55
+ 'PRD 04 R3.');
56
+
57
+ PERFORM migration._allow('service_packages', 'public.service_packages', 'catalog', 'package', '{}', '{}',
58
+ '{"uuid":"id","name":"name","description":"description","total_price":"price","status":"jsonb:v1_status","validity_days":"validity_days","validity_months":"jsonb:validity.months","validity_enabled":"jsonb:validity.enabled","replaces_uuid":"jsonb:v1_replaces","replaced_by_uuid":"jsonb:v1_replaced_by","active":"is_active"}',
59
+ 'PRD 04 R4: packages facet.');
60
+
61
+ PERFORM migration._allow('service_package_items', 'public.service_package_items', 'catalog', 'package_item', '{}', '{}',
62
+ '{"uuid":"id","package_id":"package_id@service_packages","service_id":"product_id@services","product_id":"product_id@products","item_type":"jsonb:v1_item_type","quantity":"quantity","unit_price":"jsonb:v1_unit_price","package_price":"jsonb:v1_package_price"}',
63
+ 'PRD 04 R4. The money columns ride in metadata: a package line carries quantity, the price is on the package and on the order (#108).');
64
+
65
+ DELETE FROM migration.allowlist WHERE source_table IN ('price_variations', 'package_settings', 'package_unit_shares');
66
+ END $unlock$;
67
+
68
+ INSERT INTO migration.excluded_tables (source_table, reason) VALUES
69
+ ('price_variations', 'Deferred: not a product variant. V1 price_variations is a price RULE (variation_type / value_type / value / first_appointment_only); V2 resolves price through price_tables + price_table_items and has no rule engine, so public.product_variants (product_id NOT NULL, name, sku, attributes) is a different concept. Re-admit in the wave that ships a pricing-rule contract (#108).'),
70
+ ('package_settings', 'Deferred: tenant-level package configuration with no destination contract. Settings are set by a human in V2, not migrated (#108).'),
71
+ ('package_unit_shares', 'Deferred: package revenue share by unit — a facet PRD 04 R4 names but does not specify. No destination contract (#108).')
72
+ ON CONFLICT (source_table) DO UPDATE SET reason = EXCLUDED.reason;
73
+
74
+
75
+
76
+ CREATE OR REPLACE FUNCTION migration.upsert_price_table(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
77
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
78
+ DECLARE
79
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
80
+ v_existing_tenant uuid;
81
+ v_created boolean := false;
82
+ v_standalone boolean := migration._standalone();
83
+ BEGIN
84
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
85
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
86
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'price table needs an id and name');
87
+ END IF;
88
+ SELECT tenant_id INTO v_existing_tenant FROM public.price_tables WHERE id = p_id;
89
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
90
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));
91
+ END IF;
92
+ IF v_existing_tenant IS NULL THEN
93
+ INSERT INTO public.price_tables (id, tenant_id, name, description, channel, currency, valid_from, valid_to, is_default, priority, is_active, metadata)
94
+ VALUES (p_id, p_tenant,
95
+ p_row ->> 'name',
96
+ p_row ->> 'description',
97
+ p_row ->> 'channel',
98
+ coalesce(p_row ->> 'currency', 'BRL'),
99
+ (p_row ->> 'valid_from')::date,
100
+ (p_row ->> 'valid_to')::date,
101
+ coalesce((p_row ->> 'is_default')::boolean, false),
102
+ coalesce((p_row ->> 'priority')::integer, 0),
103
+ coalesce((p_row ->> 'is_active')::boolean, true),
104
+ v_meta);
105
+ v_created := true;
106
+ ELSE
107
+ UPDATE public.price_tables SET
108
+ name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,
109
+ description = CASE WHEN p_row ? 'description' THEN p_row ->> 'description' ELSE description END,
110
+ channel = CASE WHEN p_row ? 'channel' THEN p_row ->> 'channel' ELSE channel END,
111
+ currency = CASE WHEN p_row ? 'currency' THEN coalesce(p_row ->> 'currency', 'BRL') ELSE currency END,
112
+ valid_from = CASE WHEN p_row ? 'valid_from' THEN (p_row ->> 'valid_from')::date ELSE valid_from END,
113
+ valid_to = CASE WHEN p_row ? 'valid_to' THEN (p_row ->> 'valid_to')::date ELSE valid_to END,
114
+ is_default = CASE WHEN p_row ? 'is_default' THEN coalesce((p_row ->> 'is_default')::boolean, false) ELSE is_default END,
115
+ priority = CASE WHEN p_row ? 'priority' THEN coalesce((p_row ->> 'priority')::integer, 0) ELSE priority END,
116
+ is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,
117
+ metadata = migration._merge_meta(metadata, v_meta)
118
+ WHERE id = p_id;
119
+ END IF;
120
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.price_tables', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
121
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
122
+ END $$;
123
+
124
+ CREATE OR REPLACE FUNCTION migration.upsert_price_table_item(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
125
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
126
+ DECLARE
127
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
128
+ v_existing_tenant uuid;
129
+ v_created boolean := false;
130
+ v_standalone boolean := migration._standalone();
131
+ BEGIN
132
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
133
+ IF p_id IS NULL THEN
134
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'price table item needs an id and nothing else');
135
+ END IF;
136
+ -- a price line without its table or its product is a broken reference, not a
137
+ -- row with holes: the FK would refuse it anyway, and a quarantine says why.
138
+ IF (p_row ->> 'price_table_id') IS NULL OR (p_row ->> 'product_id') IS NULL THEN
139
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'a price line needs both its price table and its product');
140
+ END IF;
141
+ IF coalesce((p_row ->> 'price')::numeric, -1) < 0 THEN
142
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a price line needs a price >= 0');
143
+ END IF;
144
+ SELECT tenant_id INTO v_existing_tenant FROM public.price_table_items WHERE id = p_id;
145
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
146
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));
147
+ END IF;
148
+ IF v_existing_tenant IS NULL THEN
149
+ INSERT INTO public.price_table_items (id, tenant_id, price_table_id, product_id, price, min_qty, metadata)
150
+ VALUES (p_id, p_tenant,
151
+ (p_row ->> 'price_table_id')::uuid,
152
+ (p_row ->> 'product_id')::uuid,
153
+ round(coalesce((p_row ->> 'price')::numeric, 0), 2),
154
+ coalesce((p_row ->> 'min_qty')::numeric, 1),
155
+ v_meta);
156
+ v_created := true;
157
+ ELSE
158
+ UPDATE public.price_table_items SET
159
+ price_table_id = CASE WHEN p_row ? 'price_table_id' THEN (p_row ->> 'price_table_id')::uuid ELSE price_table_id END,
160
+ product_id = CASE WHEN p_row ? 'product_id' THEN (p_row ->> 'product_id')::uuid ELSE product_id END,
161
+ price = CASE WHEN p_row ? 'price' THEN round(coalesce((p_row ->> 'price')::numeric, 0), 2) ELSE price END,
162
+ min_qty = CASE WHEN p_row ? 'min_qty' THEN coalesce((p_row ->> 'min_qty')::numeric, 1) ELSE min_qty END,
163
+ metadata = migration._merge_meta(metadata, v_meta)
164
+ WHERE id = p_id;
165
+ END IF;
166
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.price_table_items', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
167
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
168
+ END $$;
169
+
170
+ CREATE OR REPLACE FUNCTION migration.upsert_package(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
171
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
172
+ DECLARE
173
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
174
+ v_existing_tenant uuid;
175
+ v_created boolean := false;
176
+ v_standalone boolean := migration._standalone();
177
+ BEGIN
178
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
179
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
180
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'package needs an id and name');
181
+ END IF;
182
+ SELECT tenant_id INTO v_existing_tenant FROM public.service_packages WHERE id = p_id;
183
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
184
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));
185
+ END IF;
186
+ IF v_existing_tenant IS NULL THEN
187
+ INSERT INTO public.service_packages (id, tenant_id, name, description, price, currency, sessions_total, validity_days, is_active, metadata)
188
+ VALUES (p_id, p_tenant,
189
+ p_row ->> 'name',
190
+ p_row ->> 'description',
191
+ round(coalesce((p_row ->> 'price')::numeric, 0), 2),
192
+ coalesce(p_row ->> 'currency', 'BRL'),
193
+ (p_row ->> 'sessions_total')::integer,
194
+ (p_row ->> 'validity_days')::integer,
195
+ coalesce((p_row ->> 'is_active')::boolean, true),
196
+ v_meta);
197
+ v_created := true;
198
+ ELSE
199
+ UPDATE public.service_packages SET
200
+ name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,
201
+ description = CASE WHEN p_row ? 'description' THEN p_row ->> 'description' ELSE description END,
202
+ price = CASE WHEN p_row ? 'price' THEN round(coalesce((p_row ->> 'price')::numeric, 0), 2) ELSE price END,
203
+ currency = CASE WHEN p_row ? 'currency' THEN coalesce(p_row ->> 'currency', 'BRL') ELSE currency END,
204
+ sessions_total = CASE WHEN p_row ? 'sessions_total' THEN (p_row ->> 'sessions_total')::integer ELSE sessions_total END,
205
+ validity_days = CASE WHEN p_row ? 'validity_days' THEN (p_row ->> 'validity_days')::integer ELSE validity_days END,
206
+ is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,
207
+ metadata = migration._merge_meta(metadata, v_meta)
208
+ WHERE id = p_id;
209
+ END IF;
210
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.service_packages', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
211
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
212
+ END $$;
213
+
214
+ CREATE OR REPLACE FUNCTION migration.upsert_package_item(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
215
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
216
+ DECLARE
217
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
218
+ v_existing_tenant uuid;
219
+ v_created boolean := false;
220
+ v_standalone boolean := migration._standalone();
221
+ BEGIN
222
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
223
+ IF p_id IS NULL THEN
224
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'package item needs an id and nothing else');
225
+ END IF;
226
+ IF (p_row ->> 'package_id') IS NULL OR (p_row ->> 'product_id') IS NULL THEN
227
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'a package line needs both its package and its product');
228
+ END IF;
229
+ SELECT tenant_id INTO v_existing_tenant FROM public.service_package_items WHERE id = p_id;
230
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
231
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));
232
+ END IF;
233
+ IF v_existing_tenant IS NULL THEN
234
+ INSERT INTO public.service_package_items (id, tenant_id, package_id, product_id, quantity, sort_order, metadata)
235
+ VALUES (p_id, p_tenant,
236
+ (p_row ->> 'package_id')::uuid,
237
+ (p_row ->> 'product_id')::uuid,
238
+ coalesce((p_row ->> 'quantity')::numeric, 1),
239
+ coalesce((p_row ->> 'sort_order')::integer, 0),
240
+ v_meta);
241
+ v_created := true;
242
+ ELSE
243
+ UPDATE public.service_package_items SET
244
+ package_id = CASE WHEN p_row ? 'package_id' THEN (p_row ->> 'package_id')::uuid ELSE package_id END,
245
+ product_id = CASE WHEN p_row ? 'product_id' THEN (p_row ->> 'product_id')::uuid ELSE product_id END,
246
+ quantity = CASE WHEN p_row ? 'quantity' THEN coalesce((p_row ->> 'quantity')::numeric, 1) ELSE quantity END,
247
+ sort_order = CASE WHEN p_row ? 'sort_order' THEN coalesce((p_row ->> 'sort_order')::integer, 0) ELSE sort_order END,
248
+ metadata = migration._merge_meta(metadata, v_meta)
249
+ WHERE id = p_id;
250
+ END IF;
251
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.service_package_items', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
252
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
253
+ END $$;
254
+
255
+ -- The writers are the migration role's, like every other one in this schema.
256
+ DO $$
257
+ DECLARE r record;
258
+ BEGIN
259
+ FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
260
+ WHERE n.nspname = 'migration' AND p.proname IN ('upsert_price_table', 'upsert_price_table_item', 'upsert_package', 'upsert_package_item') LOOP
261
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', r.sig);
262
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', r.sig);
263
+ END LOOP;
264
+ END $$;
265
+
266
+ -- ── dispatch by name, so a new writer is an _allow call and a function ───
267
+ CREATE OR REPLACE FUNCTION migration._upsert_row_impl(p_batch uuid, p_source_table text, p_source_numeric_id bigint, p_source_uuid uuid, p_cursor text, p_row jsonb)
268
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
269
+ DECLARE
270
+ b migration.batches%ROWTYPE;
271
+ a migration.allowlist%ROWTYPE;
272
+ l migration.ledger%ROWTYPE;
273
+ v_checksum text := md5(p_row::text);
274
+ v_target_table text;
275
+ v_target_id uuid;
276
+ v_mapped jsonb; v_row jsonb; v_meta jsonb; v_unres jsonb;
277
+ v_dest jsonb; v_fin jsonb;
278
+ v_res jsonb; v_status text; v_col text; v_excl text;
279
+ v_retry_split boolean := false;
280
+ x jsonb; v_xid bigint;
281
+ BEGIN
282
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch;
283
+ IF NOT FOUND THEN RAISE EXCEPTION 'upsert_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
284
+ IF b.status <> 'running' THEN
285
+ RAISE EXCEPTION 'upsert_row: batch % is % (only a running batch accepts rows)', p_batch, b.status USING ERRCODE = '55000';
286
+ END IF;
287
+ PERFORM migration._assert_fence(b.tenant_id);
288
+
289
+ SELECT * INTO a FROM migration.allowlist WHERE source_table = p_source_table;
290
+ v_target_table := a.target_table;
291
+
292
+ SELECT * INTO l FROM migration.ledger
293
+ WHERE source_project = b.source_project AND source_table = p_source_table
294
+ AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
295
+ OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
296
+ AND coalesce(target_table, '') = coalesce(v_target_table, '')
297
+ FOR UPDATE;
298
+ IF NOT FOUND THEN
299
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, checksum, cursor, batch_id, status)
300
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, v_target_table, v_checksum, p_cursor, p_batch, 'pending')
301
+ RETURNING * INTO l;
302
+ ELSIF l.tenant_id <> b.tenant_id THEN
303
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
304
+ format('source row already belongs to tenant %s; this batch migrates tenant %s', l.tenant_id, b.tenant_id), p_row);
305
+ END IF;
306
+
307
+ -- allowlist
308
+ IF p_source_table LIKE 'import\_ef\_%' OR EXISTS (SELECT 1 FROM migration.excluded_tables e WHERE e.source_table = p_source_table) THEN
309
+ SELECT reason INTO v_excl FROM migration.excluded_tables e WHERE e.source_table = CASE WHEN p_source_table LIKE 'import\_ef\_%' THEN 'import_ef_*' ELSE p_source_table END;
310
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', 'source table is explicitly excluded: ' || coalesce(v_excl, 'no reason recorded'), p_row,
311
+ NULL, 'recorded decision: the table is in migration.excluded_tables');
312
+ END IF;
313
+ IF a.source_table IS NULL THEN
314
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', format('source table %s is not in the core-A allowlist', p_source_table), p_row);
315
+ END IF;
316
+ -- tenant identity of the source row vs the batch
317
+ IF b.source_tenant_id IS NOT NULL AND (p_row ->> 'tenant_id') ~ '^\d+$' AND (p_row ->> 'tenant_id')::bigint <> b.source_tenant_id THEN
318
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
319
+ format('source row tenant_id %s differs from the batch source_tenant_id %s', p_row ->> 'tenant_id', b.source_tenant_id), p_row);
320
+ END IF;
321
+
322
+ -- idempotency + delta
323
+ IF EXISTS (SELECT 1 FROM migration.quarantine q WHERE q.ledger_id = l.id AND q.resolved_at IS NULL AND md5(q.payload::text) = v_checksum) THEN
324
+ PERFORM migration._count(p_batch, 'skipped');
325
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'quarantined and unresolved: ' || coalesce(l.reason, ''),
326
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
327
+ END IF;
328
+ IF l.status = 'migrated' THEN
329
+ -- a split destination that did not exist on the first offer (invoice) and exists now: run the writer again
330
+ SELECT EXISTS (SELECT 1 FROM migration.ledger s
331
+ WHERE s.source_project = l.source_project AND s.source_table = l.source_table
332
+ AND s.source_numeric_id IS NOT DISTINCT FROM l.source_numeric_id AND s.source_uuid IS NOT DISTINCT FROM l.source_uuid
333
+ AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
334
+ IF l.checksum = v_checksum AND NOT v_retry_split THEN
335
+ PERFORM migration._count(p_batch, 'skipped');
336
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'unchanged', 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
337
+ END IF;
338
+ IF l.checksum <> v_checksum AND migration.cursor_cmp(p_cursor, l.cursor) < 0 THEN
339
+ PERFORM migration._count(p_batch, 'skipped');
340
+ RETURN jsonb_build_object('status', 'skipped', 'reason', format('stale cursor: %s < %s already migrated', p_cursor, l.cursor),
341
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
342
+ END IF;
343
+ END IF;
344
+
345
+ -- no writer yet: recorded, re-offered later
346
+ IF a.writer IS NULL THEN
347
+ UPDATE migration.ledger SET status = 'pending', reason = format('no destination writer for %s yet', v_target_table),
348
+ checksum = v_checksum, cursor = p_cursor, batch_id = p_batch WHERE id = l.id;
349
+ PERFORM migration._count(p_batch, 'pending');
350
+ RETURN jsonb_build_object('status', 'pending', 'reason', format('no destination writer for %s yet', v_target_table),
351
+ 'target_table', v_target_table, 'target_id', l.target_id, 'ledger_id', l.id);
352
+ END IF;
353
+
354
+ -- map + references
355
+ v_mapped := migration.map_row(b.source_project, p_source_table, p_row);
356
+ v_row := v_mapped -> 'row'; v_meta := v_mapped -> 'metadata'; v_unres := v_mapped -> 'unresolved';
357
+ IF jsonb_array_length(v_unres) > 0 THEN
358
+ RETURN migration._quarantine(l.id, p_batch, 'fk', 'unresolved reference(s): ' || v_unres::text, p_row);
359
+ END IF;
360
+
361
+ -- destination id (ADR 0007): the source uuid, else the id minted on the first offer
362
+ v_target_id := coalesce(l.target_id, p_source_uuid, gen_random_uuid());
363
+ v_meta := jsonb_set(v_meta, '{migration}', coalesce(v_meta -> 'migration', '{}'::jsonb) || jsonb_build_object(
364
+ 'source_project', b.source_project, 'source_table', p_source_table, 'source_numeric_id', p_source_numeric_id,
365
+ 'source_uuid', p_source_uuid, 'batch_id', p_batch), true);
366
+ v_row := v_row || jsonb_build_object('metadata', v_meta);
367
+ SELECT coalesce(jsonb_object_agg(c, v_row -> c), '{}'::jsonb) INTO v_fin FROM unnest(a.financial_columns) c WHERE v_row ? c;
368
+
369
+ -- never overwrite an existing financial value; never re-create a deleted row
370
+ IF l.status = 'migrated' THEN
371
+ IF cardinality(a.financial_columns) > 0 THEN
372
+ v_dest := migration._destination_values(v_target_table, l.target_id, a.financial_columns);
373
+ IF v_dest IS NULL THEN
374
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
375
+ END IF;
376
+ FOREACH v_col IN ARRAY a.financial_columns LOOP
377
+ IF (v_row ? v_col) AND round(coalesce((v_row ->> v_col)::numeric, 0), 2) IS DISTINCT FROM round(coalesce((v_dest ->> v_col)::numeric, 0), 2) THEN
378
+ RETURN migration._quarantine(l.id, p_batch, 'value',
379
+ format('financial column %s: destination %s, incoming %s — an existing financial value is never overwritten', v_col, coalesce(v_dest ->> v_col, 'null'), coalesce(v_row ->> v_col, 'null')), p_row);
380
+ END IF;
381
+ END LOOP;
382
+ ELSIF migration._destination_values(v_target_table, l.target_id, ARRAY['id']) IS NULL THEN
383
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
384
+ END IF;
385
+ END IF;
386
+
387
+ -- delegate; any error is a state quarantine, never a partial write
388
+ BEGIN
389
+ -- Dispatch by name rather than by a CASE arm per writer. The CASE meant every
390
+ -- new destination writer had to re-emit this whole function, and a row naming
391
+ -- a writer the CASE did not list fell through to NULL — silently, which is
392
+ -- the failure mode this schema exists to prevent. The allowlist's CHECK is
393
+ -- the vocabulary, `migration` is definer-only, and the name is verified to
394
+ -- exist before it is called, so an allowlist row naming a writer nobody built
395
+ -- quarantines with that sentence instead of vanishing.
396
+ IF to_regprocedure(format('migration.upsert_%s(uuid,uuid,jsonb,jsonb)', a.writer)) IS NULL THEN
397
+ RETURN migration._quarantine(l.id, p_batch, 'state',
398
+ format('the allowlist names writer %L for %s, and migration.upsert_%s does not exist', a.writer, p_source_table, a.writer),
399
+ p_row, v_target_table);
400
+ END IF;
401
+ -- %I on the whole identifier, not on the suffix: format('upsert_%I', 'order')
402
+ -- yields upsert_"order", which is not the function's name.
403
+ EXECUTE format('SELECT migration.%I($1, $2, $3, $4)', 'upsert_' || a.writer)
404
+ INTO v_res USING b.tenant_id, v_target_id, v_row, a.writer_args;
405
+ EXCEPTION WHEN OTHERS THEN
406
+ v_res := jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
407
+ END;
408
+ v_status := coalesce(v_res ->> 'status', 'quarantined');
409
+
410
+ IF v_status = 'quarantined' THEN
411
+ RETURN migration._quarantine(l.id, p_batch, coalesce(v_res ->> 'kind', 'state'), coalesce(v_res ->> 'reason', 'writer refused'), p_row);
412
+ END IF;
413
+ IF v_status = 'skipped' THEN
414
+ UPDATE migration.ledger SET status = 'skipped', reason = v_res ->> 'reason', checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
415
+ target_id = coalesce((v_res ->> 'target_id')::uuid, target_id) WHERE id = l.id;
416
+ PERFORM migration._count(p_batch, 'skipped');
417
+ RETURN jsonb_build_object('status', 'skipped', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_res ->> 'target_id', 'ledger_id', l.id);
418
+ END IF;
419
+
420
+ v_target_id := coalesce((v_res ->> 'target_id')::uuid, v_target_id);
421
+ UPDATE migration.ledger
422
+ SET status = 'migrated', target_table = v_target_table, target_id = v_target_id, checksum = v_checksum, cursor = p_cursor,
423
+ batch_id = p_batch, reason = v_res ->> 'reason', financial = nullif(v_fin, '{}'::jsonb)
424
+ WHERE id = l.id;
425
+ UPDATE migration.quarantine SET resolved_at = now(), resolution = 'row migrated on a later offer' WHERE ledger_id = l.id AND resolved_at IS NULL;
426
+
427
+ -- split rows (a Venda's invoice): one more ledger row per extra destination
428
+ FOR x IN SELECT value FROM jsonb_array_elements(coalesce(v_res -> 'extra', '[]'::jsonb)) LOOP
429
+ SELECT id INTO v_xid FROM migration.ledger
430
+ WHERE source_project = b.source_project AND source_table = p_source_table
431
+ AND source_numeric_id IS NOT DISTINCT FROM p_source_numeric_id AND source_uuid IS NOT DISTINCT FROM p_source_uuid
432
+ AND target_table = x ->> 'target_table';
433
+ IF v_xid IS NULL THEN
434
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, target_id, checksum, cursor, batch_id, status, reason, financial)
435
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, x ->> 'target_table', (x ->> 'target_id')::uuid,
436
+ v_checksum, p_cursor, p_batch, x ->> 'status', x ->> 'reason', nullif(x -> 'financial', 'null'::jsonb));
437
+ ELSE
438
+ UPDATE migration.ledger SET target_id = (x ->> 'target_id')::uuid, checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
439
+ status = x ->> 'status', reason = x ->> 'reason', financial = coalesce(nullif(x -> 'financial', 'null'::jsonb), financial) WHERE id = v_xid;
440
+ END IF;
441
+ END LOOP;
442
+
443
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
444
+ VALUES (b.tenant_id, NULL, 'migration.upsert', v_target_table, v_target_id::text,
445
+ jsonb_build_object('batch_id', p_batch, 'source_project', b.source_project, 'source_table', p_source_table,
446
+ 'source_numeric_id', p_source_numeric_id, 'source_uuid', p_source_uuid, 'reason', v_res ->> 'reason'));
447
+ PERFORM migration._count(p_batch, 'migrated');
448
+ RETURN jsonb_build_object('status', 'migrated', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_target_id,
449
+ 'ledger_id', l.id, 'merged', coalesce((v_res ->> 'merged')::boolean, false), 'extra', coalesce(v_res -> 'extra', '[]'::jsonb));
450
+ END $$;