webspresso 0.0.96 → 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.
- package/.agents/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +51 -11
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/page.js +6 -3
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/seed.js +1 -1
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +31 -17
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +32 -12
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +124 -31
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +34 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/core.js +1 -1
- package/plugins/file-manager/security.js +33 -23
- package/plugins/index.js +49 -87
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +30 -8
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +213 -63
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
- package/src/services/discovery.js +6 -1
- package/src/services/index.js +6 -13
package/core/orm/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const { createSchemaHelpers, extractColumnsFromSchema, getColumnMeta } = require('./schema-helpers');
|
|
10
|
-
const { defineModel, getModel, getAllModels, hasModel, clearRegistry } = require('./model');
|
|
10
|
+
const { defineModel, getModel, getAllModels, hasModel, clearRegistry, resolveModelName } = require('./model');
|
|
11
11
|
const { createRepository } = require('./repository');
|
|
12
12
|
const { createMigrationManager } = require('./migrations');
|
|
13
13
|
const { createSeeder } = require('./seeder');
|
|
@@ -172,13 +172,11 @@ function createDatabase(config) {
|
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
174
|
* Get a model by name
|
|
175
|
-
* @param {string|import('./types').ModelDefinition} nameOrModel - Model name or model definition
|
|
175
|
+
* @param {string|Object|import('./types').ModelDefinition} nameOrModel - Model name or model definition
|
|
176
176
|
* @returns {import('./types').ModelDefinition}
|
|
177
177
|
*/
|
|
178
178
|
function getModelInstance(nameOrModel) {
|
|
179
|
-
const name =
|
|
180
|
-
? nameOrModel.name
|
|
181
|
-
: nameOrModel;
|
|
179
|
+
const name = resolveModelName(nameOrModel);
|
|
182
180
|
|
|
183
181
|
// First check local registry
|
|
184
182
|
let model = models.get(name);
|
|
@@ -199,11 +197,12 @@ function createDatabase(config) {
|
|
|
199
197
|
|
|
200
198
|
/**
|
|
201
199
|
* Check if a model exists
|
|
202
|
-
* @param {string} name - Model name
|
|
200
|
+
* @param {string|Object} name - Model name
|
|
203
201
|
* @returns {boolean}
|
|
204
202
|
*/
|
|
205
203
|
function hasModelInstance(name) {
|
|
206
|
-
|
|
204
|
+
const modelName = resolveModelName(name);
|
|
205
|
+
return models.has(modelName) || hasModel(modelName);
|
|
207
206
|
}
|
|
208
207
|
|
|
209
208
|
/**
|
|
@@ -211,6 +210,12 @@ function createDatabase(config) {
|
|
|
211
210
|
* @returns {Array<import('./types').ModelDefinition>}
|
|
212
211
|
*/
|
|
213
212
|
function getAllModelInstances() {
|
|
213
|
+
const globalModels = getAllModels();
|
|
214
|
+
for (const [name, model] of globalModels) {
|
|
215
|
+
if (!models.has(name)) {
|
|
216
|
+
models.set(name, model);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
214
219
|
return Array.from(models.values());
|
|
215
220
|
}
|
|
216
221
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>}
|
package/core/queue/manager.js
CHANGED
|
@@ -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
|
*/
|
package/core/ssr/stream.js
CHANGED
|
@@ -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) {
|