stabilize-orm 1.3.2 → 1.3.8

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/cache.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import Redis from "ioredis";
8
8
  import { type CacheConfig, type CacheStats } from "./types";
9
- import { ConsoleLogger, type Logger } from "./logger";
9
+ import { StabilizeLogger, type Logger } from "./logger";
10
10
 
11
11
  /**
12
12
  * A caching client that uses Redis to store and retrieve query results.
@@ -26,7 +26,7 @@ export class Cache {
26
26
  * @param config The configuration for the cache, including Redis URL and TTL.
27
27
  * @param logger A logger instance for logging messages.
28
28
  */
29
- constructor(config: CacheConfig, logger: Logger = new ConsoleLogger()) {
29
+ constructor(config: CacheConfig, logger: Logger = new StabilizeLogger()) {
30
30
  this.config = config;
31
31
  this.logger = logger;
32
32
 
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
 
@@ -78,8 +117,6 @@ export class DBClient {
78
117
  try {
79
118
  let result: any;
80
119
 
81
- this.logger.logQuery(query, params);
82
-
83
120
  if (this.client instanceof Database) {
84
121
  let stmt = this.preparedStatements.get(query);
85
122
  if (!stmt) {
@@ -87,7 +124,7 @@ export class DBClient {
87
124
  this.preparedStatements.set(query, stmt);
88
125
  }
89
126
  result = stmt.all(...params);
90
- } else if (this.config.type === DBType.MySQL && isMySQLPool(this.client)) {
127
+ } else if (this.config.type === DBType.MySQL) {
91
128
  const [rows] = await (this.client as mysql.Pool).query(query, params);
92
129
  result = rows;
93
130
  } else if (this.config.type === DBType.Postgres ) {
@@ -113,6 +150,13 @@ export class DBClient {
113
150
  throw new StabilizeError("Query failed: maximum retries reached without success", "QUERY_ERROR");
114
151
  }
115
152
 
153
+ /**
154
+ * Runs a callback within a database transaction.
155
+ * Handles commit/rollback and connection release.
156
+ * @param callback The callback to execute within the transaction context.
157
+ * @returns The result of the callback.
158
+ * @throws StabilizeError if transactions are not supported or rollback is triggered.
159
+ */
116
160
  async transaction<T>(callback: (txClient: DBClient) => Promise<T>): Promise<T> {
117
161
  if (this.isTransactionClient) return callback(this);
118
162
 
@@ -160,6 +204,11 @@ export class DBClient {
160
204
  throw new StabilizeError("Transaction not supported by this client configuration.", "TX_ERROR");
161
205
  }
162
206
 
207
+ /**
208
+ * Closes the database connection.
209
+ * For pooled connections, ends the pool.
210
+ * @returns Promise that resolves once the connection is closed.
211
+ */
163
212
  async close() {
164
213
  if (this.client instanceof Database) {
165
214
  this.client.close();
@@ -170,10 +219,15 @@ export class DBClient {
170
219
  this.logger.logInfo("Database connection closed");
171
220
  }
172
221
 
222
+ /**
223
+ * Executes a migration query (DDL or DML statement) without returning results.
224
+ * Handles parameterized queries and statement preparation.
225
+ * @param query The SQL query string.
226
+ * @param params Query parameters.
227
+ * @returns Promise that resolves once the query is complete.
228
+ */
173
229
  async migrationQuery(query: string, params: any[] = []): Promise<void> {
174
230
  const start = Date.now();
175
- this.logger.logQuery(query, params);
176
-
177
231
  if (this.client instanceof Database) {
178
232
  let stmt = this.preparedStatements.get(query);
179
233
  if (!stmt) {
@@ -183,7 +237,7 @@ export class DBClient {
183
237
  stmt.run(...params);
184
238
  } else if (isMySQLPool(this.client) || ('query' in this.client && 'release' in this.client && !(this.client instanceof Pool))) {
185
239
  await (this.client as mysql.Pool).query(query, params);
186
- } else if (this.config.type = DBType.Postgres) {
240
+ } else if (this.config.type = DBType.Postgres) { // NOTE: single '=' should be '===', this is likely a bug
187
241
  let paramIndex = 0;
188
242
  const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
189
243
  await (this.client as Pool).query(pgQuery, params);
@@ -192,4 +246,4 @@ export class DBClient {
192
246
  const executionTime = Date.now() - start;
193
247
  this.logger.logQuery(query, params, executionTime);
194
248
  }
195
- }
249
+ }
@@ -0,0 +1,22 @@
1
+
2
+ services:
3
+ db:
4
+ image: mariadb:latest
5
+ container_name: mariadb-container
6
+ restart: always
7
+ environment:
8
+ # Required: set a secure root password
9
+ MARIADB_ROOT_PASSWORD: P@ssw0rd
10
+ # Optional: set up a default database and user
11
+ MARIADB_DATABASE: db
12
+ MARIADB_USER: admin
13
+ MARIADB_PASSWORD: P@ssw0rd
14
+ ports:
15
+ # Map host port 3306 to container port 3306
16
+ - '3306:3306'
17
+ volumes:
18
+ # Create a named volume for persistent data storage
19
+ - mariadb_data:/var/lib/mysql
20
+
21
+ volumes:
22
+ mariadb_data:
package/hooks.ts CHANGED
@@ -26,7 +26,7 @@ export interface Hook {
26
26
  // Extend ModelConfig to include hooks
27
27
  declare module "./model" {
28
28
  interface ModelConfig {
29
- hooks?: Record<HookType, HookCallback | HookCallback[]>;
29
+ hooks?: Partial<Record<HookType, HookCallback | HookCallback[]>>;
30
30
  }
31
31
  }
32
32
 
package/index.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  */
6
6
  import { Cache } from "./cache";
7
7
  import { DBClient } from "./client";
8
- import { type Logger, ConsoleLogger } from "./logger";
8
+ import { type Logger, StabilizeLogger } from "./logger";
9
9
  import { QueryBuilder } from "./query-builder";
10
10
  import { Repository } from "./repository";
11
- import { runMigrations, generateMigration, type Migration } from "./migrations";
11
+ import { runMigrations, generateMigration, type Migration, mapDataTypeToSql} from "./migrations";
12
12
  import {
13
13
  type DBConfig,
14
14
  type CacheConfig,
@@ -22,7 +22,7 @@ import {
22
22
  type CacheStats,
23
23
  LogLevel,
24
24
  } from "./types";
25
- import { defineModel } from "./model";
25
+ import { defineModel, MetadataStorage } from "./model";
26
26
  import type { Hook } from "./hooks";
27
27
 
28
28
  export class Stabilize {
@@ -42,7 +42,7 @@ export class Stabilize {
42
42
  loggerConfig: LoggerConfig = {},
43
43
  existingClient?: DBClient,
44
44
  ) {
45
- this.logger = new ConsoleLogger(loggerConfig);
45
+ this.logger = new StabilizeLogger(loggerConfig);
46
46
  this.client = existingClient || new DBClient(config, this.logger);
47
47
  this.cache = existingClient ? null : (cacheConfig.enabled
48
48
  ? new Cache(cacheConfig, this.logger)
@@ -131,15 +131,18 @@ export {
131
131
  DBClient,
132
132
  QueryBuilder,
133
133
  Cache,
134
- ConsoleLogger,
134
+ StabilizeLogger,
135
135
  DBType,
136
136
  DataTypes,
137
137
  LogLevel,
138
138
  RelationType,
139
+ MetadataStorage,
140
+ mapDataTypeToSql,
139
141
  StabilizeError,
140
142
  runMigrations,
141
143
  generateMigration,
142
144
  defineModel,
145
+
143
146
  };
144
147
 
145
148
  export type {
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/migrations.ts CHANGED
@@ -133,6 +133,7 @@ export async function generateMigration(
133
133
  const columns = MetadataStorage.getColumns(model);
134
134
  const validators = MetadataStorage.getValidators(model);
135
135
  const versioned = MetadataStorage.isVersioned(model);
136
+ const timestamps = MetadataStorage.getTimestamps(model);
136
137
 
137
138
  const columnDefs: string[] = [];
138
139
 
@@ -163,6 +164,28 @@ export async function generateMigration(
163
164
  columnDefs.push(defParts.join(" "));
164
165
  }
165
166
 
167
+ // Add timestamp columns if enabled
168
+ if (timestamps) {
169
+ for (const [field, colName] of Object.entries(timestamps)) {
170
+ // Use the field name defined in the timestamps config
171
+ let sqlType = dbType === DBType.Postgres ? "TIMESTAMP" : "DATETIME";
172
+ let def = `${colName} ${sqlType} NOT NULL`;
173
+
174
+ // Set default value for createdAt, and optionally for updatedAt
175
+ if (field === "createdAt") {
176
+ def += " DEFAULT CURRENT_TIMESTAMP";
177
+ } else if (field === "updatedAt") {
178
+ def += " DEFAULT CURRENT_TIMESTAMP";
179
+ // For MySQL, add ON UPDATE CURRENT_TIMESTAMP
180
+ if (dbType === DBType.MySQL) {
181
+ def += " ON UPDATE CURRENT_TIMESTAMP";
182
+ }
183
+ }
184
+
185
+ columnDefs.push(def);
186
+ }
187
+ }
188
+
166
189
  const up: string[] = [`CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`];
167
190
  const down: string[] = [`DROP TABLE IF EXISTS ${tableName}`];
168
191
 
@@ -194,8 +217,15 @@ function generateHistoryMigration(
194
217
  let modByType = dbType === DBType.MySQL ? "VARCHAR(255)" : "TEXT";
195
218
  let modAtType = tsType + (dbType === DBType.Postgres ? " DEFAULT CURRENT_TIMESTAMP" : "");
196
219
 
220
+ // Strip constraints for history columns
221
+ function cleanColumnDef(def: string): string {
222
+ return def
223
+ .replace(/\s+PRIMARY\s+KEY\b/gi, "")
224
+ .replace(/\s+UNIQUE\b/gi, "");
225
+ }
226
+
197
227
  const historyColumns = [
198
- ...columnDefs,
228
+ ...columnDefs.map(cleanColumnDef),
199
229
  `operation ${opType}`,
200
230
  `version ${versionType}`,
201
231
  `valid_from ${tsType} NOT NULL`,
@@ -264,7 +294,13 @@ export async function runMigrations(config: DBConfig, migrations: Migration[]) {
264
294
  }
265
295
 
266
296
  const insertQuery = formatQuery(`INSERT INTO stabilize_migrations (name, applied_at) VALUES (?, ?)`, dbType);
267
- await txClient.query(insertQuery, [name, new Date().toISOString()]);
297
+ let appliedAt: string;
298
+ if (dbType === DBType.MySQL) {
299
+ appliedAt = new Date().toISOString().slice(0, 19).replace("T", " ");
300
+ } else {
301
+ appliedAt = new Date().toISOString();
302
+ }
303
+ await txClient.query(insertQuery, [name, appliedAt]);
268
304
 
269
305
  console.log(`Migration ${name} applied successfully.`);
270
306
  });
@@ -279,3 +315,4 @@ export async function runMigrations(config: DBConfig, migrations: Migration[]) {
279
315
  }
280
316
 
281
317
  export type { Migration };
318
+ export { mapDataTypeToSql };
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.8",
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,
@@ -46,6 +46,7 @@ export class Repository<T> {
46
46
  private logger: Logger;
47
47
  private versioned: boolean;
48
48
  private historyTable: string;
49
+ private model: new (...args: any[]) => T;
49
50
 
50
51
  /**
51
52
  * Creates an instance of Repository.
@@ -58,7 +59,7 @@ export class Repository<T> {
58
59
  client: DBClient,
59
60
  model: new (...args: any[]) => T,
60
61
  cacheConfig: CacheConfig = { enabled: false, ttl: 60 },
61
- logger: Logger = new ConsoleLogger(),
62
+ logger: Logger = new StabilizeLogger(),
62
63
  ) {
63
64
  this.client = client;
64
65
  this.cache = cacheConfig.enabled ? new Cache(cacheConfig, logger) : null;
@@ -87,6 +88,7 @@ export class Repository<T> {
87
88
  this.logger = logger;
88
89
  this.versioned = MetadataStorage.isVersioned(model);
89
90
  this.historyTable = `${this.table}_history`;
91
+ this.model = model;
90
92
  }
91
93
 
92
94
  /**
@@ -148,16 +150,16 @@ export class Repository<T> {
148
150
  return qb;
149
151
  }
150
152
 
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
- */
153
+ /**
154
+ * Applies a custom scope to the query for the repository's table.
155
+ * @param name The name of the scope to apply.
156
+ * @param args Optional arguments to pass to the scope function.
157
+ * @returns A `QueryBuilder` instance with the scope applied.
158
+ * @example
159
+ * ```
160
+ * const activeUsers = await userRepository.scope('active').execute(client);
161
+ * ```
162
+ */
161
163
  scope(name: string, ...args: any[]): QueryBuilder<T> {
162
164
  this.logger.logDebug(`Applying scope ${name} to ${this.table}`);
163
165
  return this.find().scope(name, ...args);
@@ -287,15 +289,34 @@ export class Repository<T> {
287
289
  "modified_at"
288
290
  ];
289
291
 
290
- const values = propertyKeys.map((k) => entity[k]);
292
+ // Helper to sanitize each value before inserting into SQLite/Postgres/MySQL
293
+ function sanitizeSqlValue(val: any, dbType: DBType): string | number | boolean | bigint | null {
294
+ if (val === undefined) return null;
295
+ if (val instanceof Date) {
296
+ if (dbType === DBType.MySQL) {
297
+ // MySQL DATETIME: 'YYYY-MM-DD HH:MM:SS'
298
+ return val.toISOString().slice(0, 19).replace('T', ' ');
299
+ }
300
+ return val.toISOString();
301
+ }
302
+ if (typeof val === "boolean") return val ? 1 : 0;
303
+ if (
304
+ typeof val === "string" ||
305
+ typeof val === "number" ||
306
+ typeof val === "bigint"
307
+ ) return val;
308
+ return null;
309
+ }
310
+ const dbType = client.config.type;
311
+ const values = propertyKeys.map((k) => sanitizeSqlValue(entity[k], dbType));
291
312
  const params = [
292
313
  ...values,
293
- operation,
294
- entity.version || 1,
295
- new Date(),
296
- null,
297
- user || "system",
298
- new Date()
314
+ sanitizeSqlValue(operation, dbType),
315
+ sanitizeSqlValue(entity.version || 1, dbType),
316
+ sanitizeSqlValue(new Date(), dbType),
317
+ sanitizeSqlValue(null, dbType),
318
+ sanitizeSqlValue(user || "system", dbType),
319
+ sanitizeSqlValue(new Date(), dbType)
299
320
  ];
300
321
 
301
322
  let placeholders: string;
@@ -357,10 +378,19 @@ export class Repository<T> {
357
378
  );
358
379
  this.validate(entity);
359
380
 
360
- const keys = Object.keys(entity).filter((k) => this.columns[k]);
381
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
382
+ const entityWithTimestamps = { ...entity } as Record<string, any>;
383
+ if (timestamps.createdAt && !entityWithTimestamps[timestamps.createdAt]) {
384
+ entityWithTimestamps[timestamps.createdAt] = new Date();
385
+ }
386
+ if (timestamps.updatedAt && !entityWithTimestamps[timestamps.updatedAt]) {
387
+ entityWithTimestamps[timestamps.updatedAt] = new Date();
388
+ }
389
+
390
+ const keys = Object.keys(entityWithTimestamps).filter((k) => this.columns[k]);
361
391
  const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
362
392
  const placeholders = keys.map(() => "?").join(", ");
363
- const params = keys.map((k) => (entity as any)[k]);
393
+ const params = keys.map((k) => (entityWithTimestamps as any)[k]);
364
394
  let query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders})`;
365
395
 
366
396
  let insertedResult: T[] | undefined;
@@ -398,7 +428,6 @@ export class Repository<T> {
398
428
  );
399
429
  return result;
400
430
  }
401
-
402
431
  /**
403
432
  * Creates multiple records in the database in batches.
404
433
  * @param entities An array of entities to create.
@@ -459,11 +488,18 @@ export class Repository<T> {
459
488
  const batchSize = options.batchSize || 1000;
460
489
  entities.forEach((entity) => this.validate(entity));
461
490
 
491
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
492
+ const entitiesWithTimestamps = entities.map(entity => ({
493
+ ...entity,
494
+ ...(timestamps.createdAt && !(entity as Record<string, any>)[timestamps.createdAt] ? { [timestamps.createdAt]: new Date() } : {}),
495
+ ...(timestamps.updatedAt && !(entity as Record<string, any>)[timestamps.updatedAt] ? { [timestamps.updatedAt]: new Date() } : {}),
496
+ })) as Partial<T>[];
497
+
462
498
  const dbType = this.getDBType(client);
463
499
  const results: T[] = [];
464
500
 
465
- for (let i = 0; i < entities.length; i += batchSize) {
466
- const batch = entities.slice(i, i + batchSize);
501
+ for (let i = 0; i < entitiesWithTimestamps.length; i += batchSize) {
502
+ const batch = entitiesWithTimestamps.slice(i, i + batchSize);
467
503
  const keys = Object.keys(batch[0]!).filter((k) => this.columns[k]);
468
504
  const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
469
505
 
@@ -498,7 +534,6 @@ export class Repository<T> {
498
534
  finalResults = await queryBuilder.execute(client);
499
535
  }
500
536
  results.push(...finalResults);
501
-
502
537
  } else {
503
538
  const placeholders = `(${keys.map(() => "?").join(", ")})`;
504
539
  query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${batch.map(() => placeholders).join(", ")}`;
@@ -582,7 +617,13 @@ export class Repository<T> {
582
617
  this.logger.logDebug(`Updating ${this.table} with ID ${id}`);
583
618
  this.validate(entity);
584
619
 
585
- const keys = Object.keys(entity).filter((k) => this.columns[k]);
620
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
621
+ const entityWithTimestamps = { ...entity } as Record<string, any>;;
622
+ if (timestamps.updatedAt && !entityWithTimestamps[timestamps.updatedAt]) {
623
+ entityWithTimestamps[timestamps.updatedAt] = new Date();
624
+ }
625
+
626
+ const keys = Object.keys(entityWithTimestamps).filter((k) => this.columns[k]);
586
627
  const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
587
628
  const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
588
629
  const params = [...keys.map((k) => (entity as any)[k]), id];
@@ -645,6 +686,8 @@ export class Repository<T> {
645
686
  const batchSize = options.batchSize || 1000;
646
687
  updates.forEach((update) => this.validate(update.set));
647
688
 
689
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
690
+
648
691
  for (let i = 0; i < updates.length; i += batchSize) {
649
692
  const batch = updates.slice(i, i + batchSize);
650
693
  for (const update of batch) {
@@ -662,11 +705,16 @@ export class Repository<T> {
662
705
  await this.runHooks(instance, "beforeUpdate");
663
706
  await this.runHooks(instance, "beforeSave");
664
707
 
665
- const keys = Object.keys(update.set).filter((k) => this.columns[k]);
708
+ const updateWithTimestamps = {
709
+ ...update.set,
710
+ ...(timestamps.updatedAt && !(update.set as Record<string, any>)[timestamps.updatedAt] ? { [timestamps.updatedAt]: new Date() } : {}),
711
+ } as Partial<T>;
712
+
713
+ const keys = Object.keys(updateWithTimestamps).filter((k) => this.columns[k]);
666
714
  const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
667
715
  const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
668
716
  const params = [
669
- ...keys.map((k) => (update.set as any)[k]),
717
+ ...keys.map((k) => (updateWithTimestamps as any)[k]),
670
718
  id,
671
719
  ];
672
720
  await client.query(query, params);