@aiquants/duckdb-helper 1.5.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  # DuckDBService 詳細仕様書
2
2
 
3
3
  > ソースファイル: `src/services/duckdb.ts`
4
+ > 最終更新: 2026-07-05(v1.6.0 — 初期化ライフサイクル / トランザクション / 型推定の堅牢化)
4
5
 
5
6
  ## 1. 概要
6
7
 
@@ -11,47 +12,36 @@ Web Worker の初期化、コネクション管理、SQL クエリの実行、
11
12
 
12
13
  ### 2.1 デザインパターン
13
14
 
14
- - **シングルトンパターン**: `getInstance()` で唯一のインスタンスを取得する。アプリケーション全体で DuckDB Worker を1つだけ保持し、メモリとリソースを節約する。
15
- - **オブザーバーパターン**: `addStatusListener` / `removeStatusListener` でステータス変更を監視できる。React Hook (`useDuckDB`) がこれを利用している。
15
+ - **シングルトンパターン**: `getInstance()` で唯一のインスタンスを取得する。アプリ全体で DuckDB Worker を1つだけ保持し、メモリとリソースを節約する。
16
+ - **オブザーバーパターン**: `addStatusListener` / `removeStatusListener` でステータス変更を購読できる(push 型)。React Hook (`useDuckDB`) がこれを利用する。**状態遷移のたびに `notifyListeners()` が必ず呼ばれる**ため、ポーリングは不要。
17
+ - **操作直列化キュー**: 単一の共有コネクション上で、`executeQuery` / `getTableInfo` / `listTables` と `createTableFromData` の DDL/DML が相互に割り込まないよう、内部の `operationQueue` で直列化する。
16
18
 
17
19
  ### 2.2 プライベートプロパティ
18
20
 
19
21
  | プロパティ名 | 型 | 初期値 | 説明 |
20
22
  |-------------|------|--------|------|
21
23
  | `instance` | `DuckDBService \| null` | `null` | シングルトンインスタンス(static) |
22
- | `operationFlags` | `Map<string, boolean>` | 空の Map | テーブル作成の重複実行防止フラグ |
23
- | `workerInstance` | `AsyncDuckDB \| null` | `null` | DuckDB Worker インスタンス |
24
+ | `operationFlags` | `Map<string, Promise<void>>` | 空の Map | **進行中の**テーブル作成 Promise(重複呼び出しは同じ Promise を await する) |
25
+ | `workerInstance` | `AsyncDuckDB \| null` | `null` | DuckDB Worker インスタンス(**instantiate 成功後にのみ設定**) |
24
26
  | `connectionInstance` | `AsyncDuckDBConnection \| null` | `null` | DB コネクション(再利用される) |
27
+ | `connectionPromise` | `Promise<AsyncDuckDBConnection> \| null` | `null` | コネクション生成中の Promise(同時 connect の二重実行防止) |
25
28
  | `isInitializing` | `boolean` | `false` | 初期化中フラグ |
26
29
  | `initPromise` | `Promise<AsyncDuckDB> \| null` | `null` | 初期化 Promise(重複呼び出し防止用) |
27
30
  | `hasError` | `boolean` | `false` | エラー発生フラグ |
31
+ | `pendingWorker` | `Worker \| null` | `null` | 初期化中の Worker(タイムアウト時などに terminate するため保持) |
32
+ | `pendingWorkerUrl` | `string \| null` | `null` | 初期化中の Blob URL(同上、revoke するため保持) |
33
+ | `operationQueue` | `Promise<unknown>` | `Promise.resolve()` | 操作直列化キュー |
28
34
  | `listeners` | `Set<() => void>` | 空の Set | ステータス変更リスナー群 |
29
35
 
30
36
  ## 3. メソッド仕様
31
37
 
32
38
  ### 3.1 `getInstance(): DuckDBService` (static)
33
39
 
34
- ```typescript
35
- public static getInstance(): DuckDBService
36
- ```
37
-
38
- | 項目 | 内容 |
39
- |------|------|
40
- | 戻り値 | `DuckDBService` のシングルトンインスタンス |
41
- | 副作用 | インスタンスが未作成の場合、新規作成する |
42
- | スレッド安全性 | JavaScript はシングルスレッドなので問題なし |
43
-
44
- **処理フロー**:
45
- 1. `instance` が `null` → 新しい `DuckDBService` を作成して `instance` に格納
46
- 2. `instance` を返す
40
+ インスタンスが未作成なら生成し、シングルトンを返す。JavaScript はシングルスレッドのため排他制御は不要。
47
41
 
48
42
  ---
49
43
 
50
- ### 3.2 `getStatus(): string`
51
-
52
- ```typescript
53
- public getStatus(): "not-initialized" | "initializing" | "ready" | "error"
54
- ```
44
+ ### 3.2 `getStatus(): "not-initialized" | "initializing" | "ready" | "error"`
55
45
 
56
46
  | ステータス | 条件 | 意味 |
57
47
  |-----------|------|------|
@@ -60,21 +50,17 @@ public getStatus(): "not-initialized" | "initializing" | "ready" | "error"
60
50
  | `"ready"` | `workerInstance !== null` | 利用可能 |
61
51
  | `"not-initialized"` | 上記いずれでもない | まだ初期化されていない |
62
52
 
63
- > **判定順序に注意**: `error` が最優先。`isInitializing` が `true` かつ `hasError` が `true` の場合は `"error"` になる。
53
+ > **v1.6.0 での整合性改善**: 初期化に失敗すると `workerInstance` は必ず `null` にリセットされる。したがって `isReady()`(`workerInstance !== null`)と `getStatus()`(`"error"`)が矛盾しなくなった(旧実装では失敗後に `isReady() === true` かつ `getStatus() === "error"` という不整合が起きていた)。
64
54
 
65
55
  ---
66
56
 
67
57
  ### 3.3 `initialize(): Promise<AsyncDuckDB>`
68
58
 
