224 lines
8.6 KiB
PL/PgSQL
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 $$;
|