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
@@ -18,6 +18,7 @@ import {
RowIdDto,
ListRowsDto,
ReorderRowDto,
CountRowsDto,
} from '../dto/update-row.dto';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
@@ -160,6 +161,26 @@ export class BaseRowController {
return this.baseRowService.list(dto, pagination, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('count')
async count(
@Body() dto: CountRowsDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseRowService.count(dto, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('reorder')
async reorder(
@@ -1,4 +1,5 @@
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsObject,
@@ -83,6 +84,25 @@ export class ListRowsDto {
search?: unknown;
}
export class CountRowsDto {
@IsUUID()
baseId: string;
@IsOptional()
@IsObject()
filter?: unknown;
@IsOptional()
@IsObject()
search?: unknown;
// Opt-in for an exact (but capped) count. Default is an EXPLAIN-plan
// estimate — cheap and good enough for "≈ N rows" headers.
@IsOptional()
@IsBoolean()
exact?: boolean;
}
export class ReorderRowDto {
@IsUUID()
rowId: string;
@@ -19,6 +19,7 @@ import {
DeleteRowsDto,
ListRowsDto,
ReorderRowDto,
CountRowsDto,
} from '../dto/update-row.dto';
import {
BasePropertyTypeValue,
@@ -46,6 +47,12 @@ import {
} from '../events/base-events';
import { FormulaService } from '../formula/formula.service';
// Cap for `count({ exact: true })`. Beyond this we return `capped: true` and
// the UI shows "N+". Chosen so a cell-wise scan stays well under 100ms on
// the existing `idx_base_rows_cells_gin_path_ops` index; callers that need
// true totals should fall back to `exact: false` (planner estimate).
const EXACT_COUNT_CAP = 10_000;
@Injectable()
export class BaseRowService {
constructor(
@@ -244,6 +251,39 @@ export class BaseRowService {
});
}
async count(dto: CountRowsDto, workspaceId: string) {
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
const schema: PropertySchema = new Map(properties.map((p) => [p.id, p]));
const filter = this.normaliseFilter({ filter: dto.filter });
const search = this.normaliseSearch(dto.search);
if (dto.exact) {
const { value, capped } = await this.baseRowRepo.countExact({
baseId: dto.baseId,
workspaceId,
filter,
search,
schema,
cap: EXACT_COUNT_CAP,
});
return { value, exact: true as const, capped };
}
const estimate = await this.baseRowRepo.countEstimate({
baseId: dto.baseId,
workspaceId,
filter,
search,
schema,
});
return {
value: estimate ?? 0,
exact: false as const,
capped: false,
};
}
async reorder(dto: ReorderRowDto, workspaceId: string, userId?: string) {
const row = await this.baseRowRepo.findById(dto.rowId, { workspaceId });
if (!row || row.baseId !== dto.baseId) {
@@ -274,7 +314,7 @@ export class BaseRowService {
// --- private helpers ------------------------------------------------
private normaliseFilter(dto: ListRowsDto): FilterNode | undefined {
private normaliseFilter(dto: { filter?: unknown }): FilterNode | undefined {
if (!dto.filter) return undefined;
const parsed = filterGroupSchema.safeParse(dto.filter);