made tiny updates
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
-- ── 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 $$;
|
||||
Reference in New Issue
Block a user