discovery-media-player 0.1.150 → 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é.
@@ -522,6 +522,40 @@ 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
+ ⚠️ **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
+
525
559
  ⚠️ **And the same ceiling applies to every read you make through your own client, not just to
526
560
  ours.** `limit=20000` does not return twenty thousand rows: PostgREST caps the response at
527
561
  `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.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
 
@@ -510,6 +510,20 @@ const compte = (n, tronque) => ({ n, tronque });
510
510
  * le compte d'en-tête (`Content-Range` sous `Prefer: count=exact`) lui est strictement supérieur :
511
511
  * il ne dépend d'aucun plafond. Il demanderait d'élargir la capacité `db` du contrat d'hôte, qui ne
512
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.
513
527
  */
514
528
  async function resteApres(chemin, cle, dernier) {
515
529
  // Sans curseur lisible, la fin ne se prouve pas : « au moins » est le seul côté sûr.
@@ -523,7 +537,47 @@ async function resteApres(chemin, cle, dernier) {
523
537
  } catch { return true; }
524
538
  }
525
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
+
526
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);
527
581
  try {
528
582
  // ⚠️ BORNE + 1 : la ligne excédentaire ne sert qu'à PROUVER qu'il en reste. On ne la publie pas.
529
583
  // ⚠️ ET L'ORDRE N'EST PAS DÉCORATIF : sans lui, « la dernière ligne reçue » ne désigne aucune