millas 0.2.23 → 0.2.25
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/README.md +19 -0
- package/package.json +3 -2
- package/src/admin/Admin.js +21 -5
- package/src/cli.js +4 -0
- package/src/commands/queue.js +1 -2
- package/src/commands/route.js +1 -1
- package/src/commands/schedule.js +176 -0
- package/src/container/AppInitializer.js +77 -3
- package/src/container/HttpServer.js +18 -10
- package/src/core/foundation.js +4 -1
- package/src/core/scheduler.js +8 -0
- package/src/core/timezone.js +15 -0
- package/src/errors/ErrorRenderer.js +72 -4
- package/src/facades/Database.js +39 -50
- package/src/facades/Schedule.js +22 -0
- package/src/http/SecurityBootstrap.js +15 -4
- package/src/http/middleware/AllowedHostsMiddleware.js +97 -0
- package/src/logger/formatters/JsonFormatter.js +1 -1
- package/src/logger/formatters/PrettyFormatter.js +3 -2
- package/src/logger/formatters/SimpleFormatter.js +2 -1
- package/src/migrations/system/0004_scheduler_locks.js +31 -0
- package/src/orm/drivers/DatabaseManager.js +105 -2
- package/src/orm/model/Model.js +18 -0
- package/src/orm/query/QueryBuilder.js +15 -0
- package/src/providers/DatabaseServiceProvider.js +2 -2
- package/src/scheduler/SchedulerLock.js +93 -0
- package/src/scheduler/SchedulerServiceProvider.js +55 -0
- package/src/scheduler/TaskScheduler.js +382 -0
- package/src/scheduler/index.js +9 -0
- package/src/support/Time.js +216 -0
package/src/facades/Database.js
CHANGED
|
@@ -1,64 +1,53 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const { createFacade } = require('./Facade');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Database facade —
|
|
6
|
+
* Database facade — Laravel-style DB access.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
+
* const { Database } = require('millas');
|
|
10
|
+
* // or
|
|
9
11
|
* const Database = require('millas/facades/Database');
|
|
10
12
|
*
|
|
11
13
|
* // Raw SQL
|
|
12
|
-
* const
|
|
14
|
+
* const users = await Database.select('SELECT * FROM users WHERE id = ?', [1]);
|
|
15
|
+
* await Database.insert('INSERT INTO users (name, email) VALUES (?, ?)', ['John', 'john@example.com']);
|
|
16
|
+
* await Database.update('UPDATE users SET name = ? WHERE id = ?', ['Jane', 1]);
|
|
17
|
+
* await Database.delete('DELETE FROM users WHERE id = ?', [1]);
|
|
13
18
|
*
|
|
14
|
-
* //
|
|
15
|
-
* const
|
|
19
|
+
* // Query Builder (most common)
|
|
20
|
+
* const users = await Database.table('users').get();
|
|
21
|
+
* const user = await Database.table('users').where('id', 1).first();
|
|
22
|
+
* await Database.table('users').insert({ name: 'John', email: 'john@example.com' });
|
|
23
|
+
* await Database.table('users').where('id', 1).update({ name: 'Jane' });
|
|
24
|
+
* await Database.table('users').where('id', 1).delete();
|
|
16
25
|
*
|
|
17
|
-
* //
|
|
18
|
-
*
|
|
26
|
+
* // Transactions
|
|
27
|
+
* await Database.transaction(async (trx) => {
|
|
28
|
+
* await trx.table('accounts').update({ balance: 100 });
|
|
29
|
+
* await trx.table('transactions').insert({ amount: 100 });
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* // Multiple connections
|
|
33
|
+
* await Database.connection('mysql').table('users').get();
|
|
34
|
+
*
|
|
35
|
+
* // Raw expressions
|
|
36
|
+
* await Database.table('users').select(Database.raw('COUNT(*) as total')).first();
|
|
37
|
+
*
|
|
38
|
+
* @class
|
|
39
|
+
* @property {function(string): *} table - Query builder for a table
|
|
40
|
+
* @property {function(string, array=): Promise<*>} raw - Execute raw SQL
|
|
41
|
+
* @property {function(string, array=): Promise<*>} select - SELECT query
|
|
42
|
+
* @property {function(string, array=): Promise<*>} insert - INSERT query
|
|
43
|
+
* @property {function(string, array=): Promise<*>} update - UPDATE query
|
|
44
|
+
* @property {function(string, array=): Promise<*>} delete - DELETE query
|
|
45
|
+
* @property {function(function): Promise<*>} transaction - Run queries in transaction
|
|
46
|
+
* @property {function(string=): *} connection - Get named connection
|
|
47
|
+
*
|
|
48
|
+
* @see src/orm/drivers/DatabaseManager.js
|
|
19
49
|
*/
|
|
20
|
-
class Database {
|
|
21
|
-
static _resolveInstance() {
|
|
22
|
-
const DatabaseManager = require('../orm/drivers/DatabaseManager');
|
|
23
|
-
return DatabaseManager.connection();
|
|
24
|
-
}
|
|
50
|
+
class Database extends createFacade('db') {
|
|
25
51
|
}
|
|
26
52
|
|
|
27
|
-
|
|
28
|
-
module.exports = new Proxy(Database, {
|
|
29
|
-
get(target, prop) {
|
|
30
|
-
// Let real static members through
|
|
31
|
-
if (prop in target || prop === 'then' || prop === 'catch') {
|
|
32
|
-
return target[prop];
|
|
33
|
-
}
|
|
34
|
-
if (typeof prop === 'symbol') return target[prop];
|
|
35
|
-
|
|
36
|
-
// Special case: raw() — normalize result across dialects
|
|
37
|
-
if (prop === 'raw') {
|
|
38
|
-
return async (sql, bindings) => {
|
|
39
|
-
const db = Database._resolveInstance();
|
|
40
|
-
const result = await db.raw(sql, bindings);
|
|
41
|
-
// Postgres returns { rows: [...], command, rowCount, ... }
|
|
42
|
-
// SQLite/MySQL return [rows, fields] or just rows
|
|
43
|
-
if (result && result.rows) return result.rows;
|
|
44
|
-
if (Array.isArray(result)) return result[0] ?? result;
|
|
45
|
-
return result;
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Special case: connection(name) returns a named knex instance
|
|
50
|
-
if (prop === 'connection') {
|
|
51
|
-
return (name) => {
|
|
52
|
-
const DatabaseManager = require('../orm/drivers/DatabaseManager');
|
|
53
|
-
return DatabaseManager.connection(name || null);
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Proxy everything else to the default knex connection
|
|
58
|
-
return (...args) => {
|
|
59
|
-
const db = Database._resolveInstance();
|
|
60
|
-
if (typeof db[prop] !== 'function') return db[prop];
|
|
61
|
-
return db[prop](...args);
|
|
62
|
-
};
|
|
63
|
-
},
|
|
64
|
-
});
|
|
53
|
+
module.exports = Database;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Facade = require('./Facade');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Schedule Facade
|
|
7
|
+
*
|
|
8
|
+
* Provides easy access to the task scheduler for defining scheduled tasks.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const { Schedule } = require('millas/facades');
|
|
12
|
+
*
|
|
13
|
+
* Schedule.job(SendEmailJob).daily().at('09:00');
|
|
14
|
+
* Schedule.job(CleanupJob).hourly();
|
|
15
|
+
*/
|
|
16
|
+
class Schedule extends Facade {
|
|
17
|
+
static getAccessor() {
|
|
18
|
+
return 'scheduler';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = Schedule;
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const SecurityHeaders
|
|
4
|
-
const CsrfMiddleware
|
|
5
|
-
const { RateLimiter }
|
|
6
|
-
const
|
|
3
|
+
const SecurityHeaders = require('./middleware/SecurityHeaders');
|
|
4
|
+
const CsrfMiddleware = require('./middleware/CsrfMiddleware');
|
|
5
|
+
const { RateLimiter } = require('./middleware/RateLimiter');
|
|
6
|
+
const AllowedHostsMiddleware = require('./middleware/AllowedHostsMiddleware');
|
|
7
|
+
const MillasResponse = require('./MillasResponse');
|
|
7
8
|
|
|
8
9
|
class SecurityBootstrap {
|
|
9
10
|
static apply(app, config = {}) {
|
|
11
|
+
// ── ALLOWED_HOSTS (Django-style) ──────────────────────────────────────────
|
|
12
|
+
// Must be first — reject invalid hosts before any other processing
|
|
13
|
+
const allowedHostsMiddleware = AllowedHostsMiddleware.from({
|
|
14
|
+
allowedHosts: config.allowedHosts,
|
|
15
|
+
env: config.env || process.env.APP_ENV || 'production',
|
|
16
|
+
});
|
|
17
|
+
app.use(allowedHostsMiddleware.middleware());
|
|
18
|
+
|
|
10
19
|
const headerConfig = config.headers !== undefined ? config.headers : {};
|
|
11
20
|
app.use(SecurityHeaders.from(headerConfig).middleware());
|
|
12
21
|
|
|
@@ -48,6 +57,7 @@ class SecurityBootstrap {
|
|
|
48
57
|
|
|
49
58
|
if (process.env.MILLAS_DEBUG_SECURITY === 'true') {
|
|
50
59
|
console.log('[Millas Security] Controls applied:');
|
|
60
|
+
console.log(' ✓ Allowed hosts: ', config.allowedHosts === false ? 'DISABLED' : (config.allowedHosts || 'development defaults'));
|
|
51
61
|
console.log(' ✓ Security headers: ', headerConfig === false ? 'DISABLED' : 'enabled');
|
|
52
62
|
console.log(' ✓ Cookie defaults: ', JSON.stringify(MillasResponse.getCookieDefaults()));
|
|
53
63
|
console.log(' ✓ Global rate limit: ', globalRateLimit ? `${config.rateLimit?.global?.max || 100} req/window` : 'disabled');
|
|
@@ -69,6 +79,7 @@ class SecurityBootstrap {
|
|
|
69
79
|
if (err.code === 'EVALIDATION' || err.name === 'ValidationError') {
|
|
70
80
|
return res.status(422).json({ message: 'Validation failed', errors: err.errors || {} });
|
|
71
81
|
}
|
|
82
|
+
// Pass all other errors (including EINVALIDHOST) to the main error handler
|
|
72
83
|
next(err);
|
|
73
84
|
});
|
|
74
85
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AllowedHostsMiddleware
|
|
5
|
+
*
|
|
6
|
+
* Django-style ALLOWED_HOSTS protection.
|
|
7
|
+
* Validates the Host header against a whitelist to prevent Host header attacks.
|
|
8
|
+
*
|
|
9
|
+
* Usage in config/app.js:
|
|
10
|
+
* module.exports = {
|
|
11
|
+
* allowedHosts: ['example.com', 'www.example.com', 'localhost', '127.0.0.1'],
|
|
12
|
+
* // or use wildcard:
|
|
13
|
+
* allowedHosts: ['*.example.com', 'localhost'],
|
|
14
|
+
* };
|
|
15
|
+
*
|
|
16
|
+
* Default behavior:
|
|
17
|
+
* - Development (APP_ENV=development): allows localhost, 127.0.0.1, and any host
|
|
18
|
+
* - Production: requires explicit allowedHosts configuration
|
|
19
|
+
*/
|
|
20
|
+
class AllowedHostsMiddleware {
|
|
21
|
+
constructor(config = {}) {
|
|
22
|
+
this._allowedHosts = config.allowedHosts;
|
|
23
|
+
this._env = config.env || 'production';
|
|
24
|
+
|
|
25
|
+
// In development, allow localhost and 127.0.0.1 by default
|
|
26
|
+
if (this._allowedHosts === undefined) {
|
|
27
|
+
this._allowedHosts = this._env === 'development'
|
|
28
|
+
? ['localhost', '127.0.0.1', '[::1]']
|
|
29
|
+
: [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if a host is allowed
|
|
35
|
+
*/
|
|
36
|
+
_isAllowed(host) {
|
|
37
|
+
if (!host) return false;
|
|
38
|
+
|
|
39
|
+
// Remove port from host
|
|
40
|
+
const hostname = host.split(':')[0];
|
|
41
|
+
|
|
42
|
+
// Check exact match
|
|
43
|
+
if (this._allowedHosts.includes(hostname)) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check wildcard match (*.example.com)
|
|
48
|
+
for (const allowed of this._allowedHosts) {
|
|
49
|
+
if (allowed.startsWith('*.')) {
|
|
50
|
+
const domain = allowed.slice(2);
|
|
51
|
+
if (hostname.endsWith('.' + domain) || hostname === domain) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Express middleware
|
|
62
|
+
*/
|
|
63
|
+
middleware() {
|
|
64
|
+
return (req, res, next) => {
|
|
65
|
+
const host = req.get('host');
|
|
66
|
+
|
|
67
|
+
if (!this._isAllowed(host)) {
|
|
68
|
+
const hostname = host.split(':')[0];
|
|
69
|
+
const message = `Invalid HTTP_HOST header: "${host}". You may need to add "${hostname}" to allowedHosts.`;
|
|
70
|
+
|
|
71
|
+
const error = new Error(message);
|
|
72
|
+
error.status = 400;
|
|
73
|
+
error.statusCode = 400;
|
|
74
|
+
error.code = 'EINVALIDHOST';
|
|
75
|
+
error._forceDebug = true;
|
|
76
|
+
error._hostDetails = {
|
|
77
|
+
receivedHost: host,
|
|
78
|
+
allowedHosts: this._allowedHosts,
|
|
79
|
+
environment: this._env,
|
|
80
|
+
suggestion: `Add "${hostname}" to the allowedHosts array in config/app.js`
|
|
81
|
+
};
|
|
82
|
+
return next(error);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
next();
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Static factory
|
|
91
|
+
*/
|
|
92
|
+
static from(config) {
|
|
93
|
+
return new AllowedHostsMiddleware(config || {});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = AllowedHostsMiddleware;
|
|
@@ -30,7 +30,7 @@ class JsonFormatter {
|
|
|
30
30
|
const { level, tag, message, context, error, timestamp } = entry;
|
|
31
31
|
|
|
32
32
|
const record = {
|
|
33
|
-
ts: timestamp || new Date().toISOString(),
|
|
33
|
+
ts: timestamp instanceof Date ? timestamp.toISOString() : (timestamp || new Date().toISOString()),
|
|
34
34
|
level: LEVEL_NAMES[level] || String(level),
|
|
35
35
|
...this.extra,
|
|
36
36
|
};
|
|
@@ -135,8 +135,9 @@ class PrettyFormatter {
|
|
|
135
135
|
|
|
136
136
|
_timestamp() {
|
|
137
137
|
const now = new Date();
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
const isoStr = now.toISOString();
|
|
139
|
+
if (this.tsFormat === 'iso') return isoStr;
|
|
140
|
+
return isoStr.replace('T', ' ').slice(0, 19);
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
@@ -26,7 +26,8 @@ class SimpleFormatter {
|
|
|
26
26
|
format(entry) {
|
|
27
27
|
const { level, tag, message, context, error, timestamp } = entry;
|
|
28
28
|
|
|
29
|
-
const
|
|
29
|
+
const isoStr = timestamp instanceof Date ? timestamp.toISOString() : (timestamp || new Date().toISOString());
|
|
30
|
+
const ts = isoStr.replace('T', ' ').slice(0, 23);
|
|
30
31
|
const lvlName = (LEVEL_NAMES[level] || String(level)).padEnd(7);
|
|
31
32
|
const tagPart = tag ? `${tag}: ` : '';
|
|
32
33
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* System Migration: Scheduler Locks
|
|
5
|
+
*
|
|
6
|
+
* Creates the scheduler_locks table for distributed locking.
|
|
7
|
+
* Prevents duplicate task execution across multiple app instances.
|
|
8
|
+
*
|
|
9
|
+
* This is a system migration - it runs automatically when you run `millas migrate`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
async up(db) {
|
|
14
|
+
await db.schema.createTable('scheduler_locks', (table) => {
|
|
15
|
+
table.string('task_id', 255).primary();
|
|
16
|
+
table.timestamp('locked_at').notNullable();
|
|
17
|
+
table.timestamp('expires_at').notNullable();
|
|
18
|
+
table.integer('instance_id').notNullable();
|
|
19
|
+
|
|
20
|
+
// Index for cleanup queries
|
|
21
|
+
table.index('expires_at', 'idx_scheduler_locks_expires');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
console.log(' ✓ Created scheduler_locks table');
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async down(db) {
|
|
28
|
+
await db.schema.dropTableIfExists('scheduler_locks');
|
|
29
|
+
console.log(' ✓ Dropped scheduler_locks table');
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -51,6 +51,88 @@ class DatabaseManager {
|
|
|
51
51
|
return this.connection();
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Query builder for a table (Laravel: DB::table('users'))
|
|
56
|
+
*/
|
|
57
|
+
table(tableName) {
|
|
58
|
+
return this.db(tableName);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Execute raw SQL SELECT (Laravel: DB::select())
|
|
63
|
+
*/
|
|
64
|
+
async select(sql, bindings = []) {
|
|
65
|
+
const result = await this.db.raw(sql, bindings);
|
|
66
|
+
// Normalize across dialects
|
|
67
|
+
if (result && result.rows) return result.rows;
|
|
68
|
+
if (Array.isArray(result)) return result[0] ?? result;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Execute INSERT (Laravel: DB::insert())
|
|
74
|
+
*/
|
|
75
|
+
async insert(sql, bindings = []) {
|
|
76
|
+
return this.db.raw(sql, bindings);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Execute UPDATE (Laravel: DB::update())
|
|
81
|
+
*/
|
|
82
|
+
async update(sql, bindings = []) {
|
|
83
|
+
return this.db.raw(sql, bindings);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Execute DELETE (Laravel: DB::delete())
|
|
88
|
+
*/
|
|
89
|
+
async delete(sql, bindings = []) {
|
|
90
|
+
return this.db.raw(sql, bindings);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Execute raw SQL (Laravel: DB::raw())
|
|
95
|
+
*/
|
|
96
|
+
async raw(sql, bindings = []) {
|
|
97
|
+
return this.db.raw(sql, bindings);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run queries in a transaction (Laravel: DB::transaction())
|
|
102
|
+
*/
|
|
103
|
+
async transaction(callback) {
|
|
104
|
+
return this.db.transaction(callback);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Begin a transaction manually (Laravel: DB::beginTransaction())
|
|
109
|
+
*/
|
|
110
|
+
async beginTransaction() {
|
|
111
|
+
const trx = await this.db.transaction();
|
|
112
|
+
return trx;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Execute a statement (Laravel: DB::statement())
|
|
117
|
+
*/
|
|
118
|
+
async statement(sql, bindings = []) {
|
|
119
|
+
return this.db.raw(sql, bindings);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Execute unprepared statement (Laravel: DB::unprepared())
|
|
124
|
+
*/
|
|
125
|
+
async unprepared(sql) {
|
|
126
|
+
return this.db.raw(sql);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get schema builder (Laravel: DB::getSchemaBuilder())
|
|
131
|
+
*/
|
|
132
|
+
get schema() {
|
|
133
|
+
return this.db.schema;
|
|
134
|
+
}
|
|
135
|
+
|
|
54
136
|
/**
|
|
55
137
|
* Close all connections.
|
|
56
138
|
*/
|
|
@@ -124,7 +206,21 @@ class DatabaseManager {
|
|
|
124
206
|
'Run: npm install pg'
|
|
125
207
|
);
|
|
126
208
|
}
|
|
127
|
-
|
|
209
|
+
|
|
210
|
+
// Configure pg to parse timestamps as UTC
|
|
211
|
+
const types = require('pg').types;
|
|
212
|
+
const TIMESTAMP_OID = 1114; // timestamp without timezone
|
|
213
|
+
const TIMESTAMPTZ_OID = 1184; // timestamp with timezone
|
|
214
|
+
|
|
215
|
+
// Override pg's default timestamp parser to always return UTC
|
|
216
|
+
types.setTypeParser(TIMESTAMP_OID, (val) => {
|
|
217
|
+
return val === null ? null : new Date(val + 'Z'); // Treat as UTC
|
|
218
|
+
});
|
|
219
|
+
types.setTypeParser(TIMESTAMPTZ_OID, (val) => {
|
|
220
|
+
return val === null ? null : new Date(val); // Already has timezone
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const pgConnection = knex({
|
|
128
224
|
client: 'pg',
|
|
129
225
|
connection: {
|
|
130
226
|
host: conf.host,
|
|
@@ -133,8 +229,15 @@ class DatabaseManager {
|
|
|
133
229
|
user: conf.username,
|
|
134
230
|
password: conf.password,
|
|
135
231
|
},
|
|
136
|
-
pool: {
|
|
232
|
+
pool: {
|
|
233
|
+
min: 2,
|
|
234
|
+
max: 10,
|
|
235
|
+
afterCreate: (conn, done) => {
|
|
236
|
+
conn.query('SET timezone = "UTC";', (err) => done(err, conn));
|
|
237
|
+
},
|
|
238
|
+
},
|
|
137
239
|
});
|
|
240
|
+
return pgConnection;
|
|
138
241
|
|
|
139
242
|
default:
|
|
140
243
|
throw new Error(`Unsupported database driver: "${conf.driver}"`);
|
package/src/orm/model/Model.js
CHANGED
|
@@ -967,6 +967,15 @@ class Model {
|
|
|
967
967
|
const d = new Date(val.valueOf?.() ?? val);
|
|
968
968
|
return isNaN(d.getTime()) ? null : d;
|
|
969
969
|
}
|
|
970
|
+
// Django-style USE_TZ: if enabled, treat naive DB timestamps as UTC
|
|
971
|
+
if (this._isUseTzEnabled()) {
|
|
972
|
+
// Parse as UTC by appending 'Z' if no timezone info present
|
|
973
|
+
const str = String(val);
|
|
974
|
+
if (!str.includes('Z') && !str.includes('+') && !str.includes('-', 10)) {
|
|
975
|
+
const d = new Date(str + 'Z');
|
|
976
|
+
return isNaN(d.getTime()) ? null : d;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
970
979
|
const d = new Date(val);
|
|
971
980
|
return isNaN(d.getTime()) ? null : d;
|
|
972
981
|
}
|
|
@@ -1038,6 +1047,15 @@ class Model {
|
|
|
1038
1047
|
} catch { return false; }
|
|
1039
1048
|
}
|
|
1040
1049
|
|
|
1050
|
+
static _isUseTzEnabled() {
|
|
1051
|
+
try {
|
|
1052
|
+
const appConfig = require(process.cwd() + '/config/app.js');
|
|
1053
|
+
return appConfig.useTz !== false; // Default to true (Django-style)
|
|
1054
|
+
} catch {
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1041
1059
|
/**
|
|
1042
1060
|
* Insert a row and return the inserted primary key — dialect-aware.
|
|
1043
1061
|
* SQLite: insert returns [lastId]
|
|
@@ -274,6 +274,21 @@ class QueryBuilder {
|
|
|
274
274
|
return this;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
joinRaw(sql, bindings = []) {
|
|
278
|
+
this._query = this._query.joinRaw(sql, bindings);
|
|
279
|
+
return this;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
leftJoinRaw(sql, bindings = []) {
|
|
283
|
+
this._query = this._query.leftJoinRaw(sql, bindings);
|
|
284
|
+
return this;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
crossJoin(table) {
|
|
288
|
+
this._query = this._query.crossJoin(table);
|
|
289
|
+
return this;
|
|
290
|
+
}
|
|
291
|
+
|
|
277
292
|
having(column, operatorOrValue, value) {
|
|
278
293
|
if (value !== undefined) {
|
|
279
294
|
this._query = this._query.having(column, operatorOrValue, value);
|
|
@@ -15,8 +15,8 @@ const SchemaBuilder = require('../orm/migration/SchemaBuilder');
|
|
|
15
15
|
*/
|
|
16
16
|
class DatabaseServiceProvider extends ServiceProvider {
|
|
17
17
|
register(container) {
|
|
18
|
-
//
|
|
19
|
-
container.instance('db',
|
|
18
|
+
// Register DatabaseManager singleton (provides Laravel-style DB methods)
|
|
19
|
+
container.instance('db', DatabaseManager);
|
|
20
20
|
container.instance('DatabaseManager', DatabaseManager);
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SchedulerLock
|
|
5
|
+
*
|
|
6
|
+
* Distributed locking mechanism to prevent duplicate task execution
|
|
7
|
+
* across multiple app instances (PM2 cluster, Docker replicas, etc.)
|
|
8
|
+
*
|
|
9
|
+
* Uses database-based locks with automatic expiration.
|
|
10
|
+
* The scheduler_locks table is created automatically by system migration 0004.
|
|
11
|
+
*/
|
|
12
|
+
class SchedulerLock {
|
|
13
|
+
constructor(db) {
|
|
14
|
+
this._db = db;
|
|
15
|
+
this._tableName = 'scheduler_locks';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Try to acquire a lock for a specific task
|
|
20
|
+
* Returns true if lock acquired, false if another instance has it
|
|
21
|
+
*/
|
|
22
|
+
async acquire(taskId, ttlSeconds = 300) {
|
|
23
|
+
if (!this._db) return true; // No DB = no locking (single instance mode)
|
|
24
|
+
|
|
25
|
+
const db = this._db.db || this._db;
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const expiresAt = new Date(now.getTime() + ttlSeconds * 1000);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// Try to insert or update lock (upsert)
|
|
31
|
+
const result = await db.raw(`
|
|
32
|
+
INSERT INTO ${this._tableName} (task_id, locked_at, expires_at, instance_id)
|
|
33
|
+
VALUES (?, ?, ?, ?)
|
|
34
|
+
ON CONFLICT (task_id) DO UPDATE
|
|
35
|
+
SET locked_at = EXCLUDED.locked_at,
|
|
36
|
+
expires_at = EXCLUDED.expires_at,
|
|
37
|
+
instance_id = EXCLUDED.instance_id
|
|
38
|
+
WHERE ${this._tableName}.expires_at < ?
|
|
39
|
+
RETURNING instance_id
|
|
40
|
+
`, [taskId, now, expiresAt, process.pid, now]);
|
|
41
|
+
|
|
42
|
+
// Check if we got the lock (our PID is in the result)
|
|
43
|
+
if (result.rows && result.rows.length > 0) {
|
|
44
|
+
return result.rows[0].instance_id === process.pid;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If no rows returned, lock is held by another instance
|
|
48
|
+
return false;
|
|
49
|
+
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('[SchedulerLock] Failed to acquire lock:', error.message);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Release a lock
|
|
58
|
+
*/
|
|
59
|
+
async release(taskId) {
|
|
60
|
+
if (!this._db) return;
|
|
61
|
+
|
|
62
|
+
const db = this._db.db || this._db;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await db.raw(`
|
|
66
|
+
DELETE FROM ${this._tableName}
|
|
67
|
+
WHERE task_id = ? AND instance_id = ?
|
|
68
|
+
`, [taskId, process.pid]);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error('[SchedulerLock] Failed to release lock:', error.message);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Clean up expired locks
|
|
76
|
+
*/
|
|
77
|
+
async cleanup() {
|
|
78
|
+
if (!this._db) return;
|
|
79
|
+
|
|
80
|
+
const db = this._db.db || this._db;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await db.raw(`
|
|
84
|
+
DELETE FROM ${this._tableName}
|
|
85
|
+
WHERE expires_at < ?
|
|
86
|
+
`, [new Date()]);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// Ignore cleanup errors
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = SchedulerLock;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ServiceProvider = require('../providers/ServiceProvider');
|
|
4
|
+
const TaskScheduler = require('./TaskScheduler');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SchedulerServiceProvider
|
|
8
|
+
*
|
|
9
|
+
* Registers the task scheduler in the DI container and starts it automatically.
|
|
10
|
+
* Integrates with the existing queue system and provides graceful shutdown.
|
|
11
|
+
*/
|
|
12
|
+
class SchedulerServiceProvider extends ServiceProvider {
|
|
13
|
+
register(container) {
|
|
14
|
+
container.singleton('scheduler', () => {
|
|
15
|
+
const queue = container.has('queue') ? container.make('queue') : null;
|
|
16
|
+
return new TaskScheduler(container, queue);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async boot(container, app) {
|
|
21
|
+
const scheduler = container.make('scheduler');
|
|
22
|
+
|
|
23
|
+
// Load configuration
|
|
24
|
+
const config = this._loadConfig(container);
|
|
25
|
+
scheduler.configure(config);
|
|
26
|
+
|
|
27
|
+
// Load scheduled tasks from routes/schedule.js
|
|
28
|
+
const basePath = container.make('basePath');
|
|
29
|
+
const schedulePath = require('path').join(basePath, 'routes', 'schedule.js');
|
|
30
|
+
scheduler.loadSchedules(schedulePath);
|
|
31
|
+
|
|
32
|
+
// Start the scheduler
|
|
33
|
+
scheduler.start();
|
|
34
|
+
|
|
35
|
+
// Register shutdown handler
|
|
36
|
+
if (app && typeof app.onShutdown === 'function') {
|
|
37
|
+
app.onShutdown(async () => {
|
|
38
|
+
await scheduler.stop();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_loadConfig(container) {
|
|
44
|
+
const basePath = container.make('basePath');
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const appConfig = require(require('path').join(basePath, 'config', 'app'));
|
|
48
|
+
return appConfig.scheduler || {};
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = SchedulerServiceProvider;
|