Files
0pt/scripts/bi-p061-release-race.mjs
T
Claude Opus 5 1a12e8a882
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
feat(analytics): BI-P061 — dashboard releases, with the publish race measured both ways
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>
2026-09-07 03:51:04 +02:00

134 lines
5.6 KiB
JavaScript

#!/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 });
}
}