@h-ai/reldb 0.1.0-alpha9 → 1.0.0-beta.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.
package/README.md CHANGED
@@ -1,20 +1,44 @@
1
1
  # @h-ai/reldb
2
2
 
3
+ [简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
4
+
5
+ [框架首页](../../README.md) · 相关模块:[@h-ai/core](../core/README.md)
6
+
7
+ ## 简介
8
+
3
9
  关系型数据库访问模块,通过统一的 `reldb` 对象访问 SQLite、PostgreSQL、MySQL。
4
10
 
5
- ## 支持的数据库
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
+ ## 核心功能清单
6
26
 
7
- - SQLite
8
- - PostgreSQL
9
- - MySQL
27
+ - 用同一套 API 连接 SQLite、PostgreSQL 和 MySQL。
28
+ - 执行参数化 SQL,创建表和索引,读取分页结果。
29
+ - 使用事务、CRUD 与 Repository 组织业务读写。
10
30
 
11
- ## 快速开始
31
+ ## 使用入门
32
+
33
+ ### 快速开始
12
34
 
13
35
  ```ts
14
36
  import { HaiReldbError, reldb } from '@h-ai/reldb'
15
37
 
16
38
  // 初始化
17
- await reldb.init({ type: 'sqlite', database: ':memory:' })
39
+ const initialized = await reldb.init({ type: 'sqlite', database: ':memory:' })
40
+ if (!initialized.success)
41
+ throw new Error(initialized.error.message)
18
42
 
19
43
  // DDL
20
44
  await reldb.ddl.createTable('users', {
@@ -49,7 +73,20 @@ const user = await userCrud.findById(1)
49
73
  await reldb.close()
50
74
  ```
51
75
 
52
- ## 配置
76
+ `reldb.config` 返回的是**脱敏后的配置快照**:数据库 `password`、URL 内嵌凭证和 TLS 客户端私钥 `ssl.key` 会自动隐藏,便于排查状态但不应用于回放原始连接信息。
77
+
78
+ ### 配置
79
+
80
+ `@h-ai/reldb/schemas` 直接导出同一组配置 Schema 和类型,只依赖 Zod,不加载数据库实例或驱动,可用于 CLI 或浏览器的配置校验。MySQL TLS 仅接受 `false` 或严格对象,`{}` 默认校验证书链和 DNS 身份;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
+ ```
53
90
 
54
91
  支持连接字符串(`url`)或分字段(`host/port/database/user/password`)。
55
92
 
@@ -58,7 +95,7 @@ await reldb.close()
58
95
  await reldb.init({ type: 'sqlite', database: './data.db' })
59
96
 
60
97
  // PostgreSQL
61
- await reldb.init({ type: 'postgresql', url: 'postgres://user:pass@localhost:5432/mydb' })
98
+ await reldb.init({ type: 'postgresql', url: 'postgres://user:pass@localhost:5432/mydb', database: 'mydb' })
62
99
 
63
100
  // MySQL
64
101
  await reldb.init({
@@ -69,65 +106,189 @@ await reldb.init({
69
106
  user: 'admin',
70
107
  password: 'secret',
71
108
  mysql: { charset: 'utf8mb4' },
109
+ pool: { max: 10, acquireTimeout: 5000, queueLimit: 100 },
72
110
  })
73
111
  ```
74
112
 
75
- ## 事务
113
+ #### 连接池与 MySQL URL
114
+
115
+ MySQL 的 `pool` 只接受 `max`、`acquireTimeout`、`queueLimit`,省略时默认分别为 10、10000 毫秒、1000。其他池字段(包括 `min`、`idleTimeout`)以及未实现的 `mysql.timezone` 会在配置校验时失败。空闲连接由 mysql2/服务端断开或 `close()` 释放,本模块不承诺 MySQL 的最小保留数或可配置的空闲回收期限;[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 借用队列:排队与建立连接共用一个 `acquireTimeout` 截止期限,超时返回 `TIMEOUT`,队列满返回 `POOL_EXHAUSTED`。超时排队请求会移除;已开始但迟到的建连只释放连接,不启动 SQL,结算前仍占容量槽位。取得连接及业务执行前再次校验期限;它不限制已启动 SQL 或事务的执行时长。
126
+
127
+ MySQL `close()` 立即拒绝新请求并结束排队等待,随后等待已借出连接、事务及在途建连结算再关闭池。驱动 `connectTimeout` 使用相同超时值;关闭可能等到该建连时限或现有 SQL/事务结束,应用仍须主动结束事务。环境变量使用 `HAI_DB_POOL_*`,`HAI_DB_POOL_MIN` / `HAI_DB_POOL_IDLETIMEOUT` 仅用于 PostgreSQL,MySQL 的 `HAI_DB_POOL_QUEUELIMIT` 控制等待总量。
128
+
129
+ MySQL 支持仅传 `url`(`mysql://`),未给 URL 时 `database` 必填。URL 中的 host/port/database/user/password 优先,冲突的分字段不参与连接;`pool`、`mysql.charset`、`ssl` 和 `operationLog` 独立适用。URL 仅接受连接地址,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 由部署时的受信 CA 提供,不是任意远端返回的证书。
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/私钥,通过真实 MySQL 验证受信 DNS、错误 CA/主机名拒绝、`REQUIRE X509` 客户端认证和会话 `Ssl_cipher`,不使用关闭验证替代认证成功证据。
153
+
154
+ #### SQLite 执行方式(worker 异步)
155
+
156
+ better-sqlite3 是同步驱动,查询会阻塞 Node 主线程事件循环。WAL 只能改善数据库锁并发,无法避免同步调用占用主线程。需要卸载这一阻塞时,可把 SQLite 切到专用 worker 线程执行(单连接,opt-in,默认 `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
+ 可在数据库配置中通过 `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
+ ### 事务
76
206
 
77
207
  三种使用方式:
78
208
 
79
- ### wrap(自动提交/回滚)
209
+ #### wrap(自动提交/回滚)
210
+
211
+ 事务回调正常返回就会提交,返回失败 HaiResult 也不例外。必须检查每一步结果,并在失败时抛出异常触发回滚;wrap 会把异常转为 TRANSACTION_FAILED。需要保留原领域错误时,使用下方手动事务。各片段假定数据库已成功初始化且 users 表已经创建。
80
212
 
81
213
  ```ts
82
214
  const result = await reldb.tx.wrap(async (tx) => {
83
- await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
84
- await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户2'])
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
+ }
85
220
  return 'ok'
86
221
  })
87
222
  // 回调正常返回 → 自动提交;抛异常 → 自动回滚
88
223
  ```
89
224
 
90
- ### begin + commit(分步提交)
225
+ #### begin + commit(分步提交)
91
226
 
92
227
  ```ts
228
+ // 放在 async 业务函数内,失败时向调用方返回 HaiResult。
93
229
  const txResult = await reldb.tx.begin()
94
- if (!txResult.success) { /* 处理错误 */ }
230
+ if (!txResult.success)
231
+ return txResult
95
232
  const tx = txResult.data
96
-
97
- await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
98
- await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户2'])
99
- await tx.commit()
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
+ }
100
249
  ```
101
250
 
102
- ### begin + rollback(分步回滚)
251
+ #### begin + rollback(分步回滚)
103
252
 
104
253
  ```ts
254
+ // 独立脚本:试写后回滚,不保留用户数据。
105
255
  const txResult = await reldb.tx.begin()
106
- if (!txResult.success) { /* 处理错误 */ }
256
+ if (!txResult.success)
257
+ throw new Error(txResult.error.message, { cause: txResult.error })
107
258
  const tx = txResult.data
108
-
109
- await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
110
- // 业务检查失败,手动回滚
111
- await tx.rollback()
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 })
112
268
  ```
113
269
 
114
- ### 事务内使用 batch / queryPage
270
+ #### 事务内使用 batch / queryPage
115
271
 
116
272
  ```ts
117
273
  await reldb.tx.wrap(async (tx) => {
118
- await tx.batch([
119
- { sql: 'INSERT INTO users (name) VALUES (?)', params: ['用户1'] },
120
- { sql: 'INSERT INTO users (name) VALUES (?)', params: ['用户2'] },
274
+ const inserted = await tx.batch([
275
+ { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Alice'] },
276
+ { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Bob'] },
121
277
  ])
278
+ if (!inserted.success)
279
+ throw new Error(inserted.error.message, { cause: inserted.error })
122
280
 
123
281
  const page = await tx.queryPage<{ id: number, name: string }>({
124
282
  sql: 'SELECT id, name FROM users ORDER BY id',
125
283
  pagination: { page: 1, pageSize: 10 },
126
284
  })
285
+ if (!page.success)
286
+ throw new Error(page.error.message, { cause: page.error })
287
+ return page.data
127
288
  })
128
289
  ```
129
290
 
130
- ## Crud
291
+ ### Crud
131
292
 
132
293
  通过 `reldb.crud.table()` 创建单表 CRUD 仓库,免写 SQL:
133
294
 
@@ -142,7 +303,7 @@ const userCrud = reldb.crud.table<{ id: number, name: string, email: string }>({
142
303
  })
143
304
  ```
144
305
 
145
- ### create / createMany
306
+ #### create / createMany
146
307
 
147
308
  ```ts
148
309
  // 单条插入
@@ -156,7 +317,7 @@ await userCrud.createMany([
156
317
  ])
157
318
  ```
158
319
 
159
- ### createOrUpdate
320
+ #### createOrUpdate
160
321
 
161
322
  主键存在时更新,否则创建(upsert):
162
323
 
@@ -175,7 +336,7 @@ await userCrud.createOrUpdate({ id: 1, name: '张三', email: 'test@example.com'
175
336
 
176
337
  `BaseReldbCrudRepository` 自动获取 `dbType`,无需手动传入。
177
338
 
178
- ### findById / getById / findAll / findPage
339
+ #### findById / getById / findAll / findPage
179
340
 
180
341
  ```ts
181
342
  // 主键查找(不存在时返回 null)
@@ -205,14 +366,14 @@ const page = await userCrud.findPage({
205
366
  // page.data → { items: [...], total: 100, page: 1, pageSize: 20 }
206
367
  ```
207
368
 
208
- ### updateById / deleteById
369
+ #### updateById / deleteById
209
370
 
210
371
  ```ts
211
372
  await userCrud.updateById(1, { name: '李四' })
212
373
  await userCrud.deleteById(1)
213
374
  ```
214
375
 
215
- ### count / exists / existsById
376
+ #### count / exists / existsById
216
377
 
217
378
  ```ts
218
379
  const total = await userCrud.count({ where: 'name LIKE ?', params: ['%张%'] })
@@ -225,22 +386,33 @@ const found = await userCrud.existsById(1)
225
386
  // found.data → true
226
387
  ```
227
388
 
228
- ### 事务中使用 Crud
389
+ #### 事务中使用 Crud
229
390
 
230
391
  所有方法均支持传入 `tx` 参数,自动路由到事务上下文:
231
392
 
232
393
  ```ts
233
394
  await reldb.tx.wrap(async (tx) => {
234
- await userCrud.create({ name: '用户A', email: 'a@test.com' }, tx)
235
- await userCrud.updateById(1, { name: '新名字' }, tx)
236
- const user = await userCrud.findById(1, 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
237
407
  })
238
408
  ```
239
409
 
240
- ## BaseReldbCrudRepository
410
+ ### BaseReldbCrudRepository
241
411
 
242
412
  业务仓库继承 `BaseReldbCrudRepository`,通过 `fields` 定义字段映射,自动建表、类型转换:
243
413
 
414
+ Unix 毫秒字段使用 `BIGINT`(SQLite 为 64 位 INTEGER,PostgreSQL/MySQL 为 BIGINT),普通 INTEGER 在 PostgreSQL/MySQL 中无法容纳当前毫秒时间。BIGINT 的原始 SQL/CRUD 和基类读取值保留驱动的 number/string/bigint 类型;需要 number 的业务仓库须校验安全整数后转换,避免静默精度丢失。TIMESTAMP 字段由基类读取为 Date。
415
+
244
416
  ```ts
245
417
  import type { DmlWithTxOperations, ReldbCrudFieldDefinition } from '@h-ai/reldb'
246
418
  import { BaseReldbCrudRepository, reldb } from '@h-ai/reldb'
@@ -273,6 +445,10 @@ class UserRepository extends BaseReldbCrudRepository<UserRow> {
273
445
 
274
446
  /** 自定义方法中使用 this.sql(tx) 自动路由到事务 */
275
447
  async insertRaw(data: { name: string, email: string }, tx?: DmlWithTxOperations) {
448
+ // 自定义原始写操作应先确保表已就绪(避免刚建库/建表较慢时的竞态)
449
+ const ready = await this.ensureReady()
450
+ if (!ready.success)
451
+ return ready
276
452
  const now = Date.now()
277
453
  return this.sql(tx).execute(
278
454
  'INSERT INTO users (name, email, created_at, updated_at) VALUES (?, ?, ?, ?)',
@@ -300,7 +476,7 @@ if (txResult.success) {
300
476
  }
301
477
  ```
302
478
 
303
- ## JSON 路径操作
479
+ ### JSON 路径操作
304
480
 
305
481
  通过 `reldb.json` 构建跨数据库统一的 JSON 路径操作 SQL 表达式。
306
482
 
@@ -316,7 +492,7 @@ if (txResult.success) {
316
492
 
317
493
  所有方法返回 `{ sql, params }` 片段,可直接嵌入 `reldb.sql.query` / `reldb.sql.execute`。
318
494
 
319
- ### extract — 提取 JSON 字段值
495
+ #### extract — 提取 JSON 字段值
320
496
 
321
497
  ```ts
322
498
  // 用于 WHERE 条件
@@ -334,7 +510,7 @@ const result = await reldb.sql.query(
334
510
  )
335
511
  ```
336
512
 
337
- ### set — 设置 JSON 字段路径(创建或替换)
513
+ #### set — 设置 JSON 字段路径(创建或替换)
338
514
 
339
515
  ```ts
340
516
  const { sql, params } = reldb.json.set('settings', '$.theme', 'dark')
@@ -344,7 +520,7 @@ await reldb.sql.execute(
344
520
  )
345
521
  ```
346
522
 
347
- ### insert — 插入 JSON 字段路径(仅当路径不存在时)
523
+ #### insert — 插入 JSON 字段路径(仅当路径不存在时)
348
524
 
349
525
  ```ts
350
526
  const { sql, params } = reldb.json.insert('data', '$.firstSeen', new Date().toISOString())
@@ -354,7 +530,7 @@ await reldb.sql.execute(
354
530
  )
355
531
  ```
356
532
 
357
- ### remove — 删除 JSON 字段路径
533
+ #### remove — 删除 JSON 字段路径
358
534
 
359
535
  ```ts
360
536
  const { sql, params } = reldb.json.remove('settings', '$.deprecated')
@@ -364,7 +540,7 @@ await reldb.sql.execute(
364
540
  )
365
541
  ```
366
542
 
367
- ### merge — 合并 JSON 对象(RFC 7396 Merge Patch)
543
+ #### merge — 合并 JSON 对象(RFC 7396 Merge Patch)
368
544
 
369
545
  ```ts
370
546
  // 覆盖已有键、保留未提及键、将 null 键删除
@@ -378,7 +554,9 @@ await reldb.sql.execute(
378
554
  )
379
555
  ```
380
556
 
381
- ### 完整示例
557
+ `json.merge(column, patch)` 在三种后端均实现 RFC 7396 的对象补丁:递归保留未改键,补丁 null 删除指定键,数组整体替换。PostgreSQL 用参数化递归 CTE 在单次 SQL 中修改当前行,不要求预装函数,也不先在应用读取再写回。补丁根必须是可 JSON 序列化的对象;此契约不扩展为任意 JSON 根补丁。列表达式由开发者静态提供,动态路径和值全部作为参数。参见 [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396) 和 [PostgreSQL JSON 运算](https://www.postgresql.org/docs/16/functions-json.html)。
558
+
559
+ #### 完整示例
382
560
 
383
561
  ```ts
384
562
  // 建表(含 JSON 列)
@@ -413,7 +591,7 @@ const { sql: rmSql, params: rmParams } = reldb.json.remove('meta', '$.reviewer')
413
591
  await reldb.sql.execute(`UPDATE products SET meta = ${rmSql} WHERE id = ?`, [...rmParams, 1])
414
592
  ```
415
593
 
416
- ## 错误处理
594
+ ### 错误处理
417
595
 
418
596
  所有操作返回 `HaiResult<T>`,通过 `result.success` 判断成功或失败。
419
597
 
@@ -438,17 +616,37 @@ if (!result.success) {
438
616
 
439
617
  常用错误码:
440
618
 
441
- | 错误码 | code | 说明 |
442
- | ------------------------------------ | --------------- | ------------ |
443
- | `HaiReldbError.NOT_INITIALIZED` | `hai:reldb:010` | 未初始化 |
444
- | `HaiReldbError.CONNECTION_FAILED` | `hai:reldb:001` | 连接失败 |
445
- | `HaiReldbError.QUERY_FAILED` | `hai:reldb:002` | 查询失败 |
446
- | `HaiReldbError.CONSTRAINT_VIOLATION` | `hai:reldb:003` | 约束违反 |
447
- | `HaiReldbError.TRANSACTION_FAILED` | `hai:reldb:004` | 事务失败 |
448
- | `HaiReldbError.DDL_FAILED` | `hai:reldb:020` | DDL 操作失败 |
449
- | `HaiReldbError.CONFIG_ERROR` | `hai:reldb:013` | 配置错误 |
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`)目录竞争,或建表时 `42710` 且服务端 routine 为 `heap_create_with_catalog` / `TypeCreate` 的行类型竞争重执行一次;重试仍失败则保留错误,DDL 与 SQL 入口一致;事务、业务唯一约束及其他错误不自动重试。初始化连接探测仅对临时 DNS 错误 `EAI_AGAIN` 最多尝试三次,间隔 250/500 ms;不重放业务 SQL。
632
+
633
+ ## 生产共享状态
450
634
 
451
- ## 测试
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/会话缓存;回退需恢复兼容数据库与旧代码,不能只换二进制。详见 [升级指南](../../docs/usage/README.md)。
640
+
641
+ ## 错误提示与关联日志
642
+
643
+ 唯一约束冲突统一返回 `HaiReldbError.DUPLICATE_ENTRY`(409);超时、队列已满等已有错误码保持不变。业务按错误码分支,不解析本地化消息或驱动错误文本。日志保留驱动 code/schema/routine/constraint 和堆栈行号;数字绑定值不会误遮蔽这些诊断标识。
644
+
645
+ HTTP 错误返回本地化安全文案及追踪编号;ToastContainer 默认隐藏编号,由排查信息图标展开和复制。响应同时包含 `error.traceId` 和 `X-Trace-Id`。框架复用本次 requestId 作为 traceId,前后端日志自动关联。底层异常、堆栈和操作上下文经凭据脱敏后写入日志,不通过 cause/ext 向用户公开。字段校验只返回本地化的字段名和错误。
646
+
647
+ `core.error.toPublic(error, traceId?)` 用于接口/UI 边界;`core.error.formatMessage(error)` 用于重新翻译后的提示,重复调用不会重复追加标识。日志传入完整 Error 对象,不能只记录通用标题或提前 String(error) 丢失上下文。数据库失败记录驱动错误码、消息、堆栈及表/列;SQL 原文、绑定值和连接凭据不进入日志。
648
+
649
+ ## 开发测试
452
650
 
453
651
  ```bash
454
652
  pnpm --filter @h-ai/reldb test
@@ -456,6 +654,8 @@ pnpm --filter @h-ai/reldb test
456
654
 
457
655
  > MySQL/PostgreSQL 测试需要 Docker。
458
656
 
459
- ## License
657
+ 源码 JSDoc 遵循[统一规范](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证):公开示例使用实际发布入口,内部示例在原模块作用域检查。构建后执行 `pnpm check:jsdoc --package reldb`;示例编译不代替真实后端行为验证。
658
+
659
+ ## 许可证
460
660
 
461
661
  Apache-2.0
@@ -0,0 +1,132 @@
1
+ import { z } from 'zod';
2
+
3
+ // src/reldb-config.ts
4
+ var ReldbTypeSchema = z.enum(["sqlite", "postgresql", "mysql"]);
5
+ var ReldbOperationLogConfigSchema = z.object({
6
+ read: z.boolean().default(false),
7
+ write: z.boolean().default(false),
8
+ /** 安全数量摘要的最大字符数;0 表示省略摘要,永不记录 SQL 原文或参数值。 */
9
+ maxLength: z.number().int().min(0).default(1e3),
10
+ level: z.enum(["info", "debug", "trace"]).default("debug")
11
+ });
12
+ var OperationLogConfigFieldSchema = {
13
+ operationLog: ReldbOperationLogConfigSchema.optional()
14
+ };
15
+ var PoolConfigSchema = z.object({
16
+ /** PostgreSQL 已建立连接的最小保留数(默认 1,不主动预建) */
17
+ min: z.number().int().min(0).default(1),
18
+ /** 最大连接数(默认 10) */
19
+ max: z.number().int().min(1).default(10),
20
+ /** PostgreSQL 空闲连接超时,毫秒(默认 30000;0 禁用) */
21
+ idleTimeout: z.number().int().min(0).default(3e4),
22
+ /** 获取连接超时,毫秒(默认10000);PostgreSQL >=0,0禁用;MySQL排队+建连总期限为1–2147483647 */
23
+ acquireTimeout: z.number().int().min(0).default(1e4)
24
+ });
25
+ var MysqlPoolConfigSchema = z.object({
26
+ /** 最大连接数(默认 10) */
27
+ max: z.number().int().min(1).default(10),
28
+ /** 获取连接超时,毫秒(默认10000);PostgreSQL >=0,0禁用;MySQL排队+建连总期限为1–2147483647 */
29
+ acquireTimeout: z.number().int().min(1).max(2147483647).default(1e4),
30
+ /** MySQL 等待队列最大请求数(默认 1000;正整数,不支持无限队列) */
31
+ queueLimit: z.number().int().min(1).default(1e3)
32
+ }).strict();
33
+ var SslConfigSchema = z.union([
34
+ z.boolean(),
35
+ z.enum(["require", "prefer", "allow", "disable"]),
36
+ z.record(z.string(), z.unknown())
37
+ ]);
38
+ var MysqlSslConfigSchema = z.union([
39
+ z.literal(false),
40
+ z.object({
41
+ /** MySQL 是否校验证书链(默认 true);false 明确放弃服务端认证 */
42
+ rejectUnauthorized: z.boolean().default(true),
43
+ /** MySQL 是否验证 DNS 主机身份(默认 true);仅证书链验证开启时有效 */
44
+ verifyIdentity: z.boolean().default(true),
45
+ /** MySQL 信任的 CA,PEM 内容;省略时使用 Node 信任库 */
46
+ ca: z.string().min(1).optional(),
47
+ /** MySQL 客户端证书,PEM 内容;必须同时提供 key */
48
+ cert: z.string().min(1).optional(),
49
+ /** MySQL 客户端私钥,PEM 内容;必须同时提供 cert */
50
+ key: z.string().min(1).optional()
51
+ }).strict().refine((ssl) => Boolean(ssl.cert) === Boolean(ssl.key), { path: ["key"] })
52
+ ]);
53
+ var SqliteOptionsSchema = z.object({
54
+ /** 是否启用 WAL 模式(默认 true),可提高并发读写性能 */
55
+ walMode: z.boolean().default(true),
56
+ /** 是否只读模式(默认 false) */
57
+ readonly: z.boolean().default(false),
58
+ /**
59
+ * 执行方式(默认 sync)。
60
+ *
61
+ * - `sync`:主线程直接执行 better-sqlite3(同步阻塞主线程事件循环)。
62
+ * - `worker`:在专用 worker 线程持有唯一连接执行,把阻塞移出主线程。
63
+ * 注意 WAL 只改善数据库锁并发,无法避免 better-sqlite3 同步调用占用调用线程。
64
+ */
65
+ executor: z.enum(["sync", "worker"]).default("sync"),
66
+ /** worker 执行方式下等待连接打开的超时(毫秒,默认 10000) */
67
+ workerConnectTimeoutMs: z.number().int().positive().default(1e4)
68
+ });
69
+ var MysqlOptionsSchema = z.object({
70
+ /** 字符集(默认 utf8mb4),推荐使用 utf8mb4 支持完整 Unicode */
71
+ charset: z.string().default("utf8mb4")
72
+ }).strict();
73
+ var NetworkConfigSchema = z.object({
74
+ /**
75
+ * 连接字符串(可选)
76
+ * 如果提供 url,将优先使用,忽略 host/port 等字段
77
+ */
78
+ url: z.string().optional(),
79
+ /** 数据库主机地址(默认 localhost) */
80
+ host: z.string().default("localhost"),
81
+ /**
82
+ * 数据库端口
83
+ * - PostgreSQL 默认 5432
84
+ * - MySQL 默认 3306
85
+ */
86
+ port: z.number().int().min(1).max(65535).optional(),
87
+ /** 数据库名称 */
88
+ database: z.string(),
89
+ /** 数据库用户名 */
90
+ user: z.string().optional(),
91
+ /** 数据库密码 */
92
+ password: z.string().optional(),
93
+ /** SSL/TLS 连接配置 */
94
+ ssl: SslConfigSchema.optional(),
95
+ /** 连接池配置 */
96
+ pool: PoolConfigSchema.optional()
97
+ });
98
+ var SqliteConfigSchema = z.object({
99
+ type: z.literal("sqlite"),
100
+ /** 数据库文件路径(如 './data.db')或 ':memory:'(内存数据库) */
101
+ database: z.string(),
102
+ /** SQLite 特定选项 */
103
+ sqlite: SqliteOptionsSchema.optional(),
104
+ ...OperationLogConfigFieldSchema
105
+ });
106
+ var PostgresqlConfigSchema = NetworkConfigSchema.extend({
107
+ type: z.literal("postgresql"),
108
+ ...OperationLogConfigFieldSchema
109
+ });
110
+ var MysqlConfigSchema = NetworkConfigSchema.extend({
111
+ type: z.literal("mysql"),
112
+ /** 网络数据库URL;MySQL空串表示使用分字段,非空仅mysql://且拒绝query/fragment,优先于host/port/database/user/password */
113
+ url: z.union([z.literal("").transform(() => void 0), z.url({ protocol: /^mysql$/ }).refine((value) => !value.includes("?") && !value.includes("#"))]).optional(),
114
+ /** 数据库:SQLite文件路径或:memory:;PostgreSQL必填名称;MySQL无URL时必填,URL模式从URL读取 */
115
+ database: z.string().min(1).optional(),
116
+ /** TLS:PostgreSQL使用其模式;MySQL仅false或严格对象,空对象默认校验证书链和DNS身份 */
117
+ ssl: MysqlSslConfigSchema.optional(),
118
+ /** 连接池配置:PostgreSQL支持min/idleTimeout;MySQL仅max/acquireTimeout/queueLimit,拒绝其他字段 */
119
+ pool: MysqlPoolConfigSchema.optional(),
120
+ /** MySQL 特定选项 */
121
+ mysql: MysqlOptionsSchema.optional(),
122
+ ...OperationLogConfigFieldSchema
123
+ }).refine((config) => Boolean(config.url) || Boolean(config.database), { path: ["database"] });
124
+ var ReldbConfigSchema = z.discriminatedUnion("type", [
125
+ SqliteConfigSchema,
126
+ PostgresqlConfigSchema,
127
+ MysqlConfigSchema
128
+ ]);
129
+
130
+ export { MysqlConfigSchema, MysqlOptionsSchema, MysqlPoolConfigSchema, MysqlSslConfigSchema, PoolConfigSchema, PostgresqlConfigSchema, ReldbConfigSchema, ReldbOperationLogConfigSchema, ReldbTypeSchema, SqliteConfigSchema, SqliteOptionsSchema, SslConfigSchema };
131
+ //# sourceMappingURL=chunk-SDS2ZT3V.js.map
132
+ //# sourceMappingURL=chunk-SDS2ZT3V.js.map