stabilize-orm 1.1.8 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -76
- package/bun.lock +0 -3
- package/decorators.ts +19 -14
- package/hooks.ts +33 -0
- package/index.ts +5 -1
- package/migrations.ts +47 -42
- package/package.json +1 -1
- package/repository.ts +323 -30
package/README.md
CHANGED
|
@@ -4,24 +4,27 @@ _A Modern, Type-Safe, and Expressive ORM for Bun, Node.js, and Deno_
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
**Stabilize** is a lightweight, feature-rich ORM designed for performance and developer experience. It provides a unified, database-agnostic API for **PostgreSQL**, **MySQL**, and **SQLite
|
|
7
|
+
**Stabilize** is a lightweight, feature-rich ORM designed for performance and developer experience. It provides a unified, database-agnostic API for **PostgreSQL**, **MySQL**, and **SQLite**. Powered by a robust query builder, elegant decorator-based models, automatic versioning, and a full-featured command-line interface, Stabilize is built to scale with your app.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
## 🚀 Features
|
|
12
12
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
13
|
+
- **Unified API**: Write once, run on PostgreSQL, MySQL, or SQLite.
|
|
14
|
+
- **Type-Safe Decorators**: Define models and columns with the powerful `DataTypes` enum for true database-agnostic schemas.
|
|
15
|
+
- **Full-Featured CLI**: Generate models, manage migrations, seed data, and reset your database from the command line.
|
|
16
|
+
- **Automatic Migrations**: Generate database-specific SQL schemas directly from your model definitions.
|
|
17
|
+
- **Versioned Models & Time-Travel**: Add `@Versioned()` to your models for automatic history tables and snapshot queries.
|
|
18
|
+
- **Retry Logic**: Automatic exponential backoff for database queries to handle transient connection issues.
|
|
19
|
+
- **Connection Pooling**: Efficient connection management for PostgreSQL and MySQL.
|
|
20
|
+
- **Transactional Integrity**: Built-in support for atomic transactions with automatic rollback on failure.
|
|
21
|
+
- **Advanced Query Builder**: Fluent, chainable API for building complex queries, including joins, filters, ordering, and pagination.
|
|
22
|
+
- **Model Relationships**: Use `OneToOne`, `ManyToOne`, `OneToMany`, and `ManyToMany` decorators for relationships.
|
|
23
|
+
- **Soft Deletes**: Add `@SoftDelete()` to your model for transparent "deleted" flags and safe row removal.
|
|
24
|
+
- **Lifecycle Hooks**: Use the `@Hook()` decorator for model lifecycle events like `beforeCreate`, `afterUpdate`, etc.
|
|
25
|
+
- **Pluggable Logging**: Includes a robust `ConsoleLogger` with support for file-based, rotating logs.
|
|
26
|
+
- **Custom Errors**: `StabilizeError` provides clear, consistent error handling.
|
|
27
|
+
- **Caching Layer**: Optional Redis-backed caching with `cache-aside` and `write-through` strategies.
|
|
25
28
|
|
|
26
29
|
---
|
|
27
30
|
|
|
@@ -41,13 +44,13 @@ npm install stabilize-orm reflect-metadata
|
|
|
41
44
|
|
|
42
45
|
## 📃 Documentation & Community
|
|
43
46
|
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
47
|
+
- [Changelog](./CHANGELOG.md)
|
|
48
|
+
- [License](./LICENSE.md)
|
|
49
|
+
- [Code of Conduct](./CODE_OF_CONDUCT.md)
|
|
50
|
+
- [Contributing Guide](./CONTRIBUTING.md)
|
|
51
|
+
- [Security Policy](./SECURITY.md)
|
|
52
|
+
- [Support](./SUPPORT.md)
|
|
53
|
+
- [Funding](./FUNDING.md)
|
|
51
54
|
|
|
52
55
|
---
|
|
53
56
|
|
|
@@ -60,13 +63,8 @@ First, create a database configuration file.
|
|
|
60
63
|
import { DBType, type DBConfig } from "stabilize-orm";
|
|
61
64
|
|
|
62
65
|
const dbConfig: DBConfig = {
|
|
63
|
-
// Choose your database type
|
|
64
66
|
type: DBType.Postgres,
|
|
65
|
-
|
|
66
|
-
// Connection string for your database
|
|
67
67
|
connectionString: process.env.DATABASE_URL || "postgres://user:password@localhost:5432/mydb",
|
|
68
|
-
|
|
69
|
-
// Optional: Connection retry settings
|
|
70
68
|
retryAttempts: 3,
|
|
71
69
|
retryDelay: 1000,
|
|
72
70
|
};
|
|
@@ -74,29 +72,27 @@ const dbConfig: DBConfig = {
|
|
|
74
72
|
export default dbConfig;
|
|
75
73
|
```
|
|
76
74
|
|
|
77
|
-
Next, create a central ORM instance that your application can use.
|
|
75
|
+
Next, create a central ORM instance that your application can use. Import `reflect-metadata` once at your application's entry point.
|
|
78
76
|
|
|
79
77
|
```typescript
|
|
80
78
|
// db.ts
|
|
81
|
-
import 'reflect-metadata';
|
|
79
|
+
import 'reflect-metadata';
|
|
82
80
|
import { Stabilize, type CacheConfig, type LoggerConfig, LogLevel } from "stabilize-orm";
|
|
83
81
|
import dbConfig from "./database";
|
|
84
82
|
|
|
85
83
|
const cacheConfig: CacheConfig = {
|
|
86
84
|
enabled: process.env.CACHE_ENABLED === "true",
|
|
87
85
|
redisUrl: process.env.REDIS_URL,
|
|
88
|
-
ttl: 60,
|
|
86
|
+
ttl: 60,
|
|
89
87
|
};
|
|
90
88
|
|
|
91
89
|
const loggerConfig: LoggerConfig = {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
90
|
+
level: LogLevel.Info,
|
|
91
|
+
filePath: 'logs/stabilize.log',
|
|
92
|
+
maxFileSize: 5 * 1024 * 1024, // 5MB
|
|
93
|
+
maxFiles: 3,
|
|
94
|
+
};
|
|
98
95
|
|
|
99
|
-
// Create and export the ORM instance
|
|
100
96
|
export const orm = new Stabilize(dbConfig, cacheConfig, loggerConfig);
|
|
101
97
|
```
|
|
102
98
|
|
|
@@ -104,19 +100,18 @@ export const orm = new Stabilize(dbConfig, cacheConfig, loggerConfig);
|
|
|
104
100
|
|
|
105
101
|
## 🏗️ Models & Relationships
|
|
106
102
|
|
|
107
|
-
Define your
|
|
108
|
-
|
|
109
|
-
### Example: Users and Roles (Many-to-Many)
|
|
103
|
+
Define your tables as classes using decorators. The `@Column` decorator uses the `DataTypes` enum for a truly database-agnostic schema.
|
|
110
104
|
|
|
111
|
-
|
|
105
|
+
### Example: Users and Roles (Many-to-Many) with Versioning
|
|
112
106
|
|
|
113
107
|
```typescript
|
|
114
108
|
// models/User.ts
|
|
115
109
|
import 'reflect-metadata';
|
|
116
|
-
import { Model, Column, DataTypes, Required, Unique, OneToMany } from 'stabilize-orm';
|
|
110
|
+
import { Model, Column, DataTypes, Required, Unique, OneToMany, Versioned } from 'stabilize-orm';
|
|
117
111
|
import { UserRole } from './UserRole';
|
|
118
112
|
|
|
119
113
|
@Model('users')
|
|
114
|
+
@Versioned() // Enables history table for time-travel/audit
|
|
120
115
|
export class User {
|
|
121
116
|
@Column({ type: DataTypes.INTEGER, name: 'id' })
|
|
122
117
|
id!: number;
|
|
@@ -124,8 +119,7 @@ export class User {
|
|
|
124
119
|
@Column({ type: DataTypes.STRING, length: 100 })
|
|
125
120
|
@Required() @Unique()
|
|
126
121
|
email!: string;
|
|
127
|
-
|
|
128
|
-
// This side of the relationship is for querying convenience
|
|
122
|
+
|
|
129
123
|
@OneToMany(() => UserRole, 'user')
|
|
130
124
|
roles?: UserRole[];
|
|
131
125
|
}
|
|
@@ -154,7 +148,7 @@ import { Model, Column, DataTypes, Required, ManyToOne, Index } from 'stabilize-
|
|
|
154
148
|
import { User } from './User';
|
|
155
149
|
import { Role } from './Role';
|
|
156
150
|
|
|
157
|
-
@Model('user_roles')
|
|
151
|
+
@Model('user_roles')
|
|
158
152
|
export class UserRole {
|
|
159
153
|
@Column({ type: DataTypes.INTEGER, name: 'id' })
|
|
160
154
|
id!: number;
|
|
@@ -167,7 +161,6 @@ export class UserRole {
|
|
|
167
161
|
@Required() @Index()
|
|
168
162
|
roleId!: number;
|
|
169
163
|
|
|
170
|
-
// Define the "many" sides of the relationship
|
|
171
164
|
@ManyToOne(() => User, 'userId')
|
|
172
165
|
user?: User;
|
|
173
166
|
|
|
@@ -178,51 +171,133 @@ export class UserRole {
|
|
|
178
171
|
|
|
179
172
|
---
|
|
180
173
|
|
|
174
|
+
## ⏳ Versioning & Auditing
|
|
175
|
+
|
|
176
|
+
Enable automatic history tracking and time-travel queries by adding `@Versioned()` to your model.
|
|
177
|
+
|
|
178
|
+
- Each change is recorded in a `<table>_history` table with version, operation, and audit columns.
|
|
179
|
+
- Supports snapshot queries, rollbacks, audits, and time-travel.
|
|
180
|
+
|
|
181
|
+
### **Versioning Example**
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
@Model('users')
|
|
185
|
+
@Versioned()
|
|
186
|
+
export class User {
|
|
187
|
+
@Column({ type: DataTypes.INTEGER, name: 'id' })
|
|
188
|
+
id!: number;
|
|
189
|
+
|
|
190
|
+
@Column({ type: DataTypes.STRING, length: 100 })
|
|
191
|
+
name!: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- Using versioning features:
|
|
195
|
+
|
|
196
|
+
const userRepository = orm.getRepository(User);
|
|
197
|
+
|
|
198
|
+
// Rollback to a previous version
|
|
199
|
+
await userRepository.rollback(1, 3); // roll back user with id=1 to version 3
|
|
200
|
+
|
|
201
|
+
// Get a snapshot as of a specific date
|
|
202
|
+
const userAsOf = await userRepository.asOf(1, new Date('2025-01-01T00:00:00Z'));
|
|
203
|
+
console.log(userAsOf);
|
|
204
|
+
|
|
205
|
+
// View full version history
|
|
206
|
+
const history = await userRepository.history(1);
|
|
207
|
+
console.log(history);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 🔄 Model Lifecycle Hooks
|
|
213
|
+
|
|
214
|
+
Stabilize ORM supports lifecycle hooks via the `@Hook()` decorator.
|
|
215
|
+
You can run logic before/after create, update, delete, or save.
|
|
216
|
+
|
|
217
|
+
### **Hooks Example**
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { Model, Column, DataTypes, Hook } from 'stabilize-orm';
|
|
221
|
+
|
|
222
|
+
@Model('users')
|
|
223
|
+
export class User {
|
|
224
|
+
@Column({ type: DataTypes.INTEGER, name: 'id' })
|
|
225
|
+
id!: number;
|
|
226
|
+
|
|
227
|
+
@Column({ type: DataTypes.STRING, length: 100 })
|
|
228
|
+
name!: string;
|
|
229
|
+
|
|
230
|
+
@Column({ type: DataTypes.DATETIME, name: 'created_at' })
|
|
231
|
+
createdAt!: Date;
|
|
232
|
+
|
|
233
|
+
@Column({ type: DataTypes.DATETIME, name: 'updated_at' })
|
|
234
|
+
updatedAt!: Date;
|
|
235
|
+
|
|
236
|
+
@Hook('beforeCreate')
|
|
237
|
+
setCreatedAt() {
|
|
238
|
+
this.createdAt = new Date();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
@Hook('beforeUpdate')
|
|
242
|
+
setUpdatedAt() {
|
|
243
|
+
this.updatedAt = new Date();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@Hook('afterCreate')
|
|
247
|
+
logCreate() {
|
|
248
|
+
console.log(`User created: ${this.name}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
You can use `@Hook` with: `'beforeCreate'`, `'afterCreate'`, `'beforeUpdate'`, `'afterUpdate'`, `'beforeDelete'`, `'afterDelete'`, `'beforeSave'`, `'afterSave'`.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
181
257
|
## 💻 Command-Line Interface (CLI)
|
|
182
258
|
|
|
183
|
-
Stabilize includes a powerful CLI for managing your
|
|
259
|
+
Stabilize includes a powerful CLI for managing your workflow.
|
|
184
260
|
|
|
185
261
|
### Generating Files
|
|
186
262
|
|
|
187
|
-
-
|
|
263
|
+
- **Generate a model**:
|
|
188
264
|
```bash
|
|
189
265
|
bun run stabilize-cli generate model Product
|
|
190
266
|
```
|
|
191
267
|
|
|
192
|
-
-
|
|
268
|
+
- **Generate a migration from a model**:
|
|
193
269
|
```bash
|
|
194
|
-
# Reads models/User.ts and creates a new migration file
|
|
195
270
|
bun run stabilize-cli generate migration User
|
|
196
271
|
```
|
|
197
272
|
|
|
198
|
-
-
|
|
273
|
+
- **Generate a seed file**:
|
|
199
274
|
```bash
|
|
200
275
|
bun run stabilize-cli generate seed InitialRoles
|
|
201
276
|
```
|
|
202
277
|
|
|
203
278
|
### Database & Migration Management
|
|
204
279
|
|
|
205
|
-
-
|
|
280
|
+
- **Run all pending migrations**:
|
|
206
281
|
```bash
|
|
207
282
|
bun run stabilize-cli migrate
|
|
208
283
|
```
|
|
209
284
|
|
|
210
|
-
-
|
|
285
|
+
- **Roll back the last migration**:
|
|
211
286
|
```bash
|
|
212
287
|
bun run stabilize-cli migrate:rollback
|
|
213
288
|
```
|
|
214
289
|
|
|
215
|
-
-
|
|
290
|
+
- **Run all pending seeds (in dependency order)**:
|
|
216
291
|
```bash
|
|
217
292
|
bun run stabilize-cli seed
|
|
218
293
|
```
|
|
219
294
|
|
|
220
|
-
-
|
|
295
|
+
- **Check the status of migrations and seeds**:
|
|
221
296
|
```bash
|
|
222
297
|
bun run stabilize-cli status
|
|
223
298
|
```
|
|
224
299
|
|
|
225
|
-
-
|
|
300
|
+
- **Reset the database (drop, migrate, seed)**:
|
|
226
301
|
```bash
|
|
227
302
|
bun run stabilize-cli db:reset
|
|
228
303
|
```
|
|
@@ -233,34 +308,23 @@ Stabilize includes a powerful CLI for managing your development workflow.
|
|
|
233
308
|
|
|
234
309
|
### Basic CRUD with Repositories
|
|
235
310
|
|
|
236
|
-
Interact with your data using the `Repository` pattern.
|
|
237
|
-
|
|
238
311
|
```typescript
|
|
239
312
|
import { orm } from './db';
|
|
240
313
|
import { User } from 'models/User';
|
|
241
314
|
|
|
242
315
|
const userRepository = orm.getRepository(User);
|
|
243
316
|
|
|
244
|
-
// Create a new user
|
|
245
317
|
const newUser = await userRepository.create({ email: 'lwazicd@icloud.com' });
|
|
246
|
-
|
|
247
|
-
// Find a user by ID
|
|
248
318
|
const foundUser = await userRepository.findOne(newUser.id);
|
|
249
|
-
|
|
250
|
-
// Update a user
|
|
251
319
|
const updatedUser = await userRepository.update(newUser.id, { email: 'admin@offbytesecure.com' });
|
|
252
|
-
|
|
253
|
-
// Delete a user
|
|
254
320
|
await userRepository.delete(newUser.id);
|
|
255
321
|
```
|
|
256
322
|
|
|
257
323
|
### Advanced Queries with the Query Builder
|
|
258
324
|
|
|
259
|
-
For complex queries, use the fluent `find()` method, which returns a chainable `QueryBuilder`.
|
|
260
|
-
|
|
261
325
|
```typescript
|
|
262
326
|
const activeAdmins = await orm.getRepository(UserRole)
|
|
263
|
-
.find()
|
|
327
|
+
.find()
|
|
264
328
|
.join("users", "user_roles.user_id = users.id")
|
|
265
329
|
.join("roles", "user_roles.role_id = roles.id")
|
|
266
330
|
.select("users.id", "users.email", "roles.name as role_name")
|
|
@@ -269,13 +333,9 @@ const activeAdmins = await orm.getRepository(UserRole)
|
|
|
269
333
|
.execute();
|
|
270
334
|
|
|
271
335
|
console.log(activeAdmins);
|
|
272
|
-
// [ { id: 1, email: 'lwazicd@icloud.com', role_name: 'Admin' } ]
|
|
273
336
|
```
|
|
274
337
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
---
|
|
278
|
-
**API:**
|
|
338
|
+
#### Query Builder API
|
|
279
339
|
|
|
280
340
|
```typescript
|
|
281
341
|
{
|
|
@@ -286,16 +346,25 @@ The `execute()` method on a repository query does not require a client to be pas
|
|
|
286
346
|
limit(limit: number): QueryBuilder<User>;
|
|
287
347
|
offset(offset: number): QueryBuilder<User>;
|
|
288
348
|
build(): { query: string; params: any[] };
|
|
289
|
-
execute(client
|
|
349
|
+
execute(client?: DBClient, cache?: Cache, cacheKey?: string): Promise<User[]>;
|
|
290
350
|
}
|
|
291
351
|
```
|
|
292
352
|
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## 🗑️ Soft Deletes
|
|
356
|
+
|
|
357
|
+
Add `@SoftDelete()` to a model property to enable transparent soft deletes (e.g., `deleted_at` timestamp).
|
|
358
|
+
- Use `repository.softDelete(id)` to mark an entity as deleted.
|
|
359
|
+
- Use `find({ includeDeleted: true })` to include soft-deleted rows.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
293
363
|
## 🌐 Express.js Integration
|
|
294
364
|
|
|
295
365
|
Stabilize ORM works seamlessly with web frameworks like Express.
|
|
296
366
|
|
|
297
367
|
```typescript
|
|
298
|
-
// src/server.ts
|
|
299
368
|
import express from "express";
|
|
300
369
|
import { orm } from "./db";
|
|
301
370
|
import { User } from "./models/User";
|
|
@@ -305,7 +374,6 @@ app.use(express.json());
|
|
|
305
374
|
|
|
306
375
|
const userRepository = orm.getRepository(User);
|
|
307
376
|
|
|
308
|
-
// Get all users
|
|
309
377
|
app.get("/users", async (req, res) => {
|
|
310
378
|
try {
|
|
311
379
|
const users = await userRepository.find().execute();
|
|
@@ -315,7 +383,6 @@ app.get("/users", async (req, res) => {
|
|
|
315
383
|
}
|
|
316
384
|
});
|
|
317
385
|
|
|
318
|
-
// Create a new user
|
|
319
386
|
app.post("/users", async (req, res) => {
|
|
320
387
|
try {
|
|
321
388
|
const user = await userRepository.create(req.body);
|
|
@@ -332,6 +399,13 @@ app.listen(3000, () => {
|
|
|
332
399
|
|
|
333
400
|
---
|
|
334
401
|
|
|
402
|
+
## 🧑🔬 Testing & Time-Travel
|
|
403
|
+
|
|
404
|
+
- Use time-travel queries to inspect historical entity states.
|
|
405
|
+
- Assert audit trails and rollback operations in your tests.
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
335
409
|
## 📑 License
|
|
336
410
|
|
|
337
411
|
Licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details.
|
|
@@ -342,6 +416,6 @@ Licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details.
|
|
|
342
416
|
|
|
343
417
|
Created with ❤️ by **ElectronSz**
|
|
344
418
|
<br/>
|
|
345
|
-
|
|
419
|
+
<em>File last updated: 2025-10-16 19:41:00 UTC</em>
|
|
346
420
|
|
|
347
421
|
</div>
|
package/bun.lock
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
"@types/pg": "^8.15.5",
|
|
8
8
|
"@types/uuid": "^11.0.0",
|
|
9
9
|
"commander": "^12.1.0",
|
|
10
|
-
"dotenv": "^17.2.3",
|
|
11
10
|
"figlet": "^1.9.3",
|
|
12
11
|
"glob": "^11.0.0",
|
|
13
12
|
"ioredis": "^5.4.1",
|
|
@@ -307,8 +306,6 @@
|
|
|
307
306
|
|
|
308
307
|
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
|
309
308
|
|
|
310
|
-
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
|
311
|
-
|
|
312
309
|
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
|
313
310
|
|
|
314
311
|
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
package/decorators.ts
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import "reflect-metadata";
|
|
8
8
|
import { RelationType, DataTypes } from "./types";
|
|
9
9
|
|
|
10
|
-
|
|
11
10
|
export const ModelKey = Symbol("model");
|
|
12
11
|
export const ColumnKey = Symbol("column");
|
|
13
12
|
export const ValidatorKey = Symbol("validator");
|
|
@@ -15,7 +14,7 @@ export const RelationKey = Symbol("relation");
|
|
|
15
14
|
export const SoftDeleteKey = Symbol("softDelete");
|
|
16
15
|
export const DefaultKey = Symbol("default");
|
|
17
16
|
export const IndexKey = Symbol("index");
|
|
18
|
-
|
|
17
|
+
export const VersionedKey = Symbol("versioned");
|
|
19
18
|
|
|
20
19
|
export interface ColumnOptions {
|
|
21
20
|
name?: string;
|
|
@@ -42,11 +41,9 @@ export function Model(tableName: string) {
|
|
|
42
41
|
export function Column(options: ColumnOptions | DataTypes) {
|
|
43
42
|
return function (target: any, propertyKey: string) {
|
|
44
43
|
const columns = Reflect.getMetadata(ColumnKey, target) || {};
|
|
45
|
-
|
|
46
44
|
const columnOptions: ColumnOptions = typeof options === 'object' ? options : { type: options };
|
|
47
|
-
|
|
48
45
|
columns[propertyKey] = {
|
|
49
|
-
name: columnOptions.name || propertyKey,
|
|
46
|
+
name: columnOptions.name || propertyKey,
|
|
50
47
|
...columnOptions,
|
|
51
48
|
};
|
|
52
49
|
Reflect.defineMetadata(ColumnKey, columns, target);
|
|
@@ -80,9 +77,9 @@ export function Unique() {
|
|
|
80
77
|
* @param value The default value.
|
|
81
78
|
*/
|
|
82
79
|
export function Default(value: any) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
return function (target: any, propertyKey: string) {
|
|
81
|
+
Reflect.defineMetadata(DefaultKey, value, target, propertyKey);
|
|
82
|
+
};
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
/**
|
|
@@ -90,14 +87,13 @@ export function Default(value: any) {
|
|
|
90
87
|
* @param indexName Optional: A custom name for the index.
|
|
91
88
|
*/
|
|
92
89
|
export function Index(indexName?: string) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
return function (target: any, propertyKey: string) {
|
|
91
|
+
const indexes = Reflect.getMetadata(IndexKey, target) || {};
|
|
92
|
+
indexes[propertyKey] = indexName || `idx_${propertyKey}`;
|
|
93
|
+
Reflect.defineMetadata(IndexKey, indexes, target);
|
|
94
|
+
};
|
|
98
95
|
}
|
|
99
96
|
|
|
100
|
-
|
|
101
97
|
/**
|
|
102
98
|
* Decorator to enable soft-delete functionality on a model.
|
|
103
99
|
* The decorated property will store the deletion timestamp.
|
|
@@ -108,6 +104,15 @@ export function SoftDelete() {
|
|
|
108
104
|
};
|
|
109
105
|
}
|
|
110
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Decorator to enable versioning (history, snapshot & time-travel) on a model.
|
|
109
|
+
*/
|
|
110
|
+
export function Versioned() {
|
|
111
|
+
return function (target: any) {
|
|
112
|
+
Reflect.defineMetadata(VersionedKey, true, target);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
111
116
|
|
|
112
117
|
export function OneToOne(model: () => any, foreignKey: string) {
|
|
113
118
|
return function (target: any, propertyKey: string) {
|
package/hooks.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
|
|
3
|
+
export type HookType =
|
|
4
|
+
| 'beforeCreate' | 'afterCreate'
|
|
5
|
+
| 'beforeUpdate' | 'afterUpdate'
|
|
6
|
+
| 'beforeDelete' | 'afterDelete'
|
|
7
|
+
| 'beforeSave' | 'afterSave';
|
|
8
|
+
|
|
9
|
+
const HOOK_METADATA_KEY = Symbol('stabilize:hooks');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Decorator to mark a method as a lifecycle hook.
|
|
13
|
+
* Usage: @Hook('beforeCreate')
|
|
14
|
+
*/
|
|
15
|
+
export function Hook(type: HookType) {
|
|
16
|
+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
17
|
+
const hooks: Record<HookType, string[]> =
|
|
18
|
+
Reflect.getMetadata(HOOK_METADATA_KEY, target) || {};
|
|
19
|
+
hooks[type] = hooks[type] || [];
|
|
20
|
+
hooks[type].push(propertyKey);
|
|
21
|
+
Reflect.defineMetadata(HOOK_METADATA_KEY, hooks, target);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get hooks of a specific type for a model instance.
|
|
27
|
+
*/
|
|
28
|
+
export function getHooks(instance: any, type: HookType): Array<() => Promise<void> | void> {
|
|
29
|
+
const proto = Object.getPrototypeOf(instance);
|
|
30
|
+
const hooks: Record<HookType, string[]> =
|
|
31
|
+
Reflect.getMetadata(HOOK_METADATA_KEY, proto) || {};
|
|
32
|
+
return (hooks[type] || []).map((methodName) => instance[methodName].bind(instance));
|
|
33
|
+
}
|
package/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type Logger, ConsoleLogger } from "./logger";
|
|
|
9
9
|
import { QueryBuilder } from "./query-builder";
|
|
10
10
|
import { Repository } from "./repository";
|
|
11
11
|
import { runMigrations, generateMigration, type Migration } from "./migrations";
|
|
12
|
+
import { Hook } from "./hooks";
|
|
12
13
|
import {
|
|
13
14
|
Model,
|
|
14
15
|
Column,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
ModelKey,
|
|
23
24
|
ColumnKey,
|
|
24
25
|
ValidatorKey,
|
|
26
|
+
Versioned,
|
|
25
27
|
RelationKey,
|
|
26
28
|
SoftDeleteKey,
|
|
27
29
|
} from "./decorators";
|
|
@@ -30,7 +32,7 @@ import {
|
|
|
30
32
|
type CacheConfig,
|
|
31
33
|
type LoggerConfig,
|
|
32
34
|
DBType,
|
|
33
|
-
DataTypes,
|
|
35
|
+
DataTypes,
|
|
34
36
|
StabilizeError,
|
|
35
37
|
type PoolMetrics,
|
|
36
38
|
type QueryHint,
|
|
@@ -158,6 +160,8 @@ export {
|
|
|
158
160
|
Required,
|
|
159
161
|
Unique,
|
|
160
162
|
SoftDelete,
|
|
163
|
+
Versioned,
|
|
164
|
+
Hook,
|
|
161
165
|
OneToOne,
|
|
162
166
|
ManyToOne,
|
|
163
167
|
OneToMany,
|
package/migrations.ts
CHANGED
|
@@ -6,14 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { DBClient } from "./client";
|
|
9
|
-
import { ModelKey, ColumnKey, ValidatorKey, SoftDeleteKey } from "./decorators";
|
|
9
|
+
import { ModelKey, ColumnKey, ValidatorKey, SoftDeleteKey, VersionedKey } from "./decorators";
|
|
10
10
|
import { type DBConfig, type Migration, StabilizeError, DBType, DataTypes } from "./types";
|
|
11
11
|
|
|
12
12
|
type ColumnData = { name: string; type: string };
|
|
13
13
|
type ColumnMetadata = Record<string, ColumnData>;
|
|
14
14
|
type ValidatorMetadata = Record<string, string[]>;
|
|
15
15
|
|
|
16
|
-
// --- FIX: New Helper Function to format queries for different DBs ---
|
|
17
16
|
/**
|
|
18
17
|
* @internal
|
|
19
18
|
* Formats a SQL query with placeholders for the target database dialect.
|
|
@@ -30,7 +29,6 @@ function formatQuery(query: string, dbType: DBType): string {
|
|
|
30
29
|
return query;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
|
|
34
32
|
/**
|
|
35
33
|
* Maps an abstract data type (from DataTypes enum or a string) to the correct SQL type string
|
|
36
34
|
* for the specified database dialect (Postgres, MySQL, or SQLite).
|
|
@@ -110,7 +108,6 @@ function mapDataTypeToSql(dt: DataTypes | string, dbType: DBType): string {
|
|
|
110
108
|
return "TEXT";
|
|
111
109
|
}
|
|
112
110
|
|
|
113
|
-
|
|
114
111
|
/**
|
|
115
112
|
* @internal
|
|
116
113
|
* Gets the database-specific SQL for an auto-incrementing primary key.
|
|
@@ -129,46 +126,12 @@ function getAutoIncrementPK(dbType: DBType): string {
|
|
|
129
126
|
}
|
|
130
127
|
}
|
|
131
128
|
|
|
132
|
-
/**
|
|
133
|
-
* @internal
|
|
134
|
-
* Gets the database-specific SQL for a timestamp column.
|
|
135
|
-
* @param dbType The target database dialect.
|
|
136
|
-
* @returns The SQL string for the timestamp column type.
|
|
137
|
-
*/
|
|
138
|
-
function getTimestampType(dbType: DBType): string {
|
|
139
|
-
switch (dbType) {
|
|
140
|
-
case DBType.Postgres:
|
|
141
|
-
return "TIMESTAMP";
|
|
142
|
-
case DBType.MySQL:
|
|
143
|
-
return "DATETIME";
|
|
144
|
-
case DBType.SQLite:
|
|
145
|
-
default:
|
|
146
|
-
return "TEXT";
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* @internal
|
|
152
|
-
* Gets the database-specific SQL for a default `CURRENT_TIMESTAMP` value.
|
|
153
|
-
* @param dbType The target database dialect.
|
|
154
|
-
* @returns The SQL string for the default value.
|
|
155
|
-
*/
|
|
156
|
-
function getTimestampDefault(dbType: DBType): string {
|
|
157
|
-
switch (dbType) {
|
|
158
|
-
case DBType.Postgres:
|
|
159
|
-
case DBType.SQLite:
|
|
160
|
-
return "DEFAULT CURRENT_TIMESTAMP";
|
|
161
|
-
case DBType.MySQL:
|
|
162
|
-
return "DEFAULT CURRENT_TIMESTAMP";
|
|
163
|
-
default:
|
|
164
|
-
return "DEFAULT CURRENT_TIMESTAMP";
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
129
|
/**
|
|
169
130
|
* Generates SQL migration scripts (`up` and `down`) based on a model's decorators.
|
|
170
131
|
* This function reads the metadata from a model class to create a `CREATE TABLE` statement.
|
|
171
132
|
*
|
|
133
|
+
* If the model is versioned (has @Versioned), also generates a history table for time-travel queries.
|
|
134
|
+
*
|
|
172
135
|
* @param model The model class decorated with `@Model` and `@Column`.
|
|
173
136
|
* @param name A descriptive name for the migration (used for the migration object).
|
|
174
137
|
* @param dbType The target database dialect to generate SQL for. Defaults to Postgres.
|
|
@@ -186,6 +149,7 @@ export async function generateMigration(
|
|
|
186
149
|
|
|
187
150
|
const columns: ColumnMetadata = Reflect.getMetadata(ColumnKey, model.prototype) || {};
|
|
188
151
|
const validators: ValidatorMetadata = Reflect.getMetadata(ValidatorKey, model.prototype) || {};
|
|
152
|
+
const versioned: boolean = !!Reflect.getMetadata(VersionedKey, model);
|
|
189
153
|
|
|
190
154
|
const columnDefs: string[] = [];
|
|
191
155
|
|
|
@@ -210,12 +174,53 @@ export async function generateMigration(
|
|
|
210
174
|
columnDefs.push(defParts.join(" "));
|
|
211
175
|
}
|
|
212
176
|
|
|
213
|
-
const up = [`CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`];
|
|
214
|
-
const down = [`DROP TABLE IF EXISTS ${tableName}`];
|
|
177
|
+
const up: string[] = [`CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`];
|
|
178
|
+
const down: string[] = [`DROP TABLE IF EXISTS ${tableName}`];
|
|
179
|
+
|
|
180
|
+
// If model is versioned, add history table migration
|
|
181
|
+
if (versioned) {
|
|
182
|
+
const [historyUp, historyDown] = generateHistoryMigration(tableName, columnDefs, dbType);
|
|
183
|
+
up.push(historyUp);
|
|
184
|
+
down.push(historyDown);
|
|
185
|
+
}
|
|
215
186
|
|
|
216
187
|
return { up, down, name };
|
|
217
188
|
}
|
|
218
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Generates SQL for a version/audit history table for time-travel queries.
|
|
192
|
+
* @param tableName The name of the main table.
|
|
193
|
+
* @param columnDefs The column definitions (from the main table).
|
|
194
|
+
* @param dbType The target database dialect.
|
|
195
|
+
*/
|
|
196
|
+
function generateHistoryMigration(
|
|
197
|
+
tableName: string,
|
|
198
|
+
columnDefs: string[],
|
|
199
|
+
dbType: DBType,
|
|
200
|
+
): [string, string] {
|
|
201
|
+
const historyTable = `${tableName}_history`;
|
|
202
|
+
let opType = "VARCHAR(10) NOT NULL";
|
|
203
|
+
let versionType = "INT NOT NULL";
|
|
204
|
+
let tsType = dbType === DBType.MySQL ? "DATETIME" :
|
|
205
|
+
dbType === DBType.SQLite ? "TEXT" : "TIMESTAMP";
|
|
206
|
+
let modByType = dbType === DBType.MySQL ? "VARCHAR(255)" : "TEXT";
|
|
207
|
+
let modAtType = tsType + (dbType === DBType.Postgres ? " DEFAULT CURRENT_TIMESTAMP" : "");
|
|
208
|
+
|
|
209
|
+
const historyColumns = [
|
|
210
|
+
...columnDefs,
|
|
211
|
+
`operation ${opType}`,
|
|
212
|
+
`version ${versionType}`,
|
|
213
|
+
`valid_from ${tsType} NOT NULL`,
|
|
214
|
+
`valid_to ${tsType}`,
|
|
215
|
+
`modified_by ${modByType}`,
|
|
216
|
+
`modified_at ${modAtType}`
|
|
217
|
+
];
|
|
218
|
+
return [
|
|
219
|
+
`CREATE TABLE IF NOT EXISTS ${historyTable} (${historyColumns.join(", ")})`,
|
|
220
|
+
`DROP TABLE IF EXISTS ${historyTable}`
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
219
224
|
/**
|
|
220
225
|
* @internal
|
|
221
226
|
* Gets the database-specific SQL for creating the `migrations` table, which tracks applied migrations.
|
package/package.json
CHANGED
package/repository.ts
CHANGED
|
@@ -20,7 +20,12 @@ import {
|
|
|
20
20
|
ValidatorKey,
|
|
21
21
|
RelationKey,
|
|
22
22
|
SoftDeleteKey,
|
|
23
|
+
VersionedKey,
|
|
23
24
|
} from "./decorators";
|
|
25
|
+
import { getHooks, type HookType } from "./hooks";
|
|
26
|
+
|
|
27
|
+
type VersionOperation = "insert" | "update" | "delete";
|
|
28
|
+
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* Provides a generic repository for a model `T`.
|
|
@@ -46,6 +51,8 @@ export class Repository<T> {
|
|
|
46
51
|
>;
|
|
47
52
|
private softDeleteField: string | null;
|
|
48
53
|
private logger: Logger;
|
|
54
|
+
private versioned: boolean;
|
|
55
|
+
private historyTable: string;
|
|
49
56
|
|
|
50
57
|
/**
|
|
51
58
|
* Creates an instance of Repository.
|
|
@@ -69,6 +76,8 @@ export class Repository<T> {
|
|
|
69
76
|
this.softDeleteField =
|
|
70
77
|
Reflect.getMetadata(SoftDeleteKey, model.prototype) || null;
|
|
71
78
|
this.logger = logger;
|
|
79
|
+
this.versioned = !!Reflect.getMetadata(VersionedKey, model);
|
|
80
|
+
this.historyTable = `${this.table}_history`;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
/**
|
|
@@ -102,6 +111,17 @@ export class Repository<T> {
|
|
|
102
111
|
}
|
|
103
112
|
}
|
|
104
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Runs lifecycle hooks of a given type for the entity.
|
|
116
|
+
* @param entity The entity instance.
|
|
117
|
+
* @param type The hook type (e.g., 'beforeCreate').
|
|
118
|
+
*/
|
|
119
|
+
private async runHooks(entity: any, type: HookType): Promise<void> {
|
|
120
|
+
for (const hook of getHooks(entity, type)) {
|
|
121
|
+
await hook();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
105
125
|
/**
|
|
106
126
|
* Creates a new `QueryBuilder` instance for the repository's table.
|
|
107
127
|
* Automatically adds a `WHERE` clause to exclude soft-deleted records if applicable.
|
|
@@ -152,6 +172,136 @@ export class Repository<T> {
|
|
|
152
172
|
return results[0] || null;
|
|
153
173
|
}
|
|
154
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Snapshot query: get record as it was at a point in time.
|
|
177
|
+
*/
|
|
178
|
+
async asOf(
|
|
179
|
+
id: number | string,
|
|
180
|
+
asOfDate: Date,
|
|
181
|
+
_client?: DBClient
|
|
182
|
+
): Promise<T | null> {
|
|
183
|
+
if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
|
|
184
|
+
const client = _client || this.client;
|
|
185
|
+
const rows = await client.query<T>(
|
|
186
|
+
`SELECT * FROM ${this.historyTable} WHERE id = ? AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) ORDER BY version DESC LIMIT 1`,
|
|
187
|
+
[id, asOfDate, asOfDate]
|
|
188
|
+
);
|
|
189
|
+
return rows[0] || null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get all history for a record.
|
|
194
|
+
*/
|
|
195
|
+
async history(
|
|
196
|
+
id: number | string,
|
|
197
|
+
_client?: DBClient
|
|
198
|
+
): Promise<T[]> {
|
|
199
|
+
if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
|
|
200
|
+
const client = _client || this.client;
|
|
201
|
+
return client.query<T>(
|
|
202
|
+
`SELECT * FROM ${this.historyTable} WHERE id = ? ORDER BY version ASC`,
|
|
203
|
+
[id]
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Rollback a record to a previous version.
|
|
211
|
+
*/
|
|
212
|
+
async rollback(
|
|
213
|
+
id: number | string,
|
|
214
|
+
version: number,
|
|
215
|
+
_client?: DBClient
|
|
216
|
+
): Promise<T> {
|
|
217
|
+
if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
|
|
218
|
+
const client = _client || this.client;
|
|
219
|
+
return client.transaction(async (txClient) => {
|
|
220
|
+
const rows = await txClient.query<T>(
|
|
221
|
+
`SELECT * FROM ${this.historyTable} WHERE id = ? AND version = ? LIMIT 1`,
|
|
222
|
+
[id, version]
|
|
223
|
+
);
|
|
224
|
+
if (!rows.length) throw new StabilizeError("Version not found", "ROLLBACK_ERROR");
|
|
225
|
+
|
|
226
|
+
const entity = rows[0];
|
|
227
|
+
const columns = Object.keys(this.columns).filter((c) => c !== "id");
|
|
228
|
+
const setClause = columns.map((c) => `${this.columns[c]!.name} = ?`).join(", ");
|
|
229
|
+
const params = columns.map((c) => (entity as any)[c]);
|
|
230
|
+
|
|
231
|
+
await txClient.query(
|
|
232
|
+
`UPDATE ${this.table} SET ${setClause} WHERE id = ?`,
|
|
233
|
+
[...params, id]
|
|
234
|
+
);
|
|
235
|
+
await this.writeHistory({ ...entity, version: version + 1 }, "update", txClient);
|
|
236
|
+
return this.findOne(id, {}, txClient) as Promise<T>;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Writes a versioned history row for the entity to the history table.
|
|
243
|
+
*
|
|
244
|
+
* This method maps entity property keys to their corresponding SQL column names
|
|
245
|
+
* (as defined in metadata) to ensure that inserts match the schema for all supported
|
|
246
|
+
* databases (Postgres, MySQL, SQLite).
|
|
247
|
+
*
|
|
248
|
+
* This function works for Postgres, MySQL, and SQLite, and uses positional parameters
|
|
249
|
+
* (properly formatted for the target database) for safety and compatibility.
|
|
250
|
+
*
|
|
251
|
+
* @param entity - The entity object being versioned
|
|
252
|
+
* @param operation - The operation performed ("insert", "update", "delete")
|
|
253
|
+
* @param client - The database client to use for the insert
|
|
254
|
+
* @param user - The user/system responsible for the change (default: "system")
|
|
255
|
+
*/
|
|
256
|
+
private async writeHistory(
|
|
257
|
+
entity: any,
|
|
258
|
+
operation: VersionOperation,
|
|
259
|
+
client: DBClient,
|
|
260
|
+
user?: string
|
|
261
|
+
) {
|
|
262
|
+
if (!this.versioned) return;
|
|
263
|
+
|
|
264
|
+
// Get property keys and corresponding SQL column names
|
|
265
|
+
const propertyKeys = Object.keys(this.columns);
|
|
266
|
+
const sqlColumnNames = propertyKeys.map((k) => this.columns[k]!.name);
|
|
267
|
+
|
|
268
|
+
// Build historyColumns using SQL column names
|
|
269
|
+
const historyColumns = [
|
|
270
|
+
...sqlColumnNames,
|
|
271
|
+
"operation",
|
|
272
|
+
"version",
|
|
273
|
+
"valid_from",
|
|
274
|
+
"valid_to",
|
|
275
|
+
"modified_by",
|
|
276
|
+
"modified_at"
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
// Map values from entity using property keys
|
|
280
|
+
const values = propertyKeys.map((k) => entity[k]);
|
|
281
|
+
|
|
282
|
+
const params = [
|
|
283
|
+
...values,
|
|
284
|
+
operation,
|
|
285
|
+
entity.version || 1,
|
|
286
|
+
new Date(),
|
|
287
|
+
null,
|
|
288
|
+
user || "system",
|
|
289
|
+
new Date()
|
|
290
|
+
];
|
|
291
|
+
// Database-agnostic placeholder formatting
|
|
292
|
+
let placeholders: string;
|
|
293
|
+
if (client.config.type === DBType.Postgres) {
|
|
294
|
+
placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
|
|
295
|
+
} else {
|
|
296
|
+
placeholders = params.map(() => "?").join(", ");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await client.query(
|
|
300
|
+
`INSERT INTO ${this.historyTable} (${historyColumns.join(", ")}) VALUES (${placeholders})`,
|
|
301
|
+
params
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
155
305
|
/**
|
|
156
306
|
* Creates a new record in the database within a transaction.
|
|
157
307
|
* @param entity The data for the new record.
|
|
@@ -166,11 +316,22 @@ export class Repository<T> {
|
|
|
166
316
|
entity: Partial<T>,
|
|
167
317
|
options: { relations?: string[] } = {},
|
|
168
318
|
): Promise<T> {
|
|
169
|
-
return this.client.transaction((txClient) =>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
319
|
+
return this.client.transaction(async (txClient) => {
|
|
320
|
+
const instance = new (Object.getPrototypeOf(entity).constructor || Object)();
|
|
321
|
+
Object.assign(instance, entity);
|
|
173
322
|
|
|
323
|
+
await this.runHooks(instance, "beforeCreate");
|
|
324
|
+
await this.runHooks(instance, "beforeSave");
|
|
325
|
+
|
|
326
|
+
const result = await this._create(entity, options, txClient);
|
|
327
|
+
|
|
328
|
+
await this.runHooks(result, "afterCreate");
|
|
329
|
+
await this.runHooks(result, "afterSave");
|
|
330
|
+
|
|
331
|
+
await this.writeHistory(result, "insert", txClient);
|
|
332
|
+
return result;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
174
335
|
/**
|
|
175
336
|
* @internal
|
|
176
337
|
* The private implementation for creating a record, executed within a transaction.
|
|
@@ -245,9 +406,30 @@ export class Repository<T> {
|
|
|
245
406
|
entities: Partial<T>[],
|
|
246
407
|
options: { relations?: string[]; batchSize?: number } = {},
|
|
247
408
|
): Promise<T[]> {
|
|
248
|
-
return this.client.transaction((txClient) =>
|
|
249
|
-
|
|
250
|
-
|
|
409
|
+
return this.client.transaction(async (txClient) => {
|
|
410
|
+
// Prepare entity instances for hooks
|
|
411
|
+
const preparedEntities = entities.map(data => {
|
|
412
|
+
const instance = new (this as any).model();
|
|
413
|
+
Object.assign(instance, data);
|
|
414
|
+
return instance;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
for (const entity of preparedEntities) {
|
|
418
|
+
await this.runHooks(entity, "beforeCreate");
|
|
419
|
+
await this.runHooks(entity, "beforeSave");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const results = await this._bulkCreate(entities, options, txClient);
|
|
423
|
+
|
|
424
|
+
for (const result of results) {
|
|
425
|
+
await this.runHooks(result, "afterCreate");
|
|
426
|
+
await this.runHooks(result, "afterSave");
|
|
427
|
+
if (this.versioned) {
|
|
428
|
+
await this.writeHistory(result, "insert", txClient);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return results;
|
|
432
|
+
});
|
|
251
433
|
}
|
|
252
434
|
|
|
253
435
|
/**
|
|
@@ -358,9 +540,27 @@ export class Repository<T> {
|
|
|
358
540
|
* ```
|
|
359
541
|
*/
|
|
360
542
|
async update(id: number | string, entity: Partial<T>): Promise<T> {
|
|
361
|
-
return this.client.transaction((txClient) =>
|
|
362
|
-
this.
|
|
363
|
-
|
|
543
|
+
return this.client.transaction(async (txClient) => {
|
|
544
|
+
const before = await this.findOne(id, {}, txClient);
|
|
545
|
+
if (!before) throw new StabilizeError("Not found", "UPDATE_ERROR");
|
|
546
|
+
const instance = new (Object.getPrototypeOf(before).constructor || Object)();
|
|
547
|
+
Object.assign(instance, before, entity);
|
|
548
|
+
|
|
549
|
+
await this.runHooks(instance, "beforeUpdate");
|
|
550
|
+
await this.runHooks(instance, "beforeSave");
|
|
551
|
+
|
|
552
|
+
const result = await this._update(id, entity, txClient);
|
|
553
|
+
|
|
554
|
+
await this.runHooks(result, "afterUpdate");
|
|
555
|
+
await this.runHooks(result, "afterSave");
|
|
556
|
+
|
|
557
|
+
await this.writeHistory(
|
|
558
|
+
{ ...before, ...entity, version: (before as any).version ? (before as any).version + 1 : 1 },
|
|
559
|
+
"update",
|
|
560
|
+
txClient
|
|
561
|
+
);
|
|
562
|
+
return result;
|
|
563
|
+
});
|
|
364
564
|
}
|
|
365
565
|
|
|
366
566
|
/**
|
|
@@ -442,14 +642,48 @@ export class Repository<T> {
|
|
|
442
642
|
for (let i = 0; i < updates.length; i += batchSize) {
|
|
443
643
|
const batch = updates.slice(i, i + batchSize);
|
|
444
644
|
for (const update of batch) {
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
645
|
+
// Find all IDs matching the where clause
|
|
646
|
+
const rows = await client.query<{ id: number | string }>(
|
|
647
|
+
`SELECT id FROM ${this.table} WHERE ${update.where.condition}${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`,
|
|
648
|
+
update.where.params,
|
|
649
|
+
);
|
|
650
|
+
for (const { id } of rows) {
|
|
651
|
+
// Fetch record before update for versioning
|
|
652
|
+
const before = await this.findOne(id, {}, client);
|
|
653
|
+
if (!before) continue;
|
|
654
|
+
|
|
655
|
+
// Prepare instance for hooks
|
|
656
|
+
const instance = new ((this as any).model || Object)();
|
|
657
|
+
Object.assign(instance, before, update.set);
|
|
658
|
+
|
|
659
|
+
await this.runHooks(instance, "beforeUpdate");
|
|
660
|
+
await this.runHooks(instance, "beforeSave");
|
|
661
|
+
|
|
662
|
+
const keys = Object.keys(update.set).filter((k) => this.columns[k]);
|
|
663
|
+
const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
|
|
664
|
+
const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
|
|
665
|
+
const params = [
|
|
666
|
+
...keys.map((k) => (update.set as any)[k]),
|
|
667
|
+
id,
|
|
668
|
+
];
|
|
669
|
+
await client.query(query, params);
|
|
670
|
+
|
|
671
|
+
const after = await this.findOne(id, {}, client);
|
|
672
|
+
if (after) {
|
|
673
|
+
await this.runHooks(after, "afterUpdate");
|
|
674
|
+
await this.runHooks(after, "afterSave");
|
|
675
|
+
if (this.versioned) {
|
|
676
|
+
await this.writeHistory(
|
|
677
|
+
{
|
|
678
|
+
...after,
|
|
679
|
+
version: (before as any).version ? (before as any).version + 1 : 1
|
|
680
|
+
},
|
|
681
|
+
"update",
|
|
682
|
+
client
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
453
687
|
}
|
|
454
688
|
}
|
|
455
689
|
|
|
@@ -459,7 +693,6 @@ export class Repository<T> {
|
|
|
459
693
|
`Bulk updated ${updates.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
460
694
|
);
|
|
461
695
|
}
|
|
462
|
-
|
|
463
696
|
/**
|
|
464
697
|
* Performs an "update or insert" operation based on a set of unique keys.
|
|
465
698
|
* @param entity The entity to upsert.
|
|
@@ -504,6 +737,32 @@ export class Repository<T> {
|
|
|
504
737
|
const insertParams = columns.map((k) => (entity as any)[k]);
|
|
505
738
|
let params = [...insertParams, ...updateParams];
|
|
506
739
|
|
|
740
|
+
// Try to find the record before upsert
|
|
741
|
+
let before: T | null = null;
|
|
742
|
+
let isUpdate = false;
|
|
743
|
+
if (this.versioned && keys.length > 0) {
|
|
744
|
+
const whereClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(" AND ");
|
|
745
|
+
const whereParams = keys.map((k) => (entity as any)[k]);
|
|
746
|
+
const found = await client.query<T>(
|
|
747
|
+
`SELECT * FROM ${this.table} WHERE ${whereClause} LIMIT 1`,
|
|
748
|
+
whereParams
|
|
749
|
+
);
|
|
750
|
+
before = found[0] || null;
|
|
751
|
+
isUpdate = !!before;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Prepare instance for hooks
|
|
755
|
+
const instance = new ((this as any).model || Object)();
|
|
756
|
+
Object.assign(instance, before || {}, entity);
|
|
757
|
+
|
|
758
|
+
if (isUpdate) {
|
|
759
|
+
await this.runHooks(instance, "beforeUpdate");
|
|
760
|
+
await this.runHooks(instance, "beforeSave");
|
|
761
|
+
} else {
|
|
762
|
+
await this.runHooks(instance, "beforeCreate");
|
|
763
|
+
await this.runHooks(instance, "beforeSave");
|
|
764
|
+
}
|
|
765
|
+
|
|
507
766
|
if (dbType === DBType.SQLite) {
|
|
508
767
|
query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT(${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${updateClause}`;
|
|
509
768
|
} else if (dbType === DBType.MySQL) {
|
|
@@ -532,6 +791,14 @@ export class Repository<T> {
|
|
|
532
791
|
|
|
533
792
|
const result = results[0] ?? ((await this.findOne(id, {}, client)) as T);
|
|
534
793
|
|
|
794
|
+
if (isUpdate) {
|
|
795
|
+
await this.runHooks(result, "afterUpdate");
|
|
796
|
+
await this.runHooks(result, "afterSave");
|
|
797
|
+
} else {
|
|
798
|
+
await this.runHooks(result, "afterCreate");
|
|
799
|
+
await this.runHooks(result, "afterSave");
|
|
800
|
+
}
|
|
801
|
+
|
|
535
802
|
if (this.cache) {
|
|
536
803
|
await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
537
804
|
if (this.cache.getStrategy() === "write-through") {
|
|
@@ -539,12 +806,19 @@ export class Repository<T> {
|
|
|
539
806
|
}
|
|
540
807
|
}
|
|
541
808
|
|
|
809
|
+
if (this.versioned) {
|
|
810
|
+
await this.writeHistory(
|
|
811
|
+
{ ...result, version: before ? ((before as any).version ? (before as any).version + 1 : 1) : 1 },
|
|
812
|
+
before ? "update" : "insert",
|
|
813
|
+
client
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
542
817
|
this.logger.logDebug(
|
|
543
818
|
`Upserted ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
544
819
|
);
|
|
545
820
|
return result;
|
|
546
821
|
}
|
|
547
|
-
|
|
548
822
|
/**
|
|
549
823
|
* Deletes a record by its ID. Performs a soft delete if enabled on the model.
|
|
550
824
|
* @param id The ID of the record to delete.
|
|
@@ -555,7 +829,16 @@ export class Repository<T> {
|
|
|
555
829
|
* ```
|
|
556
830
|
*/
|
|
557
831
|
async delete(id: number | string): Promise<void> {
|
|
558
|
-
return this.client.transaction((txClient) =>
|
|
832
|
+
return this.client.transaction(async (txClient) => {
|
|
833
|
+
const before = await this.findOne(id, {}, txClient);
|
|
834
|
+
if (!before) throw new StabilizeError("Not found", "DELETE_ERROR");
|
|
835
|
+
await this.runHooks(before, "beforeDelete");
|
|
836
|
+
|
|
837
|
+
await this._delete(id, txClient);
|
|
838
|
+
|
|
839
|
+
await this.runHooks(before, "afterDelete");
|
|
840
|
+
await this.writeHistory(before, "delete", txClient);
|
|
841
|
+
});
|
|
559
842
|
}
|
|
560
843
|
|
|
561
844
|
/**
|
|
@@ -619,14 +902,25 @@ export class Repository<T> {
|
|
|
619
902
|
const batchSize = options.batchSize || 1000;
|
|
620
903
|
for (let i = 0; i < ids.length; i += batchSize) {
|
|
621
904
|
const batch = ids.slice(i, i + batchSize);
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
905
|
+
for (const id of batch) {
|
|
906
|
+
const before = await this.findOne(id, {}, client);
|
|
907
|
+
if (!before) continue;
|
|
908
|
+
|
|
909
|
+
await this.runHooks(before, "beforeDelete");
|
|
910
|
+
|
|
911
|
+
const query = this.softDeleteField
|
|
912
|
+
? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id = ?`
|
|
913
|
+
: `DELETE FROM ${this.table} WHERE id = ?`;
|
|
914
|
+
const params = this.softDeleteField ? [new Date().toISOString(), id] : [id];
|
|
915
|
+
|
|
916
|
+
await client.query(query, params);
|
|
917
|
+
|
|
918
|
+
await this.runHooks(before, "afterDelete");
|
|
919
|
+
|
|
920
|
+
if (this.versioned) {
|
|
921
|
+
await this.writeHistory(before, "delete", client);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
630
924
|
}
|
|
631
925
|
|
|
632
926
|
if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
@@ -635,7 +929,6 @@ export class Repository<T> {
|
|
|
635
929
|
`Bulk deleted ${ids.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
636
930
|
);
|
|
637
931
|
}
|
|
638
|
-
|
|
639
932
|
/**
|
|
640
933
|
* Recovers a soft-deleted record by its ID.
|
|
641
934
|
* Throws an error if soft delete is not enabled on the model.
|