├── .codebeatignore ├── .codebeatsettings ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── bin ├── rename.js └── wiki.sh ├── eslint.config.mjs ├── index.ts ├── package-lock.json ├── package.json ├── src ├── NoLock.ts ├── PgChannelEmitter.ts ├── PgIpLock.ts ├── PgPubSub.ts ├── constants.ts ├── helpers.ts ├── index.ts └── types │ ├── AnyJson.ts │ ├── AnyLock.ts │ ├── AnyLogger.ts │ ├── PgClient.ts │ ├── PgIpLockOptions.ts │ ├── PgPubSubOptions.ts │ ├── events.ts │ └── index.ts ├── test ├── index.ts ├── mocha.opts ├── mocks │ ├── constants.ts │ ├── index.ts │ └── pg.ts └── src │ ├── PgIpLock.ts │ ├── PgPubSub.ts │ ├── helpers.ts │ └── index.ts └── tsconfig.json /.codebeatignore: -------------------------------------------------------------------------------- 1 | src/test/** 2 | -------------------------------------------------------------------------------- /.codebeatsettings: -------------------------------------------------------------------------------- 1 | { 2 | "TYPESCRIPT": { 3 | "TOTAL_LOC": [250, 500, 750, 1000], 4 | "TOO_MANY_FUNCTIONS": [25, 30, 35, 40], 5 | "TOTAL_COMPLEXITY": [100, 180, 280, 400] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [lts/*] 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: Install dependencies 25 | run: npm install 26 | 27 | - name: Run tests 28 | run: npm test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ssh/ 2 | dist/ 3 | tmp/ 4 | out-tsc/ 5 | build/ 6 | .nyc_output/ 7 | node_modules/ 8 | .idea/ 9 | .project 10 | .classpath 11 | .c9/ 12 | *.launch 13 | .settings/ 14 | *.sublime-workspace 15 | .vscode/* 16 | .sass-cache/ 17 | connect.lock/ 18 | coverage/ 19 | typings/ 20 | docs/ 21 | wiki/ 22 | pg-pubsub.wiki 23 | debug* 24 | *.txt 25 | *.js 26 | !bin/*.js 27 | *.d.ts 28 | *.js.map 29 | *.pid 30 | *.log 31 | *.swp 32 | *.tgz 33 | .env 34 | .eslintrc 35 | .editorconfig 36 | .gitlab-ci.yml 37 | .DS_Store 38 | Thumbs.db 39 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .github 3 | .travis.yml 4 | .dockerignore 5 | .ssh/ 6 | .nyc_output/ 7 | .idea/ 8 | .project 9 | .classpath 10 | .c9/ 11 | .settings/ 12 | .vscode/ 13 | .sass-cache/ 14 | node_modules/ 15 | dist/ 16 | tmp/ 17 | out-tsc/ 18 | build/ 19 | connect.lock/ 20 | coverage/ 21 | typings/ 22 | docs/ 23 | wiki/ 24 | test/ 25 | debug* 26 | *.sublime-workspace 27 | *.launch 28 | *.pid 29 | *.log 30 | *.js.map 31 | *.ts 32 | *.tgz 33 | !*.d.ts 34 | debug*.d.ts 35 | .DS_Store 36 | .env 37 | .eslintrc 38 | .editorconfig 39 | .gitlab-ci.yml 40 | Dockerfile 41 | Thumbs.db 42 | tsconfig.json 43 | tslint.json 44 | .codebeatsettings 45 | .codebeatignore 46 | .eslintrc.json 47 | bin/ 48 | .git/ 49 | examples/ 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright © 2007 Free Software Foundation, Inc. 5 | 6 | Everyone is permitted to copy and distribute verbatim copies of this license 7 | document, but changing it is not allowed. 8 | 9 | Preamble 10 | The GNU General Public License is a free, copyleft license for software and 11 | other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed to take 14 | away your freedom to share and change the works. By contrast, the GNU General 15 | Public License is intended to guarantee your freedom to share and change all 16 | versions of a program--to make sure it remains free software for all its users. 17 | We, the Free Software Foundation, use the GNU General Public License for most of 18 | our software; it applies also to any other work released this way by its 19 | authors. You can apply it to your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not price. Our 22 | General Public Licenses are designed to make sure that you have the freedom to 23 | distribute copies of free software (and charge for them if you wish), that you 24 | receive source code or can get it if you want it, that you can change the 25 | software or use pieces of it in new free programs, and that you know you can 26 | do these things. 27 | 28 | To protect your rights, we need to prevent others from denying you these rights 29 | or asking you to surrender the rights. Therefore, you have certain 30 | responsibilities if you distribute copies of the software, or if you modify it: 31 | responsibilities to respect the freedom of others. 32 | 33 | For example, if you distribute copies of such a program, whether gratis or for a 34 | fee, you must pass on to the recipients the same freedoms that you received. You 35 | must make sure that they, too, receive or can get the source code. And you must 36 | show them these terms so they know their rights. 37 | 38 | Developers that use the GNU GPL protect your rights with two steps: (1) assert 39 | copyright on the software, and (2) offer you this License giving you legal 40 | permission to copy, distribute and/or modify it. 41 | 42 | For the developers' and authors' protection, the GPL clearly explains that there 43 | is no warranty for this free software. For both users' and authors' sake, the 44 | GPL requires that modified versions be marked as changed, so that their problems 45 | will not be attributed erroneously to authors of previous versions. 46 | 47 | Some devices are designed to deny users access to install or run modified 48 | versions of the software inside them, although the manufacturer can do so. This 49 | is fundamentally incompatible with the aim of protecting users' freedom to 50 | change the software. The systematic pattern of such abuse occurs in the area of 51 | products for individuals to use, which is precisely where it is most 52 | unacceptable. Therefore, we have designed this version of the GPL to prohibit 53 | the practice for those products. If such problems arise substantially in other 54 | domains, we stand ready to extend this provision to those domains in future 55 | versions of the GPL, as needed to protect the freedom of users. 56 | 57 | Finally, every program is threatened constantly by software patents. States 58 | should not allow patents to restrict development and use of software on 59 | general-purpose computers, but in those that do, we wish to avoid the special 60 | danger that patents applied to a free program could make it effectively 61 | proprietary. To prevent this, the GPL assures that patents cannot be used to 62 | render the program non-free. 63 | 64 | The precise terms and conditions for copying, distribution and modification 65 | follow. 66 | 67 | TERMS AND CONDITIONS 68 | 0. Definitions. 69 | “This License” refers to version 3 of the GNU General Public License. 70 | 71 | “Copyright” also means copyright-like laws that apply to other kinds of works, 72 | such as semiconductor masks. 73 | 74 | “The Program” refers to any copyrightable work licensed under this License. Each 75 | licensee is addressed as “you”. “Licensees” and “recipients” may be individuals 76 | or organizations. 77 | 78 | To “modify” a work means to copy from or adapt all or part of the work in a 79 | fashion requiring copyright permission, other than the making of an exact copy. 80 | The resulting work is called a “modified version” of the earlier work or a work 81 | “based on” the earlier work. 82 | 83 | A “covered work” means either the unmodified Program or a work based on the 84 | Program. 85 | 86 | To “propagate” a work means to do anything with it that, without permission, 87 | would make you directly or secondarily liable for infringement under applicable 88 | copyright law, except executing it on a computer or modifying a private copy. 89 | Propagation includes copying, distribution (with or without modification), 90 | making available to the public, and in some countries other activities as well. 91 | 92 | To “convey” a work means any kind of propagation that enables other parties to 93 | make or receive copies. Mere interaction with a user through a computer network, 94 | with no transfer of a copy, is not conveying. 95 | 96 | An interactive user interface displays “Appropriate Legal Notices” to the extent 97 | that it includes a convenient and prominently visible feature that (1) displays 98 | an appropriate copyright notice, and (2) tells the user that there is no 99 | warranty for the work (except to the extent that warranties are provided), that 100 | licensees may convey the work under this License, and how to view a copy of this 101 | License. If the interface presents a list of user commands or options, such as a 102 | menu, a prominent item in the list meets this criterion. 103 | 104 | 1. Source Code. 105 | The “source code” for a work means the preferred form of the work for making 106 | modifications to it. “Object code” means any non-source form of a work. 107 | 108 | A “Standard Interface” means an interface that either is an official standard 109 | defined by a recognized standards body, or, in the case of interfaces specified 110 | for a particular programming language, one that is widely used among developers 111 | working in that language. 112 | 113 | The “System Libraries” of an executable work include anything, other than the 114 | work as a whole, that (a) is included in the normal form of packaging a Major 115 | Component, but which is not part of that Major Component, and (b) serves only to 116 | enable use of the work with that Major Component, or to implement a Standard 117 | Interface for which an implementation is available to the public in source code 118 | form. A “Major Component”, in this context, means a major essential component 119 | (kernel, window system, and so on) of the specific operating system (if any) on 120 | which the executable work runs, or a compiler used to produce the work, or an 121 | object code interpreter used to run it. 122 | 123 | The “Corresponding Source” for a work in object code form means all the source 124 | code needed to generate, install, and (for an executable work) run the object 125 | code and to modify the work, including scripts to control those activities. 126 | However, it does not include the work's System Libraries, or general-purpose 127 | tools or generally available free programs which are used unmodified in 128 | performing those activities but which are not part of the work. For example, 129 | Corresponding Source includes interface definition files associated with source 130 | files for the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, such as by 132 | intimate data communication or control flow between those subprograms and other 133 | parts of the work. 134 | 135 | The Corresponding Source need not include anything that users can regenerate 136 | automatically from other parts of the Corresponding Source. 137 | 138 | The Corresponding Source for a work in source code form is that same work. 139 | 140 | 2. Basic Permissions. 141 | All rights granted under this License are granted for the term of copyright on 142 | the Program, and are irrevocable provided the stated conditions are met. This 143 | License explicitly affirms your unlimited permission to run the unmodified 144 | Program. The output from running a covered work is covered by this License only 145 | if the output, given its content, constitutes a covered work. This License 146 | acknowledges your rights of fair use or other equivalent, as provided by 147 | copyright law. 148 | 149 | You may make, run and propagate covered works that you do not convey, without 150 | conditions so long as your license otherwise remains in force. You may convey 151 | covered works to others for the sole purpose of having them make modifications 152 | exclusively for you, or provide you with facilities for running those works, 153 | provided that you comply with the terms of this License in conveying all 154 | material for which you do not control copyright. Those thus making or running 155 | the covered works for you must do so exclusively on your behalf, under your 156 | direction and control, on terms that prohibit them from making any copies of 157 | your copyrighted material outside their relationship with you. 158 | 159 | Conveying under any other circumstances is permitted solely under the conditions 160 | stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 161 | 162 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 163 | No covered work shall be deemed part of an effective technological measure under 164 | any applicable law fulfilling obligations under article 11 of the WIPO copyright 165 | treaty adopted on 20 December 1996, or similar laws prohibiting or restricting 166 | circumvention of such measures. 167 | 168 | When you convey a covered work, you waive any legal power to forbid 169 | circumvention of technological measures to the extent such circumvention is 170 | effected by exercising rights under this License with respect to the covered 171 | work, and you disclaim any intention to limit operation or modification of the 172 | work as a means of enforcing, against the work's users, your or third parties' 173 | legal rights to forbid circumvention of technological measures. 174 | 175 | 4. Conveying Verbatim Copies. 176 | You may convey verbatim copies of the Program's source code as you receive it, 177 | in any medium, provided that you conspicuously and appropriately publish on each 178 | copy an appropriate copyright notice; keep intact all notices stating that this 179 | License and any non-permissive terms added in accord with section 7 apply to the 180 | code; keep intact all notices of the absence of any warranty; and give all 181 | recipients a copy of this License along with the Program. 182 | 183 | You may charge any price or no price for each copy that you convey, and you may 184 | offer support or warranty protection for a fee. 185 | 186 | 5. Conveying Modified Source Versions. 187 | You may convey a work based on the Program, or the modifications to produce it 188 | from the Program, in the form of source code under the terms of section 4, 189 | provided that you also meet all of these conditions: 190 | 191 | a) The work must carry prominent notices stating that you modified it, and 192 | giving a relevant date. 193 | b) The work must carry prominent notices stating that it is released under this 194 | License and any conditions added under section 7. This requirement modifies 195 | the requirement in section 4 to “keep intact all notices”. 196 | c) You must license the entire work, as a whole, under this License to anyone 197 | who comes into possession of a copy. This License will therefore apply, along 198 | with any applicable section 7 additional terms, to the whole of the work, 199 | and all its parts, regardless of how they are packaged. This License gives no 200 | permission to license the work in any other way, but it does not invalidate 201 | such permission if you have separately received it. 202 | d) If the work has interactive user interfaces, each must display Appropriate 203 | Legal Notices; however, if the Program has interactive interfaces that do not 204 | display Appropriate Legal Notices, your work need not make them do so. 205 | A compilation of a covered work with other separate and independent works,which 206 | are not by their nature extensions of the covered work, and which are not 207 | combined with it such as to form a larger program, in or on a volume of a 208 | storage or distribution medium, is called an “aggregate” if the compilation and 209 | its resulting copyright are not used to limit the access or legal rights of the 210 | compilation's users beyond what the individual works permit. Inclusion of a 211 | covered work in an aggregate does not cause this License to apply to the other 212 | parts of the aggregate. 213 | 214 | 6. Conveying Non-Source Forms. 215 | You may convey a covered work in object code form under the terms of sections 4 216 | and 5, provided that you also convey the machine-readable Corresponding Source 217 | under the terms of this License, in one of these ways: 218 | 219 | a) Convey the object code in, or embodied in, a physical product (including a 220 | physical distribution medium), accompanied by the Corresponding Source fixed 221 | on a durable physical medium customarily used for software interchange. 222 | b) Convey the object code in, or embodied in, a physical product (including a 223 | physical distribution medium), accompanied by a written offer, valid for at 224 | least three years and valid for as long as you offer spare parts or customer 225 | support for that product model, to give anyone who possesses the object code 226 | either (1) a copy of the Corresponding Source for all the software in the 227 | product that is covered by this License, on a durable physical medium 228 | customarily used for software interchange, for a price no more than your 229 | reasonable cost of physically performing this conveying of source, or (2) 230 | access to copy the Corresponding Source from a network server at no charge. 231 | c) Convey individual copies of the object code with a copy of the written offer 232 | to provide the Corresponding Source. This alternative is allowed only 233 | occasionally and noncommercially, and only if you received the object code 234 | with such an offer, in accord with subsection 6b. 235 | d) Convey the object code by offering access from a designated place (gratis or 236 | for a charge), and offer equivalent access to the Corresponding Source in the 237 | same way through the same place at no further charge. You need not require 238 | recipients to copy the Corresponding Source along with the object code. If 239 | the place to copy the object code is a network server, the Corresponding 240 | Source may be on a different server (operated by you or a third party) that 241 | supports equivalent copying facilities, provided you maintain clear 242 | directions next to the object code saying where to find the Corresponding 243 | Source. Regardless of what server hosts the Corresponding Source, you remain 244 | obligated to ensure that it is available for as long as needed to satisfy 245 | these requirements. 246 | e) Convey the object code using peer-to-peer transmission, provided you inform 247 | other peers where the object code and Corresponding Source of the work are 248 | being offered to the general public at no charge under subsection 6d. 249 | A separable portion of the object code, whose source code is excluded from the 250 | Corresponding Source as a System Library, need not be included in conveying the 251 | object code work. 252 | 253 | A “User Product” is either (1) a “consumer product”, which means any tangible 254 | personal property which is normally used for personal, family, or household 255 | purposes, or (2) anything designed or sold for incorporation into a dwelling. 256 | In determining whether a product is a consumer product, doubtful cases shall be 257 | resolved in favor of coverage. For a particular product received by a particular 258 | user, “normally used” refers to a typical or common use of that class of 259 | product, regardless of the status of the particular user or of the way in which 260 | the particular user actually uses, or expects or is expected to use, the 261 | product. A product is a consumer product regardless of whether the product has 262 | substantial commercial, industrial or non-consumer uses, unless such uses 263 | represent the only significant mode of use of the product. 264 | 265 | “Installation Information” for a User Product means any methods, procedures, 266 | authorization keys, or other information required to install and execute 267 | modified versions of a covered work in that User Product from a modified version 268 | of its Corresponding Source. The information must suffice to ensure that the 269 | continued functioning of the modified object code is in no case prevented or 270 | interfered with solely because modification has been made. 271 | 272 | If you convey an object code work under this section in, or with, or 273 | specifically for use in, a User Product, and the conveying occurs as part of a 274 | transaction in which the right of possession and use of the User Product is 275 | transferred to the recipient in perpetuity or for a fixed term (regardless of 276 | how the transaction is characterized), the Corresponding Source conveyed under 277 | this section must be accompanied by the Installation Information. But this 278 | requirement does not apply if neither you nor any third party retains the 279 | ability to install modified object code on the User Product (for example, the 280 | work has been installed in ROM). 281 | 282 | The requirement to provide Installation Information does not include a 283 | requirement to continue to provide support service, warranty, or updates for a 284 | work that has been modified or installed by the recipient, or for the User 285 | Product in which it has been modified or installed. Access to a network may be 286 | denied when the modification itself materially and adversely affects the 287 | operation of the network or violates the rules and protocols for communication 288 | across the network. 289 | 290 | Corresponding Source conveyed, and Installation Information provided, in accord 291 | with this section must be in a format that is publicly documented (and with an 292 | implementation available to the public in source code form), and must require no 293 | special password or key for unpacking, reading or copying. 294 | 295 | 7. Additional Terms. 296 | “Additional permissions” are terms that supplement the terms of this License by 297 | making exceptions from one or more of its conditions. Additional permissions 298 | that are applicable to the entire Program shall be treated as though they were 299 | included in this License, to the extent that they are valid under applicable 300 | law. If additional permissions apply only to part of the Program, that part may 301 | be used separately under those permissions, but the entire Program remains 302 | governed by this License without regard to the additional permissions. 303 | 304 | When you convey a copy of a covered work, you may at your option remove any 305 | additional permissions from that copy, or from any part of it. (Additional 306 | permissions may be written to require their own removal in certain cases when 307 | you modify the work.) You may place additional permissions on material, added by 308 | you to a covered work, for which you have or can give appropriate copyright 309 | permission. 310 | 311 | Notwithstanding any other provision of this License, for material you add to a 312 | covered work, you may (if authorized by the copyright holders of that material) 313 | supplement the terms of this License with terms: 314 | 315 | a) Disclaiming warranty or limiting liability differently from the terms of 316 | sections 15 and 16 of this License; or 317 | b) Requiring preservation of specified reasonable legal notices or author 318 | attributions in that material or in the Appropriate Legal Notices displayed 319 | by works containing it; or 320 | c) Prohibiting misrepresentation of the origin of that material, or requiring 321 | that modified versions of such material be marked in reasonable ways as 322 | different from the original version; or 323 | d) Limiting the use for publicity purposes of names of licensors or authors of 324 | the material; or 325 | e) Declining to grant rights under trademark law for use of some trade names, 326 | trademarks, or service marks; or 327 | f) Requiring indemnification of licensors and authors of that material by anyone 328 | who conveys the material (or modified versions of it) with contractual 329 | assumptions of liability to the recipient, for any liability that these 330 | contractual assumptions directly impose on those licensors and authors. 331 | All other non-permissive additional terms are considered “further restrictions” 332 | within the meaning of section 10. If the Program as you received it, or any part 333 | of it, contains a notice stating that it is governed by this License along with 334 | a term that is a further restriction, you may remove that term. If a license 335 | document contains a further restriction but permits relicensing or conveying 336 | under this License, you may add to a covered work material governed by the terms 337 | of that license document, provided that the further restriction does not survive 338 | such relicensing or conveying. 339 | 340 | If you add terms to a covered work in accord with this section, you must place, 341 | in the relevant source files, a statement of the additional terms that apply to 342 | those files, or a notice indicating where to find the applicable terms. 343 | 344 | Additional terms, permissive or non-permissive, may be stated in the form of a 345 | separately written license, or stated as exceptions; the above requirements 346 | apply either way. 347 | 348 | 8. Termination. 349 | You may not propagate or modify a covered work except as expressly provided 350 | under this License. Any attempt otherwise to propagate or modify it is void, and 351 | will automatically terminate your rights under this License (including any 352 | patent licenses granted under the third paragraph of section 11). 353 | 354 | However, if you cease all violation of this License, then your license from a 355 | particular copyright holder is reinstated (a) provisionally, unless and until 356 | the copyright holder explicitly and finally terminates your license, and (b) 357 | permanently, if the copyright holder fails to notify you of the violation by 358 | some reasonable means prior to 60 days after the cessation. 359 | 360 | Moreover, your license from a particular copyright holder is reinstated 361 | permanently if the copyright holder notifies you of the violation by some 362 | reasonable means, this is the first time you have received notice of violation 363 | of this License (for any work) from that copyright holder, and you cure the 364 | violation prior to 30 days after your receipt of the notice. 365 | 366 | Termination of your rights under this section does not terminate the licenses of 367 | parties who have received copies or rights from you under this License. If your 368 | rights have been terminated and not permanently reinstated, you do not qualify 369 | to receive new licenses for the same material under section 10. 370 | 371 | 9. Acceptance Not Required for Having Copies. 372 | You are not required to accept this License in order to receive or run a copy of 373 | the Program. Ancillary propagation of a covered work occurring solely as a 374 | consequence of using peer-to-peer transmission to receive a copy likewise does 375 | not require acceptance. However, nothing other than this License grants you 376 | permission to propagate or modify any covered work. These actions infringe 377 | copyright if you do not accept this License. Therefore, by modifying or 378 | propagating a covered work, you indicate your acceptance of this License to do 379 | so. 380 | 381 | 10. Automatic Licensing of Downstream Recipients. 382 | Each time you convey a covered work, the recipient automatically receives a 383 | license from the original licensors, to run, modify and propagate that work, 384 | subject to this License. You are not responsible for enforcing compliance by 385 | third parties with this License. 386 | 387 | An “entity transaction” is a transaction transferring control of an 388 | organization, or substantially all assets of one, or subdividing an 389 | organization, or merging organizations. If propagation of a covered work results 390 | from an entity transaction, each party to that transaction who receives a copy 391 | of the work also receives whatever licenses to the work the party's predecessor 392 | in interest had or could give under the previous paragraph, plus a right to 393 | possession of the Corresponding Source of the work from the predecessor in 394 | interest, if the predecessor has it or can get it with reasonable efforts. 395 | 396 | You may not impose any further restrictions on the exercise of the rights 397 | granted or affirmed under this License. For example, you may not impose a 398 | license fee, royalty, or other charge for exercise of rights granted under this 399 | License, and you may not initiate litigation (including a cross-claim or 400 | counterclaim in a lawsuit) alleging that any patent claim is infringed by 401 | making, using, selling, offering for sale, or importing the Program or any 402 | portion of it. 403 | 404 | 11. Patents. 405 | A “contributor” is a copyright holder who authorizes use under this License of 406 | the Program or a work on which the Program is based. The work thus licensed is 407 | called the contributor's “contributor version”. 408 | 409 | A contributor's “essential patent claims” are all patent claims owned or 410 | controlled by the contributor, whether already acquired or hereafter acquired, 411 | that would be infringed by some manner, permitted by this License, of making, 412 | using, or selling its contributor version, but do not include claims that would 413 | be infringed only as a consequence of further modification of the contributor 414 | version. For purposes of this definition, “control” includes the right to grant 415 | patent sublicenses in a manner consistent with the requirements of this License. 416 | 417 | Each contributor grants you a non-exclusive, worldwide, royalty-free patent 418 | license under the contributor's essential patent claims, to make, use, sell, 419 | offer for sale, import and otherwise run, modify and propagate the contents of 420 | its contributor version. 421 | 422 | In the following three paragraphs, a “patent license” is any express agreement 423 | or commitment, however denominated, not to enforce a patent (such as an express 424 | permission to practice a patent or covenant not to sue for patent infringement). 425 | To “grant” such a patent license to a party means to make such an agreement or 426 | commitment not to enforce a patent against the party. 427 | 428 | If you convey a covered work, knowingly relying on a patent license, and the 429 | Corresponding Source of the work is not available for anyone to copy, free of 430 | charge and under the terms of this License, through a publicly available network 431 | server or other readily accessible means, then you must either (1) cause the 432 | Corresponding Source to be so available, or (2) arrange to deprive yourself of 433 | the benefit of the patent license for this particular work, or (3) arrange, in a 434 | manner consistent with the requirements of this License, to extend the patent 435 | license to downstream recipients. “Knowingly relying” means you have actual 436 | knowledge that, but for the patent license, your conveying the covered work in a 437 | country, or your recipient's use of the covered work in a country, would 438 | infringe one or more identifiable patents in that country that you have reason 439 | to believe are valid. 440 | 441 | If, pursuant to or in connection with a single transaction or arrangement, you 442 | convey, or propagate by procuring conveyance of, a covered work, and grant a 443 | patent license to some of the parties receiving the covered work authorizing 444 | them to use, propagate, modify or convey a specific copy of the covered work, 445 | then the patent license you grant is automatically extended to all recipients of 446 | the covered work and works based on it. 447 | 448 | A patent license is “discriminatory” if it does not include within the scope of 449 | its coverage, prohibits the exercise of, or is conditioned on the non-exercise 450 | of one or more of the rights that are specifically granted under this License. 451 | You may not convey a covered work if you are a party to an arrangement with a 452 | third party that is in the business of distributing software, under which you 453 | make payment to the third party based on the extent of your activity of 454 | conveying the work, and under which the third party grants, to any of the 455 | parties who would receive the covered work from you, a discriminatory patent 456 | license (a) in connection with copies of the covered work conveyed by you (or 457 | copies made from those copies), or (b) primarily for and in connection with 458 | specific products or compilations that contain the covered work, unless you 459 | entered into that arrangement, or that patent license was granted, prior to 28 460 | March 2007. 461 | 462 | Nothing in this License shall be construed as excluding or limiting any implied 463 | license or other defenses to infringement that may otherwise be available to you 464 | under applicable patent law. 465 | 466 | 12. No Surrender of Others' Freedom. 467 | If conditions are imposed on you (whether by court order, agreement or 468 | otherwise) that contradict the conditions of this License, they do not excuse 469 | you from the conditions of this License. If you cannot convey a covered work so 470 | as to satisfy simultaneously your obligations under this License and any other 471 | pertinent obligations, then as a consequence you may not convey it at all. For 472 | example, if you agree to terms that obligate you to collect a royalty for 473 | further conveying from those to whom you convey the Program, the only way you 474 | could satisfy both those terms and this License would be to refrain entirely 475 | from conveying the Program. 476 | 477 | 13. Use with the GNU Affero General Public License. 478 | Notwithstanding any other provision of this License, you have permission to link 479 | or combine any covered work with a work licensed under version 3 of the GNU 480 | Affero General Public License into a single combined work, and to convey the 481 | resulting work. The terms of this License will continue to apply to the part 482 | which is the covered work, but the special requirements of the GNU Affero 483 | General Public License, section 13, concerning interaction through a network 484 | will apply to the combination as such. 485 | 486 | 14. Revised Versions of this License. 487 | The Free Software Foundation may publish revised and/or new versions of the GNU 488 | General Public License from time to time. Such new versions will be similar in 489 | spirit to the present version, but may differ in detail to address new problems 490 | or concerns. 491 | 492 | Each version is given a distinguishing version number. If the Program specifies 493 | that a certain numbered version of the GNU General Public License “or any later 494 | version” applies to it, you have the option of following the terms and 495 | conditions either of that numbered version or of any later version published by 496 | the Free Software Foundation. If the Program does not specify a version number 497 | of the GNU General Public License, you may choose any version ever published by 498 | the Free Software Foundation. 499 | 500 | If the Program specifies that a proxy can decide which future versions of the 501 | GNU General Public License can be used, that proxy's public statement of 502 | acceptance of a version permanently authorizes you to choose that version for 503 | the Program. 504 | 505 | Later license versions may give you additional or different permissions. 506 | However, no additional obligations are imposed on any author or copyright holder 507 | as a result of your choosing to follow a later version. 508 | 509 | 15. Disclaimer of Warranty. 510 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 511 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER 512 | PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER 513 | EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 514 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE 515 | QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 516 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 517 | 518 | 16. Limitation of Liability. 519 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY 520 | COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS 521 | PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, 522 | INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE 523 | THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED 524 | INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE 525 | PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY 526 | HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 527 | 528 | 17. Interpretation of Sections 15 and 16. 529 | If the disclaimer of warranty and limitation of liability provided above cannot 530 | be given local legal effect according to their terms, reviewing courts shall 531 | apply local law that most closely approximates an absolute waiver of all civil 532 | liability in connection with the Program, unless a warranty or assumption of 533 | liability accompanies a copy of the Program in return for a fee. 534 | 535 | END OF TERMS AND CONDITIONS 536 | 537 | How to Apply These Terms to Your New Programs 538 | If you develop a new program, and you want it to be of the greatest possible use 539 | to the public, the best way to achieve this is to make it free software which 540 | everyone can redistribute and change under these terms. 541 | 542 | To do so, attach the following notices to the program. It is safest to attach 543 | them to the start of each source file to most effectively state the exclusion of 544 | warranty; and each file should have at least the “copyright” line and a pointer 545 | to where the full notice is found. 546 | 547 | 548 | Copyright (C) 549 | 550 | This program is free software: you can redistribute it and/or modify 551 | it under the terms of the GNU General Public License as published by 552 | the Free Software Foundation, either version 3 of the License, or 553 | (at your option) any later version. 554 | 555 | This program is distributed in the hope that it will be useful, 556 | but WITHOUT ANY WARRANTY; without even the implied warranty of 557 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 558 | GNU General Public License for more details. 559 | 560 | You should have received a copy of the GNU General Public License 561 | along with this program. If not, see . 562 | Also add information on how to contact you by electronic and paper mail. 563 | 564 | If the program does terminal interaction, make it output a short notice like 565 | this when it starts in an interactive mode: 566 | 567 | Copyright (C) 568 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 569 | This is free software, and you are welcome to redistribute it 570 | under certain conditions; type `show c' for details. 571 | The hypothetical commands `show w' and `show c' should show the appropriate 572 | parts of the General Public License. Of course, your program's commands might be 573 | different; for a GUI interface, you would use an “about box”. 574 | 575 | You should also get your employer (if you work as a programmer) or school, if 576 | any, to sign a “copyright disclaimer” for the program, if necessary. For more 577 | information on this, and how to apply and follow the GNU GPL, see 578 | . 579 | 580 | The GNU General Public License does not permit incorporating your program into 581 | proprietary programs. If your program is a subroutine library, you may consider 582 | it more useful to permit linking proprietary applications with the library. If 583 | this is what you want to do, use the GNU Lesser General Public License instead 584 | of this License. But first, please read 585 | . 586 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | @imqueue/pg-pubsub 3 | 4 | Tweet 5 | 6 |

7 | 27 |
28 |

29 | Reliable PostgreSQL LISTEN/NOTIFY with inter-process lock support 30 |

31 |
32 |
33 | pg-pubsub in action 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 | [![Natural behavior](https://raw.githubusercontent.com/Mikhus/blob/master/pg-pubsub.gif)]() 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 | --------------------------------------------------------------------------------