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 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**, powered by a robust query builder, an elegant decorator-based model system, and a full-featured command-line interface.
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
- - **Unified API**: Write once, run against 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
- - **Retry Logic**: Automatic exponential backoff for database queries to handle transient connection issues.
18
- - **Connection Pooling**: Efficient connection management for PostgreSQL and MySQL out of the box.
19
- - **Transactional Integrity**: Built-in support for atomic transactions with automatic rollback on failure.
20
- - **Advanced Query Builder**: A fluent, chainable API for building complex queries with joins, where clauses, ordering, and pagination.
21
- - **Model Relationships**: Define `OneToOne`, `ManyToOne`, `OneToMany`, and `ManyToMany` relationships with simple decorators.
22
- - **Pluggable Logging**: Includes a robust `ConsoleLogger` with support for file-based, rotating logs.
23
- - **Custom Errors**: `StabilizeError` provides clear, consistent error handling.
24
- - **Caching Layer**: Optional Redis-backed caching with `cache-aside` and `write-through` strategies.
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
- - [Changelog](./CHANGELOG.md)
45
- - [License](./LICENSE.md)
46
- - [Code of Conduct](./CODE_OF_CONDUCT.md)
47
- - [Contributing Guide](./CONTRIBUTING.md)
48
- - [Security Policy](./SECURITY.md)
49
- - [Support](./SUPPORT.md)
50
- - [Funding](./FUNDING.md)
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. Remember to import `reflect-metadata` once at your application's entry point.
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'; // declared first
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, // Default TTL in seconds
86
+ ttl: 60,
89
87
  };
90
88
 
91
89
  const loggerConfig: LoggerConfig = {
92
- level: LogLevel.Info,
93
- // Optional: Configure file logging
94
- filePath: 'logs/stabilize.log',
95
- maxFileSize: 5 * 1024 * 1024, // 5MB
96
- maxFiles: 3,
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 database tables as classes using decorators. The new `@Column` decorator uses the `DataTypes` enum for a truly database-agnostic schema definition.
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
- Here's how to model a many-to-many relationship between `User` and `Role` through a `UserRole` join table.
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') // The join table
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 development workflow.
259
+ Stabilize includes a powerful CLI for managing your workflow.
184
260
 
185
261
  ### Generating Files
186
262
 
187
- - **Generate a model**:
263
+ - **Generate a model**:
188
264
  ```bash
189
265
  bun run stabilize-cli generate model Product
190
266
  ```
191
267
 
192
- - **Generate a migration from a model**:
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
- - **Generate a seed file**:
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
- - **Run all pending migrations**:
280
+ - **Run all pending migrations**:
206
281
  ```bash
207
282
  bun run stabilize-cli migrate
208
283
  ```
209
284
 
210
- - **Roll back the last migration**:
285
+ - **Roll back the last migration**:
211
286
  ```bash
212
287
  bun run stabilize-cli migrate:rollback
213
288
  ```
214
289
 
215
- - **Run all pending seeds** (in dependency order):
290
+ - **Run all pending seeds (in dependency order)**:
216
291
  ```bash
217
292
  bun run stabilize-cli seed
218
293
  ```
219
294
 
220
- - **Check the status of all migrations and seeds**:
295
+ - **Check the status of migrations and seeds**:
221
296
  ```bash
222
297
  bun run stabilize-cli status
223
298
  ```
224
299
 
225
- - **Reset the database (drop, migrate, seed)**:
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() // Start a query on the user_roles table
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
- The `execute()` method on a repository query does not require a client to be passed; it uses the repository's default client automatically.
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: DBClient, cache?: Cache, cacheKey?: string): Promise<User[]>;
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
- *File last updated: 2025-10-15 19:32:00 UTC*
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
- return function (target: any, propertyKey: string) {
84
- Reflect.defineMetadata(DefaultKey, value, target, propertyKey);
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
- return function (target: any, propertyKey: string) {
94
- const indexes = Reflect.getMetadata(IndexKey, target) || {};
95
- indexes[propertyKey] = indexName || `idx_${propertyKey}`;
96
- Reflect.defineMetadata(IndexKey, indexes, target);
97
- };
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, // --- FIX: Import DataTypes here ---
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stabilize-orm",
3
- "version": "1.1.8",
3
+ "version": "1.2.0",
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
@@ -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
- this._create(entity, options, txClient),
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
- this._bulkCreate(entities, options, txClient),
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._update(id, entity, txClient),
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
- const keys = Object.keys(update.set).filter((k) => this.columns[k]);
446
- const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
447
- const query = `UPDATE ${this.table} SET ${setClause} WHERE ${update.where.condition}${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
448
- const params = [
449
- ...keys.map((k) => (update.set as any)[k]),
450
- ...update.where.params,
451
- ];
452
- await client.query(query, params);
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) => this._delete(id, 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 placeholders = batch.map(() => "?").join(", ");
623
- const query = this.softDeleteField
624
- ? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id IN (${placeholders})`
625
- : `DELETE FROM ${this.table} WHERE id IN (${placeholders})`;
626
- const params = this.softDeleteField
627
- ? [new Date().toISOString(), ...batch]
628
- : batch;
629
- await client.query(query, params);
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.