pingql/apps/shared/db.ts

288 lines
12 KiB
TypeScript

export async function migrate(sql: any) {
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
await sql`
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
email_hash TEXT,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS monitors (
id TEXT PRIMARY KEY DEFAULT encode(gen_random_bytes(8), 'hex'),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'GET',
request_headers JSONB,
request_body TEXT,
timeout_ms INTEGER NOT NULL DEFAULT 30000,
interval_s INTEGER NOT NULL DEFAULT 60,
query JSONB,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`
CREATE TABLE IF NOT EXISTS pings (
id BIGSERIAL PRIMARY KEY,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_at TIMESTAMPTZ,
jitter_ms INTEGER,
status_code INTEGER,
latency_ms INTEGER,
up BOOLEAN NOT NULL,
error TEXT,
meta JSONB
)
`;
await sql`
CREATE TABLE IF NOT EXISTS ping_bodies (
ping_id BIGINT PRIMARY KEY REFERENCES pings(id) ON DELETE CASCADE,
body TEXT
)
`;
await sql`
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
label TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ
)
`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS regions TEXT[] NOT NULL DEFAULT '{}'`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS region TEXT`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS run_id TEXT`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS max_retries INTEGER NOT NULL DEFAULT 0`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS retry_interval_s INTEGER NOT NULL DEFAULT 30`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS resend_interval INTEGER NOT NULL DEFAULT 0`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS cert_alert_days INTEGER NOT NULL DEFAULT 0`;
await sql`ALTER TABLE monitors ALTER COLUMN cert_alert_days SET DEFAULT 0`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS important BOOLEAN NOT NULL DEFAULT false`;
// Per-region transition state. region='' for unspecified/single-region monitors.
await sql`
CREATE TABLE IF NOT EXISTS monitor_region_state (
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
region TEXT NOT NULL,
last_state TEXT,
consecutive_down INTEGER NOT NULL DEFAULT 0,
cert_alert_sent BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (monitor_id, region)
)
`;
// Drop the now-stale per-monitor state columns from the previous slice.
await sql`ALTER TABLE monitors DROP COLUMN IF EXISTS last_state`;
await sql`ALTER TABLE monitors DROP COLUMN IF EXISTS consecutive_down`;
// Notifications: modular providers, webhook only for now.
await sql`
CREATE TABLE IF NOT EXISTS notification_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL,
config JSONB NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_notification_channels_account ON notification_channels(account_id)`;
await sql`
CREATE TABLE IF NOT EXISTS monitor_notifications (
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
PRIMARY KEY (monitor_id, channel_id)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_monitor_notifications_channel ON monitor_notifications(channel_id)`;
// Tier 3: monitor tags. One row per (monitor, tag). Used by the dashboard
// home filter and the status page builder's "all monitors with tag X" picker.
await sql`
CREATE TABLE IF NOT EXISTS monitor_tags (
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (monitor_id, tag)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_monitor_tags_tag ON monitor_tags(tag)`;
// Tier 3: public status pages. The whole subgraph below is read by the
// standalone apps/status service; writes happen via apps/api admin routes.
await sql`
CREATE TABLE IF NOT EXISTS status_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT,
theme TEXT NOT NULL DEFAULT 'auto',
password_hash TEXT,
index_search BOOLEAN NOT NULL DEFAULT true,
show_powered_by BOOLEAN NOT NULL DEFAULT true,
show_response_time BOOLEAN NOT NULL DEFAULT true,
show_cert_expiry BOOLEAN NOT NULL DEFAULT false,
default_window TEXT NOT NULL DEFAULT '24h',
custom_css TEXT,
footer_text TEXT,
og_image_url TEXT,
analytics_html TEXT,
auto_refresh_s INTEGER NOT NULL DEFAULT 60,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`;
// Display mode: 'compact' = one-line rows with click-to-expand details,
// 'expanded' = full detail card always visible (default).
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS display_mode TEXT NOT NULL DEFAULT 'expanded'`;
await sql`
CREATE TABLE IF NOT EXISTS status_page_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_groups_page ON status_page_groups(status_page_id)`;
await sql`
CREATE TABLE IF NOT EXISTS status_page_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL,
display_name TEXT,
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_monitors_monitor ON status_page_monitors(monitor_id)`;
// Per-monitor display mode override. NULL = inherit status_pages.display_mode.
await sql`ALTER TABLE status_page_monitors ADD COLUMN IF NOT EXISTS display_mode TEXT`;
await sql`
CREATE TABLE IF NOT EXISTS incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'minor',
pinned BOOLEAN NOT NULL DEFAULT true,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_incidents_account ON incidents(account_id, started_at DESC)`;
await sql`
CREATE TABLE IF NOT EXISTS incident_updates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
status TEXT NOT NULL,
body TEXT NOT NULL,
body_html TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON incident_updates(incident_id, created_at)`;
await sql`
CREATE TABLE IF NOT EXISTS incident_monitors (
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
PRIMARY KEY (incident_id, monitor_id)
)
`;
await sql`
CREATE TABLE IF NOT EXISTS incident_status_pages (
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
PRIMARY KEY (incident_id, status_page_id)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_incident_status_pages_page ON incident_status_pages(status_page_id)`;
// Shared uptime rollup. One row per (monitor, region, bucket_type, bucket_start).
// Powers status page uptime windows AND any future dashboard widgets.
await sql`
CREATE TABLE IF NOT EXISTS monitor_uptime_rollup (
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
region TEXT NOT NULL,
bucket_type TEXT NOT NULL,
bucket_start TIMESTAMPTZ NOT NULL,
total INTEGER NOT NULL,
up_count INTEGER NOT NULL,
avg_latency REAL,
PRIMARY KEY (monitor_id, region, bucket_type, bucket_start)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_uptime_rollup_lookup ON monitor_uptime_rollup(monitor_id, bucket_type, bucket_start DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
await sql`
CREATE TABLE IF NOT EXISTS payments (
id BIGSERIAL PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
plan TEXT NOT NULL,
months INTEGER,
amount_usd NUMERIC(10,2) NOT NULL,
coin TEXT NOT NULL,
amount_crypto TEXT NOT NULL,
address TEXT NOT NULL,
derivation_index INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT now(),
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
txid TEXT
)
`;
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_received TEXT NOT NULL DEFAULT '0'`;
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS receipt_html TEXT`;
await sql`
CREATE TABLE IF NOT EXISTS payment_txs (
id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL REFERENCES payments(id) ON DELETE CASCADE,
txid TEXT NOT NULL,
amount TEXT NOT NULL,
confirmed BOOLEAN NOT NULL DEFAULT false,
detected_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(payment_id, txid)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_payment ON payment_txs(payment_id)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_txid ON payment_txs(txid)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`;
await sql`DROP INDEX IF EXISTS payments_derivation_index_key`;
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_coin_derivation ON payments(coin, derivation_index)`;
console.log("DB ready");
}