@h-ai/reldb 0.1.0-alpha8 → 1.0.0-beta.1

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.
package/README.ja.md ADDED
@@ -0,0 +1,661 @@
1
+ # @h-ai/reldb
2
+
3
+ [简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
4
+
5
+ [フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/core](../core/README.ja.md)
6
+
7
+ ## 概要
8
+
9
+ 共通の `reldb` オブジェクトで SQLite、PostgreSQL、MySQL を操作するリレーショナルデータベースモジュールです。
10
+
11
+ 接続はヘルスチェック成功後に公開します。初期化失敗時は候補リソースを解放し、解放にも失敗した場合は初期化エラーの `error.ext.cleanupPending` が `true` になります。`reldb.close()` は `Promise<HaiResult<void>>` を返し、初期化を待機して並行する終了処理をまとめます。解放開始後は `isInitialized` が `false` となり、設定と操作は利用できません。解放に失敗したリソースは保持され、次の `close()` または `init()` で再試行します。解放が成功するまで代替接続は作成しません。終了結果を必ず確認してください。
12
+
13
+ ```ts
14
+ const closed = await reldb.close()
15
+ if (!closed.success) {
16
+ // 障害への対処後に一度再試行し、再失敗はアプリへ報告します。
17
+ const retried = await reldb.close()
18
+ if (!retried.success)
19
+ throw new Error(retried.error.message)
20
+ }
21
+ ```
22
+
23
+ 終了処理は既存トランザクションのコミット/ロールバックと解放を待ちます。アプリはトランザクションを終了させてください。SQLite の WAL 設定と worker のハンドシェイクも初期化リソースとして管理します。PostgreSQL の切断済みアイドル接続はプールが除去し、次のクエリで置き換えます。プロセスは終了せず、ログには安全なエラー識別子のみを記録します。
24
+
25
+ ## 主な機能
26
+
27
+ - 共通 API で SQLite・PostgreSQL・MySQL に接続。
28
+ - パラメーター付き SQL、テーブル・索引管理、ページ取得。
29
+ - トランザクション、CRUD、Repository による業務の読み書き。
30
+
31
+ ## 使い始める
32
+
33
+ ### クイックスタート
34
+
35
+ ```ts
36
+ import { HaiReldbError, reldb } from '@h-ai/reldb'
37
+
38
+ // 初始化
39
+ const initialized = await reldb.init({ type: 'sqlite', database: ':memory:' })
40
+ if (!initialized.success)
41
+ throw new Error(initialized.error.message)
42
+
43
+ // DDL
44
+ await reldb.ddl.createTable('users', {
45
+ id: { type: 'INTEGER', primaryKey: true, autoIncrement: true },
46
+ name: { type: 'TEXT', notNull: true },
47
+ email: { type: 'TEXT', unique: true },
48
+ })
49
+
50
+ // SQL
51
+ await reldb.sql.execute('INSERT INTO users (name, email) VALUES (?, ?)', ['张三', 'test@example.com'])
52
+ const users = await reldb.sql.query<{ id: number, name: string }>('SELECT * FROM users')
53
+
54
+ // 分页查询
55
+ const page = await reldb.sql.queryPage<{ id: number, name: string }>({
56
+ sql: 'SELECT id, name FROM users ORDER BY id',
57
+ pagination: { page: 1, pageSize: 20 },
58
+ })
59
+
60
+ // CRUD
61
+ const userCrud = reldb.crud.table({
62
+ table: 'users',
63
+ idColumn: 'id',
64
+ select: ['id', 'name', 'email'],
65
+ createColumns: ['name', 'email'],
66
+ updateColumns: ['name', 'email'],
67
+ dbType: 'sqlite',
68
+ })
69
+ await userCrud.create({ name: '李四', email: 'li@test.com' })
70
+ const user = await userCrud.findById(1)
71
+
72
+ // 关闭
73
+ await reldb.close()
74
+ ```
75
+
76
+ `reldb.config` は**機密情報をマスクした設定スナップショット**を返します。DB の `password`、URL 内の認証情報、TLS クライアント秘密鍵 `ssl.key` を非表示にします。状態確認用であり、元の接続情報の再利用には使わないでください。
77
+
78
+ ### 設定
79
+
80
+ `@h-ai/reldb/schemas` は同じ設定 Schema と型を Zod のみで公開し、DB インスタンスやドライバーを読み込みません。CLI やブラウザーで設定検証に利用できます。MySQL TLS は `false` または厳密なオブジェクトのみで、`{}` は証明書チェーンと DNS ID を検証します。PostgreSQL の真偽値・文字列モード・オブジェクト契約は維持します。
81
+
82
+ ```ts
83
+ import { ReldbConfigSchema } from '@h-ai/reldb/schemas'
84
+
85
+ const checked = ReldbConfigSchema.safeParse({ type: 'mysql', database: 'shop', ssl: {} })
86
+ if (!checked.success)
87
+ throw new Error('Invalid database configuration')
88
+ // checked.data.ssl: { rejectUnauthorized: true, verifyIdentity: true }
89
+ ```
90
+
91
+ 接続文字列(`url`)または個別フィールド(`host/port/database/user/password`)を指定できます。
92
+
93
+ ```ts
94
+ // SQLite
95
+ await reldb.init({ type: 'sqlite', database: './data.db' })
96
+
97
+ // PostgreSQL
98
+ await reldb.init({ type: 'postgresql', url: 'postgres://user:pass@localhost:5432/mydb', database: 'mydb' })
99
+
100
+ // MySQL
101
+ await reldb.init({
102
+ type: 'mysql',
103
+ host: 'localhost',
104
+ port: 3306,
105
+ database: 'mydb',
106
+ user: 'admin',
107
+ password: 'secret',
108
+ mysql: { charset: 'utf8mb4' },
109
+ pool: { max: 10, acquireTimeout: 5000, queueLimit: 100 },
110
+ })
111
+ ```
112
+
113
+ #### 接続プールと MySQL URL
114
+
115
+ MySQL の `pool` は `max`、`acquireTimeout`、`queueLimit` のみを受け付け、省略時もそれぞれ 10、10000 ミリ秒、1000 を使用します。`min`、`idleTimeout` などの未対応フィールドと未実装の `mysql.timezone` は設定検証で拒否します。アイドル接続は mysql2/サーバーによる切断または `close()` で解放し、最小保持数や設定可能なアイドル回収期限は保証しません。[mysql2 3.24.5 の設定](https://github.com/sidorares/node-mysql2/blob/v3.24.5/typings/mysql/lib/Pool.d.ts)は PostgreSQL と異なります。
116
+
117
+ | フィールド | PostgreSQL(`pool` 指定時の既定値) | MySQL |
118
+ | ---------------- | ----------------------------------- | -------------------------------------- |
119
+ | `max` | 10 | 10、借用中と接続確立中の合計上限 |
120
+ | `min` | 1、既存接続の保持下限。事前作成なし | 未対応、拒否 |
121
+ | `idleTimeout` | 30000 ミリ秒、0 は無効化 | 未対応、拒否 |
122
+ | `acquireTimeout` | 10000 ミリ秒、0 は無効化 | 10000 ミリ秒、1–2147483647 の整数 |
123
+ | `queueLimit` | 未対応 | 1000、待機リクエスト数の上限、正の整数 |
124
+
125
+ PostgreSQL で `pool` を省略した場合は pg の既定値を使用します。保持と回収の仕様は [pg.Pool](https://node-postgres.com/apis/pool) を参照してください。MySQL の SQL、DDL、CRUD、バッチ、トランザクションは共通の有限 FIFO 待機キューを使います。待機と接続確立に一つの期限を適用し、超過時は `TIMEOUT`、満杯時は `POOL_EXHAUSTED` を返します。期限切れ項目は削除し、遅れて取得した接続は SQL を実行せず解放します。確立処理が完了するまで容量枠を保持します。受領時と処理開始前にも期限を確認しますが、開始済み SQL/トランザクションの実行時間は制限しません。
126
+
127
+ MySQL の `close()` は新規・待機リクエストを直ちに拒否し、借用接続、トランザクション、接続確立の完了後にプールを閉じます。`connectTimeout` にも同じ値を使うため、その期限や既存処理を待つ場合があります。アプリはトランザクションを終了してください。環境変数は `HAI_DB_POOL_*` です。`MIN` / `IDLETIMEOUT` は PostgreSQL 専用で、MySQL の `QUEUELIMIT` は待機総数を制限します。
128
+
129
+ MySQL は `mysql://` の `url` だけでも接続でき、URL がなければ `database` が必須です。URL の host/port/database/user/password を優先し、競合する個別フィールドは接続に使用しません。`pool`、`mysql.charset`、`ssl`、`operationLog` は独立して適用します。query/fragment は拒否し、URL 内の隠れた設定で TLS やプールを変更できません。接続ログの URL は機密情報を伏せます。以下は `HAI_DB_URL` の設定を前提とします:
130
+
131
+ ```ts
132
+ const initialized = await reldb.init({ type: 'mysql', url: process.env.HAI_DB_URL, pool: { max: 10, acquireTimeout: 5000, queueLimit: 100 } })
133
+ if (!initialized.success)
134
+ throw new Error(initialized.error.message)
135
+ ```
136
+
137
+ MySQL の空 `url`(空の環境変数を含む)は host/port/database/user/password を使用します。空でない URL は厳密に検証します。
138
+
139
+ #### MySQL TLS
140
+
141
+ `ssl` を省略するか `false` にすると TLS を使用しません。有効化には厳密なオブジェクトを指定し、空オブジェクトの既定値は `{ rejectUnauthorized: true, verifyIdentity: true }` です。`ca` は信頼する CA の PEM 内容で、省略時は Node の信頼ストアを使います。任意のクライアント `cert` / `key` も PEM 内容で、必ず対で指定します。未知の TLS フィールド、`true`、PostgreSQL の文字列モードは拒否し、任意の設定やコールバックは透過しません。
142
+
143
+ [mysql2 3.24.5 の主機名検証](https://github.com/sidorares/node-mysql2/blob/v3.24.5/lib/base/connection.js)は DNS servername に依存します。証明書と名前の両方を検証する場合、IP 接続先は `CONFIG_ERROR` とし、証明書の DNS SAN に一致するホスト名を使います。明示的な `verifyIdentity: false` は証明書チェーンのみを検証します。`rejectUnauthorized: false` はサーバー認証を放棄し、暗号化だけでは相手の身元を確認できません。どちらかの検証を無効にすると固定のリスク警告を記録し、安全な構成とは扱いません。
144
+
145
+ ```ts
146
+ // trustedCaPem は配置時の信頼設定から供給し、任意の接続先証明書をそのまま信頼しません。
147
+ const initialized = await reldb.init({ type: 'mysql', host: 'db.example.com', database: 'app', ssl: { ca: trustedCaPem } })
148
+ if (!initialized.success)
149
+ throw new Error(initialized.error.message)
150
+ ```
151
+
152
+ TLS テストには OpenSSL(Windows は Git 同梱版も可)とローカルのコンテナー転送が必要です。各実行で CA/秘密鍵を生成・削除し、信頼済み DNS、誤った CA/主機名の拒否、`REQUIRE X509` 認証、実際の `Ssl_cipher` を検証します。検証無効の接続を認証済み TLS の証拠にはしません。
153
+
154
+ #### SQLite の実行方式(非同期 worker)
155
+
156
+ better-sqlite3 は同期ドライバーなので、クエリは Node のメインイベントループをブロックします。WAL は DB ロックの並行性を改善しますが、同期呼び出しによるブロックは解消しません。専用 worker スレッドで SQLite を実行して処理を移せます(単一接続、任意設定、既定は `sync`):
157
+
158
+ ```ts
159
+ await reldb.init({
160
+ type: 'sqlite',
161
+ database: './data.db',
162
+ sqlite: {
163
+ executor: 'worker', // 'sync'(默认)| 'worker'
164
+ walMode: true, // 默认 true
165
+ workerConnectTimeoutMs: 10000, // worker 打开连接超时,默认 10000
166
+ },
167
+ })
168
+ ```
169
+
170
+ worker モードではクエリ、書き込み、トランザクションを worker スレッドで順番に実行します。公開 API と戻り値は同期モードと同じです。
171
+
172
+ #### 操作ログ
173
+
174
+ DB 設定の `operationLog` で SQL 操作ログを有効にできます。ログは reldb provider-base の DML 層で出力され、`reldb-main.ts` や各 raw provider で重複してラップしません。
175
+
176
+ ```yaml
177
+ # config/_db.yml
178
+ type: sqlite
179
+ database: ./data/app.db
180
+ operationLog:
181
+ read: false # query / get / queryPage
182
+ write: false # DDL, execute / batch, tx.begin / tx.commit / tx.rollback
183
+ maxLength: 1000 # 安全な件数サマリーの最大長。0は省略。SQL本文と値は記録しない
184
+ level: debug # info | debug | trace,默认 debug
185
+ ```
186
+
187
+ `core.config.load('db', ...)` で読み込む場合、`HAI_DB_TYPE`、
188
+ `HAI_DB_DATABASE`、`HAI_DB_OPERATIONLOG_LEVEL` などの規約に沿う変数が YAML より優先されます。
189
+
190
+ ```ts
191
+ await reldb.init({
192
+ type: 'sqlite',
193
+ database: './data.db',
194
+ operationLog: {
195
+ read: true,
196
+ write: true,
197
+ maxLength: 500,
198
+ level: 'debug',
199
+ },
200
+ })
201
+ ```
202
+
203
+ 操作ログには固定の操作名、SQL 長、パラメーター数、バッチ件数を記録します。失敗時は、エラーコード、traceId、許可リストに含まれるマスキング済みのドライバーメッセージとスタックも保持します。SQL 全文、バインド値、接続設定をそのまま記録することはありません。
204
+
205
+ ### トランザクション
206
+
207
+ 3 種類の使用方法:
208
+
209
+ #### wrap(自動コミット/ロールバック)
210
+
211
+ コールバックが正常に戻ると、失敗した HaiResult を返した場合もコミットされます。各操作の結果を確認し、失敗時は例外を投げてロールバックしてください。wrap は TRANSACTION_FAILED を返します。元の業務エラーを保つ場合は下記の手動トランザクションを使用します。各例は初期化が成功し、users テーブルが作成済みであることを前提とします。
212
+
213
+ ```ts
214
+ const result = await reldb.tx.wrap(async (tx) => {
215
+ for (const name of ['Alice', 'Bob']) {
216
+ const inserted = await tx.execute('INSERT INTO users (name) VALUES (?)', [name])
217
+ if (!inserted.success)
218
+ throw new Error(inserted.error.message, { cause: inserted.error })
219
+ }
220
+ return 'ok'
221
+ })
222
+ // 回调正常返回 → 自动提交;抛异常 → 自动回滚
223
+ ```
224
+
225
+ #### begin + commit(手動コミット)
226
+
227
+ ```ts
228
+ // 放在 async 业务函数内,失败时向调用方返回 HaiResult。
229
+ const txResult = await reldb.tx.begin()
230
+ if (!txResult.success)
231
+ return txResult
232
+ const tx = txResult.data
233
+ try {
234
+ for (const name of ['Alice', 'Bob']) {
235
+ const inserted = await tx.execute('INSERT INTO users (name) VALUES (?)', [name])
236
+ if (!inserted.success) {
237
+ const rolledBack = await tx.rollback()
238
+ return rolledBack.success ? inserted : rolledBack
239
+ }
240
+ }
241
+ return await tx.commit()
242
+ }
243
+ catch (error) {
244
+ const rolledBack = await tx.rollback()
245
+ if (!rolledBack.success)
246
+ return rolledBack
247
+ throw error // 只传播非业务异常,由宿主边界处理。
248
+ }
249
+ ```
250
+
251
+ #### begin + rollback(手動ロールバック)
252
+
253
+ ```ts
254
+ // 独立脚本:试写后回滚,不保留用户数据。
255
+ const txResult = await reldb.tx.begin()
256
+ if (!txResult.success)
257
+ throw new Error(txResult.error.message, { cause: txResult.error })
258
+ const tx = txResult.data
259
+ // 两个 API 均返回 HaiResult;同时失败时保留执行和回滚两份原因。
260
+ const inserted = await tx.execute('INSERT INTO users (name) VALUES (?)', ['Alice'])
261
+ const rolledBack = await tx.rollback()
262
+ if (!inserted.success && !rolledBack.success)
263
+ throw new AggregateError([inserted.error, rolledBack.error], rolledBack.error.message)
264
+ if (!inserted.success)
265
+ throw new Error(inserted.error.message, { cause: inserted.error })
266
+ if (!rolledBack.success)
267
+ throw new Error(rolledBack.error.message, { cause: rolledBack.error })
268
+ ```
269
+
270
+ #### トランザクション内での batch / queryPage
271
+
272
+ ```ts
273
+ await reldb.tx.wrap(async (tx) => {
274
+ const inserted = await tx.batch([
275
+ { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Alice'] },
276
+ { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Bob'] },
277
+ ])
278
+ if (!inserted.success)
279
+ throw new Error(inserted.error.message, { cause: inserted.error })
280
+
281
+ const page = await tx.queryPage<{ id: number, name: string }>({
282
+ sql: 'SELECT id, name FROM users ORDER BY id',
283
+ pagination: { page: 1, pageSize: 10 },
284
+ })
285
+ if (!page.success)
286
+ throw new Error(page.error.message, { cause: page.error })
287
+ return page.data
288
+ })
289
+ ```
290
+
291
+ ### Crud
292
+
293
+ `reldb.crud.table()` で単一テーブルの CRUD リポジトリーを作成できます。SQL の記述は不要です:
294
+
295
+ ```ts
296
+ const userCrud = reldb.crud.table<{ id: number, name: string, email: string }>({
297
+ table: 'users',
298
+ idColumn: 'id', // 主键列,默认 'id'
299
+ select: ['id', 'name', 'email'], // 查询列,默认 '*'
300
+ createColumns: ['name', 'email'], // 允许插入的列
301
+ updateColumns: ['name', 'email'], // 允许更新的列
302
+ dbType: 'sqlite', // 数据库类型
303
+ })
304
+ ```
305
+
306
+ #### create / createMany
307
+
308
+ ```ts
309
+ // 单条插入
310
+ const result = await userCrud.create({ name: '张三', email: 'test@example.com' })
311
+ // result.data → { changes: 1, lastInsertRowid: 1 }
312
+
313
+ // 批量插入
314
+ await userCrud.createMany([
315
+ { name: '用户A', email: 'a@test.com' },
316
+ { name: '用户B', email: 'b@test.com' },
317
+ ])
318
+ ```
319
+
320
+ #### createOrUpdate
321
+
322
+ 主キーが存在すれば更新し、存在しなければ作成します(upsert):
323
+
324
+ ```ts
325
+ const userCrud = reldb.crud.table<{ id: number, name: string, email: string }>({
326
+ table: 'users',
327
+ idColumn: 'id',
328
+ createColumns: ['id', 'name', 'email'],
329
+ updateColumns: ['name', 'email'],
330
+ dbType: 'sqlite',
331
+ })
332
+
333
+ await userCrud.createOrUpdate({ id: 1, name: '张三', email: 'test@example.com' })
334
+ // 不存在 → 插入;已存在 → 更新 name、email
335
+ ```
336
+
337
+ `BaseReldbCrudRepository` は `dbType` を自動取得するため、手動指定は不要です。
338
+
339
+ #### findById / getById / findAll / findPage
340
+
341
+ ```ts
342
+ // 主键查找(不存在时返回 null)
343
+ const user = await userCrud.findById(1)
344
+ // user.data → { id: 1, name: '张三', email: 'test@example.com' } | null
345
+
346
+ // 主键获取(不存在时返回 RECORD_NOT_FOUND 错误)
347
+ const user2 = await userCrud.getById(1)
348
+ // user2.data → { id: 1, name: '张三', email: 'test@example.com' }
349
+
350
+ // 条件查询(where + params 占位符)
351
+ const actives = await userCrud.findAll({
352
+ where: 'name LIKE ?',
353
+ params: ['%张%'],
354
+ orderBy: 'id DESC',
355
+ limit: 10,
356
+ offset: 0,
357
+ })
358
+
359
+ // 分页查询
360
+ const page = await userCrud.findPage({
361
+ where: 'name LIKE ?',
362
+ params: ['%张%'],
363
+ orderBy: 'id DESC',
364
+ pagination: { page: 1, pageSize: 20 },
365
+ })
366
+ // page.data → { items: [...], total: 100, page: 1, pageSize: 20 }
367
+ ```
368
+
369
+ #### updateById / deleteById
370
+
371
+ ```ts
372
+ await userCrud.updateById(1, { name: '李四' })
373
+ await userCrud.deleteById(1)
374
+ ```
375
+
376
+ #### count / exists / existsById
377
+
378
+ ```ts
379
+ const total = await userCrud.count({ where: 'name LIKE ?', params: ['%张%'] })
380
+ // total.data → 5
381
+
382
+ const has = await userCrud.exists({ where: 'email = ?', params: ['test@example.com'] })
383
+ // has.data → true
384
+
385
+ const found = await userCrud.existsById(1)
386
+ // found.data → true
387
+ ```
388
+
389
+ #### トランザクション内での Crud
390
+
391
+ すべてのメソッドに `tx` を渡すことができ、対応するトランザクションのコンテキストへ自動的に切り替わります:
392
+
393
+ ```ts
394
+ await reldb.tx.wrap(async (tx) => {
395
+ const created = await userCrud.create({ name: 'Alice', email: 'alice@example.com' }, tx)
396
+ if (!created.success)
397
+ throw new Error(created.error.message, { cause: created.error })
398
+ if (created.data.lastInsertRowid === undefined)
399
+ throw new Error('Insert did not return an ID')
400
+ const updated = await userCrud.updateById(created.data.lastInsertRowid, { name: 'Alice Smith' }, tx)
401
+ if (!updated.success)
402
+ throw new Error(updated.error.message, { cause: updated.error })
403
+ const user = await userCrud.findById(created.data.lastInsertRowid, tx)
404
+ if (!user.success)
405
+ throw new Error(user.error.message, { cause: user.error })
406
+ return user.data
407
+ })
408
+ ```
409
+
410
+ ### BaseReldbCrudRepository
411
+
412
+ 業務リポジトリーは `BaseReldbCrudRepository` を継承し、`fields` でフィールドの対応を定義します。テーブル作成と型変換は自動です:
413
+
414
+ Unix ミリ秒には `BIGINT`(SQLite では 64 ビット INTEGER、PostgreSQL/MySQL では BIGINT)を使用します。PostgreSQL/MySQL の通常の INTEGER では現在のミリ秒値を保存できません。生 SQL/CRUD と基底リポジトリーは BIGINT のドライバー型(number/string/bigint)を保持します。number が必要な業務リポジトリーは変換時に安全な整数であることを検証し、精度の損失を防いでください。基底リポジトリーは TIMESTAMP を Date として読み取ります。
415
+
416
+ ```ts
417
+ import type { DmlWithTxOperations, ReldbCrudFieldDefinition } from '@h-ai/reldb'
418
+ import { BaseReldbCrudRepository, reldb } from '@h-ai/reldb'
419
+
420
+ interface UserRow {
421
+ id: number
422
+ name: string
423
+ email: string
424
+ createdAt: Date
425
+ updatedAt: Date
426
+ }
427
+
428
+ const USER_FIELDS: ReldbCrudFieldDefinition[] = [
429
+ { fieldName: 'id', columnName: 'id', def: { type: 'INTEGER', primaryKey: true, autoIncrement: true }, select: true, create: false, update: false },
430
+ { fieldName: 'name', columnName: 'name', def: { type: 'TEXT', notNull: true }, select: true, create: true, update: true },
431
+ { fieldName: 'email', columnName: 'email', def: { type: 'TEXT', notNull: true }, select: true, create: true, update: true },
432
+ { fieldName: 'createdAt', columnName: 'created_at', def: { type: 'TIMESTAMP', notNull: true }, select: true, create: true, update: false },
433
+ { fieldName: 'updatedAt', columnName: 'updated_at', def: { type: 'TIMESTAMP', notNull: true }, select: true, create: true, update: false },
434
+ ]
435
+
436
+ class UserRepository extends BaseReldbCrudRepository<UserRow> {
437
+ constructor() {
438
+ super(reldb, { table: 'users', idColumn: 'id', fields: USER_FIELDS })
439
+ }
440
+
441
+ /** 自定义查询方法 */
442
+ async findByEmail(email: string) {
443
+ return this.findAll({ where: 'email = ?', params: [email], limit: 1 })
444
+ }
445
+
446
+ /** 自定义方法中使用 this.sql(tx) 自动路由到事务 */
447
+ async insertRaw(data: { name: string, email: string }, tx?: DmlWithTxOperations) {
448
+ // 自定义原始写操作应先确保表已就绪(避免刚建库/建表较慢时的竞态)
449
+ const ready = await this.ensureReady()
450
+ if (!ready.success)
451
+ return ready
452
+ const now = Date.now()
453
+ return this.sql(tx).execute(
454
+ 'INSERT INTO users (name, email, created_at, updated_at) VALUES (?, ?, ?, ?)',
455
+ [data.name, data.email, now, now],
456
+ )
457
+ }
458
+ }
459
+
460
+ // 使用
461
+ const repo = new UserRepository()
462
+ await repo.create({ name: '张三', email: 'test@example.com' })
463
+ const user = await repo.findById(1)
464
+
465
+ // 创建或更新(upsert)
466
+ await repo.createOrUpdate({ id: 1, name: '李四', email: 'test@example.com' })
467
+ // id=1 已存在 → 更新 name;不存在 → 插入新记录
468
+
469
+ // 跨仓库事务
470
+ const txResult = await reldb.tx.begin()
471
+ if (txResult.success) {
472
+ const tx = txResult.data
473
+ await repo.create({ name: '用户A', email: 'a@test.com' }, tx)
474
+ await repo.create({ name: '用户B', email: 'b@test.com' }, tx)
475
+ await tx.commit()
476
+ }
477
+ ```
478
+
479
+ ### JSON パス操作
480
+
481
+ `reldb.json` で、複数のデータベースに共通する JSON パス操作の SQL 式を作成します。
482
+
483
+ パスは SQL/JSON Path(`$.key` / `$.key.subkey` / `$[0]`)に従い、各 DB の関数・演算子に自動変換されます。
484
+
485
+ | 操作 | SQLite | PostgreSQL | MySQL |
486
+ | --------- | -------------- | -------------- | ------------------ |
487
+ | `extract` | `json_extract` | `#>` (text[]) | `JSON_EXTRACT` |
488
+ | `set` | `json_set` | `jsonb_set` | `JSON_SET` |
489
+ | `insert` | `json_insert` | `jsonb_insert` | `JSON_INSERT` |
490
+ | `remove` | `json_remove` | `#-` (text[]) | `JSON_REMOVE` |
491
+ | `merge` | `json_patch` | `\|\| ::jsonb` | `JSON_MERGE_PATCH` |
492
+
493
+ すべてのメソッドは `{ sql, params }` の断片を返し、`reldb.sql.query` / `reldb.sql.execute` に直接組み込めます。
494
+
495
+ #### extract — JSON フィールド値の取得
496
+
497
+ ```ts
498
+ // 用于 WHERE 条件
499
+ const { sql, params } = reldb.json.extract('settings', '$.theme')
500
+ const rows = await reldb.sql.query(
501
+ `SELECT * FROM users WHERE ${sql} = ?`,
502
+ [...params, '"dark"'],
503
+ )
504
+
505
+ // 用于 SELECT 列
506
+ const { sql, params } = reldb.json.extract('profile', '$.name')
507
+ const result = await reldb.sql.query(
508
+ `SELECT id, ${sql} as display_name FROM users`,
509
+ params,
510
+ )
511
+ ```
512
+
513
+ #### set — JSON パスの設定(作成または置換)
514
+
515
+ ```ts
516
+ const { sql, params } = reldb.json.set('settings', '$.theme', 'dark')
517
+ await reldb.sql.execute(
518
+ `UPDATE users SET settings = ${sql} WHERE id = ?`,
519
+ [...params, userId],
520
+ )
521
+ ```
522
+
523
+ #### insert — JSON パスへの挿入(未存在の場合のみ)
524
+
525
+ ```ts
526
+ const { sql, params } = reldb.json.insert('data', '$.firstSeen', new Date().toISOString())
527
+ await reldb.sql.execute(
528
+ `UPDATE events SET data = ${sql} WHERE id = ?`,
529
+ [...params, eventId],
530
+ )
531
+ ```
532
+
533
+ #### remove — JSON パスの削除
534
+
535
+ ```ts
536
+ const { sql, params } = reldb.json.remove('settings', '$.deprecated')
537
+ await reldb.sql.execute(
538
+ `UPDATE users SET settings = ${sql} WHERE id = ?`,
539
+ [...params, userId],
540
+ )
541
+ ```
542
+
543
+ #### merge — JSON オブジェクトのマージ(RFC 7396 Merge Patch)
544
+
545
+ ```ts
546
+ // 覆盖已有键、保留未提及键、将 null 键删除
547
+ const { sql, params } = reldb.json.merge('profile', {
548
+ bio: '新简介',
549
+ avatar: null, // 传入 null 表示删除该键
550
+ })
551
+ await reldb.sql.execute(
552
+ `UPDATE users SET profile = ${sql} WHERE id = ?`,
553
+ [...params, userId],
554
+ )
555
+ ```
556
+
557
+ `json.merge(column, patch)` は三つのバックエンドで RFC 7396 のオブジェクトパッチを実装します。未変更の入れ子キーを保持し、null が指定したキーを削除し、配列を全体置換します。PostgreSQL はパラメーター化した再帰 CTE を単一 SQL 内で適用し、関数の事前インストールやアプリ側の読み取り後の書き戻しを必要としません。パッチのルートは JSON シリアライズ可能なオブジェクトに限ります。列式は開発者の静的 SQL で、パスと値はパラメーターです。[RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)・[PostgreSQL JSON 演算](https://www.postgresql.org/docs/16/functions-json.html)。
558
+
559
+ #### 完全な使用例
560
+
561
+ ```ts
562
+ // 建表(含 JSON 列)
563
+ await reldb.ddl.createTable('products', {
564
+ id: { type: 'INTEGER', primaryKey: true, autoIncrement: true },
565
+ meta: { type: 'JSON' },
566
+ })
567
+
568
+ // 插入初始 JSON
569
+ await reldb.sql.execute(
570
+ `INSERT INTO products (meta) VALUES (?)`,
571
+ [JSON.stringify({ status: 'draft', tags: ['new'], version: 1 })],
572
+ )
573
+
574
+ // 设置字段
575
+ const { sql: setSql, params: setParams } = reldb.json.set('meta', '$.status', 'published')
576
+ await reldb.sql.execute(`UPDATE products SET meta = ${setSql} WHERE id = ?`, [...setParams, 1])
577
+
578
+ // 提取字段(用于过滤)
579
+ const { sql: exSql, params: exParams } = reldb.json.extract('meta', '$.status')
580
+ const published = await reldb.sql.query(
581
+ `SELECT * FROM products WHERE ${exSql} = ?`,
582
+ [...exParams, '"published"'],
583
+ )
584
+
585
+ // 合并更新
586
+ const { sql: mSql, params: mParams } = reldb.json.merge('meta', { version: 2, reviewer: 'admin' })
587
+ await reldb.sql.execute(`UPDATE products SET meta = ${mSql} WHERE id = ?`, [...mParams, 1])
588
+
589
+ // 删除字段
590
+ const { sql: rmSql, params: rmParams } = reldb.json.remove('meta', '$.reviewer')
591
+ await reldb.sql.execute(`UPDATE products SET meta = ${rmSql} WHERE id = ?`, [...rmParams, 1])
592
+ ```
593
+
594
+ ### エラー処理
595
+
596
+ すべての操作は `HaiResult<T>` を返し、`result.success` で成功・失敗を判定します。
597
+
598
+ ```ts
599
+ import { HaiReldbError, reldb } from '@h-ai/reldb'
600
+
601
+ const result = await reldb.sql.query('SELECT * FROM users')
602
+ if (!result.success) {
603
+ switch (result.error.code) {
604
+ case HaiReldbError.NOT_INITIALIZED.code:
605
+ // 请先调用 reldb.init()
606
+ break
607
+ case HaiReldbError.QUERY_FAILED.code:
608
+ // SQL 执行错误
609
+ break
610
+ case HaiReldbError.CONSTRAINT_VIOLATION.code:
611
+ // 约束违反(如唯一约束、外键等)
612
+ break
613
+ }
614
+ }
615
+ ```
616
+
617
+ 主なエラーコード:
618
+
619
+ | エラーコード | code | 説明 |
620
+ | ------------------------------------ | --------------- | -------------------------- |
621
+ | `HaiReldbError.NOT_INITIALIZED` | `hai:reldb:010` | 未初期化 |
622
+ | `HaiReldbError.CONNECTION_FAILED` | `hai:reldb:001` | 接続に失敗 |
623
+ | `HaiReldbError.QUERY_FAILED` | `hai:reldb:002` | 検索に失敗 |
624
+ | `HaiReldbError.CONSTRAINT_VIOLATION` | `hai:reldb:003` | 制約違反 |
625
+ | `HaiReldbError.TRANSACTION_FAILED` | `hai:reldb:004` | トランザクションに失敗 |
626
+ | `HaiReldbError.DDL_FAILED` | `hai:reldb:020` | DDL 操作に失敗 |
627
+ | `HaiReldbError.CONFIG_ERROR` | `hai:reldb:013` | 設定エラー |
628
+ | `HaiReldbError.TIMEOUT` | `hai:reldb:009` | MySQL 接続取得の期限超過 |
629
+ | `HaiReldbError.POOL_EXHAUSTED` | `hai:reldb:011` | MySQL 接続待機キューが満杯 |
630
+
631
+ `BaseReldbCrudRepository` の派生クラスで独自 SQL を実行する前に `await this.ensureReady()` を確認します。構築時に建表を開始するだけなので、サービス init は候補の準備完了後に公開します。PostgreSQL の自動コミットによる単一の `CREATE TABLE/INDEX IF NOT EXISTS` は、`23505/pg_class_relname_nsp_index`(建表は `pg_type_typname_nsp_index` も対象)の競合、または建表時にサーバー routine が `heap_create_with_catalog` / `TypeCreate` である `42710` の行型競合に限り、DDL/SQL の両入口で一度だけ再実行します。再試行も失敗した場合はエラーを返します。トランザクション、業務の一意制約、その他の失敗は再試行しません。初期接続確認の一時 DNS エラー `EAI_AGAIN` は 250/500 ms 間隔で最大三回試行し、業務 SQL は再送しません。
632
+
633
+ ## 本番の共有状態
634
+
635
+ 本番は PostgreSQL/MySQL が必須です。ファイルを含む SQLite は開発・明示的テスト用であり、ホスト間の共有状態を保証しません。 NODE_ENV または HAI_CORE_ENV が production の場合、初期化成功前に検証します。明示的な HAI_E2E=1/HAI_E2E_MOCK=1 テストのみ除外します。 [状態・並行制御・復旧範囲(中国語)](../../.agents/instructions/distributed-state.md)。
636
+
637
+ ## モジュールのスキーマ移行
638
+
639
+ `schemaColumns(reldb, table, sql?)` は実際の列を読み取ります。`migrateSchema(reldb, module, steps, { dryRun: true })` は台帳を作成しません。各モジュールが手順を所有し、`hai_schema_migrations` に版を記録します。SQLite/PostgreSQL はトランザクション、MySQL の暗黙 DDL コミットには再入可能な手順が必要です。失敗時は版を進めず、実構造を再確認して再試行します。新しい台帳へのダウングレードは拒否します。旧書込を停止し DB/セッションキャッシュをバックアップしてください。復旧は互換 DB とバイナリを一緒に戻します。[更新ガイド](../../docs/usage/README.ja.md)を参照。
640
+
641
+ ## エラー表示とログの関連付け
642
+
643
+ 一意制約違反は `HaiReldbError.DUPLICATE_ENTRY`(409)に統一し、タイムアウトや待機列満杯など既存のコードは保持します。翻訳済みメッセージやドライバー文字列ではなくコードで分岐してください。ログには code/schema/routine/constraint とスタック行番号を残し、数値のバインド値でこれらの診断識別子を隠しません。
644
+
645
+ HTTP エラーには安全な翻訳文と追跡番号を含めます。ToastContainer は番号を既定で非表示にし、調査情報のアイコンから表示・コピーできます。同じ ID を error.traceId と X-Trace-Id に返します。requestId を traceId として共有し、ブラウザーとサーバーのログを関連付けます。脱機密化した例外・スタック・操作情報はログに残し、内部 cause/ext は公開しません。項目検証は翻訳済みの項目名とメッセージだけを返します。
646
+
647
+ HTTP/UI 境界には core.error.toPublic(error, traceId?)、再翻訳後の表示には core.error.formatMessage(error) を使い、ID を重複追加しません。ログには完全な Error を渡してください。DB ログにはドライバーコード、脱機密化したメッセージ・スタック、表・列を残し、SQL 本文、バインド値と接続認証情報を記録しません。
648
+
649
+ ## 開発とテスト
650
+
651
+ ```bash
652
+ pnpm --filter @h-ai/reldb test
653
+ ```
654
+
655
+ > MySQL/PostgreSQL のテストには Docker が必要です。
656
+
657
+ ソースの JSDoc は[共通規約](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证)に従います。公開例は実際の公開入口、内部例は元のモジュールスコープで型検査します。ビルド後に `pnpm check:jsdoc --package reldb` を実行してください。例の型検査は実バックエンドの動作検証の代わりにはなりません。
658
+
659
+ ## ライセンス
660
+
661
+ Apache-2.0