stabilize-orm 1.1.7 → 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/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
@@ -2,7 +2,6 @@
2
2
  * @file stabilize.ts
3
3
  * @description The main entry point for the Stabilize ORM, tying together the client, cache, and repositories.
4
4
  * @author ElectronSz
5
- * @date 2025-10-15 20:35:34
6
5
  */
7
6
  import { Cache } from "./cache";
8
7
  import { DBClient } from "./client";
@@ -10,6 +9,7 @@ import { type Logger, ConsoleLogger } from "./logger";
10
9
  import { QueryBuilder } from "./query-builder";
11
10
  import { Repository } from "./repository";
12
11
  import { runMigrations, generateMigration, type Migration } from "./migrations";
12
+ import { Hook } from "./hooks";
13
13
  import {
14
14
  Model,
15
15
  Column,
@@ -23,6 +23,7 @@ import {
23
23
  ModelKey,
24
24
  ColumnKey,
25
25
  ValidatorKey,
26
+ Versioned,
26
27
  RelationKey,
27
28
  SoftDeleteKey,
28
29
  } from "./decorators";
@@ -31,7 +32,7 @@ import {
31
32
  type CacheConfig,
32
33
  type LoggerConfig,
33
34
  DBType,
34
- DataTypes, // --- FIX: Import DataTypes here ---
35
+ DataTypes,
35
36
  StabilizeError,
36
37
  type PoolMetrics,
37
38
  type QueryHint,
@@ -159,6 +160,8 @@ export {
159
160
  Required,
160
161
  Unique,
161
162
  SoftDelete,
163
+ Versioned,
164
+ Hook,
162
165
  OneToOne,
163
166
  ManyToOne,
164
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";
10
- import { type DBConfig, type Migration, StabilizeError, DBType } from "./types";
9
+ import { ModelKey, ColumnKey, ValidatorKey, SoftDeleteKey, VersionedKey } from "./decorators";
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.
@@ -31,56 +30,99 @@ function formatQuery(query: string, dbType: DBType): string {
31
30
  }
32
31
 
33
32
  /**
34
- * @internal
35
- * Gets the database-specific SQL for an auto-incrementing primary key.
36
- * @param dbType The target database dialect.
37
- * @returns The SQL string for the primary key column definition.
33
+ * Maps an abstract data type (from DataTypes enum or a string) to the correct SQL type string
34
+ * for the specified database dialect (Postgres, MySQL, or SQLite).
35
+ *
36
+ * This function enables model definitions to be portable across different databases by
37
+ * converting each logical type to its proper SQL type in CREATE TABLE migrations.
38
+ *
39
+ * @param dt - The data type to map. Accepts either a value from the DataTypes enum, or a string
40
+ * (e.g., "string", "integer", "boolean", etc.).
41
+ * @param dbType - The target database dialect (DBType.Postgres, DBType.MySQL, or DBType.SQLite).
42
+ * @returns The SQL column type string appropriate for the database and logical type.
43
+ *
38
44
  */
39
- function getAutoIncrementPK(dbType: DBType): string {
40
- switch (dbType) {
41
- case DBType.Postgres:
42
- return "SERIAL PRIMARY KEY";
43
- case DBType.MySQL:
44
- return "INT AUTO_INCREMENT PRIMARY KEY";
45
- case DBType.SQLite:
46
- default:
47
- return "INTEGER PRIMARY KEY AUTOINCREMENT";
45
+ function mapDataTypeToSql(dt: DataTypes | string, dbType: DBType): string {
46
+ let type: string;
47
+ if (typeof dt === "string") {
48
+ type = dt.toLowerCase();
49
+ } else {
50
+ type = DataTypes[dt].toLowerCase();
48
51
  }
49
- }
50
52
 
51
- /**
52
- * @internal
53
- * Gets the database-specific SQL for a timestamp column.
54
- * @param dbType The target database dialect.
55
- * @returns The SQL string for the timestamp column type.
56
- */
57
- function getTimestampType(dbType: DBType): string {
58
- switch (dbType) {
59
- case DBType.Postgres:
60
- return "TIMESTAMP";
61
- case DBType.MySQL:
62
- return "DATETIME";
63
- case DBType.SQLite:
64
- default:
65
- return "TEXT";
53
+ if (dbType === DBType.Postgres) {
54
+ switch (type) {
55
+ case "string": return "TEXT";
56
+ case "text": return "TEXT";
57
+ case "integer": return "INTEGER";
58
+ case "bigint": return "BIGINT";
59
+ case "float": return "REAL";
60
+ case "double": return "DOUBLE PRECISION";
61
+ case "decimal": return "DECIMAL";
62
+ case "boolean": return "BOOLEAN";
63
+ case "date": return "DATE";
64
+ case "datetime": return "TIMESTAMP";
65
+ case "json": return "JSONB";
66
+ case "uuid": return "UUID";
67
+ case "blob": return "BYTEA";
68
+ default: return "TEXT";
69
+ }
66
70
  }
71
+ if (dbType === DBType.MySQL) {
72
+ switch (type) {
73
+ case "string": return "VARCHAR(255)";
74
+ case "text": return "TEXT";
75
+ case "integer": return "INT";
76
+ case "bigint": return "BIGINT";
77
+ case "float": return "FLOAT";
78
+ case "double": return "DOUBLE";
79
+ case "decimal": return "DECIMAL(10,2)";
80
+ case "boolean": return "TINYINT(1)";
81
+ case "date": return "DATE";
82
+ case "datetime": return "DATETIME";
83
+ case "json": return "JSON";
84
+ case "uuid": return "CHAR(36)";
85
+ case "blob": return "BLOB";
86
+ default: return "TEXT";
87
+ }
88
+ }
89
+ // SQLite
90
+ if (dbType === DBType.SQLite) {
91
+ switch (type) {
92
+ case "string": return "TEXT";
93
+ case "text": return "TEXT";
94
+ case "integer": return "INTEGER";
95
+ case "bigint": return "INTEGER";
96
+ case "float": return "REAL";
97
+ case "double": return "REAL";
98
+ case "decimal": return "NUMERIC";
99
+ case "boolean": return "INTEGER";
100
+ case "date": return "TEXT";
101
+ case "datetime": return "TEXT";
102
+ case "json": return "TEXT";
103
+ case "uuid": return "TEXT";
104
+ case "blob": return "BLOB";
105
+ default: return "TEXT";
106
+ }
107
+ }
108
+ return "TEXT";
67
109
  }
68
110
 
69
111
  /**
70
112
  * @internal
71
- * Gets the database-specific SQL for a default `CURRENT_TIMESTAMP` value.
113
+ * Gets the database-specific SQL for an auto-incrementing primary key.
72
114
  * @param dbType The target database dialect.
73
- * @returns The SQL string for the default value.
115
+ * @returns The SQL string for the primary key column definition.
74
116
  */
75
- function getTimestampDefault(dbType: DBType): string {
117
+ function getAutoIncrementPK(dbType: DBType): string {
76
118
  switch (dbType) {
77
119
  case DBType.Postgres:
78
- case DBType.SQLite:
79
- return "DEFAULT CURRENT_TIMESTAMP";
120
+ return "SERIAL PRIMARY KEY";
80
121
  case DBType.MySQL:
81
- return "DEFAULT CURRENT_TIMESTAMP";
122
+ return "INT AUTO_INCREMENT PRIMARY KEY";
123
+ case DBType.SQLite:
82
124
  default:
83
- return "DEFAULT CURRENT_TIMESTAMP";
125
+ return "INTEGER PRIMARY KEY AUTOINCREMENT";
84
126
  }
85
127
  }
86
128
 
@@ -88,6 +130,8 @@ function getTimestampDefault(dbType: DBType): string {
88
130
  * Generates SQL migration scripts (`up` and `down`) based on a model's decorators.
89
131
  * This function reads the metadata from a model class to create a `CREATE TABLE` statement.
90
132
  *
133
+ * If the model is versioned (has @Versioned), also generates a history table for time-travel queries.
134
+ *
91
135
  * @param model The model class decorated with `@Model` and `@Column`.
92
136
  * @param name A descriptive name for the migration (used for the migration object).
93
137
  * @param dbType The target database dialect to generate SQL for. Defaults to Postgres.
@@ -95,8 +139,8 @@ function getTimestampDefault(dbType: DBType): string {
95
139
  */
96
140
  export async function generateMigration(
97
141
  model: new (...args: any[]) => any,
98
- name: string,
99
- dbType: DBType = DBType.Postgres,
142
+ name: string,
143
+ dbType: DBType,
100
144
  ): Promise<Migration> {
101
145
  const tableName = Reflect.getMetadata(ModelKey, model);
102
146
  if (!tableName) {
@@ -105,19 +149,19 @@ export async function generateMigration(
105
149
 
106
150
  const columns: ColumnMetadata = Reflect.getMetadata(ColumnKey, model.prototype) || {};
107
151
  const validators: ValidatorMetadata = Reflect.getMetadata(ValidatorKey, model.prototype) || {};
108
- const softDeleteField = Reflect.getMetadata(SoftDeleteKey, model.prototype);
152
+ const versioned: boolean = !!Reflect.getMetadata(VersionedKey, model);
109
153
 
110
- const columnDefs = Object.entries(columns).map(([key, col]) => {
111
- if (col.name === "id") {
112
- return `id ${getAutoIncrementPK(dbType)}`;
113
- }
154
+ const columnDefs: string[] = [];
155
+
156
+ for (const [key, col] of Object.entries(columns)) {
157
+ const defParts: string[] = [];
114
158
 
115
- const defParts: string[] = [col.name];
116
-
117
- if (["createdAt", "updatedAt"].includes(key) || (softDeleteField && key === softDeleteField)) {
118
- defParts.push(getTimestampType(dbType));
159
+ if (col.name === "id") {
160
+ defParts.push("id");
161
+ defParts.push(getAutoIncrementPK(dbType));
119
162
  } else {
120
- defParts.push(col.type);
163
+ defParts.push(col.name);
164
+ defParts.push(mapDataTypeToSql(col.type, dbType));
121
165
  }
122
166
 
123
167
  if (validators[key]?.includes("required")) {
@@ -127,21 +171,54 @@ export async function generateMigration(
127
171
  defParts.push("UNIQUE");
128
172
  }
129
173
 
130
- if (key === "createdAt") {
131
- defParts.push(getTimestampDefault(dbType));
132
- }
174
+ columnDefs.push(defParts.join(" "));
175
+ }
133
176
 
134
- return defParts.join(" ");
135
- });
177
+ const up: string[] = [`CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`];
178
+ const down: string[] = [`DROP TABLE IF EXISTS ${tableName}`];
136
179
 
137
- if (softDeleteField && !columns[softDeleteField]) {
138
- columnDefs.push(`${softDeleteField} ${getTimestampType(dbType)}`);
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);
139
185
  }
140
186
 
141
- const up = [`CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`];
142
- const down = [`DROP TABLE IF EXISTS ${tableName}`];
187
+ return { up, down, name };
188
+ }
143
189
 
144
- return { up, down, name: tableName };
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
+ ];
145
222
  }
146
223
 
147
224
  /**
@@ -153,20 +230,20 @@ export async function generateMigration(
153
230
  function getMigrationsTableSQL(dbType: DBType): string {
154
231
  switch (dbType) {
155
232
  case DBType.Postgres:
156
- return `CREATE TABLE IF NOT EXISTS migrations (
233
+ return `CREATE TABLE IF NOT EXISTS stabilize_migrations (
157
234
  id SERIAL PRIMARY KEY,
158
235
  name VARCHAR(255) UNIQUE NOT NULL,
159
236
  applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
160
237
  )`;
161
238
  case DBType.MySQL:
162
- return `CREATE TABLE IF NOT EXISTS migrations (
239
+ return `CREATE TABLE IF NOT EXISTS stabilize_migrations (
163
240
  id INT AUTO_INCREMENT PRIMARY KEY,
164
241
  name VARCHAR(255) UNIQUE NOT NULL,
165
242
  applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
166
243
  )`;
167
244
  case DBType.SQLite:
168
245
  default:
169
- return `CREATE TABLE IF NOT EXISTS migrations (
246
+ return `CREATE TABLE IF NOT EXISTS stabilize_migrations (
170
247
  id INTEGER PRIMARY KEY AUTOINCREMENT,
171
248
  name TEXT UNIQUE NOT NULL,
172
249
  applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -190,8 +267,8 @@ export async function runMigrations(config: DBConfig, migrations: Migration[]) {
190
267
 
191
268
  for (const [index, migration] of migrations.entries()) {
192
269
  const name = migration.name || `migration_${index}_${new Date().getTime()}`;
193
-
194
- const selectQuery = formatQuery(`SELECT id FROM migrations WHERE name = ?`, dbType);
270
+
271
+ const selectQuery = formatQuery(`SELECT id FROM stabilize_migrations WHERE name = ?`, dbType);
195
272
  const applied = await client.query<{ id: number }>(selectQuery, [name]);
196
273
 
197
274
  if (applied.length === 0) {
@@ -200,17 +277,17 @@ export async function runMigrations(config: DBConfig, migrations: Migration[]) {
200
277
  for (const query of migration.up) {
201
278
  await txClient.query(query);
202
279
  }
203
-
204
- const insertQuery = formatQuery(`INSERT INTO migrations (name, applied_at) VALUES (?, ?)`, dbType);
280
+
281
+ const insertQuery = formatQuery(`INSERT INTO stabilize_migrations (name, applied_at) VALUES (?, ?)`, dbType);
205
282
  await txClient.query(insertQuery, [name, new Date().toISOString()]);
206
-
283
+
207
284
  console.log(`Migration ${name} applied successfully.`);
208
285
  });
209
286
  }
210
287
  }
211
288
  } catch (error) {
212
289
  console.error("Migration failed:", error);
213
- throw error;
290
+ throw error;
214
291
  } finally {
215
292
  await client.close();
216
293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stabilize-orm",
3
- "version": "1.1.7",
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",