trezor APIで得たデータをdexie.js経由でIndexedDBに保存し、それをsql.js経由でSQLite3形式に変換してダウンロード&アップロードできるようにした。

ブツ

eyecatch.png

今回の目的

データを保存したり集計したりする方法や形式の模索。

アップロードできるが無意味

ダウンロードしたZIPを展開してアドレス.dbファイルをドラッグ&ドロップすれば、IndexedDBに上書きできる。が、レコード数が少ないなどの理由で古いデータだと判断したらアップロードしないようにしている。そしてDEMOページはアクセスしたら自動的に最新データを取得してしまう。よってアップロードできるのは最新データ取得後にダウンロードしたファイルを、そのまますぐにドラッグ&ドロップしたときだけ。当然、同じデータなので上書きされているかどうかは見た目上わからない。なので実質、アップロードしても何も嬉しいことはない。

今回はSQLite3ファイルからIndexedDBにアップロードするときの実装方法を確認したかったので、無意味だと知りつつ機能をつけた。

保存方法

保存方法 問題点
IndexedDBのみ ローカルにそれをダウンロードする方法がない
SQLite3のみ ブラウザでそれを保存する方法がない

ブラウザでもローカルでも取引データを取得・編集・保存できるようにしたかったのでIndexedDBとSQLite3の両方を使ってみた。

取得方法

trezor APIで取引データを取得し、それをdexie.js経由でIndexedDBに保存する。

DB, テーブル作成

this.dexie = new Dexie(`${address}`); // 自分のアドレスひとつごとにDBを作成する。テーブル結合不要になる。
this.dexie.version(1).stores({
    last: `++id`,
    sendPartners: `address`,
    receivePartners: `address`,
    transactions:  `txid`,
})

じつは今回transactionsテーブルの値のうちisPayプロパティをisSendに改名した。以下のようなコードで。

const txs = await this.dexie.transactions.toArray().catch(e=>console.error(e))
for (const t of txs) {
    if (t.hasOwnProperty('isPay')) {
        await this.dexie.transactions.put({
            txid: t.txid,
            isSend: t.isPay, // このプロパティ名を変更する
            addresses: t.addresses,
            value: t.value,
            fee: t.fee,
            confirmations: t.confirmations,
            blockTime: t.blockTime,
            blockHeight: t.blockHeight,
        })
    }
}

このように列名を変えたり追加したりしてテーブルのスキーマを変更する対処をマイグレーションという。その手順は、あたらしいテーブルを作ったあと、古いテーブルからそのデータを挿入して、古いテーブルを削除すれば完了。

マイグレーションをdexie.jsで行うときはversion()の値をインクリメントし、さらにupgradeメソッドで古いテーブルを修正するコードを書くのが正しい作法らしい。以下のように。

this.dexie.version(2).stores({
    last: `++id`,
    sendPartners: `address`,
    receivePartners: `address`,
    transactions:  `txid`,
}).upgrade(async(tx)=>{
    const txs = await this.dexie.transactions.toArray().catch(e=>console.error(e))
    for (const t of txs) {
        if (t.hasOwnProperty('isPay')) {
            await this.dexie.transactions.put({
                txid: t.txid,
                isSend: t.isPay, // このプロパティ名を変更する
                addresses: t.addresses,
                value: t.value,
                fee: t.fee,
                confirmations: t.confirmations,
                blockTime: t.blockTime,
                blockHeight: t.blockHeight,
            })
        }
    }
})

でも今回はそうしなかった。その理由は一度バージョン値をインクリメントするとデクリメントできないから。正しくマイグレーション用コードを書けなかったのにエラーがなければ成功と判断されてしまいバージョンがインクリメントされてしまう。ムダにバージョンがあがる。

デバックの時点でそんな仕様にハマり、デベロッパツールで何度もDB削除とデータ取得を繰り返す羽目になって大変だった。

よって今後dexie.jsでマイグレーションするときはバージョン値を1に固定し、力技で修正するようにすることに決めた。

レコード挿入

trezor APIで取引したデータをdexie.js経由でIndexedDBに保存する。

dbs.get(address).dexie.transactions.put({
    txid: tx.txid,
    isSend: isSend,
    addresses: Array.from(addrs).join(','),
    value: value,
    fee: fee,
    confirmations: tx.confirmations,
    blockTime: tx.blockTime,
    blockHeight: tx.blockHeight,
})

