Back to flin
flin

Chaque entité se souvient de tout : le modèle temporel

Comment nous avons conçu le modèle temporel de FLIN pour que chaque entité suive automatiquement son historique complet -- zéro configuration, zéro code répétitif, voyage dans le temps inclus.

Thales & Claude | March 30, 2026 12 min flin
EN/ FR/ ES
flintemporalversioningmemory-native

La plupart des langages de programmation traitent les données comme jetables. Vous mettez à jour un enregistrement, et l'ancienne valeur disparaît. Vous supprimez une ligne, et elle est partie pour toujours. Si vous voulez un historique, vous le construisez vous-même : tables d'audit, middleware de changelog, frameworks d'event sourcing, colonnes de version avec des triggers. Des centaines de lignes de code d'infrastructure avant de pouvoir répondre à la question la plus simple : « Quelle était cette valeur hier ? »

Nous avons décidé que FLIN serait différent. Chaque entité, dès l'instant de sa création, se souvient de chaque état qu'elle a jamais connu. Pas comme une fonctionnalité optionnelle. Pas comme un plugin. Comme la nature fondamentale des données dans le langage lui-même.

Voici l'histoire de la conception et de la construction du modèle temporel de FLIN -- la philosophie derrière, l'architecture qui le fait fonctionner, et pourquoi nous croyons que le « memory-native » est l'avenir du développement d'applications.

Le problème que nous rencontrions sans cesse

Avant FLIN, chaque application que Thales construisait chez ZeroSuite tombait sur le même pattern. Un client comptable appelait : « Le solde était faux mardi dernier, mais maintenant il est différent -- que s'est-il passé ? » Un enseignant sur Déblo signalait : « La note de mon élève a changé et je ne sais pas quand. » Un utilisateur sur n'importe quelle plateforme écrasait accidentellement des données sans aucun moyen de les récupérer.

La solution était toujours la même : construire une piste d'audit. Ajouter une table _history. Écrire des triggers. Câbler un middleware. Tester le middleware. Découvrir que le middleware rate des cas limites. Corriger les cas limites. Répéter pour chaque entité du système.

Après avoir construit des pistes d'audit pour la cinquième fois, le pattern est devenu évident. Cela ne devrait pas être du code applicatif. Cela devrait être de l'infrastructure. Mieux encore, cela devrait être le langage lui-même.

Le principe de conception : temporel à configuration zéro

Le principe de conception central était la simplicité radicale. Si vous définissez une entité dans FLIN, elle suit l'historique. Point. Pas d'annotations. Pas de configuration. Pas d'opt-in.

flinentity User {
    name: text
    email: text
}

user = User { name: "Juste", email: "[email protected]" }
save user

user.name = "Juste Gnimavo"
save user

// Both versions exist. Always.
current_name = user.name          // "Juste Gnimavo"
old_name = (user @ -1).name       // "Juste"

C'est toute l'API. Définissez votre entité. Sauvegardez-la. Mettez-la à jour. Sauvegardez-la à nouveau. Chaque état précédent est préservé et interrogeable. Le développeur écrit zéro code supplémentaire. Il n'y a pas d'annotation @Versioned, pas d'appel enable_history(), pas de migration pour ajouter une table d'historique. Le langage s'en charge.

Nous avons appelé cela « memory-native » parce que la mémoire n'est pas une fonctionnalité boulonnée sur le modèle de données de FLIN -- c'est le modèle de données. Tout comme les langages à ramasse-miettes ont libéré les développeurs de la gestion manuelle de la mémoire, le modèle temporel de FLIN libère les développeurs de la gestion manuelle de l'historique.

Comment fonctionne le versionnage automatique

Chaque opération save crée une nouvelle version. Le runtime maintient une chronologie de versions pour chaque instance d'entité :

Version 0         Version 1         Version 2         Current
-------------------------------------------------------------------------->

+------------+   +------------+   +------------+   +------------+
| price: 10  |-->| price: 12  |-->| price: 15  |-->| price: 15  |
| 10:00 AM   |   | 2:00 PM    |   | 5:00 PM    |   | (now)      |
+------------+   +------------+   +------------+   +------------+

product @ -3     product @ -2     product @ -1     product

Chaque version stocke l'état complet des champs plus des métadonnées : un numéro de version, un horodatage et l'identifiant d'entité. Les versions précédentes sont immuables -- on ne peut pas modifier l'historique, seulement le lire.

L'implémentation utilise un modèle de stockage bitemporel. Chaque version a un horodatage valid_from (quand cette version est devenue active) et un horodatage valid_to (quand elle a été remplacée). La version courante a valid_to = NULL. Cette structure rend les requêtes par plage temporelle efficaces : pour trouver l'état à un moment donné, le runtime cherche la version dont le valid_from est au moment cible ou avant et dont le valid_to est après (ou NULL).

rust// How `user @ yesterday` translates to a database query
SELECT * FROM users
WHERE id = ?
  AND valid_from <= '2026-12-30T00:00:00Z'
  AND (valid_to IS NULL OR valid_to > '2026-12-30T00:00:00Z')
