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 +24 -0
- package/README.md +46 -2
- package/cache.ts +2 -2
- package/client.ts +64 -10
- package/docker-compose.yml +22 -0
- package/hooks.ts +1 -1
- package/index.ts +8 -5
- package/logger.ts +1 -1
- package/migrations.ts +39 -2
- package/model.ts +13 -1
- package/package.json +1 -1
- package/repository.ts +76 -28
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 `
|
|
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
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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?:
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
package/repository.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { Cache } from "./cache";
|
|
8
8
|
import { DBClient } from "./client";
|
|
9
|
-
import {
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
|
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) => (
|
|
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 <
|
|
466
|
-
const batch =
|
|
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
|
|
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
|
|
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) => (
|
|
717
|
+
...keys.map((k) => (updateWithTimestamps as any)[k]),
|
|
670
718
|
id,
|
|
671
719
|
];
|
|
672
720
|
await client.query(query, params);
|