Files
wursor/supabase/migrations/014_design_languages.sql
T
2026-05-06 22:11:40 +01:00

224 lines
8.6 KiB
PL/PgSQL

-- ── Migration 014: design_languages & design_language_versions ────────────────
-- spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.10 "DB Schema"
--
-- Stores the active Design Language (token file) for each workspace and
-- maintains a versioned history so designers can roll back to any prior upload.
--
-- Tables:
-- design_languages — one row per workspace (current active version)
-- design_language_versions — append-only version history (last 10 kept by trigger)
--
-- Identity note:
-- team_members.email is the authoritative user identifier (migration 015
-- renames user_id → email). RLS policies here use auth.jwt() ->> 'email'
-- to match the Clerk JWT template that injects the user's primary email as
-- the `email` claim.
--
-- Security:
-- RLS enabled on both tables; users can only access their own workspace rows.
-- The prune trigger runs as the table owner (SECURITY DEFINER) so it can
-- delete old version rows regardless of the invoking user's RLS policies.
-- ── design_languages ─────────────────────────────────────────────────────────
-- One row per workspace — UPSERT on every upload to keep this table small.
CREATE TABLE IF NOT EXISTS design_languages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL UNIQUE,
name text NOT NULL DEFAULT 'Design Language',
-- Full raw token file JSON as uploaded (before normalisation).
raw_json jsonb NOT NULL,
-- Normalised DesignToken[] flat array (produced by the Phase 6 parser).
normalized jsonb NOT NULL,
-- Detected source format: 'dtcg' | 'style-dictionary' | 'flat-css-vars'
source_format text NOT NULL,
-- Number of tokens in the normalised array (for quick display).
token_count integer NOT NULL DEFAULT 0,
-- Monotonically increasing version counter — starts at 1.
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Enforce allowed source_format values.
ALTER TABLE design_languages
ADD CONSTRAINT design_languages_source_format_check
CHECK (source_format IN ('dtcg', 'style-dictionary', 'flat-css-vars'));
-- Index for workspace lookups (already implicit from UNIQUE constraint, but
-- explicit for clarity in query plans).
CREATE INDEX IF NOT EXISTS design_languages_workspace_id_idx
ON design_languages (workspace_id);
-- ── design_language_versions ──────────────────────────────────────────────────
-- Append-only history; pruned to last 10 entries per design_language by trigger.
CREATE TABLE IF NOT EXISTS design_language_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
design_language_id uuid NOT NULL REFERENCES design_languages(id) ON DELETE CASCADE,
version integer NOT NULL,
raw_json jsonb NOT NULL,
normalized jsonb NOT NULL,
source_format text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (design_language_id, version)
);
ALTER TABLE design_language_versions
ADD CONSTRAINT design_language_versions_source_format_check
CHECK (source_format IN ('dtcg', 'style-dictionary', 'flat-css-vars'));
CREATE INDEX IF NOT EXISTS design_language_versions_dl_id_idx
ON design_language_versions (design_language_id);
-- ── Prune trigger ─────────────────────────────────────────────────────────────
-- After every INSERT into design_language_versions, delete all rows for the
-- same design_language_id except the 10 most-recent (by version DESC).
-- Keeps storage bounded without requiring a separate cron job.
-- Runs as SECURITY DEFINER so it can bypass RLS on the versions table.
CREATE OR REPLACE FUNCTION prune_design_language_versions()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
DELETE FROM design_language_versions
WHERE design_language_id = NEW.design_language_id
AND id NOT IN (
SELECT id
FROM design_language_versions
WHERE design_language_id = NEW.design_language_id
ORDER BY version DESC
LIMIT 10
);
RETURN NULL; -- AFTER trigger; return value is ignored
END;
$$;
CREATE TRIGGER prune_design_language_versions_trigger
AFTER INSERT ON design_language_versions
FOR EACH ROW
EXECUTE FUNCTION prune_design_language_versions();
-- ── updated_at trigger ────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'design_languages_set_updated_at'
AND tgrelid = 'design_languages'::regclass
) THEN
CREATE TRIGGER design_languages_set_updated_at
BEFORE UPDATE ON design_languages
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
END IF;
END;
$$;
-- ── Row Level Security ────────────────────────────────────────────────────────
-- API routes use the service-role key (bypasses RLS entirely).
-- These policies guard direct browser / anon-key access.
--
-- User identity: auth.jwt() ->> 'email' — set by the Clerk Supabase JWT
-- template which injects the user's verified primary email as the `email` claim.
-- This matches team_members.email (the authoritative identity column after
-- migration 015 renames user_id → email).
--
-- NOTE: these policies reference team_members.email. Migration 015 performs
-- that rename; if run before 015, team_members.user_id must be substituted
-- below. In practice migrations run in order so 014 always precedes 015.
-- The policies are dropped and recreated by migration 015 once the column
-- exists under its final name.
ALTER TABLE design_languages ENABLE ROW LEVEL SECURITY;
ALTER TABLE design_language_versions ENABLE ROW LEVEL SECURITY;
-- Deny all anon access (belt-and-suspenders with the service-role default).
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_languages' AND policyname = 'dl_no_anon'
) THEN
CREATE POLICY dl_no_anon ON design_languages FOR ALL TO anon USING (false);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_language_versions' AND policyname = 'dlv_no_anon'
) THEN
CREATE POLICY dlv_no_anon ON design_language_versions FOR ALL TO anon USING (false);
END IF;
END $$;
-- design_languages: workspace members may read / write their workspace row.
-- (Migration 015 drops and recreates these after renaming user_id → email.)
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_languages'
AND policyname = 'dl_workspace_member_select'
) THEN
CREATE POLICY dl_workspace_member_select ON design_languages
FOR SELECT
USING (
workspace_id IN (
SELECT workspace_id FROM team_members
WHERE user_id = auth.jwt() ->> 'email'
)
);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_languages'
AND policyname = 'dl_workspace_member_write'
) THEN
CREATE POLICY dl_workspace_member_write ON design_languages
FOR ALL
USING (
workspace_id IN (
SELECT workspace_id FROM team_members
WHERE user_id = auth.jwt() ->> 'email'
)
);
END IF;
END $$;
-- design_language_versions: workspace members may read; inserts go via the
-- application layer (service role), deletes via the prune trigger.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_language_versions'
AND policyname = 'dlv_workspace_member_select'
) THEN
CREATE POLICY dlv_workspace_member_select ON design_language_versions
FOR SELECT
USING (
design_language_id IN (
SELECT dl.id FROM design_languages dl
JOIN team_members tm ON tm.workspace_id = dl.workspace_id
WHERE tm.user_id = auth.jwt() ->> 'email'
)
);
END IF;
END $$;