discovery-media-player 0.1.63 → 0.1.64
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
CHANGED
|
@@ -29,6 +29,7 @@ need.
|
|
|
29
29
|
"capabilities": ["docshare", "presentations", "embed-denied", "host-fetch", "brand-reference", "host-auth", "host-share", "host-mail"],
|
|
30
30
|
"frameAncestors": ["'self'", "https://*.vercel.app", "https://app.example.com"],
|
|
31
31
|
"separateIssuer": true,
|
|
32
|
+
"internalStrict": true,
|
|
32
33
|
"hostShare": true,
|
|
33
34
|
"hostMail": true,
|
|
34
35
|
"plugins": { "bot": false, "visitors": false, "brandIntro": false, "botBrowser": false, "providerQuotas": false },
|
|
@@ -89,6 +90,13 @@ exactly the monitoring case where "no data" would otherwise read as "all clear".
|
|
|
89
90
|
⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
|
|
90
91
|
on its own, even when the rest has not been checked.
|
|
91
92
|
|
|
93
|
+
⚠️ **`internalStrict: false` means internal identity comes from the browser.** In transitional
|
|
94
|
+
mode the internal-analytics route accepts `docId`, `email` and `name` as the client declares them —
|
|
95
|
+
a caller can fabricate "this colleague read this document". Strict mode (`PLAYER_INTERNAL_STRICT=1`)
|
|
96
|
+
only accepts host-signed tokens. The flag exists so monitoring can **refuse** a non-strict instance
|
|
97
|
+
instead of discovering it in a log; treat `false` as an alert on any instance whose host already
|
|
98
|
+
issues internal tokens. The default will flip to strict at the next announced breaking change.
|
|
99
|
+
|
|
92
100
|
⚠️ **`couvre` states the scope, because `complet` without a scope overpromises.** The card checks
|
|
93
101
|
the **conditional columns** — the ones a feature probes before writing. The rate-limit migrations
|
|
94
102
|
(`0003`, `0004`) are deliberately **not** in it: a host may provide its own `limits` capability, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.64",
|
|
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",
|
package/server/handler.js
CHANGED
|
@@ -336,9 +336,10 @@ var Live=(function(){
|
|
|
336
336
|
// qui exige que cette table soit lisible publiquement — donc, avec la clé publiable, les
|
|
337
337
|
// conversations de TOUTES les présentations, pas seulement la sienne. C'était le dernier
|
|
338
338
|
// obstacle avant de pouvoir fermer cette lecture.
|
|
339
|
-
// L'émetteur
|
|
339
|
+
// L'émetteur SIGNALE qu'un message existe ; chacun — émetteur compris — tient son affichage de
|
|
340
|
+
// la RÉPONSE du serveur ou de la relecture HTTP, jamais du canal.
|
|
340
341
|
// ⚠️ La diffusion ne revient pas à son émetteur : il ajoute donc SA copie lui-même — depuis la
|
|
341
|
-
// RÉPONSE du serveur
|
|
342
|
+
// RÉPONSE du serveur.
|
|
342
343
|
//
|
|
343
344
|
// ⚠️ LA CHARGE EST VIDE, ET C'EST CE QUI REND LA PROPRIÉTÉ STRUCTURELLE. Elle transportait la
|
|
344
345
|
// ligne projetée — que le récepteur IGNORAIT (il relit par HTTP, cf. le commentaire du
|
|
@@ -347,13 +348,18 @@ var Live=(function(){
|
|
|
347
348
|
// était plus forte que le code, et tenait lieu de garde sans en être une — le jour où un
|
|
348
349
|
// récepteur nouveau aurait lu la charge « puisqu'elle est là », la projection serait devenue
|
|
349
350
|
// optionnelle sans que rien ne le dise. Le second hôte a levé l'écart ; sendState et
|
|
350
|
-
// sendMap avaient déjà la bonne forme.
|
|
351
|
-
// (« les autres l'ajoutent chez eux ») : il a fait dériver
|
|
352
|
-
//
|
|
351
|
+
// sendMap avaient déjà la bonne forme. Le commentaire d'en tête de ce bloc a affirmé l'ancien
|
|
352
|
+
// monde (« les autres l'ajoutent chez eux ») pendant des versions : il a fait dériver la
|
|
353
|
+
// lecture du second hôte — puis a SURVÉCU à sa propre citation : le correctif qui le désignait
|
|
354
|
+
// comme menteur l'a cité au lieu de le réécrire, et documenter un défaut l'avait rendu
|
|
355
|
+
// intouchable. Réécrit à la relecture suivante, par le même hôte. Un commentaire n'est pas du
|
|
356
|
+
// code : il vieillit sans essai pour le contredire — et un essai fige désormais la charge vide.
|
|
353
357
|
function sendMsg(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg',payload:{}});}catch(e){}}
|
|
354
358
|
function sendMsgUpd(m){if(!m)return;try{if(ch)ch.send({type:'broadcast',event:'msg-upd',payload:{}});}catch(e){}}
|
|
355
359
|
// Édition, suppression, réaction : le serveur renvoie la ligne à jour, on l'applique chez soi
|
|
356
|
-
// puis on la
|
|
360
|
+
// puis on SIGNALE — la charge part vide, les autres relisent. Même chemin pour les trois — une
|
|
361
|
+
// seule façon de se tromper. (« Puis on la diffuse » a survécu ici une version de trop : second
|
|
362
|
+
// exemplaire du commentaire d'en tête, trouvé par le second hôte dans le tarball publié.)
|
|
357
363
|
function majDiffusee(r){return r.json().then(function(d){if(d&&d.ok&&d.message){updateMsg(d.message);sendMsgUpd(d.message);}}).catch(function(){});}
|
|
358
364
|
function onState(fn){_onState=fn;}
|
|
359
365
|
// Badge « non lus » sur le bouton chat/FAB (panneau fermé) + pulse du FAB. Aperçu (ticker) au nouveau message.
|
|
@@ -3908,6 +3914,12 @@ async function handler(req, res) {
|
|
|
3908
3914
|
// l'hôte connaît déjà son émetteur — il veut seulement savoir si l'instance le regarde.
|
|
3909
3915
|
// Dire lequel n'aiderait personne et renseignerait qui sonde.
|
|
3910
3916
|
separateIssuer: !!(PLAYER.config && PLAYER.config.separateIssuer),
|
|
3917
|
+
// ⚠️ « L'identité interne vient-elle d'un JETON, ou du navigateur ? » En mode transitoire
|
|
3918
|
+
// (false), la route interne accepte docId/email/name tels que le client les déclare — un
|
|
3919
|
+
// appelant peut fabriquer « tel collègue a consulté tel document ». Le booléen rend l'état
|
|
3920
|
+
// MESURABLE : un cockpit peut refuser une instance non stricte, au lieu de le découvrir en
|
|
3921
|
+
// lisant un journal. Demandé par le second hôte (cinquième audit, P1-4).
|
|
3922
|
+
internalStrict: !!(PLAYER.config && PLAYER.config.internalStrict),
|
|
3911
3923
|
// ⚠️ L'ÉTAT DU SCHÉMA, LÀ OÙ ON REGARDE. Une colonne absente était signalée par un
|
|
3912
3924
|
// `console.warn`, une fois par processus : sur une fonction serverless, une ligne perdue
|
|
3913
3925
|
// dans une sortie que personne n'ouvre tant que tout a l'air de marcher — et « tout a
|
package/server/presentations.js
CHANGED
|
@@ -446,6 +446,11 @@ function messagePublic(row) {
|
|
|
446
446
|
// relit sans avoir à vérifier d'où vient chaque clé.
|
|
447
447
|
const out = Object.create(null);
|
|
448
448
|
for (const c of CHAMPS_PUBLICS) if (c in row) out[c] = row[c];
|
|
449
|
+
// ⚠️ LA CEINTURE : une ligne supprimée sort VIDE, quoi que porte encore la base. Les courses
|
|
450
|
+
// d'avant ce correctif — et les lignes historiques qu'elles ont laissées — peuvent avoir gardé
|
|
451
|
+
// un corps, une pièce jointe ou une citation sous deleted=true : la projection est le dernier
|
|
452
|
+
// endroit qui peut refuser de les resservir, et le seul qui couvre AUSSI le passé.
|
|
453
|
+
if (out.deleted) { out.body = ""; out.attachment = null; out.reactions = {}; out.reply_text = null; out.reply_name = null; }
|
|
449
454
|
if ("reactions" in out) out.reactions = reactionsPubliques(out.reactions);
|
|
450
455
|
// Dérivé, jamais recopié : `author_hash` est lu pour ça et ne sort jamais tel quel.
|
|
451
456
|
if ("author_hash" in row) out.author_ref = refAuteur(row.author_hash);
|
|
@@ -551,7 +556,13 @@ async function editMessage(slug, msgId, authorToken, body) {
|
|
|
551
556
|
const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=author_hash,deleted&limit=1`);
|
|
552
557
|
const m = Array.isArray(rows) && rows[0]; if (!m || m.deleted) return { ok: false, status: 404 };
|
|
553
558
|
if (!m.author_hash || m.author_hash !== sha(authorToken)) return { ok: false, status: 403 };
|
|
554
|
-
|
|
559
|
+
// ⚠️ CE QUI A ÉTÉ VÉRIFIÉ VOYAGE DANS LA CONDITION. Une suppression entre la lecture et
|
|
560
|
+
// l'écriture vidait le message — et l'édition retardée RÉÉCRIVAIT le texte dans une ligne
|
|
561
|
+
// marquée supprimée : effacé à l'écran, ressuscité dans le JSON. Cinquième audit, P1-3.
|
|
562
|
+
// `deleted=eq.false` fait de « pas supprimé » une propriété de l'ÉCRITURE ; `author_hash` et
|
|
563
|
+
// `slug` par cohérence — l'écriture ne repose plus sur rien qu'elle n'exige elle-même.
|
|
564
|
+
const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&author_hash=eq.${sha(authorToken)}&deleted=eq.false&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { body: b, edited: true } });
|
|
565
|
+
if (!Array.isArray(maj) || !maj.length) return { ok: false, status: 409 };
|
|
555
566
|
return { ok: true, message: premierPublic(maj) };
|
|
556
567
|
}
|
|
557
568
|
|
|
@@ -566,7 +577,16 @@ async function deleteMessage(slug, msgId, { authorToken, control }) {
|
|
|
566
577
|
let byPresenter = false;
|
|
567
578
|
if (!byAuthor && control) { const pres = await getPresentation(slug); byPresenter = !!(pres && tokenMatches(control, pres.control_hash)); }
|
|
568
579
|
if (!byAuthor && !byPresenter) return { ok: false, status: 403 };
|
|
569
|
-
|
|
580
|
+
// ⚠️ Par l'AUTEUR : son hash dans la condition — l'écriture n'exige que ce qu'elle a vérifié.
|
|
581
|
+
// Par le PRÉSENTATEUR : le jeton vit dans une AUTRE table, aucun filtre PostgREST ne peut le
|
|
582
|
+
// porter — la fenêtre résiduelle (un ex-présentateur modère dans la seconde où on lui reprend
|
|
583
|
+
// la main) est assumée : elle ne rend qu'un droit qu'il avait légitimement l'instant d'avant.
|
|
584
|
+
// `deleted=eq.false` partout : supprimer deux fois est IDEMPOTENT — zéro ligne veut dire
|
|
585
|
+
// « déjà supprimé », et c'est l'état que l'appelant voulait. `reply_text`/`reply_name` partent
|
|
586
|
+
// aussi : un message supprimé ne doit pas continuer de citer ce qu'il citait.
|
|
587
|
+
const cond = byAuthor ? `&author_hash=eq.${sha(authorToken)}` : "";
|
|
588
|
+
const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}${cond}&deleted=eq.false&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { deleted: true, body: "", reactions: {}, attachment: null, reply_text: null, reply_name: null } });
|
|
589
|
+
if (!Array.isArray(maj) || !maj.length) return { ok: true, deja: true };
|
|
570
590
|
return { ok: true, message: premierPublic(maj) };
|
|
571
591
|
}
|
|
572
592
|
|
|
@@ -612,7 +632,7 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
|
|
|
612
632
|
// ⚠️ LE RANG N'EST DEMANDÉ QUE LÀ OÙ IL EXISTE — sélectionner une colonne absente fait échouer
|
|
613
633
|
// la LECTURE entière chez un hôte non migré, et ce serait les réactions qu'on casserait.
|
|
614
634
|
const ordonne = await require("./schema").attendue("reactionsOrdonnees");
|
|
615
|
-
const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=${ordonne ? "reactions,reactions_seq" : "reactions"}&limit=1`);
|
|
635
|
+
const rows = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=${ordonne ? "reactions,reactions_seq,deleted" : "reactions,deleted"}&limit=1`);
|
|
616
636
|
// ⚠️ LE NOM DE PROPRIÉTÉ ÉCRIT ICI VIENT DU CLIENT — et la garde posée en 0.1.2 ne couvrait que
|
|
617
637
|
// les LECTURES. `Object.hasOwn` empêchait de LIRE `constructor` ; rien n'empêchait de l'ÉCRIRE.
|
|
618
638
|
//
|
|
@@ -635,6 +655,10 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
|
|
|
635
655
|
// nombre de concurrents réels, pas par la malchance.
|
|
636
656
|
let lu = rows;
|
|
637
657
|
for (let essai = 0; essai < 4; essai += 1) {
|
|
658
|
+
// ⚠️ Un message supprimé PENDANT la boucle : on s'arrête — réagir à un message effacé le
|
|
659
|
+
// ferait revivre dans le tableau des réactions. Et `deleted=eq.false` dans la condition
|
|
660
|
+
// d'écriture ferme la course entre cette lecture et le PATCH.
|
|
661
|
+
if (Array.isArray(lu) && lu[0] && lu[0].deleted) return { ok: false, status: 404 };
|
|
638
662
|
const brut = (Array.isArray(lu) && lu[0] && lu[0].reactions && typeof lu[0].reactions === "object" && !Array.isArray(lu[0].reactions)) ? lu[0].reactions : {};
|
|
639
663
|
const rang = ordonne ? Math.trunc(Number((Array.isArray(lu) && lu[0] && lu[0].reactions_seq) || 0)) : 0;
|
|
640
664
|
// Objet NU déclaré tel quel : la garde statique des écritures indexées reconnaît la
|
|
@@ -647,11 +671,14 @@ async function toggleReaction(slug, msgId, emoji, reactor, etat) {
|
|
|
647
671
|
if (veut && i < 0) arr.push(who);
|
|
648
672
|
if (!veut && i >= 0) arr.splice(i, 1);
|
|
649
673
|
if (arr.length) cur[e] = arr; else delete cur[e];
|
|
650
|
-
const condition = ordonne ? `&reactions_seq=eq.${rang}` : "";
|
|
674
|
+
const condition = (ordonne ? `&reactions_seq=eq.${rang}` : "") + "&deleted=eq.false";
|
|
651
675
|
const maj = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}${condition}&select=*`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: { reactions: cur, ...(ordonne ? { reactions_seq: rang + 1 } : {}) } });
|
|
652
|
-
if (
|
|
676
|
+
if (Array.isArray(maj) && maj.length) return { ok: true, message: premierPublic(maj) };
|
|
677
|
+
// Hôte non migré (pas de rang) : zéro ligne ne peut venir que de `deleted` — le message est
|
|
678
|
+
// parti pendant qu'on écrivait. Pas de rejeu qui le ferait revivre.
|
|
679
|
+
if (!ordonne) return { ok: false, status: 404 };
|
|
653
680
|
// Rang dépassé : quelqu'un a écrit entre notre lecture et notre écriture. On relit, on rejoue.
|
|
654
|
-
lu = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=reactions,reactions_seq&limit=1`);
|
|
681
|
+
lu = await PLAYER.db.request(`doc_presentation_messages?id=eq.${id}&slug=eq.${enc(String(slug || ""))}&select=reactions,reactions_seq,deleted&limit=1`);
|
|
655
682
|
}
|
|
656
683
|
// Quatre tours perdus : plus de concurrents que de raisons d'attendre. Un refus net vaut mieux
|
|
657
684
|
// qu'une écriture qui écraserait ce que les gagnants viennent de poser.
|
|
@@ -794,7 +821,12 @@ async function switchPresentationDoc(slug, email, isAdmin, { fileUrl, fileName,
|
|
|
794
821
|
if (!row) return { ok: false, status: 404 };
|
|
795
822
|
if (estClose(row)) return { ok: false, status: 409, error: "ended" };
|
|
796
823
|
if (!isAdmin && (!row.owner_email || row.owner_email !== lc(email))) return { ok: false, status: 403 };
|
|
797
|
-
|
|
824
|
+
// ⚠️ LE PROPRIÉTAIRE VÉRIFIÉ VOYAGE DANS LA CONDITION — un transfert entre la lecture et
|
|
825
|
+
// l'écriture donnait la présentation à Bob, et la requête retardée d'Alice changeait ENCORE le
|
|
826
|
+
// document de Bob. Même classe que reprise/clôture/transfert (#130), deux survivants trouvés par
|
|
827
|
+
// le cinquième audit. L'admin, lui, écrit sur la présentation active telle quelle : modération.
|
|
828
|
+
const ecrit = await ecrireSiEncoreVrai(
|
|
829
|
+
`doc_presentations?slug=eq.${enc(slug)}&active=eq.true${isAdmin ? "" : `&owner_email=eq.${enc(row.owner_email)}`}`, {
|
|
798
830
|
file_url: String(fileUrl), file_name: fileName || null, doc_title: docTitle || null, doc_id: docId ? String(docId) : null,
|
|
799
831
|
content: null, current_page: 1, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(),
|
|
800
832
|
});
|
|
@@ -859,10 +891,12 @@ async function setPresentationContent(slug, email, isAdmin, content, control) {
|
|
|
859
891
|
// droit en fermant une porte. Le PILOTE écrit tant que son jeton vaut : une carte affichée
|
|
860
892
|
// pendant que le portable dormait doit encore partir, comme une page. Le PROPRIÉTAIRE, lui,
|
|
861
893
|
// n'écrit que sur une présentation vivante : c'est déjà ce que le contrôle au-dessus exige.
|
|
894
|
+
// ⚠️ Et le PROPRIÉTAIRE (non-admin) voyage dans la condition, comme partout : sans ça, sa
|
|
895
|
+
// requête retardée écrivait encore sur la présentation qu'un transfert venait de donner.
|
|
862
896
|
const ecrit = await ecrireSiEncoreVrai(
|
|
863
897
|
pilote
|
|
864
898
|
? `doc_presentations?slug=eq.${enc(slug)}&control_hash=eq.${sha(control)}`
|
|
865
|
-
: `doc_presentations?slug=eq.${enc(slug)}&active=eq.true`,
|
|
899
|
+
: `doc_presentations?slug=eq.${enc(slug)}&active=eq.true${isAdmin ? "" : `&owner_email=eq.${enc(row.owner_email)}`}`,
|
|
866
900
|
{ content: sanitizeContent(content), active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString() },
|
|
867
901
|
);
|
|
868
902
|
if (!ecrit) return { ok: false, status: 409, error: "ended" };
|
package/supabase/init.sql
CHANGED
|
@@ -269,6 +269,9 @@ begin
|
|
|
269
269
|
then now() + make_interval(secs => p_window_seconds)
|
|
270
270
|
else l.expires_at end
|
|
271
271
|
returning l.count into v_count;
|
|
272
|
+
|
|
273
|
+
-- Le refus est décidé ICI, sur la valeur qu'on vient d'écrire : le client n'a rien à recalculer,
|
|
274
|
+
-- donc rien à recalculer sur une valeur déjà périmée.
|
|
272
275
|
return query select (v_count <= p_max), v_count;
|
|
273
276
|
end;
|
|
274
277
|
$$;
|
|
@@ -344,8 +347,11 @@ alter table public.doc_bot_sessions enable row level security;
|
|
|
344
347
|
-- ── Le scellé de l'archive ─────────────────────────────────────────────────────────────────────
|
|
345
348
|
-- Sept chemins d'écriture vérifient l'archive PUIS écrivent — dans une AUTRE table que celle qui
|
|
346
349
|
-- porte l'état. Aucun filtre PostgREST ne peut fermer cette fenêtre : l'arbitre est la base.
|
|
347
|
-
-- `FOR KEY SHARE`
|
|
348
|
-
--
|
|
350
|
+
-- ⚠️ `FOR SHARE`, PAS `FOR KEY SHARE` — la clôture modifie des colonnes NON-CLÉS, son UPDATE prend
|
|
351
|
+
-- FOR NO KEY UPDATE, que KEY SHARE ne bloque pas. La 0007 a porté ce verrou trop faible pendant
|
|
352
|
+
-- une version, avec un commentaire qui affirmait l'atomicité (corrigé par la 0010, constat du
|
|
353
|
+
-- cinquième audit — VU au banc à deux transactions de la forge : le message entrait dans
|
|
354
|
+
-- l'archive). C'est FOR SHARE qui rend le refus atomique.
|
|
349
355
|
create or replace function public.player_archive_scellee()
|
|
350
356
|
returns trigger
|
|
351
357
|
language plpgsql
|
|
@@ -357,7 +363,7 @@ begin
|
|
|
357
363
|
select p.active, p.control_hash into v_active, v_control
|
|
358
364
|
from public.doc_presentations p
|
|
359
365
|
where p.slug = new.slug
|
|
360
|
-
for
|
|
366
|
+
for share;
|
|
361
367
|
-- Pas de présentation : rien à sceller (chat d'un lien sans session live, données historiques).
|
|
362
368
|
if not found then return new; end if;
|
|
363
369
|
if v_active = false and v_control is null then
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
-- doc_presentation_attendees, la condition vit dans doc_presentations — une AUTRE table. Sans
|
|
10
10
|
-- transaction ni RPC côté player, l'arbitre ne peut être que la base elle-même. D'où ce trigger.
|
|
11
11
|
--
|
|
12
|
-
-- ⚠️
|
|
13
|
-
--
|
|
14
|
-
--
|
|
15
|
-
--
|
|
12
|
+
-- ⚠️ LE VERROU CI-DESSOUS EST TROP FAIBLE, ET LA 0010 LE REMPLACE. Cette migration affirmait que
|
|
13
|
+
-- FOR KEY SHARE rendait le refus atomique : faux — la clôture modifie des colonnes non-clés, son
|
|
14
|
+
-- UPDATE prend FOR NO KEY UPDATE, compatible avec KEY SHARE. La fenêtre prétendue fermée restait
|
|
15
|
+
-- ouverte (cinquième audit, VU au banc à deux transactions). Le fichier reste tel qu'appliqué par
|
|
16
|
+
-- les bases historiques ; la correction est une migration de plus, comme toujours.
|
|
16
17
|
--
|
|
17
18
|
-- Sans lui : rien ne casse — les sept chemins gardent leur vérification préalable, et la fenêtre
|
|
18
19
|
-- de course reste ce qu'elle était avant cette migration. Le trigger est le scellé, pas la porte.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- LE SCELLÉ DE L'ARCHIVE PRÉTENDAIT UNE ATOMICITÉ QUE SON VERROU NE FOURNISSAIT PAS.
|
|
2
|
+
--
|
|
3
|
+
-- La 0007 verrouillait la présentation en FOR KEY SHARE — et son commentaire affirmait que c'était
|
|
4
|
+
-- « ce qui rend le refus atomique ». Faux : la clôture modifie `active` et `control_hash`, des
|
|
5
|
+
-- colonnes NON-CLÉS, donc son UPDATE prend FOR NO KEY UPDATE — que KEY SHARE ne bloque PAS
|
|
6
|
+
-- (matrice des verrous PostgreSQL : KEY SHARE ne conflit qu'avec FOR UPDATE). Le trigger vérifiait
|
|
7
|
+
-- « ouverte », la clôture se committait sous lui, le message entrait dans l'archive : la fenêtre
|
|
8
|
+
-- que 0007 prétendait fermer était ouverte. Constat P1-1 du cinquième audit — VU au banc de la
|
|
9
|
+
-- forge : deux transactions psql, l'écriture n'attendait pas la clôture.
|
|
10
|
+
--
|
|
11
|
+
-- FOR SHARE, lui, conflit avec FOR NO KEY UPDATE : une clôture non committée fait ATTENDRE
|
|
12
|
+
-- l'écriture (qui voit ensuite l'archive et est refusée) ; une écriture en cours fait attendre la
|
|
13
|
+
-- clôture. Le coût est nul en pratique : plusieurs écritures partagent le verrou entre elles,
|
|
14
|
+
-- seule la clôture entre en conflit — précisément l'objectif.
|
|
15
|
+
--
|
|
16
|
+
-- Sans lui : le scellé garde sa valeur de refus (une écriture APRÈS la clôture committée est
|
|
17
|
+
-- toujours refusée) — seule la fenêtre concurrente reste ouverte, comme entre 0007 et 0010.
|
|
18
|
+
create or replace function public.player_archive_scellee()
|
|
19
|
+
returns trigger
|
|
20
|
+
language plpgsql
|
|
21
|
+
as $$
|
|
22
|
+
declare
|
|
23
|
+
v_active boolean;
|
|
24
|
+
v_control text;
|
|
25
|
+
begin
|
|
26
|
+
select p.active, p.control_hash into v_active, v_control
|
|
27
|
+
from public.doc_presentations p
|
|
28
|
+
where p.slug = new.slug
|
|
29
|
+
for share;
|
|
30
|
+
-- Pas de présentation : rien à sceller (chat d'un lien sans session live, données historiques).
|
|
31
|
+
if not found then return new; end if;
|
|
32
|
+
if v_active = false and v_control is null then
|
|
33
|
+
raise exception 'presentation archivee : ecriture refusee'
|
|
34
|
+
using errcode = 'P0001', hint = 'l''archive est en lecture seule';
|
|
35
|
+
end if;
|
|
36
|
+
return new;
|
|
37
|
+
end
|
|
38
|
+
$$;
|