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 | 2 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/sessions_api.dart | 52 +++++++++ mobile/openapi/lib/api_client.dart | 2 + .../model/session_create_response_dto.dart | 10 +- .../lib/model/session_response_dto.dart | 10 +- .../openapi/lib/model/session_update_dto.dart | 108 ++++++++++++++++++ .../openapi/lib/model/sync_entity_type.dart | 3 + mobile/openapi/lib/model/sync_stream_dto.dart | 19 ++- 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, 499 insertions(+), 27 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 bb6d17a248..b14aedf895 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 3ba9327ec4..5b90e4a633 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -208,6 +208,7 @@ Class | Method | HTTP request | Description *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | *SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock | +*SessionsApi* | [**updateSession**](doc//SessionsApi.md#updatesession) | **PUT** /sessions/{id} | *SharedLinksApi* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets | *SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links | *SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links | @@ -449,6 +450,7 @@ Class | Method | HTTP request | Description - [SessionCreateResponseDto](doc//SessionCreateResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md) - [SessionUnlockDto](doc//SessionUnlockDto.md) + - [SessionUpdateDto](doc//SessionUpdateDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) - [SharedLinkEditDto](doc//SharedLinkEditDto.md) - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e771664827..4de0614cf6 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -232,6 +232,7 @@ part 'model/session_create_dto.dart'; part 'model/session_create_response_dto.dart'; part 'model/session_response_dto.dart'; part 'model/session_unlock_dto.dart'; +part 'model/session_update_dto.dart'; part 'model/shared_link_create_dto.dart'; part 'model/shared_link_edit_dto.dart'; part 'model/shared_link_response_dto.dart'; diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index 3228d31e91..d54f520641 100644 --- a/mobile/openapi/lib/api/sessions_api.dart +++ b/mobile/openapi/lib/api/sessions_api.dart @@ -219,4 +219,56 @@ class SessionsApi { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } } + + /// Performs an HTTP 'PUT /sessions/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [SessionUpdateDto] sessionUpdateDto (required): + Future updateSessionWithHttpInfo(String id, SessionUpdateDto sessionUpdateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sessions/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = sessionUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [SessionUpdateDto] sessionUpdateDto (required): + Future updateSession(String id, SessionUpdateDto sessionUpdateDto,) async { + final response = await updateSessionWithHttpInfo(id, sessionUpdateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SessionResponseDto',) as SessionResponseDto; + + } + return null; + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 8bdda3a320..26113a1115 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -520,6 +520,8 @@ class ApiClient { return SessionResponseDto.fromJson(value); case 'SessionUnlockDto': return SessionUnlockDto.fromJson(value); + case 'SessionUpdateDto': + return SessionUpdateDto.fromJson(value); case 'SharedLinkCreateDto': return SharedLinkCreateDto.fromJson(value); case 'SharedLinkEditDto': diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart index ab1c4ca2d8..a4f93e8d9c 100644 --- a/mobile/openapi/lib/model/session_create_response_dto.dart +++ b/mobile/openapi/lib/model/session_create_response_dto.dart @@ -19,6 +19,7 @@ class SessionCreateResponseDto { required this.deviceType, this.expiresAt, required this.id, + required this.isPendingSyncReset, required this.token, required this.updatedAt, }); @@ -41,6 +42,8 @@ class SessionCreateResponseDto { String id; + bool isPendingSyncReset; + String token; String updatedAt; @@ -53,6 +56,7 @@ class SessionCreateResponseDto { other.deviceType == deviceType && other.expiresAt == expiresAt && other.id == id && + other.isPendingSyncReset == isPendingSyncReset && other.token == token && other.updatedAt == updatedAt; @@ -65,11 +69,12 @@ class SessionCreateResponseDto { (deviceType.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) + (id.hashCode) + + (isPendingSyncReset.hashCode) + (token.hashCode) + (updatedAt.hashCode); @override - String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, token=$token, updatedAt=$updatedAt]'; + String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, token=$token, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -83,6 +88,7 @@ class SessionCreateResponseDto { // json[r'expiresAt'] = null; } json[r'id'] = this.id; + json[r'isPendingSyncReset'] = this.isPendingSyncReset; json[r'token'] = this.token; json[r'updatedAt'] = this.updatedAt; return json; @@ -103,6 +109,7 @@ class SessionCreateResponseDto { deviceType: mapValueOfType(json, r'deviceType')!, expiresAt: mapValueOfType(json, r'expiresAt'), id: mapValueOfType(json, r'id')!, + isPendingSyncReset: mapValueOfType(json, r'isPendingSyncReset')!, token: mapValueOfType(json, r'token')!, updatedAt: mapValueOfType(json, r'updatedAt')!, ); @@ -157,6 +164,7 @@ class SessionCreateResponseDto { 'deviceOS', 'deviceType', 'id', + 'isPendingSyncReset', 'token', 'updatedAt', }; diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index cf9eb08a78..e76e4d48b4 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -19,6 +19,7 @@ class SessionResponseDto { required this.deviceType, this.expiresAt, required this.id, + required this.isPendingSyncReset, required this.updatedAt, }); @@ -40,6 +41,8 @@ class SessionResponseDto { String id; + bool isPendingSyncReset; + String updatedAt; @override @@ -50,6 +53,7 @@ class SessionResponseDto { other.deviceType == deviceType && other.expiresAt == expiresAt && other.id == id && + other.isPendingSyncReset == isPendingSyncReset && other.updatedAt == updatedAt; @override @@ -61,10 +65,11 @@ class SessionResponseDto { (deviceType.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) + (id.hashCode) + + (isPendingSyncReset.hashCode) + (updatedAt.hashCode); @override - String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, updatedAt=$updatedAt]'; + String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, isPendingSyncReset=$isPendingSyncReset, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -78,6 +83,7 @@ class SessionResponseDto { // json[r'expiresAt'] = null; } json[r'id'] = this.id; + json[r'isPendingSyncReset'] = this.isPendingSyncReset; json[r'updatedAt'] = this.updatedAt; return json; } @@ -97,6 +103,7 @@ class SessionResponseDto { deviceType: mapValueOfType(json, r'deviceType')!, expiresAt: mapValueOfType(json, r'expiresAt'), id: mapValueOfType(json, r'id')!, + isPendingSyncReset: mapValueOfType(json, r'isPendingSyncReset')!, updatedAt: mapValueOfType(json, r'updatedAt')!, ); } @@ -150,6 +157,7 @@ class SessionResponseDto { 'deviceOS', 'deviceType', 'id', + 'isPendingSyncReset', 'updatedAt', }; } 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 0000000000..cd170b1baa --- /dev/null +++ b/mobile/openapi/lib/model/session_update_dto.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SessionUpdateDto { + /// Returns a new [SessionUpdateDto] instance. + SessionUpdateDto({ + this.isPendingSyncReset, + }); + + /// + /// 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. + /// + bool? isPendingSyncReset; + + @override + bool operator ==(Object other) => identical(this, other) || other is SessionUpdateDto && + other.isPendingSyncReset == isPendingSyncReset; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (isPendingSyncReset == null ? 0 : isPendingSyncReset!.hashCode); + + @override + String toString() => 'SessionUpdateDto[isPendingSyncReset=$isPendingSyncReset]'; + + Map toJson() { + final json = {}; + if (this.isPendingSyncReset != null) { + json[r'isPendingSyncReset'] = this.isPendingSyncReset; + } else { + // json[r'isPendingSyncReset'] = null; + } + return json; + } + + /// Returns a new [SessionUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SessionUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "SessionUpdateDto"); + if (value is Map) { + final json = value.cast(); + + return SessionUpdateDto( + isPendingSyncReset: mapValueOfType(json, r'isPendingSyncReset'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SessionUpdateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SessionUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SessionUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SessionUpdateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 60cc4da6cb..1d09a6dbe0 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -59,6 +59,7 @@ class SyncEntityType { static const personV1 = SyncEntityType._(r'PersonV1'); static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1'); static const syncAckV1 = SyncEntityType._(r'SyncAckV1'); + static const syncResetV1 = SyncEntityType._(r'SyncResetV1'); /// List of all possible values in this [enum][SyncEntityType]. static const values = [ @@ -98,6 +99,7 @@ class SyncEntityType { personV1, personDeleteV1, syncAckV1, + syncResetV1, ]; static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); @@ -172,6 +174,7 @@ class SyncEntityTypeTypeTransformer { case r'PersonV1': return SyncEntityType.personV1; case r'PersonDeleteV1': return SyncEntityType.personDeleteV1; case r'SyncAckV1': return SyncEntityType.syncAckV1; + case r'SyncResetV1': return SyncEntityType.syncResetV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/sync_stream_dto.dart b/mobile/openapi/lib/model/sync_stream_dto.dart index 28fd3dfaee..9884eef342 100644 --- a/mobile/openapi/lib/model/sync_stream_dto.dart +++ b/mobile/openapi/lib/model/sync_stream_dto.dart @@ -13,25 +13,41 @@ part of openapi.api; class SyncStreamDto { /// Returns a new [SyncStreamDto] instance. SyncStreamDto({ + this.reset, this.types = const [], }); + /// + /// 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. + /// + bool? reset; + List types; @override bool operator ==(Object other) => identical(this, other) || other is SyncStreamDto && + other.reset == reset && _deepEquality.equals(other.types, types); @override int get hashCode => // ignore: unnecessary_parenthesis + (reset == null ? 0 : reset!.hashCode) + (types.hashCode); @override - String toString() => 'SyncStreamDto[types=$types]'; + String toString() => 'SyncStreamDto[reset=$reset, types=$types]'; Map toJson() { final json = {}; + if (this.reset != null) { + json[r'reset'] = this.reset; + } else { + // json[r'reset'] = null; + } json[r'types'] = this.types; return json; } @@ -45,6 +61,7 @@ class SyncStreamDto { final json = value.cast(); return SyncStreamDto( + reset: mapValueOfType(json, r'reset'), types: SyncRequestType.listFromJson(json[r'types']), ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fbd7165dd5..2e6c70adaa 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 c869510b6f..f7fc9fe61e 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 3838d5af80..f5eb10b3dd 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 acd6980985..b9af37cc46 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 f15166fbf5..0babbb9182 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 53b0041462..ff5df03eaf 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 81af35cf0c..6d960e1fbb 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 6a9b69c2e3..315ea82112 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 b9fa5458e4..a3d8281db5 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 0000000000..6264831181 --- /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 cc05500beb..3305bcbc0b 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 85c9f07815..6f180b3017 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 ec3415ec8c..2d6c4b1b15 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 059ff00e16..198e380c53 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 5e9c679d7f..2cec72a3b5 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 44182602cd..c7f2e017a1 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 0000000000..4cfdc8249e --- /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 79d6d511a3..7b0ebeb86b 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, });