@martel/calyx 1.8.0 → 1.9.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/CHANGELOG.md +8 -0
- package/README.md +71 -27
- package/benchmarks/graphql-benchmark.ts +81 -0
- package/benchmarks/index.ts +32 -0
- package/benchmarks/openapi-benchmark.ts +168 -0
- package/benchmarks/serialization-benchmark.ts +52 -0
- package/benchmarks/techniques-benchmark.ts +84 -0
- package/benchmarks/validation-benchmark.ts +74 -0
- package/bun.lock +11 -0
- package/package.json +7 -6
- package/src/cli/index.ts +19 -3
- package/src/compression/compression.middleware.ts +7 -0
- package/src/cookies/cookies.ts +69 -0
- package/src/database/mongoose.module.ts +250 -0
- package/src/database/typeorm.module.ts +276 -0
- package/src/file-upload/file-upload.interceptor.ts +93 -0
- package/src/file-upload/index.ts +1 -0
- package/src/graphql/decorators.ts +70 -0
- package/src/graphql/graphql.module.ts +197 -47
- package/src/http/application.ts +330 -70
- package/src/http-client/http-client.module.ts +124 -0
- package/src/http-client/index.ts +1 -0
- package/src/index.ts +14 -0
- package/src/logger/index.ts +1 -0
- package/src/logger/logger.service.ts +118 -0
- package/src/mvc/index.ts +1 -0
- package/src/mvc/mvc.ts +22 -0
- package/src/openapi/decorators.ts +154 -0
- package/src/openapi/swagger.module.ts +172 -20
- package/src/queue/queue.module.ts +174 -0
- package/src/session/index.ts +1 -0
- package/src/session/session.middleware.ts +82 -0
- package/src/sse/index.ts +1 -0
- package/src/sse/sse.ts +18 -0
- package/src/streaming/index.ts +1 -0
- package/src/streaming/streamable-file.ts +32 -0
- package/src/validation/pipe.ts +79 -10
- package/src/versioning/versioning.ts +46 -0
- package/tests/graphql.test.ts +68 -4
- package/tests/openapi.test.ts +78 -11
- package/tests/techniques.test.ts +471 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@martel/calyx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "High-performance Bun-native NestJS-compatible framework",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"bin": {
|
|
@@ -19,15 +19,16 @@
|
|
|
19
19
|
"@nestjs/common": "^11.1.27",
|
|
20
20
|
"@nestjs/core": "^11.1.27",
|
|
21
21
|
"@nestjs/platform-express": "^11.1.27",
|
|
22
|
+
"@nestjs/swagger": "^11.4.5",
|
|
23
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
24
|
+
"@semantic-release/git": "^10.0.1",
|
|
25
|
+
"@semantic-release/github": "^12.0.0",
|
|
26
|
+
"@semantic-release/npm": "^13.0.0",
|
|
22
27
|
"@types/autocannon": "^7.12.7",
|
|
23
28
|
"@types/bun": "latest",
|
|
24
29
|
"autocannon": "^8.0.0",
|
|
25
30
|
"rxjs": "^7.8.2",
|
|
26
|
-
"semantic-release": "^25.0.0"
|
|
27
|
-
"@semantic-release/changelog": "^6.0.3",
|
|
28
|
-
"@semantic-release/git": "^10.0.1",
|
|
29
|
-
"@semantic-release/github": "^12.0.0",
|
|
30
|
-
"@semantic-release/npm": "^13.0.0"
|
|
31
|
+
"semantic-release": "^25.0.0"
|
|
31
32
|
},
|
|
32
33
|
"publishConfig": {
|
|
33
34
|
"access": "public",
|
package/src/cli/index.ts
CHANGED
|
@@ -85,7 +85,20 @@ function runBuild(cmdArgs: string[]) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
console.log('Building Calyx application using bun build...');
|
|
88
|
-
const proc = spawnSync('bun', [
|
|
88
|
+
const proc = spawnSync('bun', [
|
|
89
|
+
'build',
|
|
90
|
+
mainPath,
|
|
91
|
+
'--outdir',
|
|
92
|
+
'./dist',
|
|
93
|
+
'--target',
|
|
94
|
+
'bun',
|
|
95
|
+
'--external',
|
|
96
|
+
'mongoose',
|
|
97
|
+
'--external',
|
|
98
|
+
'typeorm',
|
|
99
|
+
'--external',
|
|
100
|
+
'graphql'
|
|
101
|
+
], { stdio: 'inherit' });
|
|
89
102
|
if (proc.status === 0) {
|
|
90
103
|
console.log('Build completed successfully. Output at ./dist/main.js');
|
|
91
104
|
}
|
|
@@ -108,11 +121,14 @@ function runNew(name: string) {
|
|
|
108
121
|
mkdirSync(name, { recursive: true });
|
|
109
122
|
mkdirSync(join(name, 'src'), { recursive: true });
|
|
110
123
|
|
|
111
|
-
|
|
124
|
+
let isDevMode = process.env.CALYX_DEV === 'true';
|
|
125
|
+
const selfPkgPath = join(import.meta.dir, '../../package.json');
|
|
126
|
+
if (existsSync(selfPkgPath)) {
|
|
127
|
+
isDevMode = true; // Auto-detect workspace dev environment
|
|
128
|
+
}
|
|
112
129
|
|
|
113
130
|
let packageVersion = '0.1.0';
|
|
114
131
|
try {
|
|
115
|
-
const selfPkgPath = join(import.meta.dir, '../../package.json');
|
|
116
132
|
if (existsSync(selfPkgPath)) {
|
|
117
133
|
const selfPkg = JSON.parse(readFileSync(selfPkgPath, 'utf-8'));
|
|
118
134
|
if (selfPkg.version) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createParamDecorator } from '../http/decorators.ts';
|
|
2
|
+
|
|
3
|
+
export interface CookieOptions {
|
|
4
|
+
maxAge?: number;
|
|
5
|
+
expires?: Date;
|
|
6
|
+
httpOnly?: boolean;
|
|
7
|
+
secure?: boolean;
|
|
8
|
+
path?: string;
|
|
9
|
+
domain?: string;
|
|
10
|
+
sameSite?: 'lax' | 'strict' | 'none' | boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseCookies(cookieHeader: string): Record<string, string> {
|
|
14
|
+
const cookies: Record<string, string> = {};
|
|
15
|
+
if (!cookieHeader) return cookies;
|
|
16
|
+
const parts = cookieHeader.split(';');
|
|
17
|
+
for (let i = 0; i < parts.length; i++) {
|
|
18
|
+
const part = parts[i];
|
|
19
|
+
const eqIdx = part.indexOf('=');
|
|
20
|
+
if (eqIdx !== -1) {
|
|
21
|
+
const key = part.substring(0, eqIdx).trim();
|
|
22
|
+
const val = part.substring(eqIdx + 1).trim();
|
|
23
|
+
cookies[key] = decodeURIComponent(val);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return cookies;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatCookie(name: string, value: string, options: CookieOptions = {}): string {
|
|
30
|
+
let str = `${name}=${encodeURIComponent(value)}`;
|
|
31
|
+
if (options.maxAge !== undefined) {
|
|
32
|
+
str += `; Max-Age=${options.maxAge}`;
|
|
33
|
+
}
|
|
34
|
+
if (options.expires !== undefined) {
|
|
35
|
+
str += `; Expires=${options.expires.toUTCString()}`;
|
|
36
|
+
}
|
|
37
|
+
if (options.path !== undefined) {
|
|
38
|
+
str += `; Path=${options.path}`;
|
|
39
|
+
} else {
|
|
40
|
+
str += `; Path=/`;
|
|
41
|
+
}
|
|
42
|
+
if (options.domain !== undefined) {
|
|
43
|
+
str += `; Domain=${options.domain}`;
|
|
44
|
+
}
|
|
45
|
+
if (options.secure) {
|
|
46
|
+
str += `; Secure`;
|
|
47
|
+
}
|
|
48
|
+
if (options.httpOnly) {
|
|
49
|
+
str += `; HttpOnly`;
|
|
50
|
+
}
|
|
51
|
+
if (options.sameSite !== undefined) {
|
|
52
|
+
if (options.sameSite === true) {
|
|
53
|
+
str += `; SameSite=Strict`;
|
|
54
|
+
} else if (options.sameSite === 'lax') {
|
|
55
|
+
str += `; SameSite=Lax`;
|
|
56
|
+
} else if (options.sameSite === 'strict') {
|
|
57
|
+
str += `; SameSite=Strict`;
|
|
58
|
+
} else if (options.sameSite === 'none') {
|
|
59
|
+
str += `; SameSite=None`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return str;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const Cookies = createParamDecorator((data: string | undefined, ctx) => {
|
|
66
|
+
const req = ctx.switchToHttp().getRequest();
|
|
67
|
+
const cookies = (req as any).cookies || {};
|
|
68
|
+
return data ? cookies[data] : cookies;
|
|
69
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { Module, DynamicModule, Inject } from '../core/decorators.ts';
|
|
3
|
+
import { ConnectionManager } from './typeorm.module.ts';
|
|
4
|
+
|
|
5
|
+
// Dynamic detection of Mongoose availability
|
|
6
|
+
let isMongooseAvailable = false;
|
|
7
|
+
try {
|
|
8
|
+
require.resolve('mongoose');
|
|
9
|
+
isMongooseAvailable = true;
|
|
10
|
+
} catch {
|
|
11
|
+
// ignore
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class QueryPromise<T> {
|
|
15
|
+
constructor(private readonly promise: Promise<T>) {}
|
|
16
|
+
then(onfulfilled?: any, onrejected?: any) {
|
|
17
|
+
return this.promise.then(onfulfilled, onrejected);
|
|
18
|
+
}
|
|
19
|
+
catch(onrejected?: any) {
|
|
20
|
+
return this.promise.catch(onrejected);
|
|
21
|
+
}
|
|
22
|
+
finally(onfn?: any) {
|
|
23
|
+
return this.promise.finally(onfn);
|
|
24
|
+
}
|
|
25
|
+
exec(): Promise<T> {
|
|
26
|
+
return this.promise;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Native Sqlite-backed document store model fallback
|
|
31
|
+
class NativeSqliteModel<T extends Record<string, any>> {
|
|
32
|
+
private tableName: string;
|
|
33
|
+
|
|
34
|
+
constructor(private readonly db: Database, private readonly modelName: string) {
|
|
35
|
+
this.tableName = `mongo_${modelName.toLowerCase()}`;
|
|
36
|
+
this.db.run(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
data TEXT
|
|
40
|
+
)
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private toDoc(row: any): T | null {
|
|
45
|
+
if (!row) return null;
|
|
46
|
+
try {
|
|
47
|
+
const obj = JSON.parse(row.data);
|
|
48
|
+
obj._id = row.id;
|
|
49
|
+
return obj as T;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async create(doc: Partial<T> | Partial<T>[]): Promise<any> {
|
|
56
|
+
const docs = Array.isArray(doc) ? doc : [doc];
|
|
57
|
+
const created: T[] = [];
|
|
58
|
+
for (const d of docs) {
|
|
59
|
+
const dataCopy = { ...d };
|
|
60
|
+
delete dataCopy._id;
|
|
61
|
+
const jsonStr = JSON.stringify(dataCopy);
|
|
62
|
+
const result = this.db.query(`INSERT INTO ${this.tableName} (data) VALUES ($data) RETURNING id`).get({
|
|
63
|
+
$data: jsonStr,
|
|
64
|
+
}) as any;
|
|
65
|
+
created.push({ ...d, _id: result.id } as unknown as T);
|
|
66
|
+
}
|
|
67
|
+
return Array.isArray(doc) ? created : created[0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async find(conditions: Partial<T> = {}): Promise<T[]> {
|
|
71
|
+
if (Object.keys(conditions).length === 0) {
|
|
72
|
+
const rows = this.db.query(`SELECT id, data FROM ${this.tableName}`).all() as any[];
|
|
73
|
+
return rows.map((r) => this.toDoc(r)).filter(Boolean) as T[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const conds: string[] = [];
|
|
77
|
+
const params: Record<string, any> = {};
|
|
78
|
+
|
|
79
|
+
for (const [key, val] of Object.entries(conditions)) {
|
|
80
|
+
if (key === '_id') {
|
|
81
|
+
conds.push(`id = $id`);
|
|
82
|
+
params['$id'] = val;
|
|
83
|
+
} else {
|
|
84
|
+
conds.push(`json_extract(data, '$.${key}') = $${key}`);
|
|
85
|
+
params[`$${key}`] = val;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sql = `SELECT id, data FROM ${this.tableName} WHERE ${conds.join(' AND ')}`;
|
|
90
|
+
const rows = this.db.query(sql).all(params) as any[];
|
|
91
|
+
return rows.map((r) => this.toDoc(r)).filter(Boolean) as T[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async findOne(conditions: Partial<T> = {}): Promise<T | null> {
|
|
95
|
+
const list = await this.find(conditions);
|
|
96
|
+
return list.length > 0 ? list[0] : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async updateOne(conditions: Partial<T>, update: Partial<T>): Promise<any> {
|
|
100
|
+
const doc = await this.findOne(conditions);
|
|
101
|
+
if (!doc) return { matchedCount: 0, modifiedCount: 0 };
|
|
102
|
+
|
|
103
|
+
const updatedData = { ...doc, ...update };
|
|
104
|
+
const id = doc._id;
|
|
105
|
+
delete updatedData._id;
|
|
106
|
+
|
|
107
|
+
this.db.query(`UPDATE ${this.tableName} SET data = $data WHERE id = $id`).run({
|
|
108
|
+
$data: JSON.stringify(updatedData),
|
|
109
|
+
$id: id,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return { matchedCount: 1, modifiedCount: 1 };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async deleteOne(conditions: Partial<T>): Promise<any> {
|
|
116
|
+
const doc = await this.findOne(conditions);
|
|
117
|
+
if (!doc) return { deletedCount: 0 };
|
|
118
|
+
|
|
119
|
+
this.db.query(`DELETE FROM ${this.tableName} WHERE id = $id`).run({ $id: doc._id });
|
|
120
|
+
return { deletedCount: 1 };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Unified Mongoose Model wrapper
|
|
125
|
+
export class Model<T extends Record<string, any>> {
|
|
126
|
+
private modelPromise: Promise<any>;
|
|
127
|
+
|
|
128
|
+
constructor(
|
|
129
|
+
connOrDbPromise: Promise<any>,
|
|
130
|
+
private readonly modelName: string,
|
|
131
|
+
private readonly schema: any,
|
|
132
|
+
private readonly isNative: boolean
|
|
133
|
+
) {
|
|
134
|
+
if (isNative) {
|
|
135
|
+
this.modelPromise = connOrDbPromise.then((db) => new NativeSqliteModel(db, modelName));
|
|
136
|
+
} else {
|
|
137
|
+
this.modelPromise = connOrDbPromise.then((conn) => {
|
|
138
|
+
// Compile schema/model using real mongoose connection
|
|
139
|
+
return conn.models[modelName] || conn.model(modelName, schema || {});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
create(doc: Partial<T> | Partial<T>[]): QueryPromise<any> {
|
|
145
|
+
const promise = this.modelPromise.then((model) => model.create(doc));
|
|
146
|
+
return new QueryPromise(promise);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
find(conditions: Partial<T> = {}): QueryPromise<T[]> {
|
|
150
|
+
const promise = this.modelPromise.then(async (model) => {
|
|
151
|
+
const q = model.find(conditions);
|
|
152
|
+
return typeof q.exec === 'function' ? await q.exec() : await q;
|
|
153
|
+
});
|
|
154
|
+
return new QueryPromise(promise);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
findOne(conditions: Partial<T> = {}): QueryPromise<T | null> {
|
|
158
|
+
const promise = this.modelPromise.then(async (model) => {
|
|
159
|
+
const q = model.findOne(conditions);
|
|
160
|
+
return typeof q.exec === 'function' ? await q.exec() : await q;
|
|
161
|
+
});
|
|
162
|
+
return new QueryPromise(promise);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
updateOne(conditions: Partial<T>, update: Partial<T>): QueryPromise<any> {
|
|
166
|
+
const promise = this.modelPromise.then(async (model) => {
|
|
167
|
+
const q = model.updateOne(conditions, update);
|
|
168
|
+
return typeof q.exec === 'function' ? await q.exec() : await q;
|
|
169
|
+
});
|
|
170
|
+
return new QueryPromise(promise);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
deleteOne(conditions: Partial<T>): QueryPromise<any> {
|
|
174
|
+
const promise = this.modelPromise.then(async (model) => {
|
|
175
|
+
const q = model.deleteOne(conditions);
|
|
176
|
+
return typeof q.exec === 'function' ? await q.exec() : await q;
|
|
177
|
+
});
|
|
178
|
+
return new QueryPromise(promise);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function InjectModel(name: string): ParameterDecorator & PropertyDecorator {
|
|
183
|
+
return Inject(`Model_${name}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@Module({})
|
|
187
|
+
export class MongooseModule {
|
|
188
|
+
static forRoot(uri: string, options: any = {}): DynamicModule {
|
|
189
|
+
const isUsingMongoose = isMongooseAvailable && !options.useNativeFallback;
|
|
190
|
+
|
|
191
|
+
let connOrDbPromise: Promise<any>;
|
|
192
|
+
if (isUsingMongoose) {
|
|
193
|
+
connOrDbPromise = (async () => {
|
|
194
|
+
const mongoose = await import('mongoose');
|
|
195
|
+
const conn = mongoose.createConnection(uri, options);
|
|
196
|
+
// Wait for connection to open
|
|
197
|
+
await new Promise((resolve, reject) => {
|
|
198
|
+
conn.once('open', resolve);
|
|
199
|
+
conn.once('error', reject);
|
|
200
|
+
});
|
|
201
|
+
return conn;
|
|
202
|
+
})();
|
|
203
|
+
} else {
|
|
204
|
+
let dbPath = ':memory:';
|
|
205
|
+
if (uri.startsWith('mongodb://') || uri.startsWith('mongodb+srv://')) {
|
|
206
|
+
const match = uri.match(/\/([a-zA-Z0-9_-]+)(?:\?|$)/);
|
|
207
|
+
if (match && match[1]) {
|
|
208
|
+
dbPath = `${match[1]}.db`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
connOrDbPromise = Promise.resolve(ConnectionManager.getOrCreate(dbPath));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
module: MongooseModule,
|
|
216
|
+
providers: [
|
|
217
|
+
{
|
|
218
|
+
provide: 'Calyx_Mongo_Connection',
|
|
219
|
+
useValue: connOrDbPromise,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
provide: 'Calyx_Mongo_IsNative',
|
|
223
|
+
useValue: !isUsingMongoose,
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
exports: ['Calyx_Mongo_Connection', 'Calyx_Mongo_IsNative'],
|
|
227
|
+
global: true,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
static forFeature(models: { name: string; schema?: any }[] = []): DynamicModule {
|
|
232
|
+
const providers = models.map((m) => {
|
|
233
|
+
return {
|
|
234
|
+
provide: `Model_${m.name}`,
|
|
235
|
+
useFactory: (connOrDbPromise: Promise<any>, isNative: boolean) => {
|
|
236
|
+
const resolvedPromise = connOrDbPromise ?? Promise.resolve(ConnectionManager.getOrCreate());
|
|
237
|
+
const resolvedIsNative = isNative !== undefined ? isNative : true;
|
|
238
|
+
return new Model(resolvedPromise, m.name, m.schema, resolvedIsNative);
|
|
239
|
+
},
|
|
240
|
+
inject: ['Calyx_Mongo_Connection', 'Calyx_Mongo_IsNative'],
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
module: MongooseModule,
|
|
246
|
+
providers,
|
|
247
|
+
exports: models.map((m) => `Model_${m.name}`),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { Module, DynamicModule, Inject } from '../core/decorators.ts';
|
|
3
|
+
|
|
4
|
+
// Dynamic detection of TypeORM availability
|
|
5
|
+
let isTypeormAvailable = false;
|
|
6
|
+
try {
|
|
7
|
+
// We check if typeorm package is in path
|
|
8
|
+
require.resolve('typeorm');
|
|
9
|
+
isTypeormAvailable = true;
|
|
10
|
+
} catch {
|
|
11
|
+
// ignore
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Connection manager for Native SQLite fallback
|
|
15
|
+
export class ConnectionManager {
|
|
16
|
+
private static db: Database | null = null;
|
|
17
|
+
|
|
18
|
+
static getOrCreate(databasePath = ':memory:'): Database {
|
|
19
|
+
if (!this.db) {
|
|
20
|
+
this.db = new Database(databasePath);
|
|
21
|
+
}
|
|
22
|
+
return this.db;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static close() {
|
|
26
|
+
if (this.db) {
|
|
27
|
+
this.db.close();
|
|
28
|
+
this.db = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Native Sqlite Repository fallback
|
|
34
|
+
class NativeSqliteRepository<Entity extends Record<string, any>> {
|
|
35
|
+
private tableName: string;
|
|
36
|
+
|
|
37
|
+
constructor(private readonly db: Database, private readonly entityClass: any) {
|
|
38
|
+
this.tableName = entityClass.name.toLowerCase();
|
|
39
|
+
this.db.run(`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
data TEXT
|
|
43
|
+
)
|
|
44
|
+
`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private toEntity(row: any): Entity | null {
|
|
48
|
+
if (!row) return null;
|
|
49
|
+
try {
|
|
50
|
+
const obj = JSON.parse(row.data);
|
|
51
|
+
obj.id = row.id;
|
|
52
|
+
const instance = Object.create(this.entityClass.prototype);
|
|
53
|
+
Object.assign(instance, obj);
|
|
54
|
+
return instance;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async save(entity: Entity | Entity[]): Promise<any> {
|
|
61
|
+
const entities = Array.isArray(entity) ? entity : [entity];
|
|
62
|
+
const saved: Entity[] = [];
|
|
63
|
+
|
|
64
|
+
for (const ent of entities) {
|
|
65
|
+
const dataCopy = { ...ent };
|
|
66
|
+
const id = dataCopy.id;
|
|
67
|
+
delete dataCopy.id;
|
|
68
|
+
|
|
69
|
+
const jsonStr = JSON.stringify(dataCopy);
|
|
70
|
+
|
|
71
|
+
if (id !== undefined && id !== null) {
|
|
72
|
+
this.db.query(`UPDATE ${this.tableName} SET data = $data WHERE id = $id`).run({
|
|
73
|
+
$data: jsonStr,
|
|
74
|
+
$id: id,
|
|
75
|
+
});
|
|
76
|
+
saved.push(ent);
|
|
77
|
+
} else {
|
|
78
|
+
const result = this.db.query(`INSERT INTO ${this.tableName} (data) VALUES ($data) RETURNING id`).get({
|
|
79
|
+
$data: jsonStr,
|
|
80
|
+
}) as any;
|
|
81
|
+
const newEntity = Object.create(this.entityClass.prototype);
|
|
82
|
+
Object.assign(newEntity, ent, { id: result.id });
|
|
83
|
+
saved.push(newEntity);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return Array.isArray(entity) ? saved : saved[0];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async find(options?: { where?: Partial<Entity> }): Promise<Entity[]> {
|
|
91
|
+
const where = options?.where;
|
|
92
|
+
if (!where || Object.keys(where).length === 0) {
|
|
93
|
+
const rows = this.db.query(`SELECT id, data FROM ${this.tableName}`).all() as any[];
|
|
94
|
+
return rows.map((r) => this.toEntity(r)).filter(Boolean) as Entity[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const conditions: string[] = [];
|
|
98
|
+
const params: Record<string, any> = {};
|
|
99
|
+
|
|
100
|
+
for (const [key, val] of Object.entries(where)) {
|
|
101
|
+
if (key === 'id') {
|
|
102
|
+
conditions.push(`id = $id`);
|
|
103
|
+
params['$id'] = val;
|
|
104
|
+
} else {
|
|
105
|
+
conditions.push(`json_extract(data, '$.${key}') = $${key}`);
|
|
106
|
+
params[`$${key}`] = typeof val === 'object' ? JSON.stringify(val) : val;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sql = `SELECT id, data FROM ${this.tableName} WHERE ${conditions.join(' AND ')}`;
|
|
111
|
+
const rows = this.db.query(sql).all(params) as any[];
|
|
112
|
+
return rows.map((r) => this.toEntity(r)).filter(Boolean) as Entity[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async findOne(options: { where: Partial<Entity> }): Promise<Entity | null> {
|
|
116
|
+
const results = await this.find(options);
|
|
117
|
+
return results.length > 0 ? results[0] : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async delete(conditions: Partial<Entity> | number | string): Promise<any> {
|
|
121
|
+
if (typeof conditions === 'number' || typeof conditions === 'string') {
|
|
122
|
+
this.db.query(`DELETE FROM ${this.tableName} WHERE id = $id`).run({ $id: Number(conditions) });
|
|
123
|
+
return { affected: 1 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const where = conditions as Record<string, any>;
|
|
127
|
+
if (Object.keys(where).length === 0) {
|
|
128
|
+
this.db.run(`DELETE FROM ${this.tableName}`);
|
|
129
|
+
return { affected: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const conds: string[] = [];
|
|
133
|
+
const params: Record<string, any> = {};
|
|
134
|
+
|
|
135
|
+
for (const [key, val] of Object.entries(where)) {
|
|
136
|
+
if (key === 'id') {
|
|
137
|
+
conds.push(`id = $id`);
|
|
138
|
+
params['$id'] = val;
|
|
139
|
+
} else {
|
|
140
|
+
conds.push(`json_extract(data, '$.${key}') = $${key}`);
|
|
141
|
+
params[`$${key}`] = val;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sql = `DELETE FROM ${this.tableName} WHERE ${conds.join(' AND ')}`;
|
|
146
|
+
this.db.query(sql).run(params);
|
|
147
|
+
return { affected: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async update(conditions: Partial<Entity> | number | string, value: Partial<Entity>): Promise<any> {
|
|
151
|
+
const id = typeof conditions === 'number' || typeof conditions === 'string' ? Number(conditions) : conditions.id;
|
|
152
|
+
|
|
153
|
+
if (id !== undefined) {
|
|
154
|
+
const existing = await this.findOne({ where: { id } as any });
|
|
155
|
+
if (existing) {
|
|
156
|
+
const updatedData = { ...existing, ...value };
|
|
157
|
+
await this.save(updatedData);
|
|
158
|
+
}
|
|
159
|
+
return { affected: 1 };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const matches = await this.find({ where: conditions as Partial<Entity> });
|
|
163
|
+
for (const match of matches) {
|
|
164
|
+
const updatedData = { ...match, ...value };
|
|
165
|
+
await this.save(updatedData);
|
|
166
|
+
}
|
|
167
|
+
return { affected: matches.length };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Unified Repository Wrapper
|
|
172
|
+
export class Repository<Entity extends Record<string, any>> {
|
|
173
|
+
private repoPromise: Promise<any>;
|
|
174
|
+
|
|
175
|
+
constructor(dbOrDs: any, private readonly entityClass: any, private readonly isNative?: boolean) {
|
|
176
|
+
const promise = dbOrDs instanceof Promise ? dbOrDs : Promise.resolve(dbOrDs);
|
|
177
|
+
const native = isNative ?? true;
|
|
178
|
+
if (native) {
|
|
179
|
+
this.repoPromise = promise.then((db) => new NativeSqliteRepository(db, entityClass));
|
|
180
|
+
} else {
|
|
181
|
+
this.repoPromise = promise.then((ds) => ds.getRepository(entityClass));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async save(entity: Entity | Entity[]): Promise<any> {
|
|
186
|
+
const repo = await this.repoPromise;
|
|
187
|
+
return await repo.save(entity);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async find(options?: any): Promise<Entity[]> {
|
|
191
|
+
const repo = await this.repoPromise;
|
|
192
|
+
return await repo.find(options);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async findOne(options: any): Promise<Entity | null> {
|
|
196
|
+
const repo = await this.repoPromise;
|
|
197
|
+
return await repo.findOne(options);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async delete(conditions: any): Promise<any> {
|
|
201
|
+
const repo = await this.repoPromise;
|
|
202
|
+
return await repo.delete(conditions);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async update(conditions: any, value: any): Promise<any> {
|
|
206
|
+
const repo = await this.repoPromise;
|
|
207
|
+
return await repo.update(conditions, value);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function InjectRepository(entity: any): ParameterDecorator & PropertyDecorator {
|
|
212
|
+
return Inject(`Repository_${entity.name}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface TypeOrmModuleOptions {
|
|
216
|
+
type?: string;
|
|
217
|
+
database?: string;
|
|
218
|
+
entities?: any[];
|
|
219
|
+
synchronize?: boolean;
|
|
220
|
+
[key: string]: any;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@Module({})
|
|
224
|
+
export class TypeOrmModule {
|
|
225
|
+
static forRoot(options: TypeOrmModuleOptions = {}): DynamicModule {
|
|
226
|
+
const isUsingTypeorm = isTypeormAvailable && options.type !== 'sqlite-native';
|
|
227
|
+
|
|
228
|
+
let dbOrDsPromise: Promise<any>;
|
|
229
|
+
if (isUsingTypeorm) {
|
|
230
|
+
dbOrDsPromise = (async () => {
|
|
231
|
+
const { DataSource } = await import('typeorm');
|
|
232
|
+
const ds = new DataSource(options as any);
|
|
233
|
+
await ds.initialize();
|
|
234
|
+
return ds;
|
|
235
|
+
})();
|
|
236
|
+
} else {
|
|
237
|
+
dbOrDsPromise = Promise.resolve(ConnectionManager.getOrCreate(options.database));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
module: TypeOrmModule,
|
|
242
|
+
providers: [
|
|
243
|
+
{
|
|
244
|
+
provide: 'Calyx_Database_Connection',
|
|
245
|
+
useValue: dbOrDsPromise,
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
provide: 'Calyx_Database_IsNative',
|
|
249
|
+
useValue: !isUsingTypeorm,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
exports: ['Calyx_Database_Connection', 'Calyx_Database_IsNative'],
|
|
253
|
+
global: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
static forFeature(entities: any[] = []): DynamicModule {
|
|
258
|
+
const providers = entities.map((entity) => {
|
|
259
|
+
return {
|
|
260
|
+
provide: `Repository_${entity.name}`,
|
|
261
|
+
useFactory: (dbOrDsPromise: Promise<any>, isNative: boolean) => {
|
|
262
|
+
const resolvedPromise = dbOrDsPromise ?? Promise.resolve(ConnectionManager.getOrCreate());
|
|
263
|
+
const resolvedIsNative = isNative !== undefined ? isNative : true;
|
|
264
|
+
return new Repository(resolvedPromise, entity, resolvedIsNative);
|
|
265
|
+
},
|
|
266
|
+
inject: ['Calyx_Database_Connection', 'Calyx_Database_IsNative'],
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
module: TypeOrmModule,
|
|
272
|
+
providers,
|
|
273
|
+
exports: entities.map((entity) => `Repository_${entity.name}`),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|