gestion_table_tournante_libre/src/stockage/document.js
Mathieu Benoit 0bc48fa1ae [FIX] storage, session: strict UTF-8, lost journal, lock, exclusions
The whole-iteration review found ways to lose or corrupt data. A state
saved in another encoding opened with its accents turned to U+FFFD and was
then overwritten; decoding is now strict, the state opens read-only, and a
gesture from its secours first moves it to the trash. A journal decodes line
by line, so one bad byte drops only the lines after it. A vanished journal
is rewritten whole, never appended to. Deleting refuses an event another
session holds; switching to writing rereads the disk; an import that
changes exclu says which reservations it suspends.
Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %.

--- FR ---

[FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions

La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données.
Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en
U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture
seule, et un geste depuis son secours le range d'abord à la corbeille. Le
journal se décode ligne par ligne : un octet fautif n'écarte que la suite.
Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse
un événement qu'une autre séance tient ; passer en écriture relit le disque ;
un import qui change exclu dit quelles réservations il suspend.
Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %.

Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00

674 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le document d'un événement (§ 4, § 6.1, § 8.1, § 8.8, § 9).
//
// SCHEMA décrit le fichier d'état en une seule table de règles : l'ordre des
// champs de chaque objet fait l'ordre des clés du texte canonique, et
// l'analyse parcourt la table pour contrôler la forme. Chaque règle porte
// aussi ce que canonique.js lit pour écrire : la mise en page et l'ordre des
// listes. Les formes sont décrites dans types.js. Ce parcours de la forme
// est le seul : premiereFaute le rend au contrôle des placements et aux
// correctifs, et premiereFauteEnPlace aux correctifs, pour le retenu posé à
// sa place.
//
// analyser lit un texte sans rien écrire et lève, à la première faute,
// ErreurStockage('ETAT_ILLISIBLE', { raison, chemin }), en contrôlant dans
// cet ordre : le texte vide (VIDE) ; le JSON (JSON) ; le format, lu avant
// tout le reste parce qu'il dit comment lire le reste (FORME s'il manque,
// FORMAT_INCONNU s'il n'est pas un entier ≥ 1) ; la forme de chaque champ,
// dans l'ordre du schéma puis des rangs (FORME) ; les identifiants des
// participants et des tables, et le tour des réservations (FORME) ; les
// comptes de la saisie dans l'en-tête — participants, tables, réservations,
// titres — (COMPTES) ; les références (REFERENCE). Les comptes des
// propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) :
// ce que l'en-tête annonce sans que la charge le porte se rend à côté
// d'elle, et le dépôt l'annonce. Un fichier
// d'un format plus récent que FORMAT se lit réduit aux clés que le schéma
// connaît, et ce qui serait FORME au format courant y porte la raison
// FORMAT_PLUS_RECENT : la lecture ne le distingue pas d'une évolution du
// format. Ses comptes et ses références gardent leur raison, une
// corruption quel que soit le format ; tout refus d'un tel fichier porte le
// format lu dans ses détails, qui disent au dépôt d'où il vient. Le dépôt
// complète les détails — base, secours — et pose lui-même la raison ABSENT,
// quand le journal existe sans l'état. Des propositions et du retenu,
// l'analyse ne lit que le conteneur : leur forme, leur cohérence et leurs
// identifiants, comparés entre eux et à prochainsIds.proposition, sont
// l'affaire du contrôle des placements, qui écarte une proposition fautive
// et signale un retenu fautif sans refuser le fichier (§ 8.9, point 3).
//
// Les autres fonctions créent une charge ou dérivent d'elle la configuration
// du moteur, l'état qu'impose son contenu et la capacité d'une table. Aucune
// ne modifie ce qu'elle reçoit.
import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { ErreurStockage } from './erreurs.js';
/** Version de format que ce code écrit (§ 8.8). */
export const FORMAT = 1;
/**
* Réglages de génération d'une charge neuve : cinq propositions de 200 000
* mouvements, valeurs de départ non mesurées (§ 5.10), et l'historique
* d'acceptation que le moteur prend par défaut.
*/
export const GENERATION_PAR_DEFAUT = Object.freeze({ nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT });
// Une règle du schéma ; types.js en décrit les propriétés (Regle). Un objet
// porte ses champs, paires [clé, règle] dans l'ordre des clés, et l'ensemble
// de leurs clés ; une liste porte la règle de ses éléments.
function regle(genre, proprietes) {
return Object.freeze({ genre, nul: false, ...proprietes });
}
const chaine = (options) => regle('chaine', options);
const entier = (min, max = Infinity, options = {}) => regle('entier', { min, max, ...options });
const parmi = (...valeurs) => regle('parmi', { valeurs: Object.freeze(valeurs) });
const liste = (element, options) => regle('liste', { element, ...options });
const objet = (champs, options) =>
regle('objet', {
champs: Object.freeze(champs.map((champ) => Object.freeze(champ))),
cles: new Set(champs.map(([cle]) => cle)),
...options,
});
const CHAINE = chaine();
const CHAINE_OU_NUL = chaine({ nul: true });
const BOOLEEN = regle('booleen');
const NOMBRE = regle('nombre');
// Un identifiant entier — d'un participant, d'une table, d'une proposition,
// ou de ce qu'une réservation, un titre ou un placement désigne — reste sous
// 2^31, dans un entier signé de 32 bits, la largeur des tableaux du moteur
// (Int32Array). Chaque compteur de prochainsIds, le prochain identifiant à
// attribuer, suit la même règle : relevé au-delà d'un identifiant lu, il
// reste ainsi loin de 2^53, où l'analyse ne le relirait plus. Au-delà, la
// valeur sort de sa règle.
const IDENTIFIANT = entier(1, 2 ** 31 - 1);
const DATE_OU_NUL = regle('date', { nul: true });
// Ordres des listes de la charge (§ 8.8, § 8.9). canonique.js range les
// copies canoniques des éléments : clés dans l'ordre du schéma, listes
// intérieures déjà rangées. Chaque ordre est total : deux éléments qu'il
// tient pour égaux s'écrivent pareil, et le tri rend la même liste quel que
// soit l'ordre reçu. Participants et tables se rangent par identifiant,
// unique sur une charge que l'analyse admet ; réservations et titres se
// comparent sur tous leurs champs ; une liste d'identifiants, par valeur.
const croissant = (a, b) => a - b;
const parIdentifiant = (a, b) => a.id - b.id;
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// null avant tout entier, puis les entiers croissants.
const nulEnTete = (a, b) => (a === b ? 0 : a === null ? -1 : b === null ? 1 : a - b);
// La portée « tous » avant « tour », que l'ordre des chaînes inverserait.
const rangDePortee = (portee) => (portee === 'tous' ? 0 : 1);
const ordreDesReservations = (a, b) =>
a.participant - b.participant ||
a.table - b.table ||
rangDePortee(a.portee) - rangDePortee(b.portee) ||
nulEnTete(a.tour, b.tour) ||
nulEnTete(a.siege, b.siege);
// Deux titres d'une même place se départagent par leur libellé.
const ordreDesTitres = (a, b) => a.table - b.table || a.siege - b.siege || comparerTextes(a.libelle, b.libelle);
// L'analyse n'examine des propositions que leur liste : deux d'entre elles
// peuvent partager un identifiant. À identifiant égal, la graine, le compte
// d'arrêt et l'historique les départagent, puis le JSON de leur copie
// canonique, qui ne coïncide que pour deux propositions écrites pareil.
const ordreDesPropositions = (a, b) =>
a.id - b.id ||
a.graine - b.graine ||
a.arret - b.arret ||
a.historique - b.historique ||
comparerTextes(JSON.stringify(a), JSON.stringify(b));
const COMPTES = objet([
['participants', entier(0)],
['tables', entier(0)],
['reservations', entier(0)],
['titres', entier(0)],
['propositions', entier(0)],
['retenu', entier(0, 1)],
]);
const ENTETE = objet([
['format', entier(1)],
['produitVersion', CHAINE],
['revision', entier(1)],
['comptes', COMPTES],
]);
const FILIATION = objet(
[
['source', objet([['id', CHAINE], ['nom', CHAINE]])],
['instant', objet([['revision', entier(1)], ['libelle', CHAINE]])],
],
{ nul: true },
);
const EVENEMENT = objet([
['id', CHAINE],
['nom', CHAINE],
['date', DATE_OU_NUL],
['siegesParDefaut', entier(2)],
['tours', entier(1)],
['unite', parmi('cm')],
['etat', parmi('brouillon', 'propose', 'retenu', 'bloque')],
['filiation', FILIATION],
]);
const REGLAGES = objet([
['separerAppartenances', BOOLEEN],
['nouveauxVoisins', BOOLEEN],
['nouvelleTable', BOOLEEN],
['varierAppartenances', BOOLEEN],
['attribuerSieges', BOOLEEN],
['generation', objet([['nombre', entier(1)], ['arret', entier(1)], ['historique', entier(1)]])],
]);
const PARTICIPANT = objet([
['id', IDENTIFIANT],
['nom', CHAINE],
['prenom', CHAINE_OU_NUL],
['appartenance', CHAINE_OU_NUL],
['courriel', CHAINE_OU_NUL],
['titrePressenti', CHAINE_OU_NUL],
['notes', CHAINE_OU_NUL],
['exclu', BOOLEEN],
]);
const TABLE = objet([
['id', IDENTIFIANT],
['numero', entier(1)],
['sieges', entier(2, undefined, { nul: true })],
['forme', parmi('ronde', 'carree')],
['position', objet([['x', NOMBRE], ['y', NOMBRE]])],
]);
const RESERVATION = objet([
['participant', IDENTIFIANT],
['table', IDENTIFIANT],
['siege', entier(1, undefined, { nul: true })],
['portee', parmi('tous', 'tour')],
['tour', entier(1, undefined, { nul: true })],
]);
const TITRE = objet([
['table', IDENTIFIANT],
['siege', entier(1)],
['libelle', CHAINE],
]);
// Le placement d'une proposition ou du retenu (§ 8.9) : un tour par élément,
// chacun sur sa ligne. sieges porte une liste d'identifiants par table
// déclarée, dans l'ordre des sièges. Quand l'objet qui porte le placement a
// siegesAttribues faux, cet ordre ne porte rien, et chaque liste se trie par
// identifiant croissant ; vrai, il est celui des sièges et se garde. reserve
// porte ceux qui ne sont assis nulle part à ce tour, un ensemble : elle se
// trie par identifiant croissant, quel que soit le drapeau.
const PLACEMENT = liste(
objet([
['sieges', liste(liste(IDENTIFIANT, { triSansAttribution: croissant }))],
['reserve', liste(IDENTIFIANT, { tri: croissant })],
]),
{ mise: 'lignes' },
);
// Champs qu'une proposition et le retenu partagent, après ce qui les
// identifie : tables et capacités se correspondent rang à rang, et leur
// ordre se garde ; participants est l'ensemble des identifiants que le plan
// place, rangé par identifiant croissant.
const PLAN = [
['tables', liste(IDENTIFIANT)],
['capacites', liste(entier(2))],
['tours', entier(1)],
['participants', liste(IDENTIFIANT, { tri: croissant })],
['placement', PLACEMENT],
];
const PROPOSITION = objet(
[
['id', IDENTIFIANT],
['graine', entier(0, 2 ** 32 - 1)],
['arret', entier(1)],
['historique', entier(1)],
['produitVersion', CHAINE],
['siegesAttribues', BOOLEEN],
...PLAN,
],
{ mise: 'ouverte' },
);
const RETENU = objet([['proposition', IDENTIFIANT], ['siegesAttribues', BOOLEEN], ...PLAN], {
nul: true,
mise: 'ouverte',
aPart: true,
});
const CHARGE = objet(
[
['evenement', EVENEMENT],
['reglages', REGLAGES],
['prochainsIds', objet([['participant', IDENTIFIANT], ['table', IDENTIFIANT], ['proposition', IDENTIFIANT]])],
['participants', liste(PARTICIPANT, { mise: 'lignes', tri: parIdentifiant })],
['tables', liste(TABLE, { mise: 'lignes', tri: parIdentifiant })],
['reservations', liste(RESERVATION, { mise: 'lignes', tri: ordreDesReservations })],
['titres', liste(TITRE, { mise: 'lignes', tri: ordreDesTitres })],
['propositions', liste(PROPOSITION, { mise: 'lignes', tri: ordreDesPropositions, aPart: true })],
['retenu', RETENU],
],
{ mise: 'lignes' },
);
/**
* Le fichier d'état (§ 8.8) : l'en-tête, puis la charge. Une règle par
* valeur, dont types.js décrit la forme (Regle) ; l'ordre des champs de
* chaque objet est l'ordre des clés du texte canonique.
* @type {import('./types.js').Regle}
*/
export const SCHEMA = objet([['entete', ENTETE], ['charge', CHARGE]], { mise: 'lignes' });
/**
* Comptes de l'en-tête (§ 8.8) : pour chaque clé des comptes, la longueur de
* la liste de la charge du même nom, et 0 ou 1 pour le retenu. Les clés
* suivent l'ordre du schéma. canonique.js les écrit ; analyser les compare.
*
* @param {import('./types.js').Charge} charge
* @returns {import('./types.js').Comptes}
*/
export function comptesDe(charge) {
const comptes = {};
for (const [cle] of COMPTES.champs) {
const valeur = charge[cle];
comptes[cle] = Array.isArray(valeur) ? valeur.length : valeur === null ? 0 : 1;
}
return comptes;
}
/**
* Charge neuve (§ 4, § 9) : brouillon, ni participant ni table, aucune
* proposition, identifiants attribués à partir de 1. Les réglages activent
* les quatre contraintes, laissent les sièges non attribués et copient
* GENERATION_PAR_DEFAUT. Chaque appel rend des objets neufs. Les arguments
* ne sont pas contrôlés ici : ce contrôle appartient à la commande qui crée
* l'événement.
*
* @param {{id: string, nom: string, date?: string|null, siegesParDefaut: number, tours: number}} evenement
* @returns {import('./types.js').Charge}
*/
export function creerCharge({ id, nom, date = null, siegesParDefaut, tours }) {
return {
evenement: { id, nom, date, siegesParDefaut, tours, unite: 'cm', etat: 'brouillon', filiation: null },
reglages: {
separerAppartenances: true,
nouveauxVoisins: true,
nouvelleTable: true,
varierAppartenances: true,
attribuerSieges: false,
generation: { ...GENERATION_PAR_DEFAUT },
},
prochainsIds: { participant: 1, table: 1, proposition: 1 },
participants: [],
tables: [],
reservations: [],
titres: [],
propositions: [],
retenu: null,
};
}
const MARQUE_ORDRE_OCTETS = '\uFEFF';
// Lève le refus d'un état illisible ; plus ajoute ses détails après la
// raison et le chemin.
function illisible(raison, chemin, plus = {}) {
throw new ErreurStockage('ETAT_ILLISIBLE', { raison, chemin, ...plus });
}
// Vrai pour un objet qui n'est ni null ni une liste.
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
// Chemin de la clé cle sous chemin : après un point quand elle s'écrit comme
// un identifiant, entre crochets en JSON sinon ; la racine est ''.
const IDENTIFIANT_JS = /^[A-Za-z_$][\w$]*$/;
function joindre(chemin, cle) {
if (!IDENTIFIANT_JS.test(cle)) return `${chemin}[${JSON.stringify(cle)}]`;
return chemin === '' ? cle : `${chemin}.${cle}`;
}
// AAAA-MM-JJ d'un jour du calendrier grégorien : mois de 1 à 12, jour de 1
// au dernier du mois, février à 29 jours les années bissextiles.
const AAAA_MM_JJ = /^(\d{4})-(\d{2})-(\d{2})$/;
const JOURS_PAR_MOIS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
function estJour(texte) {
const morceaux = AAAA_MM_JJ.exec(texte);
if (morceaux === null) return false;
const [annee, mois, jour] = morceaux.slice(1).map(Number);
const bissextile = annee % 4 === 0 && (annee % 100 !== 0 || annee % 400 === 0);
const dernier = mois === 2 && bissextile ? 29 : JOURS_PAR_MOIS[mois - 1];
return mois >= 1 && mois <= 12 && jour >= 1 && jour <= dernier;
}
// Vrai quand valeur, non nulle, a le genre de sa règle et tient dans son
// domaine. Un entier du fichier est un entier exact, au plus 2^53 − 1 :
// au-delà, deux entiers distincts se lisent comme un seul, et la relecture
// rend un autre texte.
function conforme(valeur, { genre, min, max, valeurs }) {
switch (genre) {
case 'objet':
return estObjet(valeur);
case 'liste':
return Array.isArray(valeur);
case 'chaine':
return typeof valeur === 'string' && valeur !== '';
case 'entier':
return Number.isSafeInteger(valeur) && valeur >= min && valeur <= max;
case 'nombre':
return Number.isFinite(valeur);
case 'booleen':
return typeof valeur === 'boolean';
case 'parmi':
return valeurs.includes(valeur);
case 'date':
return typeof valeur === 'string' && estJour(valeur);
}
}
/**
* Clés propres de l'objet valeur, rangées par unités UTF-16 croissantes :
* leur ordre ne dépend que de leur ensemble, non de l'ordre dans lequel
* l'objet les a reçues. C'est le seul parcours des clés d'un objet du
* stockage : l'analyse y cherche une clé inconnue, et canonique.js recopie
* ainsi ce que le schéma ne décrit pas.
*
* @param {Object} valeur
* @returns {string[]}
*/
export function clesRangees(valeur) {
return Object.keys(valeur).sort(comparerTextes);
}
// Plus petite des clés de valeur absentes de cles, en comparant les unités
// UTF-16, ou undefined.
const cleInconnue = (valeur, cles) => clesRangees(valeur).find((cle) => !cles.has(cle));
// Format du fichier lu, contrôlé avant toute autre règle : il dit comment
// lire le reste. Une racine qui n'est pas un objet, un en-tête ou un format
// absents sont des fautes de forme ; un format qui n'est pas un entier ≥ 1
// est inconnu.
function lireFormat(lu) {
if (!estObjet(lu)) illisible('FORME', '');
if (!estObjet(lu.entete)) illisible('FORME', 'entete');
if (lu.entete.format === undefined) illisible('FORME', 'entete.format');
const { format } = lu.entete;
if (!Number.isSafeInteger(format) || format < 1) illisible('FORMAT_INCONNU', 'entete.format');
return format;
}
// Valeur réduite aux clés que sa règle connaît, en profondeur, chaque objet
// refait dans l'ordre du schéma, propositions et retenu compris : la lecture
// d'un format plus récent ignore les clés qu'elle ne connaît pas, et un tel
// fichier ne se réécrit jamais. Une valeur qui n'a pas la sorte de sa règle
// passe telle quelle : le contrôle de forme la nomme.
function reduire(valeur, regleDeValeur) {
if (regleDeValeur.genre === 'objet' && estObjet(valeur)) {
const reduite = {};
for (const [cle, regleDuChamp] of regleDeValeur.champs) {
if (valeur[cle] !== undefined) reduite[cle] = reduire(valeur[cle], regleDuChamp);
}
return reduite;
}
if (regleDeValeur.genre === 'liste' && Array.isArray(valeur)) {
return valeur.map((element) => reduire(element, regleDeValeur.element));
}
return valeur;
}
// Chemin de la première valeur qui sort de sa règle, ou null. Une règle
// aPart rencontrée sous une autre n'examine que son conteneur ; racine
// vrai fait examiner tout le contenu de la règle donnée, même aPart, et
// faux la lit à sa place sous une autre, comme l'analyse.
function fauteDeForme(valeur, regleDeValeur, chemin, racine) {
if (valeur === null && regleDeValeur.nul) return null;
if (!conforme(valeur, regleDeValeur)) return chemin;
if (regleDeValeur.aPart && !racine) return null;
if (regleDeValeur.genre === 'objet') {
for (const [cle, regleDuChamp] of regleDeValeur.champs) {
const faute = fauteDeForme(valeur[cle], regleDuChamp, joindre(chemin, cle), false);
if (faute !== null) return faute;
}
const inconnue = cleInconnue(valeur, regleDeValeur.cles);
return inconnue === undefined ? null : joindre(chemin, inconnue);
}
if (regleDeValeur.genre === 'liste') {
for (let rang = 0; rang < valeur.length; rang += 1) {
const faute = fauteDeForme(valeur[rang], regleDeValeur.element, `${chemin}[${rang}]`, false);
if (faute !== null) return faute;
}
}
return null;
}
/**
* Chemin de la première valeur qui sort de sa règle, en profondeur, ou null
* quand valeur est conforme ; ne lève jamais. D'un objet, chaque champ dans
* l'ordre du schéma, puis la plus petite de ses clés inconnues ; d'une
* liste, chaque élément dans l'ordre des rangs. null est conforme à une
* règle qui l'admet. Un champ absent se lit undefined, qu'aucune règle
* n'admet, et se nomme par son chemin comme une valeur fautive. Le chemin
* suit la convention d'analyser, à partir de chemin, '' par défaut.
*
* Une règle aPart rencontrée sous une autre n'examine que son conteneur :
* son contenu s'examine à part, la règle donnée ici pour racine. analyser
* contrôle ainsi le fichier sans entrer dans les propositions ni dans le
* retenu ; le contrôle des placements les examine chacun sous leur règle.
*
* @param {*} valeur
* @param {import('./types.js').Regle} regle
* @param {string} [chemin]
* @returns {string|null}
*/
export function premiereFaute(valeur, regle, chemin = '') {
return fauteDeForme(valeur, regle, chemin, true);
}
/**
* premiereFaute de valeur lue à la place d'une règle regle sous une autre,
* comme l'analyse la lit : une règle aPart n'y examine que son conteneur,
* même donnée ici. Les correctifs contrôlent ainsi le retenu posé à sa
* place, que la lecture admet hors de sa règle.
*
* @param {*} valeur
* @param {import('./types.js').Regle} regle
* @param {string} [chemin]
* @returns {string|null}
*/
export function premiereFauteEnPlace(valeur, regle, chemin = '') {
return fauteDeForme(valeur, regle, chemin, false);
}
// Identifiants des participants, puis des tables : chacun unique, et sous
// prochainsIds, qui ne recule jamais (§ 4) ; d'un doublon, le second est
// fautif. Ceux des propositions et la proposition d'origine du retenu ne se
// comparent pas ici : une proposition qui contredit prochainsIds.proposition
// tombe seule, et le retenu se signale sans fermer la saisie (§ 8.9, point
// 3). faute(chemin) lève l'écart.
function examinerIdentifiants({ prochainsIds, participants, tables }, faute) {
for (const [cle, enregistrements, prochain] of [
['participants', participants, prochainsIds.participant],
['tables', tables, prochainsIds.table],
]) {
const vus = new Set();
enregistrements.forEach(({ id }, rang) => {
if (id >= prochain || vus.has(id)) faute(`charge.${cle}[${rang}].id`);
vus.add(id);
});
}
}
// Tour de chaque réservation : null pour la portée « tous », de 1 à R pour
// la portée « tour » ; faute(chemin) lève l'écart.
function examinerTours({ evenement, reservations }, faute) {
reservations.forEach(({ portee, tour }, rang) => {
const admis = portee === 'tous' ? tour === null : tour !== null && tour <= evenement.tours;
if (!admis) faute(`charge.reservations[${rang}].tour`);
});
}
// Comptes de l'en-tête qui portent sur les placements : une proposition ou
// un retenu perdus se recalculent, la saisie non.
const COMPTES_DES_PLACEMENTS = new Set(['propositions', 'retenu']);
// Chaque compte de la saisie dans l'en-tête contre la charge, dans l'ordre
// du schéma ; plus s'ajoute aux détails du refus. Rend ce que les comptes
// des placements annoncent sans que la charge le porte : propositions, le
// nombre annoncé au-delà de la liste ; retenu, vrai quand il est annoncé et
// absent. Un compte plus petit que sa liste ne dit pas quelle proposition
// est en trop : la liste se lit telle quelle.
function examinerComptes(comptes, charge, plus) {
const reels = comptesDe(charge);
for (const [cle] of COMPTES.champs) {
if (COMPTES_DES_PLACEMENTS.has(cle)) continue;
if (comptes[cle] !== reels[cle]) illisible('COMPTES', `entete.comptes.${cle}`, plus);
}
return {
propositions: Math.max(0, comptes.propositions - reels.propositions),
retenu: comptes.retenu > reels.retenu,
};
}
// Références des réservations, puis des titres : la personne et la table
// désignées existent, et le siège, quand il est donné, tient dans la
// capacité courante de la table (§ 6.2). La réservation d'une personne
// exclue reste admise : elle est suspendue, non fautive (§ 4.4). plus
// s'ajoute aux détails du refus.
function examinerReferences(charge, plus) {
const personnes = new Set(charge.participants.map(({ id }) => id));
const tables = new Map(charge.tables.map((table) => [table.id, table]));
const examinerPlace = ({ table, siege }, chemin) => {
const designee = tables.get(table);
if (designee === undefined) illisible('REFERENCE', `${chemin}.table`, plus);
if (siege !== null && siege > capacite(charge, designee)) illisible('REFERENCE', `${chemin}.siege`, plus);
};
charge.reservations.forEach((reservation, rang) => {
const chemin = `charge.reservations[${rang}]`;
if (!personnes.has(reservation.participant)) illisible('REFERENCE', `${chemin}.participant`, plus);
examinerPlace(reservation, chemin);
});
charge.titres.forEach((titre, rang) => examinerPlace(titre, `charge.titres[${rang}]`));
}
/**
* Lit le texte d'un fichier d'état, sans rien écrire (§ 8.4, § 8.8). Une
* marque d'ordre d'octets en tête est ignorée. Rend l'en-tête, la charge,
* formatPlusRecent, vrai quand le format dépasse FORMAT, et manquants, ce que
* les comptes des propositions et du retenu annoncent sans que la charge le
* porte, qui ne refuse jamais le fichier (§ 8.9, point 3). Au format courant,
* l'en-tête et la charge sont ceux que le texte porte, ni copiés ni triés :
* canoniser les met dans l'ordre canonique. Un fichier d'un format plus
* récent s'ouvre en lecture seule et ne se réécrit jamais : l'en-tête et la
* charge rendus sont des copies réduites aux clés que le schéma connaît, et
* une faute de forme y porte la raison FORMAT_PLUS_RECENT ; les comptes et
* les références gardent leur raison, et chaque refus porte le format lu
* dans ses détails. Des propositions et du retenu, seul le conteneur se
* contrôle ici : examiner (placements.js) juge le reste, identifiants
* compris, sans refuser le fichier (§ 8.9, point 3).
*
* Lève ErreurStockage('ETAT_ILLISIBLE', { raison, chemin }), et format
* pour un fichier plus récent, à la première faute, dans l'ordre que donne
* l'en-tête du module. chemin désigne l'élément fautif : clés séparées par
* un point, rangs entre crochets, une clé qui ne s'écrit pas comme un
* identifiant entre crochets en JSON ; '' pour la racine ; null quand le
* texte n'a rien à désigner (VIDE, JSON). D'un objet, un champ du schéma
* fautif est nommé avant une clé inconnue, et de plusieurs clés inconnues,
* la plus petite.
*
* @param {string} texte
* @returns {import('./types.js').Lecture}
*/
export function analyser(texte) {
const sansMarque = texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte;
if (sansMarque.trim() === '') illisible('VIDE', null);
let lu;
try {
lu = JSON.parse(sansMarque);
} catch {
illisible('JSON', null);
}
const format = lireFormat(lu);
const formatPlusRecent = format > FORMAT;
// Tout refus d'un fichier plus récent porte le format lu.
const plus = formatPlusRecent ? { format } : {};
const fichier = formatPlusRecent ? reduire(lu, SCHEMA) : lu;
const faute = (chemin) => illisible(formatPlusRecent ? 'FORMAT_PLUS_RECENT' : 'FORME', chemin, plus);
const cheminFautif = premiereFaute(fichier, SCHEMA);
if (cheminFautif !== null) faute(cheminFautif);
const { entete, charge } = fichier;
examinerIdentifiants(charge, faute);
examinerTours(charge, faute);
const manquants = examinerComptes(entete.comptes, charge, plus);
examinerReferences(charge, plus);
return { entete, charge, formatPlusRecent, manquants };
}
/**
* Configuration du moteur tirée d'une charge : les participants avec leur
* appartenance et leur exclusion ; les tables avec leur capacité courante ;
* le nombre de tours ; les réservations sans leur siège, que le moteur ne
* lit pas, et sans tour pour la portée « tous » ; les quatre contraintes des
* réglages. Les tables vont par identifiant croissant, l'ordre du texte
* canonique : le moteur assoit table après table, et une charge aux tables
* dans un autre ordre, relue après un enregistrement, ne régénérerait plus
* ses propositions (§ 8.9). Les participants et les réservations gardent
* l'ordre de la charge : le rang d'une réservation que nomme une
* ErreurConfiguration est son rang dans la charge.
*
* @param {import('./types.js').Charge} charge
* @returns {import('../moteur/types.js').Configuration}
*/
export function configurationDepuisCharge(charge) {
const { evenement, reglages } = charge;
return {
participants: charge.participants.map(({ id, nom, appartenance, exclu }) => ({ id, nom, appartenance, exclu })),
tables: [...charge.tables]
.sort((a, b) => a.id - b.id)
.map((table) => ({ id: table.id, numero: table.numero, capacite: capacite(charge, table) })),
tours: evenement.tours,
reservations: charge.reservations.map(({ participant, table, portee, tour }) =>
portee === 'tous' ? { participant, table, portee } : { participant, table, portee, tour },
),
contraintes: {
separerAppartenances: reglages.separerAppartenances,
nouveauxVoisins: reglages.nouveauxVoisins,
nouvelleTable: reglages.nouvelleTable,
varierAppartenances: reglages.varierAppartenances,
},
};
}
/**
* État que le contenu impose à une copie (§ 8.7, § 9) : retenu quand un
* placement est retenu, proposé quand des propositions existent, brouillon
* sinon. L'état inscrit dans l'événement n'est pas lu : la copie d'un plan
* bloqué n'est pas bloquée.
*
* @param {import('./types.js').Charge} charge
* @returns {'brouillon'|'propose'|'retenu'}
*/
export function etatDeduit({ retenu, propositions }) {
if (retenu !== null) return 'retenu';
return propositions.length > 0 ? 'propose' : 'brouillon';
}
/**
* Capacité courante d'une table (§ 6.1) : ses sièges quand elle est
* surchargée, le défaut de l'événement quand elle le suit (sieges null).
* table est l'enregistrement de charge.tables : un identifiant lève
* TypeError, au lieu de rendre le défaut sans bruit.
*
* @param {import('./types.js').Charge} charge
* @param {import('./types.js').Table} table
* @returns {number}
*/
export function capacite(charge, table) {
if (!estObjet(table)) {
throw new TypeError(`capacite : enregistrement de table attendu, reçu ${JSON.stringify(table)}`);
}
return table.sieges ?? charge.evenement.siegesParDefaut;
}