fix: enforce no foreign keys in new migrations

This commit is contained in:
2026-09-20 01:51:07 +08:00
parent cb93c76b03
commit b2d64e0e99
2 changed files with 33 additions and 0 deletions

View File

@@ -176,6 +176,9 @@ func Migrate(ctx context.Context, db *sql.DB) error {
if err != nil {
return fmt.Errorf("migration %d: %w", m.Version, err)
}
if err := validateNoReferenceDDL(m.Version, statements); err != nil {
return err
}
if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", m.Version, m.Name, m.Checksum); err != nil {
return err
}
@@ -191,6 +194,24 @@ func Migrate(ctx context.Context, db *sql.DB) error {
return nil
}
// validateNoReferenceDDL protects the post-removal migration line at runtime.
// Versions 1-8 are immutable compatibility history: version 8 removes the
// legacy constraints from databases that were created before the no-FK policy.
// Every later migration must be incapable of creating or retaining a database
// reference constraint, even if a future author forgets the static test.
func validateNoReferenceDDL(version uint64, statements []string) error {
if version <= 8 {
return nil
}
for _, statement := range statements {
upper := strings.ToUpper(statement)
if strings.Contains(upper, "FOREIGN KEY") || strings.Contains(upper, " REFERENCES ") {
return fmt.Errorf("migration %d contains forbidden foreign-key DDL", version)
}
}
return nil
}
// applyFreshInstallBaseline creates a new database directly at the current
// schema version. It records every historical migration as dirty before DDL so
// an interrupted baseline remains blocked for operator reconciliation, just

View File

@@ -51,3 +51,15 @@ func TestFreshInstallBaselineContainsNoReferenceDDL(t *testing.T) {
t.Fatalf("baseline version=%d, migrations=%d", freshInstallBaselineVersion, len(migrations))
}
}
func TestPostRemovalMigrationGuard(t *testing.T) {
if err := validateNoReferenceDDL(9, []string{"ALTER TABLE pulse_tasks ADD CONSTRAINT fk FOREIGN KEY (queue_id) REFERENCES pulse_queues(id)"}); err == nil {
t.Fatal("post-removal migration accepted foreign-key DDL")
}
if err := validateNoReferenceDDL(9, []string{"ALTER TABLE pulse_tasks ADD INDEX ix_queue (queue_id)"}); err != nil {
t.Fatalf("valid post-removal migration rejected: %v", err)
}
if err := validateNoReferenceDDL(8, []string{"ALTER TABLE pulse_tasks DROP FOREIGN KEY legacy_fk"}); err != nil {
t.Fatalf("immutable removal migration rejected: %v", err)
}
}