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.
Files changed (125) hide show
  1. package/README.md +98 -2509
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +199 -0
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +4 -0
  12. package/core/auth/middleware.js +9 -0
  13. package/core/compileSchema.js +1 -5
  14. package/core/compression/compressible.js +63 -0
  15. package/core/compression/compression-stream.js +59 -0
  16. package/core/compression/index.js +25 -0
  17. package/core/compression/middleware.js +310 -0
  18. package/core/compression/negotiate.js +165 -0
  19. package/core/errors/base.js +31 -0
  20. package/core/errors/domain.js +193 -0
  21. package/core/errors/http.js +133 -0
  22. package/core/errors/index.js +77 -0
  23. package/core/errors/middleware.js +162 -0
  24. package/core/errors/normalize.js +171 -0
  25. package/core/kernel/app.js +2 -1
  26. package/core/kernel/index.js +5 -1
  27. package/core/orm/cache/fingerprint.js +23 -5
  28. package/core/orm/complexity.js +120 -0
  29. package/core/orm/eager-loader.js +54 -9
  30. package/core/orm/events.js +64 -14
  31. package/core/orm/index.js +83 -12
  32. package/core/orm/model.js +13 -2
  33. package/core/orm/query-builder.js +30 -4
  34. package/core/orm/repository.js +57 -25
  35. package/core/orm/transaction.js +39 -5
  36. package/core/orm/utils.js +6 -3
  37. package/core/queue/adapters/database.js +317 -0
  38. package/core/queue/adapters/memory.js +176 -0
  39. package/core/queue/adapters/redis.js +357 -0
  40. package/core/queue/index.js +21 -0
  41. package/core/queue/job.js +103 -0
  42. package/core/queue/manager.js +413 -0
  43. package/core/realtime/adapters/redis.js +431 -0
  44. package/core/realtime/adapters/socket-io.js +203 -0
  45. package/core/realtime/adapters/sse.js +210 -0
  46. package/core/realtime/adapters/websocket.js +337 -0
  47. package/core/realtime/auth-manager.js +140 -0
  48. package/core/realtime/core.js +359 -0
  49. package/core/realtime/emitter.js +98 -0
  50. package/core/realtime/errors.js +71 -0
  51. package/core/realtime/index.js +79 -0
  52. package/core/realtime/reconnect-manager.js +118 -0
  53. package/core/realtime/registry.js +243 -0
  54. package/core/shutdown/http-adapter.js +134 -0
  55. package/core/shutdown/index.js +12 -0
  56. package/core/shutdown/shutdown-manager.js +312 -0
  57. package/core/ssr/index.js +13 -0
  58. package/core/ssr/stream.js +174 -0
  59. package/core/validation/index.js +70 -0
  60. package/index.d.ts +924 -53
  61. package/index.js +110 -1
  62. package/package.json +30 -2
  63. package/plugins/admin-panel/api.js +37 -4
  64. package/plugins/admin-panel/app.js +57 -17
  65. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
  66. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  67. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
  68. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  69. package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
  70. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  71. package/plugins/admin-panel/core/admin-module.js +2 -2
  72. package/plugins/admin-panel/core/api-extensions.js +149 -0
  73. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  74. package/plugins/admin-panel/index.js +155 -23
  75. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  76. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  77. package/plugins/admin-panel/modules/dashboard.js +8 -1
  78. package/plugins/admin-panel/modules/menu.js +85 -0
  79. package/plugins/admin-panel/modules/user-management.js +5 -2
  80. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  81. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  82. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  83. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  84. package/plugins/basic-auth/index.js +293 -0
  85. package/plugins/data-exchange/export-xlsx.js +28 -2
  86. package/plugins/data-exchange/import.js +55 -16
  87. package/plugins/data-exchange/index.js +19 -0
  88. package/plugins/data-exchange/parse-table.js +14 -5
  89. package/plugins/data-exchange/record-selection.js +2 -0
  90. package/plugins/email/index.js +14 -1
  91. package/plugins/index.js +33 -0
  92. package/plugins/mcp/auth.js +104 -0
  93. package/plugins/mcp/discovery.js +88 -0
  94. package/plugins/mcp/index.js +208 -0
  95. package/plugins/mcp/prompts.js +149 -0
  96. package/plugins/mcp/providers/orm.js +325 -0
  97. package/plugins/mcp/providers/services.js +190 -0
  98. package/plugins/mcp/providers/system.js +87 -0
  99. package/plugins/mcp/server.js +472 -0
  100. package/plugins/mcp/transports/sse.js +139 -0
  101. package/plugins/mcp/transports/stdio.js +108 -0
  102. package/plugins/queue/index.js +108 -0
  103. package/plugins/realtime/index.js +150 -0
  104. package/plugins/recaptcha/index.js +11 -0
  105. package/plugins/redirect/index.js +9 -1
  106. package/plugins/rest-resources/index.js +280 -11
  107. package/plugins/upload/index.js +16 -0
  108. package/src/app-context.js +46 -6
  109. package/src/file-router.js +366 -130
  110. package/src/helpers.js +20 -2
  111. package/src/njk-frontmatter.js +24 -14
  112. package/src/plugin-manager.js +124 -4
  113. package/src/server.js +332 -54
  114. package/src/services/builtins/auth.js +273 -0
  115. package/src/services/builtins/exchange.js +229 -0
  116. package/src/services/builtins/mail.js +134 -0
  117. package/src/services/builtins/media.js +196 -0
  118. package/src/services/builtins/system.js +221 -0
  119. package/src/services/discovery.js +141 -0
  120. package/src/services/executor.js +292 -0
  121. package/src/services/index.js +70 -0
  122. package/src/services/memoize.js +256 -0
  123. package/src/services/registry.js +290 -0
  124. package/src/services/validator.js +230 -0
  125. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
@@ -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
- let qb = knex(model.table);
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, knex, scopeContext);
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, knex, scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
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, knex, scopeContext);
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
- const kind = isPkOnly ? 'pk' : 'collection';
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
- model,
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, knex, scopeContext);
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, knex, scopeContext, fingerprint, tags, loadFromDb, () => true);
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', knex.isTransaction ? knex : null);
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 knex(model.table).insert(insertData).returning(model.primaryKey);
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', knex.isTransaction ? knex : null);
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', knex.isTransaction ? knex : null);
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 knex(model.table)
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', knex.isTransaction ? knex : null);
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 knex(model.table)
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 knex(model.table)
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, knex, scopeContext, cacheLayer);
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 knex.raw(sql, bindings);
581
+ const result = await getActiveKnex().raw(sql, bindings);
550
582
  // Normalize result across database drivers
551
583
  return result.rows || result;
552
584
  }
@@ -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 { createRepository } = require('./repository');
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 in obj) {
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 };