├── .eslintignore ├── index.js ├── lib ├── index.js ├── STATEMENTS.enum.js ├── EVENTS.enum.js ├── connectionHandler.js ├── eventHandler.js ├── dataNormalizer.js └── MySQLEvents.js ├── .gitignore ├── AUTHORS ├── .npmignore ├── .editorconfig ├── .codeclimate.yml ├── .eslintrc.yml ├── examples └── watchWholeInstance.js ├── scripts └── test.sh ├── .circleci └── config.yml ├── docker-compose.yml ├── LICENSE ├── package.json ├── .gitattributes ├── README.md └── test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); 2 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./MySQLEvents'); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /node_modules/ 3 | *.iml 4 | /.env 5 | coverage 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Rodrigo Gomes da Silva (https://github.com/rodrigogs) 2 | -------------------------------------------------------------------------------- /lib/STATEMENTS.enum.js: -------------------------------------------------------------------------------- 1 | const STATEMENTS = { 2 | ALL: 'ALL', 3 | INSERT: 'INSERT', 4 | UPDATE: 'UPDATE', 5 | DELETE: 'DELETE', 6 | }; 7 | 8 | module.exports = STATEMENTS; 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.vscode/ 2 | /.idea/ 3 | /.circleci/ 4 | /example/ 5 | /examples/ 6 | .codeclimate.yml 7 | .editorconfig 8 | .eslintignore 9 | .eslintrc.yml 10 | .gitattributes 11 | .gitignore 12 | example.js 13 | *.iml 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | #trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | eslint: 3 | enabled: true 4 | channel: 'eslint-2' 5 | checks: 6 | import/no-unresolved: 7 | enabled: false 8 | new-cap: 9 | enabled: false 10 | ratings: 11 | paths: 12 | - '*.js' 13 | -------------------------------------------------------------------------------- /lib/EVENTS.enum.js: -------------------------------------------------------------------------------- 1 | const EVENTS = { 2 | STARTED: 'started', 3 | STOPPED: 'stopped', 4 | PAUSED: 'paused', 5 | RESUMED: 'resumed', 6 | BINLOG: 'binlog', 7 | TRIGGER_ERROR: 'triggerError', 8 | CONNECTION_ERROR: 'connectionError', 9 | ZONGJI_ERROR: 'zongjiError', 10 | }; 11 | 12 | module.exports = EVENTS; 13 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: airbnb-base 2 | plugins: 3 | - import 4 | rules: 5 | no-shadow: off 6 | import/no-dynamic-require: off 7 | global-require: off 8 | no-param-reassign: off 9 | consistent-return: off 10 | arrow-body-style: off 11 | no-underscore-dangle: off 12 | import/extensions: off 13 | prefer-destructuring: off 14 | strict: off 15 | max-len: off 16 | no-console: off 17 | no-continue: off 18 | no-return-assign: off 19 | globals: 20 | process: on 21 | describe: on 22 | beforeAll: on 23 | afterAll: on 24 | beforeEach: on 25 | afterEach: on 26 | suite: on 27 | test: on 28 | it: on 29 | -------------------------------------------------------------------------------- /examples/watchWholeInstance.js: -------------------------------------------------------------------------------- 1 | const MySQLEvents = require('@rodrigogs/mysql-events'); 2 | 3 | const program = async () => { 4 | const instance = new MySQLEvents({ 5 | host: 'localhost', 6 | user: 'root', 7 | password: 'root', 8 | }, { 9 | startAtEnd: true, 10 | }); 11 | 12 | await instance.start(); 13 | 14 | instance.addTrigger({ 15 | name: 'Whole database instance', 16 | expression: '*', 17 | statement: MySQLEvents.STATEMENTS.ALL, 18 | onEvent: (event) => { 19 | console.log(event); 20 | }, 21 | }); 22 | 23 | instance.on(MySQLEvents.EVENTS.CONNECTION_ERROR, console.error); 24 | instance.on(MySQLEvents.EVENTS.ZONGJI_ERROR, console.error); 25 | }; 26 | 27 | program() 28 | .then(() => console.log('Waiting for database vents...')) 29 | .catch(console.error); 30 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ### CONFIG 4 | export MSYS_NO_PATHCONV=1; # git-bash workaroung for Windows 5 | 6 | my_dir="$(dirname "$0")"; 7 | 8 | ### PROGRAM 9 | docker-compose up -d; 10 | echo 'Waiting for docker services...'; 11 | while ! docker exec mysql55 mysqladmin ping -h'127.0.0.1' --silent; do sleep 3; done 12 | while ! docker exec mysql56 mysqladmin ping -h'127.0.0.1' --silent; do sleep 3; done 13 | while ! docker exec mysql57 mysqladmin ping -h'127.0.0.1' --silent; do sleep 3; done 14 | while ! docker exec mysql80 mysqladmin ping -h'127.0.0.1' --silent; do sleep 3; done 15 | 16 | ### SINGLE CONNECTION 17 | docker run --rm -t \ 18 | --net=host \ 19 | -v `pwd`:/app \ 20 | -w /app node:8-alpine \ 21 | /bin/sh -c "npm install && npm test" 22 | 23 | exitCode=$?; 24 | 25 | ### CONNECTION POOL 26 | if [ "$exitCode" == "0" ]; then 27 | docker run --rm -t \ 28 | --net=host \ 29 | -v `pwd`:/app \ 30 | -w /app node:8-alpine \ 31 | /bin/sh -c "export IS_POOL=true; npm test" 32 | 33 | exitCode=$?; 34 | fi 35 | 36 | docker-compose down -v; 37 | 38 | exit $exitCode; 39 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | machine: true 5 | working_directory: ~/mysql-events 6 | steps: 7 | - checkout 8 | 9 | - restore_cache: 10 | keys: 11 | - lib-dependencies-{{ checksum "package.json" }} 12 | - lib-dependencies- 13 | 14 | - run: 15 | name: Setup Code Climate test-reporter 16 | command: | 17 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 18 | chmod +x ./cc-test-reporter 19 | 20 | - run: 21 | name: Install dependencies 22 | command: npm install 23 | 24 | # - run: 25 | # name: Wait for DB 26 | # command: dockerize -wait tcp://127.0.0.1:3306 -timeout 120s 27 | 28 | - save_cache: 29 | paths: 30 | - node_modules 31 | key: lib-dependencies-{{ checksum "package.json" }} 32 | 33 | - run: 34 | name: Run tests 35 | command: | 36 | chmod +x scripts/test.sh 37 | ./scripts/test.sh 38 | 39 | # - run: 40 | # name: Run tests/coverage 41 | # command: | 42 | # npm run coverage 43 | # ./cc-test-reporter format-coverage -t lcov -o coverage.json coverage/lcov.info 44 | # ./cc-test-reporter upload-coverage -i coverage.json 45 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '2' 3 | services: 4 | mysql55: 5 | image: mysql:5.5 6 | container_name: mysql55 7 | command: [ "--server-id=1", "--log-bin=/var/lib/mysql/mysql-bin.log", "--binlog-format=row"] 8 | ports: 9 | - 3355:3306 10 | networks: 11 | default: 12 | aliases: 13 | - mysql55 14 | environment: 15 | MYSQL_ROOT_PASSWORD: root 16 | 17 | mysql56: 18 | image: mysql:5.6 19 | container_name: mysql56 20 | command: [ "--server-id=1", "--log-bin=/var/lib/mysql/mysql-bin.log", "--binlog-format=row"] 21 | ports: 22 | - 3356:3306 23 | networks: 24 | default: 25 | aliases: 26 | - mysql56 27 | environment: 28 | MYSQL_ROOT_PASSWORD: root 29 | 30 | mysql57: 31 | image: mysql:5.7 32 | container_name: mysql57 33 | command: [ "--server-id=1", "--log-bin=/var/lib/mysql/mysql-bin.log", "--binlog-format=row"] 34 | ports: 35 | - 3357:3306 36 | networks: 37 | default: 38 | aliases: 39 | - mysql57 40 | environment: 41 | MYSQL_ROOT_PASSWORD: root 42 | 43 | mysql80: 44 | image: mysql:8.0 45 | container_name: mysql80 46 | command: [ "--server-id=1", "--log-bin=/var/lib/mysql/mysql-bin.log", "--binlog-format=row", "--default-authentication-plugin=mysql_native_password"] 47 | ports: 48 | - 3380:3306 49 | networks: 50 | default: 51 | aliases: 52 | - mysql80 53 | environment: 54 | MYSQL_ROOT_PASSWORD: root 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, Rodrigo Gomes da Silva 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /lib/connectionHandler.js: -------------------------------------------------------------------------------- 1 | const debug = require('debuggler')(); 2 | const mysql = require('mysql'); 3 | const Connection = require('mysql/lib/Connection'); 4 | const Pool = require('mysql/lib/Pool'); 5 | 6 | const connect = connection => new Promise((resolve, reject) => connection.connect((err) => { 7 | if (err) return reject(err); 8 | resolve(); 9 | })); 10 | 11 | const connectionHandler = async (connection) => { 12 | if (connection instanceof Pool) { 13 | debug('reusing pool:', connection); 14 | if (connection._closed) { 15 | connection = mysql.createPool(connection.config.connectionConfig); 16 | } 17 | } 18 | 19 | if (connection instanceof Connection) { 20 | debug('reusing connection:', connection); 21 | if (connection.state !== 'connected') { 22 | connection = mysql.createConnection(connection.config); 23 | } 24 | } 25 | 26 | if (typeof connection === 'string') { 27 | debug('creating connection from string:', connection); 28 | connection = mysql.createConnection(connection); 29 | } 30 | 31 | if ((typeof connection === 'object') && (!(connection instanceof Connection) && !(connection instanceof Pool))) { 32 | debug('creating connection from object:', connection); 33 | if (connection.isPool) { 34 | connection = mysql.createPool(connection); 35 | } else { 36 | connection = mysql.createConnection(connection); 37 | } 38 | } 39 | 40 | if ((connection instanceof Connection) && (connection.state !== 'connected')) { 41 | debug('initializing connection'); 42 | await connect(connection); 43 | } 44 | 45 | return connection; 46 | }; 47 | 48 | module.exports = connectionHandler; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rodrigogs/mysql-events", 3 | "version": "0.6.0", 4 | "license": "BSD-3-Clause", 5 | "description": "A node.js package that watches a MySQL database and runs callbacks on matched events like updates on tables and/or specific columns.", 6 | "homepage": "https://github.com/rodrigogs/mysql-events", 7 | "keywords": [ 8 | "mysql", 9 | "events", 10 | "trigger", 11 | "notify", 12 | "watcher" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git@github.com:rodrigogs/mysql-events.git" 17 | }, 18 | "main": "index.js", 19 | "scripts": { 20 | "eslint": "eslint . --ext .js", 21 | "test": "npm run test:55 && npm run test:56 && npm run test:57 && npm run test:80", 22 | "test:55": "cross-env DATABASE_PORT=3355 jest --forceExit --runInBand", 23 | "test:56": "cross-env DATABASE_PORT=3356 jest --forceExit --runInBand", 24 | "test:57": "cross-env DATABASE_PORT=3357 jest --forceExit --runInBand", 25 | "test:80": "cross-env DATABASE_PORT=3380 jest --forceExit --runInBand", 26 | "test:local": "./scripts/test.sh", 27 | "coverage": "nyc --reporter=lcov npm test" 28 | }, 29 | "dependencies": { 30 | "@rodrigogs/zongji": "^0.4.14", 31 | "debug": "^4.1.1", 32 | "debuggler": "^1.0.0", 33 | "mysql": "^2.17.1" 34 | }, 35 | "devDependencies": { 36 | "chai": "^4.2.0", 37 | "codeclimate-test-reporter": "^0.5.1", 38 | "cross-env": "^5.2.0", 39 | "dotenv-cli": "^2.0.0", 40 | "eslint": "^5.16.0", 41 | "eslint-config-airbnb-base": "^13.1.0", 42 | "eslint-plugin-import": "^2.17.2", 43 | "jest": "^24.8.0", 44 | "nyc": "^14.1.1" 45 | }, 46 | "engines": { 47 | "node": ">=7.6.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/eventHandler.js: -------------------------------------------------------------------------------- 1 | const normalize = require('./dataNormalizer'); 2 | const STATEMENTS = require('./STATEMENTS.enum'); 3 | 4 | const parseExpression = (expression = '') => { 5 | const parts = expression.split('.'); 6 | return { 7 | schema: parts[0], 8 | table: parts[1], 9 | column: parts[2], 10 | value: parts[3], 11 | }; 12 | }; 13 | 14 | const normalizeEvent = (event) => { 15 | const dataEvents = [ 16 | 'writerows', 17 | 'updaterows', 18 | 'deleterows', 19 | ]; 20 | 21 | if (dataEvents.indexOf(event.getEventName()) !== -1) { 22 | return normalize(event); 23 | } 24 | 25 | return event; 26 | }; 27 | 28 | /** 29 | * @param {Object} event 30 | * @param {Object} triggers 31 | * @return {Object[]} 32 | */ 33 | const findTriggers = (event, triggers) => { 34 | if (!event.type) return []; 35 | 36 | const triggerExpressions = Object.getOwnPropertyNames(triggers); 37 | const statements = []; 38 | 39 | for (let i = 0, len = triggerExpressions.length; i < len; i += 1) { 40 | const expression = triggerExpressions[i]; 41 | const trigger = triggers[expression]; 42 | 43 | const parts = parseExpression(expression); 44 | if (parts.schema !== '*' && parts.schema !== event.schema) continue; 45 | if (!(!parts.table || parts.table === '*') && parts.table !== event.table) continue; 46 | if (!(!parts.column || parts.column === '*') && event.affectedColumns.indexOf(parts.column) === -1) continue; 47 | 48 | if (trigger.statements[STATEMENTS.ALL]) statements.push(...trigger.statements[STATEMENTS.ALL]); 49 | if (trigger.statements[event.type]) statements.push(...trigger.statements[event.type]); 50 | } 51 | 52 | return statements; 53 | }; 54 | 55 | /** 56 | * @type {{normalizeEvent: normalizeEvent, findTriggers: findTriggers}} 57 | */ 58 | const eventHandler = { 59 | normalizeEvent, 60 | findTriggers, 61 | }; 62 | 63 | module.exports = eventHandler; 64 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # These settings are for any web project 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | # * text=auto 6 | # NOTE - originally I had the above line un-commented. it caused me a lot of grief related to line endings because I was dealing with WordPress plugins and the website changing line endings out if a user modified a plugin through the web interface. commenting this line out seems to have alleviated the git chaos where simply switching to a branch caused it to believe 500 files were modified. 7 | 8 | # 9 | # The above will handle all files NOT found below 10 | # 11 | 12 | # 13 | ## These files are text and should be normalized (Convert crlf => lf) 14 | # 15 | 16 | # source code 17 | *.php text 18 | *.css text 19 | *.sass text 20 | *.scss text 21 | *.less text 22 | *.styl text 23 | *.js text 24 | *.coffee text 25 | *.json text 26 | *.htm text 27 | *.html text 28 | *.xml text 29 | *.svg text 30 | *.txt text 31 | *.ini text 32 | *.inc text 33 | *.pl text 34 | *.rb text 35 | *.py text 36 | *.scm text 37 | *.sql text 38 | *.sh text 39 | *.bat text 40 | 41 | # templates 42 | *.ejs text 43 | *.hbt text 44 | *.jade text 45 | *.haml text 46 | *.hbs text 47 | *.dot text 48 | *.tmpl text 49 | *.phtml text 50 | 51 | # server config 52 | .htaccess text 53 | 54 | # git config 55 | .gitattributes text 56 | .gitignore text 57 | .gitconfig text 58 | 59 | # code analysis config 60 | .jshintrc text 61 | .jscsrc text 62 | .jshintignore text 63 | .csslintrc text 64 | 65 | # misc config 66 | *.yaml text 67 | *.yml text 68 | .editorconfig text 69 | 70 | # build config 71 | *.npmignore text 72 | *.bowerrc text 73 | 74 | # Heroku 75 | Procfile text 76 | .slugignore text 77 | 78 | # Documentation 79 | *.md text 80 | LICENSE text 81 | AUTHORS text 82 | 83 | 84 | # 85 | ## These files are binary and should be left untouched 86 | # 87 | 88 | # (binary is a macro for -text -diff) 89 | *.png binary 90 | *.jpg binary 91 | *.jpeg binary 92 | *.gif binary 93 | *.ico binary 94 | *.mov binary 95 | *.mp4 binary 96 | *.mp3 binary 97 | *.flv binary 98 | *.fla binary 99 | *.swf binary 100 | *.gz binary 101 | *.zip binary 102 | *.7z binary 103 | *.ttf binary 104 | *.eot binary 105 | *.woff binary 106 | *.pyc binary 107 | *.pdf binary 108 | -------------------------------------------------------------------------------- /lib/dataNormalizer.js: -------------------------------------------------------------------------------- 1 | const STATEMENTS = require('./STATEMENTS.enum'); 2 | 3 | const getEventType = (eventName) => { 4 | return { 5 | writerows: STATEMENTS.INSERT, 6 | updaterows: STATEMENTS.UPDATE, 7 | deleterows: STATEMENTS.DELETE, 8 | }[eventName]; 9 | }; 10 | 11 | const normalizeRow = (row) => { 12 | if (!row) return undefined; 13 | 14 | const columns = Object.getOwnPropertyNames(row); 15 | for (let i = 0, len = columns.length; i < len; i += 1) { 16 | const columnValue = row[columns[i]]; 17 | 18 | if (columnValue instanceof Buffer && columnValue.length === 1) { // It's a boolean 19 | row[columns[i]] = (columnValue[0] > 0); 20 | } 21 | } 22 | 23 | return row; 24 | }; 25 | 26 | const hasDifference = (beforeValue, afterValue) => { 27 | if ((beforeValue && afterValue) && beforeValue instanceof Date) { 28 | return beforeValue.getTime() !== afterValue.getTime(); 29 | } 30 | 31 | return beforeValue !== afterValue; 32 | }; 33 | 34 | const fixRowStructure = (type, row) => { 35 | if (type === STATEMENTS.INSERT) { 36 | row = { 37 | before: undefined, 38 | after: row, 39 | }; 40 | } 41 | if (type === STATEMENTS.DELETE) { 42 | row = { 43 | before: row, 44 | after: undefined, 45 | }; 46 | } 47 | 48 | return row; 49 | }; 50 | 51 | const resolveAffectedColumns = (normalizedEvent, normalizedRows) => { 52 | const columns = Object.getOwnPropertyNames((normalizedRows.after || normalizedRows.before)); 53 | for (let i = 0, len = columns.length; i < len; i += 1) { 54 | const columnName = columns[i]; 55 | const beforeValue = (normalizedRows.before || {})[columnName]; 56 | const afterValue = (normalizedRows.after || {})[columnName]; 57 | 58 | if (hasDifference(beforeValue, afterValue)) { 59 | if (normalizedEvent.affectedColumns.indexOf(columnName) === -1) { 60 | normalizedEvent.affectedColumns.push(columnName); 61 | } 62 | } 63 | } 64 | }; 65 | 66 | const dataNormalizer = (event) => { 67 | const type = getEventType(event.getEventName()); 68 | const schema = event.tableMap[event.tableId].parentSchema; 69 | const table = event.tableMap[event.tableId].tableName; 70 | const { timestamp, nextPosition, binlogName } = event; 71 | 72 | const normalized = { 73 | type, 74 | schema, 75 | table, 76 | affectedRows: [], 77 | affectedColumns: [], 78 | timestamp, 79 | nextPosition, 80 | binlogName, 81 | }; 82 | 83 | event.rows.forEach((row) => { 84 | row = fixRowStructure(type, row); 85 | 86 | const normalizedRows = { 87 | after: normalizeRow(row.after), 88 | before: normalizeRow(row.before), 89 | }; 90 | 91 | normalized.affectedRows.push(normalizedRows); 92 | 93 | resolveAffectedColumns(normalized, normalizedRows); 94 | }); 95 | 96 | return normalized; 97 | }; 98 | 99 | module.exports = dataNormalizer; 100 | -------------------------------------------------------------------------------- /lib/MySQLEvents.js: -------------------------------------------------------------------------------- 1 | const debug = require('debuggler')(); 2 | const ZongJi = require('@rodrigogs/zongji'); 3 | const EventEmitter = require('events'); 4 | const eventHandler = require('./eventHandler'); 5 | const connectionHandler = require('./connectionHandler'); 6 | 7 | const EVENTS = require('./EVENTS.enum'); 8 | const STATEMENTS = require('./STATEMENTS.enum'); 9 | 10 | /** 11 | * @param {Object|Connection|String} connection 12 | * @param {Object} options 13 | */ 14 | class MySQLEvents extends EventEmitter { 15 | constructor(connection, options = {}) { 16 | super(); 17 | 18 | this.connection = connection; 19 | this.options = options; 20 | 21 | this.isStarted = false; 22 | this.isPaused = false; 23 | 24 | this.zongJi = null; 25 | this.expressions = {}; 26 | } 27 | 28 | /** 29 | * @return {{BINLOG, TRIGGER_ERROR, CONNECTION_ERROR, ZONGJI_ERROR}} 30 | * @constructor 31 | */ 32 | static get EVENTS() { 33 | return EVENTS; 34 | } 35 | 36 | /** 37 | * @return {{ALL: string, INSERT: string, UPDATE: string, DELETE: string}} 38 | */ 39 | static get STATEMENTS() { 40 | return STATEMENTS; 41 | } 42 | 43 | /** 44 | * @param {Object} event binlog event object. 45 | * @private 46 | */ 47 | _handleEvent(event) { 48 | if (!this.zongJi) return; 49 | 50 | event.binlogName = this.zongJi.binlogName; 51 | event = eventHandler.normalizeEvent(event); 52 | const triggers = eventHandler.findTriggers(event, this.expressions); 53 | 54 | Promise.all(triggers.map(async (trigger) => { 55 | try { 56 | await trigger.onEvent(event); 57 | } catch (error) { 58 | this.emit(EVENTS.TRIGGER_ERROR, { trigger, error }); 59 | } 60 | })).then(() => debug('triggers executed')); 61 | } 62 | 63 | /** 64 | * @private 65 | */ 66 | _handleZongJiEvents() { 67 | this.zongJi.on('error', err => this.emit(EVENTS.ZONGJI_ERROR, err)); 68 | this.zongJi.on('binlog', (event) => { 69 | this.emit(EVENTS.BINLOG, event); 70 | this._handleEvent(event); 71 | }); 72 | } 73 | 74 | /** 75 | * @private 76 | */ 77 | _handleConnectionEvents() { 78 | this.connection.on('error', err => this.emit(EVENTS.CONNECTION_ERROR, err)); 79 | } 80 | 81 | /** 82 | * @param {Object} [options = {}] 83 | * @return {Promise} 84 | */ 85 | async start(options = {}) { 86 | if (this.isStarted) return; 87 | debug('connecting to mysql'); 88 | this.connection = await connectionHandler(this.connection); 89 | 90 | debug('initializing zongji'); 91 | this.zongJi = new ZongJi(this.connection, Object.assign({}, this.options, options)); 92 | 93 | debug('connected'); 94 | this.emit('connected'); 95 | this._handleConnectionEvents(); 96 | this._handleZongJiEvents(); 97 | this.zongJi.start(this.options); 98 | this.isStarted = true; 99 | this.emit(EVENTS.STARTED); 100 | } 101 | 102 | /** 103 | * @return {Promise} 104 | */ 105 | async stop() { 106 | if (!this.isStarted) return; 107 | debug('disconnecting from mysql'); 108 | 109 | this.zongJi.stop(); 110 | delete this.zongJi; 111 | 112 | await new Promise((resolve, reject) => { 113 | this.connection.end((err) => { 114 | if (err) return reject(err); 115 | resolve(); 116 | }); 117 | }); 118 | 119 | debug('disconnected'); 120 | this.emit('disconnected'); 121 | this.isStarted = false; 122 | this.emit(EVENTS.STOPPED); 123 | } 124 | 125 | /** 126 | * 127 | */ 128 | pause() { 129 | if (!this.isStarted || this.isPaused) return; 130 | debug('pausing connection'); 131 | 132 | this.zongJi.connection.pause(); 133 | this.isPaused = true; 134 | this.emit(EVENTS.PAUSED); 135 | } 136 | 137 | /** 138 | * 139 | */ 140 | resume() { 141 | if (!this.isStarted || !this.isPaused) return; 142 | debug('resuming connection'); 143 | 144 | this.zongJi.connection.resume(); 145 | this.isPaused = false; 146 | this.emit(EVENTS.RESUMED); 147 | } 148 | 149 | /** 150 | * @param {String} name 151 | * @param {String} expression 152 | * @param {String} [statement = 'ALL'] 153 | * @param {Function} [onEvent] 154 | * @return {void} 155 | */ 156 | addTrigger({ 157 | name, 158 | expression, 159 | statement = STATEMENTS.ALL, 160 | onEvent, 161 | }) { 162 | if (!name) throw new Error('Missing trigger name'); 163 | if (!expression) throw new Error('Missing trigger expression'); 164 | if (typeof onEvent !== 'function') throw new Error('onEvent argument should be a function'); 165 | 166 | this.expressions[expression] = this.expressions[expression] || {}; 167 | this.expressions[expression].statements = this.expressions[expression].statements || {}; 168 | this.expressions[expression].statements[statement] = this.expressions[expression].statements[statement] || []; 169 | 170 | const triggers = this.expressions[expression].statements[statement]; 171 | if (triggers.find(st => st.name === name)) { 172 | throw new Error(`There's already a trigger named "${name}" for expression "${expression}" with statement "${statement}"`); 173 | } 174 | 175 | triggers.push({ 176 | name, 177 | onEvent, 178 | }); 179 | } 180 | 181 | /** 182 | * @param {String} name 183 | * @param {String} expression 184 | * @param {String} [statement = 'ALL'] 185 | * @return {void} 186 | */ 187 | removeTrigger({ 188 | name, 189 | expression, 190 | statement = STATEMENTS.ALL, 191 | }) { 192 | const exp = this.expressions[expression]; 193 | if (!exp) return; 194 | 195 | const triggers = exp.statements[statement]; 196 | if (!triggers) return; 197 | 198 | const named = triggers.find(st => st.name === name); 199 | if (!named) return; 200 | 201 | const index = triggers.indexOf(named); 202 | triggers.splice(index, 1); 203 | } 204 | } 205 | 206 | module.exports = MySQLEvents; 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mysql-events 2 | [![CircleCI](https://circleci.com/gh/rodrigogs/mysql-events.svg)](https://circleci.com/gh/rodrigogs/mysql-events) 3 | [![Code Climate](https://codeclimate.com/github/rodrigogs/mysql-events/badges/gpa.svg)](https://codeclimate.com/github/rodrigogs/mysql-events) 4 | [![Test Coverage](https://codeclimate.com/github/rodrigogs/mysql-events/badges/coverage.svg)](https://codeclimate.com/github/rodrigogs/mysql-events/coverage) 5 | 6 | A [node.js](https://nodejs.org) package that watches a MySQL database and runs callbacks on matched events. 7 | 8 | This package is based on the [original ZongJi](https://github.com/nevill/zongji) and the [original mysql-events](https://github.com/spencerlambert/mysql-events) modules. Please make sure that you meet the requirements described at [ZongJi](https://github.com/rodrigogs/zongji#installation), like MySQL binlog etc. 9 | 10 | Check [@kuroski](https://github.com/kuroski)'s [mysql-events-ui](https://github.com/kuroski/mysql-events-ui) for a `mysql-events` UI implementation. 11 | 12 | ## Install 13 | ```sh 14 | npm install @rodrigogs/mysql-events 15 | ``` 16 | 17 | ## Quick Start 18 | ```javascript 19 | const mysql = require('mysql'); 20 | const MySQLEvents = require('@rodrigogs/mysql-events'); 21 | 22 | const program = async () => { 23 | const connection = mysql.createConnection({ 24 | host: 'localhost', 25 | user: 'root', 26 | password: 'root', 27 | }); 28 | 29 | const instance = new MySQLEvents(connection, { 30 | startAtEnd: true, 31 | excludedSchemas: { 32 | mysql: true, 33 | }, 34 | }); 35 | 36 | await instance.start(); 37 | 38 | instance.addTrigger({ 39 | name: 'TEST', 40 | expression: '*', 41 | statement: MySQLEvents.STATEMENTS.ALL, 42 | onEvent: (event) => { // You will receive the events here 43 | console.log(event); 44 | }, 45 | }); 46 | 47 | instance.on(MySQLEvents.EVENTS.CONNECTION_ERROR, console.error); 48 | instance.on(MySQLEvents.EVENTS.ZONGJI_ERROR, console.error); 49 | }; 50 | 51 | program() 52 | .then(() => console.log('Waiting for database events...')) 53 | .catch(console.error); 54 | ``` 55 | [Check the examples](https://github.com/rodrigogs/mysql-events/examples) 56 | 57 | ## Usage 58 | ### #constructor(connection, options) 59 | - Instantiate and create a database connection using a DSN 60 | ```javascript 61 | const dsn = { 62 | host: 'localhost', 63 | user: 'username', 64 | password: 'password', 65 | }; 66 | 67 | const myInstance = new MySQLEvents(dsn, { /* ZongJi options */ }); 68 | ``` 69 | 70 | - Instantiate and create a database connection using a preexisting connection 71 | ```javascript 72 | const connection = mysql.createConnection({ 73 | host: 'localhost', 74 | user: 'username', 75 | password: 'password', 76 | }); 77 | 78 | const myInstance = new MySQLEvents(connection, { /* ZongJi options */ }); 79 | ``` 80 | - Options(the second argument) is for ZongJi options 81 | ```javascript 82 | const myInstance = new MySQLEvents({ /* connection */ }, { 83 | serverId: 3, 84 | startAtEnd: true, 85 | }); 86 | ``` 87 | [See more about ZongJi options](https://github.com/rodrigogs/zongji#zongji-class) 88 | 89 | ### #start() 90 | - start function ensures that MySQL is connected and ZongJi is running before resolving its promise 91 | ```javascript 92 | myInstance.start() 93 | .then(() => console.log('I\'m running!')) 94 | .catch(err => console.error('Something bad happened', err)); 95 | ``` 96 | ### #stop() 97 | - stop function terminates MySQL connection and stops ZongJi before resolving its promise 98 | ```javascript 99 | myInstance.stop() 100 | .then(() => console.log('I\'m stopped!')) 101 | .catch(err => console.error('Something bad happened', err)); 102 | ``` 103 | ### #pause() 104 | - pause function pauses MySQL connection until `#resume()` is called, this it useful when you're receiving more data than you can handle at the time 105 | ```javascript 106 | myInstance.pause(); 107 | ``` 108 | ### #resume() 109 | - resume function resumes a paused MySQL connection, so it starts to generate binlog events again 110 | ```javascript 111 | myInstance.resume(); 112 | ``` 113 | ### #addTrigger({ name, expression, statement, onEvent }) 114 | - Adds a trigger for the given expression/statement and calls the `onEvent` function when the event happens 115 | ```javascript 116 | instance.addTrigger({ 117 | name: 'MY_TRIGGER', 118 | expression: 'MY_SCHEMA.MY_TABLE.MY_COLUMN', 119 | statement: MySQLEvents.STATEMENTS.INSERT, 120 | onEvent: async (event) => { 121 | // Here you will get the events for the given expression/statement. 122 | // This could be an async function. 123 | await doSomething(event); 124 | }, 125 | }); 126 | ``` 127 | - The `name` argument must be unique for each expression/statement, it will be user later if you want to remove a trigger 128 | ```javascript 129 | instance.addTrigger({ 130 | name: 'MY_TRIGGER', 131 | expression: 'MY_SCHEMA.*', 132 | statement: MySQLEvents.STATEMENTS.ALL, 133 | ... 134 | }); 135 | 136 | instance.removeTrigger({ 137 | name: 'MY_TRIGGER', 138 | expression: 'MY_SCHEMA.*', 139 | statement: MySQLEvents.STATEMENTS.ALL, 140 | }); 141 | ``` 142 | - The `expression` argument is very dynamic, you can replace any step by `*` to make it wait for any schema, table or column events 143 | ```javascript 144 | instance.addTrigger({ 145 | name: 'Name updates from table USERS at SCHEMA2', 146 | expression: 'SCHEMA2.USERS.name', 147 | ... 148 | }); 149 | ``` 150 | ```javascript 151 | instance.addTrigger({ 152 | name: 'All database events', 153 | expression: '*', 154 | ... 155 | }); 156 | ``` 157 | ```javascript 158 | instance.addTrigger({ 159 | name: 'All events from SCHEMA2', 160 | expression: 'SCHEMA2.*', 161 | ... 162 | }); 163 | ``` 164 | ```javascript 165 | instance.addTrigger({ 166 | name: 'All database events for table USERS', 167 | expression: '*.USERS', 168 | ... 169 | }); 170 | ``` 171 | - The `statement` argument indicates in which database operation an event should be triggered 172 | ```javascript 173 | instance.addTrigger({ 174 | ... 175 | statement: MySQLEvents.STATEMENTS.ALL, 176 | ... 177 | }); 178 | ``` 179 | [Allowed statements](https://github.com/rodrigogs/mysql-events/blob/master/lib/STATEMENTS.enum.js) 180 | - The `onEvent` argument is a function where the trigger events should be threated 181 | ```javascript 182 | instance.addTrigger({ 183 | ... 184 | onEvent: (event) => { 185 | console.log(event); // { type, schema, table, affectedRows: [], affectedColumns: [], timestamp, } 186 | }, 187 | ... 188 | }); 189 | ``` 190 | ### #removeTrigger({ name, expression, statement }) 191 | - Removes a trigger from the current instance 192 | ```javascript 193 | instance.removeTrigger({ 194 | name: 'My previous created trigger', 195 | expression: '', 196 | statement: MySQLEvents.STATEMENTS.INSERT, 197 | }); 198 | ``` 199 | ### Instance events 200 | - MySQLEvents class emits some events related to its MySQL connection and ZongJi instance 201 | ```javascript 202 | instance.on(MySQLEvents.EVENTS.CONNECTION_ERROR, (err) => console.log('Connection error', err)); 203 | instance.on(MySQLEvents.EVENTS.ZONGJI_ERROR, (err) => console.log('ZongJi error', err)); 204 | ``` 205 | [Available events](https://github.com/rodrigogs/mysql-events/blob/master/lib/EVENTS.enum.js) 206 | 207 | ## Tigger event object 208 | It has the following structure: 209 | ```javascript 210 | { 211 | type: 'INSERT | UPDATE | DELETE', 212 | schema: 'SCHEMA_NAME', 213 | table: 'TABLE_NAME', 214 | affectedRows: [{ 215 | before: { 216 | column1: 'A', 217 | column2: 'B', 218 | column3: 'C', 219 | ... 220 | }, 221 | after: { 222 | column1: 'D', 223 | column2: 'E', 224 | column3: 'F', 225 | ... 226 | }, 227 | }], 228 | affectedColumns: [ 229 | 'column1', 230 | 'column2', 231 | 'column3', 232 | ], 233 | timestamp: 1530645380029, 234 | nextPosition: 1343, 235 | binlogName: 'bin.001', 236 | } 237 | ``` 238 | 239 | **Make sure the database user has the privilege to read the binlog on database that you want to watch on.** 240 | 241 | ## LICENSE 242 | [BSD-3-Clause](https://github.com/rodrigogs/mysql-events/blob/master/LICENSE) © Rodrigo Gomes da Silva 243 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable padded-blocks,no-unused-expressions,no-await-in-loop */ 2 | 3 | const chai = require('chai'); 4 | const mysql = require('mysql'); 5 | const MySQLEvents = require('./lib'); 6 | 7 | const { expect } = chai; 8 | 9 | const DATABASE_PORT = process.env.DATABASE_PORT || 3306; 10 | const IS_POOL = process.env.IS_POOL || false; 11 | const TEST_SCHEMA_1 = 'testSchema1'; 12 | const TEST_SCHEMA_2 = 'testSchema2'; 13 | const TEST_TABLE_1 = 'testTable1'; 14 | const TEST_TABLE_2 = 'testTable2'; 15 | const TEST_COLUMN_1 = 'column1'; 16 | const TEST_COLUMN_2 = 'column2'; 17 | 18 | const delay = (timeout = 500) => new Promise((resolve) => { 19 | setTimeout(resolve, timeout); 20 | }); 21 | 22 | let _serverId = 0; 23 | const getServerId = () => { 24 | return _serverId += 1; 25 | }; 26 | 27 | const getConnection = () => { 28 | const connection = mysql.createConnection({ 29 | host: 'localhost', 30 | user: 'root', 31 | password: 'root', 32 | port: DATABASE_PORT, 33 | }); 34 | 35 | return new Promise((resolve, reject) => connection.connect((err) => { 36 | if (err) return reject(err); 37 | resolve(connection); 38 | })); 39 | }; 40 | 41 | const executeQuery = (conn, query) => { 42 | return new Promise((resolve, reject) => conn.query(query, (err, results) => { 43 | if (err) return reject(err); 44 | resolve(results); 45 | })); 46 | }; 47 | 48 | const closeConnection = conn => new Promise((resolve, reject) => conn.end((err) => { 49 | if (err) return reject(err); 50 | resolve(); 51 | })); 52 | 53 | const grantPrivileges = async () => { 54 | const conn = await getConnection(); 55 | try { 56 | await executeQuery(conn, 'GRANT REPLICATION SLAVE, REPLICATION CLIENT, SELECT ON *.* TO \'root\'@\'localhost\''); 57 | } catch (err) { 58 | throw err; 59 | } finally { 60 | await closeConnection(conn); 61 | } 62 | }; 63 | 64 | const createSchemas = async () => { 65 | console.log('Creating connection...'); 66 | const conn = await getConnection(); 67 | try { 68 | await executeQuery(conn, `CREATE DATABASE IF NOT EXISTS ${TEST_SCHEMA_1};`); 69 | await executeQuery(conn, `CREATE DATABASE IF NOT EXISTS ${TEST_SCHEMA_2};`); 70 | } catch (err) { 71 | throw err; 72 | } finally { 73 | await closeConnection(conn); 74 | } 75 | }; 76 | 77 | const dropSchemas = async () => { 78 | const conn = await getConnection(); 79 | try { 80 | await executeQuery(conn, `DROP DATABASE IF EXISTS ${TEST_SCHEMA_1};`); 81 | await executeQuery(conn, `DROP DATABASE IF EXISTS ${TEST_SCHEMA_2};`); 82 | } catch (err) { 83 | throw err; 84 | } finally { 85 | await closeConnection(conn); 86 | } 87 | }; 88 | 89 | const createTables = async () => { 90 | const conn = await getConnection(); 91 | try { 92 | await executeQuery(conn, `CREATE TABLE IF NOT EXISTS ${TEST_SCHEMA_1}.${TEST_TABLE_1} (${TEST_COLUMN_1} varchar(255), ${TEST_COLUMN_2} varchar(255));`); 93 | await executeQuery(conn, `CREATE TABLE IF NOT EXISTS ${TEST_SCHEMA_1}.${TEST_TABLE_2} (${TEST_COLUMN_1} varchar(255), ${TEST_COLUMN_2} varchar(255));`); 94 | await executeQuery(conn, `CREATE TABLE IF NOT EXISTS ${TEST_SCHEMA_2}.${TEST_TABLE_1} (${TEST_COLUMN_1} varchar(255), ${TEST_COLUMN_2} varchar(255));`); 95 | await executeQuery(conn, `CREATE TABLE IF NOT EXISTS ${TEST_SCHEMA_2}.${TEST_TABLE_2} (${TEST_COLUMN_1} varchar(255), ${TEST_COLUMN_2} varchar(255));`); 96 | } catch (err) { 97 | throw err; 98 | } finally { 99 | await closeConnection(conn); 100 | } 101 | }; 102 | 103 | const dropTables = async () => { 104 | const conn = await getConnection(); 105 | try { 106 | await executeQuery(conn, `DROP TABLE IF EXISTS ${TEST_SCHEMA_1}.${TEST_TABLE_1};`); 107 | await executeQuery(conn, `DROP TABLE IF EXISTS ${TEST_SCHEMA_1}.${TEST_TABLE_2};`); 108 | await executeQuery(conn, `DROP TABLE IF EXISTS ${TEST_SCHEMA_2}.${TEST_TABLE_1};`); 109 | await executeQuery(conn, `DROP TABLE IF EXISTS ${TEST_SCHEMA_2}.${TEST_TABLE_2};`); 110 | } catch (err) { 111 | throw err; 112 | } finally { 113 | await closeConnection(conn); 114 | } 115 | }; 116 | 117 | beforeAll(async () => { 118 | console.log(`Runnning tests on port ${DATABASE_PORT}...`); 119 | 120 | chai.should(); 121 | await createSchemas(); 122 | await grantPrivileges(); 123 | }); 124 | 125 | beforeEach(async () => { 126 | await createTables(); 127 | }); 128 | 129 | afterEach(async () => { 130 | await dropTables(); 131 | }); 132 | 133 | afterAll(async () => { 134 | await dropSchemas(); 135 | }); 136 | 137 | describe(`MySQLEvents using ${IS_POOL ? 'connection pool' : 'single connection'} on port ${DATABASE_PORT}`, () => { 138 | 139 | it('should expose EVENTS enum', async () => { 140 | MySQLEvents.EVENTS.should.be.an('object'); 141 | MySQLEvents.EVENTS.should.have.ownPropertyDescriptor('BINLOG'); 142 | MySQLEvents.EVENTS.BINLOG.should.be.equal('binlog'); 143 | MySQLEvents.EVENTS.should.have.ownPropertyDescriptor('TRIGGER_ERROR'); 144 | MySQLEvents.EVENTS.TRIGGER_ERROR.should.be.equal('triggerError'); 145 | MySQLEvents.EVENTS.should.have.ownPropertyDescriptor('CONNECTION_ERROR'); 146 | MySQLEvents.EVENTS.CONNECTION_ERROR.should.be.equal('connectionError'); 147 | MySQLEvents.EVENTS.should.have.ownPropertyDescriptor('ZONGJI_ERROR'); 148 | MySQLEvents.EVENTS.ZONGJI_ERROR.should.be.equal('zongjiError'); 149 | }); 150 | 151 | it('should expose STATEMENTS enum', async () => { 152 | MySQLEvents.STATEMENTS.should.be.an('object'); 153 | MySQLEvents.STATEMENTS.should.have.ownPropertyDescriptor('ALL'); 154 | MySQLEvents.STATEMENTS.ALL.should.be.equal('ALL'); 155 | MySQLEvents.STATEMENTS.should.have.ownPropertyDescriptor('INSERT'); 156 | MySQLEvents.STATEMENTS.INSERT.should.be.equal('INSERT'); 157 | MySQLEvents.STATEMENTS.should.have.ownPropertyDescriptor('UPDATE'); 158 | MySQLEvents.STATEMENTS.UPDATE.should.be.equal('UPDATE'); 159 | MySQLEvents.STATEMENTS.should.have.ownPropertyDescriptor('DELETE'); 160 | MySQLEvents.STATEMENTS.DELETE.should.be.equal('DELETE'); 161 | }); 162 | 163 | it('should connect and disconnect from MySQL using a pre existing connection', async () => { 164 | let connection; 165 | if (IS_POOL) { 166 | connection = mysql.createPool({ 167 | host: 'localhost', 168 | user: 'root', 169 | password: 'root', 170 | port: DATABASE_PORT, 171 | }); 172 | } else { 173 | connection = mysql.createConnection({ 174 | host: 'localhost', 175 | user: 'root', 176 | password: 'root', 177 | port: DATABASE_PORT, 178 | }); 179 | } 180 | 181 | const instance = new MySQLEvents(connection); 182 | 183 | await instance.start(); 184 | 185 | await delay(); 186 | 187 | await instance.stop(); 188 | }, 10000); 189 | 190 | it('should connect and disconnect from MySQL using a dsn', async () => { 191 | const instance = new MySQLEvents({ 192 | host: 'localhost', 193 | user: 'root', 194 | password: 'root', 195 | port: DATABASE_PORT, 196 | isPool: IS_POOL, 197 | }); 198 | 199 | await instance.start(); 200 | 201 | await delay(); 202 | 203 | await instance.stop(); 204 | }, 10000); 205 | 206 | it('should connect and disconnect from MySQL using a connection string', async () => { 207 | const instance = new MySQLEvents(`mysql://root:root@localhost:${DATABASE_PORT}/${TEST_SCHEMA_1}`); 208 | 209 | await instance.start(); 210 | 211 | await delay(); 212 | 213 | await instance.stop(); 214 | }, 10000); 215 | 216 | it('should catch an event using an INSERT trigger', async () => { 217 | const instance = new MySQLEvents({ 218 | host: 'localhost', 219 | user: 'root', 220 | password: 'root', 221 | port: DATABASE_PORT, 222 | isPool: IS_POOL, 223 | }, { 224 | serverId: getServerId(), 225 | startAtEnd: true, 226 | excludedSchemas: { 227 | mysql: true, 228 | }, 229 | }); 230 | 231 | await instance.start(); 232 | 233 | const triggerEvents = []; 234 | instance.addTrigger({ 235 | name: 'Test', 236 | expression: `${TEST_SCHEMA_1}.${TEST_TABLE_1}`, 237 | statement: MySQLEvents.STATEMENTS.INSERT, 238 | onEvent: event => triggerEvents.push(event), 239 | }); 240 | 241 | instance.on(MySQLEvents.EVENTS.TRIGGER_ERROR, console.error); 242 | instance.on(MySQLEvents.EVENTS.CONNECTION_ERROR, console.error); 243 | instance.on(MySQLEvents.EVENTS.ZONGJI_ERROR, console.error); 244 | 245 | await delay(5000); 246 | 247 | await executeQuery(instance.connection, `INSERT INTO ${TEST_SCHEMA_1}.${TEST_TABLE_1} VALUES ('test1', 'test2');`); 248 | 249 | await delay(5000); 250 | 251 | if (!triggerEvents.length) throw new Error('No trigger was caught'); 252 | 253 | triggerEvents[0].should.be.an('object'); 254 | 255 | triggerEvents[0].should.have.ownPropertyDescriptor('type'); 256 | triggerEvents[0].type.should.be.a('string').equals('INSERT'); 257 | 258 | triggerEvents[0].should.have.ownPropertyDescriptor('timestamp'); 259 | triggerEvents[0].timestamp.should.be.a('number'); 260 | 261 | triggerEvents[0].should.have.ownPropertyDescriptor('table'); 262 | triggerEvents[0].table.should.be.a('string').equals(TEST_TABLE_1); 263 | 264 | triggerEvents[0].should.have.ownPropertyDescriptor('schema'); 265 | triggerEvents[0].schema.should.be.a('string').equals(TEST_SCHEMA_1); 266 | 267 | triggerEvents[0].should.have.ownPropertyDescriptor('nextPosition'); 268 | triggerEvents[0].nextPosition.should.be.a('number'); 269 | 270 | triggerEvents[0].should.have.ownPropertyDescriptor('affectedRows'); 271 | triggerEvents[0].affectedRows.should.be.an('array').to.have.lengthOf(1); 272 | triggerEvents[0].affectedRows[0].should.be.an('object'); 273 | triggerEvents[0].affectedRows[0].should.have.ownPropertyDescriptor('after'); 274 | triggerEvents[0].affectedRows[0].after.should.be.an('object'); 275 | triggerEvents[0].affectedRows[0].after.should.have.ownPropertyDescriptor(TEST_COLUMN_1); 276 | triggerEvents[0].affectedRows[0].after[TEST_COLUMN_1].should.be.a('string').equals('test1'); 277 | triggerEvents[0].affectedRows[0].after.should.have.ownPropertyDescriptor(TEST_COLUMN_2); 278 | triggerEvents[0].affectedRows[0].after[TEST_COLUMN_2].should.be.a('string').equals('test2'); 279 | triggerEvents[0].affectedRows[0].should.have.ownPropertyDescriptor('before'); 280 | expect(triggerEvents[0].affectedRows[0].before).to.be.an('undefined'); 281 | 282 | await instance.stop(); 283 | }, 15000); 284 | 285 | it('should catch an event using an UPDATE trigger', async () => { 286 | const instance = new MySQLEvents({ 287 | host: 'localhost', 288 | user: 'root', 289 | password: 'root', 290 | port: DATABASE_PORT, 291 | isPool: IS_POOL, 292 | }, { 293 | serverId: getServerId(), 294 | startAtEnd: true, 295 | excludedSchemas: { 296 | mysql: true, 297 | }, 298 | }); 299 | 300 | await instance.start(); 301 | 302 | const triggerEvents = []; 303 | instance.addTrigger({ 304 | name: 'Test', 305 | expression: `${TEST_SCHEMA_1}.${TEST_TABLE_1}`, 306 | statement: MySQLEvents.STATEMENTS.UPDATE, 307 | onEvent: event => triggerEvents.push(event), 308 | }); 309 | 310 | instance.on(MySQLEvents.EVENTS.TRIGGER_ERROR, console.error); 311 | instance.on(MySQLEvents.EVENTS.CONNECTION_ERROR, console.error); 312 | instance.on(MySQLEvents.EVENTS.ZONGJI_ERROR, console.error); 313 | 314 | await delay(5000); 315 | 316 | await executeQuery(instance.connection, `INSERT INTO ${TEST_SCHEMA_1}.${TEST_TABLE_1} VALUES ('test1', 'test2');`); 317 | await executeQuery(instance.connection, `UPDATE ${TEST_SCHEMA_1}.${TEST_TABLE_1} SET ${TEST_COLUMN_1} = 'test3', ${TEST_COLUMN_2} = 'test4';`); 318 | 319 | await delay(5000); 320 | 321 | if (!triggerEvents.length) throw new Error('No trigger was caught'); 322 | 323 | triggerEvents[0].should.be.an('object'); 324 | 325 | triggerEvents[0].should.have.ownPropertyDescriptor('type'); 326 | triggerEvents[0].type.should.be.a('string').equals('UPDATE'); 327 | 328 | triggerEvents[0].should.have.ownPropertyDescriptor('timestamp'); 329 | triggerEvents[0].timestamp.should.be.a('number'); 330 | 331 | triggerEvents[0].should.have.ownPropertyDescriptor('table'); 332 | triggerEvents[0].table.should.be.a('string').equals(TEST_TABLE_1); 333 | 334 | triggerEvents[0].should.have.ownPropertyDescriptor('schema'); 335 | triggerEvents[0].schema.should.be.a('string').equals(TEST_SCHEMA_1); 336 | 337 | triggerEvents[0].should.have.ownPropertyDescriptor('nextPosition'); 338 | triggerEvents[0].nextPosition.should.be.a('number'); 339 | 340 | triggerEvents[0].should.have.ownPropertyDescriptor('affectedRows'); 341 | triggerEvents[0].affectedRows.should.be.an('array').to.have.lengthOf(1); 342 | triggerEvents[0].affectedRows[0].should.be.an('object'); 343 | 344 | triggerEvents[0].affectedRows[0].should.have.ownPropertyDescriptor('after'); 345 | triggerEvents[0].affectedRows[0].after.should.be.an('object'); 346 | triggerEvents[0].affectedRows[0].after.should.have.ownPropertyDescriptor(TEST_COLUMN_1); 347 | triggerEvents[0].affectedRows[0].after[TEST_COLUMN_1].should.be.a('string').equals('test3'); 348 | triggerEvents[0].affectedRows[0].after.should.have.ownPropertyDescriptor(TEST_COLUMN_2); 349 | triggerEvents[0].affectedRows[0].after[TEST_COLUMN_2].should.be.a('string').equals('test4'); 350 | 351 | triggerEvents[0].affectedRows[0].should.have.ownPropertyDescriptor('before'); 352 | triggerEvents[0].affectedRows[0].before.should.be.an('object'); 353 | triggerEvents[0].affectedRows[0].before.should.have.ownPropertyDescriptor(TEST_COLUMN_1); 354 | triggerEvents[0].affectedRows[0].before[TEST_COLUMN_1].should.be.a('string').equals('test1'); 355 | triggerEvents[0].affectedRows[0].before.should.have.ownPropertyDescriptor(TEST_COLUMN_2); 356 | triggerEvents[0].affectedRows[0].before[TEST_COLUMN_2].should.be.a('string').equals('test2'); 357 | 358 | await instance.stop(); 359 | }, 15000); 360 | 361 | it('should catch an event using a DELETE trigger', async () => { 362 | const instance = new MySQLEvents({ 363 | host: 'localhost', 364 | user: 'root', 365 | password: 'root', 366 | port: DATABASE_PORT, 367 | isPool: IS_POOL, 368 | }, { 369 | serverId: getServerId(), 370 | startAtEnd: true, 371 | excludedSchemas: { 372 | mysql: true, 373 | }, 374 | }); 375 | 376 | await instance.start(); 377 | 378 | const triggerEvents = []; 379 | instance.addTrigger({ 380 | name: 'Test', 381 | expression: `${TEST_SCHEMA_1}.${TEST_TABLE_1}`, 382 | statement: MySQLEvents.STATEMENTS.DELETE, 383 | onEvent: event => triggerEvents.push(event), 384 | }); 385 | 386 | await delay(5000); 387 | 388 | await executeQuery(instance.connection, `INSERT INTO ${TEST_SCHEMA_1}.${TEST_TABLE_1} VALUES ('test1', 'test2');`); 389 | await executeQuery(instance.connection, `DELETE FROM ${TEST_SCHEMA_1}.${TEST_TABLE_1} WHERE ${TEST_COLUMN_1} = 'test1' AND ${TEST_COLUMN_2} = 'test2';`); 390 | 391 | await delay(5000); 392 | 393 | if (!triggerEvents.length) throw new Error('No trigger was caught'); 394 | 395 | triggerEvents[0].should.be.an('object'); 396 | 397 | triggerEvents[0].should.have.ownPropertyDescriptor('type'); 398 | triggerEvents[0].type.should.be.a('string').equals('DELETE'); 399 | 400 | triggerEvents[0].should.have.ownPropertyDescriptor('timestamp'); 401 | triggerEvents[0].timestamp.should.be.a('number'); 402 | 403 | triggerEvents[0].should.have.ownPropertyDescriptor('table'); 404 | triggerEvents[0].table.should.be.a('string').equals(TEST_TABLE_1); 405 | 406 | triggerEvents[0].should.have.ownPropertyDescriptor('schema'); 407 | triggerEvents[0].schema.should.be.a('string').equals(TEST_SCHEMA_1); 408 | 409 | triggerEvents[0].should.have.ownPropertyDescriptor('nextPosition'); 410 | triggerEvents[0].nextPosition.should.be.a('number'); 411 | 412 | triggerEvents[0].should.have.ownPropertyDescriptor('affectedRows'); 413 | triggerEvents[0].affectedRows.should.be.an('array').to.have.lengthOf(1); 414 | triggerEvents[0].affectedRows[0].should.be.an('object'); 415 | 416 | triggerEvents[0].affectedRows[0].should.have.ownPropertyDescriptor('after'); 417 | expect(triggerEvents[0].affectedRows[0].after).to.be.an('undefined'); 418 | 419 | triggerEvents[0].affectedRows[0].should.have.ownPropertyDescriptor('before'); 420 | triggerEvents[0].affectedRows[0].before.should.be.an('object'); 421 | triggerEvents[0].affectedRows[0].before.should.have.ownPropertyDescriptor(TEST_COLUMN_1); 422 | triggerEvents[0].affectedRows[0].before[TEST_COLUMN_1].should.be.a('string').equals('test1'); 423 | triggerEvents[0].affectedRows[0].before.should.have.ownPropertyDescriptor(TEST_COLUMN_2); 424 | triggerEvents[0].affectedRows[0].before[TEST_COLUMN_2].should.be.a('string').equals('test2'); 425 | 426 | await instance.stop(); 427 | }, 15000); 428 | 429 | it('should catch events using an ALL trigger', async () => { 430 | const instance = new MySQLEvents({ 431 | host: 'localhost', 432 | user: 'root', 433 | password: 'root', 434 | port: DATABASE_PORT, 435 | isPool: IS_POOL, 436 | }, { 437 | serverId: getServerId(), 438 | startAtEnd: true, 439 | excludedSchemas: { 440 | mysql: true, 441 | }, 442 | }); 443 | 444 | await instance.start(); 445 | 446 | const triggerEvents = []; 447 | instance.addTrigger({ 448 | name: 'Test', 449 | expression: `${TEST_SCHEMA_1}.${TEST_TABLE_1}`, 450 | statement: MySQLEvents.STATEMENTS.ALL, 451 | onEvent: event => triggerEvents.push(event), 452 | }); 453 | 454 | await delay(5000); 455 | 456 | await executeQuery(instance.connection, `INSERT INTO ${TEST_SCHEMA_1}.${TEST_TABLE_1} VALUES ('test1', 'test2');`); 457 | await executeQuery(instance.connection, `UPDATE ${TEST_SCHEMA_1}.${TEST_TABLE_1} SET ${TEST_COLUMN_1} = 'test3', ${TEST_COLUMN_2} = 'test4';`); 458 | await executeQuery(instance.connection, `DELETE FROM ${TEST_SCHEMA_1}.${TEST_TABLE_1} WHERE ${TEST_COLUMN_1} = 'test3' AND ${TEST_COLUMN_2} = 'test4';`); 459 | 460 | await delay(1000); 461 | 462 | expect(triggerEvents).to.be.an('array').that.is.not.empty; 463 | 464 | triggerEvents[0].should.have.ownPropertyDescriptor('type'); 465 | triggerEvents[0].type.should.be.a('string').equals('INSERT'); 466 | 467 | triggerEvents[1].should.have.ownPropertyDescriptor('type'); 468 | triggerEvents[1].type.should.be.a('string').equals('UPDATE'); 469 | 470 | triggerEvents[2].should.have.ownPropertyDescriptor('type'); 471 | triggerEvents[2].type.should.be.a('string').equals('DELETE'); 472 | 473 | await instance.stop(); 474 | }, 15000); 475 | 476 | it('should remove a previously added event trigger', async () => { 477 | const instance = new MySQLEvents({ 478 | host: 'localhost', 479 | user: 'root', 480 | password: 'root', 481 | port: DATABASE_PORT, 482 | isPool: IS_POOL, 483 | }); 484 | 485 | instance.addTrigger({ 486 | name: 'Test', 487 | expression: `${TEST_SCHEMA_1}.${TEST_TABLE_1}`, 488 | statement: MySQLEvents.STATEMENTS.ALL, 489 | onEvent: () => {}, 490 | }); 491 | 492 | instance.expressions[`${TEST_SCHEMA_1}.${TEST_TABLE_1}`].statements[MySQLEvents.STATEMENTS.ALL].should.be.an('array').that.is.not.empty; 493 | 494 | instance.expressions[`${TEST_SCHEMA_1}.${TEST_TABLE_1}`].statements[MySQLEvents.STATEMENTS.ALL][0].should.be.an('object'); 495 | instance.expressions[`${TEST_SCHEMA_1}.${TEST_TABLE_1}`].statements[MySQLEvents.STATEMENTS.ALL][0].name.should.be.a('string').equals('Test'); 496 | instance.expressions[`${TEST_SCHEMA_1}.${TEST_TABLE_1}`].statements[MySQLEvents.STATEMENTS.ALL][0].onEvent.should.be.a('function'); 497 | 498 | instance.removeTrigger({ 499 | name: 'Test', 500 | expression: `${TEST_SCHEMA_1}.${TEST_TABLE_1}`, 501 | statement: MySQLEvents.STATEMENTS.ALL, 502 | }); 503 | 504 | expect(instance.expressions[`${TEST_SCHEMA_1}.${TEST_TABLE_1}`].statements[MySQLEvents.STATEMENTS.ALL][0]).to.be.an('undefined'); 505 | 506 | await instance.stop(); 507 | }, 10000); 508 | 509 | it('should throw an error when adding duplicated trigger name for a statement', async () => { 510 | const instance = new MySQLEvents({ 511 | host: 'localhost', 512 | user: 'root', 513 | password: 'root', 514 | port: DATABASE_PORT, 515 | isPool: IS_POOL, 516 | }); 517 | 518 | instance.addTrigger({ 519 | name: 'Test', 520 | expression: `${TEST_SCHEMA_1}.${TEST_TABLE_1}`, 521 | statement: MySQLEvents.STATEMENTS.ALL, 522 | onEvent: () => {}, 523 | }); 524 | 525 | expect(() => instance.addTrigger({ 526 | name: 'Test', 527 | expression: `${TEST_SCHEMA_1}.${TEST_TABLE_1}`, 528 | statement: MySQLEvents.STATEMENTS.ALL, 529 | onEvent: () => {}, 530 | })).to.throw(Error); 531 | }); 532 | 533 | it('should emit an event when a trigger produces an error', async () => { 534 | const instance = new MySQLEvents({ 535 | host: 'localhost', 536 | user: 'root', 537 | password: 'root', 538 | port: DATABASE_PORT, 539 | isPool: IS_POOL, 540 | }, { 541 | serverId: getServerId(), 542 | startAtEnd: true, 543 | excludedSchemas: { 544 | mysql: true, 545 | }, 546 | }); 547 | 548 | await instance.start(); 549 | 550 | await delay(); 551 | 552 | let error = null; 553 | instance.on(MySQLEvents.EVENTS.TRIGGER_ERROR, (err) => { 554 | error = err; 555 | }); 556 | 557 | instance.addTrigger({ 558 | name: 'Test', 559 | expression: `${TEST_SCHEMA_1}.${TEST_TABLE_1}`, 560 | statement: MySQLEvents.STATEMENTS.ALL, 561 | onEvent: () => { 562 | throw new Error('Error'); 563 | }, 564 | }); 565 | 566 | await delay(5000); 567 | 568 | await executeQuery(instance.connection, `INSERT INTO ${TEST_SCHEMA_1}.${TEST_TABLE_1} VALUES ('test1', 'test2');`); 569 | 570 | await delay(1000); 571 | 572 | expect(error).to.be.an('object'); 573 | error.trigger.should.be.an('object'); 574 | error.error.should.be.an('Error'); 575 | }, 10000); 576 | 577 | it('should receive events from multiple schemas', async () => { 578 | const instance = new MySQLEvents({ 579 | host: 'localhost', 580 | user: 'root', 581 | password: 'root', 582 | port: DATABASE_PORT, 583 | isPool: IS_POOL, 584 | }, { 585 | serverId: getServerId(), 586 | startAtEnd: true, 587 | excludedSchemas: { 588 | mysql: true, 589 | }, 590 | }); 591 | 592 | await instance.start(); 593 | 594 | const triggeredEvents = []; 595 | instance.addTrigger({ 596 | name: 'Test', 597 | expression: `${TEST_SCHEMA_1}`, 598 | statement: MySQLEvents.STATEMENTS.UPDATE, 599 | onEvent: event => triggeredEvents.push(event), 600 | }); 601 | instance.addTrigger({ 602 | name: 'Test2', 603 | expression: `${TEST_SCHEMA_2}`, 604 | statement: MySQLEvents.STATEMENTS.ALL, 605 | onEvent: event => triggeredEvents.push(event), 606 | }); 607 | 608 | await delay(5000); 609 | 610 | await executeQuery(instance.connection, `INSERT INTO ${TEST_SCHEMA_1}.${TEST_TABLE_1} VALUES ('test1', 'test2');`); 611 | await executeQuery(instance.connection, `UPDATE ${TEST_SCHEMA_1}.${TEST_TABLE_1} SET ${TEST_COLUMN_1} = 'test3', ${TEST_COLUMN_2} = 'test4';`); 612 | 613 | await executeQuery(instance.connection, `INSERT INTO ${TEST_SCHEMA_2}.${TEST_TABLE_1} VALUES ('test1', 'test2');`); 614 | await executeQuery(instance.connection, `UPDATE ${TEST_SCHEMA_2}.${TEST_TABLE_1} SET ${TEST_COLUMN_1} = 'test3', ${TEST_COLUMN_2} = 'test4';`); 615 | 616 | await delay(1000); 617 | 618 | if (!triggeredEvents.length) throw new Error('No trigger was caught'); 619 | }, 20000); 620 | 621 | it('should pause and resume connection', async () => { 622 | const connection = mysql.createConnection({ 623 | host: 'localhost', 624 | user: 'root', 625 | password: 'root', 626 | port: DATABASE_PORT, 627 | }); 628 | 629 | const instance = new MySQLEvents({ 630 | host: 'localhost', 631 | user: 'root', 632 | password: 'root', 633 | port: DATABASE_PORT, 634 | isPool: IS_POOL, 635 | }, { 636 | serverId: getServerId(), 637 | startAtEnd: true, 638 | excludedSchemas: { 639 | mysql: true, 640 | }, 641 | }); 642 | 643 | await instance.start(); 644 | 645 | const triggeredEvents = []; 646 | instance.addTrigger({ 647 | name: 'Test', 648 | expression: `${TEST_SCHEMA_1}`, 649 | statement: MySQLEvents.STATEMENTS.ALL, 650 | onEvent: event => triggeredEvents.push(event), 651 | }); 652 | 653 | await delay(5000); 654 | 655 | await executeQuery(connection, `INSERT INTO ${TEST_SCHEMA_1}.${TEST_TABLE_1} VALUES ('test1', 'test2');`); 656 | await executeQuery(connection, `UPDATE ${TEST_SCHEMA_1}.${TEST_TABLE_1} SET ${TEST_COLUMN_1} = 'test3', ${TEST_COLUMN_2} = 'test4';`); 657 | 658 | await delay(1000); 659 | 660 | if (!triggeredEvents.length) throw new Error('No trigger was caught'); 661 | triggeredEvents.splice(0); 662 | 663 | instance.pause(); 664 | await delay(300); 665 | 666 | await executeQuery(connection, `INSERT INTO ${TEST_SCHEMA_1}.${TEST_TABLE_1} VALUES ('test3', 'test4');`); 667 | await executeQuery(connection, `UPDATE ${TEST_SCHEMA_1}.${TEST_TABLE_1} SET ${TEST_COLUMN_1} = 'test4', ${TEST_COLUMN_2} = 'test5';`); 668 | 669 | await delay(1000); 670 | 671 | if (triggeredEvents.length) throw new Error('Connection should be stopped'); 672 | 673 | instance.resume(); 674 | 675 | await delay(1000); 676 | 677 | if (!triggeredEvents.length) throw new Error('No trigger was caught'); 678 | }, 20000); 679 | 680 | }); 681 | --------------------------------------------------------------------------------