webspresso 0.0.97 → 0.0.98

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 (100) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/bin/commands/add-esbuild.js +80 -0
  7. package/bin/commands/add-tailwind.js +76 -31
  8. package/bin/commands/analytics-prune.js +57 -0
  9. package/bin/commands/api.js +207 -15
  10. package/bin/commands/build.js +99 -0
  11. package/bin/commands/dev.js +64 -35
  12. package/bin/commands/doctor.js +26 -0
  13. package/bin/commands/email-prune.js +47 -0
  14. package/bin/commands/make-job.js +86 -0
  15. package/bin/commands/make-model.js +250 -0
  16. package/bin/commands/new.js +45 -28
  17. package/bin/commands/openapi.js +102 -0
  18. package/bin/commands/page.js +6 -3
  19. package/bin/commands/queue-prune.js +59 -0
  20. package/bin/commands/routes.js +52 -0
  21. package/bin/commands/service.js +6 -3
  22. package/bin/commands/typegen.js +106 -0
  23. package/bin/commands/upload-prune.js +79 -0
  24. package/bin/webspresso.js +22 -0
  25. package/core/applySchema.js +13 -6
  26. package/core/auth/hash.js +21 -12
  27. package/core/compression/middleware.js +23 -17
  28. package/core/openapi/build-from-api-routes.js +160 -29
  29. package/core/orm/cache/fingerprint.js +15 -1
  30. package/core/orm/cache/memory-provider.js +27 -2
  31. package/core/orm/index.js +12 -7
  32. package/core/orm/model.js +49 -7
  33. package/core/queue/adapters/database.js +94 -0
  34. package/core/queue/adapters/memory.js +62 -0
  35. package/core/queue/adapters/redis.js +80 -0
  36. package/core/queue/manager.js +123 -0
  37. package/core/ssr/stream.js +11 -0
  38. package/index.d.ts +660 -10
  39. package/index.js +44 -96
  40. package/package.json +1 -1
  41. package/plugins/admin-panel/api.js +279 -17
  42. package/plugins/admin-panel/app.js +114 -22
  43. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  44. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  46. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  47. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  48. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  49. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  50. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  51. package/plugins/admin-panel/core/admin-module.js +13 -7
  52. package/plugins/admin-panel/core/api-extensions.js +30 -2
  53. package/plugins/admin-panel/core/registry.js +96 -16
  54. package/plugins/admin-panel/index.js +96 -0
  55. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  56. package/plugins/admin-panel/modules/admin-users.js +16 -1
  57. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  58. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  59. package/plugins/admin-panel/modules/dashboard.js +283 -0
  60. package/plugins/admin-panel/modules/menu.js +56 -18
  61. package/plugins/admin-panel/modules/user-management.js +24 -6
  62. package/plugins/audit-log/admin-component.js +11 -0
  63. package/plugins/audit-log/api-handlers.js +34 -0
  64. package/plugins/audit-log/index.js +129 -3
  65. package/plugins/audit-log/purge.js +60 -10
  66. package/plugins/dashboard/app.js +152 -27
  67. package/plugins/dashboard/index.js +143 -39
  68. package/plugins/dashboard/styles.js +78 -0
  69. package/plugins/data-exchange/export-xlsx.js +9 -2
  70. package/plugins/data-exchange/import.js +9 -2
  71. package/plugins/data-exchange/index.js +6 -3
  72. package/plugins/email/index.js +132 -2
  73. package/plugins/email/purge.js +60 -10
  74. package/plugins/index.js +4 -0
  75. package/plugins/orm-cache-admin/index.js +4 -2
  76. package/plugins/queue/index.js +11 -0
  77. package/plugins/schema-explorer/admin-component.js +714 -0
  78. package/plugins/schema-explorer/relations-graph.js +205 -0
  79. package/plugins/schema-explorer.js +248 -69
  80. package/plugins/site-analytics/admin-component.js +23 -0
  81. package/plugins/site-analytics/api-handlers.js +42 -0
  82. package/plugins/site-analytics/index.js +147 -2
  83. package/plugins/site-analytics/purge.js +168 -0
  84. package/plugins/swagger.js +261 -13
  85. package/plugins/upload/index.js +26 -0
  86. package/plugins/upload/orphan-pruner.js +292 -0
  87. package/src/api/api-loader.js +3 -1
  88. package/src/bundle/index.js +517 -0
  89. package/src/bundle/manifest.js +165 -0
  90. package/src/dev/banner.js +296 -0
  91. package/src/dev/colors.js +70 -0
  92. package/src/dev/index.js +62 -0
  93. package/src/dev/profiler.js +354 -0
  94. package/src/dev/typegen.js +497 -0
  95. package/src/dev-reload/index.js +611 -0
  96. package/src/file-router.js +210 -62
  97. package/src/helpers.js +958 -98
  98. package/src/njk-frontmatter.js +12 -0
  99. package/src/pages/page-loader.js +61 -11
  100. package/src/server.js +629 -63
