171 lines
7.2 KiB
PL/PgSQL
171 lines
7.2 KiB
PL/PgSQL
-- Recoverable daily retirement and withdrawal of stale monthly publications.
|
|
-- PostgreSQL 15+. Migrations 008 through 021 remain immutable.
|
|
BEGIN;
|
|
DO $$
|
|
BEGIN
|
|
IF current_database() <> 'booking_test' THEN
|
|
RAISE EXCEPTION 'ARR daily lifecycle migration is allowed only in booking_test';
|
|
END IF;
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'finance' AND table_name = 'daily_versions'
|
|
AND column_name = 'excluded_pm_rows'
|
|
) OR to_regprocedure('reporting.protect_published_monthly_child()') IS NULL THEN
|
|
RAISE EXCEPTION 'ARR migrations 008 through 021 must be applied before 022';
|
|
END IF;
|
|
IF to_regclass('ingestion.daily_run_retirements') IS NOT NULL THEN
|
|
RAISE EXCEPTION 'ARR daily lifecycle migration 022 is already applied';
|
|
END IF;
|
|
END $$;
|
|
|
|
CREATE TABLE ingestion.daily_run_retirements (
|
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
processing_run_id bigint NOT NULL UNIQUE REFERENCES ingestion.processing_runs(id),
|
|
business_date date NOT NULL,
|
|
daily_version_id bigint REFERENCES finance.daily_versions(id),
|
|
current_removed boolean NOT NULL,
|
|
actor_username text NOT NULL CHECK (btrim(actor_username) <> '' AND char_length(actor_username) <= 255),
|
|
retired_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT daily_run_retirements_current_shape CHECK (NOT current_removed OR daily_version_id IS NOT NULL)
|
|
);
|
|
COMMENT ON TABLE ingestion.daily_run_retirements IS
|
|
'Append-only retirement receipts. Sources, processing runs, original Finance facts and prior publications are retained; no older daily version is automatically restored.';
|
|
|
|
CREATE FUNCTION ingestion.protect_daily_run_retirement()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF TG_OP <> 'INSERT' THEN
|
|
RAISE EXCEPTION 'daily retirement audit is immutable';
|
|
END IF;
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM ingestion.processing_runs AS run
|
|
WHERE run.id = NEW.processing_run_id AND run.pipeline_type = 'opera_daily'
|
|
AND run.business_date = NEW.business_date
|
|
AND run.run_status IN ('accepted', 'rejected', 'failed', 'cancelled')
|
|
) OR (NEW.daily_version_id IS NOT NULL AND NOT EXISTS (
|
|
SELECT 1 FROM finance.daily_versions AS version
|
|
WHERE version.id = NEW.daily_version_id AND version.processing_run_id = NEW.processing_run_id
|
|
AND (version.business_date = NEW.business_date OR version.version_status = 'rejected')
|
|
)) THEN
|
|
RAISE EXCEPTION 'daily retirement must reference its terminal run and exact daily version';
|
|
END IF;
|
|
IF EXISTS (
|
|
SELECT 1 FROM finance.current_daily_versions AS current_version
|
|
WHERE current_version.daily_version_id = NEW.daily_version_id
|
|
) THEN
|
|
RAISE EXCEPTION 'a retired daily version cannot remain the current daily source';
|
|
END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
CREATE TRIGGER daily_run_retirements_immutable
|
|
BEFORE INSERT OR UPDATE OR DELETE ON ingestion.daily_run_retirements
|
|
FOR EACH ROW EXECUTE FUNCTION ingestion.protect_daily_run_retirement();
|
|
|
|
CREATE OR REPLACE FUNCTION finance.validate_current_daily_version()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM finance.daily_versions AS version
|
|
WHERE version.id = NEW.daily_version_id
|
|
AND version.business_date = NEW.business_date
|
|
AND version.version_status = 'active'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM ingestion.daily_run_retirements AS retired
|
|
WHERE retired.processing_run_id = version.processing_run_id
|
|
)
|
|
) THEN
|
|
RAISE EXCEPTION 'current business date must reference an active non-retired daily version';
|
|
END IF;
|
|
RETURN NEW;
|
|
END $$;
|
|
|
|
ALTER TABLE reporting.monthly_runs
|
|
ADD COLUMN withdrawn_at timestamptz,
|
|
DROP CONSTRAINT monthly_runs_report_status_check,
|
|
DROP CONSTRAINT monthly_runs_status_shape,
|
|
DROP CONSTRAINT monthly_runs_snapshot_unique;
|
|
ALTER TABLE reporting.monthly_runs
|
|
ADD CONSTRAINT monthly_runs_report_status_check CHECK (report_status IN (
|
|
'reserved', 'active', 'superseded', 'failed', 'withdrawn'
|
|
)),
|
|
ADD CONSTRAINT monthly_runs_status_shape CHECK (
|
|
(withdrawn_at IS NULL AND (
|
|
(
|
|
report_status = 'reserved'
|
|
AND workbook_artifact_id IS NULL
|
|
AND result_artifact_id IS NULL
|
|
AND semantic_sha256 IS NULL
|
|
AND failure_code IS NULL
|
|
AND failure_message IS NULL
|
|
AND published_at IS NULL
|
|
AND superseded_at IS NULL
|
|
AND failed_at IS NULL
|
|
)
|
|
OR (
|
|
report_status = 'active'
|
|
AND workbook_artifact_id IS NOT NULL
|
|
AND result_artifact_id IS NOT NULL
|
|
AND semantic_sha256 IS NOT NULL
|
|
AND failure_code IS NULL
|
|
AND failure_message IS NULL
|
|
AND published_at IS NOT NULL
|
|
AND superseded_at IS NULL
|
|
AND failed_at IS NULL
|
|
)
|
|
OR (
|
|
report_status = 'superseded'
|
|
AND workbook_artifact_id IS NOT NULL
|
|
AND result_artifact_id IS NOT NULL
|
|
AND semantic_sha256 IS NOT NULL
|
|
AND failure_code IS NULL
|
|
AND failure_message IS NULL
|
|
AND published_at IS NOT NULL
|
|
AND superseded_at IS NOT NULL
|
|
AND failed_at IS NULL
|
|
)
|
|
OR (
|
|
report_status = 'failed'
|
|
AND workbook_artifact_id IS NULL
|
|
AND result_artifact_id IS NULL
|
|
AND semantic_sha256 IS NULL
|
|
AND failure_code IS NOT NULL
|
|
AND btrim(failure_code) <> ''
|
|
AND published_at IS NULL
|
|
AND superseded_at IS NULL
|
|
AND failed_at IS NOT NULL
|
|
)
|
|
)) OR (report_status = 'withdrawn' AND withdrawn_at IS NOT NULL)
|
|
);
|
|
CREATE UNIQUE INDEX monthly_runs_live_snapshot_unique
|
|
ON reporting.monthly_runs (period_start, source_snapshot_sha256)
|
|
WHERE report_status <> 'withdrawn';
|
|
|
|
CREATE OR REPLACE FUNCTION reporting.protect_published_monthly_child()
|
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
DECLARE parent_id bigint;
|
|
BEGIN
|
|
parent_id := COALESCE(OLD.report_id, NEW.report_id);
|
|
IF EXISTS (
|
|
SELECT 1 FROM reporting.monthly_runs AS run
|
|
WHERE run.id = parent_id AND run.report_status IN ('active', 'superseded', 'withdrawn')
|
|
) THEN
|
|
RAISE EXCEPTION 'published monthly report lineage and manifest are immutable';
|
|
END IF;
|
|
RETURN COALESCE(NEW, OLD);
|
|
END $$;
|
|
COMMENT ON COLUMN reporting.monthly_runs.withdrawn_at IS
|
|
'Publication removed from downloads because a current daily source was retired. Historical artifacts and lineage remain intact; reserved builds cannot later publish.';
|
|
|
|
-- Existing consuming application role; no source, artifact or Finance-fact
|
|
-- DELETE privileges are added. Differently named roles require equivalent
|
|
-- explicit grants in the release deployment, never PUBLIC or web read roles.
|
|
DO $$
|
|
BEGIN
|
|
IF to_regrole('arr_app') IS NOT NULL THEN
|
|
EXECUTE 'GRANT SELECT, INSERT ON ingestion.daily_run_retirements TO arr_app';
|
|
EXECUTE 'GRANT USAGE, SELECT ON SEQUENCE ingestion.daily_run_retirements_id_seq TO arr_app';
|
|
EXECUTE 'GRANT DELETE ON finance.current_daily_versions TO arr_app';
|
|
END IF;
|
|
END $$;
|
|
COMMIT;
|