@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,109 @@
1
+ -- ============================================================================
2
+ -- 139_distributable_is_a_scaffold_option.sql — multi-unit distribution stops
3
+ -- being hand-wired, and `people` gets it (#224).
4
+ --
5
+ -- The RESTRICTIVE release policy was written by hand on six catalog tables and
6
+ -- was absent from `people`. So "a provider serves 2 of 15 branches" was covered
7
+ -- when the provider LOGS IN (a membership plus two membership_units rows) and not
8
+ -- covered when the provider is a record — which is most providers.
9
+ --
10
+ -- The mechanism was always generic: `resource_grants` is
11
+ -- (resource_type, resource_id, relation, subject_type, subject_id) and serves
12
+ -- anything. What was not generic is the wiring, and for an SDK meant to carry a
13
+ -- fleet, "does this table support multi-unit distribution" cannot depend on
14
+ -- whether someone remembered to write a policy.
15
+ --
16
+ -- TWO SHAPES, because the six hand-written policies had two:
17
+ --
18
+ -- self-keyed products, services, service_packages
19
+ -- `unit_id IS NOT NULL OR available_in_my_units(rt, id)`
20
+ -- the unit escape is the point: a row that BELONGS to a branch is
21
+ -- governed by has_unit, and only a network row is distributed
22
+ -- (#225 — "Vela B2" is a real, tested case)
23
+ -- parent-keyed product_variants, price_table_items, service_package_items
24
+ -- `available_in_my_units(rt, <parent>_id)`
25
+ -- a variant is released with the product it varies, never on its
26
+ -- own; no escape, because the parent's row is the one that has a
27
+ -- unit
28
+ --
29
+ -- Replay-safe: additive columns, generated policy dropped and recreated.
30
+ -- ============================================================================
31
+
32
+ ALTER TABLE app.scaffold_registry ADD COLUMN IF NOT EXISTS distributable boolean NOT NULL DEFAULT false;
33
+ ALTER TABLE app.scaffold_registry ADD COLUMN IF NOT EXISTS dist_resource_type text;
34
+ ALTER TABLE app.scaffold_registry ADD COLUMN IF NOT EXISTS dist_key_column text;
35
+ ALTER TABLE app.scaffold_registry ADD COLUMN IF NOT EXISTS dist_unit_escape boolean;
36
+ COMMENT ON COLUMN app.scaffold_registry.distributable IS
37
+ 'Whether this table carries the generated RESTRICTIVE release policy (#224). dist_resource_type/dist_key_column say WHICH resource the row is released with — a variant is released with its product.';
38
+
39
+ -- ── declare a table distributable, and generate its release policy ──────
40
+ CREATE OR REPLACE FUNCTION app.scaffold_distributable(
41
+ p_table regclass,
42
+ p_resource_type text DEFAULT NULL, -- defaults to the table's own resource_type
43
+ p_key_column text DEFAULT 'id',
44
+ p_unit_escape boolean DEFAULT true,
45
+ p_enabled boolean DEFAULT true
46
+ )
47
+ RETURNS void
48
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
49
+ AS $$
50
+ DECLARE
51
+ v_schema text; v_name text; v_q text; v_policy text;
52
+ r app.scaffold_registry%ROWTYPE; v_rt text;
53
+ BEGIN
54
+ SELECT n.nspname, c.relname INTO v_schema, v_name
55
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
56
+ v_q := format('%I.%I', v_schema, v_name);
57
+ v_policy := v_name || '_available_in';
58
+
59
+ SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
60
+ IF NOT FOUND THEN
61
+ RAISE EXCEPTION 'scaffold_distributable: %.% is not scaffolded', v_schema, v_name USING ERRCODE = 'P0002';
62
+ END IF;
63
+ v_rt := coalesce(p_resource_type, r.resource_type);
64
+
65
+ EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_policy, v_q);
66
+
67
+ IF p_enabled THEN
68
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
69
+ WHERE table_schema = v_schema AND table_name = v_name AND column_name = p_key_column) THEN
70
+ RAISE EXCEPTION 'scaffold_distributable: %.% has no column %', v_schema, v_name, p_key_column;
71
+ END IF;
72
+ -- RESTRICTIVE: permissive policies OR together, so a release rule that is
73
+ -- meant to REMOVE rows has to AND with the scaffold's select.
74
+ EXECUTE format(
75
+ 'CREATE POLICY %I ON %s AS RESTRICTIVE FOR SELECT TO authenticated USING (%s(select app.available_in_my_units(%L, %I)))',
76
+ v_policy, v_q,
77
+ CASE WHEN p_unit_escape THEN 'unit_id IS NOT NULL OR ' ELSE '' END,
78
+ v_rt, p_key_column);
79
+ END IF;
80
+
81
+ UPDATE app.scaffold_registry
82
+ SET distributable = p_enabled,
83
+ dist_resource_type = CASE WHEN p_enabled THEN v_rt END,
84
+ dist_key_column = CASE WHEN p_enabled THEN p_key_column END,
85
+ dist_unit_escape = CASE WHEN p_enabled THEN p_unit_escape END
86
+ WHERE table_schema = v_schema AND table_name = v_name;
87
+ END $$;
88
+ REVOKE ALL ON FUNCTION app.scaffold_distributable(regclass, text, text, boolean, boolean) FROM public, anon, authenticated;
89
+
90
+ COMMENT ON FUNCTION app.scaffold_distributable(regclass, text, text, boolean, boolean) IS
91
+ 'Generate (or remove) a table''s RESTRICTIVE release policy (#224). Whether a table supports multi-unit distribution becomes a declaration in the registry rather than a policy somebody remembered to write.';
92
+
93
+ -- ── convert the six hand-written catalog policies ───────────────────────
94
+ -- Same predicates, now generated. 075/077's CREATE POLICY statements stay in
95
+ -- their files (never edited); the scaffold drops and re-creates the same name,
96
+ -- so a replay converges on the generated one.
97
+ SELECT app.scaffold_distributable('public.products', 'catalog.product', 'id', true);
98
+ SELECT app.scaffold_distributable('public.services', 'catalog.product', 'id', true);
99
+ SELECT app.scaffold_distributable('public.service_packages', 'catalog.service_package', 'id', true);
100
+ SELECT app.scaffold_distributable('public.product_variants', 'catalog.product', 'product_id', false);
101
+ SELECT app.scaffold_distributable('public.price_table_items', 'catalog.product', 'product_id', false);
102
+ SELECT app.scaffold_distributable('public.service_package_items', 'catalog.service_package', 'package_id', false);
103
+
104
+ -- ── the case this issue was filed for ───────────────────────────────────
105
+ -- A provider who does not log in is a Person. With this, a Person registered at
106
+ -- no branch (unit_id NULL — the network's) can be released to the two branches
107
+ -- that use them, and a Person registered AT a branch keeps being that branch's
108
+ -- through has_unit. Both readings coexist, which is #225's point.
109
+ SELECT app.scaffold_distributable('public.people', 'people.person', 'id', true);
@@ -0,0 +1,85 @@
1
+ -- ============================================================================
2
+ -- 140_masking_is_a_scaffold_option.sql — field masking joins scope and
3
+ -- distribution as something a table DECLARES (#222).
4
+ --
5
+ -- 124 generated `v_people` from the catalog, so the view was never hand-written
6
+ -- and a new column is picked up by regenerating. What was missing is the rest of
7
+ -- the scaffold contract: a declaration API shaped like the other two, and a gate
8
+ -- that notices when a table grows a column its view has not.
9
+ --
10
+ -- app.scaffold_scope(table, scope, enforce) #225
11
+ -- app.scaffold_distributable(table, rt, key, escape) #224
12
+ -- app.scaffold_masked(table, context, reference) here
13
+ --
14
+ -- The staleness gate is the point. A generated view is only trustworthy while
15
+ -- something checks that it was regenerated: add `cpf` to `people`, forget to
16
+ -- refresh, and the column is simply absent from the view — which reads as "no
17
+ -- such field" rather than as a mistake. N16d turns that into a red build.
18
+ --
19
+ -- WHAT THIS DELIBERATELY DOES NOT DO: revoke SELECT on the base tables.
20
+ --
21
+ -- That is what turns a masked view from available into mandatory, and it is a
22
+ -- fleet-wide change — ~92 SQL references to `public.people` (most inside
23
+ -- SECURITY DEFINER functions and unaffected), two security_invoker views, 22
24
+ -- client-side `.from('people')` call sites and 7 pgTAP files reading as personas.
25
+ -- It is also no longer required for the compliance goal: the boundary may be the
26
+ -- app's own server, decided per app (DECISIONS.md 2026-08-20). What must hold
27
+ -- either way is the condition, not the mechanism — no path from a browser to the
28
+ -- unmasked column — and that is an app-by-app fact this migration cannot assert.
29
+ --
30
+ -- Replay-safe: CREATE OR REPLACE + an idempotent re-declaration.
31
+ -- ============================================================================
32
+
33
+ CREATE OR REPLACE FUNCTION app.scaffold_masked(
34
+ p_table regclass,
35
+ p_context text,
36
+ p_reference text,
37
+ p_enabled boolean DEFAULT true
38
+ )
39
+ RETURNS void
40
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
41
+ AS $$
42
+ DECLARE v_schema text; v_name text;
43
+ BEGIN
44
+ SELECT n.nspname, c.relname INTO v_schema, v_name
45
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
46
+
47
+ IF p_enabled THEN
48
+ PERFORM app.masked_view_refresh(p_table, p_context, p_reference);
49
+ ELSE
50
+ EXECUTE format('DROP VIEW IF EXISTS %I.%I', v_schema, 'v_' || v_name);
51
+ DELETE FROM app.masked_views WHERE table_schema = v_schema AND table_name = v_name;
52
+ END IF;
53
+ END $$;
54
+ REVOKE ALL ON FUNCTION app.scaffold_masked(regclass, text, text, boolean) FROM public, anon, authenticated;
55
+
56
+ COMMENT ON FUNCTION app.scaffold_masked(regclass, text, text, boolean) IS
57
+ 'Declare that a table reads through a field_rules-masked view, and generate it (#222). The third member of the scaffold''s declaration API, beside scaffold_scope and scaffold_distributable.';
58
+
59
+ -- ── which masked views no longer match their table ──────────────────────
60
+ -- Column SETS, not order: the generator emits ordinal order, and a table that
61
+ -- gained a column mid-list would otherwise read as merely reordered.
62
+ CREATE OR REPLACE FUNCTION app.masked_views_stale()
63
+ RETURNS TABLE (table_name text, missing text[])
64
+ LANGUAGE sql STABLE SECURITY DEFINER
65
+ SET search_path = ''
66
+ AS $$
67
+ SELECT m.table_name,
68
+ array_agg(c.column_name ORDER BY c.column_name)
69
+ FROM app.masked_views m
70
+ JOIN information_schema.columns c
71
+ ON c.table_schema = m.table_schema AND c.table_name = m.table_name
72
+ WHERE NOT EXISTS (
73
+ SELECT 1 FROM information_schema.columns v
74
+ WHERE v.table_schema = m.table_schema AND v.table_name = m.view_name
75
+ AND v.column_name = c.column_name)
76
+ GROUP BY m.table_name;
77
+ $$;
78
+ REVOKE ALL ON FUNCTION app.masked_views_stale() FROM public, anon;
79
+ GRANT EXECUTE ON FUNCTION app.masked_views_stale() TO authenticated, service_role;
80
+
81
+ COMMENT ON FUNCTION app.masked_views_stale() IS
82
+ 'Masked views that have fallen behind their table (#222). A column absent from the view reads to a client as "no such field", not as a mistake — 900_verification/001 N16d makes it a red build instead.';
83
+
84
+ -- ── re-declare the pilot through the API ────────────────────────────────
85
+ SELECT app.scaffold_masked('public.people', 'person', 'person');
@@ -0,0 +1,192 @@
1
+ -- ============================================================================
2
+ -- 141_renamed_objects_keep_a_compat_name.sql — the old names answer again,
3
+ -- so the rename stops being a synchronised deploy.
4
+ --
5
+ -- 107_core_table_prefix and 137_vendor_named_spine_objects each renamed objects
6
+ -- and each wrote the same paragraph: NO COMPAT VIEW. Both gave one reason, and
7
+ -- it was the same reason:
8
+ --
9
+ -- 107 — "on the bench's force-replay pass the frozen files re-run and execute
10
+ -- ALTER TABLE … ENABLE ROW LEVEL SECURITY … every one of which errors
11
+ -- with 'this operation is not supported for views'."
12
+ -- 137 — "frozen migrations re-run ALTER TABLE and CREATE POLICY against the
13
+ -- old name on a replay, and those fail on a view."
14
+ --
15
+ -- THAT CONSTRAINT WAS THE BENCH, NEVER PRODUCTION. A pool applies each file once
16
+ -- and the ledger skips it forever after; only the force-replay pass re-executes
17
+ -- a frozen file, and replay-from-zero is no longer a gate this repository pays
18
+ -- for. With that dropped, the compat shape those files wanted is available, and
19
+ -- it is the difference between a rename anyone can ship and a rename that needs
20
+ -- every pool's edge functions redeployed in the same minute.
21
+ --
22
+ -- WHAT THE RENAME COSTS WITHOUT THIS FILE. `plg_connections` and `plg_sync_runs`
23
+ -- are read by literal name inside SIX functions that are deployed right now
24
+ -- (bling-sync v20 and shop-shipping-quote v3 on ecommerce, tyxter-template-sync
25
+ -- and tyxter-number-claim on beauty, kiwify-courses-sync on school, plus the
26
+ -- three vindi-* through _shared/vindi-shop.ts). An edge function is a deployed
27
+ -- artifact: publishing the npm packages does not update it. So the instant 107
28
+ -- lands, Bling sync, shipping quotes, Vindi checkout and the WhatsApp channel
29
+ -- answer with "relation does not exist" — and nothing in the SDK reports it.
30
+ --
31
+ -- WHY A VIEW AND NOT A PROJECTION TABLE. 063 needed tables because the legacy
32
+ -- tenancy names had to keep taking writes and carry FKs. Here a simple view over
33
+ -- one table is auto-updatable in Postgres, so INSERT/UPDATE/DELETE through the
34
+ -- old name reach the real table with no trigger; and nothing declares a foreign
35
+ -- key to these eight.
36
+ --
37
+ -- security_invoker = true, deliberately. A definer view would answer with the
38
+ -- owner's rights over a tenant_id table — exactly the leak 043 swept out of
39
+ -- v_documents. The caller's own RLS on the base table decides, which makes the
40
+ -- old name a pure alias and never a second, weaker door.
41
+ --
42
+ -- THIS FILE IS A BRIDGE, NOT A DESTINATION. Every caller moves to the real name
43
+ -- (scripts/check-schema-code-names.mjs fails the PR that adds a new one), and
44
+ -- when the last one has moved these views are dropped. Until then the exemption
45
+ -- list in that script names who is still crossing.
46
+ --
47
+ -- Idempotent and order-independent: each object is guarded on what is actually
48
+ -- in the catalog, so a pool that has not taken 107/137 yet is left completely
49
+ -- alone — the old name is still the real table there, and creating a view over
50
+ -- a name that is already a table would be wrong, not helpful.
51
+ -- ============================================================================
52
+
53
+ -- ── §1 the renamed tables ──────────────────────────────────────────────────
54
+ DO $$
55
+ DECLARE
56
+ pair text[];
57
+ v_old text;
58
+ v_new text;
59
+ g record;
60
+ v_cols text;
61
+ BEGIN
62
+ FOREACH pair SLICE 1 IN ARRAY ARRAY[
63
+ -- 107_core_table_prefix
64
+ ['plg_analytics_read_models', 'analytics_read_models'],
65
+ ['plg_connections', 'connections'],
66
+ ['plg_connection_secrets', 'connection_secrets'],
67
+ ['plg_connector_schedules', 'connector_schedules'],
68
+ ['plg_entity_fields', 'entity_fields'],
69
+ ['plg_event_log', 'event_log'],
70
+ ['plg_sync_runs', 'sync_runs'],
71
+ ['plg_sync_schedule', 'sync_schedule'],
72
+ -- 137_vendor_named_spine_objects
73
+ ['fayz_projects', 'published_sites'],
74
+ ['fayz_search_sources', 'search_sources']
75
+ ] LOOP
76
+ v_old := pair[1];
77
+ v_new := pair[2];
78
+
79
+ -- The new name has to be there, or there is nothing to alias.
80
+ CONTINUE WHEN to_regclass('public.' || v_new) IS NULL;
81
+ -- The old name being a TABLE means this pool has not taken the rename: it is
82
+ -- the real thing and must stay that way. The old name being a VIEW means it
83
+ -- is an earlier run of THIS file — and that one may be wrong (the first cut
84
+ -- did `SELECT *`, which answers under the new column name). Skipping on
85
+ -- "exists" would make this file unable to correct itself, so a view is
86
+ -- dropped and rebuilt while a table is left alone.
87
+ CONTINUE WHEN EXISTS (
88
+ SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
89
+ WHERE n.nspname = 'public' AND c.relname = v_old AND c.relkind = 'r');
90
+ -- Something may read the alias by now (marketing's landing-page view reads
91
+ -- fayz_projects), so the dependents come off first and go back after — 143.
92
+ --
93
+ -- Guarded on the helper EXISTING, because 143 runs after this file: on a
94
+ -- fresh pool there is no alias yet and nothing to drop, so the helper is
95
+ -- never needed there. It is only ever needed on the re-application that
96
+ -- corrects an earlier alias, and by then 143 has long been applied.
97
+ IF to_regproc('app.detach_view_dependents') IS NOT NULL THEN
98
+ PERFORM app.detach_view_dependents('public.' || v_old);
99
+ END IF;
100
+ EXECUTE format('DROP VIEW IF EXISTS public.%I', v_old);
101
+
102
+ -- SELECT * IS NOT ENOUGH WHERE A COLUMN WAS RENAMED TOO. 137 renamed
103
+ -- fayz_projects.fayz_project_id to provider_site_id, so a `SELECT *` alias
104
+ -- answers under the old TABLE name with the new COLUMN name — and every
105
+ -- caller that names the column still breaks. plugin-marketing 008 found it.
106
+ -- A compat alias has to reproduce the old SHAPE, not just the old name.
107
+ SELECT string_agg(
108
+ CASE WHEN v_new = 'published_sites' AND a.attname = 'provider_site_id'
109
+ THEN 'provider_site_id AS fayz_project_id'
110
+ ELSE quote_ident(a.attname) END, ', ' ORDER BY a.attnum)
111
+ INTO v_cols
112
+ FROM pg_attribute a
113
+ WHERE a.attrelid = ('public.' || v_new)::regclass
114
+ AND a.attnum > 0 AND NOT a.attisdropped;
115
+
116
+ EXECUTE format(
117
+ 'CREATE VIEW public.%I WITH (security_invoker = true) AS SELECT %s FROM public.%I',
118
+ v_old, v_cols, v_new);
119
+
120
+ -- The alias is exactly as reachable as what it points at — no more (a role
121
+ -- that cannot read the table cannot read it through the old name either)
122
+ -- and no less (the deployed callers keep the access they have today).
123
+ FOR g IN
124
+ SELECT DISTINCT grantee, privilege_type
125
+ FROM information_schema.role_table_grants
126
+ WHERE table_schema = 'public'
127
+ AND table_name = v_new
128
+ AND grantee IN ('anon', 'authenticated', 'service_role')
129
+ LOOP
130
+ EXECUTE format('GRANT %s ON public.%I TO %I', g.privilege_type, v_old, g.grantee);
131
+ END LOOP;
132
+
133
+ EXECUTE format(
134
+ 'COMMENT ON VIEW public.%I IS %L',
135
+ v_old,
136
+ format('Compat alias for public.%s (141). Callers move to the real name; '
137
+ || 'this view is dropped when the last one has.', v_new));
138
+
139
+ IF to_regproc('app.reattach_view_dependents') IS NOT NULL THEN
140
+ PERFORM app.reattach_view_dependents();
141
+ END IF;
142
+
143
+ RAISE NOTICE '141: public.% → compat view over public.%', v_old, v_new;
144
+ END LOOP;
145
+ END $$;
146
+
147
+ -- ── §2 fayz_global_search() ────────────────────────────────────────────────
148
+ -- 137 renamed the function and dropped the old name. The SDK's search engine
149
+ -- degrades rather than breaks (it reads PGRST202 and falls back to the fan-out
150
+ -- path), so this is a performance regression and not an outage — but the
151
+ -- fallback is the slow path on every query until the caller is repointed, and
152
+ -- restoring the name costs one wrapper.
153
+ DO $$
154
+ DECLARE
155
+ v_args text;
156
+ v_ret text;
157
+ BEGIN
158
+ -- By NAME, never by a spelled-out signature: the real one is
159
+ -- (text, uuid, text[], integer, integer) and a guard that hardcodes the
160
+ -- arguments silently misses, then CREATE FUNCTION collides with the alias it
161
+ -- was supposed to detect.
162
+ IF EXISTS (SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
163
+ WHERE n.nspname = 'public' AND p.proname = 'fayz_global_search') THEN
164
+ RETURN; -- already aliased
165
+ END IF;
166
+
167
+ SELECT pg_get_function_identity_arguments(p.oid), pg_get_function_result(p.oid)
168
+ INTO v_args, v_ret
169
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
170
+ WHERE n.nspname = 'public' AND p.proname = 'global_search'
171
+ LIMIT 1;
172
+
173
+ IF v_args IS NULL THEN
174
+ RETURN; -- pool has not taken 137
175
+ END IF;
176
+
177
+ EXECUTE format($f$
178
+ CREATE OR REPLACE FUNCTION public.fayz_global_search(%s) RETURNS %s
179
+ LANGUAGE sql STABLE SECURITY INVOKER SET search_path = public, pg_temp
180
+ AS 'SELECT * FROM public.global_search(%s)'
181
+ $f$, v_args, v_ret,
182
+ (SELECT string_agg(split_part(trim(a), ' ', 1), ', ')
183
+ FROM unnest(string_to_array(v_args, ',')) AS a));
184
+
185
+ EXECUTE 'REVOKE ALL ON FUNCTION public.fayz_global_search(' || v_args || ') FROM public, anon';
186
+ EXECUTE 'GRANT EXECUTE ON FUNCTION public.fayz_global_search(' || v_args || ') TO authenticated, service_role';
187
+
188
+ COMMENT ON FUNCTION public.fayz_global_search IS
189
+ 'Compat alias for public.global_search() (141). Dropped when the last caller has moved.';
190
+
191
+ RAISE NOTICE '141: public.fayz_global_search() → wrapper over public.global_search()';
192
+ END $$;
@@ -0,0 +1,212 @@
1
+ -- ============================================================================
2
+ -- 142_a_person_has_addresses.sql — a person has addresses, not an address.
3
+ --
4
+ -- `people` carries four inline columns — address, city, state, postal_code —
5
+ -- while `public.addresses` has been the spine's address book all along and 113
6
+ -- already lists 'person' among the owners it accepts. So the shape was never in
7
+ -- question; the inline copy just outlived it.
8
+ --
9
+ -- ONE COLUMN CANNOT HOLD WHAT THE PRODUCT ALREADY ASKS FOR. A client has a home
10
+ -- and a delivery address; a professional has a home and the branch they bill
11
+ -- from; a supplier has a fiscal address and a warehouse. The UI has been
12
+ -- modelling that for a while, and every screen that does it has to keep the two
13
+ -- sides in step by hand because the database only remembers one.
14
+ --
15
+ -- WHAT THIS DOES. Every non-empty inline address becomes a row in `addresses`
16
+ -- owned by the person, marked `is_default` because it is the only one they had.
17
+ -- Idempotent by construction: a person who already owns an address is skipped,
18
+ -- so re-running adds nothing and a pool that was migrated by hand converges.
19
+ --
20
+ -- WHAT THIS DELIBERATELY DOES NOT DO. The columns stay. Dropping them is a
21
+ -- separate wave with the readers moved first — `products.stock` is the same
22
+ -- shape in this chain, kept as a deprecated mirror while the writers migrate.
23
+ -- They are COMMENTed as deprecated so nothing new reaches for them, and the
24
+ -- 900_verification posture suite is where a "no new readers" rule belongs once
25
+ -- the readers are counted.
26
+ --
27
+ -- The street is NOT parsed. `address` is one free-text line and `addresses`
28
+ -- wants street/number/complement/district; splitting "Rua X, 123 - apto 4" with
29
+ -- a regex invents structure the row never had, and a wrong number on a delivery
30
+ -- address costs more than an unsplit one. The line lands in `street` whole, and
31
+ -- whoever edits it next in the UI fills the parts.
32
+ -- ============================================================================
33
+
34
+ INSERT INTO public.addresses (
35
+ tenant_id, owner_type, owner_id, kind, label,
36
+ street, city, state, postal_code, country, is_default
37
+ )
38
+ SELECT
39
+ p.tenant_id,
40
+ 'person',
41
+ p.id,
42
+ -- THE VOCABULARY IS 113'S, NOT A NEW ONE. addresses_kind_check allows
43
+ -- shipping|billing|both|main|fiscal, and 113 already chose 'main' for a unit's
44
+ -- principal address and 'fiscal' for a company's. A person's principal address
45
+ -- is the same idea, so it is 'main'. The word this file used before was a
46
+ -- sixth name for a concept that already had one, and the ecommerce pool —
47
+ -- which has no trigger quietly rewriting it — refused the row outright.
48
+ 'main',
49
+ 'Endereço principal',
50
+ NULLIF(btrim(COALESCE(p.address, '')), ''),
51
+ NULLIF(btrim(COALESCE(p.city, '')), ''),
52
+ NULLIF(btrim(COALESCE(p.state, '')), ''),
53
+ NULLIF(btrim(COALESCE(p.postal_code, '')), ''),
54
+ COALESCE(NULLIF(btrim(COALESCE(p.country, '')), ''), 'BR'),
55
+ true
56
+ FROM public.people p
57
+ -- COMPLETE ONES ONLY. `addresses` requires postal_code, street, city and state,
58
+ -- and 113 already settled why: "inventing a value to satisfy a constraint is how
59
+ -- a fiscal address quietly becomes wrong". resto carries people with a city and
60
+ -- nothing else — those are counted below and left where they are, readable in
61
+ -- the deprecated columns, rather than becoming an address with a made-up CEP.
62
+ WHERE NULLIF(btrim(COALESCE(p.address, '')), '') IS NOT NULL
63
+ AND NULLIF(btrim(COALESCE(p.city, '')), '') IS NOT NULL
64
+ AND NULLIF(btrim(COALESCE(p.state, '')), '') IS NOT NULL
65
+ AND NULLIF(btrim(COALESCE(p.postal_code, '')), '') IS NOT NULL
66
+ -- A person who already owns one has either been migrated or edited since;
67
+ -- either way the address book is ahead of the inline copy and wins.
68
+ AND NOT EXISTS (
69
+ SELECT 1 FROM public.addresses a
70
+ WHERE a.owner_type = 'person' AND a.owner_id = p.id
71
+ );
72
+
73
+ COMMENT ON COLUMN public.people.address IS
74
+ 'DEPRECATED (142): a person has many addresses — read public.addresses WHERE owner_type = ''person''. Kept as a mirror until the writers move.';
75
+ COMMENT ON COLUMN public.people.city IS
76
+ 'DEPRECATED (142): see public.addresses.';
77
+ COMMENT ON COLUMN public.people.state IS
78
+ 'DEPRECATED (142): see public.addresses.';
79
+ COMMENT ON COLUMN public.people.postal_code IS
80
+ 'DEPRECATED (142): see public.addresses.';
81
+
82
+ -- The read every screen wants: a person with their addresses, newest default
83
+ -- first, without each one re-deriving the join and the ordering.
84
+ CREATE OR REPLACE VIEW public.v_person_addresses
85
+ WITH (security_invoker = true) AS
86
+ SELECT
87
+ a.id, a.tenant_id, a.owner_id AS person_id, p.name AS person_name,
88
+ a.kind, a.label, a.recipient, a.phone,
89
+ a.postal_code, a.street, a.number, a.complement, a.district,
90
+ a.city, a.state, a.country, a.is_default,
91
+ a.created_at, a.updated_at
92
+ FROM public.addresses a
93
+ JOIN public.people p ON p.id = a.owner_id
94
+ WHERE a.owner_type = 'person';
95
+
96
+ GRANT SELECT ON public.v_person_addresses TO authenticated;
97
+
98
+ COMMENT ON VIEW public.v_person_addresses IS
99
+ 'A person and every address they own (142). The default one sorts first via is_default.';
100
+
101
+ -- ── the same, for the Empresa ───────────────────────────────────────────────
102
+ -- 113 already named this: "company_legal_entities (#138) inlines seven address
103
+ -- columns", and it already added 'company_legal_entity' to the owners
104
+ -- `addresses` accepts. What it did not do was move the rows — it declared the
105
+ -- address book the read model and left the write path for later. This is later.
106
+ --
107
+ -- A company genuinely has more than one: the fiscal address on the nota is not
108
+ -- always where the goods ship from, and a change of accountant moves one without
109
+ -- touching the other.
110
+ --
111
+ -- Unlike a person's, this address is ALREADY structured — number, complement and
112
+ -- district are their own columns — so each part lands in its own field and
113
+ -- nothing is guessed.
114
+ INSERT INTO public.addresses (
115
+ tenant_id, owner_type, owner_id, kind, label,
116
+ street, number, complement, district, city, state, postal_code, country,
117
+ phone, is_default
118
+ )
119
+ SELECT
120
+ c.tenant_id,
121
+ 'company_legal_entity',
122
+ c.id,
123
+ 'fiscal',
124
+ 'Endereço fiscal',
125
+ NULLIF(btrim(COALESCE(c.address, '')), ''),
126
+ NULLIF(btrim(COALESCE(c.address_number, '')), ''),
127
+ NULLIF(btrim(COALESCE(c.address_complement, '')), ''),
128
+ NULLIF(btrim(COALESCE(c.district, '')), ''),
129
+ NULLIF(btrim(COALESCE(c.city, '')), ''),
130
+ NULLIF(btrim(COALESCE(c.state, '')), ''),
131
+ NULLIF(btrim(COALESCE(c.postal_code, '')), ''),
132
+ COALESCE(NULLIF(btrim(COALESCE(c.country, '')), ''), 'BR'),
133
+ NULLIF(btrim(COALESCE(c.phone, '')), ''),
134
+ true
135
+ FROM public.company_legal_entities c
136
+ WHERE NULLIF(btrim(COALESCE(c.address, '')), '') IS NOT NULL
137
+ AND NULLIF(btrim(COALESCE(c.city, '')), '') IS NOT NULL
138
+ AND NULLIF(btrim(COALESCE(c.state, '')), '') IS NOT NULL
139
+ AND NULLIF(btrim(COALESCE(c.postal_code, '')), '') IS NOT NULL
140
+ AND NOT EXISTS (
141
+ SELECT 1 FROM public.addresses a
142
+ WHERE a.owner_type = 'company_legal_entity' AND a.owner_id = c.id
143
+ );
144
+
145
+ COMMENT ON COLUMN public.company_legal_entities.address IS
146
+ 'DEPRECATED (142): a company has many addresses — read public.addresses WHERE owner_type = ''company_legal_entity''. Kept as a mirror until the writers move.';
147
+ COMMENT ON COLUMN public.company_legal_entities.address_number IS
148
+ 'DEPRECATED (142): see public.addresses.number.';
149
+ COMMENT ON COLUMN public.company_legal_entities.address_complement IS
150
+ 'DEPRECATED (142): see public.addresses.complement.';
151
+ COMMENT ON COLUMN public.company_legal_entities.district IS
152
+ 'DEPRECATED (142): see public.addresses.district.';
153
+ COMMENT ON COLUMN public.company_legal_entities.city IS
154
+ 'DEPRECATED (142): see public.addresses.';
155
+ COMMENT ON COLUMN public.company_legal_entities.state IS
156
+ 'DEPRECATED (142): see public.addresses.';
157
+ COMMENT ON COLUMN public.company_legal_entities.postal_code IS
158
+ 'DEPRECATED (142): see public.addresses.';
159
+
160
+ -- A unit bills through a company (app.units.company_legal_entity_id), so the
161
+ -- branch's fiscal address is the company's — this is the join every fiscal
162
+ -- screen needs and none of them should re-derive.
163
+ CREATE OR REPLACE VIEW public.v_company_addresses
164
+ WITH (security_invoker = true) AS
165
+ SELECT
166
+ a.id, a.tenant_id, a.owner_id AS company_legal_entity_id,
167
+ c.legal_name, c.trade_name, c.document_number,
168
+ a.kind, a.label,
169
+ a.postal_code, a.street, a.number, a.complement, a.district,
170
+ a.city, a.state, a.country, a.phone, a.is_default,
171
+ a.created_at, a.updated_at
172
+ FROM public.addresses a
173
+ JOIN public.company_legal_entities c ON c.id = a.owner_id
174
+ WHERE a.owner_type = 'company_legal_entity';
175
+
176
+ GRANT SELECT ON public.v_company_addresses TO authenticated;
177
+
178
+ COMMENT ON VIEW public.v_company_addresses IS
179
+ 'An Empresa and every address it owns (142), with the CNPJ that identifies it.';
180
+
181
+ -- What did not come across, said out loud. A partial address is not a failure to
182
+ -- fix here — it is a person or a company whose address was never fully typed in,
183
+ -- and the place to finish it is the screen, not a migration.
184
+ DO $$
185
+ DECLARE v_people integer; v_co integer;
186
+ BEGIN
187
+ SELECT count(*) INTO v_people FROM public.people p
188
+ WHERE (COALESCE(btrim(p.address),'') <> '' OR COALESCE(btrim(p.city),'') <> ''
189
+ OR COALESCE(btrim(p.state),'') <> '' OR COALESCE(btrim(p.postal_code),'') <> '')
190
+ AND NOT EXISTS (SELECT 1 FROM public.addresses a
191
+ WHERE a.owner_type = 'person' AND a.owner_id = p.id);
192
+ SELECT count(*) INTO v_co FROM public.company_legal_entities c
193
+ WHERE (COALESCE(btrim(c.address),'') <> '' OR COALESCE(btrim(c.city),'') <> '')
194
+ AND NOT EXISTS (SELECT 1 FROM public.addresses a
195
+ WHERE a.owner_type = 'company_legal_entity' AND a.owner_id = c.id);
196
+ IF v_people > 0 OR v_co > 0 THEN
197
+ RAISE NOTICE '142: % person(s) and % company(ies) hold a PARTIAL address — left in the deprecated columns, not invented into public.addresses', v_people, v_co;
198
+ END IF;
199
+ END $$;
200
+
201
+ -- Pools that took an earlier version of this file. It asked for a kind the
202
+ -- CHECK does not list, and a pool with a normalising trigger stored 'both'
203
+ -- instead — a person's one address filed as "shipping and billing at once",
204
+ -- which is not what anyone meant. The label is this migration's own signature,
205
+ -- so only its own rows move.
206
+ UPDATE public.addresses
207
+ SET kind = 'main'
208
+ WHERE owner_type = 'person' AND label = 'Endereço principal' AND kind <> 'main';
209
+
210
+ UPDATE public.addresses
211
+ SET kind = 'fiscal'
212
+ WHERE owner_type = 'company_legal_entity' AND label = 'Endereço fiscal' AND kind <> 'fiscal';