@fayz-ai/db 0.13.0 → 0.14.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 +106 -23
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +42 -37
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/000_baseline.sql +29667 -0
- package/migrations/001_the_tenant_has_apps.sql +314 -0
- package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
- package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
- package/migrations/004_the_projections_get_a_successor.sql +312 -0
- package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
- package/migrations/006_the_helpers_read_the_source.sql +153 -0
- package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
- package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
- package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
- package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
- package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
- package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
- package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
- package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
- package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
- package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
- package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
- package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
- package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
- package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
- package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
- package/package.json +1 -1
- package/migrations/0000_legacy_quarantine.sql +0 -86
- package/migrations/0000b_straggler_sweep.sql +0 -37
- package/migrations/000_core_v1_convert.sql +0 -343
- package/migrations/001_core.sql +0 -372
- package/migrations/002_rls_user_tenant_ids.sql +0 -64
- package/migrations/003_invitations.sql +0 -17
- package/migrations/004_archetypes.sql +0 -343
- package/migrations/005_locations_archetype.sql +0 -18
- package/migrations/006_archetype_rls.sql +0 -74
- package/migrations/007_archetype_grants.sql +0 -4
- package/migrations/008_grants.sql +0 -5
- package/migrations/009_anon_hardening.sql +0 -19
- package/migrations/010_migration_ledger.sql +0 -46
- package/migrations/011_anon_write_revoke.sql +0 -12
- package/migrations/012_rename_migration_ledger.sql +0 -61
- package/migrations/013_sequences_rls.sql +0 -22
- package/migrations/014_plan_entitlements.sql +0 -28
- package/migrations/015_agent_guard.sql +0 -108
- package/migrations/016_agent_guard_actor.sql +0 -102
- package/migrations/017_core_addresses.sql +0 -115
- package/migrations/018_global_search.sql +0 -482
- package/migrations/019_tenant_members_person_id.sql +0 -20
- package/migrations/020_fayz_projects.sql +0 -63
- package/migrations/021_tenant_roles.sql +0 -74
- package/migrations/022_invite_acceptance.sql +0 -91
- package/migrations/023_team_visibility.sql +0 -75
- package/migrations/024_analytics_engine.sql +0 -317
- package/migrations/025_created_by.sql +0 -75
- package/migrations/026_audit_trail.sql +0 -73
- package/migrations/027_domain_events.sql +0 -266
- package/migrations/028_tenant_scoped_token.sql +0 -127
- package/migrations/029_connections.sql +0 -186
- package/migrations/030_effect_idempotency.sql +0 -159
- package/migrations/031_sync_run_message.sql +0 -39
- package/migrations/032_connection_secrets.sql +0 -227
- package/migrations/033_sync_schedule.sql +0 -651
- package/migrations/034_custom_fields.sql +0 -55
- package/migrations/035_field_registry.sql +0 -148
- package/migrations/036_analytics_run_batch.sql +0 -84
- package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
- package/migrations/038_onboarding_responses.sql +0 -103
- package/migrations/039_unit_tree.sql +0 -270
- package/migrations/040_resource_grants.sql +0 -474
- package/migrations/041_scoped_columns.sql +0 -192
- package/migrations/042_unit_scope_policies.sql +0 -145
- package/migrations/043_view_invoker.sql +0 -90
- package/migrations/044_unit_member_facts.sql +0 -47
- package/migrations/045_unit_entry.sql +0 -236
- package/migrations/046_membership_visible_to_members.sql +0 -85
- package/migrations/047_tasks.sql +0 -266
- package/migrations/048_every_login_is_a_person.sql +0 -190
- package/migrations/049_bookable_people.sql +0 -126
- package/migrations/050_authz_core.sql +0 -230
- package/migrations/051_authz_helpers.sql +0 -330
- package/migrations/052_authz_fanout.sql +0 -253
- package/migrations/053_authz_catalog_templates.sql +0 -402
- package/migrations/060_tenancy_seam.sql +0 -132
- package/migrations/061_create_tenant.sql +0 -125
- package/migrations/062_invitations.sql +0 -329
- package/migrations/063_legacy_tenancy_retired.sql +0 -568
- package/migrations/064_agent_guard_authz.sql +0 -118
- package/migrations/064b_supersede_unit_scope.sql +0 -205
- package/migrations/065_scaffold_sweep.sql +0 -139
- package/migrations/066_shadow_report.sql +0 -122
- package/migrations/070_permission_catalog.sql +0 -205
- package/migrations/071_v1_permission_map.sql +0 -266
- package/migrations/072_role_rpcs.sql +0 -126
- package/migrations/073_legacy_rbac_retire.sql +0 -471
- package/migrations/075_company_legal_entities.sql +0 -169
- package/migrations/076_tenant_settings.sql +0 -251
- package/migrations/080_people_roles.sql +0 -269
- package/migrations/081_people_identity.sql +0 -271
- package/migrations/082_people_membership_link.sql +0 -142
- package/migrations/083_people_data_migration.sql +0 -75
- package/migrations/084_people_registrations_tokens.sql +0 -282
- package/migrations/085_catalog_products.sql +0 -333
- package/migrations/086_catalog_pricing.sql +0 -194
- package/migrations/087_catalog_packages.sql +0 -134
- package/migrations/090_orders_venda.sql +0 -166
- package/migrations/091_orders_scaffold.sql +0 -92
- package/migrations/095_config_field_rules.sql +0 -261
- package/migrations/096_audit_append_only.sql +0 -256
- package/migrations/097_sequences_authorized.sql +0 -111
- package/migrations/098_holidays.sql +0 -80
- package/migrations/099_access_grants.sql +0 -293
- package/migrations/100_migration_schema.sql +0 -217
- package/migrations/101_migration_allowlist.sql +0 -202
- package/migrations/102_migration_rpcs.sql +0 -933
- package/migrations/103_migration_reconcile_ponr.sql +0 -132
- package/migrations/105_policies_to_authenticated.sql +0 -71
- package/migrations/106_function_search_path.sql +0 -55
- package/migrations/107_core_table_prefix.sql +0 -361
- package/migrations/108_authz_write_guards.sql +0 -149
- package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
- package/migrations/110_people_kind_vocabulary.sql +0 -120
- package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
- package/migrations/112_money_precision_standard.sql +0 -132
- package/migrations/113_addresses_units_companies.sql +0 -114
- package/migrations/114_migrations_ledger_immutable.sql +0 -65
- package/migrations/115_registration_honours_field_rules.sql +0 -172
- package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
- package/migrations/117_authz_reconciliation_schedule.sql +0 -82
- package/migrations/118_migration_catalog_writers.sql +0 -450
- package/migrations/119_migration_config_writers.sql +0 -193
- package/migrations/120_migration_facet_writer.sql +0 -139
- package/migrations/121_migration_financial_writers.sql +0 -294
- package/migrations/122_migration_inventory_writers.sql +0 -245
- package/migrations/123_migration_order_item_writer.sql +0 -144
- package/migrations/124_migration_ledger_writers.sql +0 -170
- package/migrations/125_migration_authz_people_writers.sql +0 -205
- package/migrations/126_owner_scoping_is_configuration.sql +0 -247
- package/migrations/127_scaffold_audit_hook.sql +0 -98
- package/migrations/128_shadow_tables_enforced.sql +0 -93
- package/migrations/129_order_kind_terminal_states.sql +0 -57
- package/migrations/130_every_login_is_a_person.sql +0 -196
- package/migrations/131_migration_invoice_split.sql +0 -127
- package/migrations/132_audit_prune_scheduled.sql +0 -28
- package/migrations/133_unit_is_a_session_property.sql +0 -155
- package/migrations/134_field_rules_on_read.sql +0 -341
- package/migrations/135_distribution_polarity.sql +0 -158
- package/migrations/136_unit_id_means_locality.sql +0 -358
- package/migrations/137_vendor_named_spine_objects.sql +0 -126
- package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
- package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
- package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
- package/migrations/142_a_person_has_addresses.sql +0 -212
- package/migrations/143_a_professional_has_a_rate.sql +0 -75
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
- package/migrations/146_products_stock_has_one_writer.sql +0 -67
- package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
- package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
- package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
- package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
- package/migrations/152_a_file_is_an_archetype.sql +0 -222
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
- package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
- package/migrations/155_a_tenant_can_leave.sql +0 -271
- package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
- package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
- package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
- package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
- package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
- package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
- package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
- package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
-- ---------------------------------------------------------------------------
|
|
2
|
-
-- 156_the_service_door_has_a_server_side.sql — the half of #278 that 153 could
|
|
3
|
-
-- not carry: WHERE a service lives, and how it is called with no browser.
|
|
4
|
-
--
|
|
5
|
-
-- 153 built the decision layer — which services exist, who may call one, what
|
|
6
|
-
-- happens when it is down, and what was called. What it deliberately did not
|
|
7
|
-
-- build is the leg that leaves the cluster, because a migration that opened a
|
|
8
|
-
-- socket would be the wrong kind of clever. That leg is an edge function
|
|
9
|
-
-- (`functions/service-router`), and this file is what the function reads.
|
|
10
|
-
--
|
|
11
|
-
-- ── Why the endpoint is not a column on app.services ────────────────────────
|
|
12
|
-
-- `services_read` grants SELECT on app.services to every authenticated user,
|
|
13
|
-
-- and that is correct: a screen needs the manual fallback and the entitlement
|
|
14
|
-
-- to render an honest refusal. An upstream URL is a different kind of fact —
|
|
15
|
-
-- internal topology, sometimes carrying a path that names a vendor. It goes in
|
|
16
|
-
-- its own table that `authenticated` cannot read at all, so widening the one
|
|
17
|
-
-- does not widen the other.
|
|
18
|
-
--
|
|
19
|
-
-- ── Why the credential is a NAME and not a value ────────────────────────────
|
|
20
|
-
-- app.service_endpoints stores `auth_env` — the name of the environment
|
|
21
|
-
-- variable the router reads — never the secret. A secret in a table is a secret
|
|
22
|
-
-- in every backup, every replica and every `pg_dump` somebody mails themselves.
|
|
23
|
-
-- The cluster carries the credential in its function environment; the database
|
|
24
|
-
-- carries only the instruction for finding it.
|
|
25
|
-
--
|
|
26
|
-
-- ── Why there is a second authorize ─────────────────────────────────────────
|
|
27
|
-
-- `service_authorize` answers for auth.uid() in the current tenant, which is
|
|
28
|
-
-- exactly right for a click. It is useless at 2am: the event consumer (#277)
|
|
29
|
-
-- reacts to a fact with no session, no JWT and no user, and 153's function
|
|
30
|
-
-- would answer `no_tenant` forever. `service_authorize_for` takes the tenant
|
|
31
|
-
-- explicitly, is service_role-only, and skips the permission check because
|
|
32
|
-
-- there is no person to have permissions — the entitlement check stays, because
|
|
33
|
-
-- the tenant's contract is a property of the tenant, not of who is looking.
|
|
34
|
-
-- ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
37
|
-
-- §1 Where a service lives
|
|
38
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
CREATE TABLE IF NOT EXISTS app.service_endpoints (
|
|
41
|
-
service text PRIMARY KEY REFERENCES app.services(name) ON DELETE CASCADE,
|
|
42
|
-
-- Absolute https URL. http is allowed only for loopback, so a dev machine
|
|
43
|
-
-- works and a production typo does not silently downgrade the transport.
|
|
44
|
-
url text NOT NULL CHECK (
|
|
45
|
-
url ~ '^https://' OR url ~ '^http://(localhost|127\.0\.0\.1)(:[0-9]+)?(/|$)'
|
|
46
|
-
),
|
|
47
|
-
-- The NAME of the env var holding the credential. Never the credential.
|
|
48
|
-
auth_env text CHECK (auth_env IS NULL OR auth_env ~ '^[A-Z][A-Z0-9_]*$'),
|
|
49
|
-
-- How the credential is presented: bearer (Authorization: Bearer …) or a
|
|
50
|
-
-- header the vendor names.
|
|
51
|
-
auth_style text NOT NULL DEFAULT 'bearer' CHECK (auth_style IN ('bearer', 'header', 'none')),
|
|
52
|
-
auth_header text,
|
|
53
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
54
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
COMMENT ON TABLE app.service_endpoints IS
|
|
58
|
-
'Where each service actually lives, and the NAME of the env var holding the credential to reach it (156). Readable by service_role only: app.services is public to a signed-in user because a screen needs the fallback text, and internal topology is not the same fact.';
|
|
59
|
-
COMMENT ON COLUMN app.service_endpoints.auth_env IS
|
|
60
|
-
'The name of an environment variable, never a secret. A secret in a table is a secret in every backup.';
|
|
61
|
-
|
|
62
|
-
ALTER TABLE app.service_endpoints ENABLE ROW LEVEL SECURITY;
|
|
63
|
-
-- No policy for `authenticated` on purpose: the table is service_role's.
|
|
64
|
-
REVOKE ALL ON app.service_endpoints FROM anon, authenticated;
|
|
65
|
-
GRANT ALL ON app.service_endpoints TO service_role;
|
|
66
|
-
|
|
67
|
-
DROP TRIGGER IF EXISTS service_endpoints_updated_at ON app.service_endpoints;
|
|
68
|
-
CREATE TRIGGER service_endpoints_updated_at BEFORE UPDATE ON app.service_endpoints
|
|
69
|
-
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
70
|
-
|
|
71
|
-
CREATE OR REPLACE FUNCTION public.register_service_endpoint(
|
|
72
|
-
p_service text,
|
|
73
|
-
p_url text,
|
|
74
|
-
p_auth_env text DEFAULT NULL,
|
|
75
|
-
p_auth_style text DEFAULT 'bearer',
|
|
76
|
-
p_auth_header text DEFAULT NULL
|
|
77
|
-
) RETURNS void
|
|
78
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
79
|
-
AS $$
|
|
80
|
-
BEGIN
|
|
81
|
-
IF p_auth_style = 'header' AND coalesce(btrim(p_auth_header), '') = '' THEN
|
|
82
|
-
RAISE EXCEPTION 'register_service_endpoint: auth_style=header needs auth_header'
|
|
83
|
-
USING ERRCODE = '22023';
|
|
84
|
-
END IF;
|
|
85
|
-
INSERT INTO app.service_endpoints (service, url, auth_env, auth_style, auth_header)
|
|
86
|
-
VALUES (p_service, p_url, p_auth_env, p_auth_style, p_auth_header)
|
|
87
|
-
ON CONFLICT (service) DO UPDATE
|
|
88
|
-
SET url = EXCLUDED.url, auth_env = EXCLUDED.auth_env,
|
|
89
|
-
auth_style = EXCLUDED.auth_style, auth_header = EXCLUDED.auth_header;
|
|
90
|
-
END $$;
|
|
91
|
-
|
|
92
|
-
REVOKE ALL ON FUNCTION public.register_service_endpoint(text, text, text, text, text) FROM public, anon, authenticated;
|
|
93
|
-
GRANT EXECUTE ON FUNCTION public.register_service_endpoint(text, text, text, text, text) TO service_role;
|
|
94
|
-
|
|
95
|
-
-- What the router reads, in one call, after it has authorized. Returns NULL
|
|
96
|
-
-- rather than raising when a service has no endpoint: "registered but not
|
|
97
|
-
-- reachable yet" is a real and temporary state, and the router turns it into
|
|
98
|
-
-- the same honest refusal as any other unreachable service.
|
|
99
|
-
CREATE OR REPLACE FUNCTION public.service_endpoint(p_service text)
|
|
100
|
-
RETURNS jsonb
|
|
101
|
-
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
|
|
102
|
-
AS $$
|
|
103
|
-
SELECT jsonb_build_object(
|
|
104
|
-
'service', e.service, 'url', e.url, 'auth_env', e.auth_env,
|
|
105
|
-
'auth_style', e.auth_style, 'auth_header', e.auth_header,
|
|
106
|
-
'timeout_ms', s.timeout_ms)
|
|
107
|
-
FROM app.service_endpoints e
|
|
108
|
-
JOIN app.services s ON s.name = e.service
|
|
109
|
-
WHERE e.service = p_service AND s.is_active;
|
|
110
|
-
$$;
|
|
111
|
-
|
|
112
|
-
REVOKE ALL ON FUNCTION public.service_endpoint(text) FROM public, anon, authenticated;
|
|
113
|
-
GRANT EXECUTE ON FUNCTION public.service_endpoint(text) TO service_role;
|
|
114
|
-
|
|
115
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
116
|
-
-- §2 Authorizing with no person in the room
|
|
117
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
-- 150's `tenant_plugin_active` reads app.current_tenant_id(), which is a JWT
|
|
120
|
-
-- claim. A background reaction has no JWT, so it needs the same question asked
|
|
121
|
-
-- of an explicit tenant. Same predicate, one argument more — not a second
|
|
122
|
-
-- source of truth for what a tenant has.
|
|
123
|
-
CREATE OR REPLACE FUNCTION public.tenant_plugin_active_for(p_tenant uuid, p_plugin text, p_facet text DEFAULT '')
|
|
124
|
-
RETURNS boolean
|
|
125
|
-
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
|
|
126
|
-
AS $$
|
|
127
|
-
SELECT EXISTS (
|
|
128
|
-
SELECT 1 FROM app.tenant_plugins t
|
|
129
|
-
WHERE t.tenant_id = p_tenant
|
|
130
|
-
AND t.plugin_id = p_plugin
|
|
131
|
-
AND t.facet = coalesce(p_facet, '')
|
|
132
|
-
AND t.status = 'active'
|
|
133
|
-
AND (t.expires_at IS NULL OR t.expires_at > now())
|
|
134
|
-
)
|
|
135
|
-
$$;
|
|
136
|
-
|
|
137
|
-
COMMENT ON FUNCTION public.tenant_plugin_active_for(uuid, text, text) IS
|
|
138
|
-
'tenant_plugin_active for an explicit tenant (156) — the same predicate, for a caller with no session. service_role only: a signed-in user asks about their own tenant and already has the other one.';
|
|
139
|
-
|
|
140
|
-
REVOKE ALL ON FUNCTION public.tenant_plugin_active_for(uuid, text, text) FROM public, anon, authenticated;
|
|
141
|
-
GRANT EXECUTE ON FUNCTION public.tenant_plugin_active_for(uuid, text, text) TO service_role;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
CREATE OR REPLACE FUNCTION public.service_authorize_for(p_name text, p_tenant uuid)
|
|
145
|
-
RETURNS jsonb
|
|
146
|
-
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
|
147
|
-
AS $$
|
|
148
|
-
DECLARE
|
|
149
|
-
v_service app.services%ROWTYPE;
|
|
150
|
-
BEGIN
|
|
151
|
-
IF p_tenant IS NULL THEN
|
|
152
|
-
RETURN jsonb_build_object('allowed', false, 'reason', 'no_tenant');
|
|
153
|
-
END IF;
|
|
154
|
-
|
|
155
|
-
SELECT * INTO v_service FROM app.services WHERE name = p_name;
|
|
156
|
-
IF NOT FOUND OR NOT v_service.is_active THEN
|
|
157
|
-
RETURN jsonb_build_object('allowed', false, 'reason', 'unknown_service');
|
|
158
|
-
END IF;
|
|
159
|
-
|
|
160
|
-
-- No permission check: there is no person. A background reaction acts for the
|
|
161
|
-
-- tenant, not for a user, and inventing a user to check would be a lie the
|
|
162
|
-
-- audit trail then repeats.
|
|
163
|
-
IF v_service.entitlement IS NOT NULL
|
|
164
|
-
AND NOT public.tenant_plugin_active_for(p_tenant, v_service.plugin, v_service.entitlement) THEN
|
|
165
|
-
RETURN jsonb_build_object('allowed', false, 'reason', 'not_contracted',
|
|
166
|
-
'entitlement', v_service.entitlement,
|
|
167
|
-
'plugin', v_service.plugin,
|
|
168
|
-
'manual_fallback', v_service.manual_fallback);
|
|
169
|
-
END IF;
|
|
170
|
-
|
|
171
|
-
RETURN jsonb_build_object('allowed', true, 'service', v_service.name, 'tenant_id', p_tenant,
|
|
172
|
-
'timeout_ms', v_service.timeout_ms,
|
|
173
|
-
'manual_fallback', v_service.manual_fallback,
|
|
174
|
-
'actor', 'system');
|
|
175
|
-
END $$;
|
|
176
|
-
|
|
177
|
-
COMMENT ON FUNCTION public.service_authorize_for(text, uuid) IS
|
|
178
|
-
'service_authorize for a caller with no session (156): the event consumer reacting to a fact at 2am. Same entitlement rule, no permission rule — there is no person to have one. service_role only.';
|
|
179
|
-
|
|
180
|
-
REVOKE ALL ON FUNCTION public.service_authorize_for(text, uuid) FROM public, anon, authenticated;
|
|
181
|
-
GRANT EXECUTE ON FUNCTION public.service_authorize_for(text, uuid) TO service_role;
|
|
182
|
-
|
|
183
|
-
-- The same for the history: a system call still has to be findable when
|
|
184
|
-
-- somebody asks why the stock did not move.
|
|
185
|
-
CREATE OR REPLACE FUNCTION public.service_call_record_for(
|
|
186
|
-
p_service text, p_tenant uuid, p_status text,
|
|
187
|
-
p_reason text DEFAULT NULL, p_duration_ms integer DEFAULT NULL
|
|
188
|
-
) RETURNS uuid
|
|
189
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
190
|
-
AS $$
|
|
191
|
-
DECLARE
|
|
192
|
-
v_id uuid;
|
|
193
|
-
BEGIN
|
|
194
|
-
INSERT INTO app.service_calls (tenant_id, service, caller, status, reason, duration_ms)
|
|
195
|
-
VALUES (p_tenant, p_service, NULL, p_status, p_reason, p_duration_ms)
|
|
196
|
-
RETURNING id INTO v_id;
|
|
197
|
-
RETURN v_id;
|
|
198
|
-
END $$;
|
|
199
|
-
|
|
200
|
-
COMMENT ON FUNCTION public.service_call_record_for(text, uuid, text, text, integer) IS
|
|
201
|
-
'Records a service call made with no session (156). caller is NULL — deliberately: the row says "the system did this", which is true, instead of naming a user who was asleep.';
|
|
202
|
-
|
|
203
|
-
REVOKE ALL ON FUNCTION public.service_call_record_for(text, uuid, text, text, integer) FROM public, anon, authenticated;
|
|
204
|
-
GRANT EXECUTE ON FUNCTION public.service_call_record_for(text, uuid, text, text, integer) TO service_role;
|
|
205
|
-
|
|
206
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
207
|
-
-- §3 What a background reaction has queued
|
|
208
|
-
--
|
|
209
|
-
-- The consumer runs inside the database and cannot open a socket. So a handler
|
|
210
|
-
-- that needs a service does not call it — it ASKS for it, and the same worker
|
|
211
|
-
-- that drains the event log drains this. One more row, and the reason it is a
|
|
212
|
-
-- row rather than a callback: a service that is down must not roll back the
|
|
213
|
-
-- fact that was already recorded.
|
|
214
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
215
|
-
|
|
216
|
-
CREATE TABLE IF NOT EXISTS app.service_requests (
|
|
217
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
218
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
219
|
-
service text NOT NULL REFERENCES app.services(name) ON DELETE CASCADE,
|
|
220
|
-
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
221
|
-
-- Where the answer goes: a function name the worker calls back with the
|
|
222
|
-
-- result. NULL means fire-and-forget.
|
|
223
|
-
callback text,
|
|
224
|
-
origin text NOT NULL DEFAULT 'event' CHECK (origin IN ('event', 'schedule', 'manual')),
|
|
225
|
-
origin_ref text,
|
|
226
|
-
status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sent', 'ok', 'failed', 'refused')),
|
|
227
|
-
attempts integer NOT NULL DEFAULT 0,
|
|
228
|
-
next_try_at timestamptz NOT NULL DEFAULT now(),
|
|
229
|
-
last_error text,
|
|
230
|
-
result jsonb,
|
|
231
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
232
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
COMMENT ON TABLE app.service_requests IS
|
|
236
|
-
'Service calls a background reaction asked for (156). A row rather than a callback, so a service being down cannot roll back the fact that caused it.';
|
|
237
|
-
|
|
238
|
-
CREATE INDEX IF NOT EXISTS service_requests_due ON app.service_requests (next_try_at)
|
|
239
|
-
WHERE status IN ('queued', 'failed');
|
|
240
|
-
|
|
241
|
-
ALTER TABLE app.service_requests ENABLE ROW LEVEL SECURITY;
|
|
242
|
-
DROP POLICY IF EXISTS service_requests_read ON app.service_requests;
|
|
243
|
-
CREATE POLICY service_requests_read ON app.service_requests
|
|
244
|
-
FOR SELECT TO authenticated
|
|
245
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
246
|
-
GRANT SELECT ON app.service_requests TO authenticated;
|
|
247
|
-
GRANT ALL ON app.service_requests TO service_role;
|
|
248
|
-
|
|
249
|
-
DROP TRIGGER IF EXISTS service_requests_updated_at ON app.service_requests;
|
|
250
|
-
CREATE TRIGGER service_requests_updated_at BEFORE UPDATE ON app.service_requests
|
|
251
|
-
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
252
|
-
|
|
253
|
-
-- What a handler calls. Deliberately cheap: it enqueues and returns, so a
|
|
254
|
-
-- handler's transaction never waits on a network it cannot see.
|
|
255
|
-
CREATE OR REPLACE FUNCTION public.service_request(
|
|
256
|
-
p_tenant uuid, p_service text, p_payload jsonb DEFAULT '{}'::jsonb,
|
|
257
|
-
p_callback text DEFAULT NULL, p_origin text DEFAULT 'event', p_origin_ref text DEFAULT NULL
|
|
258
|
-
) RETURNS uuid
|
|
259
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
260
|
-
AS $$
|
|
261
|
-
DECLARE
|
|
262
|
-
v_id uuid;
|
|
263
|
-
BEGIN
|
|
264
|
-
INSERT INTO app.service_requests (tenant_id, service, payload, callback, origin, origin_ref)
|
|
265
|
-
VALUES (p_tenant, p_service, coalesce(p_payload, '{}'::jsonb), p_callback, p_origin, p_origin_ref)
|
|
266
|
-
RETURNING id INTO v_id;
|
|
267
|
-
RETURN v_id;
|
|
268
|
-
END $$;
|
|
269
|
-
|
|
270
|
-
REVOKE ALL ON FUNCTION public.service_request(uuid, text, jsonb, text, text, text) FROM public, anon, authenticated;
|
|
271
|
-
GRANT EXECUTE ON FUNCTION public.service_request(uuid, text, jsonb, text, text, text) TO service_role;
|
|
272
|
-
|
|
273
|
-
-- What the worker drains. Same shape as consume_event_log: SKIP LOCKED so two
|
|
274
|
-
-- workers do not fight, and a claim that marks before the call so a crash
|
|
275
|
-
-- cannot produce a second identical request.
|
|
276
|
-
CREATE OR REPLACE FUNCTION public.claim_service_requests(p_limit integer DEFAULT 20)
|
|
277
|
-
RETURNS TABLE (id uuid, tenant_id uuid, service text, payload jsonb, callback text, attempts integer)
|
|
278
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
279
|
-
AS $$
|
|
280
|
-
BEGIN
|
|
281
|
-
RETURN QUERY
|
|
282
|
-
WITH claimed AS (
|
|
283
|
-
SELECT r.id FROM app.service_requests r
|
|
284
|
-
WHERE r.status IN ('queued', 'failed')
|
|
285
|
-
AND r.next_try_at <= now()
|
|
286
|
-
AND r.attempts < 5
|
|
287
|
-
ORDER BY r.next_try_at
|
|
288
|
-
LIMIT greatest(1, least(p_limit, 200))
|
|
289
|
-
FOR UPDATE SKIP LOCKED
|
|
290
|
-
)
|
|
291
|
-
UPDATE app.service_requests r
|
|
292
|
-
SET status = 'sent', attempts = r.attempts + 1
|
|
293
|
-
FROM claimed c
|
|
294
|
-
WHERE r.id = c.id
|
|
295
|
-
RETURNING r.id, r.tenant_id, r.service, r.payload, r.callback, r.attempts;
|
|
296
|
-
END $$;
|
|
297
|
-
|
|
298
|
-
REVOKE ALL ON FUNCTION public.claim_service_requests(integer) FROM public, anon, authenticated;
|
|
299
|
-
GRANT EXECUTE ON FUNCTION public.claim_service_requests(integer) TO service_role;
|
|
300
|
-
|
|
301
|
-
CREATE OR REPLACE FUNCTION public.settle_service_request(
|
|
302
|
-
p_id uuid, p_status text, p_result jsonb DEFAULT NULL, p_error text DEFAULT NULL
|
|
303
|
-
) RETURNS void
|
|
304
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
305
|
-
AS $$
|
|
306
|
-
BEGIN
|
|
307
|
-
UPDATE app.service_requests
|
|
308
|
-
SET status = p_status,
|
|
309
|
-
result = coalesce(p_result, result),
|
|
310
|
-
last_error = p_error,
|
|
311
|
-
-- Same capped backoff as the event consumer, for the same reason: a
|
|
312
|
-
-- service that is down for an hour must not be hammered for an hour.
|
|
313
|
-
next_try_at = CASE WHEN p_status = 'failed'
|
|
314
|
-
THEN now() + least(interval '1 hour',
|
|
315
|
-
(power(2, attempts + 1)::text || ' seconds')::interval)
|
|
316
|
-
ELSE next_try_at END
|
|
317
|
-
WHERE id = p_id;
|
|
318
|
-
END $$;
|
|
319
|
-
|
|
320
|
-
REVOKE ALL ON FUNCTION public.settle_service_request(uuid, text, jsonb, text) FROM public, anon, authenticated;
|
|
321
|
-
GRANT EXECUTE ON FUNCTION public.settle_service_request(uuid, text, jsonb, text) TO service_role;
|
|
322
|
-
|
|
323
|
-
-- The one place to look when a background reaction did not happen.
|
|
324
|
-
CREATE OR REPLACE VIEW public.v_service_queue
|
|
325
|
-
WITH (security_invoker = true) AS
|
|
326
|
-
SELECT r.tenant_id, r.service, r.status, r.origin, r.origin_ref,
|
|
327
|
-
r.attempts, r.next_try_at, r.last_error, r.created_at
|
|
328
|
-
FROM app.service_requests r
|
|
329
|
-
WHERE r.status <> 'ok'
|
|
330
|
-
ORDER BY r.created_at DESC;
|
|
331
|
-
|
|
332
|
-
COMMENT ON VIEW public.v_service_queue IS
|
|
333
|
-
'Background service calls that have not succeeded (156) — the answer to "why did the reaction not happen?", in one place instead of per plugin.';
|
|
334
|
-
|
|
335
|
-
GRANT SELECT ON public.v_service_queue TO authenticated, service_role;
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
-- ---------------------------------------------------------------------------
|
|
2
|
-
-- 157_nobody_creates_objects_in_public.sql — the other half of the definer rule
|
|
3
|
-
-- (#283).
|
|
4
|
-
--
|
|
5
|
-
-- `SECURITY DEFINER` without a pinned `search_path` is the classic escalation
|
|
6
|
-
-- shape: the function runs as its owner, and resolves unqualified names using
|
|
7
|
-
-- the CALLER's path. An attacker who can create `public.now()` — or anything
|
|
8
|
-
-- else the body calls unqualified — gets it executed as the owner.
|
|
9
|
-
--
|
|
10
|
-
-- Pinning the path on every definer function is the direct fix, and the plugins
|
|
11
|
-
-- do that in their own migrations. This file removes the other precondition:
|
|
12
|
-
-- the ability to create the shadowing object at all.
|
|
13
|
-
--
|
|
14
|
-
-- Postgres 15 already stopped granting CREATE on `public` to PUBLIC, so on a
|
|
15
|
-
-- current cluster this is a no-op that documents an assumption. It is written
|
|
16
|
-
-- down anyway because "it is the default" is exactly the kind of belief that
|
|
17
|
-
-- silently stops being true — a restore from an older dump, a pool created
|
|
18
|
-
-- before an upgrade, a `GRANT` somebody added for one migration and left.
|
|
19
|
-
-- Cheap to assert, and asserting it is what makes the definer rule complete.
|
|
20
|
-
--
|
|
21
|
-
-- Nothing legitimate loses anything: migrations run as the owner, and the owner
|
|
22
|
-
-- keeps CREATE. What loses is `authenticated` planting a function in the path
|
|
23
|
-
-- of one that runs as somebody else.
|
|
24
|
-
-- ---------------------------------------------------------------------------
|
|
25
|
-
|
|
26
|
-
DO $$
|
|
27
|
-
BEGIN
|
|
28
|
-
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'public') THEN
|
|
29
|
-
RETURN;
|
|
30
|
-
END IF;
|
|
31
|
-
|
|
32
|
-
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
|
33
|
-
-- Named explicitly as well: a role holding it directly is not covered by the
|
|
34
|
-
-- revoke from PUBLIC, and that is the case somebody's one-off GRANT creates.
|
|
35
|
-
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
|
36
|
-
REVOKE CREATE ON SCHEMA public FROM anon;
|
|
37
|
-
END IF;
|
|
38
|
-
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
|
39
|
-
REVOKE CREATE ON SCHEMA public FROM authenticated;
|
|
40
|
-
END IF;
|
|
41
|
-
END $$;
|
|
42
|
-
|
|
43
|
-
COMMENT ON SCHEMA public IS
|
|
44
|
-
'The archetype schema. No role below the owner may CREATE here (157): a SECURITY DEFINER function resolving an unqualified name is only exploitable if somebody else can plant the object it resolves to.';
|
|
45
|
-
|
|
46
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
47
|
-
-- The census: which definer functions still resolve names loosely.
|
|
48
|
-
--
|
|
49
|
-
-- The CI gate reads the repository, which is where a fix belongs. This reads
|
|
50
|
-
-- the live catalog, which is where the truth is — a pool carries functions from
|
|
51
|
-
-- versions the repository has moved past, and a plugin uninstalled from the
|
|
52
|
-
-- code does not uninstall itself from the database.
|
|
53
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
CREATE OR REPLACE VIEW public.v_definer_functions_unpinned
|
|
56
|
-
WITH (security_invoker = true) AS
|
|
57
|
-
SELECT n.nspname AS schema_name,
|
|
58
|
-
p.proname AS function_name,
|
|
59
|
-
pg_get_function_identity_arguments(p.oid) AS arguments,
|
|
60
|
-
pg_get_userbyid(p.proowner) AS owner
|
|
61
|
-
FROM pg_proc p
|
|
62
|
-
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
63
|
-
WHERE p.prosecdef
|
|
64
|
-
AND n.nspname IN ('public', 'app')
|
|
65
|
-
AND NOT EXISTS (
|
|
66
|
-
SELECT 1 FROM unnest(coalesce(p.proconfig, ARRAY[]::text[])) AS cfg
|
|
67
|
-
WHERE cfg LIKE 'search\_path=%'
|
|
68
|
-
)
|
|
69
|
-
ORDER BY n.nspname, p.proname;
|
|
70
|
-
|
|
71
|
-
COMMENT ON VIEW public.v_definer_functions_unpinned IS
|
|
72
|
-
'SECURITY DEFINER functions in this database whose search_path is not pinned (157, #283). Zero is the target; a row here is a function that runs as its owner and resolves unqualified names as the caller.';
|
|
73
|
-
|
|
74
|
-
GRANT SELECT ON public.v_definer_functions_unpinned TO service_role;
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
-- ---------------------------------------------------------------------------
|
|
2
|
-
-- 158_the_registry_owns_the_status_vocabulary.sql — the base takes back the one
|
|
3
|
-
-- constraint that forced plugins to write a base table (#283, #274).
|
|
4
|
-
--
|
|
5
|
-
-- ── The thing that was wrong ────────────────────────────────────────────────
|
|
6
|
-
-- A beauty-era migration put `orders_kind_status_valid` on public.orders: a
|
|
7
|
-
-- CHECK whose body is a CASE over `kind`, listing the statuses each kind may
|
|
8
|
-
-- have. Every time a plugin gained a status, the only way to say so was to DROP
|
|
9
|
-
-- and re-ADD a constraint on an archetype the base owns — which plugin-crm did
|
|
10
|
-
-- twice (009 for `archived` on a deal, 010 for the same on a quote), each time
|
|
11
|
-
-- rewriting the branch for every OTHER plugin's kinds in the process.
|
|
12
|
-
--
|
|
13
|
-
-- That is not a plugin behaving badly. It is a base that left no other door.
|
|
14
|
-
-- The rule "a plugin does not restructure a base table" is only fair once the
|
|
15
|
-
-- base provides the mechanism, and since 090 it does: `order_kinds.statuses` is
|
|
16
|
-
-- the per-kind vocabulary, and `trg_orders_kind_guard` already enforces it on
|
|
17
|
-
-- every INSERT and UPDATE. The CHECK was a second opinion about the same fact —
|
|
18
|
-
-- and the more expensive of the two, because widening it needed DDL on a table
|
|
19
|
-
-- other plugins were writing at the time.
|
|
20
|
-
--
|
|
21
|
-
-- ── What this does, in order ───────────────────────────────────────────────
|
|
22
|
-
-- 1. Reads the CHECK, if the pool has one, and folds any status it allowed into
|
|
23
|
-
-- the registry. Nothing loosens and nothing tightens: a vocabulary the pool
|
|
24
|
-
-- accepted yesterday is accepted today, now written where it can be read.
|
|
25
|
-
-- 2. Drops the constraint.
|
|
26
|
-
-- 3. Records the retirement, so the review gate can tell a historical ALTER
|
|
27
|
-
-- from a new one instead of being handed a list of names to ignore.
|
|
28
|
-
--
|
|
29
|
-
-- Pools that never had the constraint — most of them — take only step 3.
|
|
30
|
-
-- ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
33
|
-
-- §1 Fold the CHECK's vocabulary into the registry
|
|
34
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
DO $$
|
|
37
|
-
DECLARE
|
|
38
|
-
v_def text;
|
|
39
|
-
v_branch text;
|
|
40
|
-
v_kind text;
|
|
41
|
-
v_statuses text[];
|
|
42
|
-
v_added integer := 0;
|
|
43
|
-
BEGIN
|
|
44
|
-
IF to_regclass('public.orders') IS NULL OR to_regclass('public.order_kinds') IS NULL THEN
|
|
45
|
-
RETURN;
|
|
46
|
-
END IF;
|
|
47
|
-
|
|
48
|
-
SELECT pg_get_constraintdef(c.oid) INTO v_def
|
|
49
|
-
FROM pg_constraint c
|
|
50
|
-
WHERE c.conname = 'orders_kind_status_valid'
|
|
51
|
-
AND c.conrelid = 'public.orders'::regclass;
|
|
52
|
-
|
|
53
|
-
IF v_def IS NULL THEN
|
|
54
|
-
RAISE NOTICE '158: no orders_kind_status_valid on this pool — nothing to fold';
|
|
55
|
-
RETURN;
|
|
56
|
-
END IF;
|
|
57
|
-
|
|
58
|
-
-- The CHECK's shape is fixed and known: WHEN 'kind' THEN status IN (…). Read
|
|
59
|
-
-- it rather than hard-coding a list, because what matters is what THIS pool
|
|
60
|
-
-- accepts — a pool that ran only some of the widening migrations has a
|
|
61
|
-
-- narrower CHECK than the newest file describes, and copying the file's list
|
|
62
|
-
-- would silently widen it.
|
|
63
|
-
FOR v_branch IN
|
|
64
|
-
SELECT m[1] || '|' || m[2]
|
|
65
|
-
FROM regexp_matches(v_def, $re$WHEN '([a-z_]+)'::text THEN status = ANY \(ARRAY\[([^\]]*)\]\)$re$, 'g') AS m
|
|
66
|
-
LOOP
|
|
67
|
-
v_kind := split_part(v_branch, '|', 1);
|
|
68
|
-
SELECT array_agg(DISTINCT btrim(btrim(s), ''''))
|
|
69
|
-
INTO v_statuses
|
|
70
|
-
FROM unnest(string_to_array(split_part(v_branch, '|', 2), ',')) AS s
|
|
71
|
-
WHERE btrim(s) <> '';
|
|
72
|
-
|
|
73
|
-
-- Fold, never replace: the union of what the CHECK allowed and what the
|
|
74
|
-
-- registry already knew. Either source having a status is enough.
|
|
75
|
-
UPDATE public.order_kinds k
|
|
76
|
-
SET statuses = (
|
|
77
|
-
SELECT array_agg(DISTINCT x)
|
|
78
|
-
FROM unnest(coalesce(k.statuses, ARRAY[]::text[]) || v_statuses) AS x
|
|
79
|
-
)
|
|
80
|
-
WHERE k.kind = v_kind
|
|
81
|
-
AND v_statuses IS NOT NULL
|
|
82
|
-
AND NOT (coalesce(k.statuses, ARRAY[]::text[]) @> v_statuses);
|
|
83
|
-
v_added := v_added + 1;
|
|
84
|
-
END LOOP;
|
|
85
|
-
|
|
86
|
-
RAISE NOTICE '158: folded % kind branch(es) from orders_kind_status_valid into order_kinds.statuses', v_added;
|
|
87
|
-
END $$;
|
|
88
|
-
|
|
89
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
90
|
-
-- §2 Drop it
|
|
91
|
-
--
|
|
92
|
-
-- Safe because 090's trigger has been enforcing the same rule from the registry
|
|
93
|
-
-- since it landed: what goes away is a duplicate, not the enforcement.
|
|
94
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
DO $$
|
|
97
|
-
BEGIN
|
|
98
|
-
IF to_regclass('public.orders') IS NULL THEN RETURN; END IF;
|
|
99
|
-
IF EXISTS (
|
|
100
|
-
SELECT 1 FROM pg_constraint
|
|
101
|
-
WHERE conname = 'orders_kind_status_valid'
|
|
102
|
-
AND conrelid = 'public.orders'::regclass
|
|
103
|
-
) THEN
|
|
104
|
-
ALTER TABLE public.orders DROP CONSTRAINT orders_kind_status_valid;
|
|
105
|
-
RAISE NOTICE '158: dropped orders_kind_status_valid — order_kinds.statuses is the vocabulary now';
|
|
106
|
-
END IF;
|
|
107
|
-
END $$;
|
|
108
|
-
|
|
109
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
110
|
-
-- §3 Record the retirement
|
|
111
|
-
--
|
|
112
|
-
-- 148 gave the base a place to say which unprefixed names it owns, and the
|
|
113
|
-
-- review gate reads it rather than carrying a list of its own. This is the same
|
|
114
|
-
-- move one level down: a constraint the base has taken back, so a plugin's
|
|
115
|
-
-- historical ALTER that names it can be told apart from a new one — by a rule
|
|
116
|
-
-- the base states, not by an entry somebody added to the gate.
|
|
117
|
-
-- ─────────────────────────────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
CREATE TABLE IF NOT EXISTS app.retired_base_constraints (
|
|
120
|
-
constraint_name text PRIMARY KEY,
|
|
121
|
-
table_name text NOT NULL,
|
|
122
|
-
retired_in text NOT NULL,
|
|
123
|
-
reason text NOT NULL,
|
|
124
|
-
created_at timestamptz NOT NULL DEFAULT now()
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
COMMENT ON TABLE app.retired_base_constraints IS
|
|
128
|
-
'Constraints on base tables that the base has withdrawn, and what replaced them (158). A plugin migration that alters one is history; a plugin migration that alters anything else is a violation. The review gate reads this instead of keeping its own list.';
|
|
129
|
-
|
|
130
|
-
ALTER TABLE app.retired_base_constraints ENABLE ROW LEVEL SECURITY;
|
|
131
|
-
DROP POLICY IF EXISTS retired_base_constraints_read ON app.retired_base_constraints;
|
|
132
|
-
CREATE POLICY retired_base_constraints_read ON app.retired_base_constraints
|
|
133
|
-
FOR SELECT TO authenticated, service_role USING (true);
|
|
134
|
-
GRANT SELECT ON app.retired_base_constraints TO authenticated, service_role;
|
|
135
|
-
|
|
136
|
-
INSERT INTO app.retired_base_constraints (constraint_name, table_name, retired_in, reason) VALUES
|
|
137
|
-
('orders_kind_status_valid', 'orders', '158',
|
|
138
|
-
'A CASE-over-kind CHECK duplicating what order_kinds.statuses says and trg_orders_kind_guard has enforced since 090. Widening it needed DDL on a base table, which is why two plugin migrations hold an ALTER TABLE public.orders. The registry is the vocabulary now: a plugin widens its own kind by updating its own row.')
|
|
139
|
-
ON CONFLICT (constraint_name) DO UPDATE
|
|
140
|
-
SET table_name = EXCLUDED.table_name, retired_in = EXCLUDED.retired_in, reason = EXCLUDED.reason;
|
|
141
|
-
|
|
142
|
-
-- The census, for a pool: has anything put it back?
|
|
143
|
-
CREATE OR REPLACE VIEW public.v_retired_constraints_resurrected
|
|
144
|
-
WITH (security_invoker = true) AS
|
|
145
|
-
SELECT r.constraint_name, r.table_name, r.retired_in, r.reason
|
|
146
|
-
FROM app.retired_base_constraints r
|
|
147
|
-
JOIN pg_constraint c ON c.conname = r.constraint_name
|
|
148
|
-
JOIN pg_class t ON t.oid = c.conrelid AND t.relname = r.table_name;
|
|
149
|
-
|
|
150
|
-
COMMENT ON VIEW public.v_retired_constraints_resurrected IS
|
|
151
|
-
'Retired base constraints that exist again in this database (158). Zero is the expected reading; a row means a migration re-created something the base withdrew.';
|
|
152
|
-
|
|
153
|
-
GRANT SELECT ON public.v_retired_constraints_resurrected TO authenticated, service_role;
|