discovery-media-player 0.1.56 → 0.1.58
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/context/storage.js +3 -1
- package/docs/HOST-CONTRACT.md +14 -1
- package/package.json +3 -2
- package/server/handler.js +78 -19
- package/server/presentations.js +35 -9
- package/server/schema.js +88 -2
- package/supabase/init.sql +106 -3
- package/supabase/migrations/0004-limites-atomiques.sql +18 -1
- package/supabase/migrations/0005-envoi-unique.sql +44 -0
package/context/storage.js
CHANGED
|
@@ -176,7 +176,9 @@ async function lireDepuis(fh, stat, cible, range) {
|
|
|
176
176
|
const buf = Buffer.alloc(fin - debut + 1);
|
|
177
177
|
const { bytesRead } = await fh.read(buf, 0, buf.length, debut);
|
|
178
178
|
const corps = bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
|
|
179
|
-
|
|
179
|
+
// La taille est connue d'un `stat` sur le descripteur ouvert : l'annoncer permet au relais de
|
|
180
|
+
// la refuser avant d'allouer, et à la visionneuse d'afficher une progression.
|
|
181
|
+
const entetes = { "content-type": type, "content-length": String(corps.length) };
|
|
180
182
|
if (statut === 206) entetes["content-range"] = `bytes ${debut}-${fin}/${total}`;
|
|
181
183
|
return reponse(statut, entetes, corps);
|
|
182
184
|
}
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -31,7 +31,8 @@ need.
|
|
|
31
31
|
"separateIssuer": true,
|
|
32
32
|
"hostShare": true,
|
|
33
33
|
"hostMail": true,
|
|
34
|
-
"plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false }
|
|
34
|
+
"plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false },
|
|
35
|
+
"schema": { "attendues": 3, "sondees": 1, "manquant": [] }
|
|
35
36
|
}
|
|
36
37
|
```
|
|
37
38
|
|
|
@@ -39,6 +40,18 @@ need.
|
|
|
39
40
|
never by order. `plugins` lets you refuse to start when you depend on an optional module this
|
|
40
41
|
instance does not have.
|
|
41
42
|
|
|
43
|
+
⚠️ **`schema` tells you which migrations this instance is still waiting for.** The player never
|
|
44
|
+
applies migrations — it cannot, it only speaks PostgREST — so it *detects* instead, and a missing
|
|
45
|
+
column makes the feature that needs it **degrade silently**, by design, so as not to break a host
|
|
46
|
+
mid-migration. That silence is the point: an operator whose write ordering and message idempotency
|
|
47
|
+
are both switched off sees an instance that looks perfectly healthy. Each entry names the file to
|
|
48
|
+
apply and the feature that is asleep.
|
|
49
|
+
|
|
50
|
+
⚠️ **`sondees` is not decoration.** The card **reports** what this process has already asked; it
|
|
51
|
+
never probes, because a diagnostic must answer when the database does not. A freshly started
|
|
52
|
+
process therefore knows nothing, and `manquant: []` on `sondees: 0` means *no question asked yet*,
|
|
53
|
+
not *nothing missing*. Compare `sondees` with `attendues` before you conclude.
|
|
54
|
+
|
|
42
55
|
⚠️ **`frameAncestors` matters more than it looks.** A host that is not listed will never see the
|
|
43
56
|
viewer: the browser blocks the iframe **before any script runs**, so no message can be emitted and
|
|
44
57
|
the host sees a silence indistinguishable from an unreachable instance. Check that your domain is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.58",
|
|
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",
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"lint:fix": "eslint bin context server src build --fix",
|
|
63
63
|
"typecheck": "tsc --noEmit",
|
|
64
64
|
"prepublishOnly": "npm run build && npm test",
|
|
65
|
-
"test:e2e": "vitest run --config vitest.e2e.config.mjs"
|
|
65
|
+
"test:e2e": "vitest run --config vitest.e2e.config.mjs",
|
|
66
|
+
"test:base": "vitest run --config vitest.base.config.mjs"
|
|
66
67
|
},
|
|
67
68
|
"engines": {
|
|
68
69
|
"node": ">=22"
|
package/server/handler.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// - GET /doc/:slug?file=1 → stream le PDF depuis le Storage (MÊME ORIGINE → pas de souci CORS pour pdf.js)
|
|
5
5
|
// - POST /api/doc {slug,event…}→ journalise un événement (open / page / heartbeat) — best-effort
|
|
6
6
|
const crypto = require("crypto");
|
|
7
|
+
const { Readable } = require("node:stream");
|
|
8
|
+
const { pipeline } = require("node:stream/promises");
|
|
7
9
|
const { getShareBySlug, logView, upsertSession, createReshare, sendReshareEmail, upsertInternalSession,
|
|
8
10
|
createShare, revokeShare, setShareAuth, overview: docOverview, listSharesForDoc, listSessionsForDoc, internalStatsForDoc } = require("./shares");
|
|
9
11
|
const { SESSION_QUOTA_PER_HOUR, PRESENT_QUOTA_PER_HOUR, PRESENT_CACHE_MS } = require("./shared.generated.js");
|
|
@@ -436,7 +438,12 @@ var Live=(function(){
|
|
|
436
438
|
function setReply(id){var m=msgData[id];if(!m||m.deleted)return;var nm=m.author_name||'Invité';replyCtx={id:+id,name:nm,text:(m.body||'').slice(0,120)};var el=document.getElementById('chatReply');if(el){el.style.display='flex';el.innerHTML='<span class=cq><b>'+esc(nm)+'</b> '+esc((m.body||'').slice(0,80))+'</span><button id=chatReplyX title=Annuler>×</button>';var x=document.getElementById('chatReplyX');if(x)x.addEventListener('click',clearReply);}var t=document.getElementById('chatText');if(t)t.focus();}
|
|
437
439
|
function clearReply(){replyCtx=null;var el=document.getElementById('chatReply');if(el){el.style.display='none';el.innerHTML='';}}
|
|
438
440
|
function send(){var i=document.getElementById('chatText');var t=(i.value||'').trim();if(!t||!ME)return;if(LOCKED&&!canMod())return;i.value='';toggleSend();
|
|
439
|
-
|
|
441
|
+
|
|
442
|
+
// ⚠️ LA CLÉ EST FABRIQUÉE ICI, UNE FOIS, AVANT LE PREMIER ENVOI. Une clé tirée à chaque
|
|
443
|
+
// tentative ne servirait à rien : deux envois porteraient deux clés et passeraient tous les
|
|
444
|
+
// deux. C'est sa RÉUTILISATION au renvoi qui rend l'opération idempotente.
|
|
445
|
+
var _cle=(function(){ try{ var r=crypto.getRandomValues(new Uint8Array(12)); return Array.from(r,function(x){return x.toString(16).padStart(2,'0');}).join(''); }catch(e){ return ''; } })();
|
|
446
|
+
var o={action:'present-chat',clientKey:_cle,slug:SLUG,name:ME.name,email:ME.email,avatar:ME.avatar,body:t,authorToken:authToken()};
|
|
440
447
|
if(CONTROL)o.control=CONTROL;
|
|
441
448
|
if(replyCtx){o.replyTo=replyCtx.id;o.replyName=replyCtx.name;o.replyText=replyCtx.text;clearReply();}
|
|
442
449
|
var h1={'Content-Type':'application/json'};var j1=accessToken();if(j1)h1.Authorization='Bearer '+j1;
|
|
@@ -1558,24 +1565,28 @@ async function relayerFichier(res, r, disposition) {
|
|
|
1558
1565
|
const compresse = !!r.headers.get("content-encoding");
|
|
1559
1566
|
if (compresse && r.status === 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
|
|
1560
1567
|
|
|
1561
|
-
// ⚠️
|
|
1562
|
-
// sans borne : un PDF de 80 Mo, trois requêtes Range simultanées, et une fonction serverless
|
|
1563
|
-
// tombe — pas pour un document en particulier, pour la somme. Le défaut n'est pas la taille d'un
|
|
1564
|
-
// fichier, c'est l'absence de toute limite haute.
|
|
1568
|
+
// ⚠️ DEUX BORNES, ET LA SECONDE EST LA SEULE QUI TIENNE DEVANT UN AMONT QUI MENT.
|
|
1565
1569
|
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
|
|
1570
|
+
// La première regarde `Content-Length` et renonce AVANT d'ouvrir le corps. C'est la seule qui
|
|
1571
|
+
// puisse encore répondre 413, puisque rien n'est parti — mais elle croit l'amont sur parole : un
|
|
1572
|
+
// stockage qui n'annonce rien, ou qui annonce 1 Ko et en envoie 500, passait sans être inquiété.
|
|
1573
|
+
//
|
|
1574
|
+
// La seconde COMPTE LES OCTETS QUI PASSENT et rompt au dépassement. Elle ne peut plus répondre
|
|
1575
|
+
// 413 : les en-têtes sont partis avec le premier octet, et on ne dédit pas un en-tête déjà
|
|
1576
|
+
// envoyé. Elle coupe. Le client voit un transfert interrompu — désagréable et honnête, là où
|
|
1577
|
+
// l'épuisement de la mémoire emportait la fonction ENTIÈRE, donc aussi les requêtes des autres.
|
|
1578
|
+
const brute = r.headers.get("content-length");
|
|
1579
|
+
const annoncee = Number(brute || 0);
|
|
1572
1580
|
if (annoncee > PLAFOND_RELAIS) {
|
|
1573
1581
|
try { PLAYER.errors.capture(new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
|
|
1574
1582
|
res.statusCode = 413;
|
|
1575
1583
|
res.end("Fichier trop volumineux");
|
|
1584
|
+
// ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
|
|
1585
|
+
// pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
|
|
1586
|
+
try { if (r.body) r.body.cancel(); } catch { /* déjà refermé */ }
|
|
1576
1587
|
return;
|
|
1577
1588
|
}
|
|
1578
|
-
|
|
1589
|
+
|
|
1579
1590
|
res.statusCode = r.status;
|
|
1580
1591
|
const typeAmont = r.headers.get("content-type") || "application/pdf";
|
|
1581
1592
|
const executable = TYPES_EXECUTABLES.test(typeAmont);
|
|
@@ -1591,10 +1602,53 @@ async function relayerFichier(res, r, disposition) {
|
|
|
1591
1602
|
// Les bornes d'un `Content-Range` ne valent que si l'amont n'a pas compressé.
|
|
1592
1603
|
const cr = !compresse && r.headers.get("content-range");
|
|
1593
1604
|
if (cr) res.setHeader("Content-Range", cr);
|
|
1594
|
-
|
|
1605
|
+
// ⚠️ `fetch` DÉCOMPRESSE DE LUI-MÊME, et c'est le piège du flux. Sur un amont gzip,
|
|
1606
|
+
// `Content-Length` compte les octets COMPRIMÉS alors que nous relayons les octets déployés :
|
|
1607
|
+
// le recopier ferait attendre au client des octets qui ne viendront jamais, ou lui ferait couper
|
|
1608
|
+
// le document au milieu. La bufferisation nous rendait ce service sans qu'on le demande —
|
|
1609
|
+
// `buf.length` était toujours juste. En flux, il faut savoir se taire ; voir plus bas.
|
|
1595
1610
|
if (disposition) res.setHeader("Content-Disposition", disposition);
|
|
1596
1611
|
res.setHeader("Cache-Control", "private, max-age=600");
|
|
1597
|
-
|
|
1612
|
+
|
|
1613
|
+
// ⚠️ UN AMONT SANS CORPS LISIBLE N'EST PAS UNE ANOMALIE, C'EST LE CONTRAT. `storage.fetchFile`
|
|
1614
|
+
// est une capacité de l'HÔTE : il rend ce qu'il veut, du moment qu'il sait dire `arrayBuffer()`.
|
|
1615
|
+
// Le chemin fichier local du mode autonome, lui, ne rend rien d'autre. Traiter cette absence
|
|
1616
|
+
// comme « rien à envoyer » servait des fichiers VIDES, sans une erreur pour le dire — un défaut
|
|
1617
|
+
// pire que celui qu'on ferme ici, et que seuls deux essais existants ont vu tomber.
|
|
1618
|
+
//
|
|
1619
|
+
// Ici la borne du flux ne peut rien : `arrayBuffer()` a déjà tout alloué quand on pourrait
|
|
1620
|
+
// compter. Seule la taille annoncée protège — ce qui suffit, parce qu'un hôte qui rend un corps
|
|
1621
|
+
// en un bloc l'a lu depuis quelque chose dont il connaît la taille.
|
|
1622
|
+
if (!r.body) {
|
|
1623
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
1624
|
+
res.setHeader("Content-Length", String(buf.length)); // connue, donc annoncée
|
|
1625
|
+
res.end(buf);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// ⚠️ ON N'ANNONCE UNE LONGUEUR QUE QUAND ON SAIT QU'ELLE DÉCRIT CE QU'ON ENVOIE — et en flux,
|
|
1630
|
+
// la seule qu'on ait est celle de l'amont. Sans encodage elle est exacte : on la garde, elle
|
|
1631
|
+
// vaut une barre de progression. Avec encodage on se tait (cf. plus haut), et la fin du corps
|
|
1632
|
+
// fait foi. Sans annonce amont, on se tait aussi : c'est le prix du flux, payé en connaissance.
|
|
1633
|
+
if (!compresse && brute) res.setHeader("Content-Length", brute);
|
|
1634
|
+
|
|
1635
|
+
const plafond = PLAFOND_RELAIS;
|
|
1636
|
+
try {
|
|
1637
|
+
await pipeline(Readable.fromWeb(r.body), async function* (source) {
|
|
1638
|
+
let vus = 0;
|
|
1639
|
+
for await (const morceau of source) {
|
|
1640
|
+
vus += morceau.length;
|
|
1641
|
+
if (vus > plafond) throw new Error(`relais interrompu : ${vus} octets reçus, plafond ${plafond}`);
|
|
1642
|
+
yield morceau;
|
|
1643
|
+
}
|
|
1644
|
+
}, res);
|
|
1645
|
+
} catch (erreur) {
|
|
1646
|
+
// ⚠️ ROMPRE, PAS RÉPONDRE — et le DIRE. Aucun code de retour n'est plus disponible ; ne
|
|
1647
|
+
// reste que la coupure. Une coupure fréquente ici est un plafond mal réglé ou un amont
|
|
1648
|
+
// défaillant : l'avaler ferait passer un défaut d'exploitation pour un caprice du réseau.
|
|
1649
|
+
try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "relais" }); } catch { /* jamais bloquant */ }
|
|
1650
|
+
try { res.destroy(); } catch { /* le socket est peut-être déjà parti */ }
|
|
1651
|
+
}
|
|
1598
1652
|
}
|
|
1599
1653
|
|
|
1600
1654
|
// ⚠️ UNE MENTION DONT L'OBJET EST D'ÊTRE EXACTE NE DOIT PAS INVENTER UN EXPÉDITEUR.
|
|
@@ -3309,7 +3363,7 @@ async function handler(req, res) {
|
|
|
3309
3363
|
name: (profil && profil.name) || body.name,
|
|
3310
3364
|
email: profil ? profil.email : body.email,
|
|
3311
3365
|
avatar: (profil && profil.avatar) || body.avatar,
|
|
3312
|
-
isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment });
|
|
3366
|
+
isPresenter: validControl, isMember: !!profil, body: body.body, replyTo: body.replyTo, replyName: body.replyName, replyText: body.replyText, authorToken: body.authorToken, attachment: body.attachment , clientKey: body.clientKey });
|
|
3313
3367
|
return jp(r.ok ? 200 : (r.status || 400), r);
|
|
3314
3368
|
} catch { return jp(500, { ok: false }); }
|
|
3315
3369
|
}
|
|
@@ -3422,10 +3476,7 @@ async function handler(req, res) {
|
|
|
3422
3476
|
// capable d'expédier en son nom : le repli silencieux ouvrirait exactement la porte que
|
|
3423
3477
|
// la séparation ferme. On nomme le fichier à appliquer et on s'arrête.
|
|
3424
3478
|
if (atteste) {
|
|
3425
|
-
const pret = await require("./schema").
|
|
3426
|
-
"commercial_doc_shares", "attested_recipient_email",
|
|
3427
|
-
"supabase/migrations/0001-destinataire-atteste.sql",
|
|
3428
|
-
);
|
|
3479
|
+
const pret = await require("./schema").attendue("destinataireAtteste");
|
|
3429
3480
|
if (!pret) {
|
|
3430
3481
|
return jd(409, { ok: false, error: "migration", message: "Destinataire attesté indisponible : appliquez supabase/migrations/0001-destinataire-atteste.sql." });
|
|
3431
3482
|
}
|
|
@@ -3770,6 +3821,14 @@ async function handler(req, res) {
|
|
|
3770
3821
|
// l'hôte connaît déjà son émetteur — il veut seulement savoir si l'instance le regarde.
|
|
3771
3822
|
// Dire lequel n'aiderait personne et renseignerait qui sonde.
|
|
3772
3823
|
separateIssuer: !!(PLAYER.config && PLAYER.config.separateIssuer),
|
|
3824
|
+
// ⚠️ L'ÉTAT DU SCHÉMA, LÀ OÙ ON REGARDE. Une colonne absente était signalée par un
|
|
3825
|
+
// `console.warn`, une fois par processus : sur une fonction serverless, une ligne perdue
|
|
3826
|
+
// dans une sortie que personne n'ouvre tant que tout a l'air de marcher — et « tout a
|
|
3827
|
+
// l'air de marcher » est exactement l'état d'un hôte dont trois protections dorment. Cette
|
|
3828
|
+
// carte est ce qu'un hôte interroge déjà pour épingler sa version ; c'est donc ici.
|
|
3829
|
+
// Elle RAPPORTE ce qui est connu, elle ne sonde pas : un diagnostic ne doit pas tomber en
|
|
3830
|
+
// même temps que ce qu'il diagnostique.
|
|
3831
|
+
schema: require("./schema").etatDuSchema(),
|
|
3773
3832
|
// « L'hôte peut-il créer un lien en son nom propre ? » — configuré, pas seulement
|
|
3774
3833
|
// possible. Un hôte qui oublie le secret reçoit un 401 qui ressemble à un droit
|
|
3775
3834
|
// manquant ; ce booléen le lui dit sans qu'il ait à essayer.
|
package/server/presentations.js
CHANGED
|
@@ -54,9 +54,7 @@ async function reclaimPresentation(slug, email) {
|
|
|
54
54
|
// ⚠️ Et j'ai écrit ce champ sans condition en premier jet, ce qui est exactement le piège que
|
|
55
55
|
// docs/MIGRATIONS.md décrit : PostgREST rejette le PATCH ENTIER si la colonne manque. La reprise
|
|
56
56
|
// aurait cessé de fonctionner chez tout hôte non migré — pas la nouvelle garantie, la reprise.
|
|
57
|
-
const rangDispo = await require("./schema").
|
|
58
|
-
"doc_presentations", "write_seq", "supabase/migrations/0002-ordre-des-ecritures.sql",
|
|
59
|
-
);
|
|
57
|
+
const rangDispo = await require("./schema").attendue("rangEcriture");
|
|
60
58
|
await PLAYER.db.request(`doc_presentations?slug=eq.${enc(slug)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { control_hash: sha(control), active: true, ...(rangDispo ? { write_seq: 0 } : {}), last_seen: new Date().toISOString(), updated_at: new Date().toISOString() } });
|
|
61
59
|
return { ok: true, slug, control, page: row.current_page || 1, fileUrl: row.file_url, fileName: row.file_name, docTitle: row.doc_title, docId: row.doc_id };
|
|
62
60
|
}
|
|
@@ -200,9 +198,7 @@ async function getPresentation(slug) {
|
|
|
200
198
|
async function rangAccepte(row, seq) {
|
|
201
199
|
const rang = Number(seq);
|
|
202
200
|
if (!Number.isFinite(rang) || rang <= 0) return { controle: false }; // client plus ancien : pas de rang
|
|
203
|
-
const dispo = await require("./schema").
|
|
204
|
-
"doc_presentations", "write_seq", "supabase/migrations/0002-ordre-des-ecritures.sql",
|
|
205
|
-
);
|
|
201
|
+
const dispo = await require("./schema").attendue("rangEcriture");
|
|
206
202
|
if (!dispo) return { controle: false };
|
|
207
203
|
if (rang <= Number(row.write_seq || 0)) return { controle: true, perime: true };
|
|
208
204
|
// ⚠️ ON REND LE RANG, PAS UN OBJET TOUT FAIT. La première version rendait « { write_seq: rang } »,
|
|
@@ -403,7 +399,7 @@ function premierPublic(reponse) {
|
|
|
403
399
|
return messagePublic(row);
|
|
404
400
|
}
|
|
405
401
|
|
|
406
|
-
async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment }) {
|
|
402
|
+
async function addMessage(slug, { name, email, avatar, isPresenter, isMember, body, replyTo, replyName, replyText, authorToken, attachment, clientKey }) {
|
|
407
403
|
if (await estArchive(slug)) return REFUS_ARCHIVE;
|
|
408
404
|
const b = String(body || "").trim().slice(0, 2000);
|
|
409
405
|
// ⚠️ Pas de normalisation ici : `config.supabaseUrl` arrive SANS barre finale, c'est le
|
|
@@ -424,10 +420,40 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
|
|
|
424
420
|
author_hash: authorToken ? sha(authorToken) : null,
|
|
425
421
|
reply_to: rt, reply_name: rt ? ((replyName || "").slice(0, 80) || null) : null, reply_text: rt ? ((replyText || "").slice(0, 140) || null) : null,
|
|
426
422
|
};
|
|
423
|
+
// ⚠️ LA CLÉ N'EST ÉCRITE QUE SI LA COLONNE EXISTE. PostgREST rejette le POST ENTIER sur une
|
|
424
|
+
// colonne inconnue : chez un hôte non migré, ce n'est pas l'idempotence qu'on perdrait, c'est
|
|
425
|
+
// l'envoi de messages. Même piège que le rang d'écriture, même sonde.
|
|
426
|
+
const cle = String(clientKey || "").slice(0, 80);
|
|
427
|
+
if (cle && await require("./schema").attendue("envoiUnique")) {
|
|
428
|
+
row.client_key = cle;
|
|
429
|
+
}
|
|
430
|
+
|
|
427
431
|
// `return=representation` : c'est la ligne écrite qui part ensuite en diffusion vers l'audience.
|
|
428
432
|
// Sans elle, l'émetteur devrait deviner l'`id` et la date attribués par la base.
|
|
429
|
-
|
|
430
|
-
|
|
433
|
+
// ⚠️ UN REFUS D'UNICITÉ N'EST PAS UNE ERREUR, C'EST UNE CONFIRMATION. La contrainte dit « ce
|
|
434
|
+
// message est déjà là » : le remonter au participant remplacerait « deux messages » par « une
|
|
435
|
+
// erreur », ce qui n'est pas mieux. On relit donc la ligne déjà écrite et on la rend comme si
|
|
436
|
+
// l'envoi venait de réussir — ce qu'il a fait, la première fois.
|
|
437
|
+
try {
|
|
438
|
+
const cree = await PLAYER.db.request("doc_presentation_messages?select=*", { method: "POST", headers: { Prefer: "return=representation" }, body: [row] });
|
|
439
|
+
return { ok: true, message: premierPublic(cree) };
|
|
440
|
+
} catch (erreur) {
|
|
441
|
+
// ⚠️ LA GARDE DES ÉCRITURES MUETTES A REFUSÉ CE BLOC, et elle avait raison de le faire : un
|
|
442
|
+
// `try` autour d'une écriture doit dire quelque chose. Ici le silence était volontaire — un 409
|
|
443
|
+
// attendu n'est pas un incident — mais rien ne distinguait « je sais ce que je rattrape » de
|
|
444
|
+
// « j'avale tout ». On le dit donc : ce qui n'est pas le conflit attendu remonte, et le conflit
|
|
445
|
+
// attendu est journalisé une fois, en clair, parce qu'un renvoi fréquent est une information.
|
|
446
|
+
const conflit = cle && String((erreur && erreur.message) || "").includes("409");
|
|
447
|
+
if (!conflit) throw erreur;
|
|
448
|
+
try { PLAYER.errors.capture(new Error("message déjà enregistré (renvoi) : " + String(slug)), { route: "present-chat", benin: true }); } catch { /* jamais bloquant */ }
|
|
449
|
+
const deja = await PLAYER.db.request(
|
|
450
|
+
`doc_presentation_messages?slug=eq.${enc(String(slug))}&client_key=eq.${enc(cle)}&select=*&limit=1`);
|
|
451
|
+
const ligne = Array.isArray(deja) && deja[0];
|
|
452
|
+
// ⚠️ Si la relecture ne trouve rien, on ne prétend pas : le 409 venait d'autre chose, et le
|
|
453
|
+
// taire ferait croire à un envoi réussi qui n'a pas eu lieu.
|
|
454
|
+
if (!ligne) throw erreur;
|
|
455
|
+
return { ok: true, message: messagePublic(ligne), deja: true };
|
|
456
|
+
}
|
|
431
457
|
}
|
|
432
458
|
|
|
433
459
|
/**
|
package/server/schema.js
CHANGED
|
@@ -16,6 +16,34 @@
|
|
|
16
16
|
// chantiers ont été repoussés pour cette seule raison. Avec cette sonde, l'ordre de déploiement
|
|
17
17
|
// cesse d'être un piège.
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* CE QUE CE CODE ATTEND DE LA BASE — DÉCLARÉ UNE FOIS, ET C'EST LA SOURCE.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ UN INVENTAIRE QUI N'EST PAS LA SOURCE DÉRIVE. Ces couples vivaient recopiés sur quatre
|
|
23
|
+
* appels ; en tirer une simple liste « pour l'affichage » aurait refait, en plus petit, le défaut
|
|
24
|
+
* qui a vidé supabase/init.sql de ses cinq migrations : deux exemplaires du même fait, personne
|
|
25
|
+
* pour les confronter. Les appelants passent donc par `attendue(nom)` et ne nomment plus de
|
|
26
|
+
* colonne — il n'existe plus qu'un endroit où se tromper. Une étape de la forge vérifie en outre
|
|
27
|
+
* que chaque fichier nommé ici existe, et qu'aucun appel ne contourne cette table.
|
|
28
|
+
*/
|
|
29
|
+
const ATTENDUES = {
|
|
30
|
+
destinataireAtteste: {
|
|
31
|
+
table: "commercial_doc_shares", colonne: "attested_recipient_email",
|
|
32
|
+
migration: "supabase/migrations/0001-destinataire-atteste.sql",
|
|
33
|
+
fonction: "attribuer une lecture au destinataire attesté par l'hôte",
|
|
34
|
+
},
|
|
35
|
+
rangEcriture: {
|
|
36
|
+
table: "doc_presentations", colonne: "write_seq",
|
|
37
|
+
migration: "supabase/migrations/0002-ordre-des-ecritures.sql",
|
|
38
|
+
fonction: "refuser une écriture de pilotage doublée en vol",
|
|
39
|
+
},
|
|
40
|
+
envoiUnique: {
|
|
41
|
+
table: "doc_presentation_messages", colonne: "client_key",
|
|
42
|
+
migration: "supabase/migrations/0005-envoi-unique.sql",
|
|
43
|
+
fonction: "empêcher qu'un renvoi crée un second message",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
19
47
|
let PLAYER = null;
|
|
20
48
|
/**
|
|
21
49
|
* Une question posée une fois, retenue pour le processus.
|
|
@@ -28,9 +56,59 @@ let PLAYER = null;
|
|
|
28
56
|
*/
|
|
29
57
|
const connues = new Map();
|
|
30
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Les réponses DÉJÀ OBTENUES, pour qui veut les lire — la carte d'identité, essentiellement.
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ CE N'EST PAS UN DOUBLON DE `connues`. Celle-ci porte des promesses, dont on ne peut rien dire
|
|
63
|
+
* sans les attendre ; celle-là porte des réponses. La distinction compte parce que la carte
|
|
64
|
+
* d'identité ne doit RIEN demander à la base — elle doit répondre quand la base ne répond plus.
|
|
65
|
+
*/
|
|
66
|
+
const reponses = new Map();
|
|
67
|
+
|
|
31
68
|
function init(ctx) {
|
|
32
69
|
PLAYER = ctx;
|
|
33
70
|
connues.clear();
|
|
71
|
+
reponses.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** La sonde d'une attente déclarée. C'est la seule forme d'appel que les appelants utilisent. */
|
|
75
|
+
function attendue(nom) {
|
|
76
|
+
const a = ATTENDUES[nom];
|
|
77
|
+
// Un nom inconnu est une faute de frappe, pas une dégradation : la taire ferait passer la
|
|
78
|
+
// fonction pour « en attente de migration » alors qu'elle est simplement mal câblée.
|
|
79
|
+
if (!a) throw new Error(`attente de schéma inconnue : ${nom}`);
|
|
80
|
+
return aLaColonne(a.table, a.colonne, a.migration);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ⚠️ CE QUE LE JOURNAL NE DIRA JAMAIS À PERSONNE.
|
|
85
|
+
*
|
|
86
|
+
* La sonde signale une colonne absente par un `console.warn`, une fois par processus. Sur une
|
|
87
|
+
* fonction serverless, c'est une ligne perdue dans une sortie que personne n'ouvre quand tout a
|
|
88
|
+
* l'air de marcher — et « tout a l'air de marcher » est précisément l'état d'un hôte dont trois
|
|
89
|
+
* protections dorment. Remarque du second hôte, et elle est juste : la trace existait à l'endroit
|
|
90
|
+
* exact où on ne regarde pas.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ ON NE SONDE PAS ICI, ON RAPPORTE. La carte d'identité doit répondre quand la base ne répond
|
|
93
|
+
* plus ; sonder depuis elle en ferait un diagnostic qui tombe en même temps que ce qu'il diagnostique.
|
|
94
|
+
*
|
|
95
|
+
* ⚠️ D'OÙ TROIS ÉTATS, ET PAS DEUX. Un processus qui n'a encore rien demandé ne sait rien — et
|
|
96
|
+
* « rien de manquant » se lirait « tout va bien ». Une absence de résultat ressemble à un
|
|
97
|
+
* résultat ; `sondees` est là pour qu'on ne puisse pas les confondre.
|
|
98
|
+
*/
|
|
99
|
+
function etatDuSchema() {
|
|
100
|
+
const manquant = [];
|
|
101
|
+
for (const [, r] of reponses) if (!r.present) manquant.push({ migration: r.migration, fonction: r.fonction });
|
|
102
|
+
return {
|
|
103
|
+
attendues: Object.keys(ATTENDUES).length,
|
|
104
|
+
sondees: reponses.size,
|
|
105
|
+
// ⚠️ ON NOMME LE FICHIER, alors que cette route est publique. Même raison que `frameAncestors`
|
|
106
|
+
// juste au-dessus d'elle : l'exploitant n'a AUCUN autre moyen d'apprendre laquelle manque, et
|
|
107
|
+
// un compte nu le laisserait deviner. Ce qu'on révèle en échange — qu'une fonction de
|
|
108
|
+
// fiabilité est en attente, dans un dépôt dont les migrations sont publiques — n'ouvre aucun
|
|
109
|
+
// accès : il faut déjà détenir un jeton de pilotage pour tirer parti d'un rang absent.
|
|
110
|
+
manquant,
|
|
111
|
+
};
|
|
34
112
|
}
|
|
35
113
|
|
|
36
114
|
/**
|
|
@@ -67,6 +145,7 @@ async function sonder(table, colonne, migration, cle) {
|
|
|
67
145
|
// portage a un fichier à réécrire, pas une habitude à retrouver partout.
|
|
68
146
|
const champ = encodeURIComponent(colonne);
|
|
69
147
|
await PLAYER.db.request(`${table}?select=${champ}&limit=0`);
|
|
148
|
+
noter(cle, true, migration);
|
|
70
149
|
return true;
|
|
71
150
|
} catch {
|
|
72
151
|
// ⚠️ ON NE DISTINGUE PAS « COLONNE ABSENTE » DE « BASE INJOIGNABLE », ET C'EST VOULU. Les deux
|
|
@@ -74,11 +153,18 @@ async function sonder(table, colonne, migration, cle) {
|
|
|
74
153
|
// message d'erreur, c'est-à-dire de dépendre du texte d'un service tiers. Ce qui change entre
|
|
75
154
|
// les deux, c'est la durée : une base injoignable le redevient, et le processus suivant reposera
|
|
76
155
|
// la question.
|
|
156
|
+
noter(cle, false, migration);
|
|
77
157
|
signaler(cle, migration);
|
|
78
158
|
return false;
|
|
79
159
|
}
|
|
80
160
|
}
|
|
81
161
|
|
|
162
|
+
/** La réponse, retenue pour qui la demandera — sans repasser par la base. */
|
|
163
|
+
function noter(cle, present, migration) {
|
|
164
|
+
const a = Object.values(ATTENDUES).find((x) => `${x.table}.${x.colonne}` === cle);
|
|
165
|
+
reponses.set(cle, { present, migration, fonction: a ? a.fonction : "" });
|
|
166
|
+
}
|
|
167
|
+
|
|
82
168
|
/**
|
|
83
169
|
* ⚠️ ON NOMME LE FICHIER, PAS L'ERREUR. « column does not exist » envoie l'exploitant lire du
|
|
84
170
|
* PostgREST ; « appliquez supabase/migrations/0001-…sql » lui dit quoi faire. La différence entre
|
|
@@ -92,6 +178,6 @@ function signaler(cle, migration) {
|
|
|
92
178
|
}
|
|
93
179
|
|
|
94
180
|
/** Pour les tests et l'exploitation : reposer la question. */
|
|
95
|
-
function oublier() { connues.clear(); }
|
|
181
|
+
function oublier() { connues.clear(); reponses.clear(); }
|
|
96
182
|
|
|
97
|
-
module.exports = { init, aLaColonne, oublier };
|
|
183
|
+
module.exports = { init, aLaColonne, attendue, etatDuSchema, oublier, ATTENDUES };
|
package/supabase/init.sql
CHANGED
|
@@ -50,7 +50,10 @@ create table if not exists public.commercial_doc_shares (
|
|
|
50
50
|
brand_logo text, -- logo RECOPIÉ (flux historiques, toujours accepté)
|
|
51
51
|
brand_dark boolean not null default false,
|
|
52
52
|
require_auth boolean not null default false,
|
|
53
|
-
brand_key text
|
|
53
|
+
brand_key text, -- ⚠️ RÉFÉRENCE résolue à l'affichage (branding.forKey)
|
|
54
|
+
-- Destinataire attesté par l'hôte : sert à ATTRIBUER une lecture, jamais à expédier en son nom.
|
|
55
|
+
-- `recipient_email`, elle, dit qui peut expédier — vide quand personne ne le peut.
|
|
56
|
+
attested_recipient_email text
|
|
54
57
|
);
|
|
55
58
|
create index if not exists cds_doc_id_idx on public.commercial_doc_shares (doc_id);
|
|
56
59
|
create index if not exists cds_parent_idx on public.commercial_doc_shares (parent_slug);
|
|
@@ -139,7 +142,11 @@ create table if not exists public.doc_presentations (
|
|
|
139
142
|
owner_avatar text,
|
|
140
143
|
owner_email text,
|
|
141
144
|
last_seen timestamptz not null default now(),
|
|
142
|
-
content jsonb
|
|
145
|
+
content jsonb, -- carte / Street View (revalidé à la réception)
|
|
146
|
+
-- Rang de la dernière écriture de pilotage acceptée. Une écriture de rang inférieur ou égal est
|
|
147
|
+
-- refusée : elle a été doublée en vol. Remis à zéro par toute émission d'un jeton de contrôle
|
|
148
|
+
-- (démarrage, reprise), qui ouvre un nouveau domaine d'ordre.
|
|
149
|
+
write_seq bigint not null default 0
|
|
143
150
|
);
|
|
144
151
|
create index if not exists doc_presentations_active_idx on public.doc_presentations (active, updated_at);
|
|
145
152
|
create index if not exists doc_presentations_last_seen_idx on public.doc_presentations (active, last_seen);
|
|
@@ -163,9 +170,17 @@ create table if not exists public.doc_presentation_messages (
|
|
|
163
170
|
reply_text text,
|
|
164
171
|
deleted boolean not null default false,
|
|
165
172
|
edited boolean not null default false,
|
|
166
|
-
attachment jsonb
|
|
173
|
+
attachment jsonb,
|
|
174
|
+
-- Clé d'idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi :
|
|
175
|
+
-- un renvoi réseau ne crée pas un second message. Nulle si le client ne la fournit pas.
|
|
176
|
+
client_key text
|
|
167
177
|
);
|
|
168
178
|
create index if not exists dpm_slug_idx on public.doc_presentation_messages (slug, created_at);
|
|
179
|
+
-- ⚠️ Portée sur (slug, client_key), pas sur la clé seule : deux présentations n'ont aucune raison
|
|
180
|
+
-- de partager un espace de clés. Partiel, pour que les lignes sans clé ne se gênent pas entre elles.
|
|
181
|
+
create unique index if not exists dpm_client_key_uniq
|
|
182
|
+
on public.doc_presentation_messages (slug, client_key)
|
|
183
|
+
where client_key is not null;
|
|
169
184
|
|
|
170
185
|
create table if not exists public.doc_presentation_attendees (
|
|
171
186
|
slug text not null,
|
|
@@ -209,6 +224,74 @@ create table if not exists public.doc_bot_sessions (
|
|
|
209
224
|
);
|
|
210
225
|
create index if not exists doc_bot_sessions_share_idx on public.doc_bot_sessions (share_slug);
|
|
211
226
|
|
|
227
|
+
-- ── Limites de débit partagées ─────────────────────────────────────────────────────────────────
|
|
228
|
+
-- ⚠️ EN MÉMOIRE, UNE LIMITE NE LIMITE RIEN. Chaque instance serverless a la sienne : N instances
|
|
229
|
+
-- accordent N fois le quota, et l'hôte croit être protégé. Le compteur vit donc en base.
|
|
230
|
+
create table if not exists public.player_rate_limits (
|
|
231
|
+
key text primary key,
|
|
232
|
+
count integer not null default 0,
|
|
233
|
+
expires_at timestamptz not null
|
|
234
|
+
);
|
|
235
|
+
create index if not exists player_rate_limits_expires_idx
|
|
236
|
+
on public.player_rate_limits (expires_at);
|
|
237
|
+
alter table public.player_rate_limits enable row level security;
|
|
238
|
+
comment on table public.player_rate_limits is
|
|
239
|
+
'Compteurs de débit partagés entre instances. Une ligne par clé et par fenêtre ; les lignes '
|
|
240
|
+
'périmées sont écrasées à la première demande suivante, il n''y a rien à purger.';
|
|
241
|
+
|
|
242
|
+
-- ⚠️ ET LIRE PUIS ÉCRIRE N'EST PAS ATOMIQUE. Deux appels simultanés lisent le même compte et
|
|
243
|
+
-- écrivent la même valeur : la limite laisse passer le double. L'incrément se fait donc en UNE
|
|
244
|
+
-- instruction, côté serveur.
|
|
245
|
+
create or replace function public.player_rate_limit_bump(
|
|
246
|
+
p_key text,
|
|
247
|
+
p_max integer,
|
|
248
|
+
p_window_seconds integer
|
|
249
|
+
)
|
|
250
|
+
returns table (autorise boolean, compte integer)
|
|
251
|
+
language plpgsql
|
|
252
|
+
security definer
|
|
253
|
+
set search_path = public
|
|
254
|
+
as $$
|
|
255
|
+
declare
|
|
256
|
+
v_count integer;
|
|
257
|
+
begin
|
|
258
|
+
insert into public.player_rate_limits as l (key, count, expires_at)
|
|
259
|
+
values (p_key, 1, now() + make_interval(secs => p_window_seconds))
|
|
260
|
+
on conflict (key) do update
|
|
261
|
+
set count = case when l.expires_at <= now() then 1 else l.count + 1 end,
|
|
262
|
+
expires_at = case when l.expires_at <= now()
|
|
263
|
+
then now() + make_interval(secs => p_window_seconds)
|
|
264
|
+
else l.expires_at end
|
|
265
|
+
returning l.count into v_count;
|
|
266
|
+
return query select (v_count <= p_max), v_count;
|
|
267
|
+
end;
|
|
268
|
+
$$;
|
|
269
|
+
-- ⚠️ `anon` ET `authenticated` SONT DES RÔLES SUPABASE, PAS DES RÔLES POSTGRES. Les nommer en dur
|
|
270
|
+
-- faisait ÉCHOUER ce fichier sur un Postgres nu — donc chez tout hôte auto-hébergé, c'est-à-dire le
|
|
271
|
+
-- public que ce dépôt vise en s'ouvrant. Le `grant` ci-dessous était déjà gardé par un `if exists` ;
|
|
272
|
+
-- ce `revoke` ne l'était pas : la prudence s'arrêtait à mi-chemin. Trouvé par la garde de schéma.
|
|
273
|
+
--
|
|
274
|
+
-- `public` n'est pas un rôle mais un mot-clé : celui-là passe partout, et c'est le seul qui compte.
|
|
275
|
+
revoke all on function public.player_rate_limit_bump(text, integer, integer) from public;
|
|
276
|
+
do $$
|
|
277
|
+
declare
|
|
278
|
+
r text;
|
|
279
|
+
begin
|
|
280
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
281
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
282
|
+
execute format('revoke all on function public.player_rate_limit_bump(text, integer, integer) from %I', r);
|
|
283
|
+
end if;
|
|
284
|
+
end loop;
|
|
285
|
+
end
|
|
286
|
+
$$;
|
|
287
|
+
do $$
|
|
288
|
+
begin
|
|
289
|
+
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
290
|
+
grant execute on function public.player_rate_limit_bump(text, integer, integer) to service_role;
|
|
291
|
+
end if;
|
|
292
|
+
end
|
|
293
|
+
$$;
|
|
294
|
+
|
|
212
295
|
-- ── Accès ──────────────────────────────────────────────────────────────────────────────────────
|
|
213
296
|
-- RLS activé SANS politique permissive : seul `service_role` (donc la route du player) passe.
|
|
214
297
|
-- ⚠️ N'ajoutez pas de politique de lecture publique « pour que le direct fonctionne ». C'est
|
|
@@ -252,3 +335,23 @@ begin
|
|
|
252
335
|
end if;
|
|
253
336
|
end $$;
|
|
254
337
|
alter table public.doc_presentation_messages replica identity full;
|
|
338
|
+
|
|
339
|
+
-- ── Rattrapage : bases installées depuis un init.sql plus ancien ───────────────────────────────
|
|
340
|
+
--
|
|
341
|
+
-- ⚠️ CE FICHIER A ÉTÉ INCOMPLET, ET RIEN NE LE DISAIT. Il annonçait « un seul fichier, sans rien à
|
|
342
|
+
-- lire ailleurs » alors qu'aucune des cinq migrations de `supabase/migrations/` n'y figurait : un
|
|
343
|
+
-- hôte neuf installait une base sans rang d'écriture, sans limites partagées et sans clé
|
|
344
|
+
-- d'idempotence. Les sondes de schéma dégradent en silence — par conception, pour ne pas casser un
|
|
345
|
+
-- hôte en cours de migration — donc cet hôte-là ne l'apprenait JAMAIS. Un état anormal que rien ne
|
|
346
|
+
-- dit devient l'état normal.
|
|
347
|
+
--
|
|
348
|
+
-- Les colonnes sont désormais dans le corps des tables ci-dessus, ce qui règle le cas d'une base
|
|
349
|
+
-- VIERGE. Mais `create table if not exists` ne touche pas une table déjà là : une base créée
|
|
350
|
+
-- depuis l'ancien init.sql resterait incomplète en rejouant celui-ci. D'où ce rattrapage, qui ne
|
|
351
|
+
-- coûte rien sur une base neuve et rend le fichier vrai dans les deux cas.
|
|
352
|
+
alter table public.commercial_doc_shares
|
|
353
|
+
add column if not exists attested_recipient_email text;
|
|
354
|
+
alter table public.doc_presentations
|
|
355
|
+
add column if not exists write_seq bigint not null default 0;
|
|
356
|
+
alter table public.doc_presentation_messages
|
|
357
|
+
add column if not exists client_key text;
|
|
@@ -52,7 +52,24 @@ end;
|
|
|
52
52
|
$$;
|
|
53
53
|
|
|
54
54
|
-- Le player parle à la base avec la clé de service ; personne d'autre n'a à appeler ceci.
|
|
55
|
-
|
|
55
|
+
-- ⚠️ `anon` ET `authenticated` SONT DES RÔLES SUPABASE, PAS DES RÔLES POSTGRES. Les nommer en dur
|
|
56
|
+
-- faisait ÉCHOUER ce fichier sur un Postgres nu — donc chez tout hôte auto-hébergé, c'est-à-dire le
|
|
57
|
+
-- public que ce dépôt vise en s'ouvrant. Le `grant` ci-dessous était déjà gardé par un `if exists` ;
|
|
58
|
+
-- ce `revoke` ne l'était pas : la prudence s'arrêtait à mi-chemin. Trouvé par la garde de schéma.
|
|
59
|
+
--
|
|
60
|
+
-- `public` n'est pas un rôle mais un mot-clé : celui-là passe partout, et c'est le seul qui compte.
|
|
61
|
+
revoke all on function public.player_rate_limit_bump(text, integer, integer) from public;
|
|
62
|
+
do $$
|
|
63
|
+
declare
|
|
64
|
+
r text;
|
|
65
|
+
begin
|
|
66
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
67
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
68
|
+
execute format('revoke all on function public.player_rate_limit_bump(text, integer, integer) from %I', r);
|
|
69
|
+
end if;
|
|
70
|
+
end loop;
|
|
71
|
+
end
|
|
72
|
+
$$;
|
|
56
73
|
|
|
57
74
|
-- ⚠️ LE `revoke` CI-DESSUS NE SUFFIT PAS À RENDRE CE FICHIER VRAI TOUT SEUL — signalé par le
|
|
58
75
|
-- second hôte, qui a interrogé `has_function_privilege` au lieu de relire la migration. Chez
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- DEUX ENVOIS DU MÊME MESSAGE NE DOIVENT PAS FAIRE DEUX MESSAGES.
|
|
2
|
+
--
|
|
3
|
+
-- Un renvoi réseau, un double-clic, une reprise après délai : la requête part deux fois et la base
|
|
4
|
+
-- enregistre deux lignes. Le participant voit son message en double, et rien ne le lui explique —
|
|
5
|
+
-- il n'y a eu aucune erreur, seulement un succès de trop.
|
|
6
|
+
--
|
|
7
|
+
-- ⚠️ LA CLÉ EST FABRIQUÉE PAR LE CLIENT, UNE FOIS, AVANT LE PREMIER ENVOI — et c'est toute la
|
|
8
|
+
-- subtilité. Une clé tirée à chaque tentative ne servirait à rien : les deux envois porteraient des
|
|
9
|
+
-- clés différentes et passeraient tous les deux. C'est la RÉUTILISATION de la clé au renvoi qui
|
|
10
|
+
-- rend l'opération idempotente, pas la clé elle-même.
|
|
11
|
+
--
|
|
12
|
+
-- ⚠️ INDEX PARTIEL, et il faut comprendre pourquoi. Les lignes déjà en base n'ont pas de clé ; un
|
|
13
|
+
-- index unique ordinaire les traiterait comme des doublons entre elles (plusieurs NULL sont
|
|
14
|
+
-- distincts en SQL, donc en réalité ça passerait — mais la condition rend l'intention explicite et
|
|
15
|
+
-- protège d'un futur `not null default ''`, où toutes les anciennes lignes deviendraient soudain
|
|
16
|
+
-- identiques). L'index ne contraint que ce que le nouveau client écrit.
|
|
17
|
+
--
|
|
18
|
+
-- ⚠️ CE QUE LA BASE FAIT, ET CE QUE LE CODE DOIT FAIRE. La contrainte REFUSE le second envoi ;
|
|
19
|
+
-- PostgREST répond alors 409. Ce refus n'est pas une erreur à remonter au participant : c'est la
|
|
20
|
+
-- preuve que son message est déjà là. Le serveur doit donc relire la ligne existante et la rendre,
|
|
21
|
+
-- comme si l'envoi avait réussi — sinon on remplace « deux messages » par « une erreur », ce qui
|
|
22
|
+
-- n'est pas mieux. Cette partie-là est du code, elle n'est pas dans ce fichier.
|
|
23
|
+
--
|
|
24
|
+
-- Sans lui : rien ne change, deux envois du même message créent toujours deux lignes. Le client
|
|
25
|
+
-- peut envoyer la clé, la colonne l'ignore poliment — aucune écriture ne casse, et le player ne
|
|
26
|
+
-- signale rien puisque l'ancien comportement EST le comportement d'aujourd'hui.
|
|
27
|
+
--
|
|
28
|
+
-- Applicable pendant que la version précédente tourne : tant que personne n'écrit la colonne, elle
|
|
29
|
+
-- reste vide et l'index ne contraint rien.
|
|
30
|
+
|
|
31
|
+
alter table public.doc_presentation_messages
|
|
32
|
+
add column if not exists client_key text;
|
|
33
|
+
|
|
34
|
+
comment on column public.doc_presentation_messages.client_key is
|
|
35
|
+
'Clé d''idempotence fabriquée par le client AVANT le premier envoi et réutilisée au renvoi. '
|
|
36
|
+
'Unique par présentation (index partiel dpm_client_key_uniq). Nulle sur les lignes antérieures '
|
|
37
|
+
'à la migration 0005, et sur celles écrites par un client qui ne la fournit pas.';
|
|
38
|
+
|
|
39
|
+
-- ⚠️ Portée sur (slug, client_key) et pas sur la clé seule : deux présentations différentes n'ont
|
|
40
|
+
-- aucune raison de partager un espace de clés, et un client qui réutiliserait par accident la même
|
|
41
|
+
-- valeur d'une session à l'autre verrait son message refusé sans comprendre.
|
|
42
|
+
create unique index if not exists dpm_client_key_uniq
|
|
43
|
+
on public.doc_presentation_messages (slug, client_key)
|
|
44
|
+
where client_key is not null;
|