package/core/orm/model.js CHANGED
@@ -107,6 +107,22 @@ function defineModel(options) {
107
107
  sortable: admin.sortable,
108
108
  sortableColumns: admin.sortableColumns,
109
109
  columns: admin.columns,
110
+ listColumns: Array.isArray(admin.listColumns) ? admin.listColumns : null,
111
+ searchFields: Array.isArray(admin.searchFields) ? admin.searchFields : null,
112
+ displayField: typeof admin.displayField === 'string' ? admin.displayField : null,
113
+ readOnly: admin.readOnly === true,
114
+ permissions: {
115
+ create: admin.permissions?.create !== false,
116
+ edit: admin.permissions?.edit !== false,
117
+ delete: admin.permissions?.delete !== false,
118
+ },
119
+ defaultSort: typeof admin.defaultSort === 'object' && admin.defaultSort !== null ? admin.defaultSort : null,
120
+ group: typeof admin.group === 'string' ? admin.group : (typeof admin.menuGroup === 'string' ? admin.menuGroup : null),
121
+ menuOrder: typeof admin.menuOrder === 'number' ? admin.menuOrder : 10,
122
+ actions: Array.isArray(admin.actions) ? admin.actions : null,
123
+ bulkActions: Array.isArray(admin.bulkActions) ? admin.bulkActions : null,
124
+ hooks: typeof admin.hooks === 'object' && admin.hooks !== null ? admin.hooks : null,
125
+ relations: typeof admin.relations === 'object' && admin.relations !== null ? admin.relations : null,
110
126
  },
111
127
  rest: {
112
128
  enabled: rest.enabled === true,
@@ -152,13 +168,36 @@ function registerModelHooks(modelName, hooks) {
152
168
  }
153
169
  }
154
170
 
171
+ /**
172
+ * Extract model name string from string, String object, or model definition/context
173
+ * @param {string|Object} nameOrModel - Model name or object
174
+ * @returns {string}
175
+ */
176
+ function resolveModelName(nameOrModel) {
177
+ if (typeof nameOrModel === 'string') {
178
+ return nameOrModel;
179
+ }
180
+ if (nameOrModel && typeof nameOrModel === 'object') {
181
+ if (typeof nameOrModel.name === 'string') return nameOrModel.name;
182
+ if (typeof nameOrModel.modelName === 'string') return nameOrModel.modelName;
183
+ if (typeof nameOrModel.model === 'string') return nameOrModel.model;
184
+ if (typeof nameOrModel.valueOf === 'function') {
185
+ const val = nameOrModel.valueOf();
186
+ if (typeof val === 'string') return val;
187
+ }
188
+ return String(nameOrModel);
189
+ }
190
+ return nameOrModel != null ? String(nameOrModel) : '';
191
+ }
192
+
155
193
  /**
156
194
  * Get a model by name
157
- * @param {string} name - Model name
195
+ * @param {string|Object} name - Model name
158
196
  * @returns {import('./types').ModelDefinition|undefined}
159
197
  */
160
198
  function getModel(name) {
161
- return modelRegistry.get(name);
199
+ const modelName = resolveModelName(name);
200
+ return modelRegistry.get(modelName);
162
201
  }
163
202
 
164
203
  /**
@@ -171,11 +210,12 @@ function getAllModels() {
171
210
 
172
211
  /**
173
212
  * Check if a model exists
174
- * @param {string} name - Model name
213
+ * @param {string|Object} name - Model name
175
214
  * @returns {boolean}
176
215
  */
177
216
  function hasModel(name) {
178
- return modelRegistry.has(name);
217
+ const modelName = resolveModelName(name);
218
+ return modelRegistry.has(modelName);
179
219
  }
180
220
 
181
221
  /**
@@ -187,11 +227,12 @@ function clearRegistry() {
187
227
 
188
228
  /**
189
229
  * Unregister a model by name
190
- * @param {string} name - Model name
230
+ * @param {string|Object} name - Model name
191
231
  * @returns {boolean} Whether the model was removed
192
232
  */
