package main import ( "encoding/json" "fmt" "log" "os" "path/filepath" "github.com/google/uuid" ) // MigratorFixture is a minimal offline dump for dry-run without MySQL. // It is NOT a substitute for validating against production MySQL. type MigratorFixture struct { Companies []struct { ID string `json:"id"` Name string `json:"name"` Language string `json:"language"` } `json:"companies"` Users []struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Active bool `json:"active"` } `json:"users"` AdminUsers []struct { UserID string `json:"user_id"` Email string `json:"email"` } `json:"admin_users"` Profiles []struct { CompanyID string `json:"company_id"` UserID string `json:"user_id"` Role string `json:"role"` Status string `json:"status"` } `json:"profiles"` XMLFeeds []struct { ID int64 `json:"id"` CompanyID string `json:"company_id"` Name string `json:"name"` FieldMappings json.RawMessage `json:"field_mappings"` } `json:"xml_feeds"` Files []struct { ID int64 `json:"id"` CompanyID string `json:"company_id"` FileName string `json:"file_name"` } `json:"files"` RawProducts int `json:"raw_products_count"` } func loadFixture(path string) (*MigratorFixture, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } var f MigratorFixture if err := json.Unmarshal(b, &f); err != nil { return nil, err } return &f, nil } // runFixtureDryRun remaps fixture rows and writes id-map + validation-style counts // without connecting to MySQL or Postgres. func runFixtureDryRun(fixturePath, mapsDir, idMapPath string) { fx, err := loadFixture(fixturePath) if err != nil { log.Fatalf("fixture: %v", err) } if err := os.MkdirAll(mapsDir, 0o755); err != nil { log.Fatalf("maps dir: %v", err) } outIDMap := idMapPath if outIDMap == "" { outIDMap = filepath.Join(mapsDir, "id-map.json") } report := map[string]int{} companyMap := map[string]string{} userMap := map[string]string{} feedMap := map[string]string{} fileMap := map[string]string{} for _, c := range fx.Companies { companyMap[c.ID] = uuid.New().String() report["companies"]++ } for _, u := range fx.Users { userMap[u.ID] = uuid.New().String() report["users"]++ } for _, a := range fx.AdminUsers { if _, ok := userMap[a.UserID]; ok { report["admin_users"]++ } else { report["admin_users_unmatched"]++ } } for _, p := range fx.Profiles { if _, okC := companyMap[p.CompanyID]; !okC { report["memberships_skipped"]++ continue } if _, okU := userMap[p.UserID]; !okU { report["memberships_skipped"]++ continue } report["memberships"]++ } for _, f := range fx.XMLFeeds { if _, ok := companyMap[f.CompanyID]; !ok { report["input_feeds_skipped"]++ continue } feedMap[fmt.Sprintf("%d", f.ID)] = uuid.New().String() report["input_feeds"]++ if len(f.FieldMappings) > 0 && string(f.FieldMappings) != "null" && string(f.FieldMappings) != "{}" { report["feed_mappings"]++ } } for _, f := range fx.Files { if _, ok := companyMap[f.CompanyID]; !ok { report["files_skipped"]++ continue } fileMap[fmt.Sprintf("%d", f.ID)] = uuid.New().String() report["files"]++ } report["raw_products"] = fx.RawProducts idDoc := NewIDMapDocument(userMap, companyMap, true) idDoc.Source = "fixture" idDoc.AttachEntityMaps(nil, nil, feedMap, nil, fileMap, report) if err := WriteIDMap(outIDMap, idDoc); err != nil { log.Fatalf("id-map: %v", err) } writeEntityMapFiles(mapsDir, companyMap, userMap, nil, nil, feedMap, nil, fileMap) counts := []CountPair{ {Entity: "companies", MySQL: int64(len(fx.Companies)), Note: "fixture"}, {Entity: "users", MySQL: int64(len(fx.Users)), Note: "fixture; password_hash never imported"}, {Entity: "admin_users", MySQL: int64(len(fx.AdminUsers)), Note: "→ is_platform_admin"}, {Entity: "profiles", MySQL: int64(len(fx.Profiles)), Note: "→ memberships"}, {Entity: "xml_feeds", MySQL: int64(len(fx.XMLFeeds)), Note: "→ input_feeds + feed_mappings"}, {Entity: "files", MySQL: int64(len(fx.Files)), Note: "metadata only"}, {Entity: "raw_products", MySQL: int64(fx.RawProducts), Note: "count-only in fixture"}, } v := ValidationReport{ Mode: "fixture-dry-run", Counts: counts, Orphans: []OrphanFinding{{ Check: "skipped_fixture", Pass: true, Sample: "orphan checks need live Postgres after a real load", }}, OK: true, } v.OrphanSummary = summarizeOrphans(v.Orphans) printValidation(v) _ = writeJSON(filepath.Join(mapsDir, "validation-report.json"), v) printMigrationReport(report, true) fmt.Printf("wrote maps under %s (unified: %s)\n", mapsDir, outIDMap) fmt.Println("BLOCKER: fixture mode is not a substitute for dry-run against production MySQL — set MIGRATE_MYSQL_DSN and re-run before cutover.") }