diff --git a/.github/workflows/release-master.yml b/.github/workflows/release-master.yml
index 9ab8530341..df25182cd6 100644
--- a/.github/workflows/release-master.yml
+++ b/.github/workflows/release-master.yml
@@ -36,6 +36,7 @@ jobs:
       - uses: actions/setup-node@v1
         with:
           node-version: 18.x
+          cache: yarn
 
       - run: yarn install --frozen-lockfile
       - name: Update versions
@@ -63,14 +64,64 @@ jobs:
           echo "Using tag $version"
           echo "version=$version" >> "$GITHUB_OUTPUT"
 
-      - name: Build/release Docker images
+      - name: Setup Docker Buildx
+        id: buildx
+        uses: docker/setup-buildx-action@v1
+
+      - name: Docker login
         run: |
           docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
-          yarn build:docker
         env:
           DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
           DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
-          BUDIBASE_RELEASE_VERSION: ${{ steps.currenttag.outputs.version }}
+
+      - name: Build worker docker
+        uses: docker/build-push-action@v5
+        with:
+          context: .
+          push: true
+          platforms: linux/amd64,linux/arm64
+          build-args: |
+            BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }}
+          tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
+          file: ./packages/worker/Dockerfile.v2
+          cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest
+          cache-to: type=inline
+        env:
+          IMAGE_NAME: budibase/worker
+          IMAGE_TAG: ${{ steps.currenttag.outputs.version }}
+          BUDIBASE_VERSION: ${{ steps.currenttag.outputs.version }}
+
+      - name: Build server docker
+        uses: docker/build-push-action@v5
+        with:
+          context: .
+          push: true
+          platforms: linux/amd64,linux/arm64
+          build-args: |
+            BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }}
+          tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
+          file: ./packages/server/Dockerfile.v2
+          cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest
+          cache-to: type=inline
+        env:
+          IMAGE_NAME: budibase/apps
+          IMAGE_TAG: ${{ steps.currenttag.outputs.version }}
+          BUDIBASE_VERSION: ${{ steps.currenttag.outputs.version }}
+
+      - name: Build proxy docker
+        uses: docker/build-push-action@v5
+        with:
+          context: ./hosting/proxy
+          push: true
+          platforms: linux/amd64,linux/arm64
+          tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
+          file: ./hosting/proxy/Dockerfile
+          cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest
+          cache-to: type=inline
+        env:
+          IMAGE_NAME: budibase/proxy
+          IMAGE_TAG: ${{ steps.currenttag.outputs.version }}
 
   release-helm-chart:
     needs: [release-images]
diff --git a/.github/workflows/release-singleimage-test.yml b/.github/workflows/release-singleimage-test.yml
deleted file mode 100644
index c3a14226ce..0000000000
--- a/.github/workflows/release-singleimage-test.yml
+++ /dev/null
@@ -1,72 +0,0 @@
-name: Test
-
-on:
-  workflow_dispatch:
-
-env:
-  CI: true
-  PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
-  REGISTRY_URL: registry.hub.docker.com
-  NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
-jobs:
-  build:
-    name: "build"
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        node-version: [18.x]
-    steps:
-      - name: "Checkout"
-        uses: actions/checkout@v4
-        with:
-          submodules: true
-          token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
-
-      - name: Use Node.js ${{ matrix.node-version }}
-        uses: actions/setup-node@v3
-        with:
-          node-version: ${{ matrix.node-version }}
-          cache: "yarn"
-      - name: Setup QEMU
-        uses: docker/setup-qemu-action@v3
-      - name: Setup Docker Buildx
-        id: buildx
-        uses: docker/setup-buildx-action@v3
-      - name: Run Yarn
-        run: yarn
-      - name: Run Yarn Build
-        run: yarn build --scope @budibase/server --scope @budibase/worker
-      - name: Login to Docker Hub
-        uses: docker/login-action@v2
-        with:
-          username: ${{ secrets.DOCKER_USERNAME }}
-          password: ${{ secrets.DOCKER_API_KEY }}
-      - name: Get the latest release version
-        id: version
-        run: |
-          release_version=$(cat lerna.json | jq -r '.version')
-          echo $release_version
-          echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
-      - name: Tag and release Budibase service docker image
-        uses: docker/build-push-action@v5
-        with:
-          context: .
-          push: true
-          pull: true
-          platforms: linux/amd64,linux/arm64
-          build-args: BUDIBASE_VERSION=0.0.0+test
-          tags: budibase/budibase-test:test
-          file: ./hosting/single/Dockerfile.v2
-          cache-from: type=registry,ref=budibase/budibase-test:test
-          cache-to: type=inline
-      - name: Tag and release Budibase Azure App Service docker image
-        uses: docker/build-push-action@v2
-        with:
-          context: .
-          push: true
-          platforms: linux/amd64
-          build-args: |
-            TARGETBUILD=aas
-            BUDIBASE_VERSION=0.0.0+test
-          tags: budibase/budibase-test:aas
-          file: ./hosting/single/Dockerfile.v2
diff --git a/.github/workflows/release-singleimage.yml b/.github/workflows/release-singleimage.yml
index f7f87f6e4c..4d35916f4d 100644
--- a/.github/workflows/release-singleimage.yml
+++ b/.github/workflows/release-singleimage.yml
@@ -67,7 +67,7 @@ jobs:
           push: true
           platforms: linux/amd64,linux/arm64
           tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }}
-          file: ./hosting/single/Dockerfile
+          file: ./hosting/single/Dockerfile.v2
       - name: Tag and release Budibase Azure App Service docker image
         uses: docker/build-push-action@v2
         with:
@@ -76,4 +76,4 @@ jobs:
           platforms: linux/amd64
           build-args: TARGETBUILD=aas
           tags: budibase/budibase-aas,budibase/budibase-aas:${{ env.RELEASE_VERSION }}
