gradatum-core 0.4.3

Shared primitives: errors, IDs, types
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! Contrat de stockage et recherche full-text + overrides + checksums.
//!
//! [`IndexStore`] expose les opérations d'indexation (FTS5), de gestion des overrides
//! génériques et du drift detection. Il est un sous-trait du [`Index`](crate::index::Index)
//! historique, conçu pour être consommé par les futurs pipelines de recherche sans
//! dépendre de `gradatum-index`.
//!
//! ## Évolution v0.4.0
//!
//! L'erreur `GradatumError` convergera vers un `StoreError` dédié (cf. `QueueStore`) à v0.4.0.

use async_trait::async_trait;
use ulid::Ulid;

use crate::error::GradatumError;
use crate::identity::NoteId;
use crate::index::{FileChecksumEntry, NoteRecord};
use crate::scope::{OverrideScope, VaultId};

// ── Types publics migrés depuis gradatum-index ────────────────────────────────

/// Résultat brut d'une recherche FTS5 avec snippet.
///
/// Retourné par [`IndexStore::search_fts_with_snippet`] — contient le snippet
/// FTS5 natif localisé sur le terme recherché (vs `build_snippet` qui tronque
/// la tête du body).
///
/// Moved from `gradatum-index::sqlite::SearchHitRaw` during an earlier refactoring
/// to allow exposure via the `IndexStore` trait (object-safe).
#[derive(Debug, Clone)]
pub struct SearchHitRaw {
    /// Identifiant ULID de la note.
    pub note_id: NoteId,
    /// Score BM25 brut (valeur négative — meilleur match plus proche de 0).
    pub bm25: f64,
    /// Statut de la note (`"live"`, `"downgraded"`, etc.).
    pub status: String,
    /// Snippet FTS5 natif localisé (`snippet(notes_fts, 0, '»', '«', '...', 32)`).
    pub snippet: String,
    /// Section de la note (ex. `"decisions"`, `"reference"`).
    pub section: String,
    /// Titre H1 de la note (extrait post-curate via migration 0005, peut être absent).
    pub title: Option<String>,
}

/// Entrée auteur retournée par [`IndexStore::distinct_authors`].
///
/// Moved from `gradatum-index::queries::AuthorRow` during an earlier refactoring
/// to allow exposure via the `IndexStore` trait (object-safe).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorRow {
    /// Nom affiché de l'auteur (`author_display_name` si défini, sinon `author_id`).
    pub name: String,
    /// Nombre de notes attribuées à cet auteur.
    pub note_count: u64,
}

/// Lignée d'une note : parents (backlinks) et enfants (forward links).
///
/// Retournée par [`IndexStore::trace_lineage`].
///
/// Moved from `gradatum-index::queries::Lineage` during an earlier refactoring.
#[derive(Debug, Clone, Default)]
pub struct Lineage {
    /// Identifiants ULID des notes qui lient vers cette note (backlinks).
    pub parents: Vec<String>,
    /// Identifiants ULID des notes vers lesquelles cette note lie.
    pub children: Vec<String>,
}

/// Contrat de stockage full-text, overrides et checksums — async, thread-safe.
///
/// Implémenté par `gradatum-index::SqliteIndex`.
///
/// ## Stabilité
///
/// `#[stability::unstable]` — l'API peut changer jusqu'à v1.0.0.
/// L'erreur `GradatumError` convergera vers `StoreError` dédié (cf. `QueueStore`) à v0.4.0.
///
/// ## Contention
///
/// En v0.3.0, ce trait partage un `Arc<Mutex<Connection>>` unique avec `DocumentStore`
/// et `VectorStore`. Séparation physique des connexions prévue à v0.4.0.
// AM1 : instabilité documentée ici et dans le module doc.
// `#[stability::unstable]` différé v0.4.0 — nécessite `[features] unstable-storage-traits = []`
// dans gradatum-core/Cargo.toml + opt-in de tous les consommateurs workspace.
// La macro stability n'empêche rien (pas d'E0365) ; sans la feature déclarée elle émettrait
// un deprecated warning sur chaque consommateur.
#[async_trait]
pub trait IndexStore: Send + Sync {
    /// Recherche plein texte dans un vault.
    ///
    /// Retourne les `NoteId` correspondants triés par pertinence descendante.
    /// `limit` est le nombre maximum de résultats.
    async fn search_fts(
        &self,
        vault_id: &VaultId,
        query: &str,
        limit: usize,
    ) -> Result<Vec<NoteId>, GradatumError>;

