Repository: rickbergfalk/sqlpad
Pull request #550 (2020-02-24 16:43:19)

Add a build pipeline
This PR add a Github Action build pipeline to SQLPad. 

Every tag on the repository will trigger the usual build, tar the result and add it to Github Release next to the source code, like this https://github.com/rgarrigue/sqlpad/releases/tag/v4.0.2

This will allow users to install SQLPad on a basic web server downloading / untaring / adding a config file without installing all the build dependencies.
 





Patch:
diff --git a/.github/workflows/build.sh b/.github/workflows/build.sh
new file mode 100755
index 00000000..12f9159a
--- /dev/null
+++ b/.github/workflows/build.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env sh
+set -x
+
+apt-get update
+apt-get install -y --no-install-recommends g++ make python3 python3-setuptools unixodbc-dev
+
+bash -x scripts/build.sh
+
+tar -cavf sqlpad.tar.gz -C server/public .
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 00000000..47a1e0b6
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,42 @@
+name: SQLPad release
+
+on:
+  push:
+    tags:
+      - "*"
+
+jobs:
+  build:
+    name: Build and upload
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code ${{ github.sha }}
+        uses: actions/checkout@v2
+
+      - name: Build
+        uses: docker://node:13-slim
+        with:
+          entrypoint: bash
+          args: .github/workflows/build.sh
+
+      - name: Create Release
+        id: create_release
+        uses: actions/create-release@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          tag_name: ${{ github.ref }}
+          release_name: Release ${{ github.ref }}
+          draft: false
+          prerelease: false
+
+      - name: Upload archive to release
+        id: upload-release-asset
+        uses: actions/upload-release-asset@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps
+          asset_path: ./sqlpad.tar.gz
+          asset_name: sqlpad.tar.gz
+          asset_content_type: application/gzip
diff --git a/.gitignore b/.gitignore
index 682580fd..d73f430f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@ server/public
 coverage/
 db/
 dbtest/
-node_modules/
\ No newline at end of file
+node_modules/
+sqlpad.tar.gz
diff --git a/client/src/queryEditor/toolbar/Toolbar.js b/client/src/queryEditor/toolbar/Toolbar.js
index 9a247458..fe9dc5e9 100644
--- a/client/src/queryEditor/toolbar/Toolbar.js
+++ b/client/src/queryEditor/toolbar/Toolbar.js
@@ -1,127 +1,19 @@
-import { MenuItem } from '@reach/menu-button';
-import CopyIcon from 'mdi-react/ContentCopyIcon';
-import UnsavedIcon from 'mdi-react/ContentSaveEditIcon';
-import SaveIcon from 'mdi-react/ContentSaveIcon';
-import DatabaseIcon from 'mdi-react/DatabaseIcon';
-import DotsVerticalIcon from 'mdi-react/DotsVerticalIcon';
-import FormatIcon from 'mdi-react/FormatAlignLeftIcon';
-import NewIcon from 'mdi-react/PlusIcon';
-import TagsIcon from 'mdi-react/TagMultipleIcon';
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Redirect } from 'react-router-dom';
-import { connect } from 'unistore/react';
-import Button from '../../common/Button';
-import Drawer from '../../common/Drawer';
-import IconButton from '../../common/IconButton';
-import IconMenu from '../../common/IconMenu';
-import Input from '../../common/Input';
-import ConnectionListDrawer from '../../connections/ConnectionListDrawer';
-import ConnectionAccessListDrawer from '../../connectionAccesses/ConnectionAccessListDrawer';
-import {
-  formatQuery,
-  handleCloneClick,
-  resetNewQuery,
-  runQuery,
-  saveQuery,
-  setQueryState
-} from '../../stores/queries';
-import { toggleSchema } from '../../stores/schema';
-import UserList from '../../users/UserList';
-import fetchJson from '../../utilities/fetch-json.js';
+import React from 'react';
 import ConnectionDropDown from '../ConnectionDropdown';
-import QueryHistoryModal from '../../queryHistory/QueryHistoryModal';
-import AboutModal from './AboutModal';
 import ChartButton from './ChartButton';
 import QueryListButton from './QueryListButton';
