webspresso 0.0.90 → 0.0.93
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 +98 -2509
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +199 -0
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/middleware.js +9 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +63 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +310 -0
- package/core/compression/negotiate.js +165 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/complexity.js +120 -0
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +83 -12
- package/core/orm/model.js +13 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +317 -0
- package/core/queue/adapters/memory.js +176 -0
- package/core/queue/adapters/redis.js +357 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +413 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/core/validation/index.js +70 -0
- package/index.d.ts +924 -53
- package/index.js +110 -1
- package/package.json +30 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +155 -23
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/data-exchange/export-xlsx.js +28 -2
- package/plugins/data-exchange/import.js +55 -16
- package/plugins/data-exchange/index.js +19 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +33 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +139 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +366 -130
- package/src/helpers.js +20 -2
- package/src/njk-frontmatter.js +24 -14
- package/src/plugin-manager.js +124 -4
- package/src/server.js +332 -54
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +141 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +290 -0
- package/src/services/validator.js +230 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-Memory Queue Adapter
|
|
3
|
+
* @module core/queue/adapters/memory
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { Job } = require('../job');
|
|
9
|
+
|
|
10
|
+
class MemoryQueueAdapter {
|
|
11
|
+
constructor() {
|
|
12
|
+
/** @type {Map<string|number, Job>} */
|
|
13
|
+
this.jobs = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Enqueue a job into memory
|
|
18
|
+
* @param {Job} job
|
|
19
|
+
* @returns {Promise<Job>}
|
|
20
|
+
*/
|
|
21
|
+
async enqueue(job) {
|
|
22
|
+
const jobInstance = job instanceof Job ? job : new Job(job);
|
|
23
|
+
this.jobs.set(jobInstance.id, jobInstance);
|
|
24
|
+
return jobInstance;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Dequeue the highest priority pending job matching registered job names
|
|
29
|
+
* @param {string[]} [jobNames]
|
|
30
|
+
* @returns {Promise<Job|null>}
|
|
31
|
+
*/
|
|
32
|
+
async dequeue(jobNames = []) {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
let candidate = null;
|
|
35
|
+
|
|
36
|
+
for (const job of this.jobs.values()) {
|
|
37
|
+
if (job.status !== 'pending') continue;
|
|
38
|
+
if (job.runAt && job.runAt.getTime() > now) continue;
|
|
39
|
+
if (jobNames.length > 0 && !jobNames.includes(job.name)) continue;
|
|
40
|
+
|
|
41
|
+
if (!candidate) {
|
|
42
|
+
candidate = job;
|
|
43
|
+
} else {
|
|
44
|
+
// Priority (higher first) then runAt (earlier first)
|
|
45
|
+
if (job.priority > candidate.priority) {
|
|
46
|
+
candidate = job;
|
|
47
|
+
} else if (job.priority === candidate.priority && job.runAt.getTime() < candidate.runAt.getTime()) {
|
|
48
|
+
candidate = job;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (candidate) {
|
|
54
|
+
candidate.status = 'running';
|
|
55
|
+
candidate.attempts += 1;
|
|
56
|
+
candidate.updatedAt = new Date();
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Dequeue multiple pending jobs up to limit
|
|
65
|
+
* @param {string[]} [jobNames]
|
|
66
|
+
* @param {number} [limit=1]
|
|
67
|
+
* @returns {Promise<Job[]>}
|
|
68
|
+
*/
|
|
69
|
+
async dequeueMany(jobNames = [], limit = 1) {
|
|
70
|
+
if (limit <= 1) {
|
|
71
|
+
const single = await this.dequeue(jobNames);
|
|
72
|
+
return single ? [single] : [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const jobs = [];
|
|
76
|
+
for (let i = 0; i < limit; i++) {
|
|
77
|
+
const job = await this.dequeue(jobNames);
|
|
78
|
+
if (!job) break;
|
|
79
|
+
jobs.push(job);
|
|
80
|
+
}
|
|
81
|
+
return jobs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Mark job as completed
|
|
86
|
+
* @param {string|number} jobId
|
|
87
|
+
* @param {any} [result]
|
|
88
|
+
* @returns {Promise<Job|null>}
|
|
89
|
+
*/
|
|
90
|
+
async complete(jobId, result = null) {
|
|
91
|
+
const job = this.jobs.get(jobId);
|
|
92
|
+
if (!job) return null;
|
|
93
|
+
job.status = 'completed';
|
|
94
|
+
job.result = result;
|
|
95
|
+
job.progressValue = 100;
|
|
96
|
+
job.updatedAt = new Date();
|
|
97
|
+
return job;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Mark job as permanently failed
|
|
102
|
+
* @param {string|number} jobId
|
|
103
|
+
* @param {any} [error]
|
|
104
|
+
* @returns {Promise<Job|null>}
|
|
105
|
+
*/
|
|
106
|
+
async fail(jobId, error = null) {
|
|
107
|
+
const job = this.jobs.get(jobId);
|
|
108
|
+
if (!job) return null;
|
|
109
|
+
job.status = 'failed';
|
|
110
|
+
job.error = error;
|
|
111
|
+
job.updatedAt = new Date();
|
|
112
|
+
return job;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Re-schedule job for retry
|
|
117
|
+
* @param {string|number} jobId
|
|
118
|
+
* @param {number} delayMs
|
|
119
|
+
* @param {any} [error]
|
|
120
|
+
* @returns {Promise<Job|null>}
|
|
121
|
+
*/
|
|
122
|
+
async retry(jobId, delayMs, error = null) {
|
|
123
|
+
const job = this.jobs.get(jobId);
|
|
124
|
+
if (!job) return null;
|
|
125
|
+
job.status = 'pending';
|
|
126
|
+
job.runAt = new Date(Date.now() + Math.max(0, delayMs));
|
|
127
|
+
job.error = error;
|
|
128
|
+
job.updatedAt = new Date();
|
|
129
|
+
return job;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get job by ID
|
|
134
|
+
* @param {string|number} jobId
|
|
135
|
+
* @returns {Promise<Job|null>}
|
|
136
|
+
*/
|
|
137
|
+
async getJob(jobId) {
|
|
138
|
+
return this.jobs.get(jobId) || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get queue statistics
|
|
143
|
+
* @returns {Promise<{ pending: number, running: number, completed: number, failed: number, total: number }>}
|
|
144
|
+
*/
|
|
145
|
+
async getStats() {
|
|
146
|
+
let pending = 0;
|
|
147
|
+
let running = 0;
|
|
148
|
+
let completed = 0;
|
|
149
|
+
let failed = 0;
|
|
150
|
+
|
|
151
|
+
for (const job of this.jobs.values()) {
|
|
152
|
+
if (job.status === 'pending') pending++;
|
|
153
|
+
else if (job.status === 'running') running++;
|
|
154
|
+
else if (job.status === 'completed') completed++;
|
|
155
|
+
else if (job.status === 'failed') failed++;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
pending,
|
|
160
|
+
running,
|
|
161
|
+
completed,
|
|
162
|
+
failed,
|
|
163
|
+
total: this.jobs.size,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Clear all jobs
|
|
169
|
+
* @returns {Promise<void>}
|
|
170
|
+
*/
|
|
171
|
+
async clear() {
|
|
172
|
+
this.jobs.clear();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { MemoryQueueAdapter };
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distributed Redis Queue Adapter
|
|
3
|
+
* @module core/queue/adapters/redis
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { Job } = require('../job');
|
|
9
|
+
|
|
10
|
+
class RedisQueueAdapter {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {any} options.client - ioredis or @redis/client instance
|
|
14
|
+
* @param {string} [options.prefix='webspresso:queue:'] - Key prefix
|
|
15
|
+
*/
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
if (!options.client) {
|
|
18
|
+
throw new Error('RedisQueueAdapter requires a Redis client in options.client');
|
|
19
|
+
}
|
|
20
|
+
this.client = options.client;
|
|
21
|
+
this.prefix = options.prefix || 'webspresso:queue:';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_key(name) {
|
|
25
|
+
return this.prefix + name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Enqueue a job into Redis
|
|
30
|
+
* @param {Job} job
|
|
31
|
+
* @returns {Promise<Job>}
|
|
32
|
+
*/
|
|
33
|
+
async enqueue(job) {
|
|
34
|
+
const jobInstance = job instanceof Job ? job : new Job(job);
|
|
35
|
+
const jobKey = this._key('job:' + jobInstance.id);
|
|
36
|
+
const delayedKey = this._key('delayed');
|
|
37
|
+
const waitingKey = this._key('waiting');
|
|
38
|
+
|
|
39
|
+
const jobData = JSON.stringify(jobInstance.toJSON());
|
|
40
|
+
|
|
41
|
+
// Save job payload
|
|
42
|
+
if (typeof this.client.set === 'function') {
|
|
43
|
+
await this.client.set(jobKey, jobData);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const runAt = jobInstance.runAt ? jobInstance.runAt.getTime() : now;
|
|
48
|
+
|
|
49
|
+
if (runAt > now) {
|
|
50
|
+
// Delayed job
|
|
51
|
+
if (typeof this.client.zadd === 'function') {
|
|
52
|
+
await this.client.zadd(delayedKey, runAt, String(jobInstance.id));
|
|
53
|
+
} else if (typeof this.client.zAdd === 'function') {
|
|
54
|
+
await this.client.zAdd(delayedKey, [{ score: runAt, value: String(jobInstance.id) }]);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
// Ready to process (score is negative priority to sort highest priority first)
|
|
58
|
+
const score = (100 - (jobInstance.priority || 10)) * 1000000000000 + runAt;
|
|
59
|
+
if (typeof this.client.zadd === 'function') {
|
|
60
|
+
await this.client.zadd(waitingKey, score, String(jobInstance.id));
|
|
61
|
+
} else if (typeof this.client.zAdd === 'function') {
|
|
62
|
+
await this.client.zAdd(waitingKey, [{ score, value: String(jobInstance.id) }]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return jobInstance;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Move due delayed jobs into waiting queue
|
|
71
|
+
* @private
|
|
72
|
+
*/
|
|
73
|
+
async _promoteDelayed() {
|
|
74
|
+
const delayedKey = this._key('delayed');
|
|
75
|
+
const waitingKey = this._key('waiting');
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
let dueIds = [];
|
|
80
|
+
if (typeof this.client.zrangebyscore === 'function') {
|
|
81
|
+
dueIds = await this.client.zrangebyscore(delayedKey, 0, now);
|
|
82
|
+
} else if (typeof this.client.zRangeByScore === 'function') {
|
|
83
|
+
dueIds = await this.client.zRangeByScore(delayedKey, 0, now);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (dueIds && dueIds.length > 0) {
|
|
87
|
+
for (const id of dueIds) {
|
|
88
|
+
const job = await this.getJob(id);
|
|
89
|
+
if (job) {
|
|
90
|
+
const score = (100 - (job.priority || 10)) * 1000000000000 + now;
|
|
91
|
+
if (typeof this.client.zadd === 'function') {
|
|
92
|
+
await this.client.zadd(waitingKey, score, String(id));
|
|
93
|
+
await this.client.zrem(delayedKey, String(id));
|
|
94
|
+
} else if (typeof this.client.zAdd === 'function') {
|
|
95
|
+
await this.client.zAdd(waitingKey, [{ score, value: String(id) }]);
|
|
96
|
+
await this.client.zRem(delayedKey, String(id));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Dequeue next pending job
|
|
106
|
+
* @param {string[]} [jobNames]
|
|
107
|
+
* @returns {Promise<Job|null>}
|
|
108
|
+
*/
|
|
109
|
+
async dequeue(jobNames = []) {
|
|
110
|
+
await this._promoteDelayed();
|
|
111
|
+
|
|
112
|
+
const waitingKey = this._key('waiting');
|
|
113
|
+
const activeKey = this._key('active');
|
|
114
|
+
|
|
115
|
+
let ids = [];
|
|
116
|
+
if (typeof this.client.zrange === 'function') {
|
|
117
|
+
ids = await this.client.zrange(waitingKey, 0, 50);
|
|
118
|
+
} else if (typeof this.client.zRange === 'function') {
|
|
119
|
+
ids = await this.client.zRange(waitingKey, 0, 50);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!ids || ids.length === 0) return null;
|
|
123
|
+
|
|
124
|
+
for (const id of ids) {
|
|
125
|
+
const job = await this.getJob(id);
|
|
126
|
+
if (!job) {
|
|
127
|
+
if (typeof this.client.zrem === 'function') await this.client.zrem(waitingKey, String(id));
|
|
128
|
+
else if (typeof this.client.zRem === 'function') await this.client.zRem(waitingKey, String(id));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (jobNames.length > 0 && !jobNames.includes(job.name)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Pop from waiting and add to active
|
|
137
|
+
if (typeof this.client.zrem === 'function') {
|
|
138
|
+
await this.client.zrem(waitingKey, String(id));
|
|
139
|
+
await this.client.sadd(activeKey, String(id));
|
|
140
|
+
} else if (typeof this.client.zRem === 'function') {
|
|
141
|
+
await this.client.zRem(waitingKey, String(id));
|
|
142
|
+
await this.client.sAdd(activeKey, String(id));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
job.status = 'running';
|
|
146
|
+
job.attempts += 1;
|
|
147
|
+
job.updatedAt = new Date();
|
|
148
|
+
|
|
149
|
+
const jobKey = this._key('job:' + job.id);
|
|
150
|
+
if (typeof this.client.set === 'function') {
|
|
151
|
+
await this.client.set(jobKey, JSON.stringify(job.toJSON()));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return job;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Dequeue multiple pending jobs up to limit
|
|
162
|
+
* @param {string[]} [jobNames]
|
|
163
|
+
* @param {number} [limit=1]
|
|
164
|
+
* @returns {Promise<Job[]>}
|
|
165
|
+
*/
|
|
166
|
+
async dequeueMany(jobNames = [], limit = 1) {
|
|
167
|
+
if (limit <= 1) {
|
|
168
|
+
const single = await this.dequeue(jobNames);
|
|
169
|
+
return single ? [single] : [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const jobs = [];
|
|
173
|
+
for (let i = 0; i < limit; i++) {
|
|
174
|
+
const job = await this.dequeue(jobNames);
|
|
175
|
+
if (!job) break;
|
|
176
|
+
jobs.push(job);
|
|
177
|
+
}
|
|
178
|
+
return jobs;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Mark job as completed
|
|
183
|
+
* @param {string|number} jobId
|
|
184
|
+
* @param {any} [result]
|
|
185
|
+
* @returns {Promise<Job|null>}
|
|
186
|
+
*/
|
|
187
|
+
async complete(jobId, result = null) {
|
|
188
|
+
const job = await this.getJob(jobId);
|
|
189
|
+
if (!job) return null;
|
|
190
|
+
|
|
191
|
+
const activeKey = this._key('active');
|
|
192
|
+
const completedKey = this._key('completed');
|
|
193
|
+
const jobKey = this._key('job:' + jobId);
|
|
194
|
+
|
|
195
|
+
if (typeof this.client.srem === 'function') {
|
|
196
|
+
await this.client.srem(activeKey, String(jobId));
|
|
197
|
+
await this.client.sadd(completedKey, String(jobId));
|
|
198
|
+
} else if (typeof this.client.sRem === 'function') {
|
|
199
|
+
await this.client.sRem(activeKey, String(jobId));
|
|
200
|
+
await this.client.sAdd(completedKey, String(jobId));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
job.status = 'completed';
|
|
204
|
+
job.result = result;
|
|
205
|
+
job.progressValue = 100;
|
|
206
|
+
job.updatedAt = new Date();
|
|
207
|
+
|
|
208
|
+
if (typeof this.client.set === 'function') {
|
|
209
|
+
await this.client.set(jobKey, JSON.stringify(job.toJSON()));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return job;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Mark job as failed
|
|
217
|
+
* @param {string|number} jobId
|
|
218
|
+
* @param {any} [error]
|
|
219
|
+
* @returns {Promise<Job|null>}
|
|
220
|
+
*/
|
|
221
|
+
async fail(jobId, error = null) {
|
|
222
|
+
const job = await this.getJob(jobId);
|
|
223
|
+
if (!job) return null;
|
|
224
|
+
|
|
225
|
+
const activeKey = this._key('active');
|
|
226
|
+
const failedKey = this._key('failed');
|
|
227
|
+
const jobKey = this._key('job:' + jobId);
|
|
228
|
+
|
|
229
|
+
if (typeof this.client.srem === 'function') {
|
|
230
|
+
await this.client.srem(activeKey, String(jobId));
|
|
231
|
+
await this.client.sadd(failedKey, String(jobId));
|
|
232
|
+
} else if (typeof this.client.sRem === 'function') {
|
|
233
|
+
await this.client.sRem(activeKey, String(jobId));
|
|
234
|
+
await this.client.sAdd(failedKey, String(jobId));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
job.status = 'failed';
|
|
238
|
+
job.error = error;
|
|
239
|
+
job.updatedAt = new Date();
|
|
240
|
+
|
|
241
|
+
if (typeof this.client.set === 'function') {
|
|
242
|
+
await this.client.set(jobKey, JSON.stringify(job.toJSON()));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return job;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Re-enqueue job for retry
|
|
250
|
+
* @param {string|number} jobId
|
|
251
|
+
* @param {number} delayMs
|
|
252
|
+
* @param {any} [error]
|
|
253
|
+
* @returns {Promise<Job|null>}
|
|
254
|
+
*/
|
|
255
|
+
async retry(jobId, delayMs, error = null) {
|
|
256
|
+
const job = await this.getJob(jobId);
|
|
257
|
+
if (!job) return null;
|
|
258
|
+
|
|
259
|
+
const activeKey = this._key('active');
|
|
260
|
+
if (typeof this.client.srem === 'function') {
|
|
261
|
+
await this.client.srem(activeKey, String(jobId));
|
|
262
|
+
} else if (typeof this.client.sRem === 'function') {
|
|
263
|
+
await this.client.sRem(activeKey, String(jobId));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
job.status = 'pending';
|
|
267
|
+
job.runAt = new Date(Date.now() + Math.max(0, delayMs));
|
|
268
|
+
job.error = error;
|
|
269
|
+
job.updatedAt = new Date();
|
|
270
|
+
|
|
271
|
+
await this.enqueue(job);
|
|
272
|
+
return job;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get job by ID
|
|
277
|
+
* @param {string|number} jobId
|
|
278
|
+
* @returns {Promise<Job|null>}
|
|
279
|
+
*/
|
|
280
|
+
async getJob(jobId) {
|
|
281
|
+
const jobKey = this._key('job:' + jobId);
|
|
282
|
+
let raw = null;
|
|
283
|
+
if (typeof this.client.get === 'function') {
|
|
284
|
+
raw = await this.client.get(jobKey);
|
|
285
|
+
}
|
|
286
|
+
if (!raw) return null;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
290
|
+
return new Job(parsed);
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get stats
|
|
298
|
+
* @returns {Promise<{ pending: number, running: number, completed: number, failed: number, total: number }>}
|
|
299
|
+
*/
|
|
300
|
+
async getStats() {
|
|
301
|
+
const waitingKey = this._key('waiting');
|
|
302
|
+
const delayedKey = this._key('delayed');
|
|
303
|
+
const activeKey = this._key('active');
|
|
304
|
+
const completedKey = this._key('completed');
|
|
305
|
+
const failedKey = this._key('failed');
|
|
306
|
+
|
|
307
|
+
let pending = 0;
|
|
308
|
+
let delayed = 0;
|
|
309
|
+
let running = 0;
|
|
310
|
+
let completed = 0;
|
|
311
|
+
let failed = 0;
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
if (typeof this.client.zcard === 'function') {
|
|
315
|
+
pending = (await this.client.zcard(waitingKey)) || 0;
|
|
316
|
+
delayed = (await this.client.zcard(delayedKey)) || 0;
|
|
317
|
+
running = (await this.client.scard(activeKey)) || 0;
|
|
318
|
+
completed = (await this.client.scard(completedKey)) || 0;
|
|
319
|
+
failed = (await this.client.scard(failedKey)) || 0;
|
|
320
|
+
} else if (typeof this.client.zCard === 'function') {
|
|
321
|
+
pending = (await this.client.zCard(waitingKey)) || 0;
|
|
322
|
+
delayed = (await this.client.zCard(delayedKey)) || 0;
|
|
323
|
+
running = (await this.client.sCard(activeKey)) || 0;
|
|
324
|
+
completed = (await this.client.sCard(completedKey)) || 0;
|
|
325
|
+
failed = (await this.client.sCard(failedKey)) || 0;
|
|
326
|
+
}
|
|
327
|
+
} catch {}
|
|
328
|
+
|
|
329
|
+
const totalPending = pending + delayed;
|
|
330
|
+
return {
|
|
331
|
+
pending: totalPending,
|
|
332
|
+
running,
|
|
333
|
+
completed,
|
|
334
|
+
failed,
|
|
335
|
+
total: totalPending + running + completed + failed,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Clear all queue keys
|
|
341
|
+
* @returns {Promise<void>}
|
|
342
|
+
*/
|
|
343
|
+
async clear() {
|
|
344
|
+
const keys = [
|
|
345
|
+
this._key('waiting'),
|
|
346
|
+
this._key('delayed'),
|
|
347
|
+
this._key('active'),
|
|
348
|
+
this._key('completed'),
|
|
349
|
+
this._key('failed'),
|
|
350
|
+
];
|
|
351
|
+
for (const k of keys) {
|
|
352
|
+
if (typeof this.client.del === 'function') await this.client.del(k);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = { RedisQueueAdapter };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Core Background Job Queue Engine
|
|
3
|
+
* @module core/queue
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { Job } = require('./job');
|
|
9
|
+
const { QueueManager, createQueueManager } = require('./manager');
|
|
10
|
+
const { MemoryQueueAdapter } = require('./adapters/memory');
|
|
11
|
+
const { DatabaseQueueAdapter } = require('./adapters/database');
|
|
12
|
+
const { RedisQueueAdapter } = require('./adapters/redis');
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
Job,
|
|
16
|
+
QueueManager,
|
|
17
|
+
createQueueManager,
|
|
18
|
+
MemoryQueueAdapter,
|
|
19
|
+
DatabaseQueueAdapter,
|
|
20
|
+
RedisQueueAdapter,
|
|
21
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Job Entity
|
|
3
|
+
* @module core/queue/job
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
class Job {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {string|number} options.id
|
|
12
|
+
* @param {string} options.name
|
|
13
|
+
* @param {any} [options.data={}]
|
|
14
|
+
* @param {'pending'|'running'|'completed'|'failed'} [options.status='pending']
|
|
15
|
+
* @param {number} [options.attempts=0]
|
|
16
|
+
* @param {number} [options.maxAttempts=3]
|
|
17
|
+
* @param {number|Object} [options.backoff={ type: 'exponential', delay: 1000 }]
|
|
18
|
+
* @param {number} [options.priority=10]
|
|
19
|
+
* @param {number} [options.timeout=30000]
|
|
20
|
+
* @param {number} [options.progress=0]
|
|
21
|
+
* @param {any} [options.result=null]
|
|
22
|
+
* @param {any} [options.error=null]
|
|
23
|
+
* @param {Date|number|string} [options.runAt]
|
|
24
|
+
* @param {Date|number|string} [options.createdAt]
|
|
25
|
+
* @param {Date|number|string} [options.updatedAt]
|
|
26
|
+
* @param {Function} [options.onProgress]
|
|
27
|
+
*/
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.id = options.id || ('job_' + Math.random().toString(36).slice(2, 11) + Date.now().toString(36));
|
|
30
|
+
this.name = options.name || 'default';
|
|
31
|
+
this.data = options.data !== undefined ? options.data : {};
|
|
32
|
+
this.status = options.status || 'pending';
|
|
33
|
+
this.attempts = options.attempts || 0;
|
|
34
|
+
this.maxAttempts = options.maxAttempts !== undefined ? options.maxAttempts : 3;
|
|
35
|
+
this.backoff = options.backoff !== undefined ? options.backoff : { type: 'exponential', delay: 1000 };
|
|
36
|
+
this.priority = options.priority !== undefined ? options.priority : 10;
|
|
37
|
+
this.timeout = options.timeout !== undefined ? options.timeout : 30000;
|
|
38
|
+
this.progressValue = options.progress || 0;
|
|
39
|
+
this.result = options.result || null;
|
|
40
|
+
this.error = options.error || null;
|
|
41
|
+
this.createdAt = options.createdAt ? new Date(options.createdAt) : new Date();
|
|
42
|
+
this.updatedAt = options.updatedAt ? new Date(options.updatedAt) : new Date();
|
|
43
|
+
this.runAt = options.runAt ? new Date(options.runAt) : new Date();
|
|
44
|
+
this._onProgress = options.onProgress || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Update progress of a running job
|
|
49
|
+
* @param {number} percent - 0 to 100
|
|
50
|
+
* @param {string} [message]
|
|
51
|
+
*/
|
|
52
|
+
progress(percent, message) {
|
|
53
|
+
this.progressValue = Math.min(100, Math.max(0, percent));
|
|
54
|
+
this.updatedAt = new Date();
|
|
55
|
+
if (typeof this._onProgress === 'function') {
|
|
56
|
+
this._onProgress(this, this.progressValue, message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compute next retry delay in milliseconds based on backoff config
|
|
62
|
+
* @returns {number}
|
|
63
|
+
*/
|
|
64
|
+
getNextRetryDelay() {
|
|
65
|
+
if (typeof this.backoff === 'number') {
|
|
66
|
+
return this.backoff;
|
|
67
|
+
}
|
|
68
|
+
const baseDelay = (this.backoff && this.backoff.delay) || 1000;
|
|
69
|
+
const type = (this.backoff && this.backoff.type) || 'exponential';
|
|
70
|
+
|
|
71
|
+
if (type === 'fixed') {
|
|
72
|
+
return baseDelay;
|
|
73
|
+
}
|
|
74
|
+
// Exponential: baseDelay * 2^(attempts - 1)
|
|
75
|
+
const factor = Math.max(1, Math.pow(2, Math.max(0, this.attempts - 1)));
|
|
76
|
+
return Math.min(baseDelay * factor, 3600000); // Cap at 1 hour
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Plain object serialization
|
|
81
|
+
*/
|
|
82
|
+
toJSON() {
|
|
83
|
+
return {
|
|
84
|
+
id: this.id,
|
|
85
|
+
name: this.name,
|
|
86
|
+
data: this.data,
|
|
87
|
+
status: this.status,
|
|
88
|
+
attempts: this.attempts,
|
|
89
|
+
maxAttempts: this.maxAttempts,
|
|
90
|
+
backoff: this.backoff,
|
|
91
|
+
priority: this.priority,
|
|
92
|
+
timeout: this.timeout,
|
|
93
|
+
progress: this.progressValue,
|
|
94
|
+
result: this.result,
|
|
95
|
+
error: this.error ? (this.error.message || String(this.error)) : null,
|
|
96
|
+
createdAt: this.createdAt.toISOString(),
|
|
97
|
+
updatedAt: this.updatedAt.toISOString(),
|
|
98
|
+
runAt: this.runAt.toISOString(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { Job };
|