diff --git a/packages/backend-core/src/constants/misc.ts b/packages/backend-core/src/constants/misc.ts index 2bb8f815cf..ba2533cf4a 100644 --- a/packages/backend-core/src/constants/misc.ts +++ b/packages/backend-core/src/constants/misc.ts @@ -16,6 +16,7 @@ export enum Header { LICENSE_KEY = "x-budibase-license-key", API_VER = "x-budibase-api-version", APP_ID = "x-budibase-app-id", + SESSION_ID = "x-budibase-session-id", TYPE = "x-budibase-type", PREVIEW_ROLE = "x-budibase-role", TENANT_ID = "x-budibase-tenant-id", diff --git a/packages/builder/src/builderStore/websocket.js b/packages/builder/src/builderStore/websocket.js index 6a3ce76ae6..e27e08e31d 100644 --- a/packages/builder/src/builderStore/websocket.js +++ b/packages/builder/src/builderStore/websocket.js @@ -15,25 +15,23 @@ export const createBuilderWebsocket = appId => { socket.on("connect_error", err => { console.log("Failed to connect to builder websocket:", err.message) }) - - // User events - socket.on(SocketEvent.UserUpdate, userStore.actions.updateUser) - socket.on(SocketEvent.UserDisconnect, userStore.actions.removeUser) - - // Table events - socket.on(BuilderSocketEvent.TableChange, ({ id, table }) => { - tables.replaceTable(id, table) - }) - - // Datasource events - socket.on(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => { - datasources.replaceDatasource(id, datasource) - }) - - // Clean up user store on disconnect socket.on("disconnect", () => { userStore.actions.reset() }) + // User events + socket.onOther(SocketEvent.UserUpdate, userStore.actions.updateUser) + socket.onOther(SocketEvent.UserDisconnect, userStore.actions.removeUser) + + // Table events + socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => { + tables.replaceTable(id, table) + }) + + // Datasource events + socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => { + datasources.replaceDatasource(id, datasource) + }) + return socket } diff --git a/packages/builder/src/components/backend/DataTable/DataTable.svelte b/packages/builder/src/components/backend/DataTable/DataTable.svelte index 3e9b7d831e..1b0c92bde0 100644 --- a/packages/builder/src/components/backend/DataTable/DataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/DataTable.svelte @@ -37,6 +37,7 @@ allowDeleteRows={!isUsersTable} schemaOverrides={isUsersTable ? userSchemaOverrides : null} showAvatars={false} + on:updatetable={e => tables.replaceTable(id, e.detail)} > {#if isInternal} diff --git a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte index 1a4ced9f3a..61b706e28e 100644 --- a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte +++ b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte @@ -81,6 +81,7 @@ (value = detail.value)} value={stringVal} diff --git a/packages/builder/src/stores/backend/tables.js b/packages/builder/src/stores/backend/tables.js index a36c91d1b1..f8796712a8 100644 --- a/packages/builder/src/stores/backend/tables.js +++ b/packages/builder/src/stores/backend/tables.js @@ -62,7 +62,7 @@ export function createTablesStore() { } const savedTable = await API.saveTable(updatedTable) - replaceTable(table._id, savedTable) + replaceTable(savedTable._id, savedTable) await datasources.fetch() select(savedTable._id) return savedTable diff --git a/packages/client/src/websocket.js b/packages/client/src/websocket.js index 1e8e908d2b..8ca95fdc84 100644 --- a/packages/client/src/websocket.js +++ b/packages/client/src/websocket.js @@ -18,7 +18,9 @@ export const initWebsocket = () => { } // Initialise connection - socket = createWebsocket("/socket/client", false) + socket = createWebsocket("/socket/client", { + heartbeat: false, + }) // Event handlers socket.on("plugin-update", data => { diff --git a/packages/frontend-core/src/api/index.js b/packages/frontend-core/src/api/index.js index f8eee45cb8..739e0fe711 100644 --- a/packages/frontend-core/src/api/index.js +++ b/packages/frontend-core/src/api/index.js @@ -1,3 +1,4 @@ +import { Helpers } from "@budibase/bbui" import { ApiVersion } from "../constants" import { buildAnalyticsEndpoints } from "./analytics" import { buildAppEndpoints } from "./app" @@ -30,6 +31,14 @@ import { buildEnvironmentVariableEndpoints } from "./environmentVariables" import { buildEventEndpoints } from "./events" import { buildAuditLogsEndpoints } from "./auditLogs" +/** + * Random identifier to uniquely identify a session in a tab. This is + * used to determine the originator of calls to the API, which is in + * turn used to determine who caused a websocket message to be sent, so + * that we can ignore events caused by ourselves. + */ +export const APISessionID = Helpers.uuid() + const defaultAPIClientConfig = { /** * Certain definitions can't change at runtime for client apps, such as the @@ -116,6 +125,7 @@ export const createAPIClient = config => { // Build headers let headers = { Accept: "application/json" } + headers["x-budibase-session-id"] = APISessionID if (!external) { headers["x-budibase-api-version"] = ApiVersion } diff --git a/packages/frontend-core/src/components/grid/lib/websocket.js b/packages/frontend-core/src/components/grid/lib/websocket.js index bb1d6991b0..11164b3148 100644 --- a/packages/frontend-core/src/components/grid/lib/websocket.js +++ b/packages/frontend-core/src/components/grid/lib/websocket.js @@ -26,15 +26,15 @@ export const createGridWebsocket = context => { }) // User events - socket.on(SocketEvent.UserUpdate, user => { + socket.onOther(SocketEvent.UserUpdate, user => { users.actions.updateUser(user) }) - socket.on(SocketEvent.UserDisconnect, user => { + socket.onOther(SocketEvent.UserDisconnect, user => { users.actions.removeUser(user) }) // Row events - socket.on(GridSocketEvent.RowChange, async data => { + socket.onOther(GridSocketEvent.RowChange, async data => { if (data.id) { rows.actions.replaceRow(data.id, data.row) } else if (data.row.id) { @@ -44,7 +44,7 @@ export const createGridWebsocket = context => { }) // Table events - socket.on(GridSocketEvent.TableChange, data => { + socket.onOther(GridSocketEvent.TableChange, data => { // Only update table if one exists. If the table was deleted then we don't // want to know - let the builder navigate away if (data.table) { diff --git a/packages/frontend-core/src/components/grid/stores/columns.js b/packages/frontend-core/src/components/grid/stores/columns.js index b9f0b340a9..024fdc29bc 100644 --- a/packages/frontend-core/src/components/grid/stores/columns.js +++ b/packages/frontend-core/src/components/grid/stores/columns.js @@ -46,7 +46,7 @@ export const createStores = () => { } export const deriveStores = context => { - const { table, columns, stickyColumn, API } = context + const { table, columns, stickyColumn, API, dispatch } = context // Updates the tables primary display column const changePrimaryDisplay = async column => { @@ -90,6 +90,10 @@ export const deriveStores = context => { // Update local state table.set(newTable) + // Broadcast change to external state can be updated, as this change + // will not be received by the builder websocket because we caused it ourselves + dispatch("updatetable", newTable) + // Update server await API.saveTable(newTable) } diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js index 4b93e1d58f..198c05025c 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.js +++ b/packages/frontend-core/src/components/grid/stores/rows.js @@ -214,8 +214,7 @@ export const deriveStores = context => { const addRow = async (row, idx, bubble = false) => { try { // Create row - let newRow = await API.saveRow({ ...row, tableId: get(tableId) }) - newRow = await fetchRow(newRow._id) + const newRow = await API.saveRow({ ...row, tableId: get(tableId) }) // Update state if (idx != null) { @@ -437,6 +436,9 @@ export const deriveStores = context => { // Checks if we have a row with a certain ID const hasRow = id => { + if (id === NewRowID) { + return true + } return get(rowLookupMap)[id] != null } diff --git a/packages/frontend-core/src/utils/websocket.js b/packages/frontend-core/src/utils/websocket.js index 561a020e13..dee679eaef 100644 --- a/packages/frontend-core/src/utils/websocket.js +++ b/packages/frontend-core/src/utils/websocket.js @@ -1,17 +1,26 @@ import { io } from "socket.io-client" import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core" +import { APISessionID } from "../api" -export const createWebsocket = (path, heartbeat = true) => { +const DefaultOptions = { + heartbeat: true, +} + +export const createWebsocket = (path, options = DefaultOptions) => { if (!path) { throw "A websocket path must be provided" } + const { heartbeat } = { + ...DefaultOptions, + ...options, + } // Determine connection info const tls = location.protocol === "https:" const proto = tls ? "wss:" : "ws:" const host = location.hostname const port = location.port || (tls ? 443 : 80) - const socket = io(`${proto}//${host}:${port}`, { + let socket = io(`${proto}//${host}:${port}`, { path, // Cap reconnection attempts to 3 (total of 15 seconds before giving up) reconnectionAttempts: 3, @@ -37,5 +46,15 @@ export const createWebsocket = (path, heartbeat = true) => { clearInterval(interval) }) + // Helper utility to ignore events that were triggered due to API + // changes made by us + socket.onOther = (event, callback) => { + socket.on(event, data => { + if (data?.apiSessionId !== APISessionID) { + callback(data) + } + }) + } + return socket } diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index ee796e32d4..a68586b099 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -1,7 +1,7 @@ import { - SortDirection, FieldTypes, NoEmptyFilterStrings, + SortDirection, } from "../../../constants" import { breakExternalTableId, @@ -11,20 +11,34 @@ import { ExternalRequest, RunConfig } from "./ExternalRequest" import * as exporters from "../view/exporters" import { apiFileReturn } from "../../../utilities/fileSystem" import { - Operation, - UserCtx, - Row, - PaginationJson, - Table, Datasource, IncludeRelationship, + Operation, + PaginationJson, + Row, SortJson, + Table, + UserCtx, } from "@budibase/types" import sdk from "../../../sdk" import * as utils from "./utils" const { cleanExportRows } = require("./utils") +async function getRow( + tableId: string, + rowId: string, + opts?: { relationships?: boolean } +) { + const response = (await handleRequest(Operation.READ, tableId, { + id: breakRowIdField(rowId), + includeSqlRelationships: opts?.relationships + ? IncludeRelationship.INCLUDE + : IncludeRelationship.EXCLUDE, + })) as Row[] + return response ? response[0] : response +} + export async function handleRequest( operation: Operation, tableId: string, @@ -63,11 +77,15 @@ export async function patch(ctx: UserCtx) { if (!validateResult.valid) { throw { validation: validateResult.errors } } - return handleRequest(Operation.UPDATE, tableId, { + const response = await handleRequest(Operation.UPDATE, tableId, { id: breakRowIdField(id), row: inputs, - includeSqlRelationships: IncludeRelationship.INCLUDE, }) + const row = await getRow(tableId, id, { relationships: true }) + return { + ...response, + row, + } } export async function save(ctx: UserCtx) { @@ -80,10 +98,20 @@ export async function save(ctx: UserCtx) { if (!validateResult.valid) { throw { validation: validateResult.errors } } - return handleRequest(Operation.CREATE, tableId, { + const response = await handleRequest(Operation.CREATE, tableId, { row: inputs, - includeSqlRelationships: IncludeRelationship.EXCLUDE, }) + const responseRow = response as { row: Row } + const rowId = responseRow.row._id + if (rowId) { + const row = await getRow(tableId, rowId, { relationships: true }) + return { + ...response, + row, + } + } else { + return response + } } export async function fetchView(ctx: UserCtx) { @@ -104,11 +132,7 @@ export async function fetch(ctx: UserCtx) { export async function find(ctx: UserCtx) { const id = ctx.params.rowId const tableId = ctx.params.tableId - const response = (await handleRequest(Operation.READ, tableId, { - id: breakRowIdField(id), - includeSqlRelationships: IncludeRelationship.EXCLUDE, - })) as Row[] - return response ? response[0] : response + return getRow(tableId, id) } export async function destroy(ctx: UserCtx) { diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 55d2d27cce..91270429a4 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -50,7 +50,7 @@ export const save = async (ctx: any) => { if (body && body._id) { return patch(ctx) } - const { row, table } = await quotas.addRow(() => + const { row, table, squashed } = await quotas.addRow(() => quotas.addQuery(() => pickApi(tableId).save(ctx), { datasourceId: tableId, }) @@ -58,8 +58,9 @@ export const save = async (ctx: any) => { ctx.status = 200 ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table) ctx.message = `${table.name} saved successfully` - ctx.body = row - gridSocket?.emitRowUpdate(ctx, row) + // prefer squashed for response + ctx.body = row || squashed + gridSocket?.emitRowUpdate(ctx, row || squashed) } export async function fetchView(ctx: any) { const tableId = utils.getTableId(ctx) diff --git a/packages/server/src/api/controllers/row/staticFormula.ts b/packages/server/src/api/controllers/row/staticFormula.ts index 6ba44dc23a..9f2c64c7b4 100644 --- a/packages/server/src/api/controllers/row/staticFormula.ts +++ b/packages/server/src/api/controllers/row/staticFormula.ts @@ -7,6 +7,7 @@ import { import { FieldTypes, FormulaTypes } from "../../../constants" import { context } from "@budibase/backend-core" import { Table, Row } from "@budibase/types" +import * as linkRows from "../../../db/linkedRows" const { isEqual } = require("lodash") const { cloneDeep } = require("lodash/fp") @@ -166,5 +167,9 @@ export async function finaliseRow( if (updateFormula) { await updateRelatedFormula(table, enrichedRow) } - return { row: enrichedRow, table } + const squashed = await linkRows.squashLinksToPrimaryDisplay( + table, + enrichedRow + ) + return { row: enrichedRow, squashed, table } } diff --git a/packages/server/src/db/linkedRows/index.ts b/packages/server/src/db/linkedRows/index.ts index a6ed7de161..4d58d32f9b 100644 --- a/packages/server/src/db/linkedRows/index.ts +++ b/packages/server/src/db/linkedRows/index.ts @@ -189,11 +189,13 @@ export async function attachFullLinkedDocs(table: Table, rows: Row[]) { */ export async function squashLinksToPrimaryDisplay( table: Table, - enriched: Row[] + enriched: Row[] | Row ) { // will populate this as we find them const linkedTables = [table] - for (let row of enriched) { + const isArray = Array.isArray(enriched) + let enrichedArray = !isArray ? [enriched] : enriched + for (let row of enrichedArray) { // this only fetches the table if its not already in array const rowTable = await getLinkedTable(row.tableId!, linkedTables) for (let [column, schema] of Object.entries(rowTable?.schema || {})) { @@ -213,5 +215,5 @@ export async function squashLinksToPrimaryDisplay( row[column] = newLinks } } - return enriched + return isArray ? enrichedArray : enrichedArray[0] } diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index 44cab4d18b..4b6e0f6e87 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -216,7 +216,10 @@ export async function outputProcessing( } } if (opts.squash) { - enriched = await linkRows.squashLinksToPrimaryDisplay(table, enriched) + enriched = (await linkRows.squashLinksToPrimaryDisplay( + table, + enriched + )) as Row[] } return wasArray ? enriched : enriched[0] } diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts index ff7c50ebf4..0015b7f601 100644 --- a/packages/server/src/websockets/builder.ts +++ b/packages/server/src/websockets/builder.ts @@ -46,29 +46,32 @@ export default class BuilderSocket extends BaseSocket { } emitTableUpdate(ctx: any, table: Table) { - this.io - .in(ctx.appId) - .emit(BuilderSocketEvent.TableChange, { id: table._id, table }) - gridSocket?.emitTableUpdate(table) + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.TableChange, { + id: table._id, + table, + }) + gridSocket?.emitTableUpdate(ctx, table) } emitTableDeletion(ctx: any, id: string) { - this.io - .in(ctx.appId) - .emit(BuilderSocketEvent.TableChange, { id, table: null }) - gridSocket?.emitTableDeletion(id) + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.TableChange, { + id, + table: null, + }) + gridSocket?.emitTableDeletion(ctx, id) } emitDatasourceUpdate(ctx: any, datasource: Datasource) { - this.io.in(ctx.appId).emit(BuilderSocketEvent.DatasourceChange, { + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.DatasourceChange, { id: datasource._id, datasource, }) } emitDatasourceDeletion(ctx: any, id: string) { - this.io - .in(ctx.appId) - .emit(BuilderSocketEvent.DatasourceChange, { id, datasource: null }) + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.DatasourceChange, { + id, + datasource: null, + }) } } diff --git a/packages/server/src/websockets/grid.ts b/packages/server/src/websockets/grid.ts index 440b4f78a5..c12715990e 100644 --- a/packages/server/src/websockets/grid.ts +++ b/packages/server/src/websockets/grid.ts @@ -31,21 +31,25 @@ export default class GridSocket extends BaseSocket { emitRowUpdate(ctx: any, row: Row) { const tableId = getTableId(ctx) - this.io.in(tableId).emit(GridSocketEvent.RowChange, { id: row._id, row }) + this.emitToRoom(ctx, tableId, GridSocketEvent.RowChange, { + id: row._id, + row, + }) } emitRowDeletion(ctx: any, id: string) { const tableId = getTableId(ctx) - this.io.in(tableId).emit(GridSocketEvent.RowChange, { id, row: null }) + this.emitToRoom(ctx, tableId, GridSocketEvent.RowChange, { id, row: null }) } - emitTableUpdate(table: Table) { - this.io - .in(table._id!) - .emit(GridSocketEvent.TableChange, { id: table._id, table }) + emitTableUpdate(ctx: any, table: Table) { + this.emitToRoom(ctx, table._id!, GridSocketEvent.TableChange, { + id: table._id, + table, + }) } - emitTableDeletion(id: string) { - this.io.in(id).emit(GridSocketEvent.TableChange, { id, table: null }) + emitTableDeletion(ctx: any, id: string) { + this.emitToRoom(ctx, id, GridSocketEvent.TableChange, { id, table: null }) } } diff --git a/packages/server/src/websockets/websocket.ts b/packages/server/src/websockets/websocket.ts index a2d2474c77..d9c105d77c 100644 --- a/packages/server/src/websockets/websocket.ts +++ b/packages/server/src/websockets/websocket.ts @@ -3,7 +3,7 @@ import http from "http" import Koa from "koa" import Cookies from "cookies" import { userAgent } from "koa-useragent" -import { auth, redis } from "@budibase/backend-core" +import { auth, Header, redis } from "@budibase/backend-core" import currentApp from "../middleware/currentapp" import { createAdapter } from "@socket.io/redis-adapter" import { Socket } from "socket.io" @@ -271,4 +271,13 @@ export class BaseSocket { emit(event: string, payload: any) { this.io.sockets.emit(event, payload) } + + // Emit an event to everyone in a room, including metadata of whom + // the originator of the request was + emitToRoom(ctx: any, room: string, event: string, payload: any) { + this.io.in(room).emit(event, { + ...payload, + apiSessionId: ctx.headers?.[Header.SESSION_ID], + }) + } } diff --git a/packages/worker/src/api/controllers/global/configs.ts b/packages/worker/src/api/controllers/global/configs.ts index 66804c3b9c..afbb7c931d 100644 --- a/packages/worker/src/api/controllers/global/configs.ts +++ b/packages/worker/src/api/controllers/global/configs.ts @@ -11,7 +11,6 @@ import { tenancy, } from "@budibase/backend-core" import { checkAnyUserExists } from "../../../utilities/users" -import { getLicensedConfig } from "../../../utilities/configs" import { Config, ConfigType,