[feat](alarm): add a durable per-device delivery ledger

pg_notify and the socket fan-out are not durable, so an offline phone used to miss a trigger forever and no-auto-stop would leave it ringing at nobody. Write one ledger row per enabled subscription in the trigger transaction, replay what a subscription still owes on every socket connect, and clear a row only on an explicit ack (live socket ack or ackAlarm); alarmStatus is a read-only check-in that answers 409 ERR_ALARM_TRIGGERED_OFFLINE with the missed frame. Ack is idempotent, and a destroyed alert cascades its debt away so a client is never told it missed an alarm that no longer exists.
This commit is contained in:
2026-10-19 02:26:46 +01:00
parent 0334d7fe93
commit 6e6a1e9c8a
12 changed files with 1128 additions and 77 deletions
@@ -0,0 +1,52 @@
\c dcntrlized;
-- 005_alarm_deliveries.sql — a durable per-device delivery ledger.
--
-- Why this exists: pg_notify and the WebSocket fan-out are not durable. A phone
-- that is offline at trigger time misses the frame forever, and the in-memory
-- catch-up cache only lives in the process that produced the event. This table is
-- the durable record of who still owes an alarm, so "no auto-stop" is safe: the
-- alarm stays live until the owner disarms it, and every subscribed device has a
-- row here until it has been told.
--
-- Shape: one row per (alert, subscription, event), written in the SAME
-- transaction as the alarm_events insert and therefore before the NOTIFY on
-- commit. If this insert fails the trigger fails — a fan-out that records no debt
-- would be exactly the silent failure this design prevents.
--
-- Cascades are in the schema, not in application code: deleting an alert or a
-- subscription removes its ledger rows. event_id deliberately has no foreign key:
-- the ledger is the record of what a device owes, and it must outlive nothing but
-- its alert/subscription (both of which cascade).
--
-- Idempotent (same guard pattern as 002/003/004): 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;
CREATE TABLE IF NOT EXISTS alarm.alarm_deliveries (
alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE,
subscription_id UUID NOT NULL REFERENCES alarm.subscriptions(id) ON DELETE CASCADE,
event_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
acked_at TIMESTAMPTZ,
PRIMARY KEY (alert_id, subscription_id, event_id)
);
-- The check-in reads by alert + subscription; the pending index keeps
-- "who still owes this alert" a small partial scan instead of a table scan.
CREATE INDEX IF NOT EXISTS alarm_deliveries_alert_idx
ON alarm.alarm_deliveries (alert_id);
CREATE INDEX IF NOT EXISTS alarm_deliveries_pending_idx
ON alarm.alarm_deliveries (alert_id, subscription_id)
WHERE acked_at IS NULL;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'alarm_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON alarm.alarm_deliveries TO alarm_user;
END IF;
END
$$;