@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,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.';