69
- ```typescript
70
- public async initialize(): Promise<duckdb.AsyncDuckDB>
71
- ```
72
-
73
59
  | 項目 | 内容 |
74
60
  |------|------|
75
61
  | 戻り値 | 初期化された `AsyncDuckDB` インスタンス |
76
62
  | 例外 | 初期化失敗時(タイムアウト含む)に throw |
77
- | タイムアウト | 120秒(120,000ms) |
63
+ | タイムアウト | 120秒(120,000ms、`INIT_TIMEOUT_MS`) |
78
64
 
79
65
  **処理フロー**:
80
66
 
@@ -84,304 +70,192 @@ flowchart TD
84
70
  B -- Yes --> C["そのまま返す(高速パス)"]
85
71
  B -- No --> D{既に初期化中?}
86
72
  D -- Yes --> E["initPromise を返す(重複防止)"]
87
- D -- No --> F["isInitializing = true, hasError = false\nリスナーに通知(status: initializing)"]
88
- F --> G["Promise.race(\n internalInitializeWorker(),\n タイムアウト 120秒\n)"]
89
- G -- 成功 --> H["isInitializing = false\nリスナーに通知(status: ready)\nAsyncDuckDB を返す"]
90
- G -- 失敗 --> I["isInitializing = false, hasError = true\nリスナーに通知(status: error)\ninitPromise = null(リトライ可能)\nエラーを throw"]
73
+ D -- No --> F["isInitializing = true, hasError = false\nリスナーに通知(initializing)\nアボートシグナル + タイムアウトタイマーを用意"]
74
+ F --> G["Promise.race(\n internalInitializeWorker(signal),\n 120秒タイムアウト\n)"]
75
+ G -- 成功 --> H["clearTimeout\nisInitializing = false\nリスナー通知(ready)\nAsyncDuckDB を返す"]
76
+ G -- 失敗/タイムアウト --> I["clearTimeout\nsignal.aborted = true\nisInitializing = false, hasError = true\nworkerInstance / connectionInstance / connectionPromise / initPromise を null\nabortPendingWorker()(Worker terminate + Blob URL revoke)\nリスナー通知(error)\nエラーを throw"]
91
77
  ```
92
78
 
93
- **重複呼び出し防止の仕組み**:
94
- 複数の箇所から同時に `initialize()` が呼ばれても、2回目以降は `initPromise` を返すため、Worker は1回だけ作成される。
79
+ **v1.6.0 での重要な修正**:
95
80
 
96
- ---
81
+ 1. **失敗後の完全リセットと再試行可能性**: 失敗/タイムアウト時に `workerInstance` を含む全状態を初期化する。旧実装では失敗しても `workerInstance` が「生成済み・未 instantiate」のまま残り、次回 `initialize()` が高速パスで壊れたインスタンスを返し、`hasError` も解除されず、**セッション全体で永久に復旧不能**になっていた。現在は `initialize()` を再度呼べばクリーンに再初期化される。
82
+ 2. **タイマーの確実な解除**: タイムアウト用 `setTimeout` を成功/失敗どちらの経路でも `clearTimeout` する(旧実装は解除漏れで最大120秒間タイマーが残留)。
83
+ 3. **タイムアウト後の遅延結果を無視**: アボートシグナルにより、タイムアウト後に遅れて `instantiate()` が解決しても、壊れた/整合しないインスタンスを公開しない。
97
84
 
98
- ### 3.4 `internalInitializeWorker(): Promise<AsyncDuckDB>` (private)
85
+ **重複呼び出し防止**: 同時に `initialize()` が複数回呼ばれても、2回目以降は `initPromise` を返すため Worker は1回だけ作成される。
99
86
 
100
- ```typescript
101
- private async internalInitializeWorker(): Promise<duckdb.AsyncDuckDB>
102
- ```
87
+ ---
88
+
89
+ ### 3.4 `internalInitializeWorker(signal): Promise<AsyncDuckDB>` (private)
103
90
 
104
- Worker の実際の初期化処理。以下のステップを順番に実行する。
91
+ Worker の実際の初期化処理。**`workerInstance` は全ステップ成功後にのみ `this` に公開する**(途中で失敗/アボートした場合はローカルの Worker / Blob URL を破棄し、壊れたインスタンスを絶対に残さない)。
105
92
 
106
93
  | ステップ | 処理内容 | 補足 |
107
94
  |---------|---------|------|
108
- | 1 | `duckdb.getJsDelivrBundles()` で CDN バンドル一覧を取得 | jsDelivr CDN を使用 |
109
- | 2 | `duckdb.selectBundle()` でブラウザ互換のバンドルを選択 | ブラウザの機能に応じて最適なものを選択 |
110
- | 3 | Blob URL を作成して Web Worker を生成 | `importScripts()` で Worker スクリプトを読み込む |
111
- | 4 | `new duckdb.AsyncDuckDB(logger, worker)` でインスタンス作成 | ConsoleLogger を使用 |
112
- | 5 | `workerInstance.instantiate()` で WASM モジュールを読み込み | メインモジュールと pthread Worker |
113
- | 6 | `splink_udfs` 拡張をインストール・ロード | **一時コネクション**を開き、`INSTALL` → `LOAD` を実行後、`finally` で close する。メインの `connectionInstance` とは別。失敗しても初期化は続行 |
114
- | 7 | Blob URL を `revokeObjectURL` で解放 | メモリリーク防止 |
115
-
116
- **splink_udfs について**:
117
- - 名寄せ(レコードリンケージ)用の UDF(ユーザー定義関数)
118
- - `INSTALL splink_udfs FROM community;` → `LOAD splink_udfs;`
119
- - インストール/ロードに失敗しても `warn` ログを出すだけで、初期化全体は失敗しない
95
+ | 1 | `duckdb.getJsDelivrBundles()` | jsDelivr CDN のバンドル一覧 |
96
+ | 2 | `duckdb.selectBundle()` → **アボート確認** | ブラウザ互換バンドルを選択 |
97
+ | 3 | Blob URL を作成し Web Worker を生成、`pendingWorker`/`pendingWorkerUrl` に退避 | 失敗時の破棄用 |
98
+ | 4 | `new duckdb.AsyncDuckDB(logger, worker)`(ローカル変数 `db`) | まだ `this` に代入しない |
99
+ | 5 | `db.instantiate()` → **アボート確認** | WASM モジュール読み込み |
100
+ | 6 | `splink_udfs` を一時コネクションで INSTALL/LOAD(失敗しても続行、`finally` で close)→ **アボート確認** | 名寄せ用 UDF |
101
+ | 7 | **成功**: `this.workerInstance = db`、`pendingWorker`/`pendingWorkerUrl` をクリア、Blob URL を revoke | ここで初めて公開 |
102
+ | 失敗時 | ローカル `worker.terminate()` と `URL.revokeObjectURL()` を**必ず**実行(各々 try/catch で握りつぶす)、エラーを rethrow | Worker/Blob URL リーク防止 |
120
103
 
121
104
  ---
122
105
 
123
106
  ### 3.5 `getConnection(): Promise<AsyncDuckDBConnection>`
124
107
 
125
- ```typescript
126
- public async getConnection(): Promise<duckdb.AsyncDuckDBConnection>
127
- ```
128
-
129
108
  | 項目 | 内容 |
130
109
  |------|------|
131
- | 戻り値 | DuckDB コネクション |
110
+ | 戻り値 | DuckDB コネクション(1つを保持・再利用) |
132
111
  | 動作 | コネクションが無ければ `initialize()` → `connect()` で作成。あれば再利用 |
133
112
 
134
- **注意**: コネクションは1つだけ保持・再利用される。並列クエリの実行には対応していない。
113
+ **v1.6.0 での修正**: 生成中の `connectionPromise` を共有することで、**同時呼び出し時の二重 `connect()` を防止**する。`connect()` が失敗した場合は `connectionPromise` を `null` に戻し、次回リトライ可能にする。
135
114
 
136
115
  ---
137
116
 
138
117
  ### 3.6 `executeQuery(sql: string): Promise<unknown>`
139
118
 
140
- ```typescript
141
- public async executeQuery(sql: string): Promise<unknown>
142
- ```
143
-
144
119
  | 項目 | 内容 |
145
120
  |------|------|
146
- | 引数 | `sql` - 実行する SQL 文字列 |
147
121
  | 戻り値 | クエリ結果(Apache Arrow Table)。型は `unknown` |
148
- | タイムアウト | 60秒(60,000ms) |
149
- | 例外 | クエリ失敗時・タイムアウト時に throw |
122
+ | タイムアウト | 60秒(60,000ms、`QUERY_TIMEOUT_MS`) |
123
+ | 直列化 | `operationQueue` で他操作と直列化(テーブル構築中の割り込み read を防止) |
150
124
 
151
- **処理フロー**:
152
- 1. `getConnection()` でコネクションを取得
153
- 2. `connection.query(sql)` を実行
154
- 3. 60秒のタイムアウトと `Promise.race` で競合
155
- 4. 結果を返す(またはエラーを throw)
125
+ **v1.6.0 での修正**:
126
+ - タイムアウト用 `setTimeout` を成功/失敗どちらの経路でも `clearTimeout` する(旧実装はクエリ1回ごとに最大60秒残留するタイマーを生成していた)。
127
+ - **タイムアウト時にコネクションを破棄する**: タイムアウトしたクエリはワーカー側で走り続けるため、使用中コネクションを `connectionInstance`/`connectionPromise` から外して close(best-effort)する。これにより後続クエリが詰まったコネクションで待たされず、次回は新しいコネクションで実行される(内部の `runQueryWithTimeout` が担当)。コネクション取得(`getConnection`)自体がハングした場合もタイムアウトが働き、破棄対象が無ければ何もしない。
156
128
 
157
129
  ---
158
130
 
159
131
  ### 3.7 `executeQueries(queries: string[]): Promise<unknown[]>`
160
132
 
161
- ```typescript
162
- public async executeQueries(queries: string[]): Promise<unknown[]>
163
- ```
133
+ 配列の順番通りに1つずつ**逐次実行**し、結果を配列で返す。CREATE → INSERT → SELECT のような依存クエリ列に適する。
164
134
 
165
- | 項目 | 内容 |
166
- |------|------|
167
- | 引数 | `queries` - SQL 文字列の配列 |
168
- | 戻り値 | 各クエリの結果を順番に格納した配列 |
169
- | 実行方法 | **逐次実行**(並列ではない) |
170
-
171
- > **重要**: クエリは配列の順番通りに1つずつ実行される。CREATE TABLE → INSERT → SELECT のように依存関係があるクエリ列に適している。
135
+ **v1.6.0 での修正**: バッチ全体を**1つのキュー単位**(単一 `enqueue`)として実行するため、他操作がクエリ列の途中に割り込まない(`executeQuery` を個別に enqueue する旧実装では、Q1 と Q2 の間に別操作が挟まり得た)。各クエリには従来どおり 60 秒タイムアウトが適用される。なお DB レベルのトランザクション(原子性)ではないため、原子性が必要な場合は `createTableFromData` か明示的な BEGIN/COMMIT を用いる。
172
136
 
173
137
  ---
174
138
 
175
139
  ### 3.8 `createTableFromData<T>(tableName, data, options): Promise<void>`
176
140
 
177
- ```typescript
178
- public async createTableFromData<T extends Record<string, unknown>>(
179
- tableName: string,
180
- data: T[],
181
- options: {
182
- dropIfExists?: boolean
183
- primaryKey?: string
184
- verbose?: boolean
185
- } = {},
186
- ): Promise<void>
187
- ```
188
-
189
141
  JavaScript の配列データから DuckDB テーブルを作成する高レベルメソッド。
190
142
 
191
143
  #### 引数
192
144
 
193
145
  | 引数名 | 型 | 必須 | 説明 |
194
146
  |--------|------|------|------|
195
- | `tableName` | `string` | Yes | 作成するテーブル名 |
196
- | `data` | `T[]` | Yes | テーブルに投入するデータ配列(1行以上必須) |
197
- | `options.dropIfExists` | `boolean` | No | `true` の場合、既存テーブルを削除してから作成 |
198
- | `options.primaryKey` | `string` | No | 主キーとして設定するカラム名 |
199
- | `options.verbose` | `boolean` | No | ログ出力の有無(デフォルト: `true`) |
147
+ | `tableName` | `string` | Yes | 作成するテーブル名(識別子として安全にクォートされる) |
148
+ | `data` | `T[]` | Yes | 投入データ配列(1行以上必須) |
149
+ | `options.dropIfExists` | `boolean` | No | `true` で既存テーブルを削除してから作成 |
150
+ | `options.primaryKey` | `string` | No | 主キーに設定するカラム名 |
151
+ | `options.verbose` | `boolean` | No | **ログ出力の有無のみ**を制御(デフォルト `true`)。DB への追加クエリは発行しない |
200
152
 
201
153
  #### 処理フロー
202
154
 
203
155
  ```mermaid
