🎉

typeorm + absurd-sql on Browser のロマン構成

2021/09/10に公開

ロマン構成が動いたので紹介します。

コード: https://github.com/mizchi/absurd-sql-example-with-typeorm
デモ: https://heuristic-perlman-94f8f4.netlify.app

tldr

  • Steam の某クリッカーゲームをやってたら放置ゲーでも作りたい気分になってきた。
  • 複雑なデータを管理するならブラウザ内に本物の sqlite を持ってきたい
  • sqlite は持ってこれたけど TS の中で 生 SQL 書くのがだるかった(補完支援がない)ので ORM でラップしたい
  • Typeorm + absurd-sql の構成を試したら色々大変だったけど動いた

つまりブラウザでこのコードが動く。

// ...
@Entity()
class User {
  @PrimaryGeneratedColumn()
  id: number;
  @Column()
  name: string;
}

/// 初期化は略
let userRepository: Repository<User>;

async function createUser() {
  const user = new User();
  user.id = Math.floor(Math.random() * 10000);
  user.name = "dummy-user-" + Math.random().toString();
  await userRepository.save(user);
  return true;
}

async function getUsers() {
  const users = await userRepository.find();
  return users;
}

typeorm + sql.js はすでにあるけど、 typeorm + sql.js + absurd-sql なのが新規性がある部分になります。

事前知識

sql.js とは

sqlite の wasm ビルドです。ブラウザ内で本物の sqlite が動きます。データ書き込み部分はインメモリの UInt8Array のバッファになっています。

sql-js/sql.js: A javascript library to run SQLite on the web.

永続化する場合、このバイナリを自分で保存する必要があります。

absurd-sql とは

最近一部で話題になった sql.js の高速な indexeddb backend の拡張です。通常の sqlite や他のDBのように、8192 byte のページ単位に分割して書き込みます。

jlongster/absurd-sql: sqlite3 in ur indexeddb (hopefully a better backend soon)

A future for SQL on the web

ベンチマーク

websql が deprecated 勧告が出て久しいので、これを置き換えるのが目標みたいですね。

Typeorm

TypeScript の ORM です。

TypeORM - Amazing ORM for TypeScript and JavaScript (ES7, ES6, ES5). Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms.

選んだ理由は、最初から sql.js backend を持っていたから。それだけ。本当のところ、デコレータとクラスベースの typeorm はシリアライズしづらく好みではないです…。

本当は prisma を使いたくて調べたのですが、最近の prisma-engine は rust で書かれていて、クエリ文字列を送ったり結果を返したりという単純な実装ではなさそう?で rust の sqlite の native 実装の adapter が挟まっていたので、一旦諦めました。sql.js ではなく sqlite ごとビルドする必要が発生します。

prisma-engines/sqlite_datamodel_connector.rs at master · prisma/prisma-engines

prisma-engines/sqlite.rs at master · prisma/prisma-engines

(Rust 得意な人やってみてほしいです)

実際に繋げる

こういう構成にします。

typeorm + sql.js の公式のサンプルをベースに TS に書き換えつつ実装していきます。

typeorm/browser-example: Example how to use TypeORM in the browser with WebSQL.

typeorm に web worker 対応がないので、無理やりモックしつつ適用します。

import type { InitSqlJsStatic } from "sql.js";
import initSqlJs from "@jlongster/sql.js";
import { SQLiteFS } from "absurd-sql";
import IndexedDBBackend from "absurd-sql/dist/indexeddb-backend";
import { Connection, createConnection, Repository } from "typeorm";
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";
import { expose } from "comlink";

async function setupTypeormEnvWithSqljs(dbPath: string) {
  const SQL = await (initSqlJs as InitSqlJsStatic)({
    locateFile: (file: string) => file,
  });
  // @ts-ignore
  const sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
  // @ts-ignore
  SQL.register_for_idb(sqlFS);
  // @ts-ignore
  SQL.FS.mkdir("/sql");
  // @ts-ignore
  SQL.FS.mount(sqlFS, {}, "/sql");
  class PatchedDatabase extends SQL.Database {
    constructor() {
      // @ts-ignore
      super(dbPath, { filename: true });
      // Set indexeddb page size
      this.exec(`PRAGMA page_size=8192;PRAGMA journal_mode=MEMORY;`);
    }
  }
  const localStorageMock = {
    getItem() {
      return undefined;
    },
    setItem() {
      return undefined;
    },
  };
  // setup global env
  Object.assign(globalThis as any, {
    SQL: {
      ...SQL,
      Database: PatchedDatabase,
    },
    localStorage: localStorageMock,
    window: globalThis,
  });
}

let conn: Connection;
let userRepository: Repository<User>;
async function setup() {
  // with /sql/ namespace
  const DBNAME = "/sql/db.sqlite";
  await setupTypeormEnvWithSqljs(DBNAME);
  conn = await createConnection({
    type: "sqljs", // this connection search window.SQL on browser
    location: DBNAME,
    autoSave: false, // commit by absurd-sql
    synchronize: true,
    entities: [User], // ここにスキーマを登録
    logging: ["query", "schema"],
  });
  userRepository = conn.getRepository(User);
}

// ...ユーザーコード

やってること

  • typeorm/browser がグローバル変数として window.SQL が存在することを期待しているので、その名前空間に無理やり worker 上に作ります。
  • absurd-sql のパッチによって SQL.Database のコンストラクタが変更されてしまってるので、無理やりパッチを当てます。
  • typeorm が localStorage にスキーマ定義のメタデータを注入するので、そこをモックします。
  • autosave せずとも absurd-sql がインクリメンタルに書き込んでくれるので、止めます。
  • synchronize スキーマ定義が永続化されてないので、都度 スキーマ定義を初期化します。

あとは上述の typeorm のコードが動きます。複雑なケースはまだ試してないので、PRAGMA が足りなかったりするかもしれません

netlify にデプロイする

sql.js の同期書き込みAPI を実現するために、SharedArrayBuffer と Atomics を使うので、ヘッダを追加する必要があります。

今回、netilfy にアップロードしたのですが、 dist/_headers に次のようにヘッダを追加するように記述しました。

/*
  Cross-Origin-Opener-Policy = "same-origin"
  Cross-Origin-Embedder-Policy = "require-corp"

実は開発用サーバーでも同じ設定が入っています。

webpack.config.js
module.exports = (env, argv) => ({
  // ....
  devServer: {
    publicPath: "auto",
    hot: true,
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
});

TODO

  • webpack 版だけでなく vite 版を作る
  • prisma ももう一回検証

Discussion