kevy
このページ

WebAssembly上のkevy

kevyはブラウザの中で、コンパイルが通るだけの珍品ではなく本物のストアとして動きます。npmパッケージ@goliapkg/kevyは、wasm32-unknown-unknown向けにコンパイルしたエンジン(KV+TTL+カウンタ+スキャン+pub/sub)を手書きのESモジュールローダーの後ろに載せて出荷し、OPFS(IndexedDBフォールバック付き)による永続化と、タブをまたぐpub/subを備えます。同じクレート群はwasm32-wasip1向けにもビルドできるので、Rust APIはwasmtimewasmerやエッジランタイムでも動きます。

ライブで試せます。kevy.golia.jpのデモは、まさにこのモジュールの上のブラウザREPLです——コマンド、リロードを生き延びるOPFS永続化、タブ間pub/subを、バックエンドなしで。

クイックスタート(ブラウザ)

npm install @goliapkg/kevy
import { open } from "@goliapkg/kevy";

const db = await open({ persist: { name: "app" } });

db.set("greeting", "hello");
db.set("session", "abc123", { ttlMs: 60_000 });
db.getText("greeting");            // "hello"
db.incrby("visits");               // 1, 2, 3, ...
db.keys("user:*");

// Pub/sub — including other tabs of the same origin:
const off = db.subscribe("events", (payload, channel) => {
  console.log(channel, new TextDecoder().decode(payload));
});
db.publish("events", "hi from this or any other tab");

await db.flush();                  // durability barrier

書き込みはkevyの追記専用ログとしてストレージへストリームされ、同じpersist.nameでの次回open()時にリプレイされます。4.0以降、ログはチェックサム付きv2レコードフォーマット(KEVYAOF2——persistence.md参照)を話します:保存バイトのビット反転はリプレイ時に拒否され、黙って適用されることはありません。4.0以前のタブが保存したログはそのままリプレイされ(v1、永久に読める)、最初のcompactionでv2へ昇格します。ブラウザのタブから汲み出したログはネイティブのkevyで変わらずリプレイでき、逆も同様です。パッケージは6ファイル(パック後496 KB、回線上はgzipで481 KB)です。wasmモジュール、ローダー、OPFSワーカー、手書きのTypeScript型定義、そしていつものREADMEとマニフェスト。境界のどちら側も依存ゼロです。

ローダーAPI

open(options)はモジュールをインスタンス化し、(永続化が有効なら)保存済みログをリプレイし、tickタイマーとタブ間ブリッジを開始します。オプションは次の通りです。

オプションデフォルト意味
wasmローダーの隣のkevy.wasmモジュールのソース。URL、ArrayBufferUint8ArrayResponse、またはコンパイル済みWebAssembly.Module
persistfalse(インメモリ){ name, backend }nameごとに1つのログ。backend"auto"(OPFS、IndexedDBフォールバック)、"opfs""idb"
broadcasttrueBroadcastChannelによるタブ間pub/subブリッジ
namepersist.nameまたは"kevy"インスタンス名。ストレージファイルとブロードキャストチャネルの両方をスコープする
tickMs100TTL掃除+イベントポーリングの周期。0でタイマー無効——自分でtick()を呼ぶ

返されるKevyハンドル(完全なシグネチャはkevy.d.ts):

メソッド意味論
set(key, value, { ttlMs? })SET。オプションで期限付き
get(key) / getText(key)GETをUint8Arrayで/UTF-8テキストで。不在または期限切れならundefined
del(key) / exists(key)DEL / EXISTS
expire(key, ttlMs) / persist(key) / pttl(key)PEXPIRE / PERSIST / PTTL(-1はTTLなし、-2はキーなし)
incrby(key, delta?)INCRBY。新しい値を返す
dbsize() / flushall()DBSIZE / FLUSHALL
keys(pattern?, limit?)Redisグロブ付きKEYS。上限も指定可
tick()手動のTTL掃除+イベントポーリング1回。期限切れキー数を返す
subscribe(channel, cb) / psubscribe(pattern, cb)SUBSCRIBE / PSUBSCRIBE。どちらも購読解除関数を返す
publish(channel, payload)ローカル購読者へ、(ブリッジ有効なら)同一オリジンの他タブへもPUBLISH。ローカル受信者数を返す
flush()永続性バリア。保留中の書き込みフレームをストレージへフラッシュ
compact()ストレージを、生きているキー空間のコンパクション済みイメージとして書き直す
close()フラッシュし、ブリッジ/タイマー/ストレージを畳み、インスタンスを解放

