stabilize-orm 1.0.5
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/.eslintrc.json +10 -0
- package/.github/workflows/ci-cd.yml +73 -0
- package/README.md +9 -0
- package/bun.lock +605 -0
- package/cli/stabilize-cli.ts +401 -0
- package/dist/cli/stabilize-cli.js +251 -0
- package/dist/index.js +181 -0
- package/examples/config/dataase.ts +8 -0
- package/examples/migrations/20251013_seed_history.ts +0 -0
- package/examples/models/Role.ts +11 -0
- package/examples/models/User.ts +22 -0
- package/examples/seeds/20251013_additional_seed.ts +29 -0
- package/examples/seeds/20251013_initial_seed.ts +33 -0
- package/package.json +59 -0
- package/src/LICENSE +21 -0
- package/src/cache.ts +110 -0
- package/src/client.ts +258 -0
- package/src/decorators.ts +99 -0
- package/src/index.ts +143 -0
- package/src/logger.ts +126 -0
- package/src/migrations.ts +81 -0
- package/src/query-builder.ts +96 -0
- package/src/repository.ts +565 -0
- package/src/types.ts +76 -0
- package/tests/migrations.test.ts +141 -0
- package/tsconfig.json +32 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Cache } from "./cache";
|
|
2
|
+
import { DBClient } from "./client";
|
|
3
|
+
import { type Logger, ConsoleLogger } from "./logger";
|
|
4
|
+
import { QueryBuilder } from "./query-builder";
|
|
5
|
+
import { Repository } from "./repository";
|
|
6
|
+
import { runMigrations, generateMigration, type Migration } from "./migrations";
|
|
7
|
+
import {
|
|
8
|
+
Model,
|
|
9
|
+
Column,
|
|
10
|
+
Required,
|
|
11
|
+
Unique,
|
|
12
|
+
SoftDelete,
|
|
13
|
+
OneToOne,
|
|
14
|
+
ManyToOne,
|
|
15
|
+
OneToMany,
|
|
16
|
+
ManyToMany,
|
|
17
|
+
ModelKey,
|
|
18
|
+
ColumnKey,
|
|
19
|
+
ValidatorKey,
|
|
20
|
+
RelationKey,
|
|
21
|
+
SoftDeleteKey,
|
|
22
|
+
} from "./decorators";
|
|
23
|
+
import {
|
|
24
|
+
type DBConfig,
|
|
25
|
+
type CacheConfig,
|
|
26
|
+
type LoggerConfig,
|
|
27
|
+
DBType,
|
|
28
|
+
StabilizeError,
|
|
29
|
+
type PoolMetrics,
|
|
30
|
+
type QueryHint,
|
|
31
|
+
RelationType,
|
|
32
|
+
type CacheStats,
|
|
33
|
+
LogLevel,
|
|
34
|
+
} from "./types";
|
|
35
|
+
|
|
36
|
+
export class Stabilize {
|
|
37
|
+
private client: DBClient;
|
|
38
|
+
private cache: Cache | null;
|
|
39
|
+
private logger: Logger;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
config: DBConfig,
|
|
43
|
+
cacheConfig: CacheConfig = { enabled: false, ttl: 60 },
|
|
44
|
+
loggerConfig: LoggerConfig = {},
|
|
45
|
+
) {
|
|
46
|
+
this.logger = new ConsoleLogger(loggerConfig);
|
|
47
|
+
this.client = new DBClient(config, this.logger);
|
|
48
|
+
this.cache = cacheConfig.enabled
|
|
49
|
+
? new Cache(cacheConfig, this.logger)
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getRepository<T>(model: new (...args: any[]) => T): Repository<T> {
|
|
54
|
+
if (this.cache) {
|
|
55
|
+
const cacheInstance = this.cache as any;
|
|
56
|
+
const repoCacheConfig: CacheConfig = {
|
|
57
|
+
enabled: true,
|
|
58
|
+
ttl: 60,
|
|
59
|
+
redisUrl: cacheInstance.redisUrl,
|
|
60
|
+
cachePrefix: cacheInstance.prefix,
|
|
61
|
+
strategy: this.cache.getStrategy(),
|
|
62
|
+
};
|
|
63
|
+
return new Repository(this.client, model, repoCacheConfig, this.logger);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return new Repository(
|
|
67
|
+
this.client,
|
|
68
|
+
model,
|
|
69
|
+
{ enabled: false, ttl: 60 },
|
|
70
|
+
this.logger,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async transaction<T>(callback: () => Promise<T>): Promise<T> {
|
|
75
|
+
return await this.client.transaction(callback);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async savepoint<T>(name: string, callback: () => Promise<T>): Promise<T> {
|
|
79
|
+
return await this.client.savepoint(name, callback);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async switchConnection(config: DBConfig) {
|
|
83
|
+
await this.client.switchConnection(config);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async getCacheStats(): Promise<CacheStats> {
|
|
87
|
+
return this.cache
|
|
88
|
+
? await this.cache.getStats()
|
|
89
|
+
: { hits: 0, misses: 0, keys: 0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getPoolMetrics(): PoolMetrics {
|
|
93
|
+
return this.client.getPoolMetrics();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async close() {
|
|
97
|
+
await this.client.close();
|
|
98
|
+
if (this.cache) await this.cache.disconnect();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export {
|
|
103
|
+
// Types
|
|
104
|
+
DBType,
|
|
105
|
+
LogLevel,
|
|
106
|
+
RelationType,
|
|
107
|
+
StabilizeError,
|
|
108
|
+
// Decorators
|
|
109
|
+
Model,
|
|
110
|
+
Column,
|
|
111
|
+
Required,
|
|
112
|
+
Unique,
|
|
113
|
+
SoftDelete,
|
|
114
|
+
OneToOne,
|
|
115
|
+
ManyToOne,
|
|
116
|
+
OneToMany,
|
|
117
|
+
ManyToMany,
|
|
118
|
+
ModelKey,
|
|
119
|
+
ColumnKey,
|
|
120
|
+
ValidatorKey,
|
|
121
|
+
RelationKey,
|
|
122
|
+
SoftDeleteKey,
|
|
123
|
+
// Classes
|
|
124
|
+
Cache,
|
|
125
|
+
DBClient,
|
|
126
|
+
QueryBuilder,
|
|
127
|
+
Repository,
|
|
128
|
+
ConsoleLogger,
|
|
129
|
+
// Migrations
|
|
130
|
+
runMigrations,
|
|
131
|
+
generateMigration,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type { Migration };
|
|
135
|
+
export type {
|
|
136
|
+
DBConfig,
|
|
137
|
+
CacheConfig,
|
|
138
|
+
LoggerConfig,
|
|
139
|
+
QueryHint,
|
|
140
|
+
PoolMetrics,
|
|
141
|
+
CacheStats,
|
|
142
|
+
Logger,
|
|
143
|
+
};
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/logger.ts
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import {
|
|
5
|
+
LogLevel,
|
|
6
|
+
type LoggerConfig,
|
|
7
|
+
type PoolMetrics,
|
|
8
|
+
StabilizeError,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
export interface Logger {
|
|
12
|
+
logQuery(query: string, params: any[], executionTime?: number): void;
|
|
13
|
+
logError(error: Error): void;
|
|
14
|
+
logMetrics(metrics: PoolMetrics): void;
|
|
15
|
+
logInfo(message: string): void;
|
|
16
|
+
logDebug(message: string): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ConsoleLogger implements Logger {
|
|
20
|
+
private level: LogLevel;
|
|
21
|
+
private filePath: string | null;
|
|
22
|
+
private maxFileSize: number;
|
|
23
|
+
private maxFiles: number;
|
|
24
|
+
private currentFileSize: number = 0;
|
|
25
|
+
|
|
26
|
+
constructor(config: LoggerConfig = {}) {
|
|
27
|
+
this.level = config.level || LogLevel.INFO;
|
|
28
|
+
this.filePath = config.filePath || null;
|
|
29
|
+
this.maxFileSize = config.maxFileSize || 1 * 1024 * 1024; // 1MB
|
|
30
|
+
this.maxFiles = config.maxFiles || 3;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private shouldLog(messageLevel: LogLevel): boolean {
|
|
34
|
+
const levels = [
|
|
35
|
+
LogLevel.ERROR,
|
|
36
|
+
LogLevel.WARN,
|
|
37
|
+
LogLevel.INFO,
|
|
38
|
+
LogLevel.DEBUG,
|
|
39
|
+
];
|
|
40
|
+
return levels.indexOf(messageLevel) <= levels.indexOf(this.level);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async rotateLogFile() {
|
|
44
|
+
if (!this.filePath) return;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const stats = await fs.stat(this.filePath).catch(() => null);
|
|
48
|
+
if (stats && stats.size >= this.maxFileSize) {
|
|
49
|
+
for (let i = this.maxFiles - 1; i > 0; i--) {
|
|
50
|
+
const oldPath = i === 1 ? this.filePath : `${this.filePath}.${i - 1}`;
|
|
51
|
+
const newPath = `${this.filePath}.${i}`;
|
|
52
|
+
if (await fs.stat(oldPath).catch(() => null)) {
|
|
53
|
+
await fs.rename(oldPath, newPath).catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await fs.writeFile(this.filePath, "");
|
|
57
|
+
this.currentFileSize = 0;
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error("Log rotation failed:", error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async writeToFile(message: string) {
|
|
65
|
+
if (!this.filePath) return;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await this.rotateLogFile();
|
|
69
|
+
const logEntry = `${new Date().toISOString()} ${message}\n`;
|
|
70
|
+
await fs.appendFile(this.filePath, logEntry);
|
|
71
|
+
this.currentFileSize += Buffer.byteLength(logEntry);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error("Failed to write to log file:", error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
logQuery(query: string, params: any[], executionTime?: number) {
|
|
78
|
+
if (this.shouldLog(LogLevel.DEBUG)) {
|
|
79
|
+
const message = `[DEBUG] Query: ${query} | Params: ${JSON.stringify(params)} | Time: ${executionTime ? `${executionTime.toFixed(2)}ms` : "N/A"}`;
|
|
80
|
+
console.log(message);
|
|
81
|
+
if (this.filePath) {
|
|
82
|
+
this.writeToFile(message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
logError(error: Error) {
|
|
88
|
+
if (this.shouldLog(LogLevel.ERROR)) {
|
|
89
|
+
const message = `[ERROR] ${error.message}${error.stack ? `\n${error.stack}` : ""}`;
|
|
90
|
+
console.error(message);
|
|
91
|
+
if (this.filePath) {
|
|
92
|
+
this.writeToFile(message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
logMetrics(metrics: PoolMetrics) {
|
|
98
|
+
if (this.shouldLog(LogLevel.INFO)) {
|
|
99
|
+
const message = `[INFO] Pool Metrics: Active=${metrics.activeConnections}, Idle=${metrics.idleConnections}, Total=${metrics.totalConnections}`;
|
|
100
|
+
console.log(message);
|
|
101
|
+
if (this.filePath) {
|
|
102
|
+
this.writeToFile(message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
logInfo(message: string) {
|
|
108
|
+
if (this.shouldLog(LogLevel.INFO)) {
|
|
109
|
+
const formatted = `[INFO] ${message}`;
|
|
110
|
+
console.log(formatted);
|
|
111
|
+
if (this.filePath) {
|
|
112
|
+
this.writeToFile(formatted);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
logDebug(message: string) {
|
|
118
|
+
if (this.shouldLog(LogLevel.DEBUG)) {
|
|
119
|
+
const formatted = `[DEBUG] ${message}`;
|
|
120
|
+
console.log(formatted);
|
|
121
|
+
if (this.filePath) {
|
|
122
|
+
this.writeToFile(formatted);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { DBClient } from "./client";
|
|
2
|
+
import { ModelKey, ColumnKey, ValidatorKey, SoftDeleteKey } from "./decorators";
|
|
3
|
+
import { type DBConfig, type Migration, StabilizeError } from "./types";
|
|
4
|
+
|
|
5
|
+
type ColumnData = { name: string; type: string };
|
|
6
|
+
type ColumnMetadata = Record<string, ColumnData>;
|
|
7
|
+
type ValidatorMetadata = Record<string, string[]>;
|
|
8
|
+
|
|
9
|
+
export async function generateMigration(
|
|
10
|
+
model: new (...args: any[]) => any,
|
|
11
|
+
name: string,
|
|
12
|
+
): Promise<Migration> {
|
|
13
|
+
const tableName = Reflect.getMetadata(ModelKey, model);
|
|
14
|
+
if (!tableName)
|
|
15
|
+
throw new StabilizeError(
|
|
16
|
+
"Model not decorated with @Model",
|
|
17
|
+
"MIGRATION_ERROR",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const columns: ColumnMetadata =
|
|
21
|
+
Reflect.getMetadata(ColumnKey, model.prototype) || {};
|
|
22
|
+
const validators: ValidatorMetadata =
|
|
23
|
+
Reflect.getMetadata(ValidatorKey, model.prototype) || {};
|
|
24
|
+
const softDeleteField = Reflect.getMetadata(SoftDeleteKey, model.prototype);
|
|
25
|
+
|
|
26
|
+
const columnDefs = Object.entries(columns).map(([key, col]) => {
|
|
27
|
+
let def = `${col.name} ${col.type}`;
|
|
28
|
+
if (col.name === "id") def += " PRIMARY KEY AUTOINCREMENT";
|
|
29
|
+
if (validators[key]?.includes("required")) def += " NOT NULL";
|
|
30
|
+
if (validators[key]?.includes("unique")) def += " UNIQUE";
|
|
31
|
+
return def;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (softDeleteField && columns[softDeleteField]) {
|
|
35
|
+
columnDefs.push(
|
|
36
|
+
`${columns[softDeleteField].name} ${columns[softDeleteField].type}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const up = [
|
|
41
|
+
`CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`,
|
|
42
|
+
];
|
|
43
|
+
const down = [`DROP TABLE IF EXISTS ${tableName}`];
|
|
44
|
+
|
|
45
|
+
return { up, down };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runMigrations(config: DBConfig, migrations: Migration[]) {
|
|
49
|
+
const client = new DBClient(config);
|
|
50
|
+
try {
|
|
51
|
+
await client.query(`
|
|
52
|
+
CREATE TABLE IF NOT EXISTS migrations (
|
|
53
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
54
|
+
name TEXT NOT NULL,
|
|
55
|
+
applied_at TEXT NOT NULL
|
|
56
|
+
)
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
for (const [index, migration] of migrations.entries()) {
|
|
60
|
+
const name = `migration_${index}_${new Date().toISOString().replace(/[-:T.]/g, "")}`;
|
|
61
|
+
const applied = await client.query<{ id: number }>(
|
|
62
|
+
`SELECT id FROM migrations WHERE name = ?`,
|
|
63
|
+
[name],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (applied.length === 0) {
|
|
67
|
+
for (const query of migration.up) {
|
|
68
|
+
await client.query(query, []);
|
|
69
|
+
}
|
|
70
|
+
await client.query(
|
|
71
|
+
`INSERT INTO migrations (name, applied_at) VALUES (?, ?)`,
|
|
72
|
+
[name, new Date().toISOString()],
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
await client.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type { Migration };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { DBClient } from "./client";
|
|
2
|
+
import { Cache } from "./cache";
|
|
3
|
+
import { type QueryHint } from "./types";
|
|
4
|
+
|
|
5
|
+
export class QueryBuilder<T> {
|
|
6
|
+
private table: string;
|
|
7
|
+
private selectFields: string[] = ["*"];
|
|
8
|
+
private joins: string[] = [];
|
|
9
|
+
private whereConditions: string[] = [];
|
|
10
|
+
private whereParams: any[] = [];
|
|
11
|
+
private orderByClause: string | null = null;
|
|
12
|
+
private limitValue: number | null = null;
|
|
13
|
+
private offsetValue: number | null = null;
|
|
14
|
+
private hints: QueryHint[] = [];
|
|
15
|
+
|
|
16
|
+
constructor(table: string) {
|
|
17
|
+
this.table = table;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
select(...fields: string[]): QueryBuilder<T> {
|
|
21
|
+
this.selectFields = fields.length > 0 ? fields : ["*"];
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
where(condition: string, ...params: any[]): QueryBuilder<T> {
|
|
26
|
+
this.whereConditions.push(condition);
|
|
27
|
+
this.whereParams.push(...params);
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
join(table: string, condition: string): QueryBuilder<T> {
|
|
32
|
+
this.joins.push(`LEFT JOIN ${table} ON ${condition}`);
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
orderBy(clause: string): QueryBuilder<T> {
|
|
37
|
+
this.orderByClause = clause;
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
limit(limit: number): QueryBuilder<T> {
|
|
42
|
+
this.limitValue = limit;
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
offset(offset: number): QueryBuilder<T> {
|
|
47
|
+
this.offsetValue = offset;
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
hint(hint: QueryHint): QueryBuilder<T> {
|
|
52
|
+
this.hints.push(hint);
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
build(): { query: string; params: any[] } {
|
|
57
|
+
let query = `SELECT ${this.selectFields.join(", ")} FROM ${this.table}`;
|
|
58
|
+
if (this.hints.length > 0) {
|
|
59
|
+
const hintStr = this.hints.map((h) => `${h.type}(${h.value})`).join(" ");
|
|
60
|
+
query = `SELECT ${hintStr} ${this.selectFields.join(", ")} FROM ${this.table}`;
|
|
61
|
+
}
|
|
62
|
+
if (this.joins.length > 0) {
|
|
63
|
+
query += " " + this.joins.join(" ");
|
|
64
|
+
}
|
|
65
|
+
if (this.whereConditions.length > 0) {
|
|
66
|
+
query += " WHERE " + this.whereConditions.join(" AND ");
|
|
67
|
+
}
|
|
68
|
+
if (this.orderByClause) {
|
|
69
|
+
query += ` ORDER BY ${this.orderByClause}`;
|
|
70
|
+
}
|
|
71
|
+
if (this.limitValue !== null) {
|
|
72
|
+
query += ` LIMIT ${this.limitValue}`;
|
|
73
|
+
}
|
|
74
|
+
if (this.offsetValue !== null) {
|
|
75
|
+
query += ` OFFSET ${this.offsetValue}`;
|
|
76
|
+
}
|
|
77
|
+
return { query, params: this.whereParams };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async execute(
|
|
81
|
+
client: DBClient,
|
|
82
|
+
cache?: Cache,
|
|
83
|
+
cacheKey?: string,
|
|
84
|
+
): Promise<T[]> {
|
|
85
|
+
const { query, params } = this.build();
|
|
86
|
+
if (cache && cacheKey) {
|
|
87
|
+
const cached = await cache.get<T[]>(cacheKey);
|
|
88
|
+
if (cached) return cached;
|
|
89
|
+
}
|
|
90
|
+
const results = await client.query<T>(query, params);
|
|
91
|
+
if (cache && cacheKey && results.length > 0) {
|
|
92
|
+
await cache.set(cacheKey, results);
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
}
|