0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GraphDB Kuzu(WebAssembly)をGithub Pagesで動かしたメモ

Last updated at Posted at 2025-10-26

概要

KuzuをGithubPagesで動かそうとしたところ、追加設定が必要になったのでメモ。SharedArrayBufferのエラーが発生する。
ffmpeg.wasmをgithub pagesで動かすよ を参考に、fetch時にヘッダにcross-origin設定を追加する設定を行った。

この時点のソース

fetchにヘッダを付与する設定

coi-serviceworkerのライブラリを使用する

publicフォルダにcoi-serviceworker.jsを配置し、index.htmlで読み込むようにする

apps/frontend/index.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>TRPG Scenario Maker</title>
  </head>
  <body>
+    <script src="/coi-serviceworker.js"></script>

    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

今回のアーキテクチャ

WebWorkerを間に挟んでブロックを防いでみた。

┌──────────────────────────────────────────────────────────────────────────────┐
│  Frontend (Main Thread)                                                      │
│  ┌─────────────┐       ┌──────────────────────┐                              │
│  │ React UI    │◄─────►│ Scene Graph          │                              │
│  └─────────────┘       └──────────┬───────────┘                              │
│                                   │                                          │
│                      ┌────────────▼───────────┐       ┌────────────────────┐ │
│                      │ graphdbWorkerClient    │◄─────►│  LocalStorage      │ │
│                      └────┬───────────────────┘       │  (CSV形式保存/復元) │ │
│                           │                           └────────────────────┘ │
│                           │                                                  │
└───────────────────────────┼──────────────────────────────────────────────────┘
                            │ postMessage
                   ┌────────▼────────┐
                   │  Web Worker     │
                   │(graphdb.worker) │
                   └────────┬────────┘
                            │
                   ┌────────▼────────┐       ┌────────────────────────────┐
                   │  Kuzu-Wasm      │◄─────►│  VFS (Virtual File System) │
                   │  (Graph Query)  │       │  (CSV形式保存/復元)         │
                   └─────────────────┘       └────────────────────────────┘

ソースコード

前回、GraphDB Kuzu(WebAssembly)でデータをlocalStorageに読み書きしたメモを行ったときよりもviteの設定が楽になっていた。

apps/frontend/vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
+  optimizeDeps: {
+    exclude: ['@electric-sql/pglite', '@kuzu/kuzu-wasm'],
+  },
  resolve: {
    alias: {
      '@': path.join(__dirname, './src'),
    },
  },
+  worker: {
+    format: 'es',
+  },
});

packages/src/db.ts
// eslint-disable-next-line camelcase
import kuzu_wasm from '@kuzu/kuzu-wasm';
import type { Database, Connection, KuzuModule } from '@kuzu/kuzu-wasm';

let kuzu: KuzuModule | null = null;
let db: Database | null = null;
let connection: Connection | null = null;

/**
 * Kùzuデータベースを初期化し、スキーマを作成
 */
export async function initializeDatabase(): Promise<void> {
  // Kùzu WASMモジュールを初期化
  kuzu = await kuzu_wasm();

  // インメモリデータベースを作成
  db = await kuzu.Database();

  // 接続を確立
  connection = await kuzu.Connection(db);
}

/**
 * データベース接続を取得
 */
export function getConnection(): Connection {
  if (!connection) {
    throw new Error(
      'Database connection not initialized. Call initializeDatabase first.',
    );
  }
  return connection;
}

/**
 * クエリを実行してJSONデータを返す
 */
export async function executeQuery(query: string): Promise<unknown> {
  if (!connection) {
    throw new Error(
      'Database connection not initialized. Call initializeDatabase first.',
    );
  }

  const result = await connection.execute(query);

  // result.tableが存在しない場合は空配列を返す
  if (!result.table) {
    return [];
  }

  return JSON.parse(result.table.toString());
}

export function readFSVFile(path: string): string {
  if (!kuzu) {
    throw new Error('Kùzu FS is not initialized');
  }
  return kuzu.FS.readFile(path, { encoding: 'utf8' });
}

export function writeFSVFile(path: string, content: string): void {
  if (!kuzu) {
    throw new Error('Kùzu FS is not initialized');
  }
  kuzu.FS.writeFile(path, content);
}
/**
 * データベースを閉じる
 */
export async function closeDatabase(): Promise<void> {
  if (connection) {
    connection.close();
    connection = null;
  }
  if (db) {
    db.close();
    db = null;
  }
}

型は相変わらず自前。

package/graphdb/src/global.d.ts
declare module '@kuzu/kuzu-wasm' {
  export default function kuzu_wasm(): Promise<KuzuModule>;

