azu/kvsというブラウザとNode.jsで動くファイルサイズが小さいキーバリューストレージを作りました。

モチベーション

ファイルサイズが小さくIndexedDBを使っていて、Node.jsでも透過的に同じAPIで利用できるライブラリが必要となったため作りました。

textlint-editorというアプリを書いていて、キャッシュストレージとしてlocalstorage-ponyfillを使っていました。 しかし、localstorage-ponyfillはブラウザとNode.jsで透過的に動くストレージライブラリですが、LocalStorageベースとなっています。 textlint-editorでは、スクリプトをWeb Workerで動かすため同期的なAPIであるLocalStorageは利用できません。

そのため、IndexedDBベースでシンプルなキーバリューストレージを扱えるものが必要でした。

有名所ではlocalForageがこの条件を満たしますが、ファイルサイズが9kb弱(min+gzip)あります。

10kb(gzip)はUIを作るような主要ライブラリのサイズ感なので、1-2kb(gzip)程度のキーバリューストレージが欲しかったです。

色々探してもなかったのでKVSというライブラリを作りました。

KVS Packages

azu/kvsはいくつかのパッケージを集めたmonorepoになっています。 基本的にはどれのパッケージも同じインターフェースですが、読み書きする先がIndexedDB、LocalStorage、ファイル、メモリなど実装が異なっています。

基本的には@kvs/envというパッケージを使えば、 自動的にブラウザとNode.jsに合わせたパッケージを使い分けしてくれます。

また自分で任意のバックエンドでStorageも作成できます。 インターフェイスとして@kvs/storageとテストケースを提供する@kvs/common-test-caseがあるので利用してください。

Features

azu/kvsは基本的に次の機能を持っています。

  • Key-ValueなAPI
    • get, set, has, delete, clear
    • 列挙はAsync Iteratorを使うので for await of で列挙します
  • バージョン管理とマイグレーションAPI
    • versionの指定とupgradeメソッドを実装することでマイグレーションができます
    • 初めてデータベースを作るときも 0 から 1 へと upgradeされるので初期データも作成できます
  • 小さなファイルサイズ
  • TypeScript
    • TypeScriptでデータベースのスキーマを設定できます
    • 単純なKey-Valueな型より強めの制約を作れます

Examples

基本的なTypeScriptでのサンプルコードは次のような感じです。

StorageSchemaというように、storageのインスタンスを作るときスキーマの型を渡します。 こうすることで、getsetなどもすべて型が付いた状態になり、スキーマに定義されていないKey-Valueはコンパイルエラーになります。

import assert from "assert";
import { kvsEnvStorage } from "@kvs/env";
(async () => {
    type StorageSchema = {
        a1: string;
        b2: number;
        c3: boolean;
    };
    // open database and initialize it
    const storage = await kvsEnvStorage<StorageSchema>({
        name: "database-name",
        version: 1
    });
    // set
    await storage.set("a1", "string"); // type check
    await storage.set("b2", 42);
    await storage.set("c3", false);
    // has
    console.log(await storage.has("a1")); // => true
    // get
    const a1 = await storage.get("a1"); // a1 will be string type
    const b2 = await storage.get("b2");
    const c3 = await storage.get("c3");
    assert.strictEqual(a1, "string");
    assert.strictEqual(b2, 42);
    assert.strictEqual(c3, false);
    // iterate
    for await (const [key, value] of storage) {
        console.log([key, value]);
    }
    // delete
    await storage.delete("a1");
    // clear all data
    await storage.clear();
})();

このスキーマ型はidbを意識した作りです。 (idbはキーバリューストレージではなく、IndexedDBの薄いラッパーが目的のライブラリ)

localForageのようなgetItemsetItemでGenericsを渡す方式では、 ストレージに入ってる値の一覧がインターフェイスからわからない問題や書き忘れるとanyとなるため、最初にスキーマで定義するようにしています。

// localforage での例
import localforage from "localforage"

(async () => {
    await localforage.setItem<string>('key', 'value')
    const value = localforage.getItem<string>('key')
    console.log(typeof value === "string"); // => true
})();

また、@kvs/envでのデータのマイグレーションは次のように書けます。 一度データを作るとversion1となるので、versionを上げればupgradeメソッドが呼ばれます。 upgradeメソッドにマイグレーションの処理を書く仕組みです。

import { kvsEnvStorage } from "@kvs/env";
(async () => {
    // Defaut version: 1 
    // when update version 1 → 2, call upgrace function
    const storage = await kvsEnvStorage({
        name: "database-name",
        version: 2,
        async upgrade({ kvs, oldVersion }) {
            if (oldVersion < 2) {
                await kvs.set("v1", "v1-migrated-value"); // modify storage as migration
            }
            return;
        }
    });
    assert.strictEqual(await storage.get("v1"), "v1-migrated-value");
})();

おわりに

ファイルサイズが1KB程度でIndexedDBに対応していてTypeScriptで書けるキーバリューストレージとして@kvs/envを作りました。

マイグレーションの仕組みが思っていたよりも便利で、またScehmaで型定義できると結構TypeScriptで使いやすいです。 IteratorをサポートしていないIE11でも動くようなもっとシンプルなlocalStorageラッパーも同じ仕組みがあると便利なのかもしれません。

次のIssueがあるので、興味がある人はやってみてください。