Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/api/table/[tableId]/rows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export const GET = withRouteHandler(
data: r.data,
executions: r.executions,
position: r.position,
orderKey: r.orderKey ?? undefined,
createdAt:
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt:
Expand Down
81 changes: 76 additions & 5 deletions apps/sim/hooks/queries/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,26 @@ function invalidateRowCount(queryClient: ReturnType<typeof useQueryClient>, tabl
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
}

/**
* Invalidate only the row-count surfaces — the table detail and the tables
* list, both of which carry the unfiltered `rowCount`. Deliberately leaves
* `rowsRoot` (the rows infinite query) untouched so an offset-paginated refetch
* can't resolve late and clobber rows already spliced in optimistically. Use
* for inserts, where `reconcileCreatedRow` is the source of truth for the rows
* cache and its `totalCount`.
*
* `rowsRoot` is nested under `detail` (`[...detail(tableId), 'rows']`), so the
* detail invalidation MUST be `exact` — a prefix match would cascade into the
* rows queries and trigger the very refetch this helper exists to avoid.
*/
function invalidateRowCountSurfaces(
queryClient: ReturnType<typeof useQueryClient>,
tableId: string
) {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true })
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
}

function invalidateTableSchema(queryClient: ReturnType<typeof useQueryClient>, tableId: string) {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
Expand Down Expand Up @@ -585,13 +605,27 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
// prior executions, so the stamped set is the full delta).
const stampedCount = countNewlyInFlight({}, stamped.executions ?? {})
if (stampedCount > 0) bumpRunState(queryClient, tableId, { [row.id]: stampedCount })

// `reconcileCreatedRow` only patches the default-order view. Filtered /
// column-sorted rows queries can't be reconciled from that heuristic
// (membership, sort position, and `totalCount` are query-specific), so
// refetch them — active ones update now, inactive ones on next view. The
// default view stays optimistic, so the common case never refetches.
queryClient.invalidateQueries({
queryKey: tableKeys.rowsRoot(tableId),
exact: false,
predicate: (query) => !isDefaultOrderRowsQuery(query.queryKey),
})
},
onError: (error) => {
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSettled: () => {
invalidateRowCount(queryClient, tableId)
// `reconcileCreatedRow` (onSuccess) is the source of truth for the rows
// cache + its `totalCount`; only refresh the count surfaces here so a late
// offset refetch can't clobber freshly-inserted rows (insert-flicker).
invalidateRowCountSurfaces(queryClient, tableId)
Comment thread
cursor[bot] marked this conversation as resolved.
},
})
}
Expand Down Expand Up @@ -630,20 +664,47 @@ function patchCachedRows(
)
}

/**
* A cached rows query whose ordering matches {@link reconcileCreatedRow}'s
* orderKey/position heuristic: the default view with no active filter or sort.
* Filtered or column-sorted variants encode a non-null `filter`/`sort` in their
* params key — their membership, order, and `totalCount` are query-specific, so
* an optimistic splice can't be trusted there (they're refetched instead). The
* `find`/`write` subtrees aren't row-list data and never match.
*/
function isDefaultOrderRowsQuery(queryKey: readonly unknown[]): boolean {
if (queryKey.includes('find') || queryKey.includes('write')) return false
const last = queryKey[queryKey.length - 1]
if (typeof last !== 'string') return false
try {
const params = JSON.parse(last) as { filter?: unknown; sort?: unknown }
return params.filter == null && params.sort == null
} catch {
return false
}
}

/**
* Splice a server-returned new row into the paginated row cache. Bumps the
* `position` of any cached row at or past the new row's position, then inserts
* the row into the overlapping page (or appends to the last page when the
* position lies past everything fetched). `onSettled` invalidation reconciles
* drift after the next refetch.
* position lies past everything fetched).
*
* Scoped to the default-order rows queries only — the orderKey/position
* heuristic matches the unfiltered, unsorted server order, not an active filter
* or column sort. Filtered/sorted queries are refetched by the caller.
*/
function reconcileCreatedRow(
queryClient: ReturnType<typeof useQueryClient>,
tableId: string,
row: TableRow
) {
queryClient.setQueriesData<InfiniteData<TableRowsResponse, number>>(
{ queryKey: tableKeys.rowsRoot(tableId), exact: false },
{
queryKey: tableKeys.rowsRoot(tableId),
exact: false,
predicate: (query) => isDefaultOrderRowsQuery(query.queryKey),
},
(old) => {
if (!old) return old
if (old.pages.some((p) => p.rows.some((r) => r.id === row.id))) return old
Expand All @@ -654,9 +715,19 @@ function reconcileCreatedRow(
// path so un-keyed rows aren't yanked to the front by an empty-string sort.
const byKey =
row.orderKey != null && old.pages.every((p) => p.rows.every((r) => r.orderKey != null))
// Compare order keys bytewise to match the server's `COLLATE "C"` ordering
// and the `>=` checks in `fitsAfter` — `localeCompare` is locale-aware and
// would place the new row in a different slot than the server (e.g. an
// uppercase-prefixed key), leaving it visibly misordered until next reload.
const sortRows = (rows: TableRow[]) =>
byKey
? [...rows].sort((a, b) => (a.orderKey as string).localeCompare(b.orderKey as string))
? [...rows].sort((a, b) =>
(a.orderKey as string) < (b.orderKey as string)
? -1
: (a.orderKey as string) > (b.orderKey as string)
? 1
: 0
)
: [...rows].sort((a, b) => a.position - b.position)
const fitsAfter = (last: TableRow | undefined) =>
last === undefined ||
Expand Down
Loading