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
package/core/orm/repository.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
const { ensureArray } = require('./utils');
|
|
17
17
|
const { ModelEvents, createEventContext, Hooks, HookCancellationError } = require('./events');
|
|
18
18
|
const { getJsonColumns, serializeJsonFields, deserializeJsonFields } = require('./json-fields');
|
|
19
|
+
const { getAmbientTransaction } = require('./transaction');
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Create a repository for a model
|
|
@@ -30,12 +31,37 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
30
31
|
const jsonColumns = getJsonColumns(model);
|
|
31
32
|
const hasJson = jsonColumns.size > 0;
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Resolve active Knex instance (explicit or ambient transaction)
|
|
36
|
+
* @returns {import('knex').Knex|import('knex').Knex.Transaction}
|
|
37
|
+
*/
|
|
38
|
+
function getActiveKnex() {
|
|
39
|
+
if (knex && (knex.isTransaction || typeof knex.commit === 'function')) {
|
|
40
|
+
return knex;
|
|
41
|
+
}
|
|
42
|
+
const ambientTrx = getAmbientTransaction();
|
|
43
|
+
if (ambientTrx) {
|
|
44
|
+
return ambientTrx;
|
|
45
|
+
}
|
|
46
|
+
return knex;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get transaction handle for lifecycle hooks
|
|
51
|
+
* @returns {import('knex').Knex.Transaction|null}
|
|
52
|
+
*/
|
|
53
|
+
function getHookTrx() {
|
|
54
|
+
const active = getActiveKnex();
|
|
55
|
+
return (active && (active.isTransaction || typeof active.commit === 'function')) ? active : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
33
58
|
/**
|
|
34
59
|
* Get base query builder
|
|
35
60
|
* @returns {import('knex').Knex.QueryBuilder}
|
|
36
61
|
*/
|
|
37
62
|
function baseQuery() {
|
|
38
|
-
|
|
63
|
+
const activeKnex = getActiveKnex();
|
|
64
|
+
let qb = activeKnex(model.table);
|
|
39
65
|
return applyScopes(qb, scopeContext, model);
|
|
40
66
|
}
|
|
41
67
|
|
|
@@ -79,7 +105,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
79
105
|
}
|
|
80
106
|
|
|
81
107
|
if (withs.length > 0) {
|
|
82
|
-
await loadRelations([record], ensureArray(withs), model,
|
|
108
|
+
await loadRelations([record], ensureArray(withs), model, getActiveKnex(), scopeContext);
|
|
83
109
|
}
|
|
84
110
|
|
|
85
111
|
if (shouldEmit) {
|
|
@@ -105,7 +131,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
105
131
|
kind,
|
|
106
132
|
kind === 'pk' ? id : null
|
|
107
133
|
);
|
|
108
|
-
return cacheLayer.wrapRead(model,
|
|
134
|
+
return cacheLayer.wrapRead(model, getActiveKnex(), scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
|
|
109
135
|
}
|
|
110
136
|
|
|
111
137
|
return loadFromDb();
|
|
@@ -160,7 +186,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
160
186
|
}
|
|
161
187
|
|
|
162
188
|
if (withs.length > 0) {
|
|
163
|
-
await loadRelations([record], ensureArray(withs), model,
|
|
189
|
+
await loadRelations([record], ensureArray(withs), model, getActiveKnex(), scopeContext);
|
|
164
190
|
}
|
|
165
191
|
|
|
166
192
|
if (shouldEmit) {
|
|
@@ -172,7 +198,18 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
172
198
|
|
|
173
199
|
if (cacheLayer && cacheLayer.strategyFor(model)) {
|
|
174
200
|
const strat = cacheLayer.strategyFor(model);
|
|
175
|
-
|
|
201
|
+
if (isPkOnly) {
|
|
202
|
+
const pkVal = conditions[model.primaryKey];
|
|
203
|
+
const fingerprint = cacheLayer.findByIdFingerprint(
|
|
204
|
+
model,
|
|
205
|
+
scopeContext,
|
|
206
|
+
pkVal,
|
|
207
|
+
select || [],
|
|
208
|
+
withs
|
|
209
|
+
);
|
|
210
|
+
const tags = cacheLayer.buildReadTags(model, strat, 'pk', pkVal);
|
|
211
|
+
return cacheLayer.wrapRead(model, getActiveKnex(), scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
|
|
212
|
+
}
|
|
176
213
|
const fingerprint = cacheLayer.findOneFingerprint(
|
|
177
214
|
model,
|
|
178
215
|
scopeContext,
|
|
@@ -180,20 +217,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
180
217
|
select || [],
|
|
181
218
|
withs
|
|
182
219
|
);
|
|
183
|
-
const tags = cacheLayer.buildReadTags(
|
|
184
|
-
|
|
185
|
-
strat,
|
|
186
|
-
kind,
|
|
187
|
-
kind === 'pk' ? conditions[model.primaryKey] : null
|
|
188
|
-
);
|
|
189
|
-
return cacheLayer.wrapRead(model, knex, scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
|
|
220
|
+
const tags = cacheLayer.buildReadTags(model, strat, 'collection', null);
|
|
221
|
+
return cacheLayer.wrapRead(model, getActiveKnex(), scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
|
|
190
222
|
}
|
|
191
223
|
|
|
192
224
|
return loadFromDb();
|
|
193
225
|
}
|
|
194
226
|
|
|
195
227
|
/**
|
|
196
|
-
* Find all records
|
|
228
|
+
* Find all records matching scope
|
|
197
229
|
* @param {import('./types').FindOptions} [options={}] - Find options
|
|
198
230
|
* @returns {Promise<Object[]>}
|
|
199
231
|
*/
|
|
@@ -229,7 +261,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
229
261
|
}
|
|
230
262
|
|
|
231
263
|
if (withs.length > 0 && records.length > 0) {
|
|
232
|
-
await loadRelations(records, ensureArray(withs), model,
|
|
264
|
+
await loadRelations(records, ensureArray(withs), model, getActiveKnex(), scopeContext);
|
|
233
265
|
}
|
|
234
266
|
|
|
235
267
|
if (shouldEmit) {
|
|
@@ -246,7 +278,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
246
278
|
const strat = cacheLayer.strategyFor(model);
|
|
247
279
|
const fingerprint = cacheLayer.findAllFingerprint(model, scopeContext, select || []);
|
|
248
280
|
const tags = cacheLayer.buildReadTags(model, strat, 'collection', null);
|
|
249
|
-
return cacheLayer.wrapRead(model,
|
|
281
|
+
return cacheLayer.wrapRead(model, getActiveKnex(), scopeContext, fingerprint, tags, loadFromDb, () => true);
|
|
250
282
|
}
|
|
251
283
|
|
|
252
284
|
return loadFromDb();
|
|
@@ -258,7 +290,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
258
290
|
* @returns {Promise<Object>}
|
|
259
291
|
*/
|
|
260
292
|
async function create(data) {
|
|
261
|
-
const ctx = createEventContext(model.name, 'create',
|
|
293
|
+
const ctx = createEventContext(model.name, 'create', getHookTrx());
|
|
262
294
|
let workingData = { ...data };
|
|
263
295
|
|
|
264
296
|
// Emit beforeValidation
|
|
@@ -294,7 +326,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
294
326
|
}
|
|
295
327
|
|
|
296
328
|
// Insert and return the record
|
|
297
|
-
const [id] = await
|
|
329
|
+
const [id] = await getActiveKnex()(model.table).insert(insertData).returning(model.primaryKey);
|
|
298
330
|
|
|
299
331
|
// For databases that don't support returning (SQLite), id might be the row itself
|
|
300
332
|
const insertedId = typeof id === 'object' ? id[model.primaryKey] : id;
|
|
@@ -334,7 +366,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
334
366
|
* @returns {Promise<Object|null>}
|
|
335
367
|
*/
|
|
336
368
|
async function update(id, data) {
|
|
337
|
-
const ctx = createEventContext(model.name, 'update',
|
|
369
|
+
const ctx = createEventContext(model.name, 'update', getHookTrx());
|
|
338
370
|
// Don't include primary key in working data for validation
|
|
339
371
|
// to avoid type mismatch issues (URL params come as strings)
|
|
340
372
|
let workingData = { ...data };
|
|
@@ -431,7 +463,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
431
463
|
* @returns {Promise<boolean>}
|
|
432
464
|
*/
|
|
433
465
|
async function del(id) {
|
|
434
|
-
const ctx = createEventContext(model.name, 'delete',
|
|
466
|
+
const ctx = createEventContext(model.name, 'delete', getHookTrx());
|
|
435
467
|
|
|
436
468
|
// Get the record before deletion for hooks
|
|
437
469
|
const record = await findById(id);
|
|
@@ -476,7 +508,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
476
508
|
*/
|
|
477
509
|
async function forceDelete(id) {
|
|
478
510
|
// Use raw query to bypass soft delete scope
|
|
479
|
-
const deleted = await
|
|
511
|
+
const deleted = await getActiveKnex()(model.table)
|
|
480
512
|
.where(model.primaryKey, id)
|
|
481
513
|
.delete();
|
|
482
514
|
if (cacheLayer && deleted > 0) {
|
|
@@ -495,10 +527,10 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
495
527
|
throw new Error(`Model "${model.name}" does not have soft delete enabled`);
|
|
496
528
|
}
|
|
497
529
|
|
|
498
|
-
const ctx = createEventContext(model.name, 'restore',
|
|
530
|
+
const ctx = createEventContext(model.name, 'restore', getHookTrx());
|
|
499
531
|
|
|
500
532
|
// Get the trashed record for hooks (bypass soft delete scope)
|
|
501
|
-
const trashedRecord = await
|
|
533
|
+
const trashedRecord = await getActiveKnex()(model.table)
|
|
502
534
|
.where(model.primaryKey, id)
|
|
503
535
|
.whereNotNull('deleted_at')
|
|
504
536
|
.first();
|
|
@@ -514,7 +546,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
514
546
|
}
|
|
515
547
|
|
|
516
548
|
// Update directly without scopes (to find trashed record)
|
|
517
|
-
const updated = await
|
|
549
|
+
const updated = await getActiveKnex()(model.table)
|
|
518
550
|
.where(model.primaryKey, id)
|
|
519
551
|
.whereNotNull('deleted_at')
|
|
520
552
|
.update({ deleted_at: null });
|
|
@@ -536,7 +568,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
536
568
|
* @returns {import('./query-builder').QueryBuilder}
|
|
537
569
|
*/
|
|
538
570
|
function query() {
|
|
539
|
-
return createQueryBuilder(model,
|
|
571
|
+
return createQueryBuilder(model, getActiveKnex(), scopeContext, cacheLayer);
|
|
540
572
|
}
|
|
541
573
|
|
|
542
574
|
/**
|
|
@@ -546,7 +578,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
546
578
|
* @returns {Promise<Object[]>}
|
|
547
579
|
*/
|
|
548
580
|
async function raw(sql, bindings = []) {
|
|
549
|
-
const result = await
|
|
581
|
+
const result = await getActiveKnex().raw(sql, bindings);
|
|
550
582
|
// Normalize result across database drivers
|
|
551
583
|
return result.rows || result;
|
|
552
584
|
}
|
package/core/orm/transaction.js
CHANGED
|
@@ -1,12 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Webspresso ORM - Transaction
|
|
3
|
-
* Transaction wrapper with scoped repositories
|
|
3
|
+
* Transaction wrapper with scoped repositories and AsyncLocalStorage ambient transaction support
|
|
4
4
|
* @module core/orm/transaction
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const {
|
|
7
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
8
8
|
const { createScopeContext } = require('./scopes');
|
|
9
9
|
|
|
10
|
+
// AsyncLocalStorage instance holding the current active ambient transaction
|
|
11
|
+
const ambientTransactionStorage = new AsyncLocalStorage();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Execute a callback within an ambient transaction context
|
|
15
|
+
* @param {import('knex').Knex.Transaction} trx - Knex transaction
|
|
16
|
+
* @param {Function} callback - Function to execute
|
|
17
|
+
* @returns {Promise<*>} Result of callback
|
|
18
|
+
*/
|
|
19
|
+
function runWithAmbientTransaction(trx, callback) {
|
|
20
|
+
return ambientTransactionStorage.run({ trx }, callback);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the currently active ambient Knex transaction, or null if none
|
|
25
|
+
* @returns {import('knex').Knex.Transaction|null}
|
|
26
|
+
*/
|
|
27
|
+
function getAmbientTransaction() {
|
|
28
|
+
const store = ambientTransactionStorage.getStore();
|
|
29
|
+
return store && store.trx ? store.trx : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if there is an active ambient Knex transaction in current execution context
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
function hasAmbientTransaction() {
|
|
37
|
+
return Boolean(getAmbientTransaction());
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
/**
|
|
11
41
|
* Create a transaction context
|
|
12
42
|
* @param {import('knex').Knex.Transaction} trx - Knex transaction
|
|
@@ -14,6 +44,7 @@ const { createScopeContext } = require('./scopes');
|
|
|
14
44
|
* @returns {import('./types').TransactionContext}
|
|
15
45
|
*/
|
|
16
46
|
function createTransactionContext(trx, scopeContext) {
|
|
47
|
+
const { createRepository } = require('./repository');
|
|
17
48
|
const context = scopeContext || createScopeContext();
|
|
18
49
|
|
|
19
50
|
return {
|
|
@@ -49,7 +80,7 @@ function createTransactionContext(trx, scopeContext) {
|
|
|
49
80
|
}
|
|
50
81
|
|
|
51
82
|
/**
|
|
52
|
-
* Run a callback within a transaction
|
|
83
|
+
* Run a callback within a transaction and bind ambient transaction storage
|
|
53
84
|
* @param {import('knex').Knex} knex - Knex instance
|
|
54
85
|
* @param {function(import('./types').TransactionContext): Promise<*>} callback - Transaction callback
|
|
55
86
|
* @param {import('./types').ScopeContext} [scopeContext] - Scope context
|
|
@@ -58,12 +89,15 @@ function createTransactionContext(trx, scopeContext) {
|
|
|
58
89
|
async function runTransaction(knex, callback, scopeContext) {
|
|
59
90
|
return knex.transaction(async (trx) => {
|
|
60
91
|
const ctx = createTransactionContext(trx, scopeContext);
|
|
61
|
-
return callback(ctx);
|
|
92
|
+
return runWithAmbientTransaction(trx, () => callback(ctx));
|
|
62
93
|
});
|
|
63
94
|
}
|
|
64
95
|
|
|
65
96
|
module.exports = {
|
|
97
|
+
ambientTransactionStorage,
|
|
98
|
+
runWithAmbientTransaction,
|
|
99
|
+
getAmbientTransaction,
|
|
100
|
+
hasAmbientTransaction,
|
|
66
101
|
createTransactionContext,
|
|
67
102
|
runTransaction,
|
|
68
103
|
};
|
|
69
|
-
|
package/core/orm/utils.js
CHANGED
|
@@ -107,11 +107,14 @@ function deepClone(obj) {
|
|
|
107
107
|
if (Array.isArray(obj)) {
|
|
108
108
|
return obj.map(deepClone);
|
|
109
109
|
}
|
|
110
|
-
const cloned =
|
|
111
|
-
for (const key
|
|
110
|
+
const cloned = Object.create(null);
|
|
111
|
+
for (const key of Object.keys(obj)) {
|
|
112
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
112
115
|
cloned[key] = deepClone(obj[key]);
|
|
113
116
|
}
|
|
114
|
-
return cloned;
|
|
117
|
+
return { ...cloned };
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
/**
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knex Database-Backed Queue Adapter
|
|
3
|
+
* @module core/queue/adapters/database
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { Job } = require('../job');
|
|
9
|
+
|
|
10
|
+
class DatabaseQueueAdapter {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {import('knex').Knex} options.knex - Knex instance
|
|
14
|
+
* @param {string} [options.tableName='webspresso_jobs'] - Queue table name
|
|
15
|
+
*/
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
if (!options.knex) {
|
|
18
|
+
throw new Error('DatabaseQueueAdapter requires a Knex instance in options.knex');
|
|
19
|
+
}
|
|
20
|
+
this.knex = options.knex;
|
|
21
|
+
this.tableName = options.tableName || 'webspresso_jobs';
|
|
22
|
+
this._tableChecked = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ensure the jobs table exists in the database
|
|
27
|
+
*/
|
|
28
|
+
async ensureTable() {
|
|
29
|
+
if (this._tableChecked) return;
|
|
30
|
+
|
|
31
|
+
const exists = await this.knex.schema.hasTable(this.tableName);
|
|
32
|
+
if (!exists) {
|
|
33
|
+
await this.knex.schema.createTable(this.tableName, (table) => {
|
|
34
|
+
table.string('id').primary();
|
|
35
|
+
table.string('name').notNullable().index();
|
|
36
|
+
table.text('data').nullable();
|
|
37
|
+
table.string('status').notNullable().defaultTo('pending').index();
|
|
38
|
+
table.integer('attempts').notNullable().defaultTo(0);
|
|
39
|
+
table.integer('max_attempts').notNullable().defaultTo(3);
|
|
40
|
+
table.text('backoff').nullable();
|
|
41
|
+
table.integer('priority').notNullable().defaultTo(10);
|
|
42
|
+
table.integer('timeout').notNullable().defaultTo(30000);
|
|
43
|
+
table.integer('progress').notNullable().defaultTo(0);
|
|
44
|
+
table.text('result').nullable();
|
|
45
|
+
table.text('error').nullable();
|
|
46
|
+
table.dateTime('run_at').notNullable().index();
|
|
47
|
+
table.dateTime('created_at').notNullable();
|
|
48
|
+
table.dateTime('updated_at').notNullable();
|
|
49
|
+
table.index(['status', 'run_at', 'priority']);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this._tableChecked = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Enqueue a job into the database
|
|
58
|
+
* @param {Job} job
|
|
59
|
+
* @returns {Promise<Job>}
|
|
60
|
+
*/
|
|
61
|
+
async enqueue(job) {
|
|
62
|
+
await this.ensureTable();
|
|
63
|
+
const jobInstance = job instanceof Job ? job : new Job(job);
|
|
64
|
+
|
|
65
|
+
await this.knex(this.tableName).insert({
|
|
66
|
+
id: String(jobInstance.id),
|
|
67
|
+
name: jobInstance.name,
|
|
68
|
+
data: JSON.stringify(jobInstance.data),
|
|
69
|
+
status: jobInstance.status,
|
|
70
|
+
attempts: jobInstance.attempts,
|
|
71
|
+
max_attempts: jobInstance.maxAttempts,
|
|
72
|
+
backoff: JSON.stringify(jobInstance.backoff),
|
|
73
|
+
priority: jobInstance.priority,
|
|
74
|
+
timeout: jobInstance.timeout,
|
|
75
|
+
progress: jobInstance.progressValue,
|
|
76
|
+
result: jobInstance.result ? JSON.stringify(jobInstance.result) : null,
|
|
77
|
+
error: jobInstance.error ? (jobInstance.error.message || String(jobInstance.error)) : null,
|
|
78
|
+
run_at: jobInstance.runAt,
|
|
79
|
+
created_at: jobInstance.createdAt,
|
|
80
|
+
updated_at: jobInstance.updatedAt,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return jobInstance;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Dequeue next pending job
|
|
88
|
+
* @param {string[]} [jobNames]
|
|
89
|
+
* @returns {Promise<Job|null>}
|
|
90
|
+
*/
|
|
91
|
+
async dequeue(jobNames = []) {
|
|
92
|
+
const jobs = await this.dequeueMany(jobNames, 1);
|
|
93
|
+
return jobs.length > 0 ? jobs[0] : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Dequeue multiple pending jobs up to limit in a single transaction
|
|
98
|
+
* @param {string[]} [jobNames]
|
|
99
|
+
* @param {number} [limit=1]
|
|
100
|
+
* @returns {Promise<Job[]>}
|
|
101
|
+
*/
|
|
102
|
+
async dequeueMany(jobNames = [], limit = 1) {
|
|
103
|
+
await this.ensureTable();
|
|
104
|
+
const now = new Date();
|
|
105
|
+
const take = Math.max(1, limit || 1);
|
|
106
|
+
|
|
107
|
+
return this.knex.transaction(async (trx) => {
|
|
108
|
+
let query = trx(this.tableName)
|
|
109
|
+
.where('status', 'pending')
|
|
110
|
+
.where('run_at', '<=', now)
|
|
111
|
+
.orderBy('priority', 'desc')
|
|
112
|
+
.orderBy('run_at', 'asc')
|
|
113
|
+
.limit(take);
|
|
114
|
+
|
|
115
|
+
if (jobNames && jobNames.length > 0) {
|
|
116
|
+
query = query.whereIn('name', jobNames);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// If Postgres or MySQL support forUpdate / skipLocked
|
|
120
|
+
const client = this.knex.client?.config?.client;
|
|
121
|
+
if (client === 'pg' || client === 'postgres' || client === 'mysql' || client === 'mysql2') {
|
|
122
|
+
try {
|
|
123
|
+
query = query.forUpdate().skipLocked();
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const rows = await query;
|
|
128
|
+
if (!rows || rows.length === 0) return [];
|
|
129
|
+
|
|
130
|
+
const ids = rows.map((r) => r.id);
|
|
131
|
+
const updatedAt = new Date();
|
|
132
|
+
|
|
133
|
+
if (take === 1) {
|
|
134
|
+
const row = rows[0];
|
|
135
|
+
const nextAttempts = Number(row.attempts) + 1;
|
|
136
|
+
await trx(this.tableName)
|
|
137
|
+
.where('id', row.id)
|
|
138
|
+
.update({
|
|
139
|
+
status: 'running',
|
|
140
|
+
attempts: nextAttempts,
|
|
141
|
+
updated_at: updatedAt,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return [this._rowToJob({
|
|
145
|
+
...row,
|
|
146
|
+
status: 'running',
|
|
147
|
+
attempts: nextAttempts,
|
|
148
|
+
updated_at: updatedAt,
|
|
149
|
+
})];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await trx(this.tableName)
|
|
153
|
+
.whereIn('id', ids)
|
|
154
|
+
.update({
|
|
155
|
+
status: 'running',
|
|
156
|
+
attempts: trx.raw('attempts + 1'),
|
|
157
|
+
updated_at: updatedAt,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return rows.map((row) =>
|
|
161
|
+
this._rowToJob({
|
|
162
|
+
...row,
|
|
163
|
+
status: 'running',
|
|
164
|
+
attempts: Number(row.attempts) + 1,
|
|
165
|
+
updated_at: updatedAt,
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Mark job as completed
|
|
173
|
+
* @param {string|number} jobId
|
|
174
|
+
* @param {any} [result]
|
|
175
|
+
* @returns {Promise<Job|null>}
|
|
176
|
+
*/
|
|
177
|
+
async complete(jobId, result = null) {
|
|
178
|
+
await this.ensureTable();
|
|
179
|
+
const updatedAt = new Date();
|
|
180
|
+
await this.knex(this.tableName)
|
|
181
|
+
.where('id', String(jobId))
|
|
182
|
+
.update({
|
|
183
|
+
status: 'completed',
|
|
184
|
+
result: result !== undefined ? JSON.stringify(result) : null,
|
|
185
|
+
progress: 100,
|
|
186
|
+
updated_at: updatedAt,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return this.getJob(jobId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mark job as failed
|
|
194
|
+
* @param {string|number} jobId
|
|
195
|
+
* @param {any} [error]
|
|
196
|
+
* @returns {Promise<Job|null>}
|
|
197
|
+
*/
|
|
198
|
+
async fail(jobId, error = null) {
|
|
199
|
+
await this.ensureTable();
|
|
200
|
+
const updatedAt = new Date();
|
|
201
|
+
const errorStr = error ? (error.message || String(error)) : null;
|
|
202
|
+
|
|
203
|
+
await this.knex(this.tableName)
|
|
204
|
+
.where('id', String(jobId))
|
|
205
|
+
.update({
|
|
206
|
+
status: 'failed',
|
|
207
|
+
error: errorStr,
|
|
208
|
+
updated_at: updatedAt,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return this.getJob(jobId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Reschedule job for retry
|
|
216
|
+
* @param {string|number} jobId
|
|
217
|
+
* @param {number} delayMs
|
|
218
|
+
* @param {any} [error]
|
|
219
|
+
* @returns {Promise<Job|null>}
|
|
220
|
+
*/
|
|
221
|
+
async retry(jobId, delayMs, error = null) {
|
|
222
|
+
await this.ensureTable();
|
|
223
|
+
const updatedAt = new Date();
|
|
224
|
+
const runAt = new Date(Date.now() + Math.max(0, delayMs));
|
|
225
|
+
const errorStr = error ? (error.message || String(error)) : null;
|
|
226
|
+
|
|
227
|
+
await this.knex(this.tableName)
|
|
228
|
+
.where('id', String(jobId))
|
|
229
|
+
.update({
|
|
230
|
+
status: 'pending',
|
|
231
|
+
run_at: runAt,
|
|
232
|
+
error: errorStr,
|
|
233
|
+
updated_at: updatedAt,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return this.getJob(jobId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get job by ID
|
|
241
|
+
* @param {string|number} jobId
|
|
242
|
+
* @returns {Promise<Job|null>}
|
|
243
|
+
*/
|
|
244
|
+
async getJob(jobId) {
|
|
245
|
+
await this.ensureTable();
|
|
246
|
+
const row = await this.knex(this.tableName).where('id', String(jobId)).first();
|
|
247
|
+
if (!row) return null;
|
|
248
|
+
return this._rowToJob(row);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get stats summary
|
|
253
|
+
* @returns {Promise<{ pending: number, running: number, completed: number, failed: number, total: number }>}
|
|
254
|
+
*/
|
|
255
|
+
async getStats() {
|
|
256
|
+
await this.ensureTable();
|
|
257
|
+
const rows = await this.knex(this.tableName)
|
|
258
|
+
.select('status')
|
|
259
|
+
.count('* as count')
|
|
260
|
+
.groupBy('status');
|
|
261
|
+
|
|
262
|
+
const stats = { pending: 0, running: 0, completed: 0, failed: 0, total: 0 };
|
|
263
|
+
for (const r of rows) {
|
|
264
|
+
const count = Number(r.count || r.COUNT || 0);
|
|
265
|
+
if (r.status in stats) {
|
|
266
|
+
stats[r.status] = count;
|
|
267
|
+
}
|
|
268
|
+
stats.total += count;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return stats;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Clear all jobs from table
|
|
276
|
+
* @returns {Promise<void>}
|
|
277
|
+
*/
|
|
278
|
+
async clear() {
|
|
279
|
+
await this.ensureTable();
|
|
280
|
+
await this.knex(this.tableName).del();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Convert DB row to Job instance
|
|
285
|
+
* @private
|
|
286
|
+
*/
|
|
287
|
+
_rowToJob(row) {
|
|
288
|
+
let data = {};
|
|
289
|
+
try { data = JSON.parse(row.data); } catch {}
|
|
290
|
+
|
|
291
|
+
let backoff = { type: 'exponential', delay: 1000 };
|
|
292
|
+
try { if (row.backoff) backoff = JSON.parse(row.backoff); } catch {}
|
|
293
|
+
|
|
294
|
+
let result = null;
|
|
295
|
+
try { if (row.result) result = JSON.parse(row.result); } catch {}
|
|
296
|
+
|
|
297
|
+
return new Job({
|
|
298
|
+
id: row.id,
|
|
299
|
+
name: row.name,
|
|
300
|
+
data,
|
|
301
|
+
status: row.status,
|
|
302
|
+
attempts: Number(row.attempts),
|
|
303
|
+
maxAttempts: Number(row.max_attempts),
|
|
304
|
+
backoff,
|
|
305
|
+
priority: Number(row.priority),
|
|
306
|
+
timeout: Number(row.timeout),
|
|
307
|
+
progress: Number(row.progress),
|
|
308
|
+
result,
|
|
309
|
+
error: row.error,
|
|
310
|
+
runAt: row.run_at,
|
|
311
|
+
createdAt: row.created_at,
|
|
312
|
+
updatedAt: row.updated_at,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = { DatabaseQueueAdapter };
|