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

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

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

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

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

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

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

328 lines
10 KiB
Go

package db_test
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/m/projax/db"
)
// skipMigrate reports whether the test process should skip ApplyMigrations.
// Useful when the live deploy or another process is concurrently migrating
// against the same DB — Postgres serialises CREATE / ALTER OWNER and the
// loser deadlocks. Set PROJAX_SKIP_MIGRATE=1 to opt out.
func skipMigrate() bool { return os.Getenv("PROJAX_SKIP_MIGRATE") == "1" }
// connect returns a pool or skips the test if no DB is configured.
// Honours PROJAX_DB_URL first, then SUPABASE_DATABASE_URL.
func connect(t *testing.T) *pgxpool.Pool {
t.Helper()
url := os.Getenv("PROJAX_DB_URL")
if url == "" {
url = os.Getenv("SUPABASE_DATABASE_URL")
}
if url == "" {
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping integration test")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatalf("pool: %v", err)
}
if err := pool.Ping(ctx); err != nil {
t.Skipf("DB unreachable: %v", err)
}
return pool
}
func TestMigrationsAreIdempotent(t *testing.T) {
if skipMigrate() {
t.Skip("PROJAX_SKIP_MIGRATE=1 — schema assumed already applied")
}
pool := connect(t)
defer pool.Close()
ctx := context.Background()
// Apply twice; second run must not fail.
if err := db.ApplyMigrations(ctx, pool); err != nil {
t.Fatalf("first apply: %v", err)
}
if err := db.ApplyMigrations(ctx, pool); err != nil {
t.Fatalf("second apply: %v", err)
}
var n int
if err := pool.QueryRow(ctx, `select count(*) from projax.items where 'area' = any(kind) and parent_id is null`).Scan(&n); err != nil {
t.Fatalf("count areas: %v", err)
}
if n < 7 {
t.Fatalf("expected at least 7 seeded areas, got %d", n)
}
}
func TestPathTriggerNestAndRename(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 homeID string
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)
}
var parentPaths []string
if err := tx.QueryRow(ctx,
`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(&parentPaths); err != nil {
t.Fatalf("insert spring-clean: %v", err)
}
if len(parentPaths) != 1 || parentPaths[0] != "home.spring-clean" {
t.Fatalf("expected paths ['home.spring-clean'], got %v", parentPaths)
}
var childPaths []string
if err := tx.QueryRow(ctx,
`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 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 'home.spring-clean' = any(paths)`,
); err != nil {
t.Fatalf("rename: %v", err)
}
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 len(renamedChildPaths) != 1 || renamedChildPaths[0] != "home.big-clean.bathroom" {
t.Fatalf("expected ['home.big-clean.bathroom'], got %v", renamedChildPaths)
}
}
func TestPathTriggerReparent(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 homeID, devID string
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 cardinality(parent_ids)=0`).Scan(&devID); err != nil {
t.Fatalf("dev: %v", err)
}
var pid string
if err := tx.QueryRow(ctx,
`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)
}
if _, err := tx.Exec(ctx,
`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_ids=ARRAY[$1]::uuid[] where id=$2`, devID, pid,
); err != nil {
t.Fatalf("reparent: %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 len(moverPaths) != 1 || moverPaths[0] != "dev.mover" {
t.Fatalf("mover paths = %v, want [dev.mover]", moverPaths)
}
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 len(childPaths) != 1 || childPaths[0] != "dev.mover.child" {
t.Fatalf("child paths = %v, want [dev.mover.child]", childPaths)
}
}
// 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()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
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)
}
}
func TestCycleRejected(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 homeID string
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_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_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_ids=ARRAY[$1]::uuid[] where id=$2`, bID, aID); err == nil {
t.Fatalf("expected cycle rejection, got nil error")
}
// 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")
}
}