@h-ai/reldb 1.0.0-beta.2 → 1.0.0-beta.21

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,33 +4,9 @@
4
4
 
5
5
  [Framework home](../../README.en.md) · Related modules: [@h-ai/core](../core/README.en.md)
6
6
 
7
- ## Introduction
8
-
9
7
  A relational database module with a unified `reldb` object for SQLite, PostgreSQL, and MySQL.
10
8
 
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.
30
-
31
- ## Getting started
32
-
33
- ### Quick start
9
+ ## Quick Start
34
10
 
35
11
  ```ts
36
12
  import { HaiReldbError, reldb } from '@h-ai/reldb'
@@ -75,135 +51,110 @@ await reldb.close()
75
51
 
76
52
  `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.
77
53
 
78
- ### Configuration
54
+ ## Features
79
55
 
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.
56
+ 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.
81
57
 
82
58
  ```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 }
59
+ const closed = await reldb.close()
60
+ if (!closed.success) {
61
+ // Retry once after addressing the cleanup failure; report another failure.
62
+ const retried = await reldb.close()
63
+ if (!retried.success)
64
+ throw new Error(retried.error.message)
65
+ }
89
66
  ```
90
67
 
91
- Use either a connection string (`url`) or separate fields (`host/port/database/user/password`).
68
+ 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.
92
69
 
93
- ```ts
94
- // SQLite
95
- await reldb.init({ type: 'sqlite', database: './data.db' })
70
+ ### Operations and results
96
71
 
97
- // PostgreSQL
98
- await reldb.init({ type: 'postgresql', url: 'postgres://user:pass@localhost:5432/mydb', database: 'mydb' })
72
+ Use the complete initialization flow in this section first. The host validates and authorizes example business values; check success before reading data from asynchronous domain results. Here void means completion without data, null means absent; synchronous factories/tools return the listed type directly.
99
73
 
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
- ```
74
+ Table/column/index names and SQL come from trusted code; params bind business values, steps define module migrations and options control preview/pagination.
112
75
 
