An OK solution using triggers
This is what I'm doing now. It's not exactly what I wanted because the trigger fires once per statement instead of once per transaction. But it works for now.
(In the future, we're thinking of using an actual table for search instead of a materialized view, and updating individual relevant rows via triggers instead of refreshing an entire matview.)
Create a function to refresh the materialized view concurrently:
CREATE OR REPLACE FUNCTION refresh_post_search()
RETURNS TRIGGER LANGUAGE plpgsql
AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY post_search;
RETURN NULL;
END $$;
And create a trigger for each of the underlying tables:
CREATE TRIGGER refresh_post_search
AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE
ON posts
FOR EACH STATEMENT
EXECUTE PROCEDURE refresh_post_search();
CREATE TRIGGER refresh_post_search
AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE
ON posts_tags
FOR EACH STATEMENT
EXECUTE PROCEDURE refresh_post_search();
CREATE TRIGGER refresh_post_search
AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE
ON tags
FOR EACH STATEMENT
EXECUTE PROCEDURE refresh_post_search();