Per t-projax-6-sliceB-readpath. mBrian migration (m/mBrian#73) is live on msupabase with 65 nodes + 78 child_of + 81 projax-* edges. This commit makes the projax read path source from there behind an env switch. CLIENT ARCH: direct pgxpool against mbrian.* schema (same SUPABASE_DATABASE_URL the projax binary already uses for projax.*) — matches flexsiebels/head's cross-coupling pattern. No MCP token plumbing. CONTRACT (all three honoured) - External links are SELF-EDGES (source=target=item, rel='projax-*', payload in edges.metadata). linkFromEdge reads the node's outbound projax-* edges; ref_id derived per ref_type from metadata (caldav url, gitea owner/repo, mai-project mai_project_id). - Slugs finalised: 'work'/'dania' resolve to mBrian's canonical nodes; projax-side squatters (renamed-aside, not deleted) are documented in the parity test as legacy-only and skipped from field comparison. - created_at/updated_at NOT preserved — ItemsCreatedInRange orders off metadata.projax.start_time when present, fall back to mBrian created_at. Aggregator surfaces (timeline / dashboard) read off caldav DTSTART + gitea updated_at, so they're unaffected. NEW FILES - store/mbrian.go: MBrianReader concrete impl. Bulk-loads projax- managed nodes + child_of edges in one pair of queries per call, builds a graphContext in memory, derives Paths via ancestor walk (depth-capped at 64 like projax's trigger). Implements every ItemReader method. - store/mbrian_parity_test.go: 5 parity tests against the live db — ListAll field equality (skipping the renamed squatter slugs), spot-check resolves, caldav-list link round-trip, gitea-repo link round-trip, AllTags union, NotFound consistency. All 5 GREEN. - cmd/projax-remap-views/main.go: one-shot tool to rewrite projax.views.filter_json.project_id from old projax uuids to new mBrian uuids using the audit map mBrian dropped (head will relay the path). Dry-run default; --apply commits. Idempotent. - docs/plans/slice-b-views-projectid-gap.md: surfaces the gap + the remediation path. Must run remap BEFORE slice E drops projax.items. CHANGES - store/adapter.go: kept the ItemReader interface + *Store assertion; removed the prep stub (replaced by mbrian.go). - web/server.go: Server.Items store.ItemReader field. web.New defaults Items to the concrete *Store (legacy path). main.go overrides to MBrianReader when PROJAX_BACKEND=mbrian. - All read-path call sites in web/ swapped from s.Store.<readMethod>( to s.Items.<readMethod>( for the 15 ItemReader methods. MCP tools unchanged (separate scope; can pivot in a follow-up). Writes still flow through s.Store. - cmd/projax/main.go: PROJAX_BACKEND env switch with "store" (default) and "mbrian" values. Logs the choice at startup. Unknown value refuses to start. SMOKE - go build ./... green; go vet green. - go test ./store/ -count=1 — all parity tests pass against live data. - Local server boot with PROJAX_BACKEND=mbrian — backs binding logs "backend=mbrian (read path via store.MBrianReader)" and serves /views/tree (auth wall protects deeper smoke; parity tests cover that surface). PRE-EXISTING failure NOT addressed in this commit: 3 timeline_filter tests in web/ already failed on main (legacy /timeline URL hits the Phase 5j 301 redirect to /views/timeline). No diff vs main in those test files; out of scope for slice B. OUT OF SCOPE FOR SLICE B (deferred): - MCP read tools migration to ItemReader (separate diff, low risk). - Aggregator's LinkLister wired to ItemReader (currently consumes *Store directly through Server.Aggregator()). - views.filter_json.project_id remap RUN — tool ships here, run waits on the head's relay of the audit-map path. - Slice C write-path. Slice D mai-bridge worker. Slice E drop.
252 lines
6.7 KiB
Go
252 lines
6.7 KiB
Go
package store_test
|
|
|
|
// Phase 6 Slice B — parity test between the legacy pgx-against-projax-
|
|
// items *Store and the new pgx-against-mbrian *MBrianReader.
|
|
//
|
|
// Skipped without SUPABASE_DATABASE_URL set. When run against the live
|
|
// post-migration database, every comparison should hold: the adapter
|
|
// must be a faithful translator of the migrated graph for projax UI
|
|
// consumers.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
func newPair(t *testing.T) (*store.Store, *store.MBrianReader, *pgxpool.Pool) {
|
|
t.Helper()
|
|
url := os.Getenv("SUPABASE_DATABASE_URL")
|
|
if url == "" {
|
|
url = os.Getenv("PROJAX_DB_URL")
|
|
}
|
|
if url == "" {
|
|
t.Skip("set SUPABASE_DATABASE_URL")
|
|
}
|
|
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 store.New(pool), store.NewMBrianReader(pool), pool
|
|
}
|
|
|
|
// TestParityListAll: both readers return the same set of items by slug.
|
|
// Field-by-field equality is asserted for slug/title/status/tags/management/
|
|
// public/parent count/paths — the consumer-facing surface.
|
|
func TestParityListAll(t *testing.T) {
|
|
s, r, pool := newPair(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
|
|
leg, err := s.ListAll(ctx)
|
|
if err != nil {
|
|
t.Fatalf("store ListAll: %v", err)
|
|
}
|
|
mb, err := r.ListAll(ctx)
|
|
if err != nil {
|
|
t.Fatalf("mbrian ListAll: %v", err)
|
|
}
|
|
if len(leg) != len(mb) {
|
|
t.Fatalf("count mismatch: store=%d mbrian=%d", len(leg), len(mb))
|
|
}
|
|
// Per the migration brief, two projax-side squatter slugs were
|
|
// renamed-aside (not deleted) so mBrian could take the canonical
|
|
// 'work' (area) + 'dania' (project) slugs. Compare every slug that
|
|
// resolves in BOTH sets; the squatters surface as legacy-only.
|
|
skip := map[string]bool{"work": true, "dania": true}
|
|
legBySlug := bySlug(leg)
|
|
mbBySlug := bySlug(mb)
|
|
for slug, l := range legBySlug {
|
|
if skip[slug] {
|
|
continue
|
|
}
|
|
m, ok := mbBySlug[slug]
|
|
if !ok {
|
|
t.Errorf("slug %q missing in mBrian set", slug)
|
|
continue
|
|
}
|
|
if l.Title != m.Title {
|
|
t.Errorf("%s title: store=%q mbrian=%q", slug, l.Title, m.Title)
|
|
}
|
|
if l.Status != m.Status {
|
|
t.Errorf("%s status: store=%q mbrian=%q", slug, l.Status, m.Status)
|
|
}
|
|
if !sameSet(l.Tags, m.Tags) {
|
|
t.Errorf("%s tags: store=%v mbrian=%v", slug, l.Tags, m.Tags)
|
|
}
|
|
if !sameSet(l.Management, m.Management) {
|
|
t.Errorf("%s management: store=%v mbrian=%v", slug, l.Management, m.Management)
|
|
}
|
|
if l.Public != m.Public {
|
|
t.Errorf("%s public: store=%v mbrian=%v", slug, l.Public, m.Public)
|
|
}
|
|
if len(l.ParentIDs) != len(m.ParentIDs) {
|
|
t.Errorf("%s parent count: store=%d mbrian=%d",
|
|
slug, len(l.ParentIDs), len(m.ParentIDs))
|
|
}
|
|
if !sameSet(l.Paths, m.Paths) {
|
|
t.Errorf("%s paths: store=%v mbrian=%v", slug, l.Paths, m.Paths)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParitySpotChecks asserts the 5 spot-check items resolve identically
|
|
// through both readers — root area / single-parent / multi-parent /
|
|
// caldav-linked / public-listing populated.
|
|
func TestParitySpotChecks(t *testing.T) {
|
|
s, r, pool := newPair(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
for _, slug := range []string{"dev", "work", "paliad", "services", "mhome", "fdbck", "dania"} {
|
|
l, lerr := s.GetByPathOrSlug(ctx, slug)
|
|
m, merr := r.GetByPathOrSlug(ctx, slug)
|
|
if lerr != nil && merr != nil {
|
|
// Both 404 — consistent.
|
|
continue
|
|
}
|
|
if lerr != nil {
|
|
t.Errorf("%s: store err=%v but mbrian found", slug, lerr)
|
|
continue
|
|
}
|
|
if merr != nil {
|
|
t.Errorf("%s: mbrian err=%v but store found", slug, merr)
|
|
continue
|
|
}
|
|
if l.Slug != m.Slug || l.Title != m.Title {
|
|
t.Errorf("%s: shape mismatch store=%+v mbrian=%+v",
|
|
slug, l.Slug+"/"+l.Title, m.Slug+"/"+m.Title)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParityCalDAVLinks: the single caldav-list link must round-trip.
|
|
func TestParityCalDAVLinks(t *testing.T) {
|
|
s, r, pool := newPair(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
leg, err := s.LinksByRefType(ctx, "caldav-list")
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
mb, err := r.LinksByRefType(ctx, "caldav-list")
|
|
if err != nil {
|
|
t.Fatalf("mbrian: %v", err)
|
|
}
|
|
if len(leg) != len(mb) {
|
|
t.Errorf("count: store=%d mbrian=%d", len(leg), len(mb))
|
|
}
|
|
// The URLs must round-trip identically. Match by ref_id.
|
|
legByRef := map[string]*store.ItemLink{}
|
|
for _, l := range leg {
|
|
legByRef[l.RefID] = l
|
|
}
|
|
for _, m := range mb {
|
|
l, ok := legByRef[m.RefID]
|
|
if !ok {
|
|
t.Errorf("mbrian caldav RefID %q not in store set", m.RefID)
|
|
continue
|
|
}
|
|
if l.Rel != m.Rel {
|
|
t.Errorf("caldav %s rel: store=%q mbrian=%q", m.RefID, l.Rel, m.Rel)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParityGiteaRepoLinks — same parity check for the 37 gitea-repo edges.
|
|
func TestParityGiteaRepoLinks(t *testing.T) {
|
|
s, r, pool := newPair(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
leg, err := s.LinksByRefType(ctx, "gitea-repo")
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
mb, err := r.LinksByRefType(ctx, "gitea-repo")
|
|
if err != nil {
|
|
t.Fatalf("mbrian: %v", err)
|
|
}
|
|
if len(leg) != len(mb) {
|
|
t.Errorf("count: store=%d mbrian=%d", len(leg), len(mb))
|
|
}
|
|
legSeen := map[string]bool{}
|
|
for _, l := range leg {
|
|
legSeen[l.RefID] = true
|
|
}
|
|
for _, m := range mb {
|
|
if !legSeen[m.RefID] {
|
|
t.Errorf("mbrian gitea-repo RefID %q not in store set", m.RefID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParityAllTags: tag union must match (modulo ordering).
|
|
func TestParityAllTags(t *testing.T) {
|
|
s, r, pool := newPair(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
leg, err := s.AllTags(ctx)
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
mb, err := r.AllTags(ctx)
|
|
if err != nil {
|
|
t.Fatalf("mbrian: %v", err)
|
|
}
|
|
if !sameSet(leg, mb) {
|
|
t.Errorf("AllTags mismatch:\n store=%v\n mbrian=%v", leg, mb)
|
|
}
|
|
}
|
|
|
|
// TestParityNotFound: an unknown slug must 404 from both.
|
|
func TestParityNotFound(t *testing.T) {
|
|
s, r, pool := newPair(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
_, le := s.GetByPathOrSlug(ctx, "definitely-not-a-real-slug-zzzzz")
|
|
_, me := r.GetByPathOrSlug(ctx, "definitely-not-a-real-slug-zzzzz")
|
|
if !errors.Is(le, store.ErrNotFound) {
|
|
t.Errorf("store should ErrNotFound, got %v", le)
|
|
}
|
|
if !errors.Is(me, store.ErrNotFound) {
|
|
t.Errorf("mbrian should ErrNotFound, got %v", me)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func bySlug(items []*store.Item) map[string]*store.Item {
|
|
out := map[string]*store.Item{}
|
|
for _, it := range items {
|
|
out[it.Slug] = it
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sameSet(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
ac := append([]string{}, a...)
|
|
bc := append([]string{}, b...)
|
|
sort.Strings(ac)
|
|
sort.Strings(bc)
|
|
for i := range ac {
|
|
if ac[i] != bc[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|