204
156
  flowchart TD
205
- A["createTableFromData(tableName, data, options)"] --> B{data が空配列?}
206
- B -- Yes --> C["Error を throw"]
207
- B -- No --> D{operationFlags に\n同名テーブルが進行中?}
208
- D -- Yes --> E["スキップして return"]
209
- D -- No --> F["operationFlags にフラグをセット"]
210
- F --> G{dropIfExists === true?}
211
- G -- Yes --> H["DROP TABLE IF EXISTS を実行"]
212
- G -- No --> I1["SHOW TABLES(作成前の確認ログ)"]
213
- H --> I1
214
- I1 --> I2["型推定(inferColumnTypes)\n先頭100行→関数内部で先頭10行を使用"]
215
- I2 --> J["CREATE TABLE 文を生成・実行"]
216
- J --> J1["SHOW TABLES + information_schema\n(作成後の検証ログ)"]
217
- J1 --> K["データを1000行ずつバッチ INSERT\nformatValueForSQL で値を変換"]
218
- K --> L["operationFlags からフラグを削除(finally)"]
157
+ A["createTableFromData(...)"] --> B{data が空 (null / 長さ0)?}
158
+ B -- Yes --> C["Error: 'Data array is empty' を throw"]
159
+ B -- No --> D{operationFlags に同名の進行中 Promise?}
160
+ D -- Yes --> E["**その Promise を await して返す**\n(早期 void 解決しない)"]
161
+ D -- No --> F["buildTable を operationQueue に積み、\noperationFlags に Promise を登録"]
162
+ F --> G["await 完了 → finally で operationFlags を削除"]
219
163
  ```
220
164
 
221
- > **注意**: `SHOW TABLES` と `information_schema` による検証ステップは `verbose` が `true`(デフォルト)の場合にログ出力される。検証自体が失敗しても処理は続行される(`try/catch` で `warn` ログのみ)。
165
+ **v1.6.0 での重複排除の修正**: 同名テーブルへの同時呼び出しは、進行中の**同一 Promise を await** する。旧実装は2回目の呼び出しが「テーブル未完成のまま即 `void` 解決」し、呼び出し側が存在しない/部分的なテーブルを read してしまう危険があった。
166
+
167
+ #### `buildTable`(private)— トランザクションによる原子的構築
222
168
 
223
- #### 型推定ルール(`inferColumnTypes`)
169
+ ```mermaid
170
+ flowchart TD
171
+ A["列名 = 全行の和集合(collectColumnNames)"] --> B["型推定(先頭 100 行、inferColumnTypes)"]
172
+ B --> C["BEGIN TRANSACTION"]
173
+ C --> D{dropIfExists?}
174
+ D -- Yes --> E["DROP TABLE IF EXISTS"]
175
+ D -- No --> F["CREATE TABLE"]
176
+ E --> F
177
+ F --> G["1000 行ずつ INSERT(formatValueForSQL)"]
178
+ G --> H["COMMIT"]
179
+ H -.->|途中で失敗| I["ROLLBACK(失敗しても warn のみ)→ 元エラーを rethrow"]
180
+ ```
224
181
 
225
- `createTableFromData` は先頭100行を `inferColumnTypes` に渡すが、`inferColumnTypes` 内部でさらに `data.slice(0, Math.min(data.length, 10))` で先頭10行に絞り込む。したがって**実効的なサンプルサイズは最大10行**。
182
+ - **原子性**: DROP/CREATE/INSERT を1トランザクションにまとめ、途中失敗時は `ROLLBACK` する。**半端に作成されたテーブルを残さない**(旧実装は失敗時にデータ無し/部分ロードのテーブルが残った)。
183
+ - **列名は全行の和集合**(`collectColumnNames`)から決定する。旧実装は先頭行のキーのみを使い、後続行だけが持つ列を**無警告で欠落**させていた。CREATE と INSERT で同じ列集合を使う。
184
+ - **`null`/`undefined` 行**はすべて `NULL` として挿入する(不揃いデータへの耐性)。
185
+
186
+ #### 型推定ルール(`inferColumnTypes`、サンプル = 先頭 100 行)
226
187
 
227
188
  | JavaScript の値 | 推定される DuckDB 型 | 条件 |
228
189
  |----------------|---------------------|------|
229
- | `number`(整数) | `INTEGER` | `Number.isInteger()` が true かつ 32bit 範囲内 |
230
- | `number`(整数、大きい) | `BIGINT` | `Number.isInteger()` が true かつ 32bit 範囲外 |
231
- | `number`(小数) | `DOUBLE` | 整数でない number |
232
- | `boolean` | `BOOLEAN` | typeof === "boolean" |
190
+ | `number`(有限・整数) | `BIGINT` | `Number.isFinite` かつ `Number.isInteger`(桁あふれ回避のため一律 BIGINT) |
191
+ | `number`(有限・小数) | `DOUBLE` | 有限だが整数でない |
192
+ | `bigint` | `BIGINT` | `typeof === "bigint"` |
193
+ | `number`(非有限) | (数値扱いしない) | `NaN` / `Infinity` は文字列カウント(挿入時は NULL) |
194
+ | `boolean` | `BOOLEAN` | |
233
195
  | `Date` オブジェクト | `TIMESTAMP` | `instanceof Date` |
234
- | `string`(日付形式) | `TIMESTAMP` | `new Date(value)` が有効かつ "-" を含む |
235
- | `string`(その他) | `VARCHAR` | 上記以外 |
236
- | `null` / `undefined` のみ | `VARCHAR` | 値がすべて null/undefined の場合のフォールバック |
196
+ | `string`(**厳密**日付) | `TIMESTAMP` | `TIMESTAMP_PATTERN`(`YYYY-MM-DD[ T HH:MM...]`)に一致し、かつ `new Date` が有効 |
197
+ | その他 / 混在 | `VARCHAR` | 上記以外、値がすべて null、型が混在する場合のフォールバック |
198
+
199
+ > **v1.6.0 での修正**:
200
+ > - 整数は `INTEGER` ではなく **`BIGINT`** を採用し、int32 を超える値でのオーバーフローを解消。
201
+ > - TIMESTAMP 推定を厳密化。旧実装の「`new Date()` が有効 かつ `"-"` を含む」は緩すぎ、`ABC-123` のような文字列まで日付扱いして後続行のキャスト失敗を招いた。現在は厳密な日付/日時パターンに一致する場合のみ TIMESTAMP。
202
+ > - サンプルは実際に先頭 100 行を使用(旧実装は「100 行」と称しつつ内部で 10 行に絞る二重スライスのバグがあった)。
237
203
 
238
204
  #### SQL 値フォーマットルール(`formatValueForSQL`)
239
205
 
240
206
  | JavaScript の値 | SQL 出力 | 補足 |
241
207
  |----------------|---------|------|
242
208
  | `null` / `undefined` | `NULL` | |
243
- | `string` | `'エスケープ済み文字列'` | シングルクォートは `''` にエスケープ |
244
- | `Date` | `'2024-01-01T00:00:00.000Z'` | ISO 8601 形式 |
209
+ | `string` | `'エスケープ済み'` | シングルクォートを `''` にエスケープ |
210
+ | `Date` | `'2024-01-01T00:00:00.000Z'` | ISO 8601 |
245
211
  | `boolean` | `TRUE` / `FALSE` | |
246
- | `number` | `123` / `45.67` | そのまま文字列化 |
247
- | その他 | `String(value)` | |
212
+ | `bigint` | `123` | `toString()` |
213
+ | `number`(有限) | `123` / `45.67` | |
214
+ | `number`(非有限: NaN/Infinity) | `NULL` | 有効な SQL 数値リテラルでないため NULL 化 |
215
+ | オブジェクト / 配列 | `'{"x":1}'` / `'["a","b"]'` | `JSON.stringify` してクォート・エスケープ。stringify 不能(循環参照・`undefined`)は `NULL` |
248
216
 
249
- ---
217
+ > **v1.6.0 での修正**: 旧実装はオブジェクト/配列を `String(value)` で `[object Object]` や `a,b`(列数ずれ)に、非有限数を裸の `NaN`/`Infinity`(無効 SQL)に変換し、INSERT バッチ全体を失敗させ得た。
250
218
 
251
- ### 3.9 `getTableInfo(tableName: string): Promise<unknown>`
219
+ #### 識別子のクォート(`quoteIdentifier`)
252
220
 
253
- ```typescript
254
- public async getTableInfo(tableName: string): Promise<unknown>
255
- ```
256
-
257
- | 項目 | 内容 |
258
- |------|------|
259
- | 実行 SQL | `DESCRIBE {tableName}` |
260
- | 戻り値 | テーブルのカラム情報(名前、型、NULL 可否など) |
221
+ `tableName` / カラム名 / `primaryKey` はすべて `"..."` でクォートし、内部のダブルクォートを `""` にエスケープする。空文字列や文字列以外の識別子は `Error("Invalid SQL identifier")` を投げる。旧実装は `tableName` を無クォートで DDL/DML に、`information_schema` の `WHERE table_name = '...'` に**文字列リテラルとして生挿入**しており、スペース/予約語/クォートを含む名前で破綻し、リテラル注入点も存在した(診断クエリ自体も v1.6.0 で撤去)。
261
222
 
262
223
  ---
263
224
 
264
- ### 3.10 `listTables(): Promise<unknown>`
225
+ ### 3.9 `getTableInfo(tableName: string): Promise<unknown>`
265
226
 
266
- ```typescript
267
- public async listTables(): Promise<unknown>
268
- ```
227
+ `DESCRIBE "<tableName>"`(クォート済み)を `operationQueue` 経由で実行し、カラム情報を返す。
269
228
 
270
- | 項目 | 内容 |
271
- |------|------|
272
- | 実行 SQL | `SHOW TABLES` |
273
- | 戻り値 | 現在のデータベースに存在するテーブル一覧 |
229
+ ### 3.10 `listTables(): Promise<unknown>`
274
230
 
275
- ---
231
+ `SHOW TABLES` を `operationQueue` 経由で実行し、テーブル一覧を返す。
276
232
 
277
233
  ### 3.11 `isReady(): boolean`
278
234
 
279
- ```typescript
280
- public isReady(): boolean
281
- ```
282
-
283
- | 項目 | 内容 |
284
- |------|------|
285
- | 戻り値 | `workerInstance !== null` の場合 `true` |
286
- | 用途 | 同期的に初期化完了を確認したい場合 |
287
-
288
- ---
235
+ `workerInstance !== null` を返す。§3.2 のとおり失敗時は `null` にリセットされるため `getStatus()` と整合する。
289
236
 
290
237
  ### 3.12 `cleanup(): Promise<void>`
291
238
 
292
- ```typescript
293
- public async cleanup(): Promise<void>
294
- ```
295
-
296
- リソースの完全な解放を行う。
297
-
298
239
  | ステップ | 処理 |
299
240
  |---------|------|
300
241
  | 1 | コネクションを close |
301
242
  | 2 | Worker を terminate |
302
- | 3 | すべての内部状態をリセット |
303
- | 4 | リスナーと operationFlags をクリア |
304
-
305
- > **注意**: cleanup 後に再度 `initialize()` を呼べば再初期化できる。
243
+ | 3 | `pendingWorker`/`pendingWorkerUrl` を破棄(`abortPendingWorker`) |
244
+ | 4 | 全内部状態(`isInitializing`/`hasError`/`initPromise`/`connectionPromise`/`operationQueue`/`operationFlags`)をリセット |
245
+ | 5 | リスナーに通知してから `listeners` をクリア |
306
246
 
307
- ---
247
+ teardown 中に例外が出ても `Logger.error` でログするのみで throw しない。cleanup 後に `initialize()` を再度呼べば再初期化できる。
308
248
 
309
249
  ### 3.13 `resetInstance(): void` (static)
310
250
 
311
- ```typescript
312
- public static resetInstance(): void
313
- ```
314
-
315
- | 項目 | 内容 |
316
- |------|------|
317
- | 用途 | テスト時にシングルトンをリセットする |
318
- | 動作 | `cleanup()` を呼び出し(**await しない**)、直後に `instance = null` にする |
319
-
320
- > **注意**: `cleanup()` は `async` メソッドだが、`resetInstance()` は同期メソッド(`void`)であり `cleanup()` を await しない。コネクションの close や Worker の terminate が完了する前にインスタンスが `null` になる。テストで確実にリソースを解放したい場合は、`resetInstance()` の前に `await DuckDBService.getInstance().cleanup()` を明示的に呼ぶこと。
321
-
322
- ---
323
-
324
- ### 3.14 ステータスリスナー
325
-
326
- ```typescript
327
- public addStatusListener(listener: () => void): void
328
- public removeStatusListener(listener: () => void): void
329
- ```
330
-
331
- | メソッド | 説明 |
332
- |---------|------|
333
- | `addStatusListener` | ステータス変更時に呼ばれるコールバックを登録 |
334
- | `removeStatusListener` | 登録済みコールバックを削除 |
335
-
336
- リスナーが呼ばれるタイミング:
337
- - `initialize()` 開始時(→ `"initializing"`)
338
- - `initialize()` 成功時(→ `"ready"`)
339
- - `initialize()` 失敗時(→ `"error"`)
340
-
341
- ## 4. エクスポート
342
-
343
- `services/duckdb.ts` のエクスポート:
344
-
345
- ```typescript
346
- // 名前付きエクスポート(クラス本体)
347
- export { DuckDBService }
348
-
349
- // デフォルトエクスポート(シングルトンインスタンス)— services/duckdb.ts 内でのみ default
350
- export default DuckDBService.getInstance()
351
- ```
352
-
353
- `index.ts` での re-export:
354
-
355
- ```typescript
356
- export { DuckDBService, default as duckDBService } from "./services/duckdb"
357
- ```
358
-
359
- > **注意**: `services/duckdb.ts` はデフォルトエクスポートを持つが、`index.ts` はそれを `duckDBService` という**名前付きエクスポート**として re-export する。パッケージ自体にデフォルトエクスポートは存在しない。
360
-
361
- | エクスポート | インポート方法 | 用途 |
362
- |------------|--------------|------|
363
- | `DuckDBService`(クラス) | `import { DuckDBService } from '...'` | `getInstance()` や `resetInstance()` を呼びたい場合 |
364
- | `duckDBService`(インスタンス) | `import { duckDBService } from '...'` | 通常利用。シングルトンインスタンスを直接使う |
365
-
366
- ## 5. ステータス遷移図
367
-
368
- ```mermaid
369
- stateDiagram-v2
370
- state "not-initialized" as not_init
371
- [*] --> not_init
372
- not_init --> initializing : initialize()
373
- initializing --> ready : 成功
374
- initializing --> error : 失敗
375
- ready --> not_init : cleanup()
376
- error --> initializing : initialize()(リトライ)
377
- error --> not_init : cleanup()
378
- ```
251
+ `cleanup()` を呼び出し(**await しない**)、直後に `instance = null` にする。主にテスト用。`instance` が既に `null` の場合は何もしない。
379
252
 
380
- ## 6. 新人向けポイント
253
+ ## 4. 内部定数
381
254
 
382
- 1. **`import { duckDBService } from '@aiquants/duckdb-helper'` が最も簡単な使い方**。シングルトンインスタンスがそのまま使える。
383
- 2. **`initialize()` は何回呼んでも安全**。既に初期化済みならすぐ返る。初期化中なら同じ Promise を返す。
384
- 3. **コネクションは自動で再利用される**。`getConnection()` を何度呼んでも、内部では1つのコネクションが使い回される。
385
- 4. **`createTableFromData` が便利**。JavaScript の配列をそのまま DuckDB テーブルにできる。型推定も自動。
386
- 5. **テスト時は `DuckDBService.resetInstance()` でリセット**できる。テスト間の状態汚染を防げる。
387
- 6. **splink_udfs の失敗は無視される**。名寄せ機能が不要な環境でもエラーにならない。
255
+ | 定数 | 値 | 用途 |
256
+ |------|------|------|
257
+ | `INIT_TIMEOUT_MS` | `120000` | 初期化タイムアウト |
258
+ | `QUERY_TIMEOUT_MS` | `60000` | クエリタイムアウト |
259
+ | `BATCH_SIZE` | `1000` | バッチ INSERT の行数 |
260
+ | `TYPE_INFERENCE_SAMPLE_SIZE` | `100` | 型推定のサンプル行数 |
261
+ | `TIMESTAMP_PATTERN` | 正規表現 | 文字列を TIMESTAMP とみなす厳密パターン |
@@ -63,36 +63,21 @@ export interface UseDuckDBResult {
63
63
 
64
64
  ### 2.4 内部動作の詳細
65
65
 
66
- #### ステータス同期の仕組み
66
+ #### ステータス同期の仕組み(v1.6.0 で push 型に一本化)
67
67
 
68
- `useDuckDB` は2つの方法で `DuckDBService` のステータスを React の状態に同期する。
68
+ `useDuckDB` は**ステータスリスナー(push 型)のみ**で `DuckDBService` のステータスを React の状態に同期する。
69
69
 
70
- **方法1: ステータスリスナー**
71
70
  ```
