frimeet-api-nlp / sql /migrate_post_feed_v1.sql
AlleksDev's picture
Fix: Custom Feed
5c793cc unverified
Raw
History Blame Contribute Delete
24 kB
BEGIN;
-- Derived post data. Source-of-truth permissions remain in the Go API.
ALTER TABLE public.post_embeddings
ADD COLUMN IF NOT EXISTS author_type TEXT,
ADD COLUMN IF NOT EXISTS author_id TEXT,
ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS source_version BIGINT,
ADD COLUMN IF NOT EXISTS indexed_at TIMESTAMPTZ NOT NULL DEFAULT now();
CREATE INDEX IF NOT EXISTS post_embeddings_active_published_idx
ON public.post_embeddings (published_at DESC, external_id DESC)
WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS post_embeddings_author_idx
ON public.post_embeddings (author_type, author_id)
WHERE is_active = TRUE;
CREATE TABLE IF NOT EXISTS public.post_cluster_runs (
id UUID PRIMARY KEY,
algorithm TEXT NOT NULL CHECK (algorithm = 'kmeans'),
algorithm_version TEXT NOT NULL,
embedding_model TEXT NOT NULL,
embedding_version TEXT NOT NULL,
embedding_dimension INTEGER NOT NULL CHECK (embedding_dimension = 300),
k INTEGER NOT NULL CHECK (k > 1),
sample_size INTEGER NOT NULL CHECK (sample_size >= k),
inertia DOUBLE PRECISION,
silhouette_score DOUBLE PRECISION,
status TEXT NOT NULL CHECK (status IN ('running', 'validated', 'active', 'failed', 'superseded')),
parameters JSONB NOT NULL DEFAULT '{}'::jsonb,
started_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
error_message TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS post_cluster_runs_one_active_idx
ON public.post_cluster_runs ((status)) WHERE status = 'active';
CREATE TABLE IF NOT EXISTS public.post_clusters (
run_id UUID NOT NULL REFERENCES public.post_cluster_runs(id) ON DELETE CASCADE,
cluster_id INTEGER NOT NULL,
centroid VECTOR(300) NOT NULL,
label TEXT,
size INTEGER NOT NULL CHECK (size >= 0),
representative_post_ids TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (run_id, cluster_id)
);
CREATE TABLE IF NOT EXISTS public.post_cluster_memberships (
run_id UUID NOT NULL,
post_id TEXT NOT NULL REFERENCES public.post_embeddings(external_id) ON DELETE CASCADE,
cluster_id INTEGER NOT NULL,
distance_to_centroid DOUBLE PRECISION NOT NULL CHECK (distance_to_centroid >= 0),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (run_id, post_id),
FOREIGN KEY (run_id, cluster_id)
REFERENCES public.post_clusters(run_id, cluster_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS post_cluster_memberships_cluster_idx
ON public.post_cluster_memberships (run_id, cluster_id, distance_to_centroid);
CREATE TABLE IF NOT EXISTS public.user_interest_embeddings (
user_id TEXT PRIMARY KEY,
embedding VECTOR(300) NOT NULL,
weighted_sum VECTOR(300) NOT NULL,
total_weight DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK (total_weight >= 0),
source_hash TEXT NOT NULL,
profile_version TEXT NOT NULL,
interaction_count INTEGER NOT NULL DEFAULT 0 CHECK (interaction_count >= 0),
last_event_id BIGINT NOT NULL DEFAULT 0 CHECK (last_event_id >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.user_interest_embeddings
ADD COLUMN IF NOT EXISTS weighted_sum VECTOR(300),
ADD COLUMN IF NOT EXISTS total_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS last_event_id BIGINT NOT NULL DEFAULT 0;
UPDATE public.user_interest_embeddings
SET weighted_sum = embedding
WHERE weighted_sum IS NULL;
ALTER TABLE public.user_interest_embeddings
ALTER COLUMN weighted_sum SET NOT NULL;
CREATE TABLE IF NOT EXISTS public.post_sync_checkpoints (
consumer TEXT PRIMARY KEY,
last_event_id BIGINT NOT NULL DEFAULT 0 CHECK (last_event_id >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.post_embedding_tombstones (
post_id TEXT PRIMARY KEY,
source_version BIGINT NOT NULL CHECK (source_version >= 0),
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Replace the legacy eight-argument function with the version-aware contract.
DROP FUNCTION IF EXISTS public.upsert_post_embedding(
TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN
);
CREATE OR REPLACE FUNCTION public.upsert_post_embedding(
p_external_id TEXT,
p_document TEXT,
p_metadata JSONB,
p_embedding VECTOR(300),
p_content_hash TEXT,
p_embedding_model TEXT,
p_embedding_version TEXT,
p_is_active BOOLEAN,
p_author_type TEXT,
p_author_id TEXT,
p_published_at TIMESTAMPTZ,
p_source_version BIGINT
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.post_embedding_tombstones tombstone
WHERE tombstone.post_id = p_external_id
AND (p_source_version IS NULL OR tombstone.source_version >= p_source_version)
) THEN
RETURN;
END IF;
IF p_source_version IS NOT NULL THEN
DELETE FROM public.post_embedding_tombstones
WHERE post_id = p_external_id
AND source_version < p_source_version;
END IF;
INSERT INTO public.post_embeddings (
external_id, document, metadata, embedding, content_hash,
embedding_model, embedding_version, is_active, author_type,
author_id, published_at, source_version, indexed_at, updated_at
) VALUES (
p_external_id, p_document, p_metadata, p_embedding, p_content_hash,
p_embedding_model, p_embedding_version, p_is_active, p_author_type,
p_author_id, p_published_at, p_source_version, now(), now()
)
ON CONFLICT (external_id) DO UPDATE SET
document = EXCLUDED.document,
metadata = EXCLUDED.metadata,
embedding = EXCLUDED.embedding,
content_hash = EXCLUDED.content_hash,
embedding_model = EXCLUDED.embedding_model,
embedding_version = EXCLUDED.embedding_version,
is_active = EXCLUDED.is_active,
author_type = EXCLUDED.author_type,
author_id = EXCLUDED.author_id,
published_at = EXCLUDED.published_at,
source_version = EXCLUDED.source_version,
indexed_at = now(),
updated_at = now()
WHERE EXCLUDED.source_version IS NULL
OR post_embeddings.source_version IS NULL
OR EXCLUDED.source_version >= post_embeddings.source_version;
END;
$$;
CREATE OR REPLACE FUNCTION public.deactivate_post_embedding(
p_external_id TEXT,
p_source_version BIGINT
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.post_embedding_tombstones (post_id, source_version, deleted_at)
VALUES (p_external_id, p_source_version, now())
ON CONFLICT (post_id) DO UPDATE SET
source_version = GREATEST(
post_embedding_tombstones.source_version,
EXCLUDED.source_version
),
deleted_at = CASE
WHEN EXCLUDED.source_version >= post_embedding_tombstones.source_version
THEN now()
ELSE post_embedding_tombstones.deleted_at
END;
UPDATE public.post_embeddings
SET is_active = FALSE,
source_version = GREATEST(COALESCE(source_version, 0), p_source_version),
indexed_at = now(),
updated_at = now()
WHERE external_id = p_external_id
AND (source_version IS NULL OR p_source_version >= source_version);
END;
$$;
CREATE OR REPLACE FUNCTION public.get_post_sync_checkpoint(p_consumer TEXT)
RETURNS BIGINT
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT COALESCE(
(SELECT last_event_id FROM public.post_sync_checkpoints WHERE consumer = p_consumer),
0
);
$$;
CREATE OR REPLACE FUNCTION public.save_post_sync_checkpoint(
p_consumer TEXT,
p_last_event_id BIGINT
) RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
INSERT INTO public.post_sync_checkpoints (consumer, last_event_id, updated_at)
VALUES (p_consumer, p_last_event_id, now())
ON CONFLICT (consumer) DO UPDATE SET
last_event_id = GREATEST(post_sync_checkpoints.last_event_id, EXCLUDED.last_event_id),
updated_at = now();
$$;
CREATE OR REPLACE FUNCTION public.reset_post_sync_checkpoint(p_consumer TEXT)
RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
DELETE FROM public.post_sync_checkpoints WHERE consumer = p_consumer;
$$;
CREATE OR REPLACE FUNCTION public.get_post_feed_features(
p_user_id TEXT,
p_post_ids TEXT[]
) RETURNS TABLE (
external_id TEXT,
semantic_similarity DOUBLE PRECISION,
cluster_id INTEGER,
cluster_run_id TEXT,
cold_start BOOLEAN,
post_embedding TEXT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
WITH requested AS (
SELECT post_id, ordinality
FROM unnest(p_post_ids) WITH ORDINALITY AS ids(post_id, ordinality)
), active_run AS (
SELECT id FROM public.post_cluster_runs
WHERE status = 'active'
ORDER BY activated_at DESC NULLS LAST
LIMIT 1
), profile AS (
SELECT embedding FROM public.user_interest_embeddings WHERE user_id = p_user_id
)
SELECT requested.post_id,
CASE WHEN profile.embedding IS NULL OR post.embedding IS NULL THEN 0.0
ELSE 1 - (post.embedding <=> profile.embedding) END,
membership.cluster_id,
active_run.id::text,
profile.embedding IS NULL,
post.embedding::text
FROM requested
LEFT JOIN public.post_embeddings post
ON post.external_id = requested.post_id AND post.is_active = TRUE
LEFT JOIN profile ON TRUE
LEFT JOIN active_run ON TRUE
LEFT JOIN public.post_cluster_memberships membership
ON membership.post_id = post.external_id AND membership.run_id = active_run.id
ORDER BY requested.ordinality;
$$;
CREATE OR REPLACE FUNCTION public.get_post_embeddings_for_profile(p_post_ids TEXT[])
RETURNS TABLE (post_id TEXT, embedding VECTOR(300))
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT external_id, embedding
FROM public.post_embeddings
WHERE external_id = ANY(p_post_ids) AND is_active = TRUE;
$$;
CREATE OR REPLACE FUNCTION public.get_user_interest_states(p_user_ids TEXT[])
RETURNS TABLE (
user_id TEXT,
embedding VECTOR(300),
weighted_sum VECTOR(300),
total_weight DOUBLE PRECISION,
interaction_count INTEGER,
last_event_id BIGINT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT profile.user_id, profile.embedding, profile.weighted_sum,
profile.total_weight, profile.interaction_count, profile.last_event_id
FROM public.user_interest_embeddings profile
WHERE profile.user_id = ANY(p_user_ids);
$$;
CREATE OR REPLACE FUNCTION public.upsert_user_interest_state(
p_user_id TEXT,
p_embedding VECTOR(300),
p_weighted_sum VECTOR(300),
p_total_weight DOUBLE PRECISION,
p_interaction_count INTEGER,
p_last_event_id BIGINT,
p_profile_version TEXT,
p_source_hash TEXT
) RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
INSERT INTO public.user_interest_embeddings (
user_id, embedding, weighted_sum, total_weight, source_hash,
profile_version, interaction_count, last_event_id, updated_at
) VALUES (
p_user_id, p_embedding, p_weighted_sum, p_total_weight, p_source_hash,
p_profile_version, p_interaction_count, p_last_event_id, now()
)
ON CONFLICT (user_id) DO UPDATE SET
embedding = EXCLUDED.embedding,
weighted_sum = EXCLUDED.weighted_sum,
total_weight = EXCLUDED.total_weight,
source_hash = EXCLUDED.source_hash,
profile_version = EXCLUDED.profile_version,
interaction_count = EXCLUDED.interaction_count,
last_event_id = EXCLUDED.last_event_id,
updated_at = now()
WHERE EXCLUDED.last_event_id >= user_interest_embeddings.last_event_id;
$$;
CREATE OR REPLACE FUNCTION public.reset_user_interest_profiles(p_user_id TEXT)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF p_user_id IS NULL THEN
DELETE FROM public.user_interest_embeddings;
ELSE
DELETE FROM public.user_interest_embeddings WHERE user_id = p_user_id;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION public.list_post_embeddings_for_clustering(
p_lookback_days INTEGER,
p_embedding_model TEXT,
p_embedding_version TEXT
) RETURNS TABLE (post_id TEXT, embedding VECTOR(300))
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT external_id, embedding
FROM public.post_embeddings
WHERE is_active = TRUE
AND published_at >= now() - (p_lookback_days * interval '1 day')
AND embedding_model = p_embedding_model
AND embedding_version = p_embedding_version
ORDER BY external_id;
$$;
CREATE OR REPLACE FUNCTION public.create_post_cluster_run(
p_id UUID,
p_algorithm_version TEXT,
p_embedding_model TEXT,
p_embedding_version TEXT,
p_embedding_dimension INTEGER,
p_k INTEGER,
p_sample_size INTEGER,
p_parameters JSONB
) RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
INSERT INTO public.post_cluster_runs (
id, algorithm, algorithm_version, embedding_model, embedding_version,
embedding_dimension, k, sample_size, parameters, status, started_at
) VALUES (
p_id, 'kmeans', p_algorithm_version, p_embedding_model,
p_embedding_version, p_embedding_dimension, p_k, p_sample_size,
p_parameters, 'running', now()
);
$$;
CREATE OR REPLACE FUNCTION public.validate_post_cluster_run(
p_run_id UUID,
p_k INTEGER,
p_inertia DOUBLE PRECISION,
p_silhouette_score DOUBLE PRECISION,
p_parameters JSONB
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
UPDATE public.post_cluster_runs
SET k = p_k,
inertia = p_inertia,
silhouette_score = p_silhouette_score,
parameters = parameters || p_parameters,
status = 'validated',
completed_at = now(),
error_message = NULL
WHERE id = p_run_id AND status = 'running';
IF NOT FOUND THEN
RAISE EXCEPTION 'cluster run % no existe o no esta running', p_run_id;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION public.fail_post_cluster_run(
p_run_id UUID,
p_error_message TEXT
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
UPDATE public.post_cluster_runs
SET status = 'failed',
completed_at = now(),
error_message = LEFT(p_error_message, 4000)
WHERE id = p_run_id AND status IN ('running', 'validated');
END;
$$;
CREATE OR REPLACE FUNCTION public.replace_post_cluster_artifacts(
p_run_id UUID,
p_clusters JSONB,
p_memberships JSONB
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
DELETE FROM public.post_cluster_memberships WHERE run_id = p_run_id;
DELETE FROM public.post_clusters WHERE run_id = p_run_id;
IF NOT EXISTS (
SELECT 1 FROM public.post_cluster_runs
WHERE id = p_run_id AND status = 'validated'
) THEN
RAISE EXCEPTION 'cluster run % no esta validado', p_run_id;
END IF;
INSERT INTO public.post_clusters (
run_id, cluster_id, centroid, size, representative_post_ids
)
SELECT p_run_id, item.cluster_id, item.centroid::vector(300), item.size,
COALESCE(item.representative_post_ids, '{}')
FROM jsonb_to_recordset(p_clusters) AS item(
cluster_id INTEGER, centroid TEXT, size INTEGER,
representative_post_ids TEXT[]
);
INSERT INTO public.post_cluster_memberships (
run_id, post_id, cluster_id, distance_to_centroid
)
SELECT p_run_id, item.post_id, item.cluster_id, item.distance
FROM jsonb_to_recordset(p_memberships) AS item(
post_id TEXT, cluster_id INTEGER, distance DOUBLE PRECISION
);
END;
$$;
CREATE OR REPLACE FUNCTION public.activate_post_cluster_run(p_run_id UUID)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM public.post_cluster_runs
WHERE id = p_run_id AND status = 'active'
) THEN
RETURN;
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.post_cluster_runs
WHERE id = p_run_id AND status IN ('validated', 'superseded')
) THEN
RAISE EXCEPTION 'cluster run % no existe o no es activable', p_run_id;
END IF;
UPDATE public.post_cluster_runs SET status = 'superseded' WHERE status = 'active';
UPDATE public.post_cluster_runs
SET status = 'active', activated_at = now()
WHERE id = p_run_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.get_post_cluster_run(p_run_id UUID)
RETURNS TABLE (
run_id UUID,
status TEXT,
k INTEGER,
sample_size INTEGER,
inertia DOUBLE PRECISION,
silhouette_score DOUBLE PRECISION,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
error_message TEXT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT id, post_cluster_runs.status, post_cluster_runs.k,
post_cluster_runs.sample_size, post_cluster_runs.inertia,
post_cluster_runs.silhouette_score, post_cluster_runs.started_at,
post_cluster_runs.completed_at, post_cluster_runs.activated_at,
post_cluster_runs.error_message
FROM public.post_cluster_runs
WHERE id = p_run_id;
$$;
CREATE OR REPLACE FUNCTION public.assign_posts_to_active_cluster(p_post_ids TEXT[])
RETURNS INTEGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
affected INTEGER;
BEGIN
WITH active_run AS (
SELECT id, embedding_model, embedding_version
FROM public.post_cluster_runs
WHERE status = 'active'
ORDER BY activated_at DESC NULLS LAST
LIMIT 1
), assignments AS (
SELECT active_run.id AS run_id,
post.external_id AS post_id,
nearest.cluster_id,
nearest.distance
FROM active_run
JOIN public.post_embeddings post
ON post.external_id = ANY(p_post_ids)
AND post.is_active = TRUE
AND post.embedding_model = active_run.embedding_model
AND post.embedding_version = active_run.embedding_version
CROSS JOIN LATERAL (
SELECT cluster.cluster_id,
(post.embedding <=> cluster.centroid) AS distance
FROM public.post_clusters cluster
WHERE cluster.run_id = active_run.id
ORDER BY post.embedding <=> cluster.centroid
LIMIT 1
) nearest
)
INSERT INTO public.post_cluster_memberships (
run_id, post_id, cluster_id, distance_to_centroid, assigned_at
)
SELECT run_id, post_id, cluster_id, distance, now()
FROM assignments
ON CONFLICT (run_id, post_id) DO UPDATE SET
cluster_id = EXCLUDED.cluster_id,
distance_to_centroid = EXCLUDED.distance_to_centroid,
assigned_at = now();
GET DIAGNOSTICS affected = ROW_COUNT;
RETURN affected;
END;
$$;
CREATE OR REPLACE FUNCTION public.get_post_cluster_status()
RETURNS TABLE (
active_run_id UUID,
k INTEGER,
sample_size INTEGER,
silhouette_score DOUBLE PRECISION,
activated_at TIMESTAMPTZ
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT id, post_cluster_runs.k, post_cluster_runs.sample_size,
post_cluster_runs.silhouette_score, post_cluster_runs.activated_at
FROM public.post_cluster_runs
WHERE status = 'active'
ORDER BY activated_at DESC NULLS LAST
LIMIT 1;
$$;
-- Least-privilege grants when the expected roles exist.
REVOKE ALL ON FUNCTION public.upsert_post_embedding(TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TIMESTAMPTZ, BIGINT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.deactivate_post_embedding(TEXT, BIGINT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_sync_checkpoint(TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.save_post_sync_checkpoint(TEXT, BIGINT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.reset_post_sync_checkpoint(TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_feed_features(TEXT, TEXT[]) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_embeddings_for_profile(TEXT[]) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_user_interest_states(TEXT[]) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.upsert_user_interest_state(TEXT, VECTOR, VECTOR, DOUBLE PRECISION, INTEGER, BIGINT, TEXT, TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.reset_user_interest_profiles(TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.list_post_embeddings_for_clustering(INTEGER, TEXT, TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.create_post_cluster_run(UUID, TEXT, TEXT, TEXT, INTEGER, INTEGER, INTEGER, JSONB) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.validate_post_cluster_run(UUID, INTEGER, DOUBLE PRECISION, DOUBLE PRECISION, JSONB) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.fail_post_cluster_run(UUID, TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.replace_post_cluster_artifacts(UUID, JSONB, JSONB) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.activate_post_cluster_run(UUID) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.assign_posts_to_active_cluster(TEXT[]) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_cluster_status() FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_cluster_run(UUID) FROM PUBLIC;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nlp_reader') THEN
GRANT EXECUTE ON FUNCTION public.get_post_feed_features(TEXT, TEXT[]) TO nlp_reader;
GRANT EXECUTE ON FUNCTION public.get_post_cluster_status() TO nlp_reader;
GRANT EXECUTE ON FUNCTION public.get_post_cluster_run(UUID) TO nlp_reader;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nlp_writer') THEN
GRANT EXECUTE ON FUNCTION public.upsert_post_embedding(TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TIMESTAMPTZ, BIGINT) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.deactivate_post_embedding(TEXT, BIGINT) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.get_post_sync_checkpoint(TEXT) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.save_post_sync_checkpoint(TEXT, BIGINT) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.reset_post_sync_checkpoint(TEXT) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.get_post_embeddings_for_profile(TEXT[]) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.get_user_interest_states(TEXT[]) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.upsert_user_interest_state(TEXT, VECTOR, VECTOR, DOUBLE PRECISION, INTEGER, BIGINT, TEXT, TEXT) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.reset_user_interest_profiles(TEXT) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.list_post_embeddings_for_clustering(INTEGER, TEXT, TEXT) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.create_post_cluster_run(UUID, TEXT, TEXT, TEXT, INTEGER, INTEGER, INTEGER, JSONB) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.validate_post_cluster_run(UUID, INTEGER, DOUBLE PRECISION, DOUBLE PRECISION, JSONB) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.fail_post_cluster_run(UUID, TEXT) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.replace_post_cluster_artifacts(UUID, JSONB, JSONB) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.activate_post_cluster_run(UUID) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.assign_posts_to_active_cluster(TEXT[]) TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.get_post_cluster_status() TO nlp_writer;
GRANT EXECUTE ON FUNCTION public.get_post_cluster_run(UUID) TO nlp_writer;
END IF;
END $$;
COMMIT;