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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/bin/millas.js +6 -0
  4. package/package.json +56 -0
  5. package/src/admin/Admin.js +617 -0
  6. package/src/admin/index.js +13 -0
  7. package/src/admin/resources/AdminResource.js +317 -0
  8. package/src/auth/Auth.js +254 -0
  9. package/src/auth/AuthController.js +188 -0
  10. package/src/auth/AuthMiddleware.js +67 -0
  11. package/src/auth/Hasher.js +51 -0
  12. package/src/auth/JwtDriver.js +74 -0
  13. package/src/auth/RoleMiddleware.js +44 -0
  14. package/src/cache/Cache.js +231 -0
  15. package/src/cache/drivers/FileDriver.js +152 -0
  16. package/src/cache/drivers/MemoryDriver.js +158 -0
  17. package/src/cache/drivers/NullDriver.js +27 -0
  18. package/src/cache/index.js +8 -0
  19. package/src/cli.js +27 -0
  20. package/src/commands/make.js +61 -0
  21. package/src/commands/migrate.js +174 -0
  22. package/src/commands/new.js +50 -0
  23. package/src/commands/queue.js +92 -0
  24. package/src/commands/route.js +93 -0
  25. package/src/commands/serve.js +50 -0
  26. package/src/container/Application.js +177 -0
  27. package/src/container/Container.js +281 -0
  28. package/src/container/index.js +13 -0
  29. package/src/controller/Controller.js +367 -0
  30. package/src/errors/HttpError.js +29 -0
  31. package/src/events/Event.js +39 -0
  32. package/src/events/EventEmitter.js +151 -0
  33. package/src/events/Listener.js +46 -0
  34. package/src/events/index.js +15 -0
  35. package/src/index.js +93 -0
  36. package/src/mail/Mail.js +210 -0
  37. package/src/mail/MailMessage.js +196 -0
  38. package/src/mail/TemplateEngine.js +150 -0
  39. package/src/mail/drivers/LogDriver.js +36 -0
  40. package/src/mail/drivers/MailgunDriver.js +84 -0
  41. package/src/mail/drivers/SendGridDriver.js +97 -0
  42. package/src/mail/drivers/SmtpDriver.js +67 -0
  43. package/src/mail/index.js +19 -0
  44. package/src/middleware/AuthMiddleware.js +46 -0
  45. package/src/middleware/CorsMiddleware.js +59 -0
  46. package/src/middleware/LogMiddleware.js +61 -0
  47. package/src/middleware/Middleware.js +36 -0
  48. package/src/middleware/MiddlewarePipeline.js +94 -0
  49. package/src/middleware/ThrottleMiddleware.js +61 -0
  50. package/src/orm/drivers/DatabaseManager.js +135 -0
  51. package/src/orm/fields/index.js +132 -0
  52. package/src/orm/index.js +19 -0
  53. package/src/orm/migration/MigrationRunner.js +216 -0
  54. package/src/orm/migration/ModelInspector.js +338 -0
  55. package/src/orm/migration/SchemaBuilder.js +173 -0
  56. package/src/orm/model/Model.js +371 -0
  57. package/src/orm/query/QueryBuilder.js +197 -0
  58. package/src/providers/AdminServiceProvider.js +40 -0
  59. package/src/providers/AuthServiceProvider.js +53 -0
  60. package/src/providers/CacheStorageServiceProvider.js +71 -0
  61. package/src/providers/DatabaseServiceProvider.js +45 -0
  62. package/src/providers/EventServiceProvider.js +34 -0
  63. package/src/providers/MailServiceProvider.js +51 -0
  64. package/src/providers/ProviderRegistry.js +82 -0
  65. package/src/providers/QueueServiceProvider.js +52 -0
  66. package/src/providers/ServiceProvider.js +45 -0
  67. package/src/queue/Job.js +135 -0
  68. package/src/queue/Queue.js +147 -0
  69. package/src/queue/drivers/DatabaseDriver.js +194 -0
  70. package/src/queue/drivers/SyncDriver.js +72 -0
  71. package/src/queue/index.js +16 -0
  72. package/src/queue/workers/QueueWorker.js +140 -0
  73. package/src/router/MiddlewareRegistry.js +82 -0
  74. package/src/router/Route.js +255 -0
  75. package/src/router/RouteGroup.js +19 -0
  76. package/src/router/RouteRegistry.js +55 -0
  77. package/src/router/Router.js +138 -0
  78. package/src/router/index.js +15 -0
  79. package/src/scaffold/generator.js +34 -0
  80. package/src/scaffold/maker.js +272 -0
  81. package/src/scaffold/templates.js +350 -0
  82. package/src/storage/Storage.js +170 -0
  83. package/src/storage/drivers/LocalDriver.js +215 -0
  84. package/src/storage/index.js +6 -0
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * DatabaseDriver
5
+ *
6
+ * Stores jobs in the `millas_jobs` database table.
7
+ * Requires a database connection configured in config/database.js.
8
+ *
9
+ * Run migration to create the table:
10
+ * millas migrate
11
+ *
12
+ * Start the worker:
13
+ * millas queue:work
14
+ * millas queue:work --queue emails,notifications
15
+ * millas queue:work --sleep 3
16
+ */
17
+ class DatabaseDriver {
18
+ constructor(config = {}) {
19
+ this._config = config;
20
+ this._connection = config.connection || null;
21
+ this._db = null;
22
+ this._table = config.table || 'millas_jobs';
23
+ this._failedTable = config.failedTable || 'millas_failed_jobs';
24
+ }
25
+
26
+ // ─── Push ─────────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Push a serialized job onto the queue.
30
+ */
31
+ async push(job) {
32
+ const db = this._getDb();
33
+ const record = job.serialize();
34
+ const runAt = record.delay
35
+ ? new Date(Date.now() + record.delay * 1000).toISOString()
36
+ : new Date().toISOString();
37
+
38
+ const [id] = await db(this._table).insert({
39
+ queue: record.queue,
40
+ payload: JSON.stringify(record),
41
+ attempts: 0,
42
+ max_tries: record.tries,
43
+ status: 'pending',
44
+ run_at: runAt,
45
+ created_at: new Date().toISOString(),
46
+ });
47
+
48
+ return { id, status: 'queued', queue: record.queue };
49
+ }
50
+
51
+ // ─── Pop (used by worker) ─────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Fetch the next available job from the queue.
55
+ * Marks it as 'processing' atomically.
56
+ */
57
+ async pop(queue = 'default') {
58
+ const db = this._getDb();
59
+ const now = new Date().toISOString();
60
+
61
+ const row = await db(this._table)
62
+ .where('queue', queue)
63
+ .where('status', 'pending')
64
+ .where('run_at', '<=', now)
65
+ .orderBy('run_at', 'asc')
66
+ .first();
67
+
68
+ if (!row) return null;
69
+
70
+ await db(this._table).where('id', row.id).update({
71
+ status: 'processing',
72
+ started_at: now,
73
+ attempts: row.attempts + 1,
74
+ });
75
+
76
+ return { ...row, attempts: row.attempts + 1 };
77
+ }
78
+
79
+ // ─── Complete / Fail ──────────────────────────────────────────────────────
80
+
81
+ async complete(id) {
82
+ await this._getDb()(this._table).where('id', id).update({
83
+ status: 'completed',
84
+ finished_at: new Date().toISOString(),
85
+ });
86
+ }
87
+
88
+ async fail(id, error, record) {
89
+ const db = this._getDb();
90
+ const row = await db(this._table).where('id', id).first();
91
+ const maxTries = row?.max_tries || 3;
92
+ const attempts = row?.attempts || 1;
93
+
94
+ if (attempts < maxTries) {
95
+ // Schedule retry with backoff
96
+ const backoff = this._backoff(record?.backoff || 'exponential', attempts);
97
+ const runAt = new Date(Date.now() + backoff * 1000).toISOString();
98
+
99
+ await db(this._table).where('id', id).update({
100
+ status: 'pending',
101
+ run_at: runAt,
102
+ last_error: error.message,
103
+ });
104
+ } else {
105
+ // Move to failed jobs table
106
+ await db(this._failedTable).insert({
107
+ queue: row?.queue,
108
+ payload: row?.payload,
109
+ error: error.message,
110
+ failed_at: new Date().toISOString(),
111
+ });
112
+ await db(this._table).where('id', id).delete();
113
+ }
114
+ }
115
+
116
+ async release(id) {
117
+ await this._getDb()(this._table).where('id', id).update({ status: 'pending' });
118
+ }
119
+
120
+ // ─── Stats ────────────────────────────────────────────────────────────────
121
+
122
+ async size(queue = 'default') {
123
+ const result = await this._getDb()(this._table)
124
+ .where('queue', queue).where('status', 'pending').count('* as count').first();
125
+ return Number(result?.count || 0);
126
+ }
127
+
128
+ async clear(queue = 'default') {
129
+ return this._getDb()(this._table)
130
+ .where('queue', queue).where('status', 'pending').delete();
131
+ }
132
+
133
+ async stats() {
134
+ const db = this._getDb();
135
+ const rows = await db(this._table)
136
+ .select('queue', 'status')
137
+ .count('* as count')
138
+ .groupBy('queue', 'status');
139
+ return rows;
140
+ }
141
+
142
+ // ─── Schema ───────────────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * Create the jobs tables. Called by migrate.
146
+ */
147
+ async createTables() {
148
+ const db = this._getDb();
149
+
150
+ const jobsExists = await db.schema.hasTable(this._table);
151
+ if (!jobsExists) {
152
+ await db.schema.createTable(this._table, (t) => {
153
+ t.increments('id');
154
+ t.string('queue', 100).notNullable().defaultTo('default').index();
155
+ t.text('payload').notNullable();
156
+ t.integer('attempts').defaultTo(0);
157
+ t.integer('max_tries').defaultTo(3);
158
+ t.string('status', 20).defaultTo('pending').index();
159
+ t.string('last_error').nullable();
160
+ t.timestamp('run_at').nullable().index();
161
+ t.timestamp('started_at').nullable();
162
+ t.timestamp('finished_at').nullable();
163
+ t.timestamp('created_at').nullable();
164
+ });
165
+ }
166
+
167
+ const failedExists = await db.schema.hasTable(this._failedTable);
168
+ if (!failedExists) {
169
+ await db.schema.createTable(this._failedTable, (t) => {
170
+ t.increments('id');
171
+ t.string('queue', 100).nullable();
172
+ t.text('payload').nullable();
173
+ t.text('error').nullable();
174
+ t.timestamp('failed_at').nullable();
175
+ });
176
+ }
177
+ }
178
+
179
+ // ─── Internal ─────────────────────────────────────────────────────────────
180
+
181
+ _getDb() {
182
+ if (this._db) return this._db;
183
+ const DatabaseManager = require('../orm/drivers/DatabaseManager');
184
+ this._db = DatabaseManager.connection(this._connection);
185
+ return this._db;
186
+ }
187
+
188
+ _backoff(strategy, attempt) {
189
+ if (strategy === 'exponential') return Math.min(Math.pow(2, attempt), 3600);
190
+ return 60; // fixed: 60 seconds
191
+ }
192
+ }
193
+
194
+ module.exports = DatabaseDriver;
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SyncDriver
5
+ *
6
+ * Executes jobs immediately and synchronously in the current process.
7
+ * Default driver — no Redis or DB required.
8
+ *
9
+ * Perfect for:
10
+ * - Local development
11
+ * - Testing
12
+ * - Simple apps that don't need background processing
13
+ *
14
+ * Set QUEUE_DRIVER=sync in .env
15
+ */
16
+ class SyncDriver {
17
+ constructor() {
18
+ this._processed = [];
19
+ this._failed = [];
20
+ }
21
+
22
+ /**
23
+ * Push and immediately execute a job.
24
+ */
25
+ async push(job) {
26
+ const record = {
27
+ id: this._id(),
28
+ job: job.constructor.name,
29
+ queue: job._queue || job.constructor.queue || 'default',
30
+ payload: job._getPayload(),
31
+ attempts: 0,
32
+ status: 'processing',
33
+ createdAt: new Date().toISOString(),
34
+ };
35
+
36
+ try {
37
+ await job.handle();
38
+ record.status = 'completed';
39
+ record.finishedAt = new Date().toISOString();
40
+ this._processed.push(record);
41
+ return { id: record.id, status: 'completed' };
42
+ } catch (error) {
43
+ record.status = 'failed';
44
+ record.error = error.message;
45
+ record.failedAt = new Date().toISOString();
46
+ this._failed.push(record);
47
+
48
+ if (typeof job.failed === 'function') {
49
+ await job.failed(error).catch(() => {});
50
+ }
51
+
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * SyncDriver has no persistent queue — nothing to work.
58
+ */
59
+ async work() {
60
+ return { processed: 0, message: 'SyncDriver: jobs run immediately on dispatch.' };
61
+ }
62
+
63
+ async size(queue = 'default') { return 0; }
64
+ async clear(queue = 'default') { return 0; }
65
+
66
+ processed() { return [...this._processed]; }
67
+ failed() { return [...this._failed]; }
68
+
69
+ _id() { return `sync_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
70
+ }
71
+
72
+ module.exports = SyncDriver;
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ const Queue = require('./Queue');
4
+ const Job = require('./Job');
5
+ const QueueWorker = require('./workers/QueueWorker');
6
+ const SyncDriver = require('./drivers/SyncDriver');
7
+ const DatabaseDriver = require('./drivers/DatabaseDriver');
8
+
9
+ module.exports = {
10
+ Queue,
11
+ Job,
12
+ QueueWorker,
13
+ SyncDriver,
14
+ DatabaseDriver,
15
+ dispatch: Queue.dispatch,
16
+ };
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * QueueWorker
5
+ *
6
+ * Polls the queue driver and executes pending jobs.
7
+ * Started by: millas queue:work
8
+ *
9
+ * Usage:
10
+ * const worker = new QueueWorker(driver, registry, options);
11
+ * await worker.start();
12
+ */
13
+ class QueueWorker {
14
+ constructor(driver, jobRegistry, options = {}) {
15
+ this._driver = driver;
16
+ this._registry = jobRegistry;
17
+ this._queues = options.queues || ['default'];
18
+ this._sleep = options.sleep || 3;
19
+ this._maxJobs = options.maxJobs || Infinity;
20
+ this._handleSignals = options.handleSignals !== false && options.maxJobs === undefined;
21
+ this._silent = options.silent || options.maxJobs !== undefined;
22
+ this._running = false;
23
+ this._processed = 0;
24
+ this._failed = 0;
25
+ }
26
+
27
+ /**
28
+ * Start the worker loop.
29
+ */
30
+ async start() {
31
+ this._running = true;
32
+
33
+ if (!this._silent) {
34
+ console.log(`\n ⚡ Queue worker started`);
35
+ console.log(` Queues: ${this._queues.join(', ')}`);
36
+ console.log(` Sleep: ${this._sleep}s between polls\n`);
37
+ }
38
+
39
+ // Only register signal handlers when running as main process
40
+ if (this._handleSignals) {
41
+ process.on('SIGINT', () => this.stop('SIGINT'));
42
+ process.on('SIGTERM', () => this.stop('SIGTERM'));
43
+ }
44
+
45
+ while (this._running && this._processed < this._maxJobs) {
46
+ let worked = false;
47
+
48
+ for (const queue of this._queues) {
49
+ const record = await this._driver.pop(queue);
50
+ if (record) {
51
+ await this._process(record);
52
+ worked = true;
53
+ break;
54
+ }
55
+ }
56
+
57
+ if (!worked) {
58
+ // If maxJobs is finite and queue is empty, stop
59
+ if (this._maxJobs < Infinity) break;
60
+ await this._sleep_ms(this._sleep * 1000);
61
+ }
62
+ }
63
+
64
+ if (!this._silent) {
65
+ console.log(`\n Worker stopped. Processed: ${this._processed}, Failed: ${this._failed}\n`);
66
+ }
67
+ }
68
+
69
+ stop(signal = 'manual') {
70
+ if (!this._silent) console.log(`\n [${signal}] Stopping worker gracefully...`);
71
+ this._running = false;
72
+ }
73
+
74
+ stats() {
75
+ return { processed: this._processed, failed: this._failed };
76
+ }
77
+
78
+ // ─── Internal ─────────────────────────────────────────────────────────────
79
+
80
+ async _process(record) {
81
+ let payload;
82
+ try {
83
+ payload = JSON.parse(record.payload);
84
+ } catch {
85
+ console.error(` ✖ Failed to parse job payload (id: ${record.id})`);
86
+ await this._driver.fail(record.id, new Error('Invalid payload'), {});
87
+ this._failed++;
88
+ return;
89
+ }
90
+
91
+ const JobClass = this._registry.get(payload.class);
92
+ if (!JobClass) {
93
+ console.error(` ✖ Unknown job class: ${payload.class}`);
94
+ await this._driver.fail(record.id, new Error(`Unknown job: ${payload.class}`), payload);
95
+ this._failed++;
96
+ return;
97
+ }
98
+
99
+ const job = JobClass.deserialize
100
+ ? JobClass.deserialize({ ...record, payload: payload.payload || {} }, JobClass)
101
+ : Object.assign(new JobClass(), payload.payload || {});
102
+
103
+ const start = Date.now();
104
+ process.stdout.write(` ⟳ ${payload.class} (id: ${record.id})... `);
105
+
106
+ try {
107
+ await Promise.race([
108
+ job.handle(),
109
+ this._timeout(payload.timeout || 60),
110
+ ]);
111
+
112
+ const ms = Date.now() - start;
113
+ console.log(`\x1b[32m✔\x1b[0m ${ms}ms`);
114
+ await this._driver.complete(record.id);
115
+ this._processed++;
116
+ } catch (error) {
117
+ const ms = Date.now() - start;
118
+ console.log(`\x1b[31m✖\x1b[0m ${ms}ms — ${error.message}`);
119
+ await this._driver.fail(record.id, error, payload);
120
+
121
+ if (typeof job.failed === 'function') {
122
+ await job.failed(error).catch(() => {});
123
+ }
124
+
125
+ this._failed++;
126
+ }
127
+ }
128
+
129
+ _timeout(seconds) {
130
+ return new Promise((_, reject) =>
131
+ setTimeout(() => reject(new Error(`Job timed out after ${seconds}s`)), seconds * 1000)
132
+ );
133
+ }
134
+
135
+ _sleep_ms(ms) {
136
+ return new Promise(resolve => setTimeout(resolve, ms));
137
+ }
138
+ }
139
+
140
+ module.exports = QueueWorker;
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MiddlewareRegistry
5
+ *
6
+ * Maps string aliases → middleware handler classes or functions.
7
+ *
8
+ * Usage:
9
+ * MiddlewareRegistry.register('auth', AuthMiddleware);
10
+ * MiddlewareRegistry.register('throttle', ThrottleMiddleware);
11
+ *
12
+ * Registered automatically by AppServiceProvider (Phase 3+).
13
+ */
14
+ class MiddlewareRegistry {
15
+ constructor() {
16
+ this._map = {};
17
+ }
18
+
19
+ /**
20
+ * Register a middleware alias.
21
+ * @param {string} alias
22
+ * @param {Function|object} handler — class with handle() or raw Express fn
23
+ */
24
+ register(alias, handler) {
25
+ this._map[alias] = handler;
26
+ }
27
+
28
+ /**
29
+ * Resolve a single alias to an Express-compatible function.
30
+ * @param {string|Function} aliasOrFn
31
+ * @returns {Function}
32
+ */
33
+ resolve(aliasOrFn) {
34
+ if (typeof aliasOrFn === 'function') return aliasOrFn;
35
+
36
+ const Handler = this._map[aliasOrFn];
37
+ if (!Handler) {
38
+ throw new Error(`Middleware "${aliasOrFn}" is not registered.`);
39
+ }
40
+
41
+ // Pre-instantiated object with handle() method (e.g. new ThrottleMiddleware())
42
+ if (typeof Handler === 'object' && Handler !== null && typeof Handler.handle === 'function') {
43
+ return (req, res, next) => {
44
+ const result = Handler.handle(req, res, next);
45
+ if (result && typeof result.catch === 'function') result.catch(next);
46
+ };
47
+ }
48
+
49
+ // Class with handle() on prototype
50
+ if (typeof Handler === 'function' && Handler.prototype && typeof Handler.prototype.handle === 'function') {
51
+ const instance = new Handler();
52
+ return (req, res, next) => {
53
+ const result = instance.handle(req, res, next);
54
+ if (result && typeof result.catch === 'function') result.catch(next);
55
+ };
56
+ }
57
+
58
+ // Raw Express function
59
+ if (typeof Handler === 'function') return Handler;
60
+
61
+ throw new Error(`Middleware "${aliasOrFn}" must be a function or class with handle().`);
62
+ }
63
+
64
+ /**
65
+ * Resolve an array of aliases/functions.
66
+ * @param {Array} list
67
+ * @returns {Function[]}
68
+ */
69
+ resolveAll(list = []) {
70
+ return list.map(m => this.resolve(m));
71
+ }
72
+
73
+ has(alias) {
74
+ return Object.prototype.hasOwnProperty.call(this._map, alias);
75
+ }
76
+
77
+ all() {
78
+ return { ...this._map };
79
+ }
80
+ }
81
+
82
+ module.exports = MiddlewareRegistry;