@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.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +90 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- 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 +6 -5
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
-- 111_migration_excluded_is_a_decision.sql
|
|
2
|
+
--
|
|
3
|
+
-- A rehearsal could never go green. Fix for #188, and for a defect underneath it.
|
|
4
|
+
--
|
|
5
|
+
-- #188 is that `migration.allowlist` points three V1 tables at
|
|
6
|
+
-- `plg_financial_recurring_bills`, which nobody built -- PRD 05 specifies invoice
|
|
7
|
+
-- + installments + ledger and never mentions recurrence. Offering those rows
|
|
8
|
+
-- leaves them `pending`, and `migration.reconcile()` requires `pending = 0`.
|
|
9
|
+
--
|
|
10
|
+
-- Deferring them is the honest move (building a recurrence contract is product
|
|
11
|
+
-- work, not a migration fix) -- except deferring did not help either, and that is
|
|
12
|
+
-- the defect underneath: an explicitly EXCLUDED table produced an OPEN quarantine,
|
|
13
|
+
-- and green also requires `quarantine_open = 0`. Nothing could ever clear it. Not
|
|
14
|
+
-- a human, not a re-offer, not a resolution -- there is no ambiguity to resolve,
|
|
15
|
+
-- the decision was taken in 091 and reviewed. So ANY rehearsal that offered a
|
|
16
|
+
-- dead legacy table (`guests`, `payments`, `orders`, the previous vendor's
|
|
17
|
+
-- `import_ef_*`) stayed red forever, for a reason nobody could act on.
|
|
18
|
+
--
|
|
19
|
+
-- The distinction this file draws:
|
|
20
|
+
--
|
|
21
|
+
-- · a table NOT IN the allowlist -> open quarantine. A surprise. Someone
|
|
22
|
+
-- has to look at it.
|
|
23
|
+
-- · a table explicitly EXCLUDED -> quarantine row written and RESOLVED
|
|
24
|
+
-- on the spot, carrying the decision as
|
|
25
|
+
-- its resolution. Still visible, because
|
|
26
|
+
-- an extractor sending tables it was
|
|
27
|
+
-- told not to send is worth seeing -- but
|
|
28
|
+
-- it does not hold the batch red.
|
|
29
|
+
--
|
|
30
|
+
-- Replay-safe: CREATE OR REPLACE throughout; the allowlist rows are removed under
|
|
31
|
+
-- the freeze's own documented unlock; the exclusion insert is an upsert.
|
|
32
|
+
|
|
33
|
+
-- ── 1. the three rows say what they are: deferred, not undeliverable ──────
|
|
34
|
+
DO $unlock$
|
|
35
|
+
BEGIN
|
|
36
|
+
PERFORM set_config('migration.allowlist_unlock', 'on', true);
|
|
37
|
+
DELETE FROM migration.allowlist
|
|
38
|
+
WHERE source_table IN ('recurring_bills', 'recurring_bill_items', 'recurring_bill_occurrences');
|
|
39
|
+
END $unlock$;
|
|
40
|
+
|
|
41
|
+
INSERT INTO migration.excluded_tables (source_table, reason) VALUES
|
|
42
|
+
('recurring_bills', 'Deferred: no destination contract. core-beauty-chef §3.1 lists recurrence under core-A financeiro base, but PRD 05 specifies invoice + installments + ledger and never defines recurrence, so plg_financial_recurring_bills was never built (#188). Re-admit to the allowlist in the wave that ships the contract; until then a rehearsal must not stay red for a table nobody can write.'),
|
|
43
|
+
('recurring_bill_items', 'Deferred with recurring_bills (#188).'),
|
|
44
|
+
('recurring_bill_occurrences', 'Deferred with recurring_bills (#188).')
|
|
45
|
+
ON CONFLICT (source_table) DO UPDATE SET reason = EXCLUDED.reason;
|
|
46
|
+
|
|
47
|
+
-- ── 2. a recorded decision is not an open question ────────────────────────
|
|
48
|
+
CREATE OR REPLACE FUNCTION migration._quarantine(p_ledger_id bigint, p_batch uuid, p_kind text, p_reason text, p_payload jsonb, p_target_table text DEFAULT NULL, p_decided text DEFAULT NULL)
|
|
49
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
50
|
+
DECLARE v_q bigint; v_l migration.ledger%ROWTYPE;
|
|
51
|
+
BEGIN
|
|
52
|
+
UPDATE migration.quarantine SET resolved_at = now(), resolution = 'superseded by a later offer of the same row'
|
|
53
|
+
WHERE ledger_id = p_ledger_id AND resolved_at IS NULL;
|
|
54
|
+
-- a row already at the destination keeps status 'migrated' (the earlier
|
|
55
|
+
-- version stays); the open quarantine row is what flags it
|
|
56
|
+
UPDATE migration.ledger SET status = CASE WHEN status = 'migrated' THEN 'migrated' ELSE 'quarantined' END,
|
|
57
|
+
reason = p_kind || ': ' || p_reason, batch_id = p_batch, target_table = coalesce(target_table, p_target_table)
|
|
58
|
+
WHERE id = p_ledger_id RETURNING * INTO v_l;
|
|
59
|
+
-- p_decided: this is not an ambiguity awaiting a human, it is a decision that
|
|
60
|
+
-- was already taken and reviewed in a migration file. The row is still written,
|
|
61
|
+
-- so an extractor sending tables it was told not to send stays visible — but it
|
|
62
|
+
-- lands RESOLVED, because nobody can "resolve" a decision that is already made.
|
|
63
|
+
-- Leaving it open made reconcile() unreachable forever: green requires
|
|
64
|
+
-- quarantine_open = 0, and no operator action could ever clear it (#188).
|
|
65
|
+
INSERT INTO migration.quarantine (ledger_id, tenant_id, batch_id, kind, reason, payload, resolved_at, resolution)
|
|
66
|
+
VALUES (p_ledger_id, v_l.tenant_id, p_batch, p_kind, p_reason, p_payload,
|
|
67
|
+
CASE WHEN p_decided IS NOT NULL THEN now() END, p_decided) RETURNING id INTO v_q;
|
|
68
|
+
PERFORM migration._count(p_batch, 'quarantined');
|
|
69
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', p_kind, 'reason', p_reason, 'target_table', v_l.target_table,
|
|
70
|
+
'target_id', v_l.target_id, 'ledger_id', p_ledger_id, 'quarantine_id', v_q);
|
|
71
|
+
END $$;
|
|
72
|
+
|
|
73
|
+
-- The added parameter makes a NEW function rather than replacing the old one, so
|
|
74
|
+
-- every 5- and 6-argument call site would be ambiguous until the previous
|
|
75
|
+
-- signature is gone. Dropping it here is what makes this replace rather than
|
|
76
|
+
-- overload; the grant posture is restated because 102's bulk REVOKE loop ran
|
|
77
|
+
-- before this signature existed.
|
|
78
|
+
DROP FUNCTION IF EXISTS migration._quarantine(bigint, uuid, text, text, jsonb, text);
|
|
79
|
+
REVOKE ALL ON FUNCTION migration._quarantine(bigint, uuid, text, text, jsonb, text, text) FROM public, anon, authenticated;
|
|
80
|
+
|
|
81
|
+
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)
|
|
82
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
83
|
+
DECLARE
|
|
84
|
+
b migration.batches%ROWTYPE;
|
|
85
|
+
a migration.allowlist%ROWTYPE;
|
|
86
|
+
l migration.ledger%ROWTYPE;
|
|
87
|
+
v_checksum text := md5(p_row::text);
|
|
88
|
+
v_target_table text;
|
|
89
|
+
v_target_id uuid;
|
|
90
|
+
v_mapped jsonb; v_row jsonb; v_meta jsonb; v_unres jsonb;
|
|
91
|
+
v_dest jsonb; v_fin jsonb;
|
|
92
|
+
v_res jsonb; v_status text; v_col text; v_excl text;
|
|
93
|
+
v_retry_split boolean := false;
|
|
94
|
+
x jsonb; v_xid bigint;
|
|
95
|
+
BEGIN
|
|
96
|
+
SELECT * INTO b FROM migration.batches WHERE id = p_batch;
|
|
97
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'upsert_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
|
|
98
|
+
IF b.status <> 'running' THEN
|
|
99
|
+
RAISE EXCEPTION 'upsert_row: batch % is % (only a running batch accepts rows)', p_batch, b.status USING ERRCODE = '55000';
|
|
100
|
+
END IF;
|
|
101
|
+
PERFORM migration._assert_fence(b.tenant_id);
|
|
102
|
+
|
|
103
|
+
SELECT * INTO a FROM migration.allowlist WHERE source_table = p_source_table;
|
|
104
|
+
v_target_table := a.target_table;
|
|
105
|
+
|
|
106
|
+
SELECT * INTO l FROM migration.ledger
|
|
107
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
108
|
+
AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
|
|
109
|
+
OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
|
|
110
|
+
AND coalesce(target_table, '') = coalesce(v_target_table, '')
|
|
111
|
+
FOR UPDATE;
|
|
112
|
+
IF NOT FOUND THEN
|
|
113
|
+
INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, checksum, cursor, batch_id, status)
|
|
114
|
+
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')
|
|
115
|
+
RETURNING * INTO l;
|
|
116
|
+
ELSIF l.tenant_id <> b.tenant_id THEN
|
|
117
|
+
RETURN migration._quarantine(l.id, p_batch, 'tenant',
|
|
118
|
+
format('source row already belongs to tenant %s; this batch migrates tenant %s', l.tenant_id, b.tenant_id), p_row);
|
|
119
|
+
END IF;
|
|
120
|
+
|
|
121
|
+
-- allowlist
|
|
122
|
+
IF p_source_table LIKE 'import\_ef\_%' OR EXISTS (SELECT 1 FROM migration.excluded_tables e WHERE e.source_table = p_source_table) THEN
|
|
123
|
+
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;
|
|
124
|
+
RETURN migration._quarantine(l.id, p_batch, 'allowlist', 'source table is explicitly excluded: ' || coalesce(v_excl, 'no reason recorded'), p_row,
|
|
125
|
+
NULL, 'recorded decision: the table is in migration.excluded_tables');
|
|
126
|
+
END IF;
|
|
127
|
+
IF a.source_table IS NULL THEN
|
|
128
|
+
RETURN migration._quarantine(l.id, p_batch, 'allowlist', format('source table %s is not in the core-A allowlist', p_source_table), p_row);
|
|
129
|
+
END IF;
|
|
130
|
+
-- tenant identity of the source row vs the batch
|
|
131
|
+
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
|
|
132
|
+
RETURN migration._quarantine(l.id, p_batch, 'tenant',
|
|
133
|
+
format('source row tenant_id %s differs from the batch source_tenant_id %s', p_row ->> 'tenant_id', b.source_tenant_id), p_row);
|
|
134
|
+
END IF;
|
|
135
|
+
|
|
136
|
+
-- idempotency + delta
|
|
137
|
+
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
|
|
138
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
139
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', 'quarantined and unresolved: ' || coalesce(l.reason, ''),
|
|
140
|
+
'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
141
|
+
END IF;
|
|
142
|
+
IF l.status = 'migrated' THEN
|
|
143
|
+
-- a split destination that did not exist on the first offer (invoice) and exists now: run the writer again
|
|
144
|
+
SELECT EXISTS (SELECT 1 FROM migration.ledger s
|
|
145
|
+
WHERE s.source_project = l.source_project AND s.source_table = l.source_table
|
|
146
|
+
AND s.source_numeric_id IS NOT DISTINCT FROM l.source_numeric_id AND s.source_uuid IS NOT DISTINCT FROM l.source_uuid
|
|
147
|
+
AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
|
|
148
|
+
IF l.checksum = v_checksum AND NOT v_retry_split THEN
|
|
149
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
150
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', 'unchanged', 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
151
|
+
END IF;
|
|
152
|
+
IF l.checksum <> v_checksum AND migration.cursor_cmp(p_cursor, l.cursor) < 0 THEN
|
|
153
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
154
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', format('stale cursor: %s < %s already migrated', p_cursor, l.cursor),
|
|
155
|
+
'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
156
|
+
END IF;
|
|
157
|
+
END IF;
|
|
158
|
+
|
|
159
|
+
-- no writer yet: recorded, re-offered later
|
|
160
|
+
IF a.writer IS NULL THEN
|
|
161
|
+
UPDATE migration.ledger SET status = 'pending', reason = format('no destination writer for %s yet', v_target_table),
|
|
162
|
+
checksum = v_checksum, cursor = p_cursor, batch_id = p_batch WHERE id = l.id;
|
|
163
|
+
PERFORM migration._count(p_batch, 'pending');
|
|
164
|
+
RETURN jsonb_build_object('status', 'pending', 'reason', format('no destination writer for %s yet', v_target_table),
|
|
165
|
+
'target_table', v_target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
166
|
+
END IF;
|
|
167
|
+
|
|
168
|
+
-- map + references
|
|
169
|
+
v_mapped := migration.map_row(b.source_project, p_source_table, p_row);
|
|
170
|
+
v_row := v_mapped -> 'row'; v_meta := v_mapped -> 'metadata'; v_unres := v_mapped -> 'unresolved';
|
|
171
|
+
IF jsonb_array_length(v_unres) > 0 THEN
|
|
172
|
+
RETURN migration._quarantine(l.id, p_batch, 'fk', 'unresolved reference(s): ' || v_unres::text, p_row);
|
|
173
|
+
END IF;
|
|
174
|
+
|
|
175
|
+
-- destination id (ADR 0007): the source uuid, else the id minted on the first offer
|
|
176
|
+
v_target_id := coalesce(l.target_id, p_source_uuid, gen_random_uuid());
|
|
177
|
+
v_meta := jsonb_set(v_meta, '{migration}', coalesce(v_meta -> 'migration', '{}'::jsonb) || jsonb_build_object(
|
|
178
|
+
'source_project', b.source_project, 'source_table', p_source_table, 'source_numeric_id', p_source_numeric_id,
|
|
179
|
+
'source_uuid', p_source_uuid, 'batch_id', p_batch), true);
|
|
180
|
+
v_row := v_row || jsonb_build_object('metadata', v_meta);
|
|
181
|
+
SELECT coalesce(jsonb_object_agg(c, v_row -> c), '{}'::jsonb) INTO v_fin FROM unnest(a.financial_columns) c WHERE v_row ? c;
|
|
182
|
+
|
|
183
|
+
-- never overwrite an existing financial value; never re-create a deleted row
|
|
184
|
+
IF l.status = 'migrated' THEN
|
|
185
|
+
IF cardinality(a.financial_columns) > 0 THEN
|
|
186
|
+
v_dest := migration._destination_values(v_target_table, l.target_id, a.financial_columns);
|
|
187
|
+
IF v_dest IS NULL THEN
|
|
188
|
+
RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
|
|
189
|
+
END IF;
|
|
190
|
+
FOREACH v_col IN ARRAY a.financial_columns LOOP
|
|
191
|
+
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
|
|
192
|
+
RETURN migration._quarantine(l.id, p_batch, 'value',
|
|
193
|
+
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);
|
|
194
|
+
END IF;
|
|
195
|
+
END LOOP;
|
|
196
|
+
ELSIF migration._destination_values(v_target_table, l.target_id, ARRAY['id']) IS NULL THEN
|
|
197
|
+
RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
|
|
198
|
+
END IF;
|
|
199
|
+
END IF;
|
|
200
|
+
|
|
201
|
+
-- delegate; any error is a state quarantine, never a partial write
|
|
202
|
+
BEGIN
|
|
203
|
+
v_res := CASE a.writer
|
|
204
|
+
WHEN 'person' THEN migration.upsert_person(b.tenant_id, v_target_id, v_row, a.writer_args)
|
|
205
|
+
WHEN 'unit' THEN migration.upsert_unit(b.tenant_id, v_target_id, v_row, a.writer_args)
|
|
206
|
+
WHEN 'category' THEN migration.upsert_category(b.tenant_id, v_target_id, v_row, a.writer_args)
|
|
207
|
+
WHEN 'product' THEN migration.upsert_product(b.tenant_id, v_target_id, v_row, a.writer_args)
|
|
208
|
+
WHEN 'order' THEN migration.upsert_order(b.tenant_id, v_target_id, v_row, a.writer_args)
|
|
209
|
+
WHEN 'invoice' THEN migration.upsert_invoice(b.tenant_id, v_target_id, v_row, a.writer_args)
|
|
210
|
+
END;
|
|
211
|
+
EXCEPTION WHEN OTHERS THEN
|
|
212
|
+
v_res := jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
|
|
213
|
+
END;
|
|
214
|
+
v_status := coalesce(v_res ->> 'status', 'quarantined');
|
|
215
|
+
|
|
216
|
+
IF v_status = 'quarantined' THEN
|
|
217
|
+
RETURN migration._quarantine(l.id, p_batch, coalesce(v_res ->> 'kind', 'state'), coalesce(v_res ->> 'reason', 'writer refused'), p_row);
|
|
218
|
+
END IF;
|
|
219
|
+
IF v_status = 'skipped' THEN
|
|
220
|
+
UPDATE migration.ledger SET status = 'skipped', reason = v_res ->> 'reason', checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
|
|
221
|
+
target_id = coalesce((v_res ->> 'target_id')::uuid, target_id) WHERE id = l.id;
|
|
222
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
223
|
+
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);
|
|
224
|
+
END IF;
|
|
225
|
+
|
|
226
|
+
v_target_id := coalesce((v_res ->> 'target_id')::uuid, v_target_id);
|
|
227
|
+
UPDATE migration.ledger
|
|
228
|
+
SET status = 'migrated', target_table = v_target_table, target_id = v_target_id, checksum = v_checksum, cursor = p_cursor,
|
|
229
|
+
batch_id = p_batch, reason = v_res ->> 'reason', financial = nullif(v_fin, '{}'::jsonb)
|
|
230
|
+
WHERE id = l.id;
|
|
231
|
+
UPDATE migration.quarantine SET resolved_at = now(), resolution = 'row migrated on a later offer' WHERE ledger_id = l.id AND resolved_at IS NULL;
|
|
232
|
+
|
|
233
|
+
-- split rows (a Venda's invoice): one more ledger row per extra destination
|
|
234
|
+
FOR x IN SELECT value FROM jsonb_array_elements(coalesce(v_res -> 'extra', '[]'::jsonb)) LOOP
|
|
235
|
+
SELECT id INTO v_xid FROM migration.ledger
|
|
236
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
237
|
+
AND source_numeric_id IS NOT DISTINCT FROM p_source_numeric_id AND source_uuid IS NOT DISTINCT FROM p_source_uuid
|
|
238
|
+
AND target_table = x ->> 'target_table';
|
|
239
|
+
IF v_xid IS NULL THEN
|
|
240
|
+
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)
|
|
241
|
+
VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, x ->> 'target_table', (x ->> 'target_id')::uuid,
|
|
242
|
+
v_checksum, p_cursor, p_batch, x ->> 'status', x ->> 'reason', nullif(x -> 'financial', 'null'::jsonb));
|
|
243
|
+
ELSE
|
|
244
|
+
UPDATE migration.ledger SET target_id = (x ->> 'target_id')::uuid, checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
|
|
245
|
+
status = x ->> 'status', reason = x ->> 'reason', financial = coalesce(nullif(x -> 'financial', 'null'::jsonb), financial) WHERE id = v_xid;
|
|
246
|
+
END IF;
|
|
247
|
+
END LOOP;
|
|
248
|
+
|
|
249
|
+
INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
250
|
+
VALUES (b.tenant_id, NULL, 'migration.upsert', v_target_table, v_target_id::text,
|
|
251
|
+
jsonb_build_object('batch_id', p_batch, 'source_project', b.source_project, 'source_table', p_source_table,
|
|
252
|
+
'source_numeric_id', p_source_numeric_id, 'source_uuid', p_source_uuid, 'reason', v_res ->> 'reason'));
|
|
253
|
+
PERFORM migration._count(p_batch, 'migrated');
|
|
254
|
+
RETURN jsonb_build_object('status', 'migrated', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_target_id,
|
|
255
|
+
'ledger_id', l.id, 'merged', coalesce((v_res ->> 'merged')::boolean, false), 'extra', coalesce(v_res -> 'extra', '[]'::jsonb));
|
|
256
|
+
END $$;
|
|
257
|
+
|
|
258
|
+
-- The generic destination write. Marks the transaction as a migration writer
|
|
259
|
+
-- (so the PONR triggers of 093 stay quiet) for the duration of the call only.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 111_replacing_a_view_keeps_its_dependents.sql — a view can be reshaped even
|
|
3
|
+
-- when something reads it.
|
|
4
|
+
--
|
|
5
|
+
-- Four files in this chain hit the same wall on a pool that already has the
|
|
6
|
+
-- view they declare: `DROP VIEW` refuses while anything depends on it, and
|
|
7
|
+
-- `CREATE OR REPLACE` refuses to reorder or rename columns. Between the two
|
|
8
|
+
-- there is no way to change a view's SHAPE — and the shape moves on its own,
|
|
9
|
+
-- because a view written `SELECT t.*` follows its table, and the scoping wave
|
|
10
|
+
-- adds `unit_id` to those tables.
|
|
11
|
+
--
|
|
12
|
+
-- CASCADE is not the answer: it drops the dependents silently and the pool comes
|
|
13
|
+
-- back missing a report nobody notices until someone opens it.
|
|
14
|
+
--
|
|
15
|
+
-- So the dependents are captured first, dropped, and put back from their own
|
|
16
|
+
-- definitions afterwards. Two calls around the replace:
|
|
17
|
+
--
|
|
18
|
+
-- SELECT app.detach_view_dependents('public.v_leads'); -- returns them
|
|
19
|
+
-- DROP VIEW IF EXISTS public.v_leads;
|
|
20
|
+
-- CREATE VIEW public.v_leads … ;
|
|
21
|
+
-- SELECT app.reattach_view_dependents(); -- puts them back
|
|
22
|
+
--
|
|
23
|
+
-- security_invoker travels with them. A dependent that came back definer would
|
|
24
|
+
-- answer with the owner's rights over a tenant_id table — the leak 043 swept out
|
|
25
|
+
-- of v_documents, reintroduced by the very act of rebuilding.
|
|
26
|
+
-- ============================================================================
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS app.view_replace_stash (
|
|
29
|
+
id bigserial PRIMARY KEY,
|
|
30
|
+
view_name text NOT NULL,
|
|
31
|
+
definition text NOT NULL,
|
|
32
|
+
invoker boolean NOT NULL DEFAULT false,
|
|
33
|
+
grants jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
34
|
+
depth integer NOT NULL DEFAULT 0
|
|
35
|
+
);
|
|
36
|
+
COMMENT ON TABLE app.view_replace_stash IS
|
|
37
|
+
'Scratch for 111: views dropped so one below them could be reshaped, waiting to be rebuilt in the same migration.';
|
|
38
|
+
|
|
39
|
+
CREATE OR REPLACE FUNCTION app.detach_view_dependents(p_view text)
|
|
40
|
+
RETURNS integer LANGUAGE plpgsql AS $$
|
|
41
|
+
DECLARE
|
|
42
|
+
r record;
|
|
43
|
+
v_n integer := 0;
|
|
44
|
+
v_lvl integer := 0;
|
|
45
|
+
BEGIN
|
|
46
|
+
DELETE FROM app.view_replace_stash;
|
|
47
|
+
IF to_regclass(p_view) IS NULL THEN RETURN 0; END IF;
|
|
48
|
+
|
|
49
|
+
-- THE WHOLE TREE, NOT JUST THE FIRST LEVEL. A report reads a view that reads
|
|
50
|
+
-- the one being reshaped, and dropping the middle one fails for exactly the
|
|
51
|
+
-- reason we are here. The recursive walk collects every descendant with the
|
|
52
|
+
-- distance from the root, so they can go deepest-first and come back the
|
|
53
|
+
-- other way round.
|
|
54
|
+
FOR r IN
|
|
55
|
+
WITH RECURSIVE tree(oid, lvl) AS (
|
|
56
|
+
SELECT to_regclass(p_view)::oid, 0
|
|
57
|
+
UNION
|
|
58
|
+
SELECT dv.oid, t.lvl + 1
|
|
59
|
+
FROM tree t
|
|
60
|
+
JOIN pg_depend d ON d.refobjid = t.oid
|
|
61
|
+
JOIN pg_rewrite rw ON rw.oid = d.objid
|
|
62
|
+
JOIN pg_class dv ON dv.oid = rw.ev_class AND dv.relkind = 'v'
|
|
63
|
+
WHERE dv.oid <> t.oid AND t.lvl < 10
|
|
64
|
+
)
|
|
65
|
+
-- Deepest FIRST: the outermost view has to go before the one it reads, which
|
|
66
|
+
-- is the whole reason a plain DROP failed. A view reachable by two paths
|
|
67
|
+
-- keeps its longest distance, so it is never dropped before something that
|
|
68
|
+
-- still depends on it.
|
|
69
|
+
SELECT d.name, d.def, d.invoker, d.lvl
|
|
70
|
+
FROM (
|
|
71
|
+
SELECT DISTINCT ON (t.oid)
|
|
72
|
+
quote_ident(dn.nspname) || '.' || quote_ident(dv.relname) AS name,
|
|
73
|
+
pg_get_viewdef(dv.oid, true) AS def,
|
|
74
|
+
COALESCE(array_to_string(dv.reloptions, ',') LIKE '%security_invoker=true%', false) AS invoker,
|
|
75
|
+
t.lvl
|
|
76
|
+
FROM tree t
|
|
77
|
+
JOIN pg_class dv ON dv.oid = t.oid
|
|
78
|
+
JOIN pg_namespace dn ON dn.oid = dv.relnamespace
|
|
79
|
+
WHERE t.lvl > 0
|
|
80
|
+
ORDER BY t.oid, t.lvl DESC
|
|
81
|
+
) d
|
|
82
|
+
ORDER BY d.lvl DESC
|
|
83
|
+
LOOP
|
|
84
|
+
v_lvl := r.lvl;
|
|
85
|
+
INSERT INTO app.view_replace_stash (view_name, definition, invoker, grants, depth)
|
|
86
|
+
SELECT r.name, r.def, r.invoker,
|
|
87
|
+
COALESCE(jsonb_agg(DISTINCT jsonb_build_object('grantee', g.grantee, 'priv', g.privilege_type))
|
|
88
|
+
FILTER (WHERE g.grantee IS NOT NULL), '[]'::jsonb),
|
|
89
|
+
v_lvl
|
|
90
|
+
FROM information_schema.role_table_grants g
|
|
91
|
+
WHERE g.table_schema = split_part(replace(r.name, '"', ''), '.', 1)
|
|
92
|
+
AND g.table_name = split_part(replace(r.name, '"', ''), '.', 2)
|
|
93
|
+
AND g.grantee IN ('anon', 'authenticated', 'service_role');
|
|
94
|
+
|
|
95
|
+
EXECUTE format('DROP VIEW IF EXISTS %s', r.name);
|
|
96
|
+
v_n := v_n + 1;
|
|
97
|
+
END LOOP;
|
|
98
|
+
RETURN v_n;
|
|
99
|
+
END $$;
|
|
100
|
+
|
|
101
|
+
CREATE OR REPLACE FUNCTION app.reattach_view_dependents()
|
|
102
|
+
RETURNS integer LANGUAGE plpgsql AS $$
|
|
103
|
+
DECLARE r record; g record; v_n integer := 0;
|
|
104
|
+
BEGIN
|
|
105
|
+
-- Deepest last out, first back in.
|
|
106
|
+
FOR r IN SELECT * FROM app.view_replace_stash ORDER BY depth ASC, id ASC LOOP
|
|
107
|
+
BEGIN
|
|
108
|
+
EXECUTE format('CREATE VIEW %s %s AS %s',
|
|
109
|
+
r.view_name,
|
|
110
|
+
CASE WHEN r.invoker THEN 'WITH (security_invoker = true)' ELSE '' END,
|
|
111
|
+
r.definition);
|
|
112
|
+
FOR g IN SELECT * FROM jsonb_to_recordset(r.grants) AS x(grantee text, priv text) LOOP
|
|
113
|
+
EXECUTE format('GRANT %s ON %s TO %I', g.priv, r.view_name, g.grantee);
|
|
114
|
+
END LOOP;
|
|
115
|
+
v_n := v_n + 1;
|
|
116
|
+
EXCEPTION WHEN OTHERS THEN
|
|
117
|
+
-- A dependent that no longer fits is EXPECTED, not an error to stop on:
|
|
118
|
+
-- the whole point of the replace is that the view underneath changed
|
|
119
|
+
-- shape, and a report written against the old columns cannot survive
|
|
120
|
+
-- that. crm's rep_leads reads `status` where v_leads now says
|
|
121
|
+
-- `lead_status` — and the file that owns that report rebuilds it correctly
|
|
122
|
+
-- later in this same chain. Failing here would stop the migration to
|
|
123
|
+
-- preserve a view that is about to be replaced anyway.
|
|
124
|
+
RAISE NOTICE '111: % not restored (%) — its own migration rebuilds it', r.view_name, SQLERRM;
|
|
125
|
+
END;
|
|
126
|
+
END LOOP;
|
|
127
|
+
DELETE FROM app.view_replace_stash;
|
|
128
|
+
RETURN v_n;
|
|
129
|
+
END $$;
|
|
130
|
+
|
|
131
|
+
REVOKE ALL ON FUNCTION app.detach_view_dependents(text) FROM public, anon, authenticated;
|
|
132
|
+
REVOKE ALL ON FUNCTION app.reattach_view_dependents() FROM public, anon, authenticated;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
-- 112_money_precision_standard.sql
|
|
2
|
+
--
|
|
3
|
+
-- One money standard, pinned where it was left open. Fix for #182.
|
|
4
|
+
--
|
|
5
|
+
-- The review asked whether the amount columns should be `decimal`. In Postgres
|
|
6
|
+
-- `decimal` IS `numeric` — an alias — so on that axis there was nothing to
|
|
7
|
+
-- change, and the property that actually matters already held: no money column
|
|
8
|
+
-- anywhere in the chain is a float. But underneath the question was a real
|
|
9
|
+
-- inconsistency. `plg_financial_*` amounts are `numeric(14,2)`, unit costs are
|
|
10
|
+
-- `numeric(16,4)`, rates are `numeric(7,4)` — all deliberate — while the oldest
|
|
11
|
+
-- columns in the chain, from `004_archetypes.sql`, are **bare `numeric`**:
|
|
12
|
+
-- unbounded scale, so 19.999999 is a legal price and rounds differently at every
|
|
13
|
+
-- reader that touches it.
|
|
14
|
+
--
|
|
15
|
+
-- The standard, written in DATA-MODEL.md and enforced by
|
|
16
|
+
-- scripts/check-money-columns.mjs from this commit:
|
|
17
|
+
--
|
|
18
|
+
-- amounts numeric(14,2)
|
|
19
|
+
-- unit costs numeric(16,4) (a unit cost genuinely needs four)
|
|
20
|
+
-- rates numeric(7,4)
|
|
21
|
+
-- quantities numeric(14,4)
|
|
22
|
+
-- never float; integer cents only where a provider's API dictates it, and
|
|
23
|
+
-- never for tenant-facing money.
|
|
24
|
+
--
|
|
25
|
+
-- WHY THIS REFUSES RATHER THAN ROUNDS. `ALTER TYPE numeric(14,2)` silently
|
|
26
|
+
-- rounds every existing value that has more scale than that. On a pool where a
|
|
27
|
+
-- price really is 19.999, that is money changing without anyone deciding it —
|
|
28
|
+
-- exactly what #148's "no silent correction" rule forbids, applied to the schema
|
|
29
|
+
-- instead of to a migrated row. So each column is checked first and the
|
|
30
|
+
-- migration STOPS with the offending count if rounding would lose anything.
|
|
31
|
+
-- Nothing in the fleet is expected to trip it; if one does, that pool needs a
|
|
32
|
+
-- human, not a DDL statement.
|
|
33
|
+
--
|
|
34
|
+
-- products.stock / min_stock are deliberately NOT pinned: they are quantities,
|
|
35
|
+
-- they are deprecated mirrors, and #185 retires them. Pinning a column on its way
|
|
36
|
+
-- out is churn.
|
|
37
|
+
--
|
|
38
|
+
-- Driven off the LIVE schema rather than a hand-written list, for two reasons.
|
|
39
|
+
-- A list goes stale the moment a slice adds a column, and — the reason a static
|
|
40
|
+
-- lint was tried first and thrown away — the migration files cannot answer this
|
|
41
|
+
-- question at all: `v_amount numeric` in a DECLARE block and `p_total numeric` in
|
|
42
|
+
-- an RPC signature are not columns and are perfectly correct as they are, but
|
|
43
|
+
-- they read identically to a column declaration in the text. The catalog knows
|
|
44
|
+
-- the difference. The posture suite (900_verification) asserts the result, so the
|
|
45
|
+
-- rule holds for every column a future slice adds, not just today's.
|
|
46
|
+
--
|
|
47
|
+
-- Replay-safe: each ALTER is guarded on the current type, so a second run is a
|
|
48
|
+
-- no-op.
|
|
49
|
+
|
|
50
|
+
DO $$
|
|
51
|
+
DECLARE
|
|
52
|
+
r record;
|
|
53
|
+
v_dep record;
|
|
54
|
+
v_views jsonb := '[]'::jsonb;
|
|
55
|
+
i int;
|
|
56
|
+
v_bad bigint;
|
|
57
|
+
v_scale integer;
|
|
58
|
+
v_n integer := 0;
|
|
59
|
+
BEGIN
|
|
60
|
+
FOR r IN
|
|
61
|
+
SELECT c.relname AS tbl, a.attname AS col, format_type(a.atttypid, a.atttypmod) AS typ
|
|
62
|
+
FROM pg_attribute a
|
|
63
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
64
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
65
|
+
WHERE n.nspname = 'public' AND c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped
|
|
66
|
+
-- a column whose NAME says money. Narrow on purpose: a false positive here
|
|
67
|
+
-- rewrites a column that was right, which is worse than a miss.
|
|
68
|
+
AND (a.attname IN ('amount', 'total', 'subtotal', 'price', 'cost', 'balance',
|
|
69
|
+
'paid_amount', 'fee_amount', 'discount_value', 'interest_value',
|
|
70
|
+
'opening_balance', 'closing_balance', 'total_amount', 'unit_price',
|
|
71
|
+
'price_delta', 'expected_amount', 'counted_amount', 'difference_amount')
|
|
72
|
+
OR a.attname LIKE '%\_amount' OR a.attname LIKE '%\_price' OR a.attname LIKE '%\_total')
|
|
73
|
+
-- …and is NOT one of the things that merely sounds like money
|
|
74
|
+
AND a.attname NOT LIKE '%\_count' AND a.attname NOT LIKE '%\_percent'
|
|
75
|
+
AND a.attname NOT LIKE '%\_rate' AND a.attname NOT LIKE '%quantity%'
|
|
76
|
+
-- bare numeric (unbounded scale) is the case being closed; a float would be
|
|
77
|
+
-- worse and is reported by the same query.
|
|
78
|
+
AND format_type(a.atttypid, a.atttypmod) IN ('numeric', 'real', 'double precision')
|
|
79
|
+
ORDER BY c.relname, a.attname
|
|
80
|
+
LOOP
|
|
81
|
+
IF r.typ <> 'numeric' THEN
|
|
82
|
+
RAISE EXCEPTION '102: public.%.% is % — money is never a float', r.tbl, r.col, r.typ
|
|
83
|
+
USING ERRCODE = '22003';
|
|
84
|
+
END IF;
|
|
85
|
+
-- A UNIT price is not an amount, and this file's own standard says so:
|
|
86
|
+
-- "unit costs numeric(16,4) (a unit cost genuinely needs four)". The name
|
|
87
|
+
-- heuristic above cannot tell them apart -- `unit_price` matches `%_price`
|
|
88
|
+
-- and would be pinned to two -- so the exceptions are named here.
|
|
89
|
+
--
|
|
90
|
+
-- resto's order_items proved it: 2.137 x 2000 units is 4274.00 exactly, and
|
|
91
|
+
-- two decimals turns that into 4280.00. The scale is the data, not noise.
|
|
92
|
+
-- The unit-cost set. `cost` reads like an amount and is one on a document,
|
|
93
|
+
-- but on a PRODUCT it is what one unit costs — resto carries 2.137 and
|
|
94
|
+
-- 35.9002, all of which land exactly on four and none on two.
|
|
95
|
+
v_scale := CASE WHEN r.col IN ('unit_price', 'unit_cost', 'cost', 'avg_cost', 'last_cost')
|
|
96
|
+
THEN 4 ELSE 2 END;
|
|
97
|
+
|
|
98
|
+
EXECUTE format('SELECT count(*) FROM public.%I WHERE %I IS NOT NULL AND %I <> round(%I, %s)',
|
|
99
|
+
r.tbl, r.col, r.col, r.col, v_scale)
|
|
100
|
+
INTO v_bad;
|
|
101
|
+
IF v_bad > 0 THEN
|
|
102
|
+
RAISE EXCEPTION
|
|
103
|
+
'102: public.%.% holds % value(s) with more than % decimals; pinning would round money without anyone deciding it',
|
|
104
|
+
r.tbl, r.col, v_bad, v_scale
|
|
105
|
+
USING ERRCODE = '22003',
|
|
106
|
+
HINT = 'Decide what those values should be and correct them, then re-run. This migration will not round them for you.';
|
|
107
|
+
END IF;
|
|
108
|
+
-- A view over the column makes ALTER TYPE fail outright, and this sweep runs
|
|
109
|
+
-- over every public table — including the ones a vertical brought with it
|
|
110
|
+
-- (resto's menu_items/v_menu_items is the case that found this).
|
|
111
|
+
--
|
|
112
|
+
-- This USED TO capture the direct dependents and DROP … CASCADE. Both halves
|
|
113
|
+
-- were wrong, and salon showed it: CASCADE takes the views it was never told
|
|
114
|
+
-- about — v_invoice_balances went that way — and the naive reverse-order
|
|
115
|
+
-- rebuild then failed on v_appointments, which reads it. 111 already solves
|
|
116
|
+
-- exactly this: it walks the WHOLE tree, records the distance, drops
|
|
117
|
+
-- deepest-first and rebuilds the other way round, carrying security_invoker.
|
|
118
|
+
PERFORM app.detach_view_dependents(format('public.%I', r.tbl));
|
|
119
|
+
|
|
120
|
+
EXECUTE format('ALTER TABLE public.%I ALTER COLUMN %I TYPE numeric(%s,%s)',
|
|
121
|
+
r.tbl, r.col, CASE WHEN v_scale = 4 THEN 16 ELSE 14 END, v_scale);
|
|
122
|
+
|
|
123
|
+
PERFORM app.reattach_view_dependents();
|
|
124
|
+
v_n := v_n + 1;
|
|
125
|
+
END LOOP;
|
|
126
|
+
RAISE NOTICE '102: pinned % money column(s) to numeric(14,2)', v_n;
|
|
127
|
+
END $$;
|
|
128
|
+
|
|
129
|
+
COMMENT ON COLUMN public.products.price IS
|
|
130
|
+
'Money: numeric(14,2) (#182). Amounts are (14,2), unit costs (16,4), rates (7,4), quantities (14,4) — never float, and never integer cents for tenant-facing money.';
|
|
131
|
+
COMMENT ON COLUMN public.products.cost IS
|
|
132
|
+
'Money: numeric(14,2) (#182). A per-unit inventory cost that needs four decimals lives on plg_inventory_stock_positions.unit_cost, not here.';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
-- 113_addresses_units_companies.sql
|
|
2
|
+
--
|
|
3
|
+
-- A unit's address becomes a typed thing again. Partial fix for #180.
|
|
4
|
+
--
|
|
5
|
+
-- `public.addresses` is the spine's address book — structured, polymorphic,
|
|
6
|
+
-- moved out of the shop in `017` precisely so there would be ONE model of "where
|
|
7
|
+
-- something is". Two Phase 0 tables never got the memo:
|
|
8
|
+
--
|
|
9
|
+
-- · `public.company_legal_entities` (#138) inlines seven address columns,
|
|
10
|
+
-- because PRD 02 R2 lists "endereço fiscal" as fields of the Empresa and the
|
|
11
|
+
-- slice implemented that literally;
|
|
12
|
+
-- · `app.units` has no typed address AT ALL — worse than inline. It inherited
|
|
13
|
+
-- a metadata bag from the `locations` → `units` projection (#134), where the
|
|
14
|
+
-- address survives as loose keys (`metadata->>'address'`, `->>'city'`…).
|
|
15
|
+
-- Against V1's `locations`, which had typed columns, that is a regression.
|
|
16
|
+
--
|
|
17
|
+
-- WHAT THIS FILE DOES, AND WHAT IT DELIBERATELY LEAVES:
|
|
18
|
+
--
|
|
19
|
+
-- It establishes the canonical shape — the owner types, the backfill, and a flat
|
|
20
|
+
-- read view for each — so the shape is settled BEFORE the V1 extractor grows an
|
|
21
|
+
-- address writer (it has none today: every address row lands `pending`). Getting
|
|
22
|
+
-- this right after data lands costs a migration per tenant.
|
|
23
|
+
--
|
|
24
|
+
-- It does NOT move the write path. Today a unit's address is written through the
|
|
25
|
+
-- `public.locations` compat projection, which packs it into `units.metadata`
|
|
26
|
+
-- inside one clause of a large trigger — and Phase 1 (#214) deletes that
|
|
27
|
+
-- projection outright. Rewiring it here, then deleting it there, is work thrown
|
|
28
|
+
-- away twice. So `addresses` becomes the canonical READ model now and the write
|
|
29
|
+
-- path moves with the retirement, which is where it belongs.
|
|
30
|
+
--
|
|
31
|
+
-- The consequence to be honest about: until #214, an address edited through
|
|
32
|
+
-- `locations` updates `units.metadata` and NOT `public.addresses`. The view says
|
|
33
|
+
-- so, and the metadata keys carry a deprecation comment. This is a staged move,
|
|
34
|
+
-- not a finished one.
|
|
35
|
+
--
|
|
36
|
+
-- Replay-safe: the CHECKs are dropped and rebuilt WIDER (never narrower — 017's
|
|
37
|
+
-- own rule), the backfill is `WHERE NOT EXISTS`, the views are CREATE OR REPLACE.
|
|
38
|
+
|
|
39
|
+
-- ── 1. two more things can own an address ────────────────────────────────
|
|
40
|
+
ALTER TABLE public.addresses DROP CONSTRAINT IF EXISTS addresses_owner_type_check;
|
|
41
|
+
ALTER TABLE public.addresses
|
|
42
|
+
ADD CONSTRAINT addresses_owner_type_check
|
|
43
|
+
CHECK (owner_type IN ('person', 'shop_customer', 'location', 'tenant', 'unit', 'company_legal_entity'));
|
|
44
|
+
|
|
45
|
+
ALTER TABLE public.addresses DROP CONSTRAINT IF EXISTS addresses_kind_check;
|
|
46
|
+
ALTER TABLE public.addresses
|
|
47
|
+
ADD CONSTRAINT addresses_kind_check
|
|
48
|
+
CHECK (kind IN ('shipping', 'billing', 'both', 'main', 'fiscal'));
|
|
49
|
+
|
|
50
|
+
-- ── 2. backfill what already exists ──────────────────────────────────────
|
|
51
|
+
-- Only rows that can be represented honestly: postal_code, street, city and
|
|
52
|
+
-- state are NOT NULL on addresses, and inventing a value to satisfy a constraint
|
|
53
|
+
-- is how a fiscal address quietly becomes wrong. Partial ones are counted and
|
|
54
|
+
-- left where they are.
|
|
55
|
+
DO $$
|
|
56
|
+
DECLARE v_units integer; v_cle integer; v_skipped integer;
|
|
57
|
+
BEGIN
|
|
58
|
+
INSERT INTO public.addresses (tenant_id, owner_type, owner_id, kind, postal_code, street, number, complement, district, city, state, country, metadata)
|
|
59
|
+
SELECT u.tenant_id, 'unit', u.id, 'main',
|
|
60
|
+
u.metadata ->> 'postal_code', u.metadata ->> 'address', u.metadata ->> 'address_number',
|
|
61
|
+
u.metadata ->> 'address_complement', u.metadata ->> 'district',
|
|
62
|
+
u.metadata ->> 'city', u.metadata ->> 'state', coalesce(u.metadata ->> 'country', 'BR'),
|
|
63
|
+
jsonb_build_object('source', 'migration:103', 'from', 'units.metadata')
|
|
64
|
+
FROM app.units u
|
|
65
|
+
WHERE nullif(btrim(coalesce(u.metadata ->> 'postal_code', '')), '') IS NOT NULL
|
|
66
|
+
AND nullif(btrim(coalesce(u.metadata ->> 'address', '')), '') IS NOT NULL
|
|
67
|
+
AND nullif(btrim(coalesce(u.metadata ->> 'city', '')), '') IS NOT NULL
|
|
68
|
+
AND nullif(btrim(coalesce(u.metadata ->> 'state', '')), '') IS NOT NULL
|
|
69
|
+
AND NOT EXISTS (SELECT 1 FROM public.addresses a WHERE a.owner_type = 'unit' AND a.owner_id = u.id);
|
|
70
|
+
GET DIAGNOSTICS v_units = ROW_COUNT;
|
|
71
|
+
|
|
72
|
+
SELECT count(*)::int INTO v_skipped FROM app.units u
|
|
73
|
+
WHERE NOT EXISTS (SELECT 1 FROM public.addresses a WHERE a.owner_type = 'unit' AND a.owner_id = u.id)
|
|
74
|
+
AND (u.metadata ?| ARRAY['address', 'city', 'state', 'postal_code']);
|
|
75
|
+
|
|
76
|
+
INSERT INTO public.addresses (tenant_id, owner_type, owner_id, kind, postal_code, street, number, complement, district, city, state, country, metadata)
|
|
77
|
+
SELECT c.tenant_id, 'company_legal_entity', c.id, 'fiscal',
|
|
78
|
+
c.postal_code, c.address, c.address_number, c.address_complement, c.district,
|
|
79
|
+
c.city, c.state, coalesce(c.country, 'BR'),
|
|
80
|
+
jsonb_build_object('source', 'migration:103', 'from', 'company_legal_entities')
|
|
81
|
+
FROM public.company_legal_entities c
|
|
82
|
+
WHERE nullif(btrim(coalesce(c.postal_code, '')), '') IS NOT NULL
|
|
83
|
+
AND nullif(btrim(coalesce(c.address, '')), '') IS NOT NULL
|
|
84
|
+
AND nullif(btrim(coalesce(c.city, '')), '') IS NOT NULL
|
|
85
|
+
AND nullif(btrim(coalesce(c.state, '')), '') IS NOT NULL
|
|
86
|
+
AND NOT EXISTS (SELECT 1 FROM public.addresses a WHERE a.owner_type = 'company_legal_entity' AND a.owner_id = c.id);
|
|
87
|
+
GET DIAGNOSTICS v_cle = ROW_COUNT;
|
|
88
|
+
|
|
89
|
+
RAISE NOTICE '103: % unit address(es), % company address(es) moved into public.addresses; % unit(s) had partial address data and were left alone', v_units, v_cle, v_skipped;
|
|
90
|
+
END $$;
|
|
91
|
+
|
|
92
|
+
-- ── 3. flat read views, so consumers do not learn a join ─────────────────
|
|
93
|
+
CREATE OR REPLACE VIEW public.v_unit_address WITH (security_invoker = true) AS
|
|
94
|
+
SELECT u.id AS unit_id, u.tenant_id, u.slug, u.name,
|
|
95
|
+
a.id AS address_id, a.postal_code, a.street, a.number, a.complement, a.district,
|
|
96
|
+
a.city, a.state, a.country
|
|
97
|
+
FROM app.units u
|
|
98
|
+
LEFT JOIN public.addresses a ON a.owner_type = 'unit' AND a.owner_id = u.id;
|
|
99
|
+
|
|
100
|
+
COMMENT ON VIEW public.v_unit_address IS
|
|
101
|
+
'A unit and its address (#180). NOTE: until the locations projection is retired (#214) the WRITE path is still public.locations -> units.metadata, so a very recent edit may not be here yet. Reads should prefer this view; writes still go through locations.';
|
|
102
|
+
|
|
103
|
+
CREATE OR REPLACE VIEW public.v_company_legal_entity_address WITH (security_invoker = true) AS
|
|
104
|
+
SELECT c.id AS company_legal_entity_id, c.tenant_id, c.legal_name, c.document_number,
|
|
105
|
+
a.id AS address_id, a.postal_code, a.street, a.number, a.complement, a.district,
|
|
106
|
+
a.city, a.state, a.country
|
|
107
|
+
FROM public.company_legal_entities c
|
|
108
|
+
LEFT JOIN public.addresses a ON a.owner_type = 'company_legal_entity' AND a.owner_id = c.id;
|
|
109
|
+
|
|
110
|
+
COMMENT ON VIEW public.v_company_legal_entity_address IS
|
|
111
|
+
'An Empresa and its fiscal address (#180). The inline columns on company_legal_entities are deprecated mirrors kept for #138''s consumers; public.addresses is the model.';
|
|
112
|
+
|
|
113
|
+
COMMENT ON COLUMN public.company_legal_entities.address IS
|
|
114
|
+
'DEPRECATED (#180): the fiscal address belongs in public.addresses (owner_type company_legal_entity, kind fiscal). Read v_company_legal_entity_address.';
|