キーと値はどこでもstring | Uint8Array | ArrayBufferです。文字列は境界でUTF-8エンコードされ、バイトビューはそのまま通ります——値は本物のバイナリであって、文字列ではありません。

バインディングの仕組み(wasm-bindgen不使用)

kevyの依存ゼロの掟はツールチェーンにも及びます。境界のどちら側にもバインディングジェネレータはありません。kevy-wasmクレートはフラットな手書きC ABI——29個のextern "C"シンボル——をエクスポートし、pkg/kevy.jsはTypedArray境界、UTF-8コーデック、永続化ポンプ、タブ間ブリッジを自前で持つ手書きローダーです。規約は次の通りです。

  • インスタンスkevy_openが返すu32ハンドルで、他のすべての呼び出しはハンドルを最初に取ります。0は決して有効なハンドルになりません。
  • 入りのバイト列は、kevy_allocで確保しkevy_freeで返す線形メモリを指す(ptr, len)対で渡ります。
  • 出のバイト列はインスタンスごとの結果バッファに置かれ、kevy_out_ptrkevy_out_lenで読みます。同じハンドルへの次の呼び出しまでが有効期間です——呼び出し側は即座にコピーアウトします。
  • ステータスコード>= 0が成功、-1が操作エラー(結果バッファにUTF-8メッセージ)、-2が不正ハンドル。
  • 数値f64で渡ります(クロック、TTL、カウント——すべて2^53の正確な整数範囲の内側です)。
  • kevy_abi_version()がABI契約のバージョンを報告するので、ローダーは不一致のモジュールを拒否できます。

エクスポート面はコア(open/close/alloc/free/clock/tick/結果バッファ)、KV(kevy_setkevy_getkevy_delkevy_existskevy_expirekevy_persistkevy_pttlkevy_incrbykevy_dbsizekevy_flushallkevy_keysなど)、pub/sub(kevy_subscribekevy_psubscribekevy_unsubscribekevy_publishkevy_poll_events)、AOFポンプ(kevy_aof_frames_outkevy_aof_frame_inkevy_aof_dump)に分かれます。同梱のローダーがあなたのホストに合わないなら、このABIこそがサポートされる統合ポイントです——クレートのドキュメントがすべてのシンボルとパック済みバイトフォーマットを規定しています。

永続化——ネイティブkevyとバイト互換の本物のログ

ブラウザにファイルシステムはないので、永続性はホスト仲介です。永続化が有効なら、すべての書き込みは、kevyのAOFがディスクに保存するのと同じRESPマルチバルクフレーム(kevy-persistフォーマット——persistence.md)としてもエンコードされます。ローダーは保留中のフレームをマイクロタスクごとに1回ストレージへポンプするので、同期的な書き込みバーストのコストはストレージ追記1回です。await db.flush()が永続性バリアで、flush()の解決はバックエンドがディスクへフラッシュしたことを意味します。

ブラウザのタブが書いたログは、そのままネイティブのkevyでリプレイできます——同じマジックヘッダ、同じフレームです。.aofをOPFSからコピーしてネイティブの組み込みストア(またはサーバー)に向ければ、キー空間が戻ってきます。逆も成り立ちます。入りのポンプはネイティブが書いたログを受け付けます。壊れた末尾はネイティブのリプレイ契約に従います——無傷のプレフィックスが適用され、末尾は捨てられ、次のコンパクションがライブ状態からストレージを書き直します。

バックエンドは2つあり、backend: "auto"では自動選択されます。

  • OPFS(第一候補):小さな専用ワーカーが所有するFileSystemSyncAccessHandle——同期ハンドルはワーカー専用だからです。追記のたびに、確認応答の前にフラッシュします。
  • IndexedDB(フォールバック):同じポンプをオブジェクトストアに対して回します。OPFS同期ハンドルのないコンテキスト(特に古いSafari)向けです。

コンパクションは自動です。追記されたログがmax(512 KB, 前回イメージの4倍)を超えると、ローダーはストレージを生きているキー空間のコンパクション済みイメージとして書き直します(AOFリライトのブラウザ側等価物)。compact()はスナップショットやエクスポートの前に1回を強制します。

localStorageは意図的にバックエンドにしていません。約5 MBのクォータ、書き込みのたびにメインスレッドをブロックする同期API、UTF-16文字列限定のストレージ——書き込みログとしては失格です。

タブ間pub/sub

