@h-ai/reldb 0.1.0-alpha.66 → 0.1.0-alpha.68

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.en.md CHANGED
@@ -4,21 +4,41 @@
4
4
 
5
5
  [Framework home](../../README.en.md) · Related modules: [@h-ai/core](../core/README.en.md)
6
6
 
7
+ ## Introduction
8
+
7
9
  A relational database module with a unified `reldb` object for SQLite, PostgreSQL, and MySQL.
8
10
 
9
- ## Supported databases
11
+ Connections become available only after a successful health check. Failed initialization disposes its candidate; `error.ext.cleanupPending === true` identifies cleanup that still needs retry. `reldb.close()` returns `Promise<HaiResult<void>>`, waits for initialization, and coalesces concurrent close calls. Once cleanup starts, `isInitialized` is false and configuration/operations are unavailable. A failed cleanup retains the resource: the next `close()` or `init()` retries disposal before allocating a replacement. Always inspect the close result.
12
+
13
+ ```ts
14
+ const closed = await reldb.close()
15
+ if (!closed.success) {
16
+ // Retry once after addressing the cleanup failure; report another failure.
17
+ const retried = await reldb.close()
18
+ if (!retried.success)
19
+ throw new Error(retried.error.message)
20
+ }
21
+ ```
22
+
23
+ Close waits for borrowed transactions to commit/roll back and release their handles; applications must finish transactions for shutdown to complete. SQLite WAL setup and worker handshakes are part of the owned initialization chain. PostgreSQL removes disconnected idle clients and replaces them on later queries without terminating the process; logs contain safe error identifiers rather than raw driver errors.
24
+
25
+ ## Core features
26
+
27
+ - Connect to SQLite, PostgreSQL and MySQL through one API.
28
+ - Run parameterized SQL, manage tables/indexes and read paginated results.
29
+ - Organize application reads and writes with transactions, CRUD and repositories.
10
30
 
11
- - SQLite
12
- - PostgreSQL
13
- - MySQL
31
+ ## Getting started
14
32
 
15
- ## Quick start
33
+ ### Quick start
16
34
 
17
35
  ```ts
18
36
  import { HaiReldbError, reldb } from '@h-ai/reldb'
19
37
 
20
38
  // 初始化
21
- 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)
22
42
 
23
43
  // DDL
24
44
  await reldb.ddl.createTable('users', {
@@ -53,9 +73,20 @@ const user = await userCrud.findById(1)
53
73
  await reldb.close()
54
74
  ```
55
75
 
56
- `reldb.config` returns a **redacted configuration snapshot**. Database `password` and credentials embedded in URLs are hidden automatically. Use it to inspect state, not to replay original connection details.
76
+ `reldb.config` returns a **redacted configuration snapshot**. Database `password`, URL credentials and the TLS client private key `ssl.key` are hidden. Use it to inspect state, not to replay connection details.
57
77
 
58
- ## Configuration
78
+ ### Configuration
79
+
80
+ `@h-ai/reldb/schemas` exports the same configuration schemas and types with only Zod, without loading the database singleton or drivers. CLI and browser code can validate configuration through this entry. MySQL TLS accepts `false` or a strict object; `{}` verifies the certificate chain and DNS identity by default. PostgreSQL retains its boolean, string mode and object contract.
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
+ ```
59
90
 
60
91
  Use either a connection string (`url`) or separate fields (`host/port/database/user/password`).
61
92
 
@@ -64,7 +95,7 @@ Use either a connection string (`url`) or separate fields (`host/port/database/u
64
95
  await reldb.init({ type: 'sqlite', database: './data.db' })
65
96
 
66
97
  // PostgreSQL
67
- 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' })
68
99
 
69
100
  // MySQL