ORDER BY version DESC
LIMIT 1

Ce n'est pas une requête que le développeur écrit. Le compilateur FLIN la génère à partir de la syntaxe de l'opérateur @. Le développeur écrit user @ yesterday. Le compilateur fait le reste.

L'architecture : six couches de profondeur

Faire en sorte que l'accès temporel ressemble à une primitive du langage a nécessité des changements à chaque couche du compilateur et du runtime de FLIN. Ce n'était pas une fonctionnalité qu'on pouvait boulonner à la fin -- elle devait être tissée dans le tissu du langage, du lexeur à la base de données.

Couche 1 : Lexeur. Le jeton @ a été ajouté au lexeur aux côtés de sept mots-clés temporels : now, today, yesterday, tomorrow, last_week, last_month, last_year. Ce sont des jetons de première classe, pas des littéraux de chaîne ou des fonctions de bibliothèque.

Couche 2 : Analyseur syntaxique. L'analyseur reconnaît entity @ temporal_reference et construit un noeud AST Expr::Temporal. La référence temporelle peut être un entier négatif (version relative), une chaîne de date (temps absolu) ou un mot-clé.

Couche 3 : Vérificateur de types. Le vérificateur de types valide que le côté gauche de @ est un type d'entité et le côté droit est un entier, un temps ou un texte (pour les chaînes de date). Il retourne un type d'entité optionnel parce que la version demandée pourrait ne pas exister.

rust// Type checker validation for @ expressions
match &time_ty {
    FlinType::Int | FlinType::Time | FlinType::Text => {}
    _ => return Err(TypeError::with_hint(
        format!("Expected time reference, found {}", time_ty),
        span,
        "Use a version number like -1, a time keyword like yesterday, \
         or a date string like \"2024-01-15\"",
    )),
}

Couche 4 : Générateur de code. Le générateur de code émet un bytecode différent selon le type de référence temporelle. L'accès par version relative émet OpCode::AtVersion. Les mots-clés temporels émettent OpCode::AtTime avec un octet de code temporel. Les chaînes de date émettent OpCode::AtDate.

Couche 5 : Machine virtuelle. La VM gère chaque opcode temporel en consultant l'historique des versions de l'entité et en trouvant la version correspondante. Pour l'accès relatif, elle indexe directement. Pour l'accès basé sur le temps, elle effectue une recherche binaire sur la liste de versions ordonnée par horodatage.

Couche 6 : Base de données (ZeroCore). La couche de stockage maintient une carte d'historique de versions indexée par (entity_type, entity_id). Chaque entrée est un vecteur de structs EntityVersion ordonnées par horodatage. La méthode get_history() retourne la chronologie complète ; la méthode find_at_version() retourne un instantané spécifique.

rust/// Historical version of an entity for time-travel queries
#[derive(Debug, Clone)]
pub struct EntityVersion {
    pub version: u64,
    pub timestamp: i64,
    pub fields: HashMap<String, Value>,
}

Pourquoi pas l'event sourcing ?

La comparaison évidente est l'event sourcing, et nous l'avons envisagé. En event sourcing, on stocke une séquence d'événements (UserNameChanged, UserEmailUpdated) et on reconstruit l'état en les rejouant. Le modèle temporel de FLIN est différent : nous stockons des instantanés complets à chaque version.

Le compromis est délibéré. L'event sourcing est puissant pour les systèmes où les événements eux-mêmes sont le modèle de domaine principal -- transactions financières, workflows de commandes, édition collaborative. Mais pour la majorité des applications, les développeurs ne pensent pas en événements. Ils pensent en états : « Quel était le nom de cet utilisateur mardi dernier ? » Répondre à cette question dans un système event-sourcé nécessite de rejouer chaque événement jusqu'à ce point. Dans FLIN, c'est une seule recherche indexée.

Le versionnage par instantanés simplifie aussi le modèle de programmation. Il n'y a pas de concept de « projections » ou de « gestionnaires d'événements » ou d'« orchestrateurs de saga ». Vous sauvegardez une entité. Vous interrogez une version passée. C'est tout.

Le coût de stockage est plus élevé -- chaque version stocke une copie complète de tous les champs, pas seulement le delta. Nous adressons cela avec des politiques de rétention planifiées qui compactent les anciennes versions, et en pratique, la commodité de requêtes ponctuelles instantanées l'emporte largement sur le stockage supplémentaire pour les applications que FLIN cible.

Métadonnées de version : pas seulement des données, mais du contexte

Chaque version porte des métadonnées que le développeur peut accéder sans aucune API spéciale :

flinuser = User.find(id)
print(user.version_number)    // 5
print(user.created_at)        // 2026-12-31T14:30:00Z
print(user.updated_at)        // 2026-12-31T18:45:00Z

Ces métadonnées sont automatiquement remplies par le runtime. Quand vous itérez sur l'historique d'une entité, chaque version de la liste a ses propres métadonnées :