publish()は同じタブ内の購読者へはエンジン経由で配送し、(ブリッジ有効なら)インスタンスごとのBroadcastChannelでフレームを同一オリジンの他のすべてのタブへブロードキャストし、各タブのローカル購読者が受け取ります。配送はat-most-once・バックログなしです——メッセージを受け取るのはその時点で開いているタブだけで、後から開いたタブへのリプレイはありません。これはkevyサーバーのpub/sub(pubsub.md)と同じ契約なので、片方の面に向けて書いたコードはもう片方でも同じように振る舞います。

クロック、tick、TTL

wasm32-unknown-unknownにはスレッドもOSクロックもないので、エンジンは手動TTLリーパーとホスト供給クロックで動きます。ローダーは各エントリポイントの前にDate.now()をABI経由で供給し、タイマー(デフォルト100 ms)でtick()を回します。帰結は次の通りです。

  • TTLの精度はtick周期に従います。500 msのTTLを持つキーは、期限後最初のtickで消えます。重要ならtickMsを詰めるか、tickMs: 0にして周期を完全に自分で握ってください。
  • 呼び出しと呼び出しの間にバックグラウンド処理は一切起きません——隠れたコストはありませんが、(tickMs: 0で)tick()を忘れると期限切れキーが生き残り、イベントキューも掃けません。

パフォーマンス

方法論・環境・生の数字の全体はbench/WASM-BENCH.md(ヘッドレスChromeハーネス、3回実行の中央値、16バイト値、1kおよび100kキー)にあります。Webアプリが実際に持っているストレージとの比較:

軸(n=100k)kevy-wasmvs IndexedDBvs localStorage
ポイント読み取り(ops/s)1.67 M77×0.48×
ポイント書き込み(ops/s)1.79 M189×4.9×
バッチロード(ms)60.836×速い4.5×速い
スキャン、約10%一致(ms)5.646×速い5.7×速い
永続書き込み(ops/s)785 k(OPFS)12.6–17.4×約2×
再起動から利用可能まで(ms)1292.7×速い遅い(下記参照)

ヘッドラインと、正直な但し書き:

  • IndexedDBに対して全軸で桁違い——ポイント読み取り77–86×、ポイント書き込みはデータセットサイズをまたいで166–189×。
  • 永続書き込みは素のIndexedDBの12.6–17.4×。ポンプが書き込みフレームをマイクロタスク単位でバッチするので、書き込みバーストのコストはストレージ追記1回です。しかも本物の追記ログでありながら、localStorageのfire-and-forgetなsetItemを約2×上回ります。
  • localStorageのポイント読み取りだけはkevyが勝てない列です(0.42–0.48×)。そしてベンチは、wasmでホストされるストアには勝てない理由まで記録しています。ChromeのlocalStorage読み取りはレンダラーメモリ内のハッシュ検索で(実測3.5–5 M ops/sは素のMap級であって、ストレージ級ではありません)、一方どのwasmストアも呼び出しごとにUTF-8エンコード+境界越え+線形メモリからのコピーアウトを払います——その天井が約2–3 M ops/sで、kevyはローダーのステージング最適化(永続スクラッチバッファ、encodeInto、キャッシュ済みメモリビュー——素朴なローダー比2.2倍)の後、その近く(1.7–2.1 M)に座っています。kevyが勝つのは、localStorageがどんな速度でもできないことすべてです。バイナリ値、TTL、カウンタ、スキャン、5 MB超のデータセット、pub/sub、そしてノンブロッキングな永続化。
  • タブ間RTTは64 KBペイロードでstorageイベントハックに勝ち(p50 0.295 ms対0.335 ms)、1 KB未満では約0.105 msのタスクホップ床で並びます——一方storageイベントハックは遅い消費者の下でフレームを落とし、文字列限定で、pingのたびにディスククォータへ書き込みます。

Rustの顔——WASI、エッジランタイム、直接組み込み

エンジンそのもの——kevy-embeddedとその依存クロージャ(kevy-storekevy-persistkevy-hashkevy-byteskevy-mapkevy-resp)——は両方のwasmターゲットでビルドでき、CIがこのリスト全体+kevy-wasmをプッシュごとにゲートします。ネットワークリアクタのクレート(kevy-rtkevy-syskevy-uring)は意図的にクロージャの外に置かれ、wasmビルドはクリーンに保たれます。

ターゲットコマンド備考
wasm32-unknown-unknowncargo build -p kevy-wasm --target wasm32-unknown-unknown --releaseブラウザ成果物(kevy_wasm.wasm)。npmローダーがこれをインスタンス化する。独自ホストならC ABIを直接叩く。
wasm32-unknown-unknown(Rust API)cargo build -p kevy-embedded --target wasm32-unknown-unknownスレッドなし・OSクロックなし。Config::with_ttl_reaper_manual()で開き、set_clock_nsset_wall_clock_msを供給し、ホストのループからStore::tick()を呼ぶ。
wasm32-wasip1cargo build -p kevy-embedded --target wasm32-wasip1InstantSystemTimeが動く——クロック供給は不要。std::fsはpreopenしたディレクトリに対して動くので、Config::with_persist("/data")wasmtime --dir=/dataで本物のAOF永続性が得られる。スレッドは依然ないので手動リーパーのまま。