70
101
  await reldb.init({
@@ -75,10 +106,52 @@ await reldb.init({
75
106
  user: 'admin',
76
107
  password: 'secret',
77
108
  mysql: { charset: 'utf8mb4' },
109
+ pool: { max: 10, acquireTimeout: 5000, queueLimit: 100 },
78
110
  })
79
111
  ```
80
112
 
81
- ### SQLite execution (asynchronous worker)
113
+ #### Connection pools and MySQL URLs
114
+
115
+ MySQL `pool` accepts only `max`, `acquireTimeout` and `queueLimit`, defaulting to 10, 10000 milliseconds and 1000 even when omitted. Unsupported pool fields such as `min` and `idleTimeout`, and the unimplemented `mysql.timezone`, fail configuration validation. Idle connections remain subject to mysql2/server disconnection and `close()`; the module promises no MySQL minimum retention or configurable idle expiry. [mysql2 3.24.5 options](https://github.com/sidorares/node-mysql2/blob/v3.24.5/typings/mysql/lib/Pool.d.ts) differ from PostgreSQL.
116
+
117
+ | Field | PostgreSQL (defaults when `pool` is provided) | MySQL |
118
+ | ---------------- | ------------------------------------------------------------- | --------------------------------------------------- |
119
+ | `max` | 10 | 10, counting borrowed and connecting slots together |
120
+ | `min` | 1, retention floor for established connections; no prewarming | Unsupported; rejected |
121
+ | `idleTimeout` | 30000 ms; 0 disables | Unsupported; rejected |
122
+ | `acquireTimeout` | 10000 ms; 0 disables | 10000 ms; integer from 1 to 2147483647 |
123
+ | `queueLimit` | Unsupported | 1000; positive integer limiting queued requests |
124
+
125
+ PostgreSQL uses pg defaults when `pool` is omitted; see [pg.Pool](https://node-postgres.com/apis/pool) for retention semantics. All MySQL SQL, DDL, CRUD, batch and transaction entry points use a bounded FIFO acquisition queue. Waiting and establishment share one deadline: expiry returns `TIMEOUT`, and a full queue returns `POOL_EXHAUSTED`. Expired waiters are removed; late connections are released without executing SQL and retain their capacity slot until settled. The deadline is checked on delivery and before starting work. It does not limit SQL or transactions that have already started.
126
+
127
+ MySQL `close()` rejects new and queued requests immediately, then waits for borrowed connections, transactions and pending establishment before ending the pool. The driver uses the same value for `connectTimeout`; shutdown may wait for that timeout or existing work to finish. Applications must finish transactions. Environment variables use `HAI_DB_POOL_*`: `MIN` and `IDLETIMEOUT` apply only to PostgreSQL; MySQL `QUEUELIMIT` bounds queued requests.
128
+
129
+ MySQL accepts a `mysql://` URL without split connection fields; `database` is required without a URL. URL host/port/database/user/password take precedence over conflicting split fields. `pool`, `mysql.charset`, `ssl` and `operationLog` remain independent options. Query strings and fragments are rejected, so hidden URL parameters cannot change TLS or pool policy. Connection logs sanitize URLs. This example assumes `HAI_DB_URL` is set:
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
+ An empty MySQL `url` (including an empty environment placeholder) selects host/port/database/user/password; nonempty URLs remain strictly validated.
138
+
139
+ #### MySQL TLS
140
+
141
+ Omitting `ssl` or using `false` disables TLS. Enabling it requires a strict object; an empty object defaults to `{ rejectUnauthorized: true, verifyIdentity: true }`. `ca` contains trusted CA PEM text, or Node's trust store is used when omitted. Optional client `cert` and `key` must contain PEM text and be provided together. Unknown TLS fields, `true` and PostgreSQL string modes are rejected; arbitrary options and callbacks are not forwarded.
142
+
143
+ [mysql2 3.24.5 identity checks](https://github.com/sidorares/node-mysql2/blob/v3.24.5/lib/base/connection.js) depend on a DNS servername. With both certificate and identity checks enabled, IP targets return `CONFIG_ERROR`; use a hostname matching the certificate DNS SAN. Explicit `verifyIdentity: false` retains only chain verification. `rejectUnauthorized: false` abandons server authentication; encryption alone does not authenticate the server. Disabling either check emits a fixed risk warning and is not reported as a secure configuration.
144
+
145
+ ```ts
146
+ // Supply trustedCaPem from deployment trust configuration, not an arbitrary peer certificate.
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 tests require OpenSSL (Git for Windows includes one) and local container port forwarding. Each run generates and removes its own CA/private keys and checks trusted DNS, wrong CA/hostname rejection, `REQUIRE X509` client authentication and actual session `Ssl_cipher`. Disabling verification is not accepted as proof of authenticated TLS.
153
+
154
+ #### SQLite execution (asynchronous worker)
82
155
 
83
156
  better-sqlite3 is synchronous, so queries block Node's main event loop. WAL improves database lock concurrency but cannot remove this blocking. To offload it, opt in to a dedicated SQLite worker thread (single connection; defaults to `sync`):
84
157
 
@@ -96,7 +169,7 @@ await reldb.init({
96
169
 
97
170
  In worker mode, queries, writes, and transactions run serially on the worker thread. Public APIs and return values remain identical to synchronous mode.
98
171
 
99
- ### Operation logging
172
+ #### Operation logging
100
173
 
101
174
  Enable SQL operation logs with `operationLog` in the database configuration. Logs are emitted at the DML layer of reldb provider-base, without duplicate wrappers in `reldb-main.ts` or raw providers.
102
175
 
@@ -107,7 +180,7 @@ database: ./data/app.db
107
180
  operationLog:
108
181
  read: false # query / get / queryPage
109
182
  write: false # DDL, execute / batch, tx.begin / tx.commit / tx.rollback
110
- maxLength: 1000 # 参数序列化后的最大输出长度,超出会截断
183
+ maxLength: 1000 # maximum numeric summary length; 0 omits the summary, SQL text and values are never logged
111
184
  level: debug # info | debug | trace,默认 debug
112
185
  ```
113
186
 
@@ -127,11 +200,13 @@ await reldb.init({
127
200
  })
128
201
  ```
129
202
 
130
- ## Transactions
203
+ Operation logs contain only fixed operation names and numeric SQL/parameter/batch counts. SQL text, values and driver error content are excluded, including failure paths.
204
+
205
+ ### Transactions
131
206
 
132
207
  Three approaches:
133
208
 
134
- ### wrap (automatic commit/rollback)
209
+ #### wrap (automatic commit/rollback)
135
210
 
136
211
  ```ts
137
212
  const result = await reldb.tx.wrap(async (tx) => {
@@ -142,7 +217,7 @@ const result = await reldb.tx.wrap(async (tx) => {
142
217
  // 回调正常返回 → 自动提交;抛异常 → 自动回滚
143
218
  ```
144
219
 
145
- ### begin + commit (explicit commit)
220
+ #### begin + commit (explicit commit)
146
221
 
147
222
  ```ts
148
223
  const txResult = await reldb.tx.begin()
@@ -154,7 +229,7 @@ await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户2'])
154
229
  await tx.commit()
155
230
  ```
156
231
 
157
- ### begin + rollback (explicit rollback)
232
+ #### begin + rollback (explicit rollback)
158
233
 
159
234
  ```ts
160
235
  const txResult = await reldb.tx.begin()
@@ -166,7 +241,7 @@ await tx.execute('INSERT INTO users (name) VALUES (?)', ['用户1'])
166
241
  await tx.rollback()
167
242
  ```
168
243
 
169
- ### batch / queryPage inside transactions
244
+ #### batch / queryPage inside transactions
170
245
 
171
246
  ```ts
172
247
  await reldb.tx.wrap(async (tx) => {
@@ -182,7 +257,7 @@ await reldb.tx.wrap(async (tx) => {
182
257
  })
183
258
  ```
184
259
 
185
- ## Crud
260
+ ### Crud
186
261
 
187
262
  Create a single-table CRUD repository with `reldb.crud.table()`, without writing SQL:
188
263
 
@@ -197,7 +272,7 @@ const userCrud = reldb.crud.table<{ id: number, name: string, email: string }>({
197
272
  })
198
273
  ```
199
274
 
200
- ### create / createMany
275
+ #### create / createMany
201
276
 
202
277
  ```ts
203
278
  // 单条插入
@@ -211,7 +286,7 @@ await userCrud.createMany([
211
286
  ])
212
287
  ```
213
288
 
214
- ### createOrUpdate
289
+ #### createOrUpdate
215
290
 
216
291
  Update when the primary key exists; otherwise create (upsert):
217
292
 
@@ -230,7 +305,7 @@ await userCrud.createOrUpdate({ id: 1, name: '张三', email: 'test@example.com'
230
305
 
231
306
  `BaseReldbCrudRepository` obtains `dbType` automatically; no manual argument is needed.
232
307
 
233
- ### findById / getById / findAll / findPage
308
+ #### findById / getById / findAll / findPage
234
309
 
235
310
  ```ts
236
311
  // 主键查找(不存在时返回 null)
@@ -260,14 +335,14 @@ const page = await userCrud.findPage({
260
335
  // page.data → { items: [...], total: 100, page: 1, pageSize: 20 }
261
336
  ```
262
337
 
263
- ### updateById / deleteById
338
+ #### updateById / deleteById
264
339
 
265
340
  ```ts
266
341
  await userCrud.updateById(1, { name: '李四' })
267
342
  await userCrud.deleteById(1)
268
343
  ```
269
344
 
270
- ### count / exists / existsById
345
+ #### count / exists / existsById
271
346
 
272
347
  ```ts
273
348
  const total = await userCrud.count({ where: 'name LIKE ?', params: ['%张%'] })
@@ -280,7 +355,7 @@ const found = await userCrud.existsById(1)
280
355
  // found.data → true
281
356
  ```
282
357
 
283
- ### Crud inside transactions
358
+ #### Crud inside transactions
284
359
 
285
360
  All methods accept `tx` and automatically route operations to that transaction context:
286
361
 
@@ -292,10 +367,12 @@ await reldb.tx.wrap(async (tx) => {
292
367
  })
293
368
  ```
294
369
 
295
- ## BaseReldbCrudRepository
370
+ ### BaseReldbCrudRepository
296
371
 
297
372
  Business repositories extend `BaseReldbCrudRepository` and define field mappings with `fields` for automatic table creation and type conversion:
298
373
 
374
+ Use `BIGINT` for Unix milliseconds (64-bit INTEGER on SQLite, BIGINT on PostgreSQL/MySQL). A regular INTEGER on PostgreSQL/MySQL cannot hold current epoch milliseconds. Raw SQL/CRUD and base repository reads preserve driver BIGINT types: number/string/bigint. Business repositories requiring number must validate safe integers before conversion to avoid silent precision loss. The base repository reads TIMESTAMP fields as Date.
375
+
299
376
  ```ts
300
377
  import type { DmlWithTxOperations, ReldbCrudFieldDefinition } from '@h-ai/reldb'
301
378
  import { BaseReldbCrudRepository, reldb } from '@h-ai/reldb'
@@ -359,7 +436,7 @@ if (txResult.success) {
359
436
  }
360
437
  ```
361
438
 
362
- ## JSON path operations
439
+ ### JSON path operations
363
440
 
364
441
  Use `reldb.json` to build SQL expressions for consistent JSON path operations across databases.
365
442
 
@@ -375,7 +452,7 @@ Paths follow SQL/JSON Path (`$.key` / `$.key.subkey` / `$[0]`); each backend map
375
452
 
376
453
  All methods return `{ sql, params }` fragments that can be embedded in `reldb.sql.query` / `reldb.sql.execute`.
377
454
 
378
- ### extract — read a JSON field value
455
+ #### extract — read a JSON field value
379
456
 
380
457
  ```ts
381
458
  // 用于 WHERE 条件
@@ -393,7 +470,7 @@ const result = await reldb.sql.query(
393
470
  )
394
471
  ```
395
472
 
396
- ### set — set a JSON path (create or replace)
473
+ #### set — set a JSON path (create or replace)
397
474
 
398
475
  ```ts
399
476
  const { sql, params } = reldb.json.set('settings', '$.theme', 'dark')
@@ -403,7 +480,7 @@ await reldb.sql.execute(
403
480
  )
404
481
  ```
405
482
 
406
- ### insert — insert at a JSON path (only when absent)
483
+ #### insert — insert at a JSON path (only when absent)
407
484
 
408
485
  ```ts
409
486
  const { sql, params } = reldb.json.insert('data', '$.firstSeen', new Date().toISOString())
@@ -413,7 +490,7 @@ await reldb.sql.execute(
413
490
  )
414
491
  ```
415
492
 
416
- ### remove — remove a JSON path
493
+ #### remove — remove a JSON path
417
494
 
418
495
  ```ts
419
496
  const { sql, params } = reldb.json.remove('settings', '$.deprecated')
@@ -423,7 +500,7 @@ await reldb.sql.execute(
423
500
  )
424
501
  ```
425
502
 
426
- ### merge — merge JSON objects (RFC 7396 Merge Patch)
503
+ #### merge — merge JSON objects (RFC 7396 Merge Patch)
427
504
 
428
505
  ```ts
429
506
  // 覆盖已有键、保留未提及键、将 null 键删除
@@ -437,7 +514,9 @@ await reldb.sql.execute(
437
514
  )
438
515
  ```
439
516
 
440
- ### Complete example
517
+ `json.merge(column, patch)` implements RFC 7396 object patches on all three backends: preserve untouched nested keys, delete keys targeted by null, and replace arrays as a whole. PostgreSQL applies parameterized steps in a recursive CTE within one SQL statement, with no installed function or application read-modify-write. The patch root must be a JSON-serializable object; arbitrary JSON patch roots are outside this contract. Column expressions are developer-owned SQL; paths and values remain parameters. See [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396) and [PostgreSQL JSON operators](https://www.postgresql.org/docs/16/functions-json.html).
518
+
519
+ #### Complete example
441
520
 
442
521
  ```ts
443
522
  // 建表(含 JSON 列)
@@ -472,7 +551,7 @@ const { sql: rmSql, params: rmParams } = reldb.json.remove('meta', '$.reviewer')
472
551
  await reldb.sql.execute(`UPDATE products SET meta = ${rmSql} WHERE id = ?`, [...rmParams, 1])
473
552
  ```
474
553
 
475
- ## Error handling
554
+ ### Error handling
476
555
 
477
556
  All operations return `HaiResult<T>`; check `result.success` for success or failure.
478
557
 
@@ -497,17 +576,25 @@ if (!result.success) {
497
576
 
498
577
  Common error codes:
499
578
 
500
- | Error code | code | Description |
501
- | ------------------------------------ | --------------- | --------------------- |
502
- | `HaiReldbError.NOT_INITIALIZED` | `hai:reldb:010` | Not initialized |
503
- | `HaiReldbError.CONNECTION_FAILED` | `hai:reldb:001` | Connection failed |
504
- | `HaiReldbError.QUERY_FAILED` | `hai:reldb:002` | Query failed |
505
- | `HaiReldbError.CONSTRAINT_VIOLATION` | `hai:reldb:003` | Constraint violation |
506
- | `HaiReldbError.TRANSACTION_FAILED` | `hai:reldb:004` | Transaction failed |
507
- | `HaiReldbError.DDL_FAILED` | `hai:reldb:020` | DDL operation failed |
508
- | `HaiReldbError.CONFIG_ERROR` | `hai:reldb:013` | Invalid configuration |
579
+ | Error code | code | Description |
580
+ | ------------------------------------ | --------------- | ----------------------------------- |
581
+ | `HaiReldbError.NOT_INITIALIZED` | `hai:reldb:010` | Not initialized |
582
+ | `HaiReldbError.CONNECTION_FAILED` | `hai:reldb:001` | Connection failed |
583
+ | `HaiReldbError.QUERY_FAILED` | `hai:reldb:002` | Query failed |
584
+ | `HaiReldbError.CONSTRAINT_VIOLATION` | `hai:reldb:003` | Constraint violation |
585
+ | `HaiReldbError.TRANSACTION_FAILED` | `hai:reldb:004` | Transaction failed |
586
+ | `HaiReldbError.DDL_FAILED` | `hai:reldb:020` | DDL operation failed |
587
+ | `HaiReldbError.CONFIG_ERROR` | `hai:reldb:013` | Invalid configuration |
588
+ | `HaiReldbError.TIMEOUT` | `hai:reldb:009` | MySQL acquisition deadline exceeded |
589
+ | `HaiReldbError.POOL_EXHAUSTED` | `hai:reldb:011` | MySQL acquisition queue is full |
509
590
 
510
- ## Tests
591
+ Custom SQL methods in `BaseReldbCrudRepository` subclasses must check `await this.ensureReady()`. Construction starts table creation; service init must await candidate readiness before publication. PostgreSQL concurrent first `CREATE TABLE IF NOT EXISTS` retries the same DDL once only for `23505/pg_type_typname_nsp_index` catalog contention; other errors remain failures.
592
+
593
+ ## Production shared state
594
+
595
+ Production requires PostgreSQL/MySQL. SQLite, including disk files, is for development/explicit tests and does not provide cross-host shared state. Initialization checks apply when NODE_ENV or HAI_CORE_ENV is production; only explicit HAI_E2E=1/HAI_E2E_MOCK=1 tests are exempt. [State, concurrency and recovery (Chinese)](../../.agents/instructions/distributed-state.md)。
596
+
597
+ ## Development and testing
511
598
 
512
599
  ```bash
513
600
  pnpm --filter @h-ai/reldb test
@@ -515,6 +602,8 @@ pnpm --filter @h-ai/reldb test
515
602
 
516
603
  > MySQL/PostgreSQL tests require Docker.
517
604
 
605
+ Source JSDoc follows the [shared conventions](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证): public examples use published entry points, and internal examples are checked in their module scope. After building, run `pnpm check:jsdoc --package reldb`; compiling examples does not replace backend behavior tests.
606
+
518
607
  ## License
519
608
 
520
609
  Apache-2.0