feat(fase-12): section visibility model — feature_flags + section_enabled()
Adds JSONB `feature_flags` columns on public.users and public.collectives
(default '{}') plus two SQL helpers:
* public.known_sections() — IMMUTABLE, returns the canonical 4 sections
(lists, tasks, notes, search). Adding a section is a fresh migration.
* public.section_enabled(section, user, collective) — STABLE, returns
boolean. Precedence is COLLECTIVE > USER > default TRUE, evaluated as
a single COALESCE so Fase 13 can prepend a server layer as a one-line
diff. Unknown section keys default TRUE (forward-compat).
No new RLS policy: writes to feature_flags piggyback on users_update_own
and collectives_update (migration 003). 16 new pgTAP assertions verify
column shape, function signatures, volatility, and precedence semantics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ export interface Database {
|
|||||||
avatar_emoji: string | null;
|
avatar_emoji: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
theme: ThemeMode;
|
theme: ThemeMode;
|
||||||
|
feature_flags: Json;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
@@ -45,6 +46,7 @@ export interface Database {
|
|||||||
avatar_emoji?: string | null;
|
avatar_emoji?: string | null;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
theme?: ThemeMode;
|
theme?: ThemeMode;
|
||||||
|
feature_flags?: Json;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
@@ -57,6 +59,7 @@ export interface Database {
|
|||||||
avatar_emoji?: string | null;
|
avatar_emoji?: string | null;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
theme?: ThemeMode;
|
theme?: ThemeMode;
|
||||||
|
feature_flags?: Json;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
@@ -68,6 +71,7 @@ export interface Database {
|
|||||||
name: string;
|
name: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
|
feature_flags: Json;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
@@ -75,6 +79,7 @@ export interface Database {
|
|||||||
name: string;
|
name: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
|
feature_flags?: Json;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
@@ -82,6 +87,7 @@ export interface Database {
|
|||||||
name?: string;
|
name?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
|
feature_flags?: Json;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
Relationships: [
|
Relationships: [
|
||||||
@@ -485,6 +491,14 @@ export interface Database {
|
|||||||
Args: { p_item_id: string };
|
Args: { p_item_id: string };
|
||||||
Returns: string;
|
Returns: string;
|
||||||
};
|
};
|
||||||
|
known_sections: {
|
||||||
|
Args: Record<string, never>;
|
||||||
|
Returns: string[];
|
||||||
|
};
|
||||||
|
section_enabled: {
|
||||||
|
Args: { p_section: string; p_user: string; p_collective: string };
|
||||||
|
Returns: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
Enums: {
|
Enums: {
|
||||||
language_code: LanguageCode;
|
language_code: LanguageCode;
|
||||||
|
|||||||
97
supabase/migrations/023_section_visibility.sql
Normal file
97
supabase/migrations/023_section_visibility.sql
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
-- Migration 023: section visibility / feature flags (Fase 12)
|
||||||
|
--
|
||||||
|
-- Lets each user hide top-level sections they don't use, and lets admins of a
|
||||||
|
-- collective hide them for every member. The control surface is intentionally
|
||||||
|
-- typed-rigid via `public.known_sections()` instead of being a free-form bag
|
||||||
|
-- of strings — adding a new section requires a migration (which is the same
|
||||||
|
-- diff that adds the section's routes anyway, so no extra friction).
|
||||||
|
--
|
||||||
|
-- ── Precedence ───────────────────────────────────────────────────────────────
|
||||||
|
-- Effective layers, MOST restrictive wins:
|
||||||
|
-- 1. (Fase 13) server — server_settings.default_sections jsonb
|
||||||
|
-- 2. collective — collectives.feature_flags jsonb (admin override)
|
||||||
|
-- 3. user — users.feature_flags jsonb (per-user override)
|
||||||
|
-- 4. default — true (ON)
|
||||||
|
-- Fase 12 implements layers 2–4. Fase 13 will extend `public.section_enabled()`
|
||||||
|
-- with a server layer above layer 2. The function body below is structured as
|
||||||
|
-- a single `coalesce(...)` so that adding the server layer is a one-line diff
|
||||||
|
-- (prepend the new SELECT to the coalesce) — no callers change shape.
|
||||||
|
--
|
||||||
|
-- ── Data shape ───────────────────────────────────────────────────────────────
|
||||||
|
-- `feature_flags jsonb` (object) where each key is a section name (matching
|
||||||
|
-- `known_sections()`) and the value is the boolean override. Missing key →
|
||||||
|
-- "no opinion" → defer to the next layer. Default for both columns is `{}`
|
||||||
|
-- so existing rows opt in to nothing and behave exactly as before.
|
||||||
|
--
|
||||||
|
-- RLS: write-paths on `users.feature_flags` and `collectives.feature_flags`
|
||||||
|
-- piggyback on the existing `users_update_own` and `collectives_update`
|
||||||
|
-- policies (migration 003). No new policy is required — the JSONB column is
|
||||||
|
-- covered by the row-level UPDATE policy automatically.
|
||||||
|
|
||||||
|
ALTER TABLE public.users
|
||||||
|
ADD COLUMN feature_flags jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE public.collectives
|
||||||
|
ADD COLUMN feature_flags jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.users.feature_flags IS
|
||||||
|
'Per-user section visibility overrides. Keys must be in public.known_sections().';
|
||||||
|
COMMENT ON COLUMN public.collectives.feature_flags IS
|
||||||
|
'Per-collective section visibility overrides set by an admin. Outranks the user layer.';
|
||||||
|
|
||||||
|
-- ── known_sections() ────────────────────────────────────────────────────────
|
||||||
|
-- IMMUTABLE: the result is a compile-time literal of the source rows in this
|
||||||
|
-- migration. Adding/removing a section is a fresh migration, not a runtime
|
||||||
|
-- change, so it's safe to inline as IMMUTABLE.
|
||||||
|
CREATE OR REPLACE FUNCTION public.known_sections()
|
||||||
|
RETURNS text[]
|
||||||
|
LANGUAGE sql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $$
|
||||||
|
SELECT ARRAY['lists', 'tasks', 'notes', 'search']::text[]
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.known_sections() IS
|
||||||
|
'Canonical list of top-level sections that participate in feature_flags. '
|
||||||
|
'Each new section requires a migration that updates this function + a '
|
||||||
|
'toggle row in /settings + /collective/manage.';
|
||||||
|
|
||||||
|
-- ── section_enabled() ───────────────────────────────────────────────────────
|
||||||
|
-- Resolves the effective ON/OFF state for one section, in one statement, with
|
||||||
|
-- the layered precedence documented at the top of this file.
|
||||||
|
--
|
||||||
|
-- The `coalesce(NULL, NULL, default)` pattern relies on `(jsonb ->> key)`
|
||||||
|
-- returning NULL when the key is missing — so a layer with "no opinion"
|
||||||
|
-- transparently falls through. An unknown section key returns NULL from every
|
||||||
|
-- layer and the trailing `true` wins, matching the documented default-ON
|
||||||
|
-- behaviour (a new feature is visible by default until someone opts out).
|
||||||
|
CREATE OR REPLACE FUNCTION public.section_enabled(
|
||||||
|
p_section text,
|
||||||
|
p_user uuid,
|
||||||
|
p_collective uuid
|
||||||
|
)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE(
|
||||||
|
-- Fase 13 will prepend a server layer here:
|
||||||
|
-- (SELECT (value ->> p_section)::boolean
|
||||||
|
-- FROM public.server_settings
|
||||||
|
-- WHERE key = 'default_sections'),
|
||||||
|
(SELECT (feature_flags ->> p_section)::boolean
|
||||||
|
FROM public.collectives
|
||||||
|
WHERE id = p_collective),
|
||||||
|
(SELECT (feature_flags ->> p_section)::boolean
|
||||||
|
FROM public.users
|
||||||
|
WHERE id = p_user),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.section_enabled(text, uuid, uuid) IS
|
||||||
|
'Effective ON/OFF of a top-level section for (user, collective). '
|
||||||
|
'Precedence: collective override > user override > default true. '
|
||||||
|
'Fase 13 will prepend a server-settings layer above the collective.';
|
||||||
162
supabase/tests/015_section_visibility.sql
Normal file
162
supabase/tests/015_section_visibility.sql
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
-- pgTAP: section visibility (feature_flags) — Fase 12.1
|
||||||
|
-- Run with: psql -U postgres -d postgres -f supabase/tests/015_section_visibility.sql
|
||||||
|
--
|
||||||
|
-- Verifies:
|
||||||
|
-- * `users.feature_flags` and `collectives.feature_flags` JSONB columns exist
|
||||||
|
-- with NOT NULL + default '{}'.
|
||||||
|
-- * `public.known_sections()` is IMMUTABLE and returns the canonical 4 sections.
|
||||||
|
-- * `public.section_enabled(section, user, collective)` is STABLE, returns
|
||||||
|
-- boolean, and applies the documented precedence:
|
||||||
|
-- collective override > user override > default ON.
|
||||||
|
-- The forward-compat server layer (Fase 13) adds another layer ABOVE the
|
||||||
|
-- collective one; nothing here should make that diff awkward.
|
||||||
|
--
|
||||||
|
-- These pgTAP assertions run as postgres which bypasses RLS — the RLS write
|
||||||
|
-- path for `users.feature_flags` / `collectives.feature_flags` is covered by
|
||||||
|
-- the Vitest integration suite (tests/section-visibility.test.ts).
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
SELECT plan(16);
|
||||||
|
|
||||||
|
-- ── Schema invariants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SELECT has_column(
|
||||||
|
'public', 'users', 'feature_flags',
|
||||||
|
'SV-T01: public.users has feature_flags column'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT col_type_is(
|
||||||
|
'public', 'users', 'feature_flags', 'jsonb',
|
||||||
|
'SV-T02: public.users.feature_flags is jsonb'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT col_not_null(
|
||||||
|
'public', 'users', 'feature_flags',
|
||||||
|
'SV-T03: public.users.feature_flags is NOT NULL'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT has_column(
|
||||||
|
'public', 'collectives', 'feature_flags',
|
||||||
|
'SV-T04: public.collectives has feature_flags column'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT col_type_is(
|
||||||
|
'public', 'collectives', 'feature_flags', 'jsonb',
|
||||||
|
'SV-T05: public.collectives.feature_flags is jsonb'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT col_not_null(
|
||||||
|
'public', 'collectives', 'feature_flags',
|
||||||
|
'SV-T06: public.collectives.feature_flags is NOT NULL'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── known_sections() ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SELECT has_function(
|
||||||
|
'public', 'known_sections',
|
||||||
|
'SV-T07: public.known_sections() exists'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT volatility_is(
|
||||||
|
'public', 'known_sections', ARRAY[]::text[], 'immutable',
|
||||||
|
'SV-T08: known_sections() is IMMUTABLE'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT results_eq(
|
||||||
|
$$ SELECT public.known_sections() $$,
|
||||||
|
$$ VALUES (ARRAY['lists','tasks','notes','search']) $$,
|
||||||
|
'SV-T09: known_sections() returns the canonical 4 sections in order'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── section_enabled() ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SELECT has_function(
|
||||||
|
'public', 'section_enabled', ARRAY['text', 'uuid', 'uuid'],
|
||||||
|
'SV-T10: public.section_enabled(text, uuid, uuid) exists'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT function_returns(
|
||||||
|
'public', 'section_enabled', ARRAY['text', 'uuid', 'uuid'], 'boolean',
|
||||||
|
'SV-T11: section_enabled() returns boolean'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT volatility_is(
|
||||||
|
'public', 'section_enabled', ARRAY['text', 'uuid', 'uuid'], 'stable',
|
||||||
|
'SV-T12: section_enabled() is STABLE (planner-cacheable in a statement)'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Precedence semantics ────────────────────────────────────────────────────
|
||||||
|
-- Uses Ana (seed admin) + the seed collective. Reset both flag columns to {}
|
||||||
|
-- at the top of each assertion so the previous one doesn't leak.
|
||||||
|
|
||||||
|
UPDATE public.users
|
||||||
|
SET feature_flags = '{}'::jsonb
|
||||||
|
WHERE id = '11111111-1111-1111-1111-111111111111';
|
||||||
|
UPDATE public.collectives
|
||||||
|
SET feature_flags = '{}'::jsonb
|
||||||
|
WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||||
|
|
||||||
|
-- SV-T13: default ON — empty flags on both rows → tasks enabled.
|
||||||
|
SELECT is(
|
||||||
|
public.section_enabled(
|
||||||
|
'tasks',
|
||||||
|
'11111111-1111-1111-1111-111111111111',
|
||||||
|
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
'SV-T13: with empty flag JSON on both rows the section defaults to ON'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SV-T14: user override OFF wins when collective has no opinion.
|
||||||
|
UPDATE public.users
|
||||||
|
SET feature_flags = '{"tasks": false}'::jsonb
|
||||||
|
WHERE id = '11111111-1111-1111-1111-111111111111';
|
||||||
|
SELECT is(
|
||||||
|
public.section_enabled(
|
||||||
|
'tasks',
|
||||||
|
'11111111-1111-1111-1111-111111111111',
|
||||||
|
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
'SV-T14: user OFF + collective absent → OFF'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SV-T15: collective override OFF wins even when user explicitly enables.
|
||||||
|
UPDATE public.users
|
||||||
|
SET feature_flags = '{"tasks": true}'::jsonb
|
||||||
|
WHERE id = '11111111-1111-1111-1111-111111111111';
|
||||||
|
UPDATE public.collectives
|
||||||
|
SET feature_flags = '{"tasks": false}'::jsonb
|
||||||
|
WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||||
|
SELECT is(
|
||||||
|
public.section_enabled(
|
||||||
|
'tasks',
|
||||||
|
'11111111-1111-1111-1111-111111111111',
|
||||||
|
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
'SV-T15: collective OFF beats user ON (collective is the higher layer)'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SV-T16: unknown sections default ON (forward-compat — new code can call
|
||||||
|
-- section_enabled() before a migration adds the key to known_sections()).
|
||||||
|
UPDATE public.users
|
||||||
|
SET feature_flags = '{}'::jsonb
|
||||||
|
WHERE id = '11111111-1111-1111-1111-111111111111';
|
||||||
|
UPDATE public.collectives
|
||||||
|
SET feature_flags = '{}'::jsonb
|
||||||
|
WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||||
|
SELECT is(
|
||||||
|
public.section_enabled(
|
||||||
|
'foo-unknown-section',
|
||||||
|
'11111111-1111-1111-1111-111111111111',
|
||||||
|
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
'SV-T16: an unknown section key returns true (default ON, no migration race)'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT * FROM finish();
|
||||||
|
ROLLBACK;
|
||||||
Reference in New Issue
Block a user