stabilize-orm 1.1.7 → 1.2.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 CHANGED
@@ -4,24 +4,27 @@ _A Modern, Type-Safe, and Expressive ORM for Bun, Node.js, and Deno_
4
4
 
5
5
  ---
6
6
 
7
- **Stabilize** is a lightweight, feature-rich ORM designed for performance and developer experience. It provides a unified, database-agnostic API for **PostgreSQL**, **MySQL**, and **SQLite**, powered by a robust query builder, an elegant decorator-based model system, and a full-featured command-line interface.
7
+ **Stabilize** is a lightweight, feature-rich ORM designed for performance and developer experience. It provides a unified, database-agnostic API for **PostgreSQL**, **MySQL**, and **SQLite**. Powered by a robust query builder, elegant decorator-based models, automatic versioning, and a full-featured command-line interface, Stabilize is built to scale with your app.
8
8
 
9
9
  ---
10
10
 
11
11
  ## 🚀 Features
12
12
 
13
- - **Unified API**: Write once, run against PostgreSQL, MySQL, or SQLite.
14
- - **Type-Safe Decorators**: Define models and columns with the powerful `DataTypes` enum for true database-agnostic schemas.
15
- - **Full-Featured CLI**: Generate models, manage migrations, seed data, and reset your database from the command line.
16
- - **Automatic Migrations**: Generate database-specific SQL schemas directly from your model definitions.
17
- - **Retry Logic**: Automatic exponential backoff for database queries to handle transient connection issues.
18
- - **Connection Pooling**: Efficient connection management for PostgreSQL and MySQL out of the box.
19
- - **Transactional Integrity**: Built-in support for atomic transactions with automatic rollback on failure.
20
- - **Advanced Query Builder**: A fluent, chainable API for building complex queries with joins, where clauses, ordering, and pagination.
21
- - **Model Relationships**: Define `OneToOne`, `ManyToOne`, `OneToMany`, and `ManyToMany` relationships with simple decorators.
22
- - **Pluggable Logging**: Includes a robust `ConsoleLogger` with support for file-based, rotating logs.
23
- - **Custom Errors**: `StabilizeError` provides clear, consistent error handling.
24
- - **Caching Layer**: Optional Redis-backed caching with `cache-aside` and `write-through` strategies.
13
+ - **Unified API**: Write once, run on PostgreSQL, MySQL, or SQLite.
14
+ - **Type-Safe Decorators**: Define models and columns with the powerful `DataTypes` enum for true database-agnostic schemas.
15
+ - **Full-Featured CLI**: Generate models, manage migrations, seed data, and reset your database from the command line.
16
+ - **Automatic Migrations**: Generate database-specific SQL schemas directly from your model definitions.
17
+ - **Versioned Models & Time-Travel**: Add `@Versioned()` to your models for automatic history tables and snapshot queries.
18
+ - **Retry Logic**: Automatic exponential backoff for database queries to handle transient connection issues.
19
+ - **Connection Pooling**: Efficient connection management for PostgreSQL and MySQL.
20
+ - **Transactional Integrity**: Built-in support for atomic transactions with automatic rollback on failure.
21
+ - **Advanced Query Builder**: Fluent, chainable API for building complex queries, including joins, filters, ordering, and pagination.
22
+ - **Model Relationships**: Use `OneToOne`, `ManyToOne`, `OneToMany`, and `ManyToMany` decorators for relationships.
23
+ - **Soft Deletes**: Add `@SoftDelete()` to your model for transparent "deleted" flags and safe row removal.
24
+ - **Lifecycle Hooks**: Use the `@Hook()` decorator for model lifecycle events like `beforeCreate`, `afterUpdate`, etc.
25
+ - **Pluggable Logging**: Includes a robust `ConsoleLogger` with support for file-based, rotating logs.
26
+ - **Custom Errors**: `StabilizeError` provides clear, consistent error handling.
27
+ - **Caching Layer**: Optional Redis-backed caching with `cache-aside` and `write-through` strategies.
25
28
 
26
29
  ---
27
30
 
@@ -41,13 +44,13 @@ npm install stabilize-orm reflect-metadata
41
44
 
42
45
  ## 📃 Documentation & Community
43
46
 
