diff --git a/server/src/db/migration/timescaledb/0001_create_enums.ts b/server/src/db/migration/timescaledb/0001_create_enums.ts new file mode 100644 index 000000000..a16ec955b --- /dev/null +++ b/server/src/db/migration/timescaledb/0001_create_enums.ts @@ -0,0 +1,50 @@ +import type { Pool } from "pg"; + +export const createEnums = async (pool: Pool) => { + await pool.query(` + DO $$ BEGIN + CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); +}; + +export const dropEnums = async (pool: Pool) => { + await pool.query(` + DROP TYPE IF EXISTS match_method; + DROP TYPE IF EXISTS status_page_type; + DROP TYPE IF EXISTS duration_unit; + DROP TYPE IF EXISTS incident_resolution_type; + DROP TYPE IF EXISTS user_role; + DROP TYPE IF EXISTS notification_type; + DROP TYPE IF EXISTS monitor_status; + DROP TYPE IF EXISTS monitor_type; + `); +}; diff --git a/server/src/db/migration/timescaledb/0002_create_teams.ts b/server/src/db/migration/timescaledb/0002_create_teams.ts new file mode 100644 index 000000000..905883eb2 --- /dev/null +++ b/server/src/db/migration/timescaledb/0002_create_teams.ts @@ -0,0 +1,18 @@ +import type { Pool } from "pg"; + +export const createTeams = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email); + `); +}; + +export const dropTeams = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS teams;`); +}; diff --git a/server/src/db/migration/timescaledb/0003_create_users.ts b/server/src/db/migration/timescaledb/0003_create_users.ts new file mode 100644 index 000000000..6c8f57027 --- /dev/null +++ b/server/src/db/migration/timescaledb/0003_create_users.ts @@ -0,0 +1,30 @@ +import type { Pool } from "pg"; + +export const createUsers = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID REFERENCES teams(id) ON DELETE SET NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + avatar_image TEXT, + profile_image BYTEA, + profile_image_content_type TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[], + check_ttl INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id); + CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); + `); +}; + +export const dropUsers = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS users;`); +}; diff --git a/server/src/db/migration/timescaledb/0004_create_monitors.ts b/server/src/db/migration/timescaledb/0004_create_monitors.ts new file mode 100644 index 000000000..a4829c7f5 --- /dev/null +++ b/server/src/db/migration/timescaledb/0004_create_monitors.ts @@ -0,0 +1,69 @@ +import type { Pool } from "pg"; + +export const createMonitors = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS monitors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + team_id UUID NOT NULL REFERENCES teams(id), + name TEXT NOT NULL, + description TEXT, + type monitor_type NOT NULL, + status monitor_status NOT NULL DEFAULT 'initializing', + + -- HTTP / Website monitoring + url TEXT, + port INTEGER, + ignore_tls_errors BOOLEAN DEFAULT FALSE, + use_advanced_matching BOOLEAN DEFAULT FALSE, + json_path TEXT, + expected_value TEXT, + match_method match_method, + secret TEXT, + + -- Scheduling + interval_ms INTEGER NOT NULL DEFAULT 60000, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Uptime tracking + status_window BOOLEAN[], + status_window_size INTEGER DEFAULT 5, + status_window_threshold INTEGER DEFAULT 60, + uptime_percentage DOUBLE PRECISION, + + -- Infrastructure alert thresholds + cpu_alert_threshold INTEGER, + cpu_alert_counter INTEGER, + memory_alert_threshold INTEGER, + memory_alert_counter INTEGER, + disk_alert_threshold INTEGER, + disk_alert_counter INTEGER, + temp_alert_threshold INTEGER, + temp_alert_counter INTEGER, + selected_disks TEXT[], + + -- Game monitoring + game_id TEXT, + grpc_service_name TEXT, + + -- Grouping + monitor_group TEXT, + + -- Geo checks + geo_check_enabled BOOLEAN DEFAULT FALSE, + geo_check_locations TEXT[], + geo_check_interval_ms INTEGER DEFAULT 300000, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type); + CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id); + CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status); + `); +}; + +export const dropMonitors = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS monitors;`); +}; diff --git a/server/src/db/migration/timescaledb/0005_create_notifications.ts b/server/src/db/migration/timescaledb/0005_create_notifications.ts new file mode 100644 index 000000000..2a4a065fa --- /dev/null +++ b/server/src/db/migration/timescaledb/0005_create_notifications.ts @@ -0,0 +1,30 @@ +import type { Pool } from "pg"; + +export const createNotifications = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + team_id UUID NOT NULL REFERENCES teams(id), + type notification_type NOT NULL, + notification_name TEXT, + address TEXT, + phone TEXT, + + -- Matrix-specific fields + homeserver_url TEXT, + room_id TEXT, + access_token TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id); + CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id); + `); +}; + +export const dropNotifications = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS notifications;`); +}; diff --git a/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts b/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts new file mode 100644 index 000000000..d2a306322 --- /dev/null +++ b/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts @@ -0,0 +1,18 @@ +import type { Pool } from "pg"; + +export const createMonitorNotifications = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS monitor_notifications ( + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE, + PRIMARY KEY (monitor_id, notification_id) + ); + + CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification + ON monitor_notifications (notification_id); + `); +}; + +export const dropMonitorNotifications = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`); +}; diff --git a/server/src/db/migration/timescaledb/0007_create_incidents.ts b/server/src/db/migration/timescaledb/0007_create_incidents.ts new file mode 100644 index 000000000..e1c990f95 --- /dev/null +++ b/server/src/db/migration/timescaledb/0007_create_incidents.ts @@ -0,0 +1,34 @@ +import type { Pool } from "pg"; + +export const createIncidents = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS incidents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ, + status BOOLEAN, + message TEXT, + status_code INTEGER, + resolution_type incident_resolution_type, + resolved_by UUID REFERENCES users(id) ON DELETE SET NULL, + resolved_by_email TEXT, + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status); + CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status); + CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC); + CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC); + CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status); + CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status); + CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC); + `); +}; + +export const dropIncidents = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS incidents;`); +}; diff --git a/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts b/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts new file mode 100644 index 000000000..f08004873 --- /dev/null +++ b/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts @@ -0,0 +1,25 @@ +import type { Pool } from "pg"; + +export const createMonitorStats = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS monitor_stats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE, + avg_response_time DOUBLE PRECISION DEFAULT 0, + max_response_time DOUBLE PRECISION DEFAULT 0, + total_checks BIGINT DEFAULT 0, + total_up_checks BIGINT DEFAULT 0, + total_down_checks BIGINT DEFAULT 0, + uptime_percentage DOUBLE PRECISION DEFAULT 0, + last_check_timestamp TIMESTAMPTZ, + last_response_time DOUBLE PRECISION DEFAULT 0, + time_of_last_failure TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); +}; + +export const dropMonitorStats = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS monitor_stats;`); +}; diff --git a/server/src/db/migration/timescaledb/0009_create_checks.ts b/server/src/db/migration/timescaledb/0009_create_checks.ts new file mode 100644 index 000000000..27753f524 --- /dev/null +++ b/server/src/db/migration/timescaledb/0009_create_checks.ts @@ -0,0 +1,105 @@ +import type { Pool } from "pg"; + +export const createChecks = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS checks ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL, + team_id UUID NOT NULL, + monitor_type monitor_type NOT NULL, + status BOOLEAN, + response_time INTEGER, + status_code INTEGER, + message TEXT, + + -- HTTP request timings + timing_start DOUBLE PRECISION, + timing_socket DOUBLE PRECISION, + timing_lookup DOUBLE PRECISION, + timing_connect DOUBLE PRECISION, + timing_secure_connect DOUBLE PRECISION, + timing_upload DOUBLE PRECISION, + timing_response DOUBLE PRECISION, + timing_end DOUBLE PRECISION, + phase_wait DOUBLE PRECISION, + phase_dns DOUBLE PRECISION, + phase_tcp DOUBLE PRECISION, + phase_tls DOUBLE PRECISION, + phase_request DOUBLE PRECISION, + phase_first_byte DOUBLE PRECISION, + phase_download DOUBLE PRECISION, + phase_total DOUBLE PRECISION, + + -- CPU metrics + cpu_physical_core INTEGER, + cpu_logical_core INTEGER, + cpu_frequency DOUBLE PRECISION, + cpu_current_frequency DOUBLE PRECISION, + cpu_temperature DOUBLE PRECISION[], + cpu_free_percent DOUBLE PRECISION, + cpu_usage_percent DOUBLE PRECISION, + + -- Memory metrics + mem_total_bytes BIGINT, + mem_available_bytes BIGINT, + mem_used_bytes BIGINT, + mem_usage_percent DOUBLE PRECISION, + + -- Host info + host_os TEXT, + host_platform TEXT, + host_kernel_version TEXT, + host_pretty_name TEXT, + + -- Capture agent info + capture_version TEXT, + capture_mode TEXT, + + -- Lighthouse / PageSpeed scores + lighthouse_performance DOUBLE PRECISION, + lighthouse_accessibility DOUBLE PRECISION, + lighthouse_best_practices DOUBLE PRECISION, + lighthouse_seo DOUBLE PRECISION, + + -- Lighthouse audit details + audit_cls_score DOUBLE PRECISION, + audit_cls_value DOUBLE PRECISION, + audit_cls_display TEXT, + audit_si_score DOUBLE PRECISION, + audit_si_value DOUBLE PRECISION, + audit_si_display TEXT, + audit_fcp_score DOUBLE PRECISION, + audit_fcp_value DOUBLE PRECISION, + audit_fcp_display TEXT, + audit_lcp_score DOUBLE PRECISION, + audit_lcp_value DOUBLE PRECISION, + audit_lcp_display TEXT, + audit_tbt_score DOUBLE PRECISION, + audit_tbt_value DOUBLE PRECISION, + audit_tbt_display TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + SELECT create_hypertable('checks', 'created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_checks_monitor_time + ON checks (monitor_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_checks_team_time + ON checks (team_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time + ON checks (monitor_id, monitor_type, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_checks_team_status_time + ON checks (team_id, status, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_checks_status_code + ON checks (status_code, created_at DESC); + `); +}; + +export const dropChecks = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS checks;`); +}; diff --git a/server/src/db/migration/timescaledb/0010_create_check_disks.ts b/server/src/db/migration/timescaledb/0010_create_check_disks.ts new file mode 100644 index 000000000..e77784d39 --- /dev/null +++ b/server/src/db/migration/timescaledb/0010_create_check_disks.ts @@ -0,0 +1,37 @@ +import type { Pool } from "pg"; + +export const createCheckDisks = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS check_disks ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + check_id UUID NOT NULL, + check_created_at TIMESTAMPTZ NOT NULL, + device TEXT, + mountpoint TEXT, + total_bytes BIGINT, + free_bytes BIGINT, + used_bytes BIGINT, + usage_percent DOUBLE PRECISION, + total_inodes BIGINT, + free_inodes BIGINT, + used_inodes BIGINT, + inodes_usage_percent DOUBLE PRECISION, + read_bytes BIGINT, + write_bytes BIGINT, + read_time BIGINT, + write_time BIGINT + ); + + SELECT create_hypertable('check_disks', 'check_created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_check_disks_check + ON check_disks (check_id, check_created_at); + `); +}; + +export const dropCheckDisks = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS check_disks;`); +}; diff --git a/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts b/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts new file mode 100644 index 000000000..4988fe58e --- /dev/null +++ b/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts @@ -0,0 +1,34 @@ +import type { Pool } from "pg"; + +export const createCheckNetworkInterfaces = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS check_network_interfaces ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + check_id UUID NOT NULL, + check_created_at TIMESTAMPTZ NOT NULL, + name TEXT, + bytes_sent BIGINT, + bytes_recv BIGINT, + packets_sent BIGINT, + packets_recv BIGINT, + err_in BIGINT, + err_out BIGINT, + drop_in BIGINT, + drop_out BIGINT, + fifo_in BIGINT, + fifo_out BIGINT + ); + + SELECT create_hypertable('check_network_interfaces', 'check_created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_check_net_check + ON check_network_interfaces (check_id, check_created_at); + `); +}; + +export const dropCheckNetworkInterfaces = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`); +}; diff --git a/server/src/db/migration/timescaledb/0012_create_check_errors.ts b/server/src/db/migration/timescaledb/0012_create_check_errors.ts new file mode 100644 index 000000000..c84bacac8 --- /dev/null +++ b/server/src/db/migration/timescaledb/0012_create_check_errors.ts @@ -0,0 +1,25 @@ +import type { Pool } from "pg"; + +export const createCheckErrors = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS check_errors ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + check_id UUID NOT NULL, + check_created_at TIMESTAMPTZ NOT NULL, + metrics TEXT[], + error TEXT + ); + + SELECT create_hypertable('check_errors', 'check_created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_check_errors_check + ON check_errors (check_id, check_created_at); + `); +}; + +export const dropCheckErrors = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS check_errors;`); +}; diff --git a/server/src/db/migration/timescaledb/0013_create_geo_checks.ts b/server/src/db/migration/timescaledb/0013_create_geo_checks.ts new file mode 100644 index 000000000..a6ee03061 --- /dev/null +++ b/server/src/db/migration/timescaledb/0013_create_geo_checks.ts @@ -0,0 +1,29 @@ +import type { Pool } from "pg"; + +export const createGeoChecks = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS geo_checks ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL, + team_id UUID NOT NULL, + monitor_type monitor_type NOT NULL, + expiry TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + SELECT create_hypertable('geo_checks', 'created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time + ON geo_checks (monitor_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time + ON geo_checks (team_id, created_at DESC); + `); +}; + +export const dropGeoChecks = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS geo_checks;`); +}; diff --git a/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts b/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts new file mode 100644 index 000000000..4e0e45e1a --- /dev/null +++ b/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts @@ -0,0 +1,46 @@ +import type { Pool } from "pg"; + +export const createGeoCheckResults = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS geo_check_results ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + geo_check_id UUID NOT NULL, + geo_check_created_at TIMESTAMPTZ NOT NULL, + + -- Status + status BOOLEAN, + status_code INTEGER, + + -- Location + location_continent TEXT, + location_region TEXT, + location_country TEXT, + location_state TEXT, + location_city TEXT, + location_longitude DOUBLE PRECISION, + location_latitude DOUBLE PRECISION, + + -- Timings + timing_total DOUBLE PRECISION, + timing_dns DOUBLE PRECISION, + timing_tcp DOUBLE PRECISION, + timing_tls DOUBLE PRECISION, + timing_first_byte DOUBLE PRECISION, + timing_download DOUBLE PRECISION + ); + + SELECT create_hypertable('geo_check_results', 'geo_check_created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_geo_results_check + ON geo_check_results (geo_check_id, geo_check_created_at); + CREATE INDEX IF NOT EXISTS idx_geo_results_country + ON geo_check_results (location_country, geo_check_created_at DESC); + `); +}; + +export const dropGeoCheckResults = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS geo_check_results;`); +}; diff --git a/server/src/db/migration/timescaledb/0015_create_invites.ts b/server/src/db/migration/timescaledb/0015_create_invites.ts new file mode 100644 index 000000000..80aca5255 --- /dev/null +++ b/server/src/db/migration/timescaledb/0015_create_invites.ts @@ -0,0 +1,22 @@ +import type { Pool } from "pg"; + +export const createInvites = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[], + token TEXT NOT NULL, + expiry TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry); + `); +}; + +export const dropInvites = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS invites;`); +}; diff --git a/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts b/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts new file mode 100644 index 000000000..36a9d0de3 --- /dev/null +++ b/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts @@ -0,0 +1,20 @@ +import type { Pool } from "pg"; + +export const createRecoveryTokens = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS recovery_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + token TEXT NOT NULL, + expiry TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry); + `); +}; + +export const dropRecoveryTokens = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`); +}; diff --git a/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts b/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts new file mode 100644 index 000000000..190769261 --- /dev/null +++ b/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts @@ -0,0 +1,29 @@ +import type { Pool } from "pg"; + +export const createMaintenanceWindows = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS maintenance_windows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + active BOOLEAN NOT NULL DEFAULT TRUE, + name TEXT, + duration INTEGER, + duration_unit duration_unit, + repeat INTEGER, + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + expiry TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id); + CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id); + CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry); + `); +}; + +export const dropMaintenanceWindows = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`); +}; diff --git a/server/src/db/migration/timescaledb/0018_create_status_pages.ts b/server/src/db/migration/timescaledb/0018_create_status_pages.ts new file mode 100644 index 000000000..6037f6852 --- /dev/null +++ b/server/src/db/migration/timescaledb/0018_create_status_pages.ts @@ -0,0 +1,51 @@ +import type { Pool } from "pg"; + +export const createStatusPages = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS status_pages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + team_id UUID NOT NULL REFERENCES teams(id), + types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[], + company_name TEXT, + url TEXT NOT NULL UNIQUE, + timezone TEXT, + color TEXT DEFAULT '#4169E1', + logo_data BYTEA, + logo_content_type TEXT, + is_published BOOLEAN DEFAULT FALSE, + show_charts BOOLEAN DEFAULT FALSE, + show_uptime_percentage BOOLEAN DEFAULT FALSE, + show_admin_login_link BOOLEAN DEFAULT FALSE, + show_infrastructure BOOLEAN DEFAULT FALSE, + custom_css TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id); + CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url); + + CREATE TABLE IF NOT EXISTS status_page_monitors ( + status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + sort_order INTEGER DEFAULT 0, + PRIMARY KEY (status_page_id, monitor_id) + ); + + CREATE TABLE IF NOT EXISTS status_page_sub_monitors ( + status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + sort_order INTEGER DEFAULT 0, + PRIMARY KEY (status_page_id, monitor_id) + ); + `); +}; + +export const dropStatusPages = async (pool: Pool) => { + await pool.query(` + DROP TABLE IF EXISTS status_page_sub_monitors; + DROP TABLE IF EXISTS status_page_monitors; + DROP TABLE IF EXISTS status_pages; + `); +}; diff --git a/server/src/db/migration/timescaledb/0019_create_app_settings.ts b/server/src/db/migration/timescaledb/0019_create_app_settings.ts new file mode 100644 index 000000000..d2d624d07 --- /dev/null +++ b/server/src/db/migration/timescaledb/0019_create_app_settings.ts @@ -0,0 +1,62 @@ +import type { Pool } from "pg"; + +export const createAppSettings = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS app_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + check_ttl INTEGER DEFAULT 30, + language TEXT, + jwt_secret TEXT, + pagespeed_api_key TEXT, + + -- SMTP / Email settings + system_email_host TEXT, + system_email_port INTEGER, + system_email_address TEXT, + system_email_password TEXT, + system_email_user TEXT, + system_email_connection_host TEXT, + system_email_tls_servername TEXT, + system_email_secure BOOLEAN, + system_email_pool BOOLEAN, + system_email_ignore_tls BOOLEAN, + system_email_require_tls BOOLEAN, + system_email_reject_unauthorized BOOLEAN, + + show_url BOOLEAN, + version TEXT, + + -- Global infrastructure thresholds + threshold_cpu_usage INTEGER, + threshold_memory_usage INTEGER, + threshold_disk_usage INTEGER, + threshold_temperature INTEGER, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Enforce singleton: only one row allowed + CREATE OR REPLACE FUNCTION enforce_singleton_app_settings() + RETURNS TRIGGER AS $$ + BEGIN + IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN + RAISE EXCEPTION 'app_settings allows only one row'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings; + CREATE TRIGGER trg_singleton_app_settings + BEFORE INSERT ON app_settings + FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings(); + `); +}; + +export const dropAppSettings = async (pool: Pool) => { + await pool.query(` + DROP TABLE IF EXISTS app_settings; + DROP FUNCTION IF EXISTS enforce_singleton_app_settings(); + `); +}; diff --git a/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts b/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts new file mode 100644 index 000000000..d435ffdf9 --- /dev/null +++ b/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts @@ -0,0 +1,127 @@ +import type { Pool } from "pg"; + +export const createContinuousAggregates = async (pool: Pool) => { + // Uptime & Response Time (Hourly) + await pool.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly + WITH (timescaledb.continuous) AS + SELECT + monitor_id, + time_bucket('1 hour', created_at) AS bucket, + COUNT(*) AS total_checks, + COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, + COUNT(*) FILTER (WHERE status = FALSE) AS down_checks, + AVG(response_time) AS avg_response_time, + AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time, + AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time, + MAX(response_time) AS max_response_time, + MIN(response_time) AS min_response_time + FROM checks + GROUP BY monitor_id, bucket + WITH NO DATA; + `); + await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`); + await pool.query(` + SELECT add_continuous_aggregate_policy('checks_hourly', + start_offset => INTERVAL '3 hours', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '1 hour', + if_not_exists => TRUE + ); + `); + await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`); + + // Uptime & Response Time (Daily) + await pool.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily + WITH (timescaledb.continuous) AS + SELECT + monitor_id, + time_bucket('1 day', created_at) AS bucket, + COUNT(*) AS total_checks, + COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, + COUNT(*) FILTER (WHERE status = FALSE) AS down_checks, + AVG(response_time) AS avg_response_time, + AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time, + AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time, + MAX(response_time) AS max_response_time + FROM checks + GROUP BY monitor_id, bucket + WITH NO DATA; + `); + await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`); + await pool.query(` + SELECT add_continuous_aggregate_policy('checks_daily', + start_offset => INTERVAL '3 days', + end_offset => INTERVAL '1 day', + schedule_interval => INTERVAL '1 day', + if_not_exists => TRUE + ); + `); + await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`); + + // Infrastructure Metrics (Hourly) + await pool.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly + WITH (timescaledb.continuous) AS + SELECT + monitor_id, + time_bucket('1 hour', created_at) AS bucket, + AVG(cpu_usage_percent) AS avg_cpu, + MAX(cpu_usage_percent) AS max_cpu, + AVG(mem_usage_percent) AS avg_memory, + MAX(mem_usage_percent) AS max_memory, + COUNT(*) AS sample_count + FROM checks + WHERE monitor_type = 'hardware' + GROUP BY monitor_id, bucket + WITH NO DATA; + `); + await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`); + await pool.query(` + SELECT add_continuous_aggregate_policy('hardware_hourly', + start_offset => INTERVAL '3 hours', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '1 hour', + if_not_exists => TRUE + ); + `); + await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`); + + // PageSpeed Scores (Daily) + await pool.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily + WITH (timescaledb.continuous) AS + SELECT + monitor_id, + time_bucket('1 day', created_at) AS bucket, + AVG(lighthouse_performance) AS avg_performance, + AVG(lighthouse_accessibility) AS avg_accessibility, + AVG(lighthouse_best_practices) AS avg_best_practices, + AVG(lighthouse_seo) AS avg_seo, + COUNT(*) AS sample_count + FROM checks + WHERE monitor_type = 'pagespeed' + GROUP BY monitor_id, bucket + WITH NO DATA; + `); + await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`); + await pool.query(` + SELECT add_continuous_aggregate_policy('pagespeed_daily', + start_offset => INTERVAL '3 days', + end_offset => INTERVAL '1 day', + schedule_interval => INTERVAL '1 day', + if_not_exists => TRUE + ); + `); + await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`); +}; + +export const dropContinuousAggregates = async (pool: Pool) => { + await pool.query(` + DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE; + DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE; + DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE; + DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE; + `); +}; diff --git a/server/src/db/migration/timescaledb/0021_create_retention_compression.ts b/server/src/db/migration/timescaledb/0021_create_retention_compression.ts new file mode 100644 index 000000000..95230e248 --- /dev/null +++ b/server/src/db/migration/timescaledb/0021_create_retention_compression.ts @@ -0,0 +1,135 @@ +import type { Pool } from "pg"; + +export const createRetentionCompression = async (pool: Pool) => { + // -- Compression policies -- + + // Checks + await pool.query(` + ALTER TABLE checks SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'monitor_id', + timescaledb.compress_orderby = 'created_at DESC' + ); + SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE); + `); + + // Check child tables + await pool.query(` + ALTER TABLE check_disks SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'check_id', + timescaledb.compress_orderby = 'check_created_at DESC' + ); + SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE); + `); + + await pool.query(` + ALTER TABLE check_network_interfaces SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'check_id', + timescaledb.compress_orderby = 'check_created_at DESC' + ); + SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE); + `); + + await pool.query(` + ALTER TABLE check_errors SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'check_id', + timescaledb.compress_orderby = 'check_created_at DESC' + ); + SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE); + `); + + // Geo checks + await pool.query(` + ALTER TABLE geo_checks SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'monitor_id', + timescaledb.compress_orderby = 'created_at DESC' + ); + SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE); + `); + + await pool.query(` + ALTER TABLE geo_check_results SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'geo_check_id', + timescaledb.compress_orderby = 'geo_check_created_at DESC' + ); + SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE); + `); + + // -- Retention policies (default 30 days) -- + + await pool.query(` + SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE); + `); + + // -- Dynamic retention helper function -- + + await pool.query(` + CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER) + RETURNS VOID AS $$ + DECLARE + ttl_interval INTERVAL; + BEGIN + ttl_interval := (new_ttl_days || ' days')::INTERVAL; + + PERFORM remove_retention_policy('checks', if_exists => true); + PERFORM remove_retention_policy('check_disks', if_exists => true); + PERFORM remove_retention_policy('check_network_interfaces', if_exists => true); + PERFORM remove_retention_policy('check_errors', if_exists => true); + PERFORM remove_retention_policy('geo_checks', if_exists => true); + PERFORM remove_retention_policy('geo_check_results', if_exists => true); + + PERFORM add_retention_policy('checks', ttl_interval); + PERFORM add_retention_policy('check_disks', ttl_interval); + PERFORM add_retention_policy('check_network_interfaces', ttl_interval); + PERFORM add_retention_policy('check_errors', ttl_interval); + PERFORM add_retention_policy('geo_checks', ttl_interval); + PERFORM add_retention_policy('geo_check_results', ttl_interval); + END; + $$ LANGUAGE plpgsql; + `); +}; + +export const dropRetentionCompression = async (pool: Pool) => { + // Remove retention policies + await pool.query(` + SELECT remove_retention_policy('checks', if_exists => true); + SELECT remove_retention_policy('check_disks', if_exists => true); + SELECT remove_retention_policy('check_network_interfaces', if_exists => true); + SELECT remove_retention_policy('check_errors', if_exists => true); + SELECT remove_retention_policy('geo_checks', if_exists => true); + SELECT remove_retention_policy('geo_check_results', if_exists => true); + `); + + // Remove compression policies + await pool.query(` + SELECT remove_compression_policy('checks', if_exists => true); + SELECT remove_compression_policy('check_disks', if_exists => true); + SELECT remove_compression_policy('check_network_interfaces', if_exists => true); + SELECT remove_compression_policy('check_errors', if_exists => true); + SELECT remove_compression_policy('geo_checks', if_exists => true); + SELECT remove_compression_policy('geo_check_results', if_exists => true); + `); + + // Disable compression on tables + await pool.query(` + ALTER TABLE checks SET (timescaledb.compress = false); + ALTER TABLE check_disks SET (timescaledb.compress = false); + ALTER TABLE check_network_interfaces SET (timescaledb.compress = false); + ALTER TABLE check_errors SET (timescaledb.compress = false); + ALTER TABLE geo_checks SET (timescaledb.compress = false); + ALTER TABLE geo_check_results SET (timescaledb.compress = false); + `); + + // Drop dynamic retention function + await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`); +}; diff --git a/server/src/db/migration/timescaledb/index.ts b/server/src/db/migration/timescaledb/index.ts new file mode 100644 index 000000000..14084ebf3 --- /dev/null +++ b/server/src/db/migration/timescaledb/index.ts @@ -0,0 +1,148 @@ +import type { Pool } from "pg"; +import type { ILogger } from "@/utils/logger.js"; +import { createEnums, dropEnums } from "./0001_create_enums.js"; +import { createTeams, dropTeams } from "./0002_create_teams.js"; +import { createUsers, dropUsers } from "./0003_create_users.js"; +import { createMonitors, dropMonitors } from "./0004_create_monitors.js"; +import { createNotifications, dropNotifications } from "./0005_create_notifications.js"; +import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js"; +import { createIncidents, dropIncidents } from "./0007_create_incidents.js"; +import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js"; +import { createChecks, dropChecks } from "./0009_create_checks.js"; +import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js"; +import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js"; +import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js"; +import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js"; +import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js"; +import { createInvites, dropInvites } from "./0015_create_invites.js"; +import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js"; +import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js"; +import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js"; +import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; +import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; +import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; + +const SERVICE_NAME = "TimescaleDB Migrations"; + +type MigrationEntry = { + name: string; + up: (pool: Pool) => Promise; + down: (pool: Pool) => Promise; +}; + +const migrations: MigrationEntry[] = [ + { name: "0001_create_enums", up: createEnums, down: dropEnums }, + { name: "0002_create_teams", up: createTeams, down: dropTeams }, + { name: "0003_create_users", up: createUsers, down: dropUsers }, + { name: "0004_create_monitors", up: createMonitors, down: dropMonitors }, + { name: "0005_create_notifications", up: createNotifications, down: dropNotifications }, + { name: "0006_create_monitor_notifications", up: createMonitorNotifications, down: dropMonitorNotifications }, + { name: "0007_create_incidents", up: createIncidents, down: dropIncidents }, + { name: "0008_create_monitor_stats", up: createMonitorStats, down: dropMonitorStats }, + { name: "0009_create_checks", up: createChecks, down: dropChecks }, + { name: "0010_create_check_disks", up: createCheckDisks, down: dropCheckDisks }, + { name: "0011_create_check_network_interfaces", up: createCheckNetworkInterfaces, down: dropCheckNetworkInterfaces }, + { name: "0012_create_check_errors", up: createCheckErrors, down: dropCheckErrors }, + { name: "0013_create_geo_checks", up: createGeoChecks, down: dropGeoChecks }, + { name: "0014_create_geo_check_results", up: createGeoCheckResults, down: dropGeoCheckResults }, + { name: "0015_create_invites", up: createInvites, down: dropInvites }, + { name: "0016_create_recovery_tokens", up: createRecoveryTokens, down: dropRecoveryTokens }, + { name: "0017_create_maintenance_windows", up: createMaintenanceWindows, down: dropMaintenanceWindows }, + { name: "0018_create_status_pages", up: createStatusPages, down: dropStatusPages }, + { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, + { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, + { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, +]; + +const ensureMigrationsTable = async (pool: Pool) => { + await pool.query(` + DO $$ BEGIN + CREATE TYPE migration_status AS ENUM ('completed', 'failed'); + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS migrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + status migration_status NOT NULL, + completed_at TIMESTAMPTZ, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); +}; + +const runTimescaleDBMigrations = async (pool: Pool, logger?: ILogger) => { + try { + logger?.info({ message: "Running migrations", service: SERVICE_NAME }); + + await ensureMigrationsTable(pool); + + for (const migration of migrations) { + const result = await pool.query("SELECT 1 FROM migrations WHERE name = $1 AND status = 'completed'", [migration.name]); + if (result.rowCount !== null && result.rowCount > 0) { + logger?.info({ message: `Skipping ${migration.name}`, service: SERVICE_NAME }); + continue; + } + + try { + await migration.up(pool); + await pool.query( + `INSERT INTO migrations (name, status, completed_at) + VALUES ($1, 'completed', NOW()) + ON CONFLICT (name) DO UPDATE SET status = 'completed', completed_at = NOW(), error = NULL, updated_at = NOW()`, + [migration.name] + ); + logger?.info({ message: `Completed ${migration.name}`, service: SERVICE_NAME }); + } catch (error) { + const err = error as Error; + await pool.query( + `INSERT INTO migrations (name, status, error) + VALUES ($1, 'failed', $2) + ON CONFLICT (name) DO UPDATE SET status = 'failed', error = $2, updated_at = NOW()`, + [migration.name, err?.message] + ); + throw error; + } + } + + logger?.info({ message: "Migrations completed", service: SERVICE_NAME }); + } catch (error) { + const err = error as Error; + logger?.error({ message: "Migration failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack }); + throw error; + } +}; + +const rollbackTimescaleDBMigration = async (pool: Pool, logger?: ILogger) => { + try { + await ensureMigrationsTable(pool); + + // Find the last completed migration + const result = await pool.query("SELECT name FROM migrations WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1"); + if (result.rowCount === 0) { + logger?.info({ message: "No migrations to roll back", service: SERVICE_NAME }); + return; + } + + const lastMigrationName = result.rows[0].name; + const migration = migrations.find((m) => m.name === lastMigrationName); + if (!migration) { + throw new Error(`Migration "${lastMigrationName}" not found in migration list`); + } + + logger?.info({ message: `Rolling back ${migration.name}`, service: SERVICE_NAME }); + await migration.down(pool); + await pool.query("DELETE FROM migrations WHERE name = $1", [migration.name]); + logger?.info({ message: `Rolled back ${migration.name}`, service: SERVICE_NAME }); + } catch (error) { + const err = error as Error; + logger?.error({ message: "Rollback failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack }); + throw error; + } +}; + +export { runTimescaleDBMigrations, rollbackTimescaleDBMigration };