refactor: use cursors instead of pages

This commit is contained in:
izzy 2026-01-06 15:49:36 +00:00
parent 06ee275202
commit d189722bbf
No known key found for this signature in database
12 changed files with 100 additions and 115 deletions

View File

@ -13,19 +13,18 @@ part of openapi.api;
class IntegrityGetReportDto {
/// Returns a new [IntegrityGetReportDto] instance.
IntegrityGetReportDto({
this.page,
this.size,
this.cursor,
this.limit,
required this.type,
});
/// Minimum value: 1
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? page;
DateTime? cursor;
/// Minimum value: 1
///
@ -34,37 +33,37 @@ class IntegrityGetReportDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? size;
num? limit;
IntegrityReportType type;
@override
bool operator ==(Object other) => identical(this, other) || other is IntegrityGetReportDto &&
other.page == page &&
other.size == size &&
other.cursor == cursor &&
other.limit == limit &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(page == null ? 0 : page!.hashCode) +
(size == null ? 0 : size!.hashCode) +
(cursor == null ? 0 : cursor!.hashCode) +
(limit == null ? 0 : limit!.hashCode) +
(type.hashCode);
@override
String toString() => 'IntegrityGetReportDto[page=$page, size=$size, type=$type]';
String toString() => 'IntegrityGetReportDto[cursor=$cursor, limit=$limit, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.page != null) {
json[r'page'] = this.page;
if (this.cursor != null) {
json[r'cursor'] = this.cursor!.toUtc().toIso8601String();
} else {
// json[r'page'] = null;
// json[r'cursor'] = null;
}
if (this.size != null) {
json[r'size'] = this.size;
if (this.limit != null) {
json[r'limit'] = this.limit;
} else {
// json[r'size'] = null;
// json[r'limit'] = null;
}
json[r'type'] = this.type;
return json;
@ -79,8 +78,8 @@ class IntegrityGetReportDto {
final json = value.cast<String, dynamic>();
return IntegrityGetReportDto(
page: num.parse('${json[r'page']}'),
size: num.parse('${json[r'size']}'),
cursor: mapDateTime(json, r'cursor', r''),
limit: num.parse('${json[r'limit']}'),
type: IntegrityReportType.fromJson(json[r'type'])!,
);
}

View File

@ -13,32 +13,42 @@ part of openapi.api;
class IntegrityReportResponseDto {
/// Returns a new [IntegrityReportResponseDto] instance.
IntegrityReportResponseDto({
required this.hasNextPage,
this.items = const [],
this.nextCursor,
});
bool hasNextPage;
List<IntegrityReportDto> items;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? nextCursor;
@override
bool operator ==(Object other) => identical(this, other) || other is IntegrityReportResponseDto &&
other.hasNextPage == hasNextPage &&
_deepEquality.equals(other.items, items);
_deepEquality.equals(other.items, items) &&
other.nextCursor == nextCursor;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(hasNextPage.hashCode) +
(items.hashCode);
(items.hashCode) +
(nextCursor == null ? 0 : nextCursor!.hashCode);
@override
String toString() => 'IntegrityReportResponseDto[hasNextPage=$hasNextPage, items=$items]';
String toString() => 'IntegrityReportResponseDto[items=$items, nextCursor=$nextCursor]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'hasNextPage'] = this.hasNextPage;
json[r'items'] = this.items;
if (this.nextCursor != null) {
json[r'nextCursor'] = this.nextCursor!.toUtc().toIso8601String();
} else {
// json[r'nextCursor'] = null;
}
return json;
}
@ -51,8 +61,8 @@ class IntegrityReportResponseDto {
final json = value.cast<String, dynamic>();
return IntegrityReportResponseDto(
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage')!,
items: IntegrityReportDto.listFromJson(json[r'items']),
nextCursor: mapDateTime(json, r'nextCursor', r''),
);
}
return null;
@ -100,7 +110,6 @@ class IntegrityReportResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'hasNextPage',
'items',
};
}

View File