wasm32-unknown-unknownでのRust直接組み込み:

use kevy_embedded::{Config, Store, set_clock_ns, set_wall_clock_ms};

let store = Store::open(Config::default().with_ttl_reaper_manual())?;

// Host feeds the clock before time-sensitive work…
set_clock_ns(now_ms_from_host().saturating_mul(1_000_000));
set_wall_clock_ms(now_ms_from_host());

store.set(b"hello", b"world")?;
let v = store.get(b"hello")?;   // Some(b"world".to_vec())

// …and drives expiry at its own cadence:
let _stats = store.tick();

エラーはKevyResultKevyErrorで、argvスタイルの書き込みメソッドは借用スライスを取ります。ネイティブとまったく同じです——kevy-embeddedのドキュメントを参照してください。

Cloudflare Workersなどのエッジアイソレートはブラウザのレシピに従います。アイソレートごとに1インスタンス、クロックソースはDate.now()tick()は遅延実行かスケジュールされたハンドラから。アイソレート再起動をまたぐ永続性には、ハンドラからプラットフォームの永続ストアへ書き込みをミラーします(AOFポンプがフレームをくれます)。アイソレートの内側では、kevyがホットなインメモリ層です。

Tauri アプリの中で

デスクトップ/モバイルのアプリで kevy を走らせるなら? Tauri のバックエンドは Rust なので、kevy は webview に wasm として置くのではなく、バックエンドにネイティブに組み込めます(共有のストアがひとつ、永続、ウィンドウをまたぐ)。バックエンドのストアと wasm のどちらを選ぶかは docs/tauri.md を参照してください。

FAQ

ブラウザでフルのコマンド面は動きますか? ほぼ動きます。モジュールはブラウザが持てるすべてのフィーチャー——corepersistindextextvector——で構築されているので、cmd は KV・TTL・カウンタ・スキャン・pub/sub に加えて IDX.*(セカンダリ索引・全文・ベクトル検索)、VIEW.*TABLE.* にも届きます。2026-08 までは小さい切り出しで、そのためプロジェクト自身のトップページが、索引を含まないビルドの上でセカンダリ索引を実演していました。

どの公開版が持つか: npm の 5.1.0 以前は小さい切り出しです——そこでは IDX.*VIEW.*TABLE.*unknown command を返します。広いビルドは main ブランチと kevy.golia.jp にあり、次の公開版で npm に届きます。先に欲しければチェックアウトからビルドしてください(cargo build -p kevy-wasm --target wasm32-unknown-unknown --release)。

外してあるものに足りないのはバイト数ではなく、ブラウザが提供できないものです——replicate はネットワーク相手、listener は TCP ソケット、tier はディスクのディレクトリを要します。ストリーム・トランザクション・geo・スクリプティングは、どのプラットフォームでも組み込みエンジンの動詞面の外です。境界は ESTORE_OPS マニフェストであって、このビルドではありません。

wasmターゲット上のRust APIは、コンパイル時に有効化したkevy-embeddedフィーチャーの全面を公開します。

永続化されたデータはポータブルですか? はい——標準のkevy AOFです。ブラウザ→ネイティブも、ネイティブ→ブラウザも、どちらもリプレイできます。フォーマット契約はpersistence.mdを参照してください。

共有メモリスレッド(+atomics)は? 同梱モジュールはシングルスレッドで、これはブラウザ系のすべてのターゲットに合致します。ホストがスレッドを提供する場所ではエンジンはスレッドセーフですが、手動tick()モデルがサポートされるパスのままです。

BroadcastChannelではなくSharedWorkerは? 単一オーナーのSharedWorkerトポロジは理論上はきれいですが、利用可能な範囲が狭い(特にAndroid Chromeに不在)。BroadcastChannelブリッジが、同じ実測レイテンシ級のまま互換性で勝ちました。

なぜwasm-bindgenを使わないのですか? kevyはサーバーも組み込みもブラウザも、サードパーティ依存ゼロで出荷します。バインディングジェネレータはビルド時依存になり、境界レイアウトの所有権を持ってしまいます。手書きのABIなら、契約は明示的で、バージョン付きで、監査可能なままです。