Files
projax/db/migrations/0008_mai_projects_sync.sql
mAi 41c1eaadaa feat(phase 1.5): tags + management + DAG + mai.projects sync
Big task. Five migrations, full store + web rewrite, and a model upgrade
that turns the parent_id tree into a parent_ids[] DAG.

Schema (db/migrations)
- 0006_tags_management_unify: adds tags + management text[] (GIN-indexed),
  collapses the area/project distinction (kind keeps the slot but 'area'
  is no longer a special value), drops the structural rules from the
  path trigger so root projects + non-root projects are both legal.
- 0007_backfill_mai_projects: one-shot, idempotent — for every row in
  mai.projects without a 'mai-project' item_link, create a projax.items
  row under a heuristic-chosen area (mhealth→health, msports/manjin→
  sports, kanzlai/hlckm/work/mworkrepo/paliad or HL/* repo→work,
  mhome→home, default→dev), insert the item_link, and tag the row
  management=['mai']. Also flips management='mai' on any already-linked
  pre-Phase-1.5 promotions.
- 0008_mai_projects_sync: bidirectional triggers. sync_to_mai runs as
  projax_admin and writes mai.projects directly (after the operator-run
  grant + RLS policy widening — documented in the migration header).
  sync_from_mai is SECURITY DEFINER so writes by the mai role fan out
  into projax.items. pg_trigger_depth() + projax.in_sync GUC keep the
  cycle suppressed. Slug stays the join key for new rows; the
  item_link pointer survives renames.
- 0009_items_unified_simplify: view collapses to a thin projection over
  projax.items now that mai.projects is a derived projection.
- 0010_multi_parent: parent_id → parent_ids uuid[], path → paths text[].
  compute_item_paths walks via parents' precomputed paths (no recursive
  CTE in the hot path; cycle detection uses one). New triggers:
  items_check_slug_collision (multi-parent uniqueness),
  items_after_delete (manual cascade since arrays don't carry FK).
  Trigger refresh_item_paths_recursive does parent-first DFS over
  descendants, guarded by projax.refreshing_paths GUC.

Go store + handlers
- Item gains ParentIDs []string + Paths []string. PrimaryPath /
  OtherPaths helpers feed the detail breadcrumb. Source always
  'projax' now; SourceRefDeref still surfaces the mai-id pointer.
- Update / Reparent / Create take ParentIDs []string. AddParent helper
  for the multi-parent UI's "also list under" action.
- GetByPath uses '$1 = any(paths)' so /i/work.paliad and /i/dev.paliad
  resolve to the same row.
- buildForest renders a multi-parent item under each of its parents
  (duplicated nodes in distinct branches). Tag-filter prune is
  branch-preserving.

Templates
- detail.tmpl: multi-select parents, tags + management chip inputs,
  "Also at: …" breadcrumb for multi-parent items.
- new.tmpl: same multi-select + chip inputs.
- tree.tmpl: tag-filter chip bar, "×N" badge on multi-parent rows,
  management chips visible on every row.
- classify.tmpl: re-parent workflow (no more promote-to-projax — the
  bidirectional sync removed the dichotomy).

Tests (DB + HTTP, all skip without env)
- TestMultiParentResolvesBothPaths   inserts an item with two parents,
                                     asserts both inherited paths.
- TestSlugCollisionUnderCommonParent  refuses a sibling clash.
- TestMultiParentBothPathsRouteToSameRow  HTTP-level: /i/dev.X and
                                          /i/work.X both 200, same row.
- TestReparentRoundTrip rewritten for parent_ids[] semantics.
- TestPathTriggerNestAndRename / Reparent rewritten to query paths[].

Docs (docs/design.md)
- §2 rewritten: items in a DAG, no area/project distinction.
- §3 schema: parent_ids + paths + tags + management + indices.
- §3.1 path-trigger overhaul incl. cycle detection via recursive CTE
  and slug-collision-under-common-parent guard.
- §3.2 view simplified.
- §3.4 NEW: mai.projects bidirectional sync, including the manual
  prereq.
- §4.1 + §4.2: classify becomes re-parent, tags+management UI section.

mai head start / mai hire / mai status / mai instruct keep working
because mai.projects retains its FK-target shape; the projax sync just
mirrors the row in lock-step.
2026-05-15 16:33:52 +02:00

240 lines
9.2 KiB
PL/PgSQL

-- 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();