@maestro-js/db 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +386 -0
- package/dist/index.d.ts +256 -0
- package/dist/index.js +588 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# @maestro-js/db
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The `@maestro-js/db` package provides typed database access through the Maestro provider pattern. It wraps `mysql2` connection pools with parameterized query methods, automatic transactions, streaming result sets with backpressure, and structured logging. All queries use raw parameterized SQL -- there is no ORM.
|
|
6
|
+
|
|
7
|
+
## Quick Setup
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Db } from '@maestro-js/db'
|
|
11
|
+
import { Log } from '@maestro-js/log'
|
|
12
|
+
|
|
13
|
+
Db.Provider.register('default', Db.Provider.create({
|
|
14
|
+
driver: Db.drivers.mysql({
|
|
15
|
+
host: process.env.DB_HOST!,
|
|
16
|
+
user: process.env.DB_USER!,
|
|
17
|
+
password: process.env.DB_PASSWORD!,
|
|
18
|
+
database: process.env.DB_DATABASE!,
|
|
19
|
+
port: Number(process.env.DB_PORT ?? 3306)
|
|
20
|
+
})
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
// Query immediately
|
|
24
|
+
const users = await Db.select<User>('SELECT * FROM users WHERE active = ?', [true])
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Driver: MySQL (mysql2)
|
|
28
|
+
|
|
29
|
+
The only built-in driver. Created via `Db.drivers.mysql(config)`.
|
|
30
|
+
|
|
31
|
+
### Configuration
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
Db.drivers.mysql({
|
|
35
|
+
// Required
|
|
36
|
+
host: 'localhost',
|
|
37
|
+
user: 'root',
|
|
38
|
+
password: '', // empty string is allowed, but the field is required
|
|
39
|
+
database: 'myapp',
|
|
40
|
+
port: 3306,
|
|
41
|
+
|
|
42
|
+
// Optional
|
|
43
|
+
connectionLimit: 10, // max pool connections (default: 10)
|
|
44
|
+
debug: false,
|
|
45
|
+
multipleStatements: false,
|
|
46
|
+
ssl: undefined, // string or tls.SecureContextOptions
|
|
47
|
+
|
|
48
|
+
// Optional loggers (default: Log.create())
|
|
49
|
+
connectionLogger: Log.create(), // logs connection lifecycle events
|
|
50
|
+
queryLogger: Log.create() // logs query start/complete/fail and transaction events
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Type Casting
|
|
55
|
+
|
|
56
|
+
The MySQL driver automatically casts:
|
|
57
|
+
- `DATE` columns to ISO date strings
|
|
58
|
+
- `TIME` columns to `iso-fns` time objects
|
|
59
|
+
- `DATETIME` columns to ISO 8601 strings (UTC)
|
|
60
|
+
- `TINYINT(1)` columns to booleans (`true`/`false`), with `null` preserved for nullable columns
|
|
61
|
+
- JSON columns returned as strings (`jsonStrings: true`)
|
|
62
|
+
- Decimal columns returned as numbers (`decimalNumbers: true`)
|
|
63
|
+
|
|
64
|
+
### Connection Behavior
|
|
65
|
+
|
|
66
|
+
- Pool uses `utf8mb4_0900_ai_ci` charset (MySQL 8.0+ default)
|
|
67
|
+
- Connect timeout: 60 minutes
|
|
68
|
+
- Connections are acquired with retry (up to 10 attempts, 1s delay)
|
|
69
|
+
- Deadlock and lock-timeout errors are retried automatically (up to 3 attempts) for non-transaction queries
|
|
70
|
+
|
|
71
|
+
## API Reference
|
|
72
|
+
|
|
73
|
+
### select
|
|
74
|
+
|
|
75
|
+
Execute a SELECT query and return typed results as an array.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
select<T = Record<string, unknown>>(query: string, parameters?: any[]): Promise<T[]>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
interface User { id: number; name: string; active: boolean }
|
|
83
|
+
|
|
84
|
+
const admins = await Db.select<User>('SELECT * FROM users WHERE role = ?', ['admin'])
|
|
85
|
+
// [{ id: 1, name: 'Alice', active: true }, { id: 2, name: 'Bob', active: true }]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### selectIterable
|
|
89
|
+
|
|
90
|
+
Execute a SELECT query and return a streaming async iterable with backpressure support. Uses `Helpers.AsyncIterableCollection` which provides `map`, `filter`, and `toArray` chainable methods.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
selectIterable<T = Record<string, unknown>>(query: string, parameters?: any[]): Helpers.AsyncIterableCollection<T>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const orders = Db.selectIterable<Order>('SELECT * FROM orders WHERE status = ?', ['pending'])
|
|
98
|
+
|
|
99
|
+
for await (const order of orders) {
|
|
100
|
+
await processOrder(order)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Or with chaining
|
|
104
|
+
const names = await Db.selectIterable<User>('SELECT * FROM users')
|
|
105
|
+
.filter(user => user.active)
|
|
106
|
+
.map(user => user.name)
|
|
107
|
+
.toArray()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Backpressure: the driver pauses the MySQL connection when the internal row queue exceeds 3000 rows and resumes when it drains below that threshold.
|
|
111
|
+
|
|
112
|
+
### insert
|
|
113
|
+
|
|
114
|
+
Execute an INSERT query and return the affected row count and auto-increment ID.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
insert(query: string, parameters?: any[]): Promise<{ affectedRows: number; insertId: number }>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const { insertId } = await Db.insert(
|
|
122
|
+
'INSERT INTO users (name, email) VALUES (?, ?)',
|
|
123
|
+
['Alice', 'alice@example.com']
|
|
124
|
+
)
|
|
125
|
+
// insertId: 42
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### update
|
|
129
|
+
|
|
130
|
+
Execute an UPDATE query and return matched and changed row counts.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
update(query: string, parameters?: any[]): Promise<{ affectedRows: number; changedRows: number }>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const { changedRows } = await Db.update(
|
|
138
|
+
'UPDATE users SET active = ? WHERE last_login < ?',
|
|
139
|
+
[false, '2024-01-01']
|
|
140
|
+
)
|
|
141
|
+
// changedRows: 15
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### delete
|
|
145
|
+
|
|
146
|
+
Execute a DELETE query and return the number of removed rows.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
delete(query: string, parameters?: any[]): Promise<{ affectedRows: number }>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const { affectedRows } = await Db.delete(
|
|
154
|
+
'DELETE FROM sessions WHERE expires_at < ?',
|
|
155
|
+
[new Date().toISOString()]
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### scalar
|
|
160
|
+
|
|
161
|
+
Return the first column of the first row, or `null` if no results. Returns `null` both when the query returns zero rows and when the value itself is SQL NULL.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
scalar<T = unknown>(query: string, parameters?: any[]): Promise<T | null>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const count = await Db.scalar<number>('SELECT COUNT(*) FROM users WHERE active = ?', [true])
|
|
169
|
+
// 42
|
|
170
|
+
|
|
171
|
+
const name = await Db.scalar<string>('SELECT name FROM users WHERE id = ?', [999])
|
|
172
|
+
// null (no matching row)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### statement
|
|
176
|
+
|
|
177
|
+
Execute an arbitrary SQL statement and return the raw result. Use for DDL or other statements that do not fit the typed methods.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
statement<T = unknown>(query: string, parameters?: any[]): Promise<T>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
await Db.statement('CREATE INDEX idx_users_email ON users (email)')
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### transaction
|
|
188
|
+
|
|
189
|
+
Wrap a callback in BEGIN/COMMIT/ROLLBACK. All `Db` calls awaited inside the callback share the same connection via `AsyncLocalStorage`. Nested transaction calls reuse the outer transaction (no savepoints).
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
transaction<T>(callback: () => Promise<T>): Promise<T>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
await Db.transaction(async () => {
|
|
197
|
+
await Db.update('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, fromId])
|
|
198
|
+
await Db.update('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, toId])
|
|
199
|
+
})
|
|
200
|
+
// Both updates commit together; if either throws, both roll back
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### format
|
|
204
|
+
|
|
205
|
+
Interpolate parameters into a query string for debugging. Does not execute the query.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
format(query: string, params?: any[]): string
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const sql = Db.format('SELECT * FROM users WHERE id = ?', [42])
|
|
213
|
+
// "SELECT * FROM users WHERE id = 42"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### destroy
|
|
217
|
+
|
|
218
|
+
Close all connections in the pool and prevent new ones from being created.
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
destroy(): Promise<void>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Common Patterns
|
|
225
|
+
|
|
226
|
+
### Parameterized Queries
|
|
227
|
+
|
|
228
|
+
Always use `?` placeholders. Never interpolate values into SQL strings.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
// Correct
|
|
232
|
+
const users = await Db.select<User>('SELECT * FROM users WHERE name = ?', ['Alice'])
|
|
233
|
+
|
|
234
|
+
// WRONG - SQL injection risk
|
|
235
|
+
const users = await Db.select<User>(`SELECT * FROM users WHERE name = '${name}'`)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Avoid Table Name Aliases
|
|
239
|
+
|
|
240
|
+
Write full table names in SQL queries instead of aliases when possible.
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
// Preferred
|
|
244
|
+
await Db.select('SELECT users.name, orders.total FROM users JOIN orders ON orders.user_id = users.id')
|
|
245
|
+
|
|
246
|
+
// Avoid
|
|
247
|
+
await Db.select('SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id')
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Transactions with Error Handling
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
try {
|
|
254
|
+
const orderId = await Db.transaction(async () => {
|
|
255
|
+
const { insertId } = await Db.insert(
|
|
256
|
+
'INSERT INTO orders (user_id, total) VALUES (?, ?)',
|
|
257
|
+
[userId, total]
|
|
258
|
+
)
|
|
259
|
+
await Db.insert(
|
|
260
|
+
'INSERT INTO order_items (order_id, product_id, quantity) VALUES (?, ?, ?)',
|
|
261
|
+
[insertId, productId, quantity]
|
|
262
|
+
)
|
|
263
|
+
await Db.update(
|
|
264
|
+
'UPDATE products SET stock = stock - ? WHERE id = ?',
|
|
265
|
+
[quantity, productId]
|
|
266
|
+
)
|
|
267
|
+
return insertId
|
|
268
|
+
})
|
|
269
|
+
} catch (e) {
|
|
270
|
+
// All three statements rolled back
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Streaming Large Result Sets
|
|
275
|
+
|
|
276
|
+
Use `selectIterable` for queries returning many rows to avoid loading everything into memory.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
const rows = Db.selectIterable<AuditLog>('SELECT * FROM audit_logs WHERE created_at > ?', ['2024-01-01'])
|
|
280
|
+
|
|
281
|
+
let count = 0
|
|
282
|
+
for await (const row of rows) {
|
|
283
|
+
await archiveToS3(row)
|
|
284
|
+
count++
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Multiple Providers
|
|
289
|
+
|
|
290
|
+
Register named providers for separate databases.
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
Db.Provider.register('default', Db.Provider.create({
|
|
294
|
+
driver: Db.drivers.mysql({ host: 'primary-db', user: 'app', password: '', database: 'myapp', port: 3306 })
|
|
295
|
+
}))
|
|
296
|
+
|
|
297
|
+
Db.Provider.register('analytics', Db.Provider.create({
|
|
298
|
+
driver: Db.drivers.mysql({ host: 'analytics-db', user: 'app', password: '', database: 'analytics', port: 3306 })
|
|
299
|
+
}))
|
|
300
|
+
|
|
301
|
+
// Use the default provider
|
|
302
|
+
const users = await Db.select<User>('SELECT * FROM users')
|
|
303
|
+
|
|
304
|
+
// Use a named provider
|
|
305
|
+
const events = await Db.provider('analytics').select<Event>('SELECT * FROM events')
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Query Logging
|
|
309
|
+
|
|
310
|
+
The MySQL driver emits structured log events via the `queryLogger` and `connectionLogger` options.
|
|
311
|
+
|
|
312
|
+
Query logger events: `queryStarted`, `queryCompleted`, `queryFailed`, `transactionStarted`, `transactionCompleted`, `transactionFailed`. Each event includes the query ID, formatted query, duration (on completion/failure), and transaction ID when applicable.
|
|
313
|
+
|
|
314
|
+
Connection logger events: `newConnection`, `connectionAcquisitionStarted`, `connectionAcquisitionCompleted`, `connectionAcquisitionFailed`.
|
|
315
|
+
|
|
316
|
+
The log message type is exported as `Db.drivers.mysql.MysqlDbDriverLogMessageData`.
|
|
317
|
+
|
|
318
|
+
## Testing
|
|
319
|
+
|
|
320
|
+
The `@maestro-js/db` package does not currently have a mock driver or built-in tests. To test code that uses `Db`:
|
|
321
|
+
|
|
322
|
+
1. **Use a real test database** -- spin up a MySQL instance (e.g., via Docker) and register a provider pointing to it.
|
|
323
|
+
2. **Wrap tests in transactions** -- roll back after each test to keep the database clean.
|
|
324
|
+
3. **Abstract database calls** -- write functions that accept a `Db.DbService` so you can substitute a test double.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
// Test setup with a real database
|
|
328
|
+
import { Db } from '@maestro-js/db'
|
|
329
|
+
|
|
330
|
+
Db.Provider.register('default', Db.Provider.create({
|
|
331
|
+
driver: Db.drivers.mysql({
|
|
332
|
+
host: 'localhost',
|
|
333
|
+
user: 'test',
|
|
334
|
+
password: '',
|
|
335
|
+
database: 'myapp_test',
|
|
336
|
+
port: 3306
|
|
337
|
+
})
|
|
338
|
+
}))
|
|
339
|
+
|
|
340
|
+
// Clean up after tests
|
|
341
|
+
afterAll(async () => {
|
|
342
|
+
await Db.destroy()
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Cross-Package Integration
|
|
347
|
+
|
|
348
|
+
### Depends On
|
|
349
|
+
|
|
350
|
+
- **@maestro-js/service-registry** -- provider pattern (create, register, resolve)
|
|
351
|
+
- **@maestro-js/log** -- structured logging for queries and connections
|
|
352
|
+
- **@maestro-js/context** -- `AsyncLocalStorage`-based request context for scoping log data
|
|
353
|
+
- **@maestro-js/helpers** -- retry logic, `AsyncIterableCollection` for streaming
|
|
354
|
+
|
|
355
|
+
### Used By
|
|
356
|
+
|
|
357
|
+
- **@maestro-js/queue** -- stores queue messages and job state in MySQL tables via `Db`
|
|
358
|
+
|
|
359
|
+
### Works With
|
|
360
|
+
|
|
361
|
+
- **@maestro-js/sql** -- provides query builder utilities (`Sql.where`, CTEs) that produce parameterized SQL strings compatible with `Db.select`, `Db.update`, etc.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import { Db } from '@maestro-js/db'
|
|
365
|
+
import { Sql } from '@maestro-js/sql'
|
|
366
|
+
|
|
367
|
+
const where = Sql.where({ active: true, role: 'admin' })
|
|
368
|
+
const users = await Db.select<User>(`SELECT * FROM users ${where.sql}`, where.params)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## DbDriver Interface
|
|
372
|
+
|
|
373
|
+
Implement this interface to create a custom driver.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
interface DbDriver {
|
|
377
|
+
selectIterable(query: string, parameters?: any[]): AsyncIterable<Record<string, unknown>>
|
|
378
|
+
execute(
|
|
379
|
+
query: string,
|
|
380
|
+
parameters?: any[]
|
|
381
|
+
): Promise<Record<string, unknown>[] | { affectedRows: number; changedRows: number; insertId: number }>
|
|
382
|
+
format(query: string, parameters: any[]): string
|
|
383
|
+
transaction<T>(callback: () => Promise<T>): Promise<T>
|
|
384
|
+
destroy(): Promise<void>
|
|
385
|
+
}
|
|
386
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Helpers } from '@maestro-js/helpers';
|
|
2
|
+
import tls from 'node:tls';
|
|
3
|
+
import { Log } from '@maestro-js/log';
|
|
4
|
+
|
|
5
|
+
interface DbDriver {
|
|
6
|
+
selectIterable(query: string, parameters?: any[]): AsyncIterable<Record<string, unknown>>;
|
|
7
|
+
execute(query: string, parameters?: any[]): Promise<Record<string, unknown>[] | {
|
|
8
|
+
affectedRows: number;
|
|
9
|
+
changedRows: number;
|
|
10
|
+
insertId: number;
|
|
11
|
+
}>;
|
|
12
|
+
format(query: string, parameters: any[]): string;
|
|
13
|
+
transaction<T>(callback: () => Promise<T>): Promise<T>;
|
|
14
|
+
destroy(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type MysqlDbDriverLogMessageData = [
|
|
18
|
+
{
|
|
19
|
+
event: 'queryStarted';
|
|
20
|
+
queryId: string;
|
|
21
|
+
formattedQuery: string;
|
|
22
|
+
query: string;
|
|
23
|
+
params: any[];
|
|
24
|
+
transactionId: string | null;
|
|
25
|
+
} | {
|
|
26
|
+
event: 'queryCompleted';
|
|
27
|
+
queryId: string;
|
|
28
|
+
formattedQuery: string;
|
|
29
|
+
query: string;
|
|
30
|
+
params: any[];
|
|
31
|
+
durationMs: number;
|
|
32
|
+
rowCount: number;
|
|
33
|
+
transactionId: string | null;
|
|
34
|
+
} | {
|
|
35
|
+
event: 'queryFailed';
|
|
36
|
+
queryId: string;
|
|
37
|
+
formattedQuery: string;
|
|
38
|
+
query: string;
|
|
39
|
+
params: any[];
|
|
40
|
+
durationMs: number;
|
|
41
|
+
error: unknown;
|
|
42
|
+
transactionId: string | null;
|
|
43
|
+
} | {
|
|
44
|
+
event: 'transactionStarted';
|
|
45
|
+
transactionId: string;
|
|
46
|
+
} | {
|
|
47
|
+
event: 'transactionFailed';
|
|
48
|
+
transactionId: string;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
error: unknown;
|
|
51
|
+
} | {
|
|
52
|
+
event: 'transactionCompleted';
|
|
53
|
+
transactionId: string;
|
|
54
|
+
durationMs: number;
|
|
55
|
+
}
|
|
56
|
+
];
|
|
57
|
+
interface MysqlDbDriverConfig {
|
|
58
|
+
host: string;
|
|
59
|
+
user: string;
|
|
60
|
+
password: string;
|
|
61
|
+
database: string;
|
|
62
|
+
port: number;
|
|
63
|
+
connectionLimit?: number;
|
|
64
|
+
debug?: boolean;
|
|
65
|
+
multipleStatements?: boolean;
|
|
66
|
+
ssl?: string | (tls.SecureContextOptions & {
|
|
67
|
+
rejectUnauthorized?: boolean | undefined;
|
|
68
|
+
}) | undefined;
|
|
69
|
+
connectionLogger?: Log.LogFunctions<[
|
|
70
|
+
{
|
|
71
|
+
event: 'newConnection';
|
|
72
|
+
connectionCount: number;
|
|
73
|
+
connectionThreadId: number | null;
|
|
74
|
+
} | {
|
|
75
|
+
event: 'connectionAcquisitionStarted';
|
|
76
|
+
queryId: string;
|
|
77
|
+
} | {
|
|
78
|
+
event: 'connectionAcquisitionCompleted';
|
|
79
|
+
queryId: string;
|
|
80
|
+
connectionId: number | undefined;
|
|
81
|
+
connectDurationMs: number;
|
|
82
|
+
} | {
|
|
83
|
+
event: 'connectionAcquisitionFailed';
|
|
84
|
+
queryId: string;
|
|
85
|
+
connectDurationMs: number;
|
|
86
|
+
error: unknown;
|
|
87
|
+
}
|
|
88
|
+
]>;
|
|
89
|
+
queryLogger?: Log.LogFunctions<MysqlDbDriverLogMessageData>;
|
|
90
|
+
}
|
|
91
|
+
declare function mysqlDbDriver(config: MysqlDbDriverConfig): DbDriver;
|
|
92
|
+
|
|
93
|
+
type KeysWithFallback = keyof Db.Provider.Keys extends never ? {
|
|
94
|
+
default: unknown;
|
|
95
|
+
} : Db.Provider.Keys;
|
|
96
|
+
/**
|
|
97
|
+
* Creates a new connection pool using the supplied options.
|
|
98
|
+
*/
|
|
99
|
+
declare function create({ driver }: Db.Provider.DbServiceConfig): Db.DbService;
|
|
100
|
+
declare function provider(key: Db.Provider.Key): {
|
|
101
|
+
transaction: <T>(callback: () => Promise<T>) => Promise<T>;
|
|
102
|
+
format: (query: string, params?: any[]) => string;
|
|
103
|
+
select: <T = Record<string, unknown>>(query: string, parameters?: any[]) => Promise<T[]>;
|
|
104
|
+
selectIterable: <T = Record<string, unknown>>(query: string, parameters?: any[]) => Helpers.AsyncIterableCollection<T>;
|
|
105
|
+
insert: (query: string, parameters?: any[]) => Promise<{
|
|
106
|
+
affectedRows: number;
|
|
107
|
+
insertId: number;
|
|
108
|
+
}>;
|
|
109
|
+
update: (query: string, parameters?: any[]) => Promise<{
|
|
110
|
+
affectedRows: number;
|
|
111
|
+
changedRows: number;
|
|
112
|
+
}>;
|
|
113
|
+
delete: (query: string, parameters?: any[]) => Promise<{
|
|
114
|
+
affectedRows: number;
|
|
115
|
+
}>;
|
|
116
|
+
statement: <T = unknown>(query: string, parameters?: any[]) => Promise<T>;
|
|
117
|
+
scalar: <T = unknown>(query: string, parameters?: any[]) => Promise<T | null>;
|
|
118
|
+
destroy: () => Promise<void>;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Database access with typed query methods, transactions, and streaming.
|
|
122
|
+
*
|
|
123
|
+
* Provides a driver-based interface to SQL databases via the provider pattern.
|
|
124
|
+
* Create a driver (e.g. `Db.drivers.mysql()`), wrap it in `Db.Provider.create()`,
|
|
125
|
+
* and register it. The `Db` facade spreads the `'default'` provider so query
|
|
126
|
+
* methods can be called directly. Any database can be supported by implementing
|
|
127
|
+
* the `DbDriver` interface.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```ts
|
|
131
|
+
* Db.Provider.register('default', Db.Provider.create({
|
|
132
|
+
* driver: Db.drivers.mysql({
|
|
133
|
+
* host: 'localhost', user: 'root', password: '', database: 'myapp', port: 3306
|
|
134
|
+
* })
|
|
135
|
+
* }))
|
|
136
|
+
*
|
|
137
|
+
* const users = await Db.select<User>('SELECT * FROM users WHERE active = ?', [true])
|
|
138
|
+
* const { insertId } = await Db.insert('INSERT INTO users (name) VALUES (?)', ['Alice'])
|
|
139
|
+
* const count = await Db.scalar<number>('SELECT COUNT(*) FROM users')
|
|
140
|
+
*
|
|
141
|
+
* await Db.transaction(async () => {
|
|
142
|
+
* await Db.update('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, fromId])
|
|
143
|
+
* await Db.update('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, toId])
|
|
144
|
+
* })
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
declare namespace Db {
|
|
148
|
+
/** The pluggable database backend interface that drivers must implement */
|
|
149
|
+
type Driver = DbDriver;
|
|
150
|
+
/**
|
|
151
|
+
* Typed query API for executing SQL against a database.
|
|
152
|
+
*
|
|
153
|
+
* All methods accept parameterized queries with `?` placeholders for safe
|
|
154
|
+
* parameter escaping. Each method returns a result shape matching the SQL
|
|
155
|
+
* operation:
|
|
156
|
+
*
|
|
157
|
+
* - **Select** – returns typed row arrays via {@link select} or streaming rows via {@link selectIterable}
|
|
158
|
+
* - **Mutate** – {@link insert}, {@link update}, and `delete` return affected/changed row counts
|
|
159
|
+
* - **Scalar** – {@link scalar} returns the first column of the first row
|
|
160
|
+
* - **Transaction** – {@link transaction} wraps a callback in BEGIN/COMMIT/ROLLBACK
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```ts
|
|
164
|
+
* const users = await Db.select<User>('SELECT * FROM users WHERE role = ?', ['admin'])
|
|
165
|
+
* const { insertId } = await Db.insert('INSERT INTO users (name) VALUES (?)', ['Alice'])
|
|
166
|
+
* await Db.transaction(async () => {
|
|
167
|
+
* await Db.update('UPDATE products SET stock = stock - ? WHERE id = ?', [1, productId])
|
|
168
|
+
* })
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
interface DbService {
|
|
172
|
+
/** Executes a SELECT query and returns typed results as an array */
|
|
173
|
+
select<T = Record<string, unknown>>(query: string, parameters?: any[]): Promise<T[]>;
|
|
174
|
+
/** Executes a SELECT query and returns a streaming async iterable with backpressure */
|
|
175
|
+
selectIterable<T = Record<string, unknown>>(query: string, parameters?: any[]): Helpers.AsyncIterableCollection<T>;
|
|
176
|
+
/** Executes an INSERT query and returns the affected row count and auto-increment ID */
|
|
177
|
+
insert(query: string, parameters?: any[]): Promise<{
|
|
178
|
+
affectedRows: number;
|
|
179
|
+
insertId: number;
|
|
180
|
+
}>;
|
|
181
|
+
/** Executes an UPDATE query and returns the matched and changed row counts */
|
|
182
|
+
update(query: string, parameters?: any[]): Promise<{
|
|
183
|
+
affectedRows: number;
|
|
184
|
+
changedRows: number;
|
|
185
|
+
}>;
|
|
186
|
+
/** Executes a DELETE query and returns the number of removed rows */
|
|
187
|
+
delete(query: string, parameters?: any[]): Promise<{
|
|
188
|
+
affectedRows: number;
|
|
189
|
+
}>;
|
|
190
|
+
/** Executes an arbitrary SQL statement and returns the raw result */
|
|
191
|
+
statement<T = unknown>(query: string, parameters?: any[]): Promise<T>;
|
|
192
|
+
/**
|
|
193
|
+
* Returns the first column of the first row, or `null` if no results.
|
|
194
|
+
*
|
|
195
|
+
* Note: returns `null` both when the query returns zero rows and when the
|
|
196
|
+
* value itself is SQL NULL. Use `select()` if you need to distinguish
|
|
197
|
+
* between "no rows" and "row with a NULL value".
|
|
198
|
+
*/
|
|
199
|
+
scalar<T = unknown>(query: string, parameters?: any[]): Promise<T | null>;
|
|
200
|
+
/** Wraps the callback in a database transaction — commits on success, rolls back on exception */
|
|
201
|
+
transaction: <T>(callback: () => Promise<T>) => Promise<T>;
|
|
202
|
+
/** Interpolates parameters into a query string for debugging — does not execute the query */
|
|
203
|
+
format: (query: string, params?: any[]) => string;
|
|
204
|
+
/** Closes all connections in the pool and prevents new ones from being created */
|
|
205
|
+
destroy: () => Promise<void>;
|
|
206
|
+
}
|
|
207
|
+
namespace drivers {
|
|
208
|
+
namespace mysql {
|
|
209
|
+
/** Log event data emitted by the MySQL driver for query and transaction lifecycle events */
|
|
210
|
+
type MysqlDbDriverLogMessageData = MysqlDbDriverLogMessageData;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
namespace Provider {
|
|
214
|
+
interface DbServiceConfig {
|
|
215
|
+
driver: Driver;
|
|
216
|
+
}
|
|
217
|
+
type Key = keyof KeysWithFallback;
|
|
218
|
+
interface Keys {
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Database access with typed query methods, transactions, and streaming.
|
|
224
|
+
* Call `Db.Provider.create({ driver })` and register it, then query via `Db.select()`, `Db.insert()`, etc.
|
|
225
|
+
* A MySQL driver is included via `Db.drivers.mysql()`, or supply your own `DbDriver` implementation.
|
|
226
|
+
*/
|
|
227
|
+
declare const Db: {
|
|
228
|
+
Provider: {
|
|
229
|
+
create: typeof create;
|
|
230
|
+
register: (name: Db.Provider.Key, item: Db.DbService) => void;
|
|
231
|
+
};
|
|
232
|
+
provider: typeof provider;
|
|
233
|
+
select: <T = Record<string, unknown>>(query: string, parameters?: any[]) => Promise<T[]>;
|
|
234
|
+
selectIterable: <T = Record<string, unknown>>(query: string, parameters?: any[]) => Helpers.AsyncIterableCollection<T>;
|
|
235
|
+
insert: (query: string, parameters?: any[]) => Promise<{
|
|
236
|
+
affectedRows: number;
|
|
237
|
+
insertId: number;
|
|
238
|
+
}>;
|
|
239
|
+
update: (query: string, parameters?: any[]) => Promise<{
|
|
240
|
+
affectedRows: number;
|
|
241
|
+
changedRows: number;
|
|
242
|
+
}>;
|
|
243
|
+
delete: (query: string, parameters?: any[]) => Promise<{
|
|
244
|
+
affectedRows: number;
|
|
245
|
+
}>;
|
|
246
|
+
statement: <T = unknown>(query: string, parameters?: any[]) => Promise<T>;
|
|
247
|
+
scalar: <T = unknown>(query: string, parameters?: any[]) => Promise<T | null>;
|
|
248
|
+
format: (query: string, params?: any[]) => string;
|
|
249
|
+
transaction: <T>(callback: () => Promise<T>) => Promise<T>;
|
|
250
|
+
destroy: () => Promise<void>;
|
|
251
|
+
drivers: {
|
|
252
|
+
mysql: typeof mysqlDbDriver;
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export { Db };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { Helpers as Helpers2 } from "@maestro-js/helpers";
|
|
3
|
+
import { ServiceRegistry } from "@maestro-js/service-registry";
|
|
4
|
+
|
|
5
|
+
// src/mysql-db-driver.ts
|
|
6
|
+
import { timeFns } from "iso-fns2";
|
|
7
|
+
import mysql from "mysql2";
|
|
8
|
+
import assert from "assert";
|
|
9
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
10
|
+
import { randomUUID } from "crypto";
|
|
11
|
+
import "tls";
|
|
12
|
+
import { Context } from "@maestro-js/context";
|
|
13
|
+
import { Helpers } from "@maestro-js/helpers";
|
|
14
|
+
import { Log } from "@maestro-js/log";
|
|
15
|
+
function mysqlDbDriver(config) {
|
|
16
|
+
const connectionLogger = config?.connectionLogger ?? Log.create();
|
|
17
|
+
const queryLogger = config?.queryLogger ?? Log.create();
|
|
18
|
+
const transactionContext = new AsyncLocalStorage();
|
|
19
|
+
function getPoolConnection() {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
pool.getConnection((err, connection) => {
|
|
22
|
+
if (err) reject(err);
|
|
23
|
+
else resolve(connection);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async function _getConnection() {
|
|
28
|
+
return Helpers.retry(
|
|
29
|
+
async () => {
|
|
30
|
+
const contextValue = transactionContext.getStore();
|
|
31
|
+
if (contextValue) {
|
|
32
|
+
return contextValue;
|
|
33
|
+
} else {
|
|
34
|
+
const connection = await getPoolConnection();
|
|
35
|
+
return {
|
|
36
|
+
id: connection.threadId,
|
|
37
|
+
release() {
|
|
38
|
+
connection.release();
|
|
39
|
+
},
|
|
40
|
+
beginTransaction() {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
connection.beginTransaction((err) => {
|
|
43
|
+
if (err) reject(err);
|
|
44
|
+
else resolve();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
commit() {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
connection.commit((err) => {
|
|
51
|
+
if (err) reject(err);
|
|
52
|
+
else resolve();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
rollback() {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
connection.rollback((err) => {
|
|
59
|
+
if (err) reject(err);
|
|
60
|
+
else resolve();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
query(queryStr, params) {
|
|
65
|
+
return params ? connection.query(queryStr, params) : connection.query(queryStr);
|
|
66
|
+
},
|
|
67
|
+
pause() {
|
|
68
|
+
return connection.pause();
|
|
69
|
+
},
|
|
70
|
+
resume() {
|
|
71
|
+
return connection.resume();
|
|
72
|
+
},
|
|
73
|
+
currentTransaction: null
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
maxAttempts: 10,
|
|
79
|
+
delay: 1e3
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
assert.ok(config.host);
|
|
84
|
+
assert.ok(config.user);
|
|
85
|
+
assert.ok(
|
|
86
|
+
config.password !== void 0 && config.password !== null,
|
|
87
|
+
"config.password is required (empty string is allowed)"
|
|
88
|
+
);
|
|
89
|
+
assert.ok(config.database);
|
|
90
|
+
assert.ok(config.port);
|
|
91
|
+
const pool = mysql.createPool({
|
|
92
|
+
host: config.host,
|
|
93
|
+
user: config.user,
|
|
94
|
+
password: config.password,
|
|
95
|
+
database: config.database,
|
|
96
|
+
port: config.port,
|
|
97
|
+
connectionLimit: config.connectionLimit ?? 10,
|
|
98
|
+
connectTimeout: 60 * 60 * 1e3,
|
|
99
|
+
waitForConnections: true,
|
|
100
|
+
queueLimit: 0,
|
|
101
|
+
timezone: "Z",
|
|
102
|
+
debug: false,
|
|
103
|
+
multipleStatements: config.multipleStatements,
|
|
104
|
+
ssl: config.ssl,
|
|
105
|
+
typeCast: typeCastHandler,
|
|
106
|
+
// Use utf8mb4_0900_ai_ci (MySQL 8.0+ default)
|
|
107
|
+
// This prevents collation mismatch errors when string literals are compared with column values
|
|
108
|
+
charset: "utf8mb4_0900_ai_ci",
|
|
109
|
+
jsonStrings: true,
|
|
110
|
+
decimalNumbers: true
|
|
111
|
+
});
|
|
112
|
+
let connectionCount = 0;
|
|
113
|
+
pool.on("connection", function(connection) {
|
|
114
|
+
connectionCount++;
|
|
115
|
+
connectionLogger.info({
|
|
116
|
+
event: "newConnection",
|
|
117
|
+
connectionCount,
|
|
118
|
+
connectionThreadId: connection.threadId
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
async function transaction(callback) {
|
|
122
|
+
const connection = await _getConnection();
|
|
123
|
+
if (connection.currentTransaction) {
|
|
124
|
+
try {
|
|
125
|
+
const result = await callback();
|
|
126
|
+
return result;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
connection.currentTransaction.transactionError = e;
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
const currentTransaction = { transactionError: null, queries: [], id: randomUUID() };
|
|
133
|
+
const contextValue = {
|
|
134
|
+
id: connection.id,
|
|
135
|
+
release() {
|
|
136
|
+
},
|
|
137
|
+
beginTransaction(...args) {
|
|
138
|
+
if (currentTransaction?.transactionError) {
|
|
139
|
+
throw new Error("This transaction has already been rolled back");
|
|
140
|
+
}
|
|
141
|
+
return connection.beginTransaction(...args);
|
|
142
|
+
},
|
|
143
|
+
async commit(...args) {
|
|
144
|
+
if (currentTransaction?.transactionError) {
|
|
145
|
+
throw new Error("This transaction has already been rolled back");
|
|
146
|
+
}
|
|
147
|
+
await Promise.all(currentTransaction.queries);
|
|
148
|
+
return connection.commit(...args);
|
|
149
|
+
},
|
|
150
|
+
async rollback(...args) {
|
|
151
|
+
if (!currentTransaction?.transactionError) {
|
|
152
|
+
await Promise.all(currentTransaction.queries);
|
|
153
|
+
return connection.rollback(...args);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
query(...args) {
|
|
157
|
+
let resolve = () => {
|
|
158
|
+
};
|
|
159
|
+
currentTransaction.queries.push(
|
|
160
|
+
new Promise((r) => {
|
|
161
|
+
resolve = r;
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
if (currentTransaction?.transactionError) {
|
|
165
|
+
throw new Error("This transaction has already been rolled back");
|
|
166
|
+
}
|
|
167
|
+
const result = connection.query(...args);
|
|
168
|
+
result.on("end", resolve);
|
|
169
|
+
return result;
|
|
170
|
+
},
|
|
171
|
+
pause() {
|
|
172
|
+
return connection.pause();
|
|
173
|
+
},
|
|
174
|
+
resume() {
|
|
175
|
+
return connection.resume();
|
|
176
|
+
},
|
|
177
|
+
currentTransaction
|
|
178
|
+
};
|
|
179
|
+
const transactionStartTime = (/* @__PURE__ */ new Date()).getTime();
|
|
180
|
+
try {
|
|
181
|
+
await connection.beginTransaction();
|
|
182
|
+
queryLogger.info({
|
|
183
|
+
event: "transactionStarted",
|
|
184
|
+
transactionId: contextValue.currentTransaction.id
|
|
185
|
+
});
|
|
186
|
+
const result = await transactionContext.run(contextValue, async () => {
|
|
187
|
+
return callback();
|
|
188
|
+
});
|
|
189
|
+
if (currentTransaction.transactionError) {
|
|
190
|
+
throw currentTransaction.transactionError;
|
|
191
|
+
}
|
|
192
|
+
await contextValue.commit();
|
|
193
|
+
const duration = (/* @__PURE__ */ new Date()).getTime() - transactionStartTime;
|
|
194
|
+
queryLogger.info({
|
|
195
|
+
event: "transactionCompleted",
|
|
196
|
+
transactionId: contextValue.currentTransaction.id,
|
|
197
|
+
durationMs: duration
|
|
198
|
+
});
|
|
199
|
+
return result;
|
|
200
|
+
} catch (e) {
|
|
201
|
+
currentTransaction.transactionError = e;
|
|
202
|
+
await Promise.allSettled(
|
|
203
|
+
currentTransaction.queries.map((q) => Promise.race([q, new Promise((r) => setTimeout(r, 1e3))]))
|
|
204
|
+
);
|
|
205
|
+
await connection.rollback();
|
|
206
|
+
const duration = (/* @__PURE__ */ new Date()).getTime() - transactionStartTime;
|
|
207
|
+
queryLogger.info({
|
|
208
|
+
event: "transactionFailed",
|
|
209
|
+
transactionId: contextValue.currentTransaction.id,
|
|
210
|
+
durationMs: duration,
|
|
211
|
+
error: e
|
|
212
|
+
});
|
|
213
|
+
throw e;
|
|
214
|
+
} finally {
|
|
215
|
+
connection.release();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function format(query, params = []) {
|
|
220
|
+
const formattedSql = mysql.format(query, params);
|
|
221
|
+
return formattedSql;
|
|
222
|
+
}
|
|
223
|
+
async function getConnectionForQuery(queryId) {
|
|
224
|
+
const connectStartTime = (/* @__PURE__ */ new Date()).getTime();
|
|
225
|
+
connectionLogger.info({
|
|
226
|
+
event: "connectionAcquisitionStarted",
|
|
227
|
+
queryId
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
const connection = await _getConnection();
|
|
231
|
+
connectionLogger.info({
|
|
232
|
+
event: "connectionAcquisitionCompleted",
|
|
233
|
+
queryId,
|
|
234
|
+
connectionId: connection.id,
|
|
235
|
+
connectDurationMs: (/* @__PURE__ */ new Date()).getTime() - connectStartTime
|
|
236
|
+
});
|
|
237
|
+
return connection;
|
|
238
|
+
} catch (e) {
|
|
239
|
+
connectionLogger.error({
|
|
240
|
+
event: "connectionAcquisitionFailed",
|
|
241
|
+
queryId,
|
|
242
|
+
connectDurationMs: (/* @__PURE__ */ new Date()).getTime() - connectStartTime,
|
|
243
|
+
error: e
|
|
244
|
+
});
|
|
245
|
+
throw e;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function executeQueryOnConnection({
|
|
249
|
+
query,
|
|
250
|
+
queryId,
|
|
251
|
+
params,
|
|
252
|
+
connection
|
|
253
|
+
}) {
|
|
254
|
+
const formatted = mysql.format(query, params);
|
|
255
|
+
const queryStartTime = (/* @__PURE__ */ new Date()).getTime();
|
|
256
|
+
const context = Context.getStore();
|
|
257
|
+
queryLogger.info({
|
|
258
|
+
event: "queryStarted",
|
|
259
|
+
queryId,
|
|
260
|
+
formattedQuery: formatted,
|
|
261
|
+
query,
|
|
262
|
+
params,
|
|
263
|
+
transactionId: connection.currentTransaction?.id ?? null
|
|
264
|
+
});
|
|
265
|
+
let streamError = null;
|
|
266
|
+
const queryStream = params ? connection.query(`/*SqlQueryTiming: ${queryId}*/` + query, params) : connection.query(`/*SqlQueryTiming: ${queryId}*/` + query);
|
|
267
|
+
let rowCount = 0;
|
|
268
|
+
queryStream.on("result", () => {
|
|
269
|
+
rowCount++;
|
|
270
|
+
});
|
|
271
|
+
queryStream.on("end", () => {
|
|
272
|
+
Context.runWith(context, () => {
|
|
273
|
+
if (!streamError) {
|
|
274
|
+
const duration = (/* @__PURE__ */ new Date()).getTime() - queryStartTime;
|
|
275
|
+
queryLogger.info({
|
|
276
|
+
event: "queryCompleted",
|
|
277
|
+
queryId,
|
|
278
|
+
durationMs: duration,
|
|
279
|
+
formattedQuery: formatted,
|
|
280
|
+
query,
|
|
281
|
+
params,
|
|
282
|
+
rowCount,
|
|
283
|
+
transactionId: connection.currentTransaction?.id ?? null
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
queryStream.on("error", (error) => {
|
|
289
|
+
Context.runWith(context, () => {
|
|
290
|
+
streamError = error;
|
|
291
|
+
queryLogger.error({
|
|
292
|
+
event: "queryFailed",
|
|
293
|
+
queryId,
|
|
294
|
+
durationMs: (/* @__PURE__ */ new Date()).getTime() - queryStartTime,
|
|
295
|
+
formattedQuery: formatted,
|
|
296
|
+
query,
|
|
297
|
+
params,
|
|
298
|
+
error,
|
|
299
|
+
transactionId: connection.currentTransaction?.id ?? null
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
return queryStream;
|
|
304
|
+
}
|
|
305
|
+
function selectIterable(query, params = []) {
|
|
306
|
+
const queryId = randomUUID();
|
|
307
|
+
return {
|
|
308
|
+
async *[Symbol.asyncIterator]() {
|
|
309
|
+
const connection = await getConnectionForQuery(queryId);
|
|
310
|
+
const queryStream = executeQueryOnConnection({
|
|
311
|
+
query,
|
|
312
|
+
queryId,
|
|
313
|
+
params,
|
|
314
|
+
connection
|
|
315
|
+
});
|
|
316
|
+
let isComplete = false;
|
|
317
|
+
let streamError = null;
|
|
318
|
+
const rowQueue = [];
|
|
319
|
+
let resolveNext = null;
|
|
320
|
+
let isPaused = false;
|
|
321
|
+
const MAX_QUEUE_SIZE = 3e3;
|
|
322
|
+
queryStream.on("result", (row) => {
|
|
323
|
+
if (isComplete) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (rowQueue.length || !isOkPacket(row)) {
|
|
327
|
+
rowQueue.push(row);
|
|
328
|
+
}
|
|
329
|
+
if (rowQueue.length >= MAX_QUEUE_SIZE && !isPaused) {
|
|
330
|
+
isPaused = true;
|
|
331
|
+
connection.pause();
|
|
332
|
+
}
|
|
333
|
+
if (resolveNext) {
|
|
334
|
+
const resolve = resolveNext;
|
|
335
|
+
resolveNext = null;
|
|
336
|
+
resolve();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
queryStream.on("end", () => {
|
|
340
|
+
isComplete = true;
|
|
341
|
+
if (resolveNext) {
|
|
342
|
+
const resolve = resolveNext;
|
|
343
|
+
resolveNext = null;
|
|
344
|
+
resolve();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
queryStream.on("error", (error) => {
|
|
348
|
+
streamError = error;
|
|
349
|
+
if (resolveNext) {
|
|
350
|
+
const resolve = resolveNext;
|
|
351
|
+
resolveNext = null;
|
|
352
|
+
resolve();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
try {
|
|
356
|
+
while (!isComplete && !streamError) {
|
|
357
|
+
if (rowQueue.length > 0) {
|
|
358
|
+
const row = rowQueue.shift();
|
|
359
|
+
if (isPaused && rowQueue.length < MAX_QUEUE_SIZE - 25) {
|
|
360
|
+
isPaused = false;
|
|
361
|
+
connection.resume();
|
|
362
|
+
}
|
|
363
|
+
yield row;
|
|
364
|
+
} else {
|
|
365
|
+
await new Promise((resolve) => {
|
|
366
|
+
resolveNext = resolve;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
while (rowQueue.length > 0) {
|
|
371
|
+
yield rowQueue.shift();
|
|
372
|
+
}
|
|
373
|
+
if (isPaused) {
|
|
374
|
+
connection.resume();
|
|
375
|
+
}
|
|
376
|
+
if (streamError) {
|
|
377
|
+
throw streamError;
|
|
378
|
+
}
|
|
379
|
+
} finally {
|
|
380
|
+
isComplete = true;
|
|
381
|
+
rowQueue.length = 0;
|
|
382
|
+
if (isPaused) {
|
|
383
|
+
connection.resume();
|
|
384
|
+
}
|
|
385
|
+
connection.release();
|
|
386
|
+
if (resolveNext) {
|
|
387
|
+
const resolve = resolveNext;
|
|
388
|
+
resolveNext = null;
|
|
389
|
+
resolve();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
async function execute(query, params = []) {
|
|
396
|
+
const queryId = randomUUID();
|
|
397
|
+
const isInTransaction = !!transactionContext.getStore();
|
|
398
|
+
const results = await Helpers.retry(
|
|
399
|
+
async () => {
|
|
400
|
+
const connection = await getConnectionForQuery(queryId);
|
|
401
|
+
const queryStream = executeQueryOnConnection({ query, params, queryId, connection });
|
|
402
|
+
const promise = new Promise((resolve, reject) => {
|
|
403
|
+
const rowQueue = [];
|
|
404
|
+
let okPacket = null;
|
|
405
|
+
queryStream.on("result", (row, ...rest) => {
|
|
406
|
+
if (rowQueue.length || !isOkPacket(row)) {
|
|
407
|
+
rowQueue.push(row);
|
|
408
|
+
} else {
|
|
409
|
+
okPacket = row;
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
queryStream.on("error", (error) => reject(error));
|
|
413
|
+
queryStream.on("end", () => {
|
|
414
|
+
if (!rowQueue.length && okPacket) {
|
|
415
|
+
resolve({
|
|
416
|
+
affectedRows: okPacket.affectedRows ?? NaN,
|
|
417
|
+
changedRows: okPacket.changedRows ?? NaN,
|
|
418
|
+
insertId: okPacket.insertId ?? NaN
|
|
419
|
+
});
|
|
420
|
+
} else {
|
|
421
|
+
resolve(rowQueue);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
return await promise.finally(() => connection.release());
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
maxAttempts: 3,
|
|
429
|
+
delay: 0,
|
|
430
|
+
shouldRetry: (err) => "code" in err && err.code === "ER_LOCK_DEADLOCK" && !isInTransaction || "code" in err && err.code === "ER_LOCK_WAIT_TIMEOUT" || err instanceof Error && err.message.includes("Please retry") || err instanceof Error && err.message.includes("Try restarting transaction") && !isInTransaction
|
|
431
|
+
}
|
|
432
|
+
);
|
|
433
|
+
return results;
|
|
434
|
+
}
|
|
435
|
+
async function destroy() {
|
|
436
|
+
await new Promise((resolve, reject) => {
|
|
437
|
+
pool.end((err) => {
|
|
438
|
+
if (err) reject(err);
|
|
439
|
+
else resolve();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return { transaction, format, execute, selectIterable, destroy };
|
|
444
|
+
}
|
|
445
|
+
var typeCastHandler = (field, next) => {
|
|
446
|
+
if (field.type === "DATE") {
|
|
447
|
+
const stringRepresentation = field.string();
|
|
448
|
+
return stringRepresentation;
|
|
449
|
+
}
|
|
450
|
+
if (field.type === "TIME") {
|
|
451
|
+
const stringRepresentation = field.string();
|
|
452
|
+
const res = stringRepresentation === null ? null : timeFns.from(stringRepresentation);
|
|
453
|
+
return res;
|
|
454
|
+
}
|
|
455
|
+
if (field.type === "DATETIME") {
|
|
456
|
+
const stringRepresentation = field.string();
|
|
457
|
+
try {
|
|
458
|
+
const res = stringRepresentation === null ? null : (/* @__PURE__ */ new Date(`${stringRepresentation}Z`)).toISOString();
|
|
459
|
+
return res;
|
|
460
|
+
} catch (e) {
|
|
461
|
+
throw new Error(`Failed to parse DATETIME value "${stringRepresentation}" for ${field.table}.${field.name}`, {
|
|
462
|
+
cause: e
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (field.type === "TINY" && field.length === 1) {
|
|
467
|
+
const num = field.string();
|
|
468
|
+
if (num === null) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
if (!isNaN(num)) {
|
|
472
|
+
const bool = Number(num) === 1;
|
|
473
|
+
return bool;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return next();
|
|
477
|
+
};
|
|
478
|
+
function isOkPacket(entry) {
|
|
479
|
+
if (entry && typeof entry === "object") {
|
|
480
|
+
return ["fieldCount", "changedRows", "affectedRows", "insertId", "serverStatus", "warningStatus", "info"].every(
|
|
481
|
+
(field) => field in entry
|
|
482
|
+
);
|
|
483
|
+
} else {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/index.ts
|
|
489
|
+
function create({ driver }) {
|
|
490
|
+
async function transaction(callback) {
|
|
491
|
+
return driver.transaction(callback);
|
|
492
|
+
}
|
|
493
|
+
function format(query, params = []) {
|
|
494
|
+
const formattedSql = driver.format(query, params);
|
|
495
|
+
return formattedSql;
|
|
496
|
+
}
|
|
497
|
+
async function scalar(query, params) {
|
|
498
|
+
const results = await driver.execute(query, params);
|
|
499
|
+
const item = Array.isArray(results) && results.length ? results[0] ?? {} : {};
|
|
500
|
+
const scalarKey = Object.keys(item).length ? Object.keys(item)[0] ?? "" : "";
|
|
501
|
+
return item[scalarKey] ?? null;
|
|
502
|
+
}
|
|
503
|
+
async function select(query, params) {
|
|
504
|
+
const results = await driver.execute(query, params);
|
|
505
|
+
return Array.isArray(results) ? results : [];
|
|
506
|
+
}
|
|
507
|
+
function selectIterable(query, params) {
|
|
508
|
+
const iterable = driver.selectIterable(query, params);
|
|
509
|
+
return Helpers2.AsyncIterableCollection.from(iterable);
|
|
510
|
+
}
|
|
511
|
+
async function insert(query, params) {
|
|
512
|
+
const results = await driver.execute(query, params);
|
|
513
|
+
if (Array.isArray(results)) {
|
|
514
|
+
throw new Error("insert() received a result set. Use select() for queries that return rows.");
|
|
515
|
+
}
|
|
516
|
+
return { affectedRows: results.affectedRows, insertId: results.insertId };
|
|
517
|
+
}
|
|
518
|
+
async function update(query, params) {
|
|
519
|
+
const results = await driver.execute(query, params);
|
|
520
|
+
if (Array.isArray(results)) {
|
|
521
|
+
throw new Error("update() received a result set. Use select() for queries that return rows.");
|
|
522
|
+
}
|
|
523
|
+
return { affectedRows: results.affectedRows, changedRows: results.changedRows };
|
|
524
|
+
}
|
|
525
|
+
async function deleteRows(query, params) {
|
|
526
|
+
const results = await driver.execute(query, params);
|
|
527
|
+
if (Array.isArray(results)) {
|
|
528
|
+
throw new Error("delete() received a result set. Use select() for queries that return rows.");
|
|
529
|
+
}
|
|
530
|
+
return { affectedRows: results.affectedRows };
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
transaction,
|
|
534
|
+
format,
|
|
535
|
+
select,
|
|
536
|
+
selectIterable,
|
|
537
|
+
insert,
|
|
538
|
+
update,
|
|
539
|
+
delete: deleteRows,
|
|
540
|
+
scalar,
|
|
541
|
+
async statement(query, params) {
|
|
542
|
+
const result = await driver.execute(query, params);
|
|
543
|
+
return result;
|
|
544
|
+
},
|
|
545
|
+
destroy: () => driver.destroy()
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
var registry = ServiceRegistry.createRegistry(ServiceRegistry.proxyFunctionsForObject);
|
|
549
|
+
var Provider = {
|
|
550
|
+
create,
|
|
551
|
+
register: registry.register
|
|
552
|
+
};
|
|
553
|
+
function provider(key) {
|
|
554
|
+
const service = registry.resolve(key);
|
|
555
|
+
return {
|
|
556
|
+
transaction: service.transaction,
|
|
557
|
+
format: service.format,
|
|
558
|
+
select: service.select,
|
|
559
|
+
selectIterable: service.selectIterable,
|
|
560
|
+
insert: service.insert,
|
|
561
|
+
update: service.update,
|
|
562
|
+
delete: service.delete,
|
|
563
|
+
statement: service.statement,
|
|
564
|
+
scalar: service.scalar,
|
|
565
|
+
destroy: service.destroy
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
var facade = provider("default");
|
|
569
|
+
var Db = {
|
|
570
|
+
Provider,
|
|
571
|
+
provider,
|
|
572
|
+
select: facade.select,
|
|
573
|
+
selectIterable: facade.selectIterable,
|
|
574
|
+
insert: facade.insert,
|
|
575
|
+
update: facade.update,
|
|
576
|
+
delete: facade.delete,
|
|
577
|
+
statement: facade.statement,
|
|
578
|
+
scalar: facade.scalar,
|
|
579
|
+
format: facade.format,
|
|
580
|
+
transaction: facade.transaction,
|
|
581
|
+
destroy: facade.destroy,
|
|
582
|
+
drivers: {
|
|
583
|
+
mysql: mysqlDbDriver
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
export {
|
|
587
|
+
Db
|
|
588
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maestro-js/db",
|
|
3
|
+
"description": "Use when working with @maestro-js/db, the database access package for Maestro. Covers MySQL driver setup, parameterized SQL queries (select, insert, update, delete, scalar, statement), transactions with automatic commit/rollback, streaming results via selectIterable, connection pooling, query formatting, and the provider pattern for multiple database instances. Maestro uses raw parameterized SQL with no ORM. Depends on service-registry and log. Used by queue for message storage. Works with the sql package for query builders.",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"iso-fns2": "npm:iso-fns@2.0.0-alpha.26",
|
|
13
|
+
"mysql2": "^3.15.3",
|
|
14
|
+
"@maestro-js/context": "1.0.0-alpha.0",
|
|
15
|
+
"@maestro-js/log": "1.0.0-alpha.0",
|
|
16
|
+
"@maestro-js/helpers": "1.0.0-alpha.0",
|
|
17
|
+
"@maestro-js/service-registry": "1.0.0-alpha.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.19.11"
|
|
21
|
+
},
|
|
22
|
+
"version": "1.0.0-alpha.0",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "restricted"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"license": "UNLICENSED",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22.18.0"
|
|
32
|
+
},
|
|
33
|
+
"repository": "https://github.com/Marcato-Partners/maestro-js",
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup --config ../../tsup.config.ts",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "echo 'No tests yet'",
|
|
38
|
+
"format": "prettier --write src/",
|
|
39
|
+
"lint": "prettier --check src/"
|
|
40
|
+
}
|
|
41
|
+
}
|