Move the database to Postgres, before teams need the schema
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 2m5s

First step of #1, and it goes first for one reason: #4 adds a team_id to
nearly every table, and doing that twice -- once for SQLite, once for
Postgres -- is work nobody gets paid for. The teams migrations now only
have to be written against one database.

The ten SQLite migrations are replaced by a single Postgres baseline
rather than ported one by one. They were incremental in a way that has
no value on a fresh install: 004 adds columns 008 drops again, and 008's
backfill rewrites data a Postgres database never had. The history stays
in git; the schema they add up to is now 001_baseline.sql.

Timestamps stay BIGINT unix seconds and are NOT converted to timestamptz.
Everything in Go already speaks epochs, so converting would have been a
second, larger change riding along inside this one. It is worth doing on
its own. The JSON columns did move to jsonb, because #4 will want to
filter and index on labels.

Most of the port is mechanical -- 170 placeholders from ? to $1 -- but
four things needed more than a search and replace:

  * Dynamically built WHERE clauses cannot keep their numbering straight
    by hand, so they hand out placeholders through sqlArgs instead. A
    filter can now be added or reordered without renumbering anything.

  * SUM(resolved_at IS NULL) was SQLite counting a boolean as 0 or 1.
    Postgres has no sum(boolean), and this was breaking every dead man's
    switch -- silently, since the sweeper only logs. Now COUNT(*) FILTER.

  * unixepoch() became FLOOR(EXTRACT(EPOCH FROM now()))::bigint. The
    FLOOR is load-bearing: a bare cast rounds half up, so a row written
    at .6 of a second claimed a timestamp a second in the future and
    disagreed with the time.Now().Unix() the Go side stamps.

  * The unique-violation check matched SQLite's error text. It matches
    SQLSTATE 23505 now, so a renamed constraint cannot turn a 409 back
    into a 500.

Tests need a real Postgres, because there is no in-memory Postgres the
way there was an in-memory SQLite. Each test gets its own schema on a
shared server -- cheaper than a database each, and still isolated.
TERDUT_TEST_DSN says where it is; `make test-db` starts one locally and
ci.yaml runs one as a service container. An unset DSN fails the suite
rather than skipping it: a run that quietly tests nothing is worse than
one that does not run.

TestMigration_BackfillCarriesAckAndComments is deleted along with the
migrations it replayed. What it protected -- an upgrade not losing
acknowledgements and comments -- now belongs to scripts/sqlite-to-postgres.go,
which is build-tagged so the SQLite driver stays out of the server
binary. Both are meant to be deleted once this install has migrated.

The chart loses the PVC, the data volume and the python backup sidecar,
and requires database.dsnSecret.name: it provisions no database and
cannot guess where the credentials live, so a render without it is meant
to fail. Backups move to where Postgres actually runs. The other half of
that -- the postgresql CR, the k8up pg_dump annotation and the network
policy -- is a change to the wrapper chart in Ryuvia/charts and is not in
here.

