1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

fix(server): stacked assets for full sync, userIds as array for delta sync (#9100)

* fix(server): stacked assets for full sync, userIds as array for delta sync

* refactor(server): sync

* fix getDeltaSync after partner removal

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Fynn Petersen-Frey 2024-04-29 05:24:21 +02:00 committed by GitHub
parent fc2e709ad4
commit 32e7cfea3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 306 additions and 218 deletions

View file

@ -28,12 +28,14 @@ doc/AssetBulkUploadCheckDto.md
doc/AssetBulkUploadCheckItem.md doc/AssetBulkUploadCheckItem.md
doc/AssetBulkUploadCheckResponseDto.md doc/AssetBulkUploadCheckResponseDto.md
doc/AssetBulkUploadCheckResult.md doc/AssetBulkUploadCheckResult.md
doc/AssetDeltaSyncDto.md
doc/AssetDeltaSyncResponseDto.md doc/AssetDeltaSyncResponseDto.md
doc/AssetFaceResponseDto.md doc/AssetFaceResponseDto.md
doc/AssetFaceUpdateDto.md doc/AssetFaceUpdateDto.md
doc/AssetFaceUpdateItem.md doc/AssetFaceUpdateItem.md
doc/AssetFaceWithoutPersonResponseDto.md doc/AssetFaceWithoutPersonResponseDto.md
doc/AssetFileUploadResponseDto.md doc/AssetFileUploadResponseDto.md
doc/AssetFullSyncDto.md
doc/AssetIdsDto.md doc/AssetIdsDto.md
doc/AssetIdsResponseDto.md doc/AssetIdsResponseDto.md
doc/AssetJobName.md doc/AssetJobName.md
@ -265,12 +267,14 @@ lib/model/asset_bulk_upload_check_dto.dart
lib/model/asset_bulk_upload_check_item.dart lib/model/asset_bulk_upload_check_item.dart
lib/model/asset_bulk_upload_check_response_dto.dart lib/model/asset_bulk_upload_check_response_dto.dart
lib/model/asset_bulk_upload_check_result.dart lib/model/asset_bulk_upload_check_result.dart
lib/model/asset_delta_sync_dto.dart
lib/model/asset_delta_sync_response_dto.dart lib/model/asset_delta_sync_response_dto.dart
lib/model/asset_face_response_dto.dart lib/model/asset_face_response_dto.dart
lib/model/asset_face_update_dto.dart lib/model/asset_face_update_dto.dart
lib/model/asset_face_update_item.dart lib/model/asset_face_update_item.dart
lib/model/asset_face_without_person_response_dto.dart lib/model/asset_face_without_person_response_dto.dart
lib/model/asset_file_upload_response_dto.dart lib/model/asset_file_upload_response_dto.dart
lib/model/asset_full_sync_dto.dart
lib/model/asset_ids_dto.dart lib/model/asset_ids_dto.dart
lib/model/asset_ids_response_dto.dart lib/model/asset_ids_response_dto.dart
lib/model/asset_job_name.dart lib/model/asset_job_name.dart
@ -449,12 +453,14 @@ test/asset_bulk_upload_check_dto_test.dart
test/asset_bulk_upload_check_item_test.dart test/asset_bulk_upload_check_item_test.dart
test/asset_bulk_upload_check_response_dto_test.dart test/asset_bulk_upload_check_response_dto_test.dart
test/asset_bulk_upload_check_result_test.dart test/asset_bulk_upload_check_result_test.dart
test/asset_delta_sync_dto_test.dart
test/asset_delta_sync_response_dto_test.dart test/asset_delta_sync_response_dto_test.dart
test/asset_face_response_dto_test.dart test/asset_face_response_dto_test.dart
test/asset_face_update_dto_test.dart test/asset_face_update_dto_test.dart
test/asset_face_update_item_test.dart test/asset_face_update_item_test.dart
test/asset_face_without_person_response_dto_test.dart test/asset_face_without_person_response_dto_test.dart
test/asset_file_upload_response_dto_test.dart test/asset_file_upload_response_dto_test.dart
test/asset_full_sync_dto_test.dart
test/asset_ids_dto_test.dart test/asset_ids_dto_test.dart
test/asset_ids_response_dto_test.dart test/asset_ids_response_dto_test.dart
test/asset_job_name_test.dart test/asset_job_name_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

BIN
mobile/openapi/doc/AssetDeltaSyncDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/AssetFullSyncDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -4958,31 +4958,19 @@
} }
}, },
"/sync/delta-sync": { "/sync/delta-sync": {
"get": { "post": {
"operationId": "getDeltaSync", "operationId": "getDeltaSync",
"parameters": [ "parameters": [],
{ "requestBody": {
"name": "updatedAfter", "content": {
"required": true, "application/json": {
"in": "query",
"schema": { "schema": {
"format": "date-time", "$ref": "#/components/schemas/AssetDeltaSyncDto"
"type": "string" }
} }
}, },
{ "required": true
"name": "userIds", },
"required": true,
"in": "query",
"schema": {
"format": "uuid",
"type": "array",
"items": {
"type": "string"
}
}
}
],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@ -5012,55 +5000,19 @@
} }
}, },
"/sync/full-sync": { "/sync/full-sync": {
"get": { "post": {
"operationId": "getAllForUserFullSync", "operationId": "getFullSyncForUser",
"parameters": [ "parameters": [],
{ "requestBody": {
"name": "lastCreationDate", "content": {
"required": false, "application/json": {
"in": "query",
"schema": { "schema": {
"format": "date-time", "$ref": "#/components/schemas/AssetFullSyncDto"
"type": "string" }
} }
}, },
{ "required": true
"name": "lastId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
}, },
{
"name": "limit",
"required": true,
"in": "query",
"schema": {
"minimum": 1,
"type": "integer"
}
},
{
"name": "updatedUntil",
"required": true,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@ -7023,6 +6975,26 @@
], ],
"type": "object" "type": "object"
}, },
"AssetDeltaSyncDto": {
"properties": {
"updatedAfter": {
"format": "date-time",
"type": "string"
},
"userIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"updatedAfter",
"userIds"
],
"type": "object"
},
"AssetDeltaSyncResponseDto": { "AssetDeltaSyncResponseDto": {
"properties": { "properties": {
"deleted": { "deleted": {
@ -7175,6 +7147,35 @@
], ],
"type": "object" "type": "object"
}, },
"AssetFullSyncDto": {
"properties": {
"lastCreationDate": {
"format": "date-time",
"type": "string"
},
"lastId": {
"format": "uuid",
"type": "string"
},
"limit": {
"minimum": 1,
"type": "integer"
},
"updatedUntil": {
"format": "date-time",
"type": "string"
},
"userId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"limit",
"updatedUntil"
],
"type": "object"
},
"AssetIdsDto": { "AssetIdsDto": {
"properties": { "properties": {
"assetIds": { "assetIds": {

View file

@ -836,11 +836,22 @@ export type AssetIdsResponseDto = {
error?: Error2; error?: Error2;
success: boolean; success: boolean;
}; };
export type AssetDeltaSyncDto = {
updatedAfter: string;
userIds: string[];
};
export type AssetDeltaSyncResponseDto = { export type AssetDeltaSyncResponseDto = {
deleted: string[]; deleted: string[];
needsFullSync: boolean; needsFullSync: boolean;
upserted: AssetResponseDto[]; upserted: AssetResponseDto[];
}; };
export type AssetFullSyncDto = {
lastCreationDate?: string;
lastId?: string;
limit: number;
updatedUntil: string;
userId?: string;
};
export type SystemConfigFFmpegDto = { export type SystemConfigFFmpegDto = {
accel: TranscodeHWAccel; accel: TranscodeHWAccel;
acceptedAudioCodecs: AudioCodec[]; acceptedAudioCodecs: AudioCodec[];
@ -2372,39 +2383,29 @@ export function addSharedLinkAssets({ id, key, assetIdsDto }: {
body: assetIdsDto body: assetIdsDto
}))); })));
} }
export function getDeltaSync({ updatedAfter, userIds }: { export function getDeltaSync({ assetDeltaSyncDto }: {
updatedAfter: string; assetDeltaSyncDto: AssetDeltaSyncDto;
userIds: string[];
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetDeltaSyncResponseDto; data: AssetDeltaSyncResponseDto;
}>(`/sync/delta-sync${QS.query(QS.explode({ }>("/sync/delta-sync", oazapfts.json({
updatedAfter, ...opts,
userIds method: "POST",
}))}`, { body: assetDeltaSyncDto
...opts })));
}));
} }
export function getAllForUserFullSync({ lastCreationDate, lastId, limit, updatedUntil, userId }: { export function getFullSyncForUser({ assetFullSyncDto }: {
lastCreationDate?: string; assetFullSyncDto: AssetFullSyncDto;
lastId?: string;
limit: number;
updatedUntil: string;
userId?: string;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetResponseDto[]; data: AssetResponseDto[];
}>(`/sync/full-sync${QS.query(QS.explode({ }>("/sync/full-sync", oazapfts.json({
lastCreationDate, ...opts,
lastId, method: "POST",
limit, body: assetFullSyncDto
updatedUntil, })));
userId
}))}`, {
...opts
}));
} }
export function getConfig(opts?: Oazapfts.RequestOpts) { export function getConfig(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{

View file

@ -1,4 +1,4 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@ -12,13 +12,15 @@ import { SyncService } from 'src/services/sync.service';
export class SyncController { export class SyncController {
constructor(private service: SyncService) {} constructor(private service: SyncService) {}
@Get('full-sync') @Post('full-sync')
getAllForUserFullSync(@Auth() auth: AuthDto, @Query() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> { @HttpCode(HttpStatus.OK)
return this.service.getAllAssetsForUserFullSync(auth, dto); getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
return this.service.getFullSync(auth, dto);
} }
@Get('delta-sync') @Post('delta-sync')
getDeltaSync(@Auth() auth: AuthDto, @Query() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> { @HttpCode(HttpStatus.OK)
return this.service.getChangesForDeltaSync(auth, dto); getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
return this.service.getDeltaSync(auth, dto);
} }
} }

View file

@ -1,5 +1,4 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsPositive } from 'class-validator'; import { IsInt, IsPositive } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ValidateDate, ValidateUUID } from 'src/validation'; import { ValidateDate, ValidateUUID } from 'src/validation';
@ -16,7 +15,6 @@ export class AssetFullSyncDto {
@IsInt() @IsInt()
@IsPositive() @IsPositive()
@Type(() => Number)
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
limit!: number; limit!: number;
@ -27,6 +25,7 @@ export class AssetFullSyncDto {
export class AssetDeltaSyncDto { export class AssetDeltaSyncDto {
@ValidateDate() @ValidateDate()
updatedAfter!: Date; updatedAfter!: Date;
@ValidateUUID({ each: true }) @ValidateUUID({ each: true })
userIds!: string[]; userIds!: string[];
} }

View file

@ -134,6 +134,8 @@ export interface AssetFullSyncOptions {
lastCreationDate?: Date; lastCreationDate?: Date;
lastId?: string; lastId?: string;
updatedUntil: Date; updatedUntil: Date;
isArchived?: false;
withStacked?: true;
limit: number; limit: number;
} }

View file

@ -798,16 +798,47 @@ SELECT
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."fps" AS "exifInfo_fps", "exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id", "stack"."id" AS "stack_id",
"stack"."primaryAssetId" AS "stack_primaryAssetId" "stack"."primaryAssetId" AS "stack_primaryAssetId",
"stackedAssets"."id" AS "stackedAssets_id",
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."previewPath" AS "stackedAssets_previewPath",
"stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
"stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly",
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
"stackedAssets"."checksum" AS "stackedAssets_checksum",
"stackedAssets"."duration" AS "stackedAssets_duration",
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
"stackedAssets"."stackId" AS "stackedAssets_stackId"
FROM FROM
"assets" "asset" "assets" "asset"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
AND ("stackedAssets"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."isVisible" = true
AND "asset"."ownerId" IN ($1)
AND ("asset"."fileCreatedAt", "asset"."id") < ($2, $3) AND ("asset"."fileCreatedAt", "asset"."id") < ($2, $3)
AND "asset"."updatedAt" <= $4 AND "asset"."updatedAt" <= $4
AND "asset"."isVisible" = true
ORDER BY ORDER BY
"asset"."fileCreatedAt" DESC, "asset"."fileCreatedAt" DESC,
"asset"."id" DESC "asset"."id" DESC
@ -816,72 +847,105 @@ LIMIT
-- AssetRepository.getChangedDeltaSync -- AssetRepository.getChangedDeltaSync
SELECT SELECT
"AssetEntity"."id" AS "AssetEntity_id", "asset"."id" AS "asset_id",
"AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", "asset"."deviceAssetId" AS "asset_deviceAssetId",
"AssetEntity"."ownerId" AS "AssetEntity_ownerId", "asset"."ownerId" AS "asset_ownerId",
"AssetEntity"."libraryId" AS "AssetEntity_libraryId", "asset"."libraryId" AS "asset_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId", "asset"."deviceId" AS "asset_deviceId",
"AssetEntity"."type" AS "AssetEntity_type", "asset"."type" AS "asset_type",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath", "asset"."originalPath" AS "asset_originalPath",
"AssetEntity"."previewPath" AS "AssetEntity_previewPath", "asset"."previewPath" AS "asset_previewPath",
"AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "asset"."thumbnailPath" AS "asset_thumbnailPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "asset"."thumbhash" AS "asset_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "asset"."encodedVideoPath" AS "asset_encodedVideoPath",
"AssetEntity"."createdAt" AS "AssetEntity_createdAt", "asset"."createdAt" AS "asset_createdAt",
"AssetEntity"."updatedAt" AS "AssetEntity_updatedAt", "asset"."updatedAt" AS "asset_updatedAt",
"AssetEntity"."deletedAt" AS "AssetEntity_deletedAt", "asset"."deletedAt" AS "asset_deletedAt",
"AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt", "asset"."fileCreatedAt" AS "asset_fileCreatedAt",
"AssetEntity"."localDateTime" AS "AssetEntity_localDateTime", "asset"."localDateTime" AS "asset_localDateTime",
"AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt", "asset"."fileModifiedAt" AS "asset_fileModifiedAt",
"AssetEntity"."isFavorite" AS "AssetEntity_isFavorite", "asset"."isFavorite" AS "asset_isFavorite",
"AssetEntity"."isArchived" AS "AssetEntity_isArchived", "asset"."isArchived" AS "asset_isArchived",
"AssetEntity"."isExternal" AS "AssetEntity_isExternal", "asset"."isExternal" AS "asset_isExternal",
"AssetEntity"."isReadOnly" AS "AssetEntity_isReadOnly", "asset"."isReadOnly" AS "asset_isReadOnly",
"AssetEntity"."isOffline" AS "AssetEntity_isOffline", "asset"."isOffline" AS "asset_isOffline",
"AssetEntity"."checksum" AS "AssetEntity_checksum", "asset"."checksum" AS "asset_checksum",
"AssetEntity"."duration" AS "AssetEntity_duration", "asset"."duration" AS "asset_duration",
"AssetEntity"."isVisible" AS "AssetEntity_isVisible", "asset"."isVisible" AS "asset_isVisible",
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "asset"."originalFileName" AS "asset_originalFileName",
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", "asset"."sidecarPath" AS "asset_sidecarPath",
"AssetEntity"."stackId" AS "AssetEntity_stackId", "asset"."stackId" AS "asset_stackId",
"AssetEntity__AssetEntity_exifInfo"."assetId" AS "AssetEntity__AssetEntity_exifInfo_assetId", "exifInfo"."assetId" AS "exifInfo_assetId",
"AssetEntity__AssetEntity_exifInfo"."description" AS "AssetEntity__AssetEntity_exifInfo_description", "exifInfo"."description" AS "exifInfo_description",
"AssetEntity__AssetEntity_exifInfo"."exifImageWidth" AS "AssetEntity__AssetEntity_exifInfo_exifImageWidth", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
"AssetEntity__AssetEntity_exifInfo"."exifImageHeight" AS "AssetEntity__AssetEntity_exifInfo_exifImageHeight", "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
"AssetEntity__AssetEntity_exifInfo"."fileSizeInByte" AS "AssetEntity__AssetEntity_exifInfo_fileSizeInByte", "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
"AssetEntity__AssetEntity_exifInfo"."orientation" AS "AssetEntity__AssetEntity_exifInfo_orientation", "exifInfo"."orientation" AS "exifInfo_orientation",
"AssetEntity__AssetEntity_exifInfo"."dateTimeOriginal" AS "AssetEntity__AssetEntity_exifInfo_dateTimeOriginal", "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
"AssetEntity__AssetEntity_exifInfo"."modifyDate" AS "AssetEntity__AssetEntity_exifInfo_modifyDate", "exifInfo"."modifyDate" AS "exifInfo_modifyDate",
"AssetEntity__AssetEntity_exifInfo"."timeZone" AS "AssetEntity__AssetEntity_exifInfo_timeZone", "exifInfo"."timeZone" AS "exifInfo_timeZone",
"AssetEntity__AssetEntity_exifInfo"."latitude" AS "AssetEntity__AssetEntity_exifInfo_latitude", "exifInfo"."latitude" AS "exifInfo_latitude",
"AssetEntity__AssetEntity_exifInfo"."longitude" AS "AssetEntity__AssetEntity_exifInfo_longitude", "exifInfo"."longitude" AS "exifInfo_longitude",
"AssetEntity__AssetEntity_exifInfo"."projectionType" AS "AssetEntity__AssetEntity_exifInfo_projectionType", "exifInfo"."projectionType" AS "exifInfo_projectionType",
"AssetEntity__AssetEntity_exifInfo"."city" AS "AssetEntity__AssetEntity_exifInfo_city", "exifInfo"."city" AS "exifInfo_city",
"AssetEntity__AssetEntity_exifInfo"."livePhotoCID" AS "AssetEntity__AssetEntity_exifInfo_livePhotoCID", "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
"AssetEntity__AssetEntity_exifInfo"."autoStackId" AS "AssetEntity__AssetEntity_exifInfo_autoStackId", "exifInfo"."autoStackId" AS "exifInfo_autoStackId",
"AssetEntity__AssetEntity_exifInfo"."state" AS "AssetEntity__AssetEntity_exifInfo_state", "exifInfo"."state" AS "exifInfo_state",
"AssetEntity__AssetEntity_exifInfo"."country" AS "AssetEntity__AssetEntity_exifInfo_country", "exifInfo"."country" AS "exifInfo_country",
"AssetEntity__AssetEntity_exifInfo"."make" AS "AssetEntity__AssetEntity_exifInfo_make", "exifInfo"."make" AS "exifInfo_make",
"AssetEntity__AssetEntity_exifInfo"."model" AS "AssetEntity__AssetEntity_exifInfo_model", "exifInfo"."model" AS "exifInfo_model",
"AssetEntity__AssetEntity_exifInfo"."lensModel" AS "AssetEntity__AssetEntity_exifInfo_lensModel", "exifInfo"."lensModel" AS "exifInfo_lensModel",
"AssetEntity__AssetEntity_exifInfo"."fNumber" AS "AssetEntity__AssetEntity_exifInfo_fNumber", "exifInfo"."fNumber" AS "exifInfo_fNumber",
"AssetEntity__AssetEntity_exifInfo"."focalLength" AS "AssetEntity__AssetEntity_exifInfo_focalLength", "exifInfo"."focalLength" AS "exifInfo_focalLength",
"AssetEntity__AssetEntity_exifInfo"."iso" AS "AssetEntity__AssetEntity_exifInfo_iso", "exifInfo"."iso" AS "exifInfo_iso",
"AssetEntity__AssetEntity_exifInfo"."exposureTime" AS "AssetEntity__AssetEntity_exifInfo_exposureTime", "exifInfo"."exposureTime" AS "exifInfo_exposureTime",
"AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "exifInfo"."colorspace" AS "exifInfo_colorspace",
"AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", "exifInfo"."fps" AS "exifInfo_fps",
"AssetEntity__AssetEntity_stack"."id" AS "AssetEntity__AssetEntity_stack_id", "stack"."id" AS "stack_id",
"AssetEntity__AssetEntity_stack"."primaryAssetId" AS "AssetEntity__AssetEntity_stack_primaryAssetId" "stack"."primaryAssetId" AS "stack_primaryAssetId",
"stackedAssets"."id" AS "stackedAssets_id",
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."previewPath" AS "stackedAssets_previewPath",
"stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
"stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly",
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
"stackedAssets"."checksum" AS "stackedAssets_checksum",
"stackedAssets"."duration" AS "stackedAssets_duration",
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
"stackedAssets"."stackId" AS "stackedAssets_stackId"
FROM FROM
"assets" "AssetEntity" "assets" "asset"
LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
LEFT JOIN "asset_stack" "AssetEntity__AssetEntity_stack" ON "AssetEntity__AssetEntity_stack"."id" = "AssetEntity"."stackId" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
AND ("stackedAssets"."deletedAt" IS NULL)
WHERE WHERE
( "asset"."isVisible" = true
("AssetEntity"."ownerId" IN ($1)) AND "asset"."ownerId" IN ($1)
AND ("AssetEntity"."isVisible" = $2) AND (
AND ("AssetEntity"."updatedAt" > $3) "stack"."primaryAssetId" = "asset"."id"
OR "asset"."stackId" IS NULL
) )
AND "asset"."updatedAt" > $2

View file

@ -710,21 +710,23 @@ export class AssetRepository implements IAssetRepository {
], ],
}) })
getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]> { getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]> {
const { ownerId, lastCreationDate, lastId, updatedUntil, limit } = options; const { ownerId, isArchived, withStacked, lastCreationDate, lastId, updatedUntil, limit } = options;
const builder = this.repository const builder = this.getBuilder({
.createQueryBuilder('asset') userIds: [ownerId],
.leftJoinAndSelect('asset.exifInfo', 'exifInfo') exifInfo: true,
.leftJoinAndSelect('asset.stack', 'stack') withStacked,
.where('asset.ownerId = :ownerId', { ownerId }); isArchived,
});
if (lastCreationDate !== undefined && lastId !== undefined) { if (lastCreationDate !== undefined && lastId !== undefined) {
builder.andWhere('(asset.fileCreatedAt, asset.id) < (:lastCreationDate, :lastId)', { builder.andWhere('(asset.fileCreatedAt, asset.id) < (:lastCreationDate, :lastId)', {
lastCreationDate, lastCreationDate,
lastId, lastId,
}); });
} }
return builder return builder
.andWhere('asset.updatedAt <= :updatedUntil', { updatedUntil }) .andWhere('asset.updatedAt <= :updatedUntil', { updatedUntil })
.andWhere('asset.isVisible = true')
.orderBy('asset.fileCreatedAt', 'DESC') .orderBy('asset.fileCreatedAt', 'DESC')
.addOrderBy('asset.id', 'DESC') .addOrderBy('asset.id', 'DESC')
.limit(limit) .limit(limit)
@ -734,18 +736,11 @@ export class AssetRepository implements IAssetRepository {
@GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE }] }) @GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE }] })
getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]> { getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]> {
return this.repository.find({ const builder = this.getBuilder({ userIds: options.userIds, exifInfo: true, withStacked: true })
where: { .andWhere({ updatedAt: MoreThan(options.updatedAfter) })
ownerId: In(options.userIds), .take(options.limit)
isVisible: true, .withDeleted();
updatedAt: MoreThan(options.updatedAfter),
}, return builder.getMany();
relations: {
exifInfo: true,
stack: true,
},
take: options.limit,
withDeleted: true,
});
} }
} }

