IndexedDB を単一のデータを格納するストレージとして扱う

この記事は [株式会社 ACCESS Advent Calendar 2019](https://qiita.com/advent-calendar/2019/access) 9 日目の記事です。

こんばんは！ @diescake です。

今回は [IndexedDB](https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API) を単一のデータを格納するストレージとして扱うラッパー実装を紹介します。

これは、へーしゃのお仕事で必要になりそうだったので、
プライベートの時間で実装検証していたものを別リポジトリに整理しました。

実は IndexedDB 自体初めて使いましたが、割とうまくいったので紹介します。

## リポジトリ

- oneshot-idb
  - https://github.com/diescake/oneshot-idb

(需要があるとは思えないけど、時間見つけて npm に公開したい…)

## なぜ必要になったか？

画像データを一時的に永続化したいユースケースがありました。

具体的には、Gmail の作成中メールや Qiita の作成中の記事を保存するイメージが近いと思います。
このとき、テキスト程度であれば localStorage で十分ですが、アップロードしようとしている画像を保持するには容量的に怪しく IndexedDB の利用を検討しました。

また、localStorage は同期的にアクセスするため、
render がブロッキングされることによるユーザビリティ低下の懸念もありましたが、今回のユースケースに関して言えば、試行回数が十分小さいため軽微でした。

ちなみに、localStorage に保存可能な容量はブラウザ毎(OS も？)に異なります。
実際に検証した範囲では、5MB あるいは 10MB 上限のどちらかでした。

下記サイトをブラウザで開いて確認するのが一番手軽でオススメです。

- HTMl5 LocalStorageの容量や動作を調べるページ
  - https://www.yoheim.net/labo/html5/localstorage/checkLocalStorage.html

## 実装の紹介

1 ファイルなので、直接リポジトリをご参照ください！
https://github.com/diescake/oneshot-idb/blob/master/src/index.ts

いくつかポイントはありますが、
仕様としては `writeData` と `readData` 以外の interface は全て隠蔽しています。

```ts
writeData(data: unknown): Promise<boolean>
readData(): Promise<unknown>
```

今回のユースケースでは、保存するデータは 1 つ。
しかも、一時的に保存できれば十分であったため one-shot な実装としました。

`writeData` を呼び出すと内部では必ず drop table ➔ create table の手順を踏むことで、
扱う側は内部状態を気にせず、シンプルに扱えるようにしてあります。

また、`readData` を呼び出した際は、データ参照に成功した場合削除するようにしています。
(名称としては pop の方が良かったなこれは…)

```ts
// NOTE: Unfortunately, the current typeScript compiler doesn't support inference of IDBOpenRequest and its result.
const getResultFromEvent = (event: Event): unknown => (event.target as IDBOpenDBRequest).result
```

このコメントの部分ですが、ここは `event.target` の推論が効かないため、アサーションで IDBOpenDBRequest にダウンキャストしています。
IndexedDB を扱うのであれば、ほぼ触る実装だと思うんですが、余程需要がないのか TypeScript リポジトリの issues でも然程コメントはつかずオープンのままになっています…。
まぁ使わんよね。IndexedDB…

https://github.com/microsoft/TypeScript/issues/28293

## 利用例

非常にシンプルです。
テストコードを見て貰うのが良いと思います。
https://github.com/diescake/oneshot-idb/blob/master/test/index.test.ts

下記に一部抜粋。

```ts
import { imageUri } from './testData'
import { readData, writeData } from '@/index.ts'

describe('Oneshot IndexedDB', () => {
  describe('Write and read data', () => {

    it('nested Object', async () => {
      await writeData({ abc: 1, def: 'ghi', jkl: { mno: [1, 2, 'foo'] } })
      const ret = await readData()
      expect(ret).toEqual({ abc: 1, def: 'ghi', jkl: { mno: [1, 2, 'foo'] } })

      const ret2 = await readData()
      expect(ret2).toBeNull()
    })

    it('1MB image data uri', async () => {
      await writeData(imageUri)
      const ret = await readData()
      expect(ret).toEqual(imageUri)

      const ret2 = await readData()
      expect(ret2).toBeNull()
    })
  })
})
```

plain object の保存が可能で、ここは IndexedDB の仕様に従います。

テストコードとしては `writeData` した種々データが `readData` で取得したデータと一致するか確認した上で、再度 `readData` してデータが消えていることを確認しています。

## 所感

普通にサーバに保持したいぞ…！

Gmail も含めて、Qiita や GitHub issues などを見ても、
画像ファイルを選択、あるいは、ドラッグ&ドロップした時点でファイルアップロードしてしまうサービスが多い印象です。

例えば、この Qiita でも画像を選択した時点で、S3 へのアップロードが行われ、その格納先の path がテキストで落ちてきて埋め込まれるという振る舞いになっています。
(記事から参照されていない画像とか、どっかで棚卸しして削除しているのかは気になる)

とはいえ、何らかの制約でブラウザ上に大容量のファイルを永続的に保持するというケースはあるかもしれません。
そんな場合は IndexedDB をストレージとして利用するためにラッパーを書くと捗ると思いますので、ご参考いただければと思います。

さて、次回は令和入社の @yocidama です！
レッツおきもち！！