Datenbank-Upgrades ohne Datenverlust und ohne Cross-Tab-Konflikte
Schema-Migration in IndexedDB unterscheidet sich fundamental von serverseitigen Datenbank-Migrationen: Es gibt kein Migrationstool, keine SQL-DDL-Befehle und keinen zentralen Ausführungsort, sondern nur das onupgradeneeded-Event, eine einzelne Versionsnummer und die Verantwortung des Entwicklers, Object Stores und Indexe schrittweise, robust und rückwärtskompatibel zu verändern.
Inhaltsverzeichnis
- 1. Warum IndexedDB-Migrationen anders funktionieren als serverseitig
- 2. Das onupgradeneeded-Event im Detail
- 3. Versionsnummern: Integer statt Semver
- 4. Schrittweise Migrationsfunktionen pro Versionssprung
- 5. Datentransformation während der Migration
- 6. Das blocked-Event und Cross-Tab-Konflikte
- 7. Reaktion auf versionchange in anderen Tabs
- 8. Fehlerbehandlung und Rollback-Strategien
- 9. Migrationsstrategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum IndexedDB-Migrationen anders funktionieren als serverseitig
Wer aus der Welt serverseitiger Datenbanken kommt, erwartet bei einer Schema-Migration typischerweise ein Migrationstool mit nummerierten Skriptdateien, einer Historie ausgeführter Migrationen und der Möglichkeit, einzelne Schritte gezielt zurückzurollen. IndexedDB bietet nichts davon. Stattdessen basiert jede IndexedDB-Schema-Migration auf einer einzigen Ganzzahl, der Datenbankversion, und einem einzigen Event, onupgradeneeded, das genau dann feuert, wenn die im Code angeforderte Version höher ist als die aktuell im Browser gespeicherte Version.
Diese Reduktion auf ein einziges Event bedeutet, dass die gesamte Logik einer IndexedDB-Schema-Migration, von der Erstellung neuer Object Stores über das Anlegen zusätzlicher Indexe bis zur Transformation bestehender Datensätze, in einer einzigen Funktion zusammenlaufen muss. Es gibt keinen separaten Migrationsordner, keine automatische Reihenfolge von Skriptdateien, und keine eingebaute Historie, welche Migration bereits gelaufen ist, außer der aktuellen Versionsnummer selbst. Wer IndexedDB-Schema-Migrationen ernst nimmt, muss diese Struktur selbst im Anwendungscode nachbauen.
Der zweite fundamentale Unterschied: Eine IndexedDB-Schema-Migration betrifft nicht nur den aktuellen Tab, sondern potenziell mehrere gleichzeitig geöffnete Tabs desselben Origins, die dieselbe Datenbank verwenden. Eine Datenbank kann nicht auf eine neue Version hochgestuft werden, solange eine ältere Verbindung noch offen ist, was IndexedDB-Schema-Migrationen zu einem Koordinationsproblem zwischen Tabs macht, nicht nur zu einem reinen Datenstruktur-Problem.
2. Das onupgradeneeded-Event im Detail
Der Einstiegspunkt jeder IndexedDB-Schema-Migration ist der Aufruf indexedDB.open(name, version) mit einer expliziten Versionsnummer. Ist die übergebene Version höher als die zuletzt gespeicherte, feuert der Browser das onupgradeneeded-Event, bevor die Datenbank für normale Lese- und Schreibzugriffe freigegeben wird. Innerhalb dieses Events, und ausschließlich dort, dürfen Object Stores mit createObjectStore() angelegt oder mit deleteObjectStore() entfernt werden, ebenso wie Indexe mit createIndex() oder deleteIndex().
Das Event-Objekt liefert zwei entscheidende Werte für jede IndexedDB-Schema-Migration: event.oldVersion, die zuvor gespeicherte Version, und event.newVersion, die neu angeforderte Version. Bei einer komplett neuen Datenbank ist oldVersion gleich 0, was sich hervorragend eignet, um zwischen einer erstmaligen Initialisierung und einer echten Migration einer bestehenden Datenbank zu unterscheiden. Die gesamte Migrationslogik läuft innerhalb derselben Transaktion, die implizit vom Browser für onupgradeneeded geöffnet wird, dem sogenannten versionchange-Transaktionstyp.
// Core structure of an IndexedDB schema migration
function openDatabase() {
const CURRENT_VERSION = 4;
const request = indexedDB.open("app_db", CURRENT_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const oldVersion = event.oldVersion;
console.log(`Migrating from version ${oldVersion} to ${event.newVersion}`);
if (oldVersion < 1) {
db.createObjectStore("orders", { keyPath: "id" });
}
if (oldVersion < 2) {
const store = event.target.transaction.objectStore("orders");
store.createIndex("byStatus", "status");
}
if (oldVersion < 3) {
db.createObjectStore("customers", { keyPath: "id" });
}
if (oldVersion < 4) {
const store = event.target.transaction.objectStore("orders");
store.createIndex("byCustomerAndDate", ["customerId", "createdAt"]);
}
};
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
3. Versionsnummern: Integer statt Semver
Anders als bei den meisten Package-Management-Systemen erwartet IndexedDB für jede Schema-Migration eine einfache Ganzzahl, keine Semver-Notation wie 2.1.0. Der Browser vergleicht ausschließlich numerisch: Jede Version, die höher ist als die gespeicherte, löst onupgradeneeded aus, unabhängig davon, wie viele Versionssprünge dazwischenliegen. Eine Migration von Version 1 direkt auf Version 5 überspringt keine Zwischenschritte automatisch, sie durchläuft innerhalb desselben Events alle Bedingungen von oldVersion < 2 bis oldVersion < 5 nacheinander.
Ein häufiger Fehler bei der Versionsnummer-Verwaltung für IndexedDB-Schema-Migrationen ist, die Versionsnummer an einer einzigen, zentralen Konstante zu pflegen und bei jeder Strukturänderung konsequent zu erhöhen, statt sie an mehreren Stellen im Code zu verstreuen. Wird eine Versionsnummer vergessen zu erhöhen, obwohl sich die Datenbankstruktur geändert hat, bemerken bestehende Nutzer die neue Struktur nie, weil onupgradeneeded schlicht nicht feuert. Dieser Fehler ist besonders tückisch, weil er in der lokalen Entwicklungsumgebung mit gelöschter Datenbank unsichtbar bleibt und erst bei echten Nutzern mit bestehenden Daten auffällt.
4. Schrittweise Migrationsfunktionen pro Versionssprung
Für komplexere Anwendungen mit vielen Versionssprüngen wird eine einzelne onupgradeneeded-Funktion mit verschachtelten if-Blöcken schnell unübersichtlich. Eine robustere Struktur für IndexedDB-Schema-Migrationen kapselt jeden Versionssprung in einer eigenen, benannten Funktion, die genau eine strukturelle Änderung durchführt, und ruft diese Funktionen sequenziell in einer Schleife von oldVersion bis newVersion auf. Das macht jede Migration einzeln testbar und die Gesamtlogik nachvollziehbar, ähnlich wie nummerierte Migrationsdateien in serverseitigen ORMs.
Diese Struktur zahlt sich besonders aus, wenn eine IndexedDB-Schema-Migration nicht nur Object Stores oder Indexe betrifft, sondern auch bestehende Datensätze umformen muss, etwa wenn ein Feld umbenannt oder ein neues Pflichtfeld mit einem Standardwert nachgetragen werden soll. Da onupgradeneeded Zugriff auf die volle Transaktion gewährt, lassen sich Cursor-basierte Datentransformationen direkt innerhalb der Migration ausführen, ohne eine separate Nachbearbeitung nach dem Öffnen der Datenbank zu benötigen.
// Structured migration steps: one function per version bump
const migrations = {
1: (db) => {
db.createObjectStore("orders", { keyPath: "id" });
},
2: (db, tx) => {
tx.objectStore("orders").createIndex("byStatus", "status");
},
3: (db) => {
db.createObjectStore("customers", { keyPath: "id" });
},
4: (db, tx) => {
tx.objectStore("orders").createIndex("byCustomerAndDate", ["customerId", "createdAt"]);
},
};
function runMigrations(db, tx, oldVersion, newVersion) {
for (let version = oldVersion + 1; version <= newVersion; version++) {
const step = migrations[version];
if (step) {
console.log(`Applying migration step ${version}`);
step(db, tx);
}
}
}
5. Datentransformation während der Migration
Manche IndexedDB-Schema-Migrationen erfordern mehr als nur strukturelle Änderungen an Object Stores und Indexen, sie müssen bestehende Datensätze inhaltlich anpassen. Ein typisches Beispiel: Ein Feld fullName soll in firstName und lastName aufgeteilt werden. Da onupgradeneeded Zugriff auf die versionchange-Transaktion gewährt, lässt sich ein Cursor öffnen, der jeden bestehenden Datensatz durchläuft, transformiert und mit cursor.update() zurückschreibt, alles innerhalb derselben atomaren Migrationstransaktion.
Wichtig für diese Art von IndexedDB-Schema-Migration: Die gesamte Transformation muss synchron innerhalb der laufenden Transaktion bleiben, das heißt, keine asynchronen Fetch-Aufrufe oder Zeitgeber dürfen zwischen den Cursor-Schritten liegen, da die Transaktion sonst automatisch committet oder abgebrochen wird, bevor die Migration fertig ist. Für Datentransformationen, die externe Daten benötigen, etwa einen Abgleich mit einem Server, ist es robuster, die Rohdaten zunächst nur strukturell zu migrieren und die inhaltliche Anreicherung in einem separaten Schritt nach dem Öffnen der Datenbank durchzuführen.
// Data transformation cursor inside a schema migration step
function splitFullNameIntoFirstAndLast(tx) {
const store = tx.objectStore("customers");
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) return;
const record = cursor.value;
if (record.fullName && !record.firstName) {
const [firstName, ...rest] = record.fullName.split(" ");
record.firstName = firstName;
record.lastName = rest.join(" ");
delete record.fullName;
cursor.update(record); // stays inside the same versionchange transaction
}
cursor.continue();
};
}
6. Das blocked-Event und Cross-Tab-Konflikte
Eine IndexedDB-Schema-Migration kann nur beginnen, wenn keine andere Verbindung zur selben Datenbank mit einer älteren Version noch geöffnet ist. Ist in einem anderen Tab eine Verbindung mit der alten Version noch aktiv, feuert request.onblocked statt onupgradeneeded, und die Migration bleibt hängen, bis diese andere Verbindung geschlossen wird. Für Nutzer äußert sich das oft als scheinbar eingefrorene Anwendung, die auf einen Reload oder ein Schließen anderer Tabs wartet, ohne dass eine Fehlermeldung erscheint.
Ein robustes Muster für IndexedDB-Schema-Migrationen behandelt onblocked aktiv, statt es zu ignorieren: Die Anwendung kann den Nutzer informieren, dass andere geöffnete Tabs geschlossen werden müssen, oder aktiv versuchen, die eigene Datenbankverbindung in anderen Tabs zu schließen, sobald ein versionchange-Event dort empfangen wird. Ohne diese Behandlung wirkt eine ausstehende IndexedDB-Schema-Migration für Endnutzer wie ein Bug, obwohl es sich um erwartetes, aber unkommuniziertes Verhalten handelt.
// Handling the blocked event during a schema migration
const request = indexedDB.open("app_db", 5);
request.onblocked = (event) => {
console.warn("Migration blocked: another tab still has an older connection open");
showBanner("Bitte schließe andere Tabs dieser Anwendung, um fortzufahren");
};
request.onupgradeneeded = (event) => {
// migration logic runs here once unblocked
};
7. Reaktion auf versionchange in anderen Tabs
Damit eine IndexedDB-Schema-Migration in einem Tab nicht dauerhaft von einem anderen Tab blockiert wird, sollte jede offene Datenbankverbindung auf das versionchange-Event lauschen, das genau dann auf der bestehenden Verbindung feuert, wenn ein anderer Kontext versucht, die Datenbank auf eine höhere Version zu heben. Der empfohlene Umgang mit diesem Event ist, die eigene Verbindung kontrolliert zu schließen, damit die wartende Migration im anderen Tab fortfahren kann.
Wird dieses Event ignoriert, bleibt die alte Verbindung offen, die wartende IndexedDB-Schema-Migration hängt im blocked-Zustand fest, und im ungünstigsten Fall merkt der Nutzer erst durch einen manuellen Tab-Neustart, dass etwas nicht funktioniert. Für Anwendungen mit typischem Multi-Tab-Nutzungsmuster, etwa Admin-Oberflächen oder Dashboards, ist eine saubere versionchange-Behandlung daher kein optionales Detail, sondern Voraussetzung für zuverlässige Updates.
// Gracefully close the connection when another tab wants to migrate
function openDatabaseWithGracefulUpgrade() {
const request = indexedDB.open("app_db", 5);
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
console.log("Another tab requested a schema upgrade, closing this connection");
db.close();
showBanner("Diese Seite wurde aktualisiert, bitte neu laden");
};
};
return request;
}
8. Fehlerbehandlung und Rollback-Strategien
Schlägt eine IndexedDB-Schema-Migration innerhalb von onupgradeneeded fehl, etwa weil eine Transformation eine Exception wirft, wird die gesamte versionchange-Transaktion automatisch zurückgerollt, und die Datenbank verbleibt auf der alten Version. Das ist ein wichtiger Sicherheitsmechanismus: Eine fehlgeschlagene IndexedDB-Schema-Migration hinterlässt keine halb migrierte Datenbank, sondern kehrt atomar zum vorherigen, konsistenten Zustand zurück. Der nachfolgende request.onerror-Handler erhält die Exception und kann angemessen reagieren, etwa mit einer Nutzerbenachrichtigung oder einem erneuten Versuch.
Für kritische IndexedDB-Schema-Migrationen empfiehlt sich zusätzlich eine defensive Prüfung nach der Migration: Ein einfacher Zähler der Datensätze in einem betroffenen Object Store, verglichen vor und nach der Transformation, kann aufdecken, ob die Migrationslogik unbeabsichtigt Daten verloren hat, etwa durch einen fehlerhaften Filter in der Cursor-Schleife. Da es kein eingebautes Backup-System für IndexedDB gibt, ist eine solche Verifikation die einzige Absicherung gegen stille Datenverluste während der Migration.
9. Migrationsstrategien im Vergleich
Die folgende Tabelle vergleicht gängige Ansätze für IndexedDB-Schema-Migrationen und ihre jeweiligen Stärken und Schwächen.
| Ansatz | Struktur | Wann geeignet | Risiko |
|---|---|---|---|
| Verschachtelte if-Blöcke | Alles in einer onupgradeneeded-Funktion | Wenige Versionssprünge, kleine Anwendung | Unübersichtlich bei vielen Versionen |
| Nummerierte Migrationsfunktionen | Eine Funktion pro Versionssprung, sequenziell ausgeführt | Mittlere bis große Anwendungen | Etwas mehr Boilerplate-Code |
| Nur strukturelle Migration | Object Stores/Indexe anpassen, Daten unverändert lassen | Reine Struktur-Änderungen ohne Datentransformation | Alte Feldnamen bleiben bestehen |
| Cursor-basierte Datentransformation | Datensätze innerhalb der Migration umformen | Feldumbenennungen, neue Pflichtfelder | Muss synchron innerhalb der Transaktion bleiben |
| Nachgelagerte Anreicherung | Migration nur strukturell, Daten später ergänzen | Transformation braucht externe Daten | Zwischenzeitlich inkonsistenter Datenzustand |
Für die meisten Anwendungen ist eine Kombination sinnvoll: nummerierte Migrationsfunktionen als Grundgerüst, Cursor-basierte Transformationen für synchron durchführbare Änderungen, und nachgelagerte Anreicherung nur dort, wo externe Daten unvermeidlich sind. Eine gut strukturierte IndexedDB-Schema-Migration bleibt dadurch testbar, nachvollziehbar und robust gegenüber wachsender Anwendungskomplexität.
Mironsoft
JavaScript-Architektur, Offline-Datenbanken und Schema-Design
IndexedDB-Migrationen ohne Datenverlust und ohne Cross-Tab-Deadlocks?
Wir strukturieren IndexedDB-Schema-Migrationen mit nummerierten Migrationsfunktionen, sicherer Datentransformation und robuster blocked/versionchange-Behandlung für Multi-Tab-Anwendungen.
Migrations-Framework
Nummerierte, testbare Migrationsfunktionen statt verschachtelter if-Blöcke
Cross-Tab-Koordination
Blocked- und versionchange-Behandlung für unterbrechungsfreie Updates
Migrations-Audit
Prüfung bestehender Migrationslogik auf Datenverlustrisiken
10. Zusammenfassung
Eine IndexedDB-Schema-Migration unterscheidet sich grundlegend von serverseitigen Datenbank-Migrationen: kein Migrationstool, keine automatische Skript-Reihenfolge, sondern eine einzige Ganzzahl-Version und das onupgradeneeded-Event als einziger Ausführungsort. Nummerierte, in sich geschlossene Migrationsfunktionen pro Versionssprung machen diese Logik nachvollziehbar und testbar, während Cursor-basierte Datentransformationen innerhalb derselben Transaktion auch inhaltliche Änderungen an bestehenden Datensätzen erlauben.
Die größte Fehlerquelle bei IndexedDB-Schema-Migrationen liegt nicht in der Struktur-Logik selbst, sondern im Cross-Tab-Verhalten: Ohne saubere Behandlung von blocked und versionchange bleiben Migrationen hängen, während ein anderer Tab noch eine alte Verbindung offen hält. Wer diese beiden Events konsequent behandelt und die versionchange-Transaktion als atomare Einheit versteht, baut Migrationen, die auch in Multi-Tab-Szenarien zuverlässig und ohne Datenverlust ablaufen.
IndexedDB Schema-Migration — Das Wichtigste auf einen Blick
onupgradeneeded
Einziger Ort für createObjectStore(), createIndex() und strukturelle Änderungen, ausgelöst bei höherer Versionsnummer.
Versionsnummer
Einfache Ganzzahl, zentral gepflegt, jede Strukturänderung erfordert eine Erhöhung.
Datentransformation
Cursor-basiert innerhalb der versionchange-Transaktion, muss synchron bleiben.
Cross-Tab-Verhalten
blocked- und versionchange-Events aktiv behandeln, um Deadlocks zu vermeiden.