stabilize-orm 1.1.3 → 1.1.5

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/client.ts CHANGED
@@ -1,233 +1,216 @@
1
- import { sql, SQL } from "bun";
1
+ /**
2
+ * @file client.ts
3
+ * @description Provides a unified database client for interacting with PostgreSQL, MySQL, and SQLite.
4
+ * @author ElectronSz
5
+ */
6
+
2
7
  import { Database, Statement } from "bun:sqlite";
8
+ import { Pool, type PoolClient } from "pg";
9
+ import mysql from "mysql2/promise";
3
10
  import {
4
11
  type DBConfig,
5
12
  StabilizeError,
6
- type PoolMetrics,
7
13
  DBType,
8
14
  } from "./types";
9
15
  import { type Logger, ConsoleLogger } from "./logger";
10
16
 
17
+ /** @internal Checks if the config is for SQLite. */
11
18
  function isSQLiteConfig(config: DBConfig): boolean {
12
- return (
13
- config.type === DBType.SQLite || config.connectionString.includes("sqlite")
14
- );
19
+ return config.type === DBType.SQLite;
20
+ }
21
+
22
+ /** @internal Checks if the config is for MySQL. */
23
+ function isMySQLConfig(config: DBConfig): boolean {
24
+ return config.type === DBType.MySQL;
15
25
  }
16
26
 