-          file: ./hosting/single/Dockerfile
+          file: ./hosting/single/Dockerfile.v2
diff --git a/README.md b/README.md
index 9deb16cd4f..7827d4e48a 100644
--- a/README.md
+++ b/README.md
@@ -126,13 +126,6 @@ You can learn more about the Budibase API at the following places:
 
 - [Build an app with Budibase and Next.js](https://budibase.com/blog/building-a-crud-app-with-budibase-and-next.js/)
 
-<p align="center">
-  <img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1647858558/Feb%20release/Start_building_with_Budibase_s_API_3_rhlzhv.png">
-</p>
-<br /><br />
-
-<br /><br /><br />
-
 ## 🏁 Get started
 
 Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean.
diff --git a/hosting/proxy/nginx.prod.conf b/hosting/proxy/nginx.prod.conf
index 365765ccbb..6da2e4a1c3 100644
--- a/hosting/proxy/nginx.prod.conf
+++ b/hosting/proxy/nginx.prod.conf
@@ -51,7 +51,7 @@ http {
     proxy_buffering off;
 
     set $csp_default "default-src 'self'";
-    set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io";
+    set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net";
     set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
     set $csp_object "object-src 'none'";
     set $csp_base_uri "base-uri 'self'";
diff --git a/hosting/scripts/linux/release-to-docker-hub.sh b/hosting/scripts/linux/release-to-docker-hub.sh
deleted file mode 100755
index 599a10f914..0000000000
--- a/hosting/scripts/linux/release-to-docker-hub.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/bin/bash
-
-tag=$1
-
-if [[ ! "$tag" ]]; then
-	echo "No tag present. You must pass a tag to this script"
-	exit 1
-fi
-
-echo "Tagging images with tag: $tag"
-
-docker tag proxy-service budibase/proxy:$tag
-docker tag app-service budibase/apps:$tag
-docker tag worker-service budibase/worker:$tag
-
-docker push --all-tags budibase/apps 
-docker push --all-tags budibase/worker
-docker push --all-tags budibase/proxy
diff --git a/lerna.json b/lerna.json
index 7d14875c97..cb92b3ba0d 100644
--- a/lerna.json
+++ b/lerna.json
@@ -1,5 +1,5 @@
 {
-  "version": "2.11.44",
+  "version": "2.12.2",
   "npmClient": "yarn",
   "packages": [
     "packages/*"
diff --git a/package.json b/package.json
index 100a306a35..417fb31e0e 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,6 @@
     "build:sdk": "lerna run --stream build:sdk",
     "deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
     "release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",
-    "release:develop": "yarn release --dist-tag develop",
     "restore": "yarn run clean && yarn && yarn run build",
     "nuke": "yarn run nuke:packages && yarn run nuke:docker",
     "nuke:packages": "yarn run restore",
@@ -55,10 +54,6 @@
     "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"",
     "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
     "build:specs": "lerna run --stream specs",
-    "build:docker": "lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
-    "build:docker:proxy": "docker build hosting/proxy -t proxy-service",
-    "build:docker:selfhost": "lerna run --stream build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -",
-    "build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
     "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
     "build:docker:airgap:single": "SINGLE_IMAGE=1 node hosting/scripts/airgapped/airgappedDockerBuild",
     "build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -",
diff --git a/packages/backend-core/__mocks__/aws-sdk.ts b/packages/backend-core/__mocks__/aws-sdk.ts
index b8d91dbaa9..e3be511d08 100644
--- a/packages/backend-core/__mocks__/aws-sdk.ts
+++ b/packages/backend-core/__mocks__/aws-sdk.ts
@@ -3,6 +3,7 @@ const mockS3 = {
   deleteObject: jest.fn().mockReturnThis(),
   deleteObjects: jest.fn().mockReturnThis(),
   createBucket: jest.fn().mockReturnThis(),
+  getObject: jest.fn().mockReturnThis(),
   listObject: jest.fn().mockReturnThis(),
   getSignedUrl: jest.fn((operation: string, params: any) => {
     return `http://s3.example.com/${params.Bucket}/${params.Key}`
diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json
index b23cd8e5b1..dc8d71b52c 100644
--- a/packages/backend-core/package.json
+++ b/packages/backend-core/package.json
@@ -21,7 +21,7 @@
     "test:watch": "jest --watchAll"
   },
   "dependencies": {
-    "@budibase/nano": "10.1.2",
+    "@budibase/nano": "10.1.3",
     "@budibase/pouchdb-replication-stream": "1.2.10",
     "@budibase/shared-core": "0.0.0",
     "@budibase/types": "0.0.0",
diff --git a/packages/backend-core/src/cache/writethrough.ts b/packages/backend-core/src/cache/writethrough.ts
index e64c116663..c331d791a6 100644
--- a/packages/backend-core/src/cache/writethrough.ts
+++ b/packages/backend-core/src/cache/writethrough.ts
@@ -119,8 +119,8 @@ export class Writethrough {
     this.writeRateMs = writeRateMs
   }
 
-  async put(doc: any) {
-    return put(this.db, doc, this.writeRateMs)
+  async put(doc: any, writeRateMs: number = this.writeRateMs) {
+    return put(this.db, doc, writeRateMs)
   }
 
   async get(id: string) {
diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts
index aea485e3e3..bfa7595d62 100644
--- a/packages/backend-core/src/db/constants.ts
+++ b/packages/backend-core/src/db/constants.ts
@@ -8,3 +8,7 @@ export const CONSTANT_INTERNAL_ROW_COLS = [
 ] as const
 
 export const CONSTANT_EXTERNAL_ROW_COLS = ["_id", "_rev", "tableId"] as const
+
+export function isInternalColumnName(name: string): boolean {
+  return (CONSTANT_INTERNAL_ROW_COLS as readonly string[]).includes(name)
+}
diff --git a/packages/backend-core/src/docIds/params.ts b/packages/backend-core/src/docIds/params.ts
index 36fd75622b..d9baee3dc6 100644
--- a/packages/backend-core/src/docIds/params.ts
+++ b/packages/backend-core/src/docIds/params.ts
@@ -6,6 +6,7 @@ import {
   ViewName,
 } from "../constants"
 import { getProdAppID } from "./conversions"
+import { DatabaseQueryOpts } from "@budibase/types"
 
 /**
  * If creating DB allDocs/query params with only a single top level ID this can be used, this
@@ -22,8 +23,8 @@ import { getProdAppID } from "./conversions"
 export function getDocParams(
   docType: string,
   docId?: string | null,
-  otherProps: any = {}
-) {
+  otherProps: Partial<DatabaseQueryOpts> = {}
+): DatabaseQueryOpts {
   if (docId == null) {
     docId = ""
   }
@@ -45,8 +46,8 @@ export function getDocParams(
 export function getRowParams(
   tableId?: string | null,
   rowId?: string | null,
-  otherProps = {}
-) {
+  otherProps: Partial<DatabaseQueryOpts> = {}
+): DatabaseQueryOpts {
   if (tableId == null) {
     return getDocParams(DocumentType.ROW, null, otherProps)
   }
@@ -88,7 +89,10 @@ export const isDatasourceId = (id: string) => {
 /**
  * Gets parameters for retrieving workspaces.
  */
-export function getWorkspaceParams(id = "", otherProps = {}) {
+export function getWorkspaceParams(
+  id = "",
+  otherProps: Partial<DatabaseQueryOpts> = {}
+): DatabaseQueryOpts {
   return {
     ...otherProps,
     startkey: `${DocumentType.WORKSPACE}${SEPARATOR}${id}`,
@@ -99,7 +103,10 @@ export function getWorkspaceParams(id = "", otherProps = {}) {
 /**
  * Gets parameters for retrieving users.
  */
-export function getGlobalUserParams(globalId: any, otherProps: any = {}) {
+export function getGlobalUserParams(
+  globalId: any,
+  otherProps: Partial<DatabaseQueryOpts> = {}
+): DatabaseQueryOpts {
   if (!globalId) {
     globalId = ""
   }
@@ -117,11 +124,17 @@ export function getGlobalUserParams(globalId: any, otherProps: any = {}) {
 /**
  * Gets parameters for retrieving users, this is a utility function for the getDocParams function.
  */
-export function getUserMetadataParams(userId?: string | null, otherProps = {}) {
+export function getUserMetadataParams(
+  userId?: string | null,
+  otherProps: Partial<DatabaseQueryOpts> = {}
+): DatabaseQueryOpts {
   return getRowParams(InternalTable.USER_METADATA, userId, otherProps)
 }
 
-export function getUsersByAppParams(appId: any, otherProps: any = {}) {
+export function getUsersByAppParams(
+  appId: any,
+  otherProps: Partial<DatabaseQueryOpts> = {}
+): DatabaseQueryOpts {
   const prodAppId = getProdAppID(appId)
   return {
     ...otherProps,
diff --git a/packages/backend-core/src/security/roles.ts b/packages/backend-core/src/security/roles.ts
index b05cf79c8c..0d33031de5 100644
--- a/packages/backend-core/src/security/roles.ts
+++ b/packages/backend-core/src/security/roles.ts
@@ -122,7 +122,9 @@ export async function roleToNumber(id?: string) {
   if (isBuiltin(id)) {
     return builtinRoleToNumber(id)
   }
-  const hierarchy = (await getUserRoleHierarchy(id)) as RoleDoc[]
+  const hierarchy = (await getUserRoleHierarchy(id, {
+    defaultPublic: true,
+  })) as RoleDoc[]
   for (let role of hierarchy) {
     if (isBuiltin(role?.inherits)) {
       return builtinRoleToNumber(role.inherits) + 1
@@ -192,12 +194,15 @@ export async function getRole(
 /**
  * Simple function to get all the roles based on the top level user role ID.
  */
-async function getAllUserRoles(userRoleId?: string): Promise<RoleDoc[]> {
+async function getAllUserRoles(
+  userRoleId?: string,
+  opts?: { defaultPublic?: boolean }
+): Promise<RoleDoc[]> {
   // admins have access to all roles
   if (userRoleId === BUILTIN_IDS.ADMIN) {
     return getAllRoles()
   }
-  let currentRole = await getRole(userRoleId)
+  let currentRole = await getRole(userRoleId, opts)
   let roles = currentRole ? [currentRole] : []
   let roleIds = [userRoleId]
   // get all the inherited roles
@@ -226,12 +231,16 @@ export async function getUserRoleIdHierarchy(
  * Returns an ordered array of the user's inherited role IDs, this can be used
  * to determine if a user can access something that requires a specific role.
  * @param userRoleId The user's role ID, this can be found in their access token.
+ * @param opts optional - if want to default to public use this.
  * @returns returns an ordered array of the roles, with the first being their
  * highest level of access and the last being the lowest level.
  */
-export async function getUserRoleHierarchy(userRoleId?: string) {
+export async function getUserRoleHierarchy(
+  userRoleId?: string,
+  opts?: { defaultPublic?: boolean }
+) {
   // special case, if they don't have a role then they are a public user
-  return getAllUserRoles(userRoleId)
+  return getAllUserRoles(userRoleId, opts)
 }
 
 // this function checks that the provided permissions are in an array format
diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts
index a2539e836e..c071064713 100644
--- a/packages/backend-core/src/users/db.ts
+++ b/packages/backend-core/src/users/db.ts
@@ -25,12 +25,17 @@ import {
 import {
   getAccountHolderFromUserIds,
   isAdmin,
+  isCreator,
   validateUniqueUser,
 } from "./utils"
 import { searchExistingEmails } from "./lookup"
 import { hash } from "../utils"
 
-type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
+type QuotaUpdateFn = (
+  change: number,
+  creatorsChange: number,
+  cb?: () => Promise<any>
+) => Promise<any>
 type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
 type FeatureFn = () => Promise<Boolean>
 type GroupGetFn = (ids: string[]) => Promise<UserGroup[]>
@@ -160,13 +165,9 @@ export class UserDB {
   }
 
   static async getUsersByAppAccess(opts: { appId?: string; limit?: number }) {
-    const params: any = {
-      include_docs: true,
-      limit: opts.limit || 50,
-    }
     let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
       opts.appId,
-      params
+      { limit: opts.limit || 50 }
     )
     return response
   }
@@ -245,7 +246,8 @@ export class UserDB {
     }
 
     const change = dbUser ? 0 : 1 // no change if there is existing user
-    return UserDB.quotas.addUsers(change, async () => {
+    const creatorsChange = isCreator(dbUser) !== isCreator(user) ? 1 : 0
+    return UserDB.quotas.addUsers(change, creatorsChange, async () => {
       await validateUniqueUser(email, tenantId)
 
       let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser)
@@ -307,6 +309,7 @@ export class UserDB {
 
     let usersToSave: any[] = []
     let newUsers: any[] = []
+    let newCreators: any[] = []
 
     const emails = newUsersRequested.map((user: User) => user.email)
     const existingEmails = await searchExistingEmails(emails)
@@ -327,59 +330,66 @@ export class UserDB {
       }
       newUser.userGroups = groups
       newUsers.push(newUser)
+      if (isCreator(newUser)) {
+        newCreators.push(newUser)
+      }
     }
 
     const account = await accountSdk.getAccountByTenantId(tenantId)
-    return UserDB.quotas.addUsers(newUsers.length, async () => {
-      // create the promises array that will be called by bulkDocs
-      newUsers.forEach((user: any) => {
-        usersToSave.push(
-          UserDB.buildUser(
-            user,
-            {
-              hashPassword: true,
-              requirePassword: user.requirePassword,
-            },
-            tenantId,
-            undefined, // no dbUser
-            account
+    return UserDB.quotas.addUsers(
+      newUsers.length,
+      newCreators.length,
+      async () => {
+        // create the promises array that will be called by bulkDocs
+        newUsers.forEach((user: any) => {
+          usersToSave.push(
+            UserDB.buildUser(
+              user,
+              {
+                hashPassword: true,
+                requirePassword: user.requirePassword,
+              },
+              tenantId,
+              undefined, // no dbUser
+              account
+            )
           )
-        )
-      })
+        })
 
-      const usersToBulkSave = await Promise.all(usersToSave)
-      await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
+        const usersToBulkSave = await Promise.all(usersToSave)
+        await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
 
-      // Post-processing of bulk added users, e.g. events and cache operations
-      for (const user of usersToBulkSave) {
-        // TODO: Refactor to bulk insert users into the info db
-        // instead of relying on looping tenant creation
-        await platform.users.addUser(tenantId, user._id, user.email)
-        await eventHelpers.handleSaveEvents(user, undefined)
-      }
+        // Post-processing of bulk added users, e.g. events and cache operations
+        for (const user of usersToBulkSave) {
+          // TODO: Refactor to bulk insert users into the info db
+          // instead of relying on looping tenant creation
+          await platform.users.addUser(tenantId, user._id, user.email)
+          await eventHelpers.handleSaveEvents(user, undefined)
+        }
+
+        const saved = usersToBulkSave.map(user => {
+          return {
+            _id: user._id,
+            email: user.email,
+          }
+        })
+
+        // now update the groups
+        if (Array.isArray(saved) && groups) {
+          const groupPromises = []
+          const createdUserIds = saved.map(user => user._id)
+          for (let groupId of groups) {
+            groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds))
+          }
+          await Promise.all(groupPromises)
+        }
 
-      const saved = usersToBulkSave.map(user => {
         return {
-          _id: user._id,
-          email: user.email,
+          successful: saved,
+          unsuccessful,
         }
-      })
-
-      // now update the groups
-      if (Array.isArray(saved) && groups) {
-        const groupPromises = []
-        const createdUserIds = saved.map(user => user._id)
-        for (let groupId of groups) {
-          groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds))
-        }
-        await Promise.all(groupPromises)
       }
-
-      return {
-        successful: saved,
-        unsuccessful,
-      }
-    })
+    )
   }
 
   static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
@@ -419,11 +429,12 @@ export class UserDB {
       _deleted: true,
     }))
     const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
+    const creatorsToDelete = usersToDelete.filter(isCreator)
 
-    await UserDB.quotas.removeUsers(toDelete.length)
     for (let user of usersToDelete) {
       await bulkDeleteProcessing(user)
     }
+    await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length)
 
     // Build Response
     // index users by id
@@ -472,7 +483,8 @@ export class UserDB {
 
     await db.remove(userId, dbUser._rev)
 
-    await UserDB.quotas.removeUsers(1)
+    const creatorsToDelete = isCreator(dbUser) ? 1 : 0
+    await UserDB.quotas.removeUsers(1, creatorsToDelete)
     await eventHelpers.handleDeleteEvents(dbUser)
     await cache.user.invalidateUser(userId)
     await sessions.invalidateSessions(userId, { reason: "deletion" })
diff --git a/packages/backend-core/src/users/users.ts b/packages/backend-core/src/users/users.ts
index 6237c23972..6dc8750b62 100644
--- a/packages/backend-core/src/users/users.ts
+++ b/packages/backend-core/src/users/users.ts
@@ -14,12 +14,13 @@ import {
 } from "../db"
 import {
   BulkDocsResponse,
-  ContextUser,
   SearchQuery,
   SearchQueryOperators,
   SearchUsersRequest,
   User,
+  ContextUser,
   DatabaseQueryOpts,
+  CouchFindOptions,
 } from "@budibase/types"
 import { getGlobalDB } from "../context"
 import * as context from "../context"
@@ -140,7 +141,7 @@ export const getGlobalUserByEmail = async (
 
 export const searchGlobalUsersByApp = async (
   appId: any,
-  opts: any,
+  opts: DatabaseQueryOpts,
   getOpts?: GetOpts
 ) => {
   if (typeof appId !== "string") {
@@ -166,7 +167,10 @@ export const searchGlobalUsersByApp = async (
   Return any user who potentially has access to the application
   Admins, developers and app users with the explicitly role.
 */
-export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => {
+export const searchGlobalUsersByAppAccess = async (
+  appId: any,
+  opts?: { limit?: number }
+) => {
   const roleSelector = `roles.${appId}`
 
   let orQuery: any[] = [
@@ -187,7 +191,7 @@ export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => {
     orQuery.push(roleCheck)
   }
 
-  let searchOptions = {
+  let searchOptions: CouchFindOptions = {
     selector: {
       $or: orQuery,
       _id: {
@@ -198,7 +202,7 @@ export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => {
   }
 
   const resp = await directCouchFind(context.getGlobalDBName(), searchOptions)
-  return resp?.rows
+  return resp.rows
 }
 
 export const getGlobalUserByAppPage = (appId: string, user: User) => {
@@ -245,7 +249,8 @@ export const paginatedUsers = async ({
   limit,
 }: SearchUsersRequest = {}) => {
   const db = getGlobalDB()
-  const pageLimit = limit ? limit + 1 : PAGE_LIMIT + 1
+  const pageSize = limit ?? PAGE_LIMIT
+  const pageLimit = pageSize + 1
   // get one extra document, to have the next page
   const opts: DatabaseQueryOpts = {
     include_docs: true,
@@ -272,7 +277,7 @@ export const paginatedUsers = async ({
     const response = await db.allDocs(getGlobalUserParams(null, opts))
     userList = response.rows.map((row: any) => row.doc)
   }
-  return pagination(userList, pageLimit, {
+  return pagination(userList, pageSize, {
     paginate: true,
     property,
     getKey,
diff --git a/packages/backend-core/tests/core/users/users.spec.js b/packages/backend-core/tests/core/users/users.spec.js
new file mode 100644
index 0000000000..ae7109344a
--- /dev/null
+++ b/packages/backend-core/tests/core/users/users.spec.js
@@ -0,0 +1,54 @@
+const _ = require('lodash/fp')
+const {structures} = require("../../../tests")
+
+jest.mock("../../../src/context")
+jest.mock("../../../src/db")
+
+const context = require("../../../src/context")
+const db = require("../../../src/db")
+
+const {getCreatorCount} = require('../../../src/users/users')
+
+describe("Users", () => {
+
+  let getGlobalDBMock
+  let getGlobalUserParamsMock
+  let paginationMock
+
+  beforeEach(() => {
+    jest.resetAllMocks()
+
+    getGlobalDBMock = jest.spyOn(context, "getGlobalDB")
+    getGlobalUserParamsMock = jest.spyOn(db, "getGlobalUserParams")
+    paginationMock = jest.spyOn(db, "pagination")
+  })
+
+  it("Retrieves the number of creators", async () => {
+    const getUsers = (offset, limit, creators = false) => {
+      const range = _.range(offset, limit)
+      const opts = creators ? {builder: {global: true}} : undefined
+      return range.map(() => structures.users.user(opts))
+    }
+    const page1Data = getUsers(0, 8)
+    const page2Data = getUsers(8, 12, true)
+    getGlobalDBMock.mockImplementation(() => ({
+      name   : "fake-db",
+      allDocs: () => ({
+        rows: [...page1Data, ...page2Data]
+      })
+    }))
+    paginationMock.mockImplementationOnce(() => ({
+      data: page1Data,
+      hasNextPage: true,
+      nextPage: "1"
+    }))
+    paginationMock.mockImplementation(() => ({
+      data: page2Data,
+      hasNextPage: false,
+      nextPage: undefined
+    }))
+    const creatorsCount = await getCreatorCount()
+    expect(creatorsCount).toBe(4)
+    expect(paginationMock).toHaveBeenCalledTimes(2)
+  })
+})
diff --git a/packages/backend-core/tests/core/utilities/mocks/date.ts b/packages/backend-core/tests/core/utilities/mocks/date.ts
index f580b68349..1e6d105d93 100644
--- a/packages/backend-core/tests/core/utilities/mocks/date.ts
+++ b/packages/backend-core/tests/core/utilities/mocks/date.ts
@@ -1,2 +1,3 @@
 export const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
+
 export const MOCK_DATE_TIMESTAMP = 1577836800000
diff --git a/packages/backend-core/tests/core/utilities/structures/licenses.ts b/packages/backend-core/tests/core/utilities/structures/licenses.ts
index 0e34f2e9bb..bb452f9ad5 100644
--- a/packages/backend-core/tests/core/utilities/structures/licenses.ts
+++ b/packages/backend-core/tests/core/utilities/structures/licenses.ts
@@ -123,6 +123,10 @@ export function customer(): Customer {
 export function subscription(): Subscription {
   return {
     amount: 10000,
+    amounts: {
+      user: 10000,
+      creator: 0,
+    },
     cancelAt: undefined,
     currency: "usd",
     currentPeriodEnd: 0,
@@ -131,6 +135,10 @@ export function subscription(): Subscription {
     duration: PriceDuration.MONTHLY,
     pastDueAt: undefined,
     quantity: 0,
+    quantities: {
+      user: 0,
+      creator: 0,
+    },
     status: "active",
   }
 }
diff --git a/packages/bbui/src/Form/Core/Dropzone.svelte b/packages/bbui/src/Form/Core/Dropzone.svelte
index e9ee75bd8b..0b6a9bb94f 100644
--- a/packages/bbui/src/Form/Core/Dropzone.svelte
+++ b/packages/bbui/src/Form/Core/Dropzone.svelte
@@ -159,8 +159,10 @@
           {#if selectedImage.size}
             <div class="filesize">
               {#if selectedImage.size <= BYTES_IN_MB}
-                {`${selectedImage.size / BYTES_IN_KB} KB`}
-              {:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if}
+                {`${(selectedImage.size / BYTES_IN_KB).toFixed(1)} KB`}
+              {:else}{`${(selectedImage.size / BYTES_IN_MB).toFixed(
+                  1
+                )} MB`}{/if}
             </div>
           {/if}
           {#if !disabled}
@@ -203,8 +205,8 @@
             {#if file.size}
               <div class="filesize">
                 {#if file.size <= BYTES_IN_MB}
-                  {`${file.size / BYTES_IN_KB} KB`}
-                {:else}{`${file.size / BYTES_IN_MB} MB`}{/if}
+                  {`${(file.size / BYTES_IN_KB).toFixed(1)} KB`}
+                {:else}{`${(file.size / BYTES_IN_MB).toFixed(1)} MB`}{/if}
               </div>
             {/if}
             {#if !disabled}
diff --git a/packages/builder/.gitignore b/packages/builder/.gitignore
index e5c961d509..acd1a70579 100644
--- a/packages/builder/.gitignore
+++ b/packages/builder/.gitignore
@@ -5,4 +5,4 @@ package-lock.json
 release/
 dist/
 routify
-.routify/
\ No newline at end of file
+.routify/
diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js
index a567caf87f..a4729b4a8a 100644
--- a/packages/builder/src/builderStore/store/frontend.js
+++ b/packages/builder/src/builderStore/store/frontend.js
@@ -580,7 +580,7 @@ export const getFrontendStore = () => {
         let table = validTables.find(table => {
           return (
             table.sourceId !== BUDIBASE_INTERNAL_DB_ID &&
-            table.type === DB_TYPE_INTERNAL
+            table.sourceType === DB_TYPE_INTERNAL
           )
         })
         if (table) {
@@ -591,7 +591,7 @@ export const getFrontendStore = () => {
         table = validTables.find(table => {
           return (
             table.sourceId === BUDIBASE_INTERNAL_DB_ID &&
-            table.type === DB_TYPE_INTERNAL
+            table.sourceType === DB_TYPE_INTERNAL
           )
         })
         if (table) {
@@ -599,7 +599,7 @@ export const getFrontendStore = () => {
         }
 
         // Finally try an external table
-        return validTables.find(table => table.type === DB_TYPE_EXTERNAL)
+        return validTables.find(table => table.sourceType === DB_TYPE_EXTERNAL)
       },
       enrichEmptySettings: (component, opts) => {
         if (!component?._component) {
diff --git a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js
index b17bd99e10..59bcd0d5e8 100644
--- a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js
+++ b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js
@@ -2,14 +2,14 @@ import sanitizeUrl from "./utils/sanitizeUrl"
 import { Screen } from "./utils/Screen"
 import { Component } from "./utils/Component"
 
-export default function (datasources) {
+export default function (datasources, mode = "table") {
   if (!Array.isArray(datasources)) {
     return []
   }
   return datasources.map(datasource => {
     return {
       name: `${datasource.label} - List`,
-      create: () => createScreen(datasource),
+      create: () => createScreen(datasource, mode),
       id: ROW_LIST_TEMPLATE,
       resourceId: datasource.resourceId,
     }
@@ -40,10 +40,24 @@ const generateTableBlock = datasource => {
   return tableBlock
 }
 
-const createScreen = datasource => {
+const generateGridBlock = datasource => {
+  const gridBlock = new Component("@budibase/standard-components/gridblock")
+  gridBlock
+    .customProps({
+      table: datasource,
+    })
+    .instanceName(`${datasource.label} - Grid block`)
+  return gridBlock
+}
+
+const createScreen = (datasource, mode) => {
   return new Screen()
     .route(rowListUrl(datasource))
     .instanceName(`${datasource.label} - List`)
-    .addChild(generateTableBlock(datasource))
+    .addChild(
+      mode === "table"
+        ? generateTableBlock(datasource)
+        : generateGridBlock(datasource)
+    )
     .json()
 }
diff --git a/packages/builder/src/components/backend/DataTable/RelationshipDataTable.svelte b/packages/builder/src/components/backend/DataTable/RelationshipDataTable.svelte
index 8ef870caca..4e67a92443 100644
--- a/packages/builder/src/components/backend/DataTable/RelationshipDataTable.svelte
+++ b/packages/builder/src/components/backend/DataTable/RelationshipDataTable.svelte
@@ -16,7 +16,6 @@
   $: linkedTable = $tables.list.find(table => table._id === linkedTableId)
   $: schema = linkedTable?.schema
   $: table = $tables.list.find(table => table._id === tableId)
-  $: type = table?.type
   $: fetchData(tableId, rowId)
   $: {
     let rowLabel = row?.[table?.primaryDisplay]
@@ -41,5 +40,5 @@
 </script>
 
 {#if row && row._id === rowId}
-  <Table {title} {schema} {data} {type} />
+  <Table {title} {schema} {data} />
 {/if}
diff --git a/packages/builder/src/components/backend/DataTable/Table.svelte b/packages/builder/src/components/backend/DataTable/Table.svelte
index f8087d8a39..f7eccd5242 100644
--- a/packages/builder/src/components/backend/DataTable/Table.svelte
+++ b/packages/builder/src/components/backend/DataTable/Table.svelte
@@ -24,17 +24,23 @@
 
   let selectedRows = []
   let customRenderers = []
+  let parsedSchema = {}
+
+  $: if (schema) {
+    parsedSchema = Object.keys(schema).reduce((acc, key) => {
+      acc[key] =
+        typeof schema[key] === "string" ? { type: schema[key] } : schema[key]
+
+      if (!canBeSortColumn(acc[key].type)) {
+        acc[key].sortable = false
+      }
+      return acc
+    }, {})
+  }
 
   $: selectedRows, dispatch("selectionUpdated", selectedRows)
   $: isUsersTable = tableId === TableNames.USERS
   $: data && resetSelectedRows()
-  $: {
-    Object.values(schema || {}).forEach(col => {
-      if (!canBeSortColumn(col.type)) {
-        col.sortable = false
-      }
-    })
-  }
   $: {
     if (isUsersTable) {
       customRenderers = [
@@ -44,24 +50,24 @@
         },
       ]
       UNEDITABLE_USER_FIELDS.forEach(field => {
-        if (schema[field]) {
-          schema[field].editable = false
+        if (parsedSchema[field]) {
+          parsedSchema[field].editable = false
         }
       })
-      if (schema.email) {
-        schema.email.displayName = "Email"
+      if (parsedSchema.email) {
+        parsedSchema.email.displayName = "Email"
       }
-      if (schema.roleId) {
-        schema.roleId.displayName = "Role"
+      if (parsedSchema.roleId) {
+        parsedSchema.roleId.displayName = "Role"
       }
-      if (schema.firstName) {
-        schema.firstName.displayName = "First Name"
+      if (parsedSchema.firstName) {
+        parsedSchema.firstName.displayName = "First Name"
       }
-      if (schema.lastName) {
-        schema.lastName.displayName = "Last Name"
+      if (parsedSchema.lastName) {
+        parsedSchema.lastName.displayName = "Last Name"
       }
-      if (schema.status) {
-        schema.status.displayName = "Status"
+      if (parsedSchema.status) {
+        parsedSchema.status.displayName = "Status"
       }
     }
   }
@@ -97,7 +103,7 @@
     <div class="table-wrapper">
       <Table
         {data}
-        {schema}
+        schema={parsedSchema}
         {loading}
         {customRenderers}
         {rowCount}
diff --git a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte
index 5fee849afb..8dd685e766 100644
--- a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte
+++ b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte
@@ -16,6 +16,7 @@
   import GridRelationshipButton from "components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte"
   import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
   import GridUsersTableButton from "components/backend/DataTable/modals/grid/GridUsersTableButton.svelte"
+  import { DB_TYPE_EXTERNAL } from "constants/backend"
 
   const userSchemaOverrides = {
     firstName: { displayName: "First name", disabled: true },
@@ -27,7 +28,7 @@
 
   $: id = $tables.selected?._id
   $: isUsersTable = id === TableNames.USERS
-  $: isInternal = $tables.selected?.type !== "external"
+  $: isInternal = $tables.selected?.sourceType !== DB_TYPE_EXTERNAL
   $: gridDatasource = {
     type: "table",
     tableId: id,
@@ -46,10 +47,7 @@
     tables.replaceTable(id, e.detail)
 
     // We need to refresh datasources when an external table changes.
-    // Type "external" may exist - sometimes type is "table" and sometimes it
-    // is "external" - it has different meanings in different endpoints.
-    // If we check both these then we hopefully catch all external tables.
-    if (e.detail?.type === "external" || e.detail?.sql) {
+    if (e.detail?.sourceType === DB_TYPE_EXTERNAL) {
       await datasources.fetch()
     }
   }
diff --git a/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte b/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte
index f6160e3caa..6fcba8d418 100644
--- a/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte
+++ b/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte
@@ -17,9 +17,9 @@
   let hideAutocolumns = true
   let data = []
   let loading = false
-  let type = "internal"
 
   $: name = view.name
+  $: schema = view.schema
   $: calculation = view.calculation
 
   $: supportedFormats = Object.values(ROW_EXPORT_FORMATS).filter(key => {
@@ -61,11 +61,10 @@
 
 <Table
   title={decodeURI(name)}
-  schema={view.schema}
+  {schema}
   tableId={view.tableId}
   {data}
   {loading}
-  {type}
   rowCount={10}
   allowEditing={false}
   bind:hideAutocolumns
diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte
index 71d971891c..74e255cf7e 100644
--- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte
+++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte
@@ -10,6 +10,6 @@
 <ImportButton
   {disabled}
   tableId={$datasource?.tableId}
-  tableType={$definition?.type}
+  tableType={$definition?.sourceType}
   on:importrows={rows.actions.refreshData}
 />
diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte
index 7b51e6c839..d5a9aba488 100644
--- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte
+++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte
@@ -26,6 +26,7 @@
     ALLOWABLE_NUMBER_TYPES,
     SWITCHABLE_TYPES,
     PrettyRelationshipDefinitions,
+    DB_TYPE_EXTERNAL,
   } from "constants/backend"
   import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
   import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@@ -254,10 +255,11 @@
     !uneditable &&
     editableColumn?.type !== AUTO_TYPE &&
     !editableColumn.autocolumn
-  $: external = table.type === "external"
+  $: externalTable = table.sourceType === DB_TYPE_EXTERNAL
   // in the case of internal tables the sourceId will just be undefined
   $: tableOptions = $tables.list.filter(
-    opt => opt.type === table.type && table.sourceId === opt.sourceId
+    opt =>
+      opt.sourceType === table.sourceType && table.sourceId === opt.sourceId
   )
   $: typeEnabled =
     !originalName ||
@@ -409,7 +411,7 @@
       editableColumn.type === FieldType.BB_REFERENCE &&
       editableColumn.subtype === FieldSubtype.USERS
 
-    if (!external) {
+    if (!externalTable) {
       return [
         FIELDS.STRING,
         FIELDS.BARCODEQR,
@@ -441,7 +443,7 @@
         isUsers ? FIELDS.USERS : FIELDS.USER,
       ]
       // no-sql or a spreadsheet
-      if (!external || table.sql) {
+      if (!externalTable || table.sql) {
         fields = [...fields, FIELDS.LINK, FIELDS.ARRAY]
       }
       return fields
@@ -486,7 +488,7 @@
       })
     }
     const newError = {}
-    if (!external && fieldInfo.name?.startsWith("_")) {
+    if (!externalTable && fieldInfo.name?.startsWith("_")) {
       newError.name = `Column name cannot start with an underscore.`
     } else if (fieldInfo.name && !fieldInfo.name.match(ValidColumnNameRegex)) {
       newError.name = `Illegal character; must be alpha-numeric.`
@@ -498,7 +500,7 @@
       newError.name = `Column name already in use.`
     }
 
-    if (fieldInfo.type == "auto" && !fieldInfo.subtype) {
+    if (fieldInfo.type === "auto" && !fieldInfo.subtype) {
       newError.subtype = `Auto Column requires a type`
     }
 
@@ -777,7 +779,8 @@
   disabled={deleteColName !== originalName}
 >
   <p>
-    Are you sure you wish to delete the column <b>{originalName}?</b>
+    Are you sure you wish to delete the column
+    <b on:click={() => (deleteColName = originalName)}>{originalName}?</b>
     Your data will be deleted and this action cannot be undone - enter the column
     name to confirm.
   </p>
@@ -810,4 +813,11 @@
     gap: 8px;
     display: flex;
   }
+  b {
+    transition: color 130ms ease-out;
+  }
+  b:hover {
+    cursor: pointer;
+    color: var(--spectrum-global-color-gray-900);
+  }
 </style>
diff --git a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte
index 43751ad944..eb1e7bc7ff 100644
--- a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte
+++ b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte
@@ -1,6 +1,6 @@
 <script>
   import { Select, Toggle, Multiselect } from "@budibase/bbui"
-  import { FIELDS } from "constants/backend"
+  import { DB_TYPE_INTERNAL, FIELDS } from "constants/backend"
   import { API } from "api"
   import { parseFile } from "./utils"
 
@@ -169,7 +169,7 @@
       </div>
     {/each}
   </div>
-  {#if tableType === "internal"}
+  {#if tableType === DB_TYPE_INTERNAL}
     <br />
     <Toggle
       bind:value={updateExistingRows}
diff --git a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte
index d13a7c30db..e5227af409 100644
--- a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte
+++ b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte
@@ -8,6 +8,7 @@
   import {
     BUDIBASE_INTERNAL_DB_ID,
     BUDIBASE_DATASOURCE_TYPE,
+    DB_TYPE_INTERNAL,
   } from "constants/backend"
 
   $: tableNames = $tables.list.map(table => table.name)
@@ -55,8 +56,9 @@
       name,
       schema: { ...schema },
       rows,
-      type: "internal",
+      type: "table",
       sourceId: targetDatasourceId,
+      sourceType: DB_TYPE_INTERNAL,
     }
 
     // Only set primary display if defined
diff --git a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte
index 1760938c53..18c0d460a8 100644
--- a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte
+++ b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte
@@ -13,6 +13,7 @@
     notifications,
   } from "@budibase/bbui"
   import ConfirmDialog from "components/common/ConfirmDialog.svelte"
+  import { DB_TYPE_EXTERNAL } from "constants/backend"
 
   export let table
 
@@ -27,8 +28,8 @@
   let willBeDeleted
   let deleteTableName
 
-  $: external = table?.type === "external"
-  $: allowDeletion = !external || table?.created
+  $: externalTable = table?.sourceType === DB_TYPE_EXTERNAL
+  $: allowDeletion = !externalTable || table?.created
 
   function showDeleteModal() {
     templateScreens = $store.screens.filter(
@@ -48,7 +49,7 @@
       for (let screen of templateScreens) {
         await store.actions.screens.delete(screen)
       }
-      if (table.type === "external") {
+      if (table.sourceType === DB_TYPE_EXTERNAL) {
         await datasources.fetch()
       }
       notifications.success("Table deleted")
@@ -91,7 +92,7 @@
     <div slot="control" class="icon">
       <Icon s hoverable name="MoreSmallList" />
     </div>
-    {#if !external}
+    {#if !externalTable}
       <MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem>
     {/if}
     <MenuItem icon="Delete" on:click={showDeleteModal}>Delete</MenuItem>
diff --git a/packages/builder/src/components/common/Dropzone.svelte b/packages/builder/src/components/common/Dropzone.svelte
index fd2359fd91..daa6ad1807 100644
--- a/packages/builder/src/components/common/Dropzone.svelte
+++ b/packages/builder/src/components/common/Dropzone.svelte
@@ -23,7 +23,7 @@
     try {
       return await API.uploadBuilderAttachment(data)
     } catch (error) {
-      notifications.error("Failed to upload attachment")
+      notifications.error(error.message || "Failed to upload attachment")
       return []
     }
   }
diff --git a/packages/builder/src/components/common/RoleSelect.svelte b/packages/builder/src/components/common/RoleSelect.svelte
index 82752554d5..2df61926e1 100644
--- a/packages/builder/src/components/common/RoleSelect.svelte
+++ b/packages/builder/src/components/common/RoleSelect.svelte
@@ -39,7 +39,15 @@
     allowCreator
   ) => {
     if (allowedRoles?.length) {
-      return roles.filter(role => allowedRoles.includes(role._id))
+      const filteredRoles = roles.filter(role =>
+        allowedRoles.includes(role._id)
+      )
+      return [
+        ...filteredRoles,
+        ...(allowedRoles.includes(Constants.Roles.CREATOR)
+          ? [{ _id: Constants.Roles.CREATOR, name: "Creator", enabled: false }]
+          : []),
+      ]
     }
     let newRoles = [...roles]
 
@@ -129,8 +137,9 @@
     getOptionColour={getColor}
     getOptionIcon={getIcon}
     isOptionEnabled={option =>
-      option._id !== Constants.Roles.CREATOR ||
-      $licensing.perAppBuildersEnabled}
+      (option._id !== Constants.Roles.CREATOR ||
+        $licensing.perAppBuildersEnabled) &&
+      option.enabled !== false}
     {placeholder}
     {error}
   />
diff --git a/packages/builder/src/components/design/settings/componentSettings.js b/packages/builder/src/components/design/settings/componentSettings.js
index 4c49587372..232b4bef31 100644
--- a/packages/builder/src/components/design/settings/componentSettings.js
+++ b/packages/builder/src/components/design/settings/componentSettings.js
@@ -23,6 +23,7 @@ import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
 import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte"
 import BarButtonList from "./controls/BarButtonList.svelte"
 import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
+import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
 import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
 
 const componentMap = {
@@ -48,6 +49,7 @@ const componentMap = {
   "filter/relationship": RelationshipFilterEditor,
   url: URLSelect,
   fieldConfiguration: FieldConfiguration,
+  buttonConfiguration: ButtonConfiguration,
   columns: ColumnEditor,
   "columns/basic": BasicColumnEditor,
   "columns/grid": GridColumnEditor,
diff --git a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte
new file mode 100644
index 0000000000..324418511b
--- /dev/null
+++ b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte
@@ -0,0 +1,134 @@
+<script>
+  import DraggableList from "../DraggableList/DraggableList.svelte"
+  import ButtonSetting from "./ButtonSetting.svelte"
+  import { createEventDispatcher } from "svelte"
+  import { store } from "builderStore"
+  import { Helpers } from "@budibase/bbui"
+
+  export let componentBindings
+  export let bindings
+  export let value
+
+  const dispatch = createEventDispatcher()
+
+  let focusItem
+
+  $: buttonList = sanitizeValue(value) || []
+  $: buttonCount = buttonList.length
+  $: itemProps = {
+    componentBindings: componentBindings || [],
+    bindings,
+    removeButton,
+    canRemove: buttonCount > 1,
+  }
+
+  const sanitizeValue = val => {
+    return val?.map(button => {
+      return button._component ? button : buildPseudoInstance(button)
+    })
+  }
+
+  const processItemUpdate = e => {
+    const updatedField = e.detail
+    const newButtonList = [...buttonList]
+    const fieldIdx = newButtonList.findIndex(pSetting => {
+      return pSetting._id === updatedField?._id
+    })
+    if (fieldIdx === -1) {
+      newButtonList.push(updatedField)
+    } else {
+      newButtonList[fieldIdx] = updatedField
+    }
+    dispatch("change", newButtonList)
+  }
+
+  const listUpdated = e => {
+    dispatch("change", [...e.detail])
+  }
+
+  const buildPseudoInstance = cfg => {
+    return store.actions.components.createInstance(
+      `@budibase/standard-components/button`,
+      {
+        _instanceName: Helpers.uuid(),
+        text: cfg.text,
+        type: cfg.type || "primary",
+      },
+      {}
+    )
+  }
+
+  const addButton = () => {
+    const newButton = buildPseudoInstance({
+      text: `Button ${buttonCount + 1}`,
+    })
+    dispatch("change", [...buttonList, newButton])
+    focusItem = newButton._id
+  }
+
+  const removeButton = id => {
+    dispatch(
+      "change",
+      buttonList.filter(button => button._id !== id)
+    )
+  }
+</script>
+
+<div class="button-configuration">
+  {#if buttonCount}
+    <DraggableList
+      on:change={listUpdated}
+      on:itemChange={processItemUpdate}
+      items={buttonList}
+      listItemKey={"_id"}
+      listType={ButtonSetting}
+      listTypeProps={itemProps}
+      focus={focusItem}
+      draggable={buttonCount > 1}
+    />
+
+    <div class="list-footer" on:click={addButton}>
+      <div class="add-button">Add button</div>
+    </div>
+  {/if}
+</div>
+
+<style>
+  .button-configuration :global(.spectrum-ActionButton) {
+    width: 100%;
+  }
+
+  .button-configuration :global(.list-wrap > li:last-child),
+  .button-configuration :global(.list-wrap) {
+    border-bottom-left-radius: unset;
+    border-bottom-right-radius: unset;
+    border-bottom: 0px;
+  }
+
+  .list-footer {
+    width: 100%;
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
+    background-color: var(
+      --spectrum-table-background-color,
+      var(--spectrum-global-color-gray-50)
+    );
+    transition: background-color ease-in-out 130ms;
+    display: flex;
+    justify-content: center;
+    border: 1px solid
+      var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
+    cursor: pointer;
+  }
+
+  .add-button {
+    margin: var(--spacing-s);
+  }
+
+  .list-footer:hover {
+    background-color: var(
+      --spectrum-table-row-background-color-hover,
+      var(--spectrum-alias-highlight-hover)
+    );
+  }
+</style>
diff --git a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte
new file mode 100644
index 0000000000..a05fd9a39b
--- /dev/null
+++ b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte
@@ -0,0 +1,64 @@
+<script>
+  import EditComponentPopover from "../EditComponentPopover.svelte"
+  import { Icon } from "@budibase/bbui"
+  import { runtimeToReadableBinding } from "builderStore/dataBinding"
+  import { isJSBinding } from "@budibase/string-templates"
+
+  export let item
+  export let componentBindings
+  export let bindings
+  export let anchor
+  export let removeButton
+  export let canRemove
+
+  $: readableText = isJSBinding(item.text)
+    ? "(JavaScript function)"
+    : runtimeToReadableBinding([...bindings, componentBindings], item.text)
+</script>
+
+<div class="list-item-body">
+  <div class="list-item-left">
+    <EditComponentPopover
+      {anchor}
+      componentInstance={item}
+      {componentBindings}
+      {bindings}
+      on:change
+    />
+    <div class="field-label">{readableText || "Button"}</div>
+  </div>
+  <div class="list-item-right">
+    <Icon
+      disabled={!canRemove}
+      size="S"
+      name="Close"
+      hoverable
+      on:click={() => removeButton(item._id)}
+    />
+  </div>
+</div>
+
+<style>
+  .field-label {
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+  }
+  .list-item-body,
+  .list-item-left {
+    display: flex;
+    align-items: center;
+    gap: var(--spacing-m);
+    min-width: 0;
+  }
+  .list-item-body {
+    margin-top: 8px;
+    margin-bottom: 8px;
+  }
+  .list-item-right :global(div.spectrum-Switch) {
+    margin: 0px;
+  }
+  .list-item-body {
+    justify-content: space-between;
+  }
+</style>
diff --git a/packages/builder/src/components/design/settings/controls/DraggableList.svelte b/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte
similarity index 82%
rename from packages/builder/src/components/design/settings/controls/DraggableList.svelte
rename to packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte
index c8395b2a1f..1992299e90 100644
--- a/packages/builder/src/components/design/settings/controls/DraggableList.svelte
+++ b/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte
@@ -1,10 +1,10 @@
 <script>
-  import { Icon } from "@budibase/bbui"
   import { dndzone } from "svelte-dnd-action"
   import { createEventDispatcher } from "svelte"
   import { generate } from "shortid"
   import { setContext } from "svelte"
-  import { writable } from "svelte/store"
+  import { writable, get } from "svelte/store"
+  import DragHandle from "./drag-handle.svelte"
 
   export let items = []
   export let showHandle = true
@@ -12,6 +12,7 @@
   export let listTypeProps = {}
   export let listItemKey
   export let draggable = true
+  export let focus
 
   let store = writable({
     selected: null,
@@ -27,6 +28,10 @@
 
   setContext("draggable", store)
 
+  $: if (focus && store) {
+    get(store).actions.select(focus)
+  }
+
   const dispatch = createEventDispatcher()
   const flipDurationMs = 150
 
@@ -82,13 +87,16 @@
 >
   {#each draggableItems as draggable (draggable.id)}
     <li
+      on:mousedown={() => {
+        get(store).actions.select()
+      }}
       bind:this={anchors[draggable.id]}
       class:highlighted={draggable.id === $store.selected}
     >
       <div class="left-content">
         {#if showHandle}
-          <div class="handle" aria-label="drag-handle">
-            <Icon name="DragHandle" size="XL" />
+          <div class="handle">
+            <DragHandle />
           </div>
         {/if}
       </div>
@@ -142,8 +150,9 @@
     border-top-right-radius: 4px;
   }
   .list-wrap > li:last-child {
-    border-top-left-radius: var(--spectrum-table-regular-border-radius);
-    border-top-right-radius: var(--spectrum-table-regular-border-radius);
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
+    border-bottom: 0px;
   }
   .right-content {
     flex: 1;
@@ -153,4 +162,15 @@
     padding-left: var(--spacing-s);
     padding-right: var(--spacing-s);
   }
+  .handle {
+    display: flex;
+    height: var(--spectrum-global-dimension-size-150);
+  }
+  .handle :global(svg) {
+    fill: var(--spectrum-global-color-gray-500);
+    margin-right: var(--spacing-m);
+    margin-left: 2px;
+    width: var(--spectrum-global-dimension-size-65);
+    height: 100%;
+  }
 </style>
diff --git a/packages/builder/src/components/design/settings/controls/DraggableList/drag-handle.svelte b/packages/builder/src/components/design/settings/controls/DraggableList/drag-handle.svelte
new file mode 100644
index 0000000000..5cfefdbe54
--- /dev/null
+++ b/packages/builder/src/components/design/settings/controls/DraggableList/drag-handle.svelte
@@ -0,0 +1,31 @@
+<svg
+  class="drag-handle spectrum-Icon spectrum-Icon--sizeS"
+  focusable="false"
+  aria-hidden="true"
+  xmlns="http://www.w3.org/2000/svg"
+>
+  <path
+    d="m1,11c0.55228,0 1,-0.4477 1,-1c0,-0.5523 -0.44772,-1 -1,-1c-0.55228,0 -1,0.4477 -1,1c0,0.5523 0.44772,1 1,1z"
+  />
+  <path
+    d="m1,8c0.55228,0 1,-0.4477 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.5523 0.44772,1 1,1z"
+  />
+  <path
+    d="m1,5c0.55228,0 1,-0.44772 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
+  />
+  <path
+    d="m1,2c0.55228,0 1,-0.44772 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
+  />
+  <path
+    d="m4,11c0.5523,0 1,-0.4477 1,-1c0,-0.5523 -0.4477,-1 -1,-1c-0.55228,0 -1,0.4477 -1,1c0,0.5523 0.44772,1 1,1z"
+  />
+  <path
+    d="m4,8c0.5523,0 1,-0.4477 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.5523 0.44772,1 1,1z"
+  />
+  <path
+    d="m4,5c0.5523,0 1,-0.44772 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
+  />
+  <path
+    d="m4,2c0.5523,0 1,-0.44772 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z"
+  />
+</svg>
diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte b/packages/builder/src/components/design/settings/controls/EditComponentPopover.svelte
similarity index 61%
rename from packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte
rename to packages/builder/src/components/design/settings/controls/EditComponentPopover.svelte
index 7d2eaae478..1533c0d1d5 100644
--- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte
+++ b/packages/builder/src/components/design/settings/controls/EditComponentPopover.svelte
@@ -3,31 +3,35 @@
   import { store } from "builderStore"
   import { cloneDeep } from "lodash/fp"
   import { createEventDispatcher } from "svelte"
-  import ComponentSettingsSection from "../../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
+  import ComponentSettingsSection from "../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
   import { getContext } from "svelte"
 
   export let anchor
-  export let field
+  export let componentInstance
   export let componentBindings
   export let bindings
+  export let parseSettings
 
   const draggable = getContext("draggable")
   const dispatch = createEventDispatcher()
 
   let popover
   let drawers = []
-  let pseudoComponentInstance
   let open = false
 
-  $: if (open && $draggable.selected && $draggable.selected != field._id) {
+  // Auto hide the component when another item is selected
+  $: if (open && $draggable.selected != componentInstance._id) {
     popover.hide()
   }
 
-  $: if (field) {
-    pseudoComponentInstance = field
+  // Open automatically if the component is marked as selected
+  $: if (!open && $draggable.selected === componentInstance._id && popover) {
+    popover.show()
+    open = true
   }
+
   $: componentDef = store.actions.components.getDefinition(
-    pseudoComponentInstance._component
+    componentInstance._component
   )
   $: parsedComponentDef = processComponentDefinitionSettings(componentDef)
 
@@ -36,17 +40,16 @@
       return {}
     }
     const clone = cloneDeep(componentDef)
-    const updatedSettings = clone.settings
-      .filter(setting => setting.key !== "field")
-      .map(setting => {
-        return { ...setting, nested: true }
-      })
-    clone.settings = updatedSettings
+
+    if (typeof parseSettings === "function") {
+      clone.settings = parseSettings(clone.settings)
+    }
+
     return clone
   }
 
   const updateSetting = async (setting, value) => {
-    const nestedComponentInstance = cloneDeep(pseudoComponentInstance)
+    const nestedComponentInstance = cloneDeep(componentInstance)
 
     const patchFn = store.actions.components.updateComponentSetting(
       setting.key,
@@ -54,12 +57,26 @@
     )
     patchFn(nestedComponentInstance)
 
-    const update = {
-      ...nestedComponentInstance,
-      active: pseudoComponentInstance.active,
+    dispatch("change", nestedComponentInstance)
+  }
+
+  const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
+    let { left, top } = cfg
+    let percentageOffset = 30
+    // left-outside
+    left = anchorBounds.left - eleBounds.width - 18
+
+    // shift up from the anchor, if space allows
+    let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
+    let defaultTop = anchorBounds.top - offsetPos
+
+    if (window.innerHeight - defaultTop < eleBounds.height) {
+      top = window.innerHeight - eleBounds.height - 5
+    } else {
+      top = anchorBounds.top - offsetPos
     }
 
-    dispatch("change", update)
+    return { ...cfg, left, top }
   }
 </script>
 
@@ -79,11 +96,11 @@
   bind:this={popover}
   on:open={() => {
     drawers = []
-    $draggable.actions.select(field._id)
+    $draggable.actions.select(componentInstance._id)
   }}
   on:close={() => {
     open = false
-    if ($draggable.selected == field._id) {
+    if ($draggable.selected == componentInstance._id) {
       $draggable.actions.select()
     }
   }}
@@ -92,33 +109,13 @@
   showPopover={drawers.length == 0}
   clickOutsideOverride={drawers.length > 0}
   maxHeight={600}
-  handlePostionUpdate={(anchorBounds, eleBounds, cfg) => {
-    let { left, top } = cfg
-    let percentageOffset = 30
-    // left-outside
-    left = anchorBounds.left - eleBounds.width - 18
-
-    // shift up from the anchor, if space allows
-    let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
-    let defaultTop = anchorBounds.top - offsetPos
-
-    if (window.innerHeight - defaultTop < eleBounds.height) {
-      top = window.innerHeight - eleBounds.height - 5
-    } else {
-      top = anchorBounds.top - offsetPos
-    }
-
-    return { ...cfg, left, top }
-  }}
+  handlePostionUpdate={customPositionHandler}
 >
   <span class="popover-wrap">
     <Layout noPadding noGap>
-      <div class="type-icon">
-        <Icon name={parsedComponentDef.icon} />
-        <span>{field.field}</span>
-      </div>
+      <slot name="header" />
       <ComponentSettingsSection
-        componentInstance={pseudoComponentInstance}
+        {componentInstance}
         componentDefinition={parsedComponentDef}
         isScreen={false}
         onUpdateSetting={updateSetting}
@@ -141,20 +138,4 @@
   .popover-wrap {
     background-color: var(--spectrum-alias-background-color-primary);
   }
-  .type-icon {
-    display: flex;
-    gap: var(--spacing-m);
-    margin: var(--spacing-xl);
-    margin-bottom: 0px;
-    height: var(--spectrum-alias-item-height-m);
-    padding: 0px var(--spectrum-alias-item-padding-m);
-    border-width: var(--spectrum-actionbutton-border-size);
-    border-radius: var(--spectrum-alias-border-radius-regular);
-    border: 1px solid
-      var(
-        --spectrum-actionbutton-m-border-color,
-        var(--spectrum-alias-border-color)
-      );
-    align-items: center;
-  }
 </style>
diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte
index 4169cb7d3d..6c74705ab0 100644
--- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte
+++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte
@@ -7,7 +7,7 @@
     getComponentBindableProperties,
   } from "builderStore/dataBinding"
   import { currentAsset } from "builderStore"
-  import DraggableList from "../DraggableList.svelte"
+  import DraggableList from "../DraggableList/DraggableList.svelte"
   import { createEventDispatcher } from "svelte"
   import { store, selectedScreen } from "builderStore"
   import FieldSetting from "./FieldSetting.svelte"
@@ -50,7 +50,7 @@
     updateSanitsedFields(sanitisedValue)
     unconfigured = buildUnconfiguredOptions(schema, sanitisedFields)
     fieldList = [...sanitisedFields, ...unconfigured]
-      .map(buildSudoInstance)
+      .map(buildPseudoInstance)
       .filter(x => x != null)
   }
 
@@ -104,7 +104,7 @@
     })
   }
 
-  const buildSudoInstance = instance => {
+  const buildPseudoInstance = instance => {
     if (instance._component) {
       return instance
     }
diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte
index b5cfcb12d9..1d9ce733b8 100644
--- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte
+++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte
@@ -1,8 +1,11 @@
 <script>
-  import EditFieldPopover from "./EditFieldPopover.svelte"
-  import { Toggle } from "@budibase/bbui"
+  import EditComponentPopover from "../EditComponentPopover.svelte"
+  import { Toggle, Icon } from "@budibase/bbui"
   import { createEventDispatcher } from "svelte"
   import { cloneDeep } from "lodash/fp"
+  import { store } from "builderStore"
+  import { runtimeToReadableBinding } from "builderStore/dataBinding"
+  import { isJSBinding } from "@budibase/string-templates"
 
   export let item
   export let componentBindings
@@ -16,18 +19,43 @@
       dispatch("change", { ...cloneDeep(item), active: e.detail })
     }
   }
+  const getReadableText = () => {
+    if (item.label) {
+      return isJSBinding(item.label)
+        ? "(JavaScript function)"
+        : runtimeToReadableBinding([...bindings, componentBindings], item.label)
+    }
+    return item.field
+  }
+
+  const parseSettings = settings => {
+    return settings
+      .filter(setting => setting.key !== "field")
+      .map(setting => {
+        return { ...setting, nested: true }
+      })
+  }
+
+  $: readableText = getReadableText(item)
+  $: componentDef = store.actions.components.getDefinition(item._component)
 </script>
 
 <div class="list-item-body">
   <div class="list-item-left">
-    <EditFieldPopover
+    <EditComponentPopover
       {anchor}
-      field={item}
+      componentInstance={item}
       {componentBindings}
       {bindings}
+      {parseSettings}
       on:change
-    />
-    <div class="field-label">{item.label || item.field}</div>
+    >
+      <div slot="header" class="type-icon">
+        <Icon name={componentDef.icon} />
+        <span>{item.field}</span>
+      </div>
+    </EditComponentPopover>
+    <div class="field-label">{readableText}</div>
   </div>
   <div class="list-item-right">
     <Toggle on:change={onToggle(item)} text="" value={item.active} thin />
@@ -53,4 +81,20 @@
   .list-item-body {
     justify-content: space-between;
   }
+  .type-icon {
+    display: flex;
+    gap: var(--spacing-m);
+    margin: var(--spacing-xl);
+    margin-bottom: 0px;
+    height: var(--spectrum-alias-item-height-m);
+    padding: 0px var(--spectrum-alias-item-padding-m);
+    border-width: var(--spectrum-actionbutton-border-size);
+    border-radius: var(--spectrum-alias-border-radius-regular);
+    border: 1px solid
+      var(
+        --spectrum-actionbutton-m-border-color,
+        var(--spectrum-alias-border-color)
+      );
+    align-items: center;
+  }
 </style>
diff --git a/packages/builder/src/components/integration/QueryViewerSidePanel/PreviewPanel.svelte b/packages/builder/src/components/integration/QueryViewerSidePanel/PreviewPanel.svelte
index f379ad18a1..3873669b63 100644
--- a/packages/builder/src/components/integration/QueryViewerSidePanel/PreviewPanel.svelte
+++ b/packages/builder/src/components/integration/QueryViewerSidePanel/PreviewPanel.svelte
@@ -23,7 +23,7 @@
 </script>
 
 <div class="table">
-  <Table {schema} data={rowsCopy} type="external" allowEditing={false} />
+  <Table {schema} data={rowsCopy} allowEditing={false} />
 </div>
 
 <style>
diff --git a/packages/builder/src/components/integration/RestQueryViewer.svelte b/packages/builder/src/components/integration/RestQueryViewer.svelte
index 254f65fcaf..e6913b0953 100644
--- a/packages/builder/src/components/integration/RestQueryViewer.svelte
+++ b/packages/builder/src/components/integration/RestQueryViewer.svelte
@@ -196,8 +196,36 @@
     }
   }
 
+  const validateQuery = async () => {
+    const forbiddenBindings = /{{\s?user(\.(\w|\$)*\s?|\s?)}}/g
+    const bindingError = new Error(
+      "'user' is a protected binding and cannot be used"
+    )
+
+    if (forbiddenBindings.test(url)) {
+      throw bindingError
+    }
+
+    if (forbiddenBindings.test(query.fields.requestBody ?? "")) {
+      throw bindingError
+    }
+
+    Object.values(requestBindings).forEach(bindingValue => {
+      if (forbiddenBindings.test(bindingValue)) {
+        throw bindingError
+      }
+    })
+
+    Object.values(query.fields.headers).forEach(headerValue => {
+      if (forbiddenBindings.test(headerValue)) {
+        throw bindingError
+      }
+    })
+  }
+
   async function runQuery() {
     try {
+      await validateQuery()
       response = await queries.preview(buildQuery())
       if (response.rows.length === 0) {
         notifications.info("Request did not return any data")
diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte
index a7d9584330..f9a40b09a6 100644
--- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte
@@ -516,6 +516,13 @@
     }
     return null
   }
+
+  const parseRole = user => {
+    if (user.isAdminOrGlobalBuilder) {
+      return Constants.Roles.CREATOR
+    }
+    return user.role
+  }
 </script>
 
 <svelte:window on:keydown={handleKeyDown} />
@@ -725,7 +732,7 @@
                     <RoleSelect
                       footer={getRoleFooter(user)}
                       placeholder={false}
-                      value={user.role}
+                      value={parseRole(user)}
                       allowRemove={user.role && !user.group}
                       allowPublic={false}
                       allowCreator={true}
@@ -744,7 +751,7 @@
                       autoWidth
                       align="right"
                       allowedRoles={user.isAdminOrGlobalBuilder
-                        ? [Constants.Roles.ADMIN]
+                        ? [Constants.Roles.CREATOR]
                         : null}
                     />
                   </div>
diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/_components/panels/Tables/CreateExternalTableModal.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/_components/panels/Tables/CreateExternalTableModal.svelte
index 664b5629d4..faa3611f5d 100644
--- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/_components/panels/Tables/CreateExternalTableModal.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/_components/panels/Tables/CreateExternalTableModal.svelte
@@ -2,6 +2,7 @@
   import { ModalContent, Body, Input, notifications } from "@budibase/bbui"
   import { tables, datasources } from "stores/backend"
   import { goto } from "@roxi/routify"
+  import { DB_TYPE_EXTERNAL } from "constants/backend"
 
   export let datasource
 
@@ -16,9 +17,10 @@
   function buildDefaultTable(tableName, datasourceId) {
     return {
       name: tableName,
-      type: "external",
+      type: "table",
       primary: ["id"],
       sourceId: datasourceId,
+      sourceType: DB_TYPE_EXTERNAL,
       schema: {
         id: {
           autocolumn: true,
diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/bb_internal/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/bb_internal/index.svelte
index 189141dd39..21214d8840 100644
--- a/packages/builder/src/pages/builder/app/[application]/data/datasource/bb_internal/index.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/bb_internal/index.svelte
@@ -5,7 +5,7 @@
   import { tables, datasources } from "stores/backend"
   import { goto } from "@roxi/routify"
   import { onMount } from "svelte"
-  import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
+  import { BUDIBASE_INTERNAL_DB_ID, DB_TYPE_EXTERNAL } from "constants/backend"
   import { TableNames } from "constants"
   import { store } from "builderStore"
 
@@ -14,7 +14,7 @@
   $: store.actions.websocket.selectResource(BUDIBASE_INTERNAL_DB_ID)
   $: internalTablesBySourceId = $tables.list.filter(
     table =>
-      table.type !== "external" &&
+      table.sourceType !== DB_TYPE_EXTERNAL &&
       table.sourceId === BUDIBASE_INTERNAL_DB_ID &&
       table._id !== TableNames.USERS
   )
diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/datasource_internal_bb_default/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/datasource_internal_bb_default/index.svelte
index f30ffea131..44cb8db3b0 100644
--- a/packages/builder/src/pages/builder/app/[application]/data/datasource/datasource_internal_bb_default/index.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/datasource_internal_bb_default/index.svelte
@@ -4,7 +4,7 @@
   import ICONS from "components/backend/DatasourceNavigator/icons"
   import { tables, datasources } from "stores/backend"
   import { goto } from "@roxi/routify"
-  import { DEFAULT_BB_DATASOURCE_ID } from "constants/backend"
+  import { DEFAULT_BB_DATASOURCE_ID, DB_TYPE_EXTERNAL } from "constants/backend"
   import { onMount } from "svelte"
   import { store } from "builderStore"
 
@@ -13,7 +13,8 @@
   $: store.actions.websocket.selectResource(DEFAULT_BB_DATASOURCE_ID)
   $: internalTablesBySourceId = $tables.list.filter(
     table =>
-      table.type !== "external" && table.sourceId === DEFAULT_BB_DATASOURCE_ID
+      table.sourceType !== DB_TYPE_EXTERNAL &&
+      table.sourceId === DEFAULT_BB_DATASOURCE_ID
   )
 
   onMount(() => {
diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte
index 414722a177..a68a782bed 100644
--- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte
@@ -53,7 +53,8 @@
   }
   .alert-wrap {
     display: flex;
-    width: 100%;
+    flex: 0 0 auto;
+    margin: -28px -40px 14px -40px;
   }
   .alert-wrap :global(> *) {
     flex: 1;
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsPanel.svelte
index 17eadb99bd..affa115ca2 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsPanel.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsPanel.svelte
@@ -91,7 +91,12 @@
         />
       {/if}
       {#if section == "styles"}
-        <DesignSection {componentInstance} {componentDefinition} {bindings} />
+        <DesignSection
+          {componentInstance}
+          {componentBindings}
+          {componentDefinition}
+          {bindings}
+        />
         <CustomStylesSection
           {componentInstance}
           {componentDefinition}
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte
index f833464d8c..6dc9078f2c 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte
@@ -16,18 +16,32 @@
   export let isScreen = false
   export let onUpdateSetting
   export let showSectionTitle = true
+  export let tag
 
-  $: sections = getSections(componentInstance, componentDefinition, isScreen)
+  $: sections = getSections(
+    componentInstance,
+    componentDefinition,
+    isScreen,
+    tag
+  )
 
-  const getSections = (instance, definition, isScreen) => {
+  const getSections = (instance, definition, isScreen, tag) => {
     const settings = definition?.settings ?? []
-    const generalSettings = settings.filter(setting => !setting.section)
-    const customSections = settings.filter(setting => setting.section)
+    const generalSettings = settings.filter(
+      setting => !setting.section && setting.tag === tag
+    )
+    const customSections = settings.filter(
+      setting => setting.section && setting.tag === tag
+    )
     let sections = [
-      {
-        name: "General",
-        settings: generalSettings,
-      },
+      ...(generalSettings?.length
+        ? [
+            {
+              name: "General",
+              settings: generalSettings,
+            },
+          ]
+        : []),
       ...(customSections || []),
     ]
 
@@ -132,7 +146,7 @@
         <div class="section-info">
           <InfoDisplay body={section.info} />
         </div>
-      {:else if idx === 0 && section.name === "General" && componentDefinition.info}
+      {:else if idx === 0 && section.name === "General" && componentDefinition?.info && !tag}
         <InfoDisplay
           title={componentDefinition.name}
           body={componentDefinition.info}
@@ -181,7 +195,7 @@
     </DetailSummary>
   {/if}
 {/each}
-{#if componentDefinition?.block}
+{#if componentDefinition?.block && !tag}
   <DetailSummary name="Eject" collapsible={false}>
     <EjectBlockButton />
   </DetailSummary>
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/DesignSection.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/DesignSection.svelte
index 444ded7e1f..def1fcf24b 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/DesignSection.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/DesignSection.svelte
@@ -1,10 +1,12 @@
 <script>
   import StyleSection from "./StyleSection.svelte"
   import * as ComponentStyles from "./componentStyles"
+  import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
 
   export let componentDefinition
   export let componentInstance
   export let bindings
+  export let componentBindings
 
   const getStyles = def => {
     if (!def?.styles?.length) {
@@ -22,6 +24,19 @@
   $: styles = getStyles(componentDefinition)
 </script>
 
+<!--
+  Load any general settings or sections tagged as "style"
+-->
+<ComponentSettingsSection
+  {componentInstance}
+  {componentDefinition}
+  isScreen={false}
+  showInstanceName={false}
+  {bindings}
+  {componentBindings}
+  tag="style"
+/>
+
 {#if styles?.length > 0}
   {#each styles as style}
     <StyleSection
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json
index 11a130490a..dd129be11e 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json
+++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json
@@ -36,6 +36,7 @@
       "heading",
       "text",
       "button",
+      "buttongroup",
       "tag",
       "spectrumcard",
       "cardstat",
diff --git a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte
index 9a96242b30..92ed3dcfc7 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte
@@ -12,6 +12,7 @@
   import { capitalise } from "helpers"
   import { goto } from "@roxi/routify"
 
+  let mode
   let pendingScreen
 
   // Modal refs
@@ -100,14 +101,15 @@
   }
 
   // Handler for NewScreenModal
-  export const show = mode => {
+  export const show = newMode => {
+    mode = newMode
     selectedTemplates = null
     blankScreenUrl = null
     screenMode = mode
     pendingScreen = null
     screenAccessRole = Roles.BASIC
 
-    if (mode === "table") {
+    if (mode === "table" || mode === "grid") {
       datasourceModal.show()
     } else if (mode === "blank") {
       let templates = getTemplates($tables.list)
@@ -123,6 +125,7 @@
 
   // Handler for DatasourceModal confirmation, move to screen access select
   const confirmScreenDatasources = async ({ templates }) => {
+    console.log(templates)
     selectedTemplates = templates
     screenAccessRoleModal.show()
   }
@@ -177,6 +180,7 @@
 
 <Modal bind:this={datasourceModal} autoFocus={false}>
   <DatasourceModal
+    {mode}
     onConfirm={confirmScreenDatasources}
     initialScreens={!selectedTemplates ? [] : [...selectedTemplates]}
   />
diff --git a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/DatasourceModal.svelte b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/DatasourceModal.svelte
index a866cd23d4..731c60a406 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/DatasourceModal.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/DatasourceModal.svelte
@@ -7,6 +7,7 @@
   import rowListScreen from "builderStore/store/screenTemplates/rowListScreen"
   import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte"
 
+  export let mode
   export let onCancel
   export let onConfirm
   export let initialScreens = []
@@ -24,7 +25,10 @@
         screen => screen.resourceId !== resourceId
       )
     } else {
-      selectedScreens = [...selectedScreens, rowListScreen([datasource])[0]]
+      selectedScreens = [
+        ...selectedScreens,
+        rowListScreen([datasource], mode)[0],
+      ]
     }
   }
 
diff --git a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/grid.png b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/grid.png
new file mode 100644
index 0000000000..c3efa30a67
Binary files /dev/null and b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/grid.png differ
diff --git a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/index.svelte b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/index.svelte
index b504940ca7..6b080747b0 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/index.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/index.svelte
@@ -3,6 +3,7 @@
   import CreationPage from "components/common/CreationPage.svelte"
   import blankImage from "./blank.png"
   import tableImage from "./table.png"
+  import gridImage from "./grid.png"
   import CreateScreenModal from "./CreateScreenModal.svelte"
   import { store } from "builderStore"
 
@@ -43,6 +44,16 @@
           <Body size="XS">View, edit and delete rows on a table</Body>
         </div>
       </div>
+
+      <div class="card" on:click={() => createScreenModal.show("grid")}>
+        <div class="image">
+          <img alt="" src={gridImage} />
+        </div>
+        <div class="text">
+          <Body size="S">Grid</Body>
+          <Body size="XS">View and manipulate rows on a grid</Body>
+        </div>
+      </div>
     </div>
   </CreationPage>
 </div>
diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte
index e1fc0ca7eb..ff8b749602 100644
--- a/packages/builder/src/pages/builder/portal/users/users/index.svelte
+++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte
@@ -3,7 +3,6 @@
     Heading,
     Body,
     Button,
-    ButtonGroup,
     Table,
     Layout,
     Modal,
@@ -46,6 +45,10 @@
     datasource: {
       type: "user",
     },
+    options: {
+      paginate: true,
+      limit: 10,
+    },
   })
 
   let groupsLoaded = !$licensing.groupsEnabled || $groups?.length
@@ -65,10 +68,12 @@
     { column: "role", component: RoleTableRenderer },
   ]
   let userData = []
+  let invitesLoaded = false
+  let pendingInvites = []
+  let parsedInvites = []
 
   $: isOwner = $auth.accountPortalAccess && $admin.cloud
   $: readonly = !sdk.users.isAdmin($auth.user) || $features.isScimEnabled
-
   $: debouncedUpdateFetch(searchEmail)
   $: schema = {
     email: {
@@ -88,16 +93,6 @@
       width: "1fr",
     },
   }
-
-  const getPendingSchema = tblSchema => {
-    if (!tblSchema) {
-      return {}
-    }
-    let pendingSchema = JSON.parse(JSON.stringify(tblSchema))
-    pendingSchema.email.displayName = "Pending Invites"
-    return pendingSchema
-  }
-
   $: pendingSchema = getPendingSchema(schema)
   $: userData = []
   $: inviteUsersResponse = { successful: [], unsuccessful: [] }
@@ -121,9 +116,15 @@
       }
     })
   }
-  let invitesLoaded = false
-  let pendingInvites = []
-  let parsedInvites = []
+
+  const getPendingSchema = tblSchema => {
+    if (!tblSchema) {
+      return {}
+    }
+    let pendingSchema = JSON.parse(JSON.stringify(tblSchema))
+    pendingSchema.email.displayName = "Pending Invites"
+    return pendingSchema
+  }
 
   const invitesToSchema = invites => {
     return invites.map(invite => {
@@ -143,7 +144,9 @@
   const updateFetch = email => {
     fetch.update({
       query: {
-        email,
+        string: {
+          email,
+        },
       },
     })
   }
@@ -296,7 +299,7 @@
   {/if}
   <div class="controls">
     {#if !readonly}
-      <ButtonGroup>
+      <div class="buttons">
         <Button
           disabled={readonly}
           on:click={$licensing.userLimitReached
@@ -315,7 +318,7 @@
         >
           Import
         </Button>
-      </ButtonGroup>
+      </div>
     {:else}
       <ScimBanner />
     {/if}
@@ -390,12 +393,15 @@
 </Modal>
 
 <style>
+  .buttons {
+    display: flex;
+    gap: 10px;
+  }
   .pagination {
     display: flex;
     flex-direction: row;
     justify-content: flex-end;
   }
-
   .controls {
     display: flex;
     flex-direction: row;
@@ -403,7 +409,6 @@
     align-items: center;
     gap: var(--spacing-xl);
   }
-
   .controls-right {
     display: flex;
     flex-direction: row;
@@ -411,7 +416,6 @@
     align-items: center;
     gap: var(--spacing-xl);
   }
-
   .controls-right :global(.spectrum-Search) {
     width: 200px;
   }
diff --git a/packages/client/manifest.json b/packages/client/manifest.json
index 7094ce88e9..eef1e50b7c 100644
--- a/packages/client/manifest.json
+++ b/packages/client/manifest.json
@@ -258,6 +258,186 @@
     "description": "Contains your app screens",
     "static": true
   },
+  "buttongroup": {
+    "name": "Button group",
+    "icon": "Button",
+    "hasChildren": false,
+    "settings": [
+      {
+        "section": true,
+        "name": "Buttons",
+        "settings": [
+          {
+            "type": "buttonConfiguration",
+            "key": "buttons",
+            "nested": true,
+            "defaultValue": [
+              {
+                "type": "cta",
+                "text": "Button 1"
+              },
+              {
+                "type": "primary",
+                "text": "Button 2"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "section": true,
+        "name": "Layout",
+        "settings": [
+          {
+            "type": "select",
+            "label": "Direction",
+            "key": "direction",
+            "showInBar": true,
+            "barStyle": "buttons",
+            "options": [
+              {
+                "label": "Column",
+                "value": "column",
+                "barIcon": "ViewColumn",
+                "barTitle": "Column layout"
+              },
+              {
+                "label": "Row",
+                "value": "row",
+                "barIcon": "ViewRow",
+                "barTitle": "Row layout"
+              }
+            ],
+            "defaultValue": "row"
+          },
+          {
+            "type": "select",
+            "label": "Horiz. align",
+            "key": "hAlign",
+            "showInBar": true,
+            "barStyle": "buttons",
+            "options": [
+              {
+                "label": "Left",
+                "value": "left",
+                "barIcon": "AlignLeft",
+                "barTitle": "Align left"
+              },
+              {
+                "label": "Center",
+                "value": "center",
+                "barIcon": "AlignCenter",
+                "barTitle": "Align center"
+              },
+              {
+                "label": "Right",
+                "value": "right",
+                "barIcon": "AlignRight",
+                "barTitle": "Align right"
+              },
+              {
+                "label": "Stretch",
+                "value": "stretch",
+                "barIcon": "MoveLeftRight",
+                "barTitle": "Align stretched horizontally"
+              }
+            ],
+            "defaultValue": "left"
+          },
+          {
+            "type": "select",
+            "label": "Vert. align",
+            "key": "vAlign",
+            "showInBar": true,
+            "barStyle": "buttons",
+            "options": [
+              {
+                "label": "Top",
+                "value": "top",
+                "barIcon": "AlignTop",
+                "barTitle": "Align top"
+              },
+              {
+                "label": "Middle",
+                "value": "middle",
+                "barIcon": "AlignMiddle",
+                "barTitle": "Align middle"
+              },
+              {
+                "label": "Bottom",
+                "value": "bottom",
+                "barIcon": "AlignBottom",
+                "barTitle": "Align bottom"
+              },
+              {
+                "label": "Stretch",
+                "value": "stretch",
+                "barIcon": "MoveUpDown",
+                "barTitle": "Align stretched vertically"
+              }
+            ],
+            "defaultValue": "top"
+          },
+          {
+            "type": "select",
+            "label": "Size",
+            "key": "size",
+            "showInBar": true,
+            "barStyle": "buttons",
+            "options": [
+              {
+                "label": "Shrink",
+                "value": "shrink",
+                "barIcon": "Minimize",
+                "barTitle": "Shrink container"
+              },
+              {
+                "label": "Grow",
+                "value": "grow",
+                "barIcon": "Maximize",
+                "barTitle": "Grow container"
+              }
+            ],
+            "defaultValue": "shrink"
+          },
+          {
+            "type": "select",
+            "label": "Gap",
+            "key": "gap",
+            "showInBar": true,
+            "barStyle": "picker",
+            "options": [
+              {
+                "label": "None",
+                "value": "N"
+              },
+              {
+                "label": "Small",
+                "value": "S"
+              },
+              {
+                "label": "Medium",
+                "value": "M"
+              },
+              {
+                "label": "Large",
+                "value": "L"
+              }
+            ],
+            "defaultValue": "M"
+          },
+          {
+            "type": "boolean",
+            "label": "Wrap",
+            "key": "wrap",
+            "showInBar": true,
+            "barIcon": "ModernGridView",
+            "barTitle": "Wrap"
+          }
+        ]
+      }
+    ]
+  },
   "button": {
     "name": "Button",
     "description": "A basic html button that is ready for styling",
@@ -2409,7 +2589,6 @@
         "key": "disabled",
         "defaultValue": false
       },
-
       {
         "type": "text",
         "label": "Initial form step",
@@ -5288,17 +5467,17 @@
     },
     "settings": [
       {
-        "type": "select",
+        "type": "table",
+        "label": "Data",
+        "key": "dataSource"
+      },
+      {
+        "type": "radio",
         "label": "Type",
         "key": "actionType",
         "options": ["Create", "Update", "View"],
         "defaultValue": "Create"
       },
-      {
-        "type": "table",
-        "label": "Data",
-        "key": "dataSource"
-      },
       {
         "type": "text",
         "label": "Title",
@@ -5329,13 +5508,37 @@
           },
           {
             "type": "text",
-            "label": "Empty text",
+            "label": "No rows found",
             "key": "noRowsMessage",
             "defaultValue": "We couldn't find a row to display",
             "nested": true
           }
         ]
       },
+      {
+        "section": true,
+        "name": "Fields",
+        "settings": [
+          {
+            "type": "fieldConfiguration",
+            "key": "fields",
+            "nested": true,
+            "resetOn": "dataSource",
+            "selectAllFields": true
+          },
+          {
+            "type": "boolean",
+            "label": "Disabled",
+            "key": "disabled",
+            "defaultValue": false,
+            "dependsOn": {
+              "setting": "actionType",
+              "value": "View",
+              "invert": true
+            }
+          }
+        ]
+      },
       {
         "section": true,
         "name": "Buttons",
@@ -5388,60 +5591,38 @@
         ]
       },
       {
-        "section": true,
-        "name": "Fields",
-        "settings": [
+        "tag": "style",
+        "type": "select",
+        "label": "Align labels",
+        "key": "labelPosition",
+        "defaultValue": "left",
+        "options": [
           {
-            "type": "select",
-            "label": "Align labels",
-            "key": "labelPosition",
-            "defaultValue": "left",
-            "options": [
-              {
-                "label": "Left",
-                "value": "left"
-              },
-              {
-                "label": "Above",
-                "value": "above"
-              }
-            ]
+            "label": "Left",
+            "value": "left"
           },
           {
-            "type": "select",
-            "label": "Size",
-            "key": "size",
-            "options": [
-              {
-                "label": "Medium",
-                "value": "spectrum--medium"
-              },
-              {
-                "label": "Large",
-                "value": "spectrum--large"
-              }
-            ],
-            "defaultValue": "spectrum--medium"
-          },
-          {
-            "type": "fieldConfiguration",
-            "key": "fields",
-            "nested": true,
-            "resetOn": "dataSource",
-            "selectAllFields": true
-          },
-          {
-            "type": "boolean",
-            "label": "Disabled",
-            "key": "disabled",
-            "defaultValue": false,
-            "dependsOn": {
-              "setting": "actionType",
-              "value": "View",
-              "invert": true
-            }
+            "label": "Above",
+            "value": "above"
           }
         ]
+      },
+      {
+        "tag": "style",
+        "type": "select",
+        "label": "Size",
+        "key": "size",
+        "options": [
+          {
+            "label": "Medium",
+            "value": "spectrum--medium"
+          },
+          {
+            "label": "Large",
+            "value": "spectrum--large"
+          }
+        ],
+        "defaultValue": "spectrum--medium"
       }
     ],
     "context": [
diff --git a/packages/client/src/components/app/ButtonGroup.svelte b/packages/client/src/components/app/ButtonGroup.svelte
new file mode 100644
index 0000000000..87b0990701
--- /dev/null
+++ b/packages/client/src/components/app/ButtonGroup.svelte
@@ -0,0 +1,37 @@
+<script>
+  import BlockComponent from "../BlockComponent.svelte"
+  import Block from "../Block.svelte"
+
+  export let buttons = []
+  export let direction
+  export let hAlign
+  export let vAlign
+  export let gap = "S"
+</script>
+
+<Block>
+  <BlockComponent
+    type="container"
+    props={{
+      direction,
+      hAlign,
+      vAlign,
+      gap,
+      wrap: true,
+    }}
+  >
+    {#each buttons as { text, type, quiet, disabled, onClick, size }}
+      <BlockComponent
+        type="button"
+        props={{
+          text: text || "Button",
+          onClick,
+          type,
+          quiet,
+          disabled,
+          size,
+        }}
+      />
+    {/each}
+  </BlockComponent>
+</Block>
diff --git a/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte b/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte
index e65d2cf90b..f7e9a0d2ed 100644
--- a/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte
+++ b/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte
@@ -220,15 +220,11 @@
               </BlockComponent>
             {/if}
           </BlockComponent>
-          {#if description}
-            <BlockComponent
-              type="text"
-              props={{ text: description }}
-              order={1}
-            />
-          {/if}
         </BlockComponent>
       {/if}
+      {#if description}
+        <BlockComponent type="text" props={{ text: description }} order={1} />
+      {/if}
       {#key fields}
         <BlockComponent type="fieldgroup" props={{ labelPosition }} order={1}>
           {#each fields as field, idx}
diff --git a/packages/client/src/components/app/index.js b/packages/client/src/components/app/index.js
index 060c15a857..97df3741e1 100644
--- a/packages/client/src/components/app/index.js
+++ b/packages/client/src/components/app/index.js
@@ -19,6 +19,7 @@ export { default as dataprovider } from "./DataProvider.svelte"
 export { default as divider } from "./Divider.svelte"
 export { default as screenslot } from "./ScreenSlot.svelte"
 export { default as button } from "./Button.svelte"
+export { default as buttongroup } from "./ButtonGroup.svelte"
 export { default as repeater } from "./Repeater.svelte"
 export { default as text } from "./Text.svelte"
 export { default as layout } from "./Layout.svelte"
diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js
index 18d6b3de3c..9b4640dbb4 100644
--- a/packages/client/src/utils/buttonActions.js
+++ b/packages/client/src/utils/buttonActions.js
@@ -103,7 +103,6 @@ const fetchRowHandler = async action => {
 
 const deleteRowHandler = async action => {
   const { tableId, rowId: rowConfig, notificationOverride } = action.parameters
-
   if (tableId && rowConfig) {
     try {
       let requestConfig
@@ -129,9 +128,11 @@ const deleteRowHandler = async action => {
         requestConfig = [parsedRowConfig]
       } else if (Array.isArray(parsedRowConfig)) {
         requestConfig = parsedRowConfig
+      } else if (Number.isInteger(parsedRowConfig)) {
+        requestConfig = [String(parsedRowConfig)]
       }
 
-      if (!requestConfig.length) {
+      if (!requestConfig && !parsedRowConfig) {
         notificationStore.actions.warning("No valid rows were supplied")
         return false
       }
diff --git a/packages/frontend-core/src/api/tables.js b/packages/frontend-core/src/api/tables.js
index a08e35d3d8..34d2371e1a 100644
--- a/packages/frontend-core/src/api/tables.js
+++ b/packages/frontend-core/src/api/tables.js
@@ -140,4 +140,13 @@ export const buildTableEndpoints = API => ({
       },
     })
   },
+  migrateColumn: async ({ tableId, oldColumn, newColumn }) => {
+    return await API.post({
+      url: `/api/tables/${tableId}/migrate`,
+      body: {
+        oldColumn,
+        newColumn,
+      },
+    })
+  },
 })
diff --git a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte
index a27c31bbe5..fc0001d55e 100644
--- a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte
+++ b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte
@@ -55,7 +55,7 @@
     try {
       return await API.uploadBuilderAttachment(data)
     } catch (error) {
-      $notifications.error("Failed to upload attachment")
+      $notifications.error(error.message || "Failed to upload attachment")
       return []
     }
   }
diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte
index f9cdef3756..cdaf28978a 100644
--- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte
+++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte
@@ -21,6 +21,7 @@
   export let invertX = false
   export let invertY = false
   export let contentLines = 1
+  export let hidden = false
 
   const emptyError = writable(null)
 
@@ -78,6 +79,7 @@
   {focused}
   {selectedUser}
   {readonly}
+  {hidden}
   error={$error}
   on:click={() => focusedCellId.set(cellId)}
   on:contextmenu={e => menu.actions.open(cellId, e)}
diff --git a/packages/frontend-core/src/components/grid/cells/GridCell.svelte b/packages/frontend-core/src/components/grid/cells/GridCell.svelte
index fe4bd70ba4..dcc76b9c75 100644
--- a/packages/frontend-core/src/components/grid/cells/GridCell.svelte
+++ b/packages/frontend-core/src/components/grid/cells/GridCell.svelte
@@ -10,6 +10,7 @@
   export let defaultHeight = false
   export let center = false
   export let readonly = false
+  export let hidden = false
 
   $: style = getStyle(width, selectedUser)
 
@@ -30,6 +31,7 @@
   class:error
   class:center
   class:readonly
+  class:hidden
   class:default-height={defaultHeight}
   class:selected-other={selectedUser != null}
   class:alt={rowIdx % 2 === 1}
@@ -81,6 +83,9 @@
   .cell.center {
     align-items: center;
   }
+  .cell.hidden {
+    content-visibility: hidden;
+  }
 
   /* Cell border */
   .cell.focused:after,
diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte
index a7b232c487..a0de8a5ef3 100644
--- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte
+++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte
@@ -1,11 +1,20 @@
 <script>
   import { getContext, onMount, tick } from "svelte"
   import { canBeDisplayColumn, canBeSortColumn } from "@budibase/shared-core"
-  import { Icon, Popover, Menu, MenuItem, clickOutside } from "@budibase/bbui"
+  import {
+    Icon,
+    Popover,
+    Menu,
+    MenuItem,
+    clickOutside,
+    Modal,
+  } from "@budibase/bbui"
   import GridCell from "./GridCell.svelte"
   import { getColumnIcon } from "../lib/utils"
+  import MigrationModal from "../controls/MigrationModal.svelte"
   import { debounce } from "../../../utils/utils"
   import { FieldType, FormulaTypes } from "@budibase/types"
+  import { TableNames } from "../../../constants"
 
   export let column
   export let idx
@@ -17,7 +26,7 @@
     isResizing,
     rand,
     sort,
-    renderedColumns,
+    visibleColumns,
     dispatch,
     subscribe,
     config,
@@ -45,12 +54,13 @@
   let editIsOpen = false
   let timeout
   let popover
+  let migrationModal
   let searchValue
   let input
 
   $: sortedBy = column.name === $sort.column
   $: canMoveLeft = orderable && idx > 0
-  $: canMoveRight = orderable && idx < $renderedColumns.length - 1
+  $: canMoveRight = orderable && idx < $visibleColumns.length - 1
   $: sortingLabels = getSortingLabels(column.schema?.type)
   $: searchable = isColumnSearchable(column)
   $: resetSearchValue(column.name)
@@ -189,6 +199,11 @@
     })
   }
 
+  const openMigrationModal = () => {
+    migrationModal.show()
+    open = false
+  }
+
   const startSearching = async () => {
     $focusedCellId = null
     searchValue = ""
@@ -224,6 +239,10 @@
   onMount(() => subscribe("close-edit-column", cancelEdit))
 </script>
 
+<Modal bind:this={migrationModal}>
+  <MigrationModal {column} />
+</Modal>
+
 <div
   class="header-cell"
   class:open
@@ -363,6 +382,11 @@
       >
         Hide column
       </MenuItem>
+      {#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS}
+        <MenuItem icon="User" on:click={openMigrationModal}>
+          Migrate to user column
+        </MenuItem>
+      {/if}
     </Menu>
   {/if}
 </Popover>
diff --git a/packages/frontend-core/src/components/grid/controls/MigrationModal.svelte b/packages/frontend-core/src/components/grid/controls/MigrationModal.svelte
new file mode 100644
index 0000000000..ecef009fe0
--- /dev/null
+++ b/packages/frontend-core/src/components/grid/controls/MigrationModal.svelte
@@ -0,0 +1,73 @@
+<script>
+  import {
+    ModalContent,
+    notifications,
+    Input,
+    InlineAlert,
+  } from "@budibase/bbui"
+  import { getContext } from "svelte"
+  import { ValidColumnNameRegex } from "@budibase/shared-core"
+  import { FieldSubtype, FieldType, RelationshipType } from "@budibase/types"
+
+  const { API, definition, rows } = getContext("grid")
+
+  export let column
+
+  let newColumnName = `${column.schema.name} migrated`
+  $: error = checkNewColumnName(newColumnName)
+
+  const checkNewColumnName = newColumnName => {
+    if (newColumnName === "") {
+      return "Column name can't be empty."
+    }
+    if (newColumnName in $definition.schema) {
+      return "New column name can't be the same as an existing column name."
+    }
+    if (newColumnName.match(ValidColumnNameRegex) === null) {
+      return "Illegal character; must be alpha-numeric."
+    }
+  }
+
+  const migrateUserColumn = async () => {
+    let subtype = FieldSubtype.USERS
+    if (column.schema.relationshipType === RelationshipType.ONE_TO_MANY) {
+      subtype = FieldSubtype.USER
+    }
+
+    try {
+      await API.migrateColumn({
+        tableId: $definition._id,
+        oldColumn: column.schema,
+        newColumn: {
+          name: newColumnName,
+          type: FieldType.BB_REFERENCE,
+          subtype,
+        },
+      })
+      notifications.success("Column migrated")
+    } catch (e) {
+      notifications.error(`Failed to migrate: ${e.message}`)
+    }
+    await rows.actions.refreshData()
+  }
+</script>
+
+<ModalContent
+  title="Migrate column"
+  confirmText="Continue"
+  cancelText="Cancel"
+  onConfirm={migrateUserColumn}
+  disabled={error !== undefined}
+  size="M"
+>
+  This operation will kick off a migration of the column "{column.schema.name}"
+  to a new column, with the name provided - this operation may take a moment to
+  complete.
+
+  <InlineAlert
+    type="error"
+    header="Are you sure?"
+    message="This will leave bindings which utilised the user relationship column in a state where they will need to be updated to use the new column instead."
+  />
+  <Input bind:value={newColumnName} label="New column name" {error} />
+</ModalContent>
diff --git a/packages/frontend-core/src/components/grid/layout/GridBody.svelte b/packages/frontend-core/src/components/grid/layout/GridBody.svelte
index 762985a4db..0bb2a51fb4 100644
--- a/packages/frontend-core/src/components/grid/layout/GridBody.svelte
+++ b/packages/frontend-core/src/components/grid/layout/GridBody.svelte
@@ -7,7 +7,7 @@
   const {
     bounds,
     renderedRows,
-    renderedColumns,
+    visibleColumns,
     rowVerticalInversionIndex,
     hoveredRowId,
     dispatch,
@@ -17,7 +17,7 @@
 
   let body
 
-  $: renderColumnsWidth = $renderedColumns.reduce(
+  $: columnsWidth = $visibleColumns.reduce(
     (total, col) => (total += col.width),
     0
   )
@@ -47,7 +47,7 @@
       <div
         class="blank"
         class:highlighted={$hoveredRowId === BlankRowID}
-        style="width:{renderColumnsWidth}px"
+        style="width:{columnsWidth}px"
         on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)}
         on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
         on:click={() => dispatch("add-row-inline")}
diff --git a/packages/frontend-core/src/components/grid/layout/GridRow.svelte b/packages/frontend-core/src/components/grid/layout/GridRow.svelte
index 4754d493bf..4a0db40ee8 100644
--- a/packages/frontend-core/src/components/grid/layout/GridRow.svelte
+++ b/packages/frontend-core/src/components/grid/layout/GridRow.svelte
@@ -10,7 +10,7 @@
     focusedCellId,
     reorder,
     selectedRows,
-    renderedColumns,
+    visibleColumns,
     hoveredRowId,
     selectedCellMap,
     focusedRow,
@@ -19,6 +19,7 @@
     isDragging,
     dispatch,
     rows,
+    columnRenderMap,
   } = getContext("grid")
 
   $: rowSelected = !!$selectedRows[row._id]
@@ -34,7 +35,7 @@
   on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
   on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
 >
-  {#each $renderedColumns as column, columnIdx (column.name)}
+  {#each $visibleColumns as column, columnIdx}
     {@const cellId = `${row._id}-${column.name}`}
     <DataCell
       {cellId}
@@ -51,6 +52,7 @@
       selectedUser={$selectedCellMap[cellId]}
       width={column.width}
       contentLines={$contentLines}
+      hidden={!$columnRenderMap[column.name]}
     />
   {/each}
 </div>
diff --git a/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte b/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte
index 05bd261721..2a131809a9 100644
--- a/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte
+++ b/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte
@@ -11,7 +11,6 @@
     maxScrollLeft,
     bounds,
     hoveredRowId,
-    hiddenColumnsWidth,
     menu,
   } = getContext("grid")
 
@@ -23,10 +22,10 @@
   let initialTouchX
   let initialTouchY
 
-  $: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth)
+  $: style = generateStyle($scroll, $rowHeight)
 
-  const generateStyle = (scroll, rowHeight, hiddenWidths) => {
-    const offsetX = scrollHorizontally ? -1 * scroll.left + hiddenWidths : 0
+  const generateStyle = (scroll, rowHeight) => {
+    const offsetX = scrollHorizontally ? -1 * scroll.left : 0
     const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0
     return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
   }
diff --git a/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte b/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte
index 97b7d054f3..b8655b98b3 100644
--- a/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte
+++ b/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte
@@ -5,14 +5,14 @@
   import HeaderCell from "../cells/HeaderCell.svelte"
   import { TempTooltip, TooltipType } from "@budibase/bbui"
 
-  const { renderedColumns, config, hasNonAutoColumn, datasource, loading } =
+  const { visibleColumns, config, hasNonAutoColumn, datasource, loading } =
     getContext("grid")
 </script>
 
 <div class="header">
   <GridScrollWrapper scrollHorizontally>
     <div class="row">
-      {#each $renderedColumns as column, idx}
+      {#each $visibleColumns as column, idx}
         <HeaderCell {column} {idx}>
           <slot name="edit-column" />
         </HeaderCell>
diff --git a/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte b/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte
index d131df26e5..46e9b40fb6 100644
--- a/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte
+++ b/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte
@@ -2,17 +2,16 @@
   import { getContext, onMount } from "svelte"
   import { Icon, Popover, clickOutside } from "@budibase/bbui"
 
-  const { renderedColumns, scroll, hiddenColumnsWidth, width, subscribe } =
-    getContext("grid")
+  const { visibleColumns, scroll, width, subscribe } = getContext("grid")
 
   let anchor
   let open = false
 
-  $: columnsWidth = $renderedColumns.reduce(
+  $: columnsWidth = $visibleColumns.reduce(
     (total, col) => (total += col.width),
     0
   )
-  $: end = $hiddenColumnsWidth + columnsWidth - 1 - $scroll.left
+  $: end = columnsWidth - 1 - $scroll.left
   $: left = Math.min($width - 40, end)
 
   const close = () => {
@@ -34,7 +33,7 @@
 <Popover
   bind:open
   {anchor}
-  align={$renderedColumns.length ? "right" : "left"}
+  align={$visibleColumns.length ? "right" : "left"}
   offset={0}
   popoverTarget={document.getElementById(`add-column-button`)}
   customZindex={100}
diff --git a/packages/frontend-core/src/components/grid/layout/NewRow.svelte b/packages/frontend-core/src/components/grid/layout/NewRow.svelte
index bbb6a6a6c5..26706b701c 100644
--- a/packages/frontend-core/src/components/grid/layout/NewRow.svelte
+++ b/packages/frontend-core/src/components/grid/layout/NewRow.svelte
@@ -20,7 +20,7 @@
     datasource,
     subscribe,
     renderedRows,
-    renderedColumns,
+    visibleColumns,
     rowHeight,
     hasNextPage,
     maxScrollTop,
@@ -31,6 +31,7 @@
     refreshing,
     config,
     filter,
+    columnRenderMap,
   } = getContext("grid")
 
   let visible = false
@@ -38,7 +39,7 @@
   let newRow
   let offset = 0
 
-  $: firstColumn = $stickyColumn || $renderedColumns[0]
+  $: firstColumn = $stickyColumn || $visibleColumns[0]
   $: width = GutterWidth + ($stickyColumn?.width || 0)
   $: $datasource, (visible = false)
   $: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows)
@@ -211,29 +212,28 @@
     <div class="normal-columns" transition:fade|local={{ duration: 130 }}>
       <GridScrollWrapper scrollHorizontally attachHandlers>
         <div class="row">
-          {#each $renderedColumns as column, columnIdx}
+          {#each $visibleColumns as column, columnIdx}
             {@const cellId = `new-${column.name}`}
-            {#key cellId}
-              <DataCell
-                {cellId}
-                {column}
-                {updateValue}
-                rowFocused
-                row={newRow}
-                focused={$focusedCellId === cellId}
-                width={column.width}
-                topRow={offset === 0}
-                invertX={columnIdx >= $columnHorizontalInversionIndex}
-                {invertY}
-              >
-                {#if column?.schema?.autocolumn}
-                  <div class="readonly-overlay">Can't edit auto column</div>
-                {/if}
-                {#if isAdding}
-                  <div in:fade={{ duration: 130 }} class="loading-overlay" />
-                {/if}
-              </DataCell>
-            {/key}
+            <DataCell
+              {cellId}
+              {column}
+              {updateValue}
+              rowFocused
+              row={newRow}
+              focused={$focusedCellId === cellId}
+              width={column.width}
+              topRow={offset === 0}
+              invertX={columnIdx >= $columnHorizontalInversionIndex}
+              {invertY}
+              hidden={!$columnRenderMap[column.name]}
+            >
+              {#if column?.schema?.autocolumn}
+                <div class="readonly-overlay">Can't edit auto column</div>
+              {/if}
+              {#if isAdding}
+                <div in:fade={{ duration: 130 }} class="loading-overlay" />
+              {/if}
+            </DataCell>
           {/each}
         </div>
       </GridScrollWrapper>
diff --git a/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte
index 13e158b300..9e584ab610 100644
--- a/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte
+++ b/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte
@@ -2,7 +2,7 @@
   import { getContext } from "svelte"
   import { GutterWidth } from "../lib/constants"
 
-  const { resize, renderedColumns, stickyColumn, isReordering, scrollLeft } =
+  const { resize, visibleColumns, stickyColumn, isReordering, scrollLeft } =
     getContext("grid")
 
   $: offset = GutterWidth + ($stickyColumn?.width || 0)
@@ -26,7 +26,7 @@
       <div class="resize-indicator" />
     </div>
   {/if}
-  {#each $renderedColumns as column}
+  {#each $visibleColumns as column}
     <div
       class="resize-slider"
       class:visible={activeColumn === column.name}
diff --git a/packages/frontend-core/src/components/grid/stores/viewport.js b/packages/frontend-core/src/components/grid/stores/viewport.js
index 6c0c4708b9..8df8acd0f4 100644
--- a/packages/frontend-core/src/components/grid/stores/viewport.js
+++ b/packages/frontend-core/src/components/grid/stores/viewport.js
@@ -1,4 +1,4 @@
-import { derived, get } from "svelte/store"
+import { derived } from "svelte/store"
 import {
   MaxCellRenderHeight,
   MaxCellRenderWidthOverflow,
@@ -50,12 +50,11 @@ export const deriveStores = context => {
     const interval = MinColumnWidth
     return Math.round($scrollLeft / interval) * interval
   })
-  const renderedColumns = derived(
+  const columnRenderMap = derived(
     [visibleColumns, scrollLeftRounded, width],
-    ([$visibleColumns, $scrollLeft, $width], set) => {
+    ([$visibleColumns, $scrollLeft, $width]) => {
       if (!$visibleColumns.length) {
-        set([])
-        return
+        return {}
       }
       let startColIdx = 0
       let rightEdge = $visibleColumns[0].width
@@ -75,34 +74,16 @@ export const deriveStores = context => {
         leftEdge += $visibleColumns[endColIdx].width
         endColIdx++
       }
-      // Render an additional column on either side to account for
-      // debounce column updates based on scroll position
-      const next = $visibleColumns.slice(
-        Math.max(0, startColIdx - 1),
-        endColIdx + 1
-      )
-      const current = get(renderedColumns)
-      if (JSON.stringify(next) !== JSON.stringify(current)) {
-        set(next)
-      }
-    }
-  )
 
-  const hiddenColumnsWidth = derived(
-    [renderedColumns, visibleColumns],
-    ([$renderedColumns, $visibleColumns]) => {
-      const idx = $visibleColumns.findIndex(
-        col => col.name === $renderedColumns[0]?.name
-      )
-      let width = 0
-      if (idx > 0) {
-        for (let i = 0; i < idx; i++) {
-          width += $visibleColumns[i].width
-        }
-      }
-      return width
-    },
-    0
+      // Only update the store if different
+      let next = {}
+      $visibleColumns
+        .slice(Math.max(0, startColIdx), endColIdx)
+        .forEach(col => {
+          next[col.name] = true
+        })
+      return next
+    }
   )
 
   // Determine the row index at which we should start vertically inverting cell
@@ -130,12 +111,12 @@ export const deriveStores = context => {
   // Determine the column index at which we should start horizontally inverting
   // cell dropdowns
   const columnHorizontalInversionIndex = derived(
-    [renderedColumns, scrollLeft, width],
-    ([$renderedColumns, $scrollLeft, $width]) => {
+    [visibleColumns, scrollLeft, width],
+    ([$visibleColumns, $scrollLeft, $width]) => {
       const cutoff = $width + $scrollLeft - ScrollBarSize * 3
-      let inversionIdx = $renderedColumns.length
-      for (let i = $renderedColumns.length - 1; i >= 0; i--, inversionIdx--) {
-        const rightEdge = $renderedColumns[i].left + $renderedColumns[i].width
+      let inversionIdx = $visibleColumns.length
+      for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) {
+        const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width
         if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) {
           break
         }
@@ -148,8 +129,7 @@ export const deriveStores = context => {
     scrolledRowCount,
     visualRowCapacity,
     renderedRows,
-    renderedColumns,
-    hiddenColumnsWidth,
+    columnRenderMap,
     rowVerticalInversionIndex,
     columnHorizontalInversionIndex,
   }
diff --git a/packages/pro b/packages/pro
index d24c0dc3a3..3820c0c93a 160000
--- a/packages/pro
+++ b/packages/pro
@@ -1 +1 @@
-Subproject commit d24c0dc3a30014cbe61860252aa48104cad36376
+Subproject commit 3820c0c93a3e448e10a60a9feb5396844b537ca8
diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile
index e1b3b208c7..ea4c5b217a 100644
--- a/packages/server/Dockerfile
+++ b/packages/server/Dockerfile
@@ -38,7 +38,7 @@ RUN apt update && apt upgrade -y \
 
 COPY package.json .
 COPY dist/yarn.lock .
-RUN yarn install --production=true \
+RUN yarn install --production=true --network-timeout 1000000 \
     # Remove unneeded data from file system to reduce image size
     && yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python \
     && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp
diff --git a/packages/server/Dockerfile.v2 b/packages/server/Dockerfile.v2
index 881c21299e..f737570fcd 100644
--- a/packages/server/Dockerfile.v2
+++ b/packages/server/Dockerfile.v2
@@ -44,7 +44,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
 WORKDIR /string-templates
 COPY packages/string-templates/package.json package.json
 RUN ../scripts/removeWorkspaceDependencies.sh package.json
-RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true
+RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
 COPY packages/string-templates .
 
 
@@ -57,7 +57,7 @@ COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.
 RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
 RUN ./scripts/removeWorkspaceDependencies.sh  package.json
 
-RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true \
+RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 \
     # Remove unneeded data from file system to reduce image size
     && yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python jq \
     && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp
diff --git a/packages/server/__mocks__/aws-sdk.ts b/packages/server/__mocks__/aws-sdk.ts
index 8a66f0e213..fa6d099f56 100644
--- a/packages/server/__mocks__/aws-sdk.ts
+++ b/packages/server/__mocks__/aws-sdk.ts
@@ -70,6 +70,13 @@ module AwsMock {
         Contents: {},
       })
     )
+
+    // @ts-ignore
+    this.getObject = jest.fn(
+      response({
+        Body: "",
+      })
+    )
   }
 
   aws.DynamoDB = { DocumentClient }
diff --git a/packages/server/package.json b/packages/server/package.json
index 4a858f3be9..c845f7889d 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -18,7 +18,6 @@
     "test": "bash scripts/test.sh",
     "test:memory": "jest --maxWorkers=2 --logHeapUsage --forceExit",
     "test:watch": "jest --watch",
-    "build:docker": "yarn build && docker build . -t app-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION",
     "run:docker": "node dist/index.js",
     "run:docker:cluster": "pm2-runtime start pm2.config.js",
     "dev:stack:up": "node scripts/dev/manage.js up",
diff --git a/packages/server/scripts/integrations/postgres/docker-compose.yml b/packages/server/scripts/integrations/postgres/docker-compose.yml
index 88efd0301d..0e8e30ecdb 100644
--- a/packages/server/scripts/integrations/postgres/docker-compose.yml
+++ b/packages/server/scripts/integrations/postgres/docker-compose.yml
@@ -2,7 +2,7 @@ version: "3.8"
 services:
   db:
     container_name: postgres
-    image: postgres:15
+    image: postgres:15-bullseye
     restart: unless-stopped
     environment:
       POSTGRES_USER: root
diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts
index 4afd7b23f9..4e4c66858e 100644
--- a/packages/server/src/api/controllers/application.ts
+++ b/packages/server/src/api/controllers/application.ts
@@ -32,11 +32,8 @@ import {
   tenancy,
   users,
 } from "@budibase/backend-core"
-import { USERS_TABLE_SCHEMA } from "../../constants"
-import {
-  buildDefaultDocs,
-  DEFAULT_BB_DATASOURCE_ID,
-} from "../../db/defaultData/datasource_bb_default"
+import { USERS_TABLE_SCHEMA, DEFAULT_BB_DATASOURCE_ID } from "../../constants"
+import { buildDefaultDocs } from "../../db/defaultData/datasource_bb_default"
 import { removeAppFromUserRoles } from "../../utilities/workerRequests"
 import { stringToReadStream } from "../../utilities"
 import { doesUserHaveLock } from "../../utilities/redis"
diff --git a/packages/server/src/api/controllers/datasource.ts b/packages/server/src/api/controllers/datasource.ts
index b50c2464f0..5d024d51b6 100644
--- a/packages/server/src/api/controllers/datasource.ts
+++ b/packages/server/src/api/controllers/datasource.ts
@@ -12,7 +12,6 @@ import {
   CreateDatasourceResponse,
   Datasource,
   DatasourcePlus,
-  ExternalTable,
   FetchDatasourceInfoRequest,
   FetchDatasourceInfoResponse,
   IntegrationBase,
@@ -59,7 +58,7 @@ async function buildSchemaHelper(datasource: Datasource): Promise<Schema> {
   const connector = (await getConnector(datasource)) as DatasourcePlus
   return await connector.buildSchema(
     datasource._id!,
-    datasource.entities! as Record<string, ExternalTable>
+    datasource.entities! as Record<string, Table>
   )
 }
 
diff --git a/packages/server/src/api/controllers/public/utils.ts b/packages/server/src/api/controllers/public/utils.ts
index 1272fcb36a..1d67b49e0d 100644
--- a/packages/server/src/api/controllers/public/utils.ts
+++ b/packages/server/src/api/controllers/public/utils.ts
@@ -1,12 +1,12 @@
 import { context } from "@budibase/backend-core"
-import { isExternalTable } from "../../../integrations/utils"
+import { isExternalTableID } from "../../../integrations/utils"
 import { APP_PREFIX, DocumentType } from "../../../db/utils"
 
 export async function addRev(
   body: { _id?: string; _rev?: string },
   tableId?: string
 ) {
-  if (!body._id || (tableId && isExternalTable(tableId))) {
+  if (!body._id || (tableId && isExternalTableID(tableId))) {
     return body
   }
   let id = body._id
diff --git a/packages/server/src/api/controllers/role.ts b/packages/server/src/api/controllers/role.ts
index ed23009706..3697bbe925 100644
--- a/packages/server/src/api/controllers/role.ts
+++ b/packages/server/src/api/controllers/role.ts
@@ -1,4 +1,10 @@
-import { context, db as dbCore, events, roles } from "@budibase/backend-core"
+import {
+  context,
+  db as dbCore,
+  events,
+  roles,
+  Header,
+} from "@budibase/backend-core"
 import { getUserMetadataParams, InternalTables } from "../../db/utils"
 import { Database, Role, UserCtx, UserRoles } from "@budibase/types"
 import { sdk as sharedSdk } from "@budibase/shared-core"
@@ -143,4 +149,20 @@ export async function accessible(ctx: UserCtx) {
   } else {
     ctx.body = await roles.getUserRoleIdHierarchy(roleId!)
   }
+
+  // If a custom role is provided in the header, filter out higher level roles
+  const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string
+  if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) {
+    const inherits = (await roles.getRole(roleHeader))?.inherits
+    const orderedRoles = ctx.body.reverse()
+    let filteredRoles = [roleHeader]
+    for (let role of orderedRoles) {
+      filteredRoles = [role, ...filteredRoles]
+      if (role === inherits) {
+        break
+      }
+    }
+    filteredRoles.pop()
+    ctx.body = [roleHeader, ...filteredRoles]
+  }
 }
diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts
index 0ccbf5cacf..1a6747a085 100644
--- a/packages/server/src/api/controllers/row/index.ts
+++ b/packages/server/src/api/controllers/row/index.ts
@@ -1,7 +1,7 @@
 import { quotas } from "@budibase/pro"
 import * as internal from "./internal"
 import * as external from "./external"
-import { isExternalTable } from "../../../integrations/utils"
+import { isExternalTableID } from "../../../integrations/utils"
 import {
   Ctx,
   UserCtx,
@@ -30,7 +30,7 @@ import { Format } from "../view/exporters"
 export * as views from "./views"
 
 function pickApi(tableId: any) {
-  if (isExternalTable(tableId)) {
+  if (isExternalTableID(tableId)) {
     return external
   }
   return internal
@@ -227,7 +227,7 @@ export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
 export async function validate(ctx: Ctx<Row, ValidateResponse>) {
   const tableId = utils.getTableId(ctx)
   // external tables are hard to validate currently
-  if (isExternalTable(tableId)) {
+  if (isExternalTableID(tableId)) {
     ctx.body = { valid: true, errors: {} }
   } else {
     ctx.body = await sdk.rows.utils.validate({
diff --git a/packages/server/src/api/controllers/static/index.ts b/packages/server/src/api/controllers/static/index.ts
index 984cb16c06..8fbc0db910 100644
--- a/packages/server/src/api/controllers/static/index.ts
+++ b/packages/server/src/api/controllers/static/index.ts
@@ -1,3 +1,5 @@
+import { ValidFileExtensions } from "@budibase/shared-core"
+
 require("svelte/register")
 
 import { join } from "../../../utilities/centralPath"
@@ -11,34 +13,21 @@ import {
 } from "../../../utilities/fileSystem"
 import env from "../../../environment"
 import { DocumentType } from "../../../db/utils"
-import { context, objectStore, utils, configs } from "@budibase/backend-core"
+import {
+  context,
+  objectStore,
+  utils,
+  configs,
+  BadRequestError,
+} from "@budibase/backend-core"
 import AWS from "aws-sdk"
 import fs from "fs"
 import sdk from "../../../sdk"
 import * as pro from "@budibase/pro"
-import { App, Ctx } from "@budibase/types"
+import { App, Ctx, ProcessAttachmentResponse, Upload } from "@budibase/types"
 
 const send = require("koa-send")
 
-async function prepareUpload({ s3Key, bucket, metadata, file }: any) {
-  const response = await objectStore.upload({
-    bucket,
-    metadata,
-    filename: s3Key,
-    path: file.path,
-    type: file.type,
-  })
-
-  // don't store a URL, work this out on the way out as the URL could change
-  return {
-    size: file.size,
-    name: file.name,
-    url: objectStore.getAppFileUrl(s3Key),
-    extension: [...file.name.split(".")].pop(),
-    key: response.Key,
-  }
-}
-
 export const toggleBetaUiFeature = async function (ctx: Ctx) {
   const cookieName = `beta:${ctx.params.feature}`
 
@@ -72,23 +61,58 @@ export const serveBuilder = async function (ctx: Ctx) {
   await send(ctx, ctx.file, { root: builderPath })
 }
 
-export const uploadFile = async function (ctx: Ctx) {
+export const uploadFile = async function (
+  ctx: Ctx<{}, ProcessAttachmentResponse>
+) {
   const file = ctx.request?.files?.file
+  if (!file) {
+    throw new BadRequestError("No file provided")
+  }
+
   let files = file && Array.isArray(file) ? Array.from(file) : [file]
 
-  const uploads = files.map(async (file: any) => {
-    const fileExtension = [...file.name.split(".")].pop()
-    // filenames converted to UUIDs so they are unique
-    const processedFileName = `${uuid.v4()}.${fileExtension}`
+  ctx.body = await Promise.all(
+    files.map(async file => {
+      if (!file.name) {
+        throw new BadRequestError(
+          "Attempted to upload a file without a filename"
+        )
+      }
 
-    return prepareUpload({
-      file,
-      s3Key: `${context.getProdAppId()}/attachments/${processedFileName}`,
-      bucket: ObjectStoreBuckets.APPS,
+      const extension = [...file.name.split(".")].pop()
+      if (!extension) {
+        throw new BadRequestError(
+          `File "${file.name}" has no extension, an extension is required to upload a file`
+        )
+      }
+
+      if (!env.SELF_HOSTED && !ValidFileExtensions.includes(extension)) {
+        throw new BadRequestError(
+          `File "${file.name}" has an invalid extension: "${extension}"`
+        )
+      }
+
+      // filenames converted to UUIDs so they are unique
+      const processedFileName = `${uuid.v4()}.${extension}`
+
+      const s3Key = `${context.getProdAppId()}/attachments/${processedFileName}`
+
+      const response = await objectStore.upload({
+        bucket: ObjectStoreBuckets.APPS,
+        filename: s3Key,
+        path: file.path,
+        type: file.type,
+      })
+
+      return {
+        size: file.size,
+        name: file.name,
+        url: objectStore.getAppFileUrl(s3Key),
+        extension,
+        key: response.Key,
+      }
     })
-  })
-
-  ctx.body = await Promise.all(uploads)
+  )
 }
 
 export const deleteObjects = async function (ctx: Ctx) {
diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts
index afb2a9d12d..db2bd672d0 100644
--- a/packages/server/src/api/controllers/table/index.ts
+++ b/packages/server/src/api/controllers/table/index.ts
@@ -5,18 +5,27 @@ import {
   isSchema,
   validate as validateSchema,
 } from "../../../utilities/schema"
-import { isExternalTable, isSQL } from "../../../integrations/utils"
+import {
+  isExternalTable,
+  isExternalTableID,
+  isSQL,
+} from "../../../integrations/utils"
 import { events } from "@budibase/backend-core"
 import {
   BulkImportRequest,
   BulkImportResponse,
+  DocumentType,
   FetchTablesResponse,
+  MigrateRequest,
+  MigrateResponse,
+  Row,
   SaveTableRequest,
   SaveTableResponse,
   Table,
   TableResponse,
+  TableSourceType,
   UserCtx,
-  Row,
+  SEPARATOR,
 } from "@budibase/types"
 import sdk from "../../../sdk"
 import { jsonFromCsvString } from "../../../utilities/csv"
@@ -24,12 +33,10 @@ import { builderSocket } from "../../../websockets"
 import { cloneDeep, isEqual } from "lodash"
 
 function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
-  if (table && !tableId) {
-    tableId = table._id
-  }
-  if (table && table.type === "external") {
+  if (table && isExternalTable(table)) {
     return external
-  } else if (tableId && isExternalTable(tableId)) {
+  }
+  if (tableId && isExternalTableID(tableId)) {
     return external
   }
   return internal
@@ -46,8 +53,8 @@ export async function fetch(ctx: UserCtx<void, FetchTablesResponse>) {
     if (entities) {
       return Object.values(entities).map<Table>((entity: Table) => ({
         ...entity,
-        type: "external",
-        sourceId: datasource._id,
+        sourceType: TableSourceType.EXTERNAL,
+        sourceId: datasource._id!,
         sql: isSQL(datasource),
       }))
     } else {
@@ -158,3 +165,19 @@ export async function validateExistingTableImport(ctx: UserCtx) {
     ctx.status = 422
   }
 }
+
+export async function migrate(ctx: UserCtx<MigrateRequest, MigrateResponse>) {
+  const { oldColumn, newColumn } = ctx.request.body
+  let tableId = ctx.params.tableId as string
+  const table = await sdk.tables.getTable(tableId)
+  let result = await sdk.tables.migrate(table, oldColumn, newColumn)
+
+  for (let table of result.tablesUpdated) {
+    builderSocket?.emitTableUpdate(ctx, table, {
+      includeOriginator: true,
+    })
+  }
+
+  ctx.status = 200
+  ctx.body = { message: `Column ${oldColumn.name} migrated.` }
+}
diff --git a/packages/server/src/api/controllers/table/internal.ts b/packages/server/src/api/controllers/table/internal.ts
index 822ff8a75d..bb94f2bc01 100644
--- a/packages/server/src/api/controllers/table/internal.ts
+++ b/packages/server/src/api/controllers/table/internal.ts
@@ -7,6 +7,7 @@ import {
   SaveTableRequest,
   SaveTableResponse,
   Table,
+  TableSourceType,
   UserCtx,
 } from "@budibase/types"
 import sdk from "../../../sdk"
@@ -16,10 +17,11 @@ export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) {
   let tableToSave: Table & {
     _rename?: RenameColumn
   } = {
-    type: "table",
     _id: generateTableID(),
-    views: {},
     ...rest,
+    type: "table",
+    sourceType: TableSourceType.INTERNAL,
+    views: {},
   }
   const renaming = tableToSave._rename
   delete tableToSave._rename
diff --git a/packages/server/src/api/routes/row.ts b/packages/server/src/api/routes/row.ts
index c29cb65eac..516bfd20c6 100644
--- a/packages/server/src/api/routes/row.ts
+++ b/packages/server/src/api/routes/row.ts
@@ -11,128 +11,24 @@ const { PermissionType, PermissionLevel } = permissions
 const router: Router = new Router()
 
 router
-  /**
-   * @api {get} /api/:sourceId/:rowId/enrich Get an enriched row
-   * @apiName Get an enriched row
-   * @apiGroup rows
-   * @apiPermission table read access
-   * @apiDescription This API is only useful when dealing with rows that have relationships.
-   * Normally when a row is a returned from the API relationships will only have the structure
-   * `{ primaryDisplay: "name", _id: ... }` but this call will return the full related rows
-   * for each relationship instead.
-   *
-   * @apiParam {string} rowId The ID of the row which is to be retrieved and enriched.
-   *
-   * @apiSuccess {object} row The response body will be the enriched row.
-   */
   .get(
     "/api/:sourceId/:rowId/enrich",
     paramSubResource("sourceId", "rowId"),
     authorized(PermissionType.TABLE, PermissionLevel.READ),
     rowController.fetchEnrichedRow
   )
-  /**
-   * @api {get} /api/:sourceId/rows Get all rows in a table
-   * @apiName Get all rows in a table
-   * @apiGroup rows
-   * @apiPermission table read access
-   * @apiDescription This is a deprecated endpoint that should not be used anymore, instead use the search endpoint.
-   * This endpoint gets all of the rows within the specified table - it is not heavily used
-   * due to its lack of support for pagination. With SQL tables this will retrieve up to a limit and then
-   * will simply stop.
-   *
-   * @apiParam {string} sourceId The ID of the table to retrieve all rows within.
-   *
-   * @apiSuccess {object[]} rows The response body will be an array of all rows found.
-   */
   .get(
     "/api/:sourceId/rows",
     paramResource("sourceId"),
     authorized(PermissionType.TABLE, PermissionLevel.READ),
     rowController.fetch
   )
-  /**
-   * @api {get} /api/:sourceId/rows/:rowId Retrieve a single row
-   * @apiName Retrieve a single row
-   * @apiGroup rows
-   * @apiPermission table read access
-   * @apiDescription This endpoint retrieves only the specified row. If you wish to retrieve
-   * a row by anything other than its _id field, use the search endpoint.
-   *
-   * @apiParam {string} sourceId The ID of the table to retrieve a row from.
-   * @apiParam {string} rowId The ID of the row to retrieve.
-   *
-   * @apiSuccess {object} body The response body will be the row that was found.
-   */
   .get(
     "/api/:sourceId/rows/:rowId",
     paramSubResource("sourceId", "rowId"),
     authorized(PermissionType.TABLE, PermissionLevel.READ),
     rowController.find
   )
-  /**
-   * @api {post} /api/:sourceId/search Search for rows in a table
-   * @apiName Search for rows in a table
-   * @apiGroup rows
-   * @apiPermission table read access
-   * @apiDescription This is the primary method of accessing rows in Budibase, the data provider
-   * and data UI in the builder are built atop this. All filtering, sorting and pagination is
-   * handled through this, for internal and external (datasource plus, e.g. SQL) tables.
-   *
-   * @apiParam {string} sourceId The ID of the table to retrieve rows from.
-   *
-   * @apiParam (Body) {boolean} [paginate] If pagination is required then this should be set to true,
-   * defaults to false.
-   * @apiParam (Body) {object} [query] This contains a set of filters which should be applied, if none
-   * specified then the request will be unfiltered. An example with all of the possible query
-   * options has been supplied below.
-   * @apiParam (Body) {number} [limit] This sets a limit for the number of rows that will be returned,
-   * this will be implemented at the database level if supported for performance reasons. This
-   * is useful when paginating to set exactly how many rows per page.
-   * @apiParam (Body) {string} [bookmark] If pagination is enabled then a bookmark will be returned
-   * with each successful search request, this should be supplied back to get the next page.
-   * @apiParam (Body) {object} [sort] If sort is desired this should contain the name of the column to
-   * sort on.
-   * @apiParam (Body) {string} [sortOrder] If sort is enabled then this can be either "descending" or
-   * "ascending" as required.
-   * @apiParam (Body) {string} [sortType] If sort is enabled then you must specify the type of search
-   * being used, either "string" or "number". This is only used for internal tables.
-   *
-   * @apiParamExample {json} Example:
-   * {
-   *  "tableId": "ta_70260ff0b85c467ca74364aefc46f26d",
-   *  "query": {
-   *    "string": {},
-   *    "fuzzy": {},
-   *    "range": {
-   *      "columnName": {
-   *        "high": 20,
-   *        "low": 10,
-   *      }
-   *    },
-   *    "equal": {
-   *      "columnName": "someValue"
-   *    },
-   *    "notEqual": {},
-   *    "empty": {},
-   *    "notEmpty": {},
-   *    "oneOf": {
-   *      "columnName": ["value"]
-   *    }
-   *  },
-   *  "limit": 10,
-   *  "sort": "name",
-   *  "sortOrder": "descending",
-   *  "sortType": "string",
-   *  "paginate": true
-   * }
-   *
-   * @apiSuccess {object[]} rows An array of rows that was found based on the supplied parameters.
-   * @apiSuccess {boolean} hasNextPage If pagination was enabled then this specifies whether or
-   * not there is another page after this request.
-   * @apiSuccess {string} bookmark The bookmark to be sent with the next request to get the next
-   * page.
-   */
   .post(
     "/api/:sourceId/search",
     internalSearchValidator(),
@@ -148,30 +44,6 @@ router
     authorized(PermissionType.TABLE, PermissionLevel.READ),
     rowController.search
   )
-  /**
-   * @api {post} /api/:sourceId/rows Creates a new row
-   * @apiName Creates a new row
-   * @apiGroup rows
-   * @apiPermission table write access
-   * @apiDescription This API will create a new row based on the supplied body. If the
-   * body includes an "_id" field then it will update an existing row if the field
-   * links to one. Please note that "_id", "_rev" and "tableId" are fields that are
-   * already used by Budibase tables and cannot be used for columns.
-   *
-   * @apiParam {string} sourceId The ID of the table to save a row to.
-   *
-   * @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided.
-   * @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision
-   * must also be provided.
-   * @apiParam (Body) {string} tableId The ID of the table should also be specified in the row body itself.
-   * @apiParam (Body) {any} [any] Any field supplied in the body will be assessed to see if it matches
-   * a column in the specified table. All other fields will be dropped and not stored.
-   *
-   * @apiSuccess {string} _id The ID of the row that was just saved, if it was just created this
-   * is the rows new ID.
-   * @apiSuccess {string} [_rev] If saving to an internal table a revision will also be returned.
-   * @apiSuccess {object} body The contents of the row that was saved will be returned as well.
-   */
   .post(
     "/api/:sourceId/rows",
     paramResource("sourceId"),
@@ -179,14 +51,6 @@ router
     trimViewRowInfo,
     rowController.save
   )
-  /**
-   * @api {patch} /api/:sourceId/rows Updates a row
-   * @apiName Update a row
-   * @apiGroup rows
-   * @apiPermission table write access
-   * @apiDescription This endpoint is identical to the row creation endpoint but instead it will
-   * error if an _id isn't provided, it will only function for existing rows.
-   */
   .patch(
     "/api/:sourceId/rows",
     paramResource("sourceId"),
@@ -194,52 +58,12 @@ router
     trimViewRowInfo,
     rowController.patch
   )
-  /**
-   * @api {post} /api/:sourceId/rows/validate Validate inputs for a row
-   * @apiName Validate inputs for a row
-   * @apiGroup rows
-   * @apiPermission table write access
-   * @apiDescription When attempting to save a row you may want to check if the row is valid
-   * given the table schema, this will iterate through all the constraints on the table and
-   * check if the request body is valid.
-   *
-   * @apiParam {string} sourceId The ID of the table the row is to be validated for.
-   *
-   * @apiParam (Body) {any} [any] Any fields provided in the request body will be tested
-   * against the table schema and constraints.
-   *
-   * @apiSuccess {boolean} valid If inputs provided are acceptable within the table schema this
-   * will be true, if it is not then then errors property will be populated.
-   * @apiSuccess {object} [errors] A key value map of information about fields on the input
-   * which do not match the table schema. The key name will be the column names that have breached
-   * the schema.
-   */
   .post(
     "/api/:sourceId/rows/validate",
     paramResource("sourceId"),
     authorized(PermissionType.TABLE, PermissionLevel.WRITE),
     rowController.validate
   )
-  /**
-   * @api {delete} /api/:sourceId/rows Delete rows
-   * @apiName Delete rows
-   * @apiGroup rows
-   * @apiPermission table write access
-   * @apiDescription This endpoint can delete a single row, or delete them in a bulk
-   * fashion.
-   *
-   * @apiParam {string} sourceId The ID of the table the row is to be deleted from.
-   *
-   * @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this
-   * key of the request body that are to be deleted.
-   * @apiParam (Body) {string} [_id] If deleting a single row then provide its ID in this field.
-   * @apiParam (Body) {string} [_rev] If deleting a single row from an internal table then provide its
-   * revision here.
-   *
-   * @apiSuccess {object[]|object} body If deleting bulk then the response body will be an array
-   * of the deleted rows, if deleting a single row then the body will contain a "row" property which
-   * is the deleted row.
-   */
   .delete(
     "/api/:sourceId/rows",
     paramResource("sourceId"),
@@ -247,20 +71,6 @@ router
     trimViewRowInfo,
     rowController.destroy
   )
-
-  /**
-   * @api {post} /api/:sourceId/rows/exportRows Export Rows
-   * @apiName Export rows
-   * @apiGroup rows
-   * @apiPermission table write access
-   * @apiDescription This API can export a number of provided rows
-   *
-   * @apiParam {string} sourceId The ID of the table the row is to be deleted from.
-   *
-   * @apiParam (Body) {object[]} [rows] The row IDs which are to be exported
-   *
-   * @apiSuccess {object[]|object}
-   */
   .post(
     "/api/:sourceId/rows/exportRows",
     paramResource("sourceId"),
diff --git a/packages/server/src/api/routes/table.ts b/packages/server/src/api/routes/table.ts
index 7ffa5acb3e..b947fa5e0b 100644
--- a/packages/server/src/api/routes/table.ts
+++ b/packages/server/src/api/routes/table.ts
@@ -9,99 +9,13 @@ const { BUILDER, PermissionLevel, PermissionType } = permissions
 const router: Router = new Router()
 
 router
-  /**
-   * @api {get} /api/tables Fetch all tables
-   * @apiName Fetch all tables
-   * @apiGroup tables
-   * @apiPermission table read access
-   * @apiDescription This endpoint retrieves all of the tables which have been created in
-   * an app. This includes all of the external and internal tables; to tell the difference
-   * between these look for the "type" property on each table, either being "internal" or "external".
-   *
-   * @apiSuccess {object[]} body The response body will be the list of tables that was found - as
-   * this does not take any parameters the only error scenario is no access.
-   */
   .get("/api/tables", authorized(BUILDER), tableController.fetch)
-  /**
-   * @api {get} /api/tables/:id Fetch a single table
-   * @apiName Fetch a single table
-   * @apiGroup tables
-   * @apiPermission table read access
-   * @apiDescription Retrieves a single table this could be be internal or external based on
-   * the provided table ID.
-   *
-   * @apiParam {string} id The ID of the table which is to be retrieved.
-   *
-   * @apiSuccess {object[]} body The response body will be the table that was found.
-   */
   .get(
     "/api/tables/:tableId",
     paramResource("tableId"),
     authorized(PermissionType.TABLE, PermissionLevel.READ, { schema: true }),
     tableController.find
   )
-  /**
-   * @api {post} /api/tables Save a table
-   * @apiName Save a table
-   * @apiGroup tables
-   * @apiPermission builder
-   * @apiDescription Create or update a table with this endpoint, this will function for both internal
-   * external tables.
-   *
-   * @apiParam (Body) {string} [_id] If updating an existing table then the ID of the table must be specified.
-   * @apiParam (Body) {string} [_rev] If updating an existing internal table then the revision must also be specified.
-   * @apiParam (Body) {string} type] This should either be "internal" or "external" depending on the table type -
-   * this will default to internal.
-   * @apiParam (Body) {string} [sourceId] If creating an external table then this should be set to the datasource ID. If
-   * building an internal table this does not need to be set, although it will be returned as "bb_internal".
-   * @apiParam (Body) {string} name The name of the table, this will be used in the UI. To rename the table simply
-   * supply the table structure to this endpoint with the name changed.
-   * @apiParam (Body) {object} schema A key value object which has all of the columns in the table as the keys in this
-   * object. For each column a "type" and "constraints" must be specified, with some types requiring further information.
-   * More information about the schema structure can be found in the Typescript definitions.
-   * @apiParam (Body) {string} [primaryDisplay] The name of the column which should be used when displaying rows
-   * from this table as relationships.
-   * @apiParam (Body) {object[]} [indexes] Specifies the search indexes - this is deprecated behaviour with the introduction
-   * of lucene indexes. This functionality is only available for internal tables.
-   * @apiParam (Body) {object} [_rename] If a column is to be renamed then the "old" column name should be set in this
-   * structure, and the "updated", new column name should also be supplied. The schema should also be updated, this field
-   * lets the server know that a field hasn't just been deleted, that the data has moved to a new name, this will fix
-   * the rows in the table. This functionality is only available for internal tables.
-   * @apiParam (Body) {object[]} [rows] When creating a table using a compatible data source, an array of objects to be imported into the new table can be provided.
-   *
-   * @apiParamExample {json} Example:
-   * {
-   *   "_id": "ta_05541307fa0f4044abee071ca2a82119",
-   *   "_rev": "10-0fbe4e78f69b255d79f1017e2eeef807",
-   *   "type": "internal",
-   *   "views": {},
-   *   "name": "tableName",
-   *   "schema": {
-   *     "column": {
-   *       "type": "string",
-   *       "constraints": {
-   *         "type": "string",
-   *         "length": {
-   *           "maximum": null
-   *         },
-   *         "presence": false
-   *       },
-   *       "name": "column"
-   *     },
-   *   },
-   *   "primaryDisplay": "column",
-   *   "indexes": [],
-   *   "sourceId": "bb_internal",
-   *   "_rename": {
-   *     "old": "columnName",
-   *     "updated": "newColumnName",
-   *   },
-   *   "rows": []
-   * }
-   *
-   * @apiSuccess {object} table The response body will contain the table structure after being cleaned up and
-   * saved to the database.
-   */
   .post(
     "/api/tables",
     // allows control over updating a table
@@ -125,41 +39,12 @@ router
     authorized(BUILDER),
     tableController.validateExistingTableImport
   )
-  /**
-   * @api {post} /api/tables/:tableId/:revId Delete a table
-   * @apiName Delete a table
-   * @apiGroup tables
-   * @apiPermission builder
-   * @apiDescription This endpoint will delete a table and all of its associated data, for this reason it is
-   * quite dangerous - it will work for internal and external tables.
-   *
-   * @apiParam {string} tableId The ID of the table which is to be deleted.
-   * @apiParam {string} [revId] If deleting an internal table then the revision must also be supplied (_rev), for
-   * external tables this can simply be set to anything, e.g. "external".
-   *
-   * @apiSuccess {string} message A message stating that the table was deleted successfully.
-   */
   .delete(
     "/api/tables/:tableId/:revId",
     paramResource("tableId"),
     authorized(BUILDER),
     tableController.destroy
   )
-  /**
-   * @api {post} /api/tables/:tableId/:revId Import CSV to existing table
-   * @apiName Import CSV to existing table
-   * @apiGroup tables
-   * @apiPermission builder
-   * @apiDescription This endpoint will import data to existing tables, internal or external. It is used in combination
-   * with the CSV validation endpoint. Take the output of the CSV validation endpoint and pass it to this endpoint to
-   * import the data; please note this will only import fields that already exist on the table/match the type.
-   *
-   * @apiParam {string} tableId The ID of the table which the data should be imported to.
-   *
-   * @apiParam (Body) {object[]} rows An array of objects representing the rows to be imported, key-value pairs not matching the table schema will be ignored.
-   *
-   * @apiSuccess {string} message A message stating that the data was imported successfully.
-   */
   .post(
     "/api/tables/:tableId/import",
     paramResource("tableId"),
@@ -167,4 +52,11 @@ router
     tableController.bulkImport
   )
 
+  .post(
+    "/api/tables/:tableId/migrate",
+    paramResource("tableId"),
+    authorized(BUILDER),
+    tableController.migrate
+  )
+
 export default router
diff --git a/packages/server/src/api/routes/tests/__snapshots__/datasource.spec.ts.snap b/packages/server/src/api/routes/tests/__snapshots__/datasource.spec.ts.snap
index 2894f597ab..8dc472173c 100644
--- a/packages/server/src/api/routes/tests/__snapshots__/datasource.spec.ts.snap
+++ b/packages/server/src/api/routes/tests/__snapshots__/datasource.spec.ts.snap
@@ -7,7 +7,7 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = `
     "entities": [
       {
         "_id": "ta_users",
-        "_rev": "1-2375e1bc58aeec664dc1b1f04ad43e44",
+        "_rev": "1-73b7912e6cbdd3d696febc60f3715844",
         "createdAt": "2020-01-01T00:00:00.000Z",
         "name": "Users",
         "primaryDisplay": "email",
@@ -21,7 +21,6 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = `
               "presence": true,
               "type": "string",
             },
-            "fieldName": "email",
             "name": "email",
             "type": "string",
           },
@@ -30,7 +29,6 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = `
               "presence": false,
               "type": "string",
             },
-            "fieldName": "firstName",
             "name": "firstName",
             "type": "string",
           },
@@ -39,7 +37,6 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = `
               "presence": false,
               "type": "string",
             },
-            "fieldName": "lastName",
             "name": "lastName",
             "type": "string",
           },
@@ -54,7 +51,6 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = `
               "presence": false,
               "type": "string",
             },
-            "fieldName": "roleId",
             "name": "roleId",
             "type": "options",
           },
@@ -67,11 +63,12 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = `
               "presence": false,
               "type": "string",
             },
-            "fieldName": "status",
             "name": "status",
             "type": "options",
           },
         },
+        "sourceId": "bb_internal",
+        "sourceType": "internal",
         "type": "table",
         "updatedAt": "2020-01-01T00:00:00.000Z",
         "views": {},
diff --git a/packages/server/src/api/routes/tests/attachment.spec.ts b/packages/server/src/api/routes/tests/attachment.spec.ts
new file mode 100644
index 0000000000..14d2e845f6
--- /dev/null
+++ b/packages/server/src/api/routes/tests/attachment.spec.ts
@@ -0,0 +1,49 @@
+import * as setup from "./utilities"
+import { APIError } from "@budibase/types"
+
+describe("/api/applications/:appId/sync", () => {
+  let config = setup.getConfig()
+
+  afterAll(setup.afterAll)
+  beforeAll(async () => {
+    await config.init()
+  })
+
+  describe("/api/attachments/process", () => {
+    it("should accept an image file upload", async () => {
+      let resp = await config.api.attachment.process(
+        "1px.jpg",
+        Buffer.from([0])
+      )
+      expect(resp.length).toBe(1)
+
+      let upload = resp[0]
+      expect(upload.url.endsWith(".jpg")).toBe(true)
+      expect(upload.extension).toBe("jpg")
+      expect(upload.size).toBe(1)
+      expect(upload.name).toBe("1px.jpg")
+    })
+
+    it("should reject an upload with a malicious file extension", async () => {
+      await config.withEnv({ SELF_HOSTED: undefined }, async () => {
+        let resp = (await config.api.attachment.process(
+          "ohno.exe",
+          Buffer.from([0]),
+          { expectStatus: 400 }
+        )) as unknown as APIError
+        expect(resp.message).toContain("invalid extension")
+      })
+    })
+
+    it("should reject an upload with no file", async () => {
+      let resp = (await config.api.attachment.process(
+        undefined as any,
+        undefined as any,
+        {
+          expectStatus: 400,
+        }
+      )) as unknown as APIError
+      expect(resp.message).toContain("No file provided")
+    })
+  })
+})
diff --git a/packages/server/src/api/routes/tests/backup.spec.ts b/packages/server/src/api/routes/tests/backup.spec.ts
index 92e0176060..d12b5e1507 100644
--- a/packages/server/src/api/routes/tests/backup.spec.ts
+++ b/packages/server/src/api/routes/tests/backup.spec.ts
@@ -5,6 +5,8 @@ import sdk from "../../../sdk"
 import { checkBuilderEndpoint } from "./utilities/TestFunctions"
 import { mocks } from "@budibase/backend-core/tests"
 
+mocks.licenses.useBackups()
+
 describe("/backups", () => {
   let request = setup.getRequest()
   let config = setup.getConfig()
@@ -12,16 +14,17 @@ describe("/backups", () => {
   afterAll(setup.afterAll)
 
   beforeEach(async () => {
+    tk.reset()
     await config.init()
   })
 
-  describe("exportAppDump", () => {
+  describe("/api/backups/export", () => {
     it("should be able to export app", async () => {
-      const res = await request
-        .post(`/api/backups/export?appId=${config.getAppId()}`)
-        .set(config.defaultHeaders())
-        .expect(200)
-      expect(res.headers["content-type"]).toEqual("application/gzip")
+      const { body, headers } = await config.api.backup.exportBasicBackup(
+        config.getAppId()!
+      )
+      expect(body instanceof Buffer).toBe(true)
+      expect(headers["content-type"]).toEqual("application/gzip")
       expect(events.app.exported).toBeCalledTimes(1)
     })
 
@@ -36,11 +39,11 @@ describe("/backups", () => {
     it("should infer the app name from the app", async () => {
       tk.freeze(mocks.date.MOCK_DATE)
 
-      const res = await request
-        .post(`/api/backups/export?appId=${config.getAppId()}`)
-        .set(config.defaultHeaders())
+      const { headers } = await config.api.backup.exportBasicBackup(
+        config.getAppId()!
+      )
 
-      expect(res.headers["content-disposition"]).toEqual(
+      expect(headers["content-disposition"]).toEqual(
         `attachment; filename="${
           config.getApp()!.name
         }-export-${mocks.date.MOCK_DATE.getTime()}.tar.gz"`
@@ -48,6 +51,21 @@ describe("/backups", () => {
     })
   })
 
+  describe("/api/backups/import", () => {
+    it("should be able to import an app", async () => {
+      const appId = config.getAppId()!
+      const automation = await config.createAutomation()
+      await config.createAutomationLog(automation, appId)
+      await config.createScreen()
+      const exportRes = await config.api.backup.createBackup(appId)
+      expect(exportRes.backupId).toBeDefined()
+      const importRes = await config.api.backup.importBackup(
+        appId,
+        exportRes.backupId
+      )
+    })
+  })
+
   describe("calculateBackupStats", () => {
     it("should be able to calculate the backup statistics", async () => {
       await config.createAutomation()
diff --git a/packages/server/src/api/routes/tests/role.spec.js b/packages/server/src/api/routes/tests/role.spec.js
index c8e383d5ed..d133a69d64 100644
--- a/packages/server/src/api/routes/tests/role.spec.js
+++ b/packages/server/src/api/routes/tests/role.spec.js
@@ -158,5 +158,25 @@ describe("/roles", () => {
       expect(res.body.length).toBe(1)
       expect(res.body[0]).toBe("PUBLIC")
     })
+
+    it("should not fetch higher level accessible roles when a custom role header is provided", async () => {
+      await createRole({
+        name: `CUSTOM_ROLE`,
+        inherits: roles.BUILTIN_ROLE_IDS.BASIC,
+        permissionId: permissions.BuiltinPermissionID.READ_ONLY,
+        version: "name",
+      })
+      const res = await request
+        .get("/api/roles/accessible")
+        .set({
+          ...config.defaultHeaders(),
+          "x-budibase-role": "CUSTOM_ROLE"
+        })
+        .expect(200)
+      expect(res.body.length).toBe(3)
+      expect(res.body[0]).toBe("CUSTOM_ROLE")
+      expect(res.body[1]).toBe("BASIC")
+      expect(res.body[2]).toBe("PUBLIC")
+    })
   })
 })
diff --git a/packages/server/src/api/routes/tests/routing.spec.js b/packages/server/src/api/routes/tests/routing.spec.js
index ff6d7aba1d..4076f4879c 100644
--- a/packages/server/src/api/routes/tests/routing.spec.js
+++ b/packages/server/src/api/routes/tests/routing.spec.js
@@ -1,5 +1,5 @@
 const setup = require("./utilities")
-const { basicScreen } = setup.structures
+const { basicScreen, powerScreen } = setup.structures
 const { checkBuilderEndpoint, runInProd } = require("./utilities/TestFunctions")
 const { roles } = require("@budibase/backend-core")
 const { BUILTIN_ROLE_IDS } = roles
@@ -12,19 +12,14 @@ const route = "/test"
 describe("/routing", () => {
   let request = setup.getRequest()
   let config = setup.getConfig()
-  let screen, screen2
+  let basic, power
 
   afterAll(setup.afterAll)
 
   beforeAll(async () => {
     await config.init()
-    screen = basicScreen()
-    screen.routing.route = route
-    screen = await config.createScreen(screen)
-    screen2 = basicScreen()
-    screen2.routing.roleId = BUILTIN_ROLE_IDS.POWER
-    screen2.routing.route = route
-    screen2 = await config.createScreen(screen2)
+    basic = await config.createScreen(basicScreen(route))
+    power = await config.createScreen(powerScreen(route))
     await config.publish()
   })
 
@@ -61,8 +56,8 @@ describe("/routing", () => {
       expect(res.body.routes[route]).toEqual({
         subpaths: {
           [route]: {
-            screenId: screen._id,
-            roleId: screen.routing.roleId
+            screenId: basic._id,
+            roleId: basic.routing.roleId
           }
         }
       })
@@ -80,8 +75,8 @@ describe("/routing", () => {
       expect(res.body.routes[route]).toEqual({
         subpaths: {
           [route]: {
-            screenId: screen2._id,
-            roleId: screen2.routing.roleId
+            screenId: power._id,
+            roleId: power.routing.roleId
           }
         }
       })
@@ -101,8 +96,8 @@ describe("/routing", () => {
       expect(res.body.routes).toBeDefined()
       expect(res.body.routes[route].subpaths[route]).toBeDefined()
       const subpath = res.body.routes[route].subpaths[route]
-      expect(subpath.screens[screen2.routing.roleId]).toEqual(screen2._id)
-      expect(subpath.screens[screen.routing.roleId]).toEqual(screen._id)
+      expect(subpath.screens[power.routing.roleId]).toEqual(power._id)
+      expect(subpath.screens[basic.routing.roleId]).toEqual(basic._id)
     })
 
     it("make sure it is a builder only endpoint", async () => {
diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts
index 4c2e7a7494..3ae4a6c1e2 100644
--- a/packages/server/src/api/routes/tests/row.spec.ts
+++ b/packages/server/src/api/routes/tests/row.spec.ts
@@ -10,6 +10,7 @@ import {
   FieldSchema,
   FieldType,
   FieldTypeSubtypes,
+  INTERNAL_TABLE_SOURCE_ID,
   MonthlyQuotaName,
   PermissionLevel,
   QuotaUsageType,
@@ -21,6 +22,7 @@ import {
   SortType,
   StaticQuotaName,
   Table,
+  TableSourceType,
 } from "@budibase/types"
 import {
   expectAnyExternalColsAttributes,
@@ -65,6 +67,8 @@ describe.each([
       type: "table",
       primary: ["id"],
       primaryDisplay: "name",
+      sourceType: TableSourceType.INTERNAL,
+      sourceId: INTERNAL_TABLE_SOURCE_ID,
       schema: {
         id: {
           type: FieldType.AUTO,
@@ -134,9 +138,22 @@ describe.each([
       }
     : undefined
 
+  async function createTable(
+    cfg: Omit<SaveTableRequest, "sourceId" | "sourceType">,
+    opts?: { skipReassigning: boolean }
+  ) {
+    let table
+    if (dsProvider) {
+      table = await config.createExternalTable(cfg, opts)
+    } else {
+      table = await config.createTable(cfg, opts)
+    }
+    return table
+  }
+
   beforeAll(async () => {
     const tableConfig = generateTableConfig()
-    const table = await config.createTable(tableConfig)
+    let table = await createTable(tableConfig)
     tableId = table._id!
   })
 
@@ -165,7 +182,7 @@ describe.each([
       const queryUsage = await getQueryUsage()
 
       const tableConfig = generateTableConfig()
-      const newTable = await config.createTable(
+      const newTable = await createTable(
         {
           ...tableConfig,
           name: "TestTableAuto",
@@ -242,7 +259,7 @@ describe.each([
     })
 
     it("should list all rows for given tableId", async () => {
-      const table = await config.createTable(generateTableConfig(), {
+      const table = await createTable(generateTableConfig(), {
         skipReassigning: true,
       })
       const tableId = table._id!
@@ -323,7 +340,7 @@ describe.each([
             inclusion: ["Alpha", "Beta", "Gamma"],
           },
         }
-        const table = await config.createTable({
+        const table = await createTable({
           name: "TestTable2",
           type: "table",
           schema: {
@@ -438,7 +455,8 @@ describe.each([
 
   describe("view save", () => {
     it("views have extra data trimmed", async () => {
-      const table = await config.createTable({
+      const table = await createTable({
+        type: "table",
         name: "orders",
         primary: ["OrderID"],
         schema: {
@@ -494,7 +512,7 @@ describe.each([
   describe("patch", () => {
     beforeAll(async () => {
       const tableConfig = generateTableConfig()
-      table = await config.createTable(tableConfig)
+      table = await createTable(tableConfig)
     })
 
     it("should update only the fields that are supplied", async () => {
@@ -548,7 +566,7 @@ describe.each([
   describe("destroy", () => {
     beforeAll(async () => {
       const tableConfig = generateTableConfig()
-      table = await config.createTable(tableConfig)
+      table = await createTable(tableConfig)
     })
 
     it("should be able to delete a row", async () => {
@@ -566,7 +584,7 @@ describe.each([
   describe("validate", () => {
     beforeAll(async () => {
       const tableConfig = generateTableConfig()
-      table = await config.createTable(tableConfig)
+      table = await createTable(tableConfig)
     })
 
     it("should return no errors on valid row", async () => {
@@ -603,7 +621,7 @@ describe.each([
   describe("bulkDelete", () => {
     beforeAll(async () => {
       const tableConfig = generateTableConfig()
-      table = await config.createTable(tableConfig)
+      table = await createTable(tableConfig)
     })
 
     it("should be able to delete a bulk set of rows", async () => {
@@ -687,7 +705,7 @@ describe.each([
     describe("fetchView", () => {
       beforeEach(async () => {
         const tableConfig = generateTableConfig()
-        table = await config.createTable(tableConfig)
+        table = await createTable(tableConfig)
       })
 
       it("should be able to fetch tables contents via 'view'", async () => {
@@ -735,7 +753,7 @@ describe.each([
   describe("fetchEnrichedRows", () => {
     beforeAll(async () => {
       const tableConfig = generateTableConfig()
-      table = await config.createTable(tableConfig)
+      table = await createTable(tableConfig)
     })
 
     it("should allow enriching some linked rows", async () => {
@@ -808,7 +826,7 @@ describe.each([
     describe("attachments", () => {
       beforeAll(async () => {
         const tableConfig = generateTableConfig()
-        table = await config.createTable(tableConfig)
+        table = await createTable(tableConfig)
       })
 
       it("should allow enriching attachment rows", async () => {
@@ -839,7 +857,7 @@ describe.each([
   describe("exportData", () => {
     beforeAll(async () => {
       const tableConfig = generateTableConfig()
-      table = await config.createTable(tableConfig)
+      table = await createTable(tableConfig)
     })
 
     it("should allow exporting all columns", async () => {
@@ -880,6 +898,8 @@ describe.each([
     async function userTable(): Promise<Table> {
       return {
         name: `users_${generator.word()}`,
+        sourceId: INTERNAL_TABLE_SOURCE_ID,
+        sourceType: TableSourceType.INTERNAL,
         type: "table",
         primary: ["id"],
         schema: {
@@ -925,7 +945,7 @@ describe.each([
 
     describe("create", () => {
       it("should persist a new row with only the provided view fields", async () => {
-        const table = await config.createTable(await userTable())
+        const table = await createTable(await userTable())
         const view = await config.createView({
           schema: {
             name: { visible: true },
@@ -960,7 +980,7 @@ describe.each([
 
     describe("patch", () => {
       it("should update only the view fields for a row", async () => {
-        const table = await config.createTable(await userTable())
+        const table = await createTable(await userTable())
         const tableId = table._id!
         const view = await config.createView({
           schema: {
@@ -1001,7 +1021,7 @@ describe.each([
 
     describe("destroy", () => {
       it("should be able to delete a row", async () => {
-        const table = await config.createTable(await userTable())
+        const table = await createTable(await userTable())
         const tableId = table._id!
         const view = await config.createView({
           schema: {
@@ -1025,7 +1045,7 @@ describe.each([
       })
 
       it("should be able to delete multiple rows", async () => {
-        const table = await config.createTable(await userTable())
+        const table = await createTable(await userTable())
         const tableId = table._id!
         const view = await config.createView({
           schema: {
@@ -1062,6 +1082,8 @@ describe.each([
       async function userTable(): Promise<Table> {
         return {
           name: `users_${generator.word()}`,
+          sourceId: INTERNAL_TABLE_SOURCE_ID,
+          sourceType: TableSourceType.INTERNAL,
           type: "table",
           primary: ["id"],
           schema: {
@@ -1088,7 +1110,7 @@ describe.each([
       }
 
       it("returns empty rows from view when no schema is passed", async () => {
-        const table = await config.createTable(await userTable())
+        const table = await createTable(await userTable())
         const rows = await Promise.all(
           Array.from({ length: 10 }, () =>
             config.api.row.save(table._id!, { tableId: table._id })
@@ -1119,7 +1141,7 @@ describe.each([
       })
 
       it("searching respects the view filters", async () => {
-        const table = await config.createTable(await userTable())
+        const table = await createTable(await userTable())
 
         await Promise.all(
           Array.from({ length: 10 }, () =>
@@ -1243,7 +1265,7 @@ describe.each([
 
       describe("sorting", () => {
         beforeAll(async () => {
-          const table = await config.createTable(await userTable())
+          const table = await createTable(await userTable())
           const users = [
             { name: "Alice", age: 25 },
             { name: "Bob", age: 30 },
@@ -1310,7 +1332,7 @@ describe.each([
       })
 
       it("when schema is defined, defined columns and row attributes are returned", async () => {
-        const table = await config.createTable(await userTable())
+        const table = await createTable(await userTable())
         const rows = await Promise.all(
           Array.from({ length: 10 }, () =>
             config.api.row.save(table._id!, {
@@ -1341,7 +1363,7 @@ describe.each([
       })
 
       it("views without data can be returned", async () => {
-        const table = await config.createTable(await userTable())
+        const table = await createTable(await userTable())
 
         const createViewResponse = await config.createView()
         const response = await config.api.viewV2.search(createViewResponse.id)
@@ -1350,7 +1372,7 @@ describe.each([
       })
 
       it("respects the limit parameter", async () => {
-        await config.createTable(await userTable())
+        await createTable(await userTable())
         await Promise.all(Array.from({ length: 10 }, () => config.createRow()))
 
         const limit = generator.integer({ min: 1, max: 8 })
@@ -1365,7 +1387,7 @@ describe.each([
       })
 
       it("can handle pagination", async () => {
-        await config.createTable(await userTable())
+        await createTable(await userTable())
         await Promise.all(Array.from({ length: 10 }, () => config.createRow()))
 
         const createViewResponse = await config.createView()
@@ -1443,7 +1465,7 @@ describe.each([
         let tableId: string
 
         beforeAll(async () => {
-          await config.createTable(await userTable())
+          await createTable(await userTable())
           await Promise.all(
             Array.from({ length: 10 }, () => config.createRow())
           )
@@ -1521,13 +1543,13 @@ describe.each([
   let o2mTable: Table
   let m2mTable: Table
   beforeAll(async () => {
-    o2mTable = await config.createTable(
+    o2mTable = await createTable(
       { ...generateTableConfig(), name: "o2m" },
       {
         skipReassigning: true,
       }
     )
-    m2mTable = await config.createTable(
+    m2mTable = await createTable(
       { ...generateTableConfig(), name: "m2m" },
       {
         skipReassigning: true,
@@ -1597,9 +1619,9 @@ describe.each([
       const tableConfig = generateTableConfig()
 
       if (config.datasource) {
-        tableConfig.sourceId = config.datasource._id
+        tableConfig.sourceId = config.datasource._id!
         if (config.datasource.plus) {
-          tableConfig.type = "external"
+          tableConfig.sourceType = TableSourceType.EXTERNAL
         }
       }
       const table = await config.api.table.create({
diff --git a/packages/server/src/api/routes/tests/static.spec.js b/packages/server/src/api/routes/tests/static.spec.js
index 13d963d057..a28d9ecd79 100644
--- a/packages/server/src/api/routes/tests/static.spec.js
+++ b/packages/server/src/api/routes/tests/static.spec.js
@@ -5,11 +5,15 @@ describe("/static", () => {
   let request = setup.getRequest()
   let config = setup.getConfig()
   let app
+  let cleanupEnv
 
-  afterAll(setup.afterAll)
+  afterAll(() => {
+    setup.afterAll()
+    cleanupEnv()
+  })
 
   beforeAll(async () => {
-    config.modeSelf()
+    cleanupEnv = config.setEnv({ SELF_HOSTED: "true" })
     app = await config.init()
   })
 
diff --git a/packages/server/src/api/routes/tests/table.spec.ts b/packages/server/src/api/routes/tests/table.spec.ts
index ded54729b9..c239c596fe 100644
--- a/packages/server/src/api/routes/tests/table.spec.ts
+++ b/packages/server/src/api/routes/tests/table.spec.ts
@@ -1,16 +1,24 @@
-import { events, context } from "@budibase/backend-core"
+import { context, events } from "@budibase/backend-core"
 import {
-  FieldType,
-  SaveTableRequest,
-  RelationshipType,
-  Table,
-  ViewCalculation,
   AutoFieldSubTypes,
+  FieldSubtype,
+  FieldType,
+  INTERNAL_TABLE_SOURCE_ID,
+  InternalTable,
+  RelationshipType,
+  Row,
+  SaveTableRequest,
+  Table,
+  TableSourceType,
+  User,
+  ViewCalculation,
 } from "@budibase/types"
 import { checkBuilderEndpoint } from "./utilities/TestFunctions"
 import * as setup from "./utilities"
-const { basicTable } = setup.structures
 import sdk from "../../../sdk"
+import uuid from "uuid"
+
+const { basicTable } = setup.structures
 
 describe("/tables", () => {
   let request = setup.getRequest()
@@ -239,7 +247,8 @@ describe("/tables", () => {
         .expect(200)
       const fetchedTable = res.body[0]
       expect(fetchedTable.name).toEqual(testTable.name)
-      expect(fetchedTable.type).toEqual("internal")
+      expect(fetchedTable.type).toEqual("table")
+      expect(fetchedTable.sourceType).toEqual("internal")
     })
 
     it("should apply authorization to endpoint", async () => {
@@ -417,4 +426,281 @@ describe("/tables", () => {
       })
     })
   })
+
+  describe("migrate", () => {
+    let users: User[]
+    beforeAll(async () => {
+      users = await Promise.all([
+        config.createUser({ email: `${uuid.v4()}@example.com` }),
+        config.createUser({ email: `${uuid.v4()}@example.com` }),
+        config.createUser({ email: `${uuid.v4()}@example.com` }),
+      ])
+    })
+
+    it("should successfully migrate a one-to-many user relationship to a user column", async () => {
+      const table = await config.api.table.create({
+        name: "table",
+        type: "table",
+        sourceId: INTERNAL_TABLE_SOURCE_ID,
+        sourceType: TableSourceType.INTERNAL,
+        schema: {
+          "user relationship": {
+            type: FieldType.LINK,
+            fieldName: "test",
+            name: "user relationship",
+            constraints: {
+              type: "array",
+              presence: false,
+            },
+            relationshipType: RelationshipType.ONE_TO_MANY,
+            tableId: InternalTable.USER_METADATA,
+          },
+        },
+      })
+
+      const rows = await Promise.all(
+        users.map(u =>
+          config.api.row.save(table._id!, { "user relationship": [u] })
+        )
+      )
+
+      await config.api.table.migrate(table._id!, {
+        oldColumn: table.schema["user relationship"],
+        newColumn: {
+          name: "user column",
+          type: FieldType.BB_REFERENCE,
+          subtype: FieldSubtype.USER,
+        },
+      })
+
+      const migratedTable = await config.api.table.get(table._id!)
+      expect(migratedTable.schema["user column"]).toBeDefined()
+      expect(migratedTable.schema["user relationship"]).not.toBeDefined()
+
+      const migratedRows = await config.api.row.fetch(table._id!)
+
+      rows.sort((a, b) => a._id!.localeCompare(b._id!))
+      migratedRows.sort((a, b) => a._id!.localeCompare(b._id!))
+
+      for (const [i, row] of rows.entries()) {
+        const migratedRow = migratedRows[i]
+        expect(migratedRow["user column"]).toBeDefined()
+        expect(migratedRow["user relationship"]).not.toBeDefined()
+        expect(row["user relationship"][0]._id).toEqual(
+          migratedRow["user column"][0]._id
+        )
+      }
+    })
+
+    it("should successfully migrate a many-to-many user relationship to a users column", async () => {
+      const table = await config.api.table.create({
+        name: "table",
+        type: "table",
+        sourceId: INTERNAL_TABLE_SOURCE_ID,
+        sourceType: TableSourceType.INTERNAL,
+        schema: {
+          "user relationship": {
+            type: FieldType.LINK,
+            fieldName: "test",
+            name: "user relationship",
+            constraints: {
+              type: "array",
+              presence: false,
+            },
+            relationshipType: RelationshipType.MANY_TO_MANY,
+            tableId: InternalTable.USER_METADATA,
+          },
+        },
+      })
+
+      const row1 = await config.api.row.save(table._id!, {
+        "user relationship": [users[0], users[1]],
+      })
+
+      const row2 = await config.api.row.save(table._id!, {
+        "user relationship": [users[1], users[2]],
+      })
+
+      await config.api.table.migrate(table._id!, {
+        oldColumn: table.schema["user relationship"],
+        newColumn: {
+          name: "user column",
+          type: FieldType.BB_REFERENCE,
+          subtype: FieldSubtype.USERS,
+        },
+      })
+
+      const migratedTable = await config.api.table.get(table._id!)
+      expect(migratedTable.schema["user column"]).toBeDefined()
+      expect(migratedTable.schema["user relationship"]).not.toBeDefined()
+
+      const row1Migrated = (await config.api.row.get(table._id!, row1._id!))
+        .body as Row
+      expect(row1Migrated["user relationship"]).not.toBeDefined()
+      expect(row1Migrated["user column"].map((r: Row) => r._id)).toEqual(
+        expect.arrayContaining([users[0]._id, users[1]._id])
+      )
+
+      const row2Migrated = (await config.api.row.get(table._id!, row2._id!))
+        .body as Row
+      expect(row2Migrated["user relationship"]).not.toBeDefined()
+      expect(row2Migrated["user column"].map((r: Row) => r._id)).toEqual(
+        expect.arrayContaining([users[1]._id, users[2]._id])
+      )
+    })
+
+    it("should successfully migrate a many-to-one user relationship to a users column", async () => {
+      const table = await config.api.table.create({
+        name: "table",
+        type: "table",
+        sourceId: INTERNAL_TABLE_SOURCE_ID,
+        sourceType: TableSourceType.INTERNAL,
+        schema: {
+          "user relationship": {
+            type: FieldType.LINK,
+            fieldName: "test",
+            name: "user relationship",
+            constraints: {
+              type: "array",
+              presence: false,
+            },
+            relationshipType: RelationshipType.MANY_TO_ONE,
+            tableId: InternalTable.USER_METADATA,
+          },
+        },
+      })
+
+      const row1 = await config.api.row.save(table._id!, {
+        "user relationship": [users[0], users[1]],
+      })
+
+      const row2 = await config.api.row.save(table._id!, {
+        "user relationship": [users[2]],
+      })
+
+      await config.api.table.migrate(table._id!, {
+        oldColumn: table.schema["user relationship"],
+        newColumn: {
+          name: "user column",
+          type: FieldType.BB_REFERENCE,
+          subtype: FieldSubtype.USERS,
+        },
+      })
+
+      const migratedTable = await config.api.table.get(table._id!)
+      expect(migratedTable.schema["user column"]).toBeDefined()
+      expect(migratedTable.schema["user relationship"]).not.toBeDefined()
+
+      const row1Migrated = (await config.api.row.get(table._id!, row1._id!))
+        .body as Row
+      expect(row1Migrated["user relationship"]).not.toBeDefined()
+      expect(row1Migrated["user column"].map((r: Row) => r._id)).toEqual(
+        expect.arrayContaining([users[0]._id, users[1]._id])
+      )
+
+      const row2Migrated = (await config.api.row.get(table._id!, row2._id!))
+        .body as Row
+      expect(row2Migrated["user relationship"]).not.toBeDefined()
+      expect(row2Migrated["user column"].map((r: Row) => r._id)).toEqual([
+        users[2]._id,
+      ])
+    })
+
+    describe("unhappy paths", () => {
+      let table: Table
+      beforeAll(async () => {
+        table = await config.api.table.create({
+          name: "table",
+          type: "table",
+          sourceId: INTERNAL_TABLE_SOURCE_ID,
+          sourceType: TableSourceType.INTERNAL,
+          schema: {
+            "user relationship": {
+              type: FieldType.LINK,
+              fieldName: "test",
+              name: "user relationship",
+              constraints: {
+                type: "array",
+                presence: false,
+              },
+              relationshipType: RelationshipType.MANY_TO_ONE,
+              tableId: InternalTable.USER_METADATA,
+            },
+            num: {
+              type: FieldType.NUMBER,
+              name: "num",
+              constraints: {
+                type: "number",
+                presence: false,
+              },
+            },
+          },
+        })
+      })
+
+      it("should fail if the new column name is blank", async () => {
+        await config.api.table.migrate(
+          table._id!,
+          {
+            oldColumn: table.schema["user relationship"],
+            newColumn: {
+              name: "",
+              type: FieldType.BB_REFERENCE,
+              subtype: FieldSubtype.USERS,
+            },
+          },
+          { expectStatus: 400 }
+        )
+      })
+
+      it("should fail if the new column name is a reserved name", async () => {
+        await config.api.table.migrate(
+          table._id!,
+          {
+            oldColumn: table.schema["user relationship"],
+            newColumn: {
+              name: "_id",
+              type: FieldType.BB_REFERENCE,
+              subtype: FieldSubtype.USERS,
+            },
+          },
+          { expectStatus: 400 }
+        )
+      })
+
+      it("should fail if the new column name is the same as an existing column", async () => {
+        await config.api.table.migrate(
+          table._id!,
+          {
+            oldColumn: table.schema["user relationship"],
+            newColumn: {
+              name: "num",
+              type: FieldType.BB_REFERENCE,
+              subtype: FieldSubtype.USERS,
+            },
+          },
+          { expectStatus: 400 }
+        )
+      })
+
+      it("should fail if the old column name isn't a column in the table", async () => {
+        await config.api.table.migrate(
+          table._id!,
+          {
+            oldColumn: {
+              name: "not a column",
+              type: FieldType.BB_REFERENCE,
+              subtype: FieldSubtype.USERS,
+            },
+            newColumn: {
+              name: "new column",
+              type: FieldType.BB_REFERENCE,
+              subtype: FieldSubtype.USERS,
+            },
+          },
+          { expectStatus: 400 }
+        )
+      })
+    })
+  })
 })
diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts
index 40060aef48..b03a73ddda 100644
--- a/packages/server/src/api/routes/tests/viewV2.spec.ts
+++ b/packages/server/src/api/routes/tests/viewV2.spec.ts
@@ -3,10 +3,12 @@ import {
   CreateViewRequest,
   FieldSchema,
   FieldType,
+  INTERNAL_TABLE_SOURCE_ID,
   SearchQueryOperators,
   SortOrder,
   SortType,
   Table,
+  TableSourceType,
   UIFieldMetadata,
   UpdateViewRequest,
   ViewV2,
@@ -18,6 +20,8 @@ function priceTable(): Table {
   return {
     name: "table",
     type: "table",
+    sourceId: INTERNAL_TABLE_SOURCE_ID,
+    sourceType: TableSourceType.INTERNAL,
     schema: {
       Price: {
         type: FieldType.NUMBER,
@@ -54,10 +58,10 @@ describe.each([
         },
       })
 
-      return config.createTable({
+      return config.createExternalTable({
         ...priceTable(),
         sourceId: datasource._id,
-        type: "external",
+        sourceType: TableSourceType.EXTERNAL,
       })
     },
   ],
diff --git a/packages/server/src/api/routes/tests/webhook.spec.ts b/packages/server/src/api/routes/tests/webhook.spec.ts
index e7046d07c8..118bfca95f 100644
--- a/packages/server/src/api/routes/tests/webhook.spec.ts
+++ b/packages/server/src/api/routes/tests/webhook.spec.ts
@@ -8,11 +8,15 @@ describe("/webhooks", () => {
   let request = setup.getRequest()
   let config = setup.getConfig()
   let webhook: Webhook
+  let cleanupEnv: () => void
 
-  afterAll(setup.afterAll)
+  afterAll(() => {
+    setup.afterAll()
+    cleanupEnv()
+  })
 
   const setupTest = async () => {
-    config.modeSelf()
+    cleanupEnv = config.setEnv({ SELF_HOSTED: "true" })
     await config.init()
     const autoConfig = basicAutomation()
     autoConfig.definition.trigger.schema = {
diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts
index b37a4b36c1..fb5c42e7b8 100644
--- a/packages/server/src/constants/index.ts
+++ b/packages/server/src/constants/index.ts
@@ -1,5 +1,11 @@
-import { objectStore, roles, constants } from "@budibase/backend-core"
-import { FieldType as FieldTypes } from "@budibase/types"
+import { constants, objectStore, roles } from "@budibase/backend-core"
+import {
+  FieldType as FieldTypes,
+  INTERNAL_TABLE_SOURCE_ID,
+  Table,
+  TableSourceType,
+} from "@budibase/types"
+
 export {
   FieldType as FieldTypes,
   RelationshipType,
@@ -70,9 +76,11 @@ export enum SortDirection {
   DESCENDING = "DESCENDING",
 }
 
-export const USERS_TABLE_SCHEMA = {
+export const USERS_TABLE_SCHEMA: Table = {
   _id: "ta_users",
   type: "table",
+  sourceId: INTERNAL_TABLE_SOURCE_ID,
+  sourceType: TableSourceType.INTERNAL,
   views: {},
   name: "Users",
   // TODO: ADMIN PANEL - when implemented this doesn't need to be carried out
@@ -87,12 +95,10 @@ export const USERS_TABLE_SCHEMA = {
         },
         presence: true,
       },
-      fieldName: "email",
       name: "email",
     },
     firstName: {
       name: "firstName",
-      fieldName: "firstName",
       type: FieldTypes.STRING,
       constraints: {
         type: FieldTypes.STRING,
@@ -101,7 +107,6 @@ export const USERS_TABLE_SCHEMA = {
     },
     lastName: {
       name: "lastName",
-      fieldName: "lastName",
       type: FieldTypes.STRING,
       constraints: {
         type: FieldTypes.STRING,
@@ -109,7 +114,6 @@ export const USERS_TABLE_SCHEMA = {
       },
     },
     roleId: {
-      fieldName: "roleId",
       name: "roleId",
       type: FieldTypes.OPTIONS,
       constraints: {
@@ -119,7 +123,6 @@ export const USERS_TABLE_SCHEMA = {
       },
     },
     status: {
-      fieldName: "status",
       name: "status",
       type: FieldTypes.OPTIONS,
       constraints: {
@@ -169,3 +172,8 @@ export enum AutomationErrors {
 export const ObjectStoreBuckets = objectStore.ObjectStoreBuckets
 export const MAX_AUTOMATION_RECURRING_ERRORS = 5
 export const GOOGLE_SHEETS_PRIMARY_KEY = "rowNumber"
+export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs"
+export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory"
+export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses"
+export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee"
+export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default"
diff --git a/packages/server/src/constants/screens.ts b/packages/server/src/constants/screens.ts
index 23e36a65b8..6c88b0f957 100644
--- a/packages/server/src/constants/screens.ts
+++ b/packages/server/src/constants/screens.ts
@@ -1,7 +1,15 @@
 import { roles } from "@budibase/backend-core"
 import { BASE_LAYOUT_PROP_IDS } from "./layouts"
 
-export function createHomeScreen() {
+export function createHomeScreen(
+  config: {
+    roleId: string
+    route: string
+  } = {
+    roleId: roles.BUILTIN_ROLE_IDS.BASIC,
+    route: "/",
+  }
+) {
   return {
     description: "",
     url: "",
@@ -40,8 +48,8 @@ export function createHomeScreen() {
       gap: "M",
     },
     routing: {
-      route: "/",
-      roleId: roles.BUILTIN_ROLE_IDS.BASIC,
+      route: config.route,
+      roleId: config.roleId,
     },
     name: "home-screen",
   }
diff --git a/packages/server/src/db/defaultData/datasource_bb_default.ts b/packages/server/src/db/defaultData/datasource_bb_default.ts
index 48d4876de1..b430f9ffb6 100644
--- a/packages/server/src/db/defaultData/datasource_bb_default.ts
+++ b/packages/server/src/db/defaultData/datasource_bb_default.ts
@@ -1,4 +1,12 @@
-import { FieldTypes, AutoFieldSubTypes } from "../../constants"
+import {
+  AutoFieldSubTypes,
+  FieldTypes,
+  DEFAULT_BB_DATASOURCE_ID,
+  DEFAULT_INVENTORY_TABLE_ID,
+  DEFAULT_EMPLOYEE_TABLE_ID,
+  DEFAULT_EXPENSES_TABLE_ID,
+  DEFAULT_JOBS_TABLE_ID,
+} from "../../constants"
 import { importToRows } from "../../api/controllers/table/utils"
 import { cloneDeep } from "lodash/fp"
 import LinkDocument from "../linkedRows/LinkDocument"
@@ -8,19 +16,14 @@ import { jobsImport } from "./jobsImport"
 import { expensesImport } from "./expensesImport"
 import { db as dbCore } from "@budibase/backend-core"
 import {
-  Table,
-  Row,
-  RelationshipType,
   FieldType,
+  RelationshipType,
+  Row,
+  Table,
   TableSchema,
+  TableSourceType,
 } from "@budibase/types"
 
-export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs"
-export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory"
-export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses"
-export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee"
-export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default"
-
 const defaultDatasource = {
   _id: DEFAULT_BB_DATASOURCE_ID,
   type: dbCore.BUDIBASE_DATASOURCE_TYPE,
@@ -89,9 +92,10 @@ const AUTO_COLUMNS: TableSchema = {
 
 export const DEFAULT_INVENTORY_TABLE_SCHEMA: Table = {
   _id: DEFAULT_INVENTORY_TABLE_ID,
-  type: "internal",
+  type: "table",
   views: {},
   sourceId: DEFAULT_BB_DATASOURCE_ID,
+  sourceType: TableSourceType.INTERNAL,
   primaryDisplay: "Item Name",
   name: "Inventory",
   schema: {
@@ -198,10 +202,11 @@ export const DEFAULT_INVENTORY_TABLE_SCHEMA: Table = {
 
 export const DEFAULT_EMPLOYEE_TABLE_SCHEMA: Table = {
   _id: DEFAULT_EMPLOYEE_TABLE_ID,
-  type: "internal",
+  type: "table",
   views: {},
   name: "Employees",
   sourceId: DEFAULT_BB_DATASOURCE_ID,
+  sourceType: TableSourceType.INTERNAL,
   primaryDisplay: "First Name",
   schema: {
     "First Name": {
@@ -346,9 +351,10 @@ export const DEFAULT_EMPLOYEE_TABLE_SCHEMA: Table = {
 
 export const DEFAULT_JOBS_TABLE_SCHEMA: Table = {
   _id: DEFAULT_JOBS_TABLE_ID,
-  type: "internal",
+  type: "table",
   name: "Jobs",
   sourceId: DEFAULT_BB_DATASOURCE_ID,
+  sourceType: TableSourceType.INTERNAL,
   primaryDisplay: "Job ID",
   schema: {
     "Job ID": {
@@ -503,10 +509,11 @@ export const DEFAULT_JOBS_TABLE_SCHEMA: Table = {
 
 export const DEFAULT_EXPENSES_TABLE_SCHEMA: Table = {
   _id: DEFAULT_EXPENSES_TABLE_ID,
-  type: "internal",
+  type: "table",
   views: {},
   name: "Expenses",
   sourceId: DEFAULT_BB_DATASOURCE_ID,
+  sourceType: TableSourceType.INTERNAL,
   primaryDisplay: "Expense ID",
   schema: {
     "Expense ID": {
diff --git a/packages/server/src/db/linkedRows/linkUtils.ts b/packages/server/src/db/linkedRows/linkUtils.ts
index c74674a865..db9a0dc7d5 100644
--- a/packages/server/src/db/linkedRows/linkUtils.ts
+++ b/packages/server/src/db/linkedRows/linkUtils.ts
@@ -2,7 +2,12 @@ import { ViewName, getQueryIndex, isRelationshipColumn } from "../utils"
 import { FieldTypes } from "../../constants"
 import { createLinkView } from "../views/staticViews"
 import { context, logging } from "@budibase/backend-core"
-import { LinkDocument, LinkDocumentValue, Table } from "@budibase/types"
+import {
+  DatabaseQueryOpts,
+  LinkDocument,
+  LinkDocumentValue,
+  Table,
+} from "@budibase/types"
 
 export { createLinkView } from "../views/staticViews"
 
@@ -36,13 +41,13 @@ export async function getLinkDocuments(args: {
 }): Promise<LinkDocumentValue[] | LinkDocument[]> {
   const { tableId, rowId, fieldName, includeDocs } = args
   const db = context.getAppDB()
-  let params: any
+  let params: DatabaseQueryOpts
   if (rowId) {
     params = { key: [tableId, rowId] }
   }
   // only table is known
   else {
-    params = { startKey: [tableId], endKey: [tableId, {}] }
+    params = { startkey: [tableId], endkey: [tableId, {}] }
   }
   if (includeDocs) {
     params.include_docs = true
diff --git a/packages/server/src/db/utils.ts b/packages/server/src/db/utils.ts
index 2c07bd8d22..d532d8a8b2 100644
--- a/packages/server/src/db/utils.ts
+++ b/packages/server/src/db/utils.ts
@@ -5,6 +5,7 @@ import {
   FieldSchema,
   RelationshipFieldMetadata,
   VirtualDocumentType,
+  INTERNAL_TABLE_SOURCE_ID,
 } from "@budibase/types"
 import { FieldTypes } from "../constants"
 export { DocumentType, VirtualDocumentType } from "@budibase/types"
@@ -18,7 +19,7 @@ export const enum AppStatus {
 }
 
 export const BudibaseInternalDB = {
-  _id: "bb_internal",
+  _id: INTERNAL_TABLE_SOURCE_ID,
   type: dbCore.BUDIBASE_DATASOURCE_TYPE,
   name: "Budibase DB",
   source: "BUDIBASE",
diff --git a/packages/server/src/environment.ts b/packages/server/src/environment.ts
index a1701535ce..91424113ac 100644
--- a/packages/server/src/environment.ts
+++ b/packages/server/src/environment.ts
@@ -75,7 +75,6 @@ const environment = {
   },
   isTest: coreEnv.isTest,
   isJest: coreEnv.isJest,
-
   isDev: coreEnv.isDev,
   isProd: () => {
     return !coreEnv.isDev()
diff --git a/packages/server/src/integration-test/postgres.spec.ts b/packages/server/src/integration-test/postgres.spec.ts
index 90f0fc9f2c..60416853b3 100644
--- a/packages/server/src/integration-test/postgres.spec.ts
+++ b/packages/server/src/integration-test/postgres.spec.ts
@@ -1,6 +1,4 @@
 import fetch from "node-fetch"
-// @ts-ignore
-fetch.mockSearch()
 import {
   generateMakeRequest,
   MakeRequestResponse,
@@ -13,12 +11,15 @@ import {
   RelationshipType,
   Row,
   Table,
+  TableSourceType,
 } from "@budibase/types"
 import _ from "lodash"
 import { generator } from "@budibase/backend-core/tests"
 import { utils } from "@budibase/backend-core"
 import { databaseTestProviders } from "../integrations/tests/utils"
 import { Client } from "pg"
+// @ts-ignore
+fetch.mockSearch()
 
 const config = setup.getConfig()!
 
@@ -52,7 +53,7 @@ describe("postgres integrations", () => {
     async function createAuxTable(prefix: string) {
       return await config.createTable({
         name: `${prefix}_${generator.word({ length: 6 })}`,
-        type: "external",
+        type: "table",
         primary: ["id"],
         primaryDisplay: "title",
         schema: {
@@ -67,6 +68,7 @@ describe("postgres integrations", () => {
           },
         },
         sourceId: postgresDatasource._id,
+        sourceType: TableSourceType.EXTERNAL,
       })
     }
 
@@ -88,7 +90,7 @@ describe("postgres integrations", () => {
 
     primaryPostgresTable = await config.createTable({
       name: `p_${generator.word({ length: 6 })}`,
-      type: "external",
+      type: "table",
       primary: ["id"],
       schema: {
         id: {
@@ -143,6 +145,7 @@ describe("postgres integrations", () => {
         },
       },
       sourceId: postgresDatasource._id,
+      sourceType: TableSourceType.EXTERNAL,
     })
   })
 
@@ -249,7 +252,7 @@ describe("postgres integrations", () => {
   async function createDefaultPgTable() {
     return await config.createTable({
       name: generator.word({ length: 10 }),
-      type: "external",
+      type: "table",
       primary: ["id"],
       schema: {
         id: {
@@ -259,6 +262,7 @@ describe("postgres integrations", () => {
         },
       },
       sourceId: postgresDatasource._id,
+      sourceType: TableSourceType.EXTERNAL,
     })
   }
 
diff --git a/packages/server/src/integrations/googlesheets.ts b/packages/server/src/integrations/googlesheets.ts
index 57b6682cc8..58c867ea0b 100644
--- a/packages/server/src/integrations/googlesheets.ts
+++ b/packages/server/src/integrations/googlesheets.ts
@@ -10,11 +10,12 @@ import {
   QueryJson,
   QueryType,
   Row,
+  Schema,
   SearchFilters,
   SortJson,
-  ExternalTable,
+  Table,
   TableRequest,
-  Schema,
+  TableSourceType,
 } from "@budibase/types"
 import { OAuth2Client } from "google-auth-library"
 import {
@@ -262,11 +263,13 @@ class GoogleSheetsIntegration implements DatasourcePlus {
     id?: string
   ) {
     // base table
-    const table: ExternalTable = {
+    const table: Table = {
+      type: "table",
       name: title,
       primary: [GOOGLE_SHEETS_PRIMARY_KEY],
       schema: {},
       sourceId: datasourceId,
+      sourceType: TableSourceType.EXTERNAL,
     }
     if (id) {
       table._id = id
@@ -283,7 +286,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
 
   async buildSchema(
     datasourceId: string,
-    entities: Record<string, ExternalTable>
+    entities: Record<string, Table>
   ): Promise<Schema> {
     // not fully configured yet
     if (!this.config.auth) {
@@ -291,7 +294,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
     }
     await this.connect()
     const sheets = this.client.sheetsByIndex
-    const tables: Record<string, ExternalTable> = {}
+    const tables: Record<string, Table> = {}
     let errors: Record<string, string> = {}
     await utils.parallelForeach(
       sheets,
diff --git a/packages/server/src/integrations/microsoftSqlServer.ts b/packages/server/src/integrations/microsoftSqlServer.ts
index ff68026369..c615e5ba48 100644
--- a/packages/server/src/integrations/microsoftSqlServer.ts
+++ b/packages/server/src/integrations/microsoftSqlServer.ts
@@ -2,7 +2,7 @@ import {
   DatasourceFieldType,
   Integration,
   Operation,
-  ExternalTable,
+  Table,
   TableSchema,
   QueryJson,
   QueryType,
@@ -12,6 +12,7 @@ import {
   ConnectionInfo,
   SourceName,
   Schema,
+  TableSourceType,
 } from "@budibase/types"
 import {
   getSqlQuery,
@@ -380,7 +381,7 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
    */
   async buildSchema(
     datasourceId: string,
-    entities: Record<string, ExternalTable>
+    entities: Record<string, Table>
   ): Promise<Schema> {
     await this.connect()
     let tableInfo: MSSQLTablesResponse[] = await this.runSQL(this.TABLES_SQL)
@@ -394,7 +395,7 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
       .map((record: any) => record.TABLE_NAME)
       .filter((name: string) => this.MASTER_TABLES.indexOf(name) === -1)
 
-    const tables: Record<string, ExternalTable> = {}
+    const tables: Record<string, Table> = {}
     for (let tableName of tableNames) {
       // get the column definition (type)
       const definition = await this.runSQL(
@@ -439,7 +440,9 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
       }
       tables[tableName] = {
         _id: buildExternalTableId(datasourceId, tableName),
+        type: "table",
         sourceId: datasourceId,
+        sourceType: TableSourceType.EXTERNAL,
         primary: primaryKeys,
         name: tableName,
         schema,
diff --git a/packages/server/src/integrations/mysql.ts b/packages/server/src/integrations/mysql.ts
index 3a954da9bd..e89393d251 100644
--- a/packages/server/src/integrations/mysql.ts
+++ b/packages/server/src/integrations/mysql.ts
@@ -4,13 +4,14 @@ import {
   QueryType,
   QueryJson,
   SqlQuery,
-  ExternalTable,
+  Table,
   TableSchema,
   DatasourcePlus,
   DatasourceFeature,
   ConnectionInfo,
   SourceName,
   Schema,
+  TableSourceType,
 } from "@budibase/types"
 import {
   getSqlQuery,
@@ -278,9 +279,9 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
 
   async buildSchema(
     datasourceId: string,
-    entities: Record<string, ExternalTable>
+    entities: Record<string, Table>
   ): Promise<Schema> {
-    const tables: { [key: string]: ExternalTable } = {}
+    const tables: { [key: string]: Table } = {}
     await this.connect()
 
     try {
@@ -317,8 +318,10 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
         }
         if (!tables[tableName]) {
           tables[tableName] = {
+            type: "table",
             _id: buildExternalTableId(datasourceId, tableName),
             sourceId: datasourceId,
+            sourceType: TableSourceType.EXTERNAL,
             primary: primaryKeys,
             name: tableName,
             schema,
diff --git a/packages/server/src/integrations/oracle.ts b/packages/server/src/integrations/oracle.ts
index b3936320ac..c6a871e41f 100644
--- a/packages/server/src/integrations/oracle.ts
+++ b/packages/server/src/integrations/oracle.ts
@@ -5,11 +5,12 @@ import {
   QueryJson,
   QueryType,
   SqlQuery,
-  ExternalTable,
+  Table,
   DatasourcePlus,
   DatasourceFeature,
   ConnectionInfo,
   Schema,
+  TableSourceType,
 } from "@budibase/types"
 import {
   buildExternalTableId,
@@ -263,25 +264,27 @@ class OracleIntegration extends Sql implements DatasourcePlus {
    */
   async buildSchema(
     datasourceId: string,
-    entities: Record<string, ExternalTable>
+    entities: Record<string, Table>
   ): Promise<Schema> {
     const columnsResponse = await this.internalQuery<OracleColumnsResponse>({
       sql: this.COLUMNS_SQL,
     })
     const oracleTables = this.mapColumns(columnsResponse)
 
-    const tables: { [key: string]: ExternalTable } = {}
+    const tables: { [key: string]: Table } = {}
 
     // iterate each table
     Object.values(oracleTables).forEach(oracleTable => {
       let table = tables[oracleTable.name]
       if (!table) {
         table = {
+          type: "table",
           _id: buildExternalTableId(datasourceId, oracleTable.name),
           primary: [],
           name: oracleTable.name,
           schema: {},
           sourceId: datasourceId,
+          sourceType: TableSourceType.EXTERNAL,
         }
         tables[oracleTable.name] = table
       }
diff --git a/packages/server/src/integrations/postgres.ts b/packages/server/src/integrations/postgres.ts
index 8479cd05d8..4d7dc33d75 100644
--- a/packages/server/src/integrations/postgres.ts
+++ b/packages/server/src/integrations/postgres.ts
@@ -5,12 +5,13 @@ import {
   QueryType,
   QueryJson,
   SqlQuery,
-  ExternalTable,
+  Table,
   DatasourcePlus,
   DatasourceFeature,
   ConnectionInfo,
   SourceName,
   Schema,
+  TableSourceType,
 } from "@budibase/types"
 import {
   getSqlQuery,
@@ -273,7 +274,7 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
    */
   async buildSchema(
     datasourceId: string,
-    entities: Record<string, ExternalTable>
+    entities: Record<string, Table>
   ): Promise<Schema> {
     let tableKeys: { [key: string]: string[] } = {}
     await this.openConnection()
@@ -300,7 +301,7 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
       const columnsResponse: { rows: PostgresColumn[] } =
         await this.client.query(this.COLUMNS_SQL)
 
-      const tables: { [key: string]: ExternalTable } = {}
+      const tables: { [key: string]: Table } = {}
 
       for (let column of columnsResponse.rows) {
         const tableName: string = column.table_name
@@ -309,11 +310,13 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
         // table key doesn't exist yet
         if (!tables[tableName] || !tables[tableName].schema) {
           tables[tableName] = {
+            type: "table",
             _id: buildExternalTableId(datasourceId, tableName),
             primary: tableKeys[tableName] || [],
             name: tableName,
             schema: {},
             sourceId: datasourceId,
+            sourceType: TableSourceType.EXTERNAL,
           }
         }
 
diff --git a/packages/server/src/integrations/tests/googlesheets.spec.ts b/packages/server/src/integrations/tests/googlesheets.spec.ts
index 748baddc39..10ec7815d6 100644
--- a/packages/server/src/integrations/tests/googlesheets.spec.ts
+++ b/packages/server/src/integrations/tests/googlesheets.spec.ts
@@ -30,18 +30,24 @@ GoogleSpreadsheet.mockImplementation(() => mockGoogleIntegration)
 import { structures } from "@budibase/backend-core/tests"
 import TestConfiguration from "../../tests/utilities/TestConfiguration"
 import GoogleSheetsIntegration from "../googlesheets"
-import { FieldType, Table, TableSchema } from "@budibase/types"
+import { FieldType, Table, TableSchema, TableSourceType } from "@budibase/types"
+import { generateDatasourceID } from "../../db/utils"
 
 describe("Google Sheets Integration", () => {
   let integration: any,
     config = new TestConfiguration()
+  let cleanupEnv: () => void
 
   beforeAll(() => {
-    config.setGoogleAuth("test")
+    cleanupEnv = config.setEnv({
+      GOOGLE_CLIENT_ID: "test",
+      GOOGLE_CLIENT_SECRET: "test",
+    })
   })
 
   afterAll(async () => {
-    await config.end()
+    cleanupEnv()
+    config.end()
   })
 
   beforeEach(async () => {
@@ -60,7 +66,10 @@ describe("Google Sheets Integration", () => {
 
   function createBasicTable(name: string, columns: string[]): Table {
     return {
+      type: "table",
       name,
+      sourceId: generateDatasourceID(),
+      sourceType: TableSourceType.EXTERNAL,
       schema: {
         ...columns.reduce((p, c) => {
           p[c] = {
diff --git a/packages/server/src/integrations/tests/utils/postgres.ts b/packages/server/src/integrations/tests/utils/postgres.ts
index b749551721..f65d33e3e0 100644
--- a/packages/server/src/integrations/tests/utils/postgres.ts
+++ b/packages/server/src/integrations/tests/utils/postgres.ts
@@ -1,39 +1,47 @@
 import { Datasource, SourceName } from "@budibase/types"
 import { GenericContainer, Wait, StartedTestContainer } from "testcontainers"
+import env from "../../../environment"
 
 let container: StartedTestContainer | undefined
 
+const isMac = process.platform === "darwin"
+
 export async function getDsConfig(): Promise<Datasource> {
-  if (!container) {
-    container = await new GenericContainer("postgres")
-      .withExposedPorts(5432)
-      .withEnv("POSTGRES_PASSWORD", "password")
-      .withWaitStrategy(
-        Wait.forLogMessage(
-          "PostgreSQL init process complete; ready for start up."
+  try {
+    if (!container) {
+      // postgres 15-bullseye safer bet on Linux
+      const version = isMac ? undefined : "15-bullseye"
+      container = await new GenericContainer("postgres", version)
+        .withExposedPorts(5432)
+        .withEnv("POSTGRES_PASSWORD", "password")
+        .withWaitStrategy(
+          Wait.forLogMessage(
+            "PostgreSQL init process complete; ready for start up."
+          )
         )
-      )
-      .start()
-  }
+        .start()
+    }
+    const host = container.getContainerIpAddress()
+    const port = container.getMappedPort(5432)
 
-  const host = container.getContainerIpAddress()
-  const port = container.getMappedPort(5432)
-
-  return {
-    type: "datasource_plus",
-    source: SourceName.POSTGRES,
-    plus: true,
-    config: {
-      host,
-      port,
-      database: "postgres",
-      user: "postgres",
-      password: "password",
-      schema: "public",
-      ssl: false,
-      rejectUnauthorized: false,
-      ca: false,
-    },
+    return {
+      type: "datasource_plus",
+      source: SourceName.POSTGRES,
+      plus: true,
+      config: {
+        host,
+        port,
+        database: "postgres",
+        user: "postgres",
+        password: "password",
+        schema: "public",
+        ssl: false,
+        rejectUnauthorized: false,
+        ca: false,
+      },
+    }
+  } catch (err) {
+    throw new Error("**UNABLE TO CREATE TO POSTGRES CONTAINER**")
   }
 }
 
diff --git a/packages/server/src/integrations/utils.ts b/packages/server/src/integrations/utils.ts
index 79b18e767c..fe8a9055b0 100644
--- a/packages/server/src/integrations/utils.ts
+++ b/packages/server/src/integrations/utils.ts
@@ -4,10 +4,14 @@ import {
   SearchFilters,
   Datasource,
   FieldType,
-  ExternalTable,
+  TableSourceType,
 } from "@budibase/types"
 import { DocumentType, SEPARATOR } from "../db/utils"
-import { InvalidColumns, NoEmptyFilterStrings } from "../constants"
+import {
+  InvalidColumns,
+  NoEmptyFilterStrings,
+  DEFAULT_BB_DATASOURCE_ID,
+} from "../constants"
 import { helpers } from "@budibase/shared-core"
 
 const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
@@ -83,10 +87,29 @@ export enum SqlClient {
   ORACLE = "oracledb",
 }
 
-export function isExternalTable(tableId: string) {
+export function isExternalTableID(tableId: string) {
   return tableId.includes(DocumentType.DATASOURCE)
 }
 
+export function isInternalTableID(tableId: string) {
+  return !isExternalTableID(tableId)
+}
+
+export function isExternalTable(table: Table) {
+  if (
+    table?.sourceId &&
+    table.sourceId.includes(DocumentType.DATASOURCE + SEPARATOR) &&
+    table?.sourceId !== DEFAULT_BB_DATASOURCE_ID
+  ) {
+    return true
+  } else if (table?.sourceType === TableSourceType.EXTERNAL) {
+    return true
+  } else if (table?._id && isExternalTableID(table._id)) {
+    return true
+  }
+  return false
+}
+
 export function buildExternalTableId(datasourceId: string, tableName: string) {
   // encode spaces
   if (tableName.includes(" ")) {
@@ -297,9 +320,9 @@ function copyExistingPropsOver(
  * @param entities The old list of tables, if there was any to look for definitions in.
  */
 export function finaliseExternalTables(
-  tables: Record<string, ExternalTable>,
-  entities: Record<string, ExternalTable>
-): Record<string, ExternalTable> {
+  tables: Record<string, Table>,
+  entities: Record<string, Table>
+): Record<string, Table> {
   let finalTables: Record<string, Table> = {}
   const tableIds = Object.values(tables).map(table => table._id!)
   for (let [name, table] of Object.entries(tables)) {
@@ -312,7 +335,7 @@ export function finaliseExternalTables(
 }
 
 export function checkExternalTables(
-  tables: Record<string, ExternalTable>
+  tables: Record<string, Table>
 ): Record<string, string> {
   const invalidColumns = Object.values(InvalidColumns) as string[]
   const errors: Record<string, string> = {}
diff --git a/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts
index bf717d5828..40ff88c1e5 100644
--- a/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts
+++ b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts
@@ -1,5 +1,12 @@
 import { generator } from "@budibase/backend-core/tests"
-import { BBRequest, FieldType, Row, Table } from "@budibase/types"
+import {
+  BBRequest,
+  FieldType,
+  Row,
+  Table,
+  INTERNAL_TABLE_SOURCE_ID,
+  TableSourceType,
+} from "@budibase/types"
 import * as utils from "../../db/utils"
 import trimViewRowInfoMiddleware from "../trimViewRowInfo"
 
@@ -73,6 +80,8 @@ describe("trimViewRowInfo middleware", () => {
   const table: Table = {
     _id: tableId,
     name: generator.word(),
+    sourceId: INTERNAL_TABLE_SOURCE_ID,
+    sourceType: TableSourceType.INTERNAL,
     type: "table",
     schema: {
       name: {
diff --git a/packages/server/src/middleware/trimViewRowInfo.ts b/packages/server/src/middleware/trimViewRowInfo.ts
index 6a7448262b..95b085a08f 100644
--- a/packages/server/src/middleware/trimViewRowInfo.ts
+++ b/packages/server/src/middleware/trimViewRowInfo.ts
@@ -1,7 +1,6 @@
 import { Ctx, Row } from "@budibase/types"
 import * as utils from "../db/utils"
 import sdk from "../sdk"
-import { db } from "@budibase/backend-core"
 import { Next } from "koa"
 import { getTableId } from "../api/controllers/row/utils"
 
diff --git a/packages/server/src/migrations/functions/backfill/global/configs.ts b/packages/server/src/migrations/functions/backfill/global/configs.ts
index 1b76727bbe..04eb9caff2 100644
--- a/packages/server/src/migrations/functions/backfill/global/configs.ts
+++ b/packages/server/src/migrations/functions/backfill/global/configs.ts
@@ -11,10 +11,11 @@ import {
   isOIDCConfig,
   isSettingsConfig,
   ConfigType,
+  DatabaseQueryOpts,
 } from "@budibase/types"
 import env from "./../../../../environment"
 
-export const getConfigParams = () => {
+export function getConfigParams(): DatabaseQueryOpts {
   return {
     include_docs: true,
     startkey: `${DocumentType.CONFIG}${SEPARATOR}`,
diff --git a/packages/server/src/sdk/app/backups/exports.ts b/packages/server/src/sdk/app/backups/exports.ts
index d5ea31cdf5..c349dcb927 100644
--- a/packages/server/src/sdk/app/backups/exports.ts
+++ b/packages/server/src/sdk/app/backups/exports.ts
@@ -26,7 +26,6 @@ export interface DBDumpOpts {
 export interface ExportOpts extends DBDumpOpts {
   tar?: boolean
   excludeRows?: boolean
-  excludeLogs?: boolean
   encryptPassword?: string
 }
 
@@ -83,14 +82,15 @@ export async function exportDB(
   })
 }
 
-function defineFilter(excludeRows?: boolean, excludeLogs?: boolean) {
-  const ids = [USER_METDATA_PREFIX, LINK_USER_METADATA_PREFIX]
+function defineFilter(excludeRows?: boolean) {
+  const ids = [
+    USER_METDATA_PREFIX,
+    LINK_USER_METADATA_PREFIX,
+    AUTOMATION_LOG_PREFIX,
+  ]
   if (excludeRows) {
     ids.push(TABLE_ROW_PREFIX)
   }
-  if (excludeLogs) {
-    ids.push(AUTOMATION_LOG_PREFIX)
-  }
   return (doc: any) =>
     !ids.map(key => doc._id.includes(key)).reduce((prev, curr) => prev || curr)
 }
@@ -118,7 +118,7 @@ export async function exportApp(appId: string, config?: ExportOpts) {
         fs.writeFileSync(join(tmpPath, path), contents)
       }
     }
-    // get all of the files
+    // get all the files
     else {
       tmpPath = await objectStore.retrieveDirectory(
         ObjectStoreBuckets.APPS,
@@ -141,7 +141,7 @@ export async function exportApp(appId: string, config?: ExportOpts) {
   // enforce an export of app DB to the tmp path
   const dbPath = join(tmpPath, DB_EXPORT_FILE)
   await exportDB(appId, {
-    filter: defineFilter(config?.excludeRows, config?.excludeLogs),
+    filter: defineFilter(config?.excludeRows),
     exportPath: dbPath,
   })
 
@@ -191,7 +191,6 @@ export async function streamExportApp({
 }) {
   const tmpPath = await exportApp(appId, {
     excludeRows,
-    excludeLogs: true,
     tar: true,
     encryptPassword,
   })
diff --git a/packages/server/src/sdk/app/links/index.ts b/packages/server/src/sdk/app/links/index.ts
new file mode 100644
index 0000000000..6655a76656
--- /dev/null
+++ b/packages/server/src/sdk/app/links/index.ts
@@ -0,0 +1,5 @@
+import * as links from "./links"
+
+export default {
+  ...links,
+}
diff --git a/packages/server/src/sdk/app/links/links.ts b/packages/server/src/sdk/app/links/links.ts
new file mode 100644
index 0000000000..fda07568f9
--- /dev/null
+++ b/packages/server/src/sdk/app/links/links.ts
@@ -0,0 +1,39 @@
+import { context } from "@budibase/backend-core"
+import { isTableId } from "@budibase/backend-core/src/docIds"
+import {
+  DatabaseQueryOpts,
+  LinkDocument,
+  LinkDocumentValue,
+} from "@budibase/types"
+import { ViewName, getQueryIndex } from "../../../../src/db/utils"
+
+export async function fetch(tableId: string): Promise<LinkDocumentValue[]> {
+  if (!isTableId(tableId)) {
+    throw new Error(`Invalid tableId: ${tableId}`)
+  }
+
+  const db = context.getAppDB()
+  const params: DatabaseQueryOpts = {
+    startkey: [tableId],
+    endkey: [tableId, {}],
+  }
+  const linkRows = (await db.query(getQueryIndex(ViewName.LINK), params)).rows
+  return linkRows.map(row => row.value as LinkDocumentValue)
+}
+
+export async function fetchWithDocument(
+  tableId: string
+): Promise<LinkDocument[]> {
+  if (!isTableId(tableId)) {
+    throw new Error(`Invalid tableId: ${tableId}`)
+  }
+
+  const db = context.getAppDB()
+  const params: DatabaseQueryOpts = {
+    startkey: [tableId],
+    endkey: [tableId, {}],
+    include_docs: true,
+  }
+  const linkRows = (await db.query(getQueryIndex(ViewName.LINK), params)).rows
+  return linkRows.map(row => row.doc as LinkDocument)
+}
diff --git a/packages/server/src/sdk/app/rows/external.ts b/packages/server/src/sdk/app/rows/external.ts
index 8bcf89a3f5..beae02e134 100644
--- a/packages/server/src/sdk/app/rows/external.ts
+++ b/packages/server/src/sdk/app/rows/external.ts
@@ -1,4 +1,4 @@
-import { IncludeRelationship, Operation, Row } from "@budibase/types"
+import { IncludeRelationship, Operation } from "@budibase/types"
 import { handleRequest } from "../../../api/controllers/row/external"
 import { breakRowIdField } from "../../../integrations/utils"
 
diff --git a/packages/server/src/sdk/app/rows/search.ts b/packages/server/src/sdk/app/rows/search.ts
index ced35db9be..31f7c74601 100644
--- a/packages/server/src/sdk/app/rows/search.ts
+++ b/packages/server/src/sdk/app/rows/search.ts
@@ -1,5 +1,5 @@
-import { SearchFilters, SearchParams, Row } from "@budibase/types"
-import { isExternalTable } from "../../../integrations/utils"
+import { Row, SearchFilters, SearchParams } from "@budibase/types"
+import { isExternalTableID } from "../../../integrations/utils"
 import * as internal from "./search/internal"
 import * as external from "./search/external"
 import { Format } from "../../../api/controllers/view/exporters"
@@ -12,7 +12,7 @@ export interface ViewParams {
 }
 
 function pickApi(tableId: any) {
-  if (isExternalTable(tableId)) {
+  if (isExternalTableID(tableId)) {
     return external
   }
   return internal
@@ -49,6 +49,10 @@ export async function fetch(tableId: string): Promise<Row[]> {
   return pickApi(tableId).fetch(tableId)
 }
 
+export async function fetchRaw(tableId: string): Promise<Row[]> {
+  return pickApi(tableId).fetchRaw(tableId)
+}
+
 export async function fetchView(
   tableId: string,
   viewName: string,
diff --git a/packages/server/src/sdk/app/rows/search/external.ts b/packages/server/src/sdk/app/rows/search/external.ts
index c41efad171..981ae1bf8d 100644
--- a/packages/server/src/sdk/app/rows/search/external.ts
+++ b/packages/server/src/sdk/app/rows/search/external.ts
@@ -186,6 +186,12 @@ export async function fetch(tableId: string): Promise<Row[]> {
   })
 }
 
+export async function fetchRaw(tableId: string): Promise<Row[]> {
+  return await handleRequest<Operation.READ>(Operation.READ, tableId, {
+    includeSqlRelationships: IncludeRelationship.INCLUDE,
+  })
+}
+
 export async function fetchView(viewName: string) {
   // there are no views in external datasources, shouldn't ever be called
   // for now just fetch
diff --git a/packages/server/src/sdk/app/rows/search/internal.ts b/packages/server/src/sdk/app/rows/search/internal.ts
index 779ff5f777..1aec8a321e 100644
--- a/packages/server/src/sdk/app/rows/search/internal.ts
+++ b/packages/server/src/sdk/app/rows/search/internal.ts
@@ -140,14 +140,13 @@ export async function exportRows(
 }
 
 export async function fetch(tableId: string): Promise<Row[]> {
-  const db = context.getAppDB()
-
   const table = await sdk.tables.getTable(tableId)
-  const rows = await getRawTableData(db, tableId)
+  const rows = await fetchRaw(tableId)
   return await outputProcessing(table, rows)
 }
 
-async function getRawTableData(db: Database, tableId: string) {
+export async function fetchRaw(tableId: string): Promise<Row[]> {
+  const db = context.getAppDB()
   let rows
   if (tableId === InternalTables.USER_METADATA) {
     rows = await sdk.users.fetchMetadata()
@@ -182,7 +181,7 @@ export async function fetchView(
     })
   } else {
     const tableId = viewInfo.meta.tableId
-    const data = await getRawTableData(db, tableId)
+    const data = await fetchRaw(tableId)
     response = await inMemoryViews.runView(
       viewInfo,
       calculation as string,
@@ -198,11 +197,7 @@ export async function fetchView(
     try {
       table = await sdk.tables.getTable(viewInfo.meta.tableId)
     } catch (err) {
-      /* istanbul ignore next */
-      table = {
-        name: "",
-        schema: {},
-      }
+      throw new Error("Unable to retrieve view table.")
     }
     rows = await outputProcessing(table, response.rows)
   }
diff --git a/packages/server/src/sdk/app/rows/search/tests/external.spec.ts b/packages/server/src/sdk/app/rows/search/tests/external.spec.ts
index b3bddfbc97..c92155230a 100644
--- a/packages/server/src/sdk/app/rows/search/tests/external.spec.ts
+++ b/packages/server/src/sdk/app/rows/search/tests/external.spec.ts
@@ -7,6 +7,7 @@ import {
   SourceName,
   Table,
   SearchParams,
+  TableSourceType,
 } from "@budibase/types"
 
 import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
@@ -15,6 +16,7 @@ import {
   expectAnyExternalColsAttributes,
   generator,
 } from "@budibase/backend-core/tests"
+import datasource from "../../../../../api/routes/datasource"
 
 jest.unmock("mysql2/promise")
 
@@ -23,36 +25,7 @@ jest.setTimeout(30000)
 describe.skip("external", () => {
   const config = new TestConfiguration()
 
-  let externalDatasource: Datasource
-
-  const tableData: Table = {
-    name: generator.word(),
-    type: "external",
-    primary: ["id"],
-    schema: {
-      id: {
-        name: "id",
-        type: FieldType.AUTO,
-        autocolumn: true,
-      },
-      name: {
-        name: "name",
-        type: FieldType.STRING,
-      },
-      surname: {
-        name: "surname",
-        type: FieldType.STRING,
-      },
-      age: {
-        name: "age",
-        type: FieldType.NUMBER,
-      },
-      address: {
-        name: "address",
-        type: FieldType.STRING,
-      },
-    },
-  }
+  let externalDatasource: Datasource, tableData: Table
 
   beforeAll(async () => {
     const container = await new GenericContainer("mysql")
@@ -84,12 +57,43 @@ describe.skip("external", () => {
         },
       },
     })
+
+    tableData = {
+      name: generator.word(),
+      type: "table",
+      primary: ["id"],
+      sourceId: externalDatasource._id!,
+      sourceType: TableSourceType.EXTERNAL,
+      schema: {
+        id: {
+          name: "id",
+          type: FieldType.AUTO,
+          autocolumn: true,
+        },
+        name: {
+          name: "name",
+          type: FieldType.STRING,
+        },
+        surname: {
+          name: "surname",
+          type: FieldType.STRING,
+        },
+        age: {
+          name: "age",
+          type: FieldType.NUMBER,
+        },
+        address: {
+          name: "address",
+          type: FieldType.STRING,
+        },
+      },
+    }
   })
 
   describe("search", () => {
     const rows: Row[] = []
     beforeAll(async () => {
-      const table = await config.createTable({
+      const table = await config.createExternalTable({
         ...tableData,
         sourceId: externalDatasource._id,
       })
diff --git a/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts b/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts
index b3e98a1149..d82af66e3d 100644
--- a/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts
+++ b/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts
@@ -1,4 +1,11 @@
-import { FieldType, Row, Table, SearchParams } from "@budibase/types"
+import {
+  FieldType,
+  Row,
+  Table,
+  SearchParams,
+  INTERNAL_TABLE_SOURCE_ID,
+  TableSourceType,
+} from "@budibase/types"
 import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
 import { search } from "../internal"
 import {
@@ -12,6 +19,8 @@ describe("internal", () => {
   const tableData: Table = {
     name: generator.word(),
     type: "table",
+    sourceId: INTERNAL_TABLE_SOURCE_ID,
+    sourceType: TableSourceType.INTERNAL,
     schema: {
       name: {
         name: "name",
diff --git a/packages/server/src/sdk/app/rows/search/tests/utils.spec.ts b/packages/server/src/sdk/app/rows/search/tests/utils.spec.ts
index d946eea432..055628c41c 100644
--- a/packages/server/src/sdk/app/rows/search/tests/utils.spec.ts
+++ b/packages/server/src/sdk/app/rows/search/tests/utils.spec.ts
@@ -3,14 +3,19 @@ import { db as dbCore } from "@budibase/backend-core"
 import {
   FieldType,
   FieldTypeSubtypes,
-  Table,
+  INTERNAL_TABLE_SOURCE_ID,
   SearchParams,
+  Table,
+  TableSourceType,
 } from "@budibase/types"
 
 const tableId = "ta_a"
 const tableWithUserCol: Table = {
+  type: "table",
   _id: tableId,
   name: "table",
+  sourceId: INTERNAL_TABLE_SOURCE_ID,
+  sourceType: TableSourceType.INTERNAL,
   schema: {
     user: {
       name: "user",
@@ -21,8 +26,11 @@ const tableWithUserCol: Table = {
 }
 
 const tableWithUsersCol: Table = {
+  type: "table",
   _id: tableId,
   name: "table",
+  sourceId: INTERNAL_TABLE_SOURCE_ID,
+  sourceType: TableSourceType.INTERNAL,
   schema: {
     user: {
       name: "user",
diff --git a/packages/server/src/sdk/app/tables/external/index.ts b/packages/server/src/sdk/app/tables/external/index.ts
index 402baada78..f445fcaf08 100644
--- a/packages/server/src/sdk/app/tables/external/index.ts
+++ b/packages/server/src/sdk/app/tables/external/index.ts
@@ -35,10 +35,10 @@ export async function save(
   opts?: { tableId?: string; renaming?: RenameColumn }
 ) {
   let tableToSave: TableRequest = {
+    ...update,
     type: "table",
     _id: buildExternalTableId(datasourceId, update.name),
     sourceId: datasourceId,
-    ...update,
   }
 
   const tableId = opts?.tableId || update._id
diff --git a/packages/server/src/sdk/app/tables/external/utils.ts b/packages/server/src/sdk/app/tables/external/utils.ts
index 10c755a7d6..bde812dd3d 100644
--- a/packages/server/src/sdk/app/tables/external/utils.ts
+++ b/packages/server/src/sdk/app/tables/external/utils.ts
@@ -6,6 +6,7 @@ import {
   RelationshipFieldMetadata,
   RelationshipType,
   Table,
+  TableSourceType,
 } from "@budibase/types"
 import { FieldTypes } from "../../../../constants"
 import {
@@ -76,12 +77,16 @@ export function generateManyLinkSchema(
   const primary = table.name + table.primary[0]
   const relatedPrimary = relatedTable.name + relatedTable.primary[0]
   const jcTblName = generateJunctionTableName(column, table, relatedTable)
+  const datasourceId = datasource._id!
   // first create the new table
-  const junctionTable = {
-    _id: buildExternalTableId(datasource._id!, jcTblName),
+  const junctionTable: Table = {
+    type: "table",
+    _id: buildExternalTableId(datasourceId, jcTblName),
     name: jcTblName,
     primary: [primary, relatedPrimary],
     constrained: [primary, relatedPrimary],
+    sourceId: datasourceId,
+    sourceType: TableSourceType.EXTERNAL,
     schema: {
       [primary]: foreignKeyStructure(primary, {
         toTable: table.name,
diff --git a/packages/server/src/sdk/app/tables/getters.ts b/packages/server/src/sdk/app/tables/getters.ts
index 02cef748c5..c0d5fe8da8 100644
--- a/packages/server/src/sdk/app/tables/getters.ts
+++ b/packages/server/src/sdk/app/tables/getters.ts
@@ -1,30 +1,47 @@
 import { context } from "@budibase/backend-core"
-import {
-  BudibaseInternalDB,
-  getMultiIDParams,
-  getTableParams,
-} from "../../../db/utils"
+import { getMultiIDParams, getTableParams } from "../../../db/utils"
 import {
   breakExternalTableId,
-  isExternalTable,
+  isExternalTableID,
   isSQL,
 } from "../../../integrations/utils"
 import {
-  AllDocsResponse,
   Database,
+  INTERNAL_TABLE_SOURCE_ID,
   Table,
   TableResponse,
+  TableSourceType,
   TableViewsResponse,
 } from "@budibase/types"
 import datasources from "../datasources"
 import sdk from "../../../sdk"
 
-function processInternalTables(docs: AllDocsResponse<Table[]>): Table[] {
-  return docs.rows.map((tableDoc: any) => ({
-    ...tableDoc.doc,
-    type: "internal",
-    sourceId: tableDoc.doc.sourceId || BudibaseInternalDB._id,
-  }))
+export function processTable(table: Table): Table {
+  if (table._id && isExternalTableID(table._id)) {
+    return {
+      ...table,
+      type: "table",
+      sourceType: TableSourceType.EXTERNAL,
+    }
+  } else {
+    return {
+      ...table,
+      type: "table",
+      sourceId: table.sourceId || INTERNAL_TABLE_SOURCE_ID,
+      sourceType: TableSourceType.INTERNAL,
+    }
+  }
+}
+
+export function processTables(tables: Table[]): Table[] {
+  return tables.map(table => processTable(table))
+}
+
+function processEntities(tables: Record<string, Table>) {
+  for (let key of Object.keys(tables)) {
+    tables[key] = processTable(tables[key])
+  }
+  return tables
 }
 
 export async function getAllInternalTables(db?: Database): Promise<Table[]> {
@@ -36,7 +53,7 @@ export async function getAllInternalTables(db?: Database): Promise<Table[]> {
       include_docs: true,
     })
   )
-  return processInternalTables(internalTables)
+  return processTables(internalTables.rows.map(row => row.doc!))
 }
 
 async function getAllExternalTables(): Promise<Table[]> {
@@ -48,7 +65,7 @@ async function getAllExternalTables(): Promise<Table[]> {
       final = final.concat(Object.values(entities))
     }
   }
-  return final
+  return processTables(final)
 }
 
 export async function getExternalTable(
@@ -56,19 +73,21 @@ export async function getExternalTable(
   tableName: string
 ): Promise<Table> {
   const entities = await getExternalTablesInDatasource(datasourceId)
-  return entities[tableName]
+  return processTable(entities[tableName])
 }
 
 export async function getTable(tableId: string): Promise<Table> {
   const db = context.getAppDB()
-  if (isExternalTable(tableId)) {
+  let output: Table
+  if (isExternalTableID(tableId)) {
     let { datasourceId, tableName } = breakExternalTableId(tableId)
     const datasource = await datasources.get(datasourceId!)
     const table = await getExternalTable(datasourceId!, tableName!)
-    return { ...table, sql: isSQL(datasource) }
+    output = { ...table, sql: isSQL(datasource) }
   } else {
-    return db.get(tableId)
+    output = await db.get<Table>(tableId)
   }
+  return processTable(output)
 }
 
 export async function getAllTables() {
@@ -76,7 +95,7 @@ export async function getAllTables() {
     getAllInternalTables(),
     getAllExternalTables(),
   ])
-  return [...internal, ...external]
+  return processTables([...internal, ...external])
 }
 
 export async function getExternalTablesInDatasource(
@@ -86,12 +105,14 @@ export async function getExternalTablesInDatasource(
   if (!datasource || !datasource.entities) {
     throw new Error("Datasource is not configured fully.")
   }
-  return datasource.entities
+  return processEntities(datasource.entities)
 }
 
 export async function getTables(tableIds: string[]): Promise<Table[]> {
-  const externalTableIds = tableIds.filter(tableId => isExternalTable(tableId)),
-    internalTableIds = tableIds.filter(tableId => !isExternalTable(tableId))
+  const externalTableIds = tableIds.filter(tableId =>
+      isExternalTableID(tableId)
+    ),
+    internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId))
   let tables: Table[] = []
   if (externalTableIds.length) {
     const externalTables = await getAllExternalTables()
@@ -106,9 +127,9 @@ export async function getTables(tableIds: string[]): Promise<Table[]> {
     const internalTableDocs = await db.allDocs<Table[]>(
       getMultiIDParams(internalTableIds)
     )
-    tables = tables.concat(processInternalTables(internalTableDocs))
+    tables = tables.concat(internalTableDocs.rows.map(row => row.doc!))
   }
-  return tables
+  return processTables(tables)
 }
 
 export function enrichViewSchemas(table: Table): TableResponse {
diff --git a/packages/server/src/sdk/app/tables/index.ts b/packages/server/src/sdk/app/tables/index.ts
index 8542250517..ed71498d44 100644
--- a/packages/server/src/sdk/app/tables/index.ts
+++ b/packages/server/src/sdk/app/tables/index.ts
@@ -2,10 +2,12 @@ import { populateExternalTableSchemas } from "./validation"
 import * as getters from "./getters"
 import * as updates from "./update"
 import * as utils from "./utils"
+import { migrate } from "./migration"
 
 export default {
   populateExternalTableSchemas,
   ...updates,
   ...getters,
   ...utils,
+  migrate,
 }
diff --git a/packages/server/src/sdk/app/tables/migration.ts b/packages/server/src/sdk/app/tables/migration.ts
new file mode 100644
index 0000000000..5a6b0c5bc0
--- /dev/null
+++ b/packages/server/src/sdk/app/tables/migration.ts
@@ -0,0 +1,194 @@
+import { BadRequestError, context, db as dbCore } from "@budibase/backend-core"
+import {
+  BBReferenceFieldMetadata,
+  FieldSchema,
+  FieldSubtype,
+  InternalTable,
+  isBBReferenceField,
+  isRelationshipField,
+  LinkDocument,
+  RelationshipFieldMetadata,
+  RelationshipType,
+  Row,
+  Table,
+} from "@budibase/types"
+import sdk from "../../../sdk"
+import { isExternalTableID } from "../../../integrations/utils"
+import { EventType, updateLinks } from "../../../db/linkedRows"
+import { cloneDeep } from "lodash"
+import { isInternalColumnName } from "@budibase/backend-core/src/db"
+
+export interface MigrationResult {
+  tablesUpdated: Table[]
+}
+
+export async function migrate(
+  table: Table,
+  oldColumn: FieldSchema,
+  newColumn: FieldSchema
+): Promise<MigrationResult> {
+  if (newColumn.name in table.schema) {
+    throw new BadRequestError(`Column "${newColumn.name}" already exists`)
+  }
+
+  if (newColumn.name === "") {
+    throw new BadRequestError(`Column name cannot be empty`)
+  }
+
+  if (isInternalColumnName(newColumn.name)) {
+    throw new BadRequestError(`Column name cannot be a reserved column name`)
+  }
+
+  table.schema[newColumn.name] = newColumn
+  table = await sdk.tables.saveTable(table)
+
+  let migrator = getColumnMigrator(table, oldColumn, newColumn)
+  try {
+    return await migrator.doMigration()
+  } catch (e) {
+    // If the migration fails then we need to roll back the table schema
+    // change.
+    delete table.schema[newColumn.name]
+    await sdk.tables.saveTable(table)
+    throw e
+  }
+}
+
+interface ColumnMigrator {
+  doMigration(): Promise<MigrationResult>
+}
+
+function getColumnMigrator(
+  table: Table,
+  oldColumn: FieldSchema,
+  newColumn: FieldSchema
+): ColumnMigrator {
+  // For now, we're only supporting migrations of user relationships to user
+  // columns in internal tables. In the future, we may want to support other
+  // migrations but for now return an error if we aren't migrating a user
+  // relationship.
+  if (isExternalTableID(table._id!)) {
+    throw new BadRequestError("External tables cannot be migrated")
+  }
+
+  if (!(oldColumn.name in table.schema)) {
+    throw new BadRequestError(`Column "${oldColumn.name}" does not exist`)
+  }
+
+  if (!isBBReferenceField(newColumn)) {
+    throw new BadRequestError(`Column "${newColumn.name}" is not a user column`)
+  }
+
+  if (newColumn.subtype !== "user" && newColumn.subtype !== "users") {
+    throw new BadRequestError(`Column "${newColumn.name}" is not a user column`)
+  }
+
+  if (!isRelationshipField(oldColumn)) {
+    throw new BadRequestError(
+      `Column "${oldColumn.name}" is not a user relationship`
+    )
+  }
+
+  if (oldColumn.tableId !== InternalTable.USER_METADATA) {
+    throw new BadRequestError(
+      `Column "${oldColumn.name}" is not a user relationship`
+    )
+  }
+
+  if (oldColumn.relationshipType === RelationshipType.ONE_TO_MANY) {
+    if (newColumn.subtype !== FieldSubtype.USER) {
+      throw new BadRequestError(
+        `Column "${oldColumn.name}" is a one-to-many column but "${newColumn.name}" is not a single user column`
+      )
+    }
+    return new SingleUserColumnMigrator(table, oldColumn, newColumn)
+  }
+  if (
+    oldColumn.relationshipType === RelationshipType.MANY_TO_MANY ||
+    oldColumn.relationshipType === RelationshipType.MANY_TO_ONE
+  ) {
+    if (newColumn.subtype !== FieldSubtype.USERS) {
+      throw new BadRequestError(
+        `Column "${oldColumn.name}" is a ${oldColumn.relationshipType} column but "${newColumn.name}" is not a multi user column`
+      )
+    }
+    return new MultiUserColumnMigrator(table, oldColumn, newColumn)
+  }
+
+  throw new BadRequestError(`Unknown migration type`)
+}
+
+abstract class UserColumnMigrator implements ColumnMigrator {
+  constructor(
+    protected table: Table,
+    protected oldColumn: RelationshipFieldMetadata,
+    protected newColumn: BBReferenceFieldMetadata
+  ) {}
+
+  abstract updateRow(row: Row, link: LinkDocument): void
+
+  async doMigration(): Promise<MigrationResult> {
+    let oldTable = cloneDeep(this.table)
+    let rows = await sdk.rows.fetchRaw(this.table._id!)
+    let rowsById = rows.reduce((acc, row) => {
+      acc[row._id!] = row
+      return acc
+    }, {} as Record<string, Row>)
+
+    let links = await sdk.links.fetchWithDocument(this.table._id!)
+    for (let link of links) {
+      if (
+        link.doc1.tableId !== this.table._id ||
+        link.doc1.fieldName !== this.oldColumn.name ||
+        link.doc2.tableId !== InternalTable.USER_METADATA
+      ) {
+        continue
+      }
+
+      let row = rowsById[link.doc1.rowId]
+      if (!row) {
+        // This can happen if the row has been deleted but the link hasn't,
+        // which was a state that was found during the initial testing of this
+        // feature. Not sure exactly what can cause it, but best to be safe.
+        continue
+      }
+
+      this.updateRow(row, link)
+    }
+
+    let db = context.getAppDB()
+    await db.bulkDocs(rows)
+
+    delete this.table.schema[this.oldColumn.name]
+    this.table = await sdk.tables.saveTable(this.table)
+    await updateLinks({
+      eventType: EventType.TABLE_UPDATED,
+      table: this.table,
+      oldTable,
+    })
+
+    let otherTable = await sdk.tables.getTable(this.oldColumn.tableId)
+    return {
+      tablesUpdated: [this.table, otherTable],
+    }
+  }
+}
+
+class SingleUserColumnMigrator extends UserColumnMigrator {
+  updateRow(row: Row, link: LinkDocument): void {
+    row[this.newColumn.name] = dbCore.getGlobalIDFromUserMetadataID(
+      link.doc2.rowId
+    )
+  }
+}
+
+class MultiUserColumnMigrator extends UserColumnMigrator {
+  updateRow(row: Row, link: LinkDocument): void {
+    if (!row[this.newColumn.name]) {
+      row[this.newColumn.name] = []
+    }
+    row[this.newColumn.name].push(
+      dbCore.getGlobalIDFromUserMetadataID(link.doc2.rowId)
+    )
+  }
+}
diff --git a/packages/server/src/sdk/app/tables/tests/tables.spec.ts b/packages/server/src/sdk/app/tables/tests/tables.spec.ts
index 78ebe59f01..457988c476 100644
--- a/packages/server/src/sdk/app/tables/tests/tables.spec.ts
+++ b/packages/server/src/sdk/app/tables/tests/tables.spec.ts
@@ -1,4 +1,10 @@
-import { FieldType, Table, ViewV2 } from "@budibase/types"
+import {
+  FieldType,
+  INTERNAL_TABLE_SOURCE_ID,
+  Table,
+  TableSourceType,
+  ViewV2,
+} from "@budibase/types"
 import { generator } from "@budibase/backend-core/tests"
 import sdk from "../../.."
 
@@ -13,6 +19,8 @@ describe("table sdk", () => {
       _id: generator.guid(),
       name: "TestTable",
       type: "table",
+      sourceId: INTERNAL_TABLE_SOURCE_ID,
+      sourceType: TableSourceType.INTERNAL,
       schema: {
         name: {
           type: FieldType.STRING,
diff --git a/packages/server/src/sdk/app/tables/tests/validation.spec.ts b/packages/server/src/sdk/app/tables/tests/validation.spec.ts
index 5347eede90..66b4222005 100644
--- a/packages/server/src/sdk/app/tables/tests/validation.spec.ts
+++ b/packages/server/src/sdk/app/tables/tests/validation.spec.ts
@@ -1,73 +1,92 @@
 import { populateExternalTableSchemas } from "../validation"
 import { cloneDeep } from "lodash/fp"
-import { AutoReason, Datasource, Table } from "@budibase/types"
+import {
+  AutoReason,
+  Datasource,
+  FieldType,
+  RelationshipType,
+  SourceName,
+  Table,
+  TableSourceType,
+} from "@budibase/types"
 import { isEqual } from "lodash"
+import { generateDatasourceID } from "../../../../db/utils"
 
-const SCHEMA = {
+const datasourceId = generateDatasourceID()
+
+const SCHEMA: Datasource = {
+  source: SourceName.POSTGRES,
+  type: "datasource",
+  _id: datasourceId,
   entities: {
     client: {
+      type: "table",
       _id: "tableA",
       name: "client",
       primary: ["idC"],
       primaryDisplay: "Name",
+      sourceId: datasourceId,
+      sourceType: TableSourceType.EXTERNAL,
       schema: {
         idC: {
           autocolumn: true,
           externalType: "int unsigned",
           name: "idC",
-          type: "number",
+          type: FieldType.NUMBER,
         },
         Name: {
           autocolumn: false,
           externalType: "varchar(255)",
           name: "Name",
-          type: "string",
+          type: FieldType.STRING,
         },
         project: {
           fieldName: "idC",
           foreignKey: "idC",
           main: true,
           name: "project",
-          relationshipType: "many-to-one",
+          relationshipType: RelationshipType.MANY_TO_ONE,
           tableId: "tableB",
-          type: "link",
+          type: FieldType.LINK,
         },
       },
     },
     project: {
+      type: "table",
       _id: "tableB",
       name: "project",
       primary: ["idP"],
       primaryDisplay: "Name",
+      sourceId: datasourceId,
+      sourceType: TableSourceType.EXTERNAL,
       schema: {
         idC: {
           externalType: "int unsigned",
           name: "idC",
-          type: "number",
+          type: FieldType.NUMBER,
         },
         idP: {
           autocolumn: true,
           externalType: "int unsigned",
           name: "idProject",
-          type: "number",
+          type: FieldType.NUMBER,
         },
         Name: {
           autocolumn: false,
           externalType: "varchar(255)",
           name: "Name",
-          type: "string",
+          type: FieldType.STRING,
         },
         client: {
           fieldName: "idC",
           foreignKey: "idC",
           name: "client",
-          relationshipType: "one-to-many",
+          relationshipType: RelationshipType.ONE_TO_MANY,
           tableId: "tableA",
-          type: "link",
+          type: FieldType.LINK,
         },
       },
       sql: true,
-      type: "table",
     },
   },
 }
@@ -95,12 +114,12 @@ describe("validation and update of external table schemas", () => {
   function noOtherTableChanges(response: any) {
     checkOtherColumns(
       response.entities!.client!,
-      SCHEMA.entities.client as Table,
+      SCHEMA.entities!.client,
       OTHER_CLIENT_COLS
     )
     checkOtherColumns(
       response.entities!.project!,
-      SCHEMA.entities.project as Table,
+      SCHEMA.entities!.project,
       OTHER_PROJECT_COLS
     )
   }
diff --git a/packages/server/src/sdk/app/tables/update.ts b/packages/server/src/sdk/app/tables/update.ts
index 9bba4a967e..5c762e628b 100644
--- a/packages/server/src/sdk/app/tables/update.ts
+++ b/packages/server/src/sdk/app/tables/update.ts
@@ -1,23 +1,30 @@
 import { Table, RenameColumn } from "@budibase/types"
-import { isExternalTable } from "../../../integrations/utils"
+import { isExternalTableID } from "../../../integrations/utils"
 import sdk from "../../index"
 import { context } from "@budibase/backend-core"
 import { isExternal } from "./utils"
+import { DocumentInsertResponse } from "@budibase/nano"
 
 import * as external from "./external"
 import * as internal from "./internal"
+import { cloneDeep } from "lodash"
 export * as external from "./external"
 export * as internal from "./internal"
 
-export async function saveTable(table: Table) {
+export async function saveTable(table: Table): Promise<Table> {
   const db = context.getAppDB()
-  if (isExternalTable(table._id!)) {
+  let resp: DocumentInsertResponse
+  if (isExternalTableID(table._id!)) {
     const datasource = await sdk.datasources.get(table.sourceId!)
     datasource.entities![table.name] = table
-    await db.put(datasource)
+    resp = await db.put(datasource)
   } else {
-    await db.put(table)
+    resp = await db.put(table)
   }
+
+  let tableClone = cloneDeep(table)
+  tableClone._rev = resp.rev
+  return tableClone
 }
 
 export async function update(table: Table, renaming?: RenameColumn) {
diff --git a/packages/server/src/sdk/app/tables/utils.ts b/packages/server/src/sdk/app/tables/utils.ts
index 88543e7c4c..b8e3d888af 100644
--- a/packages/server/src/sdk/app/tables/utils.ts
+++ b/packages/server/src/sdk/app/tables/utils.ts
@@ -1,10 +1,10 @@
-import { Table } from "@budibase/types"
-import { isExternalTable } from "../../../integrations/utils"
+import { Table, TableSourceType } from "@budibase/types"
+import { isExternalTableID } from "../../../integrations/utils"
 
 export function isExternal(opts: { table?: Table; tableId?: string }): boolean {
-  if (opts.table && opts.table.type === "external") {
+  if (opts.table && opts.table.sourceType === TableSourceType.EXTERNAL) {
     return true
-  } else if (opts.tableId && isExternalTable(opts.tableId)) {
+  } else if (opts.tableId && isExternalTableID(opts.tableId)) {
     return true
   }
   return false
diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts
index 927f82cc68..67e7158f21 100644
--- a/packages/server/src/sdk/app/views/index.ts
+++ b/packages/server/src/sdk/app/views/index.ts
@@ -4,13 +4,13 @@ import { cloneDeep } from "lodash"
 
 import sdk from "../../../sdk"
 import * as utils from "../../../db/utils"
-import { isExternalTable } from "../../../integrations/utils"
+import { isExternalTableID } from "../../../integrations/utils"
 
 import * as internal from "./internal"
 import * as external from "./external"
 
 function pickApi(tableId: any) {
-  if (isExternalTable(tableId)) {
+  if (isExternalTableID(tableId)) {
     return external
   }
   return internal
diff --git a/packages/server/src/sdk/app/views/tests/views.spec.ts b/packages/server/src/sdk/app/views/tests/views.spec.ts
index 8fcc6405ef..508285651a 100644
--- a/packages/server/src/sdk/app/views/tests/views.spec.ts
+++ b/packages/server/src/sdk/app/views/tests/views.spec.ts
@@ -2,8 +2,10 @@ import _ from "lodash"
 import {
   FieldSchema,
   FieldType,
+  INTERNAL_TABLE_SOURCE_ID,
   Table,
   TableSchema,
+  TableSourceType,
   ViewV2,
 } from "@budibase/types"
 import { generator } from "@budibase/backend-core/tests"
@@ -14,6 +16,8 @@ describe("table sdk", () => {
     _id: generator.guid(),
     name: "TestTable",
     type: "table",
+    sourceId: INTERNAL_TABLE_SOURCE_ID,
+    sourceType: TableSourceType.INTERNAL,
     schema: {
       name: {
         type: FieldType.STRING,
diff --git a/packages/server/src/sdk/index.ts b/packages/server/src/sdk/index.ts
index 24eb1ebf3c..c3057e3d4f 100644
--- a/packages/server/src/sdk/index.ts
+++ b/packages/server/src/sdk/index.ts
@@ -5,6 +5,7 @@ import { default as applications } from "./app/applications"
 import { default as datasources } from "./app/datasources"
 import { default as queries } from "./app/queries"
 import { default as rows } from "./app/rows"
+import { default as links } from "./app/links"
 import { default as users } from "./users"
 import { default as plugins } from "./plugins"
 import * as views from "./app/views"
@@ -22,6 +23,7 @@ const sdk = {
   plugins,
   views,
   permissions,
+  links,
 }
 
 // default export for TS
diff --git a/packages/server/src/sdk/users/tests/utils.spec.ts b/packages/server/src/sdk/users/tests/utils.spec.ts
index 5c6777df59..f7c9413ebd 100644
--- a/packages/server/src/sdk/users/tests/utils.spec.ts
+++ b/packages/server/src/sdk/users/tests/utils.spec.ts
@@ -39,12 +39,12 @@ describe("syncGlobalUsers", () => {
       expect(metadata).toHaveLength(3)
       expect(metadata).toContainEqual(
         expect.objectContaining({
-          _id: db.generateUserMetadataID(user1._id),
+          _id: db.generateUserMetadataID(user1._id!),
         })
       )
       expect(metadata).toContainEqual(
         expect.objectContaining({
-          _id: db.generateUserMetadataID(user2._id),
+          _id: db.generateUserMetadataID(user2._id!),
         })
       )
     })
@@ -59,7 +59,7 @@ describe("syncGlobalUsers", () => {
       expect(metadata).toHaveLength(1)
       expect(metadata).not.toContainEqual(
         expect.objectContaining({
-          _id: db.generateUserMetadataID(user._id),
+          _id: db.generateUserMetadataID(user._id!),
         })
       )
     })
@@ -70,7 +70,7 @@ describe("syncGlobalUsers", () => {
       const group = await proSdk.groups.save(structures.userGroups.userGroup())
       const user1 = await config.createUser({ admin: false, builder: false })
       const user2 = await config.createUser({ admin: false, builder: false })
-      await proSdk.groups.addUsers(group.id, [user1._id, user2._id])
+      await proSdk.groups.addUsers(group.id, [user1._id!, user2._id!])
 
       await config.doInContext(config.appId, async () => {
         await syncGlobalUsers()
@@ -87,12 +87,12 @@ describe("syncGlobalUsers", () => {
         expect(metadata).toHaveLength(3)
         expect(metadata).toContainEqual(
           expect.objectContaining({
-            _id: db.generateUserMetadataID(user1._id),
+            _id: db.generateUserMetadataID(user1._id!),
           })
         )
         expect(metadata).toContainEqual(
           expect.objectContaining({
-            _id: db.generateUserMetadataID(user2._id),
+            _id: db.generateUserMetadataID(user2._id!),
           })
         )
       })
@@ -109,7 +109,7 @@ describe("syncGlobalUsers", () => {
           { appId: config.prodAppId!, roleId: roles.BUILTIN_ROLE_IDS.BASIC },
         ],
       })
-      await proSdk.groups.addUsers(group.id, [user1._id, user2._id])
+      await proSdk.groups.addUsers(group.id, [user1._id!, user2._id!])
 
       await config.doInContext(config.appId, async () => {
         await syncGlobalUsers()
diff --git a/packages/server/src/tests/utilities/TestConfiguration.ts b/packages/server/src/tests/utilities/TestConfiguration.ts
index cec8c8aa12..04c0552457 100644
--- a/packages/server/src/tests/utilities/TestConfiguration.ts
+++ b/packages/server/src/tests/utilities/TestConfiguration.ts
@@ -2,37 +2,31 @@ import { generator, mocks, structures } from "@budibase/backend-core/tests"
 
 // init the licensing mock
 import * as pro from "@budibase/pro"
-mocks.licenses.init(pro)
-
-// use unlimited license by default
-mocks.licenses.useUnlimited()
-
 import { init as dbInit } from "../../db"
-dbInit()
 import env from "../../environment"
 import {
-  basicTable,
-  basicRow,
-  basicRole,
   basicAutomation,
-  basicDatasource,
-  basicQuery,
-  basicScreen,
-  basicLayout,
-  basicWebhook,
   basicAutomationResults,
+  basicDatasource,
+  basicLayout,
+  basicQuery,
+  basicRole,
+  basicRow,
+  basicScreen,
+  basicTable,
+  basicWebhook,
 } from "./structures"
 import {
-  constants,
-  tenancy,
-  sessions,
+  auth,
   cache,
+  constants,
   context,
   db as dbCore,
   encryption,
-  auth,
-  roles,
   env as coreEnv,
+  roles,
+  sessions,
+  tenancy,
 } from "@budibase/backend-core"
 import * as controllers from "./controllers"
 import { cleanup } from "../../utilities/fileSystem"
@@ -43,21 +37,32 @@ import supertest from "supertest"
 import {
   App,
   AuthToken,
+  Automation,
+  CreateViewRequest,
   Datasource,
+  FieldType,
+  INTERNAL_TABLE_SOURCE_ID,
+  RelationshipFieldMetadata,
+  RelationshipType,
   Row,
+  SearchFilters,
   SourceName,
   Table,
-  SearchFilters,
+  TableSourceType,
+  User,
   UserRoles,
-  Automation,
   View,
-  FieldType,
-  RelationshipType,
-  CreateViewRequest,
-  RelationshipFieldMetadata,
 } from "@budibase/types"
 
 import API from "./api"
+import { cloneDeep } from "lodash"
+
+mocks.licenses.init(pro)
+
+// use unlimited license by default
+mocks.licenses.useUnlimited()
+
+dbInit()
 
 type DefaultUserValues = {
   globalUserId: string
@@ -67,6 +72,11 @@ type DefaultUserValues = {
   csrfToken: string
 }
 
+interface TableToBuild extends Omit<Table, "sourceId" | "sourceType"> {
+  sourceId?: string
+  sourceType?: TableSourceType
+}
+
 class TestConfiguration {
   server: any
   request: supertest.SuperTest<supertest.Test> | undefined
@@ -188,30 +198,38 @@ class TestConfiguration {
     }
   }
 
-  // MODES
-  setMultiTenancy = (value: boolean) => {
-    env._set("MULTI_TENANCY", value)
-    coreEnv._set("MULTI_TENANCY", value)
+  async withEnv(newEnvVars: Partial<typeof env>, f: () => Promise<void>) {
+    let cleanup = this.setEnv(newEnvVars)
+    try {
+      await f()
+    } finally {
+      cleanup()
+    }
   }
 
-  setSelfHosted = (value: boolean) => {
-    env._set("SELF_HOSTED", value)
-    coreEnv._set("SELF_HOSTED", value)
-  }
+  /*
+   * Sets the environment variables to the given values and returns a function
+   * that can be called to reset the environment variables to their original values.
+   */
+  setEnv(newEnvVars: Partial<typeof env>): () => void {
+    const oldEnv = cloneDeep(env)
+    const oldCoreEnv = cloneDeep(coreEnv)
 
-  setGoogleAuth = (value: string) => {
-    env._set("GOOGLE_CLIENT_ID", value)
-    env._set("GOOGLE_CLIENT_SECRET", value)
-    coreEnv._set("GOOGLE_CLIENT_ID", value)
-    coreEnv._set("GOOGLE_CLIENT_SECRET", value)
-  }
+    let key: keyof typeof newEnvVars
+    for (key in newEnvVars) {
+      env._set(key, newEnvVars[key])
+      coreEnv._set(key, newEnvVars[key])
+    }
 
-  modeCloud = () => {
-    this.setSelfHosted(false)
-  }
+    return () => {
+      for (const [key, value] of Object.entries(oldEnv)) {
+        env._set(key, value)
+      }
 
-  modeSelf = () => {
-    this.setSelfHosted(true)
+      for (const [key, value] of Object.entries(oldCoreEnv)) {
+        coreEnv._set(key, value)
+      }
+    }
   }
 
   // UTILS
@@ -254,7 +272,7 @@ class TestConfiguration {
     } catch (err) {
       existing = { email }
     }
-    const user = {
+    const user: User = {
       _id: id,
       ...existing,
       roles: roles || {},
@@ -294,7 +312,7 @@ class TestConfiguration {
       admin?: boolean
       roles?: UserRoles
     } = {}
-  ) {
+  ): Promise<User> {
     let { id, firstName, lastName, email, builder, admin, roles } = user
     firstName = firstName || this.defaultUserValues.firstName
     lastName = lastName || this.defaultUserValues.lastName
@@ -314,10 +332,7 @@ class TestConfiguration {
       roles,
     })
     await cache.user.invalidateUser(globalId)
-    return {
-      ...resp,
-      globalId,
-    }
+    return resp
   }
 
   async createGroup(roleId: string = roles.BUILTIN_ROLE_IDS.BASIC) {
@@ -540,10 +555,12 @@ class TestConfiguration {
   // TABLE
 
   async updateTable(
-    config?: Table,
+    config?: TableToBuild,
     { skipReassigning } = { skipReassigning: false }
   ): Promise<Table> {
     config = config || basicTable()
+    config.sourceType = config.sourceType || TableSourceType.INTERNAL
+    config.sourceId = config.sourceId || INTERNAL_TABLE_SOURCE_ID
     const response = await this._req(config, null, controllers.table.save)
     if (!skipReassigning) {
       this.table = response
@@ -551,18 +568,32 @@ class TestConfiguration {
     return response
   }
 
-  async createTable(config?: Table, options = { skipReassigning: false }) {
+  async createTable(
+    config?: TableToBuild,
+    options = { skipReassigning: false }
+  ) {
     if (config != null && config._id) {
       delete config._id
     }
     config = config || basicTable()
-    if (this.datasource && !config.sourceId) {
-      config.sourceId = this.datasource._id
-      if (this.datasource.plus) {
-        config.type = "external"
-      }
+    if (!config.sourceId) {
+      config.sourceId = INTERNAL_TABLE_SOURCE_ID
     }
+    return this.updateTable(config, options)
+  }
 
+  async createExternalTable(
+    config?: TableToBuild,
+    options = { skipReassigning: false }
+  ) {
+    if (config != null && config._id) {
+      delete config._id
+    }
+    config = config || basicTable()
+    if (this.datasource?._id) {
+      config.sourceId = this.datasource._id
+      config.sourceType = TableSourceType.EXTERNAL
+    }
     return this.updateTable(config, options)
   }
 
@@ -574,12 +605,15 @@ class TestConfiguration {
   async createLinkedTable(
     relationshipType = RelationshipType.ONE_TO_MANY,
     links: any = ["link"],
-    config?: Table
+    config?: TableToBuild
   ) {
     if (!this.table) {
       throw "Must have created a table first."
     }
     const tableConfig = config || basicTable()
+    if (!tableConfig.sourceId) {
+      tableConfig.sourceId = INTERNAL_TABLE_SOURCE_ID
+    }
     tableConfig.primaryDisplay = "name"
     for (let link of links) {
       tableConfig.schema[link] = {
@@ -591,15 +625,12 @@ class TestConfiguration {
       } as RelationshipFieldMetadata
     }
 
-    if (this.datasource && !tableConfig.sourceId) {
+    if (this.datasource?._id) {
       tableConfig.sourceId = this.datasource._id
-      if (this.datasource.plus) {
-        tableConfig.type = "external"
-      }
+      tableConfig.sourceType = TableSourceType.EXTERNAL
     }
 
-    const linkedTable = await this.createTable(tableConfig)
-    return linkedTable
+    return await this.createTable(tableConfig)
   }
 
   async createAttachmentTable() {
@@ -774,8 +805,9 @@ class TestConfiguration {
 
   // AUTOMATION LOG
 
-  async createAutomationLog(automation: Automation) {
-    return await context.doInAppContext(this.getProdAppId(), async () => {
+  async createAutomationLog(automation: Automation, appId?: string) {
+    appId = appId || this.getProdAppId()
+    return await context.doInAppContext(appId!, async () => {
       return await pro.sdk.automations.logs.storeLog(
         automation,
         basicAutomationResults(automation._id!)
diff --git a/packages/server/src/tests/utilities/api/attachment.ts b/packages/server/src/tests/utilities/api/attachment.ts
new file mode 100644
index 0000000000..a466f1a67e
--- /dev/null
+++ b/packages/server/src/tests/utilities/api/attachment.ts
@@ -0,0 +1,35 @@
+import {
+  APIError,
+  Datasource,
+  ProcessAttachmentResponse,
+} from "@budibase/types"
+import TestConfiguration from "../TestConfiguration"
+import { TestAPI } from "./base"
+import fs from "fs"
+
+export class AttachmentAPI extends TestAPI {
+  constructor(config: TestConfiguration) {
+    super(config)
+  }
+
+  process = async (
+    name: string,
+    file: Buffer | fs.ReadStream | string,
+    { expectStatus } = { expectStatus: 200 }
+  ): Promise<ProcessAttachmentResponse> => {
+    const result = await this.request
+      .post(`/api/attachments/process`)
+      .attach("file", file, name)
+      .set(this.config.defaultHeaders())
+
+    if (result.statusCode !== expectStatus) {
+      throw new Error(
+        `Expected status ${expectStatus} but got ${
+          result.statusCode
+        }, body: ${JSON.stringify(result.body)}`
+      )
+    }
+
+    return result.body
+  }
+}
diff --git a/packages/server/src/tests/utilities/api/backup.ts b/packages/server/src/tests/utilities/api/backup.ts
new file mode 100644
index 0000000000..f9cbc7086e
--- /dev/null
+++ b/packages/server/src/tests/utilities/api/backup.ts
@@ -0,0 +1,45 @@
+import {
+  CreateAppBackupResponse,
+  ImportAppBackupResponse,
+} from "@budibase/types"
+import TestConfiguration from "../TestConfiguration"
+import { TestAPI } from "./base"
+
+export class BackupAPI extends TestAPI {
+  constructor(config: TestConfiguration) {
+    super(config)
+  }
+
+  exportBasicBackup = async (appId: string) => {
+    const result = await this.request
+      .post(`/api/backups/export?appId=${appId}`)
+      .set(this.config.defaultHeaders())
+      .expect("Content-Type", /application\/gzip/)
+      .expect(200)
+    return {
+      body: result.body as Buffer,
+      headers: result.headers,
+    }
+  }
+
+  createBackup = async (appId: string) => {
+    const result = await this.request
+      .post(`/api/apps/${appId}/backups`)
+      .set(this.config.defaultHeaders())
+      .expect("Content-Type", /json/)
+      .expect(200)
+    return result.body as CreateAppBackupResponse
+  }
+
+  importBackup = async (
+    appId: string,
+    backupId: string
+  ): Promise<ImportAppBackupResponse> => {
+    const result = await this.request
+      .post(`/api/apps/${appId}/backups/${backupId}/import`)
+      .set(this.config.defaultHeaders())
+      .expect("Content-Type", /json/)
+      .expect(200)
+    return result.body as ImportAppBackupResponse
+  }
+}
diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts
index fce8237760..c553e7b8f4 100644
--- a/packages/server/src/tests/utilities/api/index.ts
+++ b/packages/server/src/tests/utilities/api/index.ts
@@ -7,6 +7,8 @@ import { DatasourceAPI } from "./datasource"
 import { LegacyViewAPI } from "./legacyView"
 import { ScreenAPI } from "./screen"
 import { ApplicationAPI } from "./application"
+import { BackupAPI } from "./backup"
+import { AttachmentAPI } from "./attachment"
 
 export default class API {
   table: TableAPI
@@ -17,6 +19,8 @@ export default class API {
   datasource: DatasourceAPI
   screen: ScreenAPI
   application: ApplicationAPI
+  backup: BackupAPI
+  attachment: AttachmentAPI
 
   constructor(config: TestConfiguration) {
     this.table = new TableAPI(config)
@@ -27,5 +31,7 @@ export default class API {
     this.datasource = new DatasourceAPI(config)
     this.screen = new ScreenAPI(config)
     this.application = new ApplicationAPI(config)
+    this.backup = new BackupAPI(config)
+    this.attachment = new AttachmentAPI(config)
   }
 }
diff --git a/packages/server/src/tests/utilities/api/table.ts b/packages/server/src/tests/utilities/api/table.ts
index 04432a788a..b80c940697 100644
--- a/packages/server/src/tests/utilities/api/table.ts
+++ b/packages/server/src/tests/utilities/api/table.ts
@@ -1,4 +1,10 @@
-import { SaveTableRequest, SaveTableResponse, Table } from "@budibase/types"
+import {
+  MigrateRequest,
+  MigrateResponse,
+  SaveTableRequest,
+  SaveTableResponse,
+  Table,
+} from "@budibase/types"
 import TestConfiguration from "../TestConfiguration"
 import { TestAPI } from "./base"
 
@@ -42,4 +48,23 @@ export class TableAPI extends TestAPI {
       .expect(expectStatus)
     return res.body
   }
+
+  migrate = async (
+    tableId: string,
+    data: MigrateRequest,
+    { expectStatus } = { expectStatus: 200 }
+  ): Promise<MigrateResponse> => {
+    const res = await this.request
+      .post(`/api/tables/${tableId}/migrate`)
+      .send(data)
+      .set(this.config.defaultHeaders())
+    if (res.status !== expectStatus) {
+      throw new Error(
+        `Expected status ${expectStatus} but got ${
+          res.status
+        } with body ${JSON.stringify(res.body)}`
+      )
+    }
+    return res.body
+  }
 }
diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts
index d3e92ea34d..b680c6ff19 100644
--- a/packages/server/src/tests/utilities/structures.ts
+++ b/packages/server/src/tests/utilities/structures.ts
@@ -19,12 +19,17 @@ import {
   FieldType,
   SourceName,
   Table,
+  INTERNAL_TABLE_SOURCE_ID,
+  TableSourceType,
 } from "@budibase/types"
+const { BUILTIN_ROLE_IDS } = roles
 
 export function basicTable(): Table {
   return {
     name: "TestTable",
     type: "table",
+    sourceId: INTERNAL_TABLE_SOURCE_ID,
+    sourceType: TableSourceType.INTERNAL,
     schema: {
       name: {
         type: FieldType.STRING,
@@ -322,8 +327,22 @@ export function basicUser(role: string) {
   }
 }
 
-export function basicScreen() {
-  return createHomeScreen()
+export function basicScreen(route: string = "/") {
+  return createHomeScreen({
+    roleId: BUILTIN_ROLE_IDS.BASIC,
+    route,
+  })
+}
+
+export function powerScreen(route: string = "/") {
+  return createHomeScreen({
+    roleId: BUILTIN_ROLE_IDS.POWER,
+    route,
+  })
+}
+
+export function customScreen(config: { roleId: string; route: string }) {
+  return createHomeScreen(config)
 }
 
 export function basicLayout() {
diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts
index cf3875b2ea..098962c646 100644
--- a/packages/server/src/utilities/rowProcessor/index.ts
+++ b/packages/server/src/utilities/rowProcessor/index.ts
@@ -17,7 +17,7 @@ import {
   processInputBBReferences,
   processOutputBBReferences,
 } from "./bbReferenceProcessor"
-import { isExternalTable } from "../../integrations/utils"
+import { isExternalTableID } from "../../integrations/utils"
 export * from "./utils"
 
 type AutoColumnProcessingOpts = {
@@ -51,7 +51,7 @@ function getRemovedAttachmentKeys(
 /**
  * This will update any auto columns that are found on the row/table with the correct information based on
  * time now and the current logged in user making the request.
- * @param user The user to be used for an appId as well as the createdBy and createdAt fields.
+ * @param userId The user to be used for an appId as well as the createdBy and createdAt fields.
  * @param table The table which is to be used for the schema, as well as handling auto IDs incrementing.
  * @param row The row which is to be updated with information for the auto columns.
  * @param opts specific options for function to carry out optional features.
@@ -241,7 +241,7 @@ export async function outputProcessing<T extends Row[] | Row>(
           continue
         }
         row[property].forEach((attachment: RowAttachment) => {
-          attachment.url = objectStore.getAppFileUrl(attachment.key)
+          attachment.url ??= objectStore.getAppFileUrl(attachment.key)
         })
       }
     } else if (
@@ -267,7 +267,7 @@ export async function outputProcessing<T extends Row[] | Row>(
     )) as Row[]
   }
   // remove null properties to match internal API
-  if (isExternalTable(table._id!)) {
+  if (isExternalTableID(table._id!)) {
     for (let row of enriched) {
       for (let key of Object.keys(row)) {
         if (row[key] === null) {
diff --git a/packages/server/src/utilities/rowProcessor/tests/inputProcessing.spec.ts b/packages/server/src/utilities/rowProcessor/tests/inputProcessing.spec.ts
index 18d5128986..b6c1db9159 100644
--- a/packages/server/src/utilities/rowProcessor/tests/inputProcessing.spec.ts
+++ b/packages/server/src/utilities/rowProcessor/tests/inputProcessing.spec.ts
@@ -1,6 +1,12 @@
 import { inputProcessing } from ".."
 import { generator, structures } from "@budibase/backend-core/tests"
-import { FieldType, FieldTypeSubtypes, Table } from "@budibase/types"
+import {
+  FieldType,
+  FieldTypeSubtypes,
+  INTERNAL_TABLE_SOURCE_ID,
+  Table,
+  TableSourceType,
+} from "@budibase/types"
 import * as bbReferenceProcessor from "../bbReferenceProcessor"
 
 jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({
@@ -20,6 +26,8 @@ describe("rowProcessor - inputProcessing", () => {
       _id: generator.guid(),
       name: "TestTable",
       type: "table",
+      sourceId: INTERNAL_TABLE_SOURCE_ID,
+      sourceType: TableSourceType.INTERNAL,
       schema: {
         name: {
           type: FieldType.STRING,
@@ -70,6 +78,8 @@ describe("rowProcessor - inputProcessing", () => {
       _id: generator.guid(),
       name: "TestTable",
       type: "table",
+      sourceId: INTERNAL_TABLE_SOURCE_ID,
+      sourceType: TableSourceType.INTERNAL,
       schema: {
         name: {
           type: FieldType.STRING,
@@ -110,6 +120,8 @@ describe("rowProcessor - inputProcessing", () => {
         _id: generator.guid(),
         name: "TestTable",
         type: "table",
+        sourceId: INTERNAL_TABLE_SOURCE_ID,
+        sourceType: TableSourceType.INTERNAL,
         schema: {
           name: {
             type: FieldType.STRING,
@@ -150,6 +162,8 @@ describe("rowProcessor - inputProcessing", () => {
       _id: generator.guid(),
       name: "TestTable",
       type: "table",
+      sourceId: INTERNAL_TABLE_SOURCE_ID,
+      sourceType: TableSourceType.INTERNAL,
       schema: {
         name: {
           type: FieldType.STRING,
diff --git a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts
index ecb8856c88..03584ef53b 100644
--- a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts
+++ b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts
@@ -2,7 +2,9 @@ import {
   FieldSubtype,
   FieldType,
   FieldTypeSubtypes,
+  INTERNAL_TABLE_SOURCE_ID,
   Table,
+  TableSourceType,
 } from "@budibase/types"
 import { outputProcessing } from ".."
 import { generator, structures } from "@budibase/backend-core/tests"
@@ -26,6 +28,8 @@ describe("rowProcessor - outputProcessing", () => {
       _id: generator.guid(),
       name: "TestTable",
       type: "table",
+      sourceId: INTERNAL_TABLE_SOURCE_ID,
+      sourceType: TableSourceType.INTERNAL,
       schema: {
         name: {
           type: FieldType.STRING,
@@ -71,6 +75,8 @@ describe("rowProcessor - outputProcessing", () => {
       _id: generator.guid(),
       name: "TestTable",
       type: "table",
+      sourceId: INTERNAL_TABLE_SOURCE_ID,
+      sourceType: TableSourceType.INTERNAL,
       schema: {
         name: {
           type: FieldType.STRING,
@@ -108,6 +114,8 @@ describe("rowProcessor - outputProcessing", () => {
       _id: generator.guid(),
       name: "TestTable",
       type: "table",
+      sourceId: INTERNAL_TABLE_SOURCE_ID,
+      sourceType: TableSourceType.INTERNAL,
       schema: {
         name: {
           type: FieldType.STRING,
diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts
index d2fdbca20c..a47d3048d3 100644
--- a/packages/server/src/websockets/builder.ts
+++ b/packages/server/src/websockets/builder.ts
@@ -1,5 +1,5 @@
 import authorized from "../middleware/authorized"
-import { BaseSocket } from "./websocket"
+import { BaseSocket, EmitOptions } from "./websocket"
 import { permissions, events, context } from "@budibase/backend-core"
 import http from "http"
 import Koa from "koa"
@@ -16,6 +16,7 @@ import { gridSocket } from "./index"
 import { clearLock, updateLock } from "../utilities/redis"
 import { Socket } from "socket.io"
 import { BuilderSocketEvent } from "@budibase/shared-core"
+import { processTable } from "../sdk/app/tables/getters"
 
 export default class BuilderSocket extends BaseSocket {
   constructor(app: Koa, server: http.Server) {
@@ -100,11 +101,22 @@ export default class BuilderSocket extends BaseSocket {
     })
   }
 
-  emitTableUpdate(ctx: any, table: Table) {
-    this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.TableChange, {
-      id: table._id,
-      table,
-    })
+  emitTableUpdate(ctx: any, table: Table, options?: EmitOptions) {
+    // This was added to make sure that sourceId is always present when
+    // sending this message to clients. Without this, tables without a
+    // sourceId (e.g. ta_users) won't get correctly updated client-side.
+    table = processTable(table)
+
+    this.emitToRoom(
+      ctx,
+      ctx.appId,
+      BuilderSocketEvent.TableChange,
+      {
+        id: table._id,
+        table,
+      },
+      options
+    )
     gridSocket?.emitTableUpdate(ctx, table)
   }
 
diff --git a/packages/server/src/websockets/websocket.ts b/packages/server/src/websockets/websocket.ts
index ffaf9e2763..1dba108d24 100644
--- a/packages/server/src/websockets/websocket.ts
+++ b/packages/server/src/websockets/websocket.ts
@@ -11,6 +11,14 @@ import { SocketSession } from "@budibase/types"
 import { v4 as uuid } from "uuid"
 import { createContext, runMiddlewares } from "./middleware"
 
+export interface EmitOptions {
+  // Whether to include the originator of the request from the broadcast,
+  // defaults to false because it is assumed that the user who triggered
+  // an action will already have the changes of that action reflected in their
+  // own UI, so there is no need to send them again.
+  includeOriginator?: boolean
+}
+
 const anonUser = () => ({
   _id: uuid(),
   email: "user@mail.com",
@@ -270,10 +278,17 @@ export class BaseSocket {
 
   // Emit an event to everyone in a room, including metadata of whom
   // the originator of the request was
-  emitToRoom(ctx: any, room: string | string[], event: string, payload: any) {
-    this.io.in(room).emit(event, {
-      ...payload,
-      apiSessionId: ctx.headers?.[Header.SESSION_ID],
-    })
+  emitToRoom(
+    ctx: any,
+    room: string | string[],
+    event: string,
+    payload: any,
+    options?: EmitOptions
+  ) {
+    let emitPayload = { ...payload }
+    if (!options?.includeOriginator) {
+      emitPayload.apiSessionId = ctx.headers?.[Header.SESSION_ID]
+    }
+    this.io.in(room).emit(event, emitPayload)
   }
 }
diff --git a/packages/shared-core/src/constants.ts b/packages/shared-core/src/constants.ts
index 725c246e2f..e7c6feb20a 100644
--- a/packages/shared-core/src/constants.ts
+++ b/packages/shared-core/src/constants.ts
@@ -96,3 +96,45 @@ export enum BuilderSocketEvent {
 export const SocketSessionTTL = 60
 export const ValidQueryNameRegex = /^[^()]*$/
 export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g
+export const ValidFileExtensions = [
+  "avif",
+  "css",
+  "csv",
+  "docx",
+  "drawio",
+  "editorconfig",
+  "edl",
+  "enc",
+  "export",
+  "geojson",
+  "gif",
+  "htm",
+  "html",
+  "ics",
+  "iqy",
+  "jfif",
+  "jpeg",
+  "jpg",
+  "json",
+  "log",
+  "md",
+  "mid",
+  "odt",
+  "pdf",
+  "png",
+  "ris",
+  "rtf",
+  "svg",
+  "tex",
+  "toml",
+  "twig",
+  "txt",
+  "url",
+  "wav",
+  "webp",
+  "xls",
+  "xlsx",
+  "xml",
+  "yaml",
+  "yml",
+]
diff --git a/packages/types/package.json b/packages/types/package.json
index 1db667e669..1b602097c7 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -15,7 +15,7 @@
   },
   "jest": {},
   "devDependencies": {
-    "@budibase/nano": "10.1.2",
+    "@budibase/nano": "10.1.3",
     "@types/koa": "2.13.4",
     "@types/node": "18.17.0",
     "@types/pouchdb": "6.4.0",
diff --git a/packages/types/src/api/web/app/attachment.ts b/packages/types/src/api/web/app/attachment.ts
new file mode 100644
index 0000000000..792bdf3885
--- /dev/null
+++ b/packages/types/src/api/web/app/attachment.ts
@@ -0,0 +1,9 @@
+export interface Upload {
+  size: number
+  name: string
+  url: string
+  extension: string
+  key: string
+}
+
+export type ProcessAttachmentResponse = Upload[]
diff --git a/packages/types/src/api/web/app/backup.ts b/packages/types/src/api/web/app/backup.ts
index c9a8d07f5e..f77707e9c6 100644
--- a/packages/types/src/api/web/app/backup.ts
+++ b/packages/types/src/api/web/app/backup.ts
@@ -20,3 +20,8 @@ export interface CreateAppBackupResponse {
 export interface UpdateAppBackupRequest {
   name: string
 }
+
+export interface ImportAppBackupResponse {
+  restoreId: string
+  message: string
+}
diff --git a/packages/types/src/api/web/app/index.ts b/packages/types/src/api/web/app/index.ts
index 276d7fa7c1..f5b876009b 100644
--- a/packages/types/src/api/web/app/index.ts
+++ b/packages/types/src/api/web/app/index.ts
@@ -5,3 +5,4 @@ export * from "./view"
 export * from "./rows"
 export * from "./table"
 export * from "./permission"
+export * from "./attachment"
diff --git a/packages/types/src/api/web/app/table.ts b/packages/types/src/api/web/app/table.ts
index cb5faaa9ea..f4d6720516 100644
--- a/packages/types/src/api/web/app/table.ts
+++ b/packages/types/src/api/web/app/table.ts
@@ -1,4 +1,5 @@
 import {
+  FieldSchema,
   Row,
   Table,
   TableRequest,
@@ -33,3 +34,12 @@ export interface BulkImportRequest {
 export interface BulkImportResponse {
   message: string
 }
+
+export interface MigrateRequest {
+  oldColumn: FieldSchema
+  newColumn: FieldSchema
+}
+
+export interface MigrateResponse {
+  message: string
+}
diff --git a/packages/types/src/documents/app/table/schema.ts b/packages/types/src/documents/app/table/schema.ts
index e529a8e8b7..755ccf61e7 100644
--- a/packages/types/src/documents/app/table/schema.ts
+++ b/packages/types/src/documents/app/table/schema.ts
@@ -164,3 +164,33 @@ export type FieldSchema =
 export interface TableSchema {
   [key: string]: FieldSchema
 }
+
+export function isRelationshipField(
+  field: FieldSchema
+): field is RelationshipFieldMetadata {
+  return field.type === FieldType.LINK
+}
+
+export function isManyToMany(
+  field: RelationshipFieldMetadata
+): field is ManyToManyRelationshipFieldMetadata {
+  return field.relationshipType === RelationshipType.MANY_TO_MANY
+}
+
+export function isOneToMany(
+  field: RelationshipFieldMetadata
+): field is OneToManyRelationshipFieldMetadata {
+  return field.relationshipType === RelationshipType.ONE_TO_MANY
+}
+
+export function isManyToOne(
+  field: RelationshipFieldMetadata
+): field is ManyToOneRelationshipFieldMetadata {
+  return field.relationshipType === RelationshipType.MANY_TO_ONE
+}
+
+export function isBBReferenceField(
+  field: FieldSchema
+): field is BBReferenceFieldMetadata {
+  return field.type === FieldType.BB_REFERENCE
+}
diff --git a/packages/types/src/documents/app/table/table.ts b/packages/types/src/documents/app/table/table.ts
index 5174ec608f..f3b8e6df8d 100644
--- a/packages/types/src/documents/app/table/table.ts
+++ b/packages/types/src/documents/app/table/table.ts
@@ -3,14 +3,22 @@ import { View, ViewV2 } from "../view"
 import { RenameColumn } from "../../../sdk"
 import { TableSchema } from "./schema"
 
+export const INTERNAL_TABLE_SOURCE_ID = "bb_internal"
+
+export enum TableSourceType {
+  EXTERNAL = "external",
+  INTERNAL = "internal",
+}
+
 export interface Table extends Document {
-  type?: string
+  type: "table"
+  sourceType: TableSourceType
   views?: { [key: string]: View | ViewV2 }
   name: string
+  sourceId: string
   primary?: string[]
   schema: TableSchema
   primaryDisplay?: string
-  sourceId?: string
   relatedFormula?: string[]
   constrained?: string[]
   sql?: boolean
@@ -19,10 +27,6 @@ export interface Table extends Document {
   rowHeight?: number
 }
 
-export interface ExternalTable extends Table {
-  sourceId: string
-}
-
 export interface TableRequest extends Table {
   _rename?: RenameColumn
   created?: boolean
diff --git a/packages/types/src/sdk/datasources.ts b/packages/types/src/sdk/datasources.ts
index 39a10961de..7a335eb3b9 100644
--- a/packages/types/src/sdk/datasources.ts
+++ b/packages/types/src/sdk/datasources.ts
@@ -1,4 +1,4 @@
-import { ExternalTable, Table } from "../documents"
+import { Table } from "../documents"
 
 export const PASSWORD_REPLACEMENT = "--secret-value--"
 
@@ -176,7 +176,7 @@ export interface IntegrationBase {
 }
 
 export interface Schema {
-  tables: Record<string, ExternalTable>
+  tables: Record<string, Table>
   errors: Record<string, string>
 }
 
@@ -187,7 +187,7 @@ export interface DatasourcePlus extends IntegrationBase {
   getStringConcat(parts: string[]): string
   buildSchema(
     datasourceId: string,
-    entities: Record<string, ExternalTable>
+    entities: Record<string, Table>
   ): Promise<Schema>
   getTableNames(): Promise<string[]>
 }
diff --git a/packages/types/src/sdk/db.ts b/packages/types/src/sdk/db.ts
index 36141cc15b..05f72f5524 100644
--- a/packages/types/src/sdk/db.ts
+++ b/packages/types/src/sdk/db.ts
@@ -54,15 +54,18 @@ export type DatabaseDeleteIndexOpts = {
   type?: string | undefined
 }
 
+type DBPrimitiveKey = string | number | {}
+export type DatabaseKey = DBPrimitiveKey | DBPrimitiveKey[]
+
 export type DatabaseQueryOpts = {
   include_docs?: boolean
-  startkey?: string
-  endkey?: string
+  startkey?: DatabaseKey
+  endkey?: DatabaseKey
   limit?: number
   skip?: number
   descending?: boolean
-  key?: string
-  keys?: string[]
+  key?: DatabaseKey
+  keys?: DatabaseKey[]
   group?: boolean
   startkey_docid?: string
 }
diff --git a/packages/types/src/sdk/featureFlag.ts b/packages/types/src/sdk/featureFlag.ts
index 53aa4842c4..e3935bc7ee 100644
--- a/packages/types/src/sdk/featureFlag.ts
+++ b/packages/types/src/sdk/featureFlag.ts
@@ -1,5 +1,8 @@
 export enum FeatureFlag {
   LICENSING = "LICENSING",
+  // Feature IDs in Posthog
+  PER_CREATOR_PER_USER_PRICE = "18873",
+  PER_CREATOR_PER_USER_PRICE_ALERT = "18530",
 }
 
 export interface TenantFeatureFlags {
diff --git a/packages/types/src/sdk/licensing/billing.ts b/packages/types/src/sdk/licensing/billing.ts
index 35f366c811..bcbc7abd18 100644
--- a/packages/types/src/sdk/licensing/billing.ts
+++ b/packages/types/src/sdk/licensing/billing.ts
@@ -5,10 +5,17 @@ export interface Customer {
   currency: string | null | undefined
 }
 
+export interface SubscriptionItems {
+  user: number | undefined
+  creator: number | undefined
+}
+
 export interface Subscription {
   amount: number
+  amounts: SubscriptionItems | undefined
   currency: string
   quantity: number
+  quantities: SubscriptionItems | undefined
   duration: PriceDuration
   cancelAt: number | null | undefined
   currentPeriodStart: number
diff --git a/packages/types/src/sdk/licensing/plan.ts b/packages/types/src/sdk/licensing/plan.ts
index 3e214a01ff..1604dfb8af 100644
--- a/packages/types/src/sdk/licensing/plan.ts
+++ b/packages/types/src/sdk/licensing/plan.ts
@@ -4,7 +4,9 @@ export enum PlanType {
   PRO = "pro",
   /** @deprecated */
   TEAM = "team",
+  /** @deprecated */
   PREMIUM = "premium",
+  PREMIUM_PLUS = "premium_plus",
   BUSINESS = "business",
   ENTERPRISE = "enterprise",
 }
@@ -26,10 +28,12 @@ export interface AvailablePrice {
   currency: string
   duration: PriceDuration
   priceId: string
+  type?: string
 }
 
 export enum PlanModel {
   PER_USER = "perUser",
+  PER_CREATOR_PER_USER = "per_creator_per_user",
   DAY_PASS = "dayPass",
 }
 
diff --git a/packages/worker/Dockerfile b/packages/worker/Dockerfile
index 4230ee86f8..50f1bb78b9 100644
--- a/packages/worker/Dockerfile
+++ b/packages/worker/Dockerfile
@@ -14,7 +14,7 @@ RUN yarn global add pm2
 
 COPY package.json .
 COPY dist/yarn.lock .
-RUN yarn install --production=true
+RUN yarn install --production=true --network-timeout 1000000
 # Remove unneeded data from file system to reduce image size
 RUN apk del .gyp \
     && yarn cache clean
diff --git a/packages/worker/Dockerfile.v2 b/packages/worker/Dockerfile.v2
index a8be432827..4706ca155a 100644
--- a/packages/worker/Dockerfile.v2
+++ b/packages/worker/Dockerfile.v2
@@ -19,7 +19,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
 WORKDIR /string-templates
 COPY packages/string-templates/package.json package.json
 RUN ../scripts/removeWorkspaceDependencies.sh package.json
-RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true
+RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
 COPY packages/string-templates .
 
 
@@ -30,7 +30,7 @@ RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-te
 
 RUN ../scripts/removeWorkspaceDependencies.sh package.json
 
-RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true
+RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
 # Remove unneeded data from file system to reduce image size
 RUN apk del .gyp \
     && yarn cache clean
diff --git a/packages/worker/package.json b/packages/worker/package.json
index 205bf3309a..ec86575395 100644
--- a/packages/worker/package.json
+++ b/packages/worker/package.json
@@ -20,7 +20,6 @@
     "run:docker": "node dist/index.js",
     "debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js",
     "run:docker:cluster": "pm2-runtime start pm2.config.js",
-    "build:docker": "yarn build && docker build . -t worker-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION",
     "dev:stack:init": "node ./scripts/dev/manage.js init",
     "dev:builder": "npm run dev:stack:init && nodemon",
     "dev:built": "yarn run dev:stack:init && yarn run run:docker",
diff --git a/packages/worker/src/api/routes/global/tests/groups.spec.ts b/packages/worker/src/api/routes/global/tests/groups.spec.ts
index afeaae952c..8f0739a812 100644
--- a/packages/worker/src/api/routes/global/tests/groups.spec.ts
+++ b/packages/worker/src/api/routes/global/tests/groups.spec.ts
@@ -1,7 +1,7 @@
 import { events } from "@budibase/backend-core"
 import { generator } from "@budibase/backend-core/tests"
 import { structures, TestConfiguration, mocks } from "../../../../tests"
-import { UserGroup } from "@budibase/types"
+import { User, UserGroup } from "@budibase/types"
 
 mocks.licenses.useGroups()
 
@@ -231,4 +231,39 @@ describe("/api/global/groups", () => {
       })
     })
   })
+
+  describe("with global builder role", () => {
+    let builder: User
+    let group: UserGroup
+
+    beforeAll(async () => {
+      builder = await config.createUser({
+        builder: { global: true },
+        admin: { global: false },
+      })
+      await config.createSession(builder)
+
+      let resp = await config.api.groups.saveGroup(
+        structures.groups.UserGroup()
+      )
+      group = resp.body as UserGroup
+    })
+
+    it("find should return 200", async () => {
+      await config.withUser(builder, async () => {
+        await config.api.groups.searchUsers(group._id!, {
+          emailSearch: `user1`,
+        })
+      })
+    })
+
+    it("update should return 200", async () => {
+      await config.withUser(builder, async () => {
+        await config.api.groups.updateGroupUsers(group._id!, {
+          add: [builder._id!],
+          remove: [],
+        })
+      })
+    })
+  })
 })
diff --git a/packages/worker/src/tests/TestConfiguration.ts b/packages/worker/src/tests/TestConfiguration.ts
index 7e9792c9e3..d4fcbeebd6 100644
--- a/packages/worker/src/tests/TestConfiguration.ts
+++ b/packages/worker/src/tests/TestConfiguration.ts
@@ -190,6 +190,16 @@ class TestConfiguration {
     }
   }
 
+  async withUser(user: User, f: () => Promise<void>) {
+    const oldUser = this.user
+    this.user = user
+    try {
+      await f()
+    } finally {
+      this.user = oldUser
+    }
+  }
+
   authHeaders(user: User) {
     const authToken: AuthToken = {
       userId: user._id!,
@@ -257,9 +267,10 @@ class TestConfiguration {
     })
   }
 
-  async createUser(user?: User) {
-    if (!user) {
-      user = structures.users.user()
+  async createUser(opts?: Partial<User>) {
+    let user = structures.users.user()
+    if (user) {
+      user = { ...user, ...opts }
     }
     const response = await this._req(user, null, controllers.users.save)
     const body = response as SaveUserResponse
diff --git a/packages/worker/src/tests/structures/groups.ts b/packages/worker/src/tests/structures/groups.ts
index b0d6bb8fc0..d39dd74eb8 100644
--- a/packages/worker/src/tests/structures/groups.ts
+++ b/packages/worker/src/tests/structures/groups.ts
@@ -1,8 +1,8 @@
 import { generator } from "@budibase/backend-core/tests"
 import { db } from "@budibase/backend-core"
-import { UserGroupRoles } from "@budibase/types"
+import { UserGroup as UserGroupType, UserGroupRoles } from "@budibase/types"
 
-export const UserGroup = () => {
+export function UserGroup(): UserGroupType {
   const appsCount = generator.integer({ min: 0, max: 3 })
   const roles = Array.from({ length: appsCount }).reduce(
     (p: UserGroupRoles, v) => {
@@ -14,13 +14,11 @@ export const UserGroup = () => {
     {}
   )
 
-  let group = {
-    apps: [],
+  return {
     color: generator.color(),
     icon: generator.word(),
     name: generator.word(),
     roles: roles,
     users: [],
   }
-  return group
 }
diff --git a/scripts/updateWorkspaceVersions.V2.sh b/scripts/updateWorkspaceVersions.V2.sh
new file mode 100755
index 0000000000..634bcbcfb0
--- /dev/null
+++ b/scripts/updateWorkspaceVersions.V2.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+version=$1
+echo "Setting version $version"
+yarn lerna exec "yarn version --no-git-tag-version --new-version=$version"
+echo "Updating dependencies"
+node scripts/syncLocalDependencies.js $version
+echo "Syncing yarn workspace"
+yarn
diff --git a/yarn.lock b/yarn.lock
index e3629a8dbc..f573046394 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2099,10 +2099,10 @@
     striptags "^3.1.1"
     to-gfm-code-block "^0.1.1"
 
-"@budibase/nano@10.1.2":
-  version "10.1.2"
-  resolved "https://registry.yarnpkg.com/@budibase/nano/-/nano-10.1.2.tgz#10fae5a1ab39be6a81261f40e7b7ec6d21cbdd4a"
-  integrity sha512-1w+YN2n/M5aZ9hBKCP4NEjdQbT8BfCLRizkdvm0Je665eEHw3aE1hvo8mon9Ro9QuDdxj1DfDMMFnym6/QUwpQ==
+"@budibase/nano@10.1.3":
+  version "10.1.3"
+  resolved "https://registry.yarnpkg.com/@budibase/nano/-/nano-10.1.3.tgz#81b99d76b5c256a393e6ee0e284a6aecc517e4b8"
+  integrity sha512-UuhwjKCfVO+oVB0dbKpssZfTfb5k3CTrbrjqdx0kd971zzSRMFJ0TwvBB/2Z7kgOOA+Evoq4BSd747meEz21YA==
   dependencies:
     "@types/tough-cookie" "^4.0.2"
     axios "^1.1.3"