> ## Documentation Index
> Fetch the complete documentation index at: https://docs.oximail.ch/llms.txt
> Use this file to discover all available pages before exploring further.

# oximail-store

> Le socle de stockage : le trait SqlBackend au-dessus d'une base SQLite avec pool de connexions, les migrations versionnées et appliquées automatiquement, l'index de recherche plein texte Tantivy, et le blob store fichier adressé par contenu.

`oximail-store` est le socle de stockage dont dépend chaque crate de domaine. Il définit trois traits de stockage : `Store` (métadonnées structurées), `BlobStore` (contenu binaire) et `SearchStore` (recherche plein texte). Il fournit aussi l'implémentation par défaut derrière chacun : une base SQLite avec pool de connexions, un blob store fichier adressé par contenu, et un index plein texte Tantivy. Il possède également le schéma : l'ensemble de migrations versionnées, appliquées automatiquement au démarrage.

## Overview

Les crates de domaine ne parlent jamais directement à un pilote de base de données. Elles détiennent un `Arc<dyn SqlBackend>` et construisent des tranches `Param` typées. Le backend les traduit vers le pilote natif et renvoie des valeurs `OwnedRow` qui possèdent leurs données. C'est la frontière entre le pilote SQL et le reste d'OxiMail. Aucune crate au-dessus de `oximail-store` n'importe `rusqlite`.

La crate se situe tout en bas du graphe de dépendances. `oximail-core` réexporte ses primitives `Id`, `BlobId` et `State`. Toutes les autres crates atteignent le stockage par les traits définis ici. Les contrats sont volontairement étroits : chaque méthode de `Store`, `BlobStore` et `SearchStore` prend `tenant_id` en paramètre obligatoire. Un handler ne peut donc physiquement pas lire les données d'une autre organisation, même en présence d'un bug.

La version communautaire (AGPL) repose sur **SQLite + Tantivy + système de fichiers**. Il existe une seule implémentation de `SqlBackend` dans la crate : `SqliteBackend`. C'est le socle de stockage qu'OxiMail livre et exécute en production.

<Note>
  Les supports marketing et tarifaires mentionnent parfois PostgreSQL, un stockage objet ou un cluster de recherche externe comme options de montée en charge. **Aucun de ces backends n'existe dans la crate AGPL `oximail-store`.** Le seul backend SQL implémenté est SQLite (avec un pool r2d2). La recherche est Tantivy. Les blobs sont sur le système de fichiers local. Quelques commentaires dans le code citent PostgreSQL comme une note de *compatibilité future* (par exemple, le moteur de migrations documente l'équivalent `pg_advisory_lock` qu'il utiliserait), mais aucun code PostgreSQL, S3 ou Elasticsearch n'est présent. Les backends alternatifs pour les grands déploiements multi-tenant relèvent d'une préoccupation de montée en charge distincte, hors de cette crate. Cette page documente uniquement ce que fournit réellement la crate communautaire AGPL.
</Note>

## Features

* **Trait `SqlBackend`** : une interface SQL à type effacé (`execute`, `query`, `query_one`, `query_opt`, `begin`, `execute_batch`) implémentée par `SqliteBackend`. Les stores de domaine construisent des tranches `&[Param]` et lisent des résultats `OwnedRow` au lieu de toucher au pilote.
* **Pool de connexions r2d2, pas de `Mutex<Connection>`** : `SqliteBackend` emprunte une connexion au pool r2d2 à chaque appel. Le mode WAL autorise des lecteurs concurrents pendant qu'un seul écrivain tient le verrou.
* **PRAGMA et cache d'instructions par connexion** : chaque connexion du pool est initialisée avec `journal_mode=WAL`, `busy_timeout=5000`, `synchronous=NORMAL`, `cache_size` et `foreign_keys=ON`. Les requêtes JMAP concurrentes ne voient donc pas d'erreurs `SQLITE_BUSY` parasites. Chaque instruction passe par un cache d'instructions préparées propre à la connexion (256 entrées) : les requêtes répétées sautent entièrement la recompilation SQL.
* **Migrations versionnées (ADR-013)** : un ensemble de migrations ordonné et statique, appliqué au démarrage dans une seule transaction `BEGIN EXCLUSIVE`. Échec bruyant : toute erreur annule le lot entier et le serveur refuse de démarrer.
* **Validateur de cohérence des FK** : une vérification au moment des tests (`validate_fk_consistency`) qui attrape une classe de bug de clé étrangère que SQLite accepte en silence au `CREATE TABLE`, mais qui ne se manifeste qu'au premier `DELETE` sur le parent.
* **Trait `Store`** : CRUD structuré, suivi des changements JMAP (RFC 8620 §5.2), concurrence optimiste (`record_changes` / `atomic_set` avec `ifInState`), surcharges de configuration, et suppression différée native avec restauration.
* **Trait `BlobStore` + store fichier** : stockage binaire adressé par contenu, indexé par le `SHA-256` du clair, avec écriture atomique (fichier + ligne de métadonnées en une seule opération).
* **Trait `SearchStore` + index Tantivy** : recherche plein texte avec un worker d'écriture en arrière-plan qui regroupe les commits.
* **Isolation par organisation obligatoire (ADR-015)** : `tenant_id` est un argument requis sur chaque méthode du store, et chaque requête SQL filtre dessus.

