@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 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
+ ```
@@ -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
+ }