@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.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @mantiq/queue
2
+
3
+ Job dispatching, chains, batches, and scheduling for MantiqJS — sync, SQLite, Redis, SQS, and Kafka drivers.
4
+
5
+ Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun add @mantiq/queue
11
+ ```
12
+
13
+ ## Documentation
14
+
15
+ See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
16
+
17
+ ## License
18
+
19
+ MIT
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@mantiq/queue",
3
+ "version": "0.0.1",
4
+ "description": "Job dispatching, workers, retry logic",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Abdullah Khan",
8
+ "homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/queue",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/abdullahkhan/mantiq.git",
12
+ "directory": "packages/queue"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/abdullahkhan/mantiq/issues"
16
+ },
17
+ "keywords": [
18
+ "mantiq",
19
+ "mantiqjs",
20
+ "bun",
21
+ "typescript",
22
+ "framework",
23
+ "queue"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.1.0"
27
+ },
28
+ "main": "./src/index.ts",
29
+ "types": "./src/index.ts",
30
+ "exports": {
31
+ ".": {
32
+ "bun": "./src/index.ts",
33
+ "default": "./src/index.ts"
34
+ }
35
+ },
36
+ "files": [
37
+ "src/",
38
+ "package.json",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun",
44
+ "test": "bun test",
45
+ "typecheck": "tsc --noEmit",
46
+ "clean": "rm -rf dist"
47
+ },
48
+ "dependencies": {
49
+ "@mantiq/core": "workspace:*",
50
+ "@mantiq/cli": "workspace:*"
51
+ },
52
+ "devDependencies": {
53
+ "bun-types": "latest",
54
+ "typescript": "^5.7.0"
55
+ },
56
+ "peerDependencies": {
57
+ "ioredis": ">=5.0.0",
58
+ "@aws-sdk/client-sqs": ">=3.0.0",
59
+ "kafkajs": ">=2.0.0"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "ioredis": {
63
+ "optional": true
64
+ },
65
+ "@aws-sdk/client-sqs": {
66
+ "optional": true
67
+ },
68
+ "kafkajs": {
69
+ "optional": true
70
+ }
71
+ }
72
+ }
package/src/Job.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type { SerializedPayload } from './contracts/JobContract.ts'
2
+
3
+ /**
4
+ * Keys that belong to the Job base class config, NOT user data.
5
+ * Used by serialize() to separate job config from user-defined properties.
6
+ */
7
+ const JOB_BASE_KEYS = new Set([
8
+ 'queue', 'connection', 'tries', 'backoff', 'timeout',
9
+ 'delay', 'attempts', 'jobId',
10
+ ])
11
+
12
+ /**
13
+ * Abstract base class for all queueable jobs.
14
+ *
15
+ * Subclasses define their own properties (the "data") and implement handle().
16
+ * The framework serializes user properties automatically for queue storage.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * export class ProcessPayment extends Job {
21
+ * override queue = 'payments'
22
+ * override tries = 5
23
+ * override backoff = 'exponential:30'
24
+ *
25
+ * constructor(public orderId: number, public amount: number) { super() }
26
+ *
27
+ * async handle(): Promise<void> {
28
+ * // process the payment...
29
+ * }
30
+ *
31
+ * override async failed(error: Error): Promise<void> {
32
+ * // notify admin of failure
33
+ * }
34
+ * }
35
+ * ```
36
+ */
37
+ export abstract class Job {
38
+ /** Queue name this job should be dispatched to */
39
+ queue = 'default'
40
+
41
+ /** Connection name (null = default connection) */
42
+ connection: string | null = null
43
+
44
+ /** Maximum number of attempts before permanent failure */
45
+ tries = 3
46
+
47
+ /**
48
+ * Backoff strategy between retries.
49
+ * - `'0'` — no delay
50
+ * - `'30'` — fixed 30s delay
51
+ * - `'30,60,120'` — custom delays per attempt
52
+ * - `'exponential:30'` — exponential backoff starting at 30s
53
+ */
54
+ backoff = '0'
55
+
56
+ /** Maximum execution time in seconds */
57
+ timeout = 60
58
+
59
+ /** Delay in seconds before the job becomes available (set by PendingDispatch) */
60
+ delay = 0
61
+
62
+ /** Current attempt number (set by Worker, starts at 0) */
63
+ attempts = 0
64
+
65
+ /** Queue driver's job ID (set after push) */
66
+ jobId: string | number | null = null
67
+
68
+ /** Execute the job logic */
69
+ abstract handle(): Promise<void>
70
+
71
+ /** Called when the job has permanently failed (optional) */
72
+ failed?(error: Error): Promise<void>
73
+
74
+ /**
75
+ * Serialize this job for queue storage.
76
+ * User-defined properties (anything not in JOB_BASE_KEYS) become the `data` object.
77
+ */
78
+ serialize(): SerializedPayload {
79
+ const data: Record<string, any> = {}
80
+ for (const key of Object.keys(this)) {
81
+ if (!JOB_BASE_KEYS.has(key)) {
82
+ data[key] = (this as any)[key]
83
+ }
84
+ }
85
+
86
+ return {
87
+ jobName: this.constructor.name,
88
+ data,
89
+ queue: this.queue,
90
+ connection: this.connection,
91
+ tries: this.tries,
92
+ backoff: this.backoff,
93
+ timeout: this.timeout,
94
+ delay: this.delay,
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Calculate the backoff delay for a given attempt number.
100
+ * @returns delay in seconds
101
+ */
102
+ getBackoffDelay(attempt: number): number {
103
+ const raw = this.backoff.trim()
104
+
105
+ if (raw === '0' || raw === '') return 0
106
+
107
+ // Exponential: 'exponential:30' → 30, 60, 120, 240, ...
108
+ if (raw.startsWith('exponential:')) {
109
+ const base = parseInt(raw.slice('exponential:'.length), 10) || 1
110
+ return base * Math.pow(2, attempt - 1)
111
+ }
112
+
113
+ // Comma-separated: '30,60,120'
114
+ if (raw.includes(',')) {
115
+ const parts = raw.split(',').map((s) => parseInt(s.trim(), 10))
116
+ return parts[Math.min(attempt - 1, parts.length - 1)] ?? 0
117
+ }
118
+
119
+ // Fixed: '30'
120
+ return parseInt(raw, 10) || 0
121
+ }
122
+ }
@@ -0,0 +1,188 @@
1
+ import type { Job } from './Job.ts'
2
+ import type { QueueManager } from './QueueManager.ts'
3
+ import type { BatchRecord, BatchOptions, SerializedPayload } from './contracts/JobContract.ts'
4
+
5
+ /** Resolve the QueueManager lazily */
6
+ let _resolveManager: (() => QueueManager) | null = null
7
+
8
+ export function setBatchResolver(resolver: () => QueueManager): void {
9
+ _resolveManager = resolver
10
+ }
11
+
12
+ /**
13
+ * Represents a dispatched batch — used to check progress and status.
14
+ */
15
+ export class Batch {
16
+ constructor(private record: BatchRecord, private manager: QueueManager) {}
17
+
18
+ get id(): string { return this.record.id }
19
+ get name(): string { return this.record.name }
20
+ get totalJobs(): number { return this.record.totalJobs }
21
+ get processedJobs(): number { return this.record.processedJobs }
22
+ get failedJobs(): number { return this.record.failedJobs }
23
+ get cancelled(): boolean { return this.record.cancelledAt !== null }
24
+ get createdAt(): number { return this.record.createdAt }
25
+ get finishedAt(): number | null { return this.record.finishedAt }
26
+
27
+ /** Progress as a percentage (0–100) */
28
+ progress(): number {
29
+ if (this.record.totalJobs === 0) return 100
30
+ return Math.round(
31
+ ((this.record.processedJobs + this.record.failedJobs) / this.record.totalJobs) * 100,
32
+ )
33
+ }
34
+
35
+ /** Whether all jobs have been processed (success or failure) */
36
+ finished(): boolean {
37
+ return this.record.finishedAt !== null
38
+ }
39
+
40
+ /** Whether any jobs in the batch have failed */
41
+ hasFailures(): boolean {
42
+ return this.record.failedJobs > 0
43
+ }
44
+
45
+ /** Cancel this batch — pending jobs with this batchId will be skipped by the Worker */
46
+ async cancel(): Promise<void> {
47
+ const driver = this.manager.driver(this.record.options.connection ?? undefined)
48
+ await driver.cancelBatch(this.record.id)
49
+ this.record.cancelledAt = Math.floor(Date.now() / 1000)
50
+ }
51
+
52
+ /** Refresh the batch status from the driver */
53
+ async fresh(): Promise<Batch> {
54
+ const driver = this.manager.driver(this.record.options.connection ?? undefined)
55
+ const updated = await driver.findBatch(this.record.id)
56
+ if (updated) this.record = updated
57
+ return this
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Fluent builder for dispatching a batch of jobs in parallel.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const batch = await Bus.batch([
67
+ * new ImportCsvChunk(file, 0, 1000),
68
+ * new ImportCsvChunk(file, 1000, 2000),
69
+ * new ImportCsvChunk(file, 2000, 3000),
70
+ * ])
71
+ * .then(new NotifyImportComplete(file))
72
+ * .catch(new NotifyImportFailed(file))
73
+ * .finally(new CleanupTempFiles(file))
74
+ * .name('csv-import')
75
+ * .onQueue('imports')
76
+ * .dispatch()
77
+ * ```
78
+ */
79
+ export class PendingBatch {
80
+ private jobs: Job[]
81
+ private thenJob: Job | null = null
82
+ private catchJob: Job | null = null
83
+ private finallyJob: Job | null = null
84
+ private _name = ''
85
+ private _queue = 'default'
86
+ private _connection: string | null = null
87
+ private _allowFailures = false
88
+
89
+ private constructor(jobs: Job[]) {
90
+ this.jobs = jobs
91
+ }
92
+
93
+ static of(jobs: Job[]): PendingBatch {
94
+ if (jobs.length === 0) {
95
+ throw new Error('PendingBatch.of() requires at least one job')
96
+ }
97
+ return new PendingBatch(jobs)
98
+ }
99
+
100
+ /** Job to dispatch when all jobs succeed (or if allowFailures is true) */
101
+ then(job: Job): this {
102
+ this.thenJob = job
103
+ return this
104
+ }
105
+
106
+ /** Job to dispatch when any job fails (and allowFailures is false) */
107
+ catch(job: Job): this {
108
+ this.catchJob = job
109
+ return this
110
+ }
111
+
112
+ /** Job to dispatch when the batch completes, regardless of success/failure */
113
+ finally(job: Job): this {
114
+ this.finallyJob = job
115
+ return this
116
+ }
117
+
118
+ /** Set a human-readable name for this batch */
119
+ name(name: string): this {
120
+ this._name = name
121
+ return this
122
+ }
123
+
124
+ /** Override the queue for all batch jobs */
125
+ onQueue(queue: string): this {
126
+ this._queue = queue
127
+ return this
128
+ }
129
+
130
+ /** Override the connection for all batch jobs */
131
+ onConnection(connection: string): this {
132
+ this._connection = connection
133
+ return this
134
+ }
135
+
136
+ /** Allow the batch to succeed even if some jobs fail */
137
+ allowFailures(): this {
138
+ this._allowFailures = true
139
+ return this
140
+ }
141
+
142
+ /** Dispatch all batch jobs and create the batch record */
143
+ async dispatch(): Promise<Batch> {
144
+ if (!_resolveManager) {
145
+ throw new Error('QueueManager not initialized. Call setBatchResolver() first.')
146
+ }
147
+
148
+ const manager = _resolveManager()
149
+ const driver = manager.driver(this._connection ?? undefined)
150
+
151
+ const batchId = crypto.randomUUID()
152
+
153
+ const options: BatchOptions = {
154
+ thenJob: this.thenJob?.serialize(),
155
+ catchJob: this.catchJob?.serialize(),
156
+ finallyJob: this.finallyJob?.serialize(),
157
+ allowFailures: this._allowFailures,
158
+ queue: this._queue,
159
+ connection: this._connection,
160
+ }
161
+
162
+ const record: BatchRecord = {
163
+ id: batchId,
164
+ name: this._name,
165
+ totalJobs: this.jobs.length,
166
+ processedJobs: 0,
167
+ failedJobs: 0,
168
+ failedJobIds: [],
169
+ options,
170
+ cancelledAt: null,
171
+ createdAt: Math.floor(Date.now() / 1000),
172
+ finishedAt: null,
173
+ }
174
+
175
+ await driver.createBatch(record)
176
+
177
+ // Push all jobs with the batchId attached
178
+ for (const job of this.jobs) {
179
+ const payload = job.serialize()
180
+ payload.batchId = batchId
181
+ payload.queue = this._queue
182
+ if (this._connection) payload.connection = this._connection
183
+ await driver.push(payload, this._queue, job.delay)
184
+ }
185
+
186
+ return new Batch(record, manager)
187
+ }
188
+ }
@@ -0,0 +1,119 @@
1
+ import type { Job } from './Job.ts'
2
+ import type { QueueManager } from './QueueManager.ts'
3
+ import type { SerializedPayload } from './contracts/JobContract.ts'
4
+
5
+ /** Resolve the QueueManager lazily */
6
+ let _resolveManager: (() => QueueManager) | null = null
7
+
8
+ export function setChainResolver(resolver: () => QueueManager): void {
9
+ _resolveManager = resolver
10
+ }
11
+
12
+ /**
13
+ * Sequential job execution — each job runs only after the previous one succeeds.
14
+ * If any job fails permanently, the chain stops and the optional catch handler runs.
15
+ *
16
+ * Chain state is stored in the serialized payload: `chainedJobs` and `chainCatchJob`.
17
+ * The Worker checks these after successful completion and dispatches the next job.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * await Chain.of([
22
+ * new ProcessPodcast(podcast),
23
+ * new OptimizeAudio(podcast),
24
+ * new PublishPodcast(podcast),
25
+ * ]).catch(new NotifyFailure(podcast)).dispatch()
26
+ * ```
27
+ */
28
+ export class Chain {
29
+ private jobs: Job[]
30
+ private catchJob: Job | null = null
31
+ private _queue: string | null = null
32
+ private _connection: string | null = null
33
+
34
+ private constructor(jobs: Job[]) {
35
+ this.jobs = jobs
36
+ }
37
+
38
+ /** Create a chain from an ordered list of jobs */
39
+ static of(jobs: Job[]): Chain {
40
+ if (jobs.length === 0) {
41
+ throw new Error('Chain.of() requires at least one job')
42
+ }
43
+ return new Chain(jobs)
44
+ }
45
+
46
+ /** Set a job to run if any job in the chain fails permanently */
47
+ catch(job: Job): this {
48
+ this.catchJob = job
49
+ return this
50
+ }
51
+
52
+ /** Override the queue name for all jobs in the chain */
53
+ onQueue(queue: string): this {
54
+ this._queue = queue
55
+ return this
56
+ }
57
+
58
+ /** Override the connection for all jobs in the chain */
59
+ onConnection(connection: string): this {
60
+ this._connection = connection
61
+ return this
62
+ }
63
+
64
+ /**
65
+ * Dispatch the chain.
66
+ * The first job is pushed to the queue with the remaining jobs serialized
67
+ * in its payload as `chainedJobs`. The Worker handles continuation.
68
+ */
69
+ async dispatch(): Promise<void> {
70
+ if (!_resolveManager) {
71
+ throw new Error('QueueManager not initialized. Call setChainResolver() first.')
72
+ }
73
+
74
+ const manager = _resolveManager()
75
+ const [first, ...rest] = this.jobs
76
+
77
+ // Serialize the first job
78
+ const payload = first!.serialize()
79
+
80
+ // Attach remaining jobs as chained payloads
81
+ if (rest.length > 0) {
82
+ payload.chainedJobs = rest.map((j) => {
83
+ const p = j.serialize()
84
+ if (this._queue) p.queue = this._queue
85
+ if (this._connection) p.connection = this._connection
86
+ return p
87
+ })
88
+ }
89
+
90
+ // Attach catch handler
91
+ if (this.catchJob) {
92
+ const catchPayload = this.catchJob.serialize()
93
+ if (this._queue) catchPayload.queue = this._queue
94
+ if (this._connection) catchPayload.connection = this._connection
95
+ payload.chainCatchJob = catchPayload
96
+ }
97
+
98
+ // Apply queue/connection overrides to first job
99
+ if (this._queue) payload.queue = this._queue
100
+ if (this._connection) payload.connection = this._connection
101
+
102
+ const queue = payload.queue
103
+ const connection = payload.connection
104
+ const driver = manager.driver(connection ?? undefined)
105
+
106
+ await driver.push(payload, queue, first!.delay)
107
+ }
108
+
109
+ /**
110
+ * Makes Chain thenable so `await Chain.of([...]).dispatch()` and
111
+ * `await Chain.of([...])` both work.
112
+ */
113
+ then<TResult1 = void, TResult2 = never>(
114
+ onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
115
+ onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
116
+ ): PromiseLike<TResult1 | TResult2> {
117
+ return this.dispatch().then(onfulfilled, onrejected)
118
+ }
119
+ }
@@ -0,0 +1,37 @@
1
+ import type { Job } from './Job.ts'
2
+ import type { Constructor } from './contracts/JobContract.ts'
3
+
4
+ /**
5
+ * Maps job class names to their constructors.
6
+ * Used by the Worker to reconstruct job instances from serialized payloads.
7
+ *
8
+ * Jobs must be registered before the worker starts processing.
9
+ */
10
+ const registry = new Map<string, Constructor<Job>>()
11
+
12
+ /** Register a job class so it can be deserialized by the worker */
13
+ export function registerJob(jobClass: Constructor<Job>): void {
14
+ registry.set(jobClass.name, jobClass)
15
+ }
16
+
17
+ /** Register multiple job classes at once */
18
+ export function registerJobs(jobClasses: Constructor<Job>[]): void {
19
+ for (const cls of jobClasses) {
20
+ registry.set(cls.name, cls)
21
+ }
22
+ }
23
+
24
+ /** Look up a job constructor by its class name */
25
+ export function resolveJob(name: string): Constructor<Job> | undefined {
26
+ return registry.get(name)
27
+ }
28
+
29
+ /** Get all registered job classes (for debugging/testing) */
30
+ export function getRegisteredJobs(): Map<string, Constructor<Job>> {
31
+ return new Map(registry)
32
+ }
33
+
34
+ /** Clear the registry (primarily for testing) */
35
+ export function clearJobRegistry(): void {
36
+ registry.clear()
37
+ }
@@ -0,0 +1,80 @@
1
+ import type { Job } from './Job.ts'
2
+ import type { QueueManager } from './QueueManager.ts'
3
+
4
+ /** Resolve the QueueManager lazily to avoid circular deps */
5
+ let _resolveManager: (() => QueueManager) | null = null
6
+
7
+ export function setPendingDispatchResolver(resolver: () => QueueManager): void {
8
+ _resolveManager = resolver
9
+ }
10
+
11
+ /**
12
+ * Fluent dispatch builder that is **thenable**.
13
+ *
14
+ * This allows `await dispatch(job).delay(60).onQueue('payments')` to work
15
+ * because PendingDispatch implements `then()` which triggers the actual push.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * // All of these work:
20
+ * await dispatch(new ProcessPayment(order))
21
+ * await dispatch(new ProcessPayment(order)).delay(60)
22
+ * await dispatch(new ProcessPayment(order)).onQueue('payments').delay(30)
23
+ * ```
24
+ */
25
+ export class PendingDispatch {
26
+ private _delay = 0
27
+ private _queue: string | null = null
28
+ private _connection: string | null = null
29
+
30
+ constructor(private readonly job: Job) {}
31
+
32
+ /** Set the delay in seconds before the job becomes available */
33
+ delay(seconds: number): this {
34
+ this._delay = seconds
35
+ return this
36
+ }
37
+
38
+ /** Override the queue name for this dispatch */
39
+ onQueue(queue: string): this {
40
+ this._queue = queue
41
+ return this
42
+ }
43
+
44
+ /** Override the connection name for this dispatch */
45
+ onConnection(connection: string): this {
46
+ this._connection = connection
47
+ return this
48
+ }
49
+
50
+ /** Push the job to the queue. Called automatically by then(). */
51
+ async send(): Promise<void> {
52
+ if (!_resolveManager) {
53
+ throw new Error('QueueManager not initialized. Call setPendingDispatchResolver() first.')
54
+ }
55
+
56
+ const manager = _resolveManager()
57
+ const connection = this._connection ?? this.job.connection
58
+ const driver = manager.driver(connection ?? undefined)
59
+
60
+ const payload = this.job.serialize()
61
+ if (this._queue) payload.queue = this._queue
62
+ if (this._connection) payload.connection = this._connection
63
+
64
+ const queue = this._queue ?? this.job.queue
65
+ const delay = this._delay || this.job.delay
66
+
67
+ await driver.push(payload, queue, delay)
68
+ }
69
+
70
+ /**
71
+ * Makes PendingDispatch thenable so `await dispatch(job)` works.
72
+ * This is the magic that lets the fluent API work with await.
73
+ */
74
+ then<TResult1 = void, TResult2 = never>(
75
+ onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
76
+ onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
77
+ ): PromiseLike<TResult1 | TResult2> {
78
+ return this.send().then(onfulfilled, onrejected)
79
+ }
80
+ }