Back to flin
flin

Accès aux métadonnées de version

Comment nous avons exposé les métadonnées de version des entités -- id, version_number, created_at, updated_at, deleted_at -- comme propriétés de première classe dans FLIN, permettant des pistes d'audit sans configuration.

Thales & Claude | March 30, 2026 13 min flin
EN/ FR/ ES
flintemporalmetadataversioningaudit-trail

Des données sans contexte ne sont que des chiffres. Savoir qu'un produit coûte quinze dollars est utile. Savoir qu'il coûte quinze dollars, que c'est la version douze, qu'il a été mis à jour il y a trois jours, et qu'il n'a jamais été supprimé -- ça, c'est une piste d'audit.

La session 081 a implémenté l'accès aux métadonnées de version pour les entités FLIN : la possibilité de lire .id, .version_number, .created_at, .updated_at et .deleted_at directement depuis n'importe quelle instance d'entité, y compris les versions historiques retournées par .history. L'implémentation a pris trente minutes et ajouté soixante-trois lignes de code. L'impact était disproportionné : elle a transformé le modèle temporel de FLIN de « nous stockons des versions » à « nous fournissons une transparence complète du cycle de vie ».

Quelles métadonnées sont disponibles

Chaque instance d'entité dans FLIN porte cinq champs de métadonnées, automatiquement remplis par le runtime :

flinuser = User.find(id)

user.id               // Identifiant unique de l'entité (entier)
user.version_number   // Compteur séquentiel de version (entier)
user.created_at       // Horodatage de la création initiale (entier, ms)
user.updated_at       // Horodatage de la dernière modification (entier, ms)
user.deleted_at       // Horodatage de la suppression douce (entier optionnel)

Ce ne sont pas des champs définis par l'utilisateur. Ils existent sur chaque entité indépendamment de son schéma. Une entité avec un seul champ name: text possède six propriétés accessibles : name, id, version_number, created_at, updated_at et deleted_at.

Le champ deleted_at est spécial : il est toujours optionnel. Pour les entités actives, il retourne none. Pour les entités supprimées en douceur, il retourne l'horodatage de suppression. Cette distinction est intégrée au système de types -- deleted_at a le type Optional<Int> même lorsqu'il est accédé sur une référence d'entité non optionnelle.

Ce qui existait avant : les métadonnées invisibles

Avant la session 081, chaque entité dans FLIN portait déjà des métadonnées -- la structure EntityInstance avait des champs id, version, created_at, updated_at et deleted_at depuis les premières sessions. La VM utilisait ces champs en interne pour les opérations temporelles : l'opérateur @ vérifiait les numéros de version, l'opération de sauvegarde incrémentait les horodatages, et la suppression douce définissait deleted_at.

Mais les développeurs ne pouvaient accéder à rien de tout cela. Les métadonnées étaient invisibles. Si vous vouliez afficher un numéro de version dans un template, vous ne pouviez pas. Si vous vouliez montrer quand une entité avait été mise à jour pour la dernière fois, vous ne pouviez pas. Les données étaient là, à l'intérieur du runtime, mais il n'y avait aucune syntaxe pour les atteindre.

C'est un pattern courant dans le développement de langages : un état interne dont le runtime a besoin mais que le développeur ne peut pas toucher. La session 081 a abattu ce mur.

L'implémentation : extension de GetField

FLIN possède deux opcodes pour l'accès aux champs : GetField (statique, quand le nom de la propriété est connu à la compilation) et GetFieldDyn (dynamique, pour les noms de propriétés calculés). Les deux devaient être étendus pour reconnaître les champs de métadonnées.

Le changement était chirurgical. Avant de vérifier les champs définis par l'utilisateur, la VM vérifie maintenant les noms de champs de métadonnées :

rustObjectData::Entity(e) => {
    match name.as_str() {
        "id" => Value::Int(e.id as i64),
        "version" | "version_number" => Value::Int(e.version as i64),
        "created_at" => Value::Int(e.created_at),
        "updated_at" => Value::Int(e.updated_at),
        "deleted_at" => e.deleted_at.map(Value::Int).unwrap_or(Value::None),
        _ => e.fields.get(&name).cloned().unwrap_or(Value::None),
    }
}

Ce code apparaît dans les deux gestionnaires GetField et GetFieldDyn -- une duplication nécessaire en raison de la manière dont la VM dispatch ces opcodes, mais la logique est identique.

