Files
supabase/apps/studio/components/grid/utils/queueOperationUtils.ts
Aaditya Bhusal 719434a7fd fix(studio): batched table edits issues (#47319)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix

## What is the current behavior?

Fixes #47318

Supabase Studio's batched table edit queue has a few related row
identity issues:

- Editing a row's primary key can make later queued edits or deletes
lose track of the original row.
- Editing a primary key and another column in the same row before saving
can save only the primary key change, because later updates still use
the old primary key in the `WHERE` clause.
- Adding a row in batched edit mode and then deleting it before saving
may not remove the pending row correctly.

## What is the new behavior?

- Preserves the original row identity for queued operations after
primary key edits.
- Applies multiple queued edits for the same row as a single update when
saving.
- Correctly deletes newly added pending rows before they are saved.
- Adds regression coverage for these batched table edit cases.

## Additional context


https://github.com/user-attachments/assets/75672361-d781-4fe5-a542-071574ad57bd


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved row identity handling for grid edits, optimistic updates, and
queued operations so changes stay correctly attached when primary keys
are edited, reverted, or “taken” by another row.
* Updated header row deletion to delete from the currently
visible/targeted rows rather than relying on the full dataset.
* Reduced retry noise for missing tables by clearing conflicting sorts
and preventing repeated retries for the same “does not exist” error.
* More reliably consolidated queued edits for the same row into fewer
combined save statements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
2026-06-29 08:12:18 -06:00

281 lines
7.9 KiB
TypeScript

import { isPendingAddRow, PendingAddRow, SupaRow } from '../types'
import { isTableLike, type Entity } from '@/data/table-editor/table-editor-types'
import { isObject } from '@/lib/helpers'
import {
EditCellContentOperation,
NewQueuedOperation,
QueuedOperation,
QueuedOperationType,
} from '@/state/table-editor-operation-queue.types'
import type { Dictionary } from '@/types'
// Client-only marker that preserves the original SQL WHERE identifiers after PK edits.
const ORIGINAL_ROW_IDENTIFIERS_KEY = '__originalRowIdentifiers'
export function getStableRowIdentifiers(
row: Dictionary<unknown>,
fallbackIdentifiers: Dictionary<unknown>
): Dictionary<unknown> {
const identifiers = row[ORIGINAL_ROW_IDENTIFIERS_KEY]
return { ...(isObject(identifiers) ? identifiers : fallbackIdentifiers) }
}
function withOriginalRowIdentifiers<T extends SupaRow>(
row: T,
rowIdentifiers: Dictionary<unknown>
): T {
return { ...row, [ORIGINAL_ROW_IDENTIFIERS_KEY]: { ...rowIdentifiers } }
}
interface EditCellKeyOperation extends Omit<
EditCellContentOperation,
'payload' | 'id' | 'timestamp'
> {
type: QueuedOperationType.EDIT_CELL_CONTENT
tableId: number
payload: {
columnName: string
rowIdentifiers: Dictionary<unknown>
}
}
export function generateTableChangeKey(
operation: NewQueuedOperation | EditCellKeyOperation
): string {
if (operation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
const { columnName, rowIdentifiers } = operation.payload
const rowIdentifiersKey = Object.entries(rowIdentifiers)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${value}`)
.join('|')
return `${operation.type}:${operation.tableId}:${columnName}:${rowIdentifiersKey}`
}
if (operation.type === QueuedOperationType.ADD_ROW) {
return `${operation.type}:${operation.tableId}:${operation.payload.tempId}`
}
if (operation.type === QueuedOperationType.DELETE_ROW) {
const { rowIdentifiers } = operation.payload
const rowIdentifiersKey = Object.entries(rowIdentifiers)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${value}`)
.join('|')
return `${operation.type}:${operation.tableId}:${rowIdentifiersKey}`
}
// Exhaustive check - TypeScript will error if we miss a case
const _exhaustiveCheck: never = operation
throw new Error(`Unknown operation type: ${(_exhaustiveCheck as { type: string }).type}`)
}
export function rowMatchesIdentifiers(
row: Dictionary<unknown>,
rowIdentifiers: Dictionary<unknown>
): boolean {
const identifierEntries = Object.entries(rowIdentifiers)
if (identifierEntries.length === 0) return false
return identifierEntries.every(([key, value]) => row[key] === value)
}
function rowMatchesOperationIdentifiers(
row: Dictionary<unknown>,
rowIdentifiers: Dictionary<unknown>
): boolean {
const identifiers = row[ORIGINAL_ROW_IDENTIFIERS_KEY]
return rowMatchesIdentifiers(isObject(identifiers) ? identifiers : row, rowIdentifiers)
}
export function removeRow(rows: SupaRow[], rowIdentifiers: Dictionary<unknown>): SupaRow[] {
return rows.filter((row) => !rowMatchesIdentifiers(row, rowIdentifiers))
}
interface QueueCellEditParams {
queueOperation: (operation: NewQueuedOperation) => void
tableId: number
table: Entity
row: SupaRow
rowIdentifiers: Dictionary<unknown>
columnName: string
oldValue: unknown
newValue: unknown
enumArrayColumns?: string[]
}
export function queueCellEditWithOptimisticUpdate({
queueOperation,
tableId,
table,
row,
rowIdentifiers: callerRowIdentifiers,
columnName,
oldValue,
newValue,
enumArrayColumns,
}: QueueCellEditParams) {
// Pending add rows use __tempId so edits merge into the ADD_ROW operation.
const rowIdentifiers = getStableRowIdentifiers(row, callerRowIdentifiers)
if (isPendingAddRow(row)) {
rowIdentifiers.__tempId = row.__tempId
}
// Queue the operation
queueOperation({
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId,
payload: {
rowIdentifiers,
columnName,
oldValue,
newValue,
table,
enumArrayColumns,
},
})
}
interface QueueRowAddParams {
queueOperation: (operation: NewQueuedOperation) => void
tableId: number
table: Entity
rowData: PendingAddRow
enumArrayColumns?: string[]
}
export function queueRowAddWithOptimisticUpdate({
queueOperation,
tableId,
table,
rowData,
enumArrayColumns,
}: QueueRowAddParams) {
// Generate unique idx and tempId for this pending row
const idx = -Date.now()
const tempId = String(idx)
// Queue the operation
queueOperation({
type: QueuedOperationType.ADD_ROW,
tableId,
payload: {
tempId,
rowData,
table,
enumArrayColumns,
},
})
}
export const formatGridDataWithOperationValues = ({
operations,
rows,
}: {
operations: QueuedOperation[]
rows: SupaRow[]
}) => {
const formattedRows = rows.slice()
operations.forEach((op) => {
if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) {
const { rowIdentifiers, columnName, newValue } = op.payload
const rowIdx = formattedRows.findIndex((row) =>
rowMatchesOperationIdentifiers(row, rowIdentifiers)
)
if (rowIdx !== -1) {
formattedRows[rowIdx] = withOriginalRowIdentifiers(
{ ...formattedRows[rowIdx], [columnName]: newValue },
rowIdentifiers
)
}
} else if (op.type === QueuedOperationType.ADD_ROW) {
const { tempId, rowData } = op.payload
const idx = Number(tempId)
// Check if row with this tempId already exists
const existingIndex = formattedRows.findIndex(
(row) => isPendingAddRow(row) && row.__tempId === tempId
)
if (existingIndex >= 0) {
// Update existing row in place
formattedRows[existingIndex] = {
...formattedRows[existingIndex],
...rowData,
__tempId: tempId,
}
} else {
const newRow: PendingAddRow = { ...rowData, idx, __tempId: tempId }
formattedRows.unshift(newRow)
}
} else if (op.type === QueuedOperationType.DELETE_ROW) {
const { rowIdentifiers } = op.payload
const rowIdx = formattedRows.findIndex((row) =>
rowMatchesOperationIdentifiers(row, rowIdentifiers)
)
if (rowIdx !== -1) {
formattedRows[rowIdx] = withOriginalRowIdentifiers(
{ ...formattedRows[rowIdx], __isDeleted: true },
rowIdentifiers
)
}
}
})
return formattedRows
}
interface QueueRowDeletesParams {
rows: SupaRow[]
table: Entity
queueOperation: (operation: NewQueuedOperation) => void
projectRef: string | undefined
}
/**
* Queue multiple row delete operations with optimistic updates.
* Caller is responsible for checking if queue mode is enabled before calling.
*/
export function queueRowDeletesWithOptimisticUpdate({
rows,
table,
queueOperation,
projectRef,
}: QueueRowDeletesParams): void {
// [Ali] We can handle these better in the future
// right now this is a pretty abnormal case of this occurring
if (!projectRef) {
console.error('Cannot queue row deletes: projectRef is required')
return
}
if (!isTableLike(table)) {
console.error('Cannot queue row deletes: table must be a TableLike entity')
return
}
if (table.primary_keys.length === 0) {
console.error('Cannot queue row deletes: table has no primary keys')
return
}
for (const row of rows) {
const rowIdentifiers: Record<string, unknown> = {}
table.primary_keys.forEach((pk) => {
rowIdentifiers[pk.name] = row[pk.name]
})
const stableRowIdentifiers = getStableRowIdentifiers(row, rowIdentifiers)
if (isPendingAddRow(row)) {
stableRowIdentifiers.__tempId = row.__tempId
}
queueOperation({
type: QueuedOperationType.DELETE_ROW,
tableId: table.id,
payload: {
rowIdentifiers: stableRowIdentifiers,
originalRow: row,
table,
},
})
}
}