@fayz-ai/db 0.12.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.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/040_resource_grants.sql +2 -2
- package/migrations/041_scoped_columns.sql +2 -2
- package/migrations/042_unit_scope_policies.sql +1 -1
- package/migrations/043_view_invoker.sql +12 -3
- package/migrations/044_unit_member_facts.sql +1 -1
- package/migrations/045_unit_entry.sql +1 -1
- package/migrations/046_membership_visible_to_members.sql +1 -1
- package/migrations/047_tasks.sql +4 -4
- package/migrations/048_every_login_is_a_person.sql +1 -1
- package/migrations/049_bookable_people.sql +1 -1
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +3 -2
|
@@ -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 $$;
|