@h-ai/reldb 0.1.0-alpha9 → 1.0.0-beta.10
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 +661 -0
- package/README.ja.md +661 -0
- package/README.md +258 -58
- package/dist/chunk-SDS2ZT3V.js +132 -0
- package/dist/chunk-SDS2ZT3V.js.map +1 -0
- package/dist/index.d.ts +648 -549
- package/dist/index.js +1285 -357
- package/dist/index.js.map +1 -1
- package/dist/providers/sqlite/reldb-sqlite-worker.mjs +143 -0
- package/dist/schemas.d.ts +490 -0
- package/dist/schemas.js +3 -0
- package/dist/schemas.js.map +1 -0
- package/package.json +25 -16
package/README.en.md
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
# @h-ai/reldb
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[Framework home](../../README.en.md) · Related modules: [@h-ai/core](../core/README.en.md)
|
|
6
|
+
|
|
7
|
+
## Introduction
|
|
8
|
+
|
|
9
|
+
A relational database module with a unified `reldb` object for SQLite, PostgreSQL, and MySQL.
|
|
10
|
+
|
|
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
|
|
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` 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
|
+
|
|
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
|
+
```
|
|
90
|
+
|
|
91
|
+
Use either a connection string (`url`) or separate fields (`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
|
+
#### 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)
|
|
155
|
+
|
|
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`):
|
|
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
|
+
In worker mode, queries, writes, and transactions run serially on the worker thread. Public APIs and return values remain identical to synchronous mode.
|
|
171
|
+
|
|
172
|
+
#### Operation logging
|
|
173
|
+
|
|
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.
|
|
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 # maximum numeric summary length; 0 omits the summary, SQL text and values are never logged
|
|
184
|
+
level: debug # info | debug | trace,默认 debug
|
|
185
|
+
```
|
|
186
|
+
|
|
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.
|
|
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
|
+
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.
|
|
204
|
+
|
|
205
|
+
### Transactions
|
|
206
|
+
|
|
207
|
+
Three approaches:
|
|
208
|
+
|
|
209
|
+
#### wrap (automatic commit/rollback)
|
|
210
|
+
|
|
211
|
+
A normally returned transaction callback commits, even if it returns a failed HaiResult. Check every operation and throw on failure to trigger rollback; wrap returns TRANSACTION_FAILED. Use an explicit transaction below to preserve the original domain error. The examples assume successful initialization and an existing users table.
|
|
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 (explicit 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 (explicit 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 inside transactions
|
|
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
|
+
Create a single-table CRUD repository with `reldb.crud.table()`, without writing 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
|
+
Update when the primary key exists; otherwise create (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` obtains `dbType` automatically; no manual argument is needed.
|
|
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 inside transactions
|
|
390
|
+
|
|
391
|
+
All methods accept `tx` and automatically route operations to that transaction context:
|
|
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
|
+
Business repositories extend `BaseReldbCrudRepository` and define field mappings with `fields` for automatic table creation and type conversion:
|
|
413
|
+
|
|
414
|
+
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.
|
|
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 path operations
|
|
480
|
+
|
|
481
|
+
Use `reldb.json` to build SQL expressions for consistent JSON path operations across databases.
|
|
482
|
+
|
|
483
|
+
Paths follow SQL/JSON Path (`$.key` / `$.key.subkey` / `$[0]`); each backend maps them to its own functions or operators.
|
|
484
|
+
|
|
485
|
+
| Operation | 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
|
+
All methods return `{ sql, params }` fragments that can be embedded in `reldb.sql.query` / `reldb.sql.execute`.
|
|
494
|
+
|
|
495
|
+
#### extract — read a JSON field value
|
|
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 — set a JSON path (create or replace)
|
|
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 — insert at a JSON path (only when absent)
|
|
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 — remove a JSON path
|
|
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 — merge JSON objects (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)` 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).
|
|
558
|
+
|
|
559
|
+
#### Complete example
|
|
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
|
+
### Error handling
|
|
595
|
+
|
|
596
|
+
All operations return `HaiResult<T>`; check `result.success` for success or failure.
|
|
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
|
+
Common error codes:
|
|
618
|
+
|
|
619
|
+
| Error code | code | Description |
|
|
620
|
+
| ------------------------------------ | --------------- | ----------------------------------- |
|
|
621
|
+
| `HaiReldbError.NOT_INITIALIZED` | `hai:reldb:010` | Not initialized |
|
|
622
|
+
| `HaiReldbError.CONNECTION_FAILED` | `hai:reldb:001` | Connection failed |
|
|
623
|
+
| `HaiReldbError.QUERY_FAILED` | `hai:reldb:002` | Query failed |
|
|
624
|
+
| `HaiReldbError.CONSTRAINT_VIOLATION` | `hai:reldb:003` | Constraint violation |
|
|
625
|
+
| `HaiReldbError.TRANSACTION_FAILED` | `hai:reldb:004` | Transaction failed |
|
|
626
|
+
| `HaiReldbError.DDL_FAILED` | `hai:reldb:020` | DDL operation failed |
|
|
627
|
+
| `HaiReldbError.CONFIG_ERROR` | `hai:reldb:013` | Invalid configuration |
|
|
628
|
+
| `HaiReldbError.TIMEOUT` | `hai:reldb:009` | MySQL acquisition deadline exceeded |
|
|
629
|
+
| `HaiReldbError.POOL_EXHAUSTED` | `hai:reldb:011` | MySQL acquisition queue is full |
|
|
630
|
+
|
|
631
|
+
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
|
+
|
|
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
|
|
642
|
+
|
|
643
|
+
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
|
+
|
|
645
|
+
HTTP errors carry safe localized messages and a reference ID. ToastContainer hides the ID by default and reveals it through its diagnostic icon. The same ID remains in `error.traceId` and `X-Trace-Id`. The framework reuses the request ID to correlate browser and server logs. Sanitized exceptions, stacks and operation context remain in logs; internal cause/ext are not exposed. Field validation returns localized field names and messages only.
|
|
646
|
+
|
|
647
|
+
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
|
+
|
|
649
|
+
## Development and testing
|
|
650
|
+
|
|
651
|
+
```bash
|
|
652
|
+
pnpm --filter @h-ai/reldb test
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
> MySQL/PostgreSQL tests require Docker.
|
|
656
|
+
|
|
657
|
+
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.
|
|
658
|
+
|
|
659
|
+
## License
|
|
660
|
+
|
|
661
|
+
Apache-2.0
|