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.
240 lines
9.2 KiB
PL/PgSQL
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();
|