commit 6cb5806858f4a38f11f6b4328357d633431fdb8c
parent e4a8def3d04b9585cc45433b2e3f5bc7c057168f
Author: Florian Dold <dold@taler.net>
Date: Mon, 31 Aug 2026 11:47:40 +0200
wallet-core: migrate legacy IndexedDB records to native database
Diffstat:
8 files changed, 541 insertions(+), 8 deletions(-)
diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts
@@ -24,6 +24,7 @@ import {
import {
PaymentInsufficientBalanceDetails,
TalerErrorDetail,
+ WalletDatabaseBackend,
} from "./types-taler-wallet.js";
/**
@@ -215,6 +216,12 @@ export interface DetailsMap {
};
[TalerErrorCode.WALLET_DB_UNAVAILABLE]: {
innerError: TalerErrorDetail | undefined;
+ /** Operation whose failure was reported through this general DB error. */
+ operation?: "migrateDatabase";
+ /** Backend that remains active after a failed migration. */
+ activeDatabaseBackend?: WalletDatabaseBackend;
+ /** Whether the migration source remains authoritative and usable. */
+ sourceDatabaseRetained?: true;
};
[TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED]: {
exchangeBaseUrl: string;
diff --git a/packages/taler-wallet-core/src/db/indexeddb/fixups.ts b/packages/taler-wallet-core/src/db/indexeddb/fixups.ts
@@ -41,6 +41,7 @@ import {
WalletCoin,
WalletDenomFamilyParams,
WalletReserve,
+ WithdrawalRecordType,
WithdrawalGroupStatus,
} from "../records.js";
import { DbAccess } from "../query.js";
@@ -62,6 +63,13 @@ export interface FixupDescription {
* Fixups *must* be idempotent.
*/
export const walletDbFixups: FixupDescription[] = [
+ // Several short-lived record shapes were never rewritten when their fields
+ // moved or became mandatory. Repair them before the DAL hides the legacy
+ // properties and before a native-backend migration enforces its columns.
+ {
+ fn: fixup20260831LegacyRecordShapes,
+ name: "fixup20260831LegacyRecordShapes",
+ },
// Exchange purging used to delete coins without deleting their histories,
// leaving rows that the sqlite foreign key cannot represent.
{
@@ -172,6 +180,137 @@ export const WALLET_DB_MAINTENANCE_TOTAL_STEPS = walletDbFixups.length + 2;
export const WALLET_DB_SCHEMA_UPGRADE_STEP = "indexeddb-schema-upgrade";
export const WALLET_DB_REMATERIALIZE_STEP = "rematerialize-transactions";
+interface LegacyMailboxParts {
+ mailboxBaseUri: string;
+ mailboxAddress: string;
+}
+
+function splitLegacyMailboxUri(
+ mailboxUri: string,
+): LegacyMailboxParts | undefined {
+ try {
+ const parsed = new URL(mailboxUri);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return undefined;
+ }
+ const pathParts = parsed.pathname.split("/");
+ const mailboxAddress = pathParts.pop();
+ if (!mailboxAddress) {
+ return undefined;
+ }
+ parsed.pathname = `${pathParts.join("/")}/`;
+ parsed.search = "";
+ parsed.hash = "";
+ return {
+ mailboxBaseUri: parsed.href,
+ mailboxAddress,
+ };
+ } catch {
+ return undefined;
+ }
+}
+
+function migrateLegacyKycFields(
+ rec: Record<string, any>,
+ legacyInfoField: "kycPending" | "kycInfo",
+): boolean {
+ let changed = false;
+ const legacyInfo = rec[legacyInfoField];
+ if (
+ rec.kycPaytoHash === undefined &&
+ typeof legacyInfo?.paytoHash === "string"
+ ) {
+ rec.kycPaytoHash = legacyInfo.paytoHash;
+ changed = true;
+ }
+ if (legacyInfoField in rec) {
+ delete rec[legacyInfoField];
+ changed = true;
+ }
+ if ("kycUrl" in rec) {
+ delete rec.kycUrl;
+ changed = true;
+ }
+ return changed;
+}
+
+/**
+ * Rewrite legacy auxiliary record shapes that predate current required fields.
+ */
+async function fixup20260831LegacyRecordShapes(
+ tx: WalletIndexedDbTransaction,
+): Promise<void> {
+ await tx.contacts.iter().forEachAsync(async (contact) => {
+ const legacyContact = contact as any;
+ let changed = false;
+ if (
+ typeof legacyContact.mailboxBaseUri !== "string" ||
+ typeof legacyContact.mailboxAddress !== "string"
+ ) {
+ const legacyUri = legacyContact.mailboxUri;
+ const parts =
+ typeof legacyUri === "string"
+ ? splitLegacyMailboxUri(legacyUri)
+ : undefined;
+ if (!parts) {
+ logger.warn("dropping a legacy contact with a malformed mailbox URI");
+ await tx.contacts.delete([contact.alias, contact.aliasType]);
+ return;
+ }
+ if (typeof legacyContact.mailboxBaseUri !== "string") {
+ legacyContact.mailboxBaseUri = parts.mailboxBaseUri;
+ }
+ if (typeof legacyContact.mailboxAddress !== "string") {
+ legacyContact.mailboxAddress = parts.mailboxAddress;
+ }
+ changed = true;
+ }
+ if (typeof legacyContact.petname !== "string") {
+ legacyContact.petname = contact.alias;
+ changed = true;
+ }
+ if ("mailboxUri" in legacyContact) {
+ delete legacyContact.mailboxUri;
+ changed = true;
+ }
+ if (changed) {
+ await tx.contacts.put(contact);
+ }
+ });
+
+ await tx.withdrawalGroups.iter().forEachAsync(async (withdrawalGroup) => {
+ const legacyGroup = withdrawalGroup as any;
+ let changed = migrateLegacyKycFields(legacyGroup, "kycPending");
+ if ("senderWire" in legacyGroup) {
+ if (
+ typeof legacyGroup.senderWire === "string" &&
+ withdrawalGroup.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated &&
+ withdrawalGroup.wgInfo.bankInfo.senderWire === undefined
+ ) {
+ withdrawalGroup.wgInfo.bankInfo.senderWire = legacyGroup.senderWire;
+ }
+ delete legacyGroup.senderWire;
+ changed = true;
+ }
+ if (changed) {
+ await tx.withdrawalGroups.put(withdrawalGroup);
+ }
+ });
+
+ await tx.peerPushCredit.iter().forEachAsync(async (peerCredit) => {
+ if (migrateLegacyKycFields(peerCredit as any, "kycInfo")) {
+ await tx.peerPushCredit.put(peerCredit);
+ }
+ });
+
+ await tx.peerPullCredit.iter().forEachAsync(async (peerCredit) => {
+ if (migrateLegacyKycFields(peerCredit as any, "kycInfo")) {
+ await tx.peerPullCredit.put(peerCredit);
+ }
+ });
+}
+
/**
* Delete coin histories whose coins were removed by the old IndexedDB
* exchange-purge implementation.
diff --git a/packages/taler-wallet-core/src/db/indexeddb/schema.ts b/packages/taler-wallet-core/src/db/indexeddb/schema.ts
@@ -500,6 +500,9 @@ export interface ContactRecord {
* The local petname of this alias
*/
petname: string;
+
+ // Reserved legacy fields, migrated by the IndexedDB fixup:
+ // * mailboxUri: string
}
/**
diff --git a/packages/taler-wallet-core/src/db/migration/converter.test.ts b/packages/taler-wallet-core/src/db/migration/converter.test.ts
@@ -54,6 +54,7 @@ import {
WalletExchangeSignkeys,
WalletPeerPushCredit,
WalletPurchase,
+ WithdrawalRecordType,
} from "../records.js";
import { SQLITE_BASELINE_SCHEMA } from "../sqlite/schema.js";
import {
@@ -78,6 +79,170 @@ const quietAsserts: ConformanceAsserts = {
},
};
+test("IndexedDB fixup migrates legacy contacts before conversion", async () => {
+ const src = await makeIdbRunner();
+ const raw = await (src as IdbWalletDbHandle).rawAccess();
+ await raw.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.contacts.put({
+ alias: "bob@example.com",
+ aliasType: "email",
+ mailboxUri: "https://mailbox.example.com/mb/BOBPKEY",
+ source: "legacy",
+ } as any);
+ await tx.contacts.put({
+ alias: "malformed@example.com",
+ aliasType: "email",
+ mailboxUri: "not a mailbox URL",
+ source: "legacy",
+ } as any);
+ await tx.contacts.put({
+ alias: "alice@example.com",
+ aliasType: "email",
+ mailboxBaseUri: "https://mailbox.example.com/mb/",
+ mailboxAddress: "ALICEPKEY",
+ source: "intermediate",
+ } as any);
+ await tx.fixups.delete("fixup20260831LegacyRecordShapes");
+ });
+
+ await applyFixups(raw);
+ const fixedContacts = await raw.runAllStoresReadWriteTx({}, (tx) =>
+ tx.contacts.getAll(),
+ );
+ assert.deepStrictEqual(fixedContacts, [
+ {
+ alias: "alice@example.com",
+ aliasType: "email",
+ mailboxBaseUri: "https://mailbox.example.com/mb/",
+ mailboxAddress: "ALICEPKEY",
+ source: "intermediate",
+ petname: "alice@example.com",
+ },
+ {
+ alias: "bob@example.com",
+ aliasType: "email",
+ mailboxBaseUri: "https://mailbox.example.com/mb/",
+ mailboxAddress: "BOBPKEY",
+ source: "legacy",
+ petname: "bob@example.com",
+ },
+ ]);
+
+ // Force the implementation to run again rather than only checking its
+ // marker, and ensure the already-current records remain byte-for-byte equal.
+ await raw.runAllStoresReadWriteTx({}, (tx) =>
+ tx.fixups.delete("fixup20260831LegacyRecordShapes"),
+ );
+ await applyFixups(raw);
+ assert.deepStrictEqual(
+ await raw.runAllStoresReadWriteTx({}, (tx) => tx.contacts.getAll()),
+ fixedContacts,
+ );
+
+ const dst = await makeSqliteRunner();
+ await convertWalletDb(src, dst);
+ assert.deepStrictEqual(
+ await dst.runReadWriteTx((tx) => tx.listContacts()),
+ fixedContacts,
+ );
+ await src.close();
+ await dst.close();
+});
+
+test("IndexedDB fixup migrates legacy withdrawal and peer KYC fields", async () => {
+ const src = await makeIdbRunner();
+ const raw = await (src as IdbWalletDbHandle).rawAccess();
+ await raw.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.withdrawalGroups.put({
+ withdrawalGroupId: "legacy-withdrawal",
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ bankInfo: { talerWithdrawUri: "taler://withdraw/example/legacy" },
+ },
+ senderWire: "payto://iban/LEGACY",
+ kycPending: { paytoHash: "LEGACY-WITHDRAWAL-HASH", requirementRow: 1 },
+ kycUrl: "https://exchange.example.com/legacy-kyc",
+ } as any);
+ await tx.withdrawalGroups.put({
+ withdrawalGroupId: "current-wins",
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ bankInfo: {
+ talerWithdrawUri: "taler://withdraw/example/current",
+ senderWire: "payto://iban/CURRENT",
+ },
+ },
+ senderWire: "payto://iban/OLD",
+ kycPaytoHash: "CURRENT-WITHDRAWAL-HASH",
+ kycPending: { paytoHash: "OLD-WITHDRAWAL-HASH", requirementRow: 2 },
+ } as any);
+ await tx.peerPushCredit.put({
+ peerPushCreditId: "legacy-push-credit",
+ exchangeBaseUrl: "https://exchange.example.com/",
+ contractPriv: "PUSH-CONTRACT-PRIV",
+ kycInfo: { paytoHash: "LEGACY-PUSH-HASH", requirementRow: 3 },
+ kycUrl: "https://exchange.example.com/push-kyc",
+ } as any);
+ await tx.peerPullCredit.put({
+ pursePub: "legacy-pull-credit",
+ exchangeBaseUrl: "https://exchange.example.com/",
+ contractPriv: "PULL-CONTRACT-PRIV",
+ kycPaytoHash: "CURRENT-PULL-HASH",
+ kycInfo: { paytoHash: "OLD-PULL-HASH", requirementRow: 4 },
+ kycUrl: "https://exchange.example.com/pull-kyc",
+ } as any);
+ await tx.fixups.delete("fixup20260831LegacyRecordShapes");
+ });
+
+ await applyFixups(raw);
+ const fixed = await raw.runAllStoresReadWriteTx({}, async (tx) => ({
+ legacyWithdrawal: await tx.withdrawalGroups.get("legacy-withdrawal"),
+ currentWithdrawal: await tx.withdrawalGroups.get("current-wins"),
+ push: await tx.peerPushCredit.get("legacy-push-credit"),
+ pull: await tx.peerPullCredit.get("legacy-pull-credit"),
+ }));
+ const legacyWithdrawal = fixed.legacyWithdrawal!;
+ const currentWithdrawal = fixed.currentWithdrawal!;
+ assert.strictEqual(
+ (legacyWithdrawal.wgInfo as any).bankInfo.senderWire,
+ "payto://iban/LEGACY",
+ );
+ assert.strictEqual(legacyWithdrawal.kycPaytoHash, "LEGACY-WITHDRAWAL-HASH");
+ assert.strictEqual(
+ (currentWithdrawal.wgInfo as any).bankInfo.senderWire,
+ "payto://iban/CURRENT",
+ );
+ assert.strictEqual(currentWithdrawal.kycPaytoHash, "CURRENT-WITHDRAWAL-HASH");
+ assert.strictEqual((fixed.push as any)?.kycPaytoHash, "LEGACY-PUSH-HASH");
+ assert.strictEqual((fixed.pull as any)?.kycPaytoHash, "CURRENT-PULL-HASH");
+ for (const rec of [
+ fixed.legacyWithdrawal as any,
+ fixed.currentWithdrawal as any,
+ fixed.push as any,
+ fixed.pull as any,
+ ]) {
+ assert.ok(!("senderWire" in rec));
+ assert.ok(!("kycPending" in rec));
+ assert.ok(!("kycInfo" in rec));
+ assert.ok(!("kycUrl" in rec));
+ }
+
+ await raw.runAllStoresReadWriteTx({}, (tx) =>
+ tx.fixups.delete("fixup20260831LegacyRecordShapes"),
+ );
+ await applyFixups(raw);
+ assert.deepStrictEqual(
+ await raw.runAllStoresReadWriteTx({}, async (tx) => ({
+ legacyWithdrawal: await tx.withdrawalGroups.get("legacy-withdrawal"),
+ currentWithdrawal: await tx.withdrawalGroups.get("current-wins"),
+ push: await tx.peerPushCredit.get("legacy-push-credit"),
+ pull: await tx.peerPullCredit.get("legacy-pull-credit"),
+ })),
+ fixed,
+ );
+ await src.close();
+});
+
test("converter: preserves a legacy orphan coin without a master key", async () => {
const src = await makeIdbRunner();
const dst = await makeSqliteRunner();
@@ -339,7 +504,21 @@ test("converter: IndexedDB to sqlite, populated by the conformance corpus", asyn
});
const idb = src as IdbWalletDbHandle;
const raw = await idb.rawAccess();
+ let legacyDenominationHash = "";
+ let legacyPeerPushPursePub = "";
await raw.runAllStoresReadWriteTx({}, async (tx) => {
+ const denomination = (await tx.denominationsV2.getAll())[0];
+ const peerPushDebit = (await tx.peerPushDebit.getAll())[0];
+ assert.ok(
+ denomination && peerPushDebit,
+ "corpus did not create legacy allow-list test records",
+ );
+ legacyDenominationHash = denomination.denomPubHash;
+ legacyPeerPushPursePub = peerPushDebit.pursePub;
+ (denomination as any).pendingWithdrawalOutputCoins = 2;
+ (peerPushDebit as any).amountPurse = peerPushDebit.amount;
+ await tx.denominationsV2.put(denomination);
+ await tx.peerPushDebit.put(peerPushDebit);
await tx.fixups.delete("fixup20260812ExchangeWithdrawValues");
});
await applyFixups(raw);
@@ -392,8 +571,30 @@ test("converter: IndexedDB to sqlite, populated by the conformance corpus", asyn
(await tx.getPlanchet(legacyPlanchetPub))?.exchangeWithdrawValues,
{ cipher: "RSA" },
);
+ const denomination = (await tx.listAllDenominations()).find(
+ (x) => x.denomPubHash === legacyDenominationHash,
+ );
+ const peerPushDebit = await tx.getPeerPushDebit(legacyPeerPushPursePub);
+ assert.ok(denomination && peerPushDebit);
+ assert.ok(!("pendingWithdrawalOutputCoins" in denomination));
+ assert.ok(!("amountPurse" in peerPushDebit));
});
+ const retainedLegacyFields = await raw.runAllStoresReadWriteTx(
+ {},
+ async (tx) => ({
+ denomination: (await tx.denominationsV2.getAll()).find(
+ (x) => x.denomPubHash === legacyDenominationHash,
+ ),
+ peerPushDebit: await tx.peerPushDebit.get(legacyPeerPushPursePub),
+ }),
+ );
+ assert.strictEqual(
+ (retainedLegacyFields.denomination as any)?.pendingWithdrawalOutputCoins,
+ 2,
+ );
+ assert.ok("amountPurse" in (retainedLegacyFields.peerPushDebit as any));
+
assert.ok(
report.totalRecords >= 100,
`only ${report.totalRecords} records converted -- the corpus did not` +
@@ -568,6 +769,79 @@ test("converter: discards the legacy exchange update retry counter", async () =>
await dst.close();
});
+test("converter: discards the legacy denomination family hash", async () => {
+ // Before the tuple index was introduced, IndexedDB records persisted this
+ // derived lookup key. Schema upgrades remove the index but do not rewrite
+ // records, while the native schema deliberately has no column for it.
+ const src = await makeIdbRunner();
+ const raw = await (src as IdbWalletDbHandle).rawAccess();
+ const familyParamsHash = encodeCrock(getRandomBytes(32));
+ const familyParams = {
+ exchangeBaseUrl: "https://exchange.example.com/",
+ exchangeMasterPub: encodeCrock(getRandomBytes(32)),
+ value: "TESTKUDOS:1",
+ feeWithdraw: "TESTKUDOS:0.01",
+ feeDeposit: "TESTKUDOS:0.02",
+ feeRefresh: "TESTKUDOS:0.03",
+ feeRefund: "TESTKUDOS:0.04",
+ };
+ let denominationFamilySerial: number | undefined;
+ await raw.runAllStoresReadWriteTx({}, async (tx) => {
+ const result = await tx.denominationFamilies.put({
+ familyParams,
+ familyParamsHash,
+ } as any);
+ assert.strictEqual(typeof result.key, "number");
+ denominationFamilySerial = result.key as number;
+ });
+
+ const dst = await makeSqliteRunner();
+ await convertWalletDb(src, dst);
+ const families = await dst.runReadWriteTx((tx) =>
+ tx.listAllDenominationFamilies(),
+ );
+ assert.deepStrictEqual(families, [
+ { denominationFamilySerial, familyParams },
+ ]);
+ assert.ok(!("familyParamsHash" in families[0]));
+
+ const sourceFamily = await raw.runAllStoresReadWriteTx({}, (tx) =>
+ tx.denominationFamilies.get(denominationFamilySerial!),
+ );
+ assert.strictEqual((sourceFamily as any)?.familyParamsHash, familyParamsHash);
+
+ await src.close();
+ await dst.close();
+});
+
+test("converter: rejects an unknown denomination family field", async () => {
+ const src = await makeIdbRunner();
+ const raw = await (src as IdbWalletDbHandle).rawAccess();
+ await raw.runAllStoresReadWriteTx({}, (tx) =>
+ tx.denominationFamilies.put({
+ familyParams: {
+ exchangeBaseUrl: "https://exchange.example.com/",
+ exchangeMasterPub: encodeCrock(getRandomBytes(32)),
+ value: "TESTKUDOS:1",
+ feeWithdraw: "TESTKUDOS:0.01",
+ feeDeposit: "TESTKUDOS:0.02",
+ feeRefresh: "TESTKUDOS:0.03",
+ feeRefund: "TESTKUDOS:0.04",
+ },
+ unknownFutureField: "must not be discarded silently",
+ } as any),
+ );
+
+ const dst = await makeSqliteRunner();
+ await assert.rejects(
+ () => convertWalletDb(src, dst),
+ /conversion verification failed: denominationFamilies differs/,
+ );
+
+ await src.close();
+ await dst.close();
+});
+
test("converter: canonicalises a legacy purchase with empty exchanges", async () => {
// Old IndexedDB wallets could persist an explicit empty array here. The
// native representation uses a junction table, where no rows means the
diff --git a/packages/taler-wallet-core/src/db/migration/converter.ts b/packages/taler-wallet-core/src/db/migration/converter.ts
@@ -105,10 +105,17 @@ const LEGACY_FIELDS: Record<string, string[]> = {
exchanges: ["updateRetryCounter"],
// Removed 2024-06-13 ("remove coinAllocationId, simplify coin history").
coins: ["spendAllocation"],
- // Dropped when denomination records were restructured; nothing reads it.
- denominations: ["listIssueDate"],
+ // Both fields were removed after their derived bookkeeping became
+ // unnecessary; neither has a native-schema representation.
+ denominations: ["listIssueDate", "pendingWithdrawalOutputCoins"],
+ // Replaced by the byFamilyParams tuple index; current records and the
+ // native schema no longer store this derived lookup key.
+ denominationFamilies: ["familyParamsHash"],
// Superseded by per-selection UIDs inside denomsSel; removed 2024-06-10.
withdrawalGroups: ["denomSelUid"],
+ // Briefly stored separately from totalCost; removed when the original
+ // peer-push fee semantics were restored.
+ peerPushDebit: ["amountPurse"],
// Documented in records.ts as a reserved legacy field (v1 refresh); the
// current protocol derives a public seed on demand instead.
refreshSessions: ["sessionSecretSeed"],
@@ -153,6 +160,10 @@ function normalizeContractPriv<T extends { contractPriv: string }>(rec: T): T {
return { ...rec, contractPriv: rec.contractPriv.toUpperCase() };
}
+function normalizePeerPushDebit<T extends { contractPriv: string }>(rec: T): T {
+ return normalizeContractPriv(stripLegacy("peerPushDebit")!(rec) as T);
+}
+
/**
* Require the one-reserve-per-public-key invariant of the native schema.
*
@@ -326,7 +337,11 @@ const COPY_PLAN: CopyStep[][] = [
step(
"denominationFamilies",
(tx) => tx.listAllDenominationFamilies(),
- (tx, r) => tx.upsertDenominationFamily(r),
+ (tx, r) =>
+ tx.upsertDenominationFamily(
+ stripLegacy("denominationFamilies")!(r) as any,
+ ),
+ stripLegacy("denominationFamilies"),
),
],
[
@@ -445,8 +460,8 @@ const COPY_PLAN: CopyStep[][] = [
step(
"peerPushDebit",
(tx) => tx.listAllPeerPushDebits(),
- (tx, r) => tx.upsertPeerPushDebit(r),
- normalizeContractPriv,
+ (tx, r) => tx.upsertPeerPushDebit(normalizePeerPushDebit(r)),
+ normalizePeerPushDebit,
),
step(
"peerPushCredit",
diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts
@@ -704,6 +704,11 @@ export interface WalletWithdrawalGroup {
abortReason?: TalerErrorDetail;
failReason?: TalerErrorDetail;
+
+ // Reserved legacy fields, migrated by the IndexedDB fixup:
+ // * senderWire: string
+ // * kycPending: { paytoHash: string; requirementRow: number }
+ // * kycUrl: string
}
/**
@@ -2434,6 +2439,9 @@ export interface WalletPeerPushDebit {
* Status of the peer push payment initiation.
*/
status: PeerPushDebitStatus;
+
+ // Reserved legacy fields:
+ // * amountPurse: AmountString
}
/**
@@ -2501,6 +2509,10 @@ export interface WalletPeerPullCredit {
failReason?: TalerErrorDetail;
withdrawalGroupId: string | undefined;
+
+ // Reserved legacy fields, migrated by the IndexedDB fixup:
+ // * kycInfo: { paytoHash: string; requirementRow: number }
+ // * kycUrl: string
}
/**
@@ -2557,6 +2569,10 @@ export interface WalletPeerPushCredit {
kycLastRuleGen?: number;
kycLastAmlReview?: boolean;
kycLastDeny?: DbPreciseTimestamp;
+
+ // Reserved legacy fields, migrated by the IndexedDB fixup:
+ // * kycInfo: { paytoHash: string; requirementRow: number }
+ // * kycUrl: string
}
/**
@@ -2945,6 +2961,10 @@ export interface WalletDenomination {
* on the denomination.
*/
exchangeMasterPub: string;
+
+ // Reserved legacy fields:
+ // * listIssueDate: DbProtocolTimestamp
+ // * pendingWithdrawalOutputCoins: number
}
export interface DenomFees {
diff --git a/packages/taler-wallet-core/src/requests.test.ts b/packages/taler-wallet-core/src/requests.test.ts
@@ -79,6 +79,74 @@ const backendCases = [
["sqlite", makeSqliteRunner],
] as const;
+test("explicit migration failure reports the retained active database", async () => {
+ const db = await makeUnopenedIdbRunner();
+ db.migrateToNative = async () => {
+ throw Error("simulated migration failure");
+ };
+ const http = {
+ async fetch(): Promise<never> {
+ throw Error("unexpected HTTP request");
+ },
+ } as HttpRequestLibrary;
+ const wallet = await Wallet.create(
+ db,
+ () => http,
+ new SetTimeoutTimerAPI(),
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+
+ try {
+ await wallet.client.call(WalletApiOperation.SetWalletRunConfig, {
+ config: {
+ lazyTaskLoop: true,
+ testing: { skipDefaults: true },
+ features: { migrateNativeDb: false },
+ },
+ });
+
+ await assert.rejects(
+ wallet.client.call(WalletApiOperation.MigrateDatabase, {}),
+ (error: unknown) => {
+ assert.ok(error instanceof TalerError);
+ assert.strictEqual(
+ error.errorDetail.code,
+ TalerErrorCode.WALLET_DB_UNAVAILABLE,
+ );
+ assert.strictEqual(
+ error.errorDetail.hint,
+ "Database migration failed; the existing database remains active.",
+ );
+ assert.strictEqual(error.errorDetail.operation, "migrateDatabase");
+ assert.strictEqual(
+ error.errorDetail.activeDatabaseBackend,
+ "indexeddb",
+ );
+ assert.strictEqual(error.errorDetail.sourceDatabaseRetained, true);
+ assert.match(
+ error.errorDetail.innerError?.hint ?? "",
+ /simulated migration failure/,
+ );
+ return true;
+ },
+ );
+
+ const response = await wallet.client.call(
+ WalletApiOperation.SetWalletRunConfig,
+ {
+ config: {
+ lazyTaskLoop: true,
+ testing: { skipDefaults: true },
+ features: { migrateNativeDb: false },
+ },
+ },
+ );
+ assert.strictEqual(response.databaseBackend, "indexeddb");
+ } finally {
+ await wallet.client.call(WalletApiOperation.Shutdown, {}).catch(() => {});
+ }
+});
+
for (const [expectedBackend, makeRunner] of backendCases) {
test(`init reports and shuts down the ${expectedBackend} database backend`, async () => {
const db = await makeRunner();
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -1720,9 +1720,16 @@ export class InternalWalletState {
) {
throw e;
}
- throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
- innerError: getErrorDetailFromException(e),
- });
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DB_UNAVAILABLE,
+ {
+ innerError: getErrorDetailFromException(e),
+ operation: "migrateDatabase",
+ activeDatabaseBackend: "indexeddb",
+ sourceDatabaseRetained: true,
+ },
+ "Database migration failed; the existing database remains active.",
+ );
}
return false;
}