discovery-media-player 0.1.149 → 0.1.151

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é.
@@ -501,12 +501,79 @@ was something to look at, *0 of 0* means the table is empty or out of reach and
501
501
  nothing. It is `null` on the same terms as the counts.
502
502
 
503
503
  The counts are **bounded** at `borne` rows and read one small column. ⚠️ **`tronque` says whether
504
- that bound was reached**: when it is `true`, every number in the block is a *lower bound*, not a
504
+ anything was cut off**: when it is `true`, every number in the block is a *lower bound*, not a
505
505
  count. Without it a saturated `5000` would be indistinguishable from an exact five thousand — a
506
506
  wrong number that reads as right, which is worse than an absent one, because an absence makes you
507
507
  look and a number makes you conclude. `vide` stays correct either way: saturation can only make it
508
508
  `false`, never wrongly `true`.
509
509
 
510
+ ⚠️ **And `tronque` does not assume our bound is the only ceiling** — it did, for one release, and a
511
+ host measured what that cost. PostgREST has a ceiling of its own, `db-max-rows`, set to **1000** by
512
+ default on Supabase: the server returns 1000 rows however many you ask for. Comparing the received
513
+ length against `borne` then compares against the wrong number, and a table of 1651 rows was
514
+ published as `1000` **with `tronque: false`** — asserting an exactness it did not have.
515
+
516
+ So the question asked is not *did I hit my bound* but **is there anything after what I received**:
517
+ one row is requested past the last one received, by keyset cursor (`col=gt.<last>`, never by
518
+ offset — a cursor is stable under concurrent writes, and it is this repository's pagination rule). A row returned proves more remain; none proves the lot was
519
+ the whole — whichever ceiling produced it, without having to know it. **What this does not cover,
520
+ stated rather than glossed:** a server ceiling of *zero* stays indistinguishable from an empty table
521
+ by the response body alone. Reading the count from `Content-Range` under `Prefer: count=exact` has
522
+ no ceiling to guess and transports nothing; it is strictly better, and it needs the `db` capability
523
+ to expose response headers, which today it does not.
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
+ ⚠️ **The two alternatives were measured at a host, not assumed here** — recorded so nobody proposes
549
+ them again in six months believing they were never tried. **`?select=count()` is dead**:
550
+ `db-aggregates-enabled` is `false` by default, verified on two distinct Supabase projects, and the
551
+ measurement is solid for a reason worth stating — the `PGRST123` error arrives *before* the
552
+ permission check, where the same table queried without an aggregate answers `42501 permission
553
+ denied`. The answer therefore depends on neither grants nor any `revoke`: it is a property of the
554
+ **configuration**, not of authorization. That was the route we would have preferred, since it bound
555
+ no one to a contract. **`Prefer: count=exact` with `Range: 0-0` works**: the exact count travels in
556
+ the header and the body carries nothing. It is the only one of the two that exists, and its only
557
+ obstacle is this contract.
558
+
559
+ ⚠️ **And the same ceiling applies to every read you make through your own client, not just to
560
+ ours.** `limit=20000` does not return twenty thousand rows: PostgREST caps the response at
561
+ `db-max-rows` — **1000** on a default Supabase project — and says so nowhere in the body. A read
562
+ that asks for more than that ceiling is not a large read, it is a **false belief**, and it stays
563
+ invisible while your tables are small. So the question is worth asking of your own code as well as
564
+ of ours: *does my client paginate, or do I believe that `limit=20000` returns 20 000 rows?*
565
+
566
+ One host asked it of itself the day it found this in our counter, and the answer was not
567
+ hypothetical: a statistics read ordered `created_at.asc` with no `limit` was seeing the **1000
568
+ oldest** rows of 6424, so a "last opened" date read months stale for a link opened the day before,
569
+ and every breakdown described the beginning of the history. They also count **32** reads asking for
570
+ more than the ceiling — all latent on their volumes today, all live on an older installation.
571
+
572
+ ⚠️ **The sort direction decides how bad it gets.** A read that saturates while ordered `desc` loses
573
+ the oldest rows; ordered `asc` it loses the newest — that is, the ones anyone is looking at. Same
574
+ ceiling, same silence, opposite severity. Counting is indifferent to it, but anything that reads
575
+ *content* under a ceiling should prefer `desc`.
576
+
510
577
  They run only under `&schema=1`, the mode where you have asked for the database.
511
578
 
512
579
  ⚠️ **The purge attestation is a commitment, not a convenience.** Every column this player empties
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.149",
3
+ "version": "0.1.151",
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
 
@@ -410,9 +410,15 @@ function tick() {
410
410
  *
411
411
  * ⚠️ ON COMPTE DES LIGNES, PAS UN `count=exact`. La capacité `db` de l'hôte rend le corps de la
412
412
  * réponse, pas ses en-têtes : le compte de PostgREST voyage dans `Content-Range`, donc il serait
413
- * illisible sans élargir le contrat d'hôte — ce qu'un compteur de diagnostic ne justifie pas.
413
+ * illisible sans élargir le contrat d'hôte — que des hôtes tiers implémentent eux-mêmes.
414
414
  * D'où un comptage BORNÉ : au plus `BORNE_RESTE` identifiants, une seule petite colonne.
415
415
  *
416
+ * ⚠️ CE CHOIX A UN COÛT, ET IL EST NOMMÉ ICI PLUTÔT QUE SUBI : lire des LIGNES, c'est dépendre des
417
+ * plafonds de qui les rend, et un hôte a mesuré que ce plafond peut être SOUS notre borne. Le
418
+ * compte d'en-tête n'a pas de plafond à deviner et ne transporte rien ; il est strictement
419
+ * supérieur, et le seul obstacle est le contrat. Tant que le contrat ne le rend pas, `resteApres`
420
+ * rattrape la seule chose qui rendait le nombre MENSONGER — l'affirmation d'exactitude.
421
+ *
416
422
  * ⚠️ ET LA SATURATION SE DIT, ELLE NE SE DEVINE PAS — deux hôtes ont trouvé ce défaut dans la
417
423
  * première version, le même jour, indépendamment. Elle demandait `limit=BORNE` et publiait
418
424
  * `lignes.length` : sur une base portant cinq mille adresses, elle rendait `1000`, que rien ne
@@ -424,6 +430,14 @@ function tick() {
424
430
  * reste, sans coûter une ligne de plus. `n` reste plafonné à la borne, et `tronque` dit qu'il faut
425
431
  * le lire « au moins ».
426
432
  *
433
+ * ⚠️ ET CE CORRECTIF ÉTAIT LUI-MÊME FAUX, D'UN CRAN PLUS LOIN — trouvé par un hôte réel QUATRE
434
+ * HEURES après sa publication. Il comparait le nombre de lignes reçues à NOTRE borne, donc il
435
+ * supposait que le seul plafond fût le nôtre. PostgREST en a un autre, `db-max-rows`, réglé à 1000
436
+ * par défaut chez Supabase : le serveur tronque EN AMONT, et la comparaison porte alors sur le
437
+ * mauvais nombre. Une table de 1651 lignes se lisait `1000` avec `tronque: false` — pire que la
438
+ * version d'avant, qui ne prétendait rien là où celle-ci AFFIRMAIT l'exactitude. `resteApres`
439
+ * ci-dessous pose désormais la seule question dont la réponse ne dépend d'aucun plafond.
440
+ *
427
441
  * ⚠️ ET LE COÛT EST INVERSE DE L'INTUITION, donc il est dit plutôt que caché : quand il reste
428
442
  * beaucoup de lignes, la base s'arrête à la borne et c'est rapide ; quand il n'en reste AUCUNE,
429
443
  * elle parcourt la table pour ne rien trouver. Le cas cher est le cas terminal — celui où ce
@@ -466,12 +480,117 @@ const COLONNE_ABSENTE = "42703";
466
480
  /** `{ n, tronque }` — `n` nul veut dire indéterminé, jamais zéro. */
467
481
  const compte = (n, tronque) => ({ n, tronque });
468
482
 
469
- async function compterBorne(chemin) {
483
+ /**
484
+ * ⚠️ « MOINS QUE DEMANDÉ » NE PROUVE PAS LA FIN — ET C'EST UN HÔTE RÉEL QUI L'A MONTRÉ.
485
+ *
486
+ * La version précédente comparait le nombre de lignes reçues à NOTRE borne, et concluait « pas
487
+ * tronqué » dès qu'il était plus petit. Elle supposait que le seul plafond fût le nôtre. PostgREST
488
+ * en a un autre, `db-max-rows`, que Supabase règle à 1000 : le serveur rend 1000 lignes quoi qu'on
489
+ * demande. Sur une table de 1651 lignes, la carte a donc publié `1000` AVEC `tronque: false` —
490
+ * c'est-à-dire le défaut qu'on venait de corriger, déplacé d'un cran et AGGRAVÉ : la version d'avant
491
+ * ne prétendait rien, celle-là AFFIRMAIT que le nombre était exact.
492
+ *
493
+ * Le contrôle honnête ne porte donc pas sur une borne connue, mais sur la seule question dont la
494
+ * réponse ne dépend d'aucun plafond : « y a-t-il quelque chose APRÈS ce que j'ai reçu ? » On la
495
+ * pose en demandant UNE ligne au-delà de la dernière reçue. Une ligne rendue prouve qu'il en
496
+ * reste ; aucune prouve que le lot reçu était le tout — quel que soit le plafond qui l'a produit,
497
+ * et sans avoir à le connaître.
498
+ *
499
+ * ⚠️ PAR CURSEUR KEYSET (`cle=gt.<dernier>`), PAS PAR `offset` — et cette phrase est déjà écrite
500
+ * trois cent quatre-vingts lignes plus haut, au-dessus de `purgerParLots`, où elle dit la même
501
+ * chose depuis toujours : la garde de portabilité de la forge interdit `offset=`, et un curseur
502
+ * est de toute façon stable sous écriture concurrente. Première rédaction de cette sonde : par
503
+ * `offset`. La forge l'a refusée. C'est la SECONDE fois dans ce fichier qu'un remède déjà présent
504
+ * n'a pas été vu — après le drapeau `tronque` de `purgerParLots`. Un fichier dont on vient
505
+ * d'écrire la partie difficile se relit mal, et c'est un fait à traiter, pas une excuse.
506
+ *
507
+ * ⚠️ ET CE QU'ELLE NE COUVRE PAS EST DIT, PARCE QU'UNE GARDE MUETTE VAUT MOINS QUE PAS DE GARDE :
508
+ * un plafond serveur à ZÉRO reste indiscernable d'une table vide par le corps seul — les deux
509
+ * requêtes rendent zéro ligne. C'est la limite de la lecture par lignes, et la raison pour laquelle
510
+ * le compte d'en-tête (`Content-Range` sous `Prefer: count=exact`) lui est strictement supérieur :
511
+ * il ne dépend d'aucun plafond. Il demanderait d'élargir la capacité `db` du contrat d'hôte, qui ne
512
+ * rend aujourd'hui que le corps analysé.
513
+ *
514
+ * ⚠️ LES DEUX AUTRES VOIES ONT ÉTÉ MESURÉES CHEZ UN HÔTE, PAS SUPPOSÉES ICI. On les note pour que
515
+ * personne ne les repropose dans six mois en croyant qu'elles n'ont jamais été essayées :
516
+ *
517
+ * `?select=count()` — MORT. `db-aggregates-enabled` vaut `false` par défaut, vérifié sur DEUX
518
+ * projets Supabase distincts. Et la mesure est solide pour une raison qui vaut d'être dite :
519
+ * l'erreur `PGRST123` arrive AVANT le contrôle de droits — la même table, interrogée sans
520
+ * agrégat, rend `42501 permission denied`. La réponse ne dépend donc ni des droits ni d'un
521
+ * `revoke` : c'est une propriété de la CONFIGURATION, pas de l'autorisation. C'était la voie
522
+ * qu'on aurait préférée, puisqu'elle n'engageait aucun contrat.
523
+ *
524
+ * `Prefer: count=exact` + `Range: 0-0` — MARCHE. Le compte exact voyage dans l'en-tête, le corps
525
+ * ne transporte rien. C'est donc la SEULE des deux qui existe, et son seul obstacle est le
526
+ * contrat d'hôte.
527
+ */
528
+ async function resteApres(chemin, cle, dernier) {
529
+ // Sans curseur lisible, la fin ne se prouve pas : « au moins » est le seul côté sûr.
530
+ if (dernier == null) return true;
531
+ try {
532
+ const suite = await PLAYER.db.request(
533
+ `${chemin}&${cle}=gt.${enc(String(dernier))}&order=${cle}.asc&limit=1`, { timeoutMs: 8000 });
534
+ // Pas de réponse analysable ⇒ on ne sait pas ⇒ « au moins ». Se tromper vers le minorant ne
535
+ // fait que sous-estimer ; se tromper vers l'exactitude fait conclure.
536
+ return !Array.isArray(suite) || suite.length > 0;
537
+ } catch { return true; }
538
+ }
539
+
540
+ /**
541
+ * ⚠️ LA VOIE EXACTE, QUAND L'HÔTE LA FOURNIT — ET LE CONTRAT DEMANDE LA QUESTION, PAS LE MÉCANISME.
542
+ * `db.count(chemin)` rend « combien de lignes ce chemin sélectionne-t-il ». Un hôte PostgREST y
543
+ * répond par `Prefer: count=exact` ; un hôte sur une autre base par un `count(*)`. Nommer l'en-tête
544
+ * dans le contrat l'aurait rendu PostgREST-seulement, ce que la règle de portabilité refuse.
545
+ *
546
+ * ⚠️ ELLE EST OPTIONNELLE, ET SON ABSENCE N'EST PAS UNE PANNE. Des hôtes tiers implémentent la
547
+ * capacité `db` eux-mêmes ; exiger une méthode nouvelle les casserait tous. Absente, on retombe sur
548
+ * le comptage borné ci-dessous, qui reste juste — seulement moins précis. C'est la seule forme
549
+ * d'ajout au contrat que ce dépôt s'autorise : celle dont le repli est le comportement d'avant.
550
+ *
551
+ * ⚠️ ET TOUT CE QUI N'EST PAS UN ENTIER POSITIF RETOMBE, plutôt que d'être cru. Un hôte qui rend
552
+ * `undefined`, une chaîne, ou un négatif n'a pas répondu à la question — le lire comme un compte
553
+ * fabriquerait le chiffre que ce fichier existe pour ne pas fabriquer.
554
+ */
555
+ async function compteExact(chemin) {
556
+ // ⚠️ SORTIE ANTICIPÉE, PAS GARDE — ET LA DISTINCTION EST MESURÉE. Le `catch` ci-dessous suffirait
557
+ // à la correction : appeler une méthode absente lève, on retombe, le résultat est le même. Muté
558
+ // en `if (!PLAYER.db)`, AUCUN banc ne rougit — c'est dit ici plutôt que laissé croire à une
559
+ // protection. Ce que cette ligne achète est un COÛT : sans elle, tout hôte qui n'implémente pas
560
+ // `count` construirait cinq exceptions à chaque lecture de carte, pour rien.
561
+ if (!PLAYER.db || typeof PLAYER.db.count !== "function") return null;
562
+ try {
563
+ const n = await PLAYER.db.count(chemin);
564
+ return Number.isInteger(n) && n >= 0 ? n : null;
565
+ } catch {
566
+ // ⚠️ ON NE RECOPIE PAS ICI LA RÈGLE DE LA COLONNE ABSENTE. Une première rédaction traitait le
567
+ // `42703` sur cette voie aussi, pour rendre zéro « comme l'autre ». Muté, ce branchement n'a
568
+ // fait rougir aucun banc — et pour une raison de fond, pas par manque de cas : une colonne
569
+ // supprimée fait échouer LES DEUX voies de la même façon, donc le repli rend déjà ce zéro. Le
570
+ // branchement n'ajoutait rien d'observable et créait un SECOND endroit où tenir la même règle.
571
+ return null; // on ne sait pas ⇒ on essaie l'autre voie, qui elle sait lire le 42703
572
+ }
573
+ }
574
+
575
+ async function compterBorne(chemin, cle) {
576
+ // ⚠️ UN COMPTE EXACT N'EST NI BORNÉ NI TRONQUÉ, quelle que soit sa taille : `borne` décrit la
577
+ // méthode par lignes, pas celle-ci. `tronque: false` garde donc le sens qu'il a partout —
578
+ // « lisez ce nombre comme exact » — au lieu d'en prendre un second selon la voie employée.
579
+ const exact = await compteExact(chemin);
580
+ if (exact !== null) return compte(exact, false);
470
581
  try {
471
582
  // ⚠️ BORNE + 1 : la ligne excédentaire ne sert qu'à PROUVER qu'il en reste. On ne la publie pas.
472
- const lignes = await PLAYER.db.request(`${chemin}&limit=${BORNE_RESTE + 1}`, { timeoutMs: 8000 });
583
+ // ⚠️ ET L'ORDRE N'EST PAS DÉCORATIF : sans lui, « la dernière ligne reçue » ne désigne aucune
584
+ // frontière, et le curseur de la sonde ne voudrait rien dire.
585
+ const lignes = await PLAYER.db.request(
586
+ `${chemin}&order=${cle}.asc&limit=${BORNE_RESTE + 1}`, { timeoutMs: 8000 });
473
587
  if (!Array.isArray(lignes)) return compte(null, false);
474
- return compte(Math.min(lignes.length, BORNE_RESTE), lignes.length > BORNE_RESTE);
588
+ // Notre propre borne atteinte : la preuve est dans la ligne excédentaire, rien à demander.
589
+ if (lignes.length > BORNE_RESTE) return compte(BORNE_RESTE, true);
590
+ // Zéro ligne : la sonde au-delà rendrait zéro elle aussi et n'apprendrait rien — y compris sous
591
+ // un plafond à zéro, que ni l'une ni l'autre ne distingue d'une table vide.
592
+ if (!lignes.length) return compte(0, false);
593
+ return compte(lignes.length, await resteApres(chemin, cle, lignes[lignes.length - 1][cle]));
475
594
  } catch (e) {
476
595
  if (e && e.details && e.details.code === COLONNE_ABSENTE) return compte(0, false);
477
596
  return compte(null, false); // indéterminé — surtout pas zéro
@@ -479,7 +598,7 @@ async function compterBorne(chemin) {
479
598
  }
480
599
 
481
600
  const compterReste = (table, cle, colonne) =>
482
- compterBorne(`${table}?select=${cle}&${colonne}=not.is.null`);
601
+ compterBorne(`${table}?select=${cle}&${colonne}=not.is.null`, cle);
483
602
 
484
603
  /**
485
604
  * ⚠️ ET LE COMPTEUR PORTE CE QU'IL A REGARDÉ — un hôte nous l'a demandé, et il avait raison.
@@ -497,7 +616,7 @@ const compterReste = (table, cle, colonne) =>
497
616
  * borne dès les premières lignes. Une par TABLE, pas une par sonde — deux des trois colonnes vivent
498
617
  * dans la même.
499
618
  */
500
- const compterLignes = (table, cle) => compterBorne(`${table}?select=${cle}`);
619
+ const compterLignes = (table, cle) => compterBorne(`${table}?select=${cle}`, cle);
501
620
 
502
621
  async function resteDeLaPurge() {
503
622
  const [comptes, totaux] = await Promise.all([