Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions TEMPLATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,55 @@ ALTER TABLE `products` ADD INDEX `idx_category_price`(`category`, `price`);
📋 **Plan**: **2** tables to create, **1** table to alter


---

▶️ **To apply** all schema changes from this PR, comment:
```
schemabot apply -e staging
```

</details>

<details>
<summary><a name="mysql-plan-ignored-namespaces"></a><strong>MySQL Plan (Ignored Namespaces)</strong></summary>


## Schema Change Plan — Staging

**Database**: `testapp` | **Type**: `MySQL` | **Schema Name**: `testapp`

*Requested by @jackjackbits at 2026-01-01 00:00:00 UTC · planned from [`abcdef1`](https://github.com/block/schemabot/commit/abcdef1234567890abcdef1234567890abcdef12)*

```sql
CREATE TABLE `users` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`created_at` timestamp DEFAULT current_timestamp(),
PRIMARY KEY(`id`),
INDEX `idx_email`(`email`)
) ENGINE InnoDB,
CHARSET utf8mb4,
COLLATE utf8mb4_0900_ai_ci;

CREATE TABLE `orders` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`total_cents` bigint NOT NULL,
`status` varchar(50) NOT NULL DEFAULT 'pending',
PRIMARY KEY(`id`),
INDEX `idx_user_id`(`user_id`)
) ENGINE InnoDB,
CHARSET utf8mb4,
COLLATE utf8mb4_0900_ai_ci;

ALTER TABLE `products` ADD INDEX `idx_category_price`(`category`, `price`);
```

📋 **Plan**: **2** tables to create, **1** table to alter

ℹ️ Namespaces excluded from this plan by `ignore_namespaces`: `local_fixtures`


---

▶️ **To apply** all schema changes from this PR, comment:
Expand Down
1 change: 1 addition & 0 deletions docs/github-app-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ type: mysql
|-------|----------|-------------|
| `database` | Yes | Must match a database name in your SchemaBot server config |
| `type` | Yes | `"mysql"` or `"vitess"` |
| `ignore_namespaces` | No | Namespace subdirectories to exclude from plans, applies, and checks (see [Ignoring Namespaces](namespaces.md#ignoring-namespaces)) |

Environment availability and promotion order are configured on the SchemaBot server.

Expand Down
62 changes: 61 additions & 1 deletion docs/namespaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
- [`$ENV` Substitution in Namespace Names](#env-substitution-in-namespace-names)
- [Example](#example)
- [Rules](#rules)
- [Per-Target Schema Overrides](#per-target-schema-overrides)
- [Ignoring Namespaces](#ignoring-namespaces)
- [Rules](#rules-1)
- [Exclusions are disclosed](#exclusions-are-disclosed)
- [MySQL target DSN requirements](#mysql-target-dsn-requirements)
- [Per-Target Schema Overrides](#per-target-schema-overrides)
- [Rules](#rules-2)
- [Summary](#summary)
- [How Namespaces Flow Through the System](#how-namespaces-flow-through-the-system)

Expand Down Expand Up @@ -220,6 +224,61 @@ schemabot plan -s myapp/schema -e production
- You can mix `$ENV` directories with regular directories in the subdirectory layout.
- When creating the directory from a shell, quote the name to prevent shell expansion: `mkdir 'bikeshare_$ENV'`

## Ignoring Namespaces

Some schema roots contain a namespace directory that should never be reconciled against a live database. A common example is a Vitess keyspace that exists only in local test infrastructure: the repository carries its schema files so local tooling can create the keyspace, but no real environment has it (or the environment keeps it intentionally empty). Without an exclusion, every plan would propose creating those tables.

List such namespaces under `ignore_namespaces` in `schemabot.yaml`:

```
myapp/schema/
├── schemabot.yaml
├── commerce/
│ ├── orders.sql
│ └── vschema.json
└── commerce_test/ ← ignored: never planned or applied
├── fixtures.sql
└── vschema.json
```

```yaml
# schemabot.yaml
database: commerce
type: vitess
ignore_namespaces:
- commerce_test
```

### Rules

- Entries are bare namespace names, not paths. An entry containing `/` or `\` (e.g., `schema/commerce_test`) is rejected when the config is loaded.
- Ignored namespaces are excluded from plans, applies, and merge-gate checks. This applies to both the GitHub PR flow and the CLI (`schemabot plan` / `schemabot apply` read the same `schemabot.yaml`).
- `$ENV` substitution applies to entries the same way it applies to directory names: `fixtures_$ENV` ignores the `fixtures_staging` namespace when planning for staging.
- Matching is exact and case-sensitive. An entry that matches no namespace directory excludes nothing; the plan proceeds and the unmatched entry is reported (a CLI warning, a server-side log) so a typo or stale entry is visible.
- Ignoring every namespace in the schema root is an error: the plan fails rather than reconciling an empty desired state.
- Ignoring a namespace does not exempt the directory from layout validation; a schema root mixing flat files and subdirectories is still rejected.

### Exclusions are disclosed

Every plan that excluded namespaces says so: the PR plan comment renders an
`ℹ️ Namespaces excluded from this plan by ignore_namespaces: …` line under the
plan summary (also on "no changes" results, so a withheld namespace is
distinguishable from an unchanged one), and the CLI prints the same disclosure
for `plan` and `apply`.
When reviewing a PR that *introduces* an `ignore_namespaces` entry, the
disclosure plus the config diff is the review surface: the plan stops
reconciling that namespace from this PR onward.

### MySQL target DSN requirements

For MySQL targets, `ignore_namespaces` requires a **namespace-free** target
DSN (one that does not name a database), where each namespace directory is
diffed against its own database. A DSN that already names a database diffs
the whole database as one unit: an ignored namespace's live tables would
have no declaring files and the diff would plan them as `DROP TABLE`, the
inverse of "ignore". SchemaBot refuses this combination, and the plan fails with
an error asking for a namespace-free DSN or removal of `ignore_namespaces`.

## Per-Target Schema Overrides

`$ENV` substitution handles physical schema names that vary by *environment*. When names vary by *deployment within one environment* — several regional clusters in the same environment naming the schema `bikeshare_qa`, `bikeshare_eu_qa`, and `bikeshare_us_qa` — one schema directory cannot express the variance, and copying the directory per region would triple the source of truth.
Expand Down Expand Up @@ -256,6 +315,7 @@ The canonical namespace stays the name everywhere SchemaBot stores or shows it
| MySQL, different databases | 1 per database | 1 each | separate directories |
| Vitess, multiple keyspaces | 1 | many | `commerce/`, `commerce_sharded/` |
| Environment-specific namespace | 1 | 1 per env | `bikeshare_$ENV/` |
| Repo-only namespace (never deployed) | 1 | all except ignored | `ignore_namespaces: [commerce_test]` |
| Deployment-specific physical schema | 1 | 1 canonical | `bikeshare/` + per-target `schema_overrides` |

## How Namespaces Flow Through the System
Expand Down
4 changes: 2 additions & 2 deletions e2e/local/vitess_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1500,7 +1500,7 @@ func TestVitess_Apply_DeferDeploy(t *testing.T) {
schemaDir := newVitessSchemaDir(t, vitessSchemaWithOverrides(map[string]string{
"testapp_sharded/users.sql": usersSchemaWithColumn(colName),
}))
planResp, err := client.CallPlanAPI(endpoint, vitessDB, "vitess", "staging", schemaDir, "", 0)
planResp, _, err := client.CallPlanAPI(endpoint, vitessDB, "vitess", "staging", schemaDir, "", 0, nil)
require.NoError(t, err)
require.NotEmpty(t, planResp.PlanID)
require.NotEmpty(t, planResp.Changes, "expected plan to have changes")
Expand Down Expand Up @@ -1554,7 +1554,7 @@ func TestVitess_Apply_DeferDeploy_StartTooEarly(t *testing.T) {
schemaDir := newVitessSchemaDir(t, vitessSchemaWithOverrides(map[string]string{
"testapp_sharded/users.sql": usersSchemaWithColumn(colName),
}))
planResp, err := client.CallPlanAPI(endpoint, vitessDB, "vitess", "staging", schemaDir, "", 0)
planResp, _, err := client.CallPlanAPI(endpoint, vitessDB, "vitess", "staging", schemaDir, "", 0, nil)
require.NoError(t, err)
require.NotEmpty(t, planResp.PlanID)

Expand Down
2 changes: 1 addition & 1 deletion e2e/testutil/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
// subdirectory as the namespace.
func groupFiles(t *testing.T, files map[string]string, database string) map[string]*apitypes.SchemaFiles {
t.Helper()
grouped, err := schema.GroupFilesByNamespace(files, database, "development")
grouped, _, err := schema.GroupFilesByNamespace(files, database, "development", nil)
require.NoError(t, err, "group schema files by namespace")
result := make(map[string]*apitypes.SchemaFiles, len(grouped))
for ns, nsFiles := range grouped {
Expand Down
33 changes: 26 additions & 7 deletions pkg/api/plan_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ type PlanRequest struct {
// Optional — absent for non-webhook callers (e.g. CLI plan invocations without a PR).
HeadSHA *string `json:"head_sha,omitempty"`
SchemaPath string `json:"-"`
// IgnoredNamespaces lists the namespaces the caller removed from
// SchemaFiles per the config's ignore_namespaces, resolved for the
// environment. Forwarded to the data plane so it can refuse engine shapes
// that cannot honor the exclusion.
IgnoredNamespaces []string `json:"ignored_namespaces,omitempty"`

// SourceTrusted is set by the GitHub webhook path after SchemaBot has
// discovered the PR source itself. It is deliberately not JSON-decodable:
Expand Down Expand Up @@ -546,13 +551,14 @@ func (s *Service) ExecutePlanProto(ctx context.Context, req PlanRequest) (*ternv
}

ternReq := &ternv1.PlanRequest{
Database: req.Database,
Type: resolvedTarget.DatabaseType,
SchemaFiles: req.SchemaFiles,
Repository: req.Repository,
Environment: req.Environment,
Target: resolvedTarget.Target,
SchemaPath: trustedSchemaPath,
Database: req.Database,
Type: resolvedTarget.DatabaseType,
SchemaFiles: req.SchemaFiles,
Repository: req.Repository,
Environment: req.Environment,
Target: resolvedTarget.Target,
SchemaPath: trustedSchemaPath,
IgnoredNamespaces: req.IgnoredNamespaces,
}
if req.PullRequest != nil {
ternReq.PullRequest = *req.PullRequest
Expand All @@ -569,6 +575,19 @@ func (s *Service) ExecutePlanProto(ctx context.Context, req PlanRequest) (*ternv
"is_remote", client.IsRemote(),
"schema_file_count", len(req.SchemaFiles),
)
if len(req.IgnoredNamespaces) > 0 {
// The desired state is deliberately partial: the caller withheld these
// namespaces per the config's ignore_namespaces. Recorded so an operator
// tracing "why does this plan not touch namespace X" finds the answer in
// server logs.
s.logger.Info("plan request excludes ignored namespaces",
"database", req.Database,
"environment", req.Environment,
"deployment", deployment,
"repository", req.Repository,
"ignored_namespaces", req.IgnoredNamespaces,
)
}

resp, err := client.Plan(ctx, ternReq)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions pkg/apitypes/apitypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,12 @@ type PlanRequest struct {
// cross-delivery race where HEAD advances between plan and confirm.
// Optional — absent for non-webhook callers (e.g. CLI plan invocations without a PR).
HeadSHA *string `json:"head_sha,omitempty"`
// IgnoredNamespaces lists the namespaces the caller removed from
// SchemaFiles per the config's ignore_namespaces, resolved for the
// environment. The data plane refuses engine shapes that diff the whole
// target as one unit (a database-scoped MySQL DSN), where a withheld
// namespace's live tables would otherwise be planned as drops.
IgnoredNamespaces []string `json:"ignored_namespaces,omitempty"`
}

// ApplyRequest is the HTTP request body for POST /api/apply.
Expand Down
60 changes: 41 additions & 19 deletions pkg/cmd/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,26 +143,44 @@ func CallPullSchemaAPIWithOptions(endpoint, database, dbType, environment string

// CallPlanAPI calls the plan API by reading .sql files from schemaDir.
// Files are grouped by namespace: subdirectories become namespace keys,
// flat files use the directory name as the namespace.
func CallPlanAPI(endpoint, database, dbType, environment, schemaDir, repo string, pr int) (*apitypes.PlanResponse, error) {
schemaFiles, err := ReadSchemaFiles(schemaDir, environment)
// flat files use the directory name as the namespace. Namespaces listed in
// ignoreNamespaces are excluded from the plan request. The second return
// value lists the namespaces actually removed by ignoreNamespaces so callers
// can disclose the exclusion alongside the plan.
func CallPlanAPI(endpoint, database, dbType, environment, schemaDir, repo string, pr int, ignoreNamespaces []string) (*apitypes.PlanResponse, []string, error) {
schemaFiles, ignored, err := ReadSchemaFiles(schemaDir, environment, ignoreNamespaces)
if err != nil {
return nil, fmt.Errorf("read schema files: %w", err)
return nil, nil, fmt.Errorf("read schema files: %w", err)
}
if len(schemaFiles) == 0 {
return nil, fmt.Errorf("no .sql files found in %s", schemaDir)
if len(ignored) > 0 {
return nil, ignored, fmt.Errorf("no .sql files found in %s after excluding ignored namespaces %v", schemaDir, ignored)
}
return nil, nil, fmt.Errorf("no .sql files found in %s", schemaDir)
}
resp, err := postPlanRequest(endpoint, database, dbType, environment, schemaFiles, repo, pr, ignored)
if err != nil {
return nil, ignored, err
}
return CallPlanAPIWithFiles(endpoint, database, dbType, environment, schemaFiles, repo, pr)
return resp, ignored, nil
}

// CallPlanAPIWithFiles calls the plan API with pre-loaded, namespace-grouped schema files.
func CallPlanAPIWithFiles(endpoint, database, dbType, environment string, schemaFiles map[string]*apitypes.SchemaFiles, repo string, pr int) (*apitypes.PlanResponse, error) {
return postPlanRequest(endpoint, database, dbType, environment, schemaFiles, repo, pr, nil)
}

// postPlanRequest posts a plan request. ignoredNamespaces names the
// namespaces removed from schemaFiles before the call — the server needs
// them to refuse engine shapes that cannot honor the exclusion.
func postPlanRequest(endpoint, database, dbType, environment string, schemaFiles map[string]*apitypes.SchemaFiles, repo string, pr int, ignoredNamespaces []string) (*apitypes.PlanResponse, error) {
req := apitypes.PlanRequest{
Database: database,
Type: dbType,
Environment: environment,
SchemaFiles: schemaFiles,
Repository: repo,
Database: database,
Type: dbType,
Environment: environment,
SchemaFiles: schemaFiles,
Repository: repo,
IgnoredNamespaces: ignoredNamespaces,
}
if pr != 0 {
prVal := int32(pr)
Expand Down Expand Up @@ -291,13 +309,17 @@ func CheckActiveSchemaChange(endpoint, database, environment string) (*ActiveSch
// The environment parameter enables $ENV substitution in namespace names.
// If non-empty, any "$ENV" in directory names or the default namespace is
// replaced with the environment value (e.g., "bikeshare_$ENV" → "bikeshare_staging").
func ReadSchemaFiles(dir string, environment string) (map[string]*apitypes.SchemaFiles, error) {
//
// Namespaces listed in ignoreNamespaces (schemabot.yaml ignore_namespaces) are
// excluded from the result. The second return value lists the namespace keys
// actually removed by ignoreNamespaces, sorted.
func ReadSchemaFiles(dir string, environment string, ignoreNamespaces []string) (map[string]*apitypes.SchemaFiles, []string, error) {
// Collect all files as relativePath → content
rawFiles := make(map[string]string)

entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
return nil, nil, err
}

for _, entry := range entries {
Expand All @@ -313,7 +335,7 @@ func ReadSchemaFiles(dir string, environment string) (map[string]*apitypes.Schem
// Read schema files inside the subdirectory
subEntries, err := os.ReadDir(filepath.Join(dir, entry.Name()))
if err != nil {
return nil, fmt.Errorf("read subdirectory %s: %w", entry.Name(), err)
return nil, nil, fmt.Errorf("read subdirectory %s: %w", entry.Name(), err)
}
for _, sub := range subEntries {
if sub.IsDir() {
Expand All @@ -327,7 +349,7 @@ func ReadSchemaFiles(dir string, environment string) (map[string]*apitypes.Schem
relPath := path.Join(entry.Name(), sub.Name())
content, err := os.ReadFile(filepath.Join(dir, entry.Name(), sub.Name()))
if err != nil {
return nil, fmt.Errorf("read %s: %w", relPath, err)
return nil, nil, fmt.Errorf("read %s: %w", relPath, err)
}
rawFiles[relPath] = string(content)
}
Expand All @@ -338,24 +360,24 @@ func ReadSchemaFiles(dir string, environment string) (map[string]*apitypes.Schem
}
content, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
return nil, fmt.Errorf("read %s: %w", entry.Name(), err)
return nil, nil, fmt.Errorf("read %s: %w", entry.Name(), err)
}
rawFiles[entry.Name()] = string(content)
}

// Group by namespace using the shared helper.
// For flat files, the directory name is the database name.
grouped, err := schema.GroupFilesByNamespace(rawFiles, filepath.Base(dir), environment)
grouped, ignored, err := schema.GroupFilesByNamespace(rawFiles, filepath.Base(dir), environment, ignoreNamespaces)
if err != nil {
return nil, err
return nil, nil, err
}

// Convert schema.SchemaFiles → apitypes.SchemaFiles
result := make(map[string]*apitypes.SchemaFiles, len(grouped))
for ns, nsFiles := range grouped {
result[ns] = &apitypes.SchemaFiles{Files: nsFiles.Files}
}
return result, nil
return result, ignored, nil
}

func isSchemaFile(name string) bool {
Expand Down
Loading
Loading