diff --git a/cmd/projax-snapshot/main.go b/cmd/projax-snapshot/main.go new file mode 100644 index 0000000..3eb98c3 --- /dev/null +++ b/cmd/projax-snapshot/main.go @@ -0,0 +1,349 @@ +// projax-snapshot dumps the current projax.items + projax.item_links state +// to a JSON file so the mBrian-side migration script (m/mBrian#73) can +// consume it. Read-only; no schema changes; idempotent across runs. +// +// Phase 6 Slice 0 — first projax-side step in the mBrian-backend migration. +// See docs/plans/mbrian-backend-migration.md §7 + §8 for the surrounding +// context. The file shape is documented in the m/mBrian#73 issue body +// (the two-pass node-then-edge layout the migration script expects). +// +// Usage: +// +// projax-snapshot # write ./projax_snapshot.json +// projax-snapshot --out path/to/file.json # custom output path +// +// Env: PROJAX_DB_URL or SUPABASE_DATABASE_URL — direct postgres URL into +// msupabase (same conventions as the main projax binary). +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Snapshot is the top-level JSON shape mBrian-side consumes. +type Snapshot struct { + Version string `json:"version"` // doc-evolution marker; bump on shape changes + GeneratedAt time.Time `json:"generated_at"` + GitCommit string `json:"git_commit,omitempty"` // optional build-time injection + Items []Item `json:"items"` + Links []ItemLink `json:"links"` + SpotChecks []SpotCheck `json:"spot_checks"` // 5 representative items per m/mBrian#73 §3 +} + +// Item mirrors every column on projax.items as of this commit. Field +// order matches the SQL projection; types are JSON-friendly (uuid → +// string, jsonb → map). Anything nullable surfaces as omitempty / *T. +type Item struct { + ID string `json:"id"` + Kind []string `json:"kind"` + Title string `json:"title"` + Slug string `json:"slug"` + Paths []string `json:"paths"` + ParentIDs []string `json:"parent_ids"` + ContentMD string `json:"content_md"` + Aliases []string `json:"aliases"` + Metadata map[string]any `json:"metadata"` + Status string `json:"status"` + Pinned bool `json:"pinned"` + Archived bool `json:"archived"` + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + Tags []string `json:"tags"` + Management []string `json:"management"` + Public bool `json:"public"` + PublicDescription string `json:"public_description,omitempty"` + PublicLiveURL string `json:"public_live_url,omitempty"` + PublicSourceURL string `json:"public_source_url,omitempty"` + PublicScreenshots []string `json:"public_screenshots,omitempty"` + TimelineExclude []string `json:"timeline_exclude,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ItemLink mirrors projax.item_links. ref_type values become projax-* +// edge rel names on the mBrian side; the payload lands in edges.metadata +// per the issue body §1. +type ItemLink struct { + ID string `json:"id"` + ItemID string `json:"item_id"` + RefType string `json:"ref_type"` + RefID string `json:"ref_id"` + Rel string `json:"rel"` + Note *string `json:"note,omitempty"` + Metadata map[string]any `json:"metadata"` + EventDate *time.Time `json:"event_date,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// SpotCheck names one of the 5 representative items the mBrian-side +// script verifies post-migration. The reason text is mirrored from +// m/mBrian#73 §3 so future readers don't need to cross-reference. +type SpotCheck struct { + ItemID string `json:"item_id"` + Slug string `json:"slug"` + Title string `json:"title"` + Reason string `json:"reason"` +} + +func main() { + out := flag.String("out", "projax_snapshot.json", "output JSON path") + flag.Parse() + + dbURL := os.Getenv("PROJAX_DB_URL") + if dbURL == "" { + dbURL = os.Getenv("SUPABASE_DATABASE_URL") + } + if dbURL == "" { + die("set PROJAX_DB_URL or SUPABASE_DATABASE_URL") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + die("pool: %v", err) + } + defer pool.Close() + + items, err := loadItems(ctx, pool) + if err != nil { + die("load items: %v", err) + } + links, err := loadLinks(ctx, pool) + if err != nil { + die("load links: %v", err) + } + spots := pickSpotChecks(items, links) + + snap := Snapshot{ + Version: "1", + GeneratedAt: time.Now().UTC(), + Items: items, + Links: links, + SpotChecks: spots, + } + + buf, err := json.MarshalIndent(snap, "", " ") + if err != nil { + die("marshal: %v", err) + } + if err := os.WriteFile(*out, buf, 0644); err != nil { + die("write %s: %v", *out, err) + } + fmt.Fprintf(os.Stderr, + "wrote %s — %d items, %d links, %d spot-checks\n", + *out, len(items), len(links), len(spots)) +} + +func loadItems(ctx context.Context, pool *pgxpool.Pool) ([]Item, error) { + rows, err := pool.Query(ctx, ` +SELECT id, kind, title, slug, paths, parent_ids, content_md, aliases, + metadata, status, pinned, archived, start_time, end_time, + tags, management, + public, coalesce(public_description, ''), + coalesce(public_live_url, ''), + coalesce(public_source_url, ''), + public_screenshots, + timeline_exclude, + created_at, updated_at +FROM projax.items +WHERE deleted_at IS NULL +ORDER BY paths NULLS FIRST, slug`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Item{} + for rows.Next() { + var it Item + if err := rows.Scan( + &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.Tags, &it.Management, + &it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL, + &it.PublicScreenshots, &it.TimelineExclude, &it.CreatedAt, &it.UpdatedAt, + ); err != nil { + return nil, err + } + // Normalise empty slices: pgx hands back nil for empty array + // columns, which renders as `null` in JSON. Coerce to [] for + // downstream-script ergonomics. + if it.Kind == nil { + it.Kind = []string{} + } + if it.Paths == nil { + it.Paths = []string{} + } + if it.ParentIDs == nil { + it.ParentIDs = []string{} + } + if it.Aliases == nil { + it.Aliases = []string{} + } + if it.Tags == nil { + it.Tags = []string{} + } + if it.Management == nil { + it.Management = []string{} + } + if it.PublicScreenshots == nil { + it.PublicScreenshots = []string{} + } + if it.TimelineExclude == nil { + it.TimelineExclude = []string{} + } + if it.Metadata == nil { + it.Metadata = map[string]any{} + } + out = append(out, it) + } + return out, rows.Err() +} + +func loadLinks(ctx context.Context, pool *pgxpool.Pool) ([]ItemLink, error) { + rows, err := pool.Query(ctx, ` +SELECT id, item_id, ref_type, ref_id, rel, note, metadata, + event_date, created_at +FROM projax.item_links +ORDER BY item_id, ref_type, created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ItemLink{} + for rows.Next() { + var l ItemLink + if err := rows.Scan( + &l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, + &l.Metadata, &l.EventDate, &l.CreatedAt, + ); err != nil { + return nil, err + } + if l.Metadata == nil { + l.Metadata = map[string]any{} + } + out = append(out, l) + } + return out, rows.Err() +} + +// pickSpotChecks selects the 5 representative items the mBrian-side +// migration script verifies post-migration, per m/mBrian#73 §3: +// +// 1. A simple root area (dev). +// 2. A single-parent project (dev.paliad — or whichever single-parent +// project we can find). +// 3. A multi-parent project (any item with >1 parent_id). +// 4. A project with a caldav-list link. +// 5. A project with public=true and public_description / public_live_url +// populated. +// +// Failures to find any one of the 5 are non-fatal — the SpotChecks slice +// just shrinks. mBrian-side script logs whatever's missing. +func pickSpotChecks(items []Item, links []ItemLink) []SpotCheck { + byID := map[string]*Item{} + for i := range items { + byID[items[i].ID] = &items[i] + } + caldavItems := map[string]bool{} + for _, l := range links { + if l.RefType == "caldav-list" { + caldavItems[l.ItemID] = true + } + } + out := []SpotCheck{} + + // 1. Root area "dev" if present. + for _, it := range items { + if it.Slug == "dev" && len(it.ParentIDs) == 0 { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "root area (dev) — verify type=['project'] + metadata.projax.kind='area' round-trip", + }) + break + } + } + + // 2. Single-parent project — prefer dev.paliad if present, else any. + added2 := false + for _, it := range items { + if it.Slug == "paliad" && len(it.ParentIDs) == 1 { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "single-parent project (dev.paliad) — verify one child_of edge", + }) + added2 = true + break + } + } + if !added2 { + for _, it := range items { + if len(it.ParentIDs) == 1 && !containsString(it.Kind, "mai-managed") { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "single-parent project — verify one child_of edge", + }) + break + } + } + } + + // 3. Multi-parent project — any item with cardinality(parent_ids) > 1. + for _, it := range items { + if len(it.ParentIDs) > 1 { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: fmt.Sprintf("multi-parent project (%d parents) — verify all child_of edges land", len(it.ParentIDs)), + }) + break + } + } + + // 4. Project with a caldav-list link. + for _, it := range items { + if caldavItems[it.ID] { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "caldav-list-linked project — verify edges.metadata.url payload round-trip", + }) + break + } + } + + // 5. Project with public=true + public_description populated. + for _, it := range items { + if it.Public && it.PublicDescription != "" { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "public-listing project — verify metadata.projax.public.* bundle preserved for flexsiebels renderer", + }) + break + } + } + + // Stable order for deterministic output. + sort.SliceStable(out, func(i, j int) bool { return out[i].Slug < out[j].Slug }) + return out +} + +func containsString(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +}