feat: add album asset sync (#19503)

wip: fix album asset exif and some other refactorings

feat: add album assets sync

feat: album to assets relation sync

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
Jason Rasmussen 2025-06-25 12:10:31 -04:00 committed by GitHub
parent b001ba44f5
commit 881a96cdf9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1706 additions and 90 deletions

View File

@ -466,6 +466,7 @@ Class | Method | HTTP request | Description
- [SyncAckDto](doc//SyncAckDto.md)
- [SyncAckSetDto](doc//SyncAckSetDto.md)
- [SyncAlbumDeleteV1](doc//SyncAlbumDeleteV1.md)
- [SyncAlbumToAssetV1](doc//SyncAlbumToAssetV1.md)
- [SyncAlbumUserDeleteV1](doc//SyncAlbumUserDeleteV1.md)
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
- [SyncAlbumV1](doc//SyncAlbumV1.md)

View File

@ -249,6 +249,7 @@ part 'model/sync_ack_delete_dto.dart';
part 'model/sync_ack_dto.dart';
part 'model/sync_ack_set_dto.dart';
part 'model/sync_album_delete_v1.dart';
part 'model/sync_album_to_asset_v1.dart';
part 'model/sync_album_user_delete_v1.dart';
part 'model/sync_album_user_v1.dart';
part 'model/sync_album_v1.dart';

View File

@ -554,6 +554,8 @@ class ApiClient {
return SyncAckSetDto.fromJson(value);
case 'SyncAlbumDeleteV1':
return SyncAlbumDeleteV1.fromJson(value);
case 'SyncAlbumToAssetV1':
return SyncAlbumToAssetV1.fromJson(value);
case 'SyncAlbumUserDeleteV1':
return SyncAlbumUserDeleteV1.fromJson(value);
case 'SyncAlbumUserV1':

View File

@ -0,0 +1,107 @@
//
// 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 SyncAlbumToAssetV1 {
/// Returns a new [SyncAlbumToAssetV1] instance.
SyncAlbumToAssetV1({
required this.albumId,
required this.assetId,
});
String albumId;
String assetId;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAlbumToAssetV1 &&
other.albumId == albumId &&
other.assetId == assetId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumId.hashCode) +
(assetId.hashCode);
@override
String toString() => 'SyncAlbumToAssetV1[albumId=$albumId, assetId=$assetId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumId'] = this.albumId;
json[r'assetId'] = this.assetId;
return json;
}
/// Returns a new [SyncAlbumToAssetV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAlbumToAssetV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAlbumToAssetV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAlbumToAssetV1(
albumId: mapValueOfType<String>(json, r'albumId')!,
assetId: mapValueOfType<String>(json, r'assetId')!,
);
}
return null;
}
static List<SyncAlbumToAssetV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAlbumToAssetV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAlbumToAssetV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAlbumToAssetV1> mapFromJson(dynamic json) {
final map = <String, SyncAlbumToAssetV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAlbumToAssetV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAlbumToAssetV1-objects as value to a dart map
static Map<String, List<SyncAlbumToAssetV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAlbumToAssetV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAlbumToAssetV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'albumId',
'assetId',
};
}

View File

@ -40,6 +40,13 @@ class SyncEntityType {
static const albumUserV1 = SyncEntityType._(r'AlbumUserV1');
static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1');
static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1');
static const albumAssetV1 = SyncEntityType._(r'AlbumAssetV1');
static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1');
static const albumAssetExifV1 = SyncEntityType._(r'AlbumAssetExifV1');
static const albumAssetExifBackfillV1 = SyncEntityType._(r'AlbumAssetExifBackfillV1');
static const albumToAssetV1 = SyncEntityType._(r'AlbumToAssetV1');
static const albumToAssetDeleteV1 = SyncEntityType._(r'AlbumToAssetDeleteV1');
static const albumToAssetBackfillV1 = SyncEntityType._(r'AlbumToAssetBackfillV1');
static const syncAckV1 = SyncEntityType._(r'SyncAckV1');
/// List of all possible values in this [enum][SyncEntityType].
@ -61,6 +68,13 @@ class SyncEntityType {
albumUserV1,
albumUserBackfillV1,
albumUserDeleteV1,
albumAssetV1,
albumAssetBackfillV1,
albumAssetExifV1,
albumAssetExifBackfillV1,
albumToAssetV1,
albumToAssetDeleteV1,
albumToAssetBackfillV1,
syncAckV1,
];
@ -117,6 +131,13 @@ class SyncEntityTypeTypeTransformer {
case r'AlbumUserV1': return SyncEntityType.albumUserV1;
case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1;
case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1;
case r'AlbumAssetV1': return SyncEntityType.albumAssetV1;
case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1;
case r'AlbumAssetExifV1': return SyncEntityType.albumAssetExifV1;
case r'AlbumAssetExifBackfillV1': return SyncEntityType.albumAssetExifBackfillV1;
case r'AlbumToAssetV1': return SyncEntityType.albumToAssetV1;
case r'AlbumToAssetDeleteV1': return SyncEntityType.albumToAssetDeleteV1;
case r'AlbumToAssetBackfillV1': return SyncEntityType.albumToAssetBackfillV1;
case r'SyncAckV1': return SyncEntityType.syncAckV1;
default:
if (!allowNull) {

View File

@ -31,6 +31,9 @@ class SyncRequestType {
static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1');
static const albumsV1 = SyncRequestType._(r'AlbumsV1');
static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1');
static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1');
static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1');
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
/// List of all possible values in this [enum][SyncRequestType].
static const values = <SyncRequestType>[
@ -42,6 +45,9 @@ class SyncRequestType {
partnerAssetExifsV1,
albumsV1,
albumUsersV1,
albumToAssetsV1,
albumAssetsV1,
albumAssetExifsV1,
];
static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value);
@ -88,6 +94,9 @@ class SyncRequestTypeTypeTransformer {
case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1;
case r'AlbumsV1': return SyncRequestType.albumsV1;
case r'AlbumUsersV1': return SyncRequestType.albumUsersV1;
case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1;
case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1;
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -13435,6 +13435,10 @@
],
"type": "object"
},
"SyncAckV1": {
"properties": {},
"type": "object"
},
"SyncAlbumDeleteV1": {
"properties": {
"albumId": {
@ -13446,6 +13450,21 @@
],
"type": "object"
},
"SyncAlbumToAssetV1": {
"properties": {
"albumId": {
"type": "string"
},
"assetId": {
"type": "string"
}
},
"required": [
"albumId",
"assetId"
],
"type": "object"
},
"SyncAlbumUserDeleteV1": {
"properties": {
"albumId": {
@ -13774,6 +13793,13 @@
"AlbumUserV1",
"AlbumUserBackfillV1",
"AlbumUserDeleteV1",
"AlbumAssetV1",
"AlbumAssetBackfillV1",
"AlbumAssetExifV1",
"AlbumAssetExifBackfillV1",
"AlbumToAssetV1",
"AlbumToAssetDeleteV1",
"AlbumToAssetBackfillV1",
"SyncAckV1"
],
"type": "string"
@ -13821,7 +13847,10 @@
"PartnerAssetsV1",
"PartnerAssetExifsV1",
"AlbumsV1",
"AlbumUsersV1"
"AlbumUsersV1",
"AlbumToAssetsV1",
"AlbumAssetsV1",
"AlbumAssetExifsV1"
],
"type": "string"
},

View File

@ -4078,6 +4078,13 @@ export enum SyncEntityType {
AlbumUserV1 = "AlbumUserV1",
AlbumUserBackfillV1 = "AlbumUserBackfillV1",
AlbumUserDeleteV1 = "AlbumUserDeleteV1",
AlbumAssetV1 = "AlbumAssetV1",
AlbumAssetBackfillV1 = "AlbumAssetBackfillV1",
AlbumAssetExifV1 = "AlbumAssetExifV1",
AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1",
AlbumToAssetV1 = "AlbumToAssetV1",
AlbumToAssetDeleteV1 = "AlbumToAssetDeleteV1",
AlbumToAssetBackfillV1 = "AlbumToAssetBackfillV1",
SyncAckV1 = "SyncAckV1"
}
export enum SyncRequestType {
@ -4088,7 +4095,10 @@ export enum SyncRequestType {
PartnerAssetsV1 = "PartnerAssetsV1",
PartnerAssetExifsV1 = "PartnerAssetExifsV1",
AlbumsV1 = "AlbumsV1",
AlbumUsersV1 = "AlbumUsersV1"
AlbumUsersV1 = "AlbumUsersV1",
AlbumToAssetsV1 = "AlbumToAssetsV1",
AlbumAssetsV1 = "AlbumAssetsV1",
AlbumAssetExifsV1 = "AlbumAssetExifsV1"
}
export enum TranscodeHWAccel {
Nvenc = "nvenc",

View File

@ -340,27 +340,21 @@ export const columns = {
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
syncAsset: [
'id',
'ownerId',
'originalFileName',
'thumbhash',
'checksum',
'fileCreatedAt',
'fileModifiedAt',
'localDateTime',
'type',
'deletedAt',
'isFavorite',
'visibility',
'updateId',
'duration',
],
syncAlbumUser: [
'albums_shared_users_users.albumsId as albumId',
'albums_shared_users_users.usersId as userId',
'albums_shared_users_users.role',
'albums_shared_users_users.updateId',
'assets.id',
'assets.ownerId',
'assets.originalFileName',
'assets.thumbhash',
'assets.checksum',
'assets.fileCreatedAt',
'assets.fileModifiedAt',
'assets.localDateTime',
'assets.type',
'assets.deletedAt',
'assets.isFavorite',
'assets.visibility',
'assets.duration',
],
syncAlbumUser: ['album_users.albumsId as albumId', 'album_users.usersId as userId', 'album_users.role'],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
syncAssetExif: [
'exif.assetId',
@ -388,7 +382,6 @@ export const columns = {
'exif.profileDescription',
'exif.rating',
'exif.fps',
'exif.updateId',
],
exif: [
'exif.assetId',

10
server/src/db.d.ts vendored
View File

@ -92,6 +92,15 @@ export interface AlbumsAssetsAssets {
albumsId: string;
assetsId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface AlbumAssetsAudit {
deletedAt: Generated<Timestamp>;
id: Generated<string>;
albumId: string;
assetId: string;
}
export interface AlbumsSharedUsersUsers {
@ -487,6 +496,7 @@ export interface DB {
albums: Albums;
albums_audit: AlbumsAudit;
albums_assets_assets: AlbumsAssetsAssets;
album_assets_audit: AlbumAssetsAudit;
albums_shared_users_users: AlbumsSharedUsersUsers;
album_users_audit: AlbumUsersAudit;
api_keys: ApiKeys;

View File

@ -145,6 +145,18 @@ export class SyncAlbumV1 {
order!: AssetOrder;
}
export class SyncAlbumToAssetV1 {
albumId!: string;
assetId!: string;
}
export class SyncAlbumToAssetDeleteV1 {
albumId!: string;
assetId!: string;
}
export class SyncAckV1 {}
export type SyncItem = {
[SyncEntityType.UserV1]: SyncUserV1;
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
@ -163,7 +175,14 @@ export type SyncItem = {
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
[SyncEntityType.SyncAckV1]: object;
[SyncEntityType.AlbumAssetV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1;
[SyncEntityType.AlbumToAssetV1]: SyncAlbumToAssetV1;
[SyncEntityType.AlbumToAssetBackfillV1]: SyncAlbumToAssetV1;
[SyncEntityType.AlbumToAssetDeleteV1]: SyncAlbumToAssetDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1;
};
const responseDtos = [
@ -178,6 +197,8 @@ const responseDtos = [
SyncAlbumDeleteV1,
SyncAlbumUserV1,
SyncAlbumUserDeleteV1,
SyncAlbumToAssetV1,
SyncAckV1,
];
export const extraSyncModels = responseDtos;

View File

@ -581,6 +581,9 @@ export enum SyncRequestType {
PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
AlbumsV1 = 'AlbumsV1',
AlbumUsersV1 = 'AlbumUsersV1',
AlbumToAssetsV1 = 'AlbumToAssetsV1',
AlbumAssetsV1 = 'AlbumAssetsV1',
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
}
export enum SyncEntityType {
@ -605,6 +608,13 @@ export enum SyncEntityType {
AlbumUserV1 = 'AlbumUserV1',
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
AlbumAssetV1 = 'AlbumAssetV1',
AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1',
AlbumAssetExifV1 = 'AlbumAssetExifV1',
AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1',
AlbumToAssetV1 = 'AlbumToAssetV1',
AlbumToAssetDeleteV1 = 'AlbumToAssetDeleteV1',
AlbumToAssetBackfillV1 = 'AlbumToAssetBackfillV1',
SyncAckV1 = 'SyncAckV1',
}

View File

@ -74,20 +74,20 @@ order by
-- SyncRepository.getAssetUpserts
select
"id",
"ownerId",
"originalFileName",
"thumbhash",
"checksum",
"fileCreatedAt",
"fileModifiedAt",
"localDateTime",
"type",
"deletedAt",
"isFavorite",
"visibility",
"updateId",
"duration"
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets"
where
@ -111,20 +111,20 @@ order by
-- SyncRepository.getPartnerAssetsBackfill
select
"id",
"ownerId",
"originalFileName",
"thumbhash",
"checksum",
"fileCreatedAt",
"fileModifiedAt",
"localDateTime",
"type",
"deletedAt",
"isFavorite",
"visibility",
"updateId",
"duration"
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets"
where
@ -137,20 +137,20 @@ order by
-- SyncRepository.getPartnerAssetsUpserts
select
"id",
"ownerId",
"originalFileName",
"thumbhash",
"checksum",
"fileCreatedAt",
"fileModifiedAt",
"localDateTime",
"type",
"deletedAt",
"isFavorite",
"visibility",
"updateId",
"duration"
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets"
where
@ -365,6 +365,35 @@ where
order by
"albums"."updateId" asc
-- SyncRepository.getAlbumToAssetDeletes
select
"id",
"assetId",
"albumId"
from
"album_assets_audit"
where
"albumId" in (
select
"id"
from
"albums"
where
"ownerId" = $1
union
(
select
"albumUsers"."albumsId" as "id"
from
"albums_shared_users_users" as "albumUsers"
where
"albumUsers"."usersId" = $2
)
)
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.getAlbumUserDeletes
select
"id",
@ -409,12 +438,12 @@ order by
-- SyncRepository.getAlbumUsersBackfill
select
"albums_shared_users_users"."albumsId" as "albumId",
"albums_shared_users_users"."usersId" as "userId",
"albums_shared_users_users"."role",
"albums_shared_users_users"."updateId"
"album_users"."albumsId" as "albumId",
"album_users"."usersId" as "userId",
"album_users"."role",
"album_users"."updateId"
from
"albums_shared_users_users"
"albums_shared_users_users" as "album_users"
where
"albumsId" = $1
and "updatedAt" < now() - interval '1 millisecond'
@ -425,15 +454,15 @@ order by
-- SyncRepository.getAlbumUserUpserts
select
"albums_shared_users_users"."albumsId" as "albumId",
"albums_shared_users_users"."usersId" as "userId",
"albums_shared_users_users"."role",
"albums_shared_users_users"."updateId"
"album_users"."albumsId" as "albumId",
"album_users"."usersId" as "userId",
"album_users"."role",
"album_users"."updateId"
from
"albums_shared_users_users"
"albums_shared_users_users" as "album_users"
where
"albums_shared_users_users"."updatedAt" < now() - interval '1 millisecond'
and "albums_shared_users_users"."albumsId" in (
"album_users"."updatedAt" < now() - interval '1 millisecond'
and "album_users"."albumsId" in (
select
"id"
from
@ -451,4 +480,175 @@ where
)
)
order by
"albums_shared_users_users"."updateId" asc
"album_users"."updateId" asc
-- SyncRepository.getAlbumAssetsBackfill
select
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "assets"."id"
where
"album_assets"."albumsId" = $1
and "assets"."updatedAt" < now() - interval '1 millisecond'
and "assets"."updateId" <= $2
and "assets"."updateId" >= $3
order by
"assets"."updateId" asc
-- SyncRepository.getAlbumAssetsUpserts
select
"assets"."id",
"assets"."ownerId",
"assets"."originalFileName",
"assets"."thumbhash",
"assets"."checksum",
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."localDateTime",
"assets"."type",
"assets"."deletedAt",
"assets"."isFavorite",
"assets"."visibility",
"assets"."duration",
"assets"."updateId"
from
"assets"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "assets"."id"
inner join "albums" on "albums"."id" = "album_assets"."albumsId"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "album_assets"."albumsId"
where
"assets"."updatedAt" < now() - interval '1 millisecond'
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
order by
"assets"."updateId" asc
-- SyncRepository.getAlbumToAssetBackfill
select
"album_assets"."assetsId" as "assetId",
"album_assets"."albumsId" as "albumId",
"album_assets"."updateId"
from
"albums_assets_assets" as "album_assets"
where
"album_assets"."albumsId" = $1
and "album_assets"."updatedAt" < now() - interval '1 millisecond'
and "album_assets"."updateId" <= $2
and "album_assets"."updateId" >= $3
order by
"album_assets"."updateId" asc
-- SyncRepository.getAlbumToAssetUpserts
select
"album_assets"."assetsId" as "assetId",
"album_assets"."albumsId" as "albumId",
"album_assets"."updateId"
from
"albums_assets_assets" as "album_assets"
inner join "albums" on "albums"."id" = "album_assets"."albumsId"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "album_assets"."albumsId"
where
"album_assets"."updatedAt" < now() - interval '1 millisecond'
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
order by
"album_assets"."updateId" asc
-- SyncRepository.getAlbumAssetExifsBackfill
select
"exif"."assetId",
"exif"."description",
"exif"."exifImageWidth",
"exif"."exifImageHeight",
"exif"."fileSizeInByte",
"exif"."orientation",
"exif"."dateTimeOriginal",
"exif"."modifyDate",
"exif"."timeZone",
"exif"."latitude",
"exif"."longitude",
"exif"."projectionType",
"exif"."city",
"exif"."state",
"exif"."country",
"exif"."make",
"exif"."model",
"exif"."lensModel",
"exif"."fNumber",
"exif"."focalLength",
"exif"."iso",
"exif"."exposureTime",
"exif"."profileDescription",
"exif"."rating",
"exif"."fps",
"exif"."updateId"
from
"exif"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "exif"."assetId"
where
"album_assets"."albumsId" = $1
and "exif"."updatedAt" < now() - interval '1 millisecond'
and "exif"."updateId" <= $2
and "exif"."updateId" >= $3
order by
"exif"."updateId" asc
-- SyncRepository.getAlbumAssetExifsUpserts
select
"exif"."assetId",
"exif"."description",
"exif"."exifImageWidth",
"exif"."exifImageHeight",
"exif"."fileSizeInByte",
"exif"."orientation",
"exif"."dateTimeOriginal",
"exif"."modifyDate",
"exif"."timeZone",
"exif"."latitude",
"exif"."longitude",
"exif"."projectionType",
"exif"."city",
"exif"."state",
"exif"."country",
"exif"."make",
"exif"."model",
"exif"."lensModel",
"exif"."fNumber",
"exif"."focalLength",
"exif"."iso",
"exif"."exposureTime",
"exif"."profileDescription",
"exif"."rating",
"exif"."fps",
"exif"."updateId"
from
"exif"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "exif"."assetId"
inner join "albums" on "albums"."id" = "album_assets"."albumsId"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "album_assets"."albumsId"
where
"exif"."updatedAt" < now() - interval '1 millisecond'
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
order by
"exif"."updateId" asc

View File

@ -7,7 +7,13 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { SyncEntityType } from 'src/enum';
import { SyncAck } from 'src/types';
type AuditTables = 'users_audit' | 'partners_audit' | 'assets_audit' | 'albums_audit' | 'album_users_audit';
type AuditTables =
| 'users_audit'
| 'partners_audit'
| 'assets_audit'
| 'albums_audit'
| 'album_users_audit'
| 'album_assets_audit';
type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users';
@Injectable()
@ -87,6 +93,7 @@ export class SyncRepository {
return this.db
.selectFrom('assets')
.select(columns.syncAsset)
.select('assets.updateId')
.where('ownerId', '=', userId)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
@ -109,6 +116,7 @@ export class SyncRepository {
return this.db
.selectFrom('assets')
.select(columns.syncAsset)
.select('assets.updateId')
.where('ownerId', '=', partnerId)
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('updateId', '<=', beforeUpdateId)
@ -122,6 +130,7 @@ export class SyncRepository {
return this.db
.selectFrom('assets')
.select(columns.syncAsset)
.select('assets.updateId')
.where('ownerId', 'in', (eb) =>
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
)
@ -156,6 +165,7 @@ export class SyncRepository {
return this.db
.selectFrom('exif')
.select(columns.syncAssetExif)
.select('exif.updateId')
.where('assetId', 'in', (eb) => eb.selectFrom('assets').select('id').where('ownerId', '=', userId))
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
@ -166,6 +176,7 @@ export class SyncRepository {
return this.db
.selectFrom('exif')
.select(columns.syncAssetExif)
.select('exif.updateId')
.innerJoin('assets', 'assets.id', 'exif.assetId')
.where('assets.ownerId', '=', partnerId)
.where('exif.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
@ -180,6 +191,7 @@ export class SyncRepository {
return this.db
.selectFrom('exif')
.select(columns.syncAssetExif)
.select('exif.updateId')
.where('assetId', 'in', (eb) =>
eb
.selectFrom('assets')
@ -227,6 +239,33 @@ export class SyncRepository {
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumToAssetDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('album_assets_audit')
.select(['id', 'assetId', 'albumId'])
.where((eb) =>
eb(
'albumId',
'in',
eb
.selectFrom('albums')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('albums_shared_users_users as albumUsers')
.select(['albumUsers.albumsId as id'])
.where('albumUsers.usersId', '=', userId),
),
),
),
)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumUserDeletes(userId: string, ack?: SyncAck) {
return this.db
@ -266,11 +305,12 @@ export class SyncRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getAlbumUsersBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('albums_shared_users_users')
.selectFrom('albums_shared_users_users as album_users')
.select(columns.syncAlbumUser)
.select('album_users.updateId')
.where('albumsId', '=', albumId)
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('updateId', '<=', beforeUpdateId)
@ -282,14 +322,15 @@ export class SyncRepository {
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumUserUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('albums_shared_users_users')
.selectFrom('albums_shared_users_users as album_users')
.select(columns.syncAlbumUser)
.where('albums_shared_users_users.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('albums_shared_users_users.updateId', '>', ack!.updateId))
.orderBy('albums_shared_users_users.updateId', 'asc')
.select('album_users.updateId')
.where('album_users.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('album_users.updateId', '>', ack!.updateId))
.orderBy('album_users.updateId', 'asc')
.where((eb) =>
eb(
'albums_shared_users_users.albumsId',
'album_users.albumsId',
'in',
eb
.selectFrom('albums')
@ -308,6 +349,95 @@ export class SyncRepository {
.stream();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getAlbumAssetsBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('assets')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id')
.select(columns.syncAsset)
.select('assets.updateId')
.where('album_assets.albumsId', '=', albumId)
.where('assets.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('assets.updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('assets.updateId', '>=', afterUpdateId!))
.orderBy('assets.updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumAssetsUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('assets')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id')
.select(columns.syncAsset)
.select('assets.updateId')
.where('assets.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('assets.updateId', '>', ack!.updateId))
.orderBy('assets.updateId', 'asc')
.innerJoin('albums', 'albums.id', 'album_assets.albumsId')
.leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'album_assets.albumsId')
.where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)]))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getAlbumToAssetBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('albums_assets_assets as album_assets')
.select(['album_assets.assetsId as assetId', 'album_assets.albumsId as albumId', 'album_assets.updateId'])
.where('album_assets.albumsId', '=', albumId)
.where('album_assets.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('album_assets.updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('album_assets.updateId', '>=', afterUpdateId!))
.orderBy('album_assets.updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumToAssetUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('albums_assets_assets as album_assets')
.select(['album_assets.assetsId as assetId', 'album_assets.albumsId as albumId', 'album_assets.updateId'])
.where('album_assets.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('album_assets.updateId', '>', ack!.updateId))
.orderBy('album_assets.updateId', 'asc')
.innerJoin('albums', 'albums.id', 'album_assets.albumsId')
.leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'album_assets.albumsId')
.where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)]))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getAlbumAssetExifsBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db
.selectFrom('exif')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'exif.assetId')
.select(columns.syncAssetExif)
.select('exif.updateId')
.where('album_assets.albumsId', '=', albumId)
.where('exif.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('exif.updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('exif.updateId', '>=', afterUpdateId!))
.orderBy('exif.updateId', 'asc')
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getAlbumAssetExifsUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('exif')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'exif.assetId')
.select(columns.syncAssetExif)
.select('exif.updateId')
.where('exif.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.$if(!!ack, (qb) => qb.where('exif.updateId', '>', ack!.updateId))
.orderBy('exif.updateId', 'asc')
.innerJoin('albums', 'albums.id', 'album_assets.albumsId')
.leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'album_assets.albumsId')
.where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)]))
.stream();
}
private auditTableFilters<T extends keyof Pick<DB, AuditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) {
const builder = qb as SelectQueryBuilder<DB, AuditTables, D>;
return builder

View File

@ -142,6 +142,20 @@ export const albums_delete_audit = registerFunction({
synchronize: false,
});
export const album_assets_delete_audit = registerFunction({
name: 'album_assets_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO album_assets_audit ("albumId", "assetId")
SELECT "albumsId", "assetsId" FROM OLD
WHERE "albumsId" IN (SELECT "id" FROM albums WHERE "id" IN (SELECT "albumsId" FROM OLD));
RETURN NULL;
END`,
synchronize: false,
});
export const album_users_delete_audit = registerFunction({
name: 'album_users_delete_audit',
returnType: 'TRIGGER',

View File

@ -13,6 +13,7 @@ import {
users_delete_audit,
} from 'src/schema/functions';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumAssetAuditTable } from 'src/schema/tables/album-asset-audit.table';
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
import { AlbumAuditTable } from 'src/schema/tables/album-audit.table';
import { AlbumUserAuditTable } from 'src/schema/tables/album-user-audit.table';
@ -58,6 +59,7 @@ export class ImmichDatabase {
tables = [
ActivityTable,
AlbumAssetTable,
AlbumAssetAuditTable,
AlbumAuditTable,
AlbumUserAuditTable,
AlbumUserTable,

View File

@ -0,0 +1,18 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "albums_assets_assets" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`ALTER TABLE "albums_assets_assets" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`CREATE INDEX "IDX_album_assets_update_id" ON "albums_assets_assets" ("updateId")`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_assets_updated_at"
BEFORE UPDATE ON "albums_assets_assets"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_album_assets_update_id";`.execute(db);
await sql`ALTER TABLE "albums_assets_assets" DROP COLUMN "updatedAt";`.execute(db);
await sql`ALTER TABLE "albums_assets_assets" DROP COLUMN "updateId";`.execute(db);
await sql`DROP TRIGGER "album_assets_updated_at" ON "albums_assets_assets";`.execute(db);
}

View File

@ -0,0 +1,22 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "album_assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "assetId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
await sql`ALTER TABLE "album_assets_audit" ADD CONSTRAINT "PK_32969b576ec8f78d84f37c2eb2d" PRIMARY KEY ("id");`.execute(db);
await sql`CREATE INDEX "IDX_album_assets_audit_album_id" ON "album_assets_audit" ("albumId")`.execute(db);
await sql`CREATE INDEX "IDX_album_assets_audit_asset_id" ON "album_assets_audit" ("assetId")`.execute(db);
await sql`CREATE INDEX "IDX_album_assets_audit_deleted_at" ON "album_assets_audit" ("deletedAt")`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_assets_updated_at"
BEFORE UPDATE ON "albums_assets_assets"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "album_assets_updated_at" ON "albums_assets_assets";`.execute(db);
await sql`DROP INDEX "IDX_album_assets_audit_album_id";`.execute(db);
await sql`DROP INDEX "IDX_album_assets_audit_asset_id";`.execute(db);
await sql`DROP INDEX "IDX_album_assets_audit_deleted_at";`.execute(db);
await sql`ALTER TABLE "album_assets_audit" DROP CONSTRAINT "PK_32969b576ec8f78d84f37c2eb2d";`.execute(db);
await sql`DROP TABLE "album_assets_audit";`.execute(db);
}

View File

@ -0,0 +1,28 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION album_assets_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO album_assets_audit ("albumId", "assetId")
SELECT "albumsId", "assetsId" FROM OLD
WHERE "albumsId" IN (SELECT "id" FROM albums WHERE "id" IN (SELECT "albumsId" FROM OLD));
RETURN NULL;
END
$$;`.execute(db);
await sql`ALTER TABLE "album_assets_audit" ADD CONSTRAINT "FK_8047b44b812619a3c75a2839b0d" FOREIGN KEY ("albumId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_assets_delete_audit"
AFTER DELETE ON "albums_assets_assets"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION album_assets_delete_audit();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "album_assets_delete_audit" ON "albums_assets_assets";`.execute(db);
await sql`ALTER TABLE "album_assets_audit" DROP CONSTRAINT "FK_8047b44b812619a3c75a2839b0d";`.execute(db);
await sql`DROP FUNCTION album_assets_delete_audit;`.execute(db);
}

View File

@ -0,0 +1,23 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { AlbumTable } from 'src/schema/tables/album.table';
import { Column, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('album_assets_audit')
export class AlbumAssetAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: string;
@ForeignKeyColumn(() => AlbumTable, {
type: 'uuid',
indexName: 'IDX_album_assets_audit_album_id',
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
albumId!: string;
@Column({ type: 'uuid', indexName: 'IDX_album_assets_audit_asset_id' })
assetId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_album_assets_audit_deleted_at' })
deletedAt!: Date;
}

View File

@ -1,8 +1,18 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { album_assets_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
import { AfterDeleteTrigger, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools';
@Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' })
@UpdatedAtTrigger('album_assets_updated_at')
@AfterDeleteTrigger({
name: 'album_assets_delete_audit',
scope: 'statement',
function: album_assets_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
export class AlbumAssetTable {
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
albumsId!: string;
@ -12,4 +22,10 @@ export class AlbumAssetTable {
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@UpdateIdColumn({ indexName: 'IDX_album_assets_update_id' })
updateId!: string;
}

View File

@ -57,11 +57,14 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.UsersV1,
SyncRequestType.PartnersV1,
SyncRequestType.AssetsV1,
SyncRequestType.AssetExifsV1,
SyncRequestType.PartnerAssetsV1,
SyncRequestType.PartnerAssetExifsV1,
SyncRequestType.AlbumsV1,
SyncRequestType.AlbumUsersV1,
SyncRequestType.AlbumAssetsV1,
SyncRequestType.AlbumToAssetsV1,
SyncRequestType.AssetExifsV1,
SyncRequestType.AlbumAssetExifsV1,
SyncRequestType.PartnerAssetExifsV1,
];
const throwSessionRequired = () => {
@ -164,6 +167,21 @@ export class SyncService extends BaseService {
break;
}
case SyncRequestType.AlbumAssetsV1: {
await this.syncAlbumAssetsV1(response, checkpointMap, auth, sessionId);
break;
}
case SyncRequestType.AlbumToAssetsV1: {
await this.syncAlbumToAssetsV1(response, checkpointMap, auth, sessionId);
break;
}
case SyncRequestType.AlbumAssetExifsV1: {
await this.syncAlbumAssetExifsV1(response, checkpointMap, auth, sessionId);
break;
}
default: {
this.logger.warn(`Unsupported sync type: ${type}`);
break;
@ -380,6 +398,147 @@ export class SyncService extends BaseService {
}
}
private async syncAlbumAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) {
const backfillType = SyncEntityType.AlbumAssetBackfillV1;
const upsertType = SyncEntityType.AlbumAssetV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
for (const album of albums) {
const createId = album.createId;
if (isEntityBackfillComplete(createId, backfillCheckpoint)) {
continue;
}
const startId = getStartId(createId, backfillCheckpoint);
const backfill = this.syncRepository.getAlbumAssetsBackfill(album.id, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV1(data) });
}
sendEntityBackfillCompleteAck(response, backfillType, createId);
}
} else if (albums.length > 0) {
await this.upsertBackfillCheckpoint({
type: backfillType,
sessionId,
createId: albums.at(-1)!.createId,
});
}
const upserts = this.syncRepository.getAlbumAssetsUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) });
}
}
private async syncAlbumAssetExifsV1(
response: Writable,
checkpointMap: CheckpointMap,
auth: AuthDto,
sessionId: string,
) {
const backfillType = SyncEntityType.AlbumAssetExifBackfillV1;
const upsertType = SyncEntityType.AlbumAssetExifV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
for (const album of albums) {
const createId = album.createId;
if (isEntityBackfillComplete(createId, backfillCheckpoint)) {
continue;
}
const startId = getStartId(createId, backfillCheckpoint);
const backfill = this.syncRepository.getAlbumAssetExifsBackfill(album.id, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [createId, updateId], data });
}
sendEntityBackfillCompleteAck(response, backfillType, createId);
}
} else if (albums.length > 0) {
await this.upsertBackfillCheckpoint({
type: backfillType,
sessionId,
createId: albums.at(-1)!.createId,
});
}
const upserts = this.syncRepository.getAlbumAssetExifsUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncAlbumToAssetsV1(
response: Writable,
checkpointMap: CheckpointMap,
auth: AuthDto,
sessionId: string,
) {
const backfillType = SyncEntityType.AlbumToAssetBackfillV1;
const upsertType = SyncEntityType.AlbumToAssetV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const deletes = this.syncRepository.getAlbumToAssetDeletes(
auth.user.id,
checkpointMap[SyncEntityType.AlbumToAssetDeleteV1],
);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.AlbumToAssetDeleteV1, ids: [id], data });
}
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
for (const album of albums) {
const createId = album.createId;
if (isEntityBackfillComplete(createId, backfillCheckpoint)) {
continue;
}
const startId = getStartId(createId, backfillCheckpoint);
const backfill = this.syncRepository.getAlbumToAssetBackfill(album.id, startId, endId);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [createId, updateId], data });
}
sendEntityBackfillCompleteAck(response, backfillType, createId);
}
} else if (albums.length > 0) {
await this.upsertBackfillCheckpoint({
type: backfillType,
sessionId,
createId: albums.at(-1)!.createId,
});
}
const upserts = this.syncRepository.getAlbumToAssetUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
const { type, sessionId, createId } = item;
await this.syncRepository.upsertCheckpoints([

View File

@ -0,0 +1,222 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncRequestType.AlbumAssetExifsV1, () => {
it('should detect and sync the first album asset exif', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
await albumRepo.create({ ownerId: user2.id }, [asset.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
assetId: asset.id,
city: null,
country: null,
dateTimeOriginal: null,
description: '',
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: 'Canon',
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
},
type: SyncEntityType.AlbumAssetExifV1,
},
]),
);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(ackSyncResponse).toEqual([]);
});
it('should sync album asset exif for own user', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
await albumRepo.create({ ownerId: auth.user.id }, [asset.id], []);
await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toHaveLength(1);
});
it('should not sync album asset exif for unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
await albumRepo.create({ ownerId: user2.id }, [asset.id], [{ userId: user3.id, role: AlbumUserRole.EDITOR }]);
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user3.id });
await sessionRepo.create(session);
const authUser3 = factory.auth({ session, user: user3 });
await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
});
it('should backfill album assets exif when a user shares an album with you', async () => {
const { auth, sut, testSync, getRepository } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
// Asset to check that we do backfill our own assets
const asset1Owner = mediumFactory.assetInsert({ ownerId: auth.user.id });
const asset1User2 = mediumFactory.assetInsert({ ownerId: user2.id });
const asset2User2 = mediumFactory.assetInsert({ ownerId: user2.id });
const asset3User2 = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset1Owner);
await assetRepo.upsertExif({ assetId: asset1Owner.id, make: 'asset1Owner' });
await wait(2);
await assetRepo.create(asset1User2);
await assetRepo.upsertExif({ assetId: asset1User2.id, make: 'asset1User2' });
await wait(2);
await assetRepo.create(asset2User2);
await assetRepo.upsertExif({ assetId: asset2User2.id, make: 'asset2User2' });
await wait(2);
await assetRepo.create(asset3User2);
await assetRepo.upsertExif({ assetId: asset3User2.id, make: 'asset3User2' });
const albumRepo = getRepository('album');
const album1 = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album1, [asset2User2.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const response = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetExifV1,
},
]);
// ack initial album asset exif sync
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
// create a second album with
const album2 = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(
album2,
[asset1User2.id, asset2User2.id, asset3User2.id, asset1Owner.id],
[{ userId: auth.user.id, role: AlbumUserRole.EDITOR }],
);
// should backfill the album user
const backfillResponse = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(backfillResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset1Owner.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset1User2.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetExifBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumAssetExifBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
assetId: asset3User2.id,
}),
type: SyncEntityType.AlbumAssetExifV1,
},
]);
await sut.setAcks(auth, { acks: [backfillResponse[3].ack, backfillResponse.at(-1).ack] });
const finalResponse = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]);
expect(finalResponse).toEqual([]);
});
});

