Budibase/budibase · error · HTTPError

Cannot delete rows through a calculation view

Error message

Cannot delete rows through a calculation view

What it means

The external (SQL-backed) row destroy endpoint refuses to delete rows when the request targets a calculation view. Calculation views are derived/aggregated read-only projections, so there is no underlying single row to delete through them. The server rejects the request up front with HTTP 400 rather than producing invalid SQL against the view.

Source

Thrown at packages/server/src/api/controllers/row/external.ts:119

    outputProcessing(source, beforeRow, {
      squash: true,
      preserveLinks: true,
    }),
  ])

  return {
    ...response,
    row: enrichedRow,
    table,
    oldRow,
  }
}

export async function destroy(ctx: UserCtx) {
  const source = await utils.getSource(ctx)

  if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) {
    throw new HTTPError("Cannot delete rows through a calculation view", 400)
  }

  const _id = ctx.request.body._id
  const { row } = await handleRequest(Operation.DELETE, source, {
    id: breakRowIdField(_id),
    includeSqlRelationships: IncludeRelationship.EXCLUDE,
  })
  return { response: { ok: true, id: _id }, row }
}

export async function bulkDestroy(ctx: UserCtx) {
  const { rows } = ctx.request.body
  const source = await utils.getSource(ctx)
  let promises: Promise<{ row: Row; table: Table }>[] = []
  for (let row of rows) {
    promises.push(
      handleRequest(Operation.DELETE, source, {
        id: breakRowIdField(row._id),

View on GitHub (pinned to a81a902e9a)

Solutions

  1. Delete using the underlying table ID (the table the calculation view is built on) rather than the view ID.
  2. Resolve the specific row's _id against the base table and issue the delete against that table.
  3. If rows must be 'removed' from a calculation view, adjust the view definition/source data so the row no longer matches, instead of deleting through the view.

Example fix

// before
deleteRow({ viewId: "vdatasource_view", rowId })
// after
const tableId = getTableIdFromViewId("vdatasource_view")
deleteRow({ tableId, rowId })
Defensive patterns

Strategy: validation

Validate before calling

// client-side check before DELETE
if (sourceId.startsWith("vd_") && isCalculationViewId(sourceId)) {
  sourceId = getTableIdFromViewId(sourceId)
}
await api.delete(`/api/${sourceId}/rows/${rowId}`)

Type guard

function isDeletableSource(source: Table | ViewV2): source is Table {
  return !isView(source)
}

Try / catch

try {
  await api.delete(`/api/${sourceId}/rows/${rowId}`)
} catch (err) {
  if (err.status === 400 && err.message.includes("calculation view")) {
    // fall back to deleting via the base table
  }
  throw err
}

Prevention

When it happens

Trigger: Calling DELETE on the external row API (e.g. DELETE /api/:tableId/rows or rows/:id) where the sourceId/tableId in the URL or body resolves to a calculation view instead of a table or ordinary view; the check `isView(source) && isCalculationView(source)` fires in destroy() at packages/server/src/api/controllers/row/external.ts:119.

Common situations: A client (automation, script, or SDK integration) was configured with a view ID instead of a table ID and the view happens to be a calculation view; UI lists rows from a calculation view and passes the view context through to the delete call; copying a working delete request for a table and swapping in a view ID.

Related errors


AI-assisted analysis of Budibase/budibase@a81a902e9a (2026-08-29). Data as JSON: /api/errors/80bd0121251eef6f. Report an issue: GitHub.