    /// Recherche FTS5 retournant les ids triés par BM25 + score réel + status.
    ///
    /// Le score est la valeur `bm25(notes_fts)` (négative — meilleur match
    /// = plus proche de 0). Ordre cohérent avec `search_fts` (ASC par score).
    ///
    /// Retourne des triplets `(NoteId, score_bm25, status)` triés du meilleur
    /// au moins bon match.
    ///
    /// Param `include_downgraded` :
    /// - `false` (défaut) : exclut les notes avec `status = 'downgraded'`.
    /// - `true` : inclut les notes downgraded avec un score BM25 multiplié par 0.1
    ///   (pénalité de pertinence — elles apparaissent en dernier).
    async fn search_fts_scored(
        &self,
        vault_id: &VaultId,
        query: &str,
        limit: usize,
        include_downgraded: bool,
    ) -> Result<Vec<(NoteId, f64, String)>, GradatumError>;

    /// Insère ou met à jour un override dans la table générique `note_overrides`.
    ///
    /// La clé est `(note_id, scope, override_type)` — 1 override actif par tuple (décision Q7).
    /// `payload_toml` est le payload sérialisé via `OverridePayload::to_toml()`.
    async fn upsert_override_raw(
        &self,
        note_id: NoteId,
        scope: &OverrideScope,
        override_type: &str,
        schema_version: u32,
        payload_toml: &str,
    ) -> Result<(), GradatumError>;

    /// Récupère un override depuis la table générique.
    ///
    /// Retourne `(schema_version, payload_toml)` ou `None` si absent.
    async fn get_override_raw(
        &self,
        note_id: NoteId,
        scope: &OverrideScope,
        override_type: &str,
    ) -> Result<Option<(u32, String)>, GradatumError>;

    /// Insère ou met à jour une entrée de checksum de fichier.
    ///
    /// Utilisé par le drift detector pour tracker l'état attendu des fichiers Markdown.
    async fn upsert_file_checksum(&self, entry: &FileChecksumEntry) -> Result<(), GradatumError>;

    /// Liste toutes les entrées de checksum de fichiers.
    ///
    /// Utilisé par le drift detector lors d'un scan complet du vault.
    async fn list_file_checksums(&self) -> Result<Vec<FileChecksumEntry>, GradatumError>;

    /// Retourne `(created_ms, in_degree)` pour une note.
    ///
    /// `created_ms` : timestamp de création en millisecondes epoch Unix.
    /// `in_degree` : nombre de backlinks entrants (liens wikilinks pointant vers cette note).
    ///
    /// Un backend sans table de liens PEUT retourner `(created_ms, 0)` pour `in_degree`.
    ///
    /// # Erreurs
    ///
    /// - `GradatumError::NoteNotFound` si la note est absente.
    /// - `GradatumError::Storage` si la requête échoue ou si `note_id` n'est pas un ULID valide.
    async fn get_note_created_and_indegree(
        &self,
        vault_id: &str,
        note_id: &str,
    ) -> Result<(i64, u64), GradatumError>;

    // ── Méthodes promues à l'Étape 0.2a (dyn-wiring) ─────────────────────────

    /// Recherche FTS5 avec snippet FTS5 natif et filtre section + locus optionnels.
    ///
    /// Retourne [`SearchHitRaw`] qui inclut le snippet, la section, le titre et le score BM25.
    ///
    /// # Paramètres
    ///
    /// - `vault_id` : identifiant du vault (ex. `VaultId::new("main")`).
    /// - `query` : requête FTS5 normalisée (via `build_fts_query`).
    /// - `limit` : nombre max de résultats.
    /// - `include_downgraded` : si `false`, exclut les notes `status='downgraded'`.
    /// - `section` : filtre section optionnel (`None` = toutes sections).
    /// - `locus` : filtre préfixe de locus optionnel (`None` = tous loci). La valeur
    ///   doit être déjà échappée via `escape_like` avant d'être passée.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête SQLite échoue.
    async fn search_fts_with_snippet(
        &self,
        vault_id: &VaultId,
        query: &str,
        limit: usize,
        include_downgraded: bool,
        section: Option<&str>,
        locus: Option<&str>,
    ) -> Result<Vec<SearchHitRaw>, GradatumError>;

