Mini ORM JavaScript pour IndexedDB : stockage local

🏷️ Front-end 📅 14/04/2026 18:00:00 👤 Mezgani said
Indexeddb Orm Javascript Stockage Local Api Browser Base De Donnees
Mini ORM JavaScript pour IndexedDB : stockage local

Créez un mini ORM JavaScript simple et réutilisable pour IndexedDB. API fluide (insert, find, update, delete), gestion des transactions, versioning et exemples pratiques pour stocker des données localement dans le navigateur.

Introduction à IndexedDB

IndexedDB est une base de données NoSQL intégrée aux navigateurs modernes. Elle permet de stocker de grandes quantités de données structurées localement, avec des index pour des recherches rapides.

Contrairement à localStorage qui est limité à ~5-10 Mo de données textuelles, IndexedDB peut stocker des centaines de mégaoctets voire plusieurs gigaoctets selon le navigateur.

🔑 Caractéristiques clés :
  • Base de données orientée objet (objets JavaScript)
  • Support des transactions ACID
  • API asynchrone (basée sur des événements)
  • Stockage hors-ligne persistant
  • Index multiples pour recherches efficaces

Pourquoi utiliser IndexedDB ?

Cas d'usage typiques

  • PWA et applications offline : Synchroniser les données quand la connexion revient
  • Cache applicatif : Améliorer les performances en évitant des appels API répétés
  • Applications riches en données : Tableaux de bord, éditeurs, gestionnaires de fichiers
  • Formulaires complexes : Sauvegarder automatiquement les brouillons
  • Jeux web : Sauvegardes locales, assets, scores

Comparaison avec d'autres solutions

Solution Capacité Type de données Performance
localStorage ~5-10 Mo String uniquement Synchrone (bloquant)
sessionStorage ~5-10 Mo String uniquement Synchrone (bloquant)
IndexedDB 50 Mo - 1+ Go Objets, Blob, ArrayBuffer Asynchrone (non-bloquant)
Cache API Variable Requêtes/Réponses HTTP Asynchrone

Limites de l'API native

L'API IndexedDB native est puissante mais très verbeuse et complexe à utiliser. Voici un exemple pour simplement ajouter un objet :

// API native - trop verbeux !
const request = indexedDB.open('myDB', 1);

request.onsuccess = (event) => {
    const db = event.target.result;
    const transaction = db.transaction(['users'], 'readwrite');
    const store = transaction.objectStore('users');
    const addRequest = store.add({ id: 1, name: 'Alice' });

    addRequest.onsuccess = () => {
        console.log('Utilisateur ajouté');
    };

    addRequest.onerror = () => {
        console.error('Erreur');
    };
};

request.onerror = () => {
    console.error('Erreur ouverture DB');
};

Pour une simple opération CRUD, il faut gérer :

  • Ouverture de la base de données
  • Création de transactions
  • Accès aux object stores
  • Gestion des callbacks success/error
  • Gestion du versioning
💡 Solution : Un mini ORM simplifie drastiquement l'utilisation en encapsulant toute cette complexité dans une API fluide et moderne.

Architecture du mini ORM

Notre ORM va fournir une API simple et chainable :

// API cible - simple et moderne
const db = new MiniORM('myApp', 1);

// CRUD en une ligne
await db.table('users').insert({ id: 1, name: 'Alice' });
const user = await db.table('users').find(1);
await db.table('users').update(1, { name: 'Bob' });
await db.table('users').delete(1);

// Requêtes avancées
const admins = await db.table('users')
    .where('role', 'admin')
    .orderBy('name')
    .limit(10)
    .getAll();

Classes principales

  • MiniORM : Classe principale, gestion de la connexion
  • Table : Représente une table (object store)
  • Query : Builder de requêtes avec méthodes chainables

Implémentation complète

Classe MiniORM

class MiniORM {
    constructor(dbName, version = 1) {
        this.dbName = dbName;
        this.version = version;
        this.db = null;
        this.schemas = new Map();
    }

    // Définir le schéma d'une table
    defineTable(tableName, options = {}) {
        this.schemas.set(tableName, {
            keyPath: options.keyPath || 'id',
            autoIncrement: options.autoIncrement !== false,
            indexes: options.indexes || []
        });
        return this;
    }

    // Ouvrir la connexion
    async connect() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(this.dbName, this.version);

            request.onerror = () => reject(request.error);
            request.onsuccess = () => {
                this.db = request.result;
                resolve(this.db);
            };

