thyscript 1.0.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/bin/thy-node.js +332 -0
- package/bin/thy.js +828 -0
- package/bin/thys.js +25 -0
- package/examples/calculator.thy +274 -0
- package/examples/calculator.thy.js +628 -0
- package/examples/hello.thy +34 -0
- package/examples/login.thy +382 -0
- package/examples/login.thy.js +710 -0
- package/package.json +54 -0
- package/scripts/_class.js +18 -0
- package/scripts/_class2.js +11 -0
- package/scripts/_class3.js +21 -0
- package/scripts/_dstruct.js +20 -0
- package/scripts/_dstruct2.js +24 -0
- package/scripts/_scaffold.js +16 -0
- package/scripts/_scaffold2.js +16 -0
- package/scripts/_scaffold3.js +26 -0
- package/scripts/_tpl_compile.js +13 -0
- package/scripts/_tpl_test.js +9 -0
- package/scripts/_typeof.js +26 -0
- package/scripts/_typeof2.js +25 -0
- package/scripts/patch_genmethod.js +44 -0
- package/scripts/smoke_test.js +167 -0
- package/scripts/smoke_test2.js +151 -0
- package/scripts/test_control_panel.js +58 -0
- package/src/compiler/CodeGenerator.js +1449 -0
- package/src/compiler/JITCompiler.js +325 -0
- package/src/database/DatabaseEngine.js +931 -0
- package/src/errors/ErrorDetector.js +305 -0
- package/src/errors/ErrorMessages.js +497 -0
- package/src/index.js +169 -0
- package/src/lexer/Lexer.js +342 -0
- package/src/lexer/TokenTypes.js +1234 -0
- package/src/memory/MemoryManager.js +283 -0
- package/src/parser/AST.js +606 -0
- package/src/parser/Parser.js +1942 -0
- package/src/runtime/ThyRuntime.js +265 -0
- package/src/runtime/stdlib/ArrayLib.js +207 -0
- package/src/runtime/stdlib/FileLib.js +168 -0
- package/src/runtime/stdlib/MathLib.js +411 -0
- package/src/runtime/stdlib/StringLib.js +387 -0
- package/src/server/ControlPanel.js +1188 -0
- package/src/server/HTMLCSSEngine.js +866 -0
- package/src/server/SecurityEngine.js +714 -0
- package/src/server/ServerEngine.js +738 -0
- package/tests/test1_math.thy +82 -0
- package/tests/test2_oop.thy +133 -0
- package/tests/test3_server.thy +143 -0
- package/tests/test4_functions.thy +159 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Optional database drivers — gracefully stubbed if not installed
|
|
4
|
+
let mysql, PgClient, mongoose, createRedisClient, sqlite3, knex;
|
|
5
|
+
|
|
6
|
+
try { mysql = require('mysql2/promise'); } catch (e) { mysql = null; }
|
|
7
|
+
try { ({ Client: PgClient } = require('pg')); } catch (e) { PgClient = null; }
|
|
8
|
+
try { mongoose = require('mongoose'); } catch (e) { mongoose = null; }
|
|
9
|
+
try { ({ createClient: createRedisClient } = require('redis')); } catch (e) { createRedisClient = null; }
|
|
10
|
+
try { sqlite3 = require('sqlite3').verbose(); } catch (e) { sqlite3 = null; }
|
|
11
|
+
try { knex = require('knex'); } catch (e) { knex = null; }
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
// ─────────────────────────────────────────────
|
|
18
|
+
// Query Builder
|
|
19
|
+
// ─────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
class QueryBuilder {
|
|
22
|
+
constructor(engine, tableName) {
|
|
23
|
+
this._engine = engine;
|
|
24
|
+
this._table = tableName;
|
|
25
|
+
this._selects = ['*'];
|
|
26
|
+
this._wheres = [];
|
|
27
|
+
this._params = [];
|
|
28
|
+
this._orderBys = [];
|
|
29
|
+
this._limitVal = null;
|
|
30
|
+
this._offsetVal = null;
|
|
31
|
+
this._joins = [];
|
|
32
|
+
this._groups = [];
|
|
33
|
+
this._havings = [];
|
|
34
|
+
this._paramIndex = 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {...string} cols
|
|
39
|
+
*/
|
|
40
|
+
select(...cols) {
|
|
41
|
+
this._selects = cols.length ? cols : ['*'];
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} condition - Raw condition string with ? placeholders.
|
|
47
|
+
* @param {...*} values
|
|
48
|
+
*/
|
|
49
|
+
where(condition, ...values) {
|
|
50
|
+
this._wheres.push(condition);
|
|
51
|
+
this._params.push(...values);
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} col
|
|
57
|
+
* @param {'ASC'|'DESC'} [dir='ASC']
|
|
58
|
+
*/
|
|
59
|
+
orderBy(col, dir = 'ASC') {
|
|
60
|
+
this._orderBys.push(`${col} ${dir.toUpperCase()}`);
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {number} n */
|
|
65
|
+
limit(n) { this._limitVal = n; return this; }
|
|
66
|
+
|
|
67
|
+
/** @param {number} n */
|
|
68
|
+
offset(n) { this._offsetVal = n; return this; }
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} table
|
|
72
|
+
* @param {string} condition
|
|
73
|
+
* @param {'INNER'|'LEFT'|'RIGHT'} [type='INNER']
|
|
74
|
+
*/
|
|
75
|
+
join(table, condition, type = 'INNER') {
|
|
76
|
+
this._joins.push(`${type} JOIN ${table} ON ${condition}`);
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {...string} cols */
|
|
81
|
+
groupBy(...cols) { this._groups = cols; return this; }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} condition
|
|
85
|
+
* @param {...*} values
|
|
86
|
+
*/
|
|
87
|
+
having(condition, ...values) {
|
|
88
|
+
this._havings.push(condition);
|
|
89
|
+
this._params.push(...values);
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_buildSQL() {
|
|
94
|
+
let sql = `SELECT ${this._selects.join(', ')} FROM ${this._table}`;
|
|
95
|
+
if (this._joins.length) sql += ' ' + this._joins.join(' ');
|
|
96
|
+
if (this._wheres.length) sql += ' WHERE ' + this._wheres.join(' AND ');
|
|
97
|
+
if (this._groups.length) sql += ' GROUP BY ' + this._groups.join(', ');
|
|
98
|
+
if (this._havings.length) sql += ' HAVING ' + this._havings.join(' AND ');
|
|
99
|
+
if (this._orderBys.length) sql += ' ORDER BY ' + this._orderBys.join(', ');
|
|
100
|
+
if (this._limitVal !== null) sql += ` LIMIT ${this._limitVal}`;
|
|
101
|
+
if (this._offsetVal !== null) sql += ` OFFSET ${this._offsetVal}`;
|
|
102
|
+
return { sql, params: this._params };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Executes the query.
|
|
107
|
+
* @returns {Promise<any[]>}
|
|
108
|
+
*/
|
|
109
|
+
async execute() {
|
|
110
|
+
const { sql, params } = this._buildSQL();
|
|
111
|
+
return this._engine._queryRaw(sql, params);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Returns the raw SQL and params without executing. */
|
|
115
|
+
toSQL() {
|
|
116
|
+
return this._buildSQL();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─────────────────────────────────────────────
|
|
121
|
+
// Migration System
|
|
122
|
+
// ─────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
class MigrationManager {
|
|
125
|
+
constructor(engine) {
|
|
126
|
+
this._engine = engine;
|
|
127
|
+
this._migrationsDir = path.resolve(process.cwd(), 'migrations');
|
|
128
|
+
this._tableName = 'thyscript_migrations';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setDir(dir) { this._migrationsDir = path.resolve(dir); return this; }
|
|
132
|
+
|
|
133
|
+
async _ensureTable() {
|
|
134
|
+
await this._engine._queryRaw(
|
|
135
|
+
`CREATE TABLE IF NOT EXISTS ${this._tableName} (
|
|
136
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
137
|
+
name VARCHAR(255) NOT NULL UNIQUE,
|
|
138
|
+
batch INTEGER NOT NULL DEFAULT 1,
|
|
139
|
+
run_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
140
|
+
)`, []
|
|
141
|
+
).catch(() => {
|
|
142
|
+
// MySQL / PG syntax
|
|
143
|
+
return this._engine._queryRaw(
|
|
144
|
+
`CREATE TABLE IF NOT EXISTS ${this._tableName} (
|
|
145
|
+
id SERIAL PRIMARY KEY,
|
|
146
|
+
name VARCHAR(255) NOT NULL UNIQUE,
|
|
147
|
+
batch INTEGER NOT NULL DEFAULT 1,
|
|
148
|
+
run_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
149
|
+
)`, []
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Creates a new migration file.
|
|
156
|
+
* @param {string} name
|
|
157
|
+
* @returns {string} File path.
|
|
158
|
+
*/
|
|
159
|
+
create(name) {
|
|
160
|
+
if (!fs.existsSync(this._migrationsDir)) {
|
|
161
|
+
fs.mkdirSync(this._migrationsDir, { recursive: true });
|
|
162
|
+
}
|
|
163
|
+
const timestamp = Date.now();
|
|
164
|
+
const filename = `${timestamp}_${name.replace(/\s+/g, '_')}.js`;
|
|
165
|
+
const filePath = path.join(this._migrationsDir, filename);
|
|
166
|
+
const template = `'use strict';
|
|
167
|
+
// Migration: ${name}
|
|
168
|
+
// Created: ${new Date().toISOString()}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
async up(db) {
|
|
172
|
+
// Write your migration here
|
|
173
|
+
// Example: await db._queryRaw('CREATE TABLE example (id INT PRIMARY KEY)', []);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async down(db) {
|
|
177
|
+
// Write your rollback here
|
|
178
|
+
// Example: await db._queryRaw('DROP TABLE IF EXISTS example', []);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
`;
|
|
182
|
+
fs.writeFileSync(filePath, template, 'utf8');
|
|
183
|
+
console.log(`[MigrationManager] Created: ${filePath}`);
|
|
184
|
+
return filePath;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Runs all pending migrations.
|
|
189
|
+
* @returns {Promise<string[]>} Names of run migrations.
|
|
190
|
+
*/
|
|
191
|
+
async up() {
|
|
192
|
+
await this._ensureTable();
|
|
193
|
+
const files = this._getMigrationFiles();
|
|
194
|
+
const ran = await this._getRanMigrations();
|
|
195
|
+
const ranNames = new Set(ran.map((r) => r.name));
|
|
196
|
+
const pending = files.filter((f) => !ranNames.has(path.basename(f)));
|
|
197
|
+
|
|
198
|
+
if (pending.length === 0) {
|
|
199
|
+
console.log('[MigrationManager] No pending migrations.');
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const batch = (ran.length > 0 ? Math.max(...ran.map((r) => r.batch)) : 0) + 1;
|
|
204
|
+
const executed = [];
|
|
205
|
+
|
|
206
|
+
for (const file of pending) {
|
|
207
|
+
const name = path.basename(file);
|
|
208
|
+
console.log(`[MigrationManager] Running: ${name}`);
|
|
209
|
+
const migration = require(file);
|
|
210
|
+
await migration.up(this._engine);
|
|
211
|
+
await this._engine._queryRaw(
|
|
212
|
+
`INSERT INTO ${this._tableName} (name, batch) VALUES (?, ?)`,
|
|
213
|
+
[name, batch]
|
|
214
|
+
);
|
|
215
|
+
executed.push(name);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log(`[MigrationManager] Ran ${executed.length} migration(s).`);
|
|
219
|
+
return executed;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Rolls back the last batch of migrations.
|
|
224
|
+
* @returns {Promise<string[]>} Names of rolled back migrations.
|
|
225
|
+
*/
|
|
226
|
+
async down() {
|
|
227
|
+
await this._ensureTable();
|
|
228
|
+
const ran = await this._getRanMigrations();
|
|
229
|
+
if (ran.length === 0) { console.log('[MigrationManager] Nothing to rollback.'); return []; }
|
|
230
|
+
|
|
231
|
+
const lastBatch = Math.max(...ran.map((r) => r.batch));
|
|
232
|
+
const toRollback = ran.filter((r) => r.batch === lastBatch);
|
|
233
|
+
const rolledBack = [];
|
|
234
|
+
|
|
235
|
+
for (const record of toRollback.reverse()) {
|
|
236
|
+
const file = path.join(this._migrationsDir, record.name);
|
|
237
|
+
if (!fs.existsSync(file)) {
|
|
238
|
+
console.warn(`[MigrationManager] File not found, skipping rollback: ${record.name}`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
console.log(`[MigrationManager] Rolling back: ${record.name}`);
|
|
242
|
+
const migration = require(file);
|
|
243
|
+
await migration.down(this._engine);
|
|
244
|
+
await this._engine._queryRaw(
|
|
245
|
+
`DELETE FROM ${this._tableName} WHERE name = ?`,
|
|
246
|
+
[record.name]
|
|
247
|
+
);
|
|
248
|
+
rolledBack.push(record.name);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return rolledBack;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Returns current migration status.
|
|
256
|
+
* @returns {Promise<object[]>}
|
|
257
|
+
*/
|
|
258
|
+
async status() {
|
|
259
|
+
await this._ensureTable();
|
|
260
|
+
const files = this._getMigrationFiles().map((f) => path.basename(f));
|
|
261
|
+
const ran = await this._getRanMigrations();
|
|
262
|
+
const ranNames = new Set(ran.map((r) => r.name));
|
|
263
|
+
|
|
264
|
+
return files.map((name) => ({
|
|
265
|
+
name,
|
|
266
|
+
status: ranNames.has(name) ? 'ran' : 'pending',
|
|
267
|
+
batch: ranNames.has(name) ? ran.find((r) => r.name === name).batch : null,
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_getMigrationFiles() {
|
|
272
|
+
if (!fs.existsSync(this._migrationsDir)) return [];
|
|
273
|
+
return fs.readdirSync(this._migrationsDir)
|
|
274
|
+
.filter((f) => f.endsWith('.js'))
|
|
275
|
+
.sort()
|
|
276
|
+
.map((f) => path.join(this._migrationsDir, f));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async _getRanMigrations() {
|
|
280
|
+
try {
|
|
281
|
+
return await this._engine._queryRaw(`SELECT * FROM ${this._tableName} ORDER BY batch, id`, []);
|
|
282
|
+
} catch { return []; }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─────────────────────────────────────────────
|
|
287
|
+
// DatabaseEngine
|
|
288
|
+
// ─────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
class DatabaseEngine {
|
|
291
|
+
constructor() {
|
|
292
|
+
this._type = null; // 'mysql' | 'pg' | 'mongo' | 'redis' | 'sqlite'
|
|
293
|
+
this._connection = null; // Active connection or pool
|
|
294
|
+
this._config = null;
|
|
295
|
+
this._reconnectAttempts = 0;
|
|
296
|
+
this._maxReconnectAttempts = 10;
|
|
297
|
+
this._reconnectDelay = 2000;
|
|
298
|
+
|
|
299
|
+
// ORM-like models registry
|
|
300
|
+
this._models = new Map(); // name -> { schema }
|
|
301
|
+
this._modelData = new Map(); // name -> Map<id, data> (in-memory fallback)
|
|
302
|
+
|
|
303
|
+
// Migration manager (attached after connection)
|
|
304
|
+
this.migrate = new MigrationManager(this);
|
|
305
|
+
|
|
306
|
+
// Knex query builder instance
|
|
307
|
+
this._knex = null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ─────────────────────────────────────────────
|
|
311
|
+
// MySQL
|
|
312
|
+
// ─────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Connects to MySQL and creates a connection pool.
|
|
316
|
+
* @param {object} config - { host, port, user, password, database, connectionLimit, ... }
|
|
317
|
+
* @returns {Promise<DatabaseEngine>}
|
|
318
|
+
*/
|
|
319
|
+
async connectMySQL(config) {
|
|
320
|
+
if (!mysql) throw new Error('mysql2 not installed. Run: npm install mysql2');
|
|
321
|
+
this._type = 'mysql';
|
|
322
|
+
this._config = config;
|
|
323
|
+
this._connection = await mysql.createPool({
|
|
324
|
+
host: config.host || 'localhost',
|
|
325
|
+
port: config.port || 3306,
|
|
326
|
+
user: config.user || 'root',
|
|
327
|
+
password: config.password || '',
|
|
328
|
+
database: config.database,
|
|
329
|
+
connectionLimit: config.connectionLimit || 10,
|
|
330
|
+
waitForConnections: true,
|
|
331
|
+
queueLimit: 0,
|
|
332
|
+
enableKeepAlive: true,
|
|
333
|
+
keepAliveInitialDelay: 0,
|
|
334
|
+
});
|
|
335
|
+
console.log('[DatabaseEngine] MySQL connected');
|
|
336
|
+
return this;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @param {string} sql - Parameterized SQL string.
|
|
341
|
+
* @param {any[]} params
|
|
342
|
+
* @returns {Promise<any[]>}
|
|
343
|
+
*/
|
|
344
|
+
async mysqlQuery(sql, params = []) {
|
|
345
|
+
const [rows] = await this._connection.execute(sql, params);
|
|
346
|
+
return rows;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** @param {string} table @param {object} data @returns {Promise<object>} */
|
|
350
|
+
async mysqlInsert(table, data) {
|
|
351
|
+
const cols = Object.keys(data);
|
|
352
|
+
const vals = Object.values(data);
|
|
353
|
+
const placeholders = cols.map(() => '?').join(', ');
|
|
354
|
+
const sql = `INSERT INTO \`${table}\` (${cols.map((c) => `\`${c}\``).join(', ')}) VALUES (${placeholders})`;
|
|
355
|
+
const [result] = await this._connection.execute(sql, vals);
|
|
356
|
+
return { insertId: result.insertId, affectedRows: result.affectedRows };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** @param {string} table @param {object} data @param {string} condition @param {any[]} params */
|
|
360
|
+
async mysqlUpdate(table, data, condition, params = []) {
|
|
361
|
+
const sets = Object.keys(data).map((k) => `\`${k}\` = ?`).join(', ');
|
|
362
|
+
const vals = [...Object.values(data), ...params];
|
|
363
|
+
const sql = `UPDATE \`${table}\` SET ${sets} WHERE ${condition}`;
|
|
364
|
+
const [result] = await this._connection.execute(sql, vals);
|
|
365
|
+
return { affectedRows: result.affectedRows, changedRows: result.changedRows };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** @param {string} table @param {string} condition @param {any[]} params */
|
|
369
|
+
async mysqlDelete(table, condition, params = []) {
|
|
370
|
+
const [result] = await this._connection.execute(
|
|
371
|
+
`DELETE FROM \`${table}\` WHERE ${condition}`, params
|
|
372
|
+
);
|
|
373
|
+
return { affectedRows: result.affectedRows };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** @param {string} table @param {string[]} cols @param {string} [condition] @param {any[]} [params] */
|
|
377
|
+
async mysqlSelect(table, cols = ['*'], condition = null, params = []) {
|
|
378
|
+
const colStr = cols.join(', ');
|
|
379
|
+
let sql = `SELECT ${colStr} FROM \`${table}\``;
|
|
380
|
+
if (condition) sql += ` WHERE ${condition}`;
|
|
381
|
+
return this.mysqlQuery(sql, params);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* @param {string} table
|
|
386
|
+
* @param {object} schema - { colName: 'INT PRIMARY KEY', ... }
|
|
387
|
+
* @param {boolean} [ifNotExists=true]
|
|
388
|
+
*/
|
|
389
|
+
async mysqlCreateTable(table, schema, ifNotExists = true) {
|
|
390
|
+
const cols = Object.entries(schema).map(([col, def]) => `\`${col}\` ${def}`).join(', ');
|
|
391
|
+
const qualifier = ifNotExists ? 'IF NOT EXISTS ' : '';
|
|
392
|
+
await this._connection.execute(`CREATE TABLE ${qualifier}\`${table}\` (${cols})`, []);
|
|
393
|
+
return this;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** @param {string} table @param {boolean} [ifExists=true] */
|
|
397
|
+
async mysqlDropTable(table, ifExists = true) {
|
|
398
|
+
const qualifier = ifExists ? 'IF EXISTS ' : '';
|
|
399
|
+
await this._connection.execute(`DROP TABLE ${qualifier}\`${table}\``, []);
|
|
400
|
+
return this;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Executes multiple queries in a single MySQL transaction.
|
|
405
|
+
* @param {function} callback - async (connection) => {}
|
|
406
|
+
* @returns {Promise<any>}
|
|
407
|
+
*/
|
|
408
|
+
async mysqlTransaction(callback) {
|
|
409
|
+
const conn = await this._connection.getConnection();
|
|
410
|
+
await conn.beginTransaction();
|
|
411
|
+
try {
|
|
412
|
+
const result = await callback(conn);
|
|
413
|
+
await conn.commit();
|
|
414
|
+
return result;
|
|
415
|
+
} catch (err) {
|
|
416
|
+
await conn.rollback();
|
|
417
|
+
throw err;
|
|
418
|
+
} finally {
|
|
419
|
+
conn.release();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ─────────────────────────────────────────────
|
|
424
|
+
// PostgreSQL
|
|
425
|
+
// ─────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Connects to PostgreSQL.
|
|
429
|
+
* @param {object} config - { host, port, user, password, database }
|
|
430
|
+
* @returns {Promise<DatabaseEngine>}
|
|
431
|
+
*/
|
|
432
|
+
async connectPostgres(config) {
|
|
433
|
+
if (!PgClient) throw new Error('pg not installed. Run: npm install pg');
|
|
434
|
+
this._type = 'pg';
|
|
435
|
+
this._config = config;
|
|
436
|
+
this._connection = new PgClient({
|
|
437
|
+
host: config.host || 'localhost',
|
|
438
|
+
port: config.port || 5432,
|
|
439
|
+
user: config.user || 'postgres',
|
|
440
|
+
password: config.password || '',
|
|
441
|
+
database: config.database,
|
|
442
|
+
});
|
|
443
|
+
await this._connection.connect();
|
|
444
|
+
this._connection.on('error', (err) => {
|
|
445
|
+
console.error('[DatabaseEngine] PostgreSQL error:', err.message);
|
|
446
|
+
this._autoReconnect(() => this.connectPostgres(this._config));
|
|
447
|
+
});
|
|
448
|
+
console.log('[DatabaseEngine] PostgreSQL connected');
|
|
449
|
+
return this;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** @param {string} sql @param {any[]} params */
|
|
453
|
+
async pgQuery(sql, params = []) {
|
|
454
|
+
const result = await this._connection.query(sql, params);
|
|
455
|
+
return result.rows;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** @param {string} table @param {object} data */
|
|
459
|
+
async pgInsert(table, data) {
|
|
460
|
+
const cols = Object.keys(data);
|
|
461
|
+
const vals = Object.values(data);
|
|
462
|
+
const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');
|
|
463
|
+
const sql = `INSERT INTO "${table}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders}) RETURNING *`;
|
|
464
|
+
const result = await this._connection.query(sql, vals);
|
|
465
|
+
return result.rows[0];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** @param {string} table @param {object} data @param {string} condition @param {any[]} condParams */
|
|
469
|
+
async pgUpdate(table, data, condition, condParams = []) {
|
|
470
|
+
const cols = Object.keys(data);
|
|
471
|
+
const vals = Object.values(data);
|
|
472
|
+
const sets = cols.map((k, i) => `"${k}" = $${i + 1}`).join(', ');
|
|
473
|
+
const offsetParams = condParams.map((_, i) => `$${vals.length + i + 1}`);
|
|
474
|
+
const condStr = condition.replace(/\?/g, () => offsetParams.shift());
|
|
475
|
+
const sql = `UPDATE "${table}" SET ${sets} WHERE ${condStr} RETURNING *`;
|
|
476
|
+
const result = await this._connection.query(sql, [...vals, ...condParams]);
|
|
477
|
+
return result.rows;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** @param {string} table @param {string} condition @param {any[]} params */
|
|
481
|
+
async pgDelete(table, condition, params = []) {
|
|
482
|
+
const paramStr = params.map((_, i) => `$${i + 1}`);
|
|
483
|
+
let condStr = condition;
|
|
484
|
+
paramStr.forEach((p, i) => { condStr = condStr.replace('?', p); });
|
|
485
|
+
const sql = `DELETE FROM "${table}" WHERE ${condStr} RETURNING *`;
|
|
486
|
+
const result = await this._connection.query(sql, params);
|
|
487
|
+
return result.rows;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** @param {string} table @param {string[]} cols @param {string} [condition] @param {any[]} [params] */
|
|
491
|
+
async pgSelect(table, cols = ['*'], condition = null, params = []) {
|
|
492
|
+
let sql = `SELECT ${cols.join(', ')} FROM "${table}"`;
|
|
493
|
+
if (condition) {
|
|
494
|
+
const paramStr = params.map((_, i) => `$${i + 1}`);
|
|
495
|
+
let condStr = condition;
|
|
496
|
+
paramStr.forEach((p) => { condStr = condStr.replace('?', p); });
|
|
497
|
+
sql += ` WHERE ${condStr}`;
|
|
498
|
+
}
|
|
499
|
+
return this.pgQuery(sql, params);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* @param {function} callback - async (client) => {}
|
|
504
|
+
*/
|
|
505
|
+
async pgTransaction(callback) {
|
|
506
|
+
await this._connection.query('BEGIN');
|
|
507
|
+
try {
|
|
508
|
+
const result = await callback(this._connection);
|
|
509
|
+
await this._connection.query('COMMIT');
|
|
510
|
+
return result;
|
|
511
|
+
} catch (err) {
|
|
512
|
+
await this._connection.query('ROLLBACK');
|
|
513
|
+
throw err;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ─────────────────────────────────────────────
|
|
518
|
+
// MongoDB
|
|
519
|
+
// ─────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Connects to MongoDB via Mongoose.
|
|
523
|
+
* @param {string} uri - MongoDB connection URI.
|
|
524
|
+
* @param {object} [options]
|
|
525
|
+
* @returns {Promise<DatabaseEngine>}
|
|
526
|
+
*/
|
|
527
|
+
async connectMongo(uri, options = {}) {
|
|
528
|
+
if (!mongoose) throw new Error('mongoose not installed. Run: npm install mongoose');
|
|
529
|
+
this._type = 'mongo';
|
|
530
|
+
await mongoose.connect(uri, Object.assign({
|
|
531
|
+
serverSelectionTimeoutMS: 5000,
|
|
532
|
+
socketTimeoutMS: 45000,
|
|
533
|
+
}, options));
|
|
534
|
+
this._connection = mongoose.connection;
|
|
535
|
+
this._connection.on('disconnected', () => {
|
|
536
|
+
console.warn('[DatabaseEngine] MongoDB disconnected. Reconnecting...');
|
|
537
|
+
this._autoReconnect(() => this.connectMongo(uri, options));
|
|
538
|
+
});
|
|
539
|
+
console.log('[DatabaseEngine] MongoDB connected');
|
|
540
|
+
return this;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
_getMongoModel(collection) {
|
|
544
|
+
if (mongoose.modelNames().includes(collection)) return mongoose.model(collection);
|
|
545
|
+
const schema = new mongoose.Schema({}, { strict: false, timestamps: true });
|
|
546
|
+
return mongoose.model(collection, schema);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** @param {string} col @param {object} filter @param {object} [projection] @param {object} [opts] */
|
|
550
|
+
async mongoFind(col, filter = {}, projection = {}, opts = {}) {
|
|
551
|
+
const Model = this._getMongoModel(col);
|
|
552
|
+
return Model.find(filter, projection, opts).lean();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** @param {string} col @param {object} data */
|
|
556
|
+
async mongoInsertOne(col, data) {
|
|
557
|
+
const Model = this._getMongoModel(col);
|
|
558
|
+
const doc = new Model(data);
|
|
559
|
+
return doc.save();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** @param {string} col @param {object[]} docs */
|
|
563
|
+
async mongoInsertMany(col, docs) {
|
|
564
|
+
const Model = this._getMongoModel(col);
|
|
565
|
+
return Model.insertMany(docs);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** @param {string} col @param {object} filter @param {object} update @param {object} [opts] */
|
|
569
|
+
async mongoUpdateOne(col, filter, update, opts = {}) {
|
|
570
|
+
const Model = this._getMongoModel(col);
|
|
571
|
+
return Model.updateOne(filter, update, opts);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** @param {string} col @param {object} filter @param {object} update @param {object} [opts] */
|
|
575
|
+
async mongoUpdateMany(col, filter, update, opts = {}) {
|
|
576
|
+
const Model = this._getMongoModel(col);
|
|
577
|
+
return Model.updateMany(filter, update, opts);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** @param {string} col @param {object} filter */
|
|
581
|
+
async mongoDeleteOne(col, filter) {
|
|
582
|
+
const Model = this._getMongoModel(col);
|
|
583
|
+
return Model.deleteOne(filter);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** @param {string} col @param {object} filter */
|
|
587
|
+
async mongoDeleteMany(col, filter) {
|
|
588
|
+
const Model = this._getMongoModel(col);
|
|
589
|
+
return Model.deleteMany(filter);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** @param {string} col @param {object[]} pipeline */
|
|
593
|
+
async mongoAggregate(col, pipeline) {
|
|
594
|
+
const Model = this._getMongoModel(col);
|
|
595
|
+
return Model.aggregate(pipeline);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ─────────────────────────────────────────────
|
|
599
|
+
// Redis
|
|
600
|
+
// ─────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Connects to Redis.
|
|
604
|
+
* @param {object|string} config - Redis URL or config object.
|
|
605
|
+
* @returns {Promise<DatabaseEngine>}
|
|
606
|
+
*/
|
|
607
|
+
async connectRedis(config = {}) {
|
|
608
|
+
if (!createRedisClient) throw new Error('redis not installed. Run: npm install redis');
|
|
609
|
+
this._type = 'redis';
|
|
610
|
+
const opts = typeof config === 'string' ? { url: config } : config;
|
|
611
|
+
this._connection = createRedisClient(Object.assign({ socket: { reconnectStrategy: (retries) => Math.min(retries * 100, 3000) } }, opts));
|
|
612
|
+
this._connection.on('error', (err) => console.error('[DatabaseEngine] Redis error:', err.message));
|
|
613
|
+
await this._connection.connect();
|
|
614
|
+
console.log('[DatabaseEngine] Redis connected');
|
|
615
|
+
return this;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async redisGet(key) { return this._connection.get(key); }
|
|
619
|
+
async redisSet(key, value, options = {}) { return this._connection.set(key, value, options); }
|
|
620
|
+
async redisDel(...keys) { return this._connection.del(keys); }
|
|
621
|
+
async redisExists(...keys) { return this._connection.exists(keys); }
|
|
622
|
+
async redisExpire(key, seconds) { return this._connection.expire(key, seconds); }
|
|
623
|
+
async redisLPush(key, ...values) { return this._connection.lPush(key, values); }
|
|
624
|
+
async redisRPush(key, ...values) { return this._connection.rPush(key, values); }
|
|
625
|
+
async redisLPop(key, count = 1) { return this._connection.lPop(key, count); }
|
|
626
|
+
async redisRPop(key, count = 1) { return this._connection.rPop(key, count); }
|
|
627
|
+
async redisHSet(key, field, value) { return this._connection.hSet(key, field, value); }
|
|
628
|
+
async redisHGet(key, field) { return this._connection.hGet(key, field); }
|
|
629
|
+
async redisHGetAll(key) { return this._connection.hGetAll(key); }
|
|
630
|
+
async redisPublish(channel, message) { return this._connection.publish(channel, typeof message === 'object' ? JSON.stringify(message) : message); }
|
|
631
|
+
async redisSubscribe(channel, callback) { return this._connection.subscribe(channel, (message) => { try { callback(JSON.parse(message), channel); } catch { callback(message, channel); } }); }
|
|
632
|
+
async redisIncr(key) { return this._connection.incr(key); }
|
|
633
|
+
async redisDecr(key) { return this._connection.decr(key); }
|
|
634
|
+
async redisTTL(key) { return this._connection.ttl(key); }
|
|
635
|
+
|
|
636
|
+
// ─────────────────────────────────────────────
|
|
637
|
+
// SQLite
|
|
638
|
+
// ─────────────────────────────────────────────
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Connects to SQLite.
|
|
642
|
+
* @param {string} [filePath=':memory:']
|
|
643
|
+
* @returns {Promise<DatabaseEngine>}
|
|
644
|
+
*/
|
|
645
|
+
async connectSQLite(filePath = ':memory:') {
|
|
646
|
+
if (!sqlite3) throw new Error('sqlite3 not installed. Run: npm install sqlite3');
|
|
647
|
+
this._type = 'sqlite';
|
|
648
|
+
return new Promise((resolve, reject) => {
|
|
649
|
+
this._connection = new sqlite3.Database(filePath, (err) => {
|
|
650
|
+
if (err) return reject(err);
|
|
651
|
+
console.log(`[DatabaseEngine] SQLite connected: ${filePath}`);
|
|
652
|
+
resolve(this);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Runs a SELECT query on SQLite.
|
|
659
|
+
* @param {string} sql
|
|
660
|
+
* @param {any[]} [params=[]]
|
|
661
|
+
* @returns {Promise<any[]>}
|
|
662
|
+
*/
|
|
663
|
+
async sqliteQuery(sql, params = []) {
|
|
664
|
+
return new Promise((resolve, reject) => {
|
|
665
|
+
this._connection.all(sql, params, (err, rows) => {
|
|
666
|
+
if (err) return reject(err);
|
|
667
|
+
resolve(rows);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Runs an INSERT/UPDATE/DELETE statement on SQLite.
|
|
674
|
+
* @param {string} sql
|
|
675
|
+
* @param {any[]} [params=[]]
|
|
676
|
+
* @returns {Promise<{ lastID: number, changes: number }>}
|
|
677
|
+
*/
|
|
678
|
+
async sqliteRun(sql, params = []) {
|
|
679
|
+
return new Promise((resolve, reject) => {
|
|
680
|
+
this._connection.run(sql, params, function (err) {
|
|
681
|
+
if (err) return reject(err);
|
|
682
|
+
resolve({ lastID: this.lastID, changes: this.changes });
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ─────────────────────────────────────────────
|
|
688
|
+
// Generic _queryRaw (used by QueryBuilder + Migrations)
|
|
689
|
+
// ─────────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
async _queryRaw(sql, params = []) {
|
|
692
|
+
switch (this._type) {
|
|
693
|
+
case 'mysql': return this.mysqlQuery(sql, params);
|
|
694
|
+
case 'pg': return this.pgQuery(sql, params);
|
|
695
|
+
case 'sqlite': return this.sqliteQuery(sql, params);
|
|
696
|
+
default: throw new Error(`_queryRaw not supported for type: ${this._type}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// ─────────────────────────────────────────────
|
|
701
|
+
// Query Builder factory
|
|
702
|
+
// ─────────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Returns a QueryBuilder for the given table.
|
|
706
|
+
* @param {string} tableName
|
|
707
|
+
* @returns {QueryBuilder}
|
|
708
|
+
*/
|
|
709
|
+
table(tableName) {
|
|
710
|
+
return new QueryBuilder(this, tableName);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ─────────────────────────────────────────────
|
|
714
|
+
// ORM-like Interface
|
|
715
|
+
// ─────────────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Registers a model schema.
|
|
719
|
+
* @param {string} name - Model name.
|
|
720
|
+
* @param {object} schema - { fieldName: { type, required, default, validate } }
|
|
721
|
+
* @returns {DatabaseEngine}
|
|
722
|
+
*/
|
|
723
|
+
model(name, schema) {
|
|
724
|
+
this._models.set(name, { schema, name });
|
|
725
|
+
if (!this._modelData.has(name)) this._modelData.set(name, new Map());
|
|
726
|
+
return this;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
_validateSchema(name, data) {
|
|
730
|
+
const modelDef = this._models.get(name);
|
|
731
|
+
if (!modelDef) throw new Error(`Model "${name}" not registered`);
|
|
732
|
+
const { schema } = modelDef;
|
|
733
|
+
const validated = {};
|
|
734
|
+
for (const [field, def] of Object.entries(schema)) {
|
|
735
|
+
let val = data[field];
|
|
736
|
+
if (val === undefined && def.default !== undefined) {
|
|
737
|
+
val = typeof def.default === 'function' ? def.default() : def.default;
|
|
738
|
+
}
|
|
739
|
+
if (def.required && (val === undefined || val === null)) {
|
|
740
|
+
throw new Error(`Field "${field}" is required in model "${name}"`);
|
|
741
|
+
}
|
|
742
|
+
if (val !== undefined && def.type) {
|
|
743
|
+
if (def.type === 'string' && typeof val !== 'string') val = String(val);
|
|
744
|
+
else if (def.type === 'number' && typeof val !== 'number') val = Number(val);
|
|
745
|
+
else if (def.type === 'boolean' && typeof val !== 'boolean') val = Boolean(val);
|
|
746
|
+
else if (def.type === 'date' && !(val instanceof Date)) val = new Date(val);
|
|
747
|
+
}
|
|
748
|
+
if (def.validate && typeof def.validate === 'function') {
|
|
749
|
+
const ok = def.validate(val);
|
|
750
|
+
if (!ok) throw new Error(`Validation failed for field "${field}" in model "${name}"`);
|
|
751
|
+
}
|
|
752
|
+
if (val !== undefined) validated[field] = val;
|
|
753
|
+
}
|
|
754
|
+
return validated;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
_genId() {
|
|
758
|
+
return crypto.randomBytes(8).toString('hex');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Creates a new record for the given model.
|
|
763
|
+
* Uses database if connected, otherwise falls back to in-memory.
|
|
764
|
+
* @param {string} modelName
|
|
765
|
+
* @param {object} data
|
|
766
|
+
* @returns {Promise<object>}
|
|
767
|
+
*/
|
|
768
|
+
async create(modelName, data) {
|
|
769
|
+
const validated = this._validateSchema(modelName, data);
|
|
770
|
+
validated.id = validated.id || this._genId();
|
|
771
|
+
validated.createdAt = new Date().toISOString();
|
|
772
|
+
validated.updatedAt = new Date().toISOString();
|
|
773
|
+
|
|
774
|
+
switch (this._type) {
|
|
775
|
+
case 'mysql': return this.mysqlInsert(modelName, validated);
|
|
776
|
+
case 'pg': return this.pgInsert(modelName, validated);
|
|
777
|
+
case 'mongo': return this.mongoInsertOne(modelName, validated);
|
|
778
|
+
case 'sqlite': {
|
|
779
|
+
const res = await this.sqliteRun(
|
|
780
|
+
`INSERT INTO ${modelName} (${Object.keys(validated).join(', ')}) VALUES (${Object.keys(validated).map(() => '?').join(', ')})`,
|
|
781
|
+
Object.values(validated)
|
|
782
|
+
);
|
|
783
|
+
return { ...validated, rowid: res.lastID };
|
|
784
|
+
}
|
|
785
|
+
default: {
|
|
786
|
+
const store = this._modelData.get(modelName);
|
|
787
|
+
store.set(validated.id, validated);
|
|
788
|
+
return { ...validated };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Finds a record by ID.
|
|
795
|
+
* @param {string} modelName
|
|
796
|
+
* @param {string|number} id
|
|
797
|
+
* @returns {Promise<object|null>}
|
|
798
|
+
*/
|
|
799
|
+
async findById(modelName, id) {
|
|
800
|
+
switch (this._type) {
|
|
801
|
+
case 'mysql': { const rows = await this.mysqlSelect(modelName, ['*'], 'id = ?', [id]); return rows[0] || null; }
|
|
802
|
+
case 'pg': { const rows = await this.pgSelect(modelName, ['*'], 'id = ?', [id]); return rows[0] || null; }
|
|
803
|
+
case 'mongo': { const Model = this._getMongoModel(modelName); return Model.findById(id).lean(); }
|
|
804
|
+
case 'sqlite': { const rows = await this.sqliteQuery(`SELECT * FROM ${modelName} WHERE id = ?`, [id]); return rows[0] || null; }
|
|
805
|
+
default: return this._modelData.get(modelName)?.get(String(id)) || null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Finds multiple records matching a filter.
|
|
811
|
+
* @param {string} modelName
|
|
812
|
+
* @param {object} filter - { fieldName: value }
|
|
813
|
+
* @returns {Promise<object[]>}
|
|
814
|
+
*/
|
|
815
|
+
async findMany(modelName, filter = {}) {
|
|
816
|
+
const keys = Object.keys(filter);
|
|
817
|
+
switch (this._type) {
|
|
818
|
+
case 'mysql': {
|
|
819
|
+
if (!keys.length) return this.mysqlSelect(modelName);
|
|
820
|
+
const cond = keys.map((k) => `\`${k}\` = ?`).join(' AND ');
|
|
821
|
+
return this.mysqlSelect(modelName, ['*'], cond, Object.values(filter));
|
|
822
|
+
}
|
|
823
|
+
case 'pg': {
|
|
824
|
+
if (!keys.length) return this.pgSelect(modelName);
|
|
825
|
+
const cond = keys.map((k) => `"${k}" = ?`).join(' AND ');
|
|
826
|
+
return this.pgSelect(modelName, ['*'], cond, Object.values(filter));
|
|
827
|
+
}
|
|
828
|
+
case 'mongo': return this.mongoFind(modelName, filter);
|
|
829
|
+
case 'sqlite': {
|
|
830
|
+
if (!keys.length) return this.sqliteQuery(`SELECT * FROM ${modelName}`, []);
|
|
831
|
+
const cond = keys.map((k) => `${k} = ?`).join(' AND ');
|
|
832
|
+
return this.sqliteQuery(`SELECT * FROM ${modelName} WHERE ${cond}`, Object.values(filter));
|
|
833
|
+
}
|
|
834
|
+
default: {
|
|
835
|
+
const store = this._modelData.get(modelName);
|
|
836
|
+
if (!store) return [];
|
|
837
|
+
return [...store.values()].filter((record) =>
|
|
838
|
+
keys.every((k) => record[k] === filter[k])
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Updates a record by ID.
|
|
846
|
+
* @param {string} modelName
|
|
847
|
+
* @param {string|number} id
|
|
848
|
+
* @param {object} data
|
|
849
|
+
* @returns {Promise<object>}
|
|
850
|
+
*/
|
|
851
|
+
async updateById(modelName, id, data) {
|
|
852
|
+
data.updatedAt = new Date().toISOString();
|
|
853
|
+
switch (this._type) {
|
|
854
|
+
case 'mysql': return this.mysqlUpdate(modelName, data, 'id = ?', [id]);
|
|
855
|
+
case 'pg': return this.pgUpdate(modelName, data, 'id = ?', [id]);
|
|
856
|
+
case 'mongo': { const Model = this._getMongoModel(modelName); return Model.findByIdAndUpdate(id, data, { new: true }).lean(); }
|
|
857
|
+
case 'sqlite': {
|
|
858
|
+
const sets = Object.keys(data).map((k) => `${k} = ?`).join(', ');
|
|
859
|
+
await this.sqliteRun(`UPDATE ${modelName} SET ${sets} WHERE id = ?`, [...Object.values(data), id]);
|
|
860
|
+
return this.findById(modelName, id);
|
|
861
|
+
}
|
|
862
|
+
default: {
|
|
863
|
+
const store = this._modelData.get(modelName);
|
|
864
|
+
const existing = store?.get(String(id));
|
|
865
|
+
if (!existing) throw new Error(`Record ${id} not found in ${modelName}`);
|
|
866
|
+
const updated = { ...existing, ...data };
|
|
867
|
+
store.set(String(id), updated);
|
|
868
|
+
return { ...updated };
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Deletes a record by ID.
|
|
875
|
+
* @param {string} modelName
|
|
876
|
+
* @param {string|number} id
|
|
877
|
+
* @returns {Promise<boolean>}
|
|
878
|
+
*/
|
|
879
|
+
async deleteById(modelName, id) {
|
|
880
|
+
switch (this._type) {
|
|
881
|
+
case 'mysql': { const r = await this.mysqlDelete(modelName, 'id = ?', [id]); return r.affectedRows > 0; }
|
|
882
|
+
case 'pg': { const r = await this.pgDelete(modelName, 'id = ?', [id]); return r.length > 0; }
|
|
883
|
+
case 'mongo': { const Model = this._getMongoModel(modelName); const r = await Model.findByIdAndDelete(id); return !!r; }
|
|
884
|
+
case 'sqlite': { const r = await this.sqliteRun(`DELETE FROM ${modelName} WHERE id = ?`, [id]); return r.changes > 0; }
|
|
885
|
+
default: { const store = this._modelData.get(modelName); return store ? store.delete(String(id)) : false; }
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ─────────────────────────────────────────────
|
|
890
|
+
// Auto-reconnect
|
|
891
|
+
// ─────────────────────────────────────────────
|
|
892
|
+
|
|
893
|
+
_autoReconnect(connectFn) {
|
|
894
|
+
if (this._reconnectAttempts >= this._maxReconnectAttempts) {
|
|
895
|
+
console.error('[DatabaseEngine] Max reconnect attempts reached. Giving up.');
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
this._reconnectAttempts++;
|
|
899
|
+
const delay = this._reconnectDelay * this._reconnectAttempts;
|
|
900
|
+
console.log(`[DatabaseEngine] Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts})...`);
|
|
901
|
+
setTimeout(async () => {
|
|
902
|
+
try {
|
|
903
|
+
await connectFn();
|
|
904
|
+
this._reconnectAttempts = 0;
|
|
905
|
+
console.log('[DatabaseEngine] Reconnected successfully.');
|
|
906
|
+
} catch (err) {
|
|
907
|
+
console.error('[DatabaseEngine] Reconnect failed:', err.message);
|
|
908
|
+
this._autoReconnect(connectFn);
|
|
909
|
+
}
|
|
910
|
+
}, delay);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// ─────────────────────────────────────────────
|
|
914
|
+
// Close connection
|
|
915
|
+
// ─────────────────────────────────────────────
|
|
916
|
+
|
|
917
|
+
async close() {
|
|
918
|
+
if (!this._connection) return;
|
|
919
|
+
switch (this._type) {
|
|
920
|
+
case 'mysql': await this._connection.end(); break;
|
|
921
|
+
case 'pg': await this._connection.end(); break;
|
|
922
|
+
case 'mongo': await mongoose.disconnect(); break;
|
|
923
|
+
case 'redis': await this._connection.quit(); break;
|
|
924
|
+
case 'sqlite': await new Promise((res, rej) => this._connection.close((e) => e ? rej(e) : res())); break;
|
|
925
|
+
}
|
|
926
|
+
this._connection = null;
|
|
927
|
+
console.log('[DatabaseEngine] Connection closed.');
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
module.exports = DatabaseEngine;
|