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 3ba9327ec..5b90e4a63 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e77166482..4de0614cf 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index 3228d31e9..d54f52064 100644 Binary files a/mobile/openapi/lib/api/sessions_api.dart and b/mobile/openapi/lib/api/sessions_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 8bdda3a32..26113a111 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart index ab1c4ca2d..a4f93e8d9 100644 Binary files a/mobile/openapi/lib/model/session_create_response_dto.dart and b/mobile/openapi/lib/model/session_create_response_dto.dart differ diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index cf9eb08a7..e76e4d48b 100644 Binary files a/mobile/openapi/lib/model/session_response_dto.dart and b/mobile/openapi/lib/model/session_response_dto.dart differ diff --git a/mobile/openapi/lib/model/session_update_dto.dart b/mobile/openapi/lib/model/session_update_dto.dart new file mode 100644 index 000000000..cd170b1ba Binary files /dev/null and b/mobile/openapi/lib/model/session_update_dto.dart differ diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 60cc4da6c..1d09a6dbe 100644 Binary files a/mobile/openapi/lib/model/sync_entity_type.dart and b/mobile/openapi/lib/model/sync_entity_type.dart differ diff --git a/mobile/openapi/lib/model/sync_stream_dto.dart b/mobile/openapi/lib/model/sync_stream_dto.dart index 28fd3dfae..9884eef34 100644 Binary files a/mobile/openapi/lib/model/sync_stream_dto.dart and b/mobile/openapi/lib/model/sync_stream_dto.dart differ 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, });