From f30898f3ec80e58f7635272069002c72ea5659ea Mon Sep 17 00:00:00 2001 From: Multica Eve Date: Thu, 9 Jul 2026 14:40:14 +0800 Subject: [PATCH] MUL-4299: guard migration numbering Closes MUL-4299 --- .../migrations/migrations_lint_test.go | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 server/internal/migrations/migrations_lint_test.go diff --git a/server/internal/migrations/migrations_lint_test.go b/server/internal/migrations/migrations_lint_test.go new file mode 100644 index 000000000..7b570eab8 --- /dev/null +++ b/server/internal/migrations/migrations_lint_test.go @@ -0,0 +1,184 @@ +package migrations + +import ( + "fmt" + "path/filepath" + "reflect" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "testing" +) + +const maxLegacyMigrationPrefix = 148 + +var legacyDuplicateMigrationStems = map[string][]string{ + "020": {"020_issue_number", "020_task_session"}, + "026": {"026_comment_reactions", "026_task_messages"}, + "029": {"029_attachment", "029_daemon_token", "029_drop_daemon_pairing"}, + "032": {"032_drop_agent_triggers", "032_issue_search_index", "032_runtime_owner", "032_task_usage"}, + "033": {"033_chat", "033_comment_search_index"}, + "035": {"035_project_priority", "035_task_queue_issue_id_index"}, + "040": {"040_agent_custom_env", "040_chat_unread_since"}, + "041": {"041_agent_custom_args", "041_workspace_invitation"}, + "043": {"043_audit_reserved_slugs", "043_fix_orphaned_autopilot_runs"}, + "046": {"046_agent_mcp_config", "046_agent_unique_name", "046_drop_runtime_usage"}, + "050": {"050_add_onboarded_at_to_users", "050_agent_model", "050_issue_first_executed_at"}, + "060": {"060_add_user_language", "060_agent_description_length", "060_chat_session_runtime_id", "060_issue_origin_quick_create"}, + "065": {"065_backfill_onboarded_at", "065_project_resources"}, + "069": {"069_comment_resolved_at", "069_drop_task_last_heartbeat"}, + "079": {"079_autopilot_run_skipped_status", "079_backfill_api_invalid_request", "079_github_integration"}, + "083": {"083_attachment_chat_columns", "083_runtime_visibility"}, + "084": {"084_squad", "084_task_usage_dashboard_rollup"}, + "091": {"091_autopilot_webhook_triggers", "091_issue_start_date", "091_pr_ci_conflict"}, + "095": {"095_agent_thinking_level", "095_backfill_starter_content_state"}, + "096": {"096_autopilot_squad_assignee", "096_pending_check_suite", "096_user_profile_description"}, + "098": {"098_contact_sales_inquiries", "098_user_onboarding_runtime_choice"}, + "109": {"109_agent_task_waiting_local_directory", "109_drop_agent_skills_local", "109_issue_pull_request_close_intent", "109_lark_integration"}, + "111": {"111_issue_origin_lark_chat", "111_workspace_avatar"}, + "112": {"112_issue_dates_to_date", "112_lark_installation_bot_union_id"}, + "113": {"113_lark_inbound_dedup_per_installation", "113_sys_cron_executions"}, + "120": {"120_autopilot_subscriber", "120_comment_source_task_id", "120_github_pending_installation", "120_runtime_profile"}, + "122": {"122_lark_chat_session_binding_thread_reply", "122_task_handoff_note"}, + "124": {"124_autopilot_run_planned_at", "124_channel_generalization", "124_task_prepare_lease"}, + "127": {"127_issue_pull_request_reference_only", "127_task_squad_id", "127_user_composio_connection"}, + "128": {"128_agent_task_queue_runtime_mcp_overlay", "128_autopilot_collaborator", "128_comment_routing_escalation"}, + "145": {"145_agent_runtime_custom_name", "145_agent_task_coalesced_comments", "145_chat_read_cursor"}, +} + +var migrationPrefixPattern = regexp.MustCompile(`^(\d+)_`) + +func TestMigrationFilesHaveMatchingDirections(t *testing.T) { + files := migrationFilesForLint(t, "*.sql") + + directionsByStem := make(map[string]map[string]bool) + for _, file := range files { + stem, direction, ok := splitMigrationFilename(filepath.Base(file)) + if !ok { + continue + } + if directionsByStem[stem] == nil { + directionsByStem[stem] = make(map[string]bool) + } + directionsByStem[stem][direction] = true + } + + for stem, directions := range directionsByStem { + if !directions["up"] || !directions["down"] { + t.Errorf("migration %s must have both .up.sql and .down.sql files", stem) + } + } +} + +func TestMigrationNumericPrefixesStayUniqueAfterLegacySet(t *testing.T) { + stemsByPrefix := migrationStemsByPrefix(t) + + for prefix, stems := range stemsByPrefix { + sort.Strings(stems) + + legacyStems, isLegacyDuplicate := legacyDuplicateMigrationStems[prefix] + if isLegacyDuplicate { + expected := append([]string(nil), legacyStems...) + sort.Strings(expected) + if !reflect.DeepEqual(stems, expected) { + t.Errorf("legacy duplicate migration prefix %s changed: got %v, want %v; do not add to or rename historical duplicate-prefix migrations", prefix, stems, expected) + } + continue + } + + if len(stems) > 1 { + t.Errorf("migration prefix %s is reused by %v; use the next unique prefix instead", prefix, stems) + } + } +} + +func TestNewMigrationPrefixesStartAfterLegacyRange(t *testing.T) { + stemsByPrefix := migrationStemsByPrefix(t) + + for prefix, stems := range stemsByPrefix { + n, err := strconv.Atoi(prefix) + if err != nil { + t.Fatalf("parse migration prefix %q: %v", prefix, err) + } + if n <= maxLegacyMigrationPrefix && !isKnownLegacyPrefix(prefix) { + t.Errorf("migration prefix %s is in the frozen legacy range 001-%03d: %v; new migrations must start at %03d", prefix, maxLegacyMigrationPrefix, stems, maxLegacyMigrationPrefix+1) + } + } +} + +func migrationStemsByPrefix(t *testing.T) map[string][]string { + t.Helper() + + files := migrationFilesForLint(t, "*.up.sql") + stemsByPrefix := make(map[string][]string) + for _, file := range files { + stem := strings.TrimSuffix(filepath.Base(file), ".up.sql") + match := migrationPrefixPattern.FindStringSubmatch(stem) + if match == nil { + t.Fatalf("migration %s does not start with a numeric prefix followed by underscore", stem) + } + stemsByPrefix[match[1]] = append(stemsByPrefix[match[1]], stem) + } + return stemsByPrefix +} + +func migrationFilesForLint(t *testing.T, pattern string) []string { + t.Helper() + + dir := realMigrationsDir(t) + files, err := filepath.Glob(filepath.Join(dir, pattern)) + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatalf("no migration files matched %s in %s", pattern, dir) + } + sort.Strings(files) + return files +} + +func realMigrationsDir(t *testing.T) string { + t.Helper() + + _, self, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve migration lint test path") + } + return filepath.Clean(filepath.Join(filepath.Dir(self), "..", "..", "migrations")) +} + +func splitMigrationFilename(name string) (stem, direction string, ok bool) { + for _, candidateDirection := range []string{"up", "down"} { + suffix := fmt.Sprintf(".%s.sql", candidateDirection) + if strings.HasSuffix(name, suffix) { + return strings.TrimSuffix(name, suffix), candidateDirection, true + } + } + return "", "", false +} + +func isKnownLegacyPrefix(prefix string) bool { + if _, ok := legacyDuplicateMigrationStems[prefix]; ok { + return true + } + + switch prefix { + case "001", "002", "003", "004", "005", "006", "007", "008", "009", "010", + "011", "012", "013", "014", "015", "016", "017", "018", "019", "021", + "022", "023", "024", "025", "027", "028", "030", "031", "034", "036", + "037", "038", "039", "042", "044", "045", "047", "048", "049", "051", + "052", "053", "054", "055", "056", "057", "058", "059", "061", "062", + "063", "064", "066", "067", "068", "072", "073", "074", "075", "076", + "077", "078", "080", "081", "082", "085", "086", "087", "088", "089", + "090", "092", "093", "094", "097", "100", "101", "102", "103", "104", + "105", "106", "107", "108", "110", "114", "115", "116", "117", "118", + "119", "121", "123", "125", "126", "129", "130", "131", "132", "133", + "134", "135", "136", "137", "138", "139", "140", "141", "142", "143", + "144", "146", "147", "148": + return true + default: + return false + } +}