44
- - [Changelog](./CHANGELOG.md)
45
- - [License](./LICENSE.md)
46
- - [Code of Conduct](./CODE_OF_CONDUCT.md)
47
- - [Contributing Guide](./CONTRIBUTING.md)
48
- - [Security Policy](./SECURITY.md)
49
- - [Support](./SUPPORT.md)
50
- - [Funding](./FUNDING.md)
47
+ - [Changelog](./CHANGELOG.md)
48
+ - [License](./LICENSE.md)
49
+ - [Code of Conduct](./CODE_OF_CONDUCT.md)
50
+ - [Contributing Guide](./CONTRIBUTING.md)
51
+ - [Security Policy](./SECURITY.md)
52
+ - [Support](./SUPPORT.md)
53
+ - [Funding](./FUNDING.md)
51
54
 
52
55
  ---
53
56
 
@@ -60,13 +63,8 @@ First, create a database configuration file.
60
63
  import { DBType, type DBConfig } from "stabilize-orm";
61
64
 
62
65
  const dbConfig: DBConfig = {
63
- // Choose your database type
64
66
  type: DBType.Postgres,
65
-
66
- // Connection string for your database
67
67
  connectionString: process.env.DATABASE_URL || "postgres://user:password@localhost:5432/mydb",
68
-
69
- // Optional: Connection retry settings
70
68
  retryAttempts: 3,
71
69
  retryDelay: 1000,
72
70
  };
@@ -74,29 +72,27 @@ const dbConfig: DBConfig = {
74
72
  export default dbConfig;
75
73
  ```
76
74
 
77
- Next, create a central ORM instance that your application can use. Remember to import `reflect-metadata` once at your application's entry point.
75
+ Next, create a central ORM instance that your application can use. Import `reflect-metadata` once at your application's entry point.
78
76
 
79
77
  ```typescript
80
78
  // db.ts
81
- import 'reflect-metadata'; // declared first
79
+ import 'reflect-metadata';
82
80
  import { Stabilize, type CacheConfig, type LoggerConfig, LogLevel } from "stabilize-orm";
83
81
  import dbConfig from "./database";
84
82
 
85
83
  const cacheConfig: CacheConfig = {
86
84
  enabled: process.env.CACHE_ENABLED === "true",
87
85
  redisUrl: process.env.REDIS_URL,
88
- ttl: 60, // Default TTL in seconds
86
+ ttl: 60,
89
87
  };
90
88
 
91
89
  const loggerConfig: LoggerConfig = {
92
- level: LogLevel.Info,
93
- // Optional: Configure file logging
94
- filePath: 'logs/stabilize.log',
95
- maxFileSize: 5 * 1024 * 1024, // 5MB
96
- maxFiles: 3,
97
- }
90
+ level: LogLevel.Info,
91
+ filePath: 'logs/stabilize.log',
92
+ maxFileSize: 5 * 1024 * 1024, // 5MB
93
+ maxFiles: 3,
94
+ };
98
95
 
99
- // Create and export the ORM instance
100
96
  export const orm = new Stabilize(dbConfig, cacheConfig, loggerConfig);
101
97
  ```
102
98
 
@@ -104,19 +100,18 @@ export const orm = new Stabilize(dbConfig, cacheConfig, loggerConfig);
104
100
 
105
101
  ## 🏗️ Models & Relationships
106
102
 
107
- Define your database tables as classes using decorators. The new `@Column` decorator uses the `DataTypes` enum for a truly database-agnostic schema definition.
108
-
109
- ### Example: Users and Roles (Many-to-Many)
103
+ Define your tables as classes using decorators. The `@Column` decorator uses the `DataTypes` enum for a truly database-agnostic schema.
110
104
 
111
- Here's how to model a many-to-many relationship between `User` and `Role` through a `UserRole` join table.
105
+ ### Example: Users and Roles (Many-to-Many) with Versioning
112
106
 
113
107
  ```typescript
114
108
  // models/User.ts
115
109
  import 'reflect-metadata';
116
- import { Model, Column, DataTypes, Required, Unique, OneToMany } from 'stabilize-orm';
110
+ import { Model, Column, DataTypes, Required, Unique, OneToMany, Versioned } from 'stabilize-orm';
117
111
  import { UserRole } from './UserRole';
