stabilize-orm 1.3.2 → 1.3.3

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/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ All notable changes to this project will be documented in this file.
6
6
 
7
7
  - Further features and improvements coming soon.
8
8
 
9
+
10
+ ## [1.3.2] - 2025-10-19
11
+
12
+ ### Added
13
+ - Added **Timestamps Configuration** feature for automatic management of `createdAt` and `updatedAt` columns (`types.ts`, `model.ts`, `repository.ts`, `migrations.ts`).
14
+ - Added `TimestampsConfig` interface and `timestamps` property to `ModelConfig` in `types.ts`.
15
+ - Added `getTimestamps` method to `MetadataStorage` in `model.ts`.
16
+ - Updated `create`, `update`, `bulkCreate`, `bulkUpdate`, and `upsert` methods in `repository.ts` to set timestamps automatically.
17
+ - Updated `migrations.ts` to include timestamp columns in schema generation.
18
+ - Updated `README.md` with a new "Timestamps" section and example.
19
+
20
+ ### Fixed
21
+ - Fixed TypeScript error (TS7053) in `repository.ts` for timestamps handling in `_create`, `_bulkCreate`, `_bulkUpdate`, and `_upsert` methods by using `Record<string, any>` for safe property access and maintaining `Partial<T>` type safety.
22
+
23
+ ## [1.3.2] - 2025-10-19
24
+
25
+ ### Added
26
+ - Added **Custom Query Scopes** feature, allowing reusable query conditions defined in model configurations (`types.ts`, `model.ts`, `query-builder.ts`, `repository.ts`).
27
+ - Added `scopes` property to `ModelConfig` interface in `types.ts` to define scope functions.
28
+ - Added `getScopes` method to `MetadataStorage` in `model.ts` to retrieve scope definitions.
29
+ - Added `scope` method to `QueryBuilder` in `query-builder.ts` to apply scopes to queries.
30
+ - Added `scope` method to `Repository` in `repository.ts` for direct scope application.
31
+ - Updated `README.md` with a new "Custom Query Scopes" section and example.
32
+
9
33
  ## [1.3.0] - 2025-10-18
10
34
 
11
35
  ### Added
package/README.md CHANGED
@@ -22,10 +22,11 @@ _A Modern, Type-Safe, and Expressive ORM for Bun_
22
22
  - **Model Relationships**: Define `OneToOne`, `ManyToOne`, `OneToMany`, and `ManyToMany` relationships in the model configuration.
23
23
  - **Soft Deletes**: Enable soft deletes in the model configuration for transparent "deleted" flags and safe row removal.
24
24
  - **Lifecycle Hooks**: Define hooks in the model configuration or as class methods for lifecycle events like `beforeCreate`, `afterUpdate`, etc.
25
- - **Pluggable Logging**: Includes a robust `ConsoleLogger` with support for file-based, rotating logs.
25
+ - **Pluggable Logging**: Includes a robust `StabilizeLogger` with support for file-based, rotating logs.
26
26
  - **Custom Errors**: `StabilizeError` provides clear, consistent error handling.
27
27
  - **Caching Layer**: Optional Redis-backed caching with `cache-aside` and `write-through` strategies.
28
28
  - **Custom Query Scopes**: Define reusable query conditions (scopes) in models for simplified, reusable filtering logic.
29
+ - **Timestamps**: Automatically manage `createdAt` and `updatedAt` columns for tracking record creation and update times.
29
30
 
30
31
  ---
31
32
 