Verified rather than assumed: the gate is green with -race against
Postgres 17, govulncheck and gitleaks are clean, and the migration script
was run end to end against a SQLite database built at the old schema and
seeded in every table. Ids survive, so incidents keep their numbers and
every foreign key still points where it did; the identity sequences are
moved past the copied ids, and a webhook after the migration opened
incident 12 rather than colliding at 1.
This commit is contained in:
Niklas Ye
2026-09-20 10:44:12 +02:00
parent 989425e550
commit dc39e3a5d3
44 changed files with 1004 additions and 725 deletions
+46 -17
View File
@@ -7,24 +7,32 @@ import (
"io/fs"
"sort"
"strings"
"time"
_ "modernc.org/sqlite"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed migrations
var migrationsFS embed.FS
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
// Open connects to Postgres. dsn is a libpq connection string or URL, e.g.
// postgres://terdut:secret@localhost:5432/terdut?sslmode=disable.
//
// The pool is modest on purpose: this server's concurrency comes from a handful
// of HTTP handlers plus two background loops, and a cloud-native-pg instance
// sized for it has a low max_connections. It is still a pool, unlike the single
// connection SQLite forced, so the notifier no longer blocks a webhook.
func Open(dsn string) (*sql.DB, error) {
if dsn == "" {
return nil, fmt.Errorf("empty DSN: set TERDUT_DB_DSN")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
// SQLite does not support concurrent writers; a single connection avoids locking errors.
db.SetMaxOpenConns(1)
if _, err := db.Exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;"); err != nil {
db.Close()
return nil, fmt.Errorf("set pragmas: %w", err)
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping: %w", err)
@@ -32,10 +40,16 @@ func Open(path string) (*sql.DB, error) {
return db, nil
}
// Migrate applies every embedded migration that has not been applied yet, in
// filename order, recording each in schema_migrations.
//
// Each file runs inside a transaction, which SQLite's version did not do: a
// migration that failed half way used to leave the schema in whatever state it
// had reached. Postgres has transactional DDL, so the rollback is real.
func Migrate(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
applied_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
@@ -55,7 +69,7 @@ func Migrate(db *sql.DB) error {
for _, name := range files {
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", name).Scan(&count); err != nil {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = $1", name).Scan(&count); err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if count > 0 {
@@ -67,13 +81,28 @@ func Migrate(db *sql.DB) error {
return fmt.Errorf("read migration %s: %w", name, err)
}
if _, err := db.Exec(string(data)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
if err := applyMigration(db, name, string(data)); err != nil {
return err
}
}
return nil
}
func applyMigration(db *sql.DB, name, body string) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
defer tx.Rollback()
if _, err := tx.Exec(body); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
return nil
}
+182
View File
@@ -0,0 +1,182 @@
-- The Postgres baseline: the schema as it stood at the end of the SQLite line,
-- in one file rather than ten.
--
-- The ten SQLite migrations are in git history up to the commit that introduced
-- this one, and they replay against nothing here: their shape was incremental
-- (columns added, then dropped again in 008) and 008's backfill rewrote data
-- that a Postgres install never had. An existing SQLite database is carried over
-- by scripts/sqlite-to-postgres.go, which copies rows into this schema.
--
-- Two conventions inherited deliberately:
--
-- * Timestamps are BIGINT unix seconds, not timestamptz. Everything in Go
-- already speaks epochs, and converting was a second change riding along
-- with the port. Worth revisiting on its own.
--
-- * Ids are GENERATED BY DEFAULT, not ALWAYS, so the migration script can
-- insert rows with their original ids and keep every foreign key intact.
-- setval at the end of the copy puts the sequences past them.
CREATE TABLE users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
-- Where this user's notifications go. NULL means they get none; incidents
-- assigned to them fall back to the configured fallback topic.
ntfy_topic TEXT,
-- NULL means the user has no password and can only use API keys.
password_hash TEXT
);
CREATE TABLE api_keys (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
last_used_at BIGINT
);
-- A session is a browser's credential, the cookie counterpart of an API key:
-- only the hash of the token is stored. expires_at slides forward while the
-- session is in use, so an on-call phone stays signed in.
CREATE TABLE sessions (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at BIGINT NOT NULL,
last_seen_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
user_agent TEXT
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
-- The machine-owned signal record: what Alertmanager says is true right now.
-- Workflow state lives on incidents, never here, because the webhook upsert owns
-- these rows and would overwrite it.
CREATE TABLE alerts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
fingerprint TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('firing', 'resolved')),
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
annotations JSONB NOT NULL DEFAULT '{}'::jsonb,
starts_at BIGINT NOT NULL,
ends_at BIGINT,
generator_url TEXT NOT NULL DEFAULT '',
received_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
archived_at BIGINT,
-- Why the alert left the firing state: 'alertmanager' when a resolved
-- webhook set it, 'expiry' when the sweeper inferred it from staleness.
resolution_source TEXT
);
CREATE INDEX alerts_status_idx ON alerts(status);
CREATE INDEX alerts_name_idx ON alerts(name);
CREATE INDEX alerts_received_at_idx ON alerts(received_at DESC);
CREATE INDEX alerts_archived_at_idx ON alerts(archived_at);
CREATE TABLE schedule_entries (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL UNIQUE, -- YYYY-MM-DD; one person per day
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
);
CREATE INDEX schedule_entries_date_idx ON schedule_entries(date);
-- The human work item: what people acknowledge, assign, snooze, discuss and
-- resolve. Correlation uses Alertmanager's own groupKey, so incidents follow the
-- group_by routing tree the operator already tuned.
CREATE TABLE incidents (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
group_key TEXT NOT NULL, -- Alertmanager groupKey, opaque
title TEXT NOT NULL, -- rendered from group_labels
group_labels JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL CHECK (status IN ('triggered', 'acknowledged', 'resolved')),
severity TEXT, -- highest `severity` label across firing members
triggered_at BIGINT NOT NULL,
acknowledged_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
acknowledged_at BIGINT,
assigned_to BIGINT REFERENCES users(id) ON DELETE SET NULL,
snoozed_until BIGINT,
resolved_at BIGINT,
resolution_source TEXT, -- 'alerts' | 'manual'
archived_at BIGINT
);
-- Load-bearing: at most one OPEN incident per group_key. This is what makes
-- "resolved incident + a new alert occurrence = a new incident" work, and it is
-- the constraint the webhook's find-or-open lookup relies on.
CREATE UNIQUE INDEX incidents_open_group_key_idx ON incidents(group_key) WHERE resolved_at IS NULL;
CREATE INDEX incidents_status_idx ON incidents(status);
CREATE INDEX incidents_triggered_at_idx ON incidents(triggered_at DESC);
CREATE INDEX incidents_archived_at_idx ON incidents(archived_at);
-- Membership is historical, not a pointer on alerts: one alert row (one
-- fingerprint) resolves and re-fires over time and belongs to a different
-- incident each occurrence.
CREATE TABLE incident_alerts (
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
alert_id BIGINT NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
added_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
PRIMARY KEY (incident_id, alert_id)
);
CREATE INDEX incident_alerts_alert_id_idx ON incident_alerts(alert_id);
-- The timeline. Append-only, and the only history this server keeps: alert rows
-- are mutated in place, so without this there is no record that anything
-- happened. Notes are events too, so one query renders the whole story.
CREATE TABLE incident_events (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
-- triggered | alert_added | alert_resolved | acknowledged | unacknowledged
-- | assigned | snoozed | unsnoozed | resolved | note | notified | notify_failed
type TEXT NOT NULL,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, -- NULL = the server acted
alert_id BIGINT REFERENCES alerts(id) ON DELETE SET NULL,
detail TEXT,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
);
CREATE INDEX incident_events_incident_idx ON incident_events(incident_id, created_at);
-- Delivery is an outbox rather than an inline HTTP call: a POST made while
-- holding the webhook's transaction would hold a connection open across a
-- network round trip. The webhook inserts a row; the notifier goroutine
-- delivers it.
CREATE TABLE notifications (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
-- Nullable: a notification sent to the fallback topic belongs to nobody,
-- because nobody was on call when the incident opened.
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
topic TEXT NOT NULL, -- resolved at enqueue: who was on call then
kind TEXT NOT NULL CHECK (kind IN ('triggered', 'reminder', 'resolved')),
created_at BIGINT NOT NULL,
send_after BIGINT NOT NULL, -- retry backoff watermark
attempts BIGINT NOT NULL DEFAULT 0,
sent_at BIGINT,
last_error TEXT -- kept after the last attempt, for debugging
);
-- The delivery loop's only query: what is due and still unsent.
CREATE INDEX notifications_pending_idx ON notifications(send_after) WHERE sent_at IS NULL;
-- Reminders and resolved notices both look up an incident's newest row.
CREATE INDEX notifications_incident_idx ON notifications(incident_id, id DESC);
-- A notification body is stored on the ntfy server and cached on the device, so
-- a real API key must never appear in one. Each delivery mints its own token
-- instead: one incident, one action, one day.
CREATE TABLE incident_ack_tokens (
token_hash TEXT PRIMARY KEY, -- SHA-256 of the raw token, as with api_keys
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE INDEX incident_ack_tokens_expires_idx ON incident_ack_tokens(expires_at);
@@ -1,2 +0,0 @@
-- Stage 1 foundation. No tables yet; subsequent migrations add schema.
SELECT 1;
@@ -1,15 +0,0 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
last_used_at INTEGER
);
-16
View File
@@ -1,16 +0,0 @@
CREATE TABLE alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('firing', 'resolved')),
labels TEXT NOT NULL DEFAULT '{}',
annotations TEXT NOT NULL DEFAULT '{}',
starts_at INTEGER NOT NULL,
ends_at INTEGER,
generator_url TEXT NOT NULL DEFAULT '',
received_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX alerts_status_idx ON alerts(status);
CREATE INDEX alerts_name_idx ON alerts(name);
CREATE INDEX alerts_received_at_idx ON alerts(received_at DESC);
@@ -1,12 +0,0 @@
ALTER TABLE alerts ADD COLUMN acknowledged_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE alerts ADD COLUMN acknowledged_at INTEGER;
CREATE TABLE alert_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alert_id INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX alert_comments_alert_id_idx ON alert_comments(alert_id);
-8
View File
@@ -1,8 +0,0 @@
CREATE TABLE schedule_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL UNIQUE, -- YYYY-MM-DD; one person per day
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX schedule_entries_date_idx ON schedule_entries(date);
@@ -1,2 +0,0 @@
ALTER TABLE alerts ADD COLUMN archived_at INTEGER;
CREATE INDEX alerts_archived_at_idx ON alerts(archived_at);
@@ -1,4 +0,0 @@
-- Records why an alert left the firing state: 'alertmanager' when a resolved
-- webhook set it, 'expiry' when the sweeper inferred it from staleness.
-- NULL for firing alerts and for rows that predate this migration.
ALTER TABLE alerts ADD COLUMN resolution_source TEXT;
-123
View File
@@ -1,123 +0,0 @@
-- Splits the single alerts row into two objects, the way an incident management
-- tool needs them: alerts stay the machine-owned signal record that Alertmanager
-- writes, and incidents become the human work item people acknowledge, assign,
-- snooze, discuss and resolve.
--
-- Correlation uses Alertmanager's own groupKey, so incidents follow the group_by
-- routing tree the operator already tuned rather than a second grouping scheme
-- invented here.
CREATE TABLE incidents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_key TEXT NOT NULL, -- Alertmanager groupKey, opaque
title TEXT NOT NULL, -- rendered from group_labels
group_labels TEXT NOT NULL DEFAULT '{}', -- JSON
status TEXT NOT NULL CHECK(status IN ('triggered', 'acknowledged', 'resolved')),
severity TEXT, -- highest `severity` label across firing members
triggered_at INTEGER NOT NULL,
acknowledged_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
acknowledged_at INTEGER,
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
snoozed_until INTEGER,
resolved_at INTEGER,
resolution_source TEXT, -- 'alerts' | 'manual'
archived_at INTEGER
);
-- Load-bearing: at most one OPEN incident per group_key. This is what makes
-- "resolved incident + a new alert occurrence = a new incident" work, and it is
-- the constraint the webhook's find-or-open lookup relies on.
CREATE UNIQUE INDEX incidents_open_group_key_idx ON incidents(group_key) WHERE resolved_at IS NULL;
CREATE INDEX incidents_status_idx ON incidents(status);
CREATE INDEX incidents_triggered_at_idx ON incidents(triggered_at DESC);
CREATE INDEX incidents_archived_at_idx ON incidents(archived_at);
-- Membership is historical, not a pointer on alerts: one alert row (one
-- fingerprint) resolves and re-fires over time and belongs to a different
-- incident each occurrence.
CREATE TABLE incident_alerts (
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
alert_id INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
added_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
PRIMARY KEY (incident_id, alert_id)
);
CREATE INDEX incident_alerts_alert_id_idx ON incident_alerts(alert_id);
-- The timeline. Append-only, and the only history this server keeps: alert rows
-- are mutated in place, so without this there is no record that anything
-- happened. Notes are events too, so one query renders the whole story.
CREATE TABLE incident_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
-- triggered | alert_added | alert_resolved | acknowledged | unacknowledged
-- | assigned | snoozed | unsnoozed | resolved | note
type TEXT NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- NULL = the server acted
alert_id INTEGER REFERENCES alerts(id) ON DELETE SET NULL,
detail TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX incident_events_incident_idx ON incident_events(incident_id, created_at);
-- ---------------------------------------------------------------------------
-- Backfill
--
-- Every pre-existing alert gets its own incident, archived ones included, so no
-- acknowledgement and no comment is orphaned. There is no historical groupKey to
-- correlate on, hence one incident per fingerprint under a 'backfill:' prefix
-- that can never collide with a real Alertmanager groupKey.
-- ---------------------------------------------------------------------------
INSERT INTO incidents (group_key, title, group_labels, status, severity, triggered_at,
acknowledged_by, acknowledged_at, assigned_to,
resolved_at, resolution_source, archived_at)
SELECT 'backfill:' || a.fingerprint,
a.name,
json_object('alertname', a.name),
CASE WHEN a.status = 'resolved' THEN 'resolved'
WHEN a.acknowledged_by IS NOT NULL THEN 'acknowledged'
ELSE 'triggered' END,
json_extract(a.labels, '$.severity'),
a.starts_at,
a.acknowledged_by,
a.acknowledged_at,
a.acknowledged_by,
CASE WHEN a.status = 'resolved' THEN COALESCE(a.ends_at, a.received_at) END,
CASE WHEN a.status = 'resolved' THEN 'alerts' END,
a.archived_at
FROM alerts a;
INSERT INTO incident_alerts (incident_id, alert_id, added_at)
SELECT i.id, a.id, a.starts_at
FROM alerts a
JOIN incidents i ON i.group_key = 'backfill:' || a.fingerprint;
INSERT INTO incident_events (incident_id, type, alert_id, created_at)
SELECT i.id, 'triggered', ia.alert_id, i.triggered_at
FROM incidents i JOIN incident_alerts ia ON ia.incident_id = i.id;
INSERT INTO incident_events (incident_id, type, user_id, created_at)
SELECT i.id, 'acknowledged', i.acknowledged_by, i.acknowledged_at
FROM incidents i WHERE i.acknowledged_at IS NOT NULL;
INSERT INTO incident_events (incident_id, type, created_at)
SELECT i.id, 'resolved', i.resolved_at
FROM incidents i WHERE i.resolved_at IS NOT NULL;
INSERT INTO incident_events (incident_id, type, user_id, alert_id, detail, created_at)
SELECT ia.incident_id, 'note', c.user_id, c.alert_id, c.content, c.created_at
FROM alert_comments c
JOIN incident_alerts ia ON ia.alert_id = c.alert_id;
-- ---------------------------------------------------------------------------
-- Workflow state now lives on incidents only. Leaving these behind would keep
-- the bug they caused: the webhook upsert owns the alerts row and never cleared
-- the acknowledgement, so a re-fire days later still read as acknowledged.
-- ---------------------------------------------------------------------------
DROP TABLE alert_comments;
ALTER TABLE alerts DROP COLUMN acknowledged_by;
ALTER TABLE alerts DROP COLUMN acknowledged_at;
@@ -1,44 +0,0 @@
-- Adds push notification delivery, so an incident reaches the person on call
-- instead of waiting to be discovered.
--
-- Delivery is an outbox rather than an inline HTTP call: the pool is limited to
-- a single connection, so a POST made while holding the webhook's transaction
-- would stall every other request behind it. The webhook inserts a row; the
-- notifier goroutine delivers it.
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
-- Nullable: a notification sent to the fallback topic belongs to nobody,
-- because nobody was on call when the incident opened.
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
topic TEXT NOT NULL, -- resolved at enqueue: who was on call then
kind TEXT NOT NULL CHECK(kind IN ('triggered', 'reminder', 'resolved')),
created_at INTEGER NOT NULL,
send_after INTEGER NOT NULL, -- retry backoff watermark
attempts INTEGER NOT NULL DEFAULT 0,
sent_at INTEGER,
last_error TEXT -- kept after the last attempt, for debugging
);
-- The delivery loop's only query: what is due and still unsent.
CREATE INDEX notifications_pending_idx ON notifications(send_after) WHERE sent_at IS NULL;
-- Reminders and resolved notices both look up an incident's newest row.
CREATE INDEX notifications_incident_idx ON notifications(incident_id, id DESC);
-- A notification body is stored on the ntfy server and cached on the device, so
-- a real API key must never appear in one. Each delivery mints its own token
-- instead: one incident, one action, one day.
CREATE TABLE incident_ack_tokens (
token_hash TEXT PRIMARY KEY, -- SHA-256 of the raw token, as with api_keys
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX incident_ack_tokens_expires_idx ON incident_ack_tokens(expires_at);
-- Where this user's notifications go. NULL means they get none; incidents
-- assigned to them fall back to the configured fallback topic.
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
-18
View File
@@ -1,18 +0,0 @@
-- A password is what lets a person sign in to the web UI. NULL means the user
-- has none and can only use API keys, which is every user created before this.
ALTER TABLE users ADD COLUMN password_hash TEXT;
-- A session is a browser's credential, the cookie counterpart of an API key:
-- only the hash of the token is stored. expires_at slides forward while the
-- session is in use, so an on-call phone stays signed in.
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
user_agent TEXT
);
CREATE INDEX idx_sessions_user ON sessions(user_id);