diff --git a/src/stockage/depot.js b/src/stockage/depot.js
new file mode 100644
index 0000000..a3e5e8b
--- /dev/null
+++ b/src/stockage/depot.js
@@ -0,0 +1,1140 @@
+// © 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) : .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. 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.
+//
+// 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.
+import { canoniser, serialiser } from './canonique.js';
+import { FORMAT, analyser } from './document.js';
+import { ErreurStockage } from './erreurs.js';
+import {
+ PLAFOND_ENTREES,
+ elaguer,
+ estInstantane,
+ ligneEntree,
+ ligneJalon,
+ ligneOuverture,
+ lireJournal,
+ reconstruire,
+ versionDe,
+} from './journal.js';
+import { SUFFIXES, deriverBase } 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 {import('./placements.js').Examen} examen
+ * @property {Array<{code: string, details: Object}>} avertissements dans
+ * cet ordre, chacun au plus une fois : RETENU_FAUTIF {} ;
+ * 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_FORMAT_PLUS_RECENT
+ * {format}, le journal de la base, d'un format plus récent, 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 ; 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 aussi, sauf pour un
+ * journal sans état, dont il est l'événement que nomme sa première ligne.
+ *
+ * @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);
+
+// Texte du fichier chemin, ou null quand il manque ; toute autre erreur
+// remonte telle quelle.
+async function lireSiPresent(fs, racine, chemin) {
+ try {
+ return await fs.lireTexte(racine, chemin);
+ } catch (erreur) {
+ if (erreur.code === 'ABSENT') return null;
+ throw erreur;
+ }
+}
+
+// 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).
+function surDisque(texte, { lecture, details }) {
+ if (lecture !== null) {
+ return { texteEtat: texte, revisionEtat: lecture.entete.revision, formatPlusRecent: lecture.formatPlusRecent };
+ }
+ return { texteEtat: null, revisionEtat: null, formatPlusRecent: (details?.format ?? FORMAT) > FORMAT };
+}
+
+// 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;
+}
+
+// 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, ...basesDe(await fs.lister(racine, ''))];
+ return deriverBase(nom, { racine: racine.chemin, separateur, existantes });
+}
+
+// Les fichiers de racine tels qu'une lecture les trouve : la liste du
+// dossier, le texte de chaque fichier — null quand il manque, toute autre
+// erreur de lecture remontant telle quelle —, et la lecture de chaque texte
+// d'état ou de .precedent (lireEtat), chacun lu une fois, à la demande. 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 textes = new Map();
+ const lectures = new Map();
+ const texte = async (chemin) => {
+ if (!textes.has(chemin)) textes.set(chemin, await lireSiPresent(fs, racine, chemin));
+ return textes.get(chemin);
+ };
+ return {
+ entrees: async () => {
+ entrees ??= await fs.lister(racine, '');
+ return entrees;
+ },
+ texte,
+ // { texte, lecture, details } d'un fichier d'état ou d'un .precedent,
+ // les trois null quand il manque.
+ lecture: async (chemin) => {
+ if (!lectures.has(chemin)) {
+ const lu = await texte(chemin);
+ const absent = { texte: null, lecture: null, details: null };
+ lectures.set(chemin, lu === null ? absent : { texte: lu, ...lireEtat(lu) });
+ }
+ 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 } 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 texte = await vue.texte(base + SUFFIXES.journal);
+ if (evenementDe(texte) === id) return { base, texte };
+ for (const autre of basesAuSuffixe(await vue.entrees(), SUFFIXES.journal)) {
+ const texteAutre = await vue.texte(autre + SUFFIXES.journal);
+ if (evenementDe(texteAutre) === id && (await proprietaire(vue, autre)) !== id) {
+ return { base: autre, texte: texteAutre };
+ }
+ }
+ 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.texte(base + SUFFIXES.journal));
+ 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.texte(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, et format. 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 format = await formatDuJournalDeBase(vue, base);
+ if (format !== null) return { lu: null, base, etranger: null, format };
+ const texte = await vue.texte(base + SUFFIXES.journal);
+ if (id === null) return { lu: null, base: null, etranger: null, format: null };
+ const apparie = await apparier(vue, base, id);
+ if (apparie !== null) return { lu: lireJournal(apparie.texte), base: apparie.base, etranger: null, format: null };
+ const etranger = evenementDe(texte);
+ if (etranger !== null) return { lu: null, base: null, etranger, format: null };
+ return { lu: texte === null ? null : lireJournal(texte), base, etranger: null, format: null };
+}
+
+// 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.
+const depuisLecture = ({ entete, charge, formatPlusRecent }, disque) => ({
+ charge,
+ revision: entete.revision,
+ produitVersion: entete.produitVersion,
+ ...disque,
+ formatPlusRecent: formatPlusRecent || disque.formatPlusRecent,
+});
+
+// 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.
+function avertir({ examen, journal, revision, rattrape, ecartees, format, etranger }) {
+ const avertissements = [];
+ const ajouter = (code, details = {}) => avertissements.push({ code, details });
+ if (examen.retenu.fautes.length > 0) 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 (format !== null) ajouter('JOURNAL_FORMAT_PLUS_RECENT', { format });
+ if (etranger !== null) ajouter('JOURNAL_ETRANGER', { evenement: etranger });
+ if (examen.fautives.length > 0) ajouter('PROPOSITIONS_FAUTIVES', { ids: examen.fautives.map(({ id }) => id) });
+ 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. 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 } = source;
+ const rattrape = rattraper && instant !== null && instant.revision === revision + 1;
+ if (rattrape) {
+ charge = canoniser(instant.charge);
+ revision = instant.revision;
+ produitVersion = versionDe(instant.journal, revision);
+ }
+ 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,
+ examen,
+ avertissements: avertir({
+ examen,
+ journal,
+ revision,
+ rattrape,
+ ecartees,
+ format: trouve.format,
+ etranger: trouve.etranger,
+ }),
+ };
+}
+
+// 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.
+async function refuser(vue, base, details) {
+ const precedent = await vue.lecture(base + SUFFIXES.precedent);
+ const journal = await vue.texte(base + SUFFIXES.journal);
+ if (details.raison === 'ABSENT' && precedent.texte === null && journal === null) {
+ 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. 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}
+ */
+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, null, 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.texte(nom));
+ 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}
+ * @throws {ErreurStockage} ETAT_ILLISIBLE {base, raison, chemin, [format],
+ * secours} : l'état ne se lit pas, ou manque à côté de son .precedent
+ * ou de son journal (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.texte === null) throw await refuser(vue, base, { raison: 'ABSENT', chemin: null });
+ if (etat.lecture === null) throw await refuser(vue, base, etat.details);
+ 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}
+ * @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, ou, source 'journal',
+ * {raison: 'FORMAT_PLUS_RECENT', chemin: le journal de la base,
+ * format} quand ce journal est d'un format plus récent
+ * @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 lecture = analyser(await fs.lireTexte(racine, base + SUFFIXES.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,
+ });
+ }
+ 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),
+ ...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);
+ depart = { ...ouvert, ...ecrit };
+ journal = ecrit.journal;
+ 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) };
+}
+
+// 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 — le dossier qui ne se liste plus, ou un journal qui ne se lit
+// plus : ABSENT quand le dossier a disparu avec son support, LECTURE quand
+// la lecture est refusée — lève ECRITURE, comme l'écriture qu'elle précède :
+// chemin le journal de la base, dossier le chemin de la racine, qui porte
+// les fichiers de l'événement, et cause celle de l'échec, ENOENT pour un
+// dossier disparu. L'application nomme ainsi le fichier et son dossier, et
+// propose d'écrire ailleurs (§ 8.6). 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) {
+ if (erreur.code !== 'ABSENT' && erreur.code !== 'LECTURE') throw erreur;
+ const cause = erreur.code === 'ABSENT' ? 'ENOENT' : erreur.details.cause;
+ throw new ErreurStockage('ECRITURE', { chemin: base + SUFFIXES.journal, dossier: racine.chemin, cause });
+ }
+}
+
+// É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 ou que le plafond est dépassé. Sans base, le journal
+// prend celle que baseCherchee trouve. Rend le journal en mémoire, qui est
+// le fichier, et sa base.
+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));
+ if (preparation.entier || journal.entrees.length > PLAFOND_ENTREES) {
+ const plein = textePlein(journal);
+ journal = plein.journal;
+ await fs.ecrireAtomique(racine, base + SUFFIXES.journal, plein.texte);
+ } else {
+ await fs.ajouterLigne(racine, base + SUFFIXES.journal, ligne);
+ }
+ return { journal: { ...journal, ecartees: 0 }, baseJournal: base, journalAReecrire: 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 et d'un journal qui finit ailleurs que sur elle.
+function apresEcriture(ouvert, { charge, revision, journal }) {
+ const examen = examiner(charge);
+ return {
+ ...ouvert,
+ charge: examen.charge,
+ revision,
+ examen,
+ avertissements: avertir({ examen, journal, revision, rattrape: false, ecartees: 0, format: null, etranger: null }),
+ };
+}
+
+/**
+ * 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}
+ * @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,
+ };
+ 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.
+ *
+ * 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}
+ * @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'} ;
+ * 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 { libelle, horodatage, produitVersion, sens, retour, libelleReprise } = options;
+ exigerEcriture(ouvert);
+ const preparation = preparer(ouvert, { libelleReprise, horodatage });
+ 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),
+ });
+ const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer');
+ const aligne = etatAligne(ouvert, preparation);
+ const suite = aligne === null ? { depart: ouvert, preparation } : await aligner(ouvert, preparation, aligne);
+ const 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 }), 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}
+ * @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 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 });
+}
+
+/**
+ * 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}
+ */
+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}
+ */
+export async function deverrouiller({ fs, racine, base }, seance) {
+ await fs.deverrouiller(racine, base + SUFFIXES.verrou, seance);
+}
+
+// La paire qu'enregistrerAilleurs écrit, calculée sans rien écrire. Sans
+// journal, 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, en entier et élagué
+// au-delà de PLAFOND_ENTREES (textePlein) ; puis l'état que restitue sa
+// dernière entrée, sous l'en-tête de celle-ci (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 lève TypeError (preparer).
+function paireAilleurs(ouvert, options) {
+ if (ouvert.journal === null) {
+ const { charge, revision, produitVersion } = ouvert;
+ return { journal: null, texteJournal: null, charge, chargeJournal: null, revision, produitVersion };
+ }
+ const preparation = preparer(ouvert, options);
+ const { texte, journal } = textePlein(preparation.journal);
+ const { charge } = preparation;
+ return { journal, texteJournal: texte, charge, chargeJournal: charge, ...enteteDeLaDerniere(journal) };
+}
+
+/**
+ * Écrit l'événement ouvert 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. L'opérateur a choisi cette
+ * racine, que rien n'a sondée — documents en mode portable — : elle se crée
+ * d'abord, parents compris, un dossier existant restant tel quel. Sa base
+ * dérive du nom là-bas (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, 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 n'écrit que son état : la charge ouverte, à sa révision. Le
+ * .precedent ne se recopie pas. Les lignes et le texte de l'é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} 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, requis avec elle
+ * @returns {Promise} 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, rien d'autre n'étant écrit, ou que le journal n'a pas pu
+ * s'écrire ; 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 état
+ * que la lecture refuserait, avant toute écriture
+ */
+export async function enregistrerAilleurs(ouvert, racine, { existantes, libelleReprise, horodatage }) {
+ exigerEcriture(ouvert);
+ const { fs } = ouvert;
+ const paire = paireAilleurs(ouvert, { libelleReprise, horodatage });
+ const { journal, charge, revision, produitVersion } = paire;
+ const texteEtat = relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs');
+ await fs.creerDossier(racine, '');
+ const base = await deriver(fs, racine, charge.evenement.nom, [...existantes]);
+ 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,
+ };
+ return ecrireEtat(apresEcriture(ailleurs, { charge, revision, journal }), texteEtat);
+}
diff --git a/src/stockage/depot.test.js b/src/stockage/depot.test.js
new file mode 100644
index 0000000..63b2b2c
--- /dev/null
+++ b/src/stockage/depot.test.js
@@ -0,0 +1,2311 @@
+// © 2026 TechnoLibre (http://www.technolibre.ca)
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+
+// Épreuves du dépôt (§ 8.1, § 8.2, § 8.4, § 8.6, § 8.8, § 8.9, § 14.10), toutes
+// sur le système de fichiers d'épreuve, chacune comptant ses écritures : une
+// ouverture n'en fait aucune. Un événement se crée, se liste et s'ouvre ; un
+// geste donne une entrée puis un état ; une panne à chaque frontière d'un geste
+// se reprend à l'ouverture, sans regarder la date des fichiers ; un état
+// illisible nomme ses secours, qui s'ouvrent ; le journal s'apparie par
+// identifiant, se répare au premier geste qui suit l'ouverture et s'élague ;
+// une proposition fautive s'écarte, une périmée se garde ; le verrou ;
+// l'écriture ailleurs. Les noms d'épreuve sont inventés ; les versions viennent
+// du module engendré.
+import assert from 'node:assert/strict';
+import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
+import { describe, test } from '../../test/lanceur.js';
+import { versionVoisine } from '../../test/version_voisine.js';
+import { VERSION } from '../version.genere.js';
+import { serialiser, serialiserCharge } from './canonique.js';
+import {
+ creer,
+ deverrouiller,
+ enregistrer,
+ enregistrerAilleurs,
+ lister,
+ nommer,
+ ouvrir,
+ ouvrirSecours,
+ verrouiller,
+} from './depot.js';
+import { analyser, creerCharge } from './document.js';
+import { ErreurStockage } from './erreurs.js';
+import { PLAFOND_ENTREES, elaguer, fil, ligneJalon, ligneOuverture, lireJournal, reconstruire } from './journal.js';
+import { SUFFIXES } from './noms.js';
+
+const V1 = VERSION.affichee;
+const V2 = versionVoisine(V1);
+
+// La racine documents du système d'épreuve, à son chemin par défaut.
+const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre';
+const DOC = { id: 'documents', chemin: DOCUMENTS };
+
+const BASE = 'Soirée des Lucioles';
+const ETAT = `${BASE}${SUFFIXES.etat}`;
+const PRECEDENT = `${BASE}${SUFFIXES.precedent}`;
+const JOURNAL = `${BASE}${SUFFIXES.journal}`;
+const VERROU = `${BASE}${SUFFIXES.verrou}`;
+
+const REPRISE = "Reprise de l'état trouvé sur le disque";
+
+// Horodatage d'affichage de l'instant n : une minute par instant à partir de
+// 8 h, le 17 mai 2031, à l'heure de l'Est.
+function heure(n) {
+ const minutes = 8 * 60 + n;
+ const deux = (valeur) => String(valeur).padStart(2, '0');
+ return `2031-05-17T${deux(Math.floor(minutes / 60))}:${deux(minutes % 60)}:00-04:00`;
+}
+
+const CREATION = { libelle: "Création de l'événement", nomJalon: 'Création', horodatage: heure(0), produitVersion: V1 };
+const geste = (n, plus = {}) => ({ libelle: `Geste ${n}`, horodatage: heure(n), produitVersion: V1, ...plus });
+
+// --- Les charges -----------------------------------------------------------
+
+const personne = (id, nom, prenom, appartenance) => ({
+ id,
+ nom,
+ prenom,
+ appartenance,
+ courriel: null,
+ titrePressenti: null,
+ notes: null,
+ exclu: false,
+});
+
+const table = (id, x) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x, y: 0 } });
+
+// Placement de deux tours sur les tables 1 et 2, deux places chacune ; le
+// premier tour est donné, le second fixé.
+function placement(id, premierTour) {
+ return {
+ id,
+ graine: 48271 + id,
+ arret: 200000,
+ historique: 1000,
+ produitVersion: V1,
+ siegesAttribues: false,
+ tables: [1, 2],
+ capacites: [2, 2],
+ tours: 2,
+ participants: [1, 2, 3, 4],
+ placement: [
+ { sieges: premierTour, reserve: [] },
+ { sieges: [[1, 4], [2, 3]], reserve: [] },
+ ],
+ };
+}
+
+// Retenu repris d'un placement, ses champs d'identification retirés.
+function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants, placement: plan }) {
+ return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement: plan };
+}
+
+// Charge d'épreuve : quatre personnes, deux tables, une réservation, un titre,
+// une proposition cohérente.
+function chargeEssai({ id = 'evt-lucioles', nom = BASE } = {}) {
+ const charge = creerCharge({ id, nom, siegesParDefaut: 2, tours: 2 });
+ charge.evenement.etat = 'propose';
+ charge.prochainsIds = { participant: 5, table: 3, proposition: 2 };
+ charge.participants = [
+ personne(1, 'Ombrelle', 'Iris', 'Club des Merles'),
+ personne(2, 'Grisaille', null, 'Club des Merles'),
+ personne(3, 'Pervenche', 'Théo', null),
+ personne(4, 'Lacasse', 'Ondine', 'Société Alpha'),
+ ];
+ charge.tables = [table(1, 0), table(2, 250)];
+ charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }];
+ charge.titres = [{ table: 1, siege: 1, libelle: 'animation' }];
+ charge.propositions = [placement(1, [[1, 3], [2, 4]])];
+ return charge;
+}
+
+// La charge d'épreuve et une seconde proposition qui se contredit : la
+// personne 3 assise deux fois au premier tour, la 4 nulle part. examiner
+// l'écarte (§ 8.9, point 3) : la charge ouverte n'est plus celle du fichier,
+// ni celle que restitue le journal.
+function avecFautive() {
+ const charge = chargeEssai();
+ charge.propositions.push(placement(2, [[1, 3], [2, 3]]));
+ charge.prochainsIds.proposition = 3;
+ return charge;
+}
+
+// La charge reçue, la note de sa première personne changée : un geste.
+function modifier(charge, n) {
+ const suivante = structuredClone(charge);
+ suivante.participants[0].notes = `note ${n}`;
+ return suivante;
+}
+
+// La charge reçue, une personne de plus sous le prochain identifiant, le
+// compteur relevé : un geste dont le correctif dépend de la charge d'où il
+// part. Il pose la personne par son identifiant, ce qui lève sur une charge
+// qui la porte déjà ; un correctif calculé depuis un instant d'avant
+// l'inscription la pose de nouveau. La personne arrive exclue : aucune
+// proposition ne la place, et aucune ne dérive.
+function inscrire(charge, n) {
+ const suivante = structuredClone(charge);
+ const id = suivante.prochainsIds.participant;
+ suivante.participants.push({ ...personne(id, 'Brunelle', null, null), notes: `inscrite au geste ${n}`, exclu: true });
+ suivante.prochainsIds.participant = id + 1;
+ return suivante;
+}
+
+// Le geste n sur la charge reçue : une inscription quand n est impair, la
+// note changée sinon. Une note se remplace d'où que parte son correctif ;
+// une inscription sur deux gestes donne à deux instants voisins des listes
+// de personnes différentes, si bien qu'exigerEquivalence voit un correctif
+// posé depuis une autre charge que la sienne (§ 14.2).
+const gesteSur = (charge, n) => (n % 2 === 1 ? inscrire(charge, n) : modifier(charge, n));
+
+const texteDeCharge = (charge) => serialiserCharge(charge);
+const etatTexte = (charge, revision = 1, produitVersion = V1) => serialiser(charge, { revision, produitVersion });
+
+// --- Les disques -----------------------------------------------------------
+
+// Un disque neuf dont la racine documents existe, et son premier processus.
+function nouveauDisque(options = {}) {
+ const disque = new Map();
+ const fs = creerFichiersSimules({ disque, ...options });
+ fs.deposerDossier('documents');
+ return { disque, fs };
+}
+
+// Un disque neuf dont documents porte les fichiers [nom, texte, modifie?].
+function disqueAvec(fichiers) {
+ const { disque, fs } = nouveauDisque();
+ for (const [nom, texte, modifie] of fichiers) fs.deposer('documents', nom, texte, modifie);
+ return { disque, fs };
+}
+
+// Fichiers de documents, [nom, texte], par nom, lus par un processus à part.
+async function fichiersDe(disque) {
+ const lecteur = creerFichiersSimules({ disque });
+ const entrees = await lecteur.lister(DOC, '');
+ return entrees.filter(({ type }) => type === 'fichier').map(({ nom }) => [nom, lecteur.contenu('documents', nom)]);
+}
+
+// Les fichiers donnés, le texte de nom remplacé, ou retiré quand il est null.
+function remplacer(fichiers, nom, texte) {
+ const autres = fichiers.filter(([autre]) => autre !== nom);
+ return texte === null ? autres : [...autres, [nom, texte]];
+}
+
+const texteDe = (fichiers, nom) => fichiers.find(([autre]) => autre === nom)[1];
+
+// Ouvre base sur le disque par un processus neuf, en affirmant qu'ouvrir
+// n'écrit rien (§ 8.4).
+async function rouvrir(disque, base) {
+ const fs = creerFichiersSimules({ disque });
+ const ouvert = await ouvrir(fs, DOC, base);
+ assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit');
+ return ouvert;
+}
+
+// Inscrit, dans l'ordre, chaque appel d'une primitive d'écriture de fichier
+// ou de dossier : [primitive, chemin].
+function tracer(fs) {
+ const trace = [];
+ for (const nom of ['ecrireAtomique', 'ajouterLigne', 'creerDossier', 'deplacer', 'supprimer']) {
+ const primitive = fs[nom];
+ fs[nom] = (racine, chemin, ...reste) => {
+ trace.push([nom, chemin]);
+ return primitive(racine, chemin, ...reste);
+ };
+ }
+ return trace;
+}
+
+// Dès l'écriture du rang-ième .precedent, le premier par défaut, les
+// renommages de sa racine sont refusés : l'écriture atomique de l'état qui
+// suit échoue sans toucher sa cible (§ 8.8). Rend la fonction qui retire ce
+// piège.
+function refuserRenommageApresPrecedent(fs, rang = 1) {
+ const ecrire = fs.ecrireAtomique;
+ let precedents = 0;
+ fs.ecrireAtomique = async (racine, chemin, texte) => {
+ await ecrire(racine, chemin, texte);
+ if (!chemin.endsWith(SUFFIXES.precedent)) return;
+ precedents += 1;
+ if (precedents >= rang) fs.pannes.renommage(racine.id);
+ };
+ return () => {
+ fs.ecrireAtomique = ecrire;
+ fs.pannes.renommage('documents', 0);
+ };
+}
+
+// Rend la raison du rejet de la promesse ; échoue quand elle se résout, par
+// un constat qui se termine sur message quand il est donné.
+async function rejet(promesse, message) {
+ let raison;
+ await assert.rejects(
+ promesse,
+ (erreur) => {
+ raison = erreur;
+ return true;
+ },
+ message,
+ );
+ return raison;
+}
+
+// Attend le rejet d'une ErreurStockage de ce code, et la rend ; message
+// nomme le cas d'une boucle dont la promesse se résout.
+async function echec(promesse, code, message) {
+ const erreur = await rejet(promesse, message);
+ assert.ok(erreur instanceof ErreurStockage, String(erreur));
+ assert.equal(erreur.code, code, String(erreur));
+ return erreur;
+}
+
+// Un geste ⇔ une entrée ⇔ un état (§ 8.2) : la dernière entrée du journal
+// porte la révision de l'état et restitue exactement sa charge ; le journal se
+// lit en entier.
+function exigerEquivalence(fs, base, { racine = 'documents', journal = base } = {}) {
+ const etat = analyser(fs.contenu(racine, `${base}${SUFFIXES.etat}`));
+ const lu = lireJournal(fs.contenu(racine, `${journal}${SUFFIXES.journal}`));
+ const derniere = lu.entrees.at(-1);
+ assert.equal(lu.ecartees, 0);
+ assert.equal(derniere.revision, etat.entete.revision);
+ assert.equal(texteDeCharge(reconstruire(lu, derniere.revision)), texteDeCharge(etat.charge));
+ return lu;
+}
+
+const revisionsEtLibelles = (journal) => journal.entrees.map(({ revision, libelle }) => [revision, libelle]);
+
+// Un événement créé, puis deux gestes, une note puis une inscription
+// (gesteSur) : le .precedent porte la révision 2, l'état et le journal la 3,
+// et ces deux instants n'ont pas les mêmes personnes. Rend ses fichiers et le
+// dernier Ouvert.
+async function troisRevisions() {
+ const { disque, fs } = nouveauDisque();
+ let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ for (const n of [2, 3]) ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, n), geste(n));
+ return { fichiers: await fichiersDe(disque), ouvert };
+}
+
+// Le journal d'un autre événement, créé sur un disque à part : posé sous la
+// base, il est JOURNAL_ETRANGER.
+async function journalEtranger() {
+ const { fs } = nouveauDisque();
+ await creer(fs, DOC, chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" }), CREATION);
+ return fs.contenu('documents', `Gala d'automne${SUFFIXES.journal}`);
+}
+
+// L'Ouvert que rend ouvrirAinsi sur un disque neuf qui porte fichiers, par un
+// processus neuf, sans rien écrire, ses avertissements de codes attendus :
+// pour exigerChaqueCoupure.
+function depuisFichiers(fichiers, ouvrirAinsi, codes) {
+ return async () => {
+ const { disque, fs } = disqueAvec(fichiers);
+ const ouvert = await ouvrirAinsi(fs);
+ assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit');
+ assert.deepEqual(
+ ouvert.avertissements.map(({ code }) => code),
+ codes,
+ );
+ return { disque, ouvert };
+ };
+}
+
+// Chaque coupure du premier geste qui part de l'Ouvert que rend depart, sur un
+// disque neuf : après n écritures réussies, de n = 0 jusqu'au geste achevé. Un
+// premier passage sans coupure désigne le journal où le geste s'inscrit, par
+// la base que rend son Ouvert : celle de l'événement, ou celle que le geste
+// dérive quand le journal de la base porte un autre événement. Rouvert après
+// la coupure, l'événement rend le geste dès que sa ligne est à ce journal — à
+// sa révision, rattrapé en mémoire, sans autre avertissement que
+// JOURNAL_REPRIS (§ 8.2) ; achevé, le geste est la dernière entrée de ce
+// journal. Rend le nombre de coupures tombées entre la ligne du geste et l'état
+// écrit : l'épreuve en exige au moins une.
+async function exigerChaqueCoupure(nom, depart) {
+ const options = geste(99, { libelleReprise: REPRISE });
+ const temoin = await depart();
+ const { baseJournal } = await enregistrer(temoin.ouvert, modifier(temoin.ouvert.charge, 99), options);
+ const journal = `${baseJournal}${SUFFIXES.journal}`;
+ let entreLigneEtEtat = 0;
+ for (let n = 0; ; n += 1) {
+ const { disque, ouvert } = await depart();
+ const apres = modifier(ouvert.charge, 99);
+ ouvert.fs.pannes.couperApres(n);
+ let acheve = true;
+ try {
+ await enregistrer(ouvert, apres, options);
+ } catch (erreur) {
+ assert.ok(erreur instanceof PanneSimulee, `${nom}, coupure après ${n} écritures : ${String(erreur)}`);
+ acheve = false;
+ }
+ const message = `${nom}, coupure après ${n} écritures`;
+ const texte = ouvert.fs.contenu('documents', journal);
+ const derniere = texte === null ? undefined : lireJournal(texte).entrees.at(-1);
+ if (acheve) assert.equal(derniere?.libelle, options.libelle, `${message} : le geste achevé finit ${journal}`);
+ if (derniere?.libelle === options.libelle) {
+ const rouvert = await rouvrir(disque, BASE);
+ assert.equal(texteDeCharge(rouvert.charge), texteDeCharge(apres), message);
+ assert.equal(rouvert.revision, derniere.revision, message);
+ assert.deepEqual(
+ rouvert.avertissements.filter(({ code }) => code !== 'JOURNAL_REPRIS'),
+ [],
+ message,
+ );
+ if (!acheve) entreLigneEtEtat += 1;
+ }
+ if (acheve) return entreLigneEtEtat;
+ }
+}
+
+describe('dépôt : créer, ouvrir, lister', () => {
+ test("créer écrit le journal, puis l'état ; ouvrir rend la même charge à la révision 1 et son journal d'une entrée, sans rien écrire", async () => {
+ const { disque, fs } = nouveauDisque();
+ const trace = tracer(fs);
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ assert.deepEqual(trace, [
+ ['ecrireAtomique', JOURNAL],
+ ['ecrireAtomique', ETAT],
+ ]);
+ assert.equal(fs.compteurs.ecritures, 2);
+ assert.equal(cree.base, BASE);
+ assert.equal(cree.revision, 1);
+ const texteEtat = etatTexte(chargeEssai());
+ assert.equal(fs.contenu('documents', ETAT), texteEtat);
+ assert.equal(cree.texteEtat, texteEtat);
+
+ const journal = lireJournal(fs.contenu('documents', JOURNAL));
+ assert.equal(journal.evenement, 'evt-lucioles');
+ assert.deepEqual(revisionsEtLibelles(journal), [[1, CREATION.libelle]]);
+ assert.equal(journal.entrees[0].produitVersion, V1);
+ assert.equal(texteDeCharge(journal.entrees[0].instantane), texteDeCharge(chargeEssai()));
+ assert.deepEqual(
+ journal.jalons.map(({ revision, nom }) => [revision, nom]),
+ [[1, 'Création']],
+ );
+
+ const ouvert = await rouvrir(disque, BASE);
+ assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(chargeEssai()));
+ assert.equal(ouvert.revision, 1);
+ assert.equal(ouvert.base, BASE);
+ assert.equal(ouvert.texteEtat, texteEtat);
+ assert.equal(ouvert.produitVersion, V1);
+ assert.equal(ouvert.formatPlusRecent, false);
+ assert.deepEqual(ouvert.journal, journal);
+ assert.deepEqual(ouvert.avertissements, []);
+ assert.deepEqual(ouvert.examen.fautives, []);
+ });
+
+ test("créer dérive la base du nom de l'événement sans reprendre une base du dossier, casse comprise", async () => {
+ const { fs } = disqueAvec([['SOIRÉE DES LUCIOLES.gtt.verrou', '{}\n']]);
+ const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ assert.equal(ouvert.base, 'Soirée des Lucioles (2)');
+ assert.equal(fs.compteurs.ecritures, 2);
+ assert.equal(fs.contenu('documents', 'Soirée des Lucioles (2).gtt.json'), etatTexte(chargeEssai()));
+ });
+
+ test("créer refuse, sans rien écrire, une charge hors de sa forme ou dont l'état ne se relirait pas", async () => {
+ const { disque, fs } = nouveauDisque();
+ await assert.rejects(creer(fs, DOC, { ...chargeEssai(), inconnue: 1 }, CREATION), TypeError);
+ const orpheline = chargeEssai();
+ orpheline.titres = [{ table: 9, siege: 1, libelle: 'animation' }];
+ await assert.rejects(creer(fs, DOC, orpheline, CREATION), /ne se relirait pas/);
+ assert.equal(fs.compteurs.ecritures, 0);
+ assert.deepEqual(await fichiersDe(disque), []);
+ });
+
+ test('lister rend chaque événement du dossier, trié par nom, avec ou sans journal, sans rien écrire', async () => {
+ const { disque, fs } = nouveauDisque();
+ await creer(fs, DOC, chargeEssai(), CREATION);
+ const gala = chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" });
+ gala.evenement.date = '2031-10-04';
+ fs.deposer('documents', "Gala d'automne.gtt.json", etatTexte(gala, 4), 12345);
+
+ const lecteur = creerFichiersSimules({ disque });
+ const entrees = await lecteur.lister(DOC, '');
+ const resumes = await lister(lecteur, DOC);
+ assert.equal(lecteur.compteurs.ecritures, 0);
+ assert.deepEqual(resumes, [
+ {
+ base: "Gala d'automne",
+ id: 'evt-gala',
+ nom: "Gala d'automne",
+ date: '2031-10-04',
+ etat: 'propose',
+ participants: 4,
+ modifie: 12345,
+ journal: null,
+ illisible: null,
+ doublon: false,
+ },
+ {
+ base: BASE,
+ id: 'evt-lucioles',
+ nom: BASE,
+ date: null,
+ etat: 'propose',
+ participants: 4,
+ modifie: entrees.find(({ nom }) => nom === ETAT).modifie,
+ journal: BASE,
+ illisible: null,
+ doublon: false,
+ },
+ ]);
+ });
+
+ test("lister apparie par l'identifiant, jamais par le nom : journal renommé, état illisible, journal sans état, doublon, journal d'un autre", async () => {
+ const ouverture = (evenement) => `${ligneOuverture(evenement)}\n`;
+ const etat = (id, nom) => etatTexte(chargeEssai({ id, nom }));
+ const { fs } = disqueAvec([
+ // état lisible, son journal renommé à la main ; une copie de ce journal
+ ['Aubepine.gtt.json', etat('evt-a', 'Aubépine'), 1000],
+ ['Carnet.gtt-journal.jsonl', ouverture('evt-a'), 1001],
+ ['Copie.gtt-journal.jsonl', ouverture('evt-a'), 1002],
+ // état illisible, son journal à côté
+ ['Bleuet.gtt.json', '', 2000],
+ ['Bleuet.gtt-journal.jsonl', ouverture('evt-b'), 2001],
+ // journal sans état
+ ['Capucine.gtt-journal.jsonl', ouverture('evt-c'), 3000],
+ // deux états du même identifiant
+ ['Dahlia.gtt.json', etat('evt-d', 'Dahlia'), 4000],
+ ['Dahlia bis.gtt.json', etat('evt-d', 'Dahlia'), 4001],
+ // état lisible, à côté du journal d'un autre événement
+ ['Esquisse.gtt.json', etat('evt-e', 'Esquisse'), 5000],
+ ['Esquisse.gtt-journal.jsonl', ouverture('evt-x'), 5001],
+ // journal sans état, dont l'ouverture ne se lit pas
+ ['Fougere.gtt-journal.jsonl', 'pas du JSON\n', 6000],
+ // état illisible dont le journal porte un autre événement, lisible lui
+ ['Glycine.gtt.json', '{', 7000],
+ ['Glycine.gtt-journal.jsonl', ouverture('evt-h'), 7001],
+ ['Hortensia.gtt.json', etat('evt-h', 'Hortensia'), 8000],
+ // état illisible, ni .precedent ni journal
+ ['Jacinthe.gtt.json', '{"entete":', 8500],
+ // ni état ni journal
+ ['notes.txt', 'à lire', 9000],
+ ]);
+ fs.deposerDossier('documents', 'Iris.gtt.json');
+ const lisible = (base, id, nom, modifie, journal, doublon = false) => ({
+ base,
+ id,
+ nom,
+ date: null,
+ etat: 'propose',
+ participants: 4,
+ modifie,
+ journal,
+ illisible: null,
+ doublon,
+ });
+ const illisible = (base, id, modifie, journal, raison) => ({
+ base,
+ id,
+ nom: null,
+ date: null,
+ etat: null,
+ participants: null,
+ modifie,
+ journal,
+ illisible: raison,
+ doublon: false,
+ });
+ assert.deepEqual(await lister(fs, DOC), [
+ lisible('Aubepine', 'evt-a', 'Aubépine', 1000, 'Carnet'),
+ lisible('Dahlia', 'evt-d', 'Dahlia', 4000, null, true),
+ lisible('Dahlia bis', 'evt-d', 'Dahlia', 4001, null, true),
+ lisible('Esquisse', 'evt-e', 'Esquisse', 5000, null),
+ lisible('Hortensia', 'evt-h', 'Hortensia', 8000, 'Glycine'),
+ illisible('Bleuet', null, 2000, 'Bleuet', 'VIDE'),
+ illisible('Capucine', 'evt-c', 3000, 'Capucine', 'ABSENT'),
+ illisible('Fougere', null, 6000, 'Fougere', 'ABSENT'),
+ illisible('Glycine', null, 7000, null, 'JSON'),
+ illisible('Jacinthe', null, 8500, null, 'JSON'),
+ ]);
+ assert.equal(fs.compteurs.ecritures, 0);
+ });
+});
+
+describe('dépôt : un geste, une entrée, un état (§ 8.2)', () => {
+ test("chaque geste ajoute son entrée au journal, puis écrit le .precedent et l'état : la dernière entrée restitue la charge écrite", async () => {
+ const { fs } = nouveauDisque();
+ const trace = tracer(fs);
+ let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ for (let n = 2; n <= 5; n += 1) {
+ const etatAvant = fs.contenu('documents', ETAT);
+ const depart = trace.length;
+ // Les gestes alternent note et inscription (gesteSur) ; le dernier
+ // revient à l'instant 3 : un retour est une entrée comme une autre, dont
+ // la charge est celle de sa cible (§ 8.3).
+ const retour = n === 5 ? { sens: 'revenir', retour: 3 } : {};
+ const apres = n === 5 ? reconstruire(ouvert.journal, 3) : gesteSur(ouvert.charge, n);
+ ouvert = await enregistrer(ouvert, apres, geste(n, retour));
+ assert.deepEqual(trace.slice(depart), [
+ ['ajouterLigne', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ]);
+ assert.equal(fs.contenu('documents', PRECEDENT), etatAvant);
+ assert.equal(ouvert.revision, n);
+ assert.equal(ouvert.texteEtat, fs.contenu('documents', ETAT));
+ assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(apres));
+ const journal = exigerEquivalence(fs, BASE);
+ assert.equal(journal.entrees.length, n);
+ assert.deepEqual(ouvert.journal, journal);
+ }
+ assert.equal(fs.compteurs.ecritures, 2 + 4 * 3);
+ const derniere = ouvert.journal.entrees.at(-1);
+ assert.deepEqual([derniere.sens, derniere.retour], ['revenir', 3]);
+ assert.equal(fil(ouvert.journal).position, 3);
+ });
+
+ test("une entrée note la construction quand elle change, et l'état porte celle du dernier geste", async () => {
+ const { fs } = nouveauDisque();
+ let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2));
+ ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3, { produitVersion: V2 }));
+ ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { produitVersion: V2 }));
+ const { entrees } = lireJournal(fs.contenu('documents', JOURNAL));
+ assert.deepEqual(
+ entrees.map(({ produitVersion }) => produitVersion),
+ [V1, null, V2, null],
+ );
+ assert.equal(analyser(fs.contenu('documents', ETAT)).entete.produitVersion, V2);
+ assert.equal(ouvert.produitVersion, V2);
+ assert.equal(fs.compteurs.ecritures, 2 + 3 * 3);
+ });
+
+ test("nommer ajoute un jalon sur l'instant courant, sans écrire l'état", async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ const etat = fs.contenu('documents', ETAT);
+ const trace = tracer(fs);
+ const nomme = await nommer(cree, { nom: 'Avant la génération', horodatage: heure(1) });
+ assert.deepEqual(trace, [['ajouterLigne', JOURNAL]]);
+ assert.equal(fs.contenu('documents', ETAT), etat);
+ assert.equal(nomme.revision, 1);
+ assert.deepEqual(
+ nomme.journal.jalons.map(({ revision, nom }) => [revision, nom]),
+ [
+ [1, 'Création'],
+ [1, 'Avant la génération'],
+ ],
+ );
+ assert.deepEqual((await rouvrir(disque, BASE)).journal, nomme.journal);
+ });
+
+ test("un geste refusé n'écrit rien : charge hors de sa forme, état qui ne se relirait pas, version absente, libellé ou nom vides", async () => {
+ const { disque, fs } = nouveauDisque();
+ const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ const avant = await fichiersDe(disque);
+ const ecritures = fs.compteurs.ecritures;
+ await assert.rejects(enregistrer(ouvert, { ...ouvert.charge, inconnue: 1 }, geste(2)), TypeError);
+ const orpheline = modifier(ouvert.charge, 2);
+ orpheline.reservations = [{ participant: 9, table: 1, siege: null, portee: 'tous', tour: null }];
+ await assert.rejects(enregistrer(ouvert, orpheline, geste(2)), /ne se relirait pas/);
+ const sansVersion = { libelle: 'Geste 2', horodatage: heure(2) };
+ await assert.rejects(enregistrer(ouvert, modifier(ouvert.charge, 2), sansVersion), TypeError);
+ await assert.rejects(enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2, { libelle: '' })), TypeError);
+ await assert.rejects(nommer(ouvert, { nom: '', horodatage: heure(2) }), TypeError);
+ assert.equal(fs.compteurs.ecritures, ecritures);
+ assert.deepEqual(await fichiersDe(disque), avant);
+ });
+});
+
+describe("dépôt : une panne à chaque frontière d'un geste (§ 8.8, § 14.10)", () => {
+ test("coupure après la ligne de journal : la réouverture rattrape le geste en mémoire sans écrire, et le geste suivant écrit d'abord l'état rattrapé", async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ const etatCree = fs.contenu('documents', ETAT);
+ fs.pannes.couperApres(1);
+ const apres = modifier(cree.charge, 2);
+ await assert.rejects(enregistrer(cree, apres, geste(2)), PanneSimulee);
+ assert.equal(fs.contenu('documents', ETAT), etatCree);
+ assert.equal(fs.contenu('documents', PRECEDENT), null);
+
+ const ouvert = await rouvrir(disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]);
+ assert.equal(ouvert.revision, 2);
+ assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(apres));
+ assert.equal(ouvert.texteEtat, etatCree);
+
+ const trace = tracer(ouvert.fs);
+ const suite = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3));
+ // L'état rattrapé s'écrit avant la ligne du geste : une coupure entre elle
+ // et l'état ne laisse le journal qu'une entrée en avance (§ 8.2).
+ assert.deepEqual(trace, [
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ['ajouterLigne', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ]);
+ assert.equal(suite.revision, 3);
+ assert.equal(ouvert.fs.contenu('documents', PRECEDENT), etatTexte(apres, 2));
+ exigerEquivalence(ouvert.fs, BASE);
+ assert.deepEqual((await rouvrir(disque, BASE)).avertissements, []);
+ });
+
+ test('coupure après le .precedent : la réouverture rattrape le geste de même', async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ const etatCree = fs.contenu('documents', ETAT);
+ fs.pannes.couperApres(2);
+ const apres = modifier(cree.charge, 2);
+ await assert.rejects(enregistrer(cree, apres, geste(2)), PanneSimulee);
+ assert.equal(fs.contenu('documents', PRECEDENT), etatCree);
+ assert.equal(fs.contenu('documents', ETAT), etatCree);
+
+ const ouvert = await rouvrir(disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]);
+ assert.equal(ouvert.revision, 2);
+ assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(apres));
+ });
+
+ test("renommage de l'état refusé : ETAT_NON_ECRIT nomme l'état et son dossier, l'état reste intact, le .precedent se lit, et l'Ouvert avancé continue", async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ const etatCree = fs.contenu('documents', ETAT);
+ const retirer = refuserRenommageApresPrecedent(fs);
+ const apres = modifier(cree.charge, 2);
+ const erreur = await echec(enregistrer(cree, apres, geste(2)), 'ETAT_NON_ECRIT');
+ const { ouvert: avance, ...details } = erreur.details;
+ assert.deepEqual(details, { chemin: ETAT, dossier: DOCUMENTS, cause: 'EBUSY' });
+ assert.ok(!erreur.message.includes('Ombrelle'), "le message de l'erreur ne porte pas l'Ouvert");
+ assert.equal(fs.contenu('documents', ETAT), etatCree);
+ assert.equal(fs.contenu('documents', `${ETAT}.ecriture`), null);
+ assert.equal(analyser(fs.contenu('documents', PRECEDENT)).entete.revision, 1);
+ assert.equal(avance.revision, 2);
+ assert.equal(avance.texteEtat, etatCree);
+ assert.equal(texteDeCharge(avance.charge), texteDeCharge(apres));
+ assert.equal(avance.journal.entrees.length, 2);
+
+ retirer();
+ const suite = await enregistrer(avance, modifier(avance.charge, 3), geste(3));
+ assert.equal(suite.revision, 3);
+ // Le geste qui suit écrit d'abord l'état de l'Ouvert avancé, que le
+ // renommage refusé n'a pas posé : il est le .precedent.
+ assert.equal(fs.contenu('documents', PRECEDENT), etatTexte(apres, 2));
+ exigerEquivalence(fs, BASE);
+ const ouvert = await rouvrir(disque, BASE);
+ assert.deepEqual([ouvert.revision, ouvert.avertissements], [3, []]);
+ });
+
+ test("ajout au journal refusé : ECRITURE nomme le journal et son dossier, et rien n'a changé ; sans base de journal (JOURNAL_ETRANGER), un dossier qui ne se liste plus lève la même ECRITURE, sur le journal de la base", async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ const avant = await fichiersDe(disque);
+ fs.pannes.ecriture('documents');
+ const erreur = await echec(enregistrer(cree, modifier(cree.charge, 2), geste(2)), 'ECRITURE');
+ assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: DOCUMENTS, cause: 'EROFS' });
+ assert.equal(fs.compteurs.ecritures, 3);
+ assert.deepEqual(await fichiersDe(disque), avant);
+
+ // Le journal de la base porte un autre événement : le geste cherche dans
+ // le dossier le journal du sien (§ 8.6). Par cas, ce que devient le
+ // dossier pour le processus, et la cause attendue : retiré avec son
+ // support, chaque lecture lève ABSENT, sa liste comprise, et la cause est
+ // ENOENT ; illisible, sa liste lève LECTURE, comme sous la coquille, dont
+ // la cause passe telle quelle.
+ const poses = [
+ [ETAT, etatTexte(chargeEssai())],
+ [JOURNAL, await journalEtranger()],
+ ];
+ const pannes = [
+ [
+ 'support retiré',
+ (lecteur) => {
+ const absent = async (racine, chemin) => {
+ throw new ErreurStockage('ABSENT', { chemin });
+ };
+ lecteur.lireTexte = absent;
+ lecteur.lister = absent;
+ },
+ 'ENOENT',
+ ],
+ [
+ 'dossier illisible',
+ (lecteur) => {
+ lecteur.lister = async (racine, chemin) => {
+ throw new ErreurStockage('LECTURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' });
+ };
+ },
+ 'EACCES',
+ ],
+ // Le support se retire entre la liste où le geste cherche le journal de
+ // l'événement et celle d'où il dérive une base libre.
+ [
+ 'support retiré entre les deux listes',
+ (lecteur) => {
+ const listerAvant = lecteur.lister;
+ let appels = 0;
+ lecteur.lister = async (racine, chemin) => {
+ appels += 1;
+ if (appels > 1) throw new ErreurStockage('ABSENT', { chemin });
+ return listerAvant(racine, chemin);
+ };
+ },
+ 'ENOENT',
+ ],
+ ];
+ for (const [nom, rompre, cause] of pannes) {
+ const etranger = disqueAvec(poses);
+ const ouvert = await ouvrir(etranger.fs, DOC, BASE);
+ assert.equal(etranger.fs.compteurs.ecritures, 0, `${nom} : ouvrir écrit`);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ETRANGER', details: { evenement: 'evt-gala' } }], nom);
+ const posesAvant = await fichiersDe(etranger.disque);
+ rompre(etranger.fs);
+ const options = geste(2, { libelleReprise: REPRISE });
+ const refus = await echec(enregistrer(ouvert, modifier(ouvert.charge, 2), options), 'ECRITURE', nom);
+ assert.deepEqual(refus.details, { chemin: JOURNAL, dossier: DOCUMENTS, cause }, nom);
+ assert.equal(etranger.fs.compteurs.ecritures, 0, nom);
+ assert.deepEqual(await fichiersDe(etranger.disque), posesAvant, nom);
+ }
+ });
+
+ test('la reprise ne se fonde pas sur la date des fichiers : un dossier recopié, aux dates égales ou inversées, se rattrape pareil', async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ fs.pannes.couperApres(1);
+ const apres = modifier(cree.charge, 2);
+ await assert.rejects(enregistrer(cree, apres, geste(2)), PanneSimulee);
+ const fichiers = await fichiersDe(disque);
+ const dates = [() => 0, (nom) => (nom === JOURNAL ? 0 : 9e11)];
+ for (const date of dates) {
+ const copie = disqueAvec(fichiers.map(([nom, texte]) => [nom, texte, date(nom)]));
+ const ouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]);
+ assert.equal(ouvert.revision, 2);
+ assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(apres));
+ }
+ });
+
+ test('une dernière ligne de journal tronquée, sans fin de ligne, est écartée et comptée ; le premier geste réécrit le journal sans elle', async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ await enregistrer(cree, modifier(cree.charge, 2), geste(2));
+ const fichiers = await fichiersDe(disque);
+ const journal = texteDe(fichiers, JOURNAL);
+ const copie = disqueAvec(remplacer(fichiers, JOURNAL, `${journal}{"type":"entree","revision":3,"lib`));
+
+ const ouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 1 } }]);
+ assert.equal(ouvert.revision, 2);
+ const trace = tracer(ouvert.fs);
+ const suite = await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3));
+ assert.deepEqual(trace, [
+ ['ecrireAtomique', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ]);
+ const reecrit = ouvert.fs.contenu('documents', JOURNAL);
+ assert.ok(reecrit.startsWith(journal), 'les lignes lues se recopient telles quelles');
+ assert.equal(exigerEquivalence(ouvert.fs, BASE).entrees.length, 3);
+ assert.deepEqual(suite.avertissements, []);
+ assert.deepEqual((await rouvrir(copie.disque, BASE)).avertissements, []);
+ });
+
+ test("le rattrapage prend la version de la construction qui a écrit le geste rattrapé : l'Ouvert la porte, l'écriture ailleurs et l'état réaligné l'inscrivent", async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ fs.pannes.couperApres(1);
+ await assert.rejects(enregistrer(cree, modifier(cree.charge, 2), geste(2, { produitVersion: V2 })), PanneSimulee);
+
+ const ouvert = await rouvrir(disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_REPRIS', details: {} }]);
+ assert.equal(ouvert.produitVersion, V2);
+ const ailleurs = await enregistrerAilleurs(ouvert, DOC, { existantes: [] });
+ const etatAilleurs = analyser(ouvert.fs.contenu('documents', `${ailleurs.base}${SUFFIXES.etat}`));
+ assert.equal(etatAilleurs.entete.produitVersion, V2);
+ // Le geste suivant écrit d'abord l'état rattrapé, à la version de son
+ // entrée : il est ensuite le .precedent.
+ await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3));
+ assert.equal(analyser(ouvert.fs.contenu('documents', PRECEDENT)).entete.produitVersion, V2);
+ exigerEquivalence(ouvert.fs, BASE);
+ });
+
+ test("un refus d'écriture pendant l'alignement lève ECRITURE, le geste hors du journal ; le même Ouvert le réessaie dans le même journal, sans doubler la reprise ni dériver un autre journal", async () => {
+ const { fichiers } = await troisRevisions();
+ const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`;
+ // Par cas : les fichiers posés, l'ouverture, la base du journal de
+ // l'événement et ses entrées après le refus. Quand le journal de la base
+ // porte un autre événement, la reprise crée celui de l'événement sous une
+ // base que son nom dérive : le nouvel essai doit l'y retrouver.
+ const cas = [
+ [
+ 'journal incomplet (JOURNAL_INCOMPLET)',
+ remplacer(fichiers, JOURNAL, tete),
+ (fs) => ouvrir(fs, DOC, BASE),
+ BASE,
+ [
+ [1, CREATION.libelle],
+ [2, REPRISE],
+ ],
+ ],
+ [
+ 'secours .precedent sur un état lisible, le journal de la base à un autre événement (JOURNAL_ETRANGER)',
+ remplacer(fichiers, JOURNAL, await journalEtranger()),
+ (fs) => ouvrirSecours(fs, DOC, BASE, 'precedent'),
+ `${BASE} (2)`,
+ [[2, REPRISE]],
+ ],
+ ];
+ for (const [nom, poses, ouvrirAinsi, base, apresRefus] of cas) {
+ const { disque, fs } = disqueAvec(poses);
+ const ouvert = await ouvrirAinsi(fs);
+ assert.equal(fs.compteurs.ecritures, 0, `${nom} : l'ouverture écrit`);
+ const journal = `${base}${SUFFIXES.journal}`;
+ const retirer = refuserRenommageApresPrecedent(fs);
+ const apres = modifier(ouvert.charge, 4);
+ const options = geste(4, { libelleReprise: REPRISE });
+ const erreur = await echec(enregistrer(ouvert, apres, options), 'ECRITURE');
+ assert.deepEqual(erreur.details, { chemin: ETAT, dossier: DOCUMENTS, cause: 'EBUSY' }, nom);
+ assert.deepEqual(revisionsEtLibelles(lireJournal(fs.contenu('documents', journal))), apresRefus, nom);
+ assert.equal(fs.contenu('documents', ETAT), texteDe(fichiers, ETAT), nom);
+ const nomsApresRefus = (await fichiersDe(disque)).map(([fichier]) => fichier);
+
+ retirer();
+ const trace = tracer(fs);
+ const suite = await enregistrer(ouvert, apres, options);
+ assert.deepEqual(
+ trace,
+ [
+ ['ecrireAtomique', journal],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ['ajouterLigne', journal],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ],
+ nom,
+ );
+ assert.deepEqual(
+ (await fichiersDe(disque)).map(([fichier]) => fichier),
+ nomsApresRefus,
+ nom,
+ );
+ assert.deepEqual([suite.revision, suite.baseJournal], [3, base], nom);
+ assert.deepEqual(
+ revisionsEtLibelles(exigerEquivalence(fs, BASE, { journal: base })),
+ [...apresRefus, [3, 'Geste 4']],
+ nom,
+ );
+ const rouvert = await rouvrir(disque, BASE);
+ assert.deepEqual([rouvert.revision, rouvert.baseJournal, rouvert.avertissements], [3, base, []], nom);
+ }
+ });
+
+ // Les ouvertures après lesquelles l'état sur le disque ne porte pas la
+ // révision de la dernière entrée du journal. Chacune pose ses fichiers, puis
+ // rend la fonction qui rouvre sur un disque neuf l'Ouvert d'où part le
+ // premier geste.
+ const departsMalAlignes = [
+ [
+ 'journal incomplet de deux entrées (JOURNAL_INCOMPLET)',
+ async () => {
+ const { fichiers } = await troisRevisions();
+ const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`;
+ return depuisFichiers(remplacer(fichiers, JOURNAL, tete), (fs) => ouvrir(fs, DOC, BASE), ['JOURNAL_INCOMPLET']);
+ },
+ ],
+ [
+ 'journal en avance de deux entrées (JOURNAL_EN_AVANCE_DE)',
+ async () => {
+ const { disque, fs } = nouveauDisque();
+ let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2));
+ const etat2 = fs.contenu('documents', ETAT);
+ for (const n of [3, 4]) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n));
+ const fichiers = remplacer(await fichiersDe(disque), ETAT, etat2);
+ return depuisFichiers(fichiers, (lecteur) => ouvrir(lecteur, DOC, BASE), ['JOURNAL_EN_AVANCE_DE']);
+ },
+ ],
+ [
+ "journal en avance d'une entrée (JOURNAL_REPRIS)",
+ async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ fs.pannes.couperApres(1);
+ await assert.rejects(enregistrer(cree, modifier(cree.charge, 2), geste(2)), PanneSimulee);
+ return depuisFichiers(await fichiersDe(disque), (lecteur) => ouvrir(lecteur, DOC, BASE), ['JOURNAL_REPRIS']);
+ },
+ ],
+ [
+ 'secours .precedent sur un état lisible',
+ async () => {
+ const { fichiers } = await troisRevisions();
+ return depuisFichiers(fichiers, (fs) => ouvrirSecours(fs, DOC, BASE, 'precedent'), ['JOURNAL_EN_AVANCE_DE']);
+ },
+ ],
+ [
+ 'secours .precedent sur un état lisible, sans journal',
+ async () => {
+ const { fichiers } = await troisRevisions();
+ const sansJournal = remplacer(fichiers, JOURNAL, null);
+ return depuisFichiers(sansJournal, (fs) => ouvrirSecours(fs, DOC, BASE, 'precedent'), []);
+ },
+ ],
+ [
+ 'secours .precedent sur un état lisible, le journal de la base à un autre événement (JOURNAL_ETRANGER)',
+ async () => {
+ const { fichiers } = await troisRevisions();
+ const etranger = remplacer(fichiers, JOURNAL, await journalEtranger());
+ return depuisFichiers(etranger, (fs) => ouvrirSecours(fs, DOC, BASE, 'precedent'), ['JOURNAL_ETRANGER']);
+ },
+ ],
+ [
+ 'secours journal sur un état lisible, le journal incomplet',
+ async () => {
+ const { fichiers } = await troisRevisions();
+ const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`;
+ const incomplet = remplacer(fichiers, JOURNAL, tete);
+ return depuisFichiers(incomplet, (fs) => ouvrirSecours(fs, DOC, BASE, 'journal'), []);
+ },
+ ],
+ [
+ "Ouvert avancé d'un ETAT_NON_ECRIT",
+ async () => async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ const retirer = refuserRenommageApresPrecedent(fs);
+ const erreur = await echec(enregistrer(cree, modifier(cree.charge, 2), geste(2)), 'ETAT_NON_ECRIT');
+ retirer();
+ return { disque, ouvert: erreur.details.ouvert };
+ },
+ ],
+ [
+ "Ouvert avancé d'un ETAT_NON_ECRIT tombé après l'alignement d'un journal incomplet",
+ async () => {
+ const { fichiers } = await troisRevisions();
+ const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`;
+ const incomplet = remplacer(fichiers, JOURNAL, tete);
+ return async () => {
+ const { disque, fs } = disqueAvec(incomplet);
+ const ouvert = await ouvrir(fs, DOC, BASE);
+ assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit');
+ // Le .precedent de l'alignement passe ; celui du geste arme le refus.
+ const retirer = refuserRenommageApresPrecedent(fs, 2);
+ const options = geste(4, { libelleReprise: REPRISE });
+ const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 4), options), 'ETAT_NON_ECRIT');
+ retirer();
+ return { disque, ouvert: erreur.details.ouvert };
+ };
+ },
+ ],
+ ];
+ for (const [nom, poser] of departsMalAlignes) {
+ test(`coupure à chaque écriture du premier geste après une ouverture mal alignée, ${nom} : le geste inscrit au journal se rouvre toujours`, async () => {
+ assert.ok((await exigerChaqueCoupure(nom, await poser())) > 0, nom);
+ });
+ }
+});
+
+describe('dépôt : un état illisible et ses secours (§ 8.8)', () => {
+ test("un état vide, tronqué ou aux comptes faux lève ETAT_ILLISIBLE, qui nomme la base, la raison et les secours lisibles, sans rien écrire", async () => {
+ const { fichiers } = await troisRevisions();
+ const etat = texteDe(fichiers, ETAT);
+ const cas = [
+ ['', 'VIDE', null],
+ [etat.slice(0, Math.floor(etat.length / 2)), 'JSON', null],
+ [
+ etat.replace(/"comptes":\{"participants":(\d+)/, (_, n) => `"comptes":{"participants":${Number(n) + 1}`),
+ 'COMPTES',
+ 'entete.comptes.participants',
+ ],
+ ];
+ for (const [texte, raison, chemin] of cas) {
+ const { fs } = disqueAvec(remplacer(fichiers, ETAT, texte));
+ const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE');
+ assert.deepEqual(erreur.details, { base: BASE, raison, chemin, secours: { precedent: 2, journal: 3 } });
+ assert.equal(fs.compteurs.ecritures, 0);
+ }
+ });
+
+ test("ouvrirSecours ouvre le .precedent, puis le dernier instant du journal, sans écrire ni rendre un événement vide", async () => {
+ const { fichiers, ouvert: dernier } = await troisRevisions();
+ const precedent = analyser(texteDe(fichiers, PRECEDENT));
+ const { fs } = disqueAvec(remplacer(fichiers, ETAT, ''));
+
+ const parPrecedent = await ouvrirSecours(fs, DOC, BASE, 'precedent');
+ assert.equal(parPrecedent.revision, 2);
+ assert.equal(texteDeCharge(parPrecedent.charge), texteDeCharge(precedent.charge));
+ assert.equal(parPrecedent.texteEtat, null);
+ assert.deepEqual(parPrecedent.avertissements, [{ code: 'JOURNAL_EN_AVANCE_DE', details: { n: 1 } }]);
+
+ const parJournal = await ouvrirSecours(fs, DOC, BASE, 'journal');
+ assert.equal(parJournal.revision, 3);
+ assert.equal(texteDeCharge(parJournal.charge), texteDeCharge(dernier.charge));
+ assert.equal(parJournal.texteEtat, null);
+ assert.equal(parJournal.produitVersion, V1);
+ assert.deepEqual(parJournal.avertissements, []);
+
+ assert.deepEqual(
+ [parPrecedent, parJournal].map(({ charge }) => charge.participants.length),
+ [4, 5],
+ );
+ assert.equal(fs.compteurs.ecritures, 0);
+ });
+
+ test("le premier geste après un secours réécrit l'état et ne met pas l'état illisible dans le .precedent", async () => {
+ const { fichiers } = await troisRevisions();
+ const textePrecedent = texteDe(fichiers, PRECEDENT);
+
+ // Depuis le journal : le geste suit son dernier instant.
+ const parJournal = disqueAvec(remplacer(fichiers, ETAT, '{"entete":'));
+ const depuisJournal = await ouvrirSecours(parJournal.fs, DOC, BASE, 'journal');
+ assert.equal(parJournal.fs.compteurs.ecritures, 0, 'ouvrirSecours écrit');
+ const traceJournal = tracer(parJournal.fs);
+ const suiteJournal = await enregistrer(depuisJournal, modifier(depuisJournal.charge, 4), geste(4));
+ assert.deepEqual(traceJournal, [
+ ['ajouterLigne', JOURNAL],
+ ['ecrireAtomique', ETAT],
+ ]);
+ assert.equal(parJournal.fs.contenu('documents', PRECEDENT), textePrecedent);
+ assert.equal(suiteJournal.revision, 4);
+ exigerEquivalence(parJournal.fs, BASE);
+ assert.deepEqual((await rouvrir(parJournal.disque, BASE)).avertissements, []);
+
+ // Depuis le .precedent : une entrée de reprise restitue sa charge, puis
+ // vient le geste. Sans libellé de reprise, le geste est refusé.
+ const parPrecedent = disqueAvec(remplacer(fichiers, ETAT, '{"entete":'));
+ const depuisPrecedent = await ouvrirSecours(parPrecedent.fs, DOC, BASE, 'precedent');
+ assert.equal(parPrecedent.fs.compteurs.ecritures, 0, 'ouvrirSecours écrit');
+ await assert.rejects(enregistrer(depuisPrecedent, modifier(depuisPrecedent.charge, 4), geste(4)), TypeError);
+ assert.equal(parPrecedent.fs.compteurs.ecritures, 0);
+ const tracePrecedent = tracer(parPrecedent.fs);
+ const reprise = geste(4, { libelleReprise: REPRISE });
+ const suitePrecedent = await enregistrer(depuisPrecedent, modifier(depuisPrecedent.charge, 4), reprise);
+ assert.deepEqual(tracePrecedent, [
+ ['ecrireAtomique', JOURNAL],
+ ['ecrireAtomique', ETAT],
+ ]);
+ assert.equal(parPrecedent.fs.contenu('documents', PRECEDENT), textePrecedent);
+ const journal = exigerEquivalence(parPrecedent.fs, BASE);
+ assert.deepEqual(revisionsEtLibelles(journal), [
+ [1, CREATION.libelle],
+ [2, 'Geste 2'],
+ [3, 'Geste 3'],
+ [4, REPRISE],
+ [5, 'Geste 4'],
+ ]);
+ assert.equal(texteDeCharge(reconstruire(journal, 4)), texteDeCharge(analyser(textePrecedent).charge));
+ assert.equal(suitePrecedent.revision, 5);
+ assert.deepEqual((await rouvrir(parPrecedent.disque, BASE)).avertissements, []);
+ });
+
+ test("un journal sans état lève ETAT_ILLISIBLE, raison ABSENT, secours du journal ; ouvert par lui, le premier geste écrit l'état", async () => {
+ const { fichiers } = await troisRevisions();
+ const cas = [
+ [remplacer(fichiers, ETAT, null), { precedent: 2, journal: 3 }],
+ [remplacer(remplacer(fichiers, ETAT, null), PRECEDENT, null), { precedent: null, journal: 3 }],
+ ];
+ for (const [restants, secours] of cas) {
+ const { fs } = disqueAvec(restants);
+ const erreur = await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE');
+ assert.deepEqual(erreur.details, { base: BASE, raison: 'ABSENT', chemin: null, secours });
+ const ouvert = await ouvrirSecours(fs, DOC, BASE, 'journal');
+ assert.equal(fs.compteurs.ecritures, 0);
+ assert.equal(ouvert.revision, 3);
+ await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4));
+ exigerEquivalence(fs, BASE);
+ }
+ });
+
+ test('un .precedent seul lève ETAT_ILLISIBLE ABSENT avec son secours ; un événement sans aucun fichier lève ABSENT', async () => {
+ const { fichiers } = await troisRevisions();
+ const { fs } = disqueAvec(fichiers.filter(([nom]) => nom === PRECEDENT));
+ assert.deepEqual((await echec(ouvrir(fs, DOC, BASE), 'ETAT_ILLISIBLE')).details, {
+ base: BASE,
+ raison: 'ABSENT',
+ chemin: null,
+ secours: { precedent: 2, journal: null },
+ });
+ assert.deepEqual((await echec(ouvrir(fs, DOC, 'Inconnu'), 'ABSENT')).details, { chemin: 'Inconnu.gtt.json' });
+ assert.equal(fs.compteurs.ecritures, 0);
+ });
+
+ test("ouvrirSecours refuse une source sans instant : .precedent absent ou illisible, journal absent ou sans entrée, source inconnue", async () => {
+ const { fs } = disqueAvec([[ETAT, etatTexte(chargeEssai())]]);
+ assert.deepEqual((await echec(ouvrirSecours(fs, DOC, BASE, 'precedent'), 'ABSENT')).details, { chemin: PRECEDENT });
+ assert.deepEqual((await echec(ouvrirSecours(fs, DOC, BASE, 'journal'), 'ABSENT')).details, { chemin: JOURNAL });
+ fs.deposer('documents', PRECEDENT, '{');
+ fs.deposer('documents', JOURNAL, `${ligneOuverture('evt-lucioles')}\n`);
+ assert.deepEqual((await echec(ouvrirSecours(fs, DOC, BASE, 'precedent'), 'ETAT_ILLISIBLE')).details, {
+ raison: 'JSON',
+ chemin: null,
+ });
+ await echec(ouvrirSecours(fs, DOC, BASE, 'journal'), 'ABSENT');
+ await assert.rejects(ouvrirSecours(fs, DOC, BASE, 'etat'), RangeError);
+ assert.equal(fs.compteurs.ecritures, 0);
+ });
+
+ test("ouvrirSecours sur un état lisible : le .precedent s'ouvre ; au premier geste, l'état lisible passe au .precedent et l'état s'aligne sur la reprise, avant le geste ; chaque geste qui suit restitue l'état écrit", async () => {
+ // Le cas d'un retenu fautif, dont le secours est le .precedent (§ 8.9).
+ // Le .precedent n'a pas la personne que l'état a inscrite (troisRevisions).
+ const { fichiers } = await troisRevisions();
+ const etat = texteDe(fichiers, ETAT);
+ const { fs } = disqueAvec(fichiers);
+ const ouvert = await ouvrirSecours(fs, DOC, BASE, 'precedent');
+ assert.equal(ouvert.texteEtat, etat);
+ assert.equal(ouvert.revision, 2);
+ const parJournal = await ouvrirSecours(fs, DOC, BASE, 'journal');
+ assert.equal(parJournal.revision, 3);
+ assert.equal(fs.compteurs.ecritures, 0);
+ const trace = tracer(fs);
+ const precedents = [];
+ const ecrire = fs.ecrireAtomique;
+ fs.ecrireAtomique = async (racine, chemin, texte) => {
+ if (chemin === PRECEDENT) precedents.push(texte);
+ return ecrire(racine, chemin, texte);
+ };
+ let suite = await enregistrer(ouvert, gesteSur(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
+ assert.deepEqual(trace, [
+ ['ecrireAtomique', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ['ajouterLigne', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ]);
+ assert.equal(precedents[0], etat);
+ // Le .precedent final est l'état aligné sur la reprise, d'avant le geste ;
+ // l'état lisible reste au journal, à sa révision.
+ const precedent = analyser(fs.contenu('documents', PRECEDENT));
+ assert.equal(precedent.entete.revision, 4);
+ assert.equal(texteDeCharge(precedent.charge), texteDeCharge(ouvert.charge));
+ const journal = exigerEquivalence(fs, BASE);
+ assert.equal(texteDeCharge(reconstruire(journal, 3)), texteDeCharge(analyser(etat).charge));
+ // Les gestes suivants, une inscription puis une note, s'ajoutent.
+ for (const n of [5, 6]) {
+ suite = await enregistrer(suite, gesteSur(suite.charge, n), geste(n));
+ exigerEquivalence(fs, BASE);
+ }
+ assert.equal(suite.revision, 7);
+ });
+
+ test("un état illisible apparie ses secours par l'identifiant de son .precedent, jamais par le nom : le journal de l'événement sous une autre base est proposé et s'ouvre, celui d'un autre événement à la base jamais ; sans .precedent, le journal de la base que porte un état lisible n'est pas le sien", async () => {
+ const { fichiers } = await troisRevisions();
+ const tronque = (texte) => texte.slice(0, Math.floor(texte.length / 2));
+ const libre = `${BASE} (2)`;
+ const carnet = `Carnet${SUFFIXES.journal}`;
+ // Le journal de la base porte un autre événement : le geste crée celui de
+ // l'événement sous une base dérivée, puis l'état devient illisible.
+ const etranger = disqueAvec(remplacer(fichiers, JOURNAL, await journalEtranger()));
+ const avecEtranger = await ouvrir(etranger.fs, DOC, BASE);
+ assert.equal(etranger.fs.compteurs.ecritures, 0, 'ouvrir écrit');
+ await enregistrer(avecEtranger, gesteSur(avecEtranger.charge, 4), geste(4, { libelleReprise: REPRISE }));
+ const apresGeste = await fichiersDe(etranger.disque);
+ const illisibleApresGeste = remplacer(apresGeste, ETAT, tronque(texteDe(apresGeste, ETAT)));
+ // Un événement dont le journal a été renommé à la main sous Glycine, à
+ // côté d'un état Glycine illisible et sans .precedent.
+ const hortensia = nouveauDisque();
+ const charge = chargeEssai({ id: 'evt-h', nom: 'Hortensia' });
+ await enregistrer(await creer(hortensia.fs, DOC, charge, CREATION), gesteSur(charge, 2), geste(2));
+ const glycine = [
+ ['Hortensia.gtt.json', hortensia.fs.contenu('documents', 'Hortensia.gtt.json')],
+ ['Glycine.gtt.json', '{'],
+ ['Glycine.gtt-journal.jsonl', hortensia.fs.contenu('documents', 'Hortensia.gtt-journal.jsonl')],
+ ];
+ // Par cas : les fichiers posés, la base ouverte, les secours attendus, et
+ // la base du journal que le secours ouvre, que lister nomme aussi ; null
+ // quand aucun ne s'apparie, et ouvrirSecours('journal') lève ABSENT.
+ const cas = [
+ [
+ "après JOURNAL_ETRANGER, le journal de l'événement sous une base dérivée",
+ illisibleApresGeste,
+ BASE,
+ { precedent: 3, journal: 4 },
+ libre,
+ ],
+ [
+ 'après JOURNAL_ETRANGER, sans le journal dérivé : celui de la base, étranger, ne se propose pas',
+ remplacer(illisibleApresGeste, `${libre}${SUFFIXES.journal}`, null),
+ BASE,
+ { precedent: 3, journal: null },
+ null,
+ ],
+ [
+ 'le journal renommé à la main',
+ [...remplacer(remplacer(fichiers, JOURNAL, null), ETAT, '{'), [carnet, texteDe(fichiers, JOURNAL)]],
+ BASE,
+ { precedent: 2, journal: 3 },
+ 'Carnet',
+ ],
+ [
+ "sans .precedent, le journal de la base porte l'événement d'un état lisible",
+ glycine,
+ 'Glycine',
+ { precedent: null, journal: null },
+ null,
+ ],
+ ];
+ for (const [nom, poses, base, secours, baseJournal] of cas) {
+ const { disque, fs } = disqueAvec(poses);
+ const erreur = await echec(ouvrir(fs, DOC, base), 'ETAT_ILLISIBLE');
+ assert.deepEqual(erreur.details, { base, raison: 'JSON', chemin: null, secours }, nom);
+ const resumes = (await lister(fs, DOC)).filter((autre) => autre.base === base || autre.base === baseJournal);
+ assert.deepEqual(
+ resumes.map(({ base: autre, journal, illisible }) => [autre, journal, illisible]),
+ [[base, baseJournal, 'JSON']],
+ nom,
+ );
+ if (baseJournal === null) {
+ const absent = await echec(ouvrirSecours(fs, DOC, base, 'journal'), 'ABSENT');
+ assert.deepEqual(absent.details, { chemin: `${base}${SUFFIXES.journal}` }, nom);
+ assert.equal(fs.compteurs.ecritures, 0, nom);
+ continue;
+ }
+ const parJournal = await ouvrirSecours(fs, DOC, base, 'journal');
+ assert.equal(fs.compteurs.ecritures, 0, nom);
+ assert.deepEqual(
+ [parJournal.revision, parJournal.baseJournal, parJournal.charge.evenement.id, parJournal.avertissements],
+ [secours.journal, baseJournal, 'evt-lucioles', []],
+ nom,
+ );
+ // Le geste suivant s'inscrit au journal de l'événement, et nulle part
+ // ailleurs.
+ const avant = new Map(await fichiersDe(disque));
+ await enregistrer(parJournal, gesteSur(parJournal.charge, 5), geste(5));
+ exigerEquivalence(fs, BASE, { journal: baseJournal });
+ const changes = (await fichiersDe(disque)).filter(([fichier, texte]) => avant.get(fichier) !== texte);
+ assert.deepEqual(
+ changes.map(([fichier]) => fichier).sort(),
+ [ETAT, `${baseJournal}${SUFFIXES.journal}`].sort(),
+ nom,
+ );
+ }
+ });
+});
+
+describe("dépôt : l'état sans journal, et le journal apparié par identifiant (§ 8.6)", () => {
+ test("un état sans journal s'ouvre ; le premier geste crée le journal par une entrée de reprise, instantané de l'état trouvé", async () => {
+ const charge = chargeEssai();
+ const etat = etatTexte(charge, 7, V2);
+ const { disque, fs } = disqueAvec([[ETAT, etat]]);
+ const ouvert = await ouvrir(fs, DOC, BASE);
+ assert.equal(fs.compteurs.ecritures, 0);
+ assert.equal(ouvert.journal, null);
+ assert.deepEqual(ouvert.avertissements, []);
+ assert.equal(ouvert.revision, 7);
+
+ await assert.rejects(enregistrer(ouvert, modifier(ouvert.charge, 8), geste(8)), TypeError);
+ assert.equal(fs.compteurs.ecritures, 0);
+ const trace = tracer(fs);
+ const suite = await enregistrer(ouvert, modifier(ouvert.charge, 8), geste(8, { libelleReprise: REPRISE }));
+ assert.deepEqual(trace, [
+ ['ecrireAtomique', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ]);
+ const journal = exigerEquivalence(fs, BASE);
+ assert.equal(journal.evenement, 'evt-lucioles');
+ assert.deepEqual(
+ journal.entrees.map(({ revision, libelle, produitVersion, instantane }) => [
+ revision,
+ libelle,
+ produitVersion,
+ instantane !== null,
+ ]),
+ [
+ [7, REPRISE, V2, true],
+ [8, 'Geste 8', V1, false],
+ ],
+ );
+ assert.equal(texteDeCharge(reconstruire(journal, 7)), texteDeCharge(charge));
+ assert.equal(fs.contenu('documents', PRECEDENT), etat);
+ assert.equal(suite.revision, 8);
+ assert.deepEqual((await rouvrir(disque, BASE)).avertissements, []);
+ });
+
+ test("nommer un état sans journal crée le journal par l'entrée de reprise, puis le jalon, sans écrire l'état", async () => {
+ const etat = etatTexte(chargeEssai(), 7);
+ const { fs } = disqueAvec([[ETAT, etat]]);
+ const ouvert = await ouvrir(fs, DOC, BASE);
+ assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit');
+ const nomme = await nommer(ouvert, { nom: 'Avant la génération', horodatage: heure(8), libelleReprise: REPRISE });
+ assert.equal(fs.compteurs.ecritures, 1);
+ assert.equal(fs.contenu('documents', ETAT), etat);
+ const journal = lireJournal(fs.contenu('documents', JOURNAL));
+ assert.deepEqual(revisionsEtLibelles(journal), [[7, REPRISE]]);
+ assert.deepEqual(
+ journal.jalons.map(({ revision, nom }) => [revision, nom]),
+ [[7, 'Avant la génération']],
+ );
+ assert.equal(nomme.revision, 7);
+ assert.deepEqual(nomme.journal, journal);
+ });
+
+ test("un journal renommé à la main reste apparié par l'identifiant : ouvrir le lit, le geste l'allonge, lister le nomme", async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, chargeEssai(), CREATION);
+ await enregistrer(cree, modifier(cree.charge, 2), geste(2));
+ const fichiers = await fichiersDe(disque);
+ const carnet = `Carnet${SUFFIXES.journal}`;
+ const copie = disqueAvec([...remplacer(fichiers, JOURNAL, null), [carnet, texteDe(fichiers, JOURNAL)]]);
+
+ const ouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual(ouvert.avertissements, []);
+ assert.equal(ouvert.baseJournal, 'Carnet');
+ assert.equal(ouvert.journal.entrees.length, 2);
+ const trace = tracer(ouvert.fs);
+ await enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3));
+ assert.deepEqual(trace, [
+ ['ajouterLigne', carnet],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ]);
+ exigerEquivalence(ouvert.fs, BASE, { journal: 'Carnet' });
+ const ecritures = ouvert.fs.compteurs.ecritures;
+ const [resume] = await lister(ouvert.fs, DOC);
+ assert.equal(ouvert.fs.compteurs.ecritures, ecritures, 'lister écrit');
+ assert.deepEqual([resume.base, resume.journal], [BASE, 'Carnet']);
+ });
+
+ test("le journal d'un autre événement sous le nom de la base est JOURNAL_ETRANGER, que l'état de cet événement soit dans le dossier ou non : ni lu, ni touché, ni ouvert en secours ; le premier geste crée le journal sous une base libre ; l'autre événement s'apparie à ce journal par son identifiant, même déplacé sous la base", async () => {
+ const { disque, fs } = nouveauDisque();
+ const gala = "Gala d'automne";
+ const journalGala = `${gala}${SUFFIXES.journal}`;
+ await creer(fs, DOC, chargeEssai(), CREATION);
+ await creer(fs, DOC, chargeEssai({ id: 'evt-gala', nom: gala }), CREATION);
+ const fichiers = await fichiersDe(disque);
+ const libre = 'Soirée des Lucioles (2)';
+ // Par cas : les fichiers posés — l'état de l'événement à la révision 1,
+ // sans .precedent, tel que le laissent creer, enregistrerAilleurs ou
+ // l'import d'un état seul, et le journal de Gala sous la base —, la base
+ // du journal auquel l'état de Gala s'apparie, null sans cet état, puis ce
+ // que lister rend après les gestes, [base, journal]. Sans l'état de Gala,
+ // aucun état lisible du dossier ne porte l'identifiant de ce journal, et
+ // la règle d'un état illisible (§ 8.6) le prendrait pour celui de la
+ // base : seul l'identifiant de l'état lisible l'écarte, au secours comme
+ // à l'ouverture. Sans son propre journal, l'état de Gala s'apparie à
+ // celui que la main a déplacé sous la base : l'état de cette base porte
+ // un autre événement, si bien que ce journal n'appartient pas à sa base.
+ const cas = [
+ [
+ "l'état de Gala dans le dossier",
+ remplacer(fichiers, JOURNAL, texteDe(fichiers, journalGala)),
+ gala,
+ [
+ [gala, gala],
+ [BASE, libre],
+ ],
+ ],
+ [
+ 'le journal de Gala seul, sans son état',
+ [
+ [ETAT, texteDe(fichiers, ETAT)],
+ [JOURNAL, await journalEtranger()],
+ ],
+ null,
+ [[BASE, libre]],
+ ],
+ [
+ "l'état de Gala sans son journal, que la main a déplacé sous la base",
+ remplacer(remplacer(fichiers, JOURNAL, texteDe(fichiers, journalGala)), journalGala, null),
+ BASE,
+ [
+ [gala, BASE],
+ [BASE, libre],
+ ],
+ ],
+ ];
+ for (const [nom, poses, baseGala, resumesApres] of cas) {
+ const copie = disqueAvec(poses);
+ if (baseGala !== null) {
+ // Gala s'ouvre sur le journal qui porte son identifiant, et son geste
+ // s'y ajoute.
+ const apparie = `${baseGala}${SUFFIXES.journal}`;
+ const ouvertGala = await rouvrir(copie.disque, gala);
+ assert.equal(ouvertGala.baseJournal, baseGala, nom);
+ assert.deepEqual(
+ [ouvertGala.journal, ouvertGala.avertissements],
+ [lireJournal(texteDe(poses, apparie)), []],
+ nom,
+ );
+ const traceGala = tracer(ouvertGala.fs);
+ await enregistrer(ouvertGala, modifier(ouvertGala.charge, 2), geste(2));
+ assert.deepEqual(
+ traceGala,
+ [
+ ['ajouterLigne', apparie],
+ ['ecrireAtomique', `${gala}${SUFFIXES.precedent}`],
+ ['ecrireAtomique', `${gala}${SUFFIXES.etat}`],
+ ],
+ nom,
+ );
+ exigerEquivalence(ouvertGala.fs, gala, { journal: baseGala });
+ }
+ const ouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ETRANGER', details: { evenement: 'evt-gala' } }], nom);
+ assert.equal(ouvert.journal, null, nom);
+ assert.equal(ouvert.revision, 1, nom);
+ const absent = await echec(ouvrirSecours(ouvert.fs, DOC, BASE, 'journal'), 'ABSENT', nom);
+ assert.deepEqual(absent.details, { chemin: JOURNAL }, nom);
+ assert.equal(ouvert.fs.compteurs.ecritures, 0, nom);
+ const journalDeLaBase = ouvert.fs.contenu('documents', JOURNAL);
+ const trace = tracer(ouvert.fs);
+ await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2, { libelleReprise: REPRISE }));
+ assert.deepEqual(
+ trace,
+ [
+ ['ecrireAtomique', `${libre}${SUFFIXES.journal}`],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ],
+ nom,
+ );
+ assert.equal(ouvert.fs.contenu('documents', JOURNAL), journalDeLaBase, nom);
+ exigerEquivalence(ouvert.fs, BASE, { journal: libre });
+
+ const rouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual([rouvert.avertissements, rouvert.baseJournal], [[], libre], nom);
+ const ecritures = rouvert.fs.compteurs.ecritures;
+ const resumes = await lister(rouvert.fs, DOC);
+ assert.equal(rouvert.fs.compteurs.ecritures, ecritures, nom);
+ assert.deepEqual(
+ resumes.map(({ base, journal }) => [base, journal]),
+ resumesApres,
+ nom,
+ );
+ }
+ });
+
+ test("un état copié à la main dans le dossier, sans son journal, ne s'apparie pas au journal que l'état de l'original, ou à défaut son .precedent, dit sien : la copie crée le sien au premier geste, et l'original n'en voit rien", async () => {
+ const { disque, fs } = nouveauDisque();
+ let original = await creer(fs, DOC, chargeEssai({ id: 'evt-dahlia', nom: 'Dahlia' }), CREATION);
+ original = await enregistrer(original, gesteSur(original.charge, 2), geste(2));
+ const etatOriginal = `Dahlia${SUFFIXES.etat}`;
+ const journalOriginal = `Dahlia${SUFFIXES.journal}`;
+ const copie = 'Dahlia - Copie';
+ // Le copier-coller d'un explorateur : l'état seul, sous un autre nom.
+ const poses = await fichiersDe(disque);
+ const fichiers = [...poses, [`${copie}${SUFFIXES.etat}`, texteDe(poses, etatOriginal)]];
+ // Par cas : l'état posé pour l'original, et ce que lister rend, [base,
+ // journal, doublon].
+ const cas = [
+ ["l'original lisible", texteDe(fichiers, etatOriginal), [['Dahlia', 'Dahlia', true], [copie, null, true]]],
+ ["l'original illisible, que son .precedent dit sien", '{', [[copie, null, false], ['Dahlia', 'Dahlia', false]]],
+ ];
+ for (const [nom, etat, resumes] of cas) {
+ const dossier = disqueAvec(remplacer(fichiers, etatOriginal, etat));
+ assert.deepEqual(
+ (await lister(dossier.fs, DOC)).map(({ base, journal, doublon }) => [base, journal, doublon]),
+ resumes,
+ nom,
+ );
+ assert.equal(dossier.fs.compteurs.ecritures, 0, nom);
+ const ouvert = await rouvrir(dossier.disque, copie);
+ assert.deepEqual([ouvert.journal, ouvert.baseJournal, ouvert.avertissements], [null, copie, []], nom);
+
+ // La copie crée son journal par une entrée de reprise ; celui de
+ // l'original ne bouge pas.
+ await enregistrer(ouvert, gesteSur(ouvert.charge, 3), geste(3, { libelleReprise: REPRISE }));
+ assert.equal(ouvert.fs.contenu('documents', journalOriginal), texteDe(fichiers, journalOriginal), nom);
+ assert.deepEqual(
+ revisionsEtLibelles(exigerEquivalence(ouvert.fs, copie)),
+ [
+ [2, REPRISE],
+ [3, 'Geste 3'],
+ ],
+ nom,
+ );
+ if (etat === '{') {
+ const lecteur = creerFichiersSimules({ disque: dossier.disque });
+ const erreur = await echec(ouvrir(lecteur, DOC, 'Dahlia'), 'ETAT_ILLISIBLE');
+ assert.deepEqual(erreur.details.secours, { precedent: 1, journal: 2 }, nom);
+ assert.equal(lecteur.compteurs.ecritures, 0, nom);
+ continue;
+ }
+ const rouvert = await rouvrir(dossier.disque, 'Dahlia');
+ assert.deepEqual([rouvert.revision, rouvert.avertissements], [2, []], nom);
+ assert.equal(texteDeCharge(rouvert.charge), texteDeCharge(original.charge), nom);
+ }
+ });
+
+ test("un journal en avance de plusieurs entrées laisse l'état tel quel, JOURNAL_EN_AVANCE_DE ; la première écriture, jalon ou geste, inscrit l'entrée de reprise, et chaque geste restitue l'état écrit", async () => {
+ // Les gestes 2 à 4 alternent note et inscription (gesteSur) : l'état remis
+ // à la révision 2 n'a pas la personne que le journal inscrit ensuite.
+ const { disque, fs } = nouveauDisque();
+ let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, 2), geste(2));
+ const etat2 = fs.contenu('documents', ETAT);
+ for (const n of [3, 4]) ouvert = await enregistrer(ouvert, gesteSur(ouvert.charge, n), geste(n));
+ const fichiers = remplacer(await fichiersDe(disque), ETAT, etat2);
+ const copie = disqueAvec(fichiers);
+
+ const rouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual(rouvert.avertissements, [{ code: 'JOURNAL_EN_AVANCE_DE', details: { n: 2 } }]);
+ assert.equal(rouvert.revision, 2);
+ assert.equal(rouvert.texteEtat, etat2);
+
+ // Nommer inscrit la reprise, puis le jalon sur elle ; l'état reste.
+ const nomme = await nommer(rouvert, { nom: 'Retour à la sauvegarde', horodatage: heure(5), libelleReprise: REPRISE });
+ assert.equal(rouvert.fs.compteurs.ecritures, 1);
+ assert.equal(nomme.revision, 5);
+ assert.deepEqual(nomme.avertissements, []);
+ assert.equal(rouvert.fs.contenu('documents', ETAT), etat2);
+
+ const trace = tracer(rouvert.fs);
+ let suite = await enregistrer(nomme, gesteSur(nomme.charge, 6), geste(6));
+ assert.equal(suite.revision, 6);
+ // L'état s'aligne d'abord sur la reprise que nommer a inscrite : il est
+ // ensuite le .precedent.
+ assert.deepEqual(trace, [
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ['ajouterLigne', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ]);
+ assert.equal(rouvert.fs.contenu('documents', PRECEDENT), etatTexte(analyser(etat2).charge, 5));
+ const journal = exigerEquivalence(rouvert.fs, BASE);
+ assert.deepEqual(
+ revisionsEtLibelles(journal).slice(3),
+ [
+ [4, 'Geste 4'],
+ [5, REPRISE],
+ [6, 'Geste 6'],
+ ],
+ );
+ assert.equal(texteDeCharge(reconstruire(journal, 5)), texteDeCharge(analyser(etat2).charge));
+ assert.deepEqual(journal.jalons.at(-1).revision, 5);
+ // Les gestes suivants, une inscription puis une note, s'ajoutent.
+ for (const n of [7, 8]) {
+ suite = await enregistrer(suite, gesteSur(suite.charge, n), geste(n));
+ exigerEquivalence(rouvert.fs, BASE);
+ }
+
+ // Sans jalon, le premier geste inscrit lui-même la reprise, puis s'inscrit.
+ const directe = await rouvrir(disqueAvec(fichiers).disque, BASE);
+ await enregistrer(directe, gesteSur(directe.charge, 6), geste(6, { libelleReprise: REPRISE }));
+ assert.deepEqual(revisionsEtLibelles(exigerEquivalence(directe.fs, BASE)).slice(3), [
+ [4, 'Geste 4'],
+ [5, REPRISE],
+ [6, 'Geste 6'],
+ ]);
+ });
+
+ test("un état en avance du journal s'ouvre tel quel, JOURNAL_INCOMPLET ; le premier geste inscrit l'entrée de reprise à la suite du journal, aligne l'état sur elle, puis s'inscrit ; chaque geste restitue l'état écrit", async () => {
+ const { fichiers, ouvert: dernier } = await troisRevisions();
+ // Le journal ne garde que son ouverture, l'entrée 1 et son jalon : il n'a
+ // pas la personne que l'état a inscrite (troisRevisions).
+ const tete = `${texteDe(fichiers, JOURNAL).split('\n').slice(0, 3).join('\n')}\n`;
+ const copie = disqueAvec(remplacer(fichiers, JOURNAL, tete));
+
+ const ouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_INCOMPLET', details: { etat: 3, journal: 1 } }]);
+ assert.equal(ouvert.revision, 3);
+ assert.equal(texteDeCharge(ouvert.charge), texteDeCharge(dernier.charge));
+
+ const trace = tracer(ouvert.fs);
+ let suite = await enregistrer(ouvert, gesteSur(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
+ // La reprise seule, l'état aligné sur elle, puis le geste : une coupure ne
+ // laisse le journal qu'une entrée en avance au plus (§ 8.2).
+ assert.deepEqual(trace, [
+ ['ecrireAtomique', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ['ajouterLigne', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ]);
+ const journal = exigerEquivalence(ouvert.fs, BASE);
+ // La reprise et le geste viennent de la construction de l'entrée 1 : aucun
+ // ne note sa version.
+ assert.deepEqual(
+ journal.entrees.map(({ revision, libelle, produitVersion }) => [revision, libelle, produitVersion]),
+ [
+ [1, CREATION.libelle, V1],
+ [2, REPRISE, null],
+ [3, 'Geste 4', null],
+ ],
+ );
+ assert.equal(texteDeCharge(reconstruire(journal, 2)), texteDeCharge(dernier.charge));
+ assert.equal(suite.revision, 3);
+ // Les gestes suivants, une inscription puis une note, s'ajoutent.
+ for (const n of [5, 6]) {
+ suite = await enregistrer(suite, gesteSur(suite.charge, n), geste(n));
+ exigerEquivalence(ouvert.fs, BASE);
+ }
+ assert.deepEqual((await rouvrir(copie.disque, BASE)).avertissements, []);
+ });
+
+ test("une reprise qui ouvre un intervalle s'écrit en instantané : journal des révisions 1 à 50, état à la 52", async () => {
+ const { disque, fs } = nouveauDisque();
+ let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ for (let n = 2; n <= 50; n += 1) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n));
+ const fichiers = await fichiersDe(disque);
+ const copie = disqueAvec(remplacer(fichiers, ETAT, etatTexte(modifier(ouvert.charge, 52), 52)));
+
+ const rouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual(rouvert.avertissements, [{ code: 'JOURNAL_INCOMPLET', details: { etat: 52, journal: 50 } }]);
+ await enregistrer(rouvert, modifier(rouvert.charge, 53), geste(53, { libelleReprise: REPRISE }));
+ const journal = exigerEquivalence(rouvert.fs, BASE);
+ assert.deepEqual(
+ journal.entrees.slice(-2).map(({ revision, libelle, instantane }) => [revision, libelle, instantane !== null]),
+ [
+ [51, REPRISE, true],
+ [52, 'Geste 53', false],
+ ],
+ );
+ });
+
+ test("un correctif qui ne s'applique pas écarte son entrée et ce qui la suit ; le premier geste réécrit le journal jusqu'à l'instant restituable", async () => {
+ const { fichiers } = await troisRevisions();
+ const lignes = texteDe(fichiers, JOURNAL).replace(/\n$/, '').split('\n');
+ const rang = lignes.findIndex((ligne) => JSON.parse(ligne).revision === 3);
+ const fausse = JSON.parse(lignes[rang]);
+ fausse.correctif = [{ op: 'retirer', chemin: ['participants', { id: 99 }] }];
+ lignes[rang] = JSON.stringify(fausse);
+ lignes.push(ligneJalon({ revision: 3, nom: 'Après le geste 3', horodatage: heure(3) }));
+ const copie = disqueAvec(remplacer(fichiers, JOURNAL, `${lignes.join('\n')}\n`));
+
+ const ouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [
+ { code: 'JOURNAL_INCOMPLET', details: { etat: 3, journal: 2 } },
+ { code: 'JOURNAL_ECARTEES', details: { n: 2 } },
+ ]);
+ assert.equal(ouvert.journal.entrees.length, 2);
+ assert.equal(ouvert.revision, 3);
+
+ await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
+ assert.equal(ouvert.fs.compteurs.ecritures, 3);
+ const journal = exigerEquivalence(ouvert.fs, BASE);
+ assert.deepEqual(revisionsEtLibelles(journal).slice(2), [
+ [3, REPRISE],
+ [4, 'Geste 4'],
+ ]);
+ assert.ok(!ouvert.fs.contenu('documents', JOURNAL).includes('"id":99'));
+ assert.deepEqual((await rouvrir(copie.disque, BASE)).avertissements, []);
+ });
+
+ test("un journal dont l'ouverture ne se lit pas est écarté en entier ; le premier geste le remplace par un journal neuf", async () => {
+ const { fichiers } = await troisRevisions();
+ const copie = disqueAvec(remplacer(fichiers, JOURNAL, 'pas du JSON\n{"type":"entree"}\n'));
+ const ouvert = await rouvrir(copie.disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 2 } }]);
+ assert.equal(ouvert.journal, null);
+ assert.equal(ouvert.baseJournal, BASE);
+
+ const trace = tracer(ouvert.fs);
+ await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
+ assert.deepEqual(trace.map(([, chemin]) => chemin), [JOURNAL, PRECEDENT, ETAT]);
+ const journal = exigerEquivalence(ouvert.fs, BASE);
+ assert.deepEqual(revisionsEtLibelles(journal), [
+ [3, REPRISE],
+ [4, 'Geste 4'],
+ ]);
+ assert.deepEqual((await rouvrir(copie.disque, BASE)).avertissements, []);
+ });
+});
+
+describe('dépôt : propositions fautives et périmées, retenu fautif (§ 8.9)', () => {
+ test("une proposition qui se contredit est écartée et nommée, PROPOSITIONS_FAUTIVES, et le fichier s'ouvre", async () => {
+ const { disque } = disqueAvec([[ETAT, etatTexte(avecFautive())]]);
+ const ouvert = await rouvrir(disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } }]);
+ assert.deepEqual(
+ ouvert.charge.propositions.map(({ id }) => id),
+ [1],
+ );
+ assert.deepEqual(
+ ouvert.examen.fautives.map(({ id, fautes }) => [id, fautes.map(({ code }) => code)]),
+ [[2, ['DOUBLON', 'MANQUANT']]],
+ );
+ assert.equal(ouvert.charge.participants.length, 4);
+ });
+
+ test('une proposition et un retenu périmés sont gardés et signalés, DERIVE : personne exclue, table supprimée, capacité réduite, tour retiré, personne ajoutée', async () => {
+ // Par péremption, ce qui a changé dans la charge depuis la production de
+ // sa proposition : périmées, la proposition et le retenu se gardent et se
+ // signalent, sans s'écarter (§ 8.9, point 3 ; § 9).
+ const exclure = (charge) => {
+ charge.participants[2].exclu = true;
+ };
+ // Chacune avec la raison de dérive qu'elle donne (examiner).
+ const parExclusion = 'PARTICIPANT_EXCLU';
+ const peremptions = [
+ ['personne exclue', exclure, parExclusion],
+ [
+ 'table supprimée',
+ (charge) => {
+ charge.tables = charge.tables.filter(({ id }) => id !== 2);
+ },
+ 'TABLE_SUPPRIMEE',
+ ],
+ [
+ // La proposition date d'une table 1 à trois sièges ; elle en a deux,
+ // ceux du défaut.
+ 'capacité réduite',
+ (charge) => {
+ charge.propositions[0].capacites = [3, 2];
+ },
+ 'CAPACITE_CHANGEE',
+ ],
+ [
+ 'tour retiré',
+ (charge) => {
+ charge.evenement.tours = 1;
+ },
+ 'TOURS_CHANGES',
+ ],
+ [
+ 'personne ajoutée',
+ (charge) => {
+ charge.participants.push(personne(5, 'Brunelle', null, null));
+ charge.prochainsIds.participant = 6;
+ },
+ 'PARTICIPANT_NON_PLACE',
+ ],
+ ];
+ // Chaque péremption avec une proposition et le retenu qui la reprend ;
+ // l'exclusion aussi avec la proposition seule, et avec le retenu seul.
+ const tout = { retenu: true, propositions: true };
+ const propositionSeule = { retenu: false, propositions: true };
+ const retenuSeul = { retenu: true, propositions: false };
+ const cas = [
+ ...peremptions.map(([nom, perimer, raison]) => [nom, perimer, raison, tout, { ids: [1], retenu: true }]),
+ ['personne exclue, proposition seule', exclure, parExclusion, propositionSeule, { ids: [1], retenu: false }],
+ ['personne exclue, retenu seul', exclure, parExclusion, retenuSeul, { ids: [], retenu: true }],
+ ];
+ for (const [nom, perimer, raison, { retenu, propositions }, details] of cas) {
+ const charge = chargeEssai();
+ perimer(charge);
+ if (retenu) charge.retenu = retenuDe(charge.propositions[0]);
+ if (!propositions) charge.propositions = [];
+ const { disque } = disqueAvec([[ETAT, etatTexte(charge)]]);
+ const ouvert = await rouvrir(disque, BASE);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'DERIVE', details }], nom);
+ assert.deepEqual(ouvert.charge.propositions, charge.propositions, nom);
+ assert.deepEqual(ouvert.charge.retenu, charge.retenu, nom);
+ const codes = (raisons) => raisons.map(({ code }) => code);
+ assert.deepEqual(
+ ouvert.examen.derives.map(({ raisons }) => codes(raisons)),
+ propositions ? [[raison]] : [],
+ nom,
+ );
+ assert.deepEqual(codes(ouvert.examen.retenu.raisons), retenu ? [raison] : [], nom);
+ }
+ });
+
+ test('un retenu qui se contredit donne RETENU_FAUTIF en premier avertissement, et reste', async () => {
+ const charge = avecFautive();
+ charge.retenu = retenuDe(placement(1, [[1, 3], [2]]));
+ const { disque } = disqueAvec([[ETAT, etatTexte(charge)]]);
+ const ouvert = await rouvrir(disque, BASE);
+ assert.deepEqual(
+ ouvert.avertissements.map(({ code }) => code),
+ ['RETENU_FAUTIF', 'PROPOSITIONS_FAUTIVES'],
+ );
+ assert.deepEqual(ouvert.charge.retenu, charge.retenu);
+ assert.deepEqual(
+ ouvert.examen.retenu.fautes.map(({ code }) => code),
+ ['MANQUANT'],
+ );
+ });
+
+ test("après une proposition écartée, le geste suivant s'inscrit depuis la charge du journal : sa dernière entrée restitue l'état écrit", async () => {
+ const { disque, fs } = nouveauDisque();
+ const cree = await creer(fs, DOC, avecFautive(), CREATION);
+ assert.deepEqual(cree.avertissements, [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } }]);
+ const ouvert = await rouvrir(disque, BASE);
+ const suite = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2));
+ exigerEquivalence(ouvert.fs, BASE);
+ assert.deepEqual(
+ analyser(ouvert.fs.contenu('documents', ETAT)).charge.propositions.map(({ id }) => id),
+ [1],
+ );
+ assert.deepEqual(suite.avertissements, []);
+ });
+});
+
+describe('dépôt : format plus récent, élagage', () => {
+ test("un fichier d'un format plus récent s'ouvre en lecture seule : enregistrer, nommer et enregistrerAilleurs refusent LECTURE_SEULE sans rien écrire", async () => {
+ const texte = etatTexte(chargeEssai()).replace('"format":1', '"format":2');
+ const { disque } = disqueAvec([[ETAT, texte]]);
+ const ouvert = await rouvrir(disque, BASE);
+ assert.equal(ouvert.formatPlusRecent, true);
+ assert.deepEqual(ouvert.avertissements, []);
+ const refus = { raison: 'FORMAT_PLUS_RECENT' };
+ const enregistrement = await echec(enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2)), 'LECTURE_SEULE');
+ assert.deepEqual(enregistrement.details, refus);
+ const nom = await echec(nommer(ouvert, { nom: 'Jalon', horodatage: heure(2) }), 'LECTURE_SEULE');
+ assert.deepEqual(nom.details, refus);
+ const ailleurs = await echec(enregistrerAilleurs(ouvert, DOC, { existantes: [] }), 'LECTURE_SEULE');
+ assert.deepEqual(ailleurs.details, refus);
+ assert.equal(ouvert.fs.compteurs.ecritures, 0);
+ });
+
+ test("ce qu'une construction plus récente laisse garde en lecture seule chaque ouverture, secours compris : un état ou un journal au format 2 ne se réécrit jamais ; le journal de la base se reconnaît plus récent avant toute copie du dossier, et un journal plus récent ne se dit jamais absent", async () => {
+ const { fichiers } = await troisRevisions();
+ const recent = (texte) => texte.replace('"format":1', '"format":2');
+ // L'état au format 2, d'une forme que ce code ne connaît pas : illisible,
+ // FORMAT_PLUS_RECENT. Le .precedent reste l'état au format 1 qu'il remplace.
+ const etatForme = recent(texteDe(fichiers, ETAT)).replace('"sieges":null', '"sieges":{"n":3}');
+ const journalRecent = recent(texteDe(fichiers, JOURNAL));
+ const plusRecent = { code: 'JOURNAL_FORMAT_PLUS_RECENT', details: { format: 2 } };
+ const enAvance = { code: 'JOURNAL_EN_AVANCE_DE', details: { n: 1 } };
+ const illisible = (journal, format = 2) => ({
+ code: 'ETAT_ILLISIBLE',
+ details: {
+ base: BASE,
+ raison: 'FORMAT_PLUS_RECENT',
+ chemin: 'charge.tables[0].sieges',
+ format,
+ secours: { precedent: 2, journal },
+ },
+ });
+ // Le secours d'un journal plus récent est refusé : il n'est pas absent.
+ const journalPlusRecent = {
+ code: 'ETAT_ILLISIBLE',
+ details: { raison: 'FORMAT_PLUS_RECENT', chemin: JOURNAL, format: 2 },
+ };
+ // L'état manque à côté d'un journal plus récent : son refus ne nomme
+ // aucun format, celui du journal s'y ajoute.
+ const sansEtat = (precedent) => ({
+ code: 'ETAT_ILLISIBLE',
+ details: { base: BASE, raison: 'ABSENT', chemin: null, format: 2, secours: { precedent, journal: null } },
+ });
+ const sansPrecedent = { code: 'ABSENT', details: { chemin: PRECEDENT } };
+ const copieALaMain = `${BASE} - Copie${SUFFIXES.journal}`;
+ // Par cas : l'état et le journal posés (null : celui des trois révisions),
+ // puis ce que rendent ouvrir, ouvrirSecours('precedent') et
+ // ouvrirSecours('journal') — les avertissements d'un Ouvert, ou l'erreur
+ // —, et ce que la main change ensuite aux fichiers posés.
+ const cas = [
+ ['état et journal au format 2', etatForme, journalRecent, [illisible(null), [plusRecent], journalPlusRecent]],
+ ['état au format 2, journal resté au format 1', etatForme, null, [illisible(3), [enAvance], []]],
+ // Le refus de l'état nomme son propre format, que celui du journal ne
+ // remplace pas.
+ [
+ 'état au format 3, journal au format 2',
+ etatForme.replace('"format":2', '"format":3'),
+ journalRecent,
+ [illisible(null, 3), [plusRecent], journalPlusRecent],
+ ],
+ ['état au format 2 lisible', recent(texteDe(fichiers, ETAT)), null, [[], [enAvance], []]],
+ ['journal seul au format 2', null, journalRecent, [[plusRecent], [plusRecent], journalPlusRecent]],
+ [
+ "journal seul au format 2, marque d'ordre d'octets en tête",
+ null,
+ `\u{FEFF}${journalRecent}`,
+ [[plusRecent], [plusRecent], journalPlusRecent],
+ ],
+ [
+ 'journal au format 2, sa copie à la main au format 1 sous une autre base',
+ null,
+ journalRecent,
+ [[plusRecent], [plusRecent], journalPlusRecent],
+ (poses) => [...poses, [copieALaMain, texteDe(fichiers, JOURNAL)]],
+ ],
+ [
+ 'journal au format 2 sans état',
+ null,
+ journalRecent,
+ [sansEtat(2), [plusRecent], journalPlusRecent],
+ (poses) => remplacer(poses, ETAT, null),
+ ],
+ [
+ 'journal au format 2 sans état ni .precedent',
+ null,
+ journalRecent,
+ [sansEtat(null), sansPrecedent, journalPlusRecent],
+ (poses) => remplacer(remplacer(poses, ETAT, null), PRECEDENT, null),
+ ],
+ ];
+ let enLectureSeule = 0;
+ for (const [nom, etat, journal, attendus, retoucher = (poses) => poses] of cas) {
+ const avecEtat = remplacer(fichiers, ETAT, etat ?? texteDe(fichiers, ETAT));
+ const restants = retoucher(remplacer(avecEtat, JOURNAL, journal ?? texteDe(fichiers, JOURNAL)));
+ const { disque, fs } = disqueAvec(restants);
+ const avant = await fichiersDe(disque);
+ const ouvertures = [
+ ['ouvrir', () => ouvrir(fs, DOC, BASE)],
+ ['secours precedent', () => ouvrirSecours(fs, DOC, BASE, 'precedent')],
+ ['secours journal', () => ouvrirSecours(fs, DOC, BASE, 'journal')],
+ ];
+ for (const [rang, [ouverture, ouvrirAinsi]] of ouvertures.entries()) {
+ const message = `${nom}, ${ouverture}`;
+ const attendu = attendus[rang];
+ if (!Array.isArray(attendu)) {
+ const erreur = await echec(ouvrirAinsi(), attendu.code);
+ assert.deepEqual(erreur.details, attendu.details, message);
+ continue;
+ }
+ const ouvert = await ouvrirAinsi();
+ assert.equal(ouvert.formatPlusRecent, true, message);
+ assert.deepEqual(ouvert.avertissements, attendu, message);
+ const ecritures = [
+ () => enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE })),
+ () => nommer(ouvert, { nom: 'Jalon', horodatage: heure(4), libelleReprise: REPRISE }),
+ () => enregistrerAilleurs(ouvert, DOC, { existantes: [] }),
+ ];
+ for (const ecrire of ecritures) {
+ assert.deepEqual((await echec(ecrire(), 'LECTURE_SEULE')).details, { raison: 'FORMAT_PLUS_RECENT' }, message);
+ }
+ enLectureSeule += 1;
+ }
+ // La liste nomme le journal qu'ouvrir lit : celui de la base, qu'il soit
+ // plus récent ou non, jamais sa copie au format courant.
+ const ligne = (await lister(fs, DOC)).find(({ base }) => base === BASE);
+ assert.equal(ligne.journal, BASE, `${nom}, lister`);
+ assert.equal(fs.compteurs.ecritures, 0, nom);
+ assert.deepEqual(await fichiersDe(disque), avant, nom);
+ }
+ assert.equal(enLectureSeule, 14);
+ });
+
+ test("une ouverture de journal dont le format n'est pas un entier plus grand que FORMAT ne se lit pas : écartée, puis remplacée au premier geste", async () => {
+ const { fichiers } = await troisRevisions();
+ const journal = texteDe(fichiers, JOURNAL);
+ const ouverture = journal.slice(0, journal.indexOf('\n'));
+ const fausses = [
+ ouverture.replace('"format":1', '"format":"2"'),
+ ouverture.replace('"format":1', '"format":0'),
+ ouverture.replace(/\}$/, ',"inconnue":1}'),
+ ];
+ for (const fausse of fausses) {
+ const copie = disqueAvec(remplacer(fichiers, JOURNAL, journal.replace(ouverture, fausse)));
+ const ouvert = await rouvrir(copie.disque, BASE);
+ assert.equal(ouvert.formatPlusRecent, false, fausse);
+ assert.deepEqual(ouvert.avertissements, [{ code: 'JOURNAL_ECARTEES', details: { n: 5 } }], fausse);
+ await enregistrer(ouvert, modifier(ouvert.charge, 4), geste(4, { libelleReprise: REPRISE }));
+ assert.deepEqual(
+ revisionsEtLibelles(exigerEquivalence(ouvert.fs, BASE)),
+ [
+ [3, REPRISE],
+ [4, 'Geste 4'],
+ ],
+ fausse,
+ );
+ }
+ });
+
+ test("au-delà de PLAFOND_ENTREES, le journal s'élague sur une frontière d'instantané qui garde au moins 500 entrées, au geste qui le dépasse comme à l'écriture ailleurs ; relu, il est la version élaguée", async () => {
+ const { disque, fs } = nouveauDisque();
+ const trace = tracer(fs);
+ let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ for (let n = 2; n <= PLAFOND_ENTREES; n += 1) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n));
+ const avant = fs.contenu('documents', JOURNAL);
+ assert.equal(lireJournal(avant).entrees.length, PLAFOND_ENTREES);
+ // Jusqu'au plafond compris, chaque geste s'ajoute en fin de journal.
+ const ecrituresDuJournal = trace.filter(([, chemin]) => chemin === JOURNAL).map(([primitive]) => primitive);
+ assert.deepEqual(new Set(ecrituresDuJournal.slice(1)), new Set(['ajouterLigne']));
+
+ // Le secours .precedent écrit ailleurs : son journal de PLAFOND_ENTREES
+ // entrées reçoit la reprise, et s'élague comme au geste qui le dépasse.
+ const lecteur = creerFichiersSimules({ disque });
+ const secours = await ouvrirSecours(lecteur, DOC, BASE, 'precedent');
+ assert.equal(lecteur.compteurs.ecritures, 0, 'ouvrirSecours écrit');
+ const options = { existantes: [], libelleReprise: REPRISE, horodatage: heure(601) };
+ const copie = await enregistrerAilleurs(secours, DOC, options);
+ const copieRelue = exigerEquivalence(lecteur, copie.base);
+ assert.deepEqual(
+ [copie.base, copieRelue.entrees[0].revision, copieRelue.entrees.length, copieRelue.entrees.at(-1).libelle],
+ [`${BASE} (2)`, 101, 501, REPRISE],
+ );
+ assert.deepEqual(copie.journal, copieRelue);
+
+ const depart = trace.length;
+ ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 601), geste(601));
+ assert.deepEqual(trace.slice(depart), [
+ ['ecrireAtomique', JOURNAL],
+ ['ecrireAtomique', PRECEDENT],
+ ['ecrireAtomique', ETAT],
+ ]);
+ const apres = fs.contenu('documents', JOURNAL);
+ const derniere = apres.slice(apres.lastIndexOf('\n', apres.length - 2) + 1);
+ assert.equal(apres, elaguer(avant + derniere));
+ const relu = exigerEquivalence(fs, BASE);
+ assert.equal(relu.entrees[0].revision, 101);
+ assert.equal(relu.entrees.length, 501);
+ assert.deepEqual(ouvert.journal, relu);
+
+ ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 602), geste(602));
+ assert.deepEqual(trace.at(-3), ['ajouterLigne', JOURNAL]);
+ exigerEquivalence(fs, BASE);
+ const rouvert = await rouvrir(disque, BASE);
+ assert.deepEqual([rouvert.revision, rouvert.avertissements, rouvert.journal.entrees.length], [602, [], 502]);
+ });
+});
+
+describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => {
+ test("le verrou de la séance se prend, se refuse à une autre séance avec ce que le système en dit, et se rend", async () => {
+ const { disque, fs } = nouveauDisque();
+ const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
+ assert.deepEqual(await verrouiller(ouvert, 'seance-a'), { pris: true });
+ const texte = fs.contenu('documents', VERROU);
+ const { depuis } = JSON.parse(texte);
+
+ const voisin = await rouvrir(disque, BASE);
+ assert.deepEqual(await verrouiller(voisin, 'seance-b'), { pris: false, seance: 'seance-a', depuis, vivant: true });
+ const lointain = await ouvrir(creerFichiersSimules({ disque, hote: 'autre-poste' }), DOC, BASE);
+ assert.equal(lointain.fs.compteurs.ecritures, 0, 'ouvrir écrit');
+ assert.deepEqual(await verrouiller(lointain, 'seance-c'), {
+ pris: false,
+ seance: 'seance-a',
+ depuis,
+ vivant: null,
+ });
+
+ await deverrouiller(voisin, 'seance-b');
+ assert.equal(fs.contenu('documents', VERROU), texte);
+ await deverrouiller(ouvert, 'seance-a');
+ assert.equal(fs.contenu('documents', VERROU), null);
+
+ assert.deepEqual(await verrouiller(voisin, 'seance-b'), { pris: true });
+ voisin.fs.pannes.couperApres(0);
+ const vuDApres = await verrouiller(ouvert, 'seance-a');
+ assert.deepEqual([vuDApres.pris, vuDApres.seance, vuDApres.vivant], [false, 'seance-b', false]);
+ });
+
+ test("après un refus d'écriture, enregistrerAilleurs crée la racine visée, puis y écrit le journal entier et l'état ; une base déjà prise là-bas se dérive ; le geste reprend là", async () => {
+ const disque = new Map();
+ const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' });
+ fs.deposerDossier('portable');
+ const { portable, documents } = await fs.racines();
+ let ouvert = await creer(fs, portable, chargeEssai(), CREATION);
+ ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2));
+ fs.pannes.ecriture('portable');
+ const erreur = await echec(enregistrer(ouvert, modifier(ouvert.charge, 3), geste(3)), 'ECRITURE');
+ assert.deepEqual(erreur.details, { chemin: JOURNAL, dossier: 'E:\\soirees\\data', cause: 'EROFS' });
+
+ // En mode portable, rien n'a sondé ni créé documents : l'opérateur l'a
+ // choisi, l'écriture le crée.
+ const ecritures = fs.compteurs.ecritures;
+ const trace = tracer(fs);
+ const ailleurs = await enregistrerAilleurs(ouvert, documents, { existantes: [] });
+ assert.deepEqual(trace, [
+ ['creerDossier', ''],
+ ['ecrireAtomique', JOURNAL],
+ ['ecrireAtomique', ETAT],
+ ]);
+ assert.equal(fs.compteurs.ecritures - ecritures, 3);
+ assert.deepEqual([ailleurs.racine, ailleurs.base, ailleurs.revision], [documents, BASE, 2]);
+ assert.equal(fs.contenu('documents', JOURNAL), fs.contenu('portable', JOURNAL));
+ assert.equal(fs.contenu('documents', PRECEDENT), null);
+ exigerEquivalence(fs, BASE);
+
+ // Écrit de nouveau là-bas, l'événement y trouve sa base prise, absente
+ // des bases reçues : la dérivation la voit dans le dossier.
+ trace.length = 0;
+ const libre = 'Soirée des Lucioles (2)';
+ const seconde = await enregistrerAilleurs(ouvert, documents, { existantes: [] });
+ assert.deepEqual(trace, [
+ ['creerDossier', ''],
+ ['ecrireAtomique', `${libre}${SUFFIXES.journal}`],
+ ['ecrireAtomique', `${libre}${SUFFIXES.etat}`],
+ ]);
+ assert.equal(seconde.base, libre);
+ exigerEquivalence(fs, libre);
+
+ const suite = await enregistrer(ailleurs, modifier(ailleurs.charge, 3), geste(3));
+ assert.equal(suite.revision, 3);
+ exigerEquivalence(fs, BASE);
+ const rouvert = await rouvrir(disque, BASE);
+ assert.deepEqual([rouvert.revision, rouvert.avertissements], [3, []]);
+ });
+
+ test("ETAT_NON_ECRIT : l'Ouvert avancé de ses détails s'écrit ailleurs, son geste compris, sous une base libre parmi celles reçues", async () => {
+ const disque = new Map();
+ const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' });
+ fs.deposerDossier('portable');
+ fs.deposerDossier('documents');
+ const { portable, documents } = await fs.racines();
+ const cree = await creer(fs, portable, chargeEssai(), CREATION);
+ refuserRenommageApresPrecedent(fs);
+ const apres = modifier(cree.charge, 2);
+ const erreur = await echec(enregistrer(cree, apres, geste(2)), 'ETAT_NON_ECRIT');
+
+ const ailleurs = await enregistrerAilleurs(erreur.details.ouvert, documents, { existantes: [BASE] });
+ assert.deepEqual([ailleurs.base, ailleurs.revision], ['Soirée des Lucioles (2)', 2]);
+ // creer : journal, état ; le geste : ligne, .precedent, état refusé ;
+ // ailleurs : la racine, qui existe déjà, le journal, l'état.
+ assert.equal(fs.compteurs.ecritures, 2 + 3 + 3);
+ exigerEquivalence(fs, ailleurs.base);
+ assert.equal(texteDeCharge(analyser(fs.contenu('documents', `${ailleurs.base}${SUFFIXES.etat}`)).charge), texteDeCharge(apres));
+ });
+
+ // Un événement du disque portable : créé, puis deux gestes, les notes 2 et
+ // 3 ; son état remplacé par etat quand il est donné. Le secours .precedent
+ // l'ouvre, sans rien écrire, à la note 2 et à la révision 2 ; puis portable
+ // refuse toute écriture, et le premier geste lève ECRITURE. Rend le disque,
+ // son processus, la racine documents et l'Ouvert du secours, à écrire
+ // ailleurs.
+ async function secoursRefuse(etat) {
+ const disque = new Map();
+ const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' });
+ fs.deposerDossier('portable');
+ const { portable, documents } = await fs.racines();
+ let ouvert = await creer(fs, portable, chargeEssai(), CREATION);
+ for (const n of [2, 3]) ouvert = await enregistrer(ouvert, modifier(ouvert.charge, n), geste(n));
+ if (etat !== null) fs.deposer('portable', ETAT, etat);
+ const ecritures = fs.compteurs.ecritures;
+ const secours = await ouvrirSecours(fs, portable, BASE, 'precedent');
+ assert.equal(fs.compteurs.ecritures, ecritures, 'ouvrirSecours écrit');
+ assert.deepEqual([secours.revision, secours.charge.participants[0].notes], [2, 'note 2']);
+ fs.pannes.ecriture('portable');
+ await echec(enregistrer(secours, modifier(secours.charge, 4), geste(4, { libelleReprise: REPRISE })), 'ECRITURE');
+ return { disque, fs, documents, secours };
+ }
+
+ // L'état que le secours laisse sur le disque de départ : lisible, à la
+ // révision 3, ou illisible.
+ const etatsDuSecours = [
+ ['état lisible', null],
+ ['état illisible', '{"entete":'],
+ ];
+
+ test("un secours .precedent s'écrit ailleurs en paire alignée — son journal, une entrée de reprise qui restitue sa charge, puis l'état à la révision de la reprise — : rouvert là-bas, il rend la charge du secours sans rien rattraper", async () => {
+ for (const [nom, etat] of etatsDuSecours) {
+ const { disque, fs, documents, secours } = await secoursRefuse(etat);
+ const trace = tracer(fs);
+ const options = { existantes: [], libelleReprise: REPRISE, horodatage: heure(4) };
+ const ailleurs = await enregistrerAilleurs(secours, documents, options);
+ assert.deepEqual(
+ trace,
+ [
+ ['creerDossier', ''],
+ ['ecrireAtomique', JOURNAL],
+ ['ecrireAtomique', ETAT],
+ ],
+ nom,
+ );
+ const rouvert = await rouvrir(disque, BASE);
+ assert.deepEqual(
+ [rouvert.revision, rouvert.avertissements, rouvert.charge.participants[0].notes],
+ [4, [], 'note 2'],
+ nom,
+ );
+ assert.equal(texteDeCharge(rouvert.charge), texteDeCharge(secours.charge), nom);
+ const journal = exigerEquivalence(fs, BASE);
+ assert.deepEqual(
+ revisionsEtLibelles(journal),
+ [
+ [1, CREATION.libelle],
+ [2, 'Geste 2'],
+ [3, 'Geste 3'],
+ [4, REPRISE],
+ ],
+ nom,
+ );
+ assert.deepEqual([ailleurs.revision, ailleurs.journal, ailleurs.avertissements], [4, journal, []], nom);
+ // Un geste depuis l'Ouvert rendu, qui remet la note 3 : son correctif
+ // part de la charge écrite là-bas, que porte chargeJournal. Parti de la
+ // note 3, il ne changerait rien, et sa dernière entrée ne restituerait
+ // pas l'état écrit.
+ await enregistrer(ailleurs, modifier(ailleurs.charge, 3), geste(5));
+ assert.deepEqual(
+ revisionsEtLibelles(exigerEquivalence(fs, BASE)).slice(-2),
+ [
+ [4, REPRISE],
+ [5, 'Geste 5'],
+ ],
+ nom,
+ );
+ }
+ });
+
+ test('écrit ailleurs sans libellé de reprise, un secours .precedent lève TypeError avant toute écriture : la racine ne se crée pas', async () => {
+ for (const [nom, etat] of etatsDuSecours) {
+ const { fs, documents, secours } = await secoursRefuse(etat);
+ const trace = tracer(fs);
+ const sansLibelle = { existantes: [], horodatage: heure(4) };
+ await assert.rejects(enregistrerAilleurs(secours, documents, sansLibelle), TypeError, nom);
+ assert.deepEqual(trace, [], nom);
+ }
+ });
+
+ test("un Ouvert aligné s'écrit ailleurs avec la charge que restitue la dernière entrée de son journal, proposition fautive comprise, sous la version qui l'a produite ; l'Ouvert rendu porte la charge examinée", async () => {
+ const fautives = [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [2] } }];
+ // Par cas : la version que la main pose dans l'en-tête de l'état créé, à
+ // la révision du journal, ou null. Retouché, l'en-tête nomme une autre
+ // construction que l'entrée qui a produit la charge : l'état écrit
+ // ailleurs prend celle du journal.
+ const cas = [
+ ["l'Ouvert de creer", null],
+ ['un en-tête retouché à la main', V2],
+ ];
+ for (const [nom, version] of cas) {
+ const disque = new Map();
+ const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' });
+ fs.deposerDossier('portable');
+ const { portable, documents } = await fs.racines();
+ let ouvert = await creer(fs, portable, avecFautive(), CREATION);
+ if (version !== null) {
+ fs.deposer('portable', ETAT, etatTexte(avecFautive(), 1, version));
+ const ecritures = fs.compteurs.ecritures;
+ ouvert = await ouvrir(fs, portable, BASE);
+ assert.equal(fs.compteurs.ecritures, ecritures, `${nom} : ouvrir écrit`);
+ assert.deepEqual([ouvert.revision, ouvert.produitVersion, ouvert.avertissements], [1, version, fautives], nom);
+ }
+ assert.notEqual(texteDeCharge(ouvert.charge), texteDeCharge(ouvert.chargeJournal), nom);
+ const ailleurs = await enregistrerAilleurs(ouvert, documents, { existantes: [] });
+ const ecrit = analyser(fs.contenu('documents', ETAT));
+ assert.deepEqual([ecrit.entete.revision, ecrit.entete.produitVersion], [1, V1], nom);
+ assert.equal(texteDeCharge(ecrit.charge), texteDeCharge(avecFautive()), nom);
+ exigerEquivalence(fs, BASE);
+ assert.equal(texteDeCharge(ailleurs.chargeJournal), texteDeCharge(avecFautive()), nom);
+ assert.deepEqual(
+ [ailleurs.produitVersion, ailleurs.charge.propositions.map(({ id }) => id), ailleurs.avertissements],
+ [V1, [1], fautives],
+ nom,
+ );
+ assert.deepEqual((await rouvrir(disque, BASE)).avertissements, fautives, nom);
+ }
+ });
+
+ test("un événement sans journal s'écrit ailleurs par son seul état ; là où l'écriture est refusée, un journal lève ECRITURE, un état seul ETAT_NON_ECRIT, et une racine qui ne se crée pas ECRITURE, sans rien écrire", async () => {
+ const disque = new Map();
+ const fs = creerFichiersSimules({ disque, executable: 'E:\\soirees' });
+ fs.deposerDossier('documents');
+ fs.deposer('portable', ETAT, etatTexte(chargeEssai(), 4));
+ const { portable, documents } = await fs.racines();
+ const sansJournal = await ouvrir(fs, portable, BASE);
+ assert.equal(fs.compteurs.ecritures, 0, 'ouvrir écrit');
+ const trace = tracer(fs);
+ const ailleurs = await enregistrerAilleurs(sansJournal, documents, { existantes: [] });
+ assert.deepEqual(trace, [
+ ['creerDossier', ''],
+ ['ecrireAtomique', ETAT],
+ ]);
+ assert.deepEqual(
+ [ailleurs.journal, ailleurs.revision, ailleurs.texteEtat],
+ [null, 4, fs.contenu('documents', ETAT)],
+ );
+
+ // Les renommages de documents refusés : chaque écriture atomique échoue,
+ // le dossier existant passe.
+ const avecJournal = await creer(fs, portable, chargeEssai({ id: 'evt-gala', nom: "Gala d'automne" }), CREATION);
+ fs.pannes.renommage('documents');
+ const refusJournal = await echec(enregistrerAilleurs(avecJournal, documents, { existantes: [] }), 'ECRITURE');
+ assert.deepEqual(refusJournal.details, {
+ chemin: "Gala d'automne.gtt-journal.jsonl",
+ dossier: DOCUMENTS,
+ cause: 'EBUSY',
+ });
+ assert.equal(fs.contenu('documents', "Gala d'automne.gtt.json"), null);
+
+ const refusEtat = await echec(enregistrerAilleurs(sansJournal, documents, { existantes: [] }), 'ETAT_NON_ECRIT');
+ const { ouvert: refuse, ...details } = refusEtat.details;
+ assert.deepEqual(details, { chemin: 'Soirée des Lucioles (2).gtt.json', dossier: DOCUMENTS, cause: 'EBUSY' });
+ assert.deepEqual([refuse.base, refuse.texteEtat, refuse.revision], ['Soirée des Lucioles (2)', null, 4]);
+
+ // Sur un disque où documents n'a jamais été créé, et qui en refuse toute
+ // écriture : creerDossier, seule primitive appelée, lève ECRITURE sur la
+ // racine elle-même ; documents n'existe toujours pas, et rien ne s'écrit.
+ const neuf = new Map();
+ const sansDocuments = creerFichiersSimules({ disque: neuf, executable: 'E:\\soirees' });
+ sansDocuments.deposer('portable', ETAT, etatTexte(chargeEssai(), 4));
+ const seul = await ouvrir(sansDocuments, portable, BASE);
+ assert.equal(sansDocuments.compteurs.ecritures, 0, 'ouvrir écrit');
+ sansDocuments.pannes.ecriture('documents');
+ const traceRacine = tracer(sansDocuments);
+ const refusRacine = await echec(enregistrerAilleurs(seul, documents, { existantes: [] }), 'ECRITURE');
+ assert.deepEqual(refusRacine.details, { chemin: '', dossier: DOCUMENTS, cause: 'EROFS' });
+ assert.deepEqual(traceRacine, [['creerDossier', '']]);
+ await echec(creerFichiersSimules({ disque: neuf }).lister(DOC, ''), 'ABSENT');
+ assert.equal(sansDocuments.contenu('portable', ETAT), etatTexte(chargeEssai(), 4));
+ });
+
+ test("une lecture qui échoue autrement que sur un fichier absent remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE", async () => {
+ const { fs } = nouveauDisque();
+ await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE');
+ assert.equal(fs.compteurs.ecritures, 0);
+ });
+});