[ADD] storage: patches between two payloads, keyed by record id
The journal stores a gesture as a patch from the previous payload, not a full copy. Records with an id are compared by id, so adding a person does not shift the others; same-length lists compare element by element; other values are set whole. Moving one person in one round of a large plan touches two seat lists. Applying never alters its input and refuses a patch that does not fit, naming the operation. Checked: 25 tests including a property over generated payloads. --- FR --- [ADD] stockage : correctifs entre deux charges, par identifiant Le journal range un geste comme un correctif depuis la charge précédente, non comme une copie entière. Les enregistrements à identifiant se comparent par identifiant, si bien qu'ajouter une personne ne décale pas les autres ; les listes de même longueur se comparent élément par élément ; le reste se pose en entier. Déplacer une personne à un tour d'un grand plan touche deux listes de sièges. Appliquer ne modifie jamais son entrée et refuse un correctif qui ne s'applique pas, en nommant l'opération. Vérifié : 25 épreuves, dont une propriété sur des charges tirées. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
parent
d0f4081961
commit
f24896c4a1
2 changed files with 1715 additions and 0 deletions
336
src/stockage/correctifs.js
Normal file
336
src/stockage/correctifs.js
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les correctifs du journal (§ 8.6, § 8.9). Un correctif est la liste des
|
||||
// opérations qui mènent d'une charge à une autre ; le journal en porte un
|
||||
// dans chaque entrée qui n'est pas un instantané.
|
||||
//
|
||||
// Une opération pose une valeur au bout d'un chemin, ou retire
|
||||
// l'enregistrement qu'il désigne : { op: 'poser', chemin, valeur } ou
|
||||
// { op: 'retirer', chemin }. Un chemin est une liste d'étapes depuis la
|
||||
// charge : une clé d'objet, chaîne ; un rang de liste, entier ≥ 0 ; ou
|
||||
// { id }, qui désigne dans une liste d'enregistrements à identifiant —
|
||||
// participants, tables, propositions — l'enregistrement de cet identifiant,
|
||||
// et non une place.
|
||||
//
|
||||
// Les deux fonctions travaillent sur les copies canoniques de canonique.js :
|
||||
// un rang y désigne une place de l'ordre canonique, que la charge seule
|
||||
// détermine, et non de l'ordre dans lequel une liste s'est remplie en
|
||||
// mémoire. Un correctif calculé depuis une charge s'applique ainsi à toute
|
||||
// charge qui lui est égale à l'ordre près, quelle que soit la façon dont
|
||||
// l'une ou l'autre s'est construite.
|
||||
//
|
||||
// Le correctif reste local (§ 8.9). Une personne qui prend, au même tour
|
||||
// d'une proposition, la place d'une autre à une autre table touche les deux
|
||||
// listes de ces tables et rien d'autre : une entrée de chacune quand chaque
|
||||
// personne prend le rang de l'autre, ce que garde l'ordre des sièges quand
|
||||
// ils sont attribués. Sans attribution, chaque liste se trie par
|
||||
// identifiant, et l'entrée qui change de rang décale celles qu'elle
|
||||
// franchit dans sa liste. Une liste qui change de longueur se pose entière.
|
||||
//
|
||||
// Une charge que rend appliquer, l'analyse l'admet à la forme près comme
|
||||
// celle qu'il reçoit : une valeur posée doit être admise à sa place, lue
|
||||
// par le parcours même de l'analyse (premiereFauteEnPlace). Dans les
|
||||
// propositions et le retenu, l'analyse ne lit que le conteneur, et le
|
||||
// contrôle des placements juge le reste (§ 8.9) ; une valeur posée plus bas
|
||||
// ne s'examine donc pas. Un retenu que sa règle refuse, que la lecture
|
||||
// admet et que le contrôle des placements garde, canonique.js le recopie
|
||||
// hors du schéma, et difference le pose entier : le journal le libère, le
|
||||
// remet en place ou le remplace tel quel, quelle que soit sa faute.
|
||||
import { canoniser, retenuHorsDeSaRegle } from './canonique.js';
|
||||
import { SCHEMA, premiereFauteEnPlace } from './document.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
|
||||
/**
|
||||
* Une étape d'un chemin : la clé d'un objet, le rang d'un élément de liste,
|
||||
* ou { id }, l'enregistrement de cet identifiant dans une liste
|
||||
* d'enregistrements à identifiant.
|
||||
* @typedef {string|number|{id: number}} Etape
|
||||
*
|
||||
* @typedef {Object} OperationPoser
|
||||
* @property {'poser'} op
|
||||
* @property {Etape[]} chemin non vide
|
||||
* @property {*} valeur valeur JSON, null comprise
|
||||
*
|
||||
* @typedef {Object} OperationRetirer
|
||||
* @property {'retirer'} op
|
||||
* @property {Etape[]} chemin non vide, terminé par { id }
|
||||
*
|
||||
* @typedef {OperationPoser|OperationRetirer} Operation
|
||||
*/
|
||||
|
||||
// Règle de la charge dans le schéma du fichier d'état.
|
||||
const CHARGE = SCHEMA.champs.find(([cle]) => cle === 'charge')[1];
|
||||
|
||||
// Vrai pour un objet qui n'est ni null ni une liste.
|
||||
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||||
|
||||
// Vrai quand chaque élément de liste est un objet dont l'id est un entier
|
||||
// exact, et qu'aucun id ne s'y répète : la seule forme où { id } désigne un
|
||||
// enregistrement et un seul. Une copie canonique ne porte que les clés du
|
||||
// schéma, où seuls les participants, les tables et les propositions ont un
|
||||
// champ id : aucune autre liste non vide n'a cette forme. Une liste vide
|
||||
// l'a, quelle que soit sa sorte, sans que comparer en tire un { id } : deux
|
||||
// listes vides ne donnent rien, et contre une liste non vide, c'est la
|
||||
// forme de celle-ci qui décide.
|
||||
function identifiantsDistincts(liste) {
|
||||
const vus = new Set();
|
||||
for (const element of liste) {
|
||||
if (!estObjet(element) || !Number.isSafeInteger(element.id) || vus.has(element.id)) return false;
|
||||
vus.add(element.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ajoute à operations ce qui mène de a à b, deux valeurs canoniques de la
|
||||
// règle regle, au bout de chemin. null contre une valeur, et deux scalaires
|
||||
// différents, posent b. Un retenu que sa règle refuse, d'un côté ou de
|
||||
// l'autre, n'a pas de champs que le schéma décrive : b se pose entier quand
|
||||
// les deux copies canoniques s'écrivent autrement, et rien sinon. Deux
|
||||
// objets se comparent champ par champ, dans l'ordre du schéma. Deux listes
|
||||
// d'enregistrements à identifiants distincts se comparent par identifiant ;
|
||||
// deux autres listes, rang par rang quand elles ont la même longueur, et b
|
||||
// se pose entière sinon.
|
||||
function comparer(a, b, regle, chemin, operations) {
|
||||
if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) {
|
||||
if (a !== b) operations.push({ op: 'poser', chemin, valeur: b });
|
||||
} else if (retenuHorsDeSaRegle(a, regle) || retenuHorsDeSaRegle(b, regle)) {
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) operations.push({ op: 'poser', chemin, valeur: b });
|
||||
} else if (regle.genre === 'objet') {
|
||||
for (const [cle, regleDeCle] of regle.champs) comparer(a[cle], b[cle], regleDeCle, [...chemin, cle], operations);
|
||||
} else if (identifiantsDistincts(a) && identifiantsDistincts(b)) {
|
||||
comparerParIdentifiant(a, b, regle.element, chemin, operations);
|
||||
} else if (a.length === b.length) {
|
||||
a.forEach((element, rang) => comparer(element, b[rang], regle.element, [...chemin, rang], operations));
|
||||
} else {
|
||||
operations.push({ op: 'poser', chemin, valeur: b });
|
||||
}
|
||||
}
|
||||
|
||||
// Fusion de deux listes d'enregistrements rangées par identifiant croissant,
|
||||
// comme les laisse canoniser : un identifiant de a seul est retiré, un
|
||||
// identifiant de b seul est posé entier, un identifiant commun se compare
|
||||
// champ par champ. Les opérations suivent l'ordre des identifiants.
|
||||
function comparerParIdentifiant(a, b, regleElement, chemin, operations) {
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length || j < b.length) {
|
||||
const idA = i < a.length ? a[i].id : Infinity;
|
||||
const idB = j < b.length ? b[j].id : Infinity;
|
||||
if (idA < idB) {
|
||||
operations.push({ op: 'retirer', chemin: [...chemin, { id: idA }] });
|
||||
i += 1;
|
||||
} else if (idB < idA) {
|
||||
operations.push({ op: 'poser', chemin: [...chemin, { id: idB }], valeur: b[j] });
|
||||
j += 1;
|
||||
} else {
|
||||
comparer(a[i], b[j], regleElement, [...chemin, { id: idA }], operations);
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Correctif menant de a à b (§ 8.6), calculé entre leurs copies canoniques :
|
||||
* [] quand elles sont égales, c'est-à-dire quand a et b ne diffèrent que par
|
||||
* l'ordre de leurs clés ou d'une liste que canoniser range. Les opérations
|
||||
* suivent l'ordre du schéma, puis celui des identifiants, puis celui des
|
||||
* rangs : le correctif ne dépend que des deux charges. Une valeur posée est
|
||||
* prise à la copie canonique de b, et ne partage aucun objet avec b. Ni a ni
|
||||
* b ne sont modifiées.
|
||||
*
|
||||
* Règles : les objets clé par clé dans l'ordre du schéma ; les listes
|
||||
* d'enregistrements à identifiant — participants, tables, propositions —
|
||||
* par identifiant : retirés, posés entiers, communs comparés ; une telle
|
||||
* liste dont un identifiant se répète, ce que la lecture admet pour deux
|
||||
* propositions, se compare comme les autres listes ; les autres listes de
|
||||
* même longueur rang par rang, et de longueurs différentes posées
|
||||
* entières ; null contre une valeur, et deux scalaires différents, posent la
|
||||
* valeur de b ; un retenu que sa règle refuse, dans a ou dans b, se pose
|
||||
* entier quand les deux diffèrent, sans rien pour lui sinon.
|
||||
*
|
||||
* @param {import('./types.js').Charge} a
|
||||
* @param {import('./types.js').Charge} b
|
||||
* @returns {Operation[]}
|
||||
*/
|
||||
export function difference(a, b) {
|
||||
const operations = [];
|
||||
comparer(canoniser(a), canoniser(b), CHARGE, [], operations);
|
||||
return operations;
|
||||
}
|
||||
|
||||
// Règle de ce que désigne etape dans une valeur de règle regle, lue dans le
|
||||
// schéma : pour un objet, le champ de cette clé ; pour une liste, son
|
||||
// élément, la valeur refusant toute étape qui n'y est ni un rang ni { id }.
|
||||
// undefined quand l'étape sort du schéma — une clé qu'une valeur posée dans
|
||||
// une proposition ou dans le retenu apporte, par exemple —, et pour toute
|
||||
// étape qui la suit.
|
||||
function regleSuivante(regle, etape) {
|
||||
if (regle?.genre === 'objet') return regle.champs.find(([cle]) => cle === etape)?.[1];
|
||||
return regle?.genre === 'liste' ? regle.element : undefined;
|
||||
}
|
||||
|
||||
// Vrai quand regle est celle d'une liste d'enregistrements à identifiant,
|
||||
// dont l'élément a un champ id : participants, tables, propositions.
|
||||
const estListeDEnregistrements = (regle) => regle?.element?.cles?.has('id') === true;
|
||||
|
||||
// Lecture de chemin dans le schéma, depuis la charge : regle, la règle de la
|
||||
// place qu'il désigne, undefined quand une étape sort du schéma ; et
|
||||
// sousAPart, vrai quand une règle aPart le précède, c'est-à-dire quand la
|
||||
// place est dans une proposition ou dans le retenu, que l'analyse n'examine
|
||||
// que comme conteneurs (§ 8.9). null quand une étape { id } ne tombe pas
|
||||
// dans une liste d'enregistrements à identifiant. C'est le schéma qui en
|
||||
// décide, et non le contenu de la liste : une liste vide n'a aucun élément
|
||||
// qui montre la forme des autres, et tout identifiant y est absent, ce qui
|
||||
// ouvrirait une insertion par { id } dans une réserve, une liste de table
|
||||
// ou les réservations. Une clé et un rang se contrôlent sur la valeur, qui
|
||||
// les porte ou non.
|
||||
function lireChemin(chemin) {
|
||||
let regle = CHARGE;
|
||||
let sousAPart = false;
|
||||
for (const etape of chemin) {
|
||||
if (estObjet(etape) && !estListeDEnregistrements(regle)) return null;
|
||||
sousAPart ||= regle?.aPart === true;
|
||||
regle = regleSuivante(regle, etape);
|
||||
}
|
||||
return { regle, sousAPart };
|
||||
}
|
||||
|
||||
// Vrai quand l'analyse admettrait valeur à la place que lecture désigne
|
||||
// (§ 8.8) : hors des propositions et du retenu, sa forme entière ; à la
|
||||
// place de la liste des propositions ou du retenu, son conteneur ; plus bas,
|
||||
// aucune valeur ne s'examine, et le contrôle des placements juge ce qu'elle
|
||||
// y fait (§ 8.9). Une place hors du schéma n'en admet aucune.
|
||||
function valeurAdmise(valeur, { regle, sousAPart }) {
|
||||
if (sousAPart) return true;
|
||||
return regle !== undefined && premiereFauteEnPlace(valeur, regle) === null;
|
||||
}
|
||||
|
||||
// Rangs des éléments de liste dont l'id vaut id, ou null quand un élément
|
||||
// n'est pas un objet à id entier exact, ce qu'une valeur posée dans la liste
|
||||
// des propositions peut y mettre : { id } n'y désigne rien. liste est une
|
||||
// liste d'enregistrements du schéma, que valeurAdmise garde une liste.
|
||||
function rangsDeLIdentifiant(liste, id) {
|
||||
if (!Number.isSafeInteger(id)) return null;
|
||||
const rangs = [];
|
||||
for (let rang = 0; rang < liste.length; rang += 1) {
|
||||
const element = liste[rang];
|
||||
if (!estObjet(element) || !Number.isSafeInteger(element.id)) return null;
|
||||
if (element.id === id) rangs.push(rang);
|
||||
}
|
||||
return rangs;
|
||||
}
|
||||
|
||||
// Vrai quand etape est une clé propre de l'objet conteneur. Une clé héritée
|
||||
// du prototype commun des objets — __proto__, constructor, toString — n'en
|
||||
// est pas une : aucune opération ne lit ni n'écrit ce prototype, que partage
|
||||
// tout le processus.
|
||||
const estCleDe = (conteneur, etape) =>
|
||||
typeof etape === 'string' && estObjet(conteneur) && Object.hasOwn(conteneur, etape);
|
||||
// Vrai quand etape est un rang de la liste conteneur.
|
||||
const estRangDe = (conteneur, etape) =>
|
||||
Number.isSafeInteger(etape) && etape >= 0 && Array.isArray(conteneur) && etape < conteneur.length;
|
||||
// Rangs que désigne l'étape { id } dans conteneur, ou null.
|
||||
const rangsDe = (conteneur, etape) => (estObjet(etape) ? rangsDeLIdentifiant(conteneur, etape.id) : null);
|
||||
|
||||
// Valeur que désigne etape dans conteneur, ou undefined : une clé absente,
|
||||
// un rang hors de la liste, un { id } qui ne désigne pas exactement un
|
||||
// enregistrement, une étape d'une autre sorte. Aucune valeur d'une charge
|
||||
// n'est undefined.
|
||||
function descendre(conteneur, etape) {
|
||||
if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape)) return conteneur[etape];
|
||||
const rangs = rangsDe(conteneur, etape);
|
||||
return rangs?.length === 1 ? conteneur[rangs[0]] : undefined;
|
||||
}
|
||||
|
||||
// Pose une copie de valeur à la place que désigne etape dans conteneur :
|
||||
// une clé qu'il porte déjà, un rang qu'il contient, ou { id } absent de la
|
||||
// liste, quand valeur est l'enregistrement de cet id ; l'enregistrement
|
||||
// s'insère alors avant le premier d'id plus grand, ce qui garde une liste
|
||||
// rangée par identifiant croissant. Rend faux quand etape ne désigne
|
||||
// aucune de ces places.
|
||||
function poser(conteneur, etape, valeur) {
|
||||
if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape)) {
|
||||
conteneur[etape] = structuredClone(valeur);
|
||||
return true;
|
||||
}
|
||||
if (rangsDe(conteneur, etape)?.length !== 0 || !estObjet(valeur) || valeur.id !== etape.id) return false;
|
||||
const suivant = conteneur.findIndex((element) => element.id > etape.id);
|
||||
conteneur.splice(suivant === -1 ? conteneur.length : suivant, 0, structuredClone(valeur));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retire de conteneur l'enregistrement que désigne l'étape { id } ; rend
|
||||
// faux quand elle n'en désigne pas exactement un. Une clé ou un rang ne se
|
||||
// retirent pas : les objets de la charge ont les clés du schéma, et une
|
||||
// liste sans identifiants se pose entière quand sa longueur change.
|
||||
function retirer(conteneur, etape) {
|
||||
const rangs = rangsDe(conteneur, etape);
|
||||
if (rangs?.length !== 1) return false;
|
||||
conteneur.splice(rangs[0], 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Applique operation à charge, en place ; rend faux, sans rien changer,
|
||||
// quand elle est mal formée, qu'une étape { id } de son chemin tombe hors
|
||||
// d'une liste d'enregistrements, que ce chemin ne désigne pas une place de
|
||||
// charge, ou que la valeur posée n'y serait pas admise par l'analyse. Un
|
||||
// chemin vide n'a pas de dernière étape, et undefined n'en désigne aucune.
|
||||
// Une opération poser porte une valeur : JSON n'en écrit pas d'undefined.
|
||||
function executer(charge, operation) {
|
||||
if (!estObjet(operation) || !Array.isArray(operation.chemin)) return false;
|
||||
const { op, chemin, valeur } = operation;
|
||||
const lecture = lireChemin(chemin);
|
||||
if (lecture === null) return false;
|
||||
let conteneur = charge;
|
||||
for (const etape of chemin.slice(0, -1)) {
|
||||
conteneur = descendre(conteneur, etape);
|
||||
if (conteneur === undefined) return false;
|
||||
}
|
||||
const derniere = chemin[chemin.length - 1];
|
||||
if (op === 'poser') {
|
||||
return valeur !== undefined && valeurAdmise(valeur, lecture) && poser(conteneur, derniere, valeur);
|
||||
}
|
||||
if (op === 'retirer') return retirer(conteneur, derniere);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique un correctif à la copie canonique de a (§ 8.6), opération après
|
||||
* opération, et rend cette copie : une charge neuve, qui ne partage aucun
|
||||
* objet avec a ni avec le correctif. a n'est pas modifiée. Appliqué à a, le
|
||||
* correctif de difference(a, b) rend la copie canonique de b. Une insertion
|
||||
* par { id } garde l'ordre des identifiants.
|
||||
*
|
||||
* Lève ErreurStockage('CORRECTIF', { rang }) à la première opération qui ne
|
||||
* s'applique pas — rang est sa place dans le correctif, à partir de 0 — :
|
||||
* une opération mal formée ; une étape { id } hors des participants, des
|
||||
* tables et des propositions, même dans une liste vide ; une étape qui ne
|
||||
* trouve pas sa clé, que l'objet porte en propre et n'hérite pas, son rang
|
||||
* ou exactement un enregistrement ; poser à une clé que l'objet ne porte
|
||||
* pas ou au-delà de la fin d'une liste ; poser par { id } un identifiant
|
||||
* déjà présent, ou une valeur qui n'est pas l'enregistrement de cet
|
||||
* identifiant ; poser une valeur que l'analyse refuserait à sa place —
|
||||
* hors des propositions et du retenu, une valeur qui sort de sa règle, en
|
||||
* genre, en domaine, null compris, ou en clés ; à la place de la liste des
|
||||
* propositions ou du retenu, un autre conteneur — ; retirer autre chose
|
||||
* qu'un enregistrement par { id }. Le contrôle porte sur la forme : une
|
||||
* opération ne porte pas la valeur qu'elle remplace, et un correctif dont
|
||||
* chaque chemin existe aussi dans une autre charge s'y applique sans lever.
|
||||
* Un correctif qui n'est pas une liste lève TypeError.
|
||||
*
|
||||
* @param {import('./types.js').Charge} a
|
||||
* @param {Operation[]} correctif
|
||||
* @returns {import('./types.js').Charge}
|
||||
*/
|
||||
export function appliquer(a, correctif) {
|
||||
if (!Array.isArray(correctif)) {
|
||||
throw new TypeError(`appliquer : liste d'opérations attendue, reçu ${JSON.stringify(correctif)}`);
|
||||
}
|
||||
const charge = canoniser(a);
|
||||
correctif.forEach((operation, rang) => {
|
||||
if (!executer(charge, operation)) throw new ErreurStockage('CORRECTIF', { rang });
|
||||
});
|
||||
return charge;
|
||||
}
|
||||
1379
src/stockage/correctifs.test.js
Normal file
1379
src/stockage/correctifs.test.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue