Files
LogiFlow/scripts/bench/compare.ts
T
LogiFlowandClaude Opus 5.5 600627c632 feat(rust) : serveur réécrit en Rust, Node retiré ; Calendrier en tête du menu (#571)
- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API,
  l'interface, les migrations de démarrage et les tâches de fond ; même
  comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés
  USERS-02, RAPPRO-21, R4, R5).
- Node retiré : server/, dépendances serveur, image Docker sans Node
  (51 Mo), docker-compose à un seul service applicatif.
- R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin).
- R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris.
- R6 : mainteneur dans .github/CODEOWNERS.
- Menu latéral : Accueil puis Calendrier en tête.
- Plan et état : docs/PLAN-MIGRATION-RUST.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
2026-10-15 20:31:14 +02:00

439 lines
19 KiB
TypeScript

// Banc de comparaison : rejoue les mêmes requêtes sur deux serveurs et compare
// statuts, corps JSON, en-têtes utiles, état de la base et appels aux services
// externes (docs/PLAN-MIGRATION-RUST.md, « Vérification »).
//
// npx tsx scripts/bench/compare.ts Node de référence contre Rust
// npx tsx scripts/bench/compare.ts --mode node Node contre Node (le banc est-il stable ?)
// npx tsx scripts/bench/compare.ts --only dlc,tasks seulement ces modules
//
// Prérequis : PostgreSQL local, base modèle bench_seed (voir README.md), serveur Node
// de référence construit (reference-node.sh), binaire Rust construit (server-rs/target/release).
import { spawn, execFileSync, type ChildProcess } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import pg from "pg";
import { fileURLToPath } from "url";
import { BENCH_ACCOUNTS, BENCH_API_KEY, BENCH_ENV, DB_PREFIX, LEFT_PORT, RIGHT_PORT, STUB_URL, TEMPLATE_DB } from "./config";
import { startStub } from "./stub";
import { READS, WRITES } from "./cases";
import { ALL_ACCOUNTS, LOGGED_ACCOUNTS, type AccountKey, type Deviation, type ReadCase, type Step, type WriteScenario } from "./types";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
// Fichiers des sauvegardes déclarées par les seeds (database_backups), copiés
// dans le dossier de sauvegardes de chaque serveur
const SEED_BACKUPS_DIR = path.join(ROOT, "scripts/bench/seeds/sauvegardes");
const PG_ADMIN_URL = process.env.BENCH_PG_URL || "postgres://logiflow_admin:bench@localhost:5432";
// Chemin relatif : depuis cette copie de travail (les serveurs sont lancés depuis BENCH_NODE_DIR)
const RUST_BIN = path.resolve(ROOT, process.env.RUST_BIN || "server-rs/target/release/logiflow");
// Serveur Node de référence construit par reference-node.sh (dist/ et node_modules/)
const NODE_DIR = process.env.BENCH_NODE_DIR || path.join(ROOT, ".bench-reference");
const WORK_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "logiflow-banc-"));
const args = process.argv.slice(2);
const mode = args.includes("--mode") ? args[args.indexOf("--mode") + 1] : "rust";
const only = args.includes("--only") ? args[args.indexOf("--only") + 1].split(",") : null;
const keep = args.includes("--keep");
// ---------- Gabarits ----------
function isoDay(offset: number): string {
const date = new Date();
date.setUTCDate(date.getUTCDate() + offset);
return date.toISOString().slice(0, 10);
}
function fill(template: string, results: unknown[]): string {
return template.replace(/\{\{([^}]+)\}\}/g, (_, expr: string) => {
const day = expr.match(/^today([+-]\d+)?$/);
if (day) return isoDay(Number(day[1] || 0));
const year = expr.match(/^year([+-]\d+)?$/);
if (year) return String(new Date().getUTCFullYear() + Number(year[1] || 0));
if (expr === "month") return String(new Date().getUTCMonth() + 1);
const ref = expr.match(/^(\d+)\.(.+)$/);
if (ref) {
const value = ref[2].split(".").reduce<any>((acc, key) => acc?.[key], results[Number(ref[1])]);
return String(value);
}
throw new Error(`Gabarit inconnu : {{${expr}}}`);
});
}
function fillDeep(value: unknown, results: unknown[]): unknown {
if (typeof value === "string") {
const whole = value.match(/^\{\{(\d+\.[^}]+)\}\}$/);
if (whole) {
// Valeur entière ({{0.id}}) : garder son type (nombre, booléen…)
const [index, ...keys] = whole[1].split(".");
return keys.reduce<any>((acc, key) => acc?.[key], results[Number(index)]);
}
return fill(value, results);
}
if (Array.isArray(value)) return value.map((item) => fillDeep(item, results));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, fillDeep(item, results)]));
}
return value;
}
// ---------- Serveurs ----------
interface Target {
name: string;
baseUrl: string;
dbName: string;
processes: ChildProcess[];
}
function dbUrl(name: string) {
return `${PG_ADMIN_URL}/${name}`;
}
function psql(database: string, sql: string) {
execFileSync("psql", ["-q", "-v", "ON_ERROR_STOP=1", dbUrl(database), "-c", sql], { stdio: ["ignore", "ignore", "inherit"] });
}
function startProcess(label: string, command: string, commandArgs: string[], env: Record<string, string>): ChildProcess {
const log = fs.openSync(path.join(WORK_DIR, `${label}.log`), "a");
const child = spawn(command, commandArgs, { cwd: NODE_DIR, env: { ...process.env, ...env }, stdio: ["ignore", log, log] });
child.on("exit", (code) => {
if (code !== null && code !== 0) console.error(` ${label} s'est arrêté (code ${code}), voir ${WORK_DIR}/${label}.log`);
});
return child;
}
async function waitHealthy(url: string, label: string) {
for (let i = 0; i < 120; i++) {
try {
const res = await fetch(`${url}/api/health`);
if (res.ok) return;
} catch { /* pas encore prêt */ }
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`${label} ne répond pas, voir ${WORK_DIR}/${label}.log`);
}
// Dossier de sauvegardes propre à chaque serveur, avec les fichiers des données du banc
function backupDir(port: number): string {
const dir = path.join(WORK_DIR, `sauvegardes-${port}`);
fs.mkdirSync(dir, { recursive: true });
if (fs.existsSync(SEED_BACKUPS_DIR)) fs.cpSync(SEED_BACKUPS_DIR, dir, { recursive: true });
return dir;
}
function nodeEnv(port: number, database: string): Record<string, string> {
return { ...BENCH_ENV, NODE_ENV: "production", PORT: String(port), DATABASE_URL: dbUrl(database), BACKUP_DIR: backupDir(port) };
}
async function startTarget(name: string, kind: "node" | "rust", port: number): Promise<Target> {
const dbName = `${DB_PREFIX}_${name}`;
psql("postgres", `DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`);
psql("postgres", `CREATE DATABASE ${dbName} TEMPLATE ${TEMPLATE_DB}`);
const processes: ChildProcess[] = [];
if (kind === "node") {
processes.push(startProcess(`${name}-node`, "node", ["dist/index.js"], nodeEnv(port, dbName)));
} else {
processes.push(startProcess(`${name}-rust`, RUST_BIN, [], {
...BENCH_ENV,
PORT: String(port),
DATABASE_URL: dbUrl(dbName),
STATIC_DIR: path.join(NODE_DIR, "dist/public"),
BACKUP_DIR: backupDir(port),
RUST_LOG: "warn",
}));
}
const target = { name, baseUrl: `http://127.0.0.1:${port}`, dbName, processes };
await waitHealthy(target.baseUrl, name);
return target;
}
function stopTarget(target: Target) {
for (const child of target.processes) child.kill("SIGTERM");
}
// Remise à zéro de la base d'une cible : vider toutes les tables, recharger les données du modèle
let seedDump: string | null = null;
function resetDatabase(database: string) {
if (!seedDump) {
seedDump = path.join(WORK_DIR, "donnees.sql");
execFileSync("pg_dump", ["--data-only", "--disable-triggers", "-f", seedDump, dbUrl(TEMPLATE_DB)]);
}
const tables = execFileSync("psql", ["-At", dbUrl(database), "-c",
"SELECT string_agg(format('%I', tablename), ', ') FROM pg_tables WHERE schemaname = 'public'"]).toString().trim();
psql(database, `TRUNCATE ${tables} RESTART IDENTITY CASCADE`);
execFileSync("psql", ["-q", "-v", "ON_ERROR_STOP=1", "-f", seedDump, dbUrl(database)], { stdio: ["ignore", "ignore", "inherit"] });
}
// ---------- Requêtes ----------
interface Recorded {
status: number;
headers: Record<string, string>;
body: unknown;
}
const KEPT_HEADERS = ["content-type", "cache-control", "location", "content-disposition"];
function describeCookie(header: string): string {
// Nom et attributs du cookie, sans sa valeur (propre à chaque session)
const [pair, ...attributes] = header.split(";").map((part) => part.trim());
const name = pair.split("=")[0];
const kept = attributes.filter((a) => !/^expires=/i.test(a)).map((a) => a.toLowerCase()).sort();
return [name, ...kept].join("; ");
}
async function login(baseUrl: string, account: AccountKey): Promise<Record<string, string>> {
if (account === "anonyme") return {};
if (account === "api") return { "x-api-key": BENCH_API_KEY };
const known = BENCH_ACCOUNTS.find((a) => a.key === account)!;
const res = await fetch(`${baseUrl}/api/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username: known.username, password: known.password }),
});
if (!res.ok) throw new Error(`Connexion impossible pour ${account} sur ${baseUrl} : ${res.status}`);
const cookie = res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
return { cookie };
}
function buildBody(step: Step, results: unknown[]): { body?: BodyInit; contentType?: string } {
if (step.body === undefined && !step.files) return {};
const data = fillDeep(step.body ?? {}, results) as Record<string, unknown>;
if (step.bodyType === "form") {
return { body: new URLSearchParams(data as Record<string, string>).toString(), contentType: "application/x-www-form-urlencoded" };
}
if (step.bodyType === "multipart") {
const form = new FormData();
for (const [key, value] of Object.entries(data)) form.append(key, String(value));
for (const file of step.files ?? []) {
const content = Buffer.alloc(file.size, 0x25); // contenu fixe : « %%%% »
form.append(file.field, new Blob([content], { type: file.contentType }), file.filename);
}
return { body: form };
}
return { body: JSON.stringify(data), contentType: "application/json" };
}
async function send(baseUrl: string, step: Step, auth: Record<string, string>, results: unknown[]): Promise<Recorded> {
const { body, contentType } = buildBody(step, results);
const headers: Record<string, string> = { ...auth, ...(step.headers ?? {}) };
if (contentType) headers["content-type"] = contentType;
const res = await fetch(`${baseUrl}${fill(step.path, results)}`, { method: step.method, headers, body, redirect: "manual" });
const kept: Record<string, string> = {};
for (const name of KEPT_HEADERS) {
const value = res.headers.get(name);
if (value !== null) kept[name] = value.replace(/;\s*/g, "; ").toLowerCase();
}
const cookies = res.headers.getSetCookie();
if (cookies.length) kept["set-cookie"] = cookies.map(describeCookie).join(" | ");
const text = await res.text();
let parsed: unknown = text;
if ((res.headers.get("content-type") || "").includes("json")) {
try { parsed = JSON.parse(text); } catch { /* texte brut */ }
} else if (text.length > 2000) {
// Gros contenu non JSON (HTML, fichiers) : taille et début suffisent
parsed = { taille: Buffer.byteLength(text), debut: text.slice(0, 200) };
}
return { status: res.status, headers: kept, body: parsed };
}
async function stubCalls(reset = false): Promise<unknown[]> {
const res = await fetch(`${STUB_URL}/__calls`, { method: reset ? "DELETE" : "GET" });
return res.json() as Promise<unknown[]>;
}
async function snapshotTables(database: string, tables: string[]) {
const client = new pg.Client({ connectionString: dbUrl(database) });
await client.connect();
const out: Record<string, unknown[]> = {};
for (const table of tables) {
const { rows } = await client.query(`SELECT * FROM ${table} ORDER BY 1, 2`);
out[table] = rows;
}
await client.end();
return out;
}
// ---------- Normalisation ----------
const ISO_DATETIME = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g;
// drop : champs d'un écart voulu, retirés des deux côtés
function normalize(value: unknown, window: [number, number], ignore: string[] = [], drop: string[] = []): unknown {
if (value instanceof Date) return normalize(value.toISOString(), window, ignore, drop);
if (typeof value === "string") {
return value.replace(ISO_DATETIME, (match) => {
const time = Date.parse(match);
return time >= window[0] && time <= window[1] ? "<maintenant>" : match;
});
}
if (Array.isArray(value)) return value.map((item) => normalize(item, window, ignore, drop));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).filter(([key]) => !drop.includes(key)).map(([key, item]) =>
[key, ignore.includes(key) ? "<ignoré>" : normalize(item, window, ignore, drop)]));
}
return value;
}
// ---------- Passage ----------
interface Entry {
key: string; // module | cas | compte
module: string;
data: unknown;
}
async function runTarget(target: Target): Promise<Entry[]> {
const entries: Entry[] = [];
const startedAt = Date.now() - 60_000;
const reads = READS.filter((c) => !only || only.includes(c.module));
const writes = WRITES.filter((c) => !only || only.includes(c.module));
// Lectures : une base fraîche, une connexion par compte
resetDatabase(target.dbName);
const sessions = new Map<AccountKey, Record<string, string>>();
for (const account of [...LOGGED_ACCOUNTS, "anonyme", "api"] as AccountKey[]) {
sessions.set(account, await login(target.baseUrl, account));
}
for (const readCase of reads) {
for (const account of readCase.accounts ?? ALL_ACCOUNTS) {
await stubCalls(true);
const recorded = await send(target.baseUrl, { method: "GET", path: readCase.path }, sessions.get(account)!, []);
const calls = await stubCalls();
entries.push({
key: `${readCase.module} | GET ${readCase.path} | ${account}`,
module: readCase.module,
data: { reponse: recorded, appels: calls, ignore: readCase.ignore, deviation: readCase.deviation },
});
}
}
// Écritures : base remise à zéro pour chaque compte
for (const scenario of writes) {
for (const account of scenario.accounts ?? LOGGED_ACCOUNTS) {
resetDatabase(target.dbName);
await stubCalls(true);
const auth = await login(target.baseUrl, account);
const results: unknown[] = [];
const responses: Recorded[] = [];
for (const step of scenario.steps) {
const recorded = await send(target.baseUrl, step, auth, results);
responses.push(recorded);
results.push(recorded.body);
}
entries.push({
key: `${scenario.module} | ${scenario.name} | ${account}`,
module: scenario.module,
data: {
reponses: responses,
tables: await snapshotTables(target.dbName, scenario.tables),
appels: await stubCalls(),
ignore: scenario.ignore,
deviation: scenario.deviation,
},
});
}
}
const endedAt = Date.now() + 60_000;
return entries.map((entry) => {
const { ignore, deviation, ...data } = entry.data as { ignore?: string[]; deviation?: Deviation };
return { ...entry, data: normalize(data, [startedAt, endedAt], ignore, deviation?.fields) };
});
}
// ---------- Comparaison ----------
function differences(a: unknown, b: unknown, where = ""): string[] {
if (JSON.stringify(a) === JSON.stringify(b)) return [];
if (Array.isArray(a) && Array.isArray(b)) {
const out: string[] = [];
if (a.length !== b.length) out.push(`${where} : ${a.length} éléments contre ${b.length}`);
for (let i = 0; i < Math.min(a.length, b.length); i++) out.push(...differences(a[i], b[i], `${where}[${i}]`));
return out;
}
if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) {
const out: string[] = [];
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const key of keys) {
if (!(key in (a as object))) out.push(`${where}.${key} : absent à gauche, ${JSON.stringify((b as any)[key])?.slice(0, 120)} à droite`);
else if (!(key in (b as object))) out.push(`${where}.${key} : ${JSON.stringify((a as any)[key])?.slice(0, 120)} à gauche, absent à droite`);
else out.push(...differences((a as any)[key], (b as any)[key], `${where}.${key}`));
}
return out;
}
return [`${where} : ${JSON.stringify(a)?.slice(0, 160)} contre ${JSON.stringify(b)?.slice(0, 160)}`];
}
async function main() {
// Décision R5 : Rust compte « aujourd'hui » à Paris, Node en UTC. Les deux
// dates diffèrent en fin de soirée (après 22 h ou 23 h UTC) : les écarts de
// date relevés alors sont attendus.
const parisDay = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Paris" }).format(new Date());
if (mode !== "node" && parisDay !== isoDay(0)) {
console.log(`Attention : il est déjà le ${parisDay} à Paris (${isoDay(0)} en UTC) ; écarts de date attendus (décision R5). Relancer après minuit UTC.`);
}
console.log(`Banc : ${mode === "node" ? "Node contre Node" : "Node de référence contre Rust"}${only ? ` (modules : ${only.join(", ")})` : ""}`);
console.log(`Journaux : ${WORK_DIR}`);
const stopStub = await startStub();
const targets: Target[] = [];
try {
const left = await startTarget("gauche", "node", LEFT_PORT);
targets.push(left);
const right = await startTarget("droite", mode === "node" ? "node" : "rust", RIGHT_PORT);
targets.push(right);
const leftEntries = await runTarget(left);
const rightEntries = await runTarget(right);
const report: { key: string; differences: string[] }[] = [];
const byModule = new Map<string, { total: number; ko: number }>();
for (const entry of leftEntries) {
const other = rightEntries.find((e) => e.key === entry.key);
const diffs = differences(entry.data, other?.data);
const stats = byModule.get(entry.module) ?? { total: 0, ko: 0 };
stats.total++;
if (diffs.length) { stats.ko++; report.push({ key: entry.key, differences: diffs }); }
byModule.set(entry.module, stats);
}
fs.writeFileSync(path.join(WORK_DIR, "gauche.json"), JSON.stringify(leftEntries, null, 2));
fs.writeFileSync(path.join(WORK_DIR, "droite.json"), JSON.stringify(rightEntries, null, 2));
fs.writeFileSync(path.join(WORK_DIR, "ecarts.json"), JSON.stringify(report, null, 2));
for (const item of report.slice(0, 40)) {
console.log(`\n✗ ${item.key}`);
for (const line of item.differences.slice(0, 8)) console.log(` ${line}`);
if (item.differences.length > 8) console.log(` … ${item.differences.length - 8} autre(s)`);
}
if (report.length > 40) console.log(`\n… ${report.length - 40} autre(s) cas en écart, voir ${WORK_DIR}/ecarts.json`);
console.log("\nModule cas écarts");
for (const [module, stats] of [...byModule].sort()) {
console.log(`${module.padEnd(22)} ${String(stats.total).padStart(4)} ${stats.ko}`);
}
const deviations = new Set(
[...READS, ...WRITES]
.filter((c) => c.deviation && (!only || only.includes(c.module)))
.map((c) => `${c.module} : ${c.deviation!.fields.join(", ")} — ${c.deviation!.reason}`),
);
if (deviations.size) {
console.log("\nÉcarts voulus (champs retirés de la comparaison) :");
for (const line of deviations) console.log(` ${line}`);
}
const total = leftEntries.length;
console.log(`\n${total} cas comparés, ${report.length} en écart.`);
process.exitCode = report.length ? 1 : 0;
} finally {
targets.forEach(stopTarget);
await stopStub();
if (!keep) {
for (const target of targets) {
try { psql("postgres", `DROP DATABASE IF EXISTS ${target.dbName} WITH (FORCE)`); } catch { /* déjà supprimée */ }
}
}
}
}
main().catch((error) => { console.error(error); process.exit(1); });