flin{for ver in product.history}
    <div class="audit-entry">
        <p>Version #{ver.version_number}</p>
        <p>Created: {ver.created_at}</p>
        <p>Price: ${ver.price}</p>
    </div>
{/for}

Construire une piste d'audit -- la fonctionnalité qui nécessitait autrefois une table dédiée, des triggers et une UI séparée -- est maintenant une boucle {for}.

La garantie d'immuabilité

Une décision de conception que nous avons longuement débattue était l'immuabilité. Pouvait-on modifier une version passée ? Notre réponse a été un non inconditionnel.

flinold_user = user @ -1

// This affects the CURRENT version only
user.name = "New Name"
save user

// Old version unchanged
print((user @ -2).name)       // Still "Juste"

Permettre la mutation des versions historiques saperait chaque cas d'usage que le modèle temporel permet. Les pistes d'audit deviennent insignifiantes si quelqu'un peut réécrire l'historique. Les exigences de conformité demandent des enregistrements immuables. Le débogage devient impossible si les états passés peuvent changer sous vos pieds.

La seule façon de « changer » l'historique est par le mot-clé destroy, qui supprime définitivement une entité et toutes ses versions -- une action délibérée et administrative équivalente au « droit à l'oubli » du RGPD. Nous couvrons cela en détail dans l'article 049.

Cas d'usage concrets

Le modèle temporel n'est pas un exercice académique. Il permet des fonctionnalités produit concrètes avec un minimum de code.

Historique des prix. Les applications e-commerce peuvent afficher l'évolution des prix sans table d'historique de prix séparée :

flinentity Product {
    name: text
    price: money
}

<div class="price-history">
    {for version in product.history.last(10)}
        <div class="price-point">
            <span>{version.created_at.format("MMM DD")}</span>
            <span>{version.price}</span>
        </div>
    {/for}
</div>

Fonctionnalité d'annulation. N'importe quelle entité peut être ramenée à son état précédent :

flinfn undo(entity) {
    previous = entity @ -1
    if previous {
        entity.name = previous.name
        entity.status = previous.status
        save entity
    }
}

Conformité et audit. Chaque changement de champ est enregistré avec un horodatage, satisfaisant les exigences réglementaires sans infrastructure supplémentaire.

Récupération de données. Les changements accidentels sont toujours récupérables parce que la version précédente existe encore.

Considérations de performance

Le modèle temporel ajoute une surcharge -- il n'y a pas lieu de le nier. Chaque opération de sauvegarde écrit deux enregistrements au lieu d'un (la nouvelle version et l'entrée d'historique). Chaque entité consomme plus de stockage à mesure que les versions s'accumulent.

Nous avons atténué cela avec plusieurs choix de conception :

OpérationComplexitéNotes
Version couranteO(1)Indexée par id + valid_to=NULL
Version spécifiqueO(log n)Indexée par id + version
À un moment donnéO(log n)Indexée par id + valid_from
Historique completO(n)Retourne toutes les versions

L'accès à la version courante -- l'opération qui se produit à chaque chargement de page -- est O(1). On ne paie le coût de l'historique que lorsqu'on l'interroge réellement. Et pour l'annotation @retention planifiée, les développeurs pourront définir des politiques de rétention par entité :

flinentity Metric {
    value: number

    @retention(90.days)     // Keep 90 days of history
}

Ce que nous avons construit en session 012

L'infrastructure temporelle initiale a été construite en session 012, un sprint de cinquante minutes qui a ajouté le struct EntityVersion, la carte d'historique de versions dans la VM, les opcodes de requête temporelle et les méthodes d'élagage de l'historique de versions. Dix nouveaux tests. Trois cent un tests passant au total.

Cette session a établi le squelette. Les vingt sessions suivantes ajouteraient le muscle : corriger le stub AtTime pour que les mots-clés fonctionnent réellement, implémenter la propriété .history, atteindre cent pour cent de couverture de tests, et ajouter la suite complète de fonctions de comparaison temporelle.

Le modèle temporel était la fonctionnalité la plus ambitieuse de la conception de FLIN. Il touchait chaque couche du compilateur. Il nécessitait de repenser comment les données circulent dans l'ensemble du système. Et il a tenu une promesse qu'aucun autre langage que nous connaissions ne fait : chaque entité se souvient de tout, automatiquement, pour toujours.


Ceci est la partie 1 de la série sur le modèle temporel de « How We Built FLIN », documentant la conception et l'implémentation du système de données memory-native de FLIN.

Navigation de la série : - [046] Every Entity Remembers Everything: The Temporal Model (vous êtes ici) - [047] Version History and Time Travel Queries - [048] Temporal Integration: From Bugs to 100% Test Coverage - [049] Destroy and Restore: Soft Deletes Done Right - [050] Temporal Filtering and Ordering - [051] Temporal Comparison Helpers - [052] Version Metadata Access - [053] Time Arithmetic: Adding Days, Comparing Dates - [054] Tracking Accuracy and Validation - [055] The Temporal Model Complete: What No Other Language Has

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles