CI (SIAX Cloud) / quality (push) Successful in 50s
Security Scan (SIAX Cloud) / security (push) Successful in 45s
CI (SIAX Cloud) / security (push) Successful in 1m0s
CI (SIAX Cloud) / integration (push) Successful in 1m12s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m6s
CI (SIAX Cloud) / security (pull_request) Successful in 33s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 32s
CI (SIAX Cloud) / integration (pull_request) Successful in 47s
DRAFT -> PUBLISHED -> SUPERSEDED as a ledger (sql/010) rather than a column on
the frozen version: sql/008 already makes DashboardVersion immutable at the
database, and a lifecycle column there would have meant weakening that.
Rollback mints a NEW version carrying the old document and publishes it, so
"we went back to v3" is a timestamped fact (v5 = rollback_of_version 3) rather
than a hole where v4 used to be. It is verified by BI-P060 canonical hash, and
documentOf() refuses to serve a stored document whose hash no longer matches
the release — a dashboard that renders an unapproved definition is worse than
one that will not open.
The claim worth attacking is "only one release can be live". The application
check is read-then-write, so it is NOT the guarantee, and the evidence says so
in both directions against Postgres 16.15:
guard_absent partial unique index dropped -> BOTH concurrent publishes
commit, dashboard ends with TWO live versions
guard_present index in place -> loser fails 23505 on
opt_analytics_dashboard_release_one_published, one live
evidence/bi-p061/dashboard-release-race.json, reproducible with
scripts/bi-p061-release-race.mjs.
Two findings while writing the tests, both fixed here rather than papered over:
the one-draft-per-dashboard index makes the two-draft race impossible to even
construct (so the race is run as two create-and-publish transactions, which is
what a "save and publish" button does), and an UPDATE matching zero rows
raises nothing — which reads as "refused" to a careless test, so the
immutability test asserts the row exists first.
23 tests: 8 unit + 15 against live Postgres.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
6.8 KiB
PL/PgSQL
130 lines
6.8 KiB
PL/PgSQL
-- 0pt analytics: dashboard releases (BI-P061 — DASHBOARD VERSIONING).
|
|
--
|
|
-- Extends sql/008. Additive and idempotent (CREATE TABLE IF NOT EXISTS,
|
|
-- CREATE OR REPLACE FUNCTION, DROP TRIGGER IF EXISTS before CREATE TRIGGER),
|
|
-- so it replays onto an already-migrated database like 009 does.
|
|
--
|
|
-- Ordering: requires sql/008 (it references opt_analytics_dashboard_version).
|
|
--
|
|
-- WHY A SEPARATE TABLE RATHER THAN A `state` COLUMN ON THE VERSION.
|
|
-- sql/008 puts a BEFORE UPDATE OR DELETE trigger on
|
|
-- opt_analytics_dashboard_version, because BI-P002 lists DashboardVersion as
|
|
-- immutable. A lifecycle column on that row would need UPDATEs, so adding one
|
|
-- would mean weakening the immutability everything else depends on. Instead
|
|
-- the frozen DOCUMENT stays immutable in the version table, and the RELEASE —
|
|
-- the thing that legitimately moves DRAFT -> PUBLISHED -> SUPERSEDED — lives
|
|
-- here with its own, much narrower, mutation rule:
|
|
--
|
|
-- * `state` may only advance draft -> published -> superseded. Any other
|
|
-- transition RAISEs, including published -> draft ("un-publishing" is how
|
|
-- a rollback gets faked without leaving a trace) and superseded -> *.
|
|
-- * Every content column is frozen on UPDATE. A release may change state and
|
|
-- nothing else; repointing a published release at a different version is
|
|
-- the exact edit that would make "immutable release" a lie.
|
|
-- * DELETE is refused outright. Release history is a ledger; a rollback is
|
|
-- an entry in it, never the removal of one.
|
|
--
|
|
-- THE INVARIANT THAT NEEDS THE DATABASE: at most ONE published release per
|
|
-- dashboard. An application check reads, decides, then writes, and two
|
|
-- requests that interleave between the read and the write both decide "yes".
|
|
-- The partial unique index below turns that race into a constraint violation
|
|
-- for the loser. tests/analytics/dashboard-release-postgres.realinfra.test.ts
|
|
-- runs the race with the index DROPPED first and shows two published releases
|
|
-- for one dashboard, so the index is known to be what prevents it.
|
|
|
|
-- ── the release ledger ────────────────────────────────────────────────────
|
|
CREATE TABLE IF NOT EXISTS opt_analytics_dashboard_release (
|
|
release_id TEXT PRIMARY KEY,
|
|
dashboard_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_definition (dashboard_id),
|
|
dashboard_version_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_version (dashboard_version_id),
|
|
tenant_id TEXT NOT NULL,
|
|
project_id TEXT NOT NULL,
|
|
version INTEGER NOT NULL CHECK (version >= 1),
|
|
state TEXT NOT NULL CHECK (state IN ('draft', 'published', 'superseded')),
|
|
-- The BI-P060 canonical hash of the released document. Copied here so a
|
|
-- rollback can be VERIFIED (the new release must carry the hash of the
|
|
-- version it restores) without re-reading and re-hashing the document.
|
|
document_hash TEXT NOT NULL CHECK (document_hash ~ '^[0-9a-f]{64}$'),
|
|
-- Set when this release restores an earlier version. It is a NEW release of
|
|
-- a NEW version, never a resurrected old row -- that is what keeps the
|
|
-- history linear and the audit answerable ("what was live at 14:05?").
|
|
rollback_of_version INTEGER CHECK (rollback_of_version >= 1),
|
|
created_by TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
published_at TIMESTAMPTZ,
|
|
superseded_at TIMESTAMPTZ,
|
|
superseded_by_release_id TEXT,
|
|
UNIQUE (dashboard_id, version),
|
|
-- A state's timestamps must match the state. A 'published' row with no
|
|
-- published_at is the shape that makes an SLO or an audit answer wrong.
|
|
CONSTRAINT opt_analytics_dashboard_release_timestamps CHECK (
|
|
(state = 'draft' AND published_at IS NULL AND superseded_at IS NULL)
|
|
OR (state = 'published' AND published_at IS NOT NULL AND superseded_at IS NULL)
|
|
OR (state = 'superseded' AND published_at IS NOT NULL AND superseded_at IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS opt_analytics_dashboard_release_tenant_idx
|
|
ON opt_analytics_dashboard_release (tenant_id, project_id, dashboard_id, version);
|
|
|
|
-- At most one PUBLISHED release per dashboard. This is the concurrency
|
|
-- guarantee; the application's check is only an early, friendlier error.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS opt_analytics_dashboard_release_one_published
|
|
ON opt_analytics_dashboard_release (dashboard_id)
|
|
WHERE state = 'published';
|
|
|
|
-- At most one open DRAFT per dashboard. Two drafts race to publish and the
|
|
-- loser's edits vanish into a superseded release nobody asked for.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS opt_analytics_dashboard_release_one_draft
|
|
ON opt_analytics_dashboard_release (dashboard_id)
|
|
WHERE state = 'draft';
|
|
|
|
-- ── transition enforcement ────────────────────────────────────────────────
|
|
CREATE OR REPLACE FUNCTION opt_analytics_dashboard_release_guard() RETURNS trigger AS $$
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
RAISE EXCEPTION
|
|
'BI-P061: dashboard releases are a ledger; delete is refused (release %)', OLD.release_id
|
|
USING ERRCODE = 'restrict_violation';
|
|
END IF;
|
|
|
|
-- Content is frozen for the life of the release.
|
|
IF NEW.dashboard_id IS DISTINCT FROM OLD.dashboard_id
|
|
OR NEW.dashboard_version_id IS DISTINCT FROM OLD.dashboard_version_id
|
|
OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
|
|
OR NEW.project_id IS DISTINCT FROM OLD.project_id
|
|
OR NEW.version IS DISTINCT FROM OLD.version
|
|
OR NEW.document_hash IS DISTINCT FROM OLD.document_hash
|
|
OR NEW.rollback_of_version IS DISTINCT FROM OLD.rollback_of_version
|
|
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
|
|
RAISE EXCEPTION
|
|
'BI-P061: a dashboard release is immutable apart from its state (release %)', OLD.release_id
|
|
USING ERRCODE = 'restrict_violation';
|
|
END IF;
|
|
|
|
IF NEW.state IS DISTINCT FROM OLD.state THEN
|
|
IF NOT ((OLD.state = 'draft' AND NEW.state = 'published')
|
|
OR (OLD.state = 'published' AND NEW.state = 'superseded')) THEN
|
|
RAISE EXCEPTION
|
|
'BI-P061: illegal dashboard release transition % -> % (release %)',
|
|
OLD.state, NEW.state, OLD.release_id
|
|
USING ERRCODE = 'restrict_violation';
|
|
END IF;
|
|
END IF;
|
|
|
|
-- A published timestamp, once set, is history.
|
|
IF OLD.published_at IS NOT NULL AND NEW.published_at IS DISTINCT FROM OLD.published_at THEN
|
|
RAISE EXCEPTION
|
|
'BI-P061: published_at is history and cannot be rewritten (release %)', OLD.release_id
|
|
USING ERRCODE = 'restrict_violation';
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS opt_analytics_dashboard_release_guard ON opt_analytics_dashboard_release;
|
|
CREATE TRIGGER opt_analytics_dashboard_release_guard
|
|
BEFORE UPDATE OR DELETE ON opt_analytics_dashboard_release
|
|
FOR EACH ROW EXECUTE FUNCTION opt_analytics_dashboard_release_guard();
|