    /// Cherche une note par son titre Markdown (première ligne `# {title}`).
    ///
    /// Retourne l'identifiant ULID de la première note trouvée, ou `None`.
    /// Exclut les notes `status != 'live'` (sémantique legacy vault : notes archivées
    /// non adressables par titre).
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête échoue.
    async fn title_lookup(
        &self,
        vault_id: &str,
        title: &str,
    ) -> Result<Option<String>, GradatumError>;

    /// Compte les notes `status = 'live'` pour un vault.
    ///
    /// Exclut les sentinelles (`id NOT LIKE '__sentinel__%'`).
    /// Utilisé par `vault_status.note_count`.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête SQLite échoue.
    async fn live_note_count(&self, vault_id: &str) -> Result<u64, GradatumError>;

    /// Liste les auteurs distincts d'un vault avec leur nombre de notes.
    ///
    /// Exclut les sentinelles et les notes sans auteur.
    /// Retourne `name` = `author_display_name` si défini, sinon `author_id`.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête SQLite échoue.
    async fn distinct_authors(&self, vault_id: &str) -> Result<Vec<AuthorRow>, GradatumError>;

    /// Liste les tags distincts d'un vault avec leur fréquence.
    ///
    /// Retourne `Vec<(tag, count)>` trié par fréquence décroissante.
    /// Les tags sont agrégés côté Rust (split espace depuis `notes.tags`).
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête échoue.
    async fn distinct_tags(&self, vault_id: &str) -> Result<Vec<(String, u64)>, GradatumError>;

    /// Retourne les voisins d'une note jusqu'à `depth` niveaux (max 3).
    ///
    /// Utilise un CTE récursif BFS sur `note_links`. La note source est exclue du résultat.
    /// `depth` est plafonné à 3 pour éviter une traversée runaway.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête CTE échoue.
    async fn neighbors(
        &self,
        vault_id: &str,
        note_id: &str,
        depth: u8,
    ) -> Result<Vec<String>, GradatumError>;

    /// Retourne les backlinks (notes qui lient vers `note_id`) pour un vault.
    ///
    /// Nécessite la table `note_links` (migration 0002).
    /// Retourne une liste d'identifiants ULID (`src_note_id`) qui pointent vers `note_id`.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête échoue.
    async fn backlinks(&self, vault_id: &str, note_id: &str) -> Result<Vec<String>, GradatumError>;

    /// Retourne la lignée d'une note : parents (backlinks) et enfants (forward links).
    ///
    /// Combine deux requêtes sur `note_links` :
    /// - `parents` = notes qui pointent vers `note_id`.
    /// - `children` = notes vers lesquelles `note_id` pointe.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si l'une des requêtes échoue.
    async fn trace_lineage(&self, vault_id: &str, note_id: &str) -> Result<Lineage, GradatumError>;

    /// Liste les notes d'un vault avec pagination par curseur ULID.
    ///
    /// Retourne `(records, total)` — `total` est le comptage absolu (pour `X-Total-Count`).
    /// `cursor` = dernier ULID reçu (exclusif) ; `None` = début de liste.
    /// `section` = filtre optionnel sur la section.
    /// Notes downgraded exclues.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête échoue.
    async fn list_notes(
        &self,
        vault_id: &str,
        section: Option<&str>,
        limit: usize,
        cursor: Option<&str>,
    ) -> Result<(Vec<NoteRecord>, u64), GradatumError>;

    /// Somme totale de `LENGTH(body_text)` pour les notes non-sentinelles d'un vault.
    ///
    /// Retourne 0 si aucune note. Utilisé par `vault_status.total_size_bytes`.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête SQLite échoue.
    async fn total_body_size_bytes(&self, vault_id: &str) -> Result<u64, GradatumError>;

    /// Insère ou ignore un lien wikilink entre deux notes.
    ///
    /// Idempotent (`INSERT OR IGNORE`). Utilisé par le curator pour enregistrer
    /// les liens `[[...]]` détectés dans le body de la note.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête échoue.
    async fn upsert_link(
        &self,
        vault_id: &str,
        src_note_id: &str,
        dst_note_id: &str,
    ) -> Result<(), GradatumError>;