  export interface KuzuModule {
    Database: () => Promise<Database>;
    Connection: (db: Database) => Promise<Connection>;
    FS: {
      writeFile: (path: string, content: string) => void;
      readFile: <E extends 'binary' | 'utf8'>(
        path: string,
        options: { encoding: E },
      ) => E extends 'utf8' ? string : Uint8Array;
    };
  }

  export class Database {
    close(): void;
  }

  export class Connection {
    execute(query: string): Promise<QueryResult>;

    close(): void;
  }

  export interface QueryResult {
    table: {
      toString(): string;
    };
  }
}
apps/frontend/src/vite-env.d.ts
/// <reference types="vite/client" />

// kuzu-wasm の型定義を参照
/// <reference types="../../../packages/graphdb/src/global.d.ts" />

スキーマは定義ファイルを作って、初期化時に作成するようにしてみた。

packages/graphpdb/src/schema.ts
export const graphDbSchemas = {
  nodes: [
    {
      name: 'Scenario',
      query: `
      CREATE NODE TABLE Scenario (
        id STRING,
        title STRING,
        PRIMARY KEY (id)
      )`,
    },
    {
      name: 'Scene',
      query: `
      CREATE NODE TABLE Scene (
        id STRING,
        title STRING,
        isMasterScene BOOL,
        description STRING,
        PRIMARY KEY (id)
      )`,
    },
  ],
  relationships: [
    {
      name: 'HAS_SCENE',
      query: `
      CREATE REL TABLE HAS_SCENE (
        FROM Scenario TO Scene
      )`,
    },
    {
      name: 'NEXT_SCENE',
      query: `
      CREATE REL TABLE NEXT_SCENE (
        FROM Scene TO Scene
      )`,
    },
  ],
};
apps/frontend/workers/graphdbWorkerClient.ts
import { graphDbSchemas } from '@trpg-scenario-maker/graphdb';
import { BaseWorkerClient } from './BaseWorkerClient';
import type {
  GraphDBWorkerRequest,
  GraphDBWorkerResponse,
} from './graphdb.worker';
import DBWorker from './graphdb.worker?worker';

const { nodes, relationships } = graphDbSchemas;
const schemas = [...nodes, ...relationships];

class GraphDBWorkerClient extends BaseWorkerClient<
  GraphDBWorkerRequest,
  GraphDBWorkerResponse
> {
  // eslint-disable-next-line class-methods-use-this
  protected getWorkerUrl(): URL | (new () => Worker) {
    if (import.meta.env.DEV) {
      return new URL('./graphdb.worker.ts', import.meta.url);
    }
    return DBWorker;
  }

  /**
   * 初期化時にデータベースをセットアップ
   */
  protected async onInitialize(): Promise<void> {
    await this.sendRequest({ type: 'init' });
    await this.createSchema();
    await this.load();
  }

  async close(): Promise<void> {
    await this.sendRequest({ type: 'close' });
  }

  async execute<T = unknown>(query: string): Promise<T> {
    const response = await this.sendRequest<
      GraphDBWorkerResponse & { data: T }
    >({
      type: 'execute',
      payload: { query },
    });
    return response.data as T;
  }

  async save(): Promise<void> {
    await Promise.all(nodes.map((schema) => this.saveNode(schema.name)));
    await Promise.all(
      relationships.map((schema) => this.saveEdge(schema.name)),
    );
  }

  async load(): Promise<void> {
    await Promise.all(schemas.map((schema) => this.loadTable(schema.name)));
  }

  private async createSchema(): Promise<void> {
    await Promise.all(schemas.map((schema) => this.execute(schema.query)));
  }

  private async saveNode(tableName: string): Promise<void> {
    const nodeFilename = `/${tableName}.csv`;
    const nodeResponse = await this.sendRequest<GraphDBWorkerResponse>({
      type: 'save',
      payload: {
        path: nodeFilename,
        query: `COPY (MATCH (n:${tableName}) RETURN n.*) TO '${nodeFilename}' (header=false);`,
      },
    });
    localStorage.setItem(nodeFilename, nodeResponse.data as string);
  }

  private async saveEdge(tableName: string): Promise<void> {
    const edgeFilename = `/${tableName}.csv`;
    const edgeResponse = await this.sendRequest<GraphDBWorkerResponse>({
      type: 'save',
      payload: {
        path: edgeFilename,
        query: `COPY (MATCH (a)-[f:${tableName}]->(b) RETURN a.id, b.id) TO '${edgeFilename}' (header=false, delim='|');`,
      },
    });
    localStorage.setItem(edgeFilename, edgeResponse.data as string);
  }

  private async loadTable(tableName: string): Promise<void> {
    const path = `/${tableName}.csv`;
    await this.sendRequest<GraphDBWorkerResponse>({
      type: 'load',
      payload: {
        path,
        query: `COPY ${tableName} FROM '${path}'`,
        content: localStorage.getItem(path) ?? '',
      },
    });
  }
}

