discovery-media-player 0.1.150 → 0.1.152

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,7 +45,13 @@ function creerDb(env) {
45
45
  const url = sansBarreFinale(env.SUPABASE_URL);
46
46
  const cle = String(env.SUPABASE_SERVICE_ROLE_KEY || "");
47
47
 
48
- async function request(chemin, options = {}) {
48
+ // ⚠️ UN SEUL ENDROIT QUI APPELLE ET QUI REJETTE. `count` a besoin d'un EN-TÊTE de la réponse,
49
+ // pas de son corps ; le tenter avec son propre `fetch` aurait recopié la construction des
50
+ // en-têtes, l'abandon, et surtout la forme de l'erreur (`statusCode`/`details`) dont six sites
51
+ // appelants dépendent. Une seconde orthographe de « appeler PostgREST et rejeter correctement »
52
+ // est exactement la recopie que ce dépôt a déjà payée trois fois. `request` et `count` se
53
+ // partagent donc l'appel ; ils ne se partagent que ce qu'ils lisent de la réponse.
54
+ async function appel(chemin, options = {}) {
49
55
  if (!url || !cle) {
50
56
  // Message explicite plutôt que `undefined` plus loin : sans base, ce sont les liens tracés
51
57
  // et les présentations qui sont indisponibles — pas l'affichage d'un document.
@@ -92,10 +98,47 @@ function creerDb(env) {
92
98
  try { erreur.details = JSON.parse(detail); } catch { /* corps non JSON : le message suffit */ }
93
99
  throw erreur;
94
100
  }
101
+ return r;
102
+ }
103
+
104
+ async function request(chemin, options = {}) {
105
+ const r = await appel(chemin, options);
95
106
  const texte = await r.text();
96
107
  return texte ? JSON.parse(texte) : null;
97
108
  }
98
109
 
110
+ /**
111
+ * ⚠️ LE COMPTE EXACT, ET C'EST UNE QUESTION — PAS UN MÉCANISME. Le contrat demande « combien de
112
+ * lignes ce chemin sélectionne-t-il ? » ; il ne demande pas de lire un en-tête. Un hôte sur une
113
+ * autre base répond par un `count(*)`, celui-ci par PostgREST. Nommer le mécanisme dans le
114
+ * contrat l'aurait rendu PostgREST-seulement, ce que la règle de portabilité refuse.
115
+ *
116
+ * ⚠️ POURQUOI IL EXISTE : le comptage par LIGNES dépend des plafonds de qui les rend. PostgREST a
117
+ * `db-max-rows`, réglé à 1000 par défaut chez Supabase, et un hôte a mesuré une table de 1651
118
+ * lignes rendue « 1000 ». Le compte d'en-tête, lui, n'a AUCUN plafond à deviner et ne transporte
119
+ * rien. Mesuré chez un hôte le 02/09 : `Prefer: count=exact` + `Range: 0-0` rend bien le compte
120
+ * exact ; l'autre voie envisagée, `?select=count()`, est morte — `db-aggregates-enabled` vaut
121
+ * `false` par défaut, vérifié sur deux projets distincts.
122
+ *
123
+ * ⚠️ ET UN GET PLUTÔT QU'UN HEAD, DÉLIBÉRÉMENT. Un HEAD ne rend aucun corps, donc aucune erreur
124
+ * ANALYSÉE : l'appelant ne pourrait plus distinguer « colonne supprimée » (42703, un état connu
125
+ * qui vaut zéro) d'une panne. `Range: 0-0` ne coûte qu'une ligne et garde l'erreur lisible.
126
+ *
127
+ * ⚠️ RENDRE `null` PLUTÔT QUE ZÉRO QUAND LE COMPTE MANQUE. PostgREST écrit `…/*` quand il ne
128
+ * compte pas. Zéro est la réponse qui autorise à supprimer une colonne : la fabriquer depuis une
129
+ * réponse qui ne compte pas serait le pire mensonge que cette capacité puisse faire.
130
+ */
131
+ async function count(chemin, options = {}) {
132
+ const r = await appel(chemin, {
133
+ ...options,
134
+ method: "GET",
135
+ headers: { ...(options.headers || {}), Prefer: "count=exact", Range: "0-0" },
136
+ });
137
+ // `Content-Range: 0-0/1651` — le total suit la barre. `…/*` veut dire « je n'ai pas compté ».
138
+ const trouve = /\/(\d+)\s*$/.exec(String(r.headers.get("content-range") || ""));
139
+ return trouve ? Number(trouve[1]) : null;
140
+ }
141
+
99
142
  /** Lecture paginée complète : un document très partagé dépasse la pagination par défaut. */
100
143
  async function selectAll(chemin, taille = 1000) {
101
144
  const tout = [];
@@ -107,7 +150,7 @@ function creerDb(env) {
107
150
  }
108
151
  }
109
152
 
110
- return { request, selectAll, configuree: !!(url && cle) };
153
+ return { request, selectAll, count, configuree: !!(url && cle) };
111
154
  }
112
155
 
113
156
  /**
@@ -356,7 +399,7 @@ function createStandaloneContext(env = process.env) {
356
399
  },
357
400
  },
358
401
 
359
- db: { request: db.request, selectAll: db.selectAll },
402
+ db: { request: db.request, selectAll: db.selectAll, count: db.count },
360
403
 
361
404
  // Sans expéditeur configuré, le re-partage et le code du mur d'accès sont indisponibles — et
362
405
  // le disent. Ils ne prétendent pas avoir envoyé.
@@ -487,7 +487,7 @@ above: an unheard-of action answered *no* narrows this view rather than breaking
487
487
  gains a `purge` block counting the rows that still carry a reader IP or a raw User-Agent:
488
488
 
489
489
  "purge": { "borne": 5000, "tronque": false, "lignes": { "sessions": 1908, "vues": 3200 },
490
- "sessionsIp": 0, "sessionsUa": 0, "vuesUa": 0, "vide": true }
490
+ "sessionsIp": 0, "sessionsUa": 0, "vuesUa": 0, "vide": true, "voie": "bornee" }
491
491
 
492
492
  `vide` is the reading that matters: `true` means nothing of that legacy is left **on this
493
493
  instance's live rows** — the condition under which those columns can eventually be dropped —
@@ -522,6 +522,58 @@ by the response body alone. Reading the count from `Content-Range` under `Prefer
522
522
  no ceiling to guess and transports nothing; it is strictly better, and it needs the `db` capability
523
523
  to expose response headers, which today it does not.
524
524
 
525
+ ⚠️ **`db.count(path)` is the seam that closes this, and it is optional.** ⚠️ **The standalone
526
+ context shipped in this package already implements it** — if you build your context from
527
+ `discovery-media-player/context/standalone`, you get it on your next upgrade and there is nothing
528
+ to decide or write. This section is for a host that implements the `db` capability itself. A host
529
+ asked which of the two it was, and the answer was missing from this page: *"the two look alike in
530
+ your code and not at all alike at your hosts."* If your `db` capability
531
+ exposes it, the player asks it first and publishes an **exact** count — no bound, no `tronque`, and
532
+ no rows transported at all. If it is absent, everything above still applies unchanged: the bounded
533
+ read with its cursor probe. **That fallback is the whole design.** Third-party hosts implement this
534
+ capability themselves, and requiring a new method would break every one of them; the only kind of
535
+ contract addition this repository allows is the kind whose absence is the previous behaviour.
536
+
537
+ db.count(path) → number | null
538
+
539
+ **It asks the question, not the mechanism.** *How many rows does this path select?* — not *read this
540
+ header*. A PostgREST host answers with `Prefer: count=exact`; a host on another database answers
541
+ with a `count(*)`. Naming the header in the contract would have made it PostgREST-only, which the
542
+ portability rule refuses.
543
+
544
+ ⚠️ **Answer `null` when you cannot say — never `0`.** Zero is the answer that authorises dropping a
545
+ column. Anything that is not a non-negative integer (a string, a float, `undefined`, `NaN`) is read
546
+ as "no answer" and the player falls back rather than believing it.
547
+
548
+ ⚠️ **And `voie` tells you which route produced the numbers, because the numbers cannot.** `"exact"`,
549
+ `"bornee"`, or `"mixte"` when both served the same read — which happens for real: a host mid-purge
550
+ whose column is already dropped makes `count` throw on the filtered paths and answer on the same
551
+ table's totals.
552
+
553
+ This field exists because two hosts, independently and on the same day, found they could not verify
554
+ their own seam. An exact count and an untruncated bounded count render an **identical** block —
555
+ same numbers, same `tronque`, same `vide`. So a `db.count` that returns a string, or a float, falls
556
+ back **silently** and the card looks exactly like a working one: you believe your seam is wired
557
+ when it is not. One host caught it only by luck of volume — their table held 1655 rows, and the
558
+ bounded route is structurally incapable of exceeding the 1000-row ceiling, so the number itself
559
+ happened to be proof. Under a thousand rows there is no such luck, and the other host, whose
560
+ volumes are small, could not tell at all.
561
+
562
+ **Check `voie`, not a value.** A control that distinguishes two *mechanisms* must rest on something
563
+ one can do and the other cannot; a value both could return proves nothing, and a value written down
564
+ from yesterday's measurement rots without anyone touching anything.
565
+
566
+ ⚠️ **The two alternatives were measured at a host, not assumed here** — recorded so nobody proposes
567
+ them again in six months believing they were never tried. **`?select=count()` is dead**:
568
+ `db-aggregates-enabled` is `false` by default, verified on two distinct Supabase projects, and the
569
+ measurement is solid for a reason worth stating — the `PGRST123` error arrives *before* the
570
+ permission check, where the same table queried without an aggregate answers `42501 permission
571
+ denied`. The answer therefore depends on neither grants nor any `revoke`: it is a property of the
572
+ **configuration**, not of authorization. That was the route we would have preferred, since it bound
573
+ no one to a contract. **`Prefer: count=exact` with `Range: 0-0` works**: the exact count travels in
574
+ the header and the body carries nothing. It is the only one of the two that exists, and its only
575
+ obstacle is this contract.
576
+
525
577
  ⚠️ **And the same ceiling applies to every read you make through your own client, not just to
526
578
  ours.** `limit=20000` does not return twenty thousand rows: PostgREST caps the response at
527
579
  `db-max-rows` — **1000** on a default Supabase project — and says so nowhere in the body. A read
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.150",
3
+ "version": "0.1.152",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
@@ -67,7 +67,7 @@
67
67
  "build": "node -e \"require('fs').existsSync('build/bundle.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && node build/bundle.mjs && tsc -p tsconfig.build.json && node -e \"import('./build/bundle.mjs').then(m=>m.marquerDistEsm())\"",
68
68
  "test": "node -e \"require('fs').existsSync('server/__tests__')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run",
69
69
  "test:watch": "vitest",
70
- "lint": "node -e \"require('fs').existsSync('eslint.config.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && eslint bin context server src build tools charge --max-warnings 0",
70
+ "lint": "node -e \"require('fs').existsSync('eslint.config.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && eslint bin context server src build tools charge base --max-warnings 0",
71
71
  "lint:fix": "eslint bin context server src build tools charge --fix",
72
72
  "typecheck": "node -e \"require('fs').existsSync('tsconfig.json')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && tsc --noEmit",
73
73
  "prepublishOnly": "npm run build && npm test",
package/server/mesures.js CHANGED
@@ -130,6 +130,12 @@ function observerBase(db) {
130
130
  vu.__mesuree = true;
131
131
  vu.request = mesurer("request");
132
132
  if (typeof db.selectAll === "function") vu.selectAll = mesurer("selectAll");
133
+ // ⚠️ CHAQUE MÉTHODE AJOUTÉE À LA CAPACITÉ DOIT ÊTRE AJOUTÉE ICI, et l'héritage rend cet oubli
134
+ // SILENCIEUX : `Object.create` laisse passer une méthode nouvelle, vivante et non mesurée — donc
135
+ // le paragraphe ci-dessus, qui promet de couvrir « y compris ce que personne n'a encore écrit »,
136
+ // deviendrait faux sans que rien ne rougisse. `count` est optionnelle chez l'hôte ; quand elle
137
+ // existe, elle interroge la base et son temps compte comme le reste.
138
+ if (typeof db.count === "function") vu.count = mesurer("count");
133
139
  return vu;
134
140
  }
135
141
 
@@ -477,8 +477,21 @@ const TABLES_RESTE = [["sessions", "commercial_doc_sessions", "session_id"],
477
477
  */
478
478
  const COLONNE_ABSENTE = "42703";
479
479
 
480
- /** `{ n, tronque }` — `n` nul veut dire indéterminé, jamais zéro. */
481
- const compte = (n, tronque) => ({ n, tronque });
480
+ /**
481
+ * `{ n, tronque, voie }` — `n` nul veut dire indéterminé, jamais zéro.
482
+ *
483
+ * ⚠️ ET `voie` NOMME LE MÉCANISME QUI A PRODUIT LE NOMBRE, parce que le nombre seul ne le dit pas.
484
+ * Un compte exact et un compte borné NON tronqué rendent le même JSON : deux hôtes l'ont relevé le
485
+ * même jour, l'un en constatant qu'il ne pouvait pas vérifier sa propre couture, l'autre en
486
+ * écrivant un contrôle qui n'a marché que par chance de volume — sa table dépassait mille, donc la
487
+ * voie par lignes était structurellement incapable de rendre son chiffre. Sous mille, personne ne
488
+ * peut trancher, et un `db.count` qui rend une chaîne retombe SILENCIEUSEMENT sur la voie bornée :
489
+ * l'hôte croit sa couture branchée alors qu'elle ne sert pas.
490
+ */
491
+ const compte = (n, tronque, voie) => ({ n, tronque, voie });
492
+
493
+ const VOIE_EXACTE = "exact";
494
+ const VOIE_BORNEE = "bornee";
482
495
 
483
496
  /**
484
497
  * ⚠️ « MOINS QUE DEMANDÉ » NE PROUVE PAS LA FIN — ET C'EST UN HÔTE RÉEL QUI L'A MONTRÉ.
@@ -510,6 +523,20 @@ const compte = (n, tronque) => ({ n, tronque });
510
523
  * le compte d'en-tête (`Content-Range` sous `Prefer: count=exact`) lui est strictement supérieur :
511
524
  * il ne dépend d'aucun plafond. Il demanderait d'élargir la capacité `db` du contrat d'hôte, qui ne
512
525
  * rend aujourd'hui que le corps analysé.
526
+ *
527
+ * ⚠️ LES DEUX AUTRES VOIES ONT ÉTÉ MESURÉES CHEZ UN HÔTE, PAS SUPPOSÉES ICI. On les note pour que
528
+ * personne ne les repropose dans six mois en croyant qu'elles n'ont jamais été essayées :
529
+ *
530
+ * `?select=count()` — MORT. `db-aggregates-enabled` vaut `false` par défaut, vérifié sur DEUX
531
+ * projets Supabase distincts. Et la mesure est solide pour une raison qui vaut d'être dite :
532
+ * l'erreur `PGRST123` arrive AVANT le contrôle de droits — la même table, interrogée sans
533
+ * agrégat, rend `42501 permission denied`. La réponse ne dépend donc ni des droits ni d'un
534
+ * `revoke` : c'est une propriété de la CONFIGURATION, pas de l'autorisation. C'était la voie
535
+ * qu'on aurait préférée, puisqu'elle n'engageait aucun contrat.
536
+ *
537
+ * `Prefer: count=exact` + `Range: 0-0` — MARCHE. Le compte exact voyage dans l'en-tête, le corps
538
+ * ne transporte rien. C'est donc la SEULE des deux qui existe, et son seul obstacle est le
539
+ * contrat d'hôte.
513
540
  */
514
541
  async function resteApres(chemin, cle, dernier) {
515
542
  // Sans curseur lisible, la fin ne se prouve pas : « au moins » est le seul côté sûr.
@@ -523,23 +550,64 @@ async function resteApres(chemin, cle, dernier) {
523
550
  } catch { return true; }
524
551
  }
525
552
 
553
+ /**
554
+ * ⚠️ LA VOIE EXACTE, QUAND L'HÔTE LA FOURNIT — ET LE CONTRAT DEMANDE LA QUESTION, PAS LE MÉCANISME.
555
+ * `db.count(chemin)` rend « combien de lignes ce chemin sélectionne-t-il ». Un hôte PostgREST y
556
+ * répond par `Prefer: count=exact` ; un hôte sur une autre base par un `count(*)`. Nommer l'en-tête
557
+ * dans le contrat l'aurait rendu PostgREST-seulement, ce que la règle de portabilité refuse.
558
+ *
559
+ * ⚠️ ELLE EST OPTIONNELLE, ET SON ABSENCE N'EST PAS UNE PANNE. Des hôtes tiers implémentent la
560
+ * capacité `db` eux-mêmes ; exiger une méthode nouvelle les casserait tous. Absente, on retombe sur
561
+ * le comptage borné ci-dessous, qui reste juste — seulement moins précis. C'est la seule forme
562
+ * d'ajout au contrat que ce dépôt s'autorise : celle dont le repli est le comportement d'avant.
563
+ *
564
+ * ⚠️ ET TOUT CE QUI N'EST PAS UN ENTIER POSITIF RETOMBE, plutôt que d'être cru. Un hôte qui rend
565
+ * `undefined`, une chaîne, ou un négatif n'a pas répondu à la question — le lire comme un compte
566
+ * fabriquerait le chiffre que ce fichier existe pour ne pas fabriquer.
567
+ */
568
+ async function compteExact(chemin) {
569
+ // ⚠️ SORTIE ANTICIPÉE, PAS GARDE — ET LA DISTINCTION EST MESURÉE. Le `catch` ci-dessous suffirait
570
+ // à la correction : appeler une méthode absente lève, on retombe, le résultat est le même. Muté
571
+ // en `if (!PLAYER.db)`, AUCUN banc ne rougit — c'est dit ici plutôt que laissé croire à une
572
+ // protection. Ce que cette ligne achète est un COÛT : sans elle, tout hôte qui n'implémente pas
573
+ // `count` construirait cinq exceptions à chaque lecture de carte, pour rien.
574
+ if (!PLAYER.db || typeof PLAYER.db.count !== "function") return null;
575
+ try {
576
+ const n = await PLAYER.db.count(chemin);
577
+ return Number.isInteger(n) && n >= 0 ? n : null;
578
+ } catch {
579
+ // ⚠️ ON NE RECOPIE PAS ICI LA RÈGLE DE LA COLONNE ABSENTE. Une première rédaction traitait le
580
+ // `42703` sur cette voie aussi, pour rendre zéro « comme l'autre ». Muté, ce branchement n'a
581
+ // fait rougir aucun banc — et pour une raison de fond, pas par manque de cas : une colonne
582
+ // supprimée fait échouer LES DEUX voies de la même façon, donc le repli rend déjà ce zéro. Le
583
+ // branchement n'ajoutait rien d'observable et créait un SECOND endroit où tenir la même règle.
584
+ return null; // on ne sait pas ⇒ on essaie l'autre voie, qui elle sait lire le 42703
585
+ }
586
+ }
587
+
526
588
  async function compterBorne(chemin, cle) {
589
+ // ⚠️ UN COMPTE EXACT N'EST NI BORNÉ NI TRONQUÉ, quelle que soit sa taille : `borne` décrit la
590
+ // méthode par lignes, pas celle-ci. `tronque: false` garde donc le sens qu'il a partout —
591
+ // « lisez ce nombre comme exact » — au lieu d'en prendre un second selon la voie employée.
592
+ const exact = await compteExact(chemin);
593
+ if (exact !== null) return compte(exact, false, VOIE_EXACTE);
527
594
  try {
528
595
  // ⚠️ BORNE + 1 : la ligne excédentaire ne sert qu'à PROUVER qu'il en reste. On ne la publie pas.
529
596
  // ⚠️ ET L'ORDRE N'EST PAS DÉCORATIF : sans lui, « la dernière ligne reçue » ne désigne aucune
530
597
  // frontière, et le curseur de la sonde ne voudrait rien dire.
531
598
  const lignes = await PLAYER.db.request(
532
599
  `${chemin}&order=${cle}.asc&limit=${BORNE_RESTE + 1}`, { timeoutMs: 8000 });
533
- if (!Array.isArray(lignes)) return compte(null, false);
600
+ if (!Array.isArray(lignes)) return compte(null, false, VOIE_BORNEE);
534
601
  // Notre propre borne atteinte : la preuve est dans la ligne excédentaire, rien à demander.
535
- if (lignes.length > BORNE_RESTE) return compte(BORNE_RESTE, true);
602
+ if (lignes.length > BORNE_RESTE) return compte(BORNE_RESTE, true, VOIE_BORNEE);
536
603
  // Zéro ligne : la sonde au-delà rendrait zéro elle aussi et n'apprendrait rien — y compris sous
537
604
  // un plafond à zéro, que ni l'une ni l'autre ne distingue d'une table vide.
538
- if (!lignes.length) return compte(0, false);
539
- return compte(lignes.length, await resteApres(chemin, cle, lignes[lignes.length - 1][cle]));
605
+ if (!lignes.length) return compte(0, false, VOIE_BORNEE);
606
+ return compte(lignes.length, await resteApres(chemin, cle, lignes[lignes.length - 1][cle]),
607
+ VOIE_BORNEE);
540
608
  } catch (e) {
541
- if (e && e.details && e.details.code === COLONNE_ABSENTE) return compte(0, false);
542
- return compte(null, false); // indéterminé — surtout pas zéro
609
+ if (e && e.details && e.details.code === COLONNE_ABSENTE) return compte(0, false, VOIE_BORNEE);
610
+ return compte(null, false, VOIE_BORNEE); // indéterminé — surtout pas zéro
543
611
  }
544
612
  }
545
613
 
@@ -582,6 +650,18 @@ async function resteDeLaPurge() {
582
650
  // confiance aux autres, alors que la borne est commune et que la question ne l'est pas.
583
651
  out.tronque = [...comptes, ...totaux].some((c) => c.tronque);
584
652
  out.lignes = parTable;
653
+ // ⚠️ UNE SEULE RÉPONSE POUR LES CINQ COMPTES, ET TROIS ÉTATS PLUTÔT QUE DEUX. La question qu'un
654
+ // hôte se pose est « ma couture sert-elle ? », pas « laquelle des cinq ». `"mixte"` n'est pas une
655
+ // commodité : il arrive vraiment — un `count` qui lève sur le chemin d'une colonne supprimée et
656
+ // répond sur le total de la même table — et c'est précisément le cas qu'un drapeau binaire
657
+ // aurait dû arrondir dans un sens ou dans l'autre, donc mentir.
658
+ //
659
+ // ⚠️ CE CHAMP NE DIT RIEN SUR LA JUSTESSE DES NOMBRES, seulement sur leur provenance. Il ne
660
+ // double aucun autre : `tronque` vaut `false` sur les DEUX voies, c'est même toute la raison
661
+ // d'être de cette ligne.
662
+ const voies = [...comptes, ...totaux].map((c) => c.voie);
663
+ out.voie = voies.every((v) => v === VOIE_EXACTE) ? VOIE_EXACTE
664
+ : voies.every((v) => v === VOIE_BORNEE) ? VOIE_BORNEE : "mixte";
585
665
  SONDES_RESTE.forEach(([nom], i) => { out[nom] = comptes[i].n; });
586
666
  // ⚠️ TROIS ÉTATS, PAS DEUX. `true` : plus rien, le retrait des colonnes est permis ICI. `false` :
587
667
  // il reste des lignes. `null` : au moins une sonde n'a pas répondu — on ne sait pas, et « on ne