From 4b3a4725c67c96977be0b2227aab5f6530fbcd11 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 11 Jul 2025 09:38:02 -0400 Subject: [PATCH] feat: pending sync reset flag (#19861) --- e2e/src/responses.ts | 1 + mobile/openapi/README.md | Bin 38265 -> 38412 bytes mobile/openapi/lib/api.dart | Bin 13729 -> 13767 bytes mobile/openapi/lib/api/sessions_api.dart | Bin 6586 -> 8383 bytes mobile/openapi/lib/api_client.dart | Bin 34455 -> 34539 bytes .../model/session_create_response_dto.dart | Bin 5109 -> 5472 bytes .../lib/model/session_response_dto.dart | Bin 4784 -> 5147 bytes .../openapi/lib/model/session_update_dto.dart | Bin 0 -> 3426 bytes .../openapi/lib/model/sync_entity_type.dart | Bin 8280 -> 8424 bytes mobile/openapi/lib/model/sync_stream_dto.dart | Bin 2805 -> 3440 bytes open-api/immich-openapi-specs.json | 76 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 22 ++++- server/src/controllers/session.controller.ts | 14 +++- server/src/database.ts | 4 +- server/src/dtos/session.dto.ts | 9 ++- server/src/dtos/sync.dto.ts | 9 ++- server/src/enum.ts | 1 + server/src/queries/session.repository.sql | 13 +++ server/src/repositories/session.repository.ts | 10 +++ .../1752169992364-AddIsPendingSyncReset.ts | 9 +++ server/src/schema/tables/session.table.ts | 3 + server/src/services/auth.service.spec.ts | 16 +++- server/src/services/auth.service.ts | 1 + server/src/services/session.service.ts | 22 ++++- server/src/services/sync.service.ts | 34 +++++--- server/test/medium.factory.ts | 5 +- .../test/medium/specs/sync/sync-reset.spec.ts | 63 +++++++++++++++ server/test/small.factory.ts | 7 +- 28 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 mobile/openapi/lib/model/session_update_dto.dart create mode 100644 server/src/schema/migrations/1752169992364-AddIsPendingSyncReset.ts create mode 100644 server/test/medium/specs/sync/sync-reset.spec.ts diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index bb6d17a24..b14aedf89 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -116,6 +116,7 @@ export const deviceDto = { createdAt: expect.any(String), updatedAt: expect.any(String), current: true, + isPendingSyncReset: false, deviceOS: '', deviceType: '', }, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3ba9327ec494d5a98c065bbfe7e8989ca34cac0b..5b90e4a633cd25377129363ce4b669266c710557 100644 GIT binary patch delta 106 zcmeylim7J}(*_;;$?SH5ETsi0iIZjQ6cs^?lGNhV;^NHwJWYif1ud1q~s^->jxtXZ)U13mjD3qY$l5U delta 23 fcmeBK!}N0%(*_;;$(I~OHZQh6Z@YO>ZI%Q8g2D=_ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e771664827f73d6377e4ebdb271cf51fc996cab2..4de0614cf65bacd9e19b95715687603b777bc7d2 100644 GIT binary patch delta 25 hcmZ3OeLQ* zeD*Cr@6$XF-gm z{!lxDQi}`n^NK;Pv$sI$L9d36o%EaX@NbN@iYqaAjU{P-=1N4c=!^h7j2^~-9Y;r-TljDyc z{@FtqN50L4^`k!qzrXD9rJ8GFWjZr9ovA_&snV5`c`6IJl-eiFwOW?O`kgC8u@Y;S zmX%Mf{8=fBAU!||Cb|gs4 z6-~F}E=h&2$kX|eJP}HlC<#v z`vME8v}oA+HMxbUikueWTGEx28bSKGu8Klj6>^WxmCht;Nphvk08}PCwnWTZojf=t6a`RRd~7+)pwaeGXT{d(r8RkgPAD$TqkLs z{Q4FDLy?XK=-D%*rWmk81*r3s-jwzf>WdQKMcRhy+}j`&6aVQ4bPl%lKkZf>{novAc@U^L73J%g zE}?u4i%{7Zynb>O|k%z6!flGYSOlf_pFSx=BbO+0-6%+d8;J zjR$5o*108FU|3^oVB(QnjCQ>+v}uF4ghhP&wswN2dkzVmQ?y=3%DXtlj%L;@k&x$V z8U`CSHABDw``8pXUnY_IpzM`$^|aJ>Kg7h#84NUdudOnSgG+f(QdqH9Sqj%)n4F~* zZ*)}uCiD=ftEr5=HLzFZ+=`r?wjUZyXn$Kg`%oPZ4wD((3Dbq%n-m0RYl$I*3^L(- zM;864Urts-zFn&yDurqtn;z0qlz+25PG%Qytk>)(<34LJp`E%pWM}7}dIz`RvM$|- zAh2Dd_gQh*Td$xo6}bHYRNjLz*1h2-Xl~7l%^y02)jFBnHsC~PtaR+{JSp4BrH|C{x&l^f2U89aF1}x058#G>n!#Leh z+?h^(9JevRn;{CJwK77>v%`V+fUZ}0!`tdNPz^Vrg)D6r+WynQi$$=Li)Qx`7DTm; zsgtJdPz~&;`=6+XxKU)~A$?=k?}oQIR;4)E-~h)5wfGje#~|+7b;IF+uatDnZWztD zqsO@ni3TCA#mpm|0;p+R_(QJQXv6xb$vY|=O79Vir7gQ;V%XLKN+!OrSIRGNi|2yr^wM(%>$+Wk9M9Y zi>FAe_OzApscbObnQ!4RkFbC7Ut58`xx{t{u~kQY}#&;pi+k{5M}a2vT!+ wqYKc?$I~mxFFJ>^JO+Y3olnmA*jTe4-hMEj_?_i1Ub{JSjBgN!(+2$GUu!~Sxc~qF literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 60cc4da6cb05b4581b497b55d17040eb52f5dff2..1d09a6dbe02f15ec636eeaf2e3b3fd64e5e21b25 100644 GIT binary patch delta 83 zcmccN@WOF}D9>a`ZUNTHyyT$N$p_h`IfKE>;?$DO{5(m_+zJZCaDi<82mF(D#n{;( QY9=#^%W)!P?d2sI0iL-VO#lD@ delta 22 ecmaFic*9|XD9`2)o*3rM2l;RDZ@w+h%Lo8%lnAT< diff --git a/mobile/openapi/lib/model/sync_stream_dto.dart b/mobile/openapi/lib/model/sync_stream_dto.dart index 28fd3dfaeed9601174ed0e971ec9bb94ed8681ab..9884eef342a72e2a047ccb2416f42c4938a2a6c9 100644 GIT binary patch delta 676 zcmZ{iy>8S%6os`5N|AyJB%nGQ0ofI1mwFSEA_Y=}6htB#lw#Jtjt3?)W`2xT2yJ)< z)8_$bkn%k63^erI8E+yXU0FJY3CYvrT}Xc&SJ@ zf;OJ!IIg8bV@*TWFX3tvREb)&gawgik$$3sQevTKgd?d7g0Bg#p+Z)&5MCOMN-A2L zmxf%l21`{a2?8ZmBCM20D^wtigOmC+xooAD^7E~0gh__le4v(WmL#X7$qG;9+TWRA znV~RjeHNTYC6vNK6z||o1drYVSIqFshNe6l_s71`PL^aL%Cgt}gMi8ZvtoJ!&vTnY zi}^+8KG=mZY7Z&zolUO3kk0QOEp_pV-Uo8N`#NZE8;;ISlL&d zih181O>5!mecpD0$K99V!L20CAEYZo_hWdnk`=}gY3)49)A|#9WR7^=I-}p%?KHA| zf;{o};+-V<1XBD*^m@+e?}^B@*8XYA`CdN1aWTk}`y6MYc^yCVVRg*O@5Wf3jUzyY ND}qUO`TgeIjoJV&*Cz9BTMY&M%LAgn-kgj*#H=e4R!zk diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fbd7165dd..2e6c70ada 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6024,6 +6024,56 @@ "tags": [ "Sessions" ] + }, + "put": { + "operationId": "updateSession", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] } }, "/sessions/{id}/lock": { @@ -12790,6 +12840,9 @@ "id": { "type": "string" }, + "isPendingSyncReset": { + "type": "boolean" + }, "token": { "type": "string" }, @@ -12803,6 +12856,7 @@ "deviceOS", "deviceType", "id", + "isPendingSyncReset", "token", "updatedAt" ], @@ -12828,6 +12882,9 @@ "id": { "type": "string" }, + "isPendingSyncReset": { + "type": "boolean" + }, "updatedAt": { "type": "string" } @@ -12838,6 +12895,7 @@ "deviceOS", "deviceType", "id", + "isPendingSyncReset", "updatedAt" ], "type": "object" @@ -12854,6 +12912,14 @@ }, "type": "object" }, + "SessionUpdateDto": { + "properties": { + "isPendingSyncReset": { + "type": "boolean" + } + }, + "type": "object" + }, "SharedLinkCreateDto": { "properties": { "albumId": { @@ -13836,7 +13902,8 @@ "StackDeleteV1", "PersonV1", "PersonDeleteV1", - "SyncAckV1" + "SyncAckV1", + "SyncResetV1" ], "type": "string" }, @@ -14074,6 +14141,10 @@ ], "type": "string" }, + "SyncResetV1": { + "properties": {}, + "type": "object" + }, "SyncStackDeleteV1": { "properties": { "stackId": { @@ -14116,6 +14187,9 @@ }, "SyncStreamDto": { "properties": { + "reset": { + "type": "boolean" + }, "types": { "items": { "$ref": "#/components/schemas/SyncRequestType" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c869510b6..f7fc9fe61 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1164,6 +1164,7 @@ export type SessionResponseDto = { deviceType: string; expiresAt?: string; id: string; + isPendingSyncReset: boolean; updatedAt: string; }; export type SessionCreateDto = { @@ -1179,9 +1180,13 @@ export type SessionCreateResponseDto = { deviceType: string; expiresAt?: string; id: string; + isPendingSyncReset: boolean; token: string; updatedAt: string; }; +export type SessionUpdateDto = { + isPendingSyncReset?: boolean; +}; export type SharedLinkResponseDto = { album?: AlbumResponseDto; allowDownload: boolean; @@ -1264,6 +1269,7 @@ export type AssetFullSyncDto = { userId?: string; }; export type SyncStreamDto = { + reset?: boolean; types: SyncRequestType[]; }; export type DatabaseBackupConfig = { @@ -3170,6 +3176,19 @@ export function deleteSession({ id }: { method: "DELETE" })); } +export function updateSession({ id, sessionUpdateDto }: { + id: string; + sessionUpdateDto: SessionUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SessionResponseDto; + }>(`/sessions/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: sessionUpdateDto + }))); +} export function lockSession({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -4097,7 +4116,8 @@ export enum SyncEntityType { StackDeleteV1 = "StackDeleteV1", PersonV1 = "PersonV1", PersonDeleteV1 = "PersonDeleteV1", - SyncAckV1 = "SyncAckV1" + SyncAckV1 = "SyncAckV1", + SyncResetV1 = "SyncResetV1" } export enum SyncRequestType { AlbumsV1 = "AlbumsV1", diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index 3838d5af8..f5eb10b3d 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto } from 'src/dtos/session.dto'; +import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, SessionUpdateDto } from 'src/dtos/session.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { SessionService } from 'src/services/session.service'; @@ -31,6 +31,16 @@ export class SessionController { return this.service.deleteAll(auth); } + @Put(':id') + @Authenticated({ permission: Permission.SESSION_UPDATE }) + updateSession( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: SessionUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + @Delete(':id') @Authenticated({ permission: Permission.SESSION_DELETE }) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/server/src/database.ts b/server/src/database.ts index acd698098..b9af37cc4 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -201,6 +201,7 @@ export type Album = Selectable & { export type AuthSession = { id: string; + isPendingSyncReset: boolean; hasElevatedPermission: boolean; }; @@ -238,6 +239,7 @@ export type Session = { deviceOS: string; deviceType: string; pinExpiresAt: Date | null; + isPendingSyncReset: boolean; }; export type Exif = Omit, 'updatedAt' | 'updateId'>; @@ -311,7 +313,7 @@ export const columns = { 'users.quotaSizeInBytes', ], authApiKey: ['api_keys.id', 'api_keys.permissions'], - authSession: ['sessions.id', 'sessions.updatedAt', 'sessions.pinExpiresAt'], + authSession: ['sessions.id', 'sessions.isPendingSyncReset', 'sessions.updatedAt', 'sessions.pinExpiresAt'], authSharedLink: [ 'shared_links.id', 'shared_links.userId', diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index f15166fbf..0babbb918 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -1,6 +1,6 @@ import { IsInt, IsPositive, IsString } from 'class-validator'; import { Session } from 'src/database'; -import { Optional } from 'src/validation'; +import { Optional, ValidateBoolean } from 'src/validation'; export class SessionCreateDto { /** @@ -20,6 +20,11 @@ export class SessionCreateDto { deviceOS?: string; } +export class SessionUpdateDto { + @ValidateBoolean({ optional: true }) + isPendingSyncReset?: boolean; +} + export class SessionResponseDto { id!: string; createdAt!: string; @@ -28,6 +33,7 @@ export class SessionResponseDto { current!: boolean; deviceType!: string; deviceOS!: string; + isPendingSyncReset!: boolean; } export class SessionCreateResponseDto extends SessionResponseDto { @@ -42,4 +48,5 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse current: currentId === entity.id, deviceOS: entity.deviceOS, deviceType: entity.deviceType, + isPendingSyncReset: entity.isPendingSyncReset, }); diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 53b004146..ff5df03ea 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -11,7 +11,7 @@ import { SyncEntityType, SyncRequestType, } from 'src/enum'; -import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class AssetFullSyncDto { @ValidateUUID({ optional: true }) @@ -256,6 +256,9 @@ export class SyncPersonDeleteV1 { @ExtraModel() export class SyncAckV1 {} +@ExtraModel() +export class SyncResetV1 {} + export type SyncItem = { [SyncEntityType.UserV1]: SyncUserV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; @@ -293,12 +296,16 @@ export type SyncItem = { [SyncEntityType.PersonV1]: SyncPersonV1; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; [SyncEntityType.SyncAckV1]: SyncAckV1; + [SyncEntityType.SyncResetV1]: SyncResetV1; }; export class SyncStreamDto { @IsEnum(SyncRequestType, { each: true }) @ApiProperty({ enumName: 'SyncRequestType', enum: SyncRequestType, isArray: true }) types!: SyncRequestType[]; + + @ValidateBoolean({ optional: true }) + reset?: boolean; } export class SyncAckDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index 81af35cf0..6d960e1fb 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -640,6 +640,7 @@ export enum SyncEntityType { PersonDeleteV1 = 'PersonDeleteV1', SyncAckV1 = 'SyncAckV1', + SyncResetV1 = 'SyncResetV1', } export enum NotificationLevel { diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 6a9b69c2e..315ea8211 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -13,6 +13,7 @@ where -- SessionRepository.getByToken select "sessions"."id", + "sessions"."isPendingSyncReset", "sessions"."updatedAt", "sessions"."pinExpiresAt", ( @@ -71,3 +72,15 @@ set "pinExpiresAt" = $1 where "userId" = $2 + +-- SessionRepository.resetSyncProgress +begin +update "sessions" +set + "isPendingSyncReset" = $1 +where + "id" = $2 +delete from "session_sync_checkpoints" +where + "sessionId" = $1 +commit diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index b9fa5458e..a3d8281db 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -95,4 +95,14 @@ export class SessionRepository { async lockAll(userId: string) { await this.db.updateTable('sessions').set({ pinExpiresAt: null }).where('userId', '=', userId).execute(); } + + @GenerateSql({ params: [DummyValue.UUID] }) + async resetSyncProgress(sessionId: string) { + await this.db.transaction().execute((tx) => { + return Promise.all([ + tx.updateTable('sessions').set({ isPendingSyncReset: false }).where('id', '=', sessionId).execute(), + tx.deleteFrom('session_sync_checkpoints').where('sessionId', '=', sessionId).execute(), + ]); + }); + } } diff --git a/server/src/schema/migrations/1752169992364-AddIsPendingSyncReset.ts b/server/src/schema/migrations/1752169992364-AddIsPendingSyncReset.ts new file mode 100644 index 000000000..626483118 --- /dev/null +++ b/server/src/schema/migrations/1752169992364-AddIsPendingSyncReset.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" ADD "isPendingSyncReset" boolean NOT NULL DEFAULT false;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" DROP COLUMN "isPendingSyncReset";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index cc05500be..3305bcbc0 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -45,6 +45,9 @@ export class SessionTable { @UpdateIdColumn({ indexName: 'IDX_sessions_update_id' }) updateId!: Generated; + @Column({ type: 'boolean', default: false }) + isPendingSyncReset!: Generated; + @Column({ type: 'timestamp with time zone', nullable: true }) pinExpiresAt!: Timestamp | null; } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 85c9f0781..6f180b301 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -241,6 +241,7 @@ describe(AuthService.name, () => { const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, + isPendingSyncReset: false, user: factory.authUser(), pinExpiresAt: null, }; @@ -255,7 +256,11 @@ describe(AuthService.name, () => { }), ).resolves.toEqual({ user: sessionWithToken.user, - session: { id: session.id, hasElevatedPermission: false }, + session: { + id: session.id, + hasElevatedPermission: false, + isPendingSyncReset: session.isPendingSyncReset, + }, }); }); }); @@ -366,6 +371,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + isPendingSyncReset: false, pinExpiresAt: null, }; @@ -379,7 +385,11 @@ describe(AuthService.name, () => { }), ).resolves.toEqual({ user: sessionWithToken.user, - session: { id: session.id, hasElevatedPermission: false }, + session: { + id: session.id, + hasElevatedPermission: false, + isPendingSyncReset: session.isPendingSyncReset, + }, }); }); @@ -389,6 +399,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + isPendingSyncReset: false, pinExpiresAt: null, }; @@ -409,6 +420,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + isPendingSyncReset: false, pinExpiresAt: null, }; diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index ec3415ec8..2d6c4b1b1 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -466,6 +466,7 @@ export class AuthService extends BaseService { user: session.user, session: { id: session.id, + isPendingSyncReset: session.isPendingSyncReset, hasElevatedPermission, }, }; diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 059ff00e1..198e380c5 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -2,7 +2,13 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, mapSession } from 'src/dtos/session.dto'; +import { + SessionCreateDto, + SessionCreateResponseDto, + SessionResponseDto, + SessionUpdateDto, + mapSession, +} from 'src/dtos/session.dto'; import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; @@ -44,6 +50,20 @@ export class SessionService extends BaseService { return sessions.map((session) => mapSession(session, auth.session?.id)); } + async update(auth: AuthDto, id: string, dto: SessionUpdateDto): Promise { + await this.requireAccess({ auth, permission: Permission.SESSION_UPDATE, ids: [id] }); + + if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) { + throw new BadRequestException('No fields to update'); + } + + const session = await this.sessionRepository.update(id, { + isPendingSyncReset: dto.isPendingSyncReset, + }); + + return mapSession(session); + } + async delete(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); await this.sessionRepository.delete(id); diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 5e9c679d7..2cec72a3b 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -22,7 +22,7 @@ import { SyncAck } from 'src/types'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { setIsEqual } from 'src/utils/set'; -import { fromAck, serialize, SerializeOptions, toAck } from 'src/utils/sync'; +import { fromAck, mapJsonLine, serialize, SerializeOptions, toAck } from 'src/utils/sync'; type CheckpointMap = Partial>; type AssetLike = Omit & { @@ -118,30 +118,42 @@ export class SyncService extends BaseService { } async stream(auth: AuthDto, response: Writable, dto: SyncStreamDto) { - const sessionId = auth.session?.id; - if (!sessionId) { + const session = auth.session; + if (!session) { return throwSessionRequired(); } - const checkpoints = await this.syncCheckpointRepository.getAll(sessionId); + if (dto.reset) { + await this.sessionRepository.resetSyncProgress(session.id); + session.isPendingSyncReset = false; + } + + if (session.isPendingSyncReset) { + response.write(mapJsonLine({ type: SyncEntityType.SyncResetV1, data: {} })); + response.end(); + return; + } + + const checkpoints = await this.syncCheckpointRepository.getAll(session.id); const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)])); + const handlers: Record Promise> = { [SyncRequestType.UsersV1]: () => this.syncUsersV1(response, checkpointMap), [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(response, checkpointMap, auth), [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(response, checkpointMap, auth), [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(response, checkpointMap, auth), - [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(response, checkpointMap, auth, session.id), [SyncRequestType.PartnerAssetExifsV1]: () => - this.syncPartnerAssetExifsV1(response, checkpointMap, auth, sessionId), + this.syncPartnerAssetExifsV1(response, checkpointMap, auth, session.id), [SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(response, checkpointMap, auth), - [SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId), - [SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(response, checkpointMap, auth, sessionId), - [SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(response, checkpointMap, auth, sessionId), - [SyncRequestType.AlbumAssetExifsV1]: () => this.syncAlbumAssetExifsV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(response, checkpointMap, auth, session.id), + [SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(response, checkpointMap, auth, session.id), + [SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(response, checkpointMap, auth, session.id), + [SyncRequestType.AlbumAssetExifsV1]: () => this.syncAlbumAssetExifsV1(response, checkpointMap, auth, session.id), [SyncRequestType.MemoriesV1]: () => this.syncMemoriesV1(response, checkpointMap, auth), [SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth), [SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth), - [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(response, checkpointMap, auth), }; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 44182602c..c7f2e017a 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -234,11 +234,11 @@ export class SyncTestContext extends MediumTestContext { }); } - async syncStream(auth: AuthDto, types: SyncRequestType[]) { + async syncStream(auth: AuthDto, types: SyncRequestType[], reset?: boolean) { const stream = mediumFactory.syncStream(); // Wait for 2ms to ensure all updates are available and account for setTimeout inaccuracy await wait(2); - await this.sut.stream(auth, stream, { types }); + await this.sut.stream(auth, stream, { types, reset }); return stream.getResponse(); } @@ -481,6 +481,7 @@ const sessionInsert = ({ const defaults: Insertable = { id, userId, + isPendingSyncReset: false, token: sha256(id), }; diff --git a/server/test/medium/specs/sync/sync-reset.spec.ts b/server/test/medium/specs/sync/sync-reset.spec.ts new file mode 100644 index 000000000..4cfdc8249 --- /dev/null +++ b/server/test/medium/specs/sync/sync-reset.spec.ts @@ -0,0 +1,63 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.SyncResetV1, () => { + it('should work', async () => { + const { auth, ctx } = await setup(); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); + expect(response).toEqual([]); + }); + + it('should detect a pending sync reset', async () => { + const { auth, ctx } = await setup(); + + auth.session!.isPendingSyncReset = true; + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); + expect(response).toEqual([{ type: SyncEntityType.SyncResetV1, data: {} }]); + }); + + it('should not send other dtos when a reset is pending', async () => { + const { auth, user, ctx } = await setup(); + + await ctx.newAsset({ ownerId: user.id }); + + await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + + auth.session!.isPendingSyncReset = true; + + await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([ + { type: SyncEntityType.SyncResetV1, data: {} }, + ]); + }); + + it('should allow resetting a pending reset when requesting changes ', async () => { + const { auth, user, ctx } = await setup(); + + await ctx.newAsset({ ownerId: user.id }); + + auth.session!.isPendingSyncReset = true; + + await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1], true)).resolves.toEqual([ + expect.objectContaining({ + type: SyncEntityType.AssetV1, + }), + ]); + }); +}); diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 79d6d511a..7b0ebeb86 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -58,7 +58,11 @@ const authFactory = ({ } if (session) { - auth.session = { id: session.id, hasElevatedPermission: false }; + auth.session = { + id: session.id, + isPendingSyncReset: false, + hasElevatedPermission: false, + }; } if (sharedLink) { @@ -131,6 +135,7 @@ const sessionFactory = (session: Partial = {}) => ({ expiresAt: null, userId: newUuid(), pinExpiresAt: newDate(), + isPendingSyncReset: false, ...session, });