113
- #### Connection pools and MySQL URLs
76
+ #### reldb.ddl
114
77
 
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.
78
+ Maintain tables, columns and indexes. Supply trusted identifiers, column/index definitions or DDL; success contains void, invalid changes fail.
116
79
 
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 |
80
+ | Entry | Input parameters | Successful result | Call example |
81
+ | ----------------------- | -------------------------------- | ----------------- | ------------------------------------------------------------------------------------ |
82
+ | `reldb.ddl.createTable` | tableName, columns, ifNotExists | `void` | `await reldb.ddl.createTable('orders', { id: { type: 'TEXT', primaryKey: true } })` |
83
+ | `reldb.ddl.dropTable` | tableName, ifExists | `void` | `await reldb.ddl.dropTable('orders_archive', true)` |
84
+ | `reldb.ddl.addColumn` | tableName, columnName, columnDef | `void` | `await reldb.ddl.addColumn('orders', 'note', { type: 'TEXT' })` |
85
+ | `reldb.ddl.dropColumn` | tableName, columnName | `void` | `await reldb.ddl.dropColumn('orders', 'note')` |
86
+ | `reldb.ddl.renameTable` | oldName, newName | `void` | `await reldb.ddl.renameTable('orders_draft', 'orders')` |
87
+ | `reldb.ddl.createIndex` | tableName, indexName, indexDef | `void` | `await reldb.ddl.createIndex('orders', 'orders_status', { columns: ['status'] })` |
88
+ | `reldb.ddl.dropIndex` | indexName, ifExists | `void` | `await reldb.ddl.dropIndex('orders_status', true)` |
89
+ | `reldb.ddl.raw` | sql | `void` | `await reldb.ddl.raw('CREATE TABLE IF NOT EXISTS app_marker (id TEXT PRIMARY KEY)')` |
124
90
 
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.
91
+ #### reldb.sql
126
92
 
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.
93
+ Read/write business data with bound params: rows, first row/null, write result, void or a page respectively; batch takes statement objects.
128
94
 
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:
95
+ | Entry | Input parameters | Successful result | Call example |
96
+ | --------------------- | ---------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------- |
97
+ | `reldb.sql.query` | sql, params | `T[]` | `await reldb.sql.query('SELECT id FROM orders WHERE status = ?', ['pending'])` |
98
+ | `reldb.sql.get` | sql, params | `T \| null` | `await reldb.sql.get('SELECT id FROM orders WHERE id = ?', ['o1'])` |
99
+ | `reldb.sql.execute` | sql, params | `ExecuteResult` | `await reldb.sql.execute('UPDATE orders SET status = ? WHERE id = ?', ['paid', 'o1'])` |
100
+ | `reldb.sql.batch` | statements | `void` | `await reldb.sql.batch([{ sql: 'DELETE FROM orders WHERE id = ?', params: ['o1'] }])` |
101
+ | `reldb.sql.queryPage` | options | `PaginatedResult<T>` | `await reldb.sql.queryPage({ sql: 'SELECT id FROM orders ORDER BY id', pagination: { page: 1, pageSize: 20 } })` |
130
102
 
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
- ```
103
+ #### reldb.pagination
136
104
 
137
- An empty MySQL `url` (including an empty environment placeholder) selects host/port/database/user/password; nonempty URLs remain strictly validated.
105
+ Pagination: normalize accepts page options and limits and returns normalized options; build accepts items, total and paging and returns the page object directly.
138
106
 
139
- #### MySQL TLS
107
+ | Entry | Input parameters | Successful result | Call example |
108
+ | ---------------------------- | ------------------------ | ---------------------- | ---------------------------------------------------------- |
109
+ | `reldb.pagination.normalize` | options, overrides | `NormalizedPagination` | `reldb.pagination.normalize({ page: 1, pageSize: 20 })` |
110
+ | `reldb.pagination.build` | items, total, pagination | `PaginatedResult<T>` | `reldb.pagination.build([], 0, { page: 1, pageSize: 20 })` |
140
111
 
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.
112
+ #### reldb
142
113
 
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.
114
+ Prepare module persistence: table name returns columns; module name, steps and dryRun return a migration plan. Back up and stop conflicting writers before applying.
144
115
 
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.
116
+ | Entry | Input parameters | Successful result | Call example |
117
+ | --------------------- | ---------------------- | --------------------- | ----------------------------------------------------------------------- |
118
+ | `reldb.schemaColumns` | table, sql | `string[]` | `await reldb.schemaColumns('orders')` |
119
+ | `reldb.migrateSchema` | module, steps, options | `SchemaMigrationPlan` | `await reldb.migrateSchema('orders', migrationSteps, { dryRun: true })` |
153
120
 
154
- #### SQLite execution (asynchronous worker)
121
+ #### reldb.tx
155
122
 
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`):
123
+ Atomic writes: begin yields a transaction with query/get/execute/batch/queryPage/commit/rollback; wrap runs a callback and returns its value. A failed HaiResult must explicitly trigger rollback.
157
124
 
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
- ```
125
+ | Entry | Input parameters | Successful result | Call example |
126
+ | ---------------- | ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `reldb.tx.begin` | No arguments | `DmlWithTxOperations` | `await reldb.tx.begin()` |
128
+ | `reldb.tx.wrap` | fn | `T` | `await reldb.tx.wrap(async tx => { const found = await tx.get('SELECT 1 AS value'); if (!found.success) throw new Error(found.error.message); return found.data })` |
169
129
 
170
- In worker mode, queries, writes, and transactions run serially on the worker thread. Public APIs and return values remain identical to synchronous mode.
130
+ #### reldb.crud
171
131
 
172
- #### Operation logging
132
+ Supply table/field mappings for a business entity; obtain a repository directly for CRUD and pagination without exposing a database connection.
173
133
 
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.
134
+ | Entry | Input parameters | Successful result | Call example |
135
+ | ------------------ | ---------------- | ---------------------------- | ----------------------------------------- |
136
+ | `reldb.crud.table` | config | `ReldbCrudRepository<TItem>` | `reldb.crud.table(orderRepositoryConfig)` |
175
137
 
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 # maximum numeric summary length; 0 omits the summary, SQL text and values are never logged
184
- level: debug # info | debug | trace,默认 debug
185
- ```
138
+ #### reldb.json
186
139
 
