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
1415 lines
72 KiB
JavaScript
1415 lines
72 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
// Le dépôt des événements (§ 8.1, § 8.2, § 8.4, § 8.6, § 8.8, § 8.9) : lister
|
|
// un dossier de travail, créer un événement, l'ouvrir, enregistrer ses gestes,
|
|
// nommer ses instants, le reprendre après une panne, l'écrire ailleurs. Chaque
|
|
// fonction reçoit le système de fichiers (§ 13.4, § 14.10) ; aucune ne lit
|
|
// l'horloge ni un aléa : horodatage et version entrent en paramètre, et les
|
|
// libellés, textes figés, viennent de l'appelant (§ 14.6).
|
|
//
|
|
// Un événement est une paire de fichiers que l'identifiant interne apparie,
|
|
// jamais le nom (§ 8.6) : <base>.gtt.json, l'état, et le journal de la même
|
|
// base quand il porte cet identifiant, sinon le premier du dossier, par nom,
|
|
// qui le porte sans appartenir à sa propre base — dont l'état, ou faute
|
|
// d'état lisible le .precedent, porte le même identifiant. Un état copié à la
|
|
// main dans le même dossier ne s'apparie donc pas au journal de l'original.
|
|
// Un état illisible s'apparie par l'identifiant de son .precedent ; sans
|
|
// lui, au journal de sa base, quand aucun état lisible du dossier ne porte
|
|
// l'identifiant de ce journal. L'écriture suit la même règle : le journal
|
|
// d'un événement dont le journal de la base porte un autre événement s'écrit
|
|
// dans celui que son identifiant apparie, et ne se crée sous une base libre
|
|
// qu'à défaut. Lister, ouvrir et ouvrir un secours ne font que lire (§ 8.4) :
|
|
// ce qu'ils trouvent à réparer, le premier geste le répare.
|
|
//
|
|
// Un geste écrit le journal d'abord, l'état ensuite (§ 8.2). Le journal reçoit
|
|
// une ligne par ajout en fin quand il continue tel quel. Il se réécrit en
|
|
// entier, par une écriture atomique, quand il reçoit une entrée de reprise,
|
|
// seule ou avant la ligne d'un geste ou d'un jalon, quand sa lecture a écarté
|
|
// des lignes, que la lecture suivante ne dépasserait pas, quand il manque, et
|
|
// quand il dépasse PLAFOND_ENTREES. Un journal qui a disparu depuis la
|
|
// dernière écriture ne reçoit jamais d'ajout, qui commencerait un fichier
|
|
// sans sa ligne d'ouverture : il se réécrit en entier, ouverture et entrées
|
|
// en mémoire comprises, et l'Ouvert l'annonce, JOURNAL_RECREE. Le correctif
|
|
// d'une entrée part de la charge que restitue l'entrée qui la précède dans
|
|
// le journal, jamais de la charge ouverte : un correctif appliqué à une autre
|
|
// charge que la sienne s'applique sans lever et rend autre chose.
|
|
//
|
|
// La reprise à l'ouverture (§ 8.8) compare les révisions, jamais la date des
|
|
// fichiers (§ 14.10). Un journal en avance d'une entrée sur l'état se
|
|
// rattrape en mémoire. Un journal plus en avance, ou en retard, laisse l'état
|
|
// tel quel : la première écriture inscrit une entrée de reprise, qui
|
|
// restitue la charge ouverte à la suite du journal. La comparaison ne
|
|
// rattrape un geste interrompu que si l'état sur le disque porte la révision
|
|
// qui précède sa ligne. Quand il en porte une autre — après un rattrapage,
|
|
// une reprise, un secours, un état resté non écrit —, le geste pose d'abord
|
|
// la reprise, s'il en faut une, puis l'état à la révision de la dernière
|
|
// entrée, et seulement ensuite sa ligne : une coupure laisse le journal en
|
|
// avance d'une entrée au plus. Un état illisible lève ETAT_ILLISIBLE et nomme
|
|
// les secours lisibles, sans jamais ouvrir un événement vide.
|
|
//
|
|
// Écrire l'événement ailleurs (§ 8.6) pose une paire alignée, comme un
|
|
// geste : le journal en mémoire, en entier — suivi de l'entrée de reprise
|
|
// quand il finit sur une autre révision que la charge ouverte —, puis l'état
|
|
// que restitue sa dernière entrée, à sa révision. Rouvert là-bas,
|
|
// l'événement rend la charge écrite sans rien rattraper. Avec un geste, la
|
|
// paire porte l'entrée de ce geste à la suite du journal, et sa charge : le
|
|
// renommage (§ 8.7) écrit ainsi la paire complète sous le nouveau nom, dans
|
|
// le dossier même, sans toucher l'ancienne ni créer le dossier. Son
|
|
// .precedent, l'état d'avant le geste, s'écrit avant le journal : la
|
|
// nouvelle base appartient à l'événement dès que son journal existe, et
|
|
// l'ancienne paire ne s'apparie jamais ce journal.
|
|
//
|
|
// Un état sur le disque, ou un journal de la base, d'un format plus récent
|
|
// que FORMAT garde en lecture seule tout ce qui ouvre l'événement, secours
|
|
// compris : rien de ce qu'une construction plus récente a écrit dans l'état
|
|
// ou dans le journal de la base ne se remplace (§ 8.8). Un journal de la base
|
|
// plus récent, dont ce code ne lit pas l'identifiant, se reconnaît avant tout
|
|
// appariement, à l'ouverture comme dans la liste, et son secours se refuse au
|
|
// lieu de se dire absent. Un .precedent d'un format plus récent
|
|
// ne garde que l'Ouvert que son secours ouvre : un geste parti d'une autre
|
|
// ouverture le remplace, comme tout .precedent, quand l'état sur le disque
|
|
// se lit.
|
|
//
|
|
// Un fichier dont les octets ne sont pas de l'UTF-8 — le système de fichiers
|
|
// lève LECTURE, cause UTF8_INVALIDE — ne se lit pas, et aucun octet ne s'y
|
|
// remplace en silence par U+FFFD. L'état ou le .precedent est illisible,
|
|
// raison ENCODAGE : l'état lève ETAT_ILLISIBLE avec ses secours. Le premier
|
|
// geste de l'Ouvert d'un secours met cet état dans un dossier daté de la
|
|
// corbeille, puis écrit comme pour un état absent : ses octets ne se perdent
|
|
// jamais, et le travail reprend sur place. Un journal se décode ligne par
|
|
// ligne : la première ligne hors UTF-8 arrête sa lecture comme une ligne
|
|
// illisible, elle et les suivantes écartées et comptées, et le premier geste
|
|
// le réécrit sans elles ; une coupure au milieu d'un caractère ne gâte que
|
|
// la dernière ligne. Le journal de la base dont la ligne d'ouverture n'est
|
|
// pas de l'UTF-8 n'est ni lu, ni apparié, ni remplacé : faute d'un autre
|
|
// journal que l'identifiant apparie, l'Ouvert l'annonce, JOURNAL_ILLISIBLE,
|
|
// et la première écriture pose l'historique sous une base libre, comme pour
|
|
// le journal d'un autre événement.
|
|
import { canoniser, serialiser } from './canonique.js';
|
|
import { FORMAT, analyser } from './document.js';
|
|
import { ErreurStockage } from './erreurs.js';
|
|
import {
|
|
PLAFOND_ENTREES,
|
|
decoderJournal,
|
|
elaguer,
|
|
estInstantane,
|
|
ligneEntree,
|
|
ligneJalon,
|
|
ligneOuverture,
|
|
lireJournal,
|
|
reconstruire,
|
|
versionDe,
|
|
} from './journal.js';
|
|
import { DOSSIER_CORBEILLE, SUFFIXES, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js';
|
|
import { examiner } from './placements.js';
|
|
|
|
/**
|
|
* Un événement ouvert. Chaque fonction qui écrit rend l'Ouvert suivant, et ne
|
|
* modifie pas celui qu'elle reçoit.
|
|
*
|
|
* @typedef {Object} Ouvert
|
|
* @property {import('./systeme_fichiers.js').SystemeFichiers} fs
|
|
* @property {import('./systeme_fichiers.js').Racine} racine
|
|
* @property {string} base base du fichier d'état
|
|
* @property {import('./types.js').Charge} charge courante, que rend
|
|
* examiner : propositions fautives écartées, compteur relevé
|
|
* @property {number} revision révision de la charge courante
|
|
* @property {string} produitVersion construction qui a produit la charge
|
|
* courante : en-tête lu, version que le journal note pour sa
|
|
* révision, ou celle du dernier geste
|
|
* @property {string|null} texteEtat texte lisible du fichier d'état sur le
|
|
* disque ; null quand il ne l'est pas, ou qu'il manque
|
|
* @property {number|null} revisionEtat révision de ce texte, null avec lui :
|
|
* quand elle n'est pas celle de la dernière entrée du journal, le
|
|
* geste suivant écrit d'abord l'état à cette entrée
|
|
* @property {import('./journal.js').Journal|null} journal le journal de
|
|
* l'événement, lu jusqu'à son dernier instant restituable ; null
|
|
* quand il n'a aucune entrée à lui
|
|
* @property {string|null} baseJournal base du fichier du journal, ou de celui
|
|
* que la prochaine écriture crée ; null quand le fichier de la base
|
|
* porte un autre événement : la prochaine écriture prend le journal
|
|
* que l'identifiant de l'événement apparie (apparier), sinon une
|
|
* base libre que son nom dérive
|
|
* @property {import('./types.js').Charge|null} chargeJournal charge que
|
|
* restitue la dernière entrée du journal, d'où part le correctif
|
|
* de la suivante ; null sans journal
|
|
* @property {boolean} journalAReecrire le fichier du journal porte des
|
|
* lignes que la lecture a écartées : la prochaine écriture le
|
|
* réécrit sans elles
|
|
* @property {boolean} formatPlusRecent lecture seule (§ 8.8) : l'état sur
|
|
* le disque, le .precedent qu'un secours ouvre ou le journal de la
|
|
* base est d'un format plus récent que FORMAT
|
|
* @property {boolean} etatMalEncode l'état sur le disque, à côté du secours
|
|
* ouvert, n'est pas de l'UTF-8 : le premier geste le met de côté
|
|
* dans la corbeille (mettreDeCoteEtatMalEncode) avant d'écrire
|
|
* @property {import('./placements.js').Examen} examen
|
|
* @property {Array<{code: string, details: Object}>} avertissements dans
|
|
* cet ordre, chacun au plus une fois : RETENU_FAUTIF {}, un retenu
|
|
* qui se contredit, ou que l'en-tête annonce et que la charge n'a
|
|
* pas ; JOURNAL_REPRIS {}, le journal en avance d'une entrée
|
|
* rattrapé en mémoire ; JOURNAL_EN_AVANCE_DE {n} ou
|
|
* JOURNAL_INCOMPLET {etat, journal}, le journal qui finit n
|
|
* révisions après la charge, ou avant elle ; JOURNAL_ECARTEES {n} ;
|
|
* JOURNAL_RECREE {}, le fichier du journal, disparu depuis
|
|
* l'écriture précédente, que celle-ci a réécrit en entier ;
|
|
* JOURNAL_FORMAT_PLUS_RECENT {format}, le journal de la base, d'un
|
|
* format plus récent, que rien ne lit ni n'écrit ; JOURNAL_ILLISIBLE
|
|
* {raison: 'ENCODAGE'}, le journal de la base aux octets hors
|
|
* UTF-8, que rien ne lit ni n'écrit ; JOURNAL_ETRANGER {evenement},
|
|
* le journal de la base, d'un autre événement, que rien ne lit ;
|
|
* PROPOSITIONS_FAUTIVES {ids}, par rang, null pour un identifiant
|
|
* illisible ; PROPOSITIONS_MANQUANTES {n}, le nombre de
|
|
* propositions que l'en-tête compte sans que la liste les porte ;
|
|
* DERIVE {ids, retenu}, les
|
|
* propositions périmées par identifiant, et vrai quand le retenu
|
|
* l'est (§ 9)
|
|
*/
|
|
|
|
/**
|
|
* Un événement du dossier, tel que lister le résume.
|
|
*
|
|
* Les champs qui viennent de l'état lu — nom, date, etat, participants —
|
|
* valent null quand il ne se lit pas ou qu'il manque. id est celui de l'état
|
|
* lu ; pour un état illisible, celui par lequel il s'apparie
|
|
* (identifiantSansEtat), null quand il n'en a aucun ; pour un journal sans
|
|
* état, l'événement que nomme sa première ligne. Un événement du dossier se
|
|
* reconnaît ainsi à son id, que son état se lise ou non.
|
|
*
|
|
* @typedef {Object} Resume
|
|
* @property {string} base
|
|
* @property {string|null} id
|
|
* @property {string|null} nom
|
|
* @property {string|null} date
|
|
* @property {string|null} etat
|
|
* @property {number|null} participants exclus compris
|
|
* @property {number} modifie du fichier d'état, ou du journal sans
|
|
* état ; pour l'affichage seulement
|
|
* @property {string|null} journal base du journal apparié
|
|
* @property {string|null} illisible raison d'un état illisible, ABSENT
|
|
* pour un journal sans état
|
|
* @property {boolean} doublon un autre état lisible du dossier porte
|
|
* le même identifiant
|
|
*/
|
|
|
|
// Suffixes des fichiers d'un événement : une base que l'un d'eux porte dans
|
|
// un dossier n'est pas libre.
|
|
const SUFFIXES_EVENEMENT = [SUFFIXES.etat, SUFFIXES.precedent, SUFFIXES.journal, SUFFIXES.verrou];
|
|
|
|
// Deux chaînes, comparées unité UTF-16 par unité.
|
|
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
|
|
// Base d'un nom de fichier qui finit par suffixe, ou null.
|
|
const baseDe = (nom, suffixe) => (nom.endsWith(suffixe) ? nom.slice(0, nom.length - suffixe.length) : null);
|
|
|
|
// Bases que portent les fichiers d'événement d'une liste d'entrées de dossier.
|
|
const basesDe = (entrees) =>
|
|
entrees.flatMap(({ nom }) =>
|
|
SUFFIXES_EVENEMENT.map((suffixe) => baseDe(nom, suffixe)).filter((base) => base !== null),
|
|
);
|
|
|
|
// Bases des fichiers d'une liste d'entrées de dossier dont le nom finit par
|
|
// suffixe, dans son ordre.
|
|
const basesAuSuffixe = (entrees, suffixe) =>
|
|
entrees
|
|
.filter(({ type }) => type === 'fichier')
|
|
.map(({ nom }) => baseDe(nom, suffixe))
|
|
.filter((base) => base !== null);
|
|
|
|
// Ce que porte le fichier chemin : { texte, present, octets }. texte est
|
|
// null quand il manque, ou quand ses octets ne sont pas de l'UTF-8 (LECTURE,
|
|
// cause UTF8_INVALIDE), octets portant alors ceux que le refus rend, null
|
|
// sinon ; present ne vaut faux que pour un fichier qui manque. Toute autre
|
|
// erreur de lecture remonte telle quelle.
|
|
async function lireSiPresent(fs, racine, chemin) {
|
|
try {
|
|
return { texte: await fs.lireTexte(racine, chemin), present: true, octets: null };
|
|
} catch (erreur) {
|
|
if (erreur.code === 'ABSENT') return { texte: null, present: false, octets: null };
|
|
if (erreur.code === 'LECTURE' && erreur.details.cause === 'UTF8_INVALIDE') {
|
|
return { texte: null, present: true, octets: erreur.details.octets };
|
|
}
|
|
throw erreur;
|
|
}
|
|
}
|
|
|
|
// Ce qu'un fichier de journal lu (lireSiPresent) donne à lire : { texte,
|
|
// ecartees }. Ses octets hors UTF-8 se décodent ligne par ligne
|
|
// (decoderJournal) : texte, les lignes qui précèdent la première qui ne se
|
|
// décode pas, null quand c'est l'ouverture, et ecartees, cette ligne et les
|
|
// suivantes. Sinon texte est celui du fichier, null quand il manque, et
|
|
// ecartees vaut 0.
|
|
const journalLisible = ({ texte, octets }) => (octets === null ? { texte, ecartees: 0 } : decoderJournal(octets));
|
|
|
|
// La lecture d'un journal lisible (journalLisible) : lireJournal de son
|
|
// texte, les lignes que le décodage a écartées comptées avec celles que la
|
|
// lecture écarte.
|
|
function lireLisible({ texte, ecartees }) {
|
|
const lu = lireJournal(texte);
|
|
return { ...lu, ecartees: lu.ecartees + ecartees };
|
|
}
|
|
|
|
// Les détails du refus d'un état ou d'un .precedent dont les octets ne sont
|
|
// pas de l'UTF-8, neufs à chaque appel : rien ne s'y désigne.
|
|
const malEncode = () => ({ raison: 'ENCODAGE', chemin: null });
|
|
|
|
// Lecture d'un texte d'état : { lecture } quand l'analyse l'admet, sinon
|
|
// { details } de son refus, raison et chemin. analyser ne lève que
|
|
// ETAT_ILLISIBLE.
|
|
function lireEtat(texte) {
|
|
try {
|
|
return { lecture: analyser(texte), details: null };
|
|
} catch (erreur) {
|
|
return { lecture: null, details: erreur.details };
|
|
}
|
|
}
|
|
|
|
// Ce que l'Ouvert tient de l'état sur le disque, d'après le texte et sa
|
|
// lecture (lireEtat), lecture et details null quand il manque : texteEtat et
|
|
// revisionEtat quand il se lit, null sinon ; formatPlusRecent quand il vient
|
|
// d'un format plus récent, qu'il se lise, réduit, ou que son refus porte ce
|
|
// format (analyser). Un tel état garde l'Ouvert en lecture seule, même ouvert
|
|
// d'un secours (§ 8.8). etatMalEncode quand ses octets ne sont pas de
|
|
// l'UTF-8 : le premier geste parti d'un secours le range d'abord dans la
|
|
// corbeille datée (mettreDeCoteEtatMalEncode), puis pose l'état.
|
|
function surDisque(texte, { lecture, details }) {
|
|
if (lecture !== null) {
|
|
return {
|
|
texteEtat: texte,
|
|
revisionEtat: lecture.entete.revision,
|
|
formatPlusRecent: lecture.formatPlusRecent,
|
|
etatMalEncode: false,
|
|
};
|
|
}
|
|
return {
|
|
texteEtat: null,
|
|
revisionEtat: null,
|
|
formatPlusRecent: (details?.format ?? FORMAT) > FORMAT,
|
|
etatMalEncode: details?.raison === 'ENCODAGE',
|
|
};
|
|
}
|
|
|
|
// Le texte d'état, que l'analyse doit relire : un état que ce module écrit se
|
|
// rouvre toujours. Une charge qu'elle refuserait lève TypeError, avant toute
|
|
// écriture.
|
|
function relisible(texte, fonction) {
|
|
const { details } = lireEtat(texte);
|
|
if (details !== null) {
|
|
throw new TypeError(`${fonction} : l'état ne se relirait pas, ${details.raison} ${details.chemin}`);
|
|
}
|
|
return texte;
|
|
}
|
|
|
|
// Événement que nomme la première ligne d'un texte de journal, null quand le
|
|
// texte manque ou que cette ligne ne se lit pas : la lecture du journal
|
|
// réduite à cette ligne.
|
|
const evenementDe = (texte) => (texte === null ? null : lireJournal(texte.slice(0, texte.indexOf('\n') + 1)).evenement);
|
|
|
|
// Format que déclare la première ligne d'un texte de journal quand c'est un
|
|
// entier plus grand que FORMAT, null sinon. lireJournal refuse une telle
|
|
// ouverture comme toute ligne illisible ; son format dit qu'elle vient d'une
|
|
// construction plus récente, dont le journal ne se remplace pas (§ 8.8). Une
|
|
// première ligne sans fin de ligne, qui n'est pas du JSON, ou dont la valeur
|
|
// est null, ne déclare rien.
|
|
function formatPlusRecentDe(texte) {
|
|
let format;
|
|
try {
|
|
format = JSON.parse(texte.slice(0, texte.indexOf('\n') + 1).replace(/^\u{FEFF}/u, '')).format;
|
|
} catch {
|
|
return null;
|
|
}
|
|
return Number.isSafeInteger(format) && format > FORMAT ? format : null;
|
|
}
|
|
|
|
/**
|
|
* Bases que portent les fichiers d'événement — état, .precedent, journal,
|
|
* verrou — à la racine de racine, dans l'ordre de sa liste, sans rien
|
|
* écrire : une base que l'un d'eux porte n'est pas libre (deriverBase). Une
|
|
* base revient une fois par fichier qui la porte.
|
|
*
|
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
|
* @returns {Promise<string[]>}
|
|
* @throws {ErreurStockage} ABSENT quand la racine n'existe pas
|
|
*/
|
|
export async function basesPrises(fs, racine) {
|
|
return basesDe(await fs.lister(racine, ''));
|
|
}
|
|
|
|
// Base que prend l'événement nommé nom dans racine (deriverBase) : aucune des
|
|
// bases d'autres, ni de celles que portent les fichiers du dossier, à la casse
|
|
// près.
|
|
async function deriver(fs, racine, nom, autres) {
|
|
const { separateur } = await fs.emplacements();
|
|
const existantes = [...autres, ...(await basesPrises(fs, racine))];
|
|
return deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
|
}
|
|
|
|
// Les fichiers de racine tels qu'une lecture les trouve : la liste du
|
|
// dossier, ce que chaque journal donne à lire (journalLisible), la présence
|
|
// de chaque fichier, et la lecture de chaque texte d'état ou de .precedent
|
|
// (lireEtat), chacun lu une fois, à la demande ; toute erreur de lecture
|
|
// autre qu'un absent ou des octets hors UTF-8 remonte telle quelle. Les
|
|
// décisions d'appariement d'une même opération se prennent ainsi sur les
|
|
// mêmes fichiers, sans relire un journal. Une vue ne voit pas ce qui s'écrit
|
|
// après sa lecture : chaque ouverture en prend une, et l'écriture qui cherche
|
|
// le journal d'un événement en prend une neuve (baseCherchee).
|
|
function vueDe(fs, racine) {
|
|
let entrees = null;
|
|
const fichiers = new Map();
|
|
const lectures = new Map();
|
|
const fichier = async (chemin) => {
|
|
if (!fichiers.has(chemin)) fichiers.set(chemin, await lireSiPresent(fs, racine, chemin));
|
|
return fichiers.get(chemin);
|
|
};
|
|
return {
|
|
entrees: async () => {
|
|
entrees ??= await fs.lister(racine, '');
|
|
return entrees;
|
|
},
|
|
// { texte, ecartees } du journal chemin (journalLisible).
|
|
journal: async (chemin) => journalLisible(await fichier(chemin)),
|
|
// Vrai quand le fichier existe, que ses octets soient de l'UTF-8 ou non.
|
|
present: async (chemin) => (await fichier(chemin)).present,
|
|
// { texte, lecture, details } d'un fichier d'état ou d'un .precedent :
|
|
// les trois null quand il manque ; texte et lecture null, details
|
|
// {raison: 'ENCODAGE', chemin: null}, quand ses octets ne sont pas de
|
|
// l'UTF-8.
|
|
lecture: async (chemin) => {
|
|
if (!lectures.has(chemin)) {
|
|
const { texte, present } = await fichier(chemin);
|
|
const illisible = { texte: null, lecture: null, details: present ? malEncode() : null };
|
|
lectures.set(chemin, texte === null ? illisible : { texte, ...lireEtat(texte) });
|
|
}
|
|
return lectures.get(chemin);
|
|
},
|
|
};
|
|
}
|
|
|
|
// L'événement auquel appartient base dans le dossier de la vue : celui que
|
|
// porte son état quand il se lit, sinon son .precedent quand il se lit ; null
|
|
// sinon. Le journal de base qui porte cet événement est le sien, et ne
|
|
// s'apparie à aucune autre base (apparier).
|
|
async function proprietaire(vue, base) {
|
|
const { lecture } = await vue.lecture(base + SUFFIXES.etat);
|
|
const lue = lecture ?? (await vue.lecture(base + SUFFIXES.precedent)).lecture;
|
|
return lue?.charge.evenement.id ?? null;
|
|
}
|
|
|
|
// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) :
|
|
// { base, texte, ecartees } (journalLisible) du journal de base quand sa
|
|
// première ligne nomme id ; sinon du premier journal du dossier, par nom,
|
|
// qui le nomme et n'appartient pas à sa propre base (proprietaire) ; null
|
|
// quand aucun ne s'apparie. Un
|
|
// journal renommé à la main, ou créé sous une base dérivée quand celui de la
|
|
// base portait un autre événement, n'a ni état ni .precedent à sa base : il
|
|
// s'apparie. Le journal d'un original reste à l'original, et un état copié à
|
|
// la main dans le même dossier ne le prend pas. C'est la seule règle
|
|
// d'appariement : lister, ouvrir, ouvrir un secours et l'écriture d'un
|
|
// journal sans base la suivent, après la préséance du journal de la base
|
|
// d'un format plus récent (formatDuJournalDeBase).
|
|
async function apparier(vue, base, id) {
|
|
const journal = await vue.journal(base + SUFFIXES.journal);
|
|
if (evenementDe(journal.texte) === id) return { base, ...journal };
|
|
for (const autre of basesAuSuffixe(await vue.entrees(), SUFFIXES.journal)) {
|
|
const journalAutre = await vue.journal(autre + SUFFIXES.journal);
|
|
if (evenementDe(journalAutre.texte) === id && (await proprietaire(vue, autre)) !== id) {
|
|
return { base: autre, ...journalAutre };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Identifiants que portent les états lisibles du dossier de la vue, un par
|
|
// état, dans l'ordre des noms : le même deux fois, l'événement est en double.
|
|
async function identifiantsLisibles(vue) {
|
|
const identifiants = [];
|
|
for (const base of basesAuSuffixe(await vue.entrees(), SUFFIXES.etat)) {
|
|
const { lecture } = await vue.lecture(base + SUFFIXES.etat);
|
|
if (lecture !== null) identifiants.push(lecture.charge.evenement.id);
|
|
}
|
|
return identifiants;
|
|
}
|
|
|
|
// L'identifiant par lequel une base dont l'état ne se lit pas, ou manque,
|
|
// s'apparie à un journal (§ 8.6) : celui de son .precedent quand il se lit ;
|
|
// sinon celui que nomme la première ligne du journal de la base, quand aucun
|
|
// état lisible du dossier ne le porte ; null sinon. Un état lisible de cet
|
|
// identifiant passe avant un état qui ne dit pas le sien, et un journal sans
|
|
// identifiant lisible n'apparie rien : l'état illisible ne s'apparie jamais
|
|
// par le seul nom.
|
|
async function identifiantSansEtat(vue, base) {
|
|
const { lecture } = await vue.lecture(base + SUFFIXES.precedent);
|
|
if (lecture !== null) return lecture.charge.evenement.id;
|
|
const id = evenementDe((await vue.journal(base + SUFFIXES.journal)).texte);
|
|
if (id === null || (await identifiantsLisibles(vue)).includes(id)) return null;
|
|
return id;
|
|
}
|
|
|
|
// Le format plus récent que FORMAT que déclare le journal de base dans le
|
|
// dossier de la vue, null quand il manque ou n'en déclare pas. Un tel journal
|
|
// passe avant tout appariement, à l'ouverture (journalDe) comme dans la liste
|
|
// (lister) : son identifiant ne se lit pas, et il reste celui de la base.
|
|
async function formatDuJournalDeBase(vue, base) {
|
|
const { texte } = await vue.journal(base + SUFFIXES.journal);
|
|
return texte === null ? null : formatPlusRecentDe(texte);
|
|
}
|
|
|
|
// Le journal de l'événement id pour base, dans le dossier de la vue (§ 8.6) :
|
|
// lu, sa base, etranger, l'événement que porte le journal de la base quand
|
|
// aucun ne s'apparie et qu'il en nomme un autre, format, et malEncode, vrai
|
|
// quand aucun ne s'apparie et que la ligne d'ouverture du journal de la base
|
|
// n'est pas de l'UTF-8 — une ligne suivante qui ne l'est pas s'écarte
|
|
// seulement : lu et base valent alors null, comme pour un journal étranger,
|
|
// que rien ne lit ni ne remplace. Le journal de la
|
|
// base qui déclare un format plus récent que FORMAT se reconnaît d'abord,
|
|
// avant tout appariement, puisque son identifiant ne se lit pas : lu vaut
|
|
// null et format le nomme, ce qui garde l'Ouvert en lecture seule, même
|
|
// quand une copie au format courant porte id ailleurs dans le dossier ; rien
|
|
// ne le lit ni ne le remplace (§ 8.8). Sinon, c'est le journal qu'apparie
|
|
// apparier, aucun quand id est null. Faute de lui, le journal de la base
|
|
// reste le sien quand il manque ou que sa première ligne ne se lit pas, une
|
|
// écriture le remplaçant. Quand il nomme un autre événement, lu et base
|
|
// valent null : rien ne le lit ni ne l'écrit, et l'écriture cherche le
|
|
// journal de l'événement (baseCherchee).
|
|
async function journalDe(vue, base, id) {
|
|
const aucun = { lu: null, base: null, etranger: null, format: null, malEncode: false };
|
|
const format = await formatDuJournalDeBase(vue, base);
|
|
if (format !== null) return { ...aucun, base, format };
|
|
const chemin = base + SUFFIXES.journal;
|
|
const journal = await vue.journal(chemin);
|
|
const apparie = id === null ? null : await apparier(vue, base, id);
|
|
if (apparie !== null) return { ...aucun, lu: lireLisible(apparie), base: apparie.base };
|
|
if (journal.texte === null && (await vue.present(chemin))) return { ...aucun, malEncode: true };
|
|
if (id === null) return aucun;
|
|
const etranger = evenementDe(journal.texte);
|
|
if (etranger !== null) return { ...aucun, etranger };
|
|
return { ...aucun, lu: journal.texte === null ? null : lireLisible(journal), base };
|
|
}
|
|
|
|
// Le journal d'une base dont l'état ne se lit pas, ou manque : journalDe
|
|
// pour l'identifiant que rend identifiantSansEtat, null compris. Le journal
|
|
// de la base d'un format plus récent s'y reconnaît sans identifiant.
|
|
async function journalSansEtat(vue, base) {
|
|
return journalDe(vue, base, await identifiantSansEtat(vue, base));
|
|
}
|
|
|
|
// Le dernier instant restituable d'un journal lu qui a des entrées : la
|
|
// charge de sa dernière entrée ou, quand un correctif ne s'applique pas, de
|
|
// la dernière entrée qui le précède (§ 8.6). Les entrées qui suivent cet
|
|
// instant et leurs jalons s'écartent et se comptent, comme la lecture compte
|
|
// les lignes qu'elle écarte : le journal rendu finit sur lui. La première
|
|
// entrée porte un instantané, qui se restitue toujours : la recherche
|
|
// s'arrête au plus tard sur elle. Rend null pour un journal absent ou sans
|
|
// entrée.
|
|
function instantDe(lu) {
|
|
if (lu === null || lu.entrees.length === 0) return null;
|
|
for (let rang = lu.entrees.length - 1; ; rang -= 1) {
|
|
const { revision } = lu.entrees[rang];
|
|
let charge;
|
|
try {
|
|
charge = reconstruire(lu, revision);
|
|
} catch {
|
|
// Un correctif ne s'applique pas : l'entrée qui précède.
|
|
continue;
|
|
}
|
|
const entrees = lu.entrees.slice(0, rang + 1);
|
|
const jalons = lu.jalons.filter((jalon) => jalon.revision <= revision);
|
|
const ecartees = lu.ecartees + lu.entrees.length - entrees.length + lu.jalons.length - jalons.length;
|
|
return { journal: { ...lu, entrees, jalons, ecartees }, charge, revision };
|
|
}
|
|
}
|
|
|
|
// Ce qu'une lecture d'état — l'état ou le .precedent — apporte à l'Ouvert,
|
|
// avec ce qu'il tient de l'état sur le disque (surDisque) : la lecture seule
|
|
// de l'un ou de l'autre, et ce que l'en-tête annonce sans que la charge le
|
|
// porte (manquants).
|
|
const depuisLecture = ({ entete, charge, formatPlusRecent, manquants }, disque) => ({
|
|
charge,
|
|
revision: entete.revision,
|
|
produitVersion: entete.produitVersion,
|
|
manquants,
|
|
...disque,
|
|
formatPlusRecent: formatPlusRecent || disque.formatPlusRecent,
|
|
});
|
|
|
|
// Une charge qui ne vient pas d'une lecture d'état ne manque de rien.
|
|
const AUCUN_MANQUANT = Object.freeze({ propositions: 0, retenu: false });
|
|
|
|
// Les avertissements d'un Ouvert, dans l'ordre que décrit le type Ouvert.
|
|
// derniere est la révision de la dernière entrée du journal, celle de la
|
|
// charge quand il n'y en a pas. Les propositions que l'en-tête compte sans
|
|
// que la liste les porte s'annoncent par leur seul nombre, que rien ne
|
|
// borne : aucune liste de cette taille ne se construit. Un retenu annoncé
|
|
// absent s'annonce comme un retenu fautif.
|
|
function avertir({ examen, journal, revision, rattrape, ecartees, recree, format, malEncode, etranger, manquants }) {
|
|
const avertissements = [];
|
|
const ajouter = (code, details = {}) => avertissements.push({ code, details });
|
|
if (examen.retenu.fautes.length > 0 || manquants.retenu) ajouter('RETENU_FAUTIF');
|
|
if (rattrape) ajouter('JOURNAL_REPRIS');
|
|
const derniere = journal === null ? revision : journal.entrees.at(-1).revision;
|
|
if (derniere > revision) ajouter('JOURNAL_EN_AVANCE_DE', { n: derniere - revision });
|
|
if (derniere < revision) ajouter('JOURNAL_INCOMPLET', { etat: revision, journal: derniere });
|
|
if (ecartees > 0) ajouter('JOURNAL_ECARTEES', { n: ecartees });
|
|
if (recree) ajouter('JOURNAL_RECREE');
|
|
if (format !== null) ajouter('JOURNAL_FORMAT_PLUS_RECENT', { format });
|
|
if (malEncode) ajouter('JOURNAL_ILLISIBLE', { raison: 'ENCODAGE' });
|
|
if (etranger !== null) ajouter('JOURNAL_ETRANGER', { evenement: etranger });
|
|
if (examen.fautives.length > 0) ajouter('PROPOSITIONS_FAUTIVES', { ids: examen.fautives.map(({ id }) => id) });
|
|
if (manquants.propositions > 0) ajouter('PROPOSITIONS_MANQUANTES', { n: manquants.propositions });
|
|
const retenu = examen.retenu.raisons.length > 0;
|
|
if (examen.derives.length > 0 || retenu) ajouter('DERIVE', { ids: examen.derives.map(({ id }) => id), retenu });
|
|
return avertissements;
|
|
}
|
|
|
|
// L'Ouvert d'une source — état, .precedent ou instant du journal — et du
|
|
// journal trouvé pour elle. rattraper : un journal en avance d'une seule
|
|
// entrée sur la source donne sa charge, sa révision et sa version, et ce que
|
|
// l'en-tête de la source annonce sans que sa charge le porte ne compte plus.
|
|
// La source d'un format plus récent, ou un journal d'un format plus récent,
|
|
// gardent l'Ouvert en lecture seule.
|
|
function assembler({ fs, racine, base, source, trouve, instant, rattraper }) {
|
|
let { charge, revision, produitVersion, manquants } = source;
|
|
const rattrape = rattraper && instant !== null && instant.revision === revision + 1;
|
|
if (rattrape) {
|
|
charge = canoniser(instant.charge);
|
|
revision = instant.revision;
|
|
produitVersion = versionDe(instant.journal, revision);
|
|
manquants = AUCUN_MANQUANT;
|
|
}
|
|
const journal = instant?.journal ?? null;
|
|
const ecartees = journal?.ecartees ?? trouve.lu?.ecartees ?? 0;
|
|
const examen = examiner(charge);
|
|
return {
|
|
fs,
|
|
racine,
|
|
base,
|
|
charge: examen.charge,
|
|
revision,
|
|
produitVersion,
|
|
texteEtat: source.texteEtat,
|
|
revisionEtat: source.revisionEtat,
|
|
journal,
|
|
baseJournal: trouve.base,
|
|
chargeJournal: instant?.charge ?? null,
|
|
journalAReecrire: journal !== null && ecartees > 0,
|
|
formatPlusRecent: source.formatPlusRecent || trouve.format !== null,
|
|
etatMalEncode: source.etatMalEncode,
|
|
examen,
|
|
avertissements: avertir({
|
|
examen,
|
|
journal,
|
|
revision,
|
|
rattrape,
|
|
ecartees,
|
|
recree: false,
|
|
format: trouve.format,
|
|
malEncode: trouve.malEncode,
|
|
etranger: trouve.etranger,
|
|
manquants,
|
|
}),
|
|
};
|
|
}
|
|
|
|
// L'erreur d'un état qui ne s'ouvre pas : ETAT_ILLISIBLE, que details décrit
|
|
// — raison, chemin, format —, complété de la base, du format du journal de la
|
|
// base quand il est plus récent et que details n'en nomme pas, et des
|
|
// secours lisibles : la révision du .precedent et le dernier instant du
|
|
// journal que l'identifiant apparie (journalSansEtat), jamais un journal
|
|
// pris par son seul nom ; ABSENT quand ni l'état, ni le .precedent, ni le
|
|
// journal de la base n'existent — un fichier aux octets hors UTF-8 existe.
|
|
async function refuser(vue, base, details) {
|
|
const precedent = await vue.lecture(base + SUFFIXES.precedent);
|
|
const existe = async (suffixe) => vue.present(base + suffixe);
|
|
if (details.raison === 'ABSENT' && !(await existe(SUFFIXES.precedent)) && !(await existe(SUFFIXES.journal))) {
|
|
return new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
|
|
}
|
|
const trouve = await journalSansEtat(vue, base);
|
|
const format = details.format ?? trouve.format;
|
|
const secours = {
|
|
precedent: precedent.lecture?.entete.revision ?? null,
|
|
journal: instantDe(trouve.lu)?.revision ?? null,
|
|
};
|
|
return new ErreurStockage('ETAT_ILLISIBLE', { base, ...details, ...(format === null ? {} : { format }), secours });
|
|
}
|
|
|
|
/**
|
|
* Événements de racine (§ 8.1, § 8.6), sans rien écrire : un résumé par
|
|
* fichier d'état, lisible ou non, et par journal sans état. Un état
|
|
* s'apparie à son journal par l'identifiant, jamais par le seul nom
|
|
* (apparier) : celui de l'état quand il se lit ; pour un état illisible,
|
|
* celui de son .precedent, ou de son journal quand aucun état lisible ne le
|
|
* porte (identifiantSansEtat). Un journal renommé à la main reste apparié ;
|
|
* le journal d'un original ne s'apparie pas à sa copie faite à la main. Un
|
|
* journal sans état dans sa base se résume quand aucun état ne se l'apparie
|
|
* et qu'aucun état lisible ne porte son identifiant, illisible ABSENT ; il
|
|
* ne se montre pas sinon : une copie d'un journal apparié, ou le journal
|
|
* qu'un état illisible s'apparie par son .precedent. Un identifiant que
|
|
* portent deux états lisibles les signale doublon ; le résumé d'un état
|
|
* illisible porte en id celui qui l'apparie, sans le compter en doublon.
|
|
* Résumés triés par nom, les noms inconnus après les autres, puis par base,
|
|
* unités UTF-16 comparées.
|
|
*
|
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
|
* @returns {Promise<Resume[]>}
|
|
*/
|
|
export async function lister(fs, racine) {
|
|
const vue = vueDe(fs, racine);
|
|
const fichiers = (await vue.entrees()).filter(({ type }) => type === 'fichier');
|
|
const identifiants = await identifiantsLisibles(vue);
|
|
const sansLecture = (base, id, modifie, journal, illisible) => ({
|
|
base,
|
|
id,
|
|
nom: null,
|
|
date: null,
|
|
etat: null,
|
|
participants: null,
|
|
modifie,
|
|
journal,
|
|
illisible,
|
|
doublon: false,
|
|
});
|
|
const apparies = new Set();
|
|
const resumes = [];
|
|
for (const { nom, modifie } of fichiers) {
|
|
const base = baseDe(nom, SUFFIXES.etat);
|
|
if (base === null) continue;
|
|
const { lecture, details } = await vue.lecture(nom);
|
|
const id = lecture === null ? await identifiantSansEtat(vue, base) : lecture.charge.evenement.id;
|
|
let journal = null;
|
|
if ((await formatDuJournalDeBase(vue, base)) !== null) journal = base;
|
|
else if (id !== null) journal = (await apparier(vue, base, id))?.base ?? null;
|
|
if (journal !== null) apparies.add(journal);
|
|
if (lecture === null) {
|
|
resumes.push(sansLecture(base, id, modifie, journal, details.raison));
|
|
continue;
|
|
}
|
|
const { evenement, participants } = lecture.charge;
|
|
resumes.push({
|
|
base,
|
|
id: evenement.id,
|
|
nom: evenement.nom,
|
|
date: evenement.date,
|
|
etat: evenement.etat,
|
|
participants: participants.length,
|
|
modifie,
|
|
journal,
|
|
illisible: null,
|
|
doublon: identifiants.indexOf(evenement.id) !== identifiants.lastIndexOf(evenement.id),
|
|
});
|
|
}
|
|
const basesDesEtats = new Set(basesAuSuffixe(fichiers, SUFFIXES.etat));
|
|
for (const { nom, modifie } of fichiers) {
|
|
const base = baseDe(nom, SUFFIXES.journal);
|
|
if (base === null || basesDesEtats.has(base) || apparies.has(base)) continue;
|
|
const evenement = evenementDe((await vue.journal(nom)).texte);
|
|
if (!identifiants.includes(evenement)) resumes.push(sansLecture(base, evenement, modifie, base, 'ABSENT'));
|
|
}
|
|
const nomsConnus = (a, b) => (a === null) - (b === null) || comparerTextes(a ?? '', b ?? '');
|
|
return resumes.sort((a, b) => nomsConnus(a.nom, b.nom) || comparerTextes(a.base, b.base));
|
|
}
|
|
|
|
/**
|
|
* Ouvre l'événement de base dans racine, sans rien écrire (§ 8.4, § 8.8) :
|
|
* la charge de son état, examinée (examiner), et son journal, apparié par
|
|
* l'identifiant. Un journal en avance d'une entrée donne sa charge, rattrapée
|
|
* en mémoire, JOURNAL_REPRIS ; l'état se réécrira au premier geste. Un journal
|
|
* plus en avance ou en retard laisse la charge de l'état, et le premier geste
|
|
* inscrit une entrée de reprise. Une proposition fautive s'écarte et se
|
|
* nomme ; une périmée se garde et se signale ; un retenu fautif se garde et
|
|
* se signale en premier (§ 8.9). Un format plus récent s'ouvre en lecture
|
|
* seule ; un format plus ancien se lit, et ne se convertit qu'au premier
|
|
* geste. Le journal de la base d'un format plus récent ne se lit pas, et
|
|
* garde l'Ouvert en lecture seule, JOURNAL_FORMAT_PLUS_RECENT, même quand une
|
|
* copie au format courant porte l'identifiant ailleurs dans le dossier.
|
|
*
|
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
|
* @param {string} base
|
|
* @returns {Promise<Ouvert>}
|
|
* @throws {ErreurStockage} ETAT_ILLISIBLE {base, raison, chemin, [format],
|
|
* secours} : l'état ne se lit pas — raison ENCODAGE pour des octets
|
|
* hors UTF-8 —, ou manque à côté de son .precedent ou de son
|
|
* journal, fût-il aux octets hors UTF-8 (raison ABSENT) ; format est
|
|
* celui de l'état quand son refus en nomme un, sinon celui du journal de la base
|
|
* quand il est plus récent ; secours nomme la révision lisible du
|
|
* .precedent et le dernier instant du journal que l'identifiant du
|
|
* .precedent apparie — ou, sans .precedent lisible, celui du journal
|
|
* de la base qu'aucun état lisible ne porte —, null pour ce qui ne se
|
|
* lit pas ou ne s'apparie pas. ABSENT {chemin} quand aucun fichier de
|
|
* l'événement n'existe
|
|
*/
|
|
export async function ouvrir(fs, racine, base) {
|
|
const vue = vueDe(fs, racine);
|
|
const etat = await vue.lecture(base + SUFFIXES.etat);
|
|
if (etat.lecture === null) throw await refuser(vue, base, etat.details ?? { raison: 'ABSENT', chemin: null });
|
|
const trouve = await journalDe(vue, base, etat.lecture.charge.evenement.id);
|
|
const source = depuisLecture(etat.lecture, surDisque(etat.texte, etat));
|
|
return assembler({ fs, racine, base, source, trouve, instant: instantDe(trouve.lu), rattraper: true });
|
|
}
|
|
|
|
/**
|
|
* Ouvre l'événement de base depuis un secours, sans rien écrire (§ 8.8) :
|
|
* source 'precedent', la charge du .precedent, que le journal de son
|
|
* identifiant suit sans être rattrapé ; source 'journal', le dernier instant
|
|
* restituable du journal, apparié par l'identifiant de l'état quand il se lit,
|
|
* sinon par celui du .precedent, et sans .precedent lisible, le journal de
|
|
* la base quand aucun état lisible du dossier ne porte son identifiant
|
|
* (journalSansEtat) : jamais un journal pris par son seul nom, ni celui d'un
|
|
* autre événement. texteEtat est le texte de l'état quand il se lit : le
|
|
* premier geste le met au .precedent, et ne met jamais un état illisible.
|
|
* L'état sur le disque d'un format plus récent, qu'il se lise ou non, garde
|
|
* le secours en lecture seule, comme un journal de la base d'un format plus
|
|
* récent : aucun geste de ce secours ne remplace l'état ou le journal qu'une
|
|
* construction plus récente a écrits (§ 8.8). Un .precedent d'un format plus
|
|
* récent garde en lecture seule le secours 'precedent', qui le lit, et non le
|
|
* secours 'journal'. Un journal plus récent ne s'ouvre pas en secours, et ne
|
|
* se dit pas absent : il existe, et ce code ne le lit pas.
|
|
*
|
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
|
* @param {string} base
|
|
* @param {'precedent'|'journal'} source
|
|
* @returns {Promise<Ouvert>}
|
|
* @throws {ErreurStockage} ABSENT {chemin} : le .precedent manque, ou aucun
|
|
* journal ne s'apparie, ou il n'a aucune entrée, chemin nommant alors
|
|
* le journal de la base ; ETAT_ILLISIBLE {raison, chemin, [format]} :
|
|
* celui de l'analyse d'un .precedent illisible, raison ENCODAGE et
|
|
* chemin null pour des octets hors UTF-8, ou, source 'journal',
|
|
* {raison: 'FORMAT_PLUS_RECENT', chemin: le journal de la base,
|
|
* format} quand ce journal est d'un format plus récent, {raison:
|
|
* 'ENCODAGE', chemin: le journal de la base} quand sa ligne
|
|
* d'ouverture n'est pas de l'UTF-8 et qu'aucun autre ne s'apparie
|
|
* @throws {RangeError} une autre source
|
|
*/
|
|
export async function ouvrirSecours(fs, racine, base, source) {
|
|
if (source !== 'precedent' && source !== 'journal') {
|
|
throw new RangeError(`ouvrirSecours : source precedent ou journal attendue, reçu ${JSON.stringify(source)}`);
|
|
}
|
|
const vue = vueDe(fs, racine);
|
|
const etat = await vue.lecture(base + SUFFIXES.etat);
|
|
const disque = surDisque(etat.texte, etat);
|
|
if (source === 'precedent') {
|
|
const precedent = await vue.lecture(base + SUFFIXES.precedent);
|
|
if (precedent.details === null && precedent.lecture === null) {
|
|
throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.precedent });
|
|
}
|
|
if (precedent.lecture === null) throw new ErreurStockage('ETAT_ILLISIBLE', precedent.details);
|
|
const { lecture } = precedent;
|
|
const trouve = await journalDe(vue, base, lecture.charge.evenement.id);
|
|
const depuis = depuisLecture(lecture, disque);
|
|
return assembler({ fs, racine, base, source: depuis, trouve, instant: instantDe(trouve.lu), rattraper: false });
|
|
}
|
|
const trouve = await (etat.lecture === null
|
|
? journalSansEtat(vue, base)
|
|
: journalDe(vue, base, etat.lecture.charge.evenement.id));
|
|
if (trouve.format !== null) {
|
|
throw new ErreurStockage('ETAT_ILLISIBLE', {
|
|
raison: 'FORMAT_PLUS_RECENT',
|
|
chemin: base + SUFFIXES.journal,
|
|
format: trouve.format,
|
|
});
|
|
}
|
|
if (trouve.malEncode) {
|
|
throw new ErreurStockage('ETAT_ILLISIBLE', { ...malEncode(), chemin: base + SUFFIXES.journal });
|
|
}
|
|
const instant = instantDe(trouve.lu);
|
|
if (instant === null) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.journal });
|
|
const depuis = {
|
|
charge: canoniser(instant.charge),
|
|
revision: instant.revision,
|
|
produitVersion: versionDe(instant.journal, instant.revision),
|
|
manquants: AUCUN_MANQUANT,
|
|
...disque,
|
|
};
|
|
return assembler({ fs, racine, base, source: depuis, trouve, instant, rattraper: false });
|
|
}
|
|
|
|
// Un Ouvert en lecture seule — un fichier qu'il a lu est d'un format plus
|
|
// récent — ne s'écrit jamais (§ 8.8).
|
|
function exigerEcriture({ formatPlusRecent }) {
|
|
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' });
|
|
}
|
|
|
|
|
|
// version quand l'entrée qui suit revision la note, null sinon : à la première
|
|
// entrée, et quand la construction diffère de la précédente (§ 8.8).
|
|
const versionNotee = (version, journal, revision) => (version === versionDe(journal, revision) ? null : version);
|
|
|
|
// Le journal en mémoire, la ligne écrite ajoutée à ses entrées ou à ses jalons.
|
|
function avec(journal, ligne) {
|
|
const lue = JSON.parse(ligne);
|
|
if (lue.type === 'jalon') return { ...journal, jalons: [...journal.jalons, lue] };
|
|
return { ...journal, entrees: [...journal.entrees, lue] };
|
|
}
|
|
|
|
// Texte d'un journal en mémoire : son ouverture, puis chaque entrée suivie
|
|
// des jalons de sa révision, dans leur ordre. Une entrée lue se récrit par
|
|
// JSON.stringify, comme ligneEntree l'a écrite : ses clés dans l'ordre lu.
|
|
function texteDe({ evenement, entrees, jalons }) {
|
|
const lignes = [ligneOuverture(evenement)];
|
|
for (const entree of entrees) {
|
|
lignes.push(JSON.stringify(entree));
|
|
for (const jalon of jalons) {
|
|
if (jalon.revision === entree.revision) lignes.push(ligneJalon(jalon));
|
|
}
|
|
}
|
|
return `${lignes.join('\n')}\n`;
|
|
}
|
|
|
|
// Le journal de l'Ouvert prêt à recevoir une ligne, en mémoire : avant, le
|
|
// journal tel que le fichier le porte, vide quand il manque ; reprise, la
|
|
// ligne de l'entrée de reprise qui le fait finir sur la charge ouverte, ou
|
|
// null ; journal, avant suivi de la reprise ; charge, ce que restitue sa
|
|
// dernière entrée ; entier, vrai quand le fichier se réécrit en entier à sa
|
|
// prochaine écriture : il manque, sa lecture a écarté des lignes, ou il
|
|
// reçoit la reprise. Sans journal, la reprise le commence, instantané de la
|
|
// charge ouverte à sa révision et à sa version. Un journal qui finit sur une
|
|
// autre révision que la charge ouverte reçoit la reprise à la révision qui
|
|
// suit la sienne, qui restitue la charge ouverte. libelleReprise manque :
|
|
// ligneEntree lève TypeError, avant toute écriture.
|
|
function preparer(ouvert, { libelleReprise, horodatage }) {
|
|
const { journal, revision, charge, produitVersion } = ouvert;
|
|
if (journal === null) {
|
|
const reprise = ligneEntree({
|
|
revision,
|
|
libelle: libelleReprise,
|
|
horodatage,
|
|
produitVersion,
|
|
apres: charge,
|
|
instantane: true,
|
|
});
|
|
const vide = { evenement: charge.evenement.id, entrees: [], jalons: [], ecartees: 0 };
|
|
return { avant: vide, reprise, journal: avec(vide, reprise), charge: canoniser(charge), entier: true };
|
|
}
|
|
const derniere = journal.entrees.at(-1).revision;
|
|
if (derniere === revision) {
|
|
return { avant: journal, reprise: null, journal, charge: ouvert.chargeJournal, entier: ouvert.journalAReecrire };
|
|
}
|
|
const suivante = derniere + 1;
|
|
const reprise = ligneEntree({
|
|
revision: suivante,
|
|
libelle: libelleReprise,
|
|
horodatage,
|
|
produitVersion: versionNotee(produitVersion, journal, derniere),
|
|
avant: ouvert.chargeJournal,
|
|
apres: charge,
|
|
instantane: estInstantane(suivante, journal.entrees[0].revision),
|
|
});
|
|
return { avant: journal, reprise, journal: avec(journal, reprise), charge: canoniser(charge), entier: true };
|
|
}
|
|
|
|
// L'en-tête de l'état que restitue la dernière entrée d'un journal : la
|
|
// révision de cette entrée, et la version de la construction qui l'a
|
|
// produite (§ 8.8). L'état qui le porte, avec la charge de cette entrée,
|
|
// fait une paire avec le journal (§ 8.2).
|
|
function enteteDeLaDerniere(journal) {
|
|
const revision = journal.entrees.at(-1).revision;
|
|
return { revision, produitVersion: versionDe(journal, revision) };
|
|
}
|
|
|
|
// Le texte d'état que le disque doit porter avant la ligne d'un geste, ou
|
|
// null quand il le porte : un état lisible sur le disque, à une autre
|
|
// révision que la dernière entrée du journal préparé, reçoit d'abord la
|
|
// charge que restitue cette entrée, sous son en-tête (enteteDeLaDerniere).
|
|
// Sans ce pas, une coupure entre la ligne du geste et l'état laisserait le
|
|
// journal en avance de plus d'une entrée, ou à la révision de l'état avec
|
|
// une autre charge, et la comparaison des révisions ne rattraperait pas le
|
|
// geste (§ 8.2). Un état illisible ou absent n'a pas de révision : ouvrir le
|
|
// refuse et nomme le journal en secours. Une charge que l'analyse refuserait
|
|
// lève TypeError, avant toute écriture.
|
|
function etatAligne(ouvert, { journal, charge }) {
|
|
const entete = enteteDeLaDerniere(journal);
|
|
if (ouvert.revisionEtat === null || ouvert.revisionEtat === entete.revision) return null;
|
|
return relisible(serialiser(charge, entete), 'enregistrer');
|
|
}
|
|
|
|
// Pose texte, que etatAligne a calculé, avant la ligne d'un geste : la
|
|
// reprise seule d'abord, que preparer fait écrire en entier, puis l'état à la
|
|
// révision de la dernière entrée du journal. Rend l'Ouvert dont le disque
|
|
// porte cet état et la préparation de la ligne qui suit. Un refus d'écriture
|
|
// remonte tel quel : le geste n'est pas au journal, et l'Ouvert reçu reste
|
|
// celui d'où le réessayer. Le nouvel essai réécrit en entier le journal où la
|
|
// reprise est déjà, celui de la base de l'Ouvert ou, sans base, celui que
|
|
// l'identifiant de l'événement désigne (baseCherchee) : la reprise s'y
|
|
// remplace au lieu de s'y doubler.
|
|
async function aligner(ouvert, preparation, texte) {
|
|
let { journal, entier } = preparation;
|
|
let depart = ouvert;
|
|
if (preparation.reprise !== null) {
|
|
const ecrit = await ecrireJournal(ouvert, { journal: preparation.avant, entier }, preparation.reprise);
|
|
({ journal } = ecrit);
|
|
depart = { ...ouvert, journal, baseJournal: ecrit.baseJournal, journalAReecrire: false };
|
|
entier = false;
|
|
}
|
|
const aligne = await poserEtat({ ...depart, revision: journal.entrees.at(-1).revision }, texte);
|
|
return { depart: aligne, preparation: { journal, entier } };
|
|
}
|
|
|
|
// Le texte entier d'un journal en mémoire, élagué au-delà de PLAFOND_ENTREES
|
|
// (elaguer), et le journal que ce texte porte, sans ligne écartée : ce que
|
|
// le fichier porte une fois écrit d'un bloc.
|
|
function textePlein(journal) {
|
|
const texte = texteDe(journal);
|
|
const elague = elaguer(texte);
|
|
if (elague === null) return { texte, journal: { ...journal, ecartees: 0 } };
|
|
return { texte: elague, journal: lireJournal(elague) };
|
|
}
|
|
|
|
// L'ECRITURE d'une recherche dans le dossier de racine qui échoue avant une
|
|
// écriture du journal chemin — le dossier qui ne se liste plus, ou un
|
|
// fichier qui ne se lit plus : ABSENT quand le dossier a disparu avec son
|
|
// support, cause ENOENT ; LECTURE quand la lecture est refusée, sa cause —,
|
|
// chemin le journal, dossier le chemin de la racine, qui porte les fichiers
|
|
// de l'événement. L'application nomme ainsi le fichier et son dossier, et
|
|
// propose d'écrire ailleurs (§ 8.6). Toute autre erreur reste telle quelle.
|
|
function enEcriture(erreur, chemin, racine) {
|
|
if (erreur.code !== 'ABSENT' && erreur.code !== 'LECTURE') return erreur;
|
|
const cause = erreur.code === 'ABSENT' ? 'ENOENT' : erreur.details.cause;
|
|
return new ErreurStockage('ECRITURE', { chemin, dossier: racine.chemin, cause });
|
|
}
|
|
|
|
// La base où s'écrit le journal d'un Ouvert sans base de journal, celui de
|
|
// sa base portant un autre événement : la base du journal que l'identifiant
|
|
// de l'événement apparie dans le dossier tel qu'il est (apparier), sinon une
|
|
// base libre que son nom dérive. L'essai repris après un refus réécrit
|
|
// ainsi, avec sa reprise, le journal que l'essai refusé a créé, au lieu d'en
|
|
// dériver un second que l'ouverture n'apparierait pas. Une recherche qui
|
|
// échoue lève ECRITURE sur le journal de la base, comme l'écriture qu'elle
|
|
// précède (enEcriture) ; toute autre erreur remonte telle quelle.
|
|
async function baseCherchee(ouvert, evenement) {
|
|
const { fs, racine, base } = ouvert;
|
|
try {
|
|
return (
|
|
(await apparier(vueDe(fs, racine), base, evenement))?.base ??
|
|
(await deriver(fs, racine, ouvert.charge.evenement.nom, []))
|
|
);
|
|
} catch (erreur) {
|
|
throw enEcriture(erreur, base + SUFFIXES.journal, racine);
|
|
}
|
|
}
|
|
|
|
// Vrai quand le dossier de l'Ouvert porte encore le fichier chemin, son nom
|
|
// comparé comme le système compare les noms : sans égard à la casse par
|
|
// cleSansCasse quand le système l'ignore, à la lettre sinon. Ne lit que la
|
|
// liste du dossier, jamais le fichier. Un dossier qui ne se liste plus lève
|
|
// ECRITURE sur ce fichier (enEcriture).
|
|
async function fichierPresent({ fs, racine }, chemin) {
|
|
let entrees;
|
|
try {
|
|
entrees = await fs.lister(racine, '');
|
|
} catch (erreur) {
|
|
throw enEcriture(erreur, chemin, racine);
|
|
}
|
|
const { insensibleCasse } = await fs.emplacements();
|
|
const cle = insensibleCasse ? cleSansCasse : (nom) => nom;
|
|
return entrees.some(({ nom }) => cle(nom) === cle(chemin));
|
|
}
|
|
|
|
// Écrit au journal la ligne à la suite de journal, le journal en mémoire que
|
|
// le fichier porte (§ 8.2, § 8.6) : par un ajout en fin, ou en entier par une
|
|
// écriture atomique, élaguée au-delà de PLAFOND_ENTREES (textePlein), quand
|
|
// entier le demande, que le plafond est dépassé, ou que le fichier a disparu
|
|
// (fichierPresent) : un ajout le recommencerait sans sa ligne d'ouverture, et
|
|
// la lecture l'écarterait en entier. Sans base, le journal prend celle que
|
|
// baseCherchee trouve. Rend le journal en mémoire, qui est le fichier, sa
|
|
// base, et recree, vrai quand le fichier disparu s'est réécrit.
|
|
async function ecrireJournal(ouvert, preparation, ligne) {
|
|
let journal = avec(preparation.journal, ligne);
|
|
const { fs, racine } = ouvert;
|
|
const base = ouvert.baseJournal ?? (await baseCherchee(ouvert, journal.evenement));
|
|
const chemin = base + SUFFIXES.journal;
|
|
const entier = preparation.entier || journal.entrees.length > PLAFOND_ENTREES;
|
|
const recree = !entier && !(await fichierPresent(ouvert, chemin));
|
|
if (entier || recree) {
|
|
const plein = textePlein(journal);
|
|
journal = plein.journal;
|
|
await fs.ecrireAtomique(racine, chemin, plein.texte);
|
|
} else {
|
|
await fs.ajouterLigne(racine, chemin, ligne);
|
|
}
|
|
return { journal: { ...journal, ecartees: 0 }, baseJournal: base, journalAReecrire: false, recree };
|
|
}
|
|
|
|
/**
|
|
* Met de côté l'état aux octets hors UTF-8 d'un Ouvert de secours (§ 8.8),
|
|
* avant le premier geste qui le remplace : le fichier passe tel quel dans un
|
|
* dossier neuf de la corbeille, daté de l'horodatage (dossierCorbeille), créé
|
|
* d'abord, comme supprimer y range un événement. Ses octets ne se perdent
|
|
* jamais, et l'écriture qui suit pose l'état comme pour un état absent :
|
|
* l'Ouvert rendu ne le tient plus pour mal encodé. Un Ouvert dont l'état se
|
|
* lit, ou manque, revient tel quel, sans rien écrire. Un état qui n'est plus
|
|
* dans le dossier — une mise de côté déjà faite, que suit un geste refusé
|
|
* réessayé du même Ouvert — ne se déplace pas une seconde fois. Un refus
|
|
* remonte tel quel : l'état reste à sa place ou dans la corbeille.
|
|
*
|
|
* @param {Ouvert} ouvert
|
|
* @param {string} horodatage AAAA-MM-JJTHH:MM:SS±HH:MM
|
|
* @returns {Promise<Ouvert>}
|
|
* @throws {ErreurStockage} ECRITURE du dossier ou de la corbeille qui ne se
|
|
* listent plus, de la création du dossier daté ou du déplacement ;
|
|
* CORBEILLE_SATUREE, sans rien écrire
|
|
* @throws {TypeError} un horodatage hors de sa forme, sans rien écrire
|
|
*/
|
|
export async function mettreDeCoteEtatMalEncode(ouvert, horodatage) {
|
|
if (!ouvert.etatMalEncode) return ouvert;
|
|
const { fs, racine, base } = ouvert;
|
|
const chemin = base + SUFFIXES.etat;
|
|
if (!(await fichierPresent(ouvert, chemin))) return { ...ouvert, etatMalEncode: false };
|
|
let existants = [];
|
|
try {
|
|
existants = (await fs.lister(racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom);
|
|
} catch (erreur) {
|
|
// Une corbeille absente est vide.
|
|
if (erreur.code !== 'ABSENT') throw enEcriture(erreur, DOSSIER_CORBEILLE, racine);
|
|
}
|
|
const dossier = dossierCorbeille(horodatage, existants);
|
|
await fs.creerDossier(racine, dossier);
|
|
await fs.deplacer(racine, chemin, `${dossier}/${chemin}`);
|
|
return { ...ouvert, etatMalEncode: false };
|
|
}
|
|
|
|
// Écrit texteEtat, l'état de l'Ouvert à sa révision : le .precedent d'abord,
|
|
// quand l'état sur le disque se lit, puis l'état (§ 8.8). Rend l'Ouvert dont
|
|
// le disque porte ce texte. Un refus remonte tel quel.
|
|
async function poserEtat(ouvert, texteEtat) {
|
|
const { fs, racine, base } = ouvert;
|
|
if (ouvert.texteEtat !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, ouvert.texteEtat);
|
|
await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat);
|
|
return { ...ouvert, texteEtat, revisionEtat: ouvert.revision };
|
|
}
|
|
|
|
// Écrit l'état de l'Ouvert avancé, dont le geste est déjà au journal
|
|
// (poserEtat). Un refus d'écriture lève ETAT_NON_ECRIT, ses détails — chemin,
|
|
// dossier, cause — complétés de l'Ouvert avancé, qui garde le texte de l'état
|
|
// resté sur le disque. L'Ouvert s'ajoute après la construction de l'erreur :
|
|
// son message, fait pour les traces, ne porte ni la charge ni le journal.
|
|
async function ecrireEtat(avance, texteEtat) {
|
|
try {
|
|
return await poserEtat(avance, texteEtat);
|
|
} catch (erreur) {
|
|
if (erreur.code !== 'ECRITURE') throw erreur;
|
|
const refus = new ErreurStockage('ETAT_NON_ECRIT', { ...erreur.details });
|
|
refus.details.ouvert = avance;
|
|
throw refus;
|
|
}
|
|
}
|
|
|
|
// L'Ouvert d'une charge écrite : examinée, ses avertissements ceux de ses
|
|
// placements, d'un journal qui finit ailleurs que sur elle, et du journal
|
|
// disparu que l'écriture a réécrit (recree).
|
|
function apresEcriture(ouvert, { charge, revision, journal, recree = false }) {
|
|
const examen = examiner(charge);
|
|
const avertissements = avertir({
|
|
examen,
|
|
journal,
|
|
revision,
|
|
rattrape: false,
|
|
ecartees: 0,
|
|
recree,
|
|
format: null,
|
|
malEncode: false,
|
|
etranger: null,
|
|
manquants: AUCUN_MANQUANT,
|
|
});
|
|
return { ...ouvert, charge: examen.charge, revision, examen, avertissements };
|
|
}
|
|
|
|
// La ligne de l'entrée d'un geste à la suite du journal que preparer a
|
|
// préparé, et sa révision, celle qui suit la dernière entrée de ce journal :
|
|
// un correctif depuis la charge que restitue cette entrée, ou un instantané à
|
|
// la cadence du journal ; produitVersion n'y paraît que quand la construction
|
|
// change (versionNotee). Une ligne que la lecture refuserait lève TypeError
|
|
// (ligneEntree), avant toute écriture.
|
|
function ligneDuGeste(preparation, apres, { libelle, horodatage, produitVersion, sens, retour }) {
|
|
const derniere = preparation.journal.entrees.at(-1).revision;
|
|
const revision = derniere + 1;
|
|
const ligne = ligneEntree({
|
|
revision,
|
|
libelle,
|
|
horodatage,
|
|
sens,
|
|
retour,
|
|
produitVersion: versionNotee(produitVersion, preparation.journal, derniere),
|
|
avant: preparation.charge,
|
|
apres,
|
|
instantane: estInstantane(revision, preparation.journal.entrees[0].revision),
|
|
});
|
|
return { ligne, revision };
|
|
}
|
|
|
|
/**
|
|
* Crée l'événement de charge dans racine (§ 8.1, § 8.2) : sa base dérive de
|
|
* son nom (deriverBase), libre dans le dossier. Le journal d'abord, écrit
|
|
* d'un bloc — ouverture, entrée 1 en instantané, jalon nomJalon sur elle —,
|
|
* puis l'état, à la révision 1.
|
|
*
|
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
|
* @param {import('./types.js').Charge} charge
|
|
* @param {{libelle: string, nomJalon: string, horodatage: string, produitVersion: string}} options
|
|
* @returns {Promise<Ouvert>}
|
|
* @throws {TypeError} une charge, un libellé, un nom ou un horodatage que la
|
|
* lecture refuserait, avant toute écriture
|
|
* @throws {ErreurStockage} ECRITURE : le journal n'a pas pu s'écrire, et rien
|
|
* n'est écrit ; ETAT_NON_ECRIT : le journal est écrit, l'état non ;
|
|
* CHEMIN_TROP_LONG (deriverBase) ; ABSENT : racine n'existe pas
|
|
*/
|
|
export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage, produitVersion }) {
|
|
const base = await deriver(fs, racine, charge.evenement.nom, []);
|
|
const entree = ligneEntree({ revision: 1, libelle, horodatage, produitVersion, apres: charge, instantane: true });
|
|
const jalon = ligneJalon({ revision: 1, nom: nomJalon, horodatage });
|
|
const vide = { evenement: charge.evenement.id, entrees: [], jalons: [], ecartees: 0 };
|
|
const journal = avec(avec(vide, entree), jalon);
|
|
const texteEtat = relisible(serialiser(charge, { revision: 1, produitVersion }), 'creer');
|
|
await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteDe(journal));
|
|
const cree = {
|
|
fs,
|
|
racine,
|
|
base,
|
|
produitVersion,
|
|
texteEtat: null,
|
|
revisionEtat: null,
|
|
journal,
|
|
baseJournal: base,
|
|
chargeJournal: canoniser(charge),
|
|
journalAReecrire: false,
|
|
formatPlusRecent: false,
|
|
etatMalEncode: false,
|
|
};
|
|
return ecrireEtat(apresEcriture(cree, { charge, revision: 1, journal }), texteEtat);
|
|
}
|
|
|
|
/**
|
|
* Enregistre un geste (§ 8.2) : apres devient la charge, à la révision qui
|
|
* suit la dernière entrée du journal. (1) Un journal qui manque, ou finit
|
|
* ailleurs que sur la charge ouverte, reçoit d'abord une entrée de reprise
|
|
* qui la restitue, au libellé libelleReprise ; (2) l'entrée du geste,
|
|
* correctif depuis la charge que restitue l'entrée qui la précède, ou
|
|
* instantané à la cadence du journal ; le journal s'écrit d'un ajout en fin,
|
|
* ou en entier quand (1) a lieu, quand sa lecture a écarté des lignes, et
|
|
* au-delà de PLAFOND_ENTREES, élagué ; (3) le .precedent reçoit texteEtat,
|
|
* s'il est lisible ; (4) l'état, son nouveau texte. Un format plus ancien
|
|
* s'écrit au format courant : la conversion tombe au premier geste. Avant
|
|
* (1), l'état aux octets hors UTF-8 d'un Ouvert de secours passe dans un
|
|
* dossier daté de la corbeille, à l'horodatage du geste
|
|
* (mettreDeCoteEtatMalEncode).
|
|
*
|
|
* Quand l'état lisible sur le disque ne porte pas la révision de la dernière
|
|
* entrée du journal — reprise comprise —, la reprise s'écrit seule, puis le
|
|
* .precedent et l'état à la révision de cette entrée, et alors seulement (2),
|
|
* (3) et (4) : une coupure laisse le journal en avance d'une entrée au plus,
|
|
* que l'ouverture rattrape (§ 8.2).
|
|
*
|
|
* Un geste refusé n'écrit rien : chaque ligne et chaque texte d'état se
|
|
* calculent, et se relisent, avant la première écriture.
|
|
*
|
|
* @param {Ouvert} ouvert
|
|
* @param {import('./types.js').Charge} apres
|
|
* @param {Object} options
|
|
* @param {string} options.libelle texte figé de l'entrée
|
|
* @param {string} options.horodatage AAAA-MM-JJTHH:MM:SS±HH:MM
|
|
* @param {string} options.produitVersion la construction qui écrit
|
|
* @param {null|'defaire'|'refaire'|'revenir'} [options.sens]
|
|
* @param {number|null} [options.retour] révision visée par un retour
|
|
* @param {string} [options.libelleReprise] texte de l'entrée de reprise,
|
|
* requis quand elle a lieu
|
|
* @returns {Promise<Ouvert>}
|
|
* @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'} ;
|
|
* ce que lève la mise de côté d'un état aux octets hors UTF-8
|
|
* (mettreDeCoteEtatMalEncode), avant le journal ;
|
|
* ECRITURE {chemin, dossier, cause} en (1), (2) ou pendant
|
|
* l'alignement, la recherche du journal d'un Ouvert sans base de
|
|
* journal comprise (baseCherchee) : le geste n'est pas au journal, et
|
|
* l'Ouvert reçu reste celui d'où le réessayer ; ETAT_NON_ECRIT
|
|
* {chemin, dossier, cause, ouvert} en (3) ou (4), le geste au journal
|
|
* et ouvert l'Ouvert avancé ; CHEMIN_TROP_LONG pour un journal à créer
|
|
* sous une base libre
|
|
* @throws {TypeError} une ligne, une charge ou un état que la lecture
|
|
* refuserait, avant toute écriture
|
|
*/
|
|
export async function enregistrer(ouvert, apres, options) {
|
|
const { horodatage, produitVersion, libelleReprise } = options;
|
|
exigerEcriture(ouvert);
|
|
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
|
const { ligne, revision } = ligneDuGeste(preparation, apres, options);
|
|
const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer');
|
|
const aligne = etatAligne(ouvert, preparation);
|
|
const depart = await mettreDeCoteEtatMalEncode(ouvert, horodatage);
|
|
const suite = aligne === null ? { depart, preparation } : await aligner(depart, preparation, aligne);
|
|
const { recree, ...ecrit } = await ecrireJournal(suite.depart, suite.preparation, ligne);
|
|
const avance = { ...suite.depart, ...ecrit, produitVersion, chargeJournal: canoniser(apres) };
|
|
return ecrireEtat(apresEcriture(avance, { charge: apres, revision, journal: ecrit.journal, recree }), texteEtat);
|
|
}
|
|
|
|
/**
|
|
* Pose le jalon nom sur l'instant courant (§ 8.3) : une ligne du journal,
|
|
* qui ne prend pas de révision ; l'état ne s'écrit pas. Un journal qui manque
|
|
* ou finit ailleurs que sur la charge ouverte reçoit d'abord l'entrée de
|
|
* reprise, comme pour enregistrer, et le jalon nomme alors son instant.
|
|
*
|
|
* @param {Ouvert} ouvert
|
|
* @param {{nom: string, horodatage: string, libelleReprise?: string}} options
|
|
* @returns {Promise<Ouvert>}
|
|
* @throws {ErreurStockage} LECTURE_SEULE ; ECRITURE, et rien n'a changé
|
|
* @throws {TypeError} un nom vide, un horodatage hors de sa forme
|
|
*/
|
|
export async function nommer(ouvert, { nom, horodatage, libelleReprise }) {
|
|
exigerEcriture(ouvert);
|
|
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
|
const revision = preparation.journal.entrees.at(-1).revision;
|
|
const { recree, ...ecrit } = await ecrireJournal(ouvert, preparation, ligneJalon({ revision, nom, horodatage }));
|
|
const nomme = { ...ouvert, ...ecrit, chargeJournal: preparation.charge };
|
|
return apresEcriture(nomme, { charge: ouvert.charge, revision, journal: ecrit.journal, recree });
|
|
}
|
|
|
|
/**
|
|
* Prend le verrou de la séance sur l'événement (§ 8.8) : le Verrou que rend
|
|
* le système de fichiers, tel quel.
|
|
*
|
|
* @param {Ouvert} ouvert
|
|
* @param {string} seance
|
|
* @returns {Promise<import('./systeme_fichiers.js').Verrou>}
|
|
*/
|
|
export async function verrouiller({ fs, racine, base }, seance) {
|
|
return fs.verrouiller(racine, base + SUFFIXES.verrou, seance);
|
|
}
|
|
|
|
/**
|
|
* Rend le verrou de la séance : seul le sien s'efface.
|
|
*
|
|
* @param {Ouvert} ouvert
|
|
* @param {string} seance
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function deverrouiller({ fs, racine, base }, seance) {
|
|
await fs.deverrouiller(racine, base + SUFFIXES.verrou, seance);
|
|
}
|
|
|
|
// L'état d'avant un geste, que le .precedent garde (§ 8.8) : le texte de
|
|
// l'état sur le disque quand il se lit ; sinon, pour un Ouvert de secours, la
|
|
// charge ouverte sous son en-tête, celle que l'entrée de reprise restitue.
|
|
// Une charge que l'analyse refuserait lève TypeError, avant toute écriture.
|
|
function etatDAvant({ texteEtat, charge, revision, produitVersion }) {
|
|
return texteEtat ?? relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs');
|
|
}
|
|
|
|
// La paire qu'enregistrerAilleurs écrit, calculée sans rien écrire. Sans
|
|
// journal ni geste, l'état seul : la charge ouverte, à sa révision et à sa
|
|
// version. Sinon le journal préparé (preparer), suivi de l'entrée de reprise
|
|
// quand il finit sur une autre révision que la charge ouverte, ou qu'il
|
|
// manque, puis de l'entrée du geste quand il y en a un (ligneDuGeste), en
|
|
// entier et élagué au-delà de PLAFOND_ENTREES (textePlein) ; puis l'état que
|
|
// restitue sa dernière entrée : la charge du geste, à sa révision et sous sa
|
|
// version, comme l'écrit enregistrer ; sans geste, la charge préparée sous
|
|
// l'en-tête de cette entrée (enteteDeLaDerniere), comme l'état qu'aligne un
|
|
// geste. La dernière entrée du journal écrit restitue l'état écrit (§ 8.2),
|
|
// et la réouverture n'a rien à rattraper. Une reprise sans libelleReprise, ou
|
|
// un geste dont la ligne ne se relirait pas, lève TypeError.
|
|
function paireAilleurs(ouvert, { libelleReprise, horodatage, geste }) {
|
|
if (ouvert.journal === null && geste === null) {
|
|
const { charge, revision, produitVersion } = ouvert;
|
|
return { journal: null, texteJournal: null, charge, chargeJournal: null, revision, produitVersion };
|
|
}
|
|
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
|
if (geste === null) {
|
|
const { texte, journal } = textePlein(preparation.journal);
|
|
const { charge } = preparation;
|
|
return { journal, texteJournal: texte, charge, chargeJournal: charge, ...enteteDeLaDerniere(journal) };
|
|
}
|
|
const { apres, produitVersion } = geste;
|
|
const { ligne, revision } = ligneDuGeste(preparation, apres, { ...geste, horodatage });
|
|
const { texte, journal } = textePlein(avec(preparation.journal, ligne));
|
|
return { journal, texteJournal: texte, charge: apres, chargeJournal: canoniser(apres), revision, produitVersion };
|
|
}
|
|
|
|
/**
|
|
* Écrit l'événement ouvert sous une base libre d'une racine : dans une autre
|
|
* racine (§ 8.6), la réponse à une écriture qui échoue, l'Ouvert reçu étant
|
|
* celui d'avant le geste refusé (ECRITURE) ou l'Ouvert avancé
|
|
* d'ETAT_NON_ECRIT ; ou, avec un geste, sous le nom que ce geste donne, dans
|
|
* le dossier même, ce qu'écrit le renommage (§ 8.7). Une racine que
|
|
* l'opérateur a choisie, que rien n'a sondée — documents en mode portable —,
|
|
* se crée d'abord, parents compris, un dossier existant restant tel quel ;
|
|
* creerRacine faux ne la crée pas : le dossier de travail qui disparaît en
|
|
* cours de séance ne renaît pas vide (§ 8.6). La base dérive du nom que porte
|
|
* la charge écrite (deriverBase), libre parmi existantes et les bases du
|
|
* dossier. La paire s'y écrit alignée, comme après un geste (§ 8.2) : le
|
|
* journal en mémoire, en entier — suivi d'une entrée de reprise, au libellé
|
|
* libelleReprise, quand il finit sur une autre révision que la charge
|
|
* ouverte, puis de l'entrée du geste, et élagué au-delà de PLAFOND_ENTREES —,
|
|
* puis l'état que restitue sa dernière entrée, à la révision et à la version
|
|
* de celle-ci. Rouvert là-bas, l'événement rend cette charge sans rien
|
|
* rattraper. Un événement sans journal ni geste n'écrit que son état : la
|
|
* charge ouverte, à sa révision ; avec un geste, l'entrée de reprise commence
|
|
* son journal. Le .precedent de l'Ouvert ne se recopie pas. Avec un geste, la
|
|
* nouvelle base reçoit d'abord le sien, l'état d'avant le geste (etatDAvant),
|
|
* comme enregistrer le pose, mais avant le journal : la base appartient alors
|
|
* à l'événement (proprietaire), si bien qu'aucun autre état de son
|
|
* identifiant — l'ancienne paire d'un renommage interrompu, dont le journal
|
|
* de la base n'est pas le sien — ne s'apparie le journal neuf (apparier). La
|
|
* paire va sous une base qu'aucun fichier du dossier ne porte : elle n'y
|
|
* remplace rien, pas même la paire de l'Ouvert quand ce dossier est le sien.
|
|
* Les lignes et les textes d'état se calculent, et se relisent, avant la
|
|
* première écriture, celle de la racine comprise.
|
|
*
|
|
* @param {Ouvert} ouvert
|
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
|
* @param {Object} options
|
|
* @param {Iterable<string>} options.existantes bases à ne pas prendre
|
|
* là-bas, en plus de celles du dossier
|
|
* @param {string} [options.libelleReprise] texte de l'entrée de reprise,
|
|
* requis quand elle a lieu
|
|
* @param {string} [options.horodatage] AAAA-MM-JJTHH:MM:SS±HH:MM de
|
|
* l'entrée de reprise et de celle du geste, requis avec elles
|
|
* @param {{apres: import('./types.js').Charge, libelle: string, produitVersion: string}|null} [options.geste]
|
|
* un geste à la suite de la charge ouverte, comme enregistrer le
|
|
* reçoit : apres devient la charge écrite, à la révision qui suit la
|
|
* dernière entrée du journal, l'entrée au libellé libelle, l'état
|
|
* sous la version produitVersion ; null par défaut
|
|
* @param {boolean} [options.creerRacine] faux : la racine n'est pas créée,
|
|
* et doit exister ; vrai par défaut
|
|
* @returns {Promise<Ouvert>} l'Ouvert dans racine : le journal et la
|
|
* révision écrits, chargeJournal la charge de l'état écrit, et
|
|
* charge celle qu'en rend examiner
|
|
* @throws {ErreurStockage} LECTURE_SEULE ; ECRITURE quand la racine ne se
|
|
* crée pas, ou que le .precedent d'un geste n'a pas pu s'écrire, rien
|
|
* d'autre n'étant écrit, ou que le journal n'a pas pu s'écrire, le
|
|
* .precedent d'un geste restant écrit avant lui ; ABSENT {chemin: ''}
|
|
* quand creerRacine est faux et que la racine n'existe pas, rien
|
|
* n'étant écrit ; ETAT_NON_ECRIT quand l'état n'a pas pu s'écrire,
|
|
* ouvert étant l'Ouvert dans racine ; CHEMIN_TROP_LONG
|
|
* @throws {TypeError} une reprise sans libelleReprise ou horodatage, un geste
|
|
* ou un état que la lecture refuserait, avant toute écriture
|
|
*/
|
|
export async function enregistrerAilleurs(
|
|
ouvert,
|
|
racine,
|
|
{ existantes, libelleReprise, horodatage, geste = null, creerRacine = true },
|
|
) {
|
|
exigerEcriture(ouvert);
|
|
const { fs } = ouvert;
|
|
const paire = paireAilleurs(ouvert, { libelleReprise, horodatage, geste });
|
|
const { journal, charge, revision, produitVersion } = paire;
|
|
const texteEtat = relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs');
|
|
const precedent = geste === null ? null : etatDAvant(ouvert);
|
|
if (creerRacine) await fs.creerDossier(racine, '');
|
|
const base = await deriver(fs, racine, charge.evenement.nom, [...existantes]);
|
|
if (precedent !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, precedent);
|
|
if (journal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, paire.texteJournal);
|
|
const ailleurs = {
|
|
...ouvert,
|
|
racine,
|
|
base,
|
|
produitVersion,
|
|
texteEtat: null,
|
|
revisionEtat: null,
|
|
journal,
|
|
baseJournal: base,
|
|
chargeJournal: paire.chargeJournal,
|
|
journalAReecrire: false,
|
|
etatMalEncode: false,
|
|
};
|
|
return ecrireEtat(apresEcriture(ailleurs, { charge, revision, journal }), texteEtat);
|
|
}
|