View file

@ -39,13 +39,12 @@ describe(SyncService.name, () => {
describe('getAllAssetsForUserFullSync', () => { describe('getAllAssetsForUserFullSync', () => {
it('should return a list of all assets owned by the user', async () => { it('should return a list of all assets owned by the user', async () => {
assetMock.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); assetMock.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]);
await expect( await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([
sut.getAllAssetsForUserFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate }),
).resolves.toEqual([
mapAsset(assetStub.external, mapAssetOpts), mapAsset(assetStub.external, mapAssetOpts),
mapAsset(assetStub.hasEncodedVideo, mapAssetOpts), mapAsset(assetStub.hasEncodedVideo, mapAssetOpts),
]); ]);
expect(assetMock.getAllForUserFullSync).toHaveBeenCalledWith({ expect(assetMock.getAllForUserFullSync).toHaveBeenCalledWith({
withStacked: true,
ownerId: authStub.user1.user.id, ownerId: authStub.user1.user.id,
updatedUntil: untilDate, updatedUntil: untilDate,
limit: 2, limit: 2,
@ -57,7 +56,7 @@ describe(SyncService.name, () => {
it('should return a response requiring a full sync when partners are out of sync', async () => { it('should return a response requiring a full sync when partners are out of sync', async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]); partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]);
await expect( await expect(
sut.getChangesForDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0); expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0);
expect(auditMock.getAfter).toHaveBeenCalledTimes(0); expect(auditMock.getAfter).toHaveBeenCalledTimes(0);
@ -66,7 +65,7 @@ describe(SyncService.name, () => {
it('should return a response requiring a full sync when last sync was too long ago', async () => { it('should return a response requiring a full sync when last sync was too long ago', async () => {
partnerMock.getAll.mockResolvedValue([]); partnerMock.getAll.mockResolvedValue([]);
await expect( await expect(
sut.getChangesForDeltaSync(authStub.user1, { updatedAfter: new Date(2000), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(2000), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0); expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(0);
expect(auditMock.getAfter).toHaveBeenCalledTimes(0); expect(auditMock.getAfter).toHaveBeenCalledTimes(0);
@ -78,7 +77,7 @@ describe(SyncService.name, () => {
Array.from<AssetEntity>({ length: 10_000 }).fill(assetStub.image), Array.from<AssetEntity>({ length: 10_000 }).fill(assetStub.image),
); );
await expect( await expect(
sut.getChangesForDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1); expect(assetMock.getChangedDeltaSync).toHaveBeenCalledTimes(1);
expect(auditMock.getAfter).toHaveBeenCalledTimes(0); expect(auditMock.getAfter).toHaveBeenCalledTimes(0);
@ -89,7 +88,7 @@ describe(SyncService.name, () => {
assetMock.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); assetMock.getChangedDeltaSync.mockResolvedValue([assetStub.image1]);
auditMock.getAfter.mockResolvedValue([assetStub.external.id]); auditMock.getAfter.mockResolvedValue([assetStub.external.id]);
await expect( await expect(
sut.getChangesForDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ ).resolves.toEqual({
needsFullSync: false, needsFullSync: false,
upserted: [mapAsset(assetStub.image1, mapAssetOpts)], upserted: [mapAsset(assetStub.image1, mapAssetOpts)],

View file

@ -1,5 +1,4 @@
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import _ from 'lodash';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { AccessCore, Permission } from 'src/cores/access.core'; import { AccessCore, Permission } from 'src/cores/access.core';
@ -11,6 +10,9 @@ import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { setIsEqual } from 'src/utils/set';
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
export class SyncService { export class SyncService {
private access: AccessCore; private access: AccessCore;
@ -24,52 +26,69 @@ export class SyncService {
this.access = AccessCore.create(accessRepository); this.access = AccessCore.create(accessRepository);
} }
async getAllAssetsForUserFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> { async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
// mobile implementation is faster if this is a single id
const userId = dto.userId || auth.user.id; const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
const assets = await this.assetRepository.getAllForUserFullSync({ const assets = await this.assetRepository.getAllForUserFullSync({
ownerId: userId, ownerId: userId,
// no archived assets for partner user
isArchived: userId === auth.user.id ? undefined : false,
// no stack for partner user
withStacked: userId === auth.user.id ? true : undefined,
lastCreationDate: dto.lastCreationDate, lastCreationDate: dto.lastCreationDate,
updatedUntil: dto.updatedUntil, updatedUntil: dto.updatedUntil,
lastId: dto.lastId, lastId: dto.lastId,
limit: dto.limit, limit: dto.limit,
}); });
const options = { auth, stripMetadata: false, withStack: true }; return assets.map((a) => mapAsset(a, { auth, stripMetadata: false, withStack: true }));
return assets.map((a) => mapAsset(a, options));
} }
async getChangesForDeltaSync(auth: AuthDto, dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> { async getDeltaSync(auth: AuthDto, dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
await this.access.requirePermission(auth, Permission.TIMELINE_READ, dto.userIds); // app has not synced in the last 100 days
const partner = await this.partnerRepository.getAll(auth.user.id);
const userIds = [auth.user.id, ...partner.filter((p) => p.sharedWithId == auth.user.id).map((p) => p.sharedById)];
userIds.sort();
dto.userIds.sort();
const duration = DateTime.now().diff(DateTime.fromJSDate(dto.updatedAfter)); const duration = DateTime.now().diff(DateTime.fromJSDate(dto.updatedAfter));
if (duration > AUDIT_LOG_MAX_DURATION) {
if (!_.isEqual(userIds, dto.userIds) || duration > AUDIT_LOG_MAX_DURATION) { return FULL_SYNC;
// app does not have the correct partners synced
// or app has not synced in the last 100 days
return { needsFullSync: true, deleted: [], upserted: [] };
} }
const authUserId = auth.user.id;
// app does not have the correct partners synced
const partner = await this.partnerRepository.getAll(authUserId);
const userIds = [authUserId, ...partner.filter((p) => p.sharedWithId == auth.user.id).map((p) => p.sharedById)];
if (!setIsEqual(new Set(userIds), new Set(dto.userIds))) {
return FULL_SYNC;
}
await this.access.requirePermission(auth, Permission.TIMELINE_READ, dto.userIds);
const limit = 10_000; const limit = 10_000;
const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds }); const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds });
// too many changes, need to do a full sync
if (upserted.length === limit) { if (upserted.length === limit) {
// too many changes -> do a full sync (paginated) instead return FULL_SYNC;
return { needsFullSync: true, deleted: [], upserted: [] };
} }
const deleted = await this.auditRepository.getAfter(dto.updatedAfter, { const deleted = await this.auditRepository.getAfter(dto.updatedAfter, {
userIds: userIds, userIds,
entityType: EntityType.ASSET, entityType: EntityType.ASSET,
action: DatabaseAction.DELETE, action: DatabaseAction.DELETE,
}); });
const options = { auth, stripMetadata: false, withStack: true };
const result = { const result = {
needsFullSync: false, needsFullSync: false,
upserted: upserted.map((a) => mapAsset(a, options)), upserted: upserted
// do not return archived assets for partner users
.filter((a) => a.ownerId === auth.user.id || (a.ownerId !== auth.user.id && !a.isArchived))
.map((a) =>
mapAsset(a, {
auth,
stripMetadata: false,
// ignore stacks for non partner users
withStack: a.ownerId === authUserId,
}),
),
deleted, deleted,
}; };
return result; return result;