Magasin SQLite des conversations du chat (~/.diapason/conversations.db).
Le serveur est la source de vérité : l'historique vivait dans le
localStorage du frontend, cloisonné par origine, et la fenêtre principale
(tauri://localhost) et le mini-panneau (http://127.0.0.1:8000) portaient
donc chacun le leur — deux historiques qui divergeaient en silence
(16 sept. 2026).
Deux choix de conception, chacun né d'un défaut trouvé en revue le même
jour :
-
Le curseur des clients est un numéro d'écriture (seq), pas une
heure. Un premier jet filtrait updated_at > since sur l'heure du
CONTENU fournie par le client : une conversation poussée en retard (vue
fermée puis rouverte, serveur relancé pendant la poussée) arrivait avec
son vieux updatedAt et restait invisible à jamais aux vues dont le
curseur avait déjà dépassé cette heure. Chaque écriture prend ici un
numéro monotone ; un client qui demande since=N reçoit tout ce qui a
été écrit après, quelle que soit l'heure que porte le contenu.
-
La fusion se fait au grain du MESSAGE, pas de la conversation. Un
dernier-écrit-gagne sur la conversation entière faisait perdre des
messages dès que deux vues touchaient le même fil dans la fenêtre de
synchronisation (une question envoyée depuis le mini-panneau pendant
que la fenêtre principale finissait de recevoir sa réponse) : la copie
la plus récente écrasait l'autre, question et réponse comprises.
:func:fusionner_conversations fait l'union des messages par identité,
et le frontend applique la MÊME règle (convSync.ts) : quel que soit
l'ordre des poussées, toutes les vues convergent vers le même résultat.
Les messages restent opaques pour le serveur hors de leur identité (id,
ou role@timestamp pour les historiques d'avant l'identifiant). Une
suppression laisse une pierre tombale (deleted_at non NULL) dont title
et messages sont vidés — une suppression qui garde le texte n'est pas une
suppression.
Classes
ConversationsStore
ConversationsStore(db_path: str | Path = '')
Conversations vivantes et pierres tombales, numérotées par écriture.
Toutes les vues tournent sur la même machine : les horloges sont
identiques, updatedAt / deletedAt (ms epoch) suffisent pour
départager deux versions d'un même objet. Mais l'ORDRE dans lequel le
serveur apprend les choses, lui, n'a rien à voir avec ces heures — d'où
seq.
Source code in src/diapason/server/conversations_store.py
| def __init__(self, db_path: str | Path = "") -> None:
# Un chemin qui n'est ni str ni Path est refusé AVANT d'ouvrir quoi
# que ce soit : ``Path(MagicMock())`` rend « MagicMock/<nom>/<id> »
# via __fspath__, et 42 vraies bases SQLite ont déjà dormi à la
# racine du dépôt pour cette raison (piège documenté du dépôt).
if db_path and not isinstance(db_path, (str, Path)):
raise TypeError(
f"db_path doit être str ou Path, pas {type(db_path).__name__}"
)
if not db_path:
db_path = str(get_config_dir() / "conversations.db")
else:
db_path = str(db_path)
if db_path != ":memory:":
from diapason.security.file_utils import secure_create
secure_create(Path(db_path))
self.chemin = db_path
# check_same_thread=False + WAL : les routes tournent en ``def``
# synchrone dans le pool de fils de Starlette, jamais sur le fil qui
# a ouvert la connexion. Le verrou sérialise les écritures.
self._lock = threading.Lock()
self._db = sqlite3.connect(db_path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._db.execute("PRAGMA journal_mode=WAL")
self._db.execute(_CREATE_CONVERSATIONS)
self._db.execute(_CREATE_INDEX_SEQ)
self._db.execute(_CREATE_COMPTEUR)
self._db.execute(
"INSERT OR IGNORE INTO compteur (nom, valeur) VALUES ('seq', 0)"
)
self._db.commit()
self._purge_old_tombstones()
|
Methods:
list
list(
since: Optional[int] = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int]
Rend (vivantes, tombales, seq) écrites APRÈS le numéro since.
Sans since : tout — vivantes et toutes les tombales non purgées.
seq est le dernier numéro attribué : tout ce qui porte un numéro
inférieur ou égal est dans la réponse (ou plus vieux que since),
le client peut donc y poser son curseur.
Source code in src/diapason/server/conversations_store.py
| def list(
self, since: Optional[int] = None
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int]:
"""Rend (vivantes, tombales, seq) écrites APRÈS le numéro ``since``.
Sans ``since`` : tout — vivantes et toutes les tombales non purgées.
``seq`` est le dernier numéro attribué : tout ce qui porte un numéro
inférieur ou égal est dans la réponse (ou plus vieux que ``since``),
le client peut donc y poser son curseur.
"""
with self._lock:
if since is None:
vivantes = self._db.execute(
"SELECT * FROM conversations WHERE deleted_at IS NULL "
"ORDER BY updated_at DESC"
).fetchall()
tombales = self._db.execute(
"SELECT id, deleted_at FROM conversations "
"WHERE deleted_at IS NOT NULL"
).fetchall()
else:
vivantes = self._db.execute(
"SELECT * FROM conversations "
"WHERE deleted_at IS NULL AND seq > ? "
"ORDER BY updated_at DESC",
(since,),
).fetchall()
tombales = self._db.execute(
"SELECT id, deleted_at FROM conversations "
"WHERE deleted_at IS NOT NULL AND seq > ?",
(since,),
).fetchall()
seq = self._seq_courant()
return (
[self._row_to_conversation(row) for row in vivantes],
[{"id": row["id"], "deletedAt": row["deleted_at"]} for row in tombales],
seq,
)
|
upsert
upsert(conv: Dict[str, Any]) -> Dict[str, Any]
Fusionne la copie reçue avec la copie stockée. Rend ce que le
client doit croire désormais.
- tombale avec
deletedAt >= conv.updatedAt : reste supprimée,
rend {"deleted": True, "deletedAt": int} — une suppression qui
ressuscite parce qu'un autre appareil a poussé sa vieille copie
serait un mensonge (§100).
- vivante : la fusion des deux (union des messages, métadonnées de
la plus récente) est stockée si elle diffère de la copie stockée,
rend
{"conversation": <copie stockée>}. Une fusion sans effet
n'écrit rien et ne consomme pas de numéro : les autres vues n'ont
rien à apprendre.
- inconnue, ou tombale plus vieille : stockée telle quelle (la
tombale est effacée), rend
{"conversation": <telle que stockée>}.
Source code in src/diapason/server/conversations_store.py
| def upsert(self, conv: Dict[str, Any]) -> Dict[str, Any]:
"""Fusionne la copie reçue avec la copie stockée. Rend ce que le
client doit croire désormais.
- tombale avec ``deletedAt >= conv.updatedAt`` : reste supprimée,
rend ``{"deleted": True, "deletedAt": int}`` — une suppression qui
ressuscite parce qu'un autre appareil a poussé sa vieille copie
serait un mensonge (§100).
- vivante : la fusion des deux (union des messages, métadonnées de
la plus récente) est stockée si elle diffère de la copie stockée,
rend ``{"conversation": <copie stockée>}``. Une fusion sans effet
n'écrit rien et ne consomme pas de numéro : les autres vues n'ont
rien à apprendre.
- inconnue, ou tombale plus vieille : stockée telle quelle (la
tombale est effacée), rend ``{"conversation": <telle que stockée>}``.
"""
with self._lock:
row = self._db.execute(
"SELECT * FROM conversations WHERE id = ?", (conv["id"],)
).fetchone()
updated_at = int(conv["updatedAt"])
if row is not None and row["deleted_at"] is not None:
if row["deleted_at"] >= updated_at:
return {"deleted": True, "deletedAt": row["deleted_at"]}
a_stocker = conv
elif row is not None:
stockee = self._row_to_conversation(row)
a_stocker = fusionner_conversations(stockee, conv)
if a_stocker == stockee:
return {"conversation": stockee}
else:
a_stocker = conv
self._ecrire(a_stocker)
stored = self._db.execute(
"SELECT * FROM conversations WHERE id = ?", (conv["id"],)
).fetchone()
return {"conversation": self._row_to_conversation(stored)}
|
delete
delete(conversation_id: str) -> int
Pose une pierre tombale et rend son deletedAt (ms).
Idempotent, même pour un id inconnu : re-supprimer ce qui n'existe
pas doit réussir, sinon deux appareils qui suppriment la même
conversation verraient le second échouer pour rien. La tombale vide
title et messages — une suppression qui garde le texte n'est pas
une suppression.
Source code in src/diapason/server/conversations_store.py
| def delete(self, conversation_id: str) -> int:
"""Pose une pierre tombale et rend son ``deletedAt`` (ms).
Idempotent, même pour un id inconnu : re-supprimer ce qui n'existe
pas doit réussir, sinon deux appareils qui suppriment la même
conversation verraient le second échouer pour rien. La tombale vide
title et messages — une suppression qui garde le texte n'est pas
une suppression.
"""
with self._lock:
row = self._db.execute(
"SELECT deleted_at FROM conversations WHERE id = ?",
(conversation_id,),
).fetchone()
existing = row["deleted_at"] if row is not None else None
deleted_at = max(_now_ms(), existing or 0)
seq = self._prochain_seq()
self._db.execute(
"INSERT INTO conversations "
"(id, title, created_at, updated_at, model, pinned, messages, "
"deleted_at, seq) VALUES (?, '', 0, 0, '', 0, '[]', ?, ?) "
"ON CONFLICT(id) DO UPDATE SET "
"title = '', messages = '[]', pinned = 0, "
"deleted_at = excluded.deleted_at, seq = excluded.seq",
(conversation_id, deleted_at, seq),
)
self._db.commit()
return deleted_at
|
Functions:
sans_substituts
sans_substituts(texte: str) -> str
Remplace tout substitut UTF-16 isolé par U+FFFD.
JSON.stringify (bien-formé depuis ES2019) échappe un demi-surrogat
collé dans un message en \ud800 — JSON valide — que json.loads
rend en str Python porteuse d'un surrogat isolé. Stocké tel quel,
sqlite3 refusait de l'encoder en UTF-8 : 500 sur chaque poussée de la
conversation, qui ne se synchronisait plus jamais (constaté en revue le
16 sept. 2026).
Source code in src/diapason/server/conversations_store.py
| def sans_substituts(texte: str) -> str:
"""Remplace tout substitut UTF-16 isolé par U+FFFD.
``JSON.stringify`` (bien-formé depuis ES2019) échappe un demi-surrogat
collé dans un message en ``\\ud800`` — JSON valide — que ``json.loads``
rend en ``str`` Python porteuse d'un surrogat isolé. Stocké tel quel,
sqlite3 refusait de l'encoder en UTF-8 : 500 sur chaque poussée de la
conversation, qui ne se synchronisait plus jamais (constaté en revue le
16 sept. 2026).
"""
return texte.encode("utf-16", "surrogatepass").decode("utf-16", "replace")
|
cle_message
cle_message(message: Dict[str, Any]) -> str
Identité d'un message : son id, sinon role@timestamp.
Les historiques d'avant l'identifiant n'en portent pas ; sans repli,
deux copies du même message se seraient dupliquées à chaque fusion.
Source code in src/diapason/server/conversations_store.py
| def cle_message(message: Dict[str, Any]) -> str:
"""Identité d'un message : son ``id``, sinon ``role@timestamp``.
Les historiques d'avant l'identifiant n'en portent pas ; sans repli,
deux copies du même message se seraient dupliquées à chaque fusion.
"""
ident = message.get("id")
if isinstance(ident, str) and ident:
return ident
return f"{message.get('role', '')}@{message.get('timestamp', 0)}"
|
fusionner_messages
fusionner_messages(
des_a: List[Dict[str, Any]], des_b: List[Dict[str, Any]]
) -> List[Dict[str, Any]]
Union par identité, la version la plus complète par message, dans
l'ordre du temps (puis question avant réponse, puis identité).
Source code in src/diapason/server/conversations_store.py
| def fusionner_messages(
des_a: List[Dict[str, Any]], des_b: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Union par identité, la version la plus complète par message, dans
l'ordre du temps (puis question avant réponse, puis identité)."""
par_cle: Dict[str, Dict[str, Any]] = {}
for message in des_b:
par_cle[cle_message(message)] = message
for message in des_a:
cle = cle_message(message)
autre = par_cle.get(cle)
par_cle[cle] = message if autre is None else _meilleur_message(message, autre)
return sorted(
par_cle.values(),
key=lambda m: (_nombre(m.get("timestamp")), _rang_role(m), cle_message(m)),
)
|
fusionner_conversations
fusionner_conversations(
a: Dict[str, Any], b: Dict[str, Any]
) -> Dict[str, Any]
Jointure commutative et idempotente de deux copies d'une conversation.
Les métadonnées viennent de la copie qui prime, les messages sont
l'union des deux, updatedAt le plus haut, createdAt le plus bas.
Re-fusionner le résultat avec l'une des entrées rend le résultat.
Source code in src/diapason/server/conversations_store.py
| def fusionner_conversations(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
"""Jointure commutative et idempotente de deux copies d'une conversation.
Les métadonnées viennent de la copie qui prime, les messages sont
l'union des deux, ``updatedAt`` le plus haut, ``createdAt`` le plus bas.
Re-fusionner le résultat avec l'une des entrées rend le résultat.
"""
gagnante, perdante = (a, b) if _prime(a, b) else (b, a)
return {
"id": gagnante["id"],
"title": gagnante["title"],
"createdAt": min(int(a["createdAt"]), int(b["createdAt"])),
"updatedAt": max(int(a["updatedAt"]), int(b["updatedAt"])),
"model": gagnante["model"],
"pinned": bool(gagnante.get("pinned")),
"messages": fusionner_messages(gagnante["messages"], perdante["messages"]),
}
|