27
+ /** @internal A type guard to reliably identify a mysql2 Pool object. */
28
+ function isMySQLPool(client: any): client is mysql.Pool {
29
+ return typeof client.getConnection === 'function';
30
+ }
31
+
32
+ /**
33
+ * A unified database client that provides a consistent interface for
34
+ * PostgreSQL, MySQL, and SQLite databases. It handles connection pooling,
35
+ * query execution with retries, and transactions.
36
+ */
17
37
  export class DBClient {
18
- private client: Database | any | null = null;
38
+ private client!: Database | Pool | mysql.Pool | PoolClient | mysql.PoolConnection;
19
39
  private logger: Logger;
40
+ public readonly config: DBConfig;
20
41
  private retryAttempts: number;
21
42
  private retryDelay: number;
22
43
  private maxJitter: number;
23
44
 
24
45
  private preparedStatements: Map<string, Statement> = new Map();
25
- private config: DBConfig;
26
-
27
- constructor(config: DBConfig, logger: Logger = new ConsoleLogger()) {
46
+ public readonly isTransactionClient: boolean = false;
47
+
48
+ /**
49
+ * Creates an instance of DBClient.
50
+ * @param config The database configuration object.
51
+ * @param logger A logger instance for logging messages.
52
+ * @param existingClient An optional existing connection, used internally for transactions.
53
+ */
54
+ constructor(
55
+ config: DBConfig,
56
+ logger: Logger = new ConsoleLogger(),
57
+ existingClient: PoolClient | mysql.PoolConnection | null = null,
58
+ ) {
28
59
  this.config = config;
29
60
  this.logger = logger;
30
61
  this.retryAttempts = config.retryAttempts || 3;
31
62
  this.retryDelay = config.retryDelay || 1000;
32
63
  this.maxJitter = config.maxJitter || 100;
33
64
 
34
- this.initializeClient(config);
65
+ if (existingClient) {
66
+ this.client = existingClient;
67
+ this.isTransactionClient = true;
68
+ } else {
69
+ this.initializeClient(config);
70
+ }
35
71
  }
36
72
 
73
+ /**
74
+ * @internal
75
+ * Initializes the database client based on the provided configuration.
76
+ * @param config The database configuration.
77
+ */
37
78
  private initializeClient(config: DBConfig) {
38
79
  if (isSQLiteConfig(config)) {
39
- try {
40
- // new Database(path, { create: true }) is the standard Bun method
41
- this.client = new Database(config.connectionString, { create: true });
42
- this.logger.logDebug(
43
- `Initialized Bun SQLite client for: ${config.connectionString}`,
44
- );
45
- } catch (e) {
46
- this.logger.logError(e as Error);
47
- throw new StabilizeError(
48
- `Failed to initialize SQLite database: ${(e as Error).message}`,
49
- "INIT_ERROR",
50
- );
51
- }
52
- } else {
53
- this.client = new SQL(config.connectionString);
54
- this.logger.logDebug(
55
- `Initialized Bun SQL client for: ${config.connectionString}`,
56
- );
80
+ this.client = new Database(config.connectionString, { create: true });
81
+ this.logger.logDebug(`Initialized Bun SQLite client.`);
82
+ } else if (isMySQLConfig(config)) {
83
+ this.client = mysql.createPool(config.connectionString);
84
+ this.logger.logDebug(`Initialized MySQL Pool client.`);
85
+ } else {
86
+ this.client = new Pool({ connectionString: config.connectionString });
87
+ this.logger.logDebug(`Initialized Postgres Pool client.`);
57
88
  }
58
89
  }
59
-
60
- private getJitter() {
61
- return Math.random() * this.maxJitter;
62
- }
63
-
64
- getPoolMetrics(): PoolMetrics {
65
- return {
66
- activeConnections: 0,
67
- idleConnections: 0,
68
- totalConnections: this.client instanceof Database ? 1 : 0,
69
- };
70
- }
71
-
72
- async switchConnection(config: DBConfig) {
73
- await this.close();
74
- this.config = config;
75
- this.preparedStatements.clear();
76
- this.retryAttempts = config.retryAttempts || 3;
77
- this.retryDelay = config.retryDelay || 1000;
78
- this.maxJitter = config.maxJitter || 100;
79
- this.initializeClient(config);
80
- }
81
-
90
+
91
+ /** @internal Gets a random jitter value to add to retry delays. */
92
+ private getJitter = () => Math.random() * this.maxJitter;
93
+
94
+ /**
95
+ * Executes a SQL query with parameters and returns the result.
96
+ * Automatically handles placeholder conversion for different databases and includes retry logic.
97
+ * @template T The expected type of the result rows.
98
+ * @param query The SQL query string with `?` as placeholders.
99
+ * @param params An array of parameters to bind to the query.
100
+ * @returns A promise that resolves to an array of results.
101
+ * @example
102
+ * ```
103
+ * const users = await dbClient.query('SELECT * FROM users WHERE status = ?', ['active']);
104
+ * ```
105
+ */
82
106
  async query<T>(query: string, params: any[] = []): Promise<T[]> {
83
107
  const start = Date.now();
84
108
  this.logger.logQuery(query, params);
85
- this.logger.logMetrics(this.getPoolMetrics());
86
-
87
- let stmt: Statement | undefined;
88
-
89
- // Check for Bun SQLite client
90
- if (this.client instanceof Database) {
91
- const stmtKey = query;
92
- // Use prepared statement caching for SQLite
93
- if (!this.preparedStatements.has(stmtKey)) {
94
- // Use this.client (the Database instance) to prepare the statement
95
- this.preparedStatements.set(stmtKey, this.client.prepare(query));
96
- }
97
- stmt = this.preparedStatements.get(stmtKey);
98
- }
99
109
 
100
110
  for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
101
111
  try {
102
- let result: T[];
103
-
104
- if (stmt) {
105
- result = stmt.all(...params) as T[];
106
- } else if (this.client) {
107
- result = (await (this.client as any).unsafe(query, params)) as T[];
108
- } else {
109
- throw new StabilizeError(
110
- "Database client is not initialized or does not support query execution.",
111
- "INIT_ERROR",
112
- );
112
+ let result: any;
113
+
114
+ if (this.client instanceof Database) { // SQLite
115
+ let stmt = this.preparedStatements.get(query);
116
+ if (!stmt) {
117
+ stmt = this.client.prepare(query);
118
+ this.preparedStatements.set(query, stmt);
119
+ }
120
+ result = stmt.all(...params);
121
+ } else if (isMySQLPool(this.client) || ('query' in this.client && 'release' in this.client)) { // mysql2 Pool or Connection
122
+ const [rows] = await (this.client as mysql.Pool).query(query, params);
123
+ result = rows;
124
+ } else { // Postgres Pool or Client
125
+ const pgQuery = query.replace(/\?/g, (_, i) => `$${i + 1}`);
126
+ const pgResult = await (this.client as Pool).query(pgQuery, params);
127
+ result = pgResult.rows;
113
128
  }
114
129
 
115
130
  const executionTime = Date.now() - start;
116
131
  this.logger.logQuery(query, params, executionTime);
117
- return result;
132
+ return result as T[];
118
133
  } catch (error) {
119
134
  this.logger.logError(error as Error);
120
- if (attempt === this.retryAttempts) {
121
- throw new StabilizeError(
122
- `Query failed after ${this.retryAttempts} attempts: ${(error as Error).message}`,
123
- "QUERY_ERROR",
124
- );
125
- }
126
- await new Promise((resolve) =>
127
- setTimeout(
128
- resolve,
129
- this.retryDelay * Math.pow(2, attempt - 1) + this.getJitter(),
130
- ),
131
- );
135
+ if (attempt === this.retryAttempts) throw new StabilizeError(`Query failed: ${(error as Error).message}`, "QUERY_ERROR");
136
+ await new Promise(res => setTimeout(res, this.retryDelay * Math.pow(2, attempt - 1) + this.getJitter()));
132
137
  }
133
138
  }
134
139
  throw new StabilizeError("Query failed: no attempts made", "QUERY_ERROR");
135
140
  }
136
141
 
137
- async transaction<T>(callback: () => Promise<T>): Promise<T> {
138
- if (this.client instanceof Database) {
139
- const start = Date.now();
140
- this.logger.logDebug("Starting native SQLite transaction");
141
-
142
- const tx = this.client.transaction(async () => {
143
- return callback();
144
- });
142
+ /**
143
+ * Executes a series of database operations within a single atomic transaction.
144
+ * If any operation in the callback fails, the entire transaction is rolled back.
145
+ * @template T The return type of the callback function.
146
+ * @param callback An async function that receives a transactional `DBClient` instance.
147
+ * @returns A promise that resolves with the result of the callback.
148
+ * @example
149
+ * ```
150
+ * await dbClient.transaction(async (txClient) => {
151
+ * await txClient.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1');
152
+ * await txClient.query('UPDATE accounts SET balance = balance + 100 WHERE id = 2');
153
+ * });
154
+ * ```
155
+ */
156
+ async transaction<T>(callback: (txClient: DBClient) => Promise<T>): Promise<T> {
157
+ if (this.isTransactionClient) return callback(this);
145
158
 
146
- try {
147
- const result = await tx();
148
- this.logger.logDebug(
149
- `Native transaction committed in ${Date.now() - start}ms`,
150
- );
151
- return result;
152
- } catch (error) {
153
- this.logger.logError(error as Error);
154
- throw new StabilizeError(
155
- `Native transaction failed: ${(error as Error).message}`,
156
- "TX_ERROR",
157
- );
158
- }
159
+ if (this.client instanceof Database) {
160
+ const tx = this.client.transaction(() => callback(this));
161
+ return tx();
162
+ }
163
+
164
+ if (isMySQLPool(this.client)) {
165
+ const connection = await this.client.getConnection();
166
+ const txClient = new DBClient(this.config, this.logger, connection);
167
+ this.logger.logDebug("Starting MySQL transaction.");
168
+ try {
169
+ await txClient.query("START TRANSACTION");
170
+ const result = await callback(txClient);
171
+ await txClient.query("COMMIT");
172
+ return result;
173
+ } catch (error) {
174
+ await txClient.query("ROLLBACK");
175
+ throw error;
176
+ } finally {
177
+ connection.release();
178
+ this.logger.logDebug("MySQL transaction connection released.");
179
+ }
159
180
  }
160
181
 
161
- const start = Date.now();
162
- this.logger.logDebug("Starting manual transaction (non-SQLite)");
163
- for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
182
+ if (this.client instanceof Pool) {
183
+ const connection = await this.client.connect();
184
+ const txClient = new DBClient(this.config, this.logger, connection);
185
+ this.logger.logDebug("Starting Postgres transaction.");
164
186
  try {
165
- await this.query("BEGIN", []);
166
- const result = await callback();
167
- await this.query("COMMIT", []);
168
- this.logger.logDebug(
169
- `Manual transaction committed in ${Date.now() - start}ms`,
170
- );
187
+ await txClient.query("BEGIN");
188
+ const result = await callback(txClient);
189
+ await txClient.query("COMMIT");
171
190
  return result;
172
191
  } catch (error) {
173
- await this.query("ROLLBACK", []).catch(() => {
174
- this.logger.logDebug("Rollback failed. Connection may be invalid.");
175
- });
176
- this.logger.logError(error as Error);
177
- if (attempt === this.retryAttempts) {
178
- throw new StabilizeError(
179
- `Manual transaction failed after ${this.retryAttempts} attempts: ${(error as Error).message}`,
180
- "TX_ERROR",
181
- );
182
- }
183
- await new Promise((resolve) =>
184
- setTimeout(
185
- resolve,
186
- this.retryDelay * Math.pow(2, attempt - 1) + this.getJitter(),
187
- ),
188
- );
192
+ await txClient.query("ROLLBACK");
193
+ throw error;
194
+ } finally {
195
+ connection.release();
196
+ this.logger.logDebug("Postgres transaction connection released.");
189
197
  }
190
198
  }
191
- throw new StabilizeError(
192
- "Transaction failed: no attempts made",
193
- "TX_ERROR",
194
- );
195
- }
196
199
 
197
- async savepoint<T>(name: string, callback: () => Promise<T>): Promise<T> {
198
- const start = Date.now();
199
- this.logger.logDebug(`Starting savepoint ${name}`);
200
- await this.query(`SAVEPOINT ${name}`, []);
201
- try {
202
- const result = await callback();
203
- await this.query(`RELEASE SAVEPOINT ${name}`, []);
204
- this.logger.logDebug(
205
- `Savepoint ${name} released in ${Date.now() - start}ms`,
206
- );
207
- return result;
208
- } catch (error) {
209
- await this.query(`ROLLBACK TO SAVEPOINT ${name}`, []).catch(() => {
210
- this.logger.logDebug(
211
- `Rollback to savepoint ${name} failed. Connection may be invalid.`,
212
- );
213
- });
214
- this.logger.logError(error as Error);
215
- throw error;
216
- }
200
+ throw new StabilizeError("Transaction not supported by this client configuration.", "TX_ERROR");
217
201
  }
218
202
 
203
+ /**
204
+ * Closes the database connection pool gracefully.
205
+ * Should be called when the application is shutting down.
206
+ */
219
207
  async close() {
220
- this.preparedStatements.clear();
221
- if (this.client instanceof Database) {
208
+ if (this.client instanceof Database) {
222
209
  this.client.close();
223
- this.client = null;
224
- } else if (
225
- this.client &&
226
- typeof (this.client as any).close === "function"
227
- ) {
228
- await (this.client as any).close();
229
- this.client = null;
210
+ } else if (this.client && 'end' in this.client) {
211
+ await (this.client as any).end();
230
212
  }
213
+ this.client = null!;
231
214
  this.logger.logInfo("Database connection closed");
232
215
  }
233
- }
216
+ }
package/decorators.ts CHANGED
@@ -1,26 +1,61 @@
1
+ /**
2
+ * @file decorators.ts
3
+ * @description Contains all the decorators used by the Stabilize ORM.
4
+ * @author ElectronSz
5
+ */
6
+
1
7
  import "reflect-metadata";
2
- import { RelationType } from "./types";
8
+ import { RelationType, DataTypes } from "./types";
9
+
3
10
 
4
11
  export const ModelKey = Symbol("model");
5
12
  export const ColumnKey = Symbol("column");
6
13
  export const ValidatorKey = Symbol("validator");
7
14
  export const RelationKey = Symbol("relation");
8
15
  export const SoftDeleteKey = Symbol("softDelete");
16
+ export const DefaultKey = Symbol("default");
17
+ export const IndexKey = Symbol("index");
18
+
19
+
20
+ export interface ColumnOptions {
21
+ name?: string;
22
+ type: DataTypes;
23
+ length?: number;
24
+ precision?: number;
25
+ scale?: number;
26
+ }
9
27
 
28
+ /**
29
+ * Decorator to mark a class as a database model.
30
+ * @param tableName The name of the table in the database.
31
+ */
10
32
  export function Model(tableName: string) {
11
33
  return function (constructor: Function) {
12
34
  Reflect.defineMetadata(ModelKey, tableName, constructor);
13
35
  };
14
36
  }
15
37
 
16
- export function Column(name: string, type: string) {
38
+ /**
39
+ * Decorator to mark a property as a database column.
40
+ * @param options The configuration for the column, including name, type, and length.
41
+ */
42
+ export function Column(options: ColumnOptions | DataTypes) {
17
43
  return function (target: any, propertyKey: string) {
18
44
  const columns = Reflect.getMetadata(ColumnKey, target) || {};
19
- columns[propertyKey] = { name, type };
45
+
46
+ const columnOptions: ColumnOptions = typeof options === 'object' ? options : { type: options };
47
+
48
+ columns[propertyKey] = {
49
+ name: columnOptions.name || propertyKey,
50
+ ...columnOptions,
51
+ };
20
52
  Reflect.defineMetadata(ColumnKey, columns, target);
21
53
  };
22
54
  }
23
55
 
56
+ /**
57
+ * Decorator to enforce a NOT NULL constraint on a column.
58
+ */
24
59
  export function Required() {
25
60
  return function (target: any, propertyKey: string) {
26
61
  const validators = Reflect.getMetadata(ValidatorKey, target) || {};
@@ -29,6 +64,9 @@ export function Required() {
29
64
  };
30
65
  }
31
66
 
67
+ /**
68
+ * Decorator to enforce a UNIQUE constraint on a column.
69
+ */
32
70
  export function Unique() {
33
71
  return function (target: any, propertyKey: string) {
34
72
  const validators = Reflect.getMetadata(ValidatorKey, target) || {};
@@ -37,12 +75,40 @@ export function Unique() {
37
75
  };
38
76
  }
39
77
 
78
+ /**
79
+ * Decorator to set a default value for a column.
80
+ * @param value The default value.
81
+ */
82
+ export function Default(value: any) {
83
+ return function (target: any, propertyKey: string) {
84
+ Reflect.defineMetadata(DefaultKey, value, target, propertyKey);
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Decorator to create a non-unique index on a column for performance.
90
+ * @param indexName Optional: A custom name for the index.
91
+ */
92
+ export function Index(indexName?: string) {
93
+ return function (target: any, propertyKey: string) {
94
+ const indexes = Reflect.getMetadata(IndexKey, target) || {};
95
+ indexes[propertyKey] = indexName || `idx_${propertyKey}`;
96
+ Reflect.defineMetadata(IndexKey, indexes, target);
97
+ };
98
+ }
99
+
100
+
101
+ /**
102
+ * Decorator to enable soft-delete functionality on a model.
103
+ * The decorated property will store the deletion timestamp.
104
+ */
40
105
  export function SoftDelete() {
41
106
  return function (target: any, propertyKey: string) {
42
107
  Reflect.defineMetadata(SoftDeleteKey, propertyKey, target);
43
108
  };
44
109
  }
45
110
 
111
+
46
112
  export function OneToOne(model: () => any, foreignKey: string) {
47
113
  return function (target: any, propertyKey: string) {
48
114
  const relations = Reflect.getMetadata(RelationKey, target) || {};
@@ -96,4 +162,4 @@ export function ManyToMany(
96
162
  };
97
163
  Reflect.defineMetadata(RelationKey, relations, target);
98
164
  };
99
- }
165
+ }