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 +150 -76
- package/client.ts +110 -65
- package/decorators.ts +19 -14
- package/hooks.ts +33 -0
- package/index.ts +5 -2
- package/migrations.ts +147 -70
- package/package.json +1 -1
- package/repository.ts +378 -60
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
|
|
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
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
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
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
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.
|
|
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';
|
|
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,
|
|
86
|
+
ttl: 60,
|
|
89
87
|
};
|
|
90
88
|
|
|
91
89
|
const loggerConfig: LoggerConfig = {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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')
|
|
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
|
|
259
|
+
Stabilize includes a powerful CLI for managing your workflow.
|
|
184
260
|
|
|
185
261
|
### Generating Files
|
|
186
262
|
|
|
187
|
-
-
|
|
263
|
+
- **Generate a model**:
|
|
188
264
|
```bash
|
|
189
265
|
bun run stabilize-cli generate model Product
|
|
190
266
|
```
|
|
191
267
|
|
|
192
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
280
|
+
- **Run all pending migrations**:
|
|
206
281
|
```bash
|
|
207
282
|
bun run stabilize-cli migrate
|
|
208
283
|
```
|
|
209
284
|
|
|
210
|
-
-
|
|
285
|
+
- **Roll back the last migration**:
|
|
211
286
|
```bash
|
|
212
287
|
bun run stabilize-cli migrate:rollback
|
|
213
288
|
```
|
|
214
289
|
|
|
215
|
-
-
|
|
290
|
+
- **Run all pending seeds (in dependency order)**:
|
|
216
291
|
```bash
|
|
217
292
|
bun run stabilize-cli seed
|
|
218
293
|
```
|
|
219
294
|
|
|
220
|
-
-
|
|
295
|
+
- **Check the status of migrations and seeds**:
|
|
221
296
|
```bash
|
|
222
297
|
bun run stabilize-cli status
|
|
223
298
|
```
|
|
224
299
|
|
|
225
|
-
-
|
|
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()
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
199
|
+
await txClient.migrationQuery("BEGIN");
|
|
197
200
|
const result = await callback(txClient);
|
|
198
|
-
await txClient.
|
|
201
|
+
await txClient.migrationQuery("COMMIT");
|
|
199
202
|
return result;
|
|
200
203
|
} catch (error) {
|
|
201
|
-
await txClient.
|
|
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
|
}
|