webspresso 0.0.97 → 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -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>}
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Webspresso Native Cron Expression Parser & Matcher
3
+ * Pure Node.js standard 5-part cron implementation (zero external dependencies)
4
+ * @module core/queue/cron
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const MONTH_NAMES = {
10
+ jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
11
+ jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12
12
+ };
13
+
14
+ const DAY_NAMES = {
15
+ sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6
16
+ };
17
+
18
+ const CRON_ALIASES = {
19
+ '@yearly': '0 0 1 1 *',
20
+ '@annually': '0 0 1 1 *',
21
+ '@monthly': '0 0 1 * *',
22
+ '@weekly': '0 0 * * 0',
23
+ '@daily': '0 0 * * *',
24
+ '@midnight': '0 0 * * *',
25
+ '@hourly': '0 * * * *',
26
+ '@every_minute': '* * * * *',
27
+ '@minutely': '* * * * *',
28
+ };
29
+
30
+ /**
31
+ * Resolves alias string (e.g. '@daily', '@every 5m') into a 5-part cron expression.
32
+ * @param {string} str
33
+ * @returns {string}
34
+ */
35
+ function resolveCronAlias(str) {
36
+ const trimmed = str.trim();
37
+ const lower = trimmed.toLowerCase();
38
+
39
+ if (CRON_ALIASES[lower]) {
40
+ return CRON_ALIASES[lower];
41
+ }
42
+
43
+ // Support '@every 5m', '@every 15m', '@every 2h'
44
+ const everyMatch = lower.match(/^@every\s+(\d+)\s*([mh])$/);
45
+ if (everyMatch) {
46
+ const amount = parseInt(everyMatch[1], 10);
47
+ const unit = everyMatch[2];
48
+ if (unit === 'm' && amount >= 1 && amount <= 59) {
49
+ return `*/${amount} * * * *`;
50
+ }
51
+ if (unit === 'h' && amount >= 1 && amount <= 23) {
52
+ return `0 */${amount} * * *`;
53
+ }
54
+ }
55
+
56
+ return trimmed;
57
+ }
58
+
59
+ /**
60
+ * Parse a single cron field with ranges, steps, and lists.
61
+ * @param {string} field
62
+ * @param {number} min
63
+ * @param {number} max
64
+ * @param {Record<string, number>} [nameMap]
65
+ * @returns {Set<number>}
66
+ */
67
+ function parseField(field, min, max, nameMap = null) {
68
+ let str = field.trim().toLowerCase();
69
+
70
+ if (nameMap) {
71
+ for (const [name, val] of Object.entries(nameMap)) {
72
+ str = str.replace(new RegExp(`\\b${name}\\b`, 'g'), String(val));
73
+ }
74
+ }
75
+
76
+ const values = new Set();
77
+ const parts = str.split(',');
78
+
79
+ for (const part of parts) {
80
+ if (!part) {
81
+ throw new Error(`Invalid empty cron segment in '${field}'`);
82
+ }
83
+
84
+ let step = 1;
85
+ let rangeStr = part;
86
+
87
+ if (part.includes('/')) {
88
+ const sub = part.split('/');
89
+ if (sub.length !== 2) {
90
+ throw new Error(`Invalid step syntax in '${part}'`);
91
+ }
92
+ rangeStr = sub[0];
93
+ step = parseInt(sub[1], 10);
94
+ if (isNaN(step) || step <= 0) {
95
+ throw new Error(`Invalid step value '${sub[1]}' in '${part}'`);
96
+ }
97
+ }
98
+
99
+ let start;
100
+ let end;
101
+
102
+ if (rangeStr === '*' || rangeStr === '') {
103
+ start = min;
104
+ end = max;
105
+ } else if (rangeStr.includes('-')) {
106
+ const sub = rangeStr.split('-');
107
+ if (sub.length !== 2) {
108
+ throw new Error(`Invalid range syntax in '${rangeStr}'`);
109
+ }
110
+ start = parseInt(sub[0], 10);
111
+ end = parseInt(sub[1], 10);
112
+ if (isNaN(start) || isNaN(end) || start > end) {
113
+ throw new Error(`Invalid range boundary '${rangeStr}' in '${part}'`);
114
+ }
115
+ } else {
116
+ start = parseInt(rangeStr, 10);
117
+ end = start;
118
+ if (isNaN(start)) {
119
+ throw new Error(`Invalid numeric value '${rangeStr}' in '${part}'`);
120
+ }
121
+ }
122
+
123
+ // Day of week special case: 7 is Sunday (same as 0)
124
+ if (max === 6 && (start === 7 || end === 7)) {
125
+ if (start === 7) start = 0;
126
+ if (end === 7) end = 0;
127
+ }
128
+
129
+ if (start < min || end > max) {
130
+ throw new Error(`Cron value out of range [${min}-${max}]: '${part}'`);
131
+ }
132
+
133
+ for (let i = start; i <= end; i += step) {
134
+ values.add(i === 7 && max === 6 ? 0 : i);
135
+ }
136
+ }
137
+
138
+ return values;
139
+ }
140
+
141
+ class CronExpression {
142
+ /**
143
+ * @param {string} expression - Standard 5-field cron or alias
144
+ * @param {Object} [options]
145
+ * @param {boolean} [options.utc=false] - Evaluate dates in UTC instead of local time
146
+ */
147
+ constructor(expression, options = {}) {
148
+ if (typeof expression !== 'string' || !expression.trim()) {
149
+ throw new Error('Cron expression must be a non-empty string');
150
+ }
151
+
152
+ this.rawExpression = expression;
153
+ this.resolvedExpression = resolveCronAlias(expression);
154
+ this.utc = !!options.utc;
155
+
156
+ const parts = this.resolvedExpression.trim().split(/\s+/);
157
+ if (parts.length !== 5) {
158
+ throw new Error(
159
+ `Invalid cron expression '${expression}': expected 5 fields (minute hour day-of-month month day-of-week), got ${parts.length}`
160
+ );
161
+ }
162
+
163
+ this.domAny = parts[2] === '*';
164
+ this.dowAny = parts[4] === '*';
165
+
166
+ this.minutes = parseField(parts[0], 0, 59);
167
+ this.hours = parseField(parts[1], 0, 23);
168
+ this.doms = parseField(parts[2], 1, 31);
169
+ this.months = parseField(parts[3], 1, 12, MONTH_NAMES);
170
+ this.dows = parseField(parts[4], 0, 6, DAY_NAMES);
171
+ }
172
+
173
+ /**
174
+ * Check if a given Date matches the cron expression
175
+ * @param {Date} [date=new Date()]
176
+ * @returns {boolean}
177
+ */
178
+ match(date = new Date()) {
179
+ const minute = this.utc ? date.getUTCMinutes() : date.getMinutes();
180
+ const hour = this.utc ? date.getUTCHours() : date.getHours();
181
+ const month = (this.utc ? date.getUTCMonth() : date.getMonth()) + 1;
182
+ const dom = this.utc ? date.getUTCDate() : date.getDate();
183
+ const dow = this.utc ? date.getUTCDay() : date.getDay();
184
+
185
+ if (!this.minutes.has(minute)) return false;
186
+ if (!this.hours.has(hour)) return false;
187
+ if (!this.months.has(month)) return false;
188
+
189
+ // Standard POSIX / crontab union behavior:
190
+ // If either dom or dow is '*', the other specifies matching day.
191
+ // If both are specified (neither is '*'), either match satisfies.
192
+ if (this.domAny && this.dowAny) return true;
193
+ if (!this.domAny && this.dowAny) return this.doms.has(dom);
194
+ if (this.domAny && !this.dowAny) return this.dows.has(dow);
195
+ return this.doms.has(dom) || this.dows.has(dow);
196
+ }
197
+
198
+ /**
199
+ * Calculate next matching Date after fromDate (default: now)
200
+ * Uses jumping skips to avoid long iteration loops.
201
+ *
202
+ * @param {Date} [fromDate=new Date()]
203
+ * @returns {Date|null}
204
+ */
205
+ getNextDate(fromDate = new Date()) {
206
+ const d = new Date(fromDate.getTime());
207
+
208
+ if (this.utc) {
209
+ d.setUTCSeconds(0, 0);
210
+ d.setUTCMinutes(d.getUTCMinutes() + 1);
211
+ } else {
212
+ d.setSeconds(0, 0);
213
+ d.setMinutes(d.getMinutes() + 1);
214
+ }
215
+
216
+ const startYear = this.utc ? d.getUTCFullYear() : d.getFullYear();
217
+ const maxYear = startYear + 5;
218
+
219
+ while ((this.utc ? d.getUTCFullYear() : d.getFullYear()) <= maxYear) {
220
+ const month = (this.utc ? d.getUTCMonth() : d.getMonth()) + 1;
221
+ if (!this.months.has(month)) {
222
+ if (this.utc) {
223
+ d.setUTCMonth(d.getUTCMonth() + 1, 1);
224
+ d.setUTCHours(0, 0, 0, 0);
225
+ } else {
226
+ d.setMonth(d.getMonth() + 1, 1);
227
+ d.setHours(0, 0, 0, 0);
228
+ }
229
+ continue;
230
+ }
231
+
232
+ const dom = this.utc ? d.getUTCDate() : d.getDate();
233
+ const dow = this.utc ? d.getUTCDay() : d.getDay();
234
+
235
+ const dayMatch = (this.domAny && this.dowAny)
236
+ ? true
237
+ : (!this.domAny && this.dowAny)
238
+ ? this.doms.has(dom)
239
+ : (this.domAny && !this.dowAny)
240
+ ? this.dows.has(dow)
241
+ : (this.doms.has(dom) || this.dows.has(dow));
242
+
243
+ if (!dayMatch) {
244
+ if (this.utc) {
245
+ d.setUTCDate(d.getUTCDate() + 1);
246
+ d.setUTCHours(0, 0, 0, 0);
247
+ } else {
248
+ d.setDate(d.getDate() + 1);
249
+ d.setHours(0, 0, 0, 0);
250
+ }
251
+ continue;
252
+ }
253
+
254
+ const hour = this.utc ? d.getUTCHours() : d.getHours();
255
+ if (!this.hours.has(hour)) {
256
+ if (this.utc) {
257
+ d.setUTCHours(d.getUTCHours() + 1, 0, 0, 0);
258
+ } else {
259
+ d.setHours(d.getHours() + 1, 0, 0, 0);
260
+ }
261
+ continue;
262
+ }
263
+
264
+ const minute = this.utc ? d.getUTCMinutes() : d.getMinutes();
265
+ if (!this.minutes.has(minute)) {
266
+ if (this.utc) {
267
+ d.setUTCMinutes(d.getUTCMinutes() + 1);
268
+ } else {
269
+ d.setMinutes(d.getMinutes() + 1);
270
+ }
271
+ continue;
272
+ }
273
+
274
+ return d;
275
+ }
276
+
277
+ return null;
278
+ }
279
+
280
+ /**
281
+ * Return the next N matching Dates
282
+ * @param {number} count
283
+ * @param {Date} [fromDate=new Date()]
284
+ * @returns {Date[]}
285
+ */
286
+ getNextDates(count = 5, fromDate = new Date()) {
287
+ const dates = [];
288
+ let current = fromDate;
289
+
290
+ for (let i = 0; i < count; i++) {
291
+ const next = this.getNextDate(current);
292
+ if (!next) break;
293
+ dates.push(next);
294
+ current = next;
295
+ }
296
+
297
+ return dates;
298
+ }
299
+
300
+ /**
301
+ * Static helper to validate a cron expression string
302
+ * @param {string} expression
303
+ * @returns {boolean}
304
+ */
305
+ static isValid(expression) {
306
+ try {
307
+ new CronExpression(expression);
308
+ return true;
309
+ } catch (_) {
310
+ return false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Parse expression into a CronExpression instance
316
+ * @param {string} expression
317
+ * @param {Object} [options]
318
+ * @returns {CronExpression}
319
+ */
320
+ static parse(expression, options) {
321
+ return new CronExpression(expression, options);
322
+ }
323
+ }
324
+
325
+ module.exports = {
326
+ CronExpression,
327
+ resolveCronAlias,
328
+ isValidCron: CronExpression.isValid,
329
+ parseCron: CronExpression.parse,
330
+ CRON_ALIASES,
331
+ };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Webspresso Core Background Job Queue Engine
2
+ * Webspresso Core Background Job Queue & Task Scheduler Engine
3
3
  * @module core/queue
4
4
  */
5
5
 
@@ -10,6 +10,8 @@ const { QueueManager, createQueueManager } = require('./manager');
10
10
  const { MemoryQueueAdapter } = require('./adapters/memory');
11
11
  const { DatabaseQueueAdapter } = require('./adapters/database');
12
12
  const { RedisQueueAdapter } = require('./adapters/redis');
13
+ const { CronExpression, isValidCron, parseCron, resolveCronAlias } = require('./cron');
14
+ const { TaskScheduler } = require('./scheduler');
13
15
 
14
16
  module.exports = {
15
17
  Job,
@@ -18,4 +20,9 @@ module.exports = {
18
20
  MemoryQueueAdapter,
19
21
  DatabaseQueueAdapter,
20
22
  RedisQueueAdapter,
23
+ CronExpression,
24
+ isValidCron,
25
+ parseCron,
26
+ resolveCronAlias,
27
+ TaskScheduler,
21
28
  };