├── .vscode └── settings.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .npmignore ├── tsup.config.ts ├── src ├── msg-manager │ ├── types.ts │ ├── helpers.ts │ ├── index.ts │ └── index.spec.ts ├── types.ts ├── constants.ts ├── query-executer.ts ├── queue-manager │ ├── types.ts │ ├── helpers.ts │ ├── index.ts │ └── index.spec.ts └── index.ts ├── .eslintrc.js ├── .github └── workflows │ ├── node.js.yml │ └── npm-publish.yml ├── package.json ├── README.md ├── tsconfig.json └── LICENSE /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["Pgmq"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Built files: 2 | dist 3 | 4 | # Auto-generated files: 5 | node_modules 6 | coverage 7 | 8 | LICENSE -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "lf", 5 | "printWidth": 100, 6 | "semi": true 7 | } 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .vscode 3 | coverage 4 | node_modules 5 | src 6 | .eslintrc.js 7 | .prettierignore 8 | .prettierrc 9 | Dockerfile 10 | tsconfig.json 11 | tsconfig.tsbuildinfo 12 | tsup.config.ts -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['cjs', 'esm'], 6 | dts: true, 7 | splitting: false, 8 | sourcemap: true, 9 | clean: true, 10 | minify: true, 11 | }); 12 | -------------------------------------------------------------------------------- /src/msg-manager/types.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | msgId: number; 3 | readCount: number; 4 | enqueuedAt: Date; 5 | vt: Date; 6 | message: T; 7 | } 8 | 9 | export interface DbMessage { 10 | msg_id: string; 11 | read_ct: string; 12 | enqueued_at: string; 13 | vt: string; 14 | message: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface PgPoolConfig { 2 | host: string; 3 | database: string; 4 | user: string; 5 | password: string; 6 | port: number; 7 | ssl?: boolean; 8 | max?: number; 9 | idleTimeoutMillis?: number; 10 | connectionTimeoutMillis?: number; 11 | maxUses?: number; 12 | } 13 | 14 | export interface PgmqConfig { 15 | skipExtensionCreation?: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { PgmqConfig } from 'src/types'; 2 | 3 | export const DEFAULT_MAX_POOL_SIZE = 20; 4 | export const DEFAULT_IDLE_TIMEOUT_MILLIS = 1000; 5 | export const DEFAULT_CONNECTION_TIMEOUT_MILLIS = 1000; 6 | export const DEFAULT_MAX_USES = 7500; 7 | export const DEFAULT_SSL = true; 8 | 9 | export const defaultPgmqConfig: PgmqConfig = { 10 | skipExtensionCreation: false, 11 | }; 12 | -------------------------------------------------------------------------------- /src/msg-manager/helpers.ts: -------------------------------------------------------------------------------- 1 | import { DbMessage, Message } from './types'; 2 | 3 | export const parseDbMessage = (m?: DbMessage): Message | undefined => { 4 | if (m == null) return m; 5 | return { 6 | msgId: parseInt(m.msg_id), 7 | readCount: parseInt(m.read_ct), 8 | enqueuedAt: new Date(m.enqueued_at), 9 | vt: new Date(m.vt), 10 | message: m.message as T, 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | extends: [ 4 | 'plugin:@typescript-eslint/strict-type-checked', 5 | 'plugin:@typescript-eslint/stylistic-type-checked', 6 | ], 7 | parser: '@typescript-eslint/parser', 8 | plugins: ['@typescript-eslint'], 9 | root: true, 10 | parserOptions: { 11 | project: 'tsconfig.json', 12 | }, 13 | ignorePatterns: ['.eslintrc.js', 'tsconfig.json'], 14 | }; 15 | -------------------------------------------------------------------------------- /src/query-executer.ts: -------------------------------------------------------------------------------- 1 | import { Client, QueryResultRow } from 'pg'; 2 | import Pool from 'pg-pool'; 3 | 4 | export class QueryExecuter { 5 | constructor(protected readonly pool: Pool) {} 6 | 7 | protected async executeQuery(query: string, params?: unknown[]) { 8 | const client = await this.pool.connect(); 9 | 10 | try { 11 | return await client.query(query, params); 12 | } finally { 13 | client.release(); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/queue-manager/types.ts: -------------------------------------------------------------------------------- 1 | export interface Queue { 2 | name: string; 3 | createdAt: Date; 4 | isPartitioned: boolean; 5 | isUnlogged: boolean; 6 | } 7 | 8 | export interface QueueMetrics { 9 | queueName: string; 10 | queueLength: number; 11 | newestMsgAgeSec?: number; 12 | oldestMsgAgeSec?: number; 13 | totalMessages: number; 14 | scrapeTime: Date; 15 | } 16 | 17 | export interface DbQueueMetrics { 18 | queue_name: string; 19 | queue_length: string; 20 | newest_msg_age_sec?: string; 21 | oldest_msg_age_sec?: string; 22 | total_messages: string; 23 | scrape_time: string; 24 | } 25 | -------------------------------------------------------------------------------- /src/queue-manager/helpers.ts: -------------------------------------------------------------------------------- 1 | import { DbQueueMetrics, Queue, QueueMetrics } from './types'; 2 | 3 | export const parseDbQueue = (q: string): Queue => { 4 | const parts = q.substring(1, q.length - 1).split(','); 5 | return { 6 | name: parts[0], 7 | isPartitioned: parts[1] === 't', 8 | isUnlogged: parts[2] === 't', 9 | createdAt: new Date(parts[3]), 10 | }; 11 | }; 12 | 13 | export const parseDbQueueMetrics = (m: DbQueueMetrics): QueueMetrics => ({ 14 | queueName: m.queue_name, 15 | queueLength: parseInt(m.queue_length), 16 | newestMsgAgeSec: m.newest_msg_age_sec != null ? parseInt(m.newest_msg_age_sec) : undefined, 17 | oldestMsgAgeSec: m.oldest_msg_age_sec != null ? parseInt(m.oldest_msg_age_sec) : undefined, 18 | totalMessages: parseInt(m.total_messages), 19 | scrapeTime: new Date(m.scrape_time), 20 | }); 21 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | services: 14 | pgmq: 15 | image: quay.io/tembo/pgmq-pg:v1.2.1 16 | env: 17 | POSTGRES_PASSWORD: password 18 | options: >- 19 | --health-cmd pg_isready 20 | --health-interval 10s 21 | --health-timeout 5s 22 | --health-retries 5 23 | ports: 24 | - 5432:5432 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | cache: 'npm' 32 | - run: npm ci 33 | - run: npm run test:cov 34 | - name: Upload coverage reports to Codecov 35 | uses: codecov/codecov-action@v3 36 | env: 37 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | services: 12 | pgmq: 13 | image: quay.io/tembo/pgmq-pg:v1.2.1 14 | env: 15 | POSTGRES_PASSWORD: password 16 | options: >- 17 | --health-cmd pg_isready 18 | --health-interval 10s 19 | --health-timeout 5s 20 | --health-retries 5 21 | ports: 22 | - 5432:5432 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | - run: npm ci 30 | - run: npm test --runInBand 31 | - run: npm run build 32 | 33 | publish-npm: 34 | needs: build 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - uses: actions/setup-node@v3 39 | with: 40 | node-version: 20 41 | registry-url: https://registry.npmjs.org/ 42 | - run: npm ci 43 | - run: npm run build 44 | - run: npm publish 45 | env: 46 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 47 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'pg'; 2 | import Pool from 'pg-pool'; 3 | import { PgPoolConfig } from './types'; 4 | import { 5 | DEFAULT_CONNECTION_TIMEOUT_MILLIS, 6 | DEFAULT_IDLE_TIMEOUT_MILLIS, 7 | DEFAULT_MAX_POOL_SIZE, 8 | DEFAULT_MAX_USES, 9 | DEFAULT_SSL, 10 | defaultPgmqConfig, 11 | } from './constants'; 12 | import { QueueManager } from './queue-manager'; 13 | import { MsgManager } from './msg-manager'; 14 | 15 | export class Pgmq { 16 | public readonly queue: QueueManager; 17 | public readonly msg: MsgManager; 18 | 19 | private constructor(private readonly pool: Pool) { 20 | this.queue = new QueueManager(pool); 21 | this.msg = new MsgManager(pool); 22 | } 23 | 24 | public static async new(c: PgPoolConfig, pgmqConfig = defaultPgmqConfig) { 25 | if (c.max === undefined) c.max = DEFAULT_MAX_POOL_SIZE; 26 | if (c.idleTimeoutMillis === undefined) c.max = DEFAULT_IDLE_TIMEOUT_MILLIS; 27 | if (c.connectionTimeoutMillis === undefined) 28 | c.connectionTimeoutMillis = DEFAULT_CONNECTION_TIMEOUT_MILLIS; 29 | if (c.maxUses === undefined) c.maxUses = DEFAULT_MAX_USES; 30 | if (c.ssl === undefined) c.ssl = DEFAULT_SSL; 31 | 32 | const pool = new Pool(c); 33 | 34 | const pgmq = new Pgmq(pool); 35 | if (!pgmqConfig.skipExtensionCreation) await pgmq.prepare(); 36 | 37 | return pgmq; 38 | } 39 | 40 | private async prepare() { 41 | const client = await this.pool.connect(); 42 | try { 43 | await client.query('CREATE EXTENSION IF NOT EXISTS pgmq CASCADE;'); 44 | } finally { 45 | client.release(); 46 | } 47 | } 48 | 49 | public async close() { 50 | await this.pool.end(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/queue-manager/index.ts: -------------------------------------------------------------------------------- 1 | import { parseDbQueue, parseDbQueueMetrics } from './helpers'; 2 | import { DbQueueMetrics, Queue } from './types'; 3 | import { QueryExecuter } from '../query-executer'; 4 | 5 | export class QueueManager extends QueryExecuter { 6 | public async list(): Promise { 7 | const query = 'SELECT pgmq.list_queues()'; 8 | const { rows } = await this.executeQuery<{ list_queues: string }>(query); 9 | return rows.map(({ list_queues }) => parseDbQueue(list_queues)); 10 | } 11 | 12 | public async create(name: string) { 13 | const query = 'SELECT pgmq.create($1)'; 14 | await this.executeQuery(query, [name]); 15 | } 16 | 17 | public async createUnlogged(name: string) { 18 | const query = 'SELECT pgmq.create_unlogged($1)'; 19 | await this.executeQuery(query, [name]); 20 | } 21 | 22 | public async drop(name: string) { 23 | const query = 'SELECT pgmq.drop_queue($1)'; 24 | await this.executeQuery(query, [name]); 25 | } 26 | 27 | public async purge(name: string) { 28 | const query = 'SELECT pgmq.purge_queue($1)'; 29 | await this.executeQuery(query, [name]); 30 | } 31 | 32 | public async detachArchive(name: string) { 33 | const query = 'SELECT pgmq.detach_archive($1)'; 34 | await this.executeQuery(query, [name]); 35 | } 36 | 37 | public async getMetrics(name: string) { 38 | const query = 'SELECT * FROM pgmq.metrics($1);'; 39 | const { rows } = await this.executeQuery(query, [name]); 40 | return parseDbQueueMetrics(rows[0]); 41 | } 42 | 43 | public async getAllMetrics() { 44 | const query = 'SELECT * FROM pgmq.metrics_all()'; 45 | const { rows } = await this.executeQuery(query); 46 | return rows.map(parseDbQueueMetrics); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pgmq-js", 3 | "version": "1.3.0", 4 | "description": "Postgres Message Queue (PSMQ) JavaScript Client Library", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "repository": { 12 | "type": "github", 13 | "url": "https://github.com/Muhammad-Magdi/pgmq-js" 14 | }, 15 | "scripts": { 16 | "build": "tsup", 17 | "start": "ts-node src/index.ts", 18 | "test": "jest --runInBand", 19 | "test:watch": "jest --watch --detectOpenHandles --runInBand", 20 | "test:cov": "jest --coverage --runInBand", 21 | "lint": "eslint . --fix", 22 | "lint:check": "eslint . --quiet", 23 | "format": "prettier . --write --ignore-unknown", 24 | "format:check": "prettier . --check --ignore-unknown" 25 | }, 26 | "keywords": [ 27 | "postgres", 28 | "postgresql", 29 | "message-queue", 30 | "queues", 31 | "postgresql-extension", 32 | "pgmq" 33 | ], 34 | "author": "muhammad-magdi", 35 | "license": "Apache 2.0", 36 | "devDependencies": { 37 | "@faker-js/faker": "^9.0.0", 38 | "@types/jest": "^29.5.12", 39 | "@types/pg-pool": "^2.0.6", 40 | "@typescript-eslint/eslint-plugin": "^6.18.1", 41 | "@typescript-eslint/parser": "^6.18.1", 42 | "eslint": "^8.56.0", 43 | "jest": "^29.7.0", 44 | "prettier": "^3.1.1", 45 | "ts-jest": "^29.2.5", 46 | "tsup": "^8.2.4", 47 | "typescript": "^5.6.2", 48 | "ts-node": "^10.9.2" 49 | }, 50 | "dependencies": { 51 | "pg": "^8.12.0", 52 | "pg-pool": "^3.6.2" 53 | }, 54 | "jest": { 55 | "globals": { 56 | "ts-jest": { 57 | "tsconfig": "tsconfig.json" 58 | } 59 | }, 60 | "moduleFileExtensions": [ 61 | "ts", 62 | "js" 63 | ], 64 | "rootDir": "src", 65 | "testRegex": ".*\\.spec\\.ts$", 66 | "transform": { 67 | "^.+\\.ts$": "ts-jest" 68 | }, 69 | "collectCoverageFrom": [ 70 | "**/*.(t|j)s" 71 | ], 72 | "coverageDirectory": "../coverage", 73 | "testEnvironment": "node", 74 | "moduleNameMapper": { 75 | "^src/(.*)$": "/$1" 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/msg-manager/index.ts: -------------------------------------------------------------------------------- 1 | import { parseDbMessage } from './helpers'; 2 | import { DbMessage, Message } from 'src/msg-manager/types'; 3 | import { QueryExecuter } from 'src/query-executer'; 4 | 5 | export class MsgManager extends QueryExecuter { 6 | public async send(q: string, msg: T, delay = 0): Promise { 7 | const query = 'SELECT * FROM pgmq.send($1, $2, $3)'; 8 | const res = await this.executeQuery<{ send: number }>(query, [q, JSON.stringify(msg), delay]); 9 | return res.rows[0].send; 10 | } 11 | 12 | public async sendBatch(q: string, msgs: T[], delay = 0): Promise { 13 | const query = 'SELECT * FROM pgmq.send_batch($1, $2::jsonb[], $3)'; 14 | const res = await this.executeQuery<{ send_batch: number }>(query, [ 15 | q, 16 | msgs.map((m) => JSON.stringify(m)), 17 | delay, 18 | ]); 19 | 20 | return res.rows.flatMap((s) => s.send_batch); 21 | } 22 | 23 | public async read(q: string, vt = 0): Promise> { 24 | return this.readBatch(q, vt, 1).then((msgs) => msgs[0]); 25 | } 26 | 27 | public async readBatch(q: string, vt: number, numMessages: number): Promise[]> { 28 | const query = 'SELECT * FROM pgmq.read($1, $2, $3)'; 29 | const res = await this.executeQuery(query, [q, vt, numMessages]); 30 | return res.rows.flatMap(parseDbMessage) as Message[]; 31 | } 32 | 33 | public async pop(q: string): Promise | undefined> { 34 | const query = 'SELECT * FROM pgmq.pop($1)'; 35 | const res = await this.executeQuery(query, [q]); 36 | return parseDbMessage(res.rows[0]); 37 | } 38 | 39 | public async archive(q: string, msgId: number): Promise { 40 | const query = 'SELECT pgmq.archive($1, $2::bigint)'; 41 | const res = await this.executeQuery<{ archive: boolean }>(query, [q, msgId]); 42 | return res.rows[0].archive; 43 | } 44 | 45 | public async archiveBatch(q: string, msgIds: number[]): Promise { 46 | const query = 'SELECT pgmq.archive($1, $2::bigint[])'; 47 | const res = await this.executeQuery<{ archive: number }>(query, [q, msgIds]); 48 | return res.rows.flatMap((a) => a.archive); 49 | } 50 | 51 | public async delete(q: string, msgId: number): Promise { 52 | const query = 'SELECT pgmq.delete($1, $2::bigint)'; 53 | const res = await this.executeQuery<{ delete: boolean }>(query, [q, msgId]); 54 | return res.rows[0].delete; 55 | } 56 | 57 | public async deleteBatch(q: string, msgIds: number[]): Promise { 58 | const query = 'SELECT pgmq.delete($1, $2::bigint[])'; 59 | const res = await this.executeQuery<{ delete: number }>(query, [q, msgIds]); 60 | return res.rows.flatMap((d) => d.delete); 61 | } 62 | 63 | public async setVt( 64 | q: string, 65 | msgId: number, 66 | vtOffset: number, 67 | ): Promise | undefined> { 68 | const query = 'SELECT * FROM pgmq.set_vt($1, $2, $3);'; 69 | const res = await this.executeQuery(query, [q, msgId, vtOffset]); 70 | return parseDbMessage(res.rows[0]); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pgmq-js 2 | 3 | Postgres Message Queue (PGMQ) JavaScript Client Library 4 | 5 |

6 | 7 | version 8 | 9 | 10 | weekly downloads 11 | 12 | 13 | license 14 | 15 | 16 | 17 | 18 |

19 | 20 | ## Installation 21 | 22 | As always: 23 | 24 | ```bash 25 | npm i pgmq-js 26 | ``` 27 | 28 | ## Usage 29 | 30 | First, Start a Postgres instance with the PGMQ extension installed: 31 | 32 | ```bash 33 | docker run -d --name postgres -e POSTGRES_PASSWORD=password -p 5432:5432 quay.io/tembo/pgmq-pg:v1.2.1 34 | ``` 35 | 36 | Then: 37 | 38 | ```ts 39 | import { Pgmq } from 'pgmq-js'; 40 | 41 | console.log('Connecting to Postgres...'); 42 | const pgmq = await Pgmq.new( 43 | { 44 | host: 'localhost', 45 | database: 'postgres', 46 | password: 'password', 47 | port: 5432, 48 | user: 'postgres', 49 | ssl: false, 50 | }, 51 | // { skipExtensionCreation: true }, Set this if you want to bypass extension creation (e.g. dbdev users). 52 | ).catch((err) => { 53 | console.error('Failed to connect to Postgres', err); 54 | }); 55 | 56 | const qName = 'my_queue'; 57 | console.log(`Creating queue ${qName}...`); 58 | await pgmq.queue.create(qName).catch((err) => { 59 | console.error('Failed to create queue', err); 60 | }); 61 | 62 | interface Msg { 63 | id: number; 64 | name: string; 65 | } 66 | const msg: Msg = { id: 1, name: 'testMsg' }; 67 | console.log('Sending message...'); 68 | const msgId = await pgmq.msg.send(qName, msg).catch((err) => { 69 | console.error('Failed to send message', err); 70 | }); 71 | 72 | const vt = 30; 73 | const receivedMsg = await pgmq.msg.read(qName, vt).catch((err) => { 74 | console.error('No messages in the queue', err); 75 | }); 76 | 77 | console.log('Received message...'); 78 | console.dir(receivedMsg.message, { depth: null }); 79 | 80 | console.log('Archiving message...'); 81 | await pgmq.msg.archive(qName, msgId).catch((err) => { 82 | console.error('Failed to archive message', err); 83 | }); 84 | ``` 85 | 86 | ## API 87 | 88 | ## Supported Functionalities 89 | 90 | - [x] [Sending Messages](https://tembo-io.github.io/pgmq/api/sql/functions/#sending-messages) 91 | - [x] [send](https://tembo-io.github.io/pgmq/api/sql/functions/#send) 92 | - [x] [send_batch](https://tembo-io.github.io/pgmq/api/sql/functions/#send_batch) 93 | - [ ] [Reading Messages](https://tembo-io.github.io/pgmq/api/sql/functions/#reading-messages) 94 | - [x] [read](https://tembo-io.github.io/pgmq/api/sql/functions/#read) 95 | - [ ] [read_with_poll](https://tembo-io.github.io/pgmq/api/sql/functions/#read_with_poll) 96 | - [x] [pop](https://tembo-io.github.io/pgmq/api/sql/functions/#pop) 97 | - [x] [Deleting/Archiving Messages](https://tembo-io.github.io/pgmq/api/sql/functions/#deletingarchiving-messages) 98 | - [x] [delete (single)](https://tembo-io.github.io/pgmq/api/sql/functions/#delete-single) 99 | - [x] [delete (batch)](https://tembo-io.github.io/pgmq/api/sql/functions/#delete-batch) 100 | - [x] [purge_queue](https://tembo-io.github.io/pgmq/api/sql/functions/#purge_queue) 101 | - [x] [archive (single)](https://tembo-io.github.io/pgmq/api/sql/functions/#archive-single) 102 | - [x] [archive (batch)](https://tembo-io.github.io/pgmq/api/sql/functions/#archive-batch) 103 | - [ ] [Queue Management](https://tembo-io.github.io/pgmq/api/sql/functions/#queue-management) 104 | - [x] [create](https://tembo-io.github.io/pgmq/api/sql/functions/#create) 105 | - [ ] [create_partitioned](https://tembo-io.github.io/pgmq/api/sql/functions/#create_partitioned) 106 | - [x] [create_unlogged](https://tembo-io.github.io/pgmq/api/sql/functions/#create_unlogged) 107 | - [x] [detach_archive](https://tembo-io.github.io/pgmq/api/sql/functions/#detach_archive) 108 | - [x] [drop_queue](https://tembo-io.github.io/pgmq/api/sql/functions/#drop_queue) 109 | - [x] [Utilities](https://tembo-io.github.io/pgmq/api/sql/functions/#utilities) 110 | - [x] [set_vt](https://tembo-io.github.io/pgmq/api/sql/functions/#set_vt) 111 | - [x] [list_queues](https://tembo-io.github.io/pgmq/api/sql/functions/#list_queues) 112 | - [x] [metrics](https://tembo-io.github.io/pgmq/api/sql/functions/#metrics) 113 | - [x] [metrics_all](https://tembo-io.github.io/pgmq/api/sql/functions/#metrics_all) 114 | -------------------------------------------------------------------------------- /src/queue-manager/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { Pgmq } from '../index'; 2 | import { faker } from '@faker-js/faker'; 3 | 4 | describe('QueueManager', () => { 5 | let pgmq: Pgmq; 6 | beforeAll(async () => { 7 | pgmq = await Pgmq.new({ 8 | host: 'localhost', 9 | database: 'postgres', 10 | password: 'password', 11 | port: 5432, 12 | user: 'postgres', 13 | ssl: false, 14 | }); 15 | }); 16 | 17 | const newQueue = (name: string, isUnlogged = false) => ({ 18 | name, 19 | isPartitioned: false, 20 | isUnlogged, 21 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 22 | createdAt: expect.any(Date), 23 | }); 24 | 25 | describe('list', () => { 26 | it.skip('returns an empty list; no queues', async () => { 27 | const queues = await pgmq.queue.list(); 28 | expect(queues).toEqual([]); 29 | }); 30 | 31 | it('returns a list of queues; one queue', async () => { 32 | const qName = faker.string.alpha(10); 33 | await pgmq.queue.create(qName); 34 | 35 | const queues = await pgmq.queue.list(); 36 | 37 | expect(queues).toContainEqual(newQueue(qName)); 38 | 39 | await pgmq.queue.drop(qName); 40 | }); 41 | 42 | it('returns a list of queues; multiple queues', async () => { 43 | const qName1 = faker.string.alpha(10); 44 | const qName2 = faker.string.alpha(10); 45 | const qName3 = faker.string.alpha(10); 46 | await Promise.all([ 47 | pgmq.queue.create(qName1), 48 | pgmq.queue.create(qName2), 49 | pgmq.queue.create(qName3), 50 | ]); 51 | 52 | const queues = await pgmq.queue.list(); 53 | 54 | expect(queues.length).toBeGreaterThanOrEqual(3); 55 | expect(queues).toContainEqual(newQueue(qName1)); 56 | expect(queues).toContainEqual(newQueue(qName2)); 57 | expect(queues).toContainEqual(newQueue(qName3)); 58 | 59 | await Promise.all([qName1, qName2, qName3].map((q) => pgmq.queue.drop(q))); 60 | }); 61 | }); 62 | 63 | describe('create', () => { 64 | it('creates a queue', async () => { 65 | const qName = faker.string.alpha(10); 66 | await pgmq.queue.create(qName); 67 | 68 | const queues = await pgmq.queue.list(); 69 | expect(queues).toContainEqual(newQueue(qName)); 70 | 71 | await pgmq.queue.drop(qName); 72 | }); 73 | 74 | it('rejects existing queue names', async () => { 75 | const qName = faker.string.alpha(10); 76 | await pgmq.queue.create(qName); 77 | await expect(pgmq.queue.create(qName)).rejects.toThrow(); 78 | 79 | await pgmq.queue.drop(qName); 80 | }); 81 | }); 82 | 83 | describe('createUnlogged', () => { 84 | it('creates an unlogged queue', async () => { 85 | const qName = faker.string.alpha(10); 86 | 87 | await pgmq.queue.createUnlogged(qName); 88 | 89 | const queues = await pgmq.queue.list(); 90 | expect(queues).toContainEqual(newQueue(qName, true)); 91 | 92 | await pgmq.queue.drop(qName); 93 | }); 94 | 95 | it('fails to create an unlogged queue with the same name as an existing logged queue', async () => { 96 | const qName = faker.string.alpha(10); 97 | await pgmq.queue.create(qName); 98 | 99 | await expect(pgmq.queue.createUnlogged(qName)).rejects.toThrow(); 100 | 101 | const queues = await pgmq.queue.list(); 102 | expect(queues).toContainEqual(newQueue(qName)); 103 | 104 | await pgmq.queue.drop(qName); 105 | }); 106 | }); 107 | 108 | describe('drop', () => { 109 | it('drops the queue', async () => { 110 | const qName = faker.string.alpha(10); 111 | await pgmq.queue.create(qName); 112 | 113 | await pgmq.queue.drop(qName); 114 | 115 | const queues = await pgmq.queue.list(); 116 | expect(queues).not.toContainEqual(newQueue(qName)); 117 | }); 118 | 119 | it('fails to drop non-existing queue', async () => { 120 | const qName = faker.string.alpha(10); 121 | await expect(pgmq.queue.drop(qName)).rejects.toThrow(); 122 | }); 123 | }); 124 | 125 | describe('purge', () => { 126 | it('does not throw', async () => { 127 | const qName = faker.string.alpha(10); 128 | await pgmq.queue.create(qName); 129 | await pgmq.queue.purge(qName); 130 | 131 | await pgmq.queue.drop(qName); 132 | }); 133 | }); 134 | 135 | const emptyQueueMetrics = (qName: string) => ({ 136 | queueName: qName, 137 | queueLength: 0, 138 | scrapeTime: expect.any(Date) as Date, 139 | newestMsgAgeSec: undefined, 140 | oldestMsgAgeSec: undefined, 141 | totalMessages: 0, 142 | }); 143 | 144 | describe('getMetrics', () => { 145 | it('returns empty queue metrics', async () => { 146 | const qName = faker.string.alpha(10); 147 | await pgmq.queue.create(qName); 148 | 149 | const metrics = await pgmq.queue.getMetrics(qName); 150 | 151 | expect(metrics).toEqual(emptyQueueMetrics(qName)); 152 | expect(metrics.scrapeTime.valueOf()).toBeCloseTo(Date.now(), -2); 153 | 154 | await pgmq.queue.drop(qName); 155 | }); 156 | 157 | it('returns queue metrics', async () => { 158 | const qName = faker.string.alpha(10); 159 | await pgmq.queue.create(qName); 160 | await pgmq.msg.sendBatch(qName, ['msg1', { key: 'msg2' }, 3, 4.0]); 161 | 162 | const metrics = await pgmq.queue.getMetrics(qName); 163 | 164 | expect(metrics).toEqual({ 165 | queueName: qName, 166 | queueLength: 4, 167 | scrapeTime: expect.any(Date) as Date, 168 | newestMsgAgeSec: expect.closeTo(0, -1) as number, 169 | oldestMsgAgeSec: expect.closeTo(0, -1) as number, 170 | totalMessages: 4, 171 | }); 172 | 173 | await pgmq.queue.drop(qName); 174 | }); 175 | 176 | it('rejects when queue does not exist', async () => { 177 | await expect(pgmq.queue.getMetrics(faker.string.alpha(10))).rejects.toThrow(); 178 | }); 179 | }); 180 | 181 | describe('getAllMetrics', () => { 182 | it('returns empty metrics', async () => { 183 | const metrics = await pgmq.queue.getAllMetrics(); 184 | expect(metrics).toEqual([]); 185 | }); 186 | 187 | it('returns metrics of a single queue', async () => { 188 | const qName = faker.string.alpha(10); 189 | await pgmq.queue.create(qName); 190 | await pgmq.msg.sendBatch(qName, ['msg1', { key: 'msg2' }, 3, 4.0]); 191 | 192 | const metrics = await pgmq.queue.getAllMetrics(); 193 | 194 | expect(metrics).toEqual([ 195 | { 196 | queueName: qName, 197 | queueLength: 4, 198 | scrapeTime: expect.any(Date) as Date, 199 | newestMsgAgeSec: expect.closeTo(0, -1) as number, 200 | oldestMsgAgeSec: expect.closeTo(0, -1) as number, 201 | totalMessages: 4, 202 | }, 203 | ]); 204 | 205 | await pgmq.queue.drop(qName); 206 | }); 207 | 208 | it('returns metrics of multiple queues', async () => { 209 | const qName1 = faker.string.alpha(10); 210 | const qName2 = faker.string.alpha(10); 211 | await Promise.all([pgmq.queue.create(qName1), pgmq.queue.create(qName2)]); 212 | 213 | const metrics = await pgmq.queue.getAllMetrics(); 214 | 215 | expect(metrics).toContainEqual(emptyQueueMetrics(qName1)); 216 | expect(metrics).toContainEqual(emptyQueueMetrics(qName2)); 217 | 218 | await Promise.all([pgmq.queue.drop(qName1), pgmq.queue.drop(qName2)]); 219 | }); 220 | }); 221 | 222 | afterAll(async () => { 223 | await pgmq.close(); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | "incremental": false /* Save .tsbuildinfo files to allow for incremental compilation of projects. */, 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs" /* Specify what module code is generated. */, 29 | "rootDir": "./src" /* Specify the root folder within your source files. */, 30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */, 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 59 | "removeComments": true /* Disable emitting comments. */, 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, 80 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 83 | 84 | /* Type Checking */ 85 | "strict": true /* Enable all strict type-checking options. */, 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Muhammad Magdi 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/msg-manager/index.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import { faker } from '@faker-js/faker'; 3 | import { Pgmq } from '../index'; 4 | 5 | describe('MsgManager', () => { 6 | let pgmq: Pgmq; 7 | const qName = faker.string.alpha(10); 8 | beforeAll(async () => { 9 | pgmq = await Pgmq.new({ 10 | host: 'localhost', 11 | database: 'postgres', 12 | password: 'password', 13 | port: 5432, 14 | user: 'postgres', 15 | ssl: false, 16 | }); 17 | }); 18 | 19 | beforeEach(async () => { 20 | await pgmq.queue.create(qName); 21 | }); 22 | 23 | const expectEmptyQueue = async (qName: string) => { 24 | await expect(pgmq.msg.pop(qName)).resolves.toBeUndefined(); 25 | }; 26 | 27 | const deleteMessage = async (qName: string, id: number) => { 28 | await expect(pgmq.msg.delete(qName, id)).resolves.toBe(true); 29 | }; 30 | 31 | const inputMsgs = [ 32 | { msg: faker.number.int(), type: 'int' }, 33 | { msg: faker.number.float(), type: 'float' }, 34 | { msg: faker.string.alphanumeric(5), type: 'string' }, 35 | { msg: faker.datatype.boolean(), type: 'boolean' }, 36 | { msg: faker.string.alphanumeric(5).split(''), type: 'array' }, 37 | { 38 | msg: { 39 | a: faker.number.int(), 40 | b: faker.datatype.boolean(), 41 | c: faker.string.alphanumeric(5), 42 | }, 43 | type: 'object', 44 | }, 45 | { msg: new Date(), type: 'date' }, 46 | { msg: null, type: 'null' }, 47 | { msg: undefined, type: 'undefined' }, 48 | ]; 49 | 50 | const newMsg = (msg: T) => ({ 51 | enqueuedAt: expect.any(Date), 52 | message: msg, 53 | msgId: expect.any(Number), 54 | readCount: expect.any(Number), 55 | vt: expect.any(Date), 56 | }); 57 | 58 | describe('send', () => { 59 | it.each(inputMsgs)('accepts $msg ($type) as a message', async ({ msg }) => { 60 | await pgmq.msg.send(qName, msg); 61 | }); 62 | 63 | it('returns the unique message id', async () => { 64 | const id = await pgmq.msg.send(qName, 'msg'); 65 | await deleteMessage(qName, id); 66 | }); 67 | }); 68 | 69 | describe('sendBatch', () => { 70 | it('returns an array of message ids', async () => { 71 | const ids = await pgmq.msg.sendBatch( 72 | qName, 73 | inputMsgs.map((i) => i.msg), 74 | ); 75 | await Promise.all(ids.map((id) => deleteMessage(qName, id))); 76 | }); 77 | 78 | it('accepts empty arrays', async () => { 79 | const id = await pgmq.msg.sendBatch(qName, []); 80 | expect(id).toEqual([]); 81 | }); 82 | }); 83 | 84 | describe('read', () => { 85 | it('returns a message with its metadata', async () => { 86 | interface T { 87 | id: number; 88 | msg: string; 89 | date: Date; 90 | isGood: boolean; 91 | } 92 | const msg = { id: 1, msg: 'msg', isGood: true }; 93 | await pgmq.msg.send(qName, msg); 94 | 95 | const readMsg = await pgmq.msg.read(qName); 96 | 97 | expect(readMsg).toEqual(newMsg(msg)); 98 | }); 99 | 100 | it('returns undefined; queue is empty', async () => { 101 | const msg = await pgmq.msg.read(qName); 102 | expect(msg).toEqual(undefined); 103 | }); 104 | 105 | it('returns undefined; all the messages are read', async () => { 106 | await pgmq.msg.sendBatch(qName, [1, 2]); 107 | 108 | const msg1 = await pgmq.msg.read(qName); 109 | await deleteMessage(qName, msg1.msgId); 110 | const msg2 = await pgmq.msg.read(qName); 111 | await deleteMessage(qName, msg2.msgId); 112 | const msg3 = await pgmq.msg.read(qName); 113 | 114 | expect(msg1).toEqual(newMsg(1)); 115 | expect(msg2).toEqual(newMsg(2)); 116 | expect(msg3).toEqual(undefined); 117 | }); 118 | 119 | it('does not read a read message within the "vt" window', async () => { 120 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 121 | 122 | interface T { 123 | id: number; 124 | msg: string; 125 | date: Date; 126 | isGood: boolean; 127 | } 128 | const msg = { id: 1, msg: 'msg', isGood: true }; 129 | await pgmq.msg.send(qName, msg); 130 | const vt = 1; 131 | 132 | const readMsg = await pgmq.msg.read(qName, vt); 133 | expect(readMsg).toEqual(newMsg(msg)); 134 | const noMsg = await pgmq.msg.read(qName, vt); 135 | expect(noMsg).toBeUndefined(); 136 | await delay(vt * 1000); 137 | const readMsg2 = await pgmq.msg.read(qName, vt); 138 | expect(readMsg2).toEqual(newMsg(msg)); 139 | }); 140 | 141 | it('rejects floating points in the "vt" window', async () => { 142 | interface T { 143 | id: number; 144 | msg: string; 145 | date: Date; 146 | isGood: boolean; 147 | } 148 | const msg = { id: 1, msg: 'msg', isGood: true }; 149 | await pgmq.msg.send(qName, msg); 150 | 151 | await expect(() => pgmq.msg.read(qName, 1.5)).rejects.toThrow(); 152 | }); 153 | }); 154 | 155 | describe('readBatch', () => { 156 | it('returns an array of messages with their metadata', async () => { 157 | interface T { 158 | id: number; 159 | msg: string; 160 | date: Date; 161 | isGood: boolean; 162 | } 163 | const msg1 = { id: 1, msg: 'msg', isGood: true }; 164 | const msg2 = { id: 1, msg: 'msg', isGood: true }; 165 | await pgmq.msg.sendBatch(qName, [msg1, msg2]); 166 | 167 | const [readMsg1, readMsg2] = await pgmq.msg.readBatch(qName, 0, 2); 168 | 169 | expect(readMsg1).toEqual(newMsg(msg1)); 170 | expect(readMsg2).toEqual(newMsg(msg2)); 171 | }); 172 | 173 | it('returns an empty array; queue is empty', async () => { 174 | const msgs = await pgmq.msg.readBatch(qName, 0, 2); 175 | expect(msgs).toEqual([]); 176 | }); 177 | }); 178 | 179 | describe('pop', () => { 180 | it('returns a message with its metadata', async () => { 181 | interface T { 182 | id: number; 183 | msg: string; 184 | date: Date; 185 | isGood: boolean; 186 | } 187 | const msg = { id: 1, msg: 'msg', isGood: true }; 188 | await pgmq.msg.send(qName, msg); 189 | 190 | const readMsg = await pgmq.msg.pop(qName); 191 | 192 | expect(readMsg).toEqual(newMsg(msg)); 193 | }); 194 | 195 | it("deletes the message so that it can't be read again", async () => { 196 | const msg = 'msg'; 197 | await pgmq.msg.send(qName, msg); 198 | await pgmq.msg.pop(qName); 199 | await expectEmptyQueue(qName); 200 | }); 201 | 202 | it('returns undefined; queue is empty', async () => { 203 | const msg = await pgmq.msg.pop(qName); 204 | expect(msg).toEqual(undefined); 205 | }); 206 | }); 207 | 208 | describe('archive', () => { 209 | it('returns true if message is archived', async () => { 210 | const id = await pgmq.msg.send(qName, 'msg'); 211 | const archived = await pgmq.msg.archive(qName, id); 212 | expect(archived).toBe(true); 213 | }); 214 | 215 | it("archives the message so that it can't be read again", async () => { 216 | const id = await pgmq.msg.send(qName, 'msg'); 217 | await pgmq.msg.archive(qName, id); 218 | await expectEmptyQueue(qName); 219 | }); 220 | 221 | it('returns false if no such message id', async () => { 222 | const archived = await pgmq.msg.archive(qName, 1); 223 | expect(archived).toBe(false); 224 | }); 225 | }); 226 | 227 | describe('archiveBatch', () => { 228 | it('returns the message ids that were archived', async () => { 229 | const [id1, id2] = await pgmq.msg.sendBatch(qName, ['msg1', 'msg2']); 230 | const [aId1, aId2] = await pgmq.msg.archiveBatch(qName, [id1, id2]); 231 | expect(aId1).toBe(id1); 232 | expect(aId2).toBe(id2); 233 | }); 234 | 235 | it("archives the messages so that they can't be read again", async () => { 236 | const [id1, id2] = await pgmq.msg.sendBatch(qName, ['msg1', 'msg2']); 237 | await pgmq.msg.archiveBatch(qName, [id1, id2]); 238 | await expectEmptyQueue(qName); 239 | }); 240 | 241 | it("does not archive message ids that don't exist", async () => { 242 | const id = await pgmq.msg.send(qName, 'msg'); 243 | const archived = await pgmq.msg.archiveBatch(qName, [id, 2]); 244 | expect(archived).toEqual([id]); 245 | }); 246 | 247 | it('returns an empty array if no such message ids', async () => { 248 | const ids = await pgmq.msg.archiveBatch(qName, [1, 2]); 249 | expect(ids).toEqual([]); 250 | }); 251 | }); 252 | 253 | describe('delete', () => { 254 | it('returns true if message is deleted', async () => { 255 | const msg = 'msg'; 256 | const id = await pgmq.msg.send(qName, msg); 257 | 258 | const deleted = await pgmq.msg.delete(qName, id); 259 | 260 | expect(deleted).toBe(true); 261 | }); 262 | 263 | it("deletes the message so that it can't be read again", async () => { 264 | const msg = 'msg'; 265 | const id = await pgmq.msg.send(qName, msg); 266 | 267 | const deleted = await pgmq.msg.delete(qName, id); 268 | 269 | expect(deleted).toBe(true); 270 | const read = await pgmq.msg.read(qName); 271 | expect(read).toBeUndefined(); 272 | }); 273 | 274 | it('returns false if no such message id', async () => { 275 | const deleted = await pgmq.msg.delete(qName, 1); 276 | expect(deleted).toBe(false); 277 | }); 278 | }); 279 | 280 | describe('deleteBatch', () => { 281 | it('returns the message ids that were deleted', async () => { 282 | const msg1 = 'msg1'; 283 | const msg2 = 'msg2'; 284 | const [id1, id2] = await pgmq.msg.sendBatch(qName, [msg1, msg2]); 285 | 286 | const [dId1, dId2] = await pgmq.msg.deleteBatch(qName, [id1, id2]); 287 | 288 | expect(dId1).toBe(id1); 289 | expect(dId2).toBe(id2); 290 | }); 291 | 292 | it("deletes the messages so that they can't be read again", async () => { 293 | const msg1 = 'msg1'; 294 | const msg2 = 'msg2'; 295 | const [id1, id2] = await pgmq.msg.sendBatch(qName, [msg1, msg2]); 296 | 297 | await pgmq.msg.deleteBatch(qName, [id1, id2]); 298 | 299 | const noMsg = await pgmq.msg.read(qName); 300 | expect(noMsg).toBeUndefined(); 301 | }); 302 | 303 | it('does not return non existing message ids', async () => { 304 | const msg = 'msg'; 305 | const id = await pgmq.msg.send(qName, msg); 306 | 307 | const deleted = await pgmq.msg.deleteBatch(qName, [id, 2, 3, 4, 5]); 308 | 309 | expect(deleted).toEqual([id]); 310 | }); 311 | 312 | it('returns an empty array if no such message ids', async () => { 313 | const deleted = await pgmq.msg.deleteBatch(qName, [1, 2, 3, 4, 5]); 314 | expect(deleted).toEqual([]); 315 | }); 316 | }); 317 | 318 | describe('setVt', () => { 319 | it('sets the vt', async () => { 320 | const msgId = await pgmq.msg.send(qName, 'msg'); 321 | const msg = await pgmq.msg.setVt(qName, msgId, 30); 322 | 323 | expect(msg).toBeDefined(); 324 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 325 | expect(msg!.vt.valueOf()).toBeCloseTo(new Date().valueOf() + 30 * 1000, -3); 326 | }); 327 | 328 | it('does not increment the vt when called twice; but resets it', async () => { 329 | const msgId = await pgmq.msg.send(qName, 'msg'); 330 | const msg = await pgmq.msg.setVt(qName, msgId, 10); 331 | 332 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 333 | expect(msg!.vt.valueOf()).toBeCloseTo(new Date().valueOf() + 10 * 1000, -3); 334 | }); 335 | 336 | it('returns the message', async () => { 337 | const msgId = await pgmq.msg.send(qName, 'msg'); 338 | const msg = await pgmq.msg.setVt(qName, msgId, 30); 339 | 340 | expect(msg).toBeDefined(); 341 | expect(msg?.message).toEqual('msg'); 342 | }); 343 | 344 | it('returns undefined when no such msgId', async () => { 345 | const msg = await pgmq.msg.setVt(qName, 10000, 30); 346 | expect(msg).toBeUndefined(); 347 | }); 348 | 349 | it('rejects when no such queue', async () => { 350 | await expect(pgmq.msg.setVt('non-existing-queue', 10000, 30)).rejects.toThrow(); 351 | }); 352 | }); 353 | 354 | afterEach(async () => { 355 | await pgmq.queue.drop(qName); 356 | }); 357 | 358 | afterAll(async () => { 359 | await pgmq.close(); 360 | }); 361 | }); 362 | --------------------------------------------------------------------------------