.
586 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | @imqueue/pg-pubsub
3 |
4 |
5 |
6 |
7 |
27 |
28 |
29 | Reliable PostgreSQL LISTEN/NOTIFY with inter-process lock support
30 |
31 |
32 |
33 |

34 |
35 |
36 |
37 | ## What Is This?
38 |
39 | This library provides a clean way to use PostgreSQL
40 | [LISTEN](https://www.postgresql.org/docs/current/sql-listen.html) and
41 | [NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html) commands
42 | for its asynchronous mechanism implementation. It comes as a top-level wrapper
43 | over [node-postgres](https://www.npmjs.com/package/pg) and provides better,
44 | cleaner way to work with database notifications engine.
45 |
46 | To make it clear - it solves several major problems you will fall into if
47 | you're going to use LISTEN/NOTIFY in your node app:
48 |
49 | 1. **Reliable connections**. This library comes with handy reconnect support
50 | out-of-the box, so all you need, is, probably to tune several settings if
51 | you have special needs, like max retry limit or reconnection delay.
52 | 2. It provides **clean way working with channels**, so you may subscribe to
53 | an exactly required channel with no need to do additional filtering
54 | implementation on messages receive. BTW, it does not hide from you
55 | possibility to manage all messages in a single handler. You just choose
56 | what you need.
57 | 3. The most important feature here is that this library comes with the first-class
58 | implementation of **inter-process locking mechanism**, allowing avoiding
59 | data duplication receive problem in scalable distributed architectures. It
60 | means it allows you to define single-listener process across many similar
61 | processes (which happens on scales) which would receive notifications
62 | and with a guarantee that if it looses connection or dies - another similar
63 | process replaces it as listener.
64 | 4. It comes with support of **graceful shutdown**, so you may don't care about
65 | this.
66 |
67 | ## Install
68 |
69 | As easy as:
70 |
71 | ~~~bash
72 | npm i --save @imqueue/pg-pubsub
73 | ~~~
74 |
75 | ## Usage & API
76 |
77 | ### Environment
78 |
79 | It supports passing environment variables to configure locker schema name to use
80 | and shutdown timeout.
81 |
82 | - **`PG_PUBSUB_SCHEMA_NAME`** - string, by default is `'pgip_lock'`
83 | - **`PG_PUBSUB_SHUTDOWN_TIMEOUT`** - number, by default is 1000,
84 | in milliseconds
85 |
86 | ### Importing, instantiation and connecting
87 |
88 | ~~~typescript
89 | import { PgPubSub } from '@imqueue/pg-pubsub';
90 |
91 | const connectionString = 'postgres://user:pass@localhost:5432/dbname';
92 | const pubSub = new PgPubSub({ connectionString, singleListener: false });
93 |
94 | (async () => {
95 | await pubSub.connect();
96 | })();
97 | ~~~
98 |
99 | With such instantiation options natural behavior of PgPubSub will be as follows:
100 |
101 | []()
102 |
103 | [See all options](https://github.com/imqueue/pg-pubsub/wiki/PgPubSubOptions).
104 |
105 | ### Listening channels
106 |
107 | After connection established you may decide to listen for any numbers of
108 | channels your application may need to utilize:
109 |
110 | ~~~typescript
111 | await pubSub.listen('UserChanged');
112 | await pubSub.listen('OrderCreated');
113 | await pubSub.listen('ArticleUpdated');
114 | ~~~
115 |
116 | BTW, the most reliable way is to initiate listening on `'connect'` event:
117 |
118 | ~~~typescript
119 | pubSub.on('connect', async () => {
120 | await Promise.all([
121 | 'UserChanged',
122 | 'OrderCreated',
123 | 'ArticleUpdated',
124 | ].map(channel => pubSub.listen(channel)));
125 | });
126 | ~~~
127 |
128 | Now, whenever you need to close/reopen connection, or reconnect occurred for any
129 | reason you'll be sure nothing broken.
130 |
131 | ### Handling messages
132 |
133 | All payloads on messages treated as JSON, so when the handler catches a
134 | message it is already parsed as JSON value, so you do not need to manage
135 | serialization/deserialization yourself.
136 |
137 | There are 2 ways of handling channel messages - by using `'message'` event
138 | handler on `pubSub` object, or using `pubSub.channels` event emitter and to
139 | listen only particular channel for its messages. On message event fires first,
140 | channels events fires afterwards, so this could be a good way if you need to
141 | inject and transform a particular message in synchronously manner before it
142 | will come to a particular channel listeners.
143 |
144 | Also `'message'` listener could be useful during implementation of handling of
145 | database side events. It is easy imagine that db can send us messages into, so
146 | called, structural channels, e.g. `'user:insert'`, `'company:update'` or
147 | `'user_company:delete'`, where such names generated by some generic trigger
148 | which handles corresponding database operations and send updates to subscribers
149 | using NOTIFY calls. In such case we can treat channel on application side
150 | as self-describable database operation change, which we can easily manage with
151 | a single piece of code and keep following DRY.
152 |
153 | ~~~typescript
154 | // using 'message' handler:
155 | pubSub.on('message', (channel: string, payload: AnyJson) => {
156 | // ... do the job
157 | switch (channel) {
158 | case 'UserChanged': {
159 | // ... do some staff with user change event payload
160 | break;
161 | }
162 | default: {
163 | // do something with payload by default
164 | break;
165 | }
166 | }
167 | });
168 | ~~~
169 |
170 | ~~~typescript
171 | // handling using channels
172 | pubSub.channels.on('UserChanged', (payload: AnyJson) => {
173 | // do something with user changed payload
174 | });
175 | pubSub.channels.on('OrderCreated', (payload: AnyJson) => {
176 | // do something with order created payload
177 | });
178 | pubSub.channels.on('ArticleUpdated', (payload: AnyJson) => {
179 | // do something with article updated payload
180 | });
181 | ~~~
182 |
183 | Of course, it is better to set up listeners before calling `connect()` that it
184 | starts handle payloads right up on connect time.
185 |
186 | ### Publishing messages
187 |
188 | You can send messages in many ways. For example, you may create
189 | database triggers which would notify all connected clients with some
190 | specific updates. Or you may use a database only as notifications engine
191 | and generate notifications on application level. Or you may combine both
192 | approaches - there are no limits!
193 |
194 | Here is how you can send notification with `PgPubSub` API (aka application
195 | level of notifications):
196 |
197 | ~~~typescript
198 | pubSub.notify('UserChanged', {
199 | old: { id: 777, name: 'John Doe', phone: '555-55-55' },
200 | new: { id: 777, name: 'Sam Peters', phone: '777-77-77' },
201 | });
202 | ~~~
203 |
204 | Now all subscribers, who listening `'UserChanged'` channel will receive a given
205 | payload JSON object.
206 |
207 | ## Single Listener (Inter Process Locking)
208 |
209 | There are variety of many possible architectures to come up with when you're
210 | building scalable distributed system.
211 |
212 | With services on scale in such systems it might be a need to make sure only
213 | single service of much similar running is listening to particular database
214 | notifications.
215 | Here why comes an idea of inter process (IP) locking mechanism, which would
216 | guarantee that only one process handles notifications and if it dies,
217 | next one which is live will immediately handle listening.
218 |
219 | This library comes with this option turned on by default. To make it work in
220 | such manner, you would need to skip passing `singleListener` option to
221 | `PgPubSub` constructor or set it to `true`:
222 |
223 | ~~~typescript
224 | const pubSub = new PgPubSub({ connectionString });
225 | // or, equivalently
226 | const pubSub = new PgPubSub({ connectionString, singleListener: true });
227 | ~~~
228 |
229 | Locking mechanism utilizes the same connection and LISTEN/NOTIFY commands, so
230 | it won't consume any additional computing resources.
231 |
232 | Also, if you already work with `pg` library in your application, and you
233 | have a need to stay for some reason with that single connection usage, you
234 | can bypass it directly as `pgClient` option, but that is not always a good idea.
235 | Normally, you have to understand what you are doing and why.
236 |
237 | ~~~typescript
238 | const pubSub = new PgPubSub({ pgClient: existingClient });
239 | ~~~
240 |
241 | > **NOTE:** With LISTEN connections it is really hard to utilize power of
242 | > connection pool as long as it will require additional implementation of
243 | > some connection switching mechanism using listen/unlisten and some specific
244 | > watchers which may fall into need of re-implementing pools from scratch. So,
245 | > that is why most of existing listen/notify solutions based on a single
246 | > connection approach. And this library as well. It is just more simple and
247 | > reliable.
248 |
249 | Also, PgPubSub supports execution lock. This means all services become listeners
250 | in single listener mode but only one listener can process a notification. To
251 | enable this feature, you can bypass `executionLock` as option and set it to
252 | `true`. By default, this lock type is turned off.
253 |
254 | > **NOTE:** Sometimes you might receive the notification with the same payloads
255 | > in a very short period of time but execution lock will process them as the
256 | > only notify message. If this important to you and your system will lave data
257 | > leaks you need to ensure that payloads are unique.
258 |
259 | ## [Full API Docs](https://github.com/imqueue/pg-pubsub/wiki)
260 |
261 | You may read API docs on [wiki pages](https://github.com/imqueue/pg-pubsub/wiki)
262 | , read the code of the library itself, use hints in your IDE or generate HTML
263 | docs with:
264 |
265 | ~~~bash
266 | git clone git@github.com:imqueue/pg-pubsub.git
267 | cd pg-pubsub
268 | npm i
269 | npm run doc
270 | ~~~
271 |
272 | ## Finally
273 |
274 | Try to run the following minimal example code of single listener scenario (do
275 | not forget to set proper database connection string):
276 |
277 | ~~~typescript
278 | import { PgPubSub } from '@imqueue/pg-pubsub';
279 | import Timer = NodeJS.Timer;
280 |
281 | let timer: Timer;
282 | const NOTIFY_DELAY = 2000;
283 | const CHANNEL = 'HelloChannel';
284 |
285 | const pubSub = new PgPubSub({
286 | connectionString: 'postgres://postgres@localhost:5432/postgres',
287 | singleListener: true,
288 | // filtered: true,
289 | });
290 |
291 | pubSub.on('listen', channel => console.info(`Listening to ${channel}...`));
292 | pubSub.on('connect', async () => {
293 | console.info('Database connected!');
294 | await pubSub.listen(CHANNEL);
295 | timer = setInterval(async () => {
296 | await pubSub.notify(CHANNEL, { hello: { from: process.pid } });
297 | }, NOTIFY_DELAY);
298 | });
299 | pubSub.on('notify', channel => console.log(`${channel} notified`));
300 | pubSub.on('end', () => console.warn('Connection closed!'));
301 | pubSub.channels.on(CHANNEL, console.log);
302 | pubSub.connect().catch(err => console.error('Connection error:', err));
303 | ~~~
304 |
305 | Or take a look at other minimal code
306 | [examples](https://github.com/imqueue/pg-pubsub/tree/examples)
307 |
308 | Play with them locally:
309 |
310 | ~~~bash
311 | git clone -b examples git://github.com/imqueue/pg-pubsub.git examples
312 | cd examples
313 | npm i
314 | ~~~
315 |
316 | Now you can start any of them, for example:
317 |
318 | ~~~bash
319 | ./node_modules/.bin/ts-node filtered.ts
320 | ~~~
321 |
322 | ## Contributing
323 |
324 | Any contributions are greatly appreciated. Feel free to fork, propose PRs, open
325 | issues, do whatever you think may be helpful to this project. PRs which passes
326 | all tests and do not brake tslint rules are first-class candidates to be
327 | accepted!
328 |
329 | ## License
330 |
331 | This project is licensed under the GNU General Public License v3.0.
332 | See the [LICENSE](LICENSE)
333 |
334 | Happy Coding!
335 |
--------------------------------------------------------------------------------
/bin/rename.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | /*!
3 | * Copyright (c) 2018, imqueue.com
4 | *
5 | * Permission to use, copy, modify, and/or distribute this software for any
6 | * purpose with or without fee is hereby granted, provided that the above
7 | * copyright notice and this permission notice appear in all copies.
8 | *
9 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10 | * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11 | * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12 | * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13 | * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14 | * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15 | * PERFORMANCE OF THIS SOFTWARE.
16 | */
17 | const glob = require('glob').sync;
18 | const fs = require('fs');
19 | const path = require('path');
20 |
21 | const map = (glob(`${__dirname}/../src/**`) || []).reduce((acc, next) => {
22 | if (fs.statSync(next).isDirectory()) {
23 | return acc;
24 | }
25 |
26 | const name = path.basename(next).replace('.ts', '.md');
27 |
28 | acc[name.toLowerCase()] = name;
29 |
30 | return acc;
31 | }, {});
32 |
33 | Object.assign(map, {
34 | 'jsonarray.md': 'JsonArray.md',
35 | 'jsonmap.md': 'JsonMap.md',
36 | });
37 |
38 | (glob(`${__dirname}/../wiki/**`) || []).forEach(file => {
39 | if (fs.statSync(file).isDirectory()) {
40 | return ;
41 | }
42 |
43 | const name = path.basename(file);
44 | const dir = path.dirname(file);
45 | const opts = { encoding: 'utf8' };
46 | let content = fs.readFileSync(file, opts);
47 |
48 | for (const name of Object.keys(map)) {
49 | const rx = new RegExp(name.replace('.', '\.'), 'g');
50 | content = content.replace(rx, map[name]);
51 | }
52 |
53 | fs.writeFileSync(file, content, opts);
54 |
55 | if (map[name]) {
56 | fs.renameSync(file, `${dir}/${map[name]}`);
57 | }
58 | });
59 |
--------------------------------------------------------------------------------
/bin/wiki.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # ISC License
3 | #
4 | # Copyright (c) 2019-present, imqueue.com
5 | #
6 | # Permission to use, copy, modify, and/or distribute this software for any
7 | # purpose with or without fee is hereby granted, provided that the above
8 | # copyright notice and this permission notice appear in all copies.
9 | #
10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
11 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
12 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
13 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
14 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
15 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
16 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
17 |
18 | # Update docs
19 | exit 0
20 | #npm run wiki
21 | #
22 | ## Flatten docs structure
23 | #cd wiki
24 | #originalPaths=$(find . -mindepth 2 -type f)
25 | #find . -mindepth 2 -type f -exec mv {} . \;
26 | #find . -type d -empty -delete
27 | #
28 | ## Strip out folder structure from links to support Github Wiki
29 | #while read -r line; do
30 | # # Remove leading ./ from each file name
31 | # line=$(sed "s|^./||" <<< ${line})
32 | # trimmedLine=$(sed "s|.*/||" <<< ${line})
33 | # sed -i '' -e "s|${line}|${trimmedLine}|" *
34 | # sed -i '1d;2d' $(basename "${line}")
35 | #done <<< "$originalPaths"
36 | #
37 | #rm -f README.md
38 | #
39 | ## Strip out .md from raw text to support Github Wiki
40 | #sed -i -e 's/.md//' *
41 | #
42 | #sed -i '1d;2d' globals.md
43 | #sed -i "s/globals#/#/g" globals.md
44 | #mv globals.md Home.md
45 | #
46 | ## Return to
47 | #cd ../
48 | #
49 | ## Clone Wiki Repo
50 | #cd ../
51 | #if [[ -d pg-pubsub.wiki ]]; then
52 | # cd pg-pubsub.wiki
53 | # git pull
54 | # cd ../
55 | #else
56 | # git clone https://github.com/imqueue/pg-pubsub.wiki
57 | #fi
58 | #
59 | ## Copy docs into wiki repo
60 | #cp -a pg-pubsub/wiki/. pg-pubsub.wiki
61 | #
62 | ## Create commit and push in wiki repo
63 | #cd pg-pubsub.wiki
64 | #git add -A
65 | #git commit -m "Wiki docs update"
66 | #git push
67 | #
68 | #cd ../pg-pubsub
69 | #npm run clean
70 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
2 | import globals from "globals";
3 | import tsParser from "@typescript-eslint/parser";
4 | import path from "node:path";
5 | import { fileURLToPath } from "node:url";
6 | import js from "@eslint/js";
7 | import { FlatCompat } from "@eslint/eslintrc";
8 |
9 | const __filename = fileURLToPath(import.meta.url);
10 | const __dirname = path.dirname(__filename);
11 | const compat = new FlatCompat({
12 | baseDirectory: __dirname,
13 | recommendedConfig: js.configs.recommended,
14 | allConfig: js.configs.all
15 | });
16 |
17 | export default [...compat.extends(
18 | "plugin:@typescript-eslint/recommended",
19 | "plugin:@typescript-eslint/recommended-requiring-type-checking",
20 | ), {
21 | plugins: {
22 | "@typescript-eslint": typescriptEslint,
23 | },
24 |
25 | languageOptions: {
26 | globals: {
27 | ...globals.browser,
28 | ...globals.node,
29 | },
30 |
31 | parser: tsParser,
32 | ecmaVersion: 5,
33 | sourceType: "module",
34 |
35 | parserOptions: {
36 | project: "tsconfig.json",
37 | },
38 | },
39 |
40 | rules: {
41 | "@typescript-eslint/adjacent-overload-signatures": "error",
42 | "@typescript-eslint/array-type": "error",
43 | "@typescript-eslint/class-name-casing": "off",
44 | "@typescript-eslint/consistent-type-assertions": "error",
45 | "@typescript-eslint/interface-name-prefix": "off",
46 | "@typescript-eslint/no-unsafe-member-access": "off",
47 | "@typescript-eslint/no-unsafe-call": "off",
48 | "@typescript-eslint/no-unsafe-assignment": "off",
49 | "@typescript-eslint/no-misused-promises": "off",
50 | "@typescript-eslint/no-empty-function": "error",
51 | "@typescript-eslint/no-empty-interface": "error",
52 | "@typescript-eslint/no-explicit-any": "off",
53 | "@typescript-eslint/no-misused-new": "error",
54 | "@typescript-eslint/no-namespace": "error",
55 | "@typescript-eslint/no-parameter-properties": "off",
56 | "@typescript-eslint/no-use-before-define": "off",
57 | "@typescript-eslint/no-var-requires": "off",
58 | "@typescript-eslint/prefer-for-of": "error",
59 | "@typescript-eslint/prefer-function-type": "error",
60 | "@typescript-eslint/prefer-namespace-keyword": "error",
61 | "@typescript-eslint/unbound-method": "off",
62 |
63 | "quotes": ["error", "single", {
64 | avoidEscape: true,
65 | }],
66 |
67 | "semi": "error",
68 | "@typescript-eslint/triple-slash-reference": "error",
69 | "@typescript-eslint/unified-signatures": "error",
70 | "arrow-parens": ["off", "as-needed"],
71 | camelcase: "error",
72 | "comma-dangle": "off",
73 | complexity: "off",
74 | "constructor-super": "error",
75 | "dot-notation": "error",
76 | eqeqeq: ["error", "smart"],
77 | "guard-for-in": "error",
78 |
79 | "id-blacklist": [
80 | "error",
81 | "any",
82 | "Number",
83 | "number",
84 | "String",
85 | "string",
86 | "Boolean",
87 | "boolean",
88 | "Undefined",
89 | "undefined",
90 | ],
91 |
92 | "id-match": "error",
93 | "max-classes-per-file": "off",
94 |
95 | "max-len": ["error", {
96 | code: 80,
97 | }],
98 |
99 | "new-parens": "error",
100 | "no-bitwise": "off",
101 | "no-caller": "error",
102 | "no-cond-assign": "error",
103 | "no-console": "off",
104 | "no-debugger": "error",
105 | "no-empty": "error",
106 | "no-eval": "error",
107 | "no-fallthrough": "off",
108 | "no-invalid-this": "off",
109 | "no-multiple-empty-lines": "off",
110 | "no-new-wrappers": "error",
111 |
112 | "no-shadow": ["error", {
113 | hoist: "all",
114 | }],
115 |
116 | "no-throw-literal": "error",
117 | "no-trailing-spaces": "error",
118 | "no-undef-init": "error",
119 | "no-underscore-dangle": "error",
120 | "no-unsafe-finally": "error",
121 | "no-unused-expressions": "off",
122 | "no-unused-labels": "error",
123 | "no-var": "error",
124 | "object-shorthand": "error",
125 | "one-var": ["error", "never"],
126 | "prefer-arrow/prefer-arrow-functions": "off",
127 | "prefer-const": "error",
128 | radix: "error",
129 | "spaced-comment": "off",
130 | "use-isnan": "error",
131 | "valid-typeof": "off",
132 | },
133 | }];
134 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) 2018, imqueue.com
3 | *
4 | * I'm Queue Software Project
5 | * Copyright (C) 2025 imqueue.com
6 | *
7 | * This program is free software: you can redistribute it and/or modify
8 | * it under the terms of the GNU General Public License as published by
9 | * the Free Software Foundation, either version 3 of the License, or
10 | * (at your option) any later version.
11 | *
12 | * This program is distributed in the hope that it will be useful,
13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 | * GNU General Public License for more details.
16 | *
17 | * You should have received a copy of the GNU General Public License
18 | * along with this program. If not, see .
19 | *
20 | * If you want to use this code in a closed source (commercial) project, you can
21 | * purchase a proprietary commercial license. Please contact us at
22 | * to get commercial licensing options.
23 | */
24 | export * from './src';
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@imqueue/pg-pubsub",
3 | "version": "2.0.0",
4 | "description": "Reliable PostgreSQL LISTEN/NOTIFY with inter-process lock support",
5 | "keywords": [
6 | "listen",
7 | "notify",
8 | "postgres",
9 | "postgresql",
10 | "pg-listen",
11 | "pg-notify",
12 | "pubsub",
13 | "publish",
14 | "subscribe",
15 | "events",
16 | "publish-subscribe",
17 | "inter-process-lock"
18 | ],
19 | "scripts": {
20 | "prepublishOnly": "npm run build",
21 | "postpublish": "./bin/wiki.sh",
22 | "clean:dts": "find . -name '*.d.ts' -not -wholename '*node_modules*' -type f -delete",
23 | "clean:map": "find . -name '*.js.map' -not -wholename '*node_modules*' -type f -delete",
24 | "clean:js": "find . -name '*.js' -not -wholename '*node_modules*' -not -wholename '*bin*' -type f -delete",
25 | "clean:build": "rm -rf ./node_modules/@types ; find . -name '*.js.map' -type f -delete ; find . -name '*.ts' -type f -delete",
26 | "clean:test": "rm -rf .nyc_output coverage",
27 | "clean:doc": "rm -rf docs",
28 | "clean:wiki": "rm -rf wiki",
29 | "clean": "npm run clean:test ; npm run clean:dts ; npm run clean:map ; npm run clean:js ; npm run clean:doc ; npm run clean:wiki",
30 | "build": "tsc",
31 | "mocha": "nyc mocha",
32 | "show:test": "/usr/bin/env node -e \"import('open').then(open => open.default('file://`pwd`/coverage/index.html',{wait:false}));\"",
33 | "show:doc": "/usr/bin/env node -e \"import('open').then(open => open.default('file://`pwd`/docs/index.html',{wait:false}));\"",
34 | "test": "npm run build && npm run mocha && npm run show:test && ((test ! -z \"${CI}\" && nyc report --reporter=text-lcov | coveralls) || exit 0)",
35 | "doc": "npm run clean && typedoc --excludePrivate --excludeExternals --hideGenerator --exclude \"**/+(debug|test|node_modules|docs|coverage|.nyc_output|examples)/**/*\" --out ./docs . && npm run show:doc",
36 | "wiki": "npm run clean && typedoc --excludePrivate --excludeExternals --hideGenerator --exclude \"**/+(debug|test|node_modules|docs|coverage|.nyc_output|examples)/**/*\" --out ./wiki --plugin typedoc-plugin-markdown --hideSources --theme markdown . && ./bin/rename.js",
37 | "help": "npm-scripts-help"
38 | },
39 | "author": "imqueue.com ",
40 | "license": "GPL-3.0-only",
41 | "repository": {
42 | "type": "git",
43 | "url": "git://github.com/imqueue/pg-pubsub.git"
44 | },
45 | "bugs": {
46 | "url": "https://github.com/imqueue/pg-pubsub/issues"
47 | },
48 | "homepage": "https://github.com/imqueue/pg-pubsub",
49 | "dependencies": {
50 | "@types/node": "^24.0.10",
51 | "@types/pg": "^8.15.4",
52 | "@types/pg-format": "^1.0.5",
53 | "farmhash": "^4.0.2",
54 | "pg": "^8.16.3",
55 | "pg-format": "^1.0.4",
56 | "uuid": "^11.1.0"
57 | },
58 | "devDependencies": {
59 | "@eslint/eslintrc": "^3.3.1",
60 | "@eslint/js": "^9.30.1",
61 | "@types/chai": "^5.2.2",
62 | "@types/mocha": "^10.0.10",
63 | "@types/mock-require": "^3.0.0",
64 | "@types/sinon": "^17.0.4",
65 | "@types/uuid": "^10.0.0",
66 | "@typescript-eslint/eslint-plugin": "^8.35.1",
67 | "@typescript-eslint/parser": "^8.35.1",
68 | "@typescript-eslint/typescript-estree": "^8.35.1",
69 | "chai": "^5.2.0",
70 | "coveralls-next": "^4.2.1",
71 | "eslint": "^9.30.1",
72 | "glob": "^11.0.3",
73 | "globals": "^16.3.0",
74 | "minimist": "^1.2.8",
75 | "mocha": "^11.7.1",
76 | "mocha-lcov-reporter": "^1.3.0",
77 | "mock-require": "^3.0.3",
78 | "npm-scripts-help": "^0.8.0",
79 | "nyc": "^17.1.0",
80 | "open": "^10.1.2",
81 | "sinon": "^21.0.0",
82 | "source-map-support": "^0.5.21",
83 | "ts-node": "^10.9.2",
84 | "typedoc": "^0.28.7",
85 | "typedoc-plugin-markdown": "^4.7.0",
86 | "typescript": "^5.8.3"
87 | },
88 | "main": "index.js",
89 | "typescript": {
90 | "definitions": "index.d.ts"
91 | },
92 | "nyc": {
93 | "check-coverage": true,
94 | "extension": [
95 | ".ts"
96 | ],
97 | "exclude": [
98 | "**/*.d.ts",
99 | "**/test/**",
100 | "**/examples/**"
101 | ],
102 | "require": [
103 | "ts-node/register"
104 | ],
105 | "reporter": [
106 | "html",
107 | "text",
108 | "text-summary",
109 | "lcovonly"
110 | ]
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/NoLock.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { AnyLock } from './types';
23 |
24 | // istanbul ignore next
25 | /**
26 | * Implements no lock to be used with multi-listener approach
27 | */
28 | export class NoLock implements AnyLock {
29 | /**
30 | * Init no lock
31 | */
32 | public async init(): Promise {
33 | return Promise.resolve();
34 | }
35 |
36 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
37 | public onRelease(handler: (channel: string) => void): void {
38 | return ;
39 | }
40 |
41 | /**
42 | * Always acquires, because it's no lock
43 | *
44 | * @return {Promise}
45 | */
46 | public async acquire(): Promise {
47 | return Promise.resolve(true);
48 | }
49 |
50 | /**
51 | * Never releases, because it's no lock
52 | *
53 | * @return {Promise}
54 | */
55 | public async release(): Promise {
56 | return Promise.resolve();
57 | }
58 |
59 | /**
60 | * Always acquired, because it's no lock
61 | *
62 | * @return {boolean}
63 | */
64 | public isAcquired(): boolean {
65 | return true;
66 | }
67 |
68 | /**
69 | * Safely destroys this no lock
70 | *
71 | * @return {Promise}
72 | */
73 | public async destroy(): Promise {
74 | return Promise.resolve();
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/PgChannelEmitter.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { EventEmitter } from 'events';
23 | import { channel } from './types';
24 |
25 | export declare interface PgChannelEmitter {
26 | /**
27 | * Sets channel listener event handler
28 | *
29 | * @param {string} channelName - channel name to listen
30 | * @param {typeof channel} listener - channel event handler
31 | * @return {PgChannelEmitter}
32 | */
33 | on(channelName: string, listener: typeof channel): this;
34 |
35 | /**
36 | * Sets channel listener event handler which will be fired only one time
37 | *
38 | * @param {string} channelName - channel name to listen
39 | * @param {typeof channel} listener - channel event handler
40 | * @return {PgChannelEmitter}
41 | */
42 | once(channelName: string, listener: typeof channel): this;
43 | }
44 |
45 | /**
46 | * Implements event emitting/subscribing on PostgreSQL LISTEN/NOTIFY
47 | * named channels.
48 | */
49 | export class PgChannelEmitter extends EventEmitter {}
50 |
--------------------------------------------------------------------------------
/src/PgIpLock.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { Notification } from 'pg';
23 | import { ident, literal } from 'pg-format';
24 | import { clearInterval } from 'timers';
25 | import {
26 | SCHEMA_NAME,
27 | SHUTDOWN_TIMEOUT,
28 | } from './constants';
29 | import { AnyLock } from './types';
30 | import { PgIpLockOptions } from './types/PgIpLockOptions';
31 | import Timeout = NodeJS.Timeout;
32 |
33 | /**
34 | * Implements manageable inter-process locking mechanism over
35 | * existing PostgreSQL connection for a given `LISTEN` channel.
36 | *
37 | * It uses periodic locks acquire retries and implements graceful shutdown
38 | * using `SIGINT`, `SIGTERM` and `SIGABRT` OS signals, by which safely releases
39 | * an acquired lock, which causes an event to other similar running instances
40 | * on another processes (or on another hosts) to capture free lock.
41 | *
42 | * By running inside Docker containers this would work flawlessly on
43 | * implementation auto-scaling services, as docker destroys containers
44 | * gracefully.
45 | *
46 | * Currently, the only known issue could happen only if, for example, database
47 | * or software (or hardware) in the middle will cause a silent disconnect. For
48 | * some period of time, despite the fact that there are other live potential
49 | * listeners some messages can go into void. This time period can be tuned by
50 | * bypassing wanted `acquireInterval` argument. By the way, take into account
51 | * that too short period and number of running services may cause huge flood of
52 | * lock acquire requests to a database, so selecting the proper number should be
53 | * a thoughtful trade-off between overall system load and reliability level.
54 | *
55 | * Usually you do not need to instantiate this class directly - it will be done
56 | * by a PgPubSub instances on their needs. Therefore, you may re-use this piece
57 | * of code in some other implementations, so it is exported as is.
58 | */
59 | export class PgIpLock implements AnyLock {
60 | /**
61 | * DB lock schema name getter
62 | *
63 | * @return {string}
64 | */
65 | public get schemaName(): string {
66 | const suffix = this.uniqueKey ? '_unique' : '';
67 |
68 | return ident(SCHEMA_NAME + suffix);
69 | }
70 |
71 | /**
72 | * Calls destroy() on all created instances at a time
73 | *
74 | * @return {Promise}
75 | */
76 | public static async destroy(): Promise {
77 | await Promise.all(
78 | PgIpLock.instances.slice().map(lock => lock.destroy()),
79 | );
80 | }
81 |
82 | /**
83 | * Returns true if at least one instance was created, false - otherwise
84 | *
85 | * @return {boolean}
86 | */
87 | public static hasInstances(): boolean {
88 | return PgIpLock.instances.length > 0;
89 | }
90 |
91 | private static instances: PgIpLock[] = [];
92 | private acquired = false;
93 | private notifyHandler: (message: Notification) => void;
94 | private acquireTimer?: Timeout;
95 |
96 | /**
97 | * @constructor
98 | * @param {string} channel - source channel name to manage locking on
99 | * @param {PgIpLockOptions} options - lock instantiate options
100 | * @param {string} [uniqueKey] - unique key for specific message
101 | */
102 | public constructor(
103 | public readonly channel: string,
104 | public readonly options: PgIpLockOptions,
105 | public readonly uniqueKey?: string,
106 | ) {
107 | this.channel = `__${PgIpLock.name}__:${
108 | channel.replace(RX_LOCK_CHANNEL, '')
109 | }`;
110 | PgIpLock.instances.push(this);
111 | }
112 |
113 | /**
114 | * Initializes inter-process locks storage in database and starts
115 | * listening of lock release events, as well as initializes lock
116 | * acquire retry timer.
117 | *
118 | * @return {Promise}
119 | */
120 | public async init(): Promise {
121 | if (!await this.schemaExists()) {
122 | try {
123 | await this.createSchema();
124 | await Promise.all([this.createLock(), this.createDeadlockCheck()]);
125 | } catch (e) {
126 | /*ignore*/
127 | }
128 | }
129 |
130 | if (this.notifyHandler && !this.uniqueKey) {
131 | this.options.pgClient.on('notification', this.notifyHandler);
132 | }
133 |
134 | if (!~PgIpLock.instances.indexOf(this)) {
135 | PgIpLock.instances.push(this);
136 | }
137 |
138 | if (!this.uniqueKey) {
139 | await this.listen();
140 |
141 | // noinspection TypeScriptValidateTypes
142 | !this.acquireTimer && (this.acquireTimer = setInterval(
143 | () => !this.acquired && this.acquire(),
144 | this.options.acquireInterval,
145 | ));
146 | }
147 | }
148 |
149 | /**
150 | * This would provide release handler which will be called once the
151 | * lock is released and the channel name would be bypassed to a given
152 | * handler
153 | *
154 | * @param {(channel: string) => void} handler
155 | */
156 | public onRelease(handler: (channel: string) => void): void {
157 | if (!!this.notifyHandler) {
158 | throw new TypeError(
159 | 'Release handler for IPC lock has been already set up!',
160 | );
161 | }
162 |
163 | this.notifyHandler = (message): void => {
164 | // istanbul ignore else
165 | // we should skip messages from pub/sub channels and listen
166 | // only to those which are ours
167 | if (message.channel === this.channel) {
168 | handler(this.channel.replace(RX_LOCK_CHANNEL, ''));
169 | }
170 | };
171 |
172 | this.options.pgClient.on('notification', this.notifyHandler);
173 | }
174 |
175 | /**
176 | * Acquires a lock on the current channel. Returns true on success,
177 | * false - otherwise
178 | *
179 | * @return {Promise}
180 | */
181 | public async acquire(): Promise {
182 | try {
183 | this.uniqueKey
184 | ? await this.acquireUniqueLock()
185 | : await this.acquireChannelLock()
186 | ;
187 | this.acquired = true;
188 | } catch (err) {
189 | // will throw, because insert duplicates existing lock
190 | this.acquired = false;
191 |
192 | // istanbul ignore next
193 | if (!(err.code === 'P0001' && err.detail === 'LOCKED')) {
194 | this.options.logger.error(err);
195 | }
196 | }
197 |
198 | return this.acquired;
199 | }
200 |
201 | /**
202 | * Returns true if lock schema exists, false - otherwise
203 | *
204 | * @return {Promise}
205 | */
206 | private async schemaExists(): Promise {
207 | const { rows } = await this.options.pgClient.query(`
208 | SELECT schema_name
209 | FROM information_schema.schemata
210 | WHERE schema_name = '${this.schemaName}'
211 | `);
212 |
213 | return (rows.length > 0);
214 | }
215 |
216 | /**
217 | * Acquires a lock with ID
218 | *
219 | * @return {Promise}
220 | */
221 | private async acquireUniqueLock(): Promise {
222 | // noinspection SqlResolve
223 | await this.options.pgClient.query(`
224 | INSERT INTO ${this.schemaName}.lock (id, channel, app)
225 | VALUES (
226 | ${literal(this.uniqueKey)},
227 | ${literal(this.channel)},
228 | ${literal(this.options.pgClient.appName)}
229 | ) ON CONFLICT (id) DO
230 | UPDATE SET app = ${this.schemaName}.deadlock_check(
231 | ${this.schemaName}.lock.app,
232 | ${literal(this.options.pgClient.appName)}
233 | )
234 | `);
235 | }
236 |
237 | /**
238 | * Acquires a lock by unique channel
239 | *
240 | * @return {Promise}
241 | */
242 | private async acquireChannelLock(): Promise {
243 | // noinspection SqlResolve
244 | await this.options.pgClient.query(`
245 | INSERT INTO ${this.schemaName}.lock (channel, app)
246 | VALUES (
247 | ${literal(this.channel)},
248 | ${literal(this.options.pgClient.appName)}
249 | ) ON CONFLICT (channel) DO
250 | UPDATE SET app = ${this.schemaName}.deadlock_check(
251 | ${this.schemaName}.lock.app,
252 | ${literal(this.options.pgClient.appName)}
253 | )
254 | `);
255 | }
256 |
257 | /**
258 | * Releases acquired lock on this channel. After lock is released, another
259 | * running process or host would be able to acquire the lock.
260 | *
261 | * @return {Promise}
262 | */
263 | public async release(): Promise {
264 | if (this.uniqueKey) {
265 | // noinspection SqlResolve
266 | await this.options.pgClient.query(`
267 | DELETE FROM ${this.schemaName}.lock
268 | WHERE id=${literal(this.uniqueKey)}
269 | `);
270 | } else {
271 | if (!this.acquired) {
272 | return ; // nothing to release, this lock has not been acquired
273 | }
274 |
275 | // noinspection SqlResolve
276 | await this.options.pgClient.query(`
277 | DELETE FROM ${this.schemaName}.lock
278 | WHERE channel=${literal(this.channel)}
279 | `);
280 | }
281 |
282 | this.acquired = false;
283 | }
284 |
285 | /**
286 | * Returns current lock state, true if acquired, false - otherwise.
287 | *
288 | * @return {boolean}
289 | */
290 | public isAcquired(): boolean {
291 | return this.acquired;
292 | }
293 |
294 | /**
295 | * Destroys this lock properly.
296 | *
297 | * @return {Promise}
298 | */
299 | public async destroy(): Promise {
300 | try {
301 | if (this.notifyHandler) {
302 | this.options.pgClient.off('notification', this.notifyHandler);
303 | }
304 |
305 | if (this.acquireTimer) {
306 | // noinspection TypeScriptValidateTypes
307 | clearInterval(this.acquireTimer);
308 | delete this.acquireTimer;
309 | }
310 |
311 | await Promise.all([this.unlisten(), this.release()]);
312 |
313 | PgIpLock.instances.splice(
314 | PgIpLock.instances.findIndex(lock => lock === this),
315 | 1,
316 | );
317 | } catch (err) {
318 | // do not crash - just log
319 | this.options.logger && this.options.logger.error &&
320 | this.options.logger.error(err);
321 | }
322 | }
323 |
324 | /**
325 | * Starts listening lock release channel
326 | *
327 | * @return {Promise}
328 | */
329 | private async listen(): Promise {
330 | await this.options.pgClient.query(`LISTEN ${ident(this.channel)}`);
331 | }
332 |
333 | /**
334 | * Stops listening lock release channel
335 | *
336 | * @return {Promise}
337 | */
338 | private async unlisten(): Promise {
339 | await this.options.pgClient.query(`UNLISTEN ${ident(this.channel)}`);
340 | }
341 |
342 | /**
343 | * Creates lock db schema
344 | *
345 | * @return {Promise}
346 | */
347 | private async createSchema(): Promise {
348 | await this.options.pgClient.query(`
349 | CREATE SCHEMA IF NOT EXISTS ${this.schemaName}
350 | `);
351 | }
352 |
353 | /**
354 | * Creates lock table with delete trigger, which notifies on record removal
355 | *
356 | * @return {Promise}
357 | */
358 | private async createLock(): Promise {
359 | // istanbul ignore if
360 | if (this.uniqueKey) {
361 | await this.createUniqueLock();
362 |
363 | return ;
364 | }
365 |
366 | await this.createChannelLock();
367 | }
368 |
369 | /**
370 | * Creates unique locks by IDs in the database
371 | *
372 | * @return {Promise}
373 | */
374 | private async createUniqueLock(): Promise {
375 | await this.options.pgClient.query(`
376 | DO $$
377 | BEGIN
378 | IF NOT EXISTS (
379 | SELECT *
380 | FROM information_schema.columns
381 | WHERE table_schema = '${ this.schemaName }'
382 | AND table_name = 'lock'
383 | AND column_name = 'id'
384 | ) THEN
385 | DROP TABLE IF EXISTS ${ this.schemaName }.lock;
386 | END IF;
387 | END
388 | $$
389 | `);
390 | await this.options.pgClient.query(`
391 | CREATE TABLE IF NOT EXISTS ${ this.schemaName }."lock" (
392 | "id" CHARACTER VARYING NOT NULL PRIMARY KEY,
393 | "channel" CHARACTER VARYING NOT NULL,
394 | "app" CHARACTER VARYING NOT NULL
395 | )
396 | `);
397 | await this.options.pgClient.query(`
398 | DROP TRIGGER IF EXISTS notify_release_lock_trigger
399 | ON ${this.schemaName}.lock
400 | `);
401 | }
402 |
403 | /**
404 | * Creates locks by channel names in the database
405 | *
406 | * @return {Promise}
407 | */
408 | private async createChannelLock(): Promise {
409 | await this.options.pgClient.query(`
410 | DO $$
411 | BEGIN
412 | IF EXISTS (
413 | SELECT *
414 | FROM information_schema.columns
415 | WHERE table_schema = '${ this.schemaName }'
416 | AND table_name = 'lock'
417 | AND column_name = 'id'
418 | ) THEN
419 | DROP TABLE IF EXISTS ${ this.schemaName }.lock;
420 | END IF;
421 | END
422 | $$
423 | `);
424 | await this.options.pgClient.query(`
425 | CREATE TABLE IF NOT EXISTS ${ this.schemaName }."lock" (
426 | "channel" CHARACTER VARYING NOT NULL PRIMARY KEY,
427 | "app" CHARACTER VARYING NOT NULL
428 | )
429 | `);
430 | // noinspection SqlResolve
431 | await this.options.pgClient.query(`
432 | CREATE OR REPLACE FUNCTION ${this.schemaName}.notify_lock()
433 | RETURNS TRIGGER LANGUAGE PLPGSQL AS $$
434 | BEGIN PERFORM PG_NOTIFY(OLD.channel, '1'); RETURN OLD; END; $$
435 | `);
436 |
437 | await this.options.pgClient.query(`
438 | DROP TRIGGER IF EXISTS notify_release_lock_trigger
439 | ON ${this.schemaName}.lock
440 | `);
441 |
442 | try {
443 | await this.options.pgClient.query(`
444 | CREATE CONSTRAINT TRIGGER notify_release_lock_trigger
445 | AFTER DELETE ON ${this.schemaName}.lock
446 | DEFERRABLE INITIALLY DEFERRED
447 | FOR EACH ROW EXECUTE PROCEDURE ${
448 | this.schemaName}.notify_lock()
449 | `);
450 | } catch (e) {
451 | /*ignore*/
452 | }
453 | }
454 |
455 | /**
456 | * Creates deadlocks check routine used on lock acquaintance
457 | *
458 | * @return {Promise}
459 | */
460 | private async createDeadlockCheck(): Promise {
461 | await this.options.pgClient.query(`
462 | CREATE OR REPLACE FUNCTION ${this.schemaName}.deadlock_check(
463 | old_app TEXT,
464 | new_app TEXT
465 | )
466 | RETURNS TEXT LANGUAGE PLPGSQL AS $$
467 | DECLARE num_apps INTEGER;
468 | BEGIN
469 | SELECT count(query) INTO num_apps
470 | FROM pg_stat_activity
471 | WHERE application_name = old_app;
472 | IF num_apps > 0 THEN
473 | RAISE EXCEPTION 'Duplicate channel for app %', new_app
474 | USING DETAIL = 'LOCKED';
475 | END IF;
476 | RETURN new_app;
477 | END;
478 | $$
479 | `);
480 | }
481 | }
482 |
483 | export const RX_LOCK_CHANNEL = new RegExp(`^(__${PgIpLock.name}__:)+`);
484 |
485 | let timer: any;
486 | /**
487 | * Performs graceful shutdown of running process releasing all instantiated
488 | * locks and properly destroy all their instances.
489 | */
490 | async function terminate(): Promise {
491 | let code = 0;
492 |
493 | timer && clearTimeout(timer);
494 | timer = setTimeout(() => process.exit(code), SHUTDOWN_TIMEOUT);
495 | code = await destroyLock();
496 | }
497 |
498 | /**
499 | * Destroys all instanced locks and returns exit code
500 | */
501 | async function destroyLock(): Promise {
502 | // istanbul ignore if
503 | if (!PgIpLock.hasInstances()) {
504 | return 0;
505 | }
506 |
507 | try {
508 | await PgIpLock.destroy();
509 |
510 | return 0;
511 | } catch (err) {
512 | // istanbul ignore next
513 | ((PgIpLock.hasInstances()
514 | ? (PgIpLock as any).instances[0].options.logger
515 | : console
516 | ) as any)?.error(err);
517 |
518 | return 1;
519 | }
520 | }
521 |
522 | process.on('SIGTERM', terminate);
523 | process.on('SIGINT', terminate);
524 | process.on('SIGABRT', terminate);
525 |
--------------------------------------------------------------------------------
/src/PgPubSub.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { EventEmitter } from 'events';
23 | import { Client, Notification } from 'pg';
24 | import { ident, literal } from 'pg-format';
25 | import { v4 as uuid } from 'uuid';
26 | import {
27 | AnyJson,
28 | AnyLock,
29 | AnyLogger,
30 | close,
31 | connect,
32 | DefaultOptions,
33 | end,
34 | error,
35 | listen,
36 | message,
37 | NoLock,
38 | notify,
39 | pack,
40 | PgClient,
41 | PgIpLock,
42 | PgPubSubOptions,
43 | reconnect,
44 | RX_LOCK_CHANNEL, signature,
45 | unlisten,
46 | unpack,
47 | } from '.';
48 | import { PgChannelEmitter } from './PgChannelEmitter';
49 |
50 | // PgPubSub Events
51 | export declare interface PgPubSub {
52 | /**
53 | * Sets `'end'` event handler
54 | *
55 | * @param {'end'} event
56 | * @param {typeof end} listener
57 | * @return {PgPubSub}
58 | */
59 | on(event: 'end', listener: typeof end): this;
60 |
61 | /**
62 | * Sets `'connect'` event handler
63 | *
64 | * @param {'connect'} event
65 | * @param {typeof connect} listener
66 | * @return {PgPubSub}
67 | */
68 | on(event: 'connect', listener: typeof connect): this;
69 |
70 | /**
71 | * Sets `'close'` event handler
72 | *
73 | * @param {'close'} event
74 | * @param {typeof close} listener
75 | * @return {PgPubSub}
76 | */
77 | on(event: 'close', listener: typeof close): this;
78 |
79 | /**
80 | * Sets `'listen'` event handler
81 | *
82 | * @param {'listen'} event
83 | * @param {typeof listen} listener
84 | * @return {PgPubSub}
85 | */
86 | on(event: 'listen', listener: typeof listen): this;
87 |
88 | /**
89 | * Sets `'unlisten'` event handler
90 | *
91 | * @param {'unlisten'} event
92 | * @param {typeof unlisten} listener
93 | * @return {PgPubSub}
94 | */
95 | on(event: 'unlisten', listener: typeof unlisten): this;
96 |
97 | /**
98 | * Sets `'error'` event handler
99 | *
100 | * @param {'error'} event
101 | * @param {typeof error} listener
102 | * @return {PgPubSub}
103 | */
104 | on(event: 'error', listener: typeof error): this;
105 |
106 | /**
107 | * Sets `'reconnect'` event handler
108 | *
109 | * @param {'reconnect'} event
110 | * @param {typeof reconnect} listener
111 | * @return {PgPubSub}
112 | */
113 | on(event: 'reconnect', listener: typeof reconnect): this;
114 |
115 | /**
116 | * Sets `'message'` event handler
117 | *
118 | * @param {'message'} event
119 | * @param {typeof message} listener
120 | * @return {PgPubSub}
121 | */
122 | on(event: 'message', listener: typeof message): this;
123 |
124 | /**
125 | * Sets `'notify'` event handler
126 | *
127 | * @param {'notify'} event
128 | * @param {typeof notify} listener
129 | * @return {PgPubSub}
130 | */
131 | on(event: 'notify', listener: typeof notify): this;
132 |
133 | /**
134 | * Sets any unknown or user-defined event handler
135 | *
136 | * @param {string | symbol} event - event name
137 | * @param {(...args: any[]) => void} listener - event handler
138 | */
139 | on(event: string | symbol, listener: (...args: any[]) => void): this;
140 |
141 | /**
142 | * Sets `'end'` event handler, which fired only one single time
143 | *
144 | * @param {'end'} event
145 | * @param {typeof end} listener
146 | * @return {PgPubSub}
147 | */
148 | once(event: 'end', listener: typeof end): this;
149 |
150 | /**
151 | * Sets `'connect'` event handler, which fired only one single time
152 | *
153 | * @param {'connect'} event
154 | * @param {typeof connect} listener
155 | * @return {PgPubSub}
156 | */
157 | once(event: 'connect', listener: typeof connect): this;
158 |
159 | /**
160 | * Sets `'close'` event handler, which fired only one single time
161 | *
162 | * @param {'close'} event
163 | * @param {typeof close} listener
164 | * @return {PgPubSub}
165 | */
166 | once(event: 'close', listener: typeof close): this;
167 |
168 | /**
169 | * Sets `'listen'` event handler, which fired only one single time
170 | *
171 | * @param {'listen'} event
172 | * @param {typeof listen} listener
173 | * @return {PgPubSub}
174 | */
175 | once(event: 'listen', listener: typeof listen): this;
176 |
177 | /**
178 | * Sets `'unlisten'` event handler, which fired only one single time
179 | *
180 | * @param {'unlisten'} event
181 | * @param {typeof unlisten} listener
182 | * @return {PgPubSub}
183 | */
184 | once(event: 'unlisten', listener: typeof unlisten): this;
185 |
186 | /**
187 | * Sets `'error'` event handler, which fired only one single time
188 | *
189 | * @param {'error'} event
190 | * @param {typeof error} listener
191 | * @return {PgPubSub}
192 | */
193 | once(event: 'error', listener: typeof error): this;
194 |
195 | /**
196 | * Sets `'reconnect'` event handler, which fired only one single time
197 | *
198 | * @param {'reconnect'} event
199 | * @param {typeof reconnect} listener
200 | * @return {PgPubSub}
201 | */
202 | once(event: 'reconnect', listener: typeof reconnect): this;
203 |
204 | /**
205 | * Sets `'message'` event handler, which fired only one single time
206 | *
207 | * @param {'message'} event
208 | * @param {typeof message} listener
209 | * @return {PgPubSub}
210 | */
211 | once(event: 'message', listener: typeof message): this;
212 |
213 | /**
214 | * Sets `'notify'` event handler, which fired only one single time
215 | *
216 | * @param {'notify'} event
217 | * @param {typeof notify} listener
218 | * @return {PgPubSub}
219 | */
220 | once(event: 'notify', listener: typeof notify): this;
221 |
222 | /**
223 | * Sets any unknown or user-defined event handler, which would fire only
224 | * one single time
225 | *
226 | * @param {string | symbol} event - event name
227 | * @param {(...args: any[]) => void} listener - event handler
228 | */
229 | once(event: string | symbol, listener: (...args: any[]) => void): this;
230 | }
231 |
232 | /**
233 | * Implements LISTEN/NOTIFY client for PostgreSQL connections.
234 | *
235 | * It is a basic public interface of this library, so the end-user is going
236 | * to work with this class directly to solve his/her tasks.
237 | *
238 | * Importing:
239 | * ~~~typescript
240 | * import { AnyJson, PgPubSub } from '@imqueue/pg-pubsub';
241 | * ~~~
242 | *
243 | * Instantiation:
244 | * ~~~typescript
245 | * const pubSub = new PgPubSub(options)
246 | * ~~~
247 | * @see PgPubSubOptions
248 | *
249 | * Connecting and listening:
250 | * ~~~typescript
251 | * pubSub.on('connect', async () => {
252 | * await pubSub.listen('ChannelOne');
253 | * await pubSub.listen('ChannelTwo');
254 | * });
255 | * // or, even better:
256 | * pubSub.on('connect', async () => {
257 | * await Promise.all(
258 | * ['ChannelOne', 'ChannelTwo'].map(channel => channel.listen()),
259 | * );
260 | * });
261 | * // or. less reliable:
262 | * await pubSub.connect();
263 | * await Promise.all(
264 | * ['ChannelOne', 'ChannelTwo'].map(channel => channel.listen()),
265 | * );
266 | * ~~~
267 | *
268 | * Handle messages:
269 | * ~~~typescript
270 | * pubSub.on('message', (channel: string, payload: AnyJson) =>
271 | * console.log(channel, payload);
272 | * );
273 | * // or, using channels
274 | * pubSub.channels.on('ChannelOne', (payload: AnyJson) =>
275 | * console.log(1, payload),
276 | * );
277 | * pubSub.channels.on('ChannelTwo', (payload: AnyJson) =>
278 | * console.log(2, payload),
279 | * );
280 | * ~~~
281 | *
282 | * Destroying:
283 | * ~~~typescript
284 | * await pubSub.destroy();
285 | * ~~~
286 | *
287 | * Closing and re-using connection:
288 | * ~~~typescript
289 | * await pubSub.close();
290 | * await pubSub.connect();
291 | * ~~~
292 | *
293 | * This close/connect technique may be used when doing some heavy message
294 | * handling, so while you close, another running copy may handle next
295 | * messages...
296 | */
297 | export class PgPubSub extends EventEmitter {
298 |
299 | public readonly pgClient: PgClient;
300 | public readonly options: PgPubSubOptions;
301 | public readonly channels: PgChannelEmitter = new PgChannelEmitter();
302 |
303 | private locks: { [channel: string]: AnyLock } = {};
304 | private retry = 0;
305 | private processId: number;
306 |
307 | /**
308 | * @constructor
309 | * @param {PgPubSubOptions} options - options
310 | * @param {AnyLogger} logger - logger
311 | */
312 | public constructor(
313 | options: Partial,
314 | public readonly logger: AnyLogger = console,
315 | ) {
316 | super();
317 |
318 | this.options = { ...DefaultOptions, ...options };
319 | this.pgClient = (this.options.pgClient || new Client(this.options)) as
320 | PgClient;
321 |
322 | this.pgClient.on('end', () => this.emit('end'));
323 | this.pgClient.on('error', () => this.emit('error'));
324 |
325 | this.onNotification = this.options.executionLock
326 | ? this.onNotificationLockExec.bind(this)
327 | : this.onNotification.bind(this)
328 | ;
329 | this.reconnect = this.reconnect.bind(this);
330 | this.onReconnect = this.onReconnect.bind(this);
331 |
332 | this.pgClient.on('notification', this.onNotification);
333 | }
334 |
335 | /**
336 | * Establishes re-connectable database connection
337 | *
338 | * @return {Promise}
339 | */
340 | public async connect(): Promise {
341 | return new Promise((resolve, reject) => {
342 | const onConnect = async () => {
343 | await this.setAppName();
344 | await this.setProcessId();
345 | this.emit('connect');
346 | resolve();
347 | cleanup();
348 | };
349 |
350 | const onError = (err: any) => {
351 | reject(err);
352 | cleanup();
353 | };
354 |
355 | const cleanup = () => {
356 | this.pgClient.off('connect', onConnect);
357 | this.off('error', onError);
358 | };
359 |
360 | this.setOnceHandler(['end', 'error'], this.reconnect);
361 | this.pgClient.once('connect', onConnect);
362 | this.once('error', onError);
363 |
364 | // eslint-disable-next-line @typescript-eslint/no-floating-promises
365 | this.pgClient.connect();
366 | });
367 | }
368 |
369 | /**
370 | * Safely closes this database connection
371 | *
372 | * @return {Promise}
373 | */
374 | public async close(): Promise {
375 | this.pgClient.off('end', this.reconnect);
376 | this.pgClient.off('error', this.reconnect);
377 | await this.pgClient.end();
378 | this.pgClient.removeAllListeners();
379 | this.emit('close');
380 | }
381 |
382 | /**
383 | * Starts listening given channel. If singleListener option is set to
384 | * true, it guarantees that only one process would be able to listen
385 | * this channel at a time.
386 | *
387 | * @param {string} channel - channel name to listen
388 | * @return {Promise}
389 | */
390 | public async listen(channel: string): Promise {
391 | // istanbul ignore if
392 | if (this.options.executionLock) {
393 | await this.pgClient.query(`LISTEN ${ident(channel)}`);
394 | this.emit('listen', channel);
395 | return ;
396 | }
397 |
398 | const lock = await this.lock(channel);
399 | const acquired = await lock.acquire();
400 | // istanbul ignore else
401 | if (acquired) {
402 | await this.pgClient.query(`LISTEN ${ident(channel)}`);
403 | this.emit('listen', channel);
404 | }
405 | }
406 |
407 | /**
408 | * Stops listening of the given channel, and, if singleListener option is
409 | * set to true - will release an acquired lock (if it was settled).
410 | *
411 | * @param {string} channel - channel name to unlisten
412 | * @return {Promise}
413 | */
414 | public async unlisten(channel: string): Promise {
415 | await this.pgClient.query(`UNLISTEN ${ident(channel)}`);
416 |
417 | if (this.locks[channel]) {
418 | await this.locks[channel].destroy();
419 | delete this.locks[channel];
420 | }
421 |
422 | this.emit('unlisten', [channel]);
423 | }
424 |
425 | /**
426 | * Stops listening all connected channels, and, if singleListener option
427 | * is set to true - will release all acquired locks (if any was settled).
428 | *
429 | * @return {Promise}
430 | */
431 | public async unlistenAll(): Promise {
432 | await this.pgClient.query('UNLISTEN *');
433 | await this.release();
434 |
435 | this.emit('unlisten', Object.keys(this.locks));
436 | }
437 |
438 | /**
439 | * Performs NOTIFY to a given channel with a given payload to all
440 | * listening subscribers
441 | *
442 | * @param {string} channel - channel to publish to
443 | * @param {AnyJson} payload - payload to publish for subscribers
444 | * @return {Promise}
445 | */
446 | public async notify(channel: string, payload: AnyJson): Promise {
447 | await this.pgClient.query(
448 | `NOTIFY ${ident(channel)}, ${literal(pack(payload, this.logger))}`,
449 | );
450 |
451 | this.emit('notify', channel, payload);
452 | }
453 |
454 | /**
455 | * Returns list of all active subscribed channels
456 | *
457 | * @return {string[]}
458 | */
459 | public activeChannels(): string[] {
460 | return Object.keys(this.locks).filter(channel =>
461 | this.locks[channel].isAcquired(),
462 | );
463 | }
464 |
465 | /**
466 | * Returns list of all inactive channels (those which are known, but
467 | * not actively listening at a time)
468 | *
469 | * @return {string[]}
470 | */
471 | public inactiveChannels(): string[] {
472 | return Object.keys(this.locks).filter(channel =>
473 | !this.locks[channel].isAcquired(),
474 | );
475 | }
476 |
477 | /**
478 | * Returns list of all known channels, despite the fact they are listening
479 | * (active) or not (inactive).
480 | *
481 | * @return {string[]}
482 | */
483 | public allChannels(): string[] {
484 | return Object.keys(this.locks);
485 | }
486 |
487 | /**
488 | * If channel argument passed will return true if channel is in active
489 | * state (listening by this pub/sub), false - otherwise. If channel is
490 | * not specified - will return true if there is at least one active channel
491 | * listened by this pub/sub, false - otherwise.
492 | *
493 | * @param {string} channel
494 | * @return {boolean}
495 | */
496 | public isActive(channel?: string): boolean {
497 | if (!channel) {
498 | return this.activeChannels().length > 0;
499 | }
500 |
501 | return !!~this.activeChannels().indexOf(channel);
502 | }
503 |
504 | /**
505 | * Destroys this object properly, destroying all locks,
506 | * closing all connections and removing all event listeners to avoid
507 | * memory leaking. So whenever you need to destroy an object
508 | * programmatically - use this method.
509 | * Note, that after destroy it is broken and should be removed from memory.
510 | *
511 | * @return {Promise}
512 | */
513 | public async destroy(): Promise {
514 | await Promise.all([this.close(), PgIpLock.destroy()]);
515 | this.channels.removeAllListeners();
516 | this.removeAllListeners();
517 | }
518 |
519 | /**
520 | * Safely sets given handler for given pg client events, making sure
521 | * we won't flood events with non-fired same stack of handlers
522 | *
523 | * @access private
524 | * @param {string[]} events - list of events to set handler for
525 | * @param {(...args: any[]) => any} handler - handler reference
526 | * @return {PgPubSub}
527 | */
528 | private setOnceHandler(
529 | events: string[],
530 | handler: (...args: any[]) => any,
531 | ): PgPubSub {
532 | for (const event of events) {
533 | // make sure we won't flood events with given handler,
534 | // so do a cleanup first
535 | this.clearListeners(event, handler);
536 | // now set event handler
537 | this.pgClient.once(event, handler);
538 | }
539 |
540 | return this;
541 | }
542 |
543 | /**
544 | * Clears all similar handlers under given event
545 | *
546 | * @param {string} event - event name
547 | * @param {(...args: any) => any} handler - handler reference
548 | */
549 | private clearListeners(
550 | event: string,
551 | handler: (...args: any[]) => any,
552 | ): void {
553 | this.pgClient.listeners(event).forEach(listener =>
554 | listener === handler && this.pgClient.off(event, handler),
555 | );
556 | }
557 |
558 | /**
559 | * Database notification event handler
560 | *
561 | * @access private
562 | * @param {Notification} notification - database message data
563 | * @return {Promise}
564 | */
565 | private async onNotification(notification: Notification): Promise {
566 | const lock = await this.lock(notification.channel);
567 | const skip = RX_LOCK_CHANNEL.test(notification.channel) || (
568 | this.options.filtered && this.processId === notification.processId
569 | );
570 |
571 | if (skip) {
572 | // as we use the same connection with locks mechanism
573 | // we should avoid pub/sub client to parse lock channels data
574 | // and also filter same-notify-channel messages if filtered option
575 | // is set to true
576 | return ;
577 | }
578 |
579 | if (this.options.singleListener && !lock.isAcquired()) {
580 | return; // we are not really a listener
581 | }
582 |
583 | const payload = unpack(notification.payload);
584 |
585 | this.emit('message', notification.channel, payload);
586 | this.channels.emit(notification.channel, payload);
587 | }
588 |
589 | /**
590 | * Database notification event handler for execution lock
591 | *
592 | * @access private
593 | * @param {Notification} notification - database message data
594 | * @return {Promise}
595 | */
596 | private async onNotificationLockExec(
597 | notification: Notification,
598 | ): Promise {
599 | const skip = RX_LOCK_CHANNEL.test(notification.channel) || (
600 | this.options.filtered && this.processId === notification.processId
601 | );
602 |
603 | if (skip) {
604 | // as we use the same connection with locks mechanism
605 | // we should avoid pub/sub client to parse lock channels data
606 | // and also filter same-notify-channel messages if filtered option
607 | // is set to true
608 | return ;
609 | }
610 |
611 | const lock = await this.createLock(notification.channel, signature(
612 | notification.processId,
613 | notification.channel,
614 | notification.payload,
615 | ));
616 |
617 | await lock.acquire();
618 |
619 | // istanbul ignore if
620 | if (this.options.singleListener && !lock.isAcquired()) {
621 | return; // we are not really a listener
622 | }
623 |
624 | const payload = unpack(notification.payload);
625 |
626 | this.emit('message', notification.channel, payload);
627 | this.channels.emit(notification.channel, payload);
628 | await lock.release();
629 | }
630 |
631 | /**
632 | * On reconnect event emitter
633 | *
634 | * @access private
635 | * @return {Promise}
636 | */
637 | private async onReconnect(): Promise {
638 | await Promise.all(Object.keys(this.locks).map(channel =>
639 | this.listen(channel),
640 | ));
641 |
642 | this.emit('reconnect', this.retry);
643 | this.retry = 0;
644 | }
645 |
646 | /**
647 | * Reconnect routine, used for implementation of auto-reconnecting db
648 | * connection
649 | *
650 | * @access private
651 | * @return {number}
652 | */
653 | private reconnect(): number {
654 | return setTimeout(async () => {
655 | if (this.options.retryLimit <= ++this.retry) {
656 | this.emit('error', new Error(
657 | `Connect failed after ${this.retry} retries...`,
658 | ));
659 |
660 | return this.close();
661 | }
662 |
663 | this.setOnceHandler(['connect'], this.onReconnect);
664 |
665 | try { await this.connect(); } catch (err) { /* ignore */ }
666 | },
667 |
668 | this.options.retryDelay) as any as number;
669 | }
670 |
671 | /**
672 | * Instantiates and returns process lock for a given channel or returns
673 | * existing one
674 | *
675 | * @access private
676 | * @param {string} channel
677 | * @return {Promise}
678 | */
679 | private async lock(channel: string): Promise {
680 | if (!this.locks[channel]) {
681 | this.locks[channel] = await this.createLock(channel);
682 | }
683 |
684 | return this.locks[channel];
685 | }
686 |
687 | /**
688 | * Instantiates new lock, properly initializes it and returns
689 | *
690 | * @param {string} channel
691 | * @param {string} [uniqueKey]
692 | * @return {Promise}
693 | */
694 | private async createLock(
695 | channel: string,
696 | uniqueKey?: string,
697 | ): Promise {
698 | if (this.options.singleListener) {
699 | const lock = new PgIpLock(channel, {
700 | pgClient: this.pgClient,
701 | logger: this.logger,
702 | acquireInterval: this.options.acquireInterval,
703 | }, uniqueKey);
704 |
705 | await lock.init();
706 | !uniqueKey && lock.onRelease(chan => this.listen(chan));
707 |
708 | return lock;
709 | }
710 |
711 | return new NoLock();
712 | }
713 |
714 | /**
715 | * Releases all acquired locks in current session
716 | *
717 | * @access private
718 | * @return {Promise}
719 | */
720 | private async release(): Promise {
721 | await Promise.all(Object.keys(this.locks).map(async channel => {
722 | const lock = await this.lock(channel);
723 |
724 | if (lock.isAcquired()) {
725 | await lock.release();
726 | }
727 |
728 | delete this.locks[channel];
729 | }));
730 | }
731 |
732 | /**
733 | * Sets application_name for this connection as unique identifier
734 | *
735 | * @access private
736 | * @return {Promise}
737 | */
738 | private async setAppName(): Promise {
739 | try {
740 | this.pgClient.appName = uuid();
741 | await this.pgClient.query(
742 | `SET APPLICATION_NAME TO '${this.pgClient.appName}'`,
743 | );
744 | } catch (err) { /* ignore */ }
745 | }
746 |
747 | /**
748 | * Retrieves process identifier from the database connection and sets it to
749 | * `this.processId`.
750 | *
751 | * @return {Promise}
752 | */
753 | private async setProcessId(): Promise {
754 | try {
755 | const { rows: [{ pid }] } = await this.pgClient.query(`
756 | SELECT pid FROM pg_stat_activity
757 | WHERE application_name = ${literal(this.pgClient.appName)}
758 | `);
759 | this.processId = +pid;
760 | } catch (err) { /* ignore */ }
761 | }
762 | }
763 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | export const SCHEMA_NAME = process.env.PG_PUBSUB_SCHEMA_NAME || 'pgip_lock';
23 | export const SHUTDOWN_TIMEOUT = +(
24 | process.env.PG_PUBSUB_SHUTDOWN_TIMEOUT || 1000
25 | );
26 | export const RETRY_DELAY = 100;
27 | export const RETRY_LIMIT = Infinity;
28 | export const IS_ONE_PROCESS = true;
29 | export const ACQUIRE_INTERVAL = 30000;
30 | export const EXECUTION_LOCK = !!+(
31 | process.env.PG_PUBSUB_EXECUTION_LOCK || 0
32 | );
33 |
--------------------------------------------------------------------------------
/src/helpers.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { AnyJson, AnyLogger } from './types';
23 | import { fingerprint64 } from 'farmhash';
24 |
25 | /**
26 | * Performs JSON.stringify on a given input taking into account
27 | * pretty flag.
28 | *
29 | * @access private
30 | * @param {AnyJson} input - serializable value
31 | * @param {boolean} [pretty] - serialized format output prettify flag
32 | * @return {string}
33 | */
34 | function stringify(input: AnyJson, pretty?: boolean): string {
35 | return pretty
36 | ? JSON.stringify(input, null, 2)
37 | : JSON.stringify(input);
38 | }
39 |
40 | /**
41 | * Serializes given input object to JSON string. On error will return
42 | * serialized null value
43 | *
44 | * @param {AnyJson} input - serializable value
45 | * @param {AnyLogger} [logger] - logger to handle errors logging with
46 | * @param {boolean} [pretty] - serialized format output prettify flag
47 | * @return {string}
48 | */
49 | export function pack(
50 | input: AnyJson,
51 | logger?: AnyLogger,
52 | pretty = false,
53 | ): string {
54 | if (typeof input === 'undefined') {
55 | return 'null';
56 | }
57 |
58 | try {
59 | return stringify(input, pretty);
60 | } catch (err) {
61 | if (logger && logger.warn) {
62 | logger.warn('pack() error:', err);
63 | }
64 |
65 | return 'null';
66 | }
67 | }
68 |
69 | /**
70 | * Deserializes given input JSON string to corresponding JSON value object.
71 | * On error will return empty object
72 | *
73 | * @param {string} input - string to deserialize
74 | * @param {AnyLogger} [logger] - logger to handle errors logging with
75 | * @return {AnyJson}
76 | */
77 | export function unpack(input?: string, logger?: AnyLogger): AnyJson {
78 | if (typeof input !== 'string') {
79 | return null;
80 | }
81 |
82 | try {
83 | return JSON.parse(input);
84 | } catch (err) {
85 | if (logger && logger.warn) {
86 | logger.warn('unpack() error:', err);
87 | }
88 |
89 | return {};
90 | }
91 | }
92 |
93 | /**
94 | * Constructs and returns hash string for a given set of processId, channel
95 | * and payload.
96 | *
97 | * @param {string} processId
98 | * @param {string} channel
99 | * @param {any} payload
100 | * @returns {string}
101 | */
102 | export function signature(
103 | processId: number,
104 | channel: string,
105 | payload: any,
106 | ): string {
107 | const data = JSON.stringify([processId, channel, payload]);
108 | const hashBigInt = fingerprint64(data);
109 | return hashBigInt.toString(16);
110 | }
111 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | export * from './helpers';
23 | export * from './PgPubSub';
24 | export * from './PgIpLock';
25 | export * from './NoLock';
26 | export * from './types';
27 | export * from './constants';
28 |
--------------------------------------------------------------------------------
/src/types/AnyJson.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | /**
23 | * Represents any JSON-serializable value
24 | */
25 | export type AnyJson = boolean | number | string | null | JsonArray | JsonMap;
26 |
27 | /**
28 | * Represents JSON serializable object
29 | */
30 | export interface JsonMap { [key: string]: AnyJson };
31 |
32 | /**
33 | * Represents JSON-serializable array
34 | */
35 | export type JsonArray = AnyJson[];
36 |
--------------------------------------------------------------------------------
/src/types/AnyLock.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | /**
23 | * Lock implementation interface to follow
24 | */
25 | export interface AnyLock {
26 | /**
27 | * Must initialize lock asynchronously
28 | */
29 | init(): Promise;
30 |
31 | /**
32 | * Implements lock acquire logic asynchronously
33 | */
34 | acquire(): Promise;
35 |
36 | /**
37 | * Implements lock release logic asynchronously
38 | */
39 | release(): Promise;
40 |
41 | /**
42 | * Implements lock acquire verification asynchronously
43 | */
44 | isAcquired(): boolean;
45 |
46 | /**
47 | * Implements lock safe destruction asynchronously
48 | */
49 | destroy(): Promise;
50 |
51 | /**
52 | * Implements lock release handler upset
53 | *
54 | * @param {(channel: string) => void} handler
55 | */
56 | onRelease(handler: (channel: string) => void): void;
57 | }
58 |
--------------------------------------------------------------------------------
/src/types/AnyLogger.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | /**
23 | * Represents logger interface suitable to be injected into this library objects
24 | */
25 | export interface AnyLogger {
26 | log(...args: any[]): void;
27 | info(...args: any[]): void;
28 | warn(...args: any[]): void;
29 | error(...args: any[]): void;
30 | }
31 |
--------------------------------------------------------------------------------
/src/types/PgClient.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { Client } from 'pg';
23 |
24 | /**
25 | * Extends `pg.Client` with additional properties
26 | */
27 | export interface PgClient extends Client {
28 | appName: string;
29 | }
30 |
--------------------------------------------------------------------------------
/src/types/PgIpLockOptions.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { AnyLogger } from './AnyLogger';
23 | import { PgClient } from './PgClient';
24 |
25 | /**
26 | * Options accepted by PgIpLock constructor.
27 | */
28 | export interface PgIpLockOptions {
29 | /**
30 | * PostgreSQL database connection client instance of [[PgClient]] interface.
31 | * Lock will not create connection itself, but await the connection client
32 | * to be provided explicitly.
33 | *
34 | * @type {PgClient}
35 | */
36 | pgClient: PgClient;
37 |
38 | /**
39 | * Logger to be used for log messages produced by lock instances. Any
40 | * logger which follows [[AnyLogger]] interface is suitable.
41 | *
42 | * @type {AnyLogger}
43 | */
44 | logger: AnyLogger;
45 |
46 | /**
47 | * Acquire re-try interval. See [[PgPubSubOptions.acquireInterval]].
48 | *
49 | * @see PgPubSubOptions.acquireInterval
50 | * @type {number}
51 | */
52 | acquireInterval: number;
53 | }
54 |
--------------------------------------------------------------------------------
/src/types/PgPubSubOptions.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { Client, ClientConfig } from 'pg';
23 | import {
24 | ACQUIRE_INTERVAL,
25 | EXECUTION_LOCK,
26 | IS_ONE_PROCESS,
27 | RETRY_DELAY,
28 | RETRY_LIMIT,
29 | } from '../constants';
30 |
31 | /**
32 | * Options accepted as option argument of PgPubSub constructor.
33 | * It extends `pg.ClientConfig` options, mostly because it is used to
34 | * construct PostgreSQL database connection, adding more properties required
35 | * to configure PgBubSub objects behavior.
36 | */
37 | export interface PgPubSubOptions extends ClientConfig {
38 | /**
39 | * Existing PostgreSQL client connection (optional). Can be passed if
40 | * there is a need to re-use existing db connection from external code.
41 | * Otherwise it is required to bypass correct options to instantiate
42 | * new `pg.Client` connection properly.
43 | *
44 | * @type {Client}
45 | */
46 | pgClient?: Client;
47 |
48 | /**
49 | * Specifies delay in milliseconds between re-connection retries
50 | *
51 | * @type {number}
52 | */
53 | retryDelay: number;
54 |
55 | /**
56 | * Specifies maximum number of re-connection retries to process, before
57 | * connection would be treated as broken (disconnected). By default
58 | * is set to infinite number of retries.
59 | *
60 | * @type {number}
61 | */
62 | retryLimit: number;
63 |
64 | /**
65 | * Time interval in milliseconds before `LISTEN` clients would re-try to
66 | * acquire channel locks. It works from one hand as connection keep-alive
67 | * periodical pings, from other hand adds additional level of reliability
68 | * for the cases when connection, which holds the lock has been suddenly
69 | * disconnected in a silent manner.
70 | *
71 | * By default is set to `30000ms` (`30sec`). Please, assume this value
72 | * should be selected for a particular system with care of often acquire
73 | * lock hits and overall infrastructure reliability.
74 | *
75 | * @type {number}
76 | */
77 | acquireInterval: number;
78 |
79 | /**
80 | * Boolean flag, which turns off/on single listener mode. By default is
81 | * set to true, so instantiated PgPubSub connections will act using
82 | * inter-process locking mechanism.
83 | *
84 | * @type {boolean}
85 | */
86 | singleListener: boolean;
87 |
88 | /**
89 | * If set to true, self emitted messages (those which were sent using
90 | * `NOTIFY` on the same connection) will be filtered on this connection.
91 | * By default is false - means that connection will `LISTEN` to the
92 | * messages, which were notified on the same connection.
93 | *
94 | * @type {boolean}
95 | */
96 | filtered: boolean;
97 |
98 | /**
99 | * If set to true, all instances become listeners but only instance is an
100 | * executor which still implements inter-process locking mechanism.
101 | *
102 | * @type {boolean}
103 | */
104 | executionLock: boolean;
105 | }
106 |
107 | /**
108 | * Hard-coded pre-set of PgPubSubOptions
109 | *
110 | * @see PgPubSubOptions
111 | * @type {PgPubSubOptions}
112 | */
113 | export const DefaultOptions: PgPubSubOptions = Object.freeze({
114 | retryLimit: RETRY_LIMIT,
115 | retryDelay: RETRY_DELAY,
116 | singleListener: IS_ONE_PROCESS,
117 | acquireInterval: ACQUIRE_INTERVAL,
118 | filtered: false,
119 | executionLock: EXECUTION_LOCK,
120 | });
121 |
--------------------------------------------------------------------------------
/src/types/events.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { AnyJson } from '../types';
23 |
24 | /**
25 | * Channel listener event, occurs whenever the listening channel gets a new
26 | * payload message.
27 | *
28 | * @mergeModuleWith PgChannelEmitter
29 | * @event channel
30 | * @param {AnyJson} payload - event payload
31 | */
32 | export declare function channel(payload: AnyJson): void;
33 |
34 | /**
35 | * `'end'` event, occurs whenever pg connection ends, so, literally it's simply
36 | * proxy to `'end'` event from `pg.Client`
37 | *
38 | * @mergeModuleWith PgPubSub
39 | * @event end
40 | */
41 | export declare function end(): void;
42 |
43 | /**
44 | * `'connect'` event, occurs each time database connection is established.
45 | *
46 | * @mergeModuleWith PgPubSub
47 | * @event connect
48 | */
49 | export declare function connect(): void;
50 |
51 | /**
52 | * `'close'` event, occurs each time connection closed. Differs from `'end'`
53 | * event, because `'end'` event may occur many times during re-connectable
54 | * connection process, but `'close'` event states that connection was
55 | * safely programmatically closed and further re-connections won't happen.
56 | *
57 | * @mergeModuleWith PgPubSub
58 | * @event close
59 | */
60 | export declare function close(): void;
61 |
62 | /**
63 | * `'listen'` event occurs each time channel starts being listening
64 | *
65 | * @mergeModuleWith PgPubSub
66 | * @event listen
67 | * @param {string[]} channels - list of channels being started listening
68 | */
69 | export declare function listen(channels: string[]): void;
70 |
71 | /**
72 | * `'unlisten'` event occurs each time channel ends being listening
73 | *
74 | * @mergeModuleWith PgPubSub
75 | * @event unlisten
76 | * @param {string[]} channels - list of channels being stopped listening
77 | */
78 | export declare function unlisten(channels: string[]): void;
79 |
80 | /**
81 | * `'error'` event occurs each time connection error is happened
82 | *
83 | * @mergeModuleWith PgPubSub
84 | * @event error
85 | * @param {Error} err - error occurred during connection
86 | */
87 | export declare function error(err: Error): void;
88 |
89 | /**
90 | * `'reconnect'` event occurs each time, when the connection is successfully
91 | * established after connection retry. It is followed by a corresponding
92 | * `'connect'` event, but after all possible channel locks finished their
93 | * attempts to be re-acquired.
94 | *
95 | * @mergeModuleWith PgPubSub
96 | * @event reconnect
97 | * @param {number} retries - number of retries made before re-connect succeeded
98 | */
99 | export declare function reconnect(retries: number): void;
100 |
101 | /**
102 | * `'message'` event occurs each time database connection gets notification
103 | * to any listening channel. Fired before channel event emitted.
104 | *
105 | * @mergeModuleWith PgPubSub
106 | * @event message
107 | * @param {string} chan - channel to which notification corresponding to
108 | * @param {AnyJson} payload - notification message payload
109 | */
110 | export declare function message(chan: string, payload: AnyJson): void;
111 |
112 | /**
113 | * `'notify'` event occurs each time new message has been published to a
114 | * particular channel. Occurs right after database NOTIFY command succeeded.
115 | *
116 | * @mergeModuleWith PgPubSub
117 | * @event notify
118 | * @param {string} chan - channel to which notification was sent
119 | * @param {AnyJson} payload - notification message payload
120 | */
121 | export declare function notify(chan: string, payload: AnyJson): void;
122 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | export * from './AnyJson';
23 | export * from './AnyLogger';
24 | export * from './PgClient';
25 | export * from './PgPubSubOptions';
26 | export * from './events';
27 | export * from './AnyLock';
28 |
--------------------------------------------------------------------------------
/test/index.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import './mocks';
23 | import './src';
24 |
--------------------------------------------------------------------------------
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | --require ts-node/register
2 | --require source-map-support/register
3 | --recursive
4 | --bail
5 | --full-trace
6 |
--------------------------------------------------------------------------------
/test/mocks/constants.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | // noinspection JSUnusedGlobalSymbols
23 | export const SCHEMA_NAME = 'pgip_lock';
24 | // noinspection JSUnusedGlobalSymbols
25 | export const SHUTDOWN_TIMEOUT = 10;
26 | // noinspection JSUnusedGlobalSymbols
27 | export const RETRY_DELAY = 10;
28 | // noinspection JSUnusedGlobalSymbols
29 | export const RETRY_LIMIT = 3;
30 | // noinspection JSUnusedGlobalSymbols
31 | export const IS_ONE_PROCESS = true;
32 | // noinspection JSUnusedGlobalSymbols
33 | export const ACQUIRE_INTERVAL = 10;
34 |
--------------------------------------------------------------------------------
/test/mocks/index.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import * as mock from 'mock-require';
23 | import * as constants from './constants';
24 | import * as pg from './pg';
25 |
26 | mock('../../src/constants', constants);
27 | mock('pg', pg);
28 |
29 | const printError = console.error;
30 |
31 | console.error = ((...args: any[]) => {
32 | args = args.filter(arg => !(arg instanceof FakeError));
33 | args.length && printError(...args);
34 | });
35 |
36 | export class FakeError extends Error {}
37 |
--------------------------------------------------------------------------------
/test/mocks/pg.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { EventEmitter } from 'events';
23 | import { Notification } from 'pg';
24 |
25 | let id = 0;
26 |
27 | export interface ClientConfig {
28 | connectionString?: string;
29 | }
30 |
31 | // noinspection JSUnusedGlobalSymbols
32 | export class Client extends EventEmitter {
33 | // noinspection JSUnusedGlobalSymbols,JSUnusedLocalSymbols
34 | public constructor(options: ClientConfig) {
35 | super();
36 | this.setMaxListeners(Infinity);
37 | }
38 | public connect() {
39 | this.emit('connect');
40 | }
41 | // noinspection JSUnusedGlobalSymbols
42 | public end() {
43 | this.emit('end');
44 | }
45 | public async query(queryText: string) {
46 | if (/^NOTIFY\s/.test(queryText)) {
47 | let [, channel, payload] = queryText.split(/\s+/);
48 |
49 | channel = channel.replace(/",?/g, '');
50 | payload = payload.replace(/^'|'$/g, '');
51 |
52 | const message: Notification = {
53 | channel,
54 | payload,
55 | processId: ++id,
56 | };
57 |
58 | this.emit('notification', message);
59 | return ;
60 | }
61 | return { rows: [] };
62 | }
63 | }
64 |
65 |
--------------------------------------------------------------------------------
/test/src/PgIpLock.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import { FakeError } from '../mocks';
23 |
24 | import { expect } from 'chai';
25 | import { Client } from 'pg';
26 | import * as sinon from 'sinon';
27 | import { SinonSandbox, SinonSpy } from 'sinon';
28 | import {
29 | ACQUIRE_INTERVAL,
30 | PgIpLock,
31 | SHUTDOWN_TIMEOUT,
32 | } from '../../src';
33 | import { PgClient } from '../../src/types';
34 |
35 | after(() => {
36 | process.removeAllListeners('SIGTERM');
37 | process.removeAllListeners('SIGABRT');
38 | process.removeAllListeners('SIGINT');
39 | });
40 |
41 | describe('IPCLock', () => {
42 | let client: PgClient;
43 | let lock: PgIpLock;
44 |
45 | beforeEach(() => {
46 | client = new Client() as PgClient;
47 | lock = new PgIpLock('LockTest', {
48 | pgClient: client,
49 | logger: console,
50 | acquireInterval: ACQUIRE_INTERVAL,
51 | });
52 | });
53 | afterEach(async () => lock.destroy());
54 |
55 | it('should be a class', () => {
56 | expect(typeof PgIpLock).equals('function');
57 | });
58 |
59 | describe('constructor()', () => {
60 | it('should accept channel name and pg client as arguments', () => {
61 | expect(lock.channel).equals(`__${PgIpLock.name}__:LockTest`);
62 | expect(lock.options.pgClient).equals(client);
63 | });
64 | });
65 | describe('init()', () => {
66 | let spy: SinonSpy[];
67 | let spyListen: SinonSpy;
68 |
69 | beforeEach(() => {
70 | spy = ['createSchema', 'createLock', 'createDeadlockCheck']
71 | .map(method => sinon.spy(lock as any, method));
72 | spyListen = sinon.spy(lock as any, 'listen');
73 | });
74 |
75 | it('should re-apply notify handler on re-use', async () => {
76 | await lock.init();
77 | lock.onRelease(() => { /**/ });
78 | await lock.destroy();
79 |
80 | const spyOn = sinon.spy(lock.options.pgClient, 'on');
81 | await lock.init();
82 |
83 | const calls = spyOn.getCalls();
84 |
85 | expect(spyOn.called).to.be.true;
86 | expect(calls[0].args).deep.equals([
87 | 'notification',
88 | (lock as any).notifyHandler,
89 | ]);
90 | });
91 | it('should periodically re-acquire after init', async () => {
92 | const spyAcquire = sinon.spy(lock, 'acquire');
93 | await lock.init();
94 | const stubAcquire = sinon.stub(client, 'query')
95 | .throws(new FakeError());
96 | await new Promise(res => setTimeout(res, ACQUIRE_INTERVAL * 2 + 5));
97 |
98 | expect(spyAcquire.calledTwice).to.be.true;
99 |
100 | // await compLock.destroy();
101 | await lock.destroy();
102 | stubAcquire.restore();
103 | });
104 | });
105 | describe('isAcquired()', () => {
106 | it('should return true if the lock is acquired', () => {
107 | (lock as any).acquired = true;
108 | expect(lock.isAcquired()).to.be.true;
109 | });
110 | it('should return false if the lock is not acquired', () => {
111 | expect(lock.isAcquired()).to.be.false;
112 | });
113 | });
114 | describe('acquire()', () => {
115 | beforeEach(() => {
116 | let count = 0;
117 | client.query = (() => {
118 | if (++count > 1) {
119 | throw new FakeError();
120 | }
121 | }) as any;
122 | });
123 |
124 | it('should acquire lock if it is free', async () => {
125 | expect(await lock.acquire()).to.be.true;
126 | });
127 | it('should not acquire lock if it is busy', async () => {
128 | await lock.acquire();
129 | expect(await lock.acquire()).to.be.false;
130 | });
131 | });
132 | describe('release()', () => {
133 | it('should release acquired lock', async () => {
134 | await lock.acquire();
135 | await lock.release();
136 | expect(lock.isAcquired()).to.be.false;
137 | });
138 | });
139 | describe('onRelease()', () => {
140 | it('should not allow set handler twice', () => {
141 | lock.onRelease(() => {/**/});
142 | expect(() => lock.onRelease(() => {/**/})).to.throw(Error);
143 | });
144 | it('should set notification event handler', () => {
145 | const spy = sinon.spy();
146 | lock.onRelease(spy);
147 | client.emit('notification', {
148 | channel: `__${PgIpLock.name}__:LockTest`,
149 | payload: '{"a":"b"}',
150 | });
151 | expect(spy.calledOnce).to.be.true;
152 | });
153 | });
154 | describe('Shutdown', () => {
155 | let sandbox: SinonSandbox;
156 | let destroy: any;
157 | let exit: any;
158 |
159 | beforeEach(() => {
160 | sandbox = sinon.createSandbox();
161 | destroy = sandbox.stub(PgIpLock, 'destroy').resolves();
162 | exit = sandbox.stub(process, 'exit');
163 | });
164 | afterEach(() => sandbox.restore());
165 |
166 | ['SIGINT', 'SIGTERM', 'SIGABRT'].forEach(SIGNAL => {
167 | describe(`gracefully on ${SIGNAL}`, () => {
168 | it(`should release lock`, done => {
169 | process.once(SIGNAL as any, () => {
170 | sinon.assert.calledOnce(destroy);
171 | done();
172 | });
173 | process.kill(process.pid, SIGNAL);
174 | });
175 | it('should exit after timeout', done => {
176 | process.once(SIGNAL as any, () => {
177 | sinon.assert.notCalled(exit);
178 | setTimeout(() => {
179 | sinon.assert.calledWith(exit, 0);
180 | done();
181 | }, SHUTDOWN_TIMEOUT + 10);
182 | });
183 | process.kill(process.pid, SIGNAL);
184 | });
185 | it(`should exit with error code`, done => {
186 | destroy.restore();
187 | sandbox.stub(lock, 'destroy').rejects(new FakeError());
188 | process.once(SIGNAL as any, () => {
189 | sinon.assert.notCalled(exit);
190 | setTimeout(() => {
191 | sinon.assert.calledWith(exit, 1);
192 | done();
193 | }, SHUTDOWN_TIMEOUT + 10);
194 | });
195 | process.kill(process.pid, SIGNAL);
196 | });
197 | });
198 | });
199 | });
200 | });
201 |
--------------------------------------------------------------------------------
/test/src/PgPubSub.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import '../mocks';
23 |
24 | import { expect } from 'chai';
25 | import { Client } from 'pg';
26 | import * as sinon from 'sinon';
27 | import { PgClient, PgIpLock, PgPubSub, RETRY_LIMIT } from '../../src';
28 |
29 | describe('PgPubSub', () => {
30 | let pgClient: Client;
31 | let pubSub: PgPubSub;
32 |
33 | const listenFunc = (pubSubCopy: PgPubSub) => {
34 | pubSubCopy.listen('TestChannel').then(() => {
35 | pgClient.emit('notification', {
36 | channel: 'TestChannel',
37 | payload: 'true',
38 | });
39 | });
40 | }
41 |
42 | beforeEach(() => {
43 | pgClient = new Client();
44 | pubSub = new PgPubSub({ pgClient });
45 | });
46 | afterEach(async () => pubSub.destroy());
47 |
48 | it('should be a class', () => {
49 | expect(typeof PgPubSub).equals('function');
50 | });
51 |
52 | describe('constructor()', () => {
53 | it('should accept pg client from options', () => {
54 | expect(pubSub.pgClient).equals(pgClient);
55 | });
56 | it('should construct pg client from options', () => {
57 | const ps = new PgPubSub({
58 | connectionString: 'postgres://user:pass@localhost:5432/dbname',
59 | });
60 | expect(ps.pgClient).instanceOf(Client);
61 | });
62 | it('should properly set events mapping', done => {
63 | pubSub.options.singleListener = false;
64 |
65 | const endSpy = sinon.spy();
66 | const messageSpy = sinon.spy();
67 | const errorSpy = sinon.spy();
68 |
69 | pubSub.on('end', endSpy);
70 | pubSub.on('message', messageSpy);
71 | pubSub.on('error', errorSpy);
72 |
73 | pgClient.emit('end');
74 | pgClient.emit('notification', {
75 | channel: 'test',
76 | payload: '"test"',
77 | });
78 | pgClient.emit('error');
79 |
80 | // because some events could be async
81 | setTimeout(() => {
82 | expect(endSpy.calledOnce).to.be.true;
83 | expect(messageSpy.calledOnce).to.be.true;
84 | expect(errorSpy.calledOnce).to.be.true;
85 |
86 | pubSub.options.singleListener = true;
87 |
88 | done();
89 | });
90 | });
91 | });
92 | describe('reconnect', () => {
93 | it('should support automatic reconnect', done => {
94 | let counter = 0;
95 |
96 | // emulate termination
97 | (pgClient as any).connect = () => {
98 | counter++;
99 | pgClient.emit('end');
100 | };
101 |
102 | pubSub.on('error', err => {
103 | expect(err.message).equals(
104 | `Connect failed after ${counter} retries...`,
105 | );
106 | done();
107 | });
108 |
109 | pubSub.connect().catch(() => { /**/ });
110 | });
111 | it('should fire connect event only once', done => {
112 | let connectCalls = 0;
113 |
114 | // emulate termination
115 | (pgClient as any).connect = () => {
116 | if (connectCalls < 1) {
117 | pgClient.emit('error');
118 | }
119 |
120 | else {
121 | pgClient.emit('connect');
122 | }
123 |
124 | connectCalls++;
125 | };
126 |
127 | // test will fail if done is called more than once
128 | pubSub.on('connect', done);
129 | pubSub.connect().catch(() => { /**/ });
130 | });
131 | it('should support automatic reconnect on errors', done => {
132 | let counter = 0;
133 |
134 | // emulate termination
135 | (pgClient as any).connect = () => {
136 | counter++;
137 | pgClient.emit('error');
138 | };
139 |
140 | pubSub.on('error', err => {
141 | if (err) {
142 | expect(err.message).equals(
143 | `Connect failed after ${counter} retries...`,
144 | );
145 | done();
146 | }
147 | });
148 |
149 | pubSub.connect().catch(() => { /* ignore faking errors */ });
150 | });
151 | it('should emit error and end if retry limit reached', async () => {
152 | // emulate connection failure
153 | (pgClient as any).connect = async () => {
154 | pgClient.emit('end');
155 | };
156 |
157 | try { await pubSub.connect(); } catch (err) {
158 | expect(err).to.be.instanceOf(Error);
159 | expect(err.message).equals(
160 | `Connect failed after ${RETRY_LIMIT} retries...`,
161 | );
162 | }
163 | });
164 | it('should re-subscribe all channels', done => {
165 | pubSub.listen('TestOne');
166 | pubSub.listen('TestTwo');
167 |
168 | const spy = sinon.spy(pubSub, 'listen');
169 |
170 | pubSub.connect().then(() => pgClient.emit('end'));
171 |
172 | setTimeout(() => {
173 | expect(spy.calledTwice).to.be.true;
174 | done();
175 | }, 30);
176 | });
177 | });
178 | describe('close()', () => {
179 | it('should not reconnect if called', async () => {
180 | let counter = 0;
181 |
182 | pubSub.on('connect', () => {
183 | counter++;
184 | pubSub.close();
185 | });
186 |
187 | await pubSub.connect();
188 |
189 | expect(counter).equals(1);
190 | });
191 | });
192 | describe('listen()', () => {
193 | it('should call SQL LISTEN "channel" command', async () => {
194 | pubSub.options.singleListener = true;
195 | const spy = sinon.spy(pubSub.pgClient, 'query');
196 | await pubSub.listen('Test');
197 | const [{ args: [arg] }] = spy.getCalls();
198 | expect(/^LISTEN\s+"Test"/.test(arg.trim()));
199 | });
200 | it('should call SQL LISTEN "channel" command always', async () => {
201 | pubSub.options.singleListener = false;
202 | const spy = sinon.spy(pubSub.pgClient, 'query');
203 | await pubSub.listen('Test');
204 | const [{ args: [arg] }] = spy.getCalls();
205 | expect(/^LISTEN\s+"Test"/.test(arg.trim()));
206 | });
207 | it('should handle messages from db with acquired lock', done => {
208 | pubSub.options.singleListener = true;
209 |
210 | listenFunc(pubSub);
211 |
212 | pubSub.on('message', (chanel, message) => {
213 | expect(chanel).equals('TestChannel');
214 | expect(message).equals(true);
215 | done();
216 | });
217 | });
218 | it('should not handle messages from db with no lock', async () => {
219 | pubSub.options.singleListener = true;
220 |
221 | const spy = sinon.spy(pubSub, 'emit');
222 |
223 | await pubSub.listen('TestChannel');
224 | await (pubSub as any).locks.TestChannel.release();
225 |
226 | pgClient.emit('notification', {
227 | channel: 'TestChannel',
228 | payload: 'true',
229 | });
230 |
231 | await new Promise(resolve => setTimeout(resolve, 20));
232 |
233 | expect(spy.calledWith('message', 'TestChannel', true)).to.be.false;
234 | });
235 | it('should avoid handling lock channel messages', async () => {
236 | pubSub.options.singleListener = true;
237 |
238 | const spy = sinon.spy(pubSub, 'emit');
239 | const spyChannel = sinon.spy(pubSub.channels, 'emit');
240 | const channel = `__${PgIpLock.name}__:TestChannel`;
241 |
242 | await pubSub.listen('TestChannel');
243 | pgClient.emit('notification', {
244 | channel,
245 | payload: 'true',
246 | });
247 |
248 | expect(spy.calledWithExactly(
249 | ['message', channel, true] as any,
250 | )).to.be.false;
251 | expect(spyChannel.called).to.be.false;
252 | });
253 | it('should handle messages from db with acquired execution '
254 | + 'lock', done => {
255 | pubSub = new PgPubSub({
256 | pgClient, executionLock: true, singleListener: true,
257 | });
258 |
259 | listenFunc(pubSub);
260 |
261 | pubSub.on('message', (chanel, message) => {
262 | expect(chanel).equals('TestChannel');
263 | expect(message).equals(true);
264 | done();
265 | });
266 | });
267 | it('should handle messages from db with acquired execution '
268 | + 'lock and multiple listeners', done => {
269 | pubSub = new PgPubSub({
270 | pgClient, executionLock: true, singleListener: false,
271 | });
272 |
273 | listenFunc(pubSub);
274 |
275 | pubSub.on('message', (chanel, message) => {
276 | expect(chanel).equals('TestChannel');
277 | expect(message).equals(true);
278 | done();
279 | });
280 | });
281 | });
282 | describe('unlisten()', () => {
283 | it('should call SQL UNLISTEN "channel" command', async () => {
284 | pubSub.options.singleListener = true;
285 | const spy = sinon.spy(pubSub.pgClient, 'query');
286 | await pubSub.unlisten('Test');
287 | const [{ args: [arg] }] = spy.getCalls();
288 | expect(/^UNLISTEN\s+"Test"/.test(arg.trim()));
289 | });
290 | it('should call SQL UNLISTEN "channel" command always', async () => {
291 | pubSub.options.singleListener = false;
292 | const spy = sinon.spy(pubSub.pgClient, 'query');
293 | await pubSub.unlisten('Test');
294 | const [{ args: [arg] }] = spy.getCalls();
295 | expect(/^UNLISTEN\s+"Test"/.test(arg.trim()));
296 | });
297 | it('should destroy existing locks', async () => {
298 | await pubSub.listen('Test');
299 | const spy = sinon.spy((pubSub as any).locks.Test, 'destroy');
300 | expect(spy.called).to.be.false;
301 | await pubSub.unlisten('Test');
302 | expect(spy.called).to.be.true;
303 | });
304 | });
305 | describe('unlistenAll()', () => {
306 | it('should call SQL UNLISTEN * command', async () => {
307 | pubSub.options.singleListener = true;
308 | const spy = sinon.spy(pubSub.pgClient, 'query');
309 | await pubSub.unlistenAll();
310 | const [{ args: [arg] }] = spy.getCalls();
311 | expect(/^UNLISTEN\s+\*/.test(arg.trim()));
312 | });
313 | it('should call SQL UNLISTEN * command always', async () => {
314 | pubSub.options.singleListener = false;
315 | const spy = sinon.spy(pubSub.pgClient, 'query');
316 | await pubSub.unlistenAll();
317 | const [{ args: [arg] }] = spy.getCalls();
318 | expect(/^UNLISTEN\s+\*/.test(arg.trim()));
319 | });
320 | });
321 | describe('notify()', () => {
322 | it('should call SQL NOTIFY command', async () => {
323 | const spy = sinon.spy(pubSub.pgClient, 'query');
324 | await pubSub.notify('Test', { a: 'b' });
325 | const [{ args: [arg, ] }] = spy.getCalls();
326 | expect(arg.trim()).equals(`NOTIFY "Test", '{"a":"b"}'`);
327 | });
328 | });
329 | describe('Channels API', () => {
330 | let pubSub1: PgPubSub;
331 | let pubSub2: PgPubSub;
332 | let pubSub3: PgPubSub;
333 |
334 | beforeEach(async () => {
335 | const pgClientShared = new Client() as PgClient;
336 |
337 | pubSub1 = new PgPubSub({ pgClient: pgClientShared });
338 | await pubSub1.connect();
339 | await pubSub1.listen('ChannelOne');
340 | await pubSub1.listen('ChannelTwo');
341 |
342 | pubSub2 = new PgPubSub({ pgClient: new Client() });
343 | await pubSub2.connect();
344 | await pubSub2.listen('ChannelThree');
345 | await pubSub2.listen('ChannelFour');
346 |
347 | pubSub3 = new PgPubSub({ pgClient: pgClientShared });
348 | await pubSub3.connect();
349 | await pubSub3.listen('ChannelFive');
350 | await pubSub3.listen('ChannelSix');
351 | await pubSub3.notify('ChannelOne', {});
352 | await pubSub3.notify('ChannelTwo', {});
353 |
354 | // make sure all async events handled
355 | await new Promise(resolve => setTimeout(resolve));
356 | });
357 | afterEach(async () => Promise.all([
358 | pubSub1.destroy(),
359 | pubSub2.destroy(),
360 | pubSub3.destroy(),
361 | ]));
362 |
363 | describe('activeChannels()', () => {
364 | it('should return active channels only', () => {
365 | expect(pubSub1.activeChannels()).to.have.same.members([
366 | 'ChannelOne', 'ChannelTwo',
367 | ]);
368 | expect(pubSub2.activeChannels()).to.have.same.members([
369 | 'ChannelThree', 'ChannelFour',
370 | ]);
371 | expect(pubSub3.activeChannels()).to.have.same.members([
372 | 'ChannelFive', 'ChannelSix',
373 | ]);
374 | });
375 | });
376 | describe('inactiveChannels()', () => {
377 | it('should return inactive channels only', () => {
378 | expect(pubSub1.inactiveChannels()).deep.equals([]);
379 | expect(pubSub2.inactiveChannels()).deep.equals([]);
380 | expect(pubSub3.inactiveChannels()).to.have.same.members([
381 | 'ChannelOne', 'ChannelTwo',
382 | ]);
383 | });
384 | });
385 | describe('allChannels()', () => {
386 | it('should return all channels', () => {
387 | expect(pubSub1.allChannels()).to.have.same.members([
388 | 'ChannelOne', 'ChannelTwo',
389 | ]);
390 | expect(pubSub2.allChannels()).to.have.same.members([
391 | 'ChannelThree', 'ChannelFour',
392 | ]);
393 | expect(pubSub3.allChannels()).to.have.same.members([
394 | 'ChannelOne', 'ChannelTwo',
395 | 'ChannelFive', 'ChannelSix',
396 | ]);
397 | });
398 | });
399 | describe('isActive()', () => {
400 | it('should return true if given channel is active', () => {
401 | expect(pubSub1.isActive('ChannelOne')).to.be.true;
402 | expect(pubSub1.isActive('ChannelTwo')).to.be.true;
403 | expect(pubSub2.isActive('ChannelThree')).to.be.true;
404 | expect(pubSub2.isActive('ChannelFour')).to.be.true;
405 | expect(pubSub3.isActive('ChannelFive')).to.be.true;
406 | expect(pubSub3.isActive('ChannelSix')).to.be.true;
407 | });
408 | it('should return false if given channel is not active', () => {
409 | expect(pubSub1.isActive('ChannelThree')).to.be.false;
410 | expect(pubSub1.isActive('ChannelFour')).to.be.false;
411 | });
412 | it('should return true if there is active channels', () => {
413 | expect(pubSub1.isActive()).to.be.true;
414 | expect(pubSub2.isActive()).to.be.true;
415 | expect(pubSub3.isActive()).to.be.true;
416 | });
417 | it('should return false if there are no active channels', () => {
418 | expect(pubSub.isActive()).to.be.false;
419 | });
420 | });
421 | });
422 | describe('release()', () => {
423 | it('should release all locks acquired', async () => {
424 | await pubSub.listen('One');
425 | await pubSub.listen('Two');
426 |
427 | const spies = [
428 | sinon.spy((pubSub as any).locks.One, 'release'),
429 | sinon.spy((pubSub as any).locks.Two, 'release'),
430 | ];
431 |
432 | await (pubSub as any).release();
433 | spies.forEach(spy => expect(spy.called).to.be.true);
434 | });
435 | it('should skip locks which was not acquired', async () => {
436 | await pubSub.listen('One');
437 | await pubSub.listen('Two');
438 |
439 | await (pubSub as any).locks.One.release();
440 | await (pubSub as any).locks.Two.release();
441 |
442 | const spies = [
443 | sinon.spy((pubSub as any).locks.One, 'release'),
444 | sinon.spy((pubSub as any).locks.Two, 'release'),
445 | ];
446 |
447 | await (pubSub as any).release();
448 | spies.forEach(spy => expect(spy.called).to.be.false);
449 | });
450 | it('should release only acquired locks', async () => {
451 | await pubSub.listen('One');
452 | await pubSub.listen('Two');
453 |
454 | await (pubSub as any).locks.One.release();
455 |
456 | const [one, two] = [
457 | sinon.spy((pubSub as any).locks.One, 'release'),
458 | sinon.spy((pubSub as any).locks.Two, 'release'),
459 | ];
460 |
461 | await (pubSub as any).release();
462 |
463 | expect(one.called).to.be.false;
464 | expect(two.called).to.be.true;
465 | });
466 | });
467 | describe('setProcessId()', () => {
468 | it('should set process id', async () => {
469 | const stub = sinon.stub(pgClient, 'query').resolves({
470 | rows: [{ pid: 7777 }],
471 | });
472 | await (pubSub as any).setProcessId();
473 | expect((pubSub as any).processId).equals(7777);
474 | stub.restore();
475 | });
476 | it('should filter messages if set and "filtered" option is set',
477 | async () => {
478 | pubSub.options.singleListener = false;
479 | pubSub.options.filtered = false;
480 | (pubSub as any).processId = 7777;
481 |
482 | await pubSub.listen('Test');
483 | let counter = 0;
484 |
485 | pubSub.channels.on('Test', () => ++counter);
486 | pgClient.emit('notification', {
487 | processId: 7777,
488 | channel: 'Test',
489 | payload: 'true',
490 | });
491 |
492 | await new Promise(res => setTimeout(res));
493 |
494 | expect(counter).equals(1);
495 |
496 | pubSub.options.filtered = true;
497 | pgClient.emit('notification', {
498 | processId: 7777,
499 | channel: 'Test',
500 | payload: 'true',
501 | });
502 |
503 | await new Promise(res => setTimeout(res));
504 |
505 | expect(counter).equals(1);
506 | });
507 | it('should filter messages if set and "filtered" option is set and'
508 | + ' execution lock is set', async () => {
509 | const pubSubCopy = new PgPubSub({
510 | singleListener: false,
511 | filtered: false,
512 | executionLock: true,
513 | pgClient,
514 | });
515 | (pubSubCopy as any).processId = 7777;
516 |
517 | await pubSubCopy.listen('Test');
518 | let counter = 0;
519 |
520 | pubSubCopy.channels.on('Test', () => ++counter);
521 | pgClient.emit('notification', {
522 | processId: 7777,
523 | channel: 'Test',
524 | payload: 'true',
525 | });
526 |
527 | await new Promise(res => setTimeout(res));
528 |
529 | expect(counter).equals(1);
530 |
531 | pubSubCopy.options.filtered = true;
532 | pgClient.emit('notification', {
533 | processId: 7777,
534 | channel: 'Test',
535 | payload: 'true',
536 | });
537 |
538 | await new Promise(res => setTimeout(res));
539 |
540 | expect(counter).equals(1);
541 | await pubSub.destroy();
542 | });
543 | });
544 | describe('destroy()', () => {
545 | it('should properly handle destruction', async () => {
546 | const spies = [
547 | sinon.spy(pubSub, 'close'),
548 | sinon.spy(pubSub, 'removeAllListeners'),
549 | sinon.spy(pubSub.channels, 'removeAllListeners'),
550 | sinon.spy(PgIpLock, 'destroy'),
551 | ];
552 | await pubSub.destroy();
553 | spies.forEach(spy => {
554 | expect(spy.calledOnce).to.be.true;
555 | spy.restore();
556 | });
557 | });
558 | });
559 | });
560 |
--------------------------------------------------------------------------------
/test/src/helpers.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * I'm Queue Software Project
3 | * Copyright (C) 2025 imqueue.com
4 | *
5 | * This program is free software: you can redistribute it and/or modify
6 | * it under the terms of the GNU General Public License as published by
7 | * the Free Software Foundation, either version 3 of the License, or
8 | * (at your option) any later version.
9 | *
10 | * This program is distributed in the hope that it will be useful,
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | * GNU General Public License for more details.
14 | *
15 | * You should have received a copy of the GNU General Public License
16 | * along with this program. If not, see .
17 | *
18 | * If you want to use this code in a closed source (commercial) project, you can
19 | * purchase a proprietary commercial license. Please contact us at
20 | * to get commercial licensing options.
21 | */
22 | import '../mocks';
23 |
24 | import { expect } from 'chai';
25 | import * as sinon from 'sinon';
26 | import { AnyLogger, pack, unpack } from '../..';
27 |
28 | describe('helpers', () => {
29 | // mock logger
30 | const logger: AnyLogger = {
31 | log: (...args: any[]) => console.log(...args),
32 | info: (...args: any[]) => console.info(...args),
33 | warn: (...args: any[]) => console.warn(...args),
34 | error: (...args: any[]) => console.error(...args),
35 | };
36 |
37 | describe('pack()', () => {
38 | it('should not throw, but log warn on serialization error', () => {
39 | const spy = sinon.stub(logger, 'warn');
40 | expect(() => pack(global as any)).to.not.throw;
41 | pack(global as any, logger);
42 | expect(spy.called).to.be.true;
43 | spy.restore();
44 | });
45 | it('should return serialized null value on error', () => {
46 | expect(pack(global as any)).equals('null');
47 | });
48 | it('should correctly pack serializable', () => {
49 | expect(pack({})).equals('{}');
50 | expect(pack([])).equals('[]');
51 | expect(pack({a: 1})).equals('{"a":1}');
52 | expect(pack({a: '1'})).equals('{"a":"1"}');
53 | expect(pack(null)).equals('null');
54 | expect(pack(undefined as any)).equals('null');
55 | expect(pack(true)).equals('true');
56 | });
57 | it('should be able to pretty print', () => {
58 | const obj = { one: { two: 'three' } };
59 | expect(pack(obj, logger, true)).equals(
60 | `{\n "one": {\n "two": "three"\n }\n}`
61 | );
62 | });
63 | });
64 |
65 | describe('unpack()', () => {
66 | it('should not throw, but log warn on deserialization', () => {
67 | const spy = sinon.stub(logger, 'warn');
68 | expect(() => unpack('unterminated string')).to.not.throw;
69 | unpack('unterminated string', logger);
70 | expect(spy.called).to.be.true;
71 | spy.restore();
72 | });
73 | it('should return empty object on error', () => {
74 | expect(unpack('unterminated string')).deep.equals({});
75 | });
76 | it('should properly unpack serializable', () => {
77 | expect(unpack('{}')).deep.equals({});
78 | expect(unpack('[]')).deep.equals([]);
79 | expect(unpack('{"a":1}')).deep.equals({a: 1});
80 | expect(unpack('{"a":"1"}')).deep.equals({ a: '1'});
81 | expect(unpack('null')).equals(null);
82 | expect(unpack('true')).equals(true);
83 | expect(unpack('123.55')).equals(123.55);
84 | });
85 | it('should return null on non-string or undefined input', () => {
86 | expect(unpack()).to.be.null;
87 | expect(unpack(global as any)).to.be.null;
88 | });
89 | });
90 | });
91 |
--------------------------------------------------------------------------------
/test/src/index.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable:ordered-imports */
2 | /*!
3 | * I'm Queue Software Project
4 | * Copyright (C) 2025 imqueue.com
5 | *
6 | * This program is free software: you can redistribute it and/or modify
7 | * it under the terms of the GNU General Public License as published by
8 | * the Free Software Foundation, either version 3 of the License, or
9 | * (at your option) any later version.
10 | *
11 | * This program is distributed in the hope that it will be useful,
12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | * GNU General Public License for more details.
15 | *
16 | * You should have received a copy of the GNU General Public License
17 | * along with this program. If not, see .
18 | *
19 | * If you want to use this code in a closed source (commercial) project, you can
20 | * purchase a proprietary commercial license. Please contact us at
21 | * to get commercial licensing options.
22 | */
23 | import '../mocks';
24 | import './helpers';
25 | import './PgPubSub';
26 | import './PgIpLock';
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "noImplicitAny": true,
6 | "strictNullChecks": true,
7 | "removeComments": false,
8 | "noUnusedLocals": false,
9 | "noUnusedParameters": false,
10 | "moduleResolution": "node",
11 | "sourceMap": true,
12 | "inlineSources": true,
13 | "experimentalDecorators": true,
14 | "emitDecoratorMetadata": true,
15 | "esModuleInterop": false,
16 | "resolveJsonModule": true,
17 | "target": "esnext",
18 | "lib": [
19 | "dom",
20 | "esnext",
21 | "esnext.asynciterable"
22 | ]
23 | }
24 | }
25 |
--------------------------------------------------------------------------------