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.
This commit is contained in:
mAi
2026-05-15 16:33:52 +02:00
parent fe62c75660
commit 41c1eaadaa
16 changed files with 1608 additions and 547 deletions

View File

@@ -78,46 +78,48 @@ func TestPathTriggerNestAndRename(t *testing.T) {
}
defer tx.Rollback(ctx)
// Get the 'home' area id.
var homeID string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&homeID); err != nil {
t.Fatalf("read home: %v", err)
}
// Insert child project under home.
var parentPath string
var parentPaths []string
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], $1, $2, $3) returning path`,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[]) returning paths`,
"Spring clean", "spring-clean", homeID,
).Scan(&parentPath); err != nil {
).Scan(&parentPaths); err != nil {
t.Fatalf("insert spring-clean: %v", err)
}
if parentPath != "home.spring-clean" {
t.Fatalf("expected path 'home.spring-clean', got %q", parentPath)
if len(parentPaths) != 1 || parentPaths[0] != "home.spring-clean" {
t.Fatalf("expected paths ['home.spring-clean'], got %v", parentPaths)
}
// Insert grandchild.
var childPath string
var childPaths []string
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id)
select array['project']::text[], 'Bathroom', 'bathroom', id from projax.items where path='home.spring-clean'
returning path`).Scan(&childPath); err != nil {
`insert into projax.items (kind, title, slug, parent_ids)
select array['project']::text[], 'Bathroom', 'bathroom', ARRAY[id]::uuid[]
from projax.items where 'home.spring-clean' = any(paths)
returning paths`).Scan(&childPaths); err != nil {
t.Fatalf("insert bathroom: %v", err)
}
if childPath != "home.spring-clean.bathroom" {
t.Fatalf("expected path 'home.spring-clean.bathroom', got %q", childPath)
if len(childPaths) != 1 || childPaths[0] != "home.spring-clean.bathroom" {
t.Fatalf("expected paths ['home.spring-clean.bathroom'], got %v", childPaths)
}
// Rename middle: descendants must be rewritten.
if _, err := tx.Exec(ctx, `update projax.items set slug='big-clean' where path='home.spring-clean'`); err != nil {
if _, err := tx.Exec(ctx,
`update projax.items set slug='big-clean' where 'home.spring-clean' = any(paths)`,
); err != nil {
t.Fatalf("rename: %v", err)
}
var renamedChild string
if err := tx.QueryRow(ctx, `select path from projax.items where slug='bathroom' and parent_id=(select id from projax.items where slug='big-clean')`).Scan(&renamedChild); err != nil {
var renamedChildPaths []string
if err := tx.QueryRow(ctx,
`select paths from projax.items where slug='bathroom'`,
).Scan(&renamedChildPaths); err != nil {
t.Fatalf("read child after rename: %v", err)
}
if renamedChild != "home.big-clean.bathroom" {
t.Fatalf("expected child path 'home.big-clean.bathroom', got %q", renamedChild)
if len(renamedChildPaths) != 1 || renamedChildPaths[0] != "home.big-clean.bathroom" {
t.Fatalf("expected ['home.big-clean.bathroom'], got %v", renamedChildPaths)
}
}
@@ -134,79 +136,82 @@ func TestPathTriggerReparent(t *testing.T) {
defer tx.Rollback(ctx)
var homeID, devID string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&homeID); err != nil {
t.Fatalf("home: %v", err)
}
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&devID); err != nil {
t.Fatalf("dev: %v", err)
}
// Create project under home, then move it to dev.
var pid string
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X', 'mover', $1) returning id`, homeID,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'X', 'mover', ARRAY[$1]::uuid[]) returning id`,
homeID,
).Scan(&pid); err != nil {
t.Fatalf("insert mover: %v", err)
}
// Child of mover.
if _, err := tx.Exec(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X.child', 'child', $1)`, pid,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'X.child', 'child', ARRAY[$1]::uuid[])`,
pid,
); err != nil {
t.Fatalf("insert child: %v", err)
}
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, devID, pid); err != nil {
if _, err := tx.Exec(ctx,
`update projax.items set parent_ids=ARRAY[$1]::uuid[] where id=$2`, devID, pid,
); err != nil {
t.Fatalf("reparent: %v", err)
}
var p1, p2 string
if err := tx.QueryRow(ctx, `select path from projax.items where id=$1`, pid).Scan(&p1); err != nil {
t.Fatalf("read mover path: %v", err)
var moverPaths, childPaths []string
if err := tx.QueryRow(ctx, `select paths from projax.items where id=$1`, pid).Scan(&moverPaths); err != nil {
t.Fatalf("read mover paths: %v", err)
}
if p1 != "dev.mover" {
t.Fatalf("mover path = %q, want dev.mover", p1)
if len(moverPaths) != 1 || moverPaths[0] != "dev.mover" {
t.Fatalf("mover paths = %v, want [dev.mover]", moverPaths)
}
if err := tx.QueryRow(ctx, `select path from projax.items where parent_id=$1`, pid).Scan(&p2); err != nil {
t.Fatalf("read child path: %v", err)
if err := tx.QueryRow(ctx,
`select paths from projax.items where $1::uuid = any(parent_ids)`, pid,
).Scan(&childPaths); err != nil {
t.Fatalf("read child paths: %v", err)
}
if p2 != "dev.mover.child" {
t.Fatalf("child path = %q, want dev.mover.child", p2)
if len(childPaths) != 1 || childPaths[0] != "dev.mover.child" {
t.Fatalf("child paths = %v, want [dev.mover.child]", childPaths)
}
}
func TestStructuralRules(t *testing.T) {
// Phase 1.5 collapsed the area/project distinction. Both shapes are now
// allowed: projects at root (former areas) and any-kind under any-parent.
// The only structural rule left is "no cycles", covered by TestCycleRejected.
func TestRootAndChildBothAllowed(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cases := []struct {
name string
sql string
args []any
}{
{
name: "area with parent rejected",
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['area']::text[], 'bad', $1, (select id from projax.items where slug='home' and parent_id is null))`,
args: []any{fmt.Sprintf("bad-area-%d", time.Now().UnixNano())},
},
{
name: "project at root rejected",
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'orphan', $1, null)`,
args: []any{fmt.Sprintf("orphan-%d", time.Now().UnixNano())},
},
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, tc.sql, tc.args...); err == nil {
t.Fatalf("expected error, got nil")
}
})
defer tx.Rollback(ctx)
rootSlug := fmt.Sprintf("root-proj-%d", time.Now().UnixNano())
if _, err := tx.Exec(ctx,
`insert into projax.items (kind, title, slug) values (array['project']::text[], 'Root proj', $1)`,
rootSlug,
); err != nil {
t.Fatalf("root project should be allowed: %v", err)
}
var rootID string
if err := tx.QueryRow(ctx, `select id from projax.items where slug=$1`, rootSlug).Scan(&rootID); err != nil {
t.Fatalf("root id: %v", err)
}
if _, err := tx.Exec(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Child', $1, ARRAY[$2]::uuid[])`,
fmt.Sprintf("child-%d", time.Now().UnixNano()), rootID,
); err != nil {
t.Fatalf("child of root project should be allowed: %v", err)
}
}
@@ -223,27 +228,100 @@ func TestCycleRejected(t *testing.T) {
defer tx.Rollback(ctx)
var homeID string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&homeID); err != nil {
t.Fatalf("home: %v", err)
}
var aID, bID string
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'A', 'cyc-a', $1) returning id`, homeID,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'A', 'cyc-a', ARRAY[$1]::uuid[]) returning id`, homeID,
).Scan(&aID); err != nil {
t.Fatalf("a: %v", err)
}
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'B', 'cyc-b', $1) returning id`, aID,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'B', 'cyc-b', ARRAY[$1]::uuid[]) returning id`, aID,
).Scan(&bID); err != nil {
t.Fatalf("b: %v", err)
}
// Now try to make A a child of B -> cycle.
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, bID, aID); err == nil {
if _, err := tx.Exec(ctx, `update projax.items set parent_ids=ARRAY[$1]::uuid[] where id=$2`, bID, aID); err == nil {
t.Fatalf("expected cycle rejection, got nil error")
}
// Also: self-parent.
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$1`, aID); err == nil {
// Self-parent.
if _, err := tx.Exec(ctx, `update projax.items set parent_ids=ARRAY[$1]::uuid[] where id=$1`, aID); err == nil {
t.Fatalf("expected self-parent rejection, got nil error")
}
}
// Multi-parent specific tests.
func TestMultiParentResolvesBothPaths(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
var dev, work string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := tx.QueryRow(ctx, `select id from projax.items where slug='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
t.Fatalf("work: %v", err)
}
slug := fmt.Sprintf("multi-%d", time.Now().UnixNano())
if _, err := tx.Exec(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Multi', $1, ARRAY[$2,$3]::uuid[])`,
slug, dev, work,
); err != nil {
t.Fatalf("insert multi-parent: %v", err)
}
var paths []string
if err := tx.QueryRow(ctx, `select paths from projax.items where slug=$1`, slug).Scan(&paths); err != nil {
t.Fatalf("read paths: %v", err)
}
want := map[string]bool{"dev." + slug: true, "work." + slug: true}
got := map[string]bool{}
for _, p := range paths {
got[p] = true
}
for k := range want {
if !got[k] {
t.Fatalf("expected path %q in %v", k, paths)
}
}
}
func TestSlugCollisionUnderCommonParent(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
var dev string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
slug := fmt.Sprintf("collide-%d", time.Now().UnixNano())
if _, err := tx.Exec(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'A', $1, ARRAY[$2]::uuid[])`,
slug, dev,
); err != nil {
t.Fatalf("first insert: %v", err)
}
if _, err := tx.Exec(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'B', $1, ARRAY[$2]::uuid[])`,
slug, dev,
); err == nil {
t.Fatalf("expected slug-collision rejection under shared parent dev")
}
}

