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

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:
Claude Opus 5
2026-09-07 03:51:04 +02:00
parent 2af126dfc4
commit 1a12e8a882
6 changed files with 1311 additions and 0 deletions
@@ -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."
}
}
+133
View File
@@ -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 });
}
}
+129
View File
@@ -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);
});
+212
View File
@@ -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,
);
});
});