refactor: use cursors instead of pages

This commit is contained in:
izzy
2026-01-06 15:49:36 +00:00
parent 06ee275202
commit d189722bbf
12 changed files with 100 additions and 115 deletions
+17 -18
View File
@@ -13,19 +13,18 @@ part of openapi.api;
class IntegrityGetReportDto { class IntegrityGetReportDto {
/// Returns a new [IntegrityGetReportDto] instance. /// Returns a new [IntegrityGetReportDto] instance.
IntegrityGetReportDto({ IntegrityGetReportDto({
this.page, this.cursor,
this.size, this.limit,
required this.type, required this.type,
}); });
/// Minimum value: 1
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? page; DateTime? cursor;
/// Minimum value: 1 /// Minimum value: 1
/// ///
@@ -34,37 +33,37 @@ class IntegrityGetReportDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? size; num? limit;
IntegrityReportType type; IntegrityReportType type;
@override @override
bool operator ==(Object other) => identical(this, other) || other is IntegrityGetReportDto && bool operator ==(Object other) => identical(this, other) || other is IntegrityGetReportDto &&
other.page == page && other.cursor == cursor &&
other.size == size && other.limit == limit &&
other.type == type; other.type == type;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(page == null ? 0 : page!.hashCode) + (cursor == null ? 0 : cursor!.hashCode) +
(size == null ? 0 : size!.hashCode) + (limit == null ? 0 : limit!.hashCode) +
(type.hashCode); (type.hashCode);
@override @override
String toString() => 'IntegrityGetReportDto[page=$page, size=$size, type=$type]'; String toString() => 'IntegrityGetReportDto[cursor=$cursor, limit=$limit, type=$type]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (this.page != null) { if (this.cursor != null) {
json[r'page'] = this.page; json[r'cursor'] = this.cursor!.toUtc().toIso8601String();
} else { } else {
// json[r'page'] = null; // json[r'cursor'] = null;
} }
if (this.size != null) { if (this.limit != null) {
json[r'size'] = this.size; json[r'limit'] = this.limit;
} else { } else {
// json[r'size'] = null; // json[r'limit'] = null;
} }
json[r'type'] = this.type; json[r'type'] = this.type;
return json; return json;
@@ -79,8 +78,8 @@ class IntegrityGetReportDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return IntegrityGetReportDto( return IntegrityGetReportDto(
page: num.parse('${json[r'page']}'), cursor: mapDateTime(json, r'cursor', r''),
size: num.parse('${json[r'size']}'), limit: num.parse('${json[r'limit']}'),
type: IntegrityReportType.fromJson(json[r'type'])!, type: IntegrityReportType.fromJson(json[r'type'])!,
); );
} }
+20 -11
View File
@@ -13,32 +13,42 @@ part of openapi.api;
class IntegrityReportResponseDto { class IntegrityReportResponseDto {
/// Returns a new [IntegrityReportResponseDto] instance. /// Returns a new [IntegrityReportResponseDto] instance.
IntegrityReportResponseDto({ IntegrityReportResponseDto({
required this.hasNextPage,
this.items = const [], this.items = const [],
this.nextCursor,
}); });
bool hasNextPage;
List<IntegrityReportDto> items; 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 @override
bool operator ==(Object other) => identical(this, other) || other is IntegrityReportResponseDto && 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 @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(hasNextPage.hashCode) + (items.hashCode) +
(items.hashCode); (nextCursor == null ? 0 : nextCursor!.hashCode);
@override @override
String toString() => 'IntegrityReportResponseDto[hasNextPage=$hasNextPage, items=$items]'; String toString() => 'IntegrityReportResponseDto[items=$items, nextCursor=$nextCursor]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'hasNextPage'] = this.hasNextPage;
json[r'items'] = this.items; json[r'items'] = this.items;
if (this.nextCursor != null) {
json[r'nextCursor'] = this.nextCursor!.toUtc().toIso8601String();
} else {
// json[r'nextCursor'] = null;
}
return json; return json;
} }
@@ -51,8 +61,8 @@ class IntegrityReportResponseDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return IntegrityReportResponseDto( return IntegrityReportResponseDto(
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage')!,
items: IntegrityReportDto.listFromJson(json[r'items']), items: IntegrityReportDto.listFromJson(json[r'items']),
nextCursor: mapDateTime(json, r'nextCursor', r''),
); );
} }
return null; return null;
@@ -100,7 +110,6 @@ class IntegrityReportResponseDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'hasNextPage',
'items', 'items',
}; };
} }
+7 -8
View File
@@ -16928,11 +16928,11 @@
}, },
"IntegrityGetReportDto": { "IntegrityGetReportDto": {
"properties": { "properties": {
"page": { "cursor": {
"minimum": 1, "format": "uuid",
"type": "number" "type": "string"
}, },
"size": { "limit": {
"minimum": 1, "minimum": 1,
"type": "number" "type": "number"
}, },
@@ -16974,18 +16974,17 @@
}, },
"IntegrityReportResponseDto": { "IntegrityReportResponseDto": {
"properties": { "properties": {
"hasNextPage": {
"type": "boolean"
},
"items": { "items": {
"items": { "items": {
"$ref": "#/components/schemas/IntegrityReportDto" "$ref": "#/components/schemas/IntegrityReportDto"
}, },
"type": "array" "type": "array"
},
"nextCursor": {
"type": "string"
} }
}, },
"required": [ "required": [
"hasNextPage",
"items" "items"
], ],
"type": "object" "type": "object"
+3 -3
View File
@@ -41,8 +41,8 @@ export type ActivityStatisticsResponseDto = {
likes: number; likes: number;
}; };
export type IntegrityGetReportDto = { export type IntegrityGetReportDto = {
page?: number; cursor?: string;
size?: number; limit?: number;
"type": IntegrityReportType; "type": IntegrityReportType;
}; };
export type IntegrityReportDto = { export type IntegrityReportDto = {
@@ -51,8 +51,8 @@ export type IntegrityReportDto = {
"type": IntegrityReportType; "type": IntegrityReportType;
}; };
export type IntegrityReportResponseDto = { export type IntegrityReportResponseDto = {
hasNextPage: boolean;
items: IntegrityReportDto[]; items: IntegrityReportDto[];
nextCursor?: string;
}; };
export type IntegrityReportSummaryResponseDto = { export type IntegrityReportSummaryResponseDto = {
checksum_mismatch: number; checksum_mismatch: number;
+8
View File
@@ -130,6 +130,14 @@ const create = (path: string, up: string[], down: string[]) => {
const compare = async () => { const compare = async () => {
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv(); 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 db = postgres(asPostgresConnectionConfig(database.config));
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' }); const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
+5 -7
View File
@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; 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 { IntegrityReportType } from 'src/enum';
import { ValidateEnum } from 'src/validation'; import { ValidateEnum } from 'src/validation';
@@ -17,17 +17,15 @@ export class IntegrityGetReportDto {
@ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' }) @ValidateEnum({ enum: IntegrityReportType, name: 'IntegrityReportType' })
type!: IntegrityReportType; type!: IntegrityReportType;
@IsInt()
@Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @IsUUID()
page?: number; cursor?: string;
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
size?: number; limit?: number;
} }
export class IntegrityDeleteReportDto { export class IntegrityDeleteReportDto {
@@ -44,5 +42,5 @@ class IntegrityReportDto {
export class IntegrityReportResponseDto { export class IntegrityReportResponseDto {
items!: IntegrityReportDto[]; items!: IntegrityReportDto[];
hasNextPage!: boolean; nextCursor?: string;
} }
+3 -3
View File
@@ -31,16 +31,16 @@ select
"type", "type",
"path", "path",
"assetId", "assetId",
"fileAssetId" "fileAssetId",
"createdAt"
from from
"integrity_report" "integrity_report"
where where
"type" = $1 "type" = $1
and "createdAt" <= $2
order by order by
"createdAt" desc "createdAt" desc
limit limit
$2
offset
$3 $3
-- IntegrityRepository.getAssetPathsByPaths -- IntegrityRepository.getAssetPathsByPaths
@@ -5,11 +5,10 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { IntegrityReportType } from 'src/enum'; import { IntegrityReportType } from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table'; import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
import { paginationHelper } from 'src/utils/pagination';
export interface ReportPaginationOptions { export interface ReportPaginationOptions {
page: number; cursor?: string;
size: number; limit: number;
} }
@Injectable() @Injectable()
@@ -64,18 +63,21 @@ export class IntegrityRepository {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [{ page: 1, size: 100 }, DummyValue.STRING] }) @GenerateSql({ params: [{ cursor: DummyValue.NUMBER, limit: 100 }, DummyValue.STRING] })
async getIntegrityReports(pagination: ReportPaginationOptions, type: IntegrityReportType) { async getIntegrityReports(pagination: ReportPaginationOptions, type: IntegrityReportType) {
const items = await this.db const items = await this.db
.selectFrom('integrity_report') .selectFrom('integrity_report')
.select(['id', 'type', 'path', 'assetId', 'fileAssetId']) .select(['id', 'type', 'path', 'assetId', 'fileAssetId', 'createdAt'])
.where('type', '=', type) .where('type', '=', type)
.orderBy('createdAt', 'desc') .$if(pagination.cursor !== undefined, (eb) => eb.where('id', '<=', pagination.cursor!))
.limit(pagination.size + 1) .orderBy('id', 'desc')
.offset((pagination.page - 1) * pagination.size) .limit(pagination.limit + 1)
.execute(); .execute();
return paginationHelper(items, pagination.size); return {
items: items.slice(0, pagination.limit),
nextCursor: items[pagination.limit]?.id,
};
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
@@ -2,7 +2,7 @@ import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "integrity_report" ( 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, "type" character varying NOT NULL,
"path" character varying NOT NULL, "path" character varying NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(),
@@ -1,21 +1,13 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { IntegrityReportType } from 'src/enum'; import { IntegrityReportType } from 'src/enum';
import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp, Unique } from 'src/sql-tools';
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
Unique,
} from 'src/sql-tools';
@Table('integrity_report') @Table('integrity_report')
@Unique({ columns: ['type', 'path'] }) @Unique({ columns: ['type', 'path'] })
export class IntegrityReportTable { export class IntegrityReportTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedUuidV7Column()
id!: Generated<string>; id!: Generated<string>;
@Column() @Column()
+1 -1
View File
@@ -140,7 +140,7 @@ export class IntegrityService extends BaseService {
} }
async getIntegrityReport(dto: IntegrityGetReportDto): Promise<IntegrityReportResponseDto> { 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 { getIntegrityReportCsv(type: IntegrityReportType): Readable {
@@ -13,6 +13,7 @@
ManualJobName, ManualJobName,
} from '@immich/sdk'; } from '@immich/sdk';
import { import {
Button,
HStack, HStack,
IconButton, IconButton,
menuManager, menuManager,
@@ -21,14 +22,7 @@
type ContextMenuBaseProps, type ContextMenuBaseProps,
type MenuItems, type MenuItems,
} from '@immich/ui'; } from '@immich/ui';
import { import { mdiDotsVertical, mdiDownload, mdiTrashCanOutline } from '@mdi/js';
mdiChevronLeft,
mdiChevronRight,
mdiDotsVertical,
mdiDownload,
mdiPageFirst,
mdiTrashCanOutline,
} from '@mdi/js';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
@@ -41,18 +35,18 @@
let { data }: Props = $props(); let { data }: Props = $props();
let deleting = new SvelteSet(); let deleting = new SvelteSet();
let page = $state(1);
let integrityReport = $state(data.integrityReport); let integrityReport = $state(data.integrityReport);
async function loadPage(target: number) { async function loadMore() {
integrityReport = await getIntegrityReport({ const { items, nextCursor } = await getIntegrityReport({
integrityGetReportDto: { integrityGetReportDto: {
type: data.type, type: data.type,
page: target, cursor: integrityReport.nextCursor,
}, },
}); });
page = target; integrityReport.items.push(...items);
integrityReport.nextCursor = nextCursor;
} }
async function removeAll() { async function removeAll() {
@@ -108,7 +102,7 @@
} }
function download(reportId: string) { 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) => { const handleOpen = async (event: Event, props: Partial<ContextMenuBaseProps>, reportId: string) => {
@@ -150,7 +144,11 @@
if (jobs.integrityCheck.queueStatus.isActive) { if (jobs.integrityCheck.queueStatus.isActive) {
expectingUpdate = true; expectingUpdate = true;
} else if (expectingUpdate) { } else if (expectingUpdate) {
await loadPage(page); integrityReport = await getIntegrityReport({
integrityGetReportDto: {
type: data.type,
},
});
expectingUpdate = false; expectingUpdate = false;
} }
@@ -195,9 +193,7 @@
<th class="w-1/8"></th> <th class="w-1/8"></th>
</tr> </tr>
</thead> </thead>
<tbody <tbody class="block w-full rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg">
class="block max-h-80 w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
{#each integrityReport.items as { id, path } (id)} {#each integrityReport.items as { id, path } (id)}
<tr <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' : ''}`} 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> </tr>
{/each} {/each}
</tbody> </tbody>
<tfoot> {#if integrityReport.nextCursor}
<HStack class="mt-4 items-center justify-end"> <tfoot>
<IconButton <HStack class="mt-4 items-center justify-center">
disabled={page === 1} <Button color="primary" onclick={() => loadMore()}>Load More</Button>
color="primary" </HStack>
icon={mdiPageFirst} </tfoot>
aria-label={$t('first_page')} {/if}
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>
</table> </table>
</section> </section>
</section> </section>