feat(snapshot): Phase 6 slice 0 — projax_snapshot.json export helper
Read-only export of projax.items + projax.item_links to a JSON file the mBrian-side migration script (m/mBrian#73) consumes. First implementation slice of the Phase 6 mBrian-backend migration. Tool: - cmd/projax-snapshot/main.go: standalone binary, takes --out flag (default ./projax_snapshot.json). Reads PROJAX_DB_URL or SUPABASE_DATABASE_URL like the main projax binary. - Pure read-only: SELECT FROM projax.items WHERE deleted_at IS NULL + SELECT FROM projax.item_links. No writes, no schema changes. - Re-runnable: each invocation produces a fresh deterministic file; no state, no DB side effects. Output shape (Snapshot struct): - version: "1" — bumped on shape changes for downstream version-pinning. - generated_at: timestamp. - items: every live projax.items row with all columns mapped 1:1 to JSON-friendly types (uuid → string, jsonb → map, timestamptz → RFC3339). Empty slices coerced to [] so the mBrian-side script doesn't see null-array surprises. - links: every projax.item_links row, ordered by item_id + ref_type for stable diffs across runs. - spot_checks: the 5 representative items the mBrian-side script verifies post-migration per m/mBrian#73 §3. Selected at runtime by characteristic (root area, single-parent, multi-parent, caldav-linked, public-listing-populated) so the picks self-update as the dataset evolves. Smoke-tested against the live msupabase dataset: wrote /tmp/projax_snapshot.json — 65 items, 81 links, 5 spot-checks Selected spot-checks (live): dev — root area paliad — single-parent project services — multi-parent (2 parents) mhome — caldav-list-linked fdbck — public-listing populated Out of scope (slices B+ pick up): - The mBrian-side script itself lives in m/mBrian per "mbrian must own the migration" (Q4=(a)). - projax-side adapter rewriting waits on the mBrian-side migration run. - No tests yet: this is a one-off helper against live data; smoke run above is the validation surface. A go-test suite can land if the snapshot shape needs evolution before mBrian-side consumes it.
This commit is contained in:
349
cmd/projax-snapshot/main.go
Normal file
349
cmd/projax-snapshot/main.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user