stabilize-orm 1.1.8 → 1.3.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/package.json CHANGED
@@ -1,19 +1,13 @@
1
1
  {
2
2
  "name": "stabilize-orm",
3
- "version": "1.1.8",
3
+ "version": "1.3.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",
7
- "bin": {
8
- "stabilize": "dist/cli/stabilize-cli.js"
9
- },
10
7
  "scripts": {
11
- "build": "bun build src/index.ts --outdir dist --target bun --minify && bun build cli/stabilize-cli.ts --outdir dist/cli --target bun --minify",
8
+ "build": "bun build src/index.ts --outdir dist --target bun --minify",
12
9
  "prepublishOnly": "bun run build && bun run test",
13
- "build:exe": "bun build cli/stabilize-cli.ts --compile --outfile dist/stabilize-cli --target bun",
14
- "format": "bunx prettier --write .",
15
- "lint": "eslint src tests cli examples",
16
- "cli": "bun run cli/stabilize-cli.ts"
10
+ "format": "bunx prettier --write ."
17
11
  },
18
12
  "keywords": [
19
13
  "orm",
package/repository.ts CHANGED
@@ -9,18 +9,16 @@ import { DBClient } from "./client";
9
9
  import { ConsoleLogger, type Logger } from "./logger";
10
10
  import { QueryBuilder } from "./query-builder";
11
11
  import {
12
+ DataTypes,
12
13
  DBType,
13
14
  RelationType,
14
15
  StabilizeError,
15
16
  type CacheConfig,
16
17
  } from "./types";
17
- import {
18
- ModelKey,
19
- ColumnKey,
20
- ValidatorKey,
21
- RelationKey,
22
- SoftDeleteKey,
23
- } from "./decorators";
18
+ import { MetadataStorage } from "./model";
19
+ import { getHooks, type HookType } from "./hooks";
20
+
21
+ type VersionOperation = "insert" | "update" | "delete";
24
22
 
25
23
  /**
26
24
  * Provides a generic repository for a model `T`.
@@ -46,11 +44,13 @@ export class Repository<T> {
46
44
  >;
47
45
  private softDeleteField: string | null;
48
46
  private logger: Logger;
47
+ private versioned: boolean;
48
+ private historyTable: string;
49
49
 
50
50
  /**
51
51
  * Creates an instance of Repository.
52
52
  * @param client The database client instance for executing queries.
53
- * @param model The model class constructor, decorated with `@Model`.
53
+ * @param model The model class constructor, defined with `defineModel`.
54
54
  * @param cacheConfig Optional configuration for caching.
55
55
  * @param logger A logger instance for logging messages.
56
56
  */
@@ -62,13 +62,31 @@ export class Repository<T> {
62
62
  ) {
63
63
  this.client = client;
64
64
  this.cache = cacheConfig.enabled ? new Cache(cacheConfig, logger) : null;
65
- this.table = Reflect.getMetadata(ModelKey, model) || "";
66
- this.columns = Reflect.getMetadata(ColumnKey, model.prototype) || {};
67
- this.validators = Reflect.getMetadata(ValidatorKey, model.prototype) || {};
68
- this.relations = Reflect.getMetadata(RelationKey, model.prototype) || {};
69
- this.softDeleteField =
70
- Reflect.getMetadata(SoftDeleteKey, model.prototype) || null;
65
+ this.table = MetadataStorage.getTableName(model);
66
+ this.columns = Object.fromEntries(
67
+ Object.entries(MetadataStorage.getColumns(model)).map(([key, col]) => [
68
+ key,
69
+ { name: col.name ?? key, type: typeof col.type === 'string' ? col.type : DataTypes[col.type] },
70
+ ])
71
+ );
72
+ this.relations = Object.fromEntries(
73
+ Object.entries(MetadataStorage.getRelations(model)).map(([key, rel]) => [
74
+ key,
75
+ {
76
+ type: rel.type,
77
+ targetModel: rel.target,
78
+ foreignKey: rel.foreignKey,
79
+ inverseKey: rel.inverseKey,
80
+ joinTable: rel.joinTable,
81
+ },
82
+ ])
83
+ );
84
+ this.validators = MetadataStorage.getValidators(model);
85
+
86
+ this.softDeleteField = MetadataStorage.getSoftDeleteField(model);
71
87
  this.logger = logger;
88
+ this.versioned = MetadataStorage.isVersioned(model);
89
+ this.historyTable = `${this.table}_history`;
72
90
  }
73
91
 
74
92
  /**
@@ -84,7 +102,7 @@ export class Repository<T> {
84
102
 
85
103
  /**
86
104
  * @internal
87
- * Validates an entity against the 'required' constraints defined in decorators.
105
+ * Validates an entity against the 'required' constraints defined in the model configuration.
88
106
  * @param entity The partial entity to validate.
89
107
  */
90
108
  private validate(entity: Partial<T>) {
@@ -102,6 +120,17 @@ export class Repository<T> {
102
120
  }
103
121
  }
104
122
 
123
+ /**
124
+ * Runs lifecycle hooks of a given type for the entity.
125
+ * @param entity The entity instance.
126
+ * @param type The hook type (e.g., 'beforeCreate').
127
+ */
128
+ private async runHooks(entity: any, type: HookType): Promise<void> {
129
+ for (const hook of getHooks(entity, type)) {
130
+ await hook.callback(entity);
131
+ }
132
+ }
133
+
105
134
  /**
106
135
  * Creates a new `QueryBuilder` instance for the repository's table.
107
136
  * Automatically adds a `WHERE` clause to exclude soft-deleted records if applicable.
@@ -152,6 +181,121 @@ export class Repository<T> {
152
181
  return results[0] || null;
153
182
  }
154
183
 
184
+ /**
185
+ * Snapshot query: get record as it was at a point in time.
186
+ */
187
+ async asOf(
188
+ id: number | string,
189
+ asOfDate: Date,
190
+ _client?: DBClient
191
+ ): Promise<T | null> {
192
+ if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
193
+ const client = _client || this.client;
194
+ const rows = await client.query<T>(
195
+ `SELECT * FROM ${this.historyTable} WHERE id = ? AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) ORDER BY version DESC LIMIT 1`,
196
+ [id, asOfDate, asOfDate]
197
+ );
198
+ return rows[0] || null;
199
+ }
200
+
201
+ /**
202
+ * Get all history for a record.
203
+ */
204
+ async history(
205
+ id: number | string,
206
+ _client?: DBClient
207
+ ): Promise<T[]> {
208
+ if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
209
+ const client = _client || this.client;
210
+ return client.query<T>(
211
+ `SELECT * FROM ${this.historyTable} WHERE id = ? ORDER BY version ASC`,
212
+ [id]
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Rollback a record to a previous version.
218
+ */
219
+ async rollback(
220
+ id: number | string,
221
+ version: number,
222
+ _client?: DBClient
223
+ ): Promise<T> {
224
+ if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
225
+ const client = _client || this.client;
226
+ return client.transaction(async (txClient) => {
227
+ const rows = await txClient.query<T>(
228
+ `SELECT * FROM ${this.historyTable} WHERE id = ? AND version = ? LIMIT 1`,
229
+ [id, version]
230
+ );
231
+ if (!rows.length) throw new StabilizeError("Version not found", "ROLLBACK_ERROR");
232
+
233
+ const entity = rows[0];
234
+ const columns = Object.keys(this.columns).filter((c) => c !== "id");
235
+ const setClause = columns.map((c) => `${this.columns[c]!.name} = ?`).join(", ");
236
+ const params = columns.map((c) => (entity as any)[c]);
237
+
238
+ await txClient.query(
239
+ `UPDATE ${this.table} SET ${setClause} WHERE id = ?`,
240
+ [...params, id]
241
+ );
242
+ await this.writeHistory({ ...entity, version: version + 1 }, "update", txClient);
243
+ return this.findOne(id, {}, txClient) as Promise<T>;
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Writes a versioned history row for the entity to the history table.
249
+ * @param entity The entity object being versioned
250
+ * @param operation The operation performed ("insert", "update", "delete")
251
+ * @param client The database client to use for the insert
252
+ * @param user The user/system responsible for the change (default: "system")
253
+ */
254
+ private async writeHistory(
255
+ entity: any,
256
+ operation: VersionOperation,
257
+ client: DBClient,
258
+ user?: string
259
+ ) {
260
+ if (!this.versioned) return;
261
+
262
+ const propertyKeys = Object.keys(this.columns);
263
+ const sqlColumnNames = propertyKeys.map((k) => this.columns[k]!.name);
264
+
265
+ const historyColumns = [
266
+ ...sqlColumnNames,
267
+ "operation",
268
+ "version",
269
+ "valid_from",
270
+ "valid_to",
271
+ "modified_by",
272
+ "modified_at"
273
+ ];
274
+
275
+ const values = propertyKeys.map((k) => entity[k]);
276
+ const params = [
277
+ ...values,
278
+ operation,
279
+ entity.version || 1,
280
+ new Date(),
281
+ null,
282
+ user || "system",
283
+ new Date()
284
+ ];
285
+
286
+ let placeholders: string;
287
+ if (client.config.type === DBType.Postgres) {
288
+ placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
289
+ } else {
290
+ placeholders = params.map(() => "?").join(", ");
291
+ }
292
+
293
+ await client.query(
294
+ `INSERT INTO ${this.historyTable} (${historyColumns.join(", ")}) VALUES (${placeholders})`,
295
+ params
296
+ );
297
+ }
298
+
155
299
  /**
156
300
  * Creates a new record in the database within a transaction.
157
301
  * @param entity The data for the new record.
@@ -166,9 +310,21 @@ export class Repository<T> {
166
310
  entity: Partial<T>,
167
311
  options: { relations?: string[] } = {},
168
312
  ): Promise<T> {
169
- return this.client.transaction((txClient) =>
170
- this._create(entity, options, txClient),
171
- );
313
+ return this.client.transaction(async (txClient) => {
314
+ const instance = new (Object.getPrototypeOf(entity).constructor || Object)();
315
+ Object.assign(instance, entity);
316
+
317
+ await this.runHooks(instance, "beforeCreate");
318
+ await this.runHooks(instance, "beforeSave");
319
+
320
+ const result = await this._create(entity, options, txClient);
321
+
322
+ await this.runHooks(result, "afterCreate");
323
+ await this.runHooks(result, "afterSave");
324
+
325
+ await this.writeHistory(result, "insert", txClient);
326
+ return result;
327
+ });
172
328
  }
173
329
 
174
330
  /**
@@ -245,9 +401,29 @@ export class Repository<T> {
245
401
  entities: Partial<T>[],
246
402
  options: { relations?: string[]; batchSize?: number } = {},
247
403
  ): Promise<T[]> {
248
- return this.client.transaction((txClient) =>
249
- this._bulkCreate(entities, options, txClient),
250
- );
404
+ return this.client.transaction(async (txClient) => {
405
+ const preparedEntities = entities.map(data => {
406
+ const instance = new (this as any).model();
407
+ Object.assign(instance, data);
408
+ return instance;
409
+ });
410
+
411
+ for (const entity of preparedEntities) {
412
+ await this.runHooks(entity, "beforeCreate");
413
+ await this.runHooks(entity, "beforeSave");
414
+ }
415
+
416
+ const results = await this._bulkCreate(entities, options, txClient);
417
+
418
+ for (const result of results) {
419
+ await this.runHooks(result, "afterCreate");
420
+ await this.runHooks(result, "afterSave");
421
+ if (this.versioned) {
422
+ await this.writeHistory(result, "insert", txClient);
423
+ }
424
+ }
425
+ return results;
426
+ });
251
427
  }
252
428
 
253
429
  /**
@@ -282,7 +458,6 @@ export class Repository<T> {
282
458
  );
283
459
 
284
460
  if (dbType === DBType.Postgres) {
285
- // PostgreSQL: numbered placeholders ($1, $2, ...)
286
461
  let paramIdx = 1;
287
462
  const valuePlaceholders = batch
288
463
  .map(
@@ -294,7 +469,6 @@ export class Repository<T> {
294
469
  const batchResults = await client.query<T>(query, params);
295
470
  const ids = batchResults.map((r) => (r as any).id);
296
471
 
297
- // Handle relation loading if needed
298
472
  let finalResults = batchResults;
299
473
  if (ids.length > 0 && batchResults.length === 0) {
300
474
  const queryBuilder = this.find().where(
@@ -311,7 +485,6 @@ export class Repository<T> {
311
485
  results.push(...finalResults);
312
486
 
313
487
  } else {
314
- // SQLite/MySQL: ? placeholders
315
488
  const placeholders = `(${keys.map(() => "?").join(", ")})`;
316
489
  query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${batch.map(() => placeholders).join(", ")}`;
317
490
  await client.query(query, params);
@@ -358,9 +531,27 @@ export class Repository<T> {
358
531
  * ```
359
532
  */
360
533
  async update(id: number | string, entity: Partial<T>): Promise<T> {
361
- return this.client.transaction((txClient) =>
362
- this._update(id, entity, txClient),
363
- );
534
+ return this.client.transaction(async (txClient) => {
535
+ const before = await this.findOne(id, {}, txClient);
536
+ if (!before) throw new StabilizeError("Not found", "UPDATE_ERROR");
537
+ const instance = new (Object.getPrototypeOf(before).constructor || Object)();
538
+ Object.assign(instance, before, entity);
539
+
540
+ await this.runHooks(instance, "beforeUpdate");
541
+ await this.runHooks(instance, "beforeSave");
542
+
543
+ const result = await this._update(id, entity, txClient);
544
+
545
+ await this.runHooks(result, "afterUpdate");
546
+ await this.runHooks(result, "afterSave");
547
+
548
+ await this.writeHistory(
549
+ { ...before, ...entity, version: (before as any).version ? (before as any).version + 1 : 1 },
550
+ "update",
551
+ txClient
552
+ );
553
+ return result;
554
+ });
364
555
  }
365
556
 
366
557
  /**
@@ -442,14 +633,45 @@ export class Repository<T> {
442
633
  for (let i = 0; i < updates.length; i += batchSize) {
443
634
  const batch = updates.slice(i, i + batchSize);
444
635
  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);
636
+ const rows = await client.query<{ id: number | string }>(
637
+ `SELECT id FROM ${this.table} WHERE ${update.where.condition}${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`,
638
+ update.where.params,
639
+ );
640
+ for (const { id } of rows) {
641
+ const before = await this.findOne(id, {}, client);
642
+ if (!before) continue;
643
+
644
+ const instance = new ((this as any).model || Object)();
645
+ Object.assign(instance, before, update.set);
646
+
647
+ await this.runHooks(instance, "beforeUpdate");
648
+ await this.runHooks(instance, "beforeSave");
649
+
650
+ const keys = Object.keys(update.set).filter((k) => this.columns[k]);
651
+ const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
652
+ const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
653
+ const params = [
654
+ ...keys.map((k) => (update.set as any)[k]),
655
+ id,
656
+ ];
657
+ await client.query(query, params);
658
+
659
+ const after = await this.findOne(id, {}, client);
660
+ if (after) {
661
+ await this.runHooks(after, "afterUpdate");
662
+ await this.runHooks(after, "afterSave");
663
+ if (this.versioned) {
664
+ await this.writeHistory(
665
+ {
666
+ ...after,
667
+ version: (before as any).version ? (before as any).version + 1 : 1
668
+ },
669
+ "update",
670
+ client
671
+ );
672
+ }
673
+ }
674
+ }
453
675
  }
454
676
  }
455
677
 
@@ -495,6 +717,7 @@ export class Repository<T> {
495
717
  const columns = Object.keys(entity).filter((k) => this.columns[k]);
496
718
  const columnNames = columns.map((k) => this.columns[k]?.name).join(", ");
497
719
  const placeholders = columns.map(() => "?").join(", ");
720
+
498
721
  const updateClause = columns
499
722
  .filter((c) => !keys.includes(c))
500
723
  .map((c) => `${this.columns[c]?.name} = ?`).join(", ");
@@ -504,11 +727,35 @@ export class Repository<T> {
504
727
  const insertParams = columns.map((k) => (entity as any)[k]);
505
728
  let params = [...insertParams, ...updateParams];
506
729
 
730
+ let before: T | null = null;
731
+ let isUpdate = false;
732
+ if (this.versioned && keys.length > 0) {
733
+ const whereClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(" AND ");
734
+ const whereParams = keys.map((k) => (entity as any)[k]);
735
+ const found = await client.query<T>(
736
+ `SELECT * FROM ${this.table} WHERE ${whereClause} LIMIT 1`,
737
+ whereParams
738
+ );
739
+ before = found[0] || null;
740
+ isUpdate = !!before;
741
+ }
742
+
743
+ const instance = new ((this as any).model || Object)();
744
+ Object.assign(instance, before || {}, entity);
745
+
746
+ if (isUpdate) {
747
+ await this.runHooks(instance, "beforeUpdate");
748
+ await this.runHooks(instance, "beforeSave");
749
+ } else {
750
+ await this.runHooks(instance, "beforeCreate");
751
+ await this.runHooks(instance, "beforeSave");
752
+ }
753
+
507
754
  if (dbType === DBType.SQLite) {
508
755
  query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT(${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${updateClause}`;
509
756
  } else if (dbType === DBType.MySQL) {
510
757
  query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${updateClause}`;
511
- } else { // PostgreSQL
758
+ } else {
512
759
  const pgUpdateClause = columns
513
760
  .filter((c) => !keys.includes(c))
514
761
  .map((c) => `${this.columns[c]?.name} = EXCLUDED.${this.columns[c]?.name}`).join(", ");
@@ -532,6 +779,22 @@ export class Repository<T> {
532
779
 
533
780
  const result = results[0] ?? ((await this.findOne(id, {}, client)) as T);
534
781
 
782
+ if (isUpdate) {
783
+ await this.runHooks(result, "afterUpdate");
784
+ await this.runHooks(result, "afterSave");
785
+ } else {
786
+ await this.runHooks(result, "afterCreate");
787
+ await this.runHooks(result, "afterSave");
788
+ }
789
+
790
+ if (this.versioned) {
791
+ await this.writeHistory(
792
+ { ...result, version: before ? ((before as any).version ? (before as any).version + 1 : 1) : 1 },
793
+ before ? "update" : "insert",
794
+ client
795
+ );
796
+ }
797
+
535
798
  if (this.cache) {
536
799
  await this.cache.invalidatePattern(`find:${this.table}:*`);
537
800
  if (this.cache.getStrategy() === "write-through") {
@@ -555,7 +818,16 @@ export class Repository<T> {
555
818
  * ```
556
819
  */
557
820
  async delete(id: number | string): Promise<void> {
558
- return this.client.transaction((txClient) => this._delete(id, txClient));
821
+ return this.client.transaction(async (txClient) => {
822
+ const before = await this.findOne(id, {}, txClient);
823
+ if (!before) throw new StabilizeError("Not found", "DELETE_ERROR");
824
+ await this.runHooks(before, "beforeDelete");
825
+
826
+ await this._delete(id, txClient);
827
+
828
+ await this.runHooks(before, "afterDelete");
829
+ await this.writeHistory(before, "delete", txClient);
830
+ });
559
831
  }
560
832
 
561
833
  /**
@@ -619,14 +891,25 @@ export class Repository<T> {
619
891
  const batchSize = options.batchSize || 1000;
620
892
  for (let i = 0; i < ids.length; i += batchSize) {
621
893
  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);
894
+ for (const id of batch) {
895
+ const before = await this.findOne(id, {}, client);
896
+ if (!before) continue;
897
+
898
+ await this.runHooks(before, "beforeDelete");
899
+
900
+ const query = this.softDeleteField
901
+ ? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id = ?`
902
+ : `DELETE FROM ${this.table} WHERE id = ?`;
903
+ const params = this.softDeleteField ? [new Date().toISOString(), id] : [id];
904
+
905
+ await client.query(query, params);
906
+
907
+ await this.runHooks(before, "afterDelete");
908
+
909
+ if (this.versioned) {
910
+ await this.writeHistory(before, "delete", client);
911
+ }
912
+ }
630
913
  }
631
914
 
632
915
  if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
@@ -721,7 +1004,7 @@ export class Repository<T> {
721
1004
  "RELATION_ERROR",
722
1005
  );
723
1006
 
724
- const relatedTable = Reflect.getMetadata(ModelKey, rel.targetModel());
1007
+ const relatedTable = MetadataStorage.getTableName(rel.targetModel());
725
1008
  if (
726
1009
  rel.type === RelationType.OneToOne ||
727
1010
  rel.type === RelationType.ManyToOne
@@ -747,4 +1030,4 @@ export class Repository<T> {
747
1030
  );
748
1031
  }
749
1032
  }
750
- }
1033
+ }