@fayz-ai/db 0.12.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.
Files changed (84) hide show
  1. package/canonical-schema.json +132 -0
  2. package/dist/index.cjs +4 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/schema/spine.d.ts +42 -37
  9. package/dist/schema/spine.d.ts.map +1 -1
  10. package/migrations/000_baseline.sql +29667 -0
  11. package/migrations/001_the_tenant_has_apps.sql +314 -0
  12. package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
  13. package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
  14. package/migrations/004_the_projections_get_a_successor.sql +312 -0
  15. package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
  16. package/migrations/006_the_helpers_read_the_source.sql +153 -0
  17. package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
  18. package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
  19. package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
  20. package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
  21. package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
  22. package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
  23. package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
  24. package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
  25. package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
  26. package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
  27. package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
  28. package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
  29. package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
  30. package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
  31. package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
  32. package/package.json +3 -2
  33. package/migrations/0000_legacy_quarantine.sql +0 -86
  34. package/migrations/0000b_straggler_sweep.sql +0 -37
  35. package/migrations/000_core_v1_convert.sql +0 -343
  36. package/migrations/001_core.sql +0 -372
  37. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  38. package/migrations/003_invitations.sql +0 -17
  39. package/migrations/004_archetypes.sql +0 -343
  40. package/migrations/005_locations_archetype.sql +0 -18
  41. package/migrations/006_archetype_rls.sql +0 -74
  42. package/migrations/007_archetype_grants.sql +0 -4
  43. package/migrations/008_grants.sql +0 -5
  44. package/migrations/009_anon_hardening.sql +0 -19
  45. package/migrations/010_migration_ledger.sql +0 -36
  46. package/migrations/011_anon_write_revoke.sql +0 -12
  47. package/migrations/012_rename_migration_ledger.sql +0 -61
  48. package/migrations/013_sequences_rls.sql +0 -22
  49. package/migrations/014_plan_entitlements.sql +0 -28
  50. package/migrations/015_agent_guard.sql +0 -108
  51. package/migrations/016_agent_guard_actor.sql +0 -102
  52. package/migrations/017_core_addresses.sql +0 -115
  53. package/migrations/018_global_search.sql +0 -482
  54. package/migrations/019_tenant_members_person_id.sql +0 -20
  55. package/migrations/020_fayz_projects.sql +0 -63
  56. package/migrations/021_tenant_roles.sql +0 -74
  57. package/migrations/022_invite_acceptance.sql +0 -91
  58. package/migrations/023_team_visibility.sql +0 -75
  59. package/migrations/024_analytics_engine.sql +0 -317
  60. package/migrations/025_created_by.sql +0 -75
  61. package/migrations/026_audit_trail.sql +0 -73
  62. package/migrations/027_domain_events.sql +0 -266
  63. package/migrations/028_tenant_scoped_token.sql +0 -127
  64. package/migrations/029_connections.sql +0 -186
  65. package/migrations/030_effect_idempotency.sql +0 -159
  66. package/migrations/031_sync_run_message.sql +0 -39
  67. package/migrations/032_connection_secrets.sql +0 -227
  68. package/migrations/033_sync_schedule.sql +0 -651
  69. package/migrations/034_custom_fields.sql +0 -55
  70. package/migrations/035_field_registry.sql +0 -148
  71. package/migrations/036_analytics_run_batch.sql +0 -84
  72. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  73. package/migrations/038_onboarding_responses.sql +0 -103
  74. package/migrations/039_unit_tree.sql +0 -270
  75. package/migrations/040_resource_grants.sql +0 -474
  76. package/migrations/041_scoped_columns.sql +0 -192
  77. package/migrations/042_unit_scope_policies.sql +0 -145
  78. package/migrations/043_view_invoker.sql +0 -81
  79. package/migrations/044_unit_member_facts.sql +0 -47
  80. package/migrations/045_unit_entry.sql +0 -236
  81. package/migrations/046_membership_visible_to_members.sql +0 -85
  82. package/migrations/047_tasks.sql +0 -266
  83. package/migrations/048_every_login_is_a_person.sql +0 -190
  84. package/migrations/049_bookable_people.sql +0 -126
