diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 2c5dea7f19..d2cae47fb5 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -443,6 +443,10 @@ Class | Method | HTTP request | Description
  - [SyncAckDeleteDto](doc//SyncAckDeleteDto.md)
  - [SyncAckDto](doc//SyncAckDto.md)
  - [SyncAckSetDto](doc//SyncAckSetDto.md)
+ - [SyncAlbumDeleteV1](doc//SyncAlbumDeleteV1.md)
+ - [SyncAlbumUserDeleteV1](doc//SyncAlbumUserDeleteV1.md)
+ - [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
+ - [SyncAlbumV1](doc//SyncAlbumV1.md)
  - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
  - [SyncAssetExifV1](doc//SyncAssetExifV1.md)
  - [SyncAssetV1](doc//SyncAssetV1.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 541614ca55..aa8ae348aa 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -238,6 +238,10 @@ part 'model/stack_update_dto.dart';
 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_user_delete_v1.dart';
+part 'model/sync_album_user_v1.dart';
+part 'model/sync_album_v1.dart';
 part 'model/sync_asset_delete_v1.dart';
 part 'model/sync_asset_exif_v1.dart';
 part 'model/sync_asset_v1.dart';
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 540dc11300..a1240c800c 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -532,6 +532,14 @@ class ApiClient {
           return SyncAckDto.fromJson(value);
         case 'SyncAckSetDto':
           return SyncAckSetDto.fromJson(value);
+        case 'SyncAlbumDeleteV1':
+          return SyncAlbumDeleteV1.fromJson(value);
+        case 'SyncAlbumUserDeleteV1':
+          return SyncAlbumUserDeleteV1.fromJson(value);
+        case 'SyncAlbumUserV1':
+          return SyncAlbumUserV1.fromJson(value);
+        case 'SyncAlbumV1':
+          return SyncAlbumV1.fromJson(value);
         case 'SyncAssetDeleteV1':
           return SyncAssetDeleteV1.fromJson(value);
         case 'SyncAssetExifV1':
diff --git a/mobile/openapi/lib/model/sync_album_delete_v1.dart b/mobile/openapi/lib/model/sync_album_delete_v1.dart
new file mode 100644
index 0000000000..ae5ba3da5d
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_album_delete_v1.dart
@@ -0,0 +1,99 @@
+//
+// 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 SyncAlbumDeleteV1 {
+  /// Returns a new [SyncAlbumDeleteV1] instance.
+  SyncAlbumDeleteV1({
+    required this.albumId,
+  });
+
+  String albumId;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SyncAlbumDeleteV1 &&
+    other.albumId == albumId;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (albumId.hashCode);
+
+  @override
+  String toString() => 'SyncAlbumDeleteV1[albumId=$albumId]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'albumId'] = this.albumId;
+    return json;
+  }
+
+  /// Returns a new [SyncAlbumDeleteV1] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SyncAlbumDeleteV1? fromJson(dynamic value) {
+    upgradeDto(value, "SyncAlbumDeleteV1");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return SyncAlbumDeleteV1(
+        albumId: mapValueOfType<String>(json, r'albumId')!,
+      );
+    }
+    return null;
+  }
+
+  static List<SyncAlbumDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SyncAlbumDeleteV1>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SyncAlbumDeleteV1.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SyncAlbumDeleteV1> mapFromJson(dynamic json) {
+    final map = <String, SyncAlbumDeleteV1>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SyncAlbumDeleteV1.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SyncAlbumDeleteV1-objects as value to a dart map
+  static Map<String, List<SyncAlbumDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SyncAlbumDeleteV1>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = SyncAlbumDeleteV1.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'albumId',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/sync_album_user_delete_v1.dart b/mobile/openapi/lib/model/sync_album_user_delete_v1.dart
new file mode 100644
index 0000000000..f2b0fbee26
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_album_user_delete_v1.dart
@@ -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 SyncAlbumUserDeleteV1 {
+  /// Returns a new [SyncAlbumUserDeleteV1] instance.
+  SyncAlbumUserDeleteV1({
+    required this.albumId,
+    required this.userId,
+  });
+
+  String albumId;
+
+  String userId;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserDeleteV1 &&
+    other.albumId == albumId &&
+    other.userId == userId;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (albumId.hashCode) +
+    (userId.hashCode);
+
+  @override
+  String toString() => 'SyncAlbumUserDeleteV1[albumId=$albumId, userId=$userId]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'albumId'] = this.albumId;
+      json[r'userId'] = this.userId;
+    return json;
+  }
+
+  /// Returns a new [SyncAlbumUserDeleteV1] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SyncAlbumUserDeleteV1? fromJson(dynamic value) {
+    upgradeDto(value, "SyncAlbumUserDeleteV1");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return SyncAlbumUserDeleteV1(
+        albumId: mapValueOfType<String>(json, r'albumId')!,
+        userId: mapValueOfType<String>(json, r'userId')!,
+      );
+    }
+    return null;
+  }
+
+  static List<SyncAlbumUserDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SyncAlbumUserDeleteV1>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SyncAlbumUserDeleteV1.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SyncAlbumUserDeleteV1> mapFromJson(dynamic json) {
+    final map = <String, SyncAlbumUserDeleteV1>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SyncAlbumUserDeleteV1.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SyncAlbumUserDeleteV1-objects as value to a dart map
+  static Map<String, List<SyncAlbumUserDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SyncAlbumUserDeleteV1>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = SyncAlbumUserDeleteV1.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'albumId',
+    'userId',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/sync_album_user_v1.dart b/mobile/openapi/lib/model/sync_album_user_v1.dart
new file mode 100644
index 0000000000..c2b8ed7f48
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_album_user_v1.dart
@@ -0,0 +1,189 @@
+//
+// 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 SyncAlbumUserV1 {
+  /// Returns a new [SyncAlbumUserV1] instance.
+  SyncAlbumUserV1({
+    required this.albumId,
+    required this.role,
+    required this.userId,
+  });
+
+  String albumId;
+
+  SyncAlbumUserV1RoleEnum role;
+
+  String userId;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserV1 &&
+    other.albumId == albumId &&
+    other.role == role &&
+    other.userId == userId;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (albumId.hashCode) +
+    (role.hashCode) +
+    (userId.hashCode);
+
+  @override
+  String toString() => 'SyncAlbumUserV1[albumId=$albumId, role=$role, userId=$userId]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'albumId'] = this.albumId;
+      json[r'role'] = this.role;
+      json[r'userId'] = this.userId;
+    return json;
+  }
+
+  /// Returns a new [SyncAlbumUserV1] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SyncAlbumUserV1? fromJson(dynamic value) {
+    upgradeDto(value, "SyncAlbumUserV1");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return SyncAlbumUserV1(
+        albumId: mapValueOfType<String>(json, r'albumId')!,
+        role: SyncAlbumUserV1RoleEnum.fromJson(json[r'role'])!,
+        userId: mapValueOfType<String>(json, r'userId')!,
+      );
+    }
+    return null;
+  }
+
+  static List<SyncAlbumUserV1> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SyncAlbumUserV1>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SyncAlbumUserV1.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SyncAlbumUserV1> mapFromJson(dynamic json) {
+    final map = <String, SyncAlbumUserV1>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SyncAlbumUserV1.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SyncAlbumUserV1-objects as value to a dart map
+  static Map<String, List<SyncAlbumUserV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SyncAlbumUserV1>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = SyncAlbumUserV1.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'albumId',
+    'role',
+    'userId',
+  };
+}
+
+
+class SyncAlbumUserV1RoleEnum {
+  /// Instantiate a new enum with the provided [value].
+  const SyncAlbumUserV1RoleEnum._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const editor = SyncAlbumUserV1RoleEnum._(r'editor');
+  static const viewer = SyncAlbumUserV1RoleEnum._(r'viewer');
+
+  /// List of all possible values in this [enum][SyncAlbumUserV1RoleEnum].
+  static const values = <SyncAlbumUserV1RoleEnum>[
+    editor,
+    viewer,
+  ];
+
+  static SyncAlbumUserV1RoleEnum? fromJson(dynamic value) => SyncAlbumUserV1RoleEnumTypeTransformer().decode(value);
+
+  static List<SyncAlbumUserV1RoleEnum> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SyncAlbumUserV1RoleEnum>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SyncAlbumUserV1RoleEnum.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [SyncAlbumUserV1RoleEnum] to String,
+/// and [decode] dynamic data back to [SyncAlbumUserV1RoleEnum].
+class SyncAlbumUserV1RoleEnumTypeTransformer {
+  factory SyncAlbumUserV1RoleEnumTypeTransformer() => _instance ??= const SyncAlbumUserV1RoleEnumTypeTransformer._();
+
+  const SyncAlbumUserV1RoleEnumTypeTransformer._();
+
+  String encode(SyncAlbumUserV1RoleEnum data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a SyncAlbumUserV1RoleEnum.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  SyncAlbumUserV1RoleEnum? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'editor': return SyncAlbumUserV1RoleEnum.editor;
+        case r'viewer': return SyncAlbumUserV1RoleEnum.viewer;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [SyncAlbumUserV1RoleEnumTypeTransformer] instance.
+  static SyncAlbumUserV1RoleEnumTypeTransformer? _instance;
+}
+
+
diff --git a/mobile/openapi/lib/model/sync_album_v1.dart b/mobile/openapi/lib/model/sync_album_v1.dart
new file mode 100644
index 0000000000..8ac8246d46
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_album_v1.dart
@@ -0,0 +1,167 @@
+//
+// 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 SyncAlbumV1 {
+  /// Returns a new [SyncAlbumV1] instance.
+  SyncAlbumV1({
+    required this.createdAt,
+    required this.description,
+    required this.id,
+    required this.isActivityEnabled,
+    required this.name,
+    required this.order,
+    required this.ownerId,
+    required this.thumbnailAssetId,
+    required this.updatedAt,
+  });
+
+  DateTime createdAt;
+
+  String description;
+
+  String id;
+
+  bool isActivityEnabled;
+
+  String name;
+
+  AssetOrder order;
+
+  String ownerId;
+
+  String? thumbnailAssetId;
+
+  DateTime updatedAt;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SyncAlbumV1 &&
+    other.createdAt == createdAt &&
+    other.description == description &&
+    other.id == id &&
+    other.isActivityEnabled == isActivityEnabled &&
+    other.name == name &&
+    other.order == order &&
+    other.ownerId == ownerId &&
+    other.thumbnailAssetId == thumbnailAssetId &&
+    other.updatedAt == updatedAt;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (createdAt.hashCode) +
+    (description.hashCode) +
+    (id.hashCode) +
+    (isActivityEnabled.hashCode) +
+    (name.hashCode) +
+    (order.hashCode) +
+    (ownerId.hashCode) +
+    (thumbnailAssetId == null ? 0 : thumbnailAssetId!.hashCode) +
+    (updatedAt.hashCode);
+
+  @override
+  String toString() => 'SyncAlbumV1[createdAt=$createdAt, description=$description, id=$id, isActivityEnabled=$isActivityEnabled, name=$name, order=$order, ownerId=$ownerId, thumbnailAssetId=$thumbnailAssetId, updatedAt=$updatedAt]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
+      json[r'description'] = this.description;
+      json[r'id'] = this.id;
+      json[r'isActivityEnabled'] = this.isActivityEnabled;
+      json[r'name'] = this.name;
+      json[r'order'] = this.order;
+      json[r'ownerId'] = this.ownerId;
+    if (this.thumbnailAssetId != null) {
+      json[r'thumbnailAssetId'] = this.thumbnailAssetId;
+    } else {
+    //  json[r'thumbnailAssetId'] = null;
+    }
+      json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
+    return json;
+  }
+
+  /// Returns a new [SyncAlbumV1] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SyncAlbumV1? fromJson(dynamic value) {
+    upgradeDto(value, "SyncAlbumV1");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return SyncAlbumV1(
+        createdAt: mapDateTime(json, r'createdAt', r'')!,
+        description: mapValueOfType<String>(json, r'description')!,
+        id: mapValueOfType<String>(json, r'id')!,
+        isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
+        name: mapValueOfType<String>(json, r'name')!,
+        order: AssetOrder.fromJson(json[r'order'])!,
+        ownerId: mapValueOfType<String>(json, r'ownerId')!,
+        thumbnailAssetId: mapValueOfType<String>(json, r'thumbnailAssetId'),
+        updatedAt: mapDateTime(json, r'updatedAt', r'')!,
+      );
+    }
+    return null;
+  }
+
+  static List<SyncAlbumV1> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SyncAlbumV1>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SyncAlbumV1.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SyncAlbumV1> mapFromJson(dynamic json) {
+    final map = <String, SyncAlbumV1>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SyncAlbumV1.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SyncAlbumV1-objects as value to a dart map
+  static Map<String, List<SyncAlbumV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SyncAlbumV1>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = SyncAlbumV1.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'createdAt',
+    'description',
+    'id',
+    'isActivityEnabled',
+    'name',
+    'order',
+    'ownerId',
+    'thumbnailAssetId',
+    'updatedAt',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart
index 5e52a10e7a..600371545a 100644
--- a/mobile/openapi/lib/model/sync_entity_type.dart
+++ b/mobile/openapi/lib/model/sync_entity_type.dart
@@ -33,6 +33,10 @@ class SyncEntityType {
   static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
   static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1');
   static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1');
+  static const albumV1 = SyncEntityType._(r'AlbumV1');
+  static const albumDeleteV1 = SyncEntityType._(r'AlbumDeleteV1');
+  static const albumUserV1 = SyncEntityType._(r'AlbumUserV1');
+  static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1');
 
   /// List of all possible values in this [enum][SyncEntityType].
   static const values = <SyncEntityType>[
@@ -46,6 +50,10 @@ class SyncEntityType {
     partnerAssetV1,
     partnerAssetDeleteV1,
     partnerAssetExifV1,
+    albumV1,
+    albumDeleteV1,
+    albumUserV1,
+    albumUserDeleteV1,
   ];
 
   static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value);
@@ -94,6 +102,10 @@ class SyncEntityTypeTypeTransformer {
         case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;
         case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1;
         case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1;
+        case r'AlbumV1': return SyncEntityType.albumV1;
+        case r'AlbumDeleteV1': return SyncEntityType.albumDeleteV1;
+        case r'AlbumUserV1': return SyncEntityType.albumUserV1;
+        case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1;
         default:
           if (!allowNull) {
             throw ArgumentError('Unknown enum value to decode: $data');
diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart
index 08f977ad57..c149c329de 100644
--- a/mobile/openapi/lib/model/sync_request_type.dart
+++ b/mobile/openapi/lib/model/sync_request_type.dart
@@ -29,6 +29,8 @@ class SyncRequestType {
   static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
   static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1');
   static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1');
+  static const albumsV1 = SyncRequestType._(r'AlbumsV1');
+  static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1');
 
   /// List of all possible values in this [enum][SyncRequestType].
   static const values = <SyncRequestType>[
@@ -38,6 +40,8 @@ class SyncRequestType {
     assetExifsV1,
     partnerAssetsV1,
     partnerAssetExifsV1,
+    albumsV1,
+    albumUsersV1,
   ];
 
   static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value);
@@ -82,6 +86,8 @@ class SyncRequestTypeTypeTransformer {
         case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
         case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1;
         case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1;
+        case r'AlbumsV1': return SyncRequestType.albumsV1;
+        case r'AlbumUsersV1': return SyncRequestType.albumUsersV1;
         default:
           if (!allowNull) {
             throw ArgumentError('Unknown enum value to decode: $data');
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 2a8555f82c..cdd1f00763 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -12710,6 +12710,105 @@
         ],
         "type": "object"
       },
+      "SyncAlbumDeleteV1": {
+        "properties": {
+          "albumId": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "albumId"
+        ],
+        "type": "object"
+      },
+      "SyncAlbumUserDeleteV1": {
+        "properties": {
+          "albumId": {
+            "type": "string"
+          },
+          "userId": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "albumId",
+          "userId"
+        ],
+        "type": "object"
+      },
+      "SyncAlbumUserV1": {
+        "properties": {
+          "albumId": {
+            "type": "string"
+          },
+          "role": {
+            "enum": [
+              "editor",
+              "viewer"
+            ],
+            "type": "string"
+          },
+          "userId": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "albumId",
+          "role",
+          "userId"
+        ],
+        "type": "object"
+      },
+      "SyncAlbumV1": {
+        "properties": {
+          "createdAt": {
+            "format": "date-time",
+            "type": "string"
+          },
+          "description": {
+            "type": "string"
+          },
+          "id": {
+            "type": "string"
+          },
+          "isActivityEnabled": {
+            "type": "boolean"
+          },
+          "name": {
+            "type": "string"
+          },
+          "order": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/AssetOrder"
+              }
+            ]
+          },
+          "ownerId": {
+            "type": "string"
+          },
+          "thumbnailAssetId": {
+            "nullable": true,
+            "type": "string"
+          },
+          "updatedAt": {
+            "format": "date-time",
+            "type": "string"
+          }
+        },
+        "required": [
+          "createdAt",
+          "description",
+          "id",
+          "isActivityEnabled",
+          "name",
+          "order",
+          "ownerId",
+          "thumbnailAssetId",
+          "updatedAt"
+        ],
+        "type": "object"
+      },
       "SyncAssetDeleteV1": {
         "properties": {
           "assetId": {
@@ -12937,7 +13036,11 @@
           "AssetExifV1",
           "PartnerAssetV1",
           "PartnerAssetDeleteV1",
-          "PartnerAssetExifV1"
+          "PartnerAssetExifV1",
+          "AlbumV1",
+          "AlbumDeleteV1",
+          "AlbumUserV1",
+          "AlbumUserDeleteV1"
         ],
         "type": "string"
       },
@@ -12982,7 +13085,9 @@
           "AssetsV1",
           "AssetExifsV1",
           "PartnerAssetsV1",
-          "PartnerAssetExifsV1"
+          "PartnerAssetExifsV1",
+          "AlbumsV1",
+          "AlbumUsersV1"
         ],
         "type": "string"
       },
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index c27c9bc194..bb1ba605a5 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -3860,7 +3860,11 @@ export enum SyncEntityType {
     AssetExifV1 = "AssetExifV1",
     PartnerAssetV1 = "PartnerAssetV1",
     PartnerAssetDeleteV1 = "PartnerAssetDeleteV1",
-    PartnerAssetExifV1 = "PartnerAssetExifV1"
+    PartnerAssetExifV1 = "PartnerAssetExifV1",
+    AlbumV1 = "AlbumV1",
+    AlbumDeleteV1 = "AlbumDeleteV1",
+    AlbumUserV1 = "AlbumUserV1",
+    AlbumUserDeleteV1 = "AlbumUserDeleteV1"
 }
 export enum SyncRequestType {
     UsersV1 = "UsersV1",
@@ -3868,7 +3872,9 @@ export enum SyncRequestType {
     AssetsV1 = "AssetsV1",
     AssetExifsV1 = "AssetExifsV1",
     PartnerAssetsV1 = "PartnerAssetsV1",
-    PartnerAssetExifsV1 = "PartnerAssetExifsV1"
+    PartnerAssetExifsV1 = "PartnerAssetExifsV1",
+    AlbumsV1 = "AlbumsV1",
+    AlbumUsersV1 = "AlbumUsersV1"
 }
 export enum TranscodeHWAccel {
     Nvenc = "nvenc",
diff --git a/server/package.json b/server/package.json
index 681fd687d0..f95817342e 100644
--- a/server/package.json
+++ b/server/package.json
@@ -23,6 +23,7 @@
     "test:medium": "vitest --config test/vitest.config.medium.mjs",
     "typeorm": "typeorm",
     "lifecycle": "node ./dist/utils/lifecycle.js",
+    "migrations:debug": "node ./dist/bin/migrations.js debug",
     "migrations:generate": "node ./dist/bin/migrations.js generate",
     "migrations:create": "node ./dist/bin/migrations.js create",
     "migrations:run": "node ./dist/bin/migrations.js run",
diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts
index 69070dc0cf..b3329e6331 100644
--- a/server/src/bin/migrations.ts
+++ b/server/src/bin/migrations.ts
@@ -125,6 +125,7 @@ const compare = async () => {
   const down = schemaDiff(target, source, {
     tables: { ignoreExtra: false },
     functions: { ignoreExtra: false },
+    extension: { ignoreMissing: true },
   });
 
   return { up, down };
diff --git a/server/src/db.d.ts b/server/src/db.d.ts
index 943c9ddfa0..af1dd964fd 100644
--- a/server/src/db.d.ts
+++ b/server/src/db.d.ts
@@ -74,6 +74,20 @@ export interface Albums {
   updateId: Generated<string>;
 }
 
+export interface AlbumsAudit {
+  deletedAt: Generated<Timestamp>;
+  id: Generated<string>;
+  albumId: string;
+  userId: string;
+}
+
+export interface AlbumUsersAudit {
+  deletedAt: Generated<Timestamp>;
+  id: Generated<string>;
+  albumId: string;
+  userId: string;
+}
+
 export interface AlbumsAssetsAssets {
   albumsId: string;
   assetsId: string;
@@ -84,6 +98,8 @@ export interface AlbumsSharedUsersUsers {
   albumsId: string;
   role: Generated<AlbumUserRole>;
   usersId: string;
+  updatedAt: Generated<Timestamp>;
+  updateId: Generated<string>;
 }
 
 export interface ApiKeys {
@@ -466,8 +482,10 @@ export interface VersionHistory {
 export interface DB {
   activity: Activity;
   albums: Albums;
+  albums_audit: AlbumsAudit;
   albums_assets_assets: AlbumsAssetsAssets;
   albums_shared_users_users: AlbumsSharedUsersUsers;
+  album_users_audit: AlbumUsersAudit;
   api_keys: ApiKeys;
   asset_faces: AssetFaces;
   asset_files: AssetFiles;
diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts
index cc11c3410b..0043cfb40b 100644
--- a/server/src/dtos/sync.dto.ts
+++ b/server/src/dtos/sync.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator';
 import { AssetResponseDto } from 'src/dtos/asset-response.dto';
-import { AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum';
+import { AlbumUserRole, AssetOrder, AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum';
 import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
 
 export class AssetFullSyncDto {
@@ -112,6 +112,34 @@ export class SyncAssetExifV1 {
   fps!: number | null;
 }
 
+export class SyncAlbumDeleteV1 {
+  albumId!: string;
+}
+
+export class SyncAlbumUserDeleteV1 {
+  albumId!: string;
+  userId!: string;
+}
+
+export class SyncAlbumUserV1 {
+  albumId!: string;
+  userId!: string;
+  role!: AlbumUserRole;
+}
+
+export class SyncAlbumV1 {
+  id!: string;
+  ownerId!: string;
+  name!: string;
+  description!: string;
+  createdAt!: Date;
+  updatedAt!: Date;
+  thumbnailAssetId!: string | null;
+  isActivityEnabled!: boolean;
+  @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
+  order!: AssetOrder;
+}
+
 export type SyncItem = {
   [SyncEntityType.UserV1]: SyncUserV1;
   [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
@@ -123,10 +151,13 @@ export type SyncItem = {
   [SyncEntityType.PartnerAssetV1]: SyncAssetV1;
   [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
   [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
+  [SyncEntityType.AlbumV1]: SyncAlbumV1;
+  [SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1;
+  [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
+  [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
 };
 
 const responseDtos = [
-  //
   SyncUserV1,
   SyncUserDeleteV1,
   SyncPartnerV1,
@@ -134,6 +165,10 @@ const responseDtos = [
   SyncAssetV1,
   SyncAssetDeleteV1,
   SyncAssetExifV1,
+  SyncAlbumV1,
+  SyncAlbumDeleteV1,
+  SyncAlbumUserV1,
+  SyncAlbumUserDeleteV1,
 ];
 
 export const extraSyncModels = responseDtos;
diff --git a/server/src/enum.ts b/server/src/enum.ts
index c9cf34383e..b00b013393 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -578,6 +578,8 @@ export enum SyncRequestType {
   AssetExifsV1 = 'AssetExifsV1',
   PartnerAssetsV1 = 'PartnerAssetsV1',
   PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
+  AlbumsV1 = 'AlbumsV1',
+  AlbumUsersV1 = 'AlbumUsersV1',
 }
 
 export enum SyncEntityType {
@@ -594,6 +596,11 @@ export enum SyncEntityType {
   PartnerAssetV1 = 'PartnerAssetV1',
   PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
   PartnerAssetExifV1 = 'PartnerAssetExifV1',
+
+  AlbumV1 = 'AlbumV1',
+  AlbumDeleteV1 = 'AlbumDeleteV1',
+  AlbumUserV1 = 'AlbumUserV1',
+  AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
 }
 
 export enum NotificationLevel {
diff --git a/server/src/queries/album.user.repository.sql b/server/src/queries/album.user.repository.sql
index d628e4980a..08f337c150 100644
--- a/server/src/queries/album.user.repository.sql
+++ b/server/src/queries/album.user.repository.sql
@@ -6,7 +6,9 @@ insert into
 values
   ($1, $2)
 returning
-  *
+  "usersId",
+  "albumsId",
+  "role"
 
 -- AlbumUserRepository.update
 update "albums_shared_users_users"
diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql
index 54c1292d80..f797f5c0b5 100644
--- a/server/src/queries/sync.repository.sql
+++ b/server/src/queries/sync.repository.sql
@@ -246,3 +246,98 @@ where
   and "updatedAt" < now() - interval '1 millisecond'
 order by
   "updateId" asc
+
+-- SyncRepository.getAlbumDeletes
+select
+  "id",
+  "albumId"
+from
+  "albums_audit"
+where
+  "userId" = $1
+  and "deletedAt" < now() - interval '1 millisecond'
+order by
+  "id" asc
+
+-- SyncRepository.getAlbumUpserts
+select distinct
+  on ("albums"."id", "albums"."updateId") "albums"."id",
+  "albums"."ownerId",
+  "albums"."albumName" as "name",
+  "albums"."description",
+  "albums"."createdAt",
+  "albums"."updatedAt",
+  "albums"."albumThumbnailAssetId" as "thumbnailAssetId",
+  "albums"."isActivityEnabled",
+  "albums"."order",
+  "albums"."updateId"
+from
+  "albums"
+  left join "albums_shared_users_users" as "album_users" on "albums"."id" = "album_users"."albumsId"
+where
+  "albums"."updatedAt" < now() - interval '1 millisecond'
+  and (
+    "albums"."ownerId" = $1
+    or "album_users"."usersId" = $2
+  )
+order by
+  "albums"."updateId" asc
+
+-- SyncRepository.getAlbumUserDeletes
+select
+  "id",
+  "userId",
+  "albumId"
+from
+  "album_users_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.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"
+from
+  "albums_shared_users_users"
+where
+  "albums_shared_users_users"."updatedAt" < now() - interval '1 millisecond'
+  and "albums_shared_users_users"."albumsId" 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
+    )
+  )
+order by
+  "albums_shared_users_users"."updateId" asc
diff --git a/server/src/repositories/album-user.repository.ts b/server/src/repositories/album-user.repository.ts
index f363f2e91a..ad7ba8d6cd 100644
--- a/server/src/repositories/album-user.repository.ts
+++ b/server/src/repositories/album-user.repository.ts
@@ -1,5 +1,5 @@
 import { Injectable } from '@nestjs/common';
-import { Insertable, Kysely, Selectable, Updateable } from 'kysely';
+import { Insertable, Kysely, Updateable } from 'kysely';
 import { InjectKysely } from 'nestjs-kysely';
 import { AlbumsSharedUsersUsers, DB } from 'src/db';
 import { DummyValue, GenerateSql } from 'src/decorators';
@@ -15,8 +15,12 @@ export class AlbumUserRepository {
   constructor(@InjectKysely() private db: Kysely<DB>) {}
 
   @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] })
-  create(albumUser: Insertable<AlbumsSharedUsersUsers>): Promise<Selectable<AlbumsSharedUsersUsers>> {
-    return this.db.insertInto('albums_shared_users_users').values(albumUser).returningAll().executeTakeFirstOrThrow();
+  create(albumUser: Insertable<AlbumsSharedUsersUsers>) {
+    return this.db
+      .insertInto('albums_shared_users_users')
+      .values(albumUser)
+      .returning(['usersId', 'albumsId', 'role'])
+      .executeTakeFirstOrThrow();
   }
 
   @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.VIEWER }] })
diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts
index f0c535ecf2..43fd732747 100644
--- a/server/src/repositories/sync.repository.ts
+++ b/server/src/repositories/sync.repository.ts
@@ -7,8 +7,8 @@ import { DummyValue, GenerateSql } from 'src/decorators';
 import { SyncEntityType } from 'src/enum';
 import { SyncAck } from 'src/types';
 
-type auditTables = 'users_audit' | 'partners_audit' | 'assets_audit';
-type upsertTables = 'users' | 'partners' | 'assets' | 'exif';
+type AuditTables = 'users_audit' | 'partners_audit' | 'assets_audit' | 'albums_audit' | 'album_users_audit';
+type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users';
 
 @Injectable()
 export class SyncRepository {
@@ -110,7 +110,6 @@ export class SyncRepository {
       .selectFrom('assets_audit')
       .select(['id', 'assetId'])
       .where('ownerId', '=', userId)
-      .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
       .$call((qb) => this.auditTableFilters(qb, ack))
       .stream();
   }
@@ -154,19 +153,115 @@ export class SyncRepository {
       .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>;
+  @GenerateSql({ params: [DummyValue.UUID], stream: true })
+  getAlbumDeletes(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('albums_audit')
+      .select(['id', 'albumId'])
+      .where('userId', '=', userId)
+      .$call((qb) => this.auditTableFilters(qb, ack))
+      .stream();
+  }
+
+  @GenerateSql({ params: [DummyValue.UUID], stream: true })
+  getAlbumUpserts(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('albums')
+      .distinctOn(['albums.id', 'albums.updateId'])
+      .where('albums.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
+      .$if(!!ack, (qb) => qb.where('albums.updateId', '>', ack!.updateId))
+      .orderBy('albums.updateId', 'asc')
+      .leftJoin('albums_shared_users_users as album_users', 'albums.id', 'album_users.albumsId')
+      .where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)]))
+      .select([
+        'albums.id',
+        'albums.ownerId',
+        'albums.albumName as name',
+        'albums.description',
+        'albums.createdAt',
+        'albums.updatedAt',
+        'albums.albumThumbnailAssetId as thumbnailAssetId',
+        'albums.isActivityEnabled',
+        'albums.order',
+        'albums.updateId',
+      ])
+      .stream();
+  }
+
+  @GenerateSql({ params: [DummyValue.UUID], stream: true })
+  getAlbumUserDeletes(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('album_users_audit')
+      .select(['id', 'userId', '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 })
+  getAlbumUserUpserts(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('albums_shared_users_users')
+      .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',
+      ])
+      .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')
+      .where((eb) =>
+        eb(
+          'albums_shared_users_users.albumsId',
+          '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),
+              ),
+            ),
+        ),
+      )
+      .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
       .where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
       .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
       .orderBy('id', 'asc') as SelectQueryBuilder<DB, T, D>;
   }
 
-  private upsertTableFilters<T extends keyof Pick<DB, upsertTables>, D>(
+  private upsertTableFilters<T extends keyof Pick<DB, UpsertTables>, D>(
     qb: SelectQueryBuilder<DB, T, D>,
     ack?: SyncAck,
   ) {
-    const builder = qb as SelectQueryBuilder<DB, upsertTables, D>;
+    const builder = qb as SelectQueryBuilder<DB, UpsertTables, D>;
     return builder
       .where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
       .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts
index 65ad2b72dc..a03f715bff 100644
--- a/server/src/schema/functions.ts
+++ b/server/src/schema/functions.ts
@@ -23,6 +23,19 @@ export const immich_uuid_v7 = registerFunction({
   synchronize: false,
 });
 
+export const album_user_after_insert = registerFunction({
+  name: 'album_user_after_insert',
+  returnType: 'TRIGGER',
+  language: 'PLPGSQL',
+  body: `
+    BEGIN
+      UPDATE albums SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
+      WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows);
+      RETURN NULL;
+    END`,
+  synchronize: false,
+});
+
 export const updated_at = registerFunction({
   name: 'updated_at',
   returnType: 'TRIGGER',
@@ -114,3 +127,38 @@ export const assets_delete_audit = registerFunction({
     END`,
   synchronize: false,
 });
+
+export const albums_delete_audit = registerFunction({
+  name: 'albums_delete_audit',
+  returnType: 'TRIGGER',
+  language: 'PLPGSQL',
+  body: `
+    BEGIN
+      INSERT INTO albums_audit ("albumId", "userId")
+      SELECT "id", "ownerId"
+      FROM OLD;
+      RETURN NULL;
+    END`,
+  synchronize: false,
+});
+
+export const album_users_delete_audit = registerFunction({
+  name: 'album_users_delete_audit',
+  returnType: 'TRIGGER',
+  language: 'PLPGSQL',
+  body: `
+    BEGIN
+      INSERT INTO albums_audit ("albumId", "userId")
+      SELECT "albumsId", "usersId"
+      FROM OLD;
+
+      IF pg_trigger_depth() = 1 THEN
+        INSERT INTO album_users_audit ("albumId", "userId")
+        SELECT "albumsId", "usersId"
+        FROM OLD;
+      END IF;
+
+      RETURN NULL;
+    END`,
+  synchronize: false,
+});
diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts
index 735dfd3ae9..d2f8d80afc 100644
--- a/server/src/schema/index.ts
+++ b/server/src/schema/index.ts
@@ -1,5 +1,8 @@
 import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
 import {
+  album_user_after_insert,
+  album_users_delete_audit,
+  albums_delete_audit,
   assets_delete_audit,
   f_concat_ws,
   f_unaccent,
@@ -11,6 +14,8 @@ import {
 } from 'src/schema/functions';
 import { ActivityTable } from 'src/schema/tables/activity.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';
 import { AlbumUserTable } from 'src/schema/tables/album-user.table';
 import { AlbumTable } from 'src/schema/tables/album.table';
 import { APIKeyTable } from 'src/schema/tables/api-key.table';
@@ -45,15 +50,16 @@ import { UserAuditTable } from 'src/schema/tables/user-audit.table';
 import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
 import { UserTable } from 'src/schema/tables/user.table';
 import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
-import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools';
+import { Database, Extensions } from 'src/sql-tools';
 
 @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
-@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' })
 @Database({ name: 'immich' })
 export class ImmichDatabase {
   tables = [
     ActivityTable,
     AlbumAssetTable,
+    AlbumAuditTable,
+    AlbumUserAuditTable,
     AlbumUserTable,
     AlbumTable,
     APIKeyTable,
@@ -99,6 +105,9 @@ export class ImmichDatabase {
     users_delete_audit,
     partners_delete_audit,
     assets_delete_audit,
+    albums_delete_audit,
+    album_user_after_insert,
+    album_users_delete_audit,
   ];
 
   enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
diff --git a/server/src/schema/migrations/1747664684909-AddAlbumAuditTables.ts b/server/src/schema/migrations/1747664684909-AddAlbumAuditTables.ts
new file mode 100644
index 0000000000..25ccfee710
--- /dev/null
+++ b/server/src/schema/migrations/1747664684909-AddAlbumAuditTables.ts
@@ -0,0 +1,96 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely<any>): Promise<void> {
+  await sql`CREATE OR REPLACE FUNCTION album_user_after_insert()
+  RETURNS TRIGGER
+  LANGUAGE PLPGSQL
+  AS $$
+    BEGIN
+      UPDATE albums SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
+      WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows);
+      RETURN NULL;
+    END
+  $$;`.execute(db);
+  await sql`CREATE OR REPLACE FUNCTION albums_delete_audit()
+  RETURNS TRIGGER
+  LANGUAGE PLPGSQL
+  AS $$
+    BEGIN
+      INSERT INTO albums_audit ("albumId", "userId")
+      SELECT "id", "ownerId"
+      FROM OLD;
+      RETURN NULL;
+    END
+  $$;`.execute(db);
+  await sql`CREATE OR REPLACE FUNCTION album_users_delete_audit()
+  RETURNS TRIGGER
+  LANGUAGE PLPGSQL
+  AS $$
+    BEGIN
+      INSERT INTO albums_audit ("albumId", "userId")
+      SELECT "albumsId", "usersId"
+      FROM OLD;
+
+      IF pg_trigger_depth() = 1 THEN
+        INSERT INTO album_users_audit ("albumId", "userId")
+        SELECT "albumsId", "usersId"
+        FROM OLD;
+      END IF;
+
+      RETURN NULL;
+    END
+  $$;`.execute(db);
+  await sql`CREATE TABLE "albums_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
+  await sql`CREATE TABLE "album_users_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
+  await sql`ALTER TABLE "albums_audit" ADD CONSTRAINT "PK_c75efea8d4dce316ad29b851a8b" PRIMARY KEY ("id");`.execute(db);
+  await sql`ALTER TABLE "album_users_audit" ADD CONSTRAINT "PK_f479a2e575b7ebc9698362c1688" PRIMARY KEY ("id");`.execute(db);
+  await sql`ALTER TABLE "albums_shared_users_users" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
+  await sql`ALTER TABLE "albums_shared_users_users" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
+  await sql`CREATE INDEX "IDX_album_users_update_id" ON "albums_shared_users_users" ("updateId")`.execute(db);
+  await sql`CREATE INDEX "IDX_albums_audit_album_id" ON "albums_audit" ("albumId")`.execute(db);
+  await sql`CREATE INDEX "IDX_albums_audit_user_id" ON "albums_audit" ("userId")`.execute(db);
+  await sql`CREATE INDEX "IDX_albums_audit_deleted_at" ON "albums_audit" ("deletedAt")`.execute(db);
+  await sql`CREATE INDEX "IDX_album_users_audit_album_id" ON "album_users_audit" ("albumId")`.execute(db);
+  await sql`CREATE INDEX "IDX_album_users_audit_user_id" ON "album_users_audit" ("userId")`.execute(db);
+  await sql`CREATE INDEX "IDX_album_users_audit_deleted_at" ON "album_users_audit" ("deletedAt")`.execute(db);
+  await sql`CREATE OR REPLACE TRIGGER "albums_delete_audit"
+  AFTER DELETE ON "albums"
+  REFERENCING OLD TABLE AS "old"
+  FOR EACH STATEMENT
+  WHEN (pg_trigger_depth() = 0)
+  EXECUTE FUNCTION albums_delete_audit();`.execute(db);
+  await sql`CREATE OR REPLACE TRIGGER "album_users_delete_audit"
+  AFTER DELETE ON "albums_shared_users_users"
+  REFERENCING OLD TABLE AS "old"
+  FOR EACH STATEMENT
+  WHEN (pg_trigger_depth() <= 1)
+  EXECUTE FUNCTION album_users_delete_audit();`.execute(db);
+  await sql`CREATE OR REPLACE TRIGGER "album_user_after_insert"
+  AFTER INSERT ON "albums_shared_users_users"
+  REFERENCING NEW TABLE AS "inserted_rows"
+  FOR EACH STATEMENT
+  EXECUTE FUNCTION album_user_after_insert();`.execute(db);
+  await sql`CREATE OR REPLACE TRIGGER "album_users_updated_at"
+  BEFORE UPDATE ON "albums_shared_users_users"
+  FOR EACH ROW
+  EXECUTE FUNCTION updated_at();`.execute(db);
+}
+
+export async function down(db: Kysely<any>): Promise<void> {
+  await sql`DROP TRIGGER "albums_delete_audit" ON "albums";`.execute(db);
+  await sql`DROP TRIGGER "album_users_delete_audit" ON "albums_shared_users_users";`.execute(db);
+  await sql`DROP TRIGGER "album_user_after_insert" ON "albums_shared_users_users";`.execute(db);
+  await sql`DROP INDEX "IDX_albums_audit_album_id";`.execute(db);
+  await sql`DROP INDEX "IDX_albums_audit_user_id";`.execute(db);
+  await sql`DROP INDEX "IDX_albums_audit_deleted_at";`.execute(db);
+  await sql`DROP INDEX "IDX_album_users_audit_album_id";`.execute(db);
+  await sql`DROP INDEX "IDX_album_users_audit_user_id";`.execute(db);
+  await sql`DROP INDEX "IDX_album_users_audit_deleted_at";`.execute(db);
+  await sql`ALTER TABLE "albums_audit" DROP CONSTRAINT "PK_c75efea8d4dce316ad29b851a8b";`.execute(db);
+  await sql`ALTER TABLE "album_users_audit" DROP CONSTRAINT "PK_f479a2e575b7ebc9698362c1688";`.execute(db);
+  await sql`DROP TABLE "albums_audit";`.execute(db);
+  await sql`DROP TABLE "album_users_audit";`.execute(db);
+  await sql`DROP FUNCTION album_user_after_insert;`.execute(db);
+  await sql`DROP FUNCTION albums_delete_audit;`.execute(db);
+  await sql`DROP FUNCTION album_users_delete_audit;`.execute(db);
+}
diff --git a/server/src/schema/tables/album-audit.table.ts b/server/src/schema/tables/album-audit.table.ts
new file mode 100644
index 0000000000..66b70654e9
--- /dev/null
+++ b/server/src/schema/tables/album-audit.table.ts
@@ -0,0 +1,17 @@
+import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
+import { Column, CreateDateColumn, Table } from 'src/sql-tools';
+
+@Table('albums_audit')
+export class AlbumAuditTable {
+  @PrimaryGeneratedUuidV7Column()
+  id!: string;
+
+  @Column({ type: 'uuid', indexName: 'IDX_albums_audit_album_id' })
+  albumId!: string;
+
+  @Column({ type: 'uuid', indexName: 'IDX_albums_audit_user_id' })
+  userId!: string;
+
+  @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_albums_audit_deleted_at' })
+  deletedAt!: Date;
+}
diff --git a/server/src/schema/tables/album-user-audit.table.ts b/server/src/schema/tables/album-user-audit.table.ts
new file mode 100644
index 0000000000..46ad6b682b
--- /dev/null
+++ b/server/src/schema/tables/album-user-audit.table.ts
@@ -0,0 +1,17 @@
+import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
+import { Column, CreateDateColumn, Table } from 'src/sql-tools';
+
+@Table('album_users_audit')
+export class AlbumUserAuditTable {
+  @PrimaryGeneratedUuidV7Column()
+  id!: string;
+
+  @Column({ type: 'uuid', indexName: 'IDX_album_users_audit_album_id' })
+  albumId!: string;
+
+  @Column({ type: 'uuid', indexName: 'IDX_album_users_audit_user_id' })
+  userId!: string;
+
+  @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_album_users_audit_deleted_at' })
+  deletedAt!: Date;
+}
diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts
index 8bd05df2ee..276efd126a 100644
--- a/server/src/schema/tables/album-user.table.ts
+++ b/server/src/schema/tables/album-user.table.ts
@@ -1,12 +1,36 @@
+import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
 import { AlbumUserRole } from 'src/enum';
+import { album_user_after_insert, album_users_delete_audit } from 'src/schema/functions';
 import { AlbumTable } from 'src/schema/tables/album.table';
 import { UserTable } from 'src/schema/tables/user.table';
-import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
+import {
+  AfterDeleteTrigger,
+  AfterInsertTrigger,
+  Column,
+  ForeignKeyColumn,
+  Index,
+  Table,
+  UpdateDateColumn,
+} from 'src/sql-tools';
 
 @Table({ name: 'albums_shared_users_users', primaryConstraintName: 'PK_7df55657e0b2e8b626330a0ebc8' })
 // Pre-existing indices from original album <--> user ManyToMany mapping
 @Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] })
 @Index({ name: 'IDX_f48513bf9bccefd6ff3ad30bd0', columns: ['usersId'] })
+@UpdatedAtTrigger('album_users_updated_at')
+@AfterInsertTrigger({
+  name: 'album_user_after_insert',
+  scope: 'statement',
+  referencingNewTableAs: 'inserted_rows',
+  function: album_user_after_insert,
+})
+@AfterDeleteTrigger({
+  name: 'album_users_delete_audit',
+  scope: 'statement',
+  function: album_users_delete_audit,
+  referencingOldTableAs: 'old',
+  when: 'pg_trigger_depth() <= 1',
+})
 export class AlbumUserTable {
   @ForeignKeyColumn(() => AlbumTable, {
     onDelete: 'CASCADE',
@@ -26,4 +50,10 @@ export class AlbumUserTable {
 
   @Column({ type: 'character varying', default: AlbumUserRole.EDITOR })
   role!: AlbumUserRole;
+
+  @UpdateIdColumn({ indexName: 'IDX_album_users_update_id' })
+  updateId?: string;
+
+  @UpdateDateColumn()
+  updatedAt!: Date;
 }
diff --git a/server/src/schema/tables/album.table.ts b/server/src/schema/tables/album.table.ts
index 428947fa51..5d02cc9f25 100644
--- a/server/src/schema/tables/album.table.ts
+++ b/server/src/schema/tables/album.table.ts
@@ -1,8 +1,10 @@
 import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
 import { AssetOrder } from 'src/enum';
+import { albums_delete_audit } from 'src/schema/functions';
 import { AssetTable } from 'src/schema/tables/asset.table';
 import { UserTable } from 'src/schema/tables/user.table';
 import {
+  AfterDeleteTrigger,
   Column,
   CreateDateColumn,
   DeleteDateColumn,
@@ -14,6 +16,13 @@ import {
 
 @Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' })
 @UpdatedAtTrigger('albums_updated_at')
+@AfterDeleteTrigger({
+  name: 'albums_delete_audit',
+  scope: 'statement',
+  function: albums_delete_audit,
+  referencingOldTableAs: 'old',
+  when: 'pg_trigger_depth() = 0',
+})
 export class AlbumTable {
   @PrimaryGeneratedColumn()
   id!: string;
diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts
index bd3c09098f..d6cbc17a29 100644
--- a/server/src/services/sync.service.ts
+++ b/server/src/services/sync.service.ts
@@ -24,13 +24,14 @@ import { fromAck, serialize } from 'src/utils/sync';
 
 const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
 export const SYNC_TYPES_ORDER = [
-  //
   SyncRequestType.UsersV1,
   SyncRequestType.PartnersV1,
   SyncRequestType.AssetsV1,
   SyncRequestType.AssetExifsV1,
   SyncRequestType.PartnerAssetsV1,
   SyncRequestType.PartnerAssetExifsV1,
+  SyncRequestType.AlbumsV1,
+  SyncRequestType.AlbumUsersV1,
 ];
 
 const throwSessionRequired = () => {
@@ -206,6 +207,43 @@ export class SyncService extends BaseService {
           break;
         }
 
+        case SyncRequestType.AlbumsV1: {
+          const deletes = this.syncRepository.getAlbumDeletes(
+            auth.user.id,
+            checkpointMap[SyncEntityType.AlbumDeleteV1],
+          );
+          for await (const { id, ...data } of deletes) {
+            response.write(serialize({ type: SyncEntityType.AlbumDeleteV1, updateId: id, data }));
+          }
+
+          const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]);
+          for await (const { updateId, ...data } of upserts) {
+            response.write(serialize({ type: SyncEntityType.AlbumV1, updateId, data }));
+          }
+
+          break;
+        }
+
+        case SyncRequestType.AlbumUsersV1: {
+          const deletes = this.syncRepository.getAlbumUserDeletes(
+            auth.user.id,
+            checkpointMap[SyncEntityType.AlbumUserDeleteV1],
+          );
+          for await (const { id, ...data } of deletes) {
+            response.write(serialize({ type: SyncEntityType.AlbumUserDeleteV1, updateId: id, data }));
+          }
+
+          const upserts = this.syncRepository.getAlbumUserUpserts(
+            auth.user.id,
+            checkpointMap[SyncEntityType.AlbumUserV1],
+          );
+          for await (const { updateId, ...data } of upserts) {
+            response.write(serialize({ type: SyncEntityType.AlbumUserV1, updateId, data }));
+          }
+
+          break;
+        }
+
         default: {
           this.logger.warn(`Unsupported sync type: ${type}`);
           break;
diff --git a/server/src/sql-tools/from-code/decorators/after-insert.decorator.ts b/server/src/sql-tools/from-code/decorators/after-insert.decorator.ts
new file mode 100644
index 0000000000..103d59b4fc
--- /dev/null
+++ b/server/src/sql-tools/from-code/decorators/after-insert.decorator.ts
@@ -0,0 +1,8 @@
+import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator';
+
+export const AfterInsertTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
+  TriggerFunction({
+    timing: 'after',
+    actions: ['insert'],
+    ...options,
+  });
diff --git a/server/src/sql-tools/public_api.ts b/server/src/sql-tools/public_api.ts
index b41cce4ab5..c7a3023a4d 100644
--- a/server/src/sql-tools/public_api.ts
+++ b/server/src/sql-tools/public_api.ts
@@ -1,6 +1,7 @@
 export { schemaDiff } from 'src/sql-tools/diff';
 export { schemaFromCode } from 'src/sql-tools/from-code';
 export * from 'src/sql-tools/from-code/decorators/after-delete.decorator';
+export * from 'src/sql-tools/from-code/decorators/after-insert.decorator';
 export * from 'src/sql-tools/from-code/decorators/before-update.decorator';
 export * from 'src/sql-tools/from-code/decorators/check.decorator';
 export * from 'src/sql-tools/from-code/decorators/column.decorator';
diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts
index 8b730c2b41..cab74f70fb 100644
--- a/server/test/medium.factory.ts
+++ b/server/test/medium.factory.ts
@@ -4,9 +4,11 @@ import { DateTime } from 'luxon';
 import { createHash, randomBytes } from 'node:crypto';
 import { Writable } from 'node:stream';
 import { AssetFace } from 'src/database';
-import { AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db';
-import { AssetType, AssetVisibility, SourceType } from 'src/enum';
+import { Albums, AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db';
+import { AuthDto } from 'src/dtos/auth.dto';
+import { AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum';
 import { ActivityRepository } from 'src/repositories/activity.repository';
+import { AlbumUserRepository } from 'src/repositories/album-user.repository';
 import { AlbumRepository } from 'src/repositories/album.repository';
 import { AssetJobRepository } from 'src/repositories/asset-job.repository';
 import { AssetRepository } from 'src/repositories/asset.repository';
@@ -28,8 +30,9 @@ import { UserRepository } from 'src/repositories/user.repository';
 import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
 import { UserTable } from 'src/schema/tables/user.table';
 import { BaseService } from 'src/services/base.service';
+import { SyncService } from 'src/services/sync.service';
 import { RepositoryInterface } from 'src/types';
-import { newDate, newEmbedding, newUuid } from 'test/small.factory';
+import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
 import { automock, ServiceOverrides } from 'test/utils';
 import { Mocked } from 'vitest';
 
@@ -39,6 +42,7 @@ const sha256 = (value: string) => createHash('sha256').update(value).digest('bas
 type RepositoriesTypes = {
   activity: ActivityRepository;
   album: AlbumRepository;
+  albumUser: AlbumUserRepository;
   asset: AssetRepository;
   assetJob: AssetJobRepository;
   config: ConfigRepository;
@@ -76,6 +80,61 @@ export type Context<R extends RepositoryOptions, S extends BaseService> = {
   getRepository<T extends keyof RepositoriesTypes>(key: T): RepositoriesTypes[T];
 };
 
+export type SyncTestOptions = {
+  db: Kysely<DB>;
+};
+
+export const newSyncAuthUser = () => {
+  const user = mediumFactory.userInsert();
+  const session = mediumFactory.sessionInsert({ userId: user.id });
+
+  const auth = factory.auth({
+    session,
+    user: {
+      id: user.id,
+      name: user.name,
+      email: user.email,
+    },
+  });
+
+  return {
+    auth,
+    session,
+    user,
+    create: async (db: Kysely<DB>) => {
+      await new UserRepository(db).create(user);
+      await new SessionRepository(db).create(session);
+    },
+  };
+};
+
+export const newSyncTest = (options: SyncTestOptions) => {
+  const { sut, mocks, repos, getRepository } = newMediumService(SyncService, {
+    database: options.db,
+    repos: {
+      sync: 'real',
+      session: 'real',
+    },
+  });
+
+  const testSync = async (auth: AuthDto, types: SyncRequestType[]) => {
+    const stream = mediumFactory.syncStream();
+    // Wait for 2ms to ensure all updates are available and account for setTimeout inaccuracy
+    await new Promise((resolve) => setTimeout(resolve, 2));
+    await sut.stream(auth, stream, { types });
+
+    return stream.getResponse();
+  };
+
+  return {
+    sut,
+    mocks,
+    repos,
+    getRepository,
+    testSync,
+  };
+};
+
 export const newMediumService = <R extends RepositoryOptions, S extends BaseService>(
   Service: ClassConstructor<S>,
   options: {
@@ -125,6 +184,14 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys
       return new ActivityRepository(db);
     }
 
+    case 'album': {
+      return new AlbumRepository(db);
+    }
+
+    case 'albumUser': {
+      return new AlbumUserRepository(db);
+    }
+
     case 'asset': {
       return new AssetRepository(db);
     }
@@ -380,6 +447,19 @@ const assetInsert = (asset: Partial<Insertable<Assets>> = {}) => {
   };
 };
 
+const albumInsert = (album: Partial<Insertable<Albums>> & { ownerId: string }) => {
+  const id = album.id || newUuid();
+  const defaults: Omit<Insertable<Albums>, 'ownerId'> = {
+    albumName: 'Album',
+  };
+
+  return {
+    ...defaults,
+    ...album,
+    id,
+  };
+};
+
 const faceInsert = (face: Partial<Insertable<FaceSearch>> & { faceId: string }) => {
   const defaults = {
     faceId: face.faceId,
@@ -502,6 +582,7 @@ export const mediumFactory = {
   assetInsert,
   assetFaceInsert,
   assetJobStatusInsert,
+  albumInsert,
   faceInsert,
   personInsert,
   sessionInsert,
diff --git a/server/test/medium/specs/services/sync.service.spec.ts b/server/test/medium/specs/services/sync.service.spec.ts
deleted file mode 100644
index 67cfeafdbf..0000000000
--- a/server/test/medium/specs/services/sync.service.spec.ts
+++ /dev/null
@@ -1,910 +0,0 @@
-import { AuthDto } from 'src/dtos/auth.dto';
-import { SyncEntityType, SyncRequestType } from 'src/enum';
-import { SYNC_TYPES_ORDER, SyncService } from 'src/services/sync.service';
-import { mediumFactory, newMediumService } from 'test/medium.factory';
-import { factory } from 'test/small.factory';
-import { getKyselyDB } from 'test/utils';
-
-const setup = async () => {
-  const db = await getKyselyDB();
-
-  const { sut, mocks, repos, getRepository } = newMediumService(SyncService, {
-    database: db,
-    repos: {
-      sync: 'real',
-      session: 'real',
-    },
-  });
-
-  const user = mediumFactory.userInsert();
-  const session = mediumFactory.sessionInsert({ userId: user.id });
-  const auth = factory.auth({
-    session,
-    user: {
-      id: user.id,
-      name: user.name,
-      email: user.email,
-    },
-  });
-
-  await getRepository('user').create(user);
-  await getRepository('session').create(session);
-
-  const testSync = async (auth: AuthDto, types: SyncRequestType[]) => {
-    const stream = mediumFactory.syncStream();
-    // Wait for 1ms to ensure all updates are available
-    await new Promise((resolve) => setTimeout(resolve, 1));
-    await sut.stream(auth, stream, { types });
-
-    return stream.getResponse();
-  };
-
-  return {
-    sut,
-    auth,
-    mocks,
-    repos,
-    getRepository,
-    testSync,
-  };
-};
-
-describe(SyncService.name, () => {
-  it('should have all the types in the ordering variable', () => {
-    for (const key in SyncRequestType) {
-      expect(SYNC_TYPES_ORDER).includes(key);
-    }
-
-    expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length);
-  });
-
-  describe.concurrent(SyncEntityType.UserV1, () => {
-    it('should detect and sync the first user', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user = await userRepo.get(auth.user.id, { withDeleted: false });
-      if (!user) {
-        expect.fail('First user should exist');
-      }
-
-      const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
-      expect(initialSyncResponse).toHaveLength(1);
-      expect(initialSyncResponse).toEqual([
-        {
-          ack: expect.any(String),
-          data: {
-            deletedAt: user.deletedAt,
-            email: user.email,
-            id: user.id,
-            name: user.name,
-          },
-          type: 'UserV1',
-        },
-      ]);
-
-      const acks = [initialSyncResponse[0].ack];
-      await sut.setAcks(auth, { acks });
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should detect and sync a soft deleted user', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const deletedAt = new Date().toISOString();
-      const deletedUser = mediumFactory.userInsert({ deletedAt });
-      const deleted = await getRepository('user').create(deletedUser);
-
-      const response = await testSync(auth, [SyncRequestType.UsersV1]);
-
-      expect(response).toHaveLength(2);
-      expect(response).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              deletedAt: null,
-              email: auth.user.email,
-              id: auth.user.id,
-              name: auth.user.name,
-            },
-            type: 'UserV1',
-          },
-          {
-            ack: expect.any(String),
-            data: {
-              deletedAt,
-              email: deleted.email,
-              id: deleted.id,
-              name: deleted.name,
-            },
-            type: 'UserV1',
-          },
-        ]),
-      );
-
-      const acks = [response[1].ack];
-      await sut.setAcks(auth, { acks });
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should detect and sync a deleted user', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user = mediumFactory.userInsert();
-      await userRepo.create(user);
-      await userRepo.delete({ id: user.id }, true);
-
-      const response = await testSync(auth, [SyncRequestType.UsersV1]);
-
-      expect(response).toHaveLength(2);
-      expect(response).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              userId: user.id,
-            },
-            type: 'UserDeleteV1',
-          },
-          {
-            ack: expect.any(String),
-            data: {
-              deletedAt: null,
-              email: auth.user.email,
-              id: auth.user.id,
-              name: auth.user.name,
-            },
-            type: 'UserV1',
-          },
-        ]),
-      );
-
-      const acks = response.map(({ ack }) => ack);
-      await sut.setAcks(auth, { acks });
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should sync a user and then an update to that same user', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
-
-      expect(initialSyncResponse).toHaveLength(1);
-      expect(initialSyncResponse).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              deletedAt: null,
-              email: auth.user.email,
-              id: auth.user.id,
-              name: auth.user.name,
-            },
-            type: 'UserV1',
-          },
-        ]),
-      );
-
-      const acks = [initialSyncResponse[0].ack];
-      await sut.setAcks(auth, { acks });
-
-      const userRepo = getRepository('user');
-      const updated = await userRepo.update(auth.user.id, { name: 'new name' });
-      const updatedSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
-
-      expect(updatedSyncResponse).toHaveLength(1);
-      expect(updatedSyncResponse).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              deletedAt: null,
-              email: auth.user.email,
-              id: auth.user.id,
-              name: updated.name,
-            },
-            type: 'UserV1',
-          },
-        ]),
-      );
-    });
-  });
-
-  describe.concurrent(SyncEntityType.PartnerV1, () => {
-    it('should detect and sync the first partner', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const user1 = auth.user;
-      const userRepo = getRepository('user');
-      const partnerRepo = getRepository('partner');
-
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
-
-      const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
-
-      expect(initialSyncResponse).toHaveLength(1);
-      expect(initialSyncResponse).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              inTimeline: partner.inTimeline,
-              sharedById: partner.sharedById,
-              sharedWithId: partner.sharedWithId,
-            },
-            type: 'PartnerV1',
-          },
-        ]),
-      );
-
-      const acks = [initialSyncResponse[0].ack];
-      await sut.setAcks(auth, { acks });
-
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should detect and sync a deleted partner', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user1 = auth.user;
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const partnerRepo = getRepository('partner');
-      const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
-      await partnerRepo.remove(partner);
-
-      const response = await testSync(auth, [SyncRequestType.PartnersV1]);
-
-      expect(response).toHaveLength(1);
-      expect(response).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              sharedById: partner.sharedById,
-              sharedWithId: partner.sharedWithId,
-            },
-            type: 'PartnerDeleteV1',
-          },
-        ]),
-      );
-
-      const acks = response.map(({ ack }) => ack);
-      await sut.setAcks(auth, { acks });
-
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should detect and sync a partner share both to and from another user', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user1 = auth.user;
-      const user2 = await userRepo.create(mediumFactory.userInsert());
-
-      const partnerRepo = getRepository('partner');
-      const partner1 = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
-      const partner2 = await partnerRepo.create({ sharedById: user1.id, sharedWithId: user2.id });
-
-      const response = await testSync(auth, [SyncRequestType.PartnersV1]);
-
-      expect(response).toHaveLength(2);
-      expect(response).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              inTimeline: partner1.inTimeline,
-              sharedById: partner1.sharedById,
-              sharedWithId: partner1.sharedWithId,
-            },
-            type: 'PartnerV1',
-          },
-          {
-            ack: expect.any(String),
-            data: {
-              inTimeline: partner2.inTimeline,
-              sharedById: partner2.sharedById,
-              sharedWithId: partner2.sharedWithId,
-            },
-            type: 'PartnerV1',
-          },
-        ]),
-      );
-
-      await sut.setAcks(auth, { acks: [response[1].ack] });
-
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should sync a partner and then an update to that same partner', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user1 = auth.user;
-      const user2 = await userRepo.create(mediumFactory.userInsert());
-
-      const partnerRepo = getRepository('partner');
-      const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
-
-      const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
-
-      expect(initialSyncResponse).toHaveLength(1);
-      expect(initialSyncResponse).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              inTimeline: partner.inTimeline,
-              sharedById: partner.sharedById,
-              sharedWithId: partner.sharedWithId,
-            },
-            type: 'PartnerV1',
-          },
-        ]),
-      );
-
-      const acks = [initialSyncResponse[0].ack];
-      await sut.setAcks(auth, { acks });
-
-      const updated = await partnerRepo.update(
-        { sharedById: partner.sharedById, sharedWithId: partner.sharedWithId },
-        { inTimeline: true },
-      );
-
-      const updatedSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
-
-      expect(updatedSyncResponse).toHaveLength(1);
-      expect(updatedSyncResponse).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              inTimeline: updated.inTimeline,
-              sharedById: updated.sharedById,
-              sharedWithId: updated.sharedWithId,
-            },
-            type: 'PartnerV1',
-          },
-        ]),
-      );
-    });
-
-    it('should not sync a partner or partner delete for an unrelated user', async () => {
-      const { auth, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = await userRepo.create(mediumFactory.userInsert());
-      const user3 = await userRepo.create(mediumFactory.userInsert());
-
-      const partnerRepo = getRepository('partner');
-      const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user3.id });
-
-      expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
-
-      await partnerRepo.remove(partner);
-
-      expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
-    });
-
-    it('should not sync a partner delete after a user is deleted', async () => {
-      const { auth, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = await userRepo.create(mediumFactory.userInsert());
-
-      const partnerRepo = getRepository('partner');
-      await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
-      await userRepo.delete({ id: user2.id }, true);
-
-      expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
-    });
-  });
-
-  describe.concurrent(SyncEntityType.AssetV1, () => {
-    it('should detect and sync the first asset', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
-      const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
-      const date = new Date().toISOString();
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({
-        ownerId: auth.user.id,
-        checksum: Buffer.from(checksum, 'base64'),
-        thumbhash: Buffer.from(thumbhash, 'base64'),
-        fileCreatedAt: date,
-        fileModifiedAt: date,
-        localDateTime: date,
-        deletedAt: null,
-      });
-      await assetRepo.create(asset);
-
-      const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
-
-      expect(initialSyncResponse).toHaveLength(1);
-      expect(initialSyncResponse).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              id: asset.id,
-              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,
-            },
-            type: 'AssetV1',
-          },
-        ]),
-      );
-
-      const acks = [initialSyncResponse[0].ack];
-      await sut.setAcks(auth, { acks });
-
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should detect and sync a deleted asset', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
-      await assetRepo.create(asset);
-      await assetRepo.remove(asset);
-
-      const response = await testSync(auth, [SyncRequestType.AssetsV1]);
-
-      expect(response).toHaveLength(1);
-      expect(response).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              assetId: asset.id,
-            },
-            type: 'AssetDeleteV1',
-          },
-        ]),
-      );
-
-      const acks = response.map(({ ack }) => ack);
-      await sut.setAcks(auth, { acks });
-
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should not sync an asset or asset delete for an unrelated user', async () => {
-      const { auth, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const sessionRepo = getRepository('session');
-      const session = mediumFactory.sessionInsert({ userId: user2.id });
-      await sessionRepo.create(session);
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({ ownerId: user2.id });
-      await assetRepo.create(asset);
-
-      const auth2 = factory.auth({ session, user: user2 });
-
-      expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
-      expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
-
-      await assetRepo.remove(asset);
-      expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
-      expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
-    });
-  });
-
-  describe.concurrent(SyncRequestType.PartnerAssetsV1, () => {
-    it('should detect and sync the first partner asset', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
-      const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
-      const date = new Date().toISOString();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({
-        ownerId: user2.id,
-        checksum: Buffer.from(checksum, 'base64'),
-        thumbhash: Buffer.from(thumbhash, 'base64'),
-        fileCreatedAt: date,
-        fileModifiedAt: date,
-        localDateTime: date,
-        deletedAt: null,
-      });
-      await assetRepo.create(asset);
-
-      const partnerRepo = getRepository('partner');
-      await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
-
-      const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
-
-      expect(initialSyncResponse).toHaveLength(1);
-      expect(initialSyncResponse).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              id: asset.id,
-              ownerId: asset.ownerId,
-              thumbhash,
-              checksum,
-              deletedAt: null,
-              fileCreatedAt: date,
-              fileModifiedAt: date,
-              isFavorite: false,
-              localDateTime: date,
-              type: asset.type,
-              visibility: asset.visibility,
-            },
-            type: SyncEntityType.PartnerAssetV1,
-          },
-        ]),
-      );
-
-      const acks = [initialSyncResponse[0].ack];
-      await sut.setAcks(auth, { acks });
-
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should detect and sync a deleted partner asset', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-      const asset = mediumFactory.assetInsert({ ownerId: user2.id });
-
-      const assetRepo = getRepository('asset');
-      await assetRepo.create(asset);
-
-      const partnerRepo = getRepository('partner');
-      await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
-      await assetRepo.remove(asset);
-
-      const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
-
-      expect(response).toHaveLength(1);
-      expect(response).toEqual(
-        expect.arrayContaining([
-          {
-            ack: expect.any(String),
-            data: {
-              assetId: asset.id,
-            },
-            type: SyncEntityType.PartnerAssetDeleteV1,
-          },
-        ]),
-      );
-
-      const acks = response.map(({ ack }) => ack);
-      await sut.setAcks(auth, { acks });
-
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should not sync a deleted partner asset due to a user delete', async () => {
-      const { auth, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const partnerRepo = getRepository('partner');
-      await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
-
-      const assetRepo = getRepository('asset');
-      await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id }));
-
-      await userRepo.delete({ id: user2.id }, true);
-
-      const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
-      expect(response).toHaveLength(0);
-    });
-
-    it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => {
-      const { auth, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const assetRepo = getRepository('asset');
-      await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id }));
-
-      const partnerRepo = getRepository('partner');
-      const partner = { sharedById: user2.id, sharedWithId: auth.user.id };
-      await partnerRepo.create(partner);
-
-      await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1);
-
-      await partnerRepo.remove(partner);
-
-      await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
-    });
-
-    it('should not sync an asset or asset delete for own user', async () => {
-      const { auth, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
-      await assetRepo.create(asset);
-
-      const partnerRepo = getRepository('partner');
-      await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
-
-      await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
-      await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
-
-      await assetRepo.remove(asset);
-
-      await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
-      await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
-    });
-
-    it('should not sync an asset or asset delete for unrelated user', async () => {
-      const { auth, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const sessionRepo = getRepository('session');
-      const session = mediumFactory.sessionInsert({ userId: user2.id });
-      await sessionRepo.create(session);
-
-      const auth2 = factory.auth({ session, user: user2 });
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({ ownerId: user2.id });
-      await assetRepo.create(asset);
-
-      await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
-      await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
-
-      await assetRepo.remove(asset);
-
-      await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
-      await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
-    });
-  });
-
-  describe.concurrent(SyncRequestType.AssetExifsV1, () => {
-    it('should detect and sync the first asset exif', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
-      await assetRepo.create(asset);
-      await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
-
-      const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
-
-      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.AssetExifV1,
-          },
-        ]),
-      );
-
-      const acks = [initialSyncResponse[0].ack];
-      await sut.setAcks(auth, { acks });
-
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should only sync asset exif for own user', async () => {
-      const { auth, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const partnerRepo = getRepository('partner');
-      await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({ ownerId: user2.id });
-      await assetRepo.create(asset);
-      await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
-
-      const sessionRepo = getRepository('session');
-      const session = mediumFactory.sessionInsert({ userId: user2.id });
-      await sessionRepo.create(session);
-
-      const auth2 = factory.auth({ session, user: user2 });
-      await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
-      await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0);
-    });
-  });
-
-  describe.concurrent(SyncRequestType.PartnerAssetExifsV1, () => {
-    it('should detect and sync the first partner asset exif', async () => {
-      const { auth, sut, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const partnerRepo = getRepository('partner');
-      await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({ ownerId: user2.id });
-      await assetRepo.create(asset);
-      await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
-
-      const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
-
-      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.PartnerAssetExifV1,
-          },
-        ]),
-      );
-
-      const acks = [initialSyncResponse[0].ack];
-      await sut.setAcks(auth, { acks });
-
-      const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
-
-      expect(ackSyncResponse).toHaveLength(0);
-    });
-
-    it('should not sync partner asset exif for own user', async () => {
-      const { auth, getRepository, testSync } = await setup();
-
-      const userRepo = getRepository('user');
-      const user2 = mediumFactory.userInsert();
-      await userRepo.create(user2);
-
-      const partnerRepo = getRepository('partner');
-      await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
-
-      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 expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
-      await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
-    });
-
-    it('should not sync partner 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 Promise.all([userRepo.create(user2), userRepo.create(user3)]);
-
-      const partnerRepo = getRepository('partner');
-      await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
-
-      const assetRepo = getRepository('asset');
-      const asset = mediumFactory.assetInsert({ ownerId: user3.id });
-      await assetRepo.create(asset);
-      await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
-
-      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);
-    });
-  });
-});
diff --git a/server/test/medium/specs/sync/sync-album-user.spec.ts b/server/test/medium/specs/sync/sync-album-user.spec.ts
new file mode 100644
index 0000000000..4967df5264
--- /dev/null
+++ b/server/test/medium/specs/sync/sync-album-user.spec.ts
@@ -0,0 +1,269 @@
+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 } 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(SyncRequestType.AlbumUsersV1, () => {
+  it('should sync an album user with the correct properties', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const albumRepo = getRepository('album');
+    const albumUserRepo = getRepository('albumUser');
+    const userRepo = getRepository('user');
+
+    const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
+    await albumRepo.create(album, [], []);
+
+    const user = mediumFactory.userInsert();
+    await userRepo.create(user);
+
+    const albumUser = { albumsId: album.id, usersId: user.id, role: AlbumUserRole.EDITOR };
+    await albumUserRepo.create(albumUser);
+
+    await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
+      {
+        ack: expect.any(String),
+        data: expect.objectContaining({
+          albumId: albumUser.albumsId,
+          role: albumUser.role,
+          userId: albumUser.usersId,
+        }),
+        type: SyncEntityType.AlbumUserV1,
+      },
+    ]);
+  });
+  describe('owner', () => {
+    it('should detect and sync a new shared user', async () => {
+      const { auth, testSync, getRepository } = await setup();
+
+      const albumRepo = getRepository('album');
+      const albumUserRepo = getRepository('albumUser');
+      const userRepo = getRepository('user');
+
+      const user1 = mediumFactory.userInsert();
+      await userRepo.create(user1);
+
+      const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
+      await albumRepo.create(album, [], []);
+
+      const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR };
+      await albumUserRepo.create(albumUser);
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({
+            albumId: albumUser.albumsId,
+            role: albumUser.role,
+            userId: albumUser.usersId,
+          }),
+          type: SyncEntityType.AlbumUserV1,
+        },
+      ]);
+    });
+
+    it('should detect and sync an updated shared user', async () => {
+      const { auth, testSync, getRepository, sut } = await setup();
+
+      const albumRepo = getRepository('album');
+      const albumUserRepo = getRepository('albumUser');
+      const userRepo = getRepository('user');
+
+      const user1 = mediumFactory.userInsert();
+      await userRepo.create(user1);
+
+      const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
+      await albumRepo.create(album, [], []);
+
+      const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR };
+      await albumUserRepo.create(albumUser);
+
+      const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
+      const acks = [initialSyncResponse[0].ack];
+      await sut.setAcks(auth, { acks });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
+
+      await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.VIEWER });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({
+            albumId: albumUser.albumsId,
+            role: AlbumUserRole.VIEWER,
+            userId: albumUser.usersId,
+          }),
+          type: SyncEntityType.AlbumUserV1,
+        },
+      ]);
+    });
+
+    it('should detect and sync a deleted shared user', async () => {
+      const { auth, testSync, getRepository, sut } = await setup();
+
+      const albumRepo = getRepository('album');
+      const albumUserRepo = getRepository('albumUser');
+      const userRepo = getRepository('user');
+
+      const user1 = mediumFactory.userInsert();
+      await userRepo.create(user1);
+
+      const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
+      await albumRepo.create(album, [], []);
+
+      const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR };
+      await albumUserRepo.create(albumUser);
+
+      const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
+      const acks = [initialSyncResponse[0].ack];
+      await sut.setAcks(auth, { acks });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
+
+      await albumUserRepo.delete({ albumsId: album.id, usersId: user1.id });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({
+            albumId: albumUser.albumsId,
+            userId: albumUser.usersId,
+          }),
+          type: SyncEntityType.AlbumUserDeleteV1,
+        },
+      ]);
+    });
+  });
+
+  describe('shared user', () => {
+    it('should detect and sync a new shared user', async () => {
+      const { auth, testSync, getRepository } = await setup();
+
+      const albumRepo = getRepository('album');
+      const albumUserRepo = getRepository('albumUser');
+      const userRepo = getRepository('user');
+
+      const user1 = mediumFactory.userInsert();
+      await userRepo.create(user1);
+
+      const album = mediumFactory.albumInsert({ ownerId: user1.id });
+      await albumRepo.create(album, [], []);
+
+      const albumUser = { albumsId: album.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR };
+      await albumUserRepo.create(albumUser);
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({
+            albumId: albumUser.albumsId,
+            role: albumUser.role,
+            userId: albumUser.usersId,
+          }),
+          type: SyncEntityType.AlbumUserV1,
+        },
+      ]);
+    });
+
+    it('should detect and sync an updated shared user', async () => {
+      const { auth, testSync, getRepository, sut } = await setup();
+
+      const albumRepo = getRepository('album');
+      const albumUserRepo = getRepository('albumUser');
+      const userRepo = getRepository('user');
+
+      const owner = mediumFactory.userInsert();
+      const user = mediumFactory.userInsert();
+      await Promise.all([userRepo.create(owner), userRepo.create(user)]);
+
+      const album = mediumFactory.albumInsert({ ownerId: owner.id });
+      await albumRepo.create(
+        album,
+        [],
+        [
+          { userId: auth.user.id, role: AlbumUserRole.EDITOR },
+          { userId: user.id, role: AlbumUserRole.EDITOR },
+        ],
+      );
+
+      const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
+      expect(initialSyncResponse).toHaveLength(2);
+      const acks = [initialSyncResponse[1].ack];
+      await sut.setAcks(auth, { acks });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
+
+      await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.VIEWER });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({
+            albumId: album.id,
+            role: AlbumUserRole.VIEWER,
+            userId: user.id,
+          }),
+          type: SyncEntityType.AlbumUserV1,
+        },
+      ]);
+    });
+
+    it('should detect and sync a deleted shared user', async () => {
+      const { auth, testSync, getRepository, sut } = await setup();
+
+      const albumRepo = getRepository('album');
+      const albumUserRepo = getRepository('albumUser');
+      const userRepo = getRepository('user');
+
+      const owner = mediumFactory.userInsert();
+      const user = mediumFactory.userInsert();
+      await Promise.all([userRepo.create(owner), userRepo.create(user)]);
+
+      const album = mediumFactory.albumInsert({ ownerId: owner.id });
+      await albumRepo.create(
+        album,
+        [],
+        [
+          { userId: auth.user.id, role: AlbumUserRole.EDITOR },
+          { userId: user.id, role: AlbumUserRole.EDITOR },
+        ],
+      );
+
+      const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]);
+      expect(initialSyncResponse).toHaveLength(2);
+      const acks = [initialSyncResponse[1].ack];
+      await sut.setAcks(auth, { acks });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
+
+      await albumUserRepo.delete({ albumsId: album.id, usersId: user.id });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({
+            albumId: album.id,
+            userId: user.id,
+          }),
+          type: SyncEntityType.AlbumUserDeleteV1,
+        },
+      ]);
+    });
+  });
+});
diff --git a/server/test/medium/specs/sync/sync-album.spec.ts b/server/test/medium/specs/sync/sync-album.spec.ts
new file mode 100644
index 0000000000..7ee7bf624f
--- /dev/null
+++ b/server/test/medium/specs/sync/sync-album.spec.ts
@@ -0,0 +1,220 @@
+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 } 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(SyncRequestType.AlbumsV1, () => {
+  it('should sync an album with the correct properties', async () => {
+    const { auth, getRepository, testSync } = await setup();
+    const albumRepo = getRepository('album');
+    const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
+    await albumRepo.create(album, [], []);
+    await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
+      {
+        ack: expect.any(String),
+        data: expect.objectContaining({
+          id: album.id,
+          name: album.albumName,
+          ownerId: album.ownerId,
+        }),
+        type: SyncEntityType.AlbumV1,
+      },
+    ]);
+  });
+
+  it('should detect and sync a new album', async () => {
+    const { auth, getRepository, testSync } = await setup();
+    const albumRepo = getRepository('album');
+    const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
+    await albumRepo.create(album, [], []);
+    await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
+      {
+        ack: expect.any(String),
+        data: expect.objectContaining({
+          id: album.id,
+        }),
+        type: SyncEntityType.AlbumV1,
+      },
+    ]);
+  });
+
+  it('should detect and sync an album delete', async () => {
+    const { auth, getRepository, testSync } = await setup();
+    const albumRepo = getRepository('album');
+    const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
+    await albumRepo.create(album, [], []);
+    await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
+      {
+        ack: expect.any(String),
+        data: expect.objectContaining({
+          id: album.id,
+        }),
+        type: SyncEntityType.AlbumV1,
+      },
+    ]);
+
+    await albumRepo.delete(album.id);
+    await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
+      {
+        ack: expect.any(String),
+        data: {
+          albumId: album.id,
+        },
+        type: SyncEntityType.AlbumDeleteV1,
+      },
+    ]);
+  });
+
+  describe('shared albums', () => {
+    it('should detect and sync an album create', async () => {
+      const { auth, getRepository, testSync } = await setup();
+      const albumRepo = getRepository('album');
+      const userRepo = getRepository('user');
+
+      const user2 = mediumFactory.userInsert();
+      await userRepo.create(user2);
+
+      const album = mediumFactory.albumInsert({ ownerId: user2.id });
+      await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
+
+      await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({ id: album.id }),
+          type: SyncEntityType.AlbumV1,
+        },
+      ]);
+    });
+
+    it('should detect and sync an album share (share before sync)', async () => {
+      const { auth, getRepository, testSync } = await setup();
+      const albumRepo = getRepository('album');
+      const albumUserRepo = getRepository('albumUser');
+      const userRepo = getRepository('user');
+
+      const user2 = mediumFactory.userInsert();
+      await userRepo.create(user2);
+
+      const album = mediumFactory.albumInsert({ ownerId: user2.id });
+      await albumRepo.create(album, [], []);
+      await albumUserRepo.create({ usersId: auth.user.id, albumsId: album.id, role: AlbumUserRole.EDITOR });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({ id: album.id }),
+          type: SyncEntityType.AlbumV1,
+        },
+      ]);
+    });
+
+    it('should detect and sync an album share (share after sync)', async () => {
+      const { auth, getRepository, sut, testSync } = await setup();
+      const albumRepo = getRepository('album');
+      const albumUserRepo = getRepository('albumUser');
+      const userRepo = getRepository('user');
+
+      const user2 = mediumFactory.userInsert();
+      await userRepo.create(user2);
+
+      const userAlbum = mediumFactory.albumInsert({ ownerId: auth.user.id });
+      const user2Album = mediumFactory.albumInsert({ ownerId: user2.id });
+      await Promise.all([albumRepo.create(user2Album, [], []), albumRepo.create(userAlbum, [], [])]);
+
+      const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
+
+      expect(initialSyncResponse).toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({ id: userAlbum.id }),
+          type: SyncEntityType.AlbumV1,
+        },
+      ]);
+
+      const acks = [initialSyncResponse[0].ack];
+      await sut.setAcks(auth, { acks });
+
+      await albumUserRepo.create({ usersId: auth.user.id, albumsId: user2Album.id, role: AlbumUserRole.EDITOR });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: expect.objectContaining({ id: user2Album.id }),
+          type: SyncEntityType.AlbumV1,
+        },
+      ]);
+    });
+
+    it('should detect and sync an album delete`', async () => {
+      const { auth, getRepository, testSync, sut } = await setup();
+      const albumRepo = getRepository('album');
+      const userRepo = getRepository('user');
+
+      const user2 = mediumFactory.userInsert();
+      await userRepo.create(user2);
+
+      const album = mediumFactory.albumInsert({ ownerId: user2.id });
+      await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
+
+      const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
+      const acks = [initialSyncResponse[0].ack];
+      await sut.setAcks(auth, { acks });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
+
+      await albumRepo.delete(album.id);
+
+      await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: { albumId: album.id },
+          type: SyncEntityType.AlbumDeleteV1,
+        },
+      ]);
+    });
+
+    it('should detect and sync an album unshare as an album delete', async () => {
+      const { auth, getRepository, testSync, sut } = await setup();
+      const albumRepo = getRepository('album');
+      const albumUserRepo = getRepository('albumUser');
+      const userRepo = getRepository('user');
+
+      const user2 = mediumFactory.userInsert();
+      await userRepo.create(user2);
+
+      const album = mediumFactory.albumInsert({ ownerId: user2.id });
+      await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
+
+      const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
+      const acks = [initialSyncResponse[0].ack];
+      await sut.setAcks(auth, { acks });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
+
+      await albumUserRepo.delete({ albumsId: album.id, usersId: auth.user.id });
+
+      await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
+        {
+          ack: expect.any(String),
+          data: { albumId: album.id },
+          type: SyncEntityType.AlbumDeleteV1,
+        },
+      ]);
+    });
+  });
+});
diff --git a/server/test/medium/specs/sync/sync-asset-exif.spec.ts b/server/test/medium/specs/sync/sync-asset-exif.spec.ts
new file mode 100644
index 0000000000..9a3bcb4314
--- /dev/null
+++ b/server/test/medium/specs/sync/sync-asset-exif.spec.ts
@@ -0,0 +1,100 @@
+import { Kysely } from 'kysely';
+import { DB } from 'src/db';
+import { SyncEntityType, SyncRequestType } from 'src/enum';
+import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
+import { factory } from 'test/small.factory';
+import { getKyselyDB } 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.AssetExifsV1, () => {
+  it('should detect and sync the first asset exif', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
+    await assetRepo.create(asset);
+    await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
+
+    const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
+
+    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.AssetExifV1,
+        },
+      ]),
+    );
+
+    const acks = [initialSyncResponse[0].ack];
+    await sut.setAcks(auth, { acks });
+
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should only sync asset exif for own user', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const partnerRepo = getRepository('partner');
+    await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({ ownerId: user2.id });
+    await assetRepo.create(asset);
+    await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
+
+    const sessionRepo = getRepository('session');
+    const session = mediumFactory.sessionInsert({ userId: user2.id });
+    await sessionRepo.create(session);
+
+    const auth2 = factory.auth({ session, user: user2 });
+    await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
+    await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0);
+  });
+});
diff --git a/server/test/medium/specs/sync/sync-asset.spec.ts b/server/test/medium/specs/sync/sync-asset.spec.ts
new file mode 100644
index 0000000000..3cf6d7d30d
--- /dev/null
+++ b/server/test/medium/specs/sync/sync-asset.spec.ts
@@ -0,0 +1,130 @@
+import { Kysely } from 'kysely';
+import { DB } from 'src/db';
+import { SyncEntityType, SyncRequestType } from 'src/enum';
+import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
+import { factory } from 'test/small.factory';
+import { getKyselyDB } 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(SyncEntityType.AssetV1, () => {
+  it('should detect and sync the first asset', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
+    const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
+    const date = new Date().toISOString();
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({
+      ownerId: auth.user.id,
+      checksum: Buffer.from(checksum, 'base64'),
+      thumbhash: Buffer.from(thumbhash, 'base64'),
+      fileCreatedAt: date,
+      fileModifiedAt: date,
+      localDateTime: date,
+      deletedAt: null,
+    });
+    await assetRepo.create(asset);
+
+    const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
+
+    expect(initialSyncResponse).toHaveLength(1);
+    expect(initialSyncResponse).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            id: asset.id,
+            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,
+          },
+          type: 'AssetV1',
+        },
+      ]),
+    );
+
+    const acks = [initialSyncResponse[0].ack];
+    await sut.setAcks(auth, { acks });
+
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should detect and sync a deleted asset', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
+    await assetRepo.create(asset);
+    await assetRepo.remove(asset);
+
+    const response = await testSync(auth, [SyncRequestType.AssetsV1]);
+
+    expect(response).toHaveLength(1);
+    expect(response).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            assetId: asset.id,
+          },
+          type: 'AssetDeleteV1',
+        },
+      ]),
+    );
+
+    const acks = response.map(({ ack }) => ack);
+    await sut.setAcks(auth, { acks });
+
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should not sync an asset or asset delete for an unrelated user', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const sessionRepo = getRepository('session');
+    const session = mediumFactory.sessionInsert({ userId: user2.id });
+    await sessionRepo.create(session);
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({ ownerId: user2.id });
+    await assetRepo.create(asset);
+
+    const auth2 = factory.auth({ session, user: user2 });
+
+    expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
+    expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
+
+    await assetRepo.remove(asset);
+    expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
+    expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
+  });
+});
diff --git a/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts b/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts
new file mode 100644
index 0000000000..8d9e6d6ac5
--- /dev/null
+++ b/server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts
@@ -0,0 +1,129 @@
+import { Kysely } from 'kysely';
+import { DB } from 'src/db';
+import { SyncEntityType, SyncRequestType } from 'src/enum';
+import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
+import { factory } from 'test/small.factory';
+import { getKyselyDB } 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.PartnerAssetExifsV1, () => {
+  it('should detect and sync the first partner asset exif', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const partnerRepo = getRepository('partner');
+    await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({ ownerId: user2.id });
+    await assetRepo.create(asset);
+    await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
+
+    const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
+
+    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.PartnerAssetExifV1,
+        },
+      ]),
+    );
+
+    const acks = [initialSyncResponse[0].ack];
+    await sut.setAcks(auth, { acks });
+
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should not sync partner asset exif for own user', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const partnerRepo = getRepository('partner');
+    await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+
+    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 expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
+    await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
+  });
+
+  it('should not sync partner 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 Promise.all([userRepo.create(user2), userRepo.create(user3)]);
+
+    const partnerRepo = getRepository('partner');
+    await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({ ownerId: user3.id });
+    await assetRepo.create(asset);
+    await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' });
+
+    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);
+  });
+});
diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts
new file mode 100644
index 0000000000..70e31eca4c
--- /dev/null
+++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts
@@ -0,0 +1,208 @@
+import { Kysely } from 'kysely';
+import { DB } from 'src/db';
+import { SyncEntityType, SyncRequestType } from 'src/enum';
+import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
+import { factory } from 'test/small.factory';
+import { getKyselyDB } 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.PartnerAssetsV1, () => {
+  it('should detect and sync the first partner asset', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
+    const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
+    const date = new Date().toISOString();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({
+      ownerId: user2.id,
+      checksum: Buffer.from(checksum, 'base64'),
+      thumbhash: Buffer.from(thumbhash, 'base64'),
+      fileCreatedAt: date,
+      fileModifiedAt: date,
+      localDateTime: date,
+      deletedAt: null,
+    });
+    await assetRepo.create(asset);
+
+    const partnerRepo = getRepository('partner');
+    await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+
+    const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
+
+    expect(initialSyncResponse).toHaveLength(1);
+    expect(initialSyncResponse).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            id: asset.id,
+            ownerId: asset.ownerId,
+            thumbhash,
+            checksum,
+            deletedAt: null,
+            fileCreatedAt: date,
+            fileModifiedAt: date,
+            isFavorite: false,
+            localDateTime: date,
+            type: asset.type,
+            visibility: asset.visibility,
+          },
+          type: SyncEntityType.PartnerAssetV1,
+        },
+      ]),
+    );
+
+    const acks = [initialSyncResponse[0].ack];
+    await sut.setAcks(auth, { acks });
+
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should detect and sync a deleted partner asset', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+    const asset = mediumFactory.assetInsert({ ownerId: user2.id });
+
+    const assetRepo = getRepository('asset');
+    await assetRepo.create(asset);
+
+    const partnerRepo = getRepository('partner');
+    await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+    await assetRepo.remove(asset);
+
+    const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
+
+    expect(response).toHaveLength(1);
+    expect(response).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            assetId: asset.id,
+          },
+          type: SyncEntityType.PartnerAssetDeleteV1,
+        },
+      ]),
+    );
+
+    const acks = response.map(({ ack }) => ack);
+    await sut.setAcks(auth, { acks });
+
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should not sync a deleted partner asset due to a user delete', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const partnerRepo = getRepository('partner');
+    await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+
+    const assetRepo = getRepository('asset');
+    await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id }));
+
+    await userRepo.delete({ id: user2.id }, true);
+
+    const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
+    expect(response).toHaveLength(0);
+  });
+
+  it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const assetRepo = getRepository('asset');
+    await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id }));
+
+    const partnerRepo = getRepository('partner');
+    const partner = { sharedById: user2.id, sharedWithId: auth.user.id };
+    await partnerRepo.create(partner);
+
+    await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1);
+
+    await partnerRepo.remove(partner);
+
+    await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
+  });
+
+  it('should not sync an asset or asset delete for own user', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({ ownerId: auth.user.id });
+    await assetRepo.create(asset);
+
+    const partnerRepo = getRepository('partner');
+    await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+
+    await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
+    await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
+
+    await assetRepo.remove(asset);
+
+    await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
+    await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
+  });
+
+  it('should not sync an asset or asset delete for unrelated user', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const sessionRepo = getRepository('session');
+    const session = mediumFactory.sessionInsert({ userId: user2.id });
+    await sessionRepo.create(session);
+
+    const auth2 = factory.auth({ session, user: user2 });
+
+    const assetRepo = getRepository('asset');
+    const asset = mediumFactory.assetInsert({ ownerId: user2.id });
+    await assetRepo.create(asset);
+
+    await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
+    await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
+
+    await assetRepo.remove(asset);
+
+    await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
+    await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
+  });
+});
diff --git a/server/test/medium/specs/sync/sync-partner.spec.ts b/server/test/medium/specs/sync/sync-partner.spec.ts
new file mode 100644
index 0000000000..f262eec853
--- /dev/null
+++ b/server/test/medium/specs/sync/sync-partner.spec.ts
@@ -0,0 +1,221 @@
+import { Kysely } from 'kysely';
+import { DB } from 'src/db';
+import { SyncEntityType, SyncRequestType } from 'src/enum';
+import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
+import { getKyselyDB } 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(SyncEntityType.PartnerV1, () => {
+  it('should detect and sync the first partner', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const user1 = auth.user;
+    const userRepo = getRepository('user');
+    const partnerRepo = getRepository('partner');
+
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
+
+    const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
+
+    expect(initialSyncResponse).toHaveLength(1);
+    expect(initialSyncResponse).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            inTimeline: partner.inTimeline,
+            sharedById: partner.sharedById,
+            sharedWithId: partner.sharedWithId,
+          },
+          type: 'PartnerV1',
+        },
+      ]),
+    );
+
+    const acks = [initialSyncResponse[0].ack];
+    await sut.setAcks(auth, { acks });
+
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should detect and sync a deleted partner', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user1 = auth.user;
+    const user2 = mediumFactory.userInsert();
+    await userRepo.create(user2);
+
+    const partnerRepo = getRepository('partner');
+    const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
+    await partnerRepo.remove(partner);
+
+    const response = await testSync(auth, [SyncRequestType.PartnersV1]);
+
+    expect(response).toHaveLength(1);
+    expect(response).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            sharedById: partner.sharedById,
+            sharedWithId: partner.sharedWithId,
+          },
+          type: 'PartnerDeleteV1',
+        },
+      ]),
+    );
+
+    const acks = response.map(({ ack }) => ack);
+    await sut.setAcks(auth, { acks });
+
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should detect and sync a partner share both to and from another user', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user1 = auth.user;
+    const user2 = await userRepo.create(mediumFactory.userInsert());
+
+    const partnerRepo = getRepository('partner');
+    const partner1 = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
+    const partner2 = await partnerRepo.create({ sharedById: user1.id, sharedWithId: user2.id });
+
+    const response = await testSync(auth, [SyncRequestType.PartnersV1]);
+
+    expect(response).toHaveLength(2);
+    expect(response).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            inTimeline: partner1.inTimeline,
+            sharedById: partner1.sharedById,
+            sharedWithId: partner1.sharedWithId,
+          },
+          type: 'PartnerV1',
+        },
+        {
+          ack: expect.any(String),
+          data: {
+            inTimeline: partner2.inTimeline,
+            sharedById: partner2.sharedById,
+            sharedWithId: partner2.sharedWithId,
+          },
+          type: 'PartnerV1',
+        },
+      ]),
+    );
+
+    await sut.setAcks(auth, { acks: [response[1].ack] });
+
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should sync a partner and then an update to that same partner', async () => {
+    const { auth, sut, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user1 = auth.user;
+    const user2 = await userRepo.create(mediumFactory.userInsert());
+
+    const partnerRepo = getRepository('partner');
+    const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id });
+
+    const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
+
+    expect(initialSyncResponse).toHaveLength(1);
+    expect(initialSyncResponse).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            inTimeline: partner.inTimeline,
+            sharedById: partner.sharedById,
+            sharedWithId: partner.sharedWithId,
+          },
+          type: 'PartnerV1',
+        },
+      ]),
+    );
+
+    const acks = [initialSyncResponse[0].ack];
+    await sut.setAcks(auth, { acks });
+
+    const updated = await partnerRepo.update(
+      { sharedById: partner.sharedById, sharedWithId: partner.sharedWithId },
+      { inTimeline: true },
+    );
+
+    const updatedSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]);
+
+    expect(updatedSyncResponse).toHaveLength(1);
+    expect(updatedSyncResponse).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            inTimeline: updated.inTimeline,
+            sharedById: updated.sharedById,
+            sharedWithId: updated.sharedWithId,
+          },
+          type: 'PartnerV1',
+        },
+      ]),
+    );
+  });
+
+  it('should not sync a partner or partner delete for an unrelated user', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = await userRepo.create(mediumFactory.userInsert());
+    const user3 = await userRepo.create(mediumFactory.userInsert());
+
+    const partnerRepo = getRepository('partner');
+    const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user3.id });
+
+    expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
+
+    await partnerRepo.remove(partner);
+
+    expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
+  });
+
+  it('should not sync a partner delete after a user is deleted', async () => {
+    const { auth, getRepository, testSync } = await setup();
+
+    const userRepo = getRepository('user');
+    const user2 = await userRepo.create(mediumFactory.userInsert());
+
+    const partnerRepo = getRepository('partner');
+    await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+    await userRepo.delete({ id: user2.id }, true);
+
+    expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
+  });
+});
diff --git a/server/test/medium/specs/sync/sync-types.spec.ts b/server/test/medium/specs/sync/sync-types.spec.ts
new file mode 100644
index 0000000000..1af5a68fd6
--- /dev/null
+++ b/server/test/medium/specs/sync/sync-types.spec.ts
@@ -0,0 +1,12 @@
+import { SyncRequestType } from 'src/enum';
+import { SYNC_TYPES_ORDER } from 'src/services/sync.service';
+
+describe('types', () => {
+  it('should have all the types in the ordering variable', () => {
+    for (const key in SyncRequestType) {
+      expect(SYNC_TYPES_ORDER).includes(key);
+    }
+
+    expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length);
+  });
+});
diff --git a/server/test/medium/specs/sync/sync-user.spec.ts b/server/test/medium/specs/sync/sync-user.spec.ts
new file mode 100644
index 0000000000..2cea38267c
--- /dev/null
+++ b/server/test/medium/specs/sync/sync-user.spec.ts
@@ -0,0 +1,179 @@
+import { Kysely } from 'kysely';
+import { DB } from 'src/db';
+import { SyncEntityType, SyncRequestType } from 'src/enum';
+import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory';
+import { getKyselyDB } 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(SyncEntityType.UserV1, () => {
+  it('should detect and sync the first user', async () => {
+    const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
+
+    const userRepo = getRepository('user');
+    const user = await userRepo.get(auth.user.id, { withDeleted: false });
+    if (!user) {
+      expect.fail('First user should exist');
+    }
+
+    const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
+    expect(initialSyncResponse).toHaveLength(1);
+    expect(initialSyncResponse).toEqual([
+      {
+        ack: expect.any(String),
+        data: {
+          deletedAt: user.deletedAt,
+          email: user.email,
+          id: user.id,
+          name: user.name,
+        },
+        type: 'UserV1',
+      },
+    ]);
+
+    const acks = [initialSyncResponse[0].ack];
+    await sut.setAcks(auth, { acks });
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should detect and sync a soft deleted user', async () => {
+    const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
+
+    const deletedAt = new Date().toISOString();
+    const deletedUser = mediumFactory.userInsert({ deletedAt });
+    const deleted = await getRepository('user').create(deletedUser);
+
+    const response = await testSync(auth, [SyncRequestType.UsersV1]);
+
+    expect(response).toHaveLength(2);
+    expect(response).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            deletedAt: null,
+            email: auth.user.email,
+            id: auth.user.id,
+            name: auth.user.name,
+          },
+          type: 'UserV1',
+        },
+        {
+          ack: expect.any(String),
+          data: {
+            deletedAt,
+            email: deleted.email,
+            id: deleted.id,
+            name: deleted.name,
+          },
+          type: 'UserV1',
+        },
+      ]),
+    );
+
+    const acks = [response[1].ack];
+    await sut.setAcks(auth, { acks });
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should detect and sync a deleted user', async () => {
+    const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
+
+    const userRepo = getRepository('user');
+    const user = mediumFactory.userInsert();
+    await userRepo.create(user);
+    await userRepo.delete({ id: user.id }, true);
+
+    const response = await testSync(auth, [SyncRequestType.UsersV1]);
+
+    expect(response).toHaveLength(2);
+    expect(response).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            userId: user.id,
+          },
+          type: 'UserDeleteV1',
+        },
+        {
+          ack: expect.any(String),
+          data: {
+            deletedAt: null,
+            email: auth.user.email,
+            id: auth.user.id,
+            name: auth.user.name,
+          },
+          type: 'UserV1',
+        },
+      ]),
+    );
+
+    const acks = response.map(({ ack }) => ack);
+    await sut.setAcks(auth, { acks });
+    const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
+
+    expect(ackSyncResponse).toHaveLength(0);
+  });
+
+  it('should sync a user and then an update to that same user', async () => {
+    const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB());
+
+    const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
+
+    expect(initialSyncResponse).toHaveLength(1);
+    expect(initialSyncResponse).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            deletedAt: null,
+            email: auth.user.email,
+            id: auth.user.id,
+            name: auth.user.name,
+          },
+          type: 'UserV1',
+        },
+      ]),
+    );
+
+    const acks = [initialSyncResponse[0].ack];
+    await sut.setAcks(auth, { acks });
+
+    const userRepo = getRepository('user');
+    const updated = await userRepo.update(auth.user.id, { name: 'new name' });
+    const updatedSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]);
+
+    expect(updatedSyncResponse).toHaveLength(1);
+    expect(updatedSyncResponse).toEqual(
+      expect.arrayContaining([
+        {
+          ack: expect.any(String),
+          data: {
+            deletedAt: null,
+            email: auth.user.email,
+            id: auth.user.id,
+            name: updated.name,
+          },
+          type: 'UserV1',
+        },
+      ]),
+    );
+  });
+});