ダウンロード

IndexedDB→SQLite3

まずはSQLite3でテーブルを作成する。

db.exec(this.#createSqlTransactions())
#createSqlTransactions() { return `
create table if not exists transactions (
txid text primary key not null,
is_send integer,
addresses text,
value integer,
fee integer,
confirmations integer,
block_time integer,
block_height integer
) without rowid;`
}

次にIndexedDBファイルからデータを取得し、それをSQLite3のテーブルへ挿入する。

const txs = await this.dbs.get(this.my).dexie.transactions.toArray()
for (const tx of txs) {
    db.exec(`insert into transactions values (
'${tx.txid}',
${tx.isSend},
'${tx.addresses}',
${tx.value},
${tx.fee},
${tx.confirmations},
${tx.blockTime},
${tx.blockHeight}
);`)
}

ダウンロードしたらZIPを展開し、アドレス.dbファイルをsqlite3コマンドで開いてSQL文を発行して中身を確かめることができる。以下参考。

アップロード

SQLite3→IndexedDB

sql.jsのライブラリファイルを読み込む。

this.SQL = await initSqlJs({locateFile: file => `${this.PATH_WASM}/${file}`})

SQLite3ファイルをドラッグ&ドロップで受け取ったら、そのバイナリデータをSQL.Database()の引数に渡す。その戻り値dbのメソッドexec()でSQL文を発行する。

return new this.SQL.Database(dbAsUint8Array)

SQLite3ファイルからIndexedDBへレコードを挿入・上書きするコードの抜粋が以下。

rows = db.exec(`select * from transactions;`)
for (const row of rows[0].values) {
    const v = row
    await this.dbs.get(this.my).dexie.transactions.put({
        txid: v[0],
        isSend: v[1],
        addresses: v[2],
        value: v[3],
        fee: v[4],
        confirmations: v[5],
        blockTime: v[6],
        blockHeight: v[7],
    })
}

SQLite3のデータはselect * from transactions;というSQL文で取得している。このとき戻り値rowsは配列である。さらに以下のようになっている。

rows[0]
  columns[N]:[列1名,列2名,列3名,...]
  values[N]
    [0]:[列1値,列2値,列3値,...]
    [1]:[列1値,列2値,列3値,...]
    ...

columnsはテーブルの列名が入っている。valuesにはレコードの値が入っている。オブジェクトにするなら{column1:value1, column2:value2, ...}のようになる。私としてはそうしてくれるとコードの可読性が上がって嬉しかったのだが。なぜ配列なのか。その理由を予想するなら、キー名が全レコードで同じであることがわかりきっており、できるだけムダなデータを削減したかったからかもしれない。

この配列の順序はSQLでcreate table文を発行するときに定義した列名の順である。

考察

今はIndexedDBにJavaScriptオブジェクトとして値を保存している。そのため集計はJavaScriptのmap,filter,reduceといったAPIを使って行っている。

だが、IndexedDBにSQLite3ファイルデータdb.export()の結果をそのまま丸ごと保存する方法も考えられる。そうすればDBまわりの取得や集計をすべてSQL文で統一できる。以下のように。

this.dexie = new Dexie(`mona-transaction-sqlite3`);
this.dexie.version(1).stores({
    dbs: `address`,
})
this.dexie.dbs.put({
    address: address,
    sqlite3: db.export(),
})
const db = SQL.Database(this.dexie.dbs.get(address))
db.exec(`select * from transactions;`)

もし取得や集計をSQL文で統一できれば、ローカルとブラウザ上で同じコードだけを使って同じことができる。そのほうが覚えることも少なくて済みそうで楽なような気もする。

けれどブラウザではJavaScriptのmap,filter,reduceといったAPIを使ったほうが楽な場合もある。どっちもどっち。

もしSQLで統一してしまえばブラウザのほうはsql.jsがないと解析できなくなってしまう。依存性が高まることには不安がある。たとえばバージョン差異により互換性がなくなるなど。

SQLite3のdb.export()は全データ丸ごとなので扱いづらいのも気になる。一件でも新しいデータが追加されたら全データ丸ごと交換になる。ムダに大きな読書が発生しそうな気がする。

はたしてどう実装するのが最善なのか。悩みどころだったが、総合的に考えて今回のコードのとおりIndexedDBにJavaScriptオブジェクトとして値を保存するのがよさそうな気がする。