-- 0008_mai_projects_sync.sql -- Bidirectional sync between projax.items and mai.projects so projax becomes -- the single source of truth for project identity/metadata while mai keeps a -- FK-compatible table for its workers/tasks/sessions/messages/metrics. -- -- Manual prereq (operator runs once on msupabase as superuser, NOT in a -- migration — RLS+grant ownership lives outside projax_admin's reach): -- -- GRANT INSERT, UPDATE, DELETE, TRIGGER ON mai.projects TO projax_admin; -- DROP POLICY IF EXISTS projax_write ON mai.projects; -- CREATE POLICY projax_write ON mai.projects FOR ALL TO projax_admin -- USING (true) WITH CHECK (true); -- -- Trust model: projax_admin is intentionally widened on mai.projects only -- (single-tenant fleet, m only). Other mai.* tables remain off-limits. -- -- Cycle prevention is twofold: -- * pg_trigger_depth() > 1 skip — handles the natural recursion case where -- the forward trigger's UPDATE on mai.projects fires the reverse trigger -- within the same call stack. -- * projax.in_sync GUC — belt-and-braces; set on entry, cleared on exit. -- -------------------------------------------------------------------------- -- Forward: projax.items -> mai.projects (runs as caller, projax_admin) -- -------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION projax.sync_to_mai() RETURNS trigger LANGUAGE plpgsql SET search_path = projax, mai, public AS $$ DECLARE was_mai bool; is_mai bool; mai_id text; mapped_status text; BEGIN IF pg_trigger_depth() > 1 THEN RETURN COALESCE(NEW, OLD); END IF; IF current_setting('projax.in_sync', true) = 'on' THEN RETURN COALESCE(NEW, OLD); END IF; PERFORM set_config('projax.in_sync', 'on', true); IF TG_OP = 'INSERT' THEN IF 'mai' = ANY(NEW.management) AND NEW.deleted_at IS NULL THEN mapped_status := CASE NEW.status WHEN 'done' THEN 'archived' ELSE NEW.status END; INSERT INTO mai.projects (id, name, goal, repo, path, memory_group, status, metadata) VALUES ( NEW.slug, NEW.title, NEW.content_md, COALESCE(NEW.metadata->>'repo', ''), COALESCE(NEW.metadata->>'path', ''), COALESCE(NEW.metadata->>'memory_group', ''), mapped_status, COALESCE(NEW.metadata, '{}'::jsonb) ) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, goal = EXCLUDED.goal, repo = EXCLUDED.repo, path = EXCLUDED.path, memory_group = EXCLUDED.memory_group, status = EXCLUDED.status, metadata = EXCLUDED.metadata, updated_at = now(); INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel) VALUES (NEW.id, 'mai-project', NEW.slug, 'derived-from') ON CONFLICT (item_id, ref_type, ref_id, rel) DO NOTHING; END IF; ELSIF TG_OP = 'UPDATE' THEN was_mai := 'mai' = ANY(OLD.management) AND OLD.deleted_at IS NULL; is_mai := 'mai' = ANY(NEW.management) AND NEW.deleted_at IS NULL; SELECT ref_id INTO mai_id FROM projax.item_links WHERE item_id = NEW.id AND ref_type = 'mai-project' LIMIT 1; IF is_mai AND NOT was_mai THEN mapped_status := CASE NEW.status WHEN 'done' THEN 'archived' ELSE NEW.status END; INSERT INTO mai.projects (id, name, goal, repo, path, memory_group, status, metadata) VALUES ( COALESCE(mai_id, NEW.slug), NEW.title, NEW.content_md, COALESCE(NEW.metadata->>'repo', ''), COALESCE(NEW.metadata->>'path', ''), COALESCE(NEW.metadata->>'memory_group', ''), mapped_status, COALESCE(NEW.metadata, '{}'::jsonb) ) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, goal = EXCLUDED.goal, repo = EXCLUDED.repo, path = EXCLUDED.path, memory_group = EXCLUDED.memory_group, status = EXCLUDED.status, metadata = EXCLUDED.metadata, updated_at = now(); INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel) VALUES (NEW.id, 'mai-project', COALESCE(mai_id, NEW.slug), 'derived-from') ON CONFLICT (item_id, ref_type, ref_id, rel) DO NOTHING; ELSIF was_mai AND NOT is_mai AND mai_id IS NOT NULL THEN -- mai management dropped → delete mai row + the pointer. FK-bound deletes -- (workers/tasks/sessions/messages/metrics) will fail and roll back the -- projax UPDATE — that is the intended safety net. DELETE FROM mai.projects WHERE id = mai_id; DELETE FROM projax.item_links WHERE item_id = NEW.id AND ref_type = 'mai-project'; ELSIF is_mai AND mai_id IS NOT NULL THEN -- Both states managed by mai → propagate editable fields. We DO NOT -- change mai.projects.id (FK targets cannot be renamed), so projax slug -- and mai.id may drift after a rename — tolerated because the pointer -- in item_links remains stable. mapped_status := CASE NEW.status WHEN 'done' THEN 'archived' ELSE NEW.status END; UPDATE mai.projects SET name = NEW.title, goal = NEW.content_md, repo = COALESCE(NEW.metadata->>'repo', ''), path = COALESCE(NEW.metadata->>'path', ''), memory_group = COALESCE(NEW.metadata->>'memory_group', ''), status = mapped_status, metadata = COALESCE(NEW.metadata, '{}'::jsonb), updated_at = now() WHERE id = mai_id; END IF; ELSIF TG_OP = 'DELETE' THEN IF 'mai' = ANY(OLD.management) THEN SELECT ref_id INTO mai_id FROM projax.item_links WHERE item_id = OLD.id AND ref_type = 'mai-project' LIMIT 1; IF mai_id IS NULL THEN mai_id := OLD.slug; END IF; DELETE FROM mai.projects WHERE id = mai_id; END IF; END IF; PERFORM set_config('projax.in_sync', 'off', true); RETURN COALESCE(NEW, OLD); EXCEPTION WHEN OTHERS THEN PERFORM set_config('projax.in_sync', 'off', true); RAISE; END; $$; DROP TRIGGER IF EXISTS items_sync_to_mai ON projax.items; CREATE TRIGGER items_sync_to_mai AFTER INSERT OR UPDATE OR DELETE ON projax.items FOR EACH ROW EXECUTE FUNCTION projax.sync_to_mai(); -- -------------------------------------------------------------------------- -- Reverse: mai.projects -> projax.items (SECURITY DEFINER so writes by the -- `mai` role can fan out into projax.items, which projax_admin owns) -- -------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION projax.sync_from_mai() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = projax, mai, public AS $$ DECLARE pid uuid; mapped_status text; BEGIN IF pg_trigger_depth() > 1 THEN RETURN COALESCE(NEW, OLD); END IF; IF current_setting('projax.in_sync', true) = 'on' THEN RETURN COALESCE(NEW, OLD); END IF; PERFORM set_config('projax.in_sync', 'on', true); IF TG_OP = 'INSERT' THEN SELECT i.id INTO pid FROM projax.items i JOIN projax.item_links l ON l.item_id = i.id WHERE l.ref_type = 'mai-project' AND l.ref_id = NEW.id LIMIT 1; IF pid IS NULL THEN mapped_status := CASE NEW.status WHEN 'sleeping' THEN 'archived' ELSE NEW.status END; INSERT INTO projax.items (kind, title, slug, parent_ids, content_md, metadata, status, management) VALUES ( ARRAY['project']::text[], NEW.name, NEW.id, '{}'::uuid[], -- root; /admin/classify will surface it for re-parenting COALESCE(NEW.goal, ''), jsonb_build_object( 'repo', COALESCE(NEW.repo, ''), 'path', COALESCE(NEW.path, ''), 'memory_group', COALESCE(NEW.memory_group, '') ) || COALESCE(NEW.metadata, '{}'::jsonb), mapped_status, ARRAY['mai']::text[] ) RETURNING id INTO pid; INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel) VALUES (pid, 'mai-project', NEW.id, 'derived-from'); END IF; ELSIF TG_OP = 'UPDATE' THEN mapped_status := CASE NEW.status WHEN 'sleeping' THEN 'archived' ELSE NEW.status END; UPDATE projax.items i SET title = NEW.name, content_md = COALESCE(NEW.goal, ''), status = mapped_status, metadata = jsonb_build_object( 'repo', COALESCE(NEW.repo, ''), 'path', COALESCE(NEW.path, ''), 'memory_group', COALESCE(NEW.memory_group, '') ) || COALESCE(NEW.metadata, '{}'::jsonb) FROM projax.item_links l WHERE l.item_id = i.id AND l.ref_type = 'mai-project' AND l.ref_id = NEW.id; ELSIF TG_OP = 'DELETE' THEN UPDATE projax.items i SET deleted_at = now() FROM projax.item_links l WHERE l.item_id = i.id AND l.ref_type = 'mai-project' AND l.ref_id = OLD.id AND i.deleted_at IS NULL; END IF; PERFORM set_config('projax.in_sync', 'off', true); RETURN COALESCE(NEW, OLD); EXCEPTION WHEN OTHERS THEN PERFORM set_config('projax.in_sync', 'off', true); RAISE; END; $$; DROP TRIGGER IF EXISTS mai_projects_sync_to_projax ON mai.projects; CREATE TRIGGER mai_projects_sync_to_projax AFTER INSERT OR UPDATE OR DELETE ON mai.projects FOR EACH ROW EXECUTE FUNCTION projax.sync_from_mai();