feat(base): add /bases/rows/count with estimate and capped-exact modes

Row-count display on a filtered view shouldn't force a full COUNT(*) on
every list fetch. New endpoint returns either an EXPLAIN-plan estimate
(default, ~1ms, no execution) or a LIMIT-capped exact count that short-
circuits to `{ capped: true }` once the match set passes EXACT_COUNT_CAP.
Clients call it in parallel with the rows query so the grid still paints
at its own pace.

- DTO + repo.countEstimate/countExact reusing the list predicate shape
- service picks the mode; controller mirrors the list Read ability check
- client hook keyed by filter/search/exact so a "show exact" toggle
  doesn't clobber the estimate cache
This commit is contained in:
Philipinho
2026-04-24 12:11:29 +01:00
parent b9d8bf948c
commit 89ee3714ac
7 changed files with 244 additions and 2 deletions
@@ -1,6 +1,7 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
InfiniteData,
} from "@tanstack/react-query";
import {
@@ -10,6 +11,7 @@ import {
deleteRows,
listRows,
reorderRow,
countRows,
} from "@/features/base/services/base-service";
import {
IBaseRow,
@@ -21,6 +23,7 @@ import {
FilterNode,
SearchSpec,
ViewSortConfig,
CountRowsResult,
} from "@/features/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
@@ -277,6 +280,35 @@ export function useDeleteRowsMutation() {
});
}
/*
* Row count for the current view. Fires in parallel with `useBaseRowsQuery`
* — doesn't block first paint. Keyed by filter + search so an independent
* view with a different filter gets its own cached count; `exact` is part
* of the key so a "show exact" toggle doesn't clobber the estimate cache.
*/
export function useBaseRowsCountQuery(
baseId: string | undefined,
filter?: FilterNode,
search?: SearchSpec,
exact = false,
) {
const activeFilter = normalizeFilter(filter);
const activeSearch = search?.query ? search : undefined;
return useQuery<CountRowsResult>({
queryKey: ["base-rows-count", baseId, activeFilter, activeSearch, exact],
queryFn: () =>
countRows({
baseId: baseId!,
filter: activeFilter,
search: activeSearch,
exact,
}),
enabled: !!baseId,
staleTime: 30 * 1000,
});
}
export function useReorderRowMutation() {
const { t } = useTranslation();
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
@@ -23,6 +23,8 @@ import {
FilterNode,
SearchSpec,
ViewSortConfig,
CountRowsInput,
CountRowsResult,
} from "@/features/base/types/base.types";
import { IPagination } from "@/lib/types";
@@ -151,6 +153,13 @@ export async function reorderRow(data: ReorderRowInput): Promise<void> {
await api.post("/bases/rows/reorder", data);
}
export async function countRows(
data: CountRowsInput,
): Promise<CountRowsResult> {
const req = await api.post<CountRowsResult>("/bases/rows/count", data);
return req.data;
}
// --- Views ---
export async function createView(data: CreateViewInput): Promise<IBaseView> {
@@ -239,6 +239,21 @@ export type ReorderPropertyInput = {
requestId?: string;
};
export type CountRowsInput = {
baseId: string;
filter?: FilterNode;
search?: SearchSpec;
// When true the server returns an exact (but capped) count. Otherwise
// a cheap EXPLAIN-plan estimate. Paired with `capped` on the result.
exact?: boolean;
};
export type CountRowsResult = {
value: number;
exact: boolean;
capped: boolean;
};
export type CreateRowInput = {
baseId: string;
cells?: Record<string, unknown>;