@@ -382,6 +383,7 @@ const User = defineModel({
382
383
  email: { type: DataTypes.String, length: 100, required: true },
383
384
  isActive: { type: DataTypes.Boolean, required: true },
384
385
  createdAt: { type: DataTypes.DateTime },
386
+ updatedAt: { type: DataTypes.DateTime },
385
387
  },
386
388
  scopes: {
387
389
  active: (qb) => qb.where("isActive = ?", true),
@@ -408,6 +410,48 @@ const recentActiveUsers = await userRepository
408
410
  console.log(recentActiveUsers);
409
411
  ```
410
412
 
413
+ ### Timestamps
414
+
415
+ Enable automatic management of `createdAt` and `updatedAt` columns by setting `timestamps` in your model configuration. The ORM automatically sets these fields during `create`, `update`, `bulkCreate`, `bulkUpdate`, and `upsert` operations in a TypeScript-safe manner, eliminating the need for manual hooks.
416
+
417
+ #### **Timestamps Example**
418
+
419
+ ```typescript
420
+ import { defineModel, DataTypes } from "stabilize-orm";
421
+ import { orm } from "./db";
422
+
423
+ const User = defineModel({
424
+ tableName: "users",
425
+ columns: {
426
+ id: { type: DataTypes.Integer, required: true },
427
+ email: { type: DataTypes.String, length: 100, required: true },
428
+ createdAt: { type: DataTypes.DateTime },
429
+ updatedAt: { type: DataTypes.DateTime },
430
+ },
431
+ timestamps: {
432
+ createdAt: "createdAt",
433
+ updatedAt: "updatedAt",
434
+ },
435
+ });
436
+
437
+ const userRepository = orm.getRepository(User);
438
+
439
+ // Create a user (createdAt and updatedAt set automatically)
440
+ const newUser = await userRepository.create({ email: "lwazicd@icloud.com" });
441
+ console.log(newUser.createdAt, newUser.updatedAt); // Outputs current timestamp
442
+
443
+ // Update a user (updatedAt updated automatically)
444
+ const updatedUser = await userRepository.update(newUser.id, { email: "admin@offbytesecure.com" });
445
+ console.log(updatedUser.updatedAt); // Outputs new timestamp
446
+
447
+ // Bulk create users
448
+ const newUsers = await userRepository.bulkCreate([
449
+ { email: "user1@example.com" },
450
+ { email: "user2@example.com" },
451
+ ]);
452
+ console.log(newUsers.map(u => u.createdAt)); // Outputs timestamps for each user
453
+ ```
454
+
411
455
  ---
412
456
 
413
457
  ## 🗑️ Soft Deletes
@@ -497,6 +541,6 @@ Licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details.
497
541
 
498
542
  Created with ❤️ by **ElectronSz**
499
543
  <br/>
500
- <em>File last updated: 2025-10-19 10:24:00 SAST</em>
544
+ <em>File last updated: 2025-10-19 11:12:00 SAST</em>
501
545
 
502
546
  </div>
package/client.ts CHANGED
@@ -12,20 +12,38 @@ import {
12
12
  StabilizeError,
13
13
  DBType,
14
14
  } from "./types";
15
- import { type Logger, ConsoleLogger } from "./logger";
15
+ import { type Logger, StabilizeLogger } from "./logger";
16
16
 
17
+ /**
18
+ * Checks if the DB configuration is for SQLite.
19
+ * @param config The database configuration object.
20
+ * @returns True if the configuration is for SQLite, false otherwise.
21
+ */
17
22
  function isSQLiteConfig(config: DBConfig): boolean {
18
23
  return config.type === DBType.SQLite;
19
24
  }
20
25
 
26
+ /**
27
+ * Checks if the DB configuration is for MySQL.
28
+ * @param config The database configuration object.
29
+ * @returns True if the configuration is for MySQL, false otherwise.
30
+ */
21
31
  function isMySQLConfig(config: DBConfig): boolean {
22
32
  return config.type === DBType.MySQL;
23
33
  }
24
34
 
35
+ /**
36
+ * Checks if the given client is a MySQL pool.
37
+ * @param client The database client.
38
+ * @returns True if the client is a MySQL pool, false otherwise.
39
+ */
25
40
  function isMySQLPool(client: any): client is mysql.Pool {
26
41
  return typeof client.getConnection === 'function';
27
42
  }
28
43
 
44
+ /**
45
+ * Provides a unified database client for interacting with PostgreSQL, MySQL, and SQLite.
46
+ */
29
47
  export class DBClient {
30
48
  private client!: Database | Pool | mysql.Pool | PoolClient | mysql.PoolConnection;
31
49
  private logger: Logger;
@@ -37,9 +55,15 @@ export class DBClient {
37
55
  private preparedStatements: Map<string, Statement> = new Map();
38
56
  public readonly isTransactionClient: boolean = false;
39
57
 
58
+ /**
59
+ * Constructs a new DBClient instance.
60
+ * @param config The database configuration object.
61
+ * @param logger Optional logger instance. Uses StabilizeLogger if not provided.
62
+ * @param existingClient Optional existing transaction client.
63
+ */
40
64
  constructor(
41
65
  config: DBConfig,
42
- logger: Logger = new ConsoleLogger(),
66
+ logger: Logger = new StabilizeLogger(),
43
67
  existingClient: PoolClient | mysql.PoolConnection | null = null,
44
68
  ) {
45
69
  this.config = config;
@@ -56,6 +80,10 @@ export class DBClient {
56
80
  }
57
81
  }
58
82
 
83
+ /**
84
+ * Initializes the database client based on the configuration.
85
+ * @param config The database configuration object.
86
+ */
59
87
  private initializeClient(config: DBConfig) {
60
88
  if (isSQLiteConfig(config)) {
61
89
  this.client = new Database(config.connectionString, { create: true });
@@ -63,14 +91,25 @@ export class DBClient {
63
91
  } else if (isMySQLConfig(config)) {
64
92
  this.client = mysql.createPool(config.connectionString);
65
93
  this.logger.logDebug(`Initialized MySQL Pool client.`);
66
- } else if (config.type = DBType.Postgres) {
94
+ } else if (config.type = DBType.Postgres) { // NOTE: single '=' should be '===', this is likely a bug
67
95
  this.client = new Pool({ connectionString: config.connectionString! });
68
96
  this.logger.logDebug(`Initialized Postgres Pool client.`);
69
97
  }
70
98
  }
71
99
 
100
+ /**
101
+ * Returns a random jitter value for retry logic.
102
+ * @returns A random number up to maxJitter.
103
+ */
72
104
  private getJitter = () => Math.random() * this.maxJitter;
73
105
 
106
+ /**
107
+ * Executes a SQL query with retries and returns the resulting rows.
108
+ * @param query The SQL query string.
109
+ * @param params Query parameters.
110
+ * @returns Array of resulting rows.
111
+ * @throws StabilizeError if all retry attempts fail.
112
+ */
74
113
  async query<T>(query: string, params: any[] = []): Promise<T[]> {
75
114
  const start = Date.now();
76
115
 
@@ -113,6 +152,13 @@ export class DBClient {
113
152
  throw new StabilizeError("Query failed: maximum retries reached without success", "QUERY_ERROR");
114
153
  }
115
154
 
155
+ /**
156
+ * Runs a callback within a database transaction.
157
+ * Handles commit/rollback and connection release.
158
+ * @param callback The callback to execute within the transaction context.
159
+ * @returns The result of the callback.
160
+ * @throws StabilizeError if transactions are not supported or rollback is triggered.
161
+ */
116
162
  async transaction<T>(callback: (txClient: DBClient) => Promise<T>): Promise<T> {
117
163
  if (this.isTransactionClient) return callback(this);
118
164
 
@@ -160,6 +206,11 @@ export class DBClient {
160
206
  throw new StabilizeError("Transaction not supported by this client configuration.", "TX_ERROR");
161
207
  }
162
208
 
209
+ /**
210
+ * Closes the database connection.
211
+ * For pooled connections, ends the pool.
212
+ * @returns Promise that resolves once the connection is closed.
213
+ */
163
214
  async close() {
164
215
  if (this.client instanceof Database) {
165
216
  this.client.close();
@@ -170,6 +221,13 @@ export class DBClient {
170
221
  this.logger.logInfo("Database connection closed");
171
222
  }
172
223
 
224
+ /**
225
+ * Executes a migration query (DDL or DML statement) without returning results.
226
+ * Handles parameterized queries and statement preparation.
227
+ * @param query The SQL query string.
228
+ * @param params Query parameters.
229
+ * @returns Promise that resolves once the query is complete.
230
+ */
173
231
  async migrationQuery(query: string, params: any[] = []): Promise<void> {
174
232
  const start = Date.now();
175
233
  this.logger.logQuery(query, params);
@@ -183,7 +241,7 @@ export class DBClient {
183
241
  stmt.run(...params);
184
242
  } else if (isMySQLPool(this.client) || ('query' in this.client && 'release' in this.client && !(this.client instanceof Pool))) {
185
243
  await (this.client as mysql.Pool).query(query, params);
186
- } else if (this.config.type = DBType.Postgres) {
244
+ } else if (this.config.type = DBType.Postgres) { // NOTE: single '=' should be '===', this is likely a bug
187
245
  let paramIndex = 0;
188
246
  const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
189
247
  await (this.client as Pool).query(pgQuery, params);
@@ -192,4 +250,4 @@ export class DBClient {
192
250
  const executionTime = Date.now() - start;
193
251
  this.logger.logQuery(query, params, executionTime);
194
252
  }
195
- }
253
+ }
package/logger.ts CHANGED
@@ -27,7 +27,7 @@ export interface Logger {
27
27
  /**
28
28
  * A logger implementation that writes to the console and can optionally write to rotating files.
29
29
  */
30
- export class ConsoleLogger implements Logger {
30
+ export class StabilizeLogger implements Logger {
31
31
  private readonly level: LogLevel;
32
32
  private readonly filePath: string | null;
33
33
  private readonly maxFileSize: number;
package/model.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { QueryBuilder } from './query-builder';
8
- import { DataTypes, RelationType, DBType } from './types';
8
+ import { DataTypes, RelationType } from './types';
9
9
 
10
10
  // Interface for column configuration
11
11
  export interface ColumnConfig {
@@ -31,6 +31,11 @@ export interface RelationConfig {
31
31
  joinTable?: string;
32
32
  }
33
33
 
34
+ export interface TimestampsConfig {
35
+ createdAt?: string;
36
+ updatedAt?: string;
37
+ }
38
+
34
39
  // Interface for model configuration
35
40
  export interface ModelConfig {
36
41
  tableName: string;
@@ -39,8 +44,10 @@ export interface ModelConfig {
39
44
  columns: Record<string, ColumnConfig>;
40
45
  relations?: RelationConfig[];
41
46
  scopes?: Record<string, (qb: QueryBuilder<any>, ...args: any[]) => QueryBuilder<any>>; // Custom query scopes
47
+ timestamps?: TimestampsConfig; // Auto-managed timestamp columns
42
48
  }
43
49
 
50
+
44
51
  /**
45
52
  * Metadata storage for models.
46
53
  * Stores and retrieves model configuration such as columns, relations, scopes, etc.
@@ -145,6 +152,10 @@ export class MetadataStorage {
145
152
  static getScopes(model: Function): Record<string, (qb: QueryBuilder<any>, ...args: any[]) => QueryBuilder<any>> {
146
153
  return this.getModelMetadata(model)?.scopes || {};
147
154
  }
155
+
156
+ static getTimestamps(model: Function): TimestampsConfig {
157
+ return this.getModelMetadata(model)?.timestamps || {};
158
+ }
148
159
  }
149
160
 
150
161
  /**
@@ -171,6 +182,7 @@ export function defineModel(config: ModelConfig) {
171
182
  columns: config.columns,
172
183
  relations: config.relations || [],
173
184
  scopes: config.scopes || {},
185
+ timestamps: config.timestamps || {},
174
186
  });
175
187
 
176
188
  return Model;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stabilize-orm",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "A lightweight, type-safe ORM for Bun.js with support for SQLite, MySQL, PostgreSQL, and Redis caching",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/repository.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { Cache } from "./cache";
8
8
  import { DBClient } from "./client";
9
- import { ConsoleLogger, type Logger } from "./logger";
9
+ import { StabilizeLogger, type Logger } from "./logger";
10
10
  import { QueryBuilder } from "./query-builder";
11
11
  import {
12
12
  DataTypes,
@@ -58,7 +58,7 @@ export class Repository<T> {
58
58
  client: DBClient,
59
59
  model: new (...args: any[]) => T,
60
60
  cacheConfig: CacheConfig = { enabled: false, ttl: 60 },
61
- logger: Logger = new ConsoleLogger(),
61
+ logger: Logger = new StabilizeLogger(),
62
62
  ) {
63
63
  this.client = client;
64
64
  this.cache = cacheConfig.enabled ? new Cache(cacheConfig, logger) : null;
@@ -148,16 +148,16 @@ export class Repository<T> {
148
148
  return qb;
149
149
  }
150
150
 
151
- /**
152
- * Applies a custom scope to the query for the repository's table.
153
- * @param name The name of the scope to apply.
154
- * @param args Optional arguments to pass to the scope function.
155
- * @returns A `QueryBuilder` instance with the scope applied.
156
- * @example
157
- * ```
158
- * const activeUsers = await userRepository.scope('active').execute(client);
159
- * ```
160
- */
151
+ /**
152
+ * Applies a custom scope to the query for the repository's table.
153
+ * @param name The name of the scope to apply.
154
+ * @param args Optional arguments to pass to the scope function.
155
+ * @returns A `QueryBuilder` instance with the scope applied.
156
+ * @example
157
+ * ```
158
+ * const activeUsers = await userRepository.scope('active').execute(client);
159
+ * ```
160
+ */
161
161
  scope(name: string, ...args: any[]): QueryBuilder<T> {
162
162
  this.logger.logDebug(`Applying scope ${name} to ${this.table}`);
163
163
  return this.find().scope(name, ...args);
@@ -357,10 +357,19 @@ export class Repository<T> {
357
357
  );
358
358
  this.validate(entity);
359
359
 
360
- const keys = Object.keys(entity).filter((k) => this.columns[k]);
360
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
361
+ const entityWithTimestamps = { ...entity } as Record<string, any>;
362
+ if (timestamps.createdAt && !entityWithTimestamps[timestamps.createdAt]) {
363
+ entityWithTimestamps[timestamps.createdAt] = new Date();
364
+ }
365
+ if (timestamps.updatedAt && !entityWithTimestamps[timestamps.updatedAt]) {
366
+ entityWithTimestamps[timestamps.updatedAt] = new Date();
367
+ }
368
+
369
+ const keys = Object.keys(entityWithTimestamps).filter((k) => this.columns[k]);
361
370
  const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
362
371
  const placeholders = keys.map(() => "?").join(", ");
363
- const params = keys.map((k) => (entity as any)[k]);
372
+ const params = keys.map((k) => (entityWithTimestamps as any)[k]);
364
373
  let query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders})`;
365
374
 
366
375
  let insertedResult: T[] | undefined;
@@ -398,7 +407,6 @@ export class Repository<T> {
398
407
  );
399
408
  return result;
400
409
  }
401
-
402
410
  /**
403
411
  * Creates multiple records in the database in batches.
404
412
  * @param entities An array of entities to create.
@@ -459,11 +467,18 @@ export class Repository<T> {
459
467
  const batchSize = options.batchSize || 1000;
460
468
  entities.forEach((entity) => this.validate(entity));
461
469
 
470
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
471
+ const entitiesWithTimestamps = entities.map(entity => ({
472
+ ...entity,
473
+ ...(timestamps.createdAt && !(entity as Record<string, any>)[timestamps.createdAt] ? { [timestamps.createdAt]: new Date() } : {}),
474
+ ...(timestamps.updatedAt && !(entity as Record<string, any>)[timestamps.updatedAt] ? { [timestamps.updatedAt]: new Date() } : {}),
475
+ })) as Partial<T>[];
476
+
462
477
  const dbType = this.getDBType(client);
463
478
  const results: T[] = [];
464
479
 
465
- for (let i = 0; i < entities.length; i += batchSize) {
466
- const batch = entities.slice(i, i + batchSize);
480
+ for (let i = 0; i < entitiesWithTimestamps.length; i += batchSize) {
481
+ const batch = entitiesWithTimestamps.slice(i, i + batchSize);
467
482
  const keys = Object.keys(batch[0]!).filter((k) => this.columns[k]);
468
483
  const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
469
484
 
@@ -498,7 +513,6 @@ export class Repository<T> {
498
513
  finalResults = await queryBuilder.execute(client);
499
514
  }
500
515
  results.push(...finalResults);
501
-
502
516
  } else {
503
517
  const placeholders = `(${keys.map(() => "?").join(", ")})`;
504
518
  query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${batch.map(() => placeholders).join(", ")}`;
@@ -582,7 +596,13 @@ export class Repository<T> {
582
596
  this.logger.logDebug(`Updating ${this.table} with ID ${id}`);
583
597
  this.validate(entity);
584
598
 
585
- const keys = Object.keys(entity).filter((k) => this.columns[k]);
599
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
600
+ const entityWithTimestamps = { ...entity } as Record<string, any>;;
601
+ if (timestamps.updatedAt && !entityWithTimestamps[timestamps.updatedAt]) {
602
+ entityWithTimestamps[timestamps.updatedAt] = new Date();
603
+ }
604
+
605
+ const keys = Object.keys(entityWithTimestamps).filter((k) => this.columns[k]);
586
606
  const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
587
607
  const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
588
608
  const params = [...keys.map((k) => (entity as any)[k]), id];
@@ -645,6 +665,8 @@ export class Repository<T> {
645
665
  const batchSize = options.batchSize || 1000;
646
666
  updates.forEach((update) => this.validate(update.set));
647
667
 
668
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
669
+
648
670
  for (let i = 0; i < updates.length; i += batchSize) {
649
671
  const batch = updates.slice(i, i + batchSize);
650
672
  for (const update of batch) {
@@ -662,11 +684,16 @@ export class Repository<T> {
662
684
  await this.runHooks(instance, "beforeUpdate");
663
685
  await this.runHooks(instance, "beforeSave");
664
686
 
665
- const keys = Object.keys(update.set).filter((k) => this.columns[k]);
687
+ const updateWithTimestamps = {
688
+ ...update.set,
689
+ ...(timestamps.updatedAt && !(update.set as Record<string, any>)[timestamps.updatedAt] ? { [timestamps.updatedAt]: new Date() } : {}),
690
+ } as Partial<T>;
691
+
692
+ const keys = Object.keys(updateWithTimestamps).filter((k) => this.columns[k]);
666
693
  const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
667
694
  const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
668
695
  const params = [
669
- ...keys.map((k) => (update.set as any)[k]),
696
+ ...keys.map((k) => (updateWithTimestamps as any)[k]),
670
697
  id,
671
698
  ];
672
699
  await client.query(query, params);