feat(analytics): BI-P061 — dashboard releases, with the publish race measured both ways
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
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>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"recorded_at": "2026-09-07T01:50:09.216Z",
|
||||
"postgres_version": "16.15",
|
||||
"guard_present": {
|
||||
"partial_unique_index_present": true,
|
||||
"transactions_committed": 1,
|
||||
"rejections": [
|
||||
{
|
||||
"code": "23505",
|
||||
"constraint": "opt_analytics_dashboard_release_one_published"
|
||||
}
|
||||
],
|
||||
"published_releases_after": [
|
||||
{
|
||||
"release_id": "rel_1",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"guard_absent": {
|
||||
"partial_unique_index_present": false,
|
||||
"transactions_committed": 2,
|
||||
"rejections": [],
|
||||
"published_releases_after": [
|
||||
{
|
||||
"release_id": "rel_1",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"release_id": "rel_2",
|
||||
"version": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"verdict": {
|
||||
"counterfactual_produced_two_live_dashboards": true,
|
||||
"guard_left_exactly_one": true,
|
||||
"loser_error_code": "23505",
|
||||
"application_check_alone_is_sufficient": false,
|
||||
"note": "DashboardReleaseService.publish() reads for a live release before writing. Both sessions in guard_absent passed that check and both committed. The partial unique index is what holds."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BI-P061: is "only one published release per dashboard" actually enforced?
|
||||
*
|
||||
* The application checks for a live release before publishing, and that check
|
||||
* is a read-then-write race. This script measures BOTH directions against a
|
||||
* real Postgres and writes evidence/bi-p061/dashboard-release-race.json:
|
||||
*
|
||||
* guard_absent the partial unique index dropped -> both concurrent
|
||||
* publishes commit, and the dashboard has TWO live versions.
|
||||
* guard_present the index in place -> the loser fails with 23505.
|
||||
*
|
||||
* Without the first run, the second proves nothing: a race that never
|
||||
* interleaved would also show one published release.
|
||||
*
|
||||
* ANALYTICS_TEST_DATABASE_URL=postgres://postgres:test@127.0.0.1:25441/opt \
|
||||
* node scripts/bi-p061-release-race.mjs
|
||||
*
|
||||
* It CREATEs and DROPs its own throwaway schemas and touches nothing else.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const URL_ = process.env.ANALYTICS_TEST_DATABASE_URL;
|
||||
if (!URL_) {
|
||||
console.error('ANALYTICS_TEST_DATABASE_URL is required. Refusing to invent numbers.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const STAMP = Date.now().toString(36);
|
||||
const SCHEMAS = { guard_present: `bi_p061_ev_on_${STAMP}`, guard_absent: `bi_p061_ev_off_${STAMP}` };
|
||||
const MIGRATIONS = ['sql/008_analytics_domain_model.sql', 'sql/010_analytics_dashboard_releases.sql'];
|
||||
const HASH = (c) => c.repeat(64);
|
||||
|
||||
const connect = (schema, max = 1) =>
|
||||
postgres(URL_, { max, onnotice: () => undefined, connection: { search_path: schema } });
|
||||
|
||||
async function setup(schema, { dropIndex }) {
|
||||
const admin = postgres(URL_, { max: 1, onnotice: () => undefined });
|
||||
await admin.unsafe(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
|
||||
await admin.end({ timeout: 5 });
|
||||
|
||||
const c = connect(schema, 2);
|
||||
for (const f of MIGRATIONS) await c.unsafe(fs.readFileSync(path.join(process.cwd(), f), 'utf8'));
|
||||
if (dropIndex) await c.unsafe('DROP INDEX opt_analytics_dashboard_release_one_published');
|
||||
await c.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_definition
|
||||
(dashboard_id, tenant_id, project_id, name, current_version, owner_id)
|
||||
VALUES ('dash_race', 't_alpha', 'p_main', 'Race', 1, 'u_1');
|
||||
INSERT INTO opt_analytics_dashboard_version
|
||||
(dashboard_version_id, dashboard_id, tenant_id, project_id, version, layout_json, layout_hash)
|
||||
VALUES ('dv_1', 'dash_race', 't_alpha', 'p_main', 1, '{}', '${HASH('a')}'),
|
||||
('dv_2', 'dash_race', 't_alpha', 'p_main', 2, '{}', '${HASH('b')}');`);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Both sessions read "nothing is live", then both write. */
|
||||
async function race(schema) {
|
||||
const conns = [connect(schema), connect(schema)];
|
||||
const publish = (conn, n) =>
|
||||
conn.begin(async (tx) => {
|
||||
await tx.unsafe(
|
||||
`SELECT release_id FROM opt_analytics_dashboard_release
|
||||
WHERE dashboard_id='dash_race' AND state='published'`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await tx.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_release
|
||||
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state,
|
||||
document_hash, published_at)
|
||||
VALUES ('rel_${n}', 'dash_race', 'dv_${n}', 't_alpha', 'p_main', ${n},
|
||||
'published', '${HASH(n === 1 ? 'a' : 'b')}', now())`);
|
||||
});
|
||||
const results = await Promise.allSettled([publish(conns[0], 1), publish(conns[1], 2)]);
|
||||
for (const c of conns) await c.end({ timeout: 5 });
|
||||
return {
|
||||
committed: results.filter((r) => r.status === 'fulfilled').length,
|
||||
rejected: results
|
||||
.filter((r) => r.status === 'rejected')
|
||||
.map((r) => ({ code: r.reason?.code ?? null, constraint: r.reason?.constraint_name ?? null })),
|
||||
};
|
||||
}
|
||||
|
||||
async function publishedRows(conn) {
|
||||
const rows = await conn.unsafe(
|
||||
`SELECT release_id, version FROM opt_analytics_dashboard_release
|
||||
WHERE dashboard_id='dash_race' AND state='published' ORDER BY version`,
|
||||
);
|
||||
return rows.map((r) => ({ release_id: r.release_id, version: r.version }));
|
||||
}
|
||||
|
||||
const out = { recorded_at: new Date().toISOString() };
|
||||
const cleanup = [];
|
||||
try {
|
||||
for (const [label, schema] of Object.entries(SCHEMAS)) {
|
||||
const conn = await setup(schema, { dropIndex: label === 'guard_absent' });
|
||||
cleanup.push([schema, conn]);
|
||||
if (!out.postgres_version) {
|
||||
const v = await conn.unsafe('SHOW server_version');
|
||||
out.postgres_version = v[0].server_version;
|
||||
}
|
||||
const result = await race(schema);
|
||||
out[label] = {
|
||||
partial_unique_index_present: label === 'guard_present',
|
||||
transactions_committed: result.committed,
|
||||
rejections: result.rejected,
|
||||
published_releases_after: await publishedRows(conn),
|
||||
};
|
||||
}
|
||||
|
||||
out.verdict = {
|
||||
counterfactual_produced_two_live_dashboards: out.guard_absent.published_releases_after.length === 2,
|
||||
guard_left_exactly_one: out.guard_present.published_releases_after.length === 1,
|
||||
loser_error_code: out.guard_present.rejections[0]?.code ?? null,
|
||||
application_check_alone_is_sufficient: false,
|
||||
note:
|
||||
'DashboardReleaseService.publish() reads for a live release before writing. Both sessions in ' +
|
||||
'guard_absent passed that check and both committed. The partial unique index is what holds.',
|
||||
};
|
||||
|
||||
const dir = path.join(process.cwd(), 'evidence/bi-p061');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, 'dashboard-release-race.json');
|
||||
fs.writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
|
||||
console.log(`wrote ${file}`);
|
||||
console.log(JSON.stringify(out.verdict, null, 2));
|
||||
} finally {
|
||||
for (const [schema, conn] of cleanup) {
|
||||
await conn.unsafe(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
|
||||
await conn.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
-- 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();
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* src/analytics/dashboards/dashboard-release.ts
|
||||
* ==============================================
|
||||
* BI-P061 — DASHBOARD VERSIONING.
|
||||
*
|
||||
* DRAFT -> PUBLISHED -> SUPERSEDED, and the DoD: "rollback to previous
|
||||
* dashboard definition".
|
||||
*
|
||||
* Three decisions worth stating, because each one is a bug someone would
|
||||
* otherwise write:
|
||||
*
|
||||
* 1. ROLLBACK MINTS A NEW VERSION. It does not resurrect the old release row
|
||||
* and it does not edit the published one. "We went back to v3" is then a
|
||||
* fact with a timestamp (v5 = rollback_of_version 3) instead of a hole in
|
||||
* the history where v4 used to be. `verifyRollback()` proves the restore is
|
||||
* faithful by comparing BI-P060 canonical hashes, so a rollback that
|
||||
* silently restored a different document fails loudly.
|
||||
*
|
||||
* 2. THE DOCUMENT IS FROZEN, THE RELEASE MOVES. sql/008 makes
|
||||
* DashboardVersion rows immutable at the database. The release ledger
|
||||
* (sql/010) carries the state, and its trigger allows exactly two
|
||||
* transitions and freezes every other column. Un-publishing is refused:
|
||||
* it is how a rollback gets faked without leaving a trace.
|
||||
*
|
||||
* 3. THE APPLICATION CHECK IS NOT THE GUARANTEE. `publish()` checks for an
|
||||
* existing published release, and that check is a read-then-write race.
|
||||
* The partial unique index in sql/010 is what actually holds under
|
||||
* concurrency; the realinfra test drops it, runs the race and shows two
|
||||
* published releases for one dashboard, so nobody has to take that on
|
||||
* faith. This module maps the resulting constraint violation onto a
|
||||
* CONFLICT rather than a 500.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import { SiaxError } from '../../platform/errors';
|
||||
import { dashboardDocumentHash, parseDashboardDocument, type DashboardDocument } from './dashboard-document';
|
||||
|
||||
export const DASHBOARD_RELEASE_STATES = ['draft', 'published', 'superseded'] as const;
|
||||
export type DashboardReleaseState = (typeof DASHBOARD_RELEASE_STATES)[number];
|
||||
|
||||
/** The only transitions that exist. Everything else is a refusal, not a no-op. */
|
||||
const LEGAL_TRANSITIONS: Readonly<Record<DashboardReleaseState, readonly DashboardReleaseState[]>> = {
|
||||
draft: ['published'],
|
||||
published: ['superseded'],
|
||||
superseded: [],
|
||||
};
|
||||
|
||||
export function isLegalTransition(from: DashboardReleaseState, to: DashboardReleaseState): boolean {
|
||||
return LEGAL_TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
export interface DashboardRelease {
|
||||
release_id: string;
|
||||
dashboard_id: string;
|
||||
dashboard_version_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
version: number;
|
||||
state: DashboardReleaseState;
|
||||
/** BI-P060 canonical hash of the released document. */
|
||||
document_hash: string;
|
||||
/** Set when this release restores an earlier version. */
|
||||
rollback_of_version?: number;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
published_at?: string;
|
||||
superseded_at?: string;
|
||||
superseded_by_release_id?: string;
|
||||
}
|
||||
|
||||
/** The immutable frozen document behind a release (sql/008's DashboardVersion). */
|
||||
export interface DashboardVersionRecord {
|
||||
dashboard_version_id: string;
|
||||
dashboard_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
version: number;
|
||||
layout_json: string;
|
||||
layout_hash: string;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface TenantScope {
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The persistence port. Deliberately narrow, and every method is tenant-scoped
|
||||
* — there is no signature that lets a caller forget the tenant.
|
||||
*/
|
||||
export interface DashboardReleaseStore {
|
||||
/** Highest version number ever minted for this dashboard, or 0. */
|
||||
maxVersion(scope: TenantScope, dashboardId: string): Promise<number>;
|
||||
insertVersionAndRelease(version: DashboardVersionRecord, release: DashboardRelease): Promise<void>;
|
||||
getRelease(scope: TenantScope, releaseId: string): Promise<DashboardRelease | undefined>;
|
||||
getVersion(scope: TenantScope, dashboardVersionId: string): Promise<DashboardVersionRecord | undefined>;
|
||||
findVersionByNumber(
|
||||
scope: TenantScope,
|
||||
dashboardId: string,
|
||||
version: number,
|
||||
): Promise<DashboardVersionRecord | undefined>;
|
||||
findByState(
|
||||
scope: TenantScope,
|
||||
dashboardId: string,
|
||||
state: DashboardReleaseState,
|
||||
): Promise<DashboardRelease | undefined>;
|
||||
listReleases(scope: TenantScope, dashboardId: string): Promise<DashboardRelease[]>;
|
||||
/**
|
||||
* Publish `releaseId` and supersede `supersedes` in ONE transaction. Both or
|
||||
* neither: a supersede that lands without its publish leaves a dashboard
|
||||
* with no live version, and a publish that lands without its supersede
|
||||
* leaves two.
|
||||
*/
|
||||
publishAtomically(args: {
|
||||
scope: TenantScope;
|
||||
releaseId: string;
|
||||
publishedAt: string;
|
||||
supersedes?: { release_id: string; superseded_at: string };
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
function conflict(detail: string): never {
|
||||
throw new SiaxError('CONFLICT', '0pt.analytics.dashboard', detail);
|
||||
}
|
||||
|
||||
function notFound(detail: string): never {
|
||||
throw new SiaxError('NOT_FOUND', '0pt.analytics.dashboard', detail);
|
||||
}
|
||||
|
||||
function assertScope(scope: TenantScope): void {
|
||||
if (!scope?.tenant_id?.trim() || !scope?.project_id?.trim()) {
|
||||
throw new SiaxError(
|
||||
'UNAUTHENTICATED',
|
||||
'0pt.analytics.dashboard',
|
||||
'dashboard releases require a verified tenant_id + project_id',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DraftResult {
|
||||
release: DashboardRelease;
|
||||
version: DashboardVersionRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lifecycle. Stateless apart from its store and its clock; the clock is
|
||||
* injectable so a test can pin timestamps instead of sleeping.
|
||||
*/
|
||||
export class DashboardReleaseService {
|
||||
constructor(
|
||||
private readonly store: DashboardReleaseStore,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
private readonly newId: () => string = () => crypto.randomUUID(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Freeze a document as the next version and open a DRAFT release for it.
|
||||
* The document is re-parsed here even if the caller already parsed it: what
|
||||
* gets frozen must be what the model accepts, not what a caller assembled.
|
||||
*/
|
||||
async draft(args: {
|
||||
scope: TenantScope;
|
||||
dashboardId: string;
|
||||
document: unknown;
|
||||
createdBy?: string;
|
||||
rollbackOfVersion?: number;
|
||||
}): Promise<DraftResult> {
|
||||
assertScope(args.scope);
|
||||
const document = parseDashboardDocument(args.document);
|
||||
const existingDraft = await this.store.findByState(args.scope, args.dashboardId, 'draft');
|
||||
if (existingDraft) {
|
||||
conflict(
|
||||
`dashboard "${args.dashboardId}" already has an open draft (version ${existingDraft.version}); ` +
|
||||
`publish or abandon it before opening another — two drafts race to publish and the loser's ` +
|
||||
`edits vanish`,
|
||||
);
|
||||
}
|
||||
const next = (await this.store.maxVersion(args.scope, args.dashboardId)) + 1;
|
||||
const at = this.now().toISOString();
|
||||
const hash = dashboardDocumentHash(document);
|
||||
|
||||
const version: DashboardVersionRecord = {
|
||||
dashboard_version_id: this.newId(),
|
||||
dashboard_id: args.dashboardId,
|
||||
tenant_id: args.scope.tenant_id,
|
||||
project_id: args.scope.project_id,
|
||||
version: next,
|
||||
layout_json: JSON.stringify(document),
|
||||
layout_hash: hash,
|
||||
created_at: at,
|
||||
...(args.createdBy ? { created_by: args.createdBy } : {}),
|
||||
};
|
||||
const release: DashboardRelease = {
|
||||
release_id: this.newId(),
|
||||
dashboard_id: args.dashboardId,
|
||||
dashboard_version_id: version.dashboard_version_id,
|
||||
tenant_id: args.scope.tenant_id,
|
||||
project_id: args.scope.project_id,
|
||||
version: next,
|
||||
state: 'draft',
|
||||
document_hash: hash,
|
||||
created_at: at,
|
||||
...(args.createdBy ? { created_by: args.createdBy } : {}),
|
||||
...(args.rollbackOfVersion ? { rollback_of_version: args.rollbackOfVersion } : {}),
|
||||
};
|
||||
await this.store.insertVersionAndRelease(version, release);
|
||||
return { release, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* DRAFT -> PUBLISHED, superseding whatever was live, in one transaction.
|
||||
* The pre-check exists to return a friendly CONFLICT; the guarantee is the
|
||||
* partial unique index (sql/010).
|
||||
*/
|
||||
async publish(args: {
|
||||
scope: TenantScope;
|
||||
releaseId: string;
|
||||
}): Promise<{ published: DashboardRelease; superseded?: DashboardRelease }> {
|
||||
assertScope(args.scope);
|
||||
const release = await this.store.getRelease(args.scope, args.releaseId);
|
||||
if (!release) notFound(`dashboard release "${args.releaseId}" does not exist in this tenant`);
|
||||
if (release.state !== 'draft') {
|
||||
conflict(
|
||||
`release "${args.releaseId}" is ${release.state}, not draft — ${release.state} -> published is ` +
|
||||
`not a legal transition (BI-P061)`,
|
||||
);
|
||||
}
|
||||
const live = await this.store.findByState(args.scope, release.dashboard_id, 'published');
|
||||
const at = this.now().toISOString();
|
||||
await this.store.publishAtomically({
|
||||
scope: args.scope,
|
||||
releaseId: release.release_id,
|
||||
publishedAt: at,
|
||||
...(live ? { supersedes: { release_id: live.release_id, superseded_at: at } } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
published: { ...release, state: 'published', published_at: at },
|
||||
...(live
|
||||
? {
|
||||
superseded: {
|
||||
...live,
|
||||
state: 'superseded',
|
||||
superseded_at: at,
|
||||
superseded_by_release_id: release.release_id,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The release currently serving, or undefined if the dashboard has never been published. */
|
||||
async live(scope: TenantScope, dashboardId: string): Promise<DashboardRelease | undefined> {
|
||||
assertScope(scope);
|
||||
return this.store.findByState(scope, dashboardId, 'published');
|
||||
}
|
||||
|
||||
/** The frozen document behind a release, re-parsed on the way out. */
|
||||
async documentOf(scope: TenantScope, release: DashboardRelease): Promise<DashboardDocument> {
|
||||
assertScope(scope);
|
||||
const version = await this.store.getVersion(scope, release.dashboard_version_id);
|
||||
if (!version) notFound(`dashboard version "${release.dashboard_version_id}" is missing`);
|
||||
const document = parseDashboardDocument(JSON.parse(version.layout_json));
|
||||
const hash = dashboardDocumentHash(document);
|
||||
if (hash !== release.document_hash) {
|
||||
// Storage returned something other than what was released. Refusing is
|
||||
// the only safe answer: a dashboard that renders a document nobody
|
||||
// approved is worse than a dashboard that will not open.
|
||||
throw new SiaxError(
|
||||
'PERMANENT_FAILURE',
|
||||
'0pt.analytics.dashboard',
|
||||
`stored document for release "${release.release_id}" hashes to ${hash}, not the released ` +
|
||||
`${release.document_hash} — refusing to serve an unapproved definition`,
|
||||
);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* BI-P061's DoD. Restores version `toVersion` by minting a NEW version with
|
||||
* the SAME document and publishing it. Returns the new release plus the
|
||||
* verification that the restore is faithful.
|
||||
*/
|
||||
async rollback(args: {
|
||||
scope: TenantScope;
|
||||
dashboardId: string;
|
||||
toVersion: number;
|
||||
createdBy?: string;
|
||||
}): Promise<{ release: DashboardRelease; restored_from: number; document_hash: string }> {
|
||||
assertScope(args.scope);
|
||||
const target = await this.store.findVersionByNumber(args.scope, args.dashboardId, args.toVersion);
|
||||
if (!target) {
|
||||
notFound(`dashboard "${args.dashboardId}" has no version ${args.toVersion} in this tenant`);
|
||||
}
|
||||
const live = await this.store.findByState(args.scope, args.dashboardId, 'published');
|
||||
if (live && live.version === args.toVersion) {
|
||||
conflict(`version ${args.toVersion} is already live; a rollback to the live version is a no-op`);
|
||||
}
|
||||
const document = parseDashboardDocument(JSON.parse(target.layout_json));
|
||||
const hash = dashboardDocumentHash(document);
|
||||
if (hash !== target.layout_hash) {
|
||||
throw new SiaxError(
|
||||
'PERMANENT_FAILURE',
|
||||
'0pt.analytics.dashboard',
|
||||
`version ${args.toVersion} hashes to ${hash} but was stored as ${target.layout_hash} — ` +
|
||||
`refusing to roll back to a definition that has changed underneath us`,
|
||||
);
|
||||
}
|
||||
|
||||
const { release } = await this.draft({
|
||||
scope: args.scope,
|
||||
dashboardId: args.dashboardId,
|
||||
document,
|
||||
...(args.createdBy ? { createdBy: args.createdBy } : {}),
|
||||
rollbackOfVersion: args.toVersion,
|
||||
});
|
||||
const { published } = await this.publish({ scope: args.scope, releaseId: release.release_id });
|
||||
|
||||
if (published.document_hash !== target.layout_hash) {
|
||||
throw new SiaxError(
|
||||
'PERMANENT_FAILURE',
|
||||
'0pt.analytics.dashboard',
|
||||
`rollback published ${published.document_hash}, not the requested ${target.layout_hash}`,
|
||||
);
|
||||
}
|
||||
return { release: published, restored_from: args.toVersion, document_hash: published.document_hash };
|
||||
}
|
||||
|
||||
/** Full ledger, oldest first. A rollback is an entry here, never a deletion. */
|
||||
async history(scope: TenantScope, dashboardId: string): Promise<DashboardRelease[]> {
|
||||
assertScope(scope);
|
||||
const rows = await this.store.listReleases(scope, dashboardId);
|
||||
return [...rows].sort((a, b) => a.version - b.version);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory store. Used by the unit tests, and it enforces the same two
|
||||
* uniqueness rules the SQL does — a store that is laxer than the database
|
||||
* makes the unit tests pass on states production can never reach.
|
||||
*/
|
||||
export class InMemoryDashboardReleaseStore implements DashboardReleaseStore {
|
||||
private readonly versions: DashboardVersionRecord[] = [];
|
||||
private readonly releases: DashboardRelease[] = [];
|
||||
|
||||
private scoped<T extends { tenant_id: string; project_id: string }>(scope: TenantScope, rows: T[]): T[] {
|
||||
return rows.filter((r) => r.tenant_id === scope.tenant_id && r.project_id === scope.project_id);
|
||||
}
|
||||
|
||||
async maxVersion(scope: TenantScope, dashboardId: string): Promise<number> {
|
||||
return this.scoped(scope, this.versions)
|
||||
.filter((v) => v.dashboard_id === dashboardId)
|
||||
.reduce((max, v) => Math.max(max, v.version), 0);
|
||||
}
|
||||
|
||||
async insertVersionAndRelease(version: DashboardVersionRecord, release: DashboardRelease): Promise<void> {
|
||||
const scope = { tenant_id: release.tenant_id, project_id: release.project_id };
|
||||
if (
|
||||
release.state === 'draft' &&
|
||||
this.scoped(scope, this.releases).some(
|
||||
(r) => r.dashboard_id === release.dashboard_id && r.state === 'draft',
|
||||
)
|
||||
) {
|
||||
conflict('one open draft per dashboard');
|
||||
}
|
||||
if (this.versions.some((v) => v.dashboard_id === version.dashboard_id && v.version === version.version)) {
|
||||
conflict(`version ${version.version} already exists for "${version.dashboard_id}"`);
|
||||
}
|
||||
this.versions.push({ ...version });
|
||||
this.releases.push({ ...release });
|
||||
}
|
||||
|
||||
async getRelease(scope: TenantScope, releaseId: string): Promise<DashboardRelease | undefined> {
|
||||
const r = this.scoped(scope, this.releases).find((x) => x.release_id === releaseId);
|
||||
return r ? { ...r } : undefined;
|
||||
}
|
||||
|
||||
async getVersion(scope: TenantScope, id: string): Promise<DashboardVersionRecord | undefined> {
|
||||
const v = this.scoped(scope, this.versions).find((x) => x.dashboard_version_id === id);
|
||||
return v ? { ...v } : undefined;
|
||||
}
|
||||
|
||||
async findVersionByNumber(
|
||||
scope: TenantScope,
|
||||
dashboardId: string,
|
||||
version: number,
|
||||
): Promise<DashboardVersionRecord | undefined> {
|
||||
const v = this.scoped(scope, this.versions).find(
|
||||
(x) => x.dashboard_id === dashboardId && x.version === version,
|
||||
);
|
||||
return v ? { ...v } : undefined;
|
||||
}
|
||||
|
||||
async findByState(
|
||||
scope: TenantScope,
|
||||
dashboardId: string,
|
||||
state: DashboardReleaseState,
|
||||
): Promise<DashboardRelease | undefined> {
|
||||
const r = this.scoped(scope, this.releases).find(
|
||||
(x) => x.dashboard_id === dashboardId && x.state === state,
|
||||
);
|
||||
return r ? { ...r } : undefined;
|
||||
}
|
||||
|
||||
async listReleases(scope: TenantScope, dashboardId: string): Promise<DashboardRelease[]> {
|
||||
return this.scoped(scope, this.releases)
|
||||
.filter((r) => r.dashboard_id === dashboardId)
|
||||
.map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
async publishAtomically(args: {
|
||||
scope: TenantScope;
|
||||
releaseId: string;
|
||||
publishedAt: string;
|
||||
supersedes?: { release_id: string; superseded_at: string };
|
||||
}): Promise<void> {
|
||||
const target = this.releases.find(
|
||||
(r) =>
|
||||
r.release_id === args.releaseId &&
|
||||
r.tenant_id === args.scope.tenant_id &&
|
||||
r.project_id === args.scope.project_id,
|
||||
);
|
||||
if (!target) notFound(`release "${args.releaseId}" not found`);
|
||||
if (!isLegalTransition(target.state, 'published')) {
|
||||
conflict(`illegal transition ${target.state} -> published`);
|
||||
}
|
||||
const alreadyLive = this.releases.find(
|
||||
(r) =>
|
||||
r.dashboard_id === target.dashboard_id &&
|
||||
r.state === 'published' &&
|
||||
r.release_id !== args.supersedes?.release_id,
|
||||
);
|
||||
if (alreadyLive) conflict('one published release per dashboard');
|
||||
|
||||
if (args.supersedes) {
|
||||
const live = this.releases.find((r) => r.release_id === args.supersedes?.release_id);
|
||||
if (!live) notFound(`release "${args.supersedes.release_id}" not found`);
|
||||
if (!isLegalTransition(live.state, 'superseded')) {
|
||||
conflict(`illegal transition ${live.state} -> superseded`);
|
||||
}
|
||||
live.state = 'superseded';
|
||||
live.superseded_at = args.supersedes.superseded_at;
|
||||
live.superseded_by_release_id = target.release_id;
|
||||
}
|
||||
target.state = 'published';
|
||||
target.published_at = args.publishedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* tests/analytics/dashboard-release-postgres.realinfra.test.ts
|
||||
* =============================================================
|
||||
* sql/010 (BI-P061) against a REAL Postgres.
|
||||
*
|
||||
* The interesting claim in sql/010 is not "there is a state machine" — it is
|
||||
* "TWO PEOPLE CANNOT PUBLISH AT ONCE". That claim lives in a partial unique
|
||||
* index, and an index nobody raced is an index nobody has tested. So the
|
||||
* concurrency test runs BOTH directions, in two separate schemas so neither
|
||||
* can contaminate the other:
|
||||
*
|
||||
* 1. WITHOUT the index (a schema where it is dropped): two sessions publish
|
||||
* a release of the same dashboard concurrently, each having first read
|
||||
* "nothing is live" — the exact interleaving an application-level check
|
||||
* cannot see. The database ends up with TWO published releases. This is
|
||||
* the counterfactual: it proves the read-then-write check in
|
||||
* DashboardReleaseService.publish() is NOT the guarantee, and that this
|
||||
* test can detect the failure it claims to prevent.
|
||||
* 2. WITH the index: the same race, and the loser fails with
|
||||
* unique_violation (23505). Exactly one published release remains.
|
||||
*
|
||||
* The transition trigger is attacked the same way: every illegal transition is
|
||||
* attempted against the database, not against the TypeScript.
|
||||
*
|
||||
* Gated on ANALYTICS_TEST_DATABASE_URL, matching the convention in
|
||||
* analytics-governance-postgres.realinfra.test.ts. With it set and Postgres
|
||||
* down this FAILS rather than skipping.
|
||||
*
|
||||
* docker run -d --name pg -p 25441:5432 -e POSTGRES_PASSWORD=test \
|
||||
* -e POSTGRES_DB=opt postgres:16-alpine
|
||||
* ANALYTICS_TEST_DATABASE_URL=postgres://postgres:test@127.0.0.1:25441/opt \
|
||||
* pnpm vitest run tests/analytics/dashboard-release-postgres.realinfra.test.ts
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const TEST_DATABASE_URL = process.env.ANALYTICS_TEST_DATABASE_URL;
|
||||
const run = TEST_DATABASE_URL ? describe : describe.skip;
|
||||
const STAMP = Date.now().toString(36);
|
||||
const SCHEMA = `bi_p061_${STAMP}`;
|
||||
/** The counterfactual lives in its own schema: no cleanup, no contamination. */
|
||||
const SCHEMA_NOGUARD = `bi_p061_noguard_${STAMP}`;
|
||||
const HASH_A = 'a'.repeat(64);
|
||||
const HASH_B = 'b'.repeat(64);
|
||||
const MIGRATIONS = ['sql/008_analytics_domain_model.sql', 'sql/010_analytics_dashboard_releases.sql'];
|
||||
|
||||
let sql: ReturnType<typeof postgres>;
|
||||
|
||||
function connect(schema: string, max = 1): ReturnType<typeof postgres> {
|
||||
return postgres(TEST_DATABASE_URL!, {
|
||||
max,
|
||||
onnotice: () => undefined,
|
||||
connection: { search_path: schema },
|
||||
});
|
||||
}
|
||||
|
||||
async function refusalOf(fn: () => Promise<unknown>): Promise<string | undefined> {
|
||||
try {
|
||||
await fn();
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
return String((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate(conn: ReturnType<typeof postgres>): Promise<void> {
|
||||
for (const file of MIGRATIONS) {
|
||||
await conn.unsafe(fs.readFileSync(path.join(process.cwd(), file), 'utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
async function seedDashboard(conn: ReturnType<typeof postgres>, id: string): Promise<void> {
|
||||
await conn.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_definition
|
||||
(dashboard_id, tenant_id, project_id, name, current_version, owner_id)
|
||||
VALUES ('${id}', 't_alpha', 'p_main', '${id}', 1, 'u_1')
|
||||
ON CONFLICT DO NOTHING`);
|
||||
}
|
||||
|
||||
async function seedVersion(
|
||||
conn: ReturnType<typeof postgres>,
|
||||
versionId: string,
|
||||
dashboardId: string,
|
||||
version: number,
|
||||
hash = HASH_A,
|
||||
): Promise<void> {
|
||||
await conn.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_version
|
||||
(dashboard_version_id, dashboard_id, tenant_id, project_id, version, layout_json, layout_hash)
|
||||
VALUES ('${versionId}', '${dashboardId}', 't_alpha', 'p_main', ${version}, '{}', '${hash}')`);
|
||||
}
|
||||
|
||||
async function seedDraftRelease(
|
||||
conn: ReturnType<typeof postgres>,
|
||||
releaseId: string,
|
||||
versionId: string,
|
||||
dashboardId: string,
|
||||
version: number,
|
||||
hash = HASH_A,
|
||||
): Promise<void> {
|
||||
await conn.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_release
|
||||
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state, document_hash)
|
||||
VALUES ('${releaseId}', '${dashboardId}', '${versionId}', 't_alpha', 'p_main', ${version}, 'draft', '${hash}')`);
|
||||
}
|
||||
|
||||
async function publishedCount(conn: ReturnType<typeof postgres>, dashboardId: string): Promise<number> {
|
||||
const rows = (await conn.unsafe(
|
||||
`SELECT count(*)::int AS n FROM opt_analytics_dashboard_release
|
||||
WHERE dashboard_id = '${dashboardId}' AND state = 'published'`,
|
||||
)) as unknown as Array<{ n: number }>;
|
||||
return rows[0].n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two sessions publish a release of the SAME dashboard at the same time, each
|
||||
* having first read "nothing is live". This is the create-and-publish path:
|
||||
* a new version is minted and released in one transaction, which is what a
|
||||
* "save and publish" button does.
|
||||
*/
|
||||
async function racePublish(schema: string, dashboardId: string): Promise<{ ok: number; codes: string[] }> {
|
||||
const a = connect(schema);
|
||||
const b = connect(schema);
|
||||
const publish = (conn: ReturnType<typeof postgres>, n: number) =>
|
||||
conn.begin(async (tx) => {
|
||||
await tx.unsafe(
|
||||
`SELECT release_id FROM opt_analytics_dashboard_release
|
||||
WHERE dashboard_id = '${dashboardId}' AND state = 'published'`,
|
||||
);
|
||||
// A tiny pause so both transactions have certainly done their read
|
||||
// before either writes. Without it the race may not interleave and the
|
||||
// counterfactual could pass for the wrong reason.
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await tx.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_release
|
||||
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state,
|
||||
document_hash, published_at)
|
||||
VALUES ('rel_race_${n}', '${dashboardId}', 'dv_race_${n}', 't_alpha', 'p_main', ${400 + n},
|
||||
'published', '${n === 1 ? HASH_A : HASH_B}', now())`);
|
||||
});
|
||||
const results = await Promise.allSettled([publish(a, 1), publish(b, 2)]);
|
||||
await a.end({ timeout: 5 });
|
||||
await b.end({ timeout: 5 });
|
||||
return {
|
||||
ok: results.filter((r) => r.status === 'fulfilled').length,
|
||||
codes: results.flatMap((r) =>
|
||||
r.status === 'rejected' ? [String((r.reason as { code?: string })?.code ?? r.reason)] : [],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!TEST_DATABASE_URL) return;
|
||||
const admin = postgres(TEST_DATABASE_URL, { max: 1, onnotice: () => undefined });
|
||||
await admin.unsafe(`CREATE SCHEMA IF NOT EXISTS ${SCHEMA}`);
|
||||
await admin.unsafe(`CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NOGUARD}`);
|
||||
await admin.end({ timeout: 5 });
|
||||
|
||||
sql = connect(SCHEMA, 3);
|
||||
await migrate(sql);
|
||||
for (const d of ['dash_txn', 'dash_race', 'dash_draft']) await seedDashboard(sql, d);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!TEST_DATABASE_URL || !sql) return;
|
||||
await sql.unsafe(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
|
||||
await sql.unsafe(`DROP SCHEMA IF EXISTS ${SCHEMA_NOGUARD} CASCADE`);
|
||||
await sql.end({ timeout: 5 });
|
||||
});
|
||||
|
||||
run('sql/010 applies and replays', () => {
|
||||
it('is idempotent — a replay onto an already-migrated schema succeeds', async () => {
|
||||
const text = fs.readFileSync(
|
||||
path.join(process.cwd(), 'sql/010_analytics_dashboard_releases.sql'),
|
||||
'utf8',
|
||||
);
|
||||
await expect(sql.unsafe(text)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('has no column that could hold a document body — the document lives in the version row', async () => {
|
||||
const rows = (await sql.unsafe(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = '${SCHEMA}' AND table_name = 'opt_analytics_dashboard_release'`,
|
||||
)) as unknown as Array<{ column_name: string }>;
|
||||
const names = new Set(rows.map((r) => r.column_name));
|
||||
for (const forbidden of ['layout_json', 'document', 'definition', 'payload', 'sql']) {
|
||||
expect(names.has(forbidden), forbidden).toBe(false);
|
||||
}
|
||||
expect(names.has('document_hash')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
run('BI-P061 transitions are enforced by the database', () => {
|
||||
beforeAll(async () => {
|
||||
await seedVersion(sql, 'dv_t1', 'dash_txn', 101);
|
||||
await seedVersion(sql, 'dv_t3', 'dash_txn', 103, HASH_B);
|
||||
await seedDraftRelease(sql, 'rel_t1', 'dv_t1', 'dash_txn', 101);
|
||||
});
|
||||
|
||||
it('draft -> published is allowed', async () => {
|
||||
await sql.unsafe(
|
||||
`UPDATE opt_analytics_dashboard_release SET state='published', published_at=now() WHERE release_id='rel_t1'`,
|
||||
);
|
||||
const rows = (await sql.unsafe(
|
||||
`SELECT state FROM opt_analytics_dashboard_release WHERE release_id='rel_t1'`,
|
||||
)) as unknown as Array<{ state: string }>;
|
||||
expect(rows[0].state).toBe('published');
|
||||
});
|
||||
|
||||
it('published -> draft (un-publishing) is REFUSED', async () => {
|
||||
const msg = await refusalOf(() =>
|
||||
sql.unsafe(
|
||||
`UPDATE opt_analytics_dashboard_release SET state='draft', published_at=NULL WHERE release_id='rel_t1'`,
|
||||
),
|
||||
);
|
||||
expect(msg).toMatch(/illegal dashboard release transition published -> draft/);
|
||||
});
|
||||
|
||||
it('repointing a release at a different version is REFUSED', async () => {
|
||||
const msg = await refusalOf(() =>
|
||||
sql.unsafe(
|
||||
`UPDATE opt_analytics_dashboard_release SET dashboard_version_id='dv_t3' WHERE release_id='rel_t1'`,
|
||||
),
|
||||
);
|
||||
expect(msg).toMatch(/immutable apart from its state/);
|
||||
});
|
||||
|
||||
it('rewriting document_hash on a release is REFUSED', async () => {
|
||||
const msg = await refusalOf(() =>
|
||||
sql.unsafe(
|
||||
`UPDATE opt_analytics_dashboard_release SET document_hash='${HASH_B}' WHERE release_id='rel_t1'`,
|
||||
),
|
||||
);
|
||||
expect(msg).toMatch(/immutable apart from its state/);
|
||||
});
|
||||
|
||||
it('rewriting published_at is REFUSED — a published timestamp is history', async () => {
|
||||
const msg = await refusalOf(() =>
|
||||
sql.unsafe(
|
||||
`UPDATE opt_analytics_dashboard_release SET published_at = now() - interval '10 days'
|
||||
WHERE release_id='rel_t1'`,
|
||||
),
|
||||
);
|
||||
expect(msg).toMatch(/published_at is history/);
|
||||
});
|
||||
|
||||
it('published -> superseded is allowed, and superseded -> published is REFUSED', async () => {
|
||||
await sql.unsafe(
|
||||
`UPDATE opt_analytics_dashboard_release SET state='superseded', superseded_at=now() WHERE release_id='rel_t1'`,
|
||||
);
|
||||
const msg = await refusalOf(() =>
|
||||
sql.unsafe(`UPDATE opt_analytics_dashboard_release SET state='published' WHERE release_id='rel_t1'`),
|
||||
);
|
||||
expect(msg).toMatch(/illegal dashboard release transition superseded -> published/);
|
||||
});
|
||||
|
||||
it('deleting a release is REFUSED — the ledger has no eraser', async () => {
|
||||
const msg = await refusalOf(() =>
|
||||
sql.unsafe(`DELETE FROM opt_analytics_dashboard_release WHERE release_id='rel_t1'`),
|
||||
);
|
||||
expect(msg).toMatch(/ledger; delete is refused/);
|
||||
});
|
||||
|
||||
it('a published release with no published_at is REFUSED by the CHECK', async () => {
|
||||
const msg = await refusalOf(() =>
|
||||
sql.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_release
|
||||
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state, document_hash)
|
||||
VALUES ('rel_bad', 'dash_txn', 'dv_t3', 't_alpha', 'p_main', 900, 'published', '${HASH_A}')`),
|
||||
);
|
||||
expect(msg).toMatch(/opt_analytics_dashboard_release_timestamps/);
|
||||
});
|
||||
|
||||
it('a document_hash that is not a sha256 digest is REFUSED', async () => {
|
||||
const msg = await refusalOf(() =>
|
||||
sql.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_release
|
||||
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state, document_hash)
|
||||
VALUES ('rel_bad2', 'dash_txn', 'dv_t3', 't_alpha', 'p_main', 901, 'draft', 'not-a-hash')`),
|
||||
);
|
||||
expect(msg).toMatch(/document_hash/);
|
||||
});
|
||||
|
||||
it('the sql/008 immutability trigger still forbids editing the frozen document', async () => {
|
||||
// Assert the row exists FIRST: an UPDATE that matches nothing raises
|
||||
// nothing, which would read as "refused" to a careless test.
|
||||
const before = (await sql.unsafe(
|
||||
`SELECT count(*)::int AS n FROM opt_analytics_dashboard_version WHERE dashboard_version_id='dv_t3'`,
|
||||
)) as unknown as Array<{ n: number }>;
|
||||
expect(before[0].n).toBe(1);
|
||||
|
||||
const msg = await refusalOf(() =>
|
||||
sql.unsafe(
|
||||
`UPDATE opt_analytics_dashboard_version SET layout_json='{"tampered":true}'
|
||||
WHERE dashboard_version_id='dv_t3'`,
|
||||
),
|
||||
);
|
||||
expect(msg).toMatch(/rows are immutable/);
|
||||
});
|
||||
|
||||
it('a second open draft for one dashboard is REFUSED', async () => {
|
||||
await seedVersion(sql, 'dv_d1', 'dash_draft', 301);
|
||||
await seedVersion(sql, 'dv_d2', 'dash_draft', 302);
|
||||
await seedDraftRelease(sql, 'rel_d1', 'dv_d1', 'dash_draft', 301);
|
||||
const msg = await refusalOf(() => seedDraftRelease(sql, 'rel_d2', 'dv_d2', 'dash_draft', 302));
|
||||
expect(msg).toMatch(/one_draft/);
|
||||
});
|
||||
});
|
||||
|
||||
run('BI-P061 concurrency: the partial unique index is the guarantee', () => {
|
||||
let noguard: ReturnType<typeof postgres>;
|
||||
|
||||
beforeAll(async () => {
|
||||
noguard = connect(SCHEMA_NOGUARD, 3);
|
||||
await migrate(noguard);
|
||||
await noguard.unsafe(`DROP INDEX opt_analytics_dashboard_release_one_published`);
|
||||
await seedDashboard(noguard, 'dash_race');
|
||||
await seedVersion(noguard, 'dv_race_1', 'dash_race', 401);
|
||||
await seedVersion(noguard, 'dv_race_2', 'dash_race', 402, HASH_B);
|
||||
|
||||
await seedVersion(sql, 'dv_race_1', 'dash_race', 401);
|
||||
await seedVersion(sql, 'dv_race_2', 'dash_race', 402, HASH_B);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (noguard) await noguard.end({ timeout: 5 });
|
||||
});
|
||||
|
||||
it('WITHOUT the index, two concurrent publishes BOTH succeed — two live dashboards', async () => {
|
||||
const race = await racePublish(SCHEMA_NOGUARD, 'dash_race');
|
||||
expect(race.ok).toBe(2);
|
||||
expect(await publishedCount(noguard, 'dash_race')).toBe(2);
|
||||
}, 30_000);
|
||||
|
||||
it('WITH the index, the same race leaves exactly one published release', async () => {
|
||||
expect(await publishedCount(sql, 'dash_race')).toBe(0);
|
||||
|
||||
const race = await racePublish(SCHEMA, 'dash_race');
|
||||
|
||||
expect(race.ok).toBe(1);
|
||||
expect(race.codes.join(' ')).toContain('23505');
|
||||
expect(await publishedCount(sql, 'dash_race')).toBe(1);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* tests/analytics/dashboard-release.test.ts
|
||||
* ==========================================
|
||||
* BI-P061 — DASHBOARD VERSIONING. DoD: "rollback to previous dashboard
|
||||
* definition".
|
||||
*
|
||||
* A rollback test that only asserts "the service returned version 4" proves
|
||||
* nothing: the interesting failure is a rollback that publishes a DIFFERENT
|
||||
* document while reporting success. So every rollback assertion here compares
|
||||
* BI-P060 canonical hashes and re-plans the restored dashboard, and one test
|
||||
* corrupts the stored document underneath a release to show that serving it
|
||||
* is refused rather than silently rendered.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SiaxError } from '@/src/platform/errors';
|
||||
import {
|
||||
dashboardDocumentHash,
|
||||
parseDashboardDocument,
|
||||
planDashboard,
|
||||
} from '@/src/analytics/dashboards/dashboard-document';
|
||||
import {
|
||||
DashboardReleaseService,
|
||||
InMemoryDashboardReleaseStore,
|
||||
isLegalTransition,
|
||||
DASHBOARD_RELEASE_STATES,
|
||||
type DashboardReleaseState,
|
||||
} from '@/src/analytics/dashboards/dashboard-release';
|
||||
|
||||
const SCOPE = { tenant_id: 't_alpha', project_id: 'p_main' };
|
||||
|
||||
function doc(title: string, metric = 'revenue') {
|
||||
return {
|
||||
id: 'dash_sales',
|
||||
title,
|
||||
version: 1,
|
||||
layout: { grid_columns: 12, row_height_px: 40 },
|
||||
parameters: [],
|
||||
filters: [],
|
||||
widgets: [
|
||||
{
|
||||
widget_id: 'w_1',
|
||||
title,
|
||||
source: { kind: 'metric', dataset: 'ds_sales', metric },
|
||||
visualization: { type: 'big_number', encoding: { value: metric } },
|
||||
layout: { x: 0, y: 0, w: 4, h: 2 },
|
||||
},
|
||||
],
|
||||
refresh: { mode: 'manual', max_staleness_seconds: 3600 },
|
||||
permissions: { visibility: 'tenant' },
|
||||
};
|
||||
}
|
||||
|
||||
function service() {
|
||||
let n = 0;
|
||||
let t = Date.parse('2026-09-07T10:00:00.000Z');
|
||||
return new DashboardReleaseService(
|
||||
new InMemoryDashboardReleaseStore(),
|
||||
() => new Date((t += 1000)),
|
||||
() => `id_${++n}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe('BI-P061 release lifecycle', () => {
|
||||
it('the transition table is exactly DRAFT -> PUBLISHED -> SUPERSEDED', () => {
|
||||
const legal: Array<[DashboardReleaseState, DashboardReleaseState]> = [
|
||||
['draft', 'published'],
|
||||
['published', 'superseded'],
|
||||
];
|
||||
for (const from of DASHBOARD_RELEASE_STATES) {
|
||||
for (const to of DASHBOARD_RELEASE_STATES) {
|
||||
const expected = legal.some(([f, t]) => f === from && t === to);
|
||||
expect(isLegalTransition(from, to), `${from} -> ${to}`).toBe(expected);
|
||||
}
|
||||
}
|
||||
// Named explicitly because each is a real request someone will make.
|
||||
expect(isLegalTransition('published', 'draft')).toBe(false);
|
||||
expect(isLegalTransition('superseded', 'published')).toBe(false);
|
||||
expect(isLegalTransition('draft', 'superseded')).toBe(false);
|
||||
});
|
||||
|
||||
it('publishing supersedes the previous release, and only one is ever live', async () => {
|
||||
const svc = service();
|
||||
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
|
||||
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
|
||||
const v2 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v2') });
|
||||
const result = await svc.publish({ scope: SCOPE, releaseId: v2.release.release_id });
|
||||
|
||||
expect(result.published.version).toBe(2);
|
||||
expect(result.superseded?.version).toBe(1);
|
||||
expect(result.superseded?.superseded_by_release_id).toBe(v2.release.release_id);
|
||||
|
||||
const history = await svc.history(SCOPE, 'dash_sales');
|
||||
expect(history.map((r) => `${r.version}:${r.state}`)).toEqual(['1:superseded', '2:published']);
|
||||
expect((await svc.live(SCOPE, 'dash_sales'))?.version).toBe(2);
|
||||
});
|
||||
|
||||
it('a second open draft is refused rather than racing the first', async () => {
|
||||
const svc = service();
|
||||
await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
|
||||
await expect(
|
||||
svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1-again') }),
|
||||
).rejects.toThrow(/already has an open draft/);
|
||||
});
|
||||
|
||||
it('a superseded release cannot be re-published', async () => {
|
||||
const svc = service();
|
||||
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
|
||||
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
|
||||
const v2 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v2') });
|
||||
await svc.publish({ scope: SCOPE, releaseId: v2.release.release_id });
|
||||
|
||||
await expect(svc.publish({ scope: SCOPE, releaseId: v1.release.release_id })).rejects.toThrow(
|
||||
/is superseded, not draft/,
|
||||
);
|
||||
});
|
||||
|
||||
it('another tenant cannot see, publish or roll back this tenant’s releases', async () => {
|
||||
const svc = service();
|
||||
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
|
||||
const other = { tenant_id: 't_beta', project_id: 'p_main' };
|
||||
|
||||
await expect(svc.publish({ scope: other, releaseId: v1.release.release_id })).rejects.toThrow(
|
||||
/does not exist in this tenant/,
|
||||
);
|
||||
expect(await svc.live(other, 'dash_sales')).toBeUndefined();
|
||||
await expect(svc.rollback({ scope: other, dashboardId: 'dash_sales', toVersion: 1 })).rejects.toThrow(
|
||||
/no version 1 in this tenant/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BI-P061 rollback — the DoD', () => {
|
||||
it('restores the earlier definition byte-for-byte, as a NEW version', async () => {
|
||||
const svc = service();
|
||||
const original = doc('Sales v1', 'revenue');
|
||||
const originalHash = dashboardDocumentHash(parseDashboardDocument(original));
|
||||
|
||||
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: original });
|
||||
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
|
||||
const v2 = await svc.draft({
|
||||
scope: SCOPE,
|
||||
dashboardId: 'dash_sales',
|
||||
document: doc('Sales v2 (broken)', 'revenue_net'),
|
||||
});
|
||||
await svc.publish({ scope: SCOPE, releaseId: v2.release.release_id });
|
||||
expect((await svc.live(SCOPE, 'dash_sales'))?.document_hash).not.toBe(originalHash);
|
||||
|
||||
const rolled = await svc.rollback({ scope: SCOPE, dashboardId: 'dash_sales', toVersion: 1 });
|
||||
|
||||
// Faithful: same canonical document.
|
||||
expect(rolled.document_hash).toBe(originalHash);
|
||||
// New version, not a resurrected row — the history stays linear.
|
||||
expect(rolled.release.version).toBe(3);
|
||||
expect(rolled.release.rollback_of_version).toBe(1);
|
||||
|
||||
const history = await svc.history(SCOPE, 'dash_sales');
|
||||
expect(history.map((r) => `${r.version}:${r.state}`)).toEqual([
|
||||
'1:superseded',
|
||||
'2:superseded',
|
||||
'3:published',
|
||||
]);
|
||||
|
||||
// And the restored dashboard actually renders as the original did.
|
||||
const live = await svc.live(SCOPE, 'dash_sales');
|
||||
const restored = await svc.documentOf(SCOPE, live!);
|
||||
expect(planDashboard(restored)).toEqual(planDashboard(parseDashboardDocument(original)));
|
||||
});
|
||||
|
||||
it('rolling back to the live version is refused as a no-op', async () => {
|
||||
const svc = service();
|
||||
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
|
||||
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
|
||||
await expect(svc.rollback({ scope: SCOPE, dashboardId: 'dash_sales', toVersion: 1 })).rejects.toThrow(
|
||||
/already live/,
|
||||
);
|
||||
});
|
||||
|
||||
it('a stored document that no longer matches its released hash is refused, not rendered', async () => {
|
||||
const store = new InMemoryDashboardReleaseStore();
|
||||
const svc = new DashboardReleaseService(
|
||||
store,
|
||||
() => new Date(),
|
||||
() => crypto.randomUUID(),
|
||||
);
|
||||
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
|
||||
const { published } = await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
|
||||
|
||||
// Tamper with storage the way a bad migration or a manual UPDATE would.
|
||||
const stored = await store.getVersion(SCOPE, published.dashboard_version_id);
|
||||
const tampered = JSON.parse(stored!.layout_json);
|
||||
tampered.widgets[0].source.metric = 'revenue_including_vat';
|
||||
await store.insertVersionAndRelease(
|
||||
{ ...stored!, dashboard_version_id: 'tampered', version: 99, layout_json: JSON.stringify(tampered) },
|
||||
{
|
||||
...published,
|
||||
release_id: 'r_tampered',
|
||||
dashboard_version_id: 'tampered',
|
||||
version: 99,
|
||||
state: 'draft',
|
||||
published_at: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// documentOf must compare hashes, not trust the row.
|
||||
await expect(svc.documentOf(SCOPE, { ...published, dashboard_version_id: 'tampered' })).rejects.toThrow(
|
||||
/refusing to serve an unapproved definition/,
|
||||
);
|
||||
await expect(svc.documentOf(SCOPE, { ...published, dashboard_version_id: 'tampered' })).rejects.toThrow(
|
||||
SiaxError,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user