La décision de précédence

Les champs de métadonnées ont priorité sur les champs définis par l'utilisateur. Si un développeur crée une entité avec un champ nommé id, le id des métadonnées intégrées l'emporte :

flinentity Problematic {
    id: text          // Champ 'id' défini par l'utilisateur
    name: text
}

item = Problematic { id: "custom-id", name: "test" }
save item

print(item.id)       // Retourne l'ID de l'entité (entier), PAS "custom-id"

C'était un choix de conception délibéré. Les métadonnées système doivent être accessibles de manière fiable. Si les champs définis par l'utilisateur pouvaient masquer les métadonnées, les développeurs n'auraient aucun moyen d'accéder à l'ID réel, la version ou les horodatages de l'entité. Le compromis -- que les noms de champs id, version, version_number, created_at, updated_at et deleted_at sont effectivement réservés -- est acceptable car ces noms sont précisément ceux que les développeurs voudraient pour les métadonnées de toute façon.

Vérification des types

Le vérificateur de types a été étendu pour reconnaître les champs de métadonnées sur les types d'entités :

rustFlinType::Entity(entity_name) => {
    match property {
        "id" | "version" | "version_number"
        | "created_at" | "updated_at" => {
            return Ok(if optional {
                FlinType::Optional(Box::new(FlinType::Int))
            } else {
                FlinType::Int
            });
        }
        "deleted_at" => {
            return Ok(FlinType::Optional(Box::new(FlinType::Int)));
        }
        _ => {}  // Fall through to user-defined field checking
    }
}

Deux nuances dans cette logique de vérification de types :

Tous les champs de métadonnées retournent des entiers. Les horodatages sont stockés en millisecondes depuis l'époque (i64). Les numéros de version et les ID sont également des entiers. Cette simplicité signifie que l'arithmétique temporelle fonctionne naturellement : user.updated_at + 7.days n'est qu'une addition d'entiers.

deleted_at est toujours optionnel. Même lorsqu'il est accédé sur une référence d'entité non optionnelle, deleted_at retourne Optional<Int>. C'est parce que la plupart des entités ne sont pas supprimées, donc le champ est None par défaut. Le système de types reflète cette réalité -- vous devez gérer le cas None :

flin{if user.deleted_at}
    <p>Supprimé le : {user.deleted_at}</p>
{else}
    <p>Actif</p>
{/if}

Métadonnées sur les versions historiques

La véritable puissance de l'accès aux métadonnées émerge lorsqu'il est combiné avec .history. Chaque version dans la liste d'historique porte ses propres métadonnées, reflétant l'état au moment où cette version a été créée :

flin{for ver in product.history}
    <div class="audit-entry">
        <p>Version #{ver.version_number}</p>
        <p>ID de l'entité : {ver.id}</p>
        <p>Créé le : {ver.created_at}</p>
        <p>Mis à jour le : {ver.updated_at}</p>
        <p>Prix : ${ver.price}</p>
    </div>
{/for}

Cette boucle produit une piste d'audit complète sans code supplémentaire. Chaque ver dans la boucle est une instance d'entité complète, reconstruite à partir de la structure EntityVersion stockée dans l'historique des versions. La reconstruction remplit les métadonnées à partir des valeurs stockées de la version :

rust// EntityInstance structure
pub struct EntityInstance {
    pub entity_type: String,
    pub id: u64,                    // Accessible via .id
    pub fields: HashMap<String, Value>,
    pub version: u64,               // Accessible via .version_number
    pub created_at: i64,            // Accessible via .created_at
    pub updated_at: i64,            // Accessible via .updated_at
    pub deleted_at: Option<i64>,    // Accessible via .deleted_at
}

Quand la VM construit une entité historique à partir d'une EntityVersion, elle mappe le timestamp de la version sur created_at et updated_at, et le numéro de version sur le champ version. Le résultat est que les entités historiques se comportent de manière identique aux entités actuelles -- la même syntaxe d'accès aux champs fonctionne pour les deux.

Construire des pistes d'audit

Avant la session 081, vous pouviez accéder à l'historique d'une entité :

flinhistory = product.history
count = history.count

Après la session 081, vous pouviez construire des pistes d'audit complètes :