View File

@ -0,0 +1,218 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncRequestType.AlbumAssetsV1, () => {
it('should detect and sync the first album asset', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const originalFileName = 'firstAsset';
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
const date = new Date().toISOString();
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({
originalFileName,
ownerId: user2.id,
checksum: Buffer.from(checksum, 'base64'),
thumbhash: Buffer.from(thumbhash, 'base64'),
fileCreatedAt: date,
fileModifiedAt: date,
localDateTime: date,
deletedAt: null,
duration: '0:10:00.00000',
});
await assetRepo.create(asset);
await albumRepo.create({ ownerId: user2.id }, [asset.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumAssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
id: asset.id,
originalFileName,
ownerId: asset.ownerId,
thumbhash,
checksum,
deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime,
type: asset.type,
visibility: asset.visibility,
duration: asset.duration,
},
type: SyncEntityType.AlbumAssetV1,
},
]),
);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumAssetsV1]);
expect(ackSyncResponse).toEqual([]);
});
it('should sync album asset for own user', async () => {
const { auth, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
await albumRepo.create({ ownerId: auth.user.id }, [asset.id], []);
await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(1);
});
it('should not sync album asset for unrelated user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user3.id });
await assetRepo.create(asset);
await albumRepo.create({ ownerId: user2.id }, [asset.id], [{ userId: user3.id, role: AlbumUserRole.EDITOR }]);
const sessionRepo = getRepository('session');
const session = mediumFactory.sessionInsert({ userId: user3.id });
await sessionRepo.create(session);
const authUser3 = factory.auth({ session, user: user3 });
await expect(testSync(authUser3, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
});
it('should backfill album assets when a user shares an album with you', async () => {
const { auth, sut, testSync, getRepository } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
const user3 = mediumFactory.userInsert();
await userRepo.create(user2);
await userRepo.create(user3);
const assetRepo = getRepository('asset');
// Asset to check that we do backfill our own assets
const asset1Owner = mediumFactory.assetInsert({ ownerId: auth.user.id });
const asset1User2 = mediumFactory.assetInsert({ ownerId: user2.id });
const asset2User2 = mediumFactory.assetInsert({ ownerId: user2.id });
const asset3User2 = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset1Owner);
await wait(2);
await assetRepo.create(asset1User2);
await wait(2);
await assetRepo.create(asset2User2);
await wait(2);
await assetRepo.create(asset3User2);
const albumRepo = getRepository('album');
const album1 = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album1, [asset2User2.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const response = await testSync(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetV1,
},
]);
// ack initial album asset sync
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
// create a second album with
const album2 = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(
album2,
[asset1User2.id, asset2User2.id, asset3User2.id, asset1Owner.id],
[{ userId: auth.user.id, role: AlbumUserRole.EDITOR }],
);
// should backfill the album user
const backfillResponse = await testSync(auth, [SyncRequestType.AlbumAssetsV1]);
expect(backfillResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset1Owner.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset1User2.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset2User2.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset3User2.id,
}),
type: SyncEntityType.AlbumAssetV1,
},
]);
await sut.setAcks(auth, { acks: [backfillResponse[3].ack, backfillResponse.at(-1).ack] });
const finalResponse = await testSync(auth, [SyncRequestType.AlbumAssetsV1]);
expect(finalResponse).toEqual([]);
});
});

