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.
101 lines
3.3 KiB
PL/PgSQL
101 lines
3.3 KiB
PL/PgSQL
-- 0007_backfill_mai_projects.sql
|
|
-- One-time backfill: for every row in mai.projects that has not already been
|
|
-- promoted into projax (no item_links row with ref_type='mai-project' yet),
|
|
-- create a projax-native item under the heuristic-chosen area, link it back
|
|
-- to mai.projects via item_links, and (if the source row has a repo) record a
|
|
-- gitea-repo pointer.
|
|
--
|
|
-- Idempotent — re-running adds nothing because the existence guard checks the
|
|
-- item_links pointer.
|
|
|
|
CREATE OR REPLACE FUNCTION projax.guess_parent_area(p_slug text, p_repo text)
|
|
RETURNS uuid
|
|
LANGUAGE sql
|
|
STABLE
|
|
AS $$
|
|
-- Pull the seven seeded areas once; choose by heuristic.
|
|
WITH area AS (
|
|
SELECT slug, id FROM projax.items WHERE parent_id IS NULL
|
|
)
|
|
SELECT id FROM area WHERE slug = (
|
|
CASE
|
|
WHEN p_slug = 'mhealth' THEN 'health'
|
|
WHEN p_slug IN ('msports', 'manjin') THEN 'sports'
|
|
WHEN p_slug IN ('kanzlai', 'hlckm', 'work', 'mworkrepo', 'paliad')
|
|
OR (p_repo IS NOT NULL AND p_repo LIKE 'HL/%') THEN 'work'
|
|
WHEN p_slug = 'mhome' THEN 'home'
|
|
ELSE 'dev'
|
|
END
|
|
);
|
|
$$;
|
|
|
|
DO $backfill$
|
|
DECLARE
|
|
rec record;
|
|
parent_id uuid;
|
|
new_id uuid;
|
|
proj_status text;
|
|
BEGIN
|
|
FOR rec IN
|
|
SELECT p.id, p.name, p.repo, p.path, p.memory_group, p.status, p.goal, p.metadata
|
|
FROM mai.projects p
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM projax.item_links l
|
|
WHERE l.ref_type = 'mai-project' AND l.ref_id = p.id
|
|
)
|
|
LOOP
|
|
parent_id := projax.guess_parent_area(rec.id, rec.repo);
|
|
|
|
proj_status := CASE rec.status
|
|
WHEN 'active' THEN 'active'
|
|
WHEN 'sleeping' THEN 'archived'
|
|
WHEN 'archived' THEN 'archived'
|
|
WHEN 'done' THEN 'done'
|
|
ELSE 'active'
|
|
END;
|
|
|
|
INSERT INTO projax.items
|
|
(kind, title, slug, parent_id, content_md, metadata, status, management, tags)
|
|
VALUES (
|
|
ARRAY['project']::text[],
|
|
rec.name,
|
|
rec.id,
|
|
parent_id,
|
|
COALESCE(rec.goal, ''),
|
|
jsonb_build_object(
|
|
'repo', COALESCE(rec.repo, ''),
|
|
'path', COALESCE(rec.path, ''),
|
|
'memory_group', COALESCE(rec.memory_group, '')
|
|
) || COALESCE(rec.metadata, '{}'::jsonb),
|
|
proj_status,
|
|
ARRAY['mai']::text[],
|
|
'{}'::text[]
|
|
)
|
|
RETURNING id INTO new_id;
|
|
|
|
INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel)
|
|
VALUES (new_id, 'mai-project', rec.id, 'derived-from')
|
|
ON CONFLICT (item_id, ref_type, ref_id, rel) DO NOTHING;
|
|
|
|
IF rec.repo IS NOT NULL AND rec.repo <> '' THEN
|
|
INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel, metadata)
|
|
VALUES (
|
|
new_id,
|
|
CASE WHEN rec.repo LIKE 'github.com/%' THEN 'github-repo' ELSE 'gitea-repo' END,
|
|
rec.repo,
|
|
'source',
|
|
'{}'::jsonb
|
|
)
|
|
ON CONFLICT (item_id, ref_type, ref_id, rel) DO NOTHING;
|
|
END IF;
|
|
END LOOP;
|
|
END
|
|
$backfill$;
|
|
|
|
-- Any item linked back to mai.projects must carry 'mai' in management, even
|
|
-- if it was promoted before Phase 1.5 (when the column didn't exist).
|
|
UPDATE projax.items
|
|
SET management = array_append(management, 'mai')
|
|
WHERE NOT ('mai' = ANY(management))
|
|
AND id IN (SELECT item_id FROM projax.item_links WHERE ref_type = 'mai-project');
|