            request.onupgradeneeded = (event) => {
                const db = event.target.result;

                // Créer les object stores selon les schémas
                this.schemas.forEach((schema, tableName) => {
                    if (!db.objectStoreNames.contains(tableName)) {
                        const store = db.createObjectStore(tableName, {
                            keyPath: schema.keyPath,
                            autoIncrement: schema.autoIncrement
                        });

                        // Créer les index
                        schema.indexes.forEach(index => {
                            store.createIndex(
                                index.name,
                                index.keyPath,
                                { unique: index.unique || false }
                            );
                        });
                    }
                });
            };
        });
    }

    // Accéder à une table
    table(tableName) {
        if (!this.db) {
            throw new Error('Database not connected. Call connect() first.');
        }
        return new Table(this.db, tableName);
    }

    // Fermer la connexion
    close() {
        if (this.db) {
            this.db.close();
            this.db = null;
        }
    }
}

Classe Table

class Table {
    constructor(db, tableName) {
        this.db = db;
        this.tableName = tableName;
    }

    // INSERT
    async insert(data) {
        return this._transaction('readwrite', (store) => {
            return store.add(data);
        });
    }

    // FIND (par clé primaire)
    async find(key) {
        return this._transaction('readonly', (store) => {
            return store.get(key);
        });
    }

    // UPDATE
    async update(key, data) {
        const existing = await this.find(key);
        if (!existing) {
            throw new Error(`Record with key ${key} not found`);
        }
        const updated = { ...existing, ...data };
        return this._transaction('readwrite', (store) => {
            return store.put(updated);
        });
    }

    // DELETE
    async delete(key) {
        return this._transaction('readwrite', (store) => {
            return store.delete(key);
        });
    }

    // GET ALL
    async getAll() {
        return this._transaction('readonly', (store) => {
            return store.getAll();
        });
    }

    // COUNT
    async count() {
        return this._transaction('readonly', (store) => {
            return store.count();
        });
    }

    // CLEAR (vider la table)
    async clear() {
        return this._transaction('readwrite', (store) => {
            return store.clear();
        });
    }

    // WHERE (requête par index)
    where(indexName, value) {
        return new Query(this.db, this.tableName, indexName, value);
    }

    // Méthode privée pour gérer les transactions
    _transaction(mode, callback) {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([this.tableName], mode);
            const store = transaction.objectStore(this.tableName);
            const request = callback(store);

            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }
}

Classe Query (requêtes avancées)

class Query {
    constructor(db, tableName, indexName, value) {
        this.db = db;
        this.tableName = tableName;
        this.indexName = indexName;
        this.value = value;
        this._limit = null;
        this._orderDirection = 'next';
    }

    // Limiter les résultats
    limit(count) {
        this._limit = count;
        return this;
    }

    // Ordre décroissant
    orderByDesc() {
        this._orderDirection = 'prev';
        return this;
    }

    // Exécuter la requête
    async getAll() {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([this.tableName], 'readonly');
            const store = transaction.objectStore(this.tableName);
            const index = store.index(this.indexName);
            const request = index.openCursor(
                IDBKeyRange.only(this.value),
                this._orderDirection
            );

            const results = [];
            let count = 0;

            request.onsuccess = (event) => {
                const cursor = event.target.result;
                if (cursor && (!this._limit || count < this._limit)) {
                    results.push(cursor.value);
                    count++;
                    cursor.continue();
                } else {
                    resolve(results);
                }
            };

            request.onerror = () => reject(request.error);
        });
    }

    // Premier résultat uniquement
    async first() {
        const results = await this.limit(1).getAll();
        return results[0] || null;
    }
}

Utilisation : opérations CRUD

Configuration initiale

// Initialiser le mini ORM
const db = new MiniORM('monApp', 1);

// Définir les tables
db.defineTable('users', {
    keyPath: 'id',
    autoIncrement: true,
    indexes: [
        { name: 'email', keyPath: 'email', unique: true },
        { name: 'role', keyPath: 'role' }
    ]
});

db.defineTable('posts', {
    keyPath: 'id',
    autoIncrement: true,
    indexes: [
        { name: 'userId', keyPath: 'userId' },
        { name: 'status', keyPath: 'status' }
    ]
});

// Connexion
await db.connect();

CREATE - Insérer des données

// Insérer un utilisateur
const userId = await db.table('users').insert({
    name: 'Alice Dupont',
    email: 'alice@example.com',
    role: 'admin',
    createdAt: new Date()
});