118
112
 
119
113
  @Model('users')
114
+ @Versioned() // Enables history table for time-travel/audit
120
115
  export class User {
121
116
  @Column({ type: DataTypes.INTEGER, name: 'id' })
122
117
  id!: number;
@@ -124,8 +119,7 @@ export class User {
124
119
  @Column({ type: DataTypes.STRING, length: 100 })
125
120
  @Required() @Unique()
126
121
  email!: string;
127
-
128
- // This side of the relationship is for querying convenience
122
+
129
123
  @OneToMany(() => UserRole, 'user')
130
124
  roles?: UserRole[];
131
125
  }
@@ -154,7 +148,7 @@ import { Model, Column, DataTypes, Required, ManyToOne, Index } from 'stabilize-
154
148
  import { User } from './User';
155
149
  import { Role } from './Role';
156
150
 
157
- @Model('user_roles') // The join table
151
+ @Model('user_roles')
158
152
  export class UserRole {
159
153
  @Column({ type: DataTypes.INTEGER, name: 'id' })
160
154
  id!: number;
@@ -167,7 +161,6 @@ export class UserRole {
167
161
  @Required() @Index()
168
162
  roleId!: number;
169
163
 
170
- // Define the "many" sides of the relationship
171
164
  @ManyToOne(() => User, 'userId')
172
165
  user?: User;
173
166
 
@@ -178,51 +171,133 @@ export class UserRole {
178
171
 
179
172
  ---
180
173
 
174
+ ## ⏳ Versioning & Auditing
175
+
176
+ Enable automatic history tracking and time-travel queries by adding `@Versioned()` to your model.
177
+
178
+ - Each change is recorded in a `<table>_history` table with version, operation, and audit columns.
179
+ - Supports snapshot queries, rollbacks, audits, and time-travel.
180
+
181
+ ### **Versioning Example**
182
+
183
+ ```typescript
184
+ @Model('users')
185
+ @Versioned()
186
+ export class User {
187
+ @Column({ type: DataTypes.INTEGER, name: 'id' })
188
+ id!: number;
189
+
190
+ @Column({ type: DataTypes.STRING, length: 100 })
191
+ name!: string;
192
+ }
193
+
194
+ // --- Using versioning features:
195
+
196
+ const userRepository = orm.getRepository(User);
197
+
198
+ // Rollback to a previous version
199
+ await userRepository.rollback(1, 3); // roll back user with id=1 to version 3
200
+
201
+ // Get a snapshot as of a specific date
202
+ const userAsOf = await userRepository.asOf(1, new Date('2025-01-01T00:00:00Z'));
203
+ console.log(userAsOf);
204
+
205
+ // View full version history
206
+ const history = await userRepository.history(1);
207
+ console.log(history);
208
+ ```
209
+
210
+ ---
211
+
212
+ ## 🔄 Model Lifecycle Hooks
213
+
214
+ Stabilize ORM supports lifecycle hooks via the `@Hook()` decorator.
215
+ You can run logic before/after create, update, delete, or save.
216
+
217
+ ### **Hooks Example**
218
+
219
+ ```typescript
220
+ import { Model, Column, DataTypes, Hook } from 'stabilize-orm';
221
+
222
+ @Model('users')
223
+ export class User {
224
+ @Column({ type: DataTypes.INTEGER, name: 'id' })
225
+ id!: number;
226
+
227
+ @Column({ type: DataTypes.STRING, length: 100 })
228
+ name!: string;
229
+
230
+ @Column({ type: DataTypes.DATETIME, name: 'created_at' })
231
+ createdAt!: Date;
232
+
233
+ @Column({ type: DataTypes.DATETIME, name: 'updated_at' })
234
+ updatedAt!: Date;
235
+
236
+ @Hook('beforeCreate')
237
+ setCreatedAt() {
238
+ this.createdAt = new Date();
239
+ }
240
+
241
+ @Hook('beforeUpdate')
242
+ setUpdatedAt() {
243
+ this.updatedAt = new Date();
244
+ }
245
+
246
+ @Hook('afterCreate')
247
+ logCreate() {
248
+ console.log(`User created: ${this.name}`);
249
+ }
250
+ }
251
+ ```
252
+
253
+ You can use `@Hook` with: `'beforeCreate'`, `'afterCreate'`, `'beforeUpdate'`, `'afterUpdate'`, `'beforeDelete'`, `'afterDelete'`, `'beforeSave'`, `'afterSave'`.
254
+
255
+ ---
256
+
181
257
  ## 💻 Command-Line Interface (CLI)
182
258
 
183
- Stabilize includes a powerful CLI for managing your development workflow.
259
+ Stabilize includes a powerful CLI for managing your workflow.
184
260
 
185
261
  ### Generating Files
186
262
 
187
- - **Generate a model**:
263
+ - **Generate a model**:
188
264
  ```bash
189
265
  bun run stabilize-cli generate model Product
190
266
  ```
191
267
 
192
- - **Generate a migration from a model**:
268
+ - **Generate a migration from a model**:
193
269
  ```bash
194
- # Reads models/User.ts and creates a new migration file
195
270
  bun run stabilize-cli generate migration User
196
271
  ```
197
272
 
198
- - **Generate a seed file**:
273
+ - **Generate a seed file**:
199
274
  ```bash
200
275
  bun run stabilize-cli generate seed InitialRoles
201
276
  ```
202
277
 
203
278
  ### Database & Migration Management
204
279
 
205
- - **Run all pending migrations**:
280
+ - **Run all pending migrations**:
206
281
  ```bash
207
282
  bun run stabilize-cli migrate
208
283
  ```
209
284
 
210
- - **Roll back the last migration**:
285
+ - **Roll back the last migration**:
211
286
  ```bash
212
287
  bun run stabilize-cli migrate:rollback
213
288
  ```
214
289
 
215
- - **Run all pending seeds** (in dependency order):
290
+ - **Run all pending seeds (in dependency order)**:
216
291
  ```bash
217
292
  bun run stabilize-cli seed
218
293
  ```
219
294
 
220
- - **Check the status of all migrations and seeds**:
295
+ - **Check the status of migrations and seeds**:
221
296
  ```bash
222
297
  bun run stabilize-cli status
223
298
  ```
224
299
 
225
- - **Reset the database (drop, migrate, seed)**:
300
+ - **Reset the database (drop, migrate, seed)**:
226
301
  ```bash
227
302
  bun run stabilize-cli db:reset
228
303
  ```
@@ -233,34 +308,23 @@ Stabilize includes a powerful CLI for managing your development workflow.
233
308
 
234
309
  ### Basic CRUD with Repositories
235
310
 
236
- Interact with your data using the `Repository` pattern.
237
-
238
311
  ```typescript
239
312
  import { orm } from './db';
240
313
  import { User } from 'models/User';
241
314
 
242
315
  const userRepository = orm.getRepository(User);
243
316
 
244
- // Create a new user
245
317
  const newUser = await userRepository.create({ email: 'lwazicd@icloud.com' });
246
-
247
- // Find a user by ID
248
318
  const foundUser = await userRepository.findOne(newUser.id);
249
-
250
- // Update a user
251
319
  const updatedUser = await userRepository.update(newUser.id, { email: 'admin@offbytesecure.com' });
252
-
253
- // Delete a user
254
320
  await userRepository.delete(newUser.id);
255
321
  ```
256
322
 
257
323
  ### Advanced Queries with the Query Builder
258
324
 
259
- For complex queries, use the fluent `find()` method, which returns a chainable `QueryBuilder`.
260
-
261
325
  ```typescript
262
326
  const activeAdmins = await orm.getRepository(UserRole)
263
- .find() // Start a query on the user_roles table
327
+ .find()
264
328
  .join("users", "user_roles.user_id = users.id")
265
329
  .join("roles", "user_roles.role_id = roles.id")
266
330
  .select("users.id", "users.email", "roles.name as role_name")
@@ -269,13 +333,9 @@ const activeAdmins = await orm.getRepository(UserRole)
269
333
  .execute();
270
334
 
271
335
  console.log(activeAdmins);
272
- // [ { id: 1, email: 'lwazicd@icloud.com', role_name: 'Admin' } ]
273
336
  ```
274
337
 
275
- The `execute()` method on a repository query does not require a client to be passed; it uses the repository's default client automatically.
276
-
277
- ---
278
- **API:**
338
+ #### Query Builder API
279
339
 
280
340
  ```typescript
281
341
  {
@@ -286,16 +346,25 @@ The `execute()` method on a repository query does not require a client to be pas
286
346
  limit(limit: number): QueryBuilder<User>;
287
347
  offset(offset: number): QueryBuilder<User>;
288
348
  build(): { query: string; params: any[] };
289
- execute(client: DBClient, cache?: Cache, cacheKey?: string): Promise<User[]>;
349
+ execute(client?: DBClient, cache?: Cache, cacheKey?: string): Promise<User[]>;
290
350
  }
291
351
  ```
292
352
 
353
+ ---
354
+
355
+ ## 🗑️ Soft Deletes
356
+
357
+ Add `@SoftDelete()` to a model property to enable transparent soft deletes (e.g., `deleted_at` timestamp).
358
+ - Use `repository.softDelete(id)` to mark an entity as deleted.
359
+ - Use `find({ includeDeleted: true })` to include soft-deleted rows.
360
+
361
+ ---
362
+
293
363
  ## 🌐 Express.js Integration
294
364
 
295
365
  Stabilize ORM works seamlessly with web frameworks like Express.
296
366
 
297
367
  ```typescript
298
- // src/server.ts
299
368
  import express from "express";
300
369
  import { orm } from "./db";
301
370
  import { User } from "./models/User";
@@ -305,7 +374,6 @@ app.use(express.json());
305
374
 
306
375
  const userRepository = orm.getRepository(User);
307
376
 
308
- // Get all users
309
377
  app.get("/users", async (req, res) => {
310
378
  try {
311
379
  const users = await userRepository.find().execute();
@@ -315,7 +383,6 @@ app.get("/users", async (req, res) => {
315
383
  }
316
384
  });
317
385
 
318
- // Create a new user
319
386
  app.post("/users", async (req, res) => {
320
387
  try {
321
388
  const user = await userRepository.create(req.body);
@@ -332,6 +399,13 @@ app.listen(3000, () => {
332
399
 
333
400
  ---
334
401
 
402
+ ## 🧑‍🔬 Testing & Time-Travel
403
+
404
+ - Use time-travel queries to inspect historical entity states.
405
+ - Assert audit trails and rollback operations in your tests.
406
+
407
+ ---
408
+
335
409
  ## 📑 License
336
410
 
337
411
  Licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details.
@@ -342,6 +416,6 @@ Licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details.
342
416
 
343
417
  Created with ❤️ by **ElectronSz**
344
418
  <br/>
345
- *File last updated: 2025-10-15 19:32:00 UTC*
419
+ <em>File last updated: 2025-10-16 19:41:00 UTC</em>
346
420
 
347
421
  </div>
package/client.ts CHANGED
@@ -37,7 +37,7 @@ function isMySQLPool(client: any): client is mysql.Pool {
37
37
  export class DBClient {
38
38
  private client!: Database | Pool | mysql.Pool | PoolClient | mysql.PoolConnection;
39
39
  private logger: Logger;
40
- public readonly config: DBConfig;
40
+ public readonly config: DBConfig;
41
41
  private retryAttempts: number;
42
42
  private retryDelay: number;
43
43
  private maxJitter: number;
@@ -82,12 +82,12 @@ export class DBClient {
82
82
  } else if (isMySQLConfig(config)) {
83
83
  this.client = mysql.createPool(config.connectionString);
84
84
  this.logger.logDebug(`Initialized MySQL Pool client.`);
85
- } else {
86
- this.client = new Pool({ connectionString: config.connectionString });
85
+ } else if (config.type = DBType.Postgres) {
86
+ this.client = new Pool({ connectionString: config.connectionString! });
87
87
  this.logger.logDebug(`Initialized Postgres Pool client.`);
88
88
  }
89
89
  }
90
-
90
+
91
91
  /** @internal Gets a random jitter value to add to retry delays. */
92
92
  private getJitter = () => Math.random() * this.maxJitter;
93
93
 
@@ -103,50 +103,53 @@ export class DBClient {
103
103
  * const users = await dbClient.query('SELECT * FROM users WHERE status = ?', ['active']);
104
104
  * ```
105
105
  */
106
-
107
- async query<T>(query: string, params: any[] = []): Promise<T[]> {
108
- const start = Date.now();
109
-
110
- for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
111
- try {
112
- let result: any;
113
-
114
- // Log the query before execution
115
- this.logger.logQuery(query, params);
116
-
117
- if (this.client instanceof Database) { // SQLite
118
- let stmt = this.preparedStatements.get(query);
119
- if (!stmt) {
120
- stmt = this.client.prepare(query);
121
- this.preparedStatements.set(query, stmt);
122
- }
123
- result = stmt.all(...params);
124
- } else if (isMySQLPool(this.client) || ('query' in this.client && 'release' in this.client && !(this.client instanceof Pool))) { // mysql2 Pool or Connection
125
- const [rows] = await (this.client as mysql.Pool).query(query, params);
126
- result = rows;
127
- } else { // Postgres Pool or Client
128
-
129
- // Correctly replace '?' with '$1', '$2', etc.
130
- let paramIndex = 0;
131
- const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
132
- const pgResult = await (this.client as Pool).query(pgQuery, params);
133
- result = pgResult.rows;
134
- }
135
-
136
- const executionTime = Date.now() - start;
137
- this.logger.logQuery(query, params, executionTime);
138
- return result as T[];
139
- } catch (error) {
140
- this.logger.logError(error as Error);
141
- if (attempt === this.retryAttempts) {
142
- throw new StabilizeError(`Query failed after ${this.retryAttempts} attempts: ${(error as Error).message}`, "QUERY_ERROR");
143
- }
144
- await new Promise(res => setTimeout(res, this.retryDelay * Math.pow(2, attempt - 1) + this.getJitter()));
145
- }
106
+
107
+ async query<T>(query: string, params: any[] = []): Promise<T[]> {
108
+ const start = Date.now();
109
+
110
+ for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
111
+ try {
112
+ let result: any;
113
+
114
+ // Log the query before execution
115
+ this.logger.logQuery(query, params);
116
+
117
+ if (this.client instanceof Database) { // SQLite
118
+ let stmt = this.preparedStatements.get(query);
119
+ if (!stmt) {
120
+ stmt = this.client.prepare(query);
121
+ this.preparedStatements.set(query, stmt);
122
+ }
123
+ result = stmt.all(...params);
124
+ } else if (this.config.type === DBType.MySQL && isMySQLPool(this.client)) { // MySQL
125
+ const [rows] = await (this.client as mysql.Pool).query(query, params);
126
+ result = rows;
127
+ } else if (this.config.type === DBType.Postgres ) { // Postgres
128
+
129
+ let paramIndex = 0;
130
+ const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
131
+ const pgResult = await (this.client as Pool).query(pgQuery, params);
132
+ result = Array.isArray(pgResult.rows) ? pgResult.rows : [];
133
+ } else {
134
+ throw new StabilizeError("Unknown database client type", "QUERY_ERROR");
135
+ }
136
+
137
+ const executionTime = Date.now() - start;
138
+ this.logger.logQuery(query, params, executionTime);
139
+ return Array.isArray(result) ? result as T[] : [];
140
+ } catch (error) {
141
+ console.log("error: ", error);
142
+
143
+ this.logger.logError(error as Error);
144
+ if (attempt === this.retryAttempts) {
145
+ throw new StabilizeError(`Query failed after ${this.retryAttempts} attempts: ${(error as Error).message}`, "QUERY_ERROR");
146
146
  }
147
- // This line should theoretically be unreachable if retryAttempts >= 1
148
- throw new StabilizeError("Query failed: maximum retries reached without success", "QUERY_ERROR");
147
+ await new Promise(res => setTimeout(res, this.retryDelay * Math.pow(2, attempt - 1) + this.getJitter()));
148
+ }
149
149
  }
150
+ // This line should theoretically be unreachable if retryAttempts >= 1
151
+ throw new StabilizeError("Query failed: maximum retries reached without success", "QUERY_ERROR");
152
+ }
150
153
 
151
154
  /**
152
155
  * Executes a series of database operations within a single atomic transaction.
@@ -169,23 +172,23 @@ export class DBClient {
169
172
  const tx = this.client.transaction(() => callback(this));
170
173
  return tx();
171
174
  }
172
-
175
+
173
176
  if (isMySQLPool(this.client)) {
174
- const connection = await this.client.getConnection();
175
- const txClient = new DBClient(this.config, this.logger, connection);
176
- this.logger.logDebug("Starting MySQL transaction.");
177
- try {
178
- await txClient.query("START TRANSACTION");
179
- const result = await callback(txClient);
180
- await txClient.query("COMMIT");
181
- return result;
182
- } catch (error) {
183
- await txClient.query("ROLLBACK");
184
- throw error;
185
- } finally {
186
- connection.release();
187
- this.logger.logDebug("MySQL transaction connection released.");
188
- }
177
+ const connection = await this.client.getConnection();
178
+ const txClient = new DBClient(this.config, this.logger, connection);
179
+ this.logger.logDebug("Starting MySQL transaction.");
180
+ try {
181
+ await txClient.query("START TRANSACTION");
182
+ const result = await callback(txClient);
183
+ await txClient.query("COMMIT");
184
+ return result;
185
+ } catch (error) {
186
+ await txClient.query("ROLLBACK");
187
+ throw error;
188
+ } finally {
189
+ connection.release();
190
+ this.logger.logDebug("MySQL transaction connection released.");
191
+ }
189
192
  }
190
193
 
191
194
  if (this.client instanceof Pool) {
@@ -193,12 +196,12 @@ export class DBClient {
193
196
  const txClient = new DBClient(this.config, this.logger, connection);
194
197
  this.logger.logDebug("Starting Postgres transaction.");
195
198
  try {
196
- await txClient.query("BEGIN");
199
+ await txClient.migrationQuery("BEGIN");
197
200
  const result = await callback(txClient);
198
- await txClient.query("COMMIT");
201
+ await txClient.migrationQuery("COMMIT");
199
202
  return result;
200
203
  } catch (error) {
201
- await txClient.query("ROLLBACK");
204
+ await txClient.migrationQuery("ROLLBACK");
202
205
  throw error;
203
206
  } finally {
204
207
  connection.release();
@@ -222,4 +225,46 @@ export class DBClient {
222
225
  this.client = null!;
223
226
  this.logger.logInfo("Database connection closed");
224
227
  }
228
+
229
+ /**
230
+ * Executes a SQL query for migrations/transactions that
231
+ * does NOT expect any result rows and returns void.
232
+ * This is used for DDL and transaction statements (e.g. CREATE TABLE, BEGIN, COMMIT)
233
+ * that should never be iterated over.
234
+ *
235
+ * Logs the execution time for each query.
236
+ *
237
+ * @param query The SQL query string with `?` as placeholders.
238
+ * @param params An array of parameters to bind to the query.
239
+ * @returns A promise that resolves when the query has executed.
240
+ * @example
241
+ * ```
242
+ * await dbClient.migrationQuery('CREATE TABLE ...');
243
+ * await dbClient.migrationQuery('BEGIN');
244
+ * await dbClient.migrationQuery('COMMIT');
245
+ * ```
246
+ */
247
+ async migrationQuery(query: string, params: any[] = []): Promise<void> {
248
+ const start = Date.now();
249
+ this.logger.logQuery(query, params);
250
+
251
+ if (this.client instanceof Database) { // SQLite
252
+ let stmt = this.preparedStatements.get(query);
253
+ if (!stmt) {
254
+ stmt = this.client.prepare(query);
255
+ this.preparedStatements.set(query, stmt);
256
+ }
257
+ stmt.run(...params);
258
+ } else if (isMySQLPool(this.client) || ('query' in this.client && 'release' in this.client && !(this.client instanceof Pool))) { // mysql2 Pool or Connection
259
+ await (this.client as mysql.Pool).query(query, params);
260
+ } else if (this.config.type = DBType.Postgres) { // Postgres Pool or Client
261
+ let paramIndex = 0;
262
+ const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
263
+ await (this.client as Pool).query(pgQuery, params);
264
+ }
265
+
266
+ const executionTime = Date.now() - start;
267
+ this.logger.logQuery(query, params, executionTime);
268
+ }
269
+
225
270
  }