72
71
  DuckDBService
73
- ├── addStatusListener(updateStatus) ← マウント時に登録
72
+ ├── addStatusListener(updateStatus) ← マウント時に登録し、updateStatus() を1回即時実行して初期値を取得
74
73
  └── removeStatusListener(updateStatus) ← アンマウント時に解除
75
74
  ```
76
75
 
77
- **方法2: ポーリング(フォールバック)**
78
- ```
79
- setInterval(pollStatus, 1000) ← 1秒ごとにステータスをチェック
80
- ```
76
+ `DuckDBService` は状態遷移(initializing / ready / error)と `cleanup()` のたびに必ず `notifyListeners()` を呼ぶため、リスナーだけで全遷移を漏れなく受け取れる。
81
77
 
82
- > **なぜ2つの方法があるのか**: リスナーだけではステータス変更が漏れる可能性がある(例: 別のコンポーネントから直接サービスを操作した場合)。ポーリングは保険として機能する。
78
+ > **v1.6.0 での変更(1秒ポーリングの撤去)**: 旧実装は上記リスナーに加えて `setInterval(pollStatus, 1000)` を各フックインスタンスに常設していた。`useDuckDBQuery` は内部で `useDuckDB()` を呼ぶため、`root.tsx` のアプリ全体マウントと合わせて **消費者ごとに永久 1 秒タイマー** が積み上がり、しかも push リスナーと完全に重複していた。リスナーで全遷移が届く以上ポーリングは不要と判断し、effect ごと削除した。
83
79
 
84
- **ポーリングのガード条件**(2つの条件を AND で結合):
85
- ```typescript
86
- if (currentStatus !== status && currentStatus !== "initializing") {
87
- setStatus(currentStatus)
88
- }
89
- ```
90
- 1. `currentStatus !== status` — サービスの現在のステータスが React state と異なる場合のみ更新
91
- 2. `currentStatus !== "initializing"` — サービスが `"initializing"` を返す場合は更新しない
92
-
93
- 両方を満たした場合のみ React state が更新される。`"initializing"` への遷移はリスナー経由でのみ通知される。
94
-
95
- **SSR 環境での動作**: `typeof window === "undefined"` の場合(SSR / Node.js 環境)、`setInterval` は設定されず、`pollStatus` を1回だけ同期的に実行してから effect を終了する。ブラウザ環境でも `setInterval` 設定直後に `pollStatus()` が1回即時実行される。
80
+ **SSR 環境での動作**: リスナー登録は `useEffect` 内で行われ、SSR(サーバー)では effect が実行されないため副作用は発生しない。初期レンダリングの `status` は `"not-initialized"` となる。
96
81
 
97
82
  #### 自動初期化フロー
98
83
 
@@ -224,6 +209,10 @@ flowchart TD
224
209
  3. `isReady` が `false` → `true` に変わった(初期化完了)
225
210
  4. `refetch()` を手動で呼んだ
226
211
 
212
+ > **`data` は生の Arrow Table**: `data` は `executeQuery()` の戻り値そのもの(Apache Arrow Table)であり、**行配列ではない**。`.length` や `.map` は持たないため、行として扱うときは必ず `duckdbTableToArray<Row>(data)` で変換すること(型パラメータ `T` には行の型ではなく Arrow Table 相当の型を想定する)。単一クエリの型付き行配列がほしい場合は `useDuckDB().executeQuery()` + `duckdbTableToArray()` の組み合わせが明快。
213
+
214
+ > **`dependencies` の扱い(v1.6.0)**: 内部では可変長の `dependencies` を effect の依存配列へ直接スプレッドせず、`Object.is` 比較で変化を検知する**固定サイズのバージョン番号**(`useDependencyVersion`)に変換して `[refetch, version]` を依存にする。これにより、レンダー間で `dependencies` の**長さが変わっても** React の「依存配列サイズ変更」エラーを起こさず、要素の変化(React と同じ参照比較)で再フェッチされる。
215
+
227
216
  ### 3.6 使用パターン
228
217
 
229
218
  #### パターン1: 基本的なデータ取得
@@ -139,9 +139,7 @@ private formatMessage(message: unknown): unknown[] {
139
139
 
140
140
  ### 開発中(すべてのログを表示)
141
141
 
142
- > **重要**: `Logger` と `LogLevel` は `index.ts` から公開エクスポートされておらず、`package.json` の `exports` にもサブパスが定義されていないため、**現状では利用側から直接インポートできない**。ログレベルを変更するには、パッケージ側で `index.ts` にエクスポートを追加する必要がある(追加方法は[開発ガイド](../guides/2026.04.11%20%5BAI%5D%20development.md)の「Q: Logger を公開 API にしたい」を参照)。
143
-
144
- パッケージ側でエクスポートが追加された場合の使用例:
142
+ > **v1.6.0 で公開**: `Logger` / `LogLevel` / `ILogger` は `index.ts` から公開エクスポートされるようになった。利用側から直接インポートしてログレベルを制御できる(旧バージョンではエクスポートされておらず、初期化ログを有効化する手段が無かった)。
145
143
 
146
144
  ```typescript
