[refactor](alarm): end alerts by disarm or expiry, not a ring timer

An alert now rings from trigger until the owner disarms it; a subscriber silences only its own device. The owner instead sets a persistence cap (default 24h, hard cap 14 days) and the sweeper destroys expired alerts. Disarm ends an armed alert too, and every ending (delivered, expired, disarmed, deleted) converges on the one destruction path so their event-log reasons cannot drift.
This commit is contained in:
2026-10-19 02:26:31 +01:00
parent f055700c2d
commit 3a2da52b7a
12 changed files with 658 additions and 246 deletions
@@ -0,0 +1,51 @@
\c dcntrlized;
-- 004_drop_auto_stop.sql — remove the auto-stop window.
--
-- Contract decision (2026-10-18, user ruling): an alert is active from trigger
-- until the owner disarms it. There is no clock-based expiry: "if an owner
-- triggers it and then loses their phone, the alarm should go off until the
-- client dismisses it, with no autostop". A subscriber's ringing ends when that
-- subscriber dismisses locally; the server does not expire the event, and the
-- server-side state does not depend on any client's cooperation.
--
-- Consequence, accepted and documented in API.md: an alert that is never
-- disarmed stays active forever, and only the owner's admin token can end it.
--
-- `alerts.auto_stop_seconds` and its CHECK constraint
-- (alerts_auto_stop_seconds_range) have no remaining reader: `active` is simply
-- `status = 'triggered'` now. `001_alarm_schema.sql` is already applied and is
-- never edited in place, so the column is dropped here; a fresh database creates
-- it in 001 and drops it in this file, keeping the history append-only.
--
-- Idempotent: guarded on the schema existing and DROP COLUMN IF EXISTS, so a
-- re-apply of the whole sequence is a clean no-op (same pattern as 002/003).
-- The fan-out trigger reads the alert row when it builds its pg_notify payload,
-- so it is replaced BEFORE the column goes: leaving the old body behind would
-- make every event INSERT fail with "column auto_stop_seconds does not exist"
-- once the column is gone. The payload is the WS frame's transport form; with no
-- auto-stop it carries alertId/eventId/action/message/createdAtMs and nothing
-- else. CREATE OR REPLACE is idempotent.
CREATE OR REPLACE FUNCTION alarm.notify_alarm_event()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('alarm_events', json_build_object(
'alertId', NEW.alert_id::text,
'eventId', NEW.id::text,
'action', NEW.kind::text,
'message', COALESCE(NEW.message, ''),
'createdAtMs', (EXTRACT(EPOCH FROM NEW.created_at) * 1000)::bigint
)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
ALTER TABLE alarm.alerts DROP COLUMN IF EXISTS auto_stop_seconds;
END IF;
END
$$;
+44
View File
@@ -0,0 +1,44 @@
\c dcntrlized;
-- 006_alert_expiry.sql — a hard persistence cap chosen by the owner.
--
-- Contract decision (2026-10-18, user ruling): the creator sets a maximum
-- persistence time, at most 14 days, after which devices that never came back
-- are ignored and the alert "along with everything about it" is destroyed. This
-- is what stops a phone that never returns from pinning an alert (and its
-- delivery debt) forever once auto-stop is gone.
--
-- The application computes the absolute timestamp from the requested duration,
-- rejects anything above the cap (API.md: ERR_VALIDATION, not a silent clamp) and
-- inserts it explicitly. The DEFAULT is a safety net only: 24 hours, the
-- documented default duration, so a row inserted by an older binary or by hand
-- still expires instead of living forever.
--
-- Existing rows are backfilled to `created + 14 days` (the maximum): they were
-- created under a contract with no cap, so they get the most generous value the
-- new contract allows, not an instant expiry.
--
-- Read paths also filter `expires_at > now()` (db.GetAlert, transition, …) so an
-- alert is invisible the moment it expires, even before the sweeper reaches it.
--
-- Idempotent (same guard pattern as 002–005): a re-apply of the whole sequence
-- is a clean no-op.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'alarm') THEN
RETURN;
END IF;
ALTER TABLE alarm.alerts ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
UPDATE alarm.alerts
SET expires_at = created + interval '14 days'
WHERE expires_at IS NULL;
ALTER TABLE alarm.alerts ALTER COLUMN expires_at SET NOT NULL;
ALTER TABLE alarm.alerts ALTER COLUMN expires_at SET DEFAULT (now() + interval '24 hours');
CREATE INDEX IF NOT EXISTS alerts_expires_at_idx ON alarm.alerts (expires_at);
END
$$;