    /// Récupère `title` et `section` en batch pour une liste d'identifiants ULID.
    ///
    /// Utilisé par le handler `vault_search` pour enrichir les hits sémantique-only
    /// (présents dans le résultat RRF fusionné mais absents de la map BM25) avec
    /// leurs métadonnées `title` et `section`.
    ///
    /// ## Comportement
    ///
    /// - 1 seul SELECT `id, title, section FROM notes WHERE vault_id = ? AND id IN (…)`.
    /// - Les identifiants absents de la table `notes` ne figurent pas dans le résultat.
    /// - Sentinelles (id LIKE `__sentinel__%`) exclues.
    ///
    /// ## Retour
    ///
    /// `HashMap<note_id, (title, section)>` — `title` est `None` si la colonne est
    /// NULL (note antérieure à la migration 0009 sans H1).
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête SQLite échoue.
    async fn get_titles_sections(
        &self,
        vault_id: &str,
        ids: &[String],
    ) -> Result<std::collections::HashMap<String, (Option<String>, String)>, GradatumError>;

    /// F-47 — Lit le score trust d'une note depuis la colonne `notes.trust`.
    ///
    /// Retourne `Some(trust)` si la note existe et que la colonne est non-NULL,
    /// `None` si la note est absente ou que le trust n'a pas été positionné.
    ///
    /// **En v0.4.0 : écrit/stocké, non consommé par le scoring** (consommation = F-17 v0.4.1).
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête SQLite échoue.
    async fn get_trust(&self, id: &NoteId) -> Result<Option<f32>, GradatumError>;

    /// F-39 — Insère ou met à jour un redirect `slug_ancien → ulid` dans `redirect_table`.
    ///
    /// `renamed_at_ms` : timestamp de renommage en millisecondes epoch Unix.
    ///
    /// Idempotent : `INSERT OR REPLACE` — si un redirect existe pour ce slug,
    /// il est remplacé par le nouveau ULID (le dernier rename gagne).
    ///
    /// **En v0.4.0 :** appelé par `gradatum-admin vault rename` après chaque rename.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si l'INSERT échoue.
    async fn upsert_redirect(
        &self,
        slug: &str,
        ulid: &Ulid,
        renamed_at_ms: i64,
    ) -> Result<(), GradatumError>;

    /// F-39 — Résout un slug d'ancien titre vers son ULID actuel via `redirect_table`.
    ///
    /// Retourne `Some(ulid)` si le slug existe dans la table de redirection,
    /// `None` si aucun redirect n'est enregistré pour ce slug.
    ///
    /// Le slug est obtenu par `title_to_slug(titre_ancien)` (lowercase + espaces→tirets).
    ///
    /// **En v0.4.0 :** utilisé par la couche lecture (handler `vault_read`) comme
    /// fallback quand `title_lookup` échoue — résolution transparente après rename.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête SQLite échoue.
    async fn resolve_redirect(&self, slug: &str) -> Result<Option<Ulid>, GradatumError>;

    // ── Semantic Forget — résolution de scope ─────────────────────────────────

    /// Résout un scope Topic via FTS5 pour l'oubli sémantique.
    ///
    /// Retourne `Vec<(id, section)>` — les notes correspondant à la query FTS,
    /// avec un limit de sécurité de 200.
    ///
    /// ## Guard query vide
    ///
    /// Si `query.trim().is_empty()`, retourne `vec![]` sans erreur.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête FTS5 échoue.
    async fn search_fts_for_forget(
        &self,
        vault_id: &str,
        query: &str,
        limit: usize,
    ) -> Result<Vec<(String, String)>, GradatumError>;

    /// Résout un scope Locus via préfixe LIKE pour l'oubli sémantique.
    ///
    /// Retourne `Vec<(id, section)>` — les notes dont le `locus` commence par `prefix`.
    /// L'échappement LIKE est appliqué côté implémentation — ne pas ré-échapper en amont.
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête SQLite échoue.
    async fn list_notes_by_locus_prefix(
        &self,
        vault_id: &str,
        prefix: &str,
    ) -> Result<Vec<(String, String)>, GradatumError>;

    /// Résout un scope Agent via `author_id` pour l'oubli sémantique.
    ///
    /// Retourne `Vec<(id, section)>` — les notes créées par `agent_id` dans `vaults`.
    /// `vaults` vide → toutes les notes de l'agent (pas de filtre vault).
    /// Cap de sécurité : 20 vaults max (protection DoS).
    ///
    /// # Erreurs
    ///
    /// Retourne `GradatumError::Storage` si la requête SQLite échoue.
    async fn list_notes_by_agent(
        &self,
        agent_id: &str,
        vaults: &[String],
    ) -> Result<Vec<(String, String)>, GradatumError>;
}