File size: 5,251 Bytes
aa89cb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
import {
  BrowserSQLiteOpenOptions,
  BrowserSQLiteRequest,
  BrowserSQLiteResponse,
} from './browser-sqlite-protocol';

type WorkerScope = {
  postMessage(message: BrowserSQLiteResponse): void;
  addEventListener(type: 'message', listener: (event: MessageEvent<BrowserSQLiteRequest>) => void): void;
};

export type BrowserSQLiteWorkerOptions = Readonly<{
  scope?: WorkerScope;
  /** Bundler-resolved URL for @sqlite.org/sqlite-wasm/sqlite3.wasm. */
  wasmUrl?: string;
}>;

type SQLiteDatabase = {
  exec(options: Record<string, unknown>): unknown;
  exec(sql: string): unknown;
  changes(total?: boolean): number;
  close(): void;
};

type SQLiteModule = {
  oo1: {
    DB: new (filename?: string, flags?: string) => SQLiteDatabase;
    OpfsDb?: new (filename: string, flags?: string) => SQLiteDatabase;
  };
};

/**
 * Installs the TeaQL SQLite/WASM RPC loop in the current dedicated worker.
 * Call this once from an application-owned worker entry point.
 */
export function startBrowserSQLiteWorker(options: BrowserSQLiteWorkerOptions = {}): void {
  const scope = options.scope ?? globalThis as unknown as WorkerScope;
  let sqlitePromise: Promise<SQLiteModule> | undefined;
  let database: SQLiteDatabase | undefined;
  let openOptions: Required<BrowserSQLiteOpenOptions> | undefined;
  let tail: Promise<void> = Promise.resolve();

  const sqlite = (): Promise<SQLiteModule> => {
    if (!sqlitePromise) {
      const initialize = sqlite3InitModule as unknown as (
        config?: { locateFile(file: string): string },
      ) => Promise<SQLiteModule>;
      sqlitePromise = initialize(options.wasmUrl
        ? { locateFile: (file: string) => file.endsWith('.wasm') ? options.wasmUrl! : file }
        : undefined);
    }
    return sqlitePromise;
  };

  const open = async (options: BrowserSQLiteOpenOptions = {}): Promise<void> => {
    database?.close();
    const storage = options.storage ?? 'memory';
    const databaseName = options.databaseName ?? 'teaql-browser.sqlite3';
    const module = await sqlite();
    if (storage === 'opfs') {
      if (!module.oo1.OpfsDb) {
        const error = new Error(
          'OPFS SQLite is unavailable. Serve COOP/COEP headers and use a compatible browser.',
        );
        error.name = 'BrowserSQLiteStorageUnavailableError';
        throw error;
      }
      database = new module.oo1.OpfsDb(`/${databaseName}`, 'c');
    } else {
      database = new module.oo1.DB(':memory:', 'ct');
    }
    openOptions = { storage, databaseName };
    database.exec('PRAGMA foreign_keys = ON');
  };

  const requireDatabase = (): SQLiteDatabase => {
    if (!database) throw new Error('Browser SQLite database is not open');
    return database;
  };

  const query = (sql: string, values: unknown[] = []): unknown => {
    if (typeof sql !== 'string' || !sql.trim()) throw new TypeError('SQL is required');
    if (!Array.isArray(values)) throw new TypeError('SQL bind values must be an array');
    const db = requireDatabase();
    const rows = db.exec({
      sql,
      bind: values,
      rowMode: 'object',
      returnValue: 'resultRows',
    }) as unknown[];
    const mutation = /^\s*(?:insert|update|delete|replace)\b/i.test(sql);
    return { rows, rowCount: rows.length || (mutation ? Number(db.changes()) : 0) };
  };

  const reset = async (): Promise<void> => {
    const db = requireDatabase();
    db.exec('PRAGMA foreign_keys = OFF');
    const rows = db.exec({
      sql: "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
      rowMode: 'object',
      returnValue: 'resultRows',
    }) as Array<{ name: unknown }>;
    for (const row of rows) {
      const name = String(row.name).replace(/"/g, '""');
      db.exec(`DROP TABLE IF EXISTS "${name}"`);
    }
    db.exec('PRAGMA foreign_keys = ON');
    if (!openOptions) throw new Error('Browser SQLite database is not open');
  };

  scope.addEventListener('message', event => {
    const request = event.data;
    if (!request || request.type !== 'teaql-browser-sqlite-request') return;
    tail = tail.then(async () => {
      try {
        let result: unknown;
        if (request.operation === 'open') result = await open(request.payload as BrowserSQLiteOpenOptions);
        else if (request.operation === 'query') {
          result = query(String(request.payload?.sql ?? ''), (request.payload?.values ?? []) as unknown[]);
        } else if (request.operation === 'reset') result = await reset();
        else if (request.operation === 'close') { database?.close(); database = undefined; }
        else throw new Error(`Unsupported Browser SQLite operation: ${String(request.operation)}`);
        scope.postMessage({
          type: 'teaql-browser-sqlite-response', requestId: request.requestId, result,
        });
      } catch (value) {
        const error = value instanceof Error ? value : new Error(String(value));
        scope.postMessage({
          type: 'teaql-browser-sqlite-response', requestId: request.requestId,
          error: {
            name: error.name,
            message: error.message,
            code: (error as Error & { code?: string }).code,
          },
        });
      }
    });
  });
}