@mostajs/recette 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
4
 
5
+ ## 0.3.0 — 2026-10-23
6
+
7
+ ### Corrigé
8
+
9
+ - **Une ligne, une issue : le DERNIER relevé fait foi.** Le formulaire enregistre à chaque
10
+ changement, comme un questionnaire ; un testeur qui hésitait entre `ko` et `ok`, ou qui écrivait
11
+ sa remarque en plusieurs fois, laissait plusieurs relevés pour la même ligne, et tous étaient
12
+ comptés. Une hésitation devenait un `ko` de plus au dossier, une remarque tapée en trois fois
13
+ trois `ok` — et le verdict proposé au certificateur s'en trouvait faussé. Les compteurs du
14
+ dossier et de `etat()` ne retiennent plus que le dernier relevé de chaque (feuillet, ligne).
15
+ **T-REC-15** le verrouille.
16
+
17
+ ### Ajouté
18
+
19
+ - `releves.derniersDuFeuillet(feuilletId)` — le relevé qui fait foi pour chaque ligne, issue et
20
+ remarque comprises : ce qu'un formulaire réaffiche.
21
+ - Le dossier figé porte `relevesRemplaces` : combien de relevés ont été remplacés. L'historique
22
+ reste en base, rien n'est effacé.
23
+
24
+ > Le **contenu** d'un dossier figé change de forme (`releves` ne garde que les relevés qui font
25
+ > foi) : son empreinte n'est pas comparable à celle d'un dossier figé en 0.2.x. Les dossiers déjà
26
+ > figés ne sont pas touchés.
27
+
28
+ ## 0.2.1 — 2026-09-10
29
+
30
+ Correction née d'une panne **en production**, invisible depuis les essais.
31
+
32
+ ### Corrigé
33
+
34
+ - **Le contenu du dossier revenait en CHAÎNE** de plusieurs dialectes SQL (sqljs le premier) là
35
+ où un dépôt en mémoire rend l'objet. L'enveloppe exportée transportait donc une chaîne, le
36
+ destinataire recalculait l'empreinte sur autre chose, et **refusait tout dossier** — avec un
37
+ message parlant de falsification. Le moteur normalise désormais à la lecture : `dossiers.of()`
38
+ et l'export rendent toujours un objet.
39
+ - **`dossiers.exporter()` vérifie l'empreinte avant de signer.** Un document dont l'empreinte ne
40
+ recouvre plus le contenu relu est refusé ici, en nommant la cause, plutôt que d'aller se faire
41
+ rejeter chez le destinataire qui, lui, n'aura que « signature ou empreinte invalide » à offrir.
42
+
43
+ ### Ajouté aux essais
44
+
45
+ - **T-REC-14** monte un dépôt qui imite sqljs — écriture d'objet, lecture de texte. C'est le cas
46
+ qui manquait : quatorze essais verts et rien ne fonctionnait en ligne.
47
+
5
48
  ## 0.2.0 — 2026-09-10
6
49
 
7
50
  Le sceau savait fermer un pli ; il ne savait pas l'ouvrir. C'est ce qui manquait pour que QaTrax
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/recette",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Validation & recette applicative : trame `mostajs-devtest/1` reçue signée du certificateur, points enrichis POUR LE CLIENT (l'import n'écrase jamais l'enrichissement), feuillets composés à l'image du manifeste PDF (ok/ko/er), anomalies déclarées puis qualifiées, dossier figé et EXPORTÉ SIGNÉ — aucun appel réseau —, certificat reçu et vérifié, feu d'exploitation DÉRIVÉ du certificat. Powers ValiTrax (valitrax.mostajs.dev).",
5
5
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
6
  "license": "AGPL-3.0-or-later",
package/src/recette.js CHANGED
@@ -31,6 +31,17 @@ const rang = (e) => PARCOURS.indexOf(e);
31
31
  const need = (v, msg) => { if (v === undefined || v === null || v === '') throw new Error(msg); return v; };
32
32
  const asDate = (d) => (d ? new Date(d) : null);
33
33
 