console.log('User ID:', userId);

// Insérer plusieurs posts
const posts = [
    { userId: 1, title: 'Premier post', status: 'published' },
    { userId: 1, title: 'Brouillon', status: 'draft' }
];

for (const post of posts) {
    await db.table('posts').insert(post);
}

READ - Lire des données

// Récupérer par ID
const user = await db.table('users').find(1);
console.log('User:', user);

// Récupérer tous les utilisateurs
const allUsers = await db.table('users').getAll();
console.log('Total users:', allUsers.length);

// Requête avec index
const admins = await db.table('users')
    .where('role', 'admin')
    .getAll();

// Requête avec limite
const recentPosts = await db.table('posts')
    .where('status', 'published')
    .orderByDesc()
    .limit(10)
    .getAll();

// Compter les entrées
const userCount = await db.table('users').count();
console.log('Nombre d\'utilisateurs:', userCount);

UPDATE - Mettre à jour

// Mise à jour partielle
await db.table('users').update(1, {
    name: 'Alice Martin',
    updatedAt: new Date()
});

// Mise à jour complète (PUT)
const user = await db.table('users').find(1);
user.lastLoginAt = new Date();
await db.table('users').update(1, user);

DELETE - Supprimer

// Supprimer un utilisateur
await db.table('users').delete(1);

// Vider toute la table
await db.table('posts').clear();

// Fermer la connexion
db.close();

Transactions et versioning

Transactions manuelles

Pour des opérations complexes nécessitant l'atomicité :

class MiniORM {
    // ... code précédent ...

    // Transaction multi-tables
    async transaction(tableNames, mode, callback) {
        return new Promise((resolve, reject) => {
            const tx = this.db.transaction(tableNames, mode);
            const stores = tableNames.map(name => tx.objectStore(name));

            tx.oncomplete = () => resolve();
            tx.onerror = () => reject(tx.error);

            try {
                callback(stores);
            } catch (error) {
                reject(error);
            }
        });
    }
}

// Utilisation : transférer un post d'un user à un autre
await db.transaction(['users', 'posts'], 'readwrite', ([usersStore, postsStore]) => {
    const getUserReq = usersStore.get(oldUserId);
    getUserReq.onsuccess = () => {
        const user = getUserReq.result;
        user.postCount--;
        usersStore.put(user);
    };

    const getPostReq = postsStore.get(postId);
    getPostReq.onsuccess = () => {
        const post = getPostReq.result;
        post.userId = newUserId;
        postsStore.put(post);
    };
});

Gestion du versioning

Migrer la structure de la base lors des montées de version :

const db = new MiniORM('monApp', 2); // Version 2

db.defineTable('users', {
    keyPath: 'id',
    autoIncrement: true,
    indexes: [
        { name: 'email', keyPath: 'email', unique: true },
        { name: 'role', keyPath: 'role' },
        { name: 'premium', keyPath: 'premium' } // Nouvel index v2
    ]
});

// Migration automatique lors du onupgradeneeded
await db.connect();

// Migrer les données existantes si nécessaire
const users = await db.table('users').getAll();
for (const user of users) {
    if (!user.premium) {
        await db.table('users').update(user.id, { premium: false });
    }
}

Exemples pratiques

Cache d'API avec expiration

class CacheManager {
    constructor(db, expirationMs = 3600000) { // 1h par défaut
        this.db = db;
        this.expirationMs = expirationMs;
    }

    async get(key) {
        const cached = await this.db.table('cache').find(key);

        if (!cached) return null;

        // Vérifier l'expiration
        if (Date.now() - cached.timestamp > this.expirationMs) {
            await this.db.table('cache').delete(key);
            return null;
        }

        return cached.data;
    }

    async set(key, data) {
        await this.db.table('cache').insert({
            id: key,
            data: data,
            timestamp: Date.now()
        });
    }
}

// Utilisation
const cache = new CacheManager(db);

async function fetchUsers() {
    const cached = await cache.get('users');
    if (cached) return cached;

    const users = await fetch('/api/users').then(r => r.json());
    await cache.set('users', users);
    return users;
}

Sauvegarde auto de formulaire

class FormAutosave {
    constructor(db, formId) {
        this.db = db;
        this.formId = formId;
        this.debounceTimer = null;
    }