flin<div class="audit-log">
    <h2>Historique du document</h2>
    <table>
        <thead>
            <tr>
                <th>Version</th>
                <th>Date</th>
                <th>Titre</th>
                <th>Statut</th>
            </tr>
        </thead>
        <tbody>
            {for ver in document.history}
                <tr>
                    <td>v{ver.version_number}</td>
                    <td>{ver.updated_at}</td>
                    <td>{ver.title}</td>
                    <td>{ver.status}</td>
                </tr>
            {/for}
        </tbody>
    </table>
</div>

Dans un framework web traditionnel, cette piste d'audit nécessiterait :

  1. Une table document_history séparée avec des clés étrangères.
  2. Des triggers de base de données ou un middleware applicatif pour la remplir à chaque changement.
  3. Un endpoint API dédié pour l'interroger.
  4. Un composant frontend pour l'afficher.
  5. Des scripts de migration pour la mettre en place.
  6. Des tests pour vérifier le comportement du trigger/middleware.

En FLIN, c'est une boucle {for} sur .history avec accès aux métadonnées. L'infrastructure n'existe pas en tant que code séparé -- c'est le runtime du langage lui-même.

Patterns d'applications réelles

Mises à jour de produits e-commerce

flin<div class="product-info">
    <h1>{product.name}</h1>
    <p class="price">${product.price}</p>
    <p class="meta">
        Version {product.version_number}
        -- Dernière mise à jour {product.updated_at}
    </p>
</div>

Les clients peuvent voir « Ce produit a été mis à jour il y a 3 jours (version 12) » sans aucun code de suivi supplémentaire.

Modifications de profil utilisateur

flin<div class="profile-history">
    <h3>Historique des modifications du profil</h3>
    {for ver in user.history.last(5)}
        <div class="change-entry">
            <span class="version">v{ver.version_number}</span>
            <span class="date">{ver.updated_at}</span>
            <span class="name">{ver.name}</span>
            <span class="email">{ver.email}</span>
        </div>
    {/for}
</div>

Rapports de conformité

flin// Générer les données du rapport de conformité
all_changes = document.history
    .where_field("created_at", ">", last_month)
    .order_by("created_at", "desc")

<div class="compliance-report">
    <h2>Changements des 30 derniers jours</h2>
    <p>Total des versions : {all_changes.count}</p>
    {for change in all_changes}
        <div class="report-entry">
            <p>Version {change.version_number} le {change.created_at}</p>
            <p>Statut : {change.status}</p>
        </div>
    {/for}
</div>

La structure de données sous-jacente

Comprendre pourquoi l'accès aux métadonnées est efficace nécessite de comprendre comment les entités sont stockées. La structure EntityInstance porte les métadonnées à côté des champs définis par l'utilisateur :

rustpub struct EntityInstance {
    pub entity_type: String,
    pub id: u64,
    pub fields: HashMap<String, Value>,
    pub version: u64,
    pub created_at: i64,
    pub updated_at: i64,
    pub deleted_at: Option<i64>,
}

Le HashMap fields contient les données définies par l'utilisateur (name, price, email). Les métadonnées vivent en dehors du HashMap comme champs directs de la structure. Cela signifie que l'accès aux métadonnées est une lecture directe de champ -- O(1) sans recherche de hash -- tandis que l'accès aux champs définis par l'utilisateur nécessite une recherche dans le HashMap.

Les versions historiques utilisent une structure plus légère :

rustpub struct EntityVersion {
    pub version: u64,
    pub timestamp: i64,
    pub fields: HashMap<String, Value>,
}

Quand la VM reconstruit une entité historique pour l'itération .history, elle mappe les champs de EntityVersion sur une EntityInstance, remplissant les métadonnées à partir des valeurs stockées de la version. Cette reconstruction se produit une fois par accès de version et produit une entité complète qui supporte la même syntaxe d'accès aux champs qu'une entité actuelle.

L'alias : .version vs .version_number

Un petit détail convivial : le numéro de version est accessible via deux noms. entity.version et entity.version_number retournent la même valeur. La forme courte est pratique pour un accès rapide. La forme longue est plus explicite et se lit mieux dans les templates où la clarté compte.

flin// Les deux sont équivalents
product.version           // 5
product.version_number    // 5

Ceci a été implémenté comme un simple pattern match dans le gestionnaire d'accès aux champs de la VM -- le bras "version" | "version_number" gère les deux noms et retourne le même champ e.version. Le vérificateur de types accepte de même les deux noms avec des types de retour identiques.