187
- When loading through `core.config.load('db', ...)`, convention-based variables such as `HAI_DB_TYPE`,
188
- `HAI_DB_DATABASE`, and `HAI_DB_OPERATIONLOG_LEVEL` override YAML values.
140
+ Cross-dialect JSON expressions: trusted column, JSON path and value/patch return SQL plus bound params directly, without executing a query.
189
141
 
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
- ```
142
+ | Entry | Input parameters | Successful result | Call example |
143
+ | -------------------- | ------------------- | ----------------- | --------------------------------------------------- |
144
+ | `reldb.json.extract` | column, path | `JsonSqlExpr` | `reldb.json.extract('payload', '$.status')` |
145
+ | `reldb.json.set` | column, path, value | `JsonSqlExpr` | `reldb.json.set('payload', '$.status', 'paid')` |
146
+ | `reldb.json.insert` | column, path, value | `JsonSqlExpr` | `reldb.json.insert('payload', '$.note', 'checked')` |
147
+ | `reldb.json.remove` | column, path | `JsonSqlExpr` | `reldb.json.remove('payload', '$.note')` |
148
+ | `reldb.json.merge` | column, patch | `JsonSqlExpr` | `reldb.json.merge('payload', { status: 'paid' })` |
202
149
 
203
- Operation logs contain fixed operation names and SQL length, parameter count, and batch count. Failure logs also retain error codes, traceId, and allowlisted, sanitized driver messages and stacks for diagnosis. Full SQL, bound parameters, and connection configuration are not logged directly.
150
+ - Connect to SQLite, PostgreSQL and MySQL through one API.
151
+ - Run parameterized SQL, manage tables/indexes and read paginated results.
152
+ - Organize application reads and writes with transactions, CRUD and repositories.
204
153
 
205
154
  ### Transactions
206
155
 
156
+ A transaction has one finishing owner. Once commit or rollback starts, new queries, duplicate commits, and concurrent rollbacks immediately return TRANSACTION_FAILED; the connection is released once. Await the first finishing result rather than using concurrent completion as recovery.
157
+
207
158
  Three approaches:
208
159
 
209
160
  #### wrap (automatic commit/rollback)
@@ -272,8 +223,14 @@ if (!rolledBack.success)
272
223
  ```ts