34
+ /**
35
+ * Le CONTENU d'un dossier, quel que soit ce que le stockage a bien voulu en faire.
36
+ *
37
+ * Un champ `json` revient en OBJET d'un dépôt en mémoire, et en CHAÎNE de plusieurs dialectes
38
+ * SQL — sqljs le premier. Sans cette normalisation, l'enveloppe exportée transportait une
39
+ * chaîne là où le destinataire attend un objet, et l'empreinte recalculée à l'arrivée ne
40
+ * pouvait plus correspondre : tout dossier était refusé, pour une raison invisible depuis les
41
+ * essais, qui n'utilisent que des dépôts en mémoire.
42
+ */
43
+ const contenuDe = (d) => (typeof d?.contenu === 'string' ? JSON.parse(d.contenu) : d?.contenu);
44
+
34
45
  export function createRecette({ repositories, ports = {}, clock = () => new Date(), numeroteur } = {}) {
35
46
  const repo = need(repositories, 'repositories requis');
36
47
  for (const c of ['campagnes', 'points', 'feuillets', 'releves', 'anomalies', 'dossiers', 'certificats']) {
@@ -250,6 +261,29 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
250
261
  },
251
262
  };
252
263
 
264
+ /**
265
+ * UNE LIGNE, UNE ISSUE : le DERNIER relevé de chaque (feuillet, ligne) fait foi.
266
+ *
267
+ * Un relevé ne s'efface pas, il se remplace : le formulaire enregistre à chaque changement, comme
268
+ * un questionnaire, et le testeur qui hésite entre `ko` et `ok`, ou qui écrit sa remarque en
269
+ * plusieurs fois, laisse plusieurs relevés pour la même ligne. Les compter tous faisait d'une
270
+ * hésitation un `ko` de plus au dossier, et d'une remarque tapée en trois fois trois `ok` :
271
+ * le verdict proposé au certificateur s'en trouvait faussé. L'historique reste en base ; le
272
+ * dossier dit combien de relevés ont été remplacés.
273
+ */
274
+ const instant = (r) => +new Date(r.createdAt || r.at || 0);
275
+ function derniers(rs) {
276
+ const parLigne = new Map();
277
+ rs.forEach((r, i) => {
278
+ const cle = `${r.feuilletId}\u0000${r.ref}`;
279
+ const avant = parLigne.get(cle);
280
+ // À instant égal, l'ordre de lecture départage : le plus tard lu est le plus récent.
281
+ if (!avant || instant(r) >= instant(avant.r)) parLigne.set(cle, { r, i });
282
+ });
283
+ return [...parLigne.values()].sort((a, b) => a.i - b.i).map((x) => x.r);
284
+ }
285
+ const compter = (rs) => ({ ok: rs.filter((r) => r.issue === 'ok').length, ko: rs.filter((r) => r.issue === 'ko').length, er: rs.filter((r) => r.issue === 'er').length });
286
+
253
287
  const releves = {
254
288
  /**
255
289
  * Consigner une observation.
@@ -274,6 +308,8 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
274
308
  },
275
309
  of: (campagneId) => repo.releves.find((r) => r.campagneId === campagneId),
276
310
  duFeuillet: (feuilletId) => repo.releves.find((r) => r.feuilletId === feuilletId),
311
+ /** Le relevé QUI FAIT FOI pour chaque ligne d'un feuillet : le dernier consigné (voir `derniers`). */
312
+ derniersDuFeuillet: async (feuilletId) => derniers(await repo.releves.find((r) => r.feuilletId === feuilletId)),
277
313
  };
278
314
 