L'approche à double nom suit un principe que nous avons appliqué partout dans FLIN : quand deux noms sont également intuitifs et qu'il n'y a pas d'ambiguïté, supporter les deux. Les développeurs ne devraient pas avoir à se souvenir si c'est .version ou .version_number -- ils peuvent utiliser celui qui semble naturel dans le contexte.

Impact sur la progression

La session 081 a complété trois tâches : - TEMP4-13 : Accéder à version.id - TEMP4-14 : Accéder à version.created_at - TEMP4-15 : Accéder à version.version_number

TEMP-4 est passé de seize sur vingt-deux (soixante-treize pour cent) à dix-neuf sur vingt-deux (quatre-vingt-six pour cent). Progression temporelle globale : cent deux sur cent soixante (soixante-trois virgule huit pour cent).

L'implémentation était modeste -- soixante-trois lignes nettes de code réparties sur trois fichiers. Mais la fonctionnalité qu'elle a permise -- une transparence complète du cycle de vie pour chaque entité dans chaque application FLIN -- est fondamentale. Les pistes d'audit, les rapports de conformité, les affichages de version et les journaux d'activité deviennent tous triviaux une fois que les métadonnées sont une propriété de première classe.

Pourquoi les métadonnées ne sont pas juste « un plus »

Dans chaque application que nous avons construite chez ZeroSuite, l'accès aux métadonnées a été une nécessité, pas une fonctionnalité. Considérez ces patterns :

Tickets de support. Quand un utilisateur signale « mes données semblent incorrectes », l'équipe de support a besoin de savoir : quelle version est-ce ? Quand a-t-elle été mise à jour pour la dernière fois ? A-t-elle été supprimée et restaurée ? Sans métadonnées, répondre à ces questions nécessite un accès à la base de données et des requêtes SQL. Avec les métadonnées de FLIN, l'équipe de support peut voir la réponse directement dans l'interface de l'application.

Concurrence. Quand deux utilisateurs modifient la même entité, le numéro de version permet le contrôle de concurrence optimiste. Avant de sauvegarder, l'application peut vérifier si le numéro de version a changé depuis le chargement de l'entité. Si c'est le cas, un autre utilisateur a effectué un changement, et la sauvegarde peut être rejetée ou fusionnée.

flin// Pattern de concurrence optimiste
loaded_version = product.version_number
// ... l'utilisateur effectue des changements ...
current_version = Product.find(product.id).version_number
{if loaded_version != current_version}
    <p>Cet enregistrement a été modifié par un autre utilisateur. Veuillez rafraîchir.</p>
{/if}

Mise en cache. L'horodatage updated_at permet l'invalidation du cache. Un client peut stocker les données d'un produit avec sa valeur updated_at et ne refaire une requête que lorsque le updated_at du serveur est plus récent.

Flux d'activité. Combiner .history avec les métadonnées crée des flux d'activité sans table d'événements séparée :

flin// Activité récente sur tous les documents
{for doc in Document.all}
    {if doc.updated_at > last_week}
        <div class="activity-item">
            <p>{doc.title} mis à jour (v{doc.version_number})</p>
            <span class="date">{doc.updated_at}</span>
        </div>
    {/if}
{/for}

Aucun de ces patterns ne nécessite d'infrastructure supplémentaire en FLIN. Les métadonnées sont toujours là, toujours exactes et toujours accessibles via la même syntaxe de propriété utilisée pour les champs définis par l'utilisateur.

Trente minutes d'implémentation. Zéro nouvel opcode. Zéro nouveau concept au runtime. Juste exposer ce qui était déjà là, rendre l'implicite explicite, et laisser les développeurs l'utiliser.


Ceci est la partie 7 de la série « Comment nous avons construit FLIN » sur le modèle temporel, documentant le système de métadonnées de version qui permet des pistes d'audit sans configuration.

Navigation dans la série : - [046] Chaque entité se souvient de tout : le modèle temporel - [047] Historique des versions et requêtes de voyage dans le temps - [048] Intégration temporelle : des bugs à 100 % de couverture de tests - [049] Destroy et Restore : la suppression douce bien faite - [050] Filtrage et tri temporels - [051] Fonctions de comparaison temporelle - [052] Accès aux métadonnées de version (vous êtes ici) - [053] Arithmétique temporelle : ajouter des jours, comparer des dates - [054] Précision du suivi et validation - [055] Le modèle temporel complet : ce qu'aucun autre langage n'offre

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles