mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 18:38:50 +08:00
## 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>
281 lines
7.9 KiB
TypeScript
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,
|
|
},
|
|
})
|
|
}
|
|
}
|