@ -16928,11 +16928,11 @@
},
"IntegrityGetReportDto": {
"properties": {
"page": {
"minimum": 1,
"type": "number"
"cursor": {
"format": "uuid",
"type": "string"
},
"size": {
"limit": {
"minimum": 1,
"type": "number"
},
@ -16974,18 +16974,17 @@
},
"IntegrityReportResponseDto": {
"properties": {
"hasNextPage": {
"type": "boolean"
},
"items": {
"items": {
"$ref": "#/components/schemas/IntegrityReportDto"
},
"type": "array"
},
"nextCursor": {
"type": "string"
}
},
"required": [
"hasNextPage",
"items"
],
"type": "object"

View File

@ -41,8 +41,8 @@ export type ActivityStatisticsResponseDto = {
likes: number;
};
export type IntegrityGetReportDto = {
page?: number;
size?: number;
cursor?: string;
limit?: number;
"type": IntegrityReportType;
};
export type IntegrityReportDto = {
@ -51,8 +51,8 @@ export type IntegrityReportDto = {
"type": IntegrityReportType;
};
export type IntegrityReportResponseDto = {
hasNextPage: boolean;
items: IntegrityReportDto[];
nextCursor?: string;
};
export type IntegrityReportSummaryResponseDto = {
checksum_mismatch: number;

View File

@ -130,6 +130,14 @@ const create = (path: string, up: string[], down: string[]) => {
const compare = async () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
database.config = {
connectionType: 'parts',
database: 'immich',
host: 'database',
password: 'postgres',
username: 'postgres',
port: 5432,
};
const db = postgres(asPostgresConnectionConfig(database.config));
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });

View File

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
import { IsInt, IsOptional, IsUUID, Min } from 'class-validator';
import { IntegrityReportType } from 'src/enum';
import { ValidateEnum } from 'src/validation';
@ -17,17 +17,15 @@ export class IntegrityGetReportDto {
@ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' })
type!: IntegrityReportType;
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@IsUUID()
cursor?: string;
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
size?: number;
limit?: number;
}
export class IntegrityDeleteReportDto {
@ -44,5 +42,5 @@ class IntegrityReportDto {
export class IntegrityReportResponseDto {
items!: IntegrityReportDto[];
hasNextPage!: boolean;
nextCursor?: string;
}

View File

@ -31,16 +31,16 @@ select
"type",
"path",
"assetId",
"fileAssetId"
"fileAssetId",
"createdAt"
from
"integrity_report"
where
"type" = $1
and "createdAt" <= $2
order by
"createdAt" desc
limit
$2
offset
$3
-- IntegrityRepository.getAssetPathsByPaths

View File

@ -5,11 +5,10 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { IntegrityReportType } from 'src/enum';
import { DB } from 'src/schema';
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
import { paginationHelper } from 'src/utils/pagination';
export interface ReportPaginationOptions {
page: number;
size: number;
cursor?: string;
limit: number;
}
@Injectable()
@ -64,18 +63,21 @@ export class IntegrityRepository {
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{ page: 1, size: 100 }, DummyValue.STRING] })
@GenerateSql({ params: [{ cursor: DummyValue.NUMBER, limit: 100 }, DummyValue.STRING] })
async getIntegrityReports(pagination: ReportPaginationOptions, type: IntegrityReportType) {
const items = await this.db
.selectFrom('integrity_report')
.select(['id', 'type', 'path', 'assetId', 'fileAssetId'])
.select(['id', 'type', 'path', 'assetId', 'fileAssetId', 'createdAt'])
.where('type', '=', type)
.orderBy('createdAt', 'desc')
.limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size)
.$if(pagination.cursor !== undefined, (eb) => eb.where('id', '<=', pagination.cursor!))
.orderBy('id', 'desc')
.limit(pagination.limit + 1)
.execute();
return paginationHelper(items, pagination.size);
return {
items: items.slice(0, pagination.limit),
nextCursor: items[pagination.limit]?.id,
};
}
@GenerateSql({ params: [DummyValue.STRING] })

View File

@ -2,7 +2,7 @@ import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "integrity_report" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"type" character varying NOT NULL,
"path" character varying NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),

View File

@ -1,21 +1,13 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { IntegrityReportType } from 'src/enum';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
Unique,
} from 'src/sql-tools';
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp, Unique } from 'src/sql-tools';
@Table('integrity_report')
@Unique({ columns: ['type', 'path'] })
export class IntegrityReportTable {
@PrimaryGeneratedColumn()
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column()

View File

@ -140,7 +140,7 @@ export class IntegrityService extends BaseService {
}
async getIntegrityReport(dto: IntegrityGetReportDto): Promise<IntegrityReportResponseDto> {
return this.integrityRepository.getIntegrityReports({ page: dto.page || 1, size: dto.size || 100 }, dto.type);
return this.integrityRepository.getIntegrityReports({ cursor: dto.cursor, limit: dto.limit || 100 }, dto.type);
}
getIntegrityReportCsv(type: IntegrityReportType): Readable {

View File

@ -13,6 +13,7 @@
ManualJobName,
} from '@immich/sdk';
import {
Button,
HStack,
IconButton,
menuManager,
@ -21,14 +22,7 @@
type ContextMenuBaseProps,
type MenuItems,
} from '@immich/ui';
import {
mdiChevronLeft,
mdiChevronRight,
mdiDotsVertical,
mdiDownload,
mdiPageFirst,
mdiTrashCanOutline,
} from '@mdi/js';
import { mdiDotsVertical, mdiDownload, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
@ -41,18 +35,18 @@
let { data }: Props = $props();
let deleting = new SvelteSet();
let page = $state(1);
let integrityReport = $state(data.integrityReport);
async function loadPage(target: number) {
integrityReport = await getIntegrityReport({
async function loadMore() {
const { items, nextCursor } = await getIntegrityReport({
integrityGetReportDto: {
type: data.type,
page: target,
cursor: integrityReport.nextCursor,
},
});
page = target;
integrityReport.items.push(...items);
integrityReport.nextCursor = nextCursor;
}
async function removeAll() {
@ -108,7 +102,7 @@
}
function download(reportId: string) {
location.href = `${getBaseUrl()}/admin/maintenance/integrity/report/${reportId}/file`;
location.href = `${getBaseUrl()}/admin/integrity/report/${reportId}/file`;
}
const handleOpen = async (event: Event, props: Partial<ContextMenuBaseProps>, reportId: string) => {
@ -150,7 +144,11 @@
if (jobs.integrityCheck.queueStatus.isActive) {
expectingUpdate = true;
} else if (expectingUpdate) {
await loadPage(page);
integrityReport = await getIntegrityReport({
integrityGetReportDto: {
type: data.type,
},
});
expectingUpdate = false;
}
@ -195,9 +193,7 @@
<th class="w-1/8"></th>
</tr>
</thead>
<tbody
class="block max-h-80 w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
<tbody class="block w-full rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg">
{#each integrityReport.items as { id, path } (id)}
<tr
class={`flex py-1 w-full place-items-center even:bg-subtle/20 odd:bg-subtle/80 ${deleting.has(id) || deleting.has('all') ? 'text-gray-500' : ''}`}
@ -216,31 +212,13 @@
</tr>
{/each}
</tbody>
<tfoot>
<HStack class="mt-4 items-center justify-end">
<IconButton
disabled={page === 1}
color="primary"
icon={mdiPageFirst}
aria-label={$t('first_page')}
onclick={() => loadPage(1)}
/>
<IconButton
disabled={page === 1}
color="primary"
icon={mdiChevronLeft}
aria-label={$t('previous_page')}
onclick={() => loadPage(page - 1)}
/>
<IconButton
disabled={!integrityReport.hasNextPage}
color="primary"
icon={mdiChevronRight}
aria-label={$t('next_page')}
onclick={() => loadPage(page + 1)}
/>
</HStack>
</tfoot>
{#if integrityReport.nextCursor}
<tfoot>
<HStack class="mt-4 items-center justify-center">
<Button color="primary" onclick={() => loadMore()}>Load More</Button>
</HStack>
</tfoot>
{/if}
</table>
</section>
</section>