discovery-media-player 0.1.127 → 0.1.129
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.
- package/docs/HOST-CONTRACT.md +54 -10
- package/docs/README.md +1 -1
- package/package.json +7 -4
- package/server/handler.js +11 -0
- package/server/presentations.js +25 -1
- package/server/schema.js +79 -6
- package/server/shares.js +31 -5
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -38,6 +38,7 @@ need.
|
|
|
38
38
|
"presenceStrict": true,
|
|
39
39
|
"presenceJetons": true,
|
|
40
40
|
"presenceDurcissement": "inconnu",
|
|
41
|
+
"presenceFusion": "inconnu",
|
|
41
42
|
"retentionSweep": false,
|
|
42
43
|
"hostShare": true,
|
|
43
44
|
"hostMail": true,
|
|
@@ -73,13 +74,33 @@ The three `presence*` fields report what the host has **observed**, not what it
|
|
|
73
74
|
| `presenceJetons` | measured — the host actually signed a throwaway token, so `PLAYER_PRESENCE_SECRET` works |
|
|
74
75
|
| `presenceStrict` | **effective** — `PLAYER_PRESENCE_STRICT` is set *and* tokens can be issued. A closed door announced over an open one would be the worse failure |
|
|
75
76
|
| `presenceDurcissement` | `actif` (a hardened call came back), `degrade` (migration 0018 is missing), `inconnu` (nothing attempted in this process — **not** a green light, and process-local: another instance may have seen otherwise) |
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
migration is
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
| `presenceFusion` | `actif` (a heartbeat used the fused contract — one round trip instead of two), `degrade` (migration 0019 is missing: heartbeats cost 3 round trips instead of 2, nothing breaks), `inconnu` (no heartbeat served in this process). Same three states, same trap, same reading rule as the row above |
|
|
78
|
+
|
|
79
|
+
⚠️ **Before you upgrade, do not read `presenceDurcissement` or `presenceFusion`.** They are *reports
|
|
80
|
+
of execution*: on an instance where nothing is running they say `inconnu`, which means *nobody
|
|
81
|
+
looked* — not *the migration is there*. A pre-flight check built on one of them silently passes on
|
|
82
|
+
every idle host, and the missing migration is then discovered at the first presentation, i.e. at the
|
|
83
|
+
worst moment. Ask `GET /api/doc?contract=1&schema=1` and read **`schema.durcissementBase`** and
|
|
84
|
+
**`schema.fusionBase`** instead: they ask the database, so they answer a global fact.
|
|
85
|
+
|
|
86
|
+
⚠️ **And on a serverless host, `inconnu` is not the exception — it is the normal answer, forever.**
|
|
87
|
+
This paragraph used to say *"on an instance where nothing is running"*, which reads as a description
|
|
88
|
+
of an **idle** deployment. Field data from the second host corrected it: a real presentation ran on
|
|
89
|
+
their instance, with a participant, on the very day both fields read `inconnu`. Nothing was idle —
|
|
90
|
+
the presentation had simply ended, and the short-lived process answering `/api/doc` was never the one
|
|
91
|
+
that served a heartbeat. On a platform where each request may be a fresh process, that is the
|
|
92
|
+
**structural** case, not an edge case: a host serving presentations daily can read `inconnu` every
|
|
93
|
+
single time you ask.
|
|
94
|
+
|
|
95
|
+
So the two fields answer *"did this process, right now, see it work?"* — useful to confirm a fix on a
|
|
96
|
+
long-lived process, worthless as an inventory anywhere else. The durable signals live in `schema`:
|
|
97
|
+
`fusionBase` and `durcissementBase` for the migrations, and `schema.presence.avecJeton` crossed with
|
|
98
|
+
`presentationsActives` for actual traffic — those are read from the database and survive the process
|
|
99
|
+
that answers.
|
|
100
|
+
|
|
101
|
+
⚠️ **A corollary worth keeping:** *"our instances are idle"* and *"our instances are lightly used"*
|
|
102
|
+
are different claims, and only the second was true here. The distinction matters because a defect
|
|
103
|
+
that needs traffic to appear had real opportunities the whole time it was assumed to have none.
|
|
83
104
|
|
|
84
105
|
| `durcissementBase` | meaning |
|
|
85
106
|
|---|---|
|
|
@@ -87,9 +108,32 @@ database, so it answers a global fact.
|
|
|
87
108
|
| `absente` | 0018 is missing: apply it **before** setting `PLAYER_PRESENCE_STRICT`, or bootstraps will be refused with `503` |
|
|
88
109
|
| `indetermine` | the question could not be asked — neither a yes nor a no |
|
|
89
110
|
|
|
90
|
-
The
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
The same answer carries **`schema.fusionBase`**, for migration `0019`, with the same three values.
|
|
112
|
+
Both come from **one** call in the normal case: `0019` succeeds `0018` and its argument set *contains*
|
|
113
|
+
it, so a call the long contract accepts proves both at once. The short contract is only asked again
|
|
114
|
+
when the long one is missing — i.e. exactly on the host that is behind and owes a precise answer.
|
|
115
|
+
|
|
116
|
+
| `fusionBase` | meaning |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `applique` | `0019` is in the database — a presence heartbeat costs **2**† database round trips (**20**† ops/s for 250 attendees) |
|
|
119
|
+
| `absente` | `0019` is missing: **nothing breaks**, a heartbeat costs **3**† round trips (**30**† ops/s for 250 attendees). Applying it needs no redeploy — the player picks it up within a minute |
|
|
120
|
+
| `indetermine` | the question could not be asked — neither a yes nor a no |
|
|
121
|
+
|
|
122
|
+
⚠️ Unlike `0018`, a missing `0019` is a **cost**, not a risk: read it when you are sizing an
|
|
123
|
+
instance, not when you are deciding whether it is safe to run. A host missing it is also logged once
|
|
124
|
+
an hour, with the exact figures, so an idle instance still finds out.
|
|
125
|
+
|
|
126
|
+
† **Recomputed from the code on every CI run** by `charge/coutParGeste.test.js`, which measures both
|
|
127
|
+
regimes — the fallback still lives in the code, so the *without-`0019`* figure is a measurement, not
|
|
128
|
+
a number remembered from an older release. The build fails when this document and the bench disagree.
|
|
129
|
+
⚠️ **A number without † in this repository's documentation is hand-written: it was true once, and
|
|
130
|
+
nothing has checked it since.**
|
|
131
|
+
|
|
132
|
+
The probe writes nothing, for **two independent reasons**: `p_page = null` on a slug that does not
|
|
133
|
+
exist leaves through `0019`'s *introuvable* branch before the insert, and `p_anon_cap = 0` already
|
|
134
|
+
left through the previous contract's *capped* branch. Two reasons rather than one, because a
|
|
135
|
+
diagnostic probe is the worst place to discover a regression. A real-Postgres test asserts that no
|
|
136
|
+
row appears. A host missing 0018 is also logged once an hour, so an idle instance still finds out.
|
|
93
137
|
|
|
94
138
|
That parameter **is** the one part of this card that needs the database, and only when you ask for
|
|
95
139
|
it. `verdict` is then one of:
|
package/docs/README.md
CHANGED
|
@@ -17,7 +17,7 @@ no document assumes you have read the others.
|
|
|
17
17
|
|---|---|
|
|
18
18
|
| [`CONFIGURATION.md`](CONFIGURATION.md) | Every environment variable. An instance is described entirely by its environment — there is no configuration file, on purpose. |
|
|
19
19
|
| [`MIGRATIONS.md`](MIGRATIONS.md) | What happens to a database **already in service** when the player expects a newer schema. (French.) |
|
|
20
|
-
| [`RETENTION.md`](RETENTION.md) | The declared perimeter of data retention: every personal-data column has a written policy, and CI enforces that the list is complete. Also an export of the package: `require.resolve("discovery-media-player/retention")`.
|
|
20
|
+
| [`RETENTION.md`](RETENTION.md) | The declared perimeter of data retention: every personal-data column has a written policy, and CI enforces that the list is complete. Also an export of the package: `require.resolve("discovery-media-player/retention")`. |
|
|
21
21
|
|
|
22
22
|
## You are contributing, or publishing a version
|
|
23
23
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.129",
|
|
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",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
"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())\"",
|
|
69
69
|
"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",
|
|
70
70
|
"test:watch": "vitest",
|
|
71
|
-
"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",
|
|
72
|
-
"lint:fix": "eslint bin context server src build --fix",
|
|
71
|
+
"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",
|
|
72
|
+
"lint:fix": "eslint bin context server src build tools charge --fix",
|
|
73
73
|
"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",
|
|
74
74
|
"prepublishOnly": "npm run build && npm test",
|
|
75
75
|
"test:e2e": "node -e \"require('fs').existsSync('vitest.e2e.config.mjs')||(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 --config vitest.e2e.config.mjs",
|
|
@@ -83,13 +83,16 @@
|
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@eslint/js": "^10.0.1",
|
|
85
85
|
"axe-core": "^4.13.0",
|
|
86
|
+
"dockerfile-ast": "0.7.1",
|
|
86
87
|
"esbuild": "^0.28.2",
|
|
87
88
|
"eslint": "^10.8.1",
|
|
89
|
+
"fast-check": "4.9.0",
|
|
88
90
|
"jsdom": "^30.0.1",
|
|
89
91
|
"playwright-core": "^1.62.1",
|
|
90
92
|
"typescript": "^5.5.4",
|
|
91
93
|
"typescript-eslint": "^8.46.4",
|
|
92
|
-
"vitest": "^4.1.10"
|
|
94
|
+
"vitest": "^4.1.10",
|
|
95
|
+
"yaml": "2.9.0"
|
|
93
96
|
},
|
|
94
97
|
"dependencies": {
|
|
95
98
|
"pdfjs-dist": "6.2.108"
|
package/server/handler.js
CHANGED
|
@@ -624,6 +624,17 @@ async function handler(req, res) {
|
|
|
624
624
|
presenceDurcissement: (() => {
|
|
625
625
|
try { return require("./presentations").etatDurcissementBootstrap(); } catch { return "inconnu"; }
|
|
626
626
|
})(),
|
|
627
|
+
// ⚠️ LE CHEMIN FUSIONNÉ EST-IL EMPRUNTÉ ? Sans ce champ, un hôte à qui 0019 manque retombe en
|
|
628
|
+
// silence à trois allers-retours par battement : correct, mais deux fois plus cher sur le
|
|
629
|
+
// chemin le plus chaud du produit, et RIEN ne le dirait. Une dégradation qu'on ne peut pas
|
|
630
|
+
// observer est une dégradation qu'on découvre à la facture — ou jamais.
|
|
631
|
+
//
|
|
632
|
+
// ⚠️ Mêmes trois états, même piège : « inconnu » veut dire « personne n'a battu dans ce
|
|
633
|
+
// processus », pas « la migration manque ». Pour la question d'avant-déploiement, c'est
|
|
634
|
+
// `schema.fusionBase` qu'il faut lire — elle, elle interroge la base.
|
|
635
|
+
presenceFusion: (() => {
|
|
636
|
+
try { return require("./presentations").etatFusionBattement(); } catch { return "inconnu"; }
|
|
637
|
+
})(),
|
|
627
638
|
// ⚠️ LES JETONS DE PRÉSENCE SONT-ILS RÉELLEMENT ÉMIS ? Sans ce booléen, un exploitant qui vient
|
|
628
639
|
// de poser `PLAYER_PRESENCE_SECRET` n'a AUCUN moyen de vérifier que son réglage a pris : la
|
|
629
640
|
// carte affiche `presence: {0,0}` aussi bien quand le secret est actif que quand la variable est
|
package/server/presentations.js
CHANGED
|
@@ -839,6 +839,25 @@ function signatureAbsente(erreur) {
|
|
|
839
839
|
// silence. Un « oui » (la signature existe) n'a pas besoin d'expirer : une fonction ne disparaît pas.
|
|
840
840
|
let _bumpSansDurcissementJusqua = 0;
|
|
841
841
|
const MEMO_SANS_DURCISSEMENT_MS = 60 * 1000;
|
|
842
|
+
// ⚠️ CE QUE CE PROCESSUS A CONSTATÉ DU CHEMIN FUSIONNÉ — pas ce qu'il espère. Trois états, comme
|
|
843
|
+
// pour le durcissement, et pour la même raison : « pas dégradé » n'est pas « vérifié ». Un
|
|
844
|
+
// processus qui vient de démarrer n'a rien tenté ; rendre « actif » annoncerait une propriété sur
|
|
845
|
+
// la foi d'une absence d'observation.
|
|
846
|
+
//
|
|
847
|
+
// ⚠️ ET C'EST UN RAPPORT D'EXÉCUTION, PAS UN INVENTAIRE. Il ne se lit pas avant un déploiement : au
|
|
848
|
+
// repos il vaut « inconnu », ce qui veut dire « personne n'a regardé ». La question « la migration
|
|
849
|
+
// est-elle là ? » se pose à la BASE — c'est `schema.fusionBase`, qui la lui pose vraiment.
|
|
850
|
+
let _etatFusion = "inconnu";
|
|
851
|
+
function etatFusionBattement() {
|
|
852
|
+
// ⚠️ MÊME LECTURE QUE `etatDurcissementBootstrap`, DÉLIBÉRÉMENT. Les deux champs partagent leur
|
|
853
|
+
// vocabulaire et se lisent côte à côte dans la carte : leur donner des règles d'expiration
|
|
854
|
+
// différentes serait un piège pour qui les compare. Tant que le mémo court, la dernière
|
|
855
|
+
// observation vaut ; passé lui, une preuve NÉGATIVE périmée retombe sur l'ignorance et jamais sur
|
|
856
|
+
// la confiance. Un « oui » n'expire pas — une fonction ne disparaît pas toute seule.
|
|
857
|
+
if (Date.now() < _bumpSansFusionJusqua) return "degrade";
|
|
858
|
+
return _etatFusion === "degrade" ? "inconnu" : _etatFusion;
|
|
859
|
+
}
|
|
860
|
+
|
|
842
861
|
// ⚠️ MÉMO DU CONTRAT FUSIONNÉ (0019), MÊME PATRON QUE 0018. La fusion est FONCTION-SEULE : aucune
|
|
843
862
|
// colonne à sonder, donc on la DEMANDE et on retient l'échec, sinon un hôte non migré paierait un
|
|
844
863
|
// aller-retour perdu à chaque battement. Soixante secondes : assez pour ne pas insister, assez court
|
|
@@ -879,6 +898,10 @@ function erreurDurcissementAbsent() {
|
|
|
879
898
|
// est là — donc 0018 aussi, elle la précède — et le durcissement est bien celui qu'on a demandé.
|
|
880
899
|
async function appelerBumpFusionne(corps, durcissementVoulu) {
|
|
881
900
|
const reponse = await PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: corps });
|
|
901
|
+
// L'appel est REVENU : le contrat à 13 arguments existe. Ce qui se mesure est le RETOUR, jamais
|
|
902
|
+
// l'intention de partir — et un durcissement demandé qui revient par ici est bien appliqué, 0019
|
|
903
|
+
// succédant à 0018 elle ne peut pas être là sans elle.
|
|
904
|
+
_etatFusion = "actif";
|
|
882
905
|
if (durcissementVoulu) _etatDurcissement = "actif";
|
|
883
906
|
// ⚠️ ON PROJETTE PLUTÔT QUE DE RENDRE LA LIGNE TELLE QUELLE — une garde de ce dépôt l'exige, et
|
|
884
907
|
// elle a raison ici : le jour où la RPC rendra une colonne de plus, elle ne traversera pas cette
|
|
@@ -1033,6 +1056,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1033
1056
|
// 0019 n'est pas appliquée. On arme le mémo et on recommence par le chemin classique, qui
|
|
1034
1057
|
// lira la présentation et décidera du présentateur ici — SANS rien perdre. Rien à signaler à
|
|
1035
1058
|
// l'exploitant : le battement reste exact, il coûte simplement l'aller-retour d'avant.
|
|
1059
|
+
_etatFusion = "degrade";
|
|
1036
1060
|
_bumpSansFusionJusqua = Date.now() + MEMO_SANS_FUSION_MS;
|
|
1037
1061
|
return recordAttendance(slug, participant,
|
|
1038
1062
|
{ presentation, ipHash, anonCap, hasToken, onlyIfUnclaimed, controlHash, sansFusion: true });
|
|
@@ -1350,4 +1374,4 @@ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
|
|
|
1350
1374
|
module.exports = {
|
|
1351
1375
|
reacteurDepuisJeton,
|
|
1352
1376
|
purgerPerimees,
|
|
1353
|
-
messagePublic, CHAMPS_PUBLICS, etatDurcissementBootstrap, signatureAbsente, cheminPieceJointe, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent , STALE_MS};
|
|
1377
|
+
messagePublic, CHAMPS_PUBLICS, etatDurcissementBootstrap, etatFusionBattement, signatureAbsente, cheminPieceJointe, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent , STALE_MS};
|
package/server/schema.js
CHANGED
|
@@ -313,6 +313,11 @@ async function vraimentSonderTout() {
|
|
|
313
313
|
return {
|
|
314
314
|
...etatDuSchema(), verdict: "indetermine",
|
|
315
315
|
durcissementBase: "indetermine", durcissementBaseCouvre: PORTEE_DURCISSEMENT,
|
|
316
|
+
// ⚠️ LE CHAMP DOIT ÊTRE LÀ ICI AUSSI, pour la raison qui a valu son correctif au voisin : un
|
|
317
|
+
// champ ABSENT ne se distingue pas d'un contrat plus ancien, et `undefined !== "applique"`
|
|
318
|
+
// est vrai par accident. Un hôte qui teste avant de déployer mérite un « je ne sais pas »
|
|
319
|
+
// explicite plutôt qu'un silence qui ressemble à un non.
|
|
320
|
+
fusionBase: "indetermine", fusionBaseCouvre: PORTEE_FUSION,
|
|
316
321
|
};
|
|
317
322
|
}
|
|
318
323
|
// ⚠️ LE TÉMOIN VIENT DE RÉPONDRE : tout « non » encore en cache est SUSPECT — il peut dater
|
|
@@ -324,7 +329,7 @@ async function vraimentSonderTout() {
|
|
|
324
329
|
const etat = etatDuSchema();
|
|
325
330
|
await ajouterSansRang(etat);
|
|
326
331
|
await ajouterPresence(etat);
|
|
327
|
-
await
|
|
332
|
+
await ajouterMigrationsDePresence(etat);
|
|
328
333
|
return etat;
|
|
329
334
|
}
|
|
330
335
|
|
|
@@ -378,12 +383,81 @@ const PORTEE_DURCISSEMENT =
|
|
|
378
383
|
+ "C'est ce champ-ci qu'on lit AVANT un déploiement ; « indetermine » = la question n'a pas pu "
|
|
379
384
|
+ "être posée, ce n'est ni un oui ni un non.";
|
|
380
385
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
386
|
+
// ⚠️ MÊME DISTINCTION, POUR 0019 : `fusionBase` est une propriété de la BASE, `presenceFusion` (la
|
|
387
|
+
// carte) est ce que CE processus a constaté en servant des battements. Le second vaut « inconnu » au
|
|
388
|
+
// repos — c'est-à-dire « personne n'a regardé » — et ne peut donc pas servir de contrôle avant
|
|
389
|
+
// déploiement. On a déjà écrit une consigne de pré-vol sur un champ qui refuse de se prononcer sans
|
|
390
|
+
// observation ; celui-ci existe pour qu'on n'ait pas à recommencer.
|
|
391
|
+
const PORTEE_FUSION =
|
|
392
|
+
"propriété de la BASE (migration 0019), globale à toutes les instances — à ne pas confondre avec "
|
|
393
|
+
+ "presenceFusion, qui est ce que CE processus a constaté en servant des battements. Sans 0019, "
|
|
394
|
+
+ "rien ne casse : un battement coûte 3 allers-retours au lieu de 2. « indetermine » = la question "
|
|
395
|
+
+ "n'a pas pu être posée, ce n'est ni un oui ni un non.";
|
|
396
|
+
|
|
397
|
+
// ⚠️ DEUX MIGRATIONS, UNE SEULE SONDE DANS LE CAS NORMAL. 0019 succède à 0018 et son jeu
|
|
398
|
+
// d'arguments CONTIENT le sien : un appel qui passe le contrat à 13 arguments prouve donc les deux
|
|
399
|
+
// d'un coup. On ne repose la question du durcissement que si le contrat long n'existe pas — c'est-
|
|
400
|
+
// à-dire sur un hôte en retard, qui est exactement celui à qui l'on doit une réponse précise.
|
|
401
|
+
//
|
|
402
|
+
// ⚠️ ET LA SONDE N'ÉCRIT TOUJOURS RIEN, POUR DEUX RAISONS INDÉPENDANTES. `p_page: null` sur un slug
|
|
403
|
+
// qui n'existe pas sort par la branche « introuvable » de 0019, avant l'insert ; et `p_anon_cap: 0`
|
|
404
|
+
// sortait déjà par la branche « capped » du contrat précédent. Deux raisons plutôt qu'une, parce
|
|
405
|
+
// qu'une sonde de diagnostic qui écrirait serait le pire endroit où découvrir une régression.
|
|
406
|
+
// ⚠️ UN SEUL ENDROIT QUI PARLE, PARCE QU'IL Y A PLUSIEURS RAISONS DE PARLER. La carte est publique
|
|
407
|
+
// donc appelable en boucle : un journal sans frein deviendrait une arme. Une fois par heure et par
|
|
408
|
+
// motif — et jamais bloquant, un diagnostic qui tombe en panne en diagnostiquant ne diagnostique
|
|
409
|
+
// plus rien.
|
|
410
|
+
async function journaliser(cle, message) {
|
|
411
|
+
try {
|
|
412
|
+
if (await PLAYER.limits.allow(cle, 1, 3600)) PLAYER.errors.capture(new Error(message), { route: "schema" });
|
|
413
|
+
} catch { /* jamais bloquant */ }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function ajouterMigrationsDePresence(etat) {
|
|
417
|
+
const commun = {
|
|
418
|
+
p_slug: SLUG_SONDE_DURCISSEMENT, p_key: "sonde", p_ip_hash: null,
|
|
384
419
|
p_name: "", p_avatar: "", p_is_member: false, p_is_presenter: false,
|
|
385
420
|
p_max_gap_ms: 0, p_anon_cap: 0, p_has_token: null, p_only_if_unclaimed: true,
|
|
386
421
|
};
|
|
422
|
+
const estSignatureAbsente = (erreur) => {
|
|
423
|
+
try { return require("./presentations.js").signatureAbsente(erreur); } catch { return false; }
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
etat.fusionBaseCouvre = PORTEE_FUSION;
|
|
427
|
+
try {
|
|
428
|
+
await PLAYER.db.request("rpc/player_attendance_bump",
|
|
429
|
+
{ method: "POST", body: { ...commun, p_page: null, p_control_hash: null } });
|
|
430
|
+
// Le contrat long a répondu : 0019 est là, donc 0018 aussi — elle la précède et 0019 reprend
|
|
431
|
+
// ses arguments. On ne repose pas une question dont la réponse vient d'être prouvée.
|
|
432
|
+
etat.fusionBase = "applique";
|
|
433
|
+
etat.durcissementBase = "applique";
|
|
434
|
+
etat.durcissementBaseCouvre = PORTEE_DURCISSEMENT;
|
|
435
|
+
return;
|
|
436
|
+
} catch (erreur) {
|
|
437
|
+
if (!estSignatureAbsente(erreur)) {
|
|
438
|
+
// ⚠️ UNE PANNE NE PROUVE RIEN — NI POUR L'UNE NI POUR L'AUTRE. Insister avec un second appel
|
|
439
|
+
// ne ferait que frapper une base déjà en difficulté pour en tirer la même absence de réponse.
|
|
440
|
+
etat.fusionBase = "indetermine";
|
|
441
|
+
etat.durcissementBase = "indetermine";
|
|
442
|
+
etat.durcissementBaseCouvre = PORTEE_DURCISSEMENT;
|
|
443
|
+
await journaliser("schema:sonde-presence-muette",
|
|
444
|
+
"sonde des migrations de présence (0018/0019) sans réponse exploitable : ni confirmées, ni "
|
|
445
|
+
+ "infirmées — " + ((erreur && erreur.message) || erreur));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
etat.fusionBase = "absente";
|
|
449
|
+
// ⚠️ ET ON LE DIT. C'est même toute la raison d'être de cette sonde : sans elle, un hôte à qui
|
|
450
|
+
// 0019 manque paie deux fois le chemin le plus chaud du produit sans que rien ne le signale.
|
|
451
|
+
// Le message dit la dégradation EXACTE — pas « ça ne marche pas », qui n'aide personne à
|
|
452
|
+
// décider si ça vaut une migration.
|
|
453
|
+
await journaliser("schema:fusion-absente",
|
|
454
|
+
"migration 0019-presence-lit-la-presentation.sql ABSENTE : rien ne casse, mais chaque "
|
|
455
|
+
+ "battement de présence coûte 3 allers-retours base au lieu de 2 — soit environ 30 op/s au "
|
|
456
|
+
+ "lieu de 20 pour 250 participants. L'appliquer ne demande aucun redéploiement.");
|
|
457
|
+
// On ne sait toujours rien de 0018 : le contrat court se demande à part, ci-dessous.
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const corps = { ...commun, p_page: 1 };
|
|
387
461
|
try {
|
|
388
462
|
await PLAYER.db.request("rpc/player_attendance_bump", { method: "POST", body: corps });
|
|
389
463
|
etat.durcissementBase = "applique";
|
|
@@ -391,8 +465,7 @@ async function ajouterDurcissement(etat) {
|
|
|
391
465
|
// ⚠️ TROIS ISSUES, ET LA TROISIÈME N'EST PAS UNE DES DEUX AUTRES. Seule la signature absente
|
|
392
466
|
// prouve que 0018 manque ; une panne réseau ou un 500 ne prouvent RIEN, et les compter comme
|
|
393
467
|
// « absente » serait le défaut qu'on a mis trois versions à retirer d'ailleurs.
|
|
394
|
-
|
|
395
|
-
try { absente = require("./presentations.js").signatureAbsente(erreur); } catch { /* module absent */ }
|
|
468
|
+
const absente = estSignatureAbsente(erreur);
|
|
396
469
|
etat.durcissementBase = absente ? "absente" : "indetermine";
|
|
397
470
|
// ⚠️ ET ON LE DIT, PAS SEULEMENT DANS LA CARTE. Une garde de ce dépôt refuse qu'une écriture soit
|
|
398
471
|
// rattrapée en silence, et elle a raison ici pour une raison qu'elle ne pouvait pas connaître :
|
package/server/shares.js
CHANGED
|
@@ -139,12 +139,27 @@ async function logView(share, { event, page, maxPage, seconds, sessionId, ua })
|
|
|
139
139
|
* d'administration) : sans restriction, un commercial verrait à qui d'autre le document a été
|
|
140
140
|
* envoyé — donc les prospects de ses collègues.
|
|
141
141
|
*/
|
|
142
|
+
// ⚠️ UNE SEULE DÉFINITION DE LA FENÊTRE D'ANALYTIQUE, PARCE QU'ELLE ÉTAIT DANS UNE FONCTION SUR DEUX.
|
|
143
|
+
// `overview()` bornait sa lecture à 24 mois glissants ; `listSharesForDoc`, quarante lignes plus haut,
|
|
144
|
+
// lisait TOUT l'historique d'un document. Deux lectures des mêmes tables d'événements, deux règles —
|
|
145
|
+
// et la seconde n'était écrite nulle part : elle se déduisait d'une absence.
|
|
146
|
+
//
|
|
147
|
+
// ⚠️ ET LE BORNAGE EST TEMPOREL, PAS EN NOMBRE DE LIGNES — la mesure l'impose. PostgREST plafonne à
|
|
148
|
+
// 1 000 lignes ici (constaté par un incident, cf. le commentaire d'`overview()` plus bas) : un
|
|
149
|
+
// `limit` inférieur mordrait DÉJÀ sur notre pire document (662 lignes), et un `limit` supérieur
|
|
150
|
+
// serait silencieusement ramené à 1 000 — donc un drapeau « tronqué » calculé sur la longueur
|
|
151
|
+
// MENTIRAIT. C'est `selectAll`, qui pagine par `Range`, qui met à l'abri du plafond ; la fenêtre,
|
|
152
|
+
// elle, borne le volume. Le patron « borné-ordonné-parlant » s'applique donc par sa borne TEMPORELLE.
|
|
153
|
+
const FENETRE_ANALYTIQUE_MOIS = 24;
|
|
154
|
+
const depuisFenetre = () =>
|
|
155
|
+
new Date(Date.now() - FENETRE_ANALYTIQUE_MOIS * 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
156
|
+
|
|
142
157
|
async function listSharesForDoc(docId, owner) {
|
|
143
158
|
const id = enc(String(docId || ""));
|
|
144
159
|
const filtreOwner = owner ? `&created_by=eq.${enc(low(owner))}` : "";
|
|
145
160
|
const [shares, views] = await Promise.all([
|
|
146
161
|
PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&is_test=not.is.true${filtreOwner}&select=*&order=created_at.desc`),
|
|
147
|
-
PLAYER.db.selectAll(`commercial_doc_views?doc_id=eq.${id}&select=slug,event,page,max_page,seconds,session_id,at&order=at.asc`),
|
|
162
|
+
PLAYER.db.selectAll(`commercial_doc_views?doc_id=eq.${id}&select=slug,event,page,max_page,seconds,session_id,at&at=gte.${enc(depuisFenetre())}&order=at.asc`),
|
|
148
163
|
]);
|
|
149
164
|
const shareList = Array.isArray(shares) ? shares : [];
|
|
150
165
|
const viewList = Array.isArray(views) ? views : [];
|
|
@@ -160,7 +175,12 @@ async function listSharesForDoc(docId, owner) {
|
|
|
160
175
|
if (mp > s.maxPage) s.maxPage = mp;
|
|
161
176
|
s.seconds = Math.max(s.seconds, Number(v.seconds) || 0);
|
|
162
177
|
if (v.session_id) s.sessions.add(v.session_id);
|
|
163
|
-
s.lastAt = v.at
|
|
178
|
+
// ⚠️ UN MAXIMUM, PAS « LA DERNIÈRE LIGNE GAGNE ». `s.lastAt = v.at` n'était juste que TANT QUE la
|
|
179
|
+
// requête triait par `at.asc` — un couplage caché entre l'agrégation et l'ORDER BY, à trente
|
|
180
|
+
// lignes de distance. Quiconque aurait inversé le tri (pour garder le récent en cas de coupe)
|
|
181
|
+
// aurait transformé « dernière activité » en « première activité », sans qu'un seul test ne
|
|
182
|
+
// bouge. L'agrégation ne dépend plus de l'ordre : elle le calcule.
|
|
183
|
+
if (!s.lastAt || String(v.at) > String(s.lastAt)) s.lastAt = v.at;
|
|
164
184
|
}
|
|
165
185
|
const enriched = shareList.map((sh) => {
|
|
166
186
|
const a = bySlug.get(sh.slug) || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null };
|
|
@@ -186,7 +206,11 @@ async function listSharesForDoc(docId, owner) {
|
|
|
186
206
|
maxPage: enriched.reduce((m, x) => Math.max(m, x.maxPage), 0),
|
|
187
207
|
readers: reached.length, // sessions distinctes ayant tourné au moins une page
|
|
188
208
|
};
|
|
189
|
-
|
|
209
|
+
// ⚠️ « PARLANT » : la réponse DIT ce qu'elle couvre. Une analytique bornée qui ne l'annonce pas
|
|
210
|
+
// est indiscernable d'une analytique complète — le lecteur y voit des chiffres définitifs. Le champ
|
|
211
|
+
// est présent même quand la fenêtre ne coupe rien (la purge à 13 mois arrive avant), pour que
|
|
212
|
+
// l'appelant n'ait jamais à déduire la couverture de l'ABSENCE d'un drapeau.
|
|
213
|
+
return { shares: enriched, total, funnel, fenetreMois: FENETRE_ANALYTIQUE_MOIS };
|
|
190
214
|
}
|
|
191
215
|
|
|
192
216
|
// Vue d'ensemble (tous documents) : stats agrégées par doc_id, pour les badges de la grille + le « top ».
|
|
@@ -197,7 +221,7 @@ async function overview() {
|
|
|
197
221
|
// Borne glissante généreuse (24 mois) : ces tables d'événements grossissent sans fin ; sans filtre, le
|
|
198
222
|
// scan intégral se dégrade avec le temps. 24 mois couvre tout l'historique utile pour la vue d'ensemble
|
|
199
223
|
// (opens / lecteurs / dernière activité) sans changer les chiffres actuels. Filtre servi par l'index sur `at`.
|
|
200
|
-
const since =
|
|
224
|
+
const since = depuisFenetre();
|
|
201
225
|
const [views, internal] = await Promise.all([
|
|
202
226
|
// PAGINÉ : au-delà de 1 000 lignes, PostgREST tronquait en silence — et comme le tri
|
|
203
227
|
// est ascendant, c'est le RÉCENT qui disparaissait. Les consultations des trois dernières
|
|
@@ -237,7 +261,9 @@ async function overview() {
|
|
|
237
261
|
if (v.event === "open") a.opens++;
|
|
238
262
|
if (v.session_id) a.readers.add(v.session_id);
|
|
239
263
|
a.maxPage = Math.max(a.maxPage, Number(v.page) || 0, Number(v.max_page) || 0);
|
|
240
|
-
|
|
264
|
+
// Même couplage caché que dans `listSharesForDoc`, et corrigé de la même façon : « dernière
|
|
265
|
+
// activité » se calcule, elle ne se déduit pas du tri de la requête.
|
|
266
|
+
if (!a.lastAt || String(v.at) > String(a.lastAt)) a.lastAt = v.at;
|
|
241
267
|
}
|
|
242
268
|
const intByDoc = new Map();
|
|
243
269
|
for (const s of Array.isArray(internal) ? internal : []) {
|