export const graphdbWorkerClient = new GraphDBWorkerClient();

apps/frontend/src/worker/graphdb.worker.ts
import {
  initializeDatabase,
  executeQuery,
  closeDatabase,
  readFSVFile,
  writeFSVFile,
} from '@trpg-scenario-maker/graphdb';

export interface GraphDBWorkerRequest {
  type: string;
  payload?: unknown;
}

export interface GraphDBWorkerResponse {
  type: string;
  data?: unknown;
  success?: boolean;
  error?: string;
  originalType?: string;
}

type HandlerFunction = (
  payload: unknown,
) => Promise<Omit<GraphDBWorkerResponse, 'type'>>;


const handlers = new Map<string, HandlerFunction>();


handlers.set('init', async () => {
  await initializeDatabase();
  return { success: true, data: { message: 'Database initialized' } };
});


handlers.set('execute', async (payload: unknown) => {
  const { query } = payload as { query: string };
  if (!query) {
    throw new Error('Query is required');
  }

  const data = await executeQuery(query);
  return { success: true, data };
});


handlers.set('save', async (payload: unknown) => {
  const { query, path } = payload as { query: string; path: string };
  if (!query || !path) {
    throw new Error('Query and path are required');
  }

  await executeQuery(query);

  return { success: true, data: readFSVFile(path) };
});

handlers.set('load', async (payload: unknown) => {
  const { query, path, content } = payload as {
    query: string;
    path: string;
    content: string;
  };
  if (!content) return { success: true, data: { message: 'Data loaded skip' } };
  if (!query || !path) {
    throw new Error('Content and path are required');
  }

  await writeFSVFile(path, content);
  await executeQuery(query);

  return { success: true, data: { message: 'Data loaded successfully' } };
});

handlers.set('close', async () => {
  await closeDatabase();
  return { success: true, data: { message: 'Database closed' } };
});

const { self } = globalThis;
self.addEventListener(
  'message',
  async (event: MessageEvent<GraphDBWorkerRequest & { id: number }>) => {
    const { type, id, payload } = event.data;

    try {
      const handler = handlers.get(type);
      if (!handler) {
        throw new Error(`No handler registered for message type: ${type}`);
      }

      const result = await handler(payload);
      const response: GraphDBWorkerResponse = { type, ...result };

      self.postMessage({ id, ...response });
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : String(error);
      self.postMessage({
        id,
        type: 'error',
        error: errorMessage,
        originalType: type,
      } satisfies GraphDBWorkerResponse & { id: number });
    }
  },
);

初期化は最初に行っている。少し読み込みに時間がかかる。

apps/frontend/src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './app/App';
import { dbWorkerClient } from './workers/dbWorkerClient';
import { graphdbWorkerClient } from './workers/graphdbWorkerClient';
import './index.css';

// DBWorkerを初期化(マイグレーション自動実行)
await dbWorkerClient.initialize();

+ // GraphDBWorkerを初期化 ローカルストレージからデータを読み込み
+ await graphdbWorkerClient.initialize();

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

CypherクエリはFSDのentitiesに今は書いているけど、ここでいいのかは検討中。
2025.10.26 追記 → やはり、RDBと同じ構成に変更。変更後のソース

apps/frontend/src/entities/scene/api/sceneApi.ts
import type { Scene, SceneConnection } from '@trpg-scenario-maker/ui';
import { graphdbWorkerClient } from '../../../workers/graphdbWorkerClient';

/**
 * シーンAPIクラス
 */
export class SceneApi {
  /**
   * シナリオに属するシーンを取得
   */
  static async getScenesByScenarioId(scenarioId: string): Promise<Scene[]> {
    const query = `
      MATCH (s:Scenario {id: '${scenarioId}'})-[:HAS_SCENE]->(scene:Scene)
      RETURN scene.id AS id, scene.title AS title, scene.description AS description, scene.isMasterScene AS isMasterScene
    `;
    const result = await graphdbWorkerClient.execute<
      Array<{
        id: string;
        title: string;
        description: string;
        isMasterScene: boolean;
      }>
    >(query);
    return result;
  }

  /**
   * シーン間の接続を取得
   */
  static async getConnectionsByScenarioId(
    scenarioId: string,
  ): Promise<SceneConnection[]> {
    const query = `
      MATCH (s:Scenario {id: '${scenarioId}'})-[:HAS_SCENE]->(scene1:Scene)-[r:NEXT_SCENE]->(scene2:Scene)
      RETURN scene1.id AS source, scene2.id AS target
    `;
    const result = await graphdbWorkerClient.execute<
      Array<{
        source: string;
        target: string;
      }>
    >(query);
    return result.map((item) => ({
      id: `${item.source}-${item.target}`,
      source: item.source,
      target: item.target,
    }));
  }