## How it works

### L'abstraction de backend

`SqlBackend` est l'unique couture entre OxiMail et sa base de données. Un store de domaine détient un `Arc<dyn SqlBackend>` et appelle `execute` / `query` / `query_one` / `query_opt` avec une chaîne SQL et une tranche `&[Param]`. `Param` est une petite énumération (`Null`, `Integer`, `Real`, `Text`, `Blob`) avec des conversions `From` pour chaque type utilisé dans une requête. Les sites d'appel construisent donc les paramètres avec la macro `params![]` plutôt qu'avec la macro du pilote.

Les résultats reviennent sous forme de valeurs `OwnedRow`. Contrairement à une ligne empruntée au pilote, une `OwnedRow` possède ses données et peut être renvoyée par une méthode ou rangée dans une collection. Une ligne peut porter une table partagée de noms de colonnes. Un parseur peut alors lire les colonnes par nom et tolérer n'importe quel sous-ensemble ou ordre. C'est la base de la projection de colonnes, où un `SELECT` omet les colonnes dont l'appelant n'a pas besoin. Une colonne **obligatoire** absente est une erreur dure. Une colonne **optionnelle** absente se lit comme `None`, exactement comme un `NULL` SQL. Rien n'est converti en silence.

Une `Transaction` expose les mêmes opérations et doit être validée explicitement. Elle s'annule à la destruction. Le trait `SqlExecutor` est implémenté à la fois par le backend et par une transaction. Une méthode de store peut donc s'écrire une fois et s'exécuter à l'intérieur ou en dehors d'une transaction. C'est ainsi que les handlers `/set` gardent toutes leurs écritures sur la même connexion (règle stricte : deux `BEGIN IMMEDIATE` sur deux connexions, c'est un interblocage).

### Pool, pas de verrou unique

Le backend SQLite enveloppe un pool r2d2. Le défaut au niveau de la crate est de 8 connexions. Le serveur ouvre son pool partagé avec 32, et l'administrateur peut surcharger cette valeur avec `[storage] pool_size` (chaque connexion porte son propre cache de pages borné, environ 8 Mio). Chaque appel emprunte une connexion, exécute l'instruction, puis la rend. Il n'y a aucun `Mutex<Connection>` nulle part. Le mode WAL laisse les lecteurs avancer pendant qu'un seul écrivain tient le verrou d'écriture.

Chaque connexion empruntée au pool exécute le même lot de PRAGMA : journalisation WAL, `busy_timeout` de 5 secondes, `synchronous=NORMAL`, un cache de pages borné par connexion, et `foreign_keys=ON`. Le `busy_timeout` est ce qui évite aux requêtes JMAP concurrentes de voir des erreurs `SQLITE_BUSY` parasites. Le backend tient aussi un compteur global des éventuelles erreurs busy, enrichi depuis le span de tracing appelant. La condition est donc observable plutôt qu'avalée.

Les instructions s'exécutent à travers un cache d'instructions préparées par connexion, dimensionné à 256 entrées. La compilation SQL alloue beaucoup de mémoire, et sous charge concurrente ces allocations se sérialisent sur le mutex mémoire global de SQLite. Repréparer chaque requête a été mesuré comme le coût dominant du chemin de lecture (le passage au cache a triplé le débit des requêtes sur un test de charge saturé). Le cache ramène le cas courant à une simple recherche, pas une compilation.

Le WAL lui-même est borné et observable : le worker de métriques publie la taille du WAL et celle du fichier de base en jauges, un checkpoint `TRUNCATE` s'exécute dès que le WAL dépasse 64 Mio, et les deux modes d'exécution font un checkpoint à l'arrêt propre. Une instance qui tourne longtemps ne peut donc plus accumuler un WAL plusieurs fois plus gros que sa base.

### Migrations : versionnées, appliquées automatiquement, échec bruyant

Le schéma est une tranche statique, `MIGRATIONS`, de **193 migrations numérotées** (versions 1 à 193 sur la ligne actuelle. `MIGRATION_COUNT` est exposé via `/admin/v1/version` sous le nom `migrations_applied` pour la vérification de déploiement). Chaque migration est un numéro de version, une description et un bloc de SQL brut.

Au démarrage, `apply_migrations_backend` exécute chaque migration dont la version est supérieure au `MAX(version)` enregistré, à l'intérieur d'une **seule transaction `BEGIN EXCLUSIVE`**. Le verrou exclusif sérialise les instances concurrentes (un redémarrage progressif ou un démarrage parallèle se met en attente au lieu de courir sur `schema_version`). Si une migration échoue, toute la transaction est annulée et le serveur refuse de démarrer. Il n'y a aucune étape de mise à jour manuelle ni de schéma à moitié appliqué (ADR-013). Après un changement de schéma, il rafraîchit aussi les statistiques du planificateur de requêtes avec `ANALYZE`. Un échec d'`ANALYZE` est journalisé mais n'interrompt pas le démarrage, car la base est pleinement utilisable, simplement non optimisée.

Une crate compagnon hors arbre peut gérer son propre schéma à côté du cœur grâce à `apply_migration_set` avec un espace de noms distinct. Les deux lignes de version n'entrent ainsi jamais en collision.

### Le validateur de cohérence des FK

SQLite accepte un `FOREIGN KEY (a, b) REFERENCES x(c, d)` au `CREATE TABLE` même quand `(c, d)` n'est pas un tuple unique sur `x`. La non-correspondance ne se manifeste qu'au premier `DELETE` sur le parent, sous la forme de l'erreur d'exécution `foreign key mismatch`. C'est une classe de bug bien réelle : une migration passée a livré une FK référençant `(tenant_id, id)` sur une table dont la clé primaire était `(tenant_id, account_id, id)`. Elle n'a été attrapée que lorsqu'un test de régression a supprimé une ligne parente.

`validate_fk_consistency` (`fk_validation.rs`) comble cette faille. Il rejoue l'ensemble des migrations dans l'ordre des versions, en maintenant un modèle des sources d'unicité de chaque table (clé primaire, `UNIQUE` au niveau table et en ligne, `CREATE UNIQUE INDEX`) et de ses FK déclarées, en appliquant `DROP TABLE` pour nettoyer l'état. Après le rejeu, chaque FK survivante est vérifiée contre le modèle d'unicité survivant. Une FK dont le tuple cible n'est pas unique fait échouer le test. Le test `fk_consistency_across_agpl_migrations` l'exécute sur toute la tranche `MIGRATIONS`. Une crate compagnon peut lancer la même vérification sur la concaténation du cœur et de ses propres migrations, pour attraper les FK qui traversent la frontière.

### Store structuré : suivi des changements et concurrence optimiste

Le trait `Store` est un CRUD générique sur des objets JSON indexés par `(tenant_id, account_id, collection, id)`, plus la mécanique JMAP posée par-dessus. `get_objects` renvoie `(id, Option<value>)` pour chaque identifiant demandé. Un identifiant inconnu est donc signalé, jamais ignoré en silence. Les suppressions sont différées : un objet passe dans une table `deleted_items` et peut être restauré. C'est ce qui soutient la restauration native et la récupération par CalDAV/CardDAV des UID détruits lors d'une sync-collection.

Le suivi des changements implémente la RFC 8620 §5.2 : `record_change` / `record_changes` ajoutent au `change_log` et renvoient le nouvel `State`. La concurrence optimiste (ADR-010) est intégrée : `record_changes` et `atomic_set` prennent un `ifInState` optionnel, vérifient l'état courant et écrivent dans la même transaction, puis renvoient une erreur `StateMismatch` si l'état a bougé. Pas de verrou pessimiste, donc pas de risque d'interblocage dans les requêtes groupées. `begin_write_transaction` plus `record_changes_in_txn` permettent à un handler `/set` de faire toutes ses mutations et l'enregistrement des changements dans une seule transaction exclusive.

### Blob store : adressé par contenu et atomique

`BlobStore` est un stockage binaire adressé par le `SHA-256` du **clair** (ADR-014, amendé par la disposition de chiffrement Tier 3.0.19). L'implémentation fichier range les blobs par organisation et par compte :

* les blobs par compte (asymétriques) à `{base}/{tenant_id}/{account_id}/{prefix}/{hash}`,
* les blobs partagés à l'échelle du tenant (symétriques) à `{base}/{tenant_id}/_tenant/{prefix}/{hash}`, où `_tenant` est une sentinelle réservée que de vrais identifiants de compte ne peuvent jamais percuter.

Une écriture est atomique vis-à-vis de la couche de stockage : l'écriture du fichier et l'insertion de la ligne de métadonnées de chiffrement se font en une seule opération possédée par le store. Les appelants n'ont donc jamais à coordonner les deux moitiés. En cas de correspondance de contenu (déduplication), le store renvoie le schéma existant sans réécrire le fichier ni régénérer de clé. Les composants de chemin sont validés pour empêcher la traversée et pour faire respecter la règle de la sentinelle.

Le chemin d'écriture répare aussi de lui-même la seule forme d'écriture partielle qui constituait une impasse : un fichier arrivé sur le disque dont la ligne de schéma de chiffrement a été perdue (un `busy_timeout` sur l'insertion) est un résidu indéchiffrable, et chaque nouvelle tentative sur le même contenu échouait pour toujours sur le contrôle d'orphelin. Le store supprime désormais le résidu et réécrit avec un schéma neuf. Le chemin est adressé par contenu, donc la nouvelle tentative porte le même clair par construction.

### Search store : Tantivy avec un worker de regroupement

`SearchStore` est une recherche plein texte sur des documents indexés par `(tenant_id, account_id, collection, doc_id)` avec un seul champ `content` interrogeable. L'implémentation Tantivy envoie les opérations d'indexation et de suppression à un worker dédié en arrière-plan, par un canal borné. Le worker accumule les mutations et valide au **repos de la file** : un document isolé est validé dès que la file se vide (le temps d'un commit, pas une minuterie fixe), tandis qu'une charge soutenue continue de grouper jusqu'à 50 documents par commit. Ce point compte parce que l'ingestion SMTP attend le commit de recherche avant de répondre `250 OK` (accepté veut dire cherchable) : l'ancienne minuterie fixe de 500 ms posait un plancher de latence sous chaque remise de message isolé. Un `flush` force un commit immédiat, utilisé par les tests et l'arrêt propre.

### Invariants

* **Ne jamais échouer en silence.** Les identifiants inconnus reviennent en `None` dans leur emplacement. Une colonne obligatoire absente, un dépassement de busy\_timeout et une violation de contrainte remontent tous comme des variantes typées de `StoreError`. Rien n'est ignoré.
* **Isolation par organisation dans la couche de stockage.** `tenant_id` est obligatoire sur chaque méthode, et chaque requête filtre dessus. Le RBAC est une défense en profondeur par-dessus, pas l'unique ligne.
* **Aucun `Mutex<Connection>`.** La concurrence repose sur le pool r2d2 plus WAL, jamais sur une connexion unique gardée par un mutex.
* **Migrations tout ou rien.** Une transaction exclusive, échec bruyant en cas d'erreur, aucune étape manuelle.

## Configuration

`oximail-store` ne lit aucun fichier de configuration. Le binaire ouvre le backend avec les valeurs qu'il résout. Les réglages d'administration pertinents sont le chemin de la base, la clé de chiffrement SQLCipher optionnelle (passée à `SqliteBackend::open`), la taille du pool de connexions (`[storage] pool_size`, 32 par défaut pour le pool partagé du serveur), et les chemins de base du blob store et de l'index de recherche. Ils sont documentés dans la [référence de configuration](../operator/configuration). Le modèle de chiffrement derrière le blob store (le schéma au repos de type LUKS, pas du bout en bout) est couvert dans [chiffrement au repos](../operator/encryption-at-rest).

## Protocol / JMAP surface

**Aucune.** `oximail-store` n'expose aucune méthode JMAP et ne sert aucune route HTTP. C'est une bibliothèque consommée par les crates de domaine : elle fournit les traits `SqlBackend`, `Store`, `BlobStore` et `SearchStore` ainsi que leurs implémentations par défaut, plus les primitives `Id` / `BlobId` / `State` que `oximail-core` réexporte. La surface JMAP posée au-dessus de ce stockage vit dans les crates de domaine ([oximail-core](./oximail-core) et les crates mail, calendrier, contact, tâche, fichier et partage).

## Design decisions

* **ADR-002 (SQLite comme backend par défaut)** : la version communautaire tourne sur une seule base SQLite embarquée avec un pool r2d2. Aucun serveur de base de données externe n'est requis.
* **ADR-003 (Tantivy pour la recherche)** : la recherche plein texte est un index Tantivy embarqué, pas un cluster de recherche externe.
* **ADR-013 (migrations versionnées et appliquées automatiquement)** : l'ensemble numéroté `MIGRATIONS` est appliqué au démarrage en une transaction exclusive, échec bruyant, sans procédure de mise à jour manuelle.
* **ADR-015 (isolation par organisation dans la couche de stockage)** : `tenant_id` est un paramètre obligatoire sur chaque méthode du store et chaque requête filtre dessus, en défense en profondeur sous le RBAC.
* **ADR-010 (concurrence optimiste)** : les vérifications `ifInState` et les écritures ont lieu dans la même transaction. Un état qui a bougé renvoie `StateMismatch`. Pas de verrou pessimiste.
* **ADR-014 (déduplication des blobs par hash de contenu)** et **ADR-009 (stockage brut plus structuré)** : les blobs sont adressés par le `SHA-256` du contenu. Le courriel est analysé une fois à l'ingestion et stocké à la fois en octets bruts et en métadonnées structurées.
* **ADR-020 / Tier 3.0.19 (chiffrement au repos)** : la géométrie de chemin des blobs par compte et à l'échelle du tenant, et l'écriture atomique fichier plus métadonnées.

Les ADR vivent dans le dépôt du serveur OxiMail. Voir l'[index des ADR](../reference/adr-index).

## Standards

* [RFC 8620](https://www.rfc-editor.org/rfc/rfc8620), JMAP Core. Le modèle de suivi des changements et d'état implémenté par le trait `Store` (§5.2 les changements, la chaîne `State`, la concurrence optimiste via `ifInState`).

Le reste de la crate est un substrat d'implémentation (SQLite, r2d2, Tantivy, le système de fichiers) plutôt qu'une surface de protocole. Il n'a donc pas d'obligation RFC supplémentaire propre.

## Source map & tests

| Fichier (dans la crate)   | Contenu                                                                                                                        |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `src/backend.rs`          | Les traits `SqlBackend`, `Transaction` et `SqlExecutor`, les types `Param` / `SqlValue` / `OwnedRow`, et la macro `params![]`. |
| `src/sqlite_backend.rs`   | `SqliteBackend`, l'implémentation SQLite avec pool r2d2, l'initialiseur de PRAGMA par connexion, et le compteur `SQLITE_BUSY`. |
| `src/sqlite.rs`           | `SqliteStore`, l'implémentation du trait `Store` (CRUD, change log, `record_changes` optimiste).                               |
| `src/migration.rs`        | La tranche `MIGRATIONS` (193 migrations numérotées), `MIGRATION_COUNT`, `apply_migrations_backend` et `apply_migration_set`.   |
| `src/fk_validation.rs`    | `validate_fk_consistency`, le validateur de cohérence des FK de migration.                                                     |
| `src/filesystem.rs`       | `FsBlobStore`, le blob store fichier adressé par contenu et atomique.                                                          |
| `src/blob_meta_store.rs`  | `BlobMetaStore`, la persistance des schémas de chiffrement par blob.                                                           |
| `src/tantivy_search.rs`   | `TantivySearchStore`, l'index plein texte avec son worker d'écriture par lots.                                                 |
| `src/traits.rs`           | Les définitions des traits `Store`, `BlobStore` et `SearchStore` (chaque méthode prend `tenant_id`).                           |
| `src/types.rs`            | `Id`, `BlobId`, `State`, `StoreError` et les autres types de stockage partagés.                                                |
| `src/layout_migration.rs` | La migration de disposition des blobs Tier 3.0.19 vers le nommage par compte.                                                  |

Le test `fk_consistency_across_agpl_migrations` exécute le validateur de FK sur toute la tranche `MIGRATIONS`. Ajouter une migration avec une clé étrangère incohérente fait donc échouer la suite avant qu'elle n'atteigne une base de données. Chaque nouvelle migration doit passer `cargo test -p oximail-store`.

## Status

Implémenté et en production en **v0.30.0** : l'abstraction `SqlBackend` au-dessus d'une base SQLite avec pool, les traits `Store` / `BlobStore` / `SearchStore`, le schéma de 193 migrations appliquées automatiquement avec le validateur de FK, le blob store fichier adressé par contenu, et l'index plein texte Tantivy. C'est le socle de stockage sur lequel tourne tout le serveur.

La crate communautaire (AGPL) livre exactement un backend SQL, SQLite, plus Tantivy et le blob store fichier. PostgreSQL, le stockage objet et les clusters de recherche externes ne font **pas** partie de cette crate. Les seules références à PostgreSQL dans le code sont des commentaires de compatibilité future, pas du code.
