-- Migration: Create Inbox table for event-driven async coupling -- Purpose: Deduplication and idempotent consumption of outbox events -- PIT Safety: All records are immutable (append-only) CREATE TABLE outbox.inbox ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Foreign key to event outbox_id UUID NOT NULL, -- Consumer identification (e.g., "SignalR", "ApprovalQueue", "AuditLog") consumer_id VARCHAR(256) NOT NULL, -- Event metadata event_type VARCHAR(256) NOT NULL, payload JSONB NOT NULL, -- Processing status status VARCHAR(32) NOT NULL DEFAULT 'Pending', -- Pending, Processed, Failed error_message TEXT, -- Timestamps created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, attempted_at TIMESTAMP, processed_at TIMESTAMP, -- Constraints CONSTRAINT inbox_outbox_fk FOREIGN KEY (outbox_id) REFERENCES outbox.outbox(id) ON DELETE RESTRICT, -- Idempotency: Each consumer processes each event exactly once CONSTRAINT inbox_idempotency UNIQUE (outbox_id, consumer_id), -- Status constraint CONSTRAINT inbox_status_valid CHECK (status IN ('Pending', 'Processed', 'Failed')) ); -- Indexes for fast lookup CREATE INDEX inbox_status_idx ON outbox.inbox(status); CREATE INDEX inbox_created_idx ON outbox.inbox(created_at DESC); CREATE INDEX inbox_consumer_idx ON outbox.inbox(consumer_id); -- Constraint: If processed, must have processed_at CREATE OR REPLACE FUNCTION outbox.inbox_processed_check() RETURNS TRIGGER AS $$ BEGIN IF NEW.status = 'Processed' AND NEW.processed_at IS NULL THEN RAISE EXCEPTION 'processed_at must be set when status = Processed'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER inbox_processed_check_trigger BEFORE INSERT OR UPDATE ON outbox.inbox FOR EACH ROW EXECUTE FUNCTION outbox.inbox_processed_check();