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
58 changes: 58 additions & 0 deletions TEMPLATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,64 @@ CREATE TABLE `addresses` (
📋 **Plan**: **2** tables to create, **2** vschema updates


---

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

</details>

<details>
<summary><a name="vitess-plan-vschema-removal-unsafe"></a><strong>Vitess Plan: VSchema Removal (Unsafe)</strong></summary>


## Schema Change Plan — Staging

**Database**: `commerce` | **Type**: `Vitess`

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

#### Keyspace: `commerce_sharded`
#### VSchema
```diff
--- a/commerce_sharded.json
+++ b/commerce_sharded.json
@@ -3,10 +3,6 @@
"hash": {
"type": "hash"
- },
- "customers_email_lookup": {
- "type": "consistent_lookup_unique",
- "params": {
- "table": "customers_email_lookup",
- "from": "email",
- "to": "keyspace_id"
- },
- "owner": "customers"
}
},
@@ -18,8 +14,4 @@
{
"column": "id",
"name": "hash"
- },
- {
- "column": "email",
- "name": "customers_email_lookup"
}
]
}
```

⚠️ **Issues**: **2** unsafe changes detected
- `commerce_sharded/vschema.json`: lookup vindex `customers_email_lookup` is removed: Vitess immediately stops maintaining its rows in backing table `customers_email_lookup`, queries routed through it can fail or scatter, and the lookup data goes stale
- `commerce_sharded/vschema.json`: table `customers` no longer uses vindex `customers_email_lookup`: routing for queries on its columns changes immediately and lookup rows stop being maintained

📋 **Plan**: **1** vschema update


---

▶️ **To apply** all schema changes from this PR, comment:
Expand Down
12 changes: 12 additions & 0 deletions docs/lint-and-safety-levels.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ classified unsafe when any of the following hold:
- a lint rule raised it at error severity (for example dropping an index that
was never made invisible).

A Vitess VSchema change is unsafe when it removes anything from the current
VSchema: a vindex definition, a table's routing entry, or a table's
column-vindex association. A removal changes query routing the moment the
VSchema is applied — Vitess stops using the removed vindex, a lookup vindex's
backing table stops being maintained and its rows go stale, and queries that
depended on the removed entry can fail or scatter. Deleting a vindex is as
dangerous as dropping a table, so it takes the same `--allow-unsafe`
acknowledgment. Additions-only VSchema changes (new vindexes, new tables, new
column-vindex associations) are not unsafe. Removals are detected structurally
by comparing the current and desired VSchema documents; a VSchema that cannot
be parsed fails the plan rather than skipping detection.

Unsafe does not mean broken. An unsafe change will usually apply successfully —
the point of the gate is that it is destructive or irreversible, so SchemaBot
requires an explicit, auditable acknowledgment (`--allow-unsafe`) instead of
Expand Down
5 changes: 5 additions & 0 deletions docs/namespaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ SchemaBot plans all keyspaces together — a single plan can contain changes acr

A plan can have DDL-only changes, VSchema-only changes, or both.

A VSchema change that removes a vindex, a table routing entry, or a table's
column-vindex association is an unsafe change and requires the same
`--allow-unsafe` acknowledgment as destructive DDL — see
[lint-and-safety-levels.md](./lint-and-safety-levels.md#what-unsafe-means).

## Where to Put the Schema Directory

SchemaBot is location-agnostic: config discovery finds `schemabot.yaml` anywhere
Expand Down
46 changes: 46 additions & 0 deletions e2e/local/vitess_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,52 @@ func TestVitess_Apply_DropIndex_BlockedWithoutFlag(t *testing.T) {
assert.Contains(t, out, "Unsafe Changes Detected")
}

// TestVitess_Apply_VSchemaVindexRemoval_BlockedWithoutFlag exercises the
// VSchema safety gate. Removing a vindex from a keyspace's vschema.json is an
// unsafe change: Vitess stops using the vindex for routing the moment the
// VSchema is applied, and queries that depended on it can fail or scatter.
// The plan must disclose the removal as unsafe, an apply without
// --allow-unsafe must be refused before anything starts, and an apply that
// acknowledges the removal with --allow-unsafe must complete.
func TestVitess_Apply_VSchemaVindexRemoval_BlockedWithoutFlag(t *testing.T) {
vitessAvailable(t)
vitessRestoreBaseSchema(t, "staging")
defer vitessRestoreBaseSchema(t, "staging")
binPath := buildCLI(t)
endpoint := schemabotURL(t)

// Seed a live VSchema carrying an extra xxhash vindex directly via the
// admin endpoint, so planning the base schema (which does not define
// xxhash) produces a vindex removal and nothing else.
vschemaWithXxhash := `{"sharded":true,"vindexes":{"hash":{"type":"hash"},"xxhash":{"type":"xxhash"}},"tables":{"users":{"column_vindexes":[{"column":"id","name":"hash"}],"auto_increment":{"column":"id","sequence":"users_seq"}},"orders":{"column_vindexes":[{"column":"user_id","name":"hash"}],"auto_increment":{"column":"id","sequence":"orders_seq"}},"products":{"column_vindexes":[{"column":"id","name":"hash"}],"auto_increment":{"column":"id","sequence":"products_seq"}}}}`
seedBody := fmt.Sprintf(`{"org":%q,"database":%q,"keyspace":%q,"vschema":%s}`,
"localscale-staging", vitessDB, "testapp_sharded", vschemaWithXxhash)
_, err := localscaleAdminPost(t, "/admin/seed-vschema", seedBody)
require.NoError(t, err, "seed VSchema with extra vindex")
clearSchemaBotState(t)

baseSchema := newVitessSchemaDir(t, vitessBaseSchema())

// The plan discloses the vindex removal as an unsafe change on the
// keyspace's vschema.json.
planOut := e2eutil.RunCLIInDir(t, binPath, baseSchema, "plan",
"-s", ".", "-e", "staging", "--endpoint", endpoint)
e2eutil.AssertContains(t, planOut, "Unsafe Changes Detected")
e2eutil.AssertContains(t, planOut, "xxhash")
e2eutil.AssertContains(t, planOut, "vschema.json")

// Applying without --allow-unsafe is refused.
out, err := e2eutil.RunCLIWithErrorInDir(t, binPath, baseSchema, "apply",
"-s", ".", "-e", "staging", "--endpoint", endpoint, "-y", "-o", "log")
t.Logf("VSchema vindex removal apply output:\n%s", out)
require.Error(t, err, "expected apply to fail without --allow-unsafe")
assert.Contains(t, out, "Unsafe Changes Detected")

// Acknowledging the removal with --allow-unsafe lets the apply proceed.
clearSchemaBotState(t)
vitessApplyAndWait(t, baseSchema, "staging")
}

func TestVitess_Apply_DropTable_WithVSchema(t *testing.T) {
vitessAvailable(t)
clearSchemaBotState(t)
Expand Down
8 changes: 5 additions & 3 deletions pkg/apitypes/apitypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,9 +476,10 @@ type UnsafeChange struct {
ChangeType string
}

// UnsafeChanges returns all table changes marked as unsafe across all
// namespaces. DROP table changes are treated as unsafe even when an engine omits
// IsUnsafe, so destructive table deletion fails closed.
// UnsafeChanges returns all changes marked as unsafe across all namespaces:
// unsafe table changes and VSchema removals. DROP table changes are treated as
// unsafe even when an engine omits IsUnsafe, so destructive table deletion
// fails closed.
func (r *PlanResponse) UnsafeChanges() []UnsafeChange {
if r == nil {
return nil
Expand All @@ -493,6 +494,7 @@ func (r *PlanResponse) UnsafeChanges() []UnsafeChange {
result = append(result, unsafeChange)
}
}
result = append(result, sc.VSchemaUnsafeChanges()...)
}
return result
}
Expand Down
79 changes: 78 additions & 1 deletion pkg/apitypes/vschema.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package apitypes

import "encoding/json"
import (
"encoding/json"
"fmt"
)

// VSchemaChangeType is the ChangeType recorded on unsafe changes that stem
// from a VSchema removal rather than table DDL.
const VSchemaChangeType = "vschema"

// VSchemaChangesMetadataKey is the progress display-metadata key under which the
// engine projects per-keyspace VSchema application state as a JSON-encoded
Expand All @@ -16,6 +23,76 @@ const (
VSchemaChangedMetadataKey = "vschema_changed"
)

// VSchemaDeletionsMetadataKey is the plan change-metadata key under which
// engines record structural removals in a namespace's VSchema change as a
// JSON-encoded []VSchemaDeletion. A removal changes Vitess query routing the
// moment the VSchema is applied — a deleted vindex stops being used for
// routing and lookups, and queries that depended on it can fail — so any
// recorded deletion makes the plan's VSchema change an unsafe change requiring
// the same operator opt-in as destructive DDL.
const VSchemaDeletionsMetadataKey = "vschema_deletions"

// VSchemaDeletion is one structural removal in a namespace's VSchema change.
// It mirrors the engine-side deletion type (pkg/vschema); apitypes keeps its
// own copy so this package stays dependency-free.
type VSchemaDeletion struct {
Kind string `json:"kind"` // "vindex", "table", or "column_vindex"
Name string `json:"name"` // vindex name, table name, or "table.vindex"
Reason string `json:"reason"` // operator-facing explanation of the risk
}

// EncodeVSchemaDeletions marshals VSchema deletions for plan change metadata.
// Returns "" for an empty list so the metadata key is omitted.
func EncodeVSchemaDeletions(deletions []VSchemaDeletion) (string, error) {
if len(deletions) == 0 {
return "", nil
}
b, err := json.Marshal(deletions)
if err != nil {
return "", fmt.Errorf("encode %d VSchema deletions: %w", len(deletions), err)
}
return string(b), nil
}

// ParseVSchemaDeletions decodes the VSchema deletions recorded in a
// namespace's plan change metadata. Returns nil when the change carries none.
func ParseVSchemaDeletions(metadata map[string]string) ([]VSchemaDeletion, error) {
raw := metadata[VSchemaDeletionsMetadataKey]
if raw == "" {
return nil, nil
}
var deletions []VSchemaDeletion
if err := json.Unmarshal([]byte(raw), &deletions); err != nil {
return nil, fmt.Errorf("decode VSchema deletions metadata: %w", err)
}
return deletions, nil
}

// VSchemaUnsafeChanges returns the unsafe-change view of this namespace's
// recorded VSchema deletions. Metadata that cannot be decoded fails closed:
// the namespace reports a single unsafe change explaining that deletions are
// present but unreadable, so a corrupt record can never bypass the opt-in
// gate.
func (sc *SchemaChangeResponse) VSchemaUnsafeChanges() []UnsafeChange {
deletions, err := ParseVSchemaDeletions(sc.Metadata)
if err != nil {
return []UnsafeChange{{
Table: sc.Namespace + "/vschema.json",
Reason: "VSchema deletions were recorded on this plan but could not be decoded, so the VSchema change is treated as unsafe",
ChangeType: VSchemaChangeType,
}}
}
result := make([]UnsafeChange, 0, len(deletions))
for _, d := range deletions {
result = append(result, UnsafeChange{
Table: sc.Namespace + "/vschema.json",
Reason: d.Reason,
ChangeType: VSchemaChangeType,
})
}
return result
}

// HasVSchemaChange reports whether this namespace's change carries VSchema work.
func (sc *SchemaChangeResponse) HasVSchemaChange() bool {
return sc.Metadata[VSchemaDiffMetadataKey] != "" || sc.Metadata[VSchemaChangedMetadataKey] == "true"
Expand Down
73 changes: 73 additions & 0 deletions pkg/apitypes/vschema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package apitypes

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// A VSchema change that removes a vindex, table routing entry, or
// column-vindex association gates the apply behind the unsafe opt-in, the
// same as destructive DDL: the removal changes Vitess query routing the
// moment it lands.
func TestPlanResponse_UnsafeChangesIncludesVSchemaDeletions(t *testing.T) {
meta, err := EncodeVSchemaDeletions([]VSchemaDeletion{
{Kind: "vindex", Name: "email_lookup", Reason: `vindex "email_lookup" is removed: Vitess immediately stops using it`},
{Kind: "column_vindex", Name: "users.email_lookup", Reason: `table "users" no longer uses vindex "email_lookup"`},
})
require.NoError(t, err)

resp := &PlanResponse{
Changes: []*SchemaChangeResponse{{
Namespace: "commerce",
Metadata: map[string]string{
VSchemaChangedMetadataKey: "true",
VSchemaDeletionsMetadataKey: meta,
},
}},
}

changes := resp.UnsafeChanges()
require.Len(t, changes, 2)
assert.Equal(t, "commerce/vschema.json", changes[0].Table)
assert.Equal(t, VSchemaChangeType, changes[0].ChangeType)
assert.Contains(t, changes[0].Reason, "email_lookup")
assert.Contains(t, changes[1].Reason, `table "users"`)
}

func TestPlanResponse_UnsafeChangesVSchemaAdditionsOnlyStaysSafe(t *testing.T) {
resp := &PlanResponse{
Changes: []*SchemaChangeResponse{{
Namespace: "commerce",
Metadata: map[string]string{
VSchemaChangedMetadataKey: "true",
VSchemaDiffMetadataKey: "+ added lines only",
},
}},
}
assert.Empty(t, resp.UnsafeChanges())
}

func TestPlanResponse_UnsafeChangesUndecodableVSchemaDeletionsFailClosed(t *testing.T) {
resp := &PlanResponse{
Changes: []*SchemaChangeResponse{{
Namespace: "commerce",
Metadata: map[string]string{
VSchemaChangedMetadataKey: "true",
VSchemaDeletionsMetadataKey: "{corrupt",
},
}},
}

changes := resp.UnsafeChanges()
require.Len(t, changes, 1)
assert.Equal(t, "commerce/vschema.json", changes[0].Table)
assert.Contains(t, changes[0].Reason, "could not be decoded")
}

func TestEncodeVSchemaDeletions_EmptyOmitsKey(t *testing.T) {
meta, err := EncodeVSchemaDeletions(nil)
require.NoError(t, err)
assert.Empty(t, meta)
}
4 changes: 2 additions & 2 deletions pkg/cmd/commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func (cmd *ApplyCmd) Run(g *Globals) error {
}

// Check for unsafe changes
if planResult.HasErrors() && !cmd.AllowUnsafe {
if len(planResult.UnsafeChanges()) > 0 && !cmd.AllowUnsafe {
return blockUnsafeApply(planResult, cfg.Database, cmd.Environment, cfg.SchemaDir)
}

Expand Down Expand Up @@ -173,7 +173,7 @@ func (cmd *ApplyCmd) Run(g *Globals) error {
OutputPlanResult(planResult, cfg.Database, cmd.Environment, cfg.SchemaDir, true)

// Show unsafe warning if --allow-unsafe was used
if planResult.HasErrors() && cmd.AllowUnsafe {
if cmd.AllowUnsafe {
templates.WriteUnsafeWarningAllowed(planResult.UnsafeChanges())
}

Expand Down
8 changes: 3 additions & 5 deletions pkg/cmd/commands/rollback.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,9 @@ func (cmd *RollbackCmd) Run(g *Globals) error {
}
}

// Show unsafe warning if any
if planResult.HasErrors() {
unsafeChanges := planResult.UnsafeChanges()
templates.WriteUnsafeWarningAllowed(unsafeChanges)
}
// Disclose unsafe changes before the confirmation prompt. Rollback has no
// --allow-unsafe flag; the interactive confirmation is the acknowledgment.
templates.WriteUnsafeChangesWarning(planResult.UnsafeChanges())

// Show options if any flags are set
templates.WriteOptions(cmd.DeferCutover, false)
Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/internal/templates/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ const (
PreviewCommentMultiEnvDiff PreviewType = "comment_multi_env_diff" // Multi-env plan (different per env)
PreviewCommentMultiEnvLint PreviewType = "comment_multi_env_lint" // Multi-env plan with lint violations
PreviewCommentVitessPlan PreviewType = "comment_vitess_plan" // Vitess plan with keyspaces + VSchema
PreviewCommentVitessPlanVSchemaRemoval PreviewType = "comment_vitess_plan_vschema_removal" // Vitess plan with unsafe VSchema removals
PreviewCommentVitessApplyPlan PreviewType = "comment_vitess_apply_plan" // Locked Vitess apply-plan with options
PreviewCommentMySQLMultiSchema PreviewType = "comment_mysql_multi_schema" // MySQL plan with multiple schema names
PreviewCommentHelp PreviewType = "comment_help" // Help command reference comment
Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/internal/templates/preview_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ func previewCommentPlanAllOutput() {
{"RECONCILIATION REQUIRED (IN PROGRESS)", func() { fmt.Print(webhooktemplates.PreviewCommentSchemaReconciliationInProgress()) }},
{"RECONCILIATION REQUIRED (COMPLETED)", func() { fmt.Print(webhooktemplates.PreviewCommentSchemaReconciliationCompleted()) }},
{"VITESS PLAN", func() { fmt.Print(webhooktemplates.PreviewCommentVitessPlan()) }},
{"VITESS PLAN: VSCHEMA REMOVAL (UNSAFE)", func() { fmt.Print(webhooktemplates.PreviewCommentVitessPlanVSchemaRemoval()) }},
{"SCHEMA CHANGE APPLY (LOCKED + OPTIONS)", func() { fmt.Print(webhooktemplates.PreviewCommentVitessApplyPlan()) }},
{"MYSQL MULTI-SCHEMA PLAN", func() { fmt.Print(webhooktemplates.PreviewCommentMySQLMultiSchema()) }},
{"MULTI-ENV PLAN (IDENTICAL)", func() { fmt.Print(webhooktemplates.PreviewCommentMultiEnvPlan()) }},
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/internal/templates/preview_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ func PreviewCLIOutput(previewType PreviewType) {
fmt.Print(webhooktemplates.PreviewCommentMultiEnvPlanLint())
case PreviewCommentVitessPlan:
fmt.Print(webhooktemplates.PreviewCommentVitessPlan())
case PreviewCommentVitessPlanVSchemaRemoval:
fmt.Print(webhooktemplates.PreviewCommentVitessPlanVSchemaRemoval())
case PreviewCommentVitessApplyPlan:
fmt.Print(webhooktemplates.PreviewCommentVitessApplyPlan())
case PreviewCommentMySQLMultiSchema:
Expand Down
Loading
Loading