diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 421d667..c2d9316 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -100,7 +100,7 @@ repos: hooks: - id: eslint files: ^ffm/.*\.(js|jsx|ts|tsx)$ - exclude: ^ffm/(node_modules|dist|build)/ + exclude: ^ffm/(node_modules|dist|build)/|^ffm/public/runtime-config\.js$ additional_dependencies: - eslint@^8.56.0 - '@typescript-eslint/parser@^8.47.0' diff --git a/README.md b/README.md index 78af317..04c8a13 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Backend For Migrations (BfM)
-BfM is a comprehensive database migration system that supports multiple backends (PostgreSQL, GreptimeDB, Etcd) with HTTP and Protobuf APIs. +BfM is a comprehensive database migration system that supports multiple backends (PostgreSQL, GreptimeDB, Etcd) with HTTP and Protobuf APIs. It centralizes migrations so that in scalable deployments many applications do not run the same migrations concurrently. It also supports dynamic schema creation, which fits workloads that use many schemas and need to create them on demand. ## Features diff --git a/api/cmd/server/auto_migrate.go b/api/cmd/server/auto_migrate.go new file mode 100644 index 0000000..f991e19 --- /dev/null +++ b/api/cmd/server/auto_migrate.go @@ -0,0 +1,276 @@ +package main + +import ( + "context" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/toolsascode/bfm/api/internal/backends" + "github.com/toolsascode/bfm/api/internal/config" + "github.com/toolsascode/bfm/api/internal/executor" + "github.com/toolsascode/bfm/api/internal/logger" + "github.com/toolsascode/bfm/api/internal/registry" +) + +// autoMigrateDefaultOn is true when BFM_AUTO_MIGRATE is unset. Set BFM_AUTO_MIGRATE=false to disable. +func autoMigrateEnabled() bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv("BFM_AUTO_MIGRATE"))) + if v == "" { + return true + } + return v != "false" && v != "0" && v != "off" && v != "no" +} + +// etcdEndpointsExtraNonEmpty returns true if Extra has a non-empty endpoints value (any key casing). +func etcdEndpointsExtraNonEmpty(extra map[string]string) bool { + if extra == nil { + return false + } + for k, v := range extra { + if strings.EqualFold(strings.TrimSpace(k), "endpoints") && strings.TrimSpace(v) != "" { + return true + } + } + return false +} + +// connectionConfigReadyForAutoMigrate reports whether conn has the minimum fields the corresponding +// backend expects, so we do not dial empty hosts or etcd with no endpoints (avoids log spam). +func connectionConfigReadyForAutoMigrate(conn *backends.ConnectionConfig) bool { + if conn == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(conn.Backend)) { + case "postgresql": + return strings.TrimSpace(conn.Host) != "" + case "greptimedb": + return strings.TrimSpace(conn.Host) != "" + case "etcd": + if etcdEndpointsExtraNonEmpty(conn.Extra) { + return true + } + return strings.TrimSpace(conn.Host) != "" && strings.TrimSpace(conn.Port) != "" + default: + return true + } +} + +// autoMigrateRetryInterval returns the pause between full auto-migrate rounds. If the env value is +// zero or negative, only one round is run (legacy single-pass behavior after the startup delay). +func autoMigrateRetryInterval() time.Duration { + v := strings.TrimSpace(os.Getenv("BFM_AUTO_MIGRATE_RETRY_INTERVAL")) + if v == "" { + return 5 * time.Second + } + d, err := time.ParseDuration(v) + if err != nil { + return 5 * time.Second + } + return d +} + +// autoMigrateRetryMaxRounds caps how many full passes run over all ready connections. +// When retryInterval is <= 0, this is forced to 1. +func autoMigrateRetryMaxRounds(retryInterval time.Duration) int { + if retryInterval <= 0 { + return 1 + } + v := strings.TrimSpace(os.Getenv("BFM_AUTO_MIGRATE_RETRY_MAX_ROUNDS")) + if v == "" { + return 24 + } + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + return 24 + } + return n +} + +type autoMigrateConn struct { + name string + cfg *backends.ConnectionConfig +} + +func sumPendingAutoMigratable(ctx context.Context, exec *executor.Executor, conns []autoMigrateConn) (int, error) { + total := 0 + for _, c := range conns { + select { + case <-ctx.Done(): + return total, ctx.Err() + default: + } + n, err := exec.CountPendingAutoMigratable(ctx, c.name, c.cfg.Backend) + if err != nil { + return total, err + } + total += n + } + return total, nil +} + +// startAutoMigrateBackground runs pending migrations per configured connection after startup, +// retrying in bounded rounds until fixed-schema work is cleared, a stall is detected, or limits hit. +// It uses the same ExecuteUp path as the HTTP API (synchronous execution, not the job queue). +// +// Limitations (documented for operators): +// - Migrations with dynamic schema (empty migration.Schema) require an explicit schema in the request; +// auto-migrate passes an empty schema, so those migrations are skipped with an info log until run manually with schemas. +// - Optional BFM_AUTO_MIGRATE_CONNECTIONS (comma-separated) restricts which connection names are processed; +// if unset, all connections from config are attempted. +// - Connections with incomplete config for their backend (e.g. etcd without endpoints or host+port) are skipped. +func startAutoMigrateBackground(ctx context.Context, exec *executor.Executor, cfg *config.Config) { + if !autoMigrateEnabled() { + logger.Info("BFM_AUTO_MIGRATE is disabled; skipping startup auto-migrate") + return + } + + filterRaw := strings.TrimSpace(os.Getenv("BFM_AUTO_MIGRATE_CONNECTIONS")) + var allow map[string]bool + if filterRaw != "" { + allow = make(map[string]bool) + for _, p := range strings.Split(filterRaw, ",") { + k := strings.TrimSpace(strings.ToLower(p)) + if k != "" { + allow[k] = true + } + } + } + + go func() { + select { + case <-ctx.Done(): + return + case <-time.After(2 * time.Second): + } + + retryInterval := autoMigrateRetryInterval() + maxRounds := autoMigrateRetryMaxRounds(retryInterval) + if retryInterval > 0 { + logger.Infof("Auto-migrate: retry enabled (interval=%s, max_rounds=%d)", retryInterval, maxRounds) + } else { + logger.Info("Auto-migrate: single round only (BFM_AUTO_MIGRATE_RETRY_INTERVAL is 0 or invalid)") + } + + connNames := make([]string, 0, len(cfg.Connections)) + for name := range cfg.Connections { + connNames = append(connNames, name) + } + sort.Strings(connNames) + + var toRun []autoMigrateConn + for _, connName := range connNames { + if allow != nil && !allow[strings.ToLower(connName)] { + continue + } + connCfg := cfg.Connections[connName] + if connCfg == nil { + continue + } + if !connectionConfigReadyForAutoMigrate(connCfg) { + logger.Infof("Auto-migrate: skipping connection %q (backend=%s): incomplete connection config for auto-migrate", connName, connCfg.Backend) + continue + } + toRun = append(toRun, autoMigrateConn{name: connName, cfg: connCfg}) + } + + for round := 1; round <= maxRounds; round++ { + select { + case <-ctx.Done(): + logger.Info("Auto-migrate cancelled during shutdown") + return + default: + } + + pendingBefore, err := sumPendingAutoMigratable(ctx, exec, toRun) + if err != nil { + logger.Errorf("Auto-migrate: failed to count pending migrations: %v", err) + break + } + if pendingBefore == 0 { + logger.Info("Auto-migrate: no pending fixed-schema migrations for ready connections") + logger.Info("Auto-migrate: startup pass completed") + return + } + + logger.Infof("Auto-migrate: round %d/%d (%d pending fixed-schema migration(s) across ready connections)", round, maxRounds, pendingBefore) + + anyApplied := false + anyErr := false + for _, cr := range toRun { + select { + case <-ctx.Done(): + logger.Info("Auto-migrate cancelled during shutdown") + return + default: + } + + target := ®istry.MigrationTarget{ + Backend: cr.cfg.Backend, + Connection: cr.name, + } + runCtx := executor.WithAutoMigrateContext(executor.SetExecutionContext(context.Background(), "bfm-server", "auto_migrate", map[string]interface{}{ + "connection": cr.name, + "source": "BFM_AUTO_MIGRATE", + "round": round, + })) + + logger.Infof("Auto-migrate: running pending migrations for connection %q (backend=%s)", cr.name, cr.cfg.Backend) + result, err := exec.ExecuteUp(runCtx, target, cr.name, []string{""}, false, false) + if err != nil { + anyErr = true + logger.Errorf("Auto-migrate: ExecuteUp failed for connection %q: %v", cr.name, err) + continue + } + if len(result.Applied) > 0 { + anyApplied = true + logger.Infof("Auto-migrate: applied for %q: %v", cr.name, result.Applied) + } + if len(result.Skipped) > 0 { + logger.Debug("Auto-migrate: skipped for %q (already applied): %v", cr.name, result.Skipped) + } + if len(result.Errors) > 0 { + anyErr = true + for _, e := range result.Errors { + logger.Warnf("Auto-migrate: error for %q: %s", cr.name, e) + } + } + } + + pendingAfter, err := sumPendingAutoMigratable(ctx, exec, toRun) + if err != nil { + logger.Errorf("Auto-migrate: failed to count pending migrations after round: %v", err) + break + } + if pendingAfter == 0 { + logger.Info("Auto-migrate: all auto-migratable migrations applied") + logger.Info("Auto-migrate: startup pass completed") + return + } + if !anyApplied && !anyErr && pendingAfter == pendingBefore { + logger.Warnf("Auto-migrate: no progress after round %d (%d pending fixed-schema migration(s) unchanged); check backend/connection alignment and logs. Stopping retries.", round, pendingAfter) + logger.Info("Auto-migrate: startup pass completed") + return + } + if round == maxRounds { + logger.Warnf("Auto-migrate: reached max rounds (%d); %d pending auto-migratable migration(s) remain", maxRounds, pendingAfter) + logger.Info("Auto-migrate: startup pass completed") + return + } + if retryInterval <= 0 { + logger.Info("Auto-migrate: startup pass completed") + return + } + select { + case <-ctx.Done(): + logger.Info("Auto-migrate cancelled during shutdown") + return + case <-time.After(retryInterval): + } + } + + logger.Info("Auto-migrate: startup pass completed") + }() +} diff --git a/api/cmd/server/auto_migrate_test.go b/api/cmd/server/auto_migrate_test.go new file mode 100644 index 0000000..7f0037e --- /dev/null +++ b/api/cmd/server/auto_migrate_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "testing" + "time" + + "github.com/toolsascode/bfm/api/internal/backends" +) + +func Test_autoMigrateRetryInterval(t *testing.T) { + t.Run("default when unset", func(t *testing.T) { + t.Setenv("BFM_AUTO_MIGRATE_RETRY_INTERVAL", "") + if got := autoMigrateRetryInterval(); got != 5*time.Second { + t.Fatalf("default = %v, want 5s", got) + } + }) + t.Run("explicit duration", func(t *testing.T) { + t.Setenv("BFM_AUTO_MIGRATE_RETRY_INTERVAL", "3s") + if got := autoMigrateRetryInterval(); got != 3*time.Second { + t.Fatalf("got %v", got) + } + }) + t.Run("zero means single-round mode", func(t *testing.T) { + t.Setenv("BFM_AUTO_MIGRATE_RETRY_INTERVAL", "0s") + if got := autoMigrateRetryInterval(); got != 0 { + t.Fatalf("got %v", got) + } + }) + t.Run("invalid falls back to 5s", func(t *testing.T) { + t.Setenv("BFM_AUTO_MIGRATE_RETRY_INTERVAL", "not-a-duration") + if got := autoMigrateRetryInterval(); got != 5*time.Second { + t.Fatalf("got %v", got) + } + }) +} + +func Test_autoMigrateRetryMaxRounds(t *testing.T) { + t.Run("zero interval forces 1", func(t *testing.T) { + if n := autoMigrateRetryMaxRounds(0); n != 1 { + t.Fatalf("got %d", n) + } + }) + t.Run("negative interval forces 1", func(t *testing.T) { + if n := autoMigrateRetryMaxRounds(-1 * time.Second); n != 1 { + t.Fatalf("got %d", n) + } + }) + t.Run("default when unset and interval positive", func(t *testing.T) { + t.Setenv("BFM_AUTO_MIGRATE_RETRY_MAX_ROUNDS", "") + if n := autoMigrateRetryMaxRounds(5 * time.Second); n != 24 { + t.Fatalf("got %d", n) + } + }) + t.Run("explicit value", func(t *testing.T) { + t.Setenv("BFM_AUTO_MIGRATE_RETRY_MAX_ROUNDS", "7") + if n := autoMigrateRetryMaxRounds(5 * time.Second); n != 7 { + t.Fatalf("got %d", n) + } + }) + t.Run("invalid falls back to 24", func(t *testing.T) { + t.Setenv("BFM_AUTO_MIGRATE_RETRY_MAX_ROUNDS", "nope") + if n := autoMigrateRetryMaxRounds(5 * time.Second); n != 24 { + t.Fatalf("got %d", n) + } + }) + t.Run("zero env falls back to 24", func(t *testing.T) { + t.Setenv("BFM_AUTO_MIGRATE_RETRY_MAX_ROUNDS", "0") + if n := autoMigrateRetryMaxRounds(5 * time.Second); n != 24 { + t.Fatalf("got %d", n) + } + }) +} + +func Test_connectionConfigReadyForAutoMigrate(t *testing.T) { + tests := []struct { + name string + conn *backends.ConnectionConfig + want bool + }{ + { + name: "nil config", + conn: nil, + want: false, + }, + { + name: "postgresql host set", + conn: &backends.ConnectionConfig{Backend: "postgresql", Host: "db", Port: "5432"}, + want: true, + }, + { + name: "postgresql host empty", + conn: &backends.ConnectionConfig{Backend: "postgresql", Host: "", Port: "5432"}, + want: false, + }, + { + name: "postgresql host whitespace", + conn: &backends.ConnectionConfig{Backend: "postgresql", Host: " "}, + want: false, + }, + { + name: "greptimedb host set", + conn: &backends.ConnectionConfig{Backend: "greptimedb", Host: "gt", Port: ""}, + want: true, + }, + { + name: "greptimedb host empty", + conn: &backends.ConnectionConfig{Backend: "greptimedb", Host: ""}, + want: false, + }, + { + name: "etcd endpoints lowercase", + conn: &backends.ConnectionConfig{Backend: "etcd", Extra: map[string]string{"endpoints": "http://etcd:2379"}}, + want: true, + }, + { + name: "etcd endpoints uppercase key from env-style extra", + conn: &backends.ConnectionConfig{Backend: "etcd", Extra: map[string]string{"ENDPOINTS": "http://etcd:2379"}}, + want: true, + }, + { + name: "etcd endpoints empty string", + conn: &backends.ConnectionConfig{Backend: "etcd", Extra: map[string]string{"endpoints": " "}}, + want: false, + }, + { + name: "etcd host and port", + conn: &backends.ConnectionConfig{Backend: "etcd", Host: "etcd", Port: "2379"}, + want: true, + }, + { + name: "etcd METADATA_BACKEND only no host port endpoints", + conn: &backends.ConnectionConfig{Backend: "etcd", Host: "", Port: "", Extra: map[string]string{}}, + want: false, + }, + { + name: "etcd host only", + conn: &backends.ConnectionConfig{Backend: "etcd", Host: "etcd", Port: ""}, + want: false, + }, + { + name: "unknown backend passes", + conn: &backends.ConnectionConfig{Backend: "futuredb", Host: ""}, + want: true, + }, + { + name: "backend casing", + conn: &backends.ConnectionConfig{Backend: "PostgreSQL", Host: "x"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := connectionConfigReadyForAutoMigrate(tt.conn); got != tt.want { + t.Errorf("connectionConfigReadyForAutoMigrate() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_etcdEndpointsExtraNonEmpty(t *testing.T) { + if !etcdEndpointsExtraNonEmpty(map[string]string{"endpoints": "a"}) { + t.Fatal("expected true") + } + if etcdEndpointsExtraNonEmpty(map[string]string{"endpoints": ""}) { + t.Fatal("expected false for empty value") + } + if etcdEndpointsExtraNonEmpty(nil) { + t.Fatal("expected false for nil") + } +} diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go index dca8933..0d90f88 100644 --- a/api/cmd/server/main.go +++ b/api/cmd/server/main.go @@ -66,6 +66,9 @@ func main() { logger.Fatalf("Failed to load configuration: %v", err) } + rootCtx, rootCancel := context.WithCancel(context.Background()) + defer rootCancel() + // Initialize state tracker stateConnStr := fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", @@ -181,6 +184,8 @@ func main() { defer reindexer.Stop() logger.Infof("Background reindexer started with interval: %v", reindexInterval) + startAutoMigrateBackground(rootCtx, exec, cfg) + // Set Gin mode - use BFM_APP_MODE env var if set, otherwise default to release mode if ginMode := os.Getenv("BFM_APP_MODE"); ginMode != "" { gin.SetMode(ginMode) @@ -330,6 +335,8 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit + rootCancel() + logger.Info("Shutting down servers...") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/api/internal/api/http/dto/migrations.go b/api/internal/api/http/dto/migrations.go index 1f3dbcd..37a7dfd 100644 --- a/api/internal/api/http/dto/migrations.go +++ b/api/internal/api/http/dto/migrations.go @@ -79,6 +79,17 @@ type ReindexResponse struct { Total int `json:"total"` } +// OrderMigrationBatchRequest requests a dependency-safe execution order for a set of migrations. +type OrderMigrationBatchRequest struct { + MigrationIDs []string `json:"migration_ids" binding:"required"` + Connection string `json:"connection" binding:"required"` +} + +// OrderMigrationBatchResponse is the ordered list of migration IDs (same strings as input, reordered). +type OrderMigrationBatchResponse struct { + OrderedMigrationIDs []string `json:"ordered_migration_ids"` +} + // MigrateUpRequest represents a request to execute up migrations type MigrateUpRequest struct { Target *registry.MigrationTarget `json:"target"` diff --git a/api/internal/api/http/handler.go b/api/internal/api/http/handler.go index 216cc12..4dcd1e8 100644 --- a/api/internal/api/http/handler.go +++ b/api/internal/api/http/handler.go @@ -40,6 +40,7 @@ func (h *Handler) RegisterRoutes(router *gin.Engine) { }) api.POST("/migrations/up", h.authenticate, h.migrateUp) + api.POST("/migrations/order-batch", h.authenticate, h.orderMigrationBatch) api.POST("/migrations/down", h.authenticate, h.migrateDown) api.GET("/migrations", h.authenticate, h.listMigrations) api.GET("/migrations/:id", h.authenticate, h.getMigration) @@ -213,6 +214,23 @@ func (h *Handler) migrateUp(c *gin.Context) { c.JSON(statusCode, response) } +// orderMigrationBatch returns migration_ids sorted by dependency order for batch execution. +func (h *Handler) orderMigrationBatch(c *gin.Context) { + var req dto.OrderMigrationBatchRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ordered, err := h.executor.OrderMigrationBatch(req.MigrationIDs, req.Connection) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, dto.OrderMigrationBatchResponse{OrderedMigrationIDs: ordered}) +} + // migrateDown handles down migration requests // @Summary Execute down migrations (rollback) // @Description Executes down migrations to rollback a specific migration @@ -539,7 +557,7 @@ func (h *Handler) getMigrationStatus(c *gin.Context) { // Find the latest successful, non-rollback record var latestSuccessRecord *state.MigrationRecord for _, record := range relatedRecords { - if !strings.Contains(record.MigrationID, "_rollback") && record.Status == "success" { + if !strings.Contains(record.MigrationID, "_rollback") && state.HistoryStatusIndicatesApplied(record.Status) { latestSuccessRecord = record break // Records are sorted DESC, so first match is most recent } @@ -586,7 +604,8 @@ func (h *Handler) getMigrationStatus(c *gin.Context) { errorMessage = latestRollbackRecord.ErrorMessage } else { // Use latest record (could be failed, pending, etc.) - applied = !strings.Contains(latestRecord.MigrationID, "_rollback") + applied = !strings.Contains(latestRecord.MigrationID, "_rollback") && + state.HistoryStatusIndicatesApplied(latestRecord.Status) status = latestRecord.Status appliedAt = latestRecord.AppliedAt errorMessage = latestRecord.ErrorMessage diff --git a/api/internal/api/http/handler_test.go b/api/internal/api/http/handler_test.go index ceb320f..b4f22e1 100644 --- a/api/internal/api/http/handler_test.go +++ b/api/internal/api/http/handler_test.go @@ -388,6 +388,10 @@ func (m *mockStateTracker) GetSkippedMigrations(ctx interface{}, migrationID str return nil, nil } +func (m *mockStateTracker) WithMigrationExecutionLock(_ interface{}, _, _, _ string, fn func() error) error { + return fn() +} + func setupTestRouter(reg *mockRegistry, tracker *mockStateTracker) (*gin.Engine, *executor.Executor) { gin.SetMode(gin.TestMode) router := gin.New() @@ -918,6 +922,49 @@ func TestHandler_getMigrationStatus(t *testing.T) { } } +func TestHandler_getMigrationStatus_appliedHistoryStatus(t *testing.T) { + originalToken := os.Getenv("BFM_API_TOKEN") + defer func() { + if originalToken != "" { + _ = os.Setenv("BFM_API_TOKEN", originalToken) + } else { + _ = os.Unsetenv("BFM_API_TOKEN") + } + }() + + _ = os.Setenv("BFM_API_TOKEN", "test-token") + reg := newMockRegistry() + tracker := newMockStateTracker() + migrationID := "20240101120000_test_migration_postgresql_core" + ts := time.Now().Format(time.RFC3339) + // Real DB orders by applied_at DESC, id DESC — completion row first when timestamps tie. + tracker.history = []*state.MigrationRecord{ + {MigrationID: migrationID, Status: "applied", AppliedAt: ts}, + {MigrationID: migrationID, Status: "pending", AppliedAt: ts}, + } + router, _ := setupTestRouter(reg, tracker) + + req, _ := http.NewRequest("GET", "/api/v1/migrations/"+migrationID+"/status", nil) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + if response["applied"] != true { + t.Errorf("expected applied=true for applied history row, got %v", response["applied"]) + } + if response["status"] != "applied" { + t.Errorf("expected status=applied, got %v", response["status"]) + } +} + func TestHandler_getMigrationHistory(t *testing.T) { // Save original token originalToken := os.Getenv("BFM_API_TOKEN") diff --git a/api/internal/api/protobuf/handler.go b/api/internal/api/protobuf/handler.go index cfa9e31..db8d2e6 100644 --- a/api/internal/api/protobuf/handler.go +++ b/api/internal/api/protobuf/handler.go @@ -410,7 +410,7 @@ func (s *Server) GetMigrationStatus(ctx context.Context, req *GetMigrationStatus // Find the latest successful, non-rollback record var latestSuccessRecord *state.MigrationRecord for _, record := range relatedRecords { - if !strings.Contains(record.MigrationID, "_rollback") && record.Status == "success" { + if !strings.Contains(record.MigrationID, "_rollback") && state.HistoryStatusIndicatesApplied(record.Status) { latestSuccessRecord = record break } @@ -451,7 +451,8 @@ func (s *Server) GetMigrationStatus(ctx context.Context, req *GetMigrationStatus statusVal = "rolled_back" errorMessage = latestRollbackRecord.ErrorMessage } else { - applied = !strings.Contains(latestRecord.MigrationID, "_rollback") + applied = !strings.Contains(latestRecord.MigrationID, "_rollback") && + state.HistoryStatusIndicatesApplied(latestRecord.Status) statusVal = latestRecord.Status appliedAt = latestRecord.AppliedAt errorMessage = latestRecord.ErrorMessage diff --git a/api/internal/backends/postgresql/validator.go b/api/internal/backends/postgresql/validator.go index 6f43d12..e02067e 100644 --- a/api/internal/backends/postgresql/validator.go +++ b/api/internal/backends/postgresql/validator.go @@ -65,14 +65,28 @@ func (v *DependencyValidator) ValidateDependenciesWithExecutionSet(ctx context.C // validateDependencyWithExecutionSet validates a single structured dependency, // considering migrations in the execution set as satisfied dependencies func (v *DependencyValidator) validateDependencyWithExecutionSet(ctx context.Context, dep backends.Dependency, currentSchema string, executionSetMap map[string]bool) error { - // Validate required schema exists + // Validate required schema exists in the database, unless the dependency migration + // is scheduled in this execution run — that migration typically creates the schema, + // so SchemaExists would falsely fail during bootstrap (empty DB). if dep.RequiresSchema != "" { - exists, err := v.backend.SchemaExists(ctx, dep.RequiresSchema) - if err != nil { - return fmt.Errorf("failed to check schema existence: %w", err) + skipSchemaExistence := false + if targetMigrations, err := v.findMigrationByTarget(dep); err == nil { + for _, tm := range targetMigrations { + migrationID := fmt.Sprintf("%s_%s_%s_%s", tm.Version, tm.Name, tm.Backend, tm.Connection) + if executionSetMap != nil && executionSetMap[migrationID] { + skipSchemaExistence = true + break + } + } } - if !exists { - return fmt.Errorf("required schema '%s' does not exist", dep.RequiresSchema) + if !skipSchemaExistence { + exists, err := v.backend.SchemaExists(ctx, dep.RequiresSchema) + if err != nil { + return fmt.Errorf("failed to check schema existence: %w", err) + } + if !exists { + return fmt.Errorf("required schema '%s' does not exist", dep.RequiresSchema) + } } } diff --git a/api/internal/backends/postgresql/validator_test.go b/api/internal/backends/postgresql/validator_test.go index df9b972..4374538 100644 --- a/api/internal/backends/postgresql/validator_test.go +++ b/api/internal/backends/postgresql/validator_test.go @@ -88,6 +88,10 @@ func (m *mockStateTrackerForValidator) GetSkippedMigrations(ctx interface{}, mig return nil, nil } +func (m *mockStateTrackerForValidator) WithMigrationExecutionLock(_ interface{}, _, _, _ string, fn func() error) error { + return fn() +} + func TestDependencyValidator_ValidateDependencies(t *testing.T) { backend := &Backend{} // We'll need to use a real backend or mock differently // For now, we'll test the logic without actual database calls @@ -267,3 +271,48 @@ func TestDependencyValidator_FindMigrationByTarget(t *testing.T) { }) } } + +func TestDependencyValidator_RequiresSchema_skippedWhenDependencyInExecutionSet(t *testing.T) { + reg := registry.NewInMemoryRegistry() + tracker := newMockStateTrackerForValidator() + backend := &Backend{} // no DB pool; SchemaExists would error if invoked + + coreSchema := &backends.MigrationScript{ + Version: "20250218000000", + Name: "core_schema", + Connection: "core", + Schema: "core", + Backend: "postgresql", + } + if err := reg.Register(coreSchema); err != nil { + t.Fatal(err) + } + + dependent := &backends.MigrationScript{ + Version: "20250218120000", + Name: "phase2_core_environments", + Connection: "core", + Schema: "core", + Backend: "postgresql", + StructuredDependencies: []backends.Dependency{ + { + Connection: "core", + Schema: "core", + Target: "20250218000000", + TargetType: "version", + RequiresSchema: "core", + }, + }, + } + if err := reg.Register(dependent); err != nil { + t.Fatal(err) + } + + v := NewDependencyValidator(backend, tracker, reg) + execSet := []*backends.MigrationScript{coreSchema, dependent} + + errs := v.ValidateDependenciesWithExecutionSet(context.Background(), dependent, "core", execSet) + if len(errs) > 0 { + t.Fatalf("expected no errors when dependency is in execution set (schema not created yet), got %v", errs) + } +} diff --git a/api/internal/executor/executor.go b/api/internal/executor/executor.go index f11375e..5ed7a94 100644 --- a/api/internal/executor/executor.go +++ b/api/internal/executor/executor.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -26,11 +27,24 @@ import ( type contextKey string const ( - executedByKey contextKey = "executed_by" - executionMethodKey contextKey = "execution_method" - executionContextKey contextKey = "execution_context" + executedByKey contextKey = "executed_by" + executionMethodKey contextKey = "execution_method" + executionContextKey contextKey = "execution_context" + autoMigrateContextKey contextKey = "bfm_auto_migrate" ) +// WithAutoMigrateContext marks ctx so executeSync skips migrations with empty Schema +// when no schema was provided in the request (startup auto-migrate). Manual/API runs +// without this value still get a clear error for dynamic-schema migrations. +func WithAutoMigrateContext(ctx context.Context) context.Context { + return context.WithValue(ctx, autoMigrateContextKey, true) +} + +func isAutoMigrateContext(ctx context.Context) bool { + v, ok := ctx.Value(autoMigrateContextKey).(bool) + return ok && v +} + // SetExecutionContext sets execution context in the context func SetExecutionContext(ctx context.Context, executedBy, executionMethod string, executionContext map[string]interface{}) context.Context { ctx = context.WithValue(ctx, executedByKey, executedBy) @@ -448,6 +462,242 @@ func (e *Executor) expandWithPendingDependencies(ctx context.Context, migrations return expanded, dependencyMap, dependencyParentMap, nil } +// runSingleMigrationUp records pending state, runs the migration backend, and records the outcome. +// Caller must hold WithMigrationExecutionLock for the same (migrationID, schema, connection). +func (e *Executor) runSingleMigrationUp( + ctx context.Context, + migration *backends.MigrationScript, + migrationID string, + schema string, + schemaName string, + dependencyMap map[string]bool, + dependencyParentMap map[string]string, + executedDependencies map[string][]string, + result *ExecuteResult, +) { + // Check if this is a dependency migration + // NOTE: Only override migrationID for dependencies if schemaName was NOT provided + // If schemaName was provided, we need to track per-schema even for dependencies + isDependency := dependencyMap != nil && dependencyMap[migrationID] + baseMigrationID := e.getMigrationID(migration) + isDependency = isDependency || (dependencyMap != nil && dependencyMap[baseMigrationID]) + if isDependency && schemaName == "" { + // Only use base ID for dependencies if no specific schema was requested + // This preserves per-schema tracking when schemaName is provided + migrationID = baseMigrationID + logger.Debug("Migration is a dependency, using base ID: %s", migrationID) + } + + logger.Debug("Recording migration with ID: %s (schema: %s, isDependency: %v)", migrationID, schema, isDependency) + + // Extract execution context + executedBy, executionMethod, executionContext := GetExecutionContext(ctx) + + // Record migration start IMMEDIATELY to prevent concurrent execution + // Use migration.Connection (not connectionName) since this migration may be from a different connection + record := &state.MigrationRecord{ + MigrationID: migrationID, + Schema: schema, + Table: "", + Version: migration.Version, + Connection: migration.Connection, + Backend: migration.Backend, + Status: "pending", + AppliedAt: time.Now().Format(time.RFC3339), + ErrorMessage: "", + ExecutedBy: executedBy, + ExecutionMethod: executionMethod, + ExecutionContext: executionContext, + } + + // Record as pending immediately to prevent race conditions + // For dependencies, use RecordDependencyMigration (requirement 4: no history) + // If this fails because another process already marked it as pending/applied, skip execution + var recordErr error + if isDependency { + recordErr = e.stateTracker.RecordDependencyMigration(ctx, record) + } else { + logger.Debug("Recording migration as pending: migrationID=%s, schema=%s, status=%s", record.MigrationID, record.Schema, record.Status) + recordErr = e.stateTracker.RecordMigration(ctx, record) + if recordErr == nil { + logger.Debug("Successfully recorded migration as pending: migrationID=%s, schema=%s - history should be in migrations_history", record.MigrationID, record.Schema) + } + } + if recordErr != nil { + // Re-check if migration was applied by another process (concurrency control) + // Use IsMigrationApplied (not IsMigrationPendingOrApplied) because we want to skip only if actually applied + applied, checkErr := e.stateTracker.IsMigrationApplied(ctx, migrationID) + if checkErr == nil && applied { + result.Skipped = append(result.Skipped, migrationID) + return + } + logger.Errorf("Failed to record migration start for %s (schema=%s): %v", migrationID, record.Schema, recordErr) + result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration start for %s: %v", migrationID, recordErr)) + return + } + + // Double-check after recording to ensure we didn't race with another process (concurrency control) + // Use IsMigrationApplied (not IsMigrationPendingOrApplied) because we just recorded it as pending ourselves + // We only want to skip if another process marked it as APPLIED while we were recording + applied, checkErr := e.stateTracker.IsMigrationApplied(ctx, migrationID) + if checkErr == nil && applied { + // Another process marked it as applied, skip + result.Skipped = append(result.Skipped, migrationID) + return + } + + // Get backend for this migration's connection (may differ from target connection for cross-connection dependencies) + migrationConnectionConfig, err := e.getConnectionConfig(migration.Connection) + if err != nil { + record.Status = "failed" + record.ErrorMessage = fmt.Sprintf("failed to get connection config for %s: %v", migration.Connection, err) + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", migrationID, err)) + if err := e.stateTracker.RecordMigration(ctx, record); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration %s: %v", migrationID, err)) + } + return + } + + migrationBackend, ok := e.backends[migrationConnectionConfig.Backend] + if !ok { + record.Status = "failed" + record.ErrorMessage = fmt.Sprintf("backend %s not registered for connection %s", migrationConnectionConfig.Backend, migration.Connection) + result.Errors = append(result.Errors, fmt.Sprintf("%s: backend %s not registered", migrationID, migrationConnectionConfig.Backend)) + if err := e.stateTracker.RecordMigration(ctx, record); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration %s: %v", migrationID, err)) + } + return + } + + // Connect to the migration's backend (may be different from target backend) + if err := migrationBackend.Connect(migrationConnectionConfig); err != nil { + record.Status = "failed" + record.ErrorMessage = fmt.Sprintf("failed to connect to backend for %s: %v", migration.Connection, err) + result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to connect: %v", migrationID, err)) + if err := e.stateTracker.RecordMigration(ctx, record); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration %s: %v", migrationID, err)) + } + return + } + + // Apply template variable replacement + upSQL, err := replaceTemplateVariables(migration.UpSQL, migration, schema) + if err != nil { + // Migration was already marked as pending, update to failed + record.Status = "failed" + record.ErrorMessage = fmt.Sprintf("failed to replace template variables in UpSQL: %v", err) + result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to replace template variables in UpSQL: %v", migrationID, err)) + // Record the failure + if isDependency { + if recordErr := e.stateTracker.RecordDependencyMigration(ctx, record); recordErr != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to record dependency migration failure %s: %v", migrationID, recordErr)) + } + } else { + if recordErr := e.stateTracker.RecordMigration(ctx, record); recordErr != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration failure %s: %v", migrationID, recordErr)) + } + } + return + } + + downSQL := migration.DownSQL + if downSQL != "" { + var err error + downSQL, err = replaceTemplateVariables(migration.DownSQL, migration, schema) + if err != nil { + // Migration was already marked as pending, update to failed + record.Status = "failed" + record.ErrorMessage = fmt.Sprintf("failed to replace template variables in DownSQL: %v", err) + result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to replace template variables in DownSQL: %v", migrationID, err)) + // Record the failure + if isDependency { + if recordErr := e.stateTracker.RecordDependencyMigration(ctx, record); recordErr != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to record dependency migration failure %s: %v", migrationID, recordErr)) + } + } else { + if recordErr := e.stateTracker.RecordMigration(ctx, record); recordErr != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration failure %s: %v", migrationID, recordErr)) + } + } + return + } + } + + // Convert executor.MigrationScript to backends.MigrationScript + // Use provided schema instead of migration.Schema for dynamic schemas + backendMigration := &backends.MigrationScript{ + Schema: schema, + Version: migration.Version, + Name: migration.Name, + Connection: migration.Connection, + Backend: migration.Backend, + UpSQL: upSQL, + DownSQL: downSQL, + } + + // Execute the migration using its own backend + err = migrationBackend.ExecuteMigration(ctx, backendMigration) + _ = migrationBackend.Close() // Close after execution + if err != nil { + record.Status = "failed" + record.ErrorMessage = err.Error() + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", migrationID, err)) + } else { + record.Status = "success" + // Fresh completion time so history ordering is deterministic (pending row may share the pre-exec timestamp). + record.AppliedAt = time.Now().Format(time.RFC3339) + result.Applied = append(result.Applied, migrationID) + + // Requirement 3: Track executed dependencies for parent migration + if isDependency && dependencyParentMap != nil { + parentID := dependencyParentMap[baseMigrationID] + if parentID == "" { + // Try with full migrationID + parentID = dependencyParentMap[migrationID] + } + if parentID != "" { + executedDependencies[parentID] = append(executedDependencies[parentID], migrationID) + logger.Debug("Tracked dependency %s for parent migration %s", migrationID, parentID) + } + } + } + + // Record migration in state tracker + // Requirement 4: Dependencies use RecordDependencyMigration (no history) + // CRITICAL: Ensure record.Schema is set correctly for schema-specific migrations + // The schema must match the schema used in migrations_executions for ON CONFLICT to work + if isDependency { + if err := e.stateTracker.RecordDependencyMigration(ctx, record); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to record dependency migration %s: %v", migrationID, err)) + } + } else { + // For non-dependencies, add executed dependencies to execution context + if len(executedDependencies[migrationID]) > 0 { + // Parse existing execution context and add dependencies + var execCtx map[string]interface{} + if executionContext != "" { + if err := json.Unmarshal([]byte(executionContext), &execCtx); err != nil { + execCtx = make(map[string]interface{}) + } + } else { + execCtx = make(map[string]interface{}) + } + execCtx["executed_dependencies"] = executedDependencies[migrationID] + if updatedCtx, err := json.Marshal(execCtx); err == nil { + record.ExecutionContext = string(updatedCtx) + } + } + // Ensure schema is set correctly for the update (should already be set from initial record creation) + logger.Debug("Updating migration record: migrationID=%s, schema=%s, status=%s", record.MigrationID, record.Schema, record.Status) + if err := e.stateTracker.RecordMigration(ctx, record); err != nil { + logger.Errorf("Failed to record migration %s (status=%s, schema=%s): %v", migrationID, record.Status, record.Schema, err) + result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration %s: %v", migrationID, err)) + } else { + logger.Debug("Successfully recorded migration %s (status=%s, schema=%s) - history should be in migrations_history", migrationID, record.Status, record.Schema) + } + } +} + // executeSync executes migrations synchronously func (e *Executor) executeSync(ctx context.Context, target *registry.MigrationTarget, connectionName string, schemaName string, dryRun bool, ignoreDependencies bool) (*ExecuteResult, error) { // Find migrations matching the target @@ -597,6 +847,10 @@ func (e *Executor) executeSync(ctx context.Context, target *registry.MigrationTa // Dynamic schema mode: track per schema // If schema is still empty, we can't track it properly - this is an error condition if schema == "" { + if isAutoMigrateContext(ctx) { + logger.Infof("Skipping migration %s_%s: dynamic schema requires an explicit schema in the request (auto-migrate)", migration.Version, migration.Name) + continue + } result.Errors = append(result.Errors, fmt.Sprintf("migration %s_%s has dynamic schema but no schema provided in request", migration.Version, migration.Name)) continue } @@ -627,225 +881,22 @@ func (e *Executor) executeSync(ctx context.Context, target *registry.MigrationTa continue } - // Check if this is a dependency migration - // NOTE: Only override migrationID for dependencies if schemaName was NOT provided - // If schemaName was provided, we need to track per-schema even for dependencies - isDependency := dependencyMap != nil && dependencyMap[migrationID] - baseMigrationID := e.getMigrationID(migration) - isDependency = isDependency || (dependencyMap != nil && dependencyMap[baseMigrationID]) - if isDependency && schemaName == "" { - // Only use base ID for dependencies if no specific schema was requested - // This preserves per-schema tracking when schemaName is provided - migrationID = baseMigrationID - logger.Debug("Migration is a dependency, using base ID: %s", migrationID) + lockSchema := schema + if lockSchema == "" { + lockSchema = migration.Schema } - logger.Debug("Recording migration with ID: %s (schema: %s, isDependency: %v)", migrationID, schema, isDependency) - - // Extract execution context - executedBy, executionMethod, executionContext := GetExecutionContext(ctx) - - // Record migration start IMMEDIATELY to prevent concurrent execution - // Use migration.Connection (not connectionName) since this migration may be from a different connection - record := &state.MigrationRecord{ - MigrationID: migrationID, - Schema: schema, - Table: "", - Version: migration.Version, - Connection: migration.Connection, - Backend: migration.Backend, - Status: "pending", - AppliedAt: time.Now().Format(time.RFC3339), - ErrorMessage: "", - ExecutedBy: executedBy, - ExecutionMethod: executionMethod, - ExecutionContext: executionContext, - } - - // Record as pending immediately to prevent race conditions - // For dependencies, use RecordDependencyMigration (requirement 4: no history) - // If this fails because another process already marked it as pending/applied, skip execution - var recordErr error - if isDependency { - recordErr = e.stateTracker.RecordDependencyMigration(ctx, record) - } else { - logger.Debug("Recording migration as pending: migrationID=%s, schema=%s, status=%s", record.MigrationID, record.Schema, record.Status) - recordErr = e.stateTracker.RecordMigration(ctx, record) - if recordErr == nil { - logger.Debug("Successfully recorded migration as pending: migrationID=%s, schema=%s - history should be in migrations_history", record.MigrationID, record.Schema) - } - } - if recordErr != nil { - // Re-check if migration was applied by another process (concurrency control) - // Use IsMigrationApplied (not IsMigrationPendingOrApplied) because we want to skip only if actually applied - applied, checkErr := e.stateTracker.IsMigrationApplied(ctx, migrationID) - if checkErr == nil && applied { - result.Skipped = append(result.Skipped, migrationID) + if err := e.stateTracker.WithMigrationExecutionLock(ctx, migrationID, lockSchema, migration.Connection, func() error { + e.runSingleMigrationUp(ctx, migration, migrationID, schema, schemaName, dependencyMap, dependencyParentMap, executedDependencies, result) + return nil + }); err != nil { + if errors.Is(err, state.ErrMigrationAlreadyInProgress) { + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", migrationID, err)) continue } - logger.Errorf("Failed to record migration start for %s (schema=%s): %v", migrationID, record.Schema, recordErr) - result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration start for %s: %v", migrationID, recordErr)) + result.Errors = append(result.Errors, fmt.Sprintf("%s: migration lock: %v", migrationID, err)) continue } - - // Double-check after recording to ensure we didn't race with another process (concurrency control) - // Use IsMigrationApplied (not IsMigrationPendingOrApplied) because we just recorded it as pending ourselves - // We only want to skip if another process marked it as APPLIED while we were recording - applied, checkErr := e.stateTracker.IsMigrationApplied(ctx, migrationID) - if checkErr == nil && applied { - // Another process marked it as applied, skip - result.Skipped = append(result.Skipped, migrationID) - continue - } - - // Get backend for this migration's connection (may differ from target connection for cross-connection dependencies) - migrationConnectionConfig, err := e.getConnectionConfig(migration.Connection) - if err != nil { - record.Status = "failed" - record.ErrorMessage = fmt.Sprintf("failed to get connection config for %s: %v", migration.Connection, err) - result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", migrationID, err)) - if err := e.stateTracker.RecordMigration(ctx, record); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration %s: %v", migrationID, err)) - } - continue - } - - migrationBackend, ok := e.backends[migrationConnectionConfig.Backend] - if !ok { - record.Status = "failed" - record.ErrorMessage = fmt.Sprintf("backend %s not registered for connection %s", migrationConnectionConfig.Backend, migration.Connection) - result.Errors = append(result.Errors, fmt.Sprintf("%s: backend %s not registered", migrationID, migrationConnectionConfig.Backend)) - if err := e.stateTracker.RecordMigration(ctx, record); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration %s: %v", migrationID, err)) - } - continue - } - - // Connect to the migration's backend (may be different from target backend) - if err := migrationBackend.Connect(migrationConnectionConfig); err != nil { - record.Status = "failed" - record.ErrorMessage = fmt.Sprintf("failed to connect to backend for %s: %v", migration.Connection, err) - result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to connect: %v", migrationID, err)) - if err := e.stateTracker.RecordMigration(ctx, record); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration %s: %v", migrationID, err)) - } - continue - } - - // Apply template variable replacement - upSQL, err := replaceTemplateVariables(migration.UpSQL, migration, schema) - if err != nil { - // Migration was already marked as pending, update to failed - record.Status = "failed" - record.ErrorMessage = fmt.Sprintf("failed to replace template variables in UpSQL: %v", err) - result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to replace template variables in UpSQL: %v", migrationID, err)) - // Record the failure - if isDependency { - if recordErr := e.stateTracker.RecordDependencyMigration(ctx, record); recordErr != nil { - result.Errors = append(result.Errors, fmt.Sprintf("failed to record dependency migration failure %s: %v", migrationID, recordErr)) - } - } else { - if recordErr := e.stateTracker.RecordMigration(ctx, record); recordErr != nil { - result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration failure %s: %v", migrationID, recordErr)) - } - } - continue - } - - downSQL := migration.DownSQL - if downSQL != "" { - var err error - downSQL, err = replaceTemplateVariables(migration.DownSQL, migration, schema) - if err != nil { - // Migration was already marked as pending, update to failed - record.Status = "failed" - record.ErrorMessage = fmt.Sprintf("failed to replace template variables in DownSQL: %v", err) - result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to replace template variables in DownSQL: %v", migrationID, err)) - // Record the failure - if isDependency { - if recordErr := e.stateTracker.RecordDependencyMigration(ctx, record); recordErr != nil { - result.Errors = append(result.Errors, fmt.Sprintf("failed to record dependency migration failure %s: %v", migrationID, recordErr)) - } - } else { - if recordErr := e.stateTracker.RecordMigration(ctx, record); recordErr != nil { - result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration failure %s: %v", migrationID, recordErr)) - } - } - continue - } - } - - // Convert executor.MigrationScript to backends.MigrationScript - // Use provided schema instead of migration.Schema for dynamic schemas - backendMigration := &backends.MigrationScript{ - Schema: schema, - Version: migration.Version, - Name: migration.Name, - Connection: migration.Connection, - Backend: migration.Backend, - UpSQL: upSQL, - DownSQL: downSQL, - } - - // Execute the migration using its own backend - err = migrationBackend.ExecuteMigration(ctx, backendMigration) - _ = migrationBackend.Close() // Close after execution - if err != nil { - record.Status = "failed" - record.ErrorMessage = err.Error() - result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", migrationID, err)) - } else { - record.Status = "success" - result.Applied = append(result.Applied, migrationID) - - // Requirement 3: Track executed dependencies for parent migration - if isDependency && dependencyParentMap != nil { - parentID := dependencyParentMap[baseMigrationID] - if parentID == "" { - // Try with full migrationID - parentID = dependencyParentMap[migrationID] - } - if parentID != "" { - executedDependencies[parentID] = append(executedDependencies[parentID], migrationID) - logger.Debug("Tracked dependency %s for parent migration %s", migrationID, parentID) - } - } - } - - // Record migration in state tracker - // Requirement 4: Dependencies use RecordDependencyMigration (no history) - // CRITICAL: Ensure record.Schema is set correctly for schema-specific migrations - // The schema must match the schema used in migrations_executions for ON CONFLICT to work - if isDependency { - if err := e.stateTracker.RecordDependencyMigration(ctx, record); err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("failed to record dependency migration %s: %v", migrationID, err)) - } - } else { - // For non-dependencies, add executed dependencies to execution context - if len(executedDependencies[migrationID]) > 0 { - // Parse existing execution context and add dependencies - var execCtx map[string]interface{} - if executionContext != "" { - if err := json.Unmarshal([]byte(executionContext), &execCtx); err != nil { - execCtx = make(map[string]interface{}) - } - } else { - execCtx = make(map[string]interface{}) - } - execCtx["executed_dependencies"] = executedDependencies[migrationID] - if updatedCtx, err := json.Marshal(execCtx); err == nil { - record.ExecutionContext = string(updatedCtx) - } - } - // Ensure schema is set correctly for the update (should already be set from initial record creation) - logger.Debug("Updating migration record: migrationID=%s, schema=%s, status=%s", record.MigrationID, record.Schema, record.Status) - if err := e.stateTracker.RecordMigration(ctx, record); err != nil { - logger.Errorf("Failed to record migration %s (status=%s, schema=%s): %v", migrationID, record.Status, record.Schema, err) - result.Errors = append(result.Errors, fmt.Sprintf("failed to record migration %s: %v", migrationID, err)) - } else { - logger.Debug("Successfully recorded migration %s (status=%s, schema=%s) - history should be in migrations_history", migrationID, record.Status, record.Schema) - } - } } // Record skipped migrations if any @@ -874,6 +925,62 @@ func (e *Executor) executeSync(ctx context.Context, target *registry.MigrationTa return result, nil } +// OrderMigrationBatch returns migration_ids sorted in dependency order for the given connection. +// Duplicate IDs are preserved in the output (grouped after their migration's topological position). +func (e *Executor) OrderMigrationBatch(migrationIDs []string, connection string) ([]string, error) { + if len(migrationIDs) == 0 { + return nil, nil + } + if connection == "" { + return nil, fmt.Errorf("connection is required") + } + + type pair struct { + id string + m *backends.MigrationScript + } + pairs := make([]pair, 0, len(migrationIDs)) + var unknown []string + for _, id := range migrationIDs { + m := e.GetMigrationByID(id) + if m == nil { + unknown = append(unknown, id) + continue + } + if m.Connection != connection { + return nil, fmt.Errorf("migration %s belongs to connection %q, expected %q", id, m.Connection, connection) + } + pairs = append(pairs, pair{id: id, m: m}) + } + if len(unknown) > 0 { + return nil, fmt.Errorf("unknown migration_id(s): %s", strings.Join(unknown, ", ")) + } + + byBase := make(map[string][]string) + seenBase := make(map[string]bool) + var unique []*backends.MigrationScript + for _, p := range pairs { + base := e.getMigrationID(p.m) + byBase[base] = append(byBase[base], p.id) + if !seenBase[base] { + seenBase[base] = true + unique = append(unique, p.m) + } + } + + sorted, err := e.resolveDependencies(unique) + if err != nil { + return nil, err + } + + out := make([]string, 0, len(migrationIDs)) + for _, m := range sorted { + base := e.getMigrationID(m) + out = append(out, byBase[base]...) + } + return out, nil +} + // GetAllMigrations returns all registered migrations func (e *Executor) GetAllMigrations() []*backends.MigrationScript { return e.registry.GetAll() @@ -1211,6 +1318,36 @@ func (e *Executor) IsMigrationApplied(ctx context.Context, migrationID string) ( return e.stateTracker.IsMigrationApplied(ctx, migrationID) } +// CountPendingAutoMigratable returns how many registered migrations for the given +// connection and backend have a non-empty Schema (fixed-schema) and are not yet +// applied. Dynamic-schema migrations (empty Schema) are excluded — they cannot be +// applied by startup auto-migrate without an explicit schema in the request. +func (e *Executor) CountPendingAutoMigratable(ctx context.Context, connectionName, backend string) (int, error) { + target := ®istry.MigrationTarget{ + Backend: backend, + Connection: connectionName, + } + migrations, err := e.registry.FindByTarget(target) + if err != nil { + return 0, err + } + n := 0 + for _, m := range migrations { + if m == nil || strings.TrimSpace(m.Schema) == "" { + continue + } + id := e.getMigrationID(m) + applied, err := e.stateTracker.IsMigrationApplied(ctx, id) + if err != nil { + return 0, err + } + if !applied { + n++ + } + } + return n, nil +} + // ExecuteUp executes up migrations for the given schemas func (e *Executor) ExecuteUp(ctx context.Context, target *registry.MigrationTarget, connectionName string, schemas []string, dryRun bool, ignoreDependencies bool) (*ExecuteResult, error) { result := &ExecuteResult{ diff --git a/api/internal/executor/executor_cross_connection_test.go b/api/internal/executor/executor_cross_connection_test.go index 1526995..4117bb0 100644 --- a/api/internal/executor/executor_cross_connection_test.go +++ b/api/internal/executor/executor_cross_connection_test.go @@ -64,6 +64,9 @@ func (f *fakeStateTracker) RecordSkippedMigrations(_ interface{}, _ []string, _ func (f *fakeStateTracker) GetSkippedMigrations(_ interface{}, _ string, _ int) ([]*state.SkippedMigration, error) { return nil, nil } +func (f *fakeStateTracker) WithMigrationExecutionLock(_ interface{}, _, _, _ string, fn func() error) error { + return fn() +} func (f *fakeStateTracker) Close() error { return nil } // fakeRegistry provides a minimal Registry for the dependency resolver. diff --git a/api/internal/executor/executor_test.go b/api/internal/executor/executor_test.go index 3b06a06..c4e0c2f 100644 --- a/api/internal/executor/executor_test.go +++ b/api/internal/executor/executor_test.go @@ -40,7 +40,7 @@ func (m *mockRegistry) FindByTarget(target *registry.MigrationTarget) ([]*backen } var results []*backends.MigrationScript for _, migration := range m.migrations { - if target.Backend != "" && migration.Backend != target.Backend { + if target.Backend != "" && !registry.BackendNamesMatch(target.Backend, migration.Backend) { continue } if target.Connection != "" && migration.Connection != target.Connection { @@ -78,7 +78,7 @@ func (m *mockRegistry) GetByConnection(connectionName string) []*backends.Migrat func (m *mockRegistry) GetByBackend(backendName string) []*backends.MigrationScript { var results []*backends.MigrationScript for _, migration := range m.migrations { - if migration.Backend == backendName { + if registry.BackendNamesMatch(backendName, migration.Backend) { results = append(results, migration) } } @@ -366,6 +366,10 @@ func (m *mockStateTracker) GetSkippedMigrations(ctx interface{}, migrationID str return nil, nil } +func (m *mockStateTracker) WithMigrationExecutionLock(_ interface{}, _, _, _ string, fn func() error) error { + return fn() +} + // mockBackend is a mock implementation of backends.Backend type mockBackend struct { name string @@ -715,6 +719,160 @@ func TestExecutor_ExecuteSync_AlreadyApplied(t *testing.T) { } } +func TestExecutor_ExecuteSync_DynamicSchema_NoSchema_ErrorWithoutAutoMigrateContext(t *testing.T) { + reg := newMockRegistry() + tracker := newMockStateTracker() + exec := NewExecutor(reg, tracker) + + migration := &backends.MigrationScript{ + Schema: "", + Version: "20240101120000", + Name: "dynamic_schema", + Connection: "test", + Backend: "postgresql", + UpSQL: "SELECT 1;", + } + _ = reg.Register(migration) + + connections := map[string]*backends.ConnectionConfig{ + "test": { + Backend: "postgresql", + Host: "localhost", + }, + } + _ = exec.SetConnections(connections) + + exec.RegisterBackend("postgresql", newMockBackend("postgresql")) + + target := ®istry.MigrationTarget{ + Connection: "test", + Backend: "postgresql", + } + + result, err := exec.ExecuteSync(context.Background(), target, "test", "", false, false) + if err != nil { + t.Fatalf("ExecuteSync() error = %v", err) + } + if result == nil { + t.Fatal("ExecuteSync() returned nil result") + } + if result.Success { + t.Error("expected Success=false when dynamic schema has no request schema") + } + if len(result.Errors) != 1 || !strings.Contains(result.Errors[0], "dynamic schema but no schema provided") { + t.Errorf("expected dynamic-schema error, got Errors=%v", result.Errors) + } +} + +func TestExecutor_ExecuteSync_DynamicSchema_AutoMigrateContextSkips(t *testing.T) { + reg := newMockRegistry() + tracker := newMockStateTracker() + exec := NewExecutor(reg, tracker) + + migration := &backends.MigrationScript{ + Schema: "", + Version: "20240101120000", + Name: "dynamic_schema", + Connection: "test", + Backend: "postgresql", + UpSQL: "SELECT 1;", + } + _ = reg.Register(migration) + + connections := map[string]*backends.ConnectionConfig{ + "test": { + Backend: "postgresql", + Host: "localhost", + }, + } + _ = exec.SetConnections(connections) + + backend := newMockBackend("postgresql") + exec.RegisterBackend("postgresql", backend) + + target := ®istry.MigrationTarget{ + Connection: "test", + Backend: "postgresql", + } + + ctx := WithAutoMigrateContext(context.Background()) + result, err := exec.ExecuteSync(ctx, target, "test", "", false, false) + if err != nil { + t.Fatalf("ExecuteSync() error = %v", err) + } + if result == nil { + t.Fatal("ExecuteSync() returned nil result") + } + if !result.Success { + t.Errorf("expected Success=true with auto-migrate context, Errors=%v", result.Errors) + } + if len(result.Errors) != 0 { + t.Errorf("expected no errors, got %v", result.Errors) + } + if backend.executeCalled { + t.Error("backend should not execute skipped dynamic-schema migration") + } +} + +func TestExecutor_ExecuteSync_MixedFixedAndDynamic_AutoMigrateContext_AppliesFixedOnly(t *testing.T) { + reg := newMockRegistry() + tracker := newMockStateTracker() + exec := NewExecutor(reg, tracker) + + fixed := &backends.MigrationScript{ + Schema: "public", + Version: "20240101120000", + Name: "fixed_schema", + Connection: "test", + Backend: "postgresql", + UpSQL: "CREATE TABLE t1 (id int);", + } + dynamic := &backends.MigrationScript{ + Schema: "", + Version: "20240101130000", + Name: "dynamic_schema", + Connection: "test", + Backend: "postgresql", + UpSQL: "SELECT 1;", + } + _ = reg.Register(fixed) + _ = reg.Register(dynamic) + + connections := map[string]*backends.ConnectionConfig{ + "test": { + Backend: "postgresql", + Host: "localhost", + }, + } + _ = exec.SetConnections(connections) + + backend := newMockBackend("postgresql") + exec.RegisterBackend("postgresql", backend) + + target := ®istry.MigrationTarget{ + Connection: "test", + Backend: "postgresql", + } + + ctx := WithAutoMigrateContext(context.Background()) + result, err := exec.ExecuteSync(ctx, target, "test", "", false, false) + if err != nil { + t.Fatalf("ExecuteSync() error = %v", err) + } + if result == nil { + t.Fatal("ExecuteSync() returned nil result") + } + if !result.Success { + t.Fatalf("expected Success=true, Errors=%v", result.Errors) + } + if len(result.Applied) != 1 { + t.Fatalf("expected 1 applied migration, got Applied=%v", result.Applied) + } + if !backend.executeCalled || backend.executeMigration == nil || backend.executeMigration.Name != fixed.Name { + t.Errorf("expected fixed migration executed, got migration=%v", backend.executeMigration) + } +} + func TestExecutor_ExecuteSync_DryRun(t *testing.T) { reg := newMockRegistry() tracker := newMockStateTracker() @@ -2624,3 +2782,102 @@ func TestExecutor_ReindexMigrations_GetMigrationListError(t *testing.T) { t.Error("Expected error from ReindexMigrations when GetMigrationList fails, got nil") } } + +func TestExecutor_CountPendingAutoMigratable(t *testing.T) { + fixed := &backends.MigrationScript{ + Version: "1", Name: "a", Backend: "postgresql", Connection: "core", Schema: "core", + UpSQL: "SELECT 1", + } + dynamic := &backends.MigrationScript{ + Version: "2", Name: "b", Backend: "postgresql", Connection: "core", Schema: "", + UpSQL: "SELECT 1", + } + fixedID := "1_a_postgresql_core" + + t.Run("empty registry", func(t *testing.T) { + exec := NewExecutor(newMockRegistry(), newMockStateTracker()) + n, err := exec.CountPendingAutoMigratable(context.Background(), "core", "postgresql") + if err != nil || n != 0 { + t.Fatalf("got n=%d err=%v, want 0 nil", n, err) + } + }) + + t.Run("fixed pending", func(t *testing.T) { + reg := newMockRegistry() + _ = reg.Register(fixed) + exec := NewExecutor(reg, newMockStateTracker()) + n, err := exec.CountPendingAutoMigratable(context.Background(), "core", "postgresql") + if err != nil || n != 1 { + t.Fatalf("got n=%d err=%v, want 1 nil", n, err) + } + }) + + t.Run("fixed applied", func(t *testing.T) { + reg := newMockRegistry() + _ = reg.Register(fixed) + tracker := newMockStateTracker() + tracker.appliedMigrations[fixedID] = true + exec := NewExecutor(reg, tracker) + n, err := exec.CountPendingAutoMigratable(context.Background(), "core", "postgresql") + if err != nil || n != 0 { + t.Fatalf("got n=%d err=%v, want 0 nil", n, err) + } + }) + + t.Run("dynamic only does not count", func(t *testing.T) { + reg := newMockRegistry() + _ = reg.Register(dynamic) + exec := NewExecutor(reg, newMockStateTracker()) + n, err := exec.CountPendingAutoMigratable(context.Background(), "core", "postgresql") + if err != nil || n != 0 { + t.Fatalf("got n=%d err=%v, want 0 nil", n, err) + } + }) + + t.Run("mixed counts fixed only", func(t *testing.T) { + reg := newMockRegistry() + _ = reg.Register(fixed) + _ = reg.Register(dynamic) + exec := NewExecutor(reg, newMockStateTracker()) + n, err := exec.CountPendingAutoMigratable(context.Background(), "core", "postgresql") + if err != nil || n != 1 { + t.Fatalf("got n=%d err=%v, want 1 nil", n, err) + } + }) + + t.Run("postgres alias matches postgresql target", func(t *testing.T) { + reg := newMockRegistry() + alias := &backends.MigrationScript{ + Version: "1", Name: "a", Backend: "postgres", Connection: "core", Schema: "core", + UpSQL: "SELECT 1", + } + _ = reg.Register(alias) + exec := NewExecutor(reg, newMockStateTracker()) + n, err := exec.CountPendingAutoMigratable(context.Background(), "core", "postgresql") + if err != nil || n != 1 { + t.Fatalf("got n=%d err=%v, want 1 nil", n, err) + } + }) + + t.Run("FindByTarget error", func(t *testing.T) { + reg := newMockRegistry() + reg.findByTargetError = errors.New("boom") + exec := NewExecutor(reg, newMockStateTracker()) + _, err := exec.CountPendingAutoMigratable(context.Background(), "core", "postgresql") + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("IsMigrationApplied error", func(t *testing.T) { + reg := newMockRegistry() + _ = reg.Register(fixed) + tracker := newMockStateTracker() + tracker.isAppliedError = errors.New("db down") + exec := NewExecutor(reg, tracker) + _, err := exec.CountPendingAutoMigratable(context.Background(), "core", "postgresql") + if err == nil { + t.Fatal("expected error") + } + }) +} diff --git a/api/internal/registry/dependency_resolver_test.go b/api/internal/registry/dependency_resolver_test.go index 5d7abc6..895bc0e 100644 --- a/api/internal/registry/dependency_resolver_test.go +++ b/api/internal/registry/dependency_resolver_test.go @@ -86,6 +86,10 @@ func (m *mockStateTracker) GetSkippedMigrations(ctx interface{}, migrationID str return nil, nil } +func (m *mockStateTracker) WithMigrationExecutionLock(_ interface{}, _, _, _ string, fn func() error) error { + return fn() +} + func TestDependencyGraph_AddNode(t *testing.T) { graph := NewDependencyGraph() migration := &backends.MigrationScript{ diff --git a/api/internal/registry/interface.go b/api/internal/registry/interface.go index d4486c5..63e8f8b 100644 --- a/api/internal/registry/interface.go +++ b/api/internal/registry/interface.go @@ -2,10 +2,25 @@ package registry import ( "fmt" + "strings" "github.com/toolsascode/bfm/api/internal/backends" ) +// BackendNamesMatch reports whether two backend strings refer to the same engine. +// Config and migration sources may use "postgres" or "postgresql" interchangeably. +func BackendNamesMatch(a, b string) bool { + return normalizedBackendName(a) == normalizedBackendName(b) +} + +func normalizedBackendName(backend string) string { + b := strings.ToLower(strings.TrimSpace(backend)) + if b == "postgres" { + return "postgresql" + } + return b +} + // MigrationTarget specifies which migrations to execute (moved here to avoid import cycle) type MigrationTarget struct { Backend string // Backend type filter @@ -66,7 +81,7 @@ func (r *inMemoryRegistry) FindByTarget(target *MigrationTarget) ([]*backends.Mi var results []*backends.MigrationScript for _, migration := range r.migrations { - if target.Backend != "" && migration.Backend != target.Backend { + if target.Backend != "" && !BackendNamesMatch(target.Backend, migration.Backend) { continue } if target.Connection != "" && migration.Connection != target.Connection { @@ -121,7 +136,7 @@ func (r *inMemoryRegistry) GetByConnection(connectionName string) []*backends.Mi func (r *inMemoryRegistry) GetByBackend(backendName string) []*backends.MigrationScript { var results []*backends.MigrationScript for _, migration := range r.migrations { - if migration.Backend == backendName { + if BackendNamesMatch(backendName, migration.Backend) { results = append(results, migration) } } diff --git a/api/internal/registry/registry_test.go b/api/internal/registry/registry_test.go index 19db8c1..a28b3fa 100644 --- a/api/internal/registry/registry_test.go +++ b/api/internal/registry/registry_test.go @@ -220,6 +220,65 @@ func TestInMemoryRegistry_GetByBackend(t *testing.T) { if len(results) != 0 { t.Errorf("Expected 0 migrations for nonexistent backend, got %v", len(results)) } + + results = reg.GetByBackend("postgres") + if len(results) != 1 || results[0].Name != "migration1" { + t.Errorf("GetByBackend(postgres) should match postgresql migration, got %v", len(results)) + } +} + +func TestBackendNamesMatch(t *testing.T) { + tests := []struct { + a, b string + match bool + }{ + {"postgresql", "postgresql", true}, + {"postgres", "postgresql", true}, + {"PostgreSQL", "postgres", true}, + {"postgresql", "mysql", false}, + {"", "", true}, + {"postgresql", "", false}, + } + for _, tt := range tests { + if got := BackendNamesMatch(tt.a, tt.b); got != tt.match { + t.Errorf("BackendNamesMatch(%q,%q) = %v, want %v", tt.a, tt.b, got, tt.match) + } + } +} + +func TestInMemoryRegistry_FindByTarget_PostgresAlias(t *testing.T) { + reg := NewInMemoryRegistry() + m := &backends.MigrationScript{ + Version: "20240101120000", + Name: "core_schema", + Connection: "core", + Backend: "postgres", + Schema: "core", + UpSQL: "SELECT 1;", + } + if err := reg.Register(m); err != nil { + t.Fatal(err) + } + results, err := reg.FindByTarget(&MigrationTarget{ + Backend: "postgresql", + Connection: "core", + }) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatalf("FindByTarget postgresql+core: got %d migrations, want 1", len(results)) + } + results, err = reg.FindByTarget(&MigrationTarget{ + Backend: "postgres", + Connection: "core", + }) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatalf("FindByTarget postgres+core: got %d migrations, want 1", len(results)) + } } func TestInMemoryRegistry_FindByTarget_WithSchema(t *testing.T) { diff --git a/api/internal/state/errors.go b/api/internal/state/errors.go new file mode 100644 index 0000000..29a2d41 --- /dev/null +++ b/api/internal/state/errors.go @@ -0,0 +1,7 @@ +package state + +import "errors" + +// ErrMigrationAlreadyInProgress is returned when another process holds the execution +// lock for the same migration key (migration_id + schema + connection). +var ErrMigrationAlreadyInProgress = errors.New("migration is already being executed") diff --git a/api/internal/state/history_status.go b/api/internal/state/history_status.go new file mode 100644 index 0000000..156e3b9 --- /dev/null +++ b/api/internal/state/history_status.go @@ -0,0 +1,8 @@ +package state + +// HistoryStatusIndicatesApplied returns true if a migrations_history status means +// the migration completed successfully. The tracker maps executor "success" to "applied" +// when persisting; both must be treated as applied everywhere we interpret history. +func HistoryStatusIndicatesApplied(status string) bool { + return status == "success" || status == "applied" +} diff --git a/api/internal/state/interface.go b/api/internal/state/interface.go index 9b9f730..1fcfadd 100644 --- a/api/internal/state/interface.go +++ b/api/internal/state/interface.go @@ -52,9 +52,15 @@ type StateTracker interface { IsMigrationApplied(ctx interface{}, migrationID string) (bool, error) // IsMigrationPendingOrApplied checks if a migration is pending or applied. - // This is used for concurrency control to prevent multiple processes from executing the same migration. + // For schema-specific IDs, a row in migrations_executions with status pending may indicate + // an in-flight run. For base IDs, migrations_list "pending" only means registered-not-applied; + // cross-process exclusion is enforced via WithMigrationExecutionLock. IsMigrationPendingOrApplied(ctx interface{}, migrationID string) (bool, error) + // WithMigrationExecutionLock runs fn while holding an exclusive lock for this migration + // execution key. If another session holds the lock, returns ErrMigrationAlreadyInProgress. + WithMigrationExecutionLock(ctx interface{}, migrationID, schema, connection string, fn func() error) error + // GetLastMigrationVersion gets the last applied version for a schema/table GetLastMigrationVersion(ctx interface{}, schema, table string) (string, error) diff --git a/api/internal/state/postgresql/advisory_lock.go b/api/internal/state/postgresql/advisory_lock.go new file mode 100644 index 0000000..a8ed80d --- /dev/null +++ b/api/internal/state/postgresql/advisory_lock.go @@ -0,0 +1,47 @@ +package postgresql + +import ( + "context" + "fmt" + "hash/fnv" + + "github.com/toolsascode/bfm/api/internal/state" +) + +// migrationAdvisoryLockKeys derives two int4 keys for pg_try_advisory_lock from execution identity. +func migrationAdvisoryLockKeys(migrationID, schema, connection string) (int32, int32) { + h := fnv.New64a() + _, _ = fmt.Fprintf(h, "%s\x00%s\x00%s", migrationID, schema, connection) + v := h.Sum64() + return int32(v >> 32), int32(v & 0xffffffff) +} + +// WithMigrationExecutionLock runs fn while holding a session-level advisory lock on the state DB. +// The lock is per (migration_id, execution schema, connection) so the same migration can run for different schemas concurrently. +func (t *Tracker) WithMigrationExecutionLock(ctx interface{}, migrationID, schema, connection string, fn func() error) error { + ctxVal := ctx.(context.Context) + + conn, err := t.pool.Acquire(ctxVal) + if err != nil { + return fmt.Errorf("acquire connection for migration lock: %w", err) + } + + k1, k2 := migrationAdvisoryLockKeys(migrationID, schema, connection) + var ok bool + if err := conn.QueryRow(ctxVal, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, k1, k2).Scan(&ok); err != nil { + conn.Release() + return fmt.Errorf("pg_try_advisory_lock: %w", err) + } + if !ok { + conn.Release() + return state.ErrMigrationAlreadyInProgress + } + + defer func() { + ctxUnlock := context.Background() + _, _ = conn.Exec(ctxUnlock, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, k1, k2) + conn.Release() + }() + + return fn() +} diff --git a/api/internal/state/postgresql/tracker.go b/api/internal/state/postgresql/tracker.go index 861c768..5a03154 100644 --- a/api/internal/state/postgresql/tracker.go +++ b/api/internal/state/postgresql/tracker.go @@ -774,7 +774,7 @@ func (t *Tracker) GetMigrationHistory(ctx interface{}, filters *state.MigrationF } } - query += " ORDER BY applied_at DESC" + query += " ORDER BY applied_at DESC, id DESC" rows, err := t.pool.Query(ctxVal, query, args...) if err != nil { @@ -1381,7 +1381,9 @@ func (t *Tracker) IsMigrationApplied(ctx interface{}, migrationID string) (bool, } // IsMigrationPendingOrApplied checks if a migration is pending or applied. -// This is used for concurrency control to prevent multiple processes from executing the same migration. +// For base migration IDs, migrations_list "pending" means registered-not-applied, not in-flight; this +// matches IsMigrationApplied (applied only). For schema-specific IDs, migrations_executions may hold +// status pending while a run is in progress. func (t *Tracker) IsMigrationPendingOrApplied(ctx interface{}, migrationID string) (bool, error) { ctxVal := ctx.(context.Context) @@ -1403,54 +1405,42 @@ func (t *Tracker) IsMigrationPendingOrApplied(ctx interface{}, migrationID strin } } - // For dynamic schemas, check migrations_executions - if schemaName != "" { - var version, connection, backend string - getMetadataQuery := fmt.Sprintf(` - SELECT version, connection, backend - FROM %s - WHERE migration_id = $1 - LIMIT 1 - `, listTableName) - err := t.pool.QueryRow(ctxVal, getMetadataQuery, baseMigrationID).Scan(&version, &connection, &backend) - if err != nil { - if err == pgx.ErrNoRows { - return false, nil - } - return false, fmt.Errorf("failed to get migration metadata: %w", err) - } + // Fixed-schema / base ID: list "pending" is not an execution lock; align with applied-only check. + if schemaName == "" { + return t.IsMigrationApplied(ctx, migrationID) + } - query := fmt.Sprintf(` - SELECT EXISTS( - SELECT 1 FROM %s - WHERE migration_id = $1 - AND schema = $2 - AND version = $3 - AND connection = $4 - AND backend = $5 - AND (status = 'applied' OR status = 'pending') - )`, executionsTableName) - var exists bool - err = t.pool.QueryRow(ctxVal, query, baseMigrationID, schemaName, version, connection, backend).Scan(&exists) - if err != nil { - return false, fmt.Errorf("failed to check migration status in executions table: %w", err) + // Schema-specific: check migrations_executions for applied or in-flight pending. + var version, connection, backend string + getMetadataQuery := fmt.Sprintf(` + SELECT version, connection, backend + FROM %s + WHERE migration_id = $1 + LIMIT 1 + `, listTableName) + err := t.pool.QueryRow(ctxVal, getMetadataQuery, baseMigrationID).Scan(&version, &connection, &backend) + if err != nil { + if err == pgx.ErrNoRows { + return false, nil } - return exists, nil + return false, fmt.Errorf("failed to get migration metadata: %w", err) } - // For fixed-schema migrations, check migrations_list query := fmt.Sprintf(` SELECT EXISTS( SELECT 1 FROM %s - WHERE migration_id IN ($1, $2) + WHERE migration_id = $1 + AND schema = $2 + AND version = $3 + AND connection = $4 + AND backend = $5 AND (status = 'applied' OR status = 'pending') - )`, listTableName) + )`, executionsTableName) var exists bool - err := t.pool.QueryRow(ctxVal, query, migrationID, baseMigrationID).Scan(&exists) + err = t.pool.QueryRow(ctxVal, query, baseMigrationID, schemaName, version, connection, backend).Scan(&exists) if err != nil { - return false, fmt.Errorf("failed to check migration status: %w", err) + return false, fmt.Errorf("failed to check migration status in executions table: %w", err) } - return exists, nil } diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 4f8371c..122c2bf 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -19,7 +19,7 @@ services: # PostgreSQL for state tracking postgres: <<: *logging - image: postgres:16-alpine + image: postgres:18-alpine container_name: bfm-postgres-dev ports: - "5433:5432" @@ -34,7 +34,8 @@ services: -c shared_buffers=256MB -c effective_cache_size=1GB volumes: - - bfm-postgres-data-dev:/var/lib/postgresql/data + # PG 18+: mount the parent dir (versioned data lives under e.g. data/18/docker); see docker-library/postgres#1259 + - bfm-postgres-data-dev:/var/lib/postgresql networks: - bfm-network-dev restart: unless-stopped @@ -63,6 +64,8 @@ services: - BFM_GRPC_PORT=9090 - BFM_API_TOKEN=${BFM_API_TOKEN:-SFXfytYJr3RfrjPMgEkhTEukOGpjhtLEmmJFYv+7GHQ=} - BFM_SFM_PATH=${BFM_SFM_PATH:-/sfm} + - BFM_AUTO_MIGRATE=${BFM_AUTO_MIGRATE:-true} + # - BFM_AUTO_MIGRATE_CONNECTIONS=${BFM_AUTO_MIGRATE_CONNECTIONS:-core,guard,organization} # State Database Configuration - BFM_STATE_BACKEND=postgresql diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index b3ba75c..7a82376 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -24,7 +24,7 @@ services: # PostgreSQL for state tracking postgres: <<: *logging - image: postgres:16-alpine + image: postgres:18-alpine container_name: bfm-postgres ports: - "5433:5432" @@ -39,7 +39,8 @@ services: -c shared_buffers=256MB -c effective_cache_size=1GB volumes: - - bfm-postgres-data:/var/lib/postgresql/data + # PG 18+: mount the parent dir (versioned data lives under e.g. data/18/docker); see docker-library/postgres#1259 + - bfm-postgres-data:/var/lib/postgresql networks: - bfm-network restart: unless-stopped diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 05efdb5..7d1d76c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -140,6 +140,30 @@ Migrations are automatically triggered when: - A new environment is created - Core/Guard schemas need initialization +### BfM server startup auto-migrate + +The API server (`cmd/server`) runs pending **up** migrations per configured connection shortly after startup, then **retries in rounds** until there are no remaining **fixed-schema** migrations to apply, a stall is detected (no progress with no errors), or limits are reached. + +- **`BFM_AUTO_MIGRATE`**: enabled by default when unset. Set to `false`, `0`, `off`, or `no` to disable. +- **`BFM_AUTO_MIGRATE_CONNECTIONS`**: optional comma-separated list of connection names (e.g. `core,guard`). If unset, every connection defined in config is considered, subject to the readiness rules below. +- **`BFM_AUTO_MIGRATE_RETRY_INTERVAL`**: duration between full rounds over all ready connections (default `5s`). Go duration syntax (e.g. `10s`, `1m`). Set to **`0`** or **`0s`** for **legacy single-pass** behavior (one round only, after the initial startup delay). +- **`BFM_AUTO_MIGRATE_RETRY_MAX_ROUNDS`**: maximum number of rounds when the retry interval is positive (default `24`). Ignored when the retry interval is zero (only one round runs). + +**Readiness (incomplete connections are skipped):** Auto-migrate does not call `ExecuteUp` for a connection if its env config is obviously incomplete for the backend, to avoid useless dials (e.g. etcd logging retries to `:2379` when no endpoints or host+port are set). + +- **postgresql**: requires non-empty `Host` (`{CONN}_DB_HOST`). +- **greptimedb**: requires non-empty `Host`. +- **etcd**: requires non-empty `{CONN}_ENDPOINTS` (or any extra key whose name matches `endpoints`, case-insensitive), **or** both `Host` and `Port` non-empty. +- **Other backends**: no extra check (forward compatible). + +If you declare `METADATA_BACKEND=etcd` but do not configure etcd endpoints in this environment, that connection is skipped until you set `METADATA_ENDPOINTS` (or host+port) or list only ready connections via `BFM_AUTO_MIGRATE_CONNECTIONS`. + +This uses the same `ExecuteUp` path as the HTTP API (synchronous, not the async queue). **Dynamic-schema migrations** (empty schema in the migration definition) still need an explicit schema in a manual/API run; auto-migrate passes an empty schema, so those migrations are **skipped** (info log) until you run migrate up with `schemas` set. They are also **excluded from the pending count** that drives retries, so the loop can finish while the migration list UI still shows those rows as pending. Fixed-schema migrations on the same connection still apply during auto-migrate. + +If every round applies nothing, reports no errors, and the fixed-schema pending count does not drop, auto-migrate **stops with a warning** (e.g. backend/connection name mismatch between config and registered migrations); fix configuration and restart or run migrations via the API. + +**PostgreSQL naming:** The registry treats **`postgres`** and **`postgresql`** as the same backend when matching config to registered migrations (e.g. config `postgresql` with migration metadata `postgres`). Migration IDs still use whatever backend string is stored on each script. + ### Manual Migration You can also trigger migrations manually via API: diff --git a/examples/sfm/postgresql/core/20260101120000_core_schema_example_settings.down.sql b/examples/sfm/postgresql/core/20260101120000_core_schema_example_settings.down.sql new file mode 100644 index 0000000..2560f0c --- /dev/null +++ b/examples/sfm/postgresql/core/20260101120000_core_schema_example_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS core_schema_example_settings; diff --git a/examples/sfm/postgresql/core/20260101120000_core_schema_example_settings.go b/examples/sfm/postgresql/core/20260101120000_core_schema_example_settings.go new file mode 100644 index 0000000..962dba5 --- /dev/null +++ b/examples/sfm/postgresql/core/20260101120000_core_schema_example_settings.go @@ -0,0 +1,30 @@ +//go:build ignore + +package core + +import ( + _ "embed" + + "github.com/toolsascode/bfm/api/migrations" +) + +//go:embed 20260101120000_core_schema_example_settings.up.sql +var upSQLCoreSchemaExampleSettings string + +//go:embed 20260101120000_core_schema_example_settings.down.sql +var downSQLCoreSchemaExampleSettings string + +func init() { + migration := &migrations.MigrationScript{ + Schema: "core", + Version: "20260101120000", + Name: "core_schema_example_settings", + Connection: "core", + Backend: "postgresql", + UpSQL: upSQLCoreSchemaExampleSettings, + DownSQL: downSQLCoreSchemaExampleSettings, + Dependencies: []string{}, + StructuredDependencies: []migrations.Dependency{}, + } + migrations.GlobalRegistry.Register(migration) +} diff --git a/examples/sfm/postgresql/core/20260101120000_core_schema_example_settings.up.sql b/examples/sfm/postgresql/core/20260101120000_core_schema_example_settings.up.sql new file mode 100644 index 0000000..34e648d --- /dev/null +++ b/examples/sfm/postgresql/core/20260101120000_core_schema_example_settings.up.sql @@ -0,0 +1,6 @@ +-- Example: fixed-schema migration (Schema: "core" in .go). Suitable for BFM_AUTO_MIGRATE. +CREATE TABLE IF NOT EXISTS core_schema_example_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/examples/sfm/postgresql/core/20260101120100_core_schema_example_audit.down.sql b/examples/sfm/postgresql/core/20260101120100_core_schema_example_audit.down.sql new file mode 100644 index 0000000..40cb1be --- /dev/null +++ b/examples/sfm/postgresql/core/20260101120100_core_schema_example_audit.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS core_schema_example_audit; diff --git a/examples/sfm/postgresql/core/20260101120100_core_schema_example_audit.go b/examples/sfm/postgresql/core/20260101120100_core_schema_example_audit.go new file mode 100644 index 0000000..fda4de2 --- /dev/null +++ b/examples/sfm/postgresql/core/20260101120100_core_schema_example_audit.go @@ -0,0 +1,30 @@ +//go:build ignore + +package core + +import ( + _ "embed" + + "github.com/toolsascode/bfm/api/migrations" +) + +//go:embed 20260101120100_core_schema_example_audit.up.sql +var upSQLCoreSchemaExampleAudit string + +//go:embed 20260101120100_core_schema_example_audit.down.sql +var downSQLCoreSchemaExampleAudit string + +func init() { + migration := &migrations.MigrationScript{ + Schema: "core", + Version: "20260101120100", + Name: "core_schema_example_audit", + Connection: "core", + Backend: "postgresql", + UpSQL: upSQLCoreSchemaExampleAudit, + DownSQL: downSQLCoreSchemaExampleAudit, + Dependencies: []string{"core_schema_example_settings"}, + StructuredDependencies: []migrations.Dependency{}, + } + migrations.GlobalRegistry.Register(migration) +} diff --git a/examples/sfm/postgresql/core/20260101120100_core_schema_example_audit.up.sql b/examples/sfm/postgresql/core/20260101120100_core_schema_example_audit.up.sql new file mode 100644 index 0000000..6a78c71 --- /dev/null +++ b/examples/sfm/postgresql/core/20260101120100_core_schema_example_audit.up.sql @@ -0,0 +1,10 @@ +-- Depends on core_schema_example_settings (same fixed schema "core"). +CREATE TABLE IF NOT EXISTS core_schema_example_audit ( + id BIGSERIAL PRIMARY KEY, + event_type TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_core_schema_example_audit_created_at + ON core_schema_example_audit (created_at DESC); diff --git a/ffm/package-lock.json b/ffm/package-lock.json index 42ac7df..92b2cc3 100644 --- a/ffm/package-lock.json +++ b/ffm/package-lock.json @@ -34,19 +34,6 @@ "vite": "^8.0.11" } }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "dev": true, @@ -829,50 +816,65 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, - "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", + "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.2.4" } }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", "cpu": [ "arm64" ], @@ -886,10 +888,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", "cpu": [ "arm64" ], @@ -903,10 +905,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", "cpu": [ "x64" ], @@ -920,10 +922,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", "cpu": [ "x64" ], @@ -937,10 +939,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", "cpu": [ "arm" ], @@ -954,10 +956,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", "cpu": [ "arm64" ], @@ -971,10 +973,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", "cpu": [ "arm64" ], @@ -988,10 +990,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", "cpu": [ "x64" ], @@ -1005,10 +1007,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", "cpu": [ "x64" ], @@ -1022,10 +1024,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1041,10 +1043,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", - "@napi-rs/wasm-runtime": "^1.1.4", + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -1052,118 +1054,326 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], "dev": true, - "inBundle": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], "dev": true, - "inBundle": true, "license": "MIT", "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "tslib": "^2.4.0" + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", + "node_modules/@tailwindcss/vite/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, - "inBundle": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", + "node_modules/@tailwindcss/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "inBundle": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", + "node_modules/@tailwindcss/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "inBundle": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", + "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 20" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "node_modules/@tailwindcss/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 20" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", - "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", "dev": true, "license": "MIT", "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "postcss": "^8.5.10", - "tailwindcss": "4.3.0" + "@babel/types": "^7.0.0" } }, "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { @@ -2005,9 +2215,9 @@ "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.21.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", - "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "dev": true, "license": "MIT", "dependencies": { @@ -2418,18 +2628,6 @@ "node": ">= 6" } }, - "node_modules/fraction.js": { - "version": "5.3.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3178,11 +3376,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "dev": true, - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, diff --git a/ffm/postcss.config.cjs b/ffm/postcss.config.cjs deleted file mode 100644 index e564072..0000000 --- a/ffm/postcss.config.cjs +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; diff --git a/ffm/src/components/Dashboard.tsx b/ffm/src/components/Dashboard.tsx index 3bb7d1b..19f6dea 100644 --- a/ffm/src/components/Dashboard.tsx +++ b/ffm/src/components/Dashboard.tsx @@ -411,14 +411,21 @@ export default function Dashboard() { ))}