@mantiq/queue 0.0.1

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.
@@ -0,0 +1,252 @@
1
+ import type { Job } from '../Job.ts'
2
+ import type { Constructor } from '../contracts/JobContract.ts'
3
+
4
+ /**
5
+ * A single scheduled entry — wraps a command, job, or callback
6
+ * with a cron-like schedule expression.
7
+ */
8
+ export class ScheduleEntry {
9
+ private cronExpression = '* * * * *'
10
+ private _description = ''
11
+ private _type: 'command' | 'job' | 'callback'
12
+ private _value: string | Constructor<Job> | (() => any)
13
+ private _jobData?: Record<string, any> | undefined
14
+
15
+ constructor(type: 'command' | 'job' | 'callback', value: string | Constructor<Job> | (() => any), jobData?: Record<string, any>) {
16
+ this._type = type
17
+ this._value = value
18
+ this._jobData = jobData
19
+ }
20
+
21
+ get type() { return this._type }
22
+ get value() { return this._value }
23
+ get jobData() { return this._jobData }
24
+ get description() { return this._description }
25
+ get expression() { return this.cronExpression }
26
+
27
+ // ── Frequency helpers ────────────────────────────────────────────
28
+
29
+ /** Run every minute */
30
+ everyMinute(): this {
31
+ this.cronExpression = '* * * * *'
32
+ return this
33
+ }
34
+
35
+ /** Run every 5 minutes */
36
+ everyFiveMinutes(): this {
37
+ this.cronExpression = '*/5 * * * *'
38
+ return this
39
+ }
40
+
41
+ /** Run every 10 minutes */
42
+ everyTenMinutes(): this {
43
+ this.cronExpression = '*/10 * * * *'
44
+ return this
45
+ }
46
+
47
+ /** Run every 15 minutes */
48
+ everyFifteenMinutes(): this {
49
+ this.cronExpression = '*/15 * * * *'
50
+ return this
51
+ }
52
+
53
+ /** Run every 30 minutes */
54
+ everyThirtyMinutes(): this {
55
+ this.cronExpression = '*/30 * * * *'
56
+ return this
57
+ }
58
+
59
+ /** Run once per hour at minute 0 */
60
+ hourly(): this {
61
+ this.cronExpression = '0 * * * *'
62
+ return this
63
+ }
64
+
65
+ /** Run once per hour at a specific minute */
66
+ hourlyAt(minute: number): this {
67
+ this.cronExpression = `${minute} * * * *`
68
+ return this
69
+ }
70
+
71
+ /** Run once per day at midnight */
72
+ daily(): this {
73
+ this.cronExpression = '0 0 * * *'
74
+ return this
75
+ }
76
+
77
+ /** Run daily at a specific time (HH:MM) */
78
+ dailyAt(time: string): this {
79
+ const [hours, minutes] = time.split(':').map(Number)
80
+ this.cronExpression = `${minutes ?? 0} ${hours ?? 0} * * *`
81
+ return this
82
+ }
83
+
84
+ /** Run twice daily at the given hours */
85
+ twiceDaily(hour1 = 1, hour2 = 13): this {
86
+ this.cronExpression = `0 ${hour1},${hour2} * * *`
87
+ return this
88
+ }
89
+
90
+ /** Run once per week on Sunday at midnight */
91
+ weekly(): this {
92
+ this.cronExpression = '0 0 * * 0'
93
+ return this
94
+ }
95
+
96
+ /** Run weekly on a specific day and time */
97
+ weeklyOn(day: number, time = '0:0'): this {
98
+ const [hours, minutes] = time.split(':').map(Number)
99
+ this.cronExpression = `${minutes ?? 0} ${hours ?? 0} * * ${day}`
100
+ return this
101
+ }
102
+
103
+ /** Run once per month on the 1st at midnight */
104
+ monthly(): this {
105
+ this.cronExpression = '0 0 1 * *'
106
+ return this
107
+ }
108
+
109
+ /** Run monthly on a specific day and time */
110
+ monthlyOn(day = 1, time = '0:0'): this {
111
+ const [hours, minutes] = time.split(':').map(Number)
112
+ this.cronExpression = `${minutes ?? 0} ${hours ?? 0} ${day} * *`
113
+ return this
114
+ }
115
+
116
+ /** Run once per year on January 1st at midnight */
117
+ yearly(): this {
118
+ this.cronExpression = '0 0 1 1 *'
119
+ return this
120
+ }
121
+
122
+ /** Set a raw cron expression */
123
+ cron(expression: string): this {
124
+ this.cronExpression = expression
125
+ return this
126
+ }
127
+
128
+ /** Set a human-readable description */
129
+ describedAs(description: string): this {
130
+ this._description = description
131
+ return this
132
+ }
133
+
134
+ /**
135
+ * Check if this entry is due to run at a given date (defaults to now).
136
+ * Matches minute, hour, day-of-month, month, and day-of-week.
137
+ */
138
+ isDue(now?: Date): boolean {
139
+ const date = now ?? new Date()
140
+ const parts = this.cronExpression.split(/\s+/)
141
+ if (parts.length !== 5) return false
142
+
143
+ const minute = date.getMinutes()
144
+ const hour = date.getHours()
145
+ const dayOfMonth = date.getDate()
146
+ const month = date.getMonth() + 1
147
+ const dayOfWeek = date.getDay()
148
+
149
+ return (
150
+ matchesCronField(parts[0]!, minute, 0, 59) &&
151
+ matchesCronField(parts[1]!, hour, 0, 23) &&
152
+ matchesCronField(parts[2]!, dayOfMonth, 1, 31) &&
153
+ matchesCronField(parts[3]!, month, 1, 12) &&
154
+ matchesCronField(parts[4]!, dayOfWeek, 0, 6)
155
+ )
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Schedule registry — collects commands, jobs, and callbacks
161
+ * that should be run on a recurring basis.
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * // routes/console.ts
166
+ * import { schedule } from '@mantiq/queue'
167
+ *
168
+ * export default function (schedule: Schedule) {
169
+ * schedule.command('cache:prune').daily()
170
+ * schedule.job(ProcessReports).dailyAt('02:00')
171
+ * schedule.call(() => console.log('heartbeat')).everyFiveMinutes()
172
+ * }
173
+ * ```
174
+ */
175
+ export class Schedule {
176
+ private entries: ScheduleEntry[] = []
177
+
178
+ /** Schedule an artisan/CLI command */
179
+ command(name: string): ScheduleEntry {
180
+ const entry = new ScheduleEntry('command', name)
181
+ this.entries.push(entry)
182
+ return entry
183
+ }
184
+
185
+ /** Schedule a queued job */
186
+ job(jobClass: Constructor<Job>, data?: Record<string, any>): ScheduleEntry {
187
+ const entry = new ScheduleEntry('job', jobClass, data)
188
+ this.entries.push(entry)
189
+ return entry
190
+ }
191
+
192
+ /** Schedule an arbitrary callback */
193
+ call(callback: () => any): ScheduleEntry {
194
+ const entry = new ScheduleEntry('callback', callback)
195
+ this.entries.push(entry)
196
+ return entry
197
+ }
198
+
199
+ /** Get all entries that are due at the given time (defaults to now) */
200
+ dueEntries(now?: Date): ScheduleEntry[] {
201
+ return this.entries.filter((e) => e.isDue(now))
202
+ }
203
+
204
+ /** Get all registered entries */
205
+ allEntries(): ScheduleEntry[] {
206
+ return [...this.entries]
207
+ }
208
+ }
209
+
210
+ // ── Cron expression matching ──────────────────────────────────────
211
+
212
+ function matchesCronField(field: string, value: number, min: number, max: number): boolean {
213
+ if (field === '*') return true
214
+
215
+ // Handle comma-separated values: '1,15,30'
216
+ if (field.includes(',')) {
217
+ return field.split(',').some((part) => matchesCronField(part.trim(), value, min, max))
218
+ }
219
+
220
+ // Handle step values: '*/5', '1-30/5'
221
+ if (field.includes('/')) {
222
+ const [range, stepStr] = field.split('/')
223
+ const step = parseInt(stepStr!, 10)
224
+ if (isNaN(step) || step <= 0) return false
225
+
226
+ let start = min
227
+ let end = max
228
+ if (range !== '*') {
229
+ if (range!.includes('-')) {
230
+ const [s, e] = range!.split('-').map(Number)
231
+ start = s!
232
+ end = e!
233
+ } else {
234
+ start = parseInt(range!, 10)
235
+ }
236
+ }
237
+
238
+ for (let i = start; i <= end; i += step) {
239
+ if (i === value) return true
240
+ }
241
+ return false
242
+ }
243
+
244
+ // Handle ranges: '1-5'
245
+ if (field.includes('-')) {
246
+ const [start, end] = field.split('-').map(Number)
247
+ return value >= start! && value <= end!
248
+ }
249
+
250
+ // Simple number
251
+ return parseInt(field, 10) === value
252
+ }
@@ -0,0 +1,209 @@
1
+ import type { QueueDriver } from '../contracts/QueueDriver.ts'
2
+ import type {
3
+ QueuedJob,
4
+ FailedJob,
5
+ SerializedPayload,
6
+ BatchRecord,
7
+ Constructor,
8
+ } from '../contracts/JobContract.ts'
9
+ import type { Job } from '../Job.ts'
10
+
11
+ interface PushedJob {
12
+ payload: SerializedPayload
13
+ queue: string
14
+ delay: number
15
+ }
16
+
17
+ /**
18
+ * Fake queue driver for testing.
19
+ * Stores jobs in memory without executing them.
20
+ * Provides assertion methods for verifying dispatch behavior.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * const fake = new QueueFake()
25
+ * // ... dispatch jobs ...
26
+ * fake.assertPushed(ProcessPayment)
27
+ * fake.assertPushedOn('payments', ProcessPayment)
28
+ * fake.assertNotPushed(SendEmail)
29
+ * ```
30
+ */
31
+ export class QueueFake implements QueueDriver {
32
+ private pushedJobs: PushedJob[] = []
33
+ private failedJobs: FailedJob[] = []
34
+ private batches = new Map<string, BatchRecord>()
35
+ private nextId = 1
36
+
37
+ // ── QueueDriver implementation ──────────────────────────────────
38
+
39
+ async push(payload: SerializedPayload, queue: string, delay = 0): Promise<string | number> {
40
+ const id = this.nextId++
41
+ this.pushedJobs.push({ payload, queue, delay })
42
+ return id
43
+ }
44
+
45
+ async pop(): Promise<QueuedJob | null> {
46
+ return null // Fake doesn't execute jobs
47
+ }
48
+
49
+ async delete(): Promise<void> {}
50
+ async release(): Promise<void> {}
51
+
52
+ async size(queue: string): Promise<number> {
53
+ return this.pushedJobs.filter((j) => j.queue === queue).length
54
+ }
55
+
56
+ async clear(queue: string): Promise<void> {
57
+ this.pushedJobs = this.pushedJobs.filter((j) => j.queue !== queue)
58
+ }
59
+
60
+ async fail(job: QueuedJob, error: Error): Promise<void> {
61
+ this.failedJobs.push({
62
+ id: job.id,
63
+ queue: job.queue,
64
+ payload: job.payload,
65
+ exception: error.message,
66
+ failedAt: Math.floor(Date.now() / 1000),
67
+ })
68
+ }
69
+
70
+ async getFailedJobs(): Promise<FailedJob[]> { return [...this.failedJobs] }
71
+ async findFailedJob(id: string | number): Promise<FailedJob | null> {
72
+ return this.failedJobs.find((j) => j.id === id) ?? null
73
+ }
74
+ async forgetFailedJob(id: string | number): Promise<boolean> {
75
+ const idx = this.failedJobs.findIndex((j) => j.id === id)
76
+ if (idx === -1) return false
77
+ this.failedJobs.splice(idx, 1)
78
+ return true
79
+ }
80
+ async flushFailedJobs(): Promise<void> { this.failedJobs = [] }
81
+
82
+ async createBatch(batch: BatchRecord): Promise<string> {
83
+ this.batches.set(batch.id, { ...batch })
84
+ return batch.id
85
+ }
86
+ async findBatch(id: string): Promise<BatchRecord | null> {
87
+ return this.batches.get(id) ?? null
88
+ }
89
+ async updateBatchProgress(id: string, processed: number, failed: number): Promise<BatchRecord | null> {
90
+ const b = this.batches.get(id)
91
+ if (!b) return null
92
+ b.processedJobs += processed
93
+ b.failedJobs += failed
94
+ return { ...b }
95
+ }
96
+ async markBatchFinished(id: string): Promise<void> {
97
+ const b = this.batches.get(id)
98
+ if (b) b.finishedAt = Math.floor(Date.now() / 1000)
99
+ }
100
+ async cancelBatch(id: string): Promise<void> {
101
+ const b = this.batches.get(id)
102
+ if (b) b.cancelledAt = Math.floor(Date.now() / 1000)
103
+ }
104
+ async pruneBatches(olderThanSeconds: number): Promise<void> {
105
+ const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds
106
+ for (const [id, b] of this.batches) {
107
+ if (b.createdAt < cutoff) this.batches.delete(id)
108
+ }
109
+ }
110
+
111
+ // ── Assertion methods ──────────────────────────────────────────
112
+
113
+ /** Get all pushed payloads matching a job class */
114
+ pushed(jobClass: Constructor<Job>): PushedJob[] {
115
+ return this.pushedJobs.filter((j) => j.payload.jobName === jobClass.name)
116
+ }
117
+
118
+ /** Assert a job was pushed, optionally checking exact count */
119
+ assertPushed(jobClass: Constructor<Job>, count?: number): void {
120
+ const matching = this.pushed(jobClass)
121
+ if (matching.length === 0) {
122
+ throw new Error(`Expected [${jobClass.name}] to be pushed, but it was not.`)
123
+ }
124
+ if (count !== undefined && matching.length !== count) {
125
+ throw new Error(
126
+ `Expected [${jobClass.name}] to be pushed ${count} time(s), but it was pushed ${matching.length} time(s).`,
127
+ )
128
+ }
129
+ }
130
+
131
+ /** Assert a job was pushed to a specific queue */
132
+ assertPushedOn(queue: string, jobClass: Constructor<Job>): void {
133
+ const matching = this.pushedJobs.filter(
134
+ (j) => j.payload.jobName === jobClass.name && j.queue === queue,
135
+ )
136
+ if (matching.length === 0) {
137
+ throw new Error(
138
+ `Expected [${jobClass.name}] to be pushed on queue [${queue}], but it was not.`,
139
+ )
140
+ }
141
+ }
142
+
143
+ /** Assert a job was NOT pushed */
144
+ assertNotPushed(jobClass: Constructor<Job>): void {
145
+ const matching = this.pushed(jobClass)
146
+ if (matching.length > 0) {
147
+ throw new Error(
148
+ `Unexpected [${jobClass.name}] was pushed ${matching.length} time(s).`,
149
+ )
150
+ }
151
+ }
152
+
153
+ /** Assert nothing was pushed at all */
154
+ assertNothingPushed(): void {
155
+ if (this.pushedJobs.length > 0) {
156
+ const names = [...new Set(this.pushedJobs.map((j) => j.payload.jobName))]
157
+ throw new Error(
158
+ `Expected no jobs to be pushed, but found: ${names.join(', ')}`,
159
+ )
160
+ }
161
+ }
162
+
163
+ /** Assert a chain was dispatched in the given order */
164
+ assertChained(jobClasses: Constructor<Job>[]): void {
165
+ if (jobClasses.length === 0) {
166
+ throw new Error('assertChained() requires at least one job class')
167
+ }
168
+
169
+ const firstName = jobClasses[0]!.name
170
+ const first = this.pushedJobs.find((j) => j.payload.jobName === firstName)
171
+ if (!first) {
172
+ throw new Error(`Expected chain starting with [${firstName}] to be dispatched, but it was not.`)
173
+ }
174
+
175
+ const chainedNames = (first.payload.chainedJobs ?? []).map((p) => p.jobName)
176
+ const expectedChained = jobClasses.slice(1).map((c) => c.name)
177
+
178
+ if (chainedNames.length !== expectedChained.length) {
179
+ throw new Error(
180
+ `Expected chain of [${jobClasses.map((c) => c.name).join(' → ')}] but got [${firstName} → ${chainedNames.join(' → ')}]`,
181
+ )
182
+ }
183
+
184
+ for (let i = 0; i < expectedChained.length; i++) {
185
+ if (chainedNames[i] !== expectedChained[i]) {
186
+ throw new Error(
187
+ `Expected chain of [${jobClasses.map((c) => c.name).join(' → ')}] but got [${firstName} → ${chainedNames.join(' → ')}]`,
188
+ )
189
+ }
190
+ }
191
+ }
192
+
193
+ /** Assert a batch was dispatched, optionally with a callback to inspect it */
194
+ assertBatched(callback?: (jobs: PushedJob[]) => void): void {
195
+ const batchedJobs = this.pushedJobs.filter((j) => j.payload.batchId)
196
+ if (batchedJobs.length === 0) {
197
+ throw new Error('Expected a batch to be dispatched, but none was.')
198
+ }
199
+ if (callback) callback(batchedJobs)
200
+ }
201
+
202
+ /** Reset all pushed jobs (for test isolation) */
203
+ reset(): void {
204
+ this.pushedJobs = []
205
+ this.failedJobs = []
206
+ this.batches.clear()
207
+ this.nextId = 1
208
+ }
209
+ }