View File

@@ -0,0 +1,50 @@
-- 0006_tags_management_unify.sql
-- Phase 1.5 schema additions:
-- * tags text[] — free-vocabulary cross-cutting labels
-- * management text[] — how the project is run (self | mai | external | ...)
-- Plus the area/project structural collapse: 'area' is no longer a special kind.
-- Existing root rows that had kind=['area'] flip to kind=['project'], same shape.
-- ORDER MATTERS: replace the BEFORE trigger first so the subsequent kind-flip
-- UPDATE does not trip the old structural rule ("project must have non-null
-- parent_id"). After this CREATE OR REPLACE only the cycle check + path-walk
-- remain.
CREATE OR REPLACE FUNCTION projax.items_before_write()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF tg_op = 'UPDATE' AND new.parent_id IS NOT NULL AND new.parent_id = new.id THEN
RAISE EXCEPTION 'projax.items: parent_id cannot equal id'
USING errcode = 'check_violation';
END IF;
new.path := projax.compute_item_path(
new.parent_id,
new.slug,
CASE WHEN tg_op = 'UPDATE' THEN new.id ELSE NULL END
);
RETURN new;
END;
$$;
ALTER TABLE projax.items
ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS management text[] NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS items_tags_idx ON projax.items USING gin (tags);
CREATE INDEX IF NOT EXISTS items_management_idx ON projax.items USING gin (management);
-- Collapse 'area' into 'project'. Older trigger required area at root only and
-- forbade project at root, which is no longer true. We rewrite the kind array
-- in two passes:
-- 1. swap 'area' for 'project'
-- 2. dedupe so kind never carries a redundant entry
UPDATE projax.items
SET kind = array_remove(array_append(array_remove(kind, 'area'), 'project'), NULL)
WHERE 'area' = ANY(kind);
UPDATE projax.items
SET kind = ARRAY(SELECT DISTINCT unnest(kind))
WHERE cardinality(kind) > 1;

View File

@@ -0,0 +1,100 @@
-- 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');

View File

@@ -0,0 +1,239 @@
-- 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();

View File

@@ -0,0 +1,46 @@
-- 0009_items_unified_simplify.sql
-- After the bidirectional sync triggers (0008), mai.projects is a derived
-- projection of projax.items rather than an independent source. The union
-- form would double-count every mai-managed item, so we collapse the view to
-- a thin projection over projax.items and expose the tags+management columns
-- the store now reads.
--
-- DROP+CREATE rather than CREATE OR REPLACE because Postgres refuses to add
-- columns via the latter even when only appending.
DROP VIEW IF EXISTS projax.items_unified;
CREATE VIEW projax.items_unified AS
SELECT
i.id,
i.kind,
i.title,
i.slug,
i.path,
i.parent_id,
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$;

View File

@@ -0,0 +1,298 @@
-- 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$;

View File

@@ -6,94 +6,71 @@ import (
"time"
)
func TestItemsUnifiedHasBothSources(t *testing.T) {
func TestItemsUnifiedShape(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var projaxN, maiN int
// After Phase 1.5 the view collapses to a thin projection over projax.items.
// Source is always 'projax'; there are no 'mai.projects' rows in the view.
var total, projaxN, otherN int
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified`).Scan(&total); err != nil {
t.Fatalf("total: %v", err)
}
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified where source = 'projax'`).Scan(&projaxN); err != nil {
t.Fatalf("projax count: %v", err)
}
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified where source = 'mai.projects'`).Scan(&maiN); err != nil {
t.Fatalf("mai count: %v", err)
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified where source <> 'projax'`).Scan(&otherN); err != nil {
t.Fatalf("other count: %v", err)
}
if projaxN < 7 {
t.Fatalf("expected at least 7 projax-native rows in items_unified, got %d", projaxN)
if projaxN != total {
t.Fatalf("expected all %d rows to have source='projax', got %d projax + %d other", total, projaxN, otherN)
}
if maiN < 1 {
t.Fatalf("expected at least 1 mai.projects row in items_unified, got %d", maiN)
if total < 7 {
t.Fatalf("expected at least the 7 seeded roots in items_unified, got %d", total)
}
}
func TestItemsUnifiedHidesPromotedMaiProjects(t *testing.T) {
func TestItemsUnifiedSurfacesMaiPointer(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
// Any item with a 'mai-project' item_link should surface source_ref_id.
var linked, withRef int
if err := pool.QueryRow(ctx,
`select count(*) from projax.item_links where ref_type='mai-project'`,
).Scan(&linked); err != nil {
t.Fatalf("count links: %v", err)
}
defer tx.Rollback(ctx)
// Pick any mai.projects row currently visible in items_unified.
var maiID string
if err := tx.QueryRow(ctx,
`select source_ref_id from projax.items_unified where source = 'mai.projects' limit 1`,
).Scan(&maiID); err != nil {
t.Fatalf("pick mai project: %v", err)
if err := pool.QueryRow(ctx,
`select count(*) from projax.items_unified where source_ref_id is not null`,
).Scan(&withRef); err != nil {
t.Fatalf("count source_ref_id: %v", err)
}
// Pre-promotion: it should be visible.
var n int
if err := tx.QueryRow(ctx,
`select count(*) from projax.items_unified where source_ref_id = $1 and source = 'mai.projects'`, maiID,
).Scan(&n); err != nil {
t.Fatalf("pre count: %v", err)
}
if n != 1 {
t.Fatalf("pre-promotion count = %d, want 1", n)
}
// Promote: create a projax-native item under 'dev' linked to the mai.projects row.
var devID string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
t.Fatalf("read dev: %v", err)
}
var newID string
if err := tx.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], $1, $2, $3) returning id`,
"Promoted "+maiID, "promo-"+maiID, devID,
).Scan(&newID); err != nil {
t.Fatalf("insert promoted: %v", err)
}
if _, err := tx.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel) values ($1, 'mai-project', $2, 'derived-from')`,
newID, maiID,
); err != nil {
t.Fatalf("insert link: %v", err)
}
// Post-promotion: the mai.projects row should drop out of the unified view.
if err := tx.QueryRow(ctx,
`select count(*) from projax.items_unified where source_ref_id = $1 and source = 'mai.projects'`, maiID,
).Scan(&n); err != nil {
t.Fatalf("post count: %v", err)
}
if n != 0 {
t.Fatalf("post-promotion mai row count = %d, want 0", n)
}
// And the projax-native row is there.
if err := tx.QueryRow(ctx,
`select count(*) from projax.items_unified where id = $1`, newID,
).Scan(&n); err != nil {
t.Fatalf("post projax count: %v", err)
}
if n != 1 {
t.Fatalf("post-promotion projax row count = %d, want 1", n)
if linked != withRef {
t.Fatalf("source_ref_id surfacing diverged from item_links: %d link rows vs %d view rows with ref", linked, withRef)
}
}
func TestPhase15ColumnsPresent(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Sanity-check tags + management + the multi-parent paths/parent_ids columns.
row := pool.QueryRow(ctx,
`select tags, management, paths, parent_ids from projax.items_unified
where cardinality(parent_ids) = 0 limit 1`)
var tags, mgmt, paths []string
var parentIDs []string
if err := row.Scan(&tags, &mgmt, &paths, &parentIDs); err != nil {
t.Fatalf("scan tags/management/paths/parent_ids: %v", err)
}
if len(paths) == 0 {
t.Fatalf("expected at least one path on a root item")
}
}

View File

@@ -12,40 +12,34 @@ projax is m's personal data backbone for self-management — areas of life, proj
## 2. Model
### 2.1 Two kinds, freely nestable
### 2.1 Items in a DAG
```
area ─┐
├─ project ─┐
│ ├─ project ─┐ (sub-projects allowed, any depth)
│ │ └─ …
└─ task (external ref)
```
Phase 1.5 collapsed the area/project structural distinction. Every node is an `item`; the `kind` array is kept for forward-compatibility (future: milestone, event, person, …) but `area` is no longer a special value. The seven seeded roots are just items with `parent_ids = '{}'`.
- **Area** — a container without start/end. Long-running domain of life. Areas live at the root (`parent_id = NULL`). Examples: `dev`, `sports`, `home`, `work`, `health`, `finances`, `social`.
- **Project** — a bounded effort with (usually) a start and an end. Lives under an area, or under another project. Sub-projects nest to arbitrary depth (`home.spring-clean.bathroom.tiles` is fine if that's how m thinks). Examples: `home.spring-clean`, `dev.prjx`, `sports.giro-okt`.
The hierarchy is a **directed acyclic graph**, not a tree: each item has zero or more parents, and the same item can surface under multiple branches. `work.paliad` and `dev.paliad` resolve to the same row. PER citations can use any valid path.
- **Item** — a node in the DAG. Examples: `dev`, `home.spring-clean`, `work.paliad`, `paliad.note`. The `kind` column carries `['project']` today; we may layer other types as needs arise.
- **Task** — atomic work item. **Lives outside projax** (CalDAV todos, Gitea issues, `mai.tasks`). projax references and aggregates them; it does not own them.
Areas and projects share one table (`projax.items`) distinguished by the `kind` array column. The tree (`parent_id`) is unconstrained on depth; the only structural rules are:
Structural rules:
- An area's `parent_id` must be `NULL` (areas are roots).
- A project's `parent_id` must point to an area or another project (no project at root).
- No cycles (enforced by the path trigger).
Tasks are referenced via `projax.item_links`.
- No cycles (enforced by `items_before_write` + `compute_item_paths` recursive-CTE ancestor closure).
- An item's slug must be unique among its siblings under any common parent (enforced by `items_check_slug_collision` BEFORE trigger, with the partial unique index `items_root_slug_uniq` covering the root case).
- Soft delete via `deleted_at`. Hard delete cascades through `items_after_delete`, which scrubs the deleted id from every descendant's `parent_ids` array.
### 2.2 Identity & naming
- `id uuid` — canonical, immutable.
- `slug text` — local-only segment (no dots). Renameable freely. Examples: `prjx`, `spring-clean`, `upc.deadlines` would be split as parent slug `upc` + child slug `deadlines`.
- `path text` — full dot-joined path computed from parent walk. Cached column maintained by trigger; not the source of truth.
- Slug convention: lowercase, vowel-elided where natural (`prjx`, `mai`, `mbrn` if m wishes), kebab-allowed for multi-word leaves (`spring-clean`).
- `slug text` — local-only segment (no dots). Renameable freely. Examples: `prjx`, `spring-clean`. Multi-word leaves use kebab-case.
- `parent_ids uuid[]` — zero or more parent ids. Root items have `parent_ids = '{}'`.
- `paths text[]` — full dot-joined paths, one per ancestor lineage. Trigger-maintained from `parent_ids` + `slug`. Lookup via `'<path>' = ANY(paths)`.
- Slug convention: lowercase, vowel-elided where natural (`prjx`, `mai`, `mbrn`), kebab-allowed for multi-word leaves (`spring-clean`).
- Aliases: `aliases text[]` keeps old slugs searchable after rename.
- `tags text[]` — free-vocabulary cross-cutting labels (`work`, `dev`, `home`, `tech`, …). GIN-indexed. No fixed vocabulary.
- `management text[]` — how the project is run: `self`, `mai`, `external`. An item can carry multiple modes. Empty array means "no specific management mode declared".
### 2.3 Lifecycle (thin)
For projects only — areas don't have lifecycle.
```
active → done → archived
```
@@ -54,8 +48,8 @@ That's it. Free-text in `content_md` covers the nuance ("waiting on Brian," "pau
### 2.4 Relationships
- **Tree** (parent/child within projax): `items.parent_id uuid` self-FK. Areas have `parent_id = NULL`. Projects point at their area or another project. Arbitrary nesting depth.
- **External refs** (`projax.item_links`): each row links an `item_id` to a typed external resource — caldav-todo, gitea-issue, mai-task, mai-project, mbrian-node, etc. Used both for aggregating tasks and for soft cross-references.
- **Tree-as-DAG** (parent/child within projax): `items.parent_ids uuid[]`. Root items have `parent_ids = '{}'`. Any item may name multiple parents — the same row then appears under each branch with paths inherited from each lineage.
- **External refs** (`projax.item_links`): each row links an `item_id` to a typed external resource — `caldav-todo`, `gitea-issue`, `github-repo`, `mai-task`, `mai-project`, `mbrian-node`, `url`, etc. Used both for aggregating tasks and for soft cross-references.
## 3. Schema (Postgres, msupabase, schema `projax`)
@@ -70,27 +64,34 @@ create table projax.items (
path text not null, -- computed, e.g. 'home.spring-clean'
parent_id uuid references projax.items(id) on delete restrict,
content_md text default '',
aliases text[] default '{}',
aliases text[] not null default '{}',
metadata jsonb not null default '{}'::jsonb,
status text not null default 'active', -- active | done | archived (projects only)
status text not null default 'active', -- active | done | archived
pinned boolean not null default false,
archived boolean not null default false,
start_time timestamptz,
end_time timestamptz,
parent_ids uuid[] not null default '{}',
paths text[] not null default '{}',
tags text[] not null default '{}',
management text[] not null default '{}',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz,
unique (parent_id, slug)
deleted_at timestamptz
);
create index items_path_idx on projax.items (path);
create index items_kind_idx on projax.items using gin (kind);
create index items_parent_idx on projax.items (parent_id);
create index items_paths_idx on projax.items using gin (paths);
create index items_parent_ids_idx on projax.items using gin (parent_ids);
create index items_kind_idx on projax.items using gin (kind);
create index items_tags_idx on projax.items using gin (tags);
create index items_management_idx on projax.items using gin (management);
create unique index items_root_slug_uniq
on projax.items (slug) where cardinality(parent_ids) = 0;
create table projax.item_links (
id uuid primary key default gen_random_uuid(),
item_id uuid not null references projax.items(id) on delete cascade,
ref_type text not null, -- 'caldav-todo' | 'gitea-issue' | 'mai-task' | 'mai-project' | 'mbrian-node' | 'url' | ...
ref_type text not null, -- 'caldav-todo' | 'gitea-issue' | 'github-repo' | 'mai-task' | 'mai-project' | 'mbrian-node' | 'url' | ...
ref_id text not null, -- opaque external identifier
rel text not null default 'contains', -- 'contains' | 'related' | 'blocked-by' | 'derived-from'
note text,
@@ -103,81 +104,92 @@ create index item_links_item_idx on projax.item_links (item_id);
create index item_links_ref_idx on projax.item_links (ref_type, ref_id);
```
### 3.1 The `path` trigger
### 3.1 Path triggers (multi-parent)
`path` is maintained by trigger on insert/update: walks `parent_id` to the root, joins slugs with `.`. Recomputed for the subtree when a parent is renamed or re-parented. Keeps queries cheap.
`paths` is a `text[]` maintained by `compute_item_paths(parent_ids, slug, self_id)`:
### 3.2 The mai.projects adapter view
- For root items (`parent_ids = '{}'`), `paths = [slug]`.
- Otherwise, look up every parent's `paths`, append `.<slug>` to each, dedupe and sort. The recursion is implicit — parents' paths are kept up to date by the same trigger, so children just consume the precomputed prefixes.
- Cycle detection: the function rejects when `self_id` appears anywhere in the recursive ancestor closure (`WITH RECURSIVE closure ...`). Plus a defensive direct-self-parent check.
mai.projects stays untouched. projax surfaces it in the unified item stream via a read-only view:
Two BEFORE triggers and two AFTER triggers cooperate:
- `items_before_write` (BEFORE INSERT/UPDATE) — cycle guard + `new.paths := compute_item_paths(...)`.
- `items_check_slug_collision` (BEFORE INSERT/UPDATE) — for each parent in `new.parent_ids`, refuse if another row already uses `new.slug` under that parent.
- `items_after_reparent` (AFTER UPDATE of slug/parent_ids) — DFS over descendants via `refresh_item_paths_recursive`, parent-first ordering. A session GUC `projax.refreshing_paths` short-circuits the inner UPDATEs so the cascade fires exactly once.
- `items_after_delete` (AFTER DELETE) — scrubs the deleted id from every other row's `parent_ids` array (we have no FK integrity on array elements; this is the manual cascade).
### 3.2 The `items_unified` view
After Phase 1.5 mai.projects is a derived projection (see §3.4), so the view collapses to a thin projection over `projax.items`:
```sql
create or replace view projax.items_unified as
create view projax.items_unified as
select
id,
kind,
title,
slug,
path,
parent_id,
content_md,
status,
pinned,
archived,
start_time,
end_time,
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,
created_at,
updated_at
from projax.items
where deleted_at is null
union all
select
('00000000-0000-0000-0000-' || substr(md5(p.id), 1, 12))::uuid as id, -- deterministic placeholder
array['project']::text[] as kind,
p.name as title,
p.id as slug,
'mai.' || p.id as path,
null::uuid as parent_id,
coalesce(p.goal, '') as content_md,
case p.status
when 'active' then 'active'
when 'sleeping' then 'archived'
when 'archived' then 'archived'
else 'active'
end as status,
false as pinned,
(p.status = 'archived') as archived,
null::timestamptz as start_time,
null::timestamptz as end_time,
'mai.projects'::text as source,
p.created_at,
p.updated_at
from mai.projects p;
(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;
```
UI reads `items_unified`. Writes target `projax.items` only. Once m wants to fully migrate, the `mai.projects` half is dropped from the view and rows are copied across with real UUIDs + proper parent assignment.
`source` is always `'projax'` (kept for forward compat); `source_ref_id` surfaces the `mai-project` pointer when one exists so the UI can show "mai id: foo".
### 3.3 Classification overlay
For each mai.projects row, m can later promote it into projax-native (assigning area parent, real slug, kind tweak). Until promoted it appears as a top-level orphan project tagged `source=mai.projects`. An admin page surfaces the unmapped set and lets m one-click classify.
Items can land at root in two ways:
- The backfill in migration 0007 heuristic-assigned every existing mai.projects row to one of the seven seeded areas (`dev`, `sports`, `work`, `home`, `health`, `finances`, `social`). None ended up at root in this pass.
- The reverse sync trigger (§3.4) drops every NEW mai.projects row at root with `management = ['mai']`, leaving m to classify it via `/admin/classify`.
`/admin/classify` surfaces items where `cardinality(parent_ids) = 0 AND 'mai' = ANY(management)`. The inline form posts to `/i/{path}/reparent` to move the item under a chosen parent without touching its other fields.
### 3.4 mai.projects bidirectional sync (Phase 1.5)
`mai.workers`, `mai.tasks`, `mai.sessions`, `mai.messages`, `mai.metrics` and `mai.pwa_head_pins` hold FKs into `mai.projects(id)`, so the table cannot be replaced by a view. Instead it becomes a **derived projection** kept in sync by triggers:
- **Forward** (`projax.sync_to_mai`, AFTER INSERT/UPDATE/DELETE on `projax.items`) — when an item has `'mai' = ANY(management)`, upsert/update/delete the matching `mai.projects` row. Slug stays the join key (`mai.projects.id = projax.items.slug` at creation), but FK targets cannot be renamed, so projax slug and mai id may drift after a rename; the cross-system pointer in `item_links(ref_type='mai-project')` remains stable.
- **Reverse** (`projax.sync_from_mai`, AFTER INSERT/UPDATE/DELETE on `mai.projects`, `SECURITY DEFINER` so `mai` role writes can fan out into `projax.items` which projax_admin owns) — mirror the change into a `projax.items` row, dropping new rows at root with `management = ['mai']` so `/admin/classify` can pick them up.
- **Cycle prevention** — both functions short-circuit when `pg_trigger_depth() > 1` (the natural recursion case) and additionally honour a `projax.in_sync` session GUC as belt-and-braces.
The mai.projects → projax.items reverse trigger requires manual prereqs on msupabase:
```sql
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);
```
(documented in 0008's header and the README).
## 4. Interfaces
### 4.1 Phase 1 — MVP (this build)
### 4.1 Phase 1 + 1.5 — Web frontend (this build)
**Web frontend at `https://projax.msbls.de`**, single binary, served by the same Go process that talks to msupabase.
Pages:
1. **Tree view** (`/`) — collapsible tree of areas → projects, reads `items_unified`. Status badges, search bar (slug, title, alias, content_md).
2. **Item detail** (`/i/{path}`) — full editor for projax-native items (title, slug, parent, kind, status, start/end, content_md). Read-only view for mai.projects-sourced rows with a "Promote to projax" button.
3. **New item** (`/new?parent={path}`) — small form, prefilled with parent.
4. **Classify orphans** (`/admin/classify`) — list of unmapped mai.projects rows, inline assign-to-area control.
1. **Tree view** (`/`) — DAG rendering: every item appears under each of its parents. Status / management / tag chips per row. Tag filter chips at the top toggle a `?tag=<csv>` filter (an item is shown when it or any descendant carries every active tag). `×N` badge on multi-parent items shows how many paths they live at.
2. **Item detail** (`/i/{path}`) — `{path}` matches any entry in `paths`; both `work.paliad` and `dev.paliad` resolve to the same row. The page shows the primary path plus an "Also at: …" breadcrumb for the others. Edit form supports title, slug, multi-select parents, status, tags, management, pinned/archived, content. Save POSTs to `/i/{path}`.
3. **New item** (`/new?parent={path}`) — same form shape; the `parent` query pre-selects one parent option, m can pick more.
4. **Classify** (`/admin/classify`) — surfaces items at root with `'mai' = ANY(management)`. Inline HTMX form sets the first parent. POSTs to `/i/{path}/reparent`.
5. **Auth** — projax's own `/login` (mBrian pattern). Same Supabase backend, per-host cookies (no `Domain` attribute).
Auth: shared msupabase login (matches flexsiebels precedent), single-user m.
### 4.2 Tags + management
- **Tags** (`projax.items.tags text[]`, GIN-indexed) — free vocabulary, no fixed list. Cross-cutting labels for "work-y dev things" (`['work', 'dev']`), "health priorities" (`['health']`), etc. Filter chips at `/` reveal which tag-flavoured slices exist.
- **Management** (`projax.items.management text[]`, GIN-indexed) — declarative mode flags:
- `mai` — bidirectional sync with `mai.projects`. Adding/removing `mai` toggles the mai.projects mirror on/off (with FK safety: removal fails if workers/tasks still reference the project).
- `self` — m runs this manually; otto does not orchestrate.
- `external` — owned by a third party; projax mirrors metadata only.
Mai.projects backfilled rows arrive with `management = ['mai']`. m can layer `self` on top without dropping mai sync.
### 4.2 Phase 2 — task aggregation

View File

@@ -11,15 +11,18 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// Item is the unified row shape: projax-native items and mai.projects rows
// both render through this struct.
// Item is the unified row shape served by projax.items_unified. Phase 1.5
// collapsed the area/project distinction (kind keeps the slot for future
// types but 'area' is no longer a special value) and extended the tree to
// a DAG: an item can have zero or more parents and surface under multiple
// paths simultaneously.
type Item struct {
ID string
Kind []string
Title string
Slug string
Path string
ParentID *string
Paths []string // sorted, deduped — one entry per ancestor lineage
ParentIDs []string
ContentMD string
Aliases []string
Metadata map[string]any
@@ -28,18 +31,45 @@ type Item struct {
Archived bool
StartTime *time.Time
EndTime *time.Time
Source string // "projax" or "mai.projects"
SourceRefID *string // mai.projects.id when Source = "mai.projects"
Source string // always "projax" after Phase 1.5; kept for forward-compat
SourceRefID *string // mai.projects.id when a 'mai-project' item_links row exists
Tags []string
Management []string
CreatedAt time.Time
UpdatedAt time.Time
}
// IsArea reports whether this item should be treated as a top-level container.
func (it *Item) IsArea() bool { return slices.Contains(it.Kind, "area") }
// IsRoot reports whether this item sits at the top of the DAG (no parents).
func (it *Item) IsRoot() bool { return len(it.ParentIDs) == 0 }
// Editable reports whether the UI may edit this row directly.
// mai.projects rows are read-only — they must be promoted first.
func (it *Item) Editable() bool { return it.Source == "projax" }
// PrimaryPath returns the first path (alphabetically) for routing & display.
// Empty string when paths is empty (defensive — every persisted row has at
// least one path).
func (it *Item) PrimaryPath() string {
if len(it.Paths) == 0 {
return ""
}
return it.Paths[0]
}
// OtherPaths returns all paths except the primary one, for the "also at: …"
// breadcrumb on the detail page.
func (it *Item) OtherPaths() []string {
if len(it.Paths) <= 1 {
return nil
}
return it.Paths[1:]
}
// HasManagement reports whether the given mode (e.g. "mai") is set on the item.
func (it *Item) HasManagement(mode string) bool { return slices.Contains(it.Management, mode) }
// HasTag reports whether the item carries the given tag.
func (it *Item) HasTag(tag string) bool { return slices.Contains(it.Tags, tag) }
// Editable is preserved for template forward-compat. All rows are editable
// in projax after the mai.projects unification.
func (it *Item) Editable() bool { return true }
// SourceRefDeref returns the source ref id (empty string if nil) for templates.
func (it *Item) SourceRefDeref() string {
@@ -58,16 +88,17 @@ func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
var ErrNotFound = errors.New("projax: item not found")
const itemsUnifiedCols = `id, kind, title, slug, path, parent_id, content_md, aliases,
const itemsUnifiedCols = `id, kind, title, slug, paths, parent_ids, content_md, aliases,
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
created_at, updated_at`
tags, management, created_at, updated_at`
func scanItem(row pgx.Row) (*Item, error) {
var it Item
if err := row.Scan(
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs, &it.ContentMD,
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
&it.Tags, &it.Management,
&it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
@@ -81,9 +112,10 @@ func scanItems(rows pgx.Rows) ([]*Item, error) {
for rows.Next() {
var it Item
if err := rows.Scan(
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs, &it.ContentMD,
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
&it.Tags, &it.Management,
&it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
@@ -96,21 +128,18 @@ func scanItems(rows pgx.Rows) ([]*Item, error) {
// ListAll returns every visible row from items_unified. Caller groups by tree.
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) {
rows, err := s.Pool.Query(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
order by source = 'projax' desc, path`)
`select `+itemsUnifiedCols+` from projax.items_unified order by paths[1]`)
if err != nil {
return nil, err
}
return scanItems(rows)
}
// GetByPath looks up a single item by path. Projax-native wins on collision.
// GetByPath looks up a single item by any of its paths. Multi-parent items
// can be accessed via /i/work.paliad or /i/dev.paliad interchangeably.
func (s *Store) GetByPath(ctx context.Context, path string) (*Item, error) {
row := s.Pool.QueryRow(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
where path = $1
order by source = 'projax' desc
limit 1`, path)
`select `+itemsUnifiedCols+` from projax.items_unified where $1 = any(paths) limit 1`, path)
it, err := scanItem(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -135,11 +164,11 @@ func (s *Store) GetByID(ctx context.Context, id string) (*Item, error) {
return it, nil
}
// Areas lists all root-level area items, ordered by slug.
func (s *Store) Areas(ctx context.Context) ([]*Item, error) {
// Roots returns the top-level items (no parents), ordered by slug.
func (s *Store) Roots(ctx context.Context) ([]*Item, error) {
rows, err := s.Pool.Query(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
where source = 'projax' and parent_id is null and 'area' = any(kind)
where cardinality(parent_ids) = 0
order by slug`)
if err != nil {
return nil, err
@@ -147,13 +176,15 @@ func (s *Store) Areas(ctx context.Context) ([]*Item, error) {
return scanItems(rows)
}
// MaiOrphans lists rows that originated in mai.projects and have not been
// promoted yet. Used by /admin/classify.
// MaiOrphans lists mai-managed items that landed at root and need m to
// re-parent them. This includes both backfilled items that the heuristic
// misplaced and brand-new mai.projects rows created by mai code (which the
// reverse sync trigger drops at root by design).
func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
rows, err := s.Pool.Query(ctx,
`select `+itemsUnifiedCols+` from projax.items_unified
where source = 'mai.projects'
order by path`)
where cardinality(parent_ids) = 0 and 'mai' = any(management)
order by slug`)
if err != nil {
return nil, err
}
@@ -162,15 +193,18 @@ func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
// CreateInput captures the editable surface of a projax-native item.
type CreateInput struct {
Kind []string
Title string
Slug string
ParentID *string
ContentMD string
Status string
Pinned bool
StartTime *time.Time
EndTime *time.Time
Kind []string
Title string
Slug string
ParentIDs []string
ContentMD string
Status string
Pinned bool
StartTime *time.Time
EndTime *time.Time
Tags []string
Management []string
Metadata map[string]any
}
func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
@@ -186,13 +220,28 @@ func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
if in.Status == "" {
in.Status = "active"
}
if in.Tags == nil {
in.Tags = []string{}
}
if in.Management == nil {
in.Management = []string{}
}
if in.ParentIDs == nil {
in.ParentIDs = []string{}
}
metadata := in.Metadata
if metadata == nil {
metadata = map[string]any{}
}
var id string
err := s.Pool.QueryRow(ctx, `
insert into projax.items
(kind, title, slug, parent_id, content_md, status, pinned, start_time, end_time)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
(kind, title, slug, parent_ids, content_md, status, pinned, start_time, end_time,
tags, management, metadata)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
returning id`,
in.Kind, in.Title, in.Slug, in.ParentID, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
in.Kind, in.Title, in.Slug, in.ParentIDs, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
in.Tags, in.Management, metadata,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("insert: %w", err)
@@ -202,25 +251,38 @@ func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
// UpdateInput captures the editable surface of an existing projax-native item.
type UpdateInput struct {
Title string
Slug string
ParentID *string
ContentMD string
Status string
Pinned bool
Archived bool
StartTime *time.Time
EndTime *time.Time
Title string
Slug string
ParentIDs []string
ContentMD string
Status string
Pinned bool
Archived bool
StartTime *time.Time
EndTime *time.Time
Tags []string
Management []string
}
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
if in.Tags == nil {
in.Tags = []string{}
}
if in.Management == nil {
in.Management = []string{}
}
if in.ParentIDs == nil {
in.ParentIDs = []string{}
}
_, err := s.Pool.Exec(ctx, `
update projax.items
set title=$2, slug=$3, parent_id=$4, content_md=$5,
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10
set title=$2, slug=$3, parent_ids=$4, content_md=$5,
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10,
tags=$11, management=$12
where id=$1 and deleted_at is null`,
id, in.Title, in.Slug, in.ParentID, in.ContentMD,
id, in.Title, in.Slug, in.ParentIDs, in.ContentMD,
in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime,
in.Tags, in.Management,
)
if err != nil {
return nil, fmt.Errorf("update: %w", err)
@@ -228,43 +290,58 @@ func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, e
return s.GetByID(ctx, id)
}
// Promote turns a mai.projects orphan into a projax-native project under the
// chosen parent. The promotion link causes items_unified to hide the source.
func (s *Store) Promote(ctx context.Context, maiID, parentID, slug, title string) (*Item, error) {
tx, err := s.Pool.Begin(ctx)
// Reparent replaces parent_ids entirely with the given set. Used by the
// classify page's inline form and any "move to under X" action.
func (s *Store) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {
if parentIDs == nil {
parentIDs = []string{}
}
_, err := s.Pool.Exec(ctx,
`update projax.items set parent_ids = $2 where id = $1 and deleted_at is null`,
id, parentIDs,
)
if err != nil {
return nil, fmt.Errorf("reparent: %w", err)
}
return s.GetByID(ctx, id)
}
// AddParent appends a parent without disturbing existing ones — used by the
// multi-parent UI to surface a project under a second branch.
func (s *Store) AddParent(ctx context.Context, id, parentID string) (*Item, error) {
_, err := s.Pool.Exec(ctx, `
update projax.items
set parent_ids = case
when $2::uuid = any(parent_ids) then parent_ids
else array_append(parent_ids, $2::uuid)
end
where id = $1 and deleted_at is null`,
id, parentID,
)
if err != nil {
return nil, fmt.Errorf("add parent: %w", err)
}
return s.GetByID(ctx, id)
}
// AllTags returns the deduplicated tag vocabulary in alphabetical order.
// Used by the tree page filter chips.
func (s *Store) AllTags(ctx context.Context) ([]string, error) {
rows, err := s.Pool.Query(ctx,
`select distinct unnest(tags) as tag from projax.items where deleted_at is null order by tag`)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var newID string
if err := tx.QueryRow(ctx, `
insert into projax.items (kind, title, slug, parent_id, content_md, metadata, status)
select array['project']::text[], $1, $2, $3,
coalesce(goal, ''),
coalesce(metadata, '{}'::jsonb),
case status
when 'sleeping' then 'archived'
when 'archived' then 'archived'
when 'done' then 'done'
else 'active' end
from mai.projects where id = $4
returning id`,
title, slug, parentID, maiID,
).Scan(&newID); err != nil {
return nil, fmt.Errorf("promote insert: %w", err)
defer rows.Close()
var out []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
out = append(out, t)
}
if _, err := tx.Exec(ctx, `
insert into projax.item_links (item_id, ref_type, ref_id, rel)
values ($1, 'mai-project', $2, 'derived-from')`,
newID, maiID,
); err != nil {
return nil, fmt.Errorf("promote link: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return s.GetByID(ctx, newID)
return out, rows.Err()
}
// SoftDelete marks a projax-native item deleted_at = now().

View File

@@ -44,6 +44,32 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
}
return *p
},
"join": func(sep string, parts []string) string { return strings.Join(parts, sep) },
"contains": func(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
},
"tagToggleURL": func(active []string, tag string, isActive bool) string {
next := []string{}
if isActive {
for _, t := range active {
if t != tag {
next = append(next, t)
}
}
} else {
next = append(next, active...)
next = append(next, tag)
}
if len(next) == 0 {
return "/"
}
return "/?tag=" + strings.Join(next, ",")
},
}
pages := map[string]*template.Template{}
for _, name := range []string{"tree", "detail", "new", "classify", "error"} {
@@ -112,13 +138,21 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
areas, orphans, projaxN, maiN := buildForest(items)
tags, err := s.Store.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
activeTags := parseCSV(r.URL.Query().Get("tag"))
roots, orphans, total, orphanN := buildForest(items, activeTags)
s.render(w, "tree", map[string]any{
"Title": "tree",
"Areas": areas,
"Orphans": orphans,
"ProjaxCount": projaxN,
"MaiCount": maiN,
"Title": "tree",
"Roots": roots,
"Orphans": orphans,
"Total": total,
"OrphanN": orphanN,
"AllTags": tags,
"ActiveTags": activeTags,
})
}
@@ -148,8 +182,8 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/i/")
if base, ok := strings.CutSuffix(path, "/promote"); ok {
s.handlePromote(w, r, base)
if base, ok := strings.CutSuffix(path, "/reparent"); ok {
s.handleReparent(w, r, base)
return
}
it, err := s.Store.GetByPath(r.Context(), path)
@@ -157,68 +191,115 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
if !it.Editable() {
http.Error(w, "read-only source — use /promote", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
var parentID *string
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentID = &v
parentIDs := r.Form["parent_ids"]
if len(parentIDs) == 0 {
// Legacy single-value field for the classify HTMX action.
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentIDs = []string{v}
}
}
parentIDs = dedupeStrings(parentIDs)
in := store.UpdateInput{
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentID: parentID,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
Pinned: r.FormValue("pinned") == "1",
Archived: r.FormValue("archived") == "1",
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentIDs: parentIDs,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
Pinned: r.FormValue("pinned") == "1",
Archived: r.FormValue("archived") == "1",
Tags: parseCSV(r.FormValue("tags")),
Management: parseCSV(r.FormValue("management")),
}
updated, err := s.Store.Update(r.Context(), it.ID, in)
if err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+updated.Path, http.StatusSeeOther)
http.Redirect(w, r, "/i/"+updated.PrimaryPath(), http.StatusSeeOther)
}
func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request, path string) {
// handleReparent replaces parent_ids. /admin/classify uses this to move
// a root mai-managed item under a chosen parent without touching other fields.
// HTMX-friendly: returns a fragment when HX-Request is set.
func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if it.Source != "mai.projects" || it.SourceRefID == nil {
http.Error(w, "promote: not a mai.projects row", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
parentID := strings.TrimSpace(r.FormValue("parent_id"))
slug := strings.TrimSpace(r.FormValue("slug"))
title := strings.TrimSpace(r.FormValue("title"))
if parentID == "" || slug == "" || title == "" {
http.Error(w, "promote: parent_id, slug, title required", http.StatusBadRequest)
parentIDs := r.Form["parent_ids"]
if len(parentIDs) == 0 {
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentIDs = []string{v}
}
}
parentIDs = dedupeStrings(parentIDs)
if len(parentIDs) == 0 {
http.Error(w, "reparent: parent_ids required", http.StatusBadRequest)
return
}
newItem, err := s.Store.Promote(r.Context(), *it.SourceRefID, parentID, slug, title)
moved, err := s.Store.Reparent(r.Context(), it.ID, parentIDs)
if err != nil {
s.fail(w, r, err)
return
}
// HTMX inline-promote on /admin/classify expects a fragment; full-page promote redirects.
if r.Header.Get("HX-Request") == "true" {
fmt.Fprintf(w, `<tr class="promoted"><td colspan="6">Promoted to <a href="/i/%s">%s</a></td></tr>`,
template.HTMLEscapeString(newItem.Path), template.HTMLEscapeString(newItem.Path))
fmt.Fprintf(w, `<tr class="classified"><td colspan="5">Moved to <a href="/i/%s">%s</a></td></tr>`,
template.HTMLEscapeString(moved.PrimaryPath()), template.HTMLEscapeString(moved.PrimaryPath()))
return
}
http.Redirect(w, r, "/i/"+newItem.Path, http.StatusSeeOther)
http.Redirect(w, r, "/i/"+moved.PrimaryPath(), http.StatusSeeOther)
}
// dedupeStrings preserves order, drops empties.
func dedupeStrings(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// parseCSV splits a comma/space-delimited chip input into a deduplicated,
// trimmed lowercase string slice. Empty input → []string{} (nil avoided so
// JSON/SQL writes get an explicit empty array).
func parseCSV(raw string) []string {
if strings.TrimSpace(raw) == "" {
return []string{}
}
seen := map[string]struct{}{}
out := []string{}
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n'
}) {
t := strings.ToLower(strings.TrimSpace(part))
if t == "" {
continue
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
return out
}
func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
@@ -230,10 +311,6 @@ func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, err)
return
}
if !p.Editable() {
http.Error(w, "parent must be a projax-native item", http.StatusBadRequest)
return
}
parent = p
}
s.render(w, "new", map[string]any{
@@ -252,28 +329,29 @@ func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
if kind == "" {
kind = "project"
}
var parentID *string
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentID = &v
}
if kind == "project" && parentID == nil {
http.Error(w, "project requires a parent", http.StatusBadRequest)
return
parentIDs := r.Form["parent_ids"]
if len(parentIDs) == 0 {
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
parentIDs = []string{v}
}
}
parentIDs = dedupeStrings(parentIDs)
in := store.CreateInput{
Kind: []string{kind},
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentID: parentID,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
Kind: []string{kind},
Title: strings.TrimSpace(r.FormValue("title")),
Slug: strings.TrimSpace(r.FormValue("slug")),
ParentIDs: parentIDs,
ContentMD: r.FormValue("content_md"),
Status: strings.TrimSpace(r.FormValue("status")),
Tags: parseCSV(r.FormValue("tags")),
Management: parseCSV(r.FormValue("management")),
}
it, err := s.Store.Create(r.Context(), in)
if err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/i/"+it.Path, http.StatusSeeOther)
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
}
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
@@ -309,42 +387,102 @@ func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
}
var out []ParentOption
for _, it := range items {
if it.Source != "projax" {
continue
}
out = append(out, ParentOption{ID: it.ID, Path: it.Path})
// Surface every primary path as a candidate parent — multi-parent
// items appear once per parent option using their primary path so the
// UI stays unambiguous.
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, nil
}
func buildForest(items []*store.Item) (areas []*treeNode, orphans []*store.Item, projaxN, maiN int) {
byID := make(map[string]*treeNode, len(items))
// buildForest groups items_unified rows by parent into a sortable tree. A
// multi-parent item appears under EACH of its parents (duplicated nodes in
// distinct branches). When activeTags is non-empty, a branch is kept only
// when it (or any descendant) matches every active tag. orphans lists
// mai-managed root items so /admin/classify and the tree page can surface
// them.
func buildForest(items []*store.Item, activeTags []string) (roots []*treeNode, orphans []*store.Item, total, orphanN int) {
for _, it := range items {
if it.Source == "projax" {
projaxN++
byID[it.ID] = &treeNode{Item: it}
} else {
maiN++
total++
if len(it.ParentIDs) == 0 && it.HasManagement("mai") {
orphans = append(orphans, it)
orphanN++
}
}
for _, n := range byID {
if n.Item.ParentID == nil {
areas = append(areas, n)
// Build a forest where every parent relationship creates a node — so
// multi-parent items are rendered under each parent. Root items get
// one node total.
nodeFor := func(it *store.Item) *treeNode { return &treeNode{Item: it} }
rootNodes := []*treeNode{}
childByParent := make(map[string][]*treeNode, len(items))
for _, it := range items {
if len(it.ParentIDs) == 0 {
rootNodes = append(rootNodes, nodeFor(it))
continue
}
if parent, ok := byID[*n.Item.ParentID]; ok {
parent.Children = append(parent.Children, n)
for _, pid := range it.ParentIDs {
childByParent[pid] = append(childByParent[pid], nodeFor(it))
}
}
sort.Slice(areas, func(i, j int) bool { return areas[i].Item.Slug < areas[j].Item.Slug })
for _, n := range byID {
var attach func(n *treeNode)
attach = func(n *treeNode) {
n.Children = childByParent[n.Item.ID]
for _, c := range n.Children {
attach(c)
}
}
for _, r := range rootNodes {
attach(r)
}
roots = rootNodes
if len(activeTags) > 0 {
var keep func(n *treeNode) bool
keep = func(n *treeNode) bool {
filtered := n.Children[:0]
for _, c := range n.Children {
if keep(c) {
filtered = append(filtered, c)
}
}
n.Children = filtered
if nodeHasAllTags(n.Item, activeTags) {
return true
}
return len(n.Children) > 0
}
filtered := roots[:0]
for _, n := range roots {
if keep(n) {
filtered = append(filtered, n)
}
}
roots = filtered
}
sort.Slice(roots, func(i, j int) bool { return roots[i].Item.Slug < roots[j].Item.Slug })
sort.Slice(orphans, func(i, j int) bool { return orphans[i].Slug < orphans[j].Slug })
var sortChildren func(n *treeNode)
sortChildren = func(n *treeNode) {
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Item.Slug < n.Children[j].Item.Slug })
for _, c := range n.Children {
sortChildren(c)
}
}
for _, r := range roots {
sortChildren(r)
}
return
}
func nodeHasAllTags(it *store.Item, want []string) bool {
for _, t := range want {
if !it.HasTag(t) {
return false
}
}
return true
}
func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
t, ok := s.pages[name]
if !ok {

View File

@@ -89,104 +89,150 @@ func TestHealthz(t *testing.T) {
}
}
func TestDetailProjaxNativeEditable(t *testing.T) {
func TestDetailRendersEditableForm(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/i/dev")
if code != 200 {
t.Fatalf("status %d", code)
t.Fatalf("status %d body=%s", code, body)
}
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
t.Errorf("editable form missing for /i/dev")
t.Errorf("edit form missing for /i/dev")
}
if !strings.Contains(body, `name="tags"`) {
t.Errorf("tags input missing")
}
if !strings.Contains(body, `name="management"`) {
t.Errorf("management input missing")
}
}
func TestDetailMaiProjectsReadOnly(t *testing.T) {
func TestDetailShowsManagementChips(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/i/mai.dotfiles")
// dev.projax is the manually-promoted item from before Phase 1.5 — should
// already carry management=['mai'] after the backfill+sync pass.
code, body := get(t, srv.Routes(), "/i/dev.projax")
if code != 200 {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "Promote to projax") {
t.Errorf("Promote section missing for mai.projects row")
}
if !strings.Contains(body, `action="/i/mai.dotfiles/promote"`) {
t.Errorf("promote form missing")
if !strings.Contains(body, "mgmt-mai") {
t.Errorf("expected mgmt-mai chip on /i/dev.projax, body did not include it")
}
}
func TestClassifyListsOrphans(t *testing.T) {
func TestClassifyListsMaiRoots(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/admin/classify")
if code != 200 {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "unclassified rows") {
t.Errorf("classify missing summary")
if !strings.Contains(body, "Classify root mai-managed items") &&
!strings.Contains(body, "No unclassified roots") {
t.Errorf("classify page body unexpected: %q", body)
}
}
func TestPromoteRoundTrip(t *testing.T) {
func TestReparentRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Pick an orphan to promote.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var maiID, maiPath string
if err := pool.QueryRow(ctx,
`select source_ref_id, path from projax.items_unified where source='mai.projects' limit 1`,
).Scan(&maiID, &maiPath); err != nil {
t.Fatalf("pick orphan: %v", err)
// Create a fresh root mai-managed item via the reverse-sync path so the
// test never collides with another project. The sync trigger drops the
// mirror at parent_id=NULL — exactly the case /admin/classify handles.
maiID := "phase15-test-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
defer func() {
_, _ = pool.Exec(context.Background(), `delete from mai.projects where id=$1`, maiID)
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, maiID)
}()
if _, err := pool.Exec(ctx,
`insert into mai.projects (id, name, status) values ($1, $2, 'active')`,
maiID, "Reparent test "+maiID,
); err != nil {
t.Fatalf("seed mai.projects: %v", err)
}
if maiID == "" {
t.Skip("no mai.projects orphans available")
var nParents int
if err := pool.QueryRow(ctx,
`select cardinality(parent_ids) from projax.items where slug=$1`, maiID,
).Scan(&nParents); err != nil {
t.Fatalf("read mirror: %v", err)
}
if nParents != 0 {
t.Fatalf("expected mirror at root (no parents), got %d parents", nParents)
}
var devID string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
if err := pool.QueryRow(ctx,
`select id from projax.items where slug='dev' and cardinality(parent_ids) = 0`,
).Scan(&devID); err != nil {
t.Fatalf("dev: %v", err)
}
promoSlug := "test-promo-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
form := url.Values{}
form.Set("parent_id", devID)
form.Set("slug", promoSlug)
form.Set("title", "Promo "+maiID)
req := httptest.NewRequest(http.MethodPost, "/i/"+maiPath+"/promote", strings.NewReader(form.Encode()))
req := httptest.NewRequest(http.MethodPost, "/i/"+maiID+"/reparent", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusSeeOther {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("promote status %d body=%s", w.Result().StatusCode, body)
t.Fatalf("reparent status %d body=%s", w.Result().StatusCode, body)
}
loc := w.Result().Header.Get("Location")
wantLoc := "/i/dev." + promoSlug
if loc != wantLoc {
t.Errorf("redirect Location = %q, want %q", loc, wantLoc)
if loc := w.Result().Header.Get("Location"); loc != "/i/dev."+maiID {
t.Errorf("Location = %q, want /i/dev.%s", loc, maiID)
}
// The mai row should be hidden from items_unified now.
var still int
var parents []string
if err := pool.QueryRow(ctx,
`select count(*) from projax.items_unified where source='mai.projects' and source_ref_id=$1`, maiID,
).Scan(&still); err != nil {
t.Fatalf("post-promote count: %v", err)
`select array(select unnest(parent_ids)::text) from projax.items where slug=$1`, maiID,
).Scan(&parents); err != nil {
t.Fatalf("post-reparent read: %v", err)
}
if still != 0 {
t.Errorf("expected mai source row hidden after promote, got count=%d", still)
}
// Clean up to keep test idempotent.
if _, err := pool.Exec(ctx, `delete from projax.item_links where ref_type='mai-project' and ref_id=$1`, maiID); err != nil {
t.Fatalf("cleanup link: %v", err)
}
if _, err := pool.Exec(ctx, `delete from projax.items where slug=$1 and parent_id=$2`, promoSlug, devID); err != nil {
t.Fatalf("cleanup item: %v", err)
if len(parents) != 1 || parents[0] != devID {
t.Errorf("parent_ids after reparent = %v, want [%s]", parents, devID)
}
}
func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
slug := "p15-multi-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
defer func() {
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, slug)
}()
var dev, work string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx, `select id from projax.items where slug='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
t.Fatalf("work: %v", err)
}
if _, err := pool.Exec(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Multi', $1, ARRAY[$2,$3]::uuid[])`,
slug, dev, work,
); err != nil {
t.Fatalf("insert multi: %v", err)
}
for _, p := range []string{"dev." + slug, "work." + slug} {
code, body := get(t, h, "/i/"+p)
if code != 200 {
t.Fatalf("GET /i/%s → %d", p, code)
}
if !strings.Contains(body, "Multi") {
t.Errorf("body for /i/%s missing item title 'Multi'", p)
}
}
}

View File

@@ -36,6 +36,16 @@ h2 { font-size: 1.1em; margin: 24px 0 8px; }
.status-archived { color: var(--muted); }
.source { display: inline-block; font-size: 0.75em; padding: 1px 6px; border-radius: 4px; background: var(--bg-alt); border: 1px solid var(--border); }
.source-mai\.projects { color: var(--warn); }
.tag { display: inline-block; font-size: 0.72em; padding: 1px 6px; border-radius: 999px; background: var(--bg-alt); border: 1px solid var(--border); margin-left: 4px; color: var(--accent); text-decoration: none; }
a.tag:hover { background: var(--accent); color: #fff; }
.tag-on { background: var(--accent); color: #fff; }
.mgmt { display: inline-block; font-size: 0.72em; padding: 1px 6px; border-radius: 4px; background: #fff; border: 1px solid var(--border); margin-left: 4px; color: var(--muted); }
.mgmt-mai { color: var(--warn); border-color: var(--warn); }
.mgmt-self { color: var(--ok); border-color: var(--ok); }
.tagbar { margin: 12px 0; padding: 8px 0; border-bottom: 1px dotted var(--border); }
.tagbar .muted { color: var(--muted); margin-right: 8px; }
.tagbar .clear { margin-left: 12px; color: var(--bad); }
.muted { color: var(--muted); }
.add { margin-left: 6px; color: var(--accent); text-decoration: none; }
.add:hover { text-decoration: underline; }
.orphans { margin-top: 32px; }

View File

@@ -1,36 +1,35 @@
{{define "content"}}
<h1>Classify mai.projects orphans</h1>
<p>{{len .Orphans}} unclassified rows. Pick an area (or any projax item) per row and click Promote — keeps the original mai.projects row untouched and links back via item_links.</p>
<h1>Classify root mai-managed items</h1>
<p>{{len .Orphans}} mai-managed items at root. Pick a parent — the multi-parent shape lets you set just one for a quick re-home; add more later from the item's detail page.</p>
<table class="classify">
<thead>
<tr><th>Mai ID / Title</th><th>Status</th><th>Parent</th><th>Slug</th><th>Title</th><th></th></tr>
<tr><th>Slug / title</th><th>Status</th><th>Tags</th><th>Parent</th><th></th></tr>
</thead>
<tbody>
{{range .Orphans}}
<tr id="row-{{.SourceRefDeref}}">
<tr id="row-{{.Slug}}">
<td>
<a href="/i/{{.Path}}">{{.Title}}</a>
<br><small class="slug">{{.SourceRefDeref}}</small>
<a href="/i/{{.PrimaryPath}}">{{.Title}}</a>
<br><small class="slug">{{.Slug}}{{with .SourceRefDeref}} · mai id: {{.}}{{end}}</small>
</td>
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
<td>{{range .Tags}}<span class="tag">{{.}}</span>{{end}}</td>
<td>
<form
hx-post="/i/{{.Path}}/promote"
hx-target="#row-{{.SourceRefDeref}}"
hx-post="/i/{{.PrimaryPath}}/reparent"
hx-target="#row-{{.Slug}}"
hx-swap="outerHTML"
class="inline-promote">
class="inline-classify">
<select name="parent_id" required>
<option value="">— pick parent —</option>
{{range $.ParentOptions}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
</select>
</td>
<td><input name="slug" value="{{.Slug}}" required pattern="[^.]+"></td>
<td><input name="title" value="{{.Title}}" required></td>
<td><button type="submit">Promote</button></form></td>
<td><button type="submit">Move</button></form></td>
</tr>
{{else}}
<tr><td colspan="6"><em>No orphans. Everything is classified.</em></td></tr>
<tr><td colspan="5"><em>No unclassified roots. Everything has at least one parent.</em></td></tr>
{{end}}
</tbody>
</table>

View File

@@ -1,71 +1,53 @@
{{define "content"}}
<h1>{{.Item.Title}}</h1>
<p class="meta">
<span class="source source-{{.Item.Source}}">{{.Item.Source}}</span>
<span class="slug">{{.Item.Path}}</span>
<span class="slug">{{.Item.PrimaryPath}}</span>
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
{{if .Item.Pinned}}<span class="pin">pinned</span>{{end}}
{{if .Item.Archived}}<span class="archived">archived</span>{{end}}
{{if .Item.SourceRefDeref}}<span class="muted">mai id: {{.Item.SourceRefDeref}}</span>{{end}}
</p>
{{if .Item.OtherPaths}}
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
{{end}}
{{if .Item.Editable}}
<form method="post" action="/i/{{.Item.Path}}" class="edit">
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parent
<select name="parent_id">
{{if .Item.IsArea}}
<option value="" selected>(root area)</option>
{{else}}
{{range .ParentOptions}}
<option value="{{.ID}}" {{if and $.Item.ParentID (eq .ID (deref $.Item.ParentID))}}selected{{end}}>{{.Path}}</option>
{{end}}
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
<label>Content
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/">Cancel</a>
</div>
</form>
{{else}}
<div class="readonly">
<p><em>Read-only: this row is sourced from {{.Item.Source}}.</em></p>
<pre class="content">{{.Item.ContentMD}}</pre>
<h2>Promote to projax</h2>
<p>Pick the area or project this should live under. mai.projects row stays untouched; the projax item links back to it via item_links.</p>
<form method="post" action="/i/{{.Item.Path}}/promote" class="promote">
<label>Parent
<select name="parent_id" required>
{{range .ParentOptions}}
<option value="{{.ID}}">{{.Path}}</option>
{{end}}
</select>
</label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<div class="actions">
<button type="submit">Promote</button>
<a class="cancel" href="/">Cancel</a>
</div>
</form>
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
{{end}}
</select>
</label>
<label>Tags
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
</label>
<label>Management
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
</label>
<label class="checkbox">
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
</label>
<label>Content
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
</label>
<div class="actions">
<button type="submit">Save</button>
<a class="cancel" href="/">Cancel</a>
</div>
{{end}}
</form>
{{end}}

View File

@@ -1,28 +1,35 @@
{{define "content"}}
<h1>New item</h1>
<p class="meta">Parent: <strong>{{if .Parent}}{{.Parent.Path}}{{else}}(root area){{end}}</strong></p>
<p class="meta">Suggested parent: <strong>{{if .Parent}}{{.Parent.PrimaryPath}}{{else}}(root){{end}}</strong></p>
<form method="post" action="/new" class="edit">
{{if .Parent}}<input type="hidden" name="parent_id" value="{{.Parent.ID}}">{{end}}
<label>Kind
<select name="kind">
{{if not .Parent}}<option value="area" selected>area</option>{{end}}
<option value="project" {{if .Parent}}selected{{end}}>project</option>
</select>
</label>
<input type="hidden" name="kind" value="project">
<label>Title <input name="title" required></label>
<label>Slug <input name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — leave empty for a root item)</small>
<select name="parent_ids" multiple size="6">
{{range .ParentOptions}}
<option value="{{.ID}}" {{if and $.Parent (eq .ID $.Parent.ID)}}selected{{end}}>{{.Path}}</option>
{{end}}
</select>
</label>
<label>Status
<select name="status">
{{range $opt := .StatusOptions}}<option value="{{$opt}}">{{$opt}}</option>{{end}}
</select>
</label>
<label>Tags
<input name="tags" placeholder="comma-separated, e.g. work, dev">
</label>
<label>Management
<input name="management" placeholder="comma-separated: self, mai, external">
</label>
<label>Content
<textarea name="content_md" rows="10"></textarea>
</label>
<div class="actions">
<button type="submit">Create</button>
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.Path}}{{else}}/{{end}}">Cancel</a>
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.PrimaryPath}}{{else}}/{{end}}">Cancel</a>
</div>
</form>
{{end}}

View File

@@ -1,38 +1,37 @@
{{define "content"}}
<h1>Tree</h1>
<p class="counts">
<strong>{{.ProjaxCount}}</strong> projax · <strong>{{.MaiCount}}</strong> mai.projects orphans
{{if .MaiCount}}<a href="/admin/classify">→ classify</a>{{end}}
<strong>{{.Total}}</strong> items
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
</p>
<section class="tree">
<h2>Areas + projax items</h2>
<ul class="forest">
{{range .Areas}}
<li class="node area">
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
<span class="slug">{{.Item.Path}}</span>
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
{{template "children" .}}
</li>
{{end}}
</ul>
</section>
{{if .Orphans}}
<section class="orphans">
<h2>mai.projects orphans <small>(unclassified)</small></h2>
<ul class="flat">
{{range .Orphans}}
<li class="node orphan">
<a href="/i/{{.Path}}">{{.Title}}</a>
<span class="slug">{{.Path}}</span>
<span class="status status-{{.Status}}">{{.Status}}</span>
</li>
{{end}}
</ul>
{{if .AllTags}}
<section class="tagbar">
<span class="muted">Filter by tag:</span>
{{range .AllTags}}
{{$on := false}}{{range $t := $.ActiveTags}}{{if eq $t .}}{{$on = true}}{{end}}{{end}}
{{if $on}}<a class="tag tag-on" href="{{tagToggleURL $.ActiveTags . true}}">{{.}}</a>{{else}}<a class="tag" href="{{tagToggleURL $.ActiveTags . false}}">{{.}}</a>{{end}}
{{end}}
{{if .ActiveTags}}<a class="clear" href="/">clear</a>{{end}}
</section>
{{end}}
<section class="tree">
<ul class="forest">
{{range .Roots}}
<li class="node root">
<a href="/i/{{.Item.PrimaryPath}}">{{.Item.Title}}</a>
<span class="slug">{{.Item.PrimaryPath}}</span>
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
<a class="add" href="/new?parent={{.Item.PrimaryPath}}">+</a>
{{template "children" .}}
</li>
{{else}}
<li><em>No items match the active filter.</em></li>
{{end}}
</ul>
</section>
{{end}}
{{define "children"}}
@@ -40,10 +39,13 @@
<ul>
{{range .Children}}
<li class="node project">
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
<span class="slug">{{.Item.Path}}</span>
<a href="/i/{{.Item.PrimaryPath}}">{{.Item.Title}}</a>
<span class="slug">{{.Item.PrimaryPath}}</span>
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
{{if gt (len .Item.Paths) 1}}<span class="muted multi-parent" title="appears under multiple parents">×{{len .Item.Paths}}</span>{{end}}
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
<a class="add" href="/new?parent={{.Item.PrimaryPath}}">+</a>
{{template "children" .}}
</li>
{{end}}