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.
53 lines
2.4 KiB
SQL
53 lines
2.4 KiB
SQL
\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
|
|
$$;
|