273
224
  await reldb.tx.wrap(async (tx) => {
274
225
  const inserted = await tx.batch([
275
- { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Alice'] },
276
- { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Bob'] },
226
+ {
227
+ sql: 'INSERT INTO users (name) VALUES (?)',
228
+ params: ['Alice']
229
+ },
230
+ {
231
+ sql: 'INSERT INTO users (name) VALUES (?)',
232
+ params: ['Bob']
233
+ },
277
234
  ])
278
235
  if (!inserted.success)
279
236
  throw new Error(inserted.error.message, { cause: inserted.error })
@@ -376,10 +333,16 @@ await userCrud.deleteById(1)
376
333
  #### count / exists / existsById
377
334
 
378
335
  ```ts
379
- const total = await userCrud.count({ where: 'name LIKE ?', params: ['%张%'] })
336
+ const total = await userCrud.count({
337
+ where: 'name LIKE ?',
338
+ params: ['%张%']
339
+ })
380
340
  // total.data → 5
381
341
 
382
- const has = await userCrud.exists({ where: 'email = ?', params: ['test@example.com'] })
342
+ const has = await userCrud.exists({
343
+ where: 'email = ?',
344
+ params: ['test@example.com']
345
+ })
383
346
  // has.data → true
384
347
 
385
348
  const found = await userCrud.existsById(1)
@@ -426,11 +389,46 @@ interface UserRow {
426
389
  }
427
390
 
428
391
  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 },
392
+ {
393
+ fieldName: 'id',
394
+ columnName: 'id',
395
+ def: { type: 'INTEGER', primaryKey: true, autoIncrement: true },
396
+ select: true,
397
+ create: false,
398
+ update: false
399
+ },
400
+ {
401
+ fieldName: 'name',
402
+ columnName: 'name',
403
+ def: { type: 'TEXT', notNull: true },
404
+ select: true,
405
+ create: true,
406
+ update: true
407
+ },
408
+ {
409
+ fieldName: 'email',
410
+ columnName: 'email',
411
+ def: { type: 'TEXT', notNull: true },
412
+ select: true,
413
+ create: true,
414
+ update: true
415
+ },
416
+ {
417
+ fieldName: 'createdAt',
418
+ columnName: 'created_at',
419
+ def: { type: 'TIMESTAMP', notNull: true },
420
+ select: true,
421
+ create: true,
422
+ update: false
423
+ },
424
+ {
425
+ fieldName: 'updatedAt',
426
+ columnName: 'updated_at',
427
+ def: { type: 'TIMESTAMP', notNull: true },
428
+ select: true,
429
+ create: true,
430
+ update: false
431
+ },
434
432
  ]
435
433
 
436
434
  class UserRepository extends BaseReldbCrudRepository<UserRow> {
@@ -440,7 +438,11 @@ class UserRepository extends BaseReldbCrudRepository<UserRow> {
440
438
 
441
439
  /** 自定义查询方法 */
442
440
  async findByEmail(email: string) {
443
- return this.findAll({ where: 'email = ?', params: [email], limit: 1 })
441
+ return this.findAll({
442
+ where: 'email = ?',
443
+ params: [email],
444
+ limit: 1
445
+ })
444
446
  }
445
447
 
446
448
  /** 自定义方法中使用 this.sql(tx) 自动路由到事务 */
@@ -568,7 +570,13 @@ await reldb.ddl.createTable('products', {
568
570
  // 插入初始 JSON
569
571
  await reldb.sql.execute(
570
572
  `INSERT INTO products (meta) VALUES (?)`,
571
- [JSON.stringify({ status: 'draft', tags: ['new'], version: 1 })],
573
+ [
574
+ JSON.stringify({
575
+ status: 'draft',
576
+ tags: ['new'],
577
+ version: 1
578
+ })
579
+ ],
572
580
  )
573
581
 
574
582
  // 设置字段
@@ -591,7 +599,159 @@ const { sql: rmSql, params: rmParams } = reldb.json.remove('meta', '$.reviewer')
591
599
  await reldb.sql.execute(`UPDATE products SET meta = ${rmSql} WHERE id = ?`, [...rmParams, 1])
592
600
  ```
593
601
 
594
- ### Error handling
602
+ ### Production shared state
603
+
604
+ 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).
605
+
606
+ ### Module schema migrations
607
+
608
+ `reldb.schemaColumns(table, sql?)` inspects real columns. `reldb.migrateSchema(module, steps, { dryRun: true })` creates no ledger. Modules own their steps. Execution journals versions in `hai_schema_migrations`; SQLite/PostgreSQL are transactional, while MySQL implicit DDL commits require reentrant steps. Failures do not advance versions; retries inspect real state. Older code rejects newer ledgers. Stop old writers and back up DB/session cache first. Rollback restores compatible database and binaries together. See the [upgrade guide](../../docs/usage/README.en.md).
609
+
610
+ ## Configuration
611
+
612
+ Lifecycle: init takes backend/business configuration; use the module only after success. Await close before releasing borrowed dependencies. Follow the quick-start example and public signatures for exact return values.
613
+
614
+ `@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.
615
+
616
+ ```ts
617
+ import { ReldbConfigSchema } from '@h-ai/reldb/schemas'
618
+
619
+ const checked = ReldbConfigSchema.safeParse({ type: 'mysql', database: 'shop', ssl: {} })
620
+ if (!checked.success)
621
+ throw new Error('Invalid database configuration')
622
+ // checked.data.ssl: { rejectUnauthorized: true, verifyIdentity: true }
623
+ ```
624
+
625
+ Use either a connection string (`url`) or separate fields (`host/port/database/user/password`).
626
+
627
+ ```ts
628
+ // SQLite
629
+ await reldb.init({ type: 'sqlite', database: './data.db' })
630
+
631
+ // PostgreSQL
632
+ await reldb.init({ type: 'postgresql', url: 'postgres://user:pass@localhost:5432/mydb', database: 'mydb' })
633
+
634
+ // MySQL
635
+ await reldb.init({
636
+ type: 'mysql',
637
+ host: 'localhost',
638
+ port: 3306,
639
+ database: 'mydb',
640
+ user: 'admin',
641
+ password: 'secret',
642
+ mysql: { charset: 'utf8mb4' },
643
+ pool: { max: 10, acquireTimeout: 5000, queueLimit: 100 },
644
+ })
645
+ ```
646
+
647
+ ### Connection pools and MySQL URLs
648
+
649
+ 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.
650
+
651
+ | Field | PostgreSQL (defaults when `pool` is provided) | MySQL |
652
+ | ---------------- | ------------------------------------------------------------- | --------------------------------------------------- |
653
+ | `max` | 10 | 10, counting borrowed and connecting slots together |
654
+ | `min` | 1, retention floor for established connections; no prewarming | Unsupported; rejected |
655
+ | `idleTimeout` | 30000 ms; 0 disables | Unsupported; rejected |
656
+ | `acquireTimeout` | 10000 ms; 0 disables | 10000 ms; integer from 1 to 2147483647 |
657
+ | `queueLimit` | Unsupported | 1000; positive integer limiting queued requests |
658
+
659
+ 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.
660
+
661
+ 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.
662
+
663
+ 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:
664
+
665
+ ```ts
666
+ const initialized = await reldb.init({
667
+ type: 'mysql',
668
+ url: process.env.HAI_DB_URL,
669
+ pool: { max: 10, acquireTimeout: 5000, queueLimit: 100 }
670
+ })
671
+ if (!initialized.success)
672
+ throw new Error(initialized.error.message)
673
+ ```
674
+
675
+ An empty MySQL `url` (including an empty environment placeholder) selects host/port/database/user/password; nonempty URLs remain strictly validated.
676
+
677
+ #### MySQL TLS
678
+
679
+ 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.
680
+
681
+ [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.
682
+
683
+ ```ts
684
+ // Supply trustedCaPem from deployment trust configuration, not an arbitrary peer certificate.
685
+ const initialized = await reldb.init({
686
+ type: 'mysql',
687
+ host: 'db.example.com',
688
+ database: 'app',
689
+ ssl: { ca: trustedCaPem }
690
+ })
691
+ if (!initialized.success)
692
+ throw new Error(initialized.error.message)
693
+ ```
694
+
695
+ 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.
696
+
697
+ #### SQLite execution (asynchronous worker)
698
+
699
+ 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`):
700
+
701
+ ```ts
702
+ await reldb.init({
703
+ type: 'sqlite',
704
+ database: './data.db',
705
+ sqlite: {
706
+ executor: 'worker', // 'sync'(默认)| 'worker'
707
+ walMode: true, // 默认 true
708
+ workerConnectTimeoutMs: 10000, // worker 打开连接超时,默认 10000
709
+ },
710
+ })
711
+ ```
712
+
713
+ In worker mode, queries, writes, and transactions run serially on the worker thread. Public APIs and return values remain identical to synchronous mode.
714
+
715
+ #### Operation logging
716
+
717
+ 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.
718
+
719
+ ```yaml
720
+ # config/_db.yml
721
+ type: sqlite
722
+ database: ./data/app.db
723
+ operationLog:
724
+ read: false # query / get / queryPage
725
+ write: false # DDL, execute / batch, tx.begin / tx.commit / tx.rollback
726
+ maxLength: 1000 # maximum numeric summary length; 0 omits the summary, SQL text and values are never logged
727
+ level: debug # info | debug | trace,默认 debug
728
+ ```
729
+
730
+ When loading through `core.config.load('db', ...)`, convention-based variables such as `HAI_DB_TYPE`,
731
+ `HAI_DB_DATABASE`, and `HAI_DB_OPERATIONLOG_LEVEL` override YAML values.
732
+
733
+ ```ts
734
+ await reldb.init({
735
+ type: 'sqlite',
736
+ database: './data.db',
737
+ operationLog: {
738
+ read: true,
739
+ write: true,
740
+ maxLength: 500,
741
+ level: 'debug',
742
+ },
743
+ })
744
+ ```
745
+
746
+ Operation logs contain fixed operation names and SQL length, parameter count, and batch count. Failure logs also retain error codes, traceId, and allowlisted, sanitized driver messages and stacks for diagnosis. Full SQL, bound parameters, and connection configuration are not logged directly.
747
+
748
+ The host loads and validates configuration before passing it to this module. Keep secrets in private server environment files. Select the backend, capacity, deadlines and logging for the actual use case; reinitialize and check the result after changes.
749
+
750
+ [Per-option purpose, use cases and examples](../../docs/usage/configuration/data.md).
751
+
752
+ ## Error Handling
753
+
754
+ Check result.success for domain operations. Handle failures with the module error definitions and safe messages; retain codes, correlation IDs and sanitized causes in server logs. Synchronous tools, factories, stream iteration and lifecycle calls follow their own signatures. A completed Promise alone is not business success. See each feature for recovery limits.
595
755
 
596
756
  All operations return `HaiResult<T>`; check `result.success` for success or failure.
597
757
 
@@ -630,15 +790,7 @@ Common error codes:
630
790
 
631
791
  Custom SQL methods in `BaseReldbCrudRepository` subclasses must check `await this.ensureReady()`. Construction starts table creation; service init must await candidate readiness before publication. PostgreSQL retries a single autocommit `CREATE TABLE/INDEX IF NOT EXISTS` once only for `23505/pg_class_relname_nsp_index` catalog contention (also `pg_type_typname_nsp_index` for tables), or a table row-type race reported as `42710` with server routine `heap_create_with_catalog` / `TypeCreate`, consistently through DDL and SQL entry points. A failed retry remains an error. Transactions, business uniqueness errors and other failures are not retried. Initial connection probing attempts temporary DNS `EAI_AGAIN` failures at most three times, with 250/500 ms delays; business SQL is never replayed.
632
792
 
633
- ## Production shared state
634
-
635
- 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)。
636
-
637
- ## Module schema migrations
638
-
639
- `schemaColumns(reldb, table, sql?)` inspects real columns. `migrateSchema(reldb, module, steps, { dryRun: true })` creates no ledger. Modules own their steps. Execution journals versions in `hai_schema_migrations`; SQLite/PostgreSQL are transactional, while MySQL implicit DDL commits require reentrant steps. Failures do not advance versions; retries inspect real state. Older code rejects newer ledgers. Stop old writers and back up DB/session cache first. Rollback restores compatible database and binaries together. See the [upgrade guide](../../docs/usage/README.en.md).
640
-
641
- ## Error messages and correlated logs
793
+ ### Error messages and correlated logs
642
794
 
643
795
  Unique constraint violations return `HaiReldbError.DUPLICATE_ENTRY` (409); timeout, pool exhaustion and other existing error codes remain intact. Branch on error codes rather than localized messages or driver text. Logs preserve driver code/schema/routine/constraint and stack line numbers; numeric bound values do not obscure these diagnostic identifiers.
644
796
 
@@ -646,7 +798,7 @@ HTTP errors carry safe localized messages and a reference ID. ToastContainer hid
646
798
 
647
799
  Use `core.error.toPublic(error, traceId?)` at HTTP/UI boundaries and `core.error.formatMessage(error)` after translating messages; formatting is idempotent. Pass complete Error objects to logs instead of discarding their context. Database failures log driver codes, sanitized messages/stacks and table/column context; SQL text, bound values and connection credentials are excluded.
648
800
 
649
- ## Development and testing
801
+ ## Development
650
802
 
651
803
  ```bash
652
804
  pnpm --filter @h-ai/reldb test