    // Sauvegarder avec debounce
    save(formData) {
        clearTimeout(this.debounceTimer);
        this.debounceTimer = setTimeout(async () => {
            await this.db.table('formDrafts').insert({
                id: this.formId,
                data: formData,
                savedAt: new Date()
            });
            console.log('Draft saved');
        }, 1000);
    }

    // Restaurer
    async restore() {
        const draft = await this.db.table('formDrafts').find(this.formId);
        return draft ? draft.data : null;
    }

    // Nettoyer après soumission
    async clean() {
        await this.db.table('formDrafts').delete(this.formId);
    }
}

// Utilisation
const formSaver = new FormAutosave(db, 'contact-form');

document.getElementById('myForm').addEventListener('input', (e) => {
    const formData = new FormData(e.target.form);
    const data = Object.fromEntries(formData);
    formSaver.save(data);
});

// Restaurer au chargement
window.addEventListener('DOMContentLoaded', async () => {
    const draft = await formSaver.restore();
    if (draft && confirm('Restaurer le brouillon ?')) {
        // Remplir le formulaire avec les données sauvegardées
        Object.entries(draft).forEach(([key, value]) => {
            const input = document.querySelector(`[name="${key}"]`);
            if (input) input.value = value;
        });
    }
});

Alternatives : Dexie.js et autres

Si vous avez besoin de fonctionnalités avancées, considérez ces bibliothèques :

Dexie.js (recommandé)

La bibliothèque la plus populaire pour IndexedDB, avec une API moderne et des fonctionnalités riches.

// npm install dexie
import Dexie from 'dexie';

const db = new Dexie('monApp');
db.version(1).stores({
    users: '++id, email, role',
    posts: '++id, userId, status'
});

// CRUD simplifié
await db.users.add({ name: 'Alice', email: 'alice@example.com' });
const user = await db.users.get(1);
await db.users.where('role').equals('admin').toArray();
await db.users.update(1, { name: 'Bob' });
await db.users.delete(1);

Autres alternatives

Bibliothèque Taille Points forts
Dexie.js ~20 KB API moderne, TypeScript, observables, hooks
idb ~1 KB Wrapper minimal basé sur Promises
localForage ~10 KB API simple type localStorage, fallback auto
PouchDB ~145 KB Compatible CouchDB, sync multi-device
💡 Quand utiliser notre mini ORM : Projets simples sans dépendances externes, besoin de comprendre les concepts, bundle size critique (<5 KB minifié).

Bonnes pratiques

  • Versionning : Incrémentez la version à chaque changement de schéma
  • Index pertinents : Créez des index sur les champs utilisés dans les requêtes
  • Gestion d'erreurs : Toujours wrapper les opérations dans try/catch
  • Fermer les connexions : Appelez db.close() quand terminé
  • Limiter les transactions : Évitez les transactions longues (bloquent l'accès)
  • Pas de données sensibles : IndexedDB n'est pas chiffré (visible DevTools)
  • Quota de stockage : Vérifier navigator.storage.estimate()
  • Nettoyage régulier : Supprimer les données expirées périodiquement
  • Backup important : Ne pas considérer IndexedDB comme stockage permanent

Vérifier le quota disponible

if ('storage' in navigator && 'estimate' in navigator.storage) {
    const { quota, usage } = await navigator.storage.estimate();
    console.log(`Utilisé: ${(usage / 1024 / 1024).toFixed(2)} MB`);
    console.log(`Total: ${(quota / 1024 / 1024).toFixed(2)} MB`);
    console.log(`Disponible: ${((quota - usage) / 1024 / 1024).toFixed(2)} MB`);
}

Gestion des erreurs complète

try {
    await db.connect();
    const user = await db.table('users').find(1);
    console.log('User:', user);
} catch (error) {
    if (error.name === 'QuotaExceededError') {
        console.error('Quota de stockage dépassé');
        // Nettoyer les anciennes données
    } else if (error.name === 'VersionError') {
        console.error('Conflit de version de base de données');
    } else {
        console.error('Erreur IndexedDB:', error);
    }
} finally {
    db.close();
}

Conclusion

IndexedDB est une solution puissante pour le stockage local dans les applications web modernes, mais son API native est complexe. Ce mini ORM simplifie drastiquement son utilisation tout en restant léger et sans dépendances.

Pour des projets plus complexes, n'hésitez pas à utiliser Dexie.js qui offre des fonctionnalités avancées (observables, relations, migrations automatiques).

📚 Ressources complémentaires :