millas 0.1.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/LICENSE +21 -0
- package/README.md +137 -0
- package/bin/millas.js +6 -0
- package/package.json +56 -0
- package/src/admin/Admin.js +617 -0
- package/src/admin/index.js +13 -0
- package/src/admin/resources/AdminResource.js +317 -0
- package/src/auth/Auth.js +254 -0
- package/src/auth/AuthController.js +188 -0
- package/src/auth/AuthMiddleware.js +67 -0
- package/src/auth/Hasher.js +51 -0
- package/src/auth/JwtDriver.js +74 -0
- package/src/auth/RoleMiddleware.js +44 -0
- package/src/cache/Cache.js +231 -0
- package/src/cache/drivers/FileDriver.js +152 -0
- package/src/cache/drivers/MemoryDriver.js +158 -0
- package/src/cache/drivers/NullDriver.js +27 -0
- package/src/cache/index.js +8 -0
- package/src/cli.js +27 -0
- package/src/commands/make.js +61 -0
- package/src/commands/migrate.js +174 -0
- package/src/commands/new.js +50 -0
- package/src/commands/queue.js +92 -0
- package/src/commands/route.js +93 -0
- package/src/commands/serve.js +50 -0
- package/src/container/Application.js +177 -0
- package/src/container/Container.js +281 -0
- package/src/container/index.js +13 -0
- package/src/controller/Controller.js +367 -0
- package/src/errors/HttpError.js +29 -0
- package/src/events/Event.js +39 -0
- package/src/events/EventEmitter.js +151 -0
- package/src/events/Listener.js +46 -0
- package/src/events/index.js +15 -0
- package/src/index.js +93 -0
- package/src/mail/Mail.js +210 -0
- package/src/mail/MailMessage.js +196 -0
- package/src/mail/TemplateEngine.js +150 -0
- package/src/mail/drivers/LogDriver.js +36 -0
- package/src/mail/drivers/MailgunDriver.js +84 -0
- package/src/mail/drivers/SendGridDriver.js +97 -0
- package/src/mail/drivers/SmtpDriver.js +67 -0
- package/src/mail/index.js +19 -0
- package/src/middleware/AuthMiddleware.js +46 -0
- package/src/middleware/CorsMiddleware.js +59 -0
- package/src/middleware/LogMiddleware.js +61 -0
- package/src/middleware/Middleware.js +36 -0
- package/src/middleware/MiddlewarePipeline.js +94 -0
- package/src/middleware/ThrottleMiddleware.js +61 -0
- package/src/orm/drivers/DatabaseManager.js +135 -0
- package/src/orm/fields/index.js +132 -0
- package/src/orm/index.js +19 -0
- package/src/orm/migration/MigrationRunner.js +216 -0
- package/src/orm/migration/ModelInspector.js +338 -0
- package/src/orm/migration/SchemaBuilder.js +173 -0
- package/src/orm/model/Model.js +371 -0
- package/src/orm/query/QueryBuilder.js +197 -0
- package/src/providers/AdminServiceProvider.js +40 -0
- package/src/providers/AuthServiceProvider.js +53 -0
- package/src/providers/CacheStorageServiceProvider.js +71 -0
- package/src/providers/DatabaseServiceProvider.js +45 -0
- package/src/providers/EventServiceProvider.js +34 -0
- package/src/providers/MailServiceProvider.js +51 -0
- package/src/providers/ProviderRegistry.js +82 -0
- package/src/providers/QueueServiceProvider.js +52 -0
- package/src/providers/ServiceProvider.js +45 -0
- package/src/queue/Job.js +135 -0
- package/src/queue/Queue.js +147 -0
- package/src/queue/drivers/DatabaseDriver.js +194 -0
- package/src/queue/drivers/SyncDriver.js +72 -0
- package/src/queue/index.js +16 -0
- package/src/queue/workers/QueueWorker.js +140 -0
- package/src/router/MiddlewareRegistry.js +82 -0
- package/src/router/Route.js +255 -0
- package/src/router/RouteGroup.js +19 -0
- package/src/router/RouteRegistry.js +55 -0
- package/src/router/Router.js +138 -0
- package/src/router/index.js +15 -0
- package/src/scaffold/generator.js +34 -0
- package/src/scaffold/maker.js +272 -0
- package/src/scaffold/templates.js +350 -0
- package/src/storage/Storage.js +170 -0
- package/src/storage/drivers/LocalDriver.js +215 -0
- package/src/storage/index.js +6 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Controller = require('../controller/Controller');
|
|
4
|
+
const Auth = require('./Auth');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AuthController
|
|
8
|
+
*
|
|
9
|
+
* Drop-in authentication controller.
|
|
10
|
+
* Register its routes in routes/api.js:
|
|
11
|
+
*
|
|
12
|
+
* const AuthController = require('millas/src/auth/AuthController');
|
|
13
|
+
*
|
|
14
|
+
* Route.post('/auth/register', AuthController, 'register');
|
|
15
|
+
* Route.post('/auth/login', AuthController, 'login');
|
|
16
|
+
* Route.post('/auth/logout', AuthController, 'logout');
|
|
17
|
+
* Route.get('/auth/me', AuthController, 'me');
|
|
18
|
+
* Route.post('/auth/refresh', AuthController, 'refresh');
|
|
19
|
+
* Route.post('/auth/forgot-password', AuthController, 'forgotPassword');
|
|
20
|
+
* Route.post('/auth/reset-password', AuthController, 'resetPassword');
|
|
21
|
+
*
|
|
22
|
+
* Or use the convenience helper:
|
|
23
|
+
* Route.auth() — registers all routes above under /auth
|
|
24
|
+
*/
|
|
25
|
+
class AuthController extends Controller {
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* POST /auth/register
|
|
29
|
+
* Body: { name, email, password, password_confirmation? }
|
|
30
|
+
*/
|
|
31
|
+
async register(req, res) {
|
|
32
|
+
const data = await this.validate(req, {
|
|
33
|
+
name: 'required|string|min:2|max:100',
|
|
34
|
+
email: 'required|email',
|
|
35
|
+
password: 'required|string|min:8',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const user = await Auth.register(data);
|
|
39
|
+
const token = Auth.issueToken(user);
|
|
40
|
+
|
|
41
|
+
return this.created(res, {
|
|
42
|
+
message: 'Registration successful',
|
|
43
|
+
user: this._safeUser(user),
|
|
44
|
+
token,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* POST /auth/login
|
|
50
|
+
* Body: { email, password }
|
|
51
|
+
*/
|
|
52
|
+
async login(req, res) {
|
|
53
|
+
const { email, password } = await this.validate(req, {
|
|
54
|
+
email: 'required|email',
|
|
55
|
+
password: 'required|string',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const { user, token, refreshToken } = await Auth.login(email, password);
|
|
59
|
+
|
|
60
|
+
return this.ok(res, {
|
|
61
|
+
message: 'Login successful',
|
|
62
|
+
user: this._safeUser(user),
|
|
63
|
+
token,
|
|
64
|
+
refresh_token: refreshToken,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* POST /auth/logout
|
|
70
|
+
* Header: Authorization: Bearer <token>
|
|
71
|
+
*
|
|
72
|
+
* JWT is stateless — logout just instructs the client to discard the token.
|
|
73
|
+
* Phase 11 (cache) will add token blocklisting.
|
|
74
|
+
*/
|
|
75
|
+
async logout(req, res) {
|
|
76
|
+
return this.ok(res, { message: 'Logged out successfully' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* GET /auth/me
|
|
81
|
+
* Header: Authorization: Bearer <token>
|
|
82
|
+
*/
|
|
83
|
+
async me(req, res) {
|
|
84
|
+
try {
|
|
85
|
+
const user = await Auth.userOrFail(req);
|
|
86
|
+
return this.ok(res, { user: this._safeUser(user) });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err.status === 401) return this.unauthorized(res, err.message);
|
|
89
|
+
return this.unauthorized(res, 'Unauthenticated');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST /auth/refresh
|
|
95
|
+
* Body: { refresh_token }
|
|
96
|
+
*/
|
|
97
|
+
async refresh(req, res) {
|
|
98
|
+
const { refresh_token } = await this.validate(req, {
|
|
99
|
+
refresh_token: 'required|string',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Verify the refresh token
|
|
103
|
+
let payload;
|
|
104
|
+
try {
|
|
105
|
+
payload = Auth.verify(refresh_token);
|
|
106
|
+
} catch {
|
|
107
|
+
return this.unauthorized(res, 'Invalid or expired refresh token');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const user = await Auth.user({ headers: { authorization: `Bearer ${refresh_token}` } });
|
|
111
|
+
if (!user) return this.unauthorized(res, 'User not found');
|
|
112
|
+
|
|
113
|
+
const newToken = Auth.issueToken(user);
|
|
114
|
+
const newRefreshToken = Auth.issueToken(user, { expiresIn: '30d' });
|
|
115
|
+
|
|
116
|
+
return this.ok(res, {
|
|
117
|
+
token: newToken,
|
|
118
|
+
refresh_token: newRefreshToken,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* POST /auth/forgot-password
|
|
124
|
+
* Body: { email }
|
|
125
|
+
*/
|
|
126
|
+
async forgotPassword(req, res) {
|
|
127
|
+
const { email } = await this.validate(req, {
|
|
128
|
+
email: 'required|email',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Always return 200 to prevent email enumeration
|
|
132
|
+
try {
|
|
133
|
+
const user = await Auth.user({ headers: {} });
|
|
134
|
+
if (user) {
|
|
135
|
+
const resetToken = Auth.generateResetToken(user);
|
|
136
|
+
// Phase 8: Mail.send({ to: email, template: 'password-reset', data: { resetToken } })
|
|
137
|
+
// For now, return token in dev mode only
|
|
138
|
+
if (process.env.APP_ENV !== 'production') {
|
|
139
|
+
return this.ok(res, {
|
|
140
|
+
message: 'Reset link sent (dev mode — token exposed)',
|
|
141
|
+
reset_token: resetToken,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch { /* silent */ }
|
|
146
|
+
|
|
147
|
+
return this.ok(res, {
|
|
148
|
+
message: 'If that email exists, a reset link has been sent.',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* POST /auth/reset-password
|
|
154
|
+
* Body: { token, password }
|
|
155
|
+
*/
|
|
156
|
+
async resetPassword(req, res) {
|
|
157
|
+
const { token, password } = await this.validate(req, {
|
|
158
|
+
token: 'required|string',
|
|
159
|
+
password: 'required|string|min:8',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const payload = Auth.verifyResetToken(token);
|
|
163
|
+
const user = await Auth.user({
|
|
164
|
+
headers: { authorization: `Bearer ${token}` },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!user || user.email !== payload.email) {
|
|
168
|
+
return this.badRequest(res, 'Invalid reset token');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const hashed = await Auth.hashPassword(password);
|
|
172
|
+
await user.update({ password: hashed });
|
|
173
|
+
|
|
174
|
+
return this.ok(res, { message: 'Password reset successfully' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
_safeUser(user) {
|
|
180
|
+
if (!user) return null;
|
|
181
|
+
const obj = user.toJSON ? user.toJSON() : { ...user };
|
|
182
|
+
delete obj.password;
|
|
183
|
+
delete obj.remember_token;
|
|
184
|
+
return obj;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = AuthController;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Middleware = require('../middleware/Middleware');
|
|
4
|
+
const HttpError = require('../errors/HttpError');
|
|
5
|
+
const Auth = require('./Auth');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AuthMiddleware
|
|
9
|
+
*
|
|
10
|
+
* Guards routes from unauthenticated access using JWT.
|
|
11
|
+
*
|
|
12
|
+
* Reads the Bearer token from the Authorization header,
|
|
13
|
+
* verifies it, loads the user from the database,
|
|
14
|
+
* and attaches them to req.user.
|
|
15
|
+
*
|
|
16
|
+
* Throws 401 if:
|
|
17
|
+
* - No Authorization header
|
|
18
|
+
* - Token is malformed / expired
|
|
19
|
+
* - User no longer exists in DB
|
|
20
|
+
*
|
|
21
|
+
* Register in bootstrap/app.js:
|
|
22
|
+
* app.middleware('auth', AuthMiddleware)
|
|
23
|
+
*
|
|
24
|
+
* Apply to routes:
|
|
25
|
+
* Route.prefix('/api').middleware(['auth']).group(() => { ... })
|
|
26
|
+
*/
|
|
27
|
+
class AuthMiddleware extends Middleware {
|
|
28
|
+
async handle(req, res, next) {
|
|
29
|
+
const header = req.headers['authorization'];
|
|
30
|
+
|
|
31
|
+
if (!header) {
|
|
32
|
+
throw new HttpError(401, 'Authorization header missing');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!header.startsWith('Bearer ')) {
|
|
36
|
+
throw new HttpError(401, 'Invalid authorization format. Use: Bearer <token>');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const token = header.slice(7);
|
|
40
|
+
if (!token) {
|
|
41
|
+
throw new HttpError(401, 'Token is empty');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Verify token — throws 401 if expired or invalid
|
|
45
|
+
const payload = Auth.verify(token);
|
|
46
|
+
|
|
47
|
+
// Load user from DB
|
|
48
|
+
let user;
|
|
49
|
+
try {
|
|
50
|
+
user = await Auth.user(req);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new HttpError(401, 'Authentication service not configured');
|
|
53
|
+
}
|
|
54
|
+
if (!user) {
|
|
55
|
+
throw new HttpError(401, 'User not found or has been deleted');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Attach to request
|
|
59
|
+
req.user = user;
|
|
60
|
+
req.token = token;
|
|
61
|
+
req.tokenPayload = payload;
|
|
62
|
+
|
|
63
|
+
next();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = AuthMiddleware;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const bcrypt = require('bcryptjs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hasher
|
|
7
|
+
*
|
|
8
|
+
* Wraps bcrypt for password hashing and verification.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const hash = await Hasher.make('my-password');
|
|
12
|
+
* const ok = await Hasher.check('my-password', hash);
|
|
13
|
+
*/
|
|
14
|
+
class Hasher {
|
|
15
|
+
constructor(rounds = 12) {
|
|
16
|
+
this.rounds = rounds;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hash a plain-text password.
|
|
21
|
+
* @param {string} plain
|
|
22
|
+
* @returns {Promise<string>}
|
|
23
|
+
*/
|
|
24
|
+
async make(plain) {
|
|
25
|
+
return bcrypt.hash(String(plain), this.rounds);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Verify a plain-text password against a hash.
|
|
30
|
+
* @param {string} plain
|
|
31
|
+
* @param {string} hash
|
|
32
|
+
* @returns {Promise<boolean>}
|
|
33
|
+
*/
|
|
34
|
+
async check(plain, hash) {
|
|
35
|
+
if (!plain || !hash) return false;
|
|
36
|
+
return bcrypt.compare(String(plain), String(hash));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Determine if a value needs to be re-hashed
|
|
41
|
+
* (e.g. rounds have changed).
|
|
42
|
+
*/
|
|
43
|
+
needsRehash(hash) {
|
|
44
|
+
const rounds = bcrypt.getRounds(hash);
|
|
45
|
+
return rounds !== this.rounds;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Singleton with default rounds
|
|
50
|
+
module.exports = new Hasher(12);
|
|
51
|
+
module.exports.Hasher = Hasher;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const jwt = require('jsonwebtoken');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* JwtDriver
|
|
7
|
+
*
|
|
8
|
+
* Issues and verifies JSON Web Tokens.
|
|
9
|
+
*
|
|
10
|
+
* Config (config/auth.js):
|
|
11
|
+
* guards: {
|
|
12
|
+
* jwt: {
|
|
13
|
+
* driver: 'jwt',
|
|
14
|
+
* secret: process.env.APP_KEY,
|
|
15
|
+
* expiresIn: '7d',
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
class JwtDriver {
|
|
20
|
+
constructor(config = {}) {
|
|
21
|
+
this.secret = config.secret || process.env.APP_KEY || 'millas-secret-change-me';
|
|
22
|
+
this.expiresIn = config.expiresIn || '7d';
|
|
23
|
+
this.algorithm = config.algorithm || 'HS256';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sign a payload and return a token string.
|
|
28
|
+
* @param {object} payload — data to encode (e.g. { id, email, role })
|
|
29
|
+
* @param {object} options — override expiresIn etc.
|
|
30
|
+
*/
|
|
31
|
+
sign(payload, options = {}) {
|
|
32
|
+
return jwt.sign(payload, this.secret, {
|
|
33
|
+
algorithm: this.algorithm,
|
|
34
|
+
expiresIn: options.expiresIn || this.expiresIn,
|
|
35
|
+
...options,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Verify a token and return the decoded payload.
|
|
41
|
+
* Throws if expired or invalid.
|
|
42
|
+
* @param {string} token
|
|
43
|
+
* @returns {object} decoded payload
|
|
44
|
+
*/
|
|
45
|
+
verify(token) {
|
|
46
|
+
return jwt.verify(token, this.secret, { algorithms: [this.algorithm] });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decode a token WITHOUT verifying the signature.
|
|
51
|
+
* Useful for inspecting expired tokens.
|
|
52
|
+
* @param {string} token
|
|
53
|
+
* @returns {object|null}
|
|
54
|
+
*/
|
|
55
|
+
decode(token) {
|
|
56
|
+
return jwt.decode(token);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sign a short-lived token for password resets.
|
|
61
|
+
*/
|
|
62
|
+
signResetToken(payload) {
|
|
63
|
+
return this.sign(payload, { expiresIn: '1h' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sign a refresh token with a longer lifetime.
|
|
68
|
+
*/
|
|
69
|
+
signRefreshToken(payload) {
|
|
70
|
+
return this.sign(payload, { expiresIn: '30d' });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = JwtDriver;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Middleware = require('../middleware/Middleware');
|
|
4
|
+
const HttpError = require('../errors/HttpError');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RoleMiddleware
|
|
8
|
+
*
|
|
9
|
+
* Restricts route access to users with specific roles.
|
|
10
|
+
* Must be used AFTER AuthMiddleware (requires req.user).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* middlewareRegistry.register('admin', new RoleMiddleware(['admin']));
|
|
14
|
+
* middlewareRegistry.register('staff', new RoleMiddleware(['admin', 'staff']));
|
|
15
|
+
*
|
|
16
|
+
* Route.prefix('/admin').middleware(['auth', 'admin']).group(() => { ... });
|
|
17
|
+
*/
|
|
18
|
+
class RoleMiddleware extends Middleware {
|
|
19
|
+
/**
|
|
20
|
+
* @param {string[]} roles — list of allowed role values
|
|
21
|
+
*/
|
|
22
|
+
constructor(roles = []) {
|
|
23
|
+
super();
|
|
24
|
+
this.roles = Array.isArray(roles) ? roles : [roles];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async handle(req, res, next) {
|
|
28
|
+
if (!req.user) {
|
|
29
|
+
throw new HttpError(401, 'Unauthenticated — run AuthMiddleware before RoleMiddleware');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const userRole = req.user.role || null;
|
|
33
|
+
|
|
34
|
+
if (!userRole || !this.roles.includes(userRole)) {
|
|
35
|
+
throw new HttpError(403,
|
|
36
|
+
`Access denied. Required role: ${this.roles.join(' or ')}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
next();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = RoleMiddleware;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MemoryDriver = require('./drivers/MemoryDriver');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cache
|
|
7
|
+
*
|
|
8
|
+
* The primary caching facade.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const { Cache } = require('millas/src');
|
|
12
|
+
*
|
|
13
|
+
* await Cache.set('user:1', user, 300); // cache for 5 minutes
|
|
14
|
+
* const user = await Cache.get('user:1'); // retrieve
|
|
15
|
+
* await Cache.delete('user:1');
|
|
16
|
+
*
|
|
17
|
+
* // remember() — get or compute
|
|
18
|
+
* const posts = await Cache.remember('posts:all', 60, async () => {
|
|
19
|
+
* return Post.all();
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* // Tags — group related keys
|
|
23
|
+
* await Cache.tags('users').set('user:1', user, 300);
|
|
24
|
+
* await Cache.tags('users').flush(); // clear all user keys
|
|
25
|
+
*/
|
|
26
|
+
class Cache {
|
|
27
|
+
constructor() {
|
|
28
|
+
this._driver = null;
|
|
29
|
+
this._config = null;
|
|
30
|
+
this._prefix = '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Configuration ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
configure(config) {
|
|
36
|
+
this._config = config;
|
|
37
|
+
this._prefix = config.prefix || '';
|
|
38
|
+
this._driver = null; // reset so driver is rebuilt
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Core API ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Store a value in the cache.
|
|
45
|
+
* @param {string} key
|
|
46
|
+
* @param {*} value — must be JSON-serialisable
|
|
47
|
+
* @param {number} ttl — seconds (0 = forever)
|
|
48
|
+
*/
|
|
49
|
+
async set(key, value, ttl = 0) {
|
|
50
|
+
return this._getDriver().set(this._k(key), value, ttl);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Retrieve a value. Returns null if missing or expired.
|
|
55
|
+
*/
|
|
56
|
+
async get(key, defaultValue = null) {
|
|
57
|
+
const val = await this._getDriver().get(this._k(key));
|
|
58
|
+
return val !== null ? val : defaultValue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if a key exists and is not expired.
|
|
63
|
+
*/
|
|
64
|
+
async has(key) {
|
|
65
|
+
return this._getDriver().has(this._k(key));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Delete a key.
|
|
70
|
+
*/
|
|
71
|
+
async delete(key) {
|
|
72
|
+
return this._getDriver().delete(this._k(key));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Delete all keys matching a prefix pattern.
|
|
77
|
+
*/
|
|
78
|
+
async deletePattern(prefix) {
|
|
79
|
+
return this._getDriver().deletePattern(this._k(prefix));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Flush the entire cache.
|
|
84
|
+
*/
|
|
85
|
+
async flush() {
|
|
86
|
+
return this._getDriver().flush();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get or compute and cache.
|
|
91
|
+
*
|
|
92
|
+
* const data = await Cache.remember('key', 300, () => fetchExpensiveData());
|
|
93
|
+
*/
|
|
94
|
+
async remember(key, ttl, fn) {
|
|
95
|
+
return this._getDriver().remember(this._k(key), ttl, fn);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get once and delete — useful for flash messages / one-time tokens.
|
|
100
|
+
*/
|
|
101
|
+
async pull(key) {
|
|
102
|
+
const value = await this.get(key);
|
|
103
|
+
if (value !== null) await this.delete(key);
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Increment a numeric value.
|
|
109
|
+
*/
|
|
110
|
+
async increment(key, amount = 1) {
|
|
111
|
+
return this._getDriver().increment(this._k(key), amount);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decrement a numeric value.
|
|
116
|
+
*/
|
|
117
|
+
async decrement(key, amount = 1) {
|
|
118
|
+
return this._getDriver().decrement(this._k(key), amount);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Add only if key does not exist. Returns true if stored, false if skipped.
|
|
123
|
+
*/
|
|
124
|
+
async add(key, value, ttl = 0) {
|
|
125
|
+
return this._getDriver().add(this._k(key), value, ttl);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Retrieve multiple keys at once.
|
|
130
|
+
* Returns { key: value|null, ... }
|
|
131
|
+
*/
|
|
132
|
+
async getMany(keys) {
|
|
133
|
+
return this._getDriver().getMany(keys.map(k => this._k(k)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Set multiple key/value pairs at once.
|
|
138
|
+
*/
|
|
139
|
+
async setMany(entries, ttl = 0) {
|
|
140
|
+
const prefixed = Object.fromEntries(
|
|
141
|
+
Object.entries(entries).map(([k, v]) => [this._k(k), v])
|
|
142
|
+
);
|
|
143
|
+
return this._getDriver().setMany(prefixed, ttl);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Cache with tag grouping — lets you flush groups of related keys.
|
|
148
|
+
*
|
|
149
|
+
* await Cache.tags('users').set('user:1', user, 300);
|
|
150
|
+
* await Cache.tags('users', 'posts').flush(); // flush all tagged keys
|
|
151
|
+
*/
|
|
152
|
+
tags(...tagNames) {
|
|
153
|
+
return new TaggedCache(this, tagNames.flat());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Return the underlying driver instance.
|
|
158
|
+
*/
|
|
159
|
+
driver() {
|
|
160
|
+
return this._getDriver();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
_k(key) {
|
|
166
|
+
return this._prefix ? `${this._prefix}:${key}` : String(key);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_getDriver() {
|
|
170
|
+
if (this._driver) return this._driver;
|
|
171
|
+
|
|
172
|
+
const name = this._config?.default || process.env.CACHE_DRIVER || 'memory';
|
|
173
|
+
const conf = this._config?.drivers?.[name] || {};
|
|
174
|
+
|
|
175
|
+
switch (name) {
|
|
176
|
+
case 'file': {
|
|
177
|
+
const FileDriver = require('./drivers/FileDriver');
|
|
178
|
+
this._driver = new FileDriver(conf);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case 'null': {
|
|
182
|
+
const NullDriver = require('./drivers/NullDriver');
|
|
183
|
+
this._driver = new NullDriver();
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case 'memory':
|
|
187
|
+
default:
|
|
188
|
+
this._driver = new MemoryDriver();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return this._driver;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── TaggedCache ───────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* TaggedCache
|
|
199
|
+
*
|
|
200
|
+
* Wraps a Cache instance and prefixes every key with a tag namespace.
|
|
201
|
+
* Flush all keys for a tag group with .flush().
|
|
202
|
+
*/
|
|
203
|
+
class TaggedCache {
|
|
204
|
+
constructor(cache, tags) {
|
|
205
|
+
this._cache = cache;
|
|
206
|
+
this._tags = tags;
|
|
207
|
+
this._prefix = tags.join(':');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_k(key) { return `tag:${this._prefix}:${key}`; }
|
|
211
|
+
|
|
212
|
+
async set(key, value, ttl = 0) { return this._cache.set(this._k(key), value, ttl); }
|
|
213
|
+
async get(key, def = null) { return this._cache.get(this._k(key), def); }
|
|
214
|
+
async has(key) { return this._cache.has(this._k(key)); }
|
|
215
|
+
async delete(key) { return this._cache.delete(this._k(key)); }
|
|
216
|
+
async remember(key, ttl, fn) { return this._cache.remember(this._k(key), ttl, fn); }
|
|
217
|
+
async pull(key) { return this._cache.pull(this._k(key)); }
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Flush all keys belonging to these tags.
|
|
221
|
+
*/
|
|
222
|
+
async flush() {
|
|
223
|
+
return this._cache.deletePattern(`tag:${this._prefix}:`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Singleton
|
|
228
|
+
const cache = new Cache();
|
|
229
|
+
module.exports = cache;
|
|
230
|
+
module.exports.Cache = Cache;
|
|
231
|
+
module.exports.TaggedCache = TaggedCache;
|