  /**
   * シーンを作成
   */
  static async createScene(
    scenarioId: string,
    scene: Omit<Scene, 'id'>,
  ): Promise<Scene> {
    const id = crypto.randomUUID();
    const escapedTitle = scene.title.replace(/'/g, "\\'");
    const escapedDescription = scene.description.replace(/'/g, "\\'");

    const query = `
      MATCH (s:Scenario {id: '${scenarioId}'})
      CREATE (scene:Scene {
        id: '${id}',
        title: '${escapedTitle}',
        description: '${escapedDescription}',
        isMasterScene: ${scene.isMasterScene}
      })
      CREATE (s)-[:HAS_SCENE]->(scene)
      RETURN scene.id AS id, scene.title AS title, scene.description AS description, scene.isMasterScene AS isMasterScene
    `;

    const result = await graphdbWorkerClient.execute<
      Array<{
        id: string;
        title: string;
        description: string;
        isMasterScene: boolean;
      }>
    >(query);
    console.log('Scene created:', result);

    if (!result || !Array.isArray(result) || result.length === 0) {
      throw new Error(
        'Failed to create scene: No result returned from database',
      );
    }

    return result[0];
  }

  /**
   * シーンを更新
   */
  // eslint-disable-next-line complexity
  static async updateScene(
    id: string,
    updates: Partial<Scene>,
  ): Promise<Scene> {
    const setClauses: string[] = [];
    if (updates.title !== undefined) {
      setClauses.push(`scene.title = '${updates.title.replace(/'/g, "\\'")}'`);
    }
    if (updates.description !== undefined) {
      setClauses.push(
        `scene.description = '${updates.description.replace(/'/g, "\\'")}'`,
      );
    }
    if (updates.isMasterScene !== undefined) {
      setClauses.push(`scene.isMasterScene = ${updates.isMasterScene}`);
    }

    if (setClauses.length === 0) {
      throw new Error('No fields to update');
    }

    const query = `
      MATCH (scene:Scene {id: '${id}'})
      SET ${setClauses.join(', ')}
      RETURN scene.id AS id, scene.title AS title, scene.description AS description, scene.isMasterScene AS isMasterScene
    `;
    const result = await graphdbWorkerClient.execute<
      Array<{
        id: string;
        title: string;
        description: string;
        isMasterScene: boolean;
      }>
    >(query);

    if (!result || !Array.isArray(result) || result.length === 0) {
      throw new Error(
        'Failed to update scene: Scene not found or no result returned',
      );
    }

    return result[0];
  }

  /**
   * シーンを削除
   */
  static async deleteScene(id: string): Promise<void> {
    const query = `
      MATCH (scene:Scene {id: '${id}'})
      DETACH DELETE scene
    `;
    await graphdbWorkerClient.execute(query);
  }

  /**
   * シーン間の接続を作成
   */
  static async createConnection(
    connection: Omit<SceneConnection, 'id'>,
  ): Promise<SceneConnection> {
    const query = `
      MATCH (source:Scene {id: '${connection.source}'}), (target:Scene {id: '${connection.target}'})
      CREATE (source)-[r:NEXT_SCENE]->(target)
      RETURN '${connection.source}-${connection.target}' AS id, '${connection.source}' AS source, '${connection.target}' AS target
    `;
    const result = await graphdbWorkerClient.execute<
      Array<{
        id: string;
        source: string;
        target: string;
      }>
    >(query);

    if (!result || !Array.isArray(result) || result.length === 0) {
      throw new Error(
        'Failed to create connection: No result returned from database',
      );
    }

    return result[0];
  }

  /**
   * シーン間の接続を削除
   */
  static async deleteConnection(id: string): Promise<void> {
    const [source, target] = id.split('-');
    const query = `
      MATCH (source:Scene {id: '${source}'})-[r:NEXT_SCENE]->(target:Scene {id: '${target}'})
      DELETE r
    `;
    await graphdbWorkerClient.execute(query);
  }
}

参考

ffmpeg.wasmをgithub pagesで動かすよ
GitHub PagesでもSharedArrayBufferを使いたい

GraphDB KuzuのWebAssemblyをブラウザ上で動かしneo4j-nvlで可視化してみたメモ
GraphDB Kuzu(WebAssembly)でデータをlocalStorageに読み書きしたメモ
GraphDB Kuzu(WebAssembly)をvitestで動かしたメモ

Typescript + WebWorker + Vite でビルドすると、 data:video/mp2t;base64 で期待しないビルド結果となったことに対応したメモ

VFS

GitHub Pages で React Router を使った SPA サイトを動かす方法
gh-pages-react-apps

kuzu wasm
kuzu shell

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?