mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
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:
co-authored by
Claude Opus 4.8
parent
f8ed2d4a71
commit
fa35fa1c05
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user