refactor(schema): collapse V1..V18 into single V1__init.sql baseline
The project is still greenfield (no production deployment) so this is the last safe moment to flatten the migration archaeology before the checksum history starts mattering for real. Schema changes - 18 migration files (531 lines) → one V1__init.sql (~380 lines) declaring the final end-state: RBAC + claim mappings + runtime management + config + audit + outbound + alerting, plus seed data (system roles, Admins group, default environment). - Drops the data-repair statements from V14 (firemode backfill), V16 (subjectFingerprint migration), V17 (ACKNOWLEDGED → FIRING coercion) — they were no-ops on any DB that starts at V1. - Declares condition_kind_enum with AGENT_LIFECYCLE from the start (was added retroactively by V18). - Declares alert_state_enum with three values only (was five, then swapped in V17) and alert_instances with read_at / deleted_at columns from day one (was added by V17). - alert_reads table never created (V12 created, V17 dropped). - alert_instances_open_rule_uq built with the V17 predicate from the start. Test changes - Replace V12MigrationIT / V17MigrationIT / V18MigrationIT with one SchemaBootstrapIT that asserts the combined invariants: tables present, alert_reads absent, enum value sets, alert_instances has read_at + deleted_at, open_rule_uq exists and is unique, env-delete cascade fires. Verification - pg_dump of the new V1 matches the pg_dump of V1..V18 applied in sequence (bytewise modulo column order and Postgres-auto FK names). - Full alerting IT suite (53 tests across 6 classes) green against the new schema. - The 47 pre-existing test failures on main (AgentRegistrationIT, SearchControllerIT, ClickHouseStatsStoreIT, …) are unrelated and fail identically without this change. Developer impact - Existing local DBs will fail checksum validation on boot. Wipe: docker compose down -v (or drop the tenant_default schema). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.search.ClickHouseLogStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Invariants of the consolidated V1 bootstrap schema. Replaces the per-migration
|
||||
* ITs (V12/V17/V18) that existed while the schema evolved across 18 files.
|
||||
*/
|
||||
class SchemaBootstrapIT extends AbstractPostgresIT {
|
||||
|
||||
@MockBean(name = "clickHouseLogStore")
|
||||
ClickHouseLogStore clickHouseLogStore;
|
||||
|
||||
private UUID testEnvId;
|
||||
private String testUserId;
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
if (testEnvId != null) jdbcTemplate.update("DELETE FROM environments WHERE id = ?", testEnvId);
|
||||
if (testUserId != null) jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", testUserId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void all_alerting_tables_exist() {
|
||||
var tables = jdbcTemplate.queryForList("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('alert_rules','alert_rule_targets','alert_instances',
|
||||
'alert_silences','alert_notifications')
|
||||
""", String.class);
|
||||
assertThat(tables).containsExactlyInAnyOrder(
|
||||
"alert_rules", "alert_rule_targets", "alert_instances",
|
||||
"alert_silences", "alert_notifications");
|
||||
}
|
||||
|
||||
@Test
|
||||
void alert_reads_table_absent() {
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)::int FROM information_schema.tables
|
||||
WHERE table_name = 'alert_reads'
|
||||
""", Integer.class);
|
||||
assertThat(count).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void alerting_enums_exist() {
|
||||
var enums = jdbcTemplate.queryForList("""
|
||||
SELECT typname FROM pg_type
|
||||
WHERE typname IN ('severity_enum','condition_kind_enum','alert_state_enum',
|
||||
'target_kind_enum','notification_status_enum')
|
||||
""", String.class);
|
||||
assertThat(enums).containsExactlyInAnyOrder(
|
||||
"severity_enum", "condition_kind_enum", "alert_state_enum",
|
||||
"target_kind_enum", "notification_status_enum");
|
||||
}
|
||||
|
||||
@Test
|
||||
void alert_state_enum_values() {
|
||||
var values = jdbcTemplate.queryForList("""
|
||||
SELECT unnest(enum_range(NULL::alert_state_enum))::text
|
||||
""", String.class);
|
||||
assertThat(values).containsExactlyInAnyOrder("PENDING", "FIRING", "RESOLVED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void condition_kind_enum_values() {
|
||||
var values = jdbcTemplate.queryForList("""
|
||||
SELECT unnest(enum_range(NULL::condition_kind_enum))::text
|
||||
""", String.class);
|
||||
assertThat(values).containsExactlyInAnyOrder(
|
||||
"ROUTE_METRIC", "EXCHANGE_MATCH", "AGENT_STATE", "AGENT_LIFECYCLE",
|
||||
"DEPLOYMENT_STATE", "LOG_PATTERN", "JVM_METRIC");
|
||||
}
|
||||
|
||||
@Test
|
||||
void alert_instances_has_read_at_and_deleted_at() {
|
||||
var cols = jdbcTemplate.queryForList("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'alert_instances'
|
||||
AND column_name IN ('read_at','deleted_at')
|
||||
""", String.class);
|
||||
assertThat(cols).containsExactlyInAnyOrder("read_at", "deleted_at");
|
||||
}
|
||||
|
||||
@Test
|
||||
void open_rule_unique_index_exists() {
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)::int FROM pg_indexes
|
||||
WHERE indexname = 'alert_instances_open_rule_uq'
|
||||
AND tablename = 'alert_instances'
|
||||
""", Integer.class);
|
||||
assertThat(count).isEqualTo(1);
|
||||
|
||||
Boolean isUnique = jdbcTemplate.queryForObject("""
|
||||
SELECT indisunique FROM pg_index
|
||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||
WHERE pg_class.relname = 'alert_instances_open_rule_uq'
|
||||
""", Boolean.class);
|
||||
assertThat(isUnique).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleting_environment_cascades_alerting_rows() {
|
||||
testEnvId = UUID.randomUUID();
|
||||
testUserId = UUID.randomUUID().toString();
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
|
||||
testEnvId, "test-cascade-env-" + testEnvId, "Test Cascade Env");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email) VALUES (?, ?, ?)",
|
||||
testUserId, "local", "cascade@example.com");
|
||||
|
||||
var ruleId = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
|
||||
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
|
||||
"VALUES (?, ?, 'r', 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', ?, ?)",
|
||||
ruleId, testEnvId, testUserId, testUserId);
|
||||
|
||||
var instanceId = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_instances (id, rule_id, rule_snapshot, environment_id, state, severity, " +
|
||||
"fired_at, context, title, message) VALUES (?, ?, '{}'::jsonb, ?, 'FIRING', 'WARNING', " +
|
||||
"now(), '{}'::jsonb, 't', 'm')",
|
||||
instanceId, ruleId, testEnvId);
|
||||
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", testEnvId);
|
||||
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_rules WHERE environment_id = ?",
|
||||
Integer.class, testEnvId)).isZero();
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_instances WHERE environment_id = ?",
|
||||
Integer.class, testEnvId)).isZero();
|
||||
|
||||
testEnvId = null; // already deleted; skip @AfterEach
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.search.ClickHouseLogStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class V12MigrationIT extends AbstractPostgresIT {
|
||||
|
||||
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
|
||||
|
||||
private java.util.UUID testEnvId;
|
||||
private String testUserId;
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
if (testEnvId != null) jdbcTemplate.update("DELETE FROM environments WHERE id = ?", testEnvId);
|
||||
if (testUserId != null) jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", testUserId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void allAlertingTablesAndEnumsExist() {
|
||||
// Note: alert_reads was created in V12 but dropped by V17 (superseded by read_at column).
|
||||
var tables = jdbcTemplate.queryForList(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema='public' " +
|
||||
"AND table_name IN ('alert_rules','alert_rule_targets','alert_instances'," +
|
||||
"'alert_silences','alert_notifications')",
|
||||
String.class);
|
||||
assertThat(tables).containsExactlyInAnyOrder(
|
||||
"alert_rules","alert_rule_targets","alert_instances",
|
||||
"alert_silences","alert_notifications");
|
||||
|
||||
var enums = jdbcTemplate.queryForList(
|
||||
"SELECT typname FROM pg_type WHERE typname IN " +
|
||||
"('severity_enum','condition_kind_enum','alert_state_enum'," +
|
||||
"'target_kind_enum','notification_status_enum')",
|
||||
String.class);
|
||||
assertThat(enums).containsExactlyInAnyOrder(
|
||||
"severity_enum", "condition_kind_enum", "alert_state_enum",
|
||||
"target_kind_enum", "notification_status_enum");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletingEnvironmentCascadesAlertingRows() {
|
||||
testEnvId = java.util.UUID.randomUUID();
|
||||
testUserId = java.util.UUID.randomUUID().toString();
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
|
||||
testEnvId, "test-cascade-env-" + testEnvId, "Test Cascade Env");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email) VALUES (?, ?, ?)",
|
||||
testUserId, "local", "test@example.com");
|
||||
|
||||
var ruleId = java.util.UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
|
||||
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
|
||||
"VALUES (?, ?, 'r', 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', ?, ?)",
|
||||
ruleId, testEnvId, testUserId, testUserId);
|
||||
|
||||
var instanceId = java.util.UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_instances (id, rule_id, rule_snapshot, environment_id, state, severity, " +
|
||||
"fired_at, context, title, message) VALUES (?, ?, '{}'::jsonb, ?, 'FIRING', 'WARNING', " +
|
||||
"now(), '{}'::jsonb, 't', 'm')",
|
||||
instanceId, ruleId, testEnvId);
|
||||
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", testEnvId);
|
||||
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_rules WHERE environment_id = ?",
|
||||
Integer.class, testEnvId)).isZero();
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT count(*) FROM alert_instances WHERE environment_id = ?",
|
||||
Integer.class, testEnvId)).isZero();
|
||||
|
||||
// testEnvId already deleted; null it so @AfterEach doesn't attempt a no-op delete
|
||||
testEnvId = null;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class V17MigrationIT extends AbstractPostgresIT {
|
||||
|
||||
@Test
|
||||
void alert_state_enum_drops_acknowledged() {
|
||||
var values = jdbcTemplate.queryForList("""
|
||||
SELECT unnest(enum_range(NULL::alert_state_enum))::text AS v
|
||||
""", String.class);
|
||||
assertThat(values).containsExactlyInAnyOrder("PENDING", "FIRING", "RESOLVED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void read_at_and_deleted_at_columns_exist() {
|
||||
var cols = jdbcTemplate.queryForList("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'alert_instances'
|
||||
AND column_name IN ('read_at','deleted_at')
|
||||
""", String.class);
|
||||
assertThat(cols).containsExactlyInAnyOrder("read_at", "deleted_at");
|
||||
}
|
||||
|
||||
@Test
|
||||
void alert_reads_table_is_gone() {
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)::int FROM information_schema.tables
|
||||
WHERE table_name = 'alert_reads'
|
||||
""", Integer.class);
|
||||
assertThat(count).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void open_rule_index_exists_and_is_unique() {
|
||||
// Structural check only — the pg_get_indexdef pretty-printer varies across
|
||||
// Postgres versions. Predicate semantics (ack doesn't close; soft-delete
|
||||
// frees the slot; RESOLVED excluded) are covered behaviorally by
|
||||
// PostgresAlertInstanceRepositoryIT#findOpenForRule_* and
|
||||
// #save_rejectsSecondOpenInstanceForSameRuleAndExchange.
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)::int FROM pg_indexes
|
||||
WHERE indexname = 'alert_instances_open_rule_uq'
|
||||
AND tablename = 'alert_instances'
|
||||
""", Integer.class);
|
||||
assertThat(count).isEqualTo(1);
|
||||
|
||||
Boolean isUnique = jdbcTemplate.queryForObject("""
|
||||
SELECT indisunique FROM pg_index
|
||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||
WHERE pg_class.relname = 'alert_instances_open_rule_uq'
|
||||
""", Boolean.class);
|
||||
assertThat(isUnique).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class V18MigrationIT extends AbstractPostgresIT {
|
||||
|
||||
@Test
|
||||
void condition_kind_enum_includes_agent_lifecycle() {
|
||||
var values = jdbcTemplate.queryForList("""
|
||||
SELECT unnest(enum_range(NULL::condition_kind_enum))::text AS v
|
||||
""", String.class);
|
||||
assertThat(values).contains("AGENT_LIFECYCLE");
|
||||
assertThat(values).containsExactlyInAnyOrder(
|
||||
"ROUTE_METRIC", "EXCHANGE_MATCH", "AGENT_STATE", "AGENT_LIFECYCLE",
|
||||
"DEPLOYMENT_STATE", "LOG_PATTERN", "JVM_METRIC");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user