├── .npmignore ├── .gitignore ├── .babelrc ├── package.json ├── mpg.js ├── src └── mpg-es6.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | NOTES.md 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | NOTES.md 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-3"], 3 | "plugins": ["syntax-async-functions", "transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meteor-pg", 3 | "version": "1.0.9", 4 | "description": "This module makes live PostgreSQL select queries work with the Meteor publish/subscribe mechanism.", 5 | "main": "dist/mpg-es6.js", 6 | "dependencies": { 7 | "babel-runtime": "^6.23.0", 8 | "pg-promise": "^6.2.1", 9 | "pg-query-observer": "^1.0.5", 10 | "pg-table-observer": "^1.0.7" 11 | }, 12 | "devDependencies": { 13 | "babel-cli": "^6.24.1", 14 | "babel-plugin-syntax-async-functions": "^6.8.0", 15 | "babel-plugin-transform-runtime": "^6.23.0", 16 | "babel-preset-es2015": "^6.24.1", 17 | "babel-preset-stage-3": "^6.24.1" 18 | }, 19 | "scripts": { 20 | "build": "babel src -d dist --source-maps inline", 21 | "build-watch": "babel --watch src -d dist --source-maps inline", 22 | "prepublish": "npm run build" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/Richie765/meteor-pg.git" 27 | }, 28 | "keywords": [ 29 | "meteor", 30 | "postgresql", 31 | "psql", 32 | "pg", 33 | "meteor-pg", 34 | "pg-live-select", 35 | "reactive" 36 | ], 37 | "author": "Richard", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/Richie765/meteor-pg/issues" 41 | }, 42 | "homepage": "https://github.com/Richie765/meteor-pg#readme" 43 | } 44 | -------------------------------------------------------------------------------- /mpg.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var pgPromise = require('pg-promise'); 3 | 4 | var PgTableObserver = require('pg-table-observer').default; 5 | var PgQueryObserver = require('pg-query-observer').default; 6 | 7 | // Initialization 8 | 9 | var pgp; 10 | var db; 11 | var query_observer; 12 | var table_observer; 13 | 14 | function init(connection, channel) { 15 | if(!connection) { 16 | connection = process.env.PG_URL ? process.env.PG_URL : 'postgres://localhost/postgres'; 17 | } 18 | 19 | if(!channel) { 20 | channel = process.env.PG_CHANNEL ? process.env.PG_CHANNEL : 'default_channel'; 21 | } 22 | 23 | // pg-promise connection 24 | 25 | pgp = pgPromise({}); 26 | 27 | try { 28 | db = Promise.await(pgp(connection)); 29 | } 30 | catch(err) { 31 | console.error('meteor-pg: failed to connect to', connection); 32 | throw err; 33 | } 34 | 35 | // PgQueryObserver 36 | 37 | query_observer = new PgQueryObserver(db, channel); 38 | 39 | table_observer = query_observer.table_observer; 40 | 41 | // Automatic cleanup 42 | 43 | function cleanupAndExit() { 44 | query_observer.cleanup().then(() => { 45 | pgp.end(); 46 | process.exit(); 47 | }) 48 | } 49 | 50 | process.on('SIGTERM', cleanupAndExit); 51 | process.on('SIGINT', cleanupAndExit); 52 | } 53 | 54 | init(); // For now just init automatically 55 | 56 | // select function 57 | function live_select(sub, collection, query, params, triggers) { 58 | if(!query_observer) throw new Error('Query observer not initialized yet'); 59 | 60 | try { 61 | let handle = Promise.await(query_observer.notify(query, params, triggers, diff => { 62 | // console.log(diff); 63 | 64 | if(diff.removed) { 65 | diff.removed.forEach(_id => { 66 | sub.removed(collection, _id); 67 | }); 68 | } 69 | 70 | if(diff.changed) { 71 | diff.changed.forEach(changed => { 72 | let _id = changed._id; 73 | sub.changed(collection, _id, changed); 74 | }); 75 | } 76 | 77 | if(diff.added) { 78 | diff.added.forEach(added => { 79 | let _id = added._id; 80 | sub.added(collection, _id, added); 81 | }); 82 | } 83 | })); 84 | 85 | // Add initial rows 86 | 87 | let rows = handle.getRows(); 88 | 89 | rows.forEach(added => { 90 | let _id = added._id; 91 | sub.added(collection, _id, added); 92 | }); 93 | 94 | // onStop handler 95 | 96 | sub.onStop(() => { 97 | // console.log("Stopped"); 98 | handle.stop(); 99 | }); 100 | } 101 | catch(err) { 102 | // console.error(err); 103 | sub.error(err); 104 | } 105 | } 106 | 107 | function select(collection, query, params, triggers) { 108 | // Usage: (inside Publish function) 109 | // return mpg.select(collection, query, params, triggers) 110 | 111 | return { 112 | _publishCursor: function(sub) { 113 | live_select(sub, collection, query, params, triggers); 114 | }, 115 | 116 | observeChanges: function(callbacks) { 117 | console.log("Not implemented yet"); 118 | // console.log("observeChanges called"); 119 | // console.log(callbacks); 120 | }, 121 | }; 122 | } 123 | 124 | // mpg object 125 | 126 | var mpg = { 127 | pgp, db, query_observer, table_observer, select, 128 | 129 | // await all query functions 130 | 131 | connect() { return Promise.await(db.connect.apply(db, arguments)) }, 132 | query() { return Promise.await(db.query.apply(db, arguments)) }, 133 | none() { return Promise.await(db.none.apply(db, arguments)) }, 134 | one() { return Promise.await(db.one.apply(db, arguments)) }, 135 | many() { return Promise.await(db.many.apply(db, arguments)) }, 136 | oneOrNone() { return Promise.await(db.oneOrNone.apply(db, arguments)) }, 137 | manyOrNone() { return Promise.await(db.manyOrNone.apply(db, arguments)) }, 138 | any() { return Promise.await(db.any.apply(db, arguments)) }, 139 | result() { return Promise.await(db.result.apply(db, arguments)) }, 140 | stream() { return Promise.await(db.stream.apply(db, arguments)) }, 141 | func() { return Promise.await(db.func.apply(db, arguments)) }, 142 | proc() { return Promise.await(db.proc.apply(db, arguments)) }, 143 | map() { return Promise.await(db.map.apply(db, arguments)) }, 144 | each() { return Promise.await(db.each.apply(db, arguments)) }, 145 | task() { return Promise.await(db.task.apply(db, arguments)) }, 146 | tx() { return Promise.await(db.tx.apply(db, arguments)) }, 147 | }; 148 | 149 | // Export 150 | 151 | module.exports = mpg; 152 | -------------------------------------------------------------------------------- /src/mpg-es6.js: -------------------------------------------------------------------------------- 1 | import pgPromise from 'pg-promise'; 2 | 3 | import PgTableObserver from 'pg-table-observer'; 4 | import PgQueryObserver from 'pg-query-observer'; 5 | 6 | // Initialization 7 | 8 | var pgp; 9 | var db; 10 | var query_observer; 11 | var table_observer; 12 | 13 | function init(connection, channel) { 14 | if(!connection) { 15 | connection = process.env.PG_URL ? process.env.PG_URL : 'postgres://localhost/postgres'; 16 | } 17 | 18 | if(!channel) { 19 | channel = process.env.PG_CHANNEL ? process.env.PG_CHANNEL : 'default_channel'; 20 | } 21 | 22 | // pg-promise connection 23 | 24 | pgp = pgPromise({}); 25 | 26 | try { 27 | db = GLOBAL.Promise.await(pgp(connection)); 28 | } 29 | catch(err) { 30 | console.error('meteor-pg: failed to connect to', connection); 31 | throw err; 32 | } 33 | 34 | // PgQueryObserver 35 | 36 | query_observer = new PgQueryObserver(db, channel); 37 | 38 | table_observer = query_observer.table_observer; 39 | 40 | // Automatic cleanup 41 | 42 | async function cleanupAndExit() { 43 | await query_observer.cleanup(); 44 | pgp.end(); 45 | process.exit(); 46 | } 47 | 48 | process.on('SIGTERM', cleanupAndExit); 49 | process.on('SIGINT', cleanupAndExit); 50 | } 51 | 52 | init(); // For now just init automatically 53 | 54 | // select function 55 | 56 | function live_select(sub, collection, query, params, triggers) { 57 | if(!query_observer) throw new Error('Query observer not initialized yet'); 58 | 59 | let handle; 60 | 61 | try { 62 | handle = GLOBAL.Promise.await(query_observer.notify(query, params, triggers, diff => { 63 | // console.log(diff); 64 | 65 | if(diff.removed) { 66 | diff.removed.forEach(_id => { 67 | sub.removed(collection, _id); 68 | }); 69 | } 70 | 71 | if(diff.changed) { 72 | diff.changed.forEach(changed => { 73 | let { _id } = changed; 74 | sub.changed(collection, _id, changed); 75 | }); 76 | } 77 | 78 | if(diff.added) { 79 | diff.added.forEach(added => { 80 | let { _id } = added; 81 | sub.added(collection, _id, added); 82 | }); 83 | } 84 | })); 85 | 86 | // Add initial rows 87 | 88 | let rows = handle.getRows(); 89 | 90 | rows.forEach(added => { 91 | let { _id } = added; 92 | sub.added(collection, _id, added); 93 | }); 94 | 95 | // onStop handler 96 | 97 | sub.onStop(() => { 98 | // console.log("Stopping", query); 99 | handle.stop(); 100 | }); 101 | } 102 | catch(err) { 103 | // console.error(err); 104 | sub.error(err); 105 | } 106 | 107 | return handle; 108 | } 109 | 110 | function select(collection, query, params, triggers) { 111 | // Usage: (inside Publish function) 112 | // return mpg.select(collection, query, params, triggers) 113 | 114 | return { 115 | _publishCursor(sub) { 116 | this.handle = live_select(sub, collection, query, params, triggers); 117 | }, 118 | 119 | refresh() { 120 | if(this.handle) { 121 | this.handle.refresh(); 122 | } 123 | }, 124 | 125 | observeChanges(callbacks) { 126 | console.log("Not implemented yet"); 127 | // console.log("observeChanges called"); 128 | // console.log(callbacks); 129 | }, 130 | }; 131 | } 132 | 133 | // mpg object 134 | 135 | var mpg = { 136 | pgp, db, query_observer, table_observer, select, 137 | 138 | // await all query functions 139 | 140 | connect(...param) { return GLOBAL.Promise.await(db.connect(...param)) }, 141 | query(...param) { return GLOBAL.Promise.await(db.query(...param)) }, 142 | none(...param) { return GLOBAL.Promise.await(db.none(...param)) }, 143 | one(...param) { return GLOBAL.Promise.await(db.one(...param)) }, 144 | many(...param) { return GLOBAL.Promise.await(db.many(...param)) }, 145 | oneOrNone(...param) { return GLOBAL.Promise.await(db.oneOrNone(...param)) }, 146 | manyOrNone(...param) { return GLOBAL.Promise.await(db.manyOrNone(...param)) }, 147 | any(...param) { return GLOBAL.Promise.await(db.any(...param)) }, 148 | result(...param) { return GLOBAL.Promise.await(db.result(...param)) }, 149 | stream(...param) { return GLOBAL.Promise.await(db.stream(...param)) }, 150 | func(...param) { return GLOBAL.Promise.await(db.func(...param)) }, 151 | proc(...param) { return GLOBAL.Promise.await(db.proc(...param)) }, 152 | map(...param) { return GLOBAL.Promise.await(db.map(...param)) }, 153 | each(...param) { return GLOBAL.Promise.await(db.each(...param)) }, 154 | task(...param) { return GLOBAL.Promise.await(db.task(...param)) }, 155 | tx(...param) { return GLOBAL.Promise.await(db.tx(...param)) }, 156 | }; 157 | 158 | // Exports 159 | 160 | export { mpg, pgp, db, query_observer, table_observer, select }; 161 | export default mpg; 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # meteor-pg 2 | This package allows you to use PostgreSQL reactively with Meteor as seamlessly 3 | as possible. 4 | 5 | It provides a method to publish PostgreSQL queries on the server. The query 6 | result will be available reactively on the client in a Minimongo collection. 7 | The collection can be used in the usual way. 8 | 9 | Data modifications (UPDATE, INSERT) can only be made from the server side. 10 | There are methods available that you can call from your server-side methods. 11 | 12 | It has been used in a small scale production environment quite successfully. 13 | 14 | For a full working example take a look at: https://github.com/Richie765/meteor-pg-leaderboard 15 | 16 | Requires PostgresSQL version 9.3 or above. 17 | 18 | # Installation 19 | ```bash 20 | meteor npm install meteor-pg --save 21 | ``` 22 | 23 | # Configuration 24 | There are two environment variables used to configure your database connection. 25 | 26 | ```bash 27 | export PG_URL=postgres://user:password@host:port/db 28 | export PG_CHANNEL=your_channel 29 | meteor run 30 | ``` 31 | 32 | The channel is used for LISTEN/NOTIFY on the PostgreSQL database and cannot 33 | be used by more than one application on the same database. 34 | 35 | # Initialization 36 | On the server side, import the package early on to establish the database connection. Your `/server/main.js` file would be a good place to do this. 37 | ```javascript 38 | import 'meteor-pg'; 39 | ``` 40 | 41 | # Usage - publication 42 | Only use this for read-only SELECT queries that you want to be reactive. For non-reactive 43 | (SELECT) queries you can use a method. 44 | 45 | Within a publish function on the server: 46 | ```javascript 47 | return mpg.select(collection, query, params, triggers); 48 | ``` 49 | 50 | Parameter | Description 51 | --------- | ----------- 52 | `collection` | The name of the Minimongo collection where the results will be stored on the client-side. 53 | `query` | SELECT query to run and observe. May contain placeholders following `pg-promise`. Each row must contain a unique \_id field as described below. 54 | `params` | The parameters to the query, following `pg-promise`. Single values will be `$1`. Array elements will be `$1`..`$n`. Object properties will be `$*property*` where `**` is one of `()`, `[]`, `{}` or `//`. See `pg-promise` for details. 55 | `triggers` | function(change). The trigger function, see below. 56 | 57 | 58 | ## Unique \_id field 59 | 60 | Your query must always return an `_id` field which uniquely identifies 61 | each row returned. This is needed so that the changed rows can be identified. 62 | For simple queries, this could just be an alias to the PK. 63 | 64 | For multi-table queries, this could be a combination of different PK's, eg: 65 | 66 | ```sql 67 | SELECT CONCAT(userid, '-', taskid) AS _id, * FROM user, task; 68 | ``` 69 | 70 | This does not mean you have to include the PK's of all the tables involved. 71 | You just need to uniquely identify each row returned. 72 | 73 | 74 | ## triggers function 75 | 76 | This function will be called whenever there is a change to one of the underlying tables of the query. 77 | You should determine if this change requires a rerun of the query. If so, you should return `true`. 78 | 79 | One parameter is passed, `change`. It contains the following fields: 80 | 81 | Field | Description 82 | -------------- | ----------- 83 | `table` | String, name of the table that changed. ***This will always be in lowercase.*** 84 | `insert` | For INSERT, `true` 85 | `delete` | For DELETE, `true` 86 | `update` | For UPDATE, an object that contains the old and new values of each changed column. If a column `score` changed from 10 to 20, `change.update.score.from` would be 10 and `change.update.score.to` would be 20. 87 | `row` | The row values, for UPDATE, the NEW row values 88 | `old` | For UPDATE, the OLD row values 89 | 90 | ES6 syntax makes it easy to write your trigger function, e.g.: 91 | 92 | ```javascript 93 | function trigger({ table, row }) { 94 | if(table === 'user' && row.name === 'name') return true; 95 | if(table === 'task' && row.status === 'completed') return true; 96 | } 97 | ``` 98 | 99 | ## Example - publication 100 | ```javascript 101 | // Server side 102 | 103 | import mpg from 'meteor-pg'; 104 | 105 | Meteor.publish('allPlayers', function() { 106 | let sql = ` 107 | SELECT id AS _id, * 108 | FROM players 109 | ORDER BY score DESC 110 | `; 111 | 112 | function triggers() { 113 | // This function is rather important. 114 | // For now, just trigger any change 115 | return true; 116 | } 117 | 118 | return mpg.select('players', sql, undefined, triggers); 119 | }); 120 | 121 | // Client side 122 | 123 | Players = new Mongo.Collection('players'); 124 | 125 | Template.leaderboard.onCreated(function () { 126 | this.subscribe('allPlayers'); 127 | }); 128 | 129 | Template.leaderboard.helpers({ 130 | players: function () { 131 | // Still need to sort client-side since record order is not preserved 132 | return Players.find({}, { sort: { score: -1, name: 1 } }); 133 | }, 134 | }); 135 | ``` 136 | 137 | # Usage - methods 138 | 139 | The `mpg` object provides the following methods that you can call within your Meteor methods to execute INSERT and UPDATE (and non-reactive SELECT) statements: `query`, `none`, `one`, `many`, `oneOrNone`, `manyOrNone`, `any`, `result`, `stream`, `func`, `proc`, `map`, `each`, `task`, `tx`. 140 | 141 | These methods take the same parameters as the methods of [pg-promise](https://github.com/vitaly-t/pg-promise). 142 | The difference is that these are called within a fiber using `Promise.await`, so they wait for the statement to be executed. You can 143 | use the return value directly, it is not a 'promise'. 144 | 145 | ## Example 146 | 147 | ```javascript 148 | // Server side 149 | 150 | import mpg from 'meteor-pg'; 151 | 152 | Meteor.methods({ 153 | 'incScore': function(id, amount){ 154 | let sql = ` 155 | UPDATE players 156 | SET score = score + $[amount] 157 | WHERE id = $[id] 158 | `; 159 | 160 | mpg.none(sql, { id, amount }); 161 | } 162 | }); 163 | 164 | // Client side 165 | 166 | Meteor.methods({ 167 | // Optional stub for latency compensation, see note below 168 | 169 | 'incScore': function(id, amount){ 170 | Players.update(id, { $inc: { score: amount } }); 171 | } 172 | }); 173 | 174 | Template.leaderboard.events({ 175 | 'click .inc': function () { 176 | Meteor.call('incScore', Session.get("selectedPlayer"), 5); 177 | } 178 | }); 179 | ``` 180 | 181 | # Advanced usage 182 | For running complex queries, involving multiple JOIN's and/or additional processing in JavaScript, 183 | you can use `mpg.table_observer` and `mpg.query_observer` directly. For the documentation see 184 | https://github.com/richie765/pg-table-observer and https://github.com/richie765/pg-query-observer. 185 | 186 | If you need direct access to the `pg-promise` object or database handle, they are available as `mpg.pgp` and `mpg.db`. 187 | 188 | # To do, known issues 189 | * There is some flicker when using latancy compensation (client-side methods). 190 | * MongoDB is still required for Accounts (pull requests welcome) 191 | --------------------------------------------------------------------------------