millas 0.2.24 → 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 +1 -1
- package/src/container/AppInitializer.js +1 -1
- package/src/core/foundation.js +2 -1
- package/src/core/timezone.js +15 -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/orm/drivers/DatabaseManager.js +23 -2
- package/src/orm/model/Model.js +18 -0
- package/src/support/Time.js +216 -0
package/README.md
CHANGED
|
@@ -89,6 +89,25 @@ class User extends Model {
|
|
|
89
89
|
}
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
// config/app.js
|
|
96
|
+
module.exports = {
|
|
97
|
+
name: 'My App',
|
|
98
|
+
env: process.env.NODE_ENV || 'development',
|
|
99
|
+
timezone: 'UTC', // Scheduler timezone (cron timing)
|
|
100
|
+
|
|
101
|
+
// ORM timezone behavior (Django-style)
|
|
102
|
+
useTz: true, // true = treat DB timestamps as UTC (recommended)
|
|
103
|
+
// false = treat DB timestamps as local server time
|
|
104
|
+
// Column type stays TIMESTAMP (without timezone)
|
|
105
|
+
// Conversion happens in application layer
|
|
106
|
+
|
|
107
|
+
allowedHosts: [], // Django-style host validation
|
|
108
|
+
};
|
|
109
|
+
```
|
|
110
|
+
|
|
92
111
|
## Authentication
|
|
93
112
|
|
|
94
113
|
```js
|
package/package.json
CHANGED
|
@@ -73,6 +73,7 @@ class AppInitializer {
|
|
|
73
73
|
*/
|
|
74
74
|
async bootKernel() {
|
|
75
75
|
const cfg = this._config;
|
|
76
|
+
const basePath = cfg.basePath || process.cwd();
|
|
76
77
|
|
|
77
78
|
const ExpressAdapter = require('../http/adapters/ExpressAdapter');
|
|
78
79
|
const expressApp = express();
|
|
@@ -83,7 +84,6 @@ class AppInitializer {
|
|
|
83
84
|
// Reads config/app.js for overrides. All protections are on by default:
|
|
84
85
|
// security headers, CSRF, rate limiting, cookie defaults, allowed hosts.
|
|
85
86
|
const SecurityBootstrap = require('../http/SecurityBootstrap');
|
|
86
|
-
const basePath = cfg.basePath || process.cwd();
|
|
87
87
|
const appConfig = SecurityBootstrap.loadConfig(basePath + '/config/app');
|
|
88
88
|
SecurityBootstrap.apply(this._adapter.nativeApp || expressApp, appConfig);
|
|
89
89
|
|
package/src/core/foundation.js
CHANGED
|
@@ -47,6 +47,7 @@ const UploadedFile = require('../http/UploadedFile');
|
|
|
47
47
|
// ── Serializer ────────────────────────────────────────────────────
|
|
48
48
|
const { Serializer } = require('../serializer/Serializer');
|
|
49
49
|
const {Str, FluentString} = require("../support/Str");
|
|
50
|
+
const Time = require('../support/Time');
|
|
50
51
|
|
|
51
52
|
module.exports = {
|
|
52
53
|
// ── Millas HTTP layer ──────────────────────────────────────────
|
|
@@ -68,7 +69,7 @@ module.exports = {
|
|
|
68
69
|
// Serializer
|
|
69
70
|
Serializer,
|
|
70
71
|
// Support
|
|
71
|
-
Str, FluentString,
|
|
72
|
+
Str, FluentString, Time,
|
|
72
73
|
Log: require('../logger').Log,
|
|
73
74
|
// Scheduler
|
|
74
75
|
TaskScheduler: require('../scheduler').TaskScheduler,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* core/timezone
|
|
5
|
+
*
|
|
6
|
+
* Timezone utilities for Millas.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const { Time } = require('millas/core/timezone');
|
|
10
|
+
* const now = Time.now();
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const Time = require('../support/Time');
|
|
14
|
+
|
|
15
|
+
module.exports = { Time };
|
|
@@ -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
|
|
|
@@ -206,7 +206,21 @@ class DatabaseManager {
|
|
|
206
206
|
'Run: npm install pg'
|
|
207
207
|
);
|
|
208
208
|
}
|
|
209
|
-
|
|
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({
|
|
210
224
|
client: 'pg',
|
|
211
225
|
connection: {
|
|
212
226
|
host: conf.host,
|
|
@@ -215,8 +229,15 @@ class DatabaseManager {
|
|
|
215
229
|
user: conf.username,
|
|
216
230
|
password: conf.password,
|
|
217
231
|
},
|
|
218
|
-
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
|
+
},
|
|
219
239
|
});
|
|
240
|
+
return pgConnection;
|
|
220
241
|
|
|
221
242
|
default:
|
|
222
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]
|
|
@@ -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;
|