147
145
  import { Logger, LogLevel } from '@aiquants/duckdb-helper'
@@ -176,8 +174,8 @@ Logger.setImplementation(customLogger)
176
174
 
177
175
  ## 7. 注意事項
178
176
 
179
- 1. **Logger / LogLevel は `index.ts` から公開エクスポートされていない**。ライブラリ内部用の位置づけ。`package.json` の `exports` にもサブパスが未定義のため、`@aiquants/duckdb-helper/utils/simple-logger` のような直接パスインポートも動作しない。利用側からログレベルを変更するにはパッケージのエクスポートを拡張する必要がある。
180
- 2. **デフォルトレベルは `WARN`**。つまり `DuckDBService` 内の `Logger.info(...)` による初期化ログは通常表示されない。デバッグ時にログを見たい場合は `Logger.setLevel(LogLevel.DEBUG)` を設定する。
177
+ 1. **Logger / LogLevel / ILogger は `index.ts` から公開エクスポートされている(v1.6.0〜)**。`import { Logger, LogLevel } from '@aiquants/duckdb-helper'` で利用でき、`Logger.setLevel()` などでログレベルや実装を制御できる。
178
+ 2. **デフォルトレベルは `WARN`**。つまり `DuckDBService` 内の `Logger.info(...)` による初期化ログは通常表示されない。ただし初期化の**失敗**(`Logger.error`)は WARN でも表示される。詳細なステップを見たい場合は `Logger.setLevel(LogLevel.DEBUG)` を設定する。
181
179
  3. **リスナーでの例外はキャッチされる**。`DuckDBService.notifyListeners()` 内でリスナーが例外を投げた場合、`Logger.error` でログに出力されるが、他のリスナーへの通知は続行される。
