diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index fdd4e18..6be186e 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -51,6 +51,11 @@ jobs:
export PATH="${PATH}:$(go env GOPATH)/bin"
cd internal/api/protobuf && ./generate.sh
+ # handler.go uses //go:embed swagger.yaml; file is gitignored locally but required for typecheck
+ - name: Prepare OpenAPI embed for lint
+ working-directory: api
+ run: cp docs/swagger.yaml internal/api/http/swagger.yaml
+
- name: Install golangci-lint
run: |
export PATH="${PATH}:$(go env GOPATH)/bin"
diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml
index 1eebf6e..5a3766f 100644
--- a/.github/workflows/test-cli.yml
+++ b/.github/workflows/test-cli.yml
@@ -51,6 +51,11 @@ jobs:
run: |
cd internal/api/protobuf && ./generate.sh
+ # Same as lint.yml / Docker: //go:embed swagger.yaml next to handler.go
+ - name: Prepare OpenAPI embed
+ working-directory: api
+ run: cp docs/swagger.yaml internal/api/http/swagger.yaml
+
- name: Run tests
working-directory: api
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
diff --git a/.gitignore b/.gitignore
index 41465f0..3941768 100644
--- a/.gitignore
+++ b/.gitignore
@@ -106,3 +106,4 @@ prompt.md
api/internal/api/http/docs.go
api/internal/api/http/swagger.yaml
api/internal/api/http/swagger.json
+ffm/public/runtime-config.js
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 078eda4..9e7248c 100644
--- a/Makefile
+++ b/Makefile
@@ -369,7 +369,7 @@ COMPOSE_DEV_FILE := deploy/docker-compose.dev.yml
dev-docker: dev-docker-build ## Start all services in Docker with hot-reload
@echo "$(GREEN)Starting development environment with hot-reload in Docker...$(NC)"
- docker compose -f $(COMPOSE_DEV_FILE) up -d
+ docker compose -p bfm-dev -f $(COMPOSE_DEV_FILE) up -d
@echo "$(GREEN)Development environment ready!$(NC)"
@echo ""
@echo " BFM Server: http://localhost:7070"
@@ -382,28 +382,28 @@ dev-docker: dev-docker-build ## Start all services in Docker with hot-reload
dev-docker-build: ## Build development Docker images
@echo "$(GREEN)Building development images...$(NC)"
- docker compose -f $(COMPOSE_DEV_FILE) build
+ docker compose -p bfm-dev -f $(COMPOSE_DEV_FILE) build
dev-docker-down: ## Stop development Docker services
@echo "$(YELLOW)Stopping development services...$(NC)"
- docker compose -f $(COMPOSE_DEV_FILE) down
+ docker compose -p bfm-dev -f $(COMPOSE_DEV_FILE) down
dev-docker-logs: ## Show logs from development services
- docker compose -f $(COMPOSE_DEV_FILE) logs -f
+ docker compose -p bfm-dev -f $(COMPOSE_DEV_FILE) logs -f
dev-docker-logs-bfm: ## Show logs from BFM server (dev)
- docker compose -f $(COMPOSE_DEV_FILE) logs -f bfm-server
+ docker compose -p bfm-dev -f $(COMPOSE_DEV_FILE) logs -f bfm-server
dev-docker-logs-ffm: ## Show logs from FFM frontend (dev)
- docker compose -f $(COMPOSE_DEV_FILE) logs -f ffm
+ docker compose -p bfm-dev -f $(COMPOSE_DEV_FILE) logs -f ffm
dev-docker-ps: ## Show status of development services
@echo "$(GREEN)Development Service Status:$(NC)"
- @docker compose -f $(COMPOSE_DEV_FILE) ps
+ @docker compose -p bfm-dev -f $(COMPOSE_DEV_FILE) ps
dev-docker-restart: ## Restart development services
@echo "$(YELLOW)Restarting development services...$(NC)"
- docker compose -f $(COMPOSE_DEV_FILE) restart
+ docker compose -p bfm-dev -f $(COMPOSE_DEV_FILE) restart
@make dev-docker-ps
dev-docker-clean: ## Stop and remove development containers and volumes
@@ -411,7 +411,7 @@ dev-docker-clean: ## Stop and remove development containers and volumes
@read -p "Are you sure? [y/N] " -n 1 -r; \
echo; \
if [[ $$REPLY =~ ^[Yy]$$ ]]; then \
- docker compose -f $(COMPOSE_DEV_FILE) down -v; \
+ docker compose -p bfm-dev -f $(COMPOSE_DEV_FILE) down -v; \
echo "$(GREEN)Cleanup complete!$(NC)"; \
fi
diff --git a/README.md b/README.md
index 04c8a13..ba551c6 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -13,18 +13,24 @@ Backend For Migrations (BfM)
-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.
+## What is BfM?
+
+**BfM (Backend for Migrations)** is a migration control plane for teams that run **PostgreSQL**, **GreptimeDB**, or **etcd** workloads. It exposes **HTTP** and **gRPC** APIs so migrations are executed in **one place** instead of from every app instance—reducing race conditions and inconsistent schema state in scaled deployments.
+
+BfM tracks migration state in a dedicated database, supports **fixed** schemas and **per-tenant (dynamic)** schema execution, and can resolve **dependencies** and optional **`key=value` tags** when selecting what to run. A web UI (**FFM**) ships with the server for operators.
## Features
-- Multi-backend support: PostgreSQL, GreptimeDB, Etcd
-- HTTP REST API with authentication
-- Protobuf/gRPC API (requires code generation)
-- Migration state tracking in PostgreSQL/MySQL
-- Support for fixed and dynamic schemas
-- Embedded SQL scripts in Go files
-- Dry-run mode for testing
-- Idempotent migrations
+- **Multi-backend**: PostgreSQL, GreptimeDB, etcd
+- **HTTP REST API** with bearer token authentication
+- **gRPC API** (Protobuf definitions in-repo; see [`api/internal/api/protobuf/migration.proto`](api/internal/api/protobuf/migration.proto))
+- **State tracking** (PostgreSQL/MySQL for migration metadata)
+- **Fixed and dynamic schemas** (runtime schema name in the migrate request)
+- **Dependency-aware execution** (expand, order, validate; optional opt-out)
+- **Tag filters** (`target.tags`: `key=value` strings, AND semantics)
+- **Dry-run** and idempotent operation patterns
+- **Embedded SQL/JSON** in generated Go registration (build-time)
+- **FFM** dashboard (migrations list, detail, manual runs)
## Screenshots
@@ -33,621 +39,15 @@ BfM is a comprehensive database migration system that supports multiple backends
|
|
|
|
|
|
-## Configuration
-
-### Environment Variables
-
-#### Server Configuration
-
-- `BFM_HTTP_PORT` - HTTP server port (default: 7070)
-- `BFM_GRPC_PORT` - gRPC server port (default: 9090)
-- `BFM_API_TOKEN` - API token for authentication (required)
-
-#### State Database Configuration
-
-- `BFM_STATE_BACKEND` - State database type: "postgresql" or "mysql" (default: "postgresql")
-- `BFM_STATE_DB_HOST` - State database host (default: "localhost")
-- `BFM_STATE_DB_PORT` - State database port (default: "5432")
-- `BFM_STATE_DB_USERNAME` - State database username (default: "postgres")
-- `BFM_STATE_DB_PASSWORD` - State database password (required)
-- `BFM_STATE_DB_NAME` - State database name (default: "migration_state")
-- `BFM_STATE_SCHEMA` - State database schema (default: "public")
-
-#### Connection Configuration
-
-For each connection (e.g., "core", "guard", "logs"), set:
-
-- `{CONNECTION}_BACKEND` - Backend type: "postgresql", "greptimedb", or "etcd"
-- `{CONNECTION}_DB_HOST` - Database host
-- `{CONNECTION}_DB_PORT` - Database port
-- `{CONNECTION}_DB_USERNAME` - Database username
-- `{CONNECTION}_DB_PASSWORD` - Database password
-- `{CONNECTION}_DB_NAME` - Database name
-- `{CONNECTION}_SCHEMA` - Schema name (optional, for fixed schemas)
-
-Example:
-
-```bash
-CORE_BACKEND=postgresql
-CORE_DB_HOST=localhost
-CORE_DB_PORT=5432
-CORE_DB_USERNAME=dashcloud
-CORE_DB_PASSWORD=password
-CORE_DB_NAME=dashcloud
-CORE_SCHEMA=core
-```
-
-## Development
-
-For development environment setup, local development, and hot-reload configuration, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
-
-## Production Deployment
-
-### Using Docker Image
-
-BfM provides a production-ready Docker image that includes:
-
-- BfM API Server (HTTP on port 7070, gRPC on port 9090)
-- BfM Worker (optional, enabled via `BFM_QUEUE_ENABLED=true`)
-- BfM CLI tool (available inside container)
-- FfM Frontend (served via the API server)
-
-#### Registry
-
-Install from the command line
-
-``` bash
-docker pull ghcr.io/toolsascode/bfm:latest
-```
-
-#### Building the Production Image
-
-Build the standalone production Docker image:
-
-```bash
-# Using Makefile
-make prod-build
-
-# Or manually
-docker build -t bfm-production:latest -f docker/Dockerfile .
-```
-
-#### Running with Docker Compose (Recommended)
-
-The easiest way to run BfM in production is using the standalone Docker Compose configuration:
-
-1. **Create environment file:**
-
-```bash
-# Copy and edit environment variables
-cp .env.example .env
-```
-
-2. **Configure environment variables:**
-
-Set the following required variables in your `.env` file:
-
-```bash
-# API Authentication
-BFM_API_TOKEN=your-secure-random-token-here
-
-# State Database
-BFM_STATE_DB_PASSWORD=your-secure-password
-BFM_STATE_DB_USERNAME=postgres
-
-# Backend Connections (configure as needed)
-CORE_DB_HOST=your-postgres-host
-CORE_DB_PASSWORD=your-password
-CORE_DB_NAME=your-database
-
-# Optional: Queue Configuration
-BFM_QUEUE_ENABLED=false # Set to true to enable worker
-```
-
-3. **Start the service:**
-
-```bash
-# Using Makefile
-make standalone-up
-
-# Or manually
-docker compose -p bfm-standalone -f deploy/docker-compose.standalone.yml up -d --build
-```
-
-4. **Verify the service:**
-
-```bash
-# Check health
-curl http://localhost:7070/health
-
-# Check service status
-make standalone-ps
-# or
-docker compose -p bfm-standalone -f deploy/docker-compose.standalone.yml ps
-```
-
-5. **View logs:**
-
-```bash
-# Using Makefile
-make standalone-logs
-
-# Or manually
-docker compose -p bfm-standalone -f deploy/docker-compose.standalone.yml logs -f
-```
-
-6. **Stop the service:**
-
-```bash
-# Using Makefile
-make standalone-down
-
-# Or manually
-docker compose -p bfm-standalone -f deploy/docker-compose.standalone.yml down
-```
-
-#### Running with Docker Run
-
-For more control, you can run the container directly:
-
-```bash
-docker run -d \
- --name bfm-production \
- -p 7070:7070 \
- -p 9090:9090 \
- -e BFM_API_TOKEN=your-secure-token \
- -e BFM_STATE_DB_HOST=postgres \
- -e BFM_STATE_DB_PASSWORD=your-password \
- -e CORE_DB_HOST=your-postgres-host \
- -e CORE_DB_PASSWORD=your-password \
- -v /path/to/your/sfm:/app/sfm:ro \
- bfm-production:latest
-```
-
-#### Accessing the Services
-
-Once running, you can access:
-
-- **Frontend UI**:
-- **HTTP API**:
-- **OpenAPI Spec (YAML)**:
-- **OpenAPI Spec (JSON)**:
-- **gRPC API**: localhost:9090
-- **Health Check**:
-
-#### Using the CLI Inside Container
-
-The BfM CLI is available inside the production container:
-
-```bash
-# Execute CLI commands
-docker exec bfm-standalone /app/bin/bfm-cli --help
-docker exec bfm-standalone /app/bin/bfm-cli version
-
-# Build migration files from SFM directory
-docker exec bfm-standalone /app/bin/bfm-cli build /app/sfm --verbose
-```
-
-### BfM CLI Tool
-
-The BfM CLI is a command-line tool for generating migration `.go` files from SQL/JSON scripts.
-
-## How to install?
-
-### Go Install
-
-```shell
-go install github.com/toolsascode/bfm@latest
-```
-
-### Via Github
-
-- [Latest version](https://github.com/toolsascode/bfm/releases/latest)
-
-```shell
-curl -fLSs https://raw.githubusercontent.com/toolsascode/bfm/main/scripts/install.sh | bash
-```
-
-Or
-
-```shell
-curl -fLSs https://raw.githubusercontent.com/toolsascode/bfm/main/scripts/install.sh | sudo bash
-```
-
-### Homebrew
-
-```shell
-brew install toolsascode/tap/bfm
-```
-
-### Scoop
-
-1. Run **PowerShell as an Administrator** and:
-2. To add this bucket, run `scoop bucket add bfm-scoop https://github.com/toolsascode/scoop-bucket`.
-3. To install, do `scoop install bfm`.
-
-#### Building the CLI
-
-```bash
-# Using Makefile
-make build-cli
-
-# Or manually
-cd api && go build -o ../bfm-cli ./cmd/cli
-```
-
-#### CLI Commands
-
-**Version:**
-
-```bash
-./bfm-cli version
-```
-
-**Build Migration Files:**
-
-Generate `.go` files from migration scripts in the SFM directory:
-
-```bash
-# Basic usage
-./bfm-cli build examples/sfm
-
-# With verbose output
-./bfm-cli build examples/sfm --verbose
-
-# Dry run (show what would be generated)
-./bfm-cli build examples/sfm --dry-run
-
-# Custom output directory
-./bfm-cli build examples/sfm --output /path/to/output
-
-# Custom path
-./bfm-cli build /path/to/sfm --verbose
-```
-
-#### SFM Directory Structure
-
-The CLI expects migration scripts in the following structure:
-
-```
-{sfm_path}/
- {backend}/ # The first folder after SfM is considered the backend.
- {connection}/ # The last folder will be considered a connection.
- {version}_{name}.up.sql
- {version}_{name}.down.sql
- # OR for etcd
- {version}_{name}.up.json
- {version}_{name}.down.json
-```
-
-Example:
-
-```
-examples/sfm/
- postgresql/
- core/
- 20250101120000_create_users.up.sql
- 20250101120000_create_users.down.sql
- greptimedb/
- logs/
- 20250101120000_create_metrics.up.sql
- 20250101120000_create_metrics.down.sql
- etcd/
- metadata/
- 20250101120000_init_config.up.json
- 20250101120000_init_config.down.json
-```
-
-The CLI will generate corresponding `.go` files that embed the SQL/JSON and register migrations in the global registry.
-
-#### Using CLI in Production
-
-In production environments, you can:
-
-1. **Build migrations as part of CI/CD:**
-
-```bash
-# In your build pipeline
-./bfm-cli build /path/to/migrations --output /path/to/generated
-go build -o bfm-server ./cmd/server
-```
-
-2. **Use in Docker builds:**
-
-```dockerfile
-# Copy migration scripts
-COPY migrations/sfm /app/sfm
-
-# Generate .go files
-RUN /app/bin/bfm-cli build /app/sfm
-
-# Build server with generated migrations
-RUN go build -o bfm-server ./cmd/server
-```
-
-3. **Validate migrations before deployment:**
-
-```bash
-# Dry run to check for errors
-./bfm-cli build /path/to/migrations --dry-run --verbose
-```
-
-### Production Best Practices
-
-1. **Security:**
- - Use strong, randomly generated API tokens
- - Store credentials in secret management systems (e.g., HashiCorp Vault, AWS Secrets Manager)
- - Enable TLS/HTTPS via reverse proxy (nginx, Traefik)
- - Restrict network access to BfM API
-
-2. **High Availability:**
- - Run multiple BfM instances behind a load balancer
- - Use PostgreSQL replication for state database
- - Implement distributed locking to prevent concurrent migrations
- - Monitor health endpoints
-
-3. **Monitoring:**
- - Set up health check monitoring (`GET /health`)
- - Collect logs via centralized logging (ELK, Loki, etc.)
- - Track migration execution metrics
- - Alert on migration failures
-
-4. **Backup:**
- - Regularly backup the state database
- - Version control all migration scripts
- - Test restore procedures
-
-5. **Migration Management:**
- - Always test migrations in staging first
- - Use dry-run mode before applying migrations
- - Keep migration scripts idempotent
- - Document complex migrations
-
-See `docs/DEPLOYMENT.md` for more detailed deployment instructions.
-
-## Usage
-
-### OpenAPI Specification
-
-BfM provides a complete OpenAPI v3.2.0 specification for the HTTP API. The specification is available at:
-
-- **YAML format**: `http://localhost:7070/api/v1/openapi.yaml`
-- **JSON format**: `http://localhost:7070/api/v1/openapi.json`
-
-The OpenAPI specification includes:
-- Complete API endpoint documentation
-- Request/response schemas
-- Authentication requirements
-- Example requests and responses
-- Error response formats
-
-You can use the OpenAPI spec with tools like:
-
-- [Swagger UI](https://swagger.io/tools/swagger-ui/) - Interactive API documentation
-- [Postman](https://www.postman.com/) - API testing and development
-- [OpenAPI Generator](https://openapi-generator.tech/) - Generate client SDKs
-- [Redoc](https://github.com/Redocly/redoc) - Beautiful API documentation
-
-**Example: View OpenAPI spec in Swagger UI**
-
-```bash
-# Using Docker
-docker run -p 8080:8080 -e SWAGGER_JSON=/openapi.yaml -v $(pwd)/api/internal/api/http/openapi.yaml:/openapi.yaml swaggerapi/swagger-ui
-
-# Or use online Swagger Editor
-# Paste the content from http://localhost:7070/api/v1/openapi.yaml
-```
-
-### HTTP API
-
-For a step-by-step, procedural guide on executing **one**, **some**, or **all** migrations (including the dynamic-schema gotchas), see [docs/EXECUTING_MIGRATIONS.md](docs/EXECUTING_MIGRATIONS.md).
-
-#### Migrate Endpoint
-
-```bash
-POST /api/v1/migrations/up
-Authorization: Bearer {BFM_API_TOKEN}
-Content-Type: application/json
-
-{
- "target": {
- "backend": "postgresql",
- "schema": "core",
- "tables": [],
- "version": "",
- "connection": "core"
- },
- "connection": "core",
- "schemas": ["core"],
- "dry_run": false,
- "ignore_dependencies": false
-}
-```
-
-Response:
-
-```json
-{
- "success": true,
- "applied": ["core_users_20250101120000_create_users"],
- "skipped": [],
- "errors": []
-}
-```
-
-**Note**: For complete API documentation, see the [OpenAPI Specification](#openapi-specification) section above.
-
-#### Ignoring Dependencies
-
-By default, BfM validates and respects migration dependencies, ensuring migrations execute in the correct order. However, you can force execution of a migration regardless of dependencies by setting `ignore_dependencies: true` in your request:
-
-```bash
-POST /api/v1/migrations/up
-Authorization: Bearer {BFM_API_TOKEN}
-Content-Type: application/json
-
-{
- "target": {
- "backend": "postgresql",
- "connection": "core",
- "version": "20250115120000"
- },
- "connection": "core",
- "schemas": ["core"],
- "ignore_dependencies": true
-}
-```
-
-When `ignore_dependencies` is `true`:
-- Dependency resolution is skipped
-- Migrations are sorted by version only
-- Dependency validation is bypassed
-- The migration executes immediately, even if dependencies are not satisfied
-
-**Warning**: Use this option with caution, as it can lead to execution errors if required dependencies are not met.
-
-#### Template Variables in Migrations
-
-BfM supports template variable replacement in migration files. Variables are replaced at execution time with actual values. Available variables:
-
-- `{{.Connection}}` - Connection name
-- `{{.Schema}}` - Schema name (uses the schema from the execution context)
-- `{{.Backend}}` - Backend name (e.g., "postgresql", "greptimedb", "etcd")
-- `{{.Version}}` - Migration version (timestamp)
-
-**Example SQL Migration:**
-
-```sql
--- 20250115120000_create_users.up.sql
-CREATE TABLE {{.Schema}}.users (
- id SERIAL PRIMARY KEY,
- connection_name VARCHAR(100) DEFAULT '{{.Connection}}',
- backend_type VARCHAR(50) DEFAULT '{{.Backend}}',
- created_at TIMESTAMP DEFAULT NOW()
-);
-
--- If executed with schema "core" and connection "core", this becomes:
--- CREATE TABLE core.users (
--- id SERIAL PRIMARY KEY,
--- connection_name VARCHAR(100) DEFAULT 'core',
--- backend_type VARCHAR(50) DEFAULT 'postgresql',
--- created_at TIMESTAMP DEFAULT NOW()
--- );
-```
-
-**Example JSON Migration (for etcd):**
-
-```json
-[
- {
- "operation": "put",
- "key": "/{{.Connection}}/{{.Schema}}/config",
- "value": {
- "backend": "{{.Backend}}",
- "version": "{{.Version}}",
- "migrated_at": "2025-01-15T12:00:00Z"
- }
- }
-]
-```
-
-Template variables work in both `.up.sql`, `.down.sql`, `.up.json`, and `.down.json` migration files. Variables are replaced using Go's `text/template` package, so you can use all standard template features.
-
-### Check if Migration is Applied
-
-Check if a specific migration has been applied:
-
-```bash
-GET /api/v1/migrations/{id}/applied
-Authorization: Bearer {BFM_API_TOKEN}
-```
-
-Response:
-
-```json
-{
- "applied": true
-}
-```
-
-This endpoint returns a simple boolean indicating whether the migration has been applied. It's useful for quick status checks without retrieving full migration details.
-
-### gRPC API
-
-BfM also provides a gRPC API for programmatic access. The gRPC service includes all the same operations as the HTTP API, including:
-
-- `Migrate` - Execute database migrations (up)
-- `StreamMigrate` - Execute migrations with streaming progress updates
-- `MigrateDown` - Execute down migrations (rollback)
-- `ListMigrations` - List all migrations with optional filtering
-- `GetMigration` - Get detailed information about a specific migration
-- `GetMigrationStatus` - Get the current status of a specific migration
-- `IsMigrationApplied` - Check if a migration has been applied (returns boolean)
-- `GetMigrationHistory` - Get the execution history for a specific migration
-- `RollbackMigration` - Roll back a specific migration
-- `ReindexMigrations` - Reindex all migration files and synchronize with database
-- `Health` - Check the health status of the service
-
-**Example: Check if migration is applied via gRPC**
-
-```protobuf
-rpc IsMigrationApplied(IsMigrationAppliedRequest) returns (IsMigrationAppliedResponse);
-
-message IsMigrationAppliedRequest {
- string migration_id = 1;
-}
-
-message IsMigrationAppliedResponse {
- bool applied = 1;
-}
-```
-
-The gRPC server runs on port 9090 by default (configurable via `BFM_GRPC_PORT`).
-
-### Health Check
-
-```bash
-GET /health
-```
-
-## Migration Scripts
-
-Migration scripts are located in `sfm/{backend}/{connection}/` and follow the naming convention:
-`{version}_{name}.up.sql` and `{version}_{name}.down.sql`
-
-The BfM CLI generates corresponding `.go` files with the format `{version}_{name}.go` that:
-
-1. Embed SQL/JSON files using `//go:embed`
-2. Register themselves in the global registry via `init()`
-3. Include both up and down migrations
-
-Example structure:
-
-```
-sfm/
- postgresql/
- core/
- 20250101120000_create_users.up.sql
- 20250101120000_create_users.down.sql
- 20250101120000_create_users.go # Generated by CLI
- greptimedb/
- logs/
- 20250101120000_create_metrics.up.sql
- 20250101120000_create_metrics.down.sql
- 20250101120000_create_metrics.go # Generated by CLI
- etcd/
- metadata/
- 20250101120000_init_config.up.json
- 20250101120000_init_config.down.json
- 20250101120000_init_config.go # Generated by CLI
-```
-
-## Migration from Existing System
+## Documentation
-To migrate from the existing GORM AutoMigrate system:
+| Document | Purpose |
+|----------|---------|
+| [docs/TAGS.md](docs/TAGS.md) | **Tag-filtered migrate** over HTTP and gRPC (dynamic schema, AND tags). |
+| [docs/MIGRATION.md](docs/MIGRATION.md) | **Run migrations** over HTTP and gRPC: targets, dependencies, dynamic schema, batch ordering. |
+| [docs/EXECUTING_MIGRATIONS.md](docs/EXECUTING_MIGRATIONS.md) | Operational checklist, IDs, troubleshooting (registry vs state DB). |
+| [docs/MIGRATION_DEPENDENCIES.md](docs/MIGRATION_DEPENDENCIES.md) | **Authoring** dependencies in Go/SQL (not API-focused). |
+| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production setup, env vars, Docker, auto-migrate. |
+| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Local dev, hot-reload, CLI build, protobuf generation. |
-1. Extract table definitions from GORM models
-2. Create SQL migration scripts following the naming convention
-3. Place scripts in appropriate `sfm/{backend}/{connection}/` directory
-4. Register migrations via `init()` functions
-5. Run migrations via HTTP API or Protobuf API
+**Machine-readable API**: OpenAPI at `/api/v1/openapi.yaml` and `/api/v1/openapi.json` on the HTTP port (default `7070`).
diff --git a/api/.air.toml b/api/.air.toml
index acd14ff..2e2058c 100644
--- a/api/.air.toml
+++ b/api/.air.toml
@@ -8,7 +8,7 @@ tmp_dir = "tmp"
[build]
args_bin = []
- bin = "./tmp/main"
+ entrypoint = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd/server"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "deploy", "examples"]
diff --git a/api/cmd/cli/main.go b/api/cmd/cli/main.go
index 236fc10..7b20177 100644
--- a/api/cmd/cli/main.go
+++ b/api/cmd/cli/main.go
@@ -1,10 +1,12 @@
package main
import (
+ "bufio"
"fmt"
"os"
"path/filepath"
"regexp"
+ "strconv"
"strings"
"text/template"
@@ -30,6 +32,9 @@ var (
outputDir string
)
+// bfm-tags line in .up.sql / .up.json (first lines of file): -- bfm-tags: env=prod, feature=x
+var bfmTagsLineRe = regexp.MustCompile(`(?i)^\s*--\s*bfm-tags:\s*(.+)\s*$`)
+
var rootCmd = &cobra.Command{
Use: "bfm",
Short: "BfM - Backend for Migrations CLI",
@@ -255,6 +260,16 @@ func buildMigrations(sfmPath string) error {
dirPath = filepath.Join(sfmPath, migration.Backend, migration.Connection)
}
+ srcUpPath := filepath.Join(sfmPath, migration.Backend, migration.Connection, migration.UpFile)
+ tags, err := readBFMTagsFromUpFile(srcUpPath)
+ if err != nil {
+ return fmt.Errorf("bfm-tags: %w", err)
+ }
+ tagsGo := formatTagsForGoInit(tags)
+ if verbose && len(tags) > 0 {
+ fmt.Printf(" %s: bfm-tags %v\n", srcUpPath, tags)
+ }
+
// Generate .go filename
goFileName := fmt.Sprintf("%s_%s.go", migration.Version, migration.Name)
goFilePath := filepath.Join(dirPath, goFileName)
@@ -285,6 +300,7 @@ func buildMigrations(sfmPath string) error {
Name string
Connection string
Backend string
+ TagsGo string
}{
PackageName: migration.PackageName,
UpFileName: migration.UpFile,
@@ -293,6 +309,7 @@ func buildMigrations(sfmPath string) error {
Name: migration.Name,
Connection: migration.Connection,
Backend: migration.Backend,
+ TagsGo: tagsGo,
})
_ = file.Close()
@@ -318,6 +335,53 @@ func buildMigrations(sfmPath string) error {
return nil
}
+func readBFMTagsFromUpFile(path string) ([]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = f.Close() }()
+
+ scanner := bufio.NewScanner(f)
+ const maxScanLines = 80
+ for lineNum := 0; lineNum < maxScanLines && scanner.Scan(); lineNum++ {
+ line := scanner.Text()
+ m := bfmTagsLineRe.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ parts := strings.Split(m[1], ",")
+ var out []string
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ eq := strings.Index(p, "=")
+ if eq <= 0 || strings.TrimSpace(p[:eq]) == "" {
+ return nil, fmt.Errorf("%s: invalid bfm-tags entry %q (expected key=value)", path, p)
+ }
+ out = append(out, p)
+ }
+ return out, nil
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ return nil, nil
+}
+
+func formatTagsForGoInit(tags []string) string {
+ if len(tags) == 0 {
+ return ""
+ }
+ parts := make([]string, len(tags))
+ for i, t := range tags {
+ parts[i] = strconv.Quote(t)
+ }
+ return strings.Join(parts, ", ")
+}
+
// sanitizePackageName converts a connection name to a valid Go package name
func sanitizePackageName(name string) string {
// Replace invalid characters with underscores
diff --git a/api/docs/docs.go b/api/docs/docs.go
index dad44ed..1184648 100644
--- a/api/docs/docs.go
+++ b/api/docs/docs.go
@@ -970,6 +970,13 @@ const docTemplate = `{
"table": {
"type": "string"
},
+ "tags": {
+ "description": "key=value from registry",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"up_sql": {
"description": "Contains SQL for SQL backends or JSON for NoSQL backends",
"type": "string"
@@ -1012,6 +1019,13 @@ const docTemplate = `{
"table": {
"type": "string"
},
+ "tags": {
+ "description": "key=value from registry",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"version": {
"type": "string"
}
@@ -1091,6 +1105,13 @@ const docTemplate = `{
"type": "string"
}
},
+ "tags": {
+ "description": "Optional key=value filters (AND); empty = no tag filter",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"version": {
"description": "Version filter (optional, empty = latest)",
"type": "string"
diff --git a/api/docs/swagger.json b/api/docs/swagger.json
index 26873f8..0b361a7 100644
--- a/api/docs/swagger.json
+++ b/api/docs/swagger.json
@@ -964,6 +964,13 @@
"table": {
"type": "string"
},
+ "tags": {
+ "description": "key=value from registry",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"up_sql": {
"description": "Contains SQL for SQL backends or JSON for NoSQL backends",
"type": "string"
@@ -1006,6 +1013,13 @@
"table": {
"type": "string"
},
+ "tags": {
+ "description": "key=value from registry",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"version": {
"type": "string"
}
@@ -1085,6 +1099,13 @@
"type": "string"
}
},
+ "tags": {
+ "description": "Optional key=value filters (AND); empty = no tag filter",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"version": {
"description": "Version filter (optional, empty = latest)",
"type": "string"
diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml
index 1bb92f2..9950ed7 100644
--- a/api/docs/swagger.yaml
+++ b/api/docs/swagger.yaml
@@ -95,6 +95,11 @@ definitions:
type: array
table:
type: string
+ tags:
+ description: key=value from registry
+ items:
+ type: string
+ type: array
up_sql:
description: Contains SQL for SQL backends or JSON for NoSQL backends
type: string
@@ -123,6 +128,11 @@ definitions:
type: string
table:
type: string
+ tags:
+ description: key=value from registry
+ items:
+ type: string
+ type: array
version:
type: string
type: object
@@ -176,6 +186,11 @@ definitions:
items:
type: string
type: array
+ tags:
+ description: Optional key=value filters (AND); empty = no tag filter
+ items:
+ type: string
+ type: array
version:
description: Version filter (optional, empty = latest)
type: string
diff --git a/api/internal/api/http/dto/migrations.go b/api/internal/api/http/dto/migrations.go
index 37a7dfd..e34cb38 100644
--- a/api/internal/api/http/dto/migrations.go
+++ b/api/internal/api/http/dto/migrations.go
@@ -20,17 +20,18 @@ type MigrationListResponse struct {
// MigrationListItem represents a single migration in the list
type MigrationListItem struct {
- MigrationID string `json:"migration_id"`
- Schema string `json:"schema"`
- Table string `json:"table"`
- Version string `json:"version"`
- Name string `json:"name"`
- Connection string `json:"connection"`
- Backend string `json:"backend"`
- Applied bool `json:"applied"`
- Status string `json:"status"`
- AppliedAt string `json:"applied_at,omitempty"`
- ErrorMessage string `json:"error_message,omitempty"`
+ MigrationID string `json:"migration_id"`
+ Schema string `json:"schema"`
+ Table string `json:"table"`
+ Version string `json:"version"`
+ Name string `json:"name"`
+ Connection string `json:"connection"`
+ Backend string `json:"backend"`
+ Applied bool `json:"applied"`
+ Status string `json:"status"`
+ AppliedAt string `json:"applied_at,omitempty"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ Tags []string `json:"tags,omitempty"` // key=value from registry
}
// DependencyResponse represents a structured dependency
@@ -57,6 +58,7 @@ type MigrationDetailResponse struct {
DownSQL string `json:"down_sql,omitempty"` // Contains SQL for SQL backends or JSON for NoSQL backends
Dependencies []string `json:"dependencies,omitempty"` // List of migration names this migration depends on (backward compatibility)
StructuredDependencies []DependencyResponse `json:"structured_dependencies,omitempty"` // Structured dependencies with validation requirements
+ Tags []string `json:"tags,omitempty"` // key=value from registry
}
// RollbackRequest represents a request to rollback a migration
diff --git a/api/internal/api/http/handler.go b/api/internal/api/http/handler.go
index 4dcd1e8..42b1bf8 100644
--- a/api/internal/api/http/handler.go
+++ b/api/internal/api/http/handler.go
@@ -12,6 +12,7 @@ import (
"github.com/toolsascode/bfm/api/internal/api/http/dto"
"github.com/toolsascode/bfm/api/internal/auth"
"github.com/toolsascode/bfm/api/internal/executor"
+ "github.com/toolsascode/bfm/api/internal/registry"
"github.com/toolsascode/bfm/api/internal/state"
"github.com/gin-gonic/gin"
@@ -180,6 +181,13 @@ func (h *Handler) migrateUp(c *gin.Context) {
return
}
+ if req.Target != nil && len(req.Target.Tags) > 0 {
+ if _, err := registry.ParseTagFilter(req.Target.Tags); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ }
+
// Set execution context
ctx := h.setExecutionContext(c)
@@ -330,7 +338,7 @@ func (h *Handler) listMigrations(c *gin.Context) {
// Convert to DTO response (only migrations from database)
items := make([]dto.MigrationListItem, 0, len(migrationList))
for _, item := range migrationList {
- items = append(items, dto.MigrationListItem{
+ listItem := dto.MigrationListItem{
MigrationID: item.MigrationID,
Schema: item.Schema,
Table: item.Table,
@@ -342,7 +350,11 @@ func (h *Handler) listMigrations(c *gin.Context) {
Status: item.LastStatus,
AppliedAt: item.LastAppliedAt,
ErrorMessage: item.LastErrorMessage,
- })
+ }
+ if regMig := h.executor.GetMigrationByID(item.MigrationID); regMig != nil && len(regMig.Tags) > 0 {
+ listItem.Tags = append([]string(nil), regMig.Tags...)
+ }
+ items = append(items, listItem)
}
response := dto.MigrationListResponse{
@@ -493,6 +505,11 @@ func (h *Handler) getMigration(c *gin.Context) {
responseMigrationID = migrationID
}
+ var tagCopy []string
+ if len(migration.Tags) > 0 {
+ tagCopy = append([]string(nil), migration.Tags...)
+ }
+
response := dto.MigrationDetailResponse{
MigrationID: responseMigrationID,
Schema: schemaValue,
@@ -506,6 +523,7 @@ func (h *Handler) getMigration(c *gin.Context) {
DownSQL: migration.DownSQL,
Dependencies: dependencies,
StructuredDependencies: structuredDeps,
+ Tags: tagCopy,
}
c.JSON(http.StatusOK, response)
diff --git a/api/internal/api/http/handler_test.go b/api/internal/api/http/handler_test.go
index b4f22e1..0f4aab4 100644
--- a/api/internal/api/http/handler_test.go
+++ b/api/internal/api/http/handler_test.go
@@ -585,6 +585,39 @@ func TestHandler_migrateUp(t *testing.T) {
}
}
+func TestHandler_migrateUp_InvalidTags(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()
+ router, _ := setupTestRouter(reg, tracker)
+
+ body, _ := json.Marshal(dto.MigrateUpRequest{
+ Target: ®istry.MigrationTarget{
+ Backend: "postgresql",
+ Connection: "test",
+ Tags: []string{"not-a-valid-tag"},
+ },
+ Connection: "test",
+ Schemas: []string{},
+ })
+ req, _ := http.NewRequest("POST", "/api/v1/migrations/up", bytes.NewBuffer(body))
+ req.Header.Set("Authorization", "Bearer test-token")
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
func TestHandler_migrateUp_PartialContent(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 db8d2e6..825ff08 100644
--- a/api/internal/api/protobuf/handler.go
+++ b/api/internal/api/protobuf/handler.go
@@ -44,6 +44,12 @@ func (s *Server) Migrate(ctx context.Context, req *MigrateRequest) (*MigrateResp
return nil, status.Error(codes.InvalidArgument, "request and target are required")
}
+ if len(req.Target.Tags) > 0 {
+ if _, err := registry.ParseTagFilter(req.Target.Tags); err != nil {
+ return nil, status.Errorf(codes.InvalidArgument, "%v", err)
+ }
+ }
+
// Convert protobuf target to registry target
target := ®istry.MigrationTarget{
Backend: req.Target.Backend,
@@ -51,6 +57,7 @@ func (s *Server) Migrate(ctx context.Context, req *MigrateRequest) (*MigrateResp
Tables: req.Target.Tables,
Version: req.Target.Version,
Connection: req.Target.Connection,
+ Tags: req.Target.Tags,
}
// Resolve schema (use schema_name if provided for dynamic schemas)
@@ -96,12 +103,13 @@ func (s *Server) StreamMigrate(req *MigrateRequest, stream MigrationService_Stre
Tables: req.Target.Tables,
Version: req.Target.Version,
Connection: req.Target.Connection,
+ Tags: req.Target.Tags,
}
// Get migrations matching target
migrations, err := s.executor.GetRegistry().FindByTarget(target)
if err != nil {
- return status.Errorf(codes.Internal, "failed to find migrations: %v", err)
+ return status.Errorf(codes.InvalidArgument, "failed to find migrations: %v", err)
}
// Sort migrations by version
@@ -282,7 +290,7 @@ func (s *Server) ListMigrations(ctx context.Context, req *ListMigrationsRequest)
// Convert to protobuf response
items := make([]*MigrationListItem, 0, len(migrationList))
for _, item := range migrationList {
- items = append(items, &MigrationListItem{
+ pbItem := &MigrationListItem{
MigrationId: item.MigrationID,
Schema: item.Schema,
Table: item.Table,
@@ -294,7 +302,11 @@ func (s *Server) ListMigrations(ctx context.Context, req *ListMigrationsRequest)
Status: item.LastStatus,
AppliedAt: item.LastAppliedAt,
ErrorMessage: item.LastErrorMessage,
- })
+ }
+ if regMig := s.executor.GetMigrationByID(item.MigrationID); regMig != nil && len(regMig.Tags) > 0 {
+ pbItem.Tags = append([]string(nil), regMig.Tags...)
+ }
+ items = append(items, pbItem)
}
response := &ListMigrationsResponse{
@@ -357,6 +369,11 @@ func (s *Server) GetMigration(ctx context.Context, req *GetMigrationRequest) (*M
})
}
+ var tagCopy []string
+ if len(migration.Tags) > 0 {
+ tagCopy = append([]string(nil), migration.Tags...)
+ }
+
response := &MigrationDetailResponse{
MigrationId: req.MigrationId,
Schema: schemaValue,
@@ -370,6 +387,7 @@ func (s *Server) GetMigration(ctx context.Context, req *GetMigrationRequest) (*M
DownSql: migration.DownSQL,
Dependencies: migration.Dependencies,
StructuredDependencies: structuredDeps,
+ Tags: tagCopy,
}
return response, nil
diff --git a/api/internal/api/protobuf/migration.pb.go b/api/internal/api/protobuf/migration.pb.go
index cc31206..9ab5626 100644
--- a/api/internal/api/protobuf/migration.pb.go
+++ b/api/internal/api/protobuf/migration.pb.go
@@ -30,6 +30,7 @@ type MigrationTarget struct {
Tables []string `protobuf:"bytes,3,rep,name=tables,proto3" json:"tables,omitempty"` // Table filters (optional, empty = all)
Version string `protobuf:"bytes,4,opt,name=version,proto3" json:"version,omitempty"` // Version filter (optional, empty = latest)
Connection string `protobuf:"bytes,5,opt,name=connection,proto3" json:"connection,omitempty"` // Connection name filter
+ Tags []string `protobuf:"bytes,6,rep,name=tags,proto3" json:"tags,omitempty"` // Optional key=value filters (AND semantics)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -99,6 +100,13 @@ func (x *MigrationTarget) GetConnection() string {
return ""
}
+func (x *MigrationTarget) GetTags() []string {
+ if x != nil {
+ return x.Tags
+ }
+ return nil
+}
+
// MigrateRequest represents a migration request
type MigrateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -543,6 +551,7 @@ type MigrationListItem struct {
Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"`
AppliedAt string `protobuf:"bytes,10,opt,name=applied_at,json=appliedAt,proto3" json:"applied_at,omitempty"` // RFC3339 timestamp
ErrorMessage string `protobuf:"bytes,11,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"`
+ Tags []string `protobuf:"bytes,12,rep,name=tags,proto3" json:"tags,omitempty"` // key=value labels from registry (optional)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -654,6 +663,13 @@ func (x *MigrationListItem) GetErrorMessage() string {
return ""
}
+func (x *MigrationListItem) GetTags() []string {
+ if x != nil {
+ return x.Tags
+ }
+ return nil
+}
+
// GetMigrationRequest represents a request to get a specific migration
type GetMigrationRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -714,6 +730,7 @@ type MigrationDetailResponse struct {
DownSql string `protobuf:"bytes,10,opt,name=down_sql,json=downSql,proto3" json:"down_sql,omitempty"` // Contains SQL for SQL backends or JSON for NoSQL backends
Dependencies []string `protobuf:"bytes,11,rep,name=dependencies,proto3" json:"dependencies,omitempty"` // List of migration names this migration depends on
StructuredDependencies []*DependencyResponse `protobuf:"bytes,12,rep,name=structured_dependencies,json=structuredDependencies,proto3" json:"structured_dependencies,omitempty"`
+ Tags []string `protobuf:"bytes,13,rep,name=tags,proto3" json:"tags,omitempty"` // key=value labels from registry (optional)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -832,6 +849,13 @@ func (x *MigrationDetailResponse) GetStructuredDependencies() []*DependencyRespo
return nil
}
+func (x *MigrationDetailResponse) GetTags() []string {
+ if x != nil {
+ return x.Tags
+ }
+ return nil
+}
+
// DependencyResponse represents a structured dependency
type DependencyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -1682,7 +1706,7 @@ var File_migration_proto protoreflect.FileDescriptor
const file_migration_proto_rawDesc = "" +
"\n" +
- "\x0fmigration.proto\x12\tmigration\"\x95\x01\n" +
+ "\x0fmigration.proto\x12\tmigration\"\xa9\x01\n" +
"\x0fMigrationTarget\x12\x18\n" +
"\abackend\x18\x01 \x01(\tR\abackend\x12\x16\n" +
"\x06schema\x18\x02 \x01(\tR\x06schema\x12\x16\n" +
@@ -1690,7 +1714,8 @@ const file_migration_proto_rawDesc = "" +
"\aversion\x18\x04 \x01(\tR\aversion\x12\x1e\n" +
"\n" +
"connection\x18\x05 \x01(\tR\n" +
- "connection\"\xe7\x01\n" +
+ "connection\x12\x12\n" +
+ "\x04tags\x18\x06 \x03(\tR\x04tags\"\xe7\x01\n" +
"\x0eMigrateRequest\x122\n" +
"\x06target\x18\x01 \x01(\v2\x1a.migration.MigrationTargetR\x06target\x12\x1e\n" +
"\n" +
@@ -1727,7 +1752,7 @@ const file_migration_proto_rawDesc = "" +
"\aversion\x18\x06 \x01(\tR\aversion\"b\n" +
"\x16ListMigrationsResponse\x122\n" +
"\x05items\x18\x01 \x03(\v2\x1c.migration.MigrationListItemR\x05items\x12\x14\n" +
- "\x05total\x18\x02 \x01(\x05R\x05total\"\xc2\x02\n" +
+ "\x05total\x18\x02 \x01(\x05R\x05total\"\xd6\x02\n" +
"\x11MigrationListItem\x12!\n" +
"\fmigration_id\x18\x01 \x01(\tR\vmigrationId\x12\x16\n" +
"\x06schema\x18\x02 \x01(\tR\x06schema\x12\x14\n" +
@@ -1743,9 +1768,10 @@ const file_migration_proto_rawDesc = "" +
"\n" +
"applied_at\x18\n" +
" \x01(\tR\tappliedAt\x12#\n" +
- "\rerror_message\x18\v \x01(\tR\ferrorMessage\"8\n" +
+ "\rerror_message\x18\v \x01(\tR\ferrorMessage\x12\x12\n" +
+ "\x04tags\x18\f \x03(\tR\x04tags\"8\n" +
"\x13GetMigrationRequest\x12!\n" +
- "\fmigration_id\x18\x01 \x01(\tR\vmigrationId\"\x9a\x03\n" +
+ "\fmigration_id\x18\x01 \x01(\tR\vmigrationId\"\xae\x03\n" +
"\x17MigrationDetailResponse\x12!\n" +
"\fmigration_id\x18\x01 \x01(\tR\vmigrationId\x12\x16\n" +
"\x06schema\x18\x02 \x01(\tR\x06schema\x12\x14\n" +
@@ -1761,7 +1787,8 @@ const file_migration_proto_rawDesc = "" +
"\bdown_sql\x18\n" +
" \x01(\tR\adownSql\x12\"\n" +
"\fdependencies\x18\v \x03(\tR\fdependencies\x12V\n" +
- "\x17structured_dependencies\x18\f \x03(\v2\x1d.migration.DependencyResponseR\x16structuredDependencies\"\xd5\x01\n" +
+ "\x17structured_dependencies\x18\f \x03(\v2\x1d.migration.DependencyResponseR\x16structuredDependencies\x12\x12\n" +
+ "\x04tags\x18\r \x03(\tR\x04tags\"\xd5\x01\n" +
"\x12DependencyResponse\x12\x1e\n" +
"\n" +
"connection\x18\x01 \x01(\tR\n" +
diff --git a/api/internal/api/protobuf/migration.proto b/api/internal/api/protobuf/migration.proto
index 51a4cd1..f262cf9 100644
--- a/api/internal/api/protobuf/migration.proto
+++ b/api/internal/api/protobuf/migration.proto
@@ -47,6 +47,7 @@ message MigrationTarget {
repeated string tables = 3; // Table filters (optional, empty = all)
string version = 4; // Version filter (optional, empty = latest)
string connection = 5; // Connection name filter
+ repeated string tags = 6; // Optional key=value filters (AND semantics)
}
// MigrateRequest represents a migration request
@@ -112,6 +113,7 @@ message MigrationListItem {
string status = 9;
string applied_at = 10; // RFC3339 timestamp
string error_message = 11;
+ repeated string tags = 12; // key=value labels from registry (optional)
}
// GetMigrationRequest represents a request to get a specific migration
@@ -133,6 +135,7 @@ message MigrationDetailResponse {
string down_sql = 10; // Contains SQL for SQL backends or JSON for NoSQL backends
repeated string dependencies = 11; // List of migration names this migration depends on
repeated DependencyResponse structured_dependencies = 12;
+ repeated string tags = 13; // key=value labels from registry (optional)
}
// DependencyResponse represents a structured dependency
diff --git a/api/internal/backends/interface.go b/api/internal/backends/interface.go
index dcbcfcb..3fabca0 100644
--- a/api/internal/backends/interface.go
+++ b/api/internal/backends/interface.go
@@ -26,6 +26,7 @@ type MigrationScript struct {
DownSQL string
Dependencies []string // Optional: list of migration names this migration depends on (backward compatibility)
StructuredDependencies []Dependency // Optional: structured dependencies with validation requirements
+ Tags []string // Optional: key=value labels for tag-filtered execution
}
// Backend represents a database backend that can execute migrations
diff --git a/api/internal/executor/executor.go b/api/internal/executor/executor.go
index 5ed7a94..df0c799 100644
--- a/api/internal/executor/executor.go
+++ b/api/internal/executor/executor.go
@@ -211,6 +211,7 @@ func convertTarget(target *registry.MigrationTarget) *queue.MigrationTarget {
Tables: target.Tables,
Version: target.Version,
Connection: target.Connection,
+ Tags: target.Tags,
}
}
diff --git a/api/internal/executor/loader.go b/api/internal/executor/loader.go
index 1c33617..1b5e2eb 100644
--- a/api/internal/executor/loader.go
+++ b/api/internal/executor/loader.go
@@ -17,6 +17,9 @@ import (
"github.com/toolsascode/bfm/api/migrations"
)
+// bfmTagsLineRe matches the optional tag declaration line at the top of .up.sql / .up.json sources.
+var bfmTagsLineRe = regexp.MustCompile(`(?i)^\s*--\s*bfm-tags:\s*(.+)\s*$`)
+
// Loader loads migration scripts from the SFM directory
type Loader struct {
sfmPath string
@@ -367,6 +370,66 @@ func extractSchemaFromGoFile(goFilePath string) string {
return ""
}
+// extractTagsFromGoFile extracts Tags ([]string) from a .go migration file when present.
+func extractTagsFromGoFile(goFilePath string) []string {
+ goContent, err := os.ReadFile(goFilePath)
+ if err != nil {
+ return nil
+ }
+ content := string(goContent)
+ tagsRegex := regexp.MustCompile(`Tags:\s*\[\]string\s*\{([^}]*)\}`)
+ matches := tagsRegex.FindStringSubmatch(content)
+ if len(matches) < 2 {
+ return nil
+ }
+ tagsStr := strings.TrimSpace(matches[1])
+ if tagsStr == "" {
+ return nil
+ }
+ itemRe := regexp.MustCompile(`["` + "`" + `]([^"` + "`" + `]+)["` + "`" + `]`)
+ tagMatches := itemRe.FindAllStringSubmatch(tagsStr, -1)
+ var tags []string
+ for _, match := range tagMatches {
+ if len(match) >= 2 {
+ t := strings.TrimSpace(match[1])
+ if t != "" {
+ tags = append(tags, t)
+ }
+ }
+ }
+ return tags
+}
+
+// parseBFMTagsFromUpSQL returns tag pairs from the first -- bfm-tags: line in the up migration body (see CLI build).
+func parseBFMTagsFromUpSQL(upSQL string) ([]string, error) {
+ lines := strings.Split(upSQL, "\n")
+ n := len(lines)
+ if n > 80 {
+ n = 80
+ }
+ for _, line := range lines[:n] {
+ m := bfmTagsLineRe.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ parts := strings.Split(m[1], ",")
+ var out []string
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ eq := strings.Index(p, "=")
+ if eq <= 0 || strings.TrimSpace(p[:eq]) == "" {
+ return nil, fmt.Errorf("invalid bfm-tags entry %q (expected key=value)", p)
+ }
+ out = append(out, p)
+ }
+ return out, nil
+ }
+ return nil, nil
+}
+
// extractDependenciesFromGoFile extracts the Dependencies field value from a .go migration file
func extractDependenciesFromGoFile(goFilePath string) []string {
// Read the .go file
@@ -556,6 +619,15 @@ func (l *Loader) loadMigrationFromFile(goFilePath, backend, connection, version,
// Extract structured dependencies from .go file if they exist
structuredDependencies := extractStructuredDependenciesFromGoFile(goFilePath)
+ tags := extractTagsFromGoFile(goFilePath)
+ if len(tags) == 0 {
+ var tagErr error
+ tags, tagErr = parseBFMTagsFromUpSQL(string(upSQL))
+ if tagErr != nil {
+ return fmt.Errorf("bfm-tags in %s: %w", upFile, tagErr)
+ }
+ }
+
// Create and register migration
migration := &backends.MigrationScript{
Schema: schema, // Use schema from .go file if available, otherwise empty (dynamic)
@@ -567,6 +639,7 @@ func (l *Loader) loadMigrationFromFile(goFilePath, backend, connection, version,
DownSQL: string(downSQL),
Dependencies: dependencies,
StructuredDependencies: structuredDependencies,
+ Tags: tags,
}
if err := l.registry.Register(migration); err != nil {
diff --git a/api/internal/queue/interface.go b/api/internal/queue/interface.go
index b6541c7..a3c3ef0 100644
--- a/api/internal/queue/interface.go
+++ b/api/internal/queue/interface.go
@@ -22,6 +22,7 @@ type MigrationTarget struct {
Tables []string `json:"tables,omitempty"`
Version string `json:"version,omitempty"`
Connection string `json:"connection,omitempty"`
+ Tags []string `json:"tags,omitempty"`
}
// JobResult represents the result of a migration job
diff --git a/api/internal/registry/interface.go b/api/internal/registry/interface.go
index 63e8f8b..7f845d3 100644
--- a/api/internal/registry/interface.go
+++ b/api/internal/registry/interface.go
@@ -23,11 +23,12 @@ func normalizedBackendName(backend string) string {
// MigrationTarget specifies which migrations to execute (moved here to avoid import cycle)
type MigrationTarget struct {
- Backend string // Backend type filter
- Schema string // Schema filter (optional)
- Tables []string // Table filters (optional, empty = all)
- Version string // Version filter (optional, empty = latest)
- Connection string // Connection name filter
+ Backend string `json:"backend"` // Backend type filter
+ Schema string `json:"schema"` // Schema filter (optional)
+ Tables []string `json:"tables"` // Table filters (optional, empty = all)
+ Version string `json:"version"` // Version filter (optional, empty = latest)
+ Connection string `json:"connection"` // Connection name filter
+ Tags []string `json:"tags,omitempty"` // Optional key=value filters (AND); empty = no tag filter
}
// Registry manages migration script registration and lookup
@@ -80,6 +81,15 @@ func (r *inMemoryRegistry) Register(migration *backends.MigrationScript) error {
func (r *inMemoryRegistry) FindByTarget(target *MigrationTarget) ([]*backends.MigrationScript, error) {
var results []*backends.MigrationScript
+ var requiredTags map[string]string
+ if target != nil && len(target.Tags) > 0 {
+ var err error
+ requiredTags, err = ParseTagFilter(target.Tags)
+ if err != nil {
+ return nil, err
+ }
+ }
+
for _, migration := range r.migrations {
if target.Backend != "" && !BackendNamesMatch(target.Backend, migration.Backend) {
continue
@@ -109,6 +119,9 @@ func (r *inMemoryRegistry) FindByTarget(target *MigrationTarget) ([]*backends.Mi
if target.Version != "" && migration.Version != target.Version {
continue
}
+ if len(requiredTags) > 0 && !MatchesTagFilter(migration.Tags, requiredTags) {
+ continue
+ }
results = append(results, migration)
}
diff --git a/api/internal/registry/tags.go b/api/internal/registry/tags.go
new file mode 100644
index 0000000..9b4bc4c
--- /dev/null
+++ b/api/internal/registry/tags.go
@@ -0,0 +1,70 @@
+package registry
+
+import (
+ "fmt"
+ "strings"
+)
+
+// ParseTagFilter parses API request tag strings (each key=value). Keys are normalized to
+// lowercase; duplicate keys in the request use last-wins semantics.
+func ParseTagFilter(raw []string) (map[string]string, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+ out := make(map[string]string)
+ for i, s := range raw {
+ k, v, err := parseOneTag(s)
+ if err != nil {
+ return nil, fmt.Errorf("tags[%d]: %w", i, err)
+ }
+ out[k] = v
+ }
+ return out, nil
+}
+
+func parseOneTag(s string) (key, value string, err error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return "", "", fmt.Errorf("empty tag")
+ }
+ eq := strings.Index(s, "=")
+ if eq <= 0 {
+ return "", "", fmt.Errorf("tag must be key=value")
+ }
+ k := strings.TrimSpace(s[:eq])
+ val := strings.TrimSpace(s[eq+1:])
+ if k == "" {
+ return "", "", fmt.Errorf("empty key")
+ }
+ return strings.ToLower(k), val, nil
+}
+
+// TagMapFromScriptTags builds a map from migration-declared tags; malformed entries are skipped.
+func TagMapFromScriptTags(raw []string) map[string]string {
+ out := make(map[string]string)
+ for _, s := range raw {
+ k, v, err := parseOneTag(s)
+ if err != nil {
+ continue
+ }
+ out[k] = v
+ }
+ return out
+}
+
+// MatchesTagFilter reports whether migrationTags satisfies required (AND: every key=value in required must match).
+func MatchesTagFilter(migrationTags []string, required map[string]string) bool {
+ if len(required) == 0 {
+ return true
+ }
+ m := TagMapFromScriptTags(migrationTags)
+ if len(m) == 0 {
+ return false
+ }
+ for k, v := range required {
+ if m[k] != v {
+ return false
+ }
+ }
+ return true
+}
diff --git a/api/internal/registry/tags_test.go b/api/internal/registry/tags_test.go
new file mode 100644
index 0000000..233435b
--- /dev/null
+++ b/api/internal/registry/tags_test.go
@@ -0,0 +1,83 @@
+package registry
+
+import (
+ "testing"
+
+ "github.com/toolsascode/bfm/api/internal/backends"
+)
+
+func TestParseTagFilter(t *testing.T) {
+ m, err := ParseTagFilter([]string{"env=prod", " feature=billing "})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m["env"] != "prod" || m["feature"] != "billing" {
+ t.Fatalf("map = %#v", m)
+ }
+ _, err = ParseTagFilter([]string{"not-a-tag"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ m2, err := ParseTagFilter([]string{"a=b", "a=c"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m2["a"] != "c" {
+ t.Fatalf("last wins: %#v", m2)
+ }
+}
+
+func TestParseTagFilter_ValueWithEquals(t *testing.T) {
+ m, err := ParseTagFilter([]string{"k=v=2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m["k"] != "v=2" {
+ t.Fatalf("got %q", m["k"])
+ }
+}
+
+func TestInMemoryRegistry_FindByTarget_Tags(t *testing.T) {
+ reg := NewInMemoryRegistry()
+ _ = reg.Register(&backends.MigrationScript{
+ Version: "20240101120000", Name: "a", Connection: "c", Backend: "postgresql",
+ Tags: []string{"env=prod", "tier=gold"},
+ })
+ _ = reg.Register(&backends.MigrationScript{
+ Version: "20240101120001", Name: "b", Connection: "c", Backend: "postgresql",
+ Tags: []string{"env=staging"},
+ })
+ _ = reg.Register(&backends.MigrationScript{
+ Version: "20240101120002", Name: "c", Connection: "c", Backend: "postgresql",
+ })
+
+ target := &MigrationTarget{Connection: "c", Tags: []string{"env=prod", "tier=gold"}}
+ got, err := reg.FindByTarget(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Name != "a" {
+ t.Fatalf("got %d migrations, first name=%v", len(got), got)
+ }
+
+ got2, err := reg.FindByTarget(&MigrationTarget{Connection: "c", Tags: []string{"env=prod"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got2) != 1 {
+ t.Fatalf("want 1, got %d", len(got2))
+ }
+
+ got3, err := reg.FindByTarget(&MigrationTarget{Connection: "c", Tags: []string{"env=prod", "tier=silver"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got3) != 0 {
+ t.Fatalf("want 0, got %d", len(got3))
+ }
+
+ _, err = reg.FindByTarget(&MigrationTarget{Connection: "c", Tags: []string{"bad"}})
+ if err == nil {
+ t.Fatal("expected invalid tag error")
+ }
+}
diff --git a/api/internal/worker/worker.go b/api/internal/worker/worker.go
index d89ba50..868e8e0 100644
--- a/api/internal/worker/worker.go
+++ b/api/internal/worker/worker.go
@@ -74,6 +74,7 @@ func convertQueueTarget(target *queue.MigrationTarget) *registry.MigrationTarget
Tables: target.Tables,
Version: target.Version,
Connection: target.Connection,
+ Tags: target.Tags,
}
}
diff --git a/api/migrations/template.go b/api/migrations/template.go
index 1c7586e..e9fb34c 100644
--- a/api/migrations/template.go
+++ b/api/migrations/template.go
@@ -25,6 +25,7 @@ func init() {
DownSQL: downSQL,
Dependencies: []string{ {{.Dependencies}} },
StructuredDependencies: []migrations.Dependency{},
+ Tags: []string{ {{.TagsGo}} },
}
migrations.GlobalRegistry.Register(migration)
}
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 2b53ebf..a1d4de8 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -33,6 +33,10 @@ WORKDIR /build/internal/api/protobuf
RUN ./generate.sh
WORKDIR /build
+# handler.go uses //go:embed swagger.yaml next to that package; the canonical spec is api/docs/swagger.yaml
+# (same copy as scripts/generate-openapi.sh; gitignored locally so it must be created in the image).
+RUN cp docs/swagger.yaml internal/api/http/swagger.yaml
+
# Build the application binaries
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o bfm-server ./cmd/server && \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o bfm-worker ./cmd/worker && \
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 7d1d76c..ef73f04 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -166,19 +166,24 @@ If every round applies nothing, reports no errors, and the fixed-schema pending
### Manual Migration
-You can also trigger migrations manually via API:
+Trigger migrations via the HTTP API (see [MIGRATION.md](./MIGRATION.md)):
```bash
-curl -X POST http://bfm:7070/api/v1/migrate \
+curl -X POST http://bfm:7070/api/v1/migrations/up \
-H "Authorization: Bearer $BFM_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"target": {
"backend": "postgresql",
- "connection": "core"
+ "connection": "core",
+ "schema": "",
+ "tables": [],
+ "version": ""
},
"connection": "core",
- "schema": "core"
+ "schemas": [],
+ "dry_run": false,
+ "ignore_dependencies": false
}'
```
@@ -220,3 +225,112 @@ export BFM_LOG_LEVEL=DEBUG
- Version control all migration scripts
- Keep backups of SQL files
- Document migration dependencies
+
+## Docker image (GHCR) and standalone Compose
+
+### Pull published image
+
+```bash
+docker pull ghcr.io/toolsascode/bfm:latest
+```
+
+### Build production image locally
+
+```bash
+make prod-build
+# or
+docker build -t bfm-production:latest -f docker/Dockerfile .
+```
+
+### Standalone Compose (typical production path)
+
+1. Copy and edit env: `cp .env.example .env` (set `BFM_API_TOKEN`, state DB password, `CORE_*` / other connections).
+2. Start:
+
+```bash
+make standalone-up
+# or
+docker compose -p bfm-standalone -f deploy/docker-compose.standalone.yml up -d --build
+```
+
+3. Verify: `curl http://localhost:7070/health`
+4. Logs: `make standalone-logs` or `docker compose -p bfm-standalone -f deploy/docker-compose.standalone.yml logs -f`
+5. Stop: `make standalone-down`
+
+### `docker run` (minimal)
+
+```bash
+docker run -d \
+ --name bfm-production \
+ -p 7070:7070 \
+ -p 9090:9090 \
+ -e BFM_API_TOKEN=your-secure-token \
+ -e BFM_STATE_DB_HOST=postgres \
+ -e BFM_STATE_DB_PASSWORD=your-password \
+ -e CORE_DB_HOST=your-postgres-host \
+ -e CORE_DB_PASSWORD=your-password \
+ -v /path/to/your/sfm:/app/sfm:ro \
+ bfm-production:latest
+```
+
+**Endpoints (defaults):** UI and API `http://localhost:7070`, OpenAPI `http://localhost:7070/api/v1/openapi.yaml`, gRPC `localhost:9090`, health `GET /health`.
+
+The production image includes the API server, optional worker (`BFM_QUEUE_ENABLED=true`), FFM static assets, and `bfm-cli` under `/app/bin/bfm-cli` for `docker exec` use.
+
+## Reference: environment variables (summary)
+
+### Server
+
+| Variable | Description |
+|----------|-------------|
+| `BFM_HTTP_PORT` | HTTP port (default `7070`) |
+| `BFM_GRPC_PORT` | gRPC port (default `9090`) |
+| `BFM_API_TOKEN` | Bearer token (required) |
+
+### State database
+
+| Variable | Description |
+|----------|-------------|
+| `BFM_STATE_BACKEND` | `postgresql` or `mysql` (default `postgresql`) |
+| `BFM_STATE_DB_HOST` | Host (default `localhost`) |
+| `BFM_STATE_DB_PORT` | Port (default `5432`) |
+| `BFM_STATE_DB_USERNAME` | User (default `postgres`) |
+| `BFM_STATE_DB_PASSWORD` | Password (required) |
+| `BFM_STATE_DB_NAME` | Database name (default `migration_state`) |
+| `BFM_STATE_SCHEMA` | Schema (default `public`) |
+
+### Per-connection targets
+
+For each connection name (e.g. `core`), set:
+
+| Pattern | Description |
+|---------|-------------|
+| `{CONNECTION}_BACKEND` | `postgresql`, `greptimedb`, or `etcd` |
+| `{CONNECTION}_DB_HOST` | Host |
+| `{CONNECTION}_DB_PORT` | Port |
+| `{CONNECTION}_DB_USERNAME` | User |
+| `{CONNECTION}_DB_PASSWORD` | Password |
+| `{CONNECTION}_DB_NAME` | Database name |
+| `{CONNECTION}_SCHEMA` | Optional fixed schema |
+
+Example:
+
+```bash
+CORE_BACKEND=postgresql
+CORE_DB_HOST=localhost
+CORE_DB_PORT=5432
+CORE_DB_USERNAME=dashcloud
+CORE_DB_PASSWORD=password
+CORE_DB_NAME=dashcloud
+CORE_SCHEMA=core
+```
+
+## Production practices (checklist)
+
+1. **Security:** Strong API token; secrets in a vault; TLS via reverse proxy; restrict network access to BfM.
+2. **Availability:** Multiple instances behind a load balancer; replicated state DB; monitor `/health`.
+3. **Monitoring:** Centralized logs; track migration success/failure; alert on errors.
+4. **Backup:** Backup state DB; version-control migration sources; test restores.
+5. **Process:** Staging first; use `dry_run` where appropriate; keep migrations idempotent.
+
+For auto-migrate knobs and readiness rules, see the **BfM server startup auto-migrate** section earlier in this file.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 436617f..9af6c89 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -342,3 +342,90 @@ CORE_SCHEMA=core
- Use `make dev-bfm` and `make dev-ffm` for local development
- Requires Air and Node.js installed locally
- Faster startup time, no Docker overhead
+
+## Installing the BfM CLI
+
+The CLI generates `.go` files from `sfm/` SQL/JSON. API usage does not require the CLI on production servers unless you generate migrations in-container.
+
+### Go install
+
+```shell
+go install github.com/toolsascode/bfm@latest
+```
+
+### GitHub releases
+
+- [Latest release](https://github.com/toolsascode/bfm/releases/latest)
+
+```shell
+curl -fLSs https://raw.githubusercontent.com/toolsascode/bfm/main/scripts/install.sh | bash
+```
+
+### Homebrew (macOS)
+
+```shell
+brew install toolsascode/tap/bfm
+```
+
+### Build from repo
+
+```bash
+make build-cli
+# or
+cd api && go build -o ../bfm-cli ./cmd/cli
+```
+
+### Common commands
+
+```bash
+./bfm-cli version
+./bfm-cli build examples/sfm --verbose
+./bfm-cli build examples/sfm --dry-run
+```
+
+### SFM layout
+
+```
+{sfm_path}/{backend}/{connection}/{version}_{name}.up.sql
+{sfm_path}/{backend}/{connection}/{version}_{name}.down.sql
+```
+
+Etcd-style JSON migrations use `.up.json` / `.down.json` instead of `.sql`.
+
+## Migration script template variables
+
+At execution time, BfM can substitute template variables in SQL/JSON (Go `text/template`):
+
+| Variable | Meaning |
+|----------|---------|
+| `{{.Connection}}` | Connection name |
+| `{{.Schema}}` | Schema from execution context |
+| `{{.Backend}}` | Backend name |
+| `{{.Version}}` | Migration version string |
+
+**SQL example:**
+
+```sql
+CREATE TABLE {{.Schema}}.users (
+ id SERIAL PRIMARY KEY,
+ connection_name VARCHAR(100) DEFAULT '{{.Connection}}'
+);
+```
+
+**JSON (etcd) example:**
+
+```json
+[
+ {
+ "operation": "put",
+ "key": "/{{.Connection}}/{{.Schema}}/config",
+ "value": { "backend": "{{.Backend}}", "version": "{{.Version}}" }
+ }
+]
+```
+
+## Migrating from another migration system (outline)
+
+1. Export or recreate DDL as versioned SQL under `sfm/{backend}/{connection}/`.
+2. Run `bfm-cli build` (or your CI equivalent) and ship the generated `.go` files in the server build.
+3. Run `POST /api/v1/migrations/reindex` if needed so listing matches disk; execute via [MIGRATION.md](./MIGRATION.md).
diff --git a/docs/EXECUTING_MIGRATIONS.md b/docs/EXECUTING_MIGRATIONS.md
index 21b4050..0c3abb0 100644
--- a/docs/EXECUTING_MIGRATIONS.md
+++ b/docs/EXECUTING_MIGRATIONS.md
@@ -1,5 +1,7 @@
+**Documentation map:** For **API-first** how-tos, prefer **[TAGS.md](./TAGS.md)** (tag-filtered migrate) and **[MIGRATION.md](./MIGRATION.md)** (migrate up, dependencies, dynamic schema, `order-batch`). This file is a **checklist** (IDs, registry vs DB, curl patterns, troubleshooting).
+
## Executing migrations (one, many, or all)
This guide focuses on **running a specific migration** (or a subset / all), and on the
@@ -193,6 +195,10 @@ If you really want “all”, do it intentionally:
- pick one connection at a time (multiple calls), or
- ensure your target filter is explicit.
+## Tag-filtered execution (`target.tags`)
+
+See **[TAGS.md](./TAGS.md)** for HTTP/gRPC examples, AND semantics, dynamic schema + tags, and declaring tags in source. The FFM UI supports tag input and **Execute by tags** when Backend and Connection filters are set.
+
## Dry-run and dependency behavior
- **Dry run**: set `dry_run: true` (BfM will report what would be applied, without executing SQL/JSON).
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
new file mode 100644
index 0000000..00234df
--- /dev/null
+++ b/docs/MIGRATION.md
@@ -0,0 +1,222 @@
+# Running migrations over the API
+
+**Scope:** How to **execute up migrations** via **HTTP** and **gRPC**, including **dependency behavior**, **fixed vs dynamic schema**, and **batch ordering**. **No CLI** in this guide. For **tag filters**, see [TAGS.md](./TAGS.md). For **authoring** dependencies in code, see [MIGRATION_DEPENDENCIES.md](./MIGRATION_DEPENDENCIES.md).
+
+**Canonical types:**
+
+- HTTP JSON: [`api/internal/api/http/dto/migrations.go`](../api/internal/api/http/dto/migrations.go) (`MigrateUpRequest`, `MigrationTarget`, …)
+- Protobuf: [`api/internal/api/protobuf/migration.proto`](../api/internal/api/protobuf/migration.proto)
+
+---
+
+## Prerequisites
+
+- `BFM_API_TOKEN` on the server; HTTP clients use `Authorization: Bearer `.
+- Target **connection** is configured (env / config) and reachable.
+- Migration scripts are **compiled into** the running server (registry). If `GET /api/v1/migrations/{id}` returns empty `up_sql` / `down_sql`, the migration is not in the binary—rebuild and redeploy, then optionally `POST /api/v1/migrations/reindex` for listing.
+
+---
+
+## HTTP: `POST /api/v1/migrations/up`
+
+**Body** (`MigrateUpRequest`):
+
+| Field | Role |
+|-------|------|
+| `connection` | **Required.** Connection name (e.g. `core`). |
+| `target` | Filters which **registered** scripts run (`backend`, `connection`, optional `schema`, `tables`, `version`, optional `tags`). |
+| `schemas` | **Runtime schema(s)** for dynamic migrations or per-schema runs; see below. |
+| `dry_run` | If true, no SQL/JSON executed; useful for CI checks. |
+| `ignore_dependencies` | If true, **skip** dependency expansion/validation and sort by **version only** (dangerous). |
+
+**Response:** `success`, `applied[]`, `skipped[]`, `errors[]` (and optional `queued` / `job_id` if async queue is enabled).
+
+---
+
+## Fixed vs dynamic schema
+
+### Fixed-schema migration
+
+The migration is registered with a **non-empty** `schema` matching a real database schema.
+
+- You may set `target.schema` to that same schema to narrow selection.
+- `schemas` is often empty `[]` or a single schema aligned with execution context.
+
+### Dynamic-schema migration
+
+The migration is registered with **empty** `schema` (tenant / per-schema execution).
+
+- **Do not** set `target.schema` to a tenant name if that filters **out** dynamic rows (registry filter is equality on `migration.Schema`).
+- Pass the tenant (or logical schema) in **`schemas`**: e.g. `["tenant_123"]`. For multiple tenants in one request, use multiple entries: `["tenant_a","tenant_b"]` (server runs the selected set once per schema).
+
+---
+
+## Dependencies (default behavior)
+
+When `ignore_dependencies` is **false** (default):
+
+1. BfM selects migrations matching `target`.
+2. It **expands** the set with **pending** migrations required by structured/simple dependencies.
+3. It **orders** runs (topological sort / resolver) so dependents run after dependencies.
+4. On PostgreSQL, it may **validate** schemas/tables and dependency state before executing.
+
+**Declaring** dependencies happens in migration source (Go / metadata)—not in this API. See [MIGRATION_DEPENDENCIES.md](./MIGRATION_DEPENDENCIES.md).
+
+### Opt out: `ignore_dependencies: true`
+
+- No dependency expansion/validation (per server logic); **version order** only for the selected set.
+- Use only when you accept breakage risk (missing tables, wrong order).
+
+---
+
+## HTTP: dependency-safe order for a known ID set
+
+**Endpoint:** `POST /api/v1/migrations/order-batch`
+
+**Body:**
+
+```json
+{
+ "migration_ids": ["20240101120000_first_core_postgresql_core", "20240101120001_second_core_postgresql_core"],
+ "connection": "core"
+}
+```
+
+**Response:**
+
+```json
+{
+ "ordered_migration_ids": ["20240101120000_first_core_postgresql_core", "20240101120001_second_core_postgresql_core"]
+}
+```
+
+Use this when a client loads a **subset** of IDs (e.g. UI selection) and must run them in dependency order: call `order-batch`, then call **`migrations/up`** once per ID with `target.version` set, or run a broader `target` if appropriate.
+
+**gRPC:** This endpoint **does not** exist on `MigrationService` in [`migration.proto`](../api/internal/api/protobuf/migration.proto). gRPC clients should either:
+
+- Issue a single **`Migrate`** with a `target` that already includes all needed scripts (server orders internally), or
+- Order IDs using an HTTP client to `order-batch`, or
+- Implement ordering out-of-band (not recommended unless IDs are independent).
+
+---
+
+## HTTP examples
+
+Set `BASE` and `BFM_API_TOKEN`.
+
+### One migration by version (fixed schema)
+
+```bash
+curl -s -X POST "${BASE}/api/v1/migrations/up" \
+ -H "Authorization: Bearer ${BFM_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "connection": "core",
+ "target": {
+ "backend": "postgresql",
+ "connection": "core",
+ "schema": "core",
+ "tables": [],
+ "version": "20250101120000"
+ },
+ "schemas": [],
+ "dry_run": false,
+ "ignore_dependencies": false
+ }'
+```
+
+### Dynamic schema (single tenant)
+
+```bash
+curl -s -X POST "${BASE}/api/v1/migrations/up" \
+ -H "Authorization: Bearer ${BFM_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "connection": "core",
+ "target": {
+ "backend": "postgresql",
+ "connection": "core",
+ "schema": "",
+ "tables": [],
+ "version": "20250101120000"
+ },
+ "schemas": ["tenant_123"],
+ "dry_run": false,
+ "ignore_dependencies": false
+ }'
+```
+
+### All pending for connection (empty version, careful)
+
+```bash
+curl -s -X POST "${BASE}/api/v1/migrations/up" \
+ -H "Authorization: Bearer ${BFM_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "connection": "core",
+ "target": {
+ "backend": "postgresql",
+ "connection": "core",
+ "schema": "",
+ "tables": [],
+ "version": ""
+ },
+ "schemas": [],
+ "dry_run": false,
+ "ignore_dependencies": false
+ }'
+```
+
+Prefer **explicit** `target` filters (and [TAGS.md](./TAGS.md) if using tags) instead of overly broad runs.
+
+---
+
+## gRPC: `Migrate`
+
+**RPC:** `migration.MigrationService/Migrate`
+
+**Request** (`MigrateRequest`):
+
+- `target` — same fields as HTTP `MigrationTarget` (including optional `tags`).
+- `connection` — required string.
+- `schema` / `schema_name` — execution schema for this call (see proto comments). If `schema` is empty, `schema_name` can supply the dynamic schema name.
+- `dry_run`, `ignore_dependencies` — same meaning as HTTP.
+
+**Response:** `MigrateResponse` — `success`, `applied`, `skipped`, `errors`.
+
+**Multi-schema:** Unlike HTTP’s `schemas` array, a single `Migrate` carries **one** schema context. Repeat **`Migrate`** per tenant/schema or use HTTP for batch `schemas`.
+
+### Protobuf sketch (reference)
+
+```protobuf
+rpc Migrate(MigrateRequest) returns (MigrateResponse);
+
+message MigrateRequest {
+ MigrationTarget target = 1;
+ string connection = 2;
+ string schema = 3;
+ string schema_name = 4;
+ bool dry_run = 5;
+ bool ignore_dependencies = 6;
+}
+```
+
+---
+
+## Agent quick reference
+
+| Goal | HTTP | gRPC |
+|------|------|------|
+| Run with deps | `ignore_dependencies: false` | `ignore_dependencies: false` |
+| Force version order | `ignore_dependencies: true` | `ignore_dependencies: true` |
+| Dynamic tenant schema | `schemas: ["tenant"]`, `target.schema` often `""` | `schema` or `schema_name` |
+| Tag AND filter | `target.tags` | `target.tags` |
+| Reorder known IDs | `POST .../order-batch` | Not in proto—use HTTP or broad `Migrate` |
+
+---
+
+## See also
+
+- [TAGS.md](./TAGS.md) — `target.tags` only.
+- [EXECUTING_MIGRATIONS.md](./EXECUTING_MIGRATIONS.md) — troubleshooting, migration IDs, reindex.
+- [DEPLOYMENT.md](./DEPLOYMENT.md) — auto-migrate, env vars.
diff --git a/docs/TAGS.md b/docs/TAGS.md
new file mode 100644
index 0000000..5a753ae
--- /dev/null
+++ b/docs/TAGS.md
@@ -0,0 +1,209 @@
+# Tag-filtered migration execution (API)
+
+**Scope:** How to **select and run** migrations using `target.tags` over **HTTP** and **gRPC** only. This document is written for humans and **AI agents** implementing clients: copy-paste examples, explicit request shapes, and common failure modes.
+
+**Related:** General migrate semantics (dependencies, `schemas`, fixed vs dynamic schema) are in [MIGRATION.md](./MIGRATION.md). Declaring tags on migration source (SQL header / Go) is summarized under [Declaring tags in source](#declaring-tags-in-source) below.
+
+---
+
+## Prerequisites
+
+- Running BfM server with HTTP (default `7070`) and gRPC (default `9090`) as configured.
+- `BFM_API_TOKEN` set; clients send `Authorization: Bearer ` on HTTP.
+- Migrations are **registered in the server binary** (empty `up_sql` on `GET /api/v1/migrations/{id}` means not in registry—rebuild/redeploy first).
+- Canonical Protobuf: [`api/internal/api/protobuf/migration.proto`](../api/internal/api/protobuf/migration.proto) — `MigrationTarget.tags` (field 6).
+
+---
+
+## Concepts (agent checklist)
+
+| Rule | Detail |
+|------|--------|
+| Wire format | Each tag is one string `"key=value"`. Use JSON array `target.tags` (HTTP) or `repeated string tags` (proto). |
+| Parsing | Split on the **first** `=` only; trim key and value; keys normalized to **lowercase** on the server. |
+| Semantics | **AND**: every requested pair must match the migration’s tag map. |
+| Untagged migrations | If the request includes any tags, migrations **without** tags **do not** match. |
+| Invalid tags | HTTP **400**; gRPC **`InvalidArgument`**. |
+| Dependencies | Tag filter applies to the **initial** selection; pending **dependency** migrations may still be added and run without those tags. |
+| Reading tags | `GET /api/v1/migrations/{id}` and gRPC `GetMigration` return `tags` from the registry when present. |
+
+---
+
+## Dynamic schema and tags
+
+If a migration is defined with an **empty** logical schema (dynamic / tenant schema):
+
+- Do **not** set `target.schema` to a non-empty value to “mean” the tenant—dynamic rows usually have **empty** `schema` in the registry; a wrong `target.schema` filters them out.
+- Pass the runtime schema via HTTP **`schemas`** (e.g. `["tenant_123"]`) or gRPC **`schema`** / **`schema_name`** (one schema per `Migrate` call—repeat the RPC for multiple tenants).
+
+See [MIGRATION.md](./MIGRATION.md) for the full fixed vs dynamic rules.
+
+---
+
+## HTTP examples
+
+Replace `BASE` (e.g. `http://localhost:7070`) and `BFM_API_TOKEN`.
+
+### 1) Single tag, fixed schema (illustrative)
+
+```bash
+curl -s -X POST "${BASE}/api/v1/migrations/up" \
+ -H "Authorization: Bearer ${BFM_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "connection": "core",
+ "target": {
+ "backend": "postgresql",
+ "connection": "core",
+ "schema": "core",
+ "tables": [],
+ "version": "",
+ "tags": ["env=prod"]
+ },
+ "schemas": [],
+ "dry_run": false,
+ "ignore_dependencies": false
+ }'
+```
+
+### 2) Multiple tags (AND)
+
+All pairs must match the same migration’s declared tags:
+
+```bash
+curl -s -X POST "${BASE}/api/v1/migrations/up" \
+ -H "Authorization: Bearer ${BFM_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "connection": "core",
+ "target": {
+ "backend": "postgresql",
+ "connection": "core",
+ "schema": "",
+ "tables": [],
+ "version": "",
+ "tags": ["env=prod", "feature=billing"]
+ },
+ "schemas": [],
+ "dry_run": false,
+ "ignore_dependencies": false
+ }'
+```
+
+### 3) Dynamic schema + tags
+
+```bash
+curl -s -X POST "${BASE}/api/v1/migrations/up" \
+ -H "Authorization: Bearer ${BFM_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "connection": "core",
+ "target": {
+ "backend": "postgresql",
+ "connection": "core",
+ "schema": "",
+ "tables": [],
+ "version": "",
+ "tags": ["env=prod", "feature=billing"]
+ },
+ "schemas": ["tenant_123"],
+ "dry_run": false,
+ "ignore_dependencies": false
+ }'
+```
+
+### 4) Run all pending for a connection that match tags (empty version)
+
+Use **`version: ""`** and connection/backend filters; keep `target.schema` **empty** for dynamic-schema migrations.
+
+```bash
+curl -s -X POST "${BASE}/api/v1/migrations/up" \
+ -H "Authorization: Bearer ${BFM_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "connection": "core",
+ "target": {
+ "backend": "postgresql",
+ "connection": "core",
+ "schema": "",
+ "tables": [],
+ "version": "",
+ "tags": ["env=prod"]
+ },
+ "schemas": ["tenant_123"],
+ "dry_run": false,
+ "ignore_dependencies": false
+ }'
+```
+
+---
+
+## gRPC / Protobuf examples
+
+**Service:** `migration.MigrationService` / **`Migrate`**.
+
+**Messages** (see [`migration.proto`](../api/internal/api/protobuf/migration.proto)):
+
+```protobuf
+message MigrationTarget {
+ string backend = 1;
+ string schema = 2;
+ repeated string tables = 3;
+ string version = 4;
+ string connection = 5;
+ repeated string tags = 6; // each: "key=value"
+}
+
+message MigrateRequest {
+ MigrationTarget target = 1;
+ string connection = 2;
+ string schema = 3;
+ string schema_name = 4; // alternative to schema for dynamic naming
+ bool dry_run = 5;
+ bool ignore_dependencies = 6;
+}
+```
+
+### Equivalent: single tag + dynamic schema
+
+- Set `target.connection`, `target.backend`, `target.tags = ["env=prod"]`.
+- For tenant execution, set **`schema`** or **`schema_name`** to the tenant schema (e.g. `tenant_123`), not `target.schema` if that would exclude dynamic migrations (same HTTP rules).
+
+Pseudo **grpcurl** (adjust `-d` to your tool):
+
+```json
+{
+ "target": {
+ "backend": "postgresql",
+ "connection": "core",
+ "tags": ["env=prod", "feature=billing"]
+ },
+ "connection": "core",
+ "schema_name": "tenant_123",
+ "dry_run": false,
+ "ignore_dependencies": false
+}
+```
+
+**Note:** One gRPC `Migrate` call applies **one** execution schema (`schema` / `schema_name`). For multiple tenants, call `Migrate` once per schema or use HTTP `schemas: ["a","b"]` which loops per schema.
+
+---
+
+## Declaring tags in source
+
+Not required for calling the API; migrations may have **no** tags.
+
+- **SQL:** optional first-line style header in `.up.sql` consumed at **build** time:
+ `-- bfm-tags: env=prod, feature=billing`
+- **Go:** `MigrationScript.Tags = []string{"env=prod", ...}`
+
+For build pipeline details, see [DEVELOPMENT.md](./DEVELOPMENT.md).
+
+---
+
+## Common mistakes (agents)
+
+1. **Malformed tag** (missing `=`, empty key) → 400 / `InvalidArgument`.
+2. **AND too strict** — combining tags that no single migration has → no matches; **nothing applied** (success with empty applied list is possible).
+3. **Dynamic schema** — setting `target.schema` incorrectly so dynamic migrations never match; use empty `target.schema` and `schemas` / `schema_name` instead.
+4. **Expecting OR between tags** — BfM uses **AND** only for `target.tags`.
diff --git a/examples/sfm/postgresql/core/20260509120000_core_schema_tagged_example.down.sql b/examples/sfm/postgresql/core/20260509120000_core_schema_tagged_example.down.sql
new file mode 100644
index 0000000..475c408
--- /dev/null
+++ b/examples/sfm/postgresql/core/20260509120000_core_schema_tagged_example.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS core_schema_tagged_example;
diff --git a/examples/sfm/postgresql/core/20260509120000_core_schema_tagged_example.go b/examples/sfm/postgresql/core/20260509120000_core_schema_tagged_example.go
new file mode 100644
index 0000000..237c63b
--- /dev/null
+++ b/examples/sfm/postgresql/core/20260509120000_core_schema_tagged_example.go
@@ -0,0 +1,31 @@
+//go:build ignore
+
+package core
+
+import (
+ _ "embed"
+
+ "github.com/toolsascode/bfm/api/migrations"
+)
+
+//go:embed 20260509120000_core_schema_tagged_example.up.sql
+var upSQLCoreSchemaTaggedExample string
+
+//go:embed 20260509120000_core_schema_tagged_example.down.sql
+var downSQLCoreSchemaTaggedExample string
+
+func init() {
+ migration := &migrations.MigrationScript{
+ Schema: "core",
+ Version: "20260509120000",
+ Name: "core_schema_tagged_example",
+ Connection: "core",
+ Backend: "postgresql",
+ UpSQL: upSQLCoreSchemaTaggedExample,
+ DownSQL: downSQLCoreSchemaTaggedExample,
+ Dependencies: []string{},
+ StructuredDependencies: []migrations.Dependency{},
+ Tags: []string{"example=demo", "tier=optional"},
+ }
+ migrations.GlobalRegistry.Register(migration)
+}
diff --git a/examples/sfm/postgresql/core/20260509120000_core_schema_tagged_example.up.sql b/examples/sfm/postgresql/core/20260509120000_core_schema_tagged_example.up.sql
new file mode 100644
index 0000000..3bb4130
--- /dev/null
+++ b/examples/sfm/postgresql/core/20260509120000_core_schema_tagged_example.up.sql
@@ -0,0 +1,6 @@
+-- bfm-tags: example=demo, tier=optional
+-- Example: fixed-schema migration with labels for tag-filtered migrate (see docs/TAGS.md).
+CREATE TABLE IF NOT EXISTS core_schema_tagged_example (
+ id BIGSERIAL PRIMARY KEY,
+ note TEXT NOT NULL DEFAULT ''
+);
diff --git a/ffm/package-lock.json b/ffm/package-lock.json
index 92b2cc3..26d265b 100644
--- a/ffm/package-lock.json
+++ b/ffm/package-lock.json
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
+ "@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
@@ -34,6 +35,19 @@
"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,
@@ -816,65 +830,50 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
- "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==",
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
+ "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
- "enhanced-resolve": "^5.19.0",
+ "enhanced-resolve": "^5.21.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
- "tailwindcss": "4.2.4"
+ "tailwindcss": "4.3.0"
}
},
- "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==",
+ "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==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
- "@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==",
+ "@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==",
"cpu": [
"arm64"
],
@@ -888,10 +887,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"cpu": [
"arm64"
],
@@ -905,10 +904,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -922,10 +921,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -939,10 +938,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"cpu": [
"arm"
],
@@ -956,10 +955,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"cpu": [
"arm64"
],
@@ -973,10 +972,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"cpu": [
"arm64"
],
@@ -990,10 +989,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -1007,10 +1006,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -1024,10 +1023,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -1043,10 +1042,10 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.8.1",
- "@emnapi/runtime": "^1.8.1",
- "@emnapi/wasi-threads": "^1.1.0",
- "@napi-rs/wasm-runtime": "^1.1.1",
+ "@emnapi/core": "^1.10.0",
+ "@emnapi/runtime": "^1.10.0",
+ "@emnapi/wasi-threads": "^1.2.1",
+ "@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
@@ -1054,10 +1053,10 @@
"node": ">=14.0.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==",
+ "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==",
"cpu": [
"arm64"
],
@@ -1071,10 +1070,10 @@
"node": ">= 20"
}
},
- "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==",
+ "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==",
"cpu": [
"x64"
],
@@ -1088,301 +1087,35 @@
"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": {
- "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/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,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "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,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "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"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "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,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "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,
- "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-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": "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": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "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": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "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",
+ "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==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.20.7",
- "@babel/types": "^7.20.7",
- "@types/babel__generator": "*",
- "@types/babel__template": "*",
- "@types/babel__traverse": "*"
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.3.0",
+ "@tailwindcss/oxide": "4.3.0",
+ "postcss": "^8.5.10",
+ "tailwindcss": "4.3.0"
}
},
- "node_modules/@types/babel__generator": {
- "version": "7.27.0",
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
+ "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.0.0"
+ "@tailwindcss/node": "4.3.0",
+ "@tailwindcss/oxide": "4.3.0",
+ "tailwindcss": "4.3.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
- "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
- "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2628,6 +2361,20 @@
"node": ">= 6"
}
},
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "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",
@@ -3376,6 +3123,13 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"dev": true,
diff --git a/ffm/package.json b/ffm/package.json
index 3d5db4b..7249fd0 100644
--- a/ffm/package.json
+++ b/ffm/package.json
@@ -13,13 +13,15 @@
"dependencies": {
"axios": "^1.13.4",
"date-fns": "^2.30.0",
- "react-dom": "^19.2.4",
"react": "^19.2.4",
+ "react-dom": "^19.2.4",
"react-is": "^19.2.4",
- "recharts": "^3.7.0",
- "react-router-dom": "^7.13.0"
+ "react-router-dom": "^7.13.0",
+ "recharts": "^3.7.0"
},
"devDependencies": {
+ "@tailwindcss/postcss": "^4.3.0",
+ "@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
@@ -27,7 +29,6 @@
"@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
- "@tailwindcss/postcss": "^4.3.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
diff --git a/ffm/public/runtime-config.js b/ffm/public/runtime-config.js
deleted file mode 100644
index 0162c92..0000000
--- a/ffm/public/runtime-config.js
+++ /dev/null
@@ -1,7 +0,0 @@
-window.__RUNTIME_CONFIG__ = {
- BFM_API_URL: "/api",
- BFM_API_TOKEN: "SFXfytYJr3RfrjPMgEkhTEukOGpjhtLEmmJFYv+7GHQ=",
- BFM_AUTH_ENABLED: "true",
- BFM_AUTH_USERNAME: "admin",
- BFM_AUTH_PASSWORD: "admin123",
-};
diff --git a/ffm/src/components/MigrationDetail.tsx b/ffm/src/components/MigrationDetail.tsx
index 692e72b..163b47b 100644
--- a/ffm/src/components/MigrationDetail.tsx
+++ b/ffm/src/components/MigrationDetail.tsx
@@ -13,6 +13,7 @@ import type {
} from "../types/api";
import { format } from "date-fns";
import { toastService } from "../services/toast";
+import { parseExecutionTags } from "../utils/migrationTags";
function historyStatusIndicatesApplied(s: string): boolean {
return s === "success" || s === "applied";
@@ -233,6 +234,7 @@ export default function MigrationDetail() {
const [historyPage, setHistoryPage] = useState(1);
const [historyPerPage, setHistoryPerPage] = useState(10);
const [ignoreDependencies, setIgnoreDependencies] = useState(false);
+ const [migrateExtraTags, setMigrateExtraTags] = useState("");
const [recentlySkipped, setRecentlySkipped] = useState(
[],
);
@@ -475,6 +477,13 @@ export default function MigrationDetail() {
setExecutionResult(null);
try {
+ const tagParse = parseExecutionTags(migrateExtraTags);
+ if (!tagParse.ok) {
+ setExecutionError(tagParse.error);
+ setIsExecuting(false);
+ return;
+ }
+
// Determine schema to use
// For SQL: use user-provided schema or migration.schema
// For NoSQL: schema might represent prefix, but it's handled server-side via env vars
@@ -487,6 +496,7 @@ export default function MigrationDetail() {
backend: migration.backend,
connection: migration.connection,
version: migration.version,
+ ...(tagParse.tags.length > 0 ? { tags: tagParse.tags } : {}),
},
schemas: schemaToUse ? [schemaToUse] : [],
ignore_dependencies: ignoreDependencies,
@@ -735,6 +745,23 @@ export default function MigrationDetail() {
{migration.connection}
+ {migration.tags && migration.tags.length > 0 && (
+
+
+
+ {migration.tags.map((t) => (
+
+ {t}
+
+ ))}
+
+
+ )}
)}
+
+ setExecutionTagsInput(e.target.value)}
+ className="w-full px-3 py-2 border border-white/30 rounded text-sm bg-white/10 text-white placeholder-white/50 focus:outline-none focus:border-white/50"
+ title="Comma-separated key=value pairs; AND filter. Optional for Execute Selected; required for Execute by tags."
+ />
+
+ AND: every pair must match. Keys normalized to lowercase on
+ server.
+
+
+