discovery-media-player 0.1.128 → 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.
@@ -76,12 +76,31 @@ The three `presence*` fields report what the host has **observed**, not what it
76
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) |
77
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
78
 
79
- ⚠️ **Before you upgrade, do not read `presenceDurcissement`.** It is a *report of execution*: on an
80
- instance where nothing is running it says `inconnu`, which means *nobody looked* — not *the migration
81
- is there*. A pre-flight check built on it silently passes on every idle host, and the missing
82
- migration is then discovered at the first presentation, i.e. at the worst moment. Ask
83
- `GET /api/doc?contract=1&schema=1` and read **`schema.durcissementBase`** instead: it asks the
84
- database, so it answers a global fact.
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.
85
104
 
86
105
  | `durcissementBase` | meaning |
87
106
  |---|---|
@@ -96,14 +115,20 @@ when the long one is missing — i.e. exactly on the host that is behind and owe
96
115
 
97
116
  | `fusionBase` | meaning |
98
117
  |---|---|
99
- | `applique` | `0019` is in the database — a presence heartbeat costs **2** database round trips |
100
- | `absente` | `0019` is missing: **nothing breaks**, a heartbeat costs **3** round trips instead of 2 (about 30 ops/s instead of 20 for 250 attendees). Applying it needs no redeploy — the player picks it up within a minute |
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 |
101
120
  | `indetermine` | the question could not be asked — neither a yes nor a no |
102
121
 
103
122
  ⚠️ Unlike `0018`, a missing `0019` is a **cost**, not a risk: read it when you are sizing an
104
123
  instance, not when you are deciding whether it is safe to run. A host missing it is also logged once
105
124
  an hour, with the exact figures, so an idle instance still finds out.
106
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
+
107
132
  The probe writes nothing, for **two independent reasons**: `p_page = null` on a slug that does not
108
133
  exist leaves through `0019`'s *introuvable* branch before the insert, and `p_anon_cap = 0` already
109
134
  left through the previous contract's *capped* branch. Two reasons rather than one, because a
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")`. (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")`. |
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.128",
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/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
- return { shares: enriched, total, funnel };
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 = new Date(Date.now() - 24 * 30 * 24 * 60 * 60 * 1000).toISOString();
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
- a.lastAt = v.at;
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 : []) {