kythia-core 0.9.3-beta
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/LICENSE +409 -0
- package/README.md +307 -0
- package/index.js +17 -0
- package/package.json +46 -0
- package/src/Kythia.js +430 -0
- package/src/KythiaClient.js +53 -0
- package/src/database/KythiaModel.js +948 -0
- package/src/database/KythiaORM.js +481 -0
- package/src/database/KythiaSequelize.js +94 -0
- package/src/managers/AddonManager.js +954 -0
- package/src/managers/EventManager.js +99 -0
- package/src/managers/InteractionManager.js +553 -0
- package/src/managers/ShutdownManager.js +197 -0
- package/src/structures/BaseCommand.js +49 -0
- package/src/utils/color.js +176 -0
- package/src/utils/formatter.js +99 -0
- package/src/utils/index.js +4 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🧠 Smart Sequelize Sync Utility
|
|
3
|
+
*
|
|
4
|
+
* @file src/database/KythiaORM.js
|
|
5
|
+
* @copyright © 2025 kenndeclouv
|
|
6
|
+
* @assistant chaa & graa
|
|
7
|
+
* @version 0.9.3-beta
|
|
8
|
+
*
|
|
9
|
+
* @description
|
|
10
|
+
* A utility for intelligent, hash-based syncing of Sequelize models.
|
|
11
|
+
* Only models with schema changes are synced, minimizing downtime and risk.
|
|
12
|
+
*
|
|
13
|
+
* ✨ Core Features:
|
|
14
|
+
* - Per-model schema hashing for change detection.
|
|
15
|
+
* - Selective, safe syncing (avoids unnecessary ALTERs).
|
|
16
|
+
* - Designed for production safety and speed.
|
|
17
|
+
* - No destructive operations; only additive/compatible changes are auto-applied.
|
|
18
|
+
* - Detailed logging for each sync operation.
|
|
19
|
+
*/
|
|
20
|
+
const readline = require('readline');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const { existsSync, readdirSync, statSync } = fs;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 🧬 generateModelHash
|
|
28
|
+
*
|
|
29
|
+
* Generates a unique, deterministic hash for a single Sequelize model's schema definition.
|
|
30
|
+
*
|
|
31
|
+
* - **Purpose:**
|
|
32
|
+
* Detects changes in a model's structure (attributes, associations, indexes) so that only changed models are synced.
|
|
33
|
+
*
|
|
34
|
+
* - **How it works:**
|
|
35
|
+
* 1. **Attributes:**
|
|
36
|
+
* - Iterates all model attributes, sorts them, and serializes their type, nullability, primary/unique status.
|
|
37
|
+
* - Handles Sequelize's type objects robustly (tries `.toSql()`, then `.toString()`, else "UnknownType").
|
|
38
|
+
* 2. **Associations:**
|
|
39
|
+
* - Serializes all associations (type, alias, target model, foreign key), sorted by alias.
|
|
40
|
+
* 3. **Indexes:**
|
|
41
|
+
* - Serializes all indexes (name, fields, uniqueness), sorted by name.
|
|
42
|
+
* 4. **Table Name:**
|
|
43
|
+
* - Ensures table name is always a string.
|
|
44
|
+
* 5. **Hashing:**
|
|
45
|
+
* - Concatenates all above into a single string, then hashes with MD5 (first 10 chars).
|
|
46
|
+
*
|
|
47
|
+
* - **Why:**
|
|
48
|
+
* This ensures that any schema change (even subtle) will result in a new hash, triggering a sync for that model only.
|
|
49
|
+
*
|
|
50
|
+
* @param {import('sequelize').ModelCtor} model - The Sequelize model to hash.
|
|
51
|
+
* @returns {string} - A short MD5 hash representing the model's schema state.
|
|
52
|
+
*/
|
|
53
|
+
function generateModelHash(model) {
|
|
54
|
+
const attributes = Object.entries(model.rawAttributes)
|
|
55
|
+
.sort()
|
|
56
|
+
.map(([attrName, attr]) => {
|
|
57
|
+
let typeString;
|
|
58
|
+
try {
|
|
59
|
+
if (typeof attr.type?.toSql === 'function') {
|
|
60
|
+
try {
|
|
61
|
+
typeString = attr.type.toSql({});
|
|
62
|
+
} catch (e) {
|
|
63
|
+
typeString = attr.type.toString();
|
|
64
|
+
}
|
|
65
|
+
} else if (typeof attr.type?.toString === 'function') {
|
|
66
|
+
typeString = attr.type.toString();
|
|
67
|
+
} else {
|
|
68
|
+
typeString = 'UnknownType';
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
typeString = 'UnknownType';
|
|
72
|
+
}
|
|
73
|
+
return `${attrName}:${typeString}:${!!attr.allowNull}:${!!attr.primaryKey}:${!!attr.unique}`;
|
|
74
|
+
})
|
|
75
|
+
.join(',');
|
|
76
|
+
|
|
77
|
+
const associations = Object.values(model.associations || {})
|
|
78
|
+
.sort((a, b) => (a.as || '').localeCompare(b.as || ''))
|
|
79
|
+
.map((assoc) => `${assoc.associationType}:${assoc.as}:${assoc.target.name}:${assoc.foreignKey}`)
|
|
80
|
+
.join(',');
|
|
81
|
+
|
|
82
|
+
const indexes = (model.options.indexes || [])
|
|
83
|
+
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
|
84
|
+
.map((idx) => {
|
|
85
|
+
const fields = Array.isArray(idx.fields) ? idx.fields.join(',') : '';
|
|
86
|
+
return `${idx.name || fields}:${fields}:${!!idx.unique}`;
|
|
87
|
+
})
|
|
88
|
+
.join(',');
|
|
89
|
+
|
|
90
|
+
let tableName = model.getTableName();
|
|
91
|
+
if (typeof tableName === 'object') tableName = tableName.tableName;
|
|
92
|
+
|
|
93
|
+
const definitionString = `${tableName}:{attr:{${attributes}},assoc:{${associations}},idx:{${indexes}}}`;
|
|
94
|
+
return crypto.createHash('md5').update(definitionString).digest('hex').substring(0, 10);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 📦 loadAllAddonModels
|
|
99
|
+
*
|
|
100
|
+
* Dynamically loads all Sequelize models from every addon's `database/models` directory.
|
|
101
|
+
*
|
|
102
|
+
* - **Purpose:**
|
|
103
|
+
* Ensures that all models from all installed addons are registered with Sequelize before syncing.
|
|
104
|
+
*
|
|
105
|
+
* - **How it works:**
|
|
106
|
+
* 1. Looks for the `addons` directory in the given root.
|
|
107
|
+
* 2. For each addon folder, checks for a `database/models` subdirectory.
|
|
108
|
+
* 3. Requires every `.js` file in that directory, which should register the model with Sequelize.
|
|
109
|
+
* 4. Logs each loaded model for visibility.
|
|
110
|
+
*
|
|
111
|
+
* - **Why:**
|
|
112
|
+
* This allows the system to support modular, pluggable features (addons) with their own database models.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} rootDir - The root directory of the project.
|
|
115
|
+
* @param {Object} sequelize - Sequelize instance
|
|
116
|
+
* @param {Object} logger - Logger instance
|
|
117
|
+
*/
|
|
118
|
+
function loadAllAddonModels(rootDir, sequelize, logger) {
|
|
119
|
+
const addonsDir = path.join(rootDir, 'addons');
|
|
120
|
+
if (!existsSync(addonsDir)) return;
|
|
121
|
+
|
|
122
|
+
const addonFolders = readdirSync(addonsDir, { withFileTypes: true })
|
|
123
|
+
.filter((dirent) => dirent.isDirectory())
|
|
124
|
+
.map((dirent) => dirent.name);
|
|
125
|
+
|
|
126
|
+
for (const addonName of addonFolders) {
|
|
127
|
+
const modelsDir = path.join(addonsDir, addonName, 'database', 'models');
|
|
128
|
+
if (existsSync(modelsDir) && statSync(modelsDir).isDirectory()) {
|
|
129
|
+
logger.info(`📂 Loading models from addon: ${addonName}`);
|
|
130
|
+
const files = readdirSync(modelsDir).filter((file) => file.endsWith('.js'));
|
|
131
|
+
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
const modelPath = path.join(modelsDir, file);
|
|
134
|
+
try {
|
|
135
|
+
const modelClass = require(modelPath);
|
|
136
|
+
|
|
137
|
+
if (modelClass && typeof modelClass.init === 'function') {
|
|
138
|
+
modelClass.init(sequelize);
|
|
139
|
+
|
|
140
|
+
logger.info(` └─> Initialized model: ${file}`);
|
|
141
|
+
} else {
|
|
142
|
+
logger.warn(` └─> File ${file} is not a valid model class, skipping init.`);
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
logger.error(` └─> ❌ Failed to load or init model: ${file}`, err.message);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Helper: Interactive prompt for production force sync
|
|
154
|
+
* @param {Array<string>} changedModels - Array of model names that will be synced
|
|
155
|
+
* @param {Object} logger - Logger instance
|
|
156
|
+
* @returns {Promise<boolean>}
|
|
157
|
+
*/
|
|
158
|
+
async function askForProductionSyncConfirmation(changedModels, logger) {
|
|
159
|
+
const readline = require('readline');
|
|
160
|
+
const rl = readline.createInterface({
|
|
161
|
+
input: process.stdin,
|
|
162
|
+
output: process.stdout,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
logger.warn('\n==================== 🚨 PRODUCTION WARNING 🚨 ====================');
|
|
167
|
+
logger.warn(`Database schema for [${changedModels.join(', ')}] is OUT OF DATE.`);
|
|
168
|
+
logger.warn('You are about to ALTER tables in PRODUCTION.');
|
|
169
|
+
logger.warn('This operation may be risky. Please ensure you have a backup.');
|
|
170
|
+
logger.warn('==================================================================');
|
|
171
|
+
|
|
172
|
+
rl.question('Do you want to continue with force sync? (y/N): ', (answer) => {
|
|
173
|
+
rl.close();
|
|
174
|
+
const normalized = answer.trim().toLowerCase();
|
|
175
|
+
if (normalized === 'y' || normalized === 'yes') {
|
|
176
|
+
resolve(true);
|
|
177
|
+
} else {
|
|
178
|
+
logger.error('❌ Force sync aborted by user.');
|
|
179
|
+
resolve(false);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Helper: Interactive prompt for destructive changes (column removal)
|
|
187
|
+
* @param {string} modelName - Name of the model with potential destructive changes
|
|
188
|
+
* @param {Array<string>} droppedColumns - Array of column names that would be dropped
|
|
189
|
+
* @param {Object} logger - Logger instance
|
|
190
|
+
* @returns {Promise<boolean>}
|
|
191
|
+
*/
|
|
192
|
+
async function askForDestructiveChangeConfirmation(modelName, droppedColumns, logger) {
|
|
193
|
+
const readline = require('readline');
|
|
194
|
+
const rl = readline.createInterface({
|
|
195
|
+
input: process.stdin,
|
|
196
|
+
output: process.stdout,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
logger.error(`\n==================== ⚠️ DANGEROUS CHANGE WARNING ⚠️ ====================`);
|
|
201
|
+
logger.error(`Model '${modelName}' detected potential COLUMN REMOVAL:`);
|
|
202
|
+
logger.error(` -> ${droppedColumns.join(', ')}`);
|
|
203
|
+
logger.error('\nThese columns exist in the database but are not found in the latest model file.');
|
|
204
|
+
logger.error('To prevent data loss, automatic sync is blocked by default.');
|
|
205
|
+
logger.error('\nSOLUTION:');
|
|
206
|
+
logger.error('1. If this is a mistake, fix your model file to include these columns.');
|
|
207
|
+
logger.error('2. If you are SURE you want to remove these columns, you can continue, but data will be lost.');
|
|
208
|
+
logger.error('================================================================================\n');
|
|
209
|
+
|
|
210
|
+
rl.question('Do you want to continue with this destructive change? (y/N): ', (answer) => {
|
|
211
|
+
rl.close();
|
|
212
|
+
const normalized = answer.trim().toLowerCase();
|
|
213
|
+
if (normalized === 'y' || normalized === 'yes') {
|
|
214
|
+
logger.warn('⚠️ Proceeding with destructive change as requested by user.');
|
|
215
|
+
resolve(true);
|
|
216
|
+
} else {
|
|
217
|
+
logger.error('❌ Destructive change aborted by user.');
|
|
218
|
+
resolve(false);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 🚨 Safety Net: Pengecekan Perubahan Destruktif
|
|
226
|
+
*
|
|
227
|
+
* Membandingkan skema model di kode dengan skema di database.
|
|
228
|
+
* Jika ada kolom yang ada di DB tapi tidak ada di model (potensi DROP COLUMN),
|
|
229
|
+
* proses akan berhenti dengan pesan error yang jelas, atau bertanya ke user jika di production.
|
|
230
|
+
*
|
|
231
|
+
* @param {import('sequelize').ModelCtor} model - Model Sequelize yang akan dicek.
|
|
232
|
+
* @param {Object} sequelize - Sequelize instance
|
|
233
|
+
* @param {Object} logger - Logger instance
|
|
234
|
+
* @param {Object} config - Application config
|
|
235
|
+
* @returns {Promise<boolean>} - true jika aman lanjut, false jika user abort.
|
|
236
|
+
*/
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 🚨 Safety Net: Pengecekan Perubahan Destruktif
|
|
240
|
+
*
|
|
241
|
+
* (Deskripsi biarin aja)
|
|
242
|
+
*
|
|
243
|
+
* @param {import('sequelize').ModelCtor} model - Model Sequelize yang akan dicek.
|
|
244
|
+
* @param {Object} sequelize - Sequelize instance
|
|
245
|
+
* @param {Object} logger - Logger instance
|
|
246
|
+
* @param {Object} config - Application config
|
|
247
|
+
* @returns {Promise<boolean>} - true jika aman lanjut, false jika user abort.
|
|
248
|
+
*/
|
|
249
|
+
async function checkForDestructiveChanges(model, sequelize, logger, config) {
|
|
250
|
+
const queryInterface = sequelize.getQueryInterface();
|
|
251
|
+
const tableName = model.getTableName();
|
|
252
|
+
const tableNameStr = typeof tableName === 'string' ? tableName : tableName.tableName;
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const dbSchema = await queryInterface.describeTable(tableNameStr);
|
|
256
|
+
const dbColumnNames = Object.keys(dbSchema);
|
|
257
|
+
|
|
258
|
+
const modelColumnNames = Object.keys(model.rawAttributes);
|
|
259
|
+
|
|
260
|
+
const droppedColumns = dbColumnNames.filter((col) => !modelColumnNames.includes(col));
|
|
261
|
+
|
|
262
|
+
if (droppedColumns.length > 0) {
|
|
263
|
+
if (config.env === 'production') {
|
|
264
|
+
logger.warn(`PERINGATAN PRODUKSI: Terdeteksi potensi penghapusan kolom di model ${model.name}.`);
|
|
265
|
+
return await askForDestructiveChangeConfirmation(model.name, droppedColumns, logger);
|
|
266
|
+
} else {
|
|
267
|
+
logger.warn(`\n⚠️ WARNING: Model '${model.name}' has ${droppedColumns.length} columns in DB that are not in the model.`);
|
|
268
|
+
logger.warn('This could cause data loss if columns are removed. Columns:', droppedColumns.join(', '));
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return true;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const isTableNotFoundError =
|
|
276
|
+
(error.name === 'SequelizeDatabaseError' && error.original?.code === 'ER_NO_SUCH_TABLE') ||
|
|
277
|
+
(error.name === 'SequelizeDatabaseError' && error.original?.code === '42P01') ||
|
|
278
|
+
(typeof error.message === 'string' && error.message.match(/table.*?doesn't exist/i)) ||
|
|
279
|
+
(typeof error.message === 'string' && error.message.match(/^No description found for/i));
|
|
280
|
+
|
|
281
|
+
if (isTableNotFoundError) {
|
|
282
|
+
logger.info(` -> Table '${tableNameStr}' not found, will be created.`);
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
logger.error(`❌ Error checking for destructive changes on ${model.name}:`, error.message);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 🧠 KythiaORM
|
|
293
|
+
*
|
|
294
|
+
* Intelligently synchronizes the database schema on a per-model basis, only syncing models whose schema has changed.
|
|
295
|
+
*
|
|
296
|
+
* - **Purpose:**
|
|
297
|
+
* Avoids unnecessary full-database syncs by tracking a hash of each model's schema.
|
|
298
|
+
* Only models with a changed hash are synced, making migrations safer and faster.
|
|
299
|
+
*
|
|
300
|
+
* - **How it works:**
|
|
301
|
+
* 1. Loads all addon models so Sequelize knows about every model.
|
|
302
|
+
* 2. Ensures a `model_versions` table exists to track each model's last known schema hash.
|
|
303
|
+
* 3. For each model:
|
|
304
|
+
* - Computes its current schema hash.
|
|
305
|
+
* - Compares to the hash stored in `model_versions`.
|
|
306
|
+
* - If different, adds to the sync list.
|
|
307
|
+
* 4. If any models need syncing:
|
|
308
|
+
* - In **production**, refuses to sync unless `options.force` is true (safety net).
|
|
309
|
+
* - Otherwise, syncs each changed model with `{ alter: true }`.
|
|
310
|
+
* - Updates the hash in `model_versions` for each synced model (UPSERT).
|
|
311
|
+
* 5. Logs the result.
|
|
312
|
+
*
|
|
313
|
+
* - **Why:**
|
|
314
|
+
* This approach minimizes risk in production, speeds up development, and makes schema management more robust.
|
|
315
|
+
*
|
|
316
|
+
* @param {Object} params - Parameters
|
|
317
|
+
* @param {Object} params.kythiaInstance - The main Kythia instance
|
|
318
|
+
* @param {Object} params.sequelize - Sequelize instance
|
|
319
|
+
* @param {Object} params.KythiaModel - KythiaModel class
|
|
320
|
+
* @param {Object} params.logger - Logger instance
|
|
321
|
+
* @param {Object} params.config - Application config
|
|
322
|
+
* @param {Object} [options] - Options for the sync
|
|
323
|
+
* @param {boolean} [options.force=false] - Force sync in production mode
|
|
324
|
+
* @returns {Promise<Object>} - Sequelize instance
|
|
325
|
+
*/
|
|
326
|
+
async function KythiaORM({ kythiaInstance, sequelize, KythiaModel, logger, config }, options = {}) {
|
|
327
|
+
try {
|
|
328
|
+
const rootDir = kythiaInstance.container.appRoot;
|
|
329
|
+
|
|
330
|
+
loadAllAddonModels(rootDir, sequelize, logger);
|
|
331
|
+
|
|
332
|
+
logger.info('↔️ Performing model associations from ready hooks...');
|
|
333
|
+
|
|
334
|
+
if (Array.isArray(kythiaInstance?.dbReadyHooks)) {
|
|
335
|
+
for (const hook of kythiaInstance.dbReadyHooks) {
|
|
336
|
+
if (typeof hook === 'function') {
|
|
337
|
+
await hook(sequelize);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (kythiaInstance?.client) {
|
|
343
|
+
KythiaModel.attachHooksToAllModels(sequelize, kythiaInstance.client);
|
|
344
|
+
} else {
|
|
345
|
+
logger.warn('🟠 No client instance provided, skipping model hooks attachment');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const isProduction = config.env === 'production';
|
|
349
|
+
const shouldReset = process.argv.includes('--db-reset');
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const dialect = (config.db?.driver || '').toLowerCase();
|
|
353
|
+
if (dialect === 'mysql' || dialect === 'mariadb') {
|
|
354
|
+
const dbName = config.db?.name || process.env.DB_NAME;
|
|
355
|
+
const { Sequelize } = require('sequelize');
|
|
356
|
+
const tempSequelize = new Sequelize(
|
|
357
|
+
'',
|
|
358
|
+
config.db?.user || process.env.DB_USER,
|
|
359
|
+
config.db?.password || process.env.DB_PASSWORD,
|
|
360
|
+
{
|
|
361
|
+
host: config.db?.host || process.env.DB_HOST,
|
|
362
|
+
port: config.db?.port || process.env.DB_PORT,
|
|
363
|
+
dialect,
|
|
364
|
+
logging: false,
|
|
365
|
+
dialectOptions:
|
|
366
|
+
config.db?.dialectOptions || (process.env.DB_DIALECT_OPTIONS ? JSON.parse(process.env.DB_DIALECT_OPTIONS) : {}),
|
|
367
|
+
socketPath: config.db?.socketPath || process.env.DB_SOCKET_PATH,
|
|
368
|
+
}
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
await tempSequelize.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``);
|
|
372
|
+
await tempSequelize.close();
|
|
373
|
+
logger.info(`🗄️ Ensured database "${dbName}" exists.`);
|
|
374
|
+
}
|
|
375
|
+
} catch (dbCreateError) {
|
|
376
|
+
logger.error('❌ Failed to create/check database existence:', dbCreateError);
|
|
377
|
+
throw dbCreateError;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (shouldReset) {
|
|
381
|
+
if (isProduction && !options.force) {
|
|
382
|
+
logger.error('❌ Cannot reset database in production without --force flag');
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
logger.warn('🔄 Resetting database...');
|
|
387
|
+
await sequelize.sync({ force: true });
|
|
388
|
+
logger.info('✅ Database reset complete.');
|
|
389
|
+
return sequelize;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const versionTableName = 'model_versions';
|
|
393
|
+
const versionTableExists = await sequelize
|
|
394
|
+
.getQueryInterface()
|
|
395
|
+
.showAllTables()
|
|
396
|
+
.then((tables) => tables.includes(versionTableName));
|
|
397
|
+
|
|
398
|
+
if (!versionTableExists) {
|
|
399
|
+
logger.info('🆕 Creating model_versions table...');
|
|
400
|
+
await sequelize.getQueryInterface().createTable(versionTableName, {
|
|
401
|
+
model_name: {
|
|
402
|
+
type: sequelize.Sequelize.STRING,
|
|
403
|
+
primaryKey: true,
|
|
404
|
+
},
|
|
405
|
+
version_hash: {
|
|
406
|
+
type: sequelize.Sequelize.STRING(10),
|
|
407
|
+
allowNull: false,
|
|
408
|
+
},
|
|
409
|
+
updated_at: {
|
|
410
|
+
type: sequelize.Sequelize.DATE,
|
|
411
|
+
allowNull: false,
|
|
412
|
+
defaultValue: sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const dbVersions = await sequelize.query(`SELECT model_name, version_hash FROM ${versionTableName}`, {
|
|
418
|
+
type: sequelize.QueryTypes.SELECT,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const dbVersionsMap = new Map(dbVersions?.map((v) => [v.model_name, v.version_hash]) || []);
|
|
422
|
+
const allModels = Object.values(sequelize.models);
|
|
423
|
+
const modelsToSync = [];
|
|
424
|
+
const changedModels = [];
|
|
425
|
+
|
|
426
|
+
for (const model of allModels) {
|
|
427
|
+
const newHash = generateModelHash(model);
|
|
428
|
+
const currentHash = dbVersionsMap.get(model.name);
|
|
429
|
+
|
|
430
|
+
if (newHash !== currentHash) {
|
|
431
|
+
const safe = await checkForDestructiveChanges(model, sequelize, logger, config);
|
|
432
|
+
if (!safe) {
|
|
433
|
+
logger.error(`❌ Aborting sync due to destructive changes in ${model.name}`);
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
modelsToSync.push({ model, newHash });
|
|
437
|
+
changedModels.push(model.name);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (modelsToSync.length > 0) {
|
|
442
|
+
logger.info(`🔄 ${changedModels.length} models need sync: ${changedModels.join(', ')}`);
|
|
443
|
+
|
|
444
|
+
if (isProduction && !options.force) {
|
|
445
|
+
const proceed = await askForProductionSyncConfirmation(changedModels, logger);
|
|
446
|
+
if (!proceed) {
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (const { model, newHash } of modelsToSync) {
|
|
452
|
+
logger.info(`🔄 Syncing model: ${model.name}...`);
|
|
453
|
+
await model.sync({ alter: true });
|
|
454
|
+
|
|
455
|
+
await sequelize.query(
|
|
456
|
+
`INSERT INTO ${versionTableName} (model_name, version_hash, updated_at)
|
|
457
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
458
|
+
ON DUPLICATE KEY UPDATE
|
|
459
|
+
version_hash = VALUES(version_hash), updated_at = CURRENT_TIMESTAMP`,
|
|
460
|
+
{
|
|
461
|
+
replacements: [model.name, newHash],
|
|
462
|
+
type: sequelize.QueryTypes.INSERT,
|
|
463
|
+
}
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
logger.info(`✅ Synced model: ${model.name} (${newHash})`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
logger.info('✨ Database sync completed successfully!');
|
|
470
|
+
} else {
|
|
471
|
+
logger.info('💾 All model schemas are up to date.');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return sequelize;
|
|
475
|
+
} catch (err) {
|
|
476
|
+
logger.error('❌ Error during database sync:', err);
|
|
477
|
+
throw err;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
module.exports = KythiaORM;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🧠 Sequelize Connection Factory
|
|
3
|
+
*
|
|
4
|
+
* @file src/database/KythiaSequelize.js
|
|
5
|
+
* @copyright © 2025 kenndeclouv
|
|
6
|
+
* @assistant chaa & graa
|
|
7
|
+
* @version 0.9.3-beta
|
|
8
|
+
*
|
|
9
|
+
* @description
|
|
10
|
+
* Main Sequelize connection factory for the application
|
|
11
|
+
*/
|
|
12
|
+
const { Sequelize } = require('sequelize');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 🧩 Creates and returns a Sequelize instance.
|
|
16
|
+
*
|
|
17
|
+
* @function createSequelizeInstance
|
|
18
|
+
* @param {object} config - The configuration object
|
|
19
|
+
* @param {object} logger - The logger instance
|
|
20
|
+
* @returns {Sequelize} Configured Sequelize instance
|
|
21
|
+
*/
|
|
22
|
+
function createSequelizeInstance(config, logger) {
|
|
23
|
+
const dbConfig = config.db || {};
|
|
24
|
+
|
|
25
|
+
const dialect = dbConfig.driver || process.env.DB_DRIVER;
|
|
26
|
+
const dbName = dbConfig.name || process.env.DB_NAME;
|
|
27
|
+
const dbUser = dbConfig.user || process.env.DB_USER;
|
|
28
|
+
const dbPassword = dbConfig.password || process.env.DB_PASSWORD;
|
|
29
|
+
const dbHost = dbConfig.host || process.env.DB_HOST;
|
|
30
|
+
const dbPort = dbConfig.port || process.env.DB_PORT;
|
|
31
|
+
const dbStorage = dbConfig.storagePath || process.env.DB_STORAGE_PATH;
|
|
32
|
+
const dbSocket = dbConfig.socketPath || process.env.DB_SOCKET_PATH;
|
|
33
|
+
const dbSsl = dbConfig.ssl || process.env.DB_SSL;
|
|
34
|
+
const dbDialectOptions = dbConfig.dialectOptions || process.env.DB_DIALECT_OPTIONS;
|
|
35
|
+
|
|
36
|
+
const seqConfig = {
|
|
37
|
+
database: dbName,
|
|
38
|
+
username: dbUser,
|
|
39
|
+
password: dbPassword,
|
|
40
|
+
dialect: dialect,
|
|
41
|
+
logging: (sql) => {
|
|
42
|
+
logger.debug(sql);
|
|
43
|
+
},
|
|
44
|
+
define: {
|
|
45
|
+
charset: 'utf8mb4',
|
|
46
|
+
collate: 'utf8mb4_unicode_ci',
|
|
47
|
+
},
|
|
48
|
+
timezone: dbConfig.timezone || '+00:00',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
switch (dialect) {
|
|
52
|
+
case 'sqlite':
|
|
53
|
+
seqConfig.storage = dbStorage;
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case 'mysql':
|
|
57
|
+
case 'mariadb':
|
|
58
|
+
seqConfig.host = dbHost;
|
|
59
|
+
seqConfig.port = dbPort;
|
|
60
|
+
if (dbSocket) {
|
|
61
|
+
seqConfig.dialectOptions = { socketPath: dbSocket };
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case 'postgres':
|
|
66
|
+
seqConfig.host = dbHost;
|
|
67
|
+
seqConfig.port = dbPort;
|
|
68
|
+
if (dbSsl === 'true' || dbSsl === true) {
|
|
69
|
+
seqConfig.dialectOptions = {
|
|
70
|
+
ssl: { require: true, rejectUnauthorized: false },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
|
|
75
|
+
case 'mssql':
|
|
76
|
+
seqConfig.host = dbHost;
|
|
77
|
+
seqConfig.port = dbPort;
|
|
78
|
+
if (dbDialectOptions) {
|
|
79
|
+
try {
|
|
80
|
+
seqConfig.dialectOptions = typeof dbDialectOptions === 'string' ? JSON.parse(dbDialectOptions) : dbDialectOptions;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
logger.error('Error parsing dialect options:', e.message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
|
|
87
|
+
default:
|
|
88
|
+
throw new Error(`${dialect} is not supported or not configured.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new Sequelize(seqConfig);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = createSequelizeInstance;
|