This commit is contained in:
2026-10-18 22:31:16 +01:00
commit ec38e680d6
64 changed files with 7222 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
\c dcntrlized;
-- ============================================================================
-- Alarm service v1 schema (contract frozen 2026-10-18).
--
-- Identity comes from the shared dcntrlized-auth service: every *_user_id
-- column is a plain UUID pointing at auth.users(id) -- NO foreign key, so the
-- alarm schema never depends on the auth service's internals (platform
-- convention, see Calendar/KeepNote).
--
-- The whole file is idempotent: every statement is guarded, so the compose
-- `migrate` one-shot can re-apply it against an already-migrated database.
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS alarm;
-- ---------------------------------------------------------------------------
-- Guarded enum types. CREATE TYPE has no IF NOT EXISTS, so each one is wrapped
-- in a DO block that checks pg_type/pg_namespace first.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'alert_status' AND n.nspname = 'alarm'
) THEN
CREATE TYPE alarm.alert_status AS ENUM ('armed', 'triggered', 'disarmed');
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'event_kind' AND n.nspname = 'alarm'
) THEN
CREATE TYPE alarm.event_kind AS ENUM ('trigger', 'disarm');
END IF;
END
$$;
-- ---------------------------------------------------------------------------
-- alerts: one row per alert created by an admin (user A).
--
-- admin_token_hash sha256(adminToken); the plaintext admin token is returned
-- exactly once at creation and never stored.
-- subscribe_token stored PLAINTEXT on purpose: it is shared through the QR
-- code, and rotating it is a cheap UPDATE (token_version).
-- status last COMMANDED state. `active` is derived, never stored:
-- active = status = 'triggered' AND (auto_stop_seconds = 0
-- OR now() < triggered_at + auto_stop_seconds).
-- Deriving it keeps auto-stop stateless and multi-instance
-- safe (no timers, no background sweep).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS alarm.alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_user_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
message TEXT,
status alarm.alert_status NOT NULL DEFAULT 'armed',
admin_token_hash BYTEA NOT NULL,
subscribe_token VARCHAR(128) NOT NULL,
token_version INTEGER NOT NULL DEFAULT 1,
auto_stop_seconds INTEGER NOT NULL DEFAULT 0,
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
modified TIMESTAMPTZ NOT NULL DEFAULT NOW(),
triggered_at TIMESTAMPTZ,
disarmed_at TIMESTAMPTZ,
CONSTRAINT alerts_auto_stop_seconds_range CHECK (auto_stop_seconds >= 0 AND auto_stop_seconds <= 86400),
CONSTRAINT alerts_token_version_positive CHECK (token_version >= 1)
);
-- ---------------------------------------------------------------------------
-- subscriptions: one row per (alert, subscriber user, device). A user may
-- subscribe several devices; the same device is idempotent.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS alarm.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE,
subscriber_user_id UUID NOT NULL,
device_id VARCHAR(128) NOT NULL,
device_label VARCHAR(128),
platform VARCHAR(32),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_seen_at TIMESTAMPTZ,
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT subscriptions_alert_user_device_key UNIQUE (alert_id, subscriber_user_id, device_id)
);
-- ---------------------------------------------------------------------------
-- alarm_events: append-only trigger/disarm log. It is the source of truth for
-- alarmStatus (the polling fallback) and for the WS catch-up frame.
--
-- The AFTER INSERT trigger publishes the event on the `alarm_events` NOTIFY
-- channel, so the fan-out reaches EVERY service instance. NOTIFY is delivered
-- on COMMIT, so a frame can never be observed before the alert state change is
-- visible.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS alarm.alarm_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE,
kind alarm.event_kind NOT NULL,
actor_user_id UUID NOT NULL,
message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ---------------------------------------------------------------------------
-- Indexes. idx_alarm_events_latest backs the alarmStatus single-row lookup
-- (alert_id, newest first); the others back the ownership/subscriber checks.
-- ---------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_alerts_admin_user_id ON alarm.alerts(admin_user_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_alert_id ON alarm.subscriptions(alert_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON alarm.subscriptions(subscriber_user_id);
CREATE INDEX IF NOT EXISTS idx_alarm_events_alert_id ON alarm.alarm_events(alert_id);
CREATE INDEX IF NOT EXISTS idx_alarm_events_latest ON alarm.alarm_events(alert_id, created_at DESC, id DESC);
-- ---------------------------------------------------------------------------
-- modified maintenance for alerts.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION alarm.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.modified = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_alerts_modtime ON alarm.alerts;
CREATE TRIGGER update_alerts_modtime
BEFORE UPDATE ON alarm.alerts
FOR EACH ROW
EXECUTE FUNCTION alarm.update_updated_at_column();
-- ---------------------------------------------------------------------------
-- Cross-instance fan-out. The payload is the transport form of the WS frame:
-- the bridge formats serverTime from createdAtMs, so the in-process fast path
-- and the NOTIFY path agree on the wire shape.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION alarm.notify_alarm_event()
RETURNS TRIGGER AS $$
DECLARE
auto_stop INTEGER;
BEGIN
SELECT a.auto_stop_seconds INTO auto_stop FROM alarm.alerts a WHERE a.id = NEW.alert_id;
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, ''),
'autoStopSeconds', COALESCE(auto_stop, 0),
'createdAtMs', (EXTRACT(EPOCH FROM NEW.created_at) * 1000)::bigint
)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS notify_alarm_event ON alarm.alarm_events;
CREATE TRIGGER notify_alarm_event
AFTER INSERT ON alarm.alarm_events
FOR EACH ROW
EXECUTE FUNCTION alarm.notify_alarm_event();
-- ---------------------------------------------------------------------------
-- Least-privilege role, mirroring the Calendar/KeepNote house pattern. The
-- shared database currently authenticates as the shared `postgres` role
-- (contract rev 9), so these grants are inert-but-ready; they are idempotent
-- and safe to re-run.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'alarm_user') THEN
CREATE USER alarm_user WITH PASSWORD 'alarm';
END IF;
END
$$;
GRANT CONNECT ON DATABASE dcntrlized TO alarm_user;
GRANT USAGE ON SCHEMA alarm TO alarm_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA alarm TO alarm_user;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA alarm TO alarm_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA alarm GRANT ALL ON TABLES TO alarm_user;
+4
View File
@@ -0,0 +1,4 @@
# Dependencies for the database/seeds/*.py scripts. Installed by the
# compose 'seed' service (and used for host-side seeding via setup.py).
psycopg2-binary
bcrypt
+43
View File
@@ -0,0 +1,43 @@
# Copyright (C) 2026 Dcntrlized
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Example seed for the shared dcntrlized database.
Seeds run automatically after migrations complete, via the compose 'seed'
service: database/seeds is mounted read-only into a python container and every
*.py file here is executed in sorted order. Connection settings come from the
PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE environment variables set by that
service (psycopg2.connect() reads them implicitly).
Keep every seed idempotent: it re-runs on every `docker compose up` (use
ON CONFLICT DO NOTHING / IF NOT EXISTS style upserts, stable uuids, ...).
"""
import psycopg2
def main():
conn = psycopg2.connect()
cur = conn.cursor()
cur.execute("SELECT 1")
cur.fetchone()
cur.close()
conn.close()
print("001_seed_example.py: no-op seed. Replace with real seeds.")
if __name__ == "__main__":
main()