@@ -0,0 +1,314 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 001_the_tenant_has_apps.sql — an account can run more than one application.
3
+ --
4
+ -- Until now the only thing a tenant could HAVE was a (plugin, facet) pair in
5
+ -- app.tenant_plugins. Which PRODUCT a tenant was living in — the restaurant
6
+ -- admin, the shop admin — was never written down: it was inferred from
7
+ -- tenants.vertical_id, a free-text column with no FK and no check, stamped once
8
+ -- by an AFTER INSERT trigger and never revisited. That was survivable while each
9
+ -- product had its own Supabase pool and a merchant was literally two different
10
+ -- auth.users. On one cluster it is not: the same person, the same account and
11
+ -- the same public.products now sit behind two front doors, and nothing in the
12
+ -- database says so.
13
+ --
14
+ -- So the relation gets written down.
15
+ --
16
+ -- app.apps the registry — which applications exist at all
17
+ -- app.tenant_apps the relation — which of them THIS account runs
18
+ --
19
+ -- The switcher in the sidebar reads the second through app_config(). Nothing
20
+ -- here gates data: an app is a lens over the tenant's rows, not a boundary
21
+ -- around them. Two apps on one tenant read the same public.products by design —
22
+ -- that is the whole point, and it is why this is a relation and not a second
23
+ -- tenant.
24
+ --
25
+ -- ── Why app_id has a real FK, when plugin_id does not ──────────────────────
26
+ --
27
+ -- tenant_plugins.plugin_id is text with only a regex, and vertical_id has not
28
+ -- even that. The bill arrived: four vocabularies of "vertical" now disagree
29
+ -- (the column, app.vertical_defaults, the TS union, the onboarding packs), and
30
+ -- four of eight live tenants matched no defaults at all and were seeded with
31
+ -- nothing. ADR 0022 — a registry with a real FOREIGN KEY from day one — is the
32
+ -- rule this file follows so the same drift cannot start again.
33
+ -- ---------------------------------------------------------------------------
34
+
35
+ -- ── §1 the registry ───────────────────────────────────────────────────────
36
+
37
+ CREATE TABLE IF NOT EXISTS app.apps (
38
+ id text PRIMARY KEY CHECK (id ~ '^[a-z][a-z0-9-]*$'),
39
+ name text NOT NULL,
40
+ -- A lucide icon NAME, resolved client-side through getLucideIcon — a curated
41
+ -- registry, not all of lucide. A name outside it falls back to initials
42
+ -- rather than dragging ~500KB of icons into every app that shows a launcher.
43
+ icon text,
44
+ accent_color text CHECK (accent_color IS NULL OR accent_color ~ '^#[0-9a-fA-F]{6}$'),
45
+ -- Canonical origin. Also the allow-list the session handoff checks against:
46
+ -- the switcher will not hand a session to an origin that is not named here.
47
+ url text,
48
+ -- The plugins this app cannot work without. Activating the app activates
49
+ -- these; deactivating it does NOT deactivate them, because another app on the
50
+ -- same tenant may be standing on them.
51
+ requires text[] NOT NULL DEFAULT '{}',
52
+ active boolean NOT NULL DEFAULT true,
53
+ created_at timestamptz NOT NULL DEFAULT now(),
54
+ updated_at timestamptz NOT NULL DEFAULT now()
55
+ );
56
+
57
+ COMMENT ON TABLE app.apps IS
58
+ 'Which applications exist on this cluster (001). The registry the app switcher reads. An app is a LENS over a tenant''s rows — it never scopes them; two apps on one tenant share public.products by design. `requires` names the plugins the app cannot work without; `url` doubles as the allow-list for the session handoff.';
59
+ COMMENT ON COLUMN app.apps.icon IS 'Lucide icon name (e.g. ChefHat). Resolved through getLucideIcon, a curated registry — an unknown name falls back to initials.';
60
+ COMMENT ON COLUMN app.apps.requires IS 'plugin_ids activated when the app is added. Removing the app does NOT remove them: another app may share them.';
61
+
62
+ -- ── §2 the relation ───────────────────────────────────────────────────────
63
+
64
+ CREATE TABLE IF NOT EXISTS app.tenant_apps (
65
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
66
+ app_id text NOT NULL REFERENCES app.apps(id) ON DELETE RESTRICT,
67
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
68
+ source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'onboarding', 'plan', 'default')),
69
+ added_by uuid,
70
+ created_at timestamptz NOT NULL DEFAULT now(),
71
+ updated_at timestamptz NOT NULL DEFAULT now(),
72
+ PRIMARY KEY (tenant_id, app_id)
73
+ );
74
+
75
+ CREATE INDEX IF NOT EXISTS tenant_apps_app_idx ON app.tenant_apps (app_id);
76
+
77
+ COMMENT ON TABLE app.tenant_apps IS
78
+ 'Which applications THIS account runs (001). The app switcher lists the active rows. Disabling a row hides the app; it never touches the tenant''s data, which belongs to the tenant and not to the app that happened to write it.';
79
+
80
+ -- Both tables carry updated_at, so reuse the spine's existing stamp.
81
+ DROP TRIGGER IF EXISTS apps_updated_at ON app.apps;
82
+ CREATE TRIGGER apps_updated_at BEFORE UPDATE ON app.apps
83
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
84
+
85
+ DROP TRIGGER IF EXISTS tenant_apps_updated_at ON app.tenant_apps;
86
+ CREATE TRIGGER tenant_apps_updated_at BEFORE UPDATE ON app.tenant_apps
87
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
88
+
89
+ -- ── §3 RLS ────────────────────────────────────────────────────────────────
90
+ --
91
+ -- Same posture as app.tenant_plugins and app.vertical_defaults: the registry is
92
+ -- world-readable to a signed-in user (it is a catalogue of products, not a
93
+ -- secret), the relation is readable only for the tenants you belong to, and
94
+ -- NEITHER has a write policy — writes go through the SECURITY DEFINER function
95
+ -- in §4, which is where the permission and the plan ceiling are checked.
96
+
97
+ ALTER TABLE app.apps ENABLE ROW LEVEL SECURITY;
98
+ ALTER TABLE app.tenant_apps ENABLE ROW LEVEL SECURITY;
99
+
100
+ DROP POLICY IF EXISTS apps_read ON app.apps;
101
+ CREATE POLICY apps_read ON app.apps FOR SELECT TO authenticated USING (true);
102
+
103
+ DROP POLICY IF EXISTS tenant_apps_read ON app.tenant_apps;
104
+ CREATE POLICY tenant_apps_read ON app.tenant_apps FOR SELECT TO authenticated
105
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
106
+
107
+ GRANT SELECT ON TABLE app.apps TO authenticated;
108
+ GRANT ALL ON TABLE app.apps TO service_role;
109
+ GRANT SELECT ON TABLE app.tenant_apps TO authenticated;
110
+ GRANT ALL ON TABLE app.tenant_apps TO service_role;
111
+
112
+ -- ── §4 the one writer ─────────────────────────────────────────────────────
113
+
114
+ CREATE OR REPLACE FUNCTION public.tenant_app_set(
115
+ p_app text,
116
+ p_status text DEFAULT 'active',
117
+ p_source text DEFAULT 'manual'
118
+ ) RETURNS jsonb
119
+ LANGUAGE plpgsql SECURITY DEFINER
120
+ SET search_path TO ''
121
+ AS $$
122
+ DECLARE
123
+ v_tenant uuid := app.current_tenant_id();
124
+ v_app app.apps%ROWTYPE;
125
+ v_plugin text;
126
+ v_refused text[] := '{}';
127
+ BEGIN
128
+ IF v_tenant IS NULL THEN
129
+ RAISE EXCEPTION 'no tenant in scope' USING ERRCODE = '42501';
130
+ END IF;
131
+ IF NOT app.has_permission('settings.manage') THEN
132
+ RAISE EXCEPTION 'settings.manage is required to change which applications this account runs'
133
+ USING ERRCODE = '42501';
134
+ END IF;
135
+ IF p_status NOT IN ('active', 'disabled') THEN
136
+ RAISE EXCEPTION 'status must be active or disabled' USING ERRCODE = '22023';
137
+ END IF;
138
+
139
+ SELECT * INTO v_app FROM app.apps WHERE id = p_app AND active;
140
+ IF NOT FOUND THEN
141
+ RAISE EXCEPTION 'no such application: %', p_app USING ERRCODE = '22023';
142
+ END IF;
143
+
144
+ INSERT INTO app.tenant_apps (tenant_id, app_id, status, source, added_by)
145
+ VALUES (v_tenant, p_app, p_status, p_source, auth.uid())
146
+ ON CONFLICT (tenant_id, app_id) DO UPDATE
147
+ SET status = EXCLUDED.status,
148
+ source = EXCLUDED.source,
149
+ added_by = EXCLUDED.added_by;
150
+
151
+ -- Adding an app switches on what it cannot work without. Each plugin still
152
+ -- passes the plan ceiling on its own: an app the plan allows may still name a
153
+ -- plugin it does not, and the honest answer is to add the app with that
154
+ -- plugin missing and SAY so, rather than to refuse the app or to smuggle the
155
+ -- plugin past the ceiling.
156
+ --
157
+ -- Removing an app switches OFF nothing. Another app on this tenant may be
158
+ -- standing on the same plugin, and the tenant's rows outlive both.
159
+ IF p_status = 'active' THEN
160
+ FOREACH v_plugin IN ARRAY v_app.requires LOOP
161
+ IF public.plan_permits(v_plugin, '', v_tenant) THEN
162
+ INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source, activated_by)
163
+ VALUES (v_tenant, v_plugin, '', 'active', 'default', auth.uid())
164
+ ON CONFLICT (tenant_id, plugin_id, facet) DO UPDATE
165
+ SET status = 'active';
166
+ ELSE
167
+ v_refused := v_refused || v_plugin;
168
+ END IF;
169
+ END LOOP;
170
+ END IF;
171
+
172
+ RETURN jsonb_build_object(
173
+ 'app', p_app,
174
+ 'status', p_status,
175
+ 'plugins_activated', to_jsonb(v_app.requires),
176
+ 'not_permitted_by_plan', to_jsonb(v_refused)
177
+ );
178
+ END $$;
179
+
180
+ REVOKE ALL ON FUNCTION public.tenant_app_set(text, text, text) FROM PUBLIC;
181
+ GRANT EXECUTE ON FUNCTION public.tenant_app_set(text, text, text) TO authenticated;
182
+
183
+ COMMENT ON FUNCTION public.tenant_app_set(text, text, text) IS
184
+ 'Add or remove an application for the tenant in scope (001). Mirrors tenant_plugin_set: settings.manage required, the plan ceiling checked per required plugin, turning something OFF always allowed. Reports the plugins it could not switch on rather than failing the whole call.';
185
+
186
+ -- ── §5 app_config() learns to answer "which apps" ─────────────────────────
187
+ --
188
+ -- Re-emitted whole rather than patched: this is the one function the shell asks
189
+ -- on boot, and a reader should see the entire answer in one place. The only
190
+ -- change is the 'apps' key.
191
+
192
+ CREATE OR REPLACE FUNCTION public.app_config() RETURNS jsonb
193
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
194
+ SET search_path TO ''
195
+ AS $$
196
+ DECLARE
197
+ v_tenant uuid := app.current_tenant_id();
198
+ BEGIN
199
+ IF v_tenant IS NULL THEN
200
+ -- Fail closed and legibly: a caller with no resolvable tenant gets an empty
201
+ -- configuration, not somebody else's.
202
+ RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb, 'apps', '[]'::jsonb);
203
+ END IF;
204
+
205
+ RETURN jsonb_build_object(
206
+ 'tenant_id', v_tenant,
207
+ 'branding', public.tenant_branding(),
208
+ 'settings', public.tenant_settings(),
209
+ 'plugins', coalesce((
210
+ SELECT jsonb_agg(jsonb_build_object(
211
+ 'plugin', t.plugin_id,
212
+ 'facet', nullif(t.facet, ''),
213
+ 'status', t.status,
214
+ 'source', t.source,
215
+ 'config', t.config,
216
+ 'expires_at', t.expires_at
217
+ ) ORDER BY t.plugin_id, t.facet)
218
+ FROM app.tenant_plugins t
219
+ WHERE t.tenant_id = v_tenant
220
+ AND t.status = 'active'
221
+ AND (t.expires_at IS NULL OR t.expires_at > now())
222
+ ), '[]'::jsonb),
223
+ 'apps', coalesce((
224
+ SELECT jsonb_agg(jsonb_build_object(
225
+ 'id', a.id,
226
+ 'name', a.name,
227
+ 'icon', a.icon,
228
+ 'accent_color', a.accent_color,
229
+ 'url', a.url
230
+ ) ORDER BY a.name)
231
+ FROM app.tenant_apps ta
232
+ JOIN app.apps a ON a.id = ta.app_id
233
+ WHERE ta.tenant_id = v_tenant
234
+ AND ta.status = 'active'
235
+ AND a.active
236
+ ), '[]'::jsonb),
237
+ 'nav', coalesce((
238
+ SELECT jsonb_agg(jsonb_build_object(
239
+ 'key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
240
+ 'section', n.section, 'position', n.position
241
+ ) ORDER BY coalesce(n.position, 999), n.entry_key)
242
+ FROM app.tenant_nav n
243
+ WHERE n.tenant_id = v_tenant
244
+ ), '[]'::jsonb),
245
+ 'permissions', coalesce((
246
+ SELECT jsonb_agg(DISTINCT rp.permission)
247
+ FROM app.memberships m
248
+ JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
249
+ WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.active
250
+ ), '[]'::jsonb),
251
+ 'generated_at', now()
252
+ );
253
+ END $$;
254
+
255
+ -- ── §6 the registry's first rows ──────────────────────────────────────────
256
+ --
257
+ -- `requires` mirrors the signals in packages/admin/src/onboarding/infer-vertical.ts,
258
+ -- which already answers "which vertical is this app" from the plugins the bundle
259
+ -- carries. Same evidence, read in the other direction — a second, disagreeing
260
+ -- list is exactly how the four vertical vocabularies happened.
261
+ --
262
+ -- `url` is the dev origin. A cluster that publishes these apps elsewhere updates
263
+ -- the row; nothing in code hardcodes a port.
264
+
265
+ -- The names are the products' OUTWARD names (ADR: "Control" is the family, not
266
+ -- the app). "Control" is spoken once — in the dropdown's own header — and each
267
+ -- app's rail says only its vertical: Chef, Store, Studio. The switcher's list is
268
+ -- outward, so it carries the full name.
269
+ INSERT INTO app.apps (id, name, icon, accent_color, url, requires) VALUES
270
+ ('resto', 'ChefControl', 'ChefHat', '#DC2626', 'http://localhost:5302', ARRAY['menu', 'financial', 'inventory']),
271
+ ('shop', 'StoreControl', 'ShoppingBag', '#0F9D58', 'http://localhost:5305', ARRAY['shop', 'financial', 'inventory']),
272
+ ('beauty', 'StudioControl', 'Scissors', '#7C3AED', 'http://localhost:5301', ARRAY['agenda', 'crm', 'financial']),
273
+ -- The school product has no agreed outward name yet; the vertical stands in.
274
+ ('school', 'Escola', 'GraduationCap', '#D97706', 'http://localhost:5311', ARRAY['courses', 'agenda', 'financial'])
275
+ ON CONFLICT (id) DO UPDATE
276
+ SET name = EXCLUDED.name, icon = EXCLUDED.icon, accent_color = EXCLUDED.accent_color,
277
+ url = EXCLUDED.url, requires = EXCLUDED.requires;
278
+
279
+ -- ── §7 backfill: what every existing tenant was already running ───────────
280
+ --
281
+ -- Read from vertical_id, which is the only record that exists. Deliberately a
282
+ -- one-shot backfill and NOT a trigger: vertical_id being applied once at INSERT
283
+ -- and never revisited is the defect this file exists to end, and reproducing
284
+ -- that mechanism for apps would reproduce the defect. From here on the writer
285
+ -- is tenant_app_set(), called by onboarding or by a person.
286
+ --
287
+ -- The mapping is wider than app.vertical_defaults' five values because the live
288
+ -- column holds seven, four of which that table has never heard of.
289
+
290
+ INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
291
+ SELECT t.id,
292
+ CASE t.vertical_id
293
+ WHEN 'restaurant' THEN 'resto'
294
+ WHEN 'food' THEN 'resto'
295
+ WHEN 'commerce' THEN 'shop'
296
+ WHEN 'ecommerce' THEN 'shop'
297
+ WHEN 'retail' THEN 'shop'
298
+ WHEN 'salon' THEN 'beauty'
299
+ WHEN 'clinic' THEN 'beauty'
300
+ WHEN 'beauty' THEN 'beauty'
301
+ WHEN 'school' THEN 'school'
302
+ WHEN 'education' THEN 'school'
303
+ END,
304
+ 'active', 'default'
305
+ FROM public.tenants t
306
+ WHERE t.vertical_id IS NOT NULL
307
+ AND CASE t.vertical_id
308
+ WHEN 'restaurant' THEN 'resto' WHEN 'food' THEN 'resto'
309
+ WHEN 'commerce' THEN 'shop' WHEN 'ecommerce' THEN 'shop'
310
+ WHEN 'retail' THEN 'shop' WHEN 'salon' THEN 'beauty'
311
+ WHEN 'clinic' THEN 'beauty' WHEN 'beauty' THEN 'beauty'
312
+ WHEN 'school' THEN 'school' WHEN 'education' THEN 'school'
313
+ END IS NOT NULL
314
+ ON CONFLICT (tenant_id, app_id) DO NOTHING;
@@ -0,0 +1,129 @@
1
+ -- ============================================================================
2
+ -- 002_the_spine_drops_the_vendor_and_the_dead — fayz_search_sources and
3
+ -- the superseded grant table leave (#352).
4
+ --
5
+ -- ── fayz_search_sources ────────────────────────────────────────────────────
6
+ -- 137 took the vendor out of the spine's names: a table should say what it
7
+ -- holds, not who built it. `fayz_search_sources` became `search_sources` and
8
+ -- 141 left the old name answering until callers moved. One caller never did,
9
+ -- and it is the spine's own: public.fayz_search_reindex(). Redefined below
10
+ -- from 000_baseline's body with that one name changed, then the view goes.
11
+ --
12
+ -- (The function keeps its own vendor name. 137 renamed three objects and this
13
+ -- was not one of them; renaming it here would be a second decision wearing
14
+ -- this file's clothes, and it needs its own with its callers counted.)
15
+ --
16
+ -- ── public.resource_grants, and the two functions that read it ─────────────
17
+ -- #242 superseded it with app.resource_grants, 064 carried the rows across,
18
+ -- and its own COMMENT has said since then that "it no longer governs any
19
+ -- policy". Measured, and that is exactly true: ZERO policies reference either
20
+ -- function that reads it, no view does, and no other function does. What is
21
+ -- left is two SECURITY DEFINER functions selecting from an empty table that
22
+ -- nothing calls — dead code over a dead table, which is the most expensive
23
+ -- kind to leave, because the next person to find it has to prove it is dead
24
+ -- all over again.
25
+ --
26
+ -- Dropped together and in order: a SQL (not plpgsql) function binds its names
27
+ -- at creation, so the table cannot go first.
28
+ --
29
+ -- ── addresses gets the FK it always should have had ───────────────────────
30
+ -- public.addresses is the address model (017, 113, 142) and carries no foreign
31
+ -- key at all. For the OWNER that is deliberate and unavoidable — owner_type is
32
+ -- polymorphic across six kinds and no single FK can express it. For tenant_id
33
+ -- it is neither: every other core table has it, and without it a deleted
34
+ -- tenant leaves its addresses behind. Added guarded, so a pool that somehow
35
+ -- has orphans is told rather than failed.
36
+ -- ============================================================================
37
+
38
+ CREATE OR REPLACE FUNCTION public.fayz_search_reindex() RETURNS integer
39
+ LANGUAGE plpgsql
40
+ SET search_path TO 'public', 'extensions', 'pg_temp'
41
+ AS $$
42
+ DECLARE
43
+ v_src record;
44
+ v_rel text;
45
+ v_name text;
46
+ v_built integer := 0;
47
+ v_has_trgm boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm');
48
+ v_has_gin boolean := EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'btree_gin');
49
+ BEGIN
50
+ IF NOT v_has_trgm THEN
51
+ RAISE NOTICE 'fayz search: pg_trgm missing — no index built, search falls back to sequential LIKE';
52
+ RETURN 0;
53
+ END IF;
54
+
55
+ FOR v_src IN
56
+ SELECT * FROM public.search_sources WHERE enabled ORDER BY entity_key
57
+ LOOP
58
+ v_rel := to_regclass(v_src.relation)::text;
59
+ CONTINUE WHEN v_rel IS NULL;
60
+ -- A view cannot be indexed; only its base tables can.
61
+ CONTINUE WHEN (SELECT c.relkind FROM pg_class c WHERE c.oid = to_regclass(v_src.relation))
62
+ NOT IN ('r', 'p');
63
+
64
+ -- Name derived from (relation, expression): two sources over the same
65
+ -- columns share one index instead of building it twice.
66
+ v_name := 'fayz_search_' || substr(md5(v_rel || '|' || v_src.haystack_expr), 1, 20);
67
+
68
+ BEGIN
69
+ IF v_has_gin THEN
70
+ -- Composite: the tenant equality is resolved INSIDE the index, so a
71
+ -- 500-tenant pool does not trigram-scan its neighbours' rows.
72
+ EXECUTE format(
73
+ 'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (%I, public.fayz_norm(%s) gin_trgm_ops)',
74
+ v_name, v_rel, v_src.tenant_column, v_src.haystack_expr);
75
+ ELSE
76
+ EXECUTE format(
77
+ 'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (public.fayz_norm(%s) gin_trgm_ops)',
78
+ v_name, v_rel, v_src.haystack_expr);
79
+ END IF;
80
+ v_built := v_built + 1;
81
+ EXCEPTION WHEN OTHERS THEN
82
+ -- Most likely cause: btree_gin present but with no GIN opclass for the
83
+ -- tenant column's type. A trigram-only index still serves every query;
84
+ -- the tenant equality just becomes a recheck.
85
+ BEGIN
86
+ EXECUTE format(
87
+ 'CREATE INDEX IF NOT EXISTS %I ON %s USING gin (public.fayz_norm(%s) gin_trgm_ops)',
88
+ v_name, v_rel, v_src.haystack_expr);
89
+ v_built := v_built + 1;
90
+ EXCEPTION WHEN OTHERS THEN
91
+ RAISE NOTICE 'fayz search: could not index % (%): %', v_src.entity_key, v_rel, SQLERRM;
92
+ END;
93
+ END;
94
+ END LOOP;
95
+
96
+ RETURN v_built;
97
+ END $$;
98
+
99
+ DROP VIEW IF EXISTS public.fayz_search_sources;
100
+
101
+ DROP FUNCTION IF EXISTS public.granted_record_ids(text);
102
+ DROP FUNCTION IF EXISTS public.restricted_record_ids(text);
103
+ DROP TABLE IF EXISTS public.resource_grants;
104
+
105
+ DO $addr$
106
+ DECLARE v_orphans bigint;
107
+ BEGIN
108
+ IF to_regclass('public.addresses') IS NULL THEN RETURN; END IF;
109
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'addresses_tenant_id_fkey') THEN RETURN; END IF;
110
+
111
+ SELECT count(*) INTO v_orphans
112
+ FROM public.addresses a
113
+ WHERE NOT EXISTS (SELECT 1 FROM public.tenants t WHERE t.id = a.tenant_id);
114
+
115
+ IF v_orphans > 0 THEN
116
+ RAISE NOTICE 'addresses: % row(s) name a tenant that does not exist — FK not added, clean them first', v_orphans;
117
+ RETURN;
118
+ END IF;
119
+
120
+ ALTER TABLE public.addresses
121
+ ADD CONSTRAINT addresses_tenant_id_fkey
122
+ FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
123
+ END $addr$;
124
+
125
+ -- ── fayz_global_search, the third of 137's renames ─────────────────────────
126
+ -- Same shim, different shape: a FUNCTION forwarding to public.global_search
127
+ -- rather than a view forwarding to a table. Same verdict — no caller in the
128
+ -- code, no view, no other function.
129
+ DROP FUNCTION IF EXISTS public.fayz_global_search(text, uuid, text[], integer, integer);
@@ -0,0 +1,135 @@
1
+ -- ============================================================================
2
+ -- 003_the_role_rpcs_finish_and_the_mirrors_go — #136's last four tables, and
3
+ -- the two RPCs that were missing for them to leave (#352).
4
+ --
5
+ -- #136 moved authorization into app.* and left four write-through mirrors in
6
+ -- public so @fayz-ai/admin's permission matrix kept working. Each carries its
7
+ -- own COMMENT saying it is deprecated and naming what replaces it, and every
8
+ -- one of those comments has ended with "Drop in Phase 1" since Phase 0.
9
+ --
10
+ -- public.permissions -> public.v_authz_permissions
11
+ -- public.role_permissions -> always empty; app.role_permissions is per
12
+ -- tenant and this global table never held a
13
+ -- row. Measured: zero, in every pool.
14
+ -- public.tenant_roles -> public.v_authz_roles
15
+ -- public.tenant_role_overrides -> public.v_authz_role_permissions
16
+ --
17
+ -- WHY THEY COULD NOT LEAVE. The read side was ready — the v_authz_* views have
18
+ -- existed all along, and they ARE security_invoker, so RLS on app.* still
19
+ -- applies to whoever selects them. The WRITE side was not: role_create_from_
20
+ -- template and role_set_permissions cover creating a role and setting its
21
+ -- grants, and nothing covered RENAMING or DELETING one. The admin did both by
22
+ -- writing public.tenant_roles and letting a trigger redirect it. So the mirror
23
+ -- was not kept for the matrix; it was kept for two verbs nobody had written.
24
+ --
25
+ -- They are written here, to the same shape as the two that exist: SECURITY
26
+ -- DEFINER with an empty search_path, the tenant from the session rather than a
27
+ -- parameter, roles.manage required, and a system role refused — with delete
28
+ -- refusing on top of that any role somebody is still assigned, because taking
29
+ -- a member's role away as a side effect of tidying is not a rename.
30
+ -- ============================================================================
31
+
32
+ CREATE OR REPLACE FUNCTION public.role_rename(p_role_id uuid, p_name text, p_description text DEFAULT NULL)
33
+ RETURNS uuid
34
+ LANGUAGE plpgsql
35
+ SECURITY DEFINER
36
+ SET search_path TO ''
37
+ AS $function$
38
+ DECLARE
39
+ v_tenant uuid := app.current_tenant_id();
40
+ v_role app.roles%ROWTYPE;
41
+ BEGIN
42
+ IF auth.uid() IS NULL OR v_tenant IS NULL THEN
43
+ RAISE EXCEPTION 'role_rename: no active tenant for this session' USING ERRCODE = '42501';
44
+ END IF;
45
+ IF NOT app.has_permission('roles.manage') THEN
46
+ RAISE EXCEPTION 'role_rename: roles.manage is required' USING ERRCODE = '42501';
47
+ END IF;
48
+
49
+ SELECT * INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.id = p_role_id;
50
+ IF NOT FOUND THEN
51
+ RAISE EXCEPTION 'role_rename: role not found in the active tenant' USING ERRCODE = 'P0002';
52
+ END IF;
53
+ -- A system role's name is the platform's, not the tenant's: it is what the
54
+ -- permission templates and every vertical's seed refer to.
55
+ IF v_role.is_system THEN
56
+ RAISE EXCEPTION 'role_rename: a system role cannot be renamed' USING ERRCODE = '42501';
57
+ END IF;
58
+ IF p_name IS NOT NULL AND btrim(p_name) = '' THEN
59
+ RAISE EXCEPTION 'role_rename: a name cannot be blank' USING ERRCODE = '22023';
60
+ END IF;
61
+
62
+ UPDATE app.roles
63
+ SET name = coalesce(btrim(p_name), name),
64
+ -- NULL description is "clear it", which is why this is not coalesce:
65
+ -- the caller passing NULL for a field it did not touch would be the
66
+ -- bug, and the admin sends the field only when the form changed it.
67
+ description = p_description,
68
+ updated_at = now()
69
+ WHERE tenant_id = v_tenant AND id = p_role_id;
70
+
71
+ RETURN p_role_id;
72
+ END $function$;
73
+
74
+ REVOKE ALL ON FUNCTION public.role_rename(uuid, text, text) FROM PUBLIC;
75
+ GRANT EXECUTE ON FUNCTION public.role_rename(uuid, text, text) TO authenticated, service_role;
76
+
77
+ COMMENT ON FUNCTION public.role_rename(uuid, text, text) IS
78
+ 'Rename a tenant-created role (PRD 01 / #136). System roles are refused: their names belong to the templates. Pass p_description explicitly — NULL clears it.';
79
+
80
+ CREATE OR REPLACE FUNCTION public.role_delete(p_role_id uuid)
81
+ RETURNS void
82
+ LANGUAGE plpgsql
83
+ SECURITY DEFINER
84
+ SET search_path TO ''
85
+ AS $function$
86
+ DECLARE
87
+ v_tenant uuid := app.current_tenant_id();
88
+ v_role app.roles%ROWTYPE;
89
+ v_holders integer;
90
+ BEGIN
91
+ IF auth.uid() IS NULL OR v_tenant IS NULL THEN
92
+ RAISE EXCEPTION 'role_delete: no active tenant for this session' USING ERRCODE = '42501';
93
+ END IF;
94
+ IF NOT app.has_permission('roles.manage') THEN
95
+ RAISE EXCEPTION 'role_delete: roles.manage is required' USING ERRCODE = '42501';
96
+ END IF;
97
+
98
+ SELECT * INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.id = p_role_id;
99
+ IF NOT FOUND THEN
100
+ RAISE EXCEPTION 'role_delete: role not found in the active tenant' USING ERRCODE = 'P0002';
101
+ END IF;
102
+ IF v_role.is_system THEN
103
+ RAISE EXCEPTION 'role_delete: a system role cannot be deleted' USING ERRCODE = '42501';
104
+ END IF;
105
+
106
+ -- Deleting a role somebody holds silently demotes them. Refuse and say how
107
+ -- many, so the caller reassigns first: an authorization change should never
108
+ -- be a side effect of tidying up a list.
109
+ SELECT count(*) INTO v_holders
110
+ FROM app.memberships m
111
+ WHERE m.tenant_id = v_tenant AND m.role_id = p_role_id AND m.active;
112
+ IF v_holders > 0 THEN
113
+ RAISE EXCEPTION 'role_delete: % member(s) still hold this role — reassign them first', v_holders
114
+ USING ERRCODE = '23503';
115
+ END IF;
116
+
117
+ DELETE FROM app.role_permissions WHERE tenant_id = v_tenant AND role_id = p_role_id;
118
+ DELETE FROM app.roles WHERE tenant_id = v_tenant AND id = p_role_id;
119
+ END $function$;
120
+
121
+ REVOKE ALL ON FUNCTION public.role_delete(uuid) FROM PUBLIC;
122
+ GRANT EXECUTE ON FUNCTION public.role_delete(uuid) TO authenticated, service_role;
123
+
124
+ COMMENT ON FUNCTION public.role_delete(uuid) IS
125
+ 'Delete a tenant-created role and its grants (PRD 01 / #136). Refuses a system role, and refuses any role an active member still holds.';
126
+
127
+ -- ── the four mirrors, now that the verbs exist ──────────────────────────────
128
+ -- No view depends on any of them (checked), the triggers that redirected their
129
+ -- writes are dropped with the tables they sit on, and every function that
130
+ -- names them is plpgsql, which resolves names when it runs rather than when it
131
+ -- is created.
132
+ DROP TABLE IF EXISTS public.tenant_role_overrides;
133
+ DROP TABLE IF EXISTS public.tenant_roles;
134
+ DROP TABLE IF EXISTS public.role_permissions;
135
+ DROP TABLE IF EXISTS public.permissions;