182
180
 
183
181
  ## 8. 新人向けポイント
@@ -121,7 +121,6 @@ export default defineConfig({
121
121
  | `build:watch` | `tsup --watch` | ファイル変更時に自動リビルド |
122
122
  | `dev` | `tsup --watch` | 開発用(build:watch と同一) |
123
123
  | `watch` | `tsup --watch` | ウォッチモード |
124
- | `type-check` | `tsc --noEmit` | 型チェックのみ実行 |
125
124
  | `clean` | `rimraf dist` | ビルド成果物を削除 |
126
125
  | `prepare` | `pnpm run build` | パッケージインストール後に自動ビルド |
127
126
  | `prepublishOnly` | clean → typecheck → test(`--if-present`) → build | 公開前の品質チェック |
@@ -131,9 +130,10 @@ export default defineConfig({
131
130
  | `format:fix` | `biome format --write src/` | Biome によるフォーマット自動修正 |
132
131
  | `check` | `biome check src/` | Lint + フォーマットの同時チェック |
133
132
  | `check:fix` | `biome check --write src/` | Lint + フォーマットの同時自動修正 |
134
- | `typecheck` | `npm run type-check` | `type-check` のエイリアス |
133
+ | `typecheck` | `tsc --noEmit` | 本体(`src`)の型チェック |
134
+ | `typecheck:test` | `tsc --noEmit -p tsconfig.test.json` | テストを含む型チェック(v1.6.0〜) |
135
135
  | `test` | `vitest run` | テスト実行 |
136
- | `test:coverage` | `vitest run --coverage` | カバレッジ付きテスト |
136
+ | `test:coverage` | `vitest run --coverage` | カバレッジ付きテスト(閾値 100%) |
137
137
  | `license-check` | `pnpm dlx license-checker ...` | 許可ライセンスのチェック |
138
138
  | `license-check:json` | `pnpm dlx license-checker ... --json` | ライセンスチェック(JSON 出力) |
139
139
  | `publish:patch` | version patch → publish | パッチバージョン公開 |
@@ -148,6 +148,24 @@ export default defineConfig({
148
148
 
149
149
  npm に公開されるファイル。`src/` はパッケージに含まれない。
150
150
 
151
+ ## 4.5 テスト設定(v1.6.0〜)
152
+
153
+ | ファイル | 役割 |
154
+ |---------|------|
155
+ | `vitest.config.ts` | Vitest 設定。`environment: "jsdom"`、`globals: true`、カバレッジは `v8` プロバイダで `src/**`(`src/types` 除外)を対象に閾値 100%(lines / functions / branches / statements) |
156
+ | `tsconfig.test.json` | `tsconfig.json` を継承し `tests/**` を含めた型チェック用設定 |
157
+
158
+ ### テスト関連 devDependencies
159
+
160
+ | パッケージ | 用途 |
161
+ |-----------|------|
162
+ | `vitest` | テストランナー |
163
+ | `@vitest/coverage-v8` | V8 カバレッジプロバイダ |
164
+ | `jsdom` | ブラウザ相当の DOM 環境(`window` / `URL` / React フックのレンダリング) |
165
+ | `@testing-library/react`, `@testing-library/dom` | React フックのテスト(`renderHook` 等) |
166
+
167
+ > テストでは `@duckdb/duckdb-wasm` を `vi.mock` で制御可能なフェイクに差し替え、`Worker` / `URL.createObjectURL` / `Blob` をスタブすることで、実際の WASM ダウンロードや Worker 生成なしに全経路を検証する。詳細は[テスト仕様書](./2026.07.05%20%5BAI%5D%2007-testing.md)を参照。
168
+
151
169
  ## 5. ビルド成果物の構成
152
170
 
153
171
  ```
@@ -164,7 +182,7 @@ dist/
164
182
 
165
183
  1. **`pnpm run build` でビルド**。`dist/` にファイルが生成される。
166
184
  2. **`pnpm run dev` で開発中はウォッチモード**を使うと、ソース変更が自動で反映される。
167
- 3. **`pnpm run type-check` で型エラーだけ確認**できる。ビルドせずに素早くチェック。
185
+ 3. **`pnpm run typecheck` で型エラーだけ確認**できる。ビルドせずに素早くチェック。
168
186
  4. **`external` に指定されたパッケージはバンドルに含まれない**。利用側でインストールが必要。
169
187
  5. **ビルドは tsup(esbuild ベース)が行い、tsc は型チェック専用**。この分業により高速ビルドと厳密な型チェックを両立。
170
188
  6. **`src/` は npm パッケージに含まれない**。利用者が見るのは `dist/` のビルド済みコードのみ。