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.
350 lines
11 KiB
Go
350 lines
11 KiB
Go
// 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)
|
|
}
|