-import QueryTagsModal from './QueryTagsModal';
-
-const growSpacerStyle = { flexShrink: 0, flexGrow: 1, width: 8 };
-const spacerStyle = { flexShrink: 0, width: 8 };
-
-function Spacer({ grow }) {
-  if (grow) {
-    return <div style={growSpacerStyle} />;
-  }
-  return <div style={spacerStyle} />;
-}
-
-function mapStateToProps(state) {
-  return {
-    currentUser: state.currentUser,
-    isRunning: state.isRunning,
-    isSaving: state.isSaving,
-    queryId: state.query && state.query._id,
-    queryName: state.query && state.query.name,
-    showValidation: state.showValidation,
-    unsavedChanges: state.unsavedChanges
-  };
-}
-
-const ConnectedEditorNavBar = connect(mapStateToProps, store => ({
-  toggleSchema,
-  formatQuery,
-  runQuery: runQuery(store),
-  saveQuery: saveQuery(store),
-  handleCloneClick,
-  resetNewQuery,
-  setQueryState
-}))(React.memo(Toolbar));
-
-function Toolbar({
-  currentUser,
-  formatQuery,
-  handleCloneClick,
-  isRunning,
-  isSaving,
-  queryId,
-  queryName,
-  resetNewQuery,
-  runQuery,
-  saveQuery,
-  setQueryState,
-  showValidation,
-  toggleSchema,
-  unsavedChanges
-}) {
-  const [showTags, setShowTags] = useState(false);
-  const [showUsers, setShowUsers] = useState(false);
-  const [redirectToSignIn, setRedirectToSignIn] = useState(false);
-  const [showQueryHistory, setShowQueryHistory] = useState(false);
-  const [showAbout, setShowAbout] = useState(false);
-  const [showConnections, setShowConnections] = useState(false);
-  const [showConnectionAccesses, setShowConnectionAccesses] = useState(false);
-
-  const error = showValidation && !queryName.length;
-  const cloneDisabled = !queryId;
-
-  const isAdmin = currentUser.role === 'admin';
-
-  if (redirectToSignIn) {
-    return <Redirect push to="/signin" />;
-  }
-
-  // Reach UI menu / IconMenu does not like the {someBoolean && <element/>} patterm
-  // As a workaround optional MenuItems are managed in an array
-  let menuItems = [];
-  if (isAdmin) {
-    menuItems = [
-      <MenuItem key="connections" onSelect={() => setShowConnections(true)}>
-        Connections
-      </MenuItem>,
-      <MenuItem key="users" onSelect={() => setShowUsers(true)}>
-        Users
-      </MenuItem>,
-      <MenuItem
-        key="connectionAccessess"
-        style={{ borderBottom: '1px solid #ddd' }}
-        onSelect={() => setShowConnectionAccesses(true)}
-      >
-        Connection Accesses
-      </MenuItem>
-    ];
-  }
-
+import ToolbarCloneButton from './ToolbarCloneButton';
+import ToolbarFormatQueryButton from './ToolbarFormatQueryButton';
+import ToolbarMenu from './ToolbarMenu';
+import ToolbarNewQueryButton from './ToolbarNewQueryButton';
+import ToolbarQueryNameInput from './ToolbarQueryNameInput';
+import ToolbarRunButton from './ToolbarRunButton';
+import ToolbarSaveButton from './ToolbarSaveButton';
+import ToolbarSpacer from './ToolbarSpacer';
+import ToolbarTagsButton from './ToolbarTagsButton';
+import ToolbarToggleSchemaButton from './ToolbarToggleSchemaButton';
+
+function Toolbar() {
   return (
     <div
       style={{
@@ -133,140 +25,38 @@ function Toolbar({
     >
       <div style={{ display: 'flex' }}>
         <QueryListButton />
+        <ToolbarNewQueryButton />
 
-        <IconButton
-          to="/queries/new"
-          tooltip="New query"
-          onClick={() => resetNewQuery()}
-        >
-          <NewIcon />
-        </IconButton>
-
-        <Spacer grow />
-
-        <IconButton tooltip="Toggle schema" onClick={toggleSchema}>
-          <DatabaseIcon />
-        </IconButton>
+        <ToolbarSpacer grow />
 
+        <ToolbarToggleSchemaButton />
         <ConnectionDropDown />
 
-        <Spacer />
-
-        <Input
-          error={error}
-          style={{ width: 260 }}
-          placeholder="Query name"
-          value={queryName}
-          onChange={e => setQueryState('name', e.target.value)}
-        />
-
-        <Spacer />
-
-        <IconButton tooltip="Tags" onClick={() => setShowTags(true)}>
-          <TagsIcon />
-        </IconButton>
-
-        <QueryTagsModal visible={showTags} onClose={() => setShowTags(false)} />
+        <ToolbarSpacer />
 
-        <IconButton
-          tooltip="Clone"
-          onClick={handleCloneClick}
-          disabled={cloneDisabled}
-        >
-          <CopyIcon />
-        </IconButton>
+        <ToolbarQueryNameInput />
 
-        <IconButton tooltip="Format" onClick={formatQuery}>
-          <FormatIcon />
-        </IconButton>
+        <ToolbarSpacer />
 
-        <IconButton
-          tooltip="Save"
-          onClick={() => saveQuery()}
-          disabled={isSaving}
-        >
-          {unsavedChanges ? <UnsavedIcon /> : <SaveIcon />}
-        </IconButton>
+        <ToolbarTagsButton />
+        <ToolbarCloneButton />
+        <ToolbarFormatQueryButton />
+        <ToolbarSaveButton />
 
-        <Spacer />
+        <ToolbarSpacer />
 
-        <Button type="primary" onClick={() => runQuery()} disabled={isRunning}>
-          Run
-        </Button>
+        <ToolbarRunButton />
 
-        <Spacer />
+        <ToolbarSpacer />
 
         <ChartButton />
 
-        <Spacer grow />
+        <ToolbarSpacer grow />
 
-        <IconMenu icon={<DotsVerticalIcon aria-label="menu" />}>
-          {menuItems}
-          <MenuItem
-            key="queryHistory"
-            style={{ borderBottom: '1px solid #ddd' }}
-            onSelect={() => setShowQueryHistory(true)}
-          >
-            Query History
-          </MenuItem>
-          <MenuItem
-            style={{ borderBottom: '1px solid #ddd' }}
-            onSelect={() => setShowAbout(true)}
-          >
-            About
-          </MenuItem>
-          <MenuItem
-            onSelect={async () => {
-              await fetchJson('GET', '/api/signout');
-              setRedirectToSignIn(true);
-            }}
-          >
-            Sign out
-          </MenuItem>
-        </IconMenu>
-
-        <Drawer
-          title={'Users'}
-          visible={showUsers}
-          width={600}
-          onClose={() => setShowUsers(false)}
-          placement={'right'}
-        >
-          <UserList />
-        </Drawer>
-
-        <QueryHistoryModal
-          visible={showQueryHistory}
-          onClose={() => setShowQueryHistory(false)}
-        />
-
-        <AboutModal visible={showAbout} onClose={() => setShowAbout(false)} />
-
-        <ConnectionListDrawer
-          visible={showConnections}
-          onClose={() => setShowConnections(false)}
-        />
-
-        <ConnectionAccessListDrawer
-          visible={showConnectionAccesses}
-          onClose={() => setShowConnectionAccesses(false)}
-        />
+        <ToolbarMenu />
       </div>
     </div>
   );
 }
 
-Toolbar.propTypes = {
-  isSaving: PropTypes.bool.isRequired,
-  isRunning: PropTypes.bool.isRequired,
-  handleCloneClick: PropTypes.func.isRequired,
-  saveQuery: PropTypes.func.isRequired,
-  runQuery: PropTypes.func.isRequired,
-  formatQuery: PropTypes.func.isRequired,
-  queryName: PropTypes.string.isRequired,
-  queryId: PropTypes.string,
-  showValidation: PropTypes.bool.isRequired,
-  unsavedChanges: PropTypes.bool.isRequired
-};
-
-export default ConnectedEditorNavBar;
+export default React.memo(Toolbar);
diff --git a/client/src/queryEditor/toolbar/ToolbarCloneButton.js b/client/src/queryEditor/toolbar/ToolbarCloneButton.js
new file mode 100644
index 00000000..25f4ec7d
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarCloneButton.js
@@ -0,0 +1,31 @@
+import CopyIcon from 'mdi-react/ContentCopyIcon';
+import React from 'react';
+import { connect } from 'unistore/react';
+import IconButton from '../../common/IconButton';
+import { handleCloneClick } from '../../stores/queries';
+
+function mapStateToProps(state) {
+  return {
+    queryId: state.query && state.query._id
+  };
+}
+
+const ConnectedToolbarCloneButton = connect(mapStateToProps, store => ({
+  handleCloneClick
+}))(React.memo(ToolbarCloneButton));
+
+function ToolbarCloneButton({ handleCloneClick, queryId }) {
+  const cloneDisabled = !queryId;
+
+  return (
+    <IconButton
+      tooltip="Clone"
+      onClick={handleCloneClick}
+      disabled={cloneDisabled}
+    >
+      <CopyIcon />
+    </IconButton>
+  );
+}
+
+export default ConnectedToolbarCloneButton;
diff --git a/client/src/queryEditor/toolbar/ToolbarFormatQueryButton.js b/client/src/queryEditor/toolbar/ToolbarFormatQueryButton.js
new file mode 100644
index 00000000..443afdf0
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarFormatQueryButton.js
@@ -0,0 +1,23 @@
+import FormatIcon from 'mdi-react/FormatAlignLeftIcon';
+import React from 'react';
+import { connect } from 'unistore/react';
+import IconButton from '../../common/IconButton';
+import { formatQuery } from '../../stores/queries';
+
+function mapStateToProps(state) {
+  return {};
+}
+
+const ConnectedToolbarFormatQueryButton = connect(mapStateToProps, store => ({
+  formatQuery
+}))(React.memo(ToolbarFormatQueryButton));
+
+function ToolbarFormatQueryButton({ formatQuery }) {
+  return (
+    <IconButton tooltip="Format" onClick={formatQuery}>
+      <FormatIcon />
+    </IconButton>
+  );
+}
+
+export default ConnectedToolbarFormatQueryButton;
diff --git a/client/src/queryEditor/toolbar/ToolbarMenu.js b/client/src/queryEditor/toolbar/ToolbarMenu.js
new file mode 100644
index 00000000..22d3586b
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarMenu.js
@@ -0,0 +1,115 @@
+import { MenuItem } from '@reach/menu-button';
+import DotsVerticalIcon from 'mdi-react/DotsVerticalIcon';
+import React, { useState } from 'react';
+import { Redirect } from 'react-router-dom';
+import { connect } from 'unistore/react';
+import Drawer from '../../common/Drawer';
+import IconMenu from '../../common/IconMenu';
+import ConnectionAccessListDrawer from '../../connectionAccesses/ConnectionAccessListDrawer';
+import ConnectionListDrawer from '../../connections/ConnectionListDrawer';
+import QueryHistoryModal from '../../queryHistory/QueryHistoryModal';
+import UserList from '../../users/UserList';
+import fetchJson from '../../utilities/fetch-json.js';
+import AboutModal from './AboutModal';
+
+function mapStateToProps(state) {
+  return {
+    currentUser: state.currentUser
+  };
+}
+
+const ConnectedToolbarMenu = connect(mapStateToProps)(React.memo(ToolbarMenu));
+
+function ToolbarMenu({ currentUser }) {
+  const [showUsers, setShowUsers] = useState(false);
+  const [redirectToSignIn, setRedirectToSignIn] = useState(false);
+  const [showQueryHistory, setShowQueryHistory] = useState(false);
+  const [showAbout, setShowAbout] = useState(false);
+  const [showConnections, setShowConnections] = useState(false);
+  const [showConnectionAccesses, setShowConnectionAccesses] = useState(false);
+
+  const isAdmin = currentUser.role === 'admin';
+
+  if (redirectToSignIn) {
+    return <Redirect push to="/signin" />;
+  }
+
+  // Reach UI menu / IconMenu does not like the {someBoolean && <element/>} patterm
+  // As a workaround optional MenuItems are managed in an array
+  let menuItems = [];
+  if (isAdmin) {
+    menuItems = [
+      <MenuItem key="connections" onSelect={() => setShowConnections(true)}>
+        Connections
+      </MenuItem>,
+      <MenuItem key="users" onSelect={() => setShowUsers(true)}>
+        Users
+      </MenuItem>,
+      <MenuItem
+        key="connectionAccessess"
+        style={{ borderBottom: '1px solid #ddd' }}
+        onSelect={() => setShowConnectionAccesses(true)}
+      >
+        Connection Accesses
+      </MenuItem>
+    ];
+  }
+
+  return (
+    <div>
+      <IconMenu icon={<DotsVerticalIcon aria-label="menu" />}>
+        {menuItems}
+        <MenuItem
+          key="queryHistory"
+          style={{ borderBottom: '1px solid #ddd' }}
+          onSelect={() => setShowQueryHistory(true)}
+        >
+          Query History
+        </MenuItem>
+        <MenuItem
+          style={{ borderBottom: '1px solid #ddd' }}
+          onSelect={() => setShowAbout(true)}
+        >
+          About
+        </MenuItem>
+        <MenuItem
+          onSelect={async () => {
+            await fetchJson('GET', '/api/signout');
+            setRedirectToSignIn(true);
+          }}
+        >
+          Sign out
+        </MenuItem>
+      </IconMenu>
+
+      <Drawer
+        title={'Users'}
+        visible={showUsers}
+        width={600}
+        onClose={() => setShowUsers(false)}
+        placement={'right'}
+      >
+        <UserList />
+      </Drawer>
+
+      <QueryHistoryModal
+        visible={showQueryHistory}
+        onClose={() => setShowQueryHistory(false)}
+      />
+
+      <AboutModal visible={showAbout} onClose={() => setShowAbout(false)} />
+
+      <ConnectionListDrawer
+        visible={showConnections}
+        onClose={() => setShowConnections(false)}
+      />
+
+      <ConnectionAccessListDrawer
+        visible={showConnectionAccesses}
+        onClose={() => setShowConnectionAccesses(false)}
+      />
+    </div>
+  );
+}
+
+export default ConnectedToolbarMenu;
diff --git a/client/src/queryEditor/toolbar/ToolbarNewQueryButton.js b/client/src/queryEditor/toolbar/ToolbarNewQueryButton.js
new file mode 100644
index 00000000..8e67eaa5
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarNewQueryButton.js
@@ -0,0 +1,27 @@
+import NewIcon from 'mdi-react/PlusIcon';
+import React from 'react';
+import { connect } from 'unistore/react';
+import IconButton from '../../common/IconButton';
+import { resetNewQuery } from '../../stores/queries';
+
+function mapStateToProps(state) {
+  return {};
+}
+
+const ConnectedToolbarNewQueryButton = connect(mapStateToProps, store => ({
+  resetNewQuery
+}))(React.memo(ToolbarNewQueryButton));
+
+function ToolbarNewQueryButton({ resetNewQuery }) {
+  return (
+    <IconButton
+      to="/queries/new"
+      tooltip="New query"
+      onClick={() => resetNewQuery()}
+    >
+      <NewIcon />
+    </IconButton>
+  );
+}
+
+export default ConnectedToolbarNewQueryButton;
diff --git a/client/src/queryEditor/toolbar/ToolbarQueryNameInput.js b/client/src/queryEditor/toolbar/ToolbarQueryNameInput.js
new file mode 100644
index 00000000..e89e1efc
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarQueryNameInput.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import { connect } from 'unistore/react';
+import Input from '../../common/Input';
+import { setQueryState } from '../../stores/queries';
+
+function mapStateToProps(state) {
+  return {
+    queryName: state.query && state.query.name,
+    showValidation: state.showValidation
+  };
+}
+
+const ConnectedToolbarQueryNameInput = connect(mapStateToProps, store => ({
+  setQueryState
+}))(React.memo(ToolbarQueryNameInput));
+
+function ToolbarQueryNameInput({ queryName, setQueryState, showValidation }) {
+  const error = showValidation && !queryName.length;
+
+  return (
+    <Input
+      error={error}
+      style={{ width: 260 }}
+      placeholder="Query name"
+      value={queryName}
+      onChange={e => setQueryState('name', e.target.value)}
+    />
+  );
+}
+
+export default ConnectedToolbarQueryNameInput;
diff --git a/client/src/queryEditor/toolbar/ToolbarRunButton.js b/client/src/queryEditor/toolbar/ToolbarRunButton.js
new file mode 100644
index 00000000..e07229bb
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarRunButton.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'unistore/react';
+import Button from '../../common/Button';
+import { runQuery } from '../../stores/queries';
+
+function mapStateToProps(state) {
+  return {
+    isRunning: state.isRunning
+  };
+}
+
+const ConnectedToolbarRunButton = connect(mapStateToProps, store => ({
+  runQuery: runQuery(store)
+}))(React.memo(ToolbarRunButton));
+
+function ToolbarRunButton({ isRunning, runQuery }) {
+  return (
+    <Button type="primary" onClick={() => runQuery()} disabled={isRunning}>
+      Run
+    </Button>
+  );
+}
+
+ToolbarRunButton.propTypes = {
+  isRunning: PropTypes.bool.isRequired,
+  runQuery: PropTypes.func.isRequired
+};
+
+export default ConnectedToolbarRunButton;
diff --git a/client/src/queryEditor/toolbar/ToolbarSaveButton.js b/client/src/queryEditor/toolbar/ToolbarSaveButton.js
new file mode 100644
index 00000000..c567f81c
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarSaveButton.js
@@ -0,0 +1,34 @@
+import UnsavedIcon from 'mdi-react/ContentSaveEditIcon';
+import SaveIcon from 'mdi-react/ContentSaveIcon';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'unistore/react';
+import IconButton from '../../common/IconButton';
+import { saveQuery } from '../../stores/queries';
+
+function mapStateToProps(state) {
+  return {
+    isSaving: state.isSaving,
+    unsavedChanges: state.unsavedChanges
+  };
+}
+
+const ConnectedToolbarSaveButton = connect(mapStateToProps, store => ({
+  saveQuery: saveQuery(store)
+}))(React.memo(ToolbarSaveButton));
+
+function ToolbarSaveButton({ isSaving, saveQuery, unsavedChanges }) {
+  return (
+    <IconButton tooltip="Save" onClick={() => saveQuery()} disabled={isSaving}>
+      {unsavedChanges ? <UnsavedIcon /> : <SaveIcon />}
+    </IconButton>
+  );
+}
+
+ToolbarSaveButton.propTypes = {
+  isSaving: PropTypes.bool.isRequired,
+  saveQuery: PropTypes.func.isRequired,
+  unsavedChanges: PropTypes.bool.isRequired
+};
+
+export default ConnectedToolbarSaveButton;
diff --git a/client/src/queryEditor/toolbar/ToolbarSpacer.js b/client/src/queryEditor/toolbar/ToolbarSpacer.js
new file mode 100644
index 00000000..7e086919
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarSpacer.js
@@ -0,0 +1,18 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const growSpacerStyle = { flexShrink: 0, flexGrow: 1, width: 8 };
+const spacerStyle = { flexShrink: 0, width: 8 };
+
+function ToolbarSpacer({ grow }) {
+  if (grow) {
+    return <div style={growSpacerStyle} />;
+  }
+  return <div style={spacerStyle} />;
+}
+
+ToolbarSpacer.propTypes = {
+  grow: PropTypes.bool
+};
+
+export default ToolbarSpacer;
diff --git a/client/src/queryEditor/toolbar/ToolbarTagsButton.js b/client/src/queryEditor/toolbar/ToolbarTagsButton.js
new file mode 100644
index 00000000..b0244826
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarTagsButton.js
@@ -0,0 +1,20 @@
+import TagsIcon from 'mdi-react/TagMultipleIcon';
+import React, { useState } from 'react';
+import IconButton from '../../common/IconButton';
+import QueryTagsModal from './QueryTagsModal';
+
+function ToolbarTagsButton() {
+  const [showTags, setShowTags] = useState(false);
+
+  return (
+    <div>
+      <IconButton tooltip="Tags" onClick={() => setShowTags(true)}>
+        <TagsIcon />
+      </IconButton>
+
+      <QueryTagsModal visible={showTags} onClose={() => setShowTags(false)} />
+    </div>
+  );
+}
+
+export default ToolbarTagsButton;
diff --git a/client/src/queryEditor/toolbar/ToolbarToggleSchemaButton.js b/client/src/queryEditor/toolbar/ToolbarToggleSchemaButton.js
new file mode 100644
index 00000000..29a62afc
--- /dev/null
+++ b/client/src/queryEditor/toolbar/ToolbarToggleSchemaButton.js
@@ -0,0 +1,23 @@
+import DatabaseIcon from 'mdi-react/DatabaseIcon';
+import React from 'react';
+import { connect } from 'unistore/react';
+import IconButton from '../../common/IconButton';
+import { toggleSchema } from '../../stores/schema';
+
+function mapStateToProps(state) {
+  return {};
+}
+
+const ConnectedToolbarToggleSchemaButton = connect(mapStateToProps, store => ({
+  toggleSchema
+}))(React.memo(ToolbarToggleSchemaButton));
+
+function ToolbarToggleSchemaButton({ toggleSchema }) {
+  return (
+    <IconButton tooltip="Toggle schema" onClick={toggleSchema}>
+      <DatabaseIcon />
+    </IconButton>
+  );
+}
+
+export default ConnectedToolbarToggleSchemaButton;
diff --git a/scripts/build.sh b/scripts/build.sh
index 3f205e2e..fe0bf407 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -3,10 +3,9 @@ SQLPAD_CLIENT_DIR=$(pwd)/client
 SQLPAD_SERVER_DIR=$(pwd)/server
 SCRIPTS_DIR=$(pwd)/scripts
 
-if [[ ! -d $SCRIPTS_DIR ]] || \
-   [[ ! -d $SQLPAD_CLIENT_DIR ]] || \
-   [[ ! -d $SQLPAD_SERVER_DIR ]]
-then
+if [ ! -d $SCRIPTS_DIR ] ||
+    [ ! -d $SQLPAD_CLIENT_DIR ] ||
+    [ ! -d $SQLPAD_SERVER_DIR ]; then
     echo This script must be executed from the sqlpad project directory
     exit 1
 fi
diff --git a/server/config.dev.ini b/server/config.dev.ini
index 89e37b73..ebcca7f1 100644
--- a/server/config.dev.ini
+++ b/server/config.dev.ini
@@ -1,4 +1,10 @@
-; INI config file that defines some connections for development purposes
+; INI config file for dev setup
+port = 3010
+baseUrl = "/sqlpad"
+debug = true
+dbPath = ../db
+appLogLevel = debug
+webLogLevel = debug
 
 [connections.devdbdriverid123]
 driver = mock
diff --git a/server/drivers/mock/index.js b/server/drivers/mock/index.js
index 15d13028..ab735b41 100644
--- a/server/drivers/mock/index.js
+++ b/server/drivers/mock/index.js
@@ -89,7 +89,8 @@ function cartesianify(rows, field) {
  * @param {object} connection
  */
 async function runQuery(query, connection) {
-  await wait(2000);
+  const ms = connection.hasOwnProperty('wait') ? connection.wait : 2000;
+  await wait(ms);
 
   // Connection here doesn't actually matter.
   // Someday this mock could get fancy and change output based on some connection value
@@ -315,6 +316,11 @@ const fields = [
     key: 'socksPassword',
     formType: 'TEXT',
     label: 'Password for socks proxy'
+  },
+  {
+    key: 'wait',
+    formType: 'TEXT',
+    label: 'Milliseconds to wait running query'
   }
 ];
 
diff --git a/server/lib/config/configItems.js b/server/lib/config/configItems.js
index 39299537..8a6787a0 100644
--- a/server/lib/config/configItems.js
+++ b/server/lib/config/configItems.js
@@ -218,6 +218,11 @@ const configItems = [
     key: 'webLogLevel',
     envVar: 'SQLPAD_WEB_LOG_LEVEL',
     default: 'info'
+  },
+  {
+    key: 'dbInMemory',
+    envVar: 'SQLPAD_DB_IN_MEMORY',
+    default: false
   }
 ];
 
diff --git a/server/lib/config/fromEnv.js b/server/lib/config/fromEnv.js
index f2f631f1..4454e6c4 100644
--- a/server/lib/config/fromEnv.js
+++ b/server/lib/config/fromEnv.js
@@ -5,7 +5,7 @@ const definitions = require('./configItems');
  * @param {object} env optional
  * @returns {object} configMap
  */
-module.exports = function getEnvConfig(env = process.env) {
+module.exports = function getEnvConfig(env) {
   return definitions
     .filter(definition => definition.hasOwnProperty('envVar'))
     .reduce((envMap, definition) => {
diff --git a/server/lib/config/fromFile.js b/server/lib/config/fromFile.js
index 0a32a9e0..69df6109 100644
--- a/server/lib/config/fromFile.js
+++ b/server/lib/config/fromFile.js
@@ -1,17 +1,13 @@
-const minimist = require('minimist');
 const fs = require('fs');
 const ini = require('ini');
 const configItems = require('./configItems');
 
-const argv = minimist(process.argv.slice(2));
-const configFilePathFromConfig = argv.config || process.env.SQLPAD_CONFIG;
-
 /**
  * Reads and parses config file.
  * This file may be either JSON or INI file.
  * @param {string} configFilePath
  */
-function fromFile(configFilePath = configFilePathFromConfig) {
+function fromFile(configFilePath) {
   let parsedFile = {};
   const warnings = [];
 
diff --git a/server/lib/config/index.js b/server/lib/config/index.js
index e43c670e..be060baf 100644
--- a/server/lib/config/index.js
+++ b/server/lib/config/index.js
@@ -1,59 +1,159 @@
-const minimist = require('minimist');
+const _ = require('lodash');
+const appLog = require('../appLog');
+const drivers = require('../../drivers');
 const fromDefault = require('./fromDefault');
 const fromEnv = require('./fromEnv');
 const fromCli = require('./fromCli');
 const fromFile = require('./fromFile');
 const getOldConfigWarning = require('./getOldConfigWarning');
 
-const argv = minimist(process.argv.slice(2));
+class Config {
+  constructor(argv, env) {
+    this.argv = argv;
 
-const defaultConfig = fromDefault();
-const envConfig = fromEnv();
-const [fileConfig, warnings] = fromFile();
-const cliConfig = fromCli(argv);
+    const configFilePathFromConfig = argv.config || env.SQLPAD_CONFIG;
 
-const all = { ...defaultConfig, ...envConfig, ...fileConfig, ...cliConfig };
+    const defaultConfig = fromDefault();
+    const envConfig = fromEnv(env);
+    const [fileConfig, warnings] = fromFile(configFilePathFromConfig);
+    const cliConfig = fromCli(argv);
 
-// Clean string boolean values
-Object.keys(all).forEach(key => {
-  const value = all[key];
-  if (typeof value === 'string') {
-    if (value.trim().toLowerCase() === 'true') {
-      all[key] = true;
-    } else if (value.trim().toLowerCase() === 'false') {
-      all[key] = false;
+    const all = { ...defaultConfig, ...envConfig, ...fileConfig, ...cliConfig };
+
+    // Clean string boolean values
+    Object.keys(all).forEach(key => {
+      const value = all[key];
+      if (typeof value === 'string') {
+        if (value.trim().toLowerCase() === 'true') {
+          all[key] = true;
+        } else if (value.trim().toLowerCase() === 'false') {
+          all[key] = false;
+        }
+      }
+    });
+
+    this.fileConfig = fileConfig;
+    this.warnings = warnings;
+    this.all = all;
+  }
+
+  get(key) {
+    const { connections, ...rest } = this.all;
+    if (!key) {
+      return rest;
+    }
+
+    if (!this.all.hasOwnProperty(key)) {
+      throw new Error(`config item ${key} not defined in configItems.js`);
+    }
+
+    return this.all[key];
+  }
+
+  getValidations() {
+    const errors = [];
+
+    // By default dbPath will exist as empty string, which is not valid
+    if (this.all.dbPath === '') {
+      errors.push(getOldConfigWarning());
     }
+
+    return {
+      errors,
+      warnings: [...this.warnings]
+    };
   }
-});
 
-exports.get = function get(key) {
-  if (!key) {
-    throw new Error('key must be provided');
+  smtpConfigured() {
+    return (
+      this.all.smtpHost &&
+      this.all.smtpUser &&
+      this.all.smtpFrom &&
+      this.all.smtpPort &&
+      this.all.publicUrl
+    );
   }
 
-  if (!all.hasOwnProperty(key)) {
-    throw new Error(`config item ${key} not defined in configItems.js`);
+  googleAuthConfigured() {
+    return (
+      this.all.publicUrl &&
+      this.all.googleClientId &&
+      this.all.googleClientSecret
+    );
   }
 
-  return all[key];
-};
+  /**
+   * Get connections from config.
+   * These are provided at runtime and not upserted
+   * This allows supporting cases where connections can be defined then later removed via config changes alone
+   *
+   * For environment variables:
+   * connection env vars must follow the format:
+   * SQLPAD_CONNECTIONS__<connectionId>__<connectionFieldName>
+   *
+   * <connectionId> can be any value to associate a grouping a fields to a connection instance
+   * If supplying a connection that was previously defined in the nedb database,
+   * this would map internally to connection._id object.
+   *
+   * <connectionFieldName> should be a field name identified in drivers.
+   *
+   * To define connections via envvars, `driver` field should be supplied.
+   * _id field is not required, as it is defined in second env var fragment.
+   *
+   * Example: SQLPAD_CONNECTIONS__ab123__sqlserverEncrypt=""
+   *
+   * From file, resulting parsed configuration from file is expected to follow format `connections.<id>.<fieldname>`
+   * {
+   *   connections: {
+   *     ab123: {
+   *       sqlserverEncrypt: true
+   *     }
+   *   }
+   * }
+   *
+   * @param {object} [env] - optional environment override for testing
+   * @returns {array<object>} arrayOfConnections
+   */
+  getConnections(env = process.env) {
+    // Create a map of connections from parsing environment variable
+    const connectionsMapFromEnv = Object.keys(env)
+      .filter(key => key.startsWith('SQLPAD_CONNECTIONS__'))
+      .reduce((connectionsMap, envVar) => {
+        // eslint-disable-next-line no-unused-vars
+        const [prefix, id, field] = envVar.split('__');
+        if (!connectionsMap[id]) {
+          connectionsMap[id] = {};
+        }
+        connectionsMap[id][field] = env[envVar];
+        return connectionsMap;
+      }, {});
 
-exports.getValidations = () => {
-  const errors = [];
+    // Get copy of connections from config file
+    const { connections } = _.cloneDeep(this.fileConfig);
 
-  // By default dbPath will exist as empty string, which is not valid
-  if (all.dbPath === '') {
-    errors.push(getOldConfigWarning());
-  }
+    // connections key from file matches format that is constructed from env
+    // merge the 2 together then create an array out of them
+    const connectionsMap = { ...connectionsMapFromEnv, ...connections };
 
-  return {
-    errors,
-    warnings: [...warnings]
-  };
-};
+    const connectionsFromConfig = [];
+    Object.keys(connectionsMap).forEach(id => {
+      try {
+        let connection = connectionsMap[id];
+        connection._id = id;
+        connection = drivers.validateConnection(connection);
+        connection.editable = false;
+        connectionsFromConfig.push(connection);
+      } catch (error) {
+        appLog.error(
+          error,
+          'Environment connection configuration failed for %s',
+          id
+        );
+      }
+    });
 
-exports.smtpConfigured = () =>
-  all.smtpHost && all.smtpUser && all.smtpFrom && all.smtpPort && all.publicUrl;
+    return connectionsFromConfig;
+  }
+}
 
-exports.googleAuthConfigured = () =>
-  all.publicUrl && all.googleClientId && all.googleClientSecret;
+module.exports = Config;
diff --git a/server/lib/connectionsFromConfig.js b/server/lib/connectionsFromConfig.js
deleted file mode 100644
index cd38751d..00000000
--- a/server/lib/connectionsFromConfig.js
+++ /dev/null
@@ -1,79 +0,0 @@
-const _ = require('lodash');
-const appLog = require('./appLog');
-const drivers = require('../drivers');
-const getConfigFromFile = require('./config/fromFile.js');
-const [configFromFile] = getConfigFromFile() || {};
-
-/**
- * Get connections from config.
- *
- * For environment variables:
- * connection env vars must follow the format:
- * SQLPAD_CONNECTIONS__<connectionId>__<connectionFieldName>
- *
- * <connectionId> can be any value to associate a grouping a fields to a connection instance
- * If supplying a connection that was previously defined in the nedb database,
- * this would map internally to connection._id object.
- *
- * <connectionFieldName> should be a field name identified in drivers.
- *
- * To define connections via envvars, `driver` field should be supplied.
- * _id field is not required, as it is defined in second env var fragment.
- *
- * Example: SQLPAD_CONNECTIONS__ab123__sqlserverEncrypt=""
- *
- * From file, resulting parsed configuration from file is expected to follow format `connections.<id>.<fieldname>`
- * {
- *   connections: {
- *     ab123: {
- *       sqlserverEncrypt: true
- *     }
- *   }
- * }
- *
- * @param {object} env
- * @returns {array<object>} arrayOfConnections
- */
-function getConnectionsFromConfig(env = process.env) {
-  // Create a map of connections from parsing environment variable
-  const connectionsMapFromEnv = Object.keys(env)
-    .filter(key => key.startsWith('SQLPAD_CONNECTIONS__'))
-    .reduce((connectionsMap, envVar) => {
-      // eslint-disable-next-line no-unused-vars
-      const [prefix, id, field] = envVar.split('__');
-      if (!connectionsMap[id]) {
-        connectionsMap[id] = {};
-      }
-      connectionsMap[id][field] = env[envVar];
-      return connectionsMap;
-    }, {});
-
-  // Get copy of connections from config file
-  const { connections } = _.cloneDeep(configFromFile);
-
-  // connections key from file matches format that is constructed from env
-  // merge the 2 together then create an array out of them
-  const connectionsMap = { ...connectionsMapFromEnv, ...connections };
-
-  const connectionsFromConfig = [];
-  Object.keys(connectionsMap).forEach(id => {
-    try {
-      let connection = connectionsMap[id];
-      connection._id = id;
-      connection = drivers.validateConnection(connection);
-      connection.editable = false;
-      connectionsFromConfig.push(connection);
-    } catch (error) {
-      appLog.error(
-        error,
-        'Environment connection configuration failed for %s',
-        id
-      );
-    }
-  });
-  return connectionsFromConfig;
-}
-
-module.exports = {
-  getConnectionsFromConfig
-};
diff --git a/server/lib/db.js b/server/lib/db.js
index d014c015..1c65255b 100644
--- a/server/lib/db.js
+++ b/server/lib/db.js
@@ -40,23 +40,26 @@ async function initNedb(config) {
   const admin = config.get('admin');
   const adminPassword = config.get('adminPassword');
   const dbPath = config.get('dbPath');
+  const dbInMemory = config.get('dbInMemory');
   const allowConnectionAccessToEveryone = config.get(
     'allowConnectionAccessToEveryone'
   );
 
   mkdirp.sync(path.join(dbPath, '/cache'));
 
+  function getDatastore(dbName) {
+    return dbInMemory
+      ? datastore()
+      : datastore({ filename: path.join(dbPath, dbName) });
+  }
+
   const nedb = {
-    users: datastore({ filename: path.join(dbPath, 'users.db') }),
-    connections: datastore({
-      filename: path.join(dbPath, 'connections.db')
-    }),
-    connectionAccesses: datastore({
-      filename: path.join(dbPath, 'connectionaccesses.db')
-    }),
-    queries: datastore({ filename: path.join(dbPath, 'queries.db') }),
-    queryHistory: datastore({ filename: path.join(dbPath, 'queryhistory.db') }),
-    cache: datastore({ filename: path.join(dbPath, 'cache.db') }),
+    users: getDatastore('users.db'),
+    connections: getDatastore('connections.db'),
+    connectionAccesses: getDatastore('connectionaccesses.db'),
+    queries: getDatastore('queries.db'),
+    queryHistory: getDatastore('queryhistory.db'),
+    cache: getDatastore('cache.db'),
     instances: [
       'users',
       'connections',
diff --git a/server/models/connectionAccesses.js b/server/models/connectionAccesses.js
index 44efd360..487b8b6a 100644
--- a/server/models/connectionAccesses.js
+++ b/server/models/connectionAccesses.js
@@ -18,6 +18,10 @@ const schema = Joi.object({
 });
 
 class ConnectionAccesses {
+  /**
+   * @param {*} nedb
+   * @param {import('../lib/config')} config
+   */
   constructor(nedb, config) {
     this.nedb = nedb;
     this.config = config;
diff --git a/server/models/connections.js b/server/models/connections.js
index 906ca8ae..6b20d393 100644
--- a/server/models/connections.js
+++ b/server/models/connections.js
@@ -1,10 +1,12 @@
 const _ = require('lodash');
 const drivers = require('../drivers');
 const makeCipher = require('../lib/makeCipher');
-// TODO: during app init upsert these into db?
-const { getConnectionsFromConfig } = require('../lib/connectionsFromConfig');
 
 class Connections {
+  /**
+   * @param {*} nedb
+   * @param {import('../lib/config')} config
+   */
   constructor(nedb, config) {
     this.nedb = nedb;
     this.config = config;
@@ -30,7 +32,9 @@ class Connections {
       return this.decipherConnection(conn);
     });
 
-    const allConnections = connectionsFromDb.concat(getConnectionsFromConfig());
+    const allConnections = connectionsFromDb.concat(
+      this.config.getConnections()
+    );
     return _.sortBy(allConnections, c => c.name.toLowerCase());
   }
 
@@ -42,9 +46,9 @@ class Connections {
     }
 
     // If connection was not found in db try env
-    const connectionFromEnv = getConnectionsFromConfig().find(
-      connection => connection._id === id
-    );
+    const connectionFromEnv = this.config
+      .getConnections()
+      .find(connection => connection._id === id);
 
     return connectionFromEnv;
   }
diff --git a/server/models/queries.js b/server/models/queries.js
index 1a6823d6..3e7f41a6 100644
--- a/server/models/queries.js
+++ b/server/models/queries.js
@@ -44,6 +44,10 @@ const schema = Joi.object({
 });
 
 class Queries {
+  /**
+   * @param {*} nedb
+   * @param {import('../lib/config')} config
+   */
   constructor(nedb, config) {
     this.nedb = nedb;
     this.config = config;
diff --git a/server/models/queryHistory.js b/server/models/queryHistory.js
index 61bb8954..b7b68ba1 100644
--- a/server/models/queryHistory.js
+++ b/server/models/queryHistory.js
@@ -18,6 +18,10 @@ const schema = Joi.object({
 });
 
 class QueryHistory {
+  /**
+   * @param {*} nedb
+   * @param {import('../lib/config')} config
+   */
   constructor(nedb, config) {
     this.nedb = nedb;
     this.config = config;
diff --git a/server/models/resultCache.js b/server/models/resultCache.js
index 40b621d3..4181462c 100644
--- a/server/models/resultCache.js
+++ b/server/models/resultCache.js
@@ -18,6 +18,10 @@ BigInt.prototype.toJSON = function() {
 };
 
 class ResultCache {
+  /**
+   * @param {*} nedb
+   * @param {import('../lib/config')} config
+   */
   constructor(nedb, config) {
     this.nedb = nedb;
     this.config = config;
diff --git a/server/models/schemaInfo.js b/server/models/schemaInfo.js
index 13f96daa..3d1c0a95 100644
--- a/server/models/schemaInfo.js
+++ b/server/models/schemaInfo.js
@@ -3,6 +3,10 @@ function getCacheKey(connectionId) {
 }
 
 class SchemaInfo {
+  /**
+   * @param {*} nedb
+   * @param {import('../lib/config')} config
+   */
   constructor(nedb, config) {
     this.nedb = nedb;
     this.config = config;
diff --git a/server/models/users.js b/server/models/users.js
index 93d33db8..ae5f9cc9 100644
--- a/server/models/users.js
+++ b/server/models/users.js
@@ -27,6 +27,10 @@ const schema = Joi.object({
 });
 
 class Users {
+  /**
+   * @param {*} nedb
+   * @param {import('../lib/config')} config
+   */
   constructor(nedb, config) {
     this.nedb = nedb;
     this.config = config;
diff --git a/server/package.json b/server/package.json
index 74702969..59faf83b 100644
--- a/server/package.json
+++ b/server/package.json
@@ -28,8 +28,8 @@
   },
   "scripts": {
     "prepublishOnly": "cd .. && ./scripts/build.sh",
-    "start": "node-dev server.js --dbPath ../db --port 3010 --debug --baseUrl /sqlpad --config './config.dev.ini' | pino-pretty",
-    "test": "rimraf ../dbtest && SQLPAD_DB_PATH='../dbtest' SQLPAD_DEBUG='true' SQLPAD_APP_LOG_LEVEL='silent' SQLPAD_WEB_LOG_LEVEL='silent' mocha test --timeout 10000 --recursive --exit",
+    "start": "node-dev server.js --config './config.dev.ini' | pino-pretty",
+    "test": "rimraf ../dbtest && mocha test --timeout 10000 --recursive --exit",
     "fixlint": "eslint --fix '**/*.js'",
     "lint": "eslint '**/*.js'"
   },
diff --git a/server/server.js b/server/server.js
index a5b05957..ecf3faaa 100755
--- a/server/server.js
+++ b/server/server.js
@@ -3,17 +3,22 @@
 const fs = require('fs');
 const http = require('http');
 const https = require('https');
+const minimist = require('minimist');
 const detectPort = require('detect-port');
 const makeApp = require('./app');
+const appLog = require('./lib/appLog');
+const Config = require('./lib/config');
+const { makeDb, getDb } = require('./lib/db');
 
 // Parse command line flags to see if anything special needs to happen
 require('./lib/cli-flow.js');
 
-const appLog = require('./lib/appLog');
-const config = require('./lib/config');
-const { makeDb, getDb } = require('./lib/db');
+const argv = minimist(process.argv.slice(2));
+const config = new Config(argv, process.env);
 
 appLog.setLevel(config.get('appLogLevel'));
+appLog.debug(config.get(), 'Final config values');
+appLog.debug(config.getConnections(), 'Connections from config');
 
 makeDb(config);
 
@@ -79,7 +84,7 @@ async function startServer(models) {
   if (keyPath && certPath) {
     // https only
     const _port = await detectPortOrSystemd(httpsPort);
-    if (!isFdObject(_port) && httpsPort !== _port) {
+    if (!isFdObject(_port) && parseInt(httpsPort, 10) !== parseInt(_port, 10)) {
       appLog.info(
         'Port %d already occupied. Using port %d instead.',
         httpsPort,
@@ -107,7 +112,7 @@ async function startServer(models) {
   } else {
     // http only
     const _port = await detectPortOrSystemd(port);
-    if (!isFdObject(_port) && port !== _port) {
+    if (!isFdObject(_port) && parseInt(port, 10) !== parseInt(_port, 10)) {
       appLog.info(
         'Port %d already occupied. Using port %d instead.',
         port,


Test patch:
diff --git a/server/drivers/mock/test.js b/server/drivers/mock/test.js
index a80f2f14..1fe6373f 100644
--- a/server/drivers/mock/test.js
+++ b/server/drivers/mock/test.js
@@ -8,7 +8,8 @@ const connection = {
   database: 'sqlpad',
   username: 'sqlpad',
   password: 'sqlpad',
-  maxRows: 100
+  maxRows: 100,
+  wait: 0
 };
 
 describe('drivers/mock', function() {
diff --git a/server/test/api/app.js b/server/test/api/app.js
index aca838fd..f8fdbb44 100644
--- a/server/test/api/app.js
+++ b/server/test/api/app.js
@@ -1,5 +1,11 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
+
+function expectKeys(data, expectedKeys) {
+  Object.keys(data).forEach(key =>
+    assert(expectedKeys.includes(key), `expected key ${key}`)
+  );
+}
 
 const expectedKeys = [
   'adminRegistrationOpen',
@@ -20,10 +26,16 @@ const expectedConfigKeys = [
 ];
 
 describe('api/app', function() {
+  const utils = new TestUtils();
+
+  before(function() {
+    return utils.init(true);
+  });
+
   it('returns expected values', async function() {
     const body = await utils.get(null, '/api/app');
-    utils.expectKeys(body, expectedKeys);
-    utils.expectKeys(body.config, expectedConfigKeys);
+    expectKeys(body, expectedKeys);
+    expectKeys(body.config, expectedConfigKeys);
     assert.equal(
       Object.keys(body.config).length,
       expectedConfigKeys.length,
@@ -33,6 +45,6 @@ describe('api/app', function() {
 
   it('handles unknown baseUrl', async function() {
     const body = await utils.get(null, '/literally/any/path/api/app');
-    utils.expectKeys(body, expectedKeys);
+    expectKeys(body, expectedKeys);
   });
 });
diff --git a/server/test/api/connection-accesses.js b/server/test/api/connection-accesses.js
index e134b0aa..0882a1c6 100644
--- a/server/test/api/connection-accesses.js
+++ b/server/test/api/connection-accesses.js
@@ -1,8 +1,9 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 const consts = require('../../lib/consts');
 
 describe('api/connection-accesses', function() {
+  const utils = new TestUtils();
   let admin2;
   let user1;
   let user2;
@@ -12,7 +13,7 @@ describe('api/connection-accesses', function() {
   let connectionAccess1;
 
   before(async function() {
-    await utils.resetWithUser();
+    await utils.init(true);
 
     let connBody = await utils.post('admin', '/api/connections', {
       name: 'test connection 1',
@@ -20,7 +21,8 @@ describe('api/connection-accesses', function() {
       host: 'localhost',
       database: 'sqlpad',
       username: 'sqlpad',
-      password: 'sqlpad'
+      password: 'sqlpad',
+      wait: 0
     });
     connection1 = connBody.connection;
 
@@ -30,7 +32,8 @@ describe('api/connection-accesses', function() {
       host: 'localhost',
       database: 'sqlpad',
       username: 'sqlpad',
-      password: 'sqlpad'
+      password: 'sqlpad',
+      wait: 0
     });
     connection2 = connBody.connection;
 
diff --git a/server/test/api/connections.js b/server/test/api/connections.js
index 50ca9add..5c886bc1 100644
--- a/server/test/api/connections.js
+++ b/server/test/api/connections.js
@@ -1,11 +1,12 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 
 describe('api/connections', function() {
+  const utils = new TestUtils();
   let connection;
 
   before(function() {
-    return utils.resetWithUser();
+    return utils.init(true);
   });
 
   it('Returns empty array', async function() {
diff --git a/server/test/api/drivers.js b/server/test/api/drivers.js
index 8adef0b9..9b45a60c 100644
--- a/server/test/api/drivers.js
+++ b/server/test/api/drivers.js
@@ -1,9 +1,11 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 
 describe('api/drivers', function() {
+  const utils = new TestUtils();
+
   before(function() {
-    return utils.resetWithUser();
+    return utils.init(true);
   });
 
   it('gets drivers', async function() {
diff --git a/server/test/api/format-sql.js b/server/test/api/format-sql.js
index b86130c8..b1074b66 100644
--- a/server/test/api/format-sql.js
+++ b/server/test/api/format-sql.js
@@ -1,9 +1,11 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 
 describe('api/format-sql', function() {
+  const utils = new TestUtils();
+
   before(function() {
-    return utils.resetWithUser();
+    return utils.init(true);
   });
 
   it('format sql query', async function() {
diff --git a/server/test/api/password-reset.js b/server/test/api/password-reset.js
index f25471bf..73e4fc50 100644
--- a/server/test/api/password-reset.js
+++ b/server/test/api/password-reset.js
@@ -1,20 +1,20 @@
 const assert = require('assert');
-const utils = require('../utils');
 const uuid = require('uuid');
-const { getDb } = require('../../lib/db');
-
-async function setReset() {
-  const { models } = await getDb();
-  const user = await models.users.findOneByEmail('admin@test.com');
-  const passwordResetId = uuid.v4();
-  user.passwordResetId = passwordResetId;
-  await models.users.save(user);
-  return passwordResetId;
-}
+const TestUtils = require('../utils');
 
 describe('api/password-reset', function() {
+  const utils = new TestUtils();
+
+  async function setReset() {
+    const user = await utils.models.users.findOneByEmail('admin@test.com');
+    const passwordResetId = uuid.v4();
+    user.passwordResetId = passwordResetId;
+    await utils.models.users.save(user);
+    return passwordResetId;
+  }
+
   before(function() {
-    return utils.resetWithUser();
+    return utils.init(true);
   });
 
   it('Allows resetting password', async function() {
diff --git a/server/test/api/queries.js b/server/test/api/queries.js
index 44d12387..8c72ec48 100644
--- a/server/test/api/queries.js
+++ b/server/test/api/queries.js
@@ -1,11 +1,12 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 
 describe('api/queries', function() {
+  const utils = new TestUtils();
   let query;
 
   before(function() {
-    return utils.resetWithUser();
+    return utils.init(true);
   });
 
   it('Returns empty array', async function() {
diff --git a/server/test/api/query-history.js b/server/test/api/query-history.js
index 0da638e6..7f07b373 100644
--- a/server/test/api/query-history.js
+++ b/server/test/api/query-history.js
@@ -1,5 +1,5 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 const urlFilterToNeDbFilter = require('../../lib/urlFilterToNeDbFilter');
 
 const queryText1 = `
@@ -19,11 +19,12 @@ const queryText2 = `
 `;
 
 describe('api/query-history', function() {
+  const utils = new TestUtils();
   let connection;
   let query1;
 
   before(async function() {
-    await utils.resetWithUser();
+    await utils.init(true);
 
     const connBody = await utils.post('admin', '/api/connections', {
       name: 'test postgres',
@@ -31,7 +32,8 @@ describe('api/query-history', function() {
       host: 'localhost',
       database: 'sqlpad',
       username: 'sqlpad',
-      password: 'sqlpad'
+      password: 'sqlpad',
+      wait: 0
     });
     connection = connBody.connection;
 
diff --git a/server/test/api/query-result.js b/server/test/api/query-result.js
index 69121c84..e56f79f4 100644
--- a/server/test/api/query-result.js
+++ b/server/test/api/query-result.js
@@ -1,5 +1,5 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 
 const queryText = `
   -- dimensions = department 10, orderdate 10
@@ -25,11 +25,12 @@ function validateQueryResult(queryResult) {
 }
 
 describe('api/query-result', function() {
+  const utils = new TestUtils();
   let query;
   let connection;
 
   before(async function() {
-    await utils.resetWithUser();
+    await utils.init(true);
 
     const connBody = await utils.post('admin', '/api/connections', {
       name: 'test postgres',
@@ -37,7 +38,8 @@ describe('api/query-result', function() {
       host: 'localhost',
       database: 'sqlpad',
       username: 'sqlpad',
-      password: 'sqlpad'
+      password: 'sqlpad',
+      wait: 0
     });
     connection = connBody.connection;
 
diff --git a/server/test/api/schema-info.js b/server/test/api/schema-info.js
index 4a20065b..13cd8e4d 100644
--- a/server/test/api/schema-info.js
+++ b/server/test/api/schema-info.js
@@ -1,18 +1,20 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 
 describe('api/schema-info', function() {
+  const utils = new TestUtils();
   let connection;
 
   before(async function() {
-    await utils.resetWithUser();
+    await utils.init(true);
     const body = await utils.post('admin', '/api/connections', {
       driver: 'mock',
       name: 'sqlpad',
       host: 'localhost',
       database: 'sqlpad',
       username: 'sqlpad',
-      password: 'sqlpad'
+      password: 'sqlpad',
+      wait: 0
     });
     assert(!body.error, 'no error');
     connection = body.connection;
diff --git a/server/test/api/signup-signin.js b/server/test/api/signup-signin.js
index b0ac890a..22bfaed1 100644
--- a/server/test/api/signup-signin.js
+++ b/server/test/api/signup-signin.js
@@ -1,9 +1,11 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtil = require('../utils');
 
 describe('api/signup', function() {
+  const utils = new TestUtil();
+
   before(function() {
-    return utils.reset();
+    return utils.init();
   });
 
   it('allows new user signup', async function() {
@@ -50,9 +52,12 @@ describe('api/signup', function() {
 });
 
 describe('api/signin', function() {
+  const utils = new TestUtil();
+
   before(function() {
-    return utils.resetWithUser();
+    return utils.init(true);
   });
+
   it('signs in user', async function() {
     const body = await utils.post(null, '/api/signin', {
       password: 'admin',
diff --git a/server/test/api/tags.js b/server/test/api/tags.js
index 589717ea..7663127c 100644
--- a/server/test/api/tags.js
+++ b/server/test/api/tags.js
@@ -1,9 +1,11 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 
 describe('api/tags', function() {
+  const utils = new TestUtils();
+
   before(function() {
-    return utils.resetWithUser();
+    return utils.init(true);
   });
 
   it('Returns empty array', async function() {
diff --git a/server/test/api/test-connection.js b/server/test/api/test-connection.js
index dbabb80e..9fd0d534 100644
--- a/server/test/api/test-connection.js
+++ b/server/test/api/test-connection.js
@@ -1,9 +1,11 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 
 describe('api/test-connection', function() {
+  const utils = new TestUtils();
+
   before(function() {
-    return utils.resetWithUser();
+    return utils.init(true);
   });
 
   it('tests connection', async function() {
@@ -13,7 +15,8 @@ describe('api/test-connection', function() {
       host: 'localhost',
       database: 'sqlpad',
       username: 'sqlpad',
-      password: 'sqlpad'
+      password: 'sqlpad',
+      wait: 0
     });
     assert(!body.error, 'Expect no error');
   });
diff --git a/server/test/api/users.js b/server/test/api/users.js
index 650a3427..f43d894e 100644
--- a/server/test/api/users.js
+++ b/server/test/api/users.js
@@ -1,11 +1,12 @@
 const assert = require('assert');
-const utils = require('../utils');
+const TestUtils = require('../utils');
 
 describe('api/users', function() {
+  const utils = new TestUtils();
   let user;
 
   before(function() {
-    return utils.resetWithUser();
+    return utils.init(true);
   });
 
   it('Returns initial array', async function() {
diff --git a/server/test/lib/config.js b/server/test/lib/config.js
index 5df192c1..8e244cdc 100644
--- a/server/test/lib/config.js
+++ b/server/test/lib/config.js
@@ -1,6 +1,5 @@
 const assert = require('assert');
-const config = require('../../lib/config');
-
+const TestUtils = require('../utils');
 const fromDefault = require('../../lib/config/fromDefault');
 const fromEnv = require('../../lib/config/fromEnv');
 const fromCli = require('../../lib/config/fromCli');
@@ -30,10 +29,16 @@ describe('config', function() {
 });
 
 describe('lib/config', function() {
+  const utils = new TestUtils();
+
+  before(function() {
+    return utils.init(true);
+  });
+
   it('.get() should get a value provided by default', async function() {
-    assert.equal(config.get('httpsPort'), 443, 'httpsPort=443');
+    assert.equal(utils.config.get('httpsPort'), 443, 'httpsPort=443');
   });
   it('.get() should only accept key in config items', async function() {
-    assert.throws(() => config.get('non-existent-key'), Error);
+    assert.throws(() => utils.config.get('non-existent-key'), Error);
   });
 });
diff --git a/server/test/lib/email.js b/server/test/lib/email.js
index 504feb09..b46c7bc7 100644
--- a/server/test/lib/email.js
+++ b/server/test/lib/email.js
@@ -1,9 +1,11 @@
 const makeEmail = require('../../lib/email.js');
-const config = require('../../lib/config');
+const TestUtils = require('../utils');
 
 describe('lib/email.js', async function() {
-  if (config.smtpConfigured() && process.env.SQLPAD_TEST_EMAIL) {
-    const email = makeEmail(config);
+  const utils = new TestUtils();
+
+  if (utils.config.smtpConfigured() && process.env.SQLPAD_TEST_EMAIL) {
+    const email = makeEmail(utils.config);
 
     it('should send invites', function() {
       return email.sendInvite(process.env.SQLPAD_TEST_EMAIL);
diff --git a/server/test/models/connections.js b/server/test/models/connections.js
index a7edc11e..957143b7 100644
--- a/server/test/models/connections.js
+++ b/server/test/models/connections.js
@@ -1,22 +1,20 @@
 const assert = require('assert');
-const { getDb } = require('../../lib/db');
-const { getConnectionsFromConfig } = require('../../lib/connectionsFromConfig');
+const TestUtils = require('../utils');
 
-describe('getConnectionsFromConfig', function() {
-  let models;
+describe('config.getConnections', function() {
+  const utils = new TestUtils();
 
-  before(async function() {
-    const db = await getDb();
-    models = db.models;
+  before(function() {
+    return utils.init(true);
   });
 
   it('handles empty object', function() {
-    const cs = getConnectionsFromConfig({});
+    const cs = utils.config.getConnections({});
     assert(Array.isArray(cs));
   });
 
   it('skips partial connections', function() {
-    const cs = getConnectionsFromConfig({
+    const cs = utils.config.getConnections({
       SQLPAD_CONNECTIONS__abc__driver: 'postgres'
     });
     assert(Array.isArray(cs));
@@ -24,7 +22,7 @@ describe('getConnectionsFromConfig', function() {
   });
 
   it('parses connection properly', function() {
-    const cs = getConnectionsFromConfig({
+    const cs = utils.config.getConnections({
       SQLPAD_CONNECTIONS__abc__driver: 'postgres',
       SQLPAD_CONNECTIONS__abc__name: 'env-postgres',
       SQLPAD_CONNECTIONS__abc__host: 'localhost',
@@ -50,7 +48,7 @@ describe('getConnectionsFromConfig', function() {
     process.env.SQLPAD_CONNECTIONS__abc__port = '5432';
     process.env.SQLPAD_CONNECTIONS__abc__postgresSsl = 'true';
 
-    const allConnections = await models.connections.findAll();
+    const allConnections = await utils.models.connections.findAll();
     const connection = allConnections.find(c => c._id === 'abc');
     assert.strictEqual(connection.name, 'env-postgres', 'connection.name');
     assert.strictEqual(connection.editable, false, 'connection.editable');
@@ -69,7 +67,7 @@ describe('getConnectionsFromConfig', function() {
     process.env.SQLPAD_CONNECTIONS__abc__port = '5432';
     process.env.SQLPAD_CONNECTIONS__abc__postgresSsl = 'true';
 
-    const connection = await models.connections.findOneById('abc');
+    const connection = await utils.models.connections.findOneById('abc');
     assert.strictEqual(connection.name, 'env-postgres', 'connection.name');
     assert.strictEqual(connection.editable, false, 'connection.editable');
 
diff --git a/server/test/utils.js b/server/test/utils.js
index 800026c4..69b53095 100644
--- a/server/test/utils.js
+++ b/server/test/utils.js
@@ -1,14 +1,11 @@
 const assert = require('assert');
+const uuid = require('uuid');
 const request = require('supertest');
-const consts = require('../lib/consts');
-const config = require('../lib/config');
+const Config = require('../lib/config');
 const appLog = require('../lib/appLog');
 const { makeDb, getDb } = require('../lib/db');
 const makeApp = require('../app');
 
-makeDb(config);
-let app;
-
 const users = {
   admin: {
     email: 'admin@test.com',
@@ -22,40 +19,79 @@ const users = {
   }
 };
 
-function expectKeys(data, expectedKeys) {
-  Object.keys(data).forEach(key =>
-    assert(expectedKeys.includes(key), `expected key ${key}`)
-  );
-}
-
-async function reset() {
-  const { nedb } = await getDb();
-  return Promise.all([
-    nedb.users.remove({}, { multi: true }),
-    nedb.queries.remove({}, { multi: true }),
-    nedb.queryHistory.remove({}, { multi: true }),
-    nedb.connections.remove({}, { multi: true }),
-    nedb.connectionAccesses.remove(
+class TestUtils {
+  constructor(args = {}, env = {}) {
+    const config = new Config(
       {
-        $not: {
-          $and: [
-            { connectionId: consts.EVERY_CONNECTION_ID },
-            { userId: consts.EVERYONE_ID }
-          ]
-        }
+        debug: true,
+        // Despite being in-memory, still need a file path for cache and session files
+        // Eventually these will be moved to sqlite and we can be fully-in-memory
+        dbPath: '../dbtest',
+        dbInMemory: true,
+        ...args
       },
-      { multi: true }
-    )
-  ]);
-}
+      {
+        SQLPAD_APP_LOG_LEVEL: 'silent',
+        SQLPAD_WEB_LOG_LEVEL: 'silent',
+        ...env
+      }
+    );
+
+    appLog.setLevel(config.get('appLogLevel'));
+
+    this.config = config;
+    this.appLog = appLog;
+    this.instanceAlias = uuid.v1();
+
+    makeDb(config, this.instanceAlias);
+    this.app = undefined;
+  }
 
-async function resetWithUser() {
-  await reset();
-  const { models } = await getDb();
-  const saves = Object.keys(users).map(key => {
-    return models.users.save(users[key]);
-  });
-  return Promise.all(saves);
+  async init(withUsers) {
+    const { models } = await getDb(this.instanceAlias);
+
+    this.models = models;
+    this.app = makeApp(this.config, models);
+
+    assert.throws(() => {
+      makeDb(this.config, this.instanceAlias);
+    }, 'ensure nedb can be made once');
+
+    if (withUsers) {
+      const saves = Object.keys(users).map(key => {
+        return models.users.save(users[key]);
+      });
+      await Promise.all(saves);
+    }
+  }
+
+  async del(role, url, statusCode = 200) {
+    let req = request(this.app).delete(url);
+    req = addAuth(req, role);
+    const response = await req.expect(statusCode);
+    return response.body;
+  }
+
+  async get(role, url, statusCode = 200) {
+    let req = request(this.app).get(url);
+    req = addAuth(req, role);
+    const response = await req.expect(statusCode);
+    return response.body;
+  }
+
+  async post(role, url, body, statusCode = 200) {
+    let req = request(this.app).post(url);
+    req = addAuth(req, role);
+    const response = await req.send(body).expect(statusCode);
+    return response.body;
+  }
+
+  async put(role, url, body, statusCode = 200) {
+    let req = request(this.app).put(url);
+    req = addAuth(req, role);
+    const response = await req.send(body).expect(statusCode);
+    return response.body;
+  }
 }
 
 function addAuth(req, role) {
@@ -67,52 +103,4 @@ function addAuth(req, role) {
   return req;
 }
 
-async function del(role, url, statusCode = 200) {
-  let req = request(app).delete(url);
-  req = addAuth(req, role);
-  const response = await req.expect(statusCode);
-  return response.body;
-}
-
-async function get(role, url, statusCode = 200) {
-  let req = request(app).get(url);
-  req = addAuth(req, role);
-  const response = await req.expect(statusCode);
-  return response.body;
-}
-
-async function post(role, url, body, statusCode = 200) {
-  let req = request(app).post(url);
-  req = addAuth(req, role);
-  const response = await req.send(body).expect(statusCode);
-  return response.body;
-}
-
-async function put(role, url, body, statusCode = 200) {
-  let req = request(app).put(url);
-  req = addAuth(req, role);
-  const response = await req.send(body).expect(statusCode);
-  return response.body;
-}
-
-before(async function() {
-  const { models } = await getDb();
-  appLog.setLevel(config.get('appLogLevel'));
-  app = makeApp(config, models);
-
-  assert.throws(() => {
-    makeDb(config);
-  }, 'ensure nedb can be made once');
-
-  return resetWithUser();
-});
-
-module.exports = {
-  del,
-  expectKeys,
-  get,
-  post,
-  put,
-  reset,
-  resetWithUser
-};
+module.exports = TestUtils;