UNPKG

@resin/pinejs

Version:

Pine.js is a sophisticated rules-driven API engine that enables you to define rules in a structured subset of English. Those rules are used in order for Pine.js to generate a database schema and the associated [OData](http://www.odata.org/) API. This make

593 lines (538 loc) • 16.9 kB
/// <references types="websql"/> import * as _mysql from 'mysql' import * as _pg from 'pg' import * as _pgConnectionString from 'pg-connection-string' import * as _ from 'lodash' import * as Promise from 'bluebird' import TypedError = require('typed-error') import * as env from '../config-loader/env' const { DEBUG } = process.env export interface CodedError extends Error { code: number | string constructor: Function } type CreateTransactionFn = (stackTraceErr?: Error) => Promise<Tx> type CloseTransactionFn = () => void export interface Row { [fieldName: string]: any } export interface Result { rows: Array<Row> rowsAffected: number insertId?: number } export type Sql = string export type Bindings = any[] const isSqlError = (value: any): value is SQLError => { return value != null && value.constructor != null && value.constructor.name === 'SQLError' } export class DatabaseError extends TypedError { public code: number | string constructor(message?: string | CodedError | SQLError) { if (isSqlError(message)) { // If this is a SQLError we have to handle it specially (since it's not actually an instance of Error) super(message.message as string) } else { super(message) } if (message != null && !_.isString(message) && message.code != null) { // If the message has a code then use that as our code. this.code = message.code } } } export class ConstraintError extends DatabaseError {} export class UniqueConstraintError extends ConstraintError {} export class ForeignKeyConstraintError extends ConstraintError {} const NotADatabaseError = (err: any) => !(err instanceof DatabaseError) const alwaysExport = { DatabaseError, ConstraintError, UniqueConstraintError, ForeignKeyConstraintError, } export type Database = { DatabaseError: typeof DatabaseError, ConstraintError: typeof ConstraintError, UniqueConstraintError: typeof UniqueConstraintError, ForeignKeyConstraintError: typeof ForeignKeyConstraintError, engine: string executeSql: (this: Database, sql: Sql, bindings?: Bindings) => Promise<Tx> transaction: (callback?: ((tx: Tx) => void)) => Promise<Tx> } export const engines: { [engine: string]: (connectString: string | object) => Database } = {} const atomicExecuteSql: Database['executeSql'] = function(sql, bindings) { return this.transaction((tx) => tx.executeSql(sql, bindings) ) } const tryFn = (fn: () => any) => { Promise.try(fn) } let timeoutMS: number if (process.env.TRANSACTION_TIMEOUT_MS) { timeoutMS = _.parseInt(process.env.TRANSACTION_TIMEOUT_MS) if (_.isNaN(timeoutMS) || timeoutMS <= 0) { throw new Error(`Invalid valid for TRANSACTION_TIMEOUT_MS: ${process.env.TRANSACTION_TIMEOUT_MS}`) } } else { timeoutMS = 10000 } type RejectedFunctions = (message: string) => { executeSql: Tx['executeSql'], rollback: Tx['rollback'], } const getRejectedFunctions: RejectedFunctions = DEBUG ? (message) => { // In debug mode we create the error here to give the stack trace of where we first closed the transaction, // but it adds significant overhead for a production environment const rejectionValue = new Error(message) return { executeSql: () => // We return a new rejected promise on each call so that bluebird can handle // logging errors if the rejection is not handled (but only if it is not handled) Promise.reject(rejectionValue), rollback: () => Promise.reject(rejectionValue), } } : (message) => { const rejectFn = () => Promise.reject(new Error(message)) return { executeSql: rejectFn, rollback: rejectFn, } } export abstract class Tx { private automaticCloseTimeout: ReturnType<typeof setTimeout> private automaticClose: () => void constructor( stackTraceErr?: Error ) { this.automaticClose = () => { console.error('Transaction still open after ' + timeoutMS + 'ms without an execute call.') if (stackTraceErr) { console.error(stackTraceErr.stack) } this.rollback() } this.automaticCloseTimeout = setTimeout(this.automaticClose, timeoutMS) } private pending: false | number = 0 private incrementPending() { if (this.pending === false) { return } this.pending++ clearTimeout(this.automaticCloseTimeout) } private decrementPending() { if (this.pending === false) { return } this.pending-- // We only ever want one timeout running at a time, hence not using <= if (this.pending === 0) { this.automaticCloseTimeout = setTimeout(this.automaticClose, timeoutMS) } else if (this.pending < 0) { console.error('Pending transactions is less than 0, wtf?') this.pending = 0 } } cancelPending() { // Set pending to false to cancel all pending. this.pending = false clearTimeout(this.automaticCloseTimeout) } private closeTransaction(message: string): void { this.cancelPending() const { executeSql, rollback } = getRejectedFunctions(message) this.executeSql = executeSql this.rollback = this.end = rollback } public executeSql(sql: Sql, bindings: Bindings = [], ...args: any[]): Promise<Result> { this.incrementPending() return this._executeSql(sql, bindings, ...args) .finally(() => this.decrementPending()) .catch(NotADatabaseError, (err: CodedError) => { // Wrap the error so we can catch it easier later throw new DatabaseError(err) }) } public rollback(): Promise<void> { const promise = this._rollback().finally(() => { this.listeners.rollback.forEach(tryFn) return null }) this.closeTransaction('Transaction has been rolled back.') return promise } public end(): Promise<void> { const promise = this._commit().tap(() => { this.listeners.end.forEach(tryFn) return null }) this.closeTransaction('Transaction has been ended.') return promise } private listeners: { end: Array<() => void>, rollback: Array<() => void>, } = { end: [], rollback: [], } public on(name: 'end', fn: () => void): void public on(name: 'rollback', fn: () => void): void public on(name: keyof Tx['listeners'], fn: () => void): void { this.listeners[name].push(fn) } protected abstract _executeSql(sql: Sql, bindings: Bindings, addReturning?: false | string): Promise<Result> protected abstract _rollback(): Promise<void> protected abstract _commit(): Promise<void> public abstract tableList(extraWhereClause?: string): Promise<Result> public dropTable(tableName: string, ifExists = true) { if (!_.isString(tableName)) { return Promise.reject(new TypeError('"tableName" must be a string')) } if (_.includes(tableName, '"')) { return Promise.reject(new TypeError('"tableName" cannot include double quotes')) } const ifExistsStr = (ifExists === true) ? ' IF EXISTS' : '' return this.executeSql(`DROP TABLE${ifExistsStr} "${tableName}";`) } } const getStackTraceErr: (() => Error | undefined) = DEBUG ? () => new Error() : (_.noop as () => undefined) const createTransaction = (createFunc: CreateTransactionFn) => { function transaction<T>(fn: (tx: Tx) => Promise<T> | T): Promise<T> function transaction(): Promise<Tx> function transaction<T>(fn?: (tx: Tx) => Promise<T> | T): Promise<T> | Promise<Tx> { const stackTraceErr = getStackTraceErr() // Create a new promise in order to be able to get access to cancellation, to let us // return the client to the pool if the promise was cancelled whilst we were waiting return new Promise<Tx | T>((resolve, reject, onCancel) => { if (onCancel) { onCancel(() => { // Rollback the promise on cancel promise.call('rollback') }) } let promise = createFunc(stackTraceErr) if (fn) { promise.tap((tx) => Promise.try<T>(() => fn(tx)) .tap(() => tx.end() ).tapCatch(() => tx.rollback() ) .then(resolve) .catch(reject) ) } else { promise .then(resolve) .catch(reject) } }) as Promise<Tx> | Promise<T> } return transaction } let maybePg: typeof _pg | undefined try { maybePg = require('pg') } catch (e) {} if (maybePg != null) { const pg = maybePg engines.postgres = (connectString: string | object): Database => { const PG_UNIQUE_VIOLATION = '23505' const PG_FOREIGN_KEY_VIOLATION = '23503' let config: _pg.PoolConfig if (_.isString(connectString)) { const pgConnectionString: typeof _pgConnectionString = require('pg-connection-string') // We have to cast because of the use of null vs undefined config = pgConnectionString.parse(connectString) as _pg.PoolConfig } else { config = connectString } // Use bluebird for our pool promises config.Promise = Promise config.max = env.db.poolSize config.idleTimeoutMillis = env.db.idleTimeoutMillis const pool = new pg.Pool(config) const { PG_SCHEMA } = process.env if (PG_SCHEMA != null ) { pool.on('connect', (client) => { client.query({ text: `SET search_path TO "${PG_SCHEMA}"` }) }) } const connect = Promise.promisify(pool.connect, { context: pool }) const createResult = ({ rowCount, rows }: { rowCount: number, rows: Array<Row> }): Result => { return { rows, rowsAffected: rowCount, insertId: _.get(rows, [ 0, 'id' ]), } } class PostgresTx extends Tx { constructor( private db: _pg.Client, private close: CloseTransactionFn, stackTraceErr?: Error ) { super(stackTraceErr) } protected _executeSql(sql: Sql, bindings: Bindings, addReturning: false | string = false) { if (addReturning && /^\s*INSERT\s+INTO/i.test(sql)) { sql = sql.replace(/;?$/, ' RETURNING "' + addReturning + '";') } return Promise.fromCallback((callback) => { this.db.query({ text: sql, values: bindings }, callback) }).catch({ code: PG_UNIQUE_VIOLATION }, (err) => { // We know that the type is an Error for pg, but typescript doesn't like the catch obj sugar throw new UniqueConstraintError(err as any as CodedError) }).catch({ code: PG_FOREIGN_KEY_VIOLATION }, (err) => { throw new ForeignKeyConstraintError(err as any as CodedError) }).then(createResult) } protected _rollback() { const promise = this.executeSql('ROLLBACK;') this.close() return promise.return() } protected _commit() { const promise = this.executeSql('COMMIT;') this.close() return promise.return() } public tableList(extraWhereClause: string = '') { if (extraWhereClause !== '') { extraWhereClause = 'WHERE ' + extraWhereClause } return this.executeSql(` SELECT * FROM ( SELECT tablename as name FROM pg_tables WHERE schemaname = 'public' ) t ${extraWhereClause}; `) } } return _.extend({ engine: 'postgres', executeSql: atomicExecuteSql, transaction: createTransaction((stackTraceErr) => connect() .then((client) => { const tx = new PostgresTx(client, client.release, stackTraceErr) tx.executeSql('START TRANSACTION;') return tx }) ), }, alwaysExport) } } let maybeMysql: typeof _mysql | undefined try { maybeMysql = require('mysql') } catch (e) {} if (maybeMysql != null) { const mysql = maybeMysql engines.mysql = (options: _mysql.IPoolConfig): Database => { const MYSQL_UNIQUE_VIOLATION = 'ER_DUP_ENTRY' const MYSQL_FOREIGN_KEY_VIOLATION = 'ER_ROW_IS_REFERENCED' const pool = mysql.createPool(options) pool.on('connection', (db) => { db.query("SET sql_mode='ANSI_QUOTES';") }) const connect = Promise.promisify(pool.getConnection, { context: pool }) interface MysqlRowArray extends Array<Row> { affectedRows: number insertId?: number } const createResult = (rows: MysqlRowArray): Result => { return { rows, rowsAffected: rows.affectedRows, insertId: rows.insertId, } } class MySqlTx extends Tx { constructor( private db: _mysql.IConnection, private close: CloseTransactionFn, stackTraceErr?: Error ) { super(stackTraceErr) } protected _executeSql(sql: Sql, bindings: Bindings) { return Promise.fromCallback((callback) => { this.db.query(sql, bindings, callback) }).catch({ code: MYSQL_UNIQUE_VIOLATION }, (err) => { // We know that the type is an IError for mysql, but typescript doesn't like the catch obj sugar throw new UniqueConstraintError(err as _mysql.IError) }).catch({ code: MYSQL_FOREIGN_KEY_VIOLATION }, (err) => { throw new ForeignKeyConstraintError(err as _mysql.IError) }).then(createResult) } protected _rollback() { const promise = this.executeSql('ROLLBACK;') this.close() return promise.return() } protected _commit() { const promise = this.executeSql('COMMIT;') this.close() return promise.return() } public tableList(extraWhereClause: string = '') { if (extraWhereClause !== '') { extraWhereClause = ' WHERE ' + extraWhereClause } return this.executeSql(` SELECT name FROM ( SELECT table_name AS name FROM information_schema.tables WHERE table_schema = ? ) t ${extraWhereClause}; `, [options.database]) } } return _.extend({ engine: 'mysql', executeSql: atomicExecuteSql, transaction: createTransaction((stackTraceErr) => connect() .then((client) => { const close = () => client.release() const tx = new MySqlTx(client, close, stackTraceErr) tx.executeSql('START TRANSACTION;') return tx }) ), }, alwaysExport) } } if (typeof window !== 'undefined' && window.openDatabase != null) { interface WebSqlResult { insertId?: number rowsAffected: number rows: { item: (i: number) => {} length: number } } type AsyncQuery = [ Sql, Bindings, SQLStatementCallback, SQLStatementErrorCallback ] engines.websql = (databaseName: string): Database => { const WEBSQL_CONSTRAINT_ERR = 6 const db = window.openDatabase(databaseName, '1.0', 'rulemotion', 2 * 1024 * 1024) const getInsertId = (result: WebSqlResult) => { // Ignore the potential DOM exception. try { return result.insertId } catch (e) {} } const createResult = (result: WebSqlResult): Result => { const rows = _.times(result.rows.length, (i) => { return result.rows.item(i) }) return { rows: rows, rowsAffected: result.rowsAffected, insertId: getInsertId(result), } } class WebSqlTx extends Tx { private running = true private queue: AsyncQuery[] = [] constructor( private tx: SQLTransaction, stackTraceErr?: Error ) { super(stackTraceErr) this.asyncRecurse() } // This function is used to recurse executeSql calls and keep the transaction open, // allowing us to use async calls within the API. private asyncRecurse = () => { let args: AsyncQuery | undefined while (args = this.queue.pop()) { console.debug('Running', args[0]) this.tx.executeSql(args[0], args[1], args[2], args[3]) } if (this.running) { console.debug('Looping') this.tx.executeSql('SELECT 0', [], this.asyncRecurse) } } protected _executeSql(sql: Sql, bindings: Bindings) { return new Promise((resolve, reject) => { const successCallback: SQLStatementCallback = (_tx, results) => { resolve(results) } const errorCallback: SQLStatementErrorCallback = (_tx, err) => { reject(err) return false } this.queue.push([sql, bindings, successCallback, errorCallback]) }).catch({ code: WEBSQL_CONSTRAINT_ERR }, () => { throw new ConstraintError('Constraint failed.') }).then(createResult) } protected _rollback(): Promise<void> { return new Promise((resolve) => { const successCallback: SQLStatementCallback = () => { resolve() throw new Error('Rollback') } const errorCallback: SQLStatementErrorCallback = () => { resolve() return true } this.queue = [['RUN A FAILING STATEMENT TO ROLLBACK', [], successCallback, errorCallback]] this.running = false }) } protected _commit() { this.running = false return Promise.resolve() } public tableList(extraWhereClause: string = '') { if (extraWhereClause !== '') { extraWhereClause = ' AND ' + extraWhereClause } return this.executeSql(` SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT IN ( '__WebKitDatabaseInfoTable__', 'sqlite_sequence' ) ${extraWhereClause}; `) } } return _.extend({ engine: 'websql', executeSql: atomicExecuteSql, transaction: createTransaction((stackTraceErr) => new Promise((resolve) => { db.transaction((tx) => { resolve(new WebSqlTx(tx, stackTraceErr)) }) }) ), }, alwaysExport) } } export const connect = (databaseOptions: { engine: string, params: {} }) => { if (engines[databaseOptions.engine] == null) { throw new Error('Unsupported database engine: ' + databaseOptions.engine) } return engines[databaseOptions.engine](databaseOptions.params) }