View File

@ -0,0 +1,350 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
import { getKyselyDB, wait } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const database = db || defaultDatabase;
const result = newSyncTest({ db: database });
const { auth, create } = newSyncAuthUser();
await create(database);
return { ...result, auth };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe.concurrent(SyncRequestType.AlbumToAssetsV1, () => {
it('should detect and sync the first album to asset relation', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [asset.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]),
);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(ackSyncResponse).toEqual([]);
});
it('should sync album to asset for owned albums', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [asset.id], []);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]),
);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(ackSyncResponse).toEqual([]);
});
it('should detect and sync the album to asset for shared albums', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
const album = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album, [asset.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]),
);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(ackSyncResponse).toEqual([]);
});
it('should not sync album to asset for an album owned by another user', async () => {
const { auth, getRepository, testSync } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(asset);
await albumRepo.create({ ownerId: user2.id }, [asset.id], []);
await expect(testSync(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toHaveLength(0);
});
it('should backfill album to assets when a user shares an album with you', async () => {
const { auth, sut, testSync, getRepository } = await setup();
const userRepo = getRepository('user');
const user2 = mediumFactory.userInsert();
await userRepo.create(user2);
const assetRepo = getRepository('asset');
const album1Asset = mediumFactory.assetInsert({ ownerId: user2.id });
await assetRepo.create(album1Asset);
const album2Asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(album2Asset);
// Backfill album
const albumRepo = getRepository('album');
const album2 = mediumFactory.albumInsert({ ownerId: user2.id });
await albumRepo.create(album2, [album2Asset.id], []);
await wait(2);
const album1 = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album1, [album1Asset.id], []);
const response = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
albumId: album1.id,
assetId: album1Asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
// ack initial album to asset sync
const acks = response.map(({ ack }) => ack);
await sut.setAcks(auth, { acks });
// add user to backfill album
const albumUserRepo = getRepository('albumUser');
await albumUserRepo.create({ albumsId: album2.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR });
// should backfill the album to asset relation
const backfillResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(backfillResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album2.id,
assetId: album2Asset.id,
}),
type: SyncEntityType.AlbumToAssetBackfillV1,
},
{
ack: expect.stringContaining(SyncEntityType.AlbumToAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]);
await sut.setAcks(auth, { acks: [backfillResponse[1].ack] });
const finalResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(finalResponse).toEqual([]);
});
it('should detect and sync a deleted album to asset relation', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [asset.id], []);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await albumRepo.removeAssetIds(album.id, [asset.id]);
await wait(2);
const syncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(syncResponse).toHaveLength(1);
expect(syncResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetDeleteV1,
},
]);
await sut.setAcks(auth, { acks: [syncResponse[0].ack] });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(ackSyncResponse).toEqual([]);
});
it('should detect and sync a deleted album to asset relation when an asset is deleted', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [asset.id], []);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await assetRepo.remove({ id: asset.id });
await wait(2);
const syncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(syncResponse).toHaveLength(1);
expect(syncResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetDeleteV1,
},
]);
await sut.setAcks(auth, { acks: [syncResponse[0].ack] });
const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(ackSyncResponse).toEqual([]);
});
it('should not sync a deleted album to asset relation when the album is deleted', async () => {
const { auth, sut, getRepository, testSync } = await setup();
const albumRepo = getRepository('album');
const assetRepo = getRepository('asset');
const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
await assetRepo.create(asset);
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
await albumRepo.create(album, [asset.id], []);
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(initialSyncResponse).toHaveLength(1);
expect(initialSyncResponse).toEqual([
{
ack: expect.any(String),
data: {
albumId: album.id,
assetId: asset.id,
},
type: SyncEntityType.AlbumToAssetV1,
},
]);
const acks = [initialSyncResponse[0].ack];
await sut.setAcks(auth, { acks });
await albumRepo.delete(album.id);
await wait(2);
const syncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(syncResponse).toHaveLength(0);
expect(syncResponse).toEqual([]);
});
});