279
315
  const anomalies = {
@@ -357,14 +393,16 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
357
393
  if (obstacles.length && !force) throw new Error(`figement refusé — ${obstacles.join(' · ')}`);
358
394
 
359
395
  const fs = await feuillets.of(campagneId);
360
- const rs = await releves.of(campagneId);
396
+ const tous = await releves.of(campagneId);
397
+ const rs = derniers(tous);
361
398
  const as = await anomalies.of(campagneId);
362
399
  const contenu = {
363
400
  campagne: { no: c.no, releaseRef: c.releaseRef, projet: c.projet, perimetre: c.perimetre, instanceUrl: c.instanceUrl },
364
401
  feuillets: fs.map((f) => ({ role: f.role, langue: f.langue, state: f.state, planVersion: f.planVersion })),
365
402
  releves: rs.map((r) => ({ ref: r.ref, issue: r.issue, at: r.at, source: r.source, confiance: r.confiance, texte: r.texte })),
366
403
  anomalies: as.map((a) => ({ titre: a.titre, graviteProposee: a.graviteProposee, graviteQualifiee: a.graviteQualifiee, state: a.state, bugRef: a.bugRef, motifEcart: a.motifEcart })),
367
- compteurs: { ok: rs.filter((r) => r.issue === 'ok').length, ko: rs.filter((r) => r.issue === 'ko').length, er: rs.filter((r) => r.issue === 'er').length },
404
+ compteurs: compter(rs),
405
+ relevesRemplaces: tous.length - rs.length,
368
406
  obstaclesAuFigement: obstacles, // consignés même en `force` : un dossier dit ce qu'il vaut
369
407
  };
370
408
  const empreinte = createHash('sha256').update(JSON.stringify(contenu)).digest('hex');
@@ -392,12 +430,21 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
392
430
  const sceau = ports.sceau;
393
431
  if (!sceau?.signer) throw new Error("aucun sceau configuré : un export non signé ne prouve rien, il est refusé");
394
432
 
433
+ // ON VÉRIFIE AVANT DE SIGNER. Exporter un document dont l'empreinte ne recouvre plus le
434
+ // contenu, c'est fabriquer un refus chez le destinataire — et un refus qu'il mettra sur le
435
+ // compte d'une falsification. Mieux vaut refuser ici, en nommant la cause.
436
+ const contenu = contenuDe(d);
437
+ const verif = createHash('sha256').update(JSON.stringify(contenu)).digest('hex');
438
+ if (verif !== d.empreinte) {
439
+ throw new Error(`empreinte du dossier ${d.no} incohérente avec son contenu relu (${verif.slice(0, 12)}… ≠ ${String(d.empreinte).slice(0, 12)}…) : le stockage n'a pas rendu ce qui lui a été confié`);
440
+ }
441
+
395
442
  const enveloppe = {
396
443
  type: 'mostajs-recette-dossier/1',
397
444
  emisLe: clock().toISOString(),
398
445
  campagne: { no: c.no, releaseRef: c.releaseRef },
399
446
  dossier: { no: d.no, empreinte: d.empreinte, figeLe: d.figeLe },
400
- contenu: d.contenu,
447
+ contenu,
401
448
  };
402
449
  const charge = JSON.stringify(enveloppe);
403
450
  const signature = await sceau.signer(charge);
@@ -416,7 +463,12 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
416
463
  const d = need(await dossiers.of(campagneId), 'aucun dossier pour cette campagne');
417
464
  return repo.dossiers.update(d.id, { depotRef: need(depotRef, 'depotRef requis') });
418
465
  },
419
- of: async (campagneId) => (await repo.dossiers.find((x) => x.campagneId === campagneId))[0] || null,
466
+ of: async (campagneId) => {
467
+ const d = (await repo.dossiers.find((x) => x.campagneId === campagneId))[0] || null;
468
+ // On rend TOUJOURS un objet : l'appelant n'a pas à connaître le dialecte SQL sous-jacent
469
+ // pour relire le contenu du dossier qu'il vient de figer.
470
+ return d ? { ...d, contenu: contenuDe(d) } : null;
471
+ },
420
472
  };
421
473
 
422
474
  const certificats = {
@@ -474,12 +526,12 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
474
526
  async function etat(campagneId) {
475
527
  const c = await getCampagne(campagneId);
476
528
  const fs = await feuillets.of(campagneId);
477
- const rs = await releves.of(campagneId);
529
+ const rs = derniers(await releves.of(campagneId));
478
530
  const as = await anomalies.of(campagneId);
479
531
  return {
480
532
  campagne: { no: c.no, state: c.state, releaseRef: c.releaseRef },
481
533
  feuillets: { remis: fs.length, rendus: fs.filter((f) => f.state !== 'remis').length },
482
- releves: { ok: rs.filter((r) => r.issue === 'ok').length, ko: rs.filter((r) => r.issue === 'ko').length, er: rs.filter((r) => r.issue === 'er').length },
534
+ releves: compter(rs),
483
535
  anomalies: { declarees: as.filter((a) => a.state === 'declaree').length, bloquantes: as.filter((a) => a.graviteQualifiee === 'bloquante').length, total: as.length },
484
536
  obstacles: await obstaclesAuDepot(campagneId),
485
537
  };