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
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const cron = require('node-cron');
|
|
6
|
+
const SchedulerLock = require('./SchedulerLock');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* TaskScheduler
|
|
10
|
+
*
|
|
11
|
+
* Built-in task scheduler that runs alongside the HTTP server.
|
|
12
|
+
* Uses node-cron for reliable cron expression handling.
|
|
13
|
+
* Uses distributed locks to prevent duplicate execution across multiple instances.
|
|
14
|
+
*
|
|
15
|
+
* Features:
|
|
16
|
+
* - Zero configuration required
|
|
17
|
+
* - DI container integration
|
|
18
|
+
* - Queue system integration
|
|
19
|
+
* - Distributed locking (multi-instance safe)
|
|
20
|
+
* - Graceful shutdown handling
|
|
21
|
+
*/
|
|
22
|
+
class TaskScheduler {
|
|
23
|
+
constructor(container = null, queue = null) {
|
|
24
|
+
this._container = container;
|
|
25
|
+
this._queue = queue;
|
|
26
|
+
this._tasks = new Map();
|
|
27
|
+
this._running = false;
|
|
28
|
+
this._lock = null;
|
|
29
|
+
this._config = {
|
|
30
|
+
enabled: true,
|
|
31
|
+
timezone: process.env.TZ || 'UTC',
|
|
32
|
+
useQueue: true,
|
|
33
|
+
useLocking: true, // Enable distributed locking by default
|
|
34
|
+
lockTTL: 300, // Lock expires after 5 minutes
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
configure(config = {}) {
|
|
39
|
+
this._config = { ...this._config, ...config };
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load scheduled tasks from a file (routes/schedule.js)
|
|
45
|
+
*/
|
|
46
|
+
loadSchedules(schedulePath) {
|
|
47
|
+
if (!fs.existsSync(schedulePath)) return this;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const scheduleDefinition = require(schedulePath);
|
|
51
|
+
const scheduleBuilder = new ScheduleBuilder(this);
|
|
52
|
+
scheduleDefinition(scheduleBuilder);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(`[TaskScheduler] Failed to load schedules from ${schedulePath}:`, error.message);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Register a scheduled task
|
|
62
|
+
*/
|
|
63
|
+
addTask(task) {
|
|
64
|
+
this._tasks.set(task.id, task);
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start the scheduler
|
|
70
|
+
*/
|
|
71
|
+
start() {
|
|
72
|
+
if (!this._config.enabled || this._running) return;
|
|
73
|
+
|
|
74
|
+
this._running = true;
|
|
75
|
+
|
|
76
|
+
// Initialize distributed locking
|
|
77
|
+
if (this._config.useLocking) {
|
|
78
|
+
const db = this._container ? this._container.make('db') : null;
|
|
79
|
+
this._lock = new SchedulerLock(db);
|
|
80
|
+
|
|
81
|
+
// Clean up expired locks every minute
|
|
82
|
+
setInterval(() => {
|
|
83
|
+
this._lock.cleanup().catch(() => {});
|
|
84
|
+
}, 60000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(`[TaskScheduler] Starting with ${this._tasks.size} scheduled tasks`);
|
|
88
|
+
if (this._config.useLocking) {
|
|
89
|
+
console.log('[TaskScheduler] Distributed locking enabled (multi-instance safe)');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Start all cron jobs
|
|
93
|
+
for (const task of this._tasks.values()) {
|
|
94
|
+
task.start();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Stop the scheduler
|
|
102
|
+
*/
|
|
103
|
+
async stop() {
|
|
104
|
+
if (!this._running) return;
|
|
105
|
+
|
|
106
|
+
this._running = false;
|
|
107
|
+
|
|
108
|
+
// Stop all cron jobs
|
|
109
|
+
for (const task of this._tasks.values()) {
|
|
110
|
+
task.stop();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log('[TaskScheduler] Stopped');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get all scheduled tasks
|
|
118
|
+
*/
|
|
119
|
+
getTasks() {
|
|
120
|
+
return Array.from(this._tasks.values());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Execute a scheduled task (used by cron jobs and manual testing)
|
|
125
|
+
*/
|
|
126
|
+
async _executeTask(task, now = new Date()) {
|
|
127
|
+
// Try to acquire distributed lock
|
|
128
|
+
if (this._config.useLocking && this._lock) {
|
|
129
|
+
const lockAcquired = await this._lock.acquire(task.id, this._config.lockTTL);
|
|
130
|
+
|
|
131
|
+
if (!lockAcquired) {
|
|
132
|
+
console.log(`[TaskScheduler] Skipping ${task.jobClass.name} - another instance is running it`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
// Prevent overlapping executions within same instance
|
|
139
|
+
if (task.isRunning()) {
|
|
140
|
+
console.warn(`[TaskScheduler] Skipping ${task.jobClass.name} - already running in this instance`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
task.markAsRunning();
|
|
145
|
+
console.log(`[TaskScheduler] Executing ${task.jobClass.name}`);
|
|
146
|
+
|
|
147
|
+
// Create job instance with DI
|
|
148
|
+
const jobInstance = this._createJobInstance(task);
|
|
149
|
+
|
|
150
|
+
if (this._config.useQueue && this._queue) {
|
|
151
|
+
// Dispatch to queue system
|
|
152
|
+
await this._queue.push(jobInstance);
|
|
153
|
+
} else {
|
|
154
|
+
// Execute immediately
|
|
155
|
+
await jobInstance.handle();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
task.updateLastRun(now);
|
|
159
|
+
task.markAsCompleted();
|
|
160
|
+
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error(`[TaskScheduler] Failed to execute ${task.jobClass.name}:`, error.message);
|
|
163
|
+
task.markAsFailed(error);
|
|
164
|
+
} finally {
|
|
165
|
+
// Always release the lock
|
|
166
|
+
if (this._config.useLocking && this._lock) {
|
|
167
|
+
await this._lock.release(task.id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create job instance using DI container
|
|
174
|
+
*/
|
|
175
|
+
_createJobInstance(task) {
|
|
176
|
+
const JobClass = task.jobClass;
|
|
177
|
+
|
|
178
|
+
if (this._container) {
|
|
179
|
+
// Use DI container to resolve dependencies
|
|
180
|
+
return this._container.make(JobClass, task.parameters);
|
|
181
|
+
} else {
|
|
182
|
+
// Fallback to manual instantiation
|
|
183
|
+
return new JobClass(...Object.values(task.parameters || {}));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* ScheduleBuilder
|
|
190
|
+
*
|
|
191
|
+
* Fluent API for defining scheduled tasks
|
|
192
|
+
*/
|
|
193
|
+
class ScheduleBuilder {
|
|
194
|
+
constructor(scheduler) {
|
|
195
|
+
this._scheduler = scheduler;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Schedule a job class
|
|
200
|
+
*/
|
|
201
|
+
job(JobClass) {
|
|
202
|
+
return new ScheduledTask(this._scheduler, JobClass);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* ScheduledTask
|
|
208
|
+
*
|
|
209
|
+
* Represents a single scheduled task with its timing and parameters
|
|
210
|
+
*/
|
|
211
|
+
class ScheduledTask {
|
|
212
|
+
constructor(scheduler, jobClass) {
|
|
213
|
+
this._scheduler = scheduler;
|
|
214
|
+
this.jobClass = jobClass;
|
|
215
|
+
this.id = `${jobClass.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
216
|
+
this.cronExpression = null;
|
|
217
|
+
this.cronJob = null;
|
|
218
|
+
this.parameters = {};
|
|
219
|
+
this.conditions = [];
|
|
220
|
+
this.timezone = scheduler._config.timezone;
|
|
221
|
+
this.lastRun = null;
|
|
222
|
+
this.running = false;
|
|
223
|
+
this.failures = [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Timing methods ────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
cron(expression) {
|
|
229
|
+
this.cronExpression = expression;
|
|
230
|
+
this._register();
|
|
231
|
+
return this;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
daily() {
|
|
235
|
+
return this.cron('0 0 * * *');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
hourly() {
|
|
239
|
+
return this.cron('0 * * * *');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
weekly() {
|
|
243
|
+
return this.cron('0 0 * * 0');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
monthly() {
|
|
247
|
+
return this.cron('0 0 1 * *');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
weekdays() {
|
|
251
|
+
return this.cron('0 0 * * 1-5');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
everyMinute() {
|
|
255
|
+
return this.cron('* * * * *');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
everyFiveMinutes() {
|
|
259
|
+
return this.cron('*/5 * * * *');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
everyTenMinutes() {
|
|
263
|
+
return this.cron('*/10 * * * *');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
everyFifteenMinutes() {
|
|
267
|
+
return this.cron('*/15 * * * *');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
everyThirtyMinutes() {
|
|
271
|
+
return this.cron('*/30 * * * *');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
at(time) {
|
|
275
|
+
if (!this.cronExpression) {
|
|
276
|
+
throw new Error('Must call a frequency method (daily, hourly, etc.) before at()');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const [hour, minute = 0] = time.split(':').map(Number);
|
|
280
|
+
const parts = this.cronExpression.split(' ');
|
|
281
|
+
parts[0] = minute.toString();
|
|
282
|
+
parts[1] = hour.toString();
|
|
283
|
+
|
|
284
|
+
this.cronExpression = parts.join(' ');
|
|
285
|
+
return this;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Configuration methods ─────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
with(params) {
|
|
291
|
+
this.parameters = { ...this.parameters, ...params };
|
|
292
|
+
return this;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
when(condition) {
|
|
296
|
+
this.conditions.push(condition);
|
|
297
|
+
return this;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
timezone(tz) {
|
|
301
|
+
this.timezone = tz;
|
|
302
|
+
return this;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Execution control ─────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
start() {
|
|
308
|
+
if (!this.cronExpression || this.cronJob) return;
|
|
309
|
+
|
|
310
|
+
this.cronJob = cron.schedule(
|
|
311
|
+
this.cronExpression,
|
|
312
|
+
async () => {
|
|
313
|
+
if (this._checkConditions()) {
|
|
314
|
+
await this._scheduler._executeTask(this, new Date());
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
scheduled: true,
|
|
319
|
+
timezone: this.timezone,
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
stop() {
|
|
325
|
+
if (this.cronJob) {
|
|
326
|
+
this.cronJob.stop();
|
|
327
|
+
this.cronJob = null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Execution state ───────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
isRunning() {
|
|
334
|
+
return this.running;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
markAsRunning() {
|
|
338
|
+
this.running = true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
markAsCompleted() {
|
|
342
|
+
this.running = false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
markAsFailed(error) {
|
|
346
|
+
this.running = false;
|
|
347
|
+
this.failures.push({
|
|
348
|
+
error: error.message,
|
|
349
|
+
timestamp: new Date(),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
updateLastRun(timestamp) {
|
|
354
|
+
this.lastRun = timestamp;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
getNextRun() {
|
|
358
|
+
// node-cron doesn't expose next run time easily
|
|
359
|
+
// Return a placeholder for now
|
|
360
|
+
return 'Scheduled';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Internal methods ──────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
_register() {
|
|
366
|
+
this._scheduler.addTask(this);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
_checkConditions() {
|
|
370
|
+
return this.conditions.every(condition => {
|
|
371
|
+
try {
|
|
372
|
+
return condition();
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = TaskScheduler;
|
|
381
|
+
module.exports.ScheduleBuilder = ScheduleBuilder;
|
|
382
|
+
module.exports.ScheduledTask = ScheduledTask;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Time
|
|
5
|
+
*
|
|
6
|
+
* Timezone utilities for Millas.
|
|
7
|
+
* Provides helpers for timezone conversion and formatting.
|
|
8
|
+
*
|
|
9
|
+
* Note: JavaScript's Date object is already timezone-aware (stores UTC internally),
|
|
10
|
+
* unlike Python's datetime which is naive by default. That's why Django needs
|
|
11
|
+
* timezone.now() but JavaScript doesn't - new Date() already returns UTC.
|
|
12
|
+
*
|
|
13
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* const { Time } = require('millas/core/timezone');
|
|
16
|
+
*
|
|
17
|
+
* // Convert UTC to local timezone for display
|
|
18
|
+
* const local = Time.localtime(new Date());
|
|
19
|
+
*
|
|
20
|
+
* // Parse datetime string as UTC
|
|
21
|
+
* const dt = Time.parse('2026-03-31 20:00:00');
|
|
22
|
+
*
|
|
23
|
+
* // Format for display
|
|
24
|
+
* const formatted = Time.format(new Date(), 'datetime');
|
|
25
|
+
*
|
|
26
|
+
* ── Configuration ─────────────────────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* // config/app.js
|
|
29
|
+
* module.exports = {
|
|
30
|
+
* timezone: 'UTC', // Default timezone for display/scheduler
|
|
31
|
+
* useTz: true, // Store/read timestamps as UTC (recommended)
|
|
32
|
+
* };
|
|
33
|
+
*/
|
|
34
|
+
class Time {
|
|
35
|
+
/**
|
|
36
|
+
* Convert a UTC datetime to the local timezone configured in config/app.js.
|
|
37
|
+
* Useful for displaying times to users in their expected timezone.
|
|
38
|
+
*
|
|
39
|
+
* @param {Date} dt - UTC datetime
|
|
40
|
+
* @returns {Date} datetime in local timezone
|
|
41
|
+
*/
|
|
42
|
+
static localtime(dt) {
|
|
43
|
+
if (!(dt instanceof Date)) {
|
|
44
|
+
throw new TypeError('localtime() requires a Date object');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const timezone = this.getTimezone();
|
|
48
|
+
if (timezone === 'UTC') return dt;
|
|
49
|
+
|
|
50
|
+
// For non-UTC timezones, we need to calculate the offset
|
|
51
|
+
// This is a simplified implementation - for production use,
|
|
52
|
+
// consider using a library like date-fns-tz or luxon
|
|
53
|
+
try {
|
|
54
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
55
|
+
timeZone: timezone,
|
|
56
|
+
year: 'numeric',
|
|
57
|
+
month: '2-digit',
|
|
58
|
+
day: '2-digit',
|
|
59
|
+
hour: '2-digit',
|
|
60
|
+
minute: '2-digit',
|
|
61
|
+
second: '2-digit',
|
|
62
|
+
hour12: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const parts = formatter.formatToParts(dt);
|
|
66
|
+
const values = {};
|
|
67
|
+
for (const part of parts) {
|
|
68
|
+
if (part.type !== 'literal') values[part.type] = part.value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return new Date(
|
|
72
|
+
`${values.year}-${values.month}-${values.day}T${values.hour}:${values.minute}:${values.second}`
|
|
73
|
+
);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// Fallback if timezone is invalid
|
|
76
|
+
return dt;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Convert a naive datetime to timezone-aware datetime.
|
|
82
|
+
* If useTz=true, assumes the naive datetime is in UTC.
|
|
83
|
+
* If useTz=false, assumes the naive datetime is in local timezone.
|
|
84
|
+
*
|
|
85
|
+
* @param {Date} dt - naive datetime
|
|
86
|
+
* @returns {Date} timezone-aware datetime
|
|
87
|
+
*/
|
|
88
|
+
static makeAware(dt) {
|
|
89
|
+
if (!(dt instanceof Date)) {
|
|
90
|
+
throw new TypeError('makeAware() requires a Date object');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If already has timezone info (ISO string with Z or offset), return as-is
|
|
94
|
+
const isoStr = dt.toISOString();
|
|
95
|
+
if (isoStr.includes('Z') || isoStr.match(/[+-]\d{2}:\d{2}$/)) {
|
|
96
|
+
return dt;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// If useTz=true, treat as UTC
|
|
100
|
+
if (this.isUseTzEnabled()) {
|
|
101
|
+
// Parse as UTC by appending Z
|
|
102
|
+
const str = dt.toISOString().replace('Z', '');
|
|
103
|
+
return new Date(str + 'Z');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// If useTz=false, treat as local time
|
|
107
|
+
return dt;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Convert a timezone-aware datetime to naive datetime.
|
|
112
|
+
* Strips timezone information, keeping the same wall-clock time.
|
|
113
|
+
*
|
|
114
|
+
* @param {Date} dt - timezone-aware datetime
|
|
115
|
+
* @returns {Date} naive datetime
|
|
116
|
+
*/
|
|
117
|
+
static makeNaive(dt) {
|
|
118
|
+
if (!(dt instanceof Date)) {
|
|
119
|
+
throw new TypeError('makeNaive() requires a Date object');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Create a new Date with the same components but no timezone
|
|
123
|
+
const year = dt.getUTCFullYear();
|
|
124
|
+
const month = dt.getUTCMonth();
|
|
125
|
+
const day = dt.getUTCDate();
|
|
126
|
+
const hours = dt.getUTCHours();
|
|
127
|
+
const minutes = dt.getUTCMinutes();
|
|
128
|
+
const seconds = dt.getUTCSeconds();
|
|
129
|
+
const ms = dt.getUTCMilliseconds();
|
|
130
|
+
|
|
131
|
+
return new Date(year, month, day, hours, minutes, seconds, ms);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the configured timezone from config/app.js.
|
|
136
|
+
*
|
|
137
|
+
* @returns {string} timezone (e.g., 'UTC', 'Africa/Nairobi')
|
|
138
|
+
*/
|
|
139
|
+
static getTimezone() {
|
|
140
|
+
try {
|
|
141
|
+
const appConfig = require(process.cwd() + '/config/app.js');
|
|
142
|
+
return appConfig.timezone || 'UTC';
|
|
143
|
+
} catch {
|
|
144
|
+
return 'UTC';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if USE_TZ is enabled (timezone awareness).
|
|
150
|
+
*
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
static isUseTzEnabled() {
|
|
154
|
+
try {
|
|
155
|
+
const appConfig = require(process.cwd() + '/config/app.js');
|
|
156
|
+
return appConfig.useTz !== false; // Default to true
|
|
157
|
+
} catch {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Format a datetime for display in the configured timezone.
|
|
164
|
+
*
|
|
165
|
+
* @param {Date} dt - datetime to format
|
|
166
|
+
* @param {string} format - format string (default: ISO)
|
|
167
|
+
* @returns {string} formatted datetime
|
|
168
|
+
*/
|
|
169
|
+
static format(dt, format = 'iso') {
|
|
170
|
+
if (!(dt instanceof Date)) {
|
|
171
|
+
throw new TypeError('format() requires a Date object');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const local = this.localtime(dt);
|
|
175
|
+
|
|
176
|
+
switch (format) {
|
|
177
|
+
case 'iso':
|
|
178
|
+
return local.toISOString();
|
|
179
|
+
case 'date':
|
|
180
|
+
return local.toISOString().split('T')[0];
|
|
181
|
+
case 'time':
|
|
182
|
+
return local.toISOString().split('T')[1].split('.')[0];
|
|
183
|
+
case 'datetime':
|
|
184
|
+
return local.toISOString().replace('T', ' ').split('.')[0];
|
|
185
|
+
default:
|
|
186
|
+
return local.toISOString();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse a datetime string, treating it as UTC if useTz=true.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} str - datetime string
|
|
194
|
+
* @returns {Date} parsed datetime
|
|
195
|
+
*/
|
|
196
|
+
static parse(str) {
|
|
197
|
+
if (typeof str !== 'string') {
|
|
198
|
+
throw new TypeError('parse() requires a string');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// If already has timezone info, parse normally
|
|
202
|
+
if (str.includes('Z') || str.match(/[+-]\d{2}:\d{2}$/)) {
|
|
203
|
+
return new Date(str);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// If useTz=true, treat as UTC
|
|
207
|
+
if (this.isUseTzEnabled()) {
|
|
208
|
+
return new Date(str + 'Z');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// If useTz=false, parse as local time
|
|
212
|
+
return new Date(str);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = Time;
|