Files
projax/db/migrations/0010_multi_parent.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

299 lines
10 KiB
PL/PgSQL

-- 0010_multi_parent.sql
-- Items become a DAG: each item has zero or more parents. The same row can
-- surface at multiple paths (work.paliad and dev.paliad → same record).
--
-- Schema delta:
-- parent_id uuid → parent_ids uuid[] NOT NULL DEFAULT '{}'
-- path text → paths text[] NOT NULL DEFAULT '{}'
--
-- Uniqueness:
-- * Partial unique on (slug) WHERE cardinality(parent_ids) = 0 — root slugs.
-- * BEFORE trigger items_check_slug_collision enforces "no two items share
-- a slug under any common parent" (the multi-parent generalisation that
-- a column constraint cannot express).
--
-- Triggers:
-- items_before_write BEFORE INS/UPD of slug/parent_ids — cycle
-- guard + maintains paths[] via compute_item_paths
-- items_after_reparent AFTER UPD of slug/parent_ids — cascades
-- paths recompute to descendants
-- items_check_slug_collision BEFORE INS/UPD — enforces multi-parent slug
-- uniqueness
-- items_after_delete AFTER DEL — scrubs deleted id from every
-- descendant's parent_ids (we lose FK integrity
-- on array elements; this is the manual cascade)
-- 0. Disable the legacy triggers so backfill UPDATEs don't fire them while
-- the column shape is in flux.
DROP TRIGGER IF EXISTS items_before_write ON projax.items;
DROP TRIGGER IF EXISTS items_after_reparent ON projax.items;
-- The view depends on `path`, so it has to go first; we'll recreate at the
-- end with paths[].
DROP VIEW IF EXISTS projax.items_unified;
-- 1. New columns. Defaults keep existing rows intact during the add.
ALTER TABLE projax.items
ADD COLUMN IF NOT EXISTS parent_ids uuid[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS paths text[] NOT NULL DEFAULT '{}';
-- 2. Backfill from the legacy single-parent columns.
UPDATE projax.items
SET parent_ids = ARRAY[parent_id]
WHERE parent_id IS NOT NULL AND cardinality(parent_ids) = 0;
UPDATE projax.items
SET paths = ARRAY[path]
WHERE path IS NOT NULL AND path <> '' AND cardinality(paths) = 0;
-- For root rows the legacy path column equalled the slug; the backfill above
-- already captured that. Be defensive for any row that still has empty paths:
UPDATE projax.items
SET paths = ARRAY[slug]
WHERE cardinality(paths) = 0;
-- 3. Path computation for the DAG. Each parent's paths are already correct
-- (the trigger maintains them on every write; the backfill above seeded
-- them); we append our slug to each parent path. Cycle detection uses a
-- recursive CTE over parent_ids to compute the ancestor closure.
CREATE OR REPLACE FUNCTION projax.compute_item_paths(p_parent_ids uuid[], p_slug text, p_self_id uuid)
RETURNS text[]
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
parent_paths text[];
result text[];
cycle_hit bool;
BEGIN
IF p_parent_ids IS NULL OR cardinality(p_parent_ids) = 0 THEN
RETURN ARRAY[p_slug];
END IF;
-- Direct cycle: self in parent set.
IF p_self_id IS NOT NULL AND p_self_id = ANY(p_parent_ids) THEN
RAISE EXCEPTION 'projax.items: cycle — item % cannot be its own parent', p_self_id
USING errcode = 'check_violation';
END IF;
-- Transitive cycle: walk the ancestor closure and look for self.
IF p_self_id IS NOT NULL THEN
WITH RECURSIVE closure AS (
SELECT i.id, i.parent_ids, 1 AS depth
FROM projax.items i WHERE i.id = ANY(p_parent_ids)
UNION
SELECT i.id, i.parent_ids, c.depth + 1
FROM closure c
JOIN projax.items i ON i.id = ANY(c.parent_ids)
WHERE c.depth < 64
)
SELECT EXISTS (SELECT 1 FROM closure WHERE id = p_self_id) INTO cycle_hit;
IF cycle_hit THEN
RAISE EXCEPTION 'projax.items: cycle detected (item % appears in its own ancestor closure)', p_self_id
USING errcode = 'check_violation';
END IF;
END IF;
-- Gather every parent's path and prefix to our slug.
SELECT COALESCE(array_agg(DISTINCT pp), '{}'::text[]) INTO parent_paths
FROM (SELECT unnest(paths) AS pp FROM projax.items WHERE id = ANY(p_parent_ids)) sub;
IF parent_paths IS NULL OR cardinality(parent_paths) = 0 THEN
-- A parent referenced an unknown / pathless id — keep the slug visible
-- rather than failing hard; the caller's INSERT will fail elsewhere if
-- truly broken.
RETURN ARRAY[p_slug];
END IF;
SELECT array_agg(DISTINCT (pp || '.' || p_slug) ORDER BY (pp || '.' || p_slug)) INTO result
FROM unnest(parent_paths) pp;
RETURN result;
END;
$$;
-- 4. BEFORE trigger: cycle guard + compute paths from parent_ids.
CREATE OR REPLACE FUNCTION projax.items_before_write()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Self-parent immediate rejection.
IF tg_op = 'UPDATE' AND new.parent_ids IS NOT NULL AND new.id = ANY(new.parent_ids) THEN
RAISE EXCEPTION 'projax.items: parent_ids must not contain self'
USING errcode = 'check_violation';
END IF;
new.paths := projax.compute_item_paths(
new.parent_ids,
new.slug,
CASE WHEN tg_op = 'UPDATE' THEN new.id ELSE NULL END
);
RETURN new;
END;
$$;
DROP TRIGGER IF EXISTS items_before_write ON projax.items;
CREATE TRIGGER items_before_write
BEFORE INSERT OR UPDATE OF slug, parent_ids, kind ON projax.items
FOR EACH ROW EXECUTE FUNCTION projax.items_before_write();
-- 5. Recursive descendant refresh helper. Parent-first ordering is guaranteed
-- because we update self before iterating direct children.
CREATE OR REPLACE FUNCTION projax.refresh_item_paths_recursive(p_id uuid)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE child_id uuid;
BEGIN
UPDATE projax.items
SET paths = projax.compute_item_paths(parent_ids, slug, id)
WHERE id = p_id;
FOR child_id IN SELECT id FROM projax.items WHERE p_id = ANY(parent_ids) LOOP
PERFORM projax.refresh_item_paths_recursive(child_id);
END LOOP;
END;
$$;
-- 6. AFTER trigger: when slug or parent_ids change, cascade the paths
-- recompute to every descendant. A session GUC short-circuits the recursive
-- UPDATEs so the cascade fires exactly once at the top.
CREATE OR REPLACE FUNCTION projax.items_after_reparent()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE child_id uuid;
BEGIN
IF current_setting('projax.refreshing_paths', true) = 'on' THEN
RETURN NULL;
END IF;
IF tg_op = 'UPDATE'
AND (old.slug IS DISTINCT FROM new.slug OR old.parent_ids IS DISTINCT FROM new.parent_ids) THEN
PERFORM set_config('projax.refreshing_paths', 'on', true);
BEGIN
FOR child_id IN SELECT id FROM projax.items WHERE new.id = ANY(parent_ids) LOOP
PERFORM projax.refresh_item_paths_recursive(child_id);
END LOOP;
EXCEPTION WHEN OTHERS THEN
PERFORM set_config('projax.refreshing_paths', 'off', true);
RAISE;
END;
PERFORM set_config('projax.refreshing_paths', 'off', true);
END IF;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS items_after_reparent ON projax.items;
CREATE TRIGGER items_after_reparent
AFTER UPDATE OF slug, parent_ids ON projax.items
FOR EACH ROW EXECUTE FUNCTION projax.items_after_reparent();
-- 7. Slug-collision under any common parent. A single (parent_id, slug)
-- column constraint cannot express this; we check per-parent in trigger.
CREATE OR REPLACE FUNCTION projax.items_check_slug_collision()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
pid uuid;
collide_id uuid;
BEGIN
IF new.parent_ids IS NULL OR cardinality(new.parent_ids) = 0 THEN
RETURN new; -- root collisions are caught by the partial unique index
END IF;
FOREACH pid IN ARRAY new.parent_ids LOOP
SELECT id INTO collide_id
FROM projax.items
WHERE pid = ANY(parent_ids)
AND slug = new.slug
AND id <> new.id
AND deleted_at IS NULL
LIMIT 1;
IF collide_id IS NOT NULL THEN
RAISE EXCEPTION 'projax.items: slug % already exists under parent %', new.slug, pid
USING errcode = 'unique_violation';
END IF;
END LOOP;
RETURN new;
END;
$$;
DROP TRIGGER IF EXISTS items_check_slug_collision ON projax.items;
CREATE TRIGGER items_check_slug_collision
BEFORE INSERT OR UPDATE OF slug, parent_ids ON projax.items
FOR EACH ROW EXECUTE FUNCTION projax.items_check_slug_collision();
-- 8. When an item is deleted, scrub its id from every descendant's
-- parent_ids array (we have no FK integrity on array elements). The UPDATE
-- fires items_before_write/items_after_reparent which recompute child paths.
CREATE OR REPLACE FUNCTION projax.items_after_delete()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE projax.items
SET parent_ids = array_remove(parent_ids, OLD.id)
WHERE OLD.id = ANY(parent_ids);
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS items_after_delete ON projax.items;
CREATE TRIGGER items_after_delete
AFTER DELETE ON projax.items
FOR EACH ROW EXECUTE FUNCTION projax.items_after_delete();
-- 9. Drop legacy columns + indexes + constraints. The (parent_id, slug)
-- unique constraint and items_root_slug_uniq partial index both ride on
-- parent_id; DROP COLUMN ... CASCADE clears them. items_path_idx rides on
-- path; same.
ALTER TABLE projax.items DROP COLUMN IF EXISTS parent_id CASCADE;
ALTER TABLE projax.items DROP COLUMN IF EXISTS path CASCADE;
DROP INDEX IF EXISTS projax.items_path_idx;
DROP INDEX IF EXISTS projax.items_parent_idx;
-- 10. New indexes for the multi-parent / multi-path columns.
CREATE INDEX IF NOT EXISTS items_parent_ids_idx ON projax.items USING gin (parent_ids);
CREATE INDEX IF NOT EXISTS items_paths_idx ON projax.items USING gin (paths);
CREATE UNIQUE INDEX IF NOT EXISTS items_root_slug_uniq
ON projax.items (slug) WHERE cardinality(parent_ids) = 0;
-- 11. Drop the legacy single-parent path function (replaced by compute_item_paths).
DROP FUNCTION IF EXISTS projax.compute_item_path(uuid, text, uuid);
-- 12. Recreate the unified view with the new paths[] / parent_ids[] columns.
CREATE VIEW projax.items_unified AS
SELECT
i.id,
i.kind,
i.title,
i.slug,
i.paths,
i.parent_ids,
i.content_md,
i.aliases,
i.metadata,
i.status,
i.pinned,
i.archived,
i.start_time,
i.end_time,
'projax'::text AS source,
(SELECT l.ref_id FROM projax.item_links l
WHERE l.item_id = i.id AND l.ref_type = 'mai-project' LIMIT 1) AS source_ref_id,
i.tags,
i.management,
i.created_at,
i.updated_at
FROM projax.items i
WHERE i.deleted_at IS NULL;
DO $own$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN
EXECUTE 'ALTER VIEW projax.items_unified OWNER TO projax_admin';
EXECUTE 'GRANT SELECT ON projax.items_unified TO projax_admin';
END IF;
END $own$;