diff --git a/docs/library.md b/docs/library.md index 428a48cf05..129ad4fab5 100644 --- a/docs/library.md +++ b/docs/library.md @@ -50,6 +50,7 @@ Arguments include: - `pgDefaultRole`: The default Postgres role to use. If no role was provided in a provided JWT token, this role will be used. - `jwtSecret`: The secret for your JSON web tokens. This will be used to verify tokens in the `Authorization` header, and signing JWT tokens you return in procedures. - `jwtPgTypeIdentifier`: The Postgres type identifier for the compound type which will be signed as a JWT token if ever found as the return type of a procedure. Can be of the form: `my_schema.my_type`. You may use quotes as needed: `"my-special-schema".my_type`. + - `watchPg`: When true, PostGraphQL will watch your database schemas and re-create the GraphQL API whenever your schema changes, notifying you as it does. This feature requires an event trigger to be added to the database by a superuser. When enabled PostGraphQL will try to add this trigger, if you did not connect as a superuser you will get a warning and the trigger won’t be added. - `disableQueryLog`: Turns off GraphQL query logging. By default PostGraphQL will log every GraphQL query it processes along with some other information. Set this to `true` to disable that feature. - `enableCors`: Enables some generous CORS settings for the GraphQL endpoint. There are some costs associated when enabling this, if at all possible try to put your API behind a reverse proxy. diff --git a/package.json b/package.json index 061f8a2411..ec0d54b3b9 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,6 @@ "testPathDirs": [ "/src" ], - "testRegex": "/__tests__/[^.]+-test.(t|j)s$", - "clearMocks": true + "testRegex": "/__tests__/[^.]+-test.(t|j)s$" } } diff --git a/resources/watch-fixtures.sql b/resources/watch-fixtures.sql new file mode 100644 index 0000000000..2f3cf5560a --- /dev/null +++ b/resources/watch-fixtures.sql @@ -0,0 +1,61 @@ +-- Adds the functionality for PostGraphQL to watch the database for schema +-- changes. This script is idempotent, you can run it as many times as you +-- would like. + +begin; + +-- Drop the `postgraphql_watch` schema and all of its dependant objects +-- including the event trigger function and the event trigger itself. We will +-- recreate those objects in this script. +drop schema if exists postgraphql_watch cascade; + +-- Create a schema for the PostGraphQL watch functionality. This schema will +-- hold things like trigger functions that are used to implement schema +-- watching. +create schema postgraphql_watch; + +-- This function will notify PostGraphQL of schema changes via a trigger. +create function postgraphql_watch.notify_watchers() returns event_trigger as $$ +begin + perform pg_notify( + 'postgraphql_watch', + (select array_to_json(array_agg(x)) from (select schema_name as schema, command_tag as command from pg_event_trigger_ddl_commands()) as x)::text + ); +end; +$$ language plpgsql; + +-- Create an event trigger which will listen for the completion of all DDL +-- events and report that they happened to PostGraphQL. Events are selected by +-- whether or not they modify the static definition of `pg_catalog` that +-- `introspection-query.sql` queries. +create event trigger postgraphql_watch + on ddl_command_end + when tag in ( + 'ALTER DOMAIN', + 'ALTER FOREIGN TABLE', + 'ALTER FUNCTION', + 'ALTER SCHEMA', + 'ALTER TABLE', + 'ALTER TYPE', + 'ALTER VIEW', + 'COMMENT', + 'CREATE DOMAIN', + 'CREATE FOREIGN TABLE', + 'CREATE FUNCTION', + 'CREATE SCHEMA', + 'CREATE TABLE', + 'CREATE TABLE AS', + 'CREATE VIEW', + 'DROP DOMAIN', + 'DROP FOREIGN TABLE', + 'DROP FUNCTION', + 'DROP SCHEMA', + 'DROP TABLE', + 'DROP VIEW', + 'GRANT', + 'REVOKE', + 'SELECT INTO' + ) + execute procedure postgraphql_watch.notify_watchers(); + +commit; diff --git a/scripts/dev b/scripts/dev index f55b9f20db..d162083197 100755 --- a/scripts/dev +++ b/scripts/dev @@ -9,4 +9,4 @@ $npm_bin/nodemon \ --ignore __tests__ \ --ignore __mocks__ \ --ext js,ts \ - --exec "$npm_bin/ts-node --ignore node_modules --disableWarnings src/postgraphql/cli.ts --schema a,b,c --show-error-stack json $@" + --exec "$npm_bin/ts-node --ignore node_modules --disableWarnings src/postgraphql/cli.ts --schema a,b,c --show-error-stack json --watch $@" diff --git a/src/postgraphql/__tests__/postgraphql-test.js b/src/postgraphql/__tests__/postgraphql-test.js index 6ad8bba503..0d434176d6 100644 --- a/src/postgraphql/__tests__/postgraphql-test.js +++ b/src/postgraphql/__tests__/postgraphql-test.js @@ -2,15 +2,19 @@ jest.mock('pg') jest.mock('pg-connection-string') jest.mock('../schema/createPostGraphQLSchema') jest.mock('../http/createPostGraphQLHttpRequestHandler') +jest.mock('../watch/watchPgSchemas') import { Pool } from 'pg' import { parse as parsePgConnectionString } from 'pg-connection-string' import createPostGraphQLSchema from '../schema/createPostGraphQLSchema' import createPostGraphQLHttpRequestHandler from '../http/createPostGraphQLHttpRequestHandler' +import watchPgSchemas from '../watch/watchPgSchemas' import postgraphql from '../postgraphql' -createPostGraphQLHttpRequestHandler - .mockImplementation(({ graphqlSchema }) => Promise.resolve(graphqlSchema).then(() => null)) +const chalk = require('chalk') + +createPostGraphQLHttpRequestHandler.mockImplementation(({ getGqlSchema }) => Promise.resolve(getGqlSchema()).then(() => null)) +watchPgSchemas.mockImplementation(() => Promise.resolve()) test('will use the first parameter as the pool if it is an instance of `Pool`', async () => { const pgPool = new Pool() @@ -50,7 +54,7 @@ test('will use a connected client from the pool, the schemas, and options to cre createPostGraphQLSchema.mockClear() createPostGraphQLHttpRequestHandler.mockClear() const pgPool = new Pool() - const schemas = Symbol('schemas') + const schemas = [Symbol('schemas')] const options = Symbol('options') const pgClient = { release: jest.fn() } pgPool.connect.mockReturnValue(Promise.resolve(pgClient)) @@ -60,13 +64,64 @@ test('will use a connected client from the pool, the schemas, and options to cre expect(pgClient.release.mock.calls).toEqual([[]]) }) -test('will use a created GraphQL schema to create the Http request handler and pass down options', async () => { +test('will use a created GraphQL schema to create the HTTP request handler and pass down options', async () => { createPostGraphQLHttpRequestHandler.mockClear() const pgPool = new Pool() - const graphqlSchema = Promise.resolve(Symbol('graphqlSchema')) + const gqlSchema = Symbol('graphqlSchema') const options = { a: 1, b: 2, c: 3 } - createPostGraphQLSchema.mockReturnValueOnce(graphqlSchema) + createPostGraphQLSchema.mockReturnValueOnce(Promise.resolve(gqlSchema)) await postgraphql(pgPool, null, options) - expect(createPostGraphQLHttpRequestHandler.mock.calls) - .toEqual([[{ pgPool, graphqlSchema, a: 1, b: 2, c: 3 }]]) + expect(createPostGraphQLHttpRequestHandler.mock.calls.length).toBe(1) + expect(createPostGraphQLHttpRequestHandler.mock.calls[0].length).toBe(1) + expect(Object.keys(createPostGraphQLHttpRequestHandler.mock.calls[0][0])).toEqual(['a', 'b', 'c', 'getGqlSchema', 'pgPool']) + expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].pgPool).toBe(pgPool) + expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].a).toBe(options.a) + expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].b).toBe(options.b) + expect(createPostGraphQLHttpRequestHandler.mock.calls[0][0].c).toBe(options.c) + expect(await createPostGraphQLHttpRequestHandler.mock.calls[0][0].getGqlSchema()).toBe(gqlSchema) +}) + +test('will watch Postgres schemas when `watchPg` is true', async () => { + const pgPool = new Pool() + const pgSchemas = [Symbol('a'), Symbol('b'), Symbol('c')] + await postgraphql(pgPool, pgSchemas, { watchPg: false }) + await postgraphql(pgPool, pgSchemas, { watchPg: true }) + expect(watchPgSchemas.mock.calls.length).toBe(1) + expect(watchPgSchemas.mock.calls[0].length).toBe(1) + expect(Object.keys(watchPgSchemas.mock.calls[0][0])).toEqual(['pgPool', 'pgSchemas', 'onChange']) + expect(watchPgSchemas.mock.calls[0][0].pgPool).toBe(pgPool) + expect(watchPgSchemas.mock.calls[0][0].pgSchemas).toBe(pgSchemas) + expect(typeof watchPgSchemas.mock.calls[0][0].onChange).toBe('function') +}) + +test('will create a new PostGraphQL schema on when `watchPgSchemas` emits a change', async () => { + watchPgSchemas.mockClear() + createPostGraphQLHttpRequestHandler.mockClear() + const gqlSchemas = [Symbol('a'), Symbol('b'), Symbol('c')] + let gqlSchemaI = 0 + createPostGraphQLSchema.mockClear() + createPostGraphQLSchema.mockImplementation(() => Promise.resolve(gqlSchemas[gqlSchemaI++])) + const pgPool = new Pool() + const pgClient = { release: jest.fn() } + pgPool.connect.mockReturnValue(Promise.resolve(pgClient)) + const mockLog = jest.fn() + const origLog = console.log + console.log = mockLog + await postgraphql(pgPool, [], { watchPg: true }) + const { onChange } = watchPgSchemas.mock.calls[0][0] + const { getGqlSchema } = createPostGraphQLHttpRequestHandler.mock.calls[0][0] + expect(pgPool.connect.mock.calls).toEqual([[]]) + expect(pgClient.release.mock.calls).toEqual([[]]) + expect(await getGqlSchema()).toBe(gqlSchemas[0]) + onChange({ commands: ['a', 'b', 'c'] }) + expect(await getGqlSchema()).toBe(gqlSchemas[1]) + onChange({ commands: ['d', 'e'] }) + expect(await getGqlSchema()).toBe(gqlSchemas[2]) + expect(pgPool.connect.mock.calls).toEqual([[], [], []]) + expect(pgClient.release.mock.calls).toEqual([[], [], []]) + expect(mockLog.mock.calls).toEqual([ + [`Restarting PostGraphQL API after Postgres command(s): ️${chalk.bold.cyan('a')}, ${chalk.bold.cyan('b')}, ${chalk.bold.cyan('c')}`], + [`Restarting PostGraphQL API after Postgres command(s): ️${chalk.bold.cyan('d')}, ${chalk.bold.cyan('e')}`], + ]) + console.log = origLog }) diff --git a/src/postgraphql/cli.ts b/src/postgraphql/cli.ts index 54613fdb93..302c155dac 100755 --- a/src/postgraphql/cli.ts +++ b/src/postgraphql/cli.ts @@ -23,6 +23,7 @@ program // .option('-d, --demo', 'run PostGraphQL using the demo database connection') .option('-c, --connection ', 'the Postgres connection. if not provided it will be inferred from your environment') .option('-s, --schema ', 'a Postgres schema to be introspected. Use commas to define multiple schemas', (option: string) => option.split(',')) + .option('-w, --watch', 'watches the Postgres schema for changes and reruns introspection if a change was detected') .option('-n, --host ', 'the hostname to be used. Defaults to `localhost`') .option('-p, --port ', 'the port to be used. Defaults to 5000', parseFloat) .option('-m, --max-pool-size ', 'the maximum number of clients to keep in the Postgres pool. defaults to 10', parseFloat) @@ -54,6 +55,7 @@ process.on('SIGINT', process.exit) const { demo: isDemo = false, connection: pgConnectionString, + watch: watchPg, host: hostname = 'localhost', port = 5000, maxPoolSize, @@ -102,9 +104,10 @@ const server = createServer(postgraphql(pgConfig, schemas, { jwtSecret, jwtPgTypeIdentifier, pgDefaultRole, + watchPg, + showErrorStack, disableQueryLog: false, enableCors, - showErrorStack, })) // Start our server by listening to a specific port and host name. Also log diff --git a/src/postgraphql/http/__tests__/createPostGraphQLHttpRequestHandler-test.js b/src/postgraphql/http/__tests__/createPostGraphQLHttpRequestHandler-test.js index 9200bb7538..c356cbb0f2 100644 --- a/src/postgraphql/http/__tests__/createPostGraphQLHttpRequestHandler-test.js +++ b/src/postgraphql/http/__tests__/createPostGraphQLHttpRequestHandler-test.js @@ -11,7 +11,7 @@ const connect = require('connect') const express = require('express') const Koa = require('koa') -const graphqlSchema = new GraphQLSchema({ +const gqlSchema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', fields: { @@ -54,7 +54,7 @@ const pgPool = { } const defaultOptions = { - graphqlSchema, + getGqlSchema: () => gqlSchema, pgPool, disableQueryLog: true, } @@ -435,26 +435,18 @@ for (const [name, createServerFromHandler] of serverCreators) { ) }) - test('can use a promised GraphQL schema', async () => { + test('cannot use a rejected GraphQL schema', async () => { const rejectedGraphQLSchema = Promise.reject(new Error('Uh oh!')) // We don’t want Jest to complain about uncaught promise rejections. rejectedGraphQLSchema.catch(() => {}) - const server1 = createServer({ graphqlSchema: Promise.resolve(graphqlSchema) }) - const server2 = createServer({ graphqlSchema: rejectedGraphQLSchema }) - await ( - request(server1) - .post('/graphql') - .send({ query: '{hello}' }) - .expect(200) - .expect({ data: { hello: 'world' } }) - ) + const server = createServer({ getGqlSchema: () => rejectedGraphQLSchema }) // We want to hide `console.error` warnings because we are intentionally // generating some here. const origConsoleError = console.error console.error = () => {} try { await ( - request(server2) + request(server) .post('/graphql') .send({ query: '{hello}' }) .expect(500) diff --git a/src/postgraphql/http/createPostGraphQLHttpRequestHandler.d.ts b/src/postgraphql/http/createPostGraphQLHttpRequestHandler.d.ts index 28c16d91c4..1e51603fc9 100644 --- a/src/postgraphql/http/createPostGraphQLHttpRequestHandler.d.ts +++ b/src/postgraphql/http/createPostGraphQLHttpRequestHandler.d.ts @@ -20,7 +20,7 @@ export interface HttpRequestHandler { */ export default function createPostGraphQLHttpRequestHandler (config: { // The actual GraphQL schema we will use. - graphqlSchema: GraphQLSchema | Promise, + getGqlSchema: () => Promise, // A Postgres client pool we use to connect Postgres clients. pgPool: Pool, diff --git a/src/postgraphql/http/createPostGraphQLHttpRequestHandler.js b/src/postgraphql/http/createPostGraphQLHttpRequestHandler.js index d7e2969692..c67b38b83f 100644 --- a/src/postgraphql/http/createPostGraphQLHttpRequestHandler.js +++ b/src/postgraphql/http/createPostGraphQLHttpRequestHandler.js @@ -39,7 +39,7 @@ const favicon = new Promise((resolve, reject) => { * @param {GraphQLSchema} graphqlSchema */ export default function createPostGraphQLHttpRequestHandler (options) { - const { graphqlSchema, pgPool } = options + const { getGqlSchema, pgPool } = options // Gets the route names for our GraphQL endpoint, and our GraphiQL endpoint. const graphqlRoute = options.graphqlRoute || '/graphql' @@ -159,7 +159,7 @@ export default function createPostGraphQLHttpRequestHandler (options) { // a result. We also keep track of `params`. let params let result - let queryDocumentAST + let queryDocumentAst const queryTimeStart = process.hrtime() let pgRole @@ -169,6 +169,10 @@ export default function createPostGraphQLHttpRequestHandler (options) { // GraphQL query. All errors thrown in this block will be returned to the // client as GraphQL errors. try { + // First thing we need to do is get the GraphQL schema for this request. + // It should never really change unless we are in watch mode. + const gqlSchema = await getGqlSchema() + // Run all of our middleware by converting them into promises and // chaining them together. Remember that if we have a middleware that // never calls `next`, we will have a promise that never resolves! Avoid @@ -241,7 +245,7 @@ export default function createPostGraphQLHttpRequestHandler (options) { // Catch an errors while parsing so that we can set the `statusCode` to // 400. Otherwise we don’t need to parse this way. try { - queryDocumentAST = parseGraphql(source) + queryDocumentAst = parseGraphql(source) } catch (error) { res.statusCode = 400 @@ -252,7 +256,7 @@ export default function createPostGraphQLHttpRequestHandler (options) { // Validate our GraphQL query using given rules. // TODO: Add a complexity GraphQL rule. - const validationErrors = validateGraphql(await graphqlSchema, queryDocumentAST) + const validationErrors = validateGraphql(gqlSchema, queryDocumentAst) // If we have some validation errors, don’t execute the query. Instead // send the errors to the client with a `400` code. @@ -266,7 +270,7 @@ export default function createPostGraphQLHttpRequestHandler (options) { // Lazily log the query. If this debugger isn’t enabled, don’t run it. if (debugGraphql.enabled) - debugGraphql(printGraphql(queryDocumentAST).replace(/\s+/g, ' ').trim()) + debugGraphql(printGraphql(queryDocumentAst).replace(/\s+/g, ' ').trim()) // Connect a new Postgres client and start a transaction. const pgClient = await pgPool.connect() @@ -283,8 +287,8 @@ export default function createPostGraphQLHttpRequestHandler (options) { try { result = await executeGraphql( - await graphqlSchema, - queryDocumentAST, + gqlSchema, + queryDocumentAst, null, { [$$pgClient]: pgClient }, params.variables, @@ -321,8 +325,8 @@ export default function createPostGraphQLHttpRequestHandler (options) { debugRequest('GraphQL query request finished.') // Log the query. If this debugger isn’t enabled, don’t run it. - if (queryDocumentAST && !options.disableQueryLog) { - const prettyQuery = printGraphql(queryDocumentAST).replace(/\s+/g, ' ').trim() + if (queryDocumentAst && !options.disableQueryLog) { + const prettyQuery = printGraphql(queryDocumentAst).replace(/\s+/g, ' ').trim() const errorCount = (result.errors || []).length const timeDiff = process.hrtime(queryTimeStart) const ms = Math.round((timeDiff[0] * 1e9 + timeDiff[1]) * 10e-7 * 100) / 100 diff --git a/src/postgraphql/postgraphql.ts b/src/postgraphql/postgraphql.ts index 040bf35c64..88b6dfb67b 100644 --- a/src/postgraphql/postgraphql.ts +++ b/src/postgraphql/postgraphql.ts @@ -1,7 +1,10 @@ import { Pool, PoolConfig } from 'pg' import { parse as parsePgConnectionString } from 'pg-connection-string' +import { GraphQLSchema } from 'graphql' +import chalk = require('chalk') import createPostGraphQLSchema from './schema/createPostGraphQLSchema' import createPostGraphQLHttpRequestHandler, { HttpRequestHandler } from './http/createPostGraphQLHttpRequestHandler' +import watchPgSchemas from './watch/watchPgSchemas' /** * Creates a PostGraphQL Http request handler by first introspecting the @@ -20,11 +23,15 @@ export default function postgraphql ( pgDefaultRole?: string, jwtSecret?: string, jwtPgTypeIdentifier?: string, + watchPg?: boolean, showErrorStack?: boolean, disableQueryLog?: boolean, enableCors?: boolean, } = {}, ): HttpRequestHandler { + // Creates the Postgres schemas array. + const pgSchemas: Array = Array.isArray(schema) ? schema : [schema] + // Do some things with `poolOrConfig` so that in the end, we actually get a // Postgres pool. const pgPool = @@ -42,28 +49,69 @@ export default function postgraphql ( // Creates a promise which will resolve to a GraphQL schema. Connects a // client from our pool to introspect the database. - const graphqlSchema = (async () => { - const pgClient = await pgPool.connect() - const subGraphqlSchema = await createPostGraphQLSchema(pgClient, schema, options) - - // If no release function exists, don’t release. This is just for tests. - if (pgClient && pgClient.release) - pgClient.release() + // + // This is not a constant because when we are in watch mode, we want to swap + // out the `gqlSchema`. + let gqlSchema = createGqlSchema() - return subGraphqlSchema - })() + // If the user wants us to watch the schema, execute the following: + if (options.watchPg) { + watchPgSchemas({ + pgPool, + pgSchemas, + onChange: ({ commands }) => { + // tslint:disable-next-line no-console + console.log(`Restarting PostGraphQL API after Postgres command(s)${options.graphiql ? '. Make sure to reload GraphiQL' : ''}: ️${commands.map(command => chalk.bold.cyan(command)).join(', ')}`) - // If we fail to build our schema, log the error and exit the process. - graphqlSchema.catch(error => { - // tslint:disable-next-line no-console - console.error(`${error.stack}\n`) - process.exit(1) - }) + // Actually restart the GraphQL schema by creating a new one. Note that + // `createGqlSchema` returns a promise and we aren’t ‘await’ing it. + gqlSchema = createGqlSchema() + }, + }) + // If an error occurs when watching the Postgres schemas, log the error and + // exit the process. + .catch(error => { + // tslint:disable-next-line no-console + console.error(`${error.stack}\n`) + process.exit(1) + }) + } // Finally create our Http request handler using our options, the Postgres // pool, and GraphQL schema. Return the final result. return createPostGraphQLHttpRequestHandler(Object.assign({}, options, { - graphqlSchema, + getGqlSchema: () => gqlSchema, pgPool, })) + + /** + * Creates a GraphQL schema by connecting a client from our pool which will + * be used to introspect our Postgres database. If this function fails, we + * will log the error and exit the process. + * + * This may only be executed once, at startup. However, if we are in watch + * mode this will be updated whenever there is a change in our schema. + */ + async function createGqlSchema (): Promise { + try { + const pgClient = await pgPool.connect() + const newGqlSchema = await createPostGraphQLSchema(pgClient, pgSchemas, options) + + // If no release function exists, don’t release. This is just for tests. + if (pgClient && pgClient.release) + pgClient.release() + + return newGqlSchema + } + // If we fail to build our schema, log the error and exit the process. + catch (error) { + // tslint:disable no-console + console.error(`${error.stack}\n`) + process.exit(1) + + // This is just here to make TypeScript type check. `process.exit` will + // quit our program meaning we never execute this code. + return null as never + } + } } diff --git a/src/postgraphql/watch/__tests__/watchPgSchemas-test.js b/src/postgraphql/watch/__tests__/watchPgSchemas-test.js new file mode 100644 index 0000000000..461d939196 --- /dev/null +++ b/src/postgraphql/watch/__tests__/watchPgSchemas-test.js @@ -0,0 +1,69 @@ +jest.useFakeTimers() + +import watchPgSchemas, { _watchFixturesQuery } from '../watchPgSchemas' + +const chalk = require('chalk') + +test('will connect a client from the provided pool, run some SQL, and listen for notifications', async () => { + const pgClient = { query: jest.fn(() => Promise.resolve()), on: jest.fn() } + const pgPool = { connect: jest.fn(() => Promise.resolve(pgClient)) } + await watchPgSchemas({ pgPool }) + expect(pgPool.connect.mock.calls).toEqual([[]]) + expect(pgClient.query.mock.calls).toEqual([[await _watchFixturesQuery], ['listen postgraphql_watch']]) + expect(pgClient.on.mock.calls.length).toBe(1) + expect(pgClient.on.mock.calls[0].length).toBe(2) + expect(pgClient.on.mock.calls[0][0]).toBe('notification') + expect(typeof pgClient.on.mock.calls[0][1]).toBe('function') +}) + +test('will log some stuff and continue if the watch fixtures query fails', async () => { + const pgClient = { + query: jest.fn(async query => { + if (query === await _watchFixturesQuery) + throw new Error('oops!') + }), + on: jest.fn(), + } + const pgPool = { + connect: jest.fn(() => Promise.resolve(pgClient)), + } + const mockWarn = jest.fn() + const origWarn = console.warn + console.warn = mockWarn + await watchPgSchemas({ pgPool }) + console.warn = origWarn + expect(pgPool.connect.mock.calls).toEqual([[]]) + expect(pgClient.query.mock.calls).toEqual([[await _watchFixturesQuery], ['listen postgraphql_watch']]) + expect(pgClient.on.mock.calls.length).toBe(1) + expect(pgClient.on.mock.calls[0].length).toBe(2) + expect(pgClient.on.mock.calls[0][0]).toBe('notification') + expect(typeof pgClient.on.mock.calls[0][1]).toBe('function') + expect(mockWarn.mock.calls).toEqual([ + [chalk.bold.yellow('Failed to setup watch fixtures in Postgres database') + ' ️️⚠️'], + [chalk.yellow('This is likely because your Postgres user is not a superuser. If the')], + [chalk.yellow('fixtures already exist, the watch functionality may still work.')], + ]) +}) + +test('will call `onChange` with the appropriate commands from the notification listener', async () => { + const onChange = jest.fn() + let notificationListener + const pgClient = { query: jest.fn(), on: jest.fn((event, listener) => notificationListener = listener) } + const pgPool = { connect: jest.fn(() => pgClient) } + await watchPgSchemas({ pgPool, pgSchemas: ['a', 'b'], onChange }) + const notifications = [ + {}, + { payload: '' }, + { channel: 'postgraphql_watch' }, + { channel: 'unknown_channel', payload: 'error!' }, + { channel: 'postgraphql_watch', payload: '' }, + { channel: 'postgraphql_watch', payload: JSON.stringify([{ schema: 'a', command: '1' }]) }, + { channel: 'postgraphql_watch', payload: JSON.stringify([{ schema: 'b', command: '2' }]) }, + { channel: 'postgraphql_watch', payload: JSON.stringify([{ schema: 'a', command: '3' }, { schema: 'b', command: '4' }]) }, + { channel: 'postgraphql_watch', payload: JSON.stringify([{ schema: 'c', command: '5' }]) }, + { channel: 'postgraphql_watch', payload: JSON.stringify([{ schema: 'a', command: '6' }, { schema: 'c', command: '7' }, { schema: 'b', command: '8' }]) }, + ] + notifications.forEach(notification => notificationListener(notification)) + jest.runAllTimers() + expect(onChange.mock.calls).toEqual([[{ commands: ['1', '2', '3', '4', '6', '8'] }]]) +}) diff --git a/src/postgraphql/watch/watchPgSchemas.ts b/src/postgraphql/watch/watchPgSchemas.ts new file mode 100644 index 0000000000..44f1e31a6f --- /dev/null +++ b/src/postgraphql/watch/watchPgSchemas.ts @@ -0,0 +1,104 @@ +import { resolve as resolvePath } from 'path' +import { readFile } from 'fs' +import chalk = require('chalk') +import { Pool } from 'pg' +import minify = require('pg-minify') + +/** + * This query creates some fixtures required to watch a Postgres database. + * Most notably an event trigger. + */ +export const _watchFixturesQuery = new Promise((resolve, reject) => { + readFile(resolvePath(__dirname, '../../../resources/watch-fixtures.sql'), (error, data) => { + if (error) reject(error) + else resolve(minify(data.toString())) + }) +}) + +/** + * Watches a Postgres schema for changes. Does this by running a query which + * sets up some fixtures for watching the database including, most importantly, + * a DDL event trigger (if the script can’t be applied it isn’t fatal, just a + * warning). + * + * The event trigger will then notify PostGraphQL whenever DDL queries are + * succesfully run. PostGraphQL will emit these notifications to a provided + * `onChange` handler. + */ +export default async function watchPgSchemas ({ pgPool, pgSchemas, onChange }: { + pgPool: Pool, + pgSchemas: Array, + onChange: (info: { commands: Array }) => void, +}): Promise { + // Connect a client from our pool. Note that we never release this query + // back to the pool. We keep it forever to receive notifications. + const pgClient = await pgPool.connect() + + // Try to apply our watch fixtures to the database. If the query fails, fail + // gracefully with a warning as the feature may still work. + try { + await pgClient.query(await _watchFixturesQuery) + } + catch (error) { + // tslint:disable no-console + console.warn(`${chalk.bold.yellow('Failed to setup watch fixtures in Postgres database')} ️️⚠️`) + console.warn(chalk.yellow('This is likely because your Postgres user is not a superuser. If the')) + console.warn(chalk.yellow('fixtures already exist, the watch functionality may still work.')) + // tslint:enable no-console + } + + // Listen to the `postgraphql_watch` channel. Any and all updates will come + // through here. + await pgClient.query('listen postgraphql_watch') + + // Flushes our `commandsQueue` to the `onChange` listener. This function is + // debounced, so it may not flush synchronously. It will accumulate commands + // and send them in batches all at once. + const flushCommands = (() => { + // tslint:disable-next-line no-any + let lastTimeoutId: any = null + let commandsBuffer: Array = [] + + return (commands: Array) => { + // Add all of our commands to our internal buffer. + commandsBuffer = commandsBuffer.concat(commands) + + // Clear the last timeout and start a new timer. This is effectively our + // ‘debounce’ implementation. + clearTimeout(lastTimeoutId) + lastTimeoutId = setTimeout(() => { + // Run the `onChange` listener with our commands buffer and clear + // our buffer. + onChange({ commands: commandsBuffer }) + commandsBuffer = [] + }, 500) + } + })() + + // Process any notifications we may get. + pgClient.on('notification', notification => { + // If the notification is for the wrong channel or if the notification + // payload is falsy (when it’s an empty string), don’t process this + // notification. + if (notification.channel !== 'postgraphql_watch' || !notification.payload) + return + + // Parse our payload into a JSON object and give it some type information. + const payload: Array<{ schema: string | null, command: string }> = JSON.parse(notification.payload) + + // Take our payload and filter out all of the ‘noise,’ i.e. the commands + // aren’t in the schemas we are watching. Then map to a format we can + // share. + const commands: Array = + payload + .filter(({ schema }) => schema == null || pgSchemas.indexOf(schema) !== -1) + .map(({ command }) => command) + + // If we filtered everything away, let’s return ang ignore those commands. + if (commands.length === 0) + return + + // Finally flush our commands. This will happen asynchronously. + flushCommands(commands) + }) +} diff --git a/src/postgres/introspection/introspectDatabase.ts b/src/postgres/introspection/introspectDatabase.ts index 674337044d..235fc7c248 100644 --- a/src/postgres/introspection/introspectDatabase.ts +++ b/src/postgres/introspection/introspectDatabase.ts @@ -1,25 +1,25 @@ -import * as path from 'path' +import { resolve as resolvePath } from 'path' import { readFile } from 'fs' import { Client } from 'pg' import minify = require('pg-minify') import PgCatalog from './PgCatalog' /** - * The introspection query Sql string. We read this from it’s Sql file + * The introspection query SQL string. We read this from it’s SQL file * synchronously at runtime. It’s just like requiring a file, except that file - * is Sql. + * is SQL. */ const introspectionQuery = new Promise((resolve, reject) => { - readFile(path.resolve(__dirname, '../../../resources/introspection-query.sql'), (error, data) => { + readFile(resolvePath(__dirname, '../../../resources/introspection-query.sql'), (error, data) => { if (error) reject(error) else resolve(minify(data.toString())) }) }) /** - * Takes a PostgreSql client and introspects it, returning an instance of + * Takes a Postgres client and introspects it, returning an instance of * `PgObjects` which can then be consumed. Note that some translation is done - * from the raw PostgreSql catalog to the friendlier `PgObjects` interface. + * from the raw Postgres catalog to the friendlier `PgObjects` interface. */ export default async function introspectDatabase (client: Client, schemas: string[]): Promise { // Run our single introspection query in the database.