2025-05-06 11:25:24 +02:00
import { OfflineDbMeta , OfflineStorage } from "./OfflineStorage.js"
2025-08-15 10:55:58 +02:00
import { assertNotNull , last } from "@tutao/tutanota-utils"
2022-12-27 15:37:40 +01:00
import { SqlCipherFacade } from "../../../native/common/generatedipc/SqlCipherFacade.js"
2023-01-04 10:54:28 +01:00
import { OutOfSyncError } from "../../common/error/OutOfSyncError.js"
2025-08-14 13:48:56 +02:00
import { offline5 } from "./migrations/offline-v5"
import { offline6 } from "./migrations/offline-v6"
2025-07-17 17:57:30 +02:00
import { offline7 } from "./migrations/offline-v7"
2025-05-13 13:17:30 +02:00
import { offline8 } from "./migrations/offline-v8"
2025-08-15 10:55:58 +02:00
import { ProgrammingError } from "../../common/error/ProgrammingError"
2022-05-17 17:40:44 +02:00
export interface OfflineMigration {
readonly version : number
2022-06-16 17:23:48 +02:00
2022-10-21 15:53:39 +02:00
migrate ( storage : OfflineStorage , sqlCipherFacade : SqlCipherFacade ) : Promise < void >
2022-05-17 17:40:44 +02:00
}
2022-11-01 09:47:19 +01:00
/ * *
* List of migrations that will be run when needed . Please add your migrations to the list .
*
* Normally you should only add them to the end of the list but with offline ones it can be a bit tricky since they change the db structure itself so sometimes
* they should rather be in the beginning .
* /
2025-05-13 13:17:30 +02:00
export const OFFLINE_STORAGE_MIGRATIONS : ReadonlyArray < OfflineMigration > = [ offline5 , offline6 , offline7 , offline8 ]
2022-05-17 17:40:44 +02:00
2025-01-07 10:42:43 +01:00
// in cases where the actual migration is not there anymore (we clean up old migrations no client would apply anymore)
// and we create a new offline database, we still need to set the offline version to the current value.
2025-08-14 13:48:56 +02:00
export const CURRENT_OFFLINE_VERSION = 8
2023-02-02 14:17:02 +01:00
2022-05-17 17:40:44 +02:00
/ * *
* Migrator for the offline storage between different versions of model . It is tightly couples to the versions of API entities : every time we make an
* "incompatible" change to the API model we need to update offline database somehow .
*
* Migrations are done manually but there are a few checks done :
* - compile time check that migration exists and is used in this file
* - runtime check that runtime model is compatible to the stored one after all the migrations are done .
*
* To add a new migration create a migration with the filename matching . / migrations / { app } - v { version } . ts and use it in the ` migrations ` field on this
* migrator .
*
* Migrations might read and write to the database and they should use StandardMigrations when needed .
* /
export class OfflineStorageMigrator {
2025-05-06 11:25:24 +02:00
constructor ( private readonly migrations : ReadonlyArray < OfflineMigration > ) { }
2022-05-17 17:40:44 +02:00
2022-10-21 15:53:39 +02:00
async migrate ( storage : OfflineStorage , sqlCipherFacade : SqlCipherFacade ) {
2025-08-15 10:55:58 +02:00
assertLastMigrationConsistentVersion ( this . migrations )
2023-02-02 15:07:12 +01:00
const meta = await storage . dumpMetadata ( )
2022-05-17 17:40:44 +02:00
2023-02-02 14:17:02 +01:00
// We did not write down the "offline" version from the beginning, so we need to figure out if we need to run the migration for the db structure or
// not. Previously we've been checking that there's something in the meta table which is a pretty decent check. Unfortunately we had multiple bugs
// which resulted in a state where we would re-create the offline db but not populate the meta table with the versions, the only thing that would be
// written is lastUpdateTime.
// {} -> new db, do not migrate offline
// {"base-version": 1, "lastUpdateTime": 123, "offline-version": 1} -> up-to-date db, do not migrate offline
// {"lastUpdateTime": 123} -> broken state after the buggy recreation of db, delete the db
// {"base-version": 1, "lastUpdateTime": 123} -> some very old state where we would actually have to migrate offline
if ( Object . keys ( meta ) . length === 1 && meta . lastUpdateTime != null ) {
throw new OutOfSyncError ( "Invalid DB state, missing model versions" )
2022-05-17 17:40:44 +02:00
}
2023-02-02 15:07:12 +01:00
const populatedMeta = await this . populateModelVersions ( meta , storage )
2022-10-18 16:27:03 +02:00
2023-02-02 15:07:12 +01:00
if ( this . isDbNewerThanCurrentClient ( populatedMeta ) ) {
2023-01-04 10:54:28 +01:00
throw new OutOfSyncError ( ` offline database has newer schema than client ` )
}
2025-08-15 10:55:58 +02:00
// note: we are passing populatedMeta to have up-to-date version
await this . runMigrations ( populatedMeta , storage , sqlCipherFacade )
2022-05-17 17:40:44 +02:00
}
2022-10-18 16:27:03 +02:00
2025-08-15 10:55:58 +02:00
private async runMigrations ( meta : Pick < OfflineDbMeta , "offline-version" > , storage : OfflineStorage , sqlCipherFacade : SqlCipherFacade ) {
let currentOfflineVersion = meta [ ` offline-version ` ]
2025-05-06 11:25:24 +02:00
for ( const { version , migrate } of this . migrations ) {
2025-08-15 10:55:58 +02:00
if ( currentOfflineVersion < version ) {
console . log ( ` running offline db migration from ${ currentOfflineVersion } to ${ version } ` )
2023-02-02 15:07:12 +01:00
await migrate ( storage , sqlCipherFacade )
2025-05-06 11:25:24 +02:00
await storage . setCurrentOfflineSchemaVersion ( version )
2025-08-15 10:55:58 +02:00
currentOfflineVersion = version
2025-11-05 11:13:22 +01:00
console . log ( ` migration finished to ${ currentOfflineVersion } ` )
2023-02-02 15:07:12 +01:00
}
}
}
2025-08-15 10:55:58 +02:00
private async populateModelVersions ( meta : Readonly < Partial < OfflineDbMeta > > , storage : OfflineStorage ) : Promise < Pick < OfflineDbMeta , "offline-version" > > {
2023-02-02 14:17:02 +01:00
// copy metadata because it's going to be mutated
const newMeta = { . . . meta }
2025-08-15 10:55:58 +02:00
return await this . prepopulateVersionIfAbsent ( CURRENT_OFFLINE_VERSION , newMeta , storage )
2023-02-02 14:17:02 +01:00
}
2022-10-18 16:27:03 +02:00
/ * *
2025-05-06 11:25:24 +02:00
* update the metadata table to initialize the row of the app with the given schema version
2023-02-02 14:17:02 +01:00
*
* NB : mutates meta
2022-10-18 16:27:03 +02:00
* /
2025-08-15 10:55:58 +02:00
private async prepopulateVersionIfAbsent (
version : number ,
meta : Partial < OfflineDbMeta > ,
storage : OfflineStorage ,
) : Promise < Pick < OfflineDbMeta , "offline-version" > > {
2025-05-06 11:25:24 +02:00
const storedVersion = meta [ "offline-version" ]
2022-10-18 16:27:03 +02:00
if ( storedVersion == null ) {
2025-05-06 11:25:24 +02:00
meta [ "offline-version" ] = version
await storage . setCurrentOfflineSchemaVersion ( version )
2022-10-18 16:27:03 +02:00
}
2025-08-15 10:55:58 +02:00
return meta as { "offline-version" : typeof version }
2022-10-18 16:27:03 +02:00
}
2023-01-04 10:54:28 +01:00
/ * *
2025-05-06 11:25:24 +02:00
* it ' s possible that the user installed an older client over a newer one .
* if the offline schema changed between the clients , it 's likely that the old client can' t even understand
* the structure of the db . we ' re going to delete it and not migrate at all .
2023-01-04 10:54:28 +01:00
* @private
2023-01-04 16:54:58 +01:00
*
2025-05-06 11:25:24 +02:00
* @returns true if the database we ' re supposed to migrate has any higher schema versions than our schema version
2023-01-04 10:54:28 +01:00
* /
2023-01-04 16:54:58 +01:00
private isDbNewerThanCurrentClient ( meta : Partial < OfflineDbMeta > ) : boolean {
2023-02-02 14:17:02 +01:00
return assertNotNull ( meta [ ` offline-version ` ] ) > CURRENT_OFFLINE_VERSION
2023-01-04 10:54:28 +01:00
}
2022-12-27 15:37:40 +01:00
}
2025-08-15 10:55:58 +02:00
export function assertLastMigrationConsistentVersion ( migrations : ReadonlyArray < OfflineMigration > ) : void {
const lastMigration = last ( migrations )
if ( lastMigration != null && lastMigration . version !== CURRENT_OFFLINE_VERSION ) {
throw new ProgrammingError (
` Inconsistent offline migration state: expected latest version to be ${ CURRENT_OFFLINE_VERSION } based on CURRENT_OFFLINE_VERSION but the last migration version is ${ lastMigration . version } ` ,
)
}
}