discovery-media-player 0.1.89 → 0.1.91
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.91",
|
|
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/gabarit-live.js
CHANGED
|
@@ -179,7 +179,7 @@ var Live=(function(){
|
|
|
179
179
|
// battait deux fois. On les déclare au niveau du CYCLE DE VIE : ce qui doit être arrêté doit
|
|
180
180
|
// être visible depuis l'endroit qui arrête.
|
|
181
181
|
var _ordEtat=null,_ordChat=null,_filet=null;
|
|
182
|
-
var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESNAME='',seen=Object.create(null),msgEls=Object.create(null),msgData=Object.create(null),replyCtx=null,typers=Object.create(null),pdfCache=Object.create(null),_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
|
|
182
|
+
var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESNAME='',seen=Object.create(null),msgEls=Object.create(null),msgData=Object.create(null),replyCtx=null,typers=Object.create(null),pdfCache=Object.create(null),_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false,_maxSeq=0;
|
|
183
183
|
try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
|
|
184
184
|
// Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
|
|
185
185
|
function applyMute(){ var b=document.getElementById('chatMute'); if(b){b.classList.toggle('muted',MUTED);b.title=MUTED?'Réactiver les notifications du chat':'Couper les notifications du chat';} setBadge(); }
|
|
@@ -317,7 +317,7 @@ var Live=(function(){
|
|
|
317
317
|
// Crochet interne (même famille que __presRelireEtat) : le piège de focus ne se PROUVE que
|
|
318
318
|
// piloté clavier dans un vrai navigateur, et la seule voie UI passe par un message à soi.
|
|
319
319
|
try{window.__confirmDialog=confirmDialog;}catch(e){}
|
|
320
|
-
function history(){fetch('/api/doc?present='+encodeURIComponent(SLUG)+'&chat=1').then(function(r){return r.json();}).then(function(d){var box=document.getElementById('chatMsgs');if(d&&d.messages&&d.messages.length){d.messages.forEach(function(m){addMsg(m);});}else if(box&&!box.children.length){box.innerHTML='<div class=chat-empty>Aucun message. Lancez la discussion.</div>';}if(d&&typeof d.locked!=='undefined')applyLock(d.locked);_histDone=true;}).catch(function(){_histDone=true;});}
|
|
320
|
+
function history(){fetch('/api/doc?present='+encodeURIComponent(SLUG)+'&chat=1').then(function(r){return r.json();}).then(function(d){var box=document.getElementById('chatMsgs');if(d&&d.messages&&d.messages.length){d.messages.forEach(function(m){if(typeof m.mod_seq==='number'&&m.mod_seq>_maxSeq)_maxSeq=m.mod_seq;addMsg(m);});}else if(box&&!box.children.length){box.innerHTML='<div class=chat-empty>Aucun message. Lancez la discussion.</div>';}if(d&&typeof d.locked!=='undefined')applyLock(d.locked);_histDone=true;}).catch(function(){_histDone=true;});}
|
|
321
321
|
function react(id,e){if(!ME||!id||!e)return;
|
|
322
322
|
// ⚠️ ON ENVOIE L'ÉTAT VOULU, PAS « INVERSE ». Basculer n'a de sens qu'une fois : un renvoi
|
|
323
323
|
// réseau, un double-clic, une reprise de requête, et la réaction que le participant vient
|
|
@@ -497,7 +497,7 @@ var Live=(function(){
|
|
|
497
497
|
// arrive en retard, jamais à côté.
|
|
498
498
|
function relireAvec(url,applique){
|
|
499
499
|
var ord=Player.live.createScheduler(function(fini){
|
|
500
|
-
Player.live.fetchBorne('/api/doc?present='+encodeURIComponent(SLUG)+url)
|
|
500
|
+
Player.live.fetchBorne('/api/doc?present='+encodeURIComponent(SLUG)+((typeof url==='function')?url():url))
|
|
501
501
|
.then(function(r){return r.json();})
|
|
502
502
|
.then(function(d){if(d&&d.ok)applique(d);})
|
|
503
503
|
.catch(function(){})
|
|
@@ -523,13 +523,13 @@ var Live=(function(){
|
|
|
523
523
|
window.__presRelireEtat=function(){_ordEtat.signaler();};
|
|
524
524
|
function relireEtat(){_ordEtat.signaler();}
|
|
525
525
|
function relireChat(){_ordChat.signaler();}
|
|
526
|
-
_ordChat=relireAvec('&chat=1',function(d){
|
|
526
|
+
_ordChat=relireAvec(function(){return '&chat=1'+(_maxSeq>0?('&chatAfter='+_maxSeq):'');},function(d){
|
|
527
527
|
// ⚠️ NOTIFIER CE QUI VIENT D'ARRIVER, ET SEULEMENT ÇA. 'addMsg' rend faux pour un
|
|
528
528
|
// message déjà connu — la relecture ramène tout l'historique, donc sans cette condition
|
|
529
529
|
// la pastille de non-lus compterait chaque message à chaque relecture. Et sans l'appel,
|
|
530
530
|
// elle ne compte plus rien : c'est la régression que le test d'un hôte a attrapée en
|
|
531
531
|
// lisant le source de ce paquet, à travers la frontière de deux dépôts.
|
|
532
|
-
if(d.messages)d.messages.forEach(function(m){if(addMsg(m))notifyMsg(m);else updateMsg(m);});
|
|
532
|
+
if(d.messages)d.messages.forEach(function(m){if(typeof m.mod_seq==='number'&&m.mod_seq>_maxSeq)_maxSeq=m.mod_seq;if(addMsg(m))notifyMsg(m);else updateMsg(m);});
|
|
533
533
|
if(typeof d.locked!=='undefined')applyLock(d.locked);
|
|
534
534
|
});
|
|
535
535
|
|
package/server/handler.js
CHANGED
|
@@ -693,6 +693,13 @@ async function handler(req, res) {
|
|
|
693
693
|
// Le cache englobe la LECTURE ET la sérialisation : sous rafale, une seule des deux se paie.
|
|
694
694
|
// `file=1` ne figure pas dans cette table, et c'est délibéré : il relaie des octets avec des
|
|
695
695
|
// en-têtes de plage propres à l'appelant — ce n'est pas une charge partagée.
|
|
696
|
+
//
|
|
697
|
+
// ⚠️ CURSEUR DU CHAT DIFFÉRENTIEL. `chatAfter` = le plus grand `seq` que le client a déjà vu ;
|
|
698
|
+
// borné à un entier positif (sinon une valeur libre exploserait les clés de cache et l'URL de la
|
|
699
|
+
// requête base). Il entre dans la CLÉ DE CACHE : deux clients au même curseur — le cas normal,
|
|
700
|
+
// toute une salle à jour du même message — partagent la même lecture ; seuls ceux qui ont pris
|
|
701
|
+
// du retard divergent. Absent → relecture complète (client ancien, ou hôte non migré).
|
|
702
|
+
const chatAfter = (Number.isFinite(+q.chatAfter) && +q.chatAfter > 0) ? String(Math.trunc(+q.chatAfter)) : null;
|
|
696
703
|
const LECTURES_PARTAGEES = [
|
|
697
704
|
{ param: "state", produire: async (p) => ({ ok: true, state: {
|
|
698
705
|
active: p.active !== false,
|
|
@@ -723,12 +730,12 @@ async function handler(req, res) {
|
|
|
723
730
|
// Signalé par la seconde passe d'audit (P0-4) ; la fuite n'y était pas.
|
|
724
731
|
presenter_name: String(p.presenter_name || "") || null,
|
|
725
732
|
} }) },
|
|
726
|
-
{ param: "chat", produire: async (p, slug) => ({ ok: true, messages: await listMessages(slug), locked: !!p.chat_locked }) },
|
|
733
|
+
{ param: "chat", suffixeCle: () => (chatAfter != null ? `:a${chatAfter}` : ""), produire: async (p, slug) => ({ ok: true, messages: await listMessages(slug, { after: chatAfter }), locked: !!p.chat_locked }) },
|
|
727
734
|
];
|
|
728
735
|
for (const lecture of LECTURES_PARTAGEES) {
|
|
729
736
|
if (String(q[lecture.param] || "") !== "1") continue;
|
|
730
737
|
const slugLu = String(q.present);
|
|
731
|
-
const corps = await cacheLecture.lire(`${lecture.param}:${slugLu}`, async () => {
|
|
738
|
+
const corps = await cacheLecture.lire(`${lecture.param}:${slugLu}${lecture.suffixeCle ? lecture.suffixeCle() : ""}`, async () => {
|
|
732
739
|
const p = await getPresentation(slugLu);
|
|
733
740
|
return p ? JSON.stringify(await lecture.produire(p, slugLu)) : null;
|
|
734
741
|
});
|
package/server/presentations.js
CHANGED
|
@@ -411,6 +411,9 @@ const CHAMPS_PUBLICS = [
|
|
|
411
411
|
"id", "author_name", "author_avatar", "is_presenter", "is_member",
|
|
412
412
|
"body", "attachment", "reactions", "reply_to", "reply_name", "reply_text",
|
|
413
413
|
"deleted", "edited", "created_at",
|
|
414
|
+
// Rang de changement (migration 0016) : le client garde le plus grand vu et le renvoie pour relire
|
|
415
|
+
// en différentiel. Absent chez un hôte non migré → le client reste en relecture complète.
|
|
416
|
+
"mod_seq",
|
|
414
417
|
];
|
|
415
418
|
|
|
416
419
|
/**
|
|
@@ -581,12 +584,32 @@ async function addMessage(slug, { name, email, avatar, isPresenter, isMember, bo
|
|
|
581
584
|
* même chose — un `select` étroit et une projection — dont une seule était appliquée ici. Une
|
|
582
585
|
* garde qui dépend de ce qu'on n'a pas demandé cède au premier champ qu'on demande.
|
|
583
586
|
*/
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
//
|
|
589
|
-
|
|
587
|
+
const CHAMPS_MSG = "id,author_name,author_avatar,author_hash,is_presenter,is_member,body,attachment,reactions,reply_to,reply_name,reply_text,deleted,edited,created_at";
|
|
588
|
+
|
|
589
|
+
async function listMessages(slug, { after } = {}) {
|
|
590
|
+
const s = enc(String(slug || ""));
|
|
591
|
+
// Le chat différentiel repose sur la colonne `seq` (migration 0016), bumpée à CHAQUE écriture. On la
|
|
592
|
+
// sonde : présente → différentiel possible ; absente (hôte non migré) → on sert les 300 derniers et
|
|
593
|
+
// on n'ajoute pas `seq` au select (PostgREST rejetterait la requête ENTIÈRE sur une colonne inconnue).
|
|
594
|
+
const seqDispo = await require("./schema").attendue("chatDifferentiel");
|
|
595
|
+
|
|
596
|
+
// ⚠️ DIFFÉRENTIEL : le client donne le plus grand `mod_seq` déjà vu ; on ne rend QUE ce qui a changé
|
|
597
|
+
// depuis — nouveaux messages ET anciens mutés (réaction, édition, suppression), le trigger ayant
|
|
598
|
+
// avancé leur `mod_seq`. En ordre de `mod_seq` : le client fusionne par id (addMsg/updateMsg), l'ordre
|
|
599
|
+
// d'affichage tenant à created_at qu'il porte déjà. `limit=300` borne un rattrapage après longue
|
|
600
|
+
// coupure — le curseur avance, le signal suivant prend la suite.
|
|
601
|
+
if (after != null && seqDispo) {
|
|
602
|
+
const curseur = Math.max(0, Math.trunc(Number(after)) || 0);
|
|
603
|
+
const rows = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${s}&mod_seq=gt.${curseur}&select=${CHAMPS_MSG},mod_seq&order=mod_seq.asc&limit=300`);
|
|
604
|
+
return Array.isArray(rows) ? rows.map(messagePublic) : [];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ⚠️ CHARGE INITIALE (ou hôte non migré) : LES 300 PLUS RÉCENTS, PAS LES 300 PLUS ANCIENS (P1). Avec
|
|
608
|
+
// `asc&limit=300`, au 301e message les nouveaux ne revenaient JAMAIS à ceux qui relisaient — seul
|
|
609
|
+
// l'auteur, par la réponse du POST. On trie `desc` (id départage created_at à la ms) puis on REND en
|
|
610
|
+
// ordre chronologique. On renvoie `mod_seq` quand il est là : c'est ce qui amorce le curseur du client.
|
|
611
|
+
const select = seqDispo ? `${CHAMPS_MSG},mod_seq` : CHAMPS_MSG;
|
|
612
|
+
const rows = await PLAYER.db.request(`doc_presentation_messages?slug=eq.${s}&select=${select}&order=created_at.desc,id.desc&limit=300`);
|
|
590
613
|
return Array.isArray(rows) ? rows.slice().reverse().map(messagePublic) : [];
|
|
591
614
|
}
|
|
592
615
|
|
package/server/schema.js
CHANGED
|
@@ -69,6 +69,14 @@ const ATTENDUES = {
|
|
|
69
69
|
migration: "supabase/migrations/0015-presence-atomique.sql",
|
|
70
70
|
fonction: "écrire la présence atomiquement et plafonner la création de faux participants anonymes",
|
|
71
71
|
},
|
|
72
|
+
// Chat différentiel : la colonne `mod_seq` (bumpée par trigger) est ce que le code sonde AVANT de
|
|
73
|
+
// servir en différentiel. Absente → on sert les 300 derniers comme avant (cf. listMessages). Ici la
|
|
74
|
+
// colonne EST le vrai signal de dégradation (le code la sonde), pas seulement un témoin — cas franc.
|
|
75
|
+
chatDifferentiel: {
|
|
76
|
+
table: "doc_presentation_messages", colonne: "mod_seq",
|
|
77
|
+
migration: "supabase/migrations/0016-chat-differentiel.sql",
|
|
78
|
+
fonction: "relire le chat en différentiel (mod_seq > curseur) au lieu des 300 derniers à chaque signal",
|
|
79
|
+
},
|
|
72
80
|
};
|
|
73
81
|
|
|
74
82
|
/**
|
package/supabase/init.sql
CHANGED
|
@@ -206,7 +206,10 @@ create table if not exists public.doc_presentation_messages (
|
|
|
206
206
|
client_key text,
|
|
207
207
|
-- Rang de la dernière écriture de réactions acceptée : deux réactions simultanées ne
|
|
208
208
|
-- s'écrasent plus — l'écriture conditionnée à un rang dépassé ne modifie rien, le serveur rejoue.
|
|
209
|
-
reactions_seq bigint not null default 0
|
|
209
|
+
reactions_seq bigint not null default 0,
|
|
210
|
+
-- Rang GLOBAL bumpé à chaque écriture (insert/update) par le trigger dpm_bump_modseq : le client
|
|
211
|
+
-- relit `mod_seq > curseur` au lieu des 300 derniers à chaque signal. Cf. migration 0016.
|
|
212
|
+
mod_seq bigint
|
|
210
213
|
);
|
|
211
214
|
create index if not exists dpm_slug_idx on public.doc_presentation_messages (slug, created_at);
|
|
212
215
|
-- ⚠️ Portée sur (slug, client_key), pas sur la clé seule : deux présentations n'ont aucune raison
|
|
@@ -218,6 +221,37 @@ create unique index if not exists dpm_client_key_uniq
|
|
|
218
221
|
on public.doc_presentation_messages (slug, client_key)
|
|
219
222
|
where client_key is not null;
|
|
220
223
|
|
|
224
|
+
-- Chat différentiel (migration 0016) : rang global monotone bumpé à chaque écriture par un trigger.
|
|
225
|
+
-- ⚠️ RÈGLE (sixième audit) : la colonne est assurée par un ALTER AVANT son index — sur une base d'hier,
|
|
226
|
+
-- `create table if not exists` saute le corps et `mod_seq` n'existerait qu'ici.
|
|
227
|
+
alter table public.doc_presentation_messages
|
|
228
|
+
add column if not exists mod_seq bigint;
|
|
229
|
+
create sequence if not exists public.doc_presentation_messages_modseq;
|
|
230
|
+
create or replace function public.player_bump_message_seq()
|
|
231
|
+
returns trigger
|
|
232
|
+
language plpgsql
|
|
233
|
+
as $$
|
|
234
|
+
begin
|
|
235
|
+
new.mod_seq := nextval('public.doc_presentation_messages_modseq');
|
|
236
|
+
return new;
|
|
237
|
+
end;
|
|
238
|
+
$$;
|
|
239
|
+
drop trigger if exists dpm_bump_modseq on public.doc_presentation_messages;
|
|
240
|
+
create trigger dpm_bump_modseq
|
|
241
|
+
before insert or update on public.doc_presentation_messages
|
|
242
|
+
for each row execute function public.player_bump_message_seq();
|
|
243
|
+
-- ⚠️ Le rattrapage EXCLUT les présentations scellées (0007/0010) : le trigger dpm_archive_scellee lève
|
|
244
|
+
-- sur toute écriture dans une présentation gelée, et une seule de ses lignes ferait échouer l'install.
|
|
245
|
+
update public.doc_presentation_messages m
|
|
246
|
+
set mod_seq = nextval('public.doc_presentation_messages_modseq')
|
|
247
|
+
where m.mod_seq is null
|
|
248
|
+
and not exists (
|
|
249
|
+
select 1 from public.doc_presentations p
|
|
250
|
+
where p.slug = m.slug and p.active = false and p.control_hash is null
|
|
251
|
+
);
|
|
252
|
+
create index if not exists idx_dpm_slug_modseq
|
|
253
|
+
on public.doc_presentation_messages (slug, mod_seq);
|
|
254
|
+
|
|
221
255
|
create table if not exists public.doc_presentation_attendees (
|
|
222
256
|
slug text not null,
|
|
223
257
|
attendee_key text not null,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- LE CHAT NE SE RELIT PLUS EN ENTIER À CHAQUE SIGNAL — SEULEMENT CE QUI A CHANGÉ DEPUIS.
|
|
2
|
+
--
|
|
3
|
+
-- ⚠️ P « architecture scalable » (audit CODEX 5.6). À chaque resynchronisation, l'audience relisait
|
|
4
|
+
-- les 300 messages les plus récents — pour une salle de N spectateurs qui se resynchronisent, c'est N
|
|
5
|
+
-- fois 300 lignes, alors que presque rien n'a bougé entre deux signaux. Le client sait déjà fusionner
|
|
6
|
+
-- par id (addMsg/updateMsg) ; il lui manque de quoi demander « donne-moi ce qui a changé depuis X ».
|
|
7
|
+
--
|
|
8
|
+
-- Un chat est MUTABLE : un message ancien reçoit une réaction, est édité, est supprimé. Un curseur sur
|
|
9
|
+
-- `id` (croissant à l'insertion) raterait ces mutations. D'où `mod_seq` — un rang bumpé à CHAQUE
|
|
10
|
+
-- écriture, insertion ET mise à jour — par un TRIGGER, depuis une séquence (atomique, aucune course).
|
|
11
|
+
-- Le nom suit la convention IMAP CONDSTORE (MODSEQ), qui résout exactement ce problème : un compteur
|
|
12
|
+
-- monotone par collection, avancé à chaque changement, pour une synchro différentielle. Le client
|
|
13
|
+
-- garde le plus grand `mod_seq` vu et demande `mod_seq > curseur` : il récupère les nouveaux messages
|
|
14
|
+
-- ET les anciens qui ont changé, et rien d'autre.
|
|
15
|
+
--
|
|
16
|
+
-- ⚠️ POURQUOI UN TRIGGER, alors que reactions_seq est bumpé par le CODE : reactions_seq est LOCAL à un
|
|
17
|
+
-- message (le rang de SA dernière écriture de réactions). `mod_seq` est GLOBAL et doit croître à chaque
|
|
18
|
+
-- écriture de N'IMPORTE quel chemin (envoi, édition, suppression, réaction) sans les toucher tous —
|
|
19
|
+
-- un before-insert-or-update le fait en un endroit, et `nextval` est atomique là où un
|
|
20
|
+
-- lire-max-puis-écrire ne l'est pas. Même famille que le trigger de scellé d'archive (0007).
|
|
21
|
+
--
|
|
22
|
+
-- ⚠️ Sans lui : le player sonde la colonne `mod_seq` (server/schema.js) ; absente, il sert les 300 plus
|
|
23
|
+
-- récents comme avant, et le client retombe sur la relecture complète. Rien ne casse, la charge
|
|
24
|
+
-- n'est simplement pas encore allégée. Additive et rejouable ; applicable pendant que le code tourne.
|
|
25
|
+
|
|
26
|
+
alter table public.doc_presentation_messages
|
|
27
|
+
add column if not exists mod_seq bigint;
|
|
28
|
+
|
|
29
|
+
-- La séquence qui rend `mod_seq` globalement monotone, quel que soit le chemin d'écriture.
|
|
30
|
+
create sequence if not exists public.doc_presentation_messages_modseq;
|
|
31
|
+
|
|
32
|
+
create or replace function public.player_bump_message_seq()
|
|
33
|
+
returns trigger
|
|
34
|
+
language plpgsql
|
|
35
|
+
as $$
|
|
36
|
+
begin
|
|
37
|
+
new.mod_seq := nextval('public.doc_presentation_messages_modseq');
|
|
38
|
+
return new;
|
|
39
|
+
end;
|
|
40
|
+
$$;
|
|
41
|
+
|
|
42
|
+
-- ⚠️ AVANT insert OU update : toute écriture acceptée avance le curseur. Un spectateur en retard
|
|
43
|
+
-- récupère ainsi une réaction posée sur un vieux message, pas seulement les messages neufs.
|
|
44
|
+
drop trigger if exists dpm_bump_modseq on public.doc_presentation_messages;
|
|
45
|
+
create trigger dpm_bump_modseq
|
|
46
|
+
before insert or update on public.doc_presentation_messages
|
|
47
|
+
for each row execute function public.player_bump_message_seq();
|
|
48
|
+
|
|
49
|
+
-- Rattrapage des lignes déjà présentes : sans `mod_seq`, elles seraient invisibles au différentiel.
|
|
50
|
+
-- ⚠️ IL RESPECTE LE SCELLÉ D'ARCHIVE (0007/0010). Cet update écrit CHAQUE ligne — or le trigger
|
|
51
|
+
-- dpm_archive_scellee LÈVE sur toute écriture dans une présentation scellée (active=false ET
|
|
52
|
+
-- control_hash IS NULL). Une seule ligne d'une présentation scellée ferait échouer la migration
|
|
53
|
+
-- ENTIÈRE. On exclut donc ces lignes — une présentation scellée est GELÉE, y compris son rang : ses
|
|
54
|
+
-- messages restent sans mod_seq (historiques, jamais resynchronisés en direct, servis par la charge
|
|
55
|
+
-- complète). Deux gardes correctes — le scellé (0007) et ce rattrapage — dont la COMPOSITION
|
|
56
|
+
-- bloquerait sans cette exclusion, et qu'aucun des deux fichiers ne pouvait prévoir seul.
|
|
57
|
+
update public.doc_presentation_messages m
|
|
58
|
+
set mod_seq = nextval('public.doc_presentation_messages_modseq')
|
|
59
|
+
where m.mod_seq is null
|
|
60
|
+
and not exists (
|
|
61
|
+
select 1 from public.doc_presentations p
|
|
62
|
+
where p.slug = m.slug and p.active = false and p.control_hash is null
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
-- La lecture différentielle `slug=eq.X&mod_seq=gt.N&order=mod_seq.asc` doit rester rapide.
|
|
66
|
+
create index if not exists idx_dpm_slug_modseq
|
|
67
|
+
on public.doc_presentation_messages (slug, mod_seq);
|