Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MigratorConfig holds portable CLI options for MySQL → Postgres ETL.
|
||||
type MigratorConfig struct {
|
||||
MySQLDSN string
|
||||
PostgresURL string
|
||||
DryRun bool
|
||||
MapsDir string
|
||||
IDMapPath string
|
||||
ReportDir string
|
||||
FixturePath string
|
||||
Resume bool
|
||||
CompanyFilter []string // legacy company ids; empty = all
|
||||
Domains domainSet
|
||||
SkipPostImport bool
|
||||
EnsureDemo bool
|
||||
DemoEmail string
|
||||
DemoPassword string
|
||||
DemoName string
|
||||
LocalDemoCo string
|
||||
}
|
||||
|
||||
type domainSet map[string]bool
|
||||
|
||||
func parseDomains(raw string) domainSet {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
if raw == "" || raw == "all" {
|
||||
return domainSet{"all": true}
|
||||
}
|
||||
out := domainSet{}
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out[p] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return domainSet{"all": true}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d domainSet) has(name string) bool {
|
||||
if d == nil || d["all"] {
|
||||
return true
|
||||
}
|
||||
return d[name]
|
||||
}
|
||||
|
||||
func (d domainSet) String() string {
|
||||
if d == nil || d["all"] {
|
||||
return "all"
|
||||
}
|
||||
parts := make([]string, 0, len(d))
|
||||
for k := range d {
|
||||
parts = append(parts, k)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func parseCompanyFilter(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func companyFilterSet(ids []string) map[string]bool {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
m[id] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func filterCompanies(rows []companyRow, allow map[string]bool) []companyRow {
|
||||
if allow == nil {
|
||||
return rows
|
||||
}
|
||||
out := make([]companyRow, 0, len(rows))
|
||||
for _, c := range rows {
|
||||
if allow[c.LegacyID] {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mysqlCompanyFilter appends AND company_id IN (...) when a filter is set.
|
||||
// Values are bound as ? placeholders; the column path is validated and quoted.
|
||||
func mysqlCompanyFilter(column string, allow map[string]bool) (clause string, args []any) {
|
||||
if len(allow) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
quotedCol, err := quoteMySQLIdentPath(column)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ids := make([]string, 0, len(allow))
|
||||
for id := range allow {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args = make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
return fmt.Sprintf(" AND %s IN (%s)", quotedCol, strings.Join(placeholders, ",")), args
|
||||
}
|
||||
|
||||
// MigrationRunReport is the portable JSON artifact written after each run.
|
||||
type MigrationRunReport struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Mode string `json:"mode"`
|
||||
Domains string `json:"domains"`
|
||||
CompanyFilter []string `json:"company_filter,omitempty"`
|
||||
Resume bool `json:"resume"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
Validation any `json:"validation,omitempty"`
|
||||
Demo *DemoReport `json:"demo,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
// DemoReport documents the ensure-demo outcome (no password plaintext).
|
||||
type DemoReport struct {
|
||||
Email string `json:"email"`
|
||||
PasswordSet bool `json:"password_set"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
PrimaryCompany string `json:"primary_company,omitempty"`
|
||||
PrimaryName string `json:"primary_company_name,omitempty"`
|
||||
Memberships int64 `json:"memberships_admin"`
|
||||
PlatformAdmin bool `json:"platform_admin"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
func newRunReport(cfg MigratorConfig, dryRun bool) *MigrationRunReport {
|
||||
mode := "live"
|
||||
if dryRun {
|
||||
mode = "dry-run"
|
||||
}
|
||||
return &MigrationRunReport{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Mode: mode,
|
||||
Domains: cfg.Domains.String(),
|
||||
CompanyFilter: append([]string(nil), cfg.CompanyFilter...),
|
||||
Resume: cfg.Resume,
|
||||
Counts: map[string]int{},
|
||||
Notes: []string{
|
||||
"Clerk is excluded: users mapped by email only; no Clerk API.",
|
||||
"Legacy password hashes are never imported.",
|
||||
"API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).",
|
||||
"File blobs are metadata-only; resync object storage separately.",
|
||||
"Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.",
|
||||
"company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.",
|
||||
"woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled.",
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user