Drop every table on factory reset, not a hand-listed seven

cleanAll IS the factory reset (DeveloperScreen). It named seven tables while the
schema has eleven, so a reset silently left wallet_counters,
onchain_mint_quotes, mints and mint_keysets behind.

That became a privacy bug in the previous commit: moving mints into SQLite meant
a factory reset no longer removed the user's mints or their onchain deposit
addresses. Introduced by that move and missed, because the drop list is
hand-maintained and nothing pointed at it.

It also cost a device. A test wallet upgrading from a genuine v26 install still
failed with "duplicate column name: mintId", and the cause was this list: an
earlier broken build's createSchemaQueries had created onchain_mint_quotes WITH
mintId — that write COMMITTED even though the migration batch rolled back — and
the factory reset then failed to remove it. Reinstalling the v26 native bundle
recreated dbversion and seeded 26, so migration 31's `CREATE TABLE IF NOT EXISTS`
silently skipped the leftover instead of building it fresh, and migration 33's
ALTER collided with the mintId already there, taking every migration down with
it. Reproduced, same error string.

The list now comes from sqlite_master rather than from source. It cannot drift as
tables are added, and it clears artifacts from any past bug — including exactly
the leftover above, which is what a hand-written list can never do.

No released build can reach that contaminated state (these commits are unpushed),
so the migrations are deliberately NOT hardened with defensive DROPs: the
invariant would hold — a create-migration only runs below its own version, so its
table cannot legitimately exist — but the blast radius of getting that wrong is
data loss, and the scenario is self-inflicted. Fully uninstalling the app is the
correct recovery.

Tests: 506 pass. The three new ones fail against the old list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-07-17 09:48:57 +02:00
co-authored by Claude Opus 4.8
parent f8ed2d4a71
commit fa35fa1c05
2 changed files with 80 additions and 16 deletions
+51
View File
@@ -233,3 +233,54 @@ describe('upgrading an existing database (the device path)', () => {
})
})
})
describe('cleanAll — the factory reset', () => {
test('drops EVERY table, not a hand-listed subset', () => {
jest.resetModules()
const {Database} = require('../src/services/db')
Database.getInstance() // builds the current schema
Database.cleanAll()
const db = Database.getInstance()
const remaining = (
db.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
.rows?._array ?? []
).map((r: any) => r.name)
// The old list named seven tables while the schema had eleven, so a factory
// reset left the user's mints and their onchain deposit addresses behind.
expect(remaining).toEqual([])
})
test('also clears tables no list would know about', () => {
// The failure mode that broke a test device: a leftover table from a bad build
// outlived the reset, and the create-migration that should have rebuilt it
// skipped it (IF NOT EXISTS) — so the ALTER after it hit "duplicate column".
jest.resetModules()
require('@op-engineering/op-sqlite').__seedNextDatabase((db: any) => {
db.exec(`CREATE TABLE some_leftover_table (id TEXT)`)
})
const {Database} = require('../src/services/db')
Database.getInstance()
Database.cleanAll()
const db = Database.getInstance()
const remaining = (
db.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
.rows?._array ?? []
).map((r: any) => r.name)
expect(remaining).toEqual([])
})
test('is safe on an already-empty database', () => {
jest.resetModules()
const {Database} = require('../src/services/db')
Database.getInstance()
Database.cleanAll()
expect(() => Database.cleanAll()).not.toThrow()
})
})
+29 -16
View File
@@ -73,26 +73,39 @@ const _createOrUpdateSchema = function (db: DbConnection) {
}
}
/**
* Drop EVERY table — the factory reset (see DeveloperScreen).
*
* The list is read from the database itself rather than written out here. A
* hand-maintained list drifts the moment a table is added, and this one had:
* it named seven tables while the schema had eleven, so a factory reset silently
* left wallet_counters, onchain_mint_quotes, mints and mint_keysets behind —
* i.e. the user's mints and their onchain deposit addresses SURVIVED a wipe.
*
* It also caused real damage during development: a leftover onchain_mint_quotes
* from a bad build outlived the reset, and because a create-migration uses
* `IF NOT EXISTS`, the migration that should have built it fresh silently skipped
* it — and the ALTER that follows died on "duplicate column name", taking every
* migration down with it.
*
* Asking sqlite_master cannot drift, and it clears artifacts from any past bug too.
*/
export const cleanAll = function () {
const dropQueries = [
['DROP TABLE transactions'],
['DROP TABLE proofs'],
['DROP TABLE dbversion'],
// IF EXISTS: these tables were added by later migrations, so a very old DB
// may lack them; without the guard a missing table aborts the atomic batch.
['DROP TABLE IF EXISTS reservations'],
['DROP TABLE IF EXISTS mint_counters'],
['DROP TABLE IF EXISTS melt_recovery'],
['DROP TABLE IF EXISTS inflight_requests'],
] as SQLBatchTuple[]
try {
const db = getInstance()
const {rowsAffected} = db.executeBatch(dropQueries)
if (rowsAffected && rowsAffected > 0) {
log.info('[cleanAll]', 'Database tables were deleted')
}
const {rows} = db.execute(
// sqlite_% is SQLite's own bookkeeping (sqlite_sequence and friends) and is
// not ours to drop.
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
)
const tables: string[] = (rows?._array ?? []).map((r: any) => r.name)
if (tables.length === 0) return
db.executeBatch(tables.map(name => [`DROP TABLE IF EXISTS "${name}"`]) as SQLBatchTuple[])
log.info('[cleanAll]', 'Database tables were deleted', {tables})
} catch (e: any) {
throw dbError('Could not delete database schema', e)
}