@pikku/queue-pg-boss 0.7.0

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/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@pikku/queue-pg-boss",
3
+ "version": "0.7.0",
4
+ "author": "yasser.fadl@gmail.com",
5
+ "license": "MIT",
6
+ "module": "dist/index.js",
7
+ "main": "dist/index.js",
8
+ "type": "module",
9
+ "scripts": {
10
+ "tsc": "tsc",
11
+ "ncu": "npx npm-check-updates",
12
+ "build": "tsc -b",
13
+ "release": "npm run build && npm test"
14
+ },
15
+ "peerDependencies": {
16
+ "@pikku/core": "^0.7.0"
17
+ },
18
+ "dependencies": {
19
+ "pg-boss": "^10.3.2"
20
+ },
21
+ "devDependencies": {
22
+ "typescript": "^5.6"
23
+ }
24
+ }
package/run-tests.sh ADDED
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+ cd "$(dirname "$0")"
3
+ node --loader tsx --test --experimental-test-coverage src/**/*.test.ts "$@"
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @module @pikku/queue-pg-boss
3
+ *
4
+ * PostgreSQL queue service for Pikku using pg-boss
5
+ * Provides job results, retries, priorities, and PostgreSQL-based queuing
6
+ */
7
+
8
+ export { PgBossQueueService } from './pg-boss-queue-service.js'
9
+ export { PgBossQueueWorkers } from './pg-boss-queue-worker.js'
10
+
11
+ // Re-export core queue types for convenience
12
+ export type {
13
+ QueueService,
14
+ PikkuWorkerConfig,
15
+ PikkuJobConfig,
16
+ QueueJob,
17
+ JobOptions,
18
+ } from '@pikku/core/queue'
@@ -0,0 +1,88 @@
1
+ import PgBoss from 'pg-boss'
2
+ import type { QueueService, QueueJob, JobOptions } from '@pikku/core/queue'
3
+ import { mapPgBossJobToQueueJob } from './utils.js'
4
+
5
+ export const mapPikkuJobToPgBoss = (
6
+ options?: JobOptions
7
+ ): PgBoss.JobOptions => {
8
+ const pgBossOptions: PgBoss.JobOptions = {}
9
+
10
+ if (options?.priority !== undefined) {
11
+ pgBossOptions.priority = options.priority
12
+ }
13
+
14
+ if (options?.delay !== undefined) {
15
+ pgBossOptions.startAfter = new Date(Date.now() + options.delay)
16
+ }
17
+
18
+ if (options?.jobId !== undefined) {
19
+ pgBossOptions.singletonKey = options.jobId
20
+ }
21
+
22
+ return pgBossOptions
23
+ }
24
+
25
+ /**
26
+ * pg-boss/PostgreSQL queue service implementation
27
+ * Supports job results, retries, and PostgreSQL-based queuing
28
+ */
29
+ export class PgBossQueueService implements QueueService {
30
+ readonly name = 'pg-boss'
31
+ readonly supportsResults = true
32
+ protected pgBoss: PgBoss
33
+
34
+ constructor(optionsOrUrl: PgBoss.ConstructorOptions | string) {
35
+ if (typeof optionsOrUrl === 'string') {
36
+ // If a string is provided, treat it as the connection URL
37
+ optionsOrUrl = { connectionString: optionsOrUrl }
38
+ }
39
+ this.pgBoss = new PgBoss(optionsOrUrl)
40
+ }
41
+
42
+ /**
43
+ * Initialize the pg-boss instance
44
+ */
45
+ async init(): Promise<void> {
46
+ await this.pgBoss.start()
47
+ }
48
+
49
+ /**
50
+ * Add a job to the queue
51
+ */
52
+ public async add<In>(
53
+ queueName: string,
54
+ data: In,
55
+ options?: JobOptions
56
+ ): Promise<string> {
57
+ const jobId = await this.pgBoss.send(
58
+ queueName,
59
+ data as any,
60
+ mapPikkuJobToPgBoss(options)
61
+ )
62
+ if (!jobId) {
63
+ throw new Error('Failed to create job')
64
+ }
65
+ return jobId
66
+ }
67
+
68
+ /**
69
+ * Get job by ID
70
+ */
71
+ async getJob<T, R>(
72
+ queueName: string,
73
+ jobId: string
74
+ ): Promise<QueueJob<T, R> | null> {
75
+ const job = await this.pgBoss.getJobById<T>(queueName, jobId)
76
+ if (!job) {
77
+ return null
78
+ }
79
+ return mapPgBossJobToQueueJob<T, R>(job, this.pgBoss)
80
+ }
81
+
82
+ /**
83
+ * Close the pg-boss connection
84
+ */
85
+ async close(): Promise<void> {
86
+ await this.pgBoss.stop()
87
+ }
88
+ }
@@ -0,0 +1,186 @@
1
+ import PgBoss from 'pg-boss'
2
+ import type {
3
+ QueueWorkers,
4
+ PikkuWorkerConfig,
5
+ QueueConfigMapping,
6
+ ConfigValidationResult,
7
+ } from '@pikku/core/queue'
8
+ import { runQueueJob, registerqueueWorkers } from '@pikku/core/queue'
9
+ import {
10
+ CoreServices,
11
+ CoreSingletonServices,
12
+ CreateSessionServices,
13
+ } from '@pikku/core'
14
+ import { mapPgBossJobToQueueJob } from './utils.js'
15
+
16
+ export const mapPikkuWorkerToPgBoss = (
17
+ workerConfig?: PikkuWorkerConfig
18
+ ): PgBoss.WorkOptions => {
19
+ const workerOptions: PgBoss.WorkOptions = {}
20
+
21
+ if (workerConfig?.batchSize !== undefined) {
22
+ workerOptions.batchSize = workerConfig.batchSize
23
+ }
24
+
25
+ if (workerConfig?.pollInterval !== undefined) {
26
+ workerOptions.pollingIntervalSeconds = Math.round(
27
+ workerConfig.pollInterval / 1000
28
+ )
29
+ }
30
+
31
+ return workerOptions
32
+ }
33
+
34
+ /**
35
+ * pg-boss/PostgreSQL queue worker implementation
36
+ * Supports job results, retries, and PostgreSQL-based queuing
37
+ */
38
+ export class PgBossQueueWorkers implements QueueWorkers {
39
+ readonly name = 'pg-boss'
40
+ readonly supportsResults = true
41
+
42
+ /**
43
+ * Configuration mapping rules for pg-boss
44
+ * This defines how Pikku worker configs map to pg-boss configurations
45
+ */
46
+ readonly configMappings: QueueConfigMapping = {
47
+ // Configurations that are directly supported
48
+ supported: {
49
+ batchSize: {
50
+ queueProperty: 'batchSize',
51
+ description: 'Number of jobs to process in a single batch',
52
+ },
53
+ pollInterval: {
54
+ queueProperty: 'pollingIntervalSeconds',
55
+ transform: (value: number) => Math.round(value / 1000),
56
+ description:
57
+ 'How often to poll for new jobs (converted from ms to seconds)',
58
+ },
59
+ },
60
+
61
+ // Configurations that are not supported but have reasonable explanations
62
+ unsupported: {
63
+ name: {
64
+ reason: 'Worker names are not supported in pg-boss',
65
+ explanation:
66
+ 'pg-boss identifies workers by their queue name and process, not custom names',
67
+ },
68
+ autorun: {
69
+ reason: 'Autorun is not configurable in pg-boss (always enabled)',
70
+ explanation:
71
+ 'pg-boss workers automatically start processing when created',
72
+ },
73
+ lockDuration: {
74
+ reason: 'Lock duration is managed differently in pg-boss',
75
+ explanation:
76
+ 'pg-boss uses job-level expiration instead of worker-level locks',
77
+ },
78
+ drainDelay: {
79
+ reason: 'Drain delay is not configurable in pg-boss',
80
+ explanation: 'pg-boss handles graceful shutdown internally',
81
+ },
82
+ maxStalledCount: {
83
+ reason: 'Max stalled count is not configurable in pg-boss',
84
+ explanation:
85
+ 'pg-boss handles stalled jobs through its built-in retry mechanism',
86
+ },
87
+ prefetch: {
88
+ reason: 'Prefetch is managed internally by pg-boss',
89
+ explanation:
90
+ 'pg-boss optimizes job fetching automatically based on batch size',
91
+ },
92
+ visibilityTimeout: {
93
+ reason: "Visibility timeout concept doesn't apply to pg-boss",
94
+ explanation:
95
+ 'pg-boss uses PostgreSQL locks and job expiration instead of visibility timeout',
96
+ },
97
+ },
98
+
99
+ // Configurations that have partial support or workarounds
100
+ fallbacks: {
101
+ removeOnComplete: {
102
+ reason: 'Job retention is managed through pg-boss archival system',
103
+ explanation:
104
+ 'pg-boss automatically archives completed jobs based on database settings',
105
+ fallbackValue: 'Managed by pg-boss database archival',
106
+ },
107
+ removeOnFail: {
108
+ reason:
109
+ 'Failed job retention is managed through pg-boss archival system',
110
+ explanation:
111
+ 'pg-boss automatically archives failed jobs based on database settings',
112
+ fallbackValue: 'Managed by pg-boss database archival',
113
+ },
114
+ },
115
+ }
116
+
117
+ private pgBoss: PgBoss
118
+ private activeWorkers = new Map<string, string>()
119
+
120
+ constructor(
121
+ options: PgBoss.ConstructorOptions | string,
122
+ private singletonServices: CoreSingletonServices,
123
+ private createSessionServices?: CreateSessionServices<
124
+ CoreSingletonServices,
125
+ CoreServices,
126
+ any
127
+ >
128
+ ) {
129
+ if (typeof options === 'string') {
130
+ // If a string is provided, treat it as the connection string
131
+ options = { connectionString: options }
132
+ }
133
+ this.pgBoss = new PgBoss(options)
134
+ }
135
+
136
+ /**
137
+ * Initialize pg-boss
138
+ */
139
+ async init(): Promise<void> {
140
+ await this.pgBoss.start()
141
+ }
142
+
143
+ /**
144
+ * Scan state and register all compatible processors
145
+ */
146
+ async registerQueues(): Promise<Record<string, ConfigValidationResult[]>> {
147
+ return await registerqueueWorkers(
148
+ this.configMappings,
149
+ this.singletonServices.logger,
150
+ async (queueName, processor) => {
151
+ await this.pgBoss.createQueue(queueName)
152
+ const workerId = await this.pgBoss.work<any>(
153
+ processor.queueName,
154
+ {
155
+ ...mapPikkuWorkerToPgBoss(processor.config),
156
+ includeMetadata: true,
157
+ },
158
+ async ([job]) => {
159
+ if (!this.singletonServices) {
160
+ throw new Error('Singleton services not available')
161
+ }
162
+ if (!job) {
163
+ this.singletonServices.logger.warn(
164
+ `No job received for queue ${queueName}`
165
+ )
166
+ return
167
+ }
168
+ await runQueueJob({
169
+ singletonServices: this.singletonServices,
170
+ createSessionServices: this.createSessionServices,
171
+ job: mapPgBossJobToQueueJob(job, this.pgBoss),
172
+ })
173
+ }
174
+ )
175
+ this.activeWorkers.set(queueName, workerId)
176
+ }
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Close all workers and connections
182
+ */
183
+ async close(): Promise<void> {
184
+ await this.pgBoss.stop()
185
+ }
186
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { QueueJob, QueueJobStatus } from '@pikku/core/queue'
2
+ import PgBoss from 'pg-boss'
3
+
4
+ /**
5
+ * Map pg-boss job state to our queue job status
6
+ */
7
+ const mapPgBossStateToStatus = (state: string): QueueJobStatus => {
8
+ switch (state) {
9
+ case 'created':
10
+ case 'retry':
11
+ return 'waiting'
12
+ case 'active':
13
+ return 'active'
14
+ case 'completed':
15
+ return 'completed'
16
+ case 'failed':
17
+ return 'failed'
18
+ case 'cancelled':
19
+ return 'failed'
20
+ default:
21
+ return 'waiting'
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Map pg-boss job to QueueJob interface
27
+ */
28
+ export const mapPgBossJobToQueueJob = <In, Out>(
29
+ pgBossJob: PgBoss.JobWithMetadata<In>,
30
+ pgBossInstance: PgBoss
31
+ ): QueueJob<In, Out> => {
32
+ return {
33
+ queueName: pgBossJob.name,
34
+ id: pgBossJob.id || '',
35
+ data: pgBossJob.data,
36
+ status: async () => {
37
+ const jobWithState = await pgBossInstance.getJobById(
38
+ pgBossJob.name,
39
+ pgBossJob.id
40
+ )
41
+ return mapPgBossStateToStatus(jobWithState?.state || 'created')
42
+ },
43
+ waitForCompletion: async (ttl?: number): Promise<Out> => {
44
+ // pg-boss doesn't have a built-in waitForCompletion, so we'll poll
45
+ const startTime = Date.now()
46
+ const timeout = ttl || 30000 // 30 seconds default
47
+
48
+ while (Date.now() - startTime < timeout) {
49
+ const job = await pgBossInstance.getJobById(
50
+ pgBossJob.name,
51
+ pgBossJob.id
52
+ )
53
+ if (job?.state === 'completed') {
54
+ return job.output as Out
55
+ }
56
+ if (job?.state === 'failed' || job?.state === 'cancelled') {
57
+ throw new Error(`Job failed: ${JSON.stringify(job.output)}`)
58
+ }
59
+ // Wait 100ms before polling again
60
+ await new Promise((resolve) => setTimeout(resolve, 100))
61
+ }
62
+
63
+ throw new Error('Job did not complete within timeout')
64
+ },
65
+ metadata: () => {
66
+ return {
67
+ progress: 0, // pg-boss doesn't have built-in progress tracking
68
+ attemptsMade: pgBossJob.retryCount || 0,
69
+ maxAttempts: pgBossJob.retryLimit || 1,
70
+ createdAt: pgBossJob.createdOn
71
+ ? new Date(pgBossJob.createdOn)
72
+ : new Date(),
73
+ processedAt: pgBossJob.startedOn
74
+ ? new Date(pgBossJob.startedOn)
75
+ : undefined,
76
+ completedAt: pgBossJob.completedOn
77
+ ? new Date(pgBossJob.completedOn)
78
+ : undefined,
79
+ failedAt:
80
+ pgBossJob.state === 'failed' && pgBossJob.completedOn
81
+ ? new Date(pgBossJob.completedOn)
82
+ : undefined,
83
+ error: (pgBossJob.data as any).error,
84
+ }
85
+ },
86
+ }
87
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "module": "Node16",
6
+ "outDir": "dist",
7
+ "target": "esnext",
8
+ "declaration": true
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["**/*.test.ts", "node_modules"],
12
+ "references": [
13
+ {
14
+ "path": "../../core/tsconfig.json"
15
+ }
16
+ ]
17
+ }