@agendajs/postgres-backend 1.0.0-alpha.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/LICENSE.md ADDED
@@ -0,0 +1,25 @@
1
+ ## License
2
+ (The MIT License)
3
+
4
+ Copyright (c) 2013 Ryan Schmukler <ryan@slingingcode.com>
5
+
6
+ Copyright (c) 2022-2026 Simon Tretter <s.tretter@gmail.com>
7
+
8
+ Contributors: Mikael Korpela, Alexis Tyler, Vasyl Boroviak, Haris Sulaiman, Loris Guignard
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
11
+ this software and associated documentation files (the 'Software'), to deal in
12
+ the Software without restriction, including without limitation the rights to
13
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
14
+ the Software, and to permit persons to whom the Software is furnished to do so,
15
+ subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
22
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
23
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
24
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # @agendajs/postgres-backend
2
+
3
+ PostgreSQL backend for [Agenda](https://github.com/agenda/agenda) job scheduler with LISTEN/NOTIFY support for real-time job processing.
4
+
5
+ ## Features
6
+
7
+ - Full PostgreSQL storage backend for Agenda jobs
8
+ - Real-time job notifications using PostgreSQL LISTEN/NOTIFY
9
+ - Automatic schema creation with optimized indexes
10
+ - Connection pooling via `pg` library
11
+ - TypeScript support with full type definitions
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @agendajs/postgres-backend
17
+ # or
18
+ pnpm add @agendajs/postgres-backend
19
+ # or
20
+ yarn add @agendajs/postgres-backend
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Basic Usage
26
+
27
+ ```typescript
28
+ import { Agenda } from 'agenda';
29
+ import { PostgresBackend } from '@agendajs/postgres-backend';
30
+
31
+ // Create agenda with PostgreSQL backend
32
+ const agenda = new Agenda({
33
+ backend: new PostgresBackend({
34
+ connectionString: 'postgresql://user:pass@localhost:5432/mydb'
35
+ })
36
+ });
37
+
38
+ // Define jobs
39
+ agenda.define('send-email', async (job) => {
40
+ const { to, subject, body } = job.attrs.data;
41
+ await sendEmail(to, subject, body);
42
+ });
43
+
44
+ // Start processing
45
+ await agenda.start();
46
+
47
+ // Schedule jobs
48
+ await agenda.every('5 minutes', 'send-email', { to: 'user@example.com', subject: 'Hello', body: 'World' });
49
+ await agenda.schedule('in 10 minutes', 'send-email', { to: 'other@example.com', subject: 'Hi', body: 'There' });
50
+ ```
51
+
52
+ ### Configuration Options
53
+
54
+ ```typescript
55
+ import { PostgresBackend } from '@agendajs/postgres-backend';
56
+
57
+ const backend = new PostgresBackend({
58
+ // PostgreSQL connection string (required unless pool/poolConfig is provided)
59
+ connectionString: 'postgresql://user:pass@localhost:5432/mydb',
60
+
61
+ // Or use pool configuration (creates a new pool)
62
+ poolConfig: {
63
+ host: 'localhost',
64
+ port: 5432,
65
+ database: 'mydb',
66
+ user: 'user',
67
+ password: 'pass',
68
+ max: 20 // max pool size
69
+ },
70
+
71
+ // Table name for jobs (default: 'agenda_jobs')
72
+ tableName: 'agenda_jobs',
73
+
74
+ // Channel name for LISTEN/NOTIFY (default: 'agenda_jobs')
75
+ channelName: 'agenda_jobs',
76
+
77
+ // Name to identify this Agenda instance (stored as lastModifiedBy)
78
+ name: 'worker-1',
79
+
80
+ // Whether to create the table and indexes on connect (default: true)
81
+ ensureSchema: true,
82
+
83
+ // Sort order for job queries
84
+ sort: {
85
+ nextRunAt: 1, // 1 for ASC, -1 for DESC
86
+ priority: -1
87
+ }
88
+ });
89
+ ```
90
+
91
+ ### Using an Existing Pool
92
+
93
+ If your application already has a PostgreSQL connection pool, you can pass it directly. The pool will **not** be closed when Agenda disconnects:
94
+
95
+ ```typescript
96
+ import { Pool } from 'pg';
97
+ import { PostgresBackend } from '@agendajs/postgres-backend';
98
+
99
+ // Your app's existing pool
100
+ const pool = new Pool({ connectionString: 'postgresql://...' });
101
+
102
+ const backend = new PostgresBackend({ pool });
103
+
104
+ // After agenda.stop(), your pool is still usable
105
+ ```
106
+
107
+ ## How It Works
108
+
109
+ ### Storage
110
+
111
+ The backend stores jobs in a PostgreSQL table with the following structure:
112
+
113
+ - `id` - UUID primary key
114
+ - `name` - Job name
115
+ - `priority` - Job priority (higher = more urgent)
116
+ - `next_run_at` - When the job should run next
117
+ - `type` - Job type ('normal' or 'single')
118
+ - `locked_at` - Lock timestamp for distributed execution
119
+ - `data` - JSONB payload for job data
120
+ - And other metadata fields...
121
+
122
+ ### Real-Time Notifications
123
+
124
+ Unlike MongoDB, PostgreSQL has built-in pub/sub via LISTEN/NOTIFY. When a job is saved, a notification is published to all listening Agenda instances, triggering immediate job processing without waiting for the next poll interval.
125
+
126
+ This means:
127
+ - Lower latency for job execution
128
+ - Reduced database polling overhead
129
+ - Efficient cross-process coordination
130
+
131
+ ### Distributed Locking
132
+
133
+ The backend uses PostgreSQL's `FOR UPDATE SKIP LOCKED` to atomically lock jobs for processing, preventing duplicate execution across multiple Agenda instances.
134
+
135
+ ## Database Indexes
136
+
137
+ When `ensureSchema: true` (default), the following indexes are created:
138
+
139
+ ```sql
140
+ -- Main index for finding and locking next job
141
+ CREATE INDEX agenda_jobs_find_and_lock_idx
142
+ ON agenda_jobs (name, next_run_at, priority DESC, locked_at, disabled)
143
+ WHERE disabled = FALSE;
144
+
145
+ -- Index for single jobs (upsert operations)
146
+ CREATE UNIQUE INDEX agenda_jobs_single_job_idx
147
+ ON agenda_jobs (name) WHERE type = 'single';
148
+
149
+ -- Index for locked_at (stale lock detection)
150
+ CREATE INDEX agenda_jobs_locked_at_idx
151
+ ON agenda_jobs (locked_at) WHERE locked_at IS NOT NULL;
152
+
153
+ -- Index for next_run_at queries
154
+ CREATE INDEX agenda_jobs_next_run_at_idx
155
+ ON agenda_jobs (next_run_at) WHERE next_run_at IS NOT NULL;
156
+ ```
157
+
158
+ ## Manual Schema Management
159
+
160
+ If you prefer to manage the schema yourself (set `ensureSchema: false`):
161
+
162
+ ```typescript
163
+ import { getCreateTableSQL, getCreateIndexesSQL } from '@agendajs/postgres-backend';
164
+
165
+ const tableName = 'agenda_jobs';
166
+
167
+ // Create table
168
+ await pool.query(getCreateTableSQL(tableName));
169
+
170
+ // Create indexes
171
+ for (const sql of getCreateIndexesSQL(tableName)) {
172
+ await pool.query(sql);
173
+ }
174
+ ```
175
+
176
+ ## Testing
177
+
178
+ Tests require a PostgreSQL database. The easiest way is to use Docker:
179
+
180
+ ```bash
181
+ # Start PostgreSQL container and run tests
182
+ pnpm test:docker
183
+
184
+ # Or manually:
185
+ pnpm docker:up # Start PostgreSQL container
186
+ pnpm test:postgres # Run tests
187
+ pnpm docker:down # Stop container
188
+ ```
189
+
190
+ You can also use an existing PostgreSQL database:
191
+
192
+ ```bash
193
+ POSTGRES_TEST_URL=postgresql://user:pass@localhost:5432/agenda_test pnpm test
194
+ ```
195
+
196
+ ### Available Scripts
197
+
198
+ | Script | Description |
199
+ |--------|-------------|
200
+ | `pnpm test` | Run tests (requires PostgreSQL or Docker) |
201
+ | `pnpm test:postgres` | Run tests with local Docker PostgreSQL |
202
+ | `pnpm test:docker` | Start container, run tests, stop container |
203
+ | `pnpm docker:up` | Start PostgreSQL container |
204
+ | `pnpm docker:down` | Stop PostgreSQL container |
205
+ | `pnpm docker:logs` | View container logs |
206
+
207
+ ## Requirements
208
+
209
+ - Node.js >= 18.0.0
210
+ - PostgreSQL >= 12 (for `gen_random_uuid()` support)
211
+ - Agenda >= 6.0.0
212
+
213
+ ## License
214
+
215
+ MIT
@@ -0,0 +1,75 @@
1
+ import type { AgendaBackend, JobRepository, NotificationChannel } from 'agenda';
2
+ import type { PostgresBackendConfig } from './types.js';
3
+ /**
4
+ * PostgreSQL backend for Agenda
5
+ *
6
+ * Provides both storage (via PostgresJobRepository) and real-time notifications
7
+ * (via PostgresNotificationChannel using LISTEN/NOTIFY).
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { Agenda } from 'agenda';
12
+ * import { PostgresBackend } from '@agendajs/postgres-backend';
13
+ *
14
+ * // Using connection string
15
+ * const agenda = new Agenda({
16
+ * backend: new PostgresBackend({
17
+ * connectionString: 'postgresql://user:pass@localhost:5432/mydb'
18
+ * })
19
+ * });
20
+ *
21
+ * // Or using an existing pool (e.g., shared with your app)
22
+ * // The pool will NOT be closed when Agenda disconnects
23
+ * import { Pool } from 'pg';
24
+ * const pool = new Pool({ connectionString: '...' });
25
+ *
26
+ * const agenda = new Agenda({
27
+ * backend: new PostgresBackend({ pool })
28
+ * });
29
+ *
30
+ * // Your app can continue using the pool after agenda.stop()
31
+ *
32
+ * agenda.define('myJob', async (job) => {
33
+ * console.log('Running job:', job.attrs.name);
34
+ * });
35
+ *
36
+ * await agenda.start();
37
+ * await agenda.every('5 minutes', 'myJob');
38
+ * ```
39
+ */
40
+ export declare class PostgresBackend implements AgendaBackend {
41
+ private _repository;
42
+ private _notificationChannel?;
43
+ private config;
44
+ private _ownsConnection;
45
+ constructor(config: PostgresBackendConfig);
46
+ /**
47
+ * The job repository for storage operations
48
+ */
49
+ get repository(): JobRepository;
50
+ /**
51
+ * Whether this backend owns its database connection.
52
+ * True if created from connectionString/poolConfig, false if pool was passed in.
53
+ */
54
+ get ownsConnection(): boolean;
55
+ /**
56
+ * The notification channel for real-time notifications via LISTEN/NOTIFY
57
+ * Returns undefined if notifications are disabled
58
+ */
59
+ get notificationChannel(): NotificationChannel | undefined;
60
+ /**
61
+ * Connect to PostgreSQL
62
+ *
63
+ * - Establishes database connection pool
64
+ * - Creates table and indexes if ensureSchema is true
65
+ * - Sets up LISTEN for real-time notifications
66
+ */
67
+ connect(): Promise<void>;
68
+ /**
69
+ * Disconnect from PostgreSQL
70
+ *
71
+ * - Stops LISTEN on notification channel
72
+ * - Closes database connection pool
73
+ */
74
+ disconnect(): Promise<void>;
75
+ }
@@ -0,0 +1,123 @@
1
+ import debug from 'debug';
2
+ import { PostgresJobRepository } from './PostgresJobRepository.js';
3
+ import { PostgresNotificationChannel } from './PostgresNotificationChannel.js';
4
+ const log = debug('agenda:postgres:backend');
5
+ /**
6
+ * PostgreSQL backend for Agenda
7
+ *
8
+ * Provides both storage (via PostgresJobRepository) and real-time notifications
9
+ * (via PostgresNotificationChannel using LISTEN/NOTIFY).
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { Agenda } from 'agenda';
14
+ * import { PostgresBackend } from '@agendajs/postgres-backend';
15
+ *
16
+ * // Using connection string
17
+ * const agenda = new Agenda({
18
+ * backend: new PostgresBackend({
19
+ * connectionString: 'postgresql://user:pass@localhost:5432/mydb'
20
+ * })
21
+ * });
22
+ *
23
+ * // Or using an existing pool (e.g., shared with your app)
24
+ * // The pool will NOT be closed when Agenda disconnects
25
+ * import { Pool } from 'pg';
26
+ * const pool = new Pool({ connectionString: '...' });
27
+ *
28
+ * const agenda = new Agenda({
29
+ * backend: new PostgresBackend({ pool })
30
+ * });
31
+ *
32
+ * // Your app can continue using the pool after agenda.stop()
33
+ *
34
+ * agenda.define('myJob', async (job) => {
35
+ * console.log('Running job:', job.attrs.name);
36
+ * });
37
+ *
38
+ * await agenda.start();
39
+ * await agenda.every('5 minutes', 'myJob');
40
+ * ```
41
+ */
42
+ export class PostgresBackend {
43
+ _repository;
44
+ _notificationChannel;
45
+ config;
46
+ _ownsConnection;
47
+ constructor(config) {
48
+ this.config = config;
49
+ // Determine if we own the connection (not passed in by user)
50
+ this._ownsConnection = !config.pool;
51
+ // Create repository
52
+ this._repository = new PostgresJobRepository(config);
53
+ // Create notification channel (unless disabled)
54
+ if (!config.disableNotifications) {
55
+ this._notificationChannel = new PostgresNotificationChannel({
56
+ channelName: config.channelName,
57
+ reconnect: {
58
+ enabled: true
59
+ }
60
+ });
61
+ }
62
+ log('PostgresBackend created with config: %O', {
63
+ tableName: config.tableName || 'agenda_jobs',
64
+ channelName: config.channelName || 'agenda_jobs',
65
+ ensureSchema: config.ensureSchema ?? true,
66
+ disableNotifications: config.disableNotifications ?? false,
67
+ ownsConnection: this._ownsConnection
68
+ });
69
+ }
70
+ /**
71
+ * The job repository for storage operations
72
+ */
73
+ get repository() {
74
+ return this._repository;
75
+ }
76
+ /**
77
+ * Whether this backend owns its database connection.
78
+ * True if created from connectionString/poolConfig, false if pool was passed in.
79
+ */
80
+ get ownsConnection() {
81
+ return this._ownsConnection;
82
+ }
83
+ /**
84
+ * The notification channel for real-time notifications via LISTEN/NOTIFY
85
+ * Returns undefined if notifications are disabled
86
+ */
87
+ get notificationChannel() {
88
+ return this._notificationChannel;
89
+ }
90
+ /**
91
+ * Connect to PostgreSQL
92
+ *
93
+ * - Establishes database connection pool
94
+ * - Creates table and indexes if ensureSchema is true
95
+ * - Sets up LISTEN for real-time notifications
96
+ */
97
+ async connect() {
98
+ log('connecting PostgresBackend');
99
+ // Connect repository first (creates pool and schema)
100
+ await this._repository.connect();
101
+ // Share the pool with notification channel (if enabled)
102
+ if (this._notificationChannel) {
103
+ this._notificationChannel.setPool(this._repository.getPool());
104
+ }
105
+ // Note: notification channel is connected by Agenda.start()
106
+ // when it's needed, so we don't connect it here
107
+ log('PostgresBackend connected');
108
+ }
109
+ /**
110
+ * Disconnect from PostgreSQL
111
+ *
112
+ * - Stops LISTEN on notification channel
113
+ * - Closes database connection pool
114
+ */
115
+ async disconnect() {
116
+ log('disconnecting PostgresBackend');
117
+ // Notification channel is disconnected by Agenda.stop()
118
+ // Repository disconnect will close the shared pool
119
+ await this._repository.disconnect();
120
+ log('PostgresBackend disconnected');
121
+ }
122
+ }
123
+ //# sourceMappingURL=PostgresBackend.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PostgresBackend.js","sourceRoot":"","sources":["../src/PostgresBackend.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACnE,OAAO,EAAE,2BAA2B,EAAE,MAAM,kCAAkC,CAAC;AAG/E,MAAM,GAAG,GAAG,KAAK,CAAC,yBAAyB,CAAC,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,MAAM,OAAO,eAAe;IACnB,WAAW,CAAwB;IACnC,oBAAoB,CAA+B;IACnD,MAAM,CAAwB;IAC9B,eAAe,CAAU;IAEjC,YAAY,MAA6B;QACxC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QAErB,6DAA6D;QAC7D,IAAI,CAAC,eAAe,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;QAEpC,oBAAoB;QACpB,IAAI,CAAC,WAAW,GAAG,IAAI,qBAAqB,CAAC,MAAM,CAAC,CAAC;QAErD,gDAAgD;QAChD,IAAI,CAAC,MAAM,CAAC,oBAAoB,EAAE,CAAC;YAClC,IAAI,CAAC,oBAAoB,GAAG,IAAI,2BAA2B,CAAC;gBAC3D,WAAW,EAAE,MAAM,CAAC,WAAW;gBAC/B,SAAS,EAAE;oBACV,OAAO,EAAE,IAAI;iBACb;aACD,CAAC,CAAC;QACJ,CAAC;QAED,GAAG,CAAC,yCAAyC,EAAE;YAC9C,SAAS,EAAE,MAAM,CAAC,SAAS,IAAI,aAAa;YAC5C,WAAW,EAAE,MAAM,CAAC,WAAW,IAAI,aAAa;YAChD,YAAY,EAAE,MAAM,CAAC,YAAY,IAAI,IAAI;YACzC,oBAAoB,EAAE,MAAM,CAAC,oBAAoB,IAAI,KAAK;YAC1D,cAAc,EAAE,IAAI,CAAC,eAAe;SACpC,CAAC,CAAC;IACJ,CAAC;IAED;;OAEG;IACH,IAAI,UAAU;QACb,OAAO,IAAI,CAAC,WAAW,CAAC;IACzB,CAAC;IAED;;;OAGG;IACH,IAAI,cAAc;QACjB,OAAO,IAAI,CAAC,eAAe,CAAC;IAC7B,CAAC;IAED;;;OAGG;IACH,IAAI,mBAAmB;QACtB,OAAO,IAAI,CAAC,oBAAoB,CAAC;IAClC,CAAC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,OAAO;QACZ,GAAG,CAAC,4BAA4B,CAAC,CAAC;QAElC,qDAAqD;QACrD,MAAM,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC;QAEjC,wDAAwD;QACxD,IAAI,IAAI,CAAC,oBAAoB,EAAE,CAAC;YAC/B,IAAI,CAAC,oBAAoB,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,CAAC;QAC/D,CAAC;QAED,4DAA4D;QAC5D,gDAAgD;QAEhD,GAAG,CAAC,2BAA2B,CAAC,CAAC;IAClC,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,UAAU;QACf,GAAG,CAAC,+BAA+B,CAAC,CAAC;QAErC,wDAAwD;QACxD,mDAAmD;QACnD,MAAM,IAAI,CAAC,WAAW,CAAC,UAAU,EAAE,CAAC;QAEpC,GAAG,CAAC,8BAA8B,CAAC,CAAC;IACrC,CAAC;CACD"}
@@ -0,0 +1,53 @@
1
+ import { Pool } from 'pg';
2
+ import type { JobRepository, JobRepositoryOptions, RemoveJobsOptions, JobParameters, JobId, JobsQueryOptions, JobsResult, JobsOverview } from 'agenda';
3
+ import type { PostgresBackendConfig } from './types.js';
4
+ /**
5
+ * PostgreSQL implementation of JobRepository
6
+ */
7
+ export declare class PostgresJobRepository implements JobRepository {
8
+ private config;
9
+ private pool;
10
+ private ownPool;
11
+ private tableName;
12
+ private ensureSchema;
13
+ private sort;
14
+ private disconnected;
15
+ constructor(config: PostgresBackendConfig);
16
+ private createPool;
17
+ /**
18
+ * Get the underlying pool (for use by notification channel)
19
+ */
20
+ getPool(): Pool;
21
+ connect(): Promise<void>;
22
+ private createSchema;
23
+ disconnect(): Promise<void>;
24
+ /**
25
+ * Force close the pool - should only be called on application shutdown
26
+ * or when you're sure the backend won't be reused
27
+ */
28
+ close(): Promise<void>;
29
+ /**
30
+ * Convert PostgreSQL row to JobParameters
31
+ */
32
+ private rowToJob;
33
+ /**
34
+ * Convert SortDirection to SQL ASC/DESC
35
+ */
36
+ private toSqlDirection;
37
+ /**
38
+ * Convert sort options to PostgreSQL ORDER BY clause
39
+ */
40
+ private toOrderByClause;
41
+ getJobById(id: string): Promise<JobParameters | null>;
42
+ queryJobs(options?: JobsQueryOptions): Promise<JobsResult>;
43
+ getJobsOverview(): Promise<JobsOverview[]>;
44
+ getDistinctJobNames(): Promise<string[]>;
45
+ getQueueSize(): Promise<number>;
46
+ removeJobs(options: RemoveJobsOptions): Promise<number>;
47
+ unlockJob(job: JobParameters): Promise<void>;
48
+ unlockJobs(jobIds: (JobId | string)[]): Promise<void>;
49
+ lockJob(job: JobParameters, options: JobRepositoryOptions | undefined): Promise<JobParameters | undefined>;
50
+ getNextJobToRun(jobName: string, nextScanAt: Date, lockDeadline: Date, now: Date | undefined, options: JobRepositoryOptions | undefined): Promise<JobParameters | undefined>;
51
+ saveJobState(job: JobParameters, options: JobRepositoryOptions | undefined): Promise<void>;
52
+ saveJob<DATA = unknown>(job: JobParameters<DATA>, options: JobRepositoryOptions | undefined): Promise<JobParameters<DATA>>;
53
+ }