-- Entry-Secret N:N migration (manual SQL) -- Safe to re-run: uses IF EXISTS/IF NOT EXISTS guards. BEGIN; -- 1) secrets: add new columns ALTER TABLE secrets ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE secrets ADD COLUMN IF NOT EXISTS type VARCHAR(64) NOT NULL DEFAULT 'text'; -- 2) rename field_name -> name (idempotent) DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'secrets' AND column_name = 'field_name' ) THEN ALTER TABLE secrets RENAME COLUMN field_name TO name; END IF; END $$; -- 3) create join table CREATE TABLE IF NOT EXISTS entry_secrets ( entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE, sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (entry_id, secret_id) ); CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id); -- 4) backfill user_id and relationship from old secrets.entry_id DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'secrets' AND column_name = 'entry_id' ) THEN UPDATE secrets s SET user_id = e.user_id FROM entries e WHERE s.entry_id = e.id AND s.user_id IS NULL; INSERT INTO entry_secrets(entry_id, secret_id, sort_order) SELECT entry_id, id, 0 FROM secrets WHERE entry_id IS NOT NULL ON CONFLICT DO NOTHING; END IF; END $$; -- 5) backfill secret types UPDATE secrets SET type = 'pem' WHERE name IN ('ssh_key'); UPDATE secrets SET type = 'password' WHERE name IN ('password'); UPDATE secrets SET type = 'phone' WHERE name LIKE 'phone%'; UPDATE secrets SET type = 'url' WHERE name IN ('webhook_url', 'address'); UPDATE secrets SET type = 'token' WHERE name IN ( 'access_key_id', 'access_key_secret', 'global_api_key', 'api_key', 'secret_key', 'personal_access_token', 'runner_token', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET' ); -- 6) drop old entry_id path ALTER TABLE secrets DROP CONSTRAINT IF EXISTS secrets_entry_id_fkey; DROP INDEX IF EXISTS idx_secrets_entry_id; ALTER TABLE secrets DROP CONSTRAINT IF EXISTS secrets_entry_id_field_name_key; ALTER TABLE secrets DROP CONSTRAINT IF EXISTS secrets_entry_id_name_key; ALTER TABLE secrets DROP COLUMN IF EXISTS entry_id; -- 7) add indexes for new access paths CREATE INDEX IF NOT EXISTS idx_secrets_user_id ON secrets(user_id) WHERE user_id IS NOT NULL; DO $$ DECLARE duplicate_samples TEXT; BEGIN SELECT string_agg( format('user_id=%s, name=%s, count=%s', t.user_id, t.name, t.cnt), E'\n' ) INTO duplicate_samples FROM ( SELECT user_id::TEXT AS user_id, name, COUNT(*) AS cnt FROM secrets WHERE user_id IS NOT NULL GROUP BY user_id, name HAVING COUNT(*) > 1 ORDER BY cnt DESC, user_id, name LIMIT 20 ) t; IF duplicate_samples IS NOT NULL THEN RAISE EXCEPTION 'Cannot enforce unique constraint on secrets(user_id, name). Duplicates found:%', E'\n' || duplicate_samples USING HINT = 'Please deduplicate conflicting rows, then rerun migration.'; END IF; END $$; CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_unique_user_name ON secrets(user_id, name) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name); CREATE INDEX IF NOT EXISTS idx_secrets_type ON secrets(type); -- 8) secrets_history: rename and remove entry-scoped columns DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'secrets_history' AND column_name = 'field_name' ) THEN ALTER TABLE secrets_history RENAME COLUMN field_name TO name; END IF; END $$; ALTER TABLE secrets_history DROP COLUMN IF EXISTS entry_id; ALTER TABLE secrets_history DROP COLUMN IF EXISTS entry_version; COMMIT;