typeorm + absurd-sql on Browser のロマン構成
ロマン構成が動いたので紹介します。
コード: 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)
ベンチマーク
websql が deprecated 勧告が出て久しいので、これを置き換えるのが目標みたいですね。
Typeorm
TypeScript の ORM です。
選んだ理由は、最初から 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"
実は開発用サーバーでも同じ設定が入っています。
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