commit 72b3202dc370578435392422cf11f2a07ab3e1ba
parent ace3ce35ddf1c8f6cda9dc7cefc96884ccb77753
Author: Florian Dold <dold@taler.net>
Date: Thu, 3 Sep 2026 15:33:41 +0200
wallet-core: snapshot the database before rolling back a migration
The restore empties the native tables, which hold everything the
wallet did since migrating.
Diffstat:
3 files changed, 49 insertions(+), 6 deletions(-)
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -3565,14 +3565,19 @@ advancedCli
.requiredArgument("dbfile", clk.STRING, {
help: "Wallet database file to roll back.",
})
+ .requiredOption("backup", ["--backup"], clk.STRING, {
+ help: "Where to snapshot the database, native records included, before it is rolled back.",
+ })
.action(async (args) => {
// Everything the wallet did since the migration lives in the native
// tables and is not carried back, so say so rather than report success
// and let it be discovered later.
- await rollbackWalletDbMigration(args.dbMigrationRollback.dbfile);
+ const cmd = args.dbMigrationRollback;
+ await rollbackWalletDbMigration(cmd.dbfile, cmd.backup);
console.log(
`rolled back to the database as it was before the migration;` +
- ` anything the wallet did since then is not part of it.`,
+ ` anything the wallet did since then is not part of it` +
+ ` and is kept only in ${cmd.backup}.`,
);
});
diff --git a/packages/taler-wallet-core/src/db/migration/native.test.ts b/packages/taler-wallet-core/src/db/migration/native.test.ts
@@ -71,6 +71,7 @@ import {
createNativeWalletHost2,
inspectWalletDbPath,
resolveWalletDbMigration,
+ rollbackWalletDbMigration,
} from "../../host-impl.node.js";
import { acquireSqliteWalletDbOwnership } from "../../host-common.js";
import { WalletApiOperation } from "../../wallet-api-types.js";
@@ -1143,3 +1144,30 @@ test("explicit native migration can be cancelled by progress token", async () =>
fs.rmSync(dir, { recursive: true, force: true });
}
});
+
+test("native migration: rollback backs up the native database first", async () => {
+ const directory = fs.mkdtempSync(
+ path.join(os.tmpdir(), "wallet-db-rollback-backup-"),
+ );
+ const filename = path.join(directory, "wallet.sqlite3");
+ const backupPath = path.join(directory, "native-backup.sqlite3");
+ try {
+ const { db, handle } = await makeMinimalIdbDb(filename);
+ await migrateWalletDbToNative(db, handle);
+ await handle.close();
+
+ await rollbackWalletDbMigration(filename, backupPath);
+
+ assert.strictEqual((await inspectWalletDbPath(filename)).kind, "indexeddb");
+ assert.ok(fs.existsSync(backupPath));
+ assert.strictEqual((await inspectWalletDbPath(backupPath)).kind, "native");
+
+ // The backup is never silently overwritten.
+ await assert.rejects(
+ () => rollbackWalletDbMigration(filename, backupPath),
+ /already exists/,
+ );
+ } finally {
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+});
diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts
@@ -358,17 +358,27 @@ export async function resolveWalletDbMigration(
/**
* Undo an in-place migration, from the copy it retained.
*
- * Everything the wallet did since the migration is in the native tables and
- * stays there; what comes back is the database as it was the moment before
- * the migration ran. The wallet must not be running.
+ * What comes back is the database as it was the moment before the migration
+ * ran. Everything the wallet did since then lives only in the native tables,
+ * which the restore empties, so the whole file is snapshotted to backupPath
+ * first. The wallet must not be running.
*/
-export async function rollbackWalletDbMigration(dbPath: string): Promise<void> {
+export async function rollbackWalletDbMigration(
+ dbPath: string,
+ backupPath: string,
+): Promise<void> {
if (!fs.existsSync(dbPath)) {
throw Error(`wallet database ${dbPath} does not exist`);
}
+ if (fs.existsSync(backupPath)) {
+ throw Error(`backup destination ${backupPath} already exists`);
+ }
const imp = await createNodeHelperSqlite3Impl();
const db = await imp.open(dbPath);
try {
+ await (
+ await db.prepare("VACUUM INTO $filename")
+ ).run({ filename: backupPath });
await restoreMigrationBackup(db);
} finally {
await db.close();