193
233
  function unregisterModel(name) {
194
- return modelRegistry.delete(name);
234
+ const modelName = resolveModelName(name);
235
+ return modelRegistry.delete(modelName);
195
236
  }
196
237
 
197
238
  /**
@@ -201,7 +242,7 @@ function unregisterModel(name) {
201
242
  */
202
243
  function resolveRelationModel(relation) {
203
244
  let model = typeof relation.model === 'function' ? relation.model() : relation.model;
204
- if (typeof model === 'string') {
245
+ if (typeof model === 'string' || (model && typeof model === 'object' && !model.table)) {
205
246
  model = getModel(model);
206
247
  }
207
248
  if (!model || !model.name) {
@@ -239,6 +280,7 @@ module.exports = {
239
280
  hasModel,
240
281
  clearRegistry,
241
282
  unregisterModel,
283
+ resolveModelName,
242
284
  resolveRelationModel,
243
285
  getRelationKeys,
244
286
  // Export registry for testing
@@ -271,6 +271,100 @@ class DatabaseQueueAdapter {
271
271
  return stats;
272
272
  }
273
273
 
274
+ /**
275
+ * Delete completed or failed jobs older than a cutoff date
276
+ * @param {Object} [options={}]
277
+ * @param {string|string[]} [options.status=['completed']] - Job statuses to prune
278
+ * @param {Date|string|number} [options.olderThan] - Explicit cutoff date
279
+ * @param {number} [options.days=7] - Retention in days (used if olderThan not provided)
280
+ * @param {number} [options.retentionDays] - Alias for days
281
+ * @param {number} [options.limit] - Max number of records to delete
282
+ * @returns {Promise<number>} Number of deleted jobs
283
+ */
284
+ async clean(options = {}) {
285
+ await this.ensureTable();
286
+
287
+ let olderThan;
288
+ if (options.olderThan !== undefined) {
289
+ olderThan = options.olderThan instanceof Date
290
+ ? options.olderThan
291
+ : new Date(options.olderThan);
292
+
293
+ if (Number.isNaN(olderThan.getTime())) {
294
+ throw new Error('clean: invalid olderThan date');
295
+ }
296
+ } else {
297
+ const rawDays = options.days !== undefined
298
+ ? options.days
299
+ : (options.retentionDays !== undefined ? options.retentionDays : 7);
300
+ const days = typeof rawDays === 'number' ? rawDays : Number(rawDays);
301
+ if (Number.isNaN(days) || days < 0) {
302
+ throw new Error('clean: days must be a non-negative number');
303
+ }
304
+ olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
305
+ }
306
+
307
+ let statuses = ['completed'];
308
+ if (options.status) {
309
+ if (Array.isArray(options.status)) {
310
+ statuses = options.status;
311
+ } else if (typeof options.status === 'string') {
312
+ if (options.status === '*' || options.status === 'all') {
313
+ statuses = ['completed', 'failed', 'pending'];
314
+ } else {
315
+ statuses = options.status.split(',').map((s) => s.trim()).filter(Boolean);
316
+ }
317
+ }
318
+ }
319
+
320
+ const client = this.knex?.client?.config?.client;
321
+ const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
322
+
323
+ let query = this.knex(this.tableName);
324
+
325
+ if (statuses.length === 1) {
326
+ query = query.where('status', statuses[0]);
327
+ } else if (statuses.length > 1) {
328
+ query = query.whereIn('status', statuses);
329
+ }
330
+
331
+ if (isSqlite) {
332
+ const iso = olderThan.toISOString();
333
+ const cutoffMs = olderThan.getTime();
334
+ query = query.where((builder) => {
335
+ builder
336
+ .whereRaw(
337
+ "((typeof(updated_at) = 'integer' and updated_at < ?) or (typeof(updated_at) = 'text' and updated_at < ?))",
338
+ [cutoffMs, iso]
339
+ )
340
+ .orWhere((b2) => {
341
+ b2.whereNull('updated_at').andWhereRaw(
342
+ "((typeof(created_at) = 'integer' and created_at < ?) or (typeof(created_at) = 'text' and created_at < ?))",
343
+ [cutoffMs, iso]
344
+ );
345
+ });
346
+ });
347
+ } else {
348
+ query = query.where((builder) => {
349
+ builder.where('updated_at', '<', olderThan)
350
+ .orWhere((b2) => {
351
+ b2.whereNull('updated_at').andWhere('created_at', '<', olderThan);
352
+ });
353
+ });
354
+ }
355
+
356
+ if (typeof options.limit === 'number' && options.limit > 0) {
357
+ const rows = await query.clone().select('id').limit(options.limit);
358
+ if (rows.length === 0) return 0;
359
+ const ids = rows.map((r) => r.id);
360
+ const deleted = await this.knex(this.tableName).whereIn('id', ids).del();
361
+ return Number(deleted) || 0;
362
+ }
363
+
364
+ const deleted = await query.del();
365
+ return Number(deleted) || 0;
366
+ }
367
+
274
368
  /**
275
369
  * Clear all jobs from table
276
370
  * @returns {Promise<void>}
@@ -164,6 +164,68 @@ class MemoryQueueAdapter {
164
164
  };
165
165
  }
166
166
 
167
+ /**
168
+ * Delete completed or failed jobs older than a cutoff date
169
+ * @param {Object} [options={}]
170
+ * @param {string|string[]} [options.status=['completed']]
171
+ * @param {Date|string|number} [options.olderThan]
172
+ * @param {number} [options.days=7]
173
+ * @param {number} [options.retentionDays]
174
+ * @param {number} [options.limit]
175
+ * @returns {Promise<number>}
176
+ */
177
+ async clean(options = {}) {
178
+ let olderThan;
179
+ if (options.olderThan !== undefined) {
180
+ olderThan = options.olderThan instanceof Date
181
+ ? options.olderThan
182
+ : new Date(options.olderThan);
183
+
184
+ if (Number.isNaN(olderThan.getTime())) {
185
+ throw new Error('clean: invalid olderThan date');
186
+ }
187
+ } else {
188
+ const rawDays = options.days !== undefined
189
+ ? options.days
190
+ : (options.retentionDays !== undefined ? options.retentionDays : 7);
191
+ const days = typeof rawDays === 'number' ? rawDays : Number(rawDays);
192
+ if (Number.isNaN(days) || days < 0) {
193
+ throw new Error('clean: days must be a non-negative number');
194
+ }
195
+ olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
196
+ }
197
+
198
+ let statuses = ['completed'];
199
+ if (options.status) {
200
+ if (Array.isArray(options.status)) {
201
+ statuses = options.status;
202
+ } else if (typeof options.status === 'string') {
203
+ if (options.status === '*' || options.status === 'all') {
204
+ statuses = ['completed', 'failed', 'pending'];
205
+ } else {
206
+ statuses = options.status.split(',').map((s) => s.trim()).filter(Boolean);
207
+ }
208
+ }
209
+ }
210
+
211
+ const cutoffTime = olderThan.getTime();
212
+ let deletedCount = 0;
213
+ const limit = typeof options.limit === 'number' && options.limit > 0 ? options.limit : Infinity;
214
+
215
+ for (const [id, job] of this.jobs.entries()) {
216
+ if (deletedCount >= limit) break;
217
+ if (!statuses.includes(job.status)) continue;
218
+
219
+ const jobTime = (job.updatedAt instanceof Date ? job.updatedAt.getTime() : (job.createdAt instanceof Date ? job.createdAt.getTime() : 0));
220
+ if (jobTime < cutoffTime) {
221
+ this.jobs.delete(id);
222
+ deletedCount++;
223
+ }
224
+ }
225
+
226
+ return deletedCount;
227
+ }
228
+
167
229
  /**
168
230
  * Clear all jobs
169
231
  * @returns {Promise<void>}
@@ -336,6 +336,86 @@ class RedisQueueAdapter {
336
336
  };
337
337
  }
338
338
 
339
+ /**
340
+ * Delete completed or failed jobs older than a cutoff date
341
+ * @param {Object} [options={}]
342
+ * @param {string|string[]} [options.status=['completed']]
343
+ * @param {Date|string|number} [options.olderThan]
344
+ * @param {number} [options.days=7]
345
+ * @param {number} [options.retentionDays]
346
+ * @param {number} [options.limit]
347
+ * @returns {Promise<number>}
348
+ */
349
+ async clean(options = {}) {
350
+ let olderThan;
351
+ if (options.olderThan !== undefined) {
352
+ olderThan = options.olderThan instanceof Date
353
+ ? options.olderThan
354
+ : new Date(options.olderThan);
355
+
356
+ if (Number.isNaN(olderThan.getTime())) {
357
+ throw new Error('clean: invalid olderThan date');
358
+ }
359
+ } else {
360
+ const rawDays = options.days !== undefined
361
+ ? options.days
362
+ : (options.retentionDays !== undefined ? options.retentionDays : 7);
363
+ const days = typeof rawDays === 'number' ? rawDays : Number(rawDays);
364
+ if (Number.isNaN(days) || days < 0) {
365
+ throw new Error('clean: days must be a non-negative number');
366
+ }
367
+ olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
368
+ }
369
+
370
+ let statuses = ['completed'];
371
+ if (options.status) {
372
+ if (Array.isArray(options.status)) {
373
+ statuses = options.status;
374
+ } else if (typeof options.status === 'string') {
375
+ if (options.status === '*' || options.status === 'all') {
376
+ statuses = ['completed', 'failed'];
377
+ } else {
378
+ statuses = options.status.split(',').map((s) => s.trim()).filter(Boolean);
379
+ }
380
+ }
381
+ }
382
+
383
+ const cutoffTime = olderThan.getTime();
384
+ let deletedCount = 0;
385
+ const limit = typeof options.limit === 'number' && options.limit > 0 ? options.limit : Infinity;
386
+
387
+ for (const status of statuses) {
388
+ const setKey = this._key(status);
389
+ let jobIds = [];
390
+ try {
391
+ if (typeof this.client.smembers === 'function') {
392
+ jobIds = (await this.client.smembers(setKey)) || [];
393
+ } else if (typeof this.client.sMembers === 'function') {
394
+ jobIds = (await this.client.sMembers(setKey)) || [];
395
+ }
396
+ } catch {}
397
+
398
+ for (const id of jobIds) {
399
+ if (deletedCount >= limit) break;
400
+ const job = await this.getJob(id);
401
+ const jobTime = job?.updatedAt ? new Date(job.updatedAt).getTime() : 0;
402
+ if (!job || jobTime < cutoffTime) {
403
+ if (typeof this.client.srem === 'function') {
404
+ await this.client.srem(setKey, id);
405
+ } else if (typeof this.client.sRem === 'function') {
406
+ await this.client.sRem(setKey, id);
407
+ }
408
+ if (typeof this.client.del === 'function') {
409
+ await this.client.del(this._key('job:' + id));
410
+ }
411
+ deletedCount++;
412
+ }
413
+ }
414
+ }
415
+
416
+ return deletedCount;
417
+ }
418
+
339
419
  /**
340
420
  * Clear all queue keys
341
421
  * @returns {Promise<void>}
@@ -42,6 +42,42 @@ class QueueManager extends EventEmitter {
42
42
  this.isRunning = false;
43
43
  this._timer = null;
44
44
 
45
+ // Retention configuration
46
+ const retentionOption = options.retention;
47
+ const isRetentionDisabled =
48
+ retentionOption === false ||
49
+ (typeof retentionOption === 'object' && retentionOption.enabled === false) ||
50
+ options.retentionDays === null;
51
+
52
+ const autoCleanupEnabled =
53
+ !isRetentionDisabled &&
54
+ options.autoCleanup !== false &&
55
+ (typeof retentionOption !== 'object' || retentionOption.autoCleanup !== false) &&
56
+ (retentionOption !== undefined || options.retentionDays !== undefined || options.cleanupIntervalMs !== undefined);
57
+
58
+ this.retentionConfig = {
59
+ enabled: !isRetentionDisabled && autoCleanupEnabled,
60
+ days: (typeof options.retentionDays === 'number' && options.retentionDays >= 0)
61
+ ? options.retentionDays
62
+ : (typeof retentionOption === 'object' && retentionOption?.days ? retentionOption.days : 7),
63
+ statuses: (typeof retentionOption === 'object' && retentionOption?.statuses)
64
+ ? retentionOption.statuses
65
+ : (options.retentionStatuses || ['completed']),
66
+ intervalMs: (typeof options.cleanupIntervalMs === 'number' && options.cleanupIntervalMs > 0)
67
+ ? options.cleanupIntervalMs
68
+ : (typeof retentionOption === 'object' && retentionOption?.intervalMs ? retentionOption.intervalMs : 24 * 60 * 60 * 1000),
69
+ runOnStart: (typeof retentionOption === 'object' && retentionOption?.runOnStart !== undefined)
70
+ ? !!retentionOption.runOnStart
71
+ : (options.cleanupOnStart !== undefined ? !!options.cleanupOnStart : false),
72
+ };
73
+
74
+ this._cleanupInterval = null;
75
+ this._cleanupStartTimeout = null;
76
+
77
+ if (this.retentionConfig.enabled) {
78
+ this.startCleanup();
79
+ }
80
+
45
81
  if (this.jobsDir && fs.existsSync(this.jobsDir)) {
46
82
  this.loadJobsFromDirectory(this.jobsDir);
47
83
  }
@@ -199,6 +235,7 @@ class QueueManager extends EventEmitter {
199
235
 
200
236
  this.isDraining = false;
201
237
  this.isRunning = false;
238
+ this.stopCleanup();
202
239
  this.emit('drain');
203
240
  }
204
241
 
@@ -208,9 +245,95 @@ class QueueManager extends EventEmitter {
208
245
  async stop() {
209
246
  this.pause();
210
247
  this.isRunning = false;
248
+ this.stopCleanup();
211
249
  this.emit('stop');
212
250
  }
213
251
 
252
+ /**
253
+ * Delete completed or failed jobs older than a cutoff date
254
+ * @param {Object} [options={}]
255
+ * @param {string|string[]} [options.status=['completed']]
256
+ * @param {Date|string|number} [options.olderThan]
257
+ * @param {number} [options.days=7]
258
+ * @param {number} [options.retentionDays]
259
+ * @param {number} [options.limit]
260
+ * @returns {Promise<number>}
261
+ */
262
+ async clean(options = {}) {
263
+ if (this.adapter && typeof this.adapter.clean === 'function') {
264
+ return this.adapter.clean(options);
265
+ }
266
+ return 0;
267
+ }
268
+
269
+ /**
270
+ * Run one-time retention cleanup pass
271
+ * @returns {Promise<number>}
272
+ */
273
+ async runCleanup() {
274
+ try {
275
+ return await this.clean({
276
+ status: this.retentionConfig.statuses,
277
+ days: this.retentionConfig.days,
278
+ });
279
+ } catch (err) {
280
+ if (process.env.NODE_ENV !== 'test') {
281
+ console.warn('[QueueManager] Failed to run retention cleanup:', err.message);
282
+ }
283
+ return 0;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Start auto-retention cleanup timer
289
+ * @param {number} [customIntervalMs]
290
+ */
291
+ startCleanup(customIntervalMs) {
292
+ this.stopCleanup();
293
+ if (!this.retentionConfig.enabled) return;
294
+ if (typeof customIntervalMs === 'number' && customIntervalMs > 0) {
295
+ this.retentionConfig.intervalMs = customIntervalMs;
296
+ }
297
+
298
+ if (this.retentionConfig.runOnStart) {
299
+ const delayMs = process.env.NODE_ENV === 'test' ? 50 : 5000;
300
+ this._cleanupStartTimeout = setTimeout(() => {
301
+ this.runCleanup();
302
+ }, delayMs);
303
+ if (this._cleanupStartTimeout && this._cleanupStartTimeout.unref) {
304
+ this._cleanupStartTimeout.unref();
305
+ }
306
+ }
307
+
308
+ this._cleanupInterval = setInterval(() => {
309
+ this.runCleanup();
310
+ }, this.retentionConfig.intervalMs);
311
+ if (this._cleanupInterval && this._cleanupInterval.unref) {
312
+ this._cleanupInterval.unref();
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Stop auto-retention cleanup timer
318
+ */
319
+ stopCleanup() {
320
+ if (this._cleanupStartTimeout) {
321
+ clearTimeout(this._cleanupStartTimeout);
322
+ this._cleanupStartTimeout = null;
323
+ }
324
+ if (this._cleanupInterval) {
325
+ clearInterval(this._cleanupInterval);
326
+ this._cleanupInterval = null;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Get retention configuration
332
+ */
333
+ getRetentionConfig() {
334
+ return { ...this.retentionConfig };
335
+ }
336
+
214
337
  /**
215
338
  * Get queue statistics
216
339
  */
@@ -182,6 +182,17 @@ function renderStream(res, templatePath, context = {}, options = {}) {
182
182
  };
183
183
 
184
184
  res.on('close', cleanup);
185
+ if (typeof res.on === 'function') {
186
+ res.on('error', (err) => {
187
+ if (!finished) {
188
+ finished = true;
189
+ if (!stream.destroyed) {
190
+ stream.destroy(err);
191
+ }
192
+ reject(err);
193
+ }
194
+ });
195
+ }
185
196
 
186
197
  stream.on('data